From cf63278126a512c7e2761dd9ab38913bd126b291 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 15 Oct 2026 17:12:35 -0700 Subject: [PATCH] Take TTC vehicle mode from the fleet number before the route Replacement buses on 5xx streetcar routes were drawn as streetcars, and Flexity streetcars reporting no route were drawn as buses. A feed can now supply vehicleMode(id); TTC maps 4400-4699 to streetcars and every other fleet number to buses, falling back to the route otherwise. --- CHANGELOG.md | 4 +- server/providers/transitHistory.test.mjs | 23 ++++++++++++ src/data/transitFeeds.js | 47 ++++++++++++++++++++---- src/data/transitFeeds.test.mjs | 16 ++++++++ src/layers/transit/ingestion.js | 8 +++- src/sources/transitHistoryStore.js | 6 ++- 6 files changed, 93 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ca1ca0de..2e7478531 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,9 @@ YRT/Viva, Guelph, Burlington, Barrie and Kingston, each carrying its own licence quote and attribution. A feed can now name its own modes, so Toronto's trams read "Streetcar" while Helsinki's stay "Tram", and a feed whose route ids - are internal keys can translate them to the number a rider would say. + are internal keys can translate them to the number a rider would say. TTC + vehicles take their mode from the fleet number first, so replacement buses on + streetcar routes read as buses and out-of-service streetcars stay streetcars. - Cyclone advisories no longer go dark while NHC publishes an advisory ahead of its nominal time: advisory and position times up to 90 minutes ahead of diff --git a/server/providers/transitHistory.test.mjs b/server/providers/transitHistory.test.mjs index cb06c182a..551fee7a7 100644 --- a/server/providers/transitHistory.test.mjs +++ b/server/providers/transitHistory.test.mjs @@ -179,3 +179,26 @@ test('snapshot capacity pressure preserves members not yet visited and discloses assert.equal(recreated.fixes.length, 1); assert.equal(recreated.truncated, true); }); +test('a feed fleet-number mode outranks its route mode in the epoch context', (t) => { + const h = history(t); + const ttc = { + id: 'ttc', + historyRetention: true, + defaultMode: 'bus', + routeMode: (route) => (/^5\d\d$/.test(route ?? '') ? 'tram' : 'bus'), + vehicleMode: (id) => + /^4[4-6]\d\d$/.test(id) ? 'tram' : /^\d{4}$/.test(id) ? 'bus' : 'unknown', + }; + h.ingest( + ttc, + [ + row(100000, '8515', { routeId: '506' }), + row(100000, '4507', { routeId: null }), + row(100000, 'x1', { routeId: '501' }), + ], + 100000, + ); + assert.equal(h.get('ttc', '8515', 100000).epochs[0].mode, 'bus'); + assert.equal(h.get('ttc', '4507', 100000).epochs[0].mode, 'tram'); + assert.equal(h.get('ttc', 'x1', 100000).epochs[0].mode, 'tram'); +}); diff --git a/src/data/transitFeeds.js b/src/data/transitFeeds.js index cdf4b030b..dfd3d6b18 100644 --- a/src/data/transitFeeds.js +++ b/src/data/transitFeeds.js @@ -130,6 +130,20 @@ function ttcRouteMode(routeId) { return 'bus'; } +/** + * The fleet number settles TTC's mode before the route does: buses replace + * streetcars on 5xx routes during track work, and streetcars running out of + * service report no route at all. Flexity streetcars are numbered 4400–4699; + * every other TTC number is a bus. + * @param {string|null} vehicleId GTFS-RT vehicle id (the fleet number). + * @returns {string} + */ +function ttcVehicleMode(vehicleId) { + if (!/^\d{4}$/.test(String(vehicleId ?? ''))) return 'unknown'; + const fleet = Number(vehicleId); + return fleet >= 4400 && fleet < 4700 ? 'tram' : 'bus'; +} + /** * Guelph Transit's realtime feed publishes the internal database key of a * route (`2991`), not the number on the front of the bus (`1`). Without this @@ -541,6 +555,7 @@ export const TRANSIT_FEED_REGISTRY = Object.freeze([ tram: Object.freeze({ word: 'Streetcar', abbr: 'STREETCAR' }), }), routeMode: ttcRouteMode, + vehicleMode: ttcVehicleMode, }), Object.freeze({ id: 'miway-mississauga', @@ -795,14 +810,32 @@ export function transitFeedsInRange(lat, lon, slackKm = 0) { } /** - * Mode for a vehicle: the feed's route hint when it has one, else its default. + * The feed's own hint for a vehicle: its fleet number when the feed maps + * those (`vehicleMode`), else its route (`routeMode`). Null when neither knows. + * @param {object} feed Registry entry. + * @param {string|null} routeId + * @param {string|null} vehicleId + * @returns {string|null} + */ +function hintedMode(feed, routeId, vehicleId) { + const byVehicle = + typeof feed?.vehicleMode === 'function' + ? feed.vehicleMode(vehicleId) + : null; + if (byVehicle && byVehicle !== 'unknown') return byVehicle; + return typeof feed?.routeMode === 'function' ? feed.routeMode(routeId) : null; +} + +/** + * Mode for a vehicle: the feed's fleet or route hint when it has one, else + * its default. * @param {object} feed Registry entry. * @param {string|null} routeId GTFS route_id from the vehicle's trip. + * @param {string|null} [vehicleId] GTFS-RT vehicle id. * @returns {string} One of TRANSIT_MODES. */ -export function transitModeFor(feed, routeId) { - const hinted = - typeof feed?.routeMode === 'function' ? feed.routeMode(routeId) : null; +export function transitModeFor(feed, routeId, vehicleId = null) { + const hinted = hintedMode(feed, routeId, vehicleId); const mode = hinted && hinted !== 'unknown' ? hinted : feed?.defaultMode || 'unknown'; return TRANSIT_MODES.includes(mode) ? mode : 'unknown'; @@ -815,11 +848,11 @@ export function transitModeFor(feed, routeId) { * not hold a defaulted vehicle to a bus's limits. * @param {object} feed Registry entry. * @param {string|null} routeId + * @param {string|null} [vehicleId] * @returns {boolean} */ -export function transitModeResolved(feed, routeId) { - const hinted = - typeof feed?.routeMode === 'function' ? feed.routeMode(routeId) : null; +export function transitModeResolved(feed, routeId, vehicleId = null) { + const hinted = hintedMode(feed, routeId, vehicleId); return ( Boolean(hinted) && hinted !== 'unknown' && TRANSIT_MODES.includes(hinted) ); diff --git a/src/data/transitFeeds.test.mjs b/src/data/transitFeeds.test.mjs index 22281e46d..fd990af45 100644 --- a/src/data/transitFeeds.test.mjs +++ b/src/data/transitFeeds.test.mjs @@ -279,6 +279,22 @@ test('TTC route numbers tell a streetcar from a bus', () => { ); }); +test('a TTC fleet number outranks the route', () => { + // Buses replace streetcars on 5xx routes during track work, and streetcars + // out of service report no route; the Flexity fleet is 4400–4699. + const ttc = getTransitFeed('ttc-toronto'); + assert.equal(transitModeFor(ttc, '506', '8515'), 'bus'); + assert.equal(transitModeFor(ttc, '512', '3101'), 'bus'); + assert.equal(transitModeFor(ttc, null, '4507'), 'tram'); + assert.equal(transitModeResolved(ttc, null, '4507'), true); + assert.equal(transitModeFor(ttc, '504', '4400'), 'tram'); + assert.equal(transitModeFor(ttc, '29', '4699'), 'tram'); + assert.equal(transitModeFor(ttc, '501', '4700'), 'bus'); + // A vehicle id that is not a fleet number leaves the route to decide. + assert.equal(transitModeFor(ttc, '501', 'abc'), 'tram'); + assert.equal(transitModeFor(ttc, '501'), 'tram'); +}); + test('the TTC row records that BusTime carries no subway', () => { const sources = readFileSync( new URL('../../DATA_SOURCES.md', import.meta.url), diff --git a/src/layers/transit/ingestion.js b/src/layers/transit/ingestion.js index cb610267e..be8833270 100644 --- a/src/layers/transit/ingestion.js +++ b/src/layers/transit/ingestion.js @@ -221,8 +221,12 @@ export function createIngestion({ state, services, parts, source }) { for (const record of records) { if (isStaleVehicleFix(record, now, fetchedAt)) continue; const key = transitVehicleKey(feed.id, record.id); - const mode = transitModeFor(feed, record.routeId); - const modeInferred = !transitModeResolved(feed, record.routeId); + const mode = transitModeFor(feed, record.routeId, record.id); + const modeInferred = !transitModeResolved( + feed, + record.routeId, + record.id, + ); // A stamp ahead of the fetch is a clock fault; the fetch time bounds it. const stamped = vehicleReportTimeMs(record, fetchedAt); const reportAt = diff --git a/src/sources/transitHistoryStore.js b/src/sources/transitHistoryStore.js index 36440975c..9fe29f59d 100644 --- a/src/sources/transitHistoryStore.js +++ b/src/sources/transitHistoryStore.js @@ -328,8 +328,12 @@ export function createTransitHistory({ t > now + 10000 ) continue; + const byVehicle = feed.vehicleMode?.(record.id); const mode = - feed.routeMode?.(record.routeId) || feed.defaultMode || 'unknown'; + (byVehicle !== 'unknown' && byVehicle) || + feed.routeMode?.(record.routeId) || + feed.defaultMode || + 'unknown'; const context = contextOf(record, mode); if (!context) continue; const key = `${feed.id}\u0000${record.id}`;