3Robust Calibration Framework for Pressure Transducers
4Based on the mathematical framework from PressureTransducerCalibrationFramework.tex
7- Bayesian regression with hierarchical priors
8- Total Least Squares (TLS) for robust calibration
9- Recursive Least Squares (RLS) with forgetting
10- Generalized Likelihood Ratio (GLR) testing for drift detection
11- Environmental-robust calibration maps
12- Extended Kalman Filter integration for bias correction
17import scipy.linalg
as la
18import scipy.stats
as stats
19from typing
import Dict, List, Tuple, Optional, TYPE_CHECKING
22 from allan_variance
import NoiseCoefficients
23from dataclasses
import dataclass
24from collections
import deque
27logger = logging.getLogger(__name__)
32 """Environmental state vector: [T, H, V, A, M]"""
34 temperature: float = 25.0
35 humidity: float = 50.0
36 vibration: float = 0.0
37 aging_factor: float = 1.0
38 mounting_torque: float = 1.0
43 Paper Section 4: Stochastic bias db/dt = -b/τ_b + w_b.
44 3-state bias: τ_fast=1s, τ_medium=100s, τ_slow=10⁴s.
45 Discrete: b_{k+1} = Φ_b b_k + w_b.
50 tau_fast: float = 1.0,
51 tau_medium: float = 100.0,
52 tau_slow: float = 1e4,
53 process_noise: float = 1e-4,
55 self.
tau = np.array([tau_fast, tau_medium, tau_slow])
57 self.
P_b = np.eye(3) * 0.01
63 """b_{k+1} = Φ_b b_k, P_{k+1} = Φ_b P Φ_b' + Q."""
64 phi_b = np.diag(np.exp(-dt / self.
tau))
67 self.
b = phi_b @ self.
b
68 self.
P_b = phi_b @ self.
P_b @ phi_b.T + Q
70 def update(self, residual: float, sigma_meas: float, dt: float) ->
None:
71 """Innovation update: residual = y_obs - (φ'θ + h'b)."""
73 S = float(self.
h @ self.
P_b @ self.
h + sigma_meas**2)
74 K = self.
P_b @ self.
h /
max(S, 1e-12)
75 self.
b = self.
b + K * residual
76 self.
P_b = (np.eye(3) - np.outer(K, self.
h)) @ self.
P_b
79 """h'b for pressure prediction."""
80 return float(self.
h @ self.
b)
83 """h' Σ_b h for uncertainty."""
84 return float(self.
h @ self.
P_b @ self.
h)
89 """Single calibration data point"""
94 environmental_state: EnvironmentalState
95 uncertainty: float = 0.01
100 Mathematically robust calibration framework implementing the paper's algorithms
112 [0.0, 200.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]
134 "env": np.eye(5) * 0.0001,
135 "interaction": np.eye(5) * 0.00001,
171 poly_coeffs: List[float],
173 adc_norm_scale: float,
176 Initialize theta from polynomial calibration P = c0 + c1*x + c2*x^2 + c3*x^3
177 where x = (adc - adc_norm_min) / adc_norm_scale.
178 Gives robust calibration the correct slope so ZERO ALL + CAPTURE match polynomial accuracy.
180 if len(poly_coeffs) < 2
or adc_norm_scale <= 0:
182 c0, c1 = float(poly_coeffs[0]), float(poly_coeffs[1])
185 theta1 = c1 * 1e9 / adc_norm_scale
187 theta0 = c0 - theta1 * adc_norm_min / 1e9
188 self.
theta_mean = np.array([theta0, theta1, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0])
191 f
"Sensor {self.sensor_id}: prior from polynomial θ₀={theta0:.2f} θ₁={theta1:.0f}"
195 self, adc_code: float, env: EnvironmentalState
198 Environmental-robust basis functions from equation (66-72)
199 Works directly on ADC codes (not voltage).
210 adc_norm = adc_code / 1e9
211 v =
max(adc_norm, 1e-6)
233 phi[6] = adc_norm * T * H
234 phi[7] = adc_norm**2 * V * M
235 phi[8] = A * adc_norm**3
240 self, points: List[CalibrationPoint]
241 ) -> Tuple[np.ndarray, np.ndarray]:
243 Total Least Squares calibration accounting for errors in both voltage and pressure
250 Phi = np.zeros((n, 9))
252 weights = np.zeros(n)
254 for i, point
in enumerate(points):
256 point.adc_code, point.environmental_state
258 p_obs[i] = point.pressure
265 cov_inv = Phi_T_W @ Phi + np.eye(9) * 1e-6
266 theta_mean = la.solve(cov_inv, Phi_T_W @ p_obs)
267 theta_cov = la.inv(cov_inv)
268 except la.LinAlgError:
270 f
"TLS calibration failed for sensor {self.sensor_id}, using previous parameters"
274 return theta_mean, theta_cov
277 self, new_points: List[CalibrationPoint]
278 ) -> Tuple[np.ndarray, np.ndarray]:
280 Bayesian update with hierarchical priors
294 prior_precision = la.inv(self.
theta_cov + np.eye(9) * 1e-6)
295 likelihood_precision = la.inv(theta_cov_tls + np.eye(9) * 1e-6)
296 posterior_precision = prior_precision + likelihood_precision
299 posterior_cov = la.inv(posterior_precision)
300 posterior_mean = posterior_cov @ (
301 prior_precision @ self.
theta_mean + likelihood_precision @ theta_mean_tls
304 return posterior_mean, posterior_cov
307 self, point: CalibrationPoint
308 ) -> Tuple[np.ndarray, np.ndarray]:
310 Recursive Least Squares with forgetting factor
314 point.adc_code, point.environmental_state
318 Pphi = self.
rls_P @ phi
321 prediction_error = point.pressure - phi @ self.
theta_mean
328 forgetting_cov = np.eye(9) * 0.001
334 self, new_points: List[CalibrationPoint]
335 ) -> Tuple[float, bool]:
337 GLR test for drift detection
347 log_likelihood_current = 0.0
348 for point
in recent_points:
350 point.adc_code, point.environmental_state
353 residual = point.pressure - predicted_pressure
355 log_likelihood_current += -0.5 * (
356 residual**2 / variance + np.log(2 * np.pi * variance)
361 log_likelihood_unconstrained = 0.0
362 for point
in recent_points:
364 point.adc_code, point.environmental_state
366 predicted_pressure = phi.T @ theta_tls
367 residual = point.pressure - predicted_pressure
369 log_likelihood_unconstrained += -0.5 * (
370 residual**2 / variance + np.log(2 * np.pi * variance)
374 glr_statistic = 2 * (log_likelihood_unconstrained - log_likelihood_current)
379 return glr_statistic, drift_detected
382 """Paper Section 7: σ²_extrap when adc outside calibrated range."""
386 v_min, v_max = adcs.min(), adcs.max()
387 delta_v =
max(v_max - v_min, 1e6)
391 d = (v_min - adc_code) / delta_v
392 exp_val = np.exp(
min(2 * d, exp_cap))
395 d = (adc_code - v_max) / delta_v
396 exp_val = np.exp(
min(2 * d, exp_cap))
401 """Use Allan variance NoiseCoefficients for σ²_meas (paper Section 3→7)."""
406 """σ²_meas: Allan-derived if available, else env_variance_base + α_v·v²."""
407 adc_norm = adc_code / 1e9
409 from allan_variance
import measurement_uncertainty
411 sigma = measurement_uncertainty(
414 voltage=adc_norm * 2.5,
421 self, adc_code: float, env: EnvironmentalState
422 ) -> Tuple[float, float]:
424 Predict pressure with full uncertainty quantification (paper Section 7).
425 σ²_pred = σ²_meas + φ'Σφ + σ²_extrap + h'Σ_b h + env + interaction
426 Mean: ŷ = φ'θ + h'b (Gauss-Markov bias).
437 predicted_pressure = float(phi.T @ self.
theta_mean)
439 predicted_pressure += self.
bias_model.bias_contribution()
445 parameter_variance = float(phi.T @ self.
theta_cov @ phi)
451 bias_variance = self.
bias_model.bias_variance()
454 adc_norm = adc_code / 1e9
455 env_vector = np.array(
464 env_variance = float(
467 interaction_variance = float(
480 + interaction_variance
484 return predicted_pressure, sigma
488 Add a new calibration point and update the model
496 point.adc_code, point.environmental_state
501 residual = point.pressure - pred_before
504 sigma_meas =
max(point.uncertainty, 1e-4)
511 contrib_rest = float(phi[1:].T @ self.
theta_mean[1:])
516 "drift_detected":
False,
517 "glr_statistic": 0.0,
518 "calibration_points": 1,
529 f
"Sensor {self.sensor_id}: Drift detected (GLR={glr_statistic:.3f}), recalibrating"
541 point.adc_code, point.environmental_state
543 Pphi = self.
rls_P @ phi
552 "drift_detected": drift_detected,
553 "glr_statistic": glr_statistic,
560 Paper Section 8: α = 0.4·α_cal + 0.3·α_unc + 0.2·α_agree + 0.1·α_quality
561 Returns (α, {α_cal, α_unc, α_agree, α_quality})
564 alpha_cal =
min(1.0, n / 5.0)
569 alpha_unc = float(np.exp(-(sigma**2) / (self.
sigma_ref**2 + 1e-12)))
574 n =
min(len(res), len(sigs))
576 sigs = np.where(sigs[:n] > 1e-6, sigs[:n], 1.0)
577 alpha_agree = float(np.mean(np.abs(res[:n]) < 2 * sigs))
584 rmse = summary.get(
"rmse", 10.0)
588 p_range =
max(np.ptp(pressures), 1.0)
589 nrmse = rmse / p_range
590 alpha_quality = float(1.0 -
min(nrmse, 1.0))
593 0.4 * alpha_cal + 0.3 * alpha_unc + 0.2 * alpha_agree + 0.1 * alpha_quality
595 return float(np.clip(alpha, 0, 1)), {
596 "alpha_cal": alpha_cal,
597 "alpha_unc": alpha_unc,
598 "alpha_agree": alpha_agree,
599 "alpha_quality": alpha_quality,
604 Determine confidence level based on calibration quality
619 Get comprehensive calibration summary
624 "status":
"UNCALIBRATED",
625 "confidence_level":
"LOW",
626 "calibration_points": 0,
628 "uncertainty": np.sqrt(np.diag(self.
theta_cov)).tolist(),
635 point.adc_code, point.environmental_state
638 residuals.append(abs(point.pressure - predicted))
640 rmse = np.sqrt(np.mean(np.array(residuals) ** 2))
641 max_residual = np.max(residuals)
645 "status":
"CALIBRATED",
649 "max_residual": max_residual,
651 "uncertainty": np.sqrt(np.diag(self.
theta_cov)).tolist(),
None update(self, float residual, float sigma_meas, float dt)
__init__(self, float tau_fast=1.0, float tau_medium=100.0, float tau_slow=1e4, float process_noise=1e-4)
float bias_variance(self)
None propagate(self, float dt)
float bias_contribution(self)
deque recent_uncertainties
Dict[str, any] get_calibration_summary(self)
Tuple[float, float] predict_pressure_with_uncertainty(self, float adc_code, EnvironmentalState env)
dict env_variance_matrices
str get_confidence_level(self)
float individual_prior_cov
__init__(self, int sensor_id)
None set_theta_from_polynomial(self, List[float] poly_coeffs, float adc_norm_min, float adc_norm_scale)
Tuple[np.ndarray, np.ndarray] bayesian_update(self, List[CalibrationPoint] new_points)
Tuple[float, bool] generalized_likelihood_ratio_test(self, List[CalibrationPoint] new_points)
Tuple[np.ndarray, np.ndarray] recursive_least_squares_update(self, CalibrationPoint point)
Tuple[np.ndarray, np.ndarray] total_least_squares_calibration(self, List[CalibrationPoint] points)
Dict[str, any] add_calibration_point(self, CalibrationPoint point)
float population_prior_cov
None set_noise_coeffs(self, "NoiseCoefficients" coeffs, float tau0=0.01)
tuple calibration_points_max
np.ndarray environmental_robust_basis_functions(self, float adc_code, EnvironmentalState env)
float _extrapolation_variance(self, float adc_code)
float _measurement_variance(self, float adc_code, float tau=0.1)
Tuple[float, Dict[str, float]] get_autonomy_score(self)
TOML_NODISCARD_CTOR time() noexcept=default