jmri_core.beans.fast_clock

FastClock — model-railroad fast clock.

Mirrors JMRI's Timebase. Operators use a fast clock to compress prototypical operating sessions (e.g. an 8-hour timetable runs in 2 real hours at rate=4). The bean holds:

  • rate — clock speedup multiplier (1.0 = real time, 4.0 = 4× faster);
  • running — whether the clock is advancing or held at a fixed time;
  • the displayed time at the last anchor (set/start/stop/rate-change) paired with the monotonic real-clock timestamp at that anchor.

Reads are computed on demand from those anchors, so there's no background task: time_seconds() returns base + (now - anchor) * rate while running, or base while stopped. This keeps the bean trivial to reason about and means a server restart that loses the in-memory state simply resets to midnight, stopped — a sensible default for alpha.

Notifications: a Signal fires whenever the bean is mutated, so listeners (web UI poll, future LogixNG triggers) can react.

  1"""``FastClock`` — model-railroad fast clock.
  2
  3Mirrors JMRI's ``Timebase``. Operators use a fast clock to compress
  4prototypical operating sessions (e.g. an 8-hour timetable runs in
  52 real hours at rate=4). The bean holds:
  6
  7* ``rate`` — clock speedup multiplier (1.0 = real time, 4.0 = 4× faster);
  8* ``running`` — whether the clock is advancing or held at a fixed time;
  9* the *displayed time* at the last anchor (set/start/stop/rate-change)
 10  paired with the monotonic real-clock timestamp at that anchor.
 11
 12Reads are computed on demand from those anchors, so there's no
 13background task: ``time_seconds()`` returns ``base + (now - anchor) *
 14rate`` while running, or ``base`` while stopped. This keeps the bean
 15trivial to reason about and means a server restart that loses the
 16in-memory state simply resets to midnight, stopped — a sensible
 17default for alpha.
 18
 19Notifications: a :class:`Signal` fires whenever the bean is mutated,
 20so listeners (web UI poll, future LogixNG triggers) can react.
 21"""
 22from __future__ import annotations
 23
 24import time
 25from dataclasses import dataclass, field
 26
 27from jmri_core.events import Signal
 28
 29SECONDS_PER_MINUTE = 60
 30SECONDS_PER_HOUR = 3600
 31SECONDS_PER_DAY = 86400
 32
 33# JMRI's Timebase clamps rate to ``[0.1, 100]``; we mirror that.
 34MIN_RATE = 0.1
 35MAX_RATE = 100.0
 36
 37
 38@dataclass
 39class FastClockChange:
 40    """Emitted whenever the fast clock is mutated. Listeners can poll
 41    :meth:`FastClock.time_seconds` directly if they need the current value."""
 42
 43    rate: float
 44    running: bool
 45    seconds_since_midnight: float
 46
 47
 48@dataclass(frozen=True, slots=True)
 49class HardwareClockState:
 50    """Snapshot of a command-station-driven fast clock.
 51
 52    Drivers whose command station has an authoritative fast clock (NCE,
 53    LocoNet's LNCP FastClock, etc.) return this from an async
 54    ``read_hardware_clock()`` method; the JSON ``TimeService`` prefers
 55    the hardware value over the software :class:`FastClock` bean when
 56    the connection exposes one.
 57
 58    * *hours* / *minutes* / *seconds* — the displayed 24-hour time
 59    * *rate* — the CS's fast-clock ratio (typically integer)
 60    * *running* — whether the CS clock is advancing
 61    * *twenty_four_hour* — the CS's display mode (12h with AM/PM vs 24h)
 62    """
 63
 64    hours: int
 65    minutes: int
 66    seconds: int
 67    rate: float
 68    running: bool
 69    twenty_four_hour: bool
 70
 71
 72@dataclass
 73class FastClock:
 74    """Software fast clock.
 75
 76    Default state: midnight, stopped, 1× rate. Real-world time source
 77    is ``time.monotonic`` so the bean is unaffected by wall-clock
 78    adjustments (NTP step, manual changes, DST).
 79    """
 80
 81    rate: float = 1.0
 82    running: bool = False
 83    _base_seconds: float = 0.0       # displayed fast-clock time at the anchor
 84    _real_anchor: float = field(default_factory=time.monotonic)
 85    state_changed: Signal[FastClockChange] = field(default_factory=Signal)
 86
 87    # ----- read --------------------------------------------------------
 88
 89    def time_seconds(self, *, now: float | None = None) -> float:
 90        """Current displayed time, in seconds since midnight (0..86399)."""
 91        if not self.running:
 92            return self._base_seconds % SECONDS_PER_DAY
 93        if now is None:
 94            now = time.monotonic()
 95        elapsed = (now - self._real_anchor) * self.rate
 96        return (self._base_seconds + elapsed) % SECONDS_PER_DAY
 97
 98    def time_hhmm(self, *, now: float | None = None) -> tuple[int, int]:
 99        """Convenience: ``(hours, minutes)`` ready for display."""
