forked from 1b-agent/fieldcraft-upstream
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
158 lines
8.1 KiB
Rust
158 lines
8.1 KiB
Rust
//! 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));
|
||
}
|