paddington-eurostar/trip_planner.py
Edward Betts a859b96a23 Split return date nav into separate outbound/return rows; show earlier tube option on inbound
For return journeys, replace the single combined date navigation row with two
separate rows so outbound and return dates can be adjusted independently.

For inbound underground options, show one service before the earliest catchable
(as an "aim for this" option) rather than the next service after it, which
often arrived too late to connect with the GWR train.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 14:09:36 +01:00

329 lines
12 KiB
Python

"""
Combine GWR station→Paddington trains with Eurostar St Pancras→destination trains.
"""
from datetime import datetime, timedelta
import circle_line
from tfl_fare import circle_line_fare
MIN_CONNECTION_MINUTES = 50
MAX_CONNECTION_MINUTES = 110
INBOUND_MIN_CONNECTION_MINUTES = 30
INBOUND_MAX_CONNECTION_MINUTES = 120
DATE_FMT = "%Y-%m-%d"
TIME_FMT = "%H:%M"
PAD_WALK_TO_UNDERGROUND_MINUTES = 8 # GWR platform → Paddington (H&C Line) platform
KX_WALK_TO_UNDERGROUND_MINUTES = 10 # St Pancras arrivals → King's Cross St Pancras Underground
def _parse_dt(date: str, time: str) -> datetime:
return datetime.strptime(f"{date} {time}", f"{DATE_FMT} {TIME_FMT}")
def _circle_line_services(arrive_paddington: datetime) -> list[dict]:
"""
Given GWR arrival at Paddington, return up to 2 upcoming Circle line services
as [{'depart': 'HH:MM', 'arrive_kx': 'HH:MM'}, ...].
Each entry gives the departure from Paddington (H&C Line) and the actual
arrival at King's Cross St Pancras underground station.
"""
earliest_board = arrive_paddington + timedelta(
minutes=PAD_WALK_TO_UNDERGROUND_MINUTES
)
services = circle_line.upcoming_services(earliest_board, count=2, direction='pad_to_kx')
return [
{
"depart": dep.strftime(TIME_FMT),
"arrive_kx": arr.strftime(TIME_FMT),
"fare": circle_line_fare(dep),
}
for dep, arr in services
]
def _circle_line_services_to_paddington(arrive_st_pancras: datetime) -> list[dict]:
earliest_board = arrive_st_pancras + timedelta(
minutes=KX_WALK_TO_UNDERGROUND_MINUTES
)
services = circle_line.upcoming_services(earliest_board, count=1, direction='kx_to_pad', preceding=1)
return [
{
"depart": dep.strftime(TIME_FMT),
"arrive_pad": arr.strftime(TIME_FMT),
"fare": circle_line_fare(dep),
}
for dep, arr in services
]
def _fmt_duration(minutes: int) -> str:
h, m = divmod(minutes, 60)
if h and m:
return f"{h}h {m}m"
if h:
return f"{h}h"
return f"{m}m"
def _is_viable_connection(
gwr: dict,
eurostar: dict,
travel_date: str,
min_connection_minutes: int,
max_connection_minutes: int,
) -> tuple[datetime, datetime, datetime, datetime] | None:
try:
arr_pad = _parse_dt(travel_date, gwr["arrive_paddington"])
dep_bri = _parse_dt(travel_date, gwr["depart_bristol"])
dep_stp = _parse_dt(travel_date, eurostar["depart_st_pancras"])
arr_dest = _parse_dt(travel_date, eurostar["arrive_destination"])
except (ValueError, KeyError):
return None
if arr_pad < dep_bri:
arr_pad += timedelta(days=1)
if arr_dest < dep_stp:
arr_dest += timedelta(days=1)
connection_minutes = (dep_stp - arr_pad).total_seconds() / 60
if connection_minutes < min_connection_minutes:
return None
if connection_minutes > max_connection_minutes:
return None
return dep_bri, arr_pad, dep_stp, arr_dest
def _is_viable_inbound_connection(
eurostar: dict,
gwr: dict,
travel_date: str,
min_connection_minutes: int,
max_connection_minutes: int,
) -> tuple[datetime, datetime, datetime, datetime] | None:
try:
dep_dest = _parse_dt(travel_date, eurostar["depart_destination"])
arr_stp = _parse_dt(travel_date, eurostar["arrive_st_pancras"])
dep_pad = _parse_dt(travel_date, gwr["depart_paddington"])
arr_station = _parse_dt(travel_date, gwr["arrive_destination"])
except (ValueError, KeyError):
return None
if arr_stp < dep_dest:
arr_stp += timedelta(days=1)
if dep_pad < arr_stp:
dep_pad += timedelta(days=1)
if arr_station < dep_pad:
arr_station += timedelta(days=1)
connection_minutes = (dep_pad - arr_stp).total_seconds() / 60
if connection_minutes < min_connection_minutes:
return None
if connection_minutes > max_connection_minutes:
return None
return dep_dest, arr_stp, dep_pad, arr_station
def combine_trips(
gwr_trains: list[dict],
eurostar_trains: list[dict],
travel_date: str,
min_connection_minutes: int = MIN_CONNECTION_MINUTES,
max_connection_minutes: int = MAX_CONNECTION_MINUTES,
gwr_fares: dict | None = None,
) -> list[dict]:
"""
Return a list of valid combined trips, sorted by Bristol departure time.
Each trip dict:
depart_bristol HH:MM
arrive_paddington HH:MM
gwr_duration str (e.g. "1h 45m")
connection_duration str
depart_st_pancras HH:MM
arrive_destination HH:MM
total_duration str (e.g. "5h 30m")
destination str
"""
trips = []
for gwr in gwr_trains:
# Find only the earliest viable Eurostar for this GWR departure
for es in eurostar_trains:
connection = _is_viable_connection(
gwr,
es,
travel_date,
min_connection_minutes,
max_connection_minutes,
)
if not connection:
continue
dep_bri, arr_pad, dep_stp, arr_dest = connection
total_mins = int((arr_dest - dep_bri).total_seconds() / 60)
# Destination time is CET/CEST, departure is GMT/BST; Europe is always 1h ahead.
eurostar_mins = int((arr_dest - dep_stp).total_seconds() / 60) - 60
fare = (gwr_fares or {}).get(gwr["depart_bristol"])
circle_svcs = _circle_line_services(arr_pad)
trips.append(
{
"depart_bristol": gwr["depart_bristol"],
"arrive_paddington": gwr["arrive_paddington"],
"arrive_platform": gwr.get("arrive_platform", ""),
"headcode": gwr.get("headcode", ""),
"gwr_duration": _fmt_duration(
int((arr_pad - dep_bri).total_seconds() / 60)
),
"connection_minutes": int((dep_stp - arr_pad).total_seconds() / 60),
"connection_duration": _fmt_duration(
int((dep_stp - arr_pad).total_seconds() / 60)
),
"circle_services": circle_svcs,
"depart_st_pancras": es["depart_st_pancras"],
"arrive_destination": es["arrive_destination"],
"eurostar_duration": _fmt_duration(eurostar_mins),
"train_number": es.get("train_number", ""),
"total_duration": _fmt_duration(total_mins),
"total_minutes": total_mins,
"destination": es["destination"],
"ticket_name": fare["ticket"] if fare else None,
"ticket_price": fare["price"] if fare else None,
"ticket_code": fare["code"] if fare else None,
}
)
break # Only the earliest valid Eurostar per GWR departure
trips.sort(key=lambda t: (t["depart_bristol"], t["depart_st_pancras"]))
return trips
def combine_inbound_trips(
eurostar_trains: list[dict],
gwr_trains: list[dict],
travel_date: str,
min_connection_minutes: int = INBOUND_MIN_CONNECTION_MINUTES,
max_connection_minutes: int = INBOUND_MAX_CONNECTION_MINUTES,
gwr_fares: dict | None = None,
) -> list[dict]:
"""Return valid continent→UK combined trips."""
trips = []
for es in eurostar_trains:
for gwr in gwr_trains:
connection = _is_viable_inbound_connection(
es,
gwr,
travel_date,
min_connection_minutes,
max_connection_minutes,
)
if not connection:
continue
dep_dest, arr_stp, dep_pad, arr_station = connection
total_mins = int((arr_station - dep_dest).total_seconds() / 60)
# Destination time is CET/CEST, arrival at London is GMT/BST.
eurostar_mins = int((arr_stp - dep_dest).total_seconds() / 60) + 60
fare = (gwr_fares or {}).get(gwr["depart_paddington"])
circle_svcs = _circle_line_services_to_paddington(arr_stp)
trips.append(
{
"direction": "inbound",
"depart_destination": es["depart_destination"],
"check_in_by": (dep_dest - timedelta(minutes=30)).strftime(TIME_FMT),
"arrive_st_pancras": es["arrive_st_pancras"],
"depart_paddington": gwr["depart_paddington"],
"arrive_uk_station": gwr["arrive_destination"],
"arrive_platform": gwr.get("arrive_platform", ""),
"headcode": gwr.get("headcode", ""),
"gwr_duration": _fmt_duration(
int((arr_station - dep_pad).total_seconds() / 60)
),
"connection_minutes": int((dep_pad - arr_stp).total_seconds() / 60),
"connection_duration": _fmt_duration(
int((dep_pad - arr_stp).total_seconds() / 60)
),
"circle_services": circle_svcs,
"eurostar_duration": _fmt_duration(eurostar_mins),
"train_number": es.get("train_number", ""),
"total_duration": _fmt_duration(total_mins),
"total_minutes": total_mins,
"destination": es["destination"],
"ticket_name": fare["ticket"] if fare else None,
"ticket_price": fare["price"] if fare else None,
"ticket_code": fare["code"] if fare else None,
}
)
break
trips.sort(key=lambda t: (t["depart_destination"], t["depart_paddington"]))
return trips
def find_unreachable_morning_eurostars(
gwr_trains: list[dict],
eurostar_trains: list[dict],
travel_date: str,
min_connection_minutes: int = MIN_CONNECTION_MINUTES,
max_connection_minutes: int = MAX_CONNECTION_MINUTES,
) -> list[dict]:
unreachable = []
for es in eurostar_trains:
if any(
_is_viable_connection(
gwr,
es,
travel_date,
min_connection_minutes,
max_connection_minutes,
)
for gwr in gwr_trains
):
continue
dep_stp = _parse_dt(travel_date, es["depart_st_pancras"])
arr_dest = _parse_dt(travel_date, es["arrive_destination"])
if arr_dest < dep_stp:
arr_dest += timedelta(days=1)
eurostar_mins = int((arr_dest - dep_stp).total_seconds() / 60) - 60
unreachable.append({**es, "eurostar_duration": _fmt_duration(eurostar_mins)})
return sorted(unreachable, key=lambda s: s["depart_st_pancras"])
def find_unreachable_inbound_eurostars(
eurostar_trains: list[dict],
gwr_trains: list[dict],
travel_date: str,
min_connection_minutes: int = INBOUND_MIN_CONNECTION_MINUTES,
max_connection_minutes: int = INBOUND_MAX_CONNECTION_MINUTES,
) -> list[dict]:
unreachable = []
for es in eurostar_trains:
if any(
_is_viable_inbound_connection(
es,
gwr,
travel_date,
min_connection_minutes,
max_connection_minutes,
)
for gwr in gwr_trains
):
continue
dep_dest = _parse_dt(travel_date, es["depart_destination"])
arr_stp = _parse_dt(travel_date, es["arrive_st_pancras"])
if arr_stp < dep_dest:
arr_stp += timedelta(days=1)
eurostar_mins = int((arr_stp - dep_dest).total_seconds() / 60) + 60
unreachable.append({**es, "eurostar_duration": _fmt_duration(eurostar_mins)})
return sorted(unreachable, key=lambda s: s["depart_destination"])