flow 1.3.0
Kokkos cut-cell IBM incompressible Navier-Stokes solver + pnm pore extraction
Loading...
Searching...
No Matches
test_march_to_steady.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""ctest `march_to_steady`: peclet.flow.march_to_steady and its Anderson accelerator
3(doc/steady_acceleration.md rev 2, work orders WO-4, WO-7).
4
5* G0(b) — `march_to_steady(accelerate=False)` IS the study's march (tests/study/
6 study_avg_velocity_spheres.py, the instrument copied verbatim below as `study_march`): the §11
7 case (one sphere, phi = 0.125, periodic unit cube, rho = mu = F = 1, nu dt / h^2 = 6, Stokes,
8 PCG(200, 1e-8), 200 velocity sweeps), N = 16, staggered and collocated 'ghost': identical step
9 count and bit-identical <u_x>.
10* a small G1 — the same case at N = 12, tight inner solves (PCG(400, 1e-12), velocity residual
11 tolerance 1e-12, rtol 1e-10), accelerated and plain both `converged=True` with
12 |K_acc / K_plain - 1| <= 1e-8, on both grids.
13* G7a — staggered N = 16 at mu = 0.0158, dt = 1.234e-2, SOU advection, whose plain march diverges:
14 `converged=False` with reason "diverged" or "max_steps" for accelerate=False and for
15 accelerate=True at windows 3, 5 and 8 (rev 2: no instability guard; the plain steps of the
16 certification expose the growth).
17* the adapter (review R4, R5), N = 12 production, both grids: `set_dt` between calls resets the
18 history (`num_resets` + 1) and that call does not mix (bitwise the plain step of a twin solver,
19 while without `set_dt` a mix was pending); the same for staggered `set_advection(True)` (the
20 advection settings are in the signature, review R5); a refused feature enabled after construction
21 (`set_pressure_warmstart(True)`) is refused at the next call; collocated `set_advection(True)`
22 after construction (4 -> 7 state fields) is reported as a configuration change and resets.
23* four pure-Python tests of the driver's certification (`peclet.flow.steady._certify`) on scripted
24 monitor sequences: (i) four blocks with R > 1, then a geometric tail inside the budget -> "pass";
25 (ii) a clean tail at R = 0.99 that fails the remainder bound -> "slow" at the first such block;
26 (iii) a sequence that never passes -> "budget" after exactly 2 (num_passes + 3) blocks;
27 (iv) two passing blocks at R = 0.9, then R = 1.1 for every later block -> "budget" after exactly
28 2 (num_passes + 3) blocks, never "pass" (R >= 1 resets the pass count — what rev 2 relies on).
29
30Exit 0 pass, 1 fail, 77 skipped (no module).
31"""
32import math
33import sys
34
35import numpy as np
36
37try:
38 import peclet.flow as pf
39 from peclet.flow import steady
40except ImportError:
41 print("SKIP: peclet.flow not importable")
42 sys.exit(77)
43
44PHI = 0.125
45L = 1.0
46FAILED = []
47
48
49def check(ok, what):
50 print(f" [{'ok' if ok else 'FAIL'}] {what}", flush=True)
51 if not ok:
52 FAILED.append(what)
53
54
55# ---- the study's instrument, verbatim (tests/study/study_avg_velocity_spheres.py, march()) ------
56TOL_K, CHECK_EVERY, N_PASS, MAX_STEPS = 1e-4, 5, 3, 1500
57RHO_SLOW = 0.997
58ROUNDOFF = 1e-11
59
60
61def study_march(step, umean):
62 prev = dprev = None
63 passes = 0
64 for it in range(MAX_STEPS):
65 step()
66 if it % CHECK_EVERY != CHECK_EVERY - 1:
67 continue
68 m = umean()
69 if prev is not None:
70 d = m - prev
71 ok = abs(d) <= ROUNDOFF * abs(m)
72 if not ok and dprev:
73 R = d / dprev
74 ok = 0.0 < R < 1.0 and \
75 abs(d) / (1.0 - max(R, RHO_SLOW ** CHECK_EVERY)) < TOL_K * abs(m)
76 passes = passes + 1 if ok else 0
77 if passes >= N_PASS:
78 return it + 1, True
79 dprev = d
80 prev = m
81 return MAX_STEPS, False
82
83
84def sphere_solver(Cls, N, *, tight, mu=1.0, dt=None, advection=None):
85 s = Cls((N, N, N), extent=(L, L, L))
86 s.set_rho(1.0)
87 s.set_mu(mu)
88 h = L / N
89 s.set_dt(dt if dt is not None else 6.0 * h * h / mu)
90 s.set_body_force((1.0, 0.0, 0.0))
91 if advection:
92 s.set_advection(True)
93 s.set_advection_scheme(advection)
94 else:
95 s.set_advection(False)
96 s.diagnostics.set_velocity_solver_params(200)
97 s.set_pressure_multigrid(True, levels=max(2, int(np.log2(N)) - 1))
98 if tight:
99 s.set_pressure_pcg(True, max_iter=400, rtol=1e-12)
100 s.set_velocity_residual_tolerance(1e-12)
101 else:
102 s.set_pressure_pcg(True, max_iter=200, rtol=1e-8)
103 cx, cy, cz = s.cell_centers()
104 X, Y, Z = np.meshgrid(cx, cy, cz, indexing="ij")
105 R = (3.0 * PHI / (4.0 * math.pi)) ** (1.0 / 3.0) * L
106 sdf = np.sqrt((X - 0.5 * L) ** 2 + (Y - 0.5 * L) ** 2 + (Z - 0.5 * L) ** 2) - R
107 s.set_solid(np.asfortranarray(sdf), cutcell_pressure=True)
108 return s
109
110
111def umean(s):
112 return float(s.get_u().mean())
113
114
115SCHEMES = (("staggered", pf.Solver), ("collocated", pf.SolverColocated))
116
117
119 print("G0(b): march_to_steady(accelerate=False) == the study's march, N = 16", flush=True)
120 for name, Cls in SCHEMES:
121 a = sphere_solver(Cls, 16, tight=False)
122 steps_a, ok_a = study_march(a.step, lambda a=a: umean(a))
123 u_a = umean(a)
124 b = sphere_solver(Cls, 16, tight=False)
125 res = pf.march_to_steady(b, lambda b=b: umean(b), rtol=TOL_K, max_steps=MAX_STEPS,
126 accelerate=False)
127 check(ok_a and res.converged and res.steps == steps_a and res.monitor == u_a
128 and umean(b) == u_a and res.accelerated_steps == 0,
129 f"{name}: study {steps_a} steps <u_x> {u_a!r}; march_to_steady {res.steps} steps "
130 f"<u_x> {res.monitor!r} ({res.reason})")
131
132
134 print("G1 (small): tight, N = 12, |K_acc / K_plain - 1| <= 1e-8", flush=True)
135 for name, Cls in SCHEMES:
136 out = {}
137 for acc in (False, True):
138 s = sphere_solver(Cls, 12, tight=True)
139 res = pf.march_to_steady(s, lambda s=s: umean(s), rtol=1e-10, max_steps=20000,
140 accelerate=acc)
141 out[acc] = res
142 p, a = out[False], out[True]
143 rel = abs(p.monitor / a.monitor - 1.0) # K ~ 1 / <u_x>: K_acc / K_plain = u_p / u_a
144 check(p.converged and a.converged and rel <= 1e-8,
145 f"{name}: plain {p.steps} steps, accelerated {a.steps} ({a.accelerated_steps} "
146 f"accelerated, {a.num_restarts} restarts), |K_acc/K_plain - 1| = {rel:.2e}")
147
148
150 print("G7a: a case whose plain march diverges -> converged=False for both drivers",
151 flush=True)
152 for acc, window in ((False, 5), (True, 3), (True, 5), (True, 8)):
153 s = sphere_solver(pf.Solver, 16, tight=False, mu=0.0158, dt=1.234e-2, advection="sou")
154 res = pf.march_to_steady(s, lambda s=s: umean(s), rtol=1e-4, max_steps=5000,
155 accelerate=acc, window=window)
156 check(not res.converged and res.reason in ("diverged", "max_steps"),
157 f"accelerate={acc} window={window}: converged={res.converged} reason={res.reason} "
158 f"steps={res.steps} (accelerated {res.accelerated_steps})")
159
160
161def state_of(s):
162 return [np.array(s.get_field(f)) for f in ("u", "v", "w", "p")]
163
164
165def accelerated(Cls, calls):
166 """A production N = 12 sphere solver and its accelerator after `calls` step(True) calls."""
167 s = sphere_solver(Cls, 12, tight=False)
168 acc = s.diagnostics.anderson_accelerator(window=5)
169 for _ in range(calls):
170 acc.step(True)
171 return s, acc
172
173
175 print("adapter: signature reset, refusal re-check, configuration change (review R4, R5)",
176 flush=True)
177 def no_mix_after(Cls, change, what):
178 """`change(solver)` between calls: num_resets + 1 and NO mix on that call. Twins: four
179 identical solvers take the same 10 accelerated calls (bit-identical trajectories). C (True)
180 and D (False) without the change differ, so a mix is pending (the check is not vacuous);
181 A (True) and B (False) after the change are bitwise equal, so A's call did not mix."""
182 sa, aa = accelerated(Cls, 10)
183 sb, ab = accelerated(Cls, 10)
184 sc, ac = accelerated(Cls, 10)
185 sd, ad = accelerated(Cls, 10)
186 ac.step(True)
187 ad.step(False)
188 pending = any(not np.array_equal(x, y) for x, y in zip(state_of(sc), state_of(sd)))
189 r0 = aa.num_resets
190 change(sa)
191 change(sb)
192 aa.step(True)
193 ab.step(False)
194 plain = all(np.array_equal(x, y) for x, y in zip(state_of(sa), state_of(sb)))
195 check(pending and plain and aa.num_resets == r0 + 1 and aa.status == "active",
196 f"{what}: num_resets {r0} -> {aa.num_resets}, the call is bitwise the plain step "
197 f"{plain} (a mix was pending without the change: {pending})")
198
199 dt2 = 2.0 * 6.0 * (L / 12) ** 2
200 # staggered set_advection between calls: the advection settings are in the signature (R5)
201 no_mix_after(pf.Solver, lambda s: s.set_advection(True), "staggered: set_advection(True)")
202 for name, Cls in SCHEMES:
203 no_mix_after(Cls, lambda s: s.set_dt(dt2), f"{name}: set_dt between calls")
204 # the refusals are re-checked at every call: a refused feature enabled after construction
205 s, acc = accelerated(Cls, 3)
206 s.diagnostics.set_pressure_warmstart(True)
207 try:
208 acc.step(True)
209 msg = None
210 except RuntimeError as e:
211 msg = str(e)
212 check(msg is not None and "pressure warm start" in msg and "accelerate=False" in msg,
213 f"{name}: set_pressure_warmstart(True) after construction -> refused at the next "
214 f"call: {msg!r}")
215 # a change of the field count is a configuration change, not a reallocation (collocated
216 # set_advection(True) adds the face field uf/vf/wf: 4 -> 7 fields)
217 s, acc = accelerated(pf.SolverColocated, 3)
218 r0 = acc.num_resets
219 s.set_advection(True)
220 try:
221 acc.step(True)
222 msg = None
223 except Exception as e: # std::logic_error
224 msg = str(e)
225 check(msg is not None and "configuration change" in msg and "reallocated" not in msg
226 and acc.num_resets == r0 + 1,
227 f"collocated: set_advection(True) after construction -> {msg!r}, num_resets {r0} -> "
228 f"{acc.num_resets}")
229
230
231def scripted(values):
232 """A fake step and a monitor that returns values[k] after the k-th step."""
233 state = {"k": 0}
234
235 def step():
236 state["k"] += 1
237
238 def monitor():
239 return values[state["k"] - 1]
240
241 return step, monitor, state
242
243
244def certify(values, **kw):
245 step, monitor, state = scripted(values)
246 counter = steady._Counter()
247 args = dict(rtol=1e-4, check_every=5, num_passes=3, slow_rate=0.997, roundoff=1e-11,
248 max_steps=10 ** 6, counter=counter, callback=None, phase="certify")
249 args.update(kw)
250 out = steady._certify(step, monitor, **args)
251 return out, counter.steps
252
253
254def per_block(blocks):
255 """A monitor sequence (one value per step) whose block-end values are `blocks`."""
256 seq = []
257 for v in blocks:
258 seq += [float("nan")] * 4 + [v] # only every 5th step is read
259 return seq
260
261
263 print("driver certification on scripted monitor sequences", flush=True)
264 budget = 2 * (3 + 3)
265 # (i) block changes that GROW (R > 1) for four blocks, at |d| ~ 1e-9 of |m|, then a clean
266 # geometric tail with R = 0.5: passes inside the 12-block budget.
267 m0 = 1.0
268 blocks = [m0]
269 d = 1e-9
270 for _ in range(4):
271 d *= 3.0
272 blocks.append(blocks[-1] + d)
273 d = 2e-6
274 for _ in range(6):
275 d *= 0.5
276 blocks.append(blocks[-1] + d)
277 out, steps = certify(per_block(blocks), budget=budget, slow_exit=True,
278 growth_residual=lambda: 0.0, growth_ref=1.0)
279 check(out == "pass" and steps <= 5 * budget, f"(i) R > 1 then a geometric tail: {out} after "
280 f"{steps} steps ({steps // 5} blocks)")
281 # (ii) a clean geometric tail at R = 0.99 (>= 0.997^5 = 0.985) whose remainder bound fails:
282 # "slow" at the first block where R is defined.
283 blocks = [1.0]
284 d = 1e-4
285 for _ in range(8):
286 blocks.append(blocks[-1] + d)
287 d *= 0.99
288 out, steps = certify(per_block(blocks), budget=budget, slow_exit=True,
289 growth_residual=lambda: 0.0, growth_ref=1.0)
290 check(out == "slow" and steps == 15, f"(ii) clean R = 0.99 tail: {out} after {steps} steps "
291 "(expected 'slow' at step 15, the first block with a ratio)")
292 # (iii) a sequence that never passes (alternating sign): "budget" after exactly 12 blocks.
293 blocks = [1.0 + (1e-3 if k % 2 else 0.0) for k in range(40)]
294 out, steps = certify(per_block(blocks), budget=budget, slow_exit=True,
295 growth_residual=lambda: 0.0, growth_ref=1.0)
296 check(out == "budget" and steps == 5 * budget,
297 f"(iii) never passing: {out} after {steps} steps (expected 'budget' at {5 * budget})")
298 # (iv) two passing blocks (R = 0.9, |d| / (1 - 0.985) < rtol |m|), then R = 1.1 for every later
299 # block: R >= 1 never passes and resets the pass count, so the third pass never comes and the
300 # run ends at the budget, never "pass".
301 blocks = [1.0]
302 d = 1e-6
303 blocks.append(blocks[-1] + d) # first change: no ratio yet
304 for _ in range(2):
305 d *= 0.9
306 blocks.append(blocks[-1] + d)
307 for _ in range(30):
308 d *= 1.1
309 blocks.append(blocks[-1] + d)
310 out, steps = certify(per_block(blocks), budget=budget, slow_exit=True,
311 growth_residual=lambda: 0.0, growth_ref=1.0)
312 check(out == "budget" and steps == 5 * budget,
313 f"(iv) two passes at R = 0.9, then R = 1.1: {out} after {steps} steps (expected 'budget' "
314 f"at {5 * budget}, never 'pass')")
315
316
317def main():
320 gate_g0b()
322 gate_g7a()
323 print("PASS" if not FAILED else f"FAIL ({len(FAILED)}): " + "; ".join(FAILED))
324 return 0 if not FAILED else 1
325
326
328 assert main() == 0
329
330
331if __name__ == "__main__":
332 sys.exit(main())
sphere_solver(Cls, N, *tight, mu=1.0, dt=None, advection=None)
static double mean(const std::vector< double > &a)