Skip to main content

pnc_engine/
unit.rs

1use crate::error::PncError;
2use crate::value::Value;
3use num_bigint::BigInt;
4use num_rational::BigRational;
5use serde::{Deserialize, Serialize};
6use std::fmt;
7
8/// Physical dimension for unit classification.
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum Dimension {
11    Temperature,
12    Length,
13    Mass,
14    Time,
15}
16
17/// Unit identifier. Known units carry dimension and affine conversion factors;
18/// unknown units are preserved as `Unknown(String)`.
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20pub enum UnitId {
21    // Temperature
22    Celsius,
23    Fahrenheit,
24    Kelvin,
25    // Length
26    Millimeter,
27    Centimeter,
28    Meter,
29    Kilometer,
30    Mile,
31    Foot,
32    Inch,
33    Yard,
34    // Mass
35    Milligram,
36    Gram,
37    Kilogram,
38    Pound,
39    Ounce,
40    // Time
41    Millisecond,
42    Second,
43    Minute,
44    Hour,
45    Day,
46    Week,
47    // Unknown
48    Unknown(String),
49}
50
51impl fmt::Display for UnitId {
52    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
53        write!(f, "{}", self.symbol())
54    }
55}
56
57impl UnitId {
58    pub fn symbol(&self) -> &str {
59        match self {
60            UnitId::Celsius => "c",
61            UnitId::Fahrenheit => "f",
62            UnitId::Kelvin => "k",
63            UnitId::Millimeter => "mm",
64            UnitId::Centimeter => "cm",
65            UnitId::Meter => "m",
66            UnitId::Kilometer => "km",
67            UnitId::Mile => "mi",
68            UnitId::Foot => "ft",
69            UnitId::Inch => "in",
70            UnitId::Yard => "yd",
71            UnitId::Milligram => "mg",
72            UnitId::Gram => "g",
73            UnitId::Kilogram => "kg",
74            UnitId::Pound => "lb",
75            UnitId::Ounce => "oz",
76            UnitId::Millisecond => "ms",
77            UnitId::Second => "s",
78            UnitId::Minute => "min",
79            UnitId::Hour => "h",
80            UnitId::Day => "d",
81            UnitId::Week => "wk",
82            UnitId::Unknown(s) => s,
83        }
84    }
85
86    pub fn from_symbol(s: &str) -> Option<UnitId> {
87        match s {
88            "c" => Some(UnitId::Celsius),
89            "f" => Some(UnitId::Fahrenheit),
90            "k" => Some(UnitId::Kelvin),
91            "mm" => Some(UnitId::Millimeter),
92            "cm" => Some(UnitId::Centimeter),
93            "m" => Some(UnitId::Meter),
94            "km" => Some(UnitId::Kilometer),
95            "mi" => Some(UnitId::Mile),
96            "ft" => Some(UnitId::Foot),
97            "in" => Some(UnitId::Inch),
98            "yd" => Some(UnitId::Yard),
99            "mg" => Some(UnitId::Milligram),
100            "g" => Some(UnitId::Gram),
101            "kg" => Some(UnitId::Kilogram),
102            "lb" => Some(UnitId::Pound),
103            "oz" => Some(UnitId::Ounce),
104            "ms" => Some(UnitId::Millisecond),
105            "s" => Some(UnitId::Second),
106            "min" => Some(UnitId::Minute),
107            "h" => Some(UnitId::Hour),
108            "d" => Some(UnitId::Day),
109            "wk" => Some(UnitId::Week),
110            _ => None,
111        }
112    }
113
114    pub fn dimension(&self) -> Option<Dimension> {
115        match self {
116            UnitId::Celsius | UnitId::Fahrenheit | UnitId::Kelvin => Some(Dimension::Temperature),
117            UnitId::Millimeter
118            | UnitId::Centimeter
119            | UnitId::Meter
120            | UnitId::Kilometer
121            | UnitId::Mile
122            | UnitId::Foot
123            | UnitId::Inch
124            | UnitId::Yard => Some(Dimension::Length),
125            UnitId::Milligram | UnitId::Gram | UnitId::Kilogram | UnitId::Pound | UnitId::Ounce => {
126                Some(Dimension::Mass)
127            }
128            UnitId::Millisecond
129            | UnitId::Second
130            | UnitId::Minute
131            | UnitId::Hour
132            | UnitId::Day
133            | UnitId::Week => Some(Dimension::Time),
134            UnitId::Unknown(_) => None,
135        }
136    }
137
138    pub(crate) fn factor(&self) -> BigRational {
139        match self {
140            UnitId::Celsius => BigRational::from_integer(BigInt::from(1)),
141            UnitId::Fahrenheit => BigRational::new(BigInt::from(5), BigInt::from(9)),
142            UnitId::Kelvin => BigRational::from_integer(BigInt::from(1)),
143            UnitId::Millimeter => BigRational::new(BigInt::from(1), BigInt::from(1000)),
144            UnitId::Centimeter => BigRational::new(BigInt::from(1), BigInt::from(100)),
145            UnitId::Meter => BigRational::from_integer(BigInt::from(1)),
146            UnitId::Kilometer => BigRational::from_integer(BigInt::from(1000)),
147            UnitId::Mile => BigRational::new(BigInt::from(1609344), BigInt::from(1000)),
148            UnitId::Foot => BigRational::new(BigInt::from(3048), BigInt::from(10000)),
149            UnitId::Inch => BigRational::new(BigInt::from(254), BigInt::from(10000)),
150            UnitId::Yard => BigRational::new(BigInt::from(9144), BigInt::from(10000)),
151            UnitId::Milligram => BigRational::new(BigInt::from(1), BigInt::from(1000000)),
152            UnitId::Gram => BigRational::new(BigInt::from(1), BigInt::from(1000)),
153            UnitId::Kilogram => BigRational::from_integer(BigInt::from(1)),
154            UnitId::Pound => BigRational::new(BigInt::from(45359237), BigInt::from(100000000)),
155            UnitId::Ounce => BigRational::new(BigInt::from(45359237), BigInt::from(1600000000)),
156            UnitId::Millisecond => BigRational::new(BigInt::from(1), BigInt::from(1000)),
157            UnitId::Second => BigRational::from_integer(BigInt::from(1)),
158            UnitId::Minute => BigRational::from_integer(BigInt::from(60)),
159            UnitId::Hour => BigRational::from_integer(BigInt::from(3600)),
160            UnitId::Day => BigRational::from_integer(BigInt::from(86400)),
161            UnitId::Week => BigRational::from_integer(BigInt::from(604800)),
162            UnitId::Unknown(_) => BigRational::from_integer(BigInt::from(1)),
163        }
164    }
165
166    pub(crate) fn offset(&self) -> BigRational {
167        match self {
168            UnitId::Celsius => BigRational::new(BigInt::from(27315), BigInt::from(100)),
169            UnitId::Fahrenheit => BigRational::new(BigInt::from(45967), BigInt::from(180)),
170            _ => BigRational::from_integer(BigInt::from(0)),
171        }
172    }
173
174    pub fn units_in_dimension(d: Dimension) -> &'static [UnitId] {
175        match d {
176            Dimension::Temperature => &[UnitId::Celsius, UnitId::Fahrenheit, UnitId::Kelvin],
177            Dimension::Length => &[
178                UnitId::Millimeter,
179                UnitId::Centimeter,
180                UnitId::Meter,
181                UnitId::Kilometer,
182                UnitId::Mile,
183                UnitId::Foot,
184                UnitId::Inch,
185                UnitId::Yard,
186            ],
187            Dimension::Mass => &[
188                UnitId::Milligram,
189                UnitId::Gram,
190                UnitId::Kilogram,
191                UnitId::Pound,
192                UnitId::Ounce,
193            ],
194            Dimension::Time => &[
195                UnitId::Millisecond,
196                UnitId::Second,
197                UnitId::Minute,
198                UnitId::Hour,
199                UnitId::Day,
200                UnitId::Week,
201            ],
202        }
203    }
204}
205
206/// Convert a value to the target unit.
207///
208/// Unitless numbers attach the target unit without changing the value.
209/// Unit-bearing values are converted via affine transform; cross-dimension
210/// conversions return `InvalidUnitConversion`.
211pub fn convert(value: &Value, target: UnitId) -> Result<Value, PncError> {
212    // Unitless number: attach target unit.
213    match value {
214        Value::Int(n) => {
215            return Ok(Value::Unit {
216                number: Box::new(Value::Int(n.clone())),
217                unit: target,
218            });
219        }
220        Value::Rational(r) => {
221            return Ok(Value::Unit {
222                number: Box::new(Value::Rational(r.clone())),
223                unit: target,
224            });
225        }
226        _ => {}
227    }
228
229    // Value::Unit
230    if let Value::Unit {
231        number,
232        unit: source,
233    } = value
234    {
235        // Same unit → return original.
236        if *source == target {
237            return Ok(value.clone());
238        }
239        // Unknown units: identity only for equal strings (already handled above).
240        if source.dimension().is_none() || target.dimension().is_none() {
241            return Err(PncError::InvalidUnitConversion);
242        }
243        // Different dimensions.
244        if source.dimension() != target.dimension() {
245            return Err(PncError::InvalidUnitConversion);
246        }
247
248        // Extract rational from number.
249        let num = match number.as_ref() {
250            Value::Int(n) => BigRational::from_integer(n.clone()),
251            Value::Rational(r) => r.clone(),
252            _ => return Err(PncError::InvalidUnitConversion),
253        };
254        let base = num * source.factor() + source.offset();
255        let result = (base - target.offset()) / target.factor();
256
257        // Precision check on rational result.
258        crate::ops::check_precision(&Value::Rational(result.clone()))
259            .map_err(|_| PncError::PrecisionBudgetExceeded)?;
260
261        // Normalize via rational_to_value (inline).
262        let normalized = if result.denom() == &BigInt::from(1) {
263            Value::Int(result.numer().clone())
264        } else {
265            Value::Rational(result)
266        };
267
268        return Ok(Value::Unit {
269            number: Box::new(normalized),
270            unit: target,
271        });
272    }
273
274    Err(PncError::InvalidUnitConversion)
275}