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}