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