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