Skip to content

Engine Core Developer Guide

Priyesh Patel edited this page Oct 17, 2026 · 8 revisions

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.

Status

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

Setup

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 -v

How a run works

The 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"]
Loading
  1. run_scenario builds the random streams from the master seed, then the components from the scenario and streams
  2. Each component's start(scheduler) schedules its first events
  3. 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's handle(event, scheduler), which may schedule more events
  4. At the end, events.jsonl and summary.json are 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.

Events

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 DepartureDue handler creates the TrainDeparture at the same instant. With a hold, it records the Hold and schedules a HoldEnd; handling HoldEnd creates the TrainDeparture. This way B, A and L include anyone who boarded during the hold. The scheduler rejects a TrainDeparture at any other time
  • Hold delay and disruption delay are kept apart (hold_s vs disruption_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.

What a run produces

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-output

gives 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.

Writing a component

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.

Where the remaining tasks plug in

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

Rules

  • Schema changes bump SCHEMA_VERSION in events.py and update test_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.

Clone this wiki locally