forked from 1b-agent/fieldcraft-upstream
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:
1 parent
8201f9a954
commit
af7836d3ce
6 files changed
+1118
-5
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
Reference in new issue
Block a user