lbm: a temperature lattice on the flow

A D3Q7 passive scalar rides on the D3Q19 flow: it is advected by the
cell velocity, diffuses with the fluid's thermal diffusivity (the
relaxation time follows from k / (rho c)), and feeds back into the
momentum through a per-cell Boussinesq force when the domain has an
expansion coefficient and gravity. Walls and obstacle faces hold a
temperature (anti-bounce-back), a heat flux (added to the bounced
population) or are adiabatic (bounce-back); velocity inlets carry the
held temperature in, pressure outlets copy their neighbour.

The heat crossing each face group is accumulated during streaming and
reported in physical units (W) in the result's heat histories, next to
the force histories; frames gain a temperature field and the stats
report the thermal relaxation time.

Closed-form checks: linear conduction between held plates, a flux wall
against q H / k with the wall's heat balance, an inlet temperature
carried through a plug flow, and a periodic blob that conserves its
total and returns after one lap. The de Vahl Davis cavity (Ra 1e3 and
1e4 against the published Nusselt numbers) is there as an ignored test
because it takes minutes.

The GPU backend declines domains with heat for now and asks for the
CPU; its kernels follow.

Part of #157.
This commit is contained in:
Christian Svensson authored and Irakli Kourtis committed 2026-10-17 08:32:30 -04:00
1 parent ea284caf80
commit 939b64e660
7 files changed
+837 -23

No files matched your search

