diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 108e89e..f4749fb 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -14,6 +14,17 @@ a non-profit, community-maintained open registry of EV charging locations. attribution, which must be shown to the end user where applicable. - We request only open data via the `opendata=true` API parameter where possible. +## Charging station data — OpenStreetMap + +Charging locations are also sourced from [OpenStreetMap](https://www.openstreetmap.org) +(`amenity=charging_station`), in addition to Open Charge Map. + +- OSM data is licensed under the [Open Database License (ODbL) 1.0](https://opendatacommons.org/licenses/odbl/). +- Attribution **© OpenStreetMap contributors** must remain visible to end users + (the station detail panel shows the source per station). +- ODbL share-alike applies to any **redistributed derived database**, not to the + application's source code (which is AGPL-3.0). + ## Map tiles — OpenFreeMap & OpenStreetMap Base map tiles are served by [OpenFreeMap](https://openfreemap.org) using diff --git a/backend/app/config.py b/backend/app/config.py index f6e8ec8..97ac6d5 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -29,6 +29,11 @@ class Settings: redis_url: str | None = os.environ.get("REDIS_URL") or None ocm_api_key: str | None = os.environ.get("OCM_API_KEY") or None + # OpenStreetMap (2nd open station source) via Overpass. + osm_overpass_url: str = os.environ.get( + "OSM_OVERPASS_URL", "https://overpass-api.de/api/interpreter" + ) + # --- OIDC / auth (optional; auth is OFF when these are unset) --- # Authentik (or any OIDC provider) issuer, JWKS endpoint, and our client id # (the expected token audience). @@ -50,9 +55,10 @@ class Settings: db_pool_max_size: int = _int("DB_POOL_MAX_SIZE", 10) # Only score reports newer than this (older ones decay to ~0 anyway). reliability_lookback_hours: int = _int("RELIABILITY_LOOKBACK_HOURS", 24) - # How long we skip re-fetching a viewport tile from Open Charge Map. + # How long we skip re-fetching a viewport tile from a source (OCM / OSM). # Station geometry changes slowly, so this can be generous. ocm_sync_ttl_seconds: int = _int("OCM_SYNC_TTL_SECONDS", 600) + osm_sync_ttl_seconds: int = _int("OSM_SYNC_TTL_SECONDS", 600) settings = Settings() diff --git a/backend/app/geo.py b/backend/app/geo.py index ae991d2..26dc1f8 100644 --- a/backend/app/geo.py +++ b/backend/app/geo.py @@ -23,10 +23,10 @@ def area_deg2(self) -> float: """Rough area in square degrees — used to reject absurdly large queries.""" return (self.east - self.west) * (self.north - self.south) - def tile_key(self) -> str: - """A coarse, stable cache key so panning within a tile reuses one sync.""" + def tile_key(self, source: str = "ocm") -> str: + """A coarse, stable per-source cache key so panning within a tile reuses one sync.""" return ( - f"ocm:synced:{round(self.west, 1)}:{round(self.south, 1)}:" + f"{source}:synced:{round(self.west, 1)}:{round(self.south, 1)}:" f"{round(self.east, 1)}:{round(self.north, 1)}" ) diff --git a/backend/app/ingest.py b/backend/app/ingest.py new file mode 100644 index 0000000..40dd5dd --- /dev/null +++ b/backend/app/ingest.py @@ -0,0 +1,89 @@ +"""Shared station ingest: the source-neutral ``StationRow`` and the UPSERT. + +Both the Open Charge Map (:mod:`app.ocm`) and OpenStreetMap (:mod:`app.osm`) +adapters produce ``StationRow`` objects and persist them via +:func:`upsert_stations`. Each row carries a ``source`` so we know its provenance, +and OSM ids are namespaced (see :func:`osm_station_id`) into a BIGINT range that +can't collide with OCM's small ids — so both sources share one ``stations`` table +keyed on ``id`` without touching the ``reports`` foreign key. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from app.db import db + +# Disjoint BIGINT ranges so OSM ids never collide with OCM (small) ids or each +# other. OCM ids are < ~1e9; these offsets (7–9 x 10^15) are far below the +# BIGINT max (9.22 x 10^18), leaving room for OSM ids (currently ~1e10). +_OSM_ID_OFFSETS = { + "node": 7_000_000_000_000_000, + "way": 8_000_000_000_000_000, + "relation": 9_000_000_000_000_000, +} + + +def osm_station_id(element_type: str, element_id: int) -> int: + """Namespace an OSM element into the shared ``stations.id`` space.""" + offset = _OSM_ID_OFFSETS.get(element_type) + if offset is None: + raise ValueError(f"unsupported OSM element type: {element_type!r}") + return offset + element_id + + +@dataclass(frozen=True) +class StationRow: + """A station ready to upsert into the ``stations`` table.""" + + id: int + source: str # "ocm" | "osm" + name: str | None + operator: str | None + max_power_kw: float | None + access_type: str | None + connectors_json: str + lat: float + lng: float + + +# geom is built from lat/lng; ST_MakePoint takes (x=lng, y=lat). +_UPSERT_SQL = """ +INSERT INTO stations + (id, source, name, operator, max_power_kw, access_type, connectors, geom, last_synced) +VALUES + ($1, $2, $3, $4, $5, $6, $7::jsonb, + ST_SetSRID(ST_MakePoint($9, $8), 4326)::geography, now()) +ON CONFLICT (id) DO UPDATE SET + source = EXCLUDED.source, + name = EXCLUDED.name, + operator = EXCLUDED.operator, + max_power_kw = EXCLUDED.max_power_kw, + access_type = EXCLUDED.access_type, + connectors = EXCLUDED.connectors, + geom = EXCLUDED.geom, + last_synced = now() +""" + + +async def upsert_stations(rows: list[StationRow]) -> None: + """Upsert station rows, de-duplicated by id, into the ``stations`` table.""" + if not rows: + return + deduped = {r.id: r for r in rows} + args: list[tuple[Any, ...]] = [ + ( + r.id, + r.source, + r.name, + r.operator, + r.max_power_kw, + r.access_type, + r.connectors_json, + r.lat, + r.lng, + ) + for r in deduped.values() + ] + await db.executemany(_UPSERT_SQL, args) diff --git a/backend/app/ocm.py b/backend/app/ocm.py index d6989a0..55e4e42 100644 --- a/backend/app/ocm.py +++ b/backend/app/ocm.py @@ -1,55 +1,23 @@ -"""Open Charge Map (OCM) client and station upsert. +"""Open Charge Map (OCM) client. The pure :func:`map_poi` (OCM POI dict -> :class:`StationRow`) has no I/O and is -unit-tested. :func:`fetch_pois` and :func:`upsert_stations` handle the network -and the database; callers degrade gracefully when either fails. +unit-tested. :func:`fetch_pois` handles the network; the shared ``StationRow`` and +:func:`upsert_stations` live in :mod:`app.ingest`. Callers degrade gracefully on error. """ from __future__ import annotations import json -from dataclasses import dataclass from typing import Any import httpx from app.config import settings -from app.db import db from app.geo import BBox +from app.ingest import StationRow OCM_API_URL = "https://api.openchargemap.io/v3/poi/" -# Upsert one station; geom built from lat/lng. ST_MakePoint takes (x=lng, y=lat). -_UPSERT_SQL = """ -INSERT INTO stations - (id, name, operator, max_power_kw, access_type, connectors, geom, last_synced) -VALUES - ($1, $2, $3, $4, $5, $6::jsonb, - ST_SetSRID(ST_MakePoint($8, $7), 4326)::geography, now()) -ON CONFLICT (id) DO UPDATE SET - name = EXCLUDED.name, - operator = EXCLUDED.operator, - max_power_kw = EXCLUDED.max_power_kw, - access_type = EXCLUDED.access_type, - connectors = EXCLUDED.connectors, - geom = EXCLUDED.geom, - last_synced = now() -""" - - -@dataclass(frozen=True) -class StationRow: - """A station ready to upsert into the ``stations`` table.""" - - id: int - name: str | None - operator: str | None - max_power_kw: float | None - access_type: str | None - connectors_json: str - lat: float - lng: float - def _as_float(value: Any) -> float | None: """Coerce a JSON value to float, or None if it isn't numeric (rejects bools).""" @@ -87,6 +55,7 @@ def map_poi(poi: dict[str, Any]) -> StationRow | None: return StationRow( id=poi_id, + source="ocm", name=address.get("Title"), operator=operator_info.get("Title"), max_power_kw=_max_power(connections), @@ -117,15 +86,3 @@ async def fetch_pois(bbox: BBox, *, maxresults: int, timeout: float = 4.0) -> li resp.raise_for_status() data: list[dict[str, Any]] = resp.json() return data - - -async def upsert_stations(rows: list[StationRow]) -> None: - """Upsert station rows, de-duplicated by id, into the ``stations`` table.""" - if not rows: - return - deduped = {r.id: r for r in rows} - args = [ - (r.id, r.name, r.operator, r.max_power_kw, r.access_type, r.connectors_json, r.lat, r.lng) - for r in deduped.values() - ] - await db.executemany(_UPSERT_SQL, args) diff --git a/backend/app/osm.py b/backend/app/osm.py new file mode 100644 index 0000000..a3c6a3d --- /dev/null +++ b/backend/app/osm.py @@ -0,0 +1,155 @@ +"""OpenStreetMap (OSM) charging-station client via the Overpass API. + +A second open data source (ODbL 1.0) alongside Open Charge Map. The pure +:func:`map_osm_element` (Overpass element -> :class:`StationRow`) has no I/O and is +unit-tested; it maps OSM tags into the *same* OCM-compatible ``connectors`` JSON +shape so all downstream code (connector filter, ``connector_titles``) is unchanged. +``upsert_stations`` is shared via :mod:`app.ingest`. +""" + +from __future__ import annotations + +import json +import re +from typing import Any + +import httpx + +from app.config import settings +from app.geo import BBox +from app.ingest import StationRow, osm_station_id + +# OSM socket: keys -> human-readable connector titles (others humanized). +_CONNECTOR_TITLES = { + "type2": "Type 2", + "type2_combo": "CCS (Type 2)", + "type2_cable": "Type 2 (cable)", + "type1": "Type 1 (J1772)", + "type1_combo": "CCS (Type 1)", + "chademo": "CHAdeMO", + "tesla_supercharger": "Tesla Supercharger", + "tesla_destination": "Tesla Destination", + "nacs": "NACS (Tesla)", + "type3": "Type 3", + "type3c": "Type 3C", + "schuko": "Schuko", + "cee_blue": "CEE Blue", + "cee_red_16a": "CEE Red 16A", + "cee_red_32a": "CEE Red 32A", +} +_SOCKET_RE = re.compile(r"^socket:([a-z0-9_]+)$") +_EMPTY_SOCKET_VALUES = {"no", "0", "false", ""} + + +def _as_float(value: Any) -> float | None: + if value is None or isinstance(value, bool): + return None + try: + return float(value) + except (TypeError, ValueError): + return None + + +def _parse_power(value: Any) -> float | None: + """Best-effort kW from values like '22 kW', '22', '7400 W'.""" + if not isinstance(value, str): + return _as_float(value) + match = re.search(r"[-+]?\d*\.?\d+", value) + if not match: + return None + try: + num = float(match.group()) + except ValueError: + return None + low = value.lower() + if "kw" in low: + return num + if "w" in low: # watts, not kW + return num / 1000.0 + return num # bare number assumed to be kW + + +def _max_power_from_tags(tags: dict[str, Any]) -> float | None: + candidates: list[float] = [] + for key, value in tags.items(): + if key.endswith(":output") or key in ("charging_station:output", "maxpower", "output"): + power = _parse_power(value) + if power is not None: + candidates.append(power) + return max(candidates) if candidates else None + + +def _connectors_from_tags(tags: dict[str, Any]) -> list[dict[str, Any]]: + """Connector list in OCM-compatible shape, from present ``socket:*`` tags.""" + titles: list[str] = [] + for key, value in tags.items(): + match = _SOCKET_RE.match(key) + if not match: + continue + if isinstance(value, str) and value.strip().lower() in _EMPTY_SOCKET_VALUES: + continue + socket = match.group(1) + title = _CONNECTOR_TITLES.get(socket, socket.replace("_", " ").title()) + if title not in titles: + titles.append(title) + return [{"ConnectionType": {"Title": t}} for t in titles] + + +def map_osm_element(element: dict[str, Any]) -> StationRow | None: + """Map one Overpass element to a :class:`StationRow`, or ``None`` if unusable. + + Coordinates come from the node itself or a way/relation ``center``; elements + without coordinates are dropped (``geom`` is NOT NULL). Location-only elements + (no tags beyond amenity) still map to a valid, renderable row. + """ + el_type = element.get("type") + el_id = element.get("id") + if el_type not in ("node", "way", "relation"): + return None + if not isinstance(el_id, int) or isinstance(el_id, bool): + return None + + if el_type == "node": + lat = _as_float(element.get("lat")) + lng = _as_float(element.get("lon")) + else: + center = element.get("center") or {} + lat = _as_float(center.get("lat")) + lng = _as_float(center.get("lon")) + if lat is None or lng is None: + return None + + tags = element.get("tags") or {} + return StationRow( + id=osm_station_id(el_type, el_id), + source="osm", + name=tags.get("name") or tags.get("operator") or tags.get("brand"), + operator=tags.get("operator") or tags.get("brand"), + max_power_kw=_max_power_from_tags(tags), + access_type=tags.get("access"), + connectors_json=json.dumps(_connectors_from_tags(tags)), + lat=lat, + lng=lng, + ) + + +async def fetch_osm_elements(bbox: BBox, *, timeout: float = 8.0) -> list[dict[str, Any]]: + """Fetch charging-station elements from Overpass for a viewport. + + Overpass bbox order is ``south,west,north,east``. Raises on transport/HTTP error. + """ + bb = f"{bbox.south},{bbox.west},{bbox.north},{bbox.east}" + query = ( + "[out:json][timeout:25];" + f'(node["amenity"="charging_station"]({bb});' + f'way["amenity"="charging_station"]({bb}););' + "out center tags;" + ) + # Overpass etiquette: identify the client (a missing/default UA can be rejected). + headers = {"User-Agent": "PlugPulse/1.0 (+https://github.com/AayushCharde/PlugPulseEV)"} + async with httpx.AsyncClient(timeout=timeout, headers=headers) as client: + resp = await client.get(settings.osm_overpass_url, params={"data": query}) + resp.raise_for_status() + payload = resp.json() + elements: list[dict[str, Any]] = payload.get("elements", []) + return elements diff --git a/backend/app/stations.py b/backend/app/stations.py index e5cb820..c003e62 100644 --- a/backend/app/stations.py +++ b/backend/app/stations.py @@ -1,9 +1,10 @@ -"""The ``GET /stations`` endpoint: viewport query with on-demand OCM ingest. +"""The ``GET /stations`` endpoint: viewport query with on-demand ingest. -Flow per request: optionally refresh the viewport from Open Charge Map (guarded -by a per-tile cache so we don't hammer OCM), then serve stations from PostGIS -joined with a freshness-weighted reliability score. Reuses -:func:`app.scoring.compute_reliability` so Phase 2 reports need no API change. +Per request: optionally refresh the viewport from each open source (Open Charge +Map + OpenStreetMap), each guarded by its own per-tile cache so we don't hammer +them, then serve stations from PostGIS joined with a freshness-weighted +reliability score. Reuses :func:`app.scoring.compute_reliability` so Phase 2 +reports need no API change. """ from __future__ import annotations @@ -11,7 +12,7 @@ import asyncio import json import logging -from collections.abc import Mapping, Sequence +from collections.abc import Awaitable, Callable, Mapping, Sequence from datetime import datetime from typing import Any @@ -24,18 +25,20 @@ from app.config import settings from app.db import db from app.geo import BBox, parse_bbox -from app.ocm import fetch_pois, map_poi, upsert_stations +from app.ingest import StationRow, upsert_stations +from app.ocm import fetch_pois, map_poi +from app.osm import fetch_osm_elements, map_osm_element from app.scoring import Report, ReportStatus, compute_reliability logger = logging.getLogger(__name__) router = APIRouter() -# Don't trigger an OCM ingest for absurdly large viewports — serve the DB only. +# Don't trigger an ingest for absurdly large viewports — serve the DB only. MAX_BBOX_DEG2 = 25.0 _SELECT_SQL = """ -SELECT id, name, operator, max_power_kw, access_type, connectors, +SELECT id, source, name, operator, max_power_kw, access_type, connectors, ST_Y(geom::geometry) AS lat, ST_X(geom::geometry) AS lng FROM stations WHERE ST_Intersects(geom, ST_MakeEnvelope($1, $2, $3, $4, 4326)::geography) @@ -60,6 +63,7 @@ class ReliabilityOut(BaseModel): class StationOut(BaseModel): id: int + source: str name: str | None operator: str | None max_power_kw: float | None @@ -71,9 +75,10 @@ class StationOut(BaseModel): def connector_titles(connectors_raw: Any) -> list[str]: - """Distinct connector-type titles from the raw OCM Connections array. + """Distinct connector-type titles from the connectors array. Accepts either a parsed list or a JSON string (asyncpg returns JSONB as text). + Both OCM and OSM store the same ``[{"ConnectionType":{"Title": …}}]`` shape. """ if isinstance(connectors_raw, str): try: @@ -122,35 +127,64 @@ def assemble_reliability( return result +# --- on-demand ingest, one guarded sync per source --- + # Keep references to background tasks so they aren't garbage-collected mid-flight. _background_tasks: set[asyncio.Task[None]] = set() +# A producer fetches + maps a source's raw data for a viewport into StationRows. +Producer = Callable[[BBox, int], Awaitable[list[StationRow]]] -async def _sync_ocm(box: BBox, maxresults: int) -> None: - """Refresh this viewport from OCM unless recently synced or far too large. - Never raises: on any OCM/DB error we log and serve whatever the DB has. +async def _produce_ocm(box: BBox, maxresults: int) -> list[StationRow]: + pois = await fetch_pois(box, maxresults=maxresults) + return [row for poi in pois if (row := map_poi(poi)) is not None] + + +async def _produce_osm(box: BBox, maxresults: int) -> list[StationRow]: + elements = await fetch_osm_elements(box) + return [row for el in elements if (row := map_osm_element(el)) is not None] + + +async def _sync_source( + box: BBox, maxresults: int, *, source: str, produce: Producer, ttl: int +) -> None: + """Refresh one source for a viewport unless recently synced or too large. + + Never raises: on any network/DB error we log and serve whatever the DB has. """ if box.area_deg2 > MAX_BBOX_DEG2: return - key = box.tile_key() + key = box.tile_key(source) if await cache.get(key) is not None: return try: - pois = await fetch_pois(box, maxresults=maxresults) - rows = [row for poi in pois if (row := map_poi(poi)) is not None] + rows = await produce(box, maxresults) await upsert_stations(rows) except (httpx.HTTPError, asyncpg.PostgresError) as exc: - logger.warning("OCM sync failed for tile %s: %s", key, exc) + logger.warning("%s sync failed for tile %s: %s", source, key, exc) return # don't set the guard, so the next request retries - await cache.set(key, "1", settings.ocm_sync_ttl_seconds) + await cache.set(key, "1", ttl) + + +async def _sync_ocm(box: BBox, maxresults: int) -> None: + await _sync_source( + box, maxresults, source="ocm", produce=_produce_ocm, ttl=settings.ocm_sync_ttl_seconds + ) + + +async def _sync_osm(box: BBox, maxresults: int) -> None: + await _sync_source( + box, maxresults, source="osm", produce=_produce_osm, ttl=settings.osm_sync_ttl_seconds + ) def _schedule_background_sync(box: BBox, maxresults: int) -> None: - """Fire-and-forget OCM refresh so a warm viewport isn't blocked by the network.""" - task = asyncio.create_task(_sync_ocm(box, maxresults)) - _background_tasks.add(task) - task.add_done_callback(_background_tasks.discard) + """Fire-and-forget refresh of all sources so a warm viewport isn't blocked.""" + for coro in (_sync_ocm(box, maxresults), _sync_osm(box, maxresults)): + task = asyncio.create_task(coro) + _background_tasks.add(task) + task.add_done_callback(_background_tasks.discard) async def _fetch_rows( @@ -186,11 +220,11 @@ async def list_stations( rows = await _fetch_rows(box, connector, min_power_kw, capped) if rows: - # Warm viewport: refresh from OCM in the background so the response is fast. + # Warm viewport: refresh sources in the background so the response is fast. _schedule_background_sync(box, capped) else: - # Cold/empty viewport: block on a sync, then re-query once. - await _sync_ocm(box, capped) + # Cold/empty viewport: block on syncing all sources in parallel, then re-query. + await asyncio.gather(_sync_ocm(box, capped), _sync_osm(box, capped)) rows = await _fetch_rows(box, connector, min_power_kw, capped) station_ids = [row["id"] for row in rows] @@ -206,6 +240,7 @@ async def list_stations( return [ StationOut( id=row["id"], + source=row["source"], name=row["name"], operator=row["operator"], max_power_kw=row["max_power_kw"], diff --git a/backend/migrations/004_station_source.sql b/backend/migrations/004_station_source.sql new file mode 100644 index 0000000..e306bfb --- /dev/null +++ b/backend/migrations/004_station_source.sql @@ -0,0 +1,4 @@ +-- Provenance for multi-source ingest (Open Charge Map, OpenStreetMap, …). +-- Existing rows default to 'ocm'. OSM ids are namespaced (app/ingest.py) so the +-- single-column PRIMARY KEY on stations.id still holds across sources. +ALTER TABLE stations ADD COLUMN IF NOT EXISTS source TEXT NOT NULL DEFAULT 'ocm'; diff --git a/backend/tests/test_ocm.py b/backend/tests/test_ocm.py index a5e285b..f61001b 100644 --- a/backend/tests/test_ocm.py +++ b/backend/tests/test_ocm.py @@ -21,6 +21,7 @@ def test_maps_a_full_poi() -> None: row = map_poi(_FULL_POI) assert row is not None + assert row.source == "ocm" assert row.id == 123 assert row.name == "Test Station" assert row.operator == "Acme Charging" diff --git a/backend/tests/test_osm.py b/backend/tests/test_osm.py new file mode 100644 index 0000000..740f003 --- /dev/null +++ b/backend/tests/test_osm.py @@ -0,0 +1,94 @@ +"""Tests for the pure OSM element mapping + id namespacing (no network/DB).""" + +from __future__ import annotations + +import json + +from app.ingest import osm_station_id +from app.osm import map_osm_element + + +def _titles(connectors_json: str) -> list[str]: + return [c["ConnectionType"]["Title"] for c in json.loads(connectors_json)] + + +def test_maps_a_full_node() -> None: + el = { + "type": "node", + "id": 42, + "lat": 17.44, + "lon": 78.49, + "tags": { + "amenity": "charging_station", + "name": "Tata Power", + "operator": "Tata Power", + "access": "public", + "socket:type2": "2", + "socket:type2:output": "22 kW", + "socket:type2_combo": "1", + "socket:type2_combo:output": "50 kW", + }, + } + row = map_osm_element(el) + assert row is not None + assert row.source == "osm" + assert row.id == osm_station_id("node", 42) + assert row.name == "Tata Power" + assert row.operator == "Tata Power" + assert row.access_type == "public" + assert row.max_power_kw == 50.0 # max of 22 and 50 + assert (row.lat, row.lng) == (17.44, 78.49) + assert _titles(row.connectors_json) == ["Type 2", "CCS (Type 2)"] + + +def test_maps_a_way_via_center() -> None: + el = { + "type": "way", + "id": 7, + "center": {"lat": 1.0, "lon": 2.0}, + "tags": {"amenity": "charging_station", "socket:chademo": "1"}, + } + row = map_osm_element(el) + assert row is not None + assert row.id == osm_station_id("way", 7) + assert (row.lat, row.lng) == (1.0, 2.0) + assert _titles(row.connectors_json) == ["CHAdeMO"] + + +def test_location_only_element_is_kept() -> None: + # Only coordinates, no tags beyond amenity — still a valid, renderable row. + el = {"type": "node", "id": 9, "lat": 5.0, "lon": 6.0, "tags": {"amenity": "charging_station"}} + row = map_osm_element(el) + assert row is not None + assert row.name is None + assert row.operator is None + assert row.max_power_kw is None + assert json.loads(row.connectors_json) == [] + + +def test_missing_coordinates_returns_none() -> None: + assert map_osm_element({"type": "node", "id": 1, "tags": {}}) is None + assert map_osm_element({"type": "way", "id": 1, "tags": {}}) is None # no center + + +def test_power_parsing_handles_units() -> None: + el = { + "type": "node", + "id": 3, + "lat": 0.0, + "lon": 0.0, + "tags": {"socket:type2": "1", "socket:type2:output": "7400 W", "maxpower": "11 kW"}, + } + row = map_osm_element(el) + assert row is not None + assert row.max_power_kw == 11.0 # max(7.4 from watts, 11 kW) + + +class TestOsmStationId: + def test_node_and_way_dont_collide(self) -> None: + assert osm_station_id("node", 100) != osm_station_id("way", 100) + + def test_no_overlap_with_small_ocm_ids(self) -> None: + # OCM ids are small positives; OSM ids are pushed into a high range. + assert osm_station_id("node", 1) > 1_000_000_000 + assert osm_station_id("way", 1) > 1_000_000_000 diff --git a/frontend/src/lib/StationDetail.svelte b/frontend/src/lib/StationDetail.svelte index 7cbff53..3254dd7 100644 --- a/frontend/src/lib/StationDetail.svelte +++ b/frontend/src/lib/StationDetail.svelte @@ -9,6 +9,7 @@ $: display = describeReliability(station.reliability); $: evidence = evidenceLine(station.reliability); + $: sourceLabel = station.source === "osm" ? "© OpenStreetMap" : "Open Charge Map"; function onKeydown(e: KeyboardEvent): void { if (e.key === "Escape") dispatch("close"); @@ -60,6 +61,8 @@ {/if} + +

Source: {sourceLabel}

@@ -106,6 +109,10 @@ .muted { color: var(--text-muted); } + .source { + margin: 12px 0 0; + font-size: 0.78rem; + } dl { display: grid; grid-template-columns: auto 1fr; diff --git a/frontend/src/lib/stations.ts b/frontend/src/lib/stations.ts index 962ae3c..c9b5baa 100644 --- a/frontend/src/lib/stations.ts +++ b/frontend/src/lib/stations.ts @@ -11,6 +11,7 @@ import type { Reliability, ReliabilityLabel } from "$lib/reliability"; /** A station as returned by GET /stations (mirrors backend StationOut). */ export interface Station { id: number | string; + source: string; // "ocm" | "osm" — data provenance name: string | null; operator: string | null; lat: number; diff --git a/frontend/tests/stations.test.ts b/frontend/tests/stations.test.ts index a176dc0..72e5a42 100644 --- a/frontend/tests/stations.test.ts +++ b/frontend/tests/stations.test.ts @@ -14,6 +14,7 @@ const bbox = { west: 78.3, south: 17.3, east: 78.6, north: 17.5 }; function station(overrides: Partial = {}): Station { return { id: 1, + source: "ocm", name: "Test", operator: "Acme", lat: 17.4,