Skip to content

Commit eca53cc

Browse files
committed
feat(roads): ingest Barcelona TRAMS real-time traffic status
Add the Barcelona "estat del trànsit" TRAMS feed (Ajuntament de Barcelona, CC-BY-4.0, no auth): a `#`-delimited live status file on a 0-6 congestion scale, with per-segment polyline geometry joined by tram id from a companion long-format CSV dictionary via a new "bcn-trams-csv" station registry. Categorical status only (no measured speed); level-of-service and derived congestion events come from the shared flow builder. New "bcn-trams" flow format + parser.
1 parent 89a58ec commit eca53cc

10 files changed

Lines changed: 209 additions & 3 deletions

File tree

packages/core/src/model.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ export type SourceFormat =
5050
| "fdt"
5151
| "hk-td"
5252
| "geojson-flow"
53+
| "bcn-trams"
5354
| "gtfs-rt"
5455
| "native"
5556
| "crowd";

packages/roads/feeds/roads/es.json5

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,4 +26,28 @@
2626
country: "ES",
2727
privacyUrl: "https://www.madrid.es/portal/site/munimadrid/menuitem.privacy",
2828
},
29+
{
30+
subdivision: "bcn",
31+
operator: "ajuntament",
32+
name: "Estat del trànsit TRAMS (Barcelona)",
33+
format: "bcn-trams",
34+
produces: "flow",
35+
// Live status file: one `#`-delimited row per segment
36+
// (tramId#YYYYMMDDHHMMSS#estatActual#estatPrevist15min) on a 0-6 congestion
37+
// scale. Categorical only (no speed). The segment geometry lives in a
38+
// separate long-format CSV dictionary, joined by tram id via the station
39+
// registry below.
40+
url: "https://opendata-ajuntament.barcelona.cat/data/dataset/8319c2b1-4c21-4962-9acd-6db4c5ff1148/resource/2d456eb5-4ea6-4f68-9794-2f3f1a58a933/download",
41+
stationRegistry: {
42+
url: "https://opendata-ajuntament.barcelona.cat/data/dataset/0e3b6840-7dff-4731-a556-44fac28a7873/resource/c97072a3-3619-4547-84dd-f1999d2a3fec/download/transit_relacio_trams_format_long.csv",
43+
format: "bcn-trams-csv",
44+
},
45+
cadenceSec: 300,
46+
freshnessWindowSec: 900,
47+
license: "CC-BY-4.0",
48+
licenseUrl: "https://creativecommons.org/licenses/by/4.0/",
49+
attribution: "Ajuntament de Barcelona",
50+
country: "ES",
51+
privacyUrl: "https://www.barcelona.cat/en/privacy-policy",
52+
},
2953
]

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

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

424-
it("loads every feed from the data files (all 72 migrated)", () => {
425-
expect(FEED_SOURCES.length).toBe(72);
424+
it("loads every feed from the data files (all 73 migrated)", () => {
425+
expect(FEED_SOURCES.length).toBe(73);
426426
expect(new Set(FEED_SOURCES.map((f) => f.id)).size).toBe(FEED_SOURCES.length);
427427
});
428428

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { describe, expect, it } from "vitest";
2+
import { parseBcnTramsFlow } from "../flow-bcn.js";
3+
import { parseBcnTramsStations } from "../stations-bcn.js";
4+
import type { SiteGeometry } from "../siteTable.js";
5+
import type { SourceDescriptor } from "../types.js";
6+
7+
const src = {
8+
id: "es-bcn-ajuntament",
9+
attribution: "Ajuntament de Barcelona",
10+
country: "ES",
11+
license: "CC-BY-4.0",
12+
} as SourceDescriptor;
13+
14+
const CSV = `Tram,Tram_Components,Descripció,Longitud,Latitud
15+
1,1,"Diagonal (Ronda de Dalt a Doctor Marañón)",2.11203535639414,41.3841912394771
16+
1,2,"Diagonal (Ronda de Dalt a Doctor Marañón)",2.101502862881051,41.3816307921222
17+
2,1,"Meridiana",2.18,41.42
18+
2,2,"Meridiana",2.19,41.43
19+
9,1,"Single vertex only",2.0,41.0`;
20+
21+
describe("parseBcnTramsStations", () => {
22+
it("groups vertices per tram (ordered) into LineStrings and drops single-vertex trams", () => {
23+
const map = parseBcnTramsStations(CSV);
24+
expect(map.size).toBe(2); // tram 9 has one vertex → dropped
25+
const geom = map.get("1") as Extract<SiteGeometry, { type: "LineString" }>;
26+
expect(geom.type).toBe("LineString");
27+
expect(geom.coordinates).toEqual([
28+
[2.11203535639414, 41.3841912394771],
29+
[2.101502862881051, 41.3816307921222],
30+
]);
31+
});
32+
});
33+
34+
describe("parseBcnTramsFlow", () => {
35+
const siteMap = parseBcnTramsStations(CSV);
36+
37+
it("joins status rows to geometry and maps the 0-6 scale to level-of-service", () => {
38+
const dat = ["1#20260729131557#2#2", "2#20260729131557#5#5"].join("\n");
39+
const { flows, events } = parseBcnTramsFlow(dat, src, siteMap);
40+
expect(flows).toHaveLength(2);
41+
const t1 = flows.find((f) => f.id === "es-bcn-ajuntament:1")!;
42+
expect(t1.los).toBe("free_flow");
43+
expect(t1.speedKph).toBeUndefined();
44+
expect(t1.geometry.type).toBe("LineString");
45+
expect(t1.dataUpdatedAt).toBe("2026-07-29T13:15:57");
46+
const t2 = flows.find((f) => f.id === "es-bcn-ajuntament:2")!;
47+
expect(t2.los).toBe("stationary");
48+
expect(t2.sourceFormat).toBe("bcn-trams");
49+
// Only the congested (stationary) segment yields a derived congestion event.
50+
expect(events).toHaveLength(1);
51+
expect(events[0]!.type).toBe("congestion");
52+
});
53+
54+
it("skips status 0 (sensor down) and segments with no known geometry", () => {
55+
const dat = ["1#20260729131557#0#0", "999#20260729131557#3#3"].join("\n");
56+
const { flows } = parseBcnTramsFlow(dat, src, siteMap);
57+
expect(flows).toHaveLength(0);
58+
});
59+
60+
it("flags a hard parse failure on an empty/garbage body", () => {
61+
expect(parseBcnTramsFlow("", src, siteMap).failed).toBe(true);
62+
expect(parseBcnTramsFlow("<html>error</html>", src, siteMap).failed).toBe(true);
63+
});
64+
});

packages/roads/src/feed-schema.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ export const roadFeedSchema = z
8686
"miv-config",
8787
"france-comptage-csv",
8888
"hk-detector-csv",
89+
"bcn-trams-csv",
8990
]),
9091
})
9192
.strict()

