Files
CelerisLab/research/fine_surface_joint_20261014/validate.py
T

348 lines
19 KiB
Python

"""Isolated, pre-registered 24-interval validation; never imported by pytest."""
import csv
import hashlib
import importlib.util
import json
import struct
from pathlib import Path
from unittest.mock import patch
import numpy as np
ROOT = Path('/home/frank14f/CelerisLab')
OUT = Path(__file__).resolve().parent
spec = importlib.util.spec_from_file_location('accepted_hooks', ROOT/'tests/validation/multigrid/test_fine_surface_gpu.py')
h = importlib.util.module_from_spec(spec)
spec.loader.exec_module(h)
EPS = np.finfo(np.float32).eps
M = h.MOMENTS
def save(name, obj):
text = json.dumps(obj, indent=2, allow_nan=False)+'\n'
assert len(text) <= 40000, (name, len(text))
(OUT/name).write_text(text)
def table(name, rows):
with (OUT/name).open('w') as file:
writer = csv.DictWriter(file, fieldnames=list(rows[0]))
writer.writeheader()
writer.writerows(rows)
assert (OUT/name).stat().st_size <= 40000
def vec(row, prefix, values):
row.update({prefix+'_'+axis: float(value) for axis, value in zip(('m', 'px', 'py'), values)})
def equilibrium(values):
a = values.astype(float)
rho = a.sum(axis=0)
u = M[1:]@a/rho
cu = h.CX[:, None]*u[0]+h.CY[:, None]*u[1]
eq = h.W[:, None]*rho*(1+3*cu+4.5*cu*cu-1.5*(u*u).sum(axis=0))
return eq, u, rho
def pull(a):
return np.stack([np.roll(a[q], (h.CY[q], h.CX[q]), (0, 1)) for q in range(9)])
def collision(incoming, output, omega, area):
"""Local observed drift; no access to global initial/final states or ledger."""
a, b = incoming.astype(float), output.astype(float)
eq, u, rho = equilibrium(a)
ideal = (1-omega)*a+omega*eq
tolerance = 8*EPS*abs(a)
error = abs(b-ideal)
# Independent local FP64 oracle plus a measured (not inferred) drift.
return dict(drift=area*(M@(b-a).sum(axis=1)),
ideal_defect=area*(M@(ideal-a).sum(axis=1)),
measured_rounding=area*(M@(b-ideal).sum(axis=1)),
max_error=float(error.max()), max_ratio=float((error/tolerance).max()),
regime=bool(np.isfinite(b).all() and np.all(a>0) and np.all(b>0) and np.all((rho>.9)&(rho<1.1))
and np.max(np.linalg.norm(u, axis=0))<.05))
def coarse_arrivals(before, fine, active, p):
"""Independent midpoint map; FP32 addition order checked against actual map."""
got = pull(before)
ideal = got.astype(float)
targets, sources = [], []
for y, x in np.argwhere(active):
for q in range(1, 9):
if 4 <= x+.5-h.CX[q]/2 <= 20 and 4 <= y+.5-h.CY[q]/2 <= 20:
a, b = 4+2*(x-4), 4+2*(y-4)
children = fine[q, b:b+2, a:a+2].ravel()
assert len(children) == 4 and np.isfinite(children).all()
got[q, y, x] = np.float32(.25)*(((children[0]+children[1])+children[2])+children[3])
ideal[q, y, x] = children.astype(float).sum()/4
targets.append(q*576+y*24+x)
sources.append([q*1600+(b+dy)*40+a+dx for dy in (0, 1) for dx in (0, 1)])
order = np.argsort(targets)
porder = np.argsort(p.coalesce_coarse)
np.testing.assert_array_equal(np.array(targets)[order], p.coalesce_coarse[porder])
np.testing.assert_array_equal(np.array(sources)[order], p.coalesce_fine[porder])
assert np.isfinite(got[:, active]).all()
flux = (got[:, active].astype(float)-before[:, active]).sum(axis=1)
rounding = (got[:, active].astype(float)-ideal[:, active]).sum(axis=1)
children = fine.ravel()[p.coalesce_fine]
bound = 3*(EPS/2)/(1-3*(EPS/2))*np.abs(children.astype(float)).sum(axis=1)/4
differences = abs(got.ravel()[p.coalesce_coarse]-ideal.ravel()[p.coalesce_coarse])
return got, M@flux, M@rounding, float(np.max(differences/bound))
def observation(values, mask):
eq, u, _ = equilibrium(values[:, mask])
return u, values[:, mask].astype(float)-eq
def run(speed, label):
cfg = h.LBMConfig(nx=24, ny=24, viscosity=.05, velocity=0, rho=1)
cmask = np.ones((24, 24), bool)
cmask[4:20, 4:20] = False
source = h.object_spec(speed)
state = vars(source.state).copy()
ledger_rows, collision_rows, boundary_rows = [], [], []
trace = []
with h.CudaContext(0) as context:
with h.implementation.FixedPatchStepper(cfg, h.BOUNDS, context=context, fine_objects=[source]) as backend, context.activate():
c, f = backend._levels
cl, p = backend._fine_curved, backend.patch
geometry = {name: getattr(cl, name).copy() for name in h.GEOMETRY}
flags = backend.fine_boundary.flags.copy().reshape(40, 40)
solid = (flags & h.SOLID) != 0
active = backend._fine_leaf.copy()
fine_mesh = p.fine_kind == 0
band = active & np.logical_or.reduce([~np.roll(fine_mesh, (cy, cx), (0, 1))
for cx, cy in zip(h.CX[1:], h.CY[1:])])
interface = np.zeros(576, bool)
interface[p.coalesce_coarse % 576] = True
interface = interface.reshape(24, 24)
bulk = cmask & ~interface
if speed == 0:
def coords(mask, dx, origin):
yx = np.argwhere(mask)
return ((yx[:, ::-1]+.5)*dx+np.array(origin)).tolist()
save('regions.json', dict(coarse_interface=coords(interface, 1, (0, 0)),
coarse_bulk=coords(bulk, 1, (0, 0)), fine_band=coords(band, .5, (2, 2)),
transfer_slots=[dict(q=int(slot//576), x=float(slot%24+.5),
y=float((slot%576)//24+.5)) for slot in p.coalesce_coarse]))
backend.initialize(h.rest((24, 24)), h.rest((40, 40)))
initial = backend.download()
initial_m = M@(initial[0][:, cmask].sum(axis=1, dtype=float)
+.25*initial[1][:, active].sum(axis=1, dtype=float))
previous = initial_m.copy()
totals = {name: np.zeros(3) for name in ('boundary', 'fine_transfer', 'coarse_transfer', 'rounding', 'average')}
interval = None
orig_b, orig_f, orig_c = backend._curved, f.kernel, c.kernel
omegas = []
for level, tau in ((c, p.tau_coarse), (f, p.tau_fine)):
pointer, size = level.module.get_global('d_params')
assert size == 20
omega, fx, fy, fz, count = struct.unpack('=ffffI', h.read(pointer, (size,), np.uint8).tobytes())
assert omega == float(np.float32(1/tau)) and (fx, fy, fz) == (0., 0., 0.)
omegas.append(omega)
omega_c, omega_f = omegas
def curved(*args, **kwargs):
backend._stream.synchronize()
before = h.read(args[0], f.shape)
expected = h.reflection_oracle(before, cl, speed)
passive = h.reflection_oracle(before, cl, None, verify_projection=False)
orig_b(*args, **kwargs)
backend._stream.synchronize()
staged = h.read(args[0], f.shape)
d, k = cl.dir.astype(int), cl.fluid_idx.astype(int)
values = staged.reshape(9, -1)[h.OPP[d], k+h.CX[d]+40*h.CY[d]]
np.testing.assert_array_equal(staged[:, active], before[:, active])
b, tr, _ = h.fine_flux(before, staged, active, solid)
exchange = h.read(cl.link_exchange_gpu, (cl.count, 3)).astype(float).sum(axis=0)
row = dict(step=interval['step'], substep=len(interval['boundary'])+1,
time=interval['step']-1+.5*(len(interval['boundary'])+1),
bfl_error=float(abs(values-expected).max()),
drive_mass=.25*float((values-passive).sum()),
latest_body_impulse_x=float(.25*exchange[0]), latest_body_impulse_y=float(.25*exchange[1]),
latest_body_angular_impulse=float(.125*exchange[2]))
vec(row, 'boundary', .25*(M@b))
vec(row, 'fine_transfer', .25*(M@tr))
interval['boundary'].append(.25*(M@b))
interval['fine_transfer'].append(.25*(M@tr))
boundary_rows.append(row)
def fine(*args, **kwargs):
backend._stream.synchronize()
staged = h.read(args[0], f.shape)
incoming = pull(staged)[:, active]
orig_f(*args, **kwargs)
backend._stream.synchronize()
after = h.read(args[1], f.shape)
assert np.isfinite(after[:, active]).all() and np.isnan(after[:, solid]).all()
outer = np.ones((40, 40), bool)
outer[2:-2, 2:-2] = False
assert np.isnan(after[:, outer]).all()
# Inner ghost is stream-only: compare including legitimate NaNs.
ghost = ~fine_mesh & ~outer
np.testing.assert_array_equal(after[:, ghost], pull(staged)[:, ghost])
stat = collision(incoming, after[:, active], omega_f, .25)
interval['collision'].append(stat)
row = dict(step=interval['step'], level='fine', substep=len(interval['collision']),
max_error=stat['max_error'], max_ratio=stat['max_ratio'], regime=stat['regime'])
for key in ('drift', 'ideal_defect', 'measured_rounding'):
vec(row, key, stat[key])
collision_rows.append(row)
def coarse(*args, **kwargs):
backend._stream.synchronize()
before, fine = h.read(args[0], c.shape), h.read(args[2], f.shape)
incoming, flux, avg, ratio = coarse_arrivals(before, fine, cmask, p)
interval['coarse_transfer'], interval['average'] = flux, avg
interval['average_ratio'] = ratio
eq, _, _ = equilibrium(incoming[:, cmask])
neq = np.full(c.shape, np.nan)
neq[:, cmask] = incoming[:, cmask].astype(float)-eq
interval['transfer_neq'] = neq.ravel()[p.coalesce_coarse].copy()
orig_c(*args, **kwargs)
backend._stream.synchronize()
after = h.read(args[1], c.shape)
assert np.isfinite(after[:, cmask]).all() and np.isnan(after[:, ~cmask]).all()
stat = collision(incoming[:, cmask], after[:, cmask], omega_c, 1.)
interval['collision'].append(stat)
row = dict(step=interval['step'], level='coarse', substep=1,
max_error=stat['max_error'], max_ratio=stat['max_ratio'], regime=stat['regime'])
for key in ('drift', 'ideal_defect', 'measured_rounding'):
vec(row, key, stat[key])
collision_rows.append(row)
with patch.object(backend, '_curved', curved), patch.object(f, 'kernel', fine), patch.object(c, 'kernel', coarse):
for n in range(1, 25):
interval = dict(step=n, boundary=[], fine_transfer=[], collision=[])
backend.step(1)
coarse_g, fine_g = backend.download()
assert np.isfinite(coarse_g[:, cmask]).all() and np.isnan(coarse_g[:, ~cmask]).all()
assert np.isfinite(fine_g[:, active]).all() and np.isnan(fine_g[:, ~active]).all()
assert len(interval['boundary']) == 2 and len(interval['collision']) == 3
current = M@(coarse_g[:, cmask].sum(axis=1, dtype=float)
+.25*fine_g[:, active].sum(axis=1, dtype=float))
b = sum(interval['boundary'])
ft = sum(interval['fine_transfer'])
ct = interval['coarse_transfer']
drift = sum(s['drift'] for s in interval['collision'])
raw = current-previous-b-ft-ct
row = dict(step=n, time=n, average_bound_ratio=interval['average_ratio'])
for key, value in (('leaf', current), ('delta', current-previous), ('boundary', b),
('fine_transfer', ft), ('coarse_transfer', ct), ('raw', raw),
('collision_drift', drift), ('average_rounding', interval['average']),
('closure', raw-drift)):
vec(row, key, value)
for key, val in (('boundary', b), ('fine_transfer', ft), ('coarse_transfer', ct),
('rounding', drift), ('average', interval['average'])):
totals[key] += val
cumulative = current-initial_m-totals['boundary']-totals['fine_transfer']-totals['coarse_transfer']
vec(row, 'cumulative_raw', cumulative)
vec(row, 'cumulative_closure', cumulative-totals['rounding'])
ledger_rows.append(row)
iu, ine = observation(coarse_g, interface)
bu, _ = observation(coarse_g, bulk)
fu, fne = observation(fine_g, band)
trace.append(dict(coarse_interface_u=iu, coarse_bulk_u=bu,
fine_band_neq=fne, transfer_neq=interval['transfer_neq']))
previous = current
print(label, n, 'raw', raw.tolist(), 'cumulative_raw', cumulative.tolist(), flush=True)
assert backend.step_count == 24 and len(boundary_rows) == 48 and len(collision_rows) == 72
for name, val in geometry.items():
np.testing.assert_array_equal(getattr(cl, name), val)
np.testing.assert_array_equal(flags.ravel(), backend.fine_boundary.flags)
assert vars(source.state) == state and source.geometry.center == source.center
table(label+'_ledger.csv', ledger_rows)
table(label+'_collisions.csv', collision_rows)
table(label+'_boundary.csv', boundary_rows)
# Small compressed diagnostic arrays, not full-field population snapshots.
np.savez_compressed(OUT/(label+'_signals.npz'), **{key: np.stack([t[key] for t in trace]) for key in trace[0]})
final = ledger_rows[-1]
checks = dict(validity=True, geometry_unchanged=True, boundary_count=len(boundary_rows),
collision_count=len(collision_rows),
local_collision=max(r['max_ratio'] for r in collision_rows)<=1,
local_regime=all(r['regime'] for r in collision_rows),
bfl=max(r['bfl_error'] for r in boundary_rows)<=1e-7,
transfer_rounding=max(r['average_bound_ratio'] for r in ledger_rows)<=1,
interval_closure=max(abs(r['closure_'+k]) for r in ledger_rows for k in ('m','px','py'))<=1e-10,
cumulative_closure=max(abs(r['cumulative_closure_'+k]) for r in ledger_rows for k in ('m','px','py'))<=1e-9)
return trace, dict(speed=speed, initial_leaf=initial_m.tolist(), final_leaf=previous.tolist(),
raw_residual=[final['cumulative_raw_'+k] for k in ('m','px','py')],
exact_average_residual=(np.array([final['cumulative_raw_'+k] for k in ('m','px','py')])+totals['average']).tolist(),
cumulative={k: v.tolist() for k,v in totals.items()}, checks=checks,
max_collision_ratio=max(r['max_ratio'] for r in collision_rows),
max_collision_error=max(r['max_error'] for r in collision_rows),
max_bfl_error=max(r['bfl_error'] for r in boundary_rows),
max_interval_closure=max(abs(r['closure_'+k]) for r in ledger_rows for k in ('m','px','py')),
drive_mass=sum(r['drive_mass'] for r in boundary_rows))
def compare(driven, zero, label):
regions = json.loads((OUT/'regions.json').read_text())
summaries, rows = {}, []
for key in driven[0]:
velocity = key.endswith('_u')
floor = 1e-6 if velocity else 1e-7
first, peak = None, None
for n, (d, z) in enumerate(zip(driven, zero), 1):
difference = d[key]-z[key]
if velocity:
signal = np.linalg.norm(difference, axis=0)
base = np.linalg.norm(z[key], axis=0)
absolute = np.linalg.norm(d[key], axis=0)
elif difference.ndim == 2:
signal, base, absolute = abs(difference).max(axis=0), abs(z[key]).max(axis=0), abs(d[key]).max(axis=0)
else:
signal, base, absolute = abs(difference), abs(z[key]), abs(d[key])
eligible = (signal>floor)&(signal>10*base)
idx = int(np.argmax(signal))
def location(i):
region = key.removesuffix('_u').removesuffix('_neq')
if key == 'transfer_neq':
return regions['transfer_slots'][i]
return dict(zip(('x','y'), regions[region][i]))
record = dict(metric=key, time=n, signal=float(signal[idx]), baseline=float(base[idx]),
absolute=float(absolute[idx]), any_crossed=bool(eligible.any()), peak_crossed=bool(eligible[idx]), **location(idx))
rows.append(record)
if peak is None or record['signal']>peak['signal']:
peak = record.copy()
if first is None and eligible.any():
i = int(np.argmax(np.where(eligible, signal, -1)))
first = dict(time=n, signal=float(signal[i]), baseline=float(base[i]), absolute=float(absolute[i]), **location(i))
summaries[key] = dict(threshold=floor, baseline_factor=10, first_arrival=first, peak=peak)
# q is present only for transfer populations; normalize CSV columns.
for row in rows:
row.setdefault('q', '')
table(label+'_propagation.csv', rows)
return summaries
def main():
assert Path(h.CelerisLab.__file__).resolve() == ROOT/'src/CelerisLab/__init__.py'
assert Path(h.compiler._KERNEL_DIR).resolve() == ROOT/'src/CelerisLab/lbm/kernels'
frozen = json.loads((OUT/'frozen.json').read_text())
for path, digest in frozen['hashes'].items():
assert hashlib.sha256((ROOT/path).read_bytes()).hexdigest() == digest, path
print('SOURCE', h.CelerisLab.__file__, 'CRITERIA', frozen['frozen_utc'], flush=True)
traces, summaries = {}, {}
for label, speed in (('zero', 0.), ('blow', .002), ('suction', -.002)):
traces[label], summaries[label] = run(speed, label)
save(label+'_summary.json', summaries[label])
for label in ('blow','suction'):
summaries[label]['propagation'] = compare(traces[label], traces['zero'], label)
summaries[label]['raw_minus_zero'] = (np.array(summaries[label]['raw_residual'])-summaries['zero']['raw_residual']).tolist()
summaries[label]['checks']['propagation'] = all(s['first_arrival'] is not None for s in summaries[label]['propagation'].values())
summaries[label]['checks']['drive_sign'] = summaries[label]['speed']*summaries[label]['drive_mass']>0
save(label+'_summary.json', summaries[label])
passed = all(all(v for k,v in s['checks'].items() if k not in ('boundary_count','collision_count')) for s in summaries.values())
save('result.json', dict(passed=passed, runs=summaries))
print('RESULT', json.dumps(dict(passed=passed, summaries=summaries)), flush=True)
raise SystemExit(0 if passed else 1)
if __name__ == '__main__':
main()