001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.data.validation.tests;
003
004import static org.openstreetmap.josm.tools.I18n.tr;
005
006import java.util.ArrayList;
007import java.util.Arrays;
008import java.util.HashMap;
009import java.util.HashSet;
010import java.util.List;
011import java.util.Locale;
012import java.util.Map;
013import java.util.Set;
014
015import org.openstreetmap.josm.command.ChangePropertyCommand;
016import org.openstreetmap.josm.data.osm.Node;
017import org.openstreetmap.josm.data.osm.OsmPrimitive;
018import org.openstreetmap.josm.data.osm.OsmUtils;
019import org.openstreetmap.josm.data.osm.Way;
020import org.openstreetmap.josm.data.validation.Severity;
021import org.openstreetmap.josm.data.validation.Test;
022import org.openstreetmap.josm.data.validation.TestError;
023import org.openstreetmap.josm.tools.Utils;
024
025/**
026 * Test that performs semantic checks on highways.
027 * @since 5902
028 */
029public class Highways extends Test {
030
031    protected static final int WRONG_ROUNDABOUT_HIGHWAY = 2701;
032    protected static final int MISSING_PEDESTRIAN_CROSSING = 2702;
033    protected static final int SOURCE_MAXSPEED_UNKNOWN_COUNTRY_CODE = 2703;
034    protected static final int SOURCE_MAXSPEED_UNKNOWN_CONTEXT = 2704;
035    protected static final int SOURCE_MAXSPEED_CONTEXT_MISMATCH_VS_MAXSPEED = 2705;
036    protected static final int SOURCE_MAXSPEED_CONTEXT_MISMATCH_VS_HIGHWAY = 2706;
037    protected static final int SOURCE_WRONG_LINK = 2707;
038
039    protected static final String SOURCE_MAXSPEED = "source:maxspeed";
040
041    /**
042     * Classified highways in order of importance
043     */
044    // CHECKSTYLE.OFF: SingleSpaceSeparator
045    private static final List<String> CLASSIFIED_HIGHWAYS = Arrays.asList(
046            "motorway",  "motorway_link",
047            "trunk",     "trunk_link",
048            "primary",   "primary_link",
049            "secondary", "secondary_link",
050            "tertiary",  "tertiary_link",
051            "unclassified",
052            "residential",
053            "living_street");
054    // CHECKSTYLE.ON: SingleSpaceSeparator
055
056    private static final Set<String> KNOWN_SOURCE_MAXSPEED_CONTEXTS = new HashSet<>(Arrays.asList(
057            "urban", "rural", "zone", "zone30", "zone:30", "nsl_single", "nsl_dual", "motorway", "trunk", "living_street", "bicycle_road"));
058
059    private static final Set<String> ISO_COUNTRIES = new HashSet<>(Arrays.asList(Locale.getISOCountries()));
060
061    private boolean leftByPedestrians;
062    private boolean leftByCyclists;
063    private boolean leftByCars;
064    private int pedestrianWays;
065    private int cyclistWays;
066    private int carsWays;
067
068    /**
069     * Constructs a new {@code Highways} test.
070     */
071    public Highways() {
072        super(tr("Highways"), tr("Performs semantic checks on highways."));
073    }
074
075    @Override
076    public void visit(Node n) {
077        if (n.isUsable()) {
078            if (!n.hasTag("crossing", "no")
079             && !(n.hasKey("crossing") && (n.hasTag("highway", "crossing") || n.hasTag("highway", "traffic_signals")))
080             && n.isReferredByWays(2)) {
081                testMissingPedestrianCrossing(n);
082            }
083            if (n.hasKey(SOURCE_MAXSPEED)) {
084                // Check maxspeed but not context against highway for nodes
085                // as maxspeed is not set on highways here but on signs, speed cameras, etc.
086                testSourceMaxspeed(n, false);
087            }
088        }
089    }
090
091    @Override
092    public void visit(Way w) {
093        if (w.isUsable()) {
094            if (w.isClosed() && w.hasKey("highway") && CLASSIFIED_HIGHWAYS.contains(w.get("highway"))
095                    && w.hasKey("junction") && "roundabout".equals(w.get("junction"))) {
096                // TODO: find out how to handle splitted roundabouts (see #12841)
097                testWrongRoundabout(w);
098            }
099            if (w.hasKey(SOURCE_MAXSPEED)) {
100                // Check maxspeed, including context against highway
101                testSourceMaxspeed(w, true);
102            }
103            testHighwayLink(w);
104        }
105    }
106
107    private void testWrongRoundabout(Way w) {
108        Map<String, List<Way>> map = new HashMap<>();
109        // Count all highways (per type) connected to this roundabout, except links
110        // As roundabouts are closed ways, take care of not processing the first/last node twice
111        for (Node n : new HashSet<>(w.getNodes())) {
112            for (Way h : Utils.filteredCollection(n.getReferrers(), Way.class)) {
113                String value = h.get("highway");
114                if (h != w && value != null && !value.endsWith("_link")) {
115                    List<Way> list = map.get(value);
116                    if (list == null) {
117                        list = new ArrayList<>();
118                        map.put(value, list);
119                    }
120                    list.add(h);
121                }
122            }
123        }
124        // The roundabout should carry the highway tag of its two biggest highways
125        for (String s : CLASSIFIED_HIGHWAYS) {
126            List<Way> list = map.get(s);
127            if (list != null && list.size() >= 2) {
128                // Except when a single road is connected, but with two oneway segments
129                Boolean oneway1 = OsmUtils.getOsmBoolean(list.get(0).get("oneway"));
130                Boolean oneway2 = OsmUtils.getOsmBoolean(list.get(1).get("oneway"));
131                if (list.size() > 2 || oneway1 == null || oneway2 == null || !oneway1 || !oneway2) {
132                    // Error when the highway tags do not match
133                    if (!w.get("highway").equals(s)) {
134                        errors.add(TestError.builder(this, Severity.WARNING, WRONG_ROUNDABOUT_HIGHWAY)
135                                .message(tr("Incorrect roundabout (highway: {0} instead of {1})", w.get("highway"), s))
136                                .primitives(w)
137                                .fix(() -> new ChangePropertyCommand(w, "highway", s))
138                                .build());
139                    }
140                    break;
141                }
142            }
143        }
144    }
145
146    public static boolean isHighwayLinkOkay(final Way way) {
147        final String highway = way.get("highway");
148        if (highway == null || !highway.endsWith("_link")
149                || !IN_DOWNLOADED_AREA.test(way.getNode(0)) || !IN_DOWNLOADED_AREA.test(way.getNode(way.getNodesCount()-1))) {
150            return true;
151        }
152
153        final Set<OsmPrimitive> referrers = new HashSet<>();
154
155        if (way.isClosed()) {
156            // for closed way we need to check all adjacent ways
157            for (Node n: way.getNodes()) {
158                referrers.addAll(n.getReferrers());
159            }
160        } else {
161            referrers.addAll(way.firstNode().getReferrers());
162            referrers.addAll(way.lastNode().getReferrers());
163        }
164
165        return Utils.filteredCollection(referrers, Way.class).stream().anyMatch(
166                otherWay -> !way.equals(otherWay) && otherWay.hasTag("highway", highway, highway.replaceAll("_link$", "")));
167    }
168
169    private void testHighwayLink(final Way way) {
170        if (!isHighwayLinkOkay(way)) {
171            errors.add(TestError.builder(this, Severity.WARNING, SOURCE_WRONG_LINK)
172                    .message(tr("Highway link is not linked to adequate highway/link"))
173                    .primitives(way)
174                    .build());
175        }
176    }
177
178    private void testMissingPedestrianCrossing(Node n) {
179        leftByPedestrians = false;
180        leftByCyclists = false;
181        leftByCars = false;
182        pedestrianWays = 0;
183        cyclistWays = 0;
184        carsWays = 0;
185
186        for (Way w : OsmPrimitive.getFilteredList(n.getReferrers(), Way.class)) {
187            String highway = w.get("highway");
188            if (highway != null) {
189                if ("footway".equals(highway) || "path".equals(highway)) {
190                    handlePedestrianWay(n, w);
191                    if (w.hasTag("bicycle", "yes", "designated")) {
192                        handleCyclistWay(n, w);
193                    }
194                } else if ("cycleway".equals(highway)) {
195                    handleCyclistWay(n, w);
196                    if (w.hasTag("foot", "yes", "designated")) {
197                        handlePedestrianWay(n, w);
198                    }
199                } else if (CLASSIFIED_HIGHWAYS.contains(highway)) {
200                    // Only look at classified highways for now:
201                    // - service highways support is TBD (see #9141 comments)
202                    // - roads should be determined first. Another warning is raised anyway
203                    handleCarWay(n, w);
204                }
205                if ((leftByPedestrians || leftByCyclists) && leftByCars) {
206                    errors.add(TestError.builder(this, Severity.OTHER, MISSING_PEDESTRIAN_CROSSING)
207                            .message(tr("Missing pedestrian crossing information"))
208                            .primitives(n)
209                            .build());
210                    return;
211                }
212            }
213        }
214    }
215
216    private void handleCarWay(Node n, Way w) {
217        carsWays++;
218        if (!w.isFirstLastNode(n) || carsWays > 1) {
219            leftByCars = true;
220        }
221    }
222
223    private void handleCyclistWay(Node n, Way w) {
224        cyclistWays++;
225        if (!w.isFirstLastNode(n) || cyclistWays > 1) {
226            leftByCyclists = true;
227        }
228    }
229
230    private void handlePedestrianWay(Node n, Way w) {
231        pedestrianWays++;
232        if (!w.isFirstLastNode(n) || pedestrianWays > 1) {
233            leftByPedestrians = true;
234        }
235    }
236
237    private void testSourceMaxspeed(OsmPrimitive p, boolean testContextHighway) {
238        String value = p.get(SOURCE_MAXSPEED);
239        if (value.matches("[A-Z]{2}:.+")) {
240            int index = value.indexOf(':');
241            // Check country
242            String country = value.substring(0, index);
243            if (!ISO_COUNTRIES.contains(country)) {
244                final TestError.Builder error = TestError.builder(this, Severity.WARNING, SOURCE_MAXSPEED_UNKNOWN_COUNTRY_CODE)
245                        .message(tr("Unknown country code: {0}", country))
246                        .primitives(p);
247                if ("UK".equals(country)) {
248                    errors.add(error.fix(() -> new ChangePropertyCommand(p, SOURCE_MAXSPEED, value.replace("UK:", "GB:"))).build());
249                } else {
250                    errors.add(error.build());
251                }
252            }
253            // Check context
254            String context = value.substring(index+1);
255            if (!KNOWN_SOURCE_MAXSPEED_CONTEXTS.contains(context)) {
256                errors.add(TestError.builder(this, Severity.WARNING, SOURCE_MAXSPEED_UNKNOWN_CONTEXT)
257                        .message(tr("Unknown source:maxspeed context: {0}", context))
258                        .primitives(p)
259                        .build());
260            }
261            // TODO: Check coherence of context against maxspeed
262            // TODO: Check coherence of context against highway
263        }
264    }
265}