packages/roads/src/feeds.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import { parseMivFlow } from "./miv.js";
3232
import { parseTurinFlow } from "./flow-turin.js";
3333
import { parseHkRawFlow } from "./hk.js";
3434
import { parseGeojsonFlow } from "./flow-geojson.js";
35+
import { parseBcnTramsFlow } from "./flow-bcn.js";
3536
import type { GeojsonFlowMapping, SourceDescriptor } from "./types.js";
3637

3738
// FeedAuth now lives in @openconditions/ingest-framework; re-exported here so
@@ -76,7 +77,8 @@ export type FeedSource = FeedSourceBase & {
7677
| "webtris-sites"
7778
| "miv-config"
7879
| "france-comptage-csv"
79-
| "hk-detector-csv";
80+
| "hk-detector-csv"
81+
| "bcn-trams-csv";
8082
};
8183
/** Field mapping for `format: "geojson"` feeds (passed to the generic reader). */
8284
geojson?: GeoJsonMapping;
@@ -176,6 +178,7 @@ export function flowParserFor(format: SourceFormat): FlowParserFn {
176178
if (format === "fdt") return parseTurinFlow;
177179
if (format === "hk-td") return parseHkRawFlow;
178180
if (format === "geojson-flow") return parseGeojsonFlow;
181+
if (format === "bcn-trams") return parseBcnTramsFlow;
179182
throw new Error(`No flow parser registered for format: ${format}`);
180183
}
181184

