Skip to main content

qtty_core/
unit.rs

1// SPDX-License-Identifier: BSD-3-Clause
2// Copyright (C) 2026 Vallés Puig, Ramon
3
4//! Unit types and traits.
5
6use crate::dimension::{DimDiv, DimMul, Dimension};
7use crate::scalar::Scalar;
8use crate::Quantity;
9use core::fmt::{Debug, Display, Formatter, LowerExp, Result, UpperExp};
10use core::marker::PhantomData;
11
12/// Trait implemented by every **unit** type.
13///
14/// * `RATIO` is the conversion factor from this unit to the *canonical scaling unit* of the same dimension.
15///   Example: if metres are canonical (`Meter::RATIO == 1.0`), then kilometres use `Kilometer::RATIO == 1000.0`
16///   because `1 km = 1000 m`.
17///
18/// * `SYMBOL` is the printable string (e.g. `"m"` or `"km"`).
19///   For some composite generic units (such as [`Per`] and [`Prod`]), this is
20///   currently an empty string because Rust does not yet support composing
21///   generic unit symbols as a `const`. Use `Display`/`LowerExp`/`UpperExp` on
22///   [`Quantity`] for user-facing formatting of composite units.
23///
24/// * `Dim` ties the unit to its underlying [`Dimension`].
25///
26/// # Invariants
27///
28/// - Implementations should be zero-sized marker types (this crate's built-in units are unit structs with no fields).
29/// - `RATIO` should be finite and non-zero.
30///
31/// # Conversion precision
32///
33/// `RATIO` is an `f64`, so converting between units always involves an `f64`
34/// multiplication even when the scalar type is "exact" (e.g. `Rational64` or
35/// an integer). This is a deliberate trade-off: the type-level unit system
36/// remains zero-cost and dimensional analysis is exact, but the *numeric*
37/// conversion factor is limited to `f64` precision. Changing this to, say, a
38/// rational ratio would require a core trait redesign because the ratio is a
39/// compile-time constant and Rust's const generics do not yet support
40/// arbitrary-precision types.
41pub trait Unit: Copy + PartialEq + Debug + 'static {
42    /// Unit-to-canonical conversion factor.
43    const RATIO: f64;
44
45    /// Dimension to which this unit belongs.
46    type Dim: Dimension;
47
48    /// Printable symbol, shown by [`core::fmt::Display`].
49    const SYMBOL: &'static str;
50}
51
52/// Unit representing the division of two other units.
53///
54/// `Per<N, D>` corresponds to `N / D` and carries both the
55/// dimensional information and the scaling ratio between the
56/// constituent units. It is generic over any numerator and
57/// denominator units, which allows implementing arithmetic
58/// generically for all pairs without bespoke macros.
59#[derive(Clone, Copy, Debug, PartialEq, PartialOrd)]
60pub struct Per<N: Unit, D: Unit>(PhantomData<(N, D)>);
61
62impl<N: Unit, D: Unit> Unit for Per<N, D>
63where
64    N::Dim: DimDiv<D::Dim>,
65    <N::Dim as DimDiv<D::Dim>>::Output: Dimension,
66{
67    const RATIO: f64 = N::RATIO / D::RATIO;
68    type Dim = <N::Dim as DimDiv<D::Dim>>::Output;
69    // Generic const-string composition is not yet available; formatted symbols
70    // are provided by Display/LowerExp/UpperExp impls for Quantity<Per<...>, S>.
71    const SYMBOL: &'static str = "";
72}
73
74impl<N: Unit, D: Unit, S: Scalar + Display> Display for Quantity<Per<N, D>, S>
75where
76    N::Dim: DimDiv<D::Dim>,
77    <N::Dim as DimDiv<D::Dim>>::Output: Dimension,
78{
79    fn fmt(&self, f: &mut Formatter<'_>) -> Result {
80        Display::fmt(&self.value(), f)?;
81        write!(f, " {}/{}", N::SYMBOL, D::SYMBOL)
82    }
83}
84
85impl<N: Unit, D: Unit, S: Scalar + LowerExp> LowerExp for Quantity<Per<N, D>, S>
86where
87    N::Dim: DimDiv<D::Dim>,
88    <N::Dim as DimDiv<D::Dim>>::Output: Dimension,
89{
90    fn fmt(&self, f: &mut Formatter<'_>) -> Result {
91        LowerExp::fmt(&self.value(), f)?;
92        write!(f, " {}/{}", N::SYMBOL, D::SYMBOL)
93    }
94}
95
96impl<N: Unit, D: Unit, S: Scalar + UpperExp> UpperExp for Quantity<Per<N, D>, S>
97where
98    N::Dim: DimDiv<D::Dim>,
99    <N::Dim as DimDiv<D::Dim>>::Output: Dimension,
100{
101    fn fmt(&self, f: &mut Formatter<'_>) -> Result {
102        UpperExp::fmt(&self.value(), f)?;
103        write!(f, " {}/{}", N::SYMBOL, D::SYMBOL)
104    }
105}
106
107/// Unit representing the product of two other units.
108///
109/// `Prod<A, B>` corresponds to `A * B` and carries both the
110/// dimensional information and the scaling ratio between the
111/// constituent units.
112#[derive(Clone, Copy, Debug, PartialEq, PartialOrd)]
113pub struct Prod<A: Unit, B: Unit>(PhantomData<(A, B)>);
114
115impl<A: Unit, B: Unit> Unit for Prod<A, B>
116where
117    A::Dim: DimMul<B::Dim>,
118    <A::Dim as DimMul<B::Dim>>::Output: Dimension,
119{
120    const RATIO: f64 = A::RATIO * B::RATIO;
121    type Dim = <A::Dim as DimMul<B::Dim>>::Output;
122    // Generic const-string composition is not yet available; formatted symbols
123    // are provided by Display/LowerExp/UpperExp impls for Quantity<Prod<...>, S>.
124    const SYMBOL: &'static str = "";
125}
126
127impl<A: Unit, B: Unit, S: Scalar + Display> Display for Quantity<Prod<A, B>, S>
128where
129    A::Dim: DimMul<B::Dim>,
130    <A::Dim as DimMul<B::Dim>>::Output: Dimension,
131{
132    fn fmt(&self, f: &mut Formatter<'_>) -> Result {
133        Display::fmt(&self.value(), f)?;
134        write!(f, " {}·{}", A::SYMBOL, B::SYMBOL)
135    }
136}
137
138impl<A: Unit, B: Unit, S: Scalar + LowerExp> LowerExp for Quantity<Prod<A, B>, S>
139where
140    A::Dim: DimMul<B::Dim>,
141    <A::Dim as DimMul<B::Dim>>::Output: Dimension,
142{
143    fn fmt(&self, f: &mut Formatter<'_>) -> Result {
144        LowerExp::fmt(&self.value(), f)?;
145        write!(f, " {}·{}", A::SYMBOL, B::SYMBOL)
146    }
147}
148
149impl<A: Unit, B: Unit, S: Scalar + UpperExp> UpperExp for Quantity<Prod<A, B>, S>
150where
151    A::Dim: DimMul<B::Dim>,
152    <A::Dim as DimMul<B::Dim>>::Output: Dimension,
153{
154    fn fmt(&self, f: &mut Formatter<'_>) -> Result {
155        UpperExp::fmt(&self.value(), f)?;
156        write!(f, " {}·{}", A::SYMBOL, B::SYMBOL)
157    }
158}
159
160#[cfg(all(test, feature = "std"))]
161mod tests {
162    use super::*;
163    use crate::units::length::{Kilometer, Meter};
164    use crate::units::time::Second;
165    use crate::Quantity;
166
167    // ── Per: Display, LowerExp, UpperExp ──────────────────────────────────────
168
169    #[test]
170    fn per_display_formats_value_and_symbol() {
171        // 10 m/s with Display
172        let qty: Quantity<Per<Meter, Second>> = Quantity::new(10.0);
173        let s = format!("{qty}");
174        assert_eq!(s, "10 m/s");
175    }
176
177    #[test]
178    fn per_display_with_precision() {
179        let qty: Quantity<Per<Meter, Second>> = Quantity::new(1.5);
180        let s = format!("{qty:.2}");
181        assert_eq!(s, "1.50 m/s");
182    }
183
184    #[test]
185    fn per_lower_exp_formats_correctly() {
186        let qty: Quantity<Per<Meter, Second>> = Quantity::new(1000.0);
187        let s = format!("{qty:e}");
188        assert!(s.contains("e"), "Expected scientific notation, got: {s}");
189        assert!(s.ends_with("m/s"), "Expected 'm/s' suffix, got: {s}");
190    }
191
192    #[test]
193    fn per_upper_exp_formats_correctly() {
194        let qty: Quantity<Per<Meter, Second>> = Quantity::new(1000.0);
195        let s = format!("{qty:E}");
196        assert!(s.contains("E"), "Expected uppercase-E notation, got: {s}");
197        assert!(s.ends_with("m/s"), "Expected 'm/s' suffix, got: {s}");
198    }
199
200    // ── Prod: Display, LowerExp, UpperExp ─────────────────────────────────────
201
202    #[test]
203    fn prod_display_formats_value_and_symbol() {
204        let qty: Quantity<Prod<Meter, Second>> = Quantity::new(3.0);
205        let s = format!("{qty}");
206        assert_eq!(s, "3 m·s");
207    }
208
209    #[test]
210    fn prod_display_with_precision() {
211        let qty: Quantity<Prod<Meter, Second>> = Quantity::new(2.5);
212        let s = format!("{qty:.3}");
213        assert_eq!(s, "2.500 m·s");
214    }
215
216    #[test]
217    fn prod_lower_exp_formats_correctly() {
218        let qty: Quantity<Prod<Kilometer, Second>> = Quantity::new(5000.0);
219        let s = format!("{qty:.2e}");
220        assert!(s.contains("e"), "Expected scientific notation, got: {s}");
221        assert!(s.ends_with("km·s"), "Expected 'km·s' suffix, got: {s}");
222    }
223
224    #[test]
225    fn prod_upper_exp_formats_correctly() {
226        let qty: Quantity<Prod<Kilometer, Second>> = Quantity::new(5000.0);
227        let s = format!("{qty:.2E}");
228        assert!(s.contains("E"), "Expected uppercase-E notation, got: {s}");
229        assert!(s.ends_with("km·s"), "Expected 'km·s' suffix, got: {s}");
230    }
231
232    // ── Unitless: LowerExp, UpperExp ──────────────────────────────────────────
233    // (tests removed — Unitless is no longer a type; same-unit division returns S)
234}