Skip to main content

vle_units/
registry.rs

1//! Runtime extensible unit registry.
2//!
3//! Each unit is stored with:
4//! - a [`DimensionVector`] (7 SI base-dimension exponents),
5//! - a `scale` factor (canonical units per 1 of this unit),
6//! - either a constant `offset` or a marker that the offset should be
7//!   resolved from the registry's atmospheric pressure (gauge units).
8//!
9//! Canonical units per dimension (kept aligned with the typed `vle_units`
10//! aliases and the legacy VB6/Pascal codebases):
11//!
12//! | Dimension | Canonical |
13//! |-----------|-----------|
14//! | Temperature (absolute) | K |
15//! | TemperatureDiff (Δ) | K |
16//! | Pressure | kPa |
17//! | MolarEnergy | kJ/kmol |
18//! | MolarEntropy | kJ/(kmol·K) |
19//! | MolarVolume | cm³/mol |
20//! | Amount | kmol |
21
22use std::collections::HashMap;
23
24use thiserror::Error;
25
26use crate::P_ATM_STANDARD_KPA;
27
28/// Errors returned by registry operations.
29#[derive(Debug, Error)]
30pub enum RegistryError {
31    #[error("unknown unit: {0}")]
32    UnknownUnit(String),
33    #[error("unknown dimension: {0}")]
34    UnknownDimension(String),
35    #[error("unit `{0}` already defined (pass overwrite=true to replace)")]
36    AlreadyDefined(String),
37    #[error("dimension `{0}` already defined")]
38    DimensionAlreadyDefined(String),
39    #[error("dimension mismatch: expected {expected:?}, found {found:?}")]
40    DimensionMismatch {
41        expected: DimensionVector,
42        found: DimensionVector,
43    },
44    #[error("non-positive absolute pressure ({0} kPa) — gauge value is below vacuum")]
45    NonPositivePressure(f64),
46    #[error("invalid unit string: {0}")]
47    ParseError(String),
48    #[error("atmospheric pressure must be > 0; got {0} kPa")]
49    BadAtmosphericPressure(f64),
50}
51
52/// 7-tuple of SI base-dimension exponents `(L, M, T, I, Θ, N, J)`.
53///
54/// See `docs/en/units/dimensional-analysis.md` §2.1.
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
56pub struct DimensionVector(pub [i8; 7]);
57
58impl DimensionVector {
59    pub const fn new(exps: [i8; 7]) -> Self {
60        DimensionVector(exps)
61    }
62}
63
64/// Built-in VLE dimensions. Custom dimensions are stored separately in the
65/// registry's [`UnitRegistry::dimensions`] table by name.
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
67pub enum Dimension {
68    Temperature,          // (0,0,0,0,1,0,0) — absolute
69    TemperatureDiff,      // (0,0,0,0,1,0,0) — interval (separate type)
70    Pressure,             // (-1,1,-2,0,0,0,0)
71    MolarEnergy,          // (2,1,-2,0,0,-1,0)
72    MolarEntropy,         // (2,1,-2,0,-1,-1,0)
73    MolarVolume,          // (3,0,0,0,0,-1,0)
74    Amount,               // (0,0,0,0,0,1,0)
75    Custom(&'static str), // not used by built-ins; future hook
76}
77
78impl Dimension {
79    pub fn name(&self) -> &str {
80        match self {
81            Dimension::Temperature => "temperature",
82            Dimension::TemperatureDiff => "temperature_diff",
83            Dimension::Pressure => "pressure",
84            Dimension::MolarEnergy => "molar_energy",
85            Dimension::MolarEntropy => "molar_entropy",
86            Dimension::MolarVolume => "molar_volume",
87            Dimension::Amount => "amount",
88            Dimension::Custom(s) => s,
89        }
90    }
91
92    pub fn vector(&self) -> DimensionVector {
93        match self {
94            Dimension::Temperature | Dimension::TemperatureDiff => {
95                DimensionVector::new([0, 0, 0, 0, 1, 0, 0])
96            }
97            Dimension::Pressure => DimensionVector::new([-1, 1, -2, 0, 0, 0, 0]),
98            Dimension::MolarEnergy => DimensionVector::new([2, 1, -2, 0, 0, -1, 0]),
99            Dimension::MolarEntropy => DimensionVector::new([2, 1, -2, 0, -1, -1, 0]),
100            Dimension::MolarVolume => DimensionVector::new([3, 0, 0, 0, 0, -1, 0]),
101            Dimension::Amount => DimensionVector::new([0, 0, 0, 0, 0, 1, 0]),
102            Dimension::Custom(_) => DimensionVector::new([0; 7]),
103        }
104    }
105
106    /// Lookup by string name. Returns `None` for unknown names — callers should
107    /// also check the custom dimensions table on the registry.
108    pub fn from_name(name: &str) -> Option<Dimension> {
109        Some(match name {
110            "temperature" => Dimension::Temperature,
111            "temperature_diff" => Dimension::TemperatureDiff,
112            "pressure" => Dimension::Pressure,
113            "molar_energy" => Dimension::MolarEnergy,
114            "molar_entropy" => Dimension::MolarEntropy,
115            "molar_volume" => Dimension::MolarVolume,
116            "amount" => Dimension::Amount,
117            _ => return None,
118        })
119    }
120}
121
122/// Source of the affine offset in the conversion `canonical = value*scale + offset`.
123#[derive(Debug, Clone, Copy)]
124enum OffsetSource {
125    /// Constant offset baked into the unit definition (e.g. 273.15 for °C).
126    Constant(f64),
127    /// Resolve from `registry.atmospheric_pressure_kpa()` at conversion time
128    /// (gauge pressure units: barg, psig, kPag, …).
129    GaugePAtm,
130}
131
132/// A single registered unit.
133#[derive(Debug, Clone)]
134pub struct UnitDef {
135    pub name: String,
136    pub dimension_name: String,
137    pub dimension_vector: DimensionVector,
138    /// Canonical units per 1 of this unit (e.g. for °C → K, scale=1).
139    pub scale: f64,
140    offset: OffsetSource,
141}
142
143impl UnitDef {
144    pub fn is_gauge(&self) -> bool {
145        matches!(self.offset, OffsetSource::GaugePAtm)
146    }
147
148    pub fn constant_offset(&self) -> Option<f64> {
149        match self.offset {
150            OffsetSource::Constant(o) => Some(o),
151            OffsetSource::GaugePAtm => None,
152        }
153    }
154}
155
156/// Runtime, extensible unit registry. Created via [`UnitRegistry::with_vle_defaults`].
157#[derive(Debug, Clone)]
158pub struct UnitRegistry {
159    units: HashMap<String, UnitDef>,
160    /// Custom dimensions registered via [`define_dimension`](Self::define_dimension).
161    /// Built-in dimensions are recognized via [`Dimension::from_name`].
162    dimensions: HashMap<String, DimensionVector>,
163    p_atm_kpa: f64,
164}
165
166impl UnitRegistry {
167    /// Create an empty registry (no units pre-registered). Mostly for testing —
168    /// most callers want [`with_vle_defaults`](Self::with_vle_defaults).
169    pub fn new() -> Self {
170        UnitRegistry {
171            units: HashMap::new(),
172            dimensions: HashMap::new(),
173            p_atm_kpa: P_ATM_STANDARD_KPA,
174        }
175    }
176
177    /// Create a registry pre-populated with VLE-relevant units (temperature,
178    /// pressure including gauge units, molar energy, etc.).
179    pub fn with_vle_defaults() -> Self {
180        let mut r = Self::new();
181        r.install_defaults();
182        r
183    }
184
185    fn install_defaults(&mut self) {
186        // The default unit catalog lives in `data/defaults.toml` and is baked
187        // into the binary via `include_str!` — no runtime file I/O, no missing-
188        // file failure mode. To add or tweak a built-in unit, edit that file.
189        const DEFAULTS_TOML: &str = include_str!("data/defaults.toml");
190        self.load_from_toml_str(DEFAULTS_TOML)
191            .expect("built-in defaults.toml must parse and apply cleanly");
192    }
193
194    fn put(&mut self, u: UnitDef) {
195        self.units.insert(u.name.clone(), u);
196    }
197
198    // ── Atmospheric pressure (configurable, never hardcoded) ────────────────
199
200    /// Current atmospheric pressure used for gauge ↔ absolute conversions.
201    ///
202    /// # Returns
203    /// Atmospheric pressure in **kPa**.
204    pub fn atmospheric_pressure_kpa(&self) -> f64 {
205        self.p_atm_kpa
206    }
207
208    /// Override atmospheric pressure for all subsequent gauge conversions.
209    ///
210    /// Default is 101.325 kPa (1 standard atm). Override for non-standard
211    /// altitude or weather (e.g. 84.5 kPa at ~1500 m elevation).
212    ///
213    /// # Arguments
214    /// * `p_atm_kpa` — Local atmospheric pressure in **kPa** (must be > 0)
215    pub fn set_atmospheric_pressure(&mut self, p_atm_kpa: f64) -> Result<(), RegistryError> {
216        // Use `is_finite() && > 0.0` so NaN and ±inf are also rejected. (The
217        // legacy `!(x > 0.0)` form trips clippy::neg_cmp_op_on_partial_ord.)
218        if !(p_atm_kpa.is_finite() && p_atm_kpa > 0.0) {
219            return Err(RegistryError::BadAtmosphericPressure(p_atm_kpa));
220        }
221        self.p_atm_kpa = p_atm_kpa;
222        Ok(())
223    }
224
225    // ── User extensions ─────────────────────────────────────────────────────
226
227    /// Define a new unit with a constant scale (and optional constant offset).
228    ///
229    /// # Arguments
230    /// * `name` — Unit symbol (no spaces, must be valid identifier-ish)
231    /// * `dimension` — One of the built-in dimensions
232    /// * `scale` — Canonical units per 1 of this unit
233    /// * `offset` — Constant affine offset (0.0 for pure scale)
234    pub fn define(
235        &mut self,
236        name: &str,
237        dimension: Dimension,
238        scale: f64,
239        offset: f64,
240    ) -> Result<(), RegistryError> {
241        self.define_with_dimension_name(name, dimension.name(), dimension.vector(), scale, offset)
242    }
243
244    /// Define a gauge unit whose offset is resolved from the registry's
245    /// atmospheric pressure at conversion time. The unit must be a pressure unit.
246    pub fn define_gauge(
247        &mut self,
248        name: &str,
249        scale_kpa_per_unit: f64,
250    ) -> Result<(), RegistryError> {
251        if self.units.contains_key(name) {
252            return Err(RegistryError::AlreadyDefined(name.into()));
253        }
254        self.put(UnitDef {
255            name: name.into(),
256            dimension_name: "pressure".into(),
257            dimension_vector: Dimension::Pressure.vector(),
258            scale: scale_kpa_per_unit,
259            offset: OffsetSource::GaugePAtm,
260        });
261        Ok(())
262    }
263
264    /// Register a brand-new derived dimension (not in the built-in list).
265    pub fn define_dimension(
266        &mut self,
267        name: &str,
268        vector: DimensionVector,
269    ) -> Result<(), RegistryError> {
270        if Dimension::from_name(name).is_some() || self.dimensions.contains_key(name) {
271            return Err(RegistryError::DimensionAlreadyDefined(name.into()));
272        }
273        self.dimensions.insert(name.into(), vector);
274        Ok(())
275    }
276
277    /// Define a unit attached to a previously-registered custom dimension.
278    pub fn define_with_dimension(
279        &mut self,
280        name: &str,
281        dimension_name: &str,
282        scale: f64,
283        offset: f64,
284    ) -> Result<(), RegistryError> {
285        let vector = self
286            .lookup_dimension(dimension_name)
287            .ok_or_else(|| RegistryError::UnknownDimension(dimension_name.into()))?;
288        self.define_with_dimension_name(name, dimension_name, vector, scale, offset)
289    }
290
291    fn define_with_dimension_name(
292        &mut self,
293        name: &str,
294        dim_name: &str,
295        vector: DimensionVector,
296        scale: f64,
297        offset: f64,
298    ) -> Result<(), RegistryError> {
299        if self.units.contains_key(name) {
300            return Err(RegistryError::AlreadyDefined(name.into()));
301        }
302        self.put(UnitDef {
303            name: name.into(),
304            dimension_name: dim_name.into(),
305            dimension_vector: vector,
306            scale,
307            offset: OffsetSource::Constant(offset),
308        });
309        Ok(())
310    }
311
312    fn lookup_dimension(&self, name: &str) -> Option<DimensionVector> {
313        Dimension::from_name(name)
314            .map(|d| d.vector())
315            .or_else(|| self.dimensions.get(name).copied())
316    }
317
318    // ── Lookups ─────────────────────────────────────────────────────────────
319
320    pub fn get(&self, name: &str) -> Result<&UnitDef, RegistryError> {
321        self.units
322            .get(name)
323            .ok_or_else(|| RegistryError::UnknownUnit(name.into()))
324    }
325
326    /// Iterate all registered unit names. Order is unspecified.
327    pub fn unit_names(&self) -> impl Iterator<Item = &str> {
328        self.units.keys().map(|s| s.as_str())
329    }
330
331    // ── Conversion ──────────────────────────────────────────────────────────
332
333    fn offset_value(&self, u: &UnitDef) -> f64 {
334        match u.offset {
335            OffsetSource::Constant(o) => o,
336            OffsetSource::GaugePAtm => self.p_atm_kpa,
337        }
338    }
339
340    /// Convert `value` (in `unit_name`) into canonical units for that unit's
341    /// dimension. For gauge pressure units, the result is **absolute kPa**.
342    pub fn to_canonical(&self, value: f64, unit_name: &str) -> Result<f64, RegistryError> {
343        let u = self.get(unit_name)?;
344        let result = value * u.scale + self.offset_value(u);
345        // Reject non-physical absolute pressures from gauge inputs
346        if u.is_gauge() && result <= 0.0 {
347            return Err(RegistryError::NonPositivePressure(result));
348        }
349        Ok(result)
350    }
351
352    /// Inverse of [`to_canonical`](Self::to_canonical): convert a canonical
353    /// value into `unit_name`'s scale.
354    pub fn from_canonical(
355        &self,
356        value_canonical: f64,
357        unit_name: &str,
358    ) -> Result<f64, RegistryError> {
359        let u = self.get(unit_name)?;
360        Ok((value_canonical - self.offset_value(u)) / u.scale)
361    }
362
363    /// Parse `"<value> <unit>"` and convert to canonical units.
364    /// See [`crate::parser::split_value_unit`].
365    pub fn parse(&self, s: &str) -> Result<Quantity, RegistryError> {
366        let (value, unit) = crate::parser::split_value_unit(s)?;
367        let canonical = self.to_canonical(value, unit)?;
368        let u = self.get(unit)?;
369        Ok(Quantity {
370            canonical,
371            dimension: u.dimension_name.clone(),
372        })
373    }
374
375    /// Format a canonical value as `"<value> <unit>"` (no rounding).
376    pub fn format(&self, q: &Quantity, unit_name: &str) -> Result<String, RegistryError> {
377        let u = self.get(unit_name)?;
378        if u.dimension_name != q.dimension {
379            return Err(RegistryError::DimensionMismatch {
380                expected: self
381                    .lookup_dimension(&q.dimension)
382                    .unwrap_or(DimensionVector::new([0; 7])),
383                found: u.dimension_vector,
384            });
385        }
386        let v = self.from_canonical(q.canonical, unit_name)?;
387        Ok(format!("{v} {unit_name}"))
388    }
389}
390
391impl Default for UnitRegistry {
392    fn default() -> Self {
393        Self::with_vle_defaults()
394    }
395}
396
397/// A dimensioned value carried across the FFI boundary in canonical units.
398#[derive(Debug, Clone, PartialEq)]
399pub struct Quantity {
400    pub canonical: f64,
401    pub dimension: String,
402}
403
404impl Quantity {
405    pub fn value_kpa(&self) -> f64 {
406        debug_assert_eq!(self.dimension, "pressure");
407        self.canonical
408    }
409    pub fn value_kelvin(&self) -> f64 {
410        debug_assert_eq!(self.dimension, "temperature");
411        self.canonical
412    }
413}