The time seam

longeron.widgets.time makes time a shared, linkable state across every time-aware view. One Clock holds the playhead. link_time subscribes the replay player, the mission globe, and the scrubber to it. A scrub in one view then scrubs them all, with no echo. The time-seam design states the full contract.

Clock, Timebase, and link_time need no extras. The scrubber widget needs the replay extra (pip install "longeron[replay]").

The pieces

  • Clock holds the shared state: t, playing, rate, and span. It owns no timer. Views animate; the clock fans state out.

  • Timebase aligns one recording with its optional mission track, so every view plays the same execution.

  • link_time wires views to the clock and returns an unlink() disposer, exactly like link_selection.

  • time_scrubber builds the transport bar: play/pause, rate, the time axis with event ticks and phase bands, and a telemetry readout.

Loss tolerance

Comm messages can be dropped under load, and the protocol has no retransmit. The seam heals: kernel pushes carry generation stamps, front-end reports acknowledge them (stale machine reports are rejected and answered with a full re-push; user actions outrank a raced push), and a trailing-edge verify re-states kernel truth after each traffic burst. The kernel clock is the source of truth; a dropped report heals by visible reversion, never a silent split. The time-seam design states the protocol; longeron.widgets._seam implements it.

Step-only traces

A pure event cascade records in step mode, and steps are not seconds. The seam refuses the globe binding for such a trace by default. seconds_per_step opts in: a scalar, or a per-step sequence/mapping when steps take unequal durations. Durations the caller states count as first-class. The scrubber labels only the synthesized gaps, with a striped band and an explicit (x10 s) readout tag.

The time seam: one clock, many views (the temporal selection seam).

The diagram replay player, the Cesium mission replay, and any future time-aware view each expose a playhead as a widget trait. This module makes “when are we” one shared state with many subscribers, exactly as the selection seam made “what is selected” one state (see The time seam: one clock across the views (design), the adopted contract). Three toolkit pieces live here:

  • Clock – the shared playhead. A small kernel-side object with no front-end, no timer, and no dependencies: views animate, the clock holds state and fans it out. Seeks clamp into the span and coalesce within one JSON quantum (1e-3, the rounding longeron.replay.Timeline.to_json() established), so every write settles at its first fixpoint – the selection seam’s no-echo discipline, restated for floats.

  • Timebase – one recording, many views. It aligns a Timeline with its optional MissionTrack binding, so every linked view plays the SAME recording. The axis is the timeline’s own: sim seconds, or the step index in step mode. Step-only traces have no time axis, so a step-mode timebase REFUSES a track unless seconds_per_step states one – a scalar, or a per-step sequence/mapping when steps take unequal durations. Durations the caller states (for example from the model’s own time triggers or occurrence durations) count as first-class; only the synthesized gaps are labeled synthetic (Timebase.synthetic_intervals()).

  • link_time() – the temporal link_selection. It attaches each view through a small adapter that knows the view’s time trait and its axis mapping, wires playing/rate traits where the view has them, and returns an unlink() disposer. A view holds ONE time link: linking it again unbinds the previous adapter first (the longeron.analysis.link.bind_config_view() handle pattern).

time_scrubber() is the fourth piece and the first new subscriber: a standalone transport bar (play/pause, rate, the time axis with the recording’s event ticks and mission phase bands, a telemetry readout) that subscribes to the clock like any other view. It renders alone under a dashboard, beside views that have no transport of their own.

Non-fighting rule (the Cesium bridge, phase 2 of the design): while playing, every animating view integrates t locally at the shared rate and reconciles against the clock at ~4 Hz; a follower snaps only when its local time drifts past a bounded tolerance (0.25 axis units, scaled by the rate), and on pause every view converges exactly. The scrubber’s front-end and the mission viewer’s Cesium bridge (longeron.widgets.mission3d) both implement it.

The seam is LOSS-TOLERANT (longeron.widgets._seam): comm messages get dropped under load and in-flight reports race kernel seeks, so every kernel push carries a generation stamp, front-end reports acknowledge the last stamp they saw, and the link REJECTS a stale report (answering with an idempotent full-state re-push) instead of letting it re-seek the clock. The kernel clock is the source of truth; front-ends reconcile to it.

Everything but the scrubber’s front-end is pure kernel code: headless tests drive clock.seek / clock.play and assert trait fan-out, mirroring how the selection seam is tested without a browser. The playhead deliberately does NOT join the selection seam (decision Q7 of the design), and the clock owns no wall-clock timer: a headless play() moves t only when a front-end or a test advances it.

Requires the replay extra (anywidget) for the scrubber widget only; Clock, Timebase, and link_time need nothing.

class longeron.widgets.time.Clock(span=(0.0, 0.0), *, step_mode=False, rate=1.0, t=None)[source]

Bases: object

The shared playhead for one linked group of views.

t is the playhead in axis units (sim seconds, or the step index when step_mode), playing says someone is animating, rate is axis units per wall second (1.0 = real time; negative plays backwards, as Cesium’s shuttle ring does), and span is the (t0, t1) window seeks clamp into. The clock owns no wall-clock timer: views animate, the clock holds state and fans it out through plain callbacks, so the core package stays dependency-free.

The no-echo discipline is the selection seam’s, restated for floats: a seek() within 1e-3 of the current t does not fan out, playing/rate coalesce on equality, and every subscriber applies the same rule before writing back, so each write settles at its first fixpoint. Linking is explicit and scoped (link_time()); two dashboards in one notebook keep two clocks.

property span: tuple[float, float]

