Annotated all functions with mypy --strict-compatible types (-> None, dict[str, Any], Generator types, etc.), added # type: ignore for untyped third-party libs (lxml), and reformatted with black. All 18 source files now pass mypy --strict with zero errors. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
359 lines
13 KiB
Python
359 lines
13 KiB
Python
"""
|
|
Combine GWR station→Paddington trains with Eurostar St Pancras→destination trains.
|
|
"""
|
|
|
|
from datetime import datetime, timedelta
|
|
from typing import Any
|
|
|
|
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[str, Any]]:
|
|
"""
|
|
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
|
|
]
|
|
|
|
|
|
PAD_WALK_FROM_UNDERGROUND_MINUTES = (
|
|
5 # Circle line platform → GWR platform at Paddington
|
|
)
|
|
INBOUND_COMFORTABLE_MIN_CONN = (
|
|
40 # threshold above which we apply the platform walk buffer
|
|
)
|
|
|
|
|
|
def _circle_line_services_to_paddington(
|
|
arrive_st_pancras: datetime,
|
|
dep_paddington: datetime | None = None,
|
|
min_conn_minutes: int = INBOUND_MIN_CONNECTION_MINUTES,
|
|
) -> list[dict[str, Any]]:
|
|
earliest_board = arrive_st_pancras + timedelta(
|
|
minutes=KX_WALK_TO_UNDERGROUND_MINUTES
|
|
)
|
|
if min_conn_minutes >= INBOUND_COMFORTABLE_MIN_CONN and dep_paddington is not None:
|
|
cutoff = dep_paddington - timedelta(minutes=PAD_WALK_FROM_UNDERGROUND_MINUTES)
|
|
candidates = circle_line.upcoming_services(
|
|
earliest_board, count=4, direction="kx_to_pad"
|
|
)
|
|
services = [(dep, arr) for dep, arr in candidates if arr <= cutoff][:2]
|
|
else:
|
|
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[str, Any],
|
|
eurostar: dict[str, Any],
|
|
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[str, Any],
|
|
gwr: dict[str, Any],
|
|
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[str, Any]],
|
|
eurostar_trains: list[dict[str, Any]],
|
|
travel_date: str,
|
|
min_connection_minutes: int = MIN_CONNECTION_MINUTES,
|
|
max_connection_minutes: int = MAX_CONNECTION_MINUTES,
|
|
gwr_fares: dict[str, Any] | None = None,
|
|
) -> list[dict[str, Any]]:
|
|
"""
|
|
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
|
|
|
|
# Destination time is CET/CEST, departure is GMT/BST; Europe is always 1h ahead.
|
|
total_mins = int((arr_dest - dep_bri).total_seconds() / 60) - 60
|
|
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[str, Any]],
|
|
gwr_trains: list[dict[str, Any]],
|
|
travel_date: str,
|
|
min_connection_minutes: int = INBOUND_MIN_CONNECTION_MINUTES,
|
|
max_connection_minutes: int = INBOUND_MAX_CONNECTION_MINUTES,
|
|
gwr_fares: dict[str, Any] | None = None,
|
|
) -> list[dict[str, Any]]:
|
|
"""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
|
|
# Destination time is CET/CEST, arrival at London is GMT/BST; Europe is always 1h ahead.
|
|
total_mins = int((arr_station - dep_dest).total_seconds() / 60) + 60
|
|
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, dep_pad, min_connection_minutes
|
|
)
|
|
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[str, Any]],
|
|
eurostar_trains: list[dict[str, Any]],
|
|
travel_date: str,
|
|
min_connection_minutes: int = MIN_CONNECTION_MINUTES,
|
|
max_connection_minutes: int = MAX_CONNECTION_MINUTES,
|
|
) -> list[dict[str, Any]]:
|
|
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[str, Any]],
|
|
gwr_trains: list[dict[str, Any]],
|
|
travel_date: str,
|
|
min_connection_minutes: int = INBOUND_MIN_CONNECTION_MINUTES,
|
|
max_connection_minutes: int = INBOUND_MAX_CONNECTION_MINUTES,
|
|
) -> list[dict[str, Any]]:
|
|
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"])
|