networkforge 1.4.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- networkforge/__init__.py +48 -0
- networkforge/__main__.py +7 -0
- networkforge/cli.py +681 -0
- networkforge/coverage.py +176 -0
- networkforge/edits.py +460 -0
- networkforge/errors.py +87 -0
- networkforge/export.py +1056 -0
- networkforge/inputs.py +256 -0
- networkforge/modes.py +336 -0
- networkforge/network.py +703 -0
- networkforge/osm.py +450 -0
- networkforge/paths.py +113 -0
- networkforge/points.py +290 -0
- networkforge/presets.py +50 -0
- networkforge/projection.py +81 -0
- networkforge/tags.py +132 -0
- networkforge/topology.py +1086 -0
- networkforge/traffic.py +905 -0
- networkforge/turns.py +308 -0
- networkforge/validation.py +642 -0
- networkforge-1.4.1.dist-info/METADATA +336 -0
- networkforge-1.4.1.dist-info/RECORD +25 -0
- networkforge-1.4.1.dist-info/WHEEL +4 -0
- networkforge-1.4.1.dist-info/entry_points.txt +2 -0
- networkforge-1.4.1.dist-info/licenses/LICENSE +674 -0
networkforge/topology.py
ADDED
|
@@ -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
|