forked from 1b-agent/fieldcraft-upstream
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1fe5def0f | ||
|
|
732e57ebf1 |
No files matched your search
@@ -26,6 +26,8 @@ is committed and listed here with its exports.
|
|||||||
| docs/media/examples/crank-mirror.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the solved crank-mirror sample (docs/examples.md) | MIT OR Apache-2.0 |
|
| docs/media/examples/crank-mirror.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the solved crank-mirror sample (docs/examples.md) | MIT OR Apache-2.0 |
|
||||||
| docs/meshing/skill/analytic-cylinder.png, docs/meshing/skill/analytic-pipe.png, docs/meshing/skill/analytic-plate-hole.png, docs/meshing/skill/analytic-sphere.png, docs/meshing/skill/analytic-torus.png, docs/meshing/skill/bent-tube.png, docs/meshing/skill/bolted-assembly.png, docs/meshing/skill/cantilever-i-beam.png, docs/meshing/skill/crank-arm.png, docs/meshing/skill/cup.png, docs/meshing/skill/filleted-bracket-blocks.png, docs/meshing/skill/filleted-bracket.png, docs/meshing/skill/i-beam.png, docs/meshing/skill/loft-transition.png, docs/meshing/skill/perforated-plate.png, docs/meshing/skill/pi5-base.png, docs/meshing/skill/pi5-lid.png, docs/meshing/skill/pinhole-block.png, docs/meshing/skill/plain-cup.png, docs/meshing/skill/rounded-box.png, docs/meshing/skill/shallow-groove.png, docs/meshing/skill/sharp-wedge.png, docs/meshing/skill/sphere-on-plate.png, docs/meshing/skill/spline-vase.png, docs/meshing/skill/stepped-block.png, docs/meshing/skill/thin-ribs.png, docs/meshing/skill/thin-wall-enclosure.png, docs/meshing/skill/upright-bracket.png | FieldCraft contributors | Original: section pictures of the corpus parts and samples meshed by the meshing skill, drawn by `fieldcraft-cli snapshot` (`cargo xtask skill-results`, docs/meshing/skill-results.md) | MIT OR Apache-2.0 |
|
| docs/meshing/skill/analytic-cylinder.png, docs/meshing/skill/analytic-pipe.png, docs/meshing/skill/analytic-plate-hole.png, docs/meshing/skill/analytic-sphere.png, docs/meshing/skill/analytic-torus.png, docs/meshing/skill/bent-tube.png, docs/meshing/skill/bolted-assembly.png, docs/meshing/skill/cantilever-i-beam.png, docs/meshing/skill/crank-arm.png, docs/meshing/skill/cup.png, docs/meshing/skill/filleted-bracket-blocks.png, docs/meshing/skill/filleted-bracket.png, docs/meshing/skill/i-beam.png, docs/meshing/skill/loft-transition.png, docs/meshing/skill/perforated-plate.png, docs/meshing/skill/pi5-base.png, docs/meshing/skill/pi5-lid.png, docs/meshing/skill/pinhole-block.png, docs/meshing/skill/plain-cup.png, docs/meshing/skill/rounded-box.png, docs/meshing/skill/shallow-groove.png, docs/meshing/skill/sharp-wedge.png, docs/meshing/skill/sphere-on-plate.png, docs/meshing/skill/spline-vase.png, docs/meshing/skill/stepped-block.png, docs/meshing/skill/thin-ribs.png, docs/meshing/skill/thin-wall-enclosure.png, docs/meshing/skill/upright-bracket.png | FieldCraft contributors | Original: section pictures of the corpus parts and samples meshed by the meshing skill, drawn by `fieldcraft-cli snapshot` (`cargo xtask skill-results`, docs/meshing/skill-results.md) | MIT OR Apache-2.0 |
|
||||||
| docs/media/examples/flow-past.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the flow-past sample before solving (its bracket as the sample opens) (docs/examples.md) | MIT OR Apache-2.0 |
|
| docs/media/examples/flow-past.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the flow-past sample before solving (its bracket as the sample opens) (docs/examples.md) | MIT OR Apache-2.0 |
|
||||||
|
| docs/media/examples/cooling-enclosure.png | FieldCraft contributors | Original: the natural-convection sealed-enclosure cooling study rendered by FieldCraft (`ui.render`, light theme), generated by `tools/cooling/make.py`; geometry is the committed `examples/cooling/enclosure.solidcraft.json` recipe | MIT OR Apache-2.0 |
|
||||||
|
| docs/media/examples/cooling-heat-sink.png | FieldCraft contributors | Original: the forced-convection heat-sink cooling study rendered by FieldCraft (`ui.render`, light theme), generated by `tools/cooling/make.py`; geometry is the committed `examples/cooling/heatsink.solidcraft.json` recipe | MIT OR Apache-2.0 |
|
||||||
| docs/media/examples/flow-past-flow.png | FieldCraft contributors | Original: the solved flow-past sample rendered by FieldCraft (`ui.render`, light theme), velocity on the mid-slice with the bracket and upstream streamlines, generated by `tools/flow-clip/make.py` | MIT OR Apache-2.0 |
|
| docs/media/examples/flow-past-flow.png | FieldCraft contributors | Original: the solved flow-past sample rendered by FieldCraft (`ui.render`, light theme), velocity on the mid-slice with the bracket and upstream streamlines, generated by `tools/flow-clip/make.py` | MIT OR Apache-2.0 |
|
||||||
| docs/media/examples/flow-past-transient.mp4 | FieldCraft contributors | Original: the flow-past start-up transient rendered frame by frame by FieldCraft (`ui.render`), generated by `tools/flow-clip/make.py` (captions drawn by `tools/demo-video/caption.py` in Inter, OFL, crates/ui-egui/fonts) | MIT OR Apache-2.0 |
|
| docs/media/examples/flow-past-transient.mp4 | FieldCraft contributors | Original: the flow-past start-up transient rendered frame by frame by FieldCraft (`ui.render`), generated by `tools/flow-clip/make.py` (captions drawn by `tools/demo-video/caption.py` in Inter, OFL, crates/ui-egui/fonts) | MIT OR Apache-2.0 |
|
||||||
| docs/media/examples/cantilever.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the solved cantilever sample (docs/examples.md) | MIT OR Apache-2.0 |
|
| docs/media/examples/cantilever.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the solved cantilever sample (docs/examples.md) | MIT OR Apache-2.0 |
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
//! Compile a SolidCraft recipe script (`{"commands": [...]}`) into a `.solidcraft` design that
|
||||||
|
//! `geometry.open` (and so `fieldcraft-cli run`/`snapshot`) can open. Used by the cooling-examples
|
||||||
|
//! runner (`tools/cooling/make.py`) to build the committed example geometry from its recipe.
|
||||||
|
//!
|
||||||
|
//! `cargo run --example compile_recipe -p fieldcraft-geometry --features scripts -- recipe.json out.solidcraft`
|
||||||
|
fn main() -> Result<(), String> {
|
||||||
|
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||||
|
let (recipe, out) = match args.as_slice() {
|
||||||
|
[recipe, out] => (recipe, out),
|
||||||
|
_ => return Err("usage: compile_recipe <recipe.json> <out.solidcraft>".into()),
|
||||||
|
};
|
||||||
|
let script = std::fs::read_to_string(recipe).map_err(|e| format!("read {recipe}: {e}"))?;
|
||||||
|
let bytes = fieldcraft_geometry::script_design(&script).map_err(|e| format!("compile: {e}"))?;
|
||||||
|
std::fs::write(out, &bytes).map_err(|e| format!("write {out}: {e}"))?;
|
||||||
|
eprintln!("compile_recipe: wrote {out} ({} bytes)", bytes.len());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -1,52 +1,9 @@
|
|||||||
//! Units: the unit system label a model carries today, and the SI unit table every conversion
|
//! The unit system label.
|
||||||
//! goes through.
|
|
||||||
//!
|
//!
|
||||||
//! **The table** ([`Dimension`], [`Unit`], [`Quantity`], [`DisplaySystem`]) is the one place
|
//! The model never converts: numbers are stored as entered. The label records which consistent
|
||||||
//! FieldCraft writes down a conversion factor. Each [`Quantity`] has a dimension and an SI unit;
|
//! system they are in, so the check can warn when a magnitude is implausible for it (a steel
|
||||||
//! a [`Unit`] parses expressions such as `MPa`, `t/mm³` or `W/(m·K)` to a dimension and a factor
|
//! modulus of 210 000 in a metre–pascal model, a density of 7850 in a millimetre–tonne one), and
|
||||||
//! to SI; [`Quantity::parse`] reads `"210 GPa"` or `"20 °C"` with a dimension check, and
|
//! so writers can say which system a deck is in.
|
||||||
//! [`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};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
//! 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
//! 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,246 +0,0 @@
|
|||||||
//! 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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,420 +0,0 @@
|
|||||||
//! 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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
//! 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));
|
|
||||||
}
|
|
||||||
@@ -227,3 +227,82 @@ temperature (with a small numerical tolerance); recorded hot walls give heat to
|
|||||||
This coarse, short transient is a quick display demonstration, not a converged convection
|
This coarse, short transient is a quick display demonstration, not a converged convection
|
||||||
benchmark. It has neither buoyancy nor solid heat conduction. The sample is isolated in
|
benchmark. It has neither buoyancy nor solid heat conduction. The sample is isolated in
|
||||||
`crates/engine/src/sample/heated_flow.rs` and its gallery registration.
|
`crates/engine/src/sample/heated_flow.rs` and its gallery registration.
|
||||||
|
|
||||||
|
# Electronics cooling studies
|
||||||
|
|
||||||
|
These are larger CFD studies, not one-click samples: each sets up a thermal lattice-Boltzmann
|
||||||
|
model from a committed SolidCraft recipe ([`examples/cooling`](../examples/cooling)) and solves it
|
||||||
|
on the CPU in a few minutes. `tools/cooling/make.py` (see its
|
||||||
|
[README](../tools/cooling/README.md)) rebuilds their numbers and pictures. They show what the
|
||||||
|
fluid solver can do for electronics cooling today, and where it stands in for what it cannot yet
|
||||||
|
do — approximations made honestly:
|
||||||
|
|
||||||
|
- **No conjugate heat transfer into solids yet**
|
||||||
|
([#157](https://github.com/tangency-labs/fieldcraft/issues/157)): a high-conductivity heat sink
|
||||||
|
or cold plate is modelled as an **isothermal surface**, and a component as a **held-temperature
|
||||||
|
or heat-flux face**. Real junction temperatures need conduction through the solid.
|
||||||
|
- **No fan curve**: a fan is a **uniform inlet velocity**. The current two-relaxation-time
|
||||||
|
collision is resolution-bound near its stability limit
|
||||||
|
([#557](https://github.com/tangency-labs/fieldcraft/issues/557)), so the forced cases use a
|
||||||
|
**modest duct velocity** standing in for a low-speed fan; higher fan speeds need the finer-grid
|
||||||
|
and turbulence work tracked there.
|
||||||
|
- **No surface-to-surface radiation**: the sealed-enclosure case is **convection only** (radiation
|
||||||
|
carries a real share of the heat in a sealed box).
|
||||||
|
|
||||||
|
Background for the planned electronics-cooling release is in
|
||||||
|
[discussion #584](https://github.com/tangency-labs/fieldcraft/discussions/584).
|
||||||
|
|
||||||
|
## Heat sink in forced air (`examples/cooling/heatsink`)
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
A 40 × 40 mm aluminium heat sink with six 18 mm fins, held isothermal at 80 °C (a high-conductivity
|
||||||
|
sink with fin efficiency near 1 — see the note above), in a 120 × 44 × 30 mm duct. Air at 20 °C
|
||||||
|
enters at 100 mm/s (0.1 m/s, a gentle fan), the duct walls no-slip and the outlet at zero pressure.
|
||||||
|
D3Q19 TRT with the D3Q7 thermal lattice on 158,400 cells of 1 mm, solved to 4 s (16,000 steps at a
|
||||||
|
halved time step, so the weakly-compressible density error stays near 1 %). About four minutes on
|
||||||
|
one CPU.
|
||||||
|
|
||||||
|
**Numbers:** the sink sheds **4.4 W** to the air — a sink-to-inlet **thermal resistance of
|
||||||
|
13.5 K/W** over the 60 K rise. Halving the time step again moves the heat by under 3 %, so the
|
||||||
|
figure is converged; it is in the range expected for a small sink in gentle airflow.
|
||||||
|
|
||||||
|
**Look at:** the air warming on the mid-plane temperature slice and the streamlines coloured by
|
||||||
|
temperature (`results.slice` / `results.streamlines` with `field: "temperature"`), with the sink
|
||||||
|
coloured by the flow beside it (`results.surface`). The heat each fin face gives the air is in the
|
||||||
|
`solve.fluid` report (`heat`, negative into the fluid).
|
||||||
|
|
||||||
|
**Caveat:** the reported minimum air temperature dips a few kelvin below the inlet near the sharp
|
||||||
|
fin corners — a local artifact of the D3Q7 anti-bounce-back wall at convex edges, not a physical
|
||||||
|
undershoot; the legend is clamped to the physical range.
|
||||||
|
|
||||||
|
**Regenerate:** `python3 tools/cooling/make.py heat-sink` (needs a display, or `xvfb-run`).
|
||||||
|
|
||||||
|
## Sealed enclosure with a PCB (`examples/cooling/enclosure`)
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
A small package held at 40 °C on a vertical PCB inside a sealed 60 × 40 × 60 mm air box whose walls
|
||||||
|
sit at 25 °C ambient — no fan. Buoyancy (Boussinesq, β = 3.4 × 10⁻³ K⁻¹, g along −z) drives a plume
|
||||||
|
up off the chip; the air stratifies warm at the ceiling over cool air below, and recirculates down
|
||||||
|
the walls. D3Q19 TRT with the D3Q7 thermal lattice and buoyancy on 144,000 cells of 1 mm, stepped
|
||||||
|
at a fixed 0.2 ms to 3 s (15,000 steps). About four minutes on one CPU.
|
||||||
|
|
||||||
|
**Numbers:** the chip sheds **63 mW** by natural convection — a natural-convection coefficient of
|
||||||
|
about **5 W/m²K** over its surface, which is in the textbook range for a small body in still air,
|
||||||
|
and a chip-to-ambient resistance of about **237 K/W**. That the bare chip's resistance is ~18×
|
||||||
|
the finned, fan-cooled sink's (13.5 K/W) is the point: natural convection alone carries little, so
|
||||||
|
real parts need a heat sink or airflow. The air stays between the wall and chip temperatures
|
||||||
|
(24.9–39.5 °C) with no numerical undershoot.
|
||||||
|
|
||||||
|
**Look at:** the temperature on the vertical mid-plane slice (the stratification), the streamlines
|
||||||
|
of the buoyant recirculation, and the heat the chip sheds in the `solve.fluid` report.
|
||||||
|
|
||||||
|
**Caveats:** the buoyant **start-up** needs a small fixed time step — the solver's automatic step
|
||||||
|
over-accelerates the first instant and goes unstable (lattice speed 0.505 at step 38); the steady
|
||||||
|
plume itself is gentle (73 mm/s, cell Reynolds ≈ 5), so a steady or accelerated solve would avoid
|
||||||
|
resolving the transient (see the Gaps note on the pull request). The rise is capped at 15 K for the
|
||||||
|
same reason. This is **convection only**: a sealed enclosure also loses a real share of the heat by
|
||||||
|
**surface-to-surface radiation**, which the solver does not model yet.
|
||||||
|
|
||||||
|
**Regenerate:** `python3 tools/cooling/make.py enclosure` (needs a display, or `xvfb-run`).
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 60 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 70 KiB |
@@ -1,107 +0,0 @@
|
|||||||
# Units inventory (#579)
|
|
||||||
|
|
||||||
Every place a dimensional number enters or leaves FieldCraft today, the unit it is assumed to be
|
|
||||||
in, and where the SI conversion goes. Today nothing converts: every stored `f64` means "whatever
|
|
||||||
consistent system the user chose", labelled by `model.units` (`UnitSystem`,
|
|
||||||
`crates/model/src/units/mod.rs`). The SI table that later steps convert through is
|
|
||||||
`crates/model/src/units/` (`Quantity`, `Unit`, `DisplaySystem`). **Risky** marks an implicit
|
|
||||||
assumption, a hard-coded factor or a missing dimension.
|
|
||||||
|
|
||||||
## Model storage (`crates/model/src`): all bare `f64`, no unit tag
|
|
||||||
Conversion goes at the command/file boundary; storage becomes SI.
|
|
||||||
|
|
||||||
| Where | Fields | Quantity |
|
|
||||||
|---|---|---|
|
|
||||||
| `lib.rs` | `units: UnitSystem` (serde default `unspecified`); `MAX_COORD = 1e9` "model units" | label; length |
|
|
||||||
| `mesh.rs`, `datums.rs`, `constraints.rs`, `relaxations.rs`, `mesh_plans.rs` | node/datum/reference point xyz, plan centre/radius/size/tolerance | length |
|
|
||||||
| `sizes.rs`, `seeds.rs`, `virtual_faces.rs`, `tie.rs` | size regions, seed sizes, tolerances, position_tolerance | length (bias, growth: dimensionless) |
|
|
||||||
| `materials.rs` | young, shear, hyperelastic c01/c20/c30, yield_stress, hardening_modulus, hardening table stress, JC a/b | pressure |
|
|
||||||
| | poisson, strains, JC n/c/m, taylor_quinney, damage d1–d5 | dimensionless |
|
|
||||||
| | JC reference_strain_rate | frequency (1/s) |
|
|
||||||
| | JC room/melt temperature, thermal reference_temperature | temperature (absolute) |
|
|
||||||
| | conductivity, specific_heat, expansion | conductivity, specific heat, expansion (1/ΔT) |
|
|
||||||
| | density; fluid viscosity (doc: "1e-9 MPa·s for water") | density; dynamic viscosity |
|
|
||||||
| `sections.rs` | profile dims, shell thickness/offset, 2D thickness, area | length, area |
|
|
||||||
| `loads.rs` | force, moment, traction, pressure, gravity, body force, centrifugal ω/point, surface flux, film, sink, heat source power, temperature | force, moment, pressure, acceleration, force/volume, angular velocity, heat flux, HTC, temperature, heat generation |
|
|
||||||
| `bcs.rs` | displacement `[Option<f64>; 6]` (3 lengths + 3 rotations rad), temperature | length, angle, temperature |
|
|
||||||
| `steps.rs` | frequency min/max_hz, buckle shift, end_time/increments/intervals, initial velocity, rigid wall point, hourglass/bulk viscosity coefs, heat theta/initial_temperature, imperfection scale, displacement control | frequency, time, velocity, length, temperature (coefs dimensionless) |
|
|
||||||
| `contact.rs` | μ static/kinetic, penalty_scale; **decay (time per length)** | dimensionless; s/m |
|
|
||||||
| `discrete.rs` | point mass, inertia, spring stiffness, dashpot coefficient | mass, mass·length², stiffness, damping |
|
|
||||||
| `fluids.rs` | wall/inlet velocity, pressure, thermal temperature/flux, initial temperature, gravity, β, domain min/max/spacing/tolerance | velocity, pressure, temperature, heat flux, acceleration, expansion, length |
|
|
||||||
|
|
||||||
**Risky:** `units` missing in a file means `unspecified`, so a migration cannot know the system of
|
|
||||||
old files without one; the step cache key includes `units`.
|
|
||||||
|
|
||||||
## Commands and forms (`crates/engine/src/cmd`, `schema.rs`, `cmd/forms.rs`)
|
|
||||||
Definition commands (`model.{material,section,load,boundary_condition,…}.{create,edit}`) deserialize
|
|
||||||
JSON straight into the model structs (`cmd/define.rs`), so every number is a bare `f64`. Each form
|
|
||||||
field carries a `Dim` (`schema.rs:299-418`); `Dim::unit(system)` only builds a label. Conversion
|
|
||||||
goes where the form value is read: `Field` → `Quantity` and `ValueInput::to_si`.
|
|
||||||
|
|
||||||
**Risky:**
|
|
||||||
- `Dim::Millimetre` is a fixed "mm" for meshing and geometry commands (tet/hex size, tolerances,
|
|
||||||
datums, hex project, defeature, mesh plan, geometry check), while the same quantities in mesh
|
|
||||||
editing commands (node add/move/relax, seeds, size regions, block, mirror, planar, stitch) are
|
|
||||||
`Dim::Length` and follow the system. One physical quantity, two assumptions.
|
|
||||||
- Missing dimensions: load heat_source power (`forms.rs:561`, really W/m³), frequency shift
|
|
||||||
(`forms.rs:2024`, really 1/s²), contact decay (`forms.rs:1015`, s/m), JC reference strain
|
|
||||||
rate (`forms.rs:137`, 1/s).
|
|
||||||
- `Dim::Temperature` has no unit; `Dim::Expansion` is fixed "1/K" while temperatures are unlabelled.
|
|
||||||
- JSON Schema (`schema.rs:1260-1365`, list_commands, `fieldcraft-cli commands --json`, MCP) emits
|
|
||||||
only `x-dimension` names and none for vectors and number_or_auto (forces, positions, gravity,
|
|
||||||
velocities lose their dimension).
|
|
||||||
- Help/defaults assume mm-t-s: `forms.rs:520` (gravity −9810), `define.rs:98` (210000, 7.85e-9),
|
|
||||||
`define.rs:291`; samples `engine/src/sample.rs:362` `GRAVITY = 9810.0` and aluminium
|
|
||||||
70000 / 2.7e-9.
|
|
||||||
- `model.units` (`cmd/model.rs:39-54`) only sets the label; it warns about mm geometry only while a
|
|
||||||
design is open.
|
|
||||||
|
|
||||||
## UI display (`crates/ui-egui`)
|
|
||||||
Forms (`form.rs`) label through `Field::unit_label/help_text`; descriptions (`describe.rs`),
|
|
||||||
replies (`reply.rs`), results panel and probes (`results_panel.rs`, `lib.rs` `probe_rows`) through
|
|
||||||
`Quantity::unit(system)`. Display conversion goes here (`Quantity::format` with the display
|
|
||||||
system). **Risky:** literal "mm", "mm²", "mm³" in `reply.rs:398, 708, 720, 723, 770` and
|
|
||||||
`feature_graph.rs:52-257`.
|
|
||||||
|
|
||||||
## Results (`crates/engine/src/results.rs`, `flow.rs`, `cmd/xy.rs`, `cmd/results.rs`)
|
|
||||||
`Field::quantity()` maps fields to the engine's `Quantity` (Length, Stress, Force, Moment, Mass,
|
|
||||||
Time, Velocity, Temperature, Energy, HeatFlow, HeatFlux); `Quantity::unit` builds the label, with
|
|
||||||
per-system strings for energy/power/flux. Reactions and probe replies are numbers only; contour
|
|
||||||
replies carry `"unit"`. **Risky:** temperature is always `"°C or K"` (also in in-lbf-s);
|
|
||||||
`ContactGap` (a length) maps to `None`. Conversion: results stay SI; legends and replies format.
|
|
||||||
|
|
||||||
## Files
|
|
||||||
| Format | Today | Conversion |
|
|
||||||
|---|---|---|
|
|
||||||
| `.fieldcraft` (`crates/io`, `FORMAT = "fieldcraft/1"`) | `model.json` numbers as entered plus `"units"`; `mesh.bin` raw coordinates | scale to SI once on open by the declared system. **Risky:** FieldCraft has no pending-format mechanism (only the `FORMAT` const and its refusal); SI storage needs a format version step. Files with `unspecified` need a rule (assume mm-t-s-N-MPa, since geometry is mm). |
|
|
||||||
| Abaqus / Nastran / LS-DYNA (`crates/decks`, write only) | unit label in a comment only | write in a chosen consistent system (`DisplaySystem` factors). **Risky:** no Nastran `PARAM,WTMASS`, no LS-DYNA `*CONTROL_UNITS`; there are no deck readers yet. |
|
|
||||||
| SolidCraft geometry (`crates/geometry`) | "lengths are millimetres" (`lib.rs:16, 96`) | ×1e-3 once at import |
|
|
||||||
| 3MF (`geometry/src/tagged_mesh.rs:151-161`) | **the one real conversion outside the table**: micron/mm/cm/inch/foot/metre → mm; writes `unit="millimeter"` | move factors to the table, convert to m |
|
|
||||||
| STEP | no import exists | — |
|
|
||||||
| STL export (`io/src/stl.rs`) | raw coordinates | write in the display length (STL has no unit field) |
|
|
||||||
| VTU/PVD (`io/src/vtk.rs`) | no unit metadata | add units in field data |
|
|
||||||
| CSV (`cmd/results.rs:1023-1075`) | bare headers; reply has `"units"` | units in headers (`fx [N]`) |
|
|
||||||
|
|
||||||
## CLI and MCP
|
|
||||||
`crates/mcp/src/server.rs:26-27` instructions: "unit-consistent and never converts … use
|
|
||||||
mm-t-s-N-MPa"; `tools.rs:105` example `model.units`; screenshot distance "in model units". The
|
|
||||||
CLI adds no units itself (commands schema as above). Replies switch to values with units.
|
|
||||||
|
|
||||||
## Solvers
|
|
||||||
- LBM (`crates/lbm/src/setup.rs`): lattice units Δx = Δt = ρ₀ = 1; ν, velocities, accelerations,
|
|
||||||
pressure, force, heat scale through h, dt, ρ, c. Self-consistent in any system, no fixed
|
|
||||||
factors; unchanged by SI storage. Temperatures pass unscaled (only differences matter).
|
|
||||||
- Implicit, explicit, fem: no unit literals; thermal strain uses α(T − T_ref), Johnson–Cook uses
|
|
||||||
room/melt in one scale: correct in kelvin. No 273.15 or Stefan–Boltzmann anywhere (radiation
|
|
||||||
would need absolute kelvin, which SI storage gives).
|
|
||||||
|
|
||||||
## Literal factors
|
|
||||||
`cargo xtask unit-factors` lists float literals equal to common factors outside the table: 548
|
|
||||||
today (mesh 187, engine 175, geometry 34, render 33, fem 25, …). Almost all are tolerances,
|
|
||||||
limits or bytes→MB; the real conversions are the 3MF table and the mm-t-s sample constants.
|
|
||||||
|
|
||||||
## Implicit "geometry in mm"
|
|
||||||
Mesh coordinates come from mm geometry whatever `model.units` says: an m-kg-s or in-lbf-s model
|
|
||||||
reads mm numbers as metres or inches; the only guard is the `model.units` warning. SI storage
|
|
||||||
fixes this at the import boundary. `MAX_COORD` is "1e9 model units" in code, "±10⁹ mm" in
|
|
||||||
`docs/formats/tagged-mesh.md:45`.
|
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"commands": [
|
||||||
|
{
|
||||||
|
"command": "solid.box",
|
||||||
|
"params": {
|
||||||
|
"corner": [
|
||||||
|
10,
|
||||||
|
19,
|
||||||
|
6
|
||||||
|
],
|
||||||
|
"length": 40,
|
||||||
|
"width": 2,
|
||||||
|
"height": 44,
|
||||||
|
"body_name": "PCB"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "solid.box",
|
||||||
|
"params": {
|
||||||
|
"corner": [
|
||||||
|
27,
|
||||||
|
21,
|
||||||
|
26
|
||||||
|
],
|
||||||
|
"length": 16,
|
||||||
|
"width": 6,
|
||||||
|
"height": 12,
|
||||||
|
"body_name": "Chip"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
{
|
||||||
|
"commands": [
|
||||||
|
{
|
||||||
|
"command": "solid.box",
|
||||||
|
"params": {
|
||||||
|
"corner": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"length": 40,
|
||||||
|
"width": 40,
|
||||||
|
"height": 3,
|
||||||
|
"body_name": "HeatSink"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "solid.box",
|
||||||
|
"params": {
|
||||||
|
"corner": [
|
||||||
|
0,
|
||||||
|
4.25,
|
||||||
|
3
|
||||||
|
],
|
||||||
|
"length": 40,
|
||||||
|
"width": 1.5,
|
||||||
|
"height": 18,
|
||||||
|
"operation": "join",
|
||||||
|
"targets": [
|
||||||
|
"HeatSink"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "solid.box",
|
||||||
|
"params": {
|
||||||
|
"corner": [
|
||||||
|
0,
|
||||||
|
10.25,
|
||||||
|
3
|
||||||
|
],
|
||||||
|
"length": 40,
|
||||||
|
"width": 1.5,
|
||||||
|
"height": 18,
|
||||||
|
"operation": "join",
|
||||||
|
"targets": [
|
||||||
|
"HeatSink"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "solid.box",
|
||||||
|
"params": {
|
||||||
|
"corner": [
|
||||||
|
0,
|
||||||
|
16.25,
|
||||||
|
3
|
||||||
|
],
|
||||||
|
"length": 40,
|
||||||
|
"width": 1.5,
|
||||||
|
"height": 18,
|
||||||
|
"operation": "join",
|
||||||
|
"targets": [
|
||||||
|
"HeatSink"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "solid.box",
|
||||||
|
"params": {
|
||||||
|
"corner": [
|
||||||
|
0,
|
||||||
|
22.25,
|
||||||
|
3
|
||||||
|
],
|
||||||
|
"length": 40,
|
||||||
|
"width": 1.5,
|
||||||
|
"height": 18,
|
||||||
|
"operation": "join",
|
||||||
|
"targets": [
|
||||||
|
"HeatSink"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "solid.box",
|
||||||
|
"params": {
|
||||||
|
"corner": [
|
||||||
|
0,
|
||||||
|
28.25,
|
||||||
|
3
|
||||||
|
],
|
||||||
|
"length": 40,
|
||||||
|
"width": 1.5,
|
||||||
|
"height": 18,
|
||||||
|
"operation": "join",
|
||||||
|
"targets": [
|
||||||
|
"HeatSink"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "solid.box",
|
||||||
|
"params": {
|
||||||
|
"corner": [
|
||||||
|
0,
|
||||||
|
34.25,
|
||||||
|
3
|
||||||
|
],
|
||||||
|
"length": 40,
|
||||||
|
"width": 1.5,
|
||||||
|
"height": 18,
|
||||||
|
"operation": "join",
|
||||||
|
"targets": [
|
||||||
|
"HeatSink"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Electronics cooling examples
|
||||||
|
|
||||||
|
`make.py` runs the electronics-cooling studies in [docs/examples.md](../../docs/examples.md) and
|
||||||
|
regenerates their numbers and pictures in `docs/media/examples/`. Each case compiles its committed
|
||||||
|
SolidCraft recipe ([`examples/cooling`](../../examples/cooling)) with the `compile_recipe` example,
|
||||||
|
sets up a thermal lattice-Boltzmann model (the setup is the per-case function in `make.py`), solves
|
||||||
|
it on the CPU, prints the headline numbers, and renders a still (and, for the transient cases, a
|
||||||
|
short MP4).
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 tools/cooling/make.py heat-sink # build, run, render into docs/media/examples
|
||||||
|
python3 tools/cooling/make.py heat-sink --no-build # with the release binaries already built
|
||||||
|
python3 tools/cooling/make.py heat-sink --no-clip # still only, skip the MP4
|
||||||
|
```
|
||||||
|
|
||||||
|
Needs Python 3 (standard library only), a display for the FieldCraft window (`xvfb-run` on a
|
||||||
|
headless machine), and `ffmpeg` for the MP4s. Honours `CARGO_TARGET_DIR`; set
|
||||||
|
`FIELDCRAFT_GPU=software` for a software renderer. The solves take a few minutes each.
|
||||||
|
|
||||||
|
What the solver does today, and the stand-ins these cases use for what it does not (conjugate heat
|
||||||
|
transfer into solids #157, fan curves, surface-to-surface radiation), are described in
|
||||||
|
docs/examples.md under *Electronics cooling studies*. The geometry is our own SolidCraft recipes;
|
||||||
|
nothing comes from a commercial product.
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Run the electronics-cooling examples and regenerate their gallery numbers and pictures.
|
||||||
|
|
||||||
|
Each case sets up a thermal lattice-Boltzmann model from a committed SolidCraft recipe
|
||||||
|
(`examples/cooling/*.solidcraft.json`, compiled with the `compile_recipe` example), solves it on
|
||||||
|
the CPU, prints the headline numbers (heat removed, temperatures, flow, thermal resistance), and
|
||||||
|
renders a still (and, for the transient cases, a short MP4) into `docs/media/examples/`.
|
||||||
|
|
||||||
|
What the solver does today and how these cases stand in for what it does not (see docs/examples.md):
|
||||||
|
* No conjugate heat transfer into solids yet (#157): a high-conductivity heat sink or cold plate
|
||||||
|
is modelled as an isothermal surface, and a component as a held-temperature or heat-flux face.
|
||||||
|
* No fan curve: a fan is a uniform inlet velocity. The current TRT collision is resolution-bound
|
||||||
|
near its stability limit (#557), so the forced cases use a modest duct velocity.
|
||||||
|
* No surface-to-surface radiation: the sealed-enclosure case is convection only (radiation, which
|
||||||
|
carries a real share in a sealed box, is noted as missing).
|
||||||
|
|
||||||
|
Python standard library only; ffmpeg encodes the MP4s. Needs a display for the app window (run
|
||||||
|
under xvfb-run on a headless machine) and the release binaries (built unless --no-build).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(ROOT / "tools" / "demo-video"))
|
||||||
|
import caption # noqa: E402
|
||||||
|
|
||||||
|
AIR = {
|
||||||
|
"name": "Air",
|
||||||
|
"density": 1.2e-12,
|
||||||
|
"fluid": {"type": "newtonian", "viscosity": 1.8e-11},
|
||||||
|
"thermal": {"conductivity": 0.026, "specific_heat": 1.005e9},
|
||||||
|
}
|
||||||
|
WIDTH, HEIGHT = 1280, 720
|
||||||
|
CAPTION_FONT = ROOT / "crates" / "ui-egui" / "fonts" / "Inter-SemiBold.ttf"
|
||||||
|
CAPTION_HEIGHT = 44
|
||||||
|
FPS = 12
|
||||||
|
|
||||||
|
|
||||||
|
def log(msg):
|
||||||
|
print(f"cooling: {msg}", file=sys.stderr, flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def target_dir():
|
||||||
|
return Path(os.environ.get("CARGO_TARGET_DIR", ROOT / "target"))
|
||||||
|
|
||||||
|
|
||||||
|
def build():
|
||||||
|
cargo = os.environ.get("CARGO", "cargo")
|
||||||
|
subprocess.run([cargo, "build", "--release", "-p", "fieldcraft", "-p", "fieldcraft-cli"], cwd=ROOT, check=True)
|
||||||
|
|
||||||
|
|
||||||
|
def compile_recipe(recipe, out):
|
||||||
|
cargo = os.environ.get("CARGO", "cargo")
|
||||||
|
subprocess.run(
|
||||||
|
[cargo, "run", "-q", "--release", "--example", "compile_recipe", "-p", "fieldcraft-geometry",
|
||||||
|
"--features", "scripts", "--", str(recipe), str(out)],
|
||||||
|
cwd=ROOT, check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def free_port():
|
||||||
|
with socket.socket() as s:
|
||||||
|
s.bind(("127.0.0.1", 0))
|
||||||
|
return s.getsockname()[1]
|
||||||
|
|
||||||
|
|
||||||
|
class Control:
|
||||||
|
def __init__(self, port, timeout=180.0):
|
||||||
|
end = time.monotonic() + timeout
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
self.sock = socket.create_connection(("127.0.0.1", port), timeout=5.0)
|
||||||
|
break
|
||||||
|
except OSError:
|
||||||
|
if time.monotonic() > end:
|
||||||
|
raise SystemExit(f"control channel on port {port} did not open")
|
||||||
|
time.sleep(0.5)
|
||||||
|
self.sock.settimeout(1200.0)
|
||||||
|
self.file = self.sock.makefile("rwb")
|
||||||
|
self.id = 0
|
||||||
|
|
||||||
|
def send(self, method, params):
|
||||||
|
self.id += 1
|
||||||
|
self.file.write((json.dumps({"id": self.id, "method": method, "params": params}) + "\n").encode())
|
||||||
|
self.file.flush()
|
||||||
|
reply = json.loads(self.file.readline())
|
||||||
|
if not reply.get("ok"):
|
||||||
|
raise SystemExit(f"{method} {json.dumps(params)[:120]}: {reply.get('error')}")
|
||||||
|
return reply.get("result")
|
||||||
|
|
||||||
|
def execute(self, command, params=None):
|
||||||
|
return self.send("engine.execute", {"command": command, "params": params or {}})
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
try:
|
||||||
|
self.send("app.quit", {})
|
||||||
|
except (OSError, SystemExit):
|
||||||
|
pass
|
||||||
|
self.file.close()
|
||||||
|
self.sock.close()
|
||||||
|
|
||||||
|
|
||||||
|
def hold_all_faces(ctl, body, value):
|
||||||
|
"""Every face of `body` held at a temperature (an isothermal high-k solid, or a held component)."""
|
||||||
|
bodies = ctl.execute("geometry.bodies", {})
|
||||||
|
b = next(x for x in bodies if x["name"] == body)
|
||||||
|
return [{"face": {"body": body, "face": f["name"]}, "condition": {"type": "temperature", "value": value}}
|
||||||
|
for f in b["faces"]]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------- heat sink + fan
|
||||||
|
|
||||||
|
def heat_sink(ctl, design):
|
||||||
|
"""Forced convection: air pushed along a 6-fin aluminium sink held isothermal at 80 C, inlet
|
||||||
|
air at 20 C. Reports the heat removed and the sink-to-air thermal resistance."""
|
||||||
|
V, Tsink, Tin = 100.0, 80.0, 20.0 # mm/s, C, C
|
||||||
|
ctl.execute("geometry.open", {"path": str(design)})
|
||||||
|
surfaces = hold_all_faces(ctl, "HeatSink", Tsink)
|
||||||
|
ctl.execute("model.units", {"system": "mm-t-s-N-MPa"})
|
||||||
|
ctl.execute("model.material.create", AIR)
|
||||||
|
ctl.execute("model.fluid_domain.create", {
|
||||||
|
"name": "Duct", "material": "Air", "min": [-30, -2, 0], "max": [90, 42, 30], "spacing": 1.0,
|
||||||
|
"faces": {"x_min": {"type": "velocity", "velocity": [V, 0, 0]}, "x_max": {"type": "pressure", "pressure": 0},
|
||||||
|
"y_min": {"type": "wall"}, "y_max": {"type": "wall"}, "z_min": {"type": "wall"}, "z_max": {"type": "wall"}},
|
||||||
|
"obstacles": ["HeatSink"], "initial_velocity": [V, 0, 0],
|
||||||
|
"thermal": {"initial_temperature": Tin, "faces": {"x_min": {"type": "temperature", "value": Tin}}, "surfaces": surfaces},
|
||||||
|
})
|
||||||
|
ctl.execute("model.step.create", {"name": "Cool",
|
||||||
|
"procedure": {"type": "fluid", "domain": "Duct", "end_time": 4.0, "frames": 40, "time_step": 0.00025}})
|
||||||
|
log("solving the heat sink (forced convection) ...")
|
||||||
|
report = ctl.execute("solve.fluid", {"backend": "cpu"})
|
||||||
|
numbers = heat_sink_numbers(report, Tsink, Tin, V)
|
||||||
|
render = {"field": "temperature", "clamp": [Tin, Tsink], "streamlines": True,
|
||||||
|
"camera": {"yaw": -40.0, "pitch": 22.0, "target": {"center_of": "bbox"}, "fit": True},
|
||||||
|
"caption": "heat sink · forced air 0.1 m/s", "mp4": False}
|
||||||
|
return report, numbers, render
|
||||||
|
|
||||||
|
|
||||||
|
def heat_sink_numbers(report, Tsink, Tin, V):
|
||||||
|
heat = report.get("heat", [])
|
||||||
|
Q = -sum(h["final"] for h in heat if h.get("body") == "HeatSink") # mW removed from the sink
|
||||||
|
R = (Tsink - Tin) / (Q / 1000.0) if Q else None # K/W
|
||||||
|
return {
|
||||||
|
"inlet_velocity_mm_s": V,
|
||||||
|
"sink_temperature_C": Tsink, "inlet_air_C": Tin,
|
||||||
|
"heat_removed_W": round(Q / 1000.0, 3),
|
||||||
|
"thermal_resistance_K_per_W": round(R, 2) if R else None,
|
||||||
|
"air_temperature_C": report.get("temperature"),
|
||||||
|
"cells": report.get("cells"), "steps": report.get("steps"),
|
||||||
|
"density_change": report.get("mass", {}).get("relative_change"),
|
||||||
|
"warnings": report.get("warnings"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------- sealed enclosure (PCB)
|
||||||
|
|
||||||
|
def enclosure(ctl, design):
|
||||||
|
"""Natural convection: a chip on a vertical PCB in a sealed 60x40x60 mm box, no fan. The chip is
|
||||||
|
held at 40 C, the enclosure walls at 25 C ambient; buoyancy (Boussinesq) drives the plume. The
|
||||||
|
buoyant start-up needs a small, fixed time step — the auto step over-accelerates and goes
|
||||||
|
unstable (see the Gaps note in docs/examples.md). Convection only; no radiation."""
|
||||||
|
Tchip, Tamb = 40.0, 25.0 # C
|
||||||
|
ctl.execute("geometry.open", {"path": str(design)})
|
||||||
|
surfaces = hold_all_faces(ctl, "Chip", Tchip)
|
||||||
|
ctl.execute("model.units", {"system": "mm-t-s-N-MPa"})
|
||||||
|
ctl.execute("model.material.create", AIR)
|
||||||
|
wall = {"type": "temperature", "value": Tamb}
|
||||||
|
ctl.execute("model.fluid_domain.create", {
|
||||||
|
"name": "Box", "material": "Air", "min": [0, 0, 0], "max": [60, 40, 60], "spacing": 1.0,
|
||||||
|
"faces": {f: {"type": "wall"} for f in ("x_min", "x_max", "y_min", "y_max", "z_min", "z_max")},
|
||||||
|
"obstacles": ["PCB", "Chip"], "initial_velocity": [0, 0, 0],
|
||||||
|
"thermal": {"initial_temperature": Tamb, "gravity": [0, 0, -9810], "expansion": 3.4e-3,
|
||||||
|
"faces": {f: wall for f in ("x_min", "x_max", "y_min", "y_max", "z_min", "z_max")},
|
||||||
|
"surfaces": surfaces},
|
||||||
|
})
|
||||||
|
ctl.execute("model.step.create", {"name": "Natural",
|
||||||
|
"procedure": {"type": "fluid", "domain": "Box", "end_time": 3.0, "frames": 40, "time_step": 0.0002}})
|
||||||
|
log("solving the enclosure (natural convection) ...")
|
||||||
|
report = ctl.execute("solve.fluid", {"backend": "cpu"})
|
||||||
|
heat = report.get("heat", [])
|
||||||
|
Q = -sum(h["final"] for h in heat if h.get("body") == "Chip") # mW shed by the chip
|
||||||
|
numbers = {
|
||||||
|
"chip_temperature_C": Tchip, "ambient_C": Tamb,
|
||||||
|
"heat_shed_W": round(Q / 1000.0, 4),
|
||||||
|
"chip_to_ambient_K_per_W": round((Tchip - Tamb) / (Q / 1000.0), 1) if Q else None,
|
||||||
|
"air_temperature_C": report.get("temperature"),
|
||||||
|
"max_speed_mm_s": round(report.get("max_speed", 0.0), 1),
|
||||||
|
"cells": report.get("cells"), "steps": report.get("steps"),
|
||||||
|
"warnings": report.get("warnings"),
|
||||||
|
}
|
||||||
|
render = {"field": "temperature", "clamp": [Tamb, Tchip],
|
||||||
|
"slice": {"axis": "y", "position": 24},
|
||||||
|
"streamlines": {"count": 14, "max_steps": 1400, "line": {"from": [27, 24, 39], "to": [43, 24, 39]}},
|
||||||
|
"camera": {"yaw": -14.0, "pitch": 8.0, "target": {"center_of": "bbox"}, "fit": True},
|
||||||
|
"caption": "sealed enclosure · natural convection", "mp4": False}
|
||||||
|
return report, numbers, render
|
||||||
|
|
||||||
|
|
||||||
|
CASES = {
|
||||||
|
"heat-sink": {"recipe": "heatsink.solidcraft.json", "run": heat_sink, "out": "cooling-heat-sink"},
|
||||||
|
"enclosure": {"recipe": "enclosure.solidcraft.json", "run": enclosure, "out": "cooling-enclosure"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------- render + encode
|
||||||
|
|
||||||
|
def apply_view(ctl, render):
|
||||||
|
ctl.send("ui.set", {"dark": False, "style": "shaded_edges", "show_tree": False})
|
||||||
|
ctl.execute("results.contour", {"field": render["field"], "range": {"min": render["clamp"][0], "max": render["clamp"][1]}})
|
||||||
|
ctl.execute("results.surface", {"show": True})
|
||||||
|
if render.get("slice"):
|
||||||
|
ctl.execute("results.slice", {"field": render["field"], **render["slice"]})
|
||||||
|
sl = render.get("streamlines")
|
||||||
|
if sl:
|
||||||
|
ctl.execute("results.streamlines", dict({"show": True}, **(sl if isinstance(sl, dict) else {})))
|
||||||
|
ctl.send("ui.camera", render["camera"])
|
||||||
|
|
||||||
|
|
||||||
|
def render_still(ctl, render, out_png):
|
||||||
|
apply_view(ctl, render)
|
||||||
|
ctl.send("ui.render", {"path": str(out_png), "width": WIDTH, "height": HEIGHT})
|
||||||
|
log(f"wrote still {out_png}")
|
||||||
|
|
||||||
|
|
||||||
|
def render_clip(ctl, render, frames_dir, mp4):
|
||||||
|
frames_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
font = caption.Font.load(CAPTION_FONT)
|
||||||
|
# Discover the frame count from a results reply.
|
||||||
|
info = ctl.execute("results.slice", {"field": render["field"]})
|
||||||
|
nframes = info.get("frames", [])
|
||||||
|
nframes = len(nframes) if isinstance(nframes, list) else 0
|
||||||
|
if nframes <= 1:
|
||||||
|
return None
|
||||||
|
shots = []
|
||||||
|
for k in range(nframes):
|
||||||
|
ctl.execute("results.frame", {"frame": k})
|
||||||
|
shot = frames_dir / f"f{k:04d}.png"
|
||||||
|
ctl.send("ui.render", {"path": str(shot), "width": WIDTH, "height": HEIGHT})
|
||||||
|
bar = frames_dir / f"c{k:04d}.png"
|
||||||
|
bar.write_bytes(caption.caption_bar(font, (WIDTH, CAPTION_HEIGHT), render["caption"], f"frame {k + 1}/{nframes}"))
|
||||||
|
shots.append((shot, bar))
|
||||||
|
seqdir = frames_dir / "seq"
|
||||||
|
shutil.rmtree(seqdir, ignore_errors=True)
|
||||||
|
(seqdir / "caps").mkdir(parents=True)
|
||||||
|
i = 0
|
||||||
|
held = [(s, b, 1) for s, b in shots]
|
||||||
|
held[-1] = (held[-1][0], held[-1][1], 16)
|
||||||
|
for s, b, c in held:
|
||||||
|
for _ in range(c):
|
||||||
|
os.link(s, seqdir / f"{i:05d}.png")
|
||||||
|
os.link(b, seqdir / "caps" / f"{i:05d}.png")
|
||||||
|
i += 1
|
||||||
|
ffmpeg = os.environ.get("FFMPEG", shutil.which("ffmpeg") or "ffmpeg")
|
||||||
|
src = ["-framerate", str(FPS), "-i", str(seqdir / "%05d.png"), "-framerate", str(FPS), "-i", str(seqdir / "caps" / "%05d.png")]
|
||||||
|
cap = f"[0:v]pad={WIDTH}:{HEIGHT + CAPTION_HEIGHT}:0:0[w];[w][1:v]overlay=0:{HEIGHT}"
|
||||||
|
mp4.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
subprocess.run([ffmpeg, "-hide_banner", "-loglevel", "error", "-y", *src, "-filter_complex", cap,
|
||||||
|
"-c:v", "libx264", "-preset", "veryslow", "-crf", "26", "-pix_fmt", "yuv420p", "-movflags", "+faststart", str(mp4)], check=True)
|
||||||
|
log(f"wrote clip {mp4} ({i} frames)")
|
||||||
|
return mp4
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
|
ap.add_argument("case", choices=sorted(CASES), help="which example to run")
|
||||||
|
ap.add_argument("--out", type=Path, default=ROOT / "docs" / "media" / "examples")
|
||||||
|
ap.add_argument("--no-build", action="store_true")
|
||||||
|
ap.add_argument("--no-clip", action="store_true", help="still only, skip the MP4")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
case = CASES[args.case]
|
||||||
|
if not args.no_build:
|
||||||
|
build()
|
||||||
|
bin_dir = target_dir() / "release"
|
||||||
|
work = Path(tempfile.mkdtemp(prefix="fieldcraft-cooling-"))
|
||||||
|
design = work / "part.solidcraft"
|
||||||
|
compile_recipe(ROOT / "examples" / "cooling" / case["recipe"], design)
|
||||||
|
args.out.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
port = free_port()
|
||||||
|
env = {**os.environ, "FIELDCRAFT_GPU": os.environ.get("FIELDCRAFT_GPU", "software")}
|
||||||
|
app = subprocess.Popen([str(bin_dir / "fieldcraft"), "--control", str(port)],
|
||||||
|
stdout=open(work / "app.log", "w"), stderr=subprocess.STDOUT, env=env)
|
||||||
|
ctl = None
|
||||||
|
try:
|
||||||
|
ctl = Control(port)
|
||||||
|
report, numbers, render = case["run"](ctl, design)
|
||||||
|
print(json.dumps(numbers, indent=2))
|
||||||
|
render_still(ctl, render, args.out / f"{case['out']}.png")
|
||||||
|
if render.get("mp4") and not args.no_clip:
|
||||||
|
render_clip(ctl, render, work / "frames", args.out / f"{case['out']}-transient.mp4")
|
||||||
|
finally:
|
||||||
|
if ctl:
|
||||||
|
ctl.close()
|
||||||
|
app.terminate()
|
||||||
|
try:
|
||||||
|
app.wait(timeout=10)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
app.kill()
|
||||||
|
shutil.rmtree(work, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -13,7 +13,6 @@ mod layers;
|
|||||||
mod licences;
|
mod licences;
|
||||||
mod skill;
|
mod skill;
|
||||||
mod test;
|
mod test;
|
||||||
mod unit_factors;
|
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::{Command, ExitCode};
|
use std::process::{Command, ExitCode};
|
||||||
@@ -54,8 +53,6 @@ commands:
|
|||||||
and the flags CI plans its jobs with: everything (manifest, lock file,
|
and the flags CI plans its jobs with: everything (manifest, lock file,
|
||||||
toolchain, xtask or workflows changed), solver (the heavy tests or the oracle
|
toolchain, xtask or workflows changed), solver (the heavy tests or the oracle
|
||||||
are affected), wasm (a crate the wasm check builds is affected)
|
are affected), wasm (a crate the wasm check builds is affected)
|
||||||
unit-factors report literal unit-conversion factors (1e-3, 1e6, 273.15, 25.4, …) written
|
|
||||||
outside the unit table, crates/model/src/units (report only, #579)
|
|
||||||
wasm cargo check every library crate and the web app for wasm32-unknown-unknown
|
wasm cargo check every library crate and the web app for wasm32-unknown-unknown
|
||||||
ci [--affected REV]
|
ci [--affected REV]
|
||||||
fmt --check, assets, layers, clippy -D warnings, then the tests
|
fmt --check, assets, layers, clippy -D warnings, then the tests
|
||||||
@@ -105,7 +102,6 @@ fn main() -> ExitCode {
|
|||||||
c.args(["run", "--release", "-p", affected::ORACLE_PACKAGE, "--bin", "oracle", "--"]).args(&rest);
|
c.args(["run", "--release", "-p", affected::ORACLE_PACKAGE, "--bin", "oracle", "--"]).args(&rest);
|
||||||
run(c, "oracle")
|
run(c, "oracle")
|
||||||
}
|
}
|
||||||
Some("unit-factors") => unit_factors::run(&root()),
|
|
||||||
Some("wasm") => cmd_wasm(),
|
Some("wasm") => cmd_wasm(),
|
||||||
Some("-h" | "--help" | "help") | None => {
|
Some("-h" | "--help" | "help") | None => {
|
||||||
print!("{USAGE}");
|
print!("{USAGE}");
|
||||||
|
|||||||
@@ -1,134 +0,0 @@
|
|||||||
//! `cargo xtask unit-factors`: report literal unit-conversion factors (1e-3, 1e6, 273.15, 25.4, …)
|
|
||||||
//! written outside the unit table (`crates/model/src/units/`).
|
|
||||||
//!
|
|
||||||
//! Issue #579 routes every conversion through the table. Until the later steps have removed the
|
|
||||||
//! existing factors this is a report and never fails; once the count is zero it becomes a CI check.
|
|
||||||
//! Many hits are tolerances or algorithm constants rather than conversions: the report lists them
|
|
||||||
//! all so each can be classified.
|
|
||||||
|
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
/// The factors flagged, with what each usually converts.
|
|
||||||
const FACTORS: &[(f64, &str)] = &[
|
|
||||||
(1e-3, "milli (mm↔m, ms↔s, g↔kg)"),
|
|
||||||
(1e3, "kilo (t↔kg, m↔mm)"),
|
|
||||||
(1e6, "mega (MPa↔Pa, mm²↔m²)"),
|
|
||||||
(1e9, "giga (GPa↔Pa, mm³↔m³)"),
|
|
||||||
(1e-6, "micro (Pa↔MPa)"),
|
|
||||||
(1e-9, "nano (Pa↔GPa, m³↔mm³)"),
|
|
||||||
(1e12, "t/mm³↔kg/m³"),
|
|
||||||
(1e-12, "kg/m³↔t/mm³"),
|
|
||||||
(273.15, "°C↔K"),
|
|
||||||
(459.67, "°F↔°R"),
|
|
||||||
(25.4, "in↔mm"),
|
|
||||||
(0.0254, "in↔m"),
|
|
||||||
(9.80665, "standard gravity"),
|
|
||||||
(9.81, "gravity"),
|
|
||||||
(9810.0, "gravity in mm/s²"),
|
|
||||||
];
|
|
||||||
|
|
||||||
/// The one place factors belong.
|
|
||||||
const TABLE: &str = "crates/model/src/units/";
|
|
||||||
const SKIP_DIRS: &[&str] = &["target", ".git", "plan", "dist", "xtask", "tests", "node_modules"];
|
|
||||||
|
|
||||||
pub fn run(root: &Path) -> Result<(), String> {
|
|
||||||
let mut files = Vec::new();
|
|
||||||
for dir in ["crates", "apps"] {
|
|
||||||
walk(&root.join(dir), &mut files);
|
|
||||||
}
|
|
||||||
files.sort();
|
|
||||||
let mut hits = Vec::new();
|
|
||||||
for path in &files {
|
|
||||||
let rel = path.strip_prefix(root).unwrap_or(path).to_string_lossy().replace('\\', "/");
|
|
||||||
if rel.starts_with(TABLE) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let Ok(text) = std::fs::read_to_string(path) else { continue };
|
|
||||||
for (n, line) in text.lines().enumerate() {
|
|
||||||
// Unit tests sit at the end of a file behind `#[cfg(test)]`; their values are data.
|
|
||||||
if line.trim_start().starts_with("#[cfg(test)]") {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
let code = line.split("//").next().unwrap_or("");
|
|
||||||
for value in literals(code) {
|
|
||||||
if let Some((_, what)) = FACTORS.iter().find(|(f, _)| *f == value) {
|
|
||||||
hits.push(format!("{rel}:{}: {value:e} ({what}): {}", n + 1, line.trim()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for h in &hits {
|
|
||||||
println!("{h}");
|
|
||||||
}
|
|
||||||
println!("unit-factors: {} literal factor(s) outside {TABLE} (report only; #579 drives this to zero)", hits.len());
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn walk(dir: &Path, out: &mut Vec<std::path::PathBuf>) {
|
|
||||||
let Ok(rd) = std::fs::read_dir(dir) else { return };
|
|
||||||
for e in rd.flatten() {
|
|
||||||
let p = e.path();
|
|
||||||
let name = e.file_name().to_string_lossy().to_string();
|
|
||||||
if p.is_dir() {
|
|
||||||
if !SKIP_DIRS.contains(&name.as_str()) && !name.starts_with('.') {
|
|
||||||
walk(&p, out);
|
|
||||||
}
|
|
||||||
} else if p.extension().is_some_and(|x| x == "rs") {
|
|
||||||
out.push(p);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The floating-point literals on a line of code, parsed (`1_000.0`, `1e-3`,
|
|
||||||
/// `273.15_f64`). Digits inside identifiers (`vec3`) are skipped.
|
|
||||||
fn literals(code: &str) -> Vec<f64> {
|
|
||||||
let chars: Vec<char> = code.chars().collect();
|
|
||||||
let mut out = Vec::new();
|
|
||||||
let mut i = 0;
|
|
||||||
while let Some(&c) = chars.get(i) {
|
|
||||||
let prev_ident = i > 0 && chars.get(i - 1).is_some_and(|p| p.is_alphanumeric() || *p == '_' || *p == '.');
|
|
||||||
if !c.is_ascii_digit() || prev_ident {
|
|
||||||
i += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let start = i;
|
|
||||||
while let Some(&d) = chars.get(i) {
|
|
||||||
let exp_sign = matches!(d, '+' | '-') && matches!(chars.get(i.wrapping_sub(1)), Some('e' | 'E'));
|
|
||||||
if d.is_ascii_digit() || d == '_' || d == '.' || d == 'e' || d == 'E' || exp_sign {
|
|
||||||
// `0..n` is a range, `1.max(x)` a method call: stop at a dot not followed by a digit.
|
|
||||||
if d == '.' && !chars.get(i + 1).is_some_and(char::is_ascii_digit) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
i += 1;
|
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let text: String = chars.get(start..i).unwrap_or_default().iter().filter(|c| **c != '_').collect();
|
|
||||||
let suffix_start = i;
|
|
||||||
// Skip a type suffix (`f64`, `_f32`, `u8`).
|
|
||||||
while chars.get(i).is_some_and(|c| c.is_alphanumeric() || *c == '_') {
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
let suffix: String = chars.get(suffix_start..i).unwrap_or_default().iter().collect();
|
|
||||||
// Conversions are floating-point arithmetic; integer literals are counts and caps.
|
|
||||||
let float = text.contains(['.', 'e', 'E']) || suffix.trim_start_matches('_').starts_with('f');
|
|
||||||
if float && let Ok(v) = text.parse::<f64>() {
|
|
||||||
out.push(v);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::literals;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn literals_on_a_line() {
|
|
||||||
assert_eq!(literals("let x = 1e-3 * y + 273.15_f64;"), vec![1e-3, 273.15]);
|
|
||||||
assert_eq!(literals("for i in 0..1000 { vec3(1_000.0) }"), vec![1000.0]);
|
|
||||||
assert_eq!(literals("a.max(1.0e6) - 2.max(b)"), vec![1e6]);
|
|
||||||
assert_eq!(literals("t.0 + x1 + 25.4f32 + 3f64"), vec![25.4, 3.0]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in new issue
Block a user