Skip to content

Commit 539bc43

Browse files
committed
feat(traffic): apply bound road conditions to the routing graph
Consume the OpenConditions segment bindings so a closure or speed cap lands on the exact edges of the affected carriageway instead of a point exclusion or a whole way. - Core: isEdgeClosure / isRoutingRelevantBinding with normalized vehicle-class matching, and binding fields on RoadConditionEvent. - data-manager live cycle: fetch /segments/conditions.json, gate on binding status and origin, trace edge-exact spans through Valhalla trace_attributes with a per-cycle budget and a persisted cache, fall back to whole-way overrides when tracing fails and to no overrides when classification fails, then write closed (speed 0) and capped edges into the live traffic tar. Stale conditions are reused up to TRAFFIC_CONDITIONS_STALE_MS. - GET /traffic/conditions/applied reports the observation ids whose every override edge was written; 501 without OPENCONDITIONS_URL. - Routing integration reads that applied set (fresh for ten minutes, fail-open) and skips point exclusions for events the graph already closes; the closure decision routes through isEdgeClosure and active-time evaluation intersects span and schedule, so lorry-only closures no longer detour cars. - New env: TRAFFIC_VALHALLA_URL, TRAFFIC_CONDITIONS_STALE_MS; routing manifest optionally requires data-manager. - Docs: log lines, endpoint, and the operator end-to-end procedure in monitoring.md; new configuration rows and .env.example entries.
1 parent ff17b49 commit 539bc43

31 files changed

Lines changed: 3779 additions & 102 deletions

docs/docs/administration/monitoring.md

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ own application log — and an **audit log** that records every admin action.
1414
Underneath the UI sits an OpenTelemetry metrics pipeline you can scrape with
1515
Prometheus.
1616

17+
One background writer gets its own section: the live road-conditions cycle has
18+
no UI at all, and the closures it bakes into the routing graph are visible only
19+
through the data-manager's log and a single endpoint.
20+
1721
This page walks each of them and points at the code or env var behind the
1822
behavior, so you can verify and tune rather than guess.
1923

@@ -250,6 +254,180 @@ that restores raw request or URL logging.
250254

251255
The viewer is read-only — it's for triage, not configuration.
252256

