348 lines
19 KiB
Python
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()
|