Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,25 @@ sealed class DynamicRailSemaphore(
@kotlin.concurrent.Volatile
private var listeners: List<ContextPropertyChangeListener> = emptyList()

/**
* The segment pair this semaphore's current proceed aspect is authorized for, as recorded by
* the last [setUpSpeed] / [setUpPath] call from the reservation flow. `null` while the signal
* is [Signal.STOP] (nothing authorized) and after [cancelPathSetup].
*
* Read by [authorizedDirection] / [isAllowingFor] so the canvas and the LLM dispatcher's
* `all_signal_aspects` can distinguish a proceed aspect authorized for the forward direction
* from one authorized for the opposite direction (Issue #812).
*
* Direct `signal =` writes that bypass [setUpSpeed] (interlocking-facade `clearSignal`,
* actuator `setSignalAspect`) leave these `null`; [authorizedDirection] then defaults to the
* canonical forward direction, which is correct for entry/facing signals.
*/
@kotlin.concurrent.Volatile
private var authorizedFrom: Cell.Segment? = null

@kotlin.concurrent.Volatile
private var authorizedTo: Cell.Segment? = null

// Static properties delegated from wrapped object
// orientation and direction() are delegated from OrientedPathSeparator
// spatialType is already available via PathSeparator delegation (getSpatialType())
Expand All @@ -73,6 +92,14 @@ sealed class DynamicRailSemaphore(
}
}
field = newSignal
// A STOP signal authorizes nothing: drop the recorded reservation direction so the
// next direct write (clearSignal/setSignalAspect without setUpSpeed) defaults cleanly
// to the canonical forward direction via authorizedDirection(). setUpSpeed records a
// fresh direction after setting a proceed aspect.
if (newSignal == Signal.STOP) {
authorizedFrom = null
authorizedTo = null
}
// Fire property change event only if signal actually changed
if (oldSignal != newSignal) {
val evt = ContextChangeEvent("signal", oldSignal, newSignal)
Expand Down Expand Up @@ -104,6 +131,14 @@ sealed class DynamicRailSemaphore(
) {
checkPathSegments(from, to)
signal = forSpeed(allowedSpeed)
// Record the direction this proceed aspect was cleared for, so isAllowingFor /
// authorizedDirection can report proceed only for that direction (Issue #812). Only
// recorded for a proceed aspect — a STOP result (e.g. allowedSpeed == 0) authorizes
// nothing, and the signal setter has already cleared any prior direction.
if (signal.isAllowing()) {
authorizedFrom = from
authorizedTo = to
}
logger.debug {
"SEMAPHORE_SIGNAL_UPDATED: ${staticRef.getName()} signal changed to $signal " +
"(allowedSpeed=$allowedSpeed)"
Expand All @@ -112,6 +147,66 @@ sealed class DynamicRailSemaphore(

override fun allowedSpeed(): Double = signal.allowedSpeed()

/**
* The (from, to) segment pair this semaphore's current proceed aspect is authorized for, or
* `null to null` when the signal is [Signal.STOP] (nothing is authorized).
*
* Backed by the [authorizedFrom]/[authorizedTo] pair recorded by [setUpSpeed] from the
* reservation flow. When the signal was set by a direct `signal =` write that bypassed
* [setUpSpeed] (interlocking-facade `clearSignal`, actuator `setSignalAspect`) and no
* direction was recorded, defaults to the canonical **forward** direction
* (`anti([direction()])` → [direction()]) — which is the correct authorization for an
* entry/facing signal cleared outside the reservation flow.
*
* [ConstantSemaphore] (InOut `outSemaphore`, predzvěst/narážník) never calls [setUpSpeed];
* its stored direction stays `null`, so it too reports the static forward — matching the
* always-proceed-for-facing-direction semantics of a constant semaphore.
*
* Consumers: [DefaultNetworkPerceptionPort.toReading] (so the LLM dispatcher's
* `all_signal_aspects` learns the actual reserved direction) and the canvas renderers via
* [isAllowingFor]. The train's own next-separator perception ([separatorAspect]) does NOT
* use it — the train is the reservation holder and always sees proceed-when-lit.
*
* @since Issue #812 (direction-aware signal display)
*/
open fun authorizedDirection(): Pair<Cell.Segment?, Cell.Segment?> {
if (!signal.isAllowing()) return null to null
val d = staticRef.direction()
return if (authorizedFrom != null) authorizedFrom to authorizedTo else anti(d) to d
}

/**
* Returns true when this semaphore's current proceed aspect authorizes travel in the
* direction [from] → [to].
*
* Compares the query against the **stored reservation direction** ([authorizedDirection]),
* not the static orientation: a signal cleared for the reverse direction reports proceed for
* the reverse query and STOP for the forward query, and vice-versa. Returns false when the
* signal is [Signal.STOP]. When the direction is unknown (direct write, no [setUpSpeed]),
* [authorizedDirection] defaults to the canonical forward, so [isAllowingFor] authorizes the
* forward query only.
*
* Used by the canvas renderers (`AnimationStateCapture.captureSignalState`,
* `SimulationCellRenderer`) to paint a forward-facing semaphore STOP when its proceed aspect
* was cleared for the opposite direction — the Issue #812 perception-truthfulness fix. The
* train's own perception ([separatorAspect]) does not use this; it reads `signal` directly
* because the train is the reservation holder.
*
* @param from segment the approaching entity is travelling **from**
* @param to segment the approaching entity is travelling **to**
* @return true if [signal] is currently allowing AND [from]/[to] match the direction the
* aspect was actually cleared for (stored, defaulting to static forward)
* @since Issue #812 (direction-aware signal display)
*/
fun isAllowingFor(
from: Cell.Segment?,
to: Cell.Segment?
): Boolean {
if (!signal.isAllowing()) return false
val (af, at) = authorizedDirection()
return from == af && to == at
}

/**
* Validate that [from]/[to] are the two segments of this semaphore's block boundary,
* in either traversal order.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -160,13 +160,38 @@ class DefaultNetworkPerceptionPort(

override fun signalAspect(semaphoreName: String): SemaphoreReading? {
val sem = semaphoreByName[semaphoreName] ?: return null
return SemaphoreReading(name = sem.name, signal = sem.signal)
return sem.toReading()
}

override fun allSignalAspects(): List<SemaphoreReading> =
semaphoreCache.map { sem ->
SemaphoreReading(name = sem.name, signal = sem.signal)
}
override fun allSignalAspects(): List<SemaphoreReading> = semaphoreCache.map { sem -> sem.toReading() }

/**
* Converts a [DynamicRailSemaphore] to an immutable [SemaphoreReading] snapshot.
*
* Populates [SemaphoreReading.authorizedFrom] and [SemaphoreReading.authorizedTo] with the
* **actual reservation direction** the current proceed aspect was cleared for, via
* [DynamicRailSemaphore.authorizedDirection]. This is the segments the reservation flow
* recorded in [DynamicRailSemaphore.setUpSpeed], so consumers (in particular the LLM
* dispatcher's `all_signal_aspects` tool) learn the real authorized direction rather than the
* semaphore's static forward — which matters when a route is reserved for the reverse
* direction (the vyhybna.xml shunting loop runs both A→B and B→A).
*
* When the signal is [Signal.STOP] nothing is authorized; both direction fields are `null`.
* A proceed aspect set by a direct `signal =` write that bypassed [DynamicRailSemaphore.setUpSpeed]
* (entry/facing signals cleared outside the reservation flow) defaults to the canonical
* forward direction inside [DynamicRailSemaphore.authorizedDirection].
*
* @since Issue #812 (direction-aware signal display)
*/
private fun DynamicRailSemaphore.toReading(): SemaphoreReading {
val (from, to) = authorizedDirection()
return SemaphoreReading(
name = name,
signal = signal,
authorizedFrom = from?.name,
authorizedTo = to?.name
)
}

// ── NetworkPerceptionPort — block occupancies ─────────────────────────

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,35 @@ import cz.vutbr.fit.interlockSim.objects.cells.Signal
* - [Signal.S30]/[Signal.S40]/[Signal.S60]/[Signal.S80]/[Signal.S100] — proceed at stated max km/h
* - [Signal.FREE] — proceed at track-section maximum speed; path fully clear
*
* ## Direction fields
*
* When [signal] is an allowing aspect (not [Signal.STOP]), [authorizedFrom] and [authorizedTo]
* identify the segment names (e.g. `"F"`, `"A"`) the current proceed aspect is **actually
* authorized for** — the direction the reservation flow recorded when clearing the signal, not
* the semaphore's static forward. A train approaching this semaphore from [authorizedFrom]
* toward [authorizedTo] may proceed; any other approach direction should be treated as
* [Signal.STOP]. This matters when a route is reserved for the reverse direction (the
* vyhybna.xml shunting loop runs both A→B and B→A): a lit forward-facing semaphore cleared for
* the reverse direction reports the reverse segment pair here, and a forward-facing observer
* must treat it as STOP.
*
* Both fields are `null` when [signal] is [Signal.STOP] (nothing is authorized), and also
* `null` in tests that construct this class with two-argument syntax and do not supply direction
* information.
*
* @property name Semaphore name as configured in the railway XML (e.g. `"zA"`, `"doB1"`).
* @property signal Current signal indication.
* @property authorizedFrom Segment name from which travel is authorized (`Cell.Segment.name`),
* or `null` when the signal is not allowing.
* @property authorizedTo Segment name to which travel is authorized (`Cell.Segment.name`),
* or `null` when the signal is not allowing.
*
* @since Issue #541 (SP0.2 — Goal 10 sensor ports)
* @since Issue #812 (direction-aware signal display — [authorizedFrom]/[authorizedTo] added)
*/
data class SemaphoreReading(
val name: String,
val signal: Signal
val signal: Signal,
val authorizedFrom: String? = null,
val authorizedTo: String? = null
)
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,26 @@ internal fun separatorName(sep: OrientedPathSeparator?): String? =
}

/**
* Signal aspect perceived at [sep]: the semaphore's own aspect for a [DynamicRailSemaphore],
* or the [DynamicInOut.outSemaphore] aspect for an InOut endpoint. `null` when [sep] is `null`.
* Signal aspect perceived at [sep] — the train's **own next-separator** perception.
*
* [sep] is always a separator on the train's own reserved path ([Train.nextSemaphore] /
* [Train.secondSemaphoreAhead] both read `reservedSeparatorsAhead`), so the train is the
* reservation holder and is authorized to proceed whenever the signal is lit. This mapping
* therefore returns the raw `signal` (proceed-when-lit), with **no** direction guard.
*
* Direction-awareness (Issue #812) is deliberately NOT applied here: a guard keyed to the
* semaphore's static orientation would be a no-op (the query always matches the forward
* direction), and one keyed to the stored reservation direction would incorrectly report STOP to
* a reverse-travelling holder at its own semaphore. The display-truthfulness fix lives in the
* **global** views instead — the canvas (`AnimationStateCapture.captureSignalState`,
* `SimulationCellRenderer`) and the LLM dispatcher's `all_signal_aspects`
* ([cz.vutbr.fit.interlockSim.ports.DefaultNetworkPerceptionPort.toReading] via
* [cz.vutbr.fit.interlockSim.objects.cells.DynamicRailSemaphore.authorizedDirection]).
*
* `null` when [sep] is `null` (no reserved path ahead).
*
* @since Issue #552 (SP2a.1 — Goal 10 train perception)
* @since Issue #812 (direction-aware signal display — kept raw here; fix moved to canvas/port)
*/
internal fun separatorAspect(sep: OrientedPathSeparator?): Signal? =
when (sep) {
Expand Down
Loading
Loading