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.
@@ -0,0 +1,703 @@
1
+ """
2
+ The NetworkForge pipeline: merge custom lines into the OSM network.
3
+
4
+ Progress is reported through the `logging` module (logger names start
5
+ with "networkforge") and, optionally, a progress callback. The library
6
+ never configures logging itself - call logging.basicConfig(level=
7
+ logging.INFO) in your script to see the build steps.
8
+ """
9
+
10
+ import logging
11
+ from collections.abc import Callable
12
+ from pathlib import Path
13
+
14
+ import geopandas as gpd
15
+ import numpy as np
16
+ import pandas as pd
17
+ import shapely
18
+ from pyproj import CRS
19
+ from shapely.geometry import box
20
+
21
+ from .edits import apply_edits, check_edit_tags, is_bidirectional, split_edits
22
+ from .errors import InputError, NetworkIntegrityError
23
+ from .inputs import MAX_LISTED, check_bbox, clean_custom_data
24
+ from .osm import (
25
+ NETWORK_TYPES,
26
+ SOURCE_ATTR,
27
+ OSMSource,
28
+ get_osm_data_from_bbox,
29
+ get_osm_data_from_file,
30
+ )
31
+ from .points import check_point_tags, place_points, split_points
32
+ from .presets import preset_tags
33
+ from .projection import get_analysis_crs
34
+ from .tags import (
35
+ EDITS_ATTR,
36
+ NODE_CHANGES_ATTR,
37
+ PART_COLUMN,
38
+ REMOVE_COLUMN,
39
+ REMOVE_TAGS_COLUMN,
40
+ REMOVED_ATTR,
41
+ TURNS_ATTR,
42
+ )
43
+ from .topology import (
44
+ ON_NODE_TOLERANCE,
45
+ assign_point_ids_to_lines,
46
+ check_line_node_consistency,
47
+ create_points_from_gdf,
48
+ cuts_after_snapping,
49
+ filter_split_lines,
50
+ keep_grade_separated_vertices_apart,
51
+ remove_duplicates_and_combine_nodes,
52
+ snap_crossings_to_network,
53
+ snap_line_ends_together,
54
+ snap_line_vertices_to_network,
55
+ split_at_self_crossings,
56
+ split_lines_with_buffered_points,
57
+ update_and_finalize_lines_gdf,
58
+ validate_user_osm_intersection,
59
+ )
60
+ from .turns import check_turn_tags, resolve_turns, split_turns
61
+ from .validation import (
62
+ assert_all_edges_have_valid_nodes,
63
+ assert_custom_lines_unbroken,
64
+ assert_no_u_equals_v,
65
+ check_custom_tags,
66
+ disconnected_custom_edges,
67
+ network_pieces,
68
+ resolve_custom_tags,
69
+ warn_about_unwritten_tags,
70
+ )
71
+
72
+ log = logging.getLogger(__name__)
73
+
74
+ # progress(step, total_steps, description) - called as each step starts.
75
+ ProgressCallback = Callable[[int, int, str], None]
76
+
77
+ TOTAL_STEPS = 13
78
+
79
+ # Where the lines of a standalone network join each other (join_at=).
80
+ JOIN_AT = ("crossings", "vertices")
81
+
82
+
83
+ def combine_custom_lines_with_osm_edges(
84
+ custom: gpd.GeoDataFrame,
85
+ edges_gdf: gpd.GeoDataFrame,
86
+ crs: str | CRS,
87
+ ) -> gpd.GeoDataFrame:
88
+ """
89
+ Combine the user's custom network lines with OSM network edges.
90
+ """
91
+
92
+ # Split MultiLineStrings into individual LineStrings.
93
+ custom = custom.explode(index_parts=True)
94
+
95
+ # Reset the OSM edge index before combining.
96
+ edges_gdf_reset = edges_gdf.reset_index(drop=True)
97
+
98
+ # Mark custom features so they can be identified later, and number
99
+ # the lines so export can write each one as a single way.
100
+ custom["custom"] = "yes"
101
+ custom[PART_COLUMN] = np.arange(len(custom))
102
+
103
+ # Combine OSM and custom network lines.
104
+ combined_gdf = gpd.GeoDataFrame(
105
+ pd.concat(
106
+ [edges_gdf_reset, custom],
107
+ ignore_index=True,
108
+ ),
109
+ crs=crs,
110
+ )
111
+ return combined_gdf
112
+
113
+ def _count_custom(gdf: gpd.GeoDataFrame) -> int:
114
+ """How many rows are tagged as custom, for logging."""
115
+ if "custom" not in gdf.columns:
116
+ return 0
117
+ return int((gdf["custom"] == "yes").sum())
118
+
119
+
120
+ def _warn_about_disconnected_features(
121
+ edges_gdf: gpd.GeoDataFrame,
122
+ custom_gdf: gpd.GeoDataFrame,
123
+ snap_tolerance: float,
124
+ ) -> None:
125
+ """
126
+ Warn, naming the features, about custom lines that ended up outside
127
+ the main network (e.g. drawn away from every street). They stay in
128
+ the output, but no router can reach them.
129
+
130
+ custom_gdf: the snapped custom lines, indexed by feature id.
131
+ """
132
+ stranded = edges_gdf[disconnected_custom_edges(edges_gdf)]
133
+ if stranded.empty:
134
+ return
135
+ features = _features_of(stranded, custom_gdf, snap_tolerance)
136
+
137
+ log.warning("%d custom feature(s) don't connect to the rest of the network, so a "
138
+ "router can never reach them (features %s). They are kept in the "
139
+ "output; extend them to meet a street if they should connect.",
140
+ len(features), ", ".join(map(str, features)), extra={"features": features})
141
+
142
+
143
+ def _features_of(
144
+ edges: gpd.GeoDataFrame,
145
+ custom_gdf: gpd.GeoDataFrame,
146
+ snap_tolerance: float,
147
+ ) -> list:
148
+ """
149
+ The features (custom_gdf index values) these custom edges were cut
150
+ from: PART_COLUMN numbers the custom lines in custom_gdf's order.
151
+ """
152
+ if PART_COLUMN in edges and edges[PART_COLUMN].notna().all():
153
+ parts = pd.unique(edges[PART_COLUMN].astype(int))
154
+ return list(dict.fromkeys(custom_gdf.index[parts]))
155
+
156
+ # Edges without the number: match each to the line its midpoint is on.
157
+ midpoints = shapely.line_interpolate_point(edges.geometry.to_numpy(), 0.5, normalized=True)
158
+ tree = shapely.STRtree(custom_gdf.geometry.to_numpy())
159
+ _, line_pos = tree.query_nearest(midpoints, max_distance=snap_tolerance)
160
+ return list(dict.fromkeys(custom_gdf.index[np.unique(line_pos)]))
161
+
162
+
163
+ def _warn_about_separate_pieces(
164
+ edges_gdf: gpd.GeoDataFrame,
165
+ custom_gdf: gpd.GeoDataFrame,
166
+ snap_tolerance: float,
167
+ ) -> None:
168
+ """
169
+ A standalone network should usually be one connected whole. Warn,
170
+ naming the features, about lines outside its largest piece: most
171
+ often lines that stop just short of the street they should meet.
172
+ """
173
+ piece = network_pieces(edges_gdf)
174
+ sizes = piece.value_counts()
175
+ if len(sizes) <= 1:
176
+ return
177
+ outside = edges_gdf[piece != sizes.index[0]]
178
+ features = _features_of(outside, custom_gdf, snap_tolerance)
179
+ listed = ", ".join(map(str, features[:MAX_LISTED]))
180
+ more = f" and {len(features) - MAX_LISTED} more" if len(features) > MAX_LISTED else ""
181
+ log.warning("The network is in %d separate pieces. %d feature(s) are not joined to "
182
+ "the largest one, so no route can pass between them and it (features "
183
+ "%s%s). Lines join where they cross, or end within %s m of each other; "
184
+ "extend the ones that should meet.", len(sizes), len(features), listed,
185
+ more, snap_tolerance, extra={"features": features})
186
+
187
+
188
+ def _lone_ferry_terminals(
189
+ source: OSMSource | None,
190
+ nodes_gdf: gpd.GeoDataFrame,
191
+ ) -> gpd.GeoDataFrame:
192
+ """
193
+ The ends of existing ferries that no street reaches (a pier, say),
194
+ as rows like nodes_gdf's: a custom line ENDING there joins the
195
+ ferry, as one ending on a street node joins the street. They aren't
196
+ on any edge, so lines only snap to them, never split or cross them.
197
+ """
198
+ ids = sorted(set() if source is None else source.ferry_terminals - set(nodes_gdf.index))
199
+ lon, lat = (np.array([source.ferry_nodes[i][k] for i in ids]) for k in (0, 1))
200
+ terminals = gpd.GeoDataFrame(
201
+ {"y": lat, "x": lon},
202
+ geometry=gpd.points_from_xy(lon, lat, crs="EPSG:4326"),
203
+ index=pd.Index(ids, name=nodes_gdf.index.name),
204
+ ).to_crs(nodes_gdf.crs)
205
+ for key in nodes_gdf.columns.difference(["x", "y", "geometry", "street_count"]):
206
+ terminals[key] = [source.node_tags.get(i, {}).get(key) for i in ids]
207
+ return terminals
208
+
209
+
210
+ def _no_network(crs) -> tuple[gpd.GeoDataFrame, gpd.GeoDataFrame]:
211
+ """Empty OSM nodes and edges, with the columns the pipeline reads."""
212
+ nodes = gpd.GeoDataFrame(
213
+ {"y": pd.Series(dtype=float), "x": pd.Series(dtype=float)},
214
+ geometry=gpd.GeoSeries([], crs=crs), index=pd.Index([], dtype="int64", name="osmid"))
215
+ edges = gpd.GeoDataFrame(
216
+ {"u": pd.Series(dtype=float), "v": pd.Series(dtype=float),
217
+ "key": pd.Series(dtype=float), "osmid": pd.Series(dtype=float),
218
+ "highway": pd.Series(dtype=object)},
219
+ geometry=gpd.GeoSeries([], crs=crs))
220
+ return nodes, edges
221
+
222
+
223
+ def build_network(
224
+ bbox_gdf: gpd.GeoDataFrame,
225
+ custom_data_gdf: gpd.GeoDataFrame,
226
+ network_tags: dict[str, str] | None = None,
227
+ network_type: str = "all",
228
+ return_source_osm: bool = False,
229
+ snap_tolerance: float = 1.0,
230
+ strict: bool = True,
231
+ *,
232
+ preset: str | None = None,
233
+ overwrite_tags: bool = False,
234
+ osm_source: str | Path | None = None,
235
+ standalone: bool = False,
236
+ join_at: str = "crossings",
237
+ progress: ProgressCallback | None = None,
238
+ ) -> tuple[
239
+ gpd.GeoDataFrame,
240
+ gpd.GeoDataFrame,
241
+ ] | tuple[
242
+ gpd.GeoDataFrame,
243
+ gpd.GeoDataFrame,
244
+ gpd.GeoDataFrame,
245
+ gpd.GeoDataFrame,
246
+ ]:
247
+ """
248
+ Download the OSM network in `bbox_gdf` and merge the custom lines
249
+ into it, following OSM access and grade-separation rules.
250
+
251
+ With standalone=True there is no OSM network: the custom lines are
252
+ the whole network, joined to each other by the same rules (see
253
+ "A network of your own lines" in docs/network-rules.md).
254
+
255
+ Tags (see docs/network-rules.md):
256
+ Each custom feature's own attributes (highway, maxspeed, ...)
257
+ are its tags. The blanket options add to them:
258
+ preset name of a ready-made tag set (presets.PRESETS)
259
+ network_tags extra tags; these win over the preset
260
+ overwrite_tags False: the feature's own values win, the
261
+ blanket only fills gaps. True: the blanket
262
+ replaces feature values for every key it sets.
263
+
264
+ Args:
265
+ bbox_gdf: area to download; any CRS. None with standalone.
266
+ custom_data_gdf: custom LineString/MultiLineString features; any CRS.
267
+ network_type: OSMnx download filter. Keep "all" to route any mode later.
268
+ return_source_osm: also return the untouched OSM nodes and edges
269
+ (the baseline network for before/after comparisons).
270
+ snap_tolerance: metres within which custom lines join the network.
271
+ strict: raise on bad tags, unusable features or structural problems.
272
+ False logs warnings and drops what can't be used instead.
273
+ Custom lines that don't connect to the network only ever warn
274
+ (and are kept), as long as at least one line reaches it.
275
+ osm_source: a local OSM file (.osm.pbf, .osm, ...), e.g. a Geofabrik
276
+ extract, to read the existing network from instead of the
277
+ Overpass API. Required for boxes over MAX_OVERPASS_AREA_KM2
278
+ (see docs/osm-data.md).
279
+ standalone: build from the custom lines alone, without
280
+ OpenStreetMap. No download, no "before" network, and an OSM id
281
+ attribute is just an attribute. A warning names features that
282
+ end up outside the largest connected piece of the network.
283
+ join_at: (standalone only) where the lines join each other.
284
+ "crossings" (default): wherever they cross or touch, unless one
285
+ is a bridge, a tunnel or on another layer - for lines drawn
286
+ without thought for junctions. "vertices": only where two lines
287
+ share a vertex; lines that merely cross do not join - for data
288
+ that already has a vertex at every junction (street centrelines
289
+ from a GIS, data taken from OpenStreetMap).
290
+ progress: optional callback(step, total_steps, description).
291
+
292
+ Returns:
293
+ (nodes, edges), or (nodes, edges, osm_nodes, osm_edges) with
294
+ return_source_osm. All in the projected analysis CRS.
295
+
296
+ Raises:
297
+ InputError (incl. InvalidTagsError, NoIntersectionError),
298
+ OSMDownloadError, NetworkIntegrityError - see errors.py.
299
+ """
300
+
301
+ def step(number: int, description: str) -> None:
302
+ log.info("[%d/%d] %s", number, TOTAL_STEPS, description)
303
+ if progress is not None:
304
+ progress(number, TOTAL_STEPS, description)
305
+
306
+ # =========================================================
307
+ # 1. Check inputs and tags (before the slow download)
308
+ # =========================================================
309
+
310
+ step(1, "Checking inputs and tags")
311
+
312
+ if network_type not in NETWORK_TYPES:
313
+ raise InputError(
314
+ f"Unknown network_type {network_type!r}. "
315
+ f"Choose one of: {', '.join(NETWORK_TYPES)}"
316
+ )
317
+ if not 0 < snap_tolerance < float("inf"):
318
+ raise InputError(f"snap_tolerance must be a positive number of metres, "
319
+ f"got {snap_tolerance!r}")
320
+
321
+ if join_at not in JOIN_AT:
322
+ raise InputError(f"Unknown join_at {join_at!r}. Choose one of: {', '.join(JOIN_AT)}")
323
+ if join_at != "crossings" and not standalone:
324
+ raise InputError("join_at is for standalone networks: lines added to OpenStreetMap "
325
+ "join the streets they cross.", guide="a-network-of-your-own-lines")
326
+ at_vertices = join_at == "vertices"
327
+
328
+ if standalone:
329
+ if osm_source is not None or return_source_osm:
330
+ raise InputError("A standalone network has no OpenStreetMap data: it can't "
331
+ "take an osm_source or return a before network.",
332
+ guide="a-network-of-your-own-lines")
333
+ custom_data_gdf = clean_custom_data(custom_data_gdf, None, strict=strict, edits=False)
334
+ bbox_gdf = gpd.GeoDataFrame(
335
+ geometry=[box(*custom_data_gdf.total_bounds)], crs=custom_data_gdf.crs)
336
+ else:
337
+ check_bbox(bbox_gdf, local_source=osm_source is not None)
338
+ custom_data_gdf = clean_custom_data(custom_data_gdf, bbox_gdf, strict=strict)
339
+
340
+ analysis_crs = get_analysis_crs(bbox_gdf)
341
+ custom_data_gdf = custom_data_gdf.to_crs(analysis_crs)
342
+
343
+ # Features with an OSM way id change that existing way (edits.py);
344
+ # the rest are new lines. Blanket tags are for new lines only.
345
+ # Turn restriction lines aren't part of the network: they are matched
346
+ # to it once it is built (turns.py).
347
+ # Points tag the network's nodes; they too are placed on the built
348
+ # network (points.py).
349
+ custom_data_gdf, points_gdf = split_points(custom_data_gdf)
350
+ check_point_tags(points_gdf, strict=strict)
351
+ custom_data_gdf, turns_gdf = split_turns(custom_data_gdf)
352
+ check_turn_tags(turns_gdf, strict=strict)
353
+
354
+ warn_about_unwritten_tags(custom_data_gdf)
355
+
356
+ # In a standalone network every feature is a line of the network.
357
+ if standalone:
358
+ edits_gdf = custom_data_gdf.iloc[:0]
359
+ custom_data_gdf = custom_data_gdf.drop(columns=[REMOVE_COLUMN, REMOVE_TAGS_COLUMN],
360
+ errors="ignore")
361
+ else:
362
+ custom_data_gdf, edits_gdf = split_edits(custom_data_gdf)
363
+
364
+ blanket_tags = {**(preset_tags(preset) if preset else {}), **(network_tags or {})}
365
+ if not custom_data_gdf.empty:
366
+ custom_data_gdf = resolve_custom_tags(
367
+ custom_data_gdf, blanket_tags, overwrite=overwrite_tags)
368
+ check_custom_tags(custom_data_gdf, network_type, strict=strict,
369
+ require_usable=not standalone)
370
+ check_edit_tags(edits_gdf, strict=strict)
371
+
372
+ if standalone:
373
+ log.info("Lines: %d | analysis CRS: %s | snap tolerance: %s m | joining at %s",
374
+ len(custom_data_gdf), analysis_crs.to_string(), snap_tolerance, join_at)
375
+ else:
376
+ log.info("New lines: %d | changes to existing streets: %d | analysis CRS: %s | "
377
+ "network type: %s | snap tolerance: %s m", len(custom_data_gdf),
378
+ len(edits_gdf), analysis_crs.to_string(), network_type, snap_tolerance)
379
+ if blanket_tags:
380
+ log.info("Blanket tags (%s): %s",
381
+ "replacing feature values" if overwrite_tags else "filling gaps",
382
+ blanket_tags)
383
+
384
+ # =========================================================
385
+ # 2. Get the existing OSM network
386
+ # =========================================================
387
+
388
+ if standalone:
389
+ step(2, "No OpenStreetMap network (standalone)")
390
+ nodes_gdf, edges_gdf = _no_network(analysis_crs)
391
+ elif osm_source is not None:
392
+ step(2, f"Reading OSM network from {Path(osm_source).name}")
393
+ nodes_gdf, edges_gdf = get_osm_data_from_file(
394
+ osm_source,
395
+ bbox_gdf,
396
+ analysis_crs,
397
+ network_type=network_type,
398
+ )
399
+ else:
400
+ step(2, "Downloading OSM network")
401
+ nodes_gdf, edges_gdf = get_osm_data_from_bbox(
402
+ bbox_gdf,
403
+ analysis_crs,
404
+ network_type=network_type,
405
+ )
406
+
407
+ if not standalone:
408
+ log.info("OSM network: %d nodes, %d edges", len(nodes_gdf), len(edges_gdf))
409
+
410
+ # OSM as it is (all tags, turn restrictions): export writes the
411
+ # existing network from this. Re-attached to the results at the end.
412
+ source_data = edges_gdf.attrs.pop(SOURCE_ATTR, None)
413
+
414
+ # The untouched network is the "before"; changes to existing streets
415
+ # go into a copy, which the new lines are then joined to.
416
+ osm_nodes_gdf, osm_edges_gdf, changes, removed = nodes_gdf, edges_gdf, {}, 0
417
+ if not edits_gdf.empty:
418
+ edges_gdf, changes, removed = apply_edits(
419
+ osm_edges_gdf, nodes_gdf, edits_gdf, source_data, snap_tolerance,
420
+ bidirectional=is_bidirectional(network_type), strict=strict,
421
+ )
422
+ log.info("Changed %d existing edge(s), removed %d",
423
+ int(edges_gdf.get("modified", pd.Series()).eq("yes").sum()), removed)
424
+ if removed:
425
+ # Nodes only removed streets used are gone too: a new line must
426
+ # not be joined to a junction that is no longer there.
427
+ in_use = set(edges_gdf["u"]) | set(edges_gdf["v"])
428
+ nodes_gdf = nodes_gdf[nodes_gdf.index.isin(in_use)]
429
+
430
+ def finish(nodes, edges):
431
+ if edges is osm_edges_gdf:
432
+ # Nothing changed the streets (turn restrictions only): the
433
+ # after network still needs its own table, or the turns would
434
+ # land in the before network too.
435
+ edges = edges.copy()
436
+ node_changes = {}
437
+ if not points_gdf.empty:
438
+ # Before the turn restrictions: cutting a street changes the
439
+ # neighbours of the junctions next to it.
440
+ taken = [int(nodes.index.max()) if len(nodes) else 0,
441
+ int(osm_nodes_gdf.index.max()) if len(osm_nodes_gdf) else 0]
442
+ if source_data is not None:
443
+ taken += list(source_data.ferry_nodes)
444
+ nodes, edges, node_changes = place_points(
445
+ points_gdf, nodes, edges, snap_tolerance, max(taken) + 1,
446
+ source_data.node_tags if source_data is not None else {}, strict=strict,
447
+ osm_nodes=osm_nodes_gdf.index)
448
+ if source_data is not None:
449
+ edges.attrs[SOURCE_ATTR] = source_data
450
+ osm_edges_gdf.attrs[SOURCE_ATTR] = source_data
451
+ edges.attrs[EDITS_ATTR] = changes
452
+ edges.attrs[NODE_CHANGES_ATTR] = node_changes
453
+ edges.attrs[REMOVED_ATTR] = removed
454
+ edges.attrs[TURNS_ATTR] = resolve_turns(
455
+ turns_gdf, nodes, edges, snap_tolerance, strict=strict)
456
+ if return_source_osm:
457
+ return nodes, edges, osm_nodes_gdf, osm_edges_gdf
458
+ return nodes, edges
459
+
460
+ if custom_data_gdf.empty:
461
+ if not changes and not removed and turns_gdf.empty and points_gdf.empty:
462
+ raise InputError("Nothing to build: no new lines, and no feature changes or "
463
+ "removes an existing street, adds a turn restriction or "
464
+ "tags a node (a point).",
465
+ guide="changing-existing-streets")
466
+ log.info("No new lines: the network is OSM with %d change(s) and %d edge(s) removed",
467
+ len(changes), removed)
468
+ return finish(nodes_gdf, edges_gdf)
469
+
470
+ # =========================================================
471
+ # 3. Combine OSM and custom network
472
+ # =========================================================
473
+
474
+ step(3, "Joining line ends that nearly meet" if standalone
475
+ else "Snapping custom lines and combining with the OSM network")
476
+
477
+ # Custom vertices within snap_tolerance of the OSM network are moved
478
+ # exactly onto it (nearest node, else nearest edge), so the line
479
+ # joins the network itself rather than a point next to it.
480
+ custom_parts_gdf = custom_data_gdf.explode(index_parts=False)
481
+ # Where a line crosses itself it is cut, but those cuts are not ends
482
+ # the user drew: they mustn't join what only ends may (a motorway).
483
+ cuts = np.empty((0, 2))
484
+ if not at_vertices:
485
+ custom_parts_gdf, cuts = split_at_self_crossings(custom_parts_gdf)
486
+ custom_parts_gdf = snap_line_ends_together(
487
+ custom_parts_gdf, snap_tolerance, every_vertex=at_vertices, not_ends=cuts)
488
+ # Line ends may also land on a ferry's end that no street reaches.
489
+ terminals = _lone_ferry_terminals(source_data, nodes_gdf)
490
+ nodes_and_terminals = (pd.concat([nodes_gdf, terminals]) if len(terminals)
491
+ else nodes_gdf)
492
+ custom_data_gdf = snap_line_vertices_to_network(
493
+ custom_parts_gdf,
494
+ nodes_and_terminals,
495
+ edges_gdf,
496
+ snap_tolerance,
497
+ not_ends=cuts,
498
+ )
499
+
500
+ cuts = cuts_after_snapping(custom_parts_gdf, custom_data_gdf, cuts)
501
+ if not at_vertices:
502
+ custom_data_gdf = keep_grade_separated_vertices_apart(
503
+ custom_data_gdf, nodes_gdf, edges_gdf)
504
+
505
+ if custom_data_gdf.empty:
506
+ raise InputError(
507
+ f"Every custom line is shorter than snap_tolerance ({snap_tolerance}) "
508
+ "and collapsed onto a single existing node - nothing to add."
509
+ )
510
+
511
+ # The snapped lines are numbered by position; keep each one's feature id.
512
+ custom_feature_ids = custom_parts_gdf.index[custom_data_gdf.index]
513
+
514
+ combined_gdf = combine_custom_lines_with_osm_edges(
515
+ custom_data_gdf,
516
+ edges_gdf,
517
+ analysis_crs,
518
+ )
519
+
520
+ # =========================================================
521
+ # 4. Check the custom network reaches the OSM network
522
+ # =========================================================
523
+
524
+ step(4, "Checking the lines" if standalone
525
+ else "Checking custom lines reach the OSM network")
526
+
527
+ if not standalone:
528
+ validate_user_osm_intersection(
529
+ edges_gdf,
530
+ custom_data_gdf,
531
+ buffer_distance=snap_tolerance,
532
+ )
533
+
534
+ # =========================================================
535
+ # 5. Find network topology points
536
+ # =========================================================
537
+
538
+ step(5, "Finding junctions")
539
+
540
+ custom_points_gdf = create_points_from_gdf(
541
+ combined_gdf,
542
+ crossings=not at_vertices,
543
+ not_ends=cuts,
544
+ )
545
+
546
+ # Crossings near an OSM node are moved onto it before splitting.
547
+ custom_points_gdf = snap_crossings_to_network(
548
+ custom_points_gdf,
549
+ custom_data_gdf,
550
+ nodes_gdf,
551
+ edges_gdf,
552
+ snap_tolerance,
553
+ )
554
+
555
+ log.debug("Topology points: %d", len(custom_points_gdf))
556
+
557
+ # =========================================================
558
+ # 6. Split lines at topology points
559
+ # =========================================================
560
+
561
+ step(6, "Splitting lines at junctions")
562
+
563
+ split_lines_gdf = split_lines_with_buffered_points(
564
+ combined_gdf,
565
+ custom_points_gdf,
566
+ buffer_distance=snap_tolerance,
567
+ own_vertices_only=at_vertices,
568
+ )
569
+
570
+ if standalone:
571
+ log.debug("Split lines: %d", len(split_lines_gdf))
572
+ else:
573
+ log.debug("Split lines: %d (%d custom)",
574
+ len(split_lines_gdf), _count_custom(split_lines_gdf))
575
+
576
+ # =========================================================
577
+ # 7. Combine OSM nodes and custom nodes
578
+ # =========================================================
579
+
580
+ step(7, "Numbering junctions" if standalone else "Combining OSM and new nodes")
581
+
582
+ # New ids go above every id in the area's OSM data, including nodes
583
+ # that aren't in nodes_gdf: those of removed streets and of ferries.
584
+ taken = [int(osm_nodes_gdf.index.max()) if len(osm_nodes_gdf) else 0]
585
+ if source_data is not None:
586
+ taken += list(source_data.ferry_nodes)
587
+ # Only points ON an existing node become that node. Snapping (steps 3
588
+ # and 5) has already moved every point that should join a node exactly
589
+ # onto it; a point merely near a node was left there on purpose - the
590
+ # node is on a bridge or tunnel, say, and the point on the street
591
+ # above. Merging by distance here joined such ways to each other.
592
+ combined_points_gdf = remove_duplicates_and_combine_nodes(
593
+ custom_points_gdf[["geometry"]],
594
+ nodes_and_terminals,
595
+ buffer_distance=ON_NODE_TOLERANCE,
596
+ first_new_id=max(taken) + 1,
597
+ )
598
+
599
+ log.debug("Combined nodes: %d (%d new)",
600
+ len(combined_points_gdf), len(combined_points_gdf) - len(nodes_gdf))
601
+
602
+ # =========================================================
603
+ # 8. Select lines requiring node assignment
604
+ # =========================================================
605
+
606
+ step(8, "Selecting lines that need node ids")
607
+
608
+ osm_split_lines_gdf = filter_split_lines(
609
+ split_lines_gdf
610
+ )
611
+
612
+ # =========================================================
613
+ # 9. Assign node IDs to line endpoints
614
+ # =========================================================
615
+
616
+ step(9, "Assigning node ids to line ends")
617
+
618
+ # Each line end takes the nearest node within snap_tolerance: its
619
+ # own point from step 7, or the existing node that point was on.
620
+ updated_lines = assign_point_ids_to_lines(
621
+ osm_split_lines_gdf,
622
+ combined_points_gdf,
623
+ buffer_distance=snap_tolerance,
624
+ )
625
+
626
+ log.debug("Updated lines: %d (%d dropped as self-loops or unmatched)",
627
+ len(updated_lines), len(osm_split_lines_gdf) - len(updated_lines))
628
+
629
+ # =========================================================
630
+ # 10. Finalise the network edges
631
+ # =========================================================
632
+
633
+ step(10, "Finalising edges")
634
+
635
+ # Pieces left without two different end nodes (the zero-length piece
636
+ # cut off where a line is split at its own end) must go: an existing
637
+ # street's piece would otherwise keep the whole street's u and v, a
638
+ # second copy of the street that bypasses the new junctions on it.
639
+ not_edges = osm_split_lines_gdf.index.difference(updated_lines.index)
640
+
641
+ final_lines_gdf = update_and_finalize_lines_gdf(
642
+ split_lines_gdf.drop(not_edges),
643
+ updated_lines,
644
+ )
645
+ # Ferry ends no new line reached stay out of the street network.
646
+ unreached = terminals.index.difference(pd.concat([final_lines_gdf["u"], final_lines_gdf["v"]]))
647
+ combined_points_gdf = combined_points_gdf.drop(unreached)
648
+
649
+ # =========================================================
650
+ # 11. Validate topology
651
+ # =========================================================
652
+
653
+ step(11, "Checking every edge references real nodes")
654
+
655
+ final_lines_gdf = check_line_node_consistency(
656
+ final_lines_gdf,
657
+ combined_points_gdf,
658
+ strict=strict,
659
+ )
660
+
661
+ # =========================================================
662
+ # 12. Validate CRS consistency
663
+ # =========================================================
664
+
665
+ step(12, "Checking CRS consistency")
666
+
667
+ if combined_points_gdf.crs != final_lines_gdf.crs:
668
+ raise NetworkIntegrityError(
669
+ "Nodes and edges must use the same CRS."
670
+ )
671
+
672
+ # =========================================================
673
+ # 13. Final structural validation
674
+ # =========================================================
675
+
676
+ step(13, "Running structural checks")
677
+
678
+ try:
679
+ assert_all_edges_have_valid_nodes(final_lines_gdf, combined_points_gdf)
680
+ assert_no_u_equals_v(final_lines_gdf)
681
+ assert_custom_lines_unbroken(
682
+ final_lines_gdf,
683
+ len(custom_data_gdf.explode(index_parts=True)),
684
+ )
685
+ except NetworkIntegrityError as exc:
686
+ if strict:
687
+ raise
688
+ log.warning("strict=False, continuing anyway: %s", exc)
689
+
690
+ (_warn_about_separate_pieces if standalone else _warn_about_disconnected_features)(
691
+ final_lines_gdf,
692
+ custom_data_gdf.set_axis(custom_feature_ids),
693
+ snap_tolerance,
694
+ )
695
+
696
+ if standalone:
697
+ log.info("Network built: %d nodes, %d edges",
698
+ len(combined_points_gdf), len(final_lines_gdf))
699
+ else:
700
+ log.info("Network built: %d nodes, %d edges (%d custom)",
701
+ len(combined_points_gdf), len(final_lines_gdf), _count_custom(final_lines_gdf))
702
+
703
+ return finish(combined_points_gdf, final_lines_gdf)