Skip to main content

sofie_values/
measurement.rs

1use std::fmt::{self, Debug};
2use std::hash::Hash;
3
4use serde::{Deserialize, Deserializer, Serialize, Serializer};
5use sofie_core::show::Fields;
6use sofie_core::{
7    Cast, ColumnReader, ColumnSpec, Error, FieldError, Result, Show, Shown, SqlType, SqlValue,
8};
9
10#[cfg(feature = "web")]
11use crate::Conventions;
12use crate::decimal::{OUT_OF_RANGE, Refusal, divide_rounded, parse_scaled, scaled_text};
13use crate::refusal::{refusal, refusal_of};
14use crate::{Control, Input, Part, Present};
15
16const DECIMALS: u8 = 3;
17const THOUSANDTHS_PER_UNIT: f64 = 1000.0;
18/// Dividing by 1000 into the float column and multiplying back each err by at most half an ulp;
19/// up to 2^50 their sum stays below half a thousandth, so rounding restores the integer.
20const MAXIMUM_THOUSANDTHS: i64 = 1 << 50;
21
22/// Each unit is a whole number of the quantity's smallest step: conversion is integer arithmetic.
23pub trait Unit: Copy + Eq + Hash + Debug + 'static {
24    const ALL: &'static [Self];
25    const NAMES: &'static [&'static str];
26
27    fn name(self) -> &'static str;
28
29    fn steps(self) -> i64;
30}
31
32/// Equality compares the unit too: convert before comparing across units.
33#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
34pub struct Measurement<U> {
35    quantity: Thousandths,
36    unit: U,
37}
38
39#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
40struct Thousandths(i64);
41
42impl fmt::Display for Thousandths {
43    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
44        formatter.write_str(&scaled_text(self.0, DECIMALS))
45    }
46}
47
48impl<U: Unit> Measurement<U> {
49    pub fn try_new(thousandths: i64, unit: U) -> Result<Self> {
50        if !(-MAXIMUM_THOUSANDTHS..=MAXIMUM_THOUSANDTHS).contains(&thousandths) {
51            let (key, english) = OUT_OF_RANGE;
52            return Err(refusal("quantity", key, english));
53        }
54        Ok(Self {
55            quantity: Thousandths(thousandths),
56            unit,
57        })
58    }
59
60    pub fn parse(quantity: &str, unit: U) -> Result<Self> {
61        let thousandths = parse_scaled(quantity, DECIMALS).map_err(|refused| {
62            let (key, english) = match refused {
63                Refusal::TooManyDecimals => {
64                    ("validation.quantity.decimals", "has at most 3 decimals")
65                }
66                other => other.reason(),
67            };
68            refusal("quantity", key, english)
69        })?;
70        Self::try_new(thousandths, unit)
71    }
72
73    #[must_use]
74    pub fn thousandths(self) -> i64 {
75        self.quantity.0
76    }
77
78    #[must_use]
79    pub fn unit(self) -> U {
80        self.unit
81    }
82
83    #[must_use]
84    pub fn quantity(self) -> String {
85        self.quantity.to_string()
86    }
87
88    pub fn convert(self, unit: U) -> Result<Self> {
89        let steps = i128::from(self.quantity.0) * i128::from(self.unit.steps());
90        divide_rounded(steps, i128::from(unit.steps()))
91            .and_then(|thousandths| Self::try_new(thousandths, unit).ok())
92            .ok_or_else(|| {
93                Error::Rule(format!(
94                    "converting {} {} to {} leaves the range of a quantity",
95                    self.quantity,
96                    self.unit.name(),
97                    unit.name()
98                ))
99            })
100    }
101}
102
103fn unit_named<U: Unit>(name: &str) -> Result<U> {
104    U::ALL
105        .iter()
106        .copied()
107        .find(|unit| unit.name() == name)
108        .ok_or_else(|| {
109            let units = U::NAMES.join(", ");
110            refusal_of(
111                FieldError::new(
112                    "unit",
113                    "validation.unit.one_of",
114                    format!("must be one of {units}"),
115                )
116                .with("units", units),
117            )
118        })
119}
120
121impl<U: Unit> Show for Measurement<U> {
122    fn show(&self) -> Shown<'_> {
123        Shown::Record(self)
124    }
125}
126
127impl<U: Unit> Fields for Measurement<U> {
128    fn name(&self, index: usize) -> Option<&str> {
129        ["quantity", "unit"].get(index).copied()
130    }
131
132    fn get(&self, name: &str) -> Option<Shown<'_>> {
133        match name {
134            "quantity" => Some(Shown::Formatted(&self.quantity)),
135            "unit" => Some(Shown::Text(self.unit.name())),
136            _ => None,
137        }
138    }
139}
140
141/// Whole thousandths survive the float column exactly. Unordered: the quantity alone would put
142/// 500 grams after 2 kilograms.
143impl<U: Unit> Cast for Measurement<U> {
144    const COLUMNS: &'static [ColumnSpec] = &[
145        ColumnSpec::new("", SqlType::Float).unordered(),
146        ColumnSpec::new("_unit", SqlType::Text).unordered(),
147    ];
148
149    fn encode(&self, out: &mut Vec<SqlValue>) {
150        #[expect(
151            clippy::cast_precision_loss,
152            reason = "a quantity is at most 2^50 thousandths, which a float holds exactly"
153        )]
154        let quantity = self.quantity.0 as f64 / THOUSANDTHS_PER_UNIT;
155        out.push(SqlValue::Float(quantity));
156        out.push(SqlValue::Text(self.unit.name().to_owned()));
157    }
158
159    fn decode(row: &mut dyn ColumnReader) -> Result<Self> {
160        let thousandths = (f64::decode(row)? * THOUSANDTHS_PER_UNIT).round();
161        let unit = unit_named(row.text()?)
162            .map_err(|error| Error::internal("a stored unit is not known", error))?;
163        if !thousandths.is_finite() {
164            return Err(Error::internal_message("a stored quantity is not a number"));
165        }
166        #[expect(
167            clippy::cast_possible_truncation,
168            reason = "a cast saturates, and `try_new` refuses whatever lies beyond 2^50"
169        )]
170        Self::try_new(thousandths as i64, unit)
171            .map_err(|error| Error::internal("a stored quantity is out of range", error))
172    }
173}
174
175#[derive(Serialize, Deserialize)]
176#[serde(deny_unknown_fields)]
177struct MeasurementFields {
178    quantity: String,
179    unit: String,
180}
181
182impl<U: Unit> Serialize for Measurement<U> {
183    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
184        let fields = MeasurementFields {
185            quantity: self.quantity(),
186            unit: self.unit.name().to_owned(),
187        };
188        fields.serialize(serializer)
189    }
190}
191
192impl<'de, U: Unit> Deserialize<'de> for Measurement<U> {
193    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
194        let fields = MeasurementFields::deserialize(deserializer)?;
195        unit_named(&fields.unit)
196            .and_then(|unit| Self::parse(&fields.quantity, unit))
197            .map_err(Error::into_deserialize_error)
198    }
199}
200
201macro_rules! units {
202    ($(#[$doc:meta])* $unit:ident { $($variant:ident = $name:literal, $steps:literal;)+ }) => {
203        $(#[$doc])*
204        #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
205        pub enum $unit {
206            $($variant),+
207        }
208
209        impl Unit for $unit {
210            const ALL: &'static [Self] = &[$(Self::$variant),+];
211            const NAMES: &'static [&'static str] = &[$($name),+];
212
213            fn name(self) -> &'static str {
214                match self {
215                    $(Self::$variant => $name),+
216                }
217            }
218
219            fn steps(self) -> i64 {
220                match self {
221                    $(Self::$variant => $steps),+
222                }
223            }
224        }
225    };
226}
227
228units! {
229    /// Steps of a nanogram: an ounce is not a whole microgram.
230    WeightUnit {
231        Milligram = "MILLIGRAM", 1_000_000;
232        Gram = "GRAM", 1_000_000_000;
233        Kilogram = "KILOGRAM", 1_000_000_000_000;
234        Ounce = "OUNCE", 28_349_523_125;
235        Pound = "POUND", 453_592_370_000;
236    }
237}
238
239units! {
240    EnergyUnit {
241        Joule = "JOULE", 1_000;
242        Kilojoule = "KILOJOULE", 1_000_000;
243        Calorie = "CALORIE", 4_184;
244        Kilocalorie = "KILOCALORIE", 4_184_000;
245        KilowattHour = "KILOWATT_HOUR", 3_600_000_000;
246    }
247}
248
249units! {
250    LengthUnit {
251        Millimetre = "MILLIMETRE", 1_000;
252        Centimetre = "CENTIMETRE", 10_000;
253        Metre = "METRE", 1_000_000;
254        Kilometre = "KILOMETRE", 1_000_000_000;
255        Inch = "INCH", 25_400;
256        Foot = "FOOT", 304_800;
257        Mile = "MILE", 1_609_344_000;
258    }
259}
260
261pub type Weight = Measurement<WeightUnit>;
262pub type Energy = Measurement<EnergyUnit>;
263pub type Length = Measurement<LengthUnit>;
264
265impl<U: Unit> Present for Measurement<U> {
266    const INPUT: Input = Input::new(Control::Parts(&[
267        Part {
268            name: "quantity",
269            attributes: &[("type", "text"), ("inputmode", "decimal")],
270            options: &[],
271        },
272        Part {
273            name: "unit",
274            attributes: &[],
275            options: U::NAMES,
276        },
277    ]));
278    const SEARCH: crate::Search = crate::Search::Number;
279
280    fn csv(&self) -> String {
281        format!("{} {}", self.quantity, self.unit.name())
282    }
283
284    #[cfg(feature = "web")]
285    fn write_localized(&self, out: &mut String, conventions: &dyn Conventions) {
286        conventions.write_number(out, &self.quantity());
287        out.push(' ');
288        out.push_str(self.unit.name());
289    }
290}
291
292#[cfg(test)]
293mod tests {
294    use sofie_core::RowReader;
295
296    use super::*;
297    use crate::testing::{round_trip, suffixes};
298
299    fn kilograms(quantity: &str) -> Weight {
300        Weight::parse(quantity, WeightUnit::Kilogram).unwrap()
301    }
302
303    #[test]
304    fn no_column_sorts_a_list_since_units_differ_row_to_row() {
305        assert!(Weight::COLUMNS.iter().all(|column| !column.orders));
306    }
307
308    #[test]
309    fn reads_and_writes_a_quantity_to_a_thousandth() {
310        assert_eq!(kilograms("1.5").thousandths(), 1_500);
311        assert_eq!(kilograms("1.5").quantity(), "1.500");
312        assert_eq!(kilograms("-0.001").thousandths(), -1);
313        for (text, message) in [
314            ("1.0005", "has at most 3 decimals"),
315            (
316                "1,5",
317                "must use one `.` before the decimals and no other separator",
318            ),
319            ("", "must be a number such as 149.90"),
320        ] {
321            let refused = Weight::parse(text, WeightUnit::Gram).unwrap_err();
322            assert_eq!(format!("{refused:#}"), message, "{text:?}");
323        }
324    }
325
326    #[test]
327    fn converts_exactly_then_rounds_once() {
328        let pound = Weight::parse("1", WeightUnit::Pound).unwrap();
329
330        assert_eq!(
331            pound.convert(WeightUnit::Kilogram).unwrap(),
332            kilograms("0.454")
333        );
334        assert_eq!(
335            pound.convert(WeightUnit::Ounce).unwrap(),
336            Weight::parse("16", WeightUnit::Ounce).unwrap()
337        );
338        let mile = Length::parse("1", LengthUnit::Mile).unwrap();
339        assert_eq!(
340            mile.convert(LengthUnit::Foot).unwrap().quantity(),
341            "5280.000"
342        );
343        assert_eq!(
344            mile.convert(LengthUnit::Inch).unwrap().quantity(),
345            "63360.000"
346        );
347        let snack = Energy::parse("250", EnergyUnit::Kilocalorie).unwrap();
348        assert_eq!(
349            snack.convert(EnergyUnit::Kilojoule).unwrap().quantity(),
350            "1046.000"
351        );
352        assert_eq!(
353            Energy::parse("1", EnergyUnit::KilowattHour)
354                .unwrap()
355                .convert(EnergyUnit::Joule)
356                .unwrap()
357                .quantity(),
358            "3600000.000"
359        );
360    }
361
362    #[test]
363    fn refuses_a_conversion_that_overflows() {
364        let huge = Length::try_new(1 << 50, LengthUnit::Kilometre).unwrap();
365
366        assert!(matches!(
367            huge.convert(LengthUnit::Millimetre),
368            Err(Error::Rule(_))
369        ));
370        assert_eq!(huge.convert(LengthUnit::Kilometre).unwrap(), huge);
371        assert!(Length::parse("-1125899906842.625", LengthUnit::Metre).is_err());
372    }
373
374    #[test]
375    fn round_trips_through_a_quantity_and_a_unit_column() {
376        let weight = kilograms("1.5");
377        assert_eq!(
378            round_trip(&weight),
379            vec![SqlValue::Float(1.5), SqlValue::Text("KILOGRAM".into())]
380        );
381        assert_eq!(suffixes::<Weight>(), ["", "_unit"]);
382    }
383
384    #[test]
385    fn every_accepted_quantity_round_trips_through_the_float_column() {
386        for thousandths in [1 << 50, (1 << 50) - 1, (1 << 50) - 7, -(1 << 50)] {
387            round_trip(&Length::try_new(thousandths, LengthUnit::Metre).unwrap());
388        }
389        assert!(Length::try_new(9_007_199_254_540_993, LengthUnit::Metre).is_err());
390        assert!(Length::try_new((1 << 50) + 1, LengthUnit::Metre).is_err());
391    }
392
393    #[test]
394    fn refuses_a_stored_unit_of_another_quantity() {
395        let stored = vec![SqlValue::Float(1.0), SqlValue::Text("METRE".into())];
396        let infinite = vec![
397            SqlValue::Float(f64::INFINITY),
398            SqlValue::Text("GRAM".into()),
399        ];
400
401        assert!(Weight::decode(&mut RowReader::new(&stored)).is_err());
402        assert!(Weight::decode(&mut RowReader::new(&infinite)).is_err());
403    }
404
405    #[test]
406    fn round_trips_through_json() {
407        let weight = kilograms("0.25");
408        let json = serde_json::to_value(weight).unwrap();
409
410        assert_eq!(
411            json,
412            serde_json::json!({"quantity": "0.250", "unit": "KILOGRAM"})
413        );
414        assert_eq!(serde_json::from_value::<Weight>(json).unwrap(), weight);
415        let refused =
416            serde_json::from_value::<Weight>(serde_json::json!({"quantity": "1", "unit": "METRE"}))
417                .unwrap_err();
418        assert_eq!(
419            refused.to_string(),
420            "must be one of MILLIGRAM, GRAM, KILOGRAM, OUNCE, POUND"
421        );
422    }
423}