Engine Core Developer Guide - team-exception-handlers/TransitSync GitHub Wiki
The engine core for US-1.1 Deterministic event engine is in place: the event schema, the event queue and clock, and a runner that writes a reproducible event log. This page covers how it works, how to run it, and where each remaining task plugs in.
In one sentence: components schedule events on a shared clock, the engine processes them in a fixed order, and the same scenario and seed always produce byte-identical output.
| Task | Issue | State | Code |
|---|---|---|---|
| 1.1.1 Event types and record schema | #96 | Done | simulator/engine/events.py |
| 1.1.2 Event queue and simulation clock | #8 | Done | simulator/engine/event_queue.py |
| 1.1.6 Simulation runner | #99 | Done |
simulator/engine/runner.py, event_log.py
|
| 1.1.3 Seed management | #9 | Open | plugs into build_streams
|
| 1.1.4 Network model | #97 | Open | plugs into build_components
|
| 1.1.5 Scenario configuration | #98 | Open | plugs into Scenario
|
| 1.1.7 Run manifest | #10 | Open | writes next to the run output |
| 1.1.8 Determinism test in CI | #11 | Open | reuses the runner tests |
Full steps are in the README under "Getting started". In short:
py -3.12 -m venv .venv
.\.venv\Scripts\python.exe -m pip install -r requirements-dev.txt
.\.venv\Scripts\python.exe -m pytest -vThe engine is a discrete-event simulation: nothing happens between events, so the clock jumps straight from one event to the next instead of moving in fixed steps.
flowchart LR
S["Scenario<br/>(duration, seed)"] --> R["run_scenario"]
R -->|master seed| ST["build_streams<br/>(#9)"]
R -->|scenario + streams| BC["build_components<br/>(#97)"]
BC --> C["Components<br/>(trains, passengers)"]
C -->|"schedule(event)"| Q["Scheduler<br/>queue + clock"]
Q -->|next event| R
R -->|"handle(event)"| C
R --> L["events.jsonl<br/>summary.json"]
-
run_scenariobuilds the random streams from the master seed, then the components from the scenario and streams - Each component's
start(scheduler)schedules its first events - The scheduler hands back events one at a time, in order, until
duration_s. Each event is written to the log, then passed to every component'shandle(event, scheduler), which may schedule more events - At the end,
events.jsonlandsummary.jsonare written to the output folder
The output folder must not already contain events.jsonl or summary.json; run_scenario raises RunError before the run starts if it does, so old and new results are never mixed. Other files in the folder are fine. If a run fails for any reason, including a failed summary write, everything it wrote is removed, partial files included.
Nine event types, listed in tie order: when two events happen at the same time, the one higher in this table is processed first. All times are in seconds.
| # | Event | When it happens | Key fields |
|---|---|---|---|
| 1 | TrainArrival |
A train reaches a platform |
train_id, platform_id, scheduled_s, disruption_delay_s
|
| 2 | Alighting |
A transferring passenger gets off |
passenger_id, train_id
|
| 3 | PassengerArrival |
A transferring passenger reaches the next platform |
passenger_id, group_id (transfer burst) |
| 4 | Boarding |
A transferring passenger gets on |
passenger_id, train_id
|
| 5 | DepartureDue |
The dwell has ended; the hold is decided here |
train_id, scheduled_s
|
| 6 | Hold |
The hold decision is recorded |
requested_s, applied_s, reason
|
| 7 | HoldEnd |
A hold has ended; the held train's departure is created here | train_id |
| 8 | TrainDeparture |
The train leaves |
hold_s, boarded_count (B), alighted_count (A), onboard_load (L) |
| 9 | MissedConnection |
A passenger's train left without them |
passenger_id, train_id
|
-
Only transferring passengers get individual events. Everyone else enters the math only as counts, recorded on
TrainDeparture -
A departure is created at the moment the train leaves, never ahead of time. With no hold, the
DepartureDuehandler creates theTrainDepartureat the same instant. With a hold, it records theHoldand schedules aHoldEnd; handlingHoldEndcreates theTrainDeparture. This way B, A and L include anyone who boarded during the hold. The scheduler rejects aTrainDepartureat any other time -
Hold delay and disruption delay are kept apart (
hold_svsdisruption_delay_s), so the cost of holding can be measured on its own - The scheduler enforces the order. It rejects an event in the past, or one at the current time that comes earlier in the table than the event just processed. A boarding cannot be scheduled after the train has already left
Full field lists and units are in the docstrings of events.py.
The runner tests use a tiny McGill timetable: one REM train and two green line trains, no holds. Keeping their output with
.\.venv\Scripts\python.exe -m pytest tests/simulator/engine/test_runner.py --basetemp=run-outputgives events.jsonl, one event per line in processing order (shortened here):
{"schema_version":2,"event_type":"train_arrival","time_s":100.0,"train_id":"green-1",...}
{"schema_version":2,"event_type":"train_arrival","time_s":120.0,"train_id":"rem-1",...}
{"schema_version":2,"event_type":"departure_due","time_s":120.0,"train_id":"green-1",...}
{"schema_version":2,"event_type":"train_departure","time_s":120.0,"train_id":"green-1","hold_s":0.0,...}At 120 s three events share the same time, and the tie order puts the REM arrival before the green line departure. summary.json holds the seed, the number of events processed and still pending, and a count per event type.
A component is any object with two methods. This is the shape of a train component with no control strategy (the full version is TimetableComponent in tests/conftest.py):
class Trains:
def start(self, scheduler):
# Schedule every train's first arrival
for train in self.timetable:
scheduler.schedule(TrainArrival(time_s=train.arrival_s, ...))
def handle(self, event, scheduler):
if isinstance(event, TrainArrival):
# Dwell ends later: that is when a hold can be decided
scheduler.schedule(DepartureDue(time_s=event.time_s + dwell_s, ...))
elif isinstance(event, DepartureDue):
# No strategy, so no hold: leave at the same instant
scheduler.schedule(TrainDeparture(time_s=event.time_s, hold_s=0.0, ...))Every component sees every event and ignores the ones it does not need. With a strategy, the DepartureDue branch would build the observation and record a Hold. If the applied hold is above 0, it schedules a HoldEnd at the current time plus the hold, and a HoldEnd branch creates the TrainDeparture with the counts at that moment. TimetableComponent in tests/conftest.py shows this with a fixed hold.
The runner depends only on the small interfaces in runner.py. Each task replaces a stand-in in tests/conftest.py once merged.
| Task | Provides | Must satisfy | Stand-in to remove |
|---|---|---|---|
| #98 Scenario | The scenario object | Has duration_s (seconds) and master_seed (non-negative int) |
MinimalScenario |
| #9 Seeds | build_streams(master_seed) |
Returns the substreams: one SeedSequence substream per source (Mathematical Foundations section 9) |
SeedOnlyStreams |
| #97 Network | build_components(scenario, streams) |
Returns a list of components (see above) |
TimetableComponent, TINY_TIMETABLE
|
| #10 Manifest | manifest.json |
Written next to the run output, with no wall clock time or absolute paths, or determinism breaks | none |
| #11 CI | GitHub Actions workflow | Runs ruff check ., ruff format --check . and pytest
|
none |
-
Schema changes bump
SCHEMA_VERSIONinevents.pyand updatetest_events.py -
Tests go in
tests/, mirroring the source tree. Expected values are worked out by hand, never computed with the code under test. Every task adds a main test and an edge-case test -
AI declaration: every source and config file starts with
# AI contribution: ...(No substantial / Below 50% / 50% or more AI-generated) - Limits: functions at most 50 lines, files at most 500. Ruff checks complexity but not function length
- No randomness outside the seeded streams, and no Supabase or network access in tests
The design decisions behind the engine (heapq instead of SimPy, per-passenger events, the tie order, DepartureDue, HoldEnd) are recorded in the Architecture changes table on the Architecture page.