100        secs = int(self.time_seconds(now=now))
101        return (secs // SECONDS_PER_HOUR, (secs % SECONDS_PER_HOUR) // SECONDS_PER_MINUTE)
102
103    # ----- mutate ------------------------------------------------------
104
105    async def set_time(self, hours: int, minutes: int) -> None:
106        """Set the displayed time. Does not affect the running flag."""
107        if not 0 <= hours <= 23:
108            raise ValueError(f"hours must be 0..23, got {hours}")
109        if not 0 <= minutes <= 59:
110            raise ValueError(f"minutes must be 0..59, got {minutes}")
111        self._base_seconds = hours * SECONDS_PER_HOUR + minutes * SECONDS_PER_MINUTE
112        self._real_anchor = time.monotonic()
113        await self._notify()
114
115    async def set_rate(self, rate: float) -> None:
116        """Change the speedup multiplier without losing the displayed time."""
117        if not MIN_RATE <= rate <= MAX_RATE:
118            raise ValueError(f"rate must be {MIN_RATE}..{MAX_RATE}, got {rate}")
119        # Capture current displayed time first so the change doesn't jump.
120        current = self.time_seconds()
121        self._base_seconds = current
122        self._real_anchor = time.monotonic()
123        self.rate = rate
124        await self._notify()
125
126    async def start(self) -> None:
127        """Begin advancing the clock. Idempotent."""
128        if self.running:
129            return
130        # Keep the displayed time stable across the start.
131        self._real_anchor = time.monotonic()
132        self.running = True
133        await self._notify()
134
135    async def stop(self) -> None:
136        """Hold the clock at the current displayed time. Idempotent."""
137        if not self.running:
138            return
139        self._base_seconds = self.time_seconds()
140        self.running = False
141        await self._notify()
142
143    # ----- internal ----------------------------------------------------
144
145    async def _notify(self) -> None:
146        await self.state_changed.emit(FastClockChange(
147            rate=self.rate,
148            running=self.running,
149            seconds_since_midnight=self.time_seconds(),
150        ))
SECONDS_PER_MINUTE = 60
SECONDS_PER_HOUR = 3600
SECONDS_PER_DAY = 86400
MIN_RATE = 0.1
MAX_RATE = 100.0
@dataclass
class FastClockChange:
39@dataclass
40class FastClockChange:
41    """Emitted whenever the fast clock is mutated. Listeners can poll
42    :meth:`FastClock.time_seconds` directly if they need the current value."""
43
44    rate: float
45    running: bool
46    seconds_since_midnight: float

Emitted whenever the fast clock is mutated. Listeners can poll FastClock.time_seconds() directly if they need the current value.

FastClockChange(rate: float, running: bool, seconds_since_midnight: float)
rate: float
running: bool
seconds_since_midnight: float
@dataclass(frozen=True, slots=True)
class HardwareClockState:
49@dataclass(frozen=True, slots=True)
50class HardwareClockState:
51    """Snapshot of a command-station-driven fast clock.
52
53    Drivers whose command station has an authoritative fast clock (NCE,
54    LocoNet's LNCP FastClock, etc.) return this from an async
55    ``read_hardware_clock()`` method; the JSON ``TimeService`` prefers
56    the hardware value over the software :class:`FastClock` bean when
57    the connection exposes one.
58
59    * *hours* / *minutes* / *seconds* — the displayed 24-hour time
60    * *rate* — the CS's fast-clock ratio (typically integer)
61    * *running* — whether the CS clock is advancing
62    * *twenty_four_hour* — the CS's display mode (12h with AM/PM vs 24h)
63    """
64
65    hours: int
66    minutes: int
67    seconds: int
68    rate: float
69    running: bool
70    twenty_four_hour: bool

Snapshot of a command-station-driven fast clock.

Drivers whose command station has an authoritative fast clock (NCE, LocoNet's LNCP FastClock, etc.) return this from an async read_hardware_clock() method; the JSON TimeService prefers the hardware value over the software FastClock bean when the connection exposes one.

  • hours / minutes / seconds — the displayed 24-hour time
  • rate — the CS's fast-clock ratio (typically integer)
  • running — whether the CS clock is advancing
  • twenty_four_hour — the CS's display mode (12h with AM/PM vs 24h)
HardwareClockState( hours: int, minutes: int, seconds: int, rate: float, running: bool, twenty_four_hour: bool)
hours: int
minutes: int
seconds: int
rate: float
running: bool
twenty_four_hour: bool
@dataclass
class FastClock:
 73@dataclass
 74class FastClock:
 75    """Software fast clock.
 76
 77    Default state: midnight, stopped, 1× rate. Real-world time source
 78    is ``time.monotonic`` so the bean is unaffected by wall-clock
 79    adjustments (NTP step, manual changes, DST).
 80    """
 81
 82    rate: float = 1.0
 83    running: bool = False
 84    _base_seconds: float = 0.0       # displayed fast-clock time at the anchor
 85    _real_anchor: float = field(default_factory=time.monotonic)
 86    state_changed: Signal[FastClockChange] = field(default_factory=Signal)
 87
 88    # ----- read --------------------------------------------------------
 89
 90    def time_seconds(self, *, now: float | None = None) -> float:
 91        """Current displayed time, in seconds since midnight (0..86399)."""
 92        if not self.running:
 93            return self._base_seconds % SECONDS_PER_DAY
 94        if now is None:
 95            now = time.monotonic()
 96        elapsed = (now - self._real_anchor) * self.rate
 97        return (self._base_seconds + elapsed) % SECONDS_PER_DAY
 98
 99    def time_hhmm(self, *, now: float | None = None) -> tuple[int, int]:
100        """Convenience: ``(hours, minutes)`` ready for display."""
101        secs = int(self.time_seconds(now=now))
102        return (secs // SECONDS_PER_HOUR, (secs % SECONDS_PER_HOUR) // SECONDS_PER_MINUTE)
103
104    # ----- mutate ------------------------------------------------------
105
106    async def set_time(self, hours: int, minutes: int) -> None:
107        """Set the displayed time. Does not affect the running flag."""
108        if not 0 <= hours <= 23:
109            raise ValueError(f"hours must be 0..23, got {hours}")
110        if not 0 <= minutes <= 59:
111            raise ValueError(f"minutes must be 0..59, got {minutes}")
112        self._base_seconds = hours * SECONDS_PER_HOUR + minutes * SECONDS_PER_MINUTE
113        self._real_anchor = time.monotonic()
114        await self._notify()
115
116    async def set_rate(self, rate: float) -> None:
117        """Change the speedup multiplier without losing the displayed time."""
118        if not MIN_RATE <= rate <= MAX_RATE:
119            raise ValueError(f"rate must be {MIN_RATE}..{MAX_RATE}, got {rate}")
120        # Capture current displayed time first so the change doesn't jump.
121        current = self.time_seconds()
122        self._base_seconds = current
123        self._real_anchor = time.monotonic()
124        self.rate = rate
125        await self._notify()
126
127    async def start(self) -> None:
128        """Begin advancing the clock. Idempotent."""
129        if self.running:
130            return
131        # Keep the displayed time stable across the start.
132        self._real_anchor = time.monotonic()
133        self.running = True
134        await self._notify()
135
136    async def stop(self) -> None:
137        """Hold the clock at the current displayed time. Idempotent."""
138        if not self.running:
139            return
140        self._base_seconds = self.time_seconds()
141        self.running = False
142        await self._notify()
143
144    # ----- internal ----------------------------------------------------
145
146    async def _notify(self) -> None:
147        await self.state_changed.emit(FastClockChange(
148            rate=self.rate,
149            running=self.running,
150            seconds_since_midnight=self.time_seconds(),
151        ))

Software fast clock.

Default state: midnight, stopped, 1× rate. Real-world time source is time.monotonic so the bean is unaffected by wall-clock adjustments (NTP step, manual changes, DST).

FastClock( rate: float = 1.0, running: bool = False, _base_seconds: float = 0.0, _real_anchor: float = <factory>, state_changed: jmri_core.Signal[FastClockChange] = <factory>)
rate: float = 1.0
running: bool = False
def time_seconds(self, *, now: float | None = None) -> float:
90    def time_seconds(self, *, now: float | None = None) -> float:
91        """Current displayed time, in seconds since midnight (0..86399)."""
92        if not self.running:
93            return self._base_seconds % SECONDS_PER_DAY
94        if now is None:
95            now = time.monotonic()
96        elapsed = (now - self._real_anchor) * self.rate
97        return (self._base_seconds + elapsed) % SECONDS_PER_DAY

Current displayed time, in seconds since midnight (0..86399).

def time_hhmm(self, *, now: float | None = None) -> tuple[int, int]:
 99    def time_hhmm(self, *, now: float | None = None) -> tuple[int, int]:
100        """Convenience: ``(hours, minutes)`` ready for display."""
101        secs = int(self.time_seconds(now=now))
102        return (secs // SECONDS_PER_HOUR, (secs % SECONDS_PER_HOUR) // SECONDS_PER_MINUTE)

Convenience: (hours, minutes) ready for display.

async def set_time(self, hours: int, minutes: int) -> None:
106    async def set_time(self, hours: int, minutes: int) -> None:
107        """Set the displayed time. Does not affect the running flag."""
108        if not 0 <= hours <= 23:
109            raise ValueError(f"hours must be 0..23, got {hours}")
110        if not 0 <= minutes <= 59:
111            raise ValueError(f"minutes must be 0..59, got {minutes}")
112        self._base_seconds = hours * SECONDS_PER_HOUR + minutes * SECONDS_PER_MINUTE
113        self._real_anchor = time.monotonic()
114        await self._notify()

Set the displayed time. Does not affect the running flag.

async def set_rate(self, rate: float) -> None:
116    async def set_rate(self, rate: float) -> None:
117        """Change the speedup multiplier without losing the displayed time."""
118        if not MIN_RATE <= rate <= MAX_RATE:
119            raise ValueError(f"rate must be {MIN_RATE}..{MAX_RATE}, got {rate}")
120        # Capture current displayed time first so the change doesn't jump.
121        current = self.time_seconds()
122        self._base_seconds = current
123        self._real_anchor = time.monotonic()
124        self.rate = rate
125        await self._notify()

Change the speedup multiplier without losing the displayed time.

async def start(self) -> None:
127    async def start(self) -> None:
128        """Begin advancing the clock. Idempotent."""
129        if self.running:
130            return
131        # Keep the displayed time stable across the start.
132        self._real_anchor = time.monotonic()
133        self.running = True
134        await self._notify()

Begin advancing the clock. Idempotent.

async def stop(self) -> None:
136    async def stop(self) -> None:
137        """Hold the clock at the current displayed time. Idempotent."""
138        if not self.running:
139            return
140        self._base_seconds = self.time_seconds()
141        self.running = False
142        await self._notify()

Hold the clock at the current displayed time. Idempotent.