networkforge 1.4.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,642 @@
1
+ """
2
+ Checks on custom data and on the finished network.
3
+
4
+ Before the build: resolve_custom_tags / check_custom_tags (feature tags
5
+ vs OSM rules and the chosen network type).
6
+
7
+ After the build, structural invariants the network must satisfy:
8
+
9
+ assert_all_edges_have_valid_nodes
10
+ assert_no_u_equals_v
11
+ assert_all_custom_edges_are_connected
12
+ assert_custom_lines_unbroken
13
+
14
+ Each raises NetworkIntegrityError with a message identifying the
15
+ offending rows/nodes, and returns None if everything is fine. They run
16
+ at the end of build_network and are also used directly by the tests
17
+ (see tests/live/test_structural_invariants.py). Connectivity uses a
18
+ small in-module union-find.
19
+
20
+ build_network doesn't run assert_all_custom_edges_are_connected: custom
21
+ lines that don't reach the network are usually the user's data, not a
22
+ broken build, so it only warns about them (disconnected_custom_edges).
23
+ The tests still assert it wherever the lines are meant to connect.
24
+ """
25
+
26
+ import collections
27
+ import difflib
28
+ import logging
29
+ import re
30
+
31
+ import geopandas as gpd
32
+ import numpy as np
33
+ import pandas as pd
34
+
35
+ from .errors import InvalidTagsError, NetworkIntegrityError
36
+ from .modes import (
37
+ OTHER_VEHICLE_KEYS,
38
+ allows_mode,
39
+ is_ferry,
40
+ mode_tag_keys,
41
+ open_to_other_vehicles,
42
+ usable_modes,
43
+ )
44
+ from .tags import (
45
+ BASE_WAY_TAGS,
46
+ EDIT_COLUMN,
47
+ EDIT_ID_COLUMNS,
48
+ GPKG_ANALYSIS_COLUMNS,
49
+ MODIFIED_COLUMN,
50
+ PART_COLUMN,
51
+ REMOVE_COLUMN,
52
+ REMOVE_TAGS_COLUMN,
53
+ ROUTING_WAY_TAGS,
54
+ )
55
+
56
+ log = logging.getLogger(__name__)
57
+
58
+
59
+ def assert_all_edges_have_valid_nodes(
60
+ edges_gdf: gpd.GeoDataFrame,
61
+ points_gdf: gpd.GeoDataFrame,
62
+ ) -> None:
63
+ """
64
+ Every edge's 'u' and 'v' must reference a node id that actually
65
+ exists in points_gdf's index. A dangling reference here means
66
+ write_osm_xml will emit a <nd ref="..."> with no matching <node>,
67
+ which is invalid OSM XML and can cause the edge (or the whole way)
68
+ to be silently dropped when reloaded by a routing engine.
69
+ """
70
+ node_ids = set(points_gdf.index)
71
+
72
+ bad = edges_gdf[
73
+ ~(edges_gdf["u"].isin(node_ids) & edges_gdf["v"].isin(node_ids))
74
+ ]
75
+
76
+ if not bad.empty:
77
+ missing = (set(bad["u"]) | set(bad["v"])) - node_ids
78
+ raise NetworkIntegrityError(
79
+ f"{len(bad)} edge(s) reference node id(s) not present in the "
80
+ f"node table: {sorted(missing, key=str)}"
81
+ )
82
+
83
+
84
+ def assert_no_u_equals_v(edges_gdf: gpd.GeoDataFrame) -> None:
85
+ """
86
+ No edge should be a self-loop (u == v). These are typically an
87
+ artifact of splitting a line exactly at its own endpoint and
88
+ should always have been filtered out before export.
89
+ """
90
+ bad = edges_gdf[edges_gdf["u"] == edges_gdf["v"]]
91
+
92
+ if not bad.empty:
93
+ raise NetworkIntegrityError(
94
+ f"{len(bad)} edge(s) have u == v (self-loop) at index(es): "
95
+ f"{list(bad.index)}"
96
+ )
97
+
98
+
99
+ def _stranded_custom_edges(edges_gdf: gpd.GeoDataFrame) -> tuple[pd.Series, int]:
100
+ """
101
+ (mask of custom edges that reach no existing street: their connected
102
+ piece of network is made of custom edges only, number of pieces).
103
+ The existing network may itself be in several pieces (islands), so
104
+ "not in the largest piece" would be the wrong test.
105
+ """
106
+ nothing = pd.Series(False, index=edges_gdf.index)
107
+
108
+ if "custom" not in edges_gdf.columns:
109
+ return nothing, 0
110
+
111
+ parent: dict = {}
112
+
113
+ def find(x):
114
+ parent.setdefault(x, x)
115
+ while parent[x] != x:
116
+ parent[x] = parent[parent[x]]
117
+ x = parent[x]
118
+ return x
119
+
120
+ def union(a, b):
121
+ ra, rb = find(a), find(b)
122
+ if ra != rb:
123
+ parent[ra] = rb
124
+
125
+ for u, v in zip(edges_gdf["u"], edges_gdf["v"], strict=True):
126
+ union(u, v)
127
+
128
+ sizes = collections.Counter(find(node) for node in parent)
129
+
130
+ if len(sizes) <= 1:
131
+ return nothing, len(sizes)
132
+
133
+ custom = edges_gdf["custom"] == "yes"
134
+ with_streets = set(edges_gdf.loc[~custom, "u"].map(find))
135
+ stranded = ~edges_gdf.loc[custom, "u"].map(find).isin(with_streets)
136
+
137
+ return stranded.reindex(edges_gdf.index, fill_value=False), len(sizes)
138
+
139
+
140
+ def network_pieces(edges_gdf: gpd.GeoDataFrame) -> pd.Series:
141
+ """
142
+ For each edge, a label of the connected piece of network it is in
143
+ (edges joined through shared nodes, whatever their direction).
144
+ """
145
+ parent: dict = {}
146
+
147
+ def find(x):
148
+ parent.setdefault(x, x)
149
+ while parent[x] != x:
150
+ parent[x] = parent[parent[x]]
151
+ x = parent[x]
152
+ return x
153
+
154
+ for u, v in zip(edges_gdf["u"], edges_gdf["v"], strict=True):
155
+ parent[find(u)] = find(v)
156
+ return edges_gdf["u"].map(find)
157
+
158
+
159
+ def disconnected_custom_edges(edges_gdf: gpd.GeoDataFrame) -> pd.Series:
160
+ """
161
+ Boolean mask over edges_gdf: custom edges (edges_gdf['custom'] ==
162
+ 'yes') that reach no existing street, directly or through other
163
+ custom edges. They export and load without any error, but a router
164
+ can't reach them from anywhere.
165
+
166
+ All False if edges_gdf has no 'custom' column.
167
+ """
168
+ return _stranded_custom_edges(edges_gdf)[0]
169
+
170
+
171
+ def assert_all_custom_edges_are_connected(edges_gdf: gpd.GeoDataFrame) -> None:
172
+ """
173
+ Every custom-tagged edge (edges_gdf['custom'] == 'yes') must reach
174
+ an existing street. A custom edge on an island of custom edges only
175
+ - which can happen if a junction gets fragmented into several
176
+ near-duplicate nodes that never end up linked to each other - will
177
+ export and load without any error, but will never actually be used
178
+ by a router, since a router can't reach it from anywhere.
179
+
180
+ If edges_gdf has no 'custom' column, this is a no-op (nothing to
181
+ check).
182
+ """
183
+ mask, pieces = _stranded_custom_edges(edges_gdf)
184
+ stranded_rows = edges_gdf[mask]
185
+
186
+ if not stranded_rows.empty:
187
+ stranded = sorted(int(n) for n in set(stranded_rows["u"]) | set(stranded_rows["v"]))
188
+ raise NetworkIntegrityError(
189
+ f"{len(stranded)} custom-network node(s) reach no existing street and "
190
+ f"will never be reachable by a router: {stranded}. The network is in "
191
+ f"{pieces} separate pieces in total."
192
+ )
193
+
194
+
195
+ def assert_custom_lines_unbroken(
196
+ edges_gdf: gpd.GeoDataFrame,
197
+ input_line_count: int,
198
+ ) -> None:
199
+ """
200
+ The custom edges on their own (ignoring OSM edges) must form no
201
+ more pieces than there were input lines. More pieces means some
202
+ custom segments were dropped during the build, leaving gaps in a
203
+ road that routers would then detour around via OSM streets.
204
+ assert_all_custom_edges_are_connected can't see this, because the
205
+ pieces are usually still linked through the surrounding network.
206
+ """
207
+ if "custom" not in edges_gdf.columns:
208
+ return
209
+
210
+ custom_rows = edges_gdf[edges_gdf["custom"] == "yes"]
211
+
212
+ parent: dict = {}
213
+
214
+ def find(x):
215
+ parent.setdefault(x, x)
216
+ while parent[x] != x:
217
+ parent[x] = parent[parent[x]]
218
+ x = parent[x]
219
+ return x
220
+
221
+ for u, v in zip(custom_rows["u"], custom_rows["v"], strict=True):
222
+ parent[find(u)] = find(v)
223
+
224
+ pieces = len({find(node) for node in parent})
225
+
226
+ if pieces > input_line_count:
227
+ raise NetworkIntegrityError(
228
+ f"custom network has {pieces} separate pieces but only "
229
+ f"{input_line_count} input line(s): some custom segments were "
230
+ f"dropped during the build, leaving gaps."
231
+ )
232
+
233
+
234
+ def validate_network(
235
+ edges_gdf: gpd.GeoDataFrame,
236
+ points_gdf: gpd.GeoDataFrame,
237
+ ) -> None:
238
+ """Run all three structural checks together."""
239
+ assert_all_edges_have_valid_nodes(edges_gdf, points_gdf)
240
+ assert_no_u_equals_v(edges_gdf)
241
+ assert_all_custom_edges_are_connected(edges_gdf)
242
+
243
+
244
+ # ---------------------------------------------------------------------
245
+ # Custom feature tag validation (runs BEFORE the build)
246
+ # ---------------------------------------------------------------------
247
+
248
+
249
+ # Routable highway values. Anything else (typos, lifecycle values like
250
+ # "proposed"/"construction", areas) is rejected: OSM routers ignore it.
251
+ KNOWN_HIGHWAYS = {
252
+ "motorway", "motorway_link", "trunk", "trunk_link",
253
+ "primary", "primary_link", "secondary", "secondary_link",
254
+ "tertiary", "tertiary_link", "unclassified", "residential",
255
+ "living_street", "service", "road", "busway", "bus_guideway",
256
+ "pedestrian", "track", "footway", "cycleway", "bridleway",
257
+ "path", "steps", "corridor",
258
+ }
259
+
260
+ ACCESS_KEYS = (
261
+ "access", "vehicle", "motor_vehicle", "motorcar", "foot", "bicycle", *OTHER_VEHICLE_KEYS,
262
+ )
263
+ ACCESS_VALUES = {
264
+ "yes", "no", "private", "permissive", "destination", "designated",
265
+ "customers", "delivery", "agricultural", "forestry", "discouraged",
266
+ "permit", "use_sidepath", "dismount", "official", "unknown",
267
+ "restricted", "military", "emergency",
268
+ }
269
+ ONEWAY_VALUES = {"yes", "no", "-1", "true", "false", "1", "0", "reversible", "alternating"}
270
+
271
+ # Numeric (km/h), "N mph", "N knots", implicit country codes such as
272
+ # "GB:nsl_single", or one of the special values.
273
+ MAXSPEED_PATTERN = re.compile(
274
+ r"^(\d+(\.\d+)?( mph| knots)?|[A-Z]{2}:[a-z_]+|none|walk|signals|variable)$"
275
+ )
276
+
277
+ # Size and weight limits: a number (metres / tonnes), a number with a
278
+ # unit ("3.5 m", "12 ft", "7.5 t", "5 st", "12000 lbs"), feet and inches
279
+ # (11'6"), or one of OSM's words for "no posted limit".
280
+ LIMIT_KEYS = ("maxheight", "maxwidth", "maxlength", "maxweight", "maxaxleload")
281
+ LIMIT_PATTERN = re.compile(
282
+ r"""^(\d+(\.\d+)?( ?(m|ft|t|st|kg|lbs))?|\d+'(\d+(\.\d+)?")?|"""
283
+ r"""none|default|below_default|unsigned)$"""
284
+ )
285
+
286
+ # Keys whose values are a documented list (OSM wiki, and what Valhalla
287
+ # reads). OSM allows other values, so these only WARN: a misspelt value
288
+ # ("asphault") is ignored by every router without a word.
289
+ KNOWN_VALUES = {
290
+ "surface": {
291
+ "paved", "asphalt", "chipseal", "concrete", "concrete:lanes", "concrete:plates",
292
+ "paving_stones", "paving_stones:lanes", "grass_paver", "sett", "unhewn_cobblestone",
293
+ "cobblestone", "bricks", "metal", "metal_grid", "wood", "stepping_stones", "rubber",
294
+ "tiles", "unpaved", "compacted", "fine_gravel", "gravel", "shells", "rock",
295
+ "pebblestone", "ground", "dirt", "earth", "grass", "mud", "sand", "woodchips", "snow",
296
+ "ice", "salt", "clay", "artificial_turf", "acrylic", "carpet", "plastic",
297
+ },
298
+ "smoothness": {
299
+ "excellent", "good", "intermediate", "bad", "very_bad", "horrible", "very_horrible",
300
+ "impassable",
301
+ },
302
+ "tracktype": {"grade1", "grade2", "grade3", "grade4", "grade5"},
303
+ "sac_scale": {
304
+ "hiking", "mountain_hiking", "demanding_mountain_hiking", "alpine_hiking",
305
+ "demanding_alpine_hiking", "difficult_alpine_hiking",
306
+ },
307
+ "sidewalk": {"both", "left", "right", "no", "none", "separate", "yes"},
308
+ "segregated": {"yes", "no"},
309
+ "lit": {"yes", "no", "automatic", "24/7", "sunset-sunrise", "limited", "interval", "disused"},
310
+ "toll": {"yes", "no"},
311
+ **{key: {"yes", "no", "separate", "automatic", "manual"}
312
+ for key in ("ramp", "ramp:wheelchair", "ramp:stroller", "ramp:bicycle")},
313
+ "junction": {"roundabout", "circular", "jughandle", "filter", "spui"},
314
+ "bridge": {
315
+ "yes", "no", "viaduct", "aqueduct", "boardwalk", "cantilever", "covered", "movable",
316
+ "trestle", "low_water_crossing", "simple_brunnel",
317
+ },
318
+ "tunnel": {
319
+ "yes", "no", "building_passage", "culvert", "avalanche_protector", "flooded",
320
+ },
321
+ **{key: {
322
+ "lane", "track", "shared_lane", "share_busway", "opposite", "opposite_lane",
323
+ "opposite_track", "opposite_share_busway", "separate", "no", "shoulder", "shared",
324
+ "crossing", "link", "yes",
325
+ } for key in ("cycleway", "cycleway:left", "cycleway:right", "cycleway:both")},
326
+ }
327
+ # incline: up / down, or a gradient such as 10% or -5%.
328
+ INCLINE_PATTERN = re.compile(r"^(up|down|-?\d+(\.\d+)?(%|°)?)$")
329
+
330
+ # A ferry's crossing time: hh:mm or hh:mm:ss (Valhalla ignores "25").
331
+ DURATION_PATTERN = re.compile(r"^\d+:[0-5]\d(:[0-5]\d)?$")
332
+
333
+ # Columns the pipeline itself uses. A custom property with one of
334
+ # these names would corrupt topology (e.g. a 'u' value stops the
335
+ # feature being treated as custom).
336
+ RESERVED_COLUMNS = {
337
+ "u", "v", "key", "osmid", "custom", "split", "reversed", "length",
338
+ PART_COLUMN, MODIFIED_COLUMN, EDIT_COLUMN,
339
+ # Spatial joins name their columns so (and leave them in layers made
340
+ # with one); the GeoPackage's analysis columns are worked out afresh.
341
+ "index_left", "index_right", *GPKG_ANALYSIS_COLUMNS,
342
+ }
343
+
344
+ # Tag keys the pipeline understands (checked, used for routing or
345
+ # exported). Used to spot misspelt or truncated attribute names.
346
+ KNOWN_TAG_KEYS = mode_tag_keys() | set(BASE_WAY_TAGS) | set(ROUTING_WAY_TAGS)
347
+
348
+
349
+ def _tag_value(value) -> str | None:
350
+ """
351
+ An attribute value as tag text; None if the feature has no value.
352
+ GIS layers hold "no value" as NULL or as empty text, and yes/no as a
353
+ boolean field. Text is taken as it is: tag values are OSM's.
354
+ """
355
+ if value is None or (isinstance(value, float) and value != value):
356
+ return None
357
+ if isinstance(value, bool | np.bool_):
358
+ return "yes" if value else "no"
359
+ if isinstance(value, float) and value.is_integer():
360
+ value = int(value)
361
+ text = str(value).strip()
362
+ return text or None
363
+
364
+
365
+ def resolve_custom_tags(
366
+ custom_gdf: gpd.GeoDataFrame,
367
+ default_tags: dict[str, str],
368
+ overwrite: bool = False,
369
+ ) -> gpd.GeoDataFrame:
370
+ """
371
+ Combine each feature's own tags (its attributes, e.g. a 'highway'
372
+ column) with blanket `default_tags` (a preset and/or network_tags).
373
+
374
+ overwrite=False: a feature's own value wins; defaults fill gaps.
375
+ overwrite=True: defaults replace the feature's value for every key
376
+ they set. Other attributes are kept.
377
+
378
+ Tag values (attributes named after a tag NetworkForge knows) are
379
+ normalised to strings (50 -> "50"); other attributes are left as
380
+ they are.
381
+ """
382
+ custom_gdf = custom_gdf.copy()
383
+
384
+ for key, value in default_tags.items():
385
+ if key not in custom_gdf.columns:
386
+ custom_gdf[key] = None
387
+ custom_gdf[key] = custom_gdf[key].astype(object)
388
+ if overwrite:
389
+ custom_gdf[key] = value
390
+ else:
391
+ custom_gdf.loc[custom_gdf[key].isna(), key] = value
392
+
393
+ # Tags become OSM text. Other attributes keep their type: a number
394
+ # stays a number, for use as a cost in QGIS's network tools.
395
+ tag_columns = [c for c in custom_gdf.columns
396
+ if c != custom_gdf.geometry.name and c in KNOWN_TAG_KEYS]
397
+ for column in tag_columns:
398
+ custom_gdf[column] = custom_gdf[column].map(_tag_value).astype(object)
399
+
400
+ return custom_gdf
401
+
402
+
403
+ def tag_value_problems(tags: dict[str, str]) -> tuple[list[str], set[str]]:
404
+ """
405
+ (problems, notes) for one feature's tag values: is each a value OSM
406
+ and routers understand. Says nothing about the highway type or who
407
+ can use the way (check_custom_tags does that for new lines).
408
+ """
409
+ problems, notes = [], set()
410
+
411
+ maxspeed = tags.get("maxspeed")
412
+ if maxspeed is not None:
413
+ if not MAXSPEED_PATTERN.match(maxspeed):
414
+ problems.append(f"maxspeed={maxspeed!r} is not a valid OSM speed")
415
+ elif maxspeed[:1].isdigit() and float(maxspeed.split()[0]) == 0:
416
+ problems.append(f"maxspeed={maxspeed!r} must be above 0")
417
+ elif maxspeed.replace(".", "").isdigit():
418
+ notes.add(f"maxspeed={maxspeed} has no unit and is read as km/h")
419
+
420
+ oneway = tags.get("oneway")
421
+ if oneway is not None and oneway not in ONEWAY_VALUES:
422
+ problems.append(f"oneway={oneway!r} is not a valid OSM value")
423
+ if oneway in {"yes", "true", "1", "-1"}:
424
+ notes.add("oneway direction follows each line's drawing direction")
425
+
426
+ lanes = tags.get("lanes")
427
+ if lanes is not None and not (lanes.isdigit() and int(lanes) > 0):
428
+ problems.append(f"lanes={lanes!r} must be a positive whole number")
429
+
430
+ for key in ACCESS_KEYS:
431
+ value = tags.get(key)
432
+ if value is not None and value not in ACCESS_VALUES:
433
+ problems.append(f"{key}={value!r} is not a valid OSM access value")
434
+
435
+ for key in LIMIT_KEYS:
436
+ value = tags.get(key)
437
+ if value is not None and not LIMIT_PATTERN.match(value):
438
+ problems.append(f"{key}={value!r} is not a valid OSM limit "
439
+ "(e.g. 3.5, '3.5 m', '12 ft', '7.5 t')")
440
+
441
+ duration = tags.get("duration")
442
+ if duration is not None and not DURATION_PATTERN.match(duration):
443
+ problems.append(f"duration={duration!r} must be hours and minutes, e.g. '00:25' "
444
+ "or '01:10:30' (routers ignore a plain number)")
445
+
446
+ layer = tags.get("layer")
447
+ if layer is not None and not re.fullmatch(r"-?\d+", layer):
448
+ problems.append(f"layer={layer!r} must be a whole number, e.g. 1 or -1")
449
+
450
+ return problems, notes
451
+
452
+
453
+ def unknown_values(tags: dict[str, str]) -> list[tuple[str, str]]:
454
+ """(key, value) pairs of `tags` that are not a value routers know (KNOWN_VALUES)."""
455
+ unknown = [(key, tags[key]) for key, values in KNOWN_VALUES.items()
456
+ if key in tags and tags[key] not in values]
457
+ incline = tags.get("incline")
458
+ if incline is not None and not INCLINE_PATTERN.match(incline):
459
+ unknown.append(("incline", incline))
460
+ return unknown
461
+
462
+
463
+ def warn_about_unknown_values(found: dict[tuple[str, str], list], known_values=None) -> None:
464
+ """One warning per unknown (key, value), naming its features and the likely value."""
465
+ for (key, value), features in found.items():
466
+ known = (known_values or KNOWN_VALUES).get(key, {"up", "down"})
467
+ close = difflib.get_close_matches(value, known, n=1, cutoff=0.7)
468
+ hint = f" - did you mean {close[0]!r}?" if close else ""
469
+ log.warning(
470
+ "%s=%r is not a value routers know%s Routers ignore it (features %s).",
471
+ key, value, hint or ".", ", ".join(map(str, features)),
472
+ extra={"features": list(features)},
473
+ )
474
+
475
+
476
+ def check_custom_tags(
477
+ custom_gdf: gpd.GeoDataFrame,
478
+ network_type: str,
479
+ strict: bool = True,
480
+ require_usable: bool = True,
481
+ ) -> None:
482
+ """
483
+ Check every custom feature's (already resolved) tags follow OSM
484
+ rules and are usable within the network being built.
485
+
486
+ Logs which modes the features support. Problems raise
487
+ InvalidTagsError when strict, otherwise are logged as warnings.
488
+ Problems name features by the custom data's index.
489
+
490
+ require_usable=False: a feature nobody may use (access=private, say)
491
+ is noted, not a problem. Adding such a line to OpenStreetMap is
492
+ almost always a mistake; in a whole network of your own, private and
493
+ closed streets are simply part of the data.
494
+ """
495
+ issues = [] # (feature id or None, message)
496
+ notes = set()
497
+ mode_counts = collections.Counter()
498
+ unknown = collections.defaultdict(list) # (key, value) -> features
499
+
500
+ reserved = RESERVED_COLUMNS & set(custom_gdf.columns)
501
+ if reserved:
502
+ issues.append((None, f"custom data has reserved column(s) {sorted(reserved)}; rename them"))
503
+
504
+ tag_columns = [c for c in custom_gdf.columns if c != custom_gdf.geometry.name]
505
+
506
+ for column in tag_columns:
507
+ suggestion = _suggest_tag_key(column)
508
+ if suggestion:
509
+ log.warning(
510
+ "Attribute %r is not a tag NetworkForge uses - did you mean %r? "
511
+ "(Shapefiles cut names to 10 characters; use GeoPackage.)",
512
+ column, suggestion, extra={"field": column},
513
+ )
514
+
515
+ for index, row in custom_gdf.iterrows():
516
+ # _tag_value turns missing values (None/NaN, e.g. a property only
517
+ # some features have) into None; pandas may store that as NaN again.
518
+ tags = {c: row[c] for c in tag_columns if _tag_value(row[c]) is not None}
519
+ label = f"feature {index}"
520
+ highway = tags.get("highway")
521
+
522
+ ferry = is_ferry(tags)
523
+ if highway is None and not ferry:
524
+ issues.append((index, "no highway tag (or route=ferry for a ferry)"))
525
+ elif highway is not None and highway not in KNOWN_HIGHWAYS:
526
+ issues.append((index, f"highway={highway!r} is not a routable highway value"))
527
+
528
+ problems, value_notes = tag_value_problems(tags)
529
+ issues += [(index, problem) for problem in problems]
530
+ notes |= value_notes
531
+ for pair in unknown_values(tags):
532
+ unknown[pair].append(index)
533
+ maxspeed = tags.get("maxspeed")
534
+
535
+ modes = usable_modes(tags)
536
+ routable = highway in KNOWN_HIGHWAYS or ferry
537
+ if routable:
538
+ mode_counts[", ".join(modes) or "NOTHING"] += 1
539
+ else:
540
+ mode_counts["NOTHING (invalid highway)"] += 1
541
+ log.debug("%s: highway=%s maxspeed=%s -> usable by: %s",
542
+ label, highway, maxspeed, ", ".join(modes) or "NOTHING")
543
+
544
+ if highway == "bridleway" and not ({"foot", "bicycle"} & tags.keys()):
545
+ notes.add("highway=bridleway: Valhalla only lets walkers and bikes use a "
546
+ "bridleway tagged foot=yes / bicycle=yes")
547
+
548
+ if routable:
549
+ if not modes and open_to_other_vehicles(tags):
550
+ notes.add("some features are open only to buses or other special "
551
+ "vehicles: the car / bike / walk columns leave them out, "
552
+ "routers use them for those vehicles")
553
+ elif not modes and not require_usable:
554
+ notes.add("some features are closed to every mode (e.g. access=private "
555
+ "or access=no); they are kept as they are")
556
+ elif not modes:
557
+ issues.append((index, "tags make it unusable by every mode"))
558
+ elif not allows_mode(tags, network_type):
559
+ issues.append((index, f"not usable in network_type={network_type!r} "
560
+ f"(usable by: {', '.join(modes)})"))
561
+
562
+ warn_about_unknown_values(unknown)
563
+ for modes, count in mode_counts.most_common():
564
+ log.info("%d custom feature(s) usable by: %s", count, modes)
565
+ for note in sorted(notes):
566
+ log.info("Note: %s", note)
567
+
568
+ if issues:
569
+ error = InvalidTagsError(issues)
570
+ if strict:
571
+ raise error
572
+ features = [i["feature"] for i in error.issues if i["feature"] is not None]
573
+ log.warning("strict=False, continuing anyway: %s", error, extra={"features": features})
574
+
575
+
576
+ # A field named like an OpenStreetMap key with a namespace
577
+ # ("ramp:wheelchair", "cycleway:surface"): GIS fields rarely have colons.
578
+ _NAMESPACED_KEY = re.compile(r"^[a-z][a-z0-9_]*(:[a-z0-9_]+)+$")
579
+
580
+ # Common OSM way keys, without a colon, that NetworkForge doesn't write.
581
+ UNWRITTEN_OSM_KEYS = {
582
+ "tactile_paving", "handrail", "step_count", "est_width", "covered", "indoor", "level",
583
+ "noexit", "cutting", "embankment", "priority_road", "overtaking", "hazard",
584
+ "traffic_calming", "kerb",
585
+ }
586
+
587
+ # Features named in one warning, at most.
588
+ MAX_FEATURES = 10
589
+
590
+ # Namespaces that never change a route: names, sources, notes, addresses
591
+ # (OSM layers, e.g. from QuickOSM, are full of them).
592
+ _NOT_FOR_ROUTING = (
593
+ "name", "alt_name", "old_name", "official_name", "loc_name", "short_name", "int_name",
594
+ "source", "note", "fixme", "description", "addr", "wikidata", "wikipedia", "wikimedia",
595
+ "check_date", "survey", "created_by", "image", "website", "url", "operator", "ref",
596
+ )
597
+
598
+
599
+ def warn_about_unwritten_tags(lines_gdf: gpd.GeoDataFrame) -> list[str]:
600
+ """
601
+ Warn about fields of the line features (new lines and changes to
602
+ existing streets) that look like OpenStreetMap tags but that
603
+ NetworkForge doesn't write: they reach the GeoPackage, not routers.
604
+ Only fields with a value on some feature count. Returns the fields.
605
+ """
606
+ written = (KNOWN_TAG_KEYS | RESERVED_COLUMNS | set(EDIT_ID_COLUMNS)
607
+ | {REMOVE_COLUMN, REMOVE_TAGS_COLUMN})
608
+ found = {}
609
+ for column in lines_gdf.columns:
610
+ if column == lines_gdf.geometry.name or column in written:
611
+ continue
612
+ namespaced = _NAMESPACED_KEY.match(column) is not None
613
+ if not (namespaced or column in UNWRITTEN_OSM_KEYS):
614
+ continue
615
+ if column.split(":")[0] in _NOT_FOR_ROUTING or _suggest_tag_key(column):
616
+ continue # not a routing tag, or already warned about as misspelt
617
+ has_value = lines_gdf[column].map(_tag_value).notna()
618
+ if has_value.any():
619
+ found[column] = list(lines_gdf.index[has_value.to_numpy()])
620
+ if found:
621
+ log.warning(
622
+ "Field(s) %s look like OpenStreetMap tags, but NetworkForge doesn't write them "
623
+ "to the OSM file, so routers won't see them (they stay in the GeoPackage). "
624
+ "Ask for a tag to be added if a router needs it.",
625
+ ", ".join(f"{column!r} (features {', '.join(map(str, features[:MAX_FEATURES]))}"
626
+ f"{' ...' if len(features) > MAX_FEATURES else ''})"
627
+ for column, features in found.items()),
628
+ extra={"fields": list(found),
629
+ "features": list(dict.fromkeys(f for fs in found.values() for f in fs))},
630
+ )
631
+ return list(found)
632
+
633
+
634
+ def _suggest_tag_key(column: str) -> str | None:
635
+ """A known tag key `column` is probably a misspelling/truncation of."""
636
+ if column in KNOWN_TAG_KEYS:
637
+ return None
638
+ truncated = [key for key in KNOWN_TAG_KEYS if len(column) >= 5 and key.startswith(column)]
639
+ if truncated:
640
+ return min(truncated, key=lambda key: (len(key), key))
641
+ close = difflib.get_close_matches(column, KNOWN_TAG_KEYS, n=1, cutoff=0.85)
642
+ return close[0] if close else None