Skip to main content

qualia_core_db/solvers/units/
conversion.rs

1//! Units and conversion. A [`Unit`] maps its own scale to coherent SI by an affine
2//! transform `si = value·factor + offset` (the offset is only non-zero for the
3//! temperature scales — Celsius, Fahrenheit). Conversion between two units requires
4//! matching dimensions and fails closed otherwise.
5
6use super::dimension::Dimension;
7use super::quantity::Quantity;
8use super::UnitsError;
9
10/// A named unit of a given dimension, defined by its affine map to coherent SI base
11/// units: `si_value = value * to_si_factor + to_si_offset`.
12#[derive(Debug, Clone, Copy, PartialEq)]
13pub struct Unit {
14    pub name: &'static str,
15    pub dimension: Dimension,
16    pub to_si_factor: f64,
17    pub to_si_offset: f64,
18}
19
20impl Unit {
21    pub const fn linear(name: &'static str, dimension: Dimension, factor: f64) -> Self {
22        Self {
23            name,
24            dimension,
25            to_si_factor: factor,
26            to_si_offset: 0.0,
27        }
28    }
29    pub const fn affine(
30        name: &'static str,
31        dimension: Dimension,
32        factor: f64,
33        offset: f64,
34    ) -> Self {
35        Self {
36            name,
37            dimension,
38            to_si_factor: factor,
39            to_si_offset: offset,
40        }
41    }
42
43    /// Convert a magnitude in this unit to coherent SI.
44    pub fn to_si(&self, value: f64) -> f64 {
45        value * self.to_si_factor + self.to_si_offset
46    }
47    /// Convert a magnitude in coherent SI back to this unit.
48    pub fn from_si(&self, si: f64) -> f64 {
49        (si - self.to_si_offset) / self.to_si_factor
50    }
51
52    /// A magnitude in this unit as a dimensioned [`Quantity`] (in SI).
53    pub fn quantity(&self, value: f64) -> Quantity {
54        Quantity::new(self.to_si(value), self.dimension)
55    }
56
57    // ── Length ──
58    pub const METRE: Unit = Unit::linear("m", Dimension::LENGTH, 1.0);
59    pub const KILOMETRE: Unit = Unit::linear("km", Dimension::LENGTH, 1000.0);
60    pub const CENTIMETRE: Unit = Unit::linear("cm", Dimension::LENGTH, 0.01);
61    pub const MILLIMETRE: Unit = Unit::linear("mm", Dimension::LENGTH, 0.001);
62    pub const INCH: Unit = Unit::linear("in", Dimension::LENGTH, 0.0254);
63    pub const FOOT: Unit = Unit::linear("ft", Dimension::LENGTH, 0.3048);
64    pub const MILE: Unit = Unit::linear("mi", Dimension::LENGTH, 1609.344);
65    // ── Mass ──
66    pub const KILOGRAM: Unit = Unit::linear("kg", Dimension::MASS, 1.0);
67    pub const GRAM: Unit = Unit::linear("g", Dimension::MASS, 0.001);
68    pub const POUND: Unit = Unit::linear("lb", Dimension::MASS, 0.45359237);
69    // ── Time ──
70    pub const SECOND: Unit = Unit::linear("s", Dimension::TIME, 1.0);
71    pub const MINUTE: Unit = Unit::linear("min", Dimension::TIME, 60.0);
72    pub const HOUR: Unit = Unit::linear("h", Dimension::TIME, 3600.0);
73    // ── Force / energy / pressure ──
74    pub const NEWTON: Unit = Unit::linear("N", Dimension::FORCE, 1.0);
75    pub const JOULE: Unit = Unit::linear("J", Dimension::ENERGY, 1.0);
76    pub const KILOWATT_HOUR: Unit = Unit::linear("kWh", Dimension::ENERGY, 3.6e6);
77    pub const PASCAL: Unit = Unit::linear("Pa", Dimension::PRESSURE, 1.0);
78    pub const BAR: Unit = Unit::linear("bar", Dimension::PRESSURE, 1.0e5);
79    // ── Temperature (affine) ──
80    pub const KELVIN: Unit = Unit::linear("K", Dimension::TEMPERATURE, 1.0);
81    pub const CELSIUS: Unit = Unit::affine("°C", Dimension::TEMPERATURE, 1.0, 273.15);
82    pub const FAHRENHEIT: Unit = Unit::affine(
83        "°F",
84        Dimension::TEMPERATURE,
85        5.0 / 9.0,
86        255.372_222_222_222_2,
87    );
88}
89
90/// Convert `value` from one unit to another. Fails closed if the units have different
91/// dimensions (e.g. metres → seconds).
92pub fn convert(value: f64, from: &Unit, to: &Unit) -> Result<f64, UnitsError> {
93    if from.dimension != to.dimension {
94        return Err(UnitsError::IncompatibleDimensions);
95    }
96    Ok(to.from_si(from.to_si(value)))
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102    const EPS: f64 = 1e-6;
103
104    #[test]
105    fn length_conversions() {
106        assert!((convert(1.0, &Unit::INCH, &Unit::CENTIMETRE).unwrap() - 2.54).abs() < EPS);
107        assert!((convert(1.0, &Unit::MILE, &Unit::KILOMETRE).unwrap() - 1.609344).abs() < EPS);
108        assert!((convert(3.0, &Unit::FOOT, &Unit::METRE).unwrap() - 0.9144).abs() < EPS);
109    }
110
111    #[test]
112    fn temperature_is_affine() {
113        // 0 °C = 273.15 K
114        assert!((convert(0.0, &Unit::CELSIUS, &Unit::KELVIN).unwrap() - 273.15).abs() < EPS);
115        // 100 °C = 212 °F
116        assert!((convert(100.0, &Unit::CELSIUS, &Unit::FAHRENHEIT).unwrap() - 212.0).abs() < 1e-3);
117        // 32 °F = 0 °C
118        assert!((convert(32.0, &Unit::FAHRENHEIT, &Unit::CELSIUS).unwrap()).abs() < 1e-3);
119        // −40 °C = −40 °F (the classic crossover)
120        assert!((convert(-40.0, &Unit::CELSIUS, &Unit::FAHRENHEIT).unwrap() + 40.0).abs() < 1e-3);
121    }
122
123    #[test]
124    fn energy_conversion() {
125        // 1 kWh = 3.6 MJ
126        assert!((convert(1.0, &Unit::KILOWATT_HOUR, &Unit::JOULE).unwrap() - 3.6e6).abs() < 1.0);
127    }
128
129    #[test]
130    fn cross_dimension_conversion_fails_closed() {
131        assert_eq!(
132            convert(1.0, &Unit::METRE, &Unit::SECOND).unwrap_err(),
133            UnitsError::IncompatibleDimensions
134        );
135    }
136}