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/export.py
ADDED
|
@@ -0,0 +1,1056 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Write a NetworkForge network out.
|
|
3
|
+
|
|
4
|
+
Two kinds of output, for two audiences:
|
|
5
|
+
|
|
6
|
+
- write_osm(): OpenStreetMap data (.osm, .osm.pbf, ...) for routing
|
|
7
|
+
engines such as Valhalla and GraphHopper, which read the tags
|
|
8
|
+
themselves.
|
|
9
|
+
- write_gpkg(): a GeoPackage for QGIS, with ready-made analysis columns
|
|
10
|
+
(which modes may use each edge, speed, travel times, direction) for
|
|
11
|
+
QGIS's network analysis tools. See analysis_edges().
|
|
12
|
+
|
|
13
|
+
write_osm() picks the format from the file name:
|
|
14
|
+
|
|
15
|
+
network.osm OSM XML
|
|
16
|
+
network.osm.pbf (or .pbf) OSM PBF: compressed binary, what routers
|
|
17
|
+
such as GraphHopper and Valhalla prefer
|
|
18
|
+
network.osm.gz / .osm.bz2 compressed OSM XML
|
|
19
|
+
|
|
20
|
+
XML is written by this module; the other formats by pyosmium. Both get
|
|
21
|
+
the same content from prepare_osm_data(), so an XML and a PBF export of
|
|
22
|
+
one network hold identical nodes, ways, relations and tags. Each kind
|
|
23
|
+
is sorted by id, as tools like osmium expect.
|
|
24
|
+
|
|
25
|
+
The existing network is written as OpenStreetMap has it (see
|
|
26
|
+
osm.OSMSource): each way keeps its id, its nodes in order and every
|
|
27
|
+
tag, tagged nodes keep theirs, and turn restrictions and route
|
|
28
|
+
relations are kept. The build only adds to it: where a custom line
|
|
29
|
+
joins an existing way mid-way, the new junction node is inserted into
|
|
30
|
+
that way. This matters to routers - Valhalla reads far more tags than
|
|
31
|
+
the edge table holds, obeys turn restrictions (which name way ids), and
|
|
32
|
+
measures curvature and junction density from whole ways.
|
|
33
|
+
|
|
34
|
+
A stretch of a way that a feature changed (edits.py) is written as its
|
|
35
|
+
own way with the changed tags and `nf:modified=yes`; the rest of the way
|
|
36
|
+
keeps its id. Turn restrictions follow the piece their via node is on.
|
|
37
|
+
|
|
38
|
+
Each custom line is one way with a new id (above every OSM way id),
|
|
39
|
+
its nodes being the line's vertices and junctions, tagged with the
|
|
40
|
+
attributes listed in tags.py plus `nf:custom=yes`.
|
|
41
|
+
|
|
42
|
+
Edges without a source (tables not made by build_network, e.g. straight
|
|
43
|
+
from OSMnx) are still written: ways are rebuilt from the `osmid` column
|
|
44
|
+
and tags come from the columns.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
import logging
|
|
48
|
+
import re
|
|
49
|
+
from collections import defaultdict
|
|
50
|
+
from dataclasses import dataclass
|
|
51
|
+
from pathlib import Path
|
|
52
|
+
from xml.sax.saxutils import quoteattr
|
|
53
|
+
|
|
54
|
+
import geopandas as gpd
|
|
55
|
+
import numpy as np
|
|
56
|
+
import osmium
|
|
57
|
+
import pandas as pd
|
|
58
|
+
import shapely
|
|
59
|
+
import shapely.ops
|
|
60
|
+
|
|
61
|
+
from .errors import InputError
|
|
62
|
+
from .modes import allows_mode, car_speed_kph, mode_tag_keys
|
|
63
|
+
from .osm import SOURCE_ATTR, OSMSource
|
|
64
|
+
from .paths import writable
|
|
65
|
+
from .tags import (
|
|
66
|
+
BASE_WAY_TAGS,
|
|
67
|
+
CUSTOM_COLUMN,
|
|
68
|
+
CUSTOM_TAG,
|
|
69
|
+
EDIT_COLUMN,
|
|
70
|
+
EDITS_ATTR,
|
|
71
|
+
FERRY_ROUTES,
|
|
72
|
+
GPKG_ANALYSIS_COLUMNS,
|
|
73
|
+
MODIFIED_TAG,
|
|
74
|
+
NODE_CHANGES_ATTR,
|
|
75
|
+
NODE_TAGS,
|
|
76
|
+
PART_COLUMN,
|
|
77
|
+
ROUTING_WAY_TAGS,
|
|
78
|
+
TURNS_ATTR,
|
|
79
|
+
)
|
|
80
|
+
from .turns import relation_tags
|
|
81
|
+
from .validation import DURATION_PATTERN
|
|
82
|
+
|
|
83
|
+
log = logging.getLogger(__name__)
|
|
84
|
+
|
|
85
|
+
GENERATOR = "NetworkForge"
|
|
86
|
+
|
|
87
|
+
# Constant speeds for bike and walk travel times in the GeoPackage.
|
|
88
|
+
BIKE_KPH = 15.0
|
|
89
|
+
WALK_KPH = 5.0
|
|
90
|
+
|
|
91
|
+
# Edge columns that only mean something inside the pipeline.
|
|
92
|
+
INTERNAL_COLUMNS = ["key", "split", "reversed", "length", PART_COLUMN, EDIT_COLUMN]
|
|
93
|
+
|
|
94
|
+
MEMBER_TYPES = {"n": "node", "w": "way", "r": "relation"}
|
|
95
|
+
|
|
96
|
+
XML_SUFFIXES = (".osm",)
|
|
97
|
+
OSMIUM_SUFFIXES = (".osm.pbf", ".pbf", ".osm.gz", ".osm.bz2")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass(frozen=True)
|
|
101
|
+
class OSMData:
|
|
102
|
+
"""A network ready to write: WGS84 coordinates, string tags, sorted by id."""
|
|
103
|
+
|
|
104
|
+
bounds: tuple[float, float, float, float] # min_lon, min_lat, max_lon, max_lat
|
|
105
|
+
nodes: list[tuple[int, float, float, dict[str, str]]] # id, lon, lat, tags
|
|
106
|
+
ways: list[tuple[int, list[int], dict[str, str]]] # id, node refs, tags
|
|
107
|
+
# id, [(member type "n"/"w"/"r", ref, role)], tags
|
|
108
|
+
relations: list[tuple[int, list[tuple[str, int, str]], dict[str, str]]] = ()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def way_tag_columns() -> dict[str, str]:
|
|
112
|
+
"""Edge column -> OSM tag key, for every way tag export writes."""
|
|
113
|
+
columns = {key: key for key in BASE_WAY_TAGS}
|
|
114
|
+
for key in [*sorted(mode_tag_keys()), *ROUTING_WAY_TAGS]:
|
|
115
|
+
columns.setdefault(key, key)
|
|
116
|
+
columns[CUSTOM_COLUMN] = CUSTOM_TAG
|
|
117
|
+
return columns
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def write_osm(
|
|
121
|
+
nodes_gdf: gpd.GeoDataFrame,
|
|
122
|
+
edges_gdf: gpd.GeoDataFrame,
|
|
123
|
+
output_file_path: str | Path,
|
|
124
|
+
data: OSMData | None = None,
|
|
125
|
+
) -> None:
|
|
126
|
+
"""
|
|
127
|
+
Write the network to `output_file_path`, in the format its name
|
|
128
|
+
implies (.osm, .osm.pbf, .pbf, .osm.gz, .osm.bz2). An existing file
|
|
129
|
+
is replaced. `data`: prepare_osm_data() of these tables, if already
|
|
130
|
+
made.
|
|
131
|
+
"""
|
|
132
|
+
name = str(output_file_path).lower()
|
|
133
|
+
|
|
134
|
+
if name.endswith(XML_SUFFIXES):
|
|
135
|
+
_write_xml(data or prepare_osm_data(nodes_gdf, edges_gdf), output_file_path)
|
|
136
|
+
elif name.endswith(OSMIUM_SUFFIXES):
|
|
137
|
+
_write_with_osmium(data or prepare_osm_data(nodes_gdf, edges_gdf), output_file_path)
|
|
138
|
+
else:
|
|
139
|
+
raise InputError(
|
|
140
|
+
f"Can't tell the output format from {Path(output_file_path).name!r}. "
|
|
141
|
+
f"Use one of: {', '.join(XML_SUFFIXES + OSMIUM_SUFFIXES)}"
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def write_gpkg(
|
|
146
|
+
nodes_gdf: gpd.GeoDataFrame,
|
|
147
|
+
edges_gdf: gpd.GeoDataFrame,
|
|
148
|
+
output_file_path: str | Path,
|
|
149
|
+
osm_data: OSMData | None = None,
|
|
150
|
+
) -> None:
|
|
151
|
+
"""
|
|
152
|
+
Write a GeoPackage for QGIS with a 'nodes', an 'edges' and a
|
|
153
|
+
'restrictions' layer. Edges carry the analysis columns from
|
|
154
|
+
analysis_edges(); restrictions are the turn restrictions of the OSM
|
|
155
|
+
file written from the same tables (restriction_lines(), always a
|
|
156
|
+
layer, empty when there are none). An existing file is replaced.
|
|
157
|
+
`osm_data`: prepare_osm_data() of these tables, if already made.
|
|
158
|
+
"""
|
|
159
|
+
path = Path(output_file_path)
|
|
160
|
+
path.unlink(missing_ok=True)
|
|
161
|
+
|
|
162
|
+
if osm_data is None and _may_have_restrictions(edges_gdf):
|
|
163
|
+
osm_data = prepare_osm_data(nodes_gdf, edges_gdf)
|
|
164
|
+
restrictions = (restriction_lines(osm_data, nodes_gdf.crs) if osm_data is not None
|
|
165
|
+
else _no_restrictions(nodes_gdf.crs))
|
|
166
|
+
layers = {
|
|
167
|
+
"nodes": nodes_gdf.reset_index(),
|
|
168
|
+
"edges": analysis_edges(nodes_gdf, edges_gdf),
|
|
169
|
+
"restrictions": restrictions,
|
|
170
|
+
}
|
|
171
|
+
for name, gdf in layers.items():
|
|
172
|
+
gdf = gdf.rename(columns=_gpkg_names(gdf.columns.drop(gdf.geometry.name)))
|
|
173
|
+
for column in gdf.columns.drop(gdf.geometry.name):
|
|
174
|
+
if gdf[column].dtype == object: # OSMnx can merge values into lists
|
|
175
|
+
gdf[column] = gdf[column].map(
|
|
176
|
+
lambda v: ";".join(map(str, v)) if isinstance(v, list) else v)
|
|
177
|
+
# The geometry type, for a layer with no rows too.
|
|
178
|
+
geometry_type = "LineString" if name != "nodes" else "Point"
|
|
179
|
+
gdf.to_file(path, layer=name, driver="GPKG",
|
|
180
|
+
geometry_type=geometry_type if gdf.empty else None)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# How far a restriction's line runs along each street from the junction.
|
|
184
|
+
RESTRICTION_REACH_M = 20.0
|
|
185
|
+
# The restrictions layer's own columns; the relation's tags follow.
|
|
186
|
+
RESTRICTION_COLUMNS = ("relation_id", "from_way", "via_node", "via_ways", "to_way",
|
|
187
|
+
CUSTOM_COLUMN)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _may_have_restrictions(edges_gdf: gpd.GeoDataFrame) -> bool:
|
|
191
|
+
"""Whether the OSM file would hold a turn restriction (cheap test)."""
|
|
192
|
+
source = edges_gdf.attrs.get(SOURCE_ATTR)
|
|
193
|
+
return bool(edges_gdf.attrs.get(TURNS_ATTR)) or (
|
|
194
|
+
source is not None
|
|
195
|
+
and any(tags.get("type") == "restriction" for _, tags, _ in source.relations))
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _no_restrictions(crs) -> gpd.GeoDataFrame:
|
|
199
|
+
return gpd.GeoDataFrame({column: pd.Series(dtype=object) for column in RESTRICTION_COLUMNS},
|
|
200
|
+
geometry=gpd.GeoSeries([], crs=crs))
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def restriction_lines(data: OSMData, crs) -> gpd.GeoDataFrame:
|
|
204
|
+
"""
|
|
205
|
+
The turn restrictions in `data` as lines a map can show: along the
|
|
206
|
+
from street to the junction (RESTRICTION_REACH_M of it at most),
|
|
207
|
+
through the via node or along the via way(s), and out along the to
|
|
208
|
+
street. One row per restriction relation, with its id, its member
|
|
209
|
+
ways and node, `custom` = yes for the user's own (`nf:custom`), and
|
|
210
|
+
its tags as columns (restriction or restriction:hgv ..., except ...).
|
|
211
|
+
A relation whose members don't meet as OSM's rules say is left out.
|
|
212
|
+
"""
|
|
213
|
+
ways = {way_id: refs for way_id, refs, _ in data.ways}
|
|
214
|
+
found = [] # (row, from side, via path, to side): node ids
|
|
215
|
+
for relation_id, members, tags in data.relations:
|
|
216
|
+
if tags.get("type") != "restriction":
|
|
217
|
+
continue
|
|
218
|
+
path = _restriction_path(members, ways)
|
|
219
|
+
if path is None:
|
|
220
|
+
log.debug("Turn restriction %d: members don't meet; not in the GeoPackage.",
|
|
221
|
+
relation_id)
|
|
222
|
+
continue
|
|
223
|
+
from_way, via_node, via_ways, to_way, sides = path
|
|
224
|
+
row = {"relation_id": relation_id, "from_way": from_way, "via_node": via_node,
|
|
225
|
+
"via_ways": ";".join(map(str, via_ways)) or None, "to_way": to_way,
|
|
226
|
+
CUSTOM_COLUMN: "yes" if tags.get(CUSTOM_TAG) == "yes" else None}
|
|
227
|
+
row |= {key: value for key, value in tags.items() if key not in ("type", CUSTOM_TAG)}
|
|
228
|
+
found.append((row, *sides))
|
|
229
|
+
if not found:
|
|
230
|
+
return _no_restrictions(crs)
|
|
231
|
+
|
|
232
|
+
# Every node used, projected once.
|
|
233
|
+
used = sorted({node for _, *sides in found for side in sides for node in side})
|
|
234
|
+
where = {node_id: (lon, lat) for node_id, lon, lat, _ in data.nodes}
|
|
235
|
+
lon_lat = np.array([where[node] for node in used])
|
|
236
|
+
points = gpd.GeoSeries(gpd.points_from_xy(lon_lat[:, 0], lon_lat[:, 1]), crs="EPSG:4326")
|
|
237
|
+
xy = dict(zip(used, shapely.get_coordinates(points.to_crs(crs).values), strict=True))
|
|
238
|
+
|
|
239
|
+
rows, lines = [], []
|
|
240
|
+
for row, from_side, via, to_side in found:
|
|
241
|
+
arrive = shapely.LineString([xy[node] for node in from_side])
|
|
242
|
+
leave = shapely.LineString([xy[node] for node in to_side])
|
|
243
|
+
arrive = shapely.ops.substring(arrive, max(arrive.length - RESTRICTION_REACH_M, 0),
|
|
244
|
+
arrive.length)
|
|
245
|
+
leave = shapely.ops.substring(leave, 0, min(RESTRICTION_REACH_M, leave.length))
|
|
246
|
+
coords = [*arrive.coords, *(xy[node] for node in via[1:]), *leave.coords[1:]]
|
|
247
|
+
rows.append(row)
|
|
248
|
+
lines.append(shapely.LineString(coords))
|
|
249
|
+
table = pd.DataFrame(rows)
|
|
250
|
+
for column in ("relation_id", "from_way", "to_way"):
|
|
251
|
+
table[column] = table[column].astype("int64")
|
|
252
|
+
table["via_node"] = table["via_node"].astype("Int64")
|
|
253
|
+
return gpd.GeoDataFrame(table, geometry=gpd.GeoSeries(lines, crs=crs))
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _restriction_path(members: list[tuple[str, int, str]], ways: dict[int, list[int]]):
|
|
257
|
+
"""
|
|
258
|
+
(from way, via node or None, via ways, to way, (from side, via path,
|
|
259
|
+
to side)) for a restriction relation, or None if it can't be drawn.
|
|
260
|
+
The from side is node ids along the from way ending at the junction,
|
|
261
|
+
the via path runs from that junction to the one the to side starts at.
|
|
262
|
+
"""
|
|
263
|
+
def first(role):
|
|
264
|
+
return next((ref for kind, ref, r in members if kind == "w" and r == role), None)
|
|
265
|
+
|
|
266
|
+
from_way, to_way = first("from"), first("to")
|
|
267
|
+
via_node = next((ref for kind, ref, role in members if kind == "n" and role == "via"), None)
|
|
268
|
+
via_ways = [ref for kind, ref, role in members if kind == "w" and role == "via"]
|
|
269
|
+
if from_way not in ways or to_way not in ways or any(w not in ways for w in via_ways):
|
|
270
|
+
return None
|
|
271
|
+
from_refs, to_refs = ways[from_way], ways[to_way]
|
|
272
|
+
|
|
273
|
+
if via_node is not None:
|
|
274
|
+
via = [via_node]
|
|
275
|
+
elif via_ways:
|
|
276
|
+
ends = {ways[via_ways[0]][0], ways[via_ways[0]][-1]} & set(from_refs)
|
|
277
|
+
if not ends:
|
|
278
|
+
return None
|
|
279
|
+
via = [ends.pop()]
|
|
280
|
+
for way in via_ways:
|
|
281
|
+
refs = ways[way]
|
|
282
|
+
if refs[0] == via[-1]:
|
|
283
|
+
via += refs[1:]
|
|
284
|
+
elif refs[-1] == via[-1]:
|
|
285
|
+
via += refs[::-1][1:]
|
|
286
|
+
else:
|
|
287
|
+
return None
|
|
288
|
+
else:
|
|
289
|
+
return None
|
|
290
|
+
|
|
291
|
+
def side(refs, junction, arriving):
|
|
292
|
+
"""The longer stretch of the way from the junction (toward it if arriving)."""
|
|
293
|
+
if junction not in refs:
|
|
294
|
+
return None
|
|
295
|
+
i = refs.index(junction)
|
|
296
|
+
before, after = refs[:i + 1], refs[i:]
|
|
297
|
+
stretch = (before if arriving else before[::-1]) if len(before) >= len(after) else (
|
|
298
|
+
after[::-1] if arriving else after)
|
|
299
|
+
return stretch if len(stretch) >= 2 else None
|
|
300
|
+
|
|
301
|
+
from_side, to_side = side(from_refs, via[0], True), side(to_refs, via[-1], False)
|
|
302
|
+
if from_side is None or to_side is None:
|
|
303
|
+
return None
|
|
304
|
+
return from_way, via_node, via_ways, to_way, (from_side, via, to_side)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _gpkg_names(columns) -> dict:
|
|
308
|
+
"""
|
|
309
|
+
New names for the columns a GeoPackage can't hold as they are: `fid`
|
|
310
|
+
is its own row id, and its field names don't tell capitals from
|
|
311
|
+
small letters (a `Highway` attribute beside `highway`). They get a
|
|
312
|
+
trailing underscore.
|
|
313
|
+
"""
|
|
314
|
+
renamed, taken = {}, {"fid"}
|
|
315
|
+
for column in columns:
|
|
316
|
+
name = str(column)
|
|
317
|
+
while name.lower() in taken:
|
|
318
|
+
name += "_"
|
|
319
|
+
taken.add(name.lower())
|
|
320
|
+
if name != column:
|
|
321
|
+
renamed[column] = name
|
|
322
|
+
if renamed:
|
|
323
|
+
log.info("GeoPackage: column(s) renamed - %s",
|
|
324
|
+
", ".join(f"{old} to {new}" for old, new in renamed.items()))
|
|
325
|
+
return renamed
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def analysis_edges(nodes_gdf: gpd.GeoDataFrame, edges_gdf: gpd.GeoDataFrame) -> gpd.GeoDataFrame:
|
|
329
|
+
"""
|
|
330
|
+
Edges ready for QGIS network analysis (Service area, Shortest path,
|
|
331
|
+
QNEAT3). Compared with the pipeline's edges:
|
|
332
|
+
|
|
333
|
+
- one row per street: OSMnx's reverse copy of each two-way street
|
|
334
|
+
is dropped (the direction columns say which way you can travel);
|
|
335
|
+
- each geometry runs exactly from its u node to its v node, so QGIS
|
|
336
|
+
connects edges at shared nodes;
|
|
337
|
+
- added columns:
|
|
338
|
+
car, bike, walk may this mode use the edge (same rules as routing)
|
|
339
|
+
speed_kph car speed: maxspeed, else a default per road type
|
|
340
|
+
length_m edge length in metres, measured in the (local, metric)
|
|
341
|
+
analysis CRS - up to ~0.5% longer than OSMnx's
|
|
342
|
+
spherical great-circle lengths, and more accurate
|
|
343
|
+
car_minutes, bike_minutes, walk_minutes
|
|
344
|
+
travel times (bike 15 km/h, walk 5 km/h)
|
|
345
|
+
car_direction, bike_direction
|
|
346
|
+
forward / backward / both, relative to the line's
|
|
347
|
+
direction (walking is always both). Bikes follow
|
|
348
|
+
oneway:bicycle=no / cycleway=opposite* (contraflow).
|
|
349
|
+
"""
|
|
350
|
+
edges = edges_gdf.copy()
|
|
351
|
+
|
|
352
|
+
oneway = edges["oneway"] if "oneway" in edges else pd.Series(None, index=edges.index)
|
|
353
|
+
car_direction = oneway.map(_direction)
|
|
354
|
+
if "junction" in edges:
|
|
355
|
+
# A roundabout is one-way in its drawn direction without saying so
|
|
356
|
+
# (OSM's rule, and Valhalla's). OSMnx already marks existing ones;
|
|
357
|
+
# this covers new lines.
|
|
358
|
+
implied = (oneway.isna() & edges["junction"].isin(["roundabout", "circular"])).to_numpy()
|
|
359
|
+
car_direction[implied] = "forward"
|
|
360
|
+
reverse_copy = reverse_copies(edges)
|
|
361
|
+
edges, car_direction = edges[~reverse_copy], car_direction[~reverse_copy]
|
|
362
|
+
if "oneway" in edges:
|
|
363
|
+
# OSM text throughout: OSMnx stores yes / no as True / False on
|
|
364
|
+
# existing streets, while new and changed lines carry the tag text
|
|
365
|
+
# - mixed, the GeoPackage got True, False and yes in one column.
|
|
366
|
+
edges["oneway"] = edges["oneway"].map(_oneway_text)
|
|
367
|
+
|
|
368
|
+
nodes = nodes_gdf.geometry
|
|
369
|
+
edges = edges.set_geometry(
|
|
370
|
+
shapely.linestrings(np.stack([
|
|
371
|
+
shapely.get_coordinates(nodes.loc[edges["u"].astype("int64")].to_numpy()),
|
|
372
|
+
shapely.get_coordinates(nodes.loc[edges["v"].astype("int64")].to_numpy()),
|
|
373
|
+
], axis=1)),
|
|
374
|
+
crs=nodes_gdf.crs,
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
# Access and speed depend only on tags; evaluate each distinct
|
|
378
|
+
# combination once (a few thousand), not each row.
|
|
379
|
+
keys = [k for k in sorted(mode_tag_keys() | {"highway", "maxspeed", "route"})
|
|
380
|
+
if k in edges.columns]
|
|
381
|
+
combos = list(zip(*(edges[k].map(_hashable) for k in keys), strict=True)) if keys else [()]
|
|
382
|
+
results = {}
|
|
383
|
+
for combo in set(combos):
|
|
384
|
+
tags = {k: v for k, v in zip(keys, combo, strict=True) if v is not None}
|
|
385
|
+
results[combo] = (allows_mode(tags, "drive"), allows_mode(tags, "bike"),
|
|
386
|
+
allows_mode(tags, "walk"), car_speed_kph(tags))
|
|
387
|
+
car, bike, walk, speed = (np.array(v) for v in zip(*(results[c] for c in combos), strict=True))
|
|
388
|
+
|
|
389
|
+
length = edges.geometry.length.to_numpy()
|
|
390
|
+
bike_direction = car_direction.copy()
|
|
391
|
+
if "oneway:bicycle" in edges:
|
|
392
|
+
bike_direction[edges["oneway:bicycle"].astype(str).eq("no")] = "both"
|
|
393
|
+
if "cycleway" in edges: # the older way to tag contraflow cycling
|
|
394
|
+
bike_direction[edges["cycleway"].astype(str).str.startswith("opposite")] = "both"
|
|
395
|
+
|
|
396
|
+
speed = speed.astype(float)
|
|
397
|
+
bike_speed = np.full(len(edges), BIKE_KPH)
|
|
398
|
+
walk_speed = np.full(len(edges), WALK_KPH)
|
|
399
|
+
ferry = _ferry_speeds(edges, length)
|
|
400
|
+
if ferry is not None:
|
|
401
|
+
# On a ferry everyone travels at the boat's speed.
|
|
402
|
+
on_board = ~np.isnan(ferry)
|
|
403
|
+
speed[on_board] = bike_speed[on_board] = walk_speed[on_board] = ferry[on_board]
|
|
404
|
+
|
|
405
|
+
analysis = pd.DataFrame({
|
|
406
|
+
"car": car.astype(bool),
|
|
407
|
+
"bike": bike.astype(bool),
|
|
408
|
+
"walk": walk.astype(bool),
|
|
409
|
+
"speed_kph": speed.round(1),
|
|
410
|
+
"length_m": length.round(2),
|
|
411
|
+
"car_minutes": (length / 1000 / speed * 60).round(4),
|
|
412
|
+
"bike_minutes": (length / 1000 / bike_speed * 60).round(4),
|
|
413
|
+
"walk_minutes": (length / 1000 / walk_speed * 60).round(4),
|
|
414
|
+
"car_direction": car_direction.to_numpy(),
|
|
415
|
+
"bike_direction": bike_direction.to_numpy(),
|
|
416
|
+
}, index=edges.index)
|
|
417
|
+
|
|
418
|
+
# Columns already called what the analysis columns are give way to them.
|
|
419
|
+
edges = edges.drop(columns=[c for c in (*INTERNAL_COLUMNS, *GPKG_ANALYSIS_COLUMNS)
|
|
420
|
+
if c in edges.columns])
|
|
421
|
+
return pd.concat([edges, analysis], axis=1).reset_index(drop=True)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _ferry_speeds(edges: gpd.GeoDataFrame, length: np.ndarray) -> np.ndarray | None:
|
|
425
|
+
"""
|
|
426
|
+
Speed (km/h) of each ferry edge, NaN for other edges; None if there
|
|
427
|
+
are no ferries. A `duration` (hh:mm[:ss]) is the time for the whole
|
|
428
|
+
line, as routers read it; otherwise maxspeed or the default speed.
|
|
429
|
+
"""
|
|
430
|
+
if "route" not in edges:
|
|
431
|
+
return None
|
|
432
|
+
no_highway = edges["highway"].isna() if "highway" in edges else True
|
|
433
|
+
ferry = (no_highway & edges["route"].isin(FERRY_ROUTES)).to_numpy(dtype=bool)
|
|
434
|
+
if not ferry.any():
|
|
435
|
+
return None
|
|
436
|
+
|
|
437
|
+
speeds = np.full(len(edges), np.nan)
|
|
438
|
+
maxspeed = edges["maxspeed"] if "maxspeed" in edges else pd.Series(None, index=edges.index)
|
|
439
|
+
speeds[ferry] = [car_speed_kph({"route": route, "maxspeed": limit})
|
|
440
|
+
for route, limit in zip(edges["route"][ferry], maxspeed[ferry], strict=True)]
|
|
441
|
+
if "duration" in edges:
|
|
442
|
+
hours = edges["duration"].map(_duration_hours).to_numpy(dtype=float)
|
|
443
|
+
timed = ferry & (hours > 0) # NaN compares False
|
|
444
|
+
line = edges[PART_COLUMN] if PART_COLUMN in edges else pd.Series(range(len(edges)))
|
|
445
|
+
line_length = pd.Series(length).groupby(line.to_numpy()).transform("sum").to_numpy()
|
|
446
|
+
speeds[timed] = line_length[timed] / 1000 / hours[timed]
|
|
447
|
+
return speeds
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _duration_hours(value) -> float:
|
|
451
|
+
"""An OSM duration (hh:mm or hh:mm:ss) in hours; NaN if it isn't one."""
|
|
452
|
+
if not isinstance(value, str) or not DURATION_PATTERN.match(value):
|
|
453
|
+
return np.nan
|
|
454
|
+
parts = [int(part) for part in value.split(":")] + [0]
|
|
455
|
+
return parts[0] + parts[1] / 60 + parts[2] / 3600
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def reverse_copies(edges: pd.DataFrame) -> pd.Series:
|
|
459
|
+
"""
|
|
460
|
+
The rows that are OSMnx's second copy of a two-way street (the same
|
|
461
|
+
street, drawn the other way). Without them there is one row per
|
|
462
|
+
street, as in the GeoPackage and in the counts the CLI reports.
|
|
463
|
+
"""
|
|
464
|
+
if "reversed" not in edges:
|
|
465
|
+
return pd.Series(False, index=edges.index)
|
|
466
|
+
oneway = edges["oneway"] if "oneway" in edges else pd.Series(None, index=edges.index)
|
|
467
|
+
return (edges["reversed"] == True) & (oneway.map(_direction) == "both") # noqa: E712
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _oneway_text(value):
|
|
471
|
+
"""An oneway value as OSM text (OSMnx's True / False become yes / no)."""
|
|
472
|
+
if isinstance(value, bool | np.bool_):
|
|
473
|
+
return "yes" if value else "no"
|
|
474
|
+
if value in ("True", "False"):
|
|
475
|
+
return "yes" if value == "True" else "no"
|
|
476
|
+
return value
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _direction(oneway) -> str:
|
|
480
|
+
"""QGIS direction value for an oneway tag, relative to the edge's direction."""
|
|
481
|
+
text = str(oneway).lower() if oneway is not None and oneway == oneway else ""
|
|
482
|
+
if text in ("true", "yes", "1"):
|
|
483
|
+
return "forward"
|
|
484
|
+
if text in ("-1", "reverse"):
|
|
485
|
+
return "backward"
|
|
486
|
+
return "both"
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def _hashable(value):
|
|
490
|
+
if isinstance(value, list):
|
|
491
|
+
return ";".join(map(str, value))
|
|
492
|
+
if value is None or (isinstance(value, float) and value != value):
|
|
493
|
+
return None
|
|
494
|
+
return value
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def write_osm_xml(
|
|
498
|
+
combined_points_gdf: gpd.GeoDataFrame,
|
|
499
|
+
split_lines_combined_gdf: gpd.GeoDataFrame,
|
|
500
|
+
output_file_path: str | Path,
|
|
501
|
+
) -> None:
|
|
502
|
+
"""Write the network as OSM XML, whatever the file name (see write_osm)."""
|
|
503
|
+
_write_xml(prepare_osm_data(combined_points_gdf, split_lines_combined_gdf), output_file_path)
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def prepare_osm_data(nodes_gdf: gpd.GeoDataFrame, edges_gdf: gpd.GeoDataFrame) -> OSMData:
|
|
507
|
+
"""
|
|
508
|
+
Convert network GeoDataFrames (any CRS) to OSMData. Edges must have
|
|
509
|
+
`u` and `v` columns referencing the node index.
|
|
510
|
+
"""
|
|
511
|
+
|
|
512
|
+
for label, gdf in (("Node", nodes_gdf), ("Edge", edges_gdf)):
|
|
513
|
+
if gdf.crs is None:
|
|
514
|
+
raise InputError(f"{label} GeoDataFrame must have a CRS assigned.")
|
|
515
|
+
for column in ("u", "v"):
|
|
516
|
+
if column not in edges_gdf.columns:
|
|
517
|
+
raise InputError(f"Edge GeoDataFrame is missing required column: {column}")
|
|
518
|
+
|
|
519
|
+
source = edges_gdf.attrs.get(SOURCE_ATTR)
|
|
520
|
+
nodes = nodes_gdf.to_crs("EPSG:4326")
|
|
521
|
+
|
|
522
|
+
# Every edge runs between nodes, so the nodes give the extent.
|
|
523
|
+
min_lon, min_lat, max_lon, max_lat = nodes.total_bounds
|
|
524
|
+
bounds = (round(min_lon, 7), round(min_lat, 7), round(max_lon, 7), round(max_lat, 7))
|
|
525
|
+
|
|
526
|
+
node_ids = nodes.index.astype("int64").tolist()
|
|
527
|
+
if source is None:
|
|
528
|
+
node_tags = _row_tags(nodes, {key: key for key in NODE_TAGS})
|
|
529
|
+
else:
|
|
530
|
+
node_tags = [source.node_tags.get(node_id, {}) for node_id in node_ids]
|
|
531
|
+
node_changes = edges_gdf.attrs.get(NODE_CHANGES_ATTR) or {}
|
|
532
|
+
if node_changes:
|
|
533
|
+
# Points in the custom layer: tags set or (None) deleted.
|
|
534
|
+
node_tags = [
|
|
535
|
+
{k: v for k, v in {**tags, **node_changes[node_id]}.items() if v is not None}
|
|
536
|
+
if node_id in node_changes else tags
|
|
537
|
+
for node_id, tags in zip(node_ids, node_tags, strict=True)
|
|
538
|
+
]
|
|
539
|
+
osm_nodes = list(zip(
|
|
540
|
+
node_ids,
|
|
541
|
+
[round(x, 7) for x in nodes.geometry.x.tolist()],
|
|
542
|
+
[round(y, 7) for y in nodes.geometry.y.tolist()],
|
|
543
|
+
node_tags,
|
|
544
|
+
strict=True,
|
|
545
|
+
))
|
|
546
|
+
if source is not None:
|
|
547
|
+
# Ferry routes aren't in the edge table: add the nodes only they use.
|
|
548
|
+
in_network = set(node_ids)
|
|
549
|
+
for node_id, (lon, lat) in source.ferry_nodes.items():
|
|
550
|
+
if node_id not in in_network:
|
|
551
|
+
node_ids.append(node_id)
|
|
552
|
+
osm_nodes.append((node_id, round(lon, 7), round(lat, 7),
|
|
553
|
+
source.node_tags.get(node_id, {})))
|
|
554
|
+
if source.ferry_nodes:
|
|
555
|
+
lons, lats = zip(*source.ferry_nodes.values(), strict=True)
|
|
556
|
+
bounds = (min(bounds[0], round(min(lons), 7)), min(bounds[1], round(min(lats), 7)),
|
|
557
|
+
max(bounds[2], round(max(lons), 7)), max(bounds[3], round(max(lats), 7)))
|
|
558
|
+
osm_nodes.sort()
|
|
559
|
+
|
|
560
|
+
is_custom = (edges_gdf[CUSTOM_COLUMN] == "yes" if CUSTOM_COLUMN in edges_gdf
|
|
561
|
+
else pd.Series(False, index=edges_gdf.index))
|
|
562
|
+
existing = edges_gdf[~is_custom]
|
|
563
|
+
if "reversed" in existing:
|
|
564
|
+
# OSMnx holds a two-way street as two edges; one way covers both.
|
|
565
|
+
existing = existing[existing["reversed"] != True] # noqa: E712
|
|
566
|
+
|
|
567
|
+
# (way id or None for "needs a new one", node refs, tags, id of the
|
|
568
|
+
# OSM way it is a piece of, or None)
|
|
569
|
+
ways = _existing_ways(existing, source, edges_gdf.attrs.get(EDITS_ATTR) or {})
|
|
570
|
+
ways += _custom_ways(edges_gdf[is_custom])
|
|
571
|
+
if source is not None:
|
|
572
|
+
ways += [(way_id if way_id > 0 else None, list(refs), dict(tags), way_id)
|
|
573
|
+
for way_id, (refs, tags) in source.ferries.items()]
|
|
574
|
+
turns = edges_gdf.attrs.get(TURNS_ATTR) or []
|
|
575
|
+
if turns:
|
|
576
|
+
ways = _cut_at_junctions(ways, turns)
|
|
577
|
+
|
|
578
|
+
# New ids start above every way id in the area's OSM data (the same
|
|
579
|
+
# in the before and after file), and go first to further pieces of
|
|
580
|
+
# existing ways in a fixed order, then to changed stretches, then to
|
|
581
|
+
# custom lines: a piece of a cropped way gets the same id in both
|
|
582
|
+
# files unless an edit changed the ways before it.
|
|
583
|
+
taken = [way[0] for way in ways if way[0]]
|
|
584
|
+
if source is not None:
|
|
585
|
+
taken += [*source.ways, *source.ferries]
|
|
586
|
+
new_ids = iter(range(max(taken, default=0) + 1, 2**62))
|
|
587
|
+
|
|
588
|
+
def allocation_order(way) -> tuple:
|
|
589
|
+
_, refs, tags, origin = way
|
|
590
|
+
kind = 2 if origin is None else 1 if MODIFIED_TAG in tags else 0
|
|
591
|
+
return (kind, origin or 0, refs[0], refs[-1]) if origin is not None else (kind,)
|
|
592
|
+
|
|
593
|
+
numbered = sorted((way for way in ways if not way[0]), key=allocation_order)
|
|
594
|
+
assigned = {id(way): next(new_ids) for way in numbered}
|
|
595
|
+
|
|
596
|
+
pieces = defaultdict(list) # OSM way id -> [(id written, refs)]
|
|
597
|
+
osm_ways = []
|
|
598
|
+
for way in ways:
|
|
599
|
+
way_id, refs, tags, origin = way
|
|
600
|
+
way_id = way_id or assigned[id(way)]
|
|
601
|
+
osm_ways.append((way_id, refs, tags))
|
|
602
|
+
if origin is not None:
|
|
603
|
+
pieces[origin].append((way_id, refs))
|
|
604
|
+
osm_ways.sort()
|
|
605
|
+
|
|
606
|
+
relations = _relations(source, pieces, set(node_ids)) if source is not None else []
|
|
607
|
+
if turns:
|
|
608
|
+
taken = [relation_id for relation_id, _, _ in relations]
|
|
609
|
+
if source is not None:
|
|
610
|
+
taken += [relation_id for relation_id, _, _ in source.relations]
|
|
611
|
+
relations += _turn_relations(turns, osm_ways, max(taken, default=0) + 1)
|
|
612
|
+
return OSMData(bounds, osm_nodes, osm_ways, relations)
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def _existing_ways(
|
|
616
|
+
edges: pd.DataFrame,
|
|
617
|
+
source: OSMSource | None,
|
|
618
|
+
changes: dict[int, dict[str, str]],
|
|
619
|
+
) -> list[tuple]:
|
|
620
|
+
"""
|
|
621
|
+
The existing network's edges (one per street segment and direction
|
|
622
|
+
of drawing) as ways. With a source, each way is its OSM original:
|
|
623
|
+
untouched ways are copied; ways the build cut (a new junction),
|
|
624
|
+
cropped (the edge of the area) or changed (`changes`, by the edit
|
|
625
|
+
number in EDIT_COLUMN) are rebuilt around their OSM nodes.
|
|
626
|
+
"""
|
|
627
|
+
if edges.empty:
|
|
628
|
+
return []
|
|
629
|
+
|
|
630
|
+
us = edges["u"].astype("int64").to_numpy()
|
|
631
|
+
vs = edges["v"].astype("int64").to_numpy()
|
|
632
|
+
if "osmid" in edges:
|
|
633
|
+
# A real way id is one whole number; anything else (missing, or a
|
|
634
|
+
# list from a simplified OSMnx graph) makes the edge its own way.
|
|
635
|
+
way_ids = pd.to_numeric(edges["osmid"], errors="coerce").fillna(0).astype("int64")
|
|
636
|
+
way_ids = way_ids.to_numpy()
|
|
637
|
+
else:
|
|
638
|
+
way_ids = np.zeros(len(edges), dtype="int64")
|
|
639
|
+
edit = (edges[EDIT_COLUMN].to_numpy(dtype="float64") if EDIT_COLUMN in edges
|
|
640
|
+
else np.full(len(edges), np.nan))
|
|
641
|
+
|
|
642
|
+
known = np.zeros(len(edges), dtype=bool)
|
|
643
|
+
ways = []
|
|
644
|
+
|
|
645
|
+
if source is not None:
|
|
646
|
+
known = np.fromiter((way_id in source.ways for way_id in way_ids.tolist()),
|
|
647
|
+
dtype=bool, count=len(edges))
|
|
648
|
+
cut = (edges["split"] == "yes").to_numpy() if "split" in edges else np.zeros_like(known)
|
|
649
|
+
counts = pd.Series(way_ids[known]).value_counts()
|
|
650
|
+
touched = set(way_ids[known & (cut | ~np.isnan(edit))].tolist())
|
|
651
|
+
|
|
652
|
+
rebuild = []
|
|
653
|
+
for way_id, count in counts.items():
|
|
654
|
+
refs, tags = source.ways[way_id]
|
|
655
|
+
if way_id in touched or count != len(refs) - 1:
|
|
656
|
+
rebuild.append(way_id)
|
|
657
|
+
else:
|
|
658
|
+
ways.append((way_id if way_id > 0 else None, list(refs), dict(tags), way_id))
|
|
659
|
+
|
|
660
|
+
segments = defaultdict(dict) # way id -> {(u, v): edit number or None}
|
|
661
|
+
rows = known & np.isin(way_ids, rebuild)
|
|
662
|
+
for way_id, u, v, number in zip(way_ids[rows].tolist(), us[rows].tolist(),
|
|
663
|
+
vs[rows].tolist(), edit[rows].tolist(), strict=True):
|
|
664
|
+
segments[way_id][(u, v)] = None if number != number else int(number)
|
|
665
|
+
for way_id in rebuild:
|
|
666
|
+
refs, tags = source.ways[way_id]
|
|
667
|
+
runs = _runs(refs, segments[way_id])
|
|
668
|
+
# The way's id stays with its first unchanged part.
|
|
669
|
+
keeper = next((i for i, (_, number) in enumerate(runs) if number is None), None)
|
|
670
|
+
for i, (run, number) in enumerate(runs):
|
|
671
|
+
run_tags = dict(tags)
|
|
672
|
+
if number is not None:
|
|
673
|
+
run_tags |= {**changes.get(number, {}), MODIFIED_TAG: "yes"}
|
|
674
|
+
run_tags = {k: v for k, v in run_tags.items() if v is not None}
|
|
675
|
+
ways.append((way_id if i == keeper and way_id > 0 else None, run, run_tags,
|
|
676
|
+
way_id))
|
|
677
|
+
|
|
678
|
+
# No source for these: chain each way's edges, tags from the columns.
|
|
679
|
+
rest = edges[~known]
|
|
680
|
+
if not rest.empty:
|
|
681
|
+
tags = _row_tags(rest, way_tag_columns())
|
|
682
|
+
groups = defaultdict(list)
|
|
683
|
+
for row, way_id in enumerate(way_ids[~known].tolist()):
|
|
684
|
+
groups[way_id or ("edge", row)].append(row)
|
|
685
|
+
rest_us, rest_vs = us[~known].tolist(), vs[~known].tolist()
|
|
686
|
+
for way_id, rows in groups.items():
|
|
687
|
+
chains = _chains([(rest_us[row], rest_vs[row]) for row in rows])
|
|
688
|
+
for number, chain in enumerate(chains):
|
|
689
|
+
keeps_id = number == 0 and isinstance(way_id, int) and way_id > 0
|
|
690
|
+
ways.append((way_id if keeps_id else None, chain, tags[rows[0]], None))
|
|
691
|
+
|
|
692
|
+
return ways
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def _cut_at_junctions(ways: list[tuple], turns: list) -> list[tuple]:
|
|
696
|
+
"""
|
|
697
|
+
Cut the member streets of each turn restriction where they run
|
|
698
|
+
through its junction(s): OSM's restrictions need the from and to
|
|
699
|
+
ways to end at the via node (or via way), and a via way to run from
|
|
700
|
+
one junction to the other. The first piece keeps the way's id.
|
|
701
|
+
"""
|
|
702
|
+
cut_at = defaultdict(set) # segment -> nodes to cut the way holding it at
|
|
703
|
+
for turn in turns:
|
|
704
|
+
cut_at[turn.from_segment].add(turn.path[0])
|
|
705
|
+
cut_at[turn.to_segment].add(turn.path[-1])
|
|
706
|
+
for segment in turn.via_segments:
|
|
707
|
+
cut_at[segment] |= {turn.path[0], turn.path[-1]}
|
|
708
|
+
|
|
709
|
+
cut = []
|
|
710
|
+
for way in ways:
|
|
711
|
+
way_id, refs, tags, origin = way
|
|
712
|
+
at = set().union(*(cut_at.get(segment, set())
|
|
713
|
+
for segment in map(frozenset, zip(refs, refs[1:], strict=False))))
|
|
714
|
+
inner = [i for i in range(1, len(refs) - 1) if refs[i] in at]
|
|
715
|
+
if not inner:
|
|
716
|
+
cut.append(way)
|
|
717
|
+
continue
|
|
718
|
+
for number, (start, stop) in enumerate(zip([0, *inner], [*inner, len(refs) - 1],
|
|
719
|
+
strict=True)):
|
|
720
|
+
cut.append((way_id if number == 0 else None, refs[start:stop + 1], dict(tags),
|
|
721
|
+
origin))
|
|
722
|
+
return cut
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def _turn_relations(turns: list, ways: list[tuple], first_id: int) -> list[tuple]:
|
|
726
|
+
"""
|
|
727
|
+
The turn restrictions as OSM relations: from way, via node (or via
|
|
728
|
+
way(s), in order), to way.
|
|
729
|
+
"""
|
|
730
|
+
wanted = {segment for turn in turns
|
|
731
|
+
for segment in (turn.from_segment, turn.to_segment, *turn.via_segments)}
|
|
732
|
+
way_of = {}
|
|
733
|
+
for way_id, refs, _ in ways:
|
|
734
|
+
for segment in map(frozenset, zip(refs, refs[1:], strict=False)):
|
|
735
|
+
if segment in wanted:
|
|
736
|
+
way_of[segment] = way_id
|
|
737
|
+
|
|
738
|
+
relations = []
|
|
739
|
+
for number, turn in enumerate(turns):
|
|
740
|
+
from_way, to_way = way_of.get(turn.from_segment), way_of.get(turn.to_segment)
|
|
741
|
+
via_ways = list(dict.fromkeys(way_of.get(segment) for segment in turn.via_segments))
|
|
742
|
+
if from_way is None or to_way is None or None in via_ways: # pragma: no cover
|
|
743
|
+
log.warning("Turn restriction %s: its streets aren't in the output; left out.",
|
|
744
|
+
turn.feature)
|
|
745
|
+
continue
|
|
746
|
+
via = ([("w", way, "via") for way in via_ways] if turn.via_segments
|
|
747
|
+
else [("n", turn.via, "via")])
|
|
748
|
+
relations.append((first_id + number,
|
|
749
|
+
[("w", from_way, "from"), *via, ("w", to_way, "to")],
|
|
750
|
+
relation_tags(turn)))
|
|
751
|
+
return relations
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
def _custom_ways(edges: pd.DataFrame) -> list[tuple]:
|
|
755
|
+
"""Custom edges as ways: one per custom line (or per edge if unnumbered)."""
|
|
756
|
+
if edges.empty:
|
|
757
|
+
return []
|
|
758
|
+
|
|
759
|
+
tags = _row_tags(edges, way_tag_columns())
|
|
760
|
+
us = edges["u"].astype("int64").tolist()
|
|
761
|
+
vs = edges["v"].astype("int64").tolist()
|
|
762
|
+
parts = edges[PART_COLUMN].tolist() if PART_COLUMN in edges else [None] * len(edges)
|
|
763
|
+
|
|
764
|
+
groups = defaultdict(list)
|
|
765
|
+
for row, part in enumerate(parts):
|
|
766
|
+
numbered = part is not None and part == part # not NaN
|
|
767
|
+
groups[("part", part) if numbered else ("edge", row)].append(row)
|
|
768
|
+
|
|
769
|
+
return [
|
|
770
|
+
(None, chain, tags[rows[0]], None)
|
|
771
|
+
for rows in groups.values()
|
|
772
|
+
for chain in _chains([(us[row], vs[row]) for row in rows])
|
|
773
|
+
]
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def _chains(segments: list[tuple[int, int]]) -> list[list[int]]:
|
|
777
|
+
"""
|
|
778
|
+
Join directed segments (u, v) end to start into as few node chains
|
|
779
|
+
as it takes to use each segment once. A line cut into pieces gives
|
|
780
|
+
back its nodes in order; a closed loop starts at its first segment.
|
|
781
|
+
"""
|
|
782
|
+
following = defaultdict(list)
|
|
783
|
+
arrivals = defaultdict(int)
|
|
784
|
+
for u, v in segments:
|
|
785
|
+
following[u].append(v)
|
|
786
|
+
arrivals[v] += 1
|
|
787
|
+
|
|
788
|
+
def walk(node: int) -> list[int]:
|
|
789
|
+
chain = [node]
|
|
790
|
+
while following[node]:
|
|
791
|
+
node = following[node].pop(0)
|
|
792
|
+
chain.append(node)
|
|
793
|
+
return chain
|
|
794
|
+
|
|
795
|
+
chains = []
|
|
796
|
+
# Start where a chain must start (more segments leave than arrive),
|
|
797
|
+
# then pick up whatever is left (loops).
|
|
798
|
+
for start in [node for node, _ in segments if len(following[node]) > arrivals[node]]:
|
|
799
|
+
while len(following[start]) > arrivals[start]:
|
|
800
|
+
chains.append(walk(start))
|
|
801
|
+
for start, _ in segments:
|
|
802
|
+
while following[start]:
|
|
803
|
+
chains.append(walk(start))
|
|
804
|
+
return chains
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
def _runs(
|
|
808
|
+
refs: list[int],
|
|
809
|
+
segments: dict[tuple[int, int], int | None],
|
|
810
|
+
) -> list[tuple[list[int], int | None]]:
|
|
811
|
+
"""
|
|
812
|
+
An OSM way's nodes after the build, from its original `refs` and
|
|
813
|
+
its remaining edges, {(u, v): edit number or None}: nodes the build
|
|
814
|
+
inserted between two neighbours are added in place. The way breaks
|
|
815
|
+
into separate runs where a stretch is gone (cropped at the edge of
|
|
816
|
+
the area) and where the edit number changes. Returns (run, edit
|
|
817
|
+
number) pairs in order along the way.
|
|
818
|
+
"""
|
|
819
|
+
joined = defaultdict(set)
|
|
820
|
+
number_of = {}
|
|
821
|
+
for (u, v), number in segments.items():
|
|
822
|
+
joined[u].add(v)
|
|
823
|
+
joined[v].add(u)
|
|
824
|
+
number_of[frozenset((u, v))] = number
|
|
825
|
+
original = set(refs)
|
|
826
|
+
|
|
827
|
+
def via_new_nodes(start: int, end: int) -> list[int] | None:
|
|
828
|
+
"""Nodes after `start` up to `end`, passing only inserted nodes."""
|
|
829
|
+
stack = [(node, [node]) for node in sorted(joined[start] - original)]
|
|
830
|
+
seen = {start}
|
|
831
|
+
while stack:
|
|
832
|
+
node, path = stack.pop()
|
|
833
|
+
if end in joined[node]:
|
|
834
|
+
return [*path, end]
|
|
835
|
+
seen.add(node)
|
|
836
|
+
stack.extend((nxt, [*path, nxt]) for nxt in sorted(joined[node] - original - seen))
|
|
837
|
+
return None
|
|
838
|
+
|
|
839
|
+
runs, run, current = [], [refs[0]], None
|
|
840
|
+
for start, end in zip(refs, refs[1:], strict=False):
|
|
841
|
+
if start == end:
|
|
842
|
+
continue
|
|
843
|
+
path = [end] if end in joined[start] else via_new_nodes(start, end)
|
|
844
|
+
if path is None:
|
|
845
|
+
runs.append((run, current))
|
|
846
|
+
run = [end]
|
|
847
|
+
continue
|
|
848
|
+
number = number_of[frozenset((start, path[0]))]
|
|
849
|
+
if len(run) > 1 and number != current:
|
|
850
|
+
runs.append((run, current))
|
|
851
|
+
run = [start]
|
|
852
|
+
current = number
|
|
853
|
+
run.extend(path)
|
|
854
|
+
runs.append((run, current))
|
|
855
|
+
return [(run, number) for run, number in runs if len(run) >= 2]
|
|
856
|
+
|
|
857
|
+
|
|
858
|
+
def _relations(
|
|
859
|
+
source: OSMSource,
|
|
860
|
+
pieces: dict[int, list[tuple[int, list[int]]]],
|
|
861
|
+
node_ids: set[int],
|
|
862
|
+
) -> list[tuple]:
|
|
863
|
+
"""
|
|
864
|
+
The source's relations that still make sense in the written network.
|
|
865
|
+
`pieces`: the ways each OSM way was written as (one, unless it was
|
|
866
|
+
cropped or part of it was changed).
|
|
867
|
+
|
|
868
|
+
A turn restriction needs every member; where a member way is now
|
|
869
|
+
several pieces, it means the piece its via node is on. A route keeps
|
|
870
|
+
whichever of its members are there, in all their pieces.
|
|
871
|
+
"""
|
|
872
|
+
relations = []
|
|
873
|
+
|
|
874
|
+
for relation_id, tags, members in source.relations:
|
|
875
|
+
if tags.get("type") == "route":
|
|
876
|
+
present = []
|
|
877
|
+
for kind, ref, role in members:
|
|
878
|
+
if kind == "w":
|
|
879
|
+
present += [("w", way_id, role) for way_id, _ in pieces.get(ref, [])]
|
|
880
|
+
elif kind == "n" and ref in node_ids:
|
|
881
|
+
present.append((kind, ref, role))
|
|
882
|
+
if any(kind == "w" for kind, _, _ in present):
|
|
883
|
+
relations.append((relation_id, present, dict(tags)))
|
|
884
|
+
continue
|
|
885
|
+
|
|
886
|
+
written = _restriction_members(members, source, pieces, node_ids)
|
|
887
|
+
if written is not None:
|
|
888
|
+
relations.append((relation_id, written, dict(tags)))
|
|
889
|
+
|
|
890
|
+
return sorted(relations)
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
def _restriction_members(
|
|
894
|
+
members: list[tuple[str, int, str]],
|
|
895
|
+
source: OSMSource,
|
|
896
|
+
pieces: dict[int, list[tuple[int, list[int]]]],
|
|
897
|
+
node_ids: set[int],
|
|
898
|
+
) -> list[tuple[str, int, str]] | None:
|
|
899
|
+
"""
|
|
900
|
+
A turn restriction's members in the written network, or None if one
|
|
901
|
+
of them is gone. A from or to way now in several pieces means the
|
|
902
|
+
piece at the junction: the one holding the via node, or meeting the
|
|
903
|
+
via way. A via way in pieces is written as all of them, in the order
|
|
904
|
+
of travel - if they still run unbroken from junction to junction.
|
|
905
|
+
"""
|
|
906
|
+
via_nodes = [ref for kind, ref, role in members if kind == "n" and role == "via"]
|
|
907
|
+
via_ways = [ref for kind, ref, role in members if kind == "w" and role == "via"]
|
|
908
|
+
|
|
909
|
+
def original(way: int) -> list[int]:
|
|
910
|
+
return source.ways[way][0] if way in source.ways else []
|
|
911
|
+
|
|
912
|
+
def junction(way: int, role: str) -> set[int]:
|
|
913
|
+
"""Where the way meets the via: the via node(s), or the nearest via way."""
|
|
914
|
+
if via_nodes or not via_ways:
|
|
915
|
+
return set(via_nodes)
|
|
916
|
+
return set(original(way)) & set(original(via_ways[-1 if role == "to" else 0]))
|
|
917
|
+
|
|
918
|
+
from_way = next((ref for kind, ref, role in members if kind == "w" and role == "from"), None)
|
|
919
|
+
written = []
|
|
920
|
+
for kind, ref, role in members:
|
|
921
|
+
if kind == "n":
|
|
922
|
+
if ref not in node_ids:
|
|
923
|
+
return None
|
|
924
|
+
written.append((kind, ref, role))
|
|
925
|
+
elif kind != "w":
|
|
926
|
+
return None
|
|
927
|
+
elif role == "via":
|
|
928
|
+
parts = pieces.get(ref, [])
|
|
929
|
+
unbroken = all(first[-1] == second[0] for (_, first), (_, second)
|
|
930
|
+
in zip(parts, parts[1:], strict=False))
|
|
931
|
+
if not parts or not unbroken or [parts[0][1][0], parts[-1][1][-1]] != [
|
|
932
|
+
original(ref)[0], original(ref)[-1]]:
|
|
933
|
+
return None
|
|
934
|
+
# Starting from the end the from way arrives at.
|
|
935
|
+
arrives_at = set(original(from_way)) if from_way is not None else set()
|
|
936
|
+
if parts[-1][1][-1] in arrives_at and parts[0][1][0] not in arrives_at:
|
|
937
|
+
parts = parts[::-1]
|
|
938
|
+
written += [(kind, way_id, role) for way_id, _ in parts]
|
|
939
|
+
else:
|
|
940
|
+
parts = pieces.get(ref, [])
|
|
941
|
+
at = junction(ref, role)
|
|
942
|
+
if at:
|
|
943
|
+
parts = [(way_id, refs) for way_id, refs in parts
|
|
944
|
+
if all(node in refs for node in at)
|
|
945
|
+
or (not via_nodes and at & set(refs))]
|
|
946
|
+
elif len(parts) != 1:
|
|
947
|
+
return None
|
|
948
|
+
if not parts:
|
|
949
|
+
return None
|
|
950
|
+
# Cut at the junction itself: the piece with the way's own id.
|
|
951
|
+
ids = [way_id for way_id, _ in parts]
|
|
952
|
+
written.append((kind, ref if ref in ids else ids[0], role))
|
|
953
|
+
return written
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
def _row_tags(gdf: gpd.GeoDataFrame, columns: dict[str, str]) -> list[dict[str, str]]:
|
|
957
|
+
"""
|
|
958
|
+
One {osm_key: text} dict per row, for the given column -> key map.
|
|
959
|
+
Goes column by column and only visits cells that have a value: most
|
|
960
|
+
tag columns are almost entirely empty.
|
|
961
|
+
"""
|
|
962
|
+
tags = [{} for _ in range(len(gdf))]
|
|
963
|
+
for column, key in columns.items():
|
|
964
|
+
if column not in gdf.columns:
|
|
965
|
+
continue
|
|
966
|
+
values = gdf[column]
|
|
967
|
+
present = np.flatnonzero(values.notna().to_numpy())
|
|
968
|
+
for row, value in zip(present.tolist(), values.iloc[present].tolist(), strict=True):
|
|
969
|
+
text = _tag_text(value)
|
|
970
|
+
if text is not None:
|
|
971
|
+
tags[row][key] = text
|
|
972
|
+
return tags
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
def _tag_text(value) -> str | None:
|
|
976
|
+
"""A tag value as OSM text, or None if missing."""
|
|
977
|
+
if isinstance(value, list): # OSMnx can merge values into lists
|
|
978
|
+
value = ";".join(map(str, value))
|
|
979
|
+
if isinstance(value, bool | np.bool_):
|
|
980
|
+
# OSMnx turns oneway into True/False. OSM (and OSMnx when reading
|
|
981
|
+
# it back) only understands yes/no: "True" made one-way streets
|
|
982
|
+
# two-way. With simplify=False each edge already runs in the
|
|
983
|
+
# allowed direction, so True is plain oneway=yes.
|
|
984
|
+
return "yes" if value else "no"
|
|
985
|
+
if value is None or (not isinstance(value, str) and pd.isna(value)):
|
|
986
|
+
return None
|
|
987
|
+
if isinstance(value, float) and value.is_integer():
|
|
988
|
+
value = int(value) # 2.0 -> "2"
|
|
989
|
+
return str(value)
|
|
990
|
+
|
|
991
|
+
|
|
992
|
+
# Characters XML 1.0 can't hold at all (control characters other than
|
|
993
|
+
# tab, new line and carriage return): a file with one can't be read back.
|
|
994
|
+
_NOT_XML = re.compile("[\x00-\x08\x0b\x0c\x0e-\x1f\ufffe\uffff]")
|
|
995
|
+
|
|
996
|
+
|
|
997
|
+
def _xml_text(text: str) -> str:
|
|
998
|
+
return _NOT_XML.sub("", text)
|
|
999
|
+
|
|
1000
|
+
|
|
1001
|
+
def _write_xml(data: OSMData, path: str | Path) -> None:
|
|
1002
|
+
"""Stream OSM XML straight to the file (no in-memory element tree)."""
|
|
1003
|
+
|
|
1004
|
+
def tag_lines(tags: dict[str, str]) -> str:
|
|
1005
|
+
return "".join(f" <tag k={quoteattr(_xml_text(k))} v={quoteattr(_xml_text(v))}/>\n"
|
|
1006
|
+
for k, v in tags.items())
|
|
1007
|
+
|
|
1008
|
+
min_lon, min_lat, max_lon, max_lat = data.bounds
|
|
1009
|
+
with open(path, "w", encoding="utf-8") as out:
|
|
1010
|
+
out.write("<?xml version='1.0' encoding='utf-8'?>\n")
|
|
1011
|
+
out.write(f'<osm version="0.6" generator="{GENERATOR}">\n')
|
|
1012
|
+
out.write(f' <bounds minlat="{min_lat}" minlon="{min_lon}" '
|
|
1013
|
+
f'maxlat="{max_lat}" maxlon="{max_lon}"/>\n')
|
|
1014
|
+
|
|
1015
|
+
for node_id, lon, lat, tags in data.nodes:
|
|
1016
|
+
if tags:
|
|
1017
|
+
out.write(f' <node id="{node_id}" lat="{lat}" lon="{lon}">\n'
|
|
1018
|
+
f"{tag_lines(tags)} </node>\n")
|
|
1019
|
+
else:
|
|
1020
|
+
out.write(f' <node id="{node_id}" lat="{lat}" lon="{lon}"/>\n')
|
|
1021
|
+
|
|
1022
|
+
for way_id, refs, tags in data.ways:
|
|
1023
|
+
nds = "".join(f' <nd ref="{ref}"/>\n' for ref in refs)
|
|
1024
|
+
out.write(f' <way id="{way_id}">\n{nds}{tag_lines(tags)} </way>\n')
|
|
1025
|
+
|
|
1026
|
+
for relation_id, members, tags in data.relations:
|
|
1027
|
+
lines = "".join(
|
|
1028
|
+
f' <member type="{MEMBER_TYPES[kind]}" ref="{ref}" role={quoteattr(role)}/>\n'
|
|
1029
|
+
for kind, ref, role in members
|
|
1030
|
+
)
|
|
1031
|
+
out.write(f' <relation id="{relation_id}">\n{lines}{tag_lines(tags)}'
|
|
1032
|
+
" </relation>\n")
|
|
1033
|
+
|
|
1034
|
+
out.write("</osm>\n")
|
|
1035
|
+
|
|
1036
|
+
|
|
1037
|
+
def _write_with_osmium(data: OSMData, path: str | Path) -> None:
|
|
1038
|
+
header = osmium.io.Header()
|
|
1039
|
+
header.set("generator", GENERATOR)
|
|
1040
|
+
min_lon, min_lat, max_lon, max_lat = data.bounds
|
|
1041
|
+
header.add_box(osmium.osm.Box(osmium.osm.Location(min_lon, min_lat),
|
|
1042
|
+
osmium.osm.Location(max_lon, max_lat)))
|
|
1043
|
+
|
|
1044
|
+
with writable(path) as name:
|
|
1045
|
+
writer = osmium.SimpleWriter(name, header=header, overwrite=True)
|
|
1046
|
+
try:
|
|
1047
|
+
for node_id, lon, lat, tags in data.nodes:
|
|
1048
|
+
writer.add_node(
|
|
1049
|
+
osmium.osm.mutable.Node(id=node_id, location=(lon, lat), tags=tags))
|
|
1050
|
+
for way_id, refs, tags in data.ways:
|
|
1051
|
+
writer.add_way(osmium.osm.mutable.Way(id=way_id, nodes=refs, tags=tags))
|
|
1052
|
+
for relation_id, members, tags in data.relations:
|
|
1053
|
+
writer.add_relation(osmium.osm.mutable.Relation(
|
|
1054
|
+
id=relation_id, members=members, tags=tags))
|
|
1055
|
+
finally:
|
|
1056
|
+
writer.close()
|