From 23df77637493e2f3bee07e360827ea28c813baa4 Mon Sep 17 00:00:00 2001 From: ulihhhh <54112476+ulihhhh@users.noreply.github.com> Date: Sat, 17 Oct 2026 02:02:38 +0200 Subject: [PATCH] feat(bikeshare): add nine Spanish city bike systems MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the station-based public bike systems of seven Spanish cities on PBSC and two on JCDecaux, 1,843 stations in all (live feeds, 2026-10-17): - PBSC (`.publicbikesystem.net`, GBFS v2 like Bicing): BiciMAD (Madrid), Bizi Zaragoza, BIKI (Valladolid), BiciCoruña, Dbizi (Donostia-San Sebastián), Bilbao Bizi and BicinRivas (Rivas-Vaciamadrid). - JCDecaux (`api.cyclocity.fr/contracts/…`, like Lillestrøm): Valenbisi (Valencia) and Sevici (Seville). Both hosts are already allowed by the proxy. Every system uses a 40 km load radius; the farthest station is 11.7 km from its centre (BiciMAD). The radius also keeps out what two feeds list away from their city: Sevici's workshop at (0,0) and lab in Madrid, and BiciCoruña's planned stations with no position yet. spainCitiesSystems.test.mjs checks the registry and proxy for each feed, that the outermost live stations activate their system and sit inside its radius, that central Madrid activates BiciMAD and BicinRivas, that the far-away stations are not loaded through the real ingestion path, and that JCDecaux names arrive without underscores and PBSC capacities as docks. Co-Authored-By: Claude Opus 5.5 --- src/layers/bikeshare/registry.js | 108 ++++++++ .../bikeshare/spainCitiesSystems.test.mjs | 256 ++++++++++++++++++ 2 files changed, 364 insertions(+) create mode 100644 src/layers/bikeshare/spainCitiesSystems.test.mjs 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); +});