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