""" Combine GWR station→Paddington trains with Eurostar St Pancras→destination trains. """ from datetime import datetime, timedelta 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]: """ 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 ] def _circle_line_services_to_paddington(arrive_st_pancras: datetime) -> list[dict]: earliest_board = arrive_st_pancras + timedelta( minutes=KX_WALK_TO_UNDERGROUND_MINUTES ) services = circle_line.upcoming_services(earliest_board, count=2, direction='kx_to_pad') 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, 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 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, gwr: dict, 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], eurostar_trains: list[dict], 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. 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 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 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], gwr_trains: list[dict], travel_date: str, min_connection_minutes: int = INBOUND_MIN_CONNECTION_MINUTES, max_connection_minutes: int = INBOUND_MAX_CONNECTION_MINUTES, gwr_fares: dict | None = None, ) -> list[dict]: """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 total_mins = int((arr_station - dep_dest).total_seconds() / 60) # Destination time is CET/CEST, arrival at London is GMT/BST. 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) 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], 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: 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], gwr_trains: list[dict], travel_date: str, min_connection_minutes: int = INBOUND_MIN_CONNECTION_MINUTES, max_connection_minutes: int = INBOUND_MAX_CONNECTION_MINUTES, ) -> list[dict]: 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"])