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

148
circle_line.py Normal file
View file

@ -0,0 +1,148 @@
"""
Circle Line timetable: Paddington (H&C Line) King's Cross St Pancras.
Parses the TransXChange XML file on first use and caches the result in memory.
"""
import os
import re
import xml.etree.ElementTree as ET
from datetime import datetime, timedelta
_PAD_STOP = '9400ZZLUPAH1' # Paddington (H&C Line)
_KXP_STOP = '9400ZZLUKSX3' # King's Cross St Pancras
_TXC_XML = os.path.join(os.path.dirname(__file__), 'output_txc_01CIR_.xml')
_NS = {'t': 'http://www.transxchange.org.uk/'}
# Populated on first call to next_service(); maps day-type -> sorted list of
# (pad_depart_seconds, kxp_arrive_seconds) measured from midnight.
_timetable: dict[str, list[tuple[int, int]]] | None = None
def _parse_duration(s: str | None) -> int:
if not s:
return 0
m = re.match(r'PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?', s)
return int(m.group(1) or 0) * 3600 + int(m.group(2) or 0) * 60 + int(m.group(3) or 0)
def _load_timetable() -> dict[str, list[tuple[int, int]]]:
tree = ET.parse(_TXC_XML)
root = tree.getroot()
# Build JPS id -> [(from_stop, to_stop, runtime_secs, wait_secs)]
jps_map: dict[str, list[tuple]] = {}
for jps_el in root.find('t:JourneyPatternSections', _NS):
links = []
for link in jps_el.findall('t:JourneyPatternTimingLink', _NS):
fr = link.find('t:From/t:StopPointRef', _NS)
to = link.find('t:To/t:StopPointRef', _NS)
rt = link.find('t:RunTime', _NS)
wait = link.find('t:From/t:WaitTime', _NS)
links.append((
fr.text if fr is not None else None,
to.text if to is not None else None,
_parse_duration(rt.text if rt is not None else None),
_parse_duration(wait.text if wait is not None else None),
))
jps_map[jps_el.get('id')] = links
def _seconds_to_depart(links, stop):
"""Seconds from journey start until departure from *stop*."""
elapsed = 0
for fr, to, rt, wait in links:
elapsed += wait
if fr == stop:
return elapsed
elapsed += rt
return None
def _seconds_to_arrive(links, stop):
"""Seconds from journey start until arrival at *stop*."""
elapsed = 0
for fr, to, rt, wait in links:
elapsed += wait + rt
if to == stop:
return elapsed
return None
# Map JP id -> (pad_offset_secs, kxp_arrive_offset_secs)
jp_offsets: dict[str, tuple[int, int]] = {}
for svc in root.find('t:Services', _NS):
for jp in svc.findall('.//t:JourneyPattern', _NS):
jps_ref = jp.find('t:JourneyPatternSectionRefs', _NS)
if jps_ref is None:
continue
links = jps_map.get(jps_ref.text, [])
stops = [l[0] for l in links] + ([links[-1][1]] if links else [])
if (
_PAD_STOP in stops
and _KXP_STOP in stops
and stops.index(_PAD_STOP) < stops.index(_KXP_STOP)
):
pad_off = _seconds_to_depart(links, _PAD_STOP)
kxp_off = _seconds_to_arrive(links, _KXP_STOP)
if pad_off is not None and kxp_off is not None:
jp_offsets[jp.get('id')] = (pad_off, kxp_off)
result: dict[str, list[tuple[int, int]]] = {
'MondayToFriday': [],
'Saturday': [],
'Sunday': [],
}
for vj in root.find('t:VehicleJourneys', _NS):
jp_ref = vj.find('t:JourneyPatternRef', _NS)
dep_time = vj.find('t:DepartureTime', _NS)
op = vj.find('t:OperatingProfile', _NS)
if jp_ref is None or dep_time is None or jp_ref.text not in jp_offsets:
continue
pad_off, kxp_off = jp_offsets[jp_ref.text]
h, m, s = map(int, dep_time.text.split(':'))
dep_secs = h * 3600 + m * 60 + s
rdt = op.find('.//t:DaysOfWeek', _NS) if op is not None else None
if rdt is None:
continue
for day_el in rdt:
day_type = day_el.tag.split('}')[-1]
if day_type in result:
result[day_type].append((dep_secs + pad_off, dep_secs + kxp_off))
for key in result:
result[key].sort()
return result
def _get_timetable() -> dict[str, list[tuple[int, int]]]:
global _timetable
if _timetable is None:
_timetable = _load_timetable()
return _timetable
def _day_type(weekday: int) -> str:
if weekday < 5:
return 'MondayToFriday'
return 'Saturday' if weekday == 5 else 'Sunday'
def next_service(earliest_board: datetime) -> tuple[datetime, datetime] | None:
"""
Given the earliest time a passenger can board at Paddington (H&C Line),
return (circle_line_depart, arrive_kings_cross) as datetimes, or None if
no service is found before midnight.
The caller is responsible for adding any walk time from the GWR platform
before passing *earliest_board*.
"""
timetable = _get_timetable()[_day_type(earliest_board.weekday())]
board_secs = (
earliest_board.hour * 3600
+ earliest_board.minute * 60
+ earliest_board.second
)
midnight = earliest_board.replace(hour=0, minute=0, second=0, microsecond=0)
for pad_secs, kxp_secs in timetable:
if pad_secs >= board_secs:
return midnight + timedelta(seconds=pad_secs), midnight + timedelta(seconds=kxp_secs)
return None

