feat(transit): add Lisbon's Carris Metropolitana buses

Keyless GTFS-Realtime feed from Carris Metropolitana, documented in the
operator's public API repository. No licence is stated for the realtime
endpoint (the static GTFS is CC BY 4.0), so it is credited as a courtesy on
the same basis as the Metro Transit and OVapi feeds. The feed publishes km/h,
so a new `speedUnit` registry field lets the proxy convert to m/s. Route ids
wrap the GTFS route id in an area prefix and are read as the line number.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Itallo BrandãoandClaude Opus 5.5 committed 2026-11-07 19:38:24 -03:00
1 parent 433b336c2d
commit e0ba12e5a9
9 files changed
+109 -8

No files matched your search

+39
View File
@@ -389,6 +389,19 @@ function rioRouteLabel(routeId) {
: routeId;
}
/**
* Carris Metropolitana realtime route ids wrap the GTFS route id in an
* operator-area prefix and keep its pattern suffix: `[LA77N]1708_0` is GTFS
* route `1708_0`, whose `short_name` (the number on the bus) is `1708`, per
* the operator's own `/v2/routes` API. Anything else passes through.
* @param {string|null} routeId
* @returns {string|null}
*/
function carrisMetropolitanaRouteLabel(routeId) {
const match = /^\[[A-Z0-9]+\](\d{4})_\d+$/.exec(routeId ?? '');
return match ? match[1] : routeId;
}
/**
* Registry of feeds. Order is presentation order in the stats/credit text.
* `loadRadiusKm` is the distance from `center` inside which the feed is polled.
@@ -398,7 +411,10 @@ function rioRouteLabel(routeId) {
* url: string, headers?: Record<string, string>,
* license: string, licenseUrl: string, attribution: string,
* defaultMode: string, routeMode?: (routeId: string|null) => string,
* speedUnit?: 'km/h',
* }>>}
* `speedUnit` marks a feed that publishes km/h where the spec requires m/s;
* the proxy converts it before the snapshot leaves the server.
*/
export const TRANSIT_FEED_REGISTRY = Object.freeze([
Object.freeze({
@@ -811,6 +827,29 @@ export const TRANSIT_FEED_REGISTRY = Object.freeze([
}),
defaultMode: 'bus',
}),
Object.freeze({
id: 'carris-metropolitana-lisboa',
name: 'Carris Metropolitana',
operator: 'Carris Metropolitana (TML), Lisbon Metropolitan Area',
region: 'Lisbon Metropolitan Area, Portugal',
// Every vehicle in a 2026-11-07 snapshot, from Mafra to Setúbal, sat
// between 38.47–39.00 N and 9.47–8.79 W, inside 40 km of this point.
center: Object.freeze({ lat: 38.73, lon: -9.13 }),
loadRadiusKm: 45,
url: 'https://api.carrismetropolitana.pt/v2/vehicles.pb',
license: 'No separate licence for the realtime feed',
licenseUrl: 'https://github.com/carrismetropolitana/api',
attribution: 'Carris Metropolitana',
defaultEnabled: true,
terms: Object.freeze({
quote:
'GET /vehicles.pb — Returns vehicle locations in Protobuf format, following the GTFS-RT Vehicle Locations standard.',
note: 'The operator publishes this feed keyless and documented for developers in its public API repository, but states no licence for the realtime endpoint; its static GTFS (github.com/carrismetropolitana/gtfs) is CC BY 4.0. Credited as a courtesy, on the same basis as the Metro Transit and OVapi feeds; switch it off if Carris objects. The quote above is the API README, not a licence. `/vehicles.pb` answers with a 307 to `/v2/vehicles.pb`, so the registry points at the target. Speeds are km/h, not the m/s the spec requires: 2026-11-07 readings of 31–33 matched 6–9 m/s of measured movement, so the proxy converts them. Vehicle reports arrive every 30–50 seconds. Route ids are the GTFS route id inside an area prefix and are read as the line number.',
}),
defaultMode: 'bus',
routeLabel: carrisMetropolitanaRouteLabel,
speedUnit: 'km/h',
}),
]);
/**
+33
View File
@@ -578,3 +578,36 @@ test('Szczecin is CC0, live, covers the city and not Berlin', () => {
'Szczecin has a DATA_SOURCES row',
);
});
test('Carris Metropolitana covers Lisbon from Mafra to Setúbal, not Porto', () => {
const feed = getTransitFeed('carris-metropolitana-lisboa');
assert.equal(feed.defaultEnabled, true);
assert.equal(feed.url, 'https://api.carrismetropolitana.pt/v2/vehicles.pb');
assert.equal(feed.speedUnit, 'km/h');
assert.match(feed.terms.note, /no licence for the realtime endpoint/);
const ids = (lat, lon) => transitFeedsInRange(lat, lon).map((f) => f.id);
for (const [place, lat, lon] of [
['Lisbon', 38.7223, -9.1393],
['Mafra', 38.9369, -9.3277],
['Setúbal', 38.5244, -8.8882],
]) {
assert.ok(ids(lat, lon).includes(feed.id), place);
}
assert.ok(!ids(41.1579, -8.6291).includes(feed.id), 'Porto');
const sources = readFileSync(
new URL('../../DATA_SOURCES.md', import.meta.url),
'utf8',
);
assert.ok(
sources.split('\n').some((l) => l.startsWith('| **Carris Metropolitana')),
'Lisbon has a DATA_SOURCES row',
);
});
test('Carris Metropolitana route ids read as the line number on the bus', () => {
const feed = getTransitFeed('carris-metropolitana-lisboa');
assert.equal(transitRouteLabel(feed, '[LA77N]1708_0'), '1708');
assert.equal(transitRouteLabel(feed, '[BNA17]2772_1'), '2772');
assert.equal(transitRouteLabel(feed, 'unexpected'), 'unexpected');
assert.equal(transitRouteLabel(feed, null), null);
});
+5 -1
View File
@@ -257,7 +257,11 @@ export function buildTransitSnapshot(feed, bytes, now = Date.now()) {
truncated: decoded.truncated,
count: decoded.vehicles.length,
vehicles: repairVehicleTimestamps(
decoded.vehicles,
feed.speedUnit === 'km/h'
? decoded.vehicles.map((v) =>
v.speedMps === null ? v : { ...v, speedMps: v.speedMps / 3.6 },
)
: decoded.vehicles,
decoded.timestamp,
Math.floor(now / 1000),
),
+21
View File
@@ -294,6 +294,27 @@ test('Rio speeds pass through as the m/s they are published in', () => {
assert.equal(rio.vehicles[0].speedMps, 10);
});
test('Carris Metropolitana km/h speeds leave the proxy as m/s', () => {
// 2026-11-07: readings of 31-33 matched 6-9 m/s of measured movement.
const bytes = encodeFeed({
entities: [
{ id: 'moving', lat: 38.72, lon: -9.14, speed: 36 },
{ id: 'parked', lat: 38.73, lon: -9.15 },
],
});
const feed = getTransitFeed('carris-metropolitana-lisboa');
const lisbon = buildTransitSnapshot(feed, bytes, 1);
const byId = Object.fromEntries(lisbon.vehicles.map((v) => [v.id, v]));
assert.ok(Math.abs(byId.moving.speedMps - 10) < 1e-6);
assert.equal(byId.parked.speedMps, null, 'no speed stays no speed');
const szczecin = buildTransitSnapshot(
getTransitFeed('zditm-szczecin'),
encodeFeed({ entities: [{ id: 'm', lat: 53.43, lon: 14.55, speed: 10 }] }),
1,
);
assert.equal(szczecin.vehicles[0].speedMps, 10, 'm/s feeds are untouched');
});
test('conditional-request validators and gzip ride along with feed identification', () => {
const entur = getTransitFeed('entur-norway');
const plain = transitUpstreamHeaders(entur);