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. Unordered: the quantity alone would put
126/// 500 grams after 2 kilograms.
127impl<U: Unit> Cast for Measurement<U> {
128    const COLUMNS: &'static [ColumnSpec] = &[
129        ColumnSpec::new("", SqlType::Float).unordered(),
130        ColumnSpec::new("_unit", SqlType::Text).unordered(),
131    ];
132
133    fn encode(&self, out: &mut Vec<SqlValue>) {
134        #[expect(
135            clippy::cast_precision_loss,
136            reason = "a quantity is at most 2^50 thousandths, which a float holds exactly"
137        )]
138        let quantity = self.quantity.0 as f64 / THOUSANDTHS_PER_UNIT;
139        out.push(SqlValue::Float(quantity));
140        out.push(SqlValue::Text(self.unit.name().to_owned()));
141    }
142
143    fn decode(row: &mut dyn ColumnReader) -> Result<Self> {
144        let thousandths = (f64::decode(row)? * THOUSANDTHS_PER_UNIT).round();
145        let unit = unit_named(row.text()?)
146            .map_err(|error| Error::internal("a stored unit is not known", error))?;
147        if !thousandths.is_finite() {
148            return Err(Error::internal_message("a stored quantity is not a number"));
149        }
150        #[expect(
151            clippy::cast_possible_truncation,
152            reason = "a cast saturates, and `try_new` refuses whatever lies beyond 2^50"
153        )]
154        Self::try_new(thousandths as i64, unit)
155            .map_err(|error| Error::internal("a stored quantity is out of range", error))
156    }
157}
158
159#[derive(Serialize, Deserialize)]
160#[serde(deny_unknown_fields)]
161struct MeasurementFields {
162    quantity: String,
163    unit: String,
164}
165
166impl<U: Unit> Serialize for Measurement<U> {
167    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
168        let fields = MeasurementFields {
169            quantity: self.quantity(),
170            unit: self.unit.name().to_owned(),
171        };
172        fields.serialize(serializer)
173    }
174}
175
176impl<'de, U: Unit> Deserialize<'de> for Measurement<U> {
177    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
178        let fields = MeasurementFields::deserialize(deserializer)?;
179        unit_named(&fields.unit)
180            .and_then(|unit| Self::parse(&fields.quantity, unit))
181            .map_err(Error::into_deserialize_error)
182    }
183}
184
185macro_rules! units {
186    ($(#[$doc:meta])* $unit:ident { $($variant:ident = $name:literal, $steps:literal;)+ }) => {
187        $(#[$doc])*
188        #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
189        pub enum $unit {
190            $($variant),+
191        }
192
193        impl Unit for $unit {
194            const ALL: &'static [Self] = &[$(Self::$variant),+];
195            const NAMES: &'static [&'static str] = &[$($name),+];
196
197            fn name(self) -> &'static str {
198                match self {
199                    $(Self::$variant => $name),+
200                }
201            }
202
203            fn steps(self) -> i64 {
204                match self {
205                    $(Self::$variant => $steps),+
206                }
207            }
208        }
209    };
210}
211
212units! {
213    /// Steps of a nanogram: an ounce is not a whole microgram.
214    WeightUnit {
215        Milligram = "MILLIGRAM", 1_000_000;
216        Gram = "GRAM", 1_000_000_000;
217        Kilogram = "KILOGRAM", 1_000_000_000_000;
218        Ounce = "OUNCE", 28_349_523_125;
219        Pound = "POUND", 453_592_370_000;
220    }
221}
222
223units! {
224    EnergyUnit {
225        Joule = "JOULE", 1_000;
226        Kilojoule = "KILOJOULE", 1_000_000;
227        Calorie = "CALORIE", 4_184;
228        Kilocalorie = "KILOCALORIE", 4_184_000;
229        KilowattHour = "KILOWATT_HOUR", 3_600_000_000;
230    }
231}
232
233units! {
234    LengthUnit {
235        Millimetre = "MILLIMETRE", 1_000;
236        Centimetre = "CENTIMETRE", 10_000;
237        Metre = "METRE", 1_000_000;
238        Kilometre = "KILOMETRE", 1_000_000_000;
239        Inch = "INCH", 25_400;
240        Foot = "FOOT", 304_800;
241        Mile = "MILE", 1_609_344_000;
242    }
243}
244
245pub type Weight = Measurement<WeightUnit>;
246pub type Energy = Measurement<EnergyUnit>;
247pub type Length = Measurement<LengthUnit>;
248
249impl<U: Unit> Present for Measurement<U> {
250    const INPUT: Input = Input::new(Control::Parts(&[
251        Part {
252            name: "quantity",
253            attributes: &[("type", "text"), ("inputmode", "decimal")],
254            options: &[],
255        },
256        Part {
257            name: "unit",
258            attributes: &[],
259            options: U::NAMES,
260        },
261    ]));
262    const SEARCH: crate::Search = crate::Search::Number;
263
264    fn csv(&self) -> String {
265        format!("{} {}", self.quantity, self.unit.name())
266    }
267
268    #[cfg(feature = "web")]
269    fn write_localized(&self, out: &mut String, conventions: &dyn Conventions) {
270        conventions.write_number(out, &self.quantity());
271        out.push(' ');
272        out.push_str(self.unit.name());
273    }
274}
275
276#[cfg(test)]
277mod tests {
278    use sofie_core::RowReader;
279
280    use super::*;
281    use crate::testing::{round_trip, suffixes};
282
283    fn kilograms(quantity: &str) -> Weight {
284        Weight::parse(quantity, WeightUnit::Kilogram).unwrap()
285    }
286
287    #[test]
288    fn no_column_sorts_a_list_since_units_differ_row_to_row() {
289        assert!(Weight::COLUMNS.iter().all(|column| !column.orders));
290    }
291
292    #[test]
293    fn reads_and_writes_a_quantity_to_a_thousandth() {
294        assert_eq!(kilograms("1.5").thousandths(), 1_500);
295        assert_eq!(kilograms("1.5").quantity(), "1.500");
296        assert_eq!(kilograms("-0.001").thousandths(), -1);
297        for (text, message) in [
298            ("1.0005", "has at most 3 decimals"),
299            (
300                "1,5",
301                "must use one `.` before the decimals and no other separator",
302            ),
303            ("", "must be a number such as 149.90"),
304        ] {
305            let refused = Weight::parse(text, WeightUnit::Gram).unwrap_err();
306            assert_eq!(format!("{refused:#}"), message, "{text:?}");
307        }
308    }
309
310    #[test]
311    fn converts_exactly_then_rounds_once() {
312        let pound = Weight::parse("1", WeightUnit::Pound).unwrap();
313
314        assert_eq!(
315            pound.convert(WeightUnit::Kilogram).unwrap(),
316            kilograms("0.454")
317        );
318        assert_eq!(
319            pound.convert(WeightUnit::Ounce).unwrap(),
320            Weight::parse("16", WeightUnit::Ounce).unwrap()
321        );
322        let mile = Length::parse("1", LengthUnit::Mile).unwrap();
323        assert_eq!(
324            mile.convert(LengthUnit::Foot).unwrap().quantity(),
325            "5280.000"
326        );
327        assert_eq!(
328            mile.convert(LengthUnit::Inch).unwrap().quantity(),
329            "63360.000"
330        );
331        let snack = Energy::parse("250", EnergyUnit::Kilocalorie).unwrap();
332        assert_eq!(
333            snack.convert(EnergyUnit::Kilojoule).unwrap().quantity(),
334            "1046.000"
335        );
336        assert_eq!(
337            Energy::parse("1", EnergyUnit::KilowattHour)
338                .unwrap()
339                .convert(EnergyUnit::Joule)
340                .unwrap()
341                .quantity(),
342            "3600000.000"
343        );
344    }
345
346    #[test]
347    fn refuses_a_conversion_that_overflows() {
348        let huge = Length::try_new(1 << 50, LengthUnit::Kilometre).unwrap();
349
350        assert!(matches!(
351            huge.convert(LengthUnit::Millimetre),
352            Err(Error::Rule(_))
353        ));
354        assert_eq!(huge.convert(LengthUnit::Kilometre).unwrap(), huge);
355        assert!(Length::parse("-1125899906842.625", LengthUnit::Metre).is_err());
356    }
357
358    #[test]
359    fn round_trips_through_a_quantity_and_a_unit_column() {
360        let weight = kilograms("1.5");
361        assert_eq!(
362            round_trip(&weight),
363            vec![SqlValue::Float(1.5), SqlValue::Text("KILOGRAM".into())]
364        );
365        assert_eq!(suffixes::<Weight>(), ["", "_unit"]);
366    }
367
368    #[test]
369    fn every_accepted_quantity_round_trips_through_the_float_column() {
370        for thousandths in [1 << 50, (1 << 50) - 1, (1 << 50) - 7, -(1 << 50)] {
371            round_trip(&Length::try_new(thousandths, LengthUnit::Metre).unwrap());
372        }
373        assert!(Length::try_new(9_007_199_254_540_993, LengthUnit::Metre).is_err());
374        assert!(Length::try_new((1 << 50) + 1, LengthUnit::Metre).is_err());
375    }
376
377    #[test]
378    fn refuses_a_stored_unit_of_another_quantity() {
379        let stored = vec![SqlValue::Float(1.0), SqlValue::Text("METRE".into())];
380        let infinite = vec![
381            SqlValue::Float(f64::INFINITY),
382            SqlValue::Text("GRAM".into()),
383        ];
384
385        assert!(Weight::decode(&mut RowReader::new(&stored)).is_err());
386        assert!(Weight::decode(&mut RowReader::new(&infinite)).is_err());
387    }
388
389    #[test]
390    fn round_trips_through_json() {
391        let weight = kilograms("0.25");
392        let json = serde_json::to_value(weight).unwrap();
393
394        assert_eq!(
395            json,
396            serde_json::json!({"quantity": "0.250", "unit": "KILOGRAM"})
397        );
398        assert_eq!(serde_json::from_value::<Weight>(json).unwrap(), weight);
399        let refused =
400            serde_json::from_value::<Weight>(serde_json::json!({"quantity": "1", "unit": "METRE"}))
401                .unwrap_err();
402        assert_eq!(
403            refused.to_string(),
404            "must be one of MILLIGRAM, GRAM, KILOGRAM, OUNCE, POUND"
405        );
406    }
407}