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/cli.py
ADDED
|
@@ -0,0 +1,681 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Command-line interface: `networkforge build | check | traffic | presets | info`.
|
|
3
|
+
|
|
4
|
+
networkforge build --extent area.gpkg --custom plan.gpkg --preset primary_road \\
|
|
5
|
+
--tag maxspeed="40 mph" --out network.osm.pbf --gpkg after.gpkg \\
|
|
6
|
+
--baseline-gpkg before.gpkg
|
|
7
|
+
networkforge traffic --pbf network.osm.pbf --settings traffic.toml --out network.tar
|
|
8
|
+
|
|
9
|
+
Output streams: stdout carries the result - readable text by default,
|
|
10
|
+
or with --json one JSON object per line, for programs driving the CLI.
|
|
11
|
+
stderr always carries human-readable progress, warnings and errors.
|
|
12
|
+
|
|
13
|
+
JSON events (a stable interface: other tools depend on these shapes):
|
|
14
|
+
|
|
15
|
+
{"event": "progress", "step": 2, "total": 13, "message": "Downloading OSM network"}
|
|
16
|
+
{"event": "warning", "message": "...", "features": [3, 7]} # or fields, rows, gaps
|
|
17
|
+
{"event": "done", "outputs": {"osm": "network.osm.pbf"}, "nodes": 131459, ...,
|
|
18
|
+
"custom_edges": 12, "modified_edges": 3, "removed_edges": 2}
|
|
19
|
+
{"event": "done", "outputs": {"tar": "network.tar"}, "streets": 840,
|
|
20
|
+
"segments": 1702, "by_road": {"primary": 310, ...}, "bands": ["Morning peak"],
|
|
21
|
+
"streets_not_found": [], "single_streets": 3, "single_segments": 8,
|
|
22
|
+
"unmatched": [], "table_rows": 40, "unmatched_rows": []} # traffic
|
|
23
|
+
(edge counts are streets, one per GeoPackage row: a two-way street is
|
|
24
|
+
one edge, not one each way)
|
|
25
|
+
{"event": "error", "type": "InvalidTagsError", "message": "...", "guide": "...",
|
|
26
|
+
"issues": [{"feature": 3, "message": "maxspeed='fast' is not a valid OSM speed"}],
|
|
27
|
+
"problems": ["feature 3: maxspeed='fast' is not a valid OSM speed"]}
|
|
28
|
+
|
|
29
|
+
Features are identified by --id-field (e.g. a GeoPackage's fid), or by
|
|
30
|
+
row number when it isn't given.
|
|
31
|
+
|
|
32
|
+
Exit codes: 0 success, 1 unexpected error, 2 bad command-line usage,
|
|
33
|
+
3 unusable input (InputError and subclasses), 4 OSM download failed,
|
|
34
|
+
5 built network failed a structural check.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
import argparse
|
|
38
|
+
import json
|
|
39
|
+
import logging
|
|
40
|
+
import os
|
|
41
|
+
import sys
|
|
42
|
+
from collections import Counter
|
|
43
|
+
from collections.abc import Sequence
|
|
44
|
+
from importlib.metadata import version
|
|
45
|
+
from pathlib import Path
|
|
46
|
+
|
|
47
|
+
import geopandas as gpd
|
|
48
|
+
import numpy as np
|
|
49
|
+
from shapely.geometry import box
|
|
50
|
+
|
|
51
|
+
from .edits import check_edit_tags, split_edits, take_edit_ids
|
|
52
|
+
from .errors import (
|
|
53
|
+
InputError,
|
|
54
|
+
InvalidTagsError,
|
|
55
|
+
NetworkForgeError,
|
|
56
|
+
NetworkIntegrityError,
|
|
57
|
+
OSMDownloadError,
|
|
58
|
+
feature_id,
|
|
59
|
+
)
|
|
60
|
+
from .export import (
|
|
61
|
+
GPKG_ANALYSIS_COLUMNS,
|
|
62
|
+
RESTRICTION_COLUMNS,
|
|
63
|
+
prepare_osm_data,
|
|
64
|
+
reverse_copies,
|
|
65
|
+
write_gpkg,
|
|
66
|
+
write_osm,
|
|
67
|
+
)
|
|
68
|
+
from .inputs import (
|
|
69
|
+
MAX_OVERPASS_AREA_KM2,
|
|
70
|
+
check_feature_ids,
|
|
71
|
+
clean_custom_data,
|
|
72
|
+
drop_reserved_columns,
|
|
73
|
+
)
|
|
74
|
+
from .modes import MODES, usable_modes
|
|
75
|
+
from .network import JOIN_AT, build_network
|
|
76
|
+
from .osm import NETWORK_TYPES
|
|
77
|
+
from .points import check_point_tags, split_points
|
|
78
|
+
from .presets import PRESETS, preset_tags
|
|
79
|
+
from .tags import (
|
|
80
|
+
EDIT_ID_COLUMN,
|
|
81
|
+
EDIT_ID_COLUMNS,
|
|
82
|
+
FERRY_ROUTES,
|
|
83
|
+
MODIFIED_COLUMN,
|
|
84
|
+
NODE_CHANGES_ATTR,
|
|
85
|
+
REMOVE_COLUMN,
|
|
86
|
+
REMOVE_TAGS_COLUMN,
|
|
87
|
+
REMOVED_ATTR,
|
|
88
|
+
TURNS_ATTR,
|
|
89
|
+
)
|
|
90
|
+
from .traffic import (
|
|
91
|
+
ALL_ROADS,
|
|
92
|
+
BUCKET_MINUTES,
|
|
93
|
+
DIRECTIONS,
|
|
94
|
+
MAX_FACTOR,
|
|
95
|
+
MAX_SPEED_KPH,
|
|
96
|
+
MIN_FACTOR,
|
|
97
|
+
MIN_SPEED_KPH,
|
|
98
|
+
STREET_FIELDS,
|
|
99
|
+
)
|
|
100
|
+
from .turns import RESTRICTION_KEYS, TURN_VALUES, check_turn_tags, split_turns
|
|
101
|
+
from .validation import (
|
|
102
|
+
ACCESS_KEYS,
|
|
103
|
+
ACCESS_VALUES,
|
|
104
|
+
DURATION_PATTERN,
|
|
105
|
+
INCLINE_PATTERN,
|
|
106
|
+
KNOWN_HIGHWAYS,
|
|
107
|
+
KNOWN_TAG_KEYS,
|
|
108
|
+
KNOWN_VALUES,
|
|
109
|
+
LIMIT_KEYS,
|
|
110
|
+
LIMIT_PATTERN,
|
|
111
|
+
MAXSPEED_PATTERN,
|
|
112
|
+
ONEWAY_VALUES,
|
|
113
|
+
check_custom_tags,
|
|
114
|
+
resolve_custom_tags,
|
|
115
|
+
warn_about_unwritten_tags,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
EXIT_OK, EXIT_ERROR, EXIT_USAGE, EXIT_INPUT, EXIT_DOWNLOAD, EXIT_INTEGRITY = range(6)
|
|
119
|
+
|
|
120
|
+
log = logging.getLogger("networkforge.cli")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
124
|
+
parser = _parser()
|
|
125
|
+
args = parser.parse_args(_join_negative_bbox(sys.argv[1:] if argv is None else argv))
|
|
126
|
+
if args.command is cmd_build:
|
|
127
|
+
_check_build_arguments(parser, args)
|
|
128
|
+
emit = _emitter(args.json)
|
|
129
|
+
_configure_logging(args.verbose, args.quiet, emit if args.json else None)
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
return args.command(args, emit)
|
|
133
|
+
except BrokenPipeError:
|
|
134
|
+
# The reader went away (e.g. `| head`): stop quietly, as the
|
|
135
|
+
# Python docs recommend, instead of a traceback on exit.
|
|
136
|
+
os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno())
|
|
137
|
+
return EXIT_ERROR
|
|
138
|
+
except NetworkForgeError as exc:
|
|
139
|
+
emit_error(emit, exc)
|
|
140
|
+
return _exit_code(exc)
|
|
141
|
+
except Exception as exc: # noqa: BLE001 - report anything, don't dump a traceback
|
|
142
|
+
log.debug("Unexpected error", exc_info=True)
|
|
143
|
+
emit_error(emit, exc)
|
|
144
|
+
return EXIT_ERROR
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# ---------------------------------------------------------------- commands
|
|
148
|
+
|
|
149
|
+
def cmd_build(args, emit) -> int:
|
|
150
|
+
if not (args.out or args.gpkg or args.baseline_out or args.baseline_gpkg):
|
|
151
|
+
raise InputError("Nothing to write: give --out, --gpkg, --baseline-out or --baseline-gpkg.")
|
|
152
|
+
|
|
153
|
+
_check_output_paths(args)
|
|
154
|
+
bbox = None if args.no_osm else _read_extent(args)
|
|
155
|
+
custom = _read_custom(args)
|
|
156
|
+
baseline = bool(args.baseline_out or args.baseline_gpkg)
|
|
157
|
+
|
|
158
|
+
def progress(step: int, total: int, message: str) -> None:
|
|
159
|
+
emit({"event": "progress", "step": step, "total": total, "message": message})
|
|
160
|
+
|
|
161
|
+
result = build_network(
|
|
162
|
+
bbox,
|
|
163
|
+
custom,
|
|
164
|
+
network_tags=_tags(args.tag),
|
|
165
|
+
network_type=args.network_type,
|
|
166
|
+
return_source_osm=baseline,
|
|
167
|
+
snap_tolerance=args.snap_tolerance,
|
|
168
|
+
strict=not args.no_strict,
|
|
169
|
+
preset=args.preset,
|
|
170
|
+
overwrite_tags=args.overwrite_tags,
|
|
171
|
+
osm_source=args.osm_source,
|
|
172
|
+
standalone=args.no_osm,
|
|
173
|
+
join_at=args.join_at,
|
|
174
|
+
progress=progress,
|
|
175
|
+
)
|
|
176
|
+
nodes, edges = result[:2]
|
|
177
|
+
|
|
178
|
+
outputs = {}
|
|
179
|
+
# Made once for each network: the GeoPackage's restrictions layer is
|
|
180
|
+
# drawn from what the OSM file holds.
|
|
181
|
+
after = prepare_osm_data(nodes, edges) if args.out else None
|
|
182
|
+
before = prepare_osm_data(result[2], result[3]) if args.baseline_out else None
|
|
183
|
+
if args.out:
|
|
184
|
+
write_osm(nodes, edges, args.out, data=after)
|
|
185
|
+
outputs["osm"] = str(args.out)
|
|
186
|
+
if args.baseline_out:
|
|
187
|
+
write_osm(result[2], result[3], args.baseline_out, data=before)
|
|
188
|
+
outputs["baseline_osm"] = str(args.baseline_out)
|
|
189
|
+
if args.gpkg:
|
|
190
|
+
write_gpkg(nodes, edges, args.gpkg, osm_data=after)
|
|
191
|
+
outputs["gpkg"] = str(args.gpkg)
|
|
192
|
+
if args.baseline_gpkg:
|
|
193
|
+
write_gpkg(result[2], result[3], args.baseline_gpkg, osm_data=before)
|
|
194
|
+
outputs["baseline_gpkg"] = str(args.baseline_gpkg)
|
|
195
|
+
|
|
196
|
+
# One per street, as in the GeoPackage: OSMnx holds a two-way street
|
|
197
|
+
# as two edges, one each way.
|
|
198
|
+
streets = edges[~reverse_copies(edges)]
|
|
199
|
+
custom_edges = int((streets["custom"] == "yes").sum()) if "custom" in streets else 0
|
|
200
|
+
modified_edges = (int((streets[MODIFIED_COLUMN] == "yes").sum())
|
|
201
|
+
if MODIFIED_COLUMN in streets else 0)
|
|
202
|
+
removed_edges = int(edges.attrs.get(REMOVED_ATTR, 0))
|
|
203
|
+
turn_restrictions = len(edges.attrs.get(TURNS_ATTR) or [])
|
|
204
|
+
tagged_nodes = len(edges.attrs.get(NODE_CHANGES_ATTR) or {})
|
|
205
|
+
emit({"event": "done", "outputs": outputs, "nodes": len(nodes), "edges": len(streets),
|
|
206
|
+
"custom_edges": custom_edges, "modified_edges": modified_edges,
|
|
207
|
+
"removed_edges": removed_edges, "turn_restrictions": turn_restrictions,
|
|
208
|
+
"tagged_nodes": tagged_nodes},
|
|
209
|
+
text=f"Done: {len(nodes):,} nodes, {len(streets):,} edges ({custom_edges:,} custom, "
|
|
210
|
+
f"{modified_edges:,} changed, {removed_edges:,} removed). "
|
|
211
|
+
f"Wrote {', '.join(outputs.values())}")
|
|
212
|
+
return EXIT_OK
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def cmd_check(args, emit) -> int:
|
|
216
|
+
custom = _read_custom(args)
|
|
217
|
+
if args.extent or args.bbox:
|
|
218
|
+
custom = clean_custom_data(custom, _read_extent(args), strict=True)
|
|
219
|
+
else:
|
|
220
|
+
check_feature_ids(custom)
|
|
221
|
+
custom = take_edit_ids(custom)
|
|
222
|
+
custom = drop_reserved_columns(custom, quiet=EDIT_ID_COLUMN in custom.columns)
|
|
223
|
+
|
|
224
|
+
# Changes to existing streets and turn restrictions are checked for
|
|
225
|
+
# valid values only: where they apply is known once the network is
|
|
226
|
+
# built.
|
|
227
|
+
custom, points = split_points(custom)
|
|
228
|
+
check_point_tags(points, strict=True)
|
|
229
|
+
custom, turns = split_turns(custom)
|
|
230
|
+
check_turn_tags(turns, strict=True)
|
|
231
|
+
warn_about_unwritten_tags(custom)
|
|
232
|
+
custom, edits = split_edits(custom)
|
|
233
|
+
check_edit_tags(edits, strict=True)
|
|
234
|
+
|
|
235
|
+
blanket = {**(preset_tags(args.preset) if args.preset else {}), **_tags(args.tag)}
|
|
236
|
+
if not custom.empty:
|
|
237
|
+
custom = resolve_custom_tags(custom, blanket, overwrite=args.overwrite_tags)
|
|
238
|
+
check_custom_tags(custom, args.network_type, strict=True)
|
|
239
|
+
|
|
240
|
+
tag_columns = [c for c in custom.columns if c != custom.geometry.name]
|
|
241
|
+
modes = Counter(
|
|
242
|
+
", ".join(usable_modes({c: row[c] for c in tag_columns if row[c] is not None}))
|
|
243
|
+
or "nothing"
|
|
244
|
+
for _, row in custom.iterrows()
|
|
245
|
+
)
|
|
246
|
+
summary = "; ".join(f"{count} usable by {m}" for m, count in modes.most_common())
|
|
247
|
+
if len(edits):
|
|
248
|
+
summary = "; ".join(filter(None, [summary, f"{len(edits)} change existing streets"]))
|
|
249
|
+
if len(turns):
|
|
250
|
+
summary = "; ".join(filter(None, [summary, f"{len(turns)} turn restriction(s)"]))
|
|
251
|
+
if len(points):
|
|
252
|
+
summary = "; ".join(filter(None, [summary, f"{len(points)} point(s)"]))
|
|
253
|
+
features = len(custom) + len(edits) + len(turns) + len(points)
|
|
254
|
+
emit({"event": "done", "features": features, "modes": dict(modes),
|
|
255
|
+
"edits": len(edits), "turn_restrictions": len(turns), "points": len(points)},
|
|
256
|
+
text=f"OK: {features} feature(s) - {summary}")
|
|
257
|
+
return EXIT_OK
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def cmd_traffic(args, emit) -> int:
|
|
261
|
+
from .traffic import add_traffic, read_traffic_settings, speed_table, street_bands
|
|
262
|
+
|
|
263
|
+
if not (args.settings or args.streets or args.speed_table):
|
|
264
|
+
raise InputError("No traffic to add: give --settings (road-class bands), --streets "
|
|
265
|
+
"(single streets), --speed-table (speeds by OSM way id), or several.",
|
|
266
|
+
guide="traffic.md")
|
|
267
|
+
bands = read_traffic_settings(args.settings) if args.settings else []
|
|
268
|
+
streets = (street_bands(_read_with_ids(args.streets, args.streets_layer, args.id_field,
|
|
269
|
+
"single streets"))
|
|
270
|
+
if args.streets else [])
|
|
271
|
+
if args.speed_table:
|
|
272
|
+
streets += speed_table(_read_table(args.speed_table, args.speed_table_layer,
|
|
273
|
+
args.speed_table_id_field))
|
|
274
|
+
|
|
275
|
+
def progress(step: int, total: int, message: str) -> None:
|
|
276
|
+
emit({"event": "progress", "step": step, "total": total, "message": message})
|
|
277
|
+
|
|
278
|
+
result = add_traffic(args.pbf, bands, args.out, streets=streets, progress=progress)
|
|
279
|
+
roads = ", ".join(f"{count:,} {road}" for road, count in result.by_road.items()) or "none"
|
|
280
|
+
emit({"event": "done", "outputs": {"tar": str(result.output)}, "streets": result.ways,
|
|
281
|
+
"segments": result.segments, "by_road": result.by_road, "bands": result.bands,
|
|
282
|
+
"streets_not_found": result.streets_not_found,
|
|
283
|
+
"single_streets": result.single_streets, "single_segments": result.single_segments,
|
|
284
|
+
"unmatched": [feature_id(f) for f in result.unmatched],
|
|
285
|
+
"table_rows": result.table_rows,
|
|
286
|
+
"unmatched_rows": [feature_id(r) for r in result.unmatched_rows]},
|
|
287
|
+
text=f"Done: traffic on {result.ways:,} streets ({result.segments:,} road segments). "
|
|
288
|
+
f"Road classes: {roads}; single streets: {result.single_streets:,}; speed "
|
|
289
|
+
f"table rows: {result.table_rows:,} ({result.single_segments:,} segments for "
|
|
290
|
+
f"the two). Wrote {result.output}")
|
|
291
|
+
return EXIT_OK
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def cmd_info(args, emit) -> int:
|
|
295
|
+
"""Everything a front end needs to build its UI from the engine."""
|
|
296
|
+
info = {
|
|
297
|
+
"event": "info",
|
|
298
|
+
"version": version("networkforge"),
|
|
299
|
+
"presets": {name: {"tags": tags, "modes": usable_modes(tags)}
|
|
300
|
+
for name, tags in PRESETS.items()},
|
|
301
|
+
"network_types": list(NETWORK_TYPES),
|
|
302
|
+
"modes": list(MODES),
|
|
303
|
+
"max_overpass_area_km2": MAX_OVERPASS_AREA_KM2,
|
|
304
|
+
"osm_formats": [".osm", ".osm.pbf", ".pbf", ".osm.gz", ".osm.bz2"],
|
|
305
|
+
"gpkg_edge_columns": list(GPKG_ANALYSIS_COLUMNS),
|
|
306
|
+
"gpkg_layers": ["nodes", "edges", "restrictions"],
|
|
307
|
+
"gpkg_restriction_columns": list(RESTRICTION_COLUMNS),
|
|
308
|
+
"edit_id_fields": list(EDIT_ID_COLUMNS),
|
|
309
|
+
"remove_field": REMOVE_COLUMN,
|
|
310
|
+
"remove_tags_field": REMOVE_TAGS_COLUMN,
|
|
311
|
+
"turn_restriction_fields": list(RESTRICTION_KEYS),
|
|
312
|
+
"standalone": True,
|
|
313
|
+
"join_at": list(JOIN_AT),
|
|
314
|
+
"traffic": {"days": ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"],
|
|
315
|
+
"minutes_step": BUCKET_MINUTES, "factor_range": [MIN_FACTOR, MAX_FACTOR],
|
|
316
|
+
"roads": sorted({r.removesuffix("_link") for r in KNOWN_HIGHWAYS}),
|
|
317
|
+
"all_roads": ALL_ROADS, "output": ".tar",
|
|
318
|
+
"street_fields": list(STREET_FIELDS), "directions": list(DIRECTIONS),
|
|
319
|
+
"street_id_fields": list(EDIT_ID_COLUMNS),
|
|
320
|
+
"speed_kph_range": [MIN_SPEED_KPH, MAX_SPEED_KPH],
|
|
321
|
+
"speed_table": True},
|
|
322
|
+
"tag_keys": sorted(KNOWN_TAG_KEYS),
|
|
323
|
+
"tag_values": {
|
|
324
|
+
"restriction": sorted(TURN_VALUES),
|
|
325
|
+
"highway": sorted(KNOWN_HIGHWAYS),
|
|
326
|
+
"route": list(FERRY_ROUTES),
|
|
327
|
+
**{key: sorted(values) for key, values in KNOWN_VALUES.items()},
|
|
328
|
+
"oneway": sorted(ONEWAY_VALUES),
|
|
329
|
+
**{key: sorted(ACCESS_VALUES) for key in ACCESS_KEYS},
|
|
330
|
+
},
|
|
331
|
+
"tag_patterns": {"maxspeed": MAXSPEED_PATTERN.pattern, "lanes": r"^[1-9]\d*$",
|
|
332
|
+
"layer": r"^-?\d+$", "duration": DURATION_PATTERN.pattern,
|
|
333
|
+
"incline": INCLINE_PATTERN.pattern,
|
|
334
|
+
**{key: LIMIT_PATTERN.pattern for key in LIMIT_KEYS}},
|
|
335
|
+
}
|
|
336
|
+
emit(info, text=(
|
|
337
|
+
f"networkforge {info['version']}\n"
|
|
338
|
+
f"presets: {', '.join(PRESETS)}\n"
|
|
339
|
+
f"network types: {', '.join(NETWORK_TYPES)}\n"
|
|
340
|
+
f"Overpass area limit: {MAX_OVERPASS_AREA_KM2:,} km2 (larger areas need --osm-source)\n"
|
|
341
|
+
f"highway values: {', '.join(sorted(KNOWN_HIGHWAYS))}"
|
|
342
|
+
))
|
|
343
|
+
return EXIT_OK
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def cmd_presets(args, emit) -> int:
|
|
347
|
+
for name, tags in PRESETS.items():
|
|
348
|
+
modes = usable_modes(tags)
|
|
349
|
+
emit({"event": "preset", "name": name, "tags": tags, "modes": modes},
|
|
350
|
+
text=f"{name:20s} {', '.join(f'{k}={v}' for k, v in tags.items()):55s} "
|
|
351
|
+
f"{', '.join(modes)}")
|
|
352
|
+
return EXIT_OK
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
# ---------------------------------------------------------------- arguments
|
|
356
|
+
|
|
357
|
+
def _parser() -> argparse.ArgumentParser:
|
|
358
|
+
parser = argparse.ArgumentParser(
|
|
359
|
+
prog="networkforge",
|
|
360
|
+
description="Build and edit routable street networks: add your own roads, cycleways "
|
|
361
|
+
"and paths to OpenStreetMap, change or remove existing streets, or "
|
|
362
|
+
"build a network from your own lines alone.",
|
|
363
|
+
epilog="Network rules: docs/network-rules.md. Large areas: docs/osm-data.md.",
|
|
364
|
+
)
|
|
365
|
+
parser.add_argument("--version", action="version",
|
|
366
|
+
version=f"%(prog)s {version('networkforge')}")
|
|
367
|
+
common = argparse.ArgumentParser(add_help=False)
|
|
368
|
+
common.add_argument("--json", action="store_true",
|
|
369
|
+
help="write JSON lines to stdout (for programs) instead of text")
|
|
370
|
+
common.add_argument("-v", "--verbose", action="store_true", help="show debug detail")
|
|
371
|
+
common.add_argument("-q", "--quiet", action="store_true", help="only show warnings and errors")
|
|
372
|
+
commands = parser.add_subparsers(title="commands", required=True)
|
|
373
|
+
|
|
374
|
+
build = commands.add_parser("build", parents=[common],
|
|
375
|
+
help="build the combined network and write it out",
|
|
376
|
+
description="Build the OSM + custom network, or with --no-osm "
|
|
377
|
+
"a network of the custom lines alone.")
|
|
378
|
+
_add_extent(build, required=False)
|
|
379
|
+
_add_custom(build)
|
|
380
|
+
build.add_argument("--no-osm", action="store_true",
|
|
381
|
+
help="build a network from the custom lines alone, without "
|
|
382
|
+
"OpenStreetMap: no area, download or before network")
|
|
383
|
+
build.add_argument("--join-at", choices=JOIN_AT, default="crossings",
|
|
384
|
+
help="with --no-osm, where lines join each other: wherever they "
|
|
385
|
+
"cross (default), or only at vertices they share")
|
|
386
|
+
build.add_argument("--osm-source", type=Path, metavar="FILE",
|
|
387
|
+
help="local OSM extract (.osm.pbf etc.) instead of the Overpass API; "
|
|
388
|
+
"required for areas over 1,000 km2")
|
|
389
|
+
build.add_argument("--snap-tolerance", type=float, default=1.0, metavar="METRES",
|
|
390
|
+
help="how close custom lines must come to join the network (default 1)")
|
|
391
|
+
build.add_argument("--no-strict", action="store_true",
|
|
392
|
+
help="warn and drop unusable features instead of failing")
|
|
393
|
+
build.add_argument("--out", type=Path, metavar="FILE",
|
|
394
|
+
help="OSM output: .osm, .osm.pbf, .osm.gz or .osm.bz2")
|
|
395
|
+
build.add_argument("--baseline-out", type=Path, metavar="FILE",
|
|
396
|
+
help="also write the untouched OSM network, for before/after comparisons")
|
|
397
|
+
build.add_argument("--baseline-gpkg", type=Path, metavar="FILE",
|
|
398
|
+
help="also write the untouched OSM network as a GeoPackage for QGIS "
|
|
399
|
+
"(same layers and columns as --gpkg), for before/after")
|
|
400
|
+
build.add_argument("--gpkg", type=Path, metavar="FILE",
|
|
401
|
+
help="GeoPackage for QGIS: 'nodes' and 'edges' layers, edges with "
|
|
402
|
+
"network-analysis columns (car/bike/walk, speed, times, direction)")
|
|
403
|
+
build.set_defaults(command=cmd_build)
|
|
404
|
+
|
|
405
|
+
check = commands.add_parser("check", parents=[common],
|
|
406
|
+
help="check custom data and tags without downloading anything",
|
|
407
|
+
description="Validate custom features and their tags.")
|
|
408
|
+
_add_extent(check, required=False)
|
|
409
|
+
_add_custom(check)
|
|
410
|
+
check.set_defaults(command=cmd_check)
|
|
411
|
+
|
|
412
|
+
traffic = commands.add_parser(
|
|
413
|
+
"traffic", parents=[common],
|
|
414
|
+
help="add time-of-day traffic to a network for Valhalla (writes a .tar)",
|
|
415
|
+
description="Put time bands of slower speeds (by road class) on a network built by "
|
|
416
|
+
"NetworkForge, as Valhalla's predicted traffic, and write Valhalla's graph "
|
|
417
|
+
"as a .tar file (the Network Analyst plugin's 'From Tar'). Routes see the "
|
|
418
|
+
"traffic only when given a departure or arrival time. Needs pyvalhalla: "
|
|
419
|
+
'pip install "networkforge[traffic]".',
|
|
420
|
+
epilog="Settings: docs/traffic.md.")
|
|
421
|
+
traffic.add_argument("--pbf", type=Path, required=True, metavar="FILE",
|
|
422
|
+
help="the network: an OSM file NetworkForge wrote (.osm.pbf)")
|
|
423
|
+
traffic.add_argument("--settings", type=Path, metavar="FILE",
|
|
424
|
+
help="road-class bands (.toml or .json; see docs/traffic.md)")
|
|
425
|
+
traffic.add_argument("--streets", type=Path, metavar="FILE",
|
|
426
|
+
help="single streets: a line layer with days, time, factor or "
|
|
427
|
+
"speed_kph (and direction, osm_id / osmid); they override the "
|
|
428
|
+
"road classes")
|
|
429
|
+
traffic.add_argument("--streets-layer", metavar="NAME", help="layer in the streets file")
|
|
430
|
+
traffic.add_argument("--speed-table", type=Path, metavar="FILE",
|
|
431
|
+
help="speeds by OSM way id: a table (CSV, or a GeoPackage/... layer) "
|
|
432
|
+
"with osm_id or osmid, days, time, speed_kph or factor (and "
|
|
433
|
+
"direction); they override the road classes")
|
|
434
|
+
traffic.add_argument("--speed-table-layer", metavar="NAME",
|
|
435
|
+
help="layer in the speed table file")
|
|
436
|
+
traffic.add_argument("--speed-table-id-field", metavar="NAME",
|
|
437
|
+
help="attribute that names speed table rows in warnings and errors; "
|
|
438
|
+
"default: row number (1 = the first row after the header)")
|
|
439
|
+
traffic.add_argument("--id-field", metavar="NAME",
|
|
440
|
+
help="attribute that identifies street features in warnings and "
|
|
441
|
+
"errors (e.g. fid); default: row number")
|
|
442
|
+
traffic.add_argument("--out", type=Path, required=True, metavar="FILE",
|
|
443
|
+
help="Valhalla graph with the traffic (.tar)")
|
|
444
|
+
traffic.set_defaults(command=cmd_traffic)
|
|
445
|
+
|
|
446
|
+
presets = commands.add_parser("presets", parents=[common], help="list the tag presets")
|
|
447
|
+
presets.set_defaults(command=cmd_presets)
|
|
448
|
+
|
|
449
|
+
info = commands.add_parser(
|
|
450
|
+
"info", parents=[common],
|
|
451
|
+
help="version, presets, valid tag values and limits (for front ends)")
|
|
452
|
+
info.set_defaults(command=cmd_info)
|
|
453
|
+
|
|
454
|
+
return parser
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _add_extent(parser, required: bool) -> None:
|
|
458
|
+
group = parser.add_mutually_exclusive_group(required=required)
|
|
459
|
+
group.add_argument("--extent", type=Path, metavar="FILE",
|
|
460
|
+
help="vector file whose extent is the area (any CRS)")
|
|
461
|
+
group.add_argument("--bbox", metavar="W,S,E,N",
|
|
462
|
+
help="area as WGS84 longitude/latitude: west,south,east,north")
|
|
463
|
+
parser.add_argument("--extent-layer", metavar="NAME", help="layer in the extent file")
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _add_custom(parser) -> None:
|
|
467
|
+
parser.add_argument("--custom", type=Path, required=True, metavar="FILE",
|
|
468
|
+
help="custom lines (GeoPackage, GeoJSON, ...)")
|
|
469
|
+
parser.add_argument("--custom-layer", metavar="NAME", help="layer in the custom file")
|
|
470
|
+
parser.add_argument("--id-field", metavar="NAME",
|
|
471
|
+
help="attribute that identifies features in warnings and errors "
|
|
472
|
+
"(e.g. fid); default: row number")
|
|
473
|
+
parser.add_argument("--preset", choices=list(PRESETS), metavar="NAME",
|
|
474
|
+
help="blanket tag preset (list them with: networkforge presets)")
|
|
475
|
+
parser.add_argument("--tag", action="append", default=[], metavar="KEY=VALUE",
|
|
476
|
+
help="blanket tag, repeatable; beats the preset")
|
|
477
|
+
parser.add_argument("--overwrite-tags", action="store_true",
|
|
478
|
+
help="blanket tags replace features' own values instead of filling gaps")
|
|
479
|
+
parser.add_argument("--network-type", choices=NETWORK_TYPES, default="all",
|
|
480
|
+
help="OSM network to download (default all: every mode)")
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _check_build_arguments(parser, args) -> None:
|
|
484
|
+
"""build needs an area unless --no-osm, which in turn has no OSM options."""
|
|
485
|
+
if not args.no_osm:
|
|
486
|
+
if not (args.extent or args.bbox):
|
|
487
|
+
parser.error("one of the arguments --extent --bbox is required (or --no-osm)")
|
|
488
|
+
if args.join_at != "crossings":
|
|
489
|
+
parser.error("--join-at is for --no-osm networks")
|
|
490
|
+
return
|
|
491
|
+
for given, flag in ((args.extent or args.bbox, "--extent / --bbox"),
|
|
492
|
+
(args.osm_source, "--osm-source"),
|
|
493
|
+
(args.baseline_out, "--baseline-out"),
|
|
494
|
+
(args.baseline_gpkg, "--baseline-gpkg")):
|
|
495
|
+
if given:
|
|
496
|
+
parser.error(f"{flag} can't be used with --no-osm (there is no OpenStreetMap "
|
|
497
|
+
"network or before network)")
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _check_output_paths(args) -> None:
|
|
501
|
+
"""
|
|
502
|
+
Before the build (and the download): no output may replace an input
|
|
503
|
+
or another output, and each needs a folder that exists.
|
|
504
|
+
"""
|
|
505
|
+
inputs = {"--custom": args.custom, "--extent": args.extent, "--osm-source": args.osm_source}
|
|
506
|
+
outputs = {"--out": args.out, "--baseline-out": args.baseline_out,
|
|
507
|
+
"--gpkg": args.gpkg, "--baseline-gpkg": args.baseline_gpkg}
|
|
508
|
+
seen = {Path(path).resolve(): flag for flag, path in inputs.items() if path}
|
|
509
|
+
for flag, path in outputs.items():
|
|
510
|
+
if not path:
|
|
511
|
+
continue
|
|
512
|
+
resolved = Path(path).resolve()
|
|
513
|
+
if resolved in seen:
|
|
514
|
+
raise InputError(f"{flag} {path} is the same file as {seen[resolved]}: it would "
|
|
515
|
+
"be overwritten. Give the output a name of its own.")
|
|
516
|
+
seen[resolved] = flag
|
|
517
|
+
if not resolved.parent.is_dir():
|
|
518
|
+
raise InputError(f"The folder for {flag} doesn't exist: {resolved.parent}")
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
# ---------------------------------------------------------------- helpers
|
|
522
|
+
|
|
523
|
+
def _join_negative_bbox(argv: Sequence[str]) -> list[str]:
|
|
524
|
+
"""
|
|
525
|
+
`--bbox -4.6,54.1,...` would make argparse read the western
|
|
526
|
+
longitude as an option; turn it into `--bbox=-4.6,54.1,...`.
|
|
527
|
+
"""
|
|
528
|
+
argv = list(argv)
|
|
529
|
+
for i, arg in enumerate(argv[:-1]):
|
|
530
|
+
if arg == "--bbox" and argv[i + 1].startswith("-"):
|
|
531
|
+
argv[i : i + 2] = [f"--bbox={argv[i + 1]}"]
|
|
532
|
+
break
|
|
533
|
+
return argv
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def _tags(pairs: list[str]) -> dict[str, str]:
|
|
537
|
+
tags = {}
|
|
538
|
+
for pair in pairs:
|
|
539
|
+
key, sep, value = pair.partition("=")
|
|
540
|
+
if not sep or not key.strip():
|
|
541
|
+
raise InputError(f"--tag must look like KEY=VALUE, got {pair!r}")
|
|
542
|
+
tags[key.strip()] = value.strip()
|
|
543
|
+
return tags
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _read_layer(path: Path, layer: str | None, label: str, **kwargs) -> gpd.GeoDataFrame:
|
|
547
|
+
if not path.is_file():
|
|
548
|
+
raise InputError(f"The {label} file doesn't exist: {path}")
|
|
549
|
+
try:
|
|
550
|
+
return gpd.read_file(path, layer=layer, **kwargs)
|
|
551
|
+
except Exception as exc: # noqa: BLE001 - GDAL raises many types
|
|
552
|
+
raise InputError(f"Can't read the {label} from {path}: {exc}") from exc
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def _read_custom(args) -> gpd.GeoDataFrame:
|
|
556
|
+
"""The custom layer, indexed by --id-field so messages name those ids."""
|
|
557
|
+
return _read_with_ids(args.custom, args.custom_layer, args.id_field, "custom data")
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def _read_with_ids(path: Path, layer: str | None, field: str | None,
|
|
561
|
+
label: str) -> gpd.GeoDataFrame:
|
|
562
|
+
"""A layer indexed by the attribute `field` (--id-field) so messages name those ids."""
|
|
563
|
+
data = _read_layer(path, layer, label)
|
|
564
|
+
if not field:
|
|
565
|
+
return data
|
|
566
|
+
if field in data.columns:
|
|
567
|
+
return data.set_index(field)
|
|
568
|
+
# A GeoPackage's fid isn't a column unless asked for.
|
|
569
|
+
with_fid = _read_layer(path, layer, label, fid_as_index=True)
|
|
570
|
+
if field.lower() == "fid":
|
|
571
|
+
return with_fid.rename_axis(field)
|
|
572
|
+
attributes = ", ".join(map(str, data.columns.drop(data.geometry.name)))
|
|
573
|
+
raise InputError(f"--id-field {field!r} isn't an attribute of the {label}. "
|
|
574
|
+
f"Attributes: {attributes}")
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def _read_table(path: Path, layer: str | None, field: str | None):
|
|
578
|
+
"""A table (CSV, or any layer, with or without geometry), indexed by `field` or row number."""
|
|
579
|
+
if not path.is_file():
|
|
580
|
+
raise InputError(f"The speed table file doesn't exist: {path}")
|
|
581
|
+
try:
|
|
582
|
+
if path.suffix.lower() == ".csv":
|
|
583
|
+
import pandas as pd
|
|
584
|
+
table = pd.read_csv(path, dtype=str, keep_default_na=False)
|
|
585
|
+
else:
|
|
586
|
+
table = gpd.read_file(path, layer=layer)
|
|
587
|
+
except Exception as exc: # noqa: BLE001 - pandas and GDAL raise many types
|
|
588
|
+
raise InputError(f"Can't read the speed table from {path}: {exc}") from exc
|
|
589
|
+
if field:
|
|
590
|
+
if field not in table.columns:
|
|
591
|
+
raise InputError(f"--speed-table-id-field {field!r} isn't a column of the speed "
|
|
592
|
+
f"table. Columns: {', '.join(map(str, table.columns))}")
|
|
593
|
+
return table.set_index(field)
|
|
594
|
+
table.index = range(1, len(table) + 1)
|
|
595
|
+
return table
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def _read_extent(args) -> gpd.GeoDataFrame:
|
|
599
|
+
if args.bbox:
|
|
600
|
+
try:
|
|
601
|
+
west, south, east, north = (float(v) for v in args.bbox.split(","))
|
|
602
|
+
except ValueError:
|
|
603
|
+
raise InputError(f"--bbox must be W,S,E,N numbers, got {args.bbox!r}") from None
|
|
604
|
+
if not (west < east and south < north):
|
|
605
|
+
raise InputError("--bbox needs west < east and south < north")
|
|
606
|
+
return gpd.GeoDataFrame(geometry=[box(west, south, east, north)], crs="EPSG:4326")
|
|
607
|
+
extent = _read_layer(args.extent, args.extent_layer, "extent")
|
|
608
|
+
if extent.empty or not np.isfinite(extent.total_bounds).all():
|
|
609
|
+
raise InputError(f"The extent file has no features with geometry: {args.extent}")
|
|
610
|
+
return gpd.GeoDataFrame(geometry=[box(*extent.total_bounds)], crs=extent.crs)
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def _configure_logging(verbose: bool, quiet: bool, emit=None) -> None:
|
|
614
|
+
"""Readable logs on stderr; with --json, warnings also become JSON events."""
|
|
615
|
+
level = logging.DEBUG if verbose else logging.WARNING if quiet else logging.INFO
|
|
616
|
+
logging.basicConfig(level=level, format="%(message)s", stream=sys.stderr, force=True)
|
|
617
|
+
library = logging.getLogger("networkforge")
|
|
618
|
+
for handler in [h for h in library.handlers if isinstance(h, _WarningEvents)]:
|
|
619
|
+
library.removeHandler(handler) # from an earlier main() in this process
|
|
620
|
+
if emit is not None:
|
|
621
|
+
library.addHandler(_WarningEvents(emit))
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
class _WarningEvents(logging.Handler):
|
|
625
|
+
"""Turns networkforge warnings into {"event": "warning", ...} JSON events."""
|
|
626
|
+
|
|
627
|
+
def __init__(self, emit):
|
|
628
|
+
super().__init__(level=logging.WARNING)
|
|
629
|
+
self.emit_event = emit
|
|
630
|
+
|
|
631
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
632
|
+
if record.levelno != logging.WARNING: # errors are reported as error events
|
|
633
|
+
return
|
|
634
|
+
event = {"event": "warning", "message": record.getMessage()}
|
|
635
|
+
if getattr(record, "features", None):
|
|
636
|
+
event["features"] = [feature_id(f) for f in record.features]
|
|
637
|
+
if getattr(record, "fields", None) or getattr(record, "field", None):
|
|
638
|
+
event["fields"] = list(getattr(record, "fields", None) or [record.field])
|
|
639
|
+
if getattr(record, "gaps", None):
|
|
640
|
+
event["gaps"] = [list(bounds) for bounds in record.gaps]
|
|
641
|
+
if getattr(record, "rows", None):
|
|
642
|
+
event["rows"] = [feature_id(r) for r in record.rows]
|
|
643
|
+
self.emit_event(event)
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def _emitter(as_json: bool):
|
|
647
|
+
"""emit(event, text=None): a JSON line on stdout, or readable text."""
|
|
648
|
+
def emit(event: dict, text: str | None = None) -> None:
|
|
649
|
+
if as_json:
|
|
650
|
+
print(json.dumps(event), flush=True)
|
|
651
|
+
elif event["event"] == "progress":
|
|
652
|
+
pass # the library already logs each step
|
|
653
|
+
elif text is not None:
|
|
654
|
+
print(text, flush=True)
|
|
655
|
+
return emit
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def emit_error(emit, exc: Exception) -> None:
|
|
659
|
+
event = {"event": "error", "type": type(exc).__name__, "message": str(exc)}
|
|
660
|
+
if isinstance(exc, NetworkForgeError) and exc.guide:
|
|
661
|
+
event["guide"] = exc.guide
|
|
662
|
+
if isinstance(exc, NetworkForgeError) and exc.issues:
|
|
663
|
+
event["issues"] = exc.issues
|
|
664
|
+
if isinstance(exc, InvalidTagsError):
|
|
665
|
+
event["problems"] = exc.problems
|
|
666
|
+
emit(event, text=None)
|
|
667
|
+
log.error("Error (%s): %s", type(exc).__name__, exc)
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def _exit_code(exc: NetworkForgeError) -> int:
|
|
671
|
+
if isinstance(exc, InputError):
|
|
672
|
+
return EXIT_INPUT
|
|
673
|
+
if isinstance(exc, OSMDownloadError):
|
|
674
|
+
return EXIT_DOWNLOAD
|
|
675
|
+
if isinstance(exc, NetworkIntegrityError):
|
|
676
|
+
return EXIT_INTEGRITY
|
|
677
|
+
return EXIT_ERROR
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
if __name__ == "__main__":
|
|
681
|
+
sys.exit(main())
|