20 return std::chrono::duration<double>(std::chrono::system_clock::now().time_since_epoch())
26 int m =
static_cast<int>(
board_id) % 10;
27 return static_cast<uint8_t
>(m == 0 ? 10 : m);
37 uint8_t logical_ch,
const std::string& role,
38 const std::string& active_model,
SensorKind kind,
39 const std::string& raw_unit) {
45 ch.connector = connector;
46 ch.logical_ch = logical_ch;
49 const std::string was =
ch.active_model;
50 if (!active_model.empty())
51 ch.active_model = active_model;
53 ch.raw_unit = raw_unit;
57 if (
ch.active_model != was && !
ch.points.empty())
62 if (
ch.active_model ==
"robust") {
67 ch.status =
ch.points.size() < 2 ?
"PENDING" :
"OK";
68 ch.last_error.clear();
73 const bool linear =
ch.active_model ==
"linear";
74 ch.fit = linear ?
ch.fit_linear :
ch.fit_cubic;
75 if (
ch.points.empty() || (linear &&
ch.points.size() < 2)) {
76 ch.status =
"PENDING";
77 ch.last_error.clear();
78 }
else if (!
ch.fit.valid) {
80 ch.last_error =
"fit produced non-finite coefficients";
81 }
else if (
ch.points.size() < 2) {
82 ch.status =
"PENDING";
83 ch.last_error.clear();
86 ch.last_error.clear();
92 const int n =
static_cast<int>(pts.size());
96 double amin = pts[0].adc, amax = pts[0].adc, amean = 0.0;
97 for (
const auto&
p : pts) {
98 amin = std::min(amin,
p.adc);
99 amax = std::max(amax,
p.adc);
102 amean /=
static_cast<double>(
n);
103 double scale = amax - amin;
104 if (!(scale > 0.0) || !std::isfinite(scale))
109 int degree = std::min(std::clamp(max_degree, 0, 3),
n - 1);
113 Eigen::MatrixXd V(
n, degree + 1);
114 Eigen::VectorXd y(
n);
115 for (
int i = 0; i <
n; ++i) {
118 for (
int j = 0; j <= degree; ++j) {
122 y(i) = pts[
static_cast<size_t>(i)].psi;
125 Eigen::VectorXd
p = V.colPivHouseholderQr().solve(y);
126 Eigen::VectorXd resid = V *
p - y;
127 f.
rmse = std::sqrt(resid.squaredNorm() /
static_cast<double>(
n));
129 f.
poly.assign(
static_cast<size_t>(degree + 1), 0.0);
130 for (
int j = 0; j <= degree; ++j)
131 f.
poly[
static_cast<size_t>(j)] =
p(j);
136 double pp[4] = {0.0, 0.0, 0.0, 0.0};
137 for (
int j = 0; j <= degree && j < 4; ++j)
141 f.
A = pp[3] *
a *
a *
a;
142 f.
B = pp[2] *
a *
a + pp[3] * 3.0 *
a *
a *
b;
143 f.
C = pp[1] *
a + pp[2] * 2.0 *
a *
b + pp[3] * 3.0 *
a *
b *
b;
144 f.
D = pp[0] + pp[1] *
b + pp[2] *
b *
b + pp[3] *
b *
b *
b;
146 f.
valid = std::isfinite(f.
A) && std::isfinite(f.
B) && std::isfinite(f.
C) && std::isfinite(f.
D);
147 for (
double c : f.
poly)
148 if (!std::isfinite(c))
161 static bool warned =
false;
164 std::cout <<
"[CubicStore] refusing capture for unregistered uid " << uid
165 <<
" (not in config); further such warnings suppressed" << std::endl;
169 auto&
ch = cit->second;
174 pt.
t = unix_now_sec();
175 ch.points.push_back(pt);
177 ch.points.erase(
ch.points.begin(),
ch.points.begin() +
static_cast<std::ptrdiff_t
>(
180 ch.updated_at = pt.
t;
192 it->second.last_capture = q;
196 const std::vector<std::pair<double, double>>& curve) {
200 it->second.fit_curve = curve;
208 it->second.points.clear();
212 it->second.fit_curve.clear();
213 it->second.status =
"PENDING";
214 it->second.last_error.clear();
215 it->second.updated_at = unix_now_sec();
221 if (it ==
channels_.end() || !it->second.fit.valid)
223 return &it->second.fit;
234 std::vector<uint16_t> out;
242 using nlohmann::json;
244 root[
"sensor_type"] =
"PT";
245 root[
"unit"] =
"PSI";
246 root[
"framework"] =
"cubic";
247 root[
"generated_unix"] = unix_now_sec();
249 json polys = json::object();
250 json poly_coeffs = json::object();
251 json norm_min = json::object();
252 json norm_scale = json::object();
253 json lc_polys = json::object();
254 json lc_poly_coeffs = json::object();
255 json lc_norm_min = json::object();
256 json lc_norm_scale = json::object();
276 const std::string lkey = std::to_string(
static_cast<int>(
ch.logical_ch));
278 if (only_if_absent && (is_lc ? lc_polys : polys).
contains(lkey))
280 (is_lc ? lc_polys : polys)[lkey] = {
ch.fit.A,
ch.fit.B,
ch.fit.C,
ch.fit.D};
281 (is_lc ? lc_poly_coeffs : poly_coeffs)[lkey] =
ch.fit.poly;
282 (is_lc ? lc_norm_min : norm_min)[lkey] =
ch.fit.norm_min;
283 (is_lc ? lc_norm_scale : norm_scale)[lkey] =
ch.fit.norm_scale;
295 cj[
"boardId"] =
static_cast<int>(
ch.board_id);
296 cj[
"connector"] =
static_cast<int>(
ch.connector);
297 cj[
"logicalCh"] =
static_cast<int>(
ch.logical_ch);
298 cj[
"role"] =
ch.role;
299 cj[
"active_model"] =
ch.active_model;
301 cj[
"rawUnit"] =
ch.raw_unit;
302 cj[
"numPoints"] =
ch.points.size();
303 cj[
"status"] =
ch.status;
304 cj[
"last_error"] =
ch.last_error;
305 cj[
"rmse"] =
ch.fit.valid ?
ch.fit.rmse : 0.0;
306 cj[
"degree"] =
ch.fit.degree;
307 cj[
"updatedAt"] =
ch.updated_at;
308 cj[
"coeffs"] = {{
"A",
ch.fit.A}, {
"B",
ch.fit.B}, {
"C",
ch.fit.C}, {
"D",
ch.fit.D}};
309 cj[
"polyCoeffs"] =
ch.fit.poly;
310 cj[
"adcNormMin"] =
ch.fit.norm_min;
311 cj[
"adcNormScale"] =
ch.fit.norm_scale;
314 auto fit_json = [](
const CubicFit& f) {
315 return json{{
"valid", f.valid},
316 {
"degree", f.degree},
317 {
"rmse", f.valid ? f.rmse : 0.0},
318 {
"polyCoeffs", f.poly},
319 {
"adcNormMin", f.norm_min},
320 {
"adcNormScale", f.norm_scale}};
322 cj[
"fits"] = {{
"cubic", fit_json(
ch.fit_cubic)}, {
"linear", fit_json(
ch.fit_linear)}};
323 if (
ch.last_capture.valid) {
324 cj[
"last_capture"] = {{
"t",
ch.last_capture.t},
325 {
"adc",
ch.last_capture.adc},
326 {
"n",
ch.last_capture.n},
327 {
"windowMs",
ch.last_capture.window_ms},
328 {
"spreadAdc",
ch.last_capture.spread},
329 {
"driftAdc",
ch.last_capture.drift},
330 {
"driftZ",
ch.last_capture.drift_z},
331 {
"settled",
ch.last_capture.settled}};
334 for (
const auto&
p :
ch.points)
335 pts.push_back({{
"adc",
p.adc}, {
"psi",
p.psi}, {
"t",
p.t}});
338 if (!
ch.fit_curve.empty()) {
340 for (
const auto& [
adc, psi] :
ch.fit_curve)
341 fc.push_back({{
"adc",
adc}, {
"psi", psi}});
344 state[std::to_string(
static_cast<int>(uid))] = cj;
347 root[
"calibration_polynomials"] = polys;
348 root[
"calibration_poly_coeffs"] = poly_coeffs;
349 root[
"calibration_adc_norm_min"] = norm_min;
350 root[
"calibration_adc_norm_scale"] = norm_scale;
351 root[
"lc_calibration_polynomials"] = lc_polys;
352 root[
"lc_calibration_poly_coeffs"] = lc_poly_coeffs;
353 root[
"lc_calibration_adc_norm_min"] = lc_norm_min;
354 root[
"lc_calibration_adc_norm_scale"] = lc_norm_scale;
355 root[
"cubic_state"] =
state;
368 if (
p.has_parent_path())
369 std::filesystem::create_directories(
p.parent_path());
372 std::ofstream f(tmp, std::ios::trunc);
392 std::string content((std::istreambuf_iterator<char>(f)), std::istreambuf_iterator<char>());
396 root = nlohmann::json::parse(content);
403 <<
" could not be parsed — refusing to overwrite it" << std::endl;
406 if (!root.contains(
"cubic_state") || !root[
"cubic_state"].is_object()) {
409 <<
" has no cubic_state — refusing to overwrite it" << std::endl;
417 std::map<std::string, uint16_t> role_to_uid;
419 if (!
ch.role.empty())
420 role_to_uid.emplace(
ch.role, uid);
423 for (
auto& [id_str, cj] : root[
"cubic_state"].items()) {
424 uint16_t persisted_uid = 0;
426 persisted_uid =
static_cast<uint16_t
>(std::stoi(id_str));
434 std::string entry_role = cj.value(
"role", std::string());
435 if (entry_role.empty()) {
436 auto pit =
channels_.find(persisted_uid);
438 entry_role = pit->second.role;
443 const std::string entry_unit = cj.value(
"rawUnit", std::string(
kRawUnitAdc));
444 uint16_t uid = persisted_uid;
445 if (!entry_role.empty()) {
446 auto rit = role_to_uid.find(entry_role);
447 if (rit != role_to_uid.end()) {
448 if (
channels_[rit->second].raw_unit == entry_unit) {
451 std::cout <<
"[CubicStore] role '" << entry_role <<
"' cal was taken in "
452 << entry_unit <<
" but the channel it now names reads "
454 <<
" — NOT attached; recalibrate it. The old points are kept under "
456 << persisted_uid <<
"." << std::endl;
463 if (dit !=
channels_.end() && dit->second.uid != 0 &&
464 dit->second.raw_unit != entry_unit) {
465 std::cout <<
"[CubicStore] uid " << uid <<
" cal was taken in " << entry_unit
466 <<
" but uid " << uid <<
" now reads " << dit->second.raw_unit
467 <<
" — dropped; recalibrate it." << std::endl;
476 const bool registered =
ch.uid != 0;
479 ch.board_id =
static_cast<uint8_t
>(uid / 100);
480 ch.connector =
static_cast<uint8_t
>(uid % 100);
486 cj[
"logicalCh"].is_number_integer()
487 ?
static_cast<uint8_t
>(cj[
"logicalCh"].get<
int>())
489 ch.raw_unit = entry_unit;
490 ch.role = entry_role;
491 ch.active_model = cj.value(
"active_model", std::string(
"cubic"));
499 if (cj.contains(
"points") && cj[
"points"].is_array()) {
500 for (
const auto& pj : cj[
"points"]) {
502 p.adc = pj.value(
"adc", 0.0);
503 p.psi = pj.value(
"psi", 0.0);
504 p.t = pj.value(
"t", 0.0);
505 ch.points.push_back(
p);
509 ch.points.erase(
ch.points.begin(),
511 static_cast<std::ptrdiff_t
>(
ch.points.size() -
kMaxPoints));
513 ch.fit_curve.clear();
514 if (cj.contains(
"fitCurve") && cj[
"fitCurve"].is_array())
515 for (
const auto& pj : cj[
"fitCurve"])
516 ch.fit_curve.emplace_back(pj.value(
"adc", 0.0), pj.value(
"psi", 0.0));
521 ch.updated_at = unix_now_sec();
522 if (uid != persisted_uid)
523 std::cout <<
"[CubicStore] role '" << entry_role <<
"' cal re-attached to connector "
524 <<
static_cast<int>(uid % 100) <<
" (was "
525 <<
static_cast<int>(persisted_uid % 100) <<
") — sensor moved" << std::endl;
* array(stored with pointer to save storage) array_t *array
void note_capture(uint16_t uid, const CaptureQuality &q)
Record how the last capture on this uid went, for the UI to show.
std::map< uint16_t, CubicChannel > channels_
size_t load()
Load the JSON record, resume points, and re-fit each channel.
static constexpr size_t kMaxPoints
bool load_failed_
Set when load() could not read an existing file.
void clear_channel(uint16_t uid)
Drop all points, fit, and fit-curve for a uid (channel goes back to PENDING).
const CubicFit * fit_for(uint16_t uid) const
void register_channel(uint16_t uid, uint8_t board_id, uint8_t connector, uint8_t logical_ch, const std::string &role, const std::string &active_model="cubic", SensorKind kind=SensorKind::PT, const std::string &raw_unit="adc")
Attach/refresh identity + role + streaming model for a uid (idempotent; call at startup).
static void refit(CubicChannel &ch)
Recompute fit_cubic / fit_linear / fit / status from ch.points per ch.active_model.
static CubicFit compute_fit(const std::vector< CubicPoint > &pts, int max_degree=3)
Least-squares polynomial of degree min(max_degree, n - 1) over pts.
CubicCalibrationStore(std::string file_path)
std::vector< uint16_t > uids() const
CubicFit add_point(uint16_t uid, double adc, double psi)
Append a capture point (raw ADC, reference PSI), cap history, and re-fit.
const CubicChannel * channel(uint16_t uid) const
std::string serialize() const
void set_fit_curve(uint16_t uid, const std::vector< std::pair< double, double > > &curve)
Replace a robust uid's display fit-curve (sampled predict_pressure_psi points).
bool save() const
Atomically write the JSON record (temp + rename).
static ActuatorControllerState state
static STAR_MCP3201 adc(MCP3201_CS_PIN, &mcp_spi)
*brief create JSON pointer *sa reference_tokens const string_t const string_t & b
*brief create JSON pointer *sa reference_tokens const string_t & a
*brief remove last reference token *sa JSON pointer has no nullptr
bool contains(const BasicJsonType *ptr) const
basic_json<> json
default specialization
uint8_t slot_from_board_id(uint8_t board_id)
slot = board_id % 10 with 0 -> 10 (same rule as calibration_service / daq_bridge).
Output smoothing for calibrated PT pressure, per sensor uid (ESP32 and LabJack T7 alike).
constexpr const char * kRawUnitAdc
CubicChannel::raw_unit values.
constexpr const char * kRawUnitMicrovolts
uint8_t pt_logical_calibration_channel(uint8_t board_slot_number, uint8_t local_connector)
PT board slot (1–10, from board_id % 10 with 0→10) + local connector (1–10) → factory/JSON logical ch...
SensorKind
Which family of sensor a channel belongs to.
Motorized ball valve position: raw ADC counts -> % open.
*brief user defined string literal for JSON values *sa std::size_t n
How trustworthy the most recent capture on this channel was.
Per-sensor calibration record: identity, captured points, and the current fit.
Result of a least-squares polynomial fit over a channel's captured points.
std::vector< double > poly
One operator-captured calibration point: raw ADC code paired with a known reference PSI.
static std::string ch(int ain, const std::string &kind, const std::string &extra="", const std::string &board="lj")