pantometry_fluid/lib.rs
1#![deny(missing_docs)]
2
3//! Incompressible flow in three dimensions, by projection on a staggered grid.
4//!
5//! ```text
6//! ∂u/∂t + ∇·(uu) = −∇p/ρ + ν∇²u + g
7//! ∇·u = 0
8//! ```
9//!
10//! # Why this is the hardest of this workspace's domains to trust
11//!
12//! Every other physics here has closed forms lying around. Fluids has few, its schemes trade
13//! stability against numerical diffusion, and **"it looks like a fluid" is the easiest wrong
14//! answer in computational physics to accept**: a scheme with the wrong viscosity still makes
15//! plausible vortices, and a scheme that quietly loses momentum still makes a pretty picture.
16//!
17//! So this crate is built around the three exact solutions that exist, and each one is chosen to
18//! be blind to a different mistake:
19//!
20//! ```text
21//! Poiseuille u(y) = (g/2ν)·y(h−y) exact — a quadratic, and a second difference of one
22//! Couette u(y) = U·y/h exact — linear, and blind to advection entirely
23//! Taylor–Green e^{−2νk²t} the full nonlinear equations, decay rate and all
24//! ```
25//!
26//! The first two are unidirectional and steady, so the advection term is identically zero in
27//! both — they cannot check it at all, and saying so is the point. Taylor–Green can: it is an
28//! exact solution of the *complete* equations, in which the nonlinear term is balanced by the
29//! pressure gradient rather than absent. Beside it sit two statements that hold at machine
30//! precision and catch what a decay rate is too coarse to see: a uniform flow must remain exactly
31//! uniform, and total momentum in a periodic box must not move at all.
32//!
33//! # The staggered grid, and its one guarantee
34//!
35//! Velocities on cell **faces**, pressure at cell **centres** — the same arrangement Yee uses for
36//! electromagnetism, and for the same reason: the divergence of a face velocity lands naturally at
37//! a cell centre, and the gradient of a centred pressure lands naturally on a face. No
38//! interpolation, and no checkerboard pressure mode.
39//!
40//! After the projection, `∇·u` is **the residual of the pressure solve** and nothing else — see
41//! [`Channel::divergence`]. That is weaker than electromagnetism's identity, which holds exactly:
42//! here it holds to whatever the conjugate-gradient solve was asked for.
43//!
44//! # Two limits, and one of them is on the grid rather than on the step
45//!
46//! ```text
47//! dt ≤ dx²/(6ν) viscous, the same Fourier limit conduction has
48//! dt ≤ dx/|u|max advective, the Courant limit
49//! |u|dx/ν ≤ 2 the cell Reynolds number — a property of the *mesh*
50//! ```
51//!
52//! The third is the one that surprises people. Central differences on the advection term go
53//! unstable when a cell is too coarse for the viscosity to smooth what advection sharpens, and no
54//! amount of shortening the step fixes it: the mesh is wrong. [`Channel::cell_reynolds`] reports
55//! it and [`Channel::step`](pantometry_core::Domain::step) refuses above two, rather than producing
56//! the wiggles that a reader would take for turbulence.
57//!
58//! # What is deliberately not here
59//!
60//! No turbulence model, no compressibility, no free surface, no immersed geometry, no adaptive
61//! mesh. A box with periodic sides and optional walls, which is exactly what the three exact
62//! solutions live in.
63
64use pantometry_units::{Density, Diffusivity};
65
66mod channel;
67
68pub use channel::{Channel, Walls, CELL_REYNOLDS_LIMIT};
69
70/// A Newtonian fluid.
71#[derive(Clone, Copy, Debug)]
72pub struct Fluid {
73 /// Density.
74 pub density: Density,
75 /// **Kinematic** viscosity, `μ/ρ`, in m²/s.
76 ///
77 /// The one that appears in the equations above and in every closed form below. Tables quote
78 /// the dynamic viscosity as often as this one and the two differ by a factor of a thousand for
79 /// water, which is the sort of error dimensions cannot catch and a name can.
80 pub kinematic_viscosity: Diffusivity,
81}
82
83impl Fluid {
84 /// Water at 20 °C.
85 pub fn water() -> Fluid {
86 Fluid {
87 density: Density::from_si(998.2),
88 kinematic_viscosity: Diffusivity::from_si(1.004e-6),
89 }
90 }
91
92 /// Air at 20 °C and one atmosphere.
93 pub fn air() -> Fluid {
94 Fluid {
95 density: Density::from_si(1.204),
96 kinematic_viscosity: Diffusivity::from_si(1.511e-5),
97 }
98 }
99
100 /// A fluid with whatever properties, for a test that wants a convenient Reynolds number.
101 pub fn new(density: Density, kinematic_viscosity: Diffusivity) -> Fluid {
102 Fluid {
103 density,
104 kinematic_viscosity,
105 }
106 }
107
108 /// Dynamic viscosity, `ρν`, in Pa·s.
109 pub fn dynamic_viscosity(&self) -> f64 {
110 self.density.to_si() * self.kinematic_viscosity.to_si()
111 }
112}
113
114/// The mean speed of plane Poiseuille flow driven by a body force.
115///
116/// ```text
117/// u(y) = (g/2ν)·y(h−y) ⇒ ū = g h² / (12 ν)
118/// ```
119///
120/// A closed form of the gap, the force and the viscosity, and of nothing else. The profile is a
121/// **quadratic**, which is why a second-order scheme reproduces it exactly rather than nearly: the
122/// second difference of a quadratic is that quadratic's second derivative, with no truncation
123/// error at all.
124pub fn poiseuille_mean_speed(force_per_mass: f64, gap: f64, kinematic_viscosity: f64) -> f64 {
125 force_per_mass * gap * gap / (12.0 * kinematic_viscosity)
126}
127
128/// The rate at which a Taylor–Green vortex's velocity decays, `2νk²`.
129///
130/// The kinetic energy decays at twice this, because energy goes as the square. Confusing the two
131/// is a factor of two in the viscosity, and it is the reason both are named here rather than one.
132pub fn taylor_green_rate(wavenumber: f64, kinematic_viscosity: f64) -> f64 {
133 2.0 * kinematic_viscosity * wavenumber * wavenumber
134}
135
136#[cfg(test)]
137mod tests {
138 use super::*;
139
140 /// **The two viscosities differ by the density**, and both are named so neither is guessed.
141 #[test]
142 fn the_kinematic_and_dynamic_viscosities_are_a_density_apart() {
143 let w = Fluid::water();
144 assert!(
145 (w.dynamic_viscosity() / 1.002e-3 - 1.0).abs() < 0.01,
146 "water is about 1.0 mPa.s: {:.4e}",
147 w.dynamic_viscosity()
148 );
149 // Air is fifteen times *more* viscous kinematically than water and fifty times less
150 // dynamically, which is the whole reason the distinction is worth a name.
151 let a = Fluid::air();
152 assert!(
153 a.kinematic_viscosity.to_si() > 10.0 * w.kinematic_viscosity.to_si()
154 && a.dynamic_viscosity() < 0.1 * w.dynamic_viscosity(),
155 "air: nu {:.3e} against water's {:.3e}, mu {:.3e} against {:.3e}",
156 a.kinematic_viscosity.to_si(),
157 w.kinematic_viscosity.to_si(),
158 a.dynamic_viscosity(),
159 w.dynamic_viscosity()
160 );
161 }
162
163 /// **The Poiseuille mean is the profile's own average**, which is an integral and not a fit.
164 #[test]
165 fn the_poiseuille_mean_is_the_integral_of_its_profile() {
166 let (g, h, nu) = (0.5, 0.02, 1e-5);
167 let closed = poiseuille_mean_speed(g, h, nu);
168 // Numerically integrate `(g/2nu) y (h-y)` over the gap, with enough points that the
169 // trapezium rule's own error is below the comparison.
170 let n = 100_000;
171 let mut sum = 0.0;
172 for i in 0..=n {
173 let y = h * i as f64 / n as f64;
174 let w = if i == 0 || i == n { 0.5 } else { 1.0 };
175 sum += w * (g / (2.0 * nu)) * y * (h - y);
176 }
177 let mean = sum * (h / n as f64) / h;
178 assert!(
179 (mean / closed - 1.0).abs() < 1e-9,
180 "gh^2/12nu is the mean of the parabola: {mean:.6e} against {closed:.6e}"
181 );
182 }
183
184 /// **Energy decays at twice the velocity's rate**, which is the factor of two this crate names.
185 #[test]
186 fn the_energy_rate_is_twice_the_velocity_rate() {
187 let (k, nu) = (100.0, 1e-4);
188 let rate = taylor_green_rate(k, nu);
189 assert!((rate - 2.0 * nu * k * k).abs() < 1e-12);
190 // A velocity going as `e^{-rt}` gives an energy going as `e^{-2rt}`.
191 let t = 0.37;
192 let u = (-rate * t).exp();
193 assert!(
194 ((u * u) / (-2.0 * rate * t).exp() - 1.0).abs() < 1e-12,
195 "energy is the square of the velocity"
196 );
197 }
198}