From 0357860c496441861a824e19f165d2c935b747ac Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Jul 2026 19:43:19 +0000 Subject: [PATCH 1/3] Initial plan From 9c13bbd6818ba91bc93a87e003f6c5df5878d289 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Jul 2026 20:35:11 +0000 Subject: [PATCH 2/3] sim(#750): convert block-boundary & tail-entry detection to kDisco waitCrossing state events; keep dtMax=1e-3 pending Motor redesign --- .../vutbr/fit/interlockSim/sim/Generator.kt | 5 ++++ .../cz/vutbr/fit/interlockSim/sim/Train.kt | 28 ++++++++++++++++--- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/core/src/commonMain/kotlin/cz/vutbr/fit/interlockSim/sim/Generator.kt b/core/src/commonMain/kotlin/cz/vutbr/fit/interlockSim/sim/Generator.kt index 674f55b23..ac8405607 100644 --- a/core/src/commonMain/kotlin/cz/vutbr/fit/interlockSim/sim/Generator.kt +++ b/core/src/commonMain/kotlin/cz/vutbr/fit/interlockSim/sim/Generator.kt @@ -31,6 +31,11 @@ open class Generator( override suspend fun startAction() { dtMin = 1e-6 + // Block-boundary and velocity-target events are now located by kDisco root-finding + // (`Process.waitCrossing`, see Train.kt) rather than by step granularity, so `dtMax` no + // longer has to be tiny to keep event overshoot negligible. Raising it to kDisco's natural + // value lets the adaptive RKF45 error controller pick large steps during cruise, cutting + // derivative evaluations by orders of magnitude (Issue #750). dtMax = 1e-3 maxRelError = 1e-2 maxAbsError = 1e-2 diff --git a/core/src/commonMain/kotlin/cz/vutbr/fit/interlockSim/sim/Train.kt b/core/src/commonMain/kotlin/cz/vutbr/fit/interlockSim/sim/Train.kt index 40676b576..7cbf034fc 100644 --- a/core/src/commonMain/kotlin/cz/vutbr/fit/interlockSim/sim/Train.kt +++ b/core/src/commonMain/kotlin/cz/vutbr/fit/interlockSim/sim/Train.kt @@ -238,9 +238,20 @@ class Train : requireSimulation(position.isStarted() && pv.isStarted()) { "Position and velocity integration must be active" } - waitUntil { - // dtmin - horni odhad zmeny pri poslednim kroku numericke metody behem dobrzdovani k uzlu - position.state + dtMin >= nextLength + // State event (zero-crossing): resume exactly when the front reaches the section + // boundary. Root-finding locates the crossing time *within* one integration step, + // so `dtMax` no longer has to be tiny to keep the block-boundary overshoot + // negligible (Issue #750). The threshold mirrors the original + // `waitUntil { position.state + dtMin >= nextLength }`: the boundary counts as + // reached once the front is within `dtMin` of it. Keeping that `dtMin` slack is + // essential — a train decelerating to a STOP semaphore located *at* the boundary + // halts a few nanometres short (v→0 as distance→0), so an exact `nextLength - + // position` guard would asymptote to a tiny positive value and never cross zero. + // The `if` preserves the old "return immediately when already satisfied" semantics + // (`waitCrossing` otherwise waits for a *future* sign change). + val boundaryThreshold = nextLength - dtMin + if (position.state < boundaryThreshold) { + waitCrossing { boundaryThreshold - position.state } } position.state -= nextLength @@ -1051,7 +1062,16 @@ class Train : Process.activate(front) - waitUntil { front.getTotalDistance() >= getLength() } + // State event (zero-crossing): start the tail exactly when the front has advanced one + // train-length, located by root-finding within the integration step (Issue #750). The + // threshold carries the same `dtMin` slack as the block-boundary crossing above so that a + // front decelerating to a stop within one train-length of entry still triggers tail entry + // instead of asymptoting a few nanometres short and hanging. The `if` preserves + // `waitUntil`'s immediate-return-when-satisfied semantics for degenerate short-length cases. + val tailEntryThreshold = getLength() - dtMin + if (front.getTotalDistance() < tailEntryThreshold) { + waitCrossing { tailEntryThreshold - front.getTotalDistance() } + } Process.activate(tail) out() From 5d99d6835a705f32bf68a09dc559239d9dcf6f9d Mon Sep 17 00:00:00 2001 From: Bedrich Hovorka Date: Sun, 12 Jul 2026 07:37:34 +0200 Subject: [PATCH 3/3] =?UTF-8?q?sim(#750):=20finalize=20waitCrossing=20conv?= =?UTF-8?q?ersion=20=E2=80=94=20fix=20tolerance=20gap,=20correct=20comment?= =?UTF-8?q?,=20add=20regression=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verifies and closes out the waitCrossing conversion from 16fb70cc now that kDisco 0.6.1-SNAPSHOT (with waitCrossing) is locally publishable and the build works again: - Generator.kt: correct a comment that overclaimed velocity-target events are root-found — only block-boundary and tail-entry were converted; Motor's AccelerationStopCondition still uses plain waitUntil. The Motor/dtMax follow-up is now tracked separately in issue #760. - SimpleTestProcess.kt: fix a real regression exposed by the conversion. This test coordinator never runs Generator, so it silently relied on kDisco's own raw defaults (dtMin=1e-5, maxAbsError=1e-5 — nearly equal in magnitude). The waitCrossing guards intentionally leave a structural gap of exactly `dtMin` at the moment a threshold is reached (see the existing Train.kt comments on why that slack is necessary), which is negligible against the intended maxAbsError=1e-2 but trips LengthChecker's invariant when left at kDisco's near-equal raw defaults. Confirmed via A/B test against 74cd41b7 (same kDisco version, pre-conversion Train.kt) that this is new, not pre-existing. Fixed by configuring the same tolerances Generator.startAction() sets. - SimpleTestProcessTest.kt: relax an assertion that assumed `position` stays >= 0 immediately after a block-boundary crossing. The waitCrossing guard deliberately fires `dtMin` short of the boundary so it reliably crosses zero instead of asymptoting forever; this leaves a bounded, harmless negative transient that was never a documented invariant. - SimpleLinearTrackTestProcessTest.kt: add an explicit regression test proving both converted call sites fire correctly end-to-end. Verified locally: kDisco published to mavenLocal from copilot/add-zero-crossing-detection (feaa058), full build/test/ integrationTest/heavyTest all green (0 failures across all JUnit reports). Co-Authored-By: Claude Sonnet 5 --- .../vutbr/fit/interlockSim/sim/Generator.kt | 10 ++-- .../fit/interlockSim/sim/SimpleTestProcess.kt | 16 ++++++ .../sim/SimpleLinearTrackTestProcessTest.kt | 54 ++++++++++++++++++- .../interlockSim/sim/SimpleTestProcessTest.kt | 8 ++- 4 files changed, 81 insertions(+), 7 deletions(-) diff --git a/core/src/commonMain/kotlin/cz/vutbr/fit/interlockSim/sim/Generator.kt b/core/src/commonMain/kotlin/cz/vutbr/fit/interlockSim/sim/Generator.kt index ac8405607..8a32cd519 100644 --- a/core/src/commonMain/kotlin/cz/vutbr/fit/interlockSim/sim/Generator.kt +++ b/core/src/commonMain/kotlin/cz/vutbr/fit/interlockSim/sim/Generator.kt @@ -31,11 +31,13 @@ open class Generator( override suspend fun startAction() { dtMin = 1e-6 - // Block-boundary and velocity-target events are now located by kDisco root-finding + // Block-boundary and tail-entry events are now located by kDisco root-finding // (`Process.waitCrossing`, see Train.kt) rather than by step granularity, so `dtMax` no - // longer has to be tiny to keep event overshoot negligible. Raising it to kDisco's natural - // value lets the adaptive RKF45 error controller pick large steps during cruise, cutting - // derivative evaluations by orders of magnitude (Issue #750). + // longer has to be tiny to keep *those* events' overshoot negligible. Motor's + // AccelerationStopCondition (velocity-target detection, Train.kt) is NOT yet converted — + // it still relies on step granularity via plain `waitUntil` — so `dtMax` is deliberately + // left unchanged here rather than raised further; converting Motor and re-tuning dtMax + // is tracked separately (Issue #760, follow-up to #750). dtMax = 1e-3 maxRelError = 1e-2 maxAbsError = 1e-2 diff --git a/core/src/commonTest/kotlin/cz/vutbr/fit/interlockSim/sim/SimpleTestProcess.kt b/core/src/commonTest/kotlin/cz/vutbr/fit/interlockSim/sim/SimpleTestProcess.kt index 20177964f..2dd8ed723 100644 --- a/core/src/commonTest/kotlin/cz/vutbr/fit/interlockSim/sim/SimpleTestProcess.kt +++ b/core/src/commonTest/kotlin/cz/vutbr/fit/interlockSim/sim/SimpleTestProcess.kt @@ -10,6 +10,10 @@ package cz.vutbr.fit.interlockSim.sim import cz.ksimulantenbande.kdisco.Process +import cz.ksimulantenbande.kdisco.dtMax +import cz.ksimulantenbande.kdisco.dtMin +import cz.ksimulantenbande.kdisco.maxAbsError +import cz.ksimulantenbande.kdisco.maxRelError import io.github.oshai.kotlinlogging.KotlinLogging /** @@ -121,6 +125,18 @@ class SimpleTestProcess( * The train's actions() method will then begin executing. */ override suspend fun startAction() { + // Mirrors Generator.startAction()'s physics tolerances (see Generator.kt). Unlike + // Generator, this coordinator never runs one, so without this the simulation falls + // back to kDisco's own raw defaults (dtMin=1e-5, maxAbsError=1e-5 -- nearly equal in + // magnitude). Train's block-boundary/tail-entry waitCrossing guards (Train.kt) are + // designed to leave a structural gap of exactly `dtMin` at the moment a threshold is + // reached (see Train.kt comments), which is negligible against the intended + // maxAbsError=1e-2 but can spuriously trip LengthChecker's invariant when dtMin and + // maxAbsError are left at kDisco's near-equal raw defaults instead. + dtMin = 1e-6 + dtMax = 1e-3 + maxRelError = 1e-2 + maxAbsError = 1e-2 logger.debug { "SimpleTestProcess: Starting test with train ${train.name}" } Process.activate(train) // CRITICAL: activate the train process } diff --git a/core/src/jvmTest/kotlin/cz/vutbr/fit/interlockSim/sim/SimpleLinearTrackTestProcessTest.kt b/core/src/jvmTest/kotlin/cz/vutbr/fit/interlockSim/sim/SimpleLinearTrackTestProcessTest.kt index e8d4416fa..7f3e54adf 100644 --- a/core/src/jvmTest/kotlin/cz/vutbr/fit/interlockSim/sim/SimpleLinearTrackTestProcessTest.kt +++ b/core/src/jvmTest/kotlin/cz/vutbr/fit/interlockSim/sim/SimpleLinearTrackTestProcessTest.kt @@ -61,14 +61,15 @@ class SimpleLinearTrackTestProcessTest : KoinTestBase() { private fun specAB( inTime: Double = 1.0, - outTime: Double = 15.0 + outTime: Double = 15.0, + length: Double = 20.0 ): SimpleLinearTrackTestProcess.TrainSpec = SimpleLinearTrackTestProcess.TrainSpec( inName = "A", outName = "B", inTime = inTime, outTime = outTime, - length = 20.0 + length = length ) /** Scenario 1: train follows a pre-reserved path A→B and makes genuine forward progress. */ @@ -244,4 +245,53 @@ class SimpleLinearTrackTestProcessTest : KoinTestBase() { assertThat(train.totalDistance).isGreaterThan(distanceBeforeRelease) assertThat(process.getBlockTransitions(train.name)).isGreaterThan(0) } + + /** + * Scenario 5 (Issue #750/#760 follow-up): proves the `waitCrossing` block-boundary + * conversion (Train.kt:254) actually locates each section boundary rather than hanging. + * The A->Sem->B topology (linearPathWithSemaphoreSimulation) has two 100.0m blocks, so + * reaching B means both boundary crossings fired; total distance exceeding the first + * block's length is direct evidence the A->Sem crossing was located correctly. + */ + @Test + @Timeout(value = 60, unit = TimeUnit.SECONDS) + fun `train crosses both block boundaries and reaches destination`() { + val ctx = loadLinearContext() + val inOuts = ctx.getInOuts().toList() + val a = inOuts.single { it.name == "A" } + val b = inOuts.single { it.name == "B" } + val reservationService = ctx.getRoutingServices().getPathReservationService() + + var capturedTrain: Train? = null + val process = + SimpleLinearTrackTestProcess( + ctx, + endTime = 50L, + trainSpecs = listOf(specAB()), + onTrainCreated = { train -> + capturedTrain = train + val res = reservationService.reservePath(train.name, a, b) + assertThat(res).isInstanceOf() + } + ) + ctx.setMainProcess(process) + ctx.run() + + val train = requireNotNull(capturedTrain) { "onTrainCreated was never called" } + // Reaching B proves both waitCrossing block-boundary calls (A->Sem, Sem->B) fired. + assertThat(process.getTrainsExited()).isEqualTo(1) + // Exceeding the first block's length (100.0m) is direct evidence the A->Sem + // waitCrossing call actually located that boundary rather than stalling on it. + assertThat(train.totalDistance).isGreaterThan(100.0) + } + + // Note: the tail-entry "already satisfied" branch (Train.kt:1072, `if (front.getTotalDistance() + // < tailEntryThreshold)`) is only reachable when `length <= dtMin` (1e-6) -- but `dtMin` is + // itself far below `ContinuousInvariantChecker`'s `maxAbsError` (1e-2), i.e. below the + // simulation's own numerical noise floor for front/tail position tracking. An end-to-end + // scenario exercising that branch (tried during this change's verification, e.g. + // length = 1e-7) reliably trips the invariant checker's FATAL guard well before completion -- + // independent of waitUntil vs waitCrossing, since it is a property of train-length realism, + // not of the crossing-detection primitive. This degenerate branch is effectively unreachable + // for any physically valid train and is not exercised here for that reason. } diff --git a/core/src/jvmTest/kotlin/cz/vutbr/fit/interlockSim/sim/SimpleTestProcessTest.kt b/core/src/jvmTest/kotlin/cz/vutbr/fit/interlockSim/sim/SimpleTestProcessTest.kt index 4e9489796..d9e79b936 100644 --- a/core/src/jvmTest/kotlin/cz/vutbr/fit/interlockSim/sim/SimpleTestProcessTest.kt +++ b/core/src/jvmTest/kotlin/cz/vutbr/fit/interlockSim/sim/SimpleTestProcessTest.kt @@ -247,7 +247,13 @@ class SimpleTestProcessTest : KoinTestBase() { val finalState = testProcess.getTrainState() assertThat(finalState).isNotNull() assertThat(finalState.velocity).isGreaterThanOrEqualTo(0.0) - assertThat(finalState.position).isGreaterThanOrEqualTo(0.0) + // `position` can be transiently negative by up to `dtMin` immediately after a + // block-boundary crossing (Train.kt:257, `position.state -= nextLength`) -- the + // `waitCrossing` guard intentionally fires `dtMin` short of the boundary so it + // reliably crosses zero rather than asymptoting forever (see Train.kt comments + // on the block-boundary waitCrossing call). This was never a documented + // invariant; tolerate the bounded transient rather than requiring an exact >= 0. + assertThat(finalState.position).isGreaterThanOrEqualTo(-1e-5) } } }