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
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)
|