Skip to main content

pantometry_units/
lib.rs

1//! Dimensional analysis: physical quantities that refuse to be added wrongly.
2//!
3//! ```
4//! use pantometry_units::{Area, Energy, Length, Mass, Power, SpecificHeat, Temperature, Time};
5//!
6//! // A unit-bearing constructor is the only place a factor of a thousand may appear.
7//! let side = Length::mm(10.0);
8//! let area: Area = side * side;                       // the dimension follows the product
9//! assert!((area.to_si() - 1e-4).abs() < 1e-18);
10//!
11//! // Absorbed power over a time is an energy, and the type says so without being told.
12//! let absorbed = Power::mw(96.0);
13//! let heat: Energy = absorbed * Time::s(1.0);
14//!
15//! // Divide it by a heat capacity and a temperature comes out.
16//! let capacity = Mass::g(2.0) * SpecificHeat::j_per_kg_k(858.0);
17//! let rise: Temperature = heat / capacity;
18//! assert!((rise.to_si() - 0.05594).abs() < 1e-4);
19//! ```
20//!
21//! And the mistake the whole crate exists to prevent does not compile:
22//!
23//! ```compile_fail
24//! use pantometry_units::{Length, Time};
25//! let nonsense = Length::mm(3.0) + Time::s(1.0);
26//! ```
27//!
28//! One domain can get away with a convention. `pantometry-core` began as optics and
29//! said "millimetres, nanometres and seconds, everywhere" in a doc comment, and
30//! that held because every number in the crate was a length, a wavelength or a
31//! fraction. It stops holding the moment a second domain arrives: a kelvin, a
32//! newton and a watt are all `f64`, they all add, and the compiler and the tests
33//! both stay green while the physics goes wrong.
34//!
35//! So dimension lives in the type. [`Qty`] carries the seven SI base exponents as
36//! const generic parameters, which makes `Length + Time` a compile error and
37//! `Force * Length` an [`Energy`] — and costs nothing at runtime, since a `Qty`
38//! is an `f64` and every operation on it is the `f64` operation.
39//!
40//! # Storage is always SI base units
41//!
42//! A `Qty` holds metres, kilograms, seconds, amperes, kelvin, moles, candela —
43//! never millimetres, never nanometres. Those are *entry and exit* forms:
44//!
45//! ```
46//! use pantometry_units::{Length, Time, Velocity};
47//!
48//! let d = Length::mm(120.0);
49//! let t = Time::ms(4.0);
50//! let v: Velocity = d / t;
51//! assert!((v.to_si() - 30.0).abs() < 1e-12);   // 30 m/s
52//! assert!((d.in_nm() - 1.2e8).abs() < 1.0);
53//! ```
54//!
55//! That way there is exactly one representation to reason about, and the
56//! unit-bearing constructors are the only place a factor of 1000 can hide.
57//!
58//! # What this cannot do
59//!
60//! **Angles are dimensionless**, so [`Frequency`] and an angular velocity are the
61//! same type — SI says radians are m/m, and no dimensional system can separate
62//! them. Same for torque and energy. Where that distinction matters, it has to be
63//! carried by a newtype in the domain crate, not here.
64//!
65//! **Only declared products compose.** `Length * Length` is an [`Area`] because
66//! that pair is written down below. Deriving arbitrary products would need
67//! arithmetic on const generic parameters, which is unstable, so the alternative
68//! to a declared list is a dependency on `uom`. The list is cheap to extend, and
69//! anything undeclared can always go through [`Qty::from_si`].
70
71// Every public item carries a doc comment. Denied rather than warned: a public physics API
72// whose `Length::mm` shows a blank summary in rustdoc is documented in the sense that a
73// paragraph exists somewhere, and not in the sense a reader needs.
74#![deny(missing_docs)]
75#![forbid(unsafe_code)]
76
77use core::fmt;
78use core::ops::{Add, AddAssign, Div, Mul, Neg, Sub, SubAssign};
79
80use serde::{Deserialize, Deserializer, Serialize, Serializer};
81
82pub mod vector;
83pub use vector::{AccelerationVec, ForceVec, LengthVec, MomentumVec, QVec3, VelocityVec};
84
85/// A quantity, with the seven SI base dimensions in its type.
86///
87/// The parameters are the exponents of metre, kilogram, second, ampere, kelvin,
88/// mole and candela, in that order, so a velocity (m·s⁻¹) is `Qty<1,0,-1,0,0,0,0>`
89/// — which is what [`Velocity`] names.
90///
91/// Addition, subtraction, negation, comparison and scaling by a plain `f64` work
92/// for every dimension. Multiplication and division between two quantities work
93/// for the pairs declared in this module.
94#[derive(Clone, Copy, PartialEq, PartialOrd, Default)]
95pub struct Qty<
96    const L: i8,
97    const M: i8,
98    const T: i8,
99    const I: i8,
100    const K: i8,
101    const N: i8,
102    const J: i8,
103>(f64);
104
105impl<const L: i8, const M: i8, const T: i8, const I: i8, const K: i8, const N: i8, const J: i8>
106    Qty<L, M, T, I, K, N, J>
107{
108    /// Zero, which is the one value every dimension shares.
109    pub const ZERO: Self = Qty(0.0);
110
111    /// Wrap a number already in SI base units. The escape hatch: use it when a
112    /// dimension has no name here, and name it if you use it twice.
113    ///
114    /// `const`, so a dimensioned constant can be written without a lazy static.
115    pub const fn from_si(value: f64) -> Self {
116        Qty(value)
117    }
118
119    /// The value in SI base units.
120    pub const fn to_si(self) -> f64 {
121        self.0
122    }
123
124    /// The seven exponents, for diagnostics and for a runtime dimension check at
125    /// a boundary the type system does not cross (deserialisation, FFI).
126    pub const fn dimension() -> [i8; 7] {
127        [L, M, T, I, K, N, J]
128    }
129
130    /// Magnitude without its sign, in the same dimension.
131    pub fn abs(self) -> Self {
132        Qty(self.0.abs())
133    }
134
135    /// The smaller of two quantities of the same dimension.
136    pub fn min(self, other: Self) -> Self {
137        Qty(self.0.min(other.0))
138    }
139
140    /// The larger of two quantities of the same dimension.
141    pub fn max(self, other: Self) -> Self {
142        Qty(self.0.max(other.0))
143    }
144
145    /// Whether the magnitude is neither infinite nor NaN.
146    ///
147    /// Worth checking where a limit is reported rather than computed: several methods here
148    /// return an infinity to mean "no limit", which is honest but arithmetic on it is not.
149    pub fn is_finite(self) -> bool {
150        self.0.is_finite()
151    }
152
153    /// Sign of the magnitude, as a plain number — a sign has no dimension.
154    pub fn signum(self) -> f64 {
155        self.0.signum()
156    }
157
158    /// Linear interpolation, which stays within the dimension.
159    pub fn lerp(self, other: Self, t: f64) -> Self {
160        Qty(self.0 + (other.0 - self.0) * t)
161    }
162}
163
164// ---------------------------------------------------------------------------
165// Dimension-preserving arithmetic: works for every dimension at once, because
166// none of it changes the exponents.
167// ---------------------------------------------------------------------------
168
169macro_rules! generic_op {
170    ($trait:ident, $method:ident, $op:tt) => {
171        impl<
172                const L: i8,
173                const M: i8,
174                const T: i8,
175                const I: i8,
176                const K: i8,
177                const N: i8,
178                const J: i8,
179            > $trait for Qty<L, M, T, I, K, N, J>
180        {
181            type Output = Self;
182            fn $method(self, rhs: Self) -> Self {
183                Qty(self.0 $op rhs.0)
184            }
185        }
186    };
187}
188
189generic_op!(Add, add, +);
190generic_op!(Sub, sub, -);
191
192impl<const L: i8, const M: i8, const T: i8, const I: i8, const K: i8, const N: i8, const J: i8>
193    AddAssign for Qty<L, M, T, I, K, N, J>
194{
195    fn add_assign(&mut self, rhs: Self) {
196        self.0 += rhs.0;
197    }
198}
199
200impl<const L: i8, const M: i8, const T: i8, const I: i8, const K: i8, const N: i8, const J: i8>
201    SubAssign for Qty<L, M, T, I, K, N, J>
202{
203    fn sub_assign(&mut self, rhs: Self) {
204        self.0 -= rhs.0;
205    }
206}
207
208impl<const L: i8, const M: i8, const T: i8, const I: i8, const K: i8, const N: i8, const J: i8> Neg
209    for Qty<L, M, T, I, K, N, J>
210{
211    type Output = Self;
212    fn neg(self) -> Self {
213        Qty(-self.0)
214    }
215}
216
217impl<const L: i8, const M: i8, const T: i8, const I: i8, const K: i8, const N: i8, const J: i8>
218    Mul<f64> for Qty<L, M, T, I, K, N, J>
219{
220    type Output = Self;
221    fn mul(self, k: f64) -> Self {
222        Qty(self.0 * k)
223    }
224}
225
226impl<const L: i8, const M: i8, const T: i8, const I: i8, const K: i8, const N: i8, const J: i8>
227    Div<f64> for Qty<L, M, T, I, K, N, J>
228{
229    type Output = Self;
230    fn div(self, k: f64) -> Self {
231        Qty(self.0 / k)
232    }
233}
234
235impl<const L: i8, const M: i8, const T: i8, const I: i8, const K: i8, const N: i8, const J: i8>
236    Mul<Qty<L, M, T, I, K, N, J>> for f64
237{
238    type Output = Qty<L, M, T, I, K, N, J>;
239    fn mul(self, q: Qty<L, M, T, I, K, N, J>) -> Qty<L, M, T, I, K, N, J> {
240        Qty(self * q.0)
241    }
242}
243
244/// Dividing two quantities of the *same* dimension gives a plain number — which
245/// is the one product rule that needs no exponent arithmetic, and the one every
246/// tolerance check uses.
247impl<const L: i8, const M: i8, const T: i8, const I: i8, const K: i8, const N: i8, const J: i8> Div
248    for Qty<L, M, T, I, K, N, J>
249{
250    type Output = f64;
251    fn div(self, rhs: Self) -> f64 {
252        self.0 / rhs.0
253    }
254}
255
256impl<const L: i8, const M: i8, const T: i8, const I: i8, const K: i8, const N: i8, const J: i8>
257    fmt::Debug for Qty<L, M, T, I, K, N, J>
258{
259    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
260        write!(f, "{}", self.0)?;
261        for (symbol, exponent) in [
262            ("m", L),
263            ("kg", M),
264            ("s", T),
265            ("A", I),
266            ("K", K),
267            ("mol", N),
268            ("cd", J),
269        ] {
270            match exponent {
271                0 => {}
272                1 => write!(f, "·{symbol}")?,
273                e => write!(f, "·{symbol}^{e}")?,
274            }
275        }
276        Ok(())
277    }
278}
279
280impl<const L: i8, const M: i8, const T: i8, const I: i8, const K: i8, const N: i8, const J: i8>
281    fmt::Display for Qty<L, M, T, I, K, N, J>
282{
283    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
284        fmt::Debug::fmt(self, f)
285    }
286}
287
288// Serialised as the bare SI number: a scene file stays readable, and the
289// dimension is carried by the field's type rather than repeated in the data.
290impl<const L: i8, const M: i8, const T: i8, const I: i8, const K: i8, const N: i8, const J: i8>
291    Serialize for Qty<L, M, T, I, K, N, J>
292{
293    fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
294        self.0.serialize(s)
295    }
296}
297
298impl<
299        'de,
300        const L: i8,
301        const M: i8,
302        const T: i8,
303        const I: i8,
304        const K: i8,
305        const N: i8,
306        const J: i8,
307    > Deserialize<'de> for Qty<L, M, T, I, K, N, J>
308{
309    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
310        f64::deserialize(d).map(Qty)
311    }
312}
313
314// ---------------------------------------------------------------------------
315// The dimensions themselves.
316// ---------------------------------------------------------------------------
317
318/// A pure ratio: reflectance, duty cycle, refractive index, Strehl.
319pub type Dimensionless = Qty<0, 0, 0, 0, 0, 0, 0>;
320
321/// Metres.
322pub type Length = Qty<1, 0, 0, 0, 0, 0, 0>;
323/// Kilograms.
324pub type Mass = Qty<0, 1, 0, 0, 0, 0, 0>;
325/// Seconds.
326pub type Time = Qty<0, 0, 1, 0, 0, 0, 0>;
327/// Amperes.
328pub type Current = Qty<0, 0, 0, 1, 0, 0, 0>;
329/// Absolute temperature. Kelvin only — see [`Temperature::celsius`].
330pub type Temperature = Qty<0, 0, 0, 0, 1, 0, 0>;
331/// Moles.
332pub type Amount = Qty<0, 0, 0, 0, 0, 1, 0>;
333/// Candelas.
334pub type LuminousIntensity = Qty<0, 0, 0, 0, 0, 0, 1>;
335
336/// Square metres.
337pub type Area = Qty<2, 0, 0, 0, 0, 0, 0>;
338/// Cubic metres.
339pub type Volume = Qty<3, 0, 0, 0, 0, 0, 0>;
340/// Metres per second.
341pub type Velocity = Qty<1, 0, -1, 0, 0, 0, 0>;
342/// Metres per second squared.
343pub type Acceleration = Qty<1, 0, -2, 0, 0, 0, 0>;
344/// kg·m·s⁻¹ — mass times velocity, and the thing a closed system conserves
345/// exactly rather than nearly.
346pub type Momentum = Qty<1, 1, -1, 0, 0, 0, 0>;
347/// Newtons.
348pub type Force = Qty<1, 1, -2, 0, 0, 0, 0>;
349/// Pascals. Also the unit of an energy density and of a stress, which are the same
350/// dimension and not a coincidence.
351pub type Pressure = Qty<-1, 1, -2, 0, 0, 0, 0>;
352/// Joules.
353pub type Energy = Qty<2, 1, -2, 0, 0, 0, 0>;
354/// Watts.
355pub type Power = Qty<2, 1, -3, 0, 0, 0, 0>;
356/// kg·m⁻³. Note that a glass catalogue quotes g/cm³, a factor of a thousand away —
357/// see [`Density::g_per_cm3`].
358pub type Density = Qty<-3, 1, 0, 0, 0, 0, 0>;
359/// Pa·s — dynamic viscosity, the `μ` of Darcy's law and of Stokes drag.
360///
361/// Kinematic viscosity is this over a [`Density`] and is a [`Diffusivity`]; the two are
362/// routinely confused in tables and differ by three orders of magnitude for water, which is
363/// exactly the sort of error a dimension check cannot catch and a name can.
364pub type DynamicViscosity = Qty<-1, 1, -1, 0, 0, 0, 0>;
365/// kg·s⁻¹ — a mass flow rate. What a brew scale reads the derivative of.
366pub type MassFlow = Qty<0, 1, -1, 0, 0, 0, 0>;
367/// kg·m⁻³ as a *concentration* of one species dissolved in another.
368///
369/// The same dimension as [`Density`] and deliberately a distinct name: a coffee's TDS and the
370/// density of the water carrying it are both kg/m³ and confusing them is a factor of a hundred.
371pub type Concentration = Qty<-3, 1, 0, 0, 0, 0, 0>;
372
373/// Cycles per second. Dimensionally identical to an angular velocity, since a
374/// radian is m/m — the type system cannot and should not pretend otherwise.
375pub type Frequency = Qty<0, 0, -1, 0, 0, 0, 0>;
376/// Power per unit area, W·m⁻². What a detector face actually receives.
377pub type Irradiance = Qty<0, 1, -3, 0, 0, 0, 0>;
378/// W·m⁻¹·K⁻¹ — the `k` of Fourier's law.
379pub type ThermalConductivity = Qty<1, 1, -3, 0, -1, 0, 0>;
380/// W·K⁻¹ — how fast heat crosses a joint, `UA`.
381///
382/// Dimensionally [`Power`] per [`Temperature`], and equivalently [`ThermalConductivity`] times
383/// a [`Length`], which is the physically meaningful reading: `kA/L`. It is what a *contact*
384/// resistance is measured in — a bolted joint, a winding pressed into a stator — and those have
385/// no bulk conductivity to be derived from, which is why the quantity exists in its own right.
386pub type Conductance = Qty<2, 1, -3, 0, -1, 0, 0>;
387/// J·kg⁻¹·K⁻¹ — the `c_p` that says how much heat a gram of glass can hide.
388pub type SpecificHeat = Qty<2, 0, -2, 0, -1, 0, 0>;
389/// J·kg⁻¹ — the heat a phase change costs at no change in temperature.
390///
391/// [`SpecificHeat`] without the per-kelvin, and that missing kelvin is the whole physics: `c_p`
392/// buys a temperature rise and this buys none. Water's 334 kJ/kg is **eighty times** what it takes
393/// to warm the same water by one kelvin, which is why an ice bath holds at zero.
394///
395/// Divided by a [`SpecificHeat`] it is a [`Temperature`] — the number of kelvin of sensible heat
396/// the phase change is worth, and the reciprocal of the Stefan number. The type system says so,
397/// which is the reason this is its own quantity and not a bare `f64`.
398pub type LatentHeat = Qty<2, 0, -2, 0, 0, 0, 0>;
399/// m²·s⁻¹ — thermal diffusivity `α = k/(ρ c_p)`, and also mass diffusivity.
400pub type Diffusivity = Qty<2, 0, -1, 0, 0, 0, 0>;
401/// K⁻¹ — the coefficient that turns absorbed light into a focus shift.
402pub type ThermalExpansion = Qty<0, 0, 0, 0, -1, 0, 0>;
403/// kg·m² — how hard a body is to spin up about an axis.
404///
405/// The rotational counterpart of mass, and unlike mass it depends on the axis: a
406/// pencil is trivial to spin about its length and awkward about its middle. That
407/// direction-dependence is why it is a tensor and why a free body's rotation is
408/// interesting rather than uniform.
409pub type MomentOfInertia = Qty<2, 1, 0, 0, 0, 0, 0>;
410/// kg·m²·s⁻¹ — the rotational counterpart of momentum, and conserved for the same
411/// reason.
412pub type AngularMomentum = Qty<2, 1, -1, 0, 0, 0, 0>;
413/// N·m⁻¹ — a spring's `k`, and the penalty stiffness a contact is modelled with.
414///
415/// This is what sets a mechanical solver's stability limit: a mass on a spring
416/// oscillates with period `2π√(m/k)`, and an explicit integrator has to resolve that
417/// period whether or not anyone cares about it. Stiff contact is expensive for
418/// exactly this reason.
419pub type Stiffness = Qty<0, 1, -2, 0, 0, 0, 0>;
420/// N·s·m⁻¹ — a dashpot's `c`. Force proportional to velocity, and the only place a
421/// mechanical simulation loses energy on purpose.
422pub type Damping = Qty<0, 1, -1, 0, 0, 0, 0>;
423/// Coulombs.
424pub type Charge = Qty<0, 0, 1, 1, 0, 0, 0>;
425/// Volts.
426pub type Voltage = Qty<2, 1, -3, -1, 0, 0, 0>;
427/// Ohms — volts per ampere.
428pub type Resistance = Qty<2, 1, -3, -2, 0, 0, 0>;
429/// Ω·m — resistance times length. The property of a *material*, where [`Resistance`] is the
430/// property of a particular piece of one.
431///
432/// The distinction is the whole point of a field formulation of current: `R = ρL/A` is a
433/// statement about a uniform bar, and a shape that is not a uniform bar does not have one.
434pub type Resistivity = Qty<3, 1, -3, -2, 0, 0, 0>;
435/// S/m — the reciprocal of [`Resistivity`], and what a finite-volume solve actually wants,
436/// because conductances in parallel add where resistances do not.
437pub type Conductivity = Qty<-3, -1, 3, 2, 0, 0, 0>;
438/// V/m — the gradient of a potential.
439pub type ElectricField = Qty<1, 1, -3, -1, 0, 0, 0>;
440/// A/m² — current per unit area. What actually flows, and the thing `I` is an integral of.
441pub type CurrentDensity = Qty<-2, 0, 0, 1, 0, 0, 0>;
442/// J·K⁻¹ — mass times specific heat. How much heat a thing can hide before it
443/// shows up as a temperature.
444pub type HeatCapacity = Qty<2, 1, -2, 0, -1, 0, 0>;
445
446// ---------------------------------------------------------------------------
447// Declared products. Each line also gives the two divisions that undo it.
448// ---------------------------------------------------------------------------
449
450macro_rules! product {
451    ($a:ty, $b:ty => $c:ty) => {
452        impl Mul<$b> for $a {
453            type Output = $c;
454            fn mul(self, rhs: $b) -> $c {
455                Qty(self.0 * rhs.0)
456            }
457        }
458        impl Mul<$a> for $b {
459            type Output = $c;
460            fn mul(self, rhs: $a) -> $c {
461                Qty(self.0 * rhs.0)
462            }
463        }
464        impl Div<$b> for $c {
465            type Output = $a;
466            fn div(self, rhs: $b) -> $a {
467                Qty(self.0 / rhs.0)
468            }
469        }
470        impl Div<$a> for $c {
471            type Output = $b;
472            fn div(self, rhs: $a) -> $b {
473                Qty(self.0 / rhs.0)
474            }
475        }
476    };
477}
478
479macro_rules! square {
480    ($a:ty => $c:ty) => {
481        impl Mul<$a> for $a {
482            type Output = $c;
483            fn mul(self, rhs: $a) -> $c {
484                Qty(self.0 * rhs.0)
485            }
486        }
487        impl Div<$a> for $c {
488            type Output = $a;
489            fn div(self, rhs: $a) -> $a {
490                Qty(self.0 / rhs.0)
491            }
492        }
493    };
494}
495
496square!(Length => Area);
497product!(Area, Length => Volume);
498product!(Velocity, Time => Length);
499product!(Acceleration, Time => Velocity);
500product!(Mass, Acceleration => Force);
501product!(Mass, Velocity => Momentum);
502product!(Force, Length => Energy);
503product!(Force, Time => Momentum);
504product!(Pressure, Area => Force);
505product!(Power, Time => Energy);
506product!(Irradiance, Area => Power);
507product!(Density, Volume => Mass);
508product!(Current, Time => Charge);
509product!(Voltage, Current => Power);
510// Ohm's law, declared rather than asserted: this line compiling is the check that ohms times
511// amperes are volts, and with the line above it that `I²R` comes out in watts.
512product!(Resistance, Current => Voltage);
513// The field form of Ohm's law: J = sigma E. These lines compiling is the check that
514// (S/m)*(V/m) is A/m^2, and that resistivity really is the reciprocal of conductivity.
515product!(Conductivity, ElectricField => CurrentDensity);
516product!(Resistivity, CurrentDensity => ElectricField);
517product!(Resistance, Length => Resistivity);
518product!(CurrentDensity, Area => Current);
519product!(ElectricField, Length => Voltage);
520product!(Mass, Area => MomentOfInertia);
521product!(MomentOfInertia, Frequency => AngularMomentum);
522product!(Stiffness, Length => Force);
523product!(Damping, Velocity => Force);
524product!(Mass, SpecificHeat => HeatCapacity);
525// A mass times its latent heat is the joules a phase change costs, and latent over specific heat is
526// the kelvin of sensible heat that buys — the Stefan number upside down. Both are identities a
527// freezing front is built out of, so the type system checks them.
528product!(Mass, LatentHeat => Energy);
529product!(SpecificHeat, Temperature => LatentHeat);
530// UA·ΔT is watts, and C/UA is a time — the two identities a thermal network is built out of,
531// so the type system checks them rather than a comment claiming them.
532product!(Conductance, Temperature => Power);
533product!(Conductance, Time => HeatCapacity);
534product!(HeatCapacity, Temperature => Energy);
535product!(Frequency, Time => Dimensionless);
536
537impl Volume {
538    /// Cubic metres.
539    pub fn m3(v: f64) -> Volume {
540        Qty(v)
541    }
542    /// Cubic centimetres — the unit a person actually has for a part.
543    pub fn cm3(v: f64) -> Volume {
544        Qty(v * 1e-6)
545    }
546    /// Cubic millimetres.
547    pub fn mm3(v: f64) -> Volume {
548        Qty(v * 1e-9)
549    }
550    /// Litres.
551    pub fn litres(v: f64) -> Volume {
552        Qty(v * 1e-3)
553    }
554}
555
556impl Area {
557    /// Square metres.
558    pub fn m2(v: f64) -> Area {
559        Qty(v)
560    }
561    /// Square centimetres.
562    pub fn cm2(v: f64) -> Area {
563        Qty(v * 1e-4)
564    }
565    /// Square millimetres — wire cross-sections live here.
566    pub fn mm2(v: f64) -> Area {
567        Qty(v * 1e-6)
568    }
569}
570
571impl Area {
572    /// The side of a square of this area. The one root worth naming, because it
573    /// is how a beam radius comes back out of a spot area.
574    pub fn sqrt(self) -> Length {
575        Qty(self.0.sqrt())
576    }
577}
578
579// ---------------------------------------------------------------------------
580// Unit-bearing entry and exit. The only place a factor of 1000 may appear.
581// ---------------------------------------------------------------------------
582
583impl Resistivity {
584    /// Ohm-metres. Copper is 1.724e-8 at 20 °C, aluminium 2.65e-8, and a resistor's ceramic
585    /// substrate is fourteen orders of magnitude up from either.
586    pub fn ohm_m(v: f64) -> Resistivity {
587        Qty(v)
588    }
589    /// µΩ·cm, which is what a materials datasheet quotes: copper is 1.724.
590    pub fn micro_ohm_cm(v: f64) -> Resistivity {
591        Qty(v * 1e-8)
592    }
593    /// The conductivity that is its reciprocal. Zero resistivity gives an infinite
594    /// conductivity, which is the honest answer and not a panic.
595    pub fn conductivity(self) -> Conductivity {
596        Qty(1.0 / self.0)
597    }
598}
599
600impl Conductivity {
601    /// Siemens per metre.
602    pub fn s_per_m(v: f64) -> Conductivity {
603        Qty(v)
604    }
605    /// The resistivity that is its reciprocal.
606    pub fn resistivity(self) -> Resistivity {
607        Qty(1.0 / self.0)
608    }
609}
610
611impl ElectricField {
612    /// Volts per metre.
613    pub fn v_per_m(v: f64) -> ElectricField {
614        Qty(v)
615    }
616}
617
618impl CurrentDensity {
619    /// Amperes per square metre.
620    pub fn a_per_m2(v: f64) -> CurrentDensity {
621        Qty(v)
622    }
623    /// A/mm², which is how a cable's rating is quoted — 5 A/mm² is a normal continuous
624    /// figure for insulated copper in air.
625    pub fn a_per_mm2(v: f64) -> CurrentDensity {
626        Qty(v * 1e6)
627    }
628}
629
630impl Resistance {
631    /// Ohms.
632    pub fn ohm(v: f64) -> Resistance {
633        Qty(v)
634    }
635    /// Milliohms — the range a motor winding or a shunt actually lives in.
636    pub fn milliohm(v: f64) -> Resistance {
637        Qty(v * 1e-3)
638    }
639}
640
641impl Current {
642    /// Amperes.
643    pub fn a(v: f64) -> Current {
644        Qty(v)
645    }
646    /// Milliamperes.
647    pub fn ma(v: f64) -> Current {
648        Qty(v * 1e-3)
649    }
650}
651
652impl Voltage {
653    /// Volts.
654    pub fn v(v: f64) -> Voltage {
655        Qty(v)
656    }
657    /// Millivolts.
658    pub fn mv(v: f64) -> Voltage {
659        Qty(v * 1e-3)
660    }
661}
662
663impl Length {
664    /// Metres.
665    pub fn m(v: f64) -> Length {
666        Qty(v)
667    }
668    /// Millimetres.
669    pub fn mm(v: f64) -> Length {
670        Qty(v * 1e-3)
671    }
672    /// Micrometres.
673    pub fn um(v: f64) -> Length {
674        Qty(v * 1e-6)
675    }
676    /// Nanometres. The wavelength unit, and why every `Spectrum` field is named `_nm`.
677    pub fn nm(v: f64) -> Length {
678        Qty(v * 1e-9)
679    }
680    /// As millimetres.
681    pub fn in_mm(self) -> f64 {
682        self.0 * 1e3
683    }
684    /// As micrometres.
685    pub fn in_um(self) -> f64 {
686        self.0 * 1e6
687    }
688    /// As nanometres.
689    pub fn in_nm(self) -> f64 {
690        self.0 * 1e9
691    }
692}
693
694impl Time {
695    /// Seconds.
696    pub fn s(v: f64) -> Time {
697        Qty(v)
698    }
699    /// Milliseconds.
700    pub fn ms(v: f64) -> Time {
701        Qty(v * 1e-3)
702    }
703    /// Microseconds.
704    pub fn us(v: f64) -> Time {
705        Qty(v * 1e-6)
706    }
707    /// Nanoseconds.
708    pub fn ns(v: f64) -> Time {
709        Qty(v * 1e-9)
710    }
711    /// As milliseconds.
712    pub fn in_ms(self) -> f64 {
713        self.0 * 1e3
714    }
715    /// As microseconds.
716    pub fn in_us(self) -> f64 {
717        self.0 * 1e6
718    }
719}
720
721impl Temperature {
722    /// Kelvin, which is what is stored.
723    pub fn kelvin(v: f64) -> Temperature {
724        Qty(v)
725    }
726    /// Celsius is an *offset* scale, not a scaled one, which is why it gets a
727    /// named constructor rather than a factor: 20 °C is 293.15 K, and a
728    /// temperature *difference* of 20 K is a different thing entirely.
729    pub fn celsius(v: f64) -> Temperature {
730        Qty(v + 273.15)
731    }
732    /// As degrees Celsius. Subtracts the offset; see [`Temperature::celsius`].
733    pub fn in_celsius(self) -> f64 {
734        self.0 - 273.15
735    }
736}
737
738impl Mass {
739    /// Kilograms.
740    pub fn kg(v: f64) -> Mass {
741        Qty(v)
742    }
743    /// Grams.
744    pub fn g(v: f64) -> Mass {
745        Qty(v * 1e-3)
746    }
747}
748
749impl Density {
750    /// The way a glass catalogue quotes it: N-BK7 is 2.51 g/cm³.
751    pub fn g_per_cm3(v: f64) -> Density {
752        Qty(v * 1e3)
753    }
754    /// Kilograms per cubic metre, which is what is stored.
755    pub fn kg_per_m3(v: f64) -> Density {
756        Qty(v)
757    }
758}
759
760impl Power {
761    /// Watts.
762    pub fn w(v: f64) -> Power {
763        Qty(v)
764    }
765    /// Milliwatts.
766    pub fn mw(v: f64) -> Power {
767        Qty(v * 1e-3)
768    }
769    /// Microwatts.
770    pub fn uw(v: f64) -> Power {
771        Qty(v * 1e-6)
772    }
773    /// As milliwatts.
774    pub fn in_mw(self) -> f64 {
775        self.0 * 1e3
776    }
777}
778
779impl Energy {
780    /// Joules.
781    pub fn j(v: f64) -> Energy {
782        Qty(v)
783    }
784    /// Millijoules.
785    pub fn mj(v: f64) -> Energy {
786        Qty(v * 1e-3)
787    }
788}
789
790impl Frequency {
791    /// Hertz.
792    pub fn hz(v: f64) -> Frequency {
793        Qty(v)
794    }
795    /// Kilohertz.
796    pub fn khz(v: f64) -> Frequency {
797        Qty(v * 1e3)
798    }
799    /// Megahertz.
800    pub fn mhz(v: f64) -> Frequency {
801        Qty(v * 1e6)
802    }
803    /// Period: one over the frequency. Named because `1.0 / f` cannot typecheck.
804    pub fn period(self) -> Time {
805        Qty(1.0 / self.0)
806    }
807}
808
809impl Velocity {
810    /// Metres per second.
811    pub fn m_per_s(v: f64) -> Velocity {
812        Qty(v)
813    }
814    /// Millimetres per second.
815    pub fn mm_per_s(v: f64) -> Velocity {
816        Qty(v * 1e-3)
817    }
818}
819
820impl Irradiance {
821    /// Watts per square metre, which is what is stored.
822    pub fn w_per_m2(v: f64) -> Irradiance {
823        Qty(v)
824    }
825    /// How an illumination spec is usually written: mW/cm².
826    pub fn mw_per_cm2(v: f64) -> Irradiance {
827        Qty(v * 10.0)
828    }
829}
830
831impl ThermalConductivity {
832    /// W·m⁻¹·K⁻¹, the unit a materials table uses.
833    pub fn w_per_m_k(v: f64) -> ThermalConductivity {
834        Qty(v)
835    }
836}
837
838impl SpecificHeat {
839    /// J·kg⁻¹·K⁻¹, the unit a materials table uses.
840    pub fn j_per_kg_k(v: f64) -> SpecificHeat {
841        Qty(v)
842    }
843}
844
845impl LatentHeat {
846    /// J·kg⁻¹.
847    pub fn j_per_kg(v: f64) -> LatentHeat {
848        Qty(v)
849    }
850
851    /// kJ·kg⁻¹, which is the unit every table of latent heats is written in.
852    pub fn kj_per_kg(v: f64) -> LatentHeat {
853        Qty(v * 1e3)
854    }
855}
856
857impl Conductance {
858    /// Watts per kelvin.
859    pub fn w_per_k(v: f64) -> Conductance {
860        Qty(v)
861    }
862}
863
864impl HeatCapacity {
865    /// Joules per kelvin. The companion to [`Conductance::w_per_k`]: their ratio is a time
866    /// constant, and the type system says so.
867    pub fn j_per_k(v: f64) -> HeatCapacity {
868        Qty(v)
869    }
870}
871
872impl ThermalExpansion {
873    /// Catalogues quote it in parts per million per kelvin: N-BK7 is 7.1.
874    pub fn ppm_per_k(v: f64) -> ThermalExpansion {
875        Qty(v * 1e-6)
876    }
877}
878
879impl Dimensionless {
880    /// A bare ratio, for the one case where a number genuinely has no dimension:
881    /// a reflectance, a duty cycle, a refractive index.
882    pub fn ratio(v: f64) -> Dimensionless {
883        Qty(v)
884    }
885}
886
887// ---------------------------------------------------------------------------
888// Physical constants, in SI base units, so that a formula written with them
889// carries its own dimensional proof.
890// ---------------------------------------------------------------------------
891
892/// Speed of light in vacuum, m·s⁻¹ (exact by definition).
893pub const C: Velocity = Qty(299_792_458.0);
894/// Planck constant, J·s (exact by definition).
895pub const PLANCK: Qty<2, 1, -1, 0, 0, 0, 0> = Qty(6.626_070_15e-34);
896/// Boltzmann constant, J·K⁻¹ (exact by definition).
897pub const BOLTZMANN: HeatCapacity = Qty(1.380_649e-23);
898/// Stefan-Boltzmann constant, W·m⁻²·K⁻⁴ — radiative exchange lives on this.
899pub const STEFAN_BOLTZMANN: Qty<0, 1, -3, 0, -4, 0, 0> = Qty(5.670_374_419e-8);
900/// Standard gravity, m·s⁻².
901pub const G0: Acceleration = Qty(9.806_65);
902
903/// Energy of one photon at a vacuum wavelength: `E = hc/λ`.
904///
905/// The bridge between a spectrum and a photon count, and the reason a detector's
906/// response is not the same shape as a lamp's output.
907pub fn photon_energy(wavelength: Length) -> Energy {
908    Qty(PLANCK.0 * C.0 / wavelength.0)
909}
910
911#[cfg(test)]
912mod tests {
913    use super::*;
914
915    /// The point of the crate: a product of dimensions lands on the type that
916    /// names it, whichever route it took there.
917    #[test]
918    fn products_land_on_the_named_dimension() {
919        let m = Mass::kg(2.0);
920        let a = Acceleration::from_si(3.0);
921        let f: Force = m * a;
922        assert!((f.to_si() - 6.0).abs() < 1e-12);
923
924        // Two different routes to the same energy, and they unify.
925        let by_work: Energy = f * Length::m(4.0);
926        let by_power: Energy = Power::w(24.0) * Time::s(1.0);
927        assert!((by_work - by_power).abs().to_si() < 1e-12);
928
929        // And multiplication commutes, as it must.
930        let swapped: Force = a * m;
931        assert_eq!(f, swapped);
932    }
933
934    /// Millimetres and nanometres are entry forms only; storage is metres. A
935    /// wavelength and a lens diameter therefore compare correctly without anyone
936    /// remembering which convention each was written in.
937    #[test]
938    fn unit_prefixes_are_only_a_doorway() {
939        let lens = Length::mm(25.4);
940        let green = Length::nm(550.0);
941        assert!(lens > green);
942        assert!((lens.to_si() - 0.0254).abs() < 1e-15);
943        assert!((green.in_nm() - 550.0).abs() < 1e-9);
944        // 25.4 mm is 46181.8... wavelengths of green light.
945        let waves = lens / green;
946        assert!((waves - 46_181.8).abs() < 0.1, "got {waves}");
947    }
948
949    /// Dividing like by like gives a plain number, which is what every
950    /// tolerance and every reflectance is.
951    #[test]
952    fn like_over_like_is_a_bare_number() {
953        let reflected = Power::mw(0.42);
954        let incident = Power::mw(10.0);
955        let r: f64 = reflected / incident;
956        assert!((r - 0.042).abs() < 1e-12);
957    }
958
959    /// Celsius offsets rather than scales, and getting that wrong is a 273 K
960    /// error that no dimensional check would ever catch.
961    #[test]
962    fn celsius_is_an_offset_not_a_factor() {
963        assert!((Temperature::celsius(20.0).to_si() - 293.15).abs() < 1e-12);
964        assert!((Temperature::kelvin(293.15).in_celsius() - 20.0).abs() < 1e-12);
965        // A *difference* of 20 K is not 293.15 K, and only one of these is a
966        // temperature you can put in Stefan-Boltzmann.
967        let rise = Temperature::kelvin(313.15) - Temperature::kelvin(293.15);
968        assert!((rise.to_si() - 20.0).abs() < 1e-12);
969    }
970
971    /// The optics-to-thermal chain this whole crate exists to make safe: a
972    /// surface absorbs a fraction of an irradiance over an area, and the watts
973    /// that result heat a mass with a known specific heat.
974    #[test]
975    fn absorbed_light_becomes_a_temperature_rise() {
976        let irradiance = Irradiance::mw_per_cm2(50.0); // 500 W/m²
977        let area: Area = Length::mm(10.0) * Length::mm(10.0); // 1e-4 m²
978        let absorptance = 0.02; // what SurfaceOptics::absorptance returns
979        let absorbed: Power = irradiance * area * absorptance;
980        assert!((absorbed.to_si() - 0.001).abs() < 1e-12, "{absorbed:?}");
981
982        // 1 mW into a 2 g piece of glass for 1 s.
983        let glass = Mass::g(2.0);
984        let c_p = SpecificHeat::j_per_kg_k(858.0); // N-BK7
985        let capacity: HeatCapacity = glass * c_p;
986        let heat: Energy = absorbed * Time::s(1.0);
987        let rise: Temperature = heat / capacity;
988        assert!(
989            (rise.to_si() - 0.000_582_7).abs() < 1e-7,
990            "expected about 0.58 mK, got {rise:?}"
991        );
992    }
993
994    /// A photon at 550 nm carries 3.6e-19 J, and the count per watt follows.
995    /// This is the number that separates radiometry from photon counting.
996    #[test]
997    fn photon_energy_matches_the_textbook_figure() {
998        let e = photon_energy(Length::nm(550.0));
999        assert!((e.to_si() - 3.612e-19).abs() < 1e-21, "{e:?}");
1000        // 2.26 eV, and about 2.77e18 photons in a joule.
1001        let per_joule = Energy::j(1.0) / e;
1002        assert!((per_joule - 2.768e18).abs() < 1e15, "got {per_joule:e}");
1003    }
1004
1005    /// Debug prints the dimension, so a mismatch found at a boundary can be
1006    /// reported in a form a human recognises.
1007    #[test]
1008    fn debug_shows_the_dimension() {
1009        assert_eq!(format!("{:?}", Force::from_si(6.0)), "6·m·kg·s^-2");
1010        assert_eq!(format!("{:?}", Dimensionless::ratio(0.5)), "0.5");
1011        assert_eq!(Force::dimension(), [1, 1, -2, 0, 0, 0, 0]);
1012    }
1013
1014    /// Serialised as the bare SI number: the dimension is in the field's type,
1015    /// not repeated in every scene file.
1016    #[test]
1017    fn serialises_as_a_bare_si_number() {
1018        let json = serde_json::to_string(&Length::mm(25.4)).unwrap();
1019        assert_eq!(json, "0.0254");
1020        let back: Length = serde_json::from_str(&json).unwrap();
1021        assert_eq!(back, Length::mm(25.4));
1022    }
1023}