diff --git a/changelog.d/1447-bikeshare-derived-capacity.md b/changelog.d/1447-bikeshare-derived-capacity.md new file mode 100644 index 000000000..c241b58f7 --- /dev/null +++ b/changelog.d/1447-bikeshare-derived-capacity.md @@ -0,0 +1,3 @@ +- Bike Share stations whose feed publishes no capacity now take it from each + status poll's bikes and docks instead of keeping the first poll's value or + the placeholder 15 (Aditya, @Rohilalala, #1447). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 84d42cb7b..125f57996 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1898,6 +1898,8 @@ limit, as well as when a tile decodes or the source resets. GBFS null or empty availability counts retain an unknown value through station parsing and rendering; they use the neutral station color rather than reporting an empty dock. Explicit zero and caller-supplied numeric fallbacks remain valid. +A station whose `station_information` publishes no capacity takes bikes plus +docks from each status poll (15 until its status arrives), not from the first. ## Terrain height cache bound diff --git a/src/layers/bikeshare/proxyRoute.test.mjs b/src/layers/bikeshare/proxyRoute.test.mjs index e4153a203..718dc5034 100644 --- a/src/layers/bikeshare/proxyRoute.test.mjs +++ b/src/layers/bikeshare/proxyRoute.test.mjs @@ -446,3 +446,62 @@ test('unknown GBFS counts stay unknown through the source, model and station col assert.equal(model.toNonNegativeInteger('3'), 3); } }); + +test('a station without a published capacity re-derives it from every status poll', async () => { + const { createRendering } = await import('./rendering.js'); + const { COLOR_YELLOW } = await import('./policy.js'); + const model = createModel({}); + const state = { + _cityRuntime: new Map(), + _stationRenderMap: new Map(), + _pointCollection: { add: (options) => ({ ...options }) }, + }; + const rendering = createRendering({ + state, + services: { render: { governorRequestRender() {} } }, + parts: { model }, + }); + const info = model.parseStationInformation({ + data: { + stations: [ + { station_id: 'open', name: 'Open', lat: 30.26, lon: -97.74 }, + { + station_id: 'fixed', + name: 'Fixed', + lat: 30.27, + lon: -97.75, + capacity: 13, + }, + ], + }, + }); + rendering.ensureCityPoints('austin', info); + const poll = (stations) => + rendering.applyStatusToPoints( + 'austin', + model.parseStationStatus({ data: { stations } }), + ); + const open = state._stationRenderMap.get('austin:open'); + const fixed = state._stationRenderMap.get('austin:fixed'); + + // The first poll has no row for the station: capacity is a placeholder. + poll([ + { + station_id: 'fixed', + num_bikes_available: 4, + num_docks_available: 9, + }, + ]); + poll([ + { station_id: 'open', num_bikes_available: 10, num_docks_available: 20 }, + { station_id: 'fixed', num_bikes_available: 2, num_docks_available: 3 }, + ]); + assert.equal(open.capacity, 30, 'the first poll fixed the capacity'); + assert.equal(open.point.color, COLOR_YELLOW, '10 of 30 is not plenty'); + assert.equal(fixed.capacity, 13, 'a published capacity is kept'); + + poll([ + { station_id: 'open', num_bikes_available: 4, num_docks_available: 2 }, + ]); + assert.equal(open.capacity, 6); +}); diff --git a/src/layers/bikeshare/rendering.js b/src/layers/bikeshare/rendering.js index f0c737d1d..c75189d4a 100644 --- a/src/layers/bikeshare/rendering.js +++ b/src/layers/bikeshare/rendering.js @@ -185,6 +185,7 @@ export function createRendering({ stationName: station.name, point, capacity: parts.model.toNonNegativeInteger(station.capacity), + publishedCapacity: parts.model.toNonNegativeInteger(station.capacity), bikesAvailable: null, docksAvailable: null, isInstalled: station.isInstalled, @@ -245,7 +246,8 @@ export function createRendering({ if (!record) continue; const status = statusMap.get(record.stationId) || null; - const capacity = resolveCapacity(record.capacity, status); + // Resolve from the published capacity, not last poll's derived value. + const capacity = resolveCapacity(record.publishedCapacity, status); record.capacity = capacity; record.bikesAvailable = parts.model.toNonNegativeInteger(