STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
ControllerService.cpp
Go to the documentation of this file.
1/**
2 * @file ControllerService.cpp
3 * @brief C++ controller service with UDP PWM output
4 *
5 * Pipeline:
6 * 1. Reads sensor measurements (pushed via setMeasurement)
7 * 2. Runs RobustDDPController::step() to compute actuation
8 * 3. Sends PWM command packets over UDP to actuator board
9 * (exact combined_gui.py packet format)
10 * 4. Optionally writes to Elodin DB for logging
11 */
12
14
15#include <arpa/inet.h>
16#include <netinet/in.h>
17#include <sys/socket.h>
18#include <unistd.h>
19
20#include <chrono>
21#include <cstring>
22#include <fstream>
23#include <iostream>
24#include <sstream>
25#include <thread>
26
31#include "db.hpp"
33#include "net/DaqInterface.hpp"
34
35using namespace vtable;
36using namespace vtable::builder;
37
38namespace fsw {
39namespace control {
40
41// ═══════════════════════════════════════════════════════════════════════
42// PWM Packet Format (matches combined_gui.py EXACTLY)
43//
44// Header: packet_type (u8) = 10
45// version (u8) = 0
46// timestamp (u32 LE) = ms since epoch & 0xFFFFFFFF
47// Body: num_commands (u8)
48// Per-cmd: actuator_id (u8)
49// duration_ms (u32 LE)
50// duty_cycle (float32 LE)
51// frequency (float32 LE)
52//
53// Total for 1 command: 6 + 1 + 13 = 20 bytes
54// ═══════════════════════════════════════════════════════════════════════
55
56static constexpr uint8_t PACKET_TYPE_PWM = 10;
57static constexpr uint8_t COMMS_VERSION = 0;
58static constexpr size_t HEADER_SIZE = 6; // <BBI>
59static constexpr size_t BODY_SIZE = 1; // <B> num_commands
60static constexpr size_t CMD_SIZE = 13; // <BIff>
61static constexpr size_t SINGLE_CMD_PACKET_SIZE = HEADER_SIZE + BODY_SIZE + CMD_SIZE; // 20
62static constexpr size_t DUAL_CMD_PACKET_SIZE = HEADER_SIZE + BODY_SIZE + CMD_SIZE * 2; // 33
63
64// ═══════════════════════════════════════════════════════════════════════
65// CONSTRUCTION / INIT / DESTROY
66// ═══════════════════════════════════════════════════════════════════════
67
69 : controller_(std::make_unique<RobustDDPController>()),
70 elodin_client_(std::make_unique<fsw::elodin::ElodinClient>()),
71 elodin_subscriber_(std::make_unique<fsw::elodin::ElodinClient>()) {
72}
73
75 stop();
76 if (udp_socket_fd_ >= 0) {
77 ::close(udp_socket_fd_);
78 udp_socket_fd_ = -1;
79 }
80}
81
83 const RobustDDPController::Config& controller_config,
84 const std::string& elodin_host, uint16_t elodin_port,
85 const std::string& lut_path,
86 const std::string& thrust_curve_path) {
87 elodin_host_ = elodin_host;
88 elodin_port_ = elodin_port;
89 pwm_config_ = pwm_config;
90
91 // ── Create UDP socket for PWM commands ──────────────────────────────
92 udp_socket_fd_ = ::socket(AF_INET, SOCK_DGRAM, 0);
93 if (udp_socket_fd_ < 0) {
94 std::cerr << "[ControllerService] ❌ Failed to create UDP socket: " << strerror(errno)
95 << std::endl;
96 return false;
97 }
99 "ControllerService")) {
100 ::close(udp_socket_fd_);
101 udp_socket_fd_ = -1;
102 return false;
103 }
104 auto describe = [](const PWMTarget& t) {
105 return t.resolved() ? t.board_ip + " CH" + std::to_string(static_cast<int>(t.channel))
106 : std::string("(unresolved — PWM suppressed)");
107 };
108 std::cout << "[ControllerService] ✅ UDP socket created for PWM output → :"
109 << pwm_config_.actuator_port
110 << (pwm_config_.bind_address == "0.0.0.0"
111 ? std::string()
112 : " (from " + pwm_config_.bind_address + ")")
113 << std::endl;
114 std::cout << "[ControllerService] Fuel " << describe(pwm_config_.fuel) << " Ox "
115 << describe(pwm_config_.ox) << " freq=" << pwm_config_.frequency_hz << "Hz"
116 << " duration=" << pwm_config_.duration_ms << "ms" << std::endl;
117
118 // ── Elodin DB (optional) ────────────────────────────────────────────
119 if (!elodin_host.empty()) {
120 if (elodin_client_->connect(elodin_host, elodin_port)) {
121 elodin_connected_ = true;
123 std::cout << "[ControllerService] ✅ Connected to Elodin DB at " << elodin_host << ":"
124 << elodin_port << std::endl;
125 } else {
126 std::cerr << "[ControllerService] ⚠️ Elodin DB connection failed — "
127 "running without DB logging"
128 << std::endl;
129 }
130 } else {
131 std::cout << "[ControllerService] ℹ️ No Elodin host specified — "
132 "running without DB logging"
133 << std::endl;
134 }
135
136 // ── Elodin subscriber (dedicated read-only connection for calibrated PT) ──
137 // A failure here is no longer terminal for the subscriber: elodinSubscriberLoop() retries.
138 if (!elodin_host.empty() && !connectSubscriber())
139 std::cerr << "[ControllerService] ⚠️ Elodin subscriber connection failed — retrying in "
140 "the background"
141 << std::endl;
142
143 // ── Initialize controller ───────────────────────────────────────────
144 if (!controller_->initialize(controller_config)) {
145 std::cerr << "[ControllerService] ❌ Failed to initialize RobustDDPController" << std::endl;
146 return false;
147 }
148
149 // ── Optional LUT (bypasses DDP for boolean control) ──────────────────
150 if (!lut_path.empty()) {
151 if (lut_.load(lut_path)) {
152 std::cout
153 << "[ControllerService] ✅ Using LUT for optimal boolean control (DDP bypassed)"
154 << std::endl;
155 } else {
156 std::cerr << "[ControllerService] ⚠️ LUT load failed — falling back to DDP" << std::endl;
157 }
158 }
159
160 // ── Optional thrust curve (time-varying target from Layer 2) ─────────
161 if (!thrust_curve_path.empty()) {
162 if (loadThrustCurve(thrust_curve_path)) {
164 std::cout << "[ControllerService] ✅ Thrust curve loaded: " << thrust_curve_path << " ("
165 << thrust_curve_times_.size() << " points)" << std::endl;
166 } else {
167 std::cerr << "[ControllerService] ⚠️ Thrust curve load failed: " << thrust_curve_path
168 << std::endl;
169 }
170 }
171
172 // Set default measurement to zeros
173 {
174 std::lock_guard<std::mutex> lock(input_mutex_);
175 current_meas_ = {};
176 current_meas_.timestamp = std::chrono::steady_clock::now();
178 current_cmd_.thrust_desired = 1000.0; // Default thrust target [N]
179 }
180
181 std::cout << "[ControllerService] ✅ Controller initialized" << std::endl;
182 return true;
183}
184
185// ═══════════════════════════════════════════════════════════════════════
186// START / STOP
187// ═══════════════════════════════════════════════════════════════════════
188
189bool ControllerService::start(double loop_rate_hz) {
190 if (running_)
191 return true;
192
193 if (udp_socket_fd_ < 0) {
194 std::cerr << "[ControllerService] ❌ Cannot start — UDP socket not initialized"
195 << std::endl;
196 return false;
197 }
198
199 running_ = true;
200 loop_rate_hz_ = loop_rate_hz;
201 loop_interval_ms_ = 1000.0 / loop_rate_hz;
202
204 // Started whenever a db is configured, not only when the first connect happened to succeed:
205 // the loop reconnects, so a controller that boots while the db is down now picks it up
206 // instead of running blind for the rest of the session.
207 if (!elodin_host_.empty()) {
209 }
210
211 std::cout << "[ControllerService] ✅ Started controller loop at " << loop_rate_hz << " Hz"
212 << std::endl;
213 return true;
214}
215
217 if (!running_)
218 return;
219
220 running_ = false;
221 if (controller_thread_.joinable()) {
222 controller_thread_.join();
223 }
224 if (elodin_subscriber_thread_.joinable()) {
226 }
227 std::cout << "[ControllerService] 🛑 Stopped controller loop" << std::endl;
228}
229
230// ═══════════════════════════════════════════════════════════════════════
231// THREAD-SAFE SETTERS / GETTERS
232// ═══════════════════════════════════════════════════════════════════════
233
235 std::lock_guard<std::mutex> lock(input_mutex_);
236 current_meas_ = meas;
237 has_measurement_ = true;
238}
239
241 std::lock_guard<std::mutex> lock(input_mutex_);
242 current_cmd_ = cmd;
243}
244
246 std::lock_guard<std::mutex> lock(input_mutex_);
248}
249
251 // The single funnel both activation paths go through — the TCP FIRE_START/FIRE_STOP server and
252 // the [0x50] state-packet parity fallback. Guarding here rather than at each caller means a
253 // disabled gate cannot be reached by any route, present or future.
254 if (!fire_gate_enabled_.load())
255 return;
256
257 const bool was_active = fire_active_.exchange(active);
258 if (was_active == active)
259 return;
260 std::cout << "[ControllerService] 🔥 Fire state: "
261 << (active ? "ACTIVE — PWM enabled" : "INACTIVE — PWM suppressed") << std::endl;
262
263 if (active && !was_active) {
264 fire_start_time_ = std::chrono::steady_clock::now();
265 // FIRE_START: send a single open command using the same priority as the loop
266 float td_f = test_duty_fuel_.load();
267 float td_o = test_duty_ox_.load();
268
270
271 if (td_f > 0.0f || td_o > 0.0f) {
272 // Fallback / test duty: just send whatever is set
273 actuation.duty_F = td_f;
274 actuation.duty_O = td_o;
275 actuation.u_F_on = td_f > 0.0f;
276 actuation.u_O_on = td_o > 0.0f;
277 actuation.valid = true;
278 } else if (lut_.is_loaded()) {
281 {
282 std::lock_guard<std::mutex> lock(input_mutex_);
283 meas = current_meas_;
284 cmd = current_cmd_;
285 }
286 std::map<std::string, double> point;
287 point["P_u_fuel"] = meas.P_u_fuel;
288 point["P_u_ox"] = meas.P_u_ox;
289 double thrust_ref = (cmd.type == RobustDDPController::CommandType::THRUST_DESIRED)
290 ? cmd.thrust_desired
291 : 0.0;
294 thrust_ref = interpolateThrustCurve(0.0); // t=0 at FIRE_START
295 point["thrust_desired"] = thrust_ref;
296 point["MR_ref"] = 2.2;
297 const double P_ch_est = 0.5 * (meas.P_ch_mp1 + meas.P_ch_mp2);
298 if (P_ch_est > 0.0)
299 point["P_ch"] = P_ch_est;
300 std::map<std::string, double> lut_out;
301 if (lut_.evaluate(point, lut_out)) {
302 double u_f = lut_out.count("u_safe_F") ? lut_out.at("u_safe_F")
303 : lut_out.count("duty_F") ? lut_out.at("duty_F")
304 : 0.0;
305 double u_o = lut_out.count("u_safe_O") ? lut_out.at("u_safe_O")
306 : lut_out.count("duty_O") ? lut_out.at("duty_O")
307 : 0.0;
308 actuation.duty_F = std::clamp(u_f, 0.0, 1.0);
309 actuation.duty_O = std::clamp(u_o, 0.0, 1.0);
310 actuation.u_F_on = actuation.duty_F > 0.01;
311 actuation.u_O_on = actuation.duty_O > 0.01;
312 actuation.valid = true;
313 }
314 } else {
318 {
319 std::lock_guard<std::mutex> lock(input_mutex_);
320 meas = current_meas_;
321 cmd = current_cmd_;
323 }
324 auto [a, d] = controller_->step(meas, nav, cmd);
325 actuation = a;
326 }
327
328 if (actuation.valid)
329 sendActuationPWM(actuation);
330 std::cout << "[ControllerService] 🔥 FIRE open command sent: duty_F=" << actuation.duty_F
331 << " duty_O=" << actuation.duty_O << std::endl;
332
333 } else if (!active && was_active) {
334 // FIRE_STOP: send a single zero-duty close command on both channels
336 zero.duty_F = 0.0;
337 zero.duty_O = 0.0;
338 zero.u_F_on = false;
339 zero.u_O_on = false;
340 zero.valid = true;
341 sendActuationPWM(zero);
342 std::cout << "[ControllerService] 🛑 FIRE close command sent (duty=0)" << std::endl;
343 }
344
345 // Write fire-state event to Elodin DB [0x44, 0x00]
348 std::get<0>(fs_msg.fields) = std::chrono::duration_cast<std::chrono::nanoseconds>(
349 std::chrono::system_clock::now().time_since_epoch())
350 .count();
351 std::get<1>(fs_msg.fields) = active ? uint8_t(1) : uint8_t(0);
352 // Capture current actuation state for context
353 {
354 std::lock_guard<std::mutex> lock(input_mutex_);
355 std::get<2>(fs_msg.fields) = static_cast<float>(current_meas_.P_u_fuel > 0 ? 1.0 : 0.0);
356 std::get<3>(fs_msg.fields) = static_cast<float>(current_meas_.P_u_ox > 0 ? 1.0 : 0.0);
357 }
358 elodin_client_->publish(0x4400, fs_msg);
359 }
360}
361
362void ControllerService::setTestDuty(float fuel, float ox) {
363 test_duty_fuel_ = std::max(0.0f, std::min(1.0f, fuel));
364 test_duty_ox_ = std::max(0.0f, std::min(1.0f, ox));
365 if (fuel > 0.0f || ox > 0.0f)
366 std::cout << "[ControllerService] 🧪 Test duty override: fuel=" << test_duty_fuel_.load()
367 << " ox=" << test_duty_ox_.load() << " (DDP bypassed)" << std::endl;
368 else
369 std::cout << "[ControllerService] 🧪 Test duty cleared — DDP controller active"
370 << std::endl;
371}
372
377
382
383// ═══════════════════════════════════════════════════════════════════════
384// PWM COMMAND SENDING (matches combined_gui.py EXACTLY)
385// ═══════════════════════════════════════════════════════════════════════
386
387bool ControllerService::sendPWMCommand(const PWMTarget& target, float duty_cycle, float frequency,
388 uint32_t duration_ms) {
389 if (udp_socket_fd_ < 0)
390 return false;
391 if (!target.resolved())
392 return false;
393 const uint8_t channel = target.channel;
394
395 // Build packet — total 20 bytes for single command
396 uint8_t packet[SINGLE_CMD_PACKET_SIZE];
397 size_t offset = 0;
398
399 // ── Header: <BBI> ──────────────────────────────────────────────────
400 packet[offset++] = PACKET_TYPE_PWM; // packet_type = 10
401 packet[offset++] = COMMS_VERSION; // version = 0
402
403 // Timestamp: milliseconds since epoch, truncated to uint32
404 auto now_ms = std::chrono::duration_cast<std::chrono::milliseconds>(
405 std::chrono::system_clock::now().time_since_epoch())
406 .count();
407 uint32_t ts = static_cast<uint32_t>(now_ms & 0xFFFFFFFF);
408 std::memcpy(&packet[offset], &ts, 4); // LE on x86
409 offset += 4;
410
411 // ── Body: <B> num_commands ──────────────────────────────────────────
412 packet[offset++] = 1; // Single command
413
414 // ── Command: <BIff> ────────────────────────────────────────────────
415 packet[offset++] = channel; // actuator_id
416 std::memcpy(&packet[offset], &duration_ms, 4); // duration_ms (LE)
417 offset += 4;
418 std::memcpy(&packet[offset], &duty_cycle, 4); // duty_cycle (float LE)
419 offset += 4;
420 std::memcpy(&packet[offset], &frequency, 4); // frequency (float LE)
421 offset += 4;
422
423 // Sanity check
424 if (offset != SINGLE_CMD_PACKET_SIZE) {
425 std::cerr << "[ControllerService] ❌ Packet build error: wrote " << offset
426 << " bytes, expected " << SINGLE_CMD_PACKET_SIZE << std::endl;
427 return false;
428 }
429
430 // ── Send via UDP ───────────────────────────────────────────────────
431 struct sockaddr_in dest{};
432 dest.sin_family = AF_INET;
433 dest.sin_port = htons(pwm_config_.actuator_port);
434 if (inet_pton(AF_INET, target.board_ip.c_str(), &dest.sin_addr) != 1) {
435 std::cerr << "[ControllerService] ❌ Invalid actuator IP: " << target.board_ip << std::endl;
436 return false;
437 }
438
439 ssize_t sent = sendto(udp_socket_fd_, packet, SINGLE_CMD_PACKET_SIZE, 0,
440 reinterpret_cast<struct sockaddr*>(&dest), sizeof(dest));
441
442 if (sent != static_cast<ssize_t>(SINGLE_CMD_PACKET_SIZE)) {
443 std::cerr << "[ControllerService] ❌ sendto failed: " << strerror(errno) << " (sent "
444 << sent << "/" << SINGLE_CMD_PACKET_SIZE << ")" << std::endl;
445 return false;
446 }
447
448 return true;
449}
450
451bool ControllerService::sendPWMCommands(const PWMTarget& t1, float duty1, const PWMTarget& t2,
452 float duty2, float frequency, uint32_t duration_ms) {
453 if (udp_socket_fd_ < 0)
454 return false;
455 // One packet, one destination — the caller must have checked both targets share a board.
456 if (!t1.resolved() || !t2.resolved() || t1.board_ip != t2.board_ip)
457 return false;
458 const uint8_t channel1 = t1.channel;
459 const uint8_t channel2 = t2.channel;
460
461 // Actuator ids are 1-based, so 0 is the "unresolved" marker controller_main leaves behind when
462 // a PWM role is missing from [actuator_roles]. Refusing here is belt and braces next to the
463 // disabled fire gate: whatever else goes wrong, a packet is never addressed to channel 0 —
464 // and, more to the point, never to a guessed channel that belongs to some other valve.
465 if (channel1 == 0 || channel2 == 0)
466 return false;
467
468 uint8_t packet[DUAL_CMD_PACKET_SIZE];
469 size_t offset = 0;
470
471 packet[offset++] = PACKET_TYPE_PWM;
472 packet[offset++] = COMMS_VERSION;
473 auto now_ms = std::chrono::duration_cast<std::chrono::milliseconds>(
474 std::chrono::system_clock::now().time_since_epoch())
475 .count();
476 uint32_t ts = static_cast<uint32_t>(now_ms & 0xFFFFFFFF);
477 std::memcpy(&packet[offset], &ts, 4);
478 offset += 4;
479
480 packet[offset++] = 2; // num_commands
481
482 packet[offset++] = channel1;
483 std::memcpy(&packet[offset], &duration_ms, 4);
484 offset += 4;
485 std::memcpy(&packet[offset], &duty1, 4);
486 offset += 4;
487 std::memcpy(&packet[offset], &frequency, 4);
488 offset += 4;
489
490 packet[offset++] = channel2;
491 std::memcpy(&packet[offset], &duration_ms, 4);
492 offset += 4;
493 std::memcpy(&packet[offset], &duty2, 4);
494 offset += 4;
495 std::memcpy(&packet[offset], &frequency, 4);
496 offset += 4;
497
498 struct sockaddr_in dest{};
499 dest.sin_family = AF_INET;
500 dest.sin_port = htons(pwm_config_.actuator_port);
501 if (inet_pton(AF_INET, t1.board_ip.c_str(), &dest.sin_addr) != 1)
502 return false;
503
504 ssize_t sent = sendto(udp_socket_fd_, packet, DUAL_CMD_PACKET_SIZE, 0,
505 reinterpret_cast<struct sockaddr*>(&dest), sizeof(dest));
506 return sent == static_cast<ssize_t>(DUAL_CMD_PACKET_SIZE);
507}
508
510 if (!act.valid)
511 return;
512
513 float duty_F = static_cast<float>(std::max(0.0, std::min(1.0, act.duty_F)));
514 float duty_O = static_cast<float>(std::max(0.0, std::min(1.0, act.duty_O)));
515
516 // Same board → one packet, so both valves are commanded by a single datagram exactly as
517 // before. Different boards → two, because a datagram has one destination; the two commands
518 // are then no longer simultaneous, which is a property of the rig's wiring rather than a
519 // choice this code gets to make. Neither branch can address an unresolved target: the send
520 // helpers reject those, and the gate is disabled at startup anyway.
521 const bool same_board = pwm_config_.fuel.resolved() && pwm_config_.ox.resolved() &&
522 pwm_config_.fuel.board_ip == pwm_config_.ox.board_ip;
523 if (same_board) {
524 sendPWMCommands(pwm_config_.fuel, duty_F, pwm_config_.ox, duty_O, pwm_config_.frequency_hz,
525 pwm_config_.duration_ms);
526 } else {
527 sendPWMCommand(pwm_config_.fuel, duty_F, pwm_config_.frequency_hz, pwm_config_.duration_ms);
528 sendPWMCommand(pwm_config_.ox, duty_O, pwm_config_.frequency_hz, pwm_config_.duration_ms);
529 }
530}
531
532// ═══════════════════════════════════════════════════════════════════════
533// CONTROLLER LOOP
534// ═══════════════════════════════════════════════════════════════════════
535
537 const auto loop_interval =
538 std::chrono::microseconds(static_cast<int64_t>(loop_interval_ms_ * 1000.0));
539
540 int tick = 0;
541
542 while (running_) {
543 auto loop_start = std::chrono::steady_clock::now();
544
545 // ── Snapshot inputs ────────────────────────────────────────────
546 float td_f = test_duty_fuel_.load();
547 float td_o = test_duty_ox_.load();
548 const bool use_test_duty = (td_f > 0.0f || td_o > 0.0f);
549
553 bool have_data;
554 {
555 std::lock_guard<std::mutex> lock(input_mutex_);
556 meas = current_meas_;
557 cmd = current_cmd_;
559 have_data = has_measurement_;
560 }
561
562 // When only upstream (ch 1, 5) is present, P_d_fuel/P_d_ox stay 0 so engine estimate
563 // and diagnostics (MR, P_ch, F_est) are zero. Use upstream as fallback for injector
564 // pressure so the controller sees varying state instead of defaulting to fixed duty.
565 // This is an estimated fallback only — flag it in diagnostics if it triggers during FIRE.
566 constexpr double P_D_FROM_U_FRAC = 0.95;
567 bool p_d_fuel_fallback = false;
568 bool p_d_ox_fallback = false;
569 if (meas.P_d_fuel <= 0.0 && meas.P_u_fuel > 0.0) {
570 meas.P_d_fuel = meas.P_u_fuel * P_D_FROM_U_FRAC;
571 p_d_fuel_fallback = true;
572 }
573 if (meas.P_d_ox <= 0.0 && meas.P_u_ox > 0.0) {
574 meas.P_d_ox = meas.P_u_ox * P_D_FROM_U_FRAC;
575 p_d_ox_fallback = true;
576 }
577 if ((p_d_fuel_fallback || p_d_ox_fallback) && fire_active_.load() && tick % 50 == 0) {
578 std::cerr << "[ControllerService] ⚠️ Downstream PT fallback active during FIRE: "
579 << (p_d_fuel_fallback ? "P_d_fuel " : "")
580 << (p_d_ox_fallback ? "P_d_ox " : "")
581 << "— using 0.95 * upstream. Install downstream PTs for accurate control."
582 << std::endl;
583 }
584
585 // Require sensor data for DDP; test duty can send without it. In fire mode with LUT, run
586 // anyway so we always command duty (even 0) and report P_ch from chamber MP1/2 average.
587 const bool run_without_data = (fire_active_ && lut_.is_loaded());
588 if (!have_data && !use_test_duty && !run_without_data) {
589 if (tick == 0 || tick % 50 == 0) {
590 std::cout << "[ControllerService] ⏳ Waiting for sensor data from Elodin…"
591 << std::endl;
592 }
593 ++tick;
594 auto loop_end = std::chrono::steady_clock::now();
595 auto elapsed = loop_end - loop_start;
596 if (elapsed < loop_interval)
597 std::this_thread::sleep_for(loop_interval - elapsed);
598 continue;
599 }
600
603
604 if (use_test_duty) {
605 // Open-loop validation: bypass DDP, send fixed duty cycles (no sensor data required)
606 actuation.duty_F = td_f;
607 actuation.duty_O = td_o;
608 actuation.u_F_on = td_f > 0.0f;
609 actuation.u_O_on = td_o > 0.0f;
610 actuation.valid = true;
611 diagnostics.F_ref = (cmd.type == RobustDDPController::CommandType::THRUST_DESIRED)
612 ? cmd.thrust_desired
613 : 0.0;
614 } else if (lut_.is_loaded()) {
615 // LUT mode: policy LUT uses P_u_fuel, P_u_ox, thrust_desired, MR_ref.
616 // Optional P_ch for legacy 3D engine LUTs.
617 const double P_ch_est = (meas.P_ch_mp1 > 0.0 || meas.P_ch_mp2 > 0.0)
618 ? 0.5 * (meas.P_ch_mp1 + meas.P_ch_mp2)
619 : 0.0;
620
621 if (!fire_active_) {
622 actuation.duty_F = 0.0;
623 actuation.duty_O = 0.0;
624 actuation.u_F_on = false;
625 actuation.u_O_on = false;
626 actuation.valid = true;
627 diagnostics.F_ref = (cmd.type == RobustDDPController::CommandType::THRUST_DESIRED)
628 ? cmd.thrust_desired
629 : 0.0;
630 diagnostics.F_estimated = 0.0;
631 diagnostics.MR_estimated = 0.0;
632 diagnostics.P_ch = 0.0;
633 } else {
634 std::map<std::string, double> point;
635 point["P_u_fuel"] = meas.P_u_fuel;
636 point["P_u_ox"] = meas.P_u_ox;
637 double thrust_ref = (cmd.type == RobustDDPController::CommandType::THRUST_DESIRED)
638 ? cmd.thrust_desired
639 : 0.0;
642 auto t_elapsed = std::chrono::steady_clock::now() - fire_start_time_;
643 double t_s = std::chrono::duration<double>(t_elapsed).count();
644 thrust_ref = interpolateThrustCurve(t_s);
645 }
646 point["thrust_desired"] = thrust_ref;
647 point["MR_ref"] = 2.2; // Default O/F target (mid-band)
648 if (P_ch_est > 0.0)
649 point["P_ch"] = P_ch_est; // For legacy 3D engine LUTs
650
651 std::map<std::string, double> lut_out;
652 if (lut_.evaluate(point, lut_out)) {
653 double u_f = lut_out.count("u_safe_F") ? lut_out.at("u_safe_F")
654 : lut_out.count("duty_F") ? lut_out.at("duty_F")
655 : 0.0;
656 double u_o = lut_out.count("u_safe_O") ? lut_out.at("u_safe_O")
657 : lut_out.count("duty_O") ? lut_out.at("duty_O")
658 : 0.0;
659 // Use continuous duty (0-1) for PWM; clamp to valid range
660 actuation.duty_F = std::clamp(u_f, 0.0, 1.0);
661 actuation.duty_O = std::clamp(u_o, 0.0, 1.0);
662 actuation.u_F_on = actuation.duty_F > 0.01;
663 actuation.u_O_on = actuation.duty_O > 0.01;
664 actuation.valid = true;
665
666 diagnostics.F_ref = thrust_ref;
667 diagnostics.P_ch = P_ch_est;
668 constexpr double P_MIN_VALID = 1e5;
669 if (meas.P_u_fuel >= P_MIN_VALID && meas.P_u_ox >= P_MIN_VALID) {
670 if (lut_out.count("F"))
671 diagnostics.F_estimated = lut_out.at("F");
672 if (lut_out.count("MR"))
673 diagnostics.MR_estimated = lut_out.at("MR");
674 } else {
675 diagnostics.F_estimated = 0.0;
676 diagnostics.MR_estimated = 0.0;
677 }
678 } else {
679 // LUT miss or out-of-range: still command 0/0 and report P_ch so fire mode
680 // always outputs
681 actuation.duty_F = 0.0;
682 actuation.duty_O = 0.0;
683 actuation.u_F_on = false;
684 actuation.u_O_on = false;
685 actuation.valid = true;
686 diagnostics.F_ref = thrust_ref;
687 diagnostics.P_ch = P_ch_est;
688 diagnostics.F_estimated = 0.0;
689 diagnostics.MR_estimated = 0.0;
690 }
691 }
692 } else {
693 auto [a, d] = controller_->step(meas, nav, cmd);
694 actuation = a;
695 diagnostics = d;
696 }
697
698 // ── Send PWM output every tick while FIRE is active ───────────
699 if (fire_active_.load() && actuation.valid) {
700 sendActuationPWM(actuation);
701 }
702
703 // ── Store outputs for external readers ─────────────────────────
704 {
705 std::lock_guard<std::mutex> lock(output_mutex_);
706 last_actuation_ = actuation;
707 last_diagnostics_ = diagnostics;
708 }
709
710 // ── Log to console (10% sample) ────────────────────────────────
711 if (tick % 10 == 0) {
712 // Diagnostic: when F_est=0 and fire active, log pressure state to debug missing PT data
713 if (fire_active_.load() && diagnostics.F_estimated == 0.0 && tick % 100 == 0) {
714 std::cerr << "[Controller] ⚠️ F_est=0: P_u_fuel=" << meas.P_u_fuel / 6894.76
715 << " psi, P_u_ox=" << meas.P_u_ox / 6894.76
716 << " psi (need both ≥14.5 psi). Check PT ch1/ch5." << std::endl;
717 }
718 std::cout << "[Controller] tick=" << tick << " duty_F=" << actuation.duty_F
719 << " duty_O=" << actuation.duty_O << " F_ref=" << diagnostics.F_ref
720 << " F_est=" << diagnostics.F_estimated << " MR=" << diagnostics.MR_estimated
721 << " P_ch=" << diagnostics.P_ch
722 << (diagnostics.safety_filtered ? " [FILTERED]" : "")
723 << (diagnostics.cutoff_active ? " [CUTOFF]" : "") << std::endl;
724 }
725
726 // ── Write to Elodin DB (optional) ──────────────────────────────
727 if (elodin_connected_) {
728 writeActuationToDB(actuation);
729 writeDiagnosticsToDB(diagnostics);
730 if (tick % 10 == 0) { // Don't spam DB with measurements
732 }
733 // Drain any responses Elodin DB sends back — without this the recv
734 // buffer fills, Elodin DB applies TCP backpressure, and our send
735 // buffer grows unboundedly until the write blocks.
736 {
737 std::array<uint8_t, 4096> drain_buf;
738 while (elodin_client_->read_data(drain_buf.data(), drain_buf.size()) > 0) {
739 }
740 }
741 }
742
743 ++tick;
744
745 // ── Sleep to maintain loop rate ────────────────────────────────
746 auto loop_end = std::chrono::steady_clock::now();
747 auto elapsed = loop_end - loop_start;
748 if (elapsed < loop_interval) {
749 std::this_thread::sleep_for(loop_interval - elapsed);
750 }
751 }
752}
753
754/**
755 * Connect the subscriber and subscribe to everything it reads.
756 *
757 * Both halves must run on every reconnect, not just the first connect: the VTables live in the db
758 * process, so a db restart loses them and a reconnect that skips re-subscription comes back
759 * permanently silent. SequencerService::tryConnectElodin() documents the same rule for the
760 * publishing side, and heartbeat_service treats a failed subscribe as a failed connection for
761 * exactly this reason — so this returns false and lets the caller retry the whole thing.
762 */
764 if (elodin_host_.empty())
765 return false;
767 return false;
768
769 // Calibrated PT tables for all board-number slots.
770 // low byte encoding: (board_number-1)*0x20 + 0x10 + channel
771 std::vector<std::pair<uint8_t, uint8_t>> tables;
772 for (uint8_t board_number = 1; board_number <= 10; ++board_number) {
773 const uint8_t base = static_cast<uint8_t>((board_number - 1) * 0x20);
774 for (uint8_t ch = 1; ch <= 10; ++ch)
775 tables.push_back({0x20, static_cast<uint8_t>(base + 0x10 + ch)});
776 }
777 // Sequencer state stream [0x50,0x00] for FIRE gate parity.
778 tables.push_back({0x50, 0x00});
779 if (!elodin_subscriber_->subscribe_tables(tables)) {
780 std::cerr << "[ControllerService] ⚠️ Elodin subscribe failed — dropping the connection "
781 "so it is retried rather than sitting on a silent socket"
782 << std::endl;
783 elodin_subscriber_->disconnect();
784 return false;
785 }
786
787 std::cout << "[ControllerService] ✅ Elodin subscriber connected — "
788 "subscribed to calibrated PT + Sequencer state"
789 << std::endl;
790 return true;
791}
792
794 std::cout
795 << "[ControllerService] 🎧 Elodin subscriber loop started (dedicated subscriber client)."
796 << std::endl;
797 std::vector<uint8_t> rx_buffer(8192);
798
799 // Outer loop: reconnect. This used to be absent — the thread started once and its inner loop
800 // exited the moment is_connected() went false, so a single db restart or network blip left
801 // the controller deaf to sequencer state for the life of the process, with nothing but one
802 // log line to say so. That matters because this path is the FIRE-gate parity fallback: it is
803 // what closes the PWM gate if the TCP command path is not working.
804 while (running_) {
805 if (!elodin_subscriber_->is_connected()) {
806 if (!connectSubscriber()) {
807 for (int i = 0; i < 20 && running_; ++i)
808 std::this_thread::sleep_for(std::chrono::milliseconds(100));
809 continue;
810 }
811 }
812 subscriberReadLoop(rx_buffer);
813 if (running_)
814 std::cerr << "[ControllerService] ⚠️ Elodin subscriber disconnected — reconnecting"
815 << std::endl;
816 }
817 std::cout << "[ControllerService] 🎧 Elodin subscriber loop stopped." << std::endl;
818}
819
820void ControllerService::subscriberReadLoop(std::vector<uint8_t>& rx_buffer) {
821 while (running_ && elodin_subscriber_->is_connected()) {
822 // 8-byte Elodin header: len(4) ty(1) id_hi(1) id_lo(1) req_id(1)
823 uint8_t header[8];
824 if (!elodin_subscriber_->read_packet_header(header)) {
825 std::this_thread::sleep_for(std::chrono::milliseconds(1));
826 continue;
827 }
828
829 // packet_len counts bytes after the 4-byte len field, so payload = packet_len - 4
830 uint32_t packet_len = *reinterpret_cast<uint32_t*>(header);
831 if (packet_len < 4 || packet_len > 65536) {
832 std::this_thread::sleep_for(std::chrono::milliseconds(1));
833 continue;
834 }
835 size_t payload_len = packet_len - 4;
836
837 if (payload_len + 8 > rx_buffer.size()) {
838 rx_buffer.resize(payload_len + 8);
839 }
840
841 std::memcpy(rx_buffer.data(), header, 8);
842
843 if (payload_len > 0) {
844 // Use blocking read_bytes_exact so we never misalign on a partial payload.
845 // The non-blocking read_data would return 0 if bytes aren't in the socket
846 // buffer yet, causing the next read_packet_header to consume payload bytes
847 // as a header and corrupt all subsequent reads.
848 if (!elodin_subscriber_->read_bytes_exact(rx_buffer.data() + 8, payload_len))
849 continue;
850 }
851
852 uint8_t type_hi = header[5];
853 uint8_t pid_lo = header[6];
854
855 // Sequencer state [0x50,0x00] -> keep controller FIRE gate in parity even
856 // if TCP FIRE_START/FIRE_STOP control path misses an edge.
857 if (type_hi == 0x50 && pid_lo == 0x00 && payload_len >= 17) {
858 uint8_t state_val = rx_buffer[8 + 8];
859 // Parity fallback for a missed TCP FIRE_START/FIRE_STOP edge. The id comes from
860 // config ([fire] state) via setFireStateId rather than a literal, so renumbering
861 // states cannot move the PWM ignition gate onto a different one.
862 //
863 // Skipped entirely when the gate is disabled — setFireActive() would refuse anyway,
864 // but this branch runs at packet rate and there is no reason to call into it
865 // continuously just to be turned away. The `continue` stays either way so the packet
866 // is still consumed rather than falling through to the PT decoders below.
867 if (fire_gate_enabled_.load())
868 setFireActive(state_val == fire_state_id_);
869 continue;
870 }
871
872 // 0x20 is PT category; calibrated channels for the low-pressure PT board (#1,
873 // board_id=21 → board_number=1) are encoded as pid_lo in 0x11..0x1A.
874 // Do NOT mask off board bits here (e.g. &0x1F) or we will mis-map HP PT
875 // channels into Fuel/Ox channels and the controller will sit on zeros.
876 if (type_hi == 0x20) {
877 if (pid_lo < 0x11 || pid_lo > 0x1A)
878 continue;
879
881 uint8_t* payload = rx_buffer.data() + 8;
882
883 // Deserialize using CommsMessage — matches FSW pattern
885 cal_msg.deserialize(payload);
886
887 float pressure_psi = cal_msg.getField<3>(); // calibrated_pressure_psi
888 uint8_t ch = cal_msg.getField<1>(); // channel_id from message
889 if (ch == 0)
890 ch = static_cast<uint8_t>(pid_lo - 0x10); // fallback to header
891
892 // Map based on PT names derived from config.toml (ch 4/8 = Chamber Mid 1/2).
893 std::lock_guard<std::mutex> lock(input_mutex_);
894 if (ch == 1)
895 current_meas_.P_u_fuel = pressure_psi * 6894.76;
896 else if (ch == 5)
897 current_meas_.P_u_ox = pressure_psi * 6894.76;
898 else if (ch == 3)
899 current_meas_.P_d_fuel = pressure_psi * 6894.76;
900 else if (ch == 7)
901 current_meas_.P_d_ox = pressure_psi * 6894.76;
902 else if (ch == 6) {
903 current_meas_.P_reg = pressure_psi * 6894.76;
904 current_meas_.P_copv = pressure_psi * 6894.76;
905 } else if (ch == 4)
906 current_meas_.P_ch_mp1 = pressure_psi * 6894.76;
907 else if (ch == 8)
908 current_meas_.P_ch_mp2 = pressure_psi * 6894.76;
909
910 current_meas_.timestamp = std::chrono::steady_clock::now();
911 has_measurement_ = true;
912 }
913 }
914 }
915}
916
917// ═══════════════════════════════════════════════════════════════════════
918// ELODIN DB WRITES (optional — only compiled if HAS_ELODIN)
919// ═══════════════════════════════════════════════════════════════════════
920
921template <typename T>
922static bool send_msg(fsw::elodin::ElodinClient& client, T msg) {
923 auto buf = Msg(msg).encode_vec();
924 if (buf.empty())
925 return false;
926 return client.send_msg({0, 0}, buf);
927}
928
931 return false;
932
933 auto actuation_vt = builder::vtable({
934 raw_field(0, 8,
935 schema(PrimType::U64(), {}, component("CONTROLLER.actuation.timestamp_ns"))),
936 raw_field(8, 4, schema(PrimType::F32(), {}, component("CONTROLLER.actuation.duty_F"))),
937 raw_field(12, 4, schema(PrimType::F32(), {}, component("CONTROLLER.actuation.duty_O"))),
938 raw_field(16, 1, schema(PrimType::U8(), {}, component("CONTROLLER.actuation.u_F_on"))),
939 raw_field(17, 1, schema(PrimType::U8(), {}, component("CONTROLLER.actuation.u_O_on"))),
940 raw_field(18, 1, schema(PrimType::U8(), {}, component("CONTROLLER.actuation.valid"))),
941 });
942
943 // Field order matches ControllerDiagnosticsMessage (tight-packed, aligned):
944 // U64@0 + 6×F64@8..55 + I32@56 (4-byte aligned) + U8@60 + U8@61 = 62 bytes
945 auto diagnostics_vt = builder::vtable({
946 raw_field(0, 8,
947 schema(PrimType::U64(), {}, component("CONTROLLER.diagnostics.timestamp_ns"))),
948 raw_field(8, 8, schema(PrimType::F64(), {}, component("CONTROLLER.diagnostics.F_ref"))),
949 raw_field(16, 8, schema(PrimType::F64(), {}, component("CONTROLLER.diagnostics.MR_ref"))),
950 raw_field(24, 8,
951 schema(PrimType::F64(), {}, component("CONTROLLER.diagnostics.F_estimated"))),
952 raw_field(32, 8,
953 schema(PrimType::F64(), {}, component("CONTROLLER.diagnostics.MR_estimated"))),
954 raw_field(40, 8, schema(PrimType::F64(), {}, component("CONTROLLER.diagnostics.P_ch"))),
955 raw_field(48, 8, schema(PrimType::F64(), {}, component("CONTROLLER.diagnostics.cost"))),
956 raw_field(56, 4,
957 schema(PrimType::I32(), {}, component("CONTROLLER.diagnostics.solver_iters"))),
958 raw_field(60, 1,
959 schema(PrimType::U8(), {}, component("CONTROLLER.diagnostics.safety_filtered"))),
960 raw_field(61, 1,
961 schema(PrimType::U8(), {}, component("CONTROLLER.diagnostics.cutoff_active"))),
962 });
963
964 auto measurement_vt = builder::vtable({
965 raw_field(0, 8,
966 schema(PrimType::U64(), {}, component("CONTROLLER.measurement.timestamp_ns"))),
967 raw_field(8, 8, schema(PrimType::F64(), {}, component("CONTROLLER.measurement.P_copv"))),
968 raw_field(16, 8, schema(PrimType::F64(), {}, component("CONTROLLER.measurement.P_reg"))),
969 raw_field(24, 8, schema(PrimType::F64(), {}, component("CONTROLLER.measurement.P_u_fuel"))),
970 raw_field(32, 8, schema(PrimType::F64(), {}, component("CONTROLLER.measurement.P_u_ox"))),
971 raw_field(40, 8, schema(PrimType::F64(), {}, component("CONTROLLER.measurement.P_d_fuel"))),
972 raw_field(48, 8, schema(PrimType::F64(), {}, component("CONTROLLER.measurement.P_d_ox"))),
973 raw_field(56, 8, schema(PrimType::F64(), {}, component("CONTROLLER.measurement.P_ch_mp1"))),
974 raw_field(64, 8, schema(PrimType::F64(), {}, component("CONTROLLER.measurement.P_ch_mp2"))),
975 });
976
977 // PSM state transition [0x43, 0x00]: U64(8) + U8(1) + U8(1) + U8(1) = 11 bytes
978 auto state_trans_vt = builder::vtable({
979 raw_field(0, 8, schema(PrimType::U64(), {}, component("CONTROLLER.state.timestamp_ns"))),
980 raw_field(8, 1, schema(PrimType::U8(), {}, component("CONTROLLER.state.from_state"))),
981 raw_field(9, 1, schema(PrimType::U8(), {}, component("CONTROLLER.state.to_state"))),
982 raw_field(10, 1, schema(PrimType::U8(), {}, component("CONTROLLER.state.reason"))),
983 });
984
985 // Fire state event [0x44, 0x00]: U64(8) + U8(1) + F32(4) + F32(4) = 17 bytes
986 auto fire_state_vt = builder::vtable({
987 raw_field(0, 8, schema(PrimType::U64(), {}, component("CONTROLLER.fire.timestamp_ns"))),
988 raw_field(8, 1, schema(PrimType::U8(), {}, component("CONTROLLER.fire.fire_active"))),
989 raw_field(9, 4, schema(PrimType::F32(), {}, component("CONTROLLER.fire.duty_F"))),
990 raw_field(13, 4, schema(PrimType::F32(), {}, component("CONTROLLER.fire.duty_O"))),
991 });
992
993 send_msg(*elodin_client_, VTableMsg{.id = std::make_tuple(uint8_t(0x40), uint8_t(0x00)),
994 .vtable = actuation_vt});
995 send_msg(*elodin_client_, VTableMsg{.id = std::make_tuple(uint8_t(0x41), uint8_t(0x00)),
996 .vtable = diagnostics_vt});
997 send_msg(*elodin_client_, VTableMsg{.id = std::make_tuple(uint8_t(0x42), uint8_t(0x00)),
998 .vtable = measurement_vt});
999 send_msg(*elodin_client_, VTableMsg{.id = std::make_tuple(uint8_t(0x43), uint8_t(0x00)),
1000 .vtable = state_trans_vt});
1001 send_msg(*elodin_client_, VTableMsg{.id = std::make_tuple(uint8_t(0x44), uint8_t(0x00)),
1002 .vtable = fire_state_vt});
1003
1004 // Send component names so Elodin exports with strings instead of schema hashes
1005 std::vector<std::string> actuation_fields = {
1006 "CONTROLLER.actuation.timestamp_ns", "CONTROLLER.actuation.duty_F",
1007 "CONTROLLER.actuation.duty_O", "CONTROLLER.actuation.u_F_on",
1008 "CONTROLLER.actuation.u_O_on", "CONTROLLER.actuation.valid"};
1009 std::vector<std::string> diagnostics_fields = {"CONTROLLER.diagnostics.timestamp_ns",
1010 "CONTROLLER.diagnostics.F_ref",
1011 "CONTROLLER.diagnostics.MR_ref",
1012 "CONTROLLER.diagnostics.F_estimated",
1013 "CONTROLLER.diagnostics.MR_estimated",
1014 "CONTROLLER.diagnostics.P_ch",
1015 "CONTROLLER.diagnostics.cost",
1016 "CONTROLLER.diagnostics.solver_iters",
1017 "CONTROLLER.diagnostics.safety_filtered",
1018 "CONTROLLER.diagnostics.cutoff_active"};
1019 std::vector<std::string> measurement_fields = {
1020 "CONTROLLER.measurement.timestamp_ns", "CONTROLLER.measurement.P_copv",
1021 "CONTROLLER.measurement.P_reg", "CONTROLLER.measurement.P_u_fuel",
1022 "CONTROLLER.measurement.P_u_ox", "CONTROLLER.measurement.P_d_fuel",
1023 "CONTROLLER.measurement.P_d_ox", "CONTROLLER.measurement.P_ch_mp1",
1024 "CONTROLLER.measurement.P_ch_mp2"};
1025 std::vector<std::string> state_fields = {
1026 "CONTROLLER.state.timestamp_ns", "CONTROLLER.state.from_state", "CONTROLLER.state.to_state",
1027 "CONTROLLER.state.reason"};
1028 std::vector<std::string> fire_fields = {"CONTROLLER.fire.timestamp_ns",
1029 "CONTROLLER.fire.fire_active", "CONTROLLER.fire.duty_F",
1030 "CONTROLLER.fire.duty_O"};
1031
1032 for (const auto& f : actuation_fields)
1033 send_msg(*elodin_client_, set_component_name(f));
1034 for (const auto& f : diagnostics_fields)
1035 send_msg(*elodin_client_, set_component_name(f));
1036 for (const auto& f : measurement_fields)
1037 send_msg(*elodin_client_, set_component_name(f));
1038 for (const auto& f : state_fields)
1039 send_msg(*elodin_client_, set_component_name(f));
1040 for (const auto& f : fire_fields)
1041 send_msg(*elodin_client_, set_component_name(f));
1042
1043 send_msg(*elodin_client_, set_entity_name(0x4000, "CONTROLLER.actuation"));
1044 send_msg(*elodin_client_, set_entity_name(0x4100, "CONTROLLER.diagnostics"));
1045 send_msg(*elodin_client_, set_entity_name(0x4200, "CONTROLLER.measurement"));
1046 send_msg(*elodin_client_, set_entity_name(0x4300, "CONTROLLER.state"));
1047 send_msg(*elodin_client_, set_entity_name(0x4400, "CONTROLLER.fire"));
1048
1049 std::cout << "[ControllerService] ✅ Registered controller tables with Elodin DB" << std::endl;
1050 return true;
1051}
1052
1054 constexpr uint16_t ACTUATION_MESSAGE_ID = 0x4000;
1055
1057 std::get<0>(msg.fields) = std::chrono::duration_cast<std::chrono::nanoseconds>(
1058 std::chrono::system_clock::now().time_since_epoch())
1059 .count();
1060 std::get<1>(msg.fields) = static_cast<float>(actuation.duty_F);
1061 std::get<2>(msg.fields) = static_cast<float>(actuation.duty_O);
1062 std::get<3>(msg.fields) = actuation.u_F_on ? 1 : 0;
1063 std::get<4>(msg.fields) = actuation.u_O_on ? 1 : 0;
1064 std::get<5>(msg.fields) = actuation.valid ? 1 : 0;
1065
1066 elodin_client_->publish(ACTUATION_MESSAGE_ID, msg);
1067}
1068
1070 constexpr uint16_t DIAGNOSTICS_MESSAGE_ID = 0x4100;
1071
1073 std::get<0>(msg.fields) = std::chrono::duration_cast<std::chrono::nanoseconds>(
1074 std::chrono::system_clock::now().time_since_epoch())
1075 .count();
1076 std::get<1>(msg.fields) = diagnostics.F_ref;
1077 std::get<2>(msg.fields) = diagnostics.MR_ref;
1078 std::get<3>(msg.fields) = diagnostics.F_estimated;
1079 std::get<4>(msg.fields) = diagnostics.MR_estimated;
1080 std::get<5>(msg.fields) = diagnostics.P_ch;
1081 std::get<6>(msg.fields) = diagnostics.cost;
1082 std::get<7>(msg.fields) = static_cast<int32_t>(diagnostics.solver_iters);
1083 std::get<8>(msg.fields) = diagnostics.safety_filtered ? 1 : 0;
1084 std::get<9>(msg.fields) = diagnostics.cutoff_active ? 1 : 0;
1085
1086 elodin_client_->publish(DIAGNOSTICS_MESSAGE_ID, msg);
1087}
1088
1090 constexpr uint16_t MEASUREMENT_MESSAGE_ID = 0x4200;
1091
1093 auto ts_ns = std::chrono::duration_cast<std::chrono::nanoseconds>(
1094 std::chrono::system_clock::now().time_since_epoch())
1095 .count();
1096 std::get<0>(msg.fields) = ts_ns;
1097 std::get<1>(msg.fields) = measurement.P_copv;
1098 std::get<2>(msg.fields) = measurement.P_reg;
1099 std::get<3>(msg.fields) = measurement.P_u_fuel;
1100 std::get<4>(msg.fields) = measurement.P_u_ox;
1101 std::get<5>(msg.fields) = measurement.P_d_fuel;
1102 std::get<6>(msg.fields) = measurement.P_d_ox;
1103 std::get<7>(msg.fields) = measurement.P_ch_mp1;
1104 std::get<8>(msg.fields) = measurement.P_ch_mp2;
1105
1106 elodin_client_->publish(MEASUREMENT_MESSAGE_ID, msg);
1107}
1108
1109// ═══════════════════════════════════════════════════════════════════════
1110// THRUST CURVE (Layer 2 pressure curve → time-varying target)
1111// ═══════════════════════════════════════════════════════════════════════
1112
1114 thrust_curve_times_.clear();
1115 thrust_curve_values_.clear();
1116
1117 std::ifstream f(path);
1118 if (!f.is_open())
1119 return false;
1120
1121 std::string line;
1122 if (!std::getline(f, line) || line.find("time_s") == std::string::npos)
1123 return false; // Expect header "time_s,thrust_N"
1124
1125 while (std::getline(f, line)) {
1126 if (line.empty())
1127 continue;
1128 std::istringstream iss(line);
1129 double t, F;
1130 char comma;
1131 if (iss >> t >> comma >> F) {
1132 thrust_curve_times_.push_back(t);
1133 thrust_curve_values_.push_back(F);
1134 }
1135 }
1136 return thrust_curve_times_.size() >= 2;
1137}
1138
1139double ControllerService::interpolateThrustCurve(double t_elapsed_s) const {
1140 if (thrust_curve_times_.empty() || thrust_curve_values_.empty())
1141 return 0.0;
1142
1143 if (t_elapsed_s <= thrust_curve_times_.front())
1144 return thrust_curve_values_.front();
1145 if (t_elapsed_s >= thrust_curve_times_.back())
1146 return thrust_curve_values_.back();
1147
1148 for (size_t i = 0; i + 1 < thrust_curve_times_.size(); ++i) {
1149 double t0 = thrust_curve_times_[i];
1150 double t1 = thrust_curve_times_[i + 1];
1151 if (t_elapsed_s >= t0 && t_elapsed_s <= t1) {
1152 double alpha = (t_elapsed_s - t0) / (t1 - t0);
1153 return thrust_curve_values_[i] * (1.0 - alpha) + thrust_curve_values_[i + 1] * alpha;
1154 }
1155 }
1156 return thrust_curve_values_.back();
1157}
1158
1159} // namespace control
1160} // namespace fsw
std::unique_ptr< elodin::ElodinClient > elodin_subscriber_
std::chrono::steady_clock::time_point fire_start_time_
std::unique_ptr< RobustDDPController > controller_
void stop()
Stop the controller loop.
std::unique_ptr< elodin::ElodinClient > elodin_client_
void writeActuationToDB(const RobustDDPController::ActuationCommand &actuation)
void setNavState(const RobustDDPController::NavState &nav)
std::vector< double > thrust_curve_times_
RobustDDPController::NavState current_nav_
void sendActuationPWM(const RobustDDPController::ActuationCommand &act)
Send the fuel + ox PWM commands for a given actuation output.
RobustDDPController::ActuationCommand last_actuation_
void setMeasurement(const RobustDDPController::Measurement &meas)
void setFireActive(bool active)
Enable or disable PWM output (FIRE state gate).
double interpolateThrustCurve(double t_elapsed_s) const
bool connectSubscriber()
Connect the subscriber and subscribe to the tables it reads.
RobustDDPController::Measurement current_meas_
void writeMeasurementToDB(const RobustDDPController::Measurement &measurement)
bool start(double loop_rate_hz=10.0)
Start the controller loop at the given rate.
void subscriberReadLoop(std::vector< uint8_t > &rx_buffer)
The read half, one connection's worth.
bool loadThrustCurve(const std::string &path)
RobustDDPController::Diagnostics getLastDiagnostics() const
void writeDiagnosticsToDB(const RobustDDPController::Diagnostics &diagnostics)
void setCommand(const RobustDDPController::Command &cmd)
bool sendPWMCommands(const PWMTarget &t1, float duty1, const PWMTarget &t2, float duty2, float frequency, uint32_t duration_ms)
Single UDP packet carrying both channels.
bool sendPWMCommand(const PWMTarget &target, float duty_cycle, float frequency, uint32_t duration_ms)
Send a PWM actuator command over UDP.
RobustDDPController::Diagnostics last_diagnostics_
void setTestDuty(float fuel, float ox)
Override controller output with fixed duty cycles for open-loop validation.
RobustDDPController::Command current_cmd_
bool initialize(const PWMConfig &pwm_config, const RobustDDPController::Config &controller_config, const std::string &elodin_host="", uint16_t elodin_port=2240, const std::string &lut_path="", const std::string &thrust_curve_path="")
Initialize the controller service.
RobustDDPController::ActuationCommand getLastActuation() const
Get last actuation output (for diagnostics / GUI broadcast).
std::vector< double > thrust_curve_values_
Robust Hybrid Optimal Controller.
Standalone Elodin database client.
long target
Definition main.cpp:38
*brief create JSON pointer *sa reference_tokens const string_t & a
Definition json.hpp:13877
static osMutexId_t lock
Definition mode.c:11
CommsMessage< uint64_t, double, double, double, double, double, double, double, double > ControllerMeasurementMessage
Controller measurement input message.
CommsMessage< uint64_t, double, double, double, double, double, double, int32_t, uint8_t, uint8_t > ControllerDiagnosticsMessage
Controller diagnostics message.
CommsMessage< uint64_t, float, float, uint8_t, uint8_t, uint8_t > ControllerActuationMessage
Controller actuation command message.
CommsMessage< uint64_t, uint8_t, float, float > ControllerFireStateMessage
Fire state / PWM event message [0x44, 0x00].
CommsMessage< uint64_t, uint8_t, std::array< uint8_t, 3 >, float, uint32_t, uint8_t > CalibratedPTMessage
Calibrated Pressure Transducer message for Elodin.
static constexpr uint8_t COMMS_VERSION
static constexpr size_t SINGLE_CMD_PACKET_SIZE
static constexpr uint8_t PACKET_TYPE_PWM
static constexpr size_t HEADER_SIZE
static bool send_msg(fsw::elodin::ElodinClient &client, T msg)
static constexpr size_t BODY_SIZE
static constexpr size_t CMD_SIZE
static constexpr size_t DUAL_CMD_PACKET_SIZE
bool bindToDaqInterface(int sock, const std::string &address, const char *who)
bind() an already-created socket to address on an ephemeral port.
Motorized ball valve position: raw ADC counts -> % open.
STL namespace.
static bool elapsed(uint32_t now, uint32_t then, uint32_t interval)
Definition self_test.c:10
void deserialize(const uint8_t *buffer)
std::tuple< Fields... > fields
Where one propellant's PWM commands go.
static SlugTables tables()
The tables every case validates against.
static std::string ch(int ain, const std::string &kind, const std::string &extra="", const std::string &board="lj")
Definition test_t7.cpp:98