Skip to content

Added ability to have arbitrary state transitions through psuedo coding - #69

Open
swissskimmilk wants to merge 23 commits into
mainfrom
worktree-daq-state-descriptions
Open

swissskimmilk wants to merge 23 commits into
mainfrom
worktree-daq-state-descriptions

Conversation

@swissskimmilk

Copy link
Copy Markdown
Contributor

No description provided.

… valve polarities the stand actually wires

The load-cell half is a prerequisite for exercising the LC tare: the profile
declared [boards.lc_board] and [boards.lc_board_2] but mapped no roles onto
them, so digital-twin surfaced no load-cell channels at all and there was
nothing to tare. Adds Fuel Scale on lc_board and LOX Scale on lc_board_2,
both cubic.

Alongside that, the profile had drifted from the stand:

- LOX Upstream PT was missing entirely -- added on pt_board channel 5 with a
  cubic model and a pressure bar.
- Dome CTRL and Ox Upstream were declared NO but are NC valves; fuel vent and
  lox vent were declared NC but are NO. Every one of those four was inverted,
  so the state machine drove them backwards.
- fuel vent and lox vent had no rows in the actuator tables, leaving them
  unowned by the state machine. Added, with Vent opening both and Fuel Tank
  Vent opening only the fuel side.
- Ox Upstream now opens in LOX Press, which is the point of that state.
- Dome Vent, High Press Vent, Fuel Tank Vent and Ready could not reach Press
  Standby, so the only way out of a vent state was back through Vent.
…he selection

Every edit the editor made went through onSource(), i.e. React writing the
textarea's value. That replaces text from OUTSIDE the browser's editing pipeline:
no undo entry is recorded and most engines discard the stack they had, so Ctrl+Z
would not undo an autocomplete or a Tab at all. Edits now go through
execCommand('insertText'), deprecated and still the only way to join a textarea's
undo history; it also fires a normal input event, so React state keeps itself in
step. A false return falls back to the old write -- an edit that cannot be undone
beats an edit that does not happen.

Tab with a selection was worse than an undo problem. It built the next source as
slice(0, start) + "    " + slice(end), which drops slice(start, end): indenting a
highlighted block DELETED it, unrecoverably. Tab now shifts every line the
selection touches and Shift+Tab outdents. A selection dragged to the start of the
next line does not indent that line, blank lines are not padded, and outdent stops
at column zero.

Also, from operating it:
- Enter carries the current line's indentation onto the new line.
- Backspace inside the leading indent takes a whole level, snapping to the
  previous boundary (8 -> 4, 5 -> 4, 3 -> 0) instead of one space at a time.
- Tab is the only accept key, so Enter always means newline and the Shift+Enter
  escape hatch -- which existed solely because the popup swallowed Enter -- is
  gone.
- The popup opens with nothing typed, because it is how you learn what the valves
  are called, but with NO row selected: with no prefix every name matches equally.
  Tab is only claimed once a row is actually selected, by typing or with the
  arrows, so an open reference list can never turn an indent into an insertion of
  whatever sorted first.

The new tests cover the text arithmetic, including a brute-force case asserting no
selection can lose text. What they cannot cover is the undo integration itself:
jsdom does not implement execCommand, so that part is verified by hand.
…ts script

The first scripted state on this profile: id 15, 30 s ceiling, returning to Press
Standby on both a clean finish and a timeout. Its script cycles DOME_CTRL open and
shut on a one-second beat for ten seconds -- enough to watch a script drive real
hardware without committing the stand to anything.

Its actuator, delay and transition columns come with it, so the state is complete
in all three tables rather than existing in [[states]] alone; the sequencer
resolves those by name and silently drops a state that is missing a column.

config/scripts/ joins the ignore list beside config/config.toml and the deployed
CSVs. It is the same kind of artifact -- written out of the active profile's
scripts/ dir by the same deploy -- and the tracked source is the profile copy.
…scriptions

# Conflicts:
#	daq-server/config/profiles/digital-twin/state_machine_actuator_delays.csv
#	daq-server/config/profiles/digital-twin/state_machine_actuators.csv
#	daq-server/config/profiles/digital-twin/state_transitions.csv
#	daq-server/test/test_integration.sh
…actually did

