SimulationLab ladder
Level 6 of 10 · Fluid dynamics
VERIFIEDPoiseuille flow converges at exactly order 2.000. Schäfer–Turek Strouhal 0.3014 (range 0.295–0.305); cylinder Strouhal 0.1651 vs Williamson's 0.164.

L06 · Virtual Wind Tunnel

A lattice Boltzmann CFD solver in one Python file, compiled by Numba, taken through the same verification & validation workflow that professional CFD teams use. First prove the code solves its equations right, then prove they are the right equations, and only then fly a wing through it.

hero

Movie: output/wind_tunnel.mp4 (1280×720, 16 s, H.264). NACA 0012 at 20°, Re = 1000, chord 128 cells, 448 000 lattice nodes. Colour is vorticity ω c/U: blue is clockwise, orange is counter-clockwise, and still air is dark. The strip below shows the live lift and drag coefficients.


1. The question

A CFD code will always produce a colourful picture. The hard question is whether you can believe the numbers. This level builds a small but real wind tunnel and asks, in order:

  1. Verification. Does the solver reproduce an exact solution of the Navier–Stokes equations, and does its error shrink at the rate theory predicts?
  2. Solution verification. On a real problem with no exact answer (a cylinder in a channel), how big is the discretisation error, and does it converge to the international benchmark?
  3. Validation. Does a cylinder in free stream shed vortices at the frequency measured in real experiments (Williamson 1996)? Which of our modelling choices (grid, Mach number, tunnel walls, inlet) cause which part of the disagreement?
  4. Showcase. What does a NACA 0012 wing do at Re = 1000 (the insect / micro-drone regime) from 0° to 20°? Lift curve, drag polar, stall and vortex shedding.
  5. Limits. Why can't a laptop LBM fly an airliner wing at Re = 10⁷, and what do industrial codes do instead?

2. The model

Lattice Boltzmann instead of Navier–Stokes. LBM does not discretise the Navier–Stokes equations directly. It evolves particle distribution functions $f_i(\mathbf x,t)$: the density of fictitious particles moving with one of nine lattice velocities $\mathbf c_i$ (D2Q9: rest, 4 axis neighbours, 4 diagonals). One time step is two operations:

$$\underbrace{f_i(\mathbf x+\mathbf c_i,\,t+1)}_{\text{stream}} = \underbrace{f_i + \omega\,\big(f_i^{eq}-f_i\big)}_{\text{collide (BGK)}},\qquad f_i^{eq}=w_i\,\rho\Big(1+3\,\mathbf c_i\!\cdot\!\mathbf u+\tfrac92(\mathbf c_i\!\cdot\!\mathbf u)^2-\tfrac32 u^2\Big)$$

with $\rho=\sum_i f_i$, $\rho\mathbf u=\sum_i \mathbf c_i f_i$, weights $w_0=4/9$, $w_{1..4}=1/9$, $w_{5..8}=1/36$, and $\omega = 1/\tau$. A Chapman–Enskog expansion shows that the moments obey the weakly compressible Navier–Stokes equations with

$$p = c_s^2\rho,\qquad c_s=\tfrac{1}{\sqrt3},\qquad \nu = c_s^2\left(\tau-\tfrac12\right),$$

up to errors of order $\mathrm{Ma}^2 = (u/c_s)^2$ (compressibility) and $\Delta x^2$ (discretisation). Everything is in lattice units ($\Delta x=\Delta t=1$). Physics only enters through dimensionless groups: $\mathrm{Re}=UL/\nu$, $\mathrm{Ma}=U/c_s$, $\mathrm{St}=fL/U$.

Boundaries and forces.

Piece Method Order / note
Solid walls, cylinder, airfoil half-way bounce-back on a staircase mask: a population that hits a wall comes back reversed 2nd order on grid-aligned walls, 1st order on curved ones (the wall sits half-way along each cut link)
Force on the body momentum exchange (Ladd 1994): every wall link hands $2\,\mathbf c_i f_i$ to the body exact bookkeeping of what bounce-back does
Inlet velocity inlet, non-equilibrium extrapolation (Guo, Zheng & Shi 2002): $u$ given, $\rho$ from the neighbour
Outlet pressure outlet ($\rho=1$), same method: $\mathbf u$ from the neighbour
Far field sponge layer: over the last 10 % of the length (and 10 % of the height at open sides), relax $f$ towards the free-stream equilibrium by up to 10 % per step absorbs vortices and sound (see results)
Sides periodic, unless they are channel walls (rows of solid nodes)
Body force (Poiseuille only) Guo forcing, $\mathbf u = (\sum \mathbf c_i f_i + \mathbf g/2)/\rho$ 2nd order

Runs (all in float32 except the Poiseuille verification, which is float64):

Case Grid Re U (lattice) peak Ma τ Purpose
Poiseuille channel N = 8…128 rows – 0.05 0.09 0.55–2.0 code verification
Schäfer–Turek 2D-2 D = 20, 30, 40 cells, 22D × 4.1D 100 0.067 mean, 0.1 peak 0.17 0.54–0.58 solution verification (GCI) + benchmark
Open cylinder D = 20; inlet 6–20D upstream, 20D of wake, sides 10–40D apart 100 0.08 0.14 0.548 validation vs experiment + domain-size study
BGK stability probe D = 20, 16D × 8D 200…6400 0.1 0.17 0.53 → 0.5009 limits
NACA 0012 sweep chord 64, 10c × 5c (+ 15c × 7.5c check) 1000 0.08 0.14 0.515 showcase polar
NACA 0012 movie chord 128 (+ chord 64 check), 7.8c × 3.5c 1000 0.08 0.14 0.531 showcase + grid check

3. M&S concepts you'll learn here

4. Tech stack

Tool Role here
Python 3.12 everything
NumPy geometry masks, link lists, moments, post-processing
Matplotlib (+ imageio-ffmpeg) figures; MP4 via FFMpegWriter (introduced in L05)
SciPy (introduced in L02) here only for scipy.fft, below
Numba @njit, prange, fastmath (introduced in L05) JIT compilation to vectorised machine code, split over 3 threads
★ hand-written stencil kernel the fused stream + collide loop: the core of every LBM code, and a lesson in what makes LLVM vectorise (or not)
★ numba.stencil the vorticity $\partial_x u_y-\partial_y u_x$ written as a relative-index kernel uy[0, 1] - uy[0, -1] ...; Numba generates the parallel loop and the borders
★ scipy.fft (rfft, rfftfreq) windowed, zero-padded spectra of the lift → Strouhal numbers
★ V&V workflow observed order, Richardson extrapolation, GCI, benchmark bands, a sensitivity study per error source, and verify() assertions

Why these matter: the stencil kernel is the whole solver (~60 lines), and its speed decides which Reynolds numbers you can reach. The FFT is how every wind-tunnel lab turns a force balance or a hot-wire signal into a shedding frequency. The V&V workflow is what separates a CFD picture from a CFD result you can put your name on.

5. Industry & scientific tools

Tool What it is Who uses it How this project maps to it
OpenFOAM open-source C++ finite-volume toolbox (simpleFoam, pimpleFoam), unstructured polyhedral meshes academia, automotive, energy, consultancies the same cylinder: blockMesh/snappyHexMesh mesh + pimpleFoam + forceCoeffs function object, then the same FFT of C_L
Ansys Fluent commercial general-purpose finite-volume CFD aerospace, turbomachinery, process industry the airfoil polar is a standard Fluent tutorial: C-mesh, k-ω SST, a sweep of angles of attack
Siemens STAR-CCM+ commercial FV CFD with automated polyhedral meshing and a design-exploration loop automotive, motorsport, marine the sweep loop in airfoil_sweep() is what its design manager automates
PowerFLOW (Dassault/SIMULIA) industrial lattice Boltzmann: D3Q19, VLES turbulence, wall models, octree grids automotive external aero and wind noise, aircraft landing-gear/airframe noise the same stream/collide/bounce-back algorithm, scaled up: 10⁸–10⁹ voxels, turbulence and wall modelling
Palabos open-source C++ LBM library (Univ. Geneva) research: porous media, biomedical, multiphase stream_collide + bounce + NEEM inlets are Palabos building blocks; its Python examples start with this cylinder
waLBerla massively parallel LBM framework (FAU Erlangen), code-generated kernels HPC research on 10⁵+ cores and GPUs our hand-unrolled, vectorised kernel is what waLBerla's lbmpy generates automatically
XFOIL Drela's panel method coupled to an integral boundary layer airfoil designers, UAV/sailplane/wind-turbine blades the polar in one second, but only for attached flow at Re ≳ 10⁴–10⁵; Re = 1000 and deep stall are where you need Navier–Stokes
ParaView open-source visualisation (VTK) everyone who runs the codes above our vorticity movie is what you'd script with pvpython for a 3D run

Industrial LBM vs finite volume: how PowerFLOW and OpenFOAM/Fluent differ

Lattice Boltzmann (PowerFLOW, XFlow, ProLB) Finite volume (OpenFOAM, Fluent, STAR-CCM+)
Unknowns 19–27 distributions $f_i$ per voxel $p$, $\mathbf u$ (+ turbulence quantities) per cell
Mesh Cartesian voxels, octree refinement boxes ("VR regions"); geometry is simply immersed, so CAD to run takes hours body-fitted unstructured mesh with prism layers along walls; meshing is often the longest step (days)
Walls surfels / interpolated bounce-back + wall functions (the first voxel is rarely inside the viscous sublayer) resolved boundary layer (y⁺ ≈ 1) or wall functions
Time inherently explicit and transient; small time steps (acoustic CFL) but trivially cheap ones steady RANS (SIMPLE) or implicit transient (PISO/PIMPLE): large time steps, each one a global linear solve
Pressure from the equation of state, $p=c_s^2\rho$: no Poisson equation pressure–velocity coupling: a Poisson-type solve every iteration
Compressibility weakly compressible: needs Ma ≲ 0.3 (or special high-Ma lattices), but acoustics come for free, the reason it dominates wind-noise and aeroacoustics incompressible or fully compressible formulations; acoustics need an extra solver or an acoustic analogy
Turbulence VLES: a k-ε-type model sets an effective relaxation time (Chen et al. 2003) RANS (k-ω SST, Spalart–Allmaras), LES, DES hybrids
Parallel scaling local stencil: near-perfect scaling, GPU-friendly global solves: good, but communication-bound at scale
Sweet spot transient, geometrically messy, acoustically relevant flows: a full car with mirrors, wheels and underbody steady or mildly unsteady design loops, heat transfer, combustion, high-speed compressible flow

