Use real Circle Line timetable; add Eurostar duration

Parse Circle Line times from TransXChange XML (output_txc_01CIR_.xml) with
separate weekday/Saturday/Sunday schedules, replacing the approximated
every-10-minutes pattern. Subtract 1 hour timezone offset (CET/CEST vs
GMT/BST) when computing Eurostar journey duration, shown for both viable
and unreachable services.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Edward Betts 2026-04-04 13:26:35 +01:00
parent 60674fe663
commit c215456620
3 changed files with 179 additions and 26 deletions

View file

@ -3,17 +3,16 @@ Combine GWR Bristol→Paddington trains with Eurostar St Pancras→destination t
"""
from datetime import datetime, timedelta, time as _time
import circle_line
MIN_CONNECTION_MINUTES = 50
MAX_CONNECTION_MINUTES = 110
MAX_GWR_MINUTES = 110
DATE_FMT = '%Y-%m-%d'
TIME_FMT = '%H:%M'
# Circle Line: Paddington (H&C) → Kings Cross St Pancras
CIRCLE_LINE_MINUTES_PAST = [8, 18, 28, 38, 48, 58] # departures past each hour
CIRCLE_LINE_JOURNEY_MINUTES = 11
PAD_WALK_TO_UNDERGROUND_MINUTES = 8 # GWR platform → Paddington Underground
KX_WALK_TO_CHECKIN_MINUTES = 10 # Kings Cross St Pancras platform → St Pancras check-in
PAD_WALK_TO_UNDERGROUND_MINUTES = 7 # GWR platform → Paddington (H&C Line) platform
KX_WALK_TO_CHECKIN_MINUTES = 8 # King's Cross St Pancras platform → St Pancras check-in
# Bristol Temple Meads → London Paddington walk-on single fares.
@ -51,26 +50,22 @@ def _parse_dt(date: str, time: str) -> datetime:
return datetime.strptime(f"{date} {time}", f"{DATE_FMT} {TIME_FMT}")
def _next_circle_line(arrive_paddington: datetime) -> tuple[datetime, datetime]:
def _circle_line_times(arrive_paddington: datetime) -> tuple[str, str] | tuple[None, None]:
"""
Given GWR arrival at Paddington, return (circle_line_depart, arrive_checkin).
Given GWR arrival at Paddington, return (circle_line_depart_str, arrive_checkin_str).
Walk 10 min to Paddington Underground, catch next Circle Line at :08/:18/:28/:38/:48/:58,
11 min journey to Kings Cross St Pancras, 10 min walk to St Pancras check-in.
Adds PAD_WALK_TO_UNDERGROUND_MINUTES to get the earliest boarding time, looks up
the next real Circle Line service from the timetable, then adds KX_WALK_TO_CHECKIN_MINUTES
to the Kings Cross arrival to give an estimated St Pancras check-in time.
Returns (None, None) if no service is found.
"""
earliest_board = arrive_paddington + timedelta(minutes=PAD_WALK_TO_UNDERGROUND_MINUTES)
minute = earliest_board.minute
depart_minute = next((m for m in CIRCLE_LINE_MINUTES_PAST if m >= minute), None)
if depart_minute is None:
circle_depart = (earliest_board + timedelta(hours=1)).replace(
minute=CIRCLE_LINE_MINUTES_PAST[0], second=0, microsecond=0
)
else:
circle_depart = earliest_board.replace(minute=depart_minute, second=0, microsecond=0)
arrive_checkin = circle_depart + timedelta(
minutes=CIRCLE_LINE_JOURNEY_MINUTES + KX_WALK_TO_CHECKIN_MINUTES
)
return circle_depart, arrive_checkin
result = circle_line.next_service(earliest_board)
if result is None:
return None, None
circle_depart, arrive_kx = result
arrive_checkin = arrive_kx + timedelta(minutes=KX_WALK_TO_CHECKIN_MINUTES)
return circle_depart.strftime(TIME_FMT), arrive_checkin.strftime(TIME_FMT)
def _fmt_duration(minutes: int) -> str:
@ -149,8 +144,10 @@ def combine_trips(
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
ticket = cheapest_gwr_ticket(gwr['depart_bristol'], travel_date)
circle_depart, arrive_checkin = _next_circle_line(arr_pad)
circle_depart, arrive_checkin = _circle_line_times(arr_pad)
trips.append({
'depart_bristol': gwr['depart_bristol'],
'arrive_paddington': gwr['arrive_paddington'],
@ -158,10 +155,11 @@ def combine_trips(
'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_line_depart': circle_depart.strftime(TIME_FMT),
'circle_arrive_checkin': arrive_checkin.strftime(TIME_FMT),
'circle_line_depart': circle_depart,
'circle_arrive_checkin': arrive_checkin,
'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,
@ -198,6 +196,11 @@ def find_unreachable_morning_eurostars(
):
continue
unreachable.append(es)
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'])