Skip to main content

exporter/
calibration.rs

1//! Calibration formulas stored with archived channel data.
2//!
3//! Calibrations come from the `calibrations` map in the dashboard sensor settings.
4//! The archiver writes the channel's [`CalibrationSpec`] as JSON into each Parquet
5//! file's key-value metadata under the key `calibration`. The exporter reads that
6//! metadata back and applies the formula while generating CSV rows, so an export
7//! uses the calibration that was in force when the file was written.
8//!
9//! The JSON shape is the formula's fields plus an optional `id` and an optional
10//! `unit`, tagged by `type`:
11//!
12//! ```text
13//! {"type":"identity"}
14//! {"type":"linear","a":70.1,"b":-8.1,"unit":"kPa"}
15//! {"id":"tp3505","type":"linear","a":2.0,"b":-1.0}
16//! {"type":"polynomial","coeffs":[0.1,2.0,0.03],"unit":"µε"}
17//! ```
18
19use serde::{Deserialize, Serialize};
20
21#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
22#[serde(tag = "type", rename_all = "snake_case")]
23/// Formula used to convert a raw LabJack reading into a calibrated value.
24///
25/// Serialized with an internal `type` tag in snake_case (`identity`, `linear`,
26/// `polynomial`).
27pub enum CalibrationFormula {
28    /// Leaves the raw value unchanged.
29    Identity,
30    /// Applies `a * raw + b`, where `a` is the slope and `b` the offset.
31    Linear { a: f64, b: f64 },
32    /// Applies the polynomial `sum(coeffs[i] * raw.powi(i))`.
33    ///
34    /// `coeffs` is in ascending power order, so `coeffs[0]` is the constant term.
35    /// An empty list evaluates to `0.0`.
36    Polynomial { coeffs: Vec<f64> },
37}
38
39// Only the exporter labels CSV rows, so other binaries never call this.
40#[allow(dead_code)]
41impl CalibrationFormula {
42    /// Returns the formula's serialized `type` tag.
43    pub fn type_name(&self) -> &'static str {
44        match self {
45            Self::Identity => "identity",
46            Self::Linear { .. } => "linear",
47            Self::Polynomial { .. } => "polynomial",
48        }
49    }
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
53/// Calibration definition for one channel.
54///
55/// Deserialized from the dashboard sensor settings by the archiver and from Parquet
56/// metadata by the exporter. The default is an unnamed identity calibration.
57pub struct CalibrationSpec {
58    /// Optional identifier, kept for older configs and files. The exporter writes
59    /// it in the `calibration_id` CSV column, or the formula's type when it is
60    /// `None` (see [`Self::id_or_default`]).
61    #[serde(default)]
62    pub id: Option<String>,
63    /// Optional engineering unit of the calibrated value, such as `kPa`. Carried
64    /// into Parquet metadata; omitted from the JSON when `None`.
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub unit: Option<String>,
67    /// Formula; its `type` tag and fields sit at the top level of the JSON object
68    /// next to `id` (serde `flatten`).
69    #[serde(flatten)]
70    pub formula: CalibrationFormula,
71}
72
73// The archiver compiles this module too but only stores specs, so it never calls
74// these methods.
75#[allow(dead_code)]
76impl CalibrationSpec {
77    /// Applies the calibration formula to one raw sample value.
78    ///
79    /// NaN and infinite inputs propagate through the arithmetic unchanged.
80    ///
81    /// # Arguments
82    ///
83    /// * `raw` - Raw sample value as stored in the archive.
84    ///
85    /// # Returns
86    ///
87    /// The calibrated value.
88    ///
89    /// # Examples
90    ///
91    /// ```text
92    /// Linear { a: 2.0, b: -1.0 }             applied to 3.0 == 5.0
93    /// Polynomial { coeffs: [1.0, 0.0, 2.0] } applied to 3.0 == 1 + 2 * 9 == 19.0
94    /// ```
95    pub fn apply(&self, raw: f64) -> f64 {
96        match &self.formula {
97            CalibrationFormula::Identity => raw,
98            CalibrationFormula::Linear { a, b } => a * raw + b,
99            CalibrationFormula::Polynomial { coeffs } => coeffs
100                .iter()
101                .enumerate()
102                .map(|(idx, coeff)| coeff * raw.powi(idx as i32))
103                .sum(),
104        }
105    }
106
107    /// Returns the configured calibration id, or the formula's type when unnamed.
108    ///
109    /// The fallback is the formula's `type` tag (`identity`, `linear` or
110    /// `polynomial`), so an unnamed linear calibration is not labelled identity.
111    pub fn id_or_default(&self) -> &str {
112        self.id
113            .as_deref()
114            .unwrap_or_else(|| self.formula.type_name())
115    }
116}
117
118impl Default for CalibrationSpec {
119    /// Creates an unnamed identity calibration.
120    fn default() -> Self {
121        Self {
122            id: None,
123            unit: None,
124            formula: CalibrationFormula::Identity,
125        }
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    /// A unit survives a JSON round trip and is left out when unset.
134    #[test]
135    fn unit_round_trips_and_is_optional() {
136        let spec: CalibrationSpec =
137            serde_json::from_str(r#"{"type":"linear","a":70.1,"b":-8.1,"unit":"kPa"}"#).unwrap();
138        assert_eq!(spec.id, None);
139        assert_eq!(spec.unit.as_deref(), Some("kPa"));
140        assert_eq!(
141            spec.formula,
142            CalibrationFormula::Linear { a: 70.1, b: -8.1 }
143        );
144        let json = serde_json::to_string(&spec).unwrap();
145        assert_eq!(
146            serde_json::from_str::<CalibrationSpec>(&json).unwrap(),
147            spec
148        );
149
150        let old: CalibrationSpec =
151            serde_json::from_str(r#"{"id":"tp3505","type":"linear","a":2.0,"b":-1.0}"#).unwrap();
152        assert_eq!(old.id.as_deref(), Some("tp3505"));
153        assert_eq!(old.unit, None);
154        assert_eq!(
155            serde_json::to_string(&CalibrationSpec::default()).unwrap(),
156            r#"{"id":null,"type":"identity"}"#
157        );
158    }
159
160    /// The CSV label is the id when set and the formula type otherwise.
161    #[test]
162    fn id_or_default_falls_back_to_the_formula_type() {
163        let label = |json: &str| {
164            serde_json::from_str::<CalibrationSpec>(json)
165                .unwrap()
166                .id_or_default()
167                .to_string()
168        };
169        assert_eq!(label(r#"{"id":null,"type":"identity"}"#), "identity");
170        assert_eq!(label(r#"{"type":"linear","a":1.0,"b":0.0}"#), "linear");
171        assert_eq!(
172            label(r#"{"type":"polynomial","coeffs":[1.0]}"#),
173            "polynomial"
174        );
175        assert_eq!(
176            label(r#"{"id":"tp3586","type":"linear","a":1.0,"b":0.0,"unit":"kPa"}"#),
177            "tp3586"
178        );
179    }
180}