This project is a 2D, laminar, single-resolution PowerFLOW in miniature: the same algorithm with none of the turbulence, wall-modelling or grid-refinement machinery that makes Re = 10⁶–10⁷ possible.

6. Results

All numbers below are printed by sim.py in the final full run (§7). Lattice units unless stated.

6.1 Code verification: Poiseuille flow

Poiseuille

A body-force-driven channel (Guo forcing, BGK, τ = 0.8) between half-way bounce-back walls, run to a steady state (update < 10⁻¹² u_max), compared with the exact parabola $u(y)=4u_{max}\,y(N-y)/N^2$:

fluid rows N 8 16 32 64 128
relative L2 error 1.112e-2 2.781e-3 6.954e-4 1.738e-4 4.346e-5
observed order p 2.000 2.000 2.000 2.000

6.2 Solution verification + benchmark: Schäfer–Turek 2D-2

Schafer-Turek

The international laminar benchmark (Schäfer & Turek 1996): cylinder in a channel of height 4.1 D, slightly off-centre, parabolic inflow, Re = 100, 24 % blockage. Three grids, Grid Convergence Index per Celik et al. (2008):

quantity D = 20 D = 30 D = 40 observed p Richardson (h → 0) GCI_fine benchmark
St 0.2987 0.2998 0.3014 2.93 0.3026 0.48 % 0.295–0.305 ✔
C_L,max 1.0764 1.0663 1.0583 0.49 1.0052 6.27 % 0.99–1.01 ✔
C_D,max 3.4510 3.4091 3.3636 1.90 3.3011 2.32 % 3.22–3.24 ✘ (+2 %)
C_D,mean 3.3867 3.3542 3.3132 2.60 3.2764 1.39 % (not tabulated)

6.3 Validation: a cylinder in free stream vs experiment

Open cylinder

Re = 100, D = 20 cells, uniform inflow, periodic sides with absorbing bands. The reference values are Williamson's (1996) measured Strouhal number, St = 0.164, and well-resolved unconfined simulations for the forces: C_D = 1.33–1.35, lift amplitude 0.33–0.34 (Park et al. 1998; Liu et al. 1998).

Domain-size study (move one boundary at a time):

inlet upstream sides apart St C_D C_L amplitude
6 D 20 D 0.1725 1.4894 0.391
12 D 20 D 0.1682 1.4347 0.371
20 D 20 D 0.1674 1.4223 0.367
20 D 10 D 0.1749 1.4902 0.369
20 D 40 D 0.1651 (+0.7 %) 1.3979 (+4 %) 0.362 (+7 %)

Error budget for the largest tunnel, using the other studies in this level:

C_D St
largest tunnel (D = 20, Ma = 0.139) 1.3979 0.1651
÷ grid factor (D = 20 vs Richardson, from §6.2, same D and Re) ÷ 1.0337 ÷ 0.9872
÷ compressibility factor (Ma² fit, §6.4) ÷ 1.0200 ÷ 1.0036
best estimate 1.3258 (refs 1.33–1.35) 0.1667 (Williamson 0.164, +1.6 %)

Applying the Schäfer–Turek grid factor for C_L,max (1.0764/1.0052 = 1.071) to the lift amplitude gives 0.362/1.071 = 0.338, inside the 0.33–0.34 references. All three quantities close to within ~2 % once each error source has been measured, not guessed.

The honest part of this story. The first validation run (inlet 6 D upstream) gave St +5.2 % and C_D +11 %. We suspected blockage and ran sides 10 D and 20 D apart. A straight-line extrapolation of those two points to zero blockage gave St = 0.1650, +0.6 % from Williamson: the right answer for the wrong reason. A third point (sides 40 D apart) barely moved (0.1718), so the extrapolation was fiction. Moving the inlet was what mattered: a velocity inlet 6 D upstream sits in the cylinder's own displacement field and forces the oncoming flow. Two-point extrapolations cannot detect curvature. Always refine until the answer stops moving.

6.4 Where the method runs out: stability and compressibility

Limits

The Reynolds number a laptop LBM can reach, honestly. With N cells across the body, $\mathrm{Re} = UN/\nu = 3UN/(\tau-\tfrac12)$. Accuracy caps U at ≈ 0.1 (Ma ≈ 0.17, a few % error), and BGK stability caps τ − ½ at ≈ 0.004–0.008 on this kind of geometry. Together that gives Re ≲ 40–80 × N: about 1000 for our 20-cell cylinder, and a few thousand for a 64–128-cell airfoil. Going further costs: at fixed Ma and τ, N ∝ Re and the steps ∝ N/U, so 2D work ∝ Re³ (3D ∝ Re⁴). A Re = 10⁶ airfoil resolving only a laminar boundary layer (δ ≈ 5c/√Re) needs c ≈ 2000 cells, ~2×10⁸ nodes (14 GB) and ~6×10¹³ updates: days on this machine. And real flow at that Re is 3D and turbulent. This is why PowerFLOW uses VLES turbulence modelling, wall functions and octree refinement. The eddy viscosity also raises the effective τ, which keeps BGK-type schemes stable at Re = 10⁷.

6.5 Showcase: NACA 0012 at Re = 1000

Polar

Gallery

Chord 64 cells, tunnel 10c × 5c, spin-up 10 c/U, then averaging over 6 c/U (bars: s.d. of the unsteady force):

α (deg) C_L ±s.d. C_D L/D St_c = f c/U St_h = f c sin α / U
0 0.000 0 0.127 0 steady
2.5 0.133 0 0.129 1.03 steady
5 0.225 0.001 0.137 1.63 steady
7.5 0.263 0.002 0.147 1.79 steady
10 0.350 0.018 0.177 1.98 weak oscillation
12.5 0.465 0.071 0.228 2.04 0.781 0.169
15 0.633 0.162 0.311 2.04 0.708 0.183
17.5 0.820 0.198 0.411 1.99 0.635 0.191
20 0.947 0.218 0.498 1.90 0.562 0.192

6.6 Engineering notes: what made the kernel fast (measured, 3 threads, shared CPU)

kernel variant throughput
loop over the 9 directions, wall test inside the loop ~40 MLUPS (float64)
walls pre-filled by bounce() (branch-free), still a loop over i with a scratch array 86–131 MLUPS
hand-unrolled 9 directions 217–443 (float64), 459–889 (float32)
+ an if store: flag to also write ρ, u inside the loop 687 → 137 (vectorisation lost)
+ Guo force terms compiled in (even with g = 0) 687 → 345 → fixed with a closure constant
+ sponge fused into the collision 596 → 102 → kept as a separate pass
grid 1024 wide vs 1000 wide 218 vs 406 (4 kB rows: all 27 input streams hit the same cache sets)

The production loop (bounce + kernel + inlet/outlet + sponge) ran at 200–530 MLUPS depending on machine load (130–140 on the smallest, overhead-bound 36k-node grid). The lesson: LLVM vectorises simple loops. Any branch or extra live value in the hot loop can cost 2–6×.

6.7 V&V summary (actual printed values)

check expected got
Poiseuille observed order, N = 8 → 128 2 2.000, 2.000, 2.000, 2.000 PASS (asserted)
Poiseuille error at N = 16 < 1 % 0.278 % PASS (asserted)
bounce-back wall error vanishes at τ = ½ + √(3/16) = 0.9330 0.9330 PASS (asserted)
Schäfer–Turek St, D = 40 0.295–0.305 0.3014 PASS (asserted)
Schäfer–Turek C_L,max, Richardson 0.99–1.01 1.0052 PASS
Schäfer–Turek C_D,max, Richardson 3.22–3.24 3.3011 (3.20 after the Ma correction) miss +2 %, explained
open cylinder St, largest tunnel Williamson 0.164 ± 3 % 0.1651 (+0.7 %) PASS (asserted)
open cylinder C_D, largest tunnel 1.33–1.35 1.3979 (1.326 after the grid + Ma budget) miss +4 % raw, closes after the budget
open cylinder lift amplitude 0.33–0.34 0.362 (0.338 after the grid factor) miss +7 % raw, closes after the budget
airfoil domain convergence (1.5× tunnel) small −1.8 … −2.7 % reported
airfoil grid convergence (chord 64 → 128, α = 20°) small +4.0 % C_L, −2.3 % C_D reported
BGK stability, D = 20, U = 0.1 – stable τ − ½ ≥ 0.0075, blows up ≤ 0.00375 reported

7. Run it

# from SimulationLab/
uv run python L06_virtual_wind_tunnel/sim.py            # full run
uv run python L06_virtual_wind_tunnel/sim.py --quick    # smoke test: fewer grids, angles and frames

Runtime. The final full run took 5.7 min (341 s) on the 8-core dev machine (3 Numba threads, CPU shared with other simulations). The main costs are the domain study (~50 s), the airfoil sweep + domain checks (~2 min) and the movie (~2 min, half of it rendering). --quick takes about 1.3 min. The solver is deterministic (no random numbers, no parallel reductions), so the numbers reproduce exactly; only MLUPS vary with machine load.

Outputs go to output/: hero.png, wind_tunnel.mp4, verify_poiseuille.png, validate_schafer_turek.png, validate_cylinder.png, limits.png, airfoil_polar.png, airfoil_gallery.png. The script raises AssertionError if a verification check fails.

8. Code tour (sim.py)

