mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-12 14:22:21 +00:00
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:
1 parent
f13c60c477
commit
a4c7b20ca8
7 files changed
+147
-6
No files matched your search
@@ -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',
|
||||
}),
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
@@ -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),
|
||||
),
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user