Files
OneCAD-Simulation/crates/lbm/src/setup.rs
T
jyvikoandClaude Opus 5.5 d5bd74570f Rename the project to FieldCraft
Crates fieldcraft-*, binaries fieldcraft and fieldcraft-cli, the .fieldcraft file (format tag
fieldcraft/1, manifest fieldcraft.json), FIELDCRAFT_* environment variables, the MCP server
name, egui widget ids (fc_*), the app icon document FieldCraft.icon and its generated family,
docs and licences. No compatibility shim: no files of the earlier name exist.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-13 23:51:40 -04:00

335 lines
15 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! From a fluid step of the model to lattice units: the grid, the time step, the relaxation
//! times, and the checks that keep the scheme in its stable and accurate range.
//!
//! # Units (Krüger et al. 2017, ch. 7)
//! Lattice units take the spacing `Δx`, the time step `Δt` and the fluid's density `ρ₀` as 1. A
//! velocity converts by `Δx/Δt`, a kinematic viscosity by `Δx²/Δt`, an acceleration by `Δx/Δt²`,
//! a pressure by `ρ₀ Δx²/Δt²` and a force by `ρ₀ Δx⁴/Δt²`. The lattice viscosity fixes the
//! symmetric relaxation time, `ν = c_s² (τ⁺ − ½)` with `c_s² = 1/3`.
//!
//! # Time step
//! Unless the step gives one, `Δt` is the largest that keeps the characteristic lattice velocity
//! at [`TARGET_LATTICE_VELOCITY`] (Mach 0.087: the weakly compressible error is O(Ma²)) and
//! `τ⁺` at most [`AUTO_TAU_MAX`] (beyond it the scheme only gets less accurate in time). The
//! characteristic velocity is the largest of the inlet and wall velocities, the initial velocity,
//! `√(2 Δp / ρ)` across pressure faces and, for a driving acceleration `a`, the smaller of the
//! viscous plane-channel peak `a H² / (8 ν)` (H the box's smallest extent) and the free-fall speed
//! `√(2 a L)` (L its largest).
//!
//! # Limits
//! A run is refused, naming the numbers and what to change, when the lattice Mach number of the
//! characteristic velocity exceeds [`MAX_MACH`] (the scheme is weakly compressible and loses
//! stability toward Ma ≈ 0.5), when `τ⁺ < `[`MIN_TAU`] (the grid cannot resolve this viscosity at
//! this velocity; linear stability analysis, Lallemand & Luo 2000, shows the stable velocity range
//! shrinks to nothing as τ → ½), or when the lattice acceleration exceeds [`MAX_LATTICE_ACCELERATION`].
//! While running, a lattice speed above [`MAX_LATTICE_SPEED`] or a non-finite or non-positive
//! density stops the run with [`crate::Error::Unstable`].
use fieldcraft_mesh::surface::BoundarySurface;
use fieldcraft_model::{BoxFace, FaceCondition, FluidDomain, Model, Procedure, SurfaceCondition};
use crate::voxel::Grid;
use crate::{Error, Limits, Result};
/// The characteristic lattice velocity the automatic time step aims at.
pub const TARGET_LATTICE_VELOCITY: f64 = 0.05;
/// The largest symmetric relaxation time the automatic time step allows.
pub const AUTO_TAU_MAX: f64 = 1.0;
/// The largest lattice Mach number `u Δt / (Δx c_s)` of the characteristic velocity.
pub const MAX_MACH: f64 = 0.3;
/// The smallest symmetric relaxation time τ⁺.
pub const MIN_TAU: f64 = 0.505;
/// The largest lattice acceleration `a Δt² / Δx`.
pub const MAX_LATTICE_ACCELERATION: f64 = 0.01;
/// A lattice speed above this during a run means it went unstable (Mach 0.87).
pub const MAX_LATTICE_SPEED: f64 = 0.5;
/// The default two-relaxation-time parameter Λ: bounce-back walls exactly halfway between cells
/// (Ginzburg, Verhaeghe & d'Humières 2008).
pub const DEFAULT_MAGIC: f64 = 3.0 / 16.0;
/// The surface of an obstacle body and the persistent name of each of its faces (by the
/// triangles' face tags); prepared by the caller from the geometry.
#[derive(Debug, Clone)]
pub struct Obstacle {
pub body: String,
pub surface: BoundarySurface,
/// Face names by tag; tags without a name are called `face0`, `face1`, ….
pub faces: Vec<String>,
}
/// An obstacle of the domain as the run sees it.
#[derive(Debug, Clone, PartialEq)]
pub struct ObstacleFaces {
/// The obstacle's index in the caller's obstacle list.
pub index: usize,
/// Every face tag's name.
pub names: Vec<String>,
/// Every face tag's wall velocity in lattice units (zero for walls at rest).
pub velocities: Vec<[f64; 3]>,
}
/// A fluid step in lattice units.
#[derive(Debug, Clone, PartialEq)]
pub struct Setup {
pub step: String,
pub domain: FluidDomain,
pub grid: Grid,
/// ρ₀ and the kinematic viscosity ν = μ / ρ₀, in model units.
pub density: f64,
pub viscosity: f64,
pub time_step: f64,
pub steps: u64,
pub frames: u32,
/// τ⁺ and τ⁻ (Λ = (τ⁺ − ½)(τ⁻ − ½)).
pub tau_plus: f64,
pub tau_minus: f64,
pub magic: f64,
/// The characteristic velocity in model and lattice units, and its lattice Mach number.
pub velocity: f64,
pub lattice_velocity: f64,
pub mach: f64,
/// The body acceleration and initial velocity in lattice units.
pub acceleration: [f64; 3],
pub initial_velocity: [f64; 3],
/// The domain's obstacles, in its order.
pub obstacles: Vec<ObstacleFaces>,
pub warnings: Vec<String>,
}
fn norm(v: [f64; 3]) -> f64 {
(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt()
}
/// The fluid step named `step` (or the only fluid step when `step` is `None`) and its domain.
pub fn fluid_step<'m>(model: &'m Model, step: Option<&str>) -> Result<(&'m fieldcraft_model::Step, &'m FluidDomain)> {
let s = model.select_step(step, |p| p.fluid_domain().is_some(), "fluid")?;
let name = s.procedure.fluid_domain().unwrap_or_default();
let domain = model.fluid_domain(name).ok_or_else(|| Error::NotFound(format!("fluid domain `{name}`")))?;
Ok((s, domain))
}
impl Setup {
/// Convert a fluid step, checking it against the stability limits and `limits`.
pub fn new(model: &Model, step: Option<&str>, obstacles: &[Obstacle], limits: &Limits) -> Result<Setup> {
// The model checked the domain and its material when they were defined.
let (s, domain) = fluid_step(model, step)?;
let domain = domain.clone();
let Procedure::Fluid { end_time, steps, time_step, frames, magic, .. } = &s.procedure else {
return Err(Error::Invalid(format!("step `{}` is not a fluid step", s.name)));
};
let material = model.material(&domain.material);
let (Some(density), Some(fluid)) = (material.and_then(|m| m.density), material.and_then(|m| m.fluid)) else {
return Err(Error::Invalid(format!("material `{}` needs a density and fluid behaviour", domain.material)));
};
let viscosity = fluid.viscosity() / density;
if !(viscosity > 0.0 && viscosity.is_finite()) {
return Err(Error::Invalid(format!("the kinematic viscosity μ/ρ of material `{}` is {viscosity}", domain.material)));
}
// The grid.
let counts = domain.cells()?;
let mut cells = [0usize; 3];
let mut total: u64 = 1;
for a in 0..3 {
cells[a] = usize::try_from(counts[a]).map_err(|_| Error::Limit("the grid is too large".into()))?;
total = total.saturating_mul(counts[a]);
}
if total > limits.max_cells {
return Err(Error::Limit(format!(
"the domain has {total} cells ({} × {} × {}), above the cap of {}; coarsen the spacing or raise `max_cells`",
cells[0], cells[1], cells[2], limits.max_cells
)));
}
for face in BoxFace::ALL {
let open = matches!(domain.faces.get(face), FaceCondition::Velocity { .. } | FaceCondition::Pressure { .. });
if open && cells[face.axis()] < 2 {
return Err(Error::Invalid(format!("box face {} is an inlet or outlet, so the box needs at least 2 cells across it", face.name())));
}
}
let grid = Grid { origin: domain.min, spacing: domain.spacing, cells };
let h = domain.spacing;
// The characteristic velocity.
let mut u_char: f64 = norm(domain.initial_velocity);
let mut pressures = Vec::new();
for face in BoxFace::ALL {
match *domain.faces.get(face) {
FaceCondition::MovingWall { velocity } | FaceCondition::Velocity { velocity, .. } => u_char = u_char.max(norm(velocity)),
FaceCondition::Pressure { pressure } => pressures.push(pressure),
FaceCondition::Wall {} | FaceCondition::Slip {} | FaceCondition::Periodic {} => {}
}
}
for sb in &domain.surfaces {
if let SurfaceCondition::MovingWall { velocity } = sb.condition {
u_char = u_char.max(norm(velocity));
}
}
let lo = pressures.iter().copied().fold(f64::INFINITY, f64::min);
let hi = pressures.iter().copied().fold(f64::NEG_INFINITY, f64::max);
if hi > lo {
u_char = u_char.max((2.0 * (hi - lo) / density).sqrt());
}
let a = norm(domain.acceleration);
if a > 0.0 {
let extents: Vec<f64> = (0..3).map(|k| domain.max[k] - domain.min[k]).collect();
let small = extents.iter().copied().fold(f64::INFINITY, f64::min);
let large = extents.iter().copied().fold(0.0, f64::max);
u_char = u_char.max((a * small * small / (8.0 * viscosity)).min((2.0 * a * large).sqrt()));
}
if !u_char.is_finite() {
return Err(Error::Invalid("the flow's velocity scale is not a finite number".into()));
}
// The time step and the number of steps.
let diffusive = (AUTO_TAU_MAX - 0.5) * h * h / (3.0 * viscosity);
let auto = if u_char > 0.0 { (TARGET_LATTICE_VELOCITY * h / u_char).min(diffusive) } else { diffusive };
let (dt, n) = match (*time_step, *end_time, *steps) {
(Some(dt), Some(t), _) => {
let n = (t / dt).round();
if !(n >= 1.0) {
return Err(Error::Invalid(format!("the end time {t} is shorter than the time step {dt}")));
}
(dt, n)
}
(Some(dt), None, Some(n)) => (dt, n as f64),
(None, Some(t), _) => {
let n = (t / auto).ceil().max(1.0);
(t / n, n)
}
(None, None, Some(n)) => (auto, n as f64),
(_, None, None) => return Err(Error::Invalid(format!("step `{}` needs `end_time` or `steps`", s.name))),
};
if !(dt > 0.0 && dt.is_finite()) {
return Err(Error::Invalid(format!("the time step {dt} is not a positive number")));
}
if !(n <= limits.max_steps as f64) {
return Err(Error::Limit(format!(
"the step needs {n} time steps of {dt}, above the cap of {}; shorten it or raise `max_steps`",
limits.max_steps
)));
}
let n = n as u64;
let updates = (total as f64) * (n as f64);
if !(updates <= limits.max_cell_updates as f64) {
return Err(Error::Limit(format!(
"{total} cells for {n} time steps is {updates:e} cell updates, above the cap of {:e}; coarsen, shorten or raise `max_cell_updates`",
limits.max_cell_updates as f64
)));
}
// Lattice parameters and the stability limits.
let nu = viscosity * dt / (h * h);
let tau_plus = 3.0 * nu + 0.5;
let lattice_velocity = u_char * dt / h;
let mach = lattice_velocity / crate::lattice::CS2.sqrt();
if mach > MAX_MACH {
return Err(Error::Invalid(format!(
"the lattice Mach number is {mach:.3} (velocity {u_char} with time step {dt} and spacing {h}), above {MAX_MACH}: lower the time step"
)));
}
if !(tau_plus >= MIN_TAU) {
return Err(Error::Invalid(format!(
"the relaxation time τ = {tau_plus:.5} is below {MIN_TAU}: the grid cannot resolve viscosity {viscosity} at this time step (cell Reynolds number {:.1}); refine the spacing or raise the time step",
if nu > 0.0 { lattice_velocity / nu } else { f64::INFINITY }
)));
}
let magic = magic.unwrap_or(DEFAULT_MAGIC);
let tau_minus = 0.5 + magic / (tau_plus - 0.5);
let acceleration = domain.acceleration.map(|c| c * dt * dt / h);
if norm(acceleration) > MAX_LATTICE_ACCELERATION {
return Err(Error::Invalid(format!(
"the lattice acceleration is {:.3e}, above {MAX_LATTICE_ACCELERATION}: lower the time step",
norm(acceleration)
)));
}
// Obstacles, their face names and their faces' wall velocities.
let mut domain_obstacles = Vec::with_capacity(domain.obstacles.len());
let mut warnings = Vec::new();
for body in &domain.obstacles {
let (index, o) = obstacles
.iter()
.enumerate()
.find(|(_, o)| &o.body == body)
.ok_or_else(|| Error::NotFound(format!("obstacle body `{body}` has no surface (open the design or import its surface)")))?;
let tags = o.surface.tri_face().iter().copied().max().map_or(1, |m| m as usize + 1).max(o.faces.len());
let names: Vec<String> = (0..tags).map(|t| o.faces.get(t).cloned().unwrap_or_else(|| format!("face{t}"))).collect();
let mut velocities = vec![[0.0; 3]; tags];
for sb in domain.surfaces.iter().filter(|sb| &sb.face.body == body) {
let mut hit = false;
for (tag, name) in names.iter().enumerate() {
if sb.face.selects(&fieldcraft_model::GeometryRef::face(body.as_str(), name.as_str())?) {
hit = true;
if let (Some(slot), SurfaceCondition::MovingWall { velocity }) = (velocities.get_mut(tag), sb.condition) {
*slot = velocity.map(|c| c * dt / h);
}
}
}
if !hit {
return Err(Error::NotFound(format!("the {} is not a face of the obstacle's surface", sb.face)));
}
}
domain_obstacles.push(ObstacleFaces { index, names, velocities });
}
if mach > 0.1 + 1e-12 {
warnings.push(format!(
"lattice Mach number {mach:.3}: the compressibility error, of order Ma² ≈ {:.1}%, may be visible",
100.0 * mach * mach
));
}
if tau_plus < 0.51 {
warnings.push(format!("τ = {tau_plus:.4} is close to ½: under-resolved flows may go unstable; a finer spacing is safer"));
}
Ok(Setup {
step: s.name.clone(),
grid,
density,
viscosity,
time_step: dt,
steps: n,
frames: *frames,
tau_plus,
tau_minus,
magic,
velocity: u_char,
lattice_velocity,
mach,
acceleration,
initial_velocity: domain.initial_velocity.map(|c| c * dt / h),
obstacles: domain_obstacles,
warnings,
domain,
})
}
/// Model units per lattice unit of velocity, pressure and force.
pub fn velocity_scale(&self) -> f64 {
self.grid.spacing / self.time_step
}
pub fn pressure_scale(&self) -> f64 {
self.density * self.grid.spacing * self.grid.spacing / (self.time_step * self.time_step)
}
pub fn force_scale(&self) -> f64 {
self.pressure_scale() * self.grid.spacing * self.grid.spacing
}
/// A velocity in lattice units.
pub fn to_lattice(&self, v: [f64; 3]) -> [f64; 3] {
let k = 1.0 / self.velocity_scale();
v.map(|c| c * k)
}
/// The lattice density of a gauge pressure: `1 + p / (c_s² ρ₀ Δx²/Δt²)`.
pub fn lattice_density(&self, pressure: f64) -> f64 {
1.0 + pressure / (crate::lattice::CS2 * self.pressure_scale())
}
/// The gauge pressure of a lattice density (the inverse of [`Setup::lattice_density`]).
pub fn gauge_pressure(&self, rho: f64) -> f64 {
(rho - 1.0) * crate::lattice::CS2 * self.pressure_scale()
}
}