1- # TMC location tables
2-
3- Some DATEX publishers never send coordinates. They say _ where_ by Alert-C
4- location code — an integer into a national TMC location table — and expect the
5- consumer to hold that table. Without it such records cannot be placed on a map,
6- so they were dropped.
7-
8- That was not a marginal loss. Two German states published nothing usable at all:
9-
10- | Source | Records per poll | Stored |
11- | ---------------------------------- | ---------------- | ------- |
12- | ` de-ni-mobilithek ` (Niedersachsen) | 1271 | ** 0** |
13- | ` de-hh-mobilithek ` (Hamburg) | 316 | ** 0** |
14- | ` de-he-mobilithek ` (Hessen) | 407 | partial |
15-
16- Both feeds looked healthy on the status page — they fetched successfully and
17- reported no errors — which is why the per-source skip counter came first. It
18- sized the problem; this resolves it.
19-
20- ## The table
1+ # Placing DATEX records that carry no coordinates
2+
3+ Several German publishers were storing nothing, or nothing usable, while their
4+ feeds reported success. The causes turned out to be four different things that
5+ all looked identical from outside: a record went in, no geometry came out.
6+
7+ | Source | Was lost per poll | Cause |
8+ | ---------------------------------- | ----------------- | ------------------------------------- |
9+ | ` de-ni-mobilithek ` (Niedersachsen) | 1271 → 0 stored | Alert-C codes, table edition 17.0 |
10+ | ` de-sh-mobilithek ` (Schleswig-H.) | 614 | position only in ` locationForDisplay ` |
11+ | ` de-th-mobilithek ` (Thüringen) | 539 | position only in a point extension |
12+ | ` de-he-mobilithek ` (Hessen) | 407 | position only in ` locationForDisplay ` |
13+ | ` de-hh-mobilithek ` (Hamburg) | 316 → 0 stored | ` posList ` inside an unnamed ` any ` |
14+ | ` de-mv-mobilithek ` (Meck.-Vorp.) | (worse) 62 stored | UTM grid stored as if it were degrees |
15+
16+ Only the first was a TMC problem. The rest were coordinates sitting in the
17+ payload that nothing was reading, which is why the per-source skip counter and
18+ its reasons came first: they turned one number into six diagnoses.
19+
20+ ## Reading coordinates by content, not by name
21+
22+ Three of these were the same mistake made three times — a coordinate reachable
23+ only through an element whose name we did not happen to check. Allow-listing
24+ names loses to the next publisher, so two content-based fallbacks run when a
25+ record yields no geometry by any named route:
26+
27+ 1 . ** A coordinate list under any element.** A whitespace-separated run of
28+ numbers that reads as a sequence of plausible WGS84 pairs is a coordinate
29+ list, whatever holds it. Hamburg wraps its ` posList ` in an XSD wildcard
30+ literally named ` any ` . Two valid pairs are required, so identifiers and
31+ measurements do not qualify.
32+ 2 . ** A lone display position.** An element carrying finite ` latitude ` and
33+ ` longitude ` leaves — ` locationForDisplay ` , point extensions. This is a label
34+ position rather than an extent, so it is tried last.
35+
36+ Neither can override real geometry: both run only when nothing better was found.
37+
38+ ## Coordinates that are not coordinates
39+
40+ Mecklenburg-Vorpommern publishes UTM zone 33 with the zone as an easting prefix
41+ (` 33342865 ` = zone 33, easting 342865) and declares no ` srsName ` , so its grid
42+ values were stored as degrees — a bounding box nowhere on Earth, with no error
43+ anywhere. A feed can now declare the CRS its payload omits (` srsName ` on the
44+ descriptor), and a coordinate outside the range degrees can occupy is refused
45+ rather than stored. An undeclared projection is invisible until the numbers
46+ land, where nothing can recover them; dropping the record puts it in the skip
47+ count instead.
48+
49+ ## The TMC location table
2150
2251Germany's Location Code List ** 22.0** is published by the Bundesanstalt für
2352Straßenwesen (BASt) under ** CC BY 4.0** , as a direct download in the ISO 14819-3
@@ -31,23 +60,18 @@ the vendored snapshot is not expected to go stale.
3160
3261Every event placed from the table carries ` attributes.locationTable ` , so the
3362attribution travels with the data and consumers can tell table-derived geometry
34- apart from a coordinate the publisher actually sent.
35-
36- Only tables whose licence permits redistribution are vendored. A feed
37- referencing a country we hold no table for resolves to ` no-table ` and its
38- records stay counted as unmapped — never placed by guesswork.
63+ apart from a coordinate the publisher actually sent. Only tables whose licence
64+ permits redistribution are vendored; a feed referencing a country we hold no
65+ table for resolves to ` no-table ` and stays counted as unmapped.
3966
40- ## Why the version must match exactly
67+ ## Why the edition matters, and how a record can override it
4168
42- Location codes are renumbered between table editions, so using the wrong edition
43- does not degrade gracefully: it returns a _ confident_ coordinate on the wrong
44- road. The resolver therefore refuses any record whose declared
45- ` alertCLocationTableVersion ` is not the table's own.
46-
47- This is not a theoretical concern. OpenStreetMap carries German TMC codes as
48- tags (` TMC:cid_58:tabcd_1:LocationCode ` ) and was the obvious redistributable
69+ Location codes are renumbered between editions, so using the wrong one does not
70+ degrade gracefully: it returns a _ confident_ coordinate on the wrong road.
71+ OpenStreetMap carries German TMC codes as tags
72+ (` TMC:cid_58:tabcd_1:LocationCode ` ) and was the obvious redistributable
4973candidate, but its data is largely LCL v8/v9 vintage from around 2010. Scored
50- against real records that carry both a code and coordinates:
74+ against real records carrying both a code and coordinates:
5175
5276| Table | Codes present | Median error |
5377| ------------------------------------ | ------------- | -------------------- |
@@ -56,43 +80,62 @@ against real records that carry both a code and coordinates:
5680| ** Published LCL 22.0** | ** ~ 100%** | ** 128 m, p90 710 m** |
5781
5882The middle row is the point: the _ mechanism_ was never wrong, the _ edition_ was.
59- A version guard is what separates those two outcomes.
6083
61- ## Accuracy, and what it means
84+ Niedersachsen references edition ** 17.0** , which is not obtainable. Refusing per
85+ edition would lose the publisher entirely, so the table carries each coded
86+ point's road number (96% of them) and a code from another edition is accepted
87+ only when ** every point it resolves lands on the road the record itself names** .
88+ Agreement is evidence the code still means the same place; disagreement is
89+ exactly the renumbering the guard exists for, and still refuses. Both ends of a
90+ linear location must agree, and a record naming no road — or a code the table
91+ knows no road for — is refused. Such placements are marked ` viaRoadMatch ` .
92+
93+ In production this places ~ 750 Niedersachsen records a poll, within the state's
94+ own bounding box, with 6 refused because the road did not vouch for them.
6295
63- TMC is a coarse referencing system by design. Consecutive coded points on the
64- same road are a median 2.5 km apart, so no resolver can place an event more
65- precisely than the table's own granularity.
96+ ## Accuracy, and what it means
6697
67- Against 1206 records carrying both a code and real coordinates, resolved
68- geometry lands a ** median 128 m ** from the record 's true extent, 94.4% within
69- 1 km. That is well inside the table's granularity, and it is why a linear
70- location is resolved as the _ stretch between _ its two coded points — following
71- the table's own point chain — rather than as a single point or a straight chord
72- between the endpoints .
98+ TMC is coarse by design: consecutive coded points on the same road are a median
99+ 2.5 km apart, so no resolver can beat the table 's own granularity. Against 1206
100+ records carrying both a code and real coordinates, resolved geometry lands a
101+ ** median 128 m ** from the record's true extent, 94.4% within 1 km. That is why a
102+ linear location resolves to the _ stretch between _ its two coded points, walking
103+ the table's own point chain, rather than to a single point or a straight chord .
73104
74- Re-check any time with:
105+ Re-check any time — it runs against the real resolver, so it measures shipped
106+ behaviour rather than a model of it:
75107
76108``` sh
77109pnpm tsx scripts/validate-tmc-table.ts ground-truth.csv
78110```
79111
80- The SQL that produces ` ground-truth.csv ` is documented at the top of that
81- script. It runs against the real resolver, so it measures shipped behaviour
82- rather than a model of it.
112+ The SQL that produces ` ground-truth.csv ` is documented at the top of that script.
113+
114+ ## Records that still cannot be placed
115+
116+ Counted per source in ` GET /feeds/status ` , with the reason logged:
83117
84- ## Unresolved records
118+ | Reason | Meaning |
119+ | ------------------ | ----------------------------------------------------------- |
120+ | ` no-alertc ` | Locates itself some other way; the shape logged says how |
121+ | ` version-mismatch ` | Names an edition we do not hold, and no road vouched for it |
122+ | ` version-missing ` | Names no edition, so there is nothing to check ours against |
123+ | ` no-table ` | No table held for that country — usually a licensing limit |
124+ | ` unknown-code ` | The referenced code is absent from the table |
125+ | ` no-reference ` | The Alert-C block is an empty placeholder (code ` 0 ` ) |
85126
86- Records that still cannot be placed are counted per source and surfaced in
87- ` GET /feeds/status ` , with the reason logged:
127+ A ` no-alertc ` record also logs how it describes its location — the location
128+ element's type, its children, and the leaf names beneath. A count says a
129+ publisher is being lost; the shape says what to build to stop losing it. Where
130+ even the leaves are opaque (` any ` inside ` any ` ), a short excerpt of the values is
131+ included, which is how Hamburg was diagnosed.
88132
89- | Reason | Meaning |
90- | ------------------ | ---------------------------------------------------------- |
91- | ` no-table ` | No table held for that country — usually a licensing limit |
92- | ` version-mismatch ` | The record references an edition we do not hold |
93- | ` unknown-code ` | The referenced code is absent from the table |
94- | ` no-reference ` | The Alert-C block is an empty placeholder (code ` 0 ` ) |
133+ Two known remainders:
95134
96- The last is common: Brandenburg emits an otherwise-empty Alert-C block on every
97- record. Treating code ` 0 ` as a location would pile every such record onto one
98- arbitrary point, so it is explicitly rejected.
135+ - ** Niedersachsen, ~ 510 a poll** — ` linearWithinLinearElement ` carrying a road
136+ number and a distance along it. That is linear referencing against the state
137+ road network (NWSIB-NI), not a location table, and needs that dataset.
138+ - ** Brandenburg** emits an otherwise-empty Alert-C block with code ` 0 ` on every
139+ record. Treating that as a location would pile every such record onto one
140+ arbitrary point, so it is explicitly rejected; those records carry real
141+ coordinates anyway.
0 commit comments