Function What it does
_make_stream_collide(forced) → stream_collide, stream_collide_forced the solver: fused pull-stream + BGK collision, hand-unrolled over the 9 directions, prange over rows. forced is a closure constant, so the Guo body-force terms are compiled out of the wind-tunnel version
bounce(f, links, body) half-way bounce-back written into the solid nodes the pull reads from (so the main loop has no wall branches), plus the momentum-exchange force
find_links(solid, body) every (direction, fluid node, solid node) link, found once with np.roll
edges, feq non-equilibrium-extrapolation velocity inlet and pressure outlet
sponge, sponge_profile absorbing layer at the outlet and open sides
_curl (@stencil), vorticity the vorticity as a numba.stencil relative-index kernel
simulate(...) the time loop: bounce → stream/collide → edges → sponge, with force history and an on_frame callback for the movie
moments, equilibrium populations ↔ (ρ, u)
spectrum, zero_cross_period Hann-windowed zero-padded scipy.fft spectrum; independent period estimate
disk, naca00xx, polygon_mask, tunnel_start geometry (NACA 4-digit formula, rotated about the quarter chord) and the kicked impulsive start
poiseuille, verify_poiseuille code verification: steady channel, observed order, wall error vs τ
cylinder_run, schafer_turek, open_cylinder the cylinder experiments and their force/Strouhal post-processing
gci Celik et al. (2008) three-grid procedure with unequal refinement ratios
validate_cylinders the grid study, benchmark comparison, validation run and their two figures
limits BGK blow-up probe and the Mach-number sensitivity study
airfoil_tunnel, airfoil_sweep, polar_figures the NACA 0012 sweep, polar and gallery
showcase_movie high-resolution stalled-airfoil run rendered frame by frame into the MP4, then hero.png
verify the assertions; main runs everything in order and prints timings

9. Simplifications & ceilings

10. Go further

  1. Rebuild it in OpenFOAM. Mesh the Schäfer–Turek channel with blockMesh (an O-grid around the cylinder), run pimpleFoam at Re = 100 with the forceCoeffs function object, and put your C_D,max, C_L,max and St in the same GCI table. Then compare wall-clock time and engineer time (meshing!) with this LBM.
  2. Interpolated bounce-back (Bouzidi et al. 2001). Use the exact wall distance q along each link instead of assuming ½. This should turn the first-order cylinder convergence of section 6 into second order and pull C_D onto the benchmark at D = 20. It also removes most of the leading-edge checkerboard.
  3. TRT or MRT collision. Two-relaxation-time with the magic parameter Λ = 3/16 makes the Poiseuille wall error vanish for every τ (not just τ = 0.933), and MRT/regularised/cumulant operators push the stability floor well below our BGK limit (τ − ½ ≈ 0.004–0.008, §6.4). How high can you take the airfoil's Re before it blows up?
  4. Compare with XFOIL. Run xfoil on NACA 0012 at Re = 10⁴, 10⁵, 10⁶ and plot its polars on top of ours at Re = 1000. Watch the lift slope climb towards 2π and the drag fall by an order of magnitude. Then push the LBM to the highest Re it survives and see where the two start to meet.
  5. Go 3D on a GPU. Port stream_collide to D3Q19 with CuPy or Numba-CUDA (the same stencil, one thread per node) and simulate a sphere at Re = 300 (where its wake becomes unsteady), or a 3D wing tip vortex. That is the first step towards what PowerFLOW does on 10⁹ voxels.

11. References

Every output

Source code

sim.py 878 lines
"""L06 Virtual Wind Tunnel: a D2Q9 lattice Boltzmann (BGK) solver in Numba, run through a V&V workflow.

  code verification      Poiseuille channel vs the exact parabola, observed order of accuracy
  solution verification  grid-convergence index (GCI) on the Schafer-Turek 2D-2 cylinder benchmark
  validation             unconfined cylinder at Re = 100: Strouhal number and drag vs Williamson 1996
  showcase               NACA 0012 angle-of-attack sweep (lift/drag polar) + a vorticity movie

Run from the repo root:   uv run python L06_virtual_wind_tunnel/sim.py          (full run, ~6 min)
                          uv run python L06_virtual_wind_tunnel/sim.py --quick  (smoke test, ~1.5 min)
Everything is in lattice units: dx = dt = 1, sound speed c_s = 1/sqrt(3), viscosity nu = (tau - 1/2) / 3.
"""
import os

os.environ.setdefault("NUMBA_NUM_THREADS", "3")  # shared machine: cap threads BEFORE numba loads
import sys
import time
from pathlib import Path

import imageio_ffmpeg
import matplotlib

matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
from matplotlib.animation import FFMpegWriter
from matplotlib.colors import AsinhNorm, LinearSegmentedColormap
from matplotlib.patches import Polygon
from matplotlib.path import Path as MplPath
from numba import njit, prange, stencil
from scipy import fft

OUT = Path(__file__).parent / "output"
QUICK = "--quick" in sys.argv
plt.rcParams["animation.ffmpeg_path"] = imageio_ffmpeg.get_ffmpeg_exe()

# D2Q9 lattice: rest, 4 axis neighbours, 4 diagonals. OPP[i] is the direction opposite to i.
CX = np.array([0, 1, 0, -1, 0, 1, -1, -1, 1])
CY = np.array([0, 0, 1, 0, -1, 1, 1, -1, -1])
W = np.array([4 / 9, 1 / 9, 1 / 9, 1 / 9, 1 / 9, 1 / 36, 1 / 36, 1 / 36, 1 / 36])
OPP = np.array([0, 3, 4, 1, 2, 7, 8, 5, 6])

BG = "#0b0e14"  # figure background
VORT = LinearSegmentedColormap.from_list(  # diverging, with a dark centre so still air is the background
    "vort", ["#e8f7ff", "#56cfe1", "#2463a6", "#12213d", BG, "#46142b", "#b8233f", "#f3722c", "#ffe8a3"])


# ----------------------------------------------------------------------------- the solver
def _make_stream_collide(forced):
    @njit(parallel=True, fastmath=True)
    def stream_collide(f, fn, solid, omega, gx):
        """One fused pull-stream + BGK-collide step on every interior fluid node (+ Guo body force gx if forced).

        Pull scheme: node (y, x) gathers population i from its upstream neighbour (y - cy_i, x - cx_i), relaxes
        the nine towards the local equilibrium and writes them to fn. A 3x3 stencil with no write conflicts, so
        rows are split over threads with prange. Measured speed notes: the nine directions written out by hand
        instead of a loop over i with a scratch array: 5x faster (values stay in registers). No branches inside
        the x loop beyond the solid test (an optional "also store rho, u" flag cost another 5x, because LLVM
        then gives up on AVX vectorisation). Solid neighbours hold bounce-back values put there by bounce().
        Fusing the sponge in here (one more multiply-add per population) was 5x slower: same vectoriser cliff.
        So was a 1024-wide grid: 4 kB rows put all 27 input streams in the same cache sets. Avoid powers of 2.
        """
        _, ny, nx = f.shape
        # ponytail: BGK single relaxation time, unstable as tau -> 1/2 (see limits()); TRT / MRT / regularised
        # collision relax the non-hydrodynamic moments separately and reach much higher Re on the same grid
        a = 1.0 - omega
        k = (1.0 - 0.5 * omega) * gx  # Guo et al. 2002 source prefactor
        for y in prange(ny):
            ym = y - 1 if y > 0 else ny - 1  # y is periodic; walls are rows of solid nodes
            yp = y + 1 if y < ny - 1 else 0
            for x in range(1, nx - 1):  # columns 0 and nx-1 are boundary/ghost columns, set by edges()
                if solid[y, x]:
                    continue
                f0 = f[0, y, x]
                f1 = f[1, y, x - 1]
                f2 = f[2, ym, x]
                f3 = f[3, y, x + 1]
                f4 = f[4, yp, x]
                f5 = f[5, ym, x - 1]
                f6 = f[6, ym, x + 1]
                f7 = f[7, yp, x + 1]
                f8 = f[8, yp, x - 1]
                rho = f0 + f1 + f2 + f3 + f4 + f5 + f6 + f7 + f8
                ux = (f1 - f3 + f5 - f6 - f7 + f8) / rho
                uy = (f2 - f4 + f5 + f6 - f7 - f8) / rho
                if forced:
                    ux += 0.5 * gx / rho
                usq = 1.5 * (ux * ux + uy * uy)
                r0 = omega * rho * (4.0 / 9.0)
                r1 = omega * rho * (1.0 / 9.0)
                r2 = omega * rho * (1.0 / 36.0)
                # f_i <- f_i + omega (feq_i - f_i),  feq_i = w_i rho (1 + 3 c.u + 4.5 (c.u)^2 - 1.5 u^2)
                g0 = a * f0 + r0 * (1.0 - usq)
                c = 3.0 * ux
                g1 = a * f1 + r1 * (1.0 + c + 0.5 * c * c - usq)
                g3 = a * f3 + r1 * (1.0 - c + 0.5 * c * c - usq)
                c = 3.0 * uy
                g2 = a * f2 + r1 * (1.0 + c + 0.5 * c * c - usq)
                g4 = a * f4 + r1 * (1.0 - c + 0.5 * c * c - usq)
                c = 3.0 * (ux + uy)
                g5 = a * f5 + r2 * (1.0 + c + 0.5 * c * c - usq)
                g7 = a * f7 + r2 * (1.0 - c + 0.5 * c * c - usq)
                c = 3.0 * (uy - ux)
                g6 = a * f6 + r2 * (1.0 + c + 0.5 * c * c - usq)
                g8 = a * f8 + r2 * (1.0 - c + 0.5 * c * c - usq)
                if forced:  # + (1 - omega/2) S_i, S_i = w_i [3 (c_i - u) + 9 (c_i.u) c_i] . (gx, 0)
                    g0 -= (4.0 / 3.0) * ux * k
                    g1 += (1.0 / 9.0) * (3.0 + 6.0 * ux) * k
                    g3 += (1.0 / 9.0) * (6.0 * ux - 3.0) * k
                    g2 -= (1.0 / 3.0) * ux * k
                    g4 -= (1.0 / 3.0) * ux * k
                    g5 += (1.0 / 36.0) * (3.0 + 6.0 * ux + 9.0 * uy) * k
                    g7 += (1.0 / 36.0) * (6.0 * ux + 9.0 * uy - 3.0) * k
                    g6 += (1.0 / 36.0) * (6.0 * ux - 9.0 * uy - 3.0) * k
                    g8 += (1.0 / 36.0) * (3.0 + 6.0 * ux - 9.0 * uy) * k
                fn[0, y, x] = g0
                fn[1, y, x] = g1
                fn[2, y, x] = g2
                fn[3, y, x] = g3
                fn[4, y, x] = g4
                fn[5, y, x] = g5
                fn[6, y, x] = g6
                fn[7, y, x] = g7
                fn[8, y, x] = g8

    return stream_collide


# "forced" is a closure constant, so Numba compiles the force terms out of the tunnel version entirely
stream_collide = _make_stream_collide(False)  # wind-tunnel runs (float32: ~4x the throughput of float64)
stream_collide_forced = _make_stream_collide(True)  # body-force-driven channel (verification, float64)


@njit
def bounce(f, links, body):
    """Half-way bounce-back + momentum-exchange force (Ladd 1994), before each stream_collide.

    For every fluid->solid link, the population that left fluid node x towards the wall comes straight back:
    f_i(x, t+1) = f_opp(i)(x, t). Writing it into the solid node the pull reads from makes the main loop
    branch-free. Each link turns the momentum c_opp f around, handing 2 c_opp f to the body.
    """
    fx = 0.0
    fy = 0.0
    # ponytail: staircase mask + wall assumed half-way along every link = 1st order on curved walls;
    # Bouzidi et al. (2001) interpolated bounce-back uses the true wall distance for 2nd order
    for k in range(links.shape[0]):
        i, y, x, sy, sx = links[k, 0], links[k, 1], links[k, 2], links[k, 3], links[k, 4]
        v = f[OPP[i], y, x]
        f[i, sy, sx] = v
        if body[k]:
            fx -= 2.0 * CX[i] * v
            fy -= 2.0 * CY[i] * v
    return fx, fy


@njit
def feq(i, rho, ux, uy):
    cu = 3.0 * (CX[i] * ux + CY[i] * uy)
    return W[i] * rho * (1.0 + cu + 0.5 * cu * cu - 1.5 * (ux * ux + uy * uy))


@njit
def edges(f, inflow, solid):
    """Velocity inlet at x = 0 and pressure outlet (rho = 1) at x = nx-1, both by non-equilibrium extrapolation
    (Guo, Zheng & Shi 2002): the edge node gets the equilibrium of the prescribed state, taking the unknown
    quantity (inlet density, outlet velocity) and the non-equilibrium part f - feq from its inner neighbour.
    A first version pinned rho = 1 at the inlet and copied the outlet: nothing anchored the pressure, and the
    tunnel slowly pressurised while the flow rate sagged by 16 %."""
    _, ny, nx = f.shape
    for y in range(ny):
        if solid[y, 0]:
            continue
        for xb, xn in ((0, 1), (nx - 1, nx - 2)):
            rho = 0.0
            jx = 0.0
            jy = 0.0
            for i in range(9):
                rho += f[i, y, xn]
                jx += CX[i] * f[i, y, xn]
                jy += CY[i] * f[i, y, xn]
            ux, uy = jx / rho, jy / rho
            rb, uxb, uyb = (rho, inflow[y], 0.0) if xb == 0 else (1.0, ux, uy)
            for i in range(9):
                f[i, y, xb] = feq(i, rb, uxb, uyb) + f[i, y, xn] - feq(i, rho, ux, uy)


@njit(parallel=True, fastmath=True)
def sponge(f, sigma, x_start, target):
    """Absorbing layer: from column x_start[y] on, relax towards target[:, y] by a fraction sigma[y, x]."""
    _, ny, nx = f.shape
    for y in prange(ny):
        for i in range(9):
            t = target[i, y]
            for x in range(x_start[y], nx - 1):
                f[i, y, x] += sigma[y, x] * (t - f[i, y, x])


def sponge_profile(solid):
    """Absorbing layer strength: relax towards the undisturbed inflow state by a fraction sigma per step,
    0 -> 0.1 (quadratic) over the last 10 % of the length, and over 10 % of the height at the top and bottom
    unless those are walls. An absorbing far field around the test section.

    Why: LBM is weakly compressible, so a closed box is also an organ pipe. The impulsive start and the
    shedding itself launch sound waves that BGK at low viscosity barely damps; without this layer they bounce
    between the inlet, the outlet and the periodic sides and ride on top of the force signal."""
    # ponytail: plain relaxation sponge, still reflects a little and costs 10-20 % of the domain;
    # characteristic (non-reflecting) boundary conditions if the acoustics themselves matter
    ny, nx = solid.shape
    ramp = lambda n, m: 0.1 * np.clip(1 - np.arange(n) / m, 0, 1) ** 2  # 0.1 at index 0, zero beyond m
    sigma = np.repeat(ramp(nx, nx // 10)[None, ::-1], ny, 0)
    if not solid[0].all():
        side = ramp(ny, ny // 10)
        sigma = np.maximum(sigma, np.maximum(side, side[::-1])[:, None])
    return sigma.astype(np.float32), np.argmax(sigma > 0, axis=1)


@stencil
def _curl(ux, uy):
    return 0.5 * (uy[0, 1] - uy[0, -1] - ux[1, 0] + ux[-1, 0])  # [y, x] indexing: dv/dx - du/dy


@njit(parallel=True)
def vorticity(ux, uy):
    """numba.stencil writes the relative-index kernel once; Numba generates the parallel loop and zero borders."""
    return _curl(ux, uy)


def equilibrium(ux, uy, dtype):
    cu = 3 * (CX[:, None, None] * ux + CY[:, None, None] * uy)
    return (W[:, None, None] * (1 + cu + 0.5 * cu**2 - 1.5 * (ux**2 + uy**2))).astype(dtype)


def find_links(solid, body):
    """All (direction, fluid node, solid node) links where the pull of an interior node reads from a solid."""
    ny, nx = solid.shape
    fluid = ~solid
    fluid[:, [0, -1]] = False  # boundary/ghost columns are set by edges(), not streamed
    out = []
    for i in range(1, 9):
        src_solid = np.roll(solid, (CY[i], CX[i]), axis=(0, 1))  # src_solid[y, x] = solid[y - cy, x - cx]
        y, x = np.nonzero(fluid & src_solid)
        out.append(np.stack([np.full_like(y, i), y, x, (y - CY[i]) % ny, (x - CX[i]) % nx], 1))
    links = np.concatenate(out)
    return links, body[links[:, 3], links[:, 4]]


def moments(f, solid, gx=0.0):
    """Density and velocity from (post-collision) populations; zero velocity inside solids.
    With a Guo force the post-collision momentum is rho u + g/2, hence the -g/2."""
    rho = f.sum(0)
    ux = (np.tensordot(CX.astype(f.dtype), f, 1) - 0.5 * gx) / rho
    uy = np.tensordot(CY.astype(f.dtype), f, 1) / rho
    ux[solid] = 0
    uy[solid] = 0
    return rho, ux, uy


def simulate(solid, tau, steps, f, inflow=None, body=None, gx=0.0, every=0, on_frame=None):
    """Advance f by `steps`; returns (f, force history [steps, 2] on the body, lattice units).

    inflow=None: periodic in x (columns 0 and nx-1 are ghost copies), else the inlet u_x profile per row.
    body: which solids count towards the force (default: all). on_frame(t, f, F) is called every `every` steps.
    """
    # ponytail: one uniform grid everywhere; octree refinement (PowerFLOW "VR regions") puts cells where needed
    links, on_body = find_links(solid, solid if body is None else body)
    if inflow is not None:
        sigma, x_start = sponge_profile(solid)
        target = equilibrium(inflow[:, None], np.zeros((len(inflow), 1)), f.dtype)[:, :, 0]
    kernel = stream_collide_forced if gx else stream_collide
    fn = f.copy()
    F = np.empty((steps, 2))
    for t in range(steps):
        F[t] = bounce(f, links, on_body)
        kernel(f, fn, solid, 1.0 / tau, gx)
        if inflow is None:
            fn[:, :, 0], fn[:, :, -1] = fn[:, :, -2], fn[:, :, 1]
        else:
            edges(fn, inflow, solid)
            sponge(fn, sigma, x_start, target)
        f, fn = fn, f
        if every and t % every == 0:
            on_frame(t, f, F)
    return f, F


def spectrum(sig):
    """Hann-windowed, 16x zero-padded power spectrum (scipy.fft). Frequencies in cycles per time step."""
    s = (sig - sig.mean()) * np.hanning(len(sig))
    n = 16 * len(s)  # padding interpolates the spectrum between bins (it does not add resolution)
    return fft.rfftfreq(n), np.abs(fft.rfft(s, n)) ** 2


def zero_cross_period(sig):
    """Mean period from upward zero crossings (linear interpolation), an independent check on the FFT."""
    s = sig - sig.mean()
    i = np.flatnonzero((s[:-1] < 0) & (s[1:] >= 0))
    tc = i + s[i] / (s[i] - s[i + 1])
    return np.diff(tc).mean() if len(tc) > 2 else np.nan


# ----------------------------------------------------------------------------- geometry
def disk(ny, nx, xc, yc, d):
    """Solid mask of a circle. Put the centre between nodes (x.5) so half-way walls sit at exactly r on the axes."""
    yy, xx = np.mgrid[:ny, :nx]
    return (xx - xc) ** 2 + (yy - yc) ** 2 < (d / 2) ** 2


def naca00xx(alpha_deg, c, xp, yp, t=0.12, n=400):
    """Closed polygon of a symmetric NACA 4-digit section, chord c, pitched nose-up by alpha about the
    quarter chord placed at (xp, yp). Cosine spacing clusters points at the leading edge."""
    xs = 0.5 * (1 - np.cos(np.linspace(0, np.pi, n)))
    yt = 5 * t * (0.2969 * np.sqrt(xs) - 0.1260 * xs - 0.3516 * xs**2 + 0.2843 * xs**3 - 0.1036 * xs**4)
    x = np.r_[xs[::-1], xs[1:]] - 0.25
    y = np.r_[yt[::-1], -yt[1:]]
    a = np.radians(alpha_deg)
    return np.c_[xp + c * (x * np.cos(a) + y * np.sin(a)), yp + c * (-x * np.sin(a) + y * np.cos(a))]


def polygon_mask(poly, ny, nx):
    yy, xx = np.mgrid[:ny, :nx]
    return MplPath(poly).contains_points(np.c_[xx.ravel(), yy.ravel()]).reshape(ny, nx)


def tunnel_start(solid, inflow, kick, dtype):
    """Impulsive start: every fluid node at the inflow velocity, plus a cross-flow blob kick = (xc, yc, r, amp)
    that breaks the symmetry, so vortex shedding starts in a few periods instead of growing from round-off."""
    yy, xx = np.mgrid[:solid.shape[0], :solid.shape[1]]
    xc, yc, r, amp = kick
    uy = amp * np.exp(-((xx - xc) ** 2 + (yy - yc) ** 2) / r**2)
    return equilibrium(inflow[:, None] * ~solid, uy * ~solid, dtype)


# ----------------------------------------------------------------------------- 1. code verification
def poiseuille(N, tau, u_max=0.05, tol=1e-12):
    """Body-force driven channel, N fluid rows between half-way bounce-back walls, run to steady state."""
    solid = np.zeros((N + 2, 3), bool)  # one real column + 2 periodic ghosts: exactly unidirectional flow
    solid[[0, -1]] = True
    nu = (tau - 0.5) / 3
    gx = 8 * nu * u_max / N**2
    s = np.arange(N + 2) - 0.5  # distance from the lower wall, which sits half-way between rows 0 and 1
    exact = (gx / (2 * nu) * s * (N - s))[1:-1]
    f = equilibrium(np.zeros(solid.shape), np.zeros(solid.shape), np.float64)
    u_old = np.zeros(N)
    while True:
        f, _ = simulate(solid, tau, 2000, f, gx=gx)
        u = moments(f, solid, gx)[1][1:-1, 1]
        if np.abs(u - u_old).max() < tol * u_max:
            return s[1:-1], u, exact
        u_old = u


def verify_poiseuille():
    tau = 0.8
    Ns = [8, 16, 32, 64] if QUICK else [8, 16, 32, 64, 128]
    errs = []
    for N in Ns:
        s, u, ex = poiseuille(N, tau)
        errs.append(np.sqrt(np.sum((u - ex) ** 2) / np.sum(ex**2)))
    errs = np.array(errs)
    p = np.log2(errs[:-1] / errs[1:])
    print("[verify] Poiseuille, BGK tau = 0.8, relative L2 error of u(y):")
    for N, e in zip(Ns, errs):
        print(f"         N = {N:4d}   error = {e:.3e}")
    print(f"         observed order p = {', '.join(f'{q:.3f}' for q in p)}  (expected 2)")

    taus = np.array([0.55, 0.6, 0.7, 0.8, 0.9, 0.95, 1.0, 1.2, 1.5, 2.0])
    slip = []  # signed error at the centreline, N = 16
    for t in taus:
        s, u, ex = poiseuille(16, t)
        slip.append((u[len(u) // 2] - ex[len(ex) // 2]) / ex[len(ex) // 2])
    slip = np.array(slip)
    tau_magic = 0.5 + np.sqrt(3 / 16)
    fit = np.polyfit((taus - 0.5) ** 2, slip, 1)  # the BGK wall error is linear in (tau - 1/2)^2 ...
    tau_zero = 0.5 + np.sqrt(-fit[1] / fit[0])  # ... and vanishes at the "magic" tau
    print(f"[verify] wall error vanishes at tau = {tau_zero:.4f} (fit), theory 1/2 + sqrt(3/16) = {tau_magic:.4f}")

    fig, ax = plt.subplots(1, 3, figsize=(15, 4.4))
    s8, u8, e8 = poiseuille(8, tau)
    ss = np.linspace(0, 8, 200)
    ax[0].plot(ss, 4 * 0.05 * ss * (8 - ss) / 64, color="#adb5bd", lw=2, label="exact parabola")
    ax[0].plot(s8, u8, "o", color="#f3722c", ms=8, label="LBM, N = 8 fluid rows")
    ax[0].axvline(0, color="#56cfe1", lw=3)
    ax[0].axvline(8, color="#56cfe1", lw=3)
    ax[0].set(xlabel="y (lattice units, walls at 0 and N)", ylabel="u_x (lattice units)",
              title="Poiseuille profile: 8 cells already match")
    ax[0].legend(loc="lower center")
    ax[1].loglog(Ns, errs, "o-", color="#f3722c", lw=2, ms=8, label="BGK, tau = 0.8")
    ax[1].loglog(Ns, errs[0] * (Ns[0] / np.array(Ns)) ** 2, "--", color="#adb5bd", label="slope -2")
    for N, e, q in zip(Ns[1:], errs[1:], p):
        ax[1].annotate(f"p = {q:.2f}", (N, e), textcoords="offset points", xytext=(6, 6), fontsize=9)
    ax[1].set_xticks(Ns, [str(n) for n in Ns], minor=False)
    ax[1].minorticks_off()
    ax[1].set(xlabel="fluid rows N", ylabel="relative L2 error", title="Grid convergence: observed order p",
              xlim=(Ns[0] / 1.3, Ns[-1] * 1.5))
    ax[1].legend()
    ax[2].axhline(0, color="#adb5bd", lw=0.8)
    ax[2].plot(taus, slip * 100, "o-", color="#f3722c", lw=2, ms=7, label="LBM, N = 16")
    tt = np.linspace(0.52, 2.0, 200)
    ax[2].plot(tt, np.polyval(fit, (tt - 0.5) ** 2) * 100, ":", color="#adb5bd",
               label="fit  a (tau - 1/2)^2 + b")
    ax[2].axvline(tau_magic, color="#56cfe1", ls="--", label="theory zero, tau = 1/2 + sqrt(3/16)")
    ax[2].set(xlabel="relaxation time tau", ylabel="centre-line velocity error (%)",
              title="Bounce-back wall error depends on viscosity")
    ax[2].legend(fontsize=8)
    for a in ax:
        a.grid(alpha=0.2)
    fig.tight_layout()
    fig.savefig(OUT / "verify_poiseuille.png", dpi=130)
    plt.close(fig)
    return dict(Ns=Ns, errs=errs, p=p, tau_zero=tau_zero, tau_magic=tau_magic)


# ----------------------------------------------------------------------------- 2. cylinder runs
def cylinder_run(D, U, Re, ny, nx, xc, yc, inflow, walls, n_conv, n_meas, dtype=np.float32):
    """Cylinder of diameter D (cells) at Re = U D / nu. Runs n_conv + n_meas convective times D/U and
    returns force coefficients, sampled every step, over the last n_meas of them."""
    body = disk(ny, nx, xc, yc, D)
    solid = body.copy()
    if walls:
        solid[[0, -1]] = True
    nu = U * D / Re
    tau = 3 * nu + 0.5
    steps = int((n_conv + n_meas) * D / U)
    f = tunnel_start(solid, inflow, (xc + D, yc, D, 0.5 * U), dtype)
    t0 = time.perf_counter()
    f, F = simulate(solid, tau, steps, f, inflow=inflow, body=body)
    wall = time.perf_counter() - t0
    m = int(n_meas * D / U)
    cd, cl = (2 * F[-m:] / (U**2 * D)).T  # rho0 = 1
    fr, P = spectrum(cl)
    j = np.argmax(P)
    j += 0.5 * (P[j - 1] - P[j + 1]) / (P[j - 1] - 2 * P[j] + P[j + 1])  # parabolic peak interpolation
    st = j * (fr[1] - fr[0]) * D / U
    return dict(D=D, U=U, tau=tau, cd=cd, cl=cl, fr=fr * D / U, P=P, St=st, St_zc=D / U / zero_cross_period(cl),
                cd_hist=2 * F[:, 0] / (U**2 * D), cl_hist=2 * F[:, 1] / (U**2 * D), f=f, solid=solid,
                mlups=solid.size * steps / wall / 1e6, blowup=not np.isfinite(F).all())


def schafer_turek(D, n_conv=35, n_meas=15):
    """Schafer & Turek (1996) test 2D-2: channel H = 4.1 D, cylinder centre 2D from the inlet and 2D above the
    lower wall (slightly off-centre), parabolic inflow, Re = U_mean D / nu = 100, blockage D/H = 24 %."""
    H = round(4.1 * D)
    ny, nx = H + 2, 22 * D + 1
    s = np.arange(ny) - 0.5
    Um = 0.1  # peak inflow velocity, Ma = 0.17
    inflow = np.clip(4 * Um * s * (H - s) / H**2, 0, None)
    r = cylinder_run(D, 2 * Um / 3, 100, ny, nx, 2 * D + 0.5, 2 * D + 0.5, inflow, True, n_conv, n_meas)
    r.update(cd_max=r["cd"].max(), cl_max=r["cl"].max(), cd_mean=r["cd"].mean())
    return r


def open_cylinder(up=20, H=20, D=20, U=0.08, n_conv=15, n_meas=25):
    """Unconfined cylinder at Re = 100 for validation against experiment: uniform inflow `up` diameters
    upstream, 20 D of wake behind it, periodic sides H diameters apart (an infinite column of cylinders,
    blockage D/H) with absorbing bands along them."""
    ny, nx = H * D, (up + 20) * D
    r = cylinder_run(D, U, 100, ny, nx, up * D + 0.5, ny / 2 + 0.5, np.full(ny, U), False, n_conv, n_meas)
    r.update(xc=up * D, yc=ny // 2, cl_amp=0.5 * (r["cl"].max() - r["cl"].min()))
    return r


# ----------------------------------------------------------------------------- 3. showcase: NACA 0012
def airfoil_tunnel(alpha, c, Re, U, ny, nx, steps, every=0, on_frame=None, f0=None, xp=2.0):
    """NACA 0012 at angle of attack alpha, chord c cells, quarter chord xp chords behind the inlet (continue
    from f0 if given). Returns polygon, solid mask, final f and C_L, C_D histories (lift +y, drag +x)."""
    poly = naca00xx(alpha, c, xp * c, ny / 2 - 0.5)
    solid = polygon_mask(poly, ny, nx)
    tau = 3 * U * c / Re + 0.5
    inflow = np.full(ny, U)
    f = tunnel_start(solid, inflow, (0, 0, 1, 0.0), np.float32) if f0 is None else f0  # no kick: alpha breaks symmetry
    t0 = time.perf_counter()
    f, F = simulate(solid, tau, steps, f, inflow=inflow, every=every, on_frame=on_frame)
    coef = 2 * F / (U**2 * c)
    return dict(poly=poly, solid=solid, tau=tau, cd=coef[:, 0], cl=coef[:, 1], f=f,
                mlups=solid.size * steps / (time.perf_counter() - t0) / 1e6, blowup=not np.isfinite(F).all())


def sweep_alphas():
    return np.array([0, 10, 20.0]) if QUICK else np.arange(0, 20.1, 2.5)


def airfoil_sweep(c=64, Re=1000, U=0.08, n_conv=10, n_meas=6):
    """Angle-of-attack sweep at Re = 1000 (insect / micro-air-vehicle regime, the laptop-LBM ceiling).
    Tunnel 10c x 5c, quarter chord 3.5c behind the inlet. Averages over the last n_meas convective times c/U."""
    ny, nx = 5 * c, 10 * c
    steps, m = int((n_conv + n_meas) * c / U), int(n_meas * c / U)
    rows, snaps = [], []
    for a in sweep_alphas():
        r = airfoil_tunnel(a, c, Re, U, ny, nx, steps, xp=3.5)
        cl, cd = r["cl"][-m:], r["cd"][-m:]
        st = np.nan
        if cl.std() > 0.02:  # unsteady: shedding frequency from the lift spectrum
            fr, P = spectrum(cl)
            st = fr[np.argmax(P)] * c / U
        rows.append((a, cl.mean(), cl.std(), cd.mean(), cd.std(), st, r["tau"], r["mlups"], r["blowup"]))
        snaps.append((a, r["f"], r["solid"], r["poly"]))
        print(f"[airfoil] alpha = {a:4.1f} deg: C_L = {cl.mean():.3f} +- {cl.std():.3f}, C_D = {cd.mean():.3f}, "
              f"L/D = {cl.mean() / cd.mean():5.2f}" + (f", St_c = {st:.3f}" if st == st else ", steady")
              + f"  ({r['mlups']:.0f} MLUPS{', BLOW-UP' if r['blowup'] else ''})")
    rows = np.array(rows, dtype=float)
    # Domain check (the cylinder taught us to distrust a nearby inlet): repeat two angles in a tunnel 1.5x as
    # long and as tall. A first sweep in a 7c x 3.5c tunnel was 6-8 % high in C_L and C_D by this test.
    check = []
    for a in ([10.0] if QUICK else [5.0, 15.0]):
        r = airfoil_tunnel(a, c, Re, U, 3 * ny // 2, 3 * nx // 2, steps, xp=5.25)
        cl, cd = r["cl"][-m:].mean(), r["cd"][-m:].mean()
        cl0, cd0 = rows[rows[:, 0] == a][0, [1, 3]]
        check.append((a, cl, cd))
        print(f"[airfoil] domain check alpha = {a:g}: 10c x 5c tunnel C_L = {cl0:.3f}, C_D = {cd0:.3f};  "
              f"15c x 7.5c tunnel C_L = {cl:.3f} ({100 * (cl / cl0 - 1):+.1f} %), C_D = {cd:.3f} ({100 * (cd / cd0 - 1):+.1f} %)")
    return dict(rows=rows, snaps=snaps, c=c, U=U, Re=Re, check=np.array(check))


# ----------------------------------------------------------------------------- limits of the method
def limits(D=20):
    """Two honest ceilings. (1) BGK stability: cylinder at D = 20 cells, U = 0.1, raise Re (tau -> 1/2) until
    it blows up. (2) Compressibility: Schafer-Turek at D = 20 for four Mach numbers, C_D against Ma^2."""
    probe = []
    U = 0.1
    ny, nx = 8 * D, 16 * D
    for Re in ([400, 3200] if QUICK else [200, 400, 800, 1600, 3200, 6400]):
        r = cylinder_run(D, U, Re, ny, nx, 4 * D + 0.5, ny / 2 + 0.5, np.full(ny, U), False, 25, 5)
        ok = not r["blowup"] and np.abs(r["cd"]).max() < 50
        probe.append((Re, r["tau"], ok))
        print(f"[limits] D = {D}, U = {U}, Re = {Re:5d}: tau = {r['tau']:.5f} -> {'stable' if ok else 'BLOWS UP'}")
    mach = []
    for Um in ([0.1, 0.15] if QUICK else [0.05, 0.075, 0.1, 0.15]):
        H = round(4.1 * D)
        s = np.arange(H + 2) - 0.5
        r = cylinder_run(D, 2 * Um / 3, 100, H + 2, 22 * D + 1, 2 * D + 0.5, 2 * D + 0.5,
                         np.clip(4 * Um * s * (H - s) / H**2, 0, None), True, 35, 15)
        ma = Um / np.sqrt(1 / 3)
        mach.append((ma, r["cd"].mean(), r["St"], r["tau"]))
        print(f"[limits] Schafer-Turek D = {D}: peak Ma = {ma:.3f}, tau = {r['tau']:.4f}: "
              f"C_D mean = {r['cd'].mean():.4f}, St = {r['St']:.4f}")
    mach = np.array(mach)
    fit = np.polyfit(mach[:, 0] ** 2, mach[:, 1], 1)
    fit_st = np.polyfit(mach[:, 0] ** 2, mach[:, 2], 1)
    print(f"[limits] C_D(Ma) ~ {fit[1]:.4f} + {fit[0]:.3f} Ma^2  ->  at Ma = 0.17 the compressibility error is "
          f"{fit[0] * 0.173**2 / fit[1] * 100:+.2f} %")

    fig, ax = plt.subplots(1, 2, figsize=(12, 4.4))
    for Re, tau, ok in probe:
        ax[0].plot(Re, tau - 0.5, "o" if ok else "X", ms=11, color="#56cfe1" if ok else "#f3722c")
    ax[0].set(xscale="log", yscale="log", xlabel="Reynolds number U D / nu", ylabel="tau - 1/2 = 3 nu",
              title=f"BGK stability, cylinder D = {D} cells, U = {U} (o stable, x blew up)")
    ax[0].set_xticks([p[0] for p in probe], [str(p[0]) for p in probe])
    ax[0].minorticks_off()
    mm = np.linspace(0, mach[:, 0].max() * 1.05, 50)
    ax[1].plot(mach[:, 0] ** 2, mach[:, 1], "o", color="#f3722c", ms=9, label="LBM, D = 20")
    ax[1].plot(mm**2, np.polyval(fit, mm**2), "--", color="#adb5bd", label=f"fit {fit[1]:.3f} + {fit[0]:.2f} Ma^2")
    ax[1].set(xlabel="Ma^2 (peak inflow)", ylabel="mean C_D", title="Compressibility error grows like Ma^2")
    ax[1].legend()
    for a in ax:
        a.grid(alpha=0.2, which="both")
    fig.tight_layout()
    fig.savefig(OUT / "limits.png", dpi=130)
    plt.close(fig)
    return dict(probe=probe, mach=mach, fit=fit, fit_st=fit_st)


def error_budget(val, lim, U=0.08):
    """Validation error budget of the largest open-cylinder tunnel: remove the discretisation error (the
    D = 20 vs Richardson ratio of the Schafer-Turek grid study: same D, same Re, a transferred estimate) and
    the compressibility error (the Ma^2 fit at this run's Ma), then compare what is left with the references."""
    if not val["study"]:
        return
    ma2 = (U / np.sqrt(1 / 3)) ** 2
    d20 = val["runs"][0]
    grid_cd = d20["cd_mean"] / val["study"]["cd_mean"][1]
    grid_st = d20["St"] / val["study"]["St"][1]
    mach_cd = np.polyval(lim["fit"], ma2) / lim["fit"][1]
    mach_st = np.polyval(lim["fit_st"], ma2) / lim["fit_st"][1]
    big = val["big"]
    cd = big["cd"].mean() / grid_cd / mach_cd
    st = big["St"] / grid_st / mach_st
    print(f"[budget] C_D: {big['cd'].mean():.4f} (largest tunnel) -> /{grid_cd:.4f} (grid) -> /{mach_cd:.4f} "
          f"(Ma = {np.sqrt(ma2):.3f}) = {cd:.4f}   refs {CD_REF[0]}-{CD_REF[1]}")
    print(f"[budget] St : {big['St']:.4f} (largest tunnel) -> /{grid_st:.4f} (grid) -> /{mach_st:.4f} (Ma) = {st:.4f}"
          f"   Williamson {WILLIAMSON_ST} ({100 * (st / WILLIAMSON_ST - 1):+.1f} %)")


# ----------------------------------------------------------------------------- figures and movie
def vort_field(f, solid, U, L):
    """Non-dimensional vorticity omega L / U, via the numba.stencil curl."""
    _, ux, uy = moments(f, solid)
    return vorticity(ux, uy) * (L / U)


def draw_field(ax, w, patch, lim=12.0, lw=0.03):
    """Vorticity on a dark background, asinh-scaled so the weak far wake and the strong boundary layer both
    show; the body is a crisp vector patch drawn over the staircase mask."""
    ny, nx = w.shape
    im = ax.imshow(w, origin="lower", cmap=VORT, norm=AsinhNorm(lw * lim, -lim, lim),
                   extent=(-0.5, nx - 0.5, -0.5, ny - 0.5), interpolation="bilinear")
    ax.add_patch(patch)
    ax.set_axis_off()
    return im


def body_patch(poly=None, circle=None):
    kw = dict(facecolor="#d8dee9", edgecolor="white", lw=0.8, zorder=3, antialiased=True)
    return Polygon(poly, closed=True, **kw) if poly is not None else plt.Circle(circle[:2], circle[2], **kw)


def gci(h, phi):
    """Grid Convergence Index, Celik et al. (2008, J. Fluids Eng. 130, 078001). Three grids, finest first.
    Returns observed order p, Richardson-extrapolated value, GCI_fine (fraction), convergence type."""
    (h1, h2, h3), (f1, f2, f3) = h, phi
    r21, r32 = h2 / h1, h3 / h2
    e21, e32 = f2 - f1, f3 - f2
    s = np.sign(e32 / e21)  # +1 monotone, -1 oscillatory convergence
    p = 1.0
    for _ in range(200):  # fixed-point iteration, needed because r21 != r32
        p = abs(np.log(abs(e32 / e21)) + np.log((r21**p - s) / (r32**p - s))) / np.log(r21)
    ext = (r21**p * f1 - f2) / (r21**p - 1)
    return p, ext, 1.25 * abs(e21 / f1) / (r21**p - 1), "monotone" if s > 0 else "oscillatory"


ST_REF = {"cd_max": (3.22, 3.24), "cl_max": (0.99, 1.01), "St": (0.295, 0.305)}  # Schafer & Turek 1996, 2D-2
WILLIAMSON_ST = 0.164  # Williamson 1996 (parallel shedding, Re = 100, experiment)
CD_REF = (1.33, 1.35)  # unconfined numerics: Park, Kwon & Choi 1998 (1.33); Liu, Zheng & Sung 1998 (1.35)
CL_AMP_REF = (0.33, 0.34)  # same two papers: lift amplitude 0.332 / 0.339


def validate_cylinders():
    Ds = [20, 30] if QUICK else [20, 30, 40]
    runs = []
    for D in Ds:
        r = schafer_turek(D)
        runs.append(r)
        print(f"[ST 2D-2] D = {D:2d} (tau = {r['tau']:.3f}, {r['mlups']:.0f} MLUPS): C_D,max = {r['cd_max']:.4f}, "
              f"C_L,max = {r['cl_max']:.4f}, St = {r['St']:.4f} (zero-crossing {r['St_zc']:.4f}), "
              f"C_D,mean = {r['cd_mean']:.4f}")
    study = {}
    if len(Ds) == 3:
        h = [1 / D for D in Ds[::-1]]
        for q in ("cd_max", "cl_max", "St", "cd_mean"):
            p, ext, g, kind = gci(h, [r[q] for r in runs[::-1]])
            study[q] = (p, ext, g, kind)
            ref = ST_REF.get(q)
            print(f"[GCI] {q:7s}: p = {p:.2f} ({kind}), Richardson extrapolation {ext:.4f}, GCI_fine = {g * 100:.2f} %"
                  + (f"   benchmark {ref[0]}-{ref[1]}" if ref else ""))
    # Domain-size study: a free-stream experiment has no inlet and no walls; the tunnel has both.
    # Move one boundary at a time until the answer stops changing. (A first version moved only the side
    # walls and blamed them for a +5 % Strouhal error; the inlet, 6 D upstream, turned out to be the culprit.)
    dom = {}
    for up, H in ([(6, 20), (20, 20)] if QUICK else [(6, 20), (12, 20), (20, 20), (20, 10), (20, 40)]):
        r = open_cylinder(up, H)
        dom[up, H] = r
        print(f"[validate] open cylinder Re = 100, D = 20, inlet {up:2d} D upstream, sides {H:2d} D apart "
              f"({r['mlups']:.0f} MLUPS): St = {r['St']:.4f}, C_D = {r['cd'].mean():.4f}, C_L amplitude = {r['cl_amp']:.3f}")
    oc, big = dom[20, 20], dom[max(dom, key=lambda k: k[0] * 100 + k[1])]
    print(f"[validate] largest tunnel vs literature: St = {big['St']:.4f} (Williamson 1996: {WILLIAMSON_ST}, "
          f"{100 * (big['St'] / WILLIAMSON_ST - 1):+.1f} %), C_D = {big['cd'].mean():.4f} (refs {CD_REF[0]}-{CD_REF[1]}), "
          f"C_L amplitude = {big['cl_amp']:.3f} (refs {CL_AMP_REF[0]}-{CL_AMP_REF[1]})")

    # --- figure: Schafer-Turek flow + grid convergence
    fine = runs[-1]
    D = fine["D"]
    fig = plt.figure(figsize=(15, 7.6), facecolor=BG)
    ax = fig.add_axes((0.02, 0.56, 0.96, 0.4))
    w = vort_field(fine["f"], fine["solid"], fine["U"], D)
    ny, nx = w.shape
    draw_field(ax, w, body_patch(circle=(2 * D + 0.5, 2 * D + 0.5, D / 2)))
    for yw in (0, ny - 1):
        ax.add_patch(plt.Rectangle((-0.5, yw - 0.5), nx, 1, color="#d8dee9", zorder=3))
    ax.set_title(f"Schafer-Turek 2D-2: Re = 100 in a channel, blockage 24 %, D = {D} cells "
                 f"(the sponge zone is the last 10 %)", color="w")
    labels = {"cd_max": "C_D,max", "cl_max": "C_L,max", "St": "Strouhal St"}
    for k, q in enumerate(labels):
        a = fig.add_axes((0.06 + 0.32 * k, 0.08, 0.26, 0.38))
        hh = np.array([1 / r["D"] for r in runs])
        a.axhspan(*ST_REF[q], color="#56cfe1", alpha=0.25, label="benchmark range")
        a.plot(hh, [r[q] for r in runs], "o-", color="#f3722c", ms=8, lw=2, label="LBM, D = 20, 30, 40")
        if q in study:
            p, ext, g, kind = study[q]
            a.plot([0, hh[-1]], [ext, runs[-1][q]], ":", color="#f3722c")
            a.plot(0, ext, "*", ms=14, color="#ffe8a3", label=f"Richardson, p = {p:.2f}")
        a.set(xlabel="grid spacing h = 1/D", ylabel=labels[q], xlim=(-0.002, 0.055))
        a.grid(alpha=0.2)
        a.legend(fontsize=8)
    fig.savefig(OUT / "validate_schafer_turek.png", dpi=120, facecolor=BG)
    plt.close(fig)

    # --- figure: open cylinder, wake + forces + spectrum
    fig = plt.figure(figsize=(15, 8.4), facecolor=BG)
    ax = fig.add_axes((0.01, 0.47, 0.98, 0.47))
    w = vort_field(oc["f"], oc["solid"], oc["U"], oc["D"])
    D, xc, yc = oc["D"], oc["xc"], oc["yc"]
    draw_field(ax, w[yc - 3 * D: yc + 3 * D, xc - 2 * D: xc + 15 * D],
               body_patch(circle=(2 * D + 0.5, 3 * D + 0.5, D / 2)), lim=6)
    ax.set_title("Karman vortex street behind a cylinder, Re = 100 (crop of the 40D x 20D tunnel)", color="w")
    a1 = fig.add_axes((0.05, 0.07, 0.27, 0.3))
    t = np.arange(len(oc["cl_hist"])) * oc["U"] / D
    a1.plot(t, oc["cd_hist"], color="#f3722c", lw=1.2, label="C_D")
    a1.plot(t, oc["cl_hist"], color="#56cfe1", lw=1.2, label="C_L")
    a1.axvspan(t[-len(oc["cl"])], t[-1], color="w", alpha=0.07, label="averaging window")
    a1.set(xlabel="t U / D", ylabel="force coefficient", ylim=(-1, 2.5))
    a1.legend(ncol=3, fontsize=8, loc="upper right")
    a2 = fig.add_axes((0.38, 0.07, 0.27, 0.3))
    a2.semilogy(oc["fr"], oc["P"] / oc["P"].max(), color="#56cfe1")
    a2.axvline(WILLIAMSON_ST, color="#ffe8a3", ls="--", label=f"Williamson 1996: St = {WILLIAMSON_ST}")
    a2.axvline(oc["St"], color="#f3722c", ls=":", label=f"LBM: St = {oc['St']:.3f}")
    a2.set(xlim=(0, 1), ylim=(1e-6, 2), xlabel="frequency f D / U", ylabel="lift power (norm.)")
    a2.legend(fontsize=8)
    a3 = fig.add_axes((0.71, 0.07, 0.27, 0.3))
    for keys, dist, ls, name in (([k for k in dom if k[1] == 20], lambda k: k[0], "-", "inlet"),
                                 ([k for k in dom if k[0] == 20], lambda k: k[1] / 2, "--", "side")):
        keys = sorted(keys, key=dist)
        x = [dist(k) for k in keys]
        a3.plot(x, [dom[k]["St"] / WILLIAMSON_ST for k in keys], "o" + ls, color="#56cfe1", label=f"St, move {name}")
        a3.plot(x, [dom[k]["cd"].mean() / np.mean(CD_REF) for k in keys], "s" + ls, color="#f3722c",
                label=f"C_D, move {name}")
    a3.axhline(1, color="#adb5bd", lw=0.8)
    a3.set(xscale="log", xlabel="distance from cylinder to that boundary (D)",
           ylabel="LBM / reference (St 0.164, C_D 1.34)", title="Domain-size study: inlet matters most")
    a3.set_xticks([5, 6, 10, 12, 20], ["5", "6", "10", "12", "20"])
    a3.minorticks_off()
    a3.legend(fontsize=7, ncol=2)
    for a in (a1, a2, a3):
        a.grid(alpha=0.2)
    fig.savefig(OUT / "validate_cylinder.png", dpi=120, facecolor=BG)
    plt.close(fig)
    return dict(runs=runs, study=study, oc=oc, big=big, dom=dom)


def polar_figures(sw):
    rows, c, U = sw["rows"], sw["c"], sw["U"]
    a, cl, cls, cd, cds, st = rows[:, :6].T
    fig, ax = plt.subplots(1, 3, figsize=(16, 4.8))
    aa = np.linspace(0, 12, 20)
    ax[0].plot(aa, 2 * np.pi * np.radians(aa), "--", color="#adb5bd", label="thin-airfoil theory 2 pi alpha")
    ax[0].errorbar(a, cl, yerr=cls, fmt="o-", color="#56cfe1", ms=7, capsize=3, lw=2,
                   label=f"LBM, Re = {sw['Re']} (bars: unsteady s.d.)")
    ck = sw["check"]
    ax[0].plot(ck[:, 0], ck[:, 1], "D", ms=9, mfc="none", mec="w", label="same, 1.5x larger tunnel")
    ax[1].plot(ck[:, 2], ck[:, 1], "D", ms=9, mfc="none", mec="w")
    ax[0].set(xlabel="angle of attack alpha (deg)", ylabel="lift coefficient C_L", title="Lift curve")
    ax[1].errorbar(cd, cl, xerr=cds, yerr=cls, fmt="o-", color="#f3722c", ms=7, capsize=3, lw=2)
    for ai, x, y in zip(a, cd, cl):
        ax[1].annotate(f"{ai:g} deg", (x, y), textcoords="offset points", xytext=(7, -4), fontsize=8, color="#adb5bd")
    ax[1].set(xlabel="drag coefficient C_D", ylabel="lift coefficient C_L", title="Drag polar")
    ax[2].plot(a, cl / cd, "o-", color="#ffe8a3", ms=7, lw=2)
    ax[2].set(xlabel="angle of attack alpha (deg)", ylabel="L / D", title="Glide ratio")
    for x in ax:
        x.grid(alpha=0.2)
    ax[0].legend(fontsize=8)
    fig.suptitle(f"NACA 0012 at Re = {sw['Re']}, chord {c} cells, D2Q9 BGK lattice Boltzmann", color="w")
    fig.tight_layout()
    fig.savefig(OUT / "airfoil_polar.png", dpi=130)
    plt.close(fig)

    n = len(sw["snaps"])
    cols = 3
    fig, axs = plt.subplots((n + cols - 1) // cols, cols, figsize=(15, 2.35 * ((n + cols - 1) // cols)),
                            facecolor=BG, squeeze=False)
    for axx, (al, f, solid, poly) in zip(axs.flat, sw["snaps"]):
        w = vort_field(f, solid, U, c)
        y0, y1, x0, x1 = int(1.5 * c), int(3.5 * c), int(3 * c), int(7.8 * c)
        draw_field(axx, w[y0:y1, x0:x1], body_patch(poly - [x0, y0]))
        axx.text(0.02, 0.86, f"alpha = {al:g} deg", transform=axx.transAxes, color="w", fontsize=10)
    for axx in axs.flat[n:]:
        axx.set_axis_off()
    fig.tight_layout(pad=0.4)
    fig.savefig(OUT / "airfoil_gallery.png", dpi=110, facecolor=BG)
    plt.close(fig)


def showcase_movie(alpha=20.0, c=128, Re=1000, U=0.08, frames=480, every=20):
    """High-resolution run of the stalled airfoil: vorticity MP4 (1280 x 720, 30 fps) + the hero still.
    The 1000-wide tunnel (not 1024: see the stream_collide docstring) is 7.8 chords long, 3.5 high."""
    if QUICK:
        c, frames = 64, 60
    ny, nx = int(3.5 * c), int(7.8125 * c)
    warm = int(10 * c / U)  # spin-up 10 c/U, then film 6 c/U: the same windows as the sweep
    if not QUICK:  # grid check: the same tunnel (in chords) at half the resolution
        h = airfoil_tunnel(alpha, 64, Re, U, int(3.5 * 64), int(7.8125 * 64), int(16 * 64 / U))
        m = int(6 * 64 / U)
        grid64 = (h["cl"][-m:].mean(), h["cd"][-m:].mean())
    r = airfoil_tunnel(alpha, c, Re, U, ny, nx, warm)  # spin-up, then film
    solid, poly = r["solid"], r["poly"]
    fig = plt.figure(figsize=(12.8, 7.2), dpi=100, facecolor=BG)
    ax = fig.add_axes((0, 0.2, 1, 0.8))
    im = draw_field(ax, np.zeros((ny, nx)), body_patch(poly))
    y0, y1 = 0.13 * ny, 0.87 * ny  # the test section: absorbing bands (10 %) and their edge shear layers left out
    x0 = 0.85 * c
    ax.set(xlim=(x0, x0 + (y1 - y0) * 1280 / 576), ylim=(y0, y1))
    ax.text(0.012, 0.93, f"NACA 0012  |  alpha = {alpha:g} deg  |  Re = {Re}", transform=ax.transAxes,
            color="w", fontsize=16)
    ax.text(0.012, 0.87, "vorticity, D2Q9 lattice Boltzmann (BGK) in Numba", transform=ax.transAxes,
            color="#8d99ae", fontsize=11)
    clock = ax.text(0.988, 0.93, "", transform=ax.transAxes, color="w", fontsize=14, ha="right", family="monospace")
    tr = fig.add_axes((0.06, 0.07, 0.9, 0.11), facecolor=BG)
    tt = np.arange(frames * every) / (c / U)
    l1, = tr.plot([], [], color="#56cfe1", lw=1.5, label="C_L")
    l2, = tr.plot([], [], color="#f3722c", lw=1.5, label="C_D")
    tr.set(xlim=(0, tt[-1]), ylim=(0, 2.4))
    tr.set_xlabel("t U / c (since filming started)", color="#8d99ae", fontsize=9, labelpad=1)
    tr.tick_params(colors="#8d99ae", labelsize=8)
    for sp in tr.spines.values():
        sp.set_color("#39414f")
    tr.legend(loc="upper right", ncol=2, fontsize=9, frameon=False, labelcolor="w")
    writer = FFMpegWriter(fps=30, bitrate=6000, extra_args=["-pix_fmt", "yuv420p"])
    t_warm = warm / (c / U)

    def on_frame(t, f, F):
        im.set_data(vort_field(f, solid, U, c))
        coef = 2 * F[: t + 1] / (U**2 * c)
        l1.set_data(tt[: t + 1], coef[:, 1])
        l2.set_data(tt[: t + 1], coef[:, 0])
        clock.set_text(f"t U/c = {t_warm + tt[t]:5.2f}")
        writer.grab_frame(facecolor=BG)

    with writer.saving(fig, str(OUT / "wind_tunnel.mp4"), dpi=100):
        r2 = airfoil_tunnel(alpha, c, Re, U, ny, nx, frames * every, every=every, on_frame=on_frame, f0=r["f"])
    plt.close(fig)
    cl, cd = r2["cl"], r2["cd"]
    print(f"[movie] alpha = {alpha:g}, chord {c}: C_L = {cl.mean():.3f} +- {cl.std():.3f}, C_D = {cd.mean():.3f} "
          f"({r2['mlups']:.0f} MLUPS incl. rendering)")
    if not QUICK:
        print(f"[movie] grid check, 7.8c x 3.5c tunnel, alpha = {alpha:g}: chord 64 -> C_L = {grid64[0]:.3f}, "
              f"C_D = {grid64[1]:.3f};  chord {c} -> C_L = {cl.mean():.3f} ({100 * (cl.mean() / grid64[0] - 1):+.1f} %), "
              f"C_D = {cd.mean():.3f} ({100 * (cd.mean() / grid64[1] - 1):+.1f} %)")

    w = vort_field(r2["f"], solid, U, c)  # hero still: same state, rendered big
    x1 = 0.9 * nx  # outlet sponge starts here
    Wp, Hp = 2400, int(2400 * (y1 - y0) / (x1 - x0))
    fig = plt.figure(figsize=(Wp / 150, Hp / 150), dpi=150, facecolor=BG)
    ax = fig.add_axes((0, 0, 1, 1))
    draw_field(ax, w, body_patch(poly))
    ax.set(xlim=(x0, x1), ylim=(y0, y1))
    ax.text(0.015, 0.93, "Virtual wind tunnel", transform=ax.transAxes, color="w", fontsize=24)
    ax.text(0.015, 0.875, f"NACA 0012 at {alpha:g} deg, Re = {Re}: the stalled wing sheds a vortex street. "
            f"Lattice Boltzmann, {ny * nx / 1e3:.0f}k cells.", transform=ax.transAxes, color="#8d99ae", fontsize=12)
    fig.savefig(OUT / "hero.png", dpi=150, facecolor=BG)
    plt.close(fig)
    return dict(cl=cl.mean(), cl_sd=cl.std(), cd=cd.mean(), c=c)


def verify(ver, val):
    """Assertions: the verification must pass; the validation must land in the right physical ballpark."""
    assert np.all(np.abs(ver["p"] - 2) < 0.1), f"Poiseuille order {ver['p']} is not 2"
    assert ver["errs"][1] < 0.01, "Poiseuille error at N = 16 above 1 %"
    assert abs(ver["tau_zero"] - ver["tau_magic"]) < 0.01, "bounce-back wall error does not vanish at the magic tau"
    st_fine = val["runs"][-1]["St"]
    assert 0.295 <= st_fine <= 0.305, f"Schafer-Turek St {st_fine} outside the benchmark range"
    big = val["big"]
    assert 0.16 <= big["St"] <= 0.20, f"open-cylinder St {big['St']} outside 0.16-0.20"
    assert abs(big["St"] / WILLIAMSON_ST - 1) < 0.03, f"open-cylinder St {big['St']} not within 3 % of Williamson"
    assert 1.2 <= big["cd"].mean() <= 1.6, "open-cylinder C_D out of the plausible range"
    print("[verify] all assertions passed")


def main():
    OUT.mkdir(exist_ok=True)
    plt.style.use("dark_background")
    plt.rcParams.update({"figure.facecolor": BG, "axes.facecolor": BG, "savefig.facecolor": BG})
    T0 = time.perf_counter()
    stamp = lambda what: print(f"[time] {what} done, {time.perf_counter() - T0:.0f} s elapsed")
    ver = verify_poiseuille()
    stamp("code verification (Poiseuille)")
    val = validate_cylinders()
    stamp("cylinder verification + validation")
    verify(ver, val)
    lim = limits()
    error_budget(val, lim)
    stamp("limits (stability, Mach)")
    sw = airfoil_sweep()
    polar_figures(sw)
    stamp("airfoil sweep")
    showcase_movie()
    print(f"[movie] MP4 {(OUT / 'wind_tunnel.mp4').stat().st_size / 1e6:.1f} MB")
    stamp("movie + hero (total runtime)")


if __name__ == "__main__":
    main()