The (t0, t1) window; seeks clamp into it.

property step_mode: bool

True when the axis is a step index, not seconds.

property t: float

The playhead, in axis units. Assigning delegates to seek.

property playing: bool

True while some view animates. Assigning plays or pauses.

property rate: float

Axis units per wall second. Assigning delegates to set_rate.

seek(t)[source]

Move the playhead: clamp into the span, coalesce, fan out.

Return type:

None

play()[source]

Mark the group playing (idempotent; fans out on the flip).

Return type:

None

pause()[source]

Mark the group paused (idempotent; fans out on the flip).

Return type:

None

set_rate(rate)[source]

Change the playback rate (coalesces equal values).

Return type:

None

observe(callback)[source]

Subscribe to changes; returns the matching unobserve.

callback receives a traitlets-shaped change dict: {"name", "old", "new", "owner"} with name one of "t", "playing", "rate".

Return type:

Callable[[], None]

class longeron.widgets.time.Timebase(timeline, track=None, seconds_per_step=None)[source]

Bases: object

One recording, many views: a trace plus its optional mission binding, aligned on one axis.

timeline is the recorded truth (longeron.replay); track is the optional globe binding, built FROM that timeline (longeron.analysis.mission3d.track_from_timeline()), so the two views replay one execution. The shared axis is the timeline’s own: sim seconds for a timed trace (track seconds are then the same numbers, the 1:1 mapping the design verified), or the step index in step mode.

Step-only traces have no time axis, so a step-mode timebase refuses a track unless seconds_per_step states one (a scalar, or a per-step sequence/mapping – see step_seconds()); the same value must then have built the track. Stated durations count as first-class; only the synthesized gaps show up in synthetic_intervals(), which is what the scrubber labels.

property step_mode: bool

True when the shared axis is the step index.

property span: tuple[float, float]

The shared axis window: (t_start, t_end), or (0, n_steps - 1) in step mode.

seconds_at(t)[source]

Track seconds at axis position t (identity when timed).

Return type:

float

axis_at(s)[source]

Axis position at track second s (the seconds_at inverse).

Return type:

float

events_at(t0, t1)[source]

The fired transitions inside [t0, t1] (axis units, closed).

Return type:

list[FiredTransition]

env_at(t)[source]

The telemetry row at t: the last scalar-env snapshot at or before it (step semantics, like the tracks); {} before the first.

Return type:

dict[str, Any]

phase_at(t)[source]

The (phase, qname) of the track segment under t (axis units); None without a track or outside every segment. The final segment includes its end instant.

Return type:

tuple[str, str] | None

synthetic_intervals()[source]

The axis intervals whose seconds were synthesized (merged runs of unstated step durations); [] for timed traces and for step-only timebases that state no seconds axis at all.

Return type:

list[tuple[float, float]]

Wire time-aware views to one clock (the temporal link_selection).

Each view is any widget with a time trait on the clock’s axis: the replay player, the mission viewer, the scrubber, or a future subscriber. The adapter observes the trait into Clock.seek() and fans clock changes back, both sides under the 1e-3 coalescing tolerance, so scrubbing one view scrubs them all and no write echoes. Views that also carry playing and rate traits (the scrubber; the mission viewer’s Cesium bridge) get those wired the same way, and the clock’s current state fans out to every view at link time.

The one non-identity mapping is the globe under a step-mode clock: steps are not seconds, so the binding is REFUSED unless seconds_per_step opts in (a scalar, or a per-step sequence/mapping matching the track’s own build – see step_seconds()); the adapter then maps step positions through the stated durations, scales rate to track seconds per wall second, and sizes the viewer’s drift tolerance to match.

A view holds ONE time link; linking it again replaces the previous adapter. Returns an idempotent unlink() that detaches every adapter, mirroring link_selection.

Return type:

Callable[[], None]

longeron.widgets.time.step_seconds(n_steps, seconds_per_step)[source]

Map a step-mode axis onto seconds.

A recording with n_steps steps has n_steps - 1 intervals (interval i runs from step i to step i + 1). seconds_per_step states their durations:

  • a scalar – every interval lasts that many seconds, and every interval counts as SYNTHETIC (the tool fabricated the number);

  • a sequence – interval i lasts seconds_per_step[i]; all stated, none synthetic (at least n_steps - 1 entries; extras are ignored);

  • a mapping {interval: seconds} – stated where present; gaps synthesize the 10-second default and count as synthetic.

Returns (seconds, stated): seconds[k] is the second at step k (seconds[0] == 0.0, one entry per step), and stated[i] tells whether interval i’s duration was stated by the caller. Every duration must be positive; a mapping key outside the interval range is refused loudly.

Return type:

tuple[list[float], list[bool]]

longeron.widgets.time.time_scrubber(timebase, *, width_px=760)[source]

The standalone transport bar for a recording.

A play/pause button, a rate select, a slim slider over the timebase’s span with tick marks at the recorded transition instants (a density band above ~100 events), the mission phase bands where a track binding exists, a readout clock, and the scalar-telemetry line that follows the playhead. Step-mode recordings read step k / N; where a seconds axis was stated per step the stated seconds show plainly and the synthesized segments carry an explicit (xN s) tag plus a striped band – a fabricated second is always displayed as fabricated.

The scrubber is one subscriber among equals: pass it to link_time() beside the replay player and the mission viewer. While playing it animates locally at the shared rate and syncs its time trait at ~4 Hz, exactly like its peers.

Needs the replay extra (anywidget).

Return type:

AnyWidget