Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions ATTRIBUTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion backend/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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()
6 changes: 3 additions & 3 deletions backend/app/geo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)}"
)

Expand Down
89 changes: 89 additions & 0 deletions backend/app/ingest.py
Original file line number Diff line number Diff line change
@@ -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)
53 changes: 5 additions & 48 deletions backend/app/ocm.py
Original file line number Diff line number Diff line change
@@ -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)."""
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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)
155 changes: 155 additions & 0 deletions backend/app/osm.py
Original file line number Diff line number Diff line change
@@ -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:<type> 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
Loading
Loading