View file

@ -106,7 +106,7 @@
<th style="padding:0.6rem 0.8rem;white-space:nowrap">Paddington</th> <th style="padding:0.6rem 0.8rem;white-space:nowrap">Paddington</th>
<th style="padding:0.6rem 0.8rem;white-space:nowrap">GWR Fare</th> <th style="padding:0.6rem 0.8rem;white-space:nowrap">GWR Fare</th>
<th class="col-transfer" style="padding:0.6rem 0.8rem;white-space:nowrap">Transfer</th> <th class="col-transfer" style="padding:0.6rem 0.8rem;white-space:nowrap">Transfer</th>
<th style="padding:0.6rem 0.8rem;white-space:nowrap">Depart St&nbsp;Pancras</th> <th style="padding:0.6rem 0.8rem;white-space:nowrap">Depart STP</th>
<th style="padding:0.6rem 0.8rem">{{ destination }}</th> <th style="padding:0.6rem 0.8rem">{{ destination }}</th>
<th style="padding:0.6rem 0.8rem;white-space:nowrap">ES Std</th> <th style="padding:0.6rem 0.8rem;white-space:nowrap">ES Std</th>
<th style="padding:0.6rem 0.8rem;white-space:nowrap">Total</th> <th style="padding:0.6rem 0.8rem;white-space:nowrap">Total</th>
@ -159,6 +159,7 @@
<td style="padding:0.6rem 0.8rem"> <td style="padding:0.6rem 0.8rem">
{{ row.arrive_destination }} {{ row.arrive_destination }}
<span style="font-weight:400;color:#718096;font-size:0.85em">(CET)</span> <span style="font-weight:400;color:#718096;font-size:0.85em">(CET)</span>
{% if row.eurostar_duration %}<br><span style="font-size:0.8rem;color:#718096;white-space:nowrap">({{ row.eurostar_duration }})</span>{% endif %}
</td> </td>
<td style="padding:0.6rem 0.8rem;white-space:nowrap"> <td style="padding:0.6rem 0.8rem;white-space:nowrap">
{% if row.eurostar_price is not none %} {% if row.eurostar_price is not none %}
@ -191,6 +192,7 @@
<td style="padding:0.6rem 0.8rem"> <td style="padding:0.6rem 0.8rem">
{{ row.arrive_destination }} {{ row.arrive_destination }}
<span style="font-weight:400;color:#a0aec0;font-size:0.85em">(CET)</span> <span style="font-weight:400;color:#a0aec0;font-size:0.85em">(CET)</span>
{% if row.eurostar_duration %}<br><span style="font-size:0.8rem;color:#a0aec0;white-space:nowrap">({{ row.eurostar_duration }})</span>{% endif %}
</td> </td>
<td style="padding:0.6rem 0.8rem;white-space:nowrap"> <td style="padding:0.6rem 0.8rem;white-space:nowrap">
{% if row.eurostar_price is not none %} {% if row.eurostar_price is not none %}

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 from datetime import datetime, timedelta, time as _time
import circle_line
MIN_CONNECTION_MINUTES = 50 MIN_CONNECTION_MINUTES = 50
MAX_CONNECTION_MINUTES = 110 MAX_CONNECTION_MINUTES = 110
MAX_GWR_MINUTES = 110 MAX_GWR_MINUTES = 110
DATE_FMT = '%Y-%m-%d' DATE_FMT = '%Y-%m-%d'
TIME_FMT = '%H:%M' TIME_FMT = '%H:%M'
# Circle Line: Paddington (H&C) → Kings Cross St Pancras PAD_WALK_TO_UNDERGROUND_MINUTES = 7 # GWR platform → Paddington (H&C Line) platform
CIRCLE_LINE_MINUTES_PAST = [8, 18, 28, 38, 48, 58] # departures past each hour KX_WALK_TO_CHECKIN_MINUTES = 8 # King's Cross St Pancras platform → St Pancras check-in
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
# Bristol Temple Meads → London Paddington walk-on single fares. # 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}") 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, Adds PAD_WALK_TO_UNDERGROUND_MINUTES to get the earliest boarding time, looks up
11 min journey to Kings Cross St Pancras, 10 min walk to St Pancras check-in. 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) earliest_board = arrive_paddington + timedelta(minutes=PAD_WALK_TO_UNDERGROUND_MINUTES)
minute = earliest_board.minute result = circle_line.next_service(earliest_board)
depart_minute = next((m for m in CIRCLE_LINE_MINUTES_PAST if m >= minute), None) if result is None:
if depart_minute is None: return None, None
circle_depart = (earliest_board + timedelta(hours=1)).replace( circle_depart, arrive_kx = result
minute=CIRCLE_LINE_MINUTES_PAST[0], second=0, microsecond=0 arrive_checkin = arrive_kx + timedelta(minutes=KX_WALK_TO_CHECKIN_MINUTES)
) return circle_depart.strftime(TIME_FMT), arrive_checkin.strftime(TIME_FMT)
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
def _fmt_duration(minutes: int) -> str: def _fmt_duration(minutes: int) -> str:
@ -149,8 +144,10 @@ def combine_trips(
dep_bri, arr_pad, dep_stp, arr_dest = connection dep_bri, arr_pad, dep_stp, arr_dest = connection
total_mins = int((arr_dest - dep_bri).total_seconds() / 60) 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) 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({ trips.append({
'depart_bristol': gwr['depart_bristol'], 'depart_bristol': gwr['depart_bristol'],
'arrive_paddington': gwr['arrive_paddington'], 'arrive_paddington': gwr['arrive_paddington'],
@ -158,10 +155,11 @@ def combine_trips(
'gwr_duration': _fmt_duration(int((arr_pad - dep_bri).total_seconds() / 60)), 'gwr_duration': _fmt_duration(int((arr_pad - dep_bri).total_seconds() / 60)),
'connection_minutes': int((dep_stp - arr_pad).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)), 'connection_duration': _fmt_duration(int((dep_stp - arr_pad).total_seconds() / 60)),
'circle_line_depart': circle_depart.strftime(TIME_FMT), 'circle_line_depart': circle_depart,
'circle_arrive_checkin': arrive_checkin.strftime(TIME_FMT), 'circle_arrive_checkin': arrive_checkin,
'depart_st_pancras': es['depart_st_pancras'], 'depart_st_pancras': es['depart_st_pancras'],
'arrive_destination': es['arrive_destination'], 'arrive_destination': es['arrive_destination'],
'eurostar_duration': _fmt_duration(eurostar_mins),
'train_number': es.get('train_number', ''), 'train_number': es.get('train_number', ''),
'total_duration': _fmt_duration(total_mins), 'total_duration': _fmt_duration(total_mins),
'total_minutes': total_mins, 'total_minutes': total_mins,
@ -198,6 +196,11 @@ def find_unreachable_morning_eurostars(
): ):
continue 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']) return sorted(unreachable, key=lambda s: s['depart_st_pancras'])