Skip to content

Commit 5ecc06f

Browse files
committed
feat(ev-charging): add Belgium Wallonia (SPW) public charging
Add the Service public de Wallonie EV charging register. Its only bulk export is a ZIP whose CSV carries geometry as WKT in EPSG:31370 (the ArcGIS query endpoint serves no geometry), so the parser unzips it with fflate and reprojects each point to WGS84 with proj4, then groups the per-connector rows by EMPLACEMENT_ID into stations. CC BY 4.0. Reprojection verified against the SPW control point (184781.2, 128873.1 -> 4.858691, 50.469649).
1 parent a123f7a commit 5ecc06f

12 files changed

Lines changed: 331 additions & 1 deletion

File tree

integrations/ev-charging/manifest.json

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,11 @@
328328
"url": "https://tdx.transportdata.tw/auth/realms/TDXConnect/protocol/openid-connect/token",
329329
"requiredConfigKeys": ["tw-tdx-client-id", "tw-tdx-client-secret"]
330330
},
331+
{
332+
"name": "SPW Bornes de recharge (BE-WA)",
333+
"type": "http",
334+
"url": "https://geoservices.wallonie.be/arcgis/rest/services/INDUSTRIES_SERVICES/BORNES_RECHARGE/MapServer?f=json"
335+
},
331336
{
332337
"name": "OpenStreetMap / Overpass",
333338
"type": "ping",
@@ -778,6 +783,22 @@
778783
"cookies": false,
779784
"dpaAvailable": false
780785
},
786+
{
787+
"sourceId": "be-wallonia",
788+
"name": "Service public de Wallonie (SPW) — public EV charging",
789+
"url": "https://geoportail.wallonie.be/catalogue/c731a282-e378-49f3-8ab6-f9a90e7a5683.html",
790+
"apiHosts": ["geoservices.wallonie.be"],
791+
"license": "CC BY 4.0",
792+
"licenseUrl": "https://creativecommons.org/licenses/by/4.0/",
793+
"attribution": "Service public de Wallonie (SPW)",
794+
"commercialUse": "yes",
795+
"providerCountry": "BE",
796+
"providerPrivacyUrl": "https://www.wallonie.be/fr/vie-privee",
797+
"endUserExposure": "server-only",
798+
"personalData": false,
799+
"cookies": false,
800+
"dpaAvailable": false
801+
},
781802
{
782803
"sourceId": "osm",
783804
"name": "OpenStreetMap",

integrations/ev-charging/package.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@
1212
"@openmapx/mobility-core": "workspace:^",
1313
"@openmapx/place-ids": "workspace:^",
1414
"@openmapx/poi-source-registry": "workspace:^",
15-
"fast-xml-parser": "^5.8.0"
15+
"fast-xml-parser": "^5.8.0",
16+
"fflate": "^0.8.2",
17+
"proj4": "^2.15.0"
18+
},
19+
"devDependencies": {
20+
"@types/proj4": "^2.5.6"
1621
}
1722
}

