Show inferred Bristol rail leg for Eurostar trips

This commit is contained in:
Edward Betts 2026-08-03 14:40:53 +01:00
parent ea80166229
commit 6a601f5c14
4 changed files with 299 additions and 36 deletions

View file

@ -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

View file

@ -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);

View file

@ -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:

View file

@ -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: