Skip to content

Commit 2771ff7

Browse files
committed
feat(roads): add Turin 5T and Hong Kong TD real-time speed feeds
Both were wrongly written off as egress-blocked; on re-investigation both work: - turin-5t-flow (turin-fdt-xml): 5T FDT feed, self-contained inline WGS84 points + speedflow speed; the earlier 403 was HTTP-only (HTTPS serves fine). Keyless, CC-BY-4.0. Enabled. ~99 flows live. - hk-td-flow (hk-raw-xml + hk-detector-csv registry): TD raw detector speed/volume joined to the detector-locations CSV (WGS84) by detector_id; representative speed = volume-weighted mean of valid lanes. The blocked segment-geometry file was the wrong path — the detector-level data resolves cleanly. Keyless, new HK-Gov-Open-Data license. Enabled. ~725 flows live. Adds turin-fdt-xml + hk-raw-xml formats, hk-detector-csv station registry, and the HK-Gov-Open-Data license.
1 parent 3416375 commit 2771ff7

13 files changed

Lines changed: 386 additions & 3 deletions

File tree

packages/core/src/model.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ export type SourceFormat =
4545
| "madrid-informo-xml"
4646
| "lta-speedbands-json"
4747
| "miv-xml"
48+
| "turin-fdt-xml"
49+
| "hk-raw-xml"
4850
| "gtfs-rt"
4951
| "native"
5052
| "crowd";