packages/roads/src/flow-bcn.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import type { RoadEvent, RoadFlow } from "./model.js";
2+
import type { SourceDescriptor } from "./types.js";
3+
import type { SiteGeometry } from "./siteTable.js";
4+
import { buildMeasuredSiteFlow, makeOrigin } from "./flow.js";
5+
import type { FlowParseResult } from "./flow.js";
6+
7+
/**
8+
* Barcelona's 0-6 congestion scale → DATEX status tokens the shared flow builder
9+
* derives level-of-service from. 0 = sensor down (no data) → skipped entirely.
10+
*/
11+
const STATUS_TO_DATEX: Record<string, string> = {
12+
"1": "freeFlow", // molt fluid
13+
"2": "freeFlow", // fluid
14+
"3": "heavy", // dens
15+
"4": "congested", // molt dens
16+
"5": "stationary", // congestió
17+
"6": "blocked", // tallat
18+
};
19+
20+
/** "YYYYMMDDHHMMSS" → ISO-8601 local timestamp, or undefined when malformed. */
21+
function parseBcnTimestamp(raw: string): string | undefined {
22+
const m = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})$/.exec(raw);
23+
if (!m) return undefined;
24+
const [, y, mo, d, h, mi, s] = m;
25+
return `${y}-${mo}-${d}T${h}:${mi}:${s}`;
26+
}
27+
28+
/**
29+
* Parse Barcelona's live "estat del trànsit" TRAMS feed — one `#`-delimited row
30+
* per segment: `tramId#YYYYMMDDHHMMSS#estatActual#estatPrevist15min`, the status
31+
* a 0-6 congestion scale. Geometry comes from the injected TRAMS registry
32+
* (tram id → LineString). Categorical status only (no speed); segments with
33+
* status 0 (sensor down) or no resolvable geometry are skipped. los and the
34+
* derived congestion events come from the shared {@link buildMeasuredSiteFlow};
35+
* only the sourceFormat is restamped here.
36+
*/
37+
export function parseBcnTramsFlow(
38+
input: string | Buffer,
39+
src: SourceDescriptor,
40+
siteMap?: Map<string, SiteGeometry>
41+
): FlowParseResult {
42+
const text = Buffer.isBuffer(input) ? input.toString("utf8") : input;
43+
if (typeof text !== "string" || text.trim() === "") {
44+
return { flows: [], events: [], failed: true };
45+
}
46+
47+
const flows: RoadFlow[] = [];
48+
const events: RoadEvent[] = [];
49+
const now = new Date().toISOString();
50+
const origin = makeOrigin(src);
51+
let sawRow = false;
52+
53+
for (const line of text.split(/\r?\n/)) {
54+
const parts = line.split("#");
55+
if (parts.length < 3) continue;
56+
const [tramId, ts, status] = parts;
57+
if (!tramId) continue;
58+
sawRow = true;
59+
const geom = siteMap?.get(tramId.trim());
60+
if (!geom) continue;
61+
const trafficStatus = status != null ? STATUS_TO_DATEX[status.trim()] : undefined;
62+
if (!trafficStatus) continue; // status 0 (no data) or unrecognised value
63+
const measuredAt = (ts ? parseBcnTimestamp(ts.trim()) : undefined) ?? now;
64+
const built = buildMeasuredSiteFlow(
65+
{ siteId: tramId.trim(), measuredAt, geom, trafficStatus },
66+
src,
67+
origin,
68+
now
69+
);
70+
if (!built) continue;
71+
flows.push({ ...built.flow, sourceFormat: "bcn-trams" });
72+
if (built.event) events.push({ ...built.event, sourceFormat: "bcn-trams" });
73+
}
74+
75+
// A body that parsed to zero recognizable rows is a hard failure (error page),
76+
// not a legitimately empty cycle — every real fetch carries ~530 segments.
77+
if (!sawRow) return { flows: [], events: [], failed: true };
78+
return { flows, events };
79+
}

packages/roads/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export * from "./predefined-locations.js";
2525
export * from "./stations-fintraffic.js";
2626
export * from "./stations-webtris.js";
2727
export * from "./stations-france.js";
28+
export * from "./stations-bcn.js";
2829
export * from "./miv.js";
2930
export * from "./flow-turin.js";
3031
export * from "./hk.js";

packages/roads/src/stations-bcn.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import type { LineString } from "geojson";
2+
import type { SiteGeometry } from "./siteTable.js";
3+
4+
/**
5+
* Parse Barcelona's "Relació de trams" long-format CSV
6+
* (`Tram,Tram_Components,Descripció,Longitud,Latitud`) into a tram-id →
7+
* LineString registry for the TRAMS flow parser. One row per polyline vertex;
8+
* vertices are ordered by `Tram_Components`. The quoted `Descripció` column
9+
* (which itself contains commas) is skipped by anchoring the match on the two
10+
* leading integer columns and the two trailing coordinate columns.
11+
*/
12+
export function parseBcnTramsStations(input: string): Map<string, SiteGeometry> {
13+
const byTram = new Map<string, { seq: number; lon: number; lat: number }[]>();
14+
const rowRe = /^(\d+),(\d+),.*,(-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?)\s*$/;
15+
for (const line of input.split(/\r?\n/)) {
16+
const m = rowRe.exec(line);
17+
if (!m) continue;
18+
const [, tram, seq, lon, lat] = m;
19+
const arr = byTram.get(tram!) ?? [];
20+
arr.push({ seq: Number(seq), lon: Number(lon), lat: Number(lat) });
21+
byTram.set(tram!, arr);
22+
}
23+
const map = new Map<string, SiteGeometry>();
24+
for (const [tram, pts] of byTram) {
25+
if (pts.length < 2) continue;
26+
pts.sort((a, b) => a.seq - b.seq);
27+
const coordinates = pts.map((p) => [p.lon, p.lat] as [number, number]);
28+
map.set(tram, { type: "LineString", coordinates } satisfies LineString);
29+
}
30+
return map;
31+
}

services/ingest/src/pipeline/station-registry.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { FeedSource, SiteGeometry } from "@openconditions/roads";
22
import {
3+
parseBcnTramsStations,
34
parseFintrafficStations,
45
parseFranceComptageStations,
56
parseHkDetectors,
@@ -28,6 +29,7 @@ const PARSERS: Record<string, (input: string) => Map<string, SiteGeometry>> = {
2829
"miv-config": parseMivConfig,
2930
"france-comptage-csv": parseFranceComptageStations,
3031
"hk-detector-csv": parseHkDetectors,
32+
"bcn-trams-csv": parseBcnTramsStations,
3133
};
3234

3335
/**

0 commit comments

Comments
 (0)