networkforge 1.4.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,905 @@
1
+ """
2
+ Traffic for Valhalla: time bands by road class, and for single streets.
3
+
4
+ bands = read_traffic_settings("traffic.toml")
5
+ streets = street_bands(geopandas.read_file("streets.gpkg")) # optional
6
+ add_traffic("after.osm.pbf", bands, "after-traffic.tar", streets=streets)
7
+
8
+ A band says how fast some streets are at some times: a `factor`, a share
9
+ of each street's normal speed (the speed Valhalla gives it from its
10
+ tags), or a measured speed, `speed_kph`:
11
+
12
+ [[band]]
13
+ name = "Morning peak"
14
+ days = "Mo-Fr"
15
+ time = "07:00-09:30"
16
+ roads = ["motorway", "trunk", "primary", "secondary"]
17
+ factor = 0.6
18
+
19
+ Valhalla keeps time-of-day speeds in its own graph, not in the OSM file
20
+ (no OSM tag describes a typical week of speeds, and Valhalla stores
21
+ `maxspeed:conditional` without routing by it). So this builds Valhalla's
22
+ graph from a NetworkForge PBF, finds the graph's road segments for every
23
+ street a band covers, gives each segment a week of speeds (2,016
24
+ five-minute values, Sunday 00:00 first), loads them with Valhalla's own
25
+ `valhalla_add_predicted_traffic`, checks they took, and packs the graph
26
+ into a tar file that Valhalla (and the QGIS Network Analyst plugin's
27
+ "From Tar") routes on directly.
28
+
29
+ Single streets: a line layer, one row per street and time band
30
+ (`days`, `time`, `factor` or `speed_kph`, optional `direction`
31
+ both/forward/backward and `name`).
32
+ A row's line is matched onto the road segments it is drawn along
33
+ (Valhalla's own map matcher), so it can be a stretch of a long way or a
34
+ new street; with an OSM way id (`osm_id` / `osmid`) as well, only that
35
+ way's stretches count, and a way id without a line is the whole way. A
36
+ segment with rows of its own ignores the road-class bands.
37
+
38
+ A speed table (`speed_table`): the same rows without lines, each naming
39
+ an OSM way (`osm_id` / `osmid`): the form measured speeds by way come
40
+ in, and what a traffic model's results can be exported as.
41
+
42
+ Routes only see the bands when they are asked for a departure or
43
+ arrival time. Without a time, every street keeps its normal speed: each
44
+ segment's free-flow speed is its normal speed and no daytime speed is
45
+ set (Valhalla would otherwise use a daytime speed for requests without
46
+ a time).
47
+
48
+ Needs pyvalhalla (`pip install "networkforge[traffic]"`); it is the only
49
+ module that imports it, and only when called.
50
+ """
51
+
52
+ import csv
53
+ import json
54
+ import logging
55
+ import os
56
+ import re
57
+ import subprocess
58
+ import sys
59
+ import tempfile
60
+ import tomllib
61
+ from collections import defaultdict
62
+ from collections.abc import Callable, Iterable
63
+ from dataclasses import dataclass, field
64
+ from pathlib import Path
65
+
66
+ import numpy as np
67
+ import osmium
68
+ import pandas as pd
69
+ import shapely
70
+
71
+ from .errors import InputError, NetworkIntegrityError, feature_id
72
+ from .inputs import OFF_THE_EARTH, within_the_earth
73
+ from .paths import readable
74
+ from .tags import EDIT_ID_COLUMNS
75
+ from .validation import KNOWN_HIGHWAYS
76
+
77
+ log = logging.getLogger(__name__)
78
+
79
+ GUIDE = "traffic.md"
80
+
81
+ # Valhalla's week: 5-minute buckets, bucket 0 = Sunday 00:00 (local time).
82
+ BUCKET_MINUTES = 5
83
+ BUCKETS_PER_DAY = 24 * 60 // BUCKET_MINUTES
84
+ BUCKETS_PER_WEEK = 7 * BUCKETS_PER_DAY
85
+ DAYS = {"su": 0, "mo": 1, "tu": 2, "we": 3, "th": 4, "fr": 5, "sa": 6}
86
+
87
+ # A band's factor: a share of the normal speed. Below 5% a street is
88
+ # effectively closed (close it with access tags instead); above 200%
89
+ # is almost certainly a typo.
90
+ MIN_FACTOR, MAX_FACTOR = 0.05, 2.0
91
+ # A measured speed, km/h: Valhalla's own limits for traffic speeds.
92
+ MIN_SPEED_KPH, MAX_SPEED_KPH = 1, 200
93
+
94
+ # Locations per /locate request.
95
+ LOCATE_BATCH = 1000
96
+
97
+ # How many loaded segments to check after loading (all, up to this).
98
+ VERIFY_SAMPLE = 500
99
+
100
+ # Valhalla packs a week of speeds into 200 numbers, which can't hold a short
101
+ # deep dip: unpacked, a 90-minute band from 64 down to 12 km/h dips to 3 km/h
102
+ # and one from 100 to 8 below zero (Valhalla then crawls at 1 km/h). So
103
+ # speeds that unpack below LIFT_BELOW of the speed asked for are raised
104
+ # before packing again (a few rounds), and a week whose trip times through
105
+ # its bands still differ from those asked by more than APPROX_LIMIT is
106
+ # reported.
107
+ LIFT_BELOW = 0.5
108
+ LIFT_ROUNDS = 40
109
+ APPROX_LIMIT = 1.25
110
+
111
+ ALL_ROADS = "all"
112
+
113
+ Progress = Callable[[int, int, str], None]
114
+
115
+
116
+ @dataclass(frozen=True)
117
+ class TrafficBand:
118
+ """
119
+ Streets of `roads` run at `factor` of their normal speed, or at
120
+ `speed` km/h, on `days` during `time` (exactly one of the two is set).
121
+ """
122
+
123
+ name: str
124
+ days: tuple[int, ...] # 0 = Sunday ... 6 = Saturday
125
+ start: int # minutes after midnight
126
+ end: int # minutes after midnight; <= start runs past midnight
127
+ roads: frozenset[str] # highway values (links included), or {"all"}
128
+ factor: float | None = None
129
+ speed: float | None = None # km/h
130
+
131
+ def speed_on(self, normal: float) -> float:
132
+ """This band's speed on a street whose normal speed is `normal` km/h."""
133
+ return self.speed if self.speed is not None else normal * self.factor
134
+
135
+ def covers(self, highway: str) -> bool:
136
+ return ALL_ROADS in self.roads or _road_class(highway) in self.roads
137
+
138
+ def buckets(self) -> np.ndarray:
139
+ """The week's 5-minute buckets this band covers."""
140
+ first, last = self.start // BUCKET_MINUTES, self.end // BUCKET_MINUTES
141
+ if last <= first:
142
+ last += BUCKETS_PER_DAY # runs past midnight into the next day
143
+ steps = np.arange(first, last)
144
+ starts = np.array(self.days) * BUCKETS_PER_DAY
145
+ return (starts[:, None] + steps[None, :]).ravel() % BUCKETS_PER_WEEK
146
+
147
+
148
+ DIRECTIONS = ("both", "forward", "backward")
149
+ STREET_FIELDS = ("days", "time", "factor", "speed_kph", "direction", "name")
150
+
151
+ # A drawn line claims a road segment if it runs along at least this share
152
+ # of it (a line copied from the network layer covers its own stretch
153
+ # fully; one drawn by hand may overshoot a little into the next).
154
+ MIN_COVERED = 0.5
155
+ MATCH_RADIUS_M = 15
156
+ DENSIFY_DEGREES = 0.0001 # about 10 m
157
+
158
+
159
+ @dataclass(frozen=True)
160
+ class StreetBand:
161
+ """One street (or stretch) at `factor` of its normal speed on `days` during `time`."""
162
+
163
+ feature: object # the row's id, for messages
164
+ way: int | None # OSM way id: that way (or its stretch along `line`)
165
+ line: tuple[tuple[float, float], ...] | None # (lon, lat), drawn direction
166
+ direction: str # both, forward or backward (of the line / way)
167
+ band: TrafficBand
168
+ from_table: bool = False # a speed-table row (named "row N", not on a map)
169
+
170
+
171
+ @dataclass
172
+ class TrafficResult:
173
+ """What add_traffic did."""
174
+
175
+ output: Path
176
+ ways: int = 0 # streets given traffic
177
+ segments: int = 0 # Valhalla directed edges given traffic
178
+ by_road: dict[str, int] = field(default_factory=dict) # road class -> streets
179
+ bands: list[str] = field(default_factory=list)
180
+ streets_not_found: list[int] = field(default_factory=list) # OSM way ids
181
+ single_streets: int = 0 # street rows used
182
+ single_segments: int = 0 # segments street and table rows set
183
+ unmatched: list = field(default_factory=list) # street features that matched nothing
184
+ table_rows: int = 0 # speed-table rows used
185
+ unmatched_rows: list = field(default_factory=list) # speed-table rows that matched nothing
186
+
187
+
188
+ # ---------------------------------------------------------------- settings
189
+
190
+ def read_traffic_settings(path: str | Path) -> list[TrafficBand]:
191
+ """Bands from a .toml or .json file: a list of tables under `band`."""
192
+ path = Path(path)
193
+ if not path.is_file():
194
+ raise InputError(f"The traffic settings file doesn't exist: {path}", guide=GUIDE)
195
+ try:
196
+ if path.suffix.lower() == ".json":
197
+ settings = json.loads(path.read_text(encoding="utf-8"))
198
+ else:
199
+ settings = tomllib.loads(path.read_text(encoding="utf-8"))
200
+ except (ValueError, UnicodeDecodeError) as exc:
201
+ raise InputError(f"Can't read the traffic settings in {path}: {exc}",
202
+ guide=GUIDE) from exc
203
+ return parse_bands(settings.get("band") if isinstance(settings, dict) else None)
204
+
205
+
206
+ def parse_bands(rows) -> list[TrafficBand]:
207
+ """Check and convert band tables ({name, days, time, roads, factor or speed_kph})."""
208
+ if not isinstance(rows, list) or not rows:
209
+ raise InputError("The traffic settings need at least one [[band]].",
210
+ guide=f"{GUIDE}#settings")
211
+ problems, bands = [], []
212
+ for number, row in enumerate(rows, start=1):
213
+ label = f"band {number}"
214
+ if not isinstance(row, dict):
215
+ problems.append(f"{label} isn't a table of settings")
216
+ continue
217
+ label = f"band {number} ({row['name']})" if row.get("name") else label
218
+ unknown = set(row) - {"name", "days", "time", "roads", "factor", "speed_kph"}
219
+ if unknown:
220
+ problems.append(f"{label}: unknown setting(s) {', '.join(sorted(unknown))}")
221
+ values, failed = {}, False
222
+ for key, check, default in (("days", _days, "Mo-Su"), ("time", _time_range, None),
223
+ ("roads", _roads, None),
224
+ ("rate", _rate, (row.get("factor"), row.get("speed_kph")))):
225
+ try:
226
+ values[key] = check(row.get(key, default))
227
+ except ValueError as exc:
228
+ problems.append(f"{label}: {exc}")
229
+ failed = True
230
+ if not failed:
231
+ start, end = values["time"]
232
+ factor, speed = values["rate"]
233
+ bands.append(TrafficBand(str(row.get("name") or f"Band {number}"), values["days"],
234
+ start, end, values["roads"], factor, speed))
235
+ if problems:
236
+ raise InputError("Traffic settings problem(s):\n - " + "\n - ".join(problems),
237
+ guide=f"{GUIDE}#settings",
238
+ issues=[{"feature": None, "message": p} for p in problems])
239
+ return bands
240
+
241
+
242
+ def _days(text) -> tuple[int, ...]:
243
+ """'Mo-Fr', 'Sa,Su', 'Mo-Su', 'Fr-Mo' (wraps) -> day numbers, Sunday = 0."""
244
+ if not isinstance(text, str) or not text.strip():
245
+ raise ValueError("days must be like 'Mo-Fr' or 'Sa,Su'")
246
+ days = []
247
+ for part in text.replace(" ", "").lower().split(","):
248
+ first, _, last = part.partition("-")
249
+ if first not in DAYS or (last and last not in DAYS):
250
+ raise ValueError(f"days {text!r}: use Mo Tu We Th Fr Sa Su, e.g. 'Mo-Fr' or 'Sa,Su'")
251
+ a = DAYS[first]
252
+ b = DAYS[last] if last else a
253
+ # Monday-first order, so 'Mo-Su' is the whole week and 'Fr-Mo' wraps.
254
+ a7, b7 = (a - 1) % 7, (b - 1) % 7
255
+ span = range(a7, b7 + 1) if a7 <= b7 else [*range(a7, 7), *range(0, b7 + 1)]
256
+ days.extend((d + 1) % 7 for d in span)
257
+ return tuple(sorted(set(days)))
258
+
259
+
260
+ _TIME = re.compile(r"^\s*(\d{1,2}):(\d{2})\s*-\s*(\d{1,2}):(\d{2})\s*$")
261
+
262
+
263
+ def _time_range(text) -> tuple[int, int]:
264
+ """'07:00-09:30' -> (420, 570); '22:00-06:00' runs past midnight; '00:00-24:00' all day."""
265
+ match = _TIME.match(text) if isinstance(text, str) else None
266
+ if not match:
267
+ raise ValueError("time must be like '07:00-09:30'")
268
+ h1, m1, h2, m2 = map(int, match.groups())
269
+ start, end = h1 * 60 + m1, h2 * 60 + m2
270
+ if h1 > 23 or m1 > 59 or m2 > 59 or end > 24 * 60:
271
+ raise ValueError(f"time {text!r} isn't a time of day")
272
+ if start % BUCKET_MINUTES or end % BUCKET_MINUTES:
273
+ raise ValueError(f"time {text!r}: times must be on a 5-minute mark (Valhalla's steps)")
274
+ if start == end:
275
+ raise ValueError(f"time {text!r} is empty")
276
+ return start, end % (24 * 60) if end == 24 * 60 and start == 0 else end
277
+
278
+
279
+ def _roads(value) -> frozenset[str]:
280
+ if isinstance(value, str):
281
+ value = [value]
282
+ if not isinstance(value, list) or not value:
283
+ raise ValueError("roads must list highway values, e.g. ['primary', 'secondary'], "
284
+ "or be 'all'")
285
+ roads = {str(v).strip().lower() for v in value}
286
+ if ALL_ROADS in roads:
287
+ return frozenset({ALL_ROADS})
288
+ unknown = sorted(r for r in roads if r not in KNOWN_HIGHWAYS)
289
+ if unknown:
290
+ raise ValueError(f"unknown road class(es) {', '.join(unknown)}; use highway values "
291
+ f"such as motorway, trunk, primary, secondary, tertiary, residential")
292
+ return frozenset(_road_class(r) for r in roads)
293
+
294
+
295
+ def _factor(value) -> float:
296
+ if isinstance(value, bool) or not isinstance(value, int | float):
297
+ raise ValueError("factor must be a number, e.g. 0.6 for 60% of the normal speed")
298
+ if not MIN_FACTOR <= value <= MAX_FACTOR:
299
+ raise ValueError(f"factor {value} must be between {MIN_FACTOR} and {MAX_FACTOR} "
300
+ "(a share of the normal speed)")
301
+ return float(value)
302
+
303
+
304
+ def _speed(value) -> float:
305
+ if isinstance(value, bool) or not isinstance(value, int | float):
306
+ raise ValueError("speed_kph must be a number of km/h, e.g. 25")
307
+ if not MIN_SPEED_KPH <= value <= MAX_SPEED_KPH:
308
+ raise ValueError(f"speed_kph {value} must be between {MIN_SPEED_KPH} and "
309
+ f"{MAX_SPEED_KPH} km/h")
310
+ return float(value)
311
+
312
+
313
+ def _rate(pair) -> tuple[float | None, float | None]:
314
+ """(factor, speed_kph) as given -> checked, exactly one of them set."""
315
+ factor, speed = (None if _empty(v) else v for v in pair)
316
+ if (factor is None) == (speed is None):
317
+ raise ValueError("give either factor (a share of the normal speed, e.g. 0.6) or "
318
+ "speed_kph (a speed, e.g. 25), not both or neither")
319
+ if factor is not None:
320
+ return _factor(_as_number(factor, "factor")), None
321
+ return None, _speed(_as_number(speed, "speed_kph"))
322
+
323
+
324
+ def _as_number(value, name):
325
+ if isinstance(value, str):
326
+ try:
327
+ return float(value.replace(",", "."))
328
+ except ValueError:
329
+ raise ValueError(f"{name} {value!r} isn't a number") from None
330
+ return value
331
+
332
+
333
+ def _empty(value) -> bool:
334
+ return value is None or value == "" or (isinstance(value, float) and np.isnan(value))
335
+
336
+
337
+ def _road_class(highway: str) -> str:
338
+ """'primary_link' counts as 'primary'."""
339
+ return highway.removesuffix("_link")
340
+
341
+
342
+ def street_bands(layer) -> list[StreetBand]:
343
+ """
344
+ Single-street bands from a line layer (a GeoDataFrame indexed by the
345
+ feature ids messages should use). Fields: days (default every day),
346
+ time, factor or speed_kph, direction (both / forward / backward;
347
+ default both), name; and optionally osm_id or osmid. Every problem is
348
+ listed at once.
349
+ """
350
+ if layer.crs is None:
351
+ raise InputError("The single-streets layer has no coordinate reference system.",
352
+ guide=f"{GUIDE}#single-streets")
353
+ # A wrong CRS (metres labelled as degrees) matched no street at all,
354
+ # silently: refuse coordinates that can't be a place on the earth.
355
+ lines = layer.geometry
356
+ drawn = lines.notna() & ~lines.is_empty
357
+ finite = pd.Series(False, index=layer.index)
358
+ if drawn.any(): # rows may give only an OSM way id
359
+ finite[drawn] = [bool(np.isfinite(shapely.get_coordinates(g)).all())
360
+ for g in lines[drawn]]
361
+ off = pd.Series(False, index=layer.index)
362
+ if finite.any():
363
+ off[finite] = ~within_the_earth(lines[finite])
364
+ off |= drawn & ~finite
365
+ if off.any():
366
+ features = list(layer.index[off])
367
+ raise InputError(
368
+ f"Some single streets can't be placed - feature(s) {', '.join(map(str, features))}: "
369
+ f"{OFF_THE_EARTH}.", guide=f"{GUIDE}#single-streets",
370
+ issues=[{"feature": feature, "message": OFF_THE_EARTH} for feature in features])
371
+ return _street_rows(layer.to_crs("EPSG:4326"), table=False)
372
+
373
+
374
+ def speed_table(table) -> list[StreetBand]:
375
+ """
376
+ Bands by OSM way id from a table (a DataFrame, no lines needed; indexed
377
+ by the row ids messages should use). Fields: osm_id or osmid, days,
378
+ time, factor or speed_kph, direction (both / forward / backward,
379
+ relative to the way's own direction), name.
380
+ """
381
+ if not any(c in table.columns for c in EDIT_ID_COLUMNS):
382
+ raise InputError("The speed table needs an OSM way id column "
383
+ f"({' or '.join(EDIT_ID_COLUMNS)}).", guide=f"{GUIDE}#speed-table")
384
+ return _street_rows(table, table=True)
385
+
386
+
387
+ def _street_rows(frame, table: bool) -> list[StreetBand]:
388
+ what, noun = ("speed table", "row") if table else ("single-street", "feature")
389
+ id_column = next((c for c in EDIT_ID_COLUMNS if c in frame.columns), None)
390
+ geometry_name = frame.geometry.name if hasattr(frame, "geometry") and not table else None
391
+ unknown = [c for c in frame.columns
392
+ if c not in (*STREET_FIELDS, *EDIT_ID_COLUMNS, geometry_name, "geometry")]
393
+ if unknown:
394
+ log.info("%s: fields not used for traffic: %s", what.capitalize(),
395
+ ", ".join(map(str, unknown)))
396
+ problems, streets = [], []
397
+ for feature, row in frame.iterrows():
398
+ def value(key, default=None, row=row):
399
+ v = row.get(key)
400
+ return default if _empty(v) else v
401
+ values, failed = {}, False
402
+ for key, check, default in (("days", _days, "Mo-Su"), ("time", _time_range, None),
403
+ ("rate", _rate, (value("factor"), value("speed_kph")))):
404
+ try:
405
+ values[key] = check(value(key, default))
406
+ except ValueError as exc:
407
+ problems.append((feature, str(exc)))
408
+ failed = True
409
+ direction = str(value("direction", "both")).strip().lower()
410
+ if direction not in DIRECTIONS:
411
+ problems.append((feature, f"direction {direction!r} must be both, forward or "
412
+ "backward"))
413
+ failed = True
414
+ way = value(id_column) if id_column else None
415
+ if way is not None:
416
+ try:
417
+ way = int(float(way))
418
+ except (TypeError, ValueError):
419
+ problems.append((feature, f"{id_column} {way!r} isn't an OSM way id"))
420
+ failed = True
421
+ lines = []
422
+ if table:
423
+ if way is None and id_column:
424
+ problems.append((feature, f"has no OSM way id ({id_column})"))
425
+ failed = True
426
+ else:
427
+ geometry = row.geometry
428
+ if geometry is not None and not geometry.is_empty:
429
+ parts = getattr(geometry, "geoms", [geometry])
430
+ lines = [tuple(p.coords) for p in parts if p.geom_type == "LineString"]
431
+ if len(lines) != len(parts):
432
+ problems.append((feature, f"is a {geometry.geom_type}; single streets "
433
+ "are lines"))
434
+ failed = True
435
+ elif way is None:
436
+ problems.append((feature, "has neither a line nor an OSM way id"))
437
+ failed = True
438
+ if failed:
439
+ continue
440
+ start, end = values["time"]
441
+ factor, speed = values["rate"]
442
+ label = f"{'Row' if table else 'Street'} {feature_id(feature)}"
443
+ band = TrafficBand(str(value("name", label)), values["days"], start, end, frozenset(),
444
+ factor, speed)
445
+ if lines:
446
+ streets.extend(StreetBand(feature, way, line, direction, band) for line in lines)
447
+ else:
448
+ streets.append(StreetBand(feature, way, None, direction, band, from_table=table))
449
+ if problems:
450
+ raise InputError(
451
+ f"{len(problems)} {what} problem(s):\n - " + "\n - ".join(
452
+ f"{noun} {feature_id(f)}: {m}" for f, m in problems),
453
+ guide=f"{GUIDE}#{'speed-table' if table else 'single-streets'}",
454
+ issues=[{"feature": None if table else f,
455
+ "message": f"{noun} {feature_id(f)}: {m}" if table else m}
456
+ for f, m in problems])
457
+ return streets
458
+
459
+
460
+ def week_speeds(bands: Iterable[TrafficBand], normal: float) -> np.ndarray | None:
461
+ """
462
+ The week of speeds (km/h, 2,016 values, Sunday 00:00 first) of a
463
+ street whose normal speed is `normal`, under these bands, or None if
464
+ there are none. Where bands overlap, the slowest wins; where none
465
+ applies, the normal speed.
466
+ """
467
+ bands = list(bands)
468
+ if not bands:
469
+ return None
470
+ speeds = np.full(BUCKETS_PER_WEEK, np.inf, dtype=np.float32)
471
+ for band in bands:
472
+ idx = band.buckets()
473
+ speeds[idx] = np.minimum(speeds[idx], band.speed_on(normal))
474
+ speeds[np.isinf(speeds)] = normal
475
+ return np.maximum(speeds, 1.0)
476
+
477
+
478
+ def road_class_bands(bands: Iterable[TrafficBand], highway: str) -> list[TrafficBand]:
479
+ """The bands that cover a street of this highway class."""
480
+ return [band for band in bands if band.covers(highway)]
481
+
482
+
483
+ # ---------------------------------------------------------------- the build
484
+
485
+ def add_traffic(pbf: str | Path, bands: list[TrafficBand], out: str | Path,
486
+ streets: list[StreetBand] | None = None,
487
+ progress: Progress | None = None) -> TrafficResult:
488
+ """
489
+ Build Valhalla's graph from `pbf`, put the road-class `bands` and the
490
+ single `streets` on it and write it as the tar file `out`.
491
+ """
492
+ (valhalla, bin_dir, get_config, GraphId, compress, encode, GraphUtils,
493
+ decompress) = _import_valhalla()
494
+ pbf, out = Path(pbf), Path(out)
495
+ streets = streets or []
496
+ if not bands and not streets:
497
+ raise InputError("No traffic to add: give road-class bands, single streets or both.",
498
+ guide=GUIDE)
499
+ if not pbf.is_file():
500
+ raise InputError(f"The OSM file doesn't exist: {pbf}", guide=GUIDE)
501
+ if out.suffix.lower() != ".tar":
502
+ raise InputError(f"The traffic output must be a .tar file, got {out.name}", guide=GUIDE)
503
+ if not out.resolve().parent.is_dir():
504
+ raise InputError(f"The folder for the traffic output doesn't exist: {out.parent}")
505
+ if out.resolve() == pbf.resolve():
506
+ raise InputError("The traffic output would overwrite the OSM file.")
507
+ step = _steps(progress, 6)
508
+ result = TrafficResult(output=out, bands=[b.name for b in bands])
509
+
510
+ step("Reading streets")
511
+ by_id = {s.way for s in streets if s.way is not None}
512
+ ways = _ways(pbf, bands, by_id)
513
+ missing = sorted(by_id - set(ways))
514
+ if missing:
515
+ _report_unmatched(
516
+ [s for s in streets if s.way in missing], result,
517
+ f"name an OSM way that isn't in {pbf.name} ({', '.join(map(str, missing[:10]))}"
518
+ f"{' ...' if len(missing) > 10 else ''})")
519
+ streets = [s for s in streets if s.way not in missing]
520
+
521
+ with tempfile.TemporaryDirectory(prefix="networkforge-traffic-") as work:
522
+ work = Path(work)
523
+ step("Building Valhalla's graph")
524
+ config_path = _build_graph(pbf, work, bin_dir, get_config)
525
+ actor = valhalla.Actor(str(config_path))
526
+
527
+ step("Finding the streets' road segments")
528
+ segments, not_found = _segments(actor, ways)
529
+ banded = {w for w, way in ways.items() if any(b.covers(way.highway) for b in bands)}
530
+ result.streets_not_found = sorted(not_found & banded)
531
+ if result.streets_not_found:
532
+ log.warning("Valhalla has no road segment for %d street(s) in a traffic band "
533
+ "(it doesn't route on them); they get no traffic.",
534
+ len(result.streets_not_found))
535
+ claims = _claim(actor, GraphUtils(str(config_path)), GraphId, streets, segments, result)
536
+
537
+ step("Writing the speeds")
538
+ traffic_dir = work / "traffic"
539
+ rows = _write_speed_files(segments, ways, bands, claims, traffic_dir,
540
+ GraphId, compress, encode, decompress)
541
+
542
+ step("Loading the speeds into the graph")
543
+ if rows:
544
+ _run([str(bin_dir / "valhalla_add_predicted_traffic"), "-c", str(config_path),
545
+ str(traffic_dir)], "load the traffic")
546
+ _verify(valhalla.Actor(str(config_path)), segments, rows)
547
+ else:
548
+ log.warning("No road segment got traffic: the tar is the network without traffic.")
549
+
550
+ step("Packing the graph")
551
+ _pack(config_path, out)
552
+
553
+ result.segments = len(rows)
554
+ by_class = {segments[e].way for e in rows if e not in claims and segments[e].way in banded}
555
+ result.ways = len({segments[e].way for e in rows})
556
+ counts: dict[str, int] = defaultdict(int)
557
+ for way in by_class:
558
+ counts[_road_class(ways[way].highway)] += 1
559
+ result.by_road = dict(sorted(counts.items()))
560
+ result.single_segments = len(claims)
561
+ return result
562
+
563
+
564
+ @dataclass
565
+ class _Way:
566
+ highway: str
567
+ points: list[tuple[float, float]] # (lon, lat) midpoints of each node-to-node step
568
+
569
+
570
+ def _ways(pbf: Path, bands: list[TrafficBand], wanted: set[int]) -> dict[int, _Way]:
571
+ """Ways a band covers, plus the `wanted` way ids: id -> highway and where to look."""
572
+ ways = {}
573
+ with readable(pbf) as name:
574
+ for way in osmium.FileProcessor(name, osmium.osm.NODE | osmium.osm.WAY).with_locations():
575
+ if not isinstance(way, osmium.osm.Way):
576
+ continue
577
+ highway = way.tags.get("highway", "")
578
+ if way.id not in wanted and not (highway and any(b.covers(highway) for b in bands)):
579
+ continue
580
+ try:
581
+ coords = [(n.location.lon, n.location.lat) for n in way.nodes]
582
+ except osmium.InvalidLocationError:
583
+ continue # a way cut at the extract's edge
584
+ if len(coords) < 2:
585
+ continue
586
+ xy = np.array(coords)
587
+ mids = (xy[:-1] + xy[1:]) / 2
588
+ ways[way.id] = _Way(highway, [tuple(p) for p in mids])
589
+ return ways
590
+
591
+
592
+ def _build_graph(pbf: Path, work: Path, bin_dir: Path, get_config) -> Path:
593
+ tile_dir = work / "tiles"
594
+ tile_dir.mkdir()
595
+ config = get_config(tile_extract="", tile_dir=str(tile_dir))
596
+ config["mjolnir"]["concurrency"] = 1
597
+ config_path = work / "valhalla.json"
598
+ config_path.write_text(json.dumps(config))
599
+ # Valhalla reads OSM files through libosmium, so it gets the same help
600
+ # with non-ASCII paths on Windows as osmium does.
601
+ with readable(pbf) as name:
602
+ _run([str(bin_dir / "valhalla_build_tiles"), "-c", str(config_path), name],
603
+ "build a graph from the OSM file")
604
+ if not any(tile_dir.rglob("*.gph")):
605
+ raise InputError(f"Valhalla found no streets to route on in {pbf.name}.", guide=GUIDE)
606
+ return config_path
607
+
608
+
609
+ @dataclass(frozen=True)
610
+ class _Segment:
611
+ way: int
612
+ speed: int # Valhalla's normal speed, km/h
613
+ probe: tuple[float, float] # a (lon, lat) where /locate finds it
614
+ forward: bool # runs in the way's own direction
615
+
616
+
617
+ def _segments(actor, ways: dict[int, _Way]) -> tuple[dict[int, _Segment], set[int]]:
618
+ """
619
+ Valhalla's directed edges for each way, by edge id. Found with /locate
620
+ at the middle of every step between two of the way's nodes, so every
621
+ segment of the way is hit (both directions).
622
+ """
623
+ points = [(way, p) for way, street in ways.items() for p in street.points]
624
+ segments: dict[int, _Segment] = {}
625
+ for batch, located in _locate(actor, [p for _, p in points], points):
626
+ for (way, point), found in zip(batch, located, strict=True):
627
+ for edge in found.get("edges") or []:
628
+ edge_id = edge["edge_id"]["value"]
629
+ speed = edge["edge"]["speeds"]["default"]
630
+ if edge["edge_info"]["way_id"] == way and speed > 0 and edge_id not in segments:
631
+ segments[edge_id] = _Segment(way, int(speed), point,
632
+ bool(edge["edge"]["forward"]))
633
+ found_ways = {s.way for s in segments.values()}
634
+ return segments, set(ways) - found_ways
635
+
636
+
637
+ def _locate(actor, points: list[tuple[float, float]], items: list):
638
+ """(items, /locate results) in batches of LOCATE_BATCH points."""
639
+ for i in range(0, len(points), LOCATE_BATCH):
640
+ located = actor.locate({
641
+ "locations": [{"lon": lon, "lat": lat, "radius": 1}
642
+ for lon, lat in points[i : i + LOCATE_BATCH]],
643
+ "verbose": True,
644
+ "costing": "none",
645
+ })
646
+ yield items[i : i + LOCATE_BATCH], located
647
+
648
+
649
+ def _claim(actor, graph, GraphId, streets: list[StreetBand], segments: dict[int, _Segment],
650
+ result: TrafficResult) -> dict[int, list[TrafficBand]]:
651
+ """
652
+ The road segments each single street sets: edge id -> its bands.
653
+ Segments a drawn line finds that no band covers are added to `segments`.
654
+ """
655
+ claims: dict[int, list[TrafficBand]] = defaultdict(list)
656
+ used, unmatched = set(), []
657
+ for street in streets:
658
+ if street.line is not None:
659
+ # Along the line; with a way id too, only that way's stretches
660
+ # (as when changing an existing street).
661
+ edges = [e for e in _match(actor, graph, GraphId, street, segments)
662
+ if street.way is None or segments[e].way == street.way]
663
+ else:
664
+ wanted = {"both": (True, False), "forward": (True,), "backward": (False,)}
665
+ edges = [e for e, s in segments.items() if s.way == street.way
666
+ and s.forward in wanted[street.direction]]
667
+ key = (street.from_table, street.feature)
668
+ if not edges:
669
+ unmatched.append(street)
670
+ continue
671
+ used.add(key)
672
+ for edge in edges:
673
+ claims[edge].append(street.band)
674
+ _report_unmatched([s for s in unmatched if (s.from_table, s.feature) not in used], result,
675
+ "run along no road segment Valhalla drives on (draw lines along the "
676
+ "street, over at least half of each stretch)")
677
+ result.single_streets = sum(1 for table, _ in used if not table)
678
+ result.table_rows = sum(1 for table, _ in used if table)
679
+ return claims
680
+
681
+
682
+ def _report_unmatched(streets: list[StreetBand], result: TrafficResult, why: str) -> None:
683
+ """Warn about street features and table rows that get no traffic, naming them."""
684
+ features = list(dict.fromkeys(s.feature for s in streets if not s.from_table))
685
+ rows = list(dict.fromkeys(s.feature for s in streets if s.from_table))
686
+ if features:
687
+ log.warning("%d single street(s) %s; they get no traffic.", len(features), why,
688
+ extra={"features": features})
689
+ result.unmatched.extend(f for f in features if f not in result.unmatched)
690
+ if rows:
691
+ log.warning("%d speed table row(s) %s; they get no traffic: rows %s.", len(rows),
692
+ why.split(" (draw")[0], ", ".join(str(feature_id(r)) for r in rows[:20]),
693
+ extra={"rows": rows})
694
+ result.unmatched_rows.extend(r for r in rows if r not in result.unmatched_rows)
695
+
696
+
697
+ def _match(actor, graph, GraphId, street: StreetBand, segments: dict[int, _Segment]) -> list[int]:
698
+ """Edges a drawn line runs along (each over at least MIN_COVERED of it)."""
699
+ from shapely import LineString, segmentize
700
+
701
+ line, back = street.line, tuple(reversed(street.line))
702
+ shapes = {"forward": [line], "backward": [back], "both": [line, back]}[street.direction]
703
+ edges = []
704
+ for shape in shapes:
705
+ dense = segmentize(LineString(shape), DENSIFY_DEGREES).coords
706
+ try:
707
+ matched = actor.trace_attributes({
708
+ "shape": [{"lon": x, "lat": y} for x, y in dense],
709
+ "shape_match": "map_snap",
710
+ "costing": "auto",
711
+ "trace_options": {"search_radius": MATCH_RADIUS_M},
712
+ "filters": {"attributes": ["edge.id", "edge.way_id", "edge.speed",
713
+ "edge.forward"], "action": "include"},
714
+ })
715
+ except RuntimeError:
716
+ continue # no drivable street along it in this direction
717
+ for edge in matched.get("edges") or []:
718
+ covered = edge.get("target_percent_along", 1) - edge.get("source_percent_along", 0)
719
+ if covered < MIN_COVERED or not edge.get("speed"):
720
+ continue
721
+ edge_id = edge["id"]
722
+ if edge_id not in segments:
723
+ shape_ll = LineString(graph.get_edge_shape(GraphId(edge_id)))
724
+ middle = shape_ll.interpolate(0.5, normalized=True)
725
+ segments[edge_id] = _Segment(edge["way_id"], int(edge["speed"]),
726
+ (middle.x, middle.y), bool(edge["forward"]))
727
+ edges.append(edge_id)
728
+ return list(dict.fromkeys(edges))
729
+
730
+
731
+ def _write_speed_files(segments: dict[int, _Segment], ways: dict[int, _Way], bands,
732
+ claims: dict[int, list[TrafficBand]], folder: Path,
733
+ GraphId, compress, encode, decompress) -> dict[int, int]:
734
+ """
735
+ One CSV per graph tile (in a tree mirroring the tiles), one row per
736
+ segment: id, free-flow speed, daytime speed (0 = none), the encoded
737
+ week. A segment a single street claims takes that street's bands
738
+ only. Returns edge id -> free-flow speed for every row written.
739
+ """
740
+ by_class: dict[str, list[TrafficBand]] = {}
741
+ weeks: dict[tuple, tuple | None] = {} # many segments share bands and speed
742
+ approximated: dict[tuple, tuple[float, int]] = {} # key -> (worst time ratio, segments)
743
+ rows: dict[int, int] = {}
744
+ files: dict[Path, list] = defaultdict(list)
745
+ for edge_id, segment in sorted(segments.items()):
746
+ if edge_id in claims:
747
+ these = claims[edge_id]
748
+ else:
749
+ way = ways.get(segment.way)
750
+ highway = way.highway if way else ""
751
+ if highway not in by_class:
752
+ by_class[highway] = road_class_bands(bands, highway) if highway else []
753
+ these = by_class[highway]
754
+ key = (tuple(these), segment.speed)
755
+ if key not in weeks:
756
+ target = week_speeds(these, segment.speed)
757
+ weeks[key] = None if target is None else _packable(target, segment.speed,
758
+ compress, decompress)
759
+ if weeks[key] is None:
760
+ continue
761
+ week, off = weeks[key]
762
+ if off:
763
+ approximated[key] = (off, approximated.get(key, (0, 0))[1] + 1)
764
+ gid = GraphId(edge_id)
765
+ path = (folder / os.fspath(gid)).with_suffix(".csv")
766
+ # Daytime speed 0 = not set: requests without a time use free-flow.
767
+ files[path].append([str(gid), segment.speed, 0, encode(week)])
768
+ rows[edge_id] = segment.speed
769
+ if approximated:
770
+ worst = max(approximated.values(), key=lambda v: abs(np.log(v[0])))[0]
771
+ bands = sorted({b.name for these, _ in approximated for b in these})
772
+ log.warning(
773
+ "Valhalla can only approximate the speeds of %d road segment(s): a band short "
774
+ "and much slower than normal doesn't fit its week of speeds, so trips during it "
775
+ "come out up to %.1fx %s than given (bands: %s). Bands of 2 hours or more, or "
776
+ "smaller drops, are kept more closely.",
777
+ sum(n for _, n in approximated.values()), max(worst, 1 / worst),
778
+ "slower" if worst > 1 else "faster", ", ".join(bands[:5]))
779
+ for path, lines in files.items():
780
+ path.parent.mkdir(parents=True, exist_ok=True)
781
+ # "\n" only: with "\r\n" Valhalla silently drops every row.
782
+ with path.open("w", newline="", encoding="ascii") as f:
783
+ csv.writer(f, lineterminator="\n").writerows(lines)
784
+ return rows
785
+
786
+
787
+ def _packable(target: np.ndarray, normal: float, compress, decompress):
788
+ """
789
+ The packed week (Valhalla's 200 numbers) for these target speeds, and
790
+ how far off trip times through its bands come out (0 if close). Speeds
791
+ that unpack below LIFT_BELOW of their target are raised and the week
792
+ packed again.
793
+ """
794
+ week = target.astype(np.float32)
795
+ for _ in range(LIFT_ROUNDS):
796
+ packed = compress(week)
797
+ unpacked = np.array([decompress(packed, i) for i in range(BUCKETS_PER_WEEK)])
798
+ short = np.maximum(LIFT_BELOW * target - unpacked, 0)
799
+ if short.max() < 0.5:
800
+ break
801
+ week = (week + short).astype(np.float32)
802
+ banded = target != normal
803
+ off = 0.0
804
+ if banded.any():
805
+ # Time per km through the banded buckets: given vs as Valhalla will have it.
806
+ ratio = (np.mean(1 / np.maximum(unpacked[banded], 1.0))
807
+ / np.mean(1 / target[banded]))
808
+ if not 1 / APPROX_LIMIT <= ratio <= APPROX_LIMIT:
809
+ off = float(ratio)
810
+ return packed, off
811
+
812
+
813
+ def _verify(actor, segments: dict[int, _Segment], rows: dict[int, int]) -> None:
814
+ """
815
+ Valhalla's loader reports nothing and drops a bad row without a word,
816
+ so look a sample of the segments up again and check they took.
817
+ """
818
+ ids = sorted(rows)
819
+ sample = ids[:: max(1, len(ids) // VERIFY_SAMPLE)][:VERIFY_SAMPLE]
820
+ missing = set(sample)
821
+ for batch, located in _locate(actor, [segments[e].probe for e in sample], sample):
822
+ for edge_id, found in zip(batch, located, strict=True):
823
+ for edge in found.get("edges") or []:
824
+ if edge["edge_id"]["value"] != edge_id:
825
+ continue
826
+ speeds = edge["edge"]["speeds"]
827
+ if speeds.get("predicted") and speeds.get("free_flow") == rows[edge_id]:
828
+ missing.discard(edge_id)
829
+ if missing:
830
+ ways = sorted({segments[e].way for e in missing})
831
+ raise NetworkIntegrityError(
832
+ f"Valhalla didn't take the traffic speeds for {len(missing)} of {len(sample)} "
833
+ f"checked road segments (OSM ways {', '.join(map(str, ways[:10]))}"
834
+ f"{' ...' if len(ways) > 10 else ''}).")
835
+ log.info("Checked %d of %d road segments: all have their traffic speeds.",
836
+ len(sample), len(rows))
837
+
838
+
839
+ def _pack(config_path: Path, out: Path) -> None:
840
+ config = json.loads(config_path.read_text())
841
+ config["mjolnir"]["tile_extract"] = str(out.resolve())
842
+ pack_config = config_path.with_name("pack.json")
843
+ pack_config.write_text(json.dumps(config))
844
+ _run([sys.executable, "-m", "valhalla.valhalla_build_extract", "-c", str(pack_config),
845
+ "-O"], "pack the graph into a tar file")
846
+ if not out.is_file():
847
+ raise NetworkIntegrityError(f"Valhalla didn't write the tar file {out}.")
848
+
849
+
850
+ def _run(command: list[str], what: str) -> None:
851
+ done = subprocess.run(command, capture_output=True, text=True, check=False,
852
+ env=_valhalla_env(Path(command[0])))
853
+ if done.returncode != 0:
854
+ detail = (done.stderr or done.stdout)[-2000:].strip()
855
+ raise NetworkIntegrityError(
856
+ f"Valhalla could not {what} (exit code {done.returncode})"
857
+ + (f":\n{detail}" if detail else "."))
858
+
859
+
860
+ def _valhalla_env(program: Path) -> dict[str, str]:
861
+ """
862
+ The environment for one of pyvalhalla's programs. On Windows their
863
+ libraries are in `pyvalhalla.libs` beside the package, which they only
864
+ find through PATH (exit code 3221225781, "DLL not found", without it).
865
+ """
866
+ env = dict(os.environ)
867
+ if os.name == "nt":
868
+ libraries = program.parent.parent.parent / "pyvalhalla.libs"
869
+ if program.parent.name == "bin" and libraries.is_dir():
870
+ env["PATH"] = os.pathsep.join([str(libraries), env.get("PATH", "")])
871
+ return env
872
+
873
+
874
+ def _steps(progress: Progress | None, total: int):
875
+ count = 0
876
+
877
+ def step(message: str) -> None:
878
+ nonlocal count
879
+ count += 1
880
+ log.info("[%d/%d] %s", count, total, message)
881
+ if progress:
882
+ progress(count, total, message)
883
+ return step
884
+
885
+
886
+ def _import_valhalla():
887
+ """pyvalhalla, imported only when traffic is used."""
888
+ try:
889
+ from valhalla._scripts import PYVALHALLA_BIN_DIR
890
+ from valhalla.baldr import GraphId
891
+ from valhalla.baldr.utils import (
892
+ GraphUtils,
893
+ compress_speed_buckets,
894
+ decompress_speed_bucket,
895
+ encode_compressed_speeds,
896
+ )
897
+ from valhalla.config import get_config
898
+
899
+ import valhalla
900
+ except ImportError as exc:
901
+ raise InputError('Traffic needs pyvalhalla: pip install "networkforge[traffic]"',
902
+ guide=GUIDE) from exc
903
+ return (valhalla, Path(PYVALHALLA_BIN_DIR), get_config, GraphId,
904
+ compress_speed_buckets, encode_compressed_speeds, GraphUtils,
905
+ decompress_speed_bucket)