Four failures found operating the stand today, all of the same shape: the
sequencer knew exactly what had happened and told nobody.

PressureFeed went blind for the life of the process. subscribe_tables() reports
only whether the SOCKET WRITE succeeded -- it sends request id 0 and never reads a
reply, and the C++ client has no ErrorResponse handling at all -- so a refused
subscription is silent. On a cold start it is refused routinely: measured
2026-09-16, PressureFeed subscribed at 16:45:08.348 and calibration_service
registered the calibrated VTables at 16:45:08.494, 146 ms later. The db answered
"invalid msg id", nothing retried, and every dynamic state reading a pressure was
refused entry with "has produced no reading yet" for the whole session -- decided
by 150 ms of startup ordering. Tables that have never delivered are now re-asked
for every second. Only those: a table that HAS delivered is demonstrably
subscribed, and re-sending it would spawn a second stream task and double its rate.

ScriptRunner emitted "start" and "stopped" and nothing else, so a loop that never
ran and one that ran and timed out were indistinguishable -- reconstructing a
single run took four journal queries across two services. It now reports the entry
decision with the operands that decided it, numbers each pass, and every exit
carries how far it got:

  Dyn State: while false at entry (117.7 < 82.1) -- body never runs, 0 iterations
  Dyn State: timeout after 4 iteration(s), condition 61.0 < 82.1 -- going to ...

The condition text is a probe: it re-evaluates both sides and RESTORES `error`, so
a failing read while logging cannot invent a run-ending error the real evaluation
never hit. Safe to repeat because expressions here have no side effects.

Re-entering a scripted state now says so. On 2026-09-16 the backend sent
TRANSITION:Dynamic Test twelve times in 216 ms; each restarted the script from
line 1 and fired a fresh OPEN -- twelve valve opens in a fifth of a second, with
the discarded runs leaving no trace.

Refusal reasons reached the caller flattened. A script-load failure replied "state
script rejected at load" while the specific fault sat unused in refused_states_,
and the sensor gate replied "<role> has no fresh calibrated reading" for all four
of NoReading / Stale / Uncalibrated / NotSubscribed -- three different things to go
and fix, and only the log knew which. Both now send what the log sends.

publishState() also clears states the sensor gate would refuse RIGHT NOW, not just
load refusals, recomputed every publish so a button greys and un-greys on its own;
scriptStatusReport() reports those as BLOCKED beside the fixed REFUSED ones.

Finally, a script waits 100 ms before its first statement. Both it and the state's
actuator column go to the same board as UDP, but the boards poll one datagram per
loop() and hotfire_config.h sets LOOP_DELAY_MS = 10, so the column's three
retransmits take ~30 ms to drain and the script's first command queued behind them
-- measured at 4 ms between the two without this. The wait is spent on the
script's own thread, never in doTransitionTo: that runs on the command worker, and
sleeping there would put 100 ms into the abort budget test_abort_ordering pins at
200 ms. A new test aborts 30 ms into the lead-in to prove it does not.
…, and stop discarding refusals

Two independent mechanisms, both severed one step short of the operator.

THE RACE. A session would occasionally start with no data in the GUI. Captured
live 2026-09-16: boards -> db healthy (DB: 11293ok/0drop), calibration_service
registered and publishing, raw entities reaching the browser, and ZERO calibrated
entities for the whole session.

The wire request id is one byte -- elodin-client writes `requestId & 0xff` at
offset 7 -- so 1..255 is the entire space and cannot be widened. registerVTables
sent 4485 subscriptions per pass while rotating ids over that space, so
pendingSubscriptionReqIds was overwritten ~17x and, after a pass, described only
the LAST 255 pairs sent. Reproduced exactly: [0x20,0x11] (PT1_Cal.CH1) went out
with reqId 2, and after the pass reqId 2 resolved to [0x65,0xfe], a self-test
table. So every rejection was attributed to the wrong pair -- the genuinely
refused table stayed marked subscribed and was NEVER retried, while an innocent
delivering table was un-marked and re-subscribed. [0x46,0x00] was re-sent 849
times in four hours, spawning a db stream task each time, and the loop never
converged: 254 new every 5 s and 1.13M refusal lines in the journal.

