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,1086 @@
1
+ import logging
2
+
3
+ import geopandas as gpd
4
+ import numpy as np
5
+ import pandas as pd
6
+ import shapely
7
+ from pyproj import CRS
8
+
9
+ from .errors import InputError, NetworkIntegrityError, NoIntersectionError
10
+ from .modes import is_ferry
11
+ from .tags import FERRY_ROUTES
12
+
13
+ log = logging.getLogger(__name__)
14
+
15
+ # A point this close to a line (projected CRS units) counts as on it -
16
+ # covers floating point error after projecting a point onto a line.
17
+ ON_LINE_TOLERANCE = 1e-6
18
+
19
+
20
+ # A point this close to a node (projected CRS units) is on it. Snapping
21
+ # puts points exactly on nodes; this only allows for rounding.
22
+ ON_NODE_TOLERANCE = 1e-3
23
+
24
+ # A middle vertex of a line that sits exactly on something the line may
25
+ # not join there (a bridge's vertex on the junction below it) is moved
26
+ # this far (projected CRS units), so it gets a node of its own. More than
27
+ # ON_NODE_TOLERANCE, far less than anything a router can tell apart.
28
+ APART = 5e-3
29
+
30
+ # Highway types that never have at-grade junctions. Trunk roads are left
31
+ # out on purpose: many (e.g. UK A roads) have ordinary junctions.
32
+ GRADE_SEPARATED_HIGHWAYS = {"motorway", "motorway_link"}
33
+
34
+
35
+ # Values that mean a bridge/tunnel tag is not there (routers read 0 and
36
+ # false as no too).
37
+ _ABSENT = {"no", "0", "false"}
38
+
39
+
40
+ def _tag_present(value) -> bool:
41
+ """True for a real tag value other than no/0/false (None/NaN = absent)."""
42
+ if value is None or (isinstance(value, float) and value != value):
43
+ return False
44
+ return str(value).strip().lower() not in _ABSENT
45
+
46
+
47
+ def _layer(tags) -> int:
48
+ try:
49
+ return int(float(tags.get("layer")))
50
+ except (TypeError, ValueError):
51
+ return 0
52
+
53
+
54
+ def is_grade_separated(tags) -> bool:
55
+ """
56
+ A way that other ways cross over/under rather than join: motorways
57
+ and their slip roads, bridges and tunnels - and ferries, which meet
58
+ the streets only at their ends. `tags` is a dict or a row (Series)
59
+ of tag values.
60
+ """
61
+ return (
62
+ tags.get("highway") in GRADE_SEPARATED_HIGHWAYS
63
+ or _tag_present(tags.get("bridge"))
64
+ or _tag_present(tags.get("tunnel"))
65
+ or is_ferry(tags)
66
+ )
67
+
68
+
69
+ def _separated_mask(gdf: gpd.GeoDataFrame) -> np.ndarray:
70
+ """is_grade_separated for every row, at once."""
71
+ separated = np.zeros(len(gdf), dtype=bool)
72
+ if "highway" in gdf:
73
+ separated |= gdf["highway"].astype(str).isin(GRADE_SEPARATED_HIGHWAYS).to_numpy()
74
+ for key in ("bridge", "tunnel"):
75
+ if key in gdf:
76
+ separated |= gdf[key].map(_tag_present).to_numpy(dtype=bool)
77
+ if "route" in gdf:
78
+ no_highway = gdf["highway"].isna() if "highway" in gdf else True
79
+ separated |= (no_highway & gdf["route"].isin(FERRY_ROUTES)).to_numpy(dtype=bool)
80
+ return separated
81
+
82
+
83
+ def _layers(gdf: gpd.GeoDataFrame) -> np.ndarray:
84
+ """_layer for every row, at once."""
85
+ if "layer" not in gdf:
86
+ return np.zeros(len(gdf), dtype=int)
87
+ return pd.to_numeric(gdf["layer"], errors="coerce").fillna(0).to_numpy().astype(int)
88
+
89
+
90
+ def crosses_at_grade(tags_a, tags_b) -> bool:
91
+ """True if two ways that cross should get a junction at the crossing."""
92
+ return (
93
+ not is_grade_separated(tags_a)
94
+ and not is_grade_separated(tags_b)
95
+ and _layer(tags_a) == _layer(tags_b)
96
+ )
97
+
98
+
99
+ def joinable_network(
100
+ nodes_gdf: gpd.GeoDataFrame,
101
+ edges_gdf: gpd.GeoDataFrame,
102
+ ) -> tuple[gpd.GeoDataFrame, gpd.GeoDataFrame]:
103
+ """
104
+ The part of the OSM network a custom line may join mid-way:
105
+ edges that aren't grade-separated, and the nodes on them. (A custom
106
+ line may still END on a grade-separated way - e.g. a new slip road.)
107
+ """
108
+ separated = edges_gdf["highway"].astype(str).isin(GRADE_SEPARATED_HIGHWAYS)
109
+ for key in ("bridge", "tunnel"):
110
+ if key in edges_gdf.columns:
111
+ separated |= edges_gdf[key].map(_tag_present).astype(bool)
112
+
113
+ edges = edges_gdf[~separated]
114
+ nodes = nodes_gdf[nodes_gdf.index.isin(set(edges["u"]) | set(edges["v"]))]
115
+ return nodes, edges
116
+
117
+
118
+ def validate_projected_crs(gdf: gpd.GeoDataFrame) -> None:
119
+ """
120
+ Ensure a GeoDataFrame has a projected CRS suitable for
121
+ distance and buffer operations.
122
+ """
123
+
124
+ if gdf.crs is None:
125
+ raise InputError(
126
+ "GeoDataFrame must have a CRS assigned."
127
+ )
128
+
129
+ crs = CRS.from_user_input(gdf.crs)
130
+
131
+ if not crs.is_projected:
132
+ raise InputError(
133
+ f"GeoDataFrame must use a projected CRS for topology operations. "
134
+ f"Received: {crs}"
135
+ )
136
+
137
+ def validate_user_osm_intersection(
138
+ lines_gdf: gpd.GeoDataFrame,
139
+ points_gdf: gpd.GeoDataFrame,
140
+ buffer_distance: float = 1.0,
141
+ ) -> None:
142
+ """
143
+ Ensure that user data intersects the OSM network.
144
+
145
+ buffer_distance is measured in the units of the projected
146
+ analysis CRS, normally metres.
147
+ """
148
+
149
+ validate_projected_crs(lines_gdf)
150
+ validate_projected_crs(points_gdf)
151
+
152
+ buffered = points_gdf.copy()
153
+ buffered["geometry"] = buffered.geometry.buffer(buffer_distance)
154
+
155
+ sjoin_result = gpd.sjoin(
156
+ lines_gdf,
157
+ buffered,
158
+ how="inner",
159
+ predicate="intersects",
160
+ )
161
+
162
+ if sjoin_result.empty:
163
+ raise NoIntersectionError(
164
+ f"None of the custom lines touch (or come within {buffer_distance} m of) "
165
+ "the OSM network in the bounding box, so they can't be joined to it. "
166
+ "Check the lines are in the right place and CRS.",
167
+ guide="preparing-your-data",
168
+ )
169
+
170
+ def snap_points_to_nodes(
171
+ points_gdf: gpd.GeoDataFrame,
172
+ nodes_gdf: gpd.GeoDataFrame,
173
+ tolerance: float,
174
+ edges_gdf: gpd.GeoDataFrame | None = None,
175
+ ) -> gpd.GeoDataFrame:
176
+ """
177
+ Move every point within `tolerance` of an OSM node exactly onto
178
+ the nearest such node. Points further away are left as they are.
179
+
180
+ edges_gdf: a point that already lies ON one of these edges only
181
+ moves onto an end node of that edge. Otherwise a line crossing a
182
+ street right beside the dead end of another way was pulled onto the
183
+ dead end, and never joined the street it crosses.
184
+
185
+ Snapping BEFORE lines are split means every split happens at an
186
+ exact node position, so later steps never have to guess which
187
+ nearby node a line end was meant to join.
188
+ """
189
+
190
+ validate_projected_crs(points_gdf)
191
+ validate_projected_crs(nodes_gdf)
192
+
193
+ snapped = points_gdf.reset_index(drop=True)
194
+ if snapped.empty or nodes_gdf.empty:
195
+ return snapped
196
+
197
+ points = snapped.geometry.to_numpy()
198
+ node_geometries = nodes_gdf.geometry.to_numpy()
199
+ point, node = shapely.STRtree(node_geometries).query(
200
+ points, predicate="dwithin", distance=tolerance)
201
+ if not len(point):
202
+ return snapped
203
+
204
+ if edges_gdf is not None and not edges_gdf.empty:
205
+ on_point, on_edge = shapely.STRtree(edges_gdf.geometry.to_numpy()).query(
206
+ points, predicate="dwithin", distance=ON_LINE_TOLERANCE)
207
+ if len(on_point):
208
+ ends = set(zip(on_point.tolist(), edges_gdf["u"].to_numpy()[on_edge].tolist(),
209
+ strict=True))
210
+ ends |= set(zip(on_point.tolist(), edges_gdf["v"].to_numpy()[on_edge].tolist(),
211
+ strict=True))
212
+ lies_on_an_edge = np.zeros(len(points), dtype=bool)
213
+ lies_on_an_edge[on_point] = True
214
+ node_ids = nodes_gdf.index.to_numpy()[node].tolist()
215
+ allowed = np.array([not lies_on_an_edge[p] or (p, n) in ends
216
+ for p, n in zip(point.tolist(), node_ids, strict=True)])
217
+ point, node = point[allowed], node[allowed]
218
+ if not len(point):
219
+ return snapped
220
+
221
+ # The nearest node of each point (equidistant nodes: the first).
222
+ distance = shapely.distance(points[point], node_geometries[node])
223
+ order = np.lexsort((node, distance, point))
224
+ point, node = point[order], node[order]
225
+ first = np.r_[True, point[1:] != point[:-1]]
226
+ snapped.loc[point[first], "geometry"] = node_geometries[node[first]]
227
+
228
+ return snapped
229
+
230
+
231
+ def snap_points_to_edges(
232
+ points_gdf: gpd.GeoDataFrame,
233
+ edges_gdf: gpd.GeoDataFrame,
234
+ tolerance: float,
235
+ ) -> gpd.GeoDataFrame:
236
+ """
237
+ Move every point within `tolerance` of an OSM edge onto the
238
+ closest position ON the nearest such edge. Points already on an
239
+ edge (e.g. crossings) don't move.
240
+ """
241
+
242
+ validate_projected_crs(points_gdf)
243
+ validate_projected_crs(edges_gdf)
244
+
245
+ snapped = points_gdf.reset_index(drop=True)
246
+ if snapped.empty or edges_gdf.empty:
247
+ return snapped
248
+
249
+ edge_geoms = gpd.GeoDataFrame(geometry=edges_gdf.geometry.values, crs=edges_gdf.crs)
250
+
251
+ nearest = gpd.sjoin_nearest(
252
+ snapped[["geometry"]],
253
+ edge_geoms,
254
+ how="inner",
255
+ max_distance=tolerance,
256
+ )
257
+ nearest = nearest[~nearest.index.duplicated(keep="first")]
258
+
259
+ edges = edge_geoms.geometry.iloc[nearest["index_right"]].values
260
+ points = snapped.geometry.loc[nearest.index].values
261
+ snapped.loc[nearest.index, "geometry"] = [
262
+ edge.interpolate(edge.project(point)) for edge, point in zip(edges, points, strict=True)
263
+ ]
264
+
265
+ return snapped
266
+
267
+
268
+ def snap_points_to_network(
269
+ points_gdf: gpd.GeoDataFrame,
270
+ nodes_gdf: gpd.GeoDataFrame,
271
+ edges_gdf: gpd.GeoDataFrame,
272
+ tolerance: float,
273
+ ) -> gpd.GeoDataFrame:
274
+ """
275
+ Snap points within `tolerance` onto the existing network: onto the
276
+ nearest OSM node if one is in range, otherwise onto the nearest OSM
277
+ edge. Points further away stay put.
278
+ """
279
+ near_node = snap_points_to_nodes(points_gdf, nodes_gdf, tolerance, edges_gdf)
280
+ return snap_points_to_edges(near_node, edges_gdf, tolerance)
281
+
282
+
283
+ def split_at_self_crossings(lines_gdf: gpd.GeoDataFrame) -> tuple[gpd.GeoDataFrame, np.ndarray]:
284
+ """
285
+ Cut each (single-part) line that crosses itself into parts that end
286
+ at the crossing, so the crossing becomes a junction like any other
287
+ place two lines cross at-grade. Lines that are grade-separated
288
+ (a bridge looping over itself) are left whole. Parts keep the line's
289
+ index label and attributes.
290
+
291
+ Also returns the cuts (x, y rows): part ends that aren't ends of a
292
+ drawn line, to pass as `not_ends` to the steps that let only ends
293
+ join a grade-separated way.
294
+ """
295
+ no_cuts = np.empty((0, 2))
296
+ crossing = ~lines_gdf.geometry.is_simple.to_numpy()
297
+ if crossing.any():
298
+ separated = np.array([is_grade_separated(row) for _, row in lines_gdf[crossing].iterrows()])
299
+ crossing[np.flatnonzero(crossing)[separated]] = False
300
+ if not crossing.any():
301
+ return lines_gdf, no_cuts
302
+
303
+ geometries = lines_gdf.geometry.to_numpy().copy()
304
+ drawn_ends = _end_coordinates(geometries)
305
+ geometries[crossing] = shapely.node(geometries[crossing])
306
+ parts = lines_gdf.set_geometry(geometries, crs=lines_gdf.crs).explode(index_parts=False)
307
+ part_ends = np.unique(_end_coordinates(parts.geometry.to_numpy()), axis=0)
308
+ cuts = part_ends[~_rows_in(part_ends, drawn_ends)]
309
+ return parts, cuts
310
+
311
+
312
+ def _end_coordinates(geometries: np.ndarray) -> np.ndarray:
313
+ """The first and last coordinates of each line, as (x, y) rows."""
314
+ return np.vstack([shapely.get_coordinates(shapely.get_point(geometries, 0)),
315
+ shapely.get_coordinates(shapely.get_point(geometries, -1))])
316
+
317
+
318
+ def _rows_in(coords: np.ndarray, rows: np.ndarray) -> np.ndarray:
319
+ """For each (x, y) row of coords: is it exactly one of `rows`?"""
320
+ if not len(rows) or not len(coords):
321
+ return np.zeros(len(coords), dtype=bool)
322
+ known = {tuple(row) for row in rows.tolist()}
323
+ return np.array([tuple(row) in known for row in coords.tolist()], dtype=bool)
324
+
325
+
326
+ def snap_line_ends_together(
327
+ lines_gdf: gpd.GeoDataFrame,
328
+ tolerance: float,
329
+ every_vertex: bool = False,
330
+ not_ends: np.ndarray | None = None,
331
+ ) -> gpd.GeoDataFrame:
332
+ """
333
+ Where ends of different (single-part) lines lie within `tolerance`
334
+ of each other without touching, move them onto one point: the end of
335
+ the first of those lines. Two lines drawn to meet end to end, but a
336
+ few centimetres apart, then share a node - instead of each being bent
337
+ to reach the other's end, which joined them with a doubled-back stub.
338
+
339
+ every_vertex: do the same for all vertices, not only the ends (for
340
+ networks whose lines join wherever they share a vertex).
341
+ not_ends: (x, y) rows of part ends that aren't line ends (see
342
+ split_at_self_crossings); they stay put.
343
+ """
344
+ geometries = lines_gdf.geometry.to_numpy()
345
+ if len(geometries) < 2:
346
+ return lines_gdf
347
+
348
+ coords, line = shapely.get_coordinates(geometries, return_index=True)
349
+ if every_vertex:
350
+ candidates = np.arange(len(coords))
351
+ else:
352
+ starts = np.flatnonzero(np.r_[True, line[1:] != line[:-1]])
353
+ stops = np.flatnonzero(np.r_[line[1:] != line[:-1], True])
354
+ candidates = np.unique(np.r_[starts, stops])
355
+ if not_ends is not None:
356
+ candidates = candidates[~_rows_in(coords[candidates], not_ends)]
357
+
358
+ points = shapely.points(coords[candidates])
359
+ a, b = shapely.STRtree(points).query(points, predicate="dwithin", distance=tolerance)
360
+ a, b = candidates[a], candidates[b]
361
+ near = (a < b) & (line[a] != line[b]) & (coords[a] != coords[b]).any(axis=1)
362
+ if not near.any():
363
+ return lines_gdf
364
+
365
+ # Group the points that are near each other; each group takes the
366
+ # position of its first member.
367
+ group = np.arange(len(coords))
368
+
369
+ def find(i: int) -> int:
370
+ while group[i] != i:
371
+ group[i] = group[group[i]]
372
+ i = group[i]
373
+ return i
374
+
375
+ for i, j in zip(a[near].tolist(), b[near].tolist(), strict=True):
376
+ first, second = sorted((find(i), find(j)))
377
+ group[second] = first
378
+ involved = np.unique(np.r_[a[near], b[near]])
379
+ target = coords.copy()
380
+ target[involved] = coords[[find(i) for i in involved.tolist()]]
381
+
382
+ new_geometries = geometries.copy()
383
+ moved = np.unique(line[(target != coords).any(axis=1)])
384
+ for number in moved.tolist():
385
+ new_geometries[number] = shapely.linestrings(target[line == number])
386
+ return lines_gdf.set_geometry(new_geometries, crs=lines_gdf.crs)
387
+
388
+
389
+ def snap_line_vertices_to_network(
390
+ lines_gdf: gpd.GeoDataFrame,
391
+ nodes_gdf: gpd.GeoDataFrame,
392
+ edges_gdf: gpd.GeoDataFrame,
393
+ tolerance: float,
394
+ not_ends: np.ndarray | None = None,
395
+ ) -> gpd.GeoDataFrame:
396
+ """
397
+ Snap each vertex of each (single-part) line onto the existing
398
+ network (see snap_points_to_network). A line's two end points may
399
+ snap to anything; its middle vertices only to ways it can join
400
+ at-grade (see joinable_network), and not at all if the line is
401
+ itself grade-separated (e.g. tagged bridge=yes). Part ends listed in
402
+ `not_ends` (cuts where a line crosses itself) count as middles.
403
+
404
+ Repeated vertices this creates are removed; a line that collapses
405
+ to one point is dropped.
406
+ """
407
+
408
+ feature_ids = lines_gdf.index # for messages; work positionally below
409
+ lines = lines_gdf.reset_index(drop=True)
410
+
411
+ # Every vertex, numbered by its line (vectorised: no loop over lines).
412
+ coords, line = shapely.get_coordinates(lines.geometry.to_numpy(), return_index=True)
413
+ new_line = np.r_[True, line[1:] != line[:-1]]
414
+ is_end = new_line | np.r_[line[1:] != line[:-1], True]
415
+ if not_ends is not None:
416
+ is_end &= ~_rows_in(coords, not_ends)
417
+ points = gpd.GeoDataFrame(
418
+ {"line": line, "is_end": is_end, "separated": _separated_mask(lines)[line]},
419
+ geometry=shapely.points(coords),
420
+ crs=lines.crs,
421
+ )
422
+
423
+ joinable_nodes, joinable_edges = joinable_network(nodes_gdf, edges_gdf)
424
+ ends = points["is_end"]
425
+ middles = ~points["is_end"] & ~points["separated"]
426
+
427
+ points.loc[ends, "geometry"] = snap_points_to_network(
428
+ points[ends], nodes_gdf, edges_gdf, tolerance
429
+ ).geometry.values
430
+ points.loc[middles, "geometry"] = snap_points_to_network(
431
+ points[middles], joinable_nodes, joinable_edges, tolerance
432
+ ).geometry.values
433
+
434
+ # Rebuild the lines without vertices repeated by snapping.
435
+ xy = shapely.get_coordinates(points.geometry.to_numpy())
436
+ keep = new_line | np.r_[False, (xy[1:] != xy[:-1]).any(axis=1)]
437
+ long_enough = np.bincount(line[keep], minlength=len(lines)) >= 2
438
+ keep &= long_enough[line]
439
+ new_geometries = np.full(len(lines), None, dtype=object)
440
+ if keep.any():
441
+ # linestrings() wants indices without gaps (dropped lines leave some).
442
+ kept_lines, compact = np.unique(line[keep], return_inverse=True)
443
+ new_geometries[kept_lines] = shapely.linestrings(xy[keep], indices=compact)
444
+
445
+ lines["geometry"] = new_geometries
446
+ collapsed = lines.geometry.isna()
447
+ if collapsed.any():
448
+ dropped = list(dict.fromkeys(feature_ids[collapsed.to_numpy()]))
449
+ log.warning("%d custom line(s) shorter than the snap tolerance collapsed onto "
450
+ "a single node and were dropped (features %s)", int(collapsed.sum()),
451
+ ", ".join(map(str, dropped)), extra={"features": dropped})
452
+
453
+ return lines[~collapsed]
454
+
455
+
456
+ def cuts_after_snapping(
457
+ before: gpd.GeoDataFrame,
458
+ after: gpd.GeoDataFrame,
459
+ cuts: np.ndarray,
460
+ ) -> np.ndarray:
461
+ """
462
+ Where the `cuts` (see split_at_self_crossings) are once
463
+ snap_line_vertices_to_network has moved the lines: `after` is its
464
+ result for `before`. A cut that snapping moved was no longer found
465
+ in the list, and so passed for a line end - which may join a
466
+ motorway or a tunnel.
467
+ """
468
+ if not len(cuts) or after.empty:
469
+ return cuts
470
+ old = before.geometry.to_numpy()[after.index.to_numpy()]
471
+ new = after.geometry.to_numpy()
472
+ moved = [shapely.get_coordinates(shapely.get_point(new, which))[
473
+ _rows_in(shapely.get_coordinates(shapely.get_point(old, which)), cuts)]
474
+ for which in (0, -1)]
475
+ return np.unique(np.vstack([cuts, *moved]), axis=0)
476
+
477
+
478
+ def keep_grade_separated_vertices_apart(
479
+ lines_gdf: gpd.GeoDataFrame,
480
+ nodes_gdf: gpd.GeoDataFrame,
481
+ edges_gdf: gpd.GeoDataFrame,
482
+ ) -> gpd.GeoDataFrame:
483
+ """
484
+ Only the ENDS of a bridge, tunnel or ferry join what they meet, and
485
+ only ends join a motorway. But nodes are told apart by where they
486
+ are: a middle vertex drawn exactly on a junction, or on a vertex of
487
+ another line, would share its node and so join it. Such vertices are
488
+ moved APART (a few millimetres):
489
+
490
+ - middle vertices of grade-separated lines that sit on an existing
491
+ node or on a vertex of another line;
492
+ - middle vertices of other lines that sit on a node only
493
+ grade-separated ways use (a motorway's), or on a vertex of a
494
+ grade-separated line.
495
+ """
496
+ geometries = lines_gdf.geometry.to_numpy()
497
+ if not len(geometries):
498
+ return lines_gdf
499
+ coords, line = shapely.get_coordinates(geometries, return_index=True)
500
+ is_end = np.r_[True, line[1:] != line[:-1]] | np.r_[line[1:] != line[:-1], True]
501
+ separated = _separated_mask(lines_gdf)[line]
502
+ points = shapely.points(coords)
503
+ move = np.zeros(len(coords), dtype=bool)
504
+
505
+ def on_any(targets: np.ndarray) -> np.ndarray:
506
+ found = np.zeros(len(coords), dtype=bool)
507
+ if len(targets):
508
+ hit, _ = shapely.STRtree(targets).query(
509
+ points, predicate="dwithin", distance=ON_NODE_TOLERANCE)
510
+ found[hit] = True
511
+ return found
512
+
513
+ joinable_nodes, _ = joinable_network(nodes_gdf, edges_gdf)
514
+ every_node = nodes_gdf.geometry.to_numpy()
515
+ separated_nodes = nodes_gdf.geometry[~nodes_gdf.index.isin(joinable_nodes.index)].to_numpy()
516
+ move |= ~is_end & separated & on_any(every_node)
517
+ move |= ~is_end & ~separated & on_any(separated_nodes)
518
+
519
+ # Vertices of different lines in the same place, one of them the
520
+ # middle of a grade-separated line: that one moves (of two such
521
+ # lines, the first).
522
+ a, b = shapely.STRtree(points).query(points, predicate="dwithin", distance=ON_NODE_TOLERANCE)
523
+ other_moves = separated[b] & ~is_end[b] & (line[b] < line[a])
524
+ pair = (line[a] != line[b]) & separated[a] & ~is_end[a] & ~other_moves
525
+ move[a[pair]] = True
526
+
527
+ if not move.any():
528
+ return lines_gdf
529
+ coords = coords.copy()
530
+ coords[move, 0] += APART
531
+ new_geometries = geometries.copy()
532
+ for number in np.unique(line[move]).tolist():
533
+ new_geometries[number] = shapely.linestrings(coords[line == number])
534
+ return lines_gdf.set_geometry(new_geometries, crs=lines_gdf.crs)
535
+
536
+
537
+ # What each junction point may join, carried with the points:
538
+ # is_end it is an end of a custom line (ends may join anything)
539
+ # separated every custom line it belongs to is a bridge / tunnel
540
+ # layers the layers of the custom lines it belongs to
541
+ POINT_COLUMNS = ["is_end", "separated", "layers"]
542
+
543
+
544
+ def _same_place(coords: np.ndarray, first: np.ndarray | None = None) -> np.ndarray:
545
+ """
546
+ (x, y) rows with every group of rows within ON_LINE_TOLERANCE of each
547
+ other given one position: that of the group's first row (`first`:
548
+ rows to prefer). Where a line end is moved onto a street, GEOS works
549
+ out the crossing a hair (1e-10 m) from the end itself; left as two
550
+ points they became two nodes in one place, joined by an edge of no
551
+ length.
552
+ """
553
+ if len(coords) < 2:
554
+ return coords
555
+ points = shapely.points(coords)
556
+ a, b = shapely.STRtree(points).query(points, predicate="dwithin", distance=ON_LINE_TOLERANCE)
557
+ near = (a != b) & (coords[a] != coords[b]).any(axis=1)
558
+ if not near.any():
559
+ return coords
560
+
561
+ rank = np.arange(len(coords))
562
+ if first is not None: # preferred rows first, each kind in its own order
563
+ rank = np.argsort(np.argsort(~first, kind="stable"))
564
+ group = np.arange(len(coords))
565
+
566
+ def find(i: int) -> int:
567
+ while group[i] != i:
568
+ group[i] = group[group[i]]
569
+ i = group[i]
570
+ return i
571
+
572
+ for i, j in zip(a[near].tolist(), b[near].tolist(), strict=True):
573
+ keep, other = sorted((find(i), find(j)), key=rank.__getitem__)
574
+ group[other] = keep
575
+ involved = np.unique(a[near])
576
+ coords = coords.copy()
577
+ coords[involved] = coords[[find(i) for i in involved.tolist()]]
578
+ return coords
579
+
580
+
581
+ def _merge_points(records: pd.DataFrame, crs) -> gpd.GeoDataFrame:
582
+ """
583
+ One point per place, from rows of x, y, is_end, separated and layer
584
+ (one layer per row; a place's `layers` are all its rows' layers).
585
+ """
586
+ if records.empty:
587
+ return gpd.GeoDataFrame({column: [] for column in POINT_COLUMNS}, geometry=[], crs=crs)
588
+ records = records.reset_index(drop=True)
589
+ records[["x", "y"]] = _same_place(records[["x", "y"]].to_numpy(dtype=float))
590
+ groups = records.groupby(["x", "y"], sort=False)
591
+ merged = groups.agg(is_end=("is_end", "any"), separated=("separated", "all"))
592
+ layers = (records.drop_duplicates(["x", "y", "layer"]).sort_values("layer", kind="stable")
593
+ .groupby(["x", "y"], sort=False)["layer"].agg(tuple))
594
+ merged["layers"] = layers.reindex(merged.index)
595
+ merged = merged.reset_index()
596
+ return gpd.GeoDataFrame(
597
+ merged[POINT_COLUMNS],
598
+ geometry=shapely.points(merged["x"].to_numpy(), merged["y"].to_numpy()),
599
+ crs=crs,
600
+ )
601
+
602
+
603
+ def create_points_from_gdf(
604
+ lines_gdf: gpd.GeoDataFrame,
605
+ crossings: bool = True,
606
+ not_ends: np.ndarray | None = None,
607
+ ) -> gpd.GeoDataFrame:
608
+ """
609
+ Create points from intersections and vertices of custom lines, each
610
+ with what it may join (POINT_COLUMNS). Part ends listed in `not_ends`
611
+ (see split_at_self_crossings) aren't line ends.
612
+
613
+ Only at-grade crossings become points (see crosses_at_grade): a
614
+ custom line crossing a motorway, bridge or tunnel - or tagged as a
615
+ bridge/tunnel itself - passes over/under without a junction.
616
+
617
+ crossings=False: vertices only. Lines then join where they share a
618
+ vertex, and merely crossing is not a junction.
619
+ """
620
+
621
+ validate_projected_crs(lines_gdf)
622
+
623
+ geometries = lines_gdf.geometry.to_numpy()
624
+ separated, layers = _separated_mask(lines_gdf), _layers(lines_gdf)
625
+ custom = np.flatnonzero(lines_gdf["u"].isna().to_numpy())
626
+ frames = []
627
+
628
+ if crossings and len(custom):
629
+ # Every (custom line, other line) pair that touches, at grade.
630
+ hit, other = shapely.STRtree(geometries).query(geometries[custom], predicate="intersects")
631
+ line = custom[hit]
632
+ at_grade = ((line != other) & ~separated[line] & ~separated[other]
633
+ & (layers[line] == layers[other]))
634
+ line, other = line[at_grade], other[at_grade]
635
+ # Where the lines overlap (a line drawn along a street) the
636
+ # overlap's vertices are junctions too: the street's nodes along
637
+ # it, and where the two part.
638
+ shared, pair = shapely.get_coordinates(
639
+ shapely.intersection(geometries[line], geometries[other]), return_index=True)
640
+ frames.append(pd.DataFrame({
641
+ "x": shared[:, 0], "y": shared[:, 1], "is_end": False,
642
+ "separated": separated[line][pair], "layer": layers[line][pair],
643
+ }))
644
+
645
+ if len(custom):
646
+ # Every vertex of every custom line part; part ends are line ends.
647
+ parts, part_of = shapely.get_parts(geometries[custom], return_index=True)
648
+ coords, part = shapely.get_coordinates(parts, return_index=True)
649
+ first = np.r_[True, part[1:] != part[:-1]]
650
+ last = np.r_[part[1:] != part[:-1], True]
651
+ line = custom[part_of[part]]
652
+ is_end = first | last
653
+ if not_ends is not None:
654
+ is_end &= ~_rows_in(coords, not_ends)
655
+ frames.append(pd.DataFrame({
656
+ "x": coords[:, 0], "y": coords[:, 1], "is_end": is_end,
657
+ "separated": separated[line], "layer": layers[line],
658
+ }))
659
+
660
+ records = pd.concat(frames, ignore_index=True) if frames else pd.DataFrame(
661
+ columns=["x", "y", "is_end", "separated", "layer"])
662
+ if len(frames) == 2:
663
+ # A crossing a hair away from a vertex is that vertex.
664
+ is_vertex = np.r_[np.zeros(len(frames[0]), dtype=bool), np.ones(len(frames[1]), dtype=bool)]
665
+ records[["x", "y"]] = _same_place(records[["x", "y"]].to_numpy(dtype=float), is_vertex)
666
+ return _merge_points(records, lines_gdf.crs)
667
+
668
+
669
+ def snap_crossings_to_network(
670
+ points_gdf: gpd.GeoDataFrame,
671
+ custom_lines_gdf: gpd.GeoDataFrame,
672
+ nodes_gdf: gpd.GeoDataFrame,
673
+ edges_gdf: gpd.GeoDataFrame,
674
+ tolerance: float,
675
+ ) -> gpd.GeoDataFrame:
676
+ """
677
+ Snap crossing points onto the joinable OSM network (a crossing
678
+ within tolerance of a node moves onto the node). Custom line
679
+ vertices are left alone: snap_line_vertices_to_network already put
680
+ them where they belong.
681
+ """
682
+
683
+ vertices = {coord for line in custom_lines_gdf.geometry for coord in line.coords}
684
+ points = points_gdf.reset_index(drop=True)
685
+ is_vertex = points.geometry.map(lambda p: (p.x, p.y) in vertices).astype(bool)
686
+
687
+ joinable_nodes, joinable_edges = joinable_network(nodes_gdf, edges_gdf)
688
+ crossings = snap_points_to_network(
689
+ points[~is_vertex], joinable_nodes, joinable_edges, tolerance
690
+ )
691
+
692
+ moved = pd.concat([points[is_vertex], crossings])
693
+ records = (pd.DataFrame(moved[POINT_COLUMNS]).assign(x=moved.geometry.x, y=moved.geometry.y)
694
+ .explode("layers").rename(columns={"layers": "layer"}))
695
+ return _merge_points(records, points_gdf.crs)
696
+
697
+
698
+ def split_lines_with_buffered_points(
699
+ lines_gdf: gpd.GeoDataFrame,
700
+ points_gdf: gpd.GeoDataFrame,
701
+ buffer_distance: float = 1.0,
702
+ own_vertices_only: bool = False,
703
+ ) -> gpd.GeoDataFrame:
704
+ """
705
+ Split lines at points.
706
+
707
+ Custom lines (u is NaN) are split at points within buffer_distance
708
+ (projected CRS units, normally metres), so they bend to meet the
709
+ snapped junction. Existing OSM lines are only split at points that
710
+ lie ON them: splitting at a nearby point would bend the existing
711
+ street sideways and can create shortcuts that don't exist.
712
+
713
+ own_vertices_only: a custom line is only split at its own vertices
714
+ (it then joins another line only where both have a vertex).
715
+ """
716
+
717
+ validate_projected_crs(lines_gdf)
718
+ validate_projected_crs(points_gdf)
719
+
720
+ buffered_points_gdf = points_gdf.copy()
721
+ buffered_points_gdf["geometry"] = (
722
+ buffered_points_gdf.geometry.buffer(buffer_distance)
723
+ )
724
+
725
+ sjoin_lines = gpd.sjoin(
726
+ lines_gdf,
727
+ buffered_points_gdf,
728
+ how="inner",
729
+ predicate="intersects",
730
+ )
731
+
732
+ is_osm = sjoin_lines["u"].notna()
733
+ distance = sjoin_lines.geometry.distance(
734
+ points_gdf.geometry.loc[sjoin_lines["index_right"]].set_axis(sjoin_lines.index)
735
+ )
736
+ # A line can be met by several points: work by position, not by label.
737
+ is_osm = is_osm.to_numpy()
738
+ on_line = is_osm & (distance.to_numpy() <= ON_LINE_TOLERANCE)
739
+ if on_line.any() and set(POINT_COLUMNS) <= set(points_gdf.columns):
740
+ # A point on an existing line only becomes a junction with it if
741
+ # the two may meet: the point is the end of a custom line, or the
742
+ # existing line is at grade on the custom line's layer. Otherwise a
743
+ # path crossing a street exactly above a tunnel would join the
744
+ # tunnel too.
745
+ rows = np.flatnonzero(on_line)
746
+ lines = sjoin_lines.iloc[rows]
747
+ points = points_gdf.loc[lines["index_right"]]
748
+ may_join = [
749
+ is_end or (not point_separated and not is_grade_separated(line)
750
+ and _layer(line) in layers)
751
+ for (_, line), is_end, point_separated, layers in zip(
752
+ lines.iterrows(), points["is_end"], points["separated"], points["layers"],
753
+ strict=True)
754
+ ]
755
+ on_line[rows[~np.array(may_join, dtype=bool)]] = False
756
+ custom = ~is_osm
757
+ if custom.any() and not own_vertices_only and set(POINT_COLUMNS) <= set(points_gdf.columns):
758
+ # A custom line is cut at its own vertices, and at other points in
759
+ # reach only if it may meet what is there: the end of a line, or
760
+ # lines at grade on its layer. Cutting it at every point in reach
761
+ # joined a bridge to the road below wherever either had a vertex
762
+ # near the crossing.
763
+ rows = np.flatnonzero(custom)
764
+ lines = sjoin_lines.iloc[rows]
765
+ points = points_gdf.loc[lines["index_right"]]
766
+ own = shapely.distance(shapely.extract_unique_points(lines.geometry.to_numpy()),
767
+ points.geometry.to_numpy()) <= ON_NODE_TOLERANCE
768
+ line_separated, line_layer = _separated_mask(lines), _layers(lines)
769
+ may_join = np.array([
770
+ is_end or (not point_separated and not separated and layer in layers)
771
+ for is_end, point_separated, layers, separated, layer in zip(
772
+ points["is_end"], points["separated"], points["layers"],
773
+ line_separated, line_layer, strict=True)
774
+ ], dtype=bool)
775
+ custom[rows[~(own | may_join)]] = False
776
+ if own_vertices_only and custom.any():
777
+ rows = np.flatnonzero(custom)
778
+ vertices = shapely.extract_unique_points(sjoin_lines.geometry.to_numpy()[rows])
779
+ points = points_gdf.geometry.loc[sjoin_lines["index_right"].iloc[rows]].to_numpy()
780
+ custom[rows[shapely.distance(vertices, points) > ON_NODE_TOLERANCE]] = False
781
+ pairs = sjoin_lines[custom | on_line]
782
+
783
+ split_lines_gdf = _split_at_points(
784
+ lines_gdf,
785
+ line_ids=pairs.index.to_numpy(),
786
+ points=points_gdf.geometry.loc[pairs["index_right"]].to_numpy(),
787
+ )
788
+
789
+ non_split_lines_gdf = lines_gdf.drop(pd.unique(pairs.index))
790
+
791
+ return pd.concat(
792
+ [non_split_lines_gdf, split_lines_gdf],
793
+ ignore_index=True,
794
+ )
795
+
796
+
797
+ def _split_at_points(
798
+ lines_gdf: gpd.GeoDataFrame,
799
+ line_ids: np.ndarray,
800
+ points: np.ndarray,
801
+ ) -> gpd.GeoDataFrame:
802
+ """
803
+ Cut each line into straight segments: start -> its points in order
804
+ along the line -> end. line_ids[i] (a lines_gdf label) is cut at
805
+ points[i]. Segments keep the line's attributes, plus split="yes".
806
+
807
+ Vectorised: one table of every line's vertices, sorted once, then
808
+ all segments built in a single shapely call.
809
+ """
810
+ if len(line_ids) == 0:
811
+ return lines_gdf.iloc[:0].assign(split=pd.Series(dtype=object))
812
+
813
+ unique_ids = np.unique(line_ids) # sorted: matches groupby order
814
+ lines = lines_gdf.geometry.loc[line_ids].to_numpy()
815
+ starts = shapely.get_point(lines_gdf.geometry.loc[unique_ids].to_numpy(), 0)
816
+ ends = shapely.get_point(lines_gdf.geometry.loc[unique_ids].to_numpy(), -1)
817
+
818
+ # Every vertex of every cut line: its start (position -inf), the cut
819
+ # points (by distance along the line), its end (+inf). A stable sort
820
+ # keeps equal-distance points in their original order.
821
+ vertices = pd.DataFrame({
822
+ "line": np.concatenate([unique_ids, line_ids, unique_ids]),
823
+ "position": np.concatenate([
824
+ np.full(len(unique_ids), -np.inf),
825
+ shapely.line_locate_point(lines, points),
826
+ np.full(len(unique_ids), np.inf),
827
+ ]),
828
+ "x": np.concatenate([shapely.get_x(starts), shapely.get_x(points), shapely.get_x(ends)]),
829
+ "y": np.concatenate([shapely.get_y(starts), shapely.get_y(points), shapely.get_y(ends)]),
830
+ }).sort_values(["line", "position"], kind="stable", ignore_index=True)
831
+
832
+ # A segment joins each vertex to the next one on the same line.
833
+ same_line = vertices["line"].to_numpy()[:-1] == vertices["line"].to_numpy()[1:]
834
+ xy = vertices[["x", "y"]].to_numpy()
835
+ segments = shapely.linestrings(np.stack([xy[:-1][same_line], xy[1:][same_line]], axis=1))
836
+
837
+ split = lines_gdf.loc[vertices["line"].to_numpy()[:-1][same_line]].copy()
838
+ split["geometry"] = segments
839
+ split["split"] = "yes"
840
+ return split.set_geometry("geometry", crs=lines_gdf.crs)
841
+
842
+
843
+ def remove_duplicates_and_combine_nodes(
844
+ custom_points_gdf: gpd.GeoDataFrame,
845
+ nodes_gdf: gpd.GeoDataFrame,
846
+ buffer_distance: float = 1.0,
847
+ first_new_id: int | None = None,
848
+ ) -> gpd.GeoDataFrame:
849
+ """
850
+ Remove duplicate points from custom points and combine with nodes.
851
+ Args:
852
+ custom_points_gdf (gpd.GeoDataFrame): GeoDataFrame containing custom point geometries.
853
+ nodes_gdf (gpd.GeoDataFrame): GeoDataFrame containing node geometries.
854
+ buffer_distance (float): distance (in the projected CRS's units,
855
+ normally metres) within which a custom point is considered
856
+ a duplicate of an existing node.
857
+ first_new_id: the id of the first new node (default: one above
858
+ the highest id in nodes_gdf). Pass it when ids outside
859
+ nodes_gdf are also taken.
860
+
861
+ Returns:
862
+ gpd.GeoDataFrame: Combined GeoDataFrame of unique nodes and custom points.
863
+ """
864
+
865
+ validate_projected_crs(custom_points_gdf)
866
+ validate_projected_crs(nodes_gdf)
867
+
868
+ # Buffer the custom points (not the nodes) so a match means "this
869
+ # custom point falls within buffer_distance of an existing node" -
870
+ # consistent with how buffering is used everywhere else in this module.
871
+ buffered_custom_points = custom_points_gdf.copy()
872
+ buffered_custom_points["geometry"] = buffered_custom_points.geometry.buffer(
873
+ buffer_distance
874
+ )
875
+
876
+ duplicates = gpd.sjoin(
877
+ buffered_custom_points,
878
+ nodes_gdf,
879
+ how="inner",
880
+ predicate="intersects",
881
+ )
882
+
883
+ # IMPORTANT: in current geopandas (>=1.0), gpd.sjoin does NOT add an
884
+ # 'index_left' column - the left frame's original index is kept as
885
+ # the result's own index, and only the right frame's index is added,
886
+ # as 'index_right'. Older geopandas versions did add 'index_left' in
887
+ # some cases. Checking only for 'index_left' (as this function used
888
+ # to) means the "duplicate found" branch below is dead code on any
889
+ # current geopandas install: NO duplicate is ever removed, exact
890
+ # match or not, and custom_points_gdf_cleaned silently falls back to
891
+ # the unmodified custom_points_gdf every time. That's a much bigger
892
+ # problem than a wrong buffer_distance - it means this function was
893
+ # never actually deduplicating anything. Handle both conventions.
894
+ if 'index_left' in duplicates.columns:
895
+ duplicate_positions = duplicates['index_left'].unique()
896
+ else:
897
+ duplicate_positions = duplicates.index.unique()
898
+
899
+ if len(duplicate_positions) > 0:
900
+ # Remove duplicates from custom_points
901
+ custom_points_gdf_cleaned = custom_points_gdf.drop(duplicate_positions)
902
+ else:
903
+ # No duplicates found - use custom_points_gdf as is
904
+ custom_points_gdf_cleaned = custom_points_gdf
905
+
906
+ # New nodes are numbered above every existing OSM id. Numbering from
907
+ # 0 collided with real low OSM ids, giving duplicate <node> ids on
908
+ # export that silently moved existing streets.
909
+ custom_points_gdf_cleaned = custom_points_gdf_cleaned.reset_index(drop=True)
910
+ if first_new_id is None:
911
+ first_new_id = int(nodes_gdf.index.max()) + 1 if len(nodes_gdf) else 1
912
+ custom_points_gdf_cleaned.index = custom_points_gdf_cleaned.index + first_new_id
913
+
914
+ # Concatenate the two GeoDataFrames, making sure the nodes_gdf index is not overwritten
915
+ combined_points_gdf = pd.concat([nodes_gdf, custom_points_gdf_cleaned])
916
+ combined_points_gdf = combined_points_gdf.rename_axis("osmid", axis='index')
917
+
918
+ return combined_points_gdf
919
+
920
+ def filter_split_lines(split_lines_combined_gdf: gpd.GeoDataFrame) -> gpd.GeoDataFrame:
921
+ """
922
+ Filter the combined OSM and custom lines GeoDataFrame to retain relevant lines.
923
+
924
+ Args:
925
+ split_lines_combined_gdf (gpd.GeoDataFrame): GeoDataFrame containing line
926
+ geometries and attributes.
927
+
928
+ Returns:
929
+ gpd.GeoDataFrame: Filtered GeoDataFrame.
930
+ """
931
+
932
+ gdf = split_lines_combined_gdf
933
+ split = gdf["split"] == "yes" if "split" in gdf.columns else False
934
+ return gdf[split | gdf["u"].isna() | gdf["v"].isna()]
935
+
936
+ def nearest_point_ids(
937
+ query: np.ndarray,
938
+ points_gdf: gpd.GeoDataFrame,
939
+ max_distance: float,
940
+ ) -> np.ndarray:
941
+ """
942
+ For each geometry in `query`, the index label of the nearest point
943
+ in points_gdf within max_distance (NaN if none). Ties go to the
944
+ smallest label, so the result doesn't depend on tree order.
945
+ """
946
+ tree = shapely.STRtree(points_gdf.geometry.to_numpy())
947
+ query_pos, point_pos = tree.query_nearest(query, max_distance=max_distance)
948
+
949
+ labels = points_gdf.index.to_numpy()[point_pos]
950
+ nearest = pd.Series(labels).groupby(query_pos).min()
951
+
952
+ result = np.full(len(query), np.nan)
953
+ result[nearest.index.to_numpy()] = nearest.to_numpy()
954
+ return result
955
+
956
+
957
+ def assign_point_ids_to_lines(
958
+ lines_gdf: gpd.GeoDataFrame,
959
+ points_gdf: gpd.GeoDataFrame,
960
+ buffer_distance: float = 0.1,
961
+ ) -> gpd.GeoDataFrame:
962
+ """
963
+ Set each line's u / v to the node nearest its start / end point
964
+ (within buffer_distance), dropping lines left without both ends or
965
+ with u == v.
966
+ """
967
+
968
+ validate_projected_crs(lines_gdf)
969
+ validate_projected_crs(points_gdf)
970
+
971
+ geometries = lines_gdf.geometry.to_numpy()
972
+
973
+ updated_lines_gdf = lines_gdf.copy()
974
+ updated_lines_gdf["u"] = nearest_point_ids(
975
+ shapely.get_point(geometries, 0), points_gdf, buffer_distance
976
+ )
977
+ updated_lines_gdf["v"] = nearest_point_ids(
978
+ shapely.get_point(geometries, -1), points_gdf, buffer_distance
979
+ )
980
+
981
+ updated_lines_gdf = updated_lines_gdf[
982
+ (updated_lines_gdf["u"] != updated_lines_gdf["v"])
983
+ & updated_lines_gdf["u"].notna()
984
+ & updated_lines_gdf["v"].notna()
985
+ ]
986
+
987
+ return updated_lines_gdf
988
+
989
+ def update_and_finalize_lines_gdf(
990
+ original_gdf: gpd.GeoDataFrame,
991
+ updated_gdf: gpd.GeoDataFrame,
992
+ ) -> gpd.GeoDataFrame:
993
+ """
994
+ Update and finalize the lines GeoDataFrame.
995
+
996
+ The GeoDataFrame remains in the projected analysis CRS.
997
+ """
998
+
999
+ validate_projected_crs(original_gdf)
1000
+ validate_projected_crs(updated_gdf)
1001
+
1002
+ original_gdf = original_gdf.copy()
1003
+
1004
+ original_gdf.update(updated_gdf)
1005
+
1006
+ return original_gdf.set_geometry("geometry")
1007
+
1008
+ def check_line_node_consistency(
1009
+ split_lines_combined_gdf: gpd.GeoDataFrame,
1010
+ combined_points_gdf: gpd.GeoDataFrame,
1011
+ strict: bool = True,
1012
+ ) -> gpd.GeoDataFrame:
1013
+ """
1014
+ Clean up and validate 'u'/'v' node references.
1015
+
1016
+ Always drops rows with missing (NaN) or self-referencing
1017
+ (u == v) node ids.
1018
+
1019
+ Then checks that every remaining 'u'/'v' value actually exists
1020
+ in combined_points_gdf's index. This used to only be printed as
1021
+ a warning and NOT enforced, which meant a dangling reference
1022
+ (an edge pointing at a node id that was never written to the
1023
+ node table) could silently reach `write_osm_xml`, producing
1024
+ invalid OSM XML. Depending on how lenient the reader is, that
1025
+ edge - which might be exactly the custom edge you care about -
1026
+ can then simply vanish when the file is reloaded, with no error
1027
+ at all.
1028
+
1029
+ Args:
1030
+ split_lines_combined_gdf: GeoDataFrame with line geometries
1031
+ ('u' and 'v' columns).
1032
+ combined_points_gdf: GeoDataFrame with point geometries
1033
+ (index checked against).
1034
+ strict: if True (default), a dangling u/v reference raises
1035
+ a ValueError immediately, so you find out at build time
1036
+ rather than after a silent, confusing routing result.
1037
+ If False, offending rows are dropped and a warning is
1038
+ printed instead - useful while iterating on messy data.
1039
+
1040
+ Returns:
1041
+ gpd.GeoDataFrame: Cleaned GeoDataFrame with line geometries.
1042
+ """
1043
+
1044
+ before = len(split_lines_combined_gdf)
1045
+
1046
+ # Drop rows with NaNs in 'u' or 'v'
1047
+ split_lines_combined_gdf = split_lines_combined_gdf.dropna(subset=['u', 'v'])
1048
+
1049
+ # Drop self-loops (u == v)
1050
+ split_lines_combined_gdf = split_lines_combined_gdf[
1051
+ split_lines_combined_gdf['u'] != split_lines_combined_gdf['v']
1052
+ ]
1053
+
1054
+ index_values = set(combined_points_gdf.index)
1055
+
1056
+ valid_mask = (
1057
+ split_lines_combined_gdf['u'].isin(index_values)
1058
+ & split_lines_combined_gdf['v'].isin(index_values)
1059
+ )
1060
+
1061
+ if not valid_mask.all():
1062
+ bad_rows = split_lines_combined_gdf.loc[~valid_mask]
1063
+ bad_u = set(bad_rows['u']) - index_values
1064
+ bad_v = set(bad_rows['v']) - index_values
1065
+
1066
+ message = (
1067
+ f"{len(bad_rows)} edge(s) reference node ids that are not in the "
1068
+ f"node table. Missing 'u' values: {bad_u or 'none'}. "
1069
+ f"Missing 'v' values: {bad_v or 'none'}."
1070
+ )
1071
+
1072
+ if 'custom' in bad_rows.columns and (bad_rows['custom'] == 'yes').any():
1073
+ message += " Some of these are your custom-tagged edges."
1074
+
1075
+ if strict:
1076
+ raise NetworkIntegrityError(message)
1077
+
1078
+ log.warning("strict=False, dropping them: %s", message)
1079
+ split_lines_combined_gdf = split_lines_combined_gdf.loc[valid_mask]
1080
+
1081
+ after = len(split_lines_combined_gdf)
1082
+ if after != before:
1083
+ log.debug("check_line_node_consistency: %d -> %d edges (%d dropped)",
1084
+ before, after, before - after)
1085
+
1086
+ return split_lines_combined_gdf