fix(tours): read e-bike chargers as mapped, keep unnamed ones on the map search

This commit is contained in:
Maurice
2026-10-21 12:05:38 +02:00
parent 1a722e63fb
commit 4eaf5e6a11
36 changed files with 77 additions and 7 deletions
+9 -2
View File
@@ -364,16 +364,23 @@ interface BikeChargingInfo extends ChargingInfo {
*/
const BIKE_SOCKET_FAMILIES = ['schuko', 'typee', 'bs1363', 'sev1011_t13', 'sev1011_t23', 'cee_blue', 'type2'] as const;
function statesNone(raw: string | undefined): boolean {
return raw?.trim() === 'no' || Number.parseInt(raw ?? '', 10) === 0;
}
/**
* What an e-bike charging point states, or null when it is not tagged for bicycles:
* the search asks for `bicycle=yes|designated`, and a point read here without either
* is never passed off as one. Sockets, capacity and fee are read as on a car charger.
* is never passed off as one. Sockets, capacity and fee are read as on a car charger,
* except that a plug mapped as `no` or `0` is left out: a bike charger shows its plugs
* as compatibility, and an explicit "none" must not read as one.
*/
export function readBikeChargingInfo(tags: Record<string, string>): BikeChargingInfo | null {
const bicycle = tags.bicycle;
if (bicycle !== 'yes' && bicycle !== 'designated') return null;
const families = BIKE_SOCKET_FAMILIES.filter((family) => !statesNone(tags[`socket:${family}`]));
return {
sockets: readSockets(tags, BIKE_SOCKET_FAMILIES),
sockets: readSockets(tags, families),
capacity: readCapacity(tags),
fee: readFee(tags),
bicycle,
+1 -1
View File
@@ -130,7 +130,7 @@ export class MapsMcp {
@Tool({
name: 'search_pois',
description:
'List points of interest of one category inside a map rectangle, with address, opening hours, website, phone and cuisine wherever the source carries them. Answers from the TREK place index where it can and from OpenStreetMap otherwise; each result names which one it came from in its `source` field. A charging station also reports its sockets: which plug families it has, how many of each and at what power, plus how many vehicles fit and whether it charges a fee. OSM states a socket type on roughly a third of them and a power on fewer, so treat a missing figure as unknown rather than as absent. ebike_charging finds only charging points tagged for bicycles, and reports beside the sockets whether they are made for bikes or let them charge (bicycle) and who may use them (access). This is the discovery tool: use it to answer "what is around here" for a neighbourhood or a whole city district. Prefer search_place when the user already named the place they mean. Never calls Google, so it costs nothing and works on an instance with no Places key.',
'List points of interest of one category inside a map rectangle, with address, opening hours, website, phone and cuisine wherever the source carries them. Answers from the TREK place index where it can and from OpenStreetMap otherwise; each result names which one it came from in its `source` field. A charging station also reports its sockets: which plug families it has, how many of each and at what power, plus how many vehicles fit and whether it charges a fee. OSM states a socket type on roughly a third of them and a power on fewer, so treat a missing figure as unknown rather than as absent. ebike_charging finds only charging points tagged for bicycles, many of them unnamed (an empty name), and reports beside the sockets whether they are made for bikes or let them charge (bicycle) and who may use them (access). This is the discovery tool: use it to answer "what is around here" for a neighbourhood or a whole city district. Prefer search_place when the user already named the place they mean. Never calls Google, so it costs nothing and works on an instance with no Places key.',
inputSchema: {
category: z
.union([z.enum(POI_CATEGORY_KEYS), z.array(z.enum(POI_CATEGORY_KEYS)).min(1).max(MAX_POI_CATEGORIES)])
+8 -1
View File
@@ -265,6 +265,13 @@ const KEPT_UNNAMED_ALONG: ReadonlySet<string> = new Set([
'ebike_charging',
]);
/**
* The kinds the map search keeps unnamed. It wants names everywhere else, but most
* e-bike charging points carry neither a name nor an operator, and dropping them
* would answer `search_pois` with a fraction of what the tour search finds.
*/
const KEPT_UNNAMED_EXPLORE: ReadonlySet<string> = new Set(['ebike_charging']);
/**
* What a charging station states, read only where it means something. Every POI
* carries `capacity` and `fee` for its own reasons (a restaurant's capacity is
@@ -779,7 +786,7 @@ export class OsmClient {
// the result count in one round-trip.
const query = `[out:json][timeout:${OVERPASS_QUERY_TIMEOUT_S}];\n(\n${selectors}\n);\nout center tags ${cap + 25};`;
const pois = readPois(await overpassFetch(query), match, osmLang, new Set());
const pois = readPois(await overpassFetch(query), match, osmLang, KEPT_UNNAMED_EXPLORE);
const truncated = pois.length > cap;
const value: PoiSearchResult = { pois: pois.slice(0, cap), source: 'openstreetmap', truncated, clamped };
cachePoiAnswer(cacheKey, value);
@@ -105,6 +105,13 @@ describe('readBikeChargingInfo (BIK-07)', () => {
expect(readBikeChargingInfo({ amenity: 'charging_station', bicycle: 'no' })).toBeNull();
expect(readBikeChargingInfo({ bicycle: 'yes', 'socket:ccs': '2', 'socket:chademo': '1' })?.sockets).toEqual([]);
});
it('MAPS-CHARGE-012: a plug mapped as none is left out, so the point reads as plug not stated', () => {
expect(readBikeChargingInfo({ bicycle: 'yes', 'socket:schuko': 'no' })?.sockets).toEqual([]);
expect(readBikeChargingInfo({ bicycle: 'yes', 'socket:type2': '0', 'socket:typee': '1' })?.sockets).toEqual([
{ type: 'typee', count: 1, kw: null },
]);
});
});
describe('OSM selectors (BIK-07)', () => {
@@ -1074,6 +1074,18 @@ describe('searchOverpassPois localized names (#1655)', () => {
const { pois } = await osm.searchOverpassPois('sights', bbox(4), 'fr-FR');
expect(pois[0].name).toBe('Obelisco della Minerva');
});
it('keeps an unnamed e-bike charging point, as the search along a tour does (BIK-07)', async () => {
stubOverpass({ amenity: 'charging_station', bicycle: 'yes', 'socket:schuko': '2' });
const { pois } = await osm.searchOverpassPois('ebike_charging', bbox(6), 'en-US');
expect(pois.map((p: any) => [p.name, p.category])).toEqual([['', 'ebike_charging']]);
});
it('still drops an unnamed place of any other kind', async () => {
stubOverpass({ tourism: 'attraction' });
const { pois } = await osm.searchOverpassPois('sights', bbox(7), 'en-US');
expect(pois).toEqual([]);
});
});
// ── fetchWikimediaPhoto (fetch stubbed) ───────────────────────────────────────