--- a/src/CelerisLab/common/checkpoint.py +++ b/src/CelerisLab/common/checkpoint.py @@ -220,6 +220,10 @@ raise ValueError(f"Checkpoint on-site boundary mismatch on '{key}'") if saved_cfg.get("wall_slot_width", 0) != lbm_cfg.wall_slot_width: raise ValueError("Checkpoint on-site boundary mismatch on 'wall_slot_width'") + if saved_cfg.get("top_wall_bc", "velocity") != lbm_cfg.top_wall_bc: + raise ValueError("Checkpoint on-site boundary mismatch on 'top_wall_bc'") + if lbm_cfg.top_wall_bc == "reservoir" and saved_cfg.get("rho") != lbm_cfg.rho: + raise ValueError("Checkpoint reservoir boundary mismatch on 'rho'") for key in ("lattice_model", "nx", "ny", "nz", "store_precision", "streaming", "ddf_shifting"): saved_val = saved_cfg.get(key) --- a/src/CelerisLab/config.py +++ b/src/CelerisLab/config.py @@ -35,6 +35,7 @@ } OUTLET_MAP = {"neq_extrap": 0, "zero_gradient": 1, "blended": 2} Y_WALL_BC_MAP = {"bounce_back": 0, "free_slip": 1, "on_site_velocity": 2} +TOP_WALL_BC_MAP = {"velocity": 0, "reservoir": 1} DTYPE_MAP = {"FP32": "float", "FP64": "double"} @@ -88,6 +89,7 @@ y_wall_bc: str = "bounce_back" wall_normal_velocity: float = 0.0 wall_slot_width: int = 0 + top_wall_bc: str = "velocity" omega_min: float = 0.01 omega_max: float = 1.99 @@ -112,6 +114,7 @@ for name, allowed in ( ("lattice_model", LATTICE_MODEL_MAP), ("collision", COLLISION_MAP), ("streaming", STREAMING_MAP), ("y_wall_bc", Y_WALL_BC_MAP), + ("top_wall_bc", TOP_WALL_BC_MAP), ("data_type", DTYPE_MAP), ("store_precision", PRECISION_MAP), ("inlet_profile", INLET_MAP), ("inlet_scheme", INLET_SCHEME_MAP), ("outlet_mode", OUTLET_MAP), @@ -157,6 +160,8 @@ raise ValueError("wall_slot_width must be 0 (disabled) or an integer in [2, nx]") if self.wall_slot_width and (self.y_wall_bc != "on_site_velocity" or self.velocity != 0): raise ValueError("wall_slot_width requires on_site_velocity and velocity=0") + if self.top_wall_bc == "reservoir" and (self.y_wall_bc != "on_site_velocity" or self.velocity != 0): + raise ValueError("reservoir requires on_site_velocity and velocity=0") if self.y_wall_bc == "on_site_velocity": if (not self.is_d2q9 or self.collision != "SRT" or self.streaming != "double_buffer" or self.store_precision != "FP32" @@ -202,12 +207,13 @@ self.validate() return (self.lattice_model, self.nx, self.ny, self.nz, self.data_type, self.store_precision, bool(self.ddf_shifting), self.streaming, - (self.velocity, self.wall_normal_velocity, self.wall_slot_width) + (self.velocity, self.wall_normal_velocity, self.wall_slot_width, + self.top_wall_bc, self.rho if self.top_wall_bc == "reservoir" else None) if self.y_wall_bc == "on_site_velocity" else None) @property def top_wall_normal_velocity(self) -> float: - """Uniform top target balancing the bottom's discrete requested volume.""" + """Volume-balanced top velocity target, or reservoir initialization guess.""" if self.wall_slot_width in (0, self.nx): return self.wall_normal_velocity weights = (max(0.0, 1.0-((2*x-(self.nx-1))/self.wall_slot_width)**2)**2 @@ -244,6 +250,7 @@ "WALL_NORMAL_VELOCITY": _float_literal(self.wall_normal_velocity), "WALL_SLOT_WIDTH": self.wall_slot_width, "TOP_WALL_NORMAL_VELOCITY": _float_literal(self.top_wall_normal_velocity), + "TOP_WALL_BC": TOP_WALL_BC_MAP[self.top_wall_bc], "OMEGA_COLLISION_MIN": _float_literal(self.omega_min), "OMEGA_COLLISION_MAX": _float_literal(self.omega_max), "TRT_MAGIC_PARAM": _float_literal(self.trt_magic_param), @@ -314,6 +321,7 @@ y_wall_bc=m.get("y_wall_bc", "bounce_back"), wall_normal_velocity=m.get("wall_normal_velocity", 0.0), wall_slot_width=m.get("wall_slot_width", 0), + top_wall_bc=m.get("top_wall_bc", "velocity"), omega_min=m["omega_guard"]["min"], omega_max=m["omega_guard"]["max"], threads_per_block=c["threads_per_block"], --- a/src/CelerisLab/configs/CONFIG.md +++ b/src/CelerisLab/configs/CONFIG.md @@ -143,3 +143,23 @@ The finite-slot mode is experimental: the bounded W=16, A=±0.002 checks in `research/j2-slot-2026-10-12/REVIEW.md` exceeded the fixed late-time mass-drift tolerance. It is not yet validated for steady mass balance or long runs. + +### Opt-in top reservoir + +Set `method.top_wall_bc="reservoir"` with `on_site_velocity` and +`physics.velocity=0` to fix the top density to `physics.rho` and tangential +velocity to zero. The top Cartesian normal velocity is solved locally from +known poststream populations; positive means outflow, negative means inflow. +It may vary along x and is **not** constrained to the bottom requested Q/NX. +The bottom slot/flanks and the supported lattice/collision/storage scope stay +the same. This changes the physical top boundary condition. + +The initializer still uses uniform Q/NX at the top as an initial guess, then +the density reconstruction applies from the first step. Mode, reservoir density +and tangential velocity are fixed at construction and checked on restart. +Missing `top_wall_bc` in older configs/checkpoints means `"velocity"`, which +remains the default and preserves the previous two-velocity-boundary path. +This reservoir mode is experimental. The bounded positive-slot test in +`research/j3-reservoir-2026-10-12/REVIEW.md` passed its mass thresholds but failed +the frozen full-field velocity convergence criterion; negative/zero cases and +GPU restart validation were therefore not run. Long-run stability is unverified. --- a/src/CelerisLab/cuda/compiler_v2.py +++ b/src/CelerisLab/cuda/compiler_v2.py @@ -178,6 +178,7 @@ #define WALL_NORMAL_VELOCITY {m['WALL_NORMAL_VELOCITY']} #define WALL_SLOT_WIDTH {m['WALL_SLOT_WIDTH']} #define TOP_WALL_NORMAL_VELOCITY {m['TOP_WALL_NORMAL_VELOCITY']} +#define TOP_WALL_BC {m['TOP_WALL_BC']} #define OMEGA_COLLISION_MIN {m['OMEGA_COLLISION_MIN']} #define OMEGA_COLLISION_MAX {m['OMEGA_COLLISION_MAX']} --- a/src/CelerisLab/lbm/kernels/boundary/planar_velocity.cuh +++ b/src/CelerisLab/lbm/kernels/boundary/planar_velocity.cuh @@ -31,4 +31,14 @@ f[6] = f[5] + 0.5f*(f[1]-f[2]) - 0.5f*rho*u - (1.0f/6.0f)*rho*v; f[7] = f[8] + 0.5f*(f[2]-f[1]) + 0.5f*rho*u - (1.0f/6.0f)*rho*v; } + +// Fixed top density and tangential u=0; positive Cartesian v exits upward. +// J_y=rho*v follows from known poststream populations (ordinary D2Q9). +__device__ __forceinline__ void reconstruct_top_reservoir(float* f, float rho) +{ + const float jy = f[0]+f[1]+f[2]+2.0f*(f[3]+f[5]+f[8])-rho; + f[4] = f[3] - (2.0f/3.0f)*jy; + f[6] = f[5] + 0.5f*(f[1]-f[2]) - (1.0f/6.0f)*jy; + f[7] = f[8] + 0.5f*(f[2]-f[1]) - (1.0f/6.0f)*jy; +} #endif --- a/src/CelerisLab/lbm/kernels/step/one_step_double.cu +++ b/src/CelerisLab/lbm/kernels/step/one_step_double.cu @@ -92,7 +92,11 @@ // Reconstruct f(t+dt) on the physical plane, then collide below. // This replaces, rather than compounds, the halfway wall correction. if (y == 1u) reconstruct_bottom_velocity(f, 0.0f, bottom_wall_normal_velocity(x)); +#if TOP_WALL_BC == 1 + else reconstruct_top_reservoir(f, RHO); +#else else reconstruct_top_velocity(f, U0, TOP_WALL_NORMAL_VELOCITY); +#endif #elif Y_WALL_BC == 1 apply_wall_freeslip_d2q9_y_pull(y, f, fi_in, k); #else --- /dev/null +++ b/tests/unit/cpp/reservoir.cpp @@ -0,0 +1,26 @@ +#include +#include +#define __device__ +#define __forceinline__ inline +#define WALL_NORMAL_VELOCITY 0.0f +#include "boundary/planar_velocity.cuh" + +int main() { + const int cy[9]={0,0,0,1,-1,1,-1,-1,1}; + const float w[9]={4.f/9,1.f/9,1.f/9,1.f/9,1.f/9,1.f/36,1.f/36,1.f/36,1.f/36}; + for (float rho : {1.f,1.04f}) for (float v : {-.01f,-.001f,0.f,.001f,.01f}) + for (int neq : {0,1}) { + float f[9]; + printf("%.9g %.9g %d\n",rho,v,neq); + for (int i=0;i<9;++i) { + float cu=cy[i]*v; + f[i]=w[i]*rho*(1+3*cu+4.5f*cu*cu-1.5f*v*v)+neq*.0001f*(i-4); + if (cy[i]==-1) f[i]=-7.f-i; + printf("%.9g ",f[i]); + } + puts(""); + reconstruct_top_reservoir(f,rho); + for (float value : f) printf("%.9g ",value); + puts(""); + } +} --- /dev/null +++ b/tests/unit/test_reservoir.py @@ -0,0 +1,90 @@ +"""Actual reservoir helper moments and static boundary restart contracts.""" +import json +from types import SimpleNamespace + +import h5py +import numpy as np +import pytest + +from CelerisLab.config import LBMConfig, load_lbm_config +from CelerisLab.common.checkpoint import save_checkpoint, load_checkpoint +from .test_numerical_operators import compile_run, ROOT +from .test_state_regressions import device, make_field, manager + + +def test_reservoir_moments_and_known_populations(tmp_path): + lines = compile_run(tmp_path, 'reservoir.cpp', {}) + cx = np.array([0,1,-1,0,0,1,-1,1,-1]) + cy = np.array([0,0,0,1,-1,1,-1,-1,1]) + weights = np.array([4/9]+[1/9]*4+[1/36]*4) + for start in range(0, len(lines), 3): + rho, v, neq = np.fromstring(lines[start], sep=' ') + before, after = [np.fromstring(s, sep=' ') for s in lines[start+1:start+3]] + known = cy >= 0 + np.testing.assert_array_equal(after[known], before[known]) + # The normal momentum reference uses ONLY prescribed rho and original known values. + jy = before[cy == 0].sum()+2*before[cy == 1].sum()-rho + np.testing.assert_allclose([after.sum(), after@cx, after@cy], [rho, 0., jy], atol=3e-7, rtol=0) + if not neq: + cu = cy*v + expected = weights*rho*(1+3*cu+4.5*cu**2-1.5*v*v) + np.testing.assert_allclose(after, expected, atol=3e-7, rtol=0) + assert abs(after@cy/rho-v) < 3e-7 + + +@pytest.mark.parametrize('override', [dict(top_wall_bc='unknown'), dict(y_wall_bc='bounce_back'), + dict(velocity=.01), dict(rho=0.)]) +def test_reservoir_invalid_configuration(override): + cfg = dict(y_wall_bc='on_site_velocity', top_wall_bc='reservoir', velocity=0.) + cfg.update(override) + with pytest.raises(ValueError): + LBMConfig(**cfg).validate_runtime() + + +def test_reservoir_json_and_static_signature(tmp_path): + data = json.loads((ROOT/'src/CelerisLab/configs/config_lbm.json').read_text()) + data['physics']['velocity'] = 0. + data['method'].update(y_wall_bc='on_site_velocity', top_wall_bc='reservoir', + collision='SRT', streaming='double_buffer', store_precision='FP32', ddf_shifting=False) + data['method']['les']['enabled'] = False + path = tmp_path/'config.json' + path.write_text(json.dumps(data)) + cfg = load_lbm_config(path) + assert cfg.to_macros()['TOP_WALL_BC'] == 1 + before = cfg.allocation_signature() + cfg.rho *= 1.01 + assert before != cfg.allocation_signature() + cfg.rho /= 1.01 + cfg.top_wall_bc = 'velocity' + assert before != cfg.allocation_signature() + + +@pytest.mark.parametrize('mode, damage, accepted', [ + ('reservoir', 'mode', False), ('reservoir', 'missing', False), + ('reservoir', 'rho', False), ('reservoir', 'u', False), + ('reservoir', None, True), ('velocity', 'missing', True), ('velocity', 'mode', False), +]) +def test_reservoir_checkpoint_contract(device, tmp_path, mode, damage, accepted): + field = make_field(device, velocity=0., y_wall_bc='on_site_velocity', top_wall_bc=mode) + bodies = manager(field) + bodies.sync_to_gpu(field) + path = save_checkpoint(field, SimpleNamespace(step_count=3), field.cfg, bodies, + str(tmp_path/'reservoir.h5')) + with h5py.File(path, 'a') as hf: + assert hf.attrs['ckpt_version'] == 3 + cfg = json.loads(hf.attrs['config_json']) + if damage == 'missing': cfg.pop('top_wall_bc') + elif damage == 'mode': cfg['top_wall_bc'] = 'velocity' if mode == 'reservoir' else 'reservoir' + elif damage == 'rho': cfg['rho'] = 1.01 + elif damage == 'u': cfg['velocity'] = .01 + hf.attrs['config_json'] = json.dumps(cfg) + writes, before = device.writes, field.ddf.copy() + stepper = SimpleNamespace(_step_count=0) + if accepted: + load_checkpoint(path, field, stepper, field.cfg, bodies) + np.testing.assert_array_equal(field.ddf, before) + assert stepper._step_count == 3 + else: + with pytest.raises(ValueError, match='boundary mismatch'): + load_checkpoint(path, field, stepper, field.cfg, bodies) + assert device.writes == writes and stepper._step_count == 0 --- a/tests/validation/run_j1.py +++ b/tests/validation/run_j1.py @@ -28,13 +28,14 @@ u_change=2e-5, v_change=2e-7, budget_relative_residual=2e-7) -def make_sim(v, mode='on_site_velocity', nx=16, top_u=.02, slot_width=0): +def make_sim(v, mode='on_site_velocity', nx=16, top_u=.02, slot_width=0, top_mode='velocity'): config = json.loads((ROOT/'src/CelerisLab/configs/config_lbm.json').read_text()) config['grid'].update(nx=nx, ny=35, nz=1, lattice_model='D2Q9') config['physics'].update(viscosity=.1, velocity=top_u, rho=1.) config['method'].update(collision='SRT', streaming='double_buffer', store_precision='FP32', ddf_shifting=False, - y_wall_bc=mode, wall_normal_velocity=v, wall_slot_width=slot_width) + y_wall_bc=mode, wall_normal_velocity=v, wall_slot_width=slot_width, + top_wall_bc=top_mode) config['method']['les']['enabled'] = False with TemporaryDirectory(prefix='celeris-j1-config-') as folder: lbm, body = Path(folder)/'lbm.json', Path(folder)/'body.json' --- a/tests/validation/run_j2_slot.py +++ b/tests/validation/run_j2_slot.py @@ -38,12 +38,12 @@ return bool(all(np.array_equal(a, b) for a, b in zip(*snapshots))) -def restart_check(sim, amplitude): +def restart_check(sim, amplitude, top_mode='velocity'): with TemporaryDirectory(prefix='celeris-j2-checkpoint-') as folder: path = sim.save_checkpoint(str(Path(folder)/'slot.h5')) sim.run(7) expected, flags, count = sim.get_ddf().copy(), sim.get_flags().copy(), sim.stepper.step_count - with make_sim(amplitude, nx=NX, top_u=0., slot_width=WIDTH) as target: + with make_sim(amplitude, nx=NX, top_u=0., slot_width=WIDTH, top_mode=top_mode) as target: target.load_checkpoint(path) target.run(7) equal = (np.array_equal(expected, target.get_ddf()) @@ -52,10 +52,10 @@ return bool(equal) -def run_case(amplitude): +def run_case(amplitude, top_mode='velocity', check_restart=True): bottom, top, slot = targets(amplitude) scale = max(abs(amplitude), .002) - with make_sim(amplitude, nx=NX, top_u=0., slot_width=WIDTH) as sim: + with make_sim(amplitude, nx=NX, top_u=0., slot_width=WIDTH, top_mode=top_mode) as sim: sim.initialize() rho, ux, uy = observables(populations(sim)) initial_mass = float(rho.sum()) @@ -90,13 +90,15 @@ symmetry = dict(rho=float(np.max(np.abs(rho-rho[:, ::-1]))), u=float(np.max(np.abs(ux+ux[:, ::-1]))/scale), v=float(np.max(np.abs(uy-uy[:, ::-1]))/scale)) - boundary_error = float(max(np.abs(uy[0]-bottom).max(), np.abs(uy[-1]-top).max(), + boundary_error = float(max(np.abs(uy[0]-bottom).max(), + np.abs(uy[-1]-top).max() if top_mode == 'velocity' else 0., np.abs(ux[[0, -1]]).max())) + top_density_error = float(np.abs(rho[-1]-sim.lbm_cfg.rho).max()/sim.lbm_cfg.rho) offset = abs(mass-initial_mass)/initial_mass speed = float(np.hypot(ux, uy).max()) density_error = float(np.abs(rho-1).max()) nonuniform = bool(uy[8, NX//2]*amplitude > 0 and np.abs(ux).max() > 1e-5) if amplitude else True - restart = restart_check(sim, amplitude) if amplitude > 0 else None + restart = restart_check(sim, amplitude, top_mode) if check_restart and amplitude > 0 else None budget = population_budget(sim, bottom_slot=slot) checks = dict(steady=steady, initialization=initial_error <= GATES['boundary_error'], boundary=boundary_error <= GATES['boundary_error'], @@ -105,14 +107,25 @@ flux=flux_error <= GATES['flux_error'] and flux_span <= GATES['flux_span'], symmetry=symmetry['rho'] <= GATES['rho_symmetry'] and max(symmetry['u'], symmetry['v']) <= GATES['velocity_symmetry'], - zero=bool(amplitude or speed <= GATES['zero_speed']), nonuniform=nonuniform, - restart=restart is not False, audit=budget['instrumented_matches_plain'] and + audit=budget['instrumented_matches_plain'] and budget['max_relative_residual'] <= GATES['budget_residual']) + if amplitude: + checks['nonuniform'] = nonuniform + else: + checks.pop('flux') # Relative flux error has no nonzero reference here. + checks['zero_speed'] = speed <= GATES['zero_speed'] + if restart is not None: + checks['restart'] = restart + if top_mode == 'reservoir': + checks['top_density'] = top_density_error <= 5e-7 checks = {name: bool(passed) for name, passed in checks.items()} return dict(amplitude=amplitude, width=WIDTH, nx=NX, height=32, viscosity=.1, top_u=0., steps=sim.stepper.step_count-16, max_steps=40*CHUNK, checks=checks, passed=all(checks.values()), - requested_volume=request_q, top_target=top, bottom_targets=bottom.tolist(), + requested_volume=request_q, top_initial_guess=top, + top_target=top if top_mode == 'velocity' else None, bottom_targets=bottom.tolist(), velocity_scale=scale, rho_scale=1., initial_boundary_error=initial_error, + top_mode=top_mode, top_density_error=top_density_error, + top_velocity_range=[float(uy[-1].min()), float(uy[-1].max())], boundary_error=boundary_error, mass_offset=offset, mass_window_span=span, flux_error=flux_error, flux_span=flux_span, section_y=sections, mass_flux_by_y=actual_mass_flux.tolist(), volume_flux_by_y=actual_volume_flux.tolist(), --- /dev/null +++ b/tests/validation/run_j3_reservoir.py @@ -0,0 +1,81 @@ +"""Sequential reservoir experiment: stop after a failed positive case.""" +import argparse +import json +import os +from pathlib import Path +import sys +from unittest.mock import patch + +import numpy as np +import CelerisLab +from CelerisLab.cuda import compiler_v2 as compiler +from run_j1 import ROOT, make_sim, off_path_check +from run_j2_slot import NX, WIDTH, GATES, run_case, restart_check + + +def old_velocity_check(baseline, width, top_u): + states = [] + for source in (baseline, Path(compiler.kernel_path(''))): + with patch.object(compiler, '_KERNEL_DIR', str(source)): + with make_sim(.002, nx=NX, slot_width=width, top_u=top_u) as sim: + sim.initialize() + initial = sim.get_ddf().copy() + sim.run(64) + states.append((initial, sim.get_ddf().copy(), sim.get_flags().copy())) + return bool(all(np.array_equal(a, b) for a, b in zip(*states))) + + +def checkpoint_check(): + with make_sim(.002, nx=NX, top_u=0., slot_width=WIDTH, top_mode='reservoir') as sim: + sim.initialize() + sim.run(11) + return restart_check(sim, .002, top_mode='reservoir') + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--output', type=Path, required=True) + parser.add_argument('--baseline-kernels', type=Path, required=True) + args = parser.parse_args() + assert Path.cwd() == ROOT == Path('/home/frank14f/CelerisLab') + assert sys.executable == '/home/frank14f/anaconda3/envs/pycuda_3_10/bin/python' + assert Path(CelerisLab.__file__).resolve() == ROOT/'src/CelerisLab/__init__.py' + assert Path(compiler.kernel_path('kernel_v2.cu')).resolve() == ROOT/'src/CelerisLab/lbm/kernels/kernel_v2.cu' + assert os.environ.get('CUDA_VISIBLE_DEVICES') in ('0', '1') + report = dict(python=sys.executable, package=CelerisLab.__file__, + cuda_source=compiler.kernel_path('kernel_v2.cu'), physical_gpu=os.environ['CUDA_VISIBLE_DEVICES'], + gates=dict(GATES, top_density_error=5e-7), regressions={}, cases=[], + status=dict(positive='not_tested', negative='not_tested', zero='not_tested', + checkpoint='not_tested')) + + def save(): + args.output.write_text(json.dumps(report, indent=2)+'\n') + + for name, result in ( + ('j1_velocity_top', old_velocity_check(args.baseline_kernels, 0, .02)), + ('j2_velocity_top', old_velocity_check(args.baseline_kernels, WIDTH, 0.)), + ('default_off', off_path_check(args.baseline_kernels)), + ): + report['regressions'][name] = 'passed' if result else 'failed' + save() + print(json.dumps(report['regressions']), flush=True) + if 'failed' in report['regressions'].values(): + return 1 + for name, amplitude in (('positive', .002), ('negative', -.002), ('zero', 0.)): + case = run_case(amplitude, top_mode='reservoir', check_restart=False) + report['cases'].append(case) + report['status'][name] = 'passed' if case['passed'] else 'failed' + save() + print(json.dumps({k: case[k] for k in ('amplitude', 'steps', 'checks', 'mass_offset', + 'mass_window_span', 'flux_error', 'top_density_error', + 'top_velocity_range', 'symmetry')}), flush=True) + if not case['passed']: + return 1 + report['status']['checkpoint'] = 'passed' if checkpoint_check() else 'failed' + save() + print(json.dumps(report['status']), flush=True) + return 0 if all(status == 'passed' for status in report['status'].values()) else 1 + + +if __name__ == '__main__': + raise SystemExit(main())