+38 -7
View File
@@ -23,10 +23,21 @@ pub struct Frame {
pub velocity: Vec<[f32; 3]>,
/// Gauge pressure.
pub pressure: Vec<f32>,
/// Temperature, when the flow carries heat (empty otherwise).
pub temperature: Vec<f32>,
/// The total fluid mass (density times volume).
pub mass: f64,
}
/// The heat the fluid gives one wall over time (power; positive into the wall).
#[derive(Debug, Clone, PartialEq)]
pub struct HeatHistory {
pub name: String,
pub body: Option<String>,
/// One value per [`FluidResult::history_times`].
pub power: Vec<f64>,
}
/// The force the fluid exerts on one wall over time.
#[derive(Debug, Clone, PartialEq)]
pub struct ForceHistory {
@@ -57,6 +68,8 @@ pub struct FluidStats {
pub cell_updates: u64,
/// What stepped the flow: `cpu`, or `gpu: <adapter>`.
pub backend: String,
/// The scalar lattice's relaxation time, with heat.
pub tau_thermal: Option<f64>,
}
/// The result of a fluid step.
@@ -71,17 +84,19 @@ pub struct FluidResult {
pub history_times: Vec<f64>,
/// Every box wall and every obstacle face.
pub forces: Vec<ForceHistory>,
/// The heat every wall receives, with heat (empty otherwise), the same walls and times.
pub heat: Vec<HeatHistory>,
pub stats: FluidStats,
pub warnings: Vec<String>,
}
impl FluidResult {
/// The cell containing a point and the frame's velocity and pressure there (`None` outside
/// the grid; zeros in a solid cell).
pub fn probe(&self, frame: usize, p: [f64; 3]) -> Option<(usize, [f32; 3], f32)> {
/// The cell containing a point and the frame's velocity, pressure and temperature (`None`
/// without heat) there (`None` outside the grid; zeros in a solid cell).
pub fn probe(&self, frame: usize, p: [f64; 3]) -> Option<(usize, [f32; 3], f32, Option<f32>)> {
let cell = self.grid.cell_at(p)?;
let f = self.frames.get(frame)?;
Some((cell, *f.velocity.get(cell)?, *f.pressure.get(cell)?))
Some((cell, *f.velocity.get(cell)?, *f.pressure.get(cell)?, f.temperature.get(cell).copied()))
}
}
@@ -106,7 +121,11 @@ fn frame(setup: &Setup, flow: &mut dyn Backend, step: u64) -> Result<Frame> {
velocity.try_reserve_exact(n).map_err(|_| Error::Limit("not enough memory for an output frame".into()))?;
pressure.try_reserve_exact(n).map_err(|_| Error::Limit("not enough memory for an output frame".into()))?;
let vs = setup.velocity_scale();
let Fields { density, velocity: u } = flow.fields()?;
let Fields { density, velocity: u, temperature: t } = flow.fields()?;
let mut temperature = Vec::new();
if !t.is_empty() {
temperature.try_reserve_exact(n).map_err(|_| Error::Limit("not enough memory for an output frame".into()))?;
}
for cell in 0..n {
if flow.is_fluid(cell) {
velocity.push(u.get(cell).copied().unwrap_or([0.0; 3]).map(|c| (c * vs) as f32));
@@ -115,9 +134,12 @@ fn frame(setup: &Setup, flow: &mut dyn Backend, step: u64) -> Result<Frame> {
velocity.push([0.0; 3]);
pressure.push(0.0);
}
if !t.is_empty() {
temperature.push(t.get(cell).copied().unwrap_or(0.0) as f32);
}
}
let mass = flow.mass()? * setup.density * setup.grid.spacing.powi(3);
Ok(Frame { time: step as f64 * setup.time_step, step, velocity, pressure, mass })
Ok(Frame { time: step as f64 * setup.time_step, step, velocity, pressure, temperature, mass })
}
/// Run a prepared flow (fresh from [`Flow::new`], possibly with its own initial state, on any
@@ -127,7 +149,7 @@ pub fn run(setup: &Setup, flow: &mut dyn Backend, limits: &Limits) -> Result<Flu
let n = setup.grid.len();
let total = setup.steps;
let frames = u64::from(setup.frames).min(total).max(1);
let frame_bytes = (frames + 1) as f64 * n as f64 * 16.0;
let frame_bytes = (frames + 1) as f64 * n as f64 * if setup.thermal.is_some() { 20.0 } else { 16.0 };
if !(frame_bytes <= limits.max_frame_bytes as f64) {
return Err(Error::Limit(format!(
"{} frames of {n} cells need {:.0} MB, above the cap of {:.0} MB; write fewer frames or raise `max_frame_bytes`",
@@ -138,10 +160,12 @@ pub fn run(setup: &Setup, flow: &mut dyn Backend, limits: &Limits) -> Result<Flu
}
let every = total.div_ceil(MAX_HISTORY).max(1);
let fs = setup.force_scale();
let hs = setup.thermal.as_ref().map_or(0.0, |t| t.heat_scale);
let mut out_frames = vec![frame(setup, flow, 0)?];
let mut history_times = Vec::new();
let mut forces: Vec<Vec<[f64; 3]>> = vec![Vec::new(); flow.groups().len()];
let mut heats: Vec<Vec<f64>> = vec![Vec::new(); if setup.thermal.is_some() { flow.groups().len() } else { 0 }];
let mut next_frame = 1u64;
// Frame j (1..=frames) at step round(j · total / frames).
let frame_step = |j: u64| (j * total + frames / 2) / frames;
@@ -165,6 +189,11 @@ pub fn run(setup: &Setup, flow: &mut dyn Backend, limits: &Limits) -> Result<Flu
for (h, f) in forces.iter_mut().zip(flow.forces()?) {
h.push(f.map(|c| c * fs));
}
if !heats.is_empty() {
for (h, q) in heats.iter_mut().zip(flow.heat()?) {
h.push(q * hs);
}
}
}
while next_frame <= frames && k == frame_step(next_frame) {
out_frames.push(frame(setup, flow, k)?);
@@ -198,6 +227,7 @@ pub fn run(setup: &Setup, flow: &mut dyn Backend, limits: &Limits) -> Result<Flu
frames: out_frames,
history_times,
forces: flow.groups().iter().zip(forces).map(|(g, force)| ForceHistory { name: g.name.clone(), body: g.body.clone(), force }).collect(),
heat: flow.groups().iter().zip(heats).map(|(g, power)| HeatHistory { name: g.name.clone(), body: g.body.clone(), power }).collect(),
stats: FluidStats {
cells: n,
fluid_cells: flow.fluid_cells(),
@@ -212,6 +242,7 @@ pub fn run(setup: &Setup, flow: &mut dyn Backend, limits: &Limits) -> Result<Flu
mach: setup.mach,
cell_updates: (flow.fluid_cells() as u64).saturating_mul(total),
backend: flow.name(),
tau_thermal: setup.thermal.as_ref().map(|t| t.tau),
},
warnings,
})