257+
## Live road conditions in the routing graph
258+
259+
When `OPENCONDITIONS_URL` is set, the data-manager runs a live-traffic cycle
260+
(`TRAFFIC_LIVE_CRON`, default every two minutes) that folds road conditions —
261+
closures and temporary speed limits — straight into the Valhalla traffic file
262+
the router reads. This is a background writer with no UI of its own, so its two
263+
observation surfaces are the data-manager's container log and one small
264+
endpoint. Both are described below.
265+
266+
### What the writer puts into the graph
267+
268+
For every condition that survives its filters, the cycle rewrites the affected
269+
directed edges in `traffic.tar`:
270+
271+
- A **closure** becomes a genuine Valhalla *closed* record — a valid record
272+
(both breakpoints `255`) whose overall speed is `0`. Costings refuse a closed
273+
edge, so the router detours around it natively; only a request that opts into
274+
`ignore_closures` drives through.
275+
- A **temporary speed limit** becomes a cap: the edge is written at
276+
`min(live speed, the condition's limit)`. A cap on an edge with no live speed
277+
is written on its own.
278+
- On one edge a closure always outranks a cap, and between two caps the lower
279+
one wins.
280+
281+
Three filters decide whether a condition reaches an edge at all:
282+
283+
- **Binding confidence.** Only bindings OpenConditions marked `exact` or
284+
`likely` may move an edge. The feed also publishes `ambiguous` bindings; those
285+
are fine to display but are never written.
286+
- **Origin.** Feed-sourced conditions are written as they arrive. A
287+
crowd-sourced report additionally has to be marked routing-eligible by
288+
OpenConditions' own trust model.
289+
- **Vehicle class.** A closure scoped to classes that exclude ordinary cars — a
290+
lorry-only ban, say — is not written as an edge closure, because it does not
291+
close the road for the traffic being routed. Such an event stays on the
292+
point-exclusion path instead.
293+
294+
The writer owns expiry: Valhalla never ages out live values by itself. Every
295+
edge written last cycle and not written again this cycle is cleared back to "no
296+
live data", so a lifted closure or an expired cap disappears within one cycle.
297+
298+
### How a closure is narrowed to the edges it really covers
299+
300+
A bound condition arrives as one or more *spans* — a directed OSM way plus the
301+
occupied fraction of it, with the cut geometry. Closing the whole way would shut
302+
kilometres of motorway for a two-hundred-metre incident, so the cycle traces
303+
each span's geometry against `TRAFFIC_VALHALLA_URL`'s `/trace_attributes` and
304+
keeps only the returned edges that also appear in this deployment's way→edge
305+
map, on the same way and in the bound direction.
306+
307+
- Cross-checked edges accepted → the span is applied **edge-exactly**.
308+
- Nothing acceptable came back, or the span had no usable geometry → the cycle
309+
falls back to **every edge of the way in the bound direction**. That
310+
over-closes, but it never under-closes.
311+
312+
Trace verdicts are cached in `traffic/span-edges-cache.json` under the
313+
data-manager's data directory, and pruned each cycle down to the spans still
314+
being reported, so lifted closures fall out of the file. Two things are
315+
deliberately *not* cached, so a bad minute cannot pin a span to the whole-way
316+
fallback for the rest of its life: a transport failure (the routing container
317+
unreachable, slow, or answering with an error), and a span left untraced because
318+
the pass hit its 30-second tracing budget. Both are retried on the next cycle.
319+
320+
### The applied set
321+
322+
The router still has its own, coarser mechanism for closures: point-based
323+
exclusions handed to Valhalla per request. Applying both to the same event would
324+
be redundant and would needlessly narrow the alternatives the router can offer,
325+
so the writer publishes what it has already baked into the graph:
326+
327+
```bash
328+
curl -s http://localhost:4000/traffic/conditions/applied | jq
329+
```
330+
331+
```json
332+
{
333+
"writtenAt": "2026-09-07T09:14:02.511Z",
334+
"observationIds": [""],
335+
"resolverVersion": ""
336+
}
337+
```
338+
339+
The route needs no bearer token — it is derived from public road-conditions
340+
feeds and is polled on the routing hot path. Until the first successful live
341+
cycle it truthfully answers `writtenAt: null` with an empty list, and on a
342+
deployment where `OPENCONDITIONS_URL` is unset — live traffic not configured —
343+
it answers `501`. An observation is listed only when
344+
**every** one of its override edges was actually written; if a single edge could
345+
not be resolved, the whole observation is withheld.
346+
347+
Two limits are worth knowing. An applied closure is a fact about the graph, not
348+
about a departure time: a route planned for after the closure has ended still
349+
detours around it, whereas the point exclusions it replaces did honour the
350+
event's schedule. And the set asserts only that the *writer* wrote the record,
351+
not that the router has reloaded the tar — a Valhalla restart that fails after a
352+
`traffic.tar` rebuild is the one case the freshness window does not catch.
353+
354+
The routing integration polls this endpoint (through `DATA_MANAGER_URL`, default
355+
`http://localhost:4000`; compose sets the service DNS name) and caches the
356+
answer for 60 seconds. It drops its own point exclusions only for ids in a set
357+
whose `writtenAt` is less than ten minutes old. If the endpoint is unreachable,
358+
errors, or the set is stale, nothing is skipped and point exclusions keep
359+
working — the writer's liveness is the only switch, there is no flag to set.
360+
361+
### Log lines to watch
362+
363+
All of these come from the data-manager container
364+
(`pnpm openmapx services logs data-manager`, or its **Logs** tab in the admin
365+
panel).
366+
367+
| Line | Means |
368+
| ------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
369+
| `traffic-live: conditions applied` | The healthy per-cycle summary: `closedEdges`, `cappedEdges`, `overridesUnresolved`, `requestedClosures`, `appliedConditions`, `edgeExactSpans`, `wholeWaySpans`, `missingWays`, `skipped`. |
370+
| `traffic-live: span tracing` | Tracing counters for the cycle: `traced`, `unanswered`, `negative`, `skippedBudget`, `cacheHits`, `edgeExactSpans`, `wholeWaySpans`. |
371+
| `traffic-live: bound ways missing from way→edge map, scheduling refresh` | Conditions referenced ways this deployment's map does not know; a way→edge rebuild was kicked off (at most hourly). Persistent counts mean the graph and the feed's road spine are on different OSM vintages. |
372+
| `traffic-live: conditions fetch failed, reusing last good set` | OpenConditions was unreachable; the previous set is still within `TRAFFIC_CONDITIONS_STALE_MS` and stays applied. |
373+
| `traffic-live: conditions fetch failed and last set is stale, dropping closures` | The outage outlasted `TRAFFIC_CONDITIONS_STALE_MS`. Closures are dropped from the graph; the router falls back to point exclusions. |
374+
| `traffic-live: span tracing failed, falling back to whole-way binding` | The tracing pass itself failed. Closures still apply, whole-way instead of edge-exactly. |
375+
| `traffic-live: conditions classification failed, writing no overrides` | Turning conditions into edge overrides threw. Live speeds are still written, but this cycle applies no closures or caps; the next cycle retries. |
376+
| `traffic-live: span cache save failed` | The trace cache could not be persisted. Harmless for correctness: the in-memory cache still serves the rest of this process, so only a restart loses the verdicts and re-traces them. |
377+
| `traffic-live: skipped out-of-range edges (traffic.tar/waysToEdges mismatch)` | The way→edge map references edges the current `traffic.tar` does not have. The daily traffic-extract cron resolves this by rebuilding both. |
378+
379+
A healthy instance shows `unanswered` at zero and `edgeExactSpans` dominating
380+
`wholeWaySpans`. A rising `unanswered` points at the routing container, not at
381+
the conditions feed; a `wholeWaySpans` that never falls usually means
382+
`TRAFFIC_VALHALLA_URL` points somewhere other than the Valhalla holding this
383+
deployment's traffic graph.
384+
385+
### Verifying edge closures end-to-end
386+
387+
Once the cycle reports closures, three requests confirm the graph really carries
388+
them. First, check that conditions are arriving and that the writer credited
389+
some of them:
390+
391+
```bash
392+
curl -s "$OPENCONDITIONS_URL/segments/conditions.json" | jq '.conditions | length'
393+
curl -s http://localhost:4000/traffic/conditions/applied | jq
394+
```
395+
396+
Then pick one applied closure, take a pair of coordinates on the closed
397+
carriageway either side of it, and route across it:
398+
399+
```bash
400+
curl -s http://127.0.0.1:8002/route -d '{
401+
"locations":[{"lat":51.40,"lon":6.80},{"lat":51.45,"lon":6.95}],
402+
"costing":"auto","date_time":{"type":0}}' | jq '.trip.summary'
403+
404+
curl -s http://127.0.0.1:8002/route -d '{
405+
"locations":[{"lat":51.40,"lon":6.80},{"lat":51.45,"lon":6.95}],
406+
"costing":"auto","date_time":{"type":0},
407+
"costing_options":{"auto":{"ignore_closures":true}}}' | jq '.trip.summary'
408+
```
409+
410+
The first must detour — a longer distance or time than the second, which ignores
411+
closures and drives straight through. If the two summaries are identical, the
412+
closure is not in the graph: check `closedEdges` in `traffic-live: conditions
413+
applied` and whether the router is reading the same `traffic.tar` the writer
414+
writes.
415+
416+
Finally, route between two points on the **same way but outside** the closed
417+
span, for example from just past the closure to the next exit:
418+
419+
```bash
420+
curl -s http://127.0.0.1:8002/route -d '{
421+
"locations":[{"lat":51.46,"lon":6.97},{"lat":51.48,"lon":7.02}],
422+
"costing":"auto","date_time":{"type":0}}' | jq '.trip.summary'
423+
```
424+
425+
This one must succeed and stay on that way. That is the difference between an
426+
edge-exact closure and the whole-way fallback: if it fails or detours, the span
427+
was applied whole-way, and the `traffic-live: span tracing` counters will show
428+
it as `negative`, `unanswered` or `skippedBudget` — unless the span was already
429+
traced in an earlier cycle, in which case it only shows in `cacheHits`.
430+
253431
## Audit log
254432

