Files

791 lines
30 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Frozen v0.2 shear interface diagnostic (test-side) against unchanged CPUREF-0.3.
v0.2 change (root/TRON checker fix only): t0 convention mismatches and init_mismatch
are now explicit Boolean hard checks (INIT_TOL=CONS_TOL for O(1) rho/j restriction,
TRANS_TOL=1e-12*U0 for t0 velocity matches); per-cell RMS of e_IF patch/outside
printed as reported diagnostics. No algorithm, threshold or reference change.
v0.1 changes (Claude review B1-B4): include A_T in hard checks; print mass/mom
drifts with relative tol wording; clarify coarse-step vs CPUREF-tick units and
unnormalised L2 norms; split e_IF patch/outside + print (uA-uB)(y) profiles;
report Pi_xy mean; drop made-up ±10% bracket boolean (print s alone).
TRON freeze (thread 1791864801.121109) + implementation ask (1791865457.667669):
7 runs, tau_c=0.8 / tau_f=1.1, 32 coarse steps, U0=1e-3, phi in {0, pi/2},
A/B/C plus A transpose. Equilibrium-only from exact cell-average sine velocity;
restrict rho/j then divide; common 16^2 projection; startup 0-3 reported, fit 4-32.
Hard checks: finite active states, conservation, conventions, transpose.
Rate brackets / acoustic / interface errors are reported diagnostics only.
NumPy+stdlib only; reference file unchanged; no project integration.
Label: init is equilibrium of *averaged velocity*, NOT the exact cell-average of
the nonlinear equilibrium population — population equality at t=0 is NOT asserted.
"""
from __future__ import annotations
import hashlib
import importlib.util
import os
import sys
import time
from dataclasses import dataclass
import numpy as np
HERE = os.path.dirname(os.path.abspath(__file__))
REF_PATH = os.environ.get(
"CPU_REFERENCE_PATH", os.path.join(HERE, "cpu_reference_v0.3.py")
)
U0 = 1e-3
N_COARSE = 16
N_FINE_DOM = 32 # uniform-fine domain in fine cells over physical [0,16)
PATCH_LO, PATCH_HI = 4, 12
TAU_C, TAU_F = 0.8, 1.1
N_STEPS = 32
FIT_LO, FIT_HI = 4, 32 # inclusive *coarse steps*; CPUREF ticks = 2*step (fine hops)
CONS_TOL = 1e-12
TRANS_TOL = 1e-12 * U0
INIT_TOL = CONS_TOL # round-off tol for restricted rho,j vs coarse formula (O(1) quantities)
T0_TOL = TRANS_TOL # round-off tol for t0 velocity matches (O(U0) quantities)
K_PHYS = 2.0 * np.pi / 16.0 # physical wave number on [0,16)
CS2 = 1.0 / 3.0
NU = CS2 * (TAU_C - 0.5) # = 0.1 in physical (coarse) units; matches fine scaling
CX = np.array([0, 1, -1, 0, 0, 1, -1, 1, -1], dtype=np.int64)
CY = np.array([0, 0, 0, 1, -1, 1, -1, -1, 1], dtype=np.int64)
W = np.array([4 / 9] + [1 / 9] * 4 + [1 / 36] * 4, dtype=np.float64)
NQ = 9
def _load_ref():
spec = importlib.util.spec_from_file_location("cpu_ref_v03", REF_PATH)
mod = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = mod
spec.loader.exec_module(mod)
return mod
def sha256_file(path: str) -> str:
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1 << 16), b""):
h.update(chunk)
return h.hexdigest()
def sinc(x: np.ndarray | float) -> np.ndarray | float:
x = np.asarray(x, dtype=np.float64)
out = np.empty_like(x, dtype=np.float64)
small = np.abs(x) < 1e-14
out[small] = 1.0
xs = x[~small]
out[~small] = np.sin(xs) / xs
return float(out) if out.ndim == 0 else out
def feq(rho, ux, uy):
"""Second-order D2Q9 equilibrium; rho,ux,uy broadcastable to (ny,nx)."""
rho = np.asarray(rho, dtype=np.float64)
ux = np.asarray(ux, dtype=np.float64)
uy = np.asarray(uy, dtype=np.float64)
cu = CX[:, None, None] * ux + CY[:, None, None] * uy
usq = ux * ux + uy * uy
return W[:, None, None] * rho * (1.0 + 3.0 * cu + 4.5 * cu * cu - 1.5 * usq)
def srt_collide(f, tau, mask=None):
g = f.copy()
if mask is None:
mask = np.ones(f.shape[1:], dtype=bool)
fm = f[:, mask]
rho = fm.sum(axis=0)
ux = (CX[:, None] * fm).sum(axis=0) / rho
uy = (CY[:, None] * fm).sum(axis=0) / rho
eq = feq(rho, ux, uy) # shape (9, ny, nx) but we need only masked — rebuild
# rebuild eq on full grid then mask
rho_g = np.zeros(f.shape[1:])
ux_g = np.zeros(f.shape[1:])
uy_g = np.zeros(f.shape[1:])
rho_g[mask] = rho
ux_g[mask] = ux
uy_g[mask] = uy
eq_full = feq(rho_g, ux_g, uy_g)
g[:, mask] = fm - (fm - eq_full[:, mask]) / tau
return g
def stream_periodic(g):
out = np.empty_like(g)
for i in range(NQ):
out[i] = np.roll(g[i], (int(CY[i]), int(CX[i])), axis=(0, 1))
return out
def moments(f):
rho = f.sum(axis=0)
jx = (CX[:, None, None] * f).sum(axis=0)
jy = (CY[:, None, None] * f).sum(axis=0)
return rho, jx, jy
def cell_avg_ux(y_centers, dx, phi, transpose=False):
"""Exact cell-average of U0 sin(k y + phi) [or sin(k x + phi) if transpose]."""
factor = float(sinc(K_PHYS * dx / 2.0))
if transpose:
# shear u_y(x): need x centers; caller passes x as y_centers arg
return U0 * factor * np.sin(K_PHYS * y_centers + phi)
return U0 * factor * np.sin(K_PHYS * y_centers + phi)
def mode_template(phi):
"""m on 16^2 coarse centers: sinc(k/2) sin(k yc + phi)."""
yc = np.arange(N_COARSE, dtype=np.float64) + 0.5
m1d = float(sinc(K_PHYS / 2.0)) * np.sin(K_PHYS * yc + phi)
return np.broadcast_to(m1d[:, None], (N_COARSE, N_COARSE)).copy() # [y,x]
def project_amp(ux, phi):
m = mode_template(phi)
return float(np.sum(ux * m) / np.sum(m * m))
def restrict_fine_to_coarse(rho_f, jx_f, jy_f):
"""2x2 arithmetic mean of conserved rho,j on 32^2 -> 16^2, then u=j/rho."""
assert rho_f.shape == (N_FINE_DOM, N_FINE_DOM)
rho = rho_f.reshape(N_COARSE, 2, N_COARSE, 2).mean(axis=(1, 3))
jx = jx_f.reshape(N_COARSE, 2, N_COARSE, 2).mean(axis=(1, 3))
jy = jy_f.reshape(N_COARSE, 2, N_COARSE, 2).mean(axis=(1, 3))
ux = jx / rho
uy = jy / rho
return rho, jx, jy, ux, uy
def pi_xy(f, tau):
"""Pre-collision Π_xy neq proxy: sum c_x c_y f (lattice units of that grid)."""
return (CX[:, None, None] * CY[:, None, None] * f).sum(axis=0)
@dataclass
class Series:
name: str
phi: float
A: np.ndarray # length 33 (coarse steps 0..32)
e_if: np.ndarray # ||u - u_B|| unnormalised L2 over 256 cells (filled later for A)
e_cb: np.ndarray
e_if_patch: np.ndarray # L2 on patch cells only (filled for A)
e_if_out: np.ndarray # L2 on outside cells only (filled for A)
nonmodal: np.ndarray
max_uy: np.ndarray
max_drho: np.ndarray
mass: np.ndarray
momx: np.ndarray
momy: np.ndarray
pi_xy_mean: np.ndarray # mean pre-collision Pi_xy proxy (uniform grids); NaN for composite
finite_ok: bool
cons_ok: bool
mass_drift: float = 0.0
momx_drift: float = 0.0
momy_drift: float = 0.0
notes: str = ""
def init_uniform(n, dx, tau, phi, transpose=False):
"""Initialize n×n periodic SRT lattice; dx = cell size in physical units."""
centers = (np.arange(n, dtype=np.float64) + 0.5) * dx
# physical coords
Y, X = np.meshgrid(centers, centers, indexing="ij") # [y,x]
rho = np.ones((n, n), dtype=np.float64)
if transpose:
ux = np.zeros_like(rho)
uy = cell_avg_ux(X, dx, phi) # u_y = U0 sinc sin(k x + phi)
else:
ux = cell_avg_ux(Y, dx, phi)
uy = np.zeros_like(rho)
f = feq(rho, ux, uy)
return f
def advance_uniform(f, tau, steps):
for _ in range(steps):
g = srt_collide(f, tau)
f = stream_periodic(g)
return f
def observe_uniform(f, dx, phi, transpose=False):
rho, jx, jy = moments(f)
if dx < 1.0 - 1e-12: # fine 32^2 -> restrict
rho_c, jx_c, jy_c, ux, uy = restrict_fine_to_coarse(rho, jx, jy)
else:
rho_c, jx_c, jy_c = rho, jx, jy
ux, uy = jx / rho, jy / rho
if transpose:
# project onto transposed mode using uy vs m(x)
m = mode_template(phi) # m[y,x] = sinc sin(k yc+phi) — for transpose need m_x
xc = np.arange(N_COARSE, dtype=np.float64) + 0.5
mx = float(sinc(K_PHYS / 2.0)) * np.sin(K_PHYS * xc + phi)
mT = np.broadcast_to(mx[None, :], (N_COARSE, N_COARSE)).copy()
amp = float(np.sum(uy * mT) / np.sum(mT * mT))
modal = amp * mT
nonmodal = float(np.linalg.norm(uy - modal) / U0)
max_cross = float(np.max(np.abs(ux)) / U0)
else:
amp = project_amp(ux, phi)
m = mode_template(phi)
modal = amp * m
nonmodal = float(np.linalg.norm(ux - modal) / U0)
max_cross = float(np.max(np.abs(uy)) / U0)
max_drho = float(np.max(np.abs(rho_c - 1.0)))
return {
"amp": amp,
"ux": ux,
"uy": uy,
"rho": rho_c,
"jx": jx_c,
"jy": jy_c,
"nonmodal": nonmodal,
"max_cross": max_cross,
"max_drho": max_drho,
"mass": float(rho_c.sum()),
"momx": float(jx_c.sum()),
"momy": float(jy_c.sum()),
"pi_xy_mean": float(np.nanmean(pi_xy(f, None))),
}
def run_uniform(kind, phi, transpose=False):
"""kind: 'B' fine 32^2 or 'C' coarse 16^2. Returns Series + per-tick ux fields."""
if kind == "B":
n, dx, tau, sub = N_FINE_DOM, 0.5, TAU_F, 2
else:
n, dx, tau, sub = N_COARSE, 1.0, TAU_C, 1
f = init_uniform(n, dx, tau, phi, transpose=transpose)
amps = []
nonmodal = []
max_cross = []
max_drho = []
mass = []
momx = []
momy = []
pi_means = []
fields = []
finite_ok = True
for tick in range(N_STEPS + 1):
if not np.isfinite(f).all():
finite_ok = False
obs = observe_uniform(f, dx, phi, transpose=transpose)
amps.append(obs["amp"])
nonmodal.append(obs["nonmodal"])
max_cross.append(obs["max_cross"])
max_drho.append(obs["max_drho"])
mass.append(obs["mass"])
momx.append(obs["momx"])
momy.append(obs["momy"])
pi_means.append(obs["pi_xy_mean"])
fields.append((obs["ux"].copy(), obs["uy"].copy(), obs["rho"].copy(),
obs["jx"].copy(), obs["jy"].copy()))
if tick < N_STEPS:
f = advance_uniform(f, tau, sub)
mass = np.asarray(mass)
momx = np.asarray(momx)
momy = np.asarray(momy)
mass_drift = float(np.max(np.abs(mass - mass[0])))
momx_drift = float(np.max(np.abs(momx - momx[0])))
momy_drift = float(np.max(np.abs(momy - momy[0])))
tol_abs = CONS_TOL * abs(mass[0])
cons_ok = (
mass_drift <= tol_abs
and momx_drift <= tol_abs
and momy_drift <= tol_abs
)
ser = Series(
name=f"{kind}_phi{phi:.4f}{'_T' if transpose else ''}",
phi=phi,
A=np.asarray(amps),
e_if=np.full(N_STEPS + 1, np.nan),
e_cb=np.full(N_STEPS + 1, np.nan),
e_if_patch=np.full(N_STEPS + 1, np.nan),
e_if_out=np.full(N_STEPS + 1, np.nan),
nonmodal=np.asarray(nonmodal),
max_uy=np.asarray(max_cross),
max_drho=np.asarray(max_drho),
mass=mass,
momx=momx,
momy=momy,
pi_xy_mean=np.asarray(pi_means),
finite_ok=finite_ok,
cons_ok=bool(cons_ok),
mass_drift=mass_drift,
momx_drift=momx_drift,
momy_drift=momy_drift,
)
return ser, fields
def init_composite(ref, spec, maps, phi, transpose=False):
"""Equilibrium-only init on composite from cell-average sine velocity."""
n, nf = spec.n_coarse, spec.n_fine
coarse = np.full((NQ, n, n), np.nan)
fine = np.full((NQ, nf, nf), np.nan)
# coarse cells (including covered): physical centers
yc = np.arange(n, dtype=np.float64) + 0.5
xc = np.arange(n, dtype=np.float64) + 0.5
Yc, Xc = np.meshgrid(yc, xc, indexing="ij")
if transpose:
ux_c = np.zeros((n, n))
uy_c = cell_avg_ux(Xc, 1.0, phi)
else:
ux_c = cell_avg_ux(Yc, 1.0, phi)
uy_c = np.zeros((n, n))
eq_c = feq(np.ones((n, n)), ux_c, uy_c)
coarse[:, :, :] = eq_c # covered also filled; advance will NaN them as inactive reads
# but make_state only fills active — we must match API: covered should be NaN for advance
coarse[:, maps.coarse_covered] = np.nan
# fine active: physical centres from maps.fine_centre (includes patch offset)
fy = maps.fine_centre # length nf
fx = maps.fine_centre
Yf, Xf = np.meshgrid(fy, fx, indexing="ij")
if transpose:
ux_f = np.zeros((nf, nf))
uy_f = cell_avg_ux(Xf, 0.5, phi)
else:
ux_f = cell_avg_ux(Yf, 0.5, phi)
uy_f = np.zeros((nf, nf))
eq_f = feq(np.ones((nf, nf)), ux_f, uy_f)
fine[:, maps.fine_active] = eq_f[:, maps.fine_active]
return ref.State(coarse_f=coarse, fine_f=fine, phase="arrival_f", tick=0)
def observe_composite(ref, maps, state, phi, transpose=False):
fc, ff = state.coarse_f, state.fine_f
# Build full 32^2 physical fine field for restriction: outside patch use
# exploded-from-coarse equivalent = coarse cell values duplicated 2x2.
# Observables: restrict rho/j then u=j/rho on common 16^2.
# Outside patch: use coarse as stored. Inside patch: 2x2 mean of fine.
n = N_COARSE
rho_c = np.full((n, n), np.nan)
jx_c = np.full((n, n), np.nan)
jy_c = np.full((n, n), np.nan)
# uncovered coarse
rho_a, jx_a, jy_a = moments(np.nan_to_num(fc, nan=0.0))
# only trust active
for y in range(n):
for x in range(n):
if maps.coarse_active[y, x]:
sl = fc[:, y, x]
rho_c[y, x] = sl.sum()
jx_c[y, x] = (CX * sl).sum()
jy_c[y, x] = (CY * sl).sum()
# fine patch -> restrict into covered coarse cells
# fine active indices a=4..19 map to physical coarse [4,12)
for ky in range(PATCH_LO, PATCH_HI):
for kx in range(PATCH_LO, PATCH_HI):
y0 = spec_children_start(ky)
x0 = spec_children_start(kx)
kids = []
for dy in (0, 1):
for dx in (0, 1):
fy, fx = y0 + dy, x0 + dx
sl = ff[:, fy, fx]
kids.append((sl.sum(), (CX * sl).sum(), (CY * sl).sum()))
rho_c[ky, kx] = np.mean([k[0] for k in kids])
jx_c[ky, kx] = np.mean([k[1] for k in kids])
jy_c[ky, kx] = np.mean([k[2] for k in kids])
ux = jx_c / rho_c
uy = jy_c / rho_c
if transpose:
xc = np.arange(n, dtype=np.float64) + 0.5
mx = float(sinc(K_PHYS / 2.0)) * np.sin(K_PHYS * xc + phi)
mT = np.broadcast_to(mx[None, :], (n, n)).copy()
amp = float(np.sum(uy * mT) / np.sum(mT * mT))
modal = amp * mT
nonmodal = float(np.linalg.norm(uy - modal) / U0)
max_cross = float(np.max(np.abs(ux)) / U0)
else:
amp = project_amp(ux, phi)
m = mode_template(phi)
modal = amp * m
nonmodal = float(np.linalg.norm(ux - modal) / U0)
max_cross = float(np.max(np.abs(uy)) / U0)
# leaf totals for conservation (volume-weighted)
leaf = ref._leaf_totals(fc, ff, maps)
return {
"amp": amp,
"ux": ux,
"uy": uy,
"rho": rho_c,
"jx": jx_c,
"jy": jy_c,
"nonmodal": nonmodal,
"max_cross": max_cross,
"max_drho": float(np.max(np.abs(rho_c - 1.0))),
"mass": float(leaf[0]),
"momx": float(leaf[1]),
"momy": float(leaf[2]),
"finite_active": bool(
np.isfinite(fc[:, maps.coarse_active]).all()
and np.isfinite(ff[:, maps.fine_active]).all()
),
}
def spec_children_start(k):
return 4 + 2 * (k - PATCH_LO) # halo=4
def run_composite(ref, phi, transpose=False):
spec = ref.make_spec(collision="srt")
# force tau (already default)
assert abs(spec.tau_coarse - TAU_C) < 1e-15 and abs(spec.tau_fine - TAU_F) < 1e-15
maps = ref.build_maps(spec)
st = init_composite(ref, spec, maps, phi, transpose=transpose)
# hard check: initial restricted rho,j under patch match coarse formula to round-off
# Compare fine-restricted vs what coarse cell-average would be under patch
yc = np.arange(N_COARSE) + 0.5
xc = np.arange(N_COARSE) + 0.5
init_mismatch = 0.0
for ky in range(PATCH_LO, PATCH_HI):
for kx in range(PATCH_LO, PATCH_HI):
y0 = spec_children_start(ky)
x0 = spec_children_start(kx)
rhos, jxs, jys = [], [], []
for dy in (0, 1):
for dx in (0, 1):
sl = st.fine_f[:, y0 + dy, x0 + dx]
rhos.append(sl.sum())
jxs.append((CX * sl).sum())
jys.append((CY * sl).sum())
rho_r, jx_r, jy_r = np.mean(rhos), np.mean(jxs), np.mean(jys)
# coarse formula at (kx,ky)
if transpose:
ux = 0.0
uy = float(cell_avg_ux(xc[kx], 1.0, phi))
else:
ux = float(cell_avg_ux(yc[ky], 1.0, phi))
uy = 0.0
init_mismatch = max(
init_mismatch,
abs(rho_r - 1.0),
abs(jx_r - ux),
abs(jy_r - uy),
)
amps, nonmodal, max_cross, max_drho = [], [], [], []
mass, momx, momy = [], [], []
fields = []
finite_ok = True
for tick in range(N_STEPS + 1):
obs = observe_composite(ref, maps, st, phi, transpose=transpose)
if not obs["finite_active"]:
finite_ok = False
amps.append(obs["amp"])
nonmodal.append(obs["nonmodal"])
max_cross.append(obs["max_cross"])
max_drho.append(obs["max_drho"])
mass.append(obs["mass"])
momx.append(obs["momx"])
momy.append(obs["momy"])
fields.append((obs["ux"].copy(), obs["uy"].copy(), obs["rho"].copy(),
obs["jx"].copy(), obs["jy"].copy()))
if tick < N_STEPS:
st, _ = ref.advance_coarse_once(spec, maps, st)
mass = np.asarray(mass)
momx = np.asarray(momx)
momy = np.asarray(momy)
mass_drift = float(np.max(np.abs(mass - mass[0])))
momx_drift = float(np.max(np.abs(momx - momx[0])))
momy_drift = float(np.max(np.abs(momy - momy[0])))
tol_abs = CONS_TOL * max(abs(mass[0]), 1.0)
cons_ok = (
mass_drift <= tol_abs
and momx_drift <= tol_abs
and momy_drift <= tol_abs
)
ser = Series(
name=f"A_phi{phi:.4f}{'_T' if transpose else ''}",
phi=phi,
A=np.asarray(amps),
e_if=np.full(N_STEPS + 1, np.nan),
e_cb=np.full(N_STEPS + 1, np.nan),
e_if_patch=np.full(N_STEPS + 1, np.nan),
e_if_out=np.full(N_STEPS + 1, np.nan),
nonmodal=np.asarray(nonmodal),
max_uy=np.asarray(max_cross),
max_drho=np.asarray(max_drho),
mass=mass,
momx=momx,
momy=momy,
pi_xy_mean=np.full(N_STEPS + 1, np.nan),
finite_ok=finite_ok,
cons_ok=bool(cons_ok),
mass_drift=mass_drift,
momx_drift=momx_drift,
momy_drift=momy_drift,
notes=f"init_rho_j_mismatch_vs_coarse_formula={init_mismatch:.3e}",
)
return ser, fields, init_mismatch
def fit_lambda(amps):
"""Log-linear fit of A(t) over ticks FIT_LO..FIT_HI; return lambda, stderr, R2."""
t = np.arange(FIT_LO, FIT_HI + 1, dtype=np.float64)
y = np.log(np.abs(amps[FIT_LO : FIT_HI + 1]))
# A(t) ~ A0 exp(-lambda t) => log|A| = log|A0| - lambda t
n = len(t)
tbar, ybar = t.mean(), y.mean()
sxx = np.sum((t - tbar) ** 2)
slope = np.sum((t - tbar) * (y - ybar)) / sxx # = -lambda
intercept = ybar - slope * tbar
pred = intercept + slope * t
resid = y - pred
dof = max(n - 2, 1)
s_err = float(np.sqrt(np.sum(resid**2) / dof / sxx))
ss_tot = np.sum((y - ybar) ** 2)
r2 = float(1.0 - np.sum(resid**2) / ss_tot) if ss_tot > 0 else 1.0
return float(-slope), s_err, r2
def field_l2(a, b):
return float(np.linalg.norm(a - b))
def main():
t0 = time.time()
print("=== shear_diagnostic_v0.2 against CPUREF-0.3 ===")
print(f"REF_PATH={REF_PATH}")
print(f"REF_SHA256={sha256_file(REF_PATH)}")
ref = _load_ref()
print(f"API_VERSION={getattr(ref, 'API_VERSION', '?')}")
assert getattr(ref, "API_VERSION", None) == "CPUREF-0.3"
print(f"U0={U0} tau_c={TAU_C} tau_f={TAU_F} steps={N_STEPS} nu={NU} k={K_PHYS}")
print(f"nu*k^2={NU * K_PHYS**2:.8f} (analytic decay rate)")
print("INIT: equilibrium of averaged velocity (NOT exact cell-avg of nonlinear feq)")
print("UNITS: fit/report 'steps' are coarse steps; CPUREF tick = fine step count")
print(f" fit window coarse steps {FIT_LO}..{FIT_HI} = CPUREF ticks {2*FIT_LO}..{2*FIT_HI}")
print(f" startup coarse steps 0..3 = CPUREF ticks 0..6")
print(" nonmodal/e_IF/e_CB are unnormalised L2 sums over 256 coarse cells")
print(" (per-cell RMS = value/16); nonmodal is already /U0")
print(f"CONS_TOL={CONS_TOL} relative to mass (~256) => abs ~{CONS_TOL*256:.3e}")
print()
phis = [0.0, 0.5 * np.pi]
results = {}
fields = {}
init_mm = {}
# C and B for both phases
for phi in phis:
tag = f"phi{phi:.4f}"
print(f"-- running C (uniform-coarse) {tag}")
ser_c, fld_c = run_uniform("C", phi)
results[f"C_{tag}"] = ser_c
fields[f"C_{tag}"] = fld_c
print(f"-- running B (uniform-fine) {tag}")
ser_b, fld_b = run_uniform("B", phi)
results[f"B_{tag}"] = ser_b
fields[f"B_{tag}"] = fld_b
print(f"-- running A (composite CPUREF) {tag}")
ser_a, fld_a, mism = run_composite(ref, phi)
init_mm[tag] = mism
results[f"A_{tag}"] = ser_a
fields[f"A_{tag}"] = fld_a
# fill e_IF = ||A-B||, e_CB = ||C-B|| on restricted ux (unnormalised L2)
patch = np.zeros((N_COARSE, N_COARSE), dtype=bool)
patch[PATCH_LO:PATCH_HI, PATCH_LO:PATCH_HI] = True
out = ~patch
for t in range(N_STEPS + 1):
du = fld_a[t][0] - fld_b[t][0]
ser_a.e_if[t] = float(np.linalg.norm(du))
ser_a.e_if_patch[t] = float(np.linalg.norm(du[patch]))
ser_a.e_if_out[t] = float(np.linalg.norm(du[out]))
ser_a.e_cb[t] = field_l2(fld_c[t][0], fld_b[t][0])
ser_c.e_cb[t] = ser_a.e_cb[t]
ser_b.e_if[t] = 0.0
# A transpose at phi=pi/2
phi_t = 0.5 * np.pi
print(f"-- running A transpose phi={phi_t}")
ser_at, fld_at, mism_t = run_composite(ref, phi_t, transpose=True)
init_mm["A_T"] = mism_t
results["A_T_phi1.5708"] = ser_at
fields["A_T_phi1.5708"] = fld_at
# Also need B transpose? For e_IF on transpose — freeze says A transposed only.
# Transpose hard check: A_T fields equal transpose of A at same phi.
ser_a_ref = results["A_phi1.5708"]
fld_a_ref = fields["A_phi1.5708"]
trans_err = 0.0
for t in range(N_STEPS + 1):
ux, uy, rho, jx, jy = fld_a_ref[t]
ux_t, uy_t, rho_t, jx_t, jy_t = fld_at[t]
# original: ux(y), uy~0; transpose: uy(x), ux~0
# transpose of original velocity: u'_x(x,y)=uy(y,x), u'_y(x,y)=ux(y,x)
err = max(
float(np.max(np.abs(ux_t - uy.T))),
float(np.max(np.abs(uy_t - ux.T))),
float(np.max(np.abs(rho_t - rho.T))),
)
trans_err = max(trans_err, err)
transpose_ok = trans_err <= TRANS_TOL
# Convention match A/B/C at t=0: restricted rho,j under patch
print("\n=== HARD CHECKS ===")
hard = {}
hard_keys = []
for phi in phis:
tag = f"phi{phi:.4f}"
hard_keys.extend([f"A_{tag}", f"B_{tag}", f"C_{tag}"])
hard_keys.append("A_T_phi1.5708")
for key in hard_keys:
s = results[key]
hard[f"finite_{key}"] = s.finite_ok
hard[f"cons_{key}"] = s.cons_ok
tol_abs = CONS_TOL * max(abs(s.mass[0]), 1.0)
print(
f" finite[{key}]={s.finite_ok} cons={s.cons_ok} "
f"mass_drift={s.mass_drift:.3e} momx_drift={s.momx_drift:.3e} "
f"momy_drift={s.momy_drift:.3e} tol_abs={tol_abs:.3e} "
f"(CONS_TOL*mass={CONS_TOL}*|{s.mass[0]:.6g}|) {s.notes}"
)
hard["transpose_ok"] = transpose_ok
hard["transpose_err"] = trans_err
print(f" transpose_ok={transpose_ok} max_err={trans_err:.3e} (tol={TRANS_TOL:.3e})")
# t=0 convention: A vs B vs C amplitudes and restricted fields
for phi in phis:
tag = f"phi{phi:.4f}"
a0 = results[f"A_{tag}"].A[0]
b0 = results[f"B_{tag}"].A[0]
c0 = results[f"C_{tag}"].A[0]
print(f" t0 amp A/B/C {tag}: {a0:.8e} {b0:.8e} {c0:.8e}")
# restricted j under patch: A vs theoretical
ux_a = fields[f"A_{tag}"][0][0]
ux_b = fields[f"B_{tag}"][0][0]
ux_c = fields[f"C_{tag}"][0][0]
# under patch and outside
patch = np.zeros((16, 16), dtype=bool)
patch[PATCH_LO:PATCH_HI, PATCH_LO:PATCH_HI] = True
d_ab_p = float(np.max(np.abs(ux_a[patch] - ux_b[patch])))
d_ac_o = float(np.max(np.abs(ux_a[~patch] - ux_c[~patch])))
d_bc = float(np.max(np.abs(ux_b - ux_c)))
print(f" t0 max|ux A-B| patch={d_ab_p:.3e} |A-C| outside={d_ac_o:.3e} |B-C| all={d_bc:.3e}")
hard[f"t0_AB_patch_{tag}"] = d_ab_p
hard[f"t0_AC_out_{tag}"] = d_ac_o
hard[f"t0_AB_patch_ok_{tag}"] = bool(d_ab_p <= T0_TOL)
hard[f"t0_AC_out_ok_{tag}"] = bool(d_ac_o <= T0_TOL)
hard[f"init_mismatch_ok_{tag}"] = bool(init_mm[tag] <= INIT_TOL)
print(f" t0_AB_patch_ok={hard[f't0_AB_patch_ok_{tag}']} t0_AC_out_ok={hard[f't0_AC_out_ok_{tag}']} "
f"(T0_TOL={T0_TOL:.1e}) init_mismatch={init_mm[tag]:.3e} ok={hard[f'init_mismatch_ok_{tag}']} "
f"(INIT_TOL={INIT_TOL:.1e})")
hard["init_mismatch_ok_A_T_phi1.5708"] = bool(init_mm["A_T"] <= INIT_TOL)
print(f" init_mismatch[A_T]={init_mm['A_T']:.3e} ok={hard['init_mismatch_ok_A_T_phi1.5708']} (INIT_TOL={INIT_TOL:.1e})")
_bool_keys = sorted(k for k, v in hard.items() if isinstance(v, bool))
print(f" N_BOOLEAN_HARD_CHECKS={len(_bool_keys)}: " + ",".join(_bool_keys))
print("\n=== DECAY FITS (coarse steps 4..32 = CPUREF ticks 8..64) ===")
lam_analytic = NU * K_PHYS**2
print(f" analytic lambda = nu*k^2 = {lam_analytic:.8f}")
fit_table = {}
patch = np.zeros((N_COARSE, N_COARSE), dtype=bool)
patch[PATCH_LO:PATCH_HI, PATCH_LO:PATCH_HI] = True
for phi in phis:
tag = f"phi{phi:.4f}"
row = {}
for kind in ("A", "B", "C"):
s = results[f"{kind}_{tag}"]
lam, se, r2 = fit_lambda(s.A)
row[kind] = (lam, se, r2, s.A[0], s.A[-1])
print(
f" {kind} {tag}: lambda={lam:.8f} stderr={se:.3e} R2={r2:.6f} "
f"A0={s.A[0]:.6e} A32={s.A[-1]:.6e}"
)
fit_table[tag] = row
la, lb, lc = row["A"][0], row["B"][0], row["C"][0]
s_flag = (la - lb) / (lc - lb) if abs(lc - lb) > 1e-16 else float("nan")
print(f" DIAG s=(lambda_A-lambda_B)/(lambda_C-lambda_B) {tag}: s={s_flag:.4f} "
f"(lambda_C-lambda_B)={lc-lb:.6e}")
sa = results[f"A_{tag}"]
print(
f" DIAG e_IF/e_CB step32 (unnorm L2/256cells): e_IF={sa.e_if[-1]:.6e} "
f"e_IF_patch={sa.e_if_patch[-1]:.6e} e_IF_out={sa.e_if_out[-1]:.6e} "
f"e_CB={sa.e_cb[-1]:.6e} ratio_IF/CB={sa.e_if[-1]/max(sa.e_cb[-1],1e-30):.4f}"
)
n_p, n_o = int(patch.sum()), int((~patch).sum())
rms_p = sa.e_if_patch[-1] / np.sqrt(n_p)
rms_o = sa.e_if_out[-1] / np.sqrt(n_o)
frac_p = sa.e_if_patch[-1] ** 2 / max(sa.e_if[-1] ** 2, 1e-300)
print(
f" DIAG per-cell RMS (uA-uB) step32 {tag}: patch={rms_p:.4e} ({n_p} cells) "
f"outside={rms_o:.4e} ({n_o} cells) ratio={rms_p/rms_o:.3f} "
f"patch_energy_frac={frac_p:.4f} (area frac {n_p/(n_p+n_o):.2f})"
)
print(
f" DIAG sensors step32: nonmodal(L2/U0)={sa.nonmodal[-1]:.4e} "
f"max|uy|/U0={sa.max_uy[-1]:.4e} max|drho|={sa.max_drho[-1]:.4e}"
)
# x-averaged (u_A - u_B)(y) at step 32
du = fields[f"A_{tag}"][32][0] - fields[f"B_{tag}"][32][0]
profile = du.mean(axis=1) # average over x
print(f" DIAG (uA-uB)_xavg(y) step32 {tag}: " +
" ".join(f"{v:.3e}" for v in profile))
# Pi_xy at step 32 for uniforms (arrival_f / post-stream)
sb = results[f"B_{tag}"]
sc = results[f"C_{tag}"]
print(
f" DIAG Pi_xy_mean step32 {tag}: B={sb.pi_xy_mean[32]:.6e} "
f"C={sc.pi_xy_mean[32]:.6e} (composite=NaN; arrival_f proxy on uniforms)"
)
print("\n=== PER-TICK AMPLITUDE (selected) ===")
hdr = "tick " + " ".join(
f"{k:>14}" for k in ["A_p0", "B_p0", "C_p0", "A_p90", "B_p90", "C_p90", "exact"]
)
print(hdr)
for t in list(range(0, 4)) + list(range(4, 33, 4)):
exact = results["B_phi0.0000"].A[0] * np.exp(-lam_analytic * t)
vals = [
results["A_phi0.0000"].A[t],
results["B_phi0.0000"].A[t],
results["C_phi0.0000"].A[t],
results["A_phi1.5708"].A[t],
results["B_phi1.5708"].A[t],
results["C_phi1.5708"].A[t],
exact,
]
print(f"{t:4d} " + " ".join(f"{v:14.6e}" for v in vals))
print("\n=== NUMERIC SUMMARY ===")
all_hard_pass = all(
v for k, v in hard.items() if isinstance(v, bool)
)
print(f"ALL_HARD_CHECKS_PASS={all_hard_pass}")
print(f"runtime_sec={time.time()-t0:.2f}")
# compact JSON-ish summary lines for Drive
print("SUMMARY_LINES_BEGIN")
for phi in phis:
tag = f"phi{phi:.4f}"
for kind in ("A", "B", "C"):
lam, se, r2, a0, a32 = fit_table[tag][kind]
print(
f"FIT {kind} {tag} lambda={lam:.10f} stderr={se:.6e} R2={r2:.8f} "
f"A0={a0:.10e} A32={a32:.10e}"
)
print(f"TRANSPOSE err={trans_err:.6e} ok={transpose_ok}")
print(f"ANALYTIC lambda={lam_analytic:.10f}")
print("SUMMARY_LINES_END")
return 0 if all_hard_pass else 1
if __name__ == "__main__":
sys.exit(main())