fix(gbfs): exclude stations with invalid coordinates (#1232)

Reject invalid bike-share station coordinates while preserving genuine zero coordinates and numeric aliases.

Contribution by @Bortlesboat.
This commit is contained in:
Sameh Khamis authored and GitHub committed 2026-10-25 16:47:51 -07:00
commit 5103ff672c
5 files changed
+132 -4

No files matched your search

@@ -0,0 +1,3 @@
- Skip bike-share stations with missing, blank or invalid coordinates instead
of placing them at coerced zero coordinates or accepting out-of-range values.
Explicit zero coordinates and numeric strings remain supported (Bortlesboat, #1232).
+5 -1
View File
@@ -1531,7 +1531,11 @@ camera is refused there with a JSON 404 and is served solely through
## GBFS upstream bounds ## GBFS upstream bounds
Station information is normalized in `src/sources/gbfsStations.js` for both the Station information is normalized in `src/sources/gbfsStations.js` for both the
globe layer and `get_bike_share`. Names accept legacy strings or GBFS v3 Localized globe layer and `get_bike_share`. Stations with missing, blank, nonnumeric or
out-of-range coordinates (latitude outside ±90, longitude outside ±180) are
skipped. Numbers and nonblank numeric strings are accepted, including genuine
zero and the `latitude`/`longitude` aliases; other JSON types are rejected.
Names accept legacy strings or GBFS v3 Localized
String arrays regardless of the reported feed version. Nonempty English text String arrays regardless of the reported feed version. Nonempty English text
(including regional `en-*` tags, case-insensitive) takes precedence, then the first (including regional `en-*` tags, case-insensitive) takes precedence, then the first
usable translation. An unusable `name` falls back to `short_name` with the same usable translation. An unusable `name` falls back to `short_name` with the same
+17 -3
View File
@@ -98,6 +98,13 @@ function dockCapacity(raw) {
return capacity - geofenced; return capacity - geofenced;
} }
/** Read a numeric coordinate without treating blank text or other JSON types as zero. */
function coordinateNumber(value) {
if (typeof value !== 'number' && (typeof value !== 'string' || !value.trim()))
return NaN;
return Number(value);
}
/** /**
* Parse a GBFS station_information payload into a Map of station metadata. * Parse a GBFS station_information payload into a Map of station metadata.
* Handles field-name variations across different GBFS providers * Handles field-name variations across different GBFS providers
@@ -113,9 +120,16 @@ export function parseStationInformation(payload) {
for (const raw of stations) { for (const raw of stations) {
// Accept both GBFS (station_id) and alternate (id) field names // Accept both GBFS (station_id) and alternate (id) field names
const stationId = String(raw?.station_id ?? raw?.id ?? '').trim(); const stationId = String(raw?.station_id ?? raw?.id ?? '').trim();
const lat = Number(raw?.lat ?? raw?.latitude); const lat = coordinateNumber(raw?.lat ?? raw?.latitude);
const lon = Number(raw?.lon ?? raw?.longitude); const lon = coordinateNumber(raw?.lon ?? raw?.longitude);
if (!stationId || !Number.isFinite(lat) || !Number.isFinite(lon)) continue; if (
!stationId ||
!Number.isFinite(lat) ||
!Number.isFinite(lon) ||
Math.abs(lat) > 90 ||
Math.abs(lon) > 180
)
continue;
stationMap.set(stationId, { stationMap.set(stationId, {
stationId, stationId,
+67
View File
@@ -19,6 +19,73 @@ const parseCapacity = (fields) =>
parseStationInformation({ data: { stations: [station(fields)] } }).get('s1') parseStationInformation({ data: { stations: [station(fields)] } }).get('s1')
.capacity; .capacity;
test('station information skips missing, blank and malformed coordinates', () => {
for (const field of ['lat', 'lon', 'latitude', 'longitude']) {
for (const value of [
undefined,
null,
'',
' \t ',
false,
true,
[],
[0],
{},
'bad',
NaN,
Infinity,
]) {
const raw = { station_id: 'bad', lat: 0, lon: 0 };
if (field === 'latitude') delete raw.lat;
if (field === 'longitude') delete raw.lon;
raw[field] = value;
const parsed = parseStationInformation({
data: { stations: [raw, station({ station_id: 'good' })] },
});
assert.deepEqual(
[...parsed.keys()],
['good'],
`${field} = ${JSON.stringify(value)}`,
);
}
}
});
test('station information skips coordinates outside latitude and longitude bounds', () => {
for (const fields of [
{ lat: 90.001 },
{ lat: -90.001 },
{ lon: 180.001 },
{ lon: -180.001 },
{ lat: undefined, latitude: '91' },
{ lon: undefined, longitude: '-181' },
]) {
const parsed = parseStationInformation({
data: { stations: [station(fields)] },
});
assert.equal(parsed.size, 0, JSON.stringify(fields));
}
});
test('station information preserves zero, boundary coordinates and numeric aliases', () => {
for (const [fields, expected] of [
[{ lat: 0, lon: 0 }, [0, 0]],
[{ lat: ' 0 ', lon: '0' }, [0, 0]],
[{ lat: 90, lon: 180 }, [90, 180]],
[{ lat: '-90', lon: '-180' }, [-90, -180]],
[
{ lat: null, lon: undefined, latitude: '30.2681', longitude: '-97.7469' },
[30.2681, -97.7469],
],
]) {
const parsed = parseStationInformation({
data: { stations: [station(fields)] },
}).get('s1');
assert.ok(parsed, JSON.stringify(fields));
assert.deepEqual([parsed.lat, parsed.lon], expected);
}
});
test('capacity counts docks, not the geofenced area PBSC adds to it', () => { test('capacity counts docks, not the geofenced area PBSC adds to it', () => {
// Bilbao Bizi, BiciMAD and Dbizi as published on 2026-10-17. // Bilbao Bizi, BiciMAD and Dbizi as published on 2026-10-17.
assert.equal(parseCapacity({ capacity: 839, geofenced_capacity: 800 }), 39); assert.equal(parseCapacity({ capacity: 839, geofenced_capacity: 800 }), 39);
+40
View File
@@ -171,6 +171,46 @@ test('bike share reports uncovered areas and failed systems honestly', async ()
); );
}); });
test('bike share excludes blank coordinates without losing stations on the prime meridian', async () => {
const stations = [
{ station_id: 'blank', lat: 51.5, lon: ' ' },
{ station_id: 'zero', lat: 51.5, lon: 0 },
];
const catalog = composeCatalog({
tools: coreTools,
services: {
bikeshare: {
systems: [system('fixture', 'Fixture Bikes', 51.5, 0)],
async getStations(url) {
return {
data: {
stations: url.endsWith('station_information.json')
? stations
: stations.map(({ station_id }) => ({
station_id,
num_bikes_available: 4,
})),
},
};
},
},
},
});
const result = await catalog.call('get_bike_share', {
area: { lat: 51.5, lon: 0, radius_km: 2 },
});
assert.deepEqual(
result.data.rows.map((row) => row.station_id),
['zero'],
);
assert.equal(result.data.total, 1);
assert.equal(result.data.rows[0].lon, 0);
assert.equal(
result.summary,
'1 bike-share station in 2 km around 51.500, 0.000 with 4 bikes available.',
);
});
const feed = { const feed = {
id: 'capmetro', id: 'capmetro',
name: 'CapMetro', name: 'CapMetro',