Compare commits

..
Author SHA1 Message Date
Christian Svensson c1fe5def0f examples: electronics cooling — sealed enclosure, natural convection
Second electronics-cooling study (discussion #584): a chip held at 40 C on a
vertical PCB in a sealed 60x40x60 mm air box at 25 C ambient, cooled only by
buoyant natural convection (Boussinesq). It sheds 63 mW, a natural-convection
coefficient near 5 W/m2K and a chip-to-ambient resistance of about 237 K/W —
roughly 18x the finned, fan-cooled sink, which is the lesson.

The buoyant start-up needs a small fixed time step (the automatic step
over-accelerates and goes unstable), and the rise is capped at 15 K; the steady
plume is gentle (cell Reynolds ~5). Convection only — no surface-to-surface
radiation yet. Documented in docs/examples.md with the Gaps on the PR.

- examples/cooling/enclosure.solidcraft.json: the committed recipe (PCB + chip).
- tools/cooling/make.py: the enclosure case, numbers and still.

Part of #584.
2026-10-18 21:47:16 +02:00
Christian Svensson 732e57ebf1 examples: electronics cooling — heat sink in forced air
First of three electronics-cooling studies for the planned CFD release
(discussion #584): a six-fin aluminium heat sink held isothermal at 80 C in a
duct with gentle forced air, solved with the thermal lattice Boltzmann solver.
It sheds 4.4 W, a sink-to-inlet thermal resistance of 13.5 K/W, converged to
under 3 % when the time step is halved.

Built only from what the solver does today, with honest stand-ins for what it
does not: the high-conductivity sink is an isothermal surface (no conjugate
conduction into solids yet, #157), the fan is a modest inlet velocity (the TRT
is resolution-bound near its stability limit, #557). These are documented in
docs/examples.md under Electronics cooling studies.

- examples/cooling/heatsink.solidcraft.json: the committed SolidCraft recipe.
- tools/cooling/make.py: runs the case, prints the numbers, renders the still.
- crates/geometry/examples/compile_recipe.rs: compiles a recipe into a design.

Part of #584.
2026-10-18 21:20:31 +02:00
18 changed files with 592 additions and 1363 deletions

No files matched your search

+2
View File
@@ -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};
-96
View File
@@ -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);
}
}
-151
View File
@@ -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)),
}
}
}
-246
View File
@@ -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");
}
}
-420
View File
@@ -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());
}
}
-157
View File
@@ -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));
}
+79
View File
@@ -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`)
![Cool air entering a six-fin heat sink held at 80 °C, warming through the fins and carrying a hot wake downstream, streamlines coloured by air temperature](media/examples/cooling-heat-sink.png)
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 chip on a vertical PCB in a sealed box, a warm buoyant plume rising to a hot ceiling over cool air below, with the recirculation drawn by streamlines](media/examples/cooling-enclosure.png)
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

-107
View File
@@ -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"
}
}
]
}
+120
View File
@@ -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"
]
}
}
]
}
+23
View File
@@ -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.
+314
View File
@@ -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()
-4
View File
@@ -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}");
-134
View File
@@ -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]);
}
}