feat(transit): add Rio de Janeiro buses and BRT (SMTR GTFS-RT, CC BY 4.0)

Register Rio's two keyless GTFS-Realtime VehiclePositions feeds from the
city transport secretariat (SMTR): municipal buses (SPPO) and the
Mobi-Rio BRT, the Transit layer's first Latin American region.

Both feeds publish Position.speed in km/h instead of m/s. A new optional
registry field, speedUnit: 'kmh', makes buildTransitSnapshot convert it
once, so every speedMps consumer stays correct and other feeds are
untouched.

Document the source in DATA_SOURCES.md and update the region lists in
README.md and docs/CURRENT-STATE.md.

Refs #1480

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Itallo BrandãoandClaude Opus 5.5 committed 2026-10-29 16:11:10 -03:00
1 parent f13c60c477
commit a4c7b20ca8
7 files changed
+147 -6

No files matched your search

+50
View File
@@ -385,7 +385,12 @@ function burlingtonRouteLabel(routeId) {
* url: string, headers?: Record<string, string>,
* license: string, licenseUrl: string, attribution: string,
* defaultMode: string, routeMode?: (routeId: string|null) => string,
* speedUnit?: 'kmh',
* }>>}
*
* `speedUnit: 'kmh'` marks a feed that publishes `Position.speed` in km/h
* instead of the m/s the spec requires; the proxy converts it before anything
* downstream reads `speedMps`.
*/
export const TRANSIT_FEED_REGISTRY = Object.freeze([
Object.freeze({
@@ -729,6 +734,51 @@ export const TRANSIT_FEED_REGISTRY = Object.freeze([
defaultMode: 'bus',
routeLabel: burlingtonRouteLabel,
}),
Object.freeze({
id: 'smtr-rio-onibus',
name: 'Rio buses (SPPO)',
operator: 'Secretaria Municipal de Transportes (SMTR), Rio de Janeiro',
region: 'Rio de Janeiro, RJ',
// Every vehicle in a 2026-10-29 snapshot, from Santa Cruz in the west to
// the Zona Sul, sat within 31 km of this point.
center: Object.freeze({ lat: -22.92, lon: -43.42 }),
loadRadiusKm: 45,
url: 'https://dados.mobilidade.rio/gtfs/realtime/onibus/vehicle-positions',
license: 'CC BY 4.0',
licenseUrl:
'https://www.data.rio/documents/997bc16cc4344a5cb0e48ab06cbdfd1f',
attribution: 'SMTR / Prefeitura da Cidade do Rio de Janeiro (CC BY 4.0)',
defaultEnabled: true,
terms: Object.freeze({
quote:
'This work is licensed under a Creative Commons Attribution 4.0 International License',
note: 'The data.rio catalog page labels the feed [beta] and states a gateway limit of 5 requests a second and 60 a minute behind a 10-second cache, which the 15-second proxy TTL stays inside. It is the named replacement for the JSON GPS endpoints SMTR deprecated on 2026-10-07 and says it will switch off by 2026-11-30. SMTR relays the concessionaires’ ITS data and repairs trips with its own matching; the page places accuracy with the suppliers. Speeds arrive in km/h, not m/s (a 2026-10-29 snapshot peaked at 89), hence `speedUnit`. Route ids are internal keys: `O0313AAA0A` carried service 313 in the same day’s JSON feed. They are shown raw until a published table is joined.',
}),
defaultMode: 'bus',
speedUnit: 'kmh',
}),
Object.freeze({
id: 'smtr-rio-brt',
name: 'Mobi-Rio BRT',
operator:
'Secretaria Municipal de Transportes (SMTR), Rio de Janeiro, for the Mobi-Rio BRT fleet',
region: 'Rio de Janeiro, RJ',
center: Object.freeze({ lat: -22.92, lon: -43.42 }),
loadRadiusKm: 45,
url: 'https://dados.mobilidade.rio/gtfs/realtime/brt/vehicle-positions',
license: 'CC BY 4.0',
licenseUrl:
'https://www.data.rio/documents/8bb196cc02174d25b77437bb2f6443d4',
attribution: 'SMTR / Prefeitura da Cidade do Rio de Janeiro (CC BY 4.0)',
defaultEnabled: true,
terms: Object.freeze({
quote:
'This work is licensed under a Creative Commons Attribution 4.0 International License',
note: 'Same catalog terms, [beta] label and gateway limits as the bus feed. The fleet is operated by Mobi-Rio and tracked by SONDA, whose data SMTR relays. Speeds are km/h here too (peak 62 in the same snapshot). Route ids are opaque (`20000461130`) and are shown raw.',
}),
defaultMode: 'bus',
speedUnit: 'kmh',
}),
]);
/**
+43
View File
@@ -482,3 +482,46 @@ test('Burlington is live and carries its terms link, which its licence requires'
assert.match(bur.licenseUrl, /Open%20Data%20Terms%20of%20Use\.pdf$/);
assert.match(bur.url, /^https:\/\//, 'port 80 has no listener at all');
});
test('Rio buses and BRT cover the city from Centro to Santa Cruz, and not São Paulo', () => {
const ids = (lat, lon) =>
transitFeedsInRange(lat, lon).map((feed) => feed.id);
for (const [place, lat, lon] of [
['Centro', -22.9068, -43.1729],
['Barra da Tijuca', -23.0004, -43.3659],
['Santa Cruz', -22.9194, -43.6847],
]) {
assert.ok(
ids(lat, lon).includes('smtr-rio-onibus'),
`buses reach ${place}`,
);
assert.ok(ids(lat, lon).includes('smtr-rio-brt'), `BRT reaches ${place}`);
}
assert.deepEqual(
ids(-23.5505, -46.6333).filter((id) => id.startsWith('smtr-rio')),
[],
'São Paulo, 360 km away, never polls Rio',
);
});
test('Rio feeds are CC BY 4.0, live, and declare the km/h speeds they publish', () => {
for (const id of ['smtr-rio-onibus', 'smtr-rio-brt']) {
const feed = getTransitFeed(id);
assert.equal(feed.defaultEnabled, true, `${id} is live`);
assert.equal(feed.license, 'CC BY 4.0');
assert.match(feed.licenseUrl, /^https:\/\/www\.data\.rio\/documents\//);
assert.match(feed.attribution, /Prefeitura da Cidade do Rio de Janeiro/);
assert.equal(feed.speedUnit, 'kmh', `${id} publishes km/h`);
assert.equal(transitModeFor(feed, null), 'bus');
}
const sources = readFileSync(
new URL('../../DATA_SOURCES.md', import.meta.url),
'utf8',
);
assert.ok(
sources
.split('\n')
.some((line) => line.startsWith('| **SMTR Rio de Janeiro')),
'Rio has a DATA_SOURCES row',
);
});
+20 -1
View File
@@ -224,6 +224,25 @@ export function repairVehicleTimestamps(vehicles, headerTimestamp, fetchedAtS) {
});
}
/**
* Bring a feed's speeds to the m/s the spec requires. A feed registered with
* `speedUnit: 'kmh'` publishes km/h in `Position.speed`, and read as m/s a
* 44 km/h bus would be reported at 158 km/h. Converting here, once, keeps every
* consumer of `speedMps` honest without each one knowing about the feed.
*
* @param {object} feed Registry entry.
* @param {object[]} vehicles Normalized vehicle records.
* @returns {object[]}
*/
export function normalizeFeedSpeeds(feed, vehicles) {
if (feed?.speedUnit !== 'kmh') return vehicles;
return vehicles.map((vehicle) =>
Number.isFinite(vehicle.speedMps)
? { ...vehicle, speedMps: vehicle.speedMps / 3.6 }
: vehicle,
);
}
/**
* Decode upstream bytes into the JSON snapshot the browser consumes.
*
@@ -257,7 +276,7 @@ export function buildTransitSnapshot(feed, bytes, now = Date.now()) {
truncated: decoded.truncated,
count: decoded.vehicles.length,
vehicles: repairVehicleTimestamps(
decoded.vehicles,
normalizeFeedSpeeds(feed, decoded.vehicles),
decoded.timestamp,
Math.floor(now / 1000),
),
+26
View File
@@ -137,6 +137,7 @@ function encodeFeed({
const position = new PbfWriter();
position.writeFloatField(1, entity.lat);
position.writeFloatField(2, entity.lon);
if (entity.speed != null) position.writeFloatField(5, entity.speed);
vehicle.writeBytesField(2, position.finish());
if (entity.timestamp) vehicle.writeVarintField(5, entity.timestamp);
const descriptor = new PbfWriter();
@@ -284,6 +285,31 @@ test('the snapshot builder fills timestamps from the header it decoded', () => {
assert.equal(snapshot.feedTimestamp, 1_700_000_000);
});
test('a feed that publishes km/h has its speeds converted to m/s, and only that feed', () => {
// Rio's SMTR feeds put km/h in Position.speed; read as m/s, a 36 km/h bus
// would be reported at 130 km/h.
const bytes = encodeFeed({
entities: [
{ id: 'moving', lat: -22.9, lon: -43.2, speed: 36 },
{ id: 'still', lat: -22.9, lon: -43.2, speed: 0 },
{ id: 'silent', lat: -22.9, lon: -43.2 },
],
});
const rio = buildTransitSnapshot(getTransitFeed('smtr-rio-onibus'), bytes, 1);
const speeds = Object.fromEntries(
rio.vehicles.map((vehicle) => [vehicle.id, vehicle.speedMps]),
);
assert.ok(Math.abs(speeds.moving - 10) < 1e-6, '36 km/h is 10 m/s');
assert.equal(speeds.still, 0);
assert.equal(speeds.silent, null, 'an absent speed stays absent');
const mbta = buildTransitSnapshot(getTransitFeed('mbta'), bytes, 1);
assert.equal(
mbta.vehicles.find((vehicle) => vehicle.id === 'moving').speedMps,
36,
'a spec-conforming feed is left alone',
);
});
test('conditional-request validators and gzip ride along with feed identification', () => {
const entur = getTransitFeed('entur-norway');
const plain = transitUpstreamHeaders(entur);