Show inferred Bristol rail leg for Eurostar trips
This commit is contained in:
parent
ea80166229
commit
6a601f5c14
4 changed files with 299 additions and 36 deletions
142
agenda/trip.py
142
agenda/trip.py
|
|
@ -130,6 +130,8 @@ def get_unbooked_flight_origin_iata(
|
||||||
UNBOOKED_RAIL_DESTINATIONS: dict[tuple[str, str], tuple[str, str]] = {
|
UNBOOKED_RAIL_DESTINATIONS: dict[tuple[str, str], tuple[str, str]] = {
|
||||||
("fr", "paris"): ("London St Pancras", "Paris Gare du Nord"),
|
("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]:
|
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
|
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]:
|
def load_travel(travel_type: str, plural: str, data_dir: str) -> list[StrDict]:
|
||||||
"""Read flight and train journeys."""
|
"""Read flight and train journeys."""
|
||||||
items: list[StrDict] = travel.parse_yaml(plural, data_dir)
|
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]]:
|
def conference_free_days(trip: Trip) -> dict[str, tuple[int, int]]:
|
||||||
"""Return (days_before, days_after) exploration days for each conference.
|
"""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]:
|
def get_trip_routes(trip: Trip, data_dir: str) -> list[StrDict]:
|
||||||
"""Get routes for given trip to show on map."""
|
"""Get routes for given trip to show on map."""
|
||||||
routes: list[StrDict] = []
|
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:
|
for t in trip.travel:
|
||||||
if t["type"] == "ferry":
|
if t["type"] == "ferry":
|
||||||
ferry_from, ferry_to = t["from_terminal"], t["to_terminal"]
|
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":
|
if t["type"] == "train":
|
||||||
for leg in t["legs"]:
|
for leg in t["legs"]:
|
||||||
train_from, train_to = leg["from_station"], leg["to_station"]
|
train_from, train_to = leg["from_station"], leg["to_station"]
|
||||||
geojson_filename = train_from.get("routes", {}).get(train_to["name"])
|
route = build_train_route(train_from, train_to, seen_geojson)
|
||||||
key = "_".join(
|
if route is not None:
|
||||||
["train"] + sorted([train_from["name"], train_to["name"]])
|
routes.append(route)
|
||||||
)
|
|
||||||
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
|
|
||||||
),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
if routes:
|
if routes:
|
||||||
return routes
|
return routes
|
||||||
|
|
@ -924,12 +1003,15 @@ def get_coordinates_and_routes(
|
||||||
seen_routes: set[str] = set()
|
seen_routes: set[str] = set()
|
||||||
for trip in trip_list:
|
for trip in trip_list:
|
||||||
for stop in collect_trip_coordinates(trip):
|
for stop in collect_trip_coordinates(trip):
|
||||||
key = (stop["type"], stop["name"])
|
key = coordinate_key(stop)
|
||||||
if key in seen_coordinates:
|
if key in seen_coordinates:
|
||||||
continue
|
continue
|
||||||
coordinates.append(stop)
|
coordinates.append(stop)
|
||||||
seen_coordinates.add(key)
|
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):
|
for route in get_trip_routes(trip, data_dir):
|
||||||
if route["key"] in seen_routes:
|
if route["key"] in seen_routes:
|
||||||
continue
|
continue
|
||||||
|
|
|
||||||
|
|
@ -59,12 +59,46 @@ function emojiIcon(emoji, zoom) {
|
||||||
|
|
||||||
function build_map(map_id, coordinates, routes) {
|
function build_map(map_id, coordinates, routes) {
|
||||||
var bounds = coordinates.map(function(station) { return [station.latitude, station.longitude]; });
|
var bounds = coordinates.map(function(station) { return [station.latitude, station.longitude]; });
|
||||||
if (bounds.length === 0) {
|
|
||||||
|
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) {
|
routes.forEach(function(r) {
|
||||||
if (r.from) bounds.push(r.from);
|
if (r.from) bounds.push(r.from);
|
||||||
if (r.to) bounds.push(r.to);
|
if (r.to) bounds.push(r.to);
|
||||||
|
if (r.geojson) addGeoJsonToBounds(JSON.parse(r.geojson));
|
||||||
});
|
});
|
||||||
}
|
|
||||||
var map = bounds.length > 0
|
var map = bounds.length > 0
|
||||||
? L.map(map_id).fitBounds(bounds)
|
? L.map(map_id).fitBounds(bounds)
|
||||||
: L.map(map_id).setView([20, 0], 2);
|
: L.map(map_id).setView([20, 0], 2);
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ from datetime import date, datetime, timezone
|
||||||
|
|
||||||
import agenda.trip
|
import agenda.trip
|
||||||
import pytest
|
import pytest
|
||||||
from agenda.types import Trip
|
from agenda.types import StrDict, Trip
|
||||||
from web_view import app
|
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(
|
def test_load_cars_infers_route_labels_and_home_marker(
|
||||||
tmp_path: pathlib.Path,
|
tmp_path: pathlib.Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
|
||||||
|
|
@ -1365,6 +1365,9 @@ def trip_page(start: str) -> str:
|
||||||
agenda.trip.add_coordinates_for_unbooked_flights(
|
agenda.trip.add_coordinates_for_unbooked_flights(
|
||||||
routes, coordinates, app.config["PERSONAL_DATA"]
|
routes, coordinates, app.config["PERSONAL_DATA"]
|
||||||
)
|
)
|
||||||
|
agenda.trip.add_coordinates_for_eurostar_home_feeder(
|
||||||
|
trip, coordinates, app.config["PERSONAL_DATA"]
|
||||||
|
)
|
||||||
|
|
||||||
for route in routes:
|
for route in routes:
|
||||||
if "geojson_filename" in route:
|
if "geojson_filename" in route:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue