feat(maps): pass through what OSM knows about a charging station

The corridor query has always ended in `out center tags`, and the projection
has always kept six keys out of the whole tag set. Everything an electric car
needs to know was in the answer and went in the bin: which plug families the
station has, how many of each, at what power, how many vehicles fit, and whether
it charges. Reading them costs no extra request, no key and no second query.

The filters treat "not stated" as its own answer, which is the only honest way
to build them: OSM states a socket type on roughly a third of charging stations
and a power on fewer still, so a power filter keeps every station that says
nothing rather than reading silence as too slow. The plug dropdown offers only
the families this search actually turned up, because CHAdeMO in a list for a
corridor with three Type 2 posts on it is a filter that can only return nothing.

Socket names stay as they are printed on the plug. "Type 2" is not a string
anyone translates, and the lowercase tag key is not what is written on the
charger.

The tool description follows, as parity requires, including the coverage caveat
— a model that reads a missing power as "no fast charging here" would be wrong
about most of the map.
This commit is contained in:
Maurice
2026-09-04 23:25:38 +02:00
committed by Maurice
parent fc74991a15
commit 061259b3d9
31 changed files with 374 additions and 33 deletions
+68
View File
@@ -264,6 +264,8 @@ export interface OverpassPoi {
/** Brand name and its Wikidata id, when OSM carries them — the logo is looked up from the id. */
brand: string | null;
brand_wikidata: string | null;
/** What a charging station offers, when it is one and OSM says. */
charging: ChargingInfo | null;
/**
* Which index the row came from. Overture is not OpenStreetMap: it carries
* OSM among other sources under other licences, so a row from the TREK index
@@ -273,6 +275,72 @@ export interface OverpassPoi {
source: 'openstreetmap' | 'trek-places';
}
/**
* The part of a charging station that decides whether it is any use to a particular car.
*
* Read out of tags the query has always returned and the projection has always thrown
* away: `out center tags` hands back the whole tag set, and only six keys of it were ever
* passed on. Nothing here costs an extra request.
*
* Coverage is the reason this is all optional. Across the charging stations in OSM,
* roughly a third carry a socket type, about seven in ten a capacity, and about half say
* whether they charge a fee. A filter built on it has to treat "not stated" as its own
* answer rather than as a no.
*/
export interface ChargingInfo {
/** One entry per socket family the station lists, with how many and how fast. */
sockets: { type: string; count: number | null; kw: number | null }[];
/** How many vehicles can charge at once, across all sockets. */
capacity: number | null;
/** true = costs money, false = free, null = OSM does not say. */
fee: boolean | null;
}
/**
* OSM writes sockets as one key per family: `socket:type2=4` is the count, and
* `socket:type2:output=22 kW` the power. Both are free text in practice, so the count is
* only taken when it parses as a whole number and the power only when a number can be
* read off the front of it.
*
* The families are listed rather than derived from the tag names, because `socket:` also
* carries keys that are not a socket family at all.
*/
const SOCKET_FAMILIES = [
'type2', 'type2_combo', 'type2_cable', 'ccs', 'chademo', 'type1', 'type1_combo',
'schuko', 'tesla_supercharger', 'tesla_destination',
] as const;
/** Leading number out of a free-text value like "22 kW" or "50kw". */
function leadingNumber(raw: string | undefined): number | null {
if (!raw) return null;
const n = Number.parseFloat(raw.replace(',', '.'));
return Number.isFinite(n) && n > 0 ? n : null;
}
export function readChargingInfo(tags: Record<string, string>): ChargingInfo | null {
const sockets: ChargingInfo['sockets'] = [];
for (const family of SOCKET_FAMILIES) {
const raw = tags[`socket:${family}`];
if (raw === undefined) continue;
const count = Number.parseInt(raw, 10);
sockets.push({
type: family,
count: Number.isInteger(count) && count > 0 ? count : null,
kw: leadingNumber(tags[`socket:${family}:output`]),
});
}
const capacity = Number.parseInt(tags.capacity ?? '', 10);
const fee = tags.fee === 'yes' ? true : tags.fee === 'no' ? false : null;
const info: ChargingInfo = {
sockets,
capacity: Number.isInteger(capacity) && capacity > 0 ? capacity : null,
fee,
};
// Nothing said is null rather than an empty shell, so the client can tell "no data"
// from "no sockets" without inspecting three fields.
return sockets.length || info.capacity !== null || fee !== null ? info : null;
}
// Each pill category → the OSM tag selectors it searches. Keys here are the
// contract with the client's POI_CATEGORIES (same keys, label/icon/colour live
// client-side).
+1 -1
View File
@@ -89,7 +89,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. 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.',
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. 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),
+9
View File
@@ -46,6 +46,7 @@ import {
haversineMetres,
namesOverlap,
mergeSearchResults,
readChargingInfo,
type GoogleOpeningHours,
type OverpassPoi,
} from './maps.helpers';
@@ -869,6 +870,10 @@ export class MapsService {
// gets its own mark here exactly as it does on the Overpass path.
brand: p.brand?.name ?? null,
brand_wikidata: p.brand?.wikidata ?? null,
// Sockets are an OSM thing; the index has no charging fields, so
// a station answered from here reports "not stated" rather than
// claiming it offers nothing.
charging: null,
// The index has no cuisine field, so this null is the truth
// rather than a field being dropped on the way through.
cuisine: null,
@@ -1273,6 +1278,10 @@ export class MapsService {
// Only the plain Q-id form is passed on; anything else would be a lookup we
// would have to guess at.
brand_wikidata: /^Q[0-9]+$/.test(tags['brand:wikidata'] || '') ? tags['brand:wikidata'] : null,
// Only where it means something. Every POI carries `capacity` and `fee` for its
// own reasons — a restaurant's capacity is seats — so reading them as charging
// data anywhere else would be wrong on most of the map.
charging: categoryOfFilter.get(matched) === 'charging' ? readChargingInfo(tags) : null,
source: 'openstreetmap',
});
}
@@ -0,0 +1,64 @@
import { describe, it, expect } from 'vitest';
import { readChargingInfo } from '../../../src/nest/maps/maps.helpers';
/**
* The tag reading that turns an Overpass answer into something a traveller can filter on.
*
* Pure, so it is tested on its own: no request, no cache, no service. The query already
* asks for the whole tag set, and every one of these fields was being dropped on the way
* through.
*/
describe('readChargingInfo', () => {
// The query has always asked for `out center tags` and the projection has always
// thrown these away. Nothing here costs an extra request.
it('MAPS-CHARGE-001: reads the socket families, their count and their power', () => {
expect(readChargingInfo({
'socket:type2': '4',
'socket:type2:output': '22 kW',
'socket:ccs': '2',
'socket:ccs:output': '150',
})).toEqual({
sockets: [
{ type: 'type2', count: 4, kw: 22 },
{ type: 'ccs', count: 2, kw: 150 },
],
capacity: null,
fee: null,
});
});
it('MAPS-CHARGE-002: a socket that states nothing beyond its presence still counts', () => {
// `socket:type2=yes` is common. The family is the useful part; the rest is unknown
// rather than zero, and a filter has to be able to tell those apart.
expect(readChargingInfo({ 'socket:type2': 'yes' })?.sockets).toEqual([
{ type: 'type2', count: null, kw: null },
]);
});
it('MAPS-CHARGE-003: free text power is read off the front, comma or not', () => {
expect(readChargingInfo({ 'socket:type2': '1', 'socket:type2:output': '11kW' })?.sockets[0].kw).toBe(11);
expect(readChargingInfo({ 'socket:type2': '1', 'socket:type2:output': '2,3 kW' })?.sockets[0].kw).toBe(2.3);
expect(readChargingInfo({ 'socket:type2': '1', 'socket:type2:output': 'unknown' })?.sockets[0].kw).toBeNull();
});
it('MAPS-CHARGE-004: fee is three answers, not two', () => {
expect(readChargingInfo({ fee: 'yes' })?.fee).toBe(true);
expect(readChargingInfo({ fee: 'no' })?.fee).toBe(false);
// Half the charging stations in OSM say nothing about it, and "not stated" is not
// "free". A value nobody can act on is read as unknown; with a socket alongside it
// the station still reports, it just does not claim a price.
expect(readChargingInfo({ 'socket:type2': '2', fee: 'interval' })?.fee).toBeNull();
// On its own it says nothing at all, so there is nothing to report.
expect(readChargingInfo({ fee: 'interval' })).toBeNull();
});
it('MAPS-CHARGE-005: a station that says nothing at all is null, not an empty shell', () => {
// So the client can tell "no data" from "no sockets" without inspecting three fields.
expect(readChargingInfo({ amenity: 'charging_station', name: 'Ionity' })).toBeNull();
});
it('MAPS-CHARGE-006: a nonsense count is dropped rather than written through', () => {
expect(readChargingInfo({ 'socket:type2': '0' })?.sockets[0].count).toBeNull();
expect(readChargingInfo({ capacity: '-2' })).toBeNull();
});
});