STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
test_hold_rules.cpp
Go to the documentation of this file.
1/**
2 * Hold rules: who may set a hold duration, who may not, and what a bad config does.
3 *
4 * The fire-window path is covered by test_fire_lifecycle, which drives HoldTimer directly. This
5 * one drives SequencerService, because the property that matters is not "the timer counts" but
6 * "a duration aimed at the burn state is refused AND the rig does not move" — and both halves of
7 * that live in transitionTo, not in the timer.
8 *
9 * The hazard being pinned: a client queues a transition, its socket drops, and the WebSocket layer
10 * replays the queued command on reconnect. If a duration could ride along with it into the fire
11 * state, a stale frame would set a burn length. So the sequencer refuses, and refuses WITHOUT
12 * transitioning — a refusal that still changed state would be its own bug.
13 */
14#include <atomic>
15#include <chrono>
16#include <filesystem>
17#include <fstream>
18#include <iostream>
19#include <string>
20#include <thread>
21
22#include "BoardListener.hpp"
23#include "control/HoldParse.hpp"
26
27namespace fs = std::filesystem;
31using Clock = std::chrono::steady_clock;
32
33static int g_failures = 0;
34
35static void check(bool ok, const std::string& what) {
36 std::cout << (ok ? " ok " : " FAIL ") << what << std::endl;
37 if (!ok)
38 g_failures++;
39}
40
42
43/** Write a config whose flow hold can be varied per case. */
44struct FlowCfg {
45 std::string flow_state_name = "Flow Test";
46 bool mark_is_flow = true;
47 std::string return_target = "Press Standby";
48 uint32_t duration_ms = 1000;
49 uint32_t max_ms = 5000;
50 /** Gate the window on this actuator, staggered by the delays below. Empty = no gate. */
51 std::string gate_actuator;
52 double gate_open_delay_s = 0.0; // its delay in the FLOW state's column
53 double gate_close_delay_s = 0.0; // its delay in the RETURN state's column
54};
55
56static std::string writeConfig(const FlowCfg& f) {
57 // Column order matches the [[states]] order; the loader keys both CSVs by name.
58 {
59 std::ofstream t(g_dir / "state_transitions.csv");
60 t << ",Idle,Armed,Press Standby,Fire," << f.flow_state_name << "\n";
61 t << "Idle,1,1,1,0,0\n";
62 t << "Armed,1,1,1,0,0\n";
63 t << "Press Standby,1,1,1,1,1\n";
64 t << "Fire,0,1,0,1,0\n";
65 t << f.flow_state_name << ",0,0,1,0,1\n";
66 }
67 {
68 std::ofstream a(g_dir / "state_machine_actuators.csv");
69 a << ",Idle,Armed,Press Standby,Fire," << f.flow_state_name << "\n";
70 a << "Main Valve,CLOSE,CLOSE,CLOSE,OPEN,OPEN\n";
71 // The valve the gate is NOT timed around: it opens on entry with no delay, so the
72 // stagger between the two is directly observable on the wire.
73 a << "Upstream Valve,CLOSE,CLOSE,CLOSE,OPEN,OPEN\n";
74 }
75 {
76 // ActuatorCommander derives this filename from the actuator CSV's, so it must sit beside
77 // it.
78 std::ofstream d(g_dir / "state_machine_actuator_delays.csv");
79 d << ",Idle,Armed,Press Standby,Fire," << f.flow_state_name << "\n";
80 // Column order above: the return state here is always Press Standby, so its delay is the
81 // third numeric column and the flow state's is the fifth.
82 d << "Main Valve,0,0," << f.gate_close_delay_s << ",0," << f.gate_open_delay_s << "\n";
83 d << "Upstream Valve,0,0,0,0,0\n";
84 }
85
86 const fs::path cfg_path = g_dir / "config.toml";
87 std::ofstream c(cfg_path);
88 c << "[network]\nactuator_cmd_port = 45914\n\n"
89 << "[database]\nport = 2\n\n" // nothing listening; the service runs without Elodin
90 << "[boards.act_board]\ntype = \"ACTUATOR\"\nip = \"127.0.0.1\"\nboard_id = 12\n"
91 "enabled = true\nnum_actuators = 10\n\n"
92 << "[actuator_roles]\n\"Main Valve\" = [\"NC\", 1, 12]\n"
93 "\"Upstream Valve\" = [\"NC\", 2, 12]\n\n"
94 << "[state_machine]\n"
95 << "transitions_csv = \"" << (g_dir / "state_transitions.csv").string() << "\"\n"
96 << "actuator_csv = \"" << (g_dir / "state_machine_actuators.csv").string()
97 << "\"\n"
98 // Declared explicitly rather than left to be guessed from the positions filename — that
99 // guess is now only a fallback, and a test that relies on it is testing the fallback.
100 << "actuator_delay_csv = \"" << (g_dir / "state_machine_actuator_delays.csv").string()
101 << "\"\n\n"
102 << "[fire]\nstate = \"Fire\"\nexpiry_target = \"Armed\"\n"
103 "duration_ms = 800\nextended_ms = 1600\n\n"
104 << "[flow]\nreturn_target = \"" << f.return_target << "\"\n"
105 << "duration_ms = " << f.duration_ms << "\nmax_ms = " << f.max_ms << "\n"
106 << (f.gate_actuator.empty() ? std::string() : "gate_actuator = \"" + f.gate_actuator + "\"\n")
107 << "\n"
108 << "[[states]]\nid = 1\nname = \"Idle\"\nis_boot = true\n\n"
109 << "[[states]]\nid = 2\nname = \"Armed\"\n\n"
110 << "[[states]]\nid = 3\nname = \"Press Standby\"\n\n"
111 << "[[states]]\nid = 12\nname = \"Fire\"\n\n"
112 << "[[states]]\nid = 13\nname = \"" << f.flow_state_name << "\"\n"
113 << (f.mark_is_flow ? "is_flow = true\n" : "") << "\n";
114 return cfg_path.string();
115}
116
117/** Wait for the service to land in `want`; returns ms elapsed, or -1 on timeout. */
118static long long waitForState(SequencerService& svc, State want, int timeout_ms) {
119 const auto t0 = Clock::now();
120 for (;;) {
121 const auto ms =
122 std::chrono::duration_cast<std::chrono::milliseconds>(Clock::now() - t0).count();
123 if (svc.currentState() == want)
124 return ms;
125 if (ms > timeout_ms)
126 return -1;
127 std::this_thread::sleep_for(std::chrono::milliseconds(2));
128 }
129}
130
131int main() {
132 std::cout << "=== Hold rules ===" << std::endl;
133
134 g_dir = fs::temp_directory_path() / "daq_hold_rules_test";
135 fs::remove_all(g_dir);
136 fs::create_directories(g_dir);
137
138 // ── parseHoldMs: strict, because the lenient version silently runs the wrong test ─────────
139 {
140 uint32_t v = 0;
141 const char* bad[] = {"0", "-1", "abc", "1e3", "999999999999", "", "1000x", " ", "+5"};
142 bool all_rejected = true;
143 for (const char* b : bad)
145 all_rejected = false;
146 check(all_rejected, "parseHoldMs rejects 0, signs, junk, partial numbers and overflow");
147
148 check(sequencer::parseHoldMs("1000", v) && v == 1000, "parseHoldMs accepts a plain number");
149 check(sequencer::parseHoldMs(" 1000 ", v) && v == 1000, "parseHoldMs tolerates padding");
150 check(!sequencer::parseHoldMs(std::to_string(sequencer::kMaxHoldMs + 1), v),
151 "parseHoldMs enforces the wire ceiling");
152 }
153
154 const std::string cfg_path = writeConfig(FlowCfg{});
155
156 {
158 check(svc.init(cfg_path), "service initialised from the test config");
159
160 const State kFlow = StateMachine::fromName("Flow Test");
161 const State kFire = StateMachine::fromName("Fire");
162 const State kStandby = StateMachine::fromName("Press Standby");
163 check(kFlow != State::UNKNOWN && kFire != State::UNKNOWN, "test states resolved");
164
165 check(svc.transitionTo(kStandby), "reached the hub state");
166
167 // ── A duration aimed at the burn state is refused, and nothing moves ──────────────────
168 {
169 std::string why;
170 const bool ok = svc.transitionTo(kFire, 1500, &why);
171 check(!ok, "a client-supplied duration is refused for the fire state");
172 check(why == "duration not permitted for this state", "the refusal says why: " + why);
173 // The half that actually matters.
174 check(
175 svc.currentState() == kStandby,
176 "the REFUSED fire hold did not transition — a replayed frame cannot start a burn");
177 }
178
179 // ── The same request is honoured for the flow state ───────────────────────────────────
180 {
181 std::string why;
182 const auto t0 = Clock::now();
183 check(svc.transitionTo(kFlow, 1500, &why), "a duration IS honoured for the flow state");
184 check(svc.currentState() == kFlow, "the rig entered the flow state");
185 const long long back = waitForState(svc, kStandby, 4000);
186 const long long total =
187 std::chrono::duration_cast<std::chrono::milliseconds>(Clock::now() - t0).count();
188 check(back >= 0, "the hold expired back into the return state on its own");
189 // Wider than the HoldTimer unit test: this crosses actuator commanding and a real
190 // transition on the timer thread.
191 check(total >= 1490 && total <= 1600,
192 "the hold ran for the REQUESTED window, not the configured default (" +
193 std::to_string(total) + " ms)");
194 }
195
196 // ── Bounds ────────────────────────────────────────────────────────────────────────────
197 {
198 std::string why;
199 check(!svc.transitionTo(kFlow, 9999, &why), "a duration over max_ms is refused");
200 check(why.rfind("duration exceeds max", 0) == 0,
201 "the refusal names the ceiling: " + why);
202 check(svc.currentState() == kStandby, "an over-max request did not transition");
203
204 why.clear();
205 check(!svc.transitionTo(kStandby, 1200, &why),
206 "a duration aimed at a non-holdable state is refused");
207 check(why == "state is not holdable", "the refusal says why: " + why);
208 }
209
210 // ── No duration supplied → the configured default ─────────────────────────────────────
211 {
212 const auto t0 = Clock::now();
213 check(svc.transitionTo(kFlow), "the flow state is enterable with no duration at all");
214 check(waitForState(svc, kStandby, 4000) >= 0, "the default hold expired");
215 const long long total =
216 std::chrono::duration_cast<std::chrono::milliseconds>(Clock::now() - t0).count();
217 check(total >= 990 && total <= 1100,
218 "it used [flow].duration_ms (" + std::to_string(total) + " ms)");
219 }
220 }
221
222 // ── A staggered state times the window on the VALVE, not on the state ────────────────────
223 {
224 // The rig cracks an upstream valve first so a regulator can settle, then opens the main a
225 // second later. "1 s" has to mean the MAIN was open for 1 s — if the hold were measured
226 // from the transition, a 1 s request would close everything exactly as the main opened and
227 // flow for zero seconds.
229 FlowCfg{"Flow Test", true, "Press Standby", 1000, 5000, "Main Valve", 0.5, 0.0});
231 check(svc.init(cfg_path), "service initialised with a gated flow state");
232 const State kFlow = StateMachine::fromName("Flow Test");
233 const State kStandby = StateMachine::fromName("Press Standby");
234 check(svc.transitionTo(kStandby), "reached the hub");
235
236 // Listen on the wire. Everything below this line used to be asserted on the STATE clock
237 // alone, which is why a 1 s request that held both valves open for 2 s passed: the
238 // arithmetic (1000 + 500) was correct and the valves were commanded together anyway.
239 daqtest::BoardListener listener(45914);
240 check(listener.start(), "bound the actuator UDP port to watch the wire");
241
242 const auto t0 = Clock::now();
243 check(svc.transitionTo(kFlow, 1000), "gated hold accepted");
244 check(waitForState(svc, kStandby, 4000) >= 0, "gated hold expired");
245 const long long total =
246 std::chrono::duration_cast<std::chrono::milliseconds>(Clock::now() - t0).count();
247 // State held for requested + open delay = 1000 + 500.
248 check(total >= 1480 && total <= 1620,
249 "state was held for requested + open-stagger (" + std::to_string(total) + " ms)");
250
251 std::this_thread::sleep_for(std::chrono::milliseconds(50)); // let the close land
252 listener.stop();
253 const auto b = listener.bursts();
254
255 // 1 = OPEN for an NC valve. Upstream first, main half a second behind it.
256 const long long up_open = daqtest::firstCommand(b, /*ch=*/2, /*hw=*/1);
257 const long long main_open = daqtest::firstCommand(b, /*ch=*/1, /*hw=*/1);
258 const long long main_close =
259 main_open < 0 ? -1 : daqtest::firstCommand(b, /*ch=*/1, /*hw=*/0, main_open + 1);
260
261 check(up_open >= 0 && main_open >= 0 && main_close >= 0,
262 "saw the upstream open, the main open and the main close on the wire");
263
264 const long long stagger = (up_open >= 0 && main_open >= 0) ? main_open - up_open : -1;
265 check(stagger >= 400 && stagger <= 700,
266 "the main opened ~500 ms AFTER the upstream, not with it (" +
267 std::to_string(stagger) + " ms)");
268
269 // The whole point of the gate arithmetic: the requested duration is how long the GATE
270 // valve is open, not how long the state lasts.
271 const long long main_ms = (main_open >= 0 && main_close >= 0) ? main_close - main_open : -1;
272 check(main_ms >= 980 && main_ms <= 1150,
273 "the main valve was open for the REQUESTED 1000 ms (" + std::to_string(main_ms) +
274 " ms)");
275 }
276
277 // ── A stagger on the way OUT shortens the hold by the same amount ─────────────────────────
278 {
279 // The valve does not close at the expiry transition either: if it lingers 300 ms into the
280 // return state, the state must be held 300 ms less or the valve is open too long.
282 FlowCfg{"Flow Test", true, "Press Standby", 1000, 5000, "Main Valve", 0.5, 0.3});
284 check(svc.init(cfg_path), "service initialised with a two-sided stagger");
285 const State kFlow = StateMachine::fromName("Flow Test");
286 const State kStandby = StateMachine::fromName("Press Standby");
287 check(svc.transitionTo(kStandby), "reached the hub");
288
289 const auto t0 = Clock::now();
290 check(svc.transitionTo(kFlow, 1000), "two-sided gated hold accepted");
291 check(waitForState(svc, kStandby, 4000) >= 0, "two-sided gated hold expired");
292 const long long total =
293 std::chrono::duration_cast<std::chrono::milliseconds>(Clock::now() - t0).count();
294 // hold = 1000 + 500 - 300 = 1200. The valve is still open 1000 ms: it opens at +500 and
295 // closes at 1200 + 300 = 1500.
296 check(total >= 1180 && total <= 1320,
297 "hold = requested + open - close stagger (" + std::to_string(total) + " ms)");
298 }
299
300 // ── A close delay that swallows the window is clamped, not wrapped ────────────────────────
301 {
302 // Unsigned arithmetic here would underflow into a ~49-day hold with the valves open.
303 writeConfig(FlowCfg{"Flow Test", true, "Press Standby", 200, 5000, "Main Valve", 0.0, 2.0});
305 check(svc.init(cfg_path), "service initialised with an oversized close stagger");
306 const State kFlow = StateMachine::fromName("Flow Test");
307 const State kStandby = StateMachine::fromName("Press Standby");
308 check(svc.transitionTo(kStandby), "reached the hub");
309 const auto t0 = Clock::now();
310 check(svc.transitionTo(kFlow, 200), "hold accepted");
311 const long long back = waitForState(svc, kStandby, 3000);
312 const long long total =
313 std::chrono::duration_cast<std::chrono::milliseconds>(Clock::now() - t0).count();
314 check(back >= 0 && total < 1000, "clamped to a minimal hold instead of underflowing (" +
315 std::to_string(total) + " ms)");
316 }
317
318 // ── The flag, not the name: rename the state and it keeps working ─────────────────────────
319 {
320 writeConfig(FlowCfg{"Cold Flow", true, "Press Standby", 300, 5000});
322 check(svc.init(cfg_path), "service initialised with the flow state renamed");
323 const State renamed = StateMachine::fromName("Cold Flow");
324 check(renamed != State::UNKNOWN, "the renamed state resolved");
325 check(svc.transitionTo(StateMachine::fromName("Press Standby")), "reached the hub");
326 std::string why;
327 check(svc.transitionTo(renamed, 400, &why),
328 "the renamed state is still the flow state — nothing resolved it by name");
329 }
330
331 // ── A return target that does not exist makes the state NOT holdable ──────────────────────
332 {
333 writeConfig(FlowCfg{"Flow Test", true, "Nowhere At All", 300, 5000});
335 check(svc.init(cfg_path), "service initialised with a broken return target");
336 check(svc.transitionTo(StateMachine::fromName("Press Standby")), "reached the hub");
337 std::string why;
338 check(!svc.transitionTo(StateMachine::fromName("Flow Test"), 400, &why),
339 "a hold whose return target is unresolvable is refused");
340 check(why == "state is not holdable", "refused as not holdable: " + why);
341 // The point of refusing up front: the valve is never opened, so it cannot be stranded open
342 // by an expiry transition that was never going to be allowed.
343 check(svc.currentState() == StateMachine::fromName("Press Standby"),
344 "the rig never entered the state, so its valve never opened");
345 }
346
347 // ── No state flagged is_flow → there is simply no flow hold ───────────────────────────────
348 {
349 writeConfig(FlowCfg{"Flow Test", false, "Press Standby", 300, 5000});
351 check(svc.init(cfg_path), "service initialised with no state marked is_flow");
352 check(svc.transitionTo(StateMachine::fromName("Press Standby")), "reached the hub");
353 std::string why;
354 check(!svc.transitionTo(StateMachine::fromName("Flow Test"), 400, &why),
355 "with nothing flagged, a supplied duration has no state to apply to");
356 check(why == "state is not holdable", "refused as not holdable: " + why);
357 }
358
359 fs::remove_all(g_dir);
360 std::cout << (g_failures == 0 ? "\nPASSED" : "\nFAILED") << std::endl;
361 return g_failures == 0 ? 0 : 1;
362}
static State fromName(const std::string &name)
Translate a CSV name (e.g.
Collects UDP actuator packets, folding the 3x retransmit of one batch into a single burst.
std::vector< Burst > bursts() const
Top-level sequencer service.
bool init(const std::string &config_path="config/config.toml")
Load config, CSVs, connect to Elodin.
bool transitionTo(const std::string &state_name)
Request a state transition by name (e.g.
Parses state_transitions.csv and validates state transitions.
State
Definition main.cpp:72
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 create JSON pointer *sa reference_tokens const string_t & a
Definition json.hpp:13877
return reference_tokens back()
std::chrono::steady_clock Clock
long long firstCommand(const std::vector< Burst > &bursts, uint8_t ch, uint8_t hw, long long after_ms=-1)
When ch was first commanded to hw at or after after_ms.
constexpr uint32_t kMaxHoldMs
Hard ceiling on any client-supplied hold, independent of per-state config.
Definition HoldParse.hpp:16
bool parseHoldMs(const std::string &text, uint32_t &out)
Parse a hold duration off the wire, strictly.
Definition HoldParse.hpp:31
State
System state IDs — must match the TypeScript SystemState enum in web-gui/shared/types....
Write a config whose flow hold can be varied per case.
double gate_open_delay_s
std::string gate_actuator
Gate the window on this actuator, staggered by the delays below.
uint32_t duration_ms
std::string flow_state_name
std::string return_target
uint32_t max_ms
double gate_close_delay_s
std::string string() const
static int g_failures
static std::string writeConfig(const FlowCfg &f)
static long long waitForState(SequencerService &svc, State want, int timeout_ms)
Wait for the service to land in want; returns ms elapsed, or -1 on timeout.
static fs::path g_dir
static void check(bool ok, const std::string &what)
int main()