Skip to content

Commit f6015f9

Browse files
committed
feat(roads): extract NDW DATEX fields that were only in sourceRaw
Audited the live NDW situations feed (972 records, 97 distinct leaf paths) against the parser. Confirmed nothing is lost at ingest: sourceRaw keeps every leaf (36,719 values, no namespace-collision drops) and persists to JSONB. But several fields with a typed home stayed buried there, and the emitter read path dropped four typed columns. Promote to typed fields (DATEX parser): - subtype now falls back to the record's specific discriminator (accidentType, obstructionType, generalNetworkManagementType for movable-bridge openings, abnormalTrafficType, speedManagementType) instead of the generic record-class name; causeType still wins. - detourGeometry: the alternativeRoute diversion polyline (was dropped; 38 of 814 live records carry one). - schedule: validPeriod date windows (53 live records). - externalRefs.external: the provider location code (NDW RIS-index, the Dutch road-register reference; 28 live records). NDW carries no human roadName/roadNumber, so this is the closest it gives to a road identity. - causeDescription as a headline/description fallback. Fix a read-path loss in readObservations: schedule, confidence, is_forecast and related_ids are written to typed columns but were never selected back, so the public emitters (GeoJSON/TraFF/JSON-LD) silently dropped them. Now read and reconstructed. Adds MultiLineStringGeometry to @openconditions/core. Verified against the live feed; a testcontainer round-trip covers the new fields.
1 parent 33c4385 commit f6015f9

6 files changed

Lines changed: 218 additions & 12 deletions

File tree

packages/core/src/model.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import type { Geometry, LineString, Point } from "geojson";
1+
import type { Geometry, LineString, MultiLineString, Point } from "geojson";
22

33
export type GeoJsonGeometry = Geometry;
44
export type LineStringGeometry = LineString;
5+
export type MultiLineStringGeometry = MultiLineString;
56
export type PointGeometry = Point;
67