A pass is now capped at the id space with ids pinned to their pairs for its whole
duration, so an id in flight names exactly one pair; the remainder waits for the
next pass rather than reusing a live id. The list also shrank 4485 -> 279: the
blanket 1..255 x 17 sweep for heartbeat/self-test now runs over CONFIGURED board
ids only, because daq_bridge registers those two families only for boards in the
config, and the dev fallbacks no longer run alongside a good config (they were
inventing LC2 CH2/CH6 where the config declares active_connectors = [1]). A pair
the db is delivering can no longer be un-marked at all, which enforces the
duplicate-delivery invariant structurally. Retries back off and park.

elodin-relay never listened for dbError at all, so every refusal there was silent
and permanent -- the same failure the thin backend already had a handler for.

THE SILENCE. The backend has always sent MessageType.ERROR for every rejection the
sequencer reports, and nothing on the dashboard subscribed, so websocket.ts
dropped the payload. refusalText() strips the wire "ERR:" prefix so a notification
reads "State transition failed: GN2 High has no fresh calibrated reading" rather
than leaking the prefix the caller branches on. It lives in its own module because
server.ts opens sockets at import, so a function defined inside it can only be
tested by copying it -- and a copy passes forever while the original drifts.

allowedBitmask is no longer discarded. The sequencer computes it and masks out
everything it would refuse, explicitly so the GUI can grey those states; the
backend decoded it and dropped it. StateUpdate now carries it, plus per-state
reasons parsed from the SCRIPTS report -- a channel that has existed since dynamic
states shipped with no consumer anywhere.
…reys out

Pressing Press Standby -> Dynamic Test did nothing on 2026-09-16. No error, no
toast, no notification -- the sequencer had refused it for a good reason ("GN2
High has produced no reading yet") and that sentence went to stderr and nowhere
else. Finding it took a journal dive.

The backend had been reporting it the whole time. It sends MessageType.ERROR for
every rejection -- refused transitions, actuator commands, extend-fire, invalid
hold durations, controls locked -- and nothing on the dashboard subscribed, so
websocket.ts looked up listeners for 'error', found none, and silently dropped the
payload. One handler in GlobalStateSubscriber now feeds those into the existing
notification store, which covers every one of those paths without touching a
single call site. One-shot, not ongoing: a rejected click is an event that
happened, not a condition that persists, so it carries no key and nothing needs to
clear it.

States the sequencer will not accept now render greyed with the reason on hover,
from the allowedBitmask it has always published and the backend now forwards. An
absent mask means "no opinion", never "nothing allowed" -- a client that connects
before the sequencer's first publish must not find every button dead. The mask
covers the live sensor gate too, so a greyed state un-greys on its own when the
feed returns.

A greyed node still explains itself when clicked rather than being inert, which is
the whole point: being unable to press it and being told nothing are the same
failure from the operator's side.

The window.alert for a CSV-invalid transition goes through the same path. A modal
stole focus mid-procedure and read nothing like the refusals the sequencer sends
for the same class of mistake, so one rejection looked like two different failures
depending on which side caught it. Its existing test now pins the notification
instead of the alert.
…-press supply

Replaces the Dome CTRL blink with something that exercises the parts of the
scripting language that matter on a stand: a live sensor read, arithmetic on it,
and a loop that ends on a measured condition rather than a stopwatch.

  start = pressure(HIGH_PRESS)
  while pressure(GN2_HIGH) < start * 0.7:
      open_valve(HIGH_PRESS_CTRL)
      delay(0.4)
      close_valve(HIGH_PRESS_CTRL)
      delay(2.5)

The supply is latched once rather than read in the condition. pressure() is live
-- re-evaluated on every pass -- so leaving it inline would let the target fall as
the supply droops each time the valve cracks, and the loop could satisfy itself by
lowering the bar instead of raising the tank.

Timeout drops 30 s -> 10 s. Both targets stay Press Standby, whose column is
all-CLOSE: the landing state's column is the ONLY thing that closes a valve a
script left open, and load-time validation does not check that it is safe.
Whitespace only — `./format.sh` output taken verbatim, produced with clang-format
19.1.7, the version daq-server-ci.yml pins. Build clean and ctest 20/20 after it.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant