Skip to main content

u_numflow/
units.rs

1//! Units of measure: dimensional analysis and conversion between units of the
2//! same dimension.
3//!
4//! A unit is a point in the space of physical dimensions — integer powers of
5//! the seven SI base quantities — together with an affine map to the coherent
6//! SI unit of that dimension: `value_in_si = value · scale + offset`. Most
7//! units have offset 0 (a kilowatt hour is 3.6 × 10⁶ J); a temperature scale
8//! such as the degree Celsius has an offset (0 °C = 273.15 K).
9//!
10//! Two conversions are distinct and both are offered:
11//!
12//! - [`Units::convert`] converts a **reading** — 20 °C is 293.15 K;
13//! - [`Units::convert_difference`] converts an **amount of change** — a rise
14//!   of 20 °C is a rise of 20 K. The offset cancels in a difference.
15//!
16//! Mixing the two is a silent error with an offset unit, so the caller picks.
17//!
18//! # Expressions
19//!
20//! Units are written as expressions in the case-sensitive syntax of UCUM
21//! (the Unified Code for Units of Measure), restricted to what dimensional
22//! conversion needs:
23//!
24//! - an atom (`m`, `s`, `J`), optionally with a decimal prefix (`km`, `mg`,
25//!   `kW`, `uA` — `u` is micro);
26//! - an integer exponent written after it (`m3`, `s-2`, `m+2`);
27//! - products with `.` and quotients with `/`, evaluated left to right
28//!   (`kW.h`, `m/s2`, `kg/m.s` = `(kg/m)·s`), a leading `/` for a reciprocal
29//!   (`/s`);
30//! - parentheses (`J/(kg.K)`), positive integers as factors (`1000.m`), `1`
31//!   for unity;
32//! - annotations in braces (`m3{gas}`), which UCUM defines to have no effect
33//!   on the unit and which are read as such.
34//!
35//! An atom is resolved before it is split into prefix and atom (`cd` is the
36//! candela, not a centi-day), and a whole token is tried as an atom before a
37//! trailing exponent is split off — so an atom whose symbol ends in digits
38//! (`Nm3`, defined by a caller) stays whole.
39//!
40//! A unit with an offset stands alone: a prefix, an exponent or a product
41//! applied to it is refused, since none has a meaning for a reading.
42//!
43//! # Built-in units
44//!
45//! [`Units::si`] knows the SI: the base units `m`, `g` (with prefix: `kg`),
46//! `s`, `A`, `K`, `mol`, `cd`; the 22 coherent derived units with special
47//! names (`rad`, `sr`, `Hz`, `N`, `Pa`, `J`, `W`, `C`, `V`, `F`, `Ohm`, `S`,
48//! `Wb`, `T`, `H`, `Cel`, `lm`, `lx`, `Bq`, `Gy`, `Sv`, `kat`); the units the SI
49//! accepts for use with it that are exact multiples (`min`, `h`, `d`, `L`/`l`,
50//! `t`); and the decimal prefixes `Q` … `q`. Everything else — calorie
51//! variants, the tonne of oil equivalent, the normal cubic metre, imperial
52//! units — is defined by the caller with [`Units::define`], so this crate
53//! never chooses between same-named units of different size (the 15 °C,
54//! international and thermochemical calories differ by up to 0.07 %).
55//!
56//! # Example
57//!
58//! ```
59//! use u_numflow::units::Units;
60//!
61//! let mut units = Units::si();
62//! units.define("cal_IT", 4.1868, "J", 0.0).unwrap();
63//! assert!((units.convert(1.0, "kW.h", "J").unwrap() - 3.6e6).abs() < 1e-6);
64//! assert!((units.convert(1.0, "kcal_IT", "J").unwrap() - 4186.8).abs() < 1e-9);
65//! assert!((units.convert(20.0, "Cel", "K").unwrap() - 293.15).abs() < 1e-9);
66//! assert_eq!(units.convert_difference(20.0, "Cel", "K").unwrap(), 20.0);
67//! assert!(units.convert(1.0, "m3", "kg").is_err()); // volume is not mass
68//! ```
69//!
70//! # References
71//! - BIPM (2019), *The International System of Units (SI)*, 9th ed.
72//! - Schadow & McDonald, *The Unified Code for Units of Measure*, §2–§5
73//!   (syntax only; no UCUM table is embedded).
74
75use std::collections::HashMap;
76use std::fmt;
77
78/// Symbols of the SI base units, in the order of a [`Dimension`]'s exponents.
79pub const BASE_SYMBOLS: [&str; 7] = ["m", "kg", "s", "A", "K", "mol", "cd"];
80
81/// Integer exponents of the seven SI base quantities — length, mass, time,
82/// electric current, thermodynamic temperature, amount of substance,
83/// luminous intensity, in that order.
84#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
85pub struct Dimension(pub [i32; 7]);
86
87impl Dimension {
88    /// The dimension of a pure number.
89    pub const NONE: Dimension = Dimension([0; 7]);
90
91    fn base(index: usize) -> Self {
92        let mut e = [0; 7];
93        e[index] = 1;
94        Dimension(e)
95    }
96
97    fn times(self, other: Self) -> Self {
98        let mut e = self.0;
99        for (a, b) in e.iter_mut().zip(other.0) {
100            *a += b;
101        }
102        Dimension(e)
103    }
104
105    fn pow(self, n: i32) -> Self {
106        Dimension(self.0.map(|e| e * n))
107    }
108
109    /// Whether this is the dimension of a pure number.
110    pub fn is_none(&self) -> bool {
111        *self == Self::NONE
112    }
113}
114
115/// Written as a product of SI base units in UCUM syntax, e.g. `kg.m2.s-2`
116/// (or `1` for a pure number).
117impl fmt::Display for Dimension {
118    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
119        // Mass first reads as engineers write it: kg.m2.s-2.
120        const ORDER: [usize; 7] = [1, 0, 2, 3, 4, 5, 6];
121        let parts: Vec<String> = ORDER
122            .iter()
123            .filter(|&&i| self.0[i] != 0)
124            .map(|&i| match self.0[i] {
125                1 => BASE_SYMBOLS[i].to_string(),
126                e => format!("{}{e}", BASE_SYMBOLS[i]),
127            })
128            .collect();
129        if parts.is_empty() {
130            f.write_str("1")
131        } else {
132            f.write_str(&parts.join("."))
133        }
134    }
135}
136
137/// A unit: its dimension and the affine map to the coherent SI unit of that
138/// dimension, `value_in_si = value · scale + offset`.
139#[derive(Debug, Clone, Copy, PartialEq)]
140pub struct Unit {
141    /// The physical dimension.
142    pub dimension: Dimension,
143    /// Size of one unit in coherent SI units (`> 0`).
144    pub scale: f64,
145    /// SI value of the unit's zero (0 except for scales such as `Cel`).
146    pub offset: f64,
147}
148
149impl Unit {
150    const ONE: Unit = Unit {
151        dimension: Dimension::NONE,
152        scale: 1.0,
153        offset: 0.0,
154    };
155
156    /// A reading `value` in this unit, in `to`.
157    ///
158    /// # Errors
159    /// [`UnitError::NotFinite`] for `value`, [`UnitError::DimensionMismatch`]
160    /// when `to` measures something else.
161    pub fn convert_to(&self, value: f64, to: &Unit) -> Result<f64, UnitError> {
162        self.check(value, to)?;
163        Ok((value * self.scale + self.offset - to.offset) / to.scale)
164    }
165
166    /// An amount of change `value` in this unit, in `to` (offsets cancel).
167    ///
168    /// # Errors
169    /// As [`Unit::convert_to`].
170    pub fn difference_to(&self, value: f64, to: &Unit) -> Result<f64, UnitError> {
171        self.check(value, to)?;
172        Ok(value * self.scale / to.scale)
173    }
174
175    fn check(&self, value: f64, to: &Unit) -> Result<(), UnitError> {
176        if !value.is_finite() {
177            return Err(UnitError::NotFinite { parameter: "value" });
178        }
179        if self.dimension != to.dimension {
180            return Err(UnitError::DimensionMismatch {
181                from: self.dimension,
182                to: to.dimension,
183            });
184        }
185        Ok(())
186    }
187
188    fn times(self, other: Unit) -> Unit {
189        Unit {
190            dimension: self.dimension.times(other.dimension),
191            scale: self.scale * other.scale,
192            offset: 0.0,
193        }
194    }
195
196    fn pow(self, n: i32) -> Unit {
197        Unit {
198            dimension: self.dimension.pow(n),
199            scale: self.scale.powi(n),
200            offset: 0.0,
201        }
202    }
203}
204
205/// Why an expression, a definition or a conversion was refused.
206#[derive(Debug, Clone, PartialEq)]
207pub enum UnitError {
208    /// The expression is not well formed (an empty term, an unbalanced
209    /// parenthesis, a misplaced operator, an exponent too large).
210    Malformed {
211        /// The expression as given.
212        expression: String,
213        /// What is wrong, in words.
214        reason: String,
215    },
216    /// A symbol that is neither a known atom nor a prefix with one.
217    UnknownSymbol {
218        /// The expression as given.
219        expression: String,
220        /// The symbol not recognised.
221        symbol: String,
222    },
223    /// A unit with an offset given a prefix, an exponent, or a product.
224    OffsetCombined {
225        /// The expression as given.
226        expression: String,
227        /// The offset unit's symbol.
228        symbol: String,
229    },
230    /// A conversion between different dimensions.
231    DimensionMismatch {
232        /// The dimension of the unit converted from.
233        from: Dimension,
234        /// The dimension of the unit converted to.
235        to: Dimension,
236    },
237    /// A definition whose symbol is already an atom (built in or defined).
238    DuplicateSymbol {
239        /// The symbol.
240        symbol: String,
241    },
242    /// A definition whose symbol is not usable as an atom.
243    InvalidSymbol {
244        /// The symbol.
245        symbol: String,
246        /// What is wrong, in words.
247        reason: String,
248    },
249    /// A value, factor or offset that is NaN or infinite.
250    NotFinite {
251        /// What it is (`value`, `factor`, `offset`).
252        parameter: &'static str,
253    },
254    /// A definition's factor that is not `> 0`.
255    NonPositiveFactor {
256        /// The factor given.
257        got: f64,
258    },
259}
260
261impl fmt::Display for UnitError {
262    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
263        match self {
264            UnitError::Malformed { expression, reason } => {
265                write!(f, "unit \"{expression}\": {reason}")
266            }
267            UnitError::UnknownSymbol { expression, symbol } => {
268                write!(f, "unit \"{expression}\": unknown unit symbol \"{symbol}\"")
269            }
270            UnitError::OffsetCombined { expression, symbol } => write!(
271                f,
272                "unit \"{expression}\": \"{symbol}\" has an offset (a scale such as Cel) and \
273                 must stand alone, without a prefix, an exponent or a product"
274            ),
275            UnitError::DimensionMismatch { from, to } => {
276                write!(f, "cannot convert between dimensions {from} and {to}")
277            }
278            UnitError::DuplicateSymbol { symbol } => {
279                write!(f, "\"{symbol}\" is already a unit")
280            }
281            UnitError::InvalidSymbol { symbol, reason } => {
282                write!(f, "\"{symbol}\" cannot be a unit symbol: {reason}")
283            }
284            UnitError::NotFinite { parameter } => {
285                write!(f, "{parameter} must be a finite number")
286            }
287            UnitError::NonPositiveFactor { got } => {
288                write!(f, "factor must be > 0, got {got}")
289            }
290        }
291    }
292}
293
294impl std::error::Error for UnitError {}
295
296/// Decimal prefixes (SI 2022, Q … q), with `u` for micro as UCUM writes it.
297const PREFIXES: [(&str, i32); 25] = [
298    ("Q", 30),
299    ("R", 27),
300    ("Y", 24),
301    ("Z", 21),
302    ("E", 18),
303    ("P", 15),
304    ("T", 12),
305    ("G", 9),
306    ("M", 6),
307    ("k", 3),
308    ("h", 2),
309    ("da", 1),
310    ("d", -1),
311    ("c", -2),
312    ("m", -3),
313    ("u", -6),
314    ("n", -9),
315    ("p", -12),
316    ("f", -15),
317    ("a", -18),
318    ("z", -21),
319    ("y", -24),
320    ("r", -27),
321    ("q", -30),
322    ("μ", -6),
323];
324
325/// A known atom: the unit it stands for, and whether a prefix may go on it.
326#[derive(Debug, Clone, Copy)]
327struct Atom {
328    unit: Unit,
329    prefixable: bool,
330}
331
332/// A set of unit symbols: the SI, and whatever the caller defines.
333///
334/// Every operation reads expressions against this set; nothing is global.
335#[derive(Debug, Clone)]
336pub struct Units {
337    atoms: HashMap<String, Atom>,
338}
339
340/// The characters that separate tokens in an expression.
341fn is_operator(c: char) -> bool {
342    matches!(c, '.' | '/' | '(' | ')' | '{' | '}') || c.is_whitespace()
343}
344
345impl Units {
346    /// The SI units (see the module documentation).
347    pub fn si() -> Self {
348        let mut atoms = HashMap::new();
349        let mut put = |symbol: &str, unit: Unit, prefixable: bool| {
350            atoms.insert(symbol.to_string(), Atom { unit, prefixable });
351        };
352        let d = |e: [i32; 7]| Dimension(e);
353        let coherent = |e: [i32; 7]| Unit {
354            dimension: d(e),
355            scale: 1.0,
356            offset: 0.0,
357        };
358        let scaled = |e: [i32; 7], scale: f64| Unit {
359            dimension: d(e),
360            scale,
361            offset: 0.0,
362        };
363        put(
364            "m",
365            Unit {
366                dimension: Dimension::base(0),
367                scale: 1.0,
368                offset: 0.0,
369            },
370            true,
371        );
372        put(
373            "g",
374            Unit {
375                dimension: Dimension::base(1),
376                scale: 1e-3,
377                offset: 0.0,
378            },
379            true,
380        );
381        put(
382            "s",
383            Unit {
384                dimension: Dimension::base(2),
385                scale: 1.0,
386                offset: 0.0,
387            },
388            true,
389        );
390        put(
391            "A",
392            Unit {
393                dimension: Dimension::base(3),
394                scale: 1.0,
395                offset: 0.0,
396            },
397            true,
398        );
399        put(
400            "K",
401            Unit {
402                dimension: Dimension::base(4),
403                scale: 1.0,
404                offset: 0.0,
405            },
406            true,
407        );
408        put(
409            "mol",
410            Unit {
411                dimension: Dimension::base(5),
412                scale: 1.0,
413                offset: 0.0,
414            },
415            true,
416        );
417        put(
418            "cd",
419            Unit {
420                dimension: Dimension::base(6),
421                scale: 1.0,
422                offset: 0.0,
423            },
424            true,
425        );
426        put("rad", coherent([0, 0, 0, 0, 0, 0, 0]), true);
427        put("sr", coherent([0, 0, 0, 0, 0, 0, 0]), true);
428        put("Hz", coherent([0, 0, -1, 0, 0, 0, 0]), true);
429        put("N", coherent([1, 1, -2, 0, 0, 0, 0]), true);
430        put("Pa", coherent([-1, 1, -2, 0, 0, 0, 0]), true);
431        put("J", coherent([2, 1, -2, 0, 0, 0, 0]), true);
432        put("W", coherent([2, 1, -3, 0, 0, 0, 0]), true);
433        put("C", coherent([0, 0, 1, 1, 0, 0, 0]), true);
434        put("V", coherent([2, 1, -3, -1, 0, 0, 0]), true);
435        put("F", coherent([-2, -1, 4, 2, 0, 0, 0]), true);
436        put("Ohm", coherent([2, 1, -3, -2, 0, 0, 0]), true);
437        put("S", coherent([-2, -1, 3, 2, 0, 0, 0]), true);
438        put("Wb", coherent([2, 1, -2, -1, 0, 0, 0]), true);
439        put("T", coherent([0, 1, -2, -1, 0, 0, 0]), true);
440        put("H", coherent([2, 1, -2, -2, 0, 0, 0]), true);
441        put("lm", coherent([0, 0, 0, 0, 0, 0, 1]), true);
442        put("lx", coherent([-2, 0, 0, 0, 0, 0, 1]), true);
443        put("Bq", coherent([0, 0, -1, 0, 0, 0, 0]), true);
444        put("Gy", coherent([2, 0, -2, 0, 0, 0, 0]), true);
445        put("Sv", coherent([2, 0, -2, 0, 0, 0, 0]), true);
446        put("kat", coherent([0, 0, -1, 0, 0, 1, 0]), true);
447        put(
448            "Cel",
449            Unit {
450                dimension: Dimension::base(4),
451                scale: 1.0,
452                offset: 273.15,
453            },
454            false,
455        );
456        // Accepted for use with the SI (SI Brochure, Table 8) — exact multiples.
457        put("min", scaled([0, 0, 1, 0, 0, 0, 0], 60.0), false);
458        put("h", scaled([0, 0, 1, 0, 0, 0, 0], 3600.0), false);
459        put("d", scaled([0, 0, 1, 0, 0, 0, 0], 86_400.0), false);
460        put("L", scaled([3, 0, 0, 0, 0, 0, 0], 1e-3), true);
461        put("l", scaled([3, 0, 0, 0, 0, 0, 0], 1e-3), true);
462        put("t", scaled([0, 1, 0, 0, 0, 0, 0], 1e3), true);
463        put("1", Unit::ONE, false);
464        Units { atoms }
465    }
466
467    /// Defines `symbol` as `factor` × `expression` (+ `offset` SI units at
468    /// its zero, for a scale): `define("cal_IT", 4.1868, "J", 0.0)`,
469    /// `define("toe", 1e7, "kcal_IT", 0.0)`,
470    /// `define("degF", 5.0 / 9.0, "K", 459.67 * 5.0 / 9.0)`.
471    ///
472    /// `expression` may use the built-in units and every symbol defined
473    /// before. A defined unit takes decimal prefixes unless it has an offset.
474    /// A symbol that reads as a prefix on an atom (`ft`, the femtotonne) is
475    /// free to define: atoms are read before prefixes, so the definition wins.
476    ///
477    /// # Errors
478    /// [`UnitError::InvalidSymbol`] for a symbol that is empty, starts with a
479    /// digit or a sign, or holds an operator character (`.` `/` `(` `)` `{`
480    /// `}` or a space); [`UnitError::DuplicateSymbol`] for one already
481    /// defined (built-in or not); [`UnitError::NotFinite`] /
482    /// [`UnitError::NonPositiveFactor`] for `factor` and `offset`; and the
483    /// errors of [`Units::parse`] for `expression`.
484    pub fn define(
485        &mut self,
486        symbol: &str,
487        factor: f64,
488        expression: &str,
489        offset: f64,
490    ) -> Result<(), UnitError> {
491        let invalid = |reason: &str| UnitError::InvalidSymbol {
492            symbol: symbol.to_string(),
493            reason: reason.to_string(),
494        };
495        let first = symbol
496            .chars()
497            .next()
498            .ok_or_else(|| invalid("it is empty"))?;
499        if first.is_ascii_digit() || first == '+' || first == '-' {
500            return Err(invalid("it starts with a digit or a sign"));
501        }
502        if symbol.chars().any(is_operator) {
503            return Err(invalid(
504                "it holds an operator character (. / ( ) { } or a space)",
505            ));
506        }
507        // A prefix on an atom (`ft` = femtotonne) may be taken over: atoms
508        // are read first, and `ft` as the foot is what a caller means.
509        if self.atoms.contains_key(symbol) {
510            return Err(UnitError::DuplicateSymbol {
511                symbol: symbol.to_string(),
512            });
513        }
514        if !factor.is_finite() {
515            return Err(UnitError::NotFinite {
516                parameter: "factor",
517            });
518        }
519        if factor <= 0.0 {
520            return Err(UnitError::NonPositiveFactor { got: factor });
521        }
522        if !offset.is_finite() {
523            return Err(UnitError::NotFinite {
524                parameter: "offset",
525            });
526        }
527        let base = self.parse(expression)?;
528        let unit = Unit {
529            dimension: base.dimension,
530            scale: factor * base.scale,
531            offset: base.offset + offset,
532        };
533        self.atoms.insert(
534            symbol.to_string(),
535            Atom {
536                unit,
537                prefixable: unit.offset == 0.0,
538            },
539        );
540        Ok(())
541    }
542
543    /// The unit `expression` stands for.
544    ///
545    /// # Errors
546    /// [`UnitError::Malformed`], [`UnitError::UnknownSymbol`],
547    /// [`UnitError::OffsetCombined`].
548    pub fn parse(&self, expression: &str) -> Result<Unit, UnitError> {
549        let mut parser = Parser {
550            units: self,
551            expression,
552            chars: expression.char_indices().peekable(),
553        };
554        let (unit, offset_symbol) = parser.term()?;
555        if let Some(&(_, c)) = parser.chars.peek() {
556            return Err(parser.malformed(format!("unexpected \"{c}\"")));
557        }
558        if let OffsetUse::Combined(symbol) = offset_symbol {
559            return Err(UnitError::OffsetCombined {
560                expression: expression.to_string(),
561                symbol,
562            });
563        }
564        Ok(unit)
565    }
566
567    /// `value` read in `from`, in `to`: a reading, so a scale's offset
568    /// applies (20 `Cel` is 293.15 `K`).
569    ///
570    /// # Errors
571    /// [`UnitError::NotFinite`] for `value`; the errors of [`Units::parse`];
572    /// [`UnitError::DimensionMismatch`] when the units measure different
573    /// things.
574    pub fn convert(&self, value: f64, from: &str, to: &str) -> Result<f64, UnitError> {
575        self.parse(from)?.convert_to(value, &self.parse(to)?)
576    }
577
578    /// An amount of change `value` in `from`, in `to`: the offset cancels
579    /// (a rise of 20 `Cel` is a rise of 20 `K`).
580    ///
581    /// # Errors
582    /// As [`Units::convert`].
583    pub fn convert_difference(&self, value: f64, from: &str, to: &str) -> Result<f64, UnitError> {
584        self.parse(from)?.difference_to(value, &self.parse(to)?)
585    }
586
587    /// The atom `token` names, alone or after a prefix; `None` if neither.
588    fn atom<'t>(&self, token: &'t str) -> Option<(Unit, Option<&'t str>)> {
589        if let Some(a) = self.atoms.get(token) {
590            return Some((a.unit, None));
591        }
592        PREFIXES.iter().find_map(|&(prefix, exp)| {
593            let rest = token.strip_prefix(prefix)?;
594            let a = self.atoms.get(rest)?;
595            if !a.prefixable && a.unit.offset == 0.0 {
596                return None;
597            }
598            Some((
599                Unit {
600                    dimension: a.unit.dimension,
601                    scale: a.unit.scale * 10f64.powi(exp),
602                    offset: a.unit.offset,
603                },
604                Some(rest),
605            ))
606        })
607    }
608}
609
610/// Whether a parsed part involves an offset unit, and how.
611#[derive(Debug, Clone, PartialEq)]
612enum OffsetUse {
613    None,
614    /// The whole part is the offset unit, unmodified.
615    Alone(String),
616    /// An offset unit with a prefix, exponent, or product.
617    Combined(String),
618}
619
620struct Parser<'a> {
621    units: &'a Units,
622    expression: &'a str,
623    chars: std::iter::Peekable<std::str::CharIndices<'a>>,
624}
625
626impl Parser<'_> {
627    fn malformed(&self, reason: String) -> UnitError {
628        UnitError::Malformed {
629            expression: self.expression.to_string(),
630            reason,
631        }
632    }
633
634    /// term := ['/'] component (('.' | '/') component)*
635    fn term(&mut self) -> Result<(Unit, OffsetUse), UnitError> {
636        let mut unit = Unit::ONE;
637        let mut parts: Vec<OffsetUse> = Vec::new();
638        let mut divide = false;
639        if let Some(&(_, '/')) = self.chars.peek() {
640            self.chars.next();
641            divide = true;
642        }
643        let mut divided = divide;
644        loop {
645            let (next, offset) = self.component()?;
646            unit = if parts.is_empty() && !divide {
647                next
648            } else {
649                unit.times(if divide { next.pow(-1) } else { next })
650            };
651            parts.push(offset);
652            match self.chars.peek() {
653                Some(&(_, '.')) => divide = false,
654                Some(&(_, '/')) => divide = true,
655                _ => break,
656            }
657            divided |= divide;
658            self.chars.next();
659        }
660        // An offset unit keeps its meaning only as the whole term.
661        let offset = match parts.as_slice() {
662            [OffsetUse::Alone(s)] if !divided => OffsetUse::Alone(s.clone()),
663            _ => parts
664                .into_iter()
665                .find_map(|p| match p {
666                    OffsetUse::Alone(s) | OffsetUse::Combined(s) => Some(OffsetUse::Combined(s)),
667                    OffsetUse::None => None,
668                })
669                .unwrap_or(OffsetUse::None),
670        };
671        if !matches!(offset, OffsetUse::Alone(_)) {
672            unit.offset = 0.0;
673        }
674        Ok((unit, offset))
675    }
676
677    /// component := '(' term ')' annotation? | token annotation? | annotation
678    fn component(&mut self) -> Result<(Unit, OffsetUse), UnitError> {
679        match self.chars.peek().copied() {
680            None => Err(self.malformed("a unit is missing at the end".to_string())),
681            Some((_, '(')) => {
682                self.chars.next();
683                let (unit, offset) = self.term()?;
684                match self.chars.next() {
685                    Some((_, ')')) => {}
686                    _ => return Err(self.malformed("\"(\" is not closed".to_string())),
687                }
688                self.annotation()?;
689                Ok((unit, offset))
690            }
691            Some((_, '{')) => {
692                self.annotation()?;
693                Ok((Unit::ONE, OffsetUse::None))
694            }
695            Some((_, c)) if is_operator(c) => {
696                Err(self.malformed(format!("a unit is missing before \"{c}\"")))
697            }
698            Some((start, _)) => {
699                let mut end = start;
700                while let Some(&(i, c)) = self.chars.peek() {
701                    if is_operator(c) {
702                        break;
703                    }
704                    end = i + c.len_utf8();
705                    self.chars.next();
706                }
707                let token = &self.expression[start..end];
708                let result = self.token(token)?;
709                self.annotation()?;
710                Ok(result)
711            }
712        }
713    }
714
715    /// An optional `{…}` annotation, which has no effect on the unit.
716    fn annotation(&mut self) -> Result<(), UnitError> {
717        if let Some(&(_, '{')) = self.chars.peek() {
718            self.chars.next();
719            loop {
720                match self.chars.next() {
721                    Some((_, '}')) => return Ok(()),
722                    Some((_, '{')) => {
723                        return Err(self.malformed("an annotation holds \"{\"".to_string()))
724                    }
725                    Some(_) => {}
726                    None => return Err(self.malformed("\"{\" is not closed".to_string())),
727                }
728            }
729        }
730        Ok(())
731    }
732
733    /// token := integer | atom exponent? — the whole token is tried as an
734    /// atom before a trailing exponent is split off.
735    fn token(&self, token: &str) -> Result<(Unit, OffsetUse), UnitError> {
736        if token.bytes().all(|b| b.is_ascii_digit()) {
737            let n: f64 = token
738                .parse()
739                .map_err(|_| self.malformed(format!("\"{token}\" is not a number")))?;
740            if n <= 0.0 {
741                return Err(self.malformed("a factor must be a positive integer".to_string()));
742            }
743            return Ok((
744                Unit {
745                    scale: n,
746                    ..Unit::ONE
747                },
748                OffsetUse::None,
749            ));
750        }
751        if let Some(found) = self.lookup(token) {
752            return Ok(found);
753        }
754        let digits = token
755            .char_indices()
756            .rev()
757            .take_while(|(_, c)| c.is_ascii_digit())
758            .last()
759            .map(|(i, _)| i);
760        let Some(digits_at) = digits else {
761            return Err(self.unknown(token));
762        };
763        let (symbol_and_sign, digits) = token.split_at(digits_at);
764        let (symbol, sign) = match symbol_and_sign.strip_suffix(['+', '-']) {
765            Some(s) => (s, &symbol_and_sign[s.len()..]),
766            None => (symbol_and_sign, ""),
767        };
768        if symbol.is_empty() {
769            return Err(self.malformed(format!("\"{token}\" has an exponent but no unit")));
770        }
771        let n: i32 = format!("{sign}{digits}")
772            .parse()
773            .ok()
774            .filter(|n: &i32| n.unsigned_abs() <= 64)
775            .ok_or_else(|| self.malformed(format!("exponent \"{sign}{digits}\" is too large")))?;
776        let (unit, offset) = self.lookup(symbol).ok_or_else(|| self.unknown(symbol))?;
777        let offset = match offset {
778            OffsetUse::Alone(s) if n != 1 => OffsetUse::Combined(s),
779            other => other,
780        };
781        Ok((unit.pow(n), offset))
782    }
783
784    fn lookup(&self, symbol: &str) -> Option<(Unit, OffsetUse)> {
785        let (unit, prefixed) = self.units.atom(symbol)?;
786        let offset = match (unit.offset != 0.0, prefixed) {
787            (false, _) => OffsetUse::None,
788            (true, None) => OffsetUse::Alone(symbol.to_string()),
789            (true, Some(atom)) => OffsetUse::Combined(atom.to_string()),
790        };
791        Some((unit, offset))
792    }
793
794    fn unknown(&self, symbol: &str) -> UnitError {
795        UnitError::UnknownSymbol {
796            expression: self.expression.to_string(),
797            symbol: symbol.to_string(),
798        }
799    }
800}
801
802#[cfg(test)]
803mod tests {
804    use super::*;
805
806    fn close(a: f64, b: f64) -> bool {
807        (a - b).abs() <= 1e-12 * b.abs().max(1.0)
808    }
809
810    /// The units a district-heating or energy report needs, defined as data.
811    fn energy() -> Units {
812        let mut u = Units::si();
813        u.define("bar", 1e5, "Pa", 0.0).unwrap();
814        u.define("cal_IT", 4.1868, "J", 0.0).unwrap();
815        u.define("cal_th", 4.184, "J", 0.0).unwrap();
816        u.define("toe", 1e7, "kcal_IT", 0.0).unwrap();
817        u.define("Nm3", 1.0, "m3", 0.0).unwrap();
818        u
819    }
820
821    // ── the behaviours the request lists ──
822
823    #[test]
824    fn one_bar_is_one_hundred_kilopascal() {
825        assert!(close(energy().convert(1.0, "bar", "kPa").unwrap(), 100.0));
826    }
827
828    #[test]
829    fn a_celsius_reading_converts_with_the_offset_and_a_difference_without() {
830        let u = Units::si();
831        assert!(close(u.convert(20.0, "Cel", "K").unwrap(), 293.15));
832        assert!(close(u.convert_difference(20.0, "Cel", "K").unwrap(), 20.0));
833        assert!(close(u.convert(300.0, "K", "Cel").unwrap(), 26.85));
834    }
835
836    #[test]
837    fn a_kilowatt_hour_is_three_point_six_megajoule() {
838        assert!(close(Units::si().convert(1.0, "kW.h", "J").unwrap(), 3.6e6));
839        assert!(close(Units::si().convert(1.0, "kW.h", "MJ").unwrap(), 3.6));
840    }
841
842    #[test]
843    fn the_calorie_is_the_one_the_caller_defined() {
844        let u = energy();
845        assert!(close(u.convert(1.0, "kcal_IT", "J").unwrap(), 4186.8));
846        assert!(close(u.convert(1.0, "kcal_th", "J").unwrap(), 4184.0));
847        assert!(close(u.convert(1.0, "Gcal_IT", "GJ").unwrap(), 4.1868));
848        assert!(close(u.convert(1.0, "toe", "GJ").unwrap(), 41.868));
849    }
850
851    #[test]
852    fn volume_is_not_mass() {
853        assert_eq!(
854            energy().convert(1.0, "m3", "kg"),
855            Err(UnitError::DimensionMismatch {
856                from: Dimension([3, 0, 0, 0, 0, 0, 0]),
857                to: Dimension([0, 1, 0, 0, 0, 0, 0]),
858            })
859        );
860    }
861
862    // ── syntax ──
863
864    #[test]
865    fn products_and_quotients_read_left_to_right() {
866        let u = Units::si();
867        assert!(close(
868            u.convert(1.0, "km/h", "m/s").unwrap(),
869            1000.0 / 3600.0
870        ));
871        // kg/m.s = (kg/m)·s, as UCUM reads it.
872        assert_eq!(
873            u.parse("kg/m.s").unwrap().dimension,
874            Dimension([-1, 1, 1, 0, 0, 0, 0])
875        );
876        assert_eq!(
877            u.parse("kg/(m.s)").unwrap().dimension,
878            Dimension([-1, 1, -1, 0, 0, 0, 0])
879        );
880        assert_eq!(
881            u.parse("/s").unwrap().dimension,
882            u.parse("Hz").unwrap().dimension
883        );
884        assert!(close(u.convert(1.0, "m/s2", "N/kg").unwrap(), 1.0));
885        assert!(close(u.convert(1.0, "J/(kg.K)", "m2/(s2.K)").unwrap(), 1.0));
886    }
887
888    #[test]
889    fn exponents_are_signed_integers() {
890        let u = Units::si();
891        assert!(close(u.convert(1.0, "m3", "L").unwrap(), 1000.0));
892        assert!(close(u.convert(1.0, "cm3", "mL").unwrap(), 1.0));
893        assert!(close(u.convert(2.0, "s-1", "Hz").unwrap(), 2.0));
894        assert!(close(u.convert(1.0, "m+2", "dm2").unwrap(), 100.0));
895    }
896
897    #[test]
898    fn integers_are_factors_and_one_is_unity() {
899        let u = Units::si();
900        assert!(close(u.convert(1.0, "1000.m", "km").unwrap(), 1.0));
901        assert!(u.parse("1").unwrap().dimension.is_none());
902        assert!(close(u.convert(1.0, "rad", "1").unwrap(), 1.0));
903    }
904
905    #[test]
906    fn annotations_have_no_effect() {
907        let u = energy();
908        assert!(close(u.convert(5.0, "m3{gas}", "m3").unwrap(), 5.0));
909        assert!(close(u.convert(5.0, "{count}/min", "/h").unwrap(), 300.0));
910    }
911
912    #[test]
913    fn an_atom_wins_over_a_prefix_split_and_a_symbol_ending_in_digits_stays_whole() {
914        let u = energy();
915        assert_eq!(u.parse("cd").unwrap().dimension, Dimension::base(6)); // candela
916        assert!(close(u.convert(1.0, "Nm3", "m3").unwrap(), 1.0));
917        assert!(close(u.convert(1.0, "Nm3", "Nm3").unwrap(), 1.0));
918        assert!(close(u.convert(1.0, "min", "s").unwrap(), 60.0)); // not milli-inch
919        assert!(close(u.convert(1.0, "mmol", "mol").unwrap(), 1e-3));
920        assert!(close(u.convert(1.0, "kg", "g").unwrap(), 1e3));
921        assert!(close(u.convert(1.0, "dam", "m").unwrap(), 10.0));
922        assert!(close(u.convert(1.0, "um", "m").unwrap(), 1e-6));
923        assert!(close(u.convert(1.0, "μm", "m").unwrap(), 1e-6));
924    }
925
926    #[test]
927    fn hours_and_days_take_no_prefix() {
928        // "kh" would be a kilohour; the SI does not prefix the hour.
929        assert!(matches!(
930            Units::si().parse("kh"),
931            Err(UnitError::UnknownSymbol { .. })
932        ));
933    }
934
935    // ── refusals ──
936
937    #[test]
938    fn an_offset_unit_stands_alone() {
939        let u = Units::si();
940        for e in ["Cel2", "kCel", "Cel/s", "m.Cel", "(Cel)", "/Cel"] {
941            let r = u.parse(e);
942            if e == "(Cel)" {
943                // A parenthesized lone unit is still the unit alone.
944                assert!(r.is_ok(), "{e}: {r:?}");
945                continue;
946            }
947            assert!(
948                matches!(r, Err(UnitError::OffsetCombined { ref symbol, .. }) if symbol == "Cel"),
949                "{e}: {r:?}"
950            );
951        }
952    }
953
954    #[test]
955    fn a_malformed_expression_is_refused() {
956        let u = Units::si();
957        for e in ["", "m.", "m//s", "(m", "m)", "m{x", ".m", "m s", "m99999"] {
958            assert!(
959                matches!(u.parse(e), Err(UnitError::Malformed { .. })),
960                "{e:?}: {:?}",
961                u.parse(e)
962            );
963        }
964    }
965
966    #[test]
967    fn an_unknown_symbol_is_named() {
968        assert_eq!(
969            Units::si().parse("kW.hr"),
970            Err(UnitError::UnknownSymbol {
971                expression: "kW.hr".into(),
972                symbol: "hr".into()
973            })
974        );
975        // The exponent is split off before the symbol is named.
976        assert!(matches!(
977            Units::si().parse("yd2"),
978            Err(UnitError::UnknownSymbol { ref symbol, .. }) if symbol == "yd"
979        ));
980        // A prefix on an atom is a unit even where a reader expects another:
981        // `ft` is the femtotonne — imperial units are the caller's to define.
982        assert!(Units::si().parse("ft").is_ok());
983    }
984
985    #[test]
986    fn a_definition_is_checked() {
987        let mut u = Units::si();
988        assert_eq!(
989            u.define("J", 1.0, "J", 0.0),
990            Err(UnitError::DuplicateSymbol { symbol: "J".into() })
991        );
992        // A prefixed atom is not an atom: the foot takes over the femtotonne.
993        u.define("ft", 0.3048, "m", 0.0).unwrap();
994        assert!(close(u.convert(1.0, "ft2", "m2").unwrap(), 0.3048 * 0.3048));
995        for bad in ["", "2x", "-x", "a.b", "a b", "a/b", "{a}"] {
996            assert!(
997                matches!(
998                    u.define(bad, 1.0, "m", 0.0),
999                    Err(UnitError::InvalidSymbol { .. })
1000                ),
1001                "{bad:?}"
1002            );
1003        }
1004        assert_eq!(
1005            u.define("x", 0.0, "m", 0.0),
1006            Err(UnitError::NonPositiveFactor { got: 0.0 })
1007        );
1008        assert_eq!(
1009            u.define("x", f64::NAN, "m", 0.0),
1010            Err(UnitError::NotFinite {
1011                parameter: "factor"
1012            })
1013        );
1014        assert!(matches!(
1015            u.define("x", 1.0, "later", 0.0),
1016            Err(UnitError::UnknownSymbol { .. })
1017        ));
1018    }
1019
1020    #[test]
1021    fn a_defined_scale_converts_readings() {
1022        let mut u = Units::si();
1023        u.define("degF", 5.0 / 9.0, "K", 459.67 * 5.0 / 9.0)
1024            .unwrap();
1025        assert!(close(u.convert(32.0, "degF", "Cel").unwrap(), 0.0));
1026        assert!(close(u.convert(212.0, "degF", "Cel").unwrap(), 100.0));
1027        assert!(close(u.convert_difference(9.0, "degF", "K").unwrap(), 5.0));
1028        assert!(matches!(
1029            u.parse("kdegF"),
1030            Err(UnitError::OffsetCombined { .. })
1031        ));
1032    }
1033
1034    #[test]
1035    fn a_value_must_be_finite() {
1036        assert_eq!(
1037            Units::si().convert(f64::INFINITY, "m", "m"),
1038            Err(UnitError::NotFinite { parameter: "value" })
1039        );
1040    }
1041
1042    #[test]
1043    fn a_dimension_is_written_in_base_units() {
1044        let u = Units::si();
1045        assert_eq!(u.parse("J").unwrap().dimension.to_string(), "kg.m2.s-2");
1046        assert_eq!(u.parse("rad").unwrap().dimension.to_string(), "1");
1047        assert_eq!(
1048            u.parse("Ohm").unwrap().dimension.to_string(),
1049            "kg.m2.s-3.A-2"
1050        );
1051    }
1052
1053    #[test]
1054    fn round_trips_are_exact_to_rounding() {
1055        let u = energy();
1056        for (a, b) in [("kW.h", "GJ"), ("Cel", "K"), ("bar", "Pa"), ("toe", "MW.h")] {
1057            let x = 123.456;
1058            let back = u.convert(u.convert(x, a, b).unwrap(), b, a).unwrap();
1059            assert!(close(back, x), "{a} ↔ {b}: {back}");
1060        }
1061    }
1062}