STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
ScriptRunner.cpp
Go to the documentation of this file.
2
3#include <cmath>
4#include <iomanip>
5#include <iostream>
6#include <map>
7#include <sstream>
8#include <string>
9
10namespace sequencer {
11
12using fsw::script::Expr;
14using fsw::script::Op;
15using fsw::script::ScriptProgram;
16using fsw::script::Stmt;
18
19namespace {
20
21/** Total loop iterations across the whole script. The wall deadline is the real bound; this
22 * catches a tight loop that would otherwise spend the whole budget spinning. */
23constexpr uint64_t kMaxIterations = 1000000;
24
25/** Shortest delay actually honoured. A script cannot express delay(0) — the parser refuses it —
26 * but an expression can still evaluate to something tiny, and a sub-millisecond wait in a loop is
27 * a spin with extra steps. */
28/**
29 * How long the script waits before its first statement, so the state's own actuator column has
30 * landed on the boards first.
31 *
32 * Both go out as UDP to the same board, but the boards poll one datagram per loop() and
33 * hotfire_config.h sets LOOP_DELAY_MS = 10 — so the column's three retransmits alone take ~30 ms
34 * to drain, and the script's first command queues behind them. Without this the valve a script
35 * opens on its first line could be commanded up to ~40 ms late, or land while the column's own
36 * CLOSE for that valve was still being consumed, which read on the stand as "the valve did not
37 * open for as long as I asked for".
38 *
39 * Spent on the SCRIPT'S thread, deliberately, not in doTransitionTo: that runs on the command
40 * worker, and sleeping there would delay every queued command behind it — an abort included,
41 * against a budget test_abort_ordering pins at 200 ms. Here it costs the worker nothing, and
42 * stop() still preempts it in well under a millisecond because sleep() waits on the condition
43 * variable with the stop flag as its predicate.
44 *
45 * Comes out of the script's own timeout budget rather than extending it, the same way the rest of
46 * the transition's overhead does — script_timeout_ms stays the wall-clock ceiling on the state.
47 */
48constexpr double kStartLeadInSeconds = 0.100;
49
50constexpr double kMinDelaySeconds = 0.010;
51
52/** How a statement ended. */
53enum class Flow {
55 /** transition_to ran, or something failed. Unwind everything; the thread is done. */
57};
58
59} // namespace
60
64
66 if (!active_.load())
67 return 0.0;
68 std::lock_guard<std::mutex> lk(mtx_);
69 return std::chrono::duration<double>(Clock::now() - started_at_).count();
70}
71
73 // Re-entering a scripted state discards a run in flight and restarts from line 1 — including
74 // its valve commands. On 2026-09-16 the backend sent TRANSITION:Dynamic Test twelve times in
75 // 216 ms and each one re-entered, so the valve was commanded open twelve times in a fifth of
76 // a second. That is worth a line in the journal: the discarded run leaves no other trace.
77 if (active_.load()) {
78 double ran_for = 0.0;
79 {
80 std::lock_guard<std::mutex> lk(mtx_);
81 ran_for = std::chrono::duration<double>(Clock::now() - started_at_).count();
82 }
83 std::cerr << "[ScriptRunner] " << ds.name << ": re-entered while still running — "
84 << "discarding a run " << std::fixed << std::setprecision(2) << ran_for
85 << " s in and restarting from the top" << std::endl;
86 }
87
88 stop(); // supersede anything still running; also reaps its thread
89
90 {
91 std::lock_guard<std::mutex> lk(mtx_);
92 started_at_ = Clock::now();
93 }
94 stop_ = false;
95 active_ = true;
96 // By value: the program cannot be mutated out from under a run, and there is no stored copy
97 // for a later config load to leave stale.
98 thread_ = std::thread(&ScriptRunner::run, this, ds, std::move(env));
99}
100
102 {
103 std::lock_guard<std::mutex> lk(mtx_);
104 stop_ = true;
105 }
106 cv_.notify_all();
107
108 if (thread_.joinable()) {
109 // Self-join guard, copied from HoldTimer for the same reason. It should be unreachable —
110 // transition_to posts its work and unwinds rather than calling back in — but a future path
111 // that stopped the runner from inside its own thread would otherwise be a std::terminate,
112 // and that is the failure that used to kill the sequencer outright.
113 if (thread_.get_id() == std::this_thread::get_id())
114 thread_.detach();
115 else
116 thread_.join();
117 }
118 active_ = false;
119}
120
121// ─────────────────────────────────────────────────────────────────────────────────────────────
122// The interpreter
123// ─────────────────────────────────────────────────────────────────────────────────────────────
124namespace {
125
126/** Everything one run needs, so the recursive walk can be free functions over one struct. */
127struct Interp {
131 std::atomic<bool>& stop;
132 std::mutex& mtx;
133 std::condition_variable& cv;
134 ScriptRunner::Clock::time_point started_at;
135 ScriptRunner::Clock::time_point deadline;
136
137 std::map<std::string, double> vars;
138 uint64_t iterations = 0;
139 /** Completed passes of the outermost loop, and the last condition rendered — both exist so
140 * the end-of-run line can say how far it got and what the deciding numbers were. Without
141 * them a timeout and a loop that never ran are the same single word in the journal. */
142 uint64_t loop_passes = 0;
143 std::string last_condition;
144 /** Set when something went wrong; names it for the log. */
145 std::string error;
146
147 /** Comparison operators, for the condition text in the log. Non-comparisons return null. */
148 static const char* opText(Op op) {
149 switch (op) {
150 case Op::Lt:
151 return "<";
152 case Op::Le:
153 return "<=";
154 case Op::Gt:
155 return ">";
156 case Op::Ge:
157 return ">=";
158 case Op::Eq:
159 return "==";
160 case Op::Ne:
161 return "!=";
162 default:
163 return nullptr;
164 }
165 }
166
167 bool stopped() const {
168 return stop.load();
169 }
170
171 double elapsed() const {
172 return std::chrono::duration<double>(ScriptRunner::Clock::now() - started_at).count();
173 }
174
175 bool pastDeadline() const {
176 return ScriptRunner::Clock::now() >= deadline;
177 }
178
179 /** Wait `seconds`, or until stopped / the deadline, whichever comes first.
180 * @return false if the run should end. */
181 bool sleep(double seconds) {
182 if (seconds < kMinDelaySeconds)
183 seconds = kMinDelaySeconds;
184 auto until = ScriptRunner::Clock::now() +
185 std::chrono::microseconds(static_cast<long long>(seconds * 1e6));
186 if (until > deadline)
187 until = deadline;
188 std::unique_lock<std::mutex> lk(mtx);
189 cv.wait_until(lk, until, [this] {
190 return stop.load();
191 });
192 return !stop.load() && !pastDeadline();
193 }
194
195 /**
196 * The condition's operands as text, e.g. "117.7 < 82.1".
197 *
198 * A probe, not part of evaluation: it re-evaluates the two sides and RESTORES `error`, so a
199 * failing read here can never invent a run-ending error that the real evaluation did not
200 * hit. Safe to repeat because expressions in this language have no side effects — pressure()
201 * is a guarded map read of the latest sample, and there is no assignment inside an
202 * expression. Returns empty for anything that is not a comparison, and the caller then
203 * simply omits the parenthetical.
204 */
205 std::string describeCondition(int32_t idx) {
206 if (idx < 0)
207 return {};
208 const Expr& e = prog.exprs[static_cast<size_t>(idx)];
209 if (e.kind != ExprKind::Binary)
210 return {};
211 const char* op = opText(e.op);
212 if (op == nullptr)
213 return {};
214 const std::string saved = error;
215 double a = 0.0;
216 double b = 0.0;
217 const bool ok = eval(e.lhs, a) && eval(e.rhs, b);
218 error = saved;
219 if (!ok)
220 return {};
221 std::ostringstream os;
222 os << std::fixed << std::setprecision(1) << a << ' ' << op << ' ' << b;
223 return os.str();
224 }
225
226 // ── Expressions ──────────────────────────────────────────────────────────────────────────
227 bool eval(int32_t idx, double& out) {
228 if (idx < 0) {
229 error = "malformed expression";
230 return false;
231 }
232 const Expr& e = prog.exprs[static_cast<size_t>(idx)];
233 switch (e.kind) {
234 case ExprKind::Number:
235 out = e.number;
236 return true;
237
238 case ExprKind::Var: {
239 const std::string& name = prog.variables[static_cast<size_t>(e.var)];
240 auto it = vars.find(name);
241 if (it == vars.end()) {
242 // The parser refuses a read-before-assign on every path it can see, so this is
243 // a belt-and-braces case rather than a reachable one.
244 error = "variable '" + name + "' was read before it was assigned";
245 return false;
246 }
247 out = it->second;
248 return true;
249 }
250
251 case ExprKind::Elapsed:
252 out = elapsed();
253 return true;
254
255 case ExprKind::Pressure: {
256 const std::string& role = ds.slug_names[static_cast<size_t>(e.slug)];
257 if (!env.read_pressure) {
258 error = "pressure(" + role + ") is not available in this build";
259 return false;
260 }
261 double psi = 0.0;
262 if (!env.read_pressure(role, psi)) {
263 // Deliberately fatal to the run. Continuing on a stale or uncalibrated reading
264 // is how a press loop ends up cycling a valve against a number that stopped
265 // moving, or computing a target of zero from a plausible-looking 0.0 PSI.
266 error = "no fresh calibrated reading for \"" + role + "\"";
267 return false;
268 }
269 out = psi;
270 return true;
271 }
272
273 case ExprKind::Unary: {
274 double v = 0.0;
275 if (!eval(e.lhs, v))
276 return false;
277 out = (e.op == Op::Neg) ? -v : (v != 0.0 ? 0.0 : 1.0);
278 return true;
279 }
280
281 case ExprKind::Binary: {
282 double a = 0.0;
283 if (!eval(e.lhs, a))
284 return false;
285 // Short-circuit, so `x != 0 and pressure(P) > x` does not read a sensor it does
286 // not need — and so a guarded pressure read is actually guarded.
287 if (e.op == Op::And && a == 0.0) {
288 out = 0.0;
289 return true;
290 }
291 if (e.op == Op::Or && a != 0.0) {
292 out = 1.0;
293 return true;
294 }
295 double b = 0.0;
296 if (!eval(e.rhs, b))
297 return false;
298 switch (e.op) {
299 case Op::Add:
300 out = a + b;
301 break;
302 case Op::Sub:
303 out = a - b;
304 break;
305 case Op::Mul:
306 out = a * b;
307 break;
308 case Op::Div:
309 if (b == 0.0) {
310 error = "division by zero";
311 return false;
312 }
313 out = a / b;
314 break;
315 case Op::Lt:
316 out = (a < b) ? 1.0 : 0.0;
317 break;
318 case Op::Le:
319 out = (a <= b) ? 1.0 : 0.0;
320 break;
321 case Op::Gt:
322 out = (a > b) ? 1.0 : 0.0;
323 break;
324 case Op::Ge:
325 out = (a >= b) ? 1.0 : 0.0;
326 break;
327 case Op::Eq:
328 out = (a == b) ? 1.0 : 0.0;
329 break;
330 case Op::Ne:
331 out = (a != b) ? 1.0 : 0.0;
332 break;
333 case Op::And:
334 out = (b != 0.0) ? 1.0 : 0.0;
335 break;
336 case Op::Or:
337 out = (b != 0.0) ? 1.0 : 0.0;
338 break;
339 default:
340 error = "unsupported operator";
341 return false;
342 }
343 if (!std::isfinite(out)) {
344 // A NaN reaching a comparison decides which branch opens a valve, by coin
345 // flip. Stop instead.
346 error = "arithmetic produced a non-finite value";
347 return false;
348 }
349 return true;
350 }
351 }
352 error = "unsupported expression";
353 return false;
354 }
355
356 // ── Statements ───────────────────────────────────────────────────────────────────────────
357 Flow execBlock(int32_t begin, int32_t end) {
358 for (int32_t k = begin; k < end; k++) {
359 if (stopped())
360 return Flow::Terminated;
361 if (pastDeadline()) {
362 error = "timeout";
363 return Flow::Terminated;
364 }
365 const Flow f = exec(prog.block_items[static_cast<size_t>(k)]);
366 if (f == Flow::Terminated)
367 return f;
368 }
369 return Flow::Continue;
370 }
371
372 Flow exec(int32_t idx) {
373 const Stmt& s = prog.stmts[static_cast<size_t>(idx)];
374 switch (s.kind) {
375 case StmtKind::Assign: {
376 double v = 0.0;
377 if (!eval(s.expr, v))
378 return Flow::Terminated;
379 vars[prog.variables[static_cast<size_t>(s.var)]] = v;
380 return Flow::Continue;
381 }
382
383 case StmtKind::OpenValve:
384 case StmtKind::CloseValve: {
385 const std::string& role = ds.slug_names[static_cast<size_t>(s.slug)];
386 if (env.set_valve)
387 env.set_valve(role, s.kind == StmtKind::OpenValve ? 1 : 0);
388 return Flow::Continue;
389 }
390
391 case StmtKind::Delay: {
392 double seconds = 0.0;
393 if (!eval(s.expr, seconds))
394 return Flow::Terminated;
395 if (!(seconds > 0.0)) {
396 error = "delay(" + std::to_string(seconds) + ") is not a positive duration";
397 return Flow::Terminated;
398 }
399 if (!sleep(seconds)) {
400 if (!stopped())
401 error = "timeout";
402 return Flow::Terminated;
403 }
404 return Flow::Continue;
405 }
406
407 case StmtKind::TransitionTo: {
408 const State target = ds.slug_states[static_cast<size_t>(s.slug)];
409 if (target == State::UNKNOWN) {
410 error = "transition target did not resolve";
411 return Flow::Terminated;
412 }
413 std::cout << "[ScriptRunner] " << env.state_name << ": transition_to "
414 << StateMachine::name(target) << std::endl;
415 if (env.transition)
416 env.transition(target);
417 return Flow::Terminated; // terminal by construction — unwind everything
418 }
419
420 case StmtKind::If: {
421 double c = 0.0;
422 if (!eval(s.expr, c))
423 return Flow::Terminated;
424 if (c != 0.0)
425 return execBlock(s.body_begin, s.body_end);
426 if (s.else_begin >= 0)
427 return execBlock(s.else_begin, s.else_end);
428 return Flow::Continue;
429 }
430
431 case StmtKind::While: {
432 bool announced = false;
433 for (;;) {
434 if (stopped())
435 return Flow::Terminated;
436 if (pastDeadline()) {
437 error = "timeout";
438 return Flow::Terminated;
439 }
440 if (++iterations > kMaxIterations) {
441 error = "loop iteration budget exhausted";
442 return Flow::Terminated;
443 }
444 double c = 0.0;
445 if (!eval(s.expr, c))
446 return Flow::Terminated;
447
449 const std::string paren =
450 last_condition.empty() ? std::string{} : " (" + last_condition + ")";
451
452 // Announce the entry decision once. A loop whose body never runs is otherwise
453 // indistinguishable in the journal from one that ran and finished, and the two
454 // mean opposite things: the first says the condition was already satisfied (or
455 // could never be), the second that the script did its work.
456 if (!announced) {
457 announced = true;
458 std::cout << "[ScriptRunner] " << env.state_name << ": while "
459 << (c != 0.0 ? "true" : "false") << " at entry" << paren
460 << (c != 0.0 ? " — entering loop"
461 : " — body never runs, 0 iterations")
462 << std::endl;
463 }
464 if (c == 0.0)
465 return Flow::Continue;
466
467 ++loop_passes;
468 // One line per pass. Loops are delay-bounded (a body with no delay() is
469 // refused at parse), so this cannot flood the journal.
470 std::cout << "[ScriptRunner] " << env.state_name << ": iteration "
471 << loop_passes << paren << std::endl;
472
473 const Flow f = execBlock(s.body_begin, s.body_end);
474 if (f == Flow::Terminated)
475 return f;
476 }
477 }
478 }
479 error = "unsupported statement";
480 return Flow::Terminated;
481 }
482};
483
484} // namespace
485
487 const auto t0 = Clock::now();
488 Interp in{ds.program, ds, env, stop_,
489 mtx_, cv_, t0, t0 + std::chrono::milliseconds(ds.timeout_ms)};
490
491 std::cout << "[ScriptRunner] " << env.state_name << ": start (timeout " << ds.timeout_ms
492 << " ms, " << static_cast<int>(kStartLeadInSeconds * 1000) << " ms lead-in)"
493 << std::endl;
494
495 // Let the state's actuator column reach the boards before the first script statement.
496 in.sleep(kStartLeadInSeconds);
497
498 const Flow f = in.execBlock(ds.program.top_begin, ds.program.top_end);
499
500 // Every ending below carries the same two facts: how far the script got, and what the
501 // condition was doing when it ended. Without them "stopped" and "timeout" are bare words and
502 // a run has to be reconstructed by correlating ActuatorCommander lines against the clock.
503 const std::string progress =
504 " after " + std::to_string(in.loop_passes) + " iteration(s)" +
505 (in.last_condition.empty() ? std::string{} : ", condition " + in.last_condition);
506
507 if (stop_.load()) {
508 // Cancelled by a transition or an abort. It is not this script's business where the rig
509 // goes next, and firing a landing transition here would fight the one already underway.
510 std::cout << "[ScriptRunner] " << env.state_name << ": stopped by transition" << progress
511 << std::endl;
512 active_ = false;
513 return;
514 }
515
516 if (f == Flow::Terminated && in.error.empty()) {
517 active_ = false; // transition_to already posted its own move
518 return;
519 }
520
521 if (!in.error.empty()) {
522 // Every abnormal end lands on the timeout target, not the return target. The two are
523 // separate fields precisely so a runaway can be sent somewhere more conservative than a
524 // clean finish, and an error is the runaway case.
525 std::cerr << "[ScriptRunner] " << env.state_name << ": " << in.error << progress
526 << " — going to " << StateMachine::name(ds.timeout_target) << std::endl;
527 if (env.transition)
529 active_ = false;
530 return;
531 }
532
533 // Ran off the end without transitioning. The landing state is mandatory in config for exactly
534 // this path, even when every branch ends in a transition_to — it is the backstop for the path
535 // the author did not think about.
536 std::cout << "[ScriptRunner] " << env.state_name << ": script complete" << progress
537 << " — going to " << StateMachine::name(ds.return_target) << std::endl;
538 if (env.transition)
540 active_ = false;
541}
542
543} // namespace sequencer
static std::string name(State s)
Human-readable name for a State (e.g.
std::atomic< bool > active_
std::atomic< bool > stop_
void stop()
Cancel and reap.
std::condition_variable cv_
void start(const DynamicState &ds, ScriptEnv env)
Begin running ds.
void run(DynamicState ds, ScriptEnv env)
double elapsedSeconds() const
Seconds since start(), which is what elapsed() reports.
Clock::time_point started_at_
static std::string name(State s)
Human-readable name for a State (e.g.
long target
Definition main.cpp:38
static bool ok
Definition flight_e2e.c:435
*brief create JSON pointer *sa reference_tokens const string_t const string_t & b
Definition json.hpp:13878
*brief append another JSON pointer at the end of this JSON pointer *sa ptr reference_tokens begin()
*brief create JSON pointer *sa reference_tokens const string_t & a
Definition json.hpp:13877
*brief create JSON pointer *sa reference_tokens end()
@ Terminated
transition_to ran, or something failed.
constexpr double kStartLeadInSeconds
Shortest delay actually honoured.
constexpr uint64_t kMaxIterations
Total loop iterations across the whole script.
State
System state IDs — must match the TypeScript SystemState enum in web-gui/shared/types....
static bool elapsed(uint32_t now, uint32_t then, uint32_t interval)
Definition self_test.c:10
static uint32_t until(uint32_t now, uint32_t t)
A parsed script, as a flat arena.
int32_t body_begin
If/While body, and If's else arm: [begin, end) into ScriptProgram::block_items.
int32_t expr
Assign value, Delay seconds, If/While condition.
int32_t slug
OpenValve/CloseValve/TransitionTo target.
int32_t var
Assign: index into ScriptProgram::variables.
One dynamic state, resolved and proven usable.
State return_target
Where the state lands when the script runs off its end.
State timeout_target
Where it lands when timeout_ms expires.
fsw::script::ScriptProgram program
What a running script is allowed to do to the world.
std::function< void(State target)> transition
Leave the state.
std::string state_name
Where the script's log lines are attributed.
Everything one run needs, so the recursive walk can be free functions over one struct.
std::string describeCondition(int32_t idx)
The condition's operands as text, e.g.
static const char * opText(Op op)
Comparison operators, for the condition text in the log.
bool sleep(double seconds)
Wait seconds, or until stopped / the deadline, whichever comes first.
std::string error
Set when something went wrong; names it for the log.
uint64_t loop_passes
Completed passes of the outermost loop, and the last condition rendered — both exist so the end-of-ru...