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            return true;
158        }
159
160        final Set<OsmPrimitive> referrers = new HashSet<>();
161
162        if (way.isClosed()) {
163            // for closed way we need to check all adjacent ways
164            for (Node n: way.getNodes()) {
165                referrers.addAll(n.getReferrers());
166            }
167        } else {
168            referrers.addAll(way.firstNode().getReferrers());
169            referrers.addAll(way.lastNode().getReferrers());
170        }
171
172        return Utils.exists(Utils.filteredCollection(referrers, Way.class), new Predicate<Way>() {
173            @Override
174            public boolean evaluate(final Way otherWay) {
175                return !way.equals(otherWay) && otherWay.hasTag("highway", highway, highway.replaceAll("_link$", ""));
176            }
177        });
178    }
179
180    private void testHighwayLink(final Way way) {
181        if (!isHighwayLinkOkay(way)) {
182            errors.add(new TestError(this, Severity.WARNING,
183                    tr("Highway link is not linked to adequate highway/link"), SOURCE_WRONG_LINK, way));
184        }
185    }
186
187    private void testMissingPedestrianCrossing(Node n) {
188        leftByPedestrians = false;
189        leftByCyclists = false;
190        leftByCars = false;
191        pedestrianWays = 0;
192        cyclistWays = 0;
193        carsWays = 0;
194
195        for (Way w : OsmPrimitive.getFilteredList(n.getReferrers(), Way.class)) {
196            String highway = w.get("highway");
197            if (highway != null) {
198                if ("footway".equals(highway) || "path".equals(highway)) {
199                    handlePedestrianWay(n, w);
200                    if (w.hasTag("bicycle", "yes", "designated")) {
201                        handleCyclistWay(n, w);
202                    }
203                } else if ("cycleway".equals(highway)) {
204                    handleCyclistWay(n, w);
205                    if (w.hasTag("foot", "yes", "designated")) {
206                        handlePedestrianWay(n, w);
207                    }
208                } else if (CLASSIFIED_HIGHWAYS.contains(highway)) {
209                    // Only look at classified highways for now:
210                    // - service highways support is TBD (see #9141 comments)
211                    // - roads should be determined first. Another warning is raised anyway
212                    handleCarWay(n, w);
213                }
214                if ((leftByPedestrians || leftByCyclists) && leftByCars) {
215                    errors.add(new TestError(this, Severity.OTHER, tr("Missing pedestrian crossing information"),
216                            MISSING_PEDESTRIAN_CROSSING, n));
217                    return;
218                }
219            }
220        }
221    }
222
223    private void handleCarWay(Node n, Way w) {
224        carsWays++;
225        if (!w.isFirstLastNode(n) || carsWays > 1) {
226            leftByCars = true;
227        }
228    }
229
230    private void handleCyclistWay(Node n, Way w) {
231        cyclistWays++;
232        if (!w.isFirstLastNode(n) || cyclistWays > 1) {
233            leftByCyclists = true;
234        }
235    }
236
237    private void handlePedestrianWay(Node n, Way w) {
238        pedestrianWays++;
239        if (!w.isFirstLastNode(n) || pedestrianWays > 1) {
240            leftByPedestrians = true;
241        }
242    }
243
244    private void testSourceMaxspeed(OsmPrimitive p, boolean testContextHighway) {
245        String value = p.get(SOURCE_MAXSPEED);
246        if (value.matches("[A-Z]{2}:.+")) {
247            int index = value.indexOf(':');
248            // Check country
249            String country = value.substring(0, index);
250            if (!ISO_COUNTRIES.contains(country)) {
251                if ("UK".equals(country)) {
252                    errors.add(new FixableTestError(this, Severity.WARNING,
253                            tr("Unknown country code: {0}", country), SOURCE_MAXSPEED_UNKNOWN_COUNTRY_CODE, p,
254                            new ChangePropertyCommand(p, SOURCE_MAXSPEED, value.replace("UK:", "GB:"))));
255                } else {
256                    errors.add(new TestError(this, Severity.WARNING,
257                            tr("Unknown country code: {0}", country), SOURCE_MAXSPEED_UNKNOWN_COUNTRY_CODE, p));
258                }
259            }
260            // Check context
261            String context = value.substring(index+1);
262            if (!KNOWN_SOURCE_MAXSPEED_CONTEXTS.contains(context)) {
263                errors.add(new TestError(this, Severity.WARNING,
264                        tr("Unknown source:maxspeed context: {0}", context), SOURCE_MAXSPEED_UNKNOWN_CONTEXT, p));
265            }
266            // TODO: Check coherence of context against maxspeed
267            // TODO: Check coherence of context against highway
268        }
269    }
270
271    @Override
272    public boolean isFixable(TestError testError) {
273        return testError instanceof WrongRoundaboutHighway;
274    }
275
276    @Override
277    public Command fixError(TestError testError) {
278        if (testError instanceof WrongRoundaboutHighway) {
279            // primitives list can be empty if all primitives have been purged
280            Iterator<? extends OsmPrimitive> it = testError.getPrimitives().iterator();
281            if (it.hasNext()) {
282                return new ChangePropertyCommand(it.next(),
283                        "highway", ((WrongRoundaboutHighway) testError).correctValue);
284            }
285        }
286        return null;
287    }
288}