diff --git a/crates/model/src/units/dimension.rs b/crates/model/src/units/dimension.rs new file mode 100644 index 0000000..05c1542 --- /dev/null +++ b/crates/model/src/units/dimension.rs @@ -0,0 +1,96 @@ +//! Physical dimensions as integer exponents of the SI base dimensions. + +use std::fmt; + +/// A physical dimension: the exponents of length L, mass M, time T, temperature Θ and electric +/// current I. Dimensionless quantities (Poisson's ratio, strain, angles) have every exponent 0. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +pub struct Dimension { + pub length: i8, + pub mass: i8, + pub time: i8, + pub temperature: i8, + pub current: i8, +} + +impl Dimension { + pub const NONE: Dimension = Dimension::new(0, 0, 0, 0); + pub const LENGTH: Dimension = Dimension::new(1, 0, 0, 0); + pub const MASS: Dimension = Dimension::new(0, 1, 0, 0); + pub const TIME: Dimension = Dimension::new(0, 0, 1, 0); + pub const TEMPERATURE: Dimension = Dimension::new(0, 0, 0, 1); + pub const CURRENT: Dimension = Dimension { current: 1, ..Dimension::NONE }; + + /// L^length M^mass T^time Θ^temperature (no current: FieldCraft has no electrical quantities yet). + pub const fn new(length: i8, mass: i8, time: i8, temperature: i8) -> Dimension { + Dimension { length, mass, time, temperature, current: 0 } + } + + fn exponents(self) -> [i8; 5] { + [self.length, self.mass, self.time, self.temperature, self.current] + } + + fn from_exponents([length, mass, time, temperature, current]: [i8; 5]) -> Dimension { + Dimension { length, mass, time, temperature, current } + } + + fn zip(self, other: Dimension, f: impl Fn(i8, i8) -> Option) -> Option { + let (a, b) = (self.exponents(), other.exponents()); + let mut out = [0i8; 5]; + for ((o, x), y) in out.iter_mut().zip(a).zip(b) { + *o = f(x, y)?; + } + Some(Dimension::from_exponents(out)) + } + + /// The dimension of a product, or `None` if an exponent overflows. + pub fn checked_mul(self, other: Dimension) -> Option { + self.zip(other, i8::checked_add) + } + + /// The dimension of a quotient, or `None` if an exponent overflows. + pub fn checked_div(self, other: Dimension) -> Option { + self.zip(other, i8::checked_sub) + } + + /// The dimension raised to an integer power, or `None` if an exponent overflows. + pub fn checked_pow(self, power: i8) -> Option { + self.zip(Dimension::NONE, |x, _| x.checked_mul(power)) + } +} + +impl fmt::Display for Dimension { + /// `L M T^-2` style; `1` for dimensionless. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut first = true; + for (symbol, e) in ["L", "M", "T", "Θ", "I"].into_iter().zip(self.exponents()) { + if e == 0 { + continue; + } + if !first { + f.write_str(" ")?; + } + first = false; + if e == 1 { write!(f, "{symbol}")? } else { write!(f, "{symbol}^{e}")? } + } + if first { f.write_str("1") } else { Ok(()) } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn arithmetic_and_display() { + let force = Dimension::new(1, 1, -2, 0); + let area = Dimension::LENGTH.checked_pow(2).unwrap(); + let pressure = force.checked_div(area).unwrap(); + assert_eq!(pressure, Dimension::new(-1, 1, -2, 0)); + assert_eq!(pressure.to_string(), "L^-1 M T^-2"); + assert_eq!(Dimension::NONE.to_string(), "1"); + assert_eq!(pressure.checked_mul(area), Some(force)); + assert_eq!(Dimension::new(100, 0, 0, 0).checked_pow(2), None); + assert_eq!(Dimension::new(127, 0, 0, 0).checked_mul(Dimension::LENGTH), None); + } +} diff --git a/crates/model/src/units/display.rs b/crates/model/src/units/display.rs new file mode 100644 index 0000000..453286e --- /dev/null +++ b/crates/model/src/units/display.rs @@ -0,0 +1,151 @@ +//! Display systems: which unit each quantity is shown and typed in. Choosing one never changes a +//! stored value. + +use serde::{Deserialize, Serialize}; + +use super::{Quantity, Unit, UnitError}; + +/// A consistent set of display units. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum Preset { + /// Strict SI: m, kg, s, N, Pa, K. + #[serde(rename = "SI")] + Si, + /// SI with temperatures in °C and angles in degrees. + #[serde(rename = "m-kg-s")] + MKgS, + /// Millimetre, tonne, second, newton, megapascal, °C: what most structural CAE users expect. + #[default] + #[serde(rename = "mm-t-s-N-MPa")] + MmTonneS, + /// Inch, lbf·s²/in, second, pound-force, psi, °F. + #[serde(rename = "in-lbf-s-psi")] + InLbfS, +} + +impl Preset { + pub const ALL: [Preset; 4] = [Preset::Si, Preset::MKgS, Preset::MmTonneS, Preset::InLbfS]; + + pub fn label(self) -> &'static str { + match self { + Preset::Si => "SI", + Preset::MKgS => "m-kg-s", + Preset::MmTonneS => "mm-t-s-N-MPa", + Preset::InLbfS => "in-lbf-s-psi", + } + } + + pub fn from_label(label: &str) -> Option { + Preset::ALL.into_iter().find(|p| p.label() == label) + } + + /// The unit this preset shows `q` in. + pub fn symbol(self, q: Quantity) -> &'static str { + use Quantity as Q; + match self { + Preset::Si => q.si_symbol(), + Preset::MKgS => match q { + Q::Temperature => "°C", + Q::Angle => "deg", + _ => q.si_symbol(), + }, + Preset::MmTonneS => match q { + Q::Dimensionless => "1", + Q::Angle => "deg", + Q::Length => "mm", + Q::Area => "mm²", + Q::Volume => "mm³", + Q::SecondMomentOfArea => "mm⁴", + Q::Mass => "t", + Q::Density => "t/mm³", + Q::Time => "s", + Q::Frequency => "Hz", + Q::Velocity => "mm/s", + Q::Acceleration => "mm/s²", + Q::Force => "N", + Q::Moment => "N·mm", + Q::Pressure => "MPa", + Q::Stiffness => "N/mm", + Q::Damping => "N·s/mm", + Q::Energy => "mJ", + Q::Power => "mW", + Q::DynamicViscosity => "MPa·s", + Q::KinematicViscosity => "mm²/s", + Q::Temperature => "°C", + Q::TemperatureDifference => "K", + Q::ThermalExpansion => "K⁻¹", + Q::ThermalConductivity => "mW/(mm·K)", + Q::SpecificHeat => "mJ/(t·K)", + Q::HeatTransferCoefficient => "mW/(mm²·K)", + Q::HeatFlux => "mW/mm²", + Q::HeatGeneration => "mW/mm³", + }, + Preset::InLbfS => match q { + Q::Dimensionless => "1", + Q::Angle => "deg", + Q::Length => "in", + Q::Area => "in²", + Q::Volume => "in³", + Q::SecondMomentOfArea => "in⁴", + Q::Mass => "lbf·s²/in", + Q::Density => "lbf·s²/in⁴", + Q::Time => "s", + Q::Frequency => "Hz", + Q::Velocity => "in/s", + Q::Acceleration => "in/s²", + Q::Force => "lbf", + Q::Moment => "lbf·in", + Q::Pressure => "psi", + Q::Stiffness => "lbf/in", + Q::Damping => "lbf·s/in", + Q::Energy => "in·lbf", + Q::Power => "in·lbf/s", + Q::DynamicViscosity => "psi·s", + Q::KinematicViscosity => "in²/s", + Q::Temperature => "°F", + Q::TemperatureDifference => "°F", + Q::ThermalExpansion => "°F⁻¹", + Q::ThermalConductivity => "lbf/(s·°F)", + Q::SpecificHeat => "in²/(s²·°F)", + Q::HeatTransferCoefficient => "lbf/(in·s·°F)", + Q::HeatFlux => "lbf/(in·s)", + Q::HeatGeneration => "lbf/(in²·s)", + }, + } + } +} + +/// The units values are shown and typed in: a preset with optional per-quantity overrides (say +/// mm-t-s-N-MPa with energies in J). +#[derive(Debug, Clone, Default, PartialEq)] +pub struct DisplaySystem { + preset: Preset, + overrides: Vec<(Quantity, Unit)>, +} + +impl DisplaySystem { + pub fn new(preset: Preset) -> DisplaySystem { + DisplaySystem { preset, overrides: Vec::new() } + } + + pub fn preset(&self) -> Preset { + self.preset + } + + /// Shows `q` in `unit` instead of the preset's unit; refused when the dimension differs. + pub fn with_override(mut self, q: Quantity, unit: &str) -> Result { + let unit = Unit::parse(unit)?; + q.express(0.0, &unit)?; + self.overrides.retain(|(o, _)| *o != q); + self.overrides.push((q, unit)); + Ok(self) + } + + /// The unit `q` is shown and typed in. + pub fn unit(&self, q: Quantity) -> Result { + match self.overrides.iter().find(|(o, _)| *o == q) { + Some((_, u)) => Ok(u.clone()), + None => Unit::parse(self.preset.symbol(q)), + } + } +} diff --git a/crates/model/src/units.rs b/crates/model/src/units/mod.rs similarity index 71% rename from crates/model/src/units.rs rename to crates/model/src/units/mod.rs index f75fe47..a7bdfad 100644 --- a/crates/model/src/units.rs +++ b/crates/model/src/units/mod.rs @@ -1,9 +1,52 @@ -//! The unit system label. +//! Units: the unit system label a model carries today, and the SI unit table every conversion +//! goes through. //! -//! The model never converts: numbers are stored as entered. The label records which consistent -//! system they are in, so the check can warn when a magnitude is implausible for it (a steel -//! modulus of 210 000 in a metre–pascal model, a density of 7850 in a millimetre–tonne one), and -//! so writers can say which system a deck is in. +//! **The table** ([`Dimension`], [`Unit`], [`Quantity`], [`DisplaySystem`]) is the one place +//! FieldCraft writes down a conversion factor. Each [`Quantity`] has a dimension and an SI unit; +//! a [`Unit`] parses expressions such as `MPa`, `t/mm³` or `W/(m·K)` to a dimension and a factor +//! to SI; [`Quantity::parse`] reads `"210 GPa"` or `"20 °C"` with a dimension check, and +//! [`Quantity::format`] shows an SI value in a [`DisplaySystem`]. Absolute temperatures +//! ([`Quantity::Temperature`]) take the °C/°F offset; temperature differences and every compound +//! unit never do. Issue #579 moves model storage to SI on top of this table; until then the +//! model still stores numbers as entered. +//! +//! **The label** ([`UnitSystem`]): the model never converts yet: numbers are stored as entered. +//! The label records which consistent system they are in, so the check can warn when a magnitude +//! is implausible for it (a steel modulus of 210 000 in a metre–pascal model, a density of 7850 +//! in a millimetre–tonne one), and so writers can say which system a deck is in. + +mod dimension; +mod display; +mod quantity; +mod unit; + +pub use dimension::Dimension; +pub use display::{DisplaySystem, Preset}; +pub use quantity::{Quantity, ValueInput, WithUnit, format_number}; +pub use unit::Unit; + +/// Why a unit or a value with a unit was refused. +#[derive(Debug, Clone, PartialEq, thiserror::Error)] +pub enum UnitError { + #[error("no value or unit given")] + Empty, + #[error("unit text is too long")] + TooLong, + #[error("`{0}` is not a finite number")] + NotFinite(String), + #[error("unknown unit `{0}`")] + UnknownUnit(String), + #[error("cannot read `{0}` as a value with a unit (for example `210 GPa`, `t/mm³`, `W/(m·K)`)")] + Syntax(String), + #[error("`{0}` is out of range")] + OutOfRange(String), + #[error("`{0}` is below absolute zero")] + BelowAbsoluteZero(String), + #[error("`{unit}` is not a unit of {quantity} (angles take rad, deg or °; ratios take 1 or %)")] + WrongKind { unit: String, quantity: &'static str }, + #[error("`{unit}` is {found}, but a {quantity} is {expected}")] + WrongDimension { unit: String, found: Dimension, quantity: &'static str, expected: Dimension }, +} use serde::{Deserialize, Serialize}; diff --git a/crates/model/src/units/quantity.rs b/crates/model/src/units/quantity.rs new file mode 100644 index 0000000..d88461d --- /dev/null +++ b/crates/model/src/units/quantity.rs @@ -0,0 +1,246 @@ +//! The kinds of physical quantity FieldCraft stores, and parsing and formatting of values with +//! units ("210 GPa", `{"value": 20, "unit": "°C"}`) against them. + +use serde::{Deserialize, Serialize}; + +use super::unit::MAX_TEXT; +use super::{Dimension, DisplaySystem, Unit, UnitError}; + +macro_rules! quantities { + ($($variant:ident = $name:literal, $dim:expr, $si:literal;)*) => { + /// A kind of physical quantity. Each has one dimension and one SI unit, which is how it is + /// stored. + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] + #[serde(rename_all = "snake_case")] + pub enum Quantity { + $($variant,)* + } + + impl Quantity { + pub const ALL: &[Quantity] = &[$(Quantity::$variant,)*]; + + /// The name used in commands and files (`thermal_conductivity`, …). + pub fn name(self) -> &'static str { + match self { $(Quantity::$variant => $name,)* } + } + + pub fn dimension(self) -> Dimension { + match self { $(Quantity::$variant => $dim,)* } + } + + /// The SI unit the quantity is stored in. + pub fn si_symbol(self) -> &'static str { + match self { $(Quantity::$variant => $si,)* } + } + } + }; +} + +const fn d(length: i8, mass: i8, time: i8, temperature: i8) -> Dimension { + Dimension::new(length, mass, time, temperature) +} + +quantities! { + Dimensionless = "dimensionless", d(0, 0, 0, 0), "1"; + Angle = "angle", d(0, 0, 0, 0), "rad"; + Length = "length", d(1, 0, 0, 0), "m"; + Area = "area", d(2, 0, 0, 0), "m²"; + Volume = "volume", d(3, 0, 0, 0), "m³"; + SecondMomentOfArea = "second_moment_of_area", d(4, 0, 0, 0), "m⁴"; + Mass = "mass", d(0, 1, 0, 0), "kg"; + Density = "density", d(-3, 1, 0, 0), "kg/m³"; + Time = "time", d(0, 0, 1, 0), "s"; + Frequency = "frequency", d(0, 0, -1, 0), "Hz"; + Velocity = "velocity", d(1, 0, -1, 0), "m/s"; + Acceleration = "acceleration", d(1, 0, -2, 0), "m/s²"; + Force = "force", d(1, 1, -2, 0), "N"; + Moment = "moment", d(2, 1, -2, 0), "N·m"; + Pressure = "pressure", d(-1, 1, -2, 0), "Pa"; + Stiffness = "stiffness", d(0, 1, -2, 0), "N/m"; + Damping = "damping", d(0, 1, -1, 0), "N·s/m"; + Energy = "energy", d(2, 1, -2, 0), "J"; + Power = "power", d(2, 1, -3, 0), "W"; + DynamicViscosity = "dynamic_viscosity", d(-1, 1, -1, 0), "Pa·s"; + KinematicViscosity = "kinematic_viscosity", d(2, 0, -1, 0), "m²/s"; + Temperature = "temperature", d(0, 0, 0, 1), "K"; + TemperatureDifference = "temperature_difference", d(0, 0, 0, 1), "K"; + ThermalExpansion = "thermal_expansion", d(0, 0, 0, -1), "K⁻¹"; + ThermalConductivity = "thermal_conductivity", d(1, 1, -3, -1), "W/(m·K)"; + SpecificHeat = "specific_heat", d(2, 0, -2, -1), "J/(kg·K)"; + HeatTransferCoefficient = "heat_transfer_coefficient", d(0, 1, -3, -1), "W/(m²·K)"; + HeatFlux = "heat_flux", d(0, 1, -3, 0), "W/m²"; + HeatGeneration = "heat_generation", d(-1, 1, -3, 0), "W/m³"; +} + +impl Quantity { + pub fn from_name(name: &str) -> Option { + Quantity::ALL.iter().copied().find(|q| q.name() == name) + } + + /// Whether the unit's zero offset applies (°C and °F as absolute temperatures). + fn absolute(self) -> bool { + self == Quantity::Temperature + } + + /// Converts `value` in `unit` to this quantity's SI value, checking the dimension and the + /// result. + pub fn to_si(self, value: f64, unit: &Unit) -> Result { + if !value.is_finite() { + return Err(UnitError::NotFinite(value.to_string())); + } + self.check(unit)?; + let si = unit.to_si(value, self.absolute()); + if !si.is_finite() { + return Err(UnitError::OutOfRange(format!("{value} {}", unit.symbol()))); + } + if self.absolute() && si < 0.0 { + return Err(UnitError::BelowAbsoluteZero(format!("{value} {}", unit.symbol()))); + } + Ok(si) + } + + /// Converts an SI value of this quantity to `unit`. + pub fn express(self, si: f64, unit: &Unit) -> Result { + self.check(unit)?; + let v = unit.express(si, self.absolute()); + if v.is_finite() { Ok(v) } else { Err(UnitError::NotFinite(v.to_string())) } + } + + fn check(self, unit: &Unit) -> Result<(), UnitError> { + if unit.dimension() == self.dimension() { + // Angles and ratios share the empty dimension: only an angle takes rad, deg or °. + if unit.is_angle() == (self == Quantity::Angle) { + Ok(()) + } else { + Err(UnitError::WrongKind { unit: unit.symbol().to_string(), quantity: self.name() }) + } + } else { + Err(UnitError::WrongDimension { + unit: unit.symbol().to_string(), + found: unit.dimension(), + quantity: self.name(), + expected: self.dimension(), + }) + } + } + + /// Parses `"210 GPa"`, `"25 mm/s"`, `"-5 °C"` (or a bare `"210"`, meaning `display`'s unit) + /// to the SI value. + pub fn parse(self, text: &str, display: &DisplaySystem) -> Result { + let text = text.trim(); + if text.is_empty() { + return Err(UnitError::Empty); + } + if text.len() > MAX_TEXT { + return Err(UnitError::TooLong); + } + let (number, unit) = split_number(text).ok_or_else(|| UnitError::Syntax(text.to_string()))?; + if unit.trim().is_empty() { self.to_si(number, &display.unit(self)?) } else { self.to_si(number, &Unit::parse(unit)?) } + } + + /// Formats an SI value in `display`'s unit for this quantity, with the unit: `210000 MPa`. + pub fn format(self, si: f64, display: &DisplaySystem) -> Result { + let unit = display.unit(self)?; + let v = self.express(si, &unit)?; + Ok(if unit.symbol() == "1" { format_number(v) } else { format!("{} {}", format_number(v), unit.symbol()) }) + } +} + +/// The leading number of `text` and the rest. `inf` and `nan` are not numbers here. +fn split_number(text: &str) -> Option<(f64, &str)> { + let end = text.find(|c: char| !(c.is_ascii_digit() || matches!(c, '+' | '-' | '.' | 'e' | 'E'))).unwrap_or(text.len()); + // Shrink until the prefix parses: "2e" in "2em" is not an exponent. + (1..=end).rev().find_map(|i| { + let head = text.get(..i)?; + let v: f64 = head.parse().ok()?; + Some((v, text.get(i..)?)) + }) +} + +/// A value with six significant digits and no trailing zeros: `210000`, `7.85e-9`, `0.3`. +pub fn format_number(v: f64) -> String { + if v == 0.0 || !v.is_finite() { + return if v == 0.0 { "0".to_string() } else { v.to_string() }; + } + let exp = v.abs().log10().floor(); + if (-4.0..7.0).contains(&exp) { + let decimals = (5.0 - exp).max(0.0) as usize; + let s = format!("{v:.decimals$}"); + if s.contains('.') { s.trim_end_matches('0').trim_end_matches('.').to_string() } else { s } + } else { + let s = format!("{v:.5e}"); + match s.split_once('e') { + Some((mantissa, e)) => { + let m = if mantissa.contains('.') { mantissa.trim_end_matches('0').trim_end_matches('.') } else { mantissa }; + format!("{m}e{e}") + } + None => s, + } + } +} + +/// A value as commands, scripts and the MCP give it: a bare number (in the display system), a +/// string with its unit (`"210 GPa"`), or `{"value": 210, "unit": "GPa"}`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum ValueInput { + Number(f64), + Text(String), + WithUnit(WithUnit), +} + +/// `{"value": 210, "unit": "GPa"}`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct WithUnit { + pub value: f64, + pub unit: String, +} + +impl ValueInput { + /// The SI value of this input as quantity `q`; a bare number is in `display`'s unit. + pub fn to_si(&self, q: Quantity, display: &DisplaySystem) -> Result { + match self { + ValueInput::Number(v) => q.to_si(*v, &display.unit(q)?), + ValueInput::Text(t) => q.parse(t, display), + ValueInput::WithUnit(w) => q.to_si(w.value, &Unit::parse(&w.unit)?), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_si_symbol_parses_to_its_dimension_with_factor_one() { + for &q in Quantity::ALL { + let u = Unit::parse(q.si_symbol()).unwrap_or_else(|e| panic!("{q:?}: {e}")); + assert_eq!(u.dimension(), q.dimension(), "{q:?}"); + assert!((u.factor() - 1.0).abs() < 1e-15, "{q:?}"); + assert_eq!(Quantity::from_name(q.name()), Some(q)); + } + } + + #[test] + fn numbers_split_from_units() { + assert_eq!(split_number("210 GPa"), Some((210.0, " GPa"))); + assert_eq!(split_number("2.1e11Pa"), Some((2.1e11, "Pa"))); + assert_eq!(split_number("-5 °C"), Some((-5.0, " °C"))); + assert_eq!(split_number("3e"), Some((3.0, "e"))); + assert_eq!(split_number("inf Pa"), None); + assert_eq!(split_number("NaN"), None); + assert_eq!(split_number("GPa"), None); + } + + #[test] + fn numbers_format_with_six_significant_digits() { + assert_eq!(format_number(210000.0), "210000"); + assert_eq!(format_number(7.85e-9), "7.85e-9"); + assert_eq!(format_number(0.3), "0.3"); + assert_eq!(format_number(2.1e11), "2.1e11"); + assert_eq!(format_number(-20.0), "-20"); + assert_eq!(format_number(1.0 / 3.0), "0.333333"); + assert_eq!(format_number(0.0), "0"); + } +} diff --git a/crates/model/src/units/unit.rs b/crates/model/src/units/unit.rs new file mode 100644 index 0000000..9a9aef6 --- /dev/null +++ b/crates/model/src/units/unit.rs @@ -0,0 +1,420 @@ +//! Named units and unit expressions (`MPa`, `t/mm³`, `W/(m·K)`), each reduced to a dimension and +//! a factor to SI. +//! +//! This table is the only place in FieldCraft where a conversion factor is written down. + +use super::{Dimension, UnitError}; + +/// Exact definitions of the non-metric units (NIST SP 811). +const INCH: f64 = 0.0254; +const FOOT: f64 = 0.3048; +const POUND_MASS: f64 = 0.453_592_37; +const STANDARD_GRAVITY: f64 = 9.806_65; +const POUND_FORCE: f64 = POUND_MASS * STANDARD_GRAVITY; +/// Celsius zero in kelvin. +const CELSIUS_ZERO: f64 = 273.15; +/// One degree Fahrenheit (or Rankine) in kelvin. +const FAHRENHEIT_STEP: f64 = 5.0 / 9.0; +/// Fahrenheit zero in kelvin: 459.67 °R. +const FAHRENHEIT_ZERO: f64 = 459.67 * FAHRENHEIT_STEP; + +const L: Dimension = Dimension::LENGTH; +const M: Dimension = Dimension::MASS; +const T: Dimension = Dimension::TIME; +const TH: Dimension = Dimension::TEMPERATURE; +const ONE: Dimension = Dimension::NONE; +const FORCE: Dimension = Dimension::new(1, 1, -2, 0); +const PRESSURE: Dimension = Dimension::new(-1, 1, -2, 0); +const ENERGY: Dimension = Dimension::new(2, 1, -2, 0); +const POWER: Dimension = Dimension::new(2, 1, -3, 0); +const FREQUENCY: Dimension = Dimension::new(0, 0, -1, 0); + +/// A named unit: its spellings (the first is the canonical symbol), dimension, the size of one +/// unit in SI, and the SI value of its zero (non-zero only for °C and °F). +struct Atom { + names: &'static [&'static str], + dimension: Dimension, + factor: f64, + offset: f64, +} + +const fn atom(names: &'static [&'static str], dimension: Dimension, factor: f64) -> Atom { + Atom { names, dimension, factor, offset: 0.0 } +} + +const ATOMS: &[Atom] = &[ + // Length. + atom(&["m"], L, 1.0), + atom(&["km"], L, 1e3), + atom(&["cm"], L, 1e-2), + atom(&["mm"], L, 1e-3), + atom(&["µm", "μm", "um"], L, 1e-6), + atom(&["nm"], L, 1e-9), + atom(&["in", "inch"], L, INCH), + atom(&["ft"], L, FOOT), + // Mass. + atom(&["kg"], M, 1.0), + atom(&["g"], M, 1e-3), + atom(&["mg"], M, 1e-6), + atom(&["t", "tonne"], M, 1e3), + atom(&["lb", "lbm"], M, POUND_MASS), + // Time. + atom(&["s"], T, 1.0), + atom(&["ms"], T, 1e-3), + atom(&["µs", "μs", "us"], T, 1e-6), + atom(&["min"], T, 60.0), + atom(&["h"], T, 3600.0), + atom(&["Hz"], FREQUENCY, 1.0), + atom(&["kHz"], FREQUENCY, 1e3), + // Temperature: as an absolute temperature °C and °F carry their offset; inside a compound unit + // (W/(m·°C)) or as a temperature difference they are only a step size. + atom(&["K"], TH, 1.0), + Atom { names: &["°C", "degC", "℃"], dimension: TH, factor: 1.0, offset: CELSIUS_ZERO }, + Atom { names: &["°F", "degF", "℉"], dimension: TH, factor: FAHRENHEIT_STEP, offset: FAHRENHEIT_ZERO }, + // Force. + atom(&["N"], FORCE, 1.0), + atom(&["mN"], FORCE, 1e-3), + atom(&["kN"], FORCE, 1e3), + atom(&["MN"], FORCE, 1e6), + atom(&["lbf"], FORCE, POUND_FORCE), + atom(&["kip"], FORCE, 1e3 * POUND_FORCE), + // Pressure and stress. + atom(&["Pa"], PRESSURE, 1.0), + atom(&["kPa"], PRESSURE, 1e3), + atom(&["MPa"], PRESSURE, 1e6), + atom(&["GPa"], PRESSURE, 1e9), + atom(&["bar"], PRESSURE, 1e5), + atom(&["psi"], PRESSURE, POUND_FORCE / (INCH * INCH)), + atom(&["ksi"], PRESSURE, 1e3 * POUND_FORCE / (INCH * INCH)), + // Energy and power. + atom(&["J"], ENERGY, 1.0), + atom(&["mJ"], ENERGY, 1e-3), + atom(&["kJ"], ENERGY, 1e3), + atom(&["W"], POWER, 1.0), + atom(&["mW"], POWER, 1e-3), + atom(&["kW"], POWER, 1e3), + // Dimensionless: angles and ratios. + atom(&["rad"], ONE, 1.0), + atom(&["deg", "°"], ONE, std::f64::consts::PI / 180.0), + atom(&["%"], ONE, 1e-2), +]; + +/// Longest accepted unit or quantity text; longer input is refused unparsed. +pub(super) const MAX_TEXT: usize = 64; +/// Largest accepted exponent in a unit expression (`m^6` is the largest anything needs). +const MAX_POWER: i8 = 6; +/// Deepest accepted parenthesis nesting. +const MAX_DEPTH: usize = 4; + +/// A unit, possibly compound, reduced to SI: `value_si = value * factor + offset`. +#[derive(Debug, Clone, PartialEq)] +pub struct Unit { + symbol: String, + dimension: Dimension, + factor: f64, + /// The SI value of the unit's zero: non-zero only for a lone °C or °F. + offset: f64, + /// Whether the unit names an angle (`rad`, `deg`, `°`): only angles accept it. + angle: bool, +} + +impl Unit { + /// Parses a unit expression: named units joined by `*`, `·`, a space or `/`, with integer + /// powers (`^2`, `²`, `^-1`, `⁻¹`, or trailing digits as in `mm2`) and parentheses; a leading + /// `1` is a numerator (`1/K`). `1` alone is the unit of a dimensionless quantity. `.` is not a + /// multiplication sign (it reads as a decimal point). + pub fn parse(text: &str) -> Result { + let text = text.trim(); + if text.is_empty() { + return Err(UnitError::Empty); + } + if text.len() > MAX_TEXT { + return Err(UnitError::TooLong); + } + if text == "1" { + return Ok(Unit { symbol: text.to_string(), dimension: Dimension::NONE, factor: 1.0, offset: 0.0, angle: false }); + } + let mut p = Parser { chars: text.chars().collect(), pos: 0, text, angle: false }; + let (dimension, factor, atoms, offset) = p.expression(0)?; + if p.pos != p.chars.len() { + return Err(p.syntax()); + } + // An offset only means something for a lone temperature unit. + let offset = if atoms == 1 { offset } else { 0.0 }; + if !(factor.is_finite() && factor > 0.0) { + return Err(UnitError::OutOfRange(text.to_string())); + } + Ok(Unit { symbol: text.to_string(), dimension, factor, offset, angle: p.angle }) + } + + pub fn symbol(&self) -> &str { + &self.symbol + } + + pub fn dimension(&self) -> Dimension { + self.dimension + } + + /// The size of one unit in SI (for °C and °F, the size of one degree). + pub fn factor(&self) -> f64 { + self.factor + } + + /// Whether the unit names an angle (`rad`, `deg`, `°`). + pub fn is_angle(&self) -> bool { + self.angle + } + + /// Converts a value in this unit to SI. `absolute` applies the zero offset (absolute + /// temperatures); temperature differences and every other quantity pass `false`. + pub(super) fn to_si(&self, value: f64, absolute: bool) -> f64 { + value * self.factor + if absolute { self.offset } else { 0.0 } + } + + /// Converts an SI value to this unit; the inverse of [`Unit::to_si`]. + pub(super) fn express(&self, si: f64, absolute: bool) -> f64 { + (si - if absolute { self.offset } else { 0.0 }) / self.factor + } +} + +struct Parser<'a> { + chars: Vec, + pos: usize, + text: &'a str, + angle: bool, +} + +/// Dimension, factor, number of named units, offset of the last named unit. +type Parsed = (Dimension, f64, usize, f64); + +impl Parser<'_> { + fn syntax(&self) -> UnitError { + UnitError::Syntax(self.text.to_string()) + } + + fn peek(&self) -> Option { + self.chars.get(self.pos).copied() + } + + fn skip_spaces(&mut self) -> bool { + let start = self.pos; + while self.peek().is_some_and(char::is_whitespace) { + self.pos += 1; + } + self.pos > start + } + + fn expression(&mut self, depth: usize) -> Result { + self.skip_spaces(); + // A leading `1` is a numerator: `1/K`, `1/s`. + let leading_one = self.peek() == Some('1') && !self.chars.get(self.pos + 1).is_some_and(char::is_ascii_digit); + let (mut dim, mut factor, mut atoms, mut offset) = if leading_one { + self.pos += 1; + self.skip_spaces(); + if self.peek() != Some('/') { + return Err(self.syntax()); + } + (Dimension::NONE, 1.0, 0, 0.0) + } else { + self.term(depth)? + }; + loop { + let spaced = self.skip_spaces(); + let divide = match self.peek() { + Some('*' | '·') => false, + Some('/') => true, + // A space between two units multiplies: `N mm`, `W/(m K)`. + Some(c) if spaced && (c == '(' || c.is_alphabetic() || matches!(c, '°' | '℃' | '℉' | '%' | 'µ' | 'μ')) => { + self.pos -= 1; + false + } + _ => break, + }; + self.pos += 1; + self.skip_spaces(); + let (d, f, a, o) = self.term(depth)?; + let overflow = || UnitError::OutOfRange(self.text.to_string()); + if divide { + dim = dim.checked_div(d).ok_or_else(overflow)?; + factor /= f; + } else { + dim = dim.checked_mul(d).ok_or_else(overflow)?; + factor *= f; + } + atoms += a; + offset = o; + } + Ok((dim, factor, atoms, offset)) + } + + fn term(&mut self, depth: usize) -> Result { + let (dim, factor, atoms, offset) = if self.peek() == Some('(') { + if depth >= MAX_DEPTH { + return Err(self.syntax()); + } + self.pos += 1; + let inner = self.expression(depth + 1)?; + self.skip_spaces(); + if self.peek() != Some(')') { + return Err(self.syntax()); + } + self.pos += 1; + inner + } else { + self.atom()? + }; + let power = self.power()?; + if power == 1 { + return Ok((dim, factor, atoms, offset)); + } + let dim = dim.checked_pow(power).ok_or_else(|| UnitError::OutOfRange(self.text.to_string()))?; + // A powered unit is never a lone absolute temperature. + Ok((dim, factor.powi(i32::from(power)), atoms + 1, 0.0)) + } + + fn atom(&mut self) -> Result { + let start = self.pos; + while let Some(c) = self.peek() { + if c.is_alphabetic() || matches!(c, '°' | '℃' | '℉' | '%' | 'µ' | 'μ') { + self.pos += 1; + } else { + break; + } + } + if self.pos == start { + return Err(self.syntax()); + } + let name: String = self.chars.get(start..self.pos).unwrap_or_default().iter().collect(); + let atom = ATOMS.iter().find(|a| a.names.contains(&name.as_str())).ok_or_else(|| UnitError::UnknownUnit(name.clone()))?; + self.angle |= matches!(atom.names.first(), Some(&("rad" | "deg"))); + Ok((atom.dimension, atom.factor, 1, atom.offset)) + } + + /// An optional integer exponent; 1 when absent. + fn power(&mut self) -> Result { + let mut digits = String::new(); + if self.peek() == Some('^') { + self.pos += 1; + if self.peek() == Some('-') { + digits.push('-'); + self.pos += 1; + } + while let Some(c) = self.peek().filter(char::is_ascii_digit) { + digits.push(c); + self.pos += 1; + } + } else { + while let Some(c) = self.peek() { + let mapped = match c { + '⁻' => '-', + '¹' => '1', + '²' => '2', + '³' => '3', + '⁴' => '4', + '⁵' => '5', + '⁶' => '6', + d if d.is_ascii_digit() => d, + _ => break, + }; + digits.push(mapped); + self.pos += 1; + } + if digits.is_empty() { + return Ok(1); + } + } + match digits.parse::() { + Ok(p) if p != 0 && p.unsigned_abs() <= MAX_POWER.unsigned_abs() => Ok(p), + _ => Err(self.syntax()), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn close(a: f64, b: f64) -> bool { + (a - b).abs() <= 1e-12 * a.abs().max(b.abs()) + } + + #[test] + fn compound_units_reduce_to_si() { + let cases = [ + ("t/mm³", Dimension::new(-3, 1, 0, 0), 1e12), + ("t/mm^3", Dimension::new(-3, 1, 0, 0), 1e12), + ("kg/m3", Dimension::new(-3, 1, 0, 0), 1.0), + ("N/mm²", PRESSURE, 1e6), + ("W/(m·K)", Dimension::new(1, 1, -3, -1), 1.0), + ("mW/(mm*K)", Dimension::new(1, 1, -3, -1), 1.0), + ("W/m/K", Dimension::new(1, 1, -3, -1), 1.0), + ("K^-1", Dimension::new(0, 0, 0, -1), 1.0), + ("lbf·s²/in", M, POUND_FORCE / INCH), + ("mm/s", Dimension::new(1, 0, -1, 0), 1e-3), + ("MPa·s", Dimension::new(-1, 1, -1, 0), 1e6), + ]; + for (text, dim, factor) in cases { + let u = Unit::parse(text).unwrap_or_else(|e| panic!("{text}: {e}")); + assert_eq!(u.dimension(), dim, "{text}"); + assert!(close(u.factor(), factor), "{text}: {} vs {factor}", u.factor()); + } + } + + #[test] + fn numerators_and_spaces() { + let k = Unit::parse("1/K").unwrap(); + assert_eq!(k.dimension(), Dimension::new(0, 0, 0, -1)); + assert_eq!(Unit::parse("1/s").unwrap().dimension(), FREQUENCY); + assert_eq!(Unit::parse("1 / (m·K)").unwrap().dimension(), Dimension::new(-1, 0, 0, -1)); + for (spaced, plain) in + [("N · mm", "N·mm"), ("W/(m K)", "W/(m·K)"), ("kg m^-3", "kg/m^3"), ("W / ( m * K )", "W/(m·K)"), ("lbf s² / in", "lbf·s²/in")] + { + let (a, b) = (Unit::parse(spaced).unwrap(), Unit::parse(plain).unwrap()); + assert_eq!(a.dimension(), b.dimension(), "{spaced}"); + assert!(close(a.factor(), b.factor()), "{spaced}"); + } + assert!(Unit::parse("deg").unwrap().is_angle()); + assert!(Unit::parse("°").unwrap().is_angle()); + assert!(!Unit::parse("%").unwrap().is_angle()); + } + + #[test] + fn offsets_only_for_lone_temperatures() { + assert!(close(Unit::parse("°C").unwrap().to_si(20.0, true), 293.15)); + assert!(close(Unit::parse("°F").unwrap().to_si(32.0, true), 273.15)); + assert!(close(Unit::parse("°F").unwrap().to_si(212.0, true), 373.15)); + // In a compound unit the degree is a step. + let k = Unit::parse("W/(m·°C)").unwrap(); + assert!(close(k.to_si(1.0, true), 1.0)); + } + + #[test] + fn hostile_units_are_refused() { + for text in [ + "", + " ", + "m^", + "m^0", + "m^99", + "m^-128", + "((((((m))))))", + "(m", + "m)", + "m//s", + "mm2x", + "foo", + "m^7", + "BTU", + "m.s", + "1 K", + "11/K", + "2/K", + "1/", + ] { + assert!(Unit::parse(text).is_err(), "{text:?}"); + } + assert!(matches!(Unit::parse(&"m".repeat(100)), Err(UnitError::TooLong))); + // Exponent overflow across many factors. + assert!(Unit::parse("(m^6)^6·(m^6)^6·(m^6)^6·(m^6)^6").is_err()); + } +} diff --git a/crates/model/tests/units.rs b/crates/model/tests/units.rs new file mode 100644 index 0000000..aadffc7 --- /dev/null +++ b/crates/model/tests/units.rs @@ -0,0 +1,157 @@ +//! The unit table against hand-computed values. + +use fieldcraft_model::units::{DisplaySystem, Preset, Quantity, Unit, UnitError, ValueInput}; + +fn close(a: f64, b: f64) -> bool { + (a - b).abs() <= 1e-12 * a.abs().max(b.abs()).max(1e-300) +} + +fn si(q: Quantity, text: &str) -> f64 { + q.parse(text, &DisplaySystem::new(Preset::Si)).unwrap_or_else(|e| panic!("{text}: {e}")) +} + +fn shown(q: Quantity, si_value: f64, preset: Preset) -> f64 { + q.express(si_value, &DisplaySystem::new(preset).unit(q).unwrap()).unwrap() +} + +#[test] +fn hand_values() { + use Quantity as Q; + // E = 210 GPa = 210 000 MPa = 2.1e11 Pa. + let e = si(Q::Pressure, "210 GPa"); + assert!(close(e, 2.1e11)); + assert!(close(si(Q::Pressure, "210000 MPa"), e)); + assert!(close(shown(Q::Pressure, e, Preset::MmTonneS), 210_000.0)); + // 1 N/mm² = 1 MPa. + assert!(close(si(Q::Pressure, "1 N/mm²"), si(Q::Pressure, "1 MPa"))); + // Steel density 7850 kg/m³ = 7.85e-9 t/mm³. + let rho = si(Q::Density, "7850 kg/m³"); + assert!(close(shown(Q::Density, rho, Preset::MmTonneS), 7.85e-9)); + assert!(close(si(Q::Density, "7.85e-9 t/mm^3"), 7850.0)); + // 20 °C = 293.15 K, but a difference of 20 °C is 20 K. + assert!(close(si(Q::Temperature, "20 °C"), 293.15)); + assert!(close(si(Q::TemperatureDifference, "20 °C"), 20.0)); + assert!(close(si(Q::Temperature, "68 °F"), 293.15)); + assert!(close(si(Q::TemperatureDifference, "9 °F"), 5.0)); + assert!(close(shown(Q::Temperature, 293.15, Preset::MmTonneS), 20.0)); + assert!(close(shown(Q::Temperature, 293.15, Preset::InLbfS), 68.0)); + // Expansion 12e-6 /°C = 12e-6 /K; 6.667e-6 /°F. + assert!(close(si(Q::ThermalExpansion, "12e-6 °C⁻¹"), 12e-6)); + assert!(close(shown(Q::ThermalExpansion, 12e-6, Preset::InLbfS), 12e-6 * 5.0 / 9.0)); + // 25 mm/s = 0.025 m/s; g = 9.81 m/s² = 9810 mm/s² = 386.22 in/s². + assert!(close(si(Q::Velocity, "25 mm/s"), 0.025)); + assert!(close(shown(Q::Acceleration, 9.81, Preset::MmTonneS), 9810.0)); + assert!(close(shown(Q::Acceleration, 9.81, Preset::InLbfS), 9.81 / 0.0254)); + // Steel in in-lbf-s: E = 2.1e11 Pa = 30.458 Msi; ρ = 7850 kg/m³ = 7.345e-4 lbf·s²/in⁴. + let psi = 4.448_221_615_260_5 / (0.0254 * 0.0254); + assert!(close(shown(Q::Pressure, e, Preset::InLbfS), 2.1e11 / psi)); + assert!(close(shown(Q::Density, rho, Preset::InLbfS), 7850.0 * 0.0254 / 4.448_221_615_260_5 * 0.0254 * 0.0254 * 0.0254)); + // Steel conductivity 50 W/(m·K) is 50 mW/(mm·K); specific heat 460 J/(kg·K) is 4.6e8 mJ/(t·K). + assert!(close(shown(Q::ThermalConductivity, 50.0, Preset::MmTonneS), 50.0)); + assert!(close(shown(Q::SpecificHeat, 460.0, Preset::MmTonneS), 4.6e8)); + // 10 W/(m²·K) = 0.01 mW/(mm²·K); 1 kW/m² = 1 mW/mm². + assert!(close(shown(Q::HeatTransferCoefficient, 10.0, Preset::MmTonneS), 0.01)); + assert!(close(shown(Q::HeatFlux, 1000.0, Preset::MmTonneS), 1.0)); + // Water viscosity 1e-3 Pa·s = 1e-9 MPa·s; 1 N·mm = 1e-3 N·m = 1 mJ. + assert!(close(shown(Q::DynamicViscosity, 1e-3, Preset::MmTonneS), 1e-9)); + assert!(close(si(Q::Moment, "1 N·mm"), si(Q::Energy, "1 mJ"))); + // 90 deg = π/2 rad; 5 % = 0.05. + assert!(close(si(Q::Angle, "90 deg"), std::f64::consts::FRAC_PI_2)); + assert!(close(si(Q::Dimensionless, "5 %"), 0.05)); +} + +#[test] +fn every_preset_unit_has_its_quantitys_dimension() { + for preset in Preset::ALL { + let display = DisplaySystem::new(preset); + for &q in Quantity::ALL { + let unit = display.unit(q).unwrap_or_else(|e| panic!("{preset:?} {q:?}: {e}")); + assert_eq!(unit.dimension(), q.dimension(), "{preset:?} {q:?} `{}`", unit.symbol()); + } + assert_eq!(Preset::from_label(preset.label()), Some(preset)); + } +} + +#[test] +fn mm_tonne_second_is_consistent() { + // A consistent system: force = mass · acceleration and stress = force / area hold with no + // factor, so the deck writers can write its numbers directly. + let d = DisplaySystem::new(Preset::MmTonneS); + let f = |q| d.unit(q).unwrap().factor(); + use Quantity as Q; + assert!(close(f(Q::Force), f(Q::Mass) * f(Q::Acceleration))); + assert!(close(f(Q::Pressure), f(Q::Force) / f(Q::Area))); + assert!(close(f(Q::Energy), f(Q::Force) * f(Q::Length))); + assert!(close(f(Q::Density), f(Q::Mass) / f(Q::Volume))); + let i = DisplaySystem::new(Preset::InLbfS); + let g = |q| i.unit(q).unwrap().factor(); + assert!(close(g(Q::Force), g(Q::Mass) * g(Q::Acceleration))); + assert!(close(g(Q::Pressure), g(Q::Force) / g(Q::Area))); +} + +#[test] +fn round_trips_through_every_preset() { + let values = [(Quantity::Pressure, 2.1e11), (Quantity::Density, 7850.0), (Quantity::Temperature, 293.15), (Quantity::Length, 0.0254)]; + for preset in Preset::ALL { + let d = DisplaySystem::new(preset); + for (q, v) in values { + let text = q.format(v, &d).unwrap(); + let back = q.parse(&text, &d).unwrap(); + assert!((back - v).abs() <= 1e-5 * v.abs(), "{preset:?} {q:?}: {text} -> {back}"); + } + } + let mm = DisplaySystem::default(); + assert_eq!(Quantity::Pressure.format(2.1e11, &mm).unwrap(), "210000 MPa"); + assert_eq!(Quantity::Density.format(7850.0, &mm).unwrap(), "7.85e-9 t/mm³"); + assert_eq!(Quantity::Temperature.format(293.15, &mm).unwrap(), "20 °C"); + assert_eq!(Quantity::Dimensionless.format(0.3, &mm).unwrap(), "0.3"); +} + +#[test] +fn inputs_from_commands() { + let mm = DisplaySystem::default(); + let parse = |json: &str| serde_json::from_str::(json).unwrap().to_si(Quantity::Pressure, &mm); + assert!(close(parse("210000").unwrap(), 2.1e11)); // bare number: display system (MPa) + assert!(close(parse(r#""210 GPa""#).unwrap(), 2.1e11)); + assert!(close(parse(r#"{"value": 210, "unit": "GPa"}"#).unwrap(), 2.1e11)); + assert!(close(Quantity::Pressure.parse("210000", &mm).unwrap(), 2.1e11)); + assert!(serde_json::from_str::(r#"{"value": 1, "unit": "Pa", "extra": 0}"#).is_err()); + let over = DisplaySystem::default().with_override(Quantity::Energy, "J").unwrap(); + assert_eq!(Quantity::Energy.format(2.0, &over).unwrap(), "2 J"); + assert!(DisplaySystem::default().with_override(Quantity::Energy, "MPa").is_err()); +} + +#[test] +fn hostile_values_are_refused() { + let mm = DisplaySystem::default(); + use Quantity as Q; + let wrong = Q::Pressure.parse("210 mm", &mm); + assert!(matches!(wrong, Err(UnitError::WrongDimension { .. })), "{wrong:?}"); + assert_eq!(wrong.unwrap_err().to_string(), "`mm` is L, but a pressure is L^-1 M T^-2"); + for text in ["", "GPa", "nan GPa", "inf Pa", "1e400 Pa", "1e300 GPa", "210 GPa extra", "210 parsecs", "--1 Pa"] { + assert!(Q::Pressure.parse(text, &mm).is_err(), "{text:?}"); + } + assert!(Q::Density.parse("1e300 t/mm³", &mm).is_err()); + assert!(matches!(Q::Temperature.parse("-300 °C", &mm), Err(UnitError::BelowAbsoluteZero(_)))); + assert!(Q::TemperatureDifference.parse("-300 °C", &mm).is_ok()); + assert!(Q::Pressure.to_si(f64::NAN, &Unit::parse("Pa").unwrap()).is_err()); + assert!(Q::Pressure.parse(&"9".repeat(500), &mm).is_err()); + let input = ValueInput::WithUnit(fieldcraft_model::units::WithUnit { value: f64::INFINITY, unit: "Pa".into() }); + assert!(input.to_si(Q::Pressure, &mm).is_err()); +} + +#[test] +fn angles_and_ratios_are_kept_apart() { + let mm = DisplaySystem::default(); + use Quantity as Q; + assert!(matches!(Q::Angle.parse("5 %", &mm), Err(UnitError::WrongKind { .. }))); + assert!(matches!(Q::Dimensionless.parse("1 deg", &mm), Err(UnitError::WrongKind { .. }))); + assert!(matches!(Q::Angle.parse("90 mm", &mm), Err(UnitError::WrongDimension { .. }))); + assert!(Q::Pressure.parse("1 MPa·deg", &mm).is_err()); + assert!(close(Q::Angle.parse("90 °", &mm).unwrap(), std::f64::consts::FRAC_PI_2)); + assert!(close(Q::Angle.parse("90", &mm).unwrap(), std::f64::consts::FRAC_PI_2)); // display: deg + assert!(close(Q::Dimensionless.parse("0.3", &mm).unwrap(), 0.3)); + assert!(close(Q::ThermalExpansion.parse("12e-6 1/K", &mm).unwrap(), 12e-6)); + assert!(close(Q::Frequency.parse("0.01 1/s", &mm).unwrap(), 0.01)); + assert!(close(Q::ThermalConductivity.parse("50 W/(m K)", &mm).unwrap(), 50.0)); +}