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