fix(transit): read Rio speeds as the m/s SMTR publishes

Both SMTR feeds now publish Position.speed in m/s. Compared with how far
vehicles moved between reports on 2026-11-05, the published values ran
3.1x (buses) and 3.6x (BRT) below the observed speed, so the km/h
conversion divided real speeds by 3.6. Drop speedUnit and the conversion.
This commit is contained in:
German Khokhlov committed 2026-11-05 13:48:11 +04:00
1 parent e5dd7c71ac
commit a67c7969fd
7 files changed
+14 -58

No files matched your search

+1 -1
View File
@@ -98,7 +98,7 @@ How to read this:
| **Barrie Transit** (myridebarrie.ca) | Transit layer live vehicles, Barrie ON | Barrie Transit realtime data, published by the City of Barrie under a licence accepted by use of the data. The licence is revocable and requires no attribution. | Courtesy credit to the City of Barrie (the licence requires none, and its trademark clause is broad) | | **Barrie Transit** (myridebarrie.ca) | Transit layer live vehicles, Barrie ON | Barrie Transit realtime data, published by the City of Barrie under a licence accepted by use of the data. The licence is revocable and requires no attribution. | Courtesy credit to the City of Barrie (the licence requires none, and its trademark clause is broad) |
| **YRT/Viva** (rtu.york.ca) | Transit layer live vehicles, York Region ON | York Region Transit realtime data, first-party York Region infrastructure under the YRT Open Data Licence v2.0, which permits commercial use and makes attribution optional. Viva route ids are named by colour from York Region’s published GTFS. | "Contains public transit Information made available under YRT’s Open Data Licence" — the licence’s own wording, given though not required | | **YRT/Viva** (rtu.york.ca) | Transit layer live vehicles, York Region ON | York Region Transit realtime data, first-party York Region infrastructure under the YRT Open Data Licence v2.0, which permits commercial use and makes attribution optional. Viva route ids are named by colour from York Region’s published GTFS. | "Contains public transit Information made available under YRT’s Open Data Licence" — the licence’s own wording, given though not required |
| **Burlington Transit** (opendata.burlington.ca) | Transit layer live vehicles, Burlington ON | Burlington Transit realtime data under the City of Burlington Open Data Terms of Use, which permit any lawful purpose. The terms are revocable and must travel with the data, so the credit links them. Route ids are internal keys and are joined to the City’s published GTFS. | Credit to the City of Burlington plus a link to its Open Data Terms of Use, which the licence requires | | **Burlington Transit** (opendata.burlington.ca) | Transit layer live vehicles, Burlington ON | Burlington Transit realtime data under the City of Burlington Open Data Terms of Use, which permit any lawful purpose. The terms are revocable and must travel with the data, so the credit links them. Route ids are internal keys and are joined to the City’s published GTFS. | Credit to the City of Burlington plus a link to its Open Data Terms of Use, which the licence requires |
| **SMTR Rio de Janeiro** (dados.mobilidade.rio) | Transit layer live vehicles, Rio de Janeiro: municipal buses (SPPO) and the Mobi-Rio BRT, as two feeds | CC BY 4.0, stated on both data.rio catalog pages. Keyless GTFS-Realtime published by the Secretaria Municipal de Transportes, labelled beta, with a gateway limit of 5 requests a second and 60 a minute. Speeds arrive in km/h rather than m/s and are converted by the proxy. Route ids are internal keys and are joined to the line names in SMTR’s published GTFS `routes.txt` (dados.mobilidade.rio/gtfs/schedule), under the same CC BY 4.0 terms. | Attribution to SMTR / Prefeitura da Cidade do Rio de Janeiro under CC BY 4.0 | | **SMTR Rio de Janeiro** (dados.mobilidade.rio) | Transit layer live vehicles, Rio de Janeiro: municipal buses (SPPO) and the Mobi-Rio BRT, as two feeds | CC BY 4.0, stated on both data.rio catalog pages. Keyless GTFS-Realtime published by the Secretaria Municipal de Transportes, labelled beta, with a gateway limit of 5 requests a second and 60 a minute. Route ids are internal keys and are joined to the line names in SMTR’s published GTFS `routes.txt` (dados.mobilidade.rio/gtfs/schedule), under the same CC BY 4.0 terms. | Attribution to SMTR / Prefeitura da Cidade do Rio de Janeiro under CC BY 4.0 |
| **Radio Browser** | Geolocated internet-radio station directory and station-level tags | Public-domain directory data under PDDL 1.0; individual broadcaster stream terms apply | "Radio Browser" plus a link to the selected broadcaster | | **Radio Browser** | Geolocated internet-radio station directory and station-level tags | Public-domain directory data under PDDL 1.0; individual broadcaster stream terms apply | "Radio Browser" plus a link to the selected broadcaster |
| **Re:Earth Terrain** (Mapterhorn) | Terrain (keyless globe stacks — OSM etc. — + `/api/terrain/heights` ellipsoidal-height lookups) | Terrain mesh: CC BY 4.0; geoid: EGM2008 (NGA, public domain) | "Terrain (keyless globe stacks): Re:Earth Terrain / Mapterhorn (CC BY 4.0) / EGM2008 (NGA)" | | **Re:Earth Terrain** (Mapterhorn) | Terrain (keyless globe stacks — OSM etc. — + `/api/terrain/heights` ellipsoidal-height lookups) | Terrain mesh: CC BY 4.0; geoid: EGM2008 (NGA, public domain) | "Terrain (keyless globe stacks): Re:Earth Terrain / Mapterhorn (CC BY 4.0) / EGM2008 (NGA)" |
| **OpenSeaMap** (`tiles.openseamap.org`) | Sea Marks layer: raster overlay of buoys, lighthouses and ports (#10) | Chart tiles CC BY-SA 2.0; data ODbL 1.0 via OpenStreetMap | "Sea marks © OpenSeaMap contributors (CC BY-SA 2.0) · Data © OpenStreetMap contributors (ODbL)" | | **OpenSeaMap** (`tiles.openseamap.org`) | Sea Marks layer: raster overlay of buoys, lighthouses and ports (#10) | Chart tiles CC BY-SA 2.0; data ODbL 1.0 via OpenStreetMap | "Sea marks © OpenSeaMap contributors (CC BY-SA 2.0) · Data © OpenStreetMap contributors (ODbL)" |
+3 -4
View File
@@ -1,6 +1,5 @@
- Add Rio de Janeiro to the Transit layer: municipal buses and the Mobi-Rio BRT - Add Rio de Janeiro to the Transit layer: municipal buses and the Mobi-Rio BRT
from SMTR's keyless GTFS-Realtime feeds (CC BY 4.0), the layer's first Latin from SMTR's keyless GTFS-Realtime feeds (CC BY 4.0), the layer's first Latin
American region. A feed that publishes speed in km/h declares American region. Rio's internal route keys are joined to SMTR's published
`speedUnit: 'kmh'` and the proxy converts it to m/s. Rio's internal route GTFS `routes.txt`, so a card reads "Route SN731" rather than
keys are joined to SMTR's published GTFS `routes.txt`, so a card reads "Route O0731AAN0A" (IMBrandao, #1481).
"Route SN731" rather than "Route O0731AAN0A" (IMBrandao, #1481).
+1 -2
View File
@@ -4649,8 +4649,7 @@ Helsinki, the Netherlands, Norway, South East Queensland, Rio de Janeiro
(municipal buses and the Mobi-Rio BRT, two feeds), and nine in (municipal buses and the Mobi-Rio BRT, two feeds), and nine in
Ontario — Toronto (TTC surface vehicles; the subway publishes no Ontario — Toronto (TTC surface vehicles; the subway publishes no
positions), MiWay, Hamilton HSR, Durham Region, YRT/Viva, Guelph, positions), MiWay, Hamilton HSR, Durham Region, YRT/Viva, Guelph,
Burlington, Barrie and Kingston. Rio's feeds publish speed in km/h, which Burlington, Barrie and Kingston. Mode silhouettes
the proxy converts to m/s before any card reads it. Mode silhouettes
identify buses, trams, subways, trains, ferries and unclassified vehicles. identify buses, trams, subways, trains, ferries and unclassified vehicles.
Retained transit history uses the same outlier quarantine as live observations and Retained transit history uses the same outlier quarantine as live observations and
+2 -9
View File
@@ -398,12 +398,7 @@ function rioRouteLabel(routeId) {
* url: string, headers?: Record<string, string>, * url: string, headers?: Record<string, string>,
* license: string, licenseUrl: string, attribution: string, * license: string, licenseUrl: string, attribution: string,
* defaultMode: string, routeMode?: (routeId: string|null) => 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([ export const TRANSIT_FEED_REGISTRY = Object.freeze([
Object.freeze({ Object.freeze({
@@ -765,10 +760,9 @@ export const TRANSIT_FEED_REGISTRY = Object.freeze([
terms: Object.freeze({ terms: Object.freeze({
quote: quote:
'This work is licensed under a Creative Commons Attribution 4.0 International License', '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 (`O0731AAN0A` is line SN731) and are joined to the line names in SMTR’s published GTFS `routes.txt`, under the same CC BY 4.0 terms.', 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 are m/s, as the spec requires: on 2026-11-05 they matched how far vehicles moved between reports, though a 2026-10-29 snapshot read as km/h. Route ids are internal keys (`O0731AAN0A` is line SN731) and are joined to the line names in SMTR’s published GTFS `routes.txt`, under the same CC BY 4.0 terms.',
}), }),
defaultMode: 'bus', defaultMode: 'bus',
speedUnit: 'kmh',
routeLabel: rioRouteLabel, routeLabel: rioRouteLabel,
}), }),
Object.freeze({ Object.freeze({
@@ -788,10 +782,9 @@ export const TRANSIT_FEED_REGISTRY = Object.freeze([
terms: Object.freeze({ terms: Object.freeze({
quote: quote:
'This work is licensed under a Creative Commons Attribution 4.0 International License', '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 (`20000501110` is line 50) and are joined to the same `routes.txt` as the buses.', 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 m/s here too. Route ids are opaque (`20000501110` is line 50) and are joined to the same `routes.txt` as the buses.',
}), }),
defaultMode: 'bus', defaultMode: 'bus',
speedUnit: 'kmh',
routeLabel: rioRouteLabel, routeLabel: rioRouteLabel,
}), }),
]); ]);
+1 -2
View File
@@ -535,14 +535,13 @@ test('the Rio route table is a clean join of published keys to names', () => {
} }
}); });
test('Rio feeds are CC BY 4.0, live, and declare the km/h speeds they publish', () => { test('Rio feeds are CC BY 4.0 and live', () => {
for (const id of ['smtr-rio-onibus', 'smtr-rio-brt']) { for (const id of ['smtr-rio-onibus', 'smtr-rio-brt']) {
const feed = getTransitFeed(id); const feed = getTransitFeed(id);
assert.equal(feed.defaultEnabled, true, `${id} is live`); assert.equal(feed.defaultEnabled, true, `${id} is live`);
assert.equal(feed.license, 'CC BY 4.0'); assert.equal(feed.license, 'CC BY 4.0');
assert.match(feed.licenseUrl, /^https:\/\/www\.data\.rio\/documents\//); assert.match(feed.licenseUrl, /^https:\/\/www\.data\.rio\/documents\//);
assert.match(feed.attribution, /Prefeitura da Cidade do Rio de Janeiro/); 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'); assert.equal(transitModeFor(feed, null), 'bus');
} }
const sources = readFileSync( const sources = readFileSync(
+1 -20
View File
@@ -224,25 +224,6 @@ 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. * Decode upstream bytes into the JSON snapshot the browser consumes.
* *
@@ -276,7 +257,7 @@ export function buildTransitSnapshot(feed, bytes, now = Date.now()) {
truncated: decoded.truncated, truncated: decoded.truncated,
count: decoded.vehicles.length, count: decoded.vehicles.length,
vehicles: repairVehicleTimestamps( vehicles: repairVehicleTimestamps(
normalizeFeedSpeeds(feed, decoded.vehicles), decoded.vehicles,
decoded.timestamp, decoded.timestamp,
Math.floor(now / 1000), Math.floor(now / 1000),
), ),
+5 -20
View File
@@ -285,29 +285,14 @@ test('the snapshot builder fills timestamps from the header it decoded', () => {
assert.equal(snapshot.feedTimestamp, 1_700_000_000); 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', () => { test('Rio speeds pass through as the m/s they are published in', () => {
// Rio's SMTR feeds put km/h in Position.speed; read as m/s, a 36 km/h bus // SMTR publishes m/s, as the spec requires: checked against how far
// would be reported at 130 km/h. // vehicles moved between reports on 2026-11-05.
const bytes = encodeFeed({ const bytes = encodeFeed({
entities: [ entities: [{ id: 'moving', lat: -22.9, lon: -43.2, speed: 10 }],
{ 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 rio = buildTransitSnapshot(getTransitFeed('smtr-rio-onibus'), bytes, 1);
const speeds = Object.fromEntries( assert.equal(rio.vehicles[0].speedMps, 10);
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', () => { test('conditional-request validators and gzip ride along with feed identification', () => {