Show cheapest GWR fare per journey and flag unreachable morning Eurostars

Add cheapest_gwr_ticket() to trip_planner.py encoding the SSS/SVS/SDS
walk-on single restrictions for Bristol Temple Meads → Paddington: on
weekdays, Super Off-Peak (£45) is valid before 05:05 or from 09:58,
Off-Peak (£63.60) from 08:26, and Anytime (£138.70) covers the gap.
Weekends have no restrictions. The fare is included in each trip dict
and displayed in a new GWR Fare column on the results page.

Also wire up find_unreachable_morning_eurostars() into the results view
so early Eurostar services unreachable from Bristol appear in the table,
with tests covering both features.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Edward Betts 2026-04-04 10:22:47 +01:00
parent b88d23a270
commit 804fcedfad
5 changed files with 428 additions and 44 deletions

View file

@ -1,15 +1,47 @@
"""
Combine GWR BristolPaddington trains with Eurostar St Pancrasdestination trains.
"""
from datetime import datetime, timedelta
from datetime import datetime, timedelta, time as _time
MIN_CONNECTION_MINUTES = 50
MAX_CONNECTION_MINUTES = 110
MAX_GWR_MINUTES = 110
MORNING_CUTOFF_HOUR = 12
DATE_FMT = '%Y-%m-%d'
TIME_FMT = '%H:%M'
# Bristol Temple Meads → London Paddington walk-on single fares.
# Weekday restrictions (MonFri only):
# Super Off-Peak (£45.00, SSS): not valid if 05:05 ≤ dep ≤ 09:57
# Off-Peak (£63.60, SVS): not valid if dep < 08:26, except 02:0005:10
# Anytime (£138.70, SDS): no restrictions
_TICKET_SUPER_OFF_PEAK = {'ticket': 'Super Off-Peak', 'price': 45.00, 'code': 'SSS'}
_TICKET_OFF_PEAK = {'ticket': 'Off-Peak', 'price': 63.60, 'code': 'SVS'}
_TICKET_ANYTIME = {'ticket': 'Anytime', 'price': 138.70, 'code': 'SDS'}
def cheapest_gwr_ticket(depart_time: str, travel_date: str) -> dict:
"""
Return the cheapest walk-on single for Bristol Temple Meads Paddington.
Weekday (MonFri) restrictions derived from the SSS/SVS ticket conditions:
Super Off-Peak valid: dep 05:04 or dep 09:58
Off-Peak valid: 02:00 dep 05:10 or dep 08:26
Weekends: no restrictions always Super Off-Peak.
"""
dt = datetime.strptime(f"{travel_date} {depart_time}", f"{DATE_FMT} {TIME_FMT}")
if dt.weekday() >= 5: # Saturday or Sunday
return _TICKET_SUPER_OFF_PEAK
dep = dt.time()
if dep <= _time(5, 4) or dep >= _time(9, 58):
return _TICKET_SUPER_OFF_PEAK
if dep >= _time(8, 26):
return _TICKET_OFF_PEAK
return _TICKET_ANYTIME
def _parse_dt(date: str, time: str) -> datetime:
return datetime.strptime(f"{date} {time}", f"{DATE_FMT} {TIME_FMT}")
@ -23,6 +55,36 @@ def _fmt_duration(minutes: int) -> str:
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 int((arr_pad - dep_bri).total_seconds() / 60) > MAX_GWR_MINUTES:
return None
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 combine_trips(
gwr_trains: list[dict],
eurostar_trains: list[dict],
@ -46,35 +108,21 @@ def combine_trips(
trips = []
for gwr in gwr_trains:
try:
arr_pad = _parse_dt(travel_date, gwr['arrive_paddington'])
dep_bri = _parse_dt(travel_date, gwr['depart_bristol'])
except (ValueError, KeyError):
continue
if int((arr_pad - dep_bri).total_seconds() / 60) > MAX_GWR_MINUTES:
continue
earliest_eurostar = arr_pad + timedelta(minutes=min_connection_minutes)
# Find only the earliest viable Eurostar for this GWR departure
for es in eurostar_trains:
try:
dep_stp = _parse_dt(travel_date, es['depart_st_pancras'])
arr_dest = _parse_dt(travel_date, es['arrive_destination'])
except (ValueError, KeyError):
continue
# Eurostar arrives next day? (e.g. night service — unlikely but handle it)
if arr_dest < dep_stp:
arr_dest += timedelta(days=1)
if dep_stp < earliest_eurostar:
continue
if (dep_stp - arr_pad).total_seconds() / 60 > max_connection_minutes:
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)
ticket = cheapest_gwr_ticket(gwr['depart_bristol'], travel_date)
trips.append({
'depart_bristol': gwr['depart_bristol'],
'arrive_paddington': gwr['arrive_paddington'],
@ -87,8 +135,46 @@ def combine_trips(
'total_duration': _fmt_duration(total_mins),
'total_minutes': total_mins,
'destination': es['destination'],
'ticket_name': ticket['ticket'],
'ticket_price': ticket['price'],
'ticket_code': ticket['code'],
})
break # Only the earliest valid Eurostar per GWR departure
trips.sort(key=lambda t: (t['depart_bristol'], t['depart_st_pancras']))
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:
try:
dep_stp = _parse_dt(travel_date, es['depart_st_pancras'])
except (ValueError, KeyError):
continue
if dep_stp.hour >= MORNING_CUTOFF_HOUR:
continue
if any(
_is_viable_connection(
gwr,
es,
travel_date,
min_connection_minutes,
max_connection_minutes,
)
for gwr in gwr_trains
):
continue
unreachable.append(es)
return sorted(unreachable, key=lambda s: s['depart_st_pancras'])