This commit is contained in:
Andras Schmelczer 2026-07-12 20:37:39 +01:00
parent 9e4e65fa2a
commit ca771a7edf
32 changed files with 1467 additions and 109 deletions

View file

@ -7,9 +7,13 @@ from pathlib import Path
import pytest
from pipeline.download.transit_network import (
STATION_COORD_OVERRIDES,
_repair_stop_coordinate,
clean_national_rail_gtfs,
convert_high_freq_to_frequency_based,
validate_gtfs_feed,
validate_london_coverage,
validate_stop_geometry,
)
@ -231,9 +235,7 @@ def test_validate_gtfs_feed_zero_and_empty_coords(tmp_path: Path) -> None:
feed = _make_gtfs(
tmp_path / "feed.zip",
stops=(
"stop_id,stop_name,stop_lat,stop_lon\n"
"STOP_A,Nowhere,0,0\n"
"STOP_B,Blank,,\n"
"stop_id,stop_name,stop_lat,stop_lon\nSTOP_A,Nowhere,0,0\nSTOP_B,Blank,,\n"
),
)
with pytest.raises(RuntimeError, match=r"plausible UK coordinates"):
@ -281,3 +283,254 @@ def test_validate_gtfs_feed_not_a_zip(tmp_path: Path) -> None:
bogus.write_text("not a zip")
with pytest.raises(RuntimeError, match="not a valid zip"):
validate_gtfs_feed(bogus, "bogus feed", today=TODAY)
# ── _repair_stop_coordinate ───────────────────────────────────────────────────
@pytest.mark.parametrize(
"stop_id,lat,lon,expected",
[
# Known-bad stations get an authoritative override (TCR ships transposed,
# BDS ships a wrong-signed longitude; both are in STATION_COORD_OVERRIDES).
("TCR", -0.1306, 51.5163, (*STATION_COORD_OVERRIDES["TCR"], "override")),
("BDS", 51.514, 0.15, (*STATION_COORD_OVERRIDES["BDS"], "override")),
# A plausible UK coordinate is left untouched.
("ZFD", 51.5205, -0.1050, (51.5205, -0.1050, "keep")),
# An unknown station with lat/lon transposed is swapped back generically.
("ZZZ", -0.13, 51.51, (51.51, -0.13, "transpose")),
# Genuinely out-of-area garbage (Irish CIE South Atlantic) is neutralised.
("IEP", -4.172, -14.5154, (54.0, -2.0, "dump")),
# Missing coordinates are neutralised too.
("NUL", None, None, (54.0, -2.0, "dump")),
],
)
def test_repair_stop_coordinate(stop_id, lat, lon, expected) -> None:
assert _repair_stop_coordinate(stop_id, lat, lon) == expected
def test_clean_national_rail_repairs_broken_station_coords(tmp_path: Path) -> None:
"""End-to-end: the cleaner repairs the exact TCR/BDS failure modes.
TCR (transposed) and BDS (wrong-signed lon) are corrected to their override
coordinates; an unknown transposed stop is swapped back; genuine out-of-area
garbage is dumped; a good coordinate is preserved.
"""
src = tmp_path / "in.zip"
dst = tmp_path / "out.zip"
stops = (
"stop_id,stop_name,stop_lat,stop_lon\n"
"TCR,Tottenham Court Road (Elizabeth line),-0.1306,51.5163\n"
"BDS,Bond Street (Elizabeth line),51.514,0.15\n"
"ZZZ,Transposed Halt,-0.20,51.40\n"
"IEP,Cork (CIE),-4.172,-14.5154\n"
"GUD,Good Station,51.50,-0.10\n"
)
with zipfile.ZipFile(src, "w") as z:
z.writestr("stops.txt", stops)
z.writestr("routes.txt", "route_id,route_type\nR1,2\n")
z.writestr("trips.txt", "trip_id,route_id,service_id\nT1,R1,S1\n")
z.writestr(
"stop_times.txt",
"trip_id,stop_id,stop_sequence,departure_time\n"
"T1,TCR,1,06:00:00\n"
"T1,BDS,2,06:03:00\n"
"T1,GUD,3,06:06:00\n",
)
clean_national_rail_gtfs(src, dst)
with zipfile.ZipFile(dst, "r") as z:
rows = z.read("stops.txt").decode("utf-8").splitlines()
coords = {r.split(",")[0]: r.split(",")[-2:] for r in rows[1:]}
assert (
float(coords["TCR"][0]),
float(coords["TCR"][1]),
) == STATION_COORD_OVERRIDES["TCR"]
assert (
float(coords["BDS"][0]),
float(coords["BDS"][1]),
) == STATION_COORD_OVERRIDES["BDS"]
assert (float(coords["ZZZ"][0]), float(coords["ZZZ"][1])) == (51.40, -0.20)
assert (float(coords["IEP"][0]), float(coords["IEP"][1])) == (54.0, -2.0)
assert (float(coords["GUD"][0]), float(coords["GUD"][1])) == (51.50, -0.10)
# ── validate_stop_geometry ────────────────────────────────────────────────────
def _geometry_gtfs(path: Path, *, n_trips: int, b_lat: float, b_lon: float) -> Path:
"""A metro line ABC (5-minute hops) repeated across n_trips.
Displacing B far from A and C makes it a displacement outlier; n_trips sets
its service level (the hard-fail vs warn tier).
"""
routes = "route_id,route_type\nR1,1\n"
trips = "trip_id,route_id,service_id\n" + "".join(
f"T{i},R1,S1\n" for i in range(n_trips)
)
stops = (
"stop_id,stop_name,stop_lat,stop_lon\n"
"A,Aaa,51.50,-0.10\n"
f"B,Bbb,{b_lat},{b_lon}\n"
"C,Ccc,51.52,-0.10\n"
)
header = "trip_id,stop_id,stop_sequence,arrival_time,departure_time\n"
body = "".join(
f"T{i},A,0,06:00:00,06:00:00\n"
f"T{i},B,1,06:05:00,06:05:00\n"
f"T{i},C,2,06:10:00,06:10:00\n"
for i in range(n_trips)
)
with zipfile.ZipFile(path, "w") as z:
z.writestr("routes.txt", routes)
z.writestr("trips.txt", trips)
z.writestr("stops.txt", stops)
z.writestr("stop_times.txt", header + body)
return path
def test_validate_stop_geometry_fails_on_high_service_displacement(
tmp_path: Path,
) -> None:
"""A busy stop whose trains imply teleportation fails the build (TCR mode)."""
feed = _geometry_gtfs(tmp_path / "feed.zip", n_trips=120, b_lat=58.0, b_lon=-2.0)
with pytest.raises(RuntimeError, match="stop-geometry validation failed"):
validate_stop_geometry(feed, "displaced feed")
def test_validate_stop_geometry_passes_when_stops_are_coherent(
tmp_path: Path,
) -> None:
feed = _geometry_gtfs(tmp_path / "feed.zip", n_trips=120, b_lat=51.51, b_lon=-0.10)
validate_stop_geometry(feed, "coherent feed") # must not raise
def test_validate_stop_geometry_only_warns_on_low_service_displacement(
tmp_path: Path,
) -> None:
"""Heritage-line quirks (few trips) warn but do not block a build."""
feed = _geometry_gtfs(tmp_path / "feed.zip", n_trips=6, b_lat=58.0, b_lon=-2.0)
validate_stop_geometry(feed, "heritage feed") # must not raise
# ── validate_london_coverage ──────────────────────────────────────────────────
_ALL_LU = (
"Bakerloo",
"Central",
"Circle",
"District",
"Hammersmith & City",
"Jubilee",
"Metropolitan",
"Northern",
"Piccadilly",
"Victoria",
"Waterloo & City",
)
_COVERAGE_CALENDAR = (
"service_id,monday,tuesday,wednesday,thursday,friday,saturday,sunday,"
"start_date,end_date\n"
"S1,1,1,1,1,1,1,1,20260101,20271231\n"
)
def _bods_coverage(
path: Path,
*,
lu_lines: tuple[str, ...] = _ALL_LU,
include_dlr: bool = True,
include_tramlink: bool = True,
) -> Path:
routes = ["route_id,agency_id,route_short_name,route_long_name,route_type"]
trips = ["trip_id,route_id,service_id"]
n = 0
for line in lu_lines:
routes.append(f"LU{n},LU,{line},,1")
trips.append(f"T{n},LU{n},S1")
n += 1
if include_dlr:
routes.append(f"DLR{n},DLRA,DLR,Docklands Light Railway,2")
trips.append(f"T{n},DLR{n},S1")
n += 1
if include_tramlink:
routes.append(f"TR{n},TRAM,Tram,London Tramlink,0")
trips.append(f"T{n},TR{n},S1")
n += 1
with zipfile.ZipFile(path, "w") as z:
z.writestr("calendar.txt", _COVERAGE_CALENDAR)
z.writestr("routes.txt", "\n".join(routes) + "\n")
z.writestr("trips.txt", "\n".join(trips) + "\n")
return path
def _nr_coverage(
path: Path, *, include_elizabeth: bool = True, include_overground: bool = True
) -> Path:
routes = ["route_id,agency_id,route_short_name,route_long_name,route_type"]
trips = ["trip_id,route_id,service_id"]
if include_elizabeth:
routes.append("XR1,XR,XR:PAD->ABW,Elizabeth line,2")
trips.append("TX,XR1,S1")
if include_overground:
routes.append("LO1,LO,LO,London Overground,2")
trips.append("TL,LO1,S1")
with zipfile.ZipFile(path, "w") as z:
z.writestr("calendar.txt", _COVERAGE_CALENDAR)
z.writestr("routes.txt", "\n".join(routes) + "\n")
z.writestr("trips.txt", "\n".join(trips) + "\n")
return path
def test_validate_london_coverage_happy_path(tmp_path: Path) -> None:
bods = _bods_coverage(tmp_path / "bods.zip")
nr = _nr_coverage(tmp_path / "nr.zip")
validate_london_coverage(bods, nr, today=TODAY) # must not raise
def test_validate_london_coverage_missing_tube_line_fails(tmp_path: Path) -> None:
bods = _bods_coverage(
tmp_path / "bods.zip",
lu_lines=tuple(name for name in _ALL_LU if name != "Victoria"),
)
nr = _nr_coverage(tmp_path / "nr.zip")
with pytest.raises(RuntimeError, match="Victoria"):
validate_london_coverage(bods, nr, today=TODAY)
def test_validate_london_coverage_missing_dlr_fails(tmp_path: Path) -> None:
bods = _bods_coverage(tmp_path / "bods.zip", include_dlr=False)
nr = _nr_coverage(tmp_path / "nr.zip")
with pytest.raises(RuntimeError, match="DLR"):
validate_london_coverage(bods, nr, today=TODAY)
def test_validate_london_coverage_missing_elizabeth_fails(tmp_path: Path) -> None:
bods = _bods_coverage(tmp_path / "bods.zip")
nr = _nr_coverage(tmp_path / "nr.zip", include_elizabeth=False)
with pytest.raises(RuntimeError, match="Elizabeth line"):
validate_london_coverage(bods, nr, today=TODAY)
def test_validate_london_coverage_expired_service_fails(tmp_path: Path) -> None:
"""A line whose only calendar expired years ago counts as missing (present in
routes.txt but with no active service the retired-TfL-feed failure mode)."""
bods = _bods_coverage(tmp_path / "bods.zip")
nr = tmp_path / "nr.zip"
expired = (
"service_id,monday,tuesday,wednesday,thursday,friday,saturday,sunday,"
"start_date,end_date\n"
"S1,1,1,1,1,1,1,1,20091201,20101224\n"
)
with zipfile.ZipFile(nr, "w") as z:
z.writestr("calendar.txt", expired)
z.writestr(
"routes.txt",
"route_id,agency_id,route_short_name,route_long_name,route_type\n"
"XR1,XR,XR:PAD->ABW,Elizabeth line,2\nLO1,LO,LO,London Overground,2\n",
)
z.writestr("trips.txt", "trip_id,route_id,service_id\nTX,XR1,S1\nTL,LO1,S1\n")
with pytest.raises(RuntimeError, match="Elizabeth line|Overground"):
validate_london_coverage(bods, nr, today=TODAY)