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/traffic.py
ADDED
|
@@ -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)
|