Skip to main content

dimvexa_core/
unit.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Unit definitions (static data and owned form) and resolved unit expressions.
3
4use core::fmt;
5
6use crate::dim::Dim;
7use crate::error::{Error, Result};
8use crate::expr::{Expr, is_valid_symbol};
9use crate::kind::{Exactness, Kind, Shape};
10use crate::prefix::PrefixMode;
11use crate::ratio::Ratio;
12
13/// A unit as it appears in a compiled data table (plain data, `const`-constructible).
14///
15/// Fractions are `(numerator, denominator)` pairs; the registry validates and reduces them
16/// when loading.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub struct UnitDef {
19    /// Primary symbol, for example `psi`.
20    pub symbol: &'static str,
21    /// Alternative symbols, for example `["\u{b0}C"]` for `degC`.
22    pub aliases: &'static [&'static str],
23    /// Dimension exponents of `[kg, m, s, A, K, mol, cd]`.
24    pub dim: [i8; 7],
25    /// Quantity kind.
26    pub kind: Kind,
27    /// Conversion shape to the coherent SI unit.
28    pub shape: Shape,
29    /// Factor `a` in `si = a * x + b`, as `(numerator, denominator)`.
30    pub factor: (i128, i128),
31    /// Offset `b` in `si = a * x + b`, as `(numerator, denominator)`.
32    pub offset: (i128, i128),
33    /// Defined or measured.
34    pub exactness: Exactness,
35    /// Accepted prefixes.
36    pub prefixes: PrefixMode,
37    /// Namespace, `si` or `std` for the core table.
38    pub namespace: &'static str,
39    /// Citation of the primary definition.
40    pub source: &'static str,
41    /// Free-text note (derivation, caveats).
42    pub note: &'static str,
43}
44
45/// A bare symbol that is deliberately rejected, with the explicit symbols to use instead.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub struct AmbiguousDef {
48    /// The ambiguous bare symbol, for example `ton`.
49    pub symbol: &'static str,
50    /// Explicit unit symbols present in the table.
51    pub candidates: &'static [&'static str],
52    /// Explicit symbols that are planned but not yet in the table.
53    pub planned: &'static [&'static str],
54    /// Why the symbol is ambiguous.
55    pub note: &'static str,
56}
57
58/// An owned unit definition held by a [`crate::Registry`].
59///
60/// `si_value = factor * x + offset`, where `x` is the value in this unit.
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct Unit {
63    symbol: String,
64    aliases: Vec<String>,
65    dim: Dim,
66    kind: Kind,
67    factor: Ratio,
68    offset: Ratio,
69    exactness: Exactness,
70    prefixes: PrefixMode,
71    namespace: String,
72    source: String,
73    note: String,
74}
75
76impl Unit {
77    /// A linear unit: `si = factor * x`, defined exactly, no prefixes, namespace `std`.
78    ///
79    /// # Errors
80    ///
81    /// [`Error::InvalidDefinition`] for a malformed symbol or a non-positive factor.
82    ///
83    /// # Examples
84    ///
85    /// ```
86    /// use dimvexa_core::{Dim, Ratio, Unit};
87    /// let inch = Unit::linear("in", Dim::M, Ratio::new(127, 5000)?)?;
88    /// assert_eq!(inch.symbol(), "in");
89    /// # Ok::<(), dimvexa_core::Error>(())
90    /// ```
91    pub fn linear(symbol: &str, dim: Dim, factor: Ratio) -> Result<Self> {
92        Self::new(symbol, dim, Kind::Plain, factor, Ratio::ZERO)
93    }
94
95    /// A general unit with an offset (affine when `offset` is non-zero).
96    ///
97    /// # Errors
98    ///
99    /// [`Error::InvalidDefinition`] for a malformed symbol, a non-positive factor, or an
100    /// offset on a unit that is not a temperature.
101    pub fn new(symbol: &str, dim: Dim, kind: Kind, factor: Ratio, offset: Ratio) -> Result<Self> {
102        let bad = |message: &str| Error::InvalidDefinition {
103            symbol: symbol.to_string(),
104            message: message.to_string(),
105        };
106        if !is_valid_symbol(symbol) {
107            return Err(bad(
108                "symbol must be non-empty and contain only letters, '_' or the degree sign",
109            ));
110        }
111        if factor.signum() <= 0 {
112            return Err(bad("factor must be positive"));
113        }
114        if !offset.is_zero() && kind != Kind::AbsoluteTemperature {
115            return Err(bad("only absolute temperature units may have an offset"));
116        }
117        Ok(Self {
118            symbol: symbol.to_string(),
119            aliases: Vec::new(),
120            dim,
121            kind,
122            factor,
123            offset,
124            exactness: Exactness::Defined,
125            prefixes: PrefixMode::None,
126            namespace: "std".to_string(),
127            source: String::new(),
128            note: String::new(),
129        })
130    }
131
132    /// Validates and converts a static table row.
133    ///
134    /// # Errors
135    ///
136    /// [`Error::InvalidDefinition`] when the row is inconsistent (shape/offset mismatch,
137    /// bad fraction, bad symbol or alias), or [`Error::DivisionByZero`] for a zero denominator.
138    pub fn from_def(def: &UnitDef) -> Result<Self> {
139        let factor = Ratio::new(def.factor.0, def.factor.1)?;
140        let offset = Ratio::new(def.offset.0, def.offset.1)?;
141        let mut unit = Self::new(def.symbol, Dim::new(def.dim), def.kind, factor, offset)?;
142        let bad = |message: &str| Error::InvalidDefinition {
143            symbol: def.symbol.to_string(),
144            message: message.to_string(),
145        };
146        match (def.shape, offset.is_zero()) {
147            (Shape::Linear, false) => return Err(bad("linear shape requires a zero offset")),
148            (Shape::Affine, true) => return Err(bad("affine shape requires a non-zero offset")),
149            _ => {}
150        }
151        if !offset.is_zero() && def.prefixes != PrefixMode::None {
152            return Err(bad("affine units cannot take prefixes"));
153        }
154        for alias in def.aliases {
155            if !is_valid_symbol(alias) {
156                return Err(bad(
157                    "alias contains characters that cannot appear in a symbol",
158                ));
159            }
160            unit.aliases.push((*alias).to_string());
161        }
162        unit.exactness = def.exactness;
163        unit.prefixes = def.prefixes;
164        unit.namespace = def.namespace.to_string();
165        unit.source = def.source.to_string();
166        unit.note = def.note.to_string();
167        Ok(unit)
168    }
169
170    /// Adds aliases (alternative symbols).
171    ///
172    /// # Errors
173    ///
174    /// [`Error::InvalidDefinition`] if an alias is not a well-formed symbol.
175    pub fn with_aliases(mut self, aliases: &[&str]) -> Result<Self> {
176        for a in aliases {
177            if !is_valid_symbol(a) {
178                return Err(Error::InvalidDefinition {
179                    symbol: self.symbol,
180                    message: format!("alias '{a}' is not a well-formed symbol"),
181                });
182            }
183            self.aliases.push((*a).to_string());
184        }
185        Ok(self)
186    }
187
188    /// Sets the accepted prefixes (ignored for units with an offset, which never take any).
189    #[must_use]
190    pub const fn with_prefixes(mut self, prefixes: PrefixMode) -> Self {
191        if self.offset.is_zero() {
192            self.prefixes = prefixes;
193        }
194        self
195    }
196
197    /// Sets exactness, source and note.
198    #[must_use]
199    pub fn with_provenance(mut self, exactness: Exactness, source: &str, note: &str) -> Self {
200        self.exactness = exactness;
201        self.source = source.to_string();
202        self.note = note.to_string();
203        self
204    }
205
206    /// Sets the namespace.
207    #[must_use]
208    pub fn with_namespace(mut self, namespace: &str) -> Self {
209        self.namespace = namespace.to_string();
210        self
211    }
212
213    /// Primary symbol.
214    #[must_use]
215    pub fn symbol(&self) -> &str {
216        &self.symbol
217    }
218
219    /// Alternative symbols.
220    #[must_use]
221    pub fn aliases(&self) -> &[String] {
222        &self.aliases
223    }
224
225    /// Dimension.
226    #[must_use]
227    pub const fn dim(&self) -> Dim {
228        self.dim
229    }
230
231    /// Quantity kind.
232    #[must_use]
233    pub const fn kind(&self) -> Kind {
234        self.kind
235    }
236
237    /// Conversion shape.
238    #[must_use]
239    pub const fn shape(&self) -> Shape {
240        if self.offset.is_zero() {
241            Shape::Linear
242        } else {
243            Shape::Affine
244        }
245    }
246
247    /// Factor to the coherent SI unit.
248    #[must_use]
249    pub const fn factor(&self) -> Ratio {
250        self.factor
251    }
252
253    /// Offset to the coherent SI unit.
254    #[must_use]
255    pub const fn offset(&self) -> Ratio {
256        self.offset
257    }
258
259    /// Defined or measured.
260    #[must_use]
261    pub const fn exactness(&self) -> Exactness {
262        self.exactness
263    }
264
265    /// Accepted prefixes.
266    #[must_use]
267    pub const fn prefixes(&self) -> PrefixMode {
268        self.prefixes
269    }
270
271    /// Namespace.
272    #[must_use]
273    pub fn namespace(&self) -> &str {
274        &self.namespace
275    }
276
277    /// Citation of the definition.
278    #[must_use]
279    pub fn source(&self) -> &str {
280        &self.source
281    }
282
283    /// Note.
284    #[must_use]
285    pub fn note(&self) -> &str {
286        &self.note
287    }
288}
289
290/// A resolved unit expression such as `km/h` or `kW.h`: dimension, kind and the exact
291/// relation to the coherent SI unit, composed once.
292///
293/// `si_value = factor * x + offset`. Only a single (possibly prefixed) unit can have a
294/// non-zero offset; compound expressions are linear.
295#[derive(Debug, Clone, PartialEq, Eq)]
296pub struct UnitExpr {
297    expr: Expr,
298    dim: Dim,
299    kind: Kind,
300    factor: Ratio,
301    offset: Ratio,
302    exactness: Exactness,
303    sources: Vec<String>,
304    /// Symbol of the registered temperature-difference unit of the same scale (`delta_K`
305    /// for `K`), only set for a plain absolute temperature unit.
306    difference: Option<String>,
307}
308
309impl UnitExpr {
310    /// The unit `1` (dimensionless, factor one).
311    #[must_use]
312    pub const fn unity() -> Self {
313        Self {
314            expr: Expr::One,
315            dim: Dim::NONE,
316            kind: Kind::Plain,
317            factor: Ratio::ONE,
318            offset: Ratio::ZERO,
319            exactness: Exactness::Defined,
320            sources: Vec::new(),
321            difference: None,
322        }
323    }
324
325    pub(crate) const fn from_parts(
326        expr: Expr,
327        dim: Dim,
328        kind: Kind,
329        factor: Ratio,
330        offset: Ratio,
331        exactness: Exactness,
332        sources: Vec<String>,
333    ) -> Self {
334        Self {
335            expr,
336            dim,
337            kind,
338            factor,
339            offset,
340            exactness,
341            sources,
342            difference: None,
343        }
344    }
345
346    pub(crate) fn with_difference(mut self, difference: Option<String>) -> Self {
347        self.difference = difference;
348        self
349    }
350
351    /// Symbol of the registered temperature-difference unit that goes with this absolute
352    /// temperature unit (`delta_K` for `K`, `delta_degC` for `degC`). `None` for every other
353    /// unit expression, including prefixed temperatures such as `mK`.
354    #[must_use]
355    pub fn difference_symbol(&self) -> Option<&str> {
356        self.difference.as_deref()
357    }
358
359    /// True for the plain scalar `1`.
360    #[must_use]
361    pub fn is_unity(&self) -> bool {
362        self.dim.is_dimensionless()
363            && self.kind == Kind::Plain
364            && self.factor == Ratio::ONE
365            && self.offset.is_zero()
366    }
367
368    /// The syntax tree this expression was resolved from.
369    #[must_use]
370    pub const fn expr(&self) -> &Expr {
371        &self.expr
372    }
373
374    /// Dimension.
375    #[must_use]
376    pub const fn dim(&self) -> Dim {
377        self.dim
378    }
379
380    /// Kind.
381    #[must_use]
382    pub const fn kind(&self) -> Kind {
383        self.kind
384    }
385
386    /// Exact factor to the coherent SI unit.
387    #[must_use]
388    pub const fn factor(&self) -> Ratio {
389        self.factor
390    }
391
392    /// Exact offset to the coherent SI unit.
393    #[must_use]
394    pub const fn offset(&self) -> Ratio {
395        self.offset
396    }
397
398    /// Conversion shape relative to SI.
399    #[must_use]
400    pub const fn shape(&self) -> Shape {
401        if self.offset.is_zero() {
402            Shape::Linear
403        } else {
404            Shape::Affine
405        }
406    }
407
408    /// Defined, or measured if any component is measured.
409    #[must_use]
410    pub const fn exactness(&self) -> Exactness {
411        self.exactness
412    }
413
414    /// Distinct source citations of all components, in order of first use.
415    #[must_use]
416    pub fn sources(&self) -> &[String] {
417        &self.sources
418    }
419
420    fn merged_sources(&self, other: &Self) -> Vec<String> {
421        let mut out = self.sources.clone();
422        for s in &other.sources {
423            if !out.contains(s) {
424                out.push(s.clone());
425            }
426        }
427        out
428    }
429
430    fn refuse_affine(&self, other: &Self) -> Result<()> {
431        for u in [self, other] {
432            if !u.offset.is_zero() {
433                let suggestions: Vec<String> = u.difference.iter().cloned().collect();
434                let advice = suggestions.first().map_or_else(
435                    || {
436                        String::from(
437                            "use a difference unit (delta_...) for a temperature difference",
438                        )
439                    },
440                    |d| format!("use the difference unit {d} for a temperature difference"),
441                );
442                return Err(Error::Refused {
443                    reason: format!(
444                        "'{}' has an offset (temperature scale) and cannot be part of a compound unit; {advice}",
445                        u.expr
446                    ),
447                    suggestions,
448                });
449            }
450        }
451        Ok(())
452    }
453
454    /// Product of two unit expressions. Multiplying by the plain scalar `1` returns the
455    /// other operand unchanged.
456    ///
457    /// # Errors
458    ///
459    /// [`Error::Refused`] if an operand has an offset, [`Error::Overflow`] on overflow.
460    pub fn mul(&self, rhs: &Self) -> Result<Self> {
461        if self.is_unity() {
462            return Ok(rhs.clone());
463        }
464        if rhs.is_unity() {
465            return Ok(self.clone());
466        }
467        self.refuse_affine(rhs)?;
468        Ok(Self {
469            expr: Expr::Mul(Box::new(self.expr.clone()), Box::new(rhs.expr.clone())),
470            dim: self.dim.checked_add(rhs.dim)?,
471            kind: Kind::Plain,
472            factor: self.factor.checked_mul(rhs.factor)?,
473            offset: Ratio::ZERO,
474            exactness: self.exactness.combine(rhs.exactness),
475            sources: self.merged_sources(rhs),
476            difference: None,
477        })
478    }
479
480    /// Quotient of two unit expressions. Dividing by the plain scalar `1` returns `self`.
481    ///
482    /// # Errors
483    ///
484    /// [`Error::Refused`] if an operand has an offset, [`Error::Overflow`] on overflow.
485    pub fn div(&self, rhs: &Self) -> Result<Self> {
486        if rhs.is_unity() {
487            return Ok(self.clone());
488        }
489        self.refuse_affine(rhs)?;
490        Ok(Self {
491            expr: Expr::Div(Box::new(self.expr.clone()), Box::new(rhs.expr.clone())),
492            dim: self.dim.checked_sub(rhs.dim)?,
493            kind: Kind::Plain,
494            factor: self.factor.checked_div(rhs.factor)?,
495            offset: Ratio::ZERO,
496            exactness: self.exactness.combine(rhs.exactness),
497            sources: self.merged_sources(rhs),
498            difference: None,
499        })
500    }
501
502    /// Integer power. The power 1 returns `self` unchanged (so `degC^1` stays valid), the
503    /// power 0 returns the scalar `1`.
504    ///
505    /// # Errors
506    ///
507    /// [`Error::Refused`] for an offset unit with a power other than 1,
508    /// [`Error::Overflow`] on overflow.
509    pub fn pow(&self, exp: i32) -> Result<Self> {
510        if exp == 1 {
511            return Ok(self.clone());
512        }
513        self.refuse_affine(&Self::unity())?;
514        Ok(Self {
515            expr: Expr::Pow(Box::new(self.expr.clone()), exp),
516            dim: self.dim.checked_scale(exp)?,
517            kind: Kind::Plain,
518            factor: self.factor.checked_pow(exp)?,
519            offset: Ratio::ZERO,
520            exactness: self.exactness,
521            sources: self.sources.clone(),
522            difference: None,
523        })
524    }
525}
526
527impl fmt::Display for UnitExpr {
528    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
529        self.expr.fmt(f)
530    }
531}
532
533#[cfg(test)]
534mod tests {
535    use super::*;
536
537    fn m() -> UnitExpr {
538        UnitExpr::from_parts(
539            Expr::Unit("m".into()),
540            Dim::M,
541            Kind::Plain,
542            Ratio::ONE,
543            Ratio::ZERO,
544            Exactness::Defined,
545            vec!["SI".into()],
546        )
547    }
548
549    #[test]
550    fn unity_is_identity_for_mul_and_div() {
551        let u = UnitExpr::unity();
552        assert_eq!(u.mul(&m()).unwrap(), m());
553        assert_eq!(m().mul(&u).unwrap(), m());
554        assert_eq!(m().div(&u).unwrap(), m());
555        assert!(m().pow(0).unwrap().is_unity() || m().pow(0).unwrap().dim().is_dimensionless());
556    }
557
558    #[test]
559    fn products_compose_exactly() {
560        let sq = m().mul(&m()).unwrap();
561        assert_eq!(sq.dim(), Dim::new([0, 2, 0, 0, 0, 0, 0]));
562        assert_eq!(sq.to_string(), "m.m");
563        let inv = UnitExpr::unity().div(&m()).unwrap();
564        assert_eq!(inv.dim(), Dim::new([0, -1, 0, 0, 0, 0, 0]));
565        assert_eq!(inv.sources(), ["SI"]);
566    }
567
568    #[test]
569    fn rejects_bad_definitions() {
570        assert!(Unit::linear("", Dim::M, Ratio::ONE).is_err());
571        assert!(Unit::linear("m2", Dim::M, Ratio::ONE).is_err());
572        assert!(Unit::linear("m", Dim::M, Ratio::ZERO).is_err());
573        assert!(Unit::linear("m", Dim::M, Ratio::from_int(-1)).is_err());
574        assert!(Unit::new("x", Dim::M, Kind::Plain, Ratio::ONE, Ratio::ONE).is_err());
575        assert!(
576            Unit::linear("m", Dim::M, Ratio::ONE)
577                .unwrap()
578                .with_aliases(&["a b"])
579                .is_err()
580        );
581    }
582
583    #[test]
584    fn from_def_checks_shape() {
585        let mut d = UnitDef {
586            symbol: "x",
587            aliases: &[],
588            dim: [0, 1, 0, 0, 0, 0, 0],
589            kind: Kind::Plain,
590            shape: Shape::Linear,
591            factor: (2, 4),
592            offset: (0, 1),
593            exactness: Exactness::Defined,
594            prefixes: PrefixMode::Si,
595            namespace: "std",
596            source: "test",
597            note: "",
598        };
599        assert_eq!(
600            Unit::from_def(&d).unwrap().factor(),
601            Ratio::new(1, 2).unwrap()
602        );
603        d.shape = Shape::Affine;
604        assert!(Unit::from_def(&d).is_err());
605        d.shape = Shape::Linear;
606        d.offset = (1, 1);
607        assert!(Unit::from_def(&d).is_err());
608        d.factor = (1, 0);
609        assert!(Unit::from_def(&d).is_err());
610    }
611}