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