Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
0b6f844
Phase 1.2: Wire SimulationRunner into GUI lifecycle (simgui mode + St…
Copilot May 5, 2026
58137f6
Fix FrameSimulationLifecycleTest: toDynamic + PropertyChangeEvent in …
Copilot May 5, 2026
b27b470
Fix FrameSimulationLifecycleTest: fire run-listeners directly in Mock…
bedaHovorka May 5, 2026
10f4518
Add SimulationControlPanel component with speed control features (#484)
Copilot May 5, 2026
b23bcb1
Add simulation menu with speed control (#486)
Copilot May 5, 2026
6a1db41
Phase 2.2: ToolBar and StatusBar Integration for Simulation Speed Con…
Copilot May 5, 2026
87786bf
Refactor SimulationController to callback-based lifecycle API and mov…
Copilot May 6, 2026
479f10a
Add global keyboard shortcuts for simulation speed control (#487)
Copilot May 6, 2026
03bf84f
Add golden output tests for simulation speed control (#489)
Copilot May 6, 2026
5fc11a9
Phase 4.2: Performance benchmarks for SimulationRunner speed control …
Copilot May 6, 2026
64795ae
feat: SimulationSpeedIntegrationTest — Phase 4.3 integration tests fo…
Copilot May 7, 2026
a6f73aa
Fix RealTimeSynch: remove presvihnuto from hold() to preserve simulat…
bedaHovorka May 7, 2026
0beb74d
Fix exampleGui speed buttons: bridge SimulationController -> Shunting…
bedaHovorka May 7, 2026
cf9759b
fix: remove flaky startedLatch.await from nullMainProcessNoOp; fix al…
Copilot May 7, 2026
31ddfc9
Correct Goal 7 documentation for kDisco-based speed control (#494)
Copilot May 7, 2026
7968d67
Add user documentation for simulation speed control (#493)
Copilot May 7, 2026
81dd75c
Fix undeterministic tests in desktop UI (#496)
Copilot May 7, 2026
3dae561
Potential fix for pull request finding
bedaHovorka May 7, 2026
9a31345
fix(docs): correct Frame KDoc — StatusBar stays visible in simulation…
Copilot May 7, 2026
0780a88
fix(gui): route slider speed change exclusively through callback when…
bedaHovorka May 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/sonarqube.yml
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ jobs:
# Requires SONAR_TOKEN and SONAR_ORGANIZATION secrets configured in GitHub
# If secrets are not configured, this step will skip gracefully
- name: SonarCloud Scan
if: github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop'
env:
GITHUB_ACTOR: ${{ github.actor }}
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
Expand Down
31 changes: 31 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,13 +52,38 @@ This project uses Gradle with Kotlin DSL. Java 21 LTS is required.
./gradlew runEditor # Launch editor GUI
./gradlew runExampleGui # Animated GUI simulation (Issue #268, milestone complete 2026-02-04)

# Goal 7 speed-control shortcuts (simulation mode only)
# 1-5 -> 0.5x, 1x, 2x, 5x, 10x
# +/- -> multiply or divide speed by 1.5
# Space -> pause/resume toggle (Goal 8 integration point)

# Other tasks
./gradlew javadoc # Generate documentation
./gradlew dependencies # Show dependency tree
```

For complete build system documentation including dependency management, GitHub Packages authentication, manual JAR execution, and Gradle configuration files, see **[docs/KOTLIN_STYLE_GUIDE.md](docs/KOTLIN_STYLE_GUIDE.md)** under "Build & Development Environment".

### Running Simulation with Speed Control

Use the animated GUI when you need live speed changes:

```bash
# Built-in animated example with speed controls
./gradlew runExampleGui

# Equivalent manual JAR launch
java -jar build/libs/interlockSim.jar exampleGui shuntingLoop 300
```

For XML files loaded from the desktop UI, use **Simulation → Start...** and then adjust speed with:

- the speed slider (`0.1x` to `10.0x`)
- preset buttons/menu items (`0.1x`, `0.5x`, `1x`, `2x`, `5x`, `10x`, `50x`)
- global keyboard shortcuts (`1`-`5`, `+`, `-`, `Space`)

The status bar shows `Speed: X.Xx` whenever the speed differs from `1.0x`.

### Directory Structure

- `core/` - KMP `:core` subproject (domain model, simulation engine, XML)
Expand Down Expand Up @@ -170,6 +195,12 @@ For complete navigation services architecture, Koin DI integration patterns, and
- AnimatedSim: Real-time animated GUI simulation (Issue #268, milestone complete 2026-02-04)
- Physics-accurate rendering with velocity/acceleration visualization
- Visual train movement with smooth interpolation
- Goal 7 speed control is built around `SimulationRunner`, which applies wall-clock throttling without changing event semantics
- `SimulationController` owns lifecycle, persists the selected speed, and reapplies it on the next simulation start
- `SimulationControlPanel` provides a `0.1x`-`10.0x` slider plus preset buttons up to `50x`
- `StatusBar.updateSpeedIndicator()` shows the live multiplier whenever speed is not `1.0x`
- `SimulationKeyBindings` installs global shortcuts (`1`-`5`, `+`, `-`, `Space`) while the frame is in simulation mode
- `Space` currently toggles `SimulationRunner.isPaused` directly as Goal 8 pause-feature groundwork
- See `docs/ANIMATION_ARCHITECTURE.md` for technical details

**XML Configuration:**
Expand Down
5 changes: 4 additions & 1 deletion LONG_TERM_GOALS.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,7 @@ Users can automatically collect key performance indicators during simulation inc
**Category:** I: System Operations
**Priority:** Critical
**Development Estimate:** 1 month
**Status:** ✅ COMPLETE

**User Value:**
Users can adjust simulation speed from slow motion (for detailed observation) to fast forward (for quick scenario completion). This provides flexibility for different use cases: slow for education, fast for research batch runs.
Expand All @@ -222,7 +223,9 @@ Users can adjust simulation speed from slow motion (for detailed observation) to
**Dependencies:** None (quick win)

**Implementation Notes:**
- kDisco already supports speed control; needs UI exposure
- The simulation library interface used by the model (historically jDisco, now kDisco/KMP) runs in pure simulation time and has no native wall-clock speed control or synchronization.
- Speed control for `ShuntingLoop` is implemented via the `RealTimeSynch` inner process inside `sim/ShuntingLoop.kt` (enabled by `enableRealTimeSync`, paced by `speedMultiplier`). This resides in the `sim/` package.
- `SimulationRunner` provides a complementary external throttling API (`throttle()`, `awaitIfPaused()`) callable from the simulation thread; this is the designed extension point for future simulation processes that delegate pacing outside the `sim/` package.
- Quick win - implement early for immediate value

---
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,11 @@ import cz.vutbr.fit.interlockSim.objects.core.Cell
import cz.vutbr.fit.interlockSim.objects.core.Cell.Segment
import cz.vutbr.fit.interlockSim.objects.core.DynamicPathSeparator
import cz.vutbr.fit.interlockSim.objects.core.OrientedPathSeparator
import cz.vutbr.fit.interlockSim.objects.core.ContextChangeEvent
import cz.vutbr.fit.interlockSim.objects.core.ContextPropertyChangeListener
import cz.vutbr.fit.interlockSim.objects.core.Track
import cz.vutbr.fit.interlockSim.objects.core.TrackFacility
import cz.vutbr.fit.interlockSim.objects.tracks.DynamicTrack
import cz.vutbr.fit.interlockSim.objects.tracks.DynamicTrackBlock
import cz.vutbr.fit.interlockSim.sim.InOutWorker
import org.koin.mp.KoinPlatformTools
Expand All @@ -42,6 +46,22 @@ class MockSimulationContext(
private val workers: MutableMap<DynamicInOut, InOutWorker> = mutableMapOf()
private val enabledReports: MutableCollection<ReportType> = mutableListOf()
private var stopped: Boolean = false
private var runListeners: List<ContextPropertyChangeListener> = emptyList()

/** Number of times [close] has been called. Used by tests to verify scope cleanup. */
var closeCount: Int = 0
private set

/**
* On-demand cache for [DynamicTrack] wrappers.
*
* [DefaultSimulationContext.toDynamic] requires [initializeDynamicMapping] to have
* been called first (normally inside [DefaultSimulationContext.run]). Tests that call
* [Frame.setContext] without actually running the simulation trigger the animation
* system which calls [toDynamic] before [run]. This cache creates wrappers on demand
* so tests do not need to call [run] first.
*/
private val dynamicTrackCache: MutableMap<TrackFacility, DynamicTrack> = mutableMapOf()

init {
// Enable all standard reports by default
Expand All @@ -68,8 +88,29 @@ class MockSimulationContext(
return delegate.getInOuts()
}

override fun addPropertyChangeListener(listener: ContextPropertyChangeListener) {
runListeners = runListeners + listener
delegate.addPropertyChangeListener(listener)
}

override fun removePropertyChangeListener(listener: ContextPropertyChangeListener) {
runListeners = runListeners - listener
delegate.removePropertyChangeListener(listener)
}

override fun run() {
stopped = false
// Fire directly from runListeners so callers waiting on addPropertyChangeListener
// (e.g. FrameSimulationLifecycleTest) are notified when simulation starts.
// Cannot rely on delegate.freeze() because ContextTransformer already freezes the
// delegate at creation time, making freeze() a no-op here.
val event = ContextChangeEvent("frozen", false, true)
runListeners.forEach { it.propertyChange(event) }
}

override fun close() {
closeCount++
delegate.close()
}

override fun stop() {
Expand Down Expand Up @@ -139,6 +180,26 @@ class MockSimulationContext(
): Boolean {
return true
}

/**
* Returns a [DynamicTrack] wrapper for [track], creating one on demand if needed.
*
* Delegate's map is only populated after [DefaultSimulationContext.run] calls
* [initializeDynamicMapping]. Tests that call [Frame.setContext] without starting
* the simulation trigger the animation system before [run], so the map is empty.
* This override falls back to an on-demand cache so tests remain independent of
* simulation startup order.
*/
override fun toDynamic(track: TrackFacility): DynamicTrack {
return try {
delegate.toDynamic(track)
} catch (_: IllegalStateException) {
// Expected: delegate map is empty before initializeDynamicMapping() runs (i.e.,
// before DefaultSimulationContext.run()). Create a wrapper on demand for test use.
val staticKey = (track as? DynamicTrackBlock)?.staticRef as? TrackFacility ?: track
dynamicTrackCache.getOrPut(staticKey) { DynamicTrack(staticKey) }
}
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -644,7 +644,7 @@
requireSimulation(separator is OrientedPathSeparator) {
"PathSeparator must be OrientedPathSeparator, got ${separator::class.simpleName ?: "unknown"}"
}
val segment = getSegment(separator, secondEndTrack!!)

Check warning on line 647 in core/src/commonMain/kotlin/cz/vutbr/fit/interlockSim/context/DefaultSimulationContext.kt

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this useless non-null assertion !!, it always succeeds.

See more on https://sonarcloud.io/project/issues?id=bedaHovorka_interlockSim&issues=AZ4BRektx9JU6ccax7LZ&open=AZ4BRektx9JU6ccax7LZ&pullRequest=482
// Match Java 1:1: return null when segment doesn't exist
return separator.getFollowingSegment(segment)
}
Expand Down Expand Up @@ -717,7 +717,7 @@
private fun getLocation(node: NodeCell): Point {
val location = getRailWayNetGrid().getLocation(node)
requireSimulation(location != null) { "Location not found for nodeCell $node in grid" }
return location!!

Check warning on line 720 in core/src/commonMain/kotlin/cz/vutbr/fit/interlockSim/context/DefaultSimulationContext.kt

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this useless non-null assertion !!, it always succeeds.

See more on https://sonarcloud.io/project/issues?id=bedaHovorka_interlockSim&issues=AZ4BRektx9JU6ccax7LY&open=AZ4BRektx9JU6ccax7LY&pullRequest=482
}

/**
Expand Down Expand Up @@ -1533,4 +1533,12 @@
fun setMainProcess(process: LoopProcess) {
mainProcess = process
}

/**
* Returns the currently registered main process, or `null` if none has been set
* via [setMainProcess]. Callers may downcast to runtime-control interfaces such
* as [cz.vutbr.fit.interlockSim.sim.SpeedControllable] to retune the live
* simulation (e.g. wall-clock pacing) from the EDT.
*/
fun getMainProcess(): LoopProcess? = mainProcess

Check warning on line 1543 in core/src/commonMain/kotlin/cz/vutbr/fit/interlockSim/context/DefaultSimulationContext.kt

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Convert this getter to a "get()" on the property "mainProcess".

See more on https://sonarcloud.io/project/issues?id=bedaHovorka_interlockSim&issues=AZ4BRektx9JU6ccax7La&open=AZ4BRektx9JU6ccax7La&pullRequest=482
}
Original file line number Diff line number Diff line change
Expand Up @@ -67,12 +67,22 @@ class ShuntingLoop(
context: SimulationContext,
private val endTime: Long,
private val enableRealTimeSync: Boolean = false,
private val speedMultiplier: Double = 1.0,
initialSpeedMultiplier: Double = 1.0,
private val pathReservationService: PathReservationService = context.getPathReservationService()
) : Interlocking(context),
SpeedControllable,
KoinComponent {
@kotlin.concurrent.Volatile
override var speedMultiplier: Double = initialSpeedMultiplier
set(value) {
require(value > 0.0) { "Speed multiplier must be positive, got: $value" }
field = value
}

init {
require(speedMultiplier > 0.0) { "Speed multiplier must be positive, got: $speedMultiplier" }
require(initialSpeedMultiplier > 0.0) {
"Speed multiplier must be positive, got: $initialSpeedMultiplier"
}
}

// Inject registry for idempotent path reservation checks
Expand Down Expand Up @@ -139,7 +149,6 @@ class ShuntingLoop(
private val blockTransitionsByTrain: MutableMap<String, Int> = mutableMapOf()

private inner class RealTimeSynch : LoopProcess() {
private var presvihnuto: Double = 0.0
private var beginTime: Long = 0

override suspend fun startAction() {
Expand All @@ -155,15 +164,12 @@ class ShuntingLoop(
// Simulation termination is handled by LoopProcess.terminate() setting the flag
// which is checked between iterations — platformSleep interrupt does not cause a tight loop.
platformSleep(sleepTime)
} else if (sleepTime < 0) {
presvihnuto = sleepTime / 1000.0
}
}

override suspend fun interLoopSleep() {
beginTime = currentTimeMillisKMP()
hold(1 + presvihnuto)
presvihnuto = 0.0
hold(1.0)
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/* Brno University of Technology
* Faculty of Information Technology
*
* BSc Thesis 2006/2007
*
* Railway Interlocking Simulator
*
* Bedrich Hovorka
*/
package cz.vutbr.fit.interlockSim.sim

/**
* Implemented by simulation processes whose wall-clock pacing can be retuned at runtime.
*
* Implementations MUST treat the property as thread-safe: reads happen on the kDisco
* simulation thread (e.g. inside a real-time-sync loop), writes happen on the EDT
* (or any thread driving GUI controls / keyboard shortcuts). Use `@kotlin.concurrent.Volatile`
* on the backing field.
*
* Range validation is left to the implementation; the canonical bounds used by the
* desktop GUI live on `SimulationRunner.MIN_SPEED`/`MAX_SPEED`.
*/
interface SpeedControllable {
/** Wall-clock speed multiplier. 1.0 = real-time, 2.0 = twice as fast, 0.5 = half-speed. */
var speedMultiplier: Double
}
Original file line number Diff line number Diff line change
Expand Up @@ -310,7 +310,7 @@ class ShuntingLoopTest : KoinTestBase() {
validContext,
60L,
enableRealTimeSync = true,
speedMultiplier = 2.0
initialSpeedMultiplier = 2.0
)
assertThat(shuntingLoop).isNotNull()
}
Expand All @@ -335,7 +335,7 @@ class ShuntingLoopTest : KoinTestBase() {
validContext,
60L,
enableRealTimeSync = true,
speedMultiplier = 0.5
initialSpeedMultiplier = 0.5
)
assertThat(shuntingLoop).isNotNull()
}
Expand All @@ -348,7 +348,7 @@ class ShuntingLoopTest : KoinTestBase() {
validContext,
60L,
enableRealTimeSync = true,
speedMultiplier = 1.0
initialSpeedMultiplier = 1.0
)
assertThat(shuntingLoop).isNotNull()
}
Expand All @@ -361,7 +361,7 @@ class ShuntingLoopTest : KoinTestBase() {
validContext,
60L,
enableRealTimeSync = true,
speedMultiplier = 2.0
initialSpeedMultiplier = 2.0
)
assertThat(shuntingLoop).isNotNull()
}
Expand All @@ -374,7 +374,7 @@ class ShuntingLoopTest : KoinTestBase() {
validContext,
60L,
enableRealTimeSync = true,
speedMultiplier = 5.0
initialSpeedMultiplier = 5.0
)
assertThat(shuntingLoop).isNotNull()
}
Expand All @@ -394,7 +394,7 @@ class ShuntingLoopTest : KoinTestBase() {
validContext,
60L,
enableRealTimeSync = true,
speedMultiplier = 0.1
initialSpeedMultiplier = 0.1
)
assertThat(shuntingLoop).isNotNull()
}
Expand All @@ -407,7 +407,7 @@ class ShuntingLoopTest : KoinTestBase() {
validContext,
60L,
enableRealTimeSync = true,
speedMultiplier = 10.0
initialSpeedMultiplier = 10.0
)
assertThat(shuntingLoop).isNotNull()
}
Expand All @@ -420,7 +420,7 @@ class ShuntingLoopTest : KoinTestBase() {
validContext,
60L,
enableRealTimeSync = true,
speedMultiplier = 0.0
initialSpeedMultiplier = 0.0
)
}.withMessage("Speed multiplier must be positive")
.isFailure()
Expand All @@ -435,12 +435,54 @@ class ShuntingLoopTest : KoinTestBase() {
validContext,
60L,
enableRealTimeSync = true,
speedMultiplier = -1.0
initialSpeedMultiplier = -1.0
)
}.withMessage("Speed multiplier must be positive")
.isFailure()
.isInstanceOf(IllegalArgumentException::class)
}

@Test
@DisplayName("speedMultiplier is mutable post-construction (RealTimeSynch reads live value)")
fun speedMultiplier_isMutableAtRuntime() {
val shuntingLoop = ShuntingLoop(validContext, 60L, enableRealTimeSync = true)

// Default initial value
assertThat(shuntingLoop.speedMultiplier).isEqualTo(1.0)

// Mutating from the EDT side (or any thread) must stick — RealTimeSynch.iteration()
// reads from this @Volatile field on the simulation thread, so the value seen by
// the wall-clock pacing must match the latest write.
shuntingLoop.speedMultiplier = 2.5
assertThat(shuntingLoop.speedMultiplier).isEqualTo(2.5)

shuntingLoop.speedMultiplier = 0.1
assertThat(shuntingLoop.speedMultiplier).isEqualTo(0.1)
}

@Test
@DisplayName("speedMultiplier setter rejects zero")
fun speedMultiplier_setter_zero_throwsException() {
val shuntingLoop = ShuntingLoop(validContext, 60L, enableRealTimeSync = true)

assertThatBlock {
shuntingLoop.speedMultiplier = 0.0
}.withMessage("Speed multiplier must be positive")
.isFailure()
.isInstanceOf(IllegalArgumentException::class)
}

@Test
@DisplayName("speedMultiplier setter rejects negative")
fun speedMultiplier_setter_negative_throwsException() {
val shuntingLoop = ShuntingLoop(validContext, 60L, enableRealTimeSync = true)

assertThatBlock {
shuntingLoop.speedMultiplier = -1.0
}.withMessage("Speed multiplier must be positive")
.isFailure()
.isInstanceOf(IllegalArgumentException::class)
}
}

@Nested
Expand Down
Loading
Loading