STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
SensorCalibration.hpp
Go to the documentation of this file.
1#ifndef SENSOR_CALIBRATION_HPP
2#define SENSOR_CALIBRATION_HPP
3
4#include <algorithm>
5#include <array>
6#include <cmath>
7#include <cstdint>
8#include <fstream>
9#include <iostream>
10#include <limits>
11#include <map>
12#include <regex>
13#include <sstream>
14#include <string>
15#include <vector>
16
17namespace fsw {
18namespace calibration {
19
20// ────────────────────────────────────────────────────────────────────────────
21// PolynomialCalibration — generic N-th order polynomial calibration
22// Applies: output = C[0]*x^N + C[1]*x^(N-1) + ... + C[N]
23// Stored highest-degree-first (numpy polyfit convention).
24// ────────────────────────────────────────────────────────────────────────────
26 std::vector<double> coeffs; // highest-degree-first
27 int order = 0; // polynomial degree (coeffs.size() - 1)
28 double r_squared = 0.0; // quality metric from fitting
29 std::string unit; // output unit label ("PSI", "°C", "lbf", …)
30
32
33 // Cubic shortcut (PT legacy compat): A*x³ + B*x² + C*x + D
34 PolynomialCalibration(double A, double B, double C, double D, const std::string& u = "PSI")
35 : coeffs{A, B, C, D}, order(3), unit(u) {
36 }
37
38 // Generic constructor
39 PolynomialCalibration(std::vector<double> c, const std::string& u = "")
40 : coeffs(std::move(c)), order(static_cast<int>(coeffs.size()) - 1), unit(u) {
41 }
42
43 /// Evaluate the polynomial at x (Horner's method — numerically stable)
44 double evaluate(double x) const {
45 if (coeffs.empty())
46 return 0.0;
47 double result = coeffs[0];
48 for (size_t i = 1; i < coeffs.size(); ++i) {
49 result = result * x + coeffs[i];
50 }
51 return result;
52 }
53
54 /// Convenience for int32 ADC codes
55 double evaluate(int32_t adc_code) const {
56 return evaluate(static_cast<double>(adc_code));
57 }
58
59 bool is_valid() const {
60 return !coeffs.empty();
61 }
62};
63
64// ────────────────────────────────────────────────────────────────────────────
65// SensorCalibrationManager — manages per-channel polynomial calibrations
66// for any sensor type (PT, TC, RTD, LC).
67// Loads from JSON / CSV, same file formats as PTCalibrationManager.
68// ────────────────────────────────────────────────────────────────────────────
70public:
71 /// @param sensor_type "PT", "TC", "RTD", "LC"
72 /// @param default_unit output physical unit
73 /// @param default_order default polynomial order if not specified in file
74 explicit SensorCalibrationManager(const std::string& sensor_type,
75 const std::string& default_unit = "", int default_order = 3)
76 : sensor_type_(sensor_type), default_unit_(default_unit), default_order_(default_order) {
77 }
78
80
81 // ── Loading ────────────────────────────────────────────────────────────
82 /// Load calibration from a JSON calibration file
83 bool load_from_json(const std::string& json_path);
84
85 /// Load calibration from a CSV file (DiabloAvionics format)
86 bool load_from_csv(const std::string& csv_path);
87
88 /// Auto-discover & load from default directories
89 bool load_calibration(const std::string& json_dir, const std::string& csv_path);
90
91 /// Manually set coefficients for a channel
92 void set_calibration(uint8_t channel_id, const PolynomialCalibration& cal) {
93 calibrations_[channel_id] = cal;
94 }
95
96 /// Remove a channel's calibration so is_calibrated() returns false again.
97 void clear_calibration(uint8_t channel_id) {
98 calibrations_.erase(channel_id);
99 }
100
101 // ── Evaluation ─────────────────────────────────────────────────────────
102 bool is_calibrated(uint8_t channel_id) const;
103 double calculate(uint8_t channel_id, int32_t raw_counts) const;
104 const PolynomialCalibration* get(uint8_t channel_id) const;
105 size_t calibrated_count() const {
106 return calibrations_.size();
107 }
108
109 const std::string& sensor_type() const {
110 return sensor_type_;
111 }
112
113private:
114 std::string sensor_type_;
115 std::string default_unit_;
117 std::map<uint8_t, PolynomialCalibration> calibrations_;
118
119 std::string find_latest_json(const std::string& dir) const;
120};
121
122// ────────────────────────────────────────────────────────────────────────────
123// RTD-specific helpers (Callendar-Van Dusen)
124// R(T) = R0*(1 + A*T + B*T² + C*(T-100)*T³) T >= 0 °C
125// R(T) = R0*(1 + A*T + B*T²) T < 0 °C (approx)
126//
127// IEC 60751 (Pt100):
128// A = 3.9083e-3
129// B = -5.775e-7
130// C = -4.183e-12 (only used T < 0)
131// R0 = 100 Ω
132//
133// For an ADC that reads counts proportional to resistance, the calibration
134// is: counts → resistance (linear) → temperature (CVD inverse).
135// The polynomial approach from SensorCalibrationManager still applies
136// if you just want a 3rd-order poly fit from calibration data.
137// But we also expose the physics-based CVD calculation here.
138// ────────────────────────────────────────────────────────────────────────────
139namespace rtd {
140
141struct CVDCoeffs {
142 double R0 = 100.0; // Reference resistance at 0 °C (Pt100)
143 double A = 3.9083e-3;
144 double B = -5.775e-7;
145 double C = -4.183e-12; // only for T < 0 °C
146};
147
148/// Resistance (Ω) at temperature (°C), IEC 60751 Callendar-Van Dusen, the C term below 0 °C.
149inline double temp_to_resistance_cvd(double t_c, const CVDCoeffs& cvd = CVDCoeffs{}) {
150 double r = 1.0 + cvd.A * t_c + cvd.B * t_c * t_c;
151 if (t_c < 0.0)
152 r += cvd.C * (t_c - 100.0) * t_c * t_c * t_c;
153 return cvd.R0 * r;
154}
155
156/// The full CVD inverse: exact above 0 °C (the quadratic), Newton on the C term below it.
157/// resistance_to_temp_cvd() drops the C term and reads cold below 0 °C: 0.2 °C at -100 °C,
158/// 1.76 °C at -183 °C (LOX), 2.25 °C at -196 °C. NaN when the resistance is off the curve or the
159/// temperature is outside the standard's -200..850 °C (5 °C of slack each side): an open element
160/// reads a huge resistance and a shorted one about -242 °C, and neither is a temperature.
161inline double resistance_to_temp_cvd_full(double resistance_ohms,
162 const CVDCoeffs& cvd = CVDCoeffs{}) {
163 if (!(resistance_ohms > 0.0) || !(cvd.R0 > 0.0))
164 return std::numeric_limits<double>::quiet_NaN();
165 const double ratio = resistance_ohms / cvd.R0;
166 const double disc = cvd.A * cvd.A - 4.0 * cvd.B * (1.0 - ratio);
167 if (disc < 0.0)
168 return std::numeric_limits<double>::quiet_NaN();
169 constexpr double kMinC = -205.0, kMaxC = 855.0;
170 double t = (-cvd.A + std::sqrt(disc)) / (2.0 * cvd.B);
171 if (resistance_ohms >= cvd.R0)
172 return t <= kMaxC ? t : std::numeric_limits<double>::quiet_NaN();
173 for (int i = 0; i < 20; ++i) {
174 const double f = temp_to_resistance_cvd(t, cvd) - resistance_ohms;
175 const double df =
176 cvd.R0 * (cvd.A + 2.0 * cvd.B * t + cvd.C * (4.0 * t * t * t - 300.0 * t * t));
177 const double step = f / df;
178 t -= step;
179 if (std::fabs(step) < 1e-9)
180 break;
181 }
182 return t >= kMinC ? t : std::numeric_limits<double>::quiet_NaN();
183}
184
185/// An ESP32 RTD board's reading -> °C: the ADC code (2^31 = adc_ref_voltage) is the voltage across
186/// the element carrying excitation_ua, so R = V / I, then the full CVD inverse. NaN when there is
187/// no temperature to report, so the caller publishes nothing rather than a plausible one:
188/// - open element: the current source drives the input to the rail and the ADC saturates. That
189/// is still on the curve (2.5 V / 1 mA = 2500 ohm, ~395 C on a Pt1000), so a code within 0.1 %
190/// of full scale is refused outright;
191/// - shorted element: ~0 ohm, about -242 C, outside -200..850 C (resistance_to_temp_cvd_full).
192inline double rtd_board_adc_to_temp_c(int32_t adc_raw, double adc_ref_voltage, double excitation_ua,
193 double r0_ohms) {
194 constexpr double kAdcMax = 2147483648.0; // 2^31
195 if (!(excitation_ua > 0.0) || std::fabs(static_cast<double>(adc_raw)) >= 0.999 * kAdcMax)
196 return std::numeric_limits<double>::quiet_NaN();
197 const double voltage_v = (static_cast<double>(adc_raw) / kAdcMax) * adc_ref_voltage;
198 const double resistance_ohms = (std::abs(voltage_v) * 1e6) / excitation_ua;
199 CVDCoeffs cvd;
200 cvd.R0 = r0_ohms;
201 return resistance_to_temp_cvd_full(resistance_ohms, cvd);
202}
203
204/// Convert resistance (Ω) to temperature (°C) using iterative CVD inverse
205inline double resistance_to_temp_cvd(double resistance_ohms, const CVDCoeffs& cvd = CVDCoeffs{}) {
206 // Quadratic approximation: T ≈ (-A + sqrt(A² - 4B*(1 - R/R0))) / (2B)
207 double ratio = resistance_ohms / cvd.R0;
208 double disc = cvd.A * cvd.A - 4.0 * cvd.B * (1.0 - ratio);
209 if (disc < 0.0)
210 return -999.0; // out of range
211 return (-cvd.A + std::sqrt(disc)) / (2.0 * cvd.B);
212}
213
214/// Convert ADC counts to resistance (assuming linear relationship)
215inline double counts_to_resistance(int32_t counts, double gain, double offset) {
216 return static_cast<double>(counts) * gain + offset;
217}
218
219} // namespace rtd
220
221// ────────────────────────────────────────────────────────────────────────────
222// TC-specific helpers
223// Standard NIST polynomial: V(mV) → T(°C)
224// Order varies by type (K, J, T, E, …). We store poly coeffs per type.
225// Cold-junction compensation: T_actual = T_measured + T_cold_junction
226// ────────────────────────────────────────────────────────────────────────────
227namespace tc {
228
229enum class TCType { K, J, T, E, N, S, R, B, CUSTOM };
230
231/// Cold junction compensation
233 bool enabled = false;
234 double fixed_temp_c = 25.0; // fixed CJC value (if no sensor)
235 uint8_t cjc_channel_id = 0; // or channel that reads CJC sensor
236};
237
238/// NIST ITS-90 Type K coefficients (0–1372 °C range, voltage in mV → temp in °C)
239/// Simplified 9th-order from NIST tables
240inline std::vector<double> nist_type_k_mv_to_c() {
241 // These are the NIST Type K inverse coefficients (mV → °C), 0–20.644 mV range
242 return {-1.318058e+2, 4.830222e+1, -1.646031e+0, 5.464731e-2, -9.650715e-4,
243 8.802193e-6, -3.110810e-8, 0.0, 0.0};
244}
245
246} // namespace tc
247
248} // namespace calibration
249} // namespace fsw
250
251#endif // SENSOR_CALIBRATION_HPP
double calculate(uint8_t channel_id, int32_t raw_counts) const
const PolynomialCalibration * get(uint8_t channel_id) const
bool load_calibration(const std::string &json_dir, const std::string &csv_path)
Auto-discover & load from default directories.
void set_calibration(uint8_t channel_id, const PolynomialCalibration &cal)
Manually set coefficients for a channel.
bool load_from_csv(const std::string &csv_path)
Load calibration from a CSV file (DiabloAvionics format).
std::string find_latest_json(const std::string &dir) const
bool is_calibrated(uint8_t channel_id) const
SensorCalibrationManager(const std::string &sensor_type, const std::string &default_unit="", int default_order=3)
bool load_from_json(const std::string &json_path)
Load calibration from a JSON calibration file.
void clear_calibration(uint8_t channel_id)
Remove a channel's calibration so is_calibrated() returns false again.
std::map< uint8_t, PolynomialCalibration > calibrations_
json_pointer result
Definition json.hpp:14064
double counts_to_resistance(int32_t counts, double gain, double offset)
Convert ADC counts to resistance (assuming linear relationship).
double resistance_to_temp_cvd(double resistance_ohms, const CVDCoeffs &cvd=CVDCoeffs{})
Convert resistance (Ω) to temperature (°C) using iterative CVD inverse.
double temp_to_resistance_cvd(double t_c, const CVDCoeffs &cvd=CVDCoeffs{})
Resistance (Ω) at temperature (°C), IEC 60751 Callendar-Van Dusen, the C term below 0 °C.
double rtd_board_adc_to_temp_c(int32_t adc_raw, double adc_ref_voltage, double excitation_ua, double r0_ohms)
An ESP32 RTD board's reading -> °C: the ADC code (2^31 = adc_ref_voltage) is the voltage across the e...
double resistance_to_temp_cvd_full(double resistance_ohms, const CVDCoeffs &cvd=CVDCoeffs{})
The full CVD inverse: exact above 0 °C (the quadratic), Newton on the C term below it.
std::vector< double > nist_type_k_mv_to_c()
NIST ITS-90 Type K coefficients (0–1372 °C range, voltage in mV → temp in °C) Simplified 9th-order fr...
Output smoothing for calibrated PT pressure, per sensor uid (ESP32 and LabJack T7 alike).
Motorized ball valve position: raw ADC counts -> % open.
STL namespace.
double evaluate(double x) const
Evaluate the polynomial at x (Horner's method — numerically stable).
double evaluate(int32_t adc_code) const
Convenience for int32 ADC codes.
PolynomialCalibration(double A, double B, double C, double D, const std::string &u="PSI")
PolynomialCalibration(std::vector< double > c, const std::string &u="")
static boot_report_t r