diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 0e04fb39b..d930bcb8f 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -69,10 +69,12 @@ How to read this: | **Bike Share Toronto** (`toronto.publicbikesystem.net`) | Bikeshare station availability, Toronto | Operator GBFS feed. No licence is published: the City's open-data package declares none and the feed has no `license_url`. Attribution is given to the operator. | "Bike Share Toronto (Toronto Parking Authority)" | | **Bike Itaú / Tembici** (`*.publicbikesystem.net`, seven Brazilian cities, Santiago and Bogotá) | Bikeshare station availability: São Paulo, Rio de Janeiro, Salvador, Recife, Porto Alegre, Brasília, Curitiba, Santiago de Chile and Bogotá | Operator GBFS feeds. No licence is published: the feeds' `system_information.json` carries no `license_url`, operator or attribution fields. Attribution is given to the operator. | "Bike Itaú (Tembici)"; "Tembici Bogotá" | | **Bicing** (`barcelona.publicbikesystem.net`) | Bikeshare station availability, Barcelona | Operator GBFS feed (PBSC), keyless. The feed publishes no licence; the City's own open-data copy of the same station data is CC BY 4.0 but needs a token. Attribution is given to the operator. | "Bicing, Barcelona City Council (B:SM)" | +| **BiciMAD** (`madrid.publicbikesystem.net`) | Bikeshare station availability, Madrid | Operator GBFS feed (PBSC), keyless. Madrid's open-data catalogue publishes this feed (`datos.madrid.es`, dataset `900021-0-bicimad-gbfs`, owner EMT Madrid) under [EMT's open-data terms](https://mobilitylabs.emtmadrid.es/sip/terms-of-use): commercial and non-commercial reuse, with attribution REQUIRED (section 5.2). | "Powered by EMT de Madrid", linking to `https://www.emtmadrid.es` | +| **Spanish city bikes on PBSC** (`*.publicbikesystem.net`: Zaragoza, Valladolid, A Coruña, Donostia-San Sebastián, Bilbao, Rivas-Vaciamadrid) | Bikeshare station availability: Bizi Zaragoza, BIKI, BiciCoruña, Dbizi, Bilbao Bizi, BicinRivas | Operator GBFS feeds, keyless. The feeds' `system_information.json` carries no `license_url`, and no operator or city publishes a licence for them; MobilityData's catalogue lists CC BY 4.0 without naming a source. Attribution is given to the operator. | "Bizi Zaragoza"; "BIKI, AUVASA (Valladolid)"; "BiciCoruña"; "Dbizi (Donostia-San Sebastián)"; "Bilbao Bizi, Bilbao City Council"; "BicinRivas, Rivas-Vaciamadrid City Council" | | **AMBici** (nextbike, `gbfs.nextbike.net`) | Bikeshare station availability, Barcelona metropolitan area (e-bikes) | Operator GBFS feed, keyless. `system_information.license_id` is **CC0-1.0**; no attribution required, given anyway. | "AMBici, Àrea Metropolitana de Barcelona, operated by nextbike" | | **Oslo / Bergen / Trondheim Bysykkel** (Urban Sharing, `gbfs.urbansharing.com`) | Bikeshare availability, Oslo, Bergen and Trondheim city bikes. Keyless GBFS; the proxy sends the `Client-Identifier` header the operators ask for | [NLOD 2.0](https://data.norge.no/nlod/en/2.0) — attribution REQUIRED ([Oslo](https://oslobysykkel.no/en/open-data/realtime), [Bergen](https://bergenbysykkel.no/en/apne-data/sanntid), [Trondheim](https://trondheimbysykkel.no/en/open-data/realtime)) | "Contains data under the Norwegian licence for Open Government data (NLOD) made available by Oslo Bysykkel, Bergen Bysykkel and Trondheim Bysykkel" | | **Kolumbus / Farte Bysykkel** (Entur Mobility, `api.entur.io/mobility/v2/gbfs`) | Bikeshare availability, Kolumbus Bysykkel (Stavanger/Nord-Jæren) and Farte Bysykkel (Skien/Porsgrunn). Keyless GBFS; the proxy sends the `ET-Client-Name` header Entur requires and is limited to Entur's GBFS paths | [NLOD](https://data.norge.no/nlod/en/2.0) per the [Entur Mobility API docs](https://developer.entur.org/pages-mobility-docs-mobility-v2) — attribution REQUIRED | "Contains data under the Norwegian licence for Open Government data (NLOD) made available by Entur (Kolumbus Bysykkel, Farte Bysykkel)" | -| **JCDecaux Cyclocity** (`api.cyclocity.fr`) | Bikeshare availability, Bysykkel Lillestrøm. Keyless GBFS; the proxy is limited to JCDecaux contract paths | [Licence Ouverte / Etalab](https://developer.jcdecaux.com/files/Open-Licence-fr.pdf) (the feed's `license_url`) — attribution REQUIRED | "Bysykkel Lillestrøm: JCDecaux, Licence Ouverte" | +| **JCDecaux Cyclocity** (`api.cyclocity.fr`) | Bikeshare availability: Bysykkel Lillestrøm, Valenbisi (Valencia) and Sevici (Seville). Keyless GBFS; the proxy is limited to JCDecaux contract paths | [Licence Ouverte / Etalab](https://developer.jcdecaux.com/files/Open-Licence-fr.pdf) (each feed's `license_url`) — attribution REQUIRED | "Bysykkel Lillestrøm, Valenbisi, Sevici: JCDecaux, Licence Ouverte" | | **MBTA / MassDOT** (cdn.mbta.com) | Transit layer live vehicles and up to 15 minutes of recently observed positions, Greater Boston | MBTA / MassDOT open realtime vehicle data, used for the live view and up to fifteen minutes of recently observed positions; published for developer use and not against their use. | Attribution to MBTA / MassDOT (courtesy text) | | **CapMetro** (data.texas.gov) | Transit layer live vehicles, Austin TX | CapMetro open realtime vehicle data, used for the live view; published for developer use and not against their use. | Attribution to CapMetro (courtesy text) | | **Metro Transit** (Metropolitan Council) | Transit layer live vehicles, Minneapolis–St Paul | Metro Transit / Metropolitan Council open realtime vehicle data, used for the live view; published for developer use and not against their use. | Attribution to Metro Transit / Metropolitan Council (courtesy text) | diff --git a/changelog.d/1190-bikeshare-spain-cities.md b/changelog.d/1190-bikeshare-spain-cities.md new file mode 100644 index 000000000..f07faa813 --- /dev/null +++ b/changelog.d/1190-bikeshare-spain-cities.md @@ -0,0 +1,6 @@ +- Add nine Spanish city bike systems to Bike Share and `get_bike_share`: BiciMAD + (Madrid), Valenbisi (Valencia), Bizi Zaragoza, Sevici (Seville), BIKI + (Valladolid), BiciCoruña, Dbizi (Donostia-San Sebastián), Bilbao Bizi and + BicinRivas, about 1,840 stations. Station names read underscores as word + breaks, and station capacity counts docks rather than PBSC's geofenced area + (ulihhhh, #1190). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c09a8a645..a7e191f8a 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1277,7 +1277,13 @@ String arrays regardless of the reported feed version. Nonempty English text (including regional `en-*` tags, case-insensitive) takes precedence, then the first usable translation. An unusable `name` falls back to `short_name` with the same rules; if neither supplies text, the station remains available with an empty name -(the globe uses its station-ID label and the tool returns `null`). Malformed +(the globe uses its station-ID label and the tool returns `null`). A run of underscores, +with the spaces around it, reads as one word break and the ends are trimmed: +JCDecaux's Spanish contracts publish `_PLAZA_MUSICO_LOPEZ_CHAVARRI`. Other spacing +stays as published. Capacity counts docks: PBSC adds the bikes a station accepts +in its geofenced area to `capacity` and reports that part as `geofenced_capacity` +(Bilbao Bizi: 839 = 39 docks + 800), so the layer, its colour ratio and +`get_bike_share` read `capacity − geofenced_capacity` when the extension is present. Malformed translation entries do not interrupt loading of the other stations. This covers station names, not complete GBFS v3 support. `scripts/qa-gbfs-names.mjs` checks synthetic localized names through the real layer, @@ -3399,7 +3405,7 @@ its criteria cannot be silently ignored. | CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) | | Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh | | Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) | -| Bikeshare 🚲 | GBFS (Lyft + BCycle + Social Bicycles + PBSC incl. Bike Share Toronto, Bike Itaú (Brazil) and Bicing (Barcelona) + Urban Sharing, Entur, JCDecaux NO + nextbike (AMBici)) | `src/data/bikeshare.js` | `/api/gbfs` | 60s | +| Bikeshare 🚲 | GBFS (Lyft + BCycle + Social Bicycles + PBSC incl. Bike Share Toronto, Bike Itaú (Brazil), Bicing (Barcelona) and seven other Spanish cities + Urban Sharing, Entur, JCDecaux (Lillestrøm, Valencia, Seville) + nextbike (AMBici)) | `src/data/bikeshare.js` | `/api/gbfs` | 60s | | Directions 🧭 | OSRM on FOSSGIS servers (OpenStreetMap) | `src/data/directions.js` | `/api/route` (`steps=1`) | on placement / mode change | | Datacenters ▣ | OSM extract (bundled) | `src/data/localLayers.js` | — | static | | Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/localLayers.js` | — | static | diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index c13c7dd94..8d3949351 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -305,7 +305,10 @@ export const DATA_CREDITS = [ html: 'Bikeshare availability: GBFS operator feeds ' + '(e.g. Austin BCycle; Bike Share Toronto, operated by the Toronto Parking Authority; Bike Itaú and Tembici Bogotá, operated by Tembici; ' + - 'Bicing, Barcelona City Council (B:SM); AMBici, Àrea Metropolitana de Barcelona, operated by nextbike)', + 'Bicing, Barcelona City Council (B:SM); AMBici, Àrea Metropolitana de Barcelona, operated by nextbike; ' + + 'BiciMAD, Powered by EMT de Madrid; ' + + 'Bizi Zaragoza; BIKI, AUVASA (Valladolid); BiciCoruña; Dbizi (Donostia-San Sebastián); ' + + 'Bilbao Bizi, Bilbao City Council; BicinRivas, Rivas-Vaciamadrid City Council)', }, { key: 'urbansharing-gbfs', @@ -324,7 +327,7 @@ export const DATA_CREDITS = [ { key: 'jcdecaux-gbfs', html: - 'City bikes (Lillestrøm): Bysykkel Lillestrøm, JCDecaux — ' + + 'City bikes (Lillestrøm, Valencia, Seville): Bysykkel Lillestrøm, Valenbisi, Sevici, JCDecaux — ' + 'Licence Ouverte', }, { diff --git a/src/layers/bikeshare/registry.js b/src/layers/bikeshare/registry.js index b56678187..9b3c29f20 100644 --- a/src/layers/bikeshare/registry.js +++ b/src/layers/bikeshare/registry.js @@ -604,6 +604,114 @@ const RAW_GBFS_CITY_REGISTRY = [ 'https://gbfs.nextbike.net/maps/gbfs/v2/nextbike_bs/en/station_status.json', provider: 'AMBici', }, + { + id: 'madrid-bicimad', + city: 'Madrid, Spain', + centerLat: 40.4241, + centerLon: -3.6825, + loadRadiusKm: 40, + stationInformationUrl: + 'https://madrid.publicbikesystem.net/customer/gbfs/v2/en/station_information.json', + stationStatusUrl: + 'https://madrid.publicbikesystem.net/customer/gbfs/v2/en/station_status.json', + provider: 'BiciMAD', + }, + { + id: 'valencia-valenbisi', + city: 'Valencia, Spain', + centerLat: 39.4706, + centerLon: -0.3749, + loadRadiusKm: 40, + stationInformationUrl: + 'https://api.cyclocity.fr/contracts/valence/gbfs/station_information.json', + stationStatusUrl: + 'https://api.cyclocity.fr/contracts/valence/gbfs/station_status.json', + provider: 'Valenbisi', + }, + { + id: 'zaragoza-bizi', + city: 'Zaragoza, Spain', + centerLat: 41.6481, + centerLon: -0.8927, + loadRadiusKm: 40, + stationInformationUrl: + 'https://zaragoza.publicbikesystem.net/customer/gbfs/v2/en/station_information.json', + stationStatusUrl: + 'https://zaragoza.publicbikesystem.net/customer/gbfs/v2/en/station_status.json', + provider: 'Bizi Zaragoza', + }, + { + id: 'sevilla-sevici', + city: 'Seville, Spain', + centerLat: 37.3726, + centerLon: -5.9607, + loadRadiusKm: 40, + stationInformationUrl: + 'https://api.cyclocity.fr/contracts/seville/gbfs/station_information.json', + stationStatusUrl: + 'https://api.cyclocity.fr/contracts/seville/gbfs/station_status.json', + provider: 'Sevici', + }, + { + id: 'valladolid-biki', + city: 'Valladolid, Spain', + centerLat: 41.6233, + centerLon: -4.7382, + loadRadiusKm: 40, + stationInformationUrl: + 'https://valladolid.publicbikesystem.net/customer/gbfs/v2/en/station_information.json', + stationStatusUrl: + 'https://valladolid.publicbikesystem.net/customer/gbfs/v2/en/station_status.json', + provider: 'BIKI', + }, + { + id: 'a-coruna-bicicoruna', + city: 'A Coruña, Spain', + centerLat: 43.3534, + centerLon: -8.4156, + loadRadiusKm: 40, + stationInformationUrl: + 'https://acoruna.publicbikesystem.net/customer/gbfs/v2/en/station_information.json', + stationStatusUrl: + 'https://acoruna.publicbikesystem.net/customer/gbfs/v2/en/station_status.json', + provider: 'BiciCoruña', + }, + { + id: 'donostia-dbizi', + city: 'Donostia-San Sebastián, Spain', + centerLat: 43.3075, + centerLon: -1.9696, + loadRadiusKm: 40, + stationInformationUrl: + 'https://sansebastian.publicbikesystem.net/customer/gbfs/v2/en/station_information.json', + stationStatusUrl: + 'https://sansebastian.publicbikesystem.net/customer/gbfs/v2/en/station_status.json', + provider: 'Dbizi', + }, + { + id: 'bilbao-bilbaobizi', + city: 'Bilbao, Spain', + centerLat: 43.2624, + centerLon: -2.9367, + loadRadiusKm: 40, + stationInformationUrl: + 'https://bilbao.publicbikesystem.net/customer/gbfs/v2/en/station_information.json', + stationStatusUrl: + 'https://bilbao.publicbikesystem.net/customer/gbfs/v2/en/station_status.json', + provider: 'Bilbao Bizi', + }, + { + id: 'rivas-bicinrivas', + city: 'Rivas-Vaciamadrid, Spain', + centerLat: 40.3535, + centerLon: -3.5319, + loadRadiusKm: 40, + stationInformationUrl: + 'https://rivas.publicbikesystem.net/customer/gbfs/v2/en/station_information.json', + stationStatusUrl: + 'https://rivas.publicbikesystem.net/customer/gbfs/v2/en/station_status.json', + provider: 'BicinRivas', + }, ]; function normalizeRegistryEntry(entry) { const id = String(entry?.id || '') diff --git a/src/layers/bikeshare/spainCitiesSystems.test.mjs b/src/layers/bikeshare/spainCitiesSystems.test.mjs new file mode 100644 index 000000000..2c502456f --- /dev/null +++ b/src/layers/bikeshare/spainCitiesSystems.test.mjs @@ -0,0 +1,256 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { CITY_BY_ID } from './registry.js'; +import { createIngestion } from './ingestion.js'; +import { createModel } from './model.js'; +import { createViewport } from './viewport.js'; +import { isAllowedGbfsHost, isAllowedGbfsPath } from '../../data/gbfsSource.js'; + +// Each system's host, its stations farthest from the registered centre in +// opposite directions, and the stations its live feed lists away from the +// city: a workshop at (0,0), a lab in Madrid, planned sites with no position +// yet (checked 2026-10-17). JCDecaux names keep their feed spelling here. +const SYSTEMS = { + 'madrid-bicimad': { + host: 'madrid.publicbikesystem.net', + provider: 'BiciMAD', + stations: [ + { + name: '627 - Alcalde Andrés Madrid Dávila, 92', + lat: 40.398303, + lon: -3.548534, + }, + { + name: '672 - Estación de tren de El Barrial-Pozuelo', + lat: 40.464908, + lon: -3.807764, + }, + ], + }, + 'valencia-valenbisi': { + host: 'api.cyclocity.fr', + provider: 'Valenbisi', + stations: [ + { name: '_CAMPAMENTO_81', lat: 39.499875, lon: -0.426354 }, + { name: '_EDIFICIO_VELES_E_VENTS', lat: 39.461943, lon: -0.323856 }, + ], + }, + 'zaragoza-bizi': { + host: 'zaragoza.publicbikesystem.net', + provider: 'Bizi Zaragoza', + stations: [ + { name: '231- Heroínas Sitios: Gómara', lat: 41.627549, lon: -0.962297 }, + { name: '263- Avda. Real Zaragoza', lat: 41.666994, lon: -0.823078 }, + ], + }, + 'sevilla-sevici': { + host: 'api.cyclocity.fr', + provider: 'Sevici', + stations: [ + { name: '_ESTADIO OLIMPICO', lat: 37.415898, lon: -6.005849 }, + { name: '_HOSPITAL VALME', lat: 37.320325, lon: -5.969204 }, + ], + strays: [ + { + station_id: '298', + name: '_LAB MADRID', + lat: 40.448779, + lon: -3.584983, + }, + { station_id: '300', name: '_TALLER SEVILLA', lat: 0, lon: 0 }, + ], + }, + 'valladolid-biki': { + host: 'valladolid.publicbikesystem.net', + provider: 'BIKI', + stations: [ + { name: '1002 Puente Duero', lat: 41.55413, lon: -4.79122 }, + { name: '1005 Santovenia Centro Civico', lat: 41.692504, lon: -4.688387 }, + ], + }, + 'a-coruna-bicicoruna': { + host: 'acoruna.publicbikesystem.net', + provider: 'BiciCoruña', + stations: [ + { name: 'Novo Mesoiro III', lat: 43.323367, lon: -8.443926 }, + { name: 'Adormideras', lat: 43.3833748, lon: -8.3956482 }, + ], + strays: [ + { station_id: '24', name: 'Playa de Oza', lat: 0, lon: 0 }, + { station_id: '37', name: 'Campus Elviña 2', lat: 0, lon: 0 }, + { station_id: '38', name: 'Campus Zapateira', lat: 0, lon: 0 }, + ], + }, + 'donostia-dbizi': { + host: 'sansebastian.publicbikesystem.net', + provider: 'Dbizi', + stations: [ + { name: '59 - BUENAVISTA', lat: 43.321211, lon: -1.922239 }, + { name: '54 - BERIO 42', lat: 43.305158, lon: -2.016997 }, + ], + }, + 'bilbao-bilbaobizi': { + host: 'bilbao.publicbikesystem.net', + provider: 'Bilbao Bizi', + stations: [ + { name: 'ZORROTZAKO KIROLDEGIA', lat: 43.2778696, lon: -2.9735575 }, + { name: 'OTXARKOAGA', lat: 43.258515, lon: -2.899282 }, + ], + }, + 'rivas-bicinrivas': { + host: 'rivas.publicbikesystem.net', + provider: 'BicinRivas', + stations: [ + { + name: 'Polideportivo Parque Sureste', + lat: 40.3236695, + lon: -3.5127191, + }, + { name: 'OCÉANO ÁRTICO', lat: 40.383292, lon: -3.5343627 }, + ], + }, +}; + +// The production range check, with no viewer or fetches behind it. +const parts = { + model: createModel({ state: {}, services: {}, parts: {}, source: null }), +}; +const { computeInRangeCities } = createViewport({ + state: {}, + services: { render: { governorRequestRender() {} } }, + parts, + source: null, +}); + +/** Station information for `id` through the real ingestion path. */ +async function loadThroughIngestion(id, stations) { + const city = CITY_BY_ID.get(id); + const state = { + _stationInfoCache: new Map(), + _inFlightInfo: new Map(), + _inFlightStatus: new Map(), + _loadingOps: 0, + _loading: false, + }; + const ingestion = createIngestion({ + state, + services: {}, + parts, + source: { + async getStations(url) { + assert.equal(url, city.stationInformationUrl); + return { data: { stations } }; + }, + }, + }); + return ingestion.loadCityStationInfo(id, 1); +} + +test('the Spanish city systems are registered and readable through the proxy', () => { + for (const [id, expected] of Object.entries(SYSTEMS)) { + const system = CITY_BY_ID.get(id); + assert.ok(system, `${id} is registered`); + assert.equal(system.provider, expected.provider); + assert.deepEqual(system.hosts, [expected.host]); + for (const feed of [ + system.stationInformationUrl, + system.stationStatusUrl, + ]) { + const url = new URL(feed); + assert.ok(isAllowedGbfsHost(url.hostname), `${id} host ${url.hostname}`); + assert.ok( + isAllowedGbfsPath(url.pathname, url.hostname), + `${id} path ${url.pathname}`, + ); + } + } +}); + +test('a camera over each outermost station activates its system', () => { + for (const [id, expected] of Object.entries(SYSTEMS)) { + for (const station of expected.stations) { + assert.ok( + computeInRangeCities({ lat: station.lat, lon: station.lon }).has(id), + `${station.name} activates ${id}`, + ); + } + } +}); + +test('each load radius reaches its outermost stations', () => { + // The range check floors every radius at 100 km, but station information is + // trimmed to the radius as registered, so it has to reach every station. + for (const [id, expected] of Object.entries(SYSTEMS)) { + const system = CITY_BY_ID.get(id); + for (const station of expected.stations) { + const distance = parts.model.haversineKm( + system.centerLat, + system.centerLon, + station.lat, + station.lon, + ); + assert.ok( + distance <= system.loadRadiusKm, + `${station.name} is ${distance.toFixed(1)} km from the ${id} centre`, + ); + } + } +}); + +test('a camera over central Madrid activates BiciMAD and BicinRivas', () => { + // Puerta del Sol. + const active = computeInRangeCities({ lat: 40.4169, lon: -3.7035 }); + assert.ok(active.has('madrid-bicimad')); + assert.ok(active.has('rivas-bicinrivas')); +}); + +test('stations a feed lists away from its city are not loaded', async () => { + for (const [id, expected] of Object.entries(SYSTEMS)) { + if (!expected.strays) continue; + const local = expected.stations.map((station, index) => ({ + station_id: `local-${index}`, + ...station, + })); + const stations = await loadThroughIngestion(id, [ + ...local, + ...expected.strays, + ]); + assert.deepEqual( + [...stations.keys()], + local.map((station) => station.station_id), + `${id} keeps only its local stations`, + ); + } +}); + +test('JCDecaux station names reach the layer without underscores', async () => { + for (const id of ['valencia-valenbisi', 'sevilla-sevici']) { + const stations = await loadThroughIngestion( + id, + SYSTEMS[id].stations.map((station, index) => ({ + station_id: String(index), + ...station, + })), + ); + for (const station of stations.values()) + assert.doesNotMatch(station.name, /_/, `${id}: ${station.name}`); + } + const valenbisi = await loadThroughIngestion('valencia-valenbisi', [ + { station_id: '81', ...SYSTEMS['valencia-valenbisi'].stations[0] }, + ]); + assert.equal(valenbisi.get('81').name, 'CAMPAMENTO 81'); +}); + +test('PBSC station capacity reaches the layer as docks, without the geofenced area', async () => { + // Bilbao Bizi's ARAGOI and a BiciMAD station as published on 2026-10-17. + const [bilbao] = SYSTEMS['bilbao-bilbaobizi'].stations; + const bilbaoStations = await loadThroughIngestion('bilbao-bilbaobizi', [ + { station_id: '1', ...bilbao, capacity: 839, geofenced_capacity: 800 }, + ]); + assert.equal(bilbaoStations.get('1').capacity, 39); + const [madrid] = SYSTEMS['madrid-bicimad'].stations; + const madridStations = await loadThroughIngestion('madrid-bicimad', [ + { station_id: '627', ...madrid, capacity: 47, geofenced_capacity: 20 }, + ]); + assert.equal(madridStations.get('627').capacity, 27); +}); diff --git a/src/sources/gbfsStations.js b/src/sources/gbfsStations.js index 74f2efd23..c37c2fae4 100644 --- a/src/sources/gbfsStations.js +++ b/src/sources/gbfsStations.js @@ -51,15 +51,25 @@ export function extractStationsArray(payload) { return []; } +/** + * Station name text as a reader expects it. JCDecaux's Spanish contracts + * (Valenbisi, Sevici) prefix every name with "_" and Valenbisi joins words + * with it ("_PLAZA_MUSICO_LOPEZ_CHAVARRI"), so an underscore reads as a word + * break. Other spacing is the operator's own and stays as published. + */ +function tidyStationName(text) { + return text.replace(/\s*_+\s*/g, ' ').trim(); +} + /** Read legacy text or a GBFS v3 Localized String, preferring usable English text. */ function stationName(value) { - if (typeof value === 'string') return value.trim(); + if (typeof value === 'string') return tidyStationName(value); if (!Array.isArray(value)) return ''; let fallback = ''; for (const translation of value) { if (typeof translation?.text !== 'string') continue; - const text = translation.text.trim(); + const text = tidyStationName(translation.text); if (!text) continue; if (!fallback) fallback = text; if ( @@ -71,6 +81,23 @@ function stationName(value) { return fallback; } +/** + * Docks a station physically has. PBSC adds the bikes a station accepts in its + * geofenced area around the docks to `capacity` and reports that part as + * `geofenced_capacity` (not a GBFS field): Bilbao Bizi publishes 39 docks as + * capacity 839 with geofenced_capacity 800. Without the extension, or when it + * would exceed the total, `capacity` is read as given. PBSC also counts the + * bikes parked in that area (`num_bikes_overflow`) in `num_bikes_available`, so + * a crowded station can report more bikes than docks. + */ +function dockCapacity(raw) { + const capacity = toNonNegativeInteger(raw?.capacity); + const geofenced = toNonNegativeInteger(raw?.geofenced_capacity); + if (capacity == null || geofenced == null || geofenced > capacity) + return capacity; + return capacity - geofenced; +} + /** * Parse a GBFS station_information payload into a Map of station metadata. * Handles field-name variations across different GBFS providers @@ -95,7 +122,7 @@ export function parseStationInformation(payload) { name: stationName(raw?.name) || stationName(raw?.short_name), lat, lon, - capacity: toNonNegativeInteger(raw?.capacity), + capacity: dockCapacity(raw), isInstalled: normalizeGbfsBool(raw?.is_installed, true), isRenting: normalizeGbfsBool(raw?.is_renting, true), isReturning: normalizeGbfsBool(raw?.is_returning, true), diff --git a/src/sources/gbfsStations.test.mjs b/src/sources/gbfsStations.test.mjs index 8c60da90c..91983df98 100644 --- a/src/sources/gbfsStations.test.mjs +++ b/src/sources/gbfsStations.test.mjs @@ -15,6 +15,25 @@ const parseName = (fields, version) => data: { stations: [station(fields)] }, }).get('s1').name; +const parseCapacity = (fields) => + parseStationInformation({ data: { stations: [station(fields)] } }).get('s1') + .capacity; + +test('capacity counts docks, not the geofenced area PBSC adds to it', () => { + // Bilbao Bizi, BiciMAD and Dbizi as published on 2026-10-17. + assert.equal(parseCapacity({ capacity: 839, geofenced_capacity: 800 }), 39); + assert.equal(parseCapacity({ capacity: 43, geofenced_capacity: 20 }), 23); + assert.equal(parseCapacity({ capacity: 19, geofenced_capacity: 0 }), 19); +}); + +test('capacity is read as given without a usable geofenced_capacity', () => { + assert.equal(parseCapacity({ capacity: 27 }), 27); + assert.equal(parseCapacity({ capacity: 27, geofenced_capacity: null }), 27); + assert.equal(parseCapacity({ capacity: 27, geofenced_capacity: 'n/a' }), 27); + assert.equal(parseCapacity({ capacity: 10, geofenced_capacity: 30 }), 10); + assert.equal(parseCapacity({ capacity: null, geofenced_capacity: 5 }), null); +}); + test('GBFS v2 station names remain trimmed strings', () => { assert.equal( parseName({ name: ' Republic Square ', short_name: 'Short' }, '2.3'), @@ -22,6 +41,42 @@ test('GBFS v2 station names remain trimmed strings', () => { ); }); +test('underscores in station names read as word breaks', () => { + // As published by JCDecaux's Spanish contracts (checked 2026-10-17). + for (const [published, shown] of [ + ['_PLAZA_MUSICO_LOPEZ_CHAVARRI', 'PLAZA MUSICO LOPEZ CHAVARRI'], + ['_GLORIETA OLIMPICA', 'GLORIETA OLIMPICA'], + ['_CAMPAMENTO_81', 'CAMPAMENTO 81'], + ['Plaza__Mayor_ ', 'Plaza Mayor'], + ]) { + assert.equal(parseName({ name: published }), shown); + } +}); + +test('spacing without underscores stays as the operator published it', () => { + assert.equal(parseName({ name: 'C/ NÀPOLS, 82' }), 'C/ NÀPOLS, 82'); +}); + +test('a name of only underscores falls back to short_name, then to empty', () => { + assert.equal(parseName({ name: '_', short_name: '_S_1_' }), 'S 1'); + assert.equal(parseName({ name: '__', short_name: '_' }), ''); +}); + +test('underscores are read the same in GBFS v3 localized names', () => { + assert.equal( + parseName( + { + name: [ + { language: 'es', text: '_' }, + { language: 'en', text: '_COLON_I' }, + ], + }, + '3.0', + ), + 'COLON I', + ); +}); + test('GBFS v3 station names prefer English over the first translation', () => { assert.equal( parseName(