forked from 1b-agent/fieldcraft-upstream
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.
298 lines
14 KiB
Rust
298 lines
14 KiB
Rust
//! Heat in the fluid: the temperature lattice against closed-form solutions and a published
|
||
//! benchmark (de Vahl Davis, heavy).
|
||
|
||
mod common;
|
||
|
||
use common::*;
|
||
use fieldcraft_lbm::backend::Backend;
|
||
use fieldcraft_lbm::{Limits, run};
|
||
use fieldcraft_model::{BoxFace, Directional, FaceCondition, Fluid, FluidThermal, Material, Model, Procedure, Step, Thermal, ThermalCondition};
|
||
|
||
const PERIODIC: FaceCondition = FaceCondition::Periodic {};
|
||
|
||
/// A fluid with heat: density ρ, kinematic viscosity ν, thermal diffusivity α (conductivity
|
||
/// ρ α, unit specific heat), so k / (ρ c) = α.
|
||
fn hot_model(density: f64, nu: f64, alpha: f64, domain: fieldcraft_model::FluidDomain, procedure: Procedure) -> Model {
|
||
let mut m = Model::default();
|
||
m.add_material(
|
||
"Fluid",
|
||
Material {
|
||
density: Some(density),
|
||
fluid: Some(Fluid::Newtonian { viscosity: nu * density }),
|
||
thermal: Some(Thermal { conductivity: Some(Directional::Isotropic(density * alpha)), specific_heat: Some(1.0), ..Default::default() }),
|
||
..Material::default()
|
||
},
|
||
)
|
||
.unwrap();
|
||
m.add_fluid_domain("Flow", domain).unwrap();
|
||
m.add_step(Step::new("Run", procedure), None).unwrap();
|
||
m
|
||
}
|
||
|
||
/// Plates at y = 0 and y = 1, periodic in x and z, no flow: held at 0 and 1, the steady
|
||
/// temperature is linear, and anti-bounce-back puts the walls exactly halfway between cells.
|
||
#[test]
|
||
fn conduction_between_plates_is_linear() {
|
||
for n in [8usize, 16] {
|
||
let (h, alpha) = (1.0, 0.05);
|
||
let dx = h / n as f64;
|
||
let mut d = domain([0.0; 3], [dx, h, dx], dx);
|
||
d.faces.x_min = PERIODIC;
|
||
d.faces.x_max = PERIODIC;
|
||
d.faces.z_min = PERIODIC;
|
||
d.faces.z_max = PERIODIC;
|
||
let mut t = FluidThermal::default();
|
||
t.faces.y_min = ThermalCondition::Temperature { value: 0.0 };
|
||
t.faces.y_max = ThermalCondition::Temperature { value: 1.0 };
|
||
d.thermal = Some(t);
|
||
// τ_T = 1: Δt = Δx² / (8 α)
|
||
let dt = dx * dx / (8.0 * alpha);
|
||
let m = hot_model(1.0, 0.1, alpha, d, step(5.0 * h * h / alpha, dt, None));
|
||
let (setup, mut flow) = prepare(&m, &[]);
|
||
assert!((setup.thermal.as_ref().unwrap().tau - 1.0).abs() < 1e-12);
|
||
run_all(&setup, &mut flow);
|
||
let mut err: f64 = 0.0;
|
||
for j in 0..n {
|
||
let y = (j as f64 + 0.5) * dx;
|
||
let t = flow.temperature(j).unwrap();
|
||
err = err.max((t - y / h).abs());
|
||
}
|
||
println!("conduction between plates, {n} cells: max error {err:.2e}");
|
||
assert!(err < 1e-9, "{n}: {err}");
|
||
}
|
||
}
|
||
|
||
/// A flux q through the cold plate and a held temperature on the other: the steady drop over
|
||
/// the gap is q H / k, and the heat the hot plate receives is the flux times its area.
|
||
#[test]
|
||
fn a_heat_flux_sets_the_gradient_and_the_wall_heat_balances() {
|
||
let (n, h, alpha, rho) = (16usize, 1.0, 0.05, 2.0);
|
||
let dx = h / n as f64;
|
||
let mut d = domain([0.0; 3], [dx, h, dx], dx);
|
||
d.faces.x_min = PERIODIC;
|
||
d.faces.x_max = PERIODIC;
|
||
d.faces.z_min = PERIODIC;
|
||
d.faces.z_max = PERIODIC;
|
||
let q = 0.3;
|
||
let mut t = FluidThermal::default();
|
||
t.faces.y_min = ThermalCondition::Flux { value: q };
|
||
t.faces.y_max = ThermalCondition::Temperature { value: 1.0 };
|
||
d.thermal = Some(t);
|
||
let dt = dx * dx / (8.0 * alpha);
|
||
let m = hot_model(rho, 0.1, alpha, d, step(8.0 * h * h / alpha, dt, None));
|
||
let setup = fieldcraft_lbm::Setup::new(&m, None, &[], &Limits::default()).unwrap();
|
||
let mut flow = fieldcraft_lbm::Flow::new(&setup, &[]).unwrap();
|
||
let r = run(&setup, &mut flow, &Limits::default()).unwrap();
|
||
let k = rho * alpha;
|
||
// the temperature halfway below the first cell (the wall) against the hot wall at 1
|
||
let t0 = f64::from(r.frames.last().unwrap().temperature[0]) + 0.5 * dx * q / k;
|
||
let drop = t0 - 1.0;
|
||
let expected = q * h / k;
|
||
println!("flux plate: drop {drop:.6} vs q H / k = {expected:.6}");
|
||
assert!((drop - expected).abs() < 1e-6 * expected, "{drop} vs {expected}");
|
||
let hot = r.heat.iter().find(|w| w.name == "y_max").unwrap();
|
||
let last = *hot.power.last().unwrap();
|
||
let area = dx * dx;
|
||
println!("heat into the hot wall: {last:.6e} vs q A = {:.6e}", q * area);
|
||
assert!((last - q * area).abs() < 1e-6 * q * area, "{last} vs {}", q * area);
|
||
let cold = r.heat.iter().find(|w| w.name == "y_min").unwrap();
|
||
assert!((cold.power.last().unwrap() + q * area).abs() < 1e-6 * q * area, "the flux face gives q A");
|
||
}
|
||
|
||
/// A channel with a velocity inlet at 1, slip (adiabatic) walls and a pressure outlet: the
|
||
/// plug flow carries the inlet temperature through the whole channel. The flow starts at
|
||
/// its steady state (an impulsive start in a frictionless channel rings for a long time).
|
||
#[test]
|
||
fn the_flow_carries_the_inlet_temperature() {
|
||
let (h, l, umax, nu, alpha) = (1.0, 4.0, 0.1, 0.05, 0.01);
|
||
let dx = h / 8.0;
|
||
let mut d = domain([0.0; 3], [l, h, dx], dx);
|
||
d.faces.x_min = FaceCondition::Velocity { velocity: [umax, 0.0, 0.0], profile: fieldcraft_model::Profile::Uniform };
|
||
d.faces.x_max = FaceCondition::Pressure { pressure: 0.0 };
|
||
d.faces.y_min = FaceCondition::Slip {};
|
||
d.faces.y_max = FaceCondition::Slip {};
|
||
d.faces.z_min = PERIODIC;
|
||
d.faces.z_max = PERIODIC;
|
||
let mut t = FluidThermal::default();
|
||
t.faces.x_min = ThermalCondition::Temperature { value: 1.0 };
|
||
d.thermal = Some(t);
|
||
let lattice_u = 0.05;
|
||
let m = hot_model(1.0, nu, alpha, d, step(4.0 * l / umax, lattice_u * dx / umax, None));
|
||
let (setup, mut flow) = prepare(&m, &[]);
|
||
flow.set_equilibrium(|_| (1.0, [lattice_u, 0.0, 0.0]));
|
||
run_all(&setup, &mut flow);
|
||
let grid = *flow.grid();
|
||
let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY);
|
||
for c in 0..grid.len() {
|
||
let t = flow.temperature(c).unwrap();
|
||
lo = lo.min(t);
|
||
hi = hi.max(t);
|
||
}
|
||
println!("inlet temperature carried through: {lo:.6}..{hi:.6}");
|
||
assert!(lo > 1.0 - 1e-3 && hi < 1.0 + 1e-3, "{lo}..{hi}");
|
||
let f = flow.fields().unwrap();
|
||
assert_eq!(f.temperature.len(), grid.len());
|
||
}
|
||
|
||
/// Natural convection in a square cavity (de Vahl Davis, Int. J. Numer. Meth. Fluids 3 (1983)
|
||
/// 249–264): hot left wall at 1, cold right wall at 0, adiabatic top and bottom, Pr = 0.71.
|
||
/// The mean Nusselt number on the hot wall at Ra = 10³ is 1.118 (10⁴: 2.243); `u_max` is the
|
||
/// fastest flow in the cavity in units of α / H (3.65 and 19.6). Returns the Nusselt number
|
||
/// and the steady power into the hot wall and into the cold wall (the mean over the last
|
||
/// tenth of the run).
|
||
fn cavity(ra: f64, n: usize, u_max: f64) -> (f64, f64, f64) {
|
||
let (h, pr) = (1.0, 0.71);
|
||
let dx = h / n as f64;
|
||
let alpha = 1.0;
|
||
let nu = pr * alpha;
|
||
// g β ΔT H³ / (ν α) = Ra, with ΔT = 1, H = 1
|
||
let g_beta = ra * nu * alpha / (h * h * h);
|
||
let mut d = domain([0.0; 3], [h, h, dx], dx);
|
||
d.faces.z_min = PERIODIC;
|
||
d.faces.z_max = PERIODIC;
|
||
let mut t = FluidThermal { initial_temperature: 0.5, gravity: [0.0, -g_beta, 0.0], expansion: Some(1.0), ..FluidThermal::default() };
|
||
t.faces.x_min = ThermalCondition::Temperature { value: 1.0 };
|
||
t.faces.x_max = ThermalCondition::Temperature { value: 0.0 };
|
||
d.thermal = Some(t);
|
||
// the fastest flow at lattice speed 0.05, and a lattice diffusivity of at most 1/4 (τ_T ≤ 1.5)
|
||
let dt = (0.05 * dx / (u_max * alpha / h)).min(dx * dx / (4.0 * alpha));
|
||
let m = hot_model(1.0, nu, alpha, d, step(4.0 * h * h / alpha, dt, None));
|
||
let setup = fieldcraft_lbm::Setup::new(&m, None, &[], &Limits::default()).unwrap();
|
||
let mut flow = fieldcraft_lbm::Flow::new(&setup, &[]).unwrap();
|
||
let r = run(&setup, &mut flow, &Limits::default()).unwrap();
|
||
// Nu = (heat into the fluid through the hot wall) H / (k ΔT A): the hot wall's history
|
||
// holds the power the wall receives (negative when it heats the fluid).
|
||
let steady = |name: &str| {
|
||
let w = r.heat.iter().find(|w| w.name == name).unwrap();
|
||
let m = w.power.len();
|
||
let tail = &w.power[m - m / 10..];
|
||
tail.iter().sum::<f64>() / tail.len() as f64
|
||
};
|
||
let (hot, cold) = (steady("x_min"), steady("x_max"));
|
||
let k = alpha;
|
||
let area = h * dx;
|
||
let nu_mean = -hot * h / (k * 1.0 * area);
|
||
println!("cavity Ra = {ra:.0e}, {n} cells: Nu = {nu_mean:.4}, hot wall {hot:.6e}, cold wall {cold:.6e}; {:?}", r.warnings);
|
||
(nu_mean, hot, cold)
|
||
}
|
||
|
||
/// At steady state the heat the hot wall gives the fluid is the heat the cold wall takes.
|
||
fn heat_balances(hot: f64, cold: f64, tolerance: f64) {
|
||
assert!(hot < 0.0 && cold > 0.0, "the hot wall heats the fluid ({hot}) and the cold wall cools it ({cold})");
|
||
assert!((hot + cold).abs() < tolerance * cold, "heat in {hot:e} vs heat out {cold:e}");
|
||
}
|
||
|
||
/// The published cavity on a coarse grid, every run: Ra = 10³ on 16 cells is within 2 % of
|
||
/// de Vahl Davis (the heavy test refines it), and the heat balances between the walls.
|
||
#[test]
|
||
fn natural_convection_cavity_is_close_on_a_coarse_grid() {
|
||
let (nu3, hot, cold) = cavity(1e3, 16, 3.65);
|
||
assert!((nu3 / 1.118 - 1.0).abs() < 0.02, "Ra 1e3: {nu3}");
|
||
heat_balances(hot, cold, 1e-3);
|
||
}
|
||
|
||
#[test]
|
||
#[ignore = "heavy: cargo xtask test --heavy (CI heavy job)"]
|
||
fn natural_convection_cavity_matches_de_vahl_davis() {
|
||
let (nu3, hot, cold) = cavity(1e3, 32, 3.65);
|
||
assert!((nu3 / 1.118 - 1.0).abs() < 0.01, "Ra 1e3: {nu3}");
|
||
heat_balances(hot, cold, 1e-3);
|
||
let (nu4, hot, cold) = cavity(1e4, 48, 19.6);
|
||
assert!((nu4 / 2.243 - 1.0).abs() < 0.01, "Ra 1e4: {nu4}");
|
||
heat_balances(hot, cold, 1e-3);
|
||
}
|
||
|
||
/// A periodic box with a uniform flow and a blob of heat: the total temperature is conserved
|
||
/// to round-off and the blob moves with the flow.
|
||
#[test]
|
||
fn a_periodic_flow_conserves_and_carries_heat() {
|
||
let (l, u0, nu, alpha) = (4.0, 0.1, 0.05, 0.01);
|
||
let dx = l / 32.0;
|
||
let mut d = domain([0.0; 3], [l, dx, dx], dx);
|
||
for f in BoxFace::ALL {
|
||
*d.faces.get_mut(f) = PERIODIC;
|
||
}
|
||
d.initial_velocity = [u0, 0.0, 0.0];
|
||
d.thermal = Some(FluidThermal::default());
|
||
let m = hot_model(1.0, nu, alpha, d, step(l / u0, 0.05 * dx / u0, None));
|
||
let (setup, mut flow) = prepare(&m, &[]);
|
||
let grid = *flow.grid();
|
||
flow.set_temperature(|c| if grid.coords(c)[0] < 8 { 1.0 } else { 0.0 });
|
||
let total = |f: &fieldcraft_lbm::Flow| (0..grid.len()).map(|c| f.temperature(c).unwrap()).sum::<f64>();
|
||
let t0 = total(&flow);
|
||
run_all(&setup, &mut flow);
|
||
let t1 = total(&flow);
|
||
let row: Vec<String> = (0..32).map(|i| format!("{:.3}", flow.temperature(grid.index([i, 0, 0])).unwrap())).collect();
|
||
println!("periodic blob after one lap: total {t0:.6} -> {t1:.6}; {}", row.join(" "));
|
||
assert!((t1 - t0).abs() < 1e-10 * t0, "{t0} vs {t1}");
|
||
}
|
||
|
||
/// A sine of temperature in a periodic box drifting with a uniform flow: it keeps its shape,
|
||
/// moves with the flow and decays as e^(−α k² t). At rest the decay converges to the
|
||
/// diffusivity at second order. With the flow the D3Q7 lattice's linear equilibrium diffuses
|
||
/// less along the flow by (τ_T − ½) u² in lattice units, 4 u² of the diffusivity (Chopard,
|
||
/// Falcone and Latt, Eur. Phys. J. Special Topics 171 (2009) 245–249): the measured decay
|
||
/// shows that deficit, which bounds the lattice speed a heat problem should run at.
|
||
#[test]
|
||
fn a_drifting_sine_decays_at_the_diffusivity() {
|
||
let (l, nu, alpha, amplitude) = (4.0, 0.05, 0.01, 0.25);
|
||
let k = 2.0 * std::f64::consts::PI / l;
|
||
let decay_time = 1.0 / (alpha * k * k);
|
||
let mut at_rest = Vec::new();
|
||
for (n, lattice_u) in [(32usize, 0.0), (64, 0.0), (64, 0.1), (64, 0.15)] {
|
||
let dx = l / n as f64;
|
||
// At rest τ_T = 1; with the flow at `lattice_u` the time step follows the speed.
|
||
let u0 = if lattice_u > 0.0 { 0.1 } else { 0.0 };
|
||
let dt = if lattice_u > 0.0 { lattice_u * dx / u0 } else { dx * dx / (8.0 * alpha) };
|
||
let mut d = domain([0.0; 3], [l, dx, dx], dx);
|
||
for f in BoxFace::ALL {
|
||
*d.faces.get_mut(f) = PERIODIC;
|
||
}
|
||
d.initial_velocity = [u0, 0.0, 0.0];
|
||
d.thermal = Some(FluidThermal { initial_temperature: 0.5, ..FluidThermal::default() });
|
||
let m = hot_model(1.0, nu, alpha, d, step(decay_time, dt, None));
|
||
let (setup, mut flow) = prepare(&m, &[]);
|
||
let tau = setup.thermal.as_ref().unwrap().tau;
|
||
let grid = *flow.grid();
|
||
let x = |c: usize| (grid.coords(c)[0] as f64 + 0.5) * dx;
|
||
flow.set_temperature(|c| 0.5 + amplitude * (k * x(c)).sin());
|
||
run_all(&setup, &mut flow);
|
||
let t = setup.steps as f64 * setup.time_step;
|
||
// The sine's amplitude and phase from the Fourier coefficient at k, then the shape.
|
||
let (mut a, mut b) = (0.0, 0.0);
|
||
for i in 0..n {
|
||
let c = grid.index([i, 0, 0]);
|
||
let temp = flow.temperature(c).unwrap();
|
||
a += temp * (k * x(c)).sin();
|
||
b += temp * (k * x(c)).cos();
|
||
}
|
||
let measured = (a * a + b * b).sqrt() * 2.0 / n as f64;
|
||
let drift = -b.atan2(a) / k;
|
||
let mut shape: f64 = 0.0;
|
||
for i in 0..n {
|
||
let c = grid.index([i, 0, 0]);
|
||
shape = shape.max((flow.temperature(c).unwrap() - 0.5 - measured * (k * (x(c) - drift)).sin()).abs());
|
||
}
|
||
let alpha_measured = -(measured / amplitude).ln() / (k * k * t);
|
||
let error = alpha_measured / alpha - 1.0;
|
||
let lag = (drift - u0 * t).rem_euclid(l);
|
||
let lag = lag.min(l - lag);
|
||
println!(
|
||
"drifting sine, {n} cells, lattice speed {lattice_u}: τ_T = {tau:.4}, α measured {alpha_measured:.5} vs {alpha} ({error:+.2e}), lag {lag:.2e}, shape error {shape:.2e}"
|
||
);
|
||
// A Fourier mode of a linear scheme on a periodic grid stays one, to round-off.
|
||
assert!(shape < 1e-9 * amplitude, "{n} cells at {lattice_u}: the sine lost its shape by {shape}");
|
||
assert!(lag < dx / 5.0, "{n} cells at {lattice_u}: drift {drift} vs {}", u0 * t);
|
||
if lattice_u > 0.0 {
|
||
let deficit = -4.0 * lattice_u * lattice_u;
|
||
assert!((error - deficit).abs() < 0.01, "{n} cells at {lattice_u}: α error {error} vs the deficit {deficit}");
|
||
} else {
|
||
assert!(error.abs() < 1e-3, "{n} cells at rest: α error {error}");
|
||
at_rest.push(error.abs());
|
||
}
|
||
}
|
||
let order = orders(&at_rest, 2.0);
|
||
println!("decay at rest converges at order {order:?}");
|
||
assert!(order.iter().all(|o| *o > 1.8), "{order:?}");
|
||
}
|