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.
- networkforge/__init__.py +48 -0
- networkforge/__main__.py +7 -0
- networkforge/cli.py +681 -0
- networkforge/coverage.py +176 -0
- networkforge/edits.py +460 -0
- networkforge/errors.py +87 -0
- networkforge/export.py +1056 -0
- networkforge/inputs.py +256 -0
- networkforge/modes.py +336 -0
- networkforge/network.py +703 -0
- networkforge/osm.py +450 -0
- networkforge/paths.py +113 -0
- networkforge/points.py +290 -0
- networkforge/presets.py +50 -0
- networkforge/projection.py +81 -0
- networkforge/tags.py +132 -0
- networkforge/topology.py +1086 -0
- networkforge/traffic.py +905 -0
- networkforge/turns.py +308 -0
- networkforge/validation.py +642 -0
- networkforge-1.4.1.dist-info/METADATA +336 -0
- networkforge-1.4.1.dist-info/RECORD +25 -0
- networkforge-1.4.1.dist-info/WHEEL +4 -0
- networkforge-1.4.1.dist-info/entry_points.txt +2 -0
- networkforge-1.4.1.dist-info/licenses/LICENSE +674 -0
|
@@ -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
|