flow 1.3.0
Kokkos cut-cell IBM incompressible Navier-Stokes solver + pnm pore extraction
Loading...
Searching...
No Matches
anderson_oracle.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""Python oracle for Anderson acceleration of steady marches (doc/steady_acceleration.md, WO-1).
3
4An INSTRUMENT, not a gate and not production code. It implements the design note exactly, in NumPy
5over the zero-copy host buffers of `diagnostics.field_view`, on a host build of peclet.flow.
6
7Three revisions of the design (`--rev`, default 2):
8 * rev 2 (the note's "Revision 2"): rev 1 without the instability guard. The window's Ritz radius
9 has no consequence; the oracle still COMPUTES it on rev 1's eligible calls and records where
10 rev 1 would have declared "unstable" (`ritz_would_fire`), as an instrument for the census.
11 Iterates, step counts and K equal rev 1's wherever rev 1's guard did not fire (the guard
12 never feeds back into gamma).
13 * rev 1 (the note since its "Revision 1" section): velocity-only metric, P carried (mixed, not
14 measured); phase A's target on the relative velocity residual; the Ritz guard only on mixed
15 calls over windows of mixed columns and above max(1e-10, 1000 x tau), tau the inner tolerance;
16 certification budget 2 (num_passes + 3) blocks with the early "slow" exit.
17 * rev 0 (WO-1, the note's revision 0, reproduced bit for bit): the c_P-weighted W-metric with the
18 fluid gauge, the guard on every active call above 1e-10, budget num_passes + 3.
19`--metric`, `--ritz-scope`, `--ritz-action` and `--budget` override single pieces for ablation
20(`--ritz-action fallback` = brief 3's alternative consequence: on "unstable", continue as the plain
21march from the current plain output). Every run also sums the map's wall time and pressure
22iterations (MAP_STATS) into its JSON record.
23
24The rev-0 description, which rev 1 modifies as above:
25
26 * §3 state vector (full padded buffers of u, v, w, p; roles Velocity / Pressure) and the
27 W-metric (velocity at unit weight + c_P^2 x the fluid-centred, gauge-centred pressure),
28 c_P = 1 / (mu_int + rho_int / dt_int) in the solver's internal units;
29 * §4 type-II Anderson, one `step(accelerate)` = §4.3 steps 0-7 verbatim: change detection, the
30 lazy MIX, the map, the provisional column, the reductions, the host decision (non-finite /
31 restart / engagement), the commit and the next coefficients; §4.1 constants; §4.4
32 solveTruncated (Jacobi-scaled cyclic-Jacobi eigendecomposition, oldest-column drop while
33 the scaled condition < kCondMin); §4.6 the Ritz guard (Gelfand radius of I + XX^+ XR);
34 * §7 the driver `march_to_steady` (phase A accelerate to (1 - slow_rate) rtol, phase B certify
35 with the UNCHANGED §3.2 instrument on consecutive plain steps within num_passes + 3 blocks,
36 target x 0.1 and resume on "budget", plain fallback on "growth" / stagnation / a status
37 other than active). accelerate=False is the study's march() verbatim.
38
39What the oracle cannot do, by construction (design note D1): the collocated face field uf_ is not in
40the field registry, so a collocated run WITH advection (n_s = 7, §3.1 row 3) is refused here. The
41parameter signature (internal dt, rho, mu, body force) has no Python getters; the oracle takes it
42from the case that built the solver, which never changes it mid-march, so §4.3 step 0's signature
43half can never fire in these runs (the inner-entry state check is implemented and live).
44
45Cases (§11 of doc/collocated_invisible_subspace.md, as tests/study/study_avg_velocity_spheres.py
46builds them: one sphere phi = 0.125 at the centre of a periodic unit cube, rho = mu = F = 1,
47nu dt / h^2 = 6, Stokes; staggered Solver or SolverColocated with its AUTO = 'ghost' scheme):
48
49 sphere --scheme staggered|collocated --N 16 [--mu 1 --beta 6 | --dt ...] [--advection koren]
50 bed --scheme ... the A1 dense random bed (design note §10 Q2), from an arrangement .npz
51
52Settings (§8): --settings production (study: PCG(200, 1e-8), 200 velocity sweeps, rtol 1e-4) or
53tight (PCG(400, 1e-12), velocity residual tolerance 1e-12, rtol 1e-10, max_steps 20000).
54
55Run (from the flow tree, OpenMP pool bounded):
56
57 OMP_NUM_THREADS=8 OMP_PROC_BIND=false PYTHONPATH=<flow host build> \\
58 python tests/study/anderson_oracle.py sphere --scheme collocated --N 16 \\
59 --settings production --window 0 3 5 8 [--json results.jsonl]
60
61`--window 0` is the plain march (accelerate=False). Each run prints one summary line and, with
62--json, appends one JSON record (steps, converged, reason, K, restarts, max Ritz radius over the
63active range, max sum|alpha|, wall time, the residual trace).
64"""
65import argparse
66import json
67import math
68import os
69import sys
70import time
71
72import numpy as np
73
74# ---------------------------------------------------------------------------------------------------
75# §4.1 fixed constants (C++ constexpr in the production design; not user-settable)
76K_MAX_WINDOW = 8
77K_ENGAGE_DECREASES = 2
78K_RESTART_GROWTH = 4.0
79K_MAX_RESTARTS = 5
80K_NOISE_FLOOR = 1e-10
81K_COND_MIN = 1e-12
82K_RITZ_DELTA = 1e-3
83K_RITZ_HI = 1e-2
84K_RITZ_CONSECUTIVE = 3
85K_GELFAND_SQUARINGS = 20
86K_RITZ_FLOOR_FACTOR = 1000.0 # rev 1: the Ritz guard runs only above 1000 x the inner tolerance
87
88VELOCITY, PRESSURE, CARRIED = 0, 1, 2
89
90# Map-cost instrument (not part of the design): wall time inside solver.step() and the pressure
91# solve's iteration count, summed over every map evaluation of a march. Reset by march_to_steady.
92MAP_STATS = {"calls": 0, "seconds": 0.0, "pressure_iterations": 0}
93
94
95def run_map(solver):
96 """One evaluation of the plain map, timed (the design's map is `solver.step()`, unchanged)."""
97 t0 = time.perf_counter()
98 solver.step()
99 MAP_STATS["seconds"] += time.perf_counter() - t0
100 MAP_STATS["calls"] += 1
101 MAP_STATS["pressure_iterations"] += int(solver.diagnostics.last_pressure_iterations())
102
103
104# ---------------------------------------------------------------------------------------------------
105# §4.4 / §4.6 host linear algebra (deterministic)
106def jacobi_eigh(A, tol=1e-15, max_sweeps=50):
107 """Cyclic Jacobi eigendecomposition of a symmetric matrix, fixed sweep order (p < q, row-major).
108
109 Stops when the off-diagonal Frobenius norm is <= tol * ||A||_F, or after max_sweeps sweeps.
110 Returns (lam, V) with A = V diag(lam) V^T (columns of V are the eigenvectors)."""
111 A = np.array(A, dtype=np.float64, copy=True)
112 n = A.shape[0]
113 V = np.eye(n)
114 normF = math.sqrt(float(np.sum(A * A)))
115 for _ in range(max_sweeps):
116 off = math.sqrt(max(float(np.sum(A * A) - np.sum(np.diag(A) ** 2)), 0.0))
117 if off <= tol * normF:
118 break
119 for p in range(n - 1):
120 for q in range(p + 1, n):
121 apq = A[p, q]
122 if apq == 0.0:
123 continue
124 theta = (A[q, q] - A[p, p]) / (2.0 * apq)
125 if abs(theta) > 1e150: # theta^2 would overflow; the same t to round-off
126 t = 0.5 / theta
127 else:
128 t = math.copysign(1.0, theta) / (abs(theta) + math.sqrt(theta * theta + 1.0))
129 c = 1.0 / math.sqrt(t * t + 1.0)
130 s = t * c
131 # A <- J^T A J with J the (p, q) rotation [[c, s], [-s, c]]
132 ap = A[:, p].copy()
133 aq = A[:, q].copy()
134 A[:, p] = c * ap - s * aq
135 A[:, q] = s * ap + c * aq
136 ap = A[p, :].copy()
137 aq = A[q, :].copy()
138 A[p, :] = c * ap - s * aq
139 A[q, :] = s * ap + c * aq
140 vp = V[:, p].copy()
141 vq = V[:, q].copy()
142 V[:, p] = c * vp - s * vq
143 V[:, q] = s * vp + c * vq
144 return np.diag(A).copy(), V
145
146
148 """Jacobi scaling of §4.4 steps 1-3: D = diag(RR)^1/2, A = D^-1 RR D^-1. Returns (D, lam, V)."""
149 D = np.sqrt(np.diag(RR))
150 A = RR / np.outer(D, D)
151 lam, V = jacobi_eigh(A)
152 return D, lam, V
153
154
155def cond_scaled(lam):
156 lmax = float(np.max(lam))
157 return float(np.min(lam)) / lmax if lmax > 0.0 else 0.0
158
159
160def solve_truncated(D, lam, V, b):
161 """§4.4 step 5: y = sum_i v_i (v_i^T b~) / lam_i, gamma = D^-1 y, with b~ = D^-1 b."""
162 bt = b / D
163 y = V @ ((V.T @ bt) / lam)
164 return y / D
165
166
168 """§4.6: the truncated pseudo-inverse of §4.4 (Jacobi-scaled), eigenvalues below
169 kCondMin * lam_max discarded, no column dropping. A zero diagonal entry's row/column is the
170 pseudo-inverse's null part (it carries no direction)."""
171 n = XX.shape[0]
172 d = np.diag(XX)
173 live = np.where(d > 0.0)[0]
174 P = np.zeros((n, n))
175 if live.size == 0:
176 return P
177 Xl = XX[np.ix_(live, live)]
178 D, lam, V = scaled_eig(Xl)
179 lmax = float(np.max(lam))
180 keep = lam >= K_COND_MIN * lmax if lmax > 0.0 else np.zeros_like(lam, dtype=bool)
181 Ainv = (V[:, keep] / lam[keep]) @ V[:, keep].T
182 P[np.ix_(live, live)] = Ainv / np.outer(D, D)
183 return P
184
185
187 """§4.6 step 3: rho(T) ~ ||T^(2^20)||_max^(2^-20), renormalised every squaring."""
188 s = float(np.max(np.abs(T)))
189 if s == 0.0 or not math.isfinite(s):
190 return 0.0 if s == 0.0 else float("inf")
191 A = T / s
192 L = math.log(s)
193 for _ in range(K_GELFAND_SQUARINGS):
194 A = A @ A
195 s = float(np.max(np.abs(A)))
196 if s == 0.0:
197 return 0.0
198 A = A / s
199 L = 2.0 * L + math.log(s)
200 return math.exp(L / 2.0 ** K_GELFAND_SQUARINGS)
201
202
203# ---------------------------------------------------------------------------------------------------
205 """§4 AndersonCore + the flow adapter, on host NumPy views of the solver's padded buffers."""
206
207 def __init__(self, solver, *, window=5, mixing=1.0, signature, c_p, gauged, collocated_advection,
208 cells, metric="V", ritz_scope="mixed", inner_tolerance=0.0, ritz_action="stop"):
209 if not 1 <= window <= K_MAX_WINDOW:
210 raise ValueError(f"window must be in [1, {K_MAX_WINDOW}]")
211 if not 0.0 < mixing <= 1.0:
212 raise ValueError("mixing must be in (0, 1]")
213 if collocated_advection:
214 raise ValueError("collocated + advection needs uf_/vf_/wf_ in the state (§3.1 row 3); "
215 "they are not in the field registry, so the oracle cannot carry them")
216 self.s = solver
217 self.m = window
218 self.beta = mixing
219 self.cP = float(c_p)
220 self.gauged = bool(gauged)
221 self.sig = tuple(signature)
222 self.sig_source = tuple(signature) # the case never changes it (see module docstring)
223 if metric not in ("V", "W") or ritz_scope not in ("mixed", "all") \
224 or ritz_action not in ("stop", "fallback", "none"):
225 raise ValueError("metric must be 'V' or 'W', ritz_scope 'mixed' or 'all', "
226 "ritz_action 'stop', 'fallback' or 'none'")
227 # rev 0/1 "stop": status "unstable", the driver returns not-converged; "fallback": status
228 # "unstable", the driver continues as the plain march; rev 2 "none": record only.
229 self.ritz_action = ritz_action
230 self.ritz_would_fire = [] # (call index, residual, radius) where rev 1 would fire
231 # rev 1: metric "V" = velocity only, P is Carried (mixed + differenced, not measured);
232 # rev 0: metric "W" = velocity + c_P^2 x the fluid-centred, gauge-centred pressure.
233 self.metric = metric
234 self.ritz_scope = ritz_scope # rev 1 "mixed": Ritz only on mixed calls over all-mixed windows
235 # rev 1: the guard's floor, max(1e-10, 1000 x the inner-solve tolerance tau)
236 self.ritz_floor = max(K_NOISE_FLOOR, K_RITZ_FLOOR_FACTOR * float(inner_tolerance))
237 names = ["u", "v", "w", "p"]
238 self.roles = [VELOCITY, VELOCITY, VELOCITY, PRESSURE if metric == "W" else CARRIED]
239 G = (solver.diagnostics.field_view("u").shape[0] - int(cells[0])) // 2
240 assert G == 2, f"ghost width {G}, the design note's G = 2"
241 self.views = []
242 for nm in names:
243 v = solver.diagnostics.field_view(nm)
244 assert v.flags.f_contiguous and v.dtype == np.float64
245 self.views.append(v.reshape(-1, order="F")) # storage order, a view (no copy)
246 assert np.shares_memory(self.views[-1], v)
247 shape = solver.diagnostics.field_view("u").shape
248 self.shape = shape
249 ex, ey, ez = shape
250 x = np.arange(ex)
251 y = np.arange(ey)
252 z = np.arange(ez)
253 X, Y, Z = np.meshgrid(x, y, z, indexing="ij")
254 inner = (X >= G) & (X < ex - G) & (Y >= G) & (Y < ey - G) & (Z >= G) & (Z < ez - G)
255 self.inner = np.flatnonzero(inner.reshape(-1, order="F"))
256 sdf = np.asarray(solver.diagnostics.field_view("sdf")).reshape(-1, order="F")
257 self.fluid = self.inner[sdf[self.inner] > 0.0] # fluid-centred inner cells (sign only)
258 self.NF = int(self.fluid.size)
259 self.ns = len(names)
260 self.npad = self.views[0].size
261 self.vel = [f for f in range(self.ns) if self.roles[f] == VELOCITY]
262 self.pf = [f for f in range(self.ns) if self.roles[f] == PRESSURE]
263 # device-side data of §4.2 (here: host arrays)
264 z = lambda: np.zeros((self.ns, self.npad)) # noqa: E731
265 self.X, self.Rprev, self.Gprev = z(), z(), z()
266 self.dR = [z() for _ in range(self.m)]
267 self.dG = [z() for _ in range(self.m)]
268 # host-side data
269 self.RR = np.zeros((self.m, self.m))
270 self.GG = np.zeros((self.m, self.m))
271 self.GR = np.zeros((self.m, self.m))
272 self.mR = np.zeros(self.m)
273 self.mG = np.zeros(self.m)
274 self.mixedCol = [False] * self.m # rev 1: was the column formed at a mixed call
275 self.order = [] # slot indices, oldest -> newest; mk = len(order)
276 self.havePrev = False
277 self.pending = False
278 self.gamma = np.zeros(0)
279 self.engaged = False
280 self.decCount = 0
281 self.rhoPrev = math.inf
282 self.rhoMin = math.inf
283 self.numRestarts = 0
284 self.numResets = 0
285 self.ritzCount = 0
286 self.status = "active"
287 self.reason = ""
288 self.residual = math.inf
289 self.ritzRadius = math.nan
290 # instrumentation (not part of the design's state)
292 self.cond_drops = 0
293 self.ritz_max = 0.0 # max ritz radius over the active range (G7c)
294 self.sum_abs_alpha_max = 0.0 # §4.5: logged, never tested
295 self.mixed_steps = 0
296 self.calls = 0 # every step() call (instrument; the census's step index)
297 self.ritz_debug = None # set to [] to record the Ritz cross-checks
298
299 # ----------------------------------------------------------------------------------- helpers
300 @property
301 def mk(self):
302 return len(self.order)
303
304 def read(self):
305 return np.stack([v for v in self.views]) # copy of the padded buffers
306
307 def write(self, a):
308 for f in range(self.ns):
309 self.views[f][:] = a[f]
310
311 def pmean(self, a):
312 if not self.gauged or self.NF == 0:
313 return 0.0
314 return float(sum(a[f, self.fluid].sum() for f in self.pf)) / self.NF
315
316 def wdot(self, a, b, ma, mb):
317 """§3.2 <a,b>_W: velocity at unit weight over inner entries + c_P^2 x the centred
318 fluid-cell pressure product."""
319 acc = 0.0
320 for f in self.vel:
321 acc += float(np.dot(a[f, self.inner], b[f, self.inner]))
322 pp = 0.0
323 for f in self.pf:
324 pp += float(np.dot(a[f, self.fluid] - ma, b[f, self.fluid] - mb))
325 return acc + self.cP * self.cP * pp
326
327 def usq(self, g):
328 return float(sum(np.dot(g[f, self.inner], g[f, self.inner]) for f in self.vel))
329
330 def reset(self):
331 """§4.2 reset(): clears history + counters, keeps havePrev."""
332 self.order = []
333 self.pending = False
334 self.engaged = False
335 self.decCount = 0
336 self.ritzCount = 0
337 self.rhoMin = math.inf
338 self.rhoPrev = math.inf
339
340 def disable(self, reason="disabled by the caller"):
341 self.status = "disabled"
342 self.reason = reason
343 self.pending = False
344
345 # ----------------------------------------------------------------------------------- §4.3
346 def step(self, accelerate):
347 self.calls += 1
348 if self.status != "active": # plain step, no history maintenance, residual kept
349 run_map(self.s)
350 return
351 # --- 0. change detection
352 if self.sig_source != self.sig:
353 self.reset()
354 self.havePrev = False
355 self.numResets += 1
356 self.sig = self.sig_source
357 buf = self.read()
358 if self.havePrev:
359 n_changed = int(sum(np.count_nonzero(buf[f, self.inner] != self.Gprev[f, self.inner])
360 for f in range(self.ns)))
361 if n_changed > 0:
362 self.reset()
363 self.havePrev = False
364 self.numResets += 1
365 # --- 1. lazy mix
366 mixed = bool(accelerate and self.pending and self.status == "active")
367 if mixed:
368 c = np.zeros_like(buf)
369 d = np.zeros_like(buf)
370 for j, slot in enumerate(self.order):
371 c += self.gamma[j] * self.dG[slot]
372 d += self.gamma[j] * self.dR[slot]
373 x = buf - c - (1.0 - self.beta) * (self.Rprev - d)
374 self.write(x)
375 self.X = x.copy()
376 self.mixed_steps += 1
377 else:
378 self.X = buf.copy()
379 self.pending = False
380 # --- 2. the map
381 try:
382 run_map(self.s)
383 except Exception as e: # noqa: BLE001 -- the design's catch-all
384 if mixed:
385 self.write(self.Gprev)
386 self.status = "disabled"
387 self.reason = f"step failed at an accelerated iterate: {e}"
388 self.reset()
389 print(f"[anderson] {self.reason}", file=sys.stderr)
390 return
391 raise
392 buf = self.read()
393 # --- 3. provisional new column
394 s = None
395 if self.havePrev:
396 if self.mkmk < self.m:
397 used = set(self.order)
398 s = next(k for k in range(self.m) if k not in used)
399 else:
400 s = self.order[0]
401 r = buf - self.X
402 self.dR[s] = r - self.Rprev
403 self.dG[s] = buf - self.Gprev
404 self.X = r
405 else:
406 self.X = buf - self.X
407 # --- 4. reductions
408 mX = self.pmean(self.X)
409 rho = math.sqrt(max(self.wdot(self.X, self.X, mX, mX), 0.0)) \
410 if np.all(np.isfinite(self.X)) else math.nan
411 U2 = self.usq(buf)
412 if s is not None:
413 mRs, mGs = self.pmean(self.dR[s]), self.pmean(self.dG[s])
414 cols = [j for j in self.order if j != s] + [s]
415 row = {}
416 for j in cols:
417 mRj = mRs if j == s else self.mR[j]
418 mGj = mGs if j == s else self.mG[j]
419 row[j] = (self.wdot(self.dR[s], self.dR[j], mRs, mRj),
420 self.wdot(self.dG[s], self.dG[j], mGs, mGj),
421 self.wdot(self.dG[s], self.dR[j], mGs, mRj),
422 self.wdot(self.dG[j], self.dR[s], mGj, mRs),
423 self.wdot(self.dR[j], self.X, mRj, mX))
424 # --- 5. host decision
425 if rho == 0.0 and U2 == 0.0:
426 residual = 0.0
427 elif U2 == 0.0:
428 residual = math.inf
429 else:
430 residual = rho / math.sqrt(U2)
431 self.residual = residual
432 failed = mixed and bool(self.s.diagnostics.pressure_solve_failed())
433 if not math.isfinite(rho) or failed:
434 if mixed:
435 self.write(self.Gprev)
436 self.reason = "non-finite residual / failed pressure solve at an accelerated iterate"
437 else:
438 self.reason = "non-finite residual on a plain step"
439 self.status = "disabled"
440 self.reset()
441 self.havePrev = False
442 return
443 b = np.zeros(self.m)
444 if s is not None: # accept column s
445 for j, (rr, gg, gr_sj, gr_js, bj) in row.items():
446 self.RR[s, j] = self.RR[j, s] = rr
447 self.GG[s, j] = self.GG[j, s] = gg
448 self.GR[s, j] = gr_sj
449 self.GR[j, s] = gr_js
450 b[j] = bj
451 self.mR[s], self.mG[s] = mRs, mGs
452 self.mixedCol[s] = mixed
453 if s in self.order:
454 self.order.remove(s)
455 self.order.append(s)
456 if mixed and residual >= K_NOISE_FLOOR and rho > K_RESTART_GROWTH * self.rhoMin:
457 self.reset()
458 self.numRestarts += 1
459 self.rhoMin = rho
460 if self.numRestarts >= K_MAX_RESTARTS:
461 self.status = "disabled"
462 self.reason = "too many restarts"
463 self.decCount = self.decCount + 1 if rho < self.rhoPrev else 0
464 if self.decCount >= K_ENGAGE_DECREASES:
465 self.engaged = True
466 self.rhoPrev = rho
467 self.rhoMin = min(self.rhoMin, rho)
468 # --- 6. commit
469 self.Rprev = self.X
470 self.X = np.zeros_like(self.X)
471 self.Gprev = buf.copy()
472 self.havePrev = True
473 # --- 7. next coefficients
474 self.ritzRadius = math.nan
475 if self.status == "active" and self.engaged and self.mkmk >= 1:
476 # §4.4 step 1: a column with a zero diagonal is dropped first
477 for j in [j for j in self.order if not self.RR[j, j] > 0.0]:
478 self.order.remove(j)
479 self.zero_diag_drops += 1
480 if self.mkmk == 0:
481 return
482 while True:
483 idx = self.order
484 D, lam, V = scaled_eig(self.RR[np.ix_(idx, idx)])
485 if self.mkmk > 1 and cond_scaled(lam) < K_COND_MIN:
486 self.order.pop(0)
487 self.cond_drops += 1
488 continue
489 break
490 self.gamma = solve_truncated(D, lam, V, b[self.order])
491 self.pending = True
492 g = self.gamma
493 alpha = np.concatenate([[g[0]], np.diff(g), [1.0 - g[-1]]])
494 self.sum_abs_alpha_max = max(self.sum_abs_alpha_max, float(np.abs(alpha).sum()))
495 if self.ritz_scope == "all": # rev 0: every active call
496 eligible = self.mkmk >= 2 and K_NOISE_FLOOR <= residual <= K_RITZ_HI
497 else: # rev 1: a mixed call, a window of mixed columns only, above the Ritz floor
498 eligible = (mixed and self.mkmk >= 2 and all(self.mixedCol[j] for j in self.order)
499 and self.ritz_floor <= residual <= K_RITZ_HI)
500 if not eligible:
501 self.ritzCount = 0
502 if eligible:
503 idx = self.order
504 RR = self.RR[np.ix_(idx, idx)]
505 GG = self.GG[np.ix_(idx, idx)]
506 GR = self.GR[np.ix_(idx, idx)]
507 XX = GG - GR - GR.T + RR
508 XR = GR - RR
509 T = np.eye(self.mkmk) + pinv_truncated(XX) @ XR
510 self.ritzRadius = gelfand_radius(T)
511 if self.ritz_debug is not None: # instrument: independent cross-checks
512 _, lamx, _ = scaled_eig(XX)
513 eigT = np.linalg.eigvals(T)
514 # M from a direct W-metric least squares on the stored vectors (no Gram cache)
515 dX = [self.dG[j] - self.dR[j] for j in idx]
516 mx = [self.pmean(v) for v in dX]
517 XXd = np.array([[self.wdot(dX[i], dX[k], mx[i], mx[k]) for k in range(len(idx))]
518 for i in range(len(idx))])
519 self.ritz_debug.append(dict(
520 residual=residual, ritz=self.ritzRadius, eig_abs_max=float(np.max(np.abs(eigT))),
521 cond_XX_scaled=cond_scaled(lamx), cond_RR_scaled=cond_scaled(lam),
522 XX_cache_vs_direct=float(np.max(np.abs(XXd - XX)) / np.max(np.abs(XX))),
523 mixed=mixed))
524 self.ritz_max = max(self.ritz_max, self.ritzRadius)
525 self.ritzCount = self.ritzCount + 1 if self.ritzRadius > 1.0 + K_RITZ_DELTA else 0
526 if self.ritzCount >= K_RITZ_CONSECUTIVE and self.ritz_action == "none":
527 # rev 2: no consequence; record where rev 1 would have fired, count afresh
528 self.ritz_would_fire.append((self.calls, residual, self.ritzRadius))
529 self.ritzCount = 0
530 elif self.ritzCount >= K_RITZ_CONSECUTIVE:
531 self.status = "unstable"
532 self.reason = (f"the plain map is locally unstable at this dt "
533 f"(Ritz radius {self.ritzRadius:.6f})")
534 self.pending = False
535 self.reset()
536
537
538# ---------------------------------------------------------------------------------------------------
539# §7 driver
540class MarchResult(dict):
541 pass
542
543
544def march_to_steady(solver, monitor, *, rtol=1e-4, max_steps=5000, accelerate=True, window=5,
545 check_every=5, num_passes=3, slow_rate=0.997, roundoff=1e-11, callback=None,
546 oracle_kw=None, holder=None, budget=None, rev=1):
547 """§7 driver. rev 0 = the note's revision 0 (certification budget num_passes + 3 blocks);
548 rev 1 = budget 2 (num_passes + 3) blocks plus the early "slow" exit: a failing block whose
549 ratio R lies in [slow_rate^check_every, 1) — a clean tail as slow as the instrument assumes,
550 failing on its remainder bound — resumes acceleration at once."""
551 st = dict(steps=0, acc_steps=0)
552 for k in MAP_STATS:
553 MAP_STATS[k] = 0
554 if budget is None:
555 budget = (num_passes + 3) if rev == 0 else 2 * (num_passes + 3)
556 slow_exit = rev >= 1
557 cb = callback or (lambda steps, phase: None)
558 acc = None
559
560 def result(converged, reason):
561 return MarchResult(converged=converged, steps=st["steps"], accelerated_steps=st["acc_steps"],
562 reason=reason, num_restarts=acc.numRestarts if acc else 0,
563 monitor=float(monitor()), acc=acc)
564
565 def certify(step_fn, budget, phase, growth_ref=None, slow_exit=False):
566 """The §3.2 instrument with a fresh block state; returns pass/budget/slow/max/growth/
567 diverged ("slow" only with slow_exit: the driver's policy, not part of the instrument)."""
568 prev = dprev = None
569 passes = 0
570 blocks = 0
571 it = 0
572 while True:
573 if st["steps"] >= max_steps:
574 return "max"
575 step_fn()
576 st["steps"] += 1
577 cb(st["steps"], phase)
578 if growth_ref is not None and (not math.isfinite(acc.residual)
579 or acc.residual > 10.0 * growth_ref):
580 return "growth"
581 local = it
582 it += 1
583 if local % check_every != check_every - 1:
584 continue
585 m = monitor()
586 if not math.isfinite(m):
587 if growth_ref is not None:
588 return "growth"
589 if budget is None:
590 return "diverged"
591 if prev is not None:
592 d = m - prev
593 ok = abs(d) <= roundoff * abs(m)
594 R = None
595 if not ok and dprev:
596 R = d / dprev
597 ok = 0.0 < R < 1.0 and \
598 abs(d) / (1.0 - max(R, slow_rate ** check_every)) < rtol * abs(m)
599 passes = passes + 1 if ok else 0
600 if slow_exit and not ok and R is not None and slow_rate ** check_every <= R < 1.0:
601 return "slow"
602 if passes >= num_passes:
603 return "pass"
604 dprev = d
605 prev = m
606 blocks += 1
607 if budget is not None and blocks >= budget:
608 return "budget"
609
610 def plain_tail(phase):
611 out = certify(lambda: run_map(solver), None, phase)
612 if out == "pass":
613 return result(True, "certified")
614 if out == "diverged":
615 return result(False, "diverged")
616 return result(False, "max_steps")
617
618 if not accelerate:
619 return plain_tail("plain")
620 acc = AndersonOracle(solver, window=window, **(oracle_kw or {}))
621 if holder is not None:
622 holder["acc"] = acc
623 target = (1.0 - slow_rate) * rtol
624 while st["steps"] < max_steps:
625 best, since, stagnated = math.inf, 0, False
626 while st["steps"] < max_steps and acc.status == "active" and acc.residual > target:
627 acc.step(True)
628 st["steps"] += 1
629 st["acc_steps"] += 1
630 cb(st["steps"], "accelerate")
631 if acc.status == "unstable":
632 if acc.ritz_action == "fallback": # brief 3 alternative: the plain march decides
633 return plain_tail("plain")
634 return result(False, "unstable")
635 if acc.residual <= 0.5 * best:
636 best, since = acc.residual, 0
637 else:
638 since += 1
639 if since >= 10 * window:
640 stagnated = True
641 break
642 if acc.status != "active":
643 return plain_tail("plain")
644 out = certify(lambda: acc.step(False), budget, "certify", growth_ref=acc.residual,
645 slow_exit=slow_exit)
646 if out == "pass":
647 return result(True, "certified")
648 if out == "max":
649 return result(False, "max_steps")
650 if out == "growth" or stagnated:
651 acc.disable()
652 return plain_tail("plain")
653 target *= 0.1 # "budget" or "slow": resume acceleration with the history intact
654 return result(False, "max_steps")
655
656
657# ---------------------------------------------------------------------------------------------------
658# cases
659L1 = 1.0
660ZH_PHI = [0.000125, 0.001, 0.008, 0.027, 0.064, 0.125, 0.216, 0.343, 0.45, 0.5236]
661ZH_K = [1.096, 1.212, 1.525, 2.008, 2.810, 4.292, 7.442, 15.4, 28.1, 42.1]
662
663
664def settings_of(name):
665 if name == "production":
666 return dict(pcg=(200, 1e-8), vel_rtol=None, rtol=1e-4, max_steps=5000)
667 if name == "tight":
668 return dict(pcg=(400, 1e-12), vel_rtol=1e-12, rtol=1e-10, max_steps=20000)
669 raise ValueError(name)
670
671
672def build_solver(flow, scheme, N, sdf_fn, L, *, rho, mu, F, dt, advection, settings, vel_sweeps=200):
673 Cls = flow.Solver if scheme == "staggered" else flow.SolverColocated
674 s = Cls((N, N, N), extent=(L, L, L))
675 s.set_rho(rho)
676 s.set_mu(mu)
677 s.set_dt(dt)
678 s.set_body_force((F, 0.0, 0.0))
679 if advection:
680 s.set_advection(True)
681 s.set_advection_scheme(advection)
682 else:
683 s.set_advection(False)
684 s.diagnostics.set_velocity_solver_params(vel_sweeps)
685 s.set_pressure_multigrid(True, levels=max(2, int(np.log2(N)) - 1))
686 s.set_pressure_pcg(True, max_iter=settings["pcg"][0], rtol=settings["pcg"][1])
687 if settings["vel_rtol"] is not None:
688 s.set_velocity_residual_tolerance(settings["vel_rtol"])
689 cx, cy, cz = s.cell_centers()
690 s.set_solid(np.asfortranarray(sdf_fn(cx, cy, cz)), cutcell_pressure=True)
691 us = s.unit_scales
692 rho_i = rho * us["density_to_internal"]
693 mu_i = mu * us["viscosity_to_internal"]
694 dt_i = dt * us["time_to_internal"]
695 f_i = F * us["force_density_to_internal"][0]
696 c_p = 1.0 / (mu_i + rho_i / dt_i) # §3.2, h_int = 1 (cubic cells)
697 # rev 1 inner tolerance tau: flow's velocityResidualTolerance() (the velocity residual tolerance
698 # when one is set, else the pressure driver's rtol) -- here the case's settings
699 tau = settings["vel_rtol"] if settings["vel_rtol"] else settings["pcg"][1]
700 oracle_kw = dict(signature=(dt_i, rho_i, mu_i, f_i, 0.0, 0.0), c_p=c_p, gauged=True,
701 collocated_advection=(scheme == "collocated" and bool(advection)),
702 cells=(N, N, N), inner_tolerance=tau)
703 return s, oracle_kw
704
705
706def main():
707 sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..",
708 "scripts"))
709 from _bootstrap import ensure_flow
710 flow = ensure_flow()
711
712 ap = argparse.ArgumentParser()
713 ap.add_argument("case", choices=("sphere", "bed"))
714 ap.add_argument("--scheme", choices=("staggered", "collocated"), required=True)
715 ap.add_argument("--N", type=int, default=16)
716 ap.add_argument("--phi", type=float, default=0.125)
717 ap.add_argument("--mu", type=float, default=1.0)
718 ap.add_argument("--beta", type=float, default=6.0, help="nu dt / h^2 (ignored with --dt)")
719 ap.add_argument("--dt", type=float, default=None)
720 ap.add_argument("--advection", default=None, help="advection scheme name (default: Stokes)")
721 ap.add_argument("--arrangement", default=None, help="bed: .npz with centres, radii, L")
722 ap.add_argument("--settings", choices=("production", "tight"), default="production")
723 ap.add_argument("--window", type=int, nargs="+", default=[0, 3, 5, 8],
724 help="0 = plain march (accelerate=False)")
725 ap.add_argument("--max-steps", type=int, default=None)
726 ap.add_argument("--json", default=None)
727 ap.add_argument("--label", default="")
728 ap.add_argument("--trace", action="store_true", help="print the per-step residual")
729 ap.add_argument("--rev", type=int, choices=(0, 1, 2), default=2,
730 help="design revision: 0 = W metric, Ritz on every call, floor 1e-10 (WO-1); "
731 "1 = velocity metric, Ritz on mixed windows above 1000 x tau; "
732 "2 = rev 1 without the guard (readings recorded, no consequence)")
733 ap.add_argument("--metric", choices=("V", "W"), default=None, help="override the revision's metric")
734 ap.add_argument("--ritz-scope", choices=("mixed", "all"), default=None)
735 ap.add_argument("--ritz-action", choices=("stop", "fallback", "none"), default=None,
736 help="override the guard's consequence (rev 0/1: stop; rev 2: none)")
737 ap.add_argument("--vel-sweeps", type=int, default=200,
738 help="velocity sweep cap (the study's 200; Revision 2's inexactness probe raised it)")
739 ap.add_argument("--budget", type=int, default=None, help="certification budget in blocks "
740 "(default num_passes + 3)")
741 a = ap.parse_args()
742 st = settings_of(a.settings)
743 if a.max_steps is not None:
744 st["max_steps"] = a.max_steps
745 rho = F = 1.0
746 if a.case == "sphere":
747 L = L1
748 R = (3.0 * a.phi / (4.0 * math.pi)) ** (1.0 / 3.0) * L
749 c = 0.5 * L
750
751 def sdf_fn(cx, cy, cz):
752 X, Y, Z = np.meshgrid(cx, cy, cz, indexing="ij")
753 return np.sqrt((X - c) ** 2 + (Y - c) ** 2 + (Z - c) ** 2) - R
754
755 def drag_K(u):
756 return F * L ** 3 / (6.0 * math.pi * a.mu * R * u)
757 else:
758 z = np.load(a.arrangement)
759 cen, rad, L = np.asarray(z["centres"], float), np.asarray(z["radii"], float), float(z["L"])
760 Dm = 2.0 * float(rad.mean())
761 npart = len(rad)
762
763 def sdf_fn(cx, cy, cz):
764 from scipy.spatial import cKDTree
765 X, Y, Z = np.meshgrid(cx, cy, cz, indexing="ij")
766 pts = np.stack([X.ravel(order="C"), Y.ravel(order="C"), Z.ravel(order="C")], axis=1)
767 tree = cKDTree(np.mod(cen, L), boxsize=L)
768 k = min(4, len(cen))
769 d, idx = tree.query(np.mod(pts, L), k=k)
770 return (d - rad[idx]).min(axis=1).reshape(X.shape, order="C")
771
772 def drag_K(u): # A1 common.drag_K: f V / (3 pi mu D U_s N_p)
773 return F * L ** 3 / (3.0 * math.pi * a.mu * Dm * u * npart)
774 h = L / a.N
775 dt = a.dt if a.dt is not None else a.beta * rho * h * h / a.mu
776 for w in a.window:
777 s, okw = build_solver(flow, a.scheme, a.N, sdf_fn, L, rho=rho, mu=a.mu, F=F, dt=dt,
778 advection=a.advection, settings=st, vel_sweeps=a.vel_sweeps)
779 okw["metric"] = a.metric or ("W" if a.rev == 0 else "V")
780 okw["ritz_scope"] = a.ritz_scope or ("all" if a.rev == 0 else "mixed")
781 okw["ritz_action"] = a.ritz_action or ("none" if a.rev == 2 else "stop")
782 if a.rev == 0:
783 okw["inner_tolerance"] = 0.0 # rev 0: the guard's floor is the constant 1e-10
784 trace = []
785 accref = {}
786
787 def cb(steps, phase, trace=trace, accref=accref):
788 acc = accref.get("acc")
789 trace.append((steps, phase, acc.residual if acc is not None else None,
790 acc.ritzRadius if acc is not None else None,
791 acc.status if acc is not None else None))
792 if a.trace and acc is not None:
793 print(f" {steps:5d} {phase:10s} res={acc.residual:.3e} ritz={acc.ritzRadius:.5f} "
794 f"mk={acc.mk} st={acc.status} rst={acc.numRestarts}", flush=True)
795
796 def monitor(s=s):
797 return float(s.get_u().mean())
798 t0 = time.perf_counter()
799 res = march_to_steady(s, monitor, rtol=st["rtol"], max_steps=st["max_steps"],
800 accelerate=(w > 0), window=max(w, 1), callback=cb, oracle_kw=okw,
801 holder=accref, budget=a.budget, rev=a.rev)
802 wall = time.perf_counter() - t0
803 acc = res["acc"]
804 um = res["monitor"]
805 K = drag_K(um) if math.isfinite(um) and um != 0 else math.nan
806 rec = dict(label=a.label, rev=a.rev, metric=okw["metric"], ritz_scope=okw["ritz_scope"],
807 ritz_action=okw["ritz_action"],
808 ritz_would_fire=[list(t) for t in acc.ritz_would_fire] if acc else None,
809 inner_tolerance=okw["inner_tolerance"], budget=a.budget,
810 case=a.case, scheme=a.scheme, N=a.N, phi=a.phi, mu=a.mu, dt=dt,
811 advection=a.advection, settings=a.settings, window=w,
812 converged=res["converged"], reason=res["reason"], steps=res["steps"],
813 accelerated_steps=res["accelerated_steps"], num_restarts=res["num_restarts"],
814 monitor=um, K=K, wall_s=wall, c_p=okw["c_p"],
815 map_seconds=MAP_STATS["seconds"], map_calls=MAP_STATS["calls"],
816 pressure_iterations=MAP_STATS["pressure_iterations"],
817 status=acc.status if acc else None, acc_reason=acc.reason if acc else None,
818 num_resets=acc.numResets if acc else None,
819 ritz_max=acc.ritz_max if acc else None,
820 sum_abs_alpha_max=acc.sum_abs_alpha_max if acc else None,
821 cond_drops=acc.cond_drops if acc else None,
822 zero_diag_drops=acc.zero_diag_drops if acc else None,
823 mixed_steps=acc.mixed_steps if acc else None,
824 trace=[list(t) for t in trace] if acc else None)
825 if acc:
826 def rmax(ph, trace=trace):
827 v = [t[3] for t in trace if t[1] == ph and t[3] is not None and math.isfinite(t[3])]
828 return max(v) if v else None
829 rec["ritz_max_accelerate"] = rmax("accelerate")
830 rec["ritz_max_certify"] = rmax("certify")
831 rec["ritz_over_1p001"] = sum(1 for t in trace if t[3] is not None and math.isfinite(t[3])
832 and t[3] > 1.001 and t[4] == "active")
833 first = next((t for t in trace if t[4] == "unstable"), None)
834 rec["unstable_step"] = first[0] if first else None
835 rec["unstable_phase"] = first[1] if first else None
836 tag = f"rev{a.rev}/{okw['metric']}/{okw['ritz_scope']}/{okw['ritz_action']}"
837 print(f"{a.label:>10s} {tag} {a.case} {a.scheme:10s} N={a.N} {a.settings:10s} m={w} "
838 f"conv={res['converged']} reason={res['reason']} steps={res['steps']} "
839 f"acc={res['accelerated_steps']} K={K:.10f} restarts={res['num_restarts']} "
840 f"ritzA={rec.get('ritz_max_accelerate')} ritzC={rec.get('ritz_max_certify')} "
841 f"unstable@{rec.get('unstable_step')}/{rec.get('unstable_phase')} "
842 f"would_fire={[(t[0], f'{t[1]:.2e}', round(t[2], 5)) for t in (rec['ritz_would_fire'] or [])]} "
843 f"sum|a|max={rec['sum_abs_alpha_max']} "
844 f"drops={rec['cond_drops']}/{rec['zero_diag_drops']} wall={wall:.2f}s", flush=True)
845 if a.json:
846 with open(a.json, "a") as fjs:
847 fjs.write(json.dumps(rec) + "\n")
848 s = None # release the solver before the next window's
849
850
851if __name__ == "__main__":
852 main()
__init__(self, solver, *window=5, mixing=1.0, signature, c_p, gauged, collocated_advection, cells, metric="V", ritz_scope="mixed", inner_tolerance=0.0, ritz_action="stop")
disable(self, reason="disabled by the caller")
build_solver(flow, scheme, N, sdf_fn, L, *rho, mu, F, dt, advection, settings, vel_sweeps=200)
jacobi_eigh(A, tol=1e-15, max_sweeps=50)
march_to_steady(solver, monitor, *rtol=1e-4, max_steps=5000, accelerate=True, window=5, check_every=5, num_passes=3, slow_rate=0.997, roundoff=1e-11, callback=None, oracle_kw=None, holder=None, budget=None, rev=1)
solve_truncated(D, lam, V, b)
static double mean(const std::vector< double > &a)