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/inputs.py ADDED
@@ -0,0 +1,256 @@
1
+ """
2
+ Checks and clean-up of build_network's inputs, before any work is done.
3
+
4
+ Input problems raise InputError with a plain-language message. Problems
5
+ with individual custom features (no geometry, not a line, outside the
6
+ bounding box) raise when strict, or are dropped with a warning when not.
7
+ """
8
+
9
+ import logging
10
+
11
+ import geopandas as gpd
12
+ import numpy as np
13
+ import pandas as pd
14
+ import shapely
15
+ from shapely.geometry import box
16
+
17
+ from .edits import take_edit_ids
18
+ from .errors import InputError
19
+ from .tags import EDIT_ID_COLUMN
20
+ from .validation import RESERVED_COLUMNS
21
+
22
+ log = logging.getLogger(__name__)
23
+
24
+ LINE_TYPES = {"LineString", "MultiLineString"}
25
+ # Points carry node tags (barriers, signals ...: see points.py).
26
+ FEATURE_TYPES = LINE_TYPES | {"Point", "MultiPoint"}
27
+
28
+ # Largest bounding box downloaded from the Overpass API. Overpass is a
29
+ # free shared service (roughly 10,000 queries or 1 GB a day per user);
30
+ # bigger areas must come from a local extract (osm_source=...), which
31
+ # is also faster and gives the same data every run.
32
+ MAX_OVERPASS_AREA_KM2 = 1000
33
+
34
+ # With a local extract there's no limit, but warn: memory and time grow.
35
+ LARGE_LOCAL_AREA_KM2 = 10_000
36
+
37
+ # How many offending feature ids to list in a message.
38
+ MAX_LISTED = 10
39
+
40
+
41
+ OFF_THE_EARTH = ("has coordinates that can't be longitude / latitude in its CRS: check the "
42
+ "layer's CRS is set right")
43
+
44
+
45
+ def within_the_earth(geometry: gpd.GeoSeries) -> pd.Series:
46
+ """
47
+ For each geometry (finite coordinates, a CRS set): whether every
48
+ coordinate is a real longitude / latitude once reprojected.
49
+ """
50
+ try:
51
+ wgs84 = geometry.to_crs("EPSG:4326")
52
+ except Exception: # noqa: BLE001 - a transform that fails is as bad
53
+ return pd.Series(False, index=geometry.index)
54
+ coords, owner = shapely.get_coordinates(wgs84.to_numpy(), return_index=True)
55
+ bad = ~np.isfinite(coords).all(axis=1) | (np.abs(coords[:, 0]) > 180) | (
56
+ np.abs(coords[:, 1]) > 90)
57
+ result = pd.Series(True, index=geometry.index)
58
+ result.iloc[np.unique(owner[bad])] = False
59
+ return result
60
+
61
+
62
+ def check_bbox(bbox_gdf, local_source: bool = False) -> None:
63
+ """
64
+ The bounding box must be a GeoDataFrame with geometry and a CRS,
65
+ and no bigger than MAX_OVERPASS_AREA_KM2 unless the OSM data comes
66
+ from a local file (local_source=True).
67
+ """
68
+
69
+ if not isinstance(bbox_gdf, gpd.GeoDataFrame):
70
+ raise InputError(
71
+ f"The bounding box must be a GeoDataFrame, not {type(bbox_gdf).__name__}."
72
+ )
73
+
74
+ geometry = bbox_gdf.geometry
75
+ if bbox_gdf.empty or (geometry.isna() | geometry.is_empty).all():
76
+ raise InputError("The bounding box has no geometry.")
77
+
78
+ if bbox_gdf.crs is None:
79
+ raise InputError(
80
+ "The bounding box has no CRS, so its coordinates can't be placed on "
81
+ "the map. Set one, e.g. bbox_gdf.set_crs('EPSG:4326')."
82
+ )
83
+
84
+ projected = bbox_gdf.to_crs(bbox_gdf.estimate_utm_crs())
85
+ area_km2 = box(*projected.total_bounds).area / 1e6
86
+
87
+ if local_source:
88
+ if area_km2 > LARGE_LOCAL_AREA_KM2:
89
+ log.warning("The bounding box covers %.0f km2; expect a long build and "
90
+ "high memory use.", area_km2)
91
+ return
92
+
93
+ if area_km2 > MAX_OVERPASS_AREA_KM2:
94
+ west, south, east, north = bbox_gdf.to_crs("EPSG:4326").total_bounds
95
+ raise InputError(
96
+ f"The bounding box covers {area_km2:,.0f} km2, more than the "
97
+ f"{MAX_OVERPASS_AREA_KM2:,} km2 NetworkForge will download from the shared "
98
+ "Overpass API. Use a local OSM extract instead:\n"
99
+ " 1. Download one covering the area, e.g. from "
100
+ "https://download.geofabrik.de (countries and regions, updated daily)\n"
101
+ " or https://extract.bbbike.org (draw your own area).\n"
102
+ " 2. Optionally crop it to your box (faster builds):\n"
103
+ f" osmium extract -b {west:.4f},{south:.4f},{east:.4f},{north:.4f} "
104
+ "region.osm.pbf -o area.osm.pbf\n"
105
+ " 3. Pass it in: build_network(..., osm_source='area.osm.pbf')",
106
+ guide="osm-data.md",
107
+ )
108
+
109
+
110
+ def clean_custom_data(
111
+ custom_gdf,
112
+ bbox_gdf: gpd.GeoDataFrame | None,
113
+ strict: bool = True,
114
+ edits: bool = True,
115
+ ) -> gpd.GeoDataFrame:
116
+ """
117
+ Check the custom data and return a cleaned copy: only line (and
118
+ point) features with geometry that reach into the bounding box, in 2D.
119
+
120
+ bbox_gdf None: there is no box (a standalone network), so nothing
121
+ is outside it. edits False: an OSM id attribute is not read as
122
+ "change this existing street" (there are no existing streets).
123
+ """
124
+
125
+ if not isinstance(custom_gdf, gpd.GeoDataFrame):
126
+ raise InputError(
127
+ f"The custom data must be a GeoDataFrame, not {type(custom_gdf).__name__}."
128
+ )
129
+
130
+ if custom_gdf.empty:
131
+ raise InputError("The custom data has no features.")
132
+
133
+ if custom_gdf.crs is None:
134
+ raise InputError(
135
+ "The custom data has no CRS, so its coordinates can't be placed on "
136
+ "the map. Set one, e.g. custom_gdf.set_crs('EPSG:4326')."
137
+ )
138
+
139
+ check_feature_ids(custom_gdf)
140
+ custom = take_edit_ids(custom_gdf) if edits else custom_gdf
141
+ custom = drop_reserved_columns(custom.copy(), quiet=EDIT_ID_COLUMN in custom.columns)
142
+ if custom.geometry.name != "geometry":
143
+ # As the network's tables call it (e.g. "geom" from a database):
144
+ # two geometry columns can't be written to a GeoPackage.
145
+ custom = custom.drop(columns="geometry", errors="ignore").rename_geometry("geometry")
146
+ geometry = custom.geometry
147
+
148
+ no_geometry = geometry.isna() | geometry.is_empty
149
+ not_a_line = ~no_geometry & ~geometry.geom_type.isin(FEATURE_TYPES)
150
+
151
+ # Coordinates that aren't numbers (NaN, inf) break every geometry operation.
152
+ coords, owner = shapely.get_coordinates(geometry.to_numpy(), return_index=True)
153
+ bad_coordinates = pd.Series(False, index=custom.index)
154
+ bad_coordinates.iloc[np.unique(owner[~np.isfinite(coords).all(axis=1)])] = True
155
+
156
+ # Numbers that can't be a place on the earth: usually a layer whose
157
+ # CRS is wrong (metres labelled as longitude / latitude).
158
+ off_the_earth = pd.Series(False, index=custom.index)
159
+ checked = ~no_geometry & ~not_a_line & ~bad_coordinates
160
+ if checked.any():
161
+ off_the_earth[checked] = ~within_the_earth(geometry[checked])
162
+
163
+ usable = ~no_geometry & ~not_a_line & ~bad_coordinates & ~off_the_earth
164
+ if bbox_gdf is None:
165
+ outside = partly_outside = usable & False
166
+ else:
167
+ # Only usable geometries: GEOS can't compare one with NaN coordinates.
168
+ bbox_area = bbox_gdf.to_crs(custom.crs).union_all()
169
+ inside = pd.Series(False, index=custom.index)
170
+ within = pd.Series(False, index=custom.index)
171
+ inside[usable] = geometry[usable].intersects(bbox_area)
172
+ within[usable] = geometry[usable].within(bbox_area)
173
+ outside = usable & ~inside
174
+ partly_outside = usable & ~outside & ~within
175
+
176
+ problems, issues = [], []
177
+ types = geometry[not_a_line].geom_type
178
+ for mask, reason in (
179
+ (no_geometry, "no geometry"),
180
+ (not_a_line, f"{'/'.join(sorted(set(types)))}, not a line or point (only lines can "
181
+ "be routed; points tag the network's nodes)"),
182
+ (bad_coordinates, "has coordinates that aren't numbers (NaN or infinite)"),
183
+ (off_the_earth, OFF_THE_EARTH),
184
+ (outside, "completely outside the bounding box"),
185
+ ):
186
+ if mask.any():
187
+ problems.append(f"{_ids(custom, mask)}: {reason}")
188
+ issues += [{"feature": fid, "message": reason} for fid in custom.index[mask]]
189
+ for issue in issues: # say which geometry type each non-line is
190
+ if issue["feature"] in types.index:
191
+ issue["message"] = f"{types.loc[issue['feature']]}, not a line or point"
192
+
193
+ if problems:
194
+ message = "Some custom features can't be used - " + "; ".join(problems) + "."
195
+ if outside.any() and outside.sum() == usable.sum():
196
+ message += (" None of them is in the box: check the layer's CRS is set right (and, "
197
+ "for longitude / latitude, that they aren't the wrong way round).")
198
+ if strict:
199
+ raise InputError(message, guide="preparing-your-data", issues=issues)
200
+ log.warning("strict=False, dropping them: %s", message,
201
+ extra={"features": [i["feature"] for i in issues]})
202
+ custom = custom[usable & ~outside]
203
+ if custom.empty:
204
+ raise InputError("No usable custom features are left. " + message, issues=issues)
205
+
206
+ partly = partly_outside.loc[custom.index]
207
+ if partly.any():
208
+ log.warning(
209
+ "%s: partly outside the bounding box; the parts outside can't join "
210
+ "the OSM network.", _ids(custom, partly).capitalize(),
211
+ extra={"features": list(custom.index[partly])},
212
+ )
213
+
214
+ if custom.geometry.has_z.any():
215
+ log.info("Dropping Z/M values from custom geometries (2D is used for routing).")
216
+ custom = custom.set_geometry(shapely.force_2d(custom.geometry.values), crs=custom.crs)
217
+
218
+ return custom
219
+
220
+
221
+ def check_feature_ids(custom_gdf: gpd.GeoDataFrame) -> None:
222
+ """The index names features in messages and JSON issues: it must be unique."""
223
+ if not custom_gdf.index.is_unique:
224
+ repeated = custom_gdf.index[custom_gdf.index.duplicated()]
225
+ duplicated = sorted(set(repeated), key=str)[:MAX_LISTED]
226
+ raise InputError(
227
+ f"Custom feature ids (the data's index / --id-field) must be unique; "
228
+ f"repeated: {', '.join(map(str, duplicated))}."
229
+ )
230
+
231
+
232
+ def drop_reserved_columns(custom_gdf: gpd.GeoDataFrame, quiet: bool = False) -> gpd.GeoDataFrame:
233
+ """
234
+ Set aside attributes whose names the pipeline uses internally (e.g.
235
+ `length`, `key` - common in GIS layers). They aren't OSM tags, so
236
+ nothing is lost; a warning names them. `quiet`: only note it - rows
237
+ copied from a NetworkForge layer (to change existing streets) always
238
+ have them.
239
+ """
240
+ reserved = sorted(RESERVED_COLUMNS & set(custom_gdf.columns))
241
+ if reserved:
242
+ log.log(
243
+ logging.INFO if quiet else logging.WARNING,
244
+ "Ignoring custom attribute(s) %s: the names are used internally and "
245
+ "aren't OSM tags.", ", ".join(reserved), extra={"fields": reserved},
246
+ )
247
+ custom_gdf = custom_gdf.drop(columns=reserved)
248
+ return custom_gdf
249
+
250
+
251
+ def _ids(gdf: gpd.GeoDataFrame, mask) -> str:
252
+ """'feature 3' / 'features 3, 7 and 2 more' for the rows selected by mask."""
253
+ ids = list(gdf.index[mask])
254
+ listed = ", ".join(map(str, ids[:MAX_LISTED]))
255
+ more = f" and {len(ids) - MAX_LISTED} more" if len(ids) > MAX_LISTED else ""
256
+ return f"feature{'s' if len(ids) > 1 else ''} {listed}{more}"
networkforge/modes.py ADDED
@@ -0,0 +1,336 @@
1
+ """
2
+ Transport-mode access rules.
3
+
4
+ Two layers, applied to plain tag dicts:
5
+
6
+ 1. OSMnx's own Overpass network filters (parsed from OSMnx), which
7
+ decide by way type: e.g. no cars on footways, no walking on
8
+ motorways.
9
+ 2. The OSM access hierarchy, which OSMnx only partly applies: the most
10
+ specific access tag for the mode decides (for cars: motorcar >
11
+ motor_vehicle > vehicle > access). This closes ways tagged e.g.
12
+ access=no or motor_vehicle=private, keeps busways for buses only,
13
+ and opens ways a mode-specific tag explicitly allows, e.g. a footway
14
+ with bicycle=designated (a shared-use path).
15
+
16
+ The same rules are used to:
17
+ - validate custom features before a build (validation.py),
18
+ - decide which OSM tags must survive download and export, and
19
+ - filter a loaded graph down to one mode at routing time.
20
+
21
+ Note: `_get_network_filter` is private OSMnx API (checked against
22
+ OSMnx 2.1). If a future release changes it, `mode_rules` will fail
23
+ loudly rather than silently allowing everything.
24
+ """
25
+
26
+ import re
27
+ from functools import cache
28
+
29
+ import networkx as nx
30
+ import osmnx as ox
31
+ from osmnx._overpass import _get_network_filter
32
+
33
+ from .errors import InputError
34
+ from .tags import BASE_WAY_TAGS, CUSTOM_TAG, FERRY_ROUTES, NODE_TAGS, ROUTING_WAY_TAGS
35
+
36
+ # Routing modes a network can be filtered to. "all" / "all_public"
37
+ # are download scopes rather than travel modes, so they're left out.
38
+ MODES = ("drive", "drive_service", "walk", "bike")
39
+
40
+ _CLAUSE = re.compile(r'\["([^"]+)"(?:(!?~)"([^"]*)")?\]')
41
+
42
+ # OSM access hierarchy per mode, general -> specific. The most specific
43
+ # tag present decides: access=no + bicycle=yes is open to bikes only.
44
+ ACCESS_HIERARCHY = {
45
+ "drive": ("access", "vehicle", "motor_vehicle", "motorcar"),
46
+ "drive_service": ("access", "vehicle", "motor_vehicle", "motorcar"),
47
+ "bike": ("access", "vehicle", "bicycle"),
48
+ "walk": ("access", "foot"),
49
+ }
50
+
51
+ # Access values that close a way to general routing. (destination,
52
+ # customers, permissive, discouraged, dismount etc. stay open.)
53
+ DENIED_ACCESS = {
54
+ "no", "private", "agricultural", "forestry", "delivery", "emergency",
55
+ "military", "restricted", "permit", "use_sidepath",
56
+ }
57
+
58
+ # Mode-specific values that explicitly open a way.
59
+ GRANTED_ACCESS = {"yes", "designated", "permissive", "official"}
60
+
61
+ # Way types closed to these modes unless a mode-specific tag opens them:
62
+ # busways are for buses; cycling on a pedestrian street needs e.g.
63
+ # bicycle=yes (OSM's default, and how Valhalla reads it).
64
+ CLOSED_BY_DEFAULT = {
65
+ "busway": ("drive", "drive_service", "bike", "walk"),
66
+ "pedestrian": ("bike",),
67
+ }
68
+
69
+ # motorroad=yes: motorway-like rules on any road type - no walking or
70
+ # cycling unless foot= / bicycle= says otherwise.
71
+ MOTORROAD_CLOSED = ("bike", "walk")
72
+
73
+ # Access tags for vehicles NetworkForge has no mode for. A way open
74
+ # only to these (a bus gate, a busway) is still a real way: routers
75
+ # with a bus, truck or taxi profile use it.
76
+ OTHER_VEHICLE_KEYS = (
77
+ "bus", "psv", "hgv", "goods", "taxi", "motorcycle", "moped", "emergency", "horse", "hov",
78
+ )
79
+ BUS_HIGHWAYS = {"busway", "bus_guideway"}
80
+
81
+ # Way types OSMnx excludes for a mode that a mode-specific tag may open:
82
+ # a footway with bicycle=yes/designated is a shared-use path; a
83
+ # cycleway with foot=yes is walkable.
84
+ OPENABLE = {"bike": {"footway"}, "walk": {"cycleway"}}
85
+
86
+
87
+ @cache
88
+ def mode_rules(mode: str) -> tuple[tuple[str, str | None, str | None], ...]:
89
+ """
90
+ Parse OSMnx's filter for `mode` into (key, operator, regex)
91
+ clauses. operator is None ("key must exist"), "~" (value must
92
+ match) or "!~" (value must not match; a missing key passes).
93
+ """
94
+ filter_string = _get_network_filter(mode)
95
+ clauses = tuple(_CLAUSE.findall(filter_string))
96
+
97
+ if not clauses or "".join(f'["{k}"{op}"{rx}"]' if op else f'["{k}"]'
98
+ for k, op, rx in clauses) != filter_string:
99
+ raise RuntimeError(
100
+ f"Could not parse OSMnx network filter for {mode!r}: "
101
+ f"{filter_string}. OSMnx's filter format may have changed."
102
+ )
103
+
104
+ return tuple((k, op or None, rx or None) for k, op, rx in clauses)
105
+
106
+
107
+ def mode_tag_keys() -> set[str]:
108
+ """Every OSM tag key any mode's access rules depend on."""
109
+ return {
110
+ key
111
+ for mode in ("all", "all_public", *MODES)
112
+ for key, _, _ in mode_rules(mode)
113
+ } | {key for keys in ACCESS_HIERARCHY.values() for key in keys} | {"motorroad"}
114
+
115
+
116
+ def keep_mode_tags() -> None:
117
+ """
118
+ Make OSMnx keep every tag the access rules use, and every way and
119
+ node tag export writes (see tags.py). By default OSMnx discards
120
+ e.g. motor_vehicle, foot, bicycle and barrier, which silently
121
+ removes restrictions from the network. Call before any graph is
122
+ downloaded or loaded from XML.
123
+ """
124
+ ox.settings.useful_tags_way = sorted(
125
+ set(ox.settings.useful_tags_way) | mode_tag_keys()
126
+ | set(BASE_WAY_TAGS) | set(ROUTING_WAY_TAGS) | {CUSTOM_TAG}
127
+ )
128
+ ox.settings.useful_tags_node = sorted(set(ox.settings.useful_tags_node) | set(NODE_TAGS))
129
+
130
+
131
+ def _as_text(value) -> str | None:
132
+ if value is None:
133
+ return None
134
+ if isinstance(value, float) and value != value: # NaN
135
+ return None
136
+ if isinstance(value, list):
137
+ return ";".join(map(str, value))
138
+ return str(value)
139
+
140
+
141
+ def effective_access(tags: dict, mode: str) -> tuple[str, str] | None:
142
+ """(key, value) of the most specific access tag for `mode`, if any."""
143
+ for key in reversed(ACCESS_HIERARCHY.get(mode, ())):
144
+ value = _as_text(tags.get(key))
145
+ if value is not None:
146
+ return key, value
147
+ return None
148
+
149
+
150
+ def is_ferry(tags: dict) -> bool:
151
+ """A ferry or shuttle-train route: a way routers use that isn't a highway."""
152
+ return _as_text(tags.get("highway")) is None and _as_text(tags.get("route")) in FERRY_ROUTES
153
+
154
+
155
+ def allows_mode(tags: dict, mode: str) -> bool:
156
+ """True if a way with these tags is usable by `mode`."""
157
+ if is_ferry(tags):
158
+ # Open to everyone unless an access tag closes it (as Valhalla
159
+ # reads it: route=ferry alone carries cars, bikes and walkers).
160
+ access = effective_access(tags, mode)
161
+ return access is None or access[1] not in DENIED_ACCESS
162
+ if mode not in ACCESS_HIERARCHY: # download scopes "all" / "all_public"
163
+ return _passes_osmnx_filter(tags, mode)
164
+
165
+ access = effective_access(tags, mode)
166
+ if access is not None and access[1] in DENIED_ACCESS:
167
+ return False
168
+
169
+ # Opened by a mode-specific tag, not just access=yes.
170
+ granted = access is not None and access[0] != "access" and access[1] in GRANTED_ACCESS
171
+ highway = _as_text(tags.get("highway"))
172
+
173
+ if mode in CLOSED_BY_DEFAULT.get(highway, ()) and not granted:
174
+ return False
175
+ if mode in MOTORROAD_CLOSED and _as_text(tags.get("motorroad")) == "yes" and not granted:
176
+ return False
177
+
178
+ # The hierarchy above replaces OSMnx's own access clauses (e.g. its
179
+ # motor_vehicle!~no would reject motor_vehicle=no + motorcar=yes).
180
+ access_keys = set(ACCESS_HIERARCHY[mode])
181
+ ignore_highway_type = granted and highway in OPENABLE.get(mode, ())
182
+ return _passes_osmnx_filter(tags, mode, access_keys, ignore_highway_type)
183
+
184
+
185
+ def _passes_osmnx_filter(
186
+ tags: dict,
187
+ mode: str,
188
+ skip_keys: set[str] = frozenset(),
189
+ ignore_highway_type: bool = False,
190
+ ) -> bool:
191
+ """
192
+ OSMnx's network filter for `mode`, without clauses on `skip_keys`
193
+ and, if asked, without its highway-type exclusions.
194
+ """
195
+ for key, op, regex in mode_rules(mode):
196
+ if key in skip_keys or (ignore_highway_type and key == "highway" and op == "!~"):
197
+ continue
198
+ value = _as_text(tags.get(key))
199
+
200
+ if op is None:
201
+ if value is None:
202
+ return False
203
+ elif op == "~":
204
+ if value is None or not re.search(regex, value):
205
+ return False
206
+ elif value is not None and re.search(regex, value):
207
+ return False
208
+
209
+ return True
210
+
211
+
212
+ def open_to_other_vehicles(tags: dict) -> bool:
213
+ """
214
+ True if the tags open the way to a vehicle NetworkForge has no mode
215
+ for (buses, taxis, lorries ...): a busway, or e.g. bus=yes / psv=yes.
216
+ """
217
+ if _as_text(tags.get("highway")) in BUS_HIGHWAYS:
218
+ return True
219
+ return any(_as_text(tags.get(key)) in GRANTED_ACCESS for key in OTHER_VEHICLE_KEYS)
220
+
221
+
222
+ def usable_modes(tags: dict) -> list[str]:
223
+ """All modes in MODES that a way with these tags supports."""
224
+ return [mode for mode in MODES if allows_mode(tags, mode)]
225
+
226
+
227
+ def filter_graph_by_mode(graph: nx.MultiDiGraph, mode: str) -> nx.MultiDiGraph:
228
+ """
229
+ Return a copy of `graph` keeping only edges usable by `mode`.
230
+ The graph must have been loaded after keep_mode_tags().
231
+ """
232
+ if mode not in MODES:
233
+ raise InputError(f"Unknown mode {mode!r}. Choose one of: {', '.join(MODES)}")
234
+
235
+ keep = [edge for edge, data in graph.edges.items() if allows_mode(data, mode)]
236
+ return graph.edge_subgraph(keep).copy()
237
+
238
+
239
+ def load_graph(osm_file: str, mode: str) -> nx.MultiDiGraph:
240
+ """
241
+ Load an exported OSM XML file as a routable graph for one mode.
242
+
243
+ Keeps access tags, then drops edges the mode can't use. Walking
244
+ ignores oneway (OSMnx only does that when bidirectional=True).
245
+ """
246
+ keep_mode_tags()
247
+
248
+ graph = ox.graph_from_xml(
249
+ osm_file,
250
+ simplify=False,
251
+ bidirectional=(mode == "walk"),
252
+ )
253
+
254
+ return filter_graph_by_mode(graph, mode)
255
+
256
+
257
+ # Fixed speeds (km/h) for ways without a maxspeed tag. OSMnx's default
258
+ # instead imputes the mean maxspeed of each highway type across the
259
+ # whole graph, so adding custom edges (with their own maxspeed) changes
260
+ # the speed of unrelated OSM edges and baseline vs custom travel times
261
+ # stop being comparable. Every highway type is listed because OSMnx
262
+ # falls back to that mean for any type missing from this dict.
263
+ DEFAULT_SPEEDS_KPH = {
264
+ "motorway": 100, "motorway_link": 60,
265
+ "trunk": 80, "trunk_link": 50,
266
+ "primary": 60, "primary_link": 40,
267
+ "secondary": 50, "secondary_link": 40,
268
+ "tertiary": 40, "tertiary_link": 30,
269
+ "unclassified": 30, "residential": 30, "road": 30,
270
+ "living_street": 10, "service": 15, "busway": 30, "bus_guideway": 30,
271
+ "pedestrian": 5, "track": 15, "footway": 5, "cycleway": 15,
272
+ "bridleway": 5, "path": 5, "steps": 3, "corridor": 5,
273
+ }
274
+
275
+
276
+ # Ferry and shuttle-train speeds without a maxspeed or duration (Valhalla's).
277
+ FERRY_SPEEDS_KPH = {"ferry": 10, "shuttle_train": 65}
278
+
279
+
280
+ _SPEED = re.compile(r"^\s*(\d+(?:\.\d+)?)\s*(mph|knots)?\s*$")
281
+ _TO_KPH = {None: 1.0, "mph": 1.609344, "knots": 1.852}
282
+
283
+
284
+ def maxspeed_kph(value) -> float | None:
285
+ """
286
+ A maxspeed tag in km/h: "50" (km/h), "30 mph", "20 knots", or
287
+ ";"-separated values (averaged, as OSMnx does). None if there's no
288
+ number above 0 (e.g. "signals", "GB:nsl_single", "0"): a speed of 0
289
+ would make every trip on the way take forever.
290
+ """
291
+ text = _as_text(value)
292
+ if text is None:
293
+ return None
294
+ speeds = [float(m[1]) * _TO_KPH[m[2]] for m in map(_SPEED.match, text.split(";"))
295
+ if m and float(m[1]) > 0]
296
+ return sum(speeds) / len(speeds) if speeds else None
297
+
298
+
299
+ def car_speed_kph(tags: dict) -> float:
300
+ """
301
+ Car speed for a way: its maxspeed, else DEFAULT_SPEEDS_KPH for its
302
+ highway type (30 km/h if unknown) - the same rule add_travel_times uses.
303
+ Ferries: FERRY_SPEEDS_KPH (a `duration` is applied per line, by
304
+ export.analysis_edges).
305
+ """
306
+ speed = maxspeed_kph(tags.get("maxspeed"))
307
+ if speed is None and is_ferry(tags):
308
+ speed = FERRY_SPEEDS_KPH[_as_text(tags.get("route"))]
309
+ if speed is None:
310
+ speed = DEFAULT_SPEEDS_KPH.get(_as_text(tags.get("highway")), 30.0)
311
+ return float(speed)
312
+
313
+
314
+ def add_travel_times(graph: nx.MultiDiGraph) -> nx.MultiDiGraph:
315
+ """
316
+ Add speed_kph and travel_time (car speeds) to every edge. Edges
317
+ keep their own maxspeed; untagged ones get DEFAULT_SPEEDS_KPH, so
318
+ the same road gets the same speed in the baseline and custom graphs.
319
+ Ferries get FERRY_SPEEDS_KPH (a `duration` is not applied here).
320
+ """
321
+ # OSMnx needs a highway type to give a speed: lend ferries their route.
322
+ ferries = [(u, v, k) for u, v, k, data in graph.edges(keys=True, data=True)
323
+ if is_ferry(data)]
324
+ for edge in ferries:
325
+ graph.edges[edge]["highway"] = graph.edges[edge]["route"]
326
+ # OSMnx reads maxspeed=0 as 0 km/h (an endless trip): hide it, as
327
+ # maxspeed_kph does, so the way gets its type's speed.
328
+ zero = {(u, v, k): data.pop("maxspeed") for u, v, k, data in graph.edges(keys=True, data=True)
329
+ if "maxspeed" in data and maxspeed_kph(data["maxspeed"]) is None}
330
+ graph = ox.add_edge_speeds(graph, hwy_speeds={**DEFAULT_SPEEDS_KPH, **FERRY_SPEEDS_KPH},
331
+ fallback=30)
332
+ for edge in ferries:
333
+ del graph.edges[edge]["highway"]
334
+ for edge, value in zero.items():
335
+ graph.edges[edge]["maxspeed"] = value
336
+ return ox.add_edge_travel_times(graph)