Add multi-station support, GWR fares API, and Circle line improvements
- Support any station with direct trains to Paddington; station CRS code is now part of the URL (/results/<crs>/<slug>/<date>) - Load station list from data/direct_to_paddington.tsv; show dropdown on index page; 404 for unknown station codes - Fetch live GWR walk-on fares via api.gwr.com for all stations (SSS/SVS/SDS with restrictions already applied per train); cache 30 days - Scrape Paddington arrival platform numbers from RTT - Show unreachable morning Eurostars (before first reachable service only) - Circle line: show actual KX St Pancras arrival times (not check-in estimate) and add a second backup service in the transfer column - Widen page max-width to 1100px for longer station names Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
71be0dd8cf
commit
3c787b33d3
12 changed files with 810 additions and 262 deletions
143
trip_planner.py
143
trip_planner.py
|
|
@ -1,71 +1,39 @@
|
|||
"""
|
||||
Combine GWR Bristol→Paddington trains with Eurostar St Pancras→destination trains.
|
||||
Combine GWR station→Paddington trains with Eurostar St Pancras→destination trains.
|
||||
"""
|
||||
from datetime import datetime, timedelta, time as _time
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import circle_line
|
||||
|
||||
MIN_CONNECTION_MINUTES = 50
|
||||
MAX_CONNECTION_MINUTES = 110
|
||||
MAX_GWR_MINUTES = 110
|
||||
DATE_FMT = '%Y-%m-%d'
|
||||
TIME_FMT = '%H:%M'
|
||||
DATE_FMT = "%Y-%m-%d"
|
||||
TIME_FMT = "%H:%M"
|
||||
|
||||
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.
|
||||
# Weekday restrictions (Mon–Fri 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:00–05: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 (Mon–Fri) 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
|
||||
PAD_WALK_TO_UNDERGROUND_MINUTES = 8 # GWR platform → Paddington (H&C Line) platform
|
||||
|
||||
|
||||
def _parse_dt(date: str, time: str) -> datetime:
|
||||
return datetime.strptime(f"{date} {time}", f"{DATE_FMT} {TIME_FMT}")
|
||||
|
||||
|
||||
def _circle_line_times(arrive_paddington: datetime) -> tuple[str, str] | tuple[None, None]:
|
||||
def _circle_line_services(arrive_paddington: datetime) -> list[dict]:
|
||||
"""
|
||||
Given GWR arrival at Paddington, return (circle_line_depart_str, arrive_checkin_str).
|
||||
Given GWR arrival at Paddington, return up to 2 upcoming Circle line services
|
||||
as [{'depart': 'HH:MM', 'arrive_kx': 'HH:MM'}, ...].
|
||||
|
||||
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.
|
||||
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)
|
||||
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)
|
||||
earliest_board = arrive_paddington + timedelta(
|
||||
minutes=PAD_WALK_TO_UNDERGROUND_MINUTES
|
||||
)
|
||||
services = circle_line.upcoming_services(earliest_board, count=2)
|
||||
return [
|
||||
{"depart": dep.strftime(TIME_FMT), "arrive_kx": arr.strftime(TIME_FMT)}
|
||||
for dep, arr in services
|
||||
]
|
||||
|
||||
|
||||
def _fmt_duration(minutes: int) -> str:
|
||||
|
|
@ -85,15 +53,15 @@ def _is_viable_connection(
|
|||
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'])
|
||||
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_pad < dep_bri:
|
||||
arr_pad += timedelta(days=1)
|
||||
|
||||
if arr_dest < dep_stp:
|
||||
arr_dest += timedelta(days=1)
|
||||
|
|
@ -113,6 +81,7 @@ def combine_trips(
|
|||
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.
|
||||
|
|
@ -146,31 +115,37 @@ def combine_trips(
|
|||
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 = _circle_line_times(arr_pad)
|
||||
trips.append({
|
||||
'depart_bristol': gwr['depart_bristol'],
|
||||
'arrive_paddington': gwr['arrive_paddington'],
|
||||
'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_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,
|
||||
'destination': es['destination'],
|
||||
'ticket_name': ticket['ticket'],
|
||||
'ticket_price': ticket['price'],
|
||||
'ticket_code': ticket['code'],
|
||||
})
|
||||
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']))
|
||||
trips.sort(key=lambda t: (t["depart_bristol"], t["depart_st_pancras"]))
|
||||
return trips
|
||||
|
||||
|
||||
|
|
@ -196,11 +171,11 @@ def find_unreachable_morning_eurostars(
|
|||
):
|
||||
continue
|
||||
|
||||
dep_stp = _parse_dt(travel_date, es['depart_st_pancras'])
|
||||
arr_dest = _parse_dt(travel_date, es['arrive_destination'])
|
||||
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)})
|
||||
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"])
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue