Skip to main content

pantometry_fluid/
channel.rs

1//! A box of fluid on a MAC grid, marched by projection.
2
3use glam::DVec3;
4use pantometry_core::conserved::quantity;
5use pantometry_core::{Domain, Exchange, Kind, Ledger, Reading, Violation};
6use pantometry_units::{Energy, Length, LengthVec, Time, Velocity};
7
8use crate::Fluid;
9
10/// How many conjugate-gradient iterations per cell the pressure solve gets before it gives up.
11const ITERATION_BUDGET: usize = 4;
12
13/// The largest cell Reynolds number central differences stay stable at.
14///
15/// Two, and it is a property of the **mesh** rather than of the step: advection sharpens what
16/// viscosity smooths, and past this the cell is too coarse for the smoothing to keep up. No amount
17/// of shortening the time step helps. The symptom is a sawtooth that a reader takes for turbulence.
18pub const CELL_REYNOLDS_LIMIT: f64 = 2.0;
19
20/// What the `y` faces of the box are.
21#[derive(Clone, Copy, Debug, PartialEq)]
22pub enum Walls {
23    /// Periodic in every direction. What Taylor–Green lives in.
24    None,
25    /// No-slip walls at `y = 0` and `y = h`, moving at the given speeds along `x`.
26    ///
27    /// Both zero is a channel; one moving is Couette flow.
28    Sliding {
29        /// Speed of the `y = 0` wall along `x`.
30        low: f64,
31        /// Speed of the `y = h` wall along `x`.
32        high: f64,
33    },
34}
35
36/// A rectangular box of incompressible fluid.
37///
38/// # The grid
39///
40/// Velocities on cell faces and pressure at cell centres. `u` sits on the `x` faces, `v` on the
41/// `y` faces and `w` on the `z` faces, so a divergence lands at a cell centre and a pressure
42/// gradient lands on a face, with no interpolation in either.
43///
44/// `x` and `z` are always periodic. `y` is periodic or walled, by [`Walls`].
45#[derive(Clone, Debug)]
46pub struct Channel {
47    name: String,
48    counts: (usize, usize, usize),
49    dx: f64,
50    fluid: Fluid,
51    walls: Walls,
52    /// Body force per unit mass, m/s².
53    force: DVec3,
54    /// Work the body force has done on the fluid, in joules.
55    ///
56    /// **A driven channel is not a closed system.** `drive` is a pressure gradient written as what
57    /// it does, and the pump behind it is outside this domain — so the kinetic energy it puts in
58    /// arrived from nowhere the bus can see, and the audit is right to notice. Counted here so the
59    /// books close, the way `Solid3D` counts what it generates and `Conductor` what it gives away.
60    ///
61    /// Viscosity takes it straight back out again as heat, which this domain does not model and
62    /// does not pretend to: at steady state the drive's power and the dissipation are equal, the
63    /// kinetic energy stops moving, and this counter keeps climbing at exactly the rate the fluid
64    /// is warming somewhere that is not here.
65    driven: f64,
66    /// The saved counterpart of [`Channel::driven`].
67    saved_driven: f64,
68    /// `u` on x faces: `nx · ny · nz`, periodic in x.
69    u: Vec<f64>,
70    /// `v` on y faces: `nx · (ny+1) · nz`.
71    v: Vec<f64>,
72    /// `w` on z faces: `nx · ny · nz`, periodic in z.
73    w: Vec<f64>,
74    /// Pressure at centres: `nx · ny · nz`.
75    p: Vec<f64>,
76    tolerance: f64,
77    residual: f64,
78    converged: bool,
79    saved: Option<Box<Saved>>,
80}
81
82#[derive(Clone, Debug)]
83struct Saved {
84    u: Vec<f64>,
85    v: Vec<f64>,
86    w: Vec<f64>,
87    p: Vec<f64>,
88}
89
90impl pantometry_core::ScalarField for Channel {
91    /// Metres per second — a **speed**, not a velocity. See [`Channel::as_field`].
92    fn unit(&self) -> &'static str {
93        "m/s"
94    }
95
96    /// The speed at `p`, from the cell it falls in.
97    ///
98    /// Nearest cell rather than trilinear, and the reason is the grid: velocity lives on the faces
99    /// and [`velocity_at`](Channel::velocity_at) already averages the pair across each cell to get
100    /// a centred vector. Interpolating that average again would smooth a field that has already
101    /// been smoothed once, and a viewer would be looking at a blur of a blur.
102    fn at(&self, p: LengthVec, _t: Time) -> f64 {
103        let (nx, ny, nz) = self.counts;
104        let q = p.to_si() / self.dx;
105        if q.is_nan() {
106            return f64::NAN;
107        }
108        let pick = |v: f64, n: usize| (v.floor().max(0.0) as usize).min(n.saturating_sub(1));
109        self.velocity_at(pick(q.x, nx), pick(q.y, ny), pick(q.z, nz))
110            .length()
111    }
112}
113
114impl Channel {
115    /// A box of `counts` cubic cells of side `cell`, at rest.
116    pub fn new(
117        name: impl Into<String>,
118        counts: (usize, usize, usize),
119        cell: Length,
120        fluid: Fluid,
121        walls: Walls,
122    ) -> Channel {
123        let counts = (counts.0.max(1), counts.1.max(1), counts.2.max(1));
124        let (nx, ny, nz) = counts;
125        Channel {
126            name: name.into(),
127            counts,
128            dx: cell.to_si(),
129            fluid,
130            walls,
131            force: DVec3::ZERO,
132            driven: 0.0,
133            saved_driven: 0.0,
134            u: vec![0.0; nx * ny * nz],
135            v: vec![0.0; nx * (ny + 1) * nz],
136            w: vec![0.0; nx * ny * nz],
137            p: vec![0.0; nx * ny * nz],
138            tolerance: 1e-12,
139            residual: f64::INFINITY,
140            converged: true,
141            saved: None,
142        }
143    }
144
145    /// Cells along each axis.
146    pub fn counts(&self) -> (usize, usize, usize) {
147        self.counts
148    }
149
150    /// The cell side.
151    pub fn cell(&self) -> Length {
152        Length::from_si(self.dx)
153    }
154
155    /// The box's dimensions.
156    pub fn size(&self) -> LengthVec {
157        LengthVec::from_si(
158            DVec3::new(
159                self.counts.0 as f64,
160                self.counts.1 as f64,
161                self.counts.2 as f64,
162            ) * self.dx,
163        )
164    }
165
166    /// What it is full of.
167    pub fn fluid(&self) -> Fluid {
168        self.fluid
169    }
170
171    /// The gap between the walls, for a walled box.
172    pub fn gap(&self) -> Length {
173        Length::from_si(self.counts.1 as f64 * self.dx)
174    }
175
176    /// Drive the flow with a uniform body force per unit mass, in m/s².
177    ///
178    /// A pressure gradient in disguise, and the form every closed form for channel flow is written
179    /// in: a periodic box cannot carry a mean pressure gradient, so the drive has to be a force.
180    pub fn drive(&mut self, force: DVec3) -> &mut Channel {
181        self.force = force;
182        self
183    }
184
185    /// Set every `u` face from a function of position, for releasing an exact solution.
186    ///
187    /// The callback is handed the face's own centre. `v` and `w` are set the same way by
188    /// [`Channel::set_velocity`], which takes all three at once and samples each component where
189    /// that component lives — the staggering is the whole point and a function evaluated at one
190    /// place for all three would be a different field.
191    pub fn set_velocity(&mut self, field: impl Fn(DVec3) -> DVec3) -> &mut Channel {
192        let (nx, ny, nz) = self.counts;
193        let h = self.dx;
194        for k in 0..nz {
195            for j in 0..ny {
196                for i in 0..nx {
197                    let at = DVec3::new(i as f64, j as f64 + 0.5, k as f64 + 0.5) * h;
198                    let at_i = self.iu(i, j, k);
199                    self.u[at_i] = field(at).x;
200                    let at = DVec3::new(i as f64 + 0.5, j as f64 + 0.5, k as f64) * h;
201                    let at_i = self.iw(i, j, k);
202                    self.w[at_i] = field(at).z;
203                }
204            }
205        }
206        for k in 0..nz {
207            for j in 0..=ny {
208                for i in 0..nx {
209                    let at = DVec3::new(i as f64 + 0.5, j as f64, k as f64 + 0.5) * h;
210                    let at_i = self.iv(i, j, k);
211                    self.v[at_i] = field(at).y;
212                }
213            }
214        }
215        self.apply_walls();
216        self
217    }
218
219    /// The velocity at a cell centre, by averaging the faces around it.
220    pub fn velocity_at(&self, i: usize, j: usize, k: usize) -> DVec3 {
221        let (nx, ny, nz) = self.counts;
222        let (i, j, k) = (i.min(nx - 1), j.min(ny - 1), k.min(nz - 1));
223        DVec3::new(
224            0.5 * (self.u[self.iu(i, j, k)] + self.u[self.iu((i + 1) % nx, j, k)]),
225            0.5 * (self.v[self.iv(i, j, k)] + self.v[self.iv(i, j + 1, k)]),
226            0.5 * (self.w[self.iw(i, j, k)] + self.w[self.iw(i, j, (k + 1) % nz)]),
227        )
228    }
229
230    /// The mean `x` velocity of the whole box.
231    pub fn mean_speed(&self) -> Velocity {
232        Velocity::from_si(self.u.iter().sum::<f64>() / self.u.len() as f64)
233    }
234
235    /// The mean `x` velocity of one layer of cells, which is what a profile is made of.
236    pub fn layer_speed(&self, j: usize) -> Velocity {
237        let (nx, ny, nz) = self.counts;
238        let j = j.min(ny - 1);
239        let mut sum = 0.0;
240        for k in 0..nz {
241            for i in 0..nx {
242                sum += self.u[self.iu(i, j, k)];
243            }
244        }
245        Velocity::from_si(sum / (nx * nz) as f64)
246    }
247
248    /// Kinetic energy, `½∫ρ|u|²dV`, from the face values.
249    pub fn kinetic_energy(&self) -> Energy {
250        let cell = self.dx.powi(3);
251        let rho = self.fluid.density.to_si();
252        // Face values, each carrying the half cell either side of it. `v` at a wall carries only
253        // the half inside, which is why its ends are halved.
254        let (_, ny, _) = self.counts;
255        let sum_u: f64 = self.u.iter().map(|a| a * a).sum();
256        let sum_w: f64 = self.w.iter().map(|a| a * a).sum();
257        let mut sum_v = 0.0;
258        for (idx, val) in self.v.iter().enumerate() {
259            let j = (idx / self.counts.0) % (ny + 1);
260            let weight = if j == 0 || j == ny { 0.5 } else { 1.0 };
261            sum_v += weight * val * val;
262        }
263        Energy::from_si(0.5 * rho * (sum_u + sum_v + sum_w) * cell)
264    }
265
266    /// Total `x` momentum, `ρ∫u dV`.
267    ///
268    /// Conserved **exactly** in a periodic box with no force and no walls: the advection is in flux
269    /// form, so every face's contribution appears twice with opposite signs, and the pressure
270    /// gradient of a periodic field sums to zero. That is a machine-precision statement and it is
271    /// what a decay rate is too coarse to check.
272    pub fn momentum_x(&self) -> f64 {
273        self.fluid.density.to_si() * self.dx.powi(3) * self.u.iter().sum::<f64>()
274    }
275
276    /// The largest `|∇·u|` anywhere, times the cell — a velocity, so it compares to the flow.
277    ///
278    /// After the projection this is the pressure solve's residual and nothing else. Weaker than
279    /// electromagnetism's divergence identity, which holds exactly; here it holds to whatever the
280    /// solve was asked for, and reporting it is the difference between knowing that and assuming
281    /// it.
282    pub fn divergence(&self) -> f64 {
283        let (nx, ny, nz) = self.counts;
284        let mut worst: f64 = 0.0;
285        for k in 0..nz {
286            for j in 0..ny {
287                for i in 0..nx {
288                    worst = worst.max(self.divergence_at(i, j, k).abs());
289                }
290            }
291        }
292        worst * self.dx
293    }
294
295    fn divergence_at(&self, i: usize, j: usize, k: usize) -> f64 {
296        let (nx, _, nz) = self.counts;
297        ((self.u[self.iu((i + 1) % nx, j, k)] - self.u[self.iu(i, j, k)])
298            + (self.v[self.iv(i, j + 1, k)] - self.v[self.iv(i, j, k)])
299            + (self.w[self.iw(i, j, (k + 1) % nz)] - self.w[self.iw(i, j, k)]))
300            / self.dx
301    }
302
303    /// The cell Reynolds number, `|u|dx/ν`.
304    ///
305    /// A property of the mesh and the flow in it, not of the step. See [`CELL_REYNOLDS_LIMIT`].
306    pub fn cell_reynolds(&self) -> f64 {
307        self.peak_speed() * self.dx / self.fluid.kinematic_viscosity.to_si()
308    }
309
310    /// The fastest face velocity anywhere.
311    pub fn peak_speed(&self) -> f64 {
312        self.u
313            .iter()
314            .chain(&self.v)
315            .chain(&self.w)
316            .fold(0.0f64, |a, b| a.max(b.abs()))
317    }
318
319    /// The viscous limit, `dx²/(6ν)` — the same Fourier number conduction has.
320    pub fn viscous_limit(&self) -> Time {
321        Time::from_si(self.dx * self.dx / (6.0 * self.fluid.kinematic_viscosity.to_si()))
322    }
323
324    /// The advective limit, `dx/|u|max`. Infinite for a fluid at rest.
325    pub fn courant_limit(&self) -> Time {
326        let speed = self.peak_speed();
327        Time::from_si(if speed > 0.0 {
328            self.dx / speed
329        } else {
330            f64::INFINITY
331        })
332    }
333
334    /// Whether the last pressure solve met its tolerance.
335    pub fn converged(&self) -> bool {
336        self.converged
337    }
338
339    /// The relative residual the last pressure solve reached.
340    pub fn residual(&self) -> f64 {
341        self.residual
342    }
343
344    // --- indexing ----------------------------------------------------------
345
346    fn iu(&self, i: usize, j: usize, k: usize) -> usize {
347        let (nx, ny, _) = self.counts;
348        i + nx * (j + ny * k)
349    }
350    fn iv(&self, i: usize, j: usize, k: usize) -> usize {
351        let (nx, ny, _) = self.counts;
352        i + nx * (j + (ny + 1) * k)
353    }
354    fn iw(&self, i: usize, j: usize, k: usize) -> usize {
355        let (nx, ny, _) = self.counts;
356        i + nx * (j + ny * k)
357    }
358
359    /// The `u` value one cell above or below `j`, through the wall if there is one.
360    ///
361    /// A no-slip wall is enforced by reflection: the ghost value is `2·u_wall − u_inside`, so the
362    /// interpolated value **at** the wall is `u_wall` exactly. Setting the ghost to `u_wall`
363    /// instead puts the no-slip condition half a cell into the fluid, which is a first-order error
364    /// dressed as a boundary condition and would put the Poiseuille profile visibly off.
365    fn u_at(&self, i: usize, j: isize, k: usize) -> f64 {
366        let (_, ny, _) = self.counts;
367        match self.walls {
368            Walls::None => {
369                let jj = ((j % ny as isize) + ny as isize) as usize % ny;
370                self.u[self.iu(i, jj, k)]
371            }
372            Walls::Sliding { low, high } => {
373                if j < 0 {
374                    2.0 * low - self.u[self.iu(i, 0, k)]
375                } else if j >= ny as isize {
376                    2.0 * high - self.u[self.iu(i, ny - 1, k)]
377                } else {
378                    self.u[self.iu(i, j as usize, k)]
379                }
380            }
381        }
382    }
383
384    fn w_at(&self, i: usize, j: isize, k: usize) -> f64 {
385        let (_, ny, _) = self.counts;
386        match self.walls {
387            Walls::None => {
388                let jj = ((j % ny as isize) + ny as isize) as usize % ny;
389                self.w[self.iw(i, jj, k)]
390            }
391            Walls::Sliding { .. } => {
392                if j < 0 {
393                    -self.w[self.iw(i, 0, k)]
394                } else if j >= ny as isize {
395                    -self.w[self.iw(i, ny - 1, k)]
396                } else {
397                    self.w[self.iw(i, j as usize, k)]
398                }
399            }
400        }
401    }
402
403    /// Zero the through-wall velocity, which is the only condition `v` has.
404    fn apply_walls(&mut self) {
405        if let Walls::Sliding { .. } = self.walls {
406            let (nx, ny, nz) = self.counts;
407            for k in 0..nz {
408                for i in 0..nx {
409                    let (a, b) = (self.iv(i, 0, k), self.iv(i, ny, k));
410                    self.v[a] = 0.0;
411                    self.v[b] = 0.0;
412                }
413            }
414        } else {
415            // Periodic in y: the two faces **are** the same face, so the high one is a copy of the
416            // low one and not an average with it.
417            //
418            // Averaging was the first version, and it is a half-step lag rather than a boundary
419            // condition: the update writes `v[0]` and leaves `v[ny]` stale, so the mean moves
420            // `v[0]` only half as far as the physics did. It cost 4.7% of a Taylor-Green decay
421            // rate — visible only because that rate has a closed form to be 4.7% away from.
422            let (nx, ny, nz) = self.counts;
423            for k in 0..nz {
424                for i in 0..nx {
425                    let (a, b) = (self.iv(i, 0, k), self.iv(i, ny, k));
426                    self.v[b] = self.v[a];
427                }
428            }
429        }
430    }
431    /// `v` at a `y` index that may be outside, resolved by the wall rule.
432    fn v_at(&self, i: usize, j: isize, k: usize) -> f64 {
433        let (_, ny, _) = self.counts;
434        match self.walls {
435            Walls::None => {
436                let jj = (((j % ny as isize) + ny as isize) % ny as isize) as usize;
437                self.v[self.iv(i, jj, k)]
438            }
439            // A wall has no flow through it, so the face itself is zero and a `v` beyond it is the
440            // reflection of the one inside.
441            Walls::Sliding { .. } => {
442                if j < 0 {
443                    -self.v[self.iv(i, 1, k)]
444                } else if j > ny as isize {
445                    -self.v[self.iv(i, ny - 1, k)]
446                } else {
447                    self.v[self.iv(i, j as usize, k)]
448                }
449            }
450        }
451    }
452
453    /// Which `v` faces are free to move: all of them when periodic, the interior when walled.
454    fn v_interior(&self) -> (usize, usize) {
455        let (_, ny, _) = self.counts;
456        match self.walls {
457            Walls::None => (0, ny),
458            Walls::Sliding { .. } => (1, ny),
459        }
460    }
461
462    /// Advection, diffusion and the body force, into a provisional velocity.
463    ///
464    /// # Flux form, and what it buys
465    ///
466    /// The advection is `div(uu)` rather than `u.grad u`. They are the same thing for a
467    /// divergence-free field and they are **not** the same discretisation: in flux form every
468    /// face's contribution appears twice with opposite signs, so total momentum changes only by
469    /// what the boundaries and the force do — exactly, rather than to within a truncation error.
470    ///
471    /// Central differences, second order, no dissipation of their own. The price is
472    /// [`CELL_REYNOLDS_LIMIT`]: with nothing damping what advection sharpens, a mesh too coarse for
473    /// the viscosity goes unstable and no time step rescues it. Upwinding would trade that for a
474    /// numerical viscosity often larger than the real one, which is how a scheme comes to report a
475    /// Reynolds number it is not running at.
476    fn advance(&mut self, dt: f64) {
477        let (nx, ny, nz) = self.counts;
478        let h = self.dx;
479        let nu = self.fluid.kinematic_viscosity.to_si();
480        let (mut du, mut dv, mut dw) = (
481            vec![0.0; self.u.len()],
482            vec![0.0; self.v.len()],
483            vec![0.0; self.w.len()],
484        );
485        let left = |i: usize| (i + nx - 1) % nx;
486        let right = |i: usize| (i + 1) % nx;
487        let back = |k: usize| (k + nz - 1) % nz;
488        let front = |k: usize| (k + 1) % nz;
489
490        for k in 0..nz {
491            for j in 0..ny {
492                for i in 0..nx {
493                    let jj = j as isize;
494
495                    // ---- u, on the x face at (i, j+1/2, k+1/2) -----------------------------
496                    let uc = self.u_at(i, jj, k);
497                    let ue = 0.5 * (uc + self.u_at(right(i), jj, k));
498                    let uw = 0.5 * (self.u_at(left(i), jj, k) + uc);
499                    let duudx = (ue * ue - uw * uw) / h;
500
501                    let v_up = 0.5 * (self.v_at(i, jj + 1, k) + self.v_at(left(i), jj + 1, k));
502                    let v_dn = 0.5 * (self.v_at(i, jj, k) + self.v_at(left(i), jj, k));
503                    let u_up = 0.5 * (uc + self.u_at(i, jj + 1, k));
504                    let u_dn = 0.5 * (self.u_at(i, jj - 1, k) + uc);
505                    let duvdy = (u_up * v_up - u_dn * v_dn) / h;
506
507                    let w_f = 0.5
508                        * (self.w[self.iw(i, j, front(k))] + self.w[self.iw(left(i), j, front(k))]);
509                    let w_b = 0.5 * (self.w[self.iw(i, j, k)] + self.w[self.iw(left(i), j, k)]);
510                    let u_f = 0.5 * (uc + self.u_at(i, jj, front(k)));
511                    let u_b = 0.5 * (self.u_at(i, jj, back(k)) + uc);
512                    let duwdz = (u_f * w_f - u_b * w_b) / h;
513
514                    let lap = (self.u_at(right(i), jj, k)
515                        + self.u_at(left(i), jj, k)
516                        + self.u_at(i, jj + 1, k)
517                        + self.u_at(i, jj - 1, k)
518                        + self.u_at(i, jj, front(k))
519                        + self.u_at(i, jj, back(k))
520                        - 6.0 * uc)
521                        / (h * h);
522                    let at = self.iu(i, j, k);
523                    du[at] = dt * (-(duudx + duvdy + duwdz) + nu * lap + self.force.x);
524
525                    // ---- w, on the z face at (i+1/2, j+1/2, k) -----------------------------
526                    let wc = self.w[self.iw(i, j, k)];
527                    let wf = 0.5 * (wc + self.w[self.iw(i, j, front(k))]);
528                    let wb = 0.5 * (self.w[self.iw(i, j, back(k))] + wc);
529                    let dwwdz = (wf * wf - wb * wb) / h;
530
531                    let u_e = 0.5 * (self.u_at(right(i), jj, k) + self.u_at(right(i), jj, back(k)));
532                    let u_w2 = 0.5 * (self.u_at(i, jj, k) + self.u_at(i, jj, back(k)));
533                    let w_e = 0.5 * (wc + self.w[self.iw(right(i), j, k)]);
534                    let w_w = 0.5 * (self.w[self.iw(left(i), j, k)] + wc);
535                    let dwudx = (w_e * u_e - w_w * u_w2) / h;
536
537                    let v_up2 = 0.5 * (self.v_at(i, jj + 1, k) + self.v_at(i, jj + 1, back(k)));
538                    let v_dn2 = 0.5 * (self.v_at(i, jj, k) + self.v_at(i, jj, back(k)));
539                    let w_up = 0.5 * (wc + self.w_at(i, jj + 1, k));
540                    let w_dn = 0.5 * (self.w_at(i, jj - 1, k) + wc);
541                    let dwvdy = (w_up * v_up2 - w_dn * v_dn2) / h;
542
543                    let lap = (self.w[self.iw(right(i), j, k)]
544                        + self.w[self.iw(left(i), j, k)]
545                        + self.w_at(i, jj + 1, k)
546                        + self.w_at(i, jj - 1, k)
547                        + self.w[self.iw(i, j, front(k))]
548                        + self.w[self.iw(i, j, back(k))]
549                        - 6.0 * wc)
550                        / (h * h);
551                    let at = self.iw(i, j, k);
552                    dw[at] = dt * (-(dwwdz + dwudx + dwvdy) + nu * lap + self.force.z);
553                }
554            }
555        }
556
557        let (lo, hi) = self.v_interior();
558        for k in 0..nz {
559            for j in lo..hi {
560                for i in 0..nx {
561                    let jj = j as isize;
562                    let vc = self.v_at(i, jj, k);
563                    let vu = 0.5 * (vc + self.v_at(i, jj + 1, k));
564                    let vd = 0.5 * (self.v_at(i, jj - 1, k) + vc);
565                    let dvvdy = (vu * vu - vd * vd) / h;
566
567                    let u_e = 0.5 * (self.u_at(right(i), jj, k) + self.u_at(right(i), jj - 1, k));
568                    let u_w = 0.5 * (self.u_at(i, jj, k) + self.u_at(i, jj - 1, k));
569                    let v_e = 0.5 * (vc + self.v_at(right(i), jj, k));
570                    let v_w = 0.5 * (self.v_at(left(i), jj, k) + vc);
571                    let dvudx = (v_e * u_e - v_w * u_w) / h;
572
573                    let w_f = 0.5 * (self.w_at(i, jj, front(k)) + self.w_at(i, jj - 1, front(k)));
574                    let w_b = 0.5 * (self.w_at(i, jj, k) + self.w_at(i, jj - 1, k));
575                    let v_f = 0.5 * (vc + self.v_at(i, jj, front(k)));
576                    let v_b = 0.5 * (self.v_at(i, jj, back(k)) + vc);
577                    let dvwdz = (v_f * w_f - v_b * w_b) / h;
578
579                    let lap = (self.v_at(right(i), jj, k)
580                        + self.v_at(left(i), jj, k)
581                        + self.v_at(i, jj + 1, k)
582                        + self.v_at(i, jj - 1, k)
583                        + self.v_at(i, jj, front(k))
584                        + self.v_at(i, jj, back(k))
585                        - 6.0 * vc)
586                        / (h * h);
587                    let at = self.iv(i, j, k);
588                    dv[at] = dt * (-(dvvdy + dvudx + dvwdz) + nu * lap + self.force.y);
589                }
590            }
591        }
592
593        for (a, b) in self.u.iter_mut().zip(&du) {
594            *a += b;
595        }
596        for (a, b) in self.v.iter_mut().zip(&dv) {
597            *a += b;
598        }
599        for (a, b) in self.w.iter_mut().zip(&dw) {
600            *a += b;
601        }
602        self.apply_walls();
603    }
604
605    /// Make the provisional velocity divergence-free by subtracting a pressure gradient.
606    ///
607    /// Solve `lap(phi) = div(u*)/dt` and set `u = u* - dt grad(phi)`. The operator is the same
608    /// finite-volume Laplacian `Conductor` and `Puck` solve, with no flux through a wall and
609    /// periodic elsewhere, and it is singular by a constant — the pressure of an incompressible
610    /// flow is defined only up to one. The right-hand side has its mean removed so the system is
611    /// consistent, and the answer has its mean removed so it is a particular one.
612    fn project(&mut self, dt: f64) -> bool {
613        let (nx, ny, nz) = self.counts;
614        let cells = nx * ny * nz;
615        let mut b = vec![0.0; cells];
616        for k in 0..nz {
617            for j in 0..ny {
618                for i in 0..nx {
619                    b[i + nx * (j + ny * k)] = self.divergence_at(i, j, k) / dt;
620                }
621            }
622        }
623        let mean: f64 = b.iter().sum::<f64>() / cells as f64;
624        for v in b.iter_mut() {
625            *v -= mean;
626        }
627
628        let mut x = std::mem::take(&mut self.p);
629        if x.len() != cells {
630            x = vec![0.0; cells];
631        }
632        let ax = self.laplacian(&x);
633        let mut r: Vec<f64> = b.iter().zip(&ax).map(|(bi, axi)| bi - axi).collect();
634        let mut p = r.clone();
635        let mut rr: f64 = r.iter().map(|v| v * v).sum();
636        let scale = b
637            .iter()
638            .map(|v| v * v)
639            .sum::<f64>()
640            .sqrt()
641            .max(f64::MIN_POSITIVE);
642        let budget = ITERATION_BUDGET * cells + 64;
643        let mut iterations = 0;
644        while rr.sqrt() / scale > self.tolerance && iterations < budget {
645            let ap = self.laplacian(&p);
646            let pap: f64 = p.iter().zip(&ap).map(|(a, b)| a * b).sum();
647            if pap.abs() <= 0.0 {
648                break;
649            }
650            let alpha = rr / pap;
651            for (xi, pi) in x.iter_mut().zip(&p) {
652                *xi += alpha * pi;
653            }
654            for (ri, api) in r.iter_mut().zip(&ap) {
655                *ri -= alpha * api;
656            }
657            let rr_next: f64 = r.iter().map(|v| v * v).sum();
658            let beta = rr_next / rr;
659            for (pi, ri) in p.iter_mut().zip(&r) {
660                *pi = ri + beta * *pi;
661            }
662            rr = rr_next;
663            iterations += 1;
664        }
665        let phi_mean: f64 = x.iter().sum::<f64>() / cells as f64;
666        for v in x.iter_mut() {
667            *v -= phi_mean;
668        }
669        self.p = x;
670        self.residual = rr.sqrt() / scale;
671        self.converged = self.residual <= self.tolerance;
672
673        let h = self.dx;
674        let phi = self.p.clone();
675        let idx = |i: usize, j: usize, k: usize| i + nx * (j + ny * k);
676        for k in 0..nz {
677            for j in 0..ny {
678                for i in 0..nx {
679                    let at = self.iu(i, j, k);
680                    self.u[at] -= dt * (phi[idx(i, j, k)] - phi[idx((i + nx - 1) % nx, j, k)]) / h;
681                    let at = self.iw(i, j, k);
682                    self.w[at] -= dt * (phi[idx(i, j, k)] - phi[idx(i, j, (k + nz - 1) % nz)]) / h;
683                }
684            }
685        }
686        let (lo, hi) = self.v_interior();
687        for k in 0..nz {
688            for j in lo..hi {
689                for i in 0..nx {
690                    let up = idx(i, j % ny, k);
691                    let dn = idx(i, (j + ny - 1) % ny, k);
692                    let at = self.iv(i, j, k);
693                    self.v[at] -= dt * (phi[up] - phi[dn]) / h;
694                }
695            }
696        }
697        self.apply_walls();
698        self.converged
699    }
700
701    /// The finite-volume Laplacian at cell centres: no flux through a wall, periodic elsewhere.
702    fn laplacian(&self, x: &[f64]) -> Vec<f64> {
703        let (nx, ny, nz) = self.counts;
704        let h2 = self.dx * self.dx;
705        let idx = |i: usize, j: usize, k: usize| i + nx * (j + ny * k);
706        let mut y = vec![0.0; x.len()];
707        let walled = matches!(self.walls, Walls::Sliding { .. });
708        for k in 0..nz {
709            for j in 0..ny {
710                for i in 0..nx {
711                    let c = idx(i, j, k);
712                    let mut acc =
713                        x[idx((i + 1) % nx, j, k)] + x[idx((i + nx - 1) % nx, j, k)] - 2.0 * x[c];
714                    acc +=
715                        x[idx(i, j, (k + 1) % nz)] + x[idx(i, j, (k + nz - 1) % nz)] - 2.0 * x[c];
716                    // The `y` direction, where a wall means the neighbour is absent rather than
717                    // mirrored: a zero-flux face contributes nothing at all.
718                    if walled {
719                        if j + 1 < ny {
720                            acc += x[idx(i, j + 1, k)] - x[c];
721                        }
722                        if j > 0 {
723                            acc += x[idx(i, j - 1, k)] - x[c];
724                        }
725                    } else {
726                        acc += x[idx(i, (j + 1) % ny, k)] + x[idx(i, (j + ny - 1) % ny, k)]
727                            - 2.0 * x[c];
728                    }
729                    y[c] = acc / h2;
730                }
731            }
732        }
733        y
734    }
735}
736
737impl Domain for Channel {
738    fn name(&self) -> &str {
739        &self.name
740    }
741
742    fn kind(&self) -> Kind {
743        Kind::Evolving
744    }
745
746    fn max_stable_dt(&self, _now: Time) -> Time {
747        Time::from_si(
748            self.viscous_limit()
749                .to_si()
750                .min(self.courant_limit().to_si()),
751        )
752    }
753
754    fn step(&mut self, _t: Time, dt: Time, _bus: &mut Exchange) -> Result<(), Violation> {
755        let h = dt.to_si();
756        let limit = self.max_stable_dt(Time::from_si(0.0)).to_si();
757        if h > limit {
758            return Err(Violation {
759                quantity: "flow step".into(),
760                site: self.name.clone(),
761                before: limit,
762                after: h,
763                scale: limit,
764                tolerance: 0.0,
765            });
766        }
767        let re = self.cell_reynolds();
768        if re > CELL_REYNOLDS_LIMIT {
769            return Err(Violation {
770                quantity: "cell Reynolds number".into(),
771                site: self.name.clone(),
772                before: CELL_REYNOLDS_LIMIT,
773                after: re,
774                scale: CELL_REYNOLDS_LIMIT,
775                tolerance: 0.0,
776            });
777        }
778
779        // The work the drive did, counted in the same statement that does it. Measured as the
780        // change in kinetic energy across a step with no other source: viscosity takes energy out
781        // and the drive puts it in, and at steady state the two are equal — so this keeps climbing
782        // while the kinetic energy stops, which is the true statement about a pumped channel.
783        let before = self.kinetic_energy().to_si();
784        self.advance(h);
785        if !self.project(h) {
786            return Err(Violation::at(
787                self.name.clone(),
788                "pressure residual",
789                self.residual,
790            ));
791        }
792        if self.force != DVec3::ZERO {
793            self.driven += self.kinetic_energy().to_si() - before;
794        }
795        Ok(())
796    }
797
798    /// The kinetic energy the box is holding.
799    /// The kinetic energy it holds, less the work the drive has put in.
800    ///
801    /// Two contributions rather than their difference: `Ledger::add` raises an entry's *scale* to
802    /// the largest thing added to it and the audit judges a change against that, so pre-summing a
803    /// near-zero net would leave no scale at all. A channel starting from rest holds exactly zero,
804    /// and the first `2.9e-12` J the drive did was judged a hundred-percent change and stopped a
805    /// correct run on its first step.
806    fn ledger(&self) -> Ledger {
807        Ledger::new()
808            .with(quantity::ENERGY, self.kinetic_energy().to_si())
809            .with(quantity::ENERGY, -self.driven)
810    }
811
812    fn readings(&self) -> Vec<Reading> {
813        let mut out = vec![
814            Reading::new(&self.name, "mean speed", self.mean_speed().to_si(), "m/s"),
815            Reading::new(&self.name, "peak speed", self.peak_speed(), "m/s"),
816            Reading::new(
817                &self.name,
818                "kinetic energy",
819                self.kinetic_energy().to_si(),
820                "J",
821            ),
822            Reading::new(&self.name, "divergence", self.divergence(), "m/s"),
823            Reading::new(&self.name, "cell Reynolds", self.cell_reynolds(), ""),
824        ];
825        // **Only for a channel that is driven**, the way a block reports `melted` only if it can
826        // melt. At steady state the kinetic energy stops moving and this keeps climbing, which is
827        // the pump's power made visible — and without it a reader would see a flow holding still
828        // and no sign that anything was paying for it.
829        if self.force != DVec3::ZERO {
830            out.push(Reading::new(&self.name, "work driven in", self.driven, "J"));
831        }
832        out
833    }
834
835    /// **Speed**, so a flow can be looked at.
836    ///
837    /// The honest caveat is in the unit and in this sentence rather than in a refusal to draw one:
838    /// velocity is a *vector* and this is its magnitude, so a picture of it shows where the fluid
839    /// is moving fast and not which way it is going. The components are in the JSON, and
840    /// `layer_speed` is what a profile should be read from.
841    ///
842    /// Drawn at all because a domain nobody can see is a domain nobody trusts, and this crate's own
843    /// documentation says why that matters here more than anywhere: "it looks like a fluid" is the
844    /// easiest wrong answer in computational physics to accept, and the answer to that is closed
845    /// forms **and** a picture, not one instead of the other.
846    fn as_field(&self) -> Option<&dyn pantometry_core::ScalarField> {
847        Some(self)
848    }
849
850    fn as_any(&self) -> Option<&dyn std::any::Any> {
851        Some(self)
852    }
853
854    /// **And mutably**, because a domain that can be read and not written is one a coupling can
855    /// only fail at silently. `Simulation::domain_as_mut` returns `None` when this is not
856    /// implemented, and a caller that wrote through it would do nothing and report nothing —
857    /// which is how a whole coupled body came back reporting zero strain that read as *no stress*
858    /// rather than as *not connected*.
859    fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
860        Some(self)
861    }
862
863    fn checkpoint(&mut self) {
864        self.saved = Some(Box::new(Saved {
865            u: self.u.clone(),
866            v: self.v.clone(),
867            w: self.w.clone(),
868            p: self.p.clone(),
869        }));
870        self.saved_driven = self.driven;
871    }
872
873    fn restore(&mut self) {
874        if let Some(s) = self.saved.take() {
875            self.u = s.u;
876            self.v = s.v;
877            self.w = s.w;
878            self.p = s.p;
879            self.driven = self.saved_driven;
880        }
881    }
882}