Files
jyvikoandClaude Opus 5.5 af7836d3ce 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
2026-10-18 10:30:33 -04:00

158 lines
8.1 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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));
}