Skip to content

Commit 752a834

Browse files
authored
SP0.10: Add AgentLoopDriver — paced sense-decide-act loop via SimulationController
1 parent 68dede4 commit 752a834

3 files changed

Lines changed: 533 additions & 0 deletions

File tree

dispatcher-agent/build.gradle.kts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ val koinVersion: String by project
4646
val logbackVersion: String by project
4747
val slf4jVersion: String by project
4848
val mockkVersion: String by project
49+
val coroutinesVersion: String by project
4950

5051
group = "cz.vutbr.fit"
5152
version = "1.0"
@@ -86,6 +87,9 @@ dependencies {
8687
// add explicitly so test code that compiles against ShuntingLoop (a kDisco Process
8788
// subclass) can resolve kDisco supertype members.
8889
testImplementation("cz.hovorka.kdisco:kdisco-core-jvm:$kdiscoVersion")
90+
// kotlinx-coroutines-core: :core uses implementation() so it is not exported
91+
// transitively; add explicitly for test code that uses runBlocking (e.g. AgentLoopDriverTest).
92+
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion")
8993
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:$junitJupiterVersion")
9094
}
9195

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
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

Comments
 (0)