Spanien (Renfe + Cercanías), Polen, Norwegen, Dänemark, Österreich (ÖBB + Wiener Linien), Schweden (Key)

- mehrere Quellen je Land (country), Grenzen aus Natural Earth, OSM und Kachelausschnitt erweitert
- Fahrpläne glätten: Unterordner im Zip, aufgefüllte Felder, fehlende parent_station, level_id
- Gattung aus der Zugnummer (ÖBB 'RJ 658'), Fernverkehr auch über Routentyp 101/102/105
- Echtzeit: Verspätung vor Uhrzeit (Renfe), Fahrten ohne Datum, Key in der Adresse (Trafiklab)
- httpx-Logs aus (würden Keys zeigen)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X48pE4bzjHej8w9HyZXCjb
This commit is contained in:
Claude
2026-10-23 09:53:56 +02:00
parent 650f8dca8c
commit ef67df6e00
12 changed files with 247 additions and 57 deletions
+1 -1
View File
@@ -7,7 +7,7 @@ import json
from pathlib import Path
_RAW = json.loads((Path(__file__).with_name("borders.json")).read_text())
ORDER = ["ch", "de", "fr", "be", "nl"] # Schweiz zuerst: genauere Grenze, gewinnt bei Überlappung
ORDER = ["ch", "de", "fr", "be", "nl", "at", "dk", "es", "no", "pl", "se"] # Schweiz zuerst: genauere Grenze, gewinnt bei Überlappung
CELL = 0.01 # ~1 km; im Grenzbereich wird je Zelle einmal gerechnet und gemerkt
File diff suppressed because one or more lines are too long
+59 -2
View File
@@ -44,8 +44,59 @@ SOURCES = {
"needs_key": False,
"rt_interval": 60,
},
# Spanien: Renfe hat Fern-/Regionalverkehr und Cercanías (S-Bahn) als getrennte Fahrpläne
"es": { # Renfe AVE, Alvia, Media Distancia …; Echtzeit ohne Datum, Trip-IDs enthalten den Tag
"gtfs_url": "https://ssl.renfe.com/gtransit/Fichero_AV_LD/google_transit.zip",
"rt_url": "https://gtfsrt.renfe.com/trip_updates_LD.pb",
"rt_key": "", "needs_key": False, "rt_interval": 60,
},
"es_cer": { # Renfe Cercanías/Rodalies (alles S-Bahn)
"country": "es", "all_sbahn": True,
"gtfs_url": "https://ssl.renfe.com/ftransit/Fichero_CER_FOMENTO/fomento_transit.zip",
"rt_url": "https://gtfsrt.renfe.com/trip_updates.pb",
"rt_key": "", "needs_key": False, "rt_interval": 60,
},
"pl": { # alle Bahnbetreiber, aufbereitet von Mikołaj Kuranowski (mkuran.pl); Echtzeit CC BY 4.0
"gtfs_url": "https://mkuran.pl/gtfs/polish_trains.zip",
"rt_url": "https://mkuran.pl/gtfs/polish_trains/updates.pb",
"rt_key": "", "needs_key": False,
"rt_interval": 120, # ~5 MB pro Abruf
},
"no": { # Entur (NLOD); Gesamt-Echtzeit wäre 15 MB, darum nur die Betreiber von Bahn, Tram, T-bane
"gtfs_url": "https://storage.googleapis.com/marduk-production/outbound/gtfs/rb_norway-aggregated-gtfs.zip",
"rt_url": " ".join(f"https://api.entur.io/realtime/v1/gtfs-rt/trip-updates?datasource={ds}" for ds in ("VYG", "FLT", "GOA", "RUT", "ATB", "SKY")),
"rt_key": "", "needs_key": False, "rt_interval": 60,
},
"dk": { # Rejseplanen (alle Verkehrsmittel); keine offene Echtzeit
"gtfs_url": "https://www.rejseplanen.info/labs/GTFS.zip",
"rt_url": "", "rt_key": "", "needs_key": False, "rt_interval": 60,
},
"at": { # ÖBB (CC BY 4.0), einmal pro Fahrplanjahr ({year}); keine offene Echtzeit
"gtfs_url": "https://static.web.oebb.at/open-data/soll-fahrplan-gtfs/GTFS_Fahrplan_{year}.zip",
"rt_url": "", "rt_key": "", "needs_key": False, "rt_interval": 60,
},
"at_wl": { # Wiener Linien (Tram, U-Bahn, Badner Bahn), CC BY 4.0
"country": "at",
"gtfs_url": "http://www.wienerlinien.at/ogd_realtime/doku/ogd/gtfs/gtfs.zip",
"rt_url": "", "rt_key": "", "needs_key": False, "rt_interval": 60,
},
"se": { # Trafiklab GTFS Sweden 3 (alle Züge); kostenloser Key nötig, auch für den Fahrplan
"gtfs_url": "https://opendata.samtrafiken.se/gtfs-sweden/sweden.zip?key={key}",
# Echtzeit nur für einige Regionen (Stockholm, Skåne); Züge landesweit noch nicht
"rt_url": " ".join("https://opendata.samtrafiken.se/gtfs-rt-sweden/{op}/TripUpdatesSweden.pb?key={key}".replace("{op}", op) for op in ("sl", "skane")),
"rt_key": os.environ.get("TRAINPULSE_SE_KEY", ""),
"needs_key": True, "static_needs_key": True,
# Bronze-Key: 30 000 Echtzeit- und 50 Fahrplan-Abrufe im Monat
"rt_interval": int(os.environ.get("TRAINPULSE_SE_RT_INTERVAL") or 300),
"check_hours": 48,
},
}
ENABLED = [s for s in os.environ.get("TRAINPULSE_SOURCES", "ch,de,fr,nl,be").split(",") if s in SOURCES]
ENABLED = [s for s in (os.environ.get("TRAINPULSE_SOURCES") or ",".join(SOURCES)).split(",") if s in SOURCES]
def country(source: str) -> str:
"""Land einer Quelle (Spanien und Österreich haben je zwei)."""
return SOURCES[source].get("country", source)
# OpenStreetMap: daraus werden das Schienennetz (für pfaedle) gefiltert
OSM_URLS = os.environ.get("TRAINPULSE_OSM_URLS", " ".join([
@@ -54,10 +105,16 @@ OSM_URLS = os.environ.get("TRAINPULSE_OSM_URLS", " ".join([
"https://download.geofabrik.de/europe/france-latest.osm.pbf",
"https://download.geofabrik.de/europe/netherlands-latest.osm.pbf",
"https://download.geofabrik.de/europe/belgium-latest.osm.pbf",
"https://download.geofabrik.de/europe/austria-latest.osm.pbf",
"https://download.geofabrik.de/europe/denmark-latest.osm.pbf",
"https://download.geofabrik.de/europe/spain-latest.osm.pbf",
"https://download.geofabrik.de/europe/norway-latest.osm.pbf",
"https://download.geofabrik.de/europe/poland-latest.osm.pbf",
"https://download.geofabrik.de/europe/sweden-latest.osm.pbf",
])).split()
PFAEDLE = os.environ.get("TRAINPULSE_PFAEDLE", "pfaedle")
OSMIUM = os.environ.get("TRAINPULSE_OSMIUM", "osmium")
PMTILES = os.environ.get("TRAINPULSE_PMTILES", "pmtiles")
TILES_BBOX = "-5.3,41.3,15.3,55.2" # CH, DE, FR, NL, BE mit etwas Rand
TILES_BBOX = "-9.6,35.8,31.3,71.3" # von Galicien bis Nordkap, Andalusien bis Finnmark
TILES_MAXZOOM = 14 # die Karte vergrössert Vektorkacheln darüber hinaus selbst; spart viel Platz
USER_AGENT = "trainpulse/0.1 (+https://scarriffle.com)"
+35 -15
View File
@@ -60,7 +60,13 @@ def travelled(length: float, duration: float, t: float) -> float:
# Gattungen des Fernverkehrs; alles andere (RE, RB, S, R, TER, …) gilt als Nahverkehr
LONG_DISTANCE = {"ICE", "IC", "ICN", "ICD", "EC", "ECE", "ECM", "EN", "NJ", "RJ", "RJX", "TGV", "OUIGO", "LYRIA",
"THA", "EST", "EUROSTAR", "EUROCITY", "NIGHTJET", "EUROPEAN", "FLX", "IR", "PE", "D"}
"THA", "EST", "EUROSTAR", "EUROCITY", "NIGHTJET", "EUROPEAN", "FLX", "IR", "PE", "D",
"AVE", "AVLO", "ALVIA", "EUROMED", # Spanien
"EIC", "EIP", "TLK", # Polen
"ICL", "WB", "SJ", "SNT", "X"} # Dänemark (IC Lyn), Westbahn, Schweden (SJ, Snälltåget, X2000)
# Zugnummer mit Gattung ("RJ 658"): bei ÖBB steht der Fernverkehr nur so, die Linie heisst "A10-1"
TRIP_CAT = re.compile(r"^([A-Z]{1,5}) ?(\d+)$")
NO_FJERNTOG = re.compile(r"^F\d+$") # Norwegen: F4, F5, F6 = Fernzüge (aber dänische S-Bahn "F" nicht)
# Frankreich (SNCF): Gattung steht nur in der Trip-ID ("…_F:OUI:…"), die Linie heisst oft "INCONNU"
FR_CATEGORY = {"OUI": "TGV", "OGO": "OUIGO", "LYR": "Lyria", "ICE": "ICE", "IC": "IC", "ICN": "IC",
"TER": "TER", "CTE": "TER", "NAV": "Navette", "TRN": "TER", "CRE": "TER"}
@@ -87,16 +93,16 @@ def route_mode(route_type: str) -> str | None:
SBAHN = re.compile(r"^(SN? ?\d+[A-Z]?|S)$") # S1, S 5, S41, SN7 (Nacht-S-Bahn), nur "S"
def sbahn(cat: str, line: str) -> bool:
"""S-Bahn: Schweiz über die Kategorie, Deutschland/Belgien über den Liniennamen. Niederländische
Sprinter und französische TER zählen als Regionalzug."""
return cat in ("S", "SN") or bool(SBAHN.match(line))
def sbahn(cat: str, line: str, route_type: str = "") -> bool:
"""S-Bahn: Schweiz über die Kategorie, Deutschland/Belgien über den Liniennamen, Dänemark (S-tog)
über den Routentyp 109. Niederländische Sprinter und französische TER zählen als Regionalzug."""
return cat in ("S", "SN") or route_type == "109" or bool(SBAHN.match(line))
def long_distance(cat: str, line: str) -> bool:
# Schweiz: Gattung in route_desc; gtfs.de: nur in der Linie ("ICE 75", "IC 3")
m = re.match(r"[A-Za-z]+", cat or line or "")
return bool(m) and m.group(0).upper() in LONG_DISTANCE
return bool(m) and m.group(0).upper() in LONG_DISTANCE or bool(NO_FJERNTOG.match(line or ""))
MIN_DWELL = 40 # s; im Fahrplan steht oft Ankunft = Abfahrt, dann würde der Zug nie sichtbar halten
@@ -142,7 +148,7 @@ def simplify(p: list, tol_m: float, fixed=()) -> list:
class Feed:
def __init__(self, path: Path, today: dt.date | None = None):
def __init__(self, path: Path, today: dt.date | None = None, all_sbahn: bool = False):
self.path = path
self.meta = json.loads((path / "meta.json").read_text())
self.version = self.meta["feed_version"]
@@ -163,11 +169,13 @@ class Feed:
self.routes = {}
for h, r in read(path / "routes.txt"):
line = LINE_PREFIX.sub("", r[h["route_short_name"]] or r[h.get("route_long_name", 0)])
if len(line) > 12: # Entur: "Ø Göteborg-Halmstad-…" -> "Ø"; Kartenbeschriftung bleibt kurz
line = line.split()[0][:12]
self.routes[r[h["route_id"]]] = {
"line": LINE_ALIAS.get(line, "" if line == "INCONNU" else line),
"cat": r[h["route_desc"]] if "route_desc" in h else "",
"agency": agencies.get(r[h["agency_id"]], "") if "agency_id" in h else "",
"mode": route_mode(r[h["route_type"]]) or "rail",
"mode": route_mode(r[h["route_type"]]) or "rail", "type": r[h["route_type"]],
}
self.trip_index, self.trip_ids, self.trip_route, self.trip_service = {}, [], [], []
@@ -188,8 +196,15 @@ class Feed:
self.trip_number[-1], self.trip_headsign[-1] = self.trip_headsign[-1], ""
for route in self.routes.values():
route["line"] = route["line"] or route["cat"]
route["fv"] = route["mode"] == "rail" and long_distance(route["cat"], route["line"])
route["sb"] = route["mode"] == "rail" and not route["fv"] and sbahn(route["cat"], route["line"])
# Routentyp 101/102/105 (Hochgeschwindigkeit, Fern-, Nachtzug): Schweden kennt sonst nur Nummern
route["fv"] = route["mode"] == "rail" and (long_distance(route["cat"], route["line"])
or route["type"] in ("101", "102", "105"))
route["sb"] = route["mode"] == "rail" and not route["fv"] and (all_sbahn or sbahn(route["cat"], route["line"], route["type"]))
self.trip_cat = {} # Fahrt -> Gattung aus der Zugnummer, wo die Linie keine Fernverkehrs-Gattung hat
for ti, num in enumerate(self.trip_number):
m = TRIP_CAT.match(num) if num and not num.isdigit() else None
if m and m.group(1) in LONG_DISTANCE and not self.routes[self.trip_route[ti]]["fv"]:
self.trip_cat[ti], self.trip_number[ti] = m.group(1), m.group(2)
# Haltezeiten flach in Arrays; off[i]..off[i+1] sind die Halte von Fahrt i
n = len(self.trip_route)
@@ -382,7 +397,7 @@ class Feed:
break # Liste ist nach Abfahrt sortiert
if end + 7200 < t: # grosszügig, damit verspätete Züge nicht verschwinden
continue
r = rt.get((self.trip_ids[ti], dstr))
r = self.rt_for(rt, ti, dstr)
if r and r[0]:
continue # ausgefallen
arr, dep, skip = self.times(ti, ds, r[1] if r else None)
@@ -412,12 +427,13 @@ class Feed:
(x1, y1), (x2, y2) = self.point_at(g, dist - 25), self.point_at(g, dist + 25)
dx, dy = (x2 - x1) * math.cos(math.radians(lat)), y2 - y1
heading = round(math.degrees(math.atan2(dx, dy)), 1) if dx or dy else None
route = self.routes[self.trip_route[ti]]
route, tc = self.routes[self.trip_route[ti]], self.trip_cat.get(ti)
feats.append({
"type": "Feature",
"geometry": {"type": "Point", "coordinates": [lon, lat]},
"properties": {
"id": f"{dstr}/{ti}", "line": route["line"], "cat": route["cat"], "fv": route["fv"], "sb": route["sb"], "mode": route["mode"],
"id": f"{dstr}/{ti}", "line": tc or route["line"], "cat": route["cat"], "mode": route["mode"],
"fv": bool(tc) or route["fv"], "sb": not tc and route["sb"],
"nr": self.trip_number[ti], "from": self.stop_name[self.st_stop[o]], "to": self.trip_headsign[ti],
"delay": delay if r else None, "next": self.stop_name[self.st_stop[o + i]],
"heading": heading,
@@ -427,10 +443,14 @@ class Feed:
})
return feats
def rt_for(self, rt: dict, ti: int, date: str):
# Renfe liefert kein Datum; dort ist die Trip-ID je Betriebstag eindeutig
return rt.get((self.trip_ids[ti], date)) or rt.get((self.trip_ids[ti], ""))
def trip_detail(self, date: str, ti: int, now: float, rt: dict):
d = dt.datetime.strptime(date, "%Y%m%d").date()
ds = day_start(d)
r = rt.get((self.trip_ids[ti], date))
r = self.rt_for(rt, ti, date)
arr, dep, skip = self.times(ti, ds, r[1] if r else None)
o0, o1 = self.off[ti], self.off[ti + 1]
@@ -450,7 +470,7 @@ class Feed:
"passed": now - ds > dep[k], "skipped": k in skip,
})
route = self.routes[self.trip_route[ti]]
return {"line": route["line"], "cat": route["cat"], "agency": route["agency"],
return {"line": self.trip_cat.get(ti, route["line"]), "cat": route["cat"], "agency": route["agency"],
"nr": self.trip_number[ti], "to": self.trip_headsign[ti],
"realtime": bool(r), "canceled": bool(r and r[0]) or len(skip) == len(stops),
"stops": stops, "shape": self.geometry(ti)[0]}
+43 -7
View File
@@ -43,7 +43,9 @@ def timetable_year(today: dt.date) -> int:
def gtfs_url(src: str) -> str:
url = C.SOURCES[src]["gtfs_url"]
cfg = C.SOURCES[src]
# {year}: ÖBB (Datei je Fahrplanjahr), {key}: Schweden (Key in der Adresse)
url = cfg["gtfs_url"].replace("{year}", str(timetable_year(dt.date.today()))).replace("{key}", cfg["rt_key"])
if src == "ch" and not url:
# shortcut: in der Nacht des Fahrplanwechsels fehlen bis zum nächsten Stundenlauf die Züge
url = f"https://data.opentransportdata.swiss/dataset/timetable-{timetable_year(dt.date.today())}-gtfs2020/permalink"
@@ -132,6 +134,28 @@ def filter_by_col(z: zipfile.ZipFile, name: str, col: str, keys: set, out: Path,
return n, seen
def normalize_zip(zip_path: Path, work: Path) -> Path:
"""Eigenheiten mancher Fahrpläne glätten: Dateien in einem Unterordner (ÖBB) und mit Leerzeichen
aufgefüllte Felder ("10T0001C1 ", Renfe Cercanías). Sonst die Datei unverändert."""
z = zipfile.ZipFile(zip_path)
members = {Path(n).name: n for n in z.namelist() if n.endswith(".txt")}
padded = any(c != c.strip() for n in members.values() for c in rows(z, n)[0])
if not padded and all(members[k] == k for k in members):
return zip_path
out = work / "normalized.zip"
with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED, compresslevel=1) as o:
for base, name in members.items():
with z.open(name) as src, o.open(base, "w") as dst:
if not padded:
shutil.copyfileobj(src, dst, 1 << 20)
continue
with io.TextIOWrapper(dst, encoding="utf-8", newline="") as t: # schliessen = alles schreiben
w = csv.writer(t)
for row in csv.reader(io.TextIOWrapper(src, encoding="utf-8-sig", newline="")):
w.writerow([c.strip() for c in row])
return out
def filter_rail(zip_path: Path, out: Path, version: str | None = None) -> dict:
out.mkdir(parents=True)
z = zipfile.ZipFile(zip_path)
@@ -159,9 +183,12 @@ def filter_rail(zip_path: Path, out: Path, version: str | None = None) -> dict:
n_st, used_stops = filter_by_col(z, "stop_times.txt", "trip_id", trip_ids, out / "stop_times.txt", "stop_id")
h, rd = rows(z, "stops.txt")
sid, par = h.index("stop_id"), h.index("parent_station")
sid = h.index("stop_id")
stops = list(rd)
parents = {r[par] for r in stops if r[sid] in used_stops}
parents = {r[h.index("parent_station")] for r in stops if r[sid] in used_stops} if "parent_station" in h else set()
if "level_id" in h: # levels.txt kopieren wir nicht (ÖBB) -> Verweise leeren, sonst bricht pfaedle ab
for r in stops:
r[h.index("level_id")] = ""
write_csv(out / "stops.txt", h, (r for r in stops if r[sid] in used_stops or r[sid] in parents))
for name in ("calendar.txt", "calendar_dates.txt"):
@@ -223,7 +250,7 @@ def run_pfaedle(work: Path, rail: Path, source: str) -> Path | None:
# Netz aufs Land (+ Rand) zuschneiden: die Fahrpläne enthalten Züge bis Paris oder Hamburg,
# pfaedle würde sonst das Netz aller Länder laden. Ausserhalb zeigt ohnehin der Nachbar-Fahrplan.
osm = work / "region.osm.pbf"
subprocess.run([C.OSMIUM, "extract", "-b", border.bbox(source), str(ensure_osm()), "-o", str(osm),
subprocess.run([C.OSMIUM, "extract", "-b", border.bbox(C.country(source)), str(ensure_osm()), "-o", str(osm),
"--overwrite"], check=True)
log.info("[%s] pfaedle rechnet Streckenverläufe …", source)
t = time.time()
@@ -308,10 +335,16 @@ def prepare(source: str, state: dict, zip_path: Path | None = None, force: bool
shutil.rmtree(work, ignore_errors=True)
work.mkdir()
version = None
cfg, version = C.SOURCES[source], None
if zip_path:
name = zip_path.name
else:
if cfg.get("static_needs_key") and not cfg["rt_key"]:
log.info("[%s] Kein Key eingetragen – übersprungen", source)
return
if time.time() - state.get(source + "@checked", 0) < cfg.get("check_hours", 0) * 3600 and not force:
return # Anbieter mit knappem Abruf-Kontingent (Schweden) nicht stündlich fragen
state[source + "@checked"] = time.time()
url = gtfs_url(source)
name, version = remote_id(source, url)
if f"{name} #{FORMAT}" == state.get(source) and not force:
@@ -322,10 +355,12 @@ def prepare(source: str, state: dict, zip_path: Path | None = None, force: bool
download(url, zip_path)
t = time.time()
version = version or zip_path.stem # (nach dem Glätten hiesse die Datei sonst "normalized")
zip_path = normalize_zip(zip_path, work)
meta = filter_rail(zip_path, work / "rail", version)
log.info("[%s] Bahn-Filter: %s in %.0f s", source, meta, time.time() - t)
if zip_path.parent == work:
zip_path.unlink()
for p in (work / "gtfs.zip", work / "normalized.zip"):
p.unlink(missing_ok=True)
shaped = run_pfaedle(work, work / "rail", source)
meta["shapes"] = shaped is not None
@@ -347,6 +382,7 @@ def main() -> None:
ap.add_argument("--force", action="store_true")
args = ap.parse_args()
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
logging.getLogger("httpx").setLevel(logging.WARNING) # würde Adressen samt Key (Schweden) loggen
if args.zip and not args.source:
ap.error("--zip braucht --source")
+6 -3
View File
@@ -27,7 +27,8 @@ def parse(data: bytes, known_trips: dict):
for name in ("arrival", "departure"):
if s.HasField(name):
x = getattr(s, name)
ev += [x.time, True] if x.HasField("time") else [x.delay, False] if x.HasField("delay") else [None, False]
# Verspätung vor Uhrzeit: Renfe liefert beides, und die Uhrzeit ist dort oft falsch
ev += [x.delay, False] if x.HasField("delay") else [x.time, True] if x.HasField("time") else [None, False]
else:
ev += [None, False]
upd.append((s.stop_sequence if s.HasField("stop_sequence") else None, s.stop_id, *ev,
@@ -38,8 +39,10 @@ def parse(data: bytes, known_trips: dict):
def fetch(url: str, key: str = "") -> bytes:
headers = {"User-Agent": C.USER_AGENT}
if key:
headers = {"User-Agent": C.USER_AGENT, "ET-Client-Name": "trainpulse"} # Entur möchte eine Kennung
if "{key}" in url: # Schweden: Key in der Adresse
url = url.replace("{key}", key)
elif key:
headers["Authorization"] = f"Bearer {key}"
r = httpx.get(url, follow_redirects=True, timeout=30, headers=headers)
r.raise_for_status()
+4 -2
View File
@@ -98,7 +98,7 @@ def watch_feeds():
continue
log.info("[%s] Lade Fahrplan %s …", src.name, want)
t = time.time()
src.feed = Feed(C.FEEDS_DIR / src.name / want)
src.feed = Feed(C.FEEDS_DIR / src.name / want, all_sbahn=src.cfg.get("all_sbahn", False))
src.loaded_at = time.time()
log.info("[%s] Fahrplan %s geladen in %.0f s (%d Fahrten)", src.name, want, time.time() - t,
len(src.feed.trip_ids))
@@ -132,7 +132,8 @@ def poll_realtime(src: Source):
def positions(src: Source, t: float, ahead: tuple = ()) -> list:
feats = []
for f in src.feed.vehicles(t, src.rt, ahead):
if len(S.sources) > 1 and not border.shows(src.name, S.sources, *f["geometry"]["coordinates"]):
if len(S.sources) > 1 and not border.shows(C.country(src.name), {C.country(n) for n in S.sources},
*f["geometry"]["coordinates"]):
continue # gehört zum Fahrplan des Nachbarlandes
f["properties"]["id"] = f"{src.name}/{f['properties']['id']}"
feats.append(f)
@@ -173,6 +174,7 @@ def compute_positions():
@asynccontextmanager
async def lifespan(app):
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
logging.getLogger("httpx").setLevel(logging.WARNING) # jeder Echtzeit-Abruf, z. T. mit Key in der Adresse
C.FEEDS_DIR.mkdir(parents=True, exist_ok=True)
loop(60, watch_feeds)
for src in S.sources.values():