|
| 1 | +/* Brno University of Technology |
| 2 | + * Faculty of Information Technology |
| 3 | + * |
| 4 | + * BSc Thesis 2006/2007 |
| 5 | + * |
| 6 | + * Railway Interlocking Simulator |
| 7 | + * |
| 8 | + * Bedrich Hovorka |
| 9 | + */ |
| 10 | +package cz.vutbr.fit.interlockSim.dispatcher |
| 11 | + |
| 12 | +import cz.vutbr.fit.interlockSim.context.SimulationController |
| 13 | +import cz.vutbr.fit.interlockSim.ports.NetworkPerceptionPort |
| 14 | +import cz.vutbr.fit.interlockSim.ports.SimulationSnapshot |
| 15 | +import cz.vutbr.fit.interlockSim.sim.DispatchObservation |
| 16 | +import cz.vutbr.fit.interlockSim.sim.Dispatcher |
| 17 | +import io.github.oshai.kotlinlogging.KotlinLogging |
| 18 | + |
| 19 | +/** |
| 20 | + * Drives the dispatcher sense→decide→act loop from outside the kDisco kernel, |
| 21 | + * paced by [SimulationController.awaitIfPaused] / [SimulationController.throttle]. |
| 22 | + * |
| 23 | + * Each call to [runCycle] executes one complete iteration: |
| 24 | + * 1. **SENSE**: reads a [SimulationSnapshot] from [perceptionPort] |
| 25 | + * 2. **DECIDE**: calls [Dispatcher.decide] with a [DispatchObservation] built from |
| 26 | + * the snapshot |
| 27 | + * 3. **ACT**: posts the returned [cz.vutbr.fit.interlockSim.sim.DispatchDecision]s to |
| 28 | + * [commandQueue] — a thread-safe handoff; the sim-thread |
| 29 | + * [DispatchDecisionApplier] drains and applies them on the kDisco thread |
| 30 | + * 4. **PACE**: calls [SimulationController.awaitIfPaused] to honour pause/step |
| 31 | + * requests, then [SimulationController.throttle] with the simulation-time delta |
| 32 | + * since the previous cycle |
| 33 | + * |
| 34 | + * ## Threading |
| 35 | + * |
| 36 | + * The driver runs on its own thread or coroutine; the kDisco kernel never blocks |
| 37 | + * on it (A6-safe from the SP0.5 design spec). The driver thread reads |
| 38 | + * [perceptionPort] (read-only, safe for off-thread access) and posts to |
| 39 | + * [commandQueue] (thread-safe). It never touches live simulation state. |
| 40 | + * |
| 41 | + * ## SimulationController invariant |
| 42 | + * |
| 43 | + * [controller] is injected into the driver **only** — it is never passed to |
| 44 | + * [SimulationController][cz.vutbr.fit.interlockSim.context.SimulationEnvironment], |
| 45 | + * [DispatchObservation], or [Dispatcher] implementations (locked invariant 3 |
| 46 | + * from the SP0.5 design spec, docs/specs/2026-07-08-544-sp05-drive-loop-design.md). |
| 47 | + * |
| 48 | + * ## DispatchObservation construction |
| 49 | + * |
| 50 | + * For the initial SP0.10 slice the observation contains only the general-purpose |
| 51 | + * [SimulationSnapshot] (signals, block occupancy, train positions, timetables). |
| 52 | + * The [DispatchObservation.unapprovedTrains] list and the block-input lists are |
| 53 | + * populated in SP0.11 (#733) when [perceptionPort] is extended and the |
| 54 | + * [ShuntingLoop][cz.vutbr.fit.interlockSim.sim.ShuntingLoop] shell is wired to |
| 55 | + * expose them. Until then the driver creates observations with empty auxiliary |
| 56 | + * lists; [cz.vutbr.fit.interlockSim.sim.RuleBasedDispatcher] handles this case |
| 57 | + * gracefully (it returns [cz.vutbr.fit.interlockSim.sim.DispatchDecision.NoAction]). |
| 58 | + * |
| 59 | + * @param perceptionPort Read-only sense port for the railway network state (SP0.4, #543) |
| 60 | + * @param dispatcher Pure decision function (SP0.7, #729); must not retain the |
| 61 | + * observation beyond the call or mutate simulation state |
| 62 | + * @param commandQueue Thread-safe queue to which decisions are posted (SP0.8, #730); |
| 63 | + * drained and applied by the sim-thread applier (SP0.9, #731) |
| 64 | + * @param controller Pacing controller; injected into the driver **only** — |
| 65 | + * never exposed to [cz.vutbr.fit.interlockSim.context.SimulationEnvironment] or |
| 66 | + * policy implementations (SP0.5 invariant 3) |
| 67 | + * |
| 68 | + * @since Issue #732 (SP0.10 — Goal 10) |
| 69 | + */ |
| 70 | +class AgentLoopDriver( |
| 71 | + private val perceptionPort: NetworkPerceptionPort, |
| 72 | + private val dispatcher: Dispatcher, |
| 73 | + private val commandQueue: ActuatorCommandQueue, |
| 74 | + private val controller: SimulationController |
| 75 | +) { |
| 76 | + companion object { |
| 77 | + private val logger = KotlinLogging.logger {} |
| 78 | + } |
| 79 | + |
| 80 | + /** Simulation time at the end of the most recently completed cycle; 0.0 before the first cycle. */ |
| 81 | + private var prevSimTime: Double = 0.0 |
| 82 | + |
| 83 | + /** |
| 84 | + * Executes one complete sense→decide→act→pace cycle. |
| 85 | + * |
| 86 | + * This is a `suspend` function so that [SimulationController.awaitIfPaused] (which |
| 87 | + * is itself `suspend`) can co-operatively yield the driver coroutine while the |
| 88 | + * simulation is paused, without blocking the underlying OS thread. |
| 89 | + * |
| 90 | + * Cycle steps: |
| 91 | + * 1. **SENSE** — [NetworkPerceptionPort.snapshot] captures a consistent, frozen |
| 92 | + * picture of the current network state. |
| 93 | + * 2. **DECIDE** — the [dispatcher] is called with an observation built from the |
| 94 | + * snapshot; [DispatchObservation.unapprovedTrains] and block-input lists are |
| 95 | + * empty in this SP0.10 slice (populated in SP0.11). |
| 96 | + * 3. **ACT** — all returned decisions are posted to [commandQueue] in a single |
| 97 | + * atomic [ActuatorCommandQueue.postAll] call. The sim-thread |
| 98 | + * [DispatchDecisionApplier] applies them; no simulation state is mutated on |
| 99 | + * the driver thread. |
| 100 | + * 4. **PACE** — [SimulationController.awaitIfPaused] is called first (honours |
| 101 | + * pause/step requests), then [SimulationController.throttle] with the |
| 102 | + * simulation-time delta since the previous cycle (wall-clock pacing). |
| 103 | + * |
| 104 | + * The simulation-time delta passed to [SimulationController.throttle] is |
| 105 | + * `snapshot.simTime - prevSimTime`. On the very first cycle [prevSimTime] is |
| 106 | + * `0.0`, so the delta equals `snapshot.simTime` — this mirrors the first-event |
| 107 | + * behaviour in [cz.vutbr.fit.interlockSim.context.DefaultSimulationContext]. |
| 108 | + */ |
| 109 | + suspend fun runCycle() { |
| 110 | + // 1. SENSE — read a consistent frozen snapshot off the perception port. |
| 111 | + val snapshot = perceptionPort.snapshot() |
| 112 | + logger.debug { "AgentLoopDriver: sensed snapshot at simTime=${snapshot.simTime}" } |
| 113 | + |
| 114 | + // 2. DECIDE — call the pure dispatcher with a read-only observation. |
| 115 | + // unapprovedTrains and block-input lists are populated in SP0.11 (#733). |
| 116 | + val observation = |
| 117 | + DispatchObservation( |
| 118 | + snapshot = snapshot, |
| 119 | + unapprovedTrains = emptyList() |
| 120 | + ) |
| 121 | + val decisions = dispatcher.decide(observation) |
| 122 | + logger.debug { "AgentLoopDriver: decided ${decisions.size} decision(s)" } |
| 123 | + |
| 124 | + // 3. ACT — post decisions to the thread-safe handoff queue. |
| 125 | + // The sim-thread DispatchDecisionApplier drains and applies them; this |
| 126 | + // call never mutates simulation state on the driver thread. |
| 127 | + val posted = commandQueue.postAll(decisions) |
| 128 | + if (!posted) { |
| 129 | + logger.warn { |
| 130 | + "AgentLoopDriver: commandQueue rejected ${decisions.size} decision(s) " + |
| 131 | + "(backpressure limit reached); decisions discarded for this cycle" |
| 132 | + } |
| 133 | + } |
| 134 | + |
| 135 | + // 4. PACE — honour pause/step requests, then throttle wall-clock time. |
| 136 | + controller.awaitIfPaused() |
| 137 | + val simDelta = snapshot.simTime - prevSimTime |
| 138 | + controller.throttle(simDelta) |
| 139 | + prevSimTime = snapshot.simTime |
| 140 | + } |
| 141 | +} |
0 commit comments