From cdfd1fc01f3d57716dcbbbd69fef9a8116594cb5 Mon Sep 17 00:00:00 2001 From: Aidan Rickert Date: Mon, 14 Sep 2026 13:43:49 -0700 Subject: [PATCH 01/13] daq: drop the dead localStorage load-cell zero offset that still biased two plot pages --- .../frontend/app/plots/chamber/page.tsx | 2 +- .../frontend/app/plots/lcs-tcs-rtd/page.tsx | 2 +- .../diablo_server/frontend/lib/store.ts | 36 ++----------------- 3 files changed, 4 insertions(+), 36 deletions(-) diff --git a/daq-server/diablo_server/frontend/app/plots/chamber/page.tsx b/daq-server/diablo_server/frontend/app/plots/chamber/page.tsx index 1c14f756..732314e6 100644 --- a/daq-server/diablo_server/frontend/app/plots/chamber/page.tsx +++ b/daq-server/diablo_server/frontend/app/plots/chamber/page.tsx @@ -47,7 +47,7 @@ function TcTempCompact({ calEntity, label, color }: { entity: string; calEntity: } function LcKgCompact({ calEntity, label, color }: { entity: string; calEntity: string; label: string; color: string }) { - const value = useLoadCellForceKg(calEntity); // offset already applied in store, C++ outputs kg + const value = useLoadCellForceKg(calEntity); // absolute kg from the calibration service const display = value !== null && Number.isFinite(value) ? value.toFixed(1) : '—'; return (
diff --git a/daq-server/diablo_server/frontend/app/plots/lcs-tcs-rtd/page.tsx b/daq-server/diablo_server/frontend/app/plots/lcs-tcs-rtd/page.tsx index d4e0fb7b..43b5a452 100644 --- a/daq-server/diablo_server/frontend/app/plots/lcs-tcs-rtd/page.tsx +++ b/daq-server/diablo_server/frontend/app/plots/lcs-tcs-rtd/page.tsx @@ -125,7 +125,7 @@ function LCForceReadout({ }: { entity: string; calEntity: string; label: string; color: string; }) { - const value = useLoadCellForceKg(calEntity); // offset already applied in store, C++ outputs kg + const value = useLoadCellForceKg(calEntity); // absolute kg from the calibration service return ; } diff --git a/daq-server/diablo_server/frontend/lib/store.ts b/daq-server/diablo_server/frontend/lib/store.ts index 192a6622..aa6bc586 100644 --- a/daq-server/diablo_server/frontend/lib/store.ts +++ b/daq-server/diablo_server/frontend/lib/store.ts @@ -76,8 +76,6 @@ interface SensorSystemState { boards: Record; /** From config [adc]; used by sense conversions (TC ref, actuator threshold). */ voltageRefNominals: VoltageRefNominals; - /** Load cell zero offsets (lbf) by cal entity e.g. LC_Cal.CH1. Display = raw_lbf - offset. Persisted to localStorage. */ - loadCellZeroOffsets: Record; notifications: NotificationEntry[]; /** Per-board live diagnostic log lines (ring buffer; accumulates while app is open). */ boardLogs: Record; @@ -85,7 +83,6 @@ interface SensorSystemState { boardLogStats: Record; updateSensor: (update: SensorUpdate) => void; - setLoadCellZeroOffset: (calEntity: string, offsetLbf: number | null) => void; updateActuator: (update: ActuatorUpdate) => void; setActuatorState: (entity: string, state: ActuatorState) => void; setActuatorCommandedOverride: (entity: string, state: ActuatorState | null) => void; @@ -111,19 +108,6 @@ interface SensorSystemState { clearPressureHistoryHidden: () => void; } -const LC_ZERO_STORAGE_KEY = 'sensor_system_loadCellZeroOffsets'; - -function loadStoredLcZeroOffsets(): Record { - try { - const raw = typeof localStorage !== 'undefined' ? localStorage.getItem(LC_ZERO_STORAGE_KEY) : null; - if (raw) { - const parsed = JSON.parse(raw) as Record; - if (parsed && typeof parsed === 'object') return parsed; - } - } catch (_) { /* ignore */ } - return {}; -} - // ── Dynamic alias system ───────────────────────────────────────────────────── // Data flows with generic TYPE.CH entity names. Components can still reference // named entities (e.g. PT_Cal.Fuel_Upstream) — the alias system resolves them @@ -417,7 +401,6 @@ export const useSensorStore = create((set, get) => ({ boardLogs: {}, boardLogStats: {}, voltageRefNominals: { internalV: 2.5, absolute5vV: 5 }, - loadCellZeroOffsets: loadStoredLcZeroOffsets(), notifications: [], pressureHistoryHiddenEntities: {}, @@ -438,20 +421,6 @@ export const useSensorStore = create((set, get) => ({ clearPressureHistoryHidden: () => set({ pressureHistoryHiddenEntities: {} }), - setLoadCellZeroOffset: (calEntity: string, offsetLbf: number | null) => { - set((s) => { - const next = { ...s.loadCellZeroOffsets }; - if (offsetLbf == null) delete next[calEntity]; - else next[calEntity] = offsetLbf; - if (typeof localStorage !== 'undefined') { - try { - localStorage.setItem(LC_ZERO_STORAGE_KEY, JSON.stringify(next)); - } catch (_) { /* ignore */ } - } - return { loadCellZeroOffsets: next }; - }); - }, - updateSensor: (update: SensorUpdate) => { const key = `${update.entity}.${update.component}`; @@ -784,12 +753,11 @@ export function useActuatorStateByEntity(entity: string): ActuatorState | null { return useSensorStore((s) => s.actuatorStateByEntity[entity] ?? null); } -/** Load cell force (kg) with zero offset applied. Use for display: displayKg = raw - offset. */ +/** Load cell force (kg), absolute. The calibration service is the sole source of the value. */ export function useLoadCellForceKg(calEntity: string): number | null { const raw = useSensorValue(calEntity, 'force_kg'); - const offset = useSensorStore((s) => s.loadCellZeroOffsets[calEntity] ?? 0); if (raw == null || !Number.isFinite(raw)) return null; - return raw - offset; + return raw; } /** @deprecated Use useLoadCellForceKg instead. Legacy alias for backwards compatibility. */ From 4bd4c299c863ea813d9454c2d1ca7252346352b5 Mon Sep 17 00:00:00 2001 From: Aidan Rickert Date: Mon, 14 Sep 2026 14:18:48 -0700 Subject: [PATCH 02/13] daq: an LC tare store that persists the ADC code, so a better fit cannot strand a stale kg offset --- daq-server/diablo_server/lib/CMakeLists.txt | 9 + .../lib/include/calibration/LcTareStore.hpp | 148 ++++++++++ .../lib/src/calibration/LcTareStore.cpp | 264 +++++++++++++++++ .../diablo_server/lib/test/test_lc_tare.cpp | 272 ++++++++++++++++++ 4 files changed, 693 insertions(+) create mode 100644 daq-server/diablo_server/lib/include/calibration/LcTareStore.hpp create mode 100644 daq-server/diablo_server/lib/src/calibration/LcTareStore.cpp create mode 100644 daq-server/diablo_server/lib/test/test_lc_tare.cpp diff --git a/daq-server/diablo_server/lib/CMakeLists.txt b/daq-server/diablo_server/lib/CMakeLists.txt index be509e14..d3f2fdba 100644 --- a/daq-server/diablo_server/lib/CMakeLists.txt +++ b/daq-server/diablo_server/lib/CMakeLists.txt @@ -19,6 +19,7 @@ set(FSW_SOURCES src/control/PressureStateMachine.cpp src/calibration/PTCalibration.cpp src/calibration/CubicCalibrationStore.cpp + src/calibration/LcTareStore.cpp src/calibration/CaptureWindow.cpp src/calibration/SensorCalibration.cpp src/calibration/AllanVariance.cpp @@ -82,6 +83,14 @@ target_link_libraries(test_capture_window fsw_daq_lib daq_comms_lib Threads::Threads pthread ${RT_LIBRARY}) add_test(NAME capture_window COMMAND test_capture_window) +add_executable(test_lc_tare test/test_lc_tare.cpp) +target_include_directories(test_lc_tare PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/include +) +target_link_libraries(test_lc_tare + fsw_daq_lib daq_comms_lib Threads::Threads pthread ${RT_LIBRARY}) +add_test(NAME lc_tare COMMAND test_lc_tare) + add_executable(test_cubic_store_namespace test/test_cubic_store_namespace.cpp) target_include_directories(test_cubic_store_namespace PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/include diff --git a/daq-server/diablo_server/lib/include/calibration/LcTareStore.hpp b/daq-server/diablo_server/lib/include/calibration/LcTareStore.hpp new file mode 100644 index 00000000..a77a2fa3 --- /dev/null +++ b/daq-server/diablo_server/lib/include/calibration/LcTareStore.hpp @@ -0,0 +1,148 @@ +#ifndef LC_TARE_STORE_HPP +#define LC_TARE_STORE_HPP + +#include +#include +#include +#include +#include +#include + +namespace fsw { +namespace calibration { + +/** + * The publish-path entity name for an LC channel: "LC_Cal.CH", where slot is + * board_id % 10 with 0 -> 10 — the same rule DatabaseConfig uses to name the calibrated LC + * VTables, and the same board_number the LC publish path already holds. + * + * It exists as one shared function because the failure mode of a second copy is specific and + * has already happened: slot is NOT board_id, so PT board 22 and LC board 42 are both slot 2, + * and deriving a uid back out of an entity string collides them. Node must key on this string + * rather than re-derive it. + */ +std::string lc_tare_entity(uint8_t board_id, uint8_t connector); + +/** + * One load cell's tare: the scale reads zero at the load it was holding when the operator + * pressed Tare. + * + * A tare is NOT a calibration point, and the difference is the reason this store exists + * separately from CubicCalibrationStore. A vented PT genuinely is at 0 psig, so capturing a + * zero on one is a true reference point and belongs in the shared fit. A load cell holding a + * tank is not at 0 kg: capturing that as a zero would inject a false point, and because + * add_point re-runs a least-squares fit over every point, it would tilt the whole cubic rather + * than shift its intercept. LC therefore gets a display-only tare, and its zero-capture keeps + * meaning "unloaded". + * + * WHAT IS STORED IS THE ADC CODE, NOT THE KILOGRAMS. + * + * `offset_kg` is a derived cache, re-evaluated from `adc_at_tare` through the current curve on + * every re-fit, clear, and profile swap. Persisting kilograms as the truth is the bug this + * layout exists to prevent: tare a 20 kg tank against a bad two-point fit that reads it as 18, + * improve the fit until the same tank evaluates to 20, and a frozen 18 kg offset displays 2 kg + * for a tank that never moved — a plausible-looking wrong number on a pad display. Re-deriving + * from the code gives 20 - 20 = 0. + * + * Subtraction happens in kilograms, never in counts: the curve is a cubic, so offsetting its + * input shifts the slope you get instead of translating its output. + */ +struct LcTare { + uint16_t uid = 0; // board_id*100 + connector + std::string entity; // publish-path identity, e.g. "LC2_Cal.CH1" — see set() + double adc_at_tare = 0.0; // the truth + double offset_kg = 0.0; // derived from adc_at_tare through the live curve; a cache + double set_at_ms = 0.0; // unix milliseconds, for "tared 2h ago" + uint64_t curve_fp = 0; // fingerprint of the curve offset_kg was computed against +}; + +/** + * Owns the per-channel tares for the calibration service. Thread-safe, persisted atomically to + * one JSON file (temp + rename) that the Node backend reads to apply the subtraction — the + * service never sends the value, because [0x46,0x00] is one-way and there is no reply packet. + * This mirrors how cubic calibration already reaches the UI. + * + * Ownership, which is deliberately asymmetric: C++ is the STEADY-STATE writer. Node may remove + * the file, but only while sensor-calibration is not running. That is what lets a tare be + * cleared at session start and still survive a service restart inside a session — two + * requirements that together force a file that is persistent AND externally cleared. Neither + * half is redundant; do not "simplify" one away. + */ +class LcTareStore { +public: + explicit LcTareStore(std::string file_path); + + /** adc -> kg through whichever model this uid streams (select_lc_kg: cubic fit or the + * datasheet physics conversion). Taken as a callable so this class never learns which. */ + using Evaluator = std::function; + + /** + * Record a tare for `uid` at `adc_at_tare` and derive its offset. `entity` is the + * publish-path name (LC_Cal.CH) computed by the caller from the same + * board_number/channel the LC publish path uses; Node keys on this string and must never + * re-derive it, because slot is board_id % 10 and two boards of different kinds share a + * slot — the collision that put a load cell's curve on a 5000 psi transducer in Sep 2026. + * + * Returns false and records nothing when the curve yields a non-finite offset (a degenerate + * one-point fit does), because a NaN offset downstream silently kills the whole series. + */ + bool set(uint16_t uid, const std::string& entity, double adc_at_tare, const Evaluator& eval); + + void clear(uint16_t uid); + void clear_all(); + + /** Re-derive offset_kg for one uid under the current curve. No-op when the uid is untared. */ + void recompute(uint16_t uid, const Evaluator& eval); + + /** Re-derive every tare; `eval_for` supplies each uid's evaluator. Call after any change to + * an LC curve: a capture, a clear, a profile swap, or startup. */ + void recompute_all(const std::function& eval_for); + + /** + * False while the curves cannot be trusted — set when the cubic store failed to load. A + * recompute then keeps the last good offset rather than replacing it with one computed + * against a zeroed curve, which would read as a confident wrong number. + */ + void set_curves_trusted(bool trusted); + bool curves_trusted() const; + + const LcTare* tare_for(uint16_t uid) const; + std::vector uids() const; + size_t size() const; + + /** Atomically write the JSON record (temp + rename). Blocked while load_failed(). */ + bool save() const; + + /** Load the JSON record. Returns tares loaded. */ + size_t load(); + + /** Set when load() could not read an existing file; blocks save() so an unreadable store is + * never replaced by the empty one we fell back to. Mirrors CubicCalibrationStore. */ + bool load_failed() const; + + /** + * A fingerprint of what a curve *does*, not of the coefficients that describe it: the + * evaluator is sampled at fixed probe codes and the results hashed. Model-agnostic by + * construction, so a cubic re-fit, a cleared channel and a flip to the physics conversion + * all move it, and a no-op re-save does not. Cheap defence in depth — if some future path + * mutates a curve without calling recompute, a mismatched fingerprint makes the stale + * offset visible instead of silently subtracted. + */ + static uint64_t fingerprint(const Evaluator& eval); + +private: + mutable std::mutex mutex_; + std::string file_path_; + bool load_failed_ = false; + bool curves_trusted_ = true; + std::map tares_; + + std::string serialize() const; // caller holds mutex_ + /** Caller holds mutex_. Returns false when the curve yields a non-finite offset. */ + bool recompute_locked(LcTare& t, const Evaluator& eval); +}; + +} // namespace calibration +} // namespace fsw + +#endif // LC_TARE_STORE_HPP diff --git a/daq-server/diablo_server/lib/src/calibration/LcTareStore.cpp b/daq-server/diablo_server/lib/src/calibration/LcTareStore.cpp new file mode 100644 index 00000000..4a5e5c62 --- /dev/null +++ b/daq-server/diablo_server/lib/src/calibration/LcTareStore.cpp @@ -0,0 +1,264 @@ +#include "calibration/LcTareStore.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace fsw { +namespace calibration { + +namespace { + +double unix_now_ms() { + return std::chrono::duration( + std::chrono::system_clock::now().time_since_epoch()) + .count(); +} + +/** Probe codes spanning the signed 32-bit ADC range the LC boards report. Fixed forever: they + * only have to be the SAME codes across two fingerprints, never meaningful loads. */ +constexpr double kProbes[] = {-1.0e9, -1.0e6, 0.0, 1.0e6, 1.0e9}; + +} // namespace + +std::string lc_tare_entity(uint8_t board_id, uint8_t connector) { + const int m = static_cast(board_id) % 10; + const int slot = (m == 0 ? 10 : m); + return "LC" + std::to_string(slot) + "_Cal.CH" + std::to_string(static_cast(connector)); +} + +LcTareStore::LcTareStore(std::string file_path) : file_path_(std::move(file_path)) { +} + +uint64_t LcTareStore::fingerprint(const Evaluator& eval) { + if (!eval) + return 0; + // FNV-1a over the raw bits of each probe's result. A non-finite result is folded in as a + // fixed sentinel so a blown-up curve still fingerprints deterministically rather than + // hashing whichever NaN payload the FPU produced. + uint64_t h = 1469598103934665603ull; + for (double probe : kProbes) { + const double v = eval(probe); + uint64_t bits; + if (std::isfinite(v)) + std::memcpy(&bits, &v, sizeof(bits)); + else + bits = 0xDEADBEEFDEADBEEFull; + for (size_t i = 0; i < sizeof(bits); ++i) { + h ^= static_cast((bits >> (i * 8)) & 0xFF); + h *= 1099511628211ull; + } + } + return h; +} + +bool LcTareStore::recompute_locked(LcTare& t, const Evaluator& eval) { + if (!eval) + return false; + const double kg = eval(t.adc_at_tare); + if (!std::isfinite(kg)) + return false; + t.offset_kg = kg; + t.curve_fp = fingerprint(eval); + return true; +} + +bool LcTareStore::set(uint16_t uid, const std::string& entity, double adc_at_tare, + const Evaluator& eval) { + std::lock_guard lock(mutex_); + if (!std::isfinite(adc_at_tare)) + return false; + + LcTare t; + t.uid = uid; + t.entity = entity; + t.adc_at_tare = adc_at_tare; + t.set_at_ms = unix_now_ms(); + if (!recompute_locked(t, eval)) { + // Nothing is recorded. A tare whose offset cannot be evaluated is worse than no tare: + // Node would subtract it from every sample on the channel. + std::cout << "[LcTare] uid " << static_cast(uid) + << ": curve yields no finite offset at adc " << adc_at_tare + << " — tare not recorded" << std::endl; + return false; + } + tares_[uid] = t; + return true; +} + +void LcTareStore::clear(uint16_t uid) { + std::lock_guard lock(mutex_); + tares_.erase(uid); +} + +void LcTareStore::clear_all() { + std::lock_guard lock(mutex_); + tares_.clear(); +} + +void LcTareStore::recompute(uint16_t uid, const Evaluator& eval) { + std::lock_guard lock(mutex_); + if (!curves_trusted_) + return; + auto it = tares_.find(uid); + if (it == tares_.end()) + return; + LcTare probe = it->second; + if (recompute_locked(probe, eval)) + it->second = probe; + // else: keep the last good offset. Writing through a curve that evaluates to NaN would + // replace a usable number with one that kills the series downstream. +} + +void LcTareStore::recompute_all(const std::function& eval_for) { + std::lock_guard lock(mutex_); + if (!curves_trusted_) + return; + if (!eval_for) + return; + for (auto& [uid, t] : tares_) { + LcTare probe = t; + if (recompute_locked(probe, eval_for(uid))) + t = probe; + } +} + +void LcTareStore::set_curves_trusted(bool trusted) { + std::lock_guard lock(mutex_); + curves_trusted_ = trusted; +} + +bool LcTareStore::curves_trusted() const { + std::lock_guard lock(mutex_); + return curves_trusted_; +} + +const LcTare* LcTareStore::tare_for(uint16_t uid) const { + std::lock_guard lock(mutex_); + auto it = tares_.find(uid); + return it == tares_.end() ? nullptr : &it->second; +} + +std::vector LcTareStore::uids() const { + std::lock_guard lock(mutex_); + std::vector out; + out.reserve(tares_.size()); + for (const auto& [uid, t] : tares_) + out.push_back(uid); + return out; +} + +size_t LcTareStore::size() const { + std::lock_guard lock(mutex_); + return tares_.size(); +} + +bool LcTareStore::load_failed() const { + std::lock_guard lock(mutex_); + return load_failed_; +} + +std::string LcTareStore::serialize() const { + nlohmann::json root; + root["version"] = 1; + nlohmann::json arr = nlohmann::json::array(); + for (const auto& [uid, t] : tares_) { + nlohmann::json tj; + tj["uid"] = t.uid; + tj["entity"] = t.entity; + tj["adc_at_tare"] = t.adc_at_tare; + tj["offset_kg"] = t.offset_kg; + tj["set_at_ms"] = t.set_at_ms; + tj["curve_fp"] = t.curve_fp; + arr.push_back(tj); + } + root["tares"] = arr; + return root.dump(2); +} + +bool LcTareStore::save() const { + std::lock_guard lock(mutex_); + if (load_failed_) { + // Same rule as CubicCalibrationStore: a file we could not read is a file we must not + // replace, because this store is empty precisely because the read failed. + return false; + } + try { + std::filesystem::path p(file_path_); + if (p.has_parent_path()) + std::filesystem::create_directories(p.parent_path()); + const std::string tmp = file_path_ + ".tmp"; + { + std::ofstream f(tmp, std::ios::trunc); + if (!f.is_open()) + return false; + f << serialize(); + f.flush(); + if (!f.good()) + return false; + } + std::filesystem::rename(tmp, file_path_); // atomic replace on same filesystem + return true; + } catch (...) { + return false; + } +} + +size_t LcTareStore::load() { + std::lock_guard lock(mutex_); + tares_.clear(); + + std::ifstream f(file_path_); + if (!f.is_open()) { + // No file is the normal state: the backend unlinks it at session start, and every + // session begins with every load cell reading absolute. Not a failure. + load_failed_ = false; + return 0; + } + std::string content((std::istreambuf_iterator(f)), std::istreambuf_iterator()); + + nlohmann::json root; + try { + root = nlohmann::json::parse(content); + } catch (...) { + load_failed_ = true; + std::cout << "[LcTare] " << file_path_ << " could not be parsed — refusing to overwrite it" + << std::endl; + return 0; + } + if (!root.contains("tares") || !root["tares"].is_array()) { + load_failed_ = true; + std::cout << "[LcTare] " << file_path_ << " has no tares array — refusing to overwrite it" + << std::endl; + return 0; + } + load_failed_ = false; + + size_t loaded = 0; + for (const auto& tj : root["tares"]) { + if (!tj.is_object()) + continue; + LcTare t; + t.uid = static_cast(tj.value("uid", 0)); + if (t.uid == 0) + continue; + t.entity = tj.value("entity", std::string()); + t.adc_at_tare = tj.value("adc_at_tare", 0.0); + t.offset_kg = tj.value("offset_kg", 0.0); + t.set_at_ms = tj.value("set_at_ms", 0.0); + t.curve_fp = tj.value("curve_fp", static_cast(0)); + if (!std::isfinite(t.adc_at_tare) || !std::isfinite(t.offset_kg)) + continue; + tares_[t.uid] = t; + ++loaded; + } + return loaded; +} + +} // namespace calibration +} // namespace fsw diff --git a/daq-server/diablo_server/lib/test/test_lc_tare.cpp b/daq-server/diablo_server/lib/test/test_lc_tare.cpp new file mode 100644 index 00000000..85363a6b --- /dev/null +++ b/daq-server/diablo_server/lib/test/test_lc_tare.cpp @@ -0,0 +1,272 @@ +/** + * LcTareStore — the guards that keep a load-cell tare from becoming a confident wrong number. + * + * The bug this whole store exists to prevent, in the operator's words: a 20 kg tank sits on a + * scale whose calibration is a bad two-point fit reading it as 18. The operator tares and the + * display reads 0. Later they add points, the fit improves, and the same tank evaluates to 20. + * A tare persisted as "18 kg" then displays 2 kg for a tank that never moved — plausible enough + * that nobody questions it. Persisting the ADC CODE instead and re-deriving through the current + * curve gives 20 - 20 = 0. Case 1 pins exactly that. + * + * 1. recompute_carries_tare_across_a_better_fit — the 2 kg bug + * 2. untrusted_curves_keep_the_last_good_offset — a failed cubic load must not zero a tare + * 3. non_finite_offset_is_never_recorded — a degenerate fit kills the series downstream + * 4. entity_matches_the_publish_path — Node keys on this string; slot != board_id + * 5. loaded_tare_is_corrected_by_recompute — startup ordering: stale offset must self-heal + * 6. fingerprint_tracks_what_the_curve_does — staleness is detectable, not assumed + */ + +#include +#include +#include +#include +#include +#include +#include + +#include "calibration/LcTareStore.hpp" + +using fsw::calibration::lc_tare_entity; +using fsw::calibration::LcTare; +using fsw::calibration::LcTareStore; +using json = nlohmann::json; + +static int g_failures = 0; + +#define CHECK(cond, ...) \ + do { \ + if (!(cond)) { \ + g_failures++; \ + std::printf(" ❌ [%s:%d] ", __func__, __LINE__); \ + std::printf(__VA_ARGS__); \ + std::printf("\n"); \ + } \ + } while (0) + +namespace { + +/** A unique scratch path per test — hermetic, no committed fixtures, parallel-safe. */ +std::string scratch(const char* name) { + static int seq = 0; + auto p = std::filesystem::temp_directory_path() / + ("lc_tare_" + std::string(name) + "_" + std::to_string(++seq) + ".json"); + std::filesystem::remove(p); + return p.string(); +} + +std::string read_text(const std::string& path) { + std::ifstream f(path); + return std::string((std::istreambuf_iterator(f)), std::istreambuf_iterator()); +} + +/** A linear adc->kg curve, standing in for select_lc_kg. */ +LcTareStore::Evaluator linear(double kg_per_count) { + return [kg_per_count](double adc) { return adc * kg_per_count; }; +} + +// ── 1. the 2 kg bug ───────────────────────────────────────────────────────── + +void recompute_carries_tare_across_a_better_fit() { + LcTareStore s(scratch("recal")); + + // Bad fit: the tank's true 20 kg reads as 18. adc 1000 -> 18 kg. + const auto bad = linear(0.018); + CHECK(s.set(4201, lc_tare_entity(42, 1), 1000.0, bad), "set should succeed"); + const LcTare* t = s.tare_for(4201); + CHECK(t != nullptr, "tare should exist"); + CHECK(std::fabs(t->offset_kg - 18.0) < 1e-9, "offset under the bad fit should be 18, got %f", + t->offset_kg); + + // The operator clears the scale, hangs a known mass, types its TRUE weight; the fit improves + // so that the same code now evaluates to the tank's real 20 kg. + const auto good = linear(0.020); + s.recompute(4201, good); + + t = s.tare_for(4201); + CHECK(std::fabs(t->offset_kg - 20.0) < 1e-9, + "offset must re-derive to 20 under the better fit, got %f", t->offset_kg); + CHECK(std::fabs(t->adc_at_tare - 1000.0) < 1e-9, "adc_at_tare is the truth and must not move"); + + // What the operator sees with the tank back on the scale: good(1000) - offset == 0. + const double displayed = good(1000.0) - t->offset_kg; + CHECK(std::fabs(displayed) < 1e-9, "the tank must still read 0 after the re-cal, got %f kg", + displayed); +} + +// ── 2. an untrusted curve must not overwrite a good offset ────────────────── + +void untrusted_curves_keep_the_last_good_offset() { + LcTareStore s(scratch("untrusted")); + CHECK(s.set(4201, lc_tare_entity(42, 1), 1000.0, linear(0.020)), "set should succeed"); + const double good_offset = s.tare_for(4201)->offset_kg; + CHECK(std::fabs(good_offset - 20.0) < 1e-9, "baseline offset should be 20, got %f", + good_offset); + + // The cubic store failed to load, so every curve currently evaluates to a flat zero. A + // recompute through it would replace 20 kg with 0 and the tank would suddenly read its full + // weight, with nothing on screen to say why. + s.set_curves_trusted(false); + s.recompute(4201, linear(0.0)); + + CHECK(std::fabs(s.tare_for(4201)->offset_kg - good_offset) < 1e-9, + "an untrusted recompute must keep the last good offset, got %f", + s.tare_for(4201)->offset_kg); + + // And once the curves are trustworthy again it does update. + s.set_curves_trusted(true); + s.recompute(4201, linear(0.030)); + CHECK(std::fabs(s.tare_for(4201)->offset_kg - 30.0) < 1e-9, + "a trusted recompute must update, got %f", s.tare_for(4201)->offset_kg); +} + +// ── 3. non-finite offsets are never recorded ──────────────────────────────── + +void non_finite_offset_is_never_recorded() { + LcTareStore s(scratch("nonfinite")); + + // A degenerate one-point fit can evaluate to inf/NaN. Node subtracts offset_kg from every + // sample; a NaN there is dropped by the finite guard downstream and the whole series simply + // vanishes from the plot with no error anywhere. + const auto blown_up = [](double) { return std::numeric_limits::quiet_NaN(); }; + CHECK(!s.set(4201, lc_tare_entity(42, 1), 1000.0, blown_up), "a NaN offset must be refused"); + CHECK(s.tare_for(4201) == nullptr, "nothing may be recorded for a refused tare"); + + const auto inf_curve = [](double) { return std::numeric_limits::infinity(); }; + CHECK(!s.set(4202, lc_tare_entity(42, 2), 1000.0, inf_curve), "an inf offset must be refused"); + CHECK(s.size() == 0, "store must still be empty, has %zu", s.size()); + + // A good tare that later sees a blown-up curve keeps its offset rather than adopting NaN. + CHECK(s.set(4203, lc_tare_entity(42, 3), 500.0, linear(0.02)), "good set should succeed"); + s.recompute(4203, blown_up); + CHECK(std::isfinite(s.tare_for(4203)->offset_kg), "offset must stay finite after a bad curve"); + CHECK(std::fabs(s.tare_for(4203)->offset_kg - 10.0) < 1e-9, "offset must be unchanged, got %f", + s.tare_for(4203)->offset_kg); +} + +// ── 4. the entity string Node keys on ─────────────────────────────────────── + +void entity_matches_the_publish_path() { + // DatabaseConfig names the calibrated LC VTable LC_Cal.CH, where + // board_number is board_id % 10 with 0 -> 10. Getting this wrong means C++ writes a key the + // backend never looks up, and the tare silently never applies. + CHECK(lc_tare_entity(42, 1) == "LC2_Cal.CH1", "board 42 ch 1 -> LC2_Cal.CH1, got %s", + lc_tare_entity(42, 1).c_str()); + CHECK(lc_tare_entity(41, 6) == "LC1_Cal.CH6", "board 41 ch 6 -> LC1_Cal.CH6, got %s", + lc_tare_entity(41, 6).c_str()); + // slot is NOT board_id: board 42 is slot 2, not 42. + CHECK(lc_tare_entity(42, 1) != "LC42_Cal.CH1", "must use the slot, not the raw board id"); + // 0 -> 10, the documented edge (id 10, id 20). + CHECK(lc_tare_entity(10, 3) == "LC10_Cal.CH3", "board 10 ch 3 -> LC10_Cal.CH3, got %s", + lc_tare_entity(10, 3).c_str()); +} + +// ── 5. a stale offset from disk self-heals on recompute ───────────────────── + +void loaded_tare_is_corrected_by_recompute() { + const std::string path = scratch("startup"); + + // Hand-write a record whose offset_kg belongs to a curve that is no longer live — exactly + // what a profile swapped while the service was down leaves behind. + json root; + root["version"] = 1; + json t; + t["uid"] = 4201; + t["entity"] = "LC2_Cal.CH1"; + t["adc_at_tare"] = 1000.0; + t["offset_kg"] = 18.0; // stale: computed against the old curve + t["set_at_ms"] = 1757800000000.0; + t["curve_fp"] = 12345; + root["tares"] = json::array({t}); + { + std::ofstream f(path); + f << root.dump(2); + } + + LcTareStore s(path); + CHECK(s.load() == 1, "one tare should load"); + CHECK(std::fabs(s.tare_for(4201)->offset_kg - 18.0) < 1e-9, "loads the stale value verbatim"); + + // The startup recompute is what corrects it. If the tare file is loaded AFTER the live store + // reload instead of before, this never runs and the stand carries the stale offset all run. + s.recompute_all([](uint16_t) { return linear(0.020); }); + CHECK(std::fabs(s.tare_for(4201)->offset_kg - 20.0) < 1e-9, + "the startup recompute must correct a stale offset, got %f", + s.tare_for(4201)->offset_kg); +} + +// ── 6. the staleness fingerprint ──────────────────────────────────────────── + +void fingerprint_tracks_what_the_curve_does() { + const auto a = linear(0.020); + const auto b = linear(0.030); + + CHECK(LcTareStore::fingerprint(a) == LcTareStore::fingerprint(linear(0.020)), + "the same curve must fingerprint the same — otherwise every read looks stale"); + CHECK(LcTareStore::fingerprint(a) != LcTareStore::fingerprint(b), + "a changed curve must change the fingerprint"); + + // The fingerprint follows the curve the offset was computed against. + LcTareStore s(scratch("fp")); + CHECK(s.set(4201, lc_tare_entity(42, 1), 1000.0, a), "set should succeed"); + CHECK(s.tare_for(4201)->curve_fp == LcTareStore::fingerprint(a), "fp must match the set curve"); + s.recompute(4201, b); + CHECK(s.tare_for(4201)->curve_fp == LcTareStore::fingerprint(b), + "fp must follow a recompute onto a new curve"); +} + +// ── round trip ────────────────────────────────────────────────────────────── + +void save_load_round_trip() { + const std::string path = scratch("roundtrip"); + { + LcTareStore s(path); + CHECK(s.set(4201, lc_tare_entity(42, 1), 1000.0, linear(0.020)), "set"); + CHECK(s.set(4206, lc_tare_entity(42, 6), -250.0, linear(0.020)), "set negative adc"); + CHECK(s.save(), "save"); + } + LcTareStore s2(path); + CHECK(s2.load() == 2, "two tares should load"); + CHECK(!s2.load_failed(), "a good file is not a failed load"); + CHECK(s2.tare_for(4201)->entity == "LC2_Cal.CH1", "entity round-trips"); + CHECK(std::fabs(s2.tare_for(4206)->offset_kg + 5.0) < 1e-9, "negative offset round-trips, got %f", + s2.tare_for(4206)->offset_kg); + + // A missing file is the normal post-session-start state, not a failure — it must not block + // the next save the way an unreadable file does. + LcTareStore s3(scratch("absent")); + CHECK(s3.load() == 0, "absent file loads nothing"); + CHECK(!s3.load_failed(), "an absent file is not a failed load"); +} + +void unreadable_file_is_not_overwritten() { + const std::string path = scratch("corrupt"); + { + std::ofstream f(path); + f << "{ this is not json"; + } + LcTareStore s(path); + CHECK(s.load() == 0, "corrupt file loads nothing"); + CHECK(s.load_failed(), "corrupt file must set load_failed"); + CHECK(!s.save(), "save must be refused while load_failed"); + CHECK(read_text(path) == "{ this is not json", "the file must be left exactly as found"); +} + +} // namespace + +int main() { + std::printf("LcTareStore tests\n"); + recompute_carries_tare_across_a_better_fit(); + untrusted_curves_keep_the_last_good_offset(); + non_finite_offset_is_never_recorded(); + entity_matches_the_publish_path(); + loaded_tare_is_corrected_by_recompute(); + fingerprint_tracks_what_the_curve_does(); + save_load_round_trip(); + unreadable_file_is_not_overwritten(); + + if (g_failures == 0) + std::printf(" ✅ all passed\n"); + else + std::printf(" %d failure(s)\n", g_failures); + return g_failures ? 1 : 0; +} From 0c8dfc931ec26d32ae31f816c4a2ef71693f4909 Mon Sep 17 00:00:00 2001 From: Aidan Rickert Date: Mon, 14 Sep 2026 15:23:00 -0700 Subject: [PATCH 03/13] daq: wire the LC tare command and recompute every standing tare when a curve moves --- .../calibration/CubicCalibrationStore.hpp | 8 + .../lib/include/calibration/LcTareStore.hpp | 13 ++ .../lib/src/calibration/LcTareStore.cpp | 17 +++ .../diablo_server/lib/test/test_lc_tare.cpp | 26 ++++ .../services/calibration/calibration_main.cpp | 141 ++++++++++++++++++ 5 files changed, 205 insertions(+) diff --git a/daq-server/diablo_server/lib/include/calibration/CubicCalibrationStore.hpp b/daq-server/diablo_server/lib/include/calibration/CubicCalibrationStore.hpp index d135d379..797b2ba4 100644 --- a/daq-server/diablo_server/lib/include/calibration/CubicCalibrationStore.hpp +++ b/daq-server/diablo_server/lib/include/calibration/CubicCalibrationStore.hpp @@ -141,6 +141,14 @@ class CubicCalibrationStore { /** Load the JSON record, resume points, and re-fit each channel. Returns channels loaded. */ size_t load(); + /** True when load() could not read an existing file, so every curve here is a fallback rather + * than the operator's. Callers that DERIVE from a curve (the LC tare recompute) must skip + * rather than persist a number computed against one. */ + bool load_failed() const { + std::lock_guard lock(mutex_); + return load_failed_; + } + static constexpr size_t kMaxPoints = 20; private: diff --git a/daq-server/diablo_server/lib/include/calibration/LcTareStore.hpp b/daq-server/diablo_server/lib/include/calibration/LcTareStore.hpp index a77a2fa3..012102ec 100644 --- a/daq-server/diablo_server/lib/include/calibration/LcTareStore.hpp +++ b/daq-server/diablo_server/lib/include/calibration/LcTareStore.hpp @@ -98,6 +98,19 @@ class LcTareStore { * an LC curve: a capture, a clear, a profile swap, or startup. */ void recompute_all(const std::function& eval_for); + /** + * Re-derive only the tares whose recorded fingerprint disagrees with the curve now live, and + * return how many those were. Zero is the expected answer: every path that moves a curve is + * supposed to recompute already. + * + * A non-zero answer means one didn't, and the caller should say so loudly — it is the audit + * that makes a missed recompute hook visible instead of silently subtracting kilograms + * derived from a curve that no longer exists. In particular it catches the startup ordering + * mistake of reading this file AFTER the live store reload, where the reload's own recompute + * runs over an empty map and every restored offset stays stale. + */ + size_t recompute_stale(const std::function& eval_for); + /** * False while the curves cannot be trusted — set when the cubic store failed to load. A * recompute then keeps the last good offset rather than replacing it with one computed diff --git a/daq-server/diablo_server/lib/src/calibration/LcTareStore.cpp b/daq-server/diablo_server/lib/src/calibration/LcTareStore.cpp index 4a5e5c62..8bbd8bca 100644 --- a/daq-server/diablo_server/lib/src/calibration/LcTareStore.cpp +++ b/daq-server/diablo_server/lib/src/calibration/LcTareStore.cpp @@ -128,6 +128,23 @@ void LcTareStore::recompute_all(const std::function& eval_f } } +size_t LcTareStore::recompute_stale(const std::function& eval_for) { + std::lock_guard lock(mutex_); + if (!curves_trusted_ || !eval_for) + return 0; + size_t stale = 0; + for (auto& [uid, t] : tares_) { + const Evaluator eval = eval_for(uid); + if (fingerprint(eval) == t.curve_fp) + continue; + ++stale; + LcTare probe = t; + if (recompute_locked(probe, eval)) + t = probe; + } + return stale; +} + void LcTareStore::set_curves_trusted(bool trusted) { std::lock_guard lock(mutex_); curves_trusted_ = trusted; diff --git a/daq-server/diablo_server/lib/test/test_lc_tare.cpp b/daq-server/diablo_server/lib/test/test_lc_tare.cpp index 85363a6b..fb4ecee1 100644 --- a/daq-server/diablo_server/lib/test/test_lc_tare.cpp +++ b/daq-server/diablo_server/lib/test/test_lc_tare.cpp @@ -14,6 +14,7 @@ * 4. entity_matches_the_publish_path — Node keys on this string; slot != board_id * 5. loaded_tare_is_corrected_by_recompute — startup ordering: stale offset must self-heal * 6. fingerprint_tracks_what_the_curve_does — staleness is detectable, not assumed + * 7. stale_audit_finds_a_missed_recompute — a hook nobody added still gets caught */ #include @@ -214,6 +215,30 @@ void fingerprint_tracks_what_the_curve_does() { "fp must follow a recompute onto a new curve"); } +// ── 7. the audit that catches a missed recompute hook ─────────────────────── + +void stale_audit_finds_a_missed_recompute() { + LcTareStore s(scratch("audit")); + CHECK(s.set(4201, lc_tare_entity(42, 1), 1000.0, linear(0.020)), "set should succeed"); + + // Nothing has changed: the audit must report zero, or it would cry wolf on every startup and + // the warning would stop meaning anything. + CHECK(s.recompute_stale([](uint16_t) { return linear(0.020); }) == 0, + "an unchanged curve must not be reported stale"); + CHECK(std::fabs(s.tare_for(4201)->offset_kg - 20.0) < 1e-9, "and the offset is untouched"); + + // Now the curve moves WITHOUT a recompute — the shape of a missed hook, and of reading the + // tare file after the startup reload instead of before it. + const size_t stale = s.recompute_stale([](uint16_t) { return linear(0.030); }); + CHECK(stale == 1, "a moved curve must be reported stale, got %zu", stale); + CHECK(std::fabs(s.tare_for(4201)->offset_kg - 30.0) < 1e-9, + "and must be re-derived, got %f", s.tare_for(4201)->offset_kg); + + // Having fixed it, a second audit is quiet. + CHECK(s.recompute_stale([](uint16_t) { return linear(0.030); }) == 0, + "the audit must be quiet once it has healed"); +} + // ── round trip ────────────────────────────────────────────────────────────── void save_load_round_trip() { @@ -261,6 +286,7 @@ int main() { entity_matches_the_publish_path(); loaded_tare_is_corrected_by_recompute(); fingerprint_tracks_what_the_curve_does(); + stale_audit_finds_a_missed_recompute(); save_load_round_trip(); unreadable_file_is_not_overwritten(); diff --git a/daq-server/diablo_server/services/calibration/calibration_main.cpp b/daq-server/diablo_server/services/calibration/calibration_main.cpp index 14768eff..d806e0b9 100644 --- a/daq-server/diablo_server/services/calibration/calibration_main.cpp +++ b/daq-server/diablo_server/services/calibration/calibration_main.cpp @@ -48,6 +48,7 @@ #include "calibration/CaptureWindow.hpp" #include "calibration/CubicCalibrationStore.hpp" +#include "calibration/LcTareStore.hpp" #include "calibration/PTCalibration.hpp" #include "calibration/RobustCalibrationManager.hpp" #include "calibration/SensorCalibration.hpp" @@ -884,6 +885,12 @@ int main(int argc, char* argv[]) { // feeds both this cubic fit and the robust learner. fsw::calibration::CubicCalibrationStore cubic_store( "scripts/calibration/calibrations/cubic_calibration.json"); + // Load-cell tares: display-only offsets the backend subtracts on the way to the browser. + // Deliberately NOT part of the cubic store — a tare is not a calibration point, and folding + // one in as a captured zero would tilt the whole fit rather than shift its intercept. + // Cleared by the backend at session start (while this service is down); survives a restart + // inside a session. See LcTareStore.hpp. + fsw::calibration::LcTareStore lc_tare_store("scripts/calibration/calibrations/lc_tare.json"); // What a capture records. Bounded in TIME, not in samples: the old 128-sample ring was // sized for a 250 Hz PT and spanned 9.3 s on a 13.7 Hz load cell, so a capture taken // soon after a load change averaged the old load in. See CaptureWindow.hpp. @@ -1046,6 +1053,45 @@ int main(int argc, char* argv[]) { // boards' vent-to-safe gate tracks the new calibration within a broadcast cycle. write_abort_thresholds(); }; + // adc -> kg exactly as the LC publish path computes it (see the 0x23 branch): the cubic fit + // if this uid streams one, else the datasheet physics conversion. A tare offset is derived + // through the SAME model selection the live sample goes through, so flipping a uid between + // cubic and physics carries its tare correctly instead of being a fourth special case. + auto lc_eval_for = [&](uint16_t uid) -> fsw::calibration::LcTareStore::Evaluator { + return [&, uid](double adc) -> double { + uint8_t board_number = static_cast((uid / 100) % 10); + if (board_number == 0) + board_number = 10; + const uint8_t connector = static_cast(uid % 100); + const uint8_t lc_log_ch = + fsw::calibration::pt_logical_calibration_channel(board_number, connector); + const int32_t code = static_cast(adc); + const bool cubic_ok = lc_calibration.is_calibrated(lc_log_ch); + const double kg_cubic = cubic_ok ? lc_calibration.calculate(lc_log_ch, code) : 0.0; + const double kg_phys = + convert_lc_adc_to_force(code, lc_sensitivity_for(uid, lc_sensitivity_mv_per_v), + lc_pga_gain_for(uid, lc_pga_gain), + lc_full_scale_for(uid, lc_full_scale_value)); + return select_lc_kg(uid, kg_cubic, kg_phys, cubic_ok); + }; + }; + // Re-derive a standing tare's kilograms from the ADC code it was taken at. Called from EVERY + // site that can change an LC curve; miss one and the stand carries an offset computed against + // a curve that no longer exists — the "tank reads 2 kg after a better fit" bug. + // + // curves_trusted tracks the cubic store: if its file could not be read, every curve here is a + // fallback, and persisting an offset derived from one would replace a good number with a + // confident wrong one. + auto recompute_tare = [&](uint16_t uid) { + lc_tare_store.set_curves_trusted(!cubic_store.load_failed()); + lc_tare_store.recompute(uid, lc_eval_for(uid)); + lc_tare_store.save(); + }; + auto recompute_all_tares = [&]() { + lc_tare_store.set_curves_trusted(!cubic_store.load_failed()); + lc_tare_store.recompute_all([&](uint16_t u) { return lc_eval_for(u); }); + lc_tare_store.save(); + }; // LC capture: cubic fit only — no robust learner (LC doesn't need drift-learning) and no abort // thresholds (a PT-only concept; abort_pts names PT roles). auto apply_lc_capture = [&](uint16_t uid, double adc_avg, double ref) { @@ -1055,6 +1101,8 @@ int main(int argc, char* argv[]) { lc_calibration.set_calibration(cch->logical_ch, fsw::calibration::PolynomialCalibration( fit.A, fit.B, fit.C, fit.D, "kg")); cubic_store.save(); + // The curve just moved under any standing tare on this channel. + recompute_tare(uid); }; // Every capture/clear is routed by uid kind (g_lc_uids), so cmd_type 0/3/4/5/6 in the // CalibrationCommand handler below work unchanged for both PT and LC uids. @@ -1112,6 +1160,9 @@ int main(int argc, char* argv[]) { lc_calibration.clear_calibration(cch->logical_ch); cubic_store.clear_channel(uid); cubic_store.save(); + // A cleared channel falls back to the physics conversion, which is a different curve. + // The tare survives and re-derives through it, so the same load still reads zero. + recompute_tare(uid); }; auto apply_clear = [&](uint16_t uid) { if (is_lc_uid(uid)) @@ -1199,6 +1250,10 @@ int main(int argc, char* argv[]) { // Whole-rig cal just (re)loaded — startup or a live profile swap (cmd 7). Emit the abort // thresholds from it so the boards' vent-to-safe gate matches the newly active calibration. write_abort_thresholds(); + // Every LC curve may have just changed, so every standing tare's kilograms are stale. + // This is why the tare file is loaded BEFORE the first call to this lambda: recomputing + // an empty map does nothing, and the stale offsets would then survive all session. + recompute_all_tares(); return loaded; }; @@ -1206,11 +1261,41 @@ int main(int argc, char* argv[]) { std::cout << "[Calibration] (override with --adjustments, CAL_BACKUP_PATH, or " "calibration_backups/calibration_backup_*.json mtime)" << std::endl; + // Resume standing tares BEFORE the live store reload below, not after. + // + // Ordering is load-bearing. reload_live_store() ends by recomputing every tare against the + // curves it just applied; if the tare file were read after that call, the recompute would run + // over an empty map and never happen. The case that bites is a calibration profile swapped on + // disk while this service was down: the offsets on disk belong to the old curves, and without + // the startup recompute a tared tank reads a wrong nonzero at rest with nothing to explain it. + const size_t tares_loaded = lc_tare_store.load(); + if (tares_loaded > 0) + std::cout << "[Calibration] LC tare: resumed " << tares_loaded + << " standing tare(s) from lc_tare.json" << std::endl; + // Resume previously captured points + learned robust state from disk. const size_t cubic_loaded = reload_live_store(/*restore_learned=*/true); if (cubic_loaded > 0) std::cout << "[Calibration] Cubic: resumed " << cubic_loaded << " channel(s) from cubic_calibration.json" << std::endl; + // Audit, not a second recompute: by here every restored tare should already have been + // re-derived by the reload above, so the expected answer is zero. A non-zero answer means a + // curve moved without its recompute running — most likely this file was read after the + // reload rather than before it — and the offsets it just fixed were being applied against a + // curve that no longer exists. Self-healing, but never silently. + { + lc_tare_store.set_curves_trusted(!cubic_store.load_failed()); + const size_t stale = + lc_tare_store.recompute_stale([&](uint16_t u) { return lc_eval_for(u); }); + if (stale > 0) { + std::cout << "[Calibration] LC tare: WARNING — " << stale + << " tare(s) were stale against the live curves and have been re-derived. " + "A curve changed without recomputing its tare; check that lc_tare_store" + ".load() still runs BEFORE the first reload_live_store()." + << std::endl; + lc_tare_store.save(); + } + } // Always persist at startup so the record (with each channel's active_model) exists immediately // — the UI / cal_model_select read it before any capture. cubic_store.save(); @@ -1488,6 +1573,62 @@ int main(int argc, char* argv[]) { const size_t n = reload_live_store(/*restore_learned=*/false); std::cout << "[Cal] Reloaded live calibration store (" << n << " channel(s)) after profile swap" << std::endl; + } else if (cmd_type == 8) { // LC tare — display only, never touches the fit + // ref_val: 0 = set, 1 = clear. sensor_id 0 = every LC channel. + // + // A tare is NOT a captured zero, and this is the one command where that + // distinction is the whole point. Zero-All (cmd 0) records a real 0 kg + // reference point into the shared fit, which is correct for a vented PT and + // wrong for a load cell holding a tank: that tank is not at 0 kg, so the + // point would be false and, because the fit is least-squares over every + // point, it would tilt the whole cubic rather than shift its intercept. + const bool clearing = ref_val >= 0.5f; + std::vector targets; + if (sensor_id == 0) { + if (clearing) { + targets = lc_tare_store.uids(); + } else { + // Only channels actually streaming can be tared — same rule Zero-All + // uses. A uid in a last-value map may have had its board go away. + for (uint16_t id : capture_window.uids()) + if (is_lc_uid(id)) + targets.push_back(id); + } + } else if (is_lc_uid(sensor_id)) { + targets.push_back(sensor_id); + } else { + std::cout << "[Cal] Tare: uid " << static_cast(sensor_id) + << " is not a load cell — ignored" << std::endl; + } + + size_t done = 0; + for (uint16_t id : targets) { + if (clearing) { + lc_tare_store.clear(id); + ++done; + continue; + } + // Through the capture window, never last_adc_map: a tare taken from a + // stale code silently biases every reading on the channel for the rest + // of the run, which is the same failure take_capture exists to refuse. + const fsw::calibration::CaptureResult r = take_capture(id, "Tare"); + if (!r.ok) + continue; + const uint8_t board_id = static_cast(id / 100); + const uint8_t connector = static_cast(id % 100); + if (lc_tare_store.set(id, fsw::calibration::lc_tare_entity(board_id, + connector), + r.adc_avg, lc_eval_for(id))) { + ++done; + const fsw::calibration::LcTare* t = lc_tare_store.tare_for(id); + std::cout << "[Cal] Tare uid=" << static_cast(id) << " " + << capture_detail(r) << " offset=" + << (t != nullptr ? t->offset_kg : 0.0) << "kg" << std::endl; + } + } + lc_tare_store.save(); + std::cout << "[Cal] " << (clearing ? "Tare clear" : "Tare") << ": " << done + << " load cell(s)" << std::endl; } } continue; From b9a5d0bf1f2bc666e441455374e43661b1ec8898 Mon Sep 17 00:00:00 2001 From: Aidan Rickert Date: Mon, 14 Sep 2026 15:31:11 -0700 Subject: [PATCH 04/13] daq: apply the LC tare on the way to the browser, and record each change beside the run --- .../backend/src/__tests__/lc-tare.test.ts | 285 ++++++++++++++++++ .../backend/src/calibration-handler.ts | 13 + .../diablo_server/backend/src/lc-tare.ts | 237 +++++++++++++++ .../src/routes/calibration-profiles.ts | 12 + .../diablo_server/backend/src/server.ts | 18 +- .../backend/src/service-controller.ts | 19 ++ .../backend/src/session-manager.ts | 13 + 7 files changed, 594 insertions(+), 3 deletions(-) create mode 100644 daq-server/diablo_server/backend/src/__tests__/lc-tare.test.ts create mode 100644 daq-server/diablo_server/backend/src/lc-tare.ts diff --git a/daq-server/diablo_server/backend/src/__tests__/lc-tare.test.ts b/daq-server/diablo_server/backend/src/__tests__/lc-tare.test.ts new file mode 100644 index 00000000..73f4acad --- /dev/null +++ b/daq-server/diablo_server/backend/src/__tests__/lc-tare.test.ts @@ -0,0 +1,285 @@ +/** + * Load-cell tare: the backend half. + * + * Every case here names a specific way the tare could put a wrong number in front of an + * operator, or make one disappear. The two that matter most: + * - a torn read must not clear the map (every LC plot would jump by the tare amount for one + * poll interval and then jump back), and + * - the derived value must reach history and the client outbox identically, or a reconnect + * shows gross where the live stream showed tared. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +// The module resolves its file path through routes/calibration-profiles.js, which walks the repo +// for scripts/calibration/calibrations. Point it at a scratch dir instead. +let TMP: string; +let TARE_FILE: string; + +vi.mock('../routes/calibration-profiles.js', () => ({ + tarePath: () => TARE_FILE, + livePath: () => path.join(path.dirname(TARE_FILE), 'cubic_calibration.json'), + profilesDir: () => path.join(path.dirname(TARE_FILE), 'profiles'), +})); + +const { expandWithTare, loadTareMap, tareOffsetKg, clearTareFile, resetTareState, setRunDir, currentTares } = + await import('../lc-tare.js'); + +function writeTares(entries: Array<{ entity: string; uid?: number; adc?: number; kg: number }>): void { + const tares = entries.map((e) => ({ + uid: e.uid ?? 4201, + entity: e.entity, + adc_at_tare: e.adc ?? 1000, + offset_kg: e.kg, + set_at_ms: 1757800000000, + curve_fp: 7, + })); + fs.writeFileSync(TARE_FILE, JSON.stringify({ version: 1, tares })); +} + +/** One calibrated LC packet as elodin-protocol parses it: the value plus its two raw echoes. */ +function lcPacket(entity: string, grossKg: number, rawAdc = 12345) { + return [ + { entity, component: 'force_kg', value: grossKg, timestamp: 1000 }, + { entity, component: 'raw_adc_counts', value: rawAdc, timestamp: 1000 }, + { entity, component: 'raw_adc', value: rawAdc, timestamp: 1000 }, + ]; +} + +beforeEach(() => { + TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'lc-tare-test-')); + TARE_FILE = path.join(TMP, 'lc_tare.json'); + resetTareState(); + setRunDir(null); +}); + +afterEach(() => { + resetTareState(); + setRunDir(null); + fs.rmSync(TMP, { recursive: true, force: true }); +}); + +describe('expandWithTare', () => { + it('adds force_kg_tared without touching force_kg or the raw echoes', () => { + writeTares([{ entity: 'LC2_Cal.CH1', kg: 20 }]); + const out = expandWithTare(lcPacket('LC2_Cal.CH1', 50)); + + // force_kg stays gross: the calibration page reads it, and the operator types the true + // weight of a known mass against that reading. + expect(out.find((p) => p.component === 'force_kg')!.value).toBe(50); + expect(out.find((p) => p.component === 'force_kg_tared')!.value).toBe(30); + // Raw echoes are the calibration capture path's input. Taring them would poison the fit. + expect(out.find((p) => p.component === 'raw_adc_counts')!.value).toBe(12345); + expect(out.find((p) => p.component === 'raw_adc')!.value).toBe(12345); + // Exactly one derived point, and it came from force_kg. Checking only the raw values above + // is not enough: deriving a SECOND tared point off a raw echo leaves those values intact and + // would sail past, while putting a 12325-kg spike in the same series as the real reading. + expect(out.filter((p) => p.component === 'force_kg_tared')).toHaveLength(1); + expect(out).toHaveLength(4); + }); + + it('emits force_kg_tared even when untared, so a 0 offset is not mistaken for a dead stream', () => { + const out = expandWithTare(lcPacket('LC2_Cal.CH1', 50)); + const tared = out.find((p) => p.component === 'force_kg_tared'); + expect(tared).toBeDefined(); + expect(tared!.value).toBe(50); + }); + + it('leaves non-load-cell packets exactly as they arrived', () => { + const pt = [{ entity: 'PT1_Cal.CH3', component: 'pressure_psi', value: 220, timestamp: 1 }]; + expect(expandWithTare(pt)).toEqual(pt); + }); + + it('treats a NaN offset as no tare rather than poisoning the series', () => { + // A NaN would survive to the caller's own Number.isFinite guard, which drops the point — + // so the whole tared series would silently vanish from the plot with nothing logged. + fs.writeFileSync( + TARE_FILE, + '{"version":1,"tares":[{"uid":4201,"entity":"LC2_Cal.CH1","adc_at_tare":1000,"offset_kg":null,"set_at_ms":1,"curve_fp":7}]}', + ); + const out = expandWithTare(lcPacket('LC2_Cal.CH1', 50)); + const tared = out.find((p) => p.component === 'force_kg_tared')!; + expect(Number.isFinite(tared.value)).toBe(true); + expect(tared.value).toBe(50); + + // And it never enters the map at all, so the status endpoint the calibration UI polls cannot + // report a tare that is not a number. Asserting only the streamed value above would pass on + // the strength of the second guard in tareOffsetKg and leave this one untested. + expect(currentTares()).toEqual([]); + }); +}); + +describe('the tare map', () => { + it('keeps the previous map when the file is torn, and empties only on ENOENT', () => { + writeTares([{ entity: 'LC2_Cal.CH1', kg: 20 }]); + expect(tareOffsetKg('LC2_Cal.CH1')).toBe(20); + + // A reader can still lose a race with the service's tmp+rename. Clearing here would make + // every LC plot jump by 20 kg for one poll interval and then jump back. + fs.writeFileSync(TARE_FILE, '{"version":1,"tares":[{"entity":"LC2_Cal.C'); + fs.utimesSync(TARE_FILE, new Date(Date.now() + 5000), new Date(Date.now() + 5000)); + expect(tareOffsetKg('LC2_Cal.CH1')).toBe(20); + + // A removed file is different: that is the session-start clear, and it must take effect. + fs.unlinkSync(TARE_FILE); + expect(tareOffsetKg('LC2_Cal.CH1')).toBe(0); + }); + + it('picks up a changed offset when the file mtime moves', () => { + writeTares([{ entity: 'LC2_Cal.CH1', kg: 20 }]); + expect(tareOffsetKg('LC2_Cal.CH1')).toBe(20); + writeTares([{ entity: 'LC2_Cal.CH1', kg: 25 }]); + fs.utimesSync(TARE_FILE, new Date(Date.now() + 5000), new Date(Date.now() + 5000)); + expect(tareOffsetKg('LC2_Cal.CH1')).toBe(25); + }); + + it('clearTareFile removes the file and forgets the held map', () => { + writeTares([{ entity: 'LC2_Cal.CH1', kg: 20 }]); + expect(tareOffsetKg('LC2_Cal.CH1')).toBe(20); + clearTareFile(); + expect(fs.existsSync(TARE_FILE)).toBe(false); + expect(tareOffsetKg('LC2_Cal.CH1')).toBe(0); + expect(currentTares()).toEqual([]); + }); + + it('clearTareFile on an already-absent file is not an error', () => { + expect(() => clearTareFile()).not.toThrow(); + }); +}); + +describe('live stream and reconnect backfill agree', () => { + // The bug guarded here: applying the tare in the outbox drain instead of before + // history.record() leaves the live plot tared and the backfill after a reconnect gross, so a + // dropped WebSocket silently changes every load-cell number on screen. + // + // server.ts calls httpServer.listen() at import, so emitSensorWindow cannot be driven + // directly from a unit test. Both halves of the invariant are pinned instead: + + it('feeds one already-tared array to both sinks, which then hold identical values', async () => { + const { HistoryCache } = await import('../history-cache.js'); + const { ClientOutbox } = await import('../client-outbox.js'); + + writeTares([{ entity: 'LC2_Cal.CH1', kg: 20 }]); + const history = new HistoryCache({ maxPoints: 256, maxKeys: 32, staleMs: 60_000 }); + const outbox = new ClientOutbox(); + + // Exactly what emitSensorWindow does: record to history, then stage to the client, from the + // same points. Anything derived before this call reaches both; anything derived after + // reaches only one. + for (const grossKg of [50, 51, 52]) { + const tared = expandWithTare(lcPacket('LC2_Cal.CH1', grossKg)) + .find((p) => p.component === 'force_kg_tared')!; + const key = `${tared.entity}.${tared.component}`; + const points = [{ tMs: 1000 + grossKg, value: tared.value }]; + history.record(key, points[0].tMs, points[0].value); + outbox.push(key, tared.entity, tared.component, points); + } + + const staged = outbox.drain().flatMap((s) => s.points.map((p) => p.value)); + const backfilled = Array.from( + history.buildPayload({ keys: ['LC2_Cal.CH1.force_kg_tared'] }, 1000)[ + 'LC2_Cal.CH1.force_kg_tared' + ].values, + ); + + expect(staged).toEqual([30, 31, 32]); + expect(backfilled).toEqual(staged); + }); + + it('server.ts derives the tare at the parse site, not in the drain loop', () => { + // A wiring assertion, deliberately narrow: it checks the one line that decides which side of + // history.record() the subtraction lands on. Moving the tare into the drain means deleting + // this wrapper, and this test is what notices. + const src = fs.readFileSync(path.join(__dirname, '..', 'server.ts'), 'utf8'); + expect(src).toContain('expandWithTare(parseElodinPacket('); + + const drain = src.slice(src.indexOf('cs.outbox.drain()'), src.indexOf('cs.pacer.noteFlush')); + expect(drain).not.toContain('force_kg_tared'); + expect(drain).not.toContain('tareOffset'); + }); +}); + +describe('the session-start clear happens in the only safe window', () => { + // The trap: unlinking beside snapshotRunConfig looks equivalent and is not. At that point the + // PREVIOUS run's calibration_service may still be alive, holding its tares in memory, and it + // rewrites the file from them on its next periodic save or clean shutdown — so the session + // starts with the last run's offsets and nothing says so. + const controllerSrc = () => + fs.readFileSync(path.join(__dirname, '..', 'service-controller.ts'), 'utf8'); + + it('clears strictly between waitUntilSettled and the pipeline start', () => { + const src = controllerSrc(); + const settled = src.indexOf('await waitUntilSettled(pipelineUnits(true))'); + const cleared = src.indexOf('clearTareFile()'); + const started = src.indexOf("await runSystemctl('start', pipelineUnits(simulated))"); + + expect(settled).toBeGreaterThan(-1); + expect(cleared).toBeGreaterThan(settled); + expect(cleared).toBeLessThan(started); + // And specifically NOT up beside the config snapshot, where a live service could still win. + expect(cleared).toBeGreaterThan(src.indexOf('snapshotRunConfig(dbDir, simulated)')); + }); + + it('clears in the mock branch too, which is the mode the tests run in', () => { + const src = controllerSrc(); + const mock = src.slice(src.indexOf('(mock) start pipeline')); + expect(mock).toContain('clearTareFile()'); + }); + + it('forgets the held map when the run stops', () => { + const src = fs.readFileSync(path.join(__dirname, '..', 'session-manager.ts'), 'utf8'); + const stop = src.slice(src.indexOf('this.onStopped();') - 600, src.indexOf('this.onStopped();')); + expect(stop).toContain('resetTareState()'); + expect(stop).toContain('setRunDir(null)'); + }); +}); + +describe('the run record', () => { + it('appends one line per change and never rewrites', () => { + const runDir = path.join(TMP, 'daq_20260914_120000'); + setRunDir(runDir); + const jsonl = path.join(runDir, 'lc_tare.jsonl'); + + writeTares([{ entity: 'LC2_Cal.CH1', kg: 20 }]); + loadTareMap(); + + // A re-cal rewrites offset_kg with NO operator action, at the same ADC code. Missing this + // line makes every reconstruction wrong from the re-cal onward. + writeTares([{ entity: 'LC2_Cal.CH1', kg: 20.44 }]); + fs.utimesSync(TARE_FILE, new Date(Date.now() + 5000), new Date(Date.now() + 5000)); + loadTareMap(); + + const lines = fs.readFileSync(jsonl, 'utf8').trim().split('\n').map((l) => JSON.parse(l)); + expect(lines).toHaveLength(2); + expect(lines[0].event).toBe('set'); + expect(lines[0].offsetKg).toBe(20); + expect(lines[1].event).toBe('recal'); + expect(lines[1].offsetKg).toBe(20.44); + expect(lines[1].appliedAtMs).toBeGreaterThanOrEqual(lines[0].appliedAtMs); + }); + + it('records a clear when the tare goes away', () => { + const runDir = path.join(TMP, 'daq_20260914_120001'); + setRunDir(runDir); + writeTares([{ entity: 'LC2_Cal.CH1', kg: 20 }]); + loadTareMap(); + fs.unlinkSync(TARE_FILE); + loadTareMap(); + + const lines = fs + .readFileSync(path.join(runDir, 'lc_tare.jsonl'), 'utf8') + .trim().split('\n').map((l) => JSON.parse(l)); + expect(lines.map((l) => l.event)).toEqual(['set', 'clear']); + }); + + it('applies the tare but writes nothing when there is no active session', () => { + setRunDir(null); + writeTares([{ entity: 'LC2_Cal.CH1', kg: 20 }]); + expect(() => loadTareMap()).not.toThrow(); + expect(tareOffsetKg('LC2_Cal.CH1')).toBe(20); + expect(fs.readdirSync(TMP).filter((f) => f.endsWith('.jsonl'))).toEqual([]); + }); +}); diff --git a/daq-server/diablo_server/backend/src/calibration-handler.ts b/daq-server/diablo_server/backend/src/calibration-handler.ts index d2161778..b0110a40 100644 --- a/daq-server/diablo_server/backend/src/calibration-handler.ts +++ b/daq-server/diablo_server/backend/src/calibration-handler.ts @@ -70,6 +70,19 @@ export function publishCalibrationReload(host: CalibrationHost): void { publishCalibrationCommand(host, 7, 0, 0); } +/** + * Tell a RUNNING calibration service to drop every load-cell tare (cmd 8, clear, all channels). + * + * The primary session-start clear is the backend unlinking lc_tare.json while the service is + * down — synchronous and verifiable. This is the mock-mode companion, where the pipeline is + * already up and a live service would otherwise rewrite the file from memory. Like every + * [0x46,0x00] publish it is fire-and-forget: if the service is down the packet is dropped, which + * is harmless here because the unlink already did the work. + */ +export function publishClearAllTares(host: CalibrationHost): void { + publishCalibrationCommand(host, 8, 0, 1); +} + function getActiveChannels(host: CalibrationHost): number[] { const channels = new Set(); diff --git a/daq-server/diablo_server/backend/src/lc-tare.ts b/daq-server/diablo_server/backend/src/lc-tare.ts new file mode 100644 index 00000000..2aa65498 --- /dev/null +++ b/daq-server/diablo_server/backend/src/lc-tare.ts @@ -0,0 +1,237 @@ +/** + * Load-cell tare: the display-only subtraction, and the per-run record of it. + * + * WHY THE SUBTRACTION LIVES HERE AND NOT IN C++ + * --------------------------------------------- + * The calibration service pushes raw ADC into its CaptureWindow *before* it computes any + * kilograms, which is what makes a calibration capture structurally immune to a standing tare — + * an operator can tare a loaded tank and still calibrate in absolute weight afterwards. Moving + * the subtraction into C++ would put a tared number in front of that boundary and destroy the + * property. So Elodin keeps carrying gross `force_kg` forever, every archive stays comparable, + * and the tare is applied on the way to the browser and nowhere else. + * + * WHY A PARALLEL COMPONENT AND NOT AN IN-PLACE EDIT + * ------------------------------------------------- + * The calibration page must be able to read absolute kilograms — the operator types the true + * weight of a known mass, and a tared readout beside that input is how a false point gets into + * the fit. Mutating `force_kg` would leave that page no way to get gross. So this emits + * `force_kg_tared` alongside the untouched original and the frontend picks which to render. + * + * This is the backend's first derived stream component; everything else it publishes is verbatim + * from the packet. That is a deliberate exception, for the reason above. + */ + +import fs from 'fs'; +import path from 'path'; +import type { ParsedSensorData } from './elodin-protocol.js'; +import { tarePath } from './routes/calibration-profiles.js'; + +/** One channel's tare as the calibration service wrote it. */ +interface TareRecord { + uid: number; + entity: string; + adc_at_tare: number; + offset_kg: number; + set_at_ms: number; + curve_fp: number; +} + +export interface TareEntry { + uid: number; + offsetKg: number; + adcAtTare: number; + setAtMs: number; +} + +/** entity (e.g. "LC2_Cal.CH1") -> tare. Keyed on the string C++ writes; never re-derived here. */ +type TareMap = Map; + +const COMPONENT_GROSS = 'force_kg'; +export const COMPONENT_TARED = 'force_kg_tared'; + +let _cache: { mtimeMs: number; map: TareMap } | null = null; +/** The map currently applied to the stream. Survives a torn read; see loadTareMap(). */ +let _held: TareMap = new Map(); +let _dbDir: string | null = null; + +/** Where the per-run record goes. Null between sessions — see appendRunRecord(). */ +export function setRunDir(dbDir: string | null): void { + _dbDir = dbDir; +} + +/** + * Forget everything. Called at session stop and on an Elodin reconnect: without it a stale + * offset from the previous run is applied to the next run's first poll interval, which is + * exactly the window in which nobody is looking closely yet. + */ +export function resetTareState(): void { + _cache = null; + _held = new Map(); +} + +function parseTareFile(text: string): TareMap { + const map: TareMap = new Map(); + const root = JSON.parse(text) as { tares?: TareRecord[] }; + if (!root || !Array.isArray(root.tares)) throw new Error('no tares array'); + for (const t of root.tares) { + if (!t || typeof t.entity !== 'string' || !t.entity) continue; + // A non-finite offset is refused rather than clamped to 0: it means the service wrote + // something it should not have, and silently treating it as "no tare" would hide that. + if (!Number.isFinite(t.offset_kg)) continue; + map.set(t.entity, { + uid: Number(t.uid) || 0, + offsetKg: t.offset_kg, + adcAtTare: Number.isFinite(t.adc_at_tare) ? t.adc_at_tare : 0, + setAtMs: Number.isFinite(t.set_at_ms) ? t.set_at_ms : 0, + }); + } + return map; +} + +/** + * The live tare map, reloaded when the file's mtime moves. + * + * Three outcomes, deliberately distinguished by error code rather than a bare catch: + * - file absent (ENOENT) -> empty map. This is the normal state after a session-start clear. + * - file unreadable/torn -> KEEP the previous map. The service writes tmp+rename, but a + * reader can still lose a race; clearing on a transient parse + * failure would make every LC plot jump by the tare amount for one + * poll interval and then jump back. + * - file parsed -> adopt it, and append any change to the run record. + */ +export function loadTareMap(): TareMap { + let stat: fs.Stats; + try { + stat = fs.statSync(tarePath()); + } catch (e) { + if ((e as NodeJS.ErrnoException).code === 'ENOENT') { + if (_held.size > 0) { + recordChanges(_held, new Map()); + _held = new Map(); + } + _cache = null; + return _held; + } + return _held; // permissions, I/O — hold what we have + } + + if (_cache && _cache.mtimeMs === stat.mtimeMs) return _cache.map; + + let next: TareMap; + try { + next = parseTareFile(fs.readFileSync(tarePath(), 'utf8')); + } catch { + return _held; // torn or malformed — keep the last good map, do NOT clear + } + + recordChanges(_held, next); + _held = next; + _cache = { mtimeMs: stat.mtimeMs, map: next }; + return next; +} + +/** The offset to subtract for a cal entity. 0 when untared, absent, or non-finite. */ +export function tareOffsetKg(entity: string): number { + const t = loadTareMap().get(entity); + if (!t) return 0; + return Number.isFinite(t.offsetKg) ? t.offsetKg : 0; +} + +/** + * Add `force_kg_tared` for every calibrated load-cell point in `parsed`. + * + * Emits unconditionally, with a 0 offset when untared: a component that appears only while a + * tare is set renders identically to a dead stream, and "is that channel untared or is the board + * gone?" is not a question to ask an operator mid-procedure. + * + * Never touches `force_kg`, and never touches the `raw_adc`/`raw_adc_counts` echoes that ride in + * the same array — those are the raw truth and feed calibration capture. + */ +export function expandWithTare(parsed: ParsedSensorData[]): ParsedSensorData[] { + let out: ParsedSensorData[] | null = null; + for (const p of parsed) { + if (p.component !== COMPONENT_GROSS) continue; + const offset = tareOffsetKg(p.entity); + const value = p.value - (Number.isFinite(offset) ? offset : 0); + // Guard again after the arithmetic: this value is produced downstream of the protocol + // layer's finite check, so a NaN here would be dropped by the caller's own guard and the + // entire tared series would vanish from the plot with nothing logged anywhere. + if (!Number.isFinite(value)) continue; + if (!out) out = [...parsed]; + out.push({ entity: p.entity, component: COMPONENT_TARED, value, timestamp: p.timestamp }); + } + return out ?? parsed; +} + +/** + * Remove the live tare file. Session start only, and only in the window where the calibration + * service is confirmed down — a running service holds its tares in memory and would rewrite the + * file from them on its next periodic save or clean shutdown, so an unlink at the wrong moment + * silently fails to clear. + * + * This is the one place Node writes to a file C++ owns in steady state. It is safe precisely + * because nothing is running to race with. + */ +export function clearTareFile(): void { + try { + fs.unlinkSync(tarePath()); + } catch (e) { + if ((e as NodeJS.ErrnoException).code !== 'ENOENT') { + console.warn('[LcTare] could not remove the live tare file:', e); + } + } + resetTareState(); +} + +/** + * Append what changed to /lc_tare.jsonl. + * + * The record is written by whoever applies the subtraction, at the instant the applied value + * changes — not when the operator clicks, and not when C++ captures. Two reasons. The browser + * can close between the command and the response while the tare still applies, which would + * leave a run whose archive cannot be reconstructed for a tare that demonstrably happened. And + * a re-cal rewrites offset_kg with no operator action at all, so there is no click to hang a + * write off; this path covers it for free. + */ +function recordChanges(prev: TareMap, next: TareMap): void { + if (!_dbDir) return; // a tare with no active session still applies; it just is not recorded + const lines: string[] = []; + const at = Date.now(); + + for (const [entity, t] of next) { + const before = prev.get(entity); + if (before && before.offsetKg === t.offsetKg && before.adcAtTare === t.adcAtTare) continue; + // Same code, different offset means the curve moved under a standing tare, not a new tare. + const event = !before ? 'set' : before.adcAtTare === t.adcAtTare ? 'recal' : 'set'; + lines.push( + JSON.stringify({ + entity, uid: t.uid, event, offsetKg: t.offsetKg, + adcAtTare: t.adcAtTare, setAtMs: t.setAtMs, appliedAtMs: at, + }), + ); + } + for (const [entity, t] of prev) { + if (next.has(entity)) continue; + lines.push( + JSON.stringify({ + entity, uid: t.uid, event: 'clear', offsetKg: 0, + adcAtTare: t.adcAtTare, setAtMs: t.setAtMs, appliedAtMs: at, + }), + ); + } + if (lines.length === 0) return; + + try { + fs.mkdirSync(_dbDir, { recursive: true }); + // Append, never rewrite: each line is a thing that happened. There is no terminal line at + // session stop, so a missing trailing 'clear' unambiguously means "in effect to end of run". + fs.appendFileSync(path.join(_dbDir, 'lc_tare.jsonl'), lines.join('\n') + '\n'); + } catch (e) { + console.warn('[LcTare] could not append the run record:', e); + } +} + +/** The live tares, for the status endpoint the calibration UI polls. */ +export function currentTares(): Array { + return [...loadTareMap()].map(([entity, t]) => ({ entity, ...t })); +} diff --git a/daq-server/diablo_server/backend/src/routes/calibration-profiles.ts b/daq-server/diablo_server/backend/src/routes/calibration-profiles.ts index 097d2635..5ee221b0 100644 --- a/daq-server/diablo_server/backend/src/routes/calibration-profiles.ts +++ b/daq-server/diablo_server/backend/src/routes/calibration-profiles.ts @@ -46,6 +46,18 @@ function getCalibrationsDir(): string { export function livePath(): string { return path.join(getCalibrationsDir(), 'cubic_calibration.json'); } +/** + * The live load-cell tare state, written by the calibration service and read here to apply the + * display subtraction. Lives beside the cubic store on purpose — same directory, same atomic + * write discipline, same "read the file to learn the answer" contract, because [0x46,0x00] is + * one-way and the service never sends a reply. + * + * NOT the per-run record: that is /lc_tare.jsonl, appended by lc-tare.ts. This file is + * overwritten on every change and removed at session start, so it cannot serve as a record. + */ +export function tarePath(): string { + return path.join(getCalibrationsDir(), 'lc_tare.json'); +} function defaultPath(): string { return path.join(getCalibrationsDir(), 'cubic_calibration.default.json'); } diff --git a/daq-server/diablo_server/backend/src/server.ts b/daq-server/diablo_server/backend/src/server.ts index 7023acf1..9559f13c 100644 --- a/daq-server/diablo_server/backend/src/server.ts +++ b/daq-server/diablo_server/backend/src/server.ts @@ -26,6 +26,7 @@ import * as http from 'http'; import WebSocket, { WebSocketServer } from 'ws'; import { ElodinClient } from './elodin-client.js'; import { parseElodinPacket } from './elodin-protocol.js'; +import { expandWithTare, resetTareState, setRunDir } from './lc-tare.js'; import { loadSensorRoleMap, hpBoardNumbers } from './sensor-config.js'; import { registerVTables, clearSubscriptionState, noteSubscriptionRejected } from './elodin-vtable-registry.js'; import { createAPIHandler } from './api-server.js'; @@ -43,7 +44,7 @@ import { ClientOutbox, FlushPacer, SOCKET_IDLE_BYTES, linkStatus } from './clien import { sendBackfill, type HistoryPayload } from './history-backfill.js'; import { HistoryCache } from './history-cache.js'; import { startGuiStaticServer } from './static-gui.js'; -import { handleCalibrationCommand, publishCalibrationReload, type CalibrationHost } from './calibration-handler.js'; +import { publishClearAllTares, handleCalibrationCommand, publishCalibrationReload, type CalibrationHost } from './calibration-handler.js'; import { loadPTCalibration, type CalibrationCoefficients } from './calibration.js'; import { MessageType, SystemState } from '../../shared/types.js'; import { isOperator } from './operators.js'; @@ -1530,7 +1531,13 @@ elodin.on('packet', (header: any, payload: Buffer) => { } // ── Parse sensor/actuator/state packets ────────────────────────────────── - const parsedList = parseElodinPacket(header.packetId, payload, _hpBoardNumbers); + // expandWithTare appends a derived `force_kg_tared` for each calibrated load-cell point and + // leaves `force_kg` alone. It belongs HERE rather than inside parseElodinPacket (a pure, + // stateless decoder that elodin-query.ts also uses to replay the archive — taring there + // would apply today's offset to yesterday's samples) and rather than in the outbox drain + // (which would leave the live stream tared and the reconnect backfill gross, because + // emitSensorWindow records to history before it stages to any client). + const parsedList = expandWithTare(parseElodinPacket(header.packetId, payload, _hpBoardNumbers)); if (parsedList.length === 0) { if (high >= 0x40) { @@ -1709,7 +1716,12 @@ httpServer.listen(WS_PORT, () => { sessionManager.init(broadcast, broadcastNotification, () => { loadBoardsFromConfig(); broadcastBoardStatus(); - }, applyDeployedConfigChange); + }, applyDeployedConfigChange, () => { + // The run pipeline is up. Every session starts with every load cell reading absolute: the + // controller already removed lc_tare.json, and this drops the copy a still-running service + // holds in memory (mock mode, where nothing went down to reload it). + publishClearAllTares(calibrationHost); +}); // Board diagnostic logs (type-15 LOGS forwarded by daq_bridge over loopback UDP). startBoardLogReceiver(broadcast); }); diff --git a/daq-server/diablo_server/backend/src/service-controller.ts b/daq-server/diablo_server/backend/src/service-controller.ts index 803b320f..b8e1ba8e 100644 --- a/daq-server/diablo_server/backend/src/service-controller.ts +++ b/daq-server/diablo_server/backend/src/service-controller.ts @@ -14,6 +14,7 @@ import { writeFileSync, mkdirSync, readFileSync, existsSync, copyFileSync } from import { dirname, join } from 'path'; import { fileURLToPath } from 'url'; import { homedir } from 'os'; +import { clearTareFile, setRunDir } from './lc-tare.js'; // daq-server repo root (…/daq-server), from …/daq-server/diablo_server/backend/{src,dist}. const PROJECT_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..'); @@ -168,6 +169,15 @@ export class ServiceController { // Never start onto a not-yet-torn-down previous run — a lingering daq_bridge // still owns :5006 and the new one would crash-loop. Wait for a clean slate. await waitUntilSettled(pipelineUnits(true)); + // Every session begins with every load cell reading absolute. + // + // This must sit between waitUntilSettled and the start below, and nowhere else. Earlier — + // beside snapshotRunConfig, say — the PREVIOUS run's calibration_service may still be + // alive; it holds its tares in memory and rewrites the file from them on its next periodic + // save or clean shutdown, so the unlink silently fails to clear. Only here is every + // process confirmed gone and the next one not yet started. + clearTareFile(); + setRunDir(dbDir); await runSystemctl('start', pipelineUnits(simulated)); // A run isn't real unless the DB actually came up. If elodin-db is missing/broken, // sensor-elodin hard-fails (AssertPathExists) or crash-loops — without this check the @@ -181,6 +191,15 @@ export class ServiceController { } } else { console.log(`[Session] (mock) start pipeline → ${dbDir} (simulated=${simulated})`); + // Mock mode is a second lifecycle: the pipeline is already up, so there is no + // service-is-down window and the unlink above would race a live writer. Clear the file AND + // tell the running service to drop its in-memory tares. The fire-and-forget weakness of + // that command is acceptable here precisely because the service IS up to receive it. + // + // Doing this only in the systemd branch would leave every dev and test session inheriting + // the previous session's tares — and mock is the mode the tests run in. + clearTareFile(); + setRunDir(dbDir); // In mock mode the pipeline is already running; only the simulator is ours // to start/stop for the run. if (simulated) this.spawnSimulator(); diff --git a/daq-server/diablo_server/backend/src/session-manager.ts b/daq-server/diablo_server/backend/src/session-manager.ts index 335fdf57..a5316f5b 100644 --- a/daq-server/diablo_server/backend/src/session-manager.ts +++ b/daq-server/diablo_server/backend/src/session-manager.ts @@ -16,6 +16,7 @@ import { ServiceController, getSessionServiceMode } from './service-controller.j import { loadSession, saveSession } from './session-state.js'; import { deployActiveProfile } from './routes/config-profiles.js'; import { validateActiveProfile, ConfigIssuesError } from './config-validation.js'; +import { resetTareState, setRunDir } from './lc-tare.js'; // Warn the operator at each of these leads before auto-stop. Default: 5 min and // 1 min. Override with SESSION_WARN_LEADS_MS (comma-separated ms) to exercise the @@ -91,6 +92,11 @@ class SessionManager { * its config-derived caches and tell open browsers to refetch. Without it a profile edited * during a run reached config.toml here and no client ever heard about it. */ private onConfigDeployed: () => void = () => {}; + /** Fired once the run pipeline is up, so the backend can tell a RUNNING calibration service to + * drop its in-memory load-cell tares. The file itself is already gone by then (the controller + * unlinks it while the service is down), but in mock mode the service never went down and + * would rewrite the file from memory on its next save. */ + private onSessionStarted: () => void = () => {}; private active = false; private dbDir: string | null = null; @@ -106,11 +112,13 @@ class SessionManager { notify: Notify, onStopped: () => void = () => {}, onConfigDeployed: () => void = () => {}, + onSessionStarted: () => void = () => {}, ): void { this.broadcast = broadcast; this.notify = notify; this.onStopped = onStopped; this.onConfigDeployed = onConfigDeployed; + this.onSessionStarted = onSessionStarted; if (!this.enabled) return; // Recover a session that outlived a backend restart. const persisted = loadSession(); @@ -294,6 +302,7 @@ class SessionManager { } } await this.controller.start(this.dbDir, this.simulated); + this.onSessionStarted(); this.active = true; this.scheduleTimers(); this.persist(); @@ -336,6 +345,10 @@ class SessionManager { this.simulated = false; this.persist(); this.emit(); + // Forget the tare: there is no run to record against any more, and a held offset would + // otherwise be applied to the next run's first poll interval before the file check notices. + setRunDir(null); + resetTareState(); this.onStopped(); // revert board status to the disconnected baseline } From 0da4c28a23e910eb29b048e6c621c0882ca8727c Mon Sep 17 00:00:00 2001 From: Aidan Rickert Date: Mon, 14 Sep 2026 15:39:07 -0700 Subject: [PATCH 05/13] daq: tare buttons on the load-cell panel, with the calibration page held to absolute weight --- .../diablo_server/backend/src/api-server.ts | 14 ++ .../backend/src/calibration-handler.ts | 38 +++++ .../frontend/__tests__/lc-tare.test.ts | 136 ++++++++++++++++ .../frontend/app/calibration/page.tsx | 32 +++- .../frontend/app/plots/chamber/page.tsx | 2 +- .../frontend/app/plots/lcs-tcs-rtd/page.tsx | 149 +++++++++++++++++- .../diablo_server/frontend/lib/store.ts | 23 ++- daq-server/diablo_server/shared/types.ts | 4 +- 8 files changed, 386 insertions(+), 12 deletions(-) create mode 100644 daq-server/diablo_server/frontend/__tests__/lc-tare.test.ts diff --git a/daq-server/diablo_server/backend/src/api-server.ts b/daq-server/diablo_server/backend/src/api-server.ts index 703d926b..285895e3 100644 --- a/daq-server/diablo_server/backend/src/api-server.ts +++ b/daq-server/diablo_server/backend/src/api-server.ts @@ -34,6 +34,7 @@ import { otaBuildFlash, otaFlashFirmwareFile } from './ota-service-cmd.js'; import { ElodinQueryClient, QueryOptions } from './elodin-query.js'; import { getBoardLogHistory, getBoardLogStats } from './board-logs.js'; import type { SensorUpdate } from './shared-types.js'; +import { currentTares } from './lc-tare.js'; // ── Sensor config helpers ────────────────────────────────────────────────── @@ -813,6 +814,19 @@ export function createAPIHandler(opts: APIHandlerOptions = {}): (req: IncomingMe try { JSON.parse(body); res.end(body); } catch { res.end(JSON.stringify({ cubic_state: {} })); } // partial/corrupt → empty } + } else if (url.pathname === '/api/lc_tare' && req.method === 'GET') { + // The live load-cell tares, for the UI to badge tared channels and show their offsets. + // + // Read from the same file the stream subtraction uses, so the badge and the number on + // the plot can never disagree. This is the authoritative source for tare UI state — do + // NOT let the page assume a tare landed because it sent the command; [0x46,0x00] carries + // no reply, so only the file says whether the service accepted it. + res.writeHead(200, { 'Content-Type': 'application/json' }); + try { + res.end(JSON.stringify({ tares: currentTares() })); + } catch { + res.end(JSON.stringify({ tares: [] })); + } } else if (url.pathname === '/api/feed-char/results') { /** * Feed-characterization results, stored WITH the run rather than in the browser. diff --git a/daq-server/diablo_server/backend/src/calibration-handler.ts b/daq-server/diablo_server/backend/src/calibration-handler.ts index b0110a40..4a3d05d9 100644 --- a/daq-server/diablo_server/backend/src/calibration-handler.ts +++ b/daq-server/diablo_server/backend/src/calibration-handler.ts @@ -223,6 +223,44 @@ export function handleCalibrationCommand( console.log(`🗑️ Cubic clear: CH${sensorId} (Board ${boardId}) → calibration_service`); break; } + case 'tare_lc': + case 'clear_tare_lc': { + // Display-only. Never enters a fit, never reaches control or abort, never changes what + // Elodin records — the archive keeps carrying absolute force_kg. That is the whole + // reason this is a separate command from 'zero_all', which captures a REAL 0 kg point + // into the shared fit: correct for a vented PT, and wrong for a load cell holding a + // tank, where the point would be false and would tilt the entire cubic. + const clearing = commandType === 'clear_tare_lc'; + // sensorId 0 (or omitted) means every load cell. + const all = sensorId == null || sensorId === 0; + if (!all && uniqueId == null) { + host.send(ws, { + type: MessageType.ERROR, timestamp: Date.now(), + payload: { message: `${commandType} requires sensorId and boardId, or neither for all` } + }); + return; + } + if (!all) { + const activeChannels = getActiveChannels(host); + if (uniqueId == null || !activeChannels.includes(uniqueId)) { + host.send(ws, { + type: MessageType.ERROR, timestamp: Date.now(), + payload: { message: `Unknown channel for ${commandType}: CH${sensorId} on board ${boardId}` } + }); + return; + } + } + if (!host.elodin) { + host.send(ws, { + type: MessageType.ERROR, timestamp: Date.now(), + payload: { message: `Elodin not connected — cannot forward ${commandType}.` } + }); + return; + } + publishCalibrationCommand(host, 8, all ? 0 : uniqueId!, clearing ? 1 : 0); + console.log(`⚖️ LC ${clearing ? 'tare clear' : 'tare'}: ${all ? 'all load cells' : `CH${sensorId} (Board ${boardId})`} → calibration_service`); + break; + } case 'capture_point': { // Unified capture: forward (channel, ref PSI); calibration_service pairs it with the // current ADC and routes it to the channel's configured model (cubic fit OR robust RLS). diff --git a/daq-server/diablo_server/frontend/__tests__/lc-tare.test.ts b/daq-server/diablo_server/frontend/__tests__/lc-tare.test.ts new file mode 100644 index 00000000..9d306ded --- /dev/null +++ b/daq-server/diablo_server/frontend/__tests__/lc-tare.test.ts @@ -0,0 +1,136 @@ +/** + * Load-cell tare: the frontend half. + * + * The frontend does no tare arithmetic at all — the backend publishes `force_kg_tared` as its own + * component and the page picks which one to render. What can still go wrong is picking the wrong + * one, and both directions are bad in a specific way: + * + * - a readout left on `force_kg` while the plot under it uses `force_kg_tared` shows 0 beside a + * trace sitting at 20, which is exactly the confusion the feature exists to remove; + * - the calibration page moved onto `force_kg_tared` would show a tared number beside the input + * where the operator types the true weight of a known mass, and that false point would tilt + * the whole cubic. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import fs from 'fs'; +import path from 'path'; +import { useSensorStore, useLoadCellForceKg, buildAliasesFromConfig } from '@/lib/store'; +import { renderHook } from '@testing-library/react'; +import { waitForSensorFlush } from './waitForSensorFlush'; + +const src = (rel: string) => fs.readFileSync(path.join(__dirname, '..', rel), 'utf8'); + +describe('useLoadCellForceKg', () => { + beforeEach(() => { + useSensorStore.setState({ sensorData: {} }); + }); + + it('renders the tared component, not gross', async () => { + const { updateSensor } = useSensorStore.getState(); + updateSensor({ entity: 'LC2_Cal.CH1', component: 'force_kg', value: 50, timestamp: Date.now() }); + updateSensor({ entity: 'LC2_Cal.CH1', component: 'force_kg_tared', value: 30, timestamp: Date.now() }); + await waitForSensorFlush(); + + const { result } = renderHook(() => useLoadCellForceKg('LC2_Cal.CH1')); + expect(result.current).toBe(30); + }); + + it('falls back to null rather than gross when the tared component is absent', async () => { + // Not a cosmetic choice: silently substituting gross would make an untared readout + // indistinguishable from a backend that stopped publishing the derived component. + const { updateSensor } = useSensorStore.getState(); + updateSensor({ entity: 'LC2_Cal.CH1', component: 'force_kg', value: 50, timestamp: Date.now() }); + await waitForSensorFlush(); + + const { result } = renderHook(() => useLoadCellForceKg('LC2_Cal.CH1')); + expect(result.current).toBeNull(); + }); +}); + +describe('force_lbf / force_n stay derived from absolute weight', () => { + it('derives from force_kg and ignores force_kg_tared', async () => { + // These are archive-comparable legacy aliases. Re-taring them silently produces "why does the + // N readout disagree with the kg readout" with nothing in the code pointing at the cause. + useSensorStore.setState({ sensorData: {} }); + const { updateSensor } = useSensorStore.getState(); + updateSensor({ entity: 'LC2_Cal.CH1', component: 'force_kg', value: 10, timestamp: Date.now() }); + updateSensor({ entity: 'LC2_Cal.CH1', component: 'force_kg_tared', value: 1, timestamp: Date.now() }); + await waitForSensorFlush(); + + const data = useSensorStore.getState().sensorData; + expect(data['LC2_Cal.CH1.force_n']).toBeCloseTo(10 * 9.80665, 4); + expect(data['LC2_Cal.CH1.force_lbf']).toBeCloseTo(10 * 2.2046226218, 4); + }); +}); + +describe('which page reads which component', () => { + it('the calibration page reads absolute weight only', () => { + const cal = src('app/calibration/page.tsx'); + expect(cal).toContain("'force_kg'"); + expect(cal).not.toContain('force_kg_tared'); + }); + + it('the LC readout and the plot beneath it read the same component', () => { + const page = src('app/plots/lcs-tcs-rtd/page.tsx'); + // The readout goes through useLoadCellForceKg (tared); the plot must match it. A bare + // component="force_kg" here is the split-screen bug. + expect(page).toContain('useLoadCellForceKg'); + expect(page).toContain('component="force_kg_tared"'); + expect(page).not.toContain('component="force_kg"'); + }); + + it('the chamber page plots the same component its readout uses', () => { + const page = src('app/plots/chamber/page.tsx'); + expect(page).toContain('useLoadCellForceKg'); + expect(page).toContain('component="force_kg_tared"'); + expect(page).not.toContain('component="force_kg"'); + }); + + it('the alias table carries force_kg_tared, or role-name lookups cannot resolve it', async () => { + // Behavioural, not a string search: store.ts mentions force_kg_tared in several places, so + // grepping for it would pass even with the component missing from lcComponents — and the + // symptom would be a role-named readout (LC2_Cal.LOX_Scale) silently reading nothing. + useSensorStore.setState({ sensorData: {} }); + buildAliasesFromConfig({ + boards: { lc_board: { type: 'LC', board_id: 42, active_connectors: [1] } }, + sensor_roles_lc_board: { 'LOX Scale': 1 }, + }); + const { updateSensor, getSensorValue } = useSensorStore.getState(); + updateSensor({ entity: 'LC2_Cal.CH1', component: 'force_kg_tared', value: 30, timestamp: Date.now() }); + await waitForSensorFlush(); + expect(getSensorValue('LC2_Cal.LOX_Scale', 'force_kg_tared')).toBe(30); + }); +}); + +describe('the tare uid', () => { + it('comes from the config row, never from the entity string', () => { + // "LC2_Cal.CH1" carries the Elodin slot (board_id % 10), not the board id, so parsing a uid + // back out of it collides two boards that share a slot — the failure that once wrote a load + // cell's curve over a 5000 psi transducer's. + const page = src('app/plots/lcs-tcs-rtd/page.tsx'); + expect(page).toContain('r.boardId * 100 + r.channel'); + expect(page).not.toMatch(/LC(\d+)_Cal.*match|match.*LC\\d\+_Cal/); + }); + + it('sends sensorId and boardId split back out of that uid', () => { + const page = src('app/plots/lcs-tcs-rtd/page.tsx'); + expect(page).toContain('sensorId: uid % 100'); + expect(page).toContain('boardId: Math.floor(uid / 100)'); + }); +}); + +describe('tare state comes from the backend', () => { + it('polls the status endpoint rather than assuming a click worked', () => { + // [0x46,0x00] carries no reply and the service can refuse a tare outright when the stream is + // stale. An optimistic zero would be a lie about a load cell. + const page = src('app/plots/lcs-tcs-rtd/page.tsx'); + expect(page).toContain('/api/lc_tare'); + // Narrow to the command sender: asserting the page merely mentions setTarePending passes on + // the strength of its own useState declaration. What matters is that sending a command marks + // the tare unconfirmed and does NOT write the offset straight into local state. + const sender = page.slice(page.indexOf('const sendTareCmd'), page.indexOf('const anyTared')); + expect(sender).toContain('setTarePending(true)'); + expect(sender).not.toContain('setLcTares'); + }); +}); diff --git a/daq-server/diablo_server/frontend/app/calibration/page.tsx b/daq-server/diablo_server/frontend/app/calibration/page.tsx index b8e5e44f..7e21409e 100644 --- a/daq-server/diablo_server/frontend/app/calibration/page.tsx +++ b/daq-server/diablo_server/frontend/app/calibration/page.tsx @@ -298,6 +298,23 @@ export default function CalibrationPage() { return () => clearInterval(id); }, [fetchCubic]); + // Whether any load cell currently has a standing tare. This page always shows ABSOLUTE weight + // — the operator types the true weight of a known mass, and a tared reading beside that input + // is exactly how a false point gets into the fit — so when a tare is live it has to say so, + // or the number here silently disagrees with every other screen. + const [anyLcTared, setAnyLcTared] = useState(false); + useEffect(() => { + const poll = () => { + fetch(`${getApiBaseUrl()}/api/lc_tare`) + .then((r) => (r.ok ? r.json() : null)) + .then((d) => setAnyLcTared(((d?.tares ?? []) as unknown[]).length > 0)) + .catch(() => {}); + }; + poll(); + const id = setInterval(poll, 2000); + return () => clearInterval(id); + }, []); + const sendCalCmd = useCallback((cmd: CalibrationCommand) => { ws.send({ type: MessageType.CALIBRATION_COMMAND, timestamp: Date.now(), payload: cmd }); setTimeout(fetchCubic, 350); @@ -465,7 +482,14 @@ export default function CalibrationPage() { )} {/* Global action: Zero all — captures a 0 reference point on every cubic/robust PT + LC - sensor. It's a real point (feeds the shared fit + persists), not a tare. */} + sensor. It's a real point (feeds the shared fit + persists), NOT a tare. + + The distinction, since load cells now have a Tare button on the LC/TC/RTD page: a + vented PT genuinely IS at 0 psig, so a zero is a true reference point and belongs in + the fit. A load cell holding a tank is NOT at 0 kg — capturing that would inject a + false point, and because the fit is least-squares over every point it would tilt the + whole cubic rather than shift its intercept. So LC gets a display-only tare that + never touches the fit, and this button still means "unloaded". */} {(counts.cubic + counts.robust + lcCounts.cubic) > 0 && (
+ {tared && ( + + −{offsetKg!.toFixed(1)} kg + + )} +
+
+ ); } export default function LCS_TCS_RTDPage() { @@ -140,6 +178,16 @@ export default function LCS_TCS_RTDPage() { const [lcEntities, setLcEntities] = useState([]); const [lcCalEntities, setLcCalEntities] = useState([]); const [lcLabels, setLcLabels] = useState([]); + // uid = boardId * 100 + connector, taken from the config rows. NEVER parsed back out of an + // entity string: the number in "LC2_Cal.CH1" is the Elodin slot (board_id % 10), not the board + // id, so two boards sharing a slot would resolve to the same uid — the collision that once put + // a load cell's curve on a 5000 psi transducer. + const [lcUids, setLcUids] = useState([]); + /** entity -> offset kg, polled from the backend. The source of truth for what is tared. */ + const [lcTares, setLcTares] = useState>({}); + const [sessionActive, setSessionActive] = useState(false); + /** Set when a tare command was sent and the backend has not confirmed it yet. */ + const [tarePending, setTarePending] = useState(false); const loadChannelConfig = useCallback(() => { Promise.all([ @@ -177,6 +225,7 @@ export default function LCS_TCS_RTDPage() { setLcEntities(lc.map((r) => r.entity)); setLcCalEntities(lc.map((r) => r.calEntity)); setLcLabels(lc.map((r) => r.label)); + setLcUids(lc.map((r) => r.boardId * 100 + r.channel)); } }).catch(() => {}); }, []); @@ -191,6 +240,59 @@ export default function LCS_TCS_RTDPage() { return () => { unsub(); }; }, [ws, loadChannelConfig]); + // A tare is only real once the backend says so. [0x46,0x00] carries no reply, so a click tells + // us nothing: the calibration service can refuse a tare outright when the stream is stale, and + // an optimistic zero would be a lie about a load cell. Poll the file the subtraction itself + // reads, so the badge and the number can never disagree. + const fetchTares = useCallback(() => { + fetch(`${getApiBaseUrl()}/api/lc_tare`) + .then((r) => (r.ok ? r.json() : null)) + .then((d) => { + if (!d) return; + const next: Record = {}; + for (const t of (d.tares ?? []) as Array<{ entity: string; offsetKg: number }>) { + if (Number.isFinite(t.offsetKg)) next[t.entity] = t.offsetKg; + } + setLcTares(next); + }) + .catch(() => {}); + }, []); + + useEffect(() => { + fetchTares(); + const id = setInterval(fetchTares, 2000); + return () => clearInterval(id); + }, [fetchTares]); + + // No live stream means no fresh ADC to tare against, so the service would refuse anyway. + useEffect(() => { + const unsub = ws.on(MessageType.SESSION_UPDATE, (p: unknown) => + setSessionActive(!!(p as { active?: boolean })?.active)); + fetch(`${getApiBaseUrl()}/api/config/profiles`) + .then((r) => (r.ok ? r.json() : null)) + .then((d) => { if (d) setSessionActive(!!d.sessionActive); }) + .catch(() => {}); + return () => { unsub(); }; + }, [ws]); + + const sendTareCmd = useCallback((commandType: 'tare_lc' | 'clear_tare_lc', uid?: number) => { + ws.send({ + type: MessageType.CALIBRATION_COMMAND, + timestamp: Date.now(), + payload: uid == null + ? { commandType } + : { commandType, sensorId: uid % 100, boardId: Math.floor(uid / 100) }, + }); + // Accelerate the poll rather than assuming an outcome. If nothing changes within ~2 s the + // banner says so, instead of a button that looks like it worked. + setTarePending(true); + const t0 = Date.now(); + const quick = setInterval(fetchTares, 120); + setTimeout(() => { clearInterval(quick); setTarePending(false); void t0; }, 2200); + }, [ws, fetchTares]); + + const anyTared = lcCalEntities.some((e) => lcTares[e] != null); + const tcEntities = tcData.map((d) => d.entity); // d.calEntity, never a string replace: entities are board-scoped (TC1.CH2), so // 'TC1.CH2'.replace('TC.', 'TC_Cal.') silently returns the raw entity unchanged. @@ -314,9 +416,48 @@ export default function LCS_TCS_RTDPage() {
{lcEntities.length > 0 ? ( <> +
+ + {anyTared + ? 'Tared \u2014 showing weight relative to the tared load. Calibration is unaffected.' + : 'Showing absolute weight.'} + +
+ {anyTared && ( + + )} + +
+
+ {tarePending && ( +
+ Waiting for the calibration service to confirm\u2026 +
+ )}
{lcEntities.map((entity, i) => ( sendTareCmd('tare_lc', lcUids[i])} + onClearTare={() => sendTareCmd('clear_tare_lc', lcUids[i])} + disabled={!sessionActive || lcUids[i] == null} + disabledReason={sessionActive ? 'No uid for this channel in config.' : 'Start a session to tare \u2014 a tare needs a live stream.'} key={entity} entity={entity} calEntity={lcCalEntities[i]} @@ -341,7 +482,7 @@ export default function LCS_TCS_RTDPage() { ((set, get) => ({ // Back-compat for legacy LC panes expecting force_lbf / force_n. // Backend now publishes canonical force_kg. + // + // These stay derived from ABSOLUTE force_kg, never from force_kg_tared. They are + // archive-comparable legacy aliases with no consumers outside the alias list, and silently + // re-taring them would produce "why does the N readout disagree with the kg readout" with + // nothing in the code pointing at the cause. if (update.component === 'force_kg') { const lbf = update.value * 2.2046226218; const n = update.value * 9.80665; @@ -753,11 +758,19 @@ export function useActuatorStateByEntity(entity: string): ActuatorState | null { return useSensorStore((s) => s.actuatorStateByEntity[entity] ?? null); } -/** Load cell force (kg), absolute. The calibration service is the sole source of the value. */ +/** + * Load cell force (kg) as the operator should read it: tared when a tare is standing, absolute + * otherwise. The backend derives `force_kg_tared` and always publishes it — a 0 offset when + * untared — so there is no arithmetic here and no way for this to disagree with the plots. + * + * Use `useSensorValue(calEntity, 'force_kg')` directly where ABSOLUTE weight is required. The + * calibration page is the one that must: the operator types the true weight of a known mass, and + * a tared reading beside that input is how a false point gets into the fit. + */ export function useLoadCellForceKg(calEntity: string): number | null { - const raw = useSensorValue(calEntity, 'force_kg'); - if (raw == null || !Number.isFinite(raw)) return null; - return raw; + const v = useSensorValue(calEntity, 'force_kg_tared'); + if (v == null || !Number.isFinite(v)) return null; + return v; } /** @deprecated Use useLoadCellForceKg instead. Legacy alias for backwards compatibility. */ diff --git a/daq-server/diablo_server/shared/types.ts b/daq-server/diablo_server/shared/types.ts index f7346475..93e738bf 100644 --- a/daq-server/diablo_server/shared/types.ts +++ b/daq-server/diablo_server/shared/types.ts @@ -298,7 +298,9 @@ export type CalibrationCommandType = | 'capture_cubic_point' // add one (current ADC, ref PSI) point to a channel's cubic fit | 'clear_cubic_channel' // drop a channel's cubic points and revert to the factory cubic | 'capture_point' // unified: add one (current ADC, ref PSI) point; service routes by config model - | 'new_calibration'; // unified: start fresh for a channel; service routes clear by config model + | 'new_calibration' // unified: start fresh for a channel; service routes clear by config model + | 'tare_lc' // load cell: display-only zero at the current load; never enters the fit + | 'clear_tare_lc'; // load cell: drop the tare, back to absolute export interface CalibrationCommand { commandType: CalibrationCommandType; From 01c1d12baa8d4853f39e46726d664d3c3d211524 Mon Sep 17 00:00:00 2001 From: Aidan Rickert Date: Mon, 14 Sep 2026 15:45:31 -0700 Subject: [PATCH 06/13] webviewer: replay a run's load-cell tares as a derived channel beside the absolute one --- .../webviewer/backend/lc_tare.py | 127 ++++++++++++ .../webviewer/backend/naming.py | 2 +- .../webviewer/backend/run_config.py | 11 +- .../webviewer/backend/series.py | 22 +- .../webviewer/backend/test_lc_tare.py | 192 ++++++++++++++++++ 5 files changed, 350 insertions(+), 4 deletions(-) create mode 100644 daq-server/tools/postprocessing/webviewer/backend/lc_tare.py create mode 100644 daq-server/tools/postprocessing/webviewer/backend/test_lc_tare.py diff --git a/daq-server/tools/postprocessing/webviewer/backend/lc_tare.py b/daq-server/tools/postprocessing/webviewer/backend/lc_tare.py new file mode 100644 index 00000000..29b48285 --- /dev/null +++ b/daq-server/tools/postprocessing/webviewer/backend/lc_tare.py @@ -0,0 +1,127 @@ +"""Load-cell tares recorded during a run, and the tared series derived from them. + +Elodin records ABSOLUTE force, always and forever — `LC_Cal.CH.force_kg` means the same +thing in every run ever archived, and this feature did not change that. A tare is display state: +the backend subtracts it on the way to the browser and appends what it did to +`/lc_tare.jsonl`, so what the operator was looking at stays reconstructible afterwards. + +This module replays that record. It exposes the reconstruction as a synthetic component, +`…force_kg_tared`, rather than as a toggle on the real one. A toggle would be a query parameter +threaded through three independent read paths (series_json, long_csv_rows, wide_csv_rows), and +missing one means the exported CSV disagrees with the plot the operator was looking at. As a +component name there is a single choke point in load_series, and series, both CSV shapes and the +download all follow for free. + +The sidecar is append-only, one JSON object per line: + + {"entity":"LC2_Cal.CH1","uid":4201,"event":"set","offsetKg":20.13, + "adcAtTare":8412331,"setAtMs":...,"appliedAtMs":1757800123456} + +`appliedAtMs` is the instant the published stream changed, not when a button was pressed — the +backend writes the line from the same code that changes the subtraction. `event` is "set" (a new +tare), "recal" (a re-fit moved the offset with no operator action) or "clear". A recal line is +load-bearing: ignore it and every reconstruction is wrong from the re-fit onward. + +There is no terminal line at session stop, so a tare with no following "clear" was in effect to +the end of the run. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import numpy as np + +from . import config + +#: The component this module synthesises, and the real one it derives from. +TARED_SUFFIX = ".force_kg_tared" +GROSS_FIELD = "force_kg" + + +def sidecar_path(run_id: str) -> Path: + """Inside the run dir, beside the DB — not a sibling like .toml. + + That file is a sibling only because it is written before elodin-db creates the directory. + This one is written mid-run when the directory certainly exists, and living inside it means + the run's own deletion takes it too, with no orphan class to clean up. + """ + return config.ELODIN_DIR / run_id / "lc_tare.jsonl" + + +def load(run_id: str) -> dict[str, list[tuple[float, float]]]: + """entity -> [(applied_at_seconds, offset_kg), ...] ascending. + + Missing or malformed → {}. A viewer must never fail to open a run over its metadata, and a + partially-written last line is expected: the file is appended to while the run is live. + """ + p = sidecar_path(run_id) + try: + text = p.read_text() + except OSError: + return {} + + events: dict[str, list[tuple[float, float]]] = {} + for line in text.splitlines(): + line = line.strip() + if not line: + continue + try: + rec = json.loads(line) + except json.JSONDecodeError: + # A torn final line is normal mid-run; earlier lines stay usable. + continue + entity = rec.get("entity") + applied = rec.get("appliedAtMs") + if not isinstance(entity, str) or not isinstance(applied, (int, float)): + continue + offset = 0.0 if rec.get("event") == "clear" else rec.get("offsetKg") + if not isinstance(offset, (int, float)) or not np.isfinite(float(offset)): + continue + events.setdefault(entity, []).append((float(applied) / 1000.0, float(offset))) + + for ev in events.values(): + ev.sort(key=lambda e: e[0]) + return events + + +def tared_components(run_id: str, components: list[dict]) -> list[dict]: + """The synthetic component entries to add to an index, one per tared LC channel. + + Only channels the sidecar actually names get one. Synthesising a tared twin for every load + cell would make an untared run indistinguishable from a tared one whose offsets happened to + be zero — the toggle would be lying about whether a tare existed. + """ + events = load(run_id) + if not events: + return [] + out = [] + for comp in components: + if comp.get("field") != GROSS_FIELD: + continue + entity = comp.get("entity", "") + if entity not in events: + continue + d = dict(comp) + d["name"] = f"{entity}{TARED_SUFFIX}" + d["field"] = "force_kg_tared" + out.append(d) + return out + + +def apply(run_id: str, entity: str, t: np.ndarray, v: np.ndarray) -> np.ndarray: + """Subtract the offset that was in effect at each sample time. + + A step function, not one offset across the whole array: a tare set partway through a run must + leave everything before it absolute, or the pre-tare portion of every run reads wrong. + """ + events = load(run_id).get(entity) + if not events or len(t) == 0: + return v + times = np.array([e[0] for e in events], dtype=float) + offsets = np.array([e[1] for e in events], dtype=float) + # side="right": a sample exactly at the applied instant already carried the new offset. + idx = np.searchsorted(times, t, side="right") - 1 + applied = np.where(idx >= 0, offsets[np.clip(idx, 0, len(offsets) - 1)], 0.0) + return v - applied diff --git a/daq-server/tools/postprocessing/webviewer/backend/naming.py b/daq-server/tools/postprocessing/webviewer/backend/naming.py index e72fb0ad..ec5925fa 100644 --- a/daq-server/tools/postprocessing/webviewer/backend/naming.py +++ b/daq-server/tools/postprocessing/webviewer/backend/naming.py @@ -58,7 +58,7 @@ # resistance (RTD raw) "raw_resistance": "Ω", "raw_resistance_counts": "counts", # force (load cells + thrust estimate/reference) - "force_n": "N", "force_kg": "kg", + "force_n": "N", "force_kg": "kg", "force_kg_tared": "kg", "f_ref": "N", "f_estimated": "N", # current (actuator sense) "current_a": "A", diff --git a/daq-server/tools/postprocessing/webviewer/backend/run_config.py b/daq-server/tools/postprocessing/webviewer/backend/run_config.py index 69f886b9..6b187e53 100644 --- a/daq-server/tools/postprocessing/webviewer/backend/run_config.py +++ b/daq-server/tools/postprocessing/webviewer/backend/run_config.py @@ -26,7 +26,7 @@ import tomllib from pathlib import Path -from . import config +from . import config, lc_tare # The state ids as the C++ enum defines them (control/StateMachine.hpp), named the way # config_base.toml's [[states]] does. Only a fallback: a snapshot's [[states]] wins. @@ -205,6 +205,15 @@ def annotate(index: dict, run_id: str) -> dict: takes effect on the next open instead of needing the export thrown away. """ cfg = load(run_id) + # Tared load-cell channels are synthesised HERE rather than in build_index, for the same + # reason names are: the sidecar is a separate file from the parquet cache, so a run exported + # before its tare record was written still shows its tared traces on the next open instead of + # needing the export thrown away. This is also why INDEX_VERSION does not move for this + # feature — bumping it would invalidate every cached export on the box for nothing. + comps = index.get("components", []) + comps.extend(lc_tare.tared_components(run_id, comps)) + index["components"] = comps + labels = entity_labels(cfg) for comp in index.get("components", []): comp["label"] = label_for(comp.get("entity", ""), labels) diff --git a/daq-server/tools/postprocessing/webviewer/backend/series.py b/daq-server/tools/postprocessing/webviewer/backend/series.py index 66327b0c..c5090cfb 100644 --- a/daq-server/tools/postprocessing/webviewer/backend/series.py +++ b/daq-server/tools/postprocessing/webviewer/backend/series.py @@ -57,7 +57,7 @@ import numpy as np import pandas as pd -from . import export_cache, run_config +from . import export_cache, lc_tare, run_config from .naming import classify @@ -152,6 +152,18 @@ def load_series( With time_source="sensor" the x-axis is the row's own sample time where the publisher stamps a real clock, falling back to the DB's write time where it does not.""" + # A tared load-cell channel is derived, not exported: read its absolute twin and replay the + # run's tare record over it. Done here because this is the one place a component name becomes + # data, so series, long CSV, wide CSV and the whole-run download all get it with no signature + # change — and cannot disagree with each other, which a per-request toggle would allow. + tared = component.endswith(lc_tare.TARED_SUFFIX) + if tared: + entity = component[: -len(lc_tare.TARED_SUFFIX)] + if not lc_tare.load(run_id).get(entity): + # No recorded tare for this channel: the component does not exist for this run. + raise FileNotFoundError(f"component not in run: {component}") + component = f"{entity}.{lc_tare.GROSS_FIELD}" + df = pd.read_parquet(_parquet_path(run_id, component)) value_col = next(c for c in df.columns if c != "time") t = _epoch_seconds(df["time"]) @@ -164,7 +176,13 @@ def load_series( if own is not None and len(own[0]) == len(t): t = own[0] order = np.argsort(t, kind="stable") - return t[order], v[order] + t, v = t[order], v[order] + if tared: + # After the sort and after the clock substitution: the sidecar stamps wall-clock, which is + # the same clock the sensor axis uses. On the DB axis the same instant sits a few ms later + # (write latency), which shifts where the step lands by less than one sample. + v = lc_tare.apply(run_id, entity, t, v) + return t, v def _slice(t: np.ndarray, v: np.ndarray, start, end) -> tuple[np.ndarray, np.ndarray]: diff --git a/daq-server/tools/postprocessing/webviewer/backend/test_lc_tare.py b/daq-server/tools/postprocessing/webviewer/backend/test_lc_tare.py new file mode 100644 index 00000000..57df11b7 --- /dev/null +++ b/daq-server/tools/postprocessing/webviewer/backend/test_lc_tare.py @@ -0,0 +1,192 @@ +"""Load-cell tare reconstruction in the viewer. + +Run: cd webviewer && .venv/bin/python -m pytest backend/test_lc_tare.py -q + +Hermetic — no elodin-db and no real run. Elodin archives ABSOLUTE force forever; the tare is +display state the backend recorded beside the run, and these tests pin the replay of it. + +Every case names the wrong number it prevents: + * a step applied across the whole array makes the PRE-tare part of every run read wrong; + * a missed 'recal' line makes everything after a re-fit read wrong; + * synthesising a tared twin unconditionally makes an untared run look tared; + * synthesising in build_index instead of at serve time means a run exported before its sidecar + landed never shows its tare, with the cached index quietly winning. +""" + +from __future__ import annotations + +import json + +import numpy as np +import pandas as pd +import pytest + +from . import config, export_cache, lc_tare, run_config, series + + +@pytest.fixture +def run(tmp_path, monkeypatch): + """A scratch run: parquet cache and ELODIN_DIR both under tmp_path.""" + cache = tmp_path / "cache" + cache.mkdir() + elodin = tmp_path / "elodin" + (elodin / "daq_20260914_120000").mkdir(parents=True) + monkeypatch.setattr(config, "ELODIN_DIR", elodin) + monkeypatch.setattr(export_cache, "cache_dir", lambda run_id: cache) + # Mandatory for anything touching load_series: both caches are keyed on (run_id, component) + # and would serve a previous test's arrays. + series.load_series.cache_clear() + series.sensor_clock.cache_clear() + return {"id": "daq_20260914_120000", "cache": cache, "elodin": elodin} + + +def write_gross(run, entity="LC2_Cal.CH1", times=(100.0, 200.0, 300.0), values=(50.0, 51.0, 52.0)): + df = pd.DataFrame({ + "time": pd.to_datetime(np.array(times) * 1e9, unit="ns"), + "value": list(values), + }) + df.to_parquet(run["cache"] / f"{entity}.force_kg.parquet") + + +def write_sidecar(run, lines): + p = run["elodin"] / run["id"] / "lc_tare.jsonl" + p.write_text("".join(json.dumps(l) + "\n" for l in lines)) + + +# ── the step function ─────────────────────────────────────────────────────── + +def test_offset_applies_only_after_the_tare_was_set(run): + # Subtracting the final offset across the whole array would make every sample before the + # operator pressed Tare read 20 kg light — the pre-tare part of the run, silently wrong. + write_gross(run) + write_sidecar(run, [ + {"entity": "LC2_Cal.CH1", "uid": 4201, "event": "set", + "offsetKg": 20.0, "adcAtTare": 1000, "appliedAtMs": 250_000}, + ]) + t, v = series.load_series(run["id"], "LC2_Cal.CH1.force_kg_tared") + assert list(t) == [100.0, 200.0, 300.0] + assert v == pytest.approx([50.0, 51.0, 32.0]) + + +def test_a_recal_line_moves_the_offset_again(run): + # A re-fit rewrites the offset with NO operator action. Reading only the first line leaves + # everything after the re-cal wrong by the difference. + write_gross(run) + write_sidecar(run, [ + {"entity": "LC2_Cal.CH1", "event": "set", "offsetKg": 20.0, "appliedAtMs": 150_000}, + {"entity": "LC2_Cal.CH1", "event": "recal", "offsetKg": 22.0, "appliedAtMs": 250_000}, + ]) + _, v = series.load_series(run["id"], "LC2_Cal.CH1.force_kg_tared") + assert v == pytest.approx([50.0, 31.0, 30.0]) + + +def test_a_clear_returns_the_trace_to_absolute(run): + write_gross(run) + write_sidecar(run, [ + {"entity": "LC2_Cal.CH1", "event": "set", "offsetKg": 20.0, "appliedAtMs": 150_000}, + {"entity": "LC2_Cal.CH1", "event": "clear", "offsetKg": 0.0, "appliedAtMs": 250_000}, + ]) + _, v = series.load_series(run["id"], "LC2_Cal.CH1.force_kg_tared") + assert v == pytest.approx([50.0, 31.0, 52.0]) + + +def test_the_absolute_series_is_never_touched(run): + # The whole premise: force_kg means the same thing in every run ever archived. + write_gross(run) + write_sidecar(run, [ + {"entity": "LC2_Cal.CH1", "event": "set", "offsetKg": 20.0, "appliedAtMs": 150_000}, + ]) + _, gross = series.load_series(run["id"], "LC2_Cal.CH1.force_kg") + assert gross == pytest.approx([50.0, 51.0, 52.0]) + + +def test_gross_and_tared_do_not_share_a_cache_entry(run): + # Both go through one lru_cache keyed on (run_id, component, time_source). Stripping the + # suffix before the lookup would make the tared request return the absolute arrays. + write_gross(run) + write_sidecar(run, [ + {"entity": "LC2_Cal.CH1", "event": "set", "offsetKg": 20.0, "appliedAtMs": 150_000}, + ]) + _, gross = series.load_series(run["id"], "LC2_Cal.CH1.force_kg") + _, tared = series.load_series(run["id"], "LC2_Cal.CH1.force_kg_tared") + assert list(gross) != list(tared) + + +# ── existence ─────────────────────────────────────────────────────────────── + +def test_no_sidecar_means_no_tared_component(run): + # Synthesising one anyway would render identically to gross and lie about whether a tare ever + # existed on this run. + write_gross(run) + with pytest.raises(FileNotFoundError): + series.load_series(run["id"], "LC2_Cal.CH1.force_kg_tared") + idx = run_config.annotate({"components": [ + {"name": "LC2_Cal.CH1.force_kg", "entity": "LC2_Cal.CH1", "field": "force_kg"}, + ]}, run["id"]) + assert [c["name"] for c in idx["components"]] == ["LC2_Cal.CH1.force_kg"] + + +def test_a_channel_with_no_tare_gets_no_twin(run): + # Two load cells, one tared. The untared one must not grow a tared component. + write_sidecar(run, [ + {"entity": "LC2_Cal.CH1", "event": "set", "offsetKg": 20.0, "appliedAtMs": 150_000}, + ]) + idx = run_config.annotate({"components": [ + {"name": "LC2_Cal.CH1.force_kg", "entity": "LC2_Cal.CH1", "field": "force_kg"}, + {"name": "LC2_Cal.CH2.force_kg", "entity": "LC2_Cal.CH2", "field": "force_kg"}, + ]}, run["id"]) + names = [c["name"] for c in idx["components"]] + assert "LC2_Cal.CH1.force_kg_tared" in names + assert "LC2_Cal.CH2.force_kg_tared" not in names + + +def test_a_sidecar_dropped_in_later_takes_effect_without_a_re_export(run): + # The reason synthesis lives in annotate() and not build_index(): a run indexed before its + # tare record was written must still show its tared traces on the next open. If this moved + # into build_index the cached index would win and the tare would never appear. + comps = [{"name": "LC2_Cal.CH1.force_kg", "entity": "LC2_Cal.CH1", "field": "force_kg"}] + before = run_config.annotate({"components": list(comps)}, run["id"]) + assert "LC2_Cal.CH1.force_kg_tared" not in [c["name"] for c in before["components"]] + + write_sidecar(run, [ + {"entity": "LC2_Cal.CH1", "event": "set", "offsetKg": 20.0, "appliedAtMs": 150_000}, + ]) + after = run_config.annotate({"components": list(comps)}, run["id"]) + assert "LC2_Cal.CH1.force_kg_tared" in [c["name"] for c in after["components"]] + + +def test_index_version_did_not_move(run): + # Bumping it would invalidate every cached export on the postprocessing box, for a feature + # that is layered on at read time and needs no re-export at all. + assert export_cache.INDEX_VERSION == 3 + + +# ── degradation ───────────────────────────────────────────────────────────── + +def test_a_torn_final_line_does_not_lose_the_earlier_ones(run): + # The backend appends to this file while the run is live, so a reader can catch it mid-write. + p = run["elodin"] / run["id"] / "lc_tare.jsonl" + p.write_text( + json.dumps({"entity": "LC2_Cal.CH1", "event": "set", + "offsetKg": 20.0, "appliedAtMs": 150_000}) + "\n" + + '{"entity":"LC2_Cal.CH1","event":"rec' + ) + assert lc_tare.load(run["id"]) == {"LC2_Cal.CH1": [(150.0, 20.0)]} + + +def test_a_missing_sidecar_is_empty_not_an_error(run): + assert lc_tare.load(run["id"]) == {} + + +def test_a_non_finite_offset_is_skipped(run): + write_sidecar(run, [ + {"entity": "LC2_Cal.CH1", "event": "set", "offsetKg": None, "appliedAtMs": 150_000}, + ]) + assert lc_tare.load(run["id"]) == {} + + +def test_the_unit_matches_its_absolute_twin(run): + # Different units put the two traces on different y-axes, so the tared and absolute lines for + # one load cell would not be comparable on the same chart. + from .naming import classify + assert classify("LC2_Cal.CH1.force_kg_tared").unit == classify("LC2_Cal.CH1.force_kg").unit From 379e97cd5ddf7a0097b1e285ba4cbda0c849dad4 Mon Sep 17 00:00:00 2001 From: Aidan Rickert Date: Mon, 14 Sep 2026 16:02:44 -0700 Subject: [PATCH 07/13] daq: prove the LC tare end-to-end in the integration stack, and fix three loose ends --- daq-server/.gitignore | 4 + .../frontend/app/plots/lcs-tcs-rtd/page.tsx | 3 +- daq-server/diablo_server/shared/types.d.ts | 2 +- .../diablo_server/shared/types.d.ts.map | 2 +- daq-server/diablo_server/shared/types.js.map | 2 +- daq-server/test/test_integration.sh | 18 +++ daq-server/test/ws_data_flow_test.ts | 134 +++++++++++++++++- 7 files changed, 159 insertions(+), 6 deletions(-) diff --git a/daq-server/.gitignore b/daq-server/.gitignore index 9cdcc4a3..5ac03536 100644 --- a/daq-server/.gitignore +++ b/daq-server/.gitignore @@ -230,6 +230,10 @@ scripts/calibration/calibrations/adjustments.json # Clear. cubic_calibration.default.json (NOT ignored) is the tracked seed the service copies here # on first run when this file is missing; promote a good cal into it via promote_default.sh. scripts/calibration/calibrations/cubic_calibration.json +# Live load-cell tare state, written by calibration_service and cleared by the backend at session +# start. Operator display state for one session on one stand — never a thing to commit, and there +# is no tracked seed for it (absent == every load cell reads absolute, which is the right default). +scripts/calibration/calibrations/lc_tare.json # Raw-ADC abort thresholds the calibration service regenerates from the live cal on every # capture/clear/reload (uid target_psi raw_adc), read by config_broadcast. Runtime-derived, per-rig. diff --git a/daq-server/diablo_server/frontend/app/plots/lcs-tcs-rtd/page.tsx b/daq-server/diablo_server/frontend/app/plots/lcs-tcs-rtd/page.tsx index 33d660b0..e3fe6453 100644 --- a/daq-server/diablo_server/frontend/app/plots/lcs-tcs-rtd/page.tsx +++ b/daq-server/diablo_server/frontend/app/plots/lcs-tcs-rtd/page.tsx @@ -286,9 +286,8 @@ export default function LCS_TCS_RTDPage() { // Accelerate the poll rather than assuming an outcome. If nothing changes within ~2 s the // banner says so, instead of a button that looks like it worked. setTarePending(true); - const t0 = Date.now(); const quick = setInterval(fetchTares, 120); - setTimeout(() => { clearInterval(quick); setTarePending(false); void t0; }, 2200); + setTimeout(() => { clearInterval(quick); setTarePending(false); }, 2200); }, [ws, fetchTares]); const anyTared = lcCalEntities.some((e) => lcTares[e] != null); diff --git a/daq-server/diablo_server/shared/types.d.ts b/daq-server/diablo_server/shared/types.d.ts index 94478b21..5d727a5a 100644 --- a/daq-server/diablo_server/shared/types.d.ts +++ b/daq-server/diablo_server/shared/types.d.ts @@ -233,7 +233,7 @@ export interface CalibrationStatusPayload { calibrationFilePath?: string | null; } /** Commands the frontend sends to drive the calibration engine */ -export type CalibrationCommandType = 'capture_reference' | 'fit_channel' | 'reset_channel' | 'enable_phase2' | 'disable_phase2' | 'zero_all' | 'save_coefficients' | 'clear_calibration' | 'capture_cubic_point' | 'clear_cubic_channel' | 'capture_point' | 'new_calibration'; +export type CalibrationCommandType = 'capture_reference' | 'fit_channel' | 'reset_channel' | 'enable_phase2' | 'disable_phase2' | 'zero_all' | 'save_coefficients' | 'clear_calibration' | 'capture_cubic_point' | 'clear_cubic_channel' | 'capture_point' | 'new_calibration' | 'tare_lc' | 'clear_tare_lc'; export interface CalibrationCommand { commandType: CalibrationCommandType; sensorId?: number; diff --git a/daq-server/diablo_server/shared/types.d.ts.map b/daq-server/diablo_server/shared/types.d.ts.map index 959a950b..ff6c7549 100644 --- a/daq-server/diablo_server/shared/types.d.ts.map +++ b/daq-server/diablo_server/shared/types.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["types.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["types.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/daq-server/diablo_server/shared/types.js.map b/daq-server/diablo_server/shared/types.js.map index 182fbd2a..98483dec 100644 --- a/daq-server/diablo_server/shared/types.js.map +++ b/daq-server/diablo_server/shared/types.js.map @@ -1 +1 @@ -{"version":3,"file":"types.js","sourceRoot":"","sources":["types.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"types.js","sourceRoot":"","sources":["types.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/daq-server/test/test_integration.sh b/daq-server/test/test_integration.sh index af208c55..00ed37e8 100644 --- a/daq-server/test/test_integration.sh +++ b/daq-server/test/test_integration.sh @@ -398,6 +398,19 @@ sedi 's/^bind_ip = .*/bind_ip = "127.0.0.1"/' "$TEST_CONFIG" # just has to be distinguishable from FireManager's 6000 ms default — no need to sit through a # realistic burn on every CI run. sedi 's/^duration_ms = .*/duration_ms = 1500/' "$TEST_CONFIG" +# Put LC board 2 CH1 in CUBIC mode (config_base leaves every load cell on the datasheet physics +# conversion, where select_lc_kg ignores captures entirely). cal_lc_tare needs a channel whose +# curve an operator can actually move, because the property it exists to prove is that a tare +# re-derives its kilograms from the stored ADC code when the curve changes underneath it. The +# other two connectors (2, 6) stay on physics, so cal_lc_capture and cal_stability are unaffected. +cat >> "$TEST_CONFIG" <<'LCCUBIC' + +[sensor_roles_lc_board_2] +"Thrust" = 1 + +[calibration_model_lc_board_2] +"Thrust" = "cubic" +LCCUBIC sedi 's/^extended_ms = .*/extended_ms = 3000/' "$TEST_CONFIG" # ── Flow Test: a gated timed hold, constructed here rather than shipped ─────────────────────── # The sequencer can time a hold around ONE actuator rather than around the state, adding that @@ -696,6 +709,11 @@ if [ -n "$CALIB_SVC" ]; then # a fresh checkout does — otherwise a stale cubic_calibration.json from a prior local run would # carry captured cubics into this run. The service regenerates it at startup. rm -f "$REPO_ROOT/scripts/calibration/calibrations/cubic_calibration.json" 2>/dev/null || true + # Same isolation for load-cell tares: a tare left by a previous run would be re-applied to this + # one's stream, and cal_lc_tare's first assertion (untared trace == absolute trace) would fail + # for a reason that has nothing to do with the code under test. The backend clears this at + # session start in production; the integration stack has no session, so do it here. + rm -f "$REPO_ROOT/scripts/calibration/calibrations/lc_tare.json" 2>/dev/null || true (cd "$REPO_ROOT" && "$CALIB_SVC" --config "$TEST_CONFIG" --adjustments "$CAL_ADJ" \ --elodin-host 127.0.0.1 --elodin-port "$TEST_ELODIN_PORT" \ > "$REPO_ROOT/.tmp/integration_calibration_$$.log" 2>&1) & diff --git a/daq-server/test/ws_data_flow_test.ts b/daq-server/test/ws_data_flow_test.ts index b5d47ef4..fd73222d 100644 --- a/daq-server/test/ws_data_flow_test.ts +++ b/daq-server/test/ws_data_flow_test.ts @@ -15,7 +15,7 @@ * bash test/test_integration.sh --only=sensor_data * * --only runs a subset of tests (comma-separated). IDs: sensor_config, sensor_data, - * cal_stability, raw_cal_presence, heartbeat, board_status (Boards pane: all enabled boards connected), + * cal_stability, cal_lc_tare, raw_cal_presence, heartbeat, board_status (Boards pane: all enabled boards connected), * selftest, state_transition, * state_debug, actuator_ws, actuator_udp, elodin_sync, controller, timestamps, * conservation, config_validate — or numbers 1–6, 10–12, 14–15 @@ -94,6 +94,7 @@ function parseOnlyTests(): Set | null { const allowed = new Set([ 'sensor_config', 'sensor_data', 'cal_stability', 'raw_cal_presence', 'cal_values', 'cal_model_select', 'cal_robust_learn', 'cal_shared_points', 'cal_clear', 'cal_lc_capture', + 'cal_lc_tare', 'heartbeat', 'board_status', 'selftest', 'backend_debug_api', 'state_transition', 'state_debug', 'actuator_ws', 'actuator_udp', 'elodin_sync', 'controller', 'timestamps', 'conservation', 'board_logs', 'board_log_mode', @@ -2125,6 +2126,136 @@ function readCalRecord(uid: number): Record | null { } catch { return null; } } +// Read the service's lc_tare.json entry for a cal entity (fresh on every tare/recompute/clear). +function readTareRecord(entity: string): Record | null { + const dir = findCalDir(); + if (!dir) return null; + try { + const j = JSON.parse(fs.readFileSync(`${dir}/lc_tare.json`, 'utf-8')); + const tares = Array.isArray(j?.tares) ? j.tares : []; + return (tares.find((t: Record) => t.entity === entity) as Record) ?? null; + } catch { return null; } +} + +/** Mean of a component's SENSOR_UPDATE values over `ms`, or null if none arrived. */ +async function meanOf(ws: WebSocket, entity: string, component: string, ms: number): Promise { + const vals: number[] = []; + await new Promise((resolve) => { + const handler = (data: WebSocket.Data) => { + try { + const msg = JSON.parse(data.toString()); + if (msg.type !== MessageType.SENSOR_UPDATE) return; + const p = msg.payload; + if (p?.entity === entity && p?.component === component && Number.isFinite(p.value)) vals.push(p.value); + } catch { /* ignore */ } + }; + ws.on('message', handler); + setTimeout(() => { ws.removeListener('message', handler); resolve(); }, ms); + }); + if (vals.length === 0) return null; + return vals.reduce((a, b) => a + b, 0) / vals.length; +} + +// ── Test: the load-cell tare, end to end through every process in the stack ────────────────── +// +// This is the only check that exercises the whole loop the feature actually lives in: +// WS client → backend → Elodin [0x46,0x00] → calibration_service → lc_tare.json +// → backend mtime poll → force_kg_tared over WS → back to this client. +// +// Four things are asserted, each of which is a wrong number an operator would otherwise believe: +// 1. an untared channel's tared trace equals its absolute one (a 0 offset is not a dead stream); +// 2. after a tare the tared trace sits at ~0 while force_kg KEEPS reading the real load — +// Elodin's archive stays absolute, which is the premise the whole design rests on; +// 3. a capture that moves the curve RE-DERIVES the offset from the stored ADC code rather than +// leaving the kilograms it was first computed with. That is the "tank reads 2 kg after a +// better fit" bug, and this is the only place it is proved through the real service; +// 4. clearing returns the tared trace to absolute. +async function testLcTare(ws: WebSocket): Promise { + console.log('\n⚖️ Test 22: LC tare end-to-end (display tared, archive absolute)'); + const CH = 1, BOARD = 42, UID = BOARD * 100 + CH; // lc_board_2, active_connectors incl. 1 + const ENTITY = 'LC2_Cal.CH1'; + const SETTLE_MS = 2500; + + // This channel is put in CUBIC mode by test_integration.sh. That matters: on the datasheet + // physics conversion (every load cell's default) a capture cannot move the curve at all, so + // the re-derivation this test exists to prove would be unobservable. Build a curve first. + const captureAt = async (ref: number) => { + for (let i = 0; i < 10; i++) { + send(ws, { type: 'calibration_command', timestamp: Date.now(), + payload: { commandType: 'capture_point', sensorId: CH, boardId: BOARD, referencePressure: ref } }); + await sleep(120); + } + await sleep(1500); + }; + await captureAt(100); + + // ── 1. untared: the derived trace must equal the absolute one ────────────── + const grossBefore = await meanOf(ws, ENTITY, 'force_kg', SETTLE_MS); + const taredBefore = await meanOf(ws, ENTITY, 'force_kg_tared', SETTLE_MS); + if (grossBefore === null || taredBefore === null) { + assert(false, `cal_lc_tare: no ${ENTITY} force_kg/force_kg_tared traffic (gross=${grossBefore} tared=${taredBefore})`); + return; + } + assert(Math.abs(grossBefore - taredBefore) < 0.5, + `cal_lc_tare: untared, tared trace tracks absolute (gross ${grossBefore.toFixed(2)} vs tared ${taredBefore.toFixed(2)})`); + + // ── 2. tare: display goes to ~0, archive keeps the real load ─────────────── + send(ws, { type: 'calibration_command', timestamp: Date.now(), + payload: { commandType: 'tare_lc', sensorId: CH, boardId: BOARD } }); + let rec: Record | null = null; + for (let i = 0; i < 12; i++) { rec = readTareRecord(ENTITY); if (rec) break; await sleep(400); } + if (!rec) { assert(false, `cal_lc_tare: service wrote no tare for ${ENTITY} (uid ${UID})`); return; } + const offset1 = rec.offset_kg as number; + const adcAtTare = rec.adc_at_tare as number; + console.log(` tared: offset=${offset1?.toFixed?.(3)}kg adc_at_tare=${adcAtTare}`); + assert(Number.isFinite(offset1), `cal_lc_tare: offset is a finite number (${offset1})`); + + const grossAfter = await meanOf(ws, ENTITY, 'force_kg', SETTLE_MS); + const taredAfter = await meanOf(ws, ENTITY, 'force_kg_tared', SETTLE_MS); + assert(taredAfter !== null && Math.abs(taredAfter) < 0.5, + `cal_lc_tare: tared trace reads ~0 at the tared load (${taredAfter?.toFixed(3)} kg)`); + assert(grossAfter !== null && Math.abs(grossAfter - grossBefore) < 0.5, + `cal_lc_tare: force_kg stays ABSOLUTE — the archive never sees the tare (${grossBefore.toFixed(2)} → ${grossAfter?.toFixed(2)})`); + + // ── 3. the re-cal case: a moved curve must re-derive the offset ──────────── + // Capture at a reference far from the first batch, so the fit moves and the SAME ADC code now + // evaluates to something else. A tare that had stored KILOGRAMS would keep offset1 here, and + // the unchanged physical load would stop reading zero — the "tank reads 2 kg after a better + // fit" bug, observed through the real service rather than a unit-test stub. + await captureAt(250); + let rec2: Record | null = null; + for (let i = 0; i < 12; i++) { + rec2 = readTareRecord(ENTITY); + if (rec2 && (rec2.offset_kg as number) !== offset1) break; + await sleep(400); + } + const offset2 = rec2?.offset_kg as number; + console.log(` after re-cal: offset=${offset2?.toFixed?.(3)}kg adc_at_tare=${rec2?.adc_at_tare}`); + assert(rec2 !== null && (rec2.adc_at_tare as number) === adcAtTare, + `cal_lc_tare: adc_at_tare is the stored truth and does not move (${adcAtTare} → ${rec2?.adc_at_tare})`); + assert(Number.isFinite(offset2) && offset2 !== offset1, + `cal_lc_tare: a changed curve RE-DERIVES the offset (${offset1?.toFixed?.(3)} → ${offset2?.toFixed?.(3)} kg) — not a stale kg value`); + const taredRecal = await meanOf(ws, ENTITY, 'force_kg_tared', SETTLE_MS); + assert(taredRecal !== null && Math.abs(taredRecal) < 0.5, + `cal_lc_tare: the same load still reads ~0 under the new curve (${taredRecal?.toFixed(3)} kg)`); + + // ── 4. clear: back to absolute ───────────────────────────────────────────── + send(ws, { type: 'calibration_command', timestamp: Date.now(), + payload: { commandType: 'clear_tare_lc', sensorId: CH, boardId: BOARD } }); + let gone = false; + for (let i = 0; i < 12; i++) { if (!readTareRecord(ENTITY)) { gone = true; break; } await sleep(400); } + assert(gone, 'cal_lc_tare: clearing removes the tare from the store'); + const taredCleared = await meanOf(ws, ENTITY, 'force_kg_tared', SETTLE_MS); + const grossCleared = await meanOf(ws, ENTITY, 'force_kg', SETTLE_MS); + assert(taredCleared !== null && grossCleared !== null && Math.abs(taredCleared - grossCleared) < 0.5, + `cal_lc_tare: cleared, tared trace tracks absolute again (${taredCleared?.toFixed(2)} vs ${grossCleared?.toFixed(2)})`); + + // Leave the shared cubic store as we found it. + send(ws, { type: 'calibration_command', timestamp: Date.now(), + payload: { commandType: 'new_calibration', sensorId: CH, boardId: BOARD } }); + await sleep(1500); +} + // ── Test: one capture feeds BOTH the cubic fit and the robust learner (shared points) ─ // The headline guarantee of the merge. Captures on a CUBIC sensor must land in the cubic store's // points AND be fed to the robust learner — the service samples robust into `fitCurve` for any @@ -3158,6 +3289,7 @@ async function main(): Promise { if (IS_THIN && canRunCommandTests && runTest('cal_shared_points')) await testSharedPoints(ws); if (IS_THIN && canRunCommandTests && runTest('cal_clear')) await testClearToNothing(ws); if (IS_THIN && canRunCommandTests && runTest('cal_lc_capture')) await testLcCapture(ws); + if (IS_THIN && canRunCommandTests && runTest('cal_lc_tare')) await testLcTare(ws); } finally { ws.close(); } From 71dc767a8432b7ef182a6d14d3740b2f10aefcd9 Mon Sep 17 00:00:00 2001 From: Aidan Rickert Date: Mon, 14 Sep 2026 16:22:45 -0700 Subject: [PATCH 08/13] docs: document the calibration command channel, including the LC tare and why it is not a zero --- daq-server/docs/adding-sensor-streams.md | 55 ++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/daq-server/docs/adding-sensor-streams.md b/daq-server/docs/adding-sensor-streams.md index 8a2557a9..ac44140a 100644 --- a/daq-server/docs/adding-sensor-streams.md +++ b/daq-server/docs/adding-sensor-streams.md @@ -29,6 +29,8 @@ Each sensor type gets a **VTable ID** — a two-byte tuple `[high, low]` that un | `0x22` | `0x11-0x14` | RTD calibrated | `temperature_c` | 21 bytes | | `0x23` | `0x01-0x14` | LC raw | `raw_adc_counts` | 21 bytes | | `0x23` | `0x11-0x24` | LC calibrated | `force_units` | 21 bytes | +| `0x24` | `0x01-0x14` | Encoder raw | `raw_adc_counts` | 21 bytes | +| `0x24` | `0x11-0x24` | Encoder calibrated | `position_deg` | 21 bytes | | `0x30` | `0x01-0x0A` | Actuator feedback | `raw_adc_counts` | 21 bytes | | `0x31` | `0x01-0x14` | Actuator state (current-sense) | `actuator_state` | 10 bytes | | `0x32` | `0x01-0x14` | Actuator commanded state | `actuator_state_commanded` | 10 bytes | @@ -37,12 +39,65 @@ Each sensor type gets a **VTable ID** — a two-byte tuple `[high, low]` that un | `0x42` | `0x00` | Controller measurement | - | 80 bytes | | `0x43` | `0x00` | PSM state transition | - | 11 bytes | | `0x44` | `0x00` | FIRE state | - | 18 bytes | +| `0x46` | `0x00` | CalibrationCommand (backend → calibration_service) | `type` + `sensor_id` + `reference_value` | 16 bytes | | `0x50` | `0x00` | SequencerState | state + bitmask + debug | **17 bytes** (see below) | | `0x50` | `0x60-0x66` | PSM actuator commands | - | 15 bytes | | `0x60` | `0x01-0xFF` | Self-test results | `sensor_id` + `result` | 10 bytes | **Raw vs Calibrated convention:** Raw channels use `low = channel_id` (1-based). Calibrated channels use `low = 0x10 + channel_id`. Example: PT channel 3 raw = `[0x20, 0x03]`, calibrated = `[0x20, 0x13]`. +**Free high bytes**, if you need a new stream: `0x25`, `0x45`, `0x47`–`0x4F`, `0x51`–`0x5F`. Anything +`>= 0x80` is off limits: by convention a high byte of `0x80` or above is a VTable **registration +ACK** rather than data, and `calibration_main.cpp` reads it that way (see its `type_hi < 0x80` +packet filter). Add a row here when you take one, or the next person reads a stale map +(this table sat without `0x24` and `0x46` for a while, which is how you end up debugging a +collision instead of picking a free byte). + +## CalibrationCommand `[0x46, 0x00]` + +The one operator-command channel into `calibration_service`. **Strictly one-way**: the service +subscribes and never publishes a reply, so nothing can wait on an acknowledgement. Callers learn +what happened by reading the file the service writes — `cubic_calibration.json` for captures, +`lc_tare.json` for tares — which is also why a UI must never assume a command landed because it +was sent. A command published while the service is down is silently dropped. + +Layout (16 bytes, and note byte 9 is covered by no field, so Elodin zeroes it — `sensor_id` sits at +the even offset 10 precisely so its high byte survives, since `uid = board_id*100 + connector` +exceeds 255): + +``` +Offset Size Type Field +0 8 uint64 timestamp_ns +8 1 uint8 type (cmd_type, below) +9 1 - padding (NOT a field — always arrives 0) +10 2 uint16 sensor_id (uid = board_id*100 + connector; 0 = "all") +12 4 float32 reference_value +``` + +| cmd | name | `sensor_id` | `reference_value` | effect | +|---|---|---|---|---| +| 0 | Zero / Zero All | uid, or 0 for all | — | Captures a **real** 0 reference point into the shared fit. Correct for a vented PT; see the note below for why it is not a load-cell tare. | +| 1 | Capture Reference | uid | reference | Feeds the robust learner only (PT). | +| 2 | Save | — | — | Persists robust adjustments. | +| 3 | Capture cubic point | uid | reference | Legacy; equivalent to 5 without the capture-quality record. | +| 4 | Clear channel | uid | — | Drops points + curve. | +| 5 | Capture point | uid | reference | The unified capture the UI uses; routed by the channel's configured model. | +| 6 | New calibration | uid | — | Unified clear. | +| 7 | Reload live store | — | — | Re-read `cubic_calibration.json` after the backend swapped a profile. | +| 8 | **LC tare** | uid, or 0 for all | `0` = set, `1` = clear | Display-only zero for a load cell. Never enters a fit, never reaches control or abort, never changes what Elodin records. | + +**Why a load-cell tare is command 8 and not command 0.** A vented PT genuinely *is* at 0 psig, so +capturing a zero on one is a true reference point and belongs in the shared fit. A load cell +holding a tank is *not* at 0 kg: the same capture would inject a false point, and because the fit +is least-squares over every point it would tilt the whole cubic rather than shift its intercept. + +The tare is therefore stored as the **ADC code** it was taken at, never as kilograms — `offset_kg` +in `lc_tare.json` is a cache the service re-derives on every capture, clear and profile swap. Tare +a 20 kg tank against a poor two-point fit that reads it as 18, then improve the fit until the same +tank evaluates to 20, and a frozen 18 kg offset would display 2 kg for a tank that never moved. +Re-deriving from the code gives 20 − 20 = 0. `test/ws_data_flow_test.ts`'s `cal_lc_tare` check +proves this through the full stack; `diablo_server/lib/test/test_lc_tare.cpp` pins the store. + ## Standard 21-Byte Sensor Message Layout All raw and calibrated sensor messages use this layout: From c621305df4c30df30402f7f96e5e5b27544456dd Mon Sep 17 00:00:00 2001 From: Aidan Rickert Date: Wed, 16 Sep 2026 14:50:56 -0700 Subject: [PATCH 09/13] daq: ignore build-ns/, the out-of-tree ninja build dir .gitignore covered build/ but not build-ns/, so 15 MB of CMake cache, ninja logs and static libs sat in every `git status` as untracked noise. --- daq-server/.gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/daq-server/.gitignore b/daq-server/.gitignore index 5ac03536..a10de6cc 100644 --- a/daq-server/.gitignore +++ b/daq-server/.gitignore @@ -2,6 +2,7 @@ # Build artifacts build/ +build-ns/ *.o *.a *.so From 766080ae74eb02399e185d5cbd68a683d0bba3c3 Mon Sep 17 00:00:00 2001 From: Aidan Rickert Date: Wed, 16 Sep 2026 14:51:05 -0700 Subject: [PATCH 10/13] digital-twin: give the profile load cells, a LOX Upstream PT, and the 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. --- .../config/profiles/digital-twin/config.toml | 28 ++++++++++++++++--- .../state_machine_actuator_delays.csv | 6 ++-- .../digital-twin/state_machine_actuators.csv | 8 ++++-- .../digital-twin/state_transitions.csv | 8 +++--- 4 files changed, 37 insertions(+), 13 deletions(-) diff --git a/daq-server/config/profiles/digital-twin/config.toml b/daq-server/config/profiles/digital-twin/config.toml index 6c475154..c2181f06 100644 --- a/daq-server/config/profiles/digital-twin/config.toml +++ b/daq-server/config/profiles/digital-twin/config.toml @@ -85,6 +85,12 @@ role = "Low Press" limits = "" color = "#ff0088" +[[gui.pressure_bars]] +label = "LOX Upstream" +role = "LOX Upstream" +limits = "" +color = "#888888" + [gui.groups] LOX = [ "LOX Upper", "LOX Mid", "LOX Lower", "COPV RTD" ] Fuel = [ "Fuel Upstream" ] @@ -297,12 +303,14 @@ active_connectors = [ 1, 2 ] "Fuel Upstream" = 2 "Dome CTRL" = 3 "Low Press" = 4 +"LOX Upstream" = 5 [calibration_model_pt_board] "Fuel Fill Vent" = "physics" "Fuel Upstream" = "cubic" "Dome CTRL" = "cubic" "Low Press" = "physics" +"LOX Upstream" = "cubic" [calibration_full_scale_pt_board] "Fuel Fill Vent" = 1000 @@ -673,11 +681,11 @@ is_flow = true [actuator_roles] "Fuel Upstream" = [ "NC", 1, 12 ] "High Press CTRL" = [ "NC", 2, 12 ] -"Dome CTRL" = [ "NO", 3, 12 ] +"Dome CTRL" = [ "NC", 3, 12 ] "Fuel Main" = [ "NC", 6, 12 ] -"fuel vent" = [ "NC", 7, 12 ] -"lox vent" = [ "NC", 9, 12 ] -"Ox Upstream" = [ "NO", 10, 12 ] +"fuel vent" = [ "NO", 7, 12 ] +"lox vent" = [ "NO", 9, 12 ] +"Ox Upstream" = [ "NC", 10, 12 ] "Fuel Fill Press" = [ "NC", 2, 14 ] "High Press Vent" = [ "NC", 3, 14 ] "Fuel Fill Vent" = [ "NC", 5, 14 ] @@ -690,3 +698,15 @@ is_flow = true "Excitation V" = "physics" "Dome Mid" = "cubic" "High Press" = "cubic" + +[sensor_roles_lc_board] +"Fuel Scale" = 1 + +[sensor_roles_lc_board_2] +"LOX Scale" = 1 + +[calibration_model_lc_board] +"Fuel Scale" = "cubic" + +[calibration_model_lc_board_2] +"LOX Scale" = "cubic" diff --git a/daq-server/config/profiles/digital-twin/state_machine_actuator_delays.csv b/daq-server/config/profiles/digital-twin/state_machine_actuator_delays.csv index 1f1bd43b..4f68adbb 100644 --- a/daq-server/config/profiles/digital-twin/state_machine_actuator_delays.csv +++ b/daq-server/config/profiles/digital-twin/state_machine_actuator_delays.csv @@ -1,12 +1,14 @@ ,Idle,Armed,Press Standby,Dome Press,COPV Fill,Fuel Press,LOX Press,Vent,Dome Vent,High Press Vent,Fuel Tank Vent,Ready,Fire,Flow Test Fuel Upstream,0,0,0,0,0,0,0,0,0,0,0,0,0,0 High Press CTRL,0,0,0,0,0,0,0,0,0,0,0,0,0,0 -Dome CTRL Vent,0,0,0,0,0,0,0,0,0,0,0,0,0,0 -Fuel Main,0,0,0,0,0,0,0,0,0,0,0,0,1,1 Dome CTRL,0,0,0,0,0,0,0,0,0,0,0,0,0,0 +Fuel Main,0,0,0,0,0,0,0,0,0,0,0,0,1,1 +fuel vent,0,0,0,0,0,0,0,0,0,0,0,0,0,0 +lox vent,0,0,0,0,0,0,0,0,0,0,0,0,0,0 Ox Upstream,0,0,0,0,0,0,0,0,0,0,0,0,0,0 Fuel Fill Press,0,0,0,0,0,0,0,0,0,0,0,0,0,0 High Press Vent,0,0,0,0,0,0,0,0,0,0,0,0,0,0 Fuel Fill Vent,0,0,0,0,0,0,0,0,0,0,0,0,0,0 Low Press Vent,0,0,0,0,0,0,0,0,0,0,0,0,0,0 Ox Main,0,0,0,0,0,0,0,0,0,0,0,0,0,1 +Dome CTRL Vent,0,0,0,0,0,0,0,0,0,0,0,0,0,0 diff --git a/daq-server/config/profiles/digital-twin/state_machine_actuators.csv b/daq-server/config/profiles/digital-twin/state_machine_actuators.csv index 014178e7..e6097330 100644 --- a/daq-server/config/profiles/digital-twin/state_machine_actuators.csv +++ b/daq-server/config/profiles/digital-twin/state_machine_actuators.csv @@ -1,12 +1,14 @@ ,Idle,Armed,Press Standby,Dome Press,COPV Fill,Fuel Press,LOX Press,Vent,Dome Vent,High Press Vent,Fuel Tank Vent,Ready,Fire,Flow Test Fuel Upstream,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,OPEN,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,OPEN,OPEN High Press CTRL,CLOSE,CLOSE,CLOSE,CLOSE,OPEN,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE -Dome CTRL Vent,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,OPEN,OPEN,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE -Fuel Main,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,OPEN,OPEN Dome CTRL,CLOSE,CLOSE,CLOSE,OPEN,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE -Ox Upstream,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,OPEN,OPEN +Fuel Main,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,OPEN,OPEN +fuel vent,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,OPEN,CLOSE,CLOSE,OPEN,CLOSE,CLOSE,CLOSE +lox vent,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,OPEN,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE +Ox Upstream,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,OPEN,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,OPEN,OPEN Fuel Fill Press,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE High Press Vent,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,OPEN,CLOSE,OPEN,CLOSE,CLOSE,CLOSE,CLOSE Fuel Fill Vent,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,OPEN,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE Low Press Vent,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,OPEN,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE Ox Main,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,OPEN,OPEN +Dome CTRL Vent,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE,OPEN,OPEN,CLOSE,CLOSE,CLOSE,CLOSE,CLOSE diff --git a/daq-server/config/profiles/digital-twin/state_transitions.csv b/daq-server/config/profiles/digital-twin/state_transitions.csv index 11605cd9..82f602e0 100644 --- a/daq-server/config/profiles/digital-twin/state_transitions.csv +++ b/daq-server/config/profiles/digital-twin/state_transitions.csv @@ -7,9 +7,9 @@ COPV Fill,0,0,1,0,1,0,0,0,0,0,0,0,0,0 Fuel Press,0,0,1,0,0,1,0,0,0,0,0,0,0,0 LOX Press,0,0,1,0,0,0,1,0,0,0,0,0,0,0 Vent,0,0,1,0,0,0,0,1,1,1,1,1,0,0 -Dome Vent,0,0,0,0,0,0,0,1,1,0,0,0,0,0 -High Press Vent,0,0,0,0,0,0,0,1,0,1,0,0,0,0 -Fuel Tank Vent,0,0,0,0,0,0,0,1,0,0,1,0,0,0 -Ready,0,0,0,0,0,0,0,0,0,0,0,1,1,1 +Dome Vent,0,0,1,0,0,0,0,1,1,0,0,0,0,0 +High Press Vent,0,0,1,0,0,0,0,1,0,1,0,0,0,0 +Fuel Tank Vent,0,0,1,0,0,0,0,1,0,0,1,0,0,0 +Ready,0,0,1,0,0,0,0,0,0,0,0,1,1,1 Fire,0,1,0,0,0,0,0,0,0,0,0,0,1,0 Flow Test,0,0,1,0,0,0,0,0,0,0,0,1,0,1 From a832eba6d2d4cd8333d9e301e978791dd71d483f Mon Sep 17 00:00:00 2001 From: Aidan Rickert Date: Wed, 16 Sep 2026 15:29:38 -0700 Subject: [PATCH 11/13] plots: a dead board no longer draws its live sibling's data under its own name findSeries() walked a REVERSE alias when a board-scoped key had no data, and the walk could leave the board it was asked about: ask LC2_Cal.CH1.force_kg_tared (LOX Scale, board dead) -> reverse index -> canonical LC_Cal.CH1.force_kg_tared -> that canonical's fallbacks are [LC1_Cal.CH1..., LC2_Cal.CH1...] -> first with data wins -> LC1 (Fuel Scale, alive) Two LC boards routinely declare the same connector number -- 41 and 42 both on CH1 on this stand -- so the generic LC_Cal.CH1 name is claimed by both. Panes are board-scoped precisely to dodge that, but the ambiguity was reachable underneath them, in the cache. The result on the stand: board 42 died mid-session and the Force plot kept drawing Fuel Scale's live weight in LOX Scale's colour, under LOX Scale's label, while the readout beside it correctly showed nothing -- the readout resolves through the store, which has no reverse path. An operator reading the plot saw a healthy LOX load cell that had been off the network for twenty minutes. Only the canonical itself is tried now. The reverse index guarantees the key we were asked for is already among that canonical's fallbacks, so every other entry is by construction a DIFFERENT BOARD's stream; walking them is never the right answer. A dead channel reads as dead. The test fails against the old walk (the dead column comes back finite instead of NaN) and a second case pins the behaviour worth keeping: a generic LC_Cal.CH1 still resolves while only one LC board is enabled. --- .../__tests__/lc-dead-board-alias.test.ts | 103 ++++++++++++++++++ .../diablo_server/frontend/lib/data-cache.ts | 18 +-- 2 files changed, 113 insertions(+), 8 deletions(-) create mode 100644 daq-server/diablo_server/frontend/__tests__/lc-dead-board-alias.test.ts diff --git a/daq-server/diablo_server/frontend/__tests__/lc-dead-board-alias.test.ts b/daq-server/diablo_server/frontend/__tests__/lc-dead-board-alias.test.ts new file mode 100644 index 00000000..e8f7d1f9 --- /dev/null +++ b/daq-server/diablo_server/frontend/__tests__/lc-dead-board-alias.test.ts @@ -0,0 +1,103 @@ +/** + * A dead board must not borrow a live sibling's stream. + * + * Two LC boards routinely declare the SAME connector number (41 and 42 both on CH1 on the + * stand). That makes the generic key LC_Cal.CH1. ambiguous: it maps onto BOTH boards. + * Panes are board-scoped to avoid it — but data-cache's findSeries() walks a REVERSE alias, + * and the walk can leave the board it was asked about: + * + * ask LC2_Cal.CH1.force_kg_tared (LOX Scale, board dead — no data) + * → reverse index → canonical LC_Cal.CH1.force_kg_tared + * → that canonical's fallbacks are [LC1_Cal.CH1…, LC2_Cal.CH1…] + * → first one with data wins → LC1 (Fuel Scale, alive) + * + * The plot then draws Fuel Scale's live weight under LOX Scale's label and colour, while the + * readout beside it — which resolves through the store, with no reverse path — correctly shows + * nothing. Seen on the stand 2026-09-16 with board 42 dead. + */ +import { describe, it, expect, vi } from 'vitest'; + +const listeners = new Map void>(); + +vi.mock('@/lib/websocket', () => ({ + getWebSocketClient: () => ({ + on: (type: string, cb: (payload: unknown) => void) => { + listeners.set(type, cb); + return () => listeners.delete(type); + }, + setHistoricalQueryProvider: () => {}, + onConnectionStatus: () => () => {}, + isConnected: () => true, + connect: () => {}, + send: () => {}, + }), + getApiBaseUrl: () => 'http://localhost:8081', +})); + +const T0 = 1_700_000_000_000; + +/** The shipped digital-twin shape: two LC boards, both on connector 1. */ +const TWO_LC_BOARDS = { + boards: { + lc_board: { type: 'LC', board_id: 41, enabled: true, active_connectors: [1] }, + lc_board_2: { type: 'LC', board_id: 42, enabled: true, active_connectors: [1] }, + }, + sensor_roles_lc_board: { 'Fuel Scale': 1 }, + sensor_roles_lc_board_2: { 'LOX Scale': 1 }, +}; + +async function freshModules() { + vi.resetModules(); + listeners.clear(); + const plotTime = await import('@/lib/plot-time'); + plotTime.resetPlotTimeForTests(); + const store = await import('@/lib/store'); + const dataCache = await import('@/lib/data-cache'); + return { store, dataCache }; +} + +describe('LC board-scoped series never resolve to a sibling board', () => { + it('leaves the dead board\'s column empty instead of filling it from the live board', async () => { + const { store, dataCache } = await freshModules(); + store.buildAliasesFromConfig(TWO_LC_BOARDS); + + const cache = dataCache.getDataCache(); + // Only board 41 (Fuel Scale) is alive. Board 42 (LOX Scale) never reports. + for (let i = 0; i < 10; i++) { + cache.addDataPoint('LC1_Cal.CH1', 'force_kg_tared', 100 + i, T0 + i * 50); + } + + const out = cache.getAlignedHistory( + ['LC1_Cal.CH1', 'LC2_Cal.CH1'], + ['force_kg_tared', 'force_kg_tared'], + 60, + ); + expect(out).not.toBeNull(); + + // Fuel Scale draws its own data. + expect(out!.values[0].some((v) => Number.isFinite(v))).toBe(true); + expect(out!.values[0][9]).toBe(109); + + // LOX Scale is dead: every sample must be NaN. A finite number here is Fuel Scale's + // weight wearing LOX Scale's name — the failure this test exists for. + expect(out!.values[1].every((v) => Number.isNaN(v))).toBe(true); + }); + + it('still resolves a generic LC_Cal key when only one LC board is enabled', async () => { + const { store, dataCache } = await freshModules(); + store.buildAliasesFromConfig({ + boards: { lc_board: { type: 'LC', board_id: 41, enabled: true, active_connectors: [1] } }, + sensor_roles_lc_board: { 'Fuel Scale': 1 }, + }); + + const cache = dataCache.getDataCache(); + for (let i = 0; i < 5; i++) { + cache.addDataPoint('LC1_Cal.CH1', 'force_kg', 10 + i, T0 + i * 50); + } + + // Unambiguous: one board claims CH1, so the generic name may still find it. + const out = cache.getAlignedHistory(['LC_Cal.CH1'], ['force_kg'], 60); + expect(out).not.toBeNull(); + expect(out!.values[0][4]).toBe(14); + }); +}); diff --git a/daq-server/diablo_server/frontend/lib/data-cache.ts b/daq-server/diablo_server/frontend/lib/data-cache.ts index 4ce721ed..d4abe043 100644 --- a/daq-server/diablo_server/frontend/lib/data-cache.ts +++ b/daq-server/diablo_server/frontend/lib/data-cache.ts @@ -327,19 +327,21 @@ class SensorDataCache { } } - // Reverse alias: O(1) lookup via pre-built index + // Reverse alias: O(1) lookup via pre-built index. `key` is board-scoped here (a pane asked + // for LC2_Cal.CH1), and the canonical it maps back to may be a GENERIC name that several + // boards claim — LC_Cal.CH1 belongs to both 41 and 42 when they share connector 1. + // + // Only the canonical itself is worth trying. Walking that canonical's other fallbacks is + // never right: the reverse index guarantees `key` is already one of them, so every OTHER + // entry is a DIFFERENT BOARD's stream. When the asked-for board was dead and a sibling was + // alive, the walk returned the sibling, and the plot drew a live load cell's weight under + // the dead one's label and colour while the readout beside it correctly showed nothing. + // A dead channel must read as dead. See __tests__/lc-dead-board-alias.test.ts. this.ensureReverseAliasIndex(); const canonical = this.reverseAliasIndex.get(key); if (canonical) { s = this.cache.get(canonical); if (s && s.len > 0) return s; - const cFallbacks = ALIASES[canonical]; - if (cFallbacks) { - for (const fb of cFallbacks) { - s = this.cache.get(fb); - if (s && s.len > 0) return s; - } - } } return null; } From 2adc00f09f020d09727c7d0939b0260ac9d3ea10 Mon Sep 17 00:00:00 2001 From: Aidan Rickert Date: Wed, 16 Sep 2026 15:31:39 -0700 Subject: [PATCH 12/13] format: run clang-format over the three LC tare C++ files CI was rejecting format-check runs clang-format 19 over the tree and the branch's own new C++ had never been through it: LcTareStore.hpp, test_lc_tare.cpp and the tare command handling in calibration_main.cpp. Whitespace only -- `./format.sh` output taken verbatim, no hand edits. Build is clean and ctest is 14/14 after it. Produced with clang-format 19.1.7 and black 25.11.0, the versions daq-server-ci.yml pins; Python was already clean. --- .../lib/include/calibration/LcTareStore.hpp | 4 +- .../diablo_server/lib/test/test_lc_tare.cpp | 41 ++++++++++++------- .../services/calibration/calibration_main.cpp | 25 ++++++----- 3 files changed, 43 insertions(+), 27 deletions(-) diff --git a/daq-server/diablo_server/lib/include/calibration/LcTareStore.hpp b/daq-server/diablo_server/lib/include/calibration/LcTareStore.hpp index 012102ec..8b44aac9 100644 --- a/daq-server/diablo_server/lib/include/calibration/LcTareStore.hpp +++ b/daq-server/diablo_server/lib/include/calibration/LcTareStore.hpp @@ -48,8 +48,8 @@ std::string lc_tare_entity(uint8_t board_id, uint8_t connector); * input shifts the slope you get instead of translating its output. */ struct LcTare { - uint16_t uid = 0; // board_id*100 + connector - std::string entity; // publish-path identity, e.g. "LC2_Cal.CH1" — see set() + uint16_t uid = 0; // board_id*100 + connector + std::string entity; // publish-path identity, e.g. "LC2_Cal.CH1" — see set() double adc_at_tare = 0.0; // the truth double offset_kg = 0.0; // derived from adc_at_tare through the live curve; a cache double set_at_ms = 0.0; // unix milliseconds, for "tared 2h ago" diff --git a/daq-server/diablo_server/lib/test/test_lc_tare.cpp b/daq-server/diablo_server/lib/test/test_lc_tare.cpp index fb4ecee1..3fa67d5a 100644 --- a/daq-server/diablo_server/lib/test/test_lc_tare.cpp +++ b/daq-server/diablo_server/lib/test/test_lc_tare.cpp @@ -20,8 +20,8 @@ #include #include #include -#include #include +#include #include #include @@ -62,7 +62,9 @@ std::string read_text(const std::string& path) { /** A linear adc->kg curve, standing in for select_lc_kg. */ LcTareStore::Evaluator linear(double kg_per_count) { - return [kg_per_count](double adc) { return adc * kg_per_count; }; + return [kg_per_count](double adc) { + return adc * kg_per_count; + }; } // ── 1. the 2 kg bug ───────────────────────────────────────────────────────── @@ -128,11 +130,15 @@ void non_finite_offset_is_never_recorded() { // A degenerate one-point fit can evaluate to inf/NaN. Node subtracts offset_kg from every // sample; a NaN there is dropped by the finite guard downstream and the whole series simply // vanishes from the plot with no error anywhere. - const auto blown_up = [](double) { return std::numeric_limits::quiet_NaN(); }; + const auto blown_up = [](double) { + return std::numeric_limits::quiet_NaN(); + }; CHECK(!s.set(4201, lc_tare_entity(42, 1), 1000.0, blown_up), "a NaN offset must be refused"); CHECK(s.tare_for(4201) == nullptr, "nothing may be recorded for a refused tare"); - const auto inf_curve = [](double) { return std::numeric_limits::infinity(); }; + const auto inf_curve = [](double) { + return std::numeric_limits::infinity(); + }; CHECK(!s.set(4202, lc_tare_entity(42, 2), 1000.0, inf_curve), "an inf offset must be refused"); CHECK(s.size() == 0, "store must still be empty, has %zu", s.size()); @@ -189,10 +195,11 @@ void loaded_tare_is_corrected_by_recompute() { // The startup recompute is what corrects it. If the tare file is loaded AFTER the live store // reload instead of before, this never runs and the stand carries the stale offset all run. - s.recompute_all([](uint16_t) { return linear(0.020); }); + s.recompute_all([](uint16_t) { + return linear(0.020); + }); CHECK(std::fabs(s.tare_for(4201)->offset_kg - 20.0) < 1e-9, - "the startup recompute must correct a stale offset, got %f", - s.tare_for(4201)->offset_kg); + "the startup recompute must correct a stale offset, got %f", s.tare_for(4201)->offset_kg); } // ── 6. the staleness fingerprint ──────────────────────────────────────────── @@ -223,19 +230,25 @@ void stale_audit_finds_a_missed_recompute() { // Nothing has changed: the audit must report zero, or it would cry wolf on every startup and // the warning would stop meaning anything. - CHECK(s.recompute_stale([](uint16_t) { return linear(0.020); }) == 0, + CHECK(s.recompute_stale([](uint16_t) { + return linear(0.020); + }) == 0, "an unchanged curve must not be reported stale"); CHECK(std::fabs(s.tare_for(4201)->offset_kg - 20.0) < 1e-9, "and the offset is untouched"); // Now the curve moves WITHOUT a recompute — the shape of a missed hook, and of reading the // tare file after the startup reload instead of before it. - const size_t stale = s.recompute_stale([](uint16_t) { return linear(0.030); }); + const size_t stale = s.recompute_stale([](uint16_t) { + return linear(0.030); + }); CHECK(stale == 1, "a moved curve must be reported stale, got %zu", stale); - CHECK(std::fabs(s.tare_for(4201)->offset_kg - 30.0) < 1e-9, - "and must be re-derived, got %f", s.tare_for(4201)->offset_kg); + CHECK(std::fabs(s.tare_for(4201)->offset_kg - 30.0) < 1e-9, "and must be re-derived, got %f", + s.tare_for(4201)->offset_kg); // Having fixed it, a second audit is quiet. - CHECK(s.recompute_stale([](uint16_t) { return linear(0.030); }) == 0, + CHECK(s.recompute_stale([](uint16_t) { + return linear(0.030); + }) == 0, "the audit must be quiet once it has healed"); } @@ -253,8 +266,8 @@ void save_load_round_trip() { CHECK(s2.load() == 2, "two tares should load"); CHECK(!s2.load_failed(), "a good file is not a failed load"); CHECK(s2.tare_for(4201)->entity == "LC2_Cal.CH1", "entity round-trips"); - CHECK(std::fabs(s2.tare_for(4206)->offset_kg + 5.0) < 1e-9, "negative offset round-trips, got %f", - s2.tare_for(4206)->offset_kg); + CHECK(std::fabs(s2.tare_for(4206)->offset_kg + 5.0) < 1e-9, + "negative offset round-trips, got %f", s2.tare_for(4206)->offset_kg); // A missing file is the normal post-session-start state, not a failure — it must not block // the next save the way an unreadable file does. diff --git a/daq-server/diablo_server/services/calibration/calibration_main.cpp b/daq-server/diablo_server/services/calibration/calibration_main.cpp index d806e0b9..a2e73dd9 100644 --- a/daq-server/diablo_server/services/calibration/calibration_main.cpp +++ b/daq-server/diablo_server/services/calibration/calibration_main.cpp @@ -1068,10 +1068,9 @@ int main(int argc, char* argv[]) { const int32_t code = static_cast(adc); const bool cubic_ok = lc_calibration.is_calibrated(lc_log_ch); const double kg_cubic = cubic_ok ? lc_calibration.calculate(lc_log_ch, code) : 0.0; - const double kg_phys = - convert_lc_adc_to_force(code, lc_sensitivity_for(uid, lc_sensitivity_mv_per_v), - lc_pga_gain_for(uid, lc_pga_gain), - lc_full_scale_for(uid, lc_full_scale_value)); + const double kg_phys = convert_lc_adc_to_force( + code, lc_sensitivity_for(uid, lc_sensitivity_mv_per_v), + lc_pga_gain_for(uid, lc_pga_gain), lc_full_scale_for(uid, lc_full_scale_value)); return select_lc_kg(uid, kg_cubic, kg_phys, cubic_ok); }; }; @@ -1089,7 +1088,9 @@ int main(int argc, char* argv[]) { }; auto recompute_all_tares = [&]() { lc_tare_store.set_curves_trusted(!cubic_store.load_failed()); - lc_tare_store.recompute_all([&](uint16_t u) { return lc_eval_for(u); }); + lc_tare_store.recompute_all([&](uint16_t u) { + return lc_eval_for(u); + }); lc_tare_store.save(); }; // LC capture: cubic fit only — no robust learner (LC doesn't need drift-learning) and no abort @@ -1285,8 +1286,9 @@ int main(int argc, char* argv[]) { // curve that no longer exists. Self-healing, but never silently. { lc_tare_store.set_curves_trusted(!cubic_store.load_failed()); - const size_t stale = - lc_tare_store.recompute_stale([&](uint16_t u) { return lc_eval_for(u); }); + const size_t stale = lc_tare_store.recompute_stale([&](uint16_t u) { + return lc_eval_for(u); + }); if (stale > 0) { std::cout << "[Calibration] LC tare: WARNING — " << stale << " tare(s) were stale against the live curves and have been re-derived. " @@ -1616,14 +1618,15 @@ int main(int argc, char* argv[]) { continue; const uint8_t board_id = static_cast(id / 100); const uint8_t connector = static_cast(id % 100); - if (lc_tare_store.set(id, fsw::calibration::lc_tare_entity(board_id, - connector), + if (lc_tare_store.set(id, + fsw::calibration::lc_tare_entity(board_id, connector), r.adc_avg, lc_eval_for(id))) { ++done; const fsw::calibration::LcTare* t = lc_tare_store.tare_for(id); std::cout << "[Cal] Tare uid=" << static_cast(id) << " " - << capture_detail(r) << " offset=" - << (t != nullptr ? t->offset_kg : 0.0) << "kg" << std::endl; + << capture_detail(r) + << " offset=" << (t != nullptr ? t->offset_kg : 0.0) << "kg" + << std::endl; } } lc_tare_store.save(); From 48860992b5e1631f8b4b5f27274a3dc68c65ddbf Mon Sep 17 00:00:00 2001 From: Aidan Rickert Date: Wed, 16 Sep 2026 15:31:51 -0700 Subject: [PATCH 13/13] daq: make the load-cell panel say which cell, which state, and let a tare be replaced The panel named its channels "LC41 Ch1" -- a board id and a connector number, which say nothing about which tank an operator is looking at. It never consulted the role map, though the RTD rows three lines above already did; they now read "Fuel Scale" and "LOX Scale". The generated label is still the fallback, and it carries the board scope for the case where a role is missing. Absolute and tared weight looked identical: 6.4 kg reads the same either way, and the only statement of which one you had was an 11px grey line at the top of the panel driven by anyTared -- so it announced "Tared" for the whole panel while one cell was still absolute. That line is gone. Each readout now carries its own chip, amber Tared or slate Absolute, with an amber border while a tare stands. The numeral keeps its channel colour: that colour is the series' identity in the plot below, and recolouring it on tare would make a readout and its own trace disagree about which load cell is which. Tare and Clear were 10px and easy to miss mid-procedure. They move into their own box under each readout, sized to split the width, and "Tare all" joins the section header. Tare is now offered while a tare is already standing: the service takes a fresh capture and derives the offset from the ABSOLUTE ADC code, so it zeroes at the current load whatever was there before. Clearing first was never required -- it just added a step and left the channel reading gross in between. --- .../frontend/app/plots/lcs-tcs-rtd/page.tsx | 146 ++++++++++++------ 1 file changed, 99 insertions(+), 47 deletions(-) diff --git a/daq-server/diablo_server/frontend/app/plots/lcs-tcs-rtd/page.tsx b/daq-server/diablo_server/frontend/app/plots/lcs-tcs-rtd/page.tsx index e3fe6453..d99330de 100644 --- a/daq-server/diablo_server/frontend/app/plots/lcs-tcs-rtd/page.tsx +++ b/daq-server/diablo_server/frontend/app/plots/lcs-tcs-rtd/page.tsx @@ -19,20 +19,49 @@ const WINDOW_SECONDS = 60; // ── Readout boxes ───────────────────────────────────────────────────────────── +/** + * `tareState` colour-codes what the number MEANS, which is not something the value itself can + * show: 12.0 kg absolute and 12.0 kg tared look identical. Amber = a tare is subtracted, slate = + * absolute. The numeral keeps its channel colour either way, because that colour is the series' + * identity in the plot below — recolouring it on tare would make the readout and its own trace + * disagree about which load cell is which, which is the confusion this panel already invites + * with two boards on connector 1. + */ function DerivedReadoutBox({ - label, value, unit, color, decimals = 1, + label, value, unit, color, decimals = 1, tareState = 'none', offsetKg = null, }: { label: string; value: number | null; unit: string; color: string; decimals?: number; + tareState?: 'none' | 'absolute' | 'tared'; + offsetKg?: number | null; }) { + const tared = tareState === 'tared'; return ( -
+
{label} {value !== null && Number.isFinite(value) ? value.toFixed(decimals) : '—'} - {unit} +
+ {unit} + {tareState !== 'none' && ( + + {tared ? 'Tared' : 'Absolute'} + + )} +
); } @@ -134,33 +163,53 @@ function LCForceReadout({ const value = useLoadCellForceKg(calEntity); const tared = offsetKg != null; return ( -
- -
+
+ + {/* The controls get their own box. Inside the readout the unit, the state chip and two + buttons had to share one row, and at three columns the buttons were the first thing + to be squeezed \u2014 a control an operator reaches for mid-procedure should not be the + part that loses the fight for space. */} +
+ {/* Offered tared or not. Re-taring is not "clear then tare": the service takes a fresh + capture and derives the offset from the ABSOLUTE ADC code, so it zeroes at the + current load whatever was standing before. Requiring a clear first only added a + step and left the channel reading gross in between. */} {tared && ( - - −{offsetKg!.toFixed(1)} kg - + )}
@@ -224,7 +273,15 @@ export default function LCS_TCS_RTDPage() { if (lc.length) { setLcEntities(lc.map((r) => r.entity)); setLcCalEntities(lc.map((r) => r.calEntity)); - setLcLabels(lc.map((r) => r.label)); + // Prefer the configured role ("Fuel Scale") over the generated "LC41 Ch1", the way the + // RTD rows above already do — a board id and a connector number say nothing about which + // tank an operator is looking at. The role stands alone: it is what the operator calls + // the channel, and the board id only earns space here if a role goes missing, which is + // when the generated label comes back with the board scope already in it. + setLcLabels(lc.map((r) => { + const role = sensorConfig?.find((s) => s.calEntity === r.calEntity)?.role; + return role ?? r.label; + })); setLcUids(lc.map((r) => r.boardId * 100 + r.channel)); } }).catch(() => {}); @@ -406,44 +463,39 @@ export default function LCS_TCS_RTDPage() { {/* ── LC (right column) ─────────────────────────────────────────────── */}
-
+

Load cells (LCS)

+ {lcEntities.length > 0 && ( +
+ {anyTared && ( + + )} + +
+ )}
{lcEntities.length > 0 ? ( <> -
- - {anyTared - ? 'Tared \u2014 showing weight relative to the tared load. Calibration is unaffected.' - : 'Showing absolute weight.'} - -
- {anyTared && ( - - )} - -
-
{tarePending && (
Waiting for the calibration service to confirm\u2026