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