78
export interface Attribution {

packages/core/src/readObservations.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ interface Row {
3232
data_updated_at: string | Date;
3333
fetched_at: string | Date;
3434
expires_at: string | Date | null;
35+
schedule: Observation["schedule"] | null;
36+
confidence: string | null;
37+
is_forecast: boolean | null;
38+
related_ids: string[] | null;
3539
attributes: Record<string, unknown> | null;
3640
subject: Observation["subject"] | null;
3741
origin: Provenance;
@@ -63,6 +67,10 @@ function rowToObservation(row: Row): Observation {
6367
origin: row.origin,
6468
...(row.subject ? { subject: row.subject } : {}),
6569
...(row.label != null ? { label: row.label } : {}),
70+
...(row.schedule ? { schedule: row.schedule } : {}),
71+
...(row.confidence != null ? { confidence: row.confidence as Observation["confidence"] } : {}),
72+
...(row.is_forecast != null ? { isForecast: row.is_forecast } : {}),
73+
...(row.related_ids ? { relatedIds: row.related_ids } : {}),
6674
};
6775
const specific =
6876
row.kind === "measurement"
@@ -123,6 +131,7 @@ export async function readObservations(
123131
severity, severity_source, headline, description, label,
124132
metric, value, level, unit, aggregation,
125133
status, valid_from, valid_to, data_updated_at, fetched_at, expires_at,
134+
schedule, confidence, is_forecast, related_ids,
126135
attributes, subject, origin,
127136
ST_AsGeoJSON(geom) AS geojson,
128137
(stale_after IS NOT NULL AND stale_after < now()) AS is_stale

packages/roads/src/__tests__/datex.test.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,3 +369,83 @@ describe("parseDatexSituations — OpenLR unresolved markers", () => {
369369
expect(events[0]!.externalRefs?.openlr).toBeUndefined();
370370
});
371371
});
372+
373+
describe("parseDatexSituations — full v3 field extraction", () => {
374+
it("keeps causeType as the subtype when a cause is present", () => {
375+
const xml = v3Record(
376+
`<cause><causeType>roadMaintenance</causeType></cause><accidentType>multiVehicleAccident</accidentType>${POINT_LOC}`
377+
);
378+
expect(parseDatexSituations(xml, NDW_SOURCE)[0]!.subtype).toBe("roadMaintenance");
379+
});
380+
381+
it("falls back to the record's specific sub-type when there is no cause", () => {
382+
const cases: [string, string][] = [
383+
["generalNetworkManagementType", "bridgeSwingInOperation"],
384+
["abnormalTrafficType", "slowTraffic"],
385+
["accidentType", "multiVehicleAccident"],
386+
["obstructionType", "objectOnTheRoad"],
387+
["speedManagementType", "speedRestrictionInOperation"],
388+
];
389+
for (const [el, val] of cases) {
390+
const xml = v3Record(`<${el}>${val}</${el}>${POINT_LOC}`);
391+
expect(parseDatexSituations(xml, NDW_SOURCE)[0]!.subtype).toBe(val);
392+
}
393+
});
394+
395+
it("extracts the alternativeRoute diversion polyline as detourGeometry", () => {
396+
const xml = v3Record(
397+
`<reroutingItineraryDescription>Follow signs</reroutingItineraryDescription>` +
398+
`<alternativeRoute xsi:type="ItineraryByIndexedLocations"><locationContainedInItinerary index="0">` +
399+
`<location xsi:type="LinearLocation"><gmlLineString srsName="WGS 84"><posList>52.0 13.0 52.1 13.1</posList></gmlLineString></location>` +
400+
`</locationContainedInItinerary></alternativeRoute>${POINT_LOC}`
401+
);
402+
const [ev] = parseDatexSituations(xml, NDW_SOURCE);
403+
expect(ev!.detour).toBe("Follow signs");
404+
expect(ev!.detourGeometry).toEqual({
405+
type: "LineString",
406+
coordinates: [
407+
[13.0, 52.0],
408+
[13.1, 52.1],
409+
],
410+
});
411+
});
412+
413+
it("extracts validPeriod windows into schedule, keeping overall start/end", () => {
414+
const xml = `<?xml version="1.0" encoding="UTF-8"?>
415+
<messageContainer xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" modelBaseVersion="3"><payload xsi:type="SituationPublication"><situation id="S"><situationRecord xsi:type="RoadOrCarriagewayOrLaneManagement" id="R" version="1">
416+
<situationRecordVersionTime>2024-01-01T00:00:00Z</situationRecordVersionTime>
417+
<validity><validityStatus>definedByValidityTimeSpec</validityStatus><validityTimeSpecification>
418+
<overallStartTime>2026-06-01T00:00:00Z</overallStartTime><overallEndTime>2026-07-01T00:00:00Z</overallEndTime>
419+
<validPeriod><startOfPeriod>2026-06-10T06:00:00Z</startOfPeriod><endOfPeriod>2026-06-10T18:00:00Z</endOfPeriod></validPeriod>
420+
</validityTimeSpecification></validity>
421+
${POINT_LOC}</situationRecord></situation></payload></messageContainer>`;
422+
const [ev] = parseDatexSituations(xml, NDW_SOURCE);
423+
expect(ev!.validFrom).toBe("2026-06-01T00:00:00Z");
424+
expect(ev!.validTo).toBe("2026-07-01T00:00:00Z");
425+
expect(ev!.schedule).toEqual([
426+
{ dateStart: "2026-06-10T06:00:00Z", dateEnd: "2026-06-10T18:00:00Z" },
427+
]);
428+
});
429+
430+
it("uses causeDescription as a headline fallback when there is no public comment", () => {
431+
const xml = v3Record(
432+
`<cause><causeDescription><values><value lang="en">Roadworks ahead</value></values></causeDescription></cause>${POINT_LOC}`
433+
);
434+
expect(parseDatexSituations(xml, NDW_SOURCE)[0]!.headline).toBe("Roadworks ahead");
435+
});
436+
437+
it("extracts the external location reference (NDW RIS-index)", () => {
438+
const xml = v3Record(
439+
`<locationReference xsi:type="PointLocation">` +
440+
`<externalReferencing><externalReferencingSystem>RIS-index</externalReferencingSystem>` +
441+
`<externalLocationCode>NLUTC0226A0578000005</externalLocationCode></externalReferencing>` +
442+
`<pointByCoordinates><pointCoordinates><latitude>52</latitude><longitude>13</longitude></pointCoordinates></pointByCoordinates>` +
443+
`</locationReference>`
444+
);
445+
const [ev] = parseDatexSituations(xml, NDW_SOURCE);
446+
expect(ev!.externalRefs?.external).toEqual({
447+
system: "RIS-index",
448+
code: "NLUTC0226A0578000005",
449+
});
450+
});
451+
});

packages/roads/src/datex.ts

Lines changed: 92 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { normaliseSeverity } from "@openconditions/core";
2-
import type { Confidence } from "@openconditions/core";
2+
import type { Confidence, RecurringWindow } from "@openconditions/core";
33
import type { Geometry } from "geojson";
44
import type { Restriction, RoadEvent, UnresolvedRoadEvent } from "./model.js";
55
import { dedupeRoadEvents } from "./dedupe.js";
@@ -115,7 +115,14 @@ function parseLatLonList(raw: string | undefined): [number, number][] {
115115
* no coordinate geometry (Alert-C/TMC only) return null (decoded in Phase 2).
116116
*/
117117
function resolveGeometry(rec: XmlObject): Geometry | null {
118-
const locRef = getXmlChild(rec, "locationReference") ?? getXmlChild(rec, "groupOfLocations");
118+
return resolveGeometryFrom(
119+
getXmlChild(rec, "locationReference") ?? getXmlChild(rec, "groupOfLocations")
120+
);
121+
}
122+
123+
/** Walk a location subtree (locationReference, groupOfLocations, alternativeRoute,
124+
* …) for any coordinate-bearing element and assemble a GeoJSON geometry. */
125+
function resolveGeometryFrom(locRef: XmlObject | undefined): Geometry | null {
119126
if (!locRef) return null;
120127

121128
const lines: [number, number][][] = [];
@@ -164,6 +171,38 @@ function resolveGeometry(rec: XmlObject): Geometry | null {
164171
return null;
165172
}
166173

174+
/** The diversion route geometry from an `alternativeRoute`, when it is linear. */
175+
function detourGeometryOf(rec: XmlObject): RoadEvent["detourGeometry"] {
176+
const g = resolveGeometryFrom(getXmlChild(rec, "alternativeRoute"));
177+
return g && (g.type === "LineString" || g.type === "MultiLineString") ? g : undefined;
178+
}
179+
180+
/**
181+
* validPeriod windows → schedule: each bounded date range (startOfPeriod /
182+
* endOfPeriod) plus the daily time window (recurringTimePeriodOfDay) when the
183+
* source supplies one. The overall start/end stay on validFrom/validTo.
184+
*/
185+
function scheduleOf(timeSpec: XmlObject | undefined): RecurringWindow[] | undefined {
186+
if (!timeSpec) return undefined;
187+
const out: RecurringWindow[] = [];
188+
for (const vp of getXmlChildren(timeSpec, "validPeriod")) {
189+
const win: RecurringWindow = {};
190+
const ds = getXmlChildText(vp, "startOfPeriod");
191+
const de = getXmlChildText(vp, "endOfPeriod");
192+
if (ds) win.dateStart = ds;
193+
if (de) win.dateEnd = de;
194+
const tod = getXmlChild(vp, "recurringTimePeriodOfDay");
195+
if (tod) {
196+
const ts = getXmlChildText(tod, "startTimeOfPeriod");
197+
const te = getXmlChildText(tod, "endTimeOfPeriod");
198+
if (ts) win.timeStart = ts;
199+
if (te) win.timeEnd = te;
200+
}
201+
if (Object.keys(win).length > 0) out.push(win);
202+
}
203+
return out.length > 0 ? out : undefined;
204+
}
205+
167206
function directionOf(rec: XmlObject): string | undefined {
168207
const locRef = getXmlChild(rec, "locationReference");
169208
if (!locRef) return undefined;
@@ -234,14 +273,33 @@ function lanesOf(rec: XmlObject): RoadEvent["lanesAffected"] | undefined {
234273
return lanes.closed != null || lanes.total != null ? lanes : undefined;
235274
}
236275

237-
/** Source cause/obstruction subtype (e.g. "roadMaintenance", "brokenDownVehicle"). */
238-
function causeOf(rec: XmlObject): string | undefined {
276+
/**
277+
* The most specific source sub-classification for the record. The cause's
278+
* causeType wins when present (e.g. "roadMaintenance"); otherwise the record's
279+
* own typed discriminator is used — each DATEX situationRecord subclass carries
280+
* its own (accidentType, obstructionType, generalNetworkManagementType for
281+
* movable-bridge openings, abnormalTrafficType for congestion detail,
282+
* speedManagementType, …). Without this, those records fell back to the generic
283+
* record class name and lost their specific kind.
284+
*/
285+
function subtypeOf(rec: XmlObject): string | undefined {
239286
return (
240287
getXmlChildText(getXmlChild(rec, "cause"), "causeType") ??
241-
getXmlChildText(rec, "vehicleObstructionType")
288+
getXmlChildText(rec, "accidentType") ??
289+
getXmlChildText(rec, "obstructionType") ??
290+
getXmlChildText(rec, "environmentalObstructionType") ??
291+
getXmlChildText(rec, "vehicleObstructionType") ??
292+
getXmlChildText(rec, "generalNetworkManagementType") ??
293+
getXmlChildText(rec, "abnormalTrafficType") ??
294+
getXmlChildText(rec, "speedManagementType")
242295
);
243296
}
244297

298+
/** Human cause text (DATEX `cause/causeDescription`), a multilingual block. */
299+
function causeDescriptionOf(rec: XmlObject): string | undefined {
300+
return multilingual(getXmlChild(getXmlChild(rec, "cause"), "causeDescription"), "en");
301+
}
302+
245303
function speedLimitOf(rec: XmlObject): number | undefined {
246304
const raw = getXmlChildText(rec, "temporarySpeedLimit");
247305
if (raw == null) return undefined;
@@ -359,7 +417,8 @@ function collectOpenLr(rec: XmlObject): string | undefined {
359417
return getXmlChildText(rec, "openlrBinary") ?? undefined;
360418
}
361419

362-
function collectRefs(rec: XmlObject): RoadEvent["externalRefs"] {
420+
/** Alert-C/TMC reference (country + table + primary specific-location code). */
421+
function tmcOf(rec: XmlObject): NonNullable<RoadEvent["externalRefs"]>["tmc"] | undefined {
363422
const locRef = getXmlChild(rec, "locationReference");
364423
if (!locRef) return undefined;
365424

@@ -374,11 +433,26 @@ function collectRefs(rec: XmlObject): RoadEvent["externalRefs"] {
374433
const table = getXmlChildText(alertC, "alertCLocationTableNumber");
375434
const code = getXmlChildText(getXmlChild(primary, "alertCLocation"), "specificLocation");
376435
if (country && table && code) {
377-
return { tmc: { country, table: parseFloat(table), code: parseInt(code, 10) } };
436+
return { country, table: parseFloat(table), code: parseInt(code, 10) };
378437
}
379438
return undefined;
380439
}
381440

441+
/** Provider external location code (NDW's `externalReferencing`, e.g. RIS-index). */
442+
function externalLocationOf(rec: XmlObject): { system: string; code: string } | undefined {
443+
const er = getXmlChild(getXmlChild(rec, "locationReference"), "externalReferencing");
444+
const system = getXmlChildText(er, "externalReferencingSystem");
445+
const code = getXmlChildText(er, "externalLocationCode");
446+
return system && code ? { system, code } : undefined;
447+
}
448+
449+
function externalRefsOf(rec: XmlObject): RoadEvent["externalRefs"] {
450+
const tmc = tmcOf(rec);
451+
const external = externalLocationOf(rec);
452+
if (!tmc && !external) return undefined;
453+
return { ...(tmc ? { tmc } : {}), ...(external ? { external } : {}) };
454+
}
455+
382456
interface SituationRecord {
383457
rec: XmlObject;
384458
situationSeverity: string;
@@ -506,6 +580,7 @@ export function parseDatexSituations(
506580

507581
const publicComment = getXmlChild(rec, "generalPublicComment");
508582
const fallbackComment = getXmlChild(rec, "comment");
583+
const causeDesc = causeDescriptionOf(rec);
509584

510585
const shared = {
511586
id: `${src.id}:${recId(rec)}`,
@@ -514,7 +589,7 @@ export function parseDatexSituations(
514589
domain: "roads" as const,
515590
kind: "event" as const,
516591
type,
517-
subtype: causeOf(rec) ?? recType ?? undefined,
592+
subtype: subtypeOf(rec) ?? recType ?? undefined,
518593
category,
519594
isPlanned,
520595
...severityFields,
@@ -528,16 +603,22 @@ export function parseDatexSituations(
528603
restrictions: dimensionRestrictionsOf(rec),
529604
vehiclesAffected: vehiclesAffectedOf(rec),
530605
detour: detourOf(rec),
606+
detourGeometry: detourGeometryOf(rec),
531607
delaySeconds: leafNumber(rec, "delayTimeValue"),
532608
queueLengthMeters: leafNumber(rec, "queueLength"),
533609
relatedIds: relatedRefsOf(rec),
534610
sourceRaw: rec,
535611
headline:
536612
multilingual(publicComment, "en") ??
537613
multilingual(fallbackComment, "en") ??
614+
causeDesc ??
538615
defaultHeadline(type),
539616
description:
540-
multilingual(publicComment, "en") ?? multilingual(fallbackComment, "en") ?? undefined,
617+
multilingual(publicComment, "en") ??
618+
multilingual(fallbackComment, "en") ??
619+
causeDesc ??
620+
undefined,
621+
schedule: scheduleOf(timeSpec),
541622
validFrom: text(timeSpec?.["overallStartTime"]) ?? null,
542623
validTo: text(timeSpec?.["overallEndTime"]) ?? null,
543624
origin: {
@@ -557,14 +638,14 @@ export function parseDatexSituations(
557638
withGeom.push({
558639
...shared,
559640
geometry,
560-
externalRefs: collectRefs(rec),
641+
externalRefs: externalRefsOf(rec),
561642
});
562643
} else {
563644
// openlr is defined here because we checked !geometry && !openlr above.
564645
unresolved.push({
565646
...shared,
566647
geometry: undefined,
567-
externalRefs: { ...collectRefs(rec), openlr: openlr! },
648+
externalRefs: { ...externalRefsOf(rec), openlr: openlr! },
568649
});
569650
}
570651
}

packages/roads/src/model.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type {
22
ConditionEvent,
33
LineStringGeometry,
44
Measurement,
5+
MultiLineStringGeometry,
56
PointGeometry,
67
} from "@openconditions/core";
78

@@ -87,6 +88,9 @@ export interface RoadEvent extends ConditionEvent {
8788
restrictions?: Restriction[];
8889
vehiclesAffected?: string[];
8990
detour?: string;
91+
/** Diversion/alternative-route geometry, when the source provides one
92+
* (DATEX `alternativeRoute`); the `detour` string is its prose counterpart. */
93+
detourGeometry?: LineStringGeometry | MultiLineStringGeometry;
9094
/** Quantified impact, when the source gives it. */
9195
delaySeconds?: number;
9296
queueLengthMeters?: number;
@@ -105,6 +109,10 @@ export interface RoadEvent extends ConditionEvent {
105109
direction?: number;
106110
extent?: number;
107111
};
112+
/** A provider-specific external location code (e.g. NDW's RIS-index, the
113+
* Dutch road-register reference) — the closest thing some feeds give to a
114+
* road identity, decodable only against that provider's network dataset. */
115+
external?: { system: string; code: string };
108116
linear?: unknown;
109117
};
110118
/** The original provider record, verbatim — a lossless passthrough so no
@@ -161,6 +169,7 @@ export function roadAttributes(ev: RoadEvent): Record<string, unknown> {
161169
attrs["vehiclesAffected"] = ev.vehiclesAffected;
162170
}
163171
if (ev.detour != null) attrs["detour"] = ev.detour;
172+
if (ev.detourGeometry != null) attrs["detourGeometry"] = ev.detourGeometry;
164173
if (ev.delaySeconds != null) attrs["delaySeconds"] = ev.delaySeconds;
165174
if (ev.queueLengthMeters != null) attrs["queueLengthMeters"] = ev.queueLengthMeters;
166175
if (ev.workersPresent != null) attrs["workersPresent"] = ev.workersPresent;

services/ingest/src/__tests__/pipeline.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,18 @@ describe("store round-trip — typed columns + attributes JSONB", () => {
212212
workZoneType: "moving",
213213
speedLimitKph: 50,
214214
regions: ["Berlin"],
215+
detourGeometry: {
216+
type: "LineString",
217+
coordinates: [
218+
[13.4, 52.5],
219+
[13.42, 52.51],
220+
],
221+
},
222+
schedule: [{ dateStart: "2026-06-10T06:00:00Z", dateEnd: "2026-06-10T18:00:00Z" }],
223+
externalRefs: { external: { system: "RIS-index", code: "NL123" } },
224+
confidence: "likely",
225+
isForecast: true,
226+
relatedIds: ["parent-1", "parent-2"],
215227
sourceRaw: { provider_field: "verbatim" },
216228
origin: { kind: "feed", attribution: { provider: "X", license: "CC0-1.0" } },
217229
dataUpdatedAt: "2026-06-23T10:00:00Z",
@@ -234,6 +246,20 @@ describe("store round-trip — typed columns + attributes JSONB", () => {
234246
expect(got!.workZoneType).toBe("moving");
235247
expect(got!.speedLimitKph).toBe(50);
236248
expect(got!.regions).toEqual(["Berlin"]);
249+
expect(got!.detourGeometry).toEqual({
250+
type: "LineString",
251+
coordinates: [
252+
[13.4, 52.5],
253+
[13.42, 52.51],
254+
],
255+
});
256+
expect(got!.schedule).toEqual([
257+
{ dateStart: "2026-06-10T06:00:00Z", dateEnd: "2026-06-10T18:00:00Z" },
258+
]);
259+
expect(got!.externalRefs?.external).toEqual({ system: "RIS-index", code: "NL123" });
260+
expect(got!.confidence).toBe("likely"); // typed column, was dropped on read
261+
expect(got!.isForecast).toBe(true);
262+
expect(got!.relatedIds).toEqual(["parent-1", "parent-2"]);
237263
expect(got!.source).toBe("rt"); // feed id NOT clobbered by sourceRaw
238264
expect(got!.sourceRaw).toEqual({ provider_field: "verbatim" }); // verbatim passthrough survives
239265
}, 30_000);

0 commit comments

Comments
 (0)