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}`;