archiver/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}