This commit is contained in:
@@ -4,3 +4,4 @@ __pycache__/
|
|||||||
.env
|
.env
|
||||||
pbt.env
|
pbt.env
|
||||||
instance/
|
instance/
|
||||||
|
data/.osm-cache/
|
||||||
|
|||||||
@@ -20,15 +20,18 @@ Beim ersten Start legt die App die Tabellen (`users`, `trips`, `stops`) automati
|
|||||||
|
|
||||||
## Haltestellen-Lookup
|
## Haltestellen-Lookup
|
||||||
|
|
||||||
Die Felder „Von" und „Nach" haben eine Autovervollständigung aus allen
|
Die Felder „Von" und „Nach" haben eine Autovervollständigung aus den
|
||||||
österreichischen Haltestellen. Die Daten stammen aus OpenStreetMap
|
Haltestellen in Österreich, Deutschland und der Schweiz (DACH) – aktuell
|
||||||
(© OpenStreetMap-Mitwirkende, ODbL) und liegen als Snapshot im Repo
|
**306 954 Haltestellen** (DE 243 278, AT 37 454, CH 26 222). Die Daten stammen
|
||||||
(`data/stops_at.csv.gz`, ~37 000 Haltestellen).
|
aus OpenStreetMap (© OpenStreetMap-Mitwirkende, ODbL) und liegen als Snapshot
|
||||||
|
im Repo (`data/stops_dach.csv.gz`, ~7,5 MB gepackt).
|
||||||
|
|
||||||
Zusätzlich ist pro Haltestelle die Menge der Linien hinterlegt (aus den
|
Zusätzlich ist pro Haltestelle die Menge der Linien hinterlegt (aus den
|
||||||
OSM-`route`-Relationen, ~87 % der Halte). Das Feld „Linie" schlägt daraus die
|
OSM-`route`-Relationen). Das Feld „Linie" schlägt daraus die Linien vor, die
|
||||||
Linien vor, die an den gewählten Halten verkehren, und füllt sich selbst aus,
|
an den gewählten Halten verkehren, und füllt sich selbst aus, wenn nur eine
|
||||||
wenn nur eine Linie in Frage kommt. Freitext bleibt immer möglich.
|
Linie in Frage kommt. Freitext bleibt immer möglich. Da mehrere Länder
|
||||||
|
gleichnamige Haltestellen haben können (mehrere „Hauptbahnhof"), zeigt die
|
||||||
|
Vorschlagsliste zusätzlich das Land.
|
||||||
|
|
||||||
Nach dem Anlegen der Tabellen den Snapshot in die DB laden:
|
Nach dem Anlegen der Tabellen den Snapshot in die DB laden:
|
||||||
|
|
||||||
@@ -40,14 +43,28 @@ Der Import ist idempotent (Upsert) und braucht kein Internet. Im Produktivbetrie
|
|||||||
übernimmt das die Unit `pbt-import-stops.service` (wird bei jedem Deploy
|
übernimmt das die Unit `pbt-import-stops.service` (wird bei jedem Deploy
|
||||||
angestoßen, s. u.).
|
angestoßen, s. u.).
|
||||||
|
|
||||||
Snapshot neu von OpenStreetMap holen (Halte + Linien, ~5 min, braucht Netzugang
|
### Snapshot aktualisieren
|
||||||
und ~1 GB RAM):
|
|
||||||
|
Bei dieser Größenordnung (AT+DE+CH) geht sich das nicht mehr über die
|
||||||
|
öffentliche Overpass-API aus – schon eine reine Zählabfrage für Deutschland
|
||||||
|
oder auch nur die Schweiz läuft dort in den Timeout. Stattdessen lädt
|
||||||
|
`fetch_stops.py` die offiziellen Geofabrik-Extrakte pro Land herunter
|
||||||
|
(`data/.osm-cache/`, nicht committed, ~6 GB) und wertet sie lokal mit
|
||||||
|
`pyosmium` aus – keine Last auf einer geteilten API.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python scripts/fetch_stops.py # überschreibt data/stops_at.csv.gz
|
pip install osmium # einmalig, nur für dieses Script
|
||||||
|
python scripts/fetch_stops.py # überschreibt data/stops_dach.csv.gz
|
||||||
flask import-stops
|
flask import-stops
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Braucht Netzugang zum Download, ein paar GB RAM und in der Praxis eher
|
||||||
|
1–1,5 Stunden als die 5 Minuten von früher (der Großteil davon ist
|
||||||
|
Deutschland – Österreich allein dauert ca. 11 Minuten). Bereits
|
||||||
|
heruntergeladene Extrakte werden für spätere Läufe wiederverwendet, solange
|
||||||
|
sie in `data/.osm-cache/` liegen (~6 GB, `rm -rf data/.osm-cache/` gibt den
|
||||||
|
Platz wieder frei).
|
||||||
|
|
||||||
## Kartenvorschau (optional, standardmäßig aus)
|
## Kartenvorschau (optional, standardmäßig aus)
|
||||||
|
|
||||||
Trip-Formular und „Meine Fahrten" können eine kleine Leaflet/OSM-Karte mit den
|
Trip-Formular und „Meine Fahrten" können eine kleine Leaflet/OSM-Karte mit den
|
||||||
|
|||||||
@@ -142,6 +142,7 @@ def register_routes(app):
|
|||||||
"id": stop.id,
|
"id": stop.id,
|
||||||
"name": stop.name,
|
"name": stop.name,
|
||||||
"type": stop.stop_type,
|
"type": stop.stop_type,
|
||||||
|
"country": stop.country,
|
||||||
"municipality": stop.municipality,
|
"municipality": stop.municipality,
|
||||||
"lat": stop.latitude,
|
"lat": stop.latitude,
|
||||||
"lon": stop.longitude,
|
"lon": stop.longitude,
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -37,7 +37,8 @@ VERKEHRSMITTEL_OPTIONEN = [
|
|||||||
|
|
||||||
|
|
||||||
class Stop(db.Model):
|
class Stop(db.Model):
|
||||||
"""A boardable public-transport stop ("Haltestelle") in Austria.
|
"""A boardable public-transport stop ("Haltestelle") in Austria, Germany
|
||||||
|
or Switzerland (DACH).
|
||||||
|
|
||||||
Sourced from OpenStreetMap; see scripts/fetch_stops.py and stops_import.py.
|
Sourced from OpenStreetMap; see scripts/fetch_stops.py and stops_import.py.
|
||||||
"""
|
"""
|
||||||
@@ -52,6 +53,9 @@ class Stop(db.Model):
|
|||||||
# lowercased, accent-folded copy of name for diacritic-insensitive search
|
# lowercased, accent-folded copy of name for diacritic-insensitive search
|
||||||
name_normalized = db.Column(db.String(200), nullable=False, index=True)
|
name_normalized = db.Column(db.String(200), nullable=False, index=True)
|
||||||
stop_type = db.Column(db.String(20), nullable=False, default="other")
|
stop_type = db.Column(db.String(20), nullable=False, default="other")
|
||||||
|
# ISO 3166-1 alpha-2 of the fetch query that found this stop (AT/DE/CH) -
|
||||||
|
# disambiguates same-named stops across borders (several "Hauptbahnhof").
|
||||||
|
country = db.Column(db.String(2), nullable=False, default="AT")
|
||||||
municipality = db.Column(db.String(120))
|
municipality = db.Column(db.String(120))
|
||||||
latitude = db.Column(db.Float, nullable=False)
|
latitude = db.Column(db.Float, nullable=False)
|
||||||
longitude = db.Column(db.Float, nullable=False)
|
longitude = db.Column(db.Float, nullable=False)
|
||||||
|
|||||||
+162
-121
@@ -1,59 +1,51 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Fetch all Austrian public-transport stops (and the lines serving them) from
|
"""Fetch all public-transport stops (and the lines serving them) in Austria,
|
||||||
OpenStreetMap via the Overpass API and write a deduplicated snapshot to
|
Germany and Switzerland (DACH) from OpenStreetMap and write a deduplicated
|
||||||
data/stops_at.csv.gz.
|
snapshot to data/stops_dach.csv.gz.
|
||||||
|
|
||||||
Data © OpenStreetMap contributors, licensed under the ODbL.
|
Data © OpenStreetMap contributors, licensed under the ODbL.
|
||||||
|
|
||||||
Run this only when you want to refresh the snapshot; the import into the
|
At this scale the public Overpass API can't be used directly - even a plain
|
||||||
database reads the checked-in CSV and needs no network access. The route pass
|
count query for one of these countries times out on it. Instead this
|
||||||
downloads a few hundred MB in ~25 tiles and needs roughly 1 GB of RAM.
|
downloads the official Geofabrik .osm.pbf extract per country (cached in
|
||||||
|
data/.osm-cache/, never committed) and processes it locally with pyosmium.
|
||||||
|
No load is put on any shared API.
|
||||||
|
|
||||||
|
Run this only when you want to refresh the snapshot; the import into the
|
||||||
|
database (`flask import-stops`) reads the checked-in CSV and needs no network
|
||||||
|
access. Refreshing needs: `pip install osmium`, ~6 GB of disk for the
|
||||||
|
extracts, a few GB of RAM, and maybe 15-30 minutes depending on disk speed
|
||||||
|
(most of it is Germany).
|
||||||
|
|
||||||
|
pip install osmium
|
||||||
python scripts/fetch_stops.py
|
python scripts/fetch_stops.py
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import csv
|
import csv
|
||||||
import gzip
|
import gzip
|
||||||
import json
|
|
||||||
import math
|
import math
|
||||||
import re
|
|
||||||
import sys
|
import sys
|
||||||
import time
|
|
||||||
import unicodedata
|
import unicodedata
|
||||||
import urllib.parse
|
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
OUT_PATH = Path(__file__).resolve().parent.parent / "data" / "stops_at.csv.gz"
|
import osmium
|
||||||
|
|
||||||
OVERPASS_ENDPOINTS = [
|
OUT_PATH = Path(__file__).resolve().parent.parent / "data" / "stops_dach.csv.gz"
|
||||||
"https://overpass-api.de/api/interpreter",
|
CACHE_DIR = Path(__file__).resolve().parent.parent / "data" / ".osm-cache"
|
||||||
"https://overpass.kumi.systems/api/interpreter",
|
|
||||||
"https://overpass.private.coffee/api/interpreter",
|
|
||||||
]
|
|
||||||
|
|
||||||
# Named nodes in Austria that represent a boardable stop ("Haltestelle").
|
# ISO 3166-1 alpha-2 -> Geofabrik region file basename.
|
||||||
STOPS_QUERY = """
|
GEOFABRIK_REGIONS = {
|
||||||
[out:json][timeout:600];
|
"AT": "austria",
|
||||||
area["ISO3166-1"="AT"][admin_level=2]->.at;
|
"CH": "switzerland",
|
||||||
(
|
"DE": "germany",
|
||||||
node(area.at)["highway"="bus_stop"]["name"];
|
}
|
||||||
node(area.at)["railway"="tram_stop"]["name"];
|
|
||||||
node(area.at)["railway"="station"]["name"];
|
|
||||||
node(area.at)["railway"="halt"]["name"];
|
|
||||||
node(area.at)["public_transport"="station"]["name"];
|
|
||||||
node(area.at)["amenity"="bus_station"]["name"];
|
|
||||||
);
|
|
||||||
out body;
|
|
||||||
"""
|
|
||||||
|
|
||||||
ROUTE_MODES = "bus|trolleybus|tram|light_rail|subway|train|monorail|share_taxi"
|
ROUTE_MODES = {
|
||||||
|
"bus", "trolleybus", "tram", "light_rail", "subway", "train",
|
||||||
# Austria bbox, tiled so each Overpass response stays small enough to parse.
|
"monorail", "share_taxi",
|
||||||
BBOX = (46.35, 9.50, 49.05, 17.20) # south, west, north, east
|
}
|
||||||
LAT_STEP = 0.9
|
|
||||||
LON_STEP = 1.0
|
|
||||||
|
|
||||||
STOP_ROLES = {
|
STOP_ROLES = {
|
||||||
"stop", "platform",
|
"stop", "platform",
|
||||||
@@ -62,7 +54,7 @@ STOP_ROLES = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
CSV_FIELDS = [
|
CSV_FIELDS = [
|
||||||
"osm_type", "osm_id", "name", "stop_type",
|
"osm_type", "osm_id", "name", "stop_type", "country",
|
||||||
"municipality", "latitude", "longitude", "lines",
|
"municipality", "latitude", "longitude", "lines",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -72,6 +64,8 @@ TYPE_RANK = {"subway": 5, "train": 4, "bus_station": 3, "tram": 2, "bus": 1, "ot
|
|||||||
# nodes with the same name closer than this are treated as one stop.
|
# nodes with the same name closer than this are treated as one stop.
|
||||||
MERGE_METERS = 250.0
|
MERGE_METERS = 250.0
|
||||||
|
|
||||||
|
MIN_STOPS = 100_000 # DACH sanity floor; refuse to overwrite a good snapshot with junk
|
||||||
|
|
||||||
|
|
||||||
def normalize_name(value: str) -> str:
|
def normalize_name(value: str) -> str:
|
||||||
"""Lowercase, fold accents and ß so search is diacritic-insensitive.
|
"""Lowercase, fold accents and ß so search is diacritic-insensitive.
|
||||||
@@ -112,46 +106,76 @@ def municipality_of(tags: dict) -> str:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def overpass(query: str, *, tries: int = 4) -> dict:
|
# --------------------------------------------------------------------------- #
|
||||||
body = urllib.parse.urlencode({"data": query}).encode()
|
# Geofabrik download
|
||||||
last_error: Exception | None = None
|
# --------------------------------------------------------------------------- #
|
||||||
for attempt in range(tries):
|
def download_extract(iso: str) -> Path:
|
||||||
endpoint = OVERPASS_ENDPOINTS[attempt % len(OVERPASS_ENDPOINTS)]
|
region = GEOFABRIK_REGIONS[iso]
|
||||||
try:
|
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
req = urllib.request.Request(
|
dest = CACHE_DIR / f"{region}-latest.osm.pbf"
|
||||||
endpoint, data=body, headers={"User-Agent": "pbt-stop-import/1.0"}
|
if dest.exists() and dest.stat().st_size > 0:
|
||||||
)
|
print(f" using cached {dest.name} ({dest.stat().st_size / 1e6:.0f} MB)", file=sys.stderr)
|
||||||
with urllib.request.urlopen(req, timeout=600) as resp:
|
return dest
|
||||||
return json.load(resp)
|
|
||||||
except Exception as exc: # noqa: BLE001 - retry on another mirror
|
url = f"https://download.geofabrik.de/europe/{region}-latest.osm.pbf"
|
||||||
last_error = exc
|
tmp = dest.with_suffix(".pbf.part")
|
||||||
print(f" {endpoint} failed ({exc}); retrying", file=sys.stderr)
|
print(f" downloading {url} ...", file=sys.stderr)
|
||||||
time.sleep(10 * (attempt + 1))
|
with urllib.request.urlopen(url, timeout=1800) as resp, open(tmp, "wb") as fh:
|
||||||
raise SystemExit(f"Overpass failed after {tries} tries; last error: {last_error}")
|
while True:
|
||||||
|
chunk = resp.read(1 << 20)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
fh.write(chunk)
|
||||||
|
tmp.rename(dest)
|
||||||
|
print(f" downloaded {dest.name} ({dest.stat().st_size / 1e6:.0f} MB)", file=sys.stderr)
|
||||||
|
return dest
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# Stops
|
# Stops
|
||||||
|
#
|
||||||
|
# All three passes below use osmium.FileProcessor with a native (C++-side)
|
||||||
|
# filter rather than SimpleHandler.apply_file(): filtering there means most
|
||||||
|
# objects never cross into Python at all. A plain SimpleHandler callback -
|
||||||
|
# even one that returns immediately - has to be invoked for every single
|
||||||
|
# node in the file, and that per-call overhead alone made a whole-country
|
||||||
|
# extract (tens of millions of nodes) impractically slow in testing.
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
def build_stop_rows(payload: dict) -> list[dict]:
|
def collect_stop_nodes(pbf_path: Path, iso: str) -> list[dict]:
|
||||||
by_name: dict[str, list[dict]] = {}
|
nodes: list[dict] = []
|
||||||
for element in payload.get("elements", []):
|
fp = osmium.FileProcessor(str(pbf_path)).with_filter(osmium.filter.EmptyTagFilter())
|
||||||
if element.get("type") != "node":
|
for obj in fp:
|
||||||
|
if not obj.is_node() or not obj.location.valid():
|
||||||
|
continue
|
||||||
|
tags = obj.tags
|
||||||
|
if not (
|
||||||
|
tags.get("highway") == "bus_stop"
|
||||||
|
or tags.get("railway") in ("tram_stop", "station", "halt")
|
||||||
|
or tags.get("public_transport") == "station"
|
||||||
|
or tags.get("amenity") == "bus_station"
|
||||||
|
):
|
||||||
continue
|
continue
|
||||||
tags = element.get("tags", {})
|
|
||||||
name = (tags.get("name") or "").strip()
|
name = (tags.get("name") or "").strip()
|
||||||
lat, lon = element.get("lat"), element.get("lon")
|
if not name:
|
||||||
if not name or lat is None or lon is None:
|
|
||||||
continue
|
continue
|
||||||
node = {
|
tags_dict = {t.k: t.v for t in tags}
|
||||||
"osm_id": int(element["id"]),
|
nodes.append({
|
||||||
|
"osm_id": int(obj.id),
|
||||||
"name": name,
|
"name": name,
|
||||||
"stop_type": classify(tags),
|
"stop_type": classify(tags_dict),
|
||||||
"municipality": municipality_of(tags),
|
"country": iso,
|
||||||
"latitude": round(float(lat), 6),
|
"municipality": municipality_of(tags_dict),
|
||||||
"longitude": round(float(lon), 6),
|
"latitude": round(obj.location.lat, 6),
|
||||||
}
|
"longitude": round(obj.location.lon, 6),
|
||||||
by_name.setdefault(normalize_name(name), []).append(node)
|
})
|
||||||
|
print(f" {iso}: {len(nodes)} raw stop nodes", file=sys.stderr)
|
||||||
|
return nodes
|
||||||
|
|
||||||
|
|
||||||
|
def build_stop_rows(nodes: list[dict]) -> list[dict]:
|
||||||
|
by_name: dict[str, list[dict]] = {}
|
||||||
|
for node in nodes:
|
||||||
|
by_name.setdefault(normalize_name(node["name"]), []).append(node)
|
||||||
|
|
||||||
rows: list[dict] = []
|
rows: list[dict] = []
|
||||||
for group in by_name.values():
|
for group in by_name.values():
|
||||||
@@ -180,6 +204,7 @@ def build_stop_rows(payload: dict) -> list[dict]:
|
|||||||
"osm_id": min(n["osm_id"] for n in cluster),
|
"osm_id": min(n["osm_id"] for n in cluster),
|
||||||
"name": lead["name"],
|
"name": lead["name"],
|
||||||
"stop_type": lead["stop_type"],
|
"stop_type": lead["stop_type"],
|
||||||
|
"country": lead["country"],
|
||||||
"municipality": lead["municipality"]
|
"municipality": lead["municipality"]
|
||||||
or next((n["municipality"] for n in cluster if n["municipality"]), ""),
|
or next((n["municipality"] for n in cluster if n["municipality"]), ""),
|
||||||
"latitude": round(sum(n["latitude"] for n in cluster) / len(cluster), 6),
|
"latitude": round(sum(n["latitude"] for n in cluster) / len(cluster), 6),
|
||||||
@@ -194,54 +219,52 @@ def build_stop_rows(payload: dict) -> list[dict]:
|
|||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# Lines (route relations -> stops)
|
# Lines (route relations -> stops)
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
def fetch_route_members() -> tuple[list[tuple[str, int]], dict[int, tuple[str, float, float]]]:
|
def collect_route_members(pbf_path: Path) -> tuple[list[tuple[str, int]], dict[int, tuple[str, float, float]]]:
|
||||||
"""Return (ref, node_id) pairs plus node_id -> (name, lat, lon)."""
|
"""Pass 1: which route relations do we care about, and which node ids do
|
||||||
seen_rel: set[int] = set()
|
their stop/platform members point at? Pass 2: resolve name/coordinates
|
||||||
ref_members: list[tuple[str, int]] = []
|
for exactly those node ids (they're often bare stop_position nodes with
|
||||||
|
no tags at all, so this can't be narrowed with a tag filter like the
|
||||||
|
stops pass - it has to look at every node, hence EntityFilter rather
|
||||||
|
than EmptyTagFilter to at least skip all ways in the same pass)."""
|
||||||
|
relations: list[tuple[list[str], list[int]]] = []
|
||||||
|
wanted_node_ids: set[int] = set()
|
||||||
|
|
||||||
|
fp1 = osmium.FileProcessor(str(pbf_path)).with_filter(osmium.filter.EntityFilter(osmium.osm.RELATION))
|
||||||
|
for obj in fp1:
|
||||||
|
tags = obj.tags
|
||||||
|
if tags.get("route") not in ROUTE_MODES:
|
||||||
|
continue
|
||||||
|
raw_ref = tags.get("ref")
|
||||||
|
if not raw_ref:
|
||||||
|
continue
|
||||||
|
# A few relations pack several numbers into one ref ("407, 413").
|
||||||
|
refs = [part.strip() for part in raw_ref.replace(",", ";").split(";") if part.strip()]
|
||||||
|
if not refs:
|
||||||
|
continue
|
||||||
|
member_ids = [m.ref for m in obj.members if m.type == "n" and m.role in STOP_ROLES]
|
||||||
|
if not member_ids:
|
||||||
|
continue
|
||||||
|
relations.append((refs, member_ids))
|
||||||
|
wanted_node_ids.update(member_ids)
|
||||||
|
|
||||||
|
print(
|
||||||
|
f" {len(relations)} route relations, "
|
||||||
|
f"{len(wanted_node_ids)} distinct stop-member nodes to resolve",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
|
||||||
node_pos: dict[int, tuple[str, float, float]] = {}
|
node_pos: dict[int, tuple[str, float, float]] = {}
|
||||||
|
fp2 = osmium.FileProcessor(str(pbf_path)).with_filter(osmium.filter.EntityFilter(osmium.osm.NODE))
|
||||||
|
for obj in fp2:
|
||||||
|
if obj.id not in wanted_node_ids or not obj.location.valid():
|
||||||
|
continue
|
||||||
|
node_pos[obj.id] = (obj.tags.get("name", ""), obj.location.lat, obj.location.lon)
|
||||||
|
|
||||||
south, west, north, east = BBOX
|
ref_members: list[tuple[str, int]] = []
|
||||||
lat = south
|
for refs, member_ids in relations:
|
||||||
tiles: list[tuple[float, float, float, float]] = []
|
for ref in refs:
|
||||||
while lat < north:
|
for node_id in member_ids:
|
||||||
lon = west
|
ref_members.append((ref, node_id))
|
||||||
while lon < east:
|
|
||||||
tiles.append((lat, lon, min(lat + LAT_STEP, north), min(lon + LON_STEP, east)))
|
|
||||||
lon += LON_STEP
|
|
||||||
lat += LAT_STEP
|
|
||||||
|
|
||||||
for i, (s, w, n, e) in enumerate(tiles, 1):
|
|
||||||
print(f" route tile {i}/{len(tiles)} ({s:.1f},{w:.1f})", file=sys.stderr)
|
|
||||||
query = (
|
|
||||||
f"[out:json][timeout:400];"
|
|
||||||
f'relation["type"="route"]["route"~"^({ROUTE_MODES})$"]["ref"]'
|
|
||||||
f"({s},{w},{n},{e})->.r;"
|
|
||||||
f".r out body;"
|
|
||||||
f"node(r.r);out body;"
|
|
||||||
)
|
|
||||||
payload = overpass(query)
|
|
||||||
for el in payload.get("elements", []):
|
|
||||||
if el["type"] == "node":
|
|
||||||
tags = el.get("tags", {})
|
|
||||||
node_pos[el["id"]] = (tags.get("name", ""), el["lat"], el["lon"])
|
|
||||||
elif el["type"] == "relation":
|
|
||||||
if el["id"] in seen_rel:
|
|
||||||
continue
|
|
||||||
seen_rel.add(el["id"])
|
|
||||||
# A few relations pack several numbers into one ref ("407, 413").
|
|
||||||
refs = [r.strip() for r in re.split(r"[;,]", el["tags"]["ref"]) if r.strip()]
|
|
||||||
if not refs:
|
|
||||||
continue
|
|
||||||
members = [
|
|
||||||
m["ref"] for m in el.get("members", [])
|
|
||||||
if m["type"] == "node" and m["role"] in STOP_ROLES
|
|
||||||
]
|
|
||||||
for ref in refs:
|
|
||||||
for node_id in members:
|
|
||||||
ref_members.append((ref, node_id))
|
|
||||||
del payload
|
|
||||||
|
|
||||||
print(f" {len(seen_rel)} routes, {len(ref_members)} stop memberships", file=sys.stderr)
|
|
||||||
return ref_members, node_pos
|
return ref_members, node_pos
|
||||||
|
|
||||||
|
|
||||||
@@ -302,16 +325,31 @@ def line_sort_key(ref: str):
|
|||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
print("Fetching stops ...", file=sys.stderr)
|
all_nodes: list[dict] = []
|
||||||
rows = build_stop_rows(overpass(STOPS_QUERY))
|
all_ref_members: list[tuple[str, int]] = []
|
||||||
if len(rows) < 10_000:
|
all_node_pos: dict[int, tuple[str, float, float]] = {}
|
||||||
|
|
||||||
|
for iso in GEOFABRIK_REGIONS:
|
||||||
|
print(f"=== {iso} ===", file=sys.stderr)
|
||||||
|
pbf_path = download_extract(iso)
|
||||||
|
|
||||||
|
print(" collecting stops ...", file=sys.stderr)
|
||||||
|
all_nodes.extend(collect_stop_nodes(pbf_path, iso))
|
||||||
|
|
||||||
|
print(" collecting routes ...", file=sys.stderr)
|
||||||
|
ref_members, node_pos = collect_route_members(pbf_path)
|
||||||
|
all_ref_members.extend(ref_members)
|
||||||
|
all_node_pos.update(node_pos)
|
||||||
|
|
||||||
|
print("Clustering stops ...", file=sys.stderr)
|
||||||
|
rows = build_stop_rows(all_nodes)
|
||||||
|
if len(rows) < MIN_STOPS:
|
||||||
raise SystemExit(
|
raise SystemExit(
|
||||||
f"Only {len(rows)} stops parsed - refusing to overwrite the snapshot."
|
f"Only {len(rows)} stops parsed (< {MIN_STOPS}) - refusing to overwrite the snapshot."
|
||||||
)
|
)
|
||||||
|
|
||||||
print("Fetching routes ...", file=sys.stderr)
|
print("Matching lines to stops ...", file=sys.stderr)
|
||||||
ref_members, node_pos = fetch_route_members()
|
matched, unmatched = assign_lines(rows, all_ref_members, all_node_pos)
|
||||||
matched, unmatched = assign_lines(rows, ref_members, node_pos)
|
|
||||||
|
|
||||||
OUT_PATH.parent.mkdir(parents=True, exist_ok=True)
|
OUT_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
with gzip.open(OUT_PATH, "wt", newline="", encoding="utf-8") as fh:
|
with gzip.open(OUT_PATH, "wt", newline="", encoding="utf-8") as fh:
|
||||||
@@ -322,10 +360,13 @@ def main() -> None:
|
|||||||
writer.writerow(row)
|
writer.writerow(row)
|
||||||
|
|
||||||
by_type: dict[str, int] = {}
|
by_type: dict[str, int] = {}
|
||||||
|
by_country: dict[str, int] = {}
|
||||||
for row in rows:
|
for row in rows:
|
||||||
by_type[row["stop_type"]] = by_type.get(row["stop_type"], 0) + 1
|
by_type[row["stop_type"]] = by_type.get(row["stop_type"], 0) + 1
|
||||||
|
by_country[row["country"]] = by_country.get(row["country"], 0) + 1
|
||||||
with_lines = sum(1 for r in rows if r["lines"])
|
with_lines = sum(1 for r in rows if r["lines"])
|
||||||
print(f"Wrote {len(rows)} stops to {OUT_PATH}", file=sys.stderr)
|
print(f"Wrote {len(rows)} stops to {OUT_PATH}", file=sys.stderr)
|
||||||
|
print(f" by country: {by_country}", file=sys.stderr)
|
||||||
print(f" by type: {by_type}", file=sys.stderr)
|
print(f" by type: {by_type}", file=sys.stderr)
|
||||||
print(
|
print(
|
||||||
f" lines: {with_lines} stops have >=1 line "
|
f" lines: {with_lines} stops have >=1 line "
|
||||||
|
|||||||
+1
-1
@@ -152,7 +152,7 @@
|
|||||||
}
|
}
|
||||||
var kind = document.createElement("span");
|
var kind = document.createElement("span");
|
||||||
kind.className = "kind";
|
kind.className = "kind";
|
||||||
kind.textContent = TYPE_LABEL[stop.type] || "";
|
kind.textContent = (TYPE_LABEL[stop.type] || "") + " · " + stop.country;
|
||||||
el.appendChild(kind);
|
el.appendChild(kind);
|
||||||
},
|
},
|
||||||
onChoose: function (stop) { input.value = stop.name; markMatch(stop); },
|
onChoose: function (stop) { input.value = stop.name; markMatch(stop); },
|
||||||
|
|||||||
+7
-6
@@ -1,8 +1,8 @@
|
|||||||
"""Load the checked-in Austrian stop snapshot into the ``stops`` table.
|
"""Load the checked-in DACH stop snapshot into the ``stops`` table.
|
||||||
|
|
||||||
The snapshot (data/stops_at.csv.gz) is produced by scripts/fetch_stops.py from
|
The snapshot (data/stops_dach.csv.gz) is produced by scripts/fetch_stops.py
|
||||||
OpenStreetMap data (© OpenStreetMap contributors, ODbL). Importing needs no
|
from OpenStreetMap data (© OpenStreetMap contributors, ODbL) for Austria,
|
||||||
network access.
|
Germany and Switzerland. Importing needs no network access.
|
||||||
|
|
||||||
flask import-stops # import the checked-in snapshot
|
flask import-stops # import the checked-in snapshot
|
||||||
flask import-stops --file X # import an alternative CSV(.gz)
|
flask import-stops --file X # import an alternative CSV(.gz)
|
||||||
@@ -21,7 +21,7 @@ from flask.cli import with_appcontext
|
|||||||
|
|
||||||
from models import Stop, Trip, db
|
from models import Stop, Trip, db
|
||||||
|
|
||||||
SNAPSHOT_PATH = Path(__file__).resolve().parent / "data" / "stops_at.csv.gz"
|
SNAPSHOT_PATH = Path(__file__).resolve().parent / "data" / "stops_dach.csv.gz"
|
||||||
|
|
||||||
|
|
||||||
def normalize_name(value: str) -> str:
|
def normalize_name(value: str) -> str:
|
||||||
@@ -69,6 +69,7 @@ def import_stops(path: Path | str = SNAPSHOT_PATH) -> dict[str, int]:
|
|||||||
name=name,
|
name=name,
|
||||||
name_normalized=normalize_name(name),
|
name_normalized=normalize_name(name),
|
||||||
stop_type=row["stop_type"] or "other",
|
stop_type=row["stop_type"] or "other",
|
||||||
|
country=(row.get("country") or "AT").strip().upper(),
|
||||||
municipality=(row.get("municipality") or "").strip() or None,
|
municipality=(row.get("municipality") or "").strip() or None,
|
||||||
latitude=float(row["latitude"]),
|
latitude=float(row["latitude"]),
|
||||||
longitude=float(row["longitude"]),
|
longitude=float(row["longitude"]),
|
||||||
@@ -120,7 +121,7 @@ def import_stops(path: Path | str = SNAPSHOT_PATH) -> dict[str, int]:
|
|||||||
)
|
)
|
||||||
@with_appcontext
|
@with_appcontext
|
||||||
def import_stops_command(file_path: str | None) -> None:
|
def import_stops_command(file_path: str | None) -> None:
|
||||||
"""Import Austrian public-transport stops into the database."""
|
"""Import DACH public-transport stops into the database."""
|
||||||
stats = import_stops(file_path or SNAPSHOT_PATH)
|
stats = import_stops(file_path or SNAPSHOT_PATH)
|
||||||
click.echo(
|
click.echo(
|
||||||
"Stops imported: "
|
"Stops imported: "
|
||||||
|
|||||||
Reference in New Issue
Block a user