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