flow 1.3.0
Kokkos cut-cell IBM incompressible Navier-Stokes solver + pnm pore extraction
Loading...
Searching...
No Matches
vof_scriven.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""WO-P23 rung P3 — SCRIVEN bubble growth (Scriven, Chem. Eng. Sci. 10:1, 1959).
3
4A spherical vapour bubble growing in uniformly superheated liquid, thermally controlled:
5
6 R(t) = 2 beta sqrt(alpha_l t) ,
7 Ja = rho_l c_pl (T_inf - T_sat) / (rho_v h_lv)
8 = 2 beta^2 int_0^1 exp( -beta^2 [ (1-x)^-2 - 2(1 - rho_v/rho_l) x - 1 ] ) dx (Scriven)
9 T(r,t) = T_inf - (rho_v h_lv)/(rho_l c_pl) * 2 beta^2
10 int_{1-R/r}^{1} exp( -beta^2 [ (1-x)^-2 - 2(1 - rho_v/rho_l) x - 1 ] ) dx .
11
12The integral is tabulated numerically here (Gauss-Legendre on a substitution that resolves the
13essential singularity at x -> 1); `beta` follows by bisection.
14
15WHAT THE RUN MEASURES. R(t) from the LIQUID VOLUME DEFICIT, R = (3 sum(1-C) / 4pi)^(1/3) — a
16conserved-quantity read-out, not a contour, so it does not depend on any interface post-processing.
17The gate is |R_num - R_exact| / R_exact over the last half of the run.
18
19INITIALISATION (`--init`, WO-P3b). Scriven's solution carries a thermal boundary layer of
20thickness ~R0/(2 beta^2) at t0, and the classic trap in this benchmark is to start from a UNIFORM
21superheat with a sharp bubble, so that the first steps evaporate the superheat sitting AT the
22interface and the radius keeps a permanent offset. `--init similarity` (the DEFAULT, and what
23this driver has always done) samples T(r, t0) from the profile above at the cell centres, with
24R0 = 2 beta sqrt(alpha_l t0) by construction (t0 is derived from R0); `--init cellavg` is the
25same profile CELL-AVERAGED by 4^3 subsampling; `--init uniform` is the trap itself, kept as the
26CONTROL that shows what it costs.
27
28BOUNDARIES. Outflow on all six faces (the net vapour production is a real volume flux and has to
29leave; with pure liquid at every face and the reference density set to rho_l the boundary
30coefficient `applyBoundaryOpenness` re-imposes IS the variable-density one, so WO-R2 item 1 is
31side-stepped by construction) and T = T_inf Dirichlet on all six.
32
33Usage:
34 PYTHONPATH=$PWD/build_cuda python tests/study/vof_scriven.py [--n 128] [--ja 0.5,2,10]
35"""
36import argparse
37import math
38import sys
39
40import numpy as np
41
42import peclet.flow as pf
43
44# The set_phase_change_area names, indexed by the integer modes this script's CLI takes
45# (0 = PLIC ... 7 = the joined PLIC-distance sheet, split deposit).
46AREA_MODES = ("plic", "cascade-metric", "cascade-normal", "cascade-footprint",
47 "sheet-color-centroid", "sheet-color-split", "sheet-plic-centroid", "sheet-plic-split")
48
49_GL = np.polynomial.legendre.leggauss(400)
50
51
52def _scriven_integral(beta, rr, lo=0.0):
53 """int_lo^1 exp(-beta^2[(1-x)^-2 - 2(1-rr) x - 1]) dx, by the substitution x = 1 - 1/u
54 (u in [1/(1-lo), inf)) folded onto a finite interval with u = 1/v."""
55 # substitute x = 1 - v (v in (0, 1-lo]) -> integrand exp(-b^2[v^-2 - 2(1-rr)(1-v) - 1]) dv
56 a, w = _GL
57 hi = 1.0 - lo
58 v = 0.5 * hi * (a + 1.0)
59 ww = 0.5 * hi * w
60 v = np.clip(v, 1e-300, None)
61 ex = -beta * beta * (1.0 / (v * v) - 2.0 * (1.0 - rr) * (1.0 - v) - 1.0)
62 return float(np.sum(ww * np.exp(np.clip(ex, -700.0, 700.0))))
63
64
65def scriven_beta(ja, rr):
66 lo, hi = 1e-6, 60.0
67 for _ in range(200):
68 mid = 0.5 * (lo + hi)
69 if 2.0 * mid * mid * _scriven_integral(mid, rr) > ja:
70 hi = mid
71 else:
72 lo = mid
73 return 0.5 * (lo + hi)
74
75
76def scriven_T(r, R, beta, rr, dT):
77 """T_inf - dT * (2 beta^2 / Ja) * int_{1-R/r}^1 (...) == the profile above, normalised so that
78 T(R) = T_inf - dT."""
79 if r <= R:
80 return 0.0 # T_sat, measured as T - T_sat
81 full = 2.0 * beta * beta * _scriven_integral(beta, rr)
82 part = 2.0 * beta * beta * _scriven_integral(beta, rr, lo=1.0 - R / r)
83 return dT - dT * part / full
84
85
86def initial_temperature(n, ctr, R, beta, rr, dT, mode="similarity", sub=4):
87 """T(r, t0) - T_sat on the cell centres, for the three initialisations of `--init`.
88
89 'similarity' : Scriven's similarity profile sampled AT the cell centre. This is what the
90 driver has always done and what Sato & Niceno (JCP 2013) and Malan et al.
91 (2021) do; it is the shipped default and this branch is byte-identical to the
92 pre-WO-P3b code.
93 'cellavg' : the same profile, CELL-AVERAGED by sub^3 subsampling (a finite-volume initial
94 state rather than a point sample -- the O(h^2 T'') half of the same question).
95 'uniform' : uniform superheat T_inf in the liquid, T_sat inside the bubble, i.e. NO thermal
96 boundary layer at t0. The classic trap, kept as the control.
97 """
98 ax = (np.arange(n) + 0.5) - ctr
99 rad = np.sqrt(ax[:, None, None] ** 2 + ax[None, :, None] ** 2 + ax[None, None, :] ** 2)
100 if mode == "similarity":
101 return np.asfortranarray(np.vectorize(lambda r: scriven_T(r, R, beta, rr, dT))(rad))
102 if mode == "uniform":
103 return np.asfortranarray(np.where(rad > R, dT, 0.0))
104 if mode != "cellavg":
105 raise ValueError(f"unknown --init mode {mode!r}")
106 # cell average: a fine RADIAL table (the profile is a function of r alone) + sub^3 subsampling,
107 # done one cell-plane at a time so the 512^3 sample cloud is never materialised.
108 rmax = math.sqrt(3.0) * n
109 rt = np.linspace(0.0, rmax, 200001)
110 tt = np.array([scriven_T(r, R, beta, rr, dT) for r in rt])
111 off = (np.arange(sub) + 0.5) / sub
112 qs = (np.arange(n)[:, None] + off[None, :]).ravel() - ctr
113 out = np.empty((n, n, n))
114 for i in range(n):
115 xi = qs[i * sub:(i + 1) * sub]
116 d2 = xi[:, None, None] ** 2 + qs[None, :, None] ** 2 + qs[None, None, :] ** 2
117 t = np.interp(np.sqrt(d2), rt, tt)
118 out[i] = t.reshape(sub, n, sub, n, sub).mean(axis=(0, 2, 4))
119 return np.asfortranarray(out)
120
121
122def sphere_colour_chunked(n, ctr, R, sub):
123 """Liquid fraction of a sphere by sub^3 subsampling, one cell-plane at a time.
124
125 WO-P3c: the AREA probe needs a colour field whose SLIVER cells survive. `sub^3` subsampling
126 quantizes C to multiples of 1/sub^3, so every cell whose true liquid fraction is below
127 1/(2 sub^3) is rounded to exactly 0 or 1 and DROPS OUT of the interface entirely — at sub = 4
128 that is a quarter of the interfacial cells of a sphere and 5-9 % of its area, while the VOLUME
129 (and hence R0) moves by 1e-4 %. That is the whole of WO-P3b's "the PLIC area of a sphere is
130 5-9 % low": see the WO-P3c findings. The chunked loop is what makes sub = 32 affordable
131 (sub = 32 on R = 28 would otherwise materialise a 2000^3 sample cloud).
132 """
133 off = (np.arange(sub) + 0.5) / sub
134 lo = max(0, int(math.floor(ctr - R - 2)))
135 hi = min(n, int(math.ceil(ctr + R + 2)))
136 q = (np.arange(lo, hi)[:, None] + off[None, :]).ravel() - ctr
137 c = np.ones((n, n, n), order="F")
138 m = hi - lo
139 for i in range(m):
140 xi = q[i * sub:(i + 1) * sub]
141 d2 = xi[:, None, None] ** 2 + q[None, :, None] ** 2 + q[None, None, :] ** 2
142 frac = (d2 < R * R).reshape(sub, m, sub, m, sub).mean(axis=(0, 2, 4))
143 c[lo + i, lo:hi, lo:hi] = 1.0 - frac
144 return np.asfortranarray(c)
145
146
147def sphere_colour(n, cx, cy, cz, R, sub=4):
148 """Liquid fraction (C = 1 liquid, 0 vapour) of a sphere of radius R, by sub^3 subsampling."""
149 off = (np.arange(sub) + 0.5) / sub
150 ax = np.arange(n)
151 c = np.ones((n, n, n), order="F")
152 lo = [max(0, int(math.floor(q - R - 2))) for q in (cx, cy, cz)]
153 hi = [min(n, int(math.ceil(q + R + 2))) for q in (cx, cy, cz)]
154 xs = (ax[lo[0]:hi[0], None] + off[None, :]).ravel()
155 ys = (ax[lo[1]:hi[1], None] + off[None, :]).ravel()
156 zs = (ax[lo[2]:hi[2], None] + off[None, :]).ravel()
157 d2 = ((xs - cx) ** 2)[:, None, None] + ((ys - cy) ** 2)[None, :, None] + \
158 ((zs - cz) ** 2)[None, None, :]
159 inside = (d2 < R * R).reshape(hi[0] - lo[0], sub, hi[1] - lo[1], sub, hi[2] - lo[2], sub)
160 frac = inside.mean(axis=(1, 3, 5))
161 c[lo[0]:hi[0], lo[1]:hi[1], lo[2]:hi[2]] = 1.0 - frac
162 return np.asfortranarray(c)
163
164
165
166def apply_p3g(s, order=1, op_mdot=None, gfm_order=None, curv_dist=None, carry=None, deposit=None):
167 """WO-P3g: the second-order interfacial energy operator, as a package and as an ablation.
168
169 `order = 2` turns on all four pieces (operator-flux mdot, the Gibou-Fedkiw three-point row,
170 curvature-consistent distances from the V3 cascade, and WO-P3f's enthalpy-conserving Dirichlet
171 overwrite); the four keyword overrides then switch individual pieces back off or on, which is
172 how the ablation rows of the findings are produced. `order = 1` with all overrides None is the
173 shipped scheme, bitwise.
174 """
175 if order == 2:
176 s.diagnostics.set_phase_change_energy_order(2)
177 if op_mdot is not None:
178 s.diagnostics.set_phase_change_mdot_operator(bool(op_mdot))
179 if gfm_order is not None:
180 s.diagnostics.set_phase_change_gfm_order(int(gfm_order))
181 if curv_dist is not None:
182 s.diagnostics.set_phase_change_curvature_distance(bool(curv_dist))
183 if carry is not None:
184 s.diagnostics.set_phase_change_carry_conserve(bool(carry))
185 if deposit is not None:
186 s.diagnostics.set_phase_change_deposit_fallback(bool(deposit))
187
188def run(n, ja, ratio, r0, r1, cfl=0.2, alpha_l=1.0, sweeps=200, plane=True, consistent=True,
189 quad=True, muscl=False, init="similarity", sub=4, area_mode=None, verbose=True,
190 budget=0, carry=False, fitkap=False, p3g=None):
191 rr = 1.0 / ratio
192 beta = scriven_beta(ja, rr)
193 t0 = (r0 / (2 * beta)) ** 2 / alpha_l # cells^2 / (cells^2/s) = s
194 te = (r1 / (2 * beta)) ** 2 / alpha_l
195 rho_l, rho_v = 1.0, rr
196 cpl = 1.0
197 k_l = alpha_l * rho_l * cpl
198 rcp_l, rcp_v = rho_l * cpl, rho_v * cpl
199 k_v = k_l / ratio
200 dT = 1.0
201 h_lv = rho_l * cpl * dT / (rho_v * ja)
202 ctr = 0.5 * n
203
204 s = pf.Solver(n, n, n)
205 s.set_rho(rho_l)
206 s.set_mu(1e-3)
207 for f in ("-x", "+x", "-y", "+y", "-z", "+z"):
208 s.set_domain_bc(f, "outflow") # outflow everywhere: the vapour production must leave
209 s.set_pressure_geometry(np.full((n, n, n), 1.0, order="F"))
210 s.enable_vof()
211 c0 = sphere_colour_chunked(n, ctr, r0, sub)
212 s.set_vof(c0)
213 # R0 CONSISTENCY (WO-P3b): the gate's read-out is the liquid volume deficit, so the number
214 # that has to match 2 beta sqrt(alpha_l t0) is the one the SUB-SAMPLED colour field carries,
215 # not the nominal r0. Reported, not corrected.
216 r0meas = (3.0 * (float(n) ** 3 - c0.sum()) / (4.0 * math.pi)) ** (1.0 / 3.0)
217 s.set_property_model("rho", "linear", "C", [rho_v, rho_l - rho_v])
218 s.set_pressure_fcg(True, 600, 1e-10)
219
220 tprof = initial_temperature(n, ctr, r0, beta, rr, dT, mode=init)
221 s.add_scalar("T", k_l / rcp_l, "koren", sweeps)
222 for f in ("-x", "+x", "-y", "+y", "-z", "+z"):
223 s.set_scalar_bc("T", f, "dirichlet", dT)
224 s.set_field("T", np.asfortranarray(tprof))
225 s.enable_phase_change(rho_v, rho_l, h_lv)
226 # Ablations kept because they are the findings' evidence. enable_phase_change turns WO-R2's
227 # wisp guard OFF (the two are incompatible on a curved interface); PECLET_P23_WISP=1 puts it
228 # back, which is how the 48 % / dt-collapse row of the findings is reproduced.
229 import os
230 if os.environ.get("PECLET_P23_WISP"):
231 s.diagnostics.set_vof_wisp_eps(float(os.environ["PECLET_P23_WISP"]))
232 if os.environ.get("PECLET_P23_NO_EXACTRES"):
233 s.diagnostics.set_pressure_exact_residual(False)
234 if os.environ.get("PECLET_P23_NO_OUTFLOWRHO"):
235 s.diagnostics.set_outflow_rho_correction(False)
236 s.set_phase_change_thermal(True, "T", 0.0, k_v, k_l, 0.0)
237 s.diagnostics.set_phase_change_plane_dirichlet(plane)
238 if consistent:
239 s.set_phase_change_energy(True, rcp_v, rcp_l)
240 s.diagnostics.set_phase_change_energy_muscl(muscl)
241 s.diagnostics.set_phase_change_quadratic_fit(quad)
242 if area_mode is not None:
243 s.diagnostics.set_phase_change_area(AREA_MODES[area_mode])
244 if fitkap:
245 s.diagnostics.set_phase_change_fit_curvature(-2.0 / r0)
246 if carry:
247 # WO-P3f: conserve the enthalpy the interfacial cells' Dirichlet overwrite destroys.
248 s.diagnostics.set_phase_change_carry_conserve(True)
249 if p3g:
250 apply_p3g(s, **p3g)
251 budget = budget or 10 # the flux identity IS gate (c); never run item 1 blind
252 if budget:
253 # WO-P3f instrument (a): the energy budget of the energy solve, printed every `budget`
254 # steps. Off by default and inert in the solver when off.
255 s.diagnostics.set_phase_change_budget(True)
256
257 # ADAPTIVE dt on the solver's OWN interface-local Courant number. The a-priori estimate
258 # u = (1 - rho_v/rho_l) Rdot is the CONTINUUM interface speed and the discrete field overshoots
259 # it by up to ~2x on the first steps (the deposit shell is one cell thick and the per-cell PLIC
260 # areas of a freshly sub-sampled sphere are uneven), so a predicted dt trips the Weymouth-Yue
261 # cap. Predict, then correct from `vof_last_courant()`, and halve on a rejected step.
262 rows = []
263 vol_prev = None
264 vol_ratio = []
265 qprev = None # WO-P3g gate (c): the operator flux the LAST energy solve drew
266 worst_id = 0.0 # ... the worst |E_lat - q_op| / E_lat over the run (by construction)
267 worst_lag = 0.0 # ... and the worst |q_op + q_solve(n-1)| / q_op (the one-step lag)
268 tcur, nst, itmax, capped = t0, 0, 0, 0
269 Rd = beta * math.sqrt(alpha_l / tcur)
270 dt0 = 0.4 * cfl / max(Rd * (1.0 - rr), 1e-30)
271 dt = dt0
272 # dt-COLLAPSE GUARD. Without it a run whose interface velocity runs away halves dt forever and
273 # the outer loop never terminates (it cost this campaign an 8-hour silent hang). A step that
274 # needs less than 1e-4 of the initial dt is not a slow run, it is a diverged one; say so.
275 dtmin = 1e-4 * dt0
276 while tcur < te:
277 dt = min(dt, te - tcur)
278 if fitkap and rows:
279 # WO-P3f: the PRESCRIBED curvature of the bubble the run currently carries, from the
280 # liquid-volume deficit. An instrument (a known geometry), not an estimator.
281 s.diagnostics.set_phase_change_fit_curvature(-2.0 / rows[-1][1])
282 while True:
283 s.set_dt(dt)
284 try:
285 s.step()
286 break
287 except RuntimeError as ex:
288 if "Weymouth-Yue boundedness cap" not in str(ex):
289 raise
290 dt *= 0.5
291 if dt < dtmin:
292 raise RuntimeError(
293 f"vof_scriven: dt COLLAPSED below {dtmin:.3e} (initial {dt0:.3e}) at step "
294 f"{nst}, t = {tcur:.4f} of {te:.4f}, last CFL {s.diagnostics.vof_last_courant():.4g} — "
295 f"the interface velocity is running away, not the time step being small")
296 dt_used = dt
297 tcur += dt
298 nst += 1
299 if verbose and nst % 200 == 0:
300 print(f" [step {nst}: t {tcur:.4f}/{te:.4f}, dt {dt:.4e}, "
301 f"cfl {s.diagnostics.vof_last_courant():.4f}]", flush=True)
302 c_ = s.diagnostics.vof_last_courant()
303 dt *= min(1.3, max(0.5, cfl / max(c_, 1e-30)))
304 it = s.diagnostics.last_pressure_iterations()
305 itmax = max(itmax, it)
306 capped += 1 if it >= 600 else 0
307 vol = float(n) ** 3 - s.get_vof().sum()
308 rnum = (3.0 * vol / (4.0 * math.pi)) ** (1.0 / 3.0)
309 rex = 2 * beta * math.sqrt(alpha_l * tcur)
310 dg = s.diagnostics.phase_change_diagnostics()
311 # exact mdot = rho_v Rdot = rho_v beta sqrt(alpha_l/t)
312 mex = rho_v * beta * math.sqrt(alpha_l / tcur)
313 # WHERE the growth deficit sits (WO-P3b). `mdot_mean` is an unweighted mean over the
314 # interfacial CELLS, so it does not measure the integrated flux. The integral is
315 # `removed_volume` = sum mdot A dt / rho_l, and dividing it by the total PLIC area gives
316 # the AREA-AVERAGED mdot; comparing the area itself against 4 pi R^2 separates a flux
317 # error from a geometry error.
318 area = dg['interface_area']
319 # WO-P3e. `dg['interface_area']` is the area `pcBuildInterface` measured at the HEAD of
320 # this step, i.e. on the colour field BEFORE this step's regression and advection, while
321 # `rnum` below is read AFTER them -- so `area/(4 pi rnum^2)` compares two different times
322 # and is low by 2 dR/R, which at this scene's ~0.2 cells/step is 2.2 %. That is the whole
323 # of the "-2.15 % run area deficit" of WO-P3c/WO-P3d. `vof_interface_area()` recomputes on
324 # the CURRENT field, so `area_end` is the one that may be compared with `rnum`.
325 area_end = s.vof_interface_area()
326 meff = rho_l * dg['removed_volume'] / (dt_used * area) if area > 0 else float("nan")
327 arel = area / (4.0 * math.pi * rnum * rnum) - 1.0
328 arel_end = area_end / (4.0 * math.pi * rnum * rnum) - 1.0
329 # WO-P3e: the REGRESSION's own step size. `delta = mdot dt / rho_l` is the normal
330 # displacement the plane shift applies, and it is the number that decides whether the
331 # shift's O(delta/R) linearization can matter at all; `R_A = sqrt(A/4pi)` is the radius
332 # the interface SHEET carries, next to the radius the liquid-volume deficit carries.
333 delta = dg['removed_volume'] / area if area > 0 else float('nan')
334 r_a = math.sqrt(area / (4.0 * math.pi))
335 # WO-P3g gate (c). Item 1 defines mdot as the flux the energy operator itself transferred,
336 # so the latent heat this step BOOKS must equal the heat the LAST energy solve DREW, to
337 # round-off. `q_gfm + q_behind` is that heat (the Dirichlet couplings plus the
338 # second-order row's one-sided band rescaling); the one-step lag is the scheme's own --
339 # mdot is evaluated at the head of the step from the converged T of the step before.
340 e_lat_n = rho_l * dg['removed_volume'] * h_lv / dt_used
341 if p3g is not None and budget:
342 b_ = s.diagnostics.phase_change_budget()
343 if e_lat_n != 0.0:
344 # the BY-CONSTRUCTION half: the latent heat this step books IS the heat the
345 # operator's own rows draw on the fields it read.
346 worst_id = max(worst_id, abs(e_lat_n - dg['q_operator']) / abs(e_lat_n))
347 if qprev is not None and dg['q_operator'] != 0.0:
348 # the LAGGED half: the heat the LAST energy solve actually removed, against the
349 # latent heat this step books from the same converged T.
350 worst_lag = max(worst_lag, abs(dg['q_operator'] + qprev) / abs(dg['q_operator']))
351 qprev = b_['q_gfm'] + b_['q_behind']
352 if budget and (nst % budget == 0 or nst == 1) and verbose:
353 b = s.diagnostics.phase_change_budget()
354 # E_lat: the latent heat the REGRESSION booked this step (W). removed_volume is
355 # sum mdot A dt / rho_l, so rho_l removed_volume h_lv / dt = sum mdot A h_lv.
356 e_lat = rho_l * dg['removed_volume'] * h_lv / dt_used
357 q = b['q_gfm'] + b['q_behind'] # W, < 0 = heat drawn OUT of the liquid
358 dcls = (b['e_enter'] - b['e_leave']) / dt_used # W lost at the class changes
359 dovw = b['d_overwrite'] / dt_used # W injected by the Dirichlet rows
360 dovn = b['d_overwrite_new'] / dt_used
361 dH = (b['h_open_new'] - b['h_open']) / dt_used
362 print(f" [BUDGET step {nst:4d} t {tcur:9.4f} dt {dt_used:.3e}] "
363 f"E_lat {e_lat:12.5e} W q_gfm {q:12.5e} W (-q_gfm/E_lat "
364 f"{-q/e_lat if e_lat else float('nan'):8.5f})")
365 print(f" H_liq {b['h_liquid']:12.5e} H_open {b['h_open']:12.5e} "
366 f"dH_open/dt {dH:12.5e} W | class change: enter {b['e_enter']:11.4e} J "
367 f"leave {b['e_leave']:11.4e} J net {dcls:12.5e} W ({100*dcls/e_lat if e_lat else float('nan'):+7.3f} % of E_lat)")
368 led = s.diagnostics.phase_change_carry_ledger()
369 # the CONSERVATION identity of the option: what the deposit hands back plus what it
370 # could not place must equal what the overwrite destroys, exactly.
371 idn = (led['deposited'] + led['lost'] + b['d_overwrite'])
372 print(f" carry ledger: deposited {led['deposited']:12.5e} J "
373 f"lost {led['lost']:11.4e} J identity residual {idn:.3e} "
374 f"(rel {idn/b['d_overwrite'] if b['d_overwrite'] else 0.0:.2e})")
375 print(f" overwrite {dovw:12.5e} W (new cells {dovn:12.5e} W, "
376 f"{100*dovn/e_lat if e_lat else float('nan'):+7.3f} % of E_lat) | "
377 f"L->I {b['n_enter_liquid']:5d} G->I {b['n_enter_gas']:5d} "
378 f"I->L {b['n_leave_liquid']:5d} I->G {b['n_leave_gas']:5d} masked {b['n_masked']:6d}")
379 # WO-P3g audit: does the COLOUR FIELD deliver the volume the regression BOOKED?
380 # The gas volume can only change by the regression (sum dV, liquid removed) plus the net
381 # liquid flux through the outflow faces, so `dGas_measured / dGas_booked` separates a flux
382 # error (the books are wrong) from a transport error (the books are right and the field
383 # does not follow). `dGas_booked = removed_volume * rho_l/rho_g` -- the regression removes
384 # liquid at mdot A dt/rho_l and the divergence source expands the vapour to
385 # mdot A dt/rho_g.
386 gas_now = float(n) ** 3 - s.get_vof().sum()
387 if vol_prev is not None:
388 booked = dg['removed_volume'] * rho_l / rho_v
389 if booked != 0.0:
390 vol_ratio.append((gas_now - vol_prev) / booked)
391 vol_prev = gas_now
392 rows.append((tcur, rnum, rex, it, dg['mdot_mean'], mex, meff, arel,
393 dg['interface_cells'], delta, r_a, dg['deficit_cells'],
394 dg['redistributed'], arel_end, area_end))
395 d = s.diagnostics.phase_change_diagnostics()
396 # thickness of the thermal boundary layer at t0: where the exact profile reaches 99 % of dT
397 lo_, hi_ = r0, 200.0 * r0
398 for _ in range(200):
399 mid_ = 0.5 * (lo_ + hi_)
400 if scriven_T(mid_, r0, beta, rr, dT) > 0.99 * dT:
401 hi_ = mid_
402 else:
403 lo_ = mid_
404 dt99 = hi_ - r0
405 half = rows[len(rows) // 2:]
406 errs = [abs(r[1] - r[2]) / r[2] for r in half]
407 # GROWTH RATE vs OFFSET (WO-P3b). R = 2 beta sqrt(alpha t) <=> R^2 = 4 beta^2 alpha t, a
408 # STRAIGHT LINE through the origin. Fitting R_num^2 against t over the last half separates the
409 # two ways this gate can fail: a wrong growth RATE shows up in the slope (beta_eff != beta,
410 # i.e. the method), a deficit acquired in the first steps and then carried shows up as a
411 # negative INTERCEPT at the same slope (an effective time/radius offset, i.e. the start).
412 _t = np.array([r[0] for r in half])
413 _R2 = np.array([r[1] for r in half]) ** 2
414 _sl, _ic = np.polyfit(_t, _R2, 1)
415 beta_eff = math.sqrt(max(_sl, 0.0) / (4.0 * alpha_l))
416 t_off = -_ic / _sl if _sl > 0 else float("nan") # R^2 = 4 b^2 a (t - t_off)
417 R_off = rows[-1][1] - 2 * beta_eff * math.sqrt(alpha_l * rows[-1][0])
418 if verbose:
419 print(f" N = {n}^3 Ja = {ja:g} ratio {ratio:g} beta = {beta:.6f} "
420 f"R {r0:g} -> {r1:g} cells steps {nst} init {init} sub {sub}^3"
421 + (f" area mode {area_mode}" if area_mode is not None else ""))
422 print(f" init: thermal BL (99 % of dT) {dt99:.3f} cells, "
423 f"R0/(2 beta^2) = {r0/(2*beta*beta):.3f} cells; R0 from the colour field "
424 f"{r0meas:.5f} vs exact {r0:.5f} ({100*(r0meas-r0)/r0:+.4f} %)")
425 for k in list(range(0, len(rows), max(1, len(rows) // 8))) + [len(rows) - 1]:
426 t_, a, b_, it, mn_, mx_, me_, ar_, nc_, de_, ra_, dc_, rd_, ae_, aa_ = rows[k]
427 print(f" t {t_:10.4f} R_num {a:8.4f} R_exact {b_:8.4f} "
428 f"rel {100*(a-b_)/b_:+7.3f} % mdot {mn_:.5e} vs {mx_:.5e} "
429 f"({100*(mn_-mx_)/mx_:+7.3f} %) mdot_area {100*(me_-mx_)/mx_:+7.3f} % "
430 f"A/4piR^2 {100*ar_:+6.2f} % cells {nc_:6d} ({nc_/(4*math.pi*a*a):.3f}/h^2)"
431 f" iters {it}")
432 print(f" REGRESSION delta {de_:.5e} cells/step (delta/R "
433 f"{de_/a:.3e}), R_area {ra_:8.4f} ({100*(ra_-b_)/b_:+7.3f} % vs exact), "
434 f"clipped {dc_}, residue moved {rd_:.3e}; A_end/4piR^2 {100*ae_:+6.2f} % "
435 f"(R from A_end {math.sqrt(aa_/(4*math.pi)):8.4f}, {100*(math.sqrt(aa_/(4*math.pi))-b_)/b_:+7.3f} % vs exact)")
436 early = ", ".join(f"{100*(r[4]-r[5])/r[5]:+.2f}" for r in rows[:6])
437 print(f" EARLY mdot rel error, steps 1..6: {early} % "
438 f"(last {100*(rows[-1][4]-rows[-1][5])/rows[-1][5]:+.2f} %)")
439 _h = half
440 print(f" AREA-AVERAGED mdot rel error, last half: mean "
441 f"{100*np.mean([(r[6]-r[5])/r[5] for r in _h]):+.3f} %, first {100*(_h[0][6]-_h[0][5])/_h[0][5]:+.3f} %, "
442 f"last {100*(_h[-1][6]-_h[-1][5])/_h[-1][5]:+.3f} %; A/(4 pi R^2) - 1 mean "
443 f"{100*np.mean([r[7] for r in _h]):+.3f} % [STALE, WO-P3e]; "
444 f"A_end/(4 pi R^2) - 1 mean {100*np.mean([r[13] for r in _h]):+.3f} %")
445 print(f" R rel error at step 1 {100*(rows[0][1]-rows[0][2])/rows[0][2]:+.4f} %, "
446 f"at the half point {100*(half[0][1]-half[0][2])/half[0][2]:+.4f} %, "
447 f"at the end {100*(rows[-1][1]-rows[-1][2])/rows[-1][2]:+.4f} %")
448 print(f" GROWTH over the last half: beta_eff {beta_eff:.6f} vs beta {beta:.6f} "
449 f"({100*(beta_eff-beta)/beta:+.3f} %), implied time offset {t_off:+.5f} of t0 "
450 f"{t0:.5f} ({100*t_off/t0:+.2f} %), radius offset at the end {R_off:+.4f} cells")
451 print(f" max |dR|/R over the LAST HALF = {100*max(errs):.3f} % [gate 1 %]")
452 if vol_ratio:
453 _v = np.array(vol_ratio[len(vol_ratio) // 2:])
454 print(f" VOLUME AUDIT d(gas)/d(gas booked by the regression), last half: mean "
455 f"{_v.mean():.6f}, min {_v.min():.6f}, max {_v.max():.6f} "
456 f"[1 = the colour field delivers exactly what mdot booked]")
457 if p3g is not None and budget:
458 print(f" WO-P3g gate (c): worst |E_lat - q_op| / E_lat = {worst_id:.3e} "
459 f"[gate 1e-10], worst |q_op(n) + q_solve(n-1)| / q_op = {worst_lag:.3e}; "
460 f"q_orphan/q_op last step "
461 f"{dg['q_orphan']/dg['q_operator'] if dg['q_operator'] else 0.0:.3e}, "
462 f"mdot_fit/mdot_op last step "
463 f"{dg['mdot_fit']/(rho_l*dg['removed_volume']/(dt_used*area)) if area > 0 and dg['removed_volume'] else float('nan'):.6f}")
464 print(f" pressure iters max {itmax}/600 (capped {capped}), band_div "
465 f"{d['band_div']:.3e}, C in [{d['min_C']:.2e}, {d['max_C']:.6f}], "
466 f"T in [{d['T_min']:.3e}, {d['T_max']:.6f}], fallback {d['fallback_cells']}, "
467 f"unresolved {d['unresolved']:.1e}")
468 if capped:
469 print(" *** CAPPED pressure solve: rule 3b, this run is INVALID")
470 return max(errs), capped
471
472
473def plane_colour(n, nrm, sub=8, shift=0.37):
474 """Liquid fraction (C = 1 liquid) of the half space n.(x - centre) < shift, by sub^3
475 subsampling. The sub-cell shift matters: a grid-aligned plane placed exactly on a cell face
476 has NO mixed cell at all (CLAUDE.md, the VoF dt-limiter note), so the probe would read zero
477 interfacial cells and no area."""
478 nrm = np.asarray(nrm, dtype=float)
479 nrm = nrm / np.linalg.norm(nrm)
480 off = (np.arange(sub) + 0.5) / sub
481 q = (np.arange(n)[:, None] + off[None, :]).ravel() - 0.5 * n
482 d = nrm[0] * q[:, None, None] + nrm[1] * q[None, :, None] + nrm[2] * q[None, None, :] - shift
483 frac = (d < 0.0).reshape(n, sub, n, sub, n, sub).mean(axis=(1, 3, 5))
484 return np.asfortranarray(frac)
485
486
487def plane_colour_periodic(n, nrm, sub=8, shift=0.37):
488 """Liquid fraction of the PERIODIC tilted-plane family `0 <= (nrm . x) mod n < n/2`.
489
490 WO-P3d. A single half-space in a PERIODIC box is not a single plane: the wrap turns the
491 domain faces into a second interface, and a JOINED surface reconstruction reports it (measured:
492 the (1,1,0) row of `--area-probe=-2` comes out +5 % against the one-plane reference purely
493 because of the seam, while a per-cell area misses it since the seam has no mixed cell). The
494 repair is a scene that is periodic BY CONSTRUCTION: with an INTEGER normal `nrm`, the level
495 sets of `f = nrm . x (mod n)` are closed flat surfaces of the torus, and the co-area formula
496 gives their total area in closed form,
497
498 integral |grad f| dV = |nrm|_2 n^3 = n * A(one level) => A(one level) = |nrm|_2 n^2 ,
499
500 so the two level sets bounding `f < n/2` carry EXACTLY `2 |nrm|_2 n^2`. No edge convention, no
501 seam, no reference uncertainty — for (0,0,1) it is 2 n^2, for (1,1,0) it is 2 sqrt(2) n^2.
502 """
503 nrm = np.asarray(nrm, dtype=float)
504 off = (np.arange(sub) + 0.5) / sub
505 q = (np.arange(n)[:, None] + off[None, :]).ravel()
506 # the sub-cell shift is not cosmetic: with shift = 0 an integer normal puts the level set
507 # exactly THROUGH the cell centres of a 45 deg plane (x + y is an integer at a half-integer
508 # centre pair), which is a degenerate placement, and for (0,0,1) it puts it exactly on a cell
509 # FACE, where there is no mixed cell at all and every per-cell area is 0 by construction.
510 f = (nrm[0] * q[:, None, None] + nrm[1] * q[None, :, None] + nrm[2] * q[None, None, :]
511 - shift) % n
512 frac = (f < 0.5 * n).reshape(n, sub, n, sub, n, sub).mean(axis=(1, 3, 5))
513 return np.asfortranarray(frac)
514
515
516def cylinder_colour_chunked(n, ctr, R, sub):
517 """Liquid fraction of an INFINITE cylinder of radius R along z (a curved interface with ONE
518 non-zero principal curvature — the control that separates a metric error from a curvature
519 error), by sub^2 subsampling of the transverse plane. Exact in z by construction, so its
520 reference area 2 pi R n_z carries no z discretisation at all."""
521 off = (np.arange(sub) + 0.5) / sub
522 lo = max(0, int(math.floor(ctr - R - 2)))
523 hi = min(n, int(math.ceil(ctr + R + 2)))
524 q = (np.arange(lo, hi)[:, None] + off[None, :]).ravel() - ctr
525 d2 = q[:, None] ** 2 + q[None, :] ** 2
526 frac = (d2 < R * R).reshape(hi - lo, sub, hi - lo, sub).mean(axis=(1, 3))
527 c = np.ones((n, n, n), order="F")
528 c[lo:hi, lo:hi, :] = (1.0 - frac)[:, :, None]
529 return np.asfortranarray(c)
530
531
532def solenoidal_faces(n, amp, drift):
533 """A face velocity field that is EXACTLY discretely divergence-free on the staggered grid.
534
535 Two cellular (Taylor-Green) cells plus a uniform drift:
536
537 u = A sin(k x_f) cos(k y_c) , v = -A cos(k x_c) sin(k y_f) + A sin(k y_f) cos(k z_c) ,
538 w = -A cos(k y_c) sin(k z_f) , k = 2 pi / n , plus the constant `drift`.
539
540 The discrete divergence vanishes to the LAST BIT, not to O(h^2), and that matters: `advect_vof`
541 refuses a field whose `max_open_divergence_projected()` exceeds 1e-10 because Weymouth-Yue's
542 exact conservation is conditional on it. The identity is
543 `sin(k(x+h)) - sin(k x) = 2 sin(kh/2) cos(k(x + h/2))`, and `x + h/2` of a FACE is exactly the
544 CELL CENTRE the transverse factor is evaluated at — so the two axes' differences cancel term by
545 term for each cellular pair.
546 """
547 k = 2.0 * math.pi / n
548 f = np.arange(n, dtype=float) # face coordinate of the LOW face of cell i
549 c = np.arange(n, dtype=float) + 0.5 # cell centre
550 u = amp * (np.sin(k * f)[:, None, None] * np.cos(k * c)[None, :, None]
551 * np.ones(n)[None, None, :]) + drift[0]
552 v = (-amp * np.cos(k * c)[:, None, None] * np.sin(k * f)[None, :, None] * np.ones(n)[None, None, :]
553 + amp * np.ones(n)[:, None, None] * np.sin(k * f)[None, :, None] * np.cos(k * c)[None, None, :]
554 + drift[1])
555 w = (-amp * np.ones(n)[:, None, None] * np.cos(k * c)[None, :, None] * np.sin(k * f)[None, None, :]
556 + drift[2])
557 return (np.asfortranarray(u), np.asfortranarray(v), np.asfortranarray(w))
558
559
560def area_advect(n=128, R=16.0, sub=16, steps=100, cfl=0.2, amp=0.0, drift=(0.5, 0.25, 0.125),
561 modes=(0, 3, 4, 6, 7)):
562 """WO-P3d gate (b): is the JOINED area BOUNDED under Weymouth-Yue advection?
563
564 An a-priori probe measures a construction on an EXACT colour field. A running one never sees
565 one: WY leaves round-off residue in every cell its sweeps touch (down to 1e-300 -- the residue
566 the V4 curvature needed `interfaceEps` for and the W0 block container needed `bubbleEps` for),
567 and the interfacial-cell density climbs from the initialisation's value to ~1.5/h^2 within ten
568 steps. A per-cell area sums over those cells, so the question "does the area drift with the
569 WISP population" is a real one; a joined sheet is built from the C = 1/2 crossing and should be
570 blind to them. Both are measured here, on the SAME field, step by step.
571
572 `amp = 0` is the pure TRANSLATION control, where the exact answer stays `4 pi R^2` for ever.
573 """
574 ctr = 0.5 * n
575 s = pf.Solver(n, n, n)
576 s.set_rho(1.0)
577 s.set_mu(1e-3)
578 s.set_pressure_geometry(np.full((n, n, n), 1.0, order="F"))
579 s.enable_vof()
580 c0 = sphere_colour_chunked(n, ctr, R, sub)
581 s.set_vof(c0)
582 vol0 = float(n) ** 3 - c0.sum()
583 ref = 4.0 * math.pi * (3.0 * vol0 / (4.0 * math.pi)) ** (2.0 / 3.0)
584 u, v, w = solenoidal_faces(n, amp, drift)
585 s.set_velocity(0, u)
586 s.set_velocity(1, v)
587 s.set_velocity(2, w)
588 div = s.max_open_divergence_projected()
589 dt = cfl / max(s.vof_max_courant(), 1e-30)
590 print(f" ADVECTION area gate: {n}^3, sphere R = {R:g} (sub {sub}^3), {steps} WY steps at "
591 f"cfl {cfl:g} (dt {dt:.4g}), amp {amp:g}, drift {drift}")
592 print(f" max|div(open u)| of the prescribed field {div:.3e} (advect_vof gate 1e-10); "
593 f"reference 4 pi R^2 = {ref:.4f}")
594
595 def sample(tag):
596 row = {}
597 for m in modes:
598 s.diagnostics.set_phase_change_area(AREA_MODES[m])
599 row[m] = s.vof_interface_area()
600 cc = s.get_vof()
601 mixed = int(np.count_nonzero((cc > 0.0) & (cc < 1.0)))
602 wisp = int(np.count_nonzero(((cc > 0.0) & (cc < 1e-8)) | ((cc < 1.0) & (cc > 1 - 1e-8))))
603 vol = float(n) ** 3 - cc.sum()
604 print(f" {tag:>6} mixed {mixed:7d} wisps {wisp:7d} dV/V {(vol-vol0)/vol0:+.3e} "
605 + " ".join(f"m{m} {100*(row[m]/ref-1):+8.3f} %" for m in modes))
606 return row
607
608 hist = {m: [] for m in modes}
609 r = sample("0")
610 for m in modes:
611 hist[m].append(r[m])
612 for i in range(1, steps + 1):
613 s.advect_vof(dt)
614 if i % 10 == 0 or i == steps:
615 r = sample(str(i))
616 for m in modes:
617 hist[m].append(r[m])
618 print(" DRIFT over the run (max-min)/ref, and the last value:")
619 for m in modes:
620 h = np.array(hist[m])
621 print(f" mode {m}: span {100*(h.max()-h.min())/ref:7.3f} % "
622 f"last {100*(h[-1]/ref-1):+8.3f} % first {100*(h[0]/ref-1):+8.3f} %")
623
624
625def _mc_area(c):
626 """Marching-cubes area of the C = 1/2 level set — an INDEPENDENT geometric reference for the
627 summed PLIC area (it agrees with 4 pi R^2 to 0.5 % on the exact spheres below)."""
628 try:
629 from skimage import measure
630 except ImportError:
631 return float("nan")
632 v, f, _, _ = measure.marching_cubes(np.ascontiguousarray(1.0 - c), level=0.5)
633 return float(measure.mesh_surface_area(v, f))
634
635
636def area_probe(n, radii, ratio=100.0, sub=4, mode=None, shape="sphere"):
637 """A-PRIORI probe (WO-P3b): the summed PLIC interface area of an EXACT sphere, against 4 pi R^2.
638
639 No time stepping, no energy solve, no velocity — the exact sphere fractions are set, one
640 `apply_phase_change(0.0)` builds the interface, and `phase_change_diagnostics()['interface_area']`
641 is read. This isolates the geometry of `plicArea` + the MYC normals from every other part of
642 the rung, and it is the quantity the R(t) gate integrates: the bubble grows as int mdot dA.
643 """
644 rho_l, rho_v = 1.0, 1.0 / ratio
645 print(f" a-priori INTERFACE AREA probe, {n}^3, exact sphere fractions (sub = {sub}^3), "
646 f"area mode {0 if mode is None else mode}")
647 prev = None
648 for R in radii:
649 ctr = 0.5 * n
650 s = pf.Solver(n, n, n)
651 s.set_rho(rho_l)
652 s.set_mu(1e-3)
653 s.set_pressure_geometry(np.full((n, n, n), 1.0, order="F"))
654 s.enable_vof()
655 if R > 0 and shape == "cylinder":
656 c0 = cylinder_colour_chunked(n, ctr, R, sub)
657 # R from the volume deficit, exactly as the sphere row does: V = pi R^2 n_z
658 rm = math.sqrt((float(n) ** 3 - c0.sum()) / (math.pi * float(n)))
659 ref, lbl = 2.0 * math.pi * rm * float(n), "2 pi R n_z"
660 elif R > 0:
661 c0 = sphere_colour_chunked(n, ctr, R, sub)
662 ref, lbl = 4.0 * math.pi * ((3.0 * (float(n) ** 3 - c0.sum()) / (4.0 * math.pi))
663 ** (1.0 / 3.0)) ** 2, "4 pi R^2 "
664 elif R <= -5: # WO-P3d: the PERIODIC tilted-plane family, with an EXACT analytic area.
665 nrm = {-5: (0, 0, 1), -6: (1, 1, 0), -7: (1, 1, 1), -8: (1, 2, 3)}[int(R)]
666 c0 = plane_colour_periodic(n, nrm, sub=sub)
667 ref = 2.0 * math.sqrt(float(nrm[0] ** 2 + nrm[1] ** 2 + nrm[2] ** 2)) * float(n) ** 2
668 lbl = f"periodic {nrm}"
669 else: # R < 0 selects a PLANE: -1 = (0,0,1), -2 = (1,1,0), -3 = (1,1,1), -4 = (1,2,3).
670 # INDICATIVE ONLY: a plane meets the domain faces, so both the PLIC sum and the
671 # marching-cubes reference carry an edge effect. The SPHERE rows are the clean ones.
672 nrm = {-1: (0, 0, 1), -2: (1, 1, 0), -3: (1, 1, 1), -4: (1, 2, 3)}[int(R)]
673 c0 = plane_colour(n, nrm)
674 ref, lbl = float("nan"), f"plane {nrm}"
675 s.set_vof(c0)
676 s.set_property_model("rho", "linear", "C", [rho_v, rho_l - rho_v])
677 s.enable_phase_change(rho_v, rho_l, 1.0)
678 s.set_mass_flux_uniform(0.0)
679 if mode is not None:
680 s.diagnostics.set_phase_change_area(AREA_MODES[mode])
681 s.apply_phase_change(0.0)
682 d = s.diagnostics.phase_change_diagnostics()
683 mc = _mc_area(c0) if R > -5 else float("nan") # the periodic rows have an EXACT ref
684 if not (ref == ref):
685 ref = mc
686 rel = d['interface_area'] / ref - 1.0
687 ordr = ""
688 if prev is not None and R > 0 and prev[0] > 0:
689 ordr = f" order vs R = {prev[0]:g}: {math.log(abs(prev[1]) / abs(rel)) / math.log(R / prev[0]):.3f}"
690 # WO-P3d: `interface_area` is the sum over cells `pcIsInterfacial` accepts, i.e. what the
691 # flux integral can USE; the joined sheet also books area to cells the wisp predicate calls
692 # pure, and that part is dropped. Report both, and never quote the usable sum alone: the
693 # drop is a CANCELLATION against the sheet's own error, not accuracy.
694 orph = ""
695 if (mode or 0) >= 4:
696 tot = d['interface_area'] + d['area_orphan']
697 orph = (f" orphan {d['area_no_cascade_cells']} cells / {d['area_orphan']:.4f} h^2"
698 f" -> SHEET {tot:.4f} ({100*(tot/ref-1):+7.3f} %)")
699 print(f" R {R:6.2f} {lbl} ({d['interface_cells']:6d} cells"
700 + (f", HF {d['area_hf_cells']:6d} PV {d['area_pv_cells']:5d} "
701 f"none {d['area_no_cascade_cells']}" if mode else "") + orph + ") "
702 f"A_sum {d['interface_area']:12.4f} ref {ref:12.4f} "
703 f"marching cubes {mc:12.4f} ({100*(mc/ref-1):+6.3f} %) "
704 f"A_sum rel {100*rel:+7.3f} %{ordr}")
705 prev = (R, rel)
706
707
708
709def _sph_moments(w, ax, ctr, lmax=4):
710 """Real spherical-harmonic moments of a signed cell weight `w` (a removal density), returned
711 as the per-l RMS coefficient normalised by |sum w|.
712
713 The regression shifts each cell's plane along that cell's OWN normal, so a systematic
714 under-shift on (say) the octant diagonals would leave the bubble faceted. A cubic lattice can
715 only produce CUBIC-harmonic anisotropy, whose leading term is l = 4 (l = 2 vanishes by the
716 three mirror symmetries), so the l = 2 row is the control and the l = 4 row is the signal.
717 """
718 import numpy as np
719 X = (ax - ctr[0])[:, None, None] * np.ones((1, len(ax), len(ax)))
720 Y = (ax - ctr[1])[None, :, None] * np.ones((len(ax), 1, len(ax)))
721 Z = (ax - ctr[2])[None, None, :] * np.ones((len(ax), len(ax), 1))
722 r = np.sqrt(X * X + Y * Y + Z * Z)
723 r = np.where(r > 0, r, 1.0)
724 u, v, t = X / r, Y / r, Z / r
725 tot = float(w.sum())
726 out = {}
727 # l = 2: the five real components of the traceless quadrupole
728 q = [u * v, v * t, t * u, u * u - v * v, (3.0 * t * t - 1.0) / math.sqrt(3.0)]
729 out[2] = math.sqrt(sum(float((w * c).sum()) ** 2 for c in q) / 5.0)
730 # l = 4: the cubic invariant (the only l = 4 combination a cubic lattice can excite) plus the
731 # remaining independent components, measured as the RMS of the nine real l = 4 harmonics
732 # written in cartesian form (unnormalised — the RATIO to the isotropic reference is what is
733 # read, and both fields go through the identical expressions).
734 u2, v2, t2 = u * u, v * v, t * t
735 k = [u2 * u2 + v2 * v2 + t2 * t2 - 3.0 / 5.0,
736 u * v * (u2 - v2), v * t * (v2 - t2), t * u * (t2 - u2),
737 u * v * (7.0 * t2 - 1.0), v * t * (7.0 * u2 - 1.0), t * u * (7.0 * v2 - 1.0),
738 (u2 - v2) * (7.0 * t2 - 1.0), (35.0 * t2 * t2 - 30.0 * t2 + 3.0)]
739 out[4] = math.sqrt(sum(float((w * c).sum()) ** 2 for c in k) / 9.0)
740 return tot, out
741
742
743def _cubic_bins(n, ctr, nb=4):
744 """(ctr is a 3-tuple of cell-index centres.)"""
745 """Direction classes by the cubic invariant s = u_x^4 + u_y^4 + u_z^4, which runs from 1/3 on
746 the BODY DIAGONAL (111) through 1/2 on a FACE diagonal (110) to 1 on an AXIS (100). Binning
747 the removed volume by s and dividing by the same bins of the EXACT removal is a direct,
748 reference-free read-out of whether the regression is isotropic."""
749 import numpy as np
750 a = np.arange(n) + 0.5
751 x, y, z = a - ctr[0], a - ctr[1], a - ctr[2]
752 r2 = x[:, None, None] ** 2 + y[None, :, None] ** 2 + z[None, None, :] ** 2
753 r2 = np.where(r2 > 0, r2, 1.0)
754 s = (x[:, None, None] ** 4 + y[None, :, None] ** 4 + z[None, None, :] ** 4) / (r2 * r2)
755 edges = np.linspace(1.0 / 3.0, 1.0, nb + 1)
756 edges[-1] = 1.0 + 1e-9
757 return np.digitize(s, edges) - 1, edges
758
759
760def regress_probe(n=128, radii=(16.0,), deltas=(0.05, 0.1, 0.2), sub=16, modes=(0, 6),
761 ratio=100.0, advect_steps=0, drift=(0.5, 0.25, 0.125), cfl=0.2, nbin=4):
762 """WO-P3e — the a-priori INTERFACE REGRESSION probe. No time stepping, no energy solve, no
763 velocity: an exact sphere goes in, ONE `apply_phase_change(dt)` with a uniform prescribed mdot
764 runs the plane shift + clip-and-redistribute, and the colour field that comes out is compared
765 against the analytically known answer.
766
767 With `rho_l = 1` and `mdot = 1` the step's normal displacement is exactly `delta = dt`, so the
768 exact liquid volume removed from a sphere of radius R is the shell
769
770 dV = 4/3 pi ((R+delta)^3 - R^3) = 4 pi R^2 delta (1 + delta/R + delta^2/(3 R^2))
771
772 (the bubble is the GAS, so evaporation GROWS it: R -> R + delta), the new radius from the
773 volume deficit is R + delta exactly, and the new interfacial area is 4 pi (R+delta)^2. Those
774 three are the gate. The fourth read-out is ISOTROPY: the shift acts along each cell's own
775 normal with the cell's own area, so an under-shift on the octant diagonals would leave the
776 bubble faceted -- measured both as the ratio of the removed volume to the exact removed volume
777 in bins of the cubic invariant `u_x^4 + u_y^4 + u_z^4`, and as the l = 2 / l = 4 spherical
778 harmonic moments of the removal density.
779
780 `--regress-advect N` runs the sphere through N Weymouth-Yue steps of the PURE TRANSLATION field
781 of gate (b) first, so the fractions are the ones a running solver actually carries (WY re-creates
782 the sliver/wisp population within ten steps) while the exact reference stays a sphere.
783 """
784 ctr0 = 0.5 * n
785 rho_l, rho_v = 1.0, 1.0 / ratio
786 print(f" a-priori REGRESSION probe, {n}^3, exact sphere fractions (sub = {sub}^3), "
787 f"ratio {ratio:g}, {advect_steps} WY pre-steps")
788 for R in radii:
789 c0 = sphere_colour_chunked(n, ctr0, R, sub)
790 cstart, ctr = c0, (ctr0, ctr0, ctr0)
791 if advect_steps:
792 s = pf.Solver(n, n, n)
793 s.set_rho(1.0)
794 s.set_mu(1e-3)
795 s.set_pressure_geometry(np.full((n, n, n), 1.0, order="F"))
796 s.enable_vof()
797 s.set_vof(c0)
798 u, v, w = solenoidal_faces(n, 0.0, drift)
799 s.set_velocity(0, u)
800 s.set_velocity(1, v)
801 s.set_velocity(2, w)
802 dta = cfl / max(s.vof_max_courant(), 1e-30)
803 for _ in range(advect_steps):
804 s.advect_vof(dta)
805 cstart = s.get_vof()
806 ctr = tuple(ctr0 + d * dta * advect_steps for d in drift)
807 del s
808 vg0 = float(n) ** 3 - cstart.sum()
809 R0 = (3.0 * vg0 / (4.0 * math.pi)) ** (1.0 / 3.0)
810 mixed0 = int(np.count_nonzero((cstart > 0.0) & (cstart < 1.0)))
811 # the exact colour field the pre-steps SHOULD have produced (a translated sphere), as the
812 # control on how far the advected fractions are from exact
813 cex0 = (sphere_colour(n, ctr[0], ctr[1], ctr[2], R, sub=sub) if advect_steps else c0)
814 l1 = float(np.abs(cstart - cex0).sum())
815 print(f" R {R:g}: R0 from the volume {R0:.5f} ({100*(R0-R)/R:+.4f} %), mixed cells "
816 f"{mixed0}, |C - C_exact|_1 {l1:.4e}")
817 bins, edges = _cubic_bins(n, ctr, nbin)
818 for mode in modes:
819 for delta in deltas:
820 s = pf.Solver(n, n, n)
821 s.set_rho(rho_l)
822 s.set_mu(1e-3)
823 s.set_pressure_geometry(np.full((n, n, n), 1.0, order="F"))
824 s.enable_vof()
825 s.set_vof(cstart)
826 s.set_property_model("rho", "linear", "C", [rho_v, rho_l - rho_v])
827 s.enable_phase_change(rho_v, rho_l, 1.0)
828 s.diagnostics.set_phase_change_area(AREA_MODES[mode])
829 s.set_mass_flux_uniform(1.0)
830 a_bef = s.vof_interface_area()
831 s.apply_phase_change(delta)
832 d = s.diagnostics.phase_change_diagnostics()
833 c1 = s.get_vof()
834 a_aft = s.vof_interface_area()
835 del s
836 vg1 = float(n) ** 3 - c1.sum()
837 R1 = (3.0 * vg1 / (4.0 * math.pi)) ** (1.0 / 3.0)
838 rem = cstart - c1
839 removed = float(rem.sum())
840 rex = 4.0 * math.pi * R0 * R0 * delta * (1.0 + delta / R0
841 + delta * delta / (3.0 * R0 * R0))
842 R1x = R0 + delta
843 print(f" mode {mode} delta {delta:6.3f} "
844 f"dV {removed:12.6f} vs exact {rex:12.6f} ({100*(removed/rex-1):+8.4f} %) "
845 f"R1 {R1:9.5f} vs {R1x:9.5f} ({100*(R1-R1x)/R1x:+8.5f} %)")
846 print(f" A_before {a_bef:11.4f} "
847 f"({100*(a_bef/(4*math.pi*R0*R0)-1):+7.3f} %) "
848 f"A_after {a_aft:11.4f} ({100*(a_aft/(4*math.pi*R1x*R1x)-1):+7.3f} %) "
849 f"A_regr_used {d['interface_area']:11.4f} removed_vol(diag) "
850 f"{d['removed_volume']:.6f}")
851 pure_l = int(np.count_nonzero((cstart >= 1.0) & (c1 < 1.0)))
852 pure_g = int(np.count_nonzero((cstart <= 0.0) & (c1 > 0.0)))
853 emptied = int(np.count_nonzero((cstart > 0.0) & (c1 <= 0.0)))
854 print(f" CENSUS clipped at 0 {d['deficit_cells']:6d}, "
855 f"at 1 {d['excess_cells']:5d}, |residue| moved {d['redistributed']:.6e}, "
856 f"unresolved {d['unresolved']:.2e}; cells emptied {emptied}, pure LIQUID "
857 f"cells touched {pure_l}, pure GAS cells touched {pure_g}; "
858 f"C in [{d['min_C']:.3e}, {d['max_C']:.6f}]")
859 if bins is None:
860 continue
861 cex1 = (sphere_colour_chunked(n, ctr0, R + delta, sub) if not advect_steps
862 else sphere_colour(n, ctr[0], ctr[1], ctr[2], R + delta, sub=sub))
863 ex = cex0 - cex1
864 rat = []
865 for b in range(nbin):
866 m = bins == b
867 se, sa = float(ex[m].sum()), float(rem[m].sum())
868 rat.append(sa / se if se != 0 else float("nan"))
869 lbl = " ".join(f"s<{edges[b+1]:.3f} {100*(rat[b]-1):+7.3f} %" for b in range(nbin))
870 print(f" ISOTROPY (removed/exact by cubic bin, "
871 f"axis=1 body-diag=1/3): {lbl}")
872 ax = np.arange(n) + 0.5
873 t_a, m_a = _sph_moments(rem, ax, ctr)
874 t_e, m_e = _sph_moments(ex, ax, ctr)
875 t_d, m_d = _sph_moments(rem - ex, ax, ctr)
876 print(f" MOMENTS l2/l0: actual {m_a[2]/abs(t_a):.3e} "
877 f"exact {m_e[2]/abs(t_e):.3e}; l4/l0: actual {m_a[4]/abs(t_a):.3e} exact "
878 f"{m_e[4]/abs(t_e):.3e}; residual l0 {t_d/t_e:+.3e} l2 "
879 f"{m_d[2]/abs(t_e):.3e} l4 {m_d[4]/abs(t_e):.3e}")
880
881
882def _mdot_scene(n, R, ja, ratio, sub, alpha_l, area_mode, quad, plane, geom, dt, prof, kap=False,
883 p3g=None):
884 """One a-priori mdot evaluation: `geom` x `prof`, the 2x2 that separates the two curvatures.
885
886 `geom` = 'sphere' (the exact sphere, sub^3 fractions) or 'plane' (a flat interface normal to x
887 at a sub-cell offset, the same sub^3 fractions). `prof` = 'scriven' (Scriven's similarity
888 profile of a bubble of radius R at the matching time, as a function of the signed distance to
889 the interface -- so the sphere and plane rows carry IDENTICAL T', T'' at the interface) or
890 'linear' (T = T'(R) d exactly, zero profile curvature).
891
892 ONE `apply_phase_change(dt)` evaluates the shipped estimator: no energy solve, no velocity, no
893 time stepping. The only things measured are the one-sided gradient fit and the area on that
894 field. `plane x linear` is the exactness control (the fit's model is the exact state);
895 `sphere x linear` isolates the INTERFACE's curvature; `plane x scriven` isolates the PROFILE's
896 curvature and its resolution; `sphere x scriven` is the run's own configuration.
897 """
898 rr = 1.0 / ratio
899 beta = scriven_beta(ja, rr)
900 t = (R / (2 * beta)) ** 2 / alpha_l
901 rho_l, rho_v = 1.0, rr
902 cpl = 1.0
903 k_l = alpha_l * rho_l * cpl
904 rcp_l, rcp_v = rho_l * cpl, rho_v * cpl
905 k_v = k_l / ratio
906 dT = 1.0
907 h_lv = rho_l * cpl * dT / (rho_v * ja)
908 ctr = 0.5 * n
909 Rdot = beta * math.sqrt(alpha_l / t)
910 mdot_ex = rho_v * Rdot
911 gradT = mdot_ex * h_lv / k_l # dT/dr at the interface, K/cell
912
913 ax = (np.arange(n) + 0.5) - ctr
914 shift = 0.37
915 if geom == "sphere":
916 c0 = sphere_colour_chunked(n, ctr, R, sub)
917 rad = np.sqrt(ax[:, None, None] ** 2 + ax[None, :, None] ** 2 + ax[None, None, :] ** 2)
918 Rmeas = (3.0 * (float(n) ** 3 - c0.sum()) / (4.0 * math.pi)) ** (1.0 / 3.0)
919 ref_area = 4.0 * math.pi * Rmeas * Rmeas
920 d = rad - R # signed distance, + into the LIQUID
921 else:
922 # a flat interface normal to x at x = ctr + shift, LIQUID on the +x side (C = 1 liquid).
923 off = (np.arange(sub) + 0.5) / sub
924 q = (np.arange(n)[:, None] + off[None, :]).ravel() - ctr - shift
925 frac = (q > 0.0).reshape(n, sub).mean(axis=1)
926 c0 = np.asfortranarray(np.repeat(frac, n * n).reshape(n, n, n))
927 d = (ax - shift)[:, None, None] * np.ones((1, n, n))
928 Rmeas = R
929 ref_area = float(n) ** 2
930 if prof == "linear":
931 tprof = np.asfortranarray(np.where(d > 0.0, gradT * d, 0.0))
932 else:
933 tprof = np.asfortranarray(np.vectorize(lambda x: scriven_T(R + x, R, beta, rr, dT))(
934 np.maximum(d, 0.0)))
935
936 s = pf.Solver(n, n, n)
937 s.set_rho(rho_l)
938 s.set_mu(1e-3)
939 s.set_pressure_geometry(np.full((n, n, n), 1.0, order="F"))
940 s.enable_vof()
941 s.set_vof(c0)
942 s.set_property_model("rho", "linear", "C", [rho_v, rho_l - rho_v])
943 s.add_scalar("T", k_l / rcp_l, "koren", 1)
944 for f in ("-x", "+x", "-y", "+y", "-z", "+z"):
945 s.set_scalar_bc("T", f, "neumann", 0.0) # Neumann: the fit reads +-2 cells only
946 s.set_field("T", tprof)
947 s.enable_phase_change(rho_v, rho_l, h_lv)
948 s.set_phase_change_thermal(True, "T", 0.0, k_v, k_l, 0.0)
949 s.diagnostics.set_phase_change_plane_dirichlet(plane)
950 s.set_phase_change_energy(True, rcp_v, rcp_l)
951 s.diagnostics.set_phase_change_quadratic_fit(quad)
952 if area_mode is not None:
953 s.diagnostics.set_phase_change_area(AREA_MODES[area_mode])
954 if kap:
955 # WO-P3f: the curvature-corrected sample distance, with kappa = div(n) PRESCRIBED from the
956 # known geometry (-2/R for a gas sphere; 0 for the flat control).
957 s.diagnostics.set_phase_change_fit_curvature(-2.0 / R if geom == "sphere" else 0.0)
958 s.diagnostics.set_phase_change_budget(True) # WO-P3f: also read the GFM heat the SAME fields draw
959 if p3g:
960 apply_p3g(s, **p3g)
961 s.apply_phase_change(dt)
962 dg = s.diagnostics.phase_change_diagnostics()
963 bud = s.diagnostics.phase_change_budget()
964 md = np.asarray(s.get_field("mdot"))
965 cc = np.asarray(c0)
966 iface = (cc > 1e-12) & (cc < 1.0 - 1e-12)
967 A = dg['interface_area']
968 m_area = rho_l * dg['removed_volume'] / (dt * A) if A > 0 else float('nan')
969 # the two interfacial FLUXES on the same fields: the one the regression books
970 # (`mdot h_lv A_Gamma`, from the least-squares one-sided fit) and the one the energy solve's
971 # plane-anchored rows would actually draw (`q_gfm`), against the EXACT `mdot_ex h_lv A_exact`.
972 e_lat = rho_l * dg['removed_volume'] * h_lv / dt
973 q_ex = mdot_ex * h_lv * ref_area
974 return dict(beta=beta, t=t, Rdot=Rdot, mdot_ex=mdot_ex, area=A, ref_area=ref_area,
975 m_area=m_area, cells=int(dg['interface_cells']), m_cell=md[iface],
976 Rmeas=Rmeas, gradT=gradT, bl=dT / gradT, e_lat=e_lat,
977 q_gfm=bud['q_gfm'] + bud['q_behind'], q_ex=q_ex, mdot_fit=dg['mdot_fit'],
978 q_op=dg['q_operator'], q_orphan=dg['q_orphan'],
979 a_orphan=dg['area_orphan'], n_noc=dg['area_no_cascade_cells'],
980 delta=dg['removed_volume'] / A if A > 0 else float('nan'),
981 clipped=int(dg['deficit_cells']))
982
983
984def mdot_probe(n, radii, ja=0.5, ratio=100.0, sub=16, alpha_l=1.0, area_mode=None, quad=True,
985 plane=True, dt=None, geoms=("sphere", "plane"), profs=("scriven",), kap=False,
986 p3g=None):
987 """WO-P3f gate (b): the a-priori CURVATURE BIAS of the one-sided mdot fit.
988
989 For each radius the exact sphere carries Scriven's similarity profile at the matching time and
990 the shipped estimator is evaluated ONCE. The area-weighted mdot is compared with the analytic
991 `mdot = rho_v beta sqrt(alpha/t)`; the flat-interface row with the SAME profile is the control.
992 """
993 print(f" a-priori MDOT probe, {n}^3, Ja {ja:g}, ratio {ratio:g}, exact fractions "
994 f"(sub = {sub}^3), area mode {0 if area_mode is None else area_mode}, "
995 f"quadratic fit {quad}, plane Dirichlet {plane}, curvature-corrected distance {kap}"
996 f", P3g {p3g}")
997 for prof in profs:
998 for geom in geoms:
999 prev = None
1000 for R in radii:
1001 step = dt
1002 if step is None:
1003 # the RUN's own regression step: delta = mdot dt / rho_l ~ 1e-3 cells
1004 rr = 1.0 / ratio
1005 b_ = scriven_beta(ja, rr)
1006 t_ = (R / (2 * b_)) ** 2 / alpha_l
1007 step = 1e-3 / max(rr * b_ * math.sqrt(alpha_l / t_), 1e-300)
1008 r = _mdot_scene(n, R, ja, ratio, sub, alpha_l, area_mode, quad, plane, geom, step,
1009 prof, kap=kap, p3g=p3g)
1010 rel = r['m_area'] / r['mdot_ex'] - 1.0
1011 relA = r['area'] / r['ref_area'] - 1.0
1012 mc = r['m_cell']
1013 cell_rel = mc / r['mdot_ex'] - 1.0
1014 ordr = ""
1015 if prev is not None:
1016 ordr = (f" order in h/R vs R = {prev[0]:g}: "
1017 f"{math.log(abs(prev[1]) / abs(rel)) / math.log(R / prev[0]):.3f}")
1018 print(f" {prof:7s} {geom:6s} R {R:6.2f} t {r['t']:10.4f} "
1019 f"cells {r['cells']:6d} A/A_ref {100*relA:+7.3f} % BL {r['bl']:6.3f} cells "
1020 f"delta {r['delta']:.3e} clipped {r['clipped']}")
1021 print(f" mdot_area {r['m_area']:.6e} vs exact {r['mdot_ex']:.6e} "
1022 f"rel {100*rel:+7.3f} %{ordr}")
1023 print(f" q_op {r['q_op']:.6e} E_lat {r['e_lat']:.6e} "
1024 f"(E_lat-q_op)/E_lat {(r['e_lat']-r['q_op'])/r['e_lat'] if r['e_lat'] else float('nan'):+.3e}"
1025 f" q_orphan/q_op {r['q_orphan']/r['q_op'] if r['q_op'] else 0.0:+.3e}"
1026 f" no-cascade cells {r['n_noc']} orphan area {r['a_orphan']:.4e}")
1027 print(f" GFM heat -q_gfm {-r['q_gfm']:.6e} vs exact "
1028 f"{r['q_ex']:.6e} (rel {100*(-r['q_gfm']/r['q_ex']-1):+7.3f} %), "
1029 f"-q_gfm/E_lat {-r['q_gfm']/r['e_lat']:8.5f}")
1030 print(f" per-CELL mdot/exact - 1: mean {100*cell_rel.mean():+7.3f} "
1031 f"% sd {100*cell_rel.std():6.3f} % p05 {100*np.percentile(cell_rel,5):+7.3f} "
1032 f"p50 {100*np.percentile(cell_rel,50):+7.3f} p95 "
1033 f"{100*np.percentile(cell_rel,95):+7.3f} %")
1034 prev = (R, rel)
1035
1036
1037def carry_probe(n, steps, ratio=100.0, carry=False, sub=16, grad=0.05, mdot=2.0e-3, p3g=None):
1038 """WO-P3f, the a-priori gate of `--carry`: a PLANAR interface swept through cells at a
1039 PRESCRIBED mass flux, with a linear superheat profile in the liquid.
1040
1041 Everything about the scene is chosen so the ONLY enthalpy book-keeping under test is the
1042 interfacial cells' Dirichlet overwrite: `mdot` is prescribed (no gradient fit), the phase
1043 conductivities are ~0 (no diffusion), the interface is flat (no curvature) and the profile is
1044 linear (no profile curvature). Per step the instrument prints the identity the option must
1045 satisfy exactly -- what the deposit hands back plus what it could not place equals what the
1046 overwrite destroys -- and the trajectory of the total enthalpy `sum rho c_p(C) (T - T_sat)`
1047 over the inner cells, which is the quantity the option exists to stop leaking.
1048 """
1049 rho_l, rho_v = 1.0, 1.0 / ratio
1050 rcp_l, rcp_v = 1.0, rho_v
1051 k_l, k_v = 1.0, 1.0 / ratio
1052 h_lv = k_l * grad / mdot # so the FITTED mass flux is exactly `mdot`
1053 ctr = 0.5 * n
1054 shift = round(0.37 * sub) / sub
1055 off = (np.arange(sub) + 0.5) / sub
1056 q = (np.arange(n)[:, None] + off[None, :]).ravel() - ctr - shift
1057 frac = (q > 0.0).reshape(n, sub).mean(axis=1)
1058 c0 = np.asfortranarray(np.repeat(frac, n * n).reshape(n, n, n))
1059 ax = (np.arange(n) + 0.5) - ctr - shift
1060 d = ax[:, None, None] * np.ones((1, n, n))
1061 tprof = np.asfortranarray(np.where(d > 0.0, grad * d, 0.0))
1062
1063 s = pf.Solver(n, n, n)
1064 s.set_rho(rho_l)
1065 s.set_mu(1e-3)
1066 for f in ("-x", "+x", "-y", "+y", "-z", "+z"):
1067 s.set_domain_bc(f, "outflow")
1068 s.set_pressure_geometry(np.full((n, n, n), 1.0, order="F"))
1069 s.enable_vof()
1070 s.set_vof(c0)
1071 s.set_property_model("rho", "linear", "C", [rho_v, rho_l - rho_v])
1072 s.set_pressure_fcg(True, 600, 1e-10)
1073 s.add_scalar("T", k_l / rcp_l, "koren", 50)
1074 for f in ("-x", "+x", "-y", "+y", "-z", "+z"):
1075 s.set_scalar_bc("T", f, "neumann", 0.0)
1076 s.set_field("T", tprof)
1077 s.enable_phase_change(rho_v, rho_l, h_lv)
1078 # thermal mdot, but on a FLAT interface with an exactly LINEAR profile, which is the one
1079 # configuration in which the one-sided fit is exact (WO-P3f's `plane x linear` control): the
1080 # mass flux is then `mdot` by construction and nothing but the enthalpy book-keeping is under
1081 # test. A PRESCRIBED mdot cannot be used here -- `set_mass_flux_uniform` turns the thermal
1082 # path (and with it the per-cell Dirichlet set) OFF.
1083 s.set_phase_change_thermal(True, "T", 0.0, k_v, k_l, 0.0)
1084 s.set_phase_change_energy(True, rcp_v, rcp_l)
1085 s.diagnostics.set_phase_change_area("sheet-plic-centroid")
1086 s.diagnostics.set_phase_change_budget(True)
1087 if carry:
1088 s.diagnostics.set_phase_change_carry_conserve(True)
1089 if p3g:
1090 apply_p3g(s, **p3g)
1091 print(f" WO-P3f CARRY probe, {n}^3, planar interface, prescribed mdot {mdot:g}, linear "
1092 f"superheat grad {grad:g}/cell, k_l = {k_l:g}, h_lv = {h_lv:g}, carry {carry}")
1093 # keep the interface-local Courant number well under the Weymouth-Yue cap: the deposit
1094 # drives |u| ~ mdot (1/rho_v - 1/rho_l).
1095 dt = 0.1 / (mdot * (1.0 / rho_v - 1.0 / rho_l))
1096 hprev = None
1097 qpr = None
1098 for k in range(steps):
1099 s.set_dt(dt)
1100 s.step()
1101 b = s.diagnostics.phase_change_budget()
1102 led = s.diagnostics.phase_change_carry_ledger()
1103 idn = led['deposited'] + led['lost'] + b['d_overwrite']
1104 T = np.asarray(s.get_field("T"))
1105 C = np.asarray(s.get_vof())
1106 H = float(np.sum((rcp_v * (1.0 - C) + rcp_l * C) * T))
1107 dH = float("nan") if hprev is None else H - hprev
1108 hprev = H
1109 dgp = s.diagnostics.phase_change_diagnostics()
1110 e_lat_p = rho_l * dgp['removed_volume'] * h_lv / dt
1111 qop = b['q_gfm'] + b['q_behind']
1112 print(f" step {k+1:3d} E_lat {e_lat_p:12.5e} W q_op(build) "
1113 f"{dgp['q_operator']:12.5e} W IDENTITY (E_lat - q_op)/E_lat "
1114 f"{(e_lat_p - dgp['q_operator'])/e_lat_p if e_lat_p else float('nan'):+.3e} | "
1115 f"solve q_gfm+q_behind {qop:12.5e} W, vs the build's flux "
1116 f"{(dgp['q_operator'] + qpr)/dgp['q_operator'] if (qpr is not None and dgp['q_operator']) else float('nan'):+.3e}")
1117 qpr = qop
1118 print(f" step {k+1:3d} overwrite {b['d_overwrite']:12.5e} J deposited "
1119 f"{led['deposited']:12.5e} lost {led['lost']:11.4e} IDENTITY {idn:.3e} "
1120 f"(rel {idn/b['d_overwrite'] if b['d_overwrite'] else 0.0:.2e}) "
1121 f"H {H:.10e} dH {dH:+.5e} q_gfm {b['q_gfm']:.3e}")
1122
1123
1124def main():
1125 ap = argparse.ArgumentParser()
1126 ap.add_argument("--n", type=int, default=128)
1127 ap.add_argument("--ja", type=str, default="0.5,2,10")
1128 ap.add_argument("--ratio", type=float, default=10.0)
1129 ap.add_argument("--r0", type=float, default=6.0)
1130 ap.add_argument("--r1", type=float, default=20.0)
1131 ap.add_argument("--sweeps", type=int, default=200)
1132 ap.add_argument("--cfl", type=float, default=0.2,
1133 help="target interface-local Courant number of the adaptive dt (WO-P3b uses "
1134 "it as the temporal-refinement ablation of the growth-rate deficit)")
1135 ap.add_argument("--no-plane", action="store_true")
1136 ap.add_argument("--no-consistent", action="store_true")
1137 ap.add_argument("--no-quad", action="store_true")
1138 ap.add_argument("--muscl", action="store_true")
1139 ap.add_argument("--init", choices=("similarity", "cellavg", "uniform"), default="similarity",
1140 help="initial T(r, t0): Scriven's similarity profile at the cell centre "
1141 "(default, = the shipped behaviour), the same profile cell-averaged, or "
1142 "a uniform superheat with a sharp bubble (the classic trap; control)")
1143 ap.add_argument("--sub", type=int, default=4,
1144 help="sub^3 subsampling of the RUN's initial sphere colour field (WO-P3c: "
1145 "sub = 4 drops a quarter of the interfacial cells and 6 %% of the area)")
1146 ap.add_argument("--area-mode", type=int, default=None,
1147 help="0 = PLIC/MYC area (rung P0/P1), 1 = cascade metric, 2 = cascade normal, "
1148 "3 = cascade footprint; WO-P3d 4..7 = the JOINED marching-tetrahedra "
1149 "sheet (4 C=1/2 centroid, 5 C=1/2 split, 6 PLIC-distance centroid, "
1150 "7 PLIC-distance split)")
1151 ap.add_argument("--area-shape", choices=("sphere", "cylinder"), default="sphere",
1152 help="what the area probe puts in the box (the cylinder has one zero "
1153 "principal curvature and an exact z direction)")
1154 ap.add_argument("--area-sub", type=int, default=4,
1155 help="sub^3 subsampling of the area probe's EXACT sphere fractions (WO-P3c: "
1156 "the sliver cells a coarse sub drops are what WO-P3b measured)")
1157 ap.add_argument("--area-probe", type=str, default="",
1158 help="comma-separated radii in cells: run the a-priori PLIC-area probe on an "
1159 "exact sphere instead of the growth run, and exit")
1160 ap.add_argument("--area-advect", action="store_true",
1161 help="WO-P3d gate (b): is the area BOUNDED under Weymouth-Yue advection? "
1162 "Runs a sphere through a solenoidal field (or a pure translation with "
1163 "--advect-amp 0) and prints every area mode on the SAME field beside the "
1164 "mixed-cell and wisp census")
1165 ap.add_argument("--advect-amp", type=float, default=0.0)
1166 ap.add_argument("--advect-steps", type=int, default=100)
1167 ap.add_argument("--advect-r", type=float, default=16.0)
1168 ap.add_argument("--regress-probe", type=str, default="",
1169 help="WO-P3e: comma-separated radii in cells — run the a-priori INTERFACE "
1170 "REGRESSION probe (one apply_phase_change at a uniform mdot on an exact "
1171 "sphere, against the analytic shell volume / radius / area / isotropy) "
1172 "instead of the growth run, and exit")
1173 ap.add_argument("--regress-delta", type=str, default="0.05,0.1,0.2",
1174 help="WO-P3e: comma-separated normal displacements per step, in cells")
1175 ap.add_argument("--regress-modes", type=str, default="0,6",
1176 help="WO-P3e: comma-separated set_phase_change_area modes to run the probe on")
1177 ap.add_argument("--regress-advect", type=int, default=0,
1178 help="WO-P3e: Weymouth-Yue pre-steps (pure translation) before the regression "
1179 "step, so the fractions are advection-realistic")
1180 ap.add_argument("--fit-curvature", action="store_true",
1181 help="WO-P3f: correct the one-sided fits' sample distances for the interface "
1182 "curvature, with kappa = -2/R PRESCRIBED from the known geometry")
1183 ap.add_argument("--carry", action="store_true",
1184 help="WO-P3f: turn on set_phase_change_carry_conserve (the enthalpy the "
1185 "interfacial cells' Dirichlet overwrite destroys is returned to the "
1186 "phase it came from)")
1187 ap.add_argument("--carry-probe", type=int, default=0,
1188 help="WO-P3f a-priori gate for --carry: N steps of a PLANAR interface swept "
1189 "at a prescribed mdot through a linear superheat profile, checking the "
1190 "conservation identity per step; then exit")
1191 ap.add_argument("--budget", type=int, default=0,
1192 help="WO-P3f instrument (a): print the ENERGY BUDGET of the energy solve every "
1193 "N steps (0 = off, the shipped behaviour)")
1194 ap.add_argument("--mdot-probe", type=str, default="",
1195 help="WO-P3f gate (b): comma-separated radii in cells - run the a-priori MDOT "
1196 "probe (Scriven's similarity profile on an EXACT sphere, one "
1197 "apply_phase_change, no energy solve) against the analytic mdot, with a "
1198 "flat-interface control carrying the same profile; then exit")
1199 ap.add_argument("--mdot-geom", type=str, default="sphere,plane",
1200 help="WO-P3f: which scenes the mdot probe runs (sphere and/or plane)")
1201 ap.add_argument("--mdot-prof", type=str, default="scriven",
1202 help="WO-P3f: which temperature profiles the mdot probe runs "
1203 "('scriven' and/or 'linear'; linear x plane is the exactness control)")
1204 ap.add_argument("--mdot-dt", type=float, default=0.0,
1205 help="WO-P3f: fixed regression dt for the mdot probe (0 = pick it so the "
1206 "normal displacement is the run's own 1e-3 cells)")
1207 ap.add_argument("--energy-order", type=int, default=1,
1208 help="WO-P3g: 1 = the shipped interfacial energy operator; 2 = the "
1209 "second-order package (operator-flux mdot + Gibou-Fedkiw row + "
1210 "curvature-consistent distances + the conserving Dirichlet overwrite)")
1211 ap.add_argument("--op-mdot", type=int, default=None,
1212 help="WO-P3g item 1 override (0/1): mdot from the energy operator's own flux")
1213 ap.add_argument("--gfm-order", type=int, default=None,
1214 help="WO-P3g item 2 override (1/2): the ghost-fluid row's order")
1215 ap.add_argument("--curv-dist", type=int, default=None,
1216 help="WO-P3g item 3 override (0/1): curvature-consistent distances")
1217 ap.add_argument("--carry-opt", type=int, default=None,
1218 help="WO-P3g item 4 override (0/1): the conserving Dirichlet overwrite")
1219 ap.add_argument("--deposit-fallback", type=int, default=None,
1220 help="WO-P3f open item 6 (0/1): give an interfacial cell whose two "
1221 "along-the-normal deposit candidates are both interfacial a target from "
1222 "the 5^3 box, instead of leaving the divergence source in place")
1223 a = ap.parse_args()
1224 p3g = None
1225 if a.energy_order != 1 or a.op_mdot is not None or a.gfm_order is not None or \
1226 a.curv_dist is not None or a.carry_opt is not None or a.deposit_fallback is not None:
1227 p3g = dict(order=a.energy_order, op_mdot=a.op_mdot, gfm_order=a.gfm_order,
1228 curv_dist=a.curv_dist, carry=a.carry_opt, deposit=a.deposit_fallback)
1229 if a.area_advect:
1230 area_advect(a.n, R=a.advect_r, sub=a.area_sub, steps=a.advect_steps, cfl=a.cfl,
1231 amp=a.advect_amp)
1232 return 0
1233 if a.regress_probe:
1234 regress_probe(a.n, [float(x) for x in a.regress_probe.split(",")],
1235 deltas=[float(x) for x in a.regress_delta.split(",")],
1236 sub=a.area_sub, modes=[int(x) for x in a.regress_modes.split(",")],
1237 ratio=a.ratio, advect_steps=a.regress_advect, cfl=a.cfl)
1238 return 0
1239 if a.carry_probe:
1240 carry_probe(a.n, a.carry_probe, ratio=a.ratio, carry=a.carry, p3g=p3g)
1241 return 0
1242 if a.mdot_probe:
1243 mdot_probe(a.n, [float(x) for x in a.mdot_probe.split(",")],
1244 ja=float(a.ja.split(",")[0]), ratio=a.ratio, sub=a.area_sub,
1245 area_mode=a.area_mode, quad=not a.no_quad, plane=not a.no_plane,
1246 dt=(a.mdot_dt if a.mdot_dt > 0 else None),
1247 geoms=tuple(a.mdot_geom.split(",")),
1248 profs=tuple(a.mdot_prof.split(",")), kap=a.fit_curvature, p3g=p3g)
1249 return 0
1250 if a.area_probe:
1251 area_probe(a.n, [float(x) for x in a.area_probe.split(",")], ratio=a.ratio,
1252 sub=a.area_sub, mode=a.area_mode, shape=a.area_shape)
1253 return 0
1254 print(f"P3 Scriven bubble growth, {a.n}^3, cfl {a.cfl:g}, sweeps {a.sweeps}, "
1255 f"plane Dirichlet {not a.no_plane}, "
1256 f"consistent energy {not a.no_consistent}, quadratic fit {not a.no_quad}, "
1257 f"energy MUSCL {a.muscl}, init {a.init}, sub {a.sub}^3, area mode {a.area_mode}, "
1258 f"WO-P3g {p3g}")
1259 for ja in [float(x) for x in a.ja.split(",")]:
1260 run(a.n, ja, a.ratio, a.r0, a.r1, cfl=a.cfl, sweeps=a.sweeps, plane=not a.no_plane,
1261 consistent=not a.no_consistent, quad=not a.no_quad, muscl=a.muscl, init=a.init,
1262 sub=a.sub, area_mode=a.area_mode, budget=a.budget, carry=a.carry,
1263 fitkap=a.fit_curvature, p3g=p3g)
1264 return 0
1265
1266
1267if __name__ == "__main__":
1268 sys.exit(main())
scriven_T(r, R, beta, rr, dT)
mdot_probe(n, radii, ja=0.5, ratio=100.0, sub=16, alpha_l=1.0, area_mode=None, quad=True, plane=True, dt=None, geoms=("sphere", "plane"), profs=("scriven",), kap=False, p3g=None)
sphere_colour(n, cx, cy, cz, R, sub=4)
apply_p3g(s, order=1, op_mdot=None, gfm_order=None, curv_dist=None, carry=None, deposit=None)
scriven_beta(ja, rr)
initial_temperature(n, ctr, R, beta, rr, dT, mode="similarity", sub=4)
run(n, ja, ratio, r0, r1, cfl=0.2, alpha_l=1.0, sweeps=200, plane=True, consistent=True, quad=True, muscl=False, init="similarity", sub=4, area_mode=None, verbose=True, budget=0, carry=False, fitkap=False, p3g=None)
solenoidal_faces(n, amp, drift)
plane_colour(n, nrm, sub=8, shift=0.37)
_scriven_integral(beta, rr, lo=0.0)
_sph_moments(w, ax, ctr, lmax=4)
sphere_colour_chunked(n, ctr, R, sub)
carry_probe(n, steps, ratio=100.0, carry=False, sub=16, grad=0.05, mdot=2.0e-3, p3g=None)
cylinder_colour_chunked(n, ctr, R, sub)
plane_colour_periodic(n, nrm, sub=8, shift=0.37)
area_probe(n, radii, ratio=100.0, sub=4, mode=None, shape="sphere")
area_advect(n=128, R=16.0, sub=16, steps=100, cfl=0.2, amp=0.0, drift=(0.5, 0.25, 0.125), modes=(0, 3, 4, 6, 7))
_cubic_bins(n, ctr, nb=4)
_mdot_scene(n, R, ja, ratio, sub, alpha_l, area_mode, quad, plane, geom, dt, prof, kap=False, p3g=None)
regress_probe(n=128, radii=(16.0,), deltas=(0.05, 0.1, 0.2), sub=16, modes=(0, 6), ratio=100.0, advect_steps=0, drift=(0.5, 0.25, 0.125), cfl=0.2, nbin=4)
static double mean(const std::vector< double > &a)