integrations/ev-charging/poi-sources.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { parseAuNsw, resolveNswUrl } from "./providers/au-nsw-parser.js";
44
import { AU_QLD_CSV_URL, parseAuQld } from "./providers/au-qld-parser.js";
55
import { parseAuVic } from "./providers/au-vic-parser.js";
66
import { BE_FLANDERS_URL, parseBeFlanders } from "./providers/be-flanders-parser.js";
7+
import { BE_WALLONIA_URL, parseBeWallonia } from "./providers/be-wallonia-parser.js";
78
import {
89
CH_SFOE_OICP_DATA_URL,
910
CH_SFOE_OICP_STATUS_URL,
@@ -222,6 +223,20 @@ export function declarePoiSources(): PoiSource[] {
222223
minRowCount: 1000,
223224
},
224225
},
226+
{
227+
parts: { country: "be", operator: "wallonia" },
228+
domain: "ev-charging",
229+
name: "Service public de Wallonie (SPW) — public EV charging",
230+
// [west, south, east, north]
231+
coverage: [2.8, 49.5, 6.4, 50.8],
232+
static: {
233+
cron: "0 4 * * *",
234+
fetch: { type: "http", url: BE_WALLONIA_URL, timeoutMs: 60_000 },
235+
parse: parseBeWallonia,
236+
// ~14.6k per-connector rows group into several thousand stations.
237+
minRowCount: 500,
238+
},
239+
},
225240
{
226241
parts: { country: "hk", operator: "epd" },
227242
domain: "ev-charging",
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { readFileSync } from "node:fs";
2+
import { fileURLToPath } from "node:url";
3+
import { zipSync } from "fflate";
4+
import { describe, expect, it, vi } from "vitest";
5+
import { BE_WALLONIA_URL, parseBeWallonia, parseWalloniaRows } from "../be-wallonia-parser.js";
6+
7+
const csv = readFileSync(fileURLToPath(new URL("./fixtures/be-wallonia.csv", import.meta.url)));
8+
const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
9+
10+
const WALLONIA_BBOX = { west: 2.8, south: 49.5, east: 6.4, north: 50.8 };
11+
function inWallonia([lng, lat]: [number, number]): boolean {
12+
return (
13+
lng >= WALLONIA_BBOX.west &&
14+
lng <= WALLONIA_BBOX.east &&
15+
lat >= WALLONIA_BBOX.south &&
16+
lat <= WALLONIA_BBOX.north
17+
);
18+
}
19+
20+
describe("parseWalloniaRows", () => {
21+
it("reprojects EPSG:31370 WKT to WGS84 against the SPW control point", () => {
22+
const rows = parseWalloniaRows(csv.toString("utf8"));
23+
const namur = rows.find((r) => r.poiId === "loc-namur");
24+
expect(namur).toBeDefined();
25+
expect(namur?.lng).toBeCloseTo(4.858691, 5);
26+
expect(namur?.lat).toBeCloseTo(50.469649, 5);
27+
expect(inWallonia(namur?.payload.coordinates as [number, number])).toBe(true);
28+
});
29+
30+
it("groups per-connector rows by EMPLACEMENT_ID into one station", () => {
31+
const rows = parseWalloniaRows(csv.toString("utf8"));
32+
expect(rows).toHaveLength(2);
33+
const namur = rows.find((r) => r.poiId === "loc-namur");
34+
const connectors = namur?.payload.connectors as Array<{
35+
type?: string;
36+
currentType?: string;
37+
powerKw?: number;
38+
}>;
39+
expect(connectors).toHaveLength(3);
40+
expect(connectors.every((c) => c.type === "Type 2" && c.currentType === "AC")).toBe(true);
41+
expect((namur?.payload.operator as { name?: string }).name).toBe("50 five");
42+
expect((namur?.payload.address as { postcode?: string; country?: string }).postcode).toBe(
43+
"5000",
44+
);
45+
expect(namur?.payload.updatedAt).toBe("2026-04-11T00:00:00");
46+
});
47+
48+
it("infers DC/CCS for higher-power tiers", () => {
49+
const rows = parseWalloniaRows(csv.toString("utf8"));
50+
const dc = rows.find((r) => r.poiId === "loc-dc");
51+
const connectors = dc?.payload.connectors as Array<{
52+
type?: string;
53+
currentType?: string;
54+
powerKw?: number;
55+
}>;
56+
expect(connectors[0]).toMatchObject({ type: "CCS", currentType: "DC", powerKw: 150 });
57+
expect(inWallonia(dc?.payload.coordinates as [number, number])).toBe(true);
58+
});
59+
60+
it("unzips the archive and parses the CSV member", () => {
61+
const zipped = zipSync({ "BORNES_RECHARGE_XY.csv": new Uint8Array(csv) });
62+
const rows = Array.from(parseBeWallonia(Buffer.from(zipped), { log }));
63+
expect(rows).toHaveLength(2);
64+
expect(BE_WALLONIA_URL).toContain("BORNES_RECHARGE_CSV.zip");
65+
});
66+
});
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
OBJECTID;CONNECTEUR_ID;EMPLACEMENT_ID;OPERATEUR;ADRESSE;CODE_POSTAL;VILLE;PROVINCE;TYPE_ACCES;TYPE_EMPLACEMENT;RESTRICTIONS_EMPLACEMENT;PUISSANCE_KW;TYPE_RECHARGE;DISTANCE_SORTIE_METRE;DISTANCE_AUTOROUTE_METRE;DATETRANS;WKT_GEOM
2+
"11024";"67409491";"loc-namur";"50 five";"Boulevard de Merckem 60";"5000";"Namur";"Namur";"semi-public";"ON_STREET";"";"22";"Normal";"3551";"3551";"2026-04-10T00:00:00";"POINT (184781.2 128873.1)"
3+
"11025";"67409492";"loc-namur";"50 five";"Boulevard de Merckem 60";"5000";"Namur";"Namur";"semi-public";"ON_STREET";"";"22";"Normal";"3551";"3551";"2026-04-10T00:00:00";"POINT (184781.2 128873.1)"
4+
"11026";"67409494";"loc-namur";"50 five";"Boulevard de Merckem 60";"5000";"Namur";"Namur";"semi-public";"ON_STREET";"";"22";"Normal";"3551";"3551";"2026-04-11T00:00:00";"POINT (184781.2 128873.1)"
5+
"20001";"88000001";"loc-dc";"Fastned";"Aire de Wanlin E411";"5570";"Wanlin";"Namur";"public";"ALONG_MOTORWAY";"";"150";"Ultra-rapide";"10";"10";"2026-05-01T00:00:00";"POINT (200000 130000)"
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import type { EvChargingConnector } from "@openmapx/mobility-core/ev-charging";
2+
import type { PoiRow, PoiStaticParseFn } from "@openmapx/poi-source-registry";
3+
import { unzipSync } from "fflate";
4+
import proj4 from "proj4";
5+
import { parseDelimited, rowsToObjects } from "./csv.js";
6+
import { cleanString, connector, newestIsoString, parseLocalizedNumber } from "./utils.js";
7+
8+
export const BE_WALLONIA_URL =
9+
"https://geoservices.wallonie.be/geotraitement/spwdatadownload/results/c731a282-e378-49f3-8ab6-f9a90e7a5683/BORNES_RECHARGE_CSV.zip";
10+
const SOURCE_URL =
11+
"https://geoportail.wallonie.be/catalogue/c731a282-e378-49f3-8ab6-f9a90e7a5683.html";
12+
13+
// The bulk download is a ZIP whose CSV carries geometry as WKT in EPSG:31370
14+
// (Belgian Lambert 72) — the ArcGIS query endpoint serves no geometry, so the
15+
// zip is the only usable source. Reproject each point to WGS84. Verified
16+
// against the SPW control point 184781.2, 128873.1 → 4.858691, 50.469649.
17+
const LAMBERT72 =
18+
"+proj=lcc +lat_1=51.16666723333333 +lat_2=49.8333339 +lat_0=90 +lon_0=4.367486666666666 +x_0=150000.013 +y_0=5400088.438 +ellps=intl +towgs84=-106.8686,52.2978,-103.7239,0.3366,-0.457,1.8422,-1.2747 +units=m +no_defs";
19+
const lambert72ToWgs84 = proj4(LAMBERT72, "WGS84");
20+
21+
function reprojectWkt(wkt: string | undefined): [number, number] | null {
22+
const match = wkt?.match(/POINT\s*\(\s*(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)\s*\)/i);
23+
if (!match) return null;
24+
const x = Number.parseFloat(match[1]);
25+
const y = Number.parseFloat(match[2]);
26+
if (!Number.isFinite(x) || !Number.isFinite(y)) return null;
27+
const [lng, lat] = lambert72ToWgs84.forward([x, y]);
28+
if (!Number.isFinite(lng) || !Number.isFinite(lat)) return null;
29+
return [lng, lat];
30+
}
31+
32+
// TYPE_RECHARGE is a power TIER (Normal/Rapide/Ultra-rapide), not a plug type,
33+
// and the feed carries no connector-standard field — so infer from power:
34+
// ≤22 kW is AC (Type 2), above is DC (CCS). Low-confidence but the only signal.
35+
function rowConnector(power: number | undefined): EvChargingConnector {
36+
const isDc = power !== undefined && power > 22;
37+
return connector({
38+
type: isDc ? "CCS (Type 2)" : "Type 2",
39+
powerKw: power,
40+
currentType: isDc ? "DC" : "AC",
41+
quantity: 1,
42+
});
43+
}
44+
45+
interface WalloniaGroup {
46+
coordinates: [number, number];
47+
operator?: string;
48+
address?: string;
49+
postcode?: string;
50+
town?: string;
51+
state?: string;
52+
connectors: EvChargingConnector[];
53+
updates: (string | undefined)[];
54+
}
55+
56+
/** Parses the decompressed `;`-delimited CSV text into grouped station rows. */
57+
export function parseWalloniaRows(text: string): PoiRow[] {
58+
const rows = rowsToObjects(parseDelimited(text.replace(/^/, ""), ";"), 0);
59+
const groups = new Map<string, WalloniaGroup>();
60+
61+
for (const row of rows) {
62+
const emplacementId = cleanString(row.EMPLACEMENT_ID);
63+
const coordinates = reprojectWkt(row.WKT_GEOM);
64+
if (!emplacementId || !coordinates) continue;
65+
66+
const existing = groups.get(emplacementId);
67+
const power = parseLocalizedNumber(row.PUISSANCE_KW);
68+
if (existing) {
69+
existing.connectors.push(rowConnector(power));
70+
existing.updates.push(cleanString(row.DATETRANS));
71+
continue;
72+
}
73+
groups.set(emplacementId, {
74+
coordinates,
75+
operator: cleanString(row.OPERATEUR),
76+
address: cleanString(row.ADRESSE),
77+
postcode: cleanString(row.CODE_POSTAL),
78+
town: cleanString(row.VILLE),
79+
state: cleanString(row.PROVINCE),
80+
connectors: [rowConnector(power)],
81+
updates: [cleanString(row.DATETRANS)],
82+
});
83+
}
84+
85+
const out: PoiRow[] = [];
86+
for (const [poiId, group] of groups) {
87+
const name = [group.operator, group.address].filter(Boolean).join(" – ");
88+
out.push({
89+
poiId,
90+
lng: group.coordinates[0],
91+
lat: group.coordinates[1],
92+
payload: {
93+
coordinates: group.coordinates,
94+
name: name || "EV Charging Station",
95+
address: {
96+
line1: group.address,
97+
town: group.town,
98+
state: group.state,
99+
postcode: group.postcode,
100+
country: "Belgium",
101+
},
102+
operator: group.operator ? { name: group.operator } : undefined,
103+
status: "unknown",
104+
connectors: group.connectors,
105+
updatedAt: newestIsoString(group.updates),
106+
sourceUrl: SOURCE_URL,
107+
},
108+
});
109+
}
110+
return out;
111+
}
112+
113+
export const parseBeWallonia: PoiStaticParseFn = (buffer) => {
114+
const files = unzipSync(new Uint8Array(buffer));
115+
const csvName = Object.keys(files).find((name) => name.toLowerCase().endsWith(".csv"));
116+
if (!csvName) return [];
117+
return parseWalloniaRows(Buffer.from(files[csvName]).toString("utf8"));
118+
};
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import type { BoundingBox } from "@openmapx/core";
2+
import { createStaticPoiReader } from "@openmapx/integration-framework";
3+
import type { EvChargingSource, EvChargingStation } from "@openmapx/mobility-core/ev-charging";
4+
import type { BBox } from "@openmapx/poi-source-registry";
5+
import { getRuntimeContext } from "../runtime.js";
6+
import { createPayloadStationMapper } from "./payload-station.js";
7+
import { getEvChargingSourcePriority } from "./source-priority.js";
8+
9+
const STATION_ID_PREFIX = "be-wallonia:";
10+
11+
const reader = createStaticPoiReader<EvChargingStation>({
12+
sourceId: "be-wallonia",
13+
mapStatic: createPayloadStationMapper({
14+
sourceId: "be-wallonia",
15+
stationIdPrefix: STATION_ID_PREFIX,
16+
}),
17+
// [west, south, east, north] — Wallonia.
18+
coverage: [2.8, 49.5, 6.4, 50.8],
19+
});
20+
21+
function toBboxTuple(b: BoundingBox): BBox {
22+
return [b.west, b.south, b.east, b.north];
23+
}
24+
25+
export async function searchBeWalloniaCharging(bbox: BoundingBox): Promise<EvChargingStation[]> {
26+
return reader.search(getRuntimeContext(), toBboxTuple(bbox));
27+
}
28+
29+
export async function fetchBeWalloniaChargingDetail(
30+
itemId: string,
31+
): Promise<EvChargingStation | null> {
32+
const poiId = itemId.startsWith(STATION_ID_PREFIX)
33+
? itemId.slice(STATION_ID_PREFIX.length)
34+
: itemId;
35+
return reader.fetchDetail(getRuntimeContext(), poiId);
36+
}
37+
38+
export const beWalloniaSource: EvChargingSource = {
39+
id: "be-wallonia",
40+
priority: getEvChargingSourcePriority("be-wallonia"),
41+
search: searchBeWalloniaCharging,
42+
canFetchDetail: (itemId) => itemId.startsWith(STATION_ID_PREFIX),
43+
fetchDetail: fetchBeWalloniaChargingDetail,
44+
};

integrations/ev-charging/providers/registry.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { fetchAuNswChargingDetail, searchAuNswCharging } from "./au-nsw.js";
44
import { fetchAuQldChargingDetail, searchAuQldCharging } from "./au-qld.js";
55
import { fetchAuVicChargingDetail, searchAuVicCharging } from "./au-vic.js";
66
import { fetchBeFlandersChargingDetail, searchBeFlandersCharging } from "./be-flanders.js";
7+
import { fetchBeWalloniaChargingDetail, searchBeWalloniaCharging } from "./be-wallonia.js";
78
import { fetchChSfoeChargingDetail, searchChSfoeCharging } from "./ch-sfoe.js";
89
import { fetchCyCynapChargingDetail, searchCyCynapCharging } from "./cy-cynap.js";
910
import { fetchDeBnetzaChargingDetail, searchDeBnetzaCharging } from "./de-bnetza.js";
@@ -61,6 +62,7 @@ export const EV_CHARGING_SOURCE_REGISTRY: EvChargingSource[] = [
6162
source("au-qld-ev", searchAuQldCharging, "au-qld-ev:", fetchAuQldChargingDetail),
6263
source("au-vic-ev", searchAuVicCharging, "au-vic-ev:", fetchAuVicChargingDetail),
6364
source("be-flanders", searchBeFlandersCharging, "be-flanders:", fetchBeFlandersChargingDetail),
65+
source("be-wallonia", searchBeWalloniaCharging, "be-wallonia:", fetchBeWalloniaChargingDetail),
6466
source("hk-epd", searchHkEpdCharging, "hk-epd:", fetchHkEpdChargingDetail),
6567
source(
6668
"fi-digitraffic",

integrations/ev-charging/providers/source-priority.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ const SOURCE_PRIORITY: Record<string, number> = {
2020
"au-qld-ev": 0,
2121
"au-vic-ev": 0,
2222
"be-flanders": 0,
23+
"be-wallonia": 0,
2324
"hk-epd": 0,
2425
"fi-digitraffic": 0,
2526
"lt-vialietuva": 0,

integrations/ev-charging/strings/de.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,11 @@
126126
"dataSent": "Keine Abfrageparameter – die vollständigen nationalen OCPI-Standort- und Tarif-Feeds werden serverseitig täglich in die lokale Datenbank geladen und anschließend lokal nach Begrenzungsrahmen abgefragt; pro Kartenabfrage erreichen den Anbieter keine Nutzerdaten",
127127
"dataReceived": "Ladestationsdatensätze mit Standort, Betreiber, Steckertypen, Nennleistung und strukturierten Tarifen je Anschluss (Energie-/Sitzungspreis, Währung, MwSt.)"
128128
},
129+
"be-wallonia": {
130+
"purpose": "Register öffentlicher E-Ladestationen für Wallonien, veröffentlicht vom Service public de Wallonie (SPW)",
131+
"dataSent": "Keine Abfrageparameter – der vollständige regionale CSV-Datensatz wird serverseitig täglich heruntergeladen, von Belgisch Lambert 72 nach WGS84 umprojiziert, in die lokale Datenbank geladen und anschließend lokal nach Begrenzungsrahmen abgefragt; pro Kartenabfrage erreichen den Anbieter keine Nutzerdaten",
132+
"dataReceived": "Ladedatensätze mit Standort, Betreiber, Adresse, Provinz, Zugangsart, Leistungsstufe (abgeleiteter Steckertyp) und letztem Aktualisierungsdatum; keine Live-Status- oder Preisdaten"
133+
},
129134
"kr-datago": {
130135
"purpose": "Landesweiter Standarddatensatz für E-Ladestationen der Korea Environment Corporation (한국환경공단) über data.go.kr",
131136
"dataSent": "Keine Abfrageparameter außer der festen Spaltenliste – der vollständige Datensatz wird serverseitig täglich über die schlüssellose Standarddaten-Schnittstelle von data.go.kr in die lokale Datenbank geladen und anschließend lokal nach Begrenzungsrahmen abgefragt; pro Kartenabfrage erreichen den Anbieter keine Nutzerdaten",

0 commit comments

Comments
 (0)