packages/ingest-framework/src/licenses.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,14 @@ export const LICENSES: Record<string, LicenseInfo> = Object.fromEntries([
138138
shareAlike: false,
139139
commercialOk: true,
140140
}),
141+
L({
142+
id: "HK-Gov-Open-Data",
143+
name: "DATA.GOV.HK Terms and Conditions of Use",
144+
url: "https://data.gov.hk/en/terms-and-conditions",
145+
attributionRequired: true,
146+
shareAlike: false,
147+
commercialOk: true,
148+
}),
141149
L({
142150
id: "NYC-Open-Data",
143151
name: "NYC Open Data Terms of Use",
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
[
2+
{
3+
id: "hk-td-flow",
4+
name: "Transport Department traffic detectors (Hong Kong)",
5+
format: "hk-raw-xml",
6+
produces: "flow",
7+
url: "https://resource.data.one.gov.hk/td/traffic-detectors/rawSpeedVol-all.xml",
8+
stationRegistry: {
9+
url: "https://static.data.gov.hk/td/traffic-data-strategic-major-roads/info/traffic_speed_volume_occ_info.csv",
10+
format: "hk-detector-csv",
11+
},
12+
cadenceSec: 120,
13+
freshnessWindowSec: 600,
14+
license: "HK-Gov-Open-Data",
15+
licenseUrl: "https://data.gov.hk/en/terms-and-conditions",
16+
attribution: "Transport Department, HKSAR",
17+
country: "HK",
18+
privacyUrl: "https://www.td.gov.hk/en/privacy_policy/index.html",
19+
enabledByDefault: true,
20+
},
21+
]
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
[
2+
{
3+
id: "turin-5t-flow",
4+
name: "5T real-time traffic flow (Turin)",
5+
format: "turin-fdt-xml",
6+
produces: "flow",
7+
url: "https://opendata.5t.torino.it/get_fdt",
8+
cadenceSec: 300,
9+
freshnessWindowSec: 900,
10+
license: "CC-BY-4.0",
11+
licenseUrl: "https://creativecommons.org/licenses/by/4.0/",
12+
attribution: "5T / Città di Torino",
13+
country: "IT",
14+
privacyUrl: "https://www.5t.torino.it/privacy/",
15+
enabledByDefault: true,
16+
},
17+
]

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -432,8 +432,8 @@ describe("FEED_SOURCES", () => {
432432
expect(new Set(ids).size).toBe(ids.length);
433433
});
434434

435-
it("loads every feed from the data files (all 60 migrated)", () => {
436-
expect(FEED_SOURCES.length).toBe(60);
435+
it("loads every feed from the data files (all 62 migrated)", () => {
436+
expect(FEED_SOURCES.length).toBe(62);
437437
expect(new Set(FEED_SOURCES.map((f) => f.id)).size).toBe(FEED_SOURCES.length);
438438
});
439439

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { describe, expect, it } from "vitest";
2+
import { parseTurinFlow } from "../flow-turin.js";
3+
import type { SourceDescriptor } from "../types.js";
4+
5+
const src = {
6+
id: "turin-5t-flow",
7+
attribution: "5T / Città di Torino",
8+
country: "IT",
9+
license: "CC-BY-4.0",
10+
} as SourceDescriptor;
11+
12+
const XML = `<?xml version="1.0" encoding="utf-8"?>
13+
<traffic_data xmlns="https://simone.5t.torino.it/ns/traffic_data.xsd" generation_time="2026-07-10T18:00:03.516Z">
14+
<FDT_data lcd1="39983" Road_name="Corso Allamano(TO)" direction="positive" lat="45.0507" lng="7.6225" accuracy="95" period="5"><speedflow flow="360" speed="54.5"/></FDT_data>
15+
<FDT_data lcd1="40121" Road_name="Corso Regina Margherita(TO)" direction="positive" lat="45.096231" lng="7.625643" accuracy="0" period="5"><speedflow flow="0" speed="0"/></FDT_data>
16+
</traffic_data>`;
17+
18+
describe("parseTurinFlow", () => {
19+
it("emits an inline-Point flow with km/h speed and drops accuracy=0 detectors", () => {
20+
const { flows } = parseTurinFlow(XML, src);
21+
expect(flows).toHaveLength(1);
22+
expect(flows[0]!.id).toBe("turin-5t-flow:39983");
23+
expect(flows[0]!.sourceFormat).toBe("turin-fdt-xml");
24+
expect(flows[0]!.speedKph).toBe(54.5);
25+
expect(flows[0]!.direction).toBe("positive");
26+
expect(flows[0]!.geometry).toEqual({ type: "Point", coordinates: [7.6225, 45.0507] });
27+
expect(flows[0]!.dataUpdatedAt).toBe("2026-07-10T18:00:03.516Z");
28+
});
29+
30+
it("flags a hard parse failure", () => {
31+
expect(parseTurinFlow("not xml <", src).failed).toBe(true);
32+
});
33+
});
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { describe, expect, it } from "vitest";
2+
import { parseHkDetectors, parseHkRawFlow } from "../hk.js";
3+
import type { SourceDescriptor } from "../types.js";
4+
5+
const src = {
6+
id: "hk-td-flow",
7+
attribution: "Transport Department, HKSAR",
8+
country: "HK",
9+
license: "HK-Gov-Open-Data",
10+
} as SourceDescriptor;
11+
12+
// Leading  mirrors the live file's UTF-8 BOM.
13+
const CSV = `AID_ID_Number,District,Road_EN,Road_TC,Road_SC,Easting,Northing,Latitude,Longitude,Direction,Rotation
14+
AID01101,Southern,Aberdeen Praya Road,x,x,833758,812147,22.248091,114.152525,South East,100
15+
BADLAT,Southern,Road,x,x,0,0,0,0,North,0
16+
`;
17+
18+
const XML = `<?xml version="1.0" encoding="utf-8"?>
19+
<raw_speed_volume_list><periods>
20+
<period><period_from>01:50:00</period_from><period_to>01:50:30</period_to><detectors>
21+
<detector><detector_id>AID01101</detector_id><lanes>
22+
<lane><lane_id>Fast Lane</lane_id><speed>60</speed><volume>1</volume><valid>Y</valid></lane>
23+
</lanes></detector>
24+
</detectors></period>
25+
<period><period_from>01:55:00</period_from><period_to>01:55:30</period_to><detectors>
26+
<detector><detector_id>AID01101</detector_id><lanes>
27+
<lane><lane_id>Fast Lane</lane_id><speed>100</speed><volume>3</volume><valid>Y</valid></lane>
28+
<lane><lane_id>Slow Lane</lane_id><speed>60</speed><volume>1</volume><valid>Y</valid></lane>
29+
<lane><lane_id>Broken</lane_id><speed>0</speed><volume>0</volume><valid>N</valid></lane>
30+
</lanes></detector>
31+
</detectors></period>
32+
</periods></raw_speed_volume_list>`;
33+
34+
describe("parseHkDetectors", () => {
35+
it("maps AID id → WGS84 Point, strips the BOM, and drops out-of-bounds rows", () => {
36+
const map = parseHkDetectors(CSV);
37+
expect(map.size).toBe(1);
38+
expect(map.get("AID01101")).toEqual({ type: "Point", coordinates: [114.152525, 22.248091] });
39+
});
40+
});
41+
42+
describe("parseHkRawFlow", () => {
43+
it("uses the latest period and the volume-weighted mean of valid lanes", () => {
44+
const siteMap = parseHkDetectors(CSV);
45+
const { flows } = parseHkRawFlow(XML, src, siteMap);
46+
expect(flows).toHaveLength(1);
47+
expect(flows[0]!.id).toBe("hk-td-flow:AID01101");
48+
// Latest period: (100·3 + 60·1) / (3+1) = 90; the valid=N lane is ignored.
49+
expect(flows[0]!.speedKph).toBe(90);
50+
expect(flows[0]!.geometry).toEqual({ type: "Point", coordinates: [114.152525, 22.248091] });
51+
expect(flows[0]!.dataUpdatedAt).toBe("01:55:30");
52+
});
53+
54+
it("skips detectors with no geometry", () => {
55+
expect(parseHkRawFlow(XML, src, new Map()).flows).toHaveLength(0);
56+
});
57+
58+
it("flags a hard parse failure", () => {
59+
expect(parseHkRawFlow("nope <", src, new Map()).failed).toBe(true);
60+
});
61+
});

packages/roads/src/feed-schema.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ export const roadFeedSchema = z
4444
"webtris-sites",
4545
"miv-config",
4646
"france-comptage-csv",
47+
"hk-detector-csv",
4748
]),
4849
})
4950
.strict()

packages/roads/src/feeds.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ import { parseBonnFlow } from "./flow-bonn.js";
2828
import { parseMadridFlow } from "./flow-madrid.js";
2929
import { parseLtaSpeedBands } from "./flow-lta-speedbands.js";
3030
import { parseMivFlow } from "./miv.js";
31+
import { parseTurinFlow } from "./flow-turin.js";
32+
import { parseHkRawFlow } from "./hk.js";
3133
import type { SourceDescriptor } from "./types.js";
3234

3335
// FeedAuth now lives in @openconditions/ingest-framework; re-exported here so
@@ -63,7 +65,12 @@ export type FeedSource = FeedSourceBase & {
6365
*/
6466
stationRegistry?: {
6567
url: string;
66-
format: "fintraffic-stations" | "webtris-sites" | "miv-config" | "france-comptage-csv";
68+
format:
69+
| "fintraffic-stations"
70+
| "webtris-sites"
71+
| "miv-config"
72+
| "france-comptage-csv"
73+
| "hk-detector-csv";
6774
};
6875
/** Field mapping for `format: "geojson"` feeds (passed to the generic reader). */
6976
geojson?: GeoJsonMapping;
@@ -157,6 +164,8 @@ export function flowParserFor(format: SourceFormat): FlowParserFn {
157164
if (format === "madrid-informo-xml") return parseMadridFlow;
158165
if (format === "lta-speedbands-json") return parseLtaSpeedBands;
159166
if (format === "miv-xml") return parseMivFlow;
167+
if (format === "turin-fdt-xml") return parseTurinFlow;
168+
if (format === "hk-raw-xml") return parseHkRawFlow;
160169
throw new Error(`No flow parser registered for format: ${format}`);
161170
}
162171

packages/roads/src/flow-turin.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import type { Point } from "geojson";
2+
import type { RoadFlow } from "./model.js";
3+
import type { SourceDescriptor } from "./types.js";
4+
import { makeOrigin } from "./flow.js";
5+
import type { FlowParseResult } from "./flow.js";
6+
import { getXmlChild, getXmlChildren, isXmlObject, parseXmlDocument, xmlText } from "./xml.js";
7+
8+
const ABSURD_SPEED_KPH = 250;
9+
10+
function num(raw: unknown): number | undefined {
11+
if (raw == null || raw === "") return undefined;
12+
const n = Number(raw);
13+
return Number.isFinite(n) ? n : undefined;
14+
}
15+
16+
/**
17+
* Parse the Turin 5T real-time traffic-flow feed (`opendata.5t.torino.it/get_fdt`)
18+
* into RoadFlow point measurements. Each `<FDT_data>` is a detector carrying
19+
* inline WGS84 `lat`/`lng`, an `accuracy` confidence, and a child
20+
* `<speedflow speed=.. flow=..>` (speed in km/h). Detectors with no confidence
21+
* (`accuracy=0`, published with a placeholder `speed=0`) or no coordinate are
22+
* skipped. los is left "unknown" for baseline enrichment.
23+
*/
24+
export function parseTurinFlow(input: string | Buffer, src: SourceDescriptor): FlowParseResult {
25+
let doc: ReturnType<typeof parseXmlDocument>;
26+
try {
27+
doc = parseXmlDocument(input, {
28+
removeNSPrefix: true,
29+
ignoreAttributes: false,
30+
isArray: (n) => n === "FDT_data",
31+
});
32+
} catch {
33+
return { flows: [], events: [], failed: true };
34+
}
35+
const root = isXmlObject(doc) ? (getXmlChild(doc, "traffic_data") ?? doc) : null;
36+
if (!root) return { flows: [], events: [], failed: true };
37+
38+
const detectors = getXmlChildren(root, "FDT_data");
39+
const genTime = xmlText(root["@_generation_time"]);
40+
const now = new Date().toISOString();
41+
const origin = makeOrigin(src);
42+
const flows: RoadFlow[] = [];
43+
44+
for (const fdt of detectors) {
45+
try {
46+
const id = xmlText(fdt["@_lcd1"]);
47+
if (!id) continue;
48+
const accuracy = num(xmlText(fdt["@_accuracy"]));
49+
if (accuracy == null || accuracy <= 0) continue; // no confident measurement this cycle
50+
const lon = num(xmlText(fdt["@_lng"]));
51+
const lat = num(xmlText(fdt["@_lat"]));
52+
if (lon == null || lat == null) continue;
53+
54+
const sf = getXmlChild(fdt, "speedflow");
55+
const speedKph = num(xmlText(sf?.["@_speed"]));
56+
if (speedKph == null || speedKph < 0 || speedKph >= ABSURD_SPEED_KPH) continue;
57+
58+
const geometry: Point = { type: "Point", coordinates: [lon, lat] };
59+
const direction = xmlText(fdt["@_direction"]);
60+
flows.push({
61+
id: `${src.id}:${id}`,
62+
source: src.id,
63+
sourceFormat: "turin-fdt-xml",
64+
domain: "roads",
65+
kind: "measurement",
66+
metric: "flow",
67+
value: speedKph,
68+
unit: "km/h",
69+
level: "unknown",
70+
aggregation: "live",
71+
status: "active",
72+
geometry,
73+
los: "unknown",
74+
speedKph,
75+
...(direction ? { direction } : {}),
76+
origin,
77+
dataUpdatedAt: genTime ?? now,
78+
fetchedAt: now,
79+
isStale: false,
80+
});
81+
} catch (err) {
82+
console.warn("[turin-flow] skipped malformed FDT_data:", err);
83+
}
84+
}
85+
86+
return { flows, events: [] };
87+
}

0 commit comments

Comments
 (0)