model: SI unit table (dimensions, units, quantities, display presets)

Part of #579. A units table in fieldcraft-model: Dimension as SI base
exponents, named units with factors (offsets only for absolute °C/°F),
unit expressions (t/mm³, W/(m·K)), Quantity kinds with parsing of
"210 GPa" / {value, unit} under dimension checks, formatting, and display
presets (SI, m-kg-s, mm-t-s-N-MPa, in-lbf-s-psi). Model storage unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
jyvikoandClaude Opus 5.5 committed 2026-10-18 10:30:33 -04:00
1 parent 8201f9a954
commit af7836d3ce
6 files changed
+1118 -5

No files matched your search

+96
View File
@@ -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<i8>) -> Option<Dimension> {
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<Dimension> {
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<Dimension> {
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<Dimension> {
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);
}
}
+151
View File
@@ -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> {
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<DisplaySystem, UnitError> {
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<Unit, UnitError> {
match self.overrides.iter().find(|(o, _)| *o == q) {
Some((_, u)) => Ok(u.clone()),
None => Unit::parse(self.preset.symbol(q)),
}
}
}
@@ -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};
+246
View File
@@ -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> {
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<f64, UnitError> {
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<f64, UnitError> {
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<f64, UnitError> {
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<String, UnitError> {
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<f64, UnitError> {
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");
}
}
+420
View File
@@ -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<Unit, UnitError> {
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<char>,
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<char> {
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<Parsed, UnitError> {
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<Parsed, UnitError> {
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<Parsed, UnitError> {
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<i8, UnitError> {
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::<i8>() {
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());
}
}
+157
View File
@@ -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::<ValueInput>(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::<ValueInput>(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));
}