255433
Every state-changing admin action is written to a durable audit trail. It's the
@@ -308,3 +486,6 @@ written by a separate process and keep polling.
308486
log attributes actions to.
309487
- **[Backup and restore](./backup-and-restore.md)** — protecting the database the
310488
audit log and persisted logs live in.
489+
- **[Configuration](../install/configuration.md)**`OPENCONDITIONS_URL`,
490+
`TRAFFIC_LIVE_CRON`, `TRAFFIC_CONDITIONS_STALE_MS` and `TRAFFIC_VALHALLA_URL`,
491+
the knobs behind the live road-conditions cycle.

docs/docs/install/configuration.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,10 @@ deployments.
305305
| `MOTIS_TILES` | MOTIS internal vector tile rendering toggle. | Optional. Default unset |
306306
| `MOTIS_INCREMENTAL_RT_UPDATE` | Toggle incremental real-time transit schedule updates. | Optional. Default unset |
307307
| `VALHALLA_CONTAINER` | Docker container name for data-manager traffic extraction. | Default `docker-valhalla-1` |
308+
| `TRAFFIC_VALHALLA_URL` | Valhalla endpoint data-manager traces road-condition spans against, to narrow a closure to the exact graph edges it covers. Must be the co-deployed Valhalla holding this deployment's traffic graph — deliberately separate from `VALHALLA_URL`, which may point at a hosted endpoint. | Default `http://valhalla:8002` |
309+
| `OPENCONDITIONS_URL` | Base URL of the OpenConditions instance the data-manager reads live road conditions from (`/segments/speed.csv` and `/segments/conditions.json`). Leave unset to run without live traffic — the live and predicted traffic crons then do not start at all. | Optional. Default unset |
310+
| `TRAFFIC_LIVE_CRON` | Cron schedule for the live-traffic cycle that writes speeds, speed caps and closures into `traffic.tar`. Set to `disabled`, `off` or `false` to turn the cycle off. | Default `*/2 * * * *` |
311+
| `TRAFFIC_CONDITIONS_STALE_MS` | How long the last good conditions set is reused while `/segments/conditions.json` keeps failing. Past this age the closures are dropped and the router falls back to point-based exclusions. | Default `600000` (10 min) |
308312
| `TRUST_PROXY_RANGES` | IP ranges trusted by Fastify for reverse-proxy headers. | Default `uniquelocal` |
309313
| `OPENMAPX_API_NODE_OPTIONS` | Node.js memory options for the `app-api` container. | Default `--max-old-space-size=1536` |
310314

@@ -325,7 +329,7 @@ Keys and overrides for the `app-api` traffic and tile proxies.
325329
| `CYCLOSM_TILE_URL` | Override URL for the CyclOSM tile proxy. | Optional. Commented |
326330
| `WAYMARKED_CYCLING_TILE_URL` | Override URL for the Waymarked Trails cycling layer. | Optional. Commented |
327331
| `OPENTOPOMAP_TILE_URL` | Override URL for the OpenTopoMap layer. | Optional. Commented |
328-
| `TRAFFIC_EXTRACT_CRON` | Cron schedule for extracting Valhalla traffic CSVs. | Default `0 */6 * * *` |
332+
| `TRAFFIC_EXTRACT_CRON` | Cron schedule for extracting Valhalla traffic CSVs. | Default `0 5 * * *` (daily, 05:00 UTC) |
329333
| `NEXT_PUBLIC_TRAFFIC_MIN_ZOOM` | Minimum zoom level where traffic overlays render in the frontend. | Default `6` |
330334
| `INTEGRATION_STREET_LEVEL_IMAGERY_PROVIDER` | Preferred order for street-level imagery providers (`panoramax,mapillary`). | Optional. Default unset |
331335
| `OPENMAPTILES_FONTS_URL` | Custom source archive URL for downloading glyph font stacks. | Optional. Default upstream |

infra/docker/.env.example

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,20 @@ OVERPASS_FASTCGI_PROCESSES=4
350350
# Traffic + tile-proxy (apps/api)
351351
# Data-manager traffic extract schedule; defaults to 05:00 UTC daily.
352352
# TRAFFIC_EXTRACT_CRON=0 5 * * *
353+
# Live road conditions from an OpenConditions instance (`/segments/speed.csv`
354+
# and `/segments/conditions.json`). Leave unset to run without live traffic:
355+
# the live and predicted traffic crons then never start.
356+
# OPENCONDITIONS_URL=http://openconditions-ingest:8080
357+
# Live-traffic cycle that folds speeds, speed caps and closures into
358+
# `traffic.tar`. Set to `disabled` to turn the cycle off.
359+
# TRAFFIC_LIVE_CRON=*/2 * * * *
360+
# How long the last good conditions set is reused while the conditions endpoint
361+
# keeps failing. Past this age the closures are dropped.
362+
# TRAFFIC_CONDITIONS_STALE_MS=600000
363+
# Valhalla the data-manager traces condition spans against, to narrow a closure
364+
# to the exact graph edges it covers. Must be the co-deployed Valhalla holding
365+
# this deployment's traffic graph, so it is separate from `VALHALLA_URL`.
366+
# TRAFFIC_VALHALLA_URL=http://valhalla:8002
353367
# TomTom Flow tile proxy. Sign up at https://developer.tomtom.com/
354368
TRAFFIC_PROVIDER=tomtom
355369
TOMTOM_TRAFFIC_KEY=

0 commit comments

Comments
 (0)