Harbor Transit Planner: Transfers, Itineraries, Live Disruptions and Frequencies
HardAI-assisted coding · AI-Assisted Feature Work
Harbor Transit Journey Planner: Transfers, Itineraries, Live Disruptions and Frequencies
You've joined the journey-planner team at Harbor Transit. The planner answers one question for the rider app: *"I'm at this station at this time; when can I be at that station?"* It reads the agency's timetable feed (stations, platforms, walking links, trips, service calendars) and runs a connection scan over it. Riders have started complaining that the app promises transfers nobody can make, product wants real itineraries instead of a bare arrival time, operations wants cancellations and delays applied live, and planning wants to define busy lines by frequency instead of listing every trip.
All times are integer minutes. Nothing here uses a wall clock.
Codebase
| File | What it does |
|---|---|
transit/api.py | run(operations) entry point and the Dispatcher that routes each operation |
transit/ingest.py | Validates feed operations and loads them (FeedLoader) |
transit/checks.py | Argument and stop-time validation helpers |
transit/network.py | Network: stations (with minimum transfer time), platforms, footpaths |
transit/services.py | ServiceCalendar: on which service days a service runs |
transit/timetable.py | Timetable: trips, and the time-sorted connections a query can scan (cached per day) |
transit/scanner.py | earliest_arrival: the connection scan |
transit/planner.py | Planner: validates a query and asks the scanner |
transit/models.py | StopTime, Trip, Connection |
transit/errors.py | TransitError and every error code |
transit/config.py | Constants: minutes per day, the search horizon, the stop-time ceiling |
transit/itinerary.py, overlays.py, frequency.py | Empty placeholders for Parts 2-4 |
tests/ | Existing tests (one of them currently fails) |
How your code is run
run(operations: list[list]) -> list executes the operations in order against a fresh planner and returns one result per operation. Every operation is [name, *args]. Errors are returned as "error: <code>" strings, never raised. An unknown name returns "error: unknown operation"; a known name with the wrong number of arguments, or an operation that is not a non-empty list whose first item is a string, returns "error: invalid arguments".
A *station* has one or more *platforms*; trips call at platforms, riders think in stations. Ids are non-empty strings and are case-sensitive. "Integer" never includes true/false.
| Operation | Arguments | Returns | Errors (checked in this order) |
|---|---|---|---|
station | station_id, min_transfer | true | invalid arguments (min_transfer must be an integer >= 1), station exists |
platform | platform_id, station_id | true | invalid arguments, platform exists, station not found |
walk | station_a, station_b, minutes | true | invalid arguments (distinct ids, minutes an integer >= 1), station not found (a is checked before b) |
service | service_id, weekdays | true | invalid arguments (weekdays: a list of distinct integers 0-6, possibly empty), service exists |
trip | trip_id, service_id, stops | true | invalid arguments, trip exists, service not found, platform not found, invalid trip |
plan | origin, destination, day, depart | arrival minute (int) | invalid arguments, station not found (origin first), same station, no journey |
walkdeclares a symmetric footpath between two stations; declaring the same pair again replaces the old minutes.stopsis a list of at least two[platform_id, arr, dep]triples. A stop'sarris when the vehicle arrives anddepwhen it leaves. Rules (elseinvalid trip):0 <= arr <= depat every stop; a stop'sarris no earlier than the previous stop'sdep(so a hop may take 0 minutes); a platform appears at most once per trip; every time is below 2880. Hop *i* leaves stop *i* at itsdepand reaches stop *i+1* at itsarr.- A
tripruns on service days according to its service: it runs on daydiffd % 7is in the service'sweekdays. A stop time is minutes after the start of the trip's service day, so1510is 25:10, ten past one the following morning, and that run still belongs to the service day it started on. plan'sdayis an integer>= 0anddepartan integer in0..1439. The rider starts at absolute minutestart = day * 1440 + depart. The result is the arrival minute relative to the start ofday(it can be 1440 or more).
The journey rules
The planner finds the earliest arrival at the destination station. Everything below is a rule of the existing system that Part 1 is meant to restore; read timetable.py and scanner.py to see how they are (supposed to be) encoded.
- A vehicle is one trip on one service day. The same trip id on two days is two vehicles.
- A rider may board any platform of the origin station on any vehicle that departs there at or after
start. Nothing extra is needed at the origin. - Every boarding, at the origin or after a transfer, must depart strictly before
start + 1440(the app does not search further ahead than a day). Once aboard, a rider may stay on as long as the vehicle runs, also beyond that horizon. - After getting off a vehicle at station
Sat minutea, the rider may board a *different* vehicle:
- at
S(any platform, including the one they got off at) departing at or aftera + min_transfer(S); - or, if a footpath
S-S'ofwminutes exists, atS'departing at or aftera + w. The walk replaces the transfer time:min_transfer(S')is not added. A transfer uses at most one footpath; footpaths are never chained, and a walk never starts or ends a journey.
- Staying on the same vehicle through its later stops is not a transfer and costs nothing.
- The journey ends when a vehicle arrives (its
arr) at any platform of the destination station. - Which service day a vehicle belongs to does not matter beyond "does it run that day": any vehicle that fits the rules above counts, including yesterday's late-night run boarded after midnight.
Part 1: Fix the transfer and overnight bugs (SUP-3318, SUP-3342)
Goal: make plan follow the journey rules.
Reports from riders and from the night desk:
- "The app told me to change at North Gate with a bus leaving one minute after I arrive. I watched it leave." (At zero minutes a rider could also appear to switch vehicles instantly at a station whose
min_transferis 9.) - "At 00:20 on Saturday the app says there is no way to the airport. The N5 night bus is running."
- "The app offers me a bus that leaves tomorrow evening." (See rule 3.)
Example (these operations, then the query):
[["station", "CEN", 5], ["station", "NOR", 3], ["station", "EAS", 4],
["platform", "CEN-1", "CEN"], ["platform", "NOR-1", "NOR"], ["platform", "NOR-2", "NOR"], ["platform", "EAS-1", "EAS"],
["service", "daily", [0, 1, 2, 3, 4, 5, 6]],
["trip", "R1", "daily", [["CEN-1", 480, 480], ["NOR-1", 490, 491]]],
["trip", "B7", "daily", [["NOR-2", 493, 493], ["EAS-1", 510, 510]]],
["trip", "B8", "daily", [["NOR-2", 492, 492], ["EAS-1", 505, 505]]],
["plan", "CEN", "EAS", 0, 470]]R1 reaches NOR at 490; NOR needs 3 minutes, so B8 (492) is out and B7 (493) is the first catchable bus: the last result is 510, not 505.
Overnight example: with ["trip", "N1", "daily", [["CEN-1", 1500, 1500], ["NOR-1", 1510, 1510]]] added, ["plan", "CEN", "NOR", 1, 20] returns 70: at 00:20 on day 1 the rider catches day 0's 25:00 departure (which is 01:00 on day 1) and arrives at 01:10, so 70.
tests/test_scanner.py::test_changing_vehicles_needs_the_stations_minimum_transfer_time currently fails.
Part 2: Itineraries (PROD-882)
Goal: add ["itinerary", origin, destination, day, depart]. It takes exactly the same arguments and returns the same errors, in the same order, as plan (a journey exists exactly when plan finds one), and plan must keep returning the same minutes as before. On success it returns this dict, with keys in this order:
{"depart": 480, "arrive": 515, "transfers": 1,
"legs": [["ride", "R1", "CEN-1", "NOR-1", 480, 490],
["walk", "NOR", "MKT", 6],
["ride", "M4", "MKT-1", "EAS-1", 497, 515]]}- A ride is one boarding of one vehicle:
["ride", trip_id, board_platform, alight_platform, dep, arr]. Staying aboard through several stops is one ride.depandarrare relative to the start of the queryday, likeplan's result. - When a transfer uses a footpath, a
["walk", from_station, to_station, minutes]leg sits between the two rides. Same-station transfers add no leg. departis the first ride'sdep,arrivethe last ride'sarr,transfersthe number of rides minus one.
Many journeys are equally early, so the choice among them is part of the contract. Among all journeys allowed by the rules, return the best by these criteria in order:
- earliest arrival;
- fewest rides;
- latest
depart(the first ride's departure); - the smallest sequence of ride keys, comparing ride by ride from the first, where a ride's key is
(trip_id, board_platform, alight_platform, dep); ids compare as plain strings (so"X10" < "X3"),depas an integer.
Example (stations O and D with platforms O-1, D-1, plus the daily service): trips X5 (O-1 480 → D-1 520), X3 (490 → 520) and X10 (490 → 520). ["itinerary", "O", "D", 0, 420] returns {"depart": 490, "arrive": 520, "transfers": 0, "legs": [["ride", "X10", "O-1", "D-1", 490, 520]]}: the latest departure is 490, and between the two 490 trips "X10" sorts before "X3".
Part 3: Live disruptions and calendar exceptions (OPS-441)
Goal: apply cancellations, delays and calendar exceptions on top of the published timetable, for both plan and itinerary, without rebuilding or editing the timetable.
| Operation | Arguments | Returns | Errors (in order) |
|---|---|---|---|
exception | service_id, day, mode | true | invalid arguments (day integer >= 0; mode one of "add", "remove", "clear"), service not found |
cancel | trip_id, day or trip_id, day, platform_id | true | invalid arguments, trip not found, stop not on trip |
delay | trip_id, day, platform_id, minutes | true | invalid arguments (minutes an integer 1..720), trip not found, stop not on trip |
restore | trip_id, day | true | invalid arguments, trip not found |
For cancel/delay/restore, invalid arguments also covers a non-id trip_id/platform_id and a day that is not an integer >= 0.
Rules
exceptionoverrides a service's weekly pattern for one service day:addmakes it run,removestops it,cleargoes back to the weekly pattern. The newest exception for a(service, day)wins. The exception is about the service day: removing day 4 also removes day 4's runs that continue past midnight into day 5.- An overlay targets one vehicle (trip id + service day). Each vehicle has at most one overlay; a new
cancelordelayreplaces the previous one, andrestoreremoves it (and succeeds when there is none). cancelwith a platform: the vehicle serves the stops *before* that platform and nothing from it onwards, so a rider cannot ride to or board at that platform or any later one. Without a platform, or with the trip's first platform, the whole run is cancelled.delayat a platform: that stop'sarranddepand every later stop's are pushed back byminutes; earlier stops are untouched. The journey rules (transfer times, footpaths, the horizon) all apply to the delayed times.- An overlay is attached to the vehicle even if the trip does not run that day. If an exception later makes it run, the overlay applies. Overlays never affect other days.
- After overlays, a vehicle's connections can be in a different order than in the published timetable. Any vehicle that departs inside the window counts, whichever service day it belongs to:
delaymay be up to 720 minutes and stop times go up to 2879. - Part 2's tie-break rules apply to the delayed times (including
depin the ride key).
Example (the Part 1 feed plus ["trip", "B9", "daily", [["NOR-2", 500, 500], ["EAS-1", 518, 518]]]). ["plan", "CEN", "EAS", 0, 470] returns 510. After ["delay", "R1", 0, "NOR-1", 5] (R1 now reaches NOR at 495, ready at 498), the same query returns 518 (B9); the same query on day 1 still returns 510; after ["restore", "R1", 0] it returns 510 again.
Part 4: Frequency routes at scale (PLAN-207)
Goal: add ["frequency", route_id, service_id, stops, first, last, headway] so planning can write "every 6 minutes from 05:00 to 24:00" once, and keep plan/itinerary fast when there are thousands of connections a day and hundreds of queries.
stops are [platform_id, arr, dep] triples as for trip, but the times are offsets from the start of each run. first, last, headway are integers with first >= 0, last >= first, headway >= 1. The route stands for one trip per start time first, first + headway, ... up to and including last (a last that is not on that grid is simply not reached). The trip starting at s is named "<route_id>@<s>" (e.g. "L2@300") and has the stops with s added to every time.
Returns true. Errors in order: invalid arguments, route exists (route ids are their own namespace), service not found, platform not found, invalid trip (the stop-offset rules from trip, and the last run must stay below 2880 too), trip exists (any generated id already used by an explicit trip or another route; nothing is registered when any check fails). Afterwards trip also rejects an id that a route already generated. The generated trips behave exactly like explicit ones: cancel, delay and restore accept their ids (a route id is not a trip id), and Part 2's string ordering applies to them.
Example (stations CEN, NOR with CEN-1, NOR-1, and the daily service):
[["frequency", "L2", "daily", [["CEN-1", 0, 0], ["NOR-1", 10, 11]], 300, 330, 10],
["plan", "CEN", "NOR", 0, 305],
["cancel", "L2@310", 0],
["plan", "CEN", "NOR", 0, 305]]returns [true, 320, true, 330]: runs L2@300, L2@310, L2@320, L2@330 exist; the 05:05 rider boards L2@310; once that run is cancelled they board L2@320.
Hidden tests build networks with thousands of connections per day from a handful of frequency operations, and run hundreds of queries (some interleaved with disruptions, exceptions and new trips) in a single run; the whole suite has a few seconds. Rebuilding or re-sorting everything for each query is too slow.
Notes
network.py,checks.py,models.pyandingest.py(up to Part 1) are known-good.- You may use the AI assistant freely; you are expected to verify what it produces.
- Later parts must not break earlier ones; hidden tests re-check earlier behaviour after each Part.
- Parts 1-3 is a strong result. Part 4 is a stretch.
Example 1
- Input:
- [...feed: CEN(5) NOR(3) EAS(4), daily service...,["trip","R1","daily",[["CEN-1",480,480],["NOR-1",490,491]]],["trip","B7","daily",[["NOR-2",493,493],["EAS-1",510,510]]],["trip","B8","daily",[["NOR-2",492,492],["EAS-1",505,505]]],["plan","CEN","EAS",0,470]]
- Output:
- [..., 510]
- Explanation
- R1 reaches NOR at 490 and NOR needs 3 minutes to change vehicles, so B8 (492) cannot be caught; B7 (493) can.
Example 2
- Input:
- [...feed..., ["trip","N1","daily",[["CEN-1",1500,1500],["NOR-1",1510,1510]]], ["plan","CEN","NOR",1,20]]
- Output:
- [..., 70]
- Explanation
- At 00:20 on day 1 the rider catches day 0's 25:00 night run (01:00 on day 1) and arrives at 01:10, which is minute 70 of day 1.
Example 3
- Input:
- [...O-1, D-1 feed..., ["trip","X5",...480->520], ["trip","X3",...490->520], ["trip","X10",...490->520], ["itinerary","O","D",0,420]]
- Output:
- [..., {"depart":490,"arrive":520,"transfers":0,"legs":[["ride","X10","O-1","D-1",490,520]]}]
- Explanation
- Equal arrival and ride count; the latest departure is 490; between the two 490 trips the plain string order puts "X10" before "X3".
Guidelines
- Python 3 standard library only
- All times are integer minutes; stop times are minutes after the start of the trip's service day and stay below 2880
- Every operation returns exactly one value; errors are returned as "error: <code>" strings, never raised
- Journeys may only board vehicles departing before start + 1440; a vehicle already boarded may run on past it
- Itinerary choice is fully deterministic: earliest arrival, fewest rides, latest departure, then smallest ride-key sequence
- Part 4 inputs expand to tens of thousands of connections per service day with hundreds of queries in one run