From 6a601f5c145f9570a881b2f20c95898420901ddd Mon Sep 17 00:00:00 2001 From: Edward Betts Date: Mon, 3 Aug 2026 14:40:53 +0100 Subject: [PATCH] Show inferred Bristol rail leg for Eurostar trips --- agenda/trip.py | 142 +++++++++++++++++++++++++++++++++---------- static/js/map.js | 44 ++++++++++++-- tests/test_trip.py | 146 ++++++++++++++++++++++++++++++++++++++++++++- web_view.py | 3 + 4 files changed, 299 insertions(+), 36 deletions(-) diff --git a/agenda/trip.py b/agenda/trip.py index 6521ab7..9c538e1 100644 --- a/agenda/trip.py +++ b/agenda/trip.py @@ -130,6 +130,8 @@ def get_unbooked_flight_origin_iata( UNBOOKED_RAIL_DESTINATIONS: dict[tuple[str, str], tuple[str, str]] = { ("fr", "paris"): ("London St Pancras", "Paris Gare du Nord"), } +EUROSTAR_LONDON_STATION = "London St Pancras" +EUROSTAR_HOME_FEEDER_ROUTE = ("Bristol Temple Meads", "London Paddington") def load_station_lookup(data_dir: str) -> dict[str, StrDict]: @@ -168,6 +170,71 @@ def get_unbooked_rail_route(item: StrDict, data_dir: str) -> StrDict | None: return route +def build_train_route( + train_from: StrDict, train_to: StrDict, seen_geojson: set[str] +) -> StrDict | None: + """Build a map route between two stations.""" + geojson_filename = train_from.get("routes", {}).get(train_to["name"]) + key = "_".join(["train"] + sorted([train_from["name"], train_to["name"]])) + if not geojson_filename: + return { + "type": "train", + "key": key, + "from": latlon_tuple(train_from), + "to": latlon_tuple(train_to), + } + + if geojson_filename in seen_geojson: + return None + seen_geojson.add(geojson_filename) + + return { + "type": "train", + "key": key, + "geojson_filename": os.path.join("train_routes", geojson_filename), + } + + +def train_operator_is_eurostar(item: StrDict) -> bool: + """Return True when a train item is operated by Eurostar.""" + operator = item.get("operator") + return isinstance(operator, str) and operator.casefold() == "eurostar" + + +def is_eurostar_departure_from_london(train: StrDict) -> bool: + """Return True when a train journey departs London St Pancras on Eurostar.""" + if train.get("from") == EUROSTAR_LONDON_STATION and train_operator_is_eurostar( + train + ): + return True + + for leg in train.get("legs", []): + if leg.get("from") == EUROSTAR_LONDON_STATION and train_operator_is_eurostar( + leg + ): + return True + return False + + +def get_eurostar_home_feeder_route( + trip: Trip, data_dir: str, seen_geojson: set[str] +) -> StrDict | None: + """Return the assumed Bristol to Paddington route for London Eurostar trips.""" + if not any( + t["type"] == "train" and is_eurostar_departure_from_london(t) + for t in trip.travel + ): + return None + + stations = load_station_lookup(data_dir) + from_station = stations.get(EUROSTAR_HOME_FEEDER_ROUTE[0]) + to_station = stations.get(EUROSTAR_HOME_FEEDER_ROUTE[1]) + if from_station is None or to_station is None: + return None + + return build_train_route(from_station, to_station, seen_geojson) + + def load_travel(travel_type: str, plural: str, data_dir: str) -> list[StrDict]: """Read flight and train journeys.""" items: list[StrDict] = travel.parse_yaml(plural, data_dir) @@ -688,6 +755,37 @@ def coordinate_dict(item: StrDict, coord_type: str) -> StrDict: } +def coordinate_key(item: StrDict) -> tuple[str, str]: + """Return the de-duplication key for a coordinate item.""" + return (item["type"], item["name"]) + + +def add_coordinate_if_missing(coordinates: list[StrDict], item: StrDict) -> None: + """Append a coordinate item if the same type/name is not already present.""" + key = coordinate_key(item) + if any(coordinate_key(existing) == key for existing in coordinates): + return + coordinates.append(item) + + +def add_coordinates_for_eurostar_home_feeder( + trip: Trip, coordinates: list[StrDict], data_dir: str +) -> None: + """Add the assumed Bristol station pin for London Eurostar feeder travel.""" + if not any( + t["type"] == "train" and is_eurostar_departure_from_london(t) + for t in trip.travel + ): + return + + stations = load_station_lookup(data_dir) + from_station = stations.get(EUROSTAR_HOME_FEEDER_ROUTE[0]) + if from_station is None: + return + + add_coordinate_if_missing(coordinates, coordinate_dict(from_station, "station")) + + def conference_free_days(trip: Trip) -> dict[str, tuple[int, int]]: """Return (days_before, days_after) exploration days for each conference. @@ -771,7 +869,13 @@ def read_geojson(data_dir: str, filename: str) -> str: def get_trip_routes(trip: Trip, data_dir: str) -> list[StrDict]: """Get routes for given trip to show on map.""" routes: list[StrDict] = [] - seen_geojson = set() + seen_geojson: set[str] = set() + eurostar_home_feeder_route = get_eurostar_home_feeder_route( + trip, data_dir, seen_geojson + ) + if eurostar_home_feeder_route is not None: + routes.append(eurostar_home_feeder_route) + for t in trip.travel: if t["type"] == "ferry": ferry_from, ferry_to = t["from_terminal"], t["to_terminal"] @@ -844,34 +948,9 @@ def get_trip_routes(trip: Trip, data_dir: str) -> list[StrDict]: if t["type"] == "train": for leg in t["legs"]: train_from, train_to = leg["from_station"], leg["to_station"] - geojson_filename = train_from.get("routes", {}).get(train_to["name"]) - key = "_".join( - ["train"] + sorted([train_from["name"], train_to["name"]]) - ) - if not geojson_filename: - routes.append( - { - "type": "train", - "key": key, - "from": latlon_tuple(train_from), - "to": latlon_tuple(train_to), - } - ) - continue - - if geojson_filename in seen_geojson: - continue - seen_geojson.add(geojson_filename) - - routes.append( - { - "type": "train", - "key": key, - "geojson_filename": os.path.join( - "train_routes", geojson_filename - ), - } - ) + route = build_train_route(train_from, train_to, seen_geojson) + if route is not None: + routes.append(route) if routes: return routes @@ -924,12 +1003,15 @@ def get_coordinates_and_routes( seen_routes: set[str] = set() for trip in trip_list: for stop in collect_trip_coordinates(trip): - key = (stop["type"], stop["name"]) + key = coordinate_key(stop) if key in seen_coordinates: continue coordinates.append(stop) seen_coordinates.add(key) + add_coordinates_for_eurostar_home_feeder(trip, coordinates, data_dir) + seen_coordinates = {coordinate_key(stop) for stop in coordinates} + for route in get_trip_routes(trip, data_dir): if route["key"] in seen_routes: continue diff --git a/static/js/map.js b/static/js/map.js index b47aa20..3469fed 100644 --- a/static/js/map.js +++ b/static/js/map.js @@ -59,12 +59,46 @@ function emojiIcon(emoji, zoom) { function build_map(map_id, coordinates, routes) { var bounds = coordinates.map(function(station) { return [station.latitude, station.longitude]; }); - if (bounds.length === 0) { - routes.forEach(function(r) { - if (r.from) bounds.push(r.from); - if (r.to) bounds.push(r.to); - }); + + function addGeoJsonCoordinatesToBounds(geojsonCoordinates) { + if (!geojsonCoordinates) return; + if (typeof geojsonCoordinates[0] === "number" && typeof geojsonCoordinates[1] === "number") { + bounds.push([geojsonCoordinates[1], geojsonCoordinates[0]]); + return; + } + geojsonCoordinates.forEach(addGeoJsonCoordinatesToBounds); } + + function addGeoJsonGeometryToBounds(geometry) { + if (!geometry) return; + if (geometry.type === "GeometryCollection") { + geometry.geometries.forEach(addGeoJsonGeometryToBounds); + return; + } + addGeoJsonCoordinatesToBounds(geometry.coordinates); + } + + function addGeoJsonToBounds(geojson) { + if (!geojson) return; + if (geojson.type === "FeatureCollection") { + geojson.features.forEach(function(feature) { + addGeoJsonGeometryToBounds(feature.geometry); + }); + return; + } + if (geojson.type === "Feature") { + addGeoJsonGeometryToBounds(geojson.geometry); + return; + } + addGeoJsonGeometryToBounds(geojson); + } + + routes.forEach(function(r) { + if (r.from) bounds.push(r.from); + if (r.to) bounds.push(r.to); + if (r.geojson) addGeoJsonToBounds(JSON.parse(r.geojson)); + }); + var map = bounds.length > 0 ? L.map(map_id).fitBounds(bounds) : L.map(map_id).setView([20, 0], 2); diff --git a/tests/test_trip.py b/tests/test_trip.py index 3301072..c3657c1 100644 --- a/tests/test_trip.py +++ b/tests/test_trip.py @@ -6,7 +6,7 @@ from datetime import date, datetime, timezone import agenda.trip import pytest -from agenda.types import Trip +from agenda.types import StrDict, Trip from web_view import app @@ -171,6 +171,150 @@ def test_get_trip_routes_assumes_unbooked_paris_trip_is_by_train( ] +def test_get_trip_routes_adds_bristol_feeder_for_london_eurostar( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Booked London Eurostar trips should show the assumed Bristol rail feeder.""" + london_st_pancras = { + "name": "London St Pancras", + "latitude": 51.531921, + "longitude": -0.126361, + "routes": {"Paris Gare du Nord": "London_St_Pancras_to_Paris_Gare_du_Nord"}, + } + paris_gare_du_nord = { + "name": "Paris Gare du Nord", + "latitude": 48.88111111111111, + "longitude": 2.355277777777778, + "routes": {"London St Pancras": "London_St_Pancras_to_Paris_Gare_du_Nord"}, + } + bristol_temple_meads = { + "name": "Bristol Temple Meads", + "latitude": 51.449, + "longitude": -2.581, + "routes": {"London Paddington": "gwml"}, + } + london_paddington = { + "name": "London Paddington", + "latitude": 51.516, + "longitude": -0.176, + "routes": {"Bristol Temple Meads": "gwml"}, + } + trip = Trip( + start=date(2026, 7, 20), + travel=[ + { + "type": "train", + "from": "London St Pancras", + "to": "Paris Gare du Nord", + "operator": "Eurostar", + "from_station": london_st_pancras, + "to_station": paris_gare_du_nord, + "legs": [ + { + "from": "London St Pancras", + "to": "Paris Gare du Nord", + "operator": "Eurostar", + "from_station": london_st_pancras, + "to_station": paris_gare_du_nord, + } + ], + } + ], + ) + stations = [ + london_st_pancras, + paris_gare_du_nord, + bristol_temple_meads, + london_paddington, + ] + + def fake_parse_yaml(name: str, data_dir: str) -> object: + if name == "stations": + return stations + raise AssertionError(f"unexpected YAML load: {name}") + + monkeypatch.setattr(agenda.trip.travel, "parse_yaml", fake_parse_yaml) + + routes = agenda.trip.get_trip_routes(trip, "/tmp/personal-data") + + assert routes == [ + { + "type": "train", + "key": "train_Bristol Temple Meads_London Paddington", + "geojson_filename": "train_routes/gwml", + }, + { + "type": "train", + "key": "train_London St Pancras_Paris Gare du Nord", + "geojson_filename": "train_routes/London_St_Pancras_to_Paris_Gare_du_Nord", + }, + ] + + +def test_add_coordinates_for_eurostar_home_feeder_adds_bristol_station( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """London Eurostar trips should get an assumed Bristol station pin.""" + london_st_pancras = { + "name": "London St Pancras", + "latitude": 51.531921, + "longitude": -0.126361, + } + paris_gare_du_nord = { + "name": "Paris Gare du Nord", + "latitude": 48.88111111111111, + "longitude": 2.355277777777778, + } + bristol_temple_meads = { + "name": "Bristol Temple Meads", + "latitude": 51.449, + "longitude": -2.581, + } + trip = Trip( + start=date(2026, 7, 20), + travel=[ + { + "type": "train", + "from": "London St Pancras", + "to": "Paris Gare du Nord", + "operator": "Eurostar", + "from_station": london_st_pancras, + "to_station": paris_gare_du_nord, + "legs": [ + { + "from": "London St Pancras", + "to": "Paris Gare du Nord", + "operator": "Eurostar", + "from_station": london_st_pancras, + "to_station": paris_gare_du_nord, + } + ], + } + ], + ) + + def fake_parse_yaml(name: str, data_dir: str) -> object: + if name == "stations": + return [london_st_pancras, paris_gare_du_nord, bristol_temple_meads] + raise AssertionError(f"unexpected YAML load: {name}") + + monkeypatch.setattr(agenda.trip.travel, "parse_yaml", fake_parse_yaml) + + coordinates: list[StrDict] = [] + agenda.trip.add_coordinates_for_eurostar_home_feeder( + trip, coordinates, "/tmp/personal-data" + ) + + assert coordinates == [ + { + "name": "Bristol Temple Meads", + "type": "station", + "latitude": 51.449, + "longitude": -2.581, + } + ] + + def test_load_cars_infers_route_labels_and_home_marker( tmp_path: pathlib.Path, ) -> None: diff --git a/web_view.py b/web_view.py index 2ddf9c4..c64382d 100755 --- a/web_view.py +++ b/web_view.py @@ -1365,6 +1365,9 @@ def trip_page(start: str) -> str: agenda.trip.add_coordinates_for_unbooked_flights( routes, coordinates, app.config["PERSONAL_DATA"] ) + agenda.trip.add_coordinates_for_eurostar_home_feeder( + trip, coordinates, app.config["PERSONAL_DATA"] + ) for route in routes: if "geojson_filename" in route: