STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
RobustCalibrationManager.cpp
Go to the documentation of this file.
2
3#include <Eigen/Dense>
4#include <algorithm>
5#include <cctype>
6#include <chrono>
7#include <cmath>
8#include <cstdlib>
9#include <fstream>
10#include <iomanip>
11#include <iostream>
12#include <map>
13#include <nlohmann/json.hpp>
14#include <optional>
15#include <sstream>
16#include <vector>
17
18namespace fsw {
19namespace calibration {
20
21namespace {
22
24 using clock = std::chrono::steady_clock;
25 static const clock::time_point t0 = clock::now();
26 return std::chrono::duration<double>(clock::now() - t0).count();
27}
28
29// Read a fixed-length numeric vector from a JSON value (an array of at least n numbers).
30std::optional<Eigen::VectorXd> json_vec(const nlohmann::json& j, int n) {
31 if (!j.is_array() || static_cast<int>(j.size()) < n)
32 return std::nullopt;
33 Eigen::VectorXd v(n);
34 for (int i = 0; i < n; ++i) {
35 if (!j[static_cast<size_t>(i)].is_number())
36 return std::nullopt;
37 v(i) = j[static_cast<size_t>(i)].get<double>();
38 }
39 return v;
40}
41
42// Read an n×n matrix from a JSON value: nested ([[...],...]) or a flat array of n*n numbers.
43std::optional<Eigen::MatrixXd> json_mat(const nlohmann::json& j, int n) {
44 if (!j.is_array())
45 return std::nullopt;
46 if (static_cast<int>(j.size()) == n && j[0].is_array()) {
47 Eigen::MatrixXd m(n, n);
48 for (int r = 0; r < n; ++r) {
49 if (!j[static_cast<size_t>(r)].is_array() ||
50 static_cast<int>(j[static_cast<size_t>(r)].size()) < n)
51 return std::nullopt;
52 for (int c = 0; c < n; ++c) {
53 const auto& e = j[static_cast<size_t>(r)][static_cast<size_t>(c)];
54 if (!e.is_number())
55 return std::nullopt;
56 m(r, c) = e.get<double>();
57 }
58 }
59 return m;
60 }
61 if (static_cast<int>(j.size()) >= n * n) { // flat fallback
62 Eigen::MatrixXd m(n, n);
63 for (int r = 0; r < n; ++r)
64 for (int c = 0; c < n; ++c) {
65 const auto& e = j[static_cast<size_t>(r * n + c)];
66 if (!e.is_number())
67 return std::nullopt;
68 m(r, c) = e.get<double>();
69 }
70 return m;
71 }
72 return std::nullopt;
73}
74
75// True when a JSON object key is a non-empty run of digits (a numeric sensor id).
76bool is_numeric_key(const std::string& s) {
77 return !s.empty() && std::all_of(s.begin(), s.end(), [](unsigned char c) {
78 return std::isdigit(c) != 0;
79 });
80}
81
82/** Saved θ that drifts far from the factory cubic is usually bad priors — drop them. */
84 const char* s = std::getenv("CAL_ROBUST_PRIOR_MAX_PSI_ERR");
85 if (!s || !*s)
86 return 125.0;
87 char* end = nullptr;
88 double v = std::strtod(s, &end);
89 if (end == s || !std::isfinite(v) || v <= 0.0)
90 return 125.0;
91 return v;
92}
93
94/** After loading adjustments.json, θ may still match an old factory seed (e.g. Ox used PT1
95 * fallback). If restored θ disagrees with the current factory cubic, re-seed so ADC→PSI tracks
96 * merged JSON/CSV.
97 */
98void reconcile_frameworks_with_factory_baseline(std::map<uint16_t, SensorState>& states) {
99 const double lim = robust_prior_max_psi_mismatch();
100 for (auto& [id, st] : states) {
101 if (!st.framework)
102 continue;
103 if (st.framework->max_abs_error_vs_factory(st.baseline) > lim) {
104 std::cerr << "[RobustCalibration] Sensor " << static_cast<int>(id)
105 << ": loaded θ vs current factory cubic mismatch > " << lim
106 << " PSI (ADC grid) — re-seeding robust from factory baseline.\n";
107 st.framework->seed_from_factory_cubic(st.baseline);
108 }
109 }
110}
111
112} // namespace
113
114SensorState::SensorState() = default;
115
117
119 const PTCalibrationCoeffs& baseline) {
120 std::lock_guard<std::mutex> lock(mutex_);
121 auto& state = states_[sensor_id];
122 state.baseline = baseline;
123 state.framework = std::make_unique<RobustCalibrationFramework>(static_cast<int>(sensor_id));
124 state.framework->seed_from_factory_cubic(baseline);
125
126 const double prior_limit = robust_prior_max_psi_mismatch();
127
128 // If priors were loaded before (or after) framework creation, apply the most specific ones.
129 if (restored_theta_mean_.count(sensor_id) && restored_theta_cov_.count(sensor_id)) {
130 state.framework->set_theta_mean_for_restore(restored_theta_mean_[sensor_id]);
131 state.framework->set_theta_cov_for_restore(restored_theta_cov_[sensor_id]);
132 state.framework->set_rls_P_for_restore(restored_theta_cov_[sensor_id]);
133 if (state.framework->max_abs_error_vs_factory(baseline) > prior_limit) {
134 std::cerr << "[RobustCalibration] Discarding adjustments prior for sensor "
135 << static_cast<int>(sensor_id) << ": max |robust-factory| > " << prior_limit
136 << " PSI on ADC grid — re-seeding from factory cubic.\n";
137 state.framework->seed_from_factory_cubic(baseline);
138 }
139 } else if (restored_theta_mean_.count(sensor_id)) {
140 state.framework->set_theta_mean_for_restore(restored_theta_mean_[sensor_id]);
141 if (state.framework->max_abs_error_vs_factory(baseline) > prior_limit) {
142 std::cerr << "[RobustCalibration] Discarding adjustments mean-only prior for sensor "
143 << static_cast<int>(sensor_id) << " — re-seeding from factory cubic.\n";
144 state.framework->seed_from_factory_cubic(baseline);
145 }
147 state.framework->set_theta_mean_for_restore(*population_theta_mean_);
148 state.framework->set_theta_cov_for_restore(*population_theta_cov_);
149 state.framework->set_rls_P_for_restore(*population_theta_cov_);
150 if (state.framework->max_abs_error_vs_factory(baseline) > prior_limit) {
151 std::cerr << "[RobustCalibration] Discarding population prior for sensor "
152 << static_cast<int>(sensor_id) << " — re-seeding from factory cubic.\n";
153 state.framework->seed_from_factory_cubic(baseline);
154 }
155 }
156}
157
158void RobustCalibrationManager::update_calibration(uint16_t sensor_id, int32_t adc_code,
159 double reference_pressure) {
160 std::lock_guard<std::mutex> lock(mutex_);
161 auto it = states_.find(sensor_id);
162 if (it == states_.end() || !it->second.framework)
163 return;
165 pt.adc_code = static_cast<double>(adc_code);
166 pt.pressure = reference_pressure;
167 pt.timestamp = monotonic_sec_now();
168 pt.uncertainty = 0.01;
169 it->second.framework->add_calibration_point(pt);
170}
171
172void RobustCalibrationManager::zero_sensor(uint16_t sensor_id, int32_t adc_code) {
173 std::lock_guard<std::mutex> lock(mutex_);
174 auto it = states_.find(sensor_id);
175 if (it == states_.end() || !it->second.framework)
176 return;
178 pt.adc_code = static_cast<double>(adc_code);
179 pt.pressure = 0.0;
180 pt.timestamp = monotonic_sec_now();
181 pt.uncertainty = 0.01;
182 it->second.framework->add_calibration_point(pt);
183}
184
185double RobustCalibrationManager::predict_pressure_psi(uint16_t sensor_id, int32_t adc_code) {
186 std::lock_guard<std::mutex> lock(mutex_);
187 auto it = states_.find(sensor_id);
188 if (it == states_.end() || !it->second.framework)
189 return 0.0;
190 return it->second.framework->predict_pressure_psi(static_cast<double>(adc_code));
191}
192
193bool RobustCalibrationManager::has_sensor(uint16_t sensor_id) const {
194 std::lock_guard<std::mutex> lock(mutex_);
195 auto it = states_.find(sensor_id);
196 return it != states_.end() && it->second.framework != nullptr;
197}
198
200 std::lock_guard<std::mutex> lock(mutex_);
201 auto it = states_.find(sensor_id);
202 if (it == states_.end())
203 return;
204 auto& state = it->second;
205 state.framework = std::make_unique<RobustCalibrationFramework>(static_cast<int>(sensor_id));
206 state.framework->seed_from_factory_cubic(state.baseline);
207}
208
210 const PTCalibrationCoeffs& baseline) {
211 std::lock_guard<std::mutex> lock(mutex_);
212 auto& state = states_[sensor_id];
213 state.baseline = baseline;
214 state.framework = std::make_unique<RobustCalibrationFramework>(static_cast<int>(sensor_id));
215 state.framework->seed_from_factory_cubic(baseline);
216}
217
219 const std::string& path, const std::map<uint16_t, std::string>* uid_to_role) const {
220 std::lock_guard<std::mutex> lock(mutex_);
221 constexpr int N = RobustCalibrationFramework::N;
222 nlohmann::json fw2 = nlohmann::json::object();
223 for (const auto& [id, state] : states_) {
224 if (!state.framework)
225 continue;
226 Eigen::VectorXd t = state.framework->theta_mean();
227 Eigen::MatrixXd cov = state.framework->theta_cov();
228 nlohmann::json mean = nlohmann::json::array();
229 for (int i = 0; i < N; ++i)
230 mean.push_back(t(i));
231 nlohmann::json cvec = nlohmann::json::array();
232 for (int r = 0; r < N; ++r) {
233 nlohmann::json row = nlohmann::json::array();
234 for (int c = 0; c < N; ++c)
235 row.push_back(cov(r, c));
236 cvec.push_back(row);
237 }
238 nlohmann::json entry = {{"theta_mean", mean}, {"theta_cov", cvec}};
239 // Record the role so load can re-attach this state to the role's current sensor_id.
240 if (uid_to_role) {
241 auto rit = uid_to_role->find(id);
242 if (rit != uid_to_role->end() && !rit->second.empty())
243 entry["role"] = rit->second;
244 }
245 fw2[std::to_string(static_cast<int>(id))] = std::move(entry);
246 }
247 const nlohmann::json root = {{"framework_v2", fw2}};
248
249 std::ofstream file(path);
250 if (!file.is_open())
251 return false;
252 file << root.dump(2) << "\n";
253 return file.good();
254}
255
257 const std::string& path, const std::map<uint16_t, std::string>* uid_to_role) {
258 std::lock_guard<std::mutex> lock(mutex_);
259 std::ifstream file(path);
260 if (!file.is_open())
261 return false;
262 std::string content((std::istreambuf_iterator<char>(file)), std::istreambuf_iterator<char>());
263
264 // Clear previous priors (this manager can be reused within one process).
266 population_theta_cov_.reset();
267 restored_theta_mean_.clear();
268 restored_theta_cov_.clear();
269
270 nlohmann::json root;
271 try {
272 root = nlohmann::json::parse(content);
273 } catch (...) {
274 return true; // unparseable — treat as unknown format, leave state as-is
275 }
276 if (!root.is_object())
277 return true;
278
279 constexpr int N = RobustCalibrationFramework::N;
280
281 // Inverse role -> current sensor_id, so a role-tagged entry is restored onto the connector that
282 // role now occupies (calibration follows the sensor across a move).
283 std::map<std::string, uint16_t> role_to_uid;
284 if (uid_to_role)
285 for (const auto& [uid, role] : *uid_to_role)
286 if (!role.empty())
287 role_to_uid.emplace(role, uid); // roles are unique; first wins
288
289 auto apply_restored = [&]() {
290 for (auto& [id, state] : states_) {
291 if (!state.framework)
292 continue;
293 if (restored_theta_mean_.count(id) && restored_theta_cov_.count(id)) {
294 state.framework->set_theta_mean_for_restore(restored_theta_mean_[id]);
295 state.framework->set_theta_cov_for_restore(restored_theta_cov_[id]);
296 state.framework->set_rls_P_for_restore(restored_theta_cov_[id]);
298 state.framework->set_theta_mean_for_restore(*population_theta_mean_);
299 state.framework->set_theta_cov_for_restore(*population_theta_cov_);
300 state.framework->set_rls_P_for_restore(*population_theta_cov_);
301 }
302 }
303 };
304
305 // Format 1: runtime save format (framework_v2: theta_mean + theta_cov per numeric id).
306 if (root.contains("framework_v2") && root["framework_v2"].is_object()) {
307 for (auto& [id_str, sub] : root["framework_v2"].items()) {
308 if (!is_numeric_key(id_str) || !sub.is_object())
309 continue;
310 uint16_t id = static_cast<uint16_t>(std::stoi(id_str));
311 // Re-file by role onto its current sensor_id when the role is known; else keep the
312 // persisted id (legacy entries / roles no longer in config).
313 if (sub.contains("role") && sub["role"].is_string()) {
314 auto rit = role_to_uid.find(sub["role"].get<std::string>());
315 if (rit != role_to_uid.end())
316 id = rit->second;
317 }
318 auto mean = sub.contains("theta_mean") ? json_vec(sub["theta_mean"], N) : std::nullopt;
319 auto cov = sub.contains("theta_cov") ? json_mat(sub["theta_cov"], N) : std::nullopt;
320 if (mean && cov) {
321 restored_theta_mean_[id] = *mean;
322 restored_theta_cov_[id] = *cov;
323 }
324 }
325 apply_restored();
326 reconcile_frameworks_with_factory_baseline(states_);
327 return true;
328 }
329
330 // Format 2: older runtime save format (framework_v1: theta_mean only).
331 if (root.contains("framework_v1") && root["framework_v1"].is_object()) {
332 for (auto& [id_str, val] : root["framework_v1"].items()) {
333 if (!is_numeric_key(id_str))
334 continue;
335 const uint16_t id = static_cast<uint16_t>(std::stoi(id_str));
336 const nlohmann::json& arr =
337 val.is_array() ? val
338 : (val.is_object() && val.contains("theta_mean") ? val["theta_mean"]
339 : nlohmann::json());
340 auto mean = json_vec(arr, N);
341 if (mean) {
342 restored_theta_mean_[id] = *mean;
343 auto it = states_.find(id);
344 if (it != states_.end() && it->second.framework)
345 it->second.framework->set_theta_mean_for_restore(*mean);
346 }
347 }
348 reconcile_frameworks_with_factory_baseline(states_);
349 return true;
350 }
351
352 // Format 3: Python calibration backup (population_prior + pt_states).
353 if (root.contains("population_prior") && root.contains("pt_states")) {
354 if (root["population_prior"].is_object()) {
355 const auto& pp = root["population_prior"];
356 auto pm =
357 pp.contains("population_mean") ? json_vec(pp["population_mean"], N) : std::nullopt;
358 auto pc = pp.contains("population_covariance")
359 ? json_mat(pp["population_covariance"], N)
360 : std::nullopt;
361 if (pm && pc) {
364 }
365 }
366 if (root["pt_states"].is_object()) {
367 for (auto& [id_str, sub] : root["pt_states"].items()) {
368 if (!is_numeric_key(id_str) || !sub.is_object())
369 continue;
370 const uint16_t id = static_cast<uint16_t>(std::stoi(id_str));
371 auto mean =
372 sub.contains("theta_mean") ? json_vec(sub["theta_mean"], N) : std::nullopt;
373 auto cov = sub.contains("theta_cov") ? json_mat(sub["theta_cov"], N) : std::nullopt;
374 if (mean && cov) {
375 restored_theta_mean_[id] = *mean;
376 restored_theta_cov_[id] = *cov;
377 }
378 }
379 }
380 apply_restored();
381 reconcile_frameworks_with_factory_baseline(states_);
382 return true;
383 }
384
385 // Unknown format: do nothing.
386 return true;
387}
388
389} // namespace calibration
390} // namespace fsw
* array(stored with pointer to save storage) array_t *array
void update_calibration(uint16_t sensor_id, int32_t adc_code, double reference_pressure)
bool load_adjustments(const std::string &path, const std::map< uint16_t, std::string > *uid_to_role=nullptr)
Restore per-sensor θ.
bool save_adjustments(const std::string &path, const std::map< uint16_t, std::string > *uid_to_role=nullptr) const
Persist per-sensor θ.
void zero_sensor(uint16_t sensor_id, int32_t adc_code)
std::map< uint16_t, Eigen::VectorXd > restored_theta_mean_
void reseed_sensor(uint16_t sensor_id, const PTCalibrationCoeffs &baseline)
Reseed a sensor's framework cleanly from the given cubic baseline, discarding any learned adjustments...
std::optional< Eigen::VectorXd > population_theta_mean_
void initialize_sensor(uint16_t sensor_id, const PTCalibrationCoeffs &baseline)
std::optional< Eigen::MatrixXd > population_theta_cov_
double predict_pressure_psi(uint16_t sensor_id, int32_t adc_code)
Mean PSI from the robust model (default environment).
std::map< uint16_t, Eigen::MatrixXd > restored_theta_cov_
bool has_sensor(uint16_t sensor_id) const
True if this sensor_id was initialized for robust tracking (factory-baselined).
static ActuatorControllerState state
Definition main.cpp:57
*brief create JSON pointer *sa reference_tokens end()
static osMutexId_t lock
Definition mode.c:11
double robust_prior_max_psi_mismatch()
Saved θ that drifts far from the factory cubic is usually bad priors — drop them.
void reconcile_frameworks_with_factory_baseline(std::map< uint16_t, SensorState > &states)
After loading adjustments.json, θ may still match an old factory seed (e.g.
std::optional< Eigen::VectorXd > json_vec(const nlohmann::json &j, int n)
std::optional< Eigen::MatrixXd > json_mat(const nlohmann::json &j, int n)
Output smoothing for calibrated PT pressure, per sensor uid (ESP32 and LabJack T7 alike).
Motorized ball valve position: raw ADC counts -> % open.
*brief user defined string literal for JSON values *sa std::size_t n
Definition json.hpp:24474
PT Calibration Coefficients.
static boot_report_t r
constexpr bool is_number
Definition toml.hpp:2244
constexpr bool is_array
Definition toml.hpp:2223