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