Add jet, surface and fixed-patch tests and runnable examples

This commit is contained in:
Frank14f committed 2026-10-15 10:13:49 +08:00
1 parent ed1a6ee54e
commit cdbb9c42f6
48 files changed
+9267 -210

No files matched your search

+93
View File
@@ -0,0 +1,93 @@
// CPU qualifier shim: executes actual operators, never creates a GPU context.
#include <cmath>
#include <initializer_list>
#include <cstdio>
#define __device__
#define __constant__
#define __forceinline__ inline
#define LATTICE_D2Q9 0
#define LATTICE_D3Q19 1
#define NQ (DIM == 2 ? 9 : 19)
#define USE_LES 0
#define OMEGA_COLLISION_MIN .001f
#define OMEGA_COLLISION_MAX 1.99f
struct Params {float omega, fx, fy, fz;} d_params;
#include "core/descriptors.cuh"
#include "operators/macro.cuh"
#include "operators/collision_srt.cuh"
#include "operators/collision_trt.cuh"
#include "operators/collision_mrt.cuh"
#include "operators/forcing_guo.cuh"
#include "operators/helpers.cuh"
#include "boundary/inlet/common.cuh"
void emit(const float* f) {for(int i=0;i<NQ;++i) printf("%.9g ",f[i]); puts("");}
int main() {
#ifdef MRT_WEAK_FORCE_CHECK
// Exercise actual FP32 collision against an independent FP64 matrix oracle.
for (float omega : {.7f, 1.25f, 1.8f})
for (float density : {1.f, 1.000001f, 1.04f})
for (float velocity : {0.f, .0075f})
for (float force : {0.f, 1.46484375e-6f, 1.e-8f}) {
d_params = {omega, force, -.3f * force, 0.f};
float f[9], source[9], rho, u, v;
compute_feq(density, velocity, -.4f * velocity, f);
if (velocity != 0.f)
for (int i=0; i<9; ++i) f[i] += 1.e-5f * ((i*7)%5-2);
compute_rho_u(f, rho, u, v);
float uc=u, vc=v;
apply_guo_velocity_correction(uc, vc, d_params.fx, d_params.fy, rho);
compute_guo_forcing(uc, vc, d_params.fx, d_params.fy, source);
printf("%.9g %.9g %.9g %.9g %.9g %.9g\n", omega, rho, uc, vc,
d_params.fx, d_params.fy);
emit(f); emit(source);
collide_dispatch(f, rho, u, v);
emit(f);
}
return 0;
#endif
for (float omega : {0.7f, 1.25f, 1.8f}) {
d_params = {omega, .001f, -.0007f, .0003f};
float f[NQ], feq[NQ], source[NQ];
#if DIM == 2
compute_feq(1.17f,.03f,-.02f,f);
#else
compute_feq(1.17f,.03f,-.02f,.01f,f);
#endif
// Non-equilibrium content: the expected result uses actual raw moments.
for(int i=0;i<NQ;++i) f[i] += .0001f * ((i*7)%5-2);
float rho,u,v,w=0;
#if DIM == 2
compute_rho_u(f,rho,u,v);
float uc=u,vc=v;
apply_guo_velocity_correction(uc,vc,d_params.fx,d_params.fy,rho);
compute_feq(rho,uc,vc,feq);
compute_guo_forcing(uc,vc,d_params.fx,d_params.fy,source);
#else
compute_rho_u(f,rho,u,v,w);
float uc=u,vc=v,wc=w;
apply_guo_velocity_correction(uc,vc,wc,d_params.fx,d_params.fy,d_params.fz,rho);
compute_feq(rho,uc,vc,wc,feq);
compute_guo_forcing(uc,vc,wc,d_params.fx,d_params.fy,d_params.fz,source);
#endif
emit(f); emit(feq); emit(source);
#if DIM == 2
collide_dispatch(f,rho,u,v);
#else
collide_dispatch(f,rho,u,v,w);
#endif
emit(f);
}
// Equilibrium and shifted inlet closure for non-unit density.
d_params={1.25f,0,0,0};
float f[NQ];
#if DIM == 2
compute_feq(1.17f,.03f,-.02f,f);
printf("closure %.9g\n", west_velocity_rho_closure_d2q9(f,.03f));
collide_dispatch(f,1.17f,.03f,-.02f);
#else
compute_feq(1.17f,.03f,-.02f,.01f,f);
printf("closure %.9g\n", west_velocity_rho_closure_d3q19(f,.03f));
collide_dispatch(f,1.17f,.03f,-.02f,.01f);
#endif
emit(f);
}
+30
View File
@@ -0,0 +1,30 @@
#include <cstdio>
#include <initializer_list>
#define __device__
#define __forceinline__ inline
#include "boundary/planar_velocity.cuh"
int main() {
const int cx[9]={0,1,-1,0,0,1,-1,1,-1};
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 (int top : {0,1}) for (float rho : {1.f,1.04f})
for (float u : {-.02f,0.f,.02f}) for (float v : {-.01f,-.001f,0.f,.001f,.01f})
for (int neq : {0,1}) {
float f[9];
printf("%d %.9g %.9g %.9g %d\n",top,rho,u,v,neq);
for (int i=0;i<9;++i) {
float cu=cx[i]*u+cy[i]*v;
f[i]=w[i]*rho*(1+3*cu+4.5f*cu*cu-1.5f*(u*u+v*v));
f[i]+=neq*.0001f*(i-4);
// Unknown inputs must be completely overwritten, never consumed.
if (cy[i]==(top?-1:1)) f[i]=-7.f-i;
printf("%.9g ",f[i]);
}
puts("");
if (top) reconstruct_top_velocity(f,u,v);
else reconstruct_bottom_velocity(f,u,v);
for (float value : f) printf("%.9g ",value);
puts("");
}
}
+26
View File
@@ -0,0 +1,26 @@
#include <cstdio>
#include <initializer_list>
#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("");
}
}
+238
View File
@@ -0,0 +1,238 @@
#include <initializer_list>
#include <cstdlib>
#include <cmath>
#include <cstdio>
#include <cstring>
#include <cstdint>
#define __device__
#define __global__
#define __forceinline__ inline
#define __constant__
#define LATTICE_D2Q9 0
#define LATTICE_D3Q19 1
#define LATTICE_MODEL 0
#define DIM 2
#define NQ 9
#define NX 8
#define NY 8
#define NZ 1
#define NT 1
#define USE_LES 0
#define RHO 1.0f
#ifndef COLLISION_MODEL
#define COLLISION_MODEL 0
#endif
#define OMEGA_COLLISION_MIN .001f
#define OMEGA_COLLISION_MAX 1.99f
struct Idx {unsigned x=0,y=0,z=0;}threadIdx,blockIdx,blockDim;
struct Params{float omega=1.25f,fx=0,fy=0;}d_params;
inline float atomicAdd(float*p,float v){*p+=v;return *p;}
inline int obs_force_index(int,int){return 0;}
inline int obs_torque_index(int,int){return 0;}
#include "core/flags.cuh"
#include "core/descriptors.cuh"
#include "core/precision.cuh"
#include "core/layout.cuh"
#include "operators/macro.cuh"
#include "operators/collision_srt.cuh"
#include "operators/collision_trt.cuh"
#include "operators/collision_mrt.cuh"
#include "operators/forcing_guo.cuh"
#include "operators/helpers.cuh"
#include "streaming/esopull_single_buffer.cuh"
#include "streaming/esopull_semantic_helpers.cuh"
#include "boundary/bounce_back.cuh"
#include "boundary/curved_boundary.cuh"
#include "step/esopull_sync.cu"
#define U0 .03f
#define OBS_BODY_SLOT_FLOATS 7
#define __launch_bounds__(...)
inline int obs_sensor_index(int,int){return 0;}
#include "boundary/inlet_outlet.cuh"
#include "streaming/pull_double_buffer.cuh"
#include "step/one_step_double.cu"
#include "step/one_step_esopull.cu"
#include "step/aux_kernels.cu"
#include "step/init_flow.cu"
#include <cassert>
#include <algorithm>
void close(float a,float b,float tol=3e-7f) {
if(fabsf(a-b)>tol) {fprintf(stderr,"got %.9g expected %.9g\n",a,b); abort();}
}
int main() {
float fi[NX*NY*NQ],before[NX*NY*NQ],f[NQ],physical[NQ],staged[6*NX+2*NY*NQ];
// Nonuniform populations: exact same-time address inverse, both parities.
for(unsigned long t=0;t<4;t++) {
for(int i=0;i<NX*NY*NQ;i++) fi[i]=.001f*((i*71)%113);
memcpy(before,fi,sizeof fi);
for(unsigned cell : {9u,27u,54u}) {
unsigned long j[NQ]; compute_neighbors(cell,j);
load_physical_node_esopull(cell,fi,j,t,f);
store_physical_node_esopull(cell,f,fi,j,t);
assert(memcmp(before,fi,sizeof fi)==0);
}
for(unsigned cell : {9u,27u,54u}) {
// Decode/encode preserves arbitrary non-equilibrium, not just feq.
DecodeCellsToPhysical(fi,physical,&cell,1,t);
EncodePhysicalToCells(fi,physical,&cell,1,t);
}
for(int i=0;i<NX*NY*NQ;i++) close(fi[i],before[i],4e-8f);
}
// Stage y-wall donors from exact labelled postcollision states. Both
// parities and wall rows. Staging is complete before any backing write.
for(unsigned long t=1;t<3;t++) {
for(unsigned long k=0;k<NX*NY;k++) {
unsigned long j[NQ];compute_neighbors(k,j);
for(int i=0;i<NQ;i++) f[i]=100*i+k;
store_f_esopull(k,f,fi,j,t-1);
}
for(unsigned side=0;side<2;side++) for(unsigned x=0;x<NX;x++) {
blockIdx.y=side;threadIdx.x=x;PrepareEsoPullBoundaries(fi,staged,t);
unsigned k=(side?NY-2:1)*NX+x;
unsigned donor[3]={side?3u:4u,side?5u:6u,side?8u:7u};
#if Y_WALL_BC == 1
std::swap(donor[1],donor[2]);
#endif
for(int a=0;a<3;a++) close(staged[(side*NX+x)*3+a],100*donor[a]+k,0);
}
}
blockIdx.y=0;threadIdx.x=0;
// t=0 uses the real initialization mapping, not double-buffer raw slots.
for(unsigned long k=0;k<NX*NY;k++) {unsigned x,y;coordinates(k,x,y);write_equilibrium_esopull(fi,k,x,y,.03f);}
compute_feq(1,.03f,0,f);
for(unsigned side=0;side<2;side++) for(unsigned x=0;x<NX;x++) {
blockIdx.y=side;threadIdx.x=x;PrepareEsoPullBoundaries(fi,staged,0);
unsigned donor[3]={side?3u:4u,side?5u:6u,side?8u:7u};
#if Y_WALL_BC == 1
std::swap(donor[1],donor[2]);
#endif
for(int a=0;a<3;a++) close(staged[(side*NX+x)*3+a],f[donor[a]]);
}
blockIdx.y=0;threadIdx.x=0;
// Complete open-boundary donor staging, including initial-state mapping,
// odd/even backing, corners and both known-direction free-slip repairs.
for(unsigned long t=0;t<3;t++) {
for(unsigned long k=0;k<NX*NY;k++) {
unsigned long j[NQ];compute_neighbors(k,j);
for(unsigned i=0;i<NQ;i++)f[i]=.001f*(100*i+k);
if(t)store_f_esopull(k,f,fi,j,t-1);
else store_physical_node_esopull(k,f,fi,j,t);
}
for(unsigned side=0;side<2;side++) for(unsigned n=0;n<std::max(NX,NY);n++) {
blockIdx.y=side;threadIdx.x=n;PrepareEsoPullBoundaries(fi,staged,t);
if(n>=NY)continue;
const float* donor=staged+6*NX+(side*NY+n)*NQ;
unsigned long k=linear_index(side?NX-2:1,n);
for(unsigned i=0;i<NQ;i++)close(donor[i],.001f*(100*i+k),0);
float known[NQ]={0};repair_west_knowns_from_donor_d2q9(known,donor,0,n);
if(n==1u){close(known[3],donor[3],0);close(known[8],donor[8],0);}
if(n==NY-2u){close(known[4],donor[4],0);close(known[6],donor[6],0);}
}
}
blockIdx.y=0;threadIdx.x=0;
#if USE_DDF_SHIFTING
// Initialization must not form w*(1+tiny)-w and erase a representable flow.
for(int layout=0;layout<2;layout++) {
for(unsigned long k=0;k<NX*NY;k++) {
const float velocity=1e-9f*(1+k%7);
if(layout) {
unsigned x,y;coordinates(k,x,y);
write_equilibrium_esopull(fi,k,x,y,velocity);
} else write_equilibrium(fi,k,velocity);
}
for(unsigned long k=0;k<NX*NY;k++) {
unsigned long j[NQ];compute_neighbors(k,j);
if(layout)load_f_esopull(k,f,fi,j,0);
else for(unsigned i=0;i<NQ;i++)f[i]=fi[index_f(k,i)];
float rho,u,v;compute_rho_u(f,rho,u,v);
close(rho,1,1e-7f);close(u,1e-9f*(1+k%7),2e-15f);close(v,0,2e-15f);
}
}
#endif
// Moving-wall equilibrium with rho != 1, all links/branches/fallbacks.
for(float q : {.2f,.5f,.8f}) for(unsigned fallback : {0u,1u}) {
compute_feq(1.17f,.02f,-.01f,f);
for(unsigned dir=1;dir<NQ;dir++) {
float value=compute_bouzidi_reflection(f[dir],f[dir],f[opp_dir(dir)],q,fallback)
+compute_moving_wall_addend(dir,q,fallback,.02f,-.01f,1.17f);
close(value,f[opp_dir(dir)]);
}
}
// Verify both curved kernel call paths supply physical density to the
// moving correction, not just the pure arithmetic helper above.
for(unsigned long t=0;t<3;t++) {
compute_feq(1.17f,.02f,-.01f,f);
for(unsigned long k=0;k<NX*NY;k++) {
unsigned long j[NQ];compute_neighbors(k,j);
if(t)store_f_esopull(k,f,fi,j,t-1);
else for(unsigned i=0;i<NQ;i++)fi[index_f(k,i)]=f[i];
}
unsigned cell=27;unsigned char dir=7,fallback=0,slot;
int bid=0;float q=.5f,rx=-10,ry=-20,rz=0,corr;
float act[OBS_BODY_SLOT_FLOATS]={0};act[OBS_BODY_SLOT_FLOATS-1]=.001f;
if(t) {
unsigned target;
PrepareEsoPullCurvedKernel(fi,&cell,&dir,&q,&rx,&ry,&fallback,&bid,
act,nullptr,&corr,&target,&slot,1,t);
} else {
CurvedBoundaryKernel(fi,&cell,&dir,&q,&rx,&ry,&rz,&fallback,&bid,act,nullptr,1);
unsigned long j[NQ];compute_neighbors(cell,j);
corr=fi[index_f(j[dir],opp_dir(dir))];
}
close(corr,f[opp_dir(dir)]);
}
// Execute actual region kernel: raw layout and same-time EsoPull, non-unit
// density, nonzero u, opposite signs. Entire vector matches exact difference.
unsigned cell=27;int bid=0;uint16_t flags[NX*NY];
for(auto& fl:flags) fl=FLAG_FLUID;
float action[7]={.001f,-.0007f,0,0,0,0,0};
for(unsigned long t=0;t<3;t++) {
float old[NQ],expected[NQ],new_eq[NQ];
compute_feq(1.17f,.03f,-.02f,old);
compute_feq(1.17f,.03f+action[0]/1.17f,-.02f+action[1]/1.17f,new_eq);
for(int i=0;i<NQ;i++) expected[i]=new_eq[i];
for(unsigned long k=0;k<NX*NY;k++) {
unsigned long j[NQ];compute_neighbors(k,j);
if(t)store_physical_node_esopull(k,old,fi,j,t);
else for(int i=0;i<NQ;i++) fi[index_f(k,i)]=old[i];
}
ForceRegionKernel(fi,flags,&cell,&bid,action,1,t);
unsigned long j[NQ];compute_neighbors(cell,j);
if(t)load_physical_node_esopull(cell,fi,j,t,f);
else for(int i=0;i<NQ;i++)f[i]=fi[index_f(cell,i)];
for(int i=0;i<NQ;i++)close(f[i],expected[i]);
}
// Nonuniform closed-channel evolution through actual step kernels. x is
// periodic, both y walls explicit; compare postcollision spatial values.
float db[NX*NY*NQ],out[NX*NY*NQ];
for(unsigned long k=0;k<NX*NY;k++) {
unsigned x,y;coordinates(k,x,y);
flags[k]=(y==0||y==NY-1)?FLAG_SOLID|FLAG_BC_WALL:FLAG_FLUID;
compute_feq(1+.001f*cosf(k),.01f*sinf(x+y),.005f*cosf(x-y),f);
unsigned long j[NQ];compute_neighbors(k,j);
store_f_esopull(k,f,fi,j,0);
for(int i=0;i<NQ;i++)db[index_f(k,i)]=f[i];
}
for(unsigned long t=1;t<=4;t++) {
for(unsigned side=0;side<2;side++) for(unsigned x=0;x<NX;x++) {
threadIdx.x=x;blockIdx.y=side;PrepareEsoPullBoundaries(fi,staged,t);
}
for(unsigned y=0;y<NY;y++) for(unsigned x=0;x<NX;x++) {
threadIdx.x=x;blockIdx.y=y;
OneStep(flags,db,out);
EsoPullStep(fi,flags,nullptr,nullptr,staged,t);
}
for(unsigned y=1;y<NY-1;y++) for(unsigned x=0;x<NX;x++) {
unsigned long k=linear_index(x,y),j[NQ];compute_neighbors(k,j);
for(unsigned i=0;i<NQ;i++)
close(load_physical_dir_pre_streaming(k,i,fi,j,t+1),out[index_f(k,i)]);
}
memcpy(db,out,sizeof db);
}
puts("same-time sync, staged wall donors, moving-wall and force-region passed");
}
+10
View File
@@ -0,0 +1,10 @@
#include <cmath>
#include <cstdio>
#define __device__
#define __forceinline__ inline
#include "boundary/planar_velocity.cuh"
int main() {
for (unsigned x=0; x<NX; ++x)
printf("%.9g\n", bottom_wall_normal_velocity(x));
}
+152
View File
@@ -0,0 +1,152 @@
"""CPU-only regressions for configuration and isolated compilation inputs."""
import hashlib
import json
import os
from pathlib import Path
import re
import subprocess
import sys
import tempfile
from concurrent.futures import ThreadPoolExecutor
from threading import Barrier
import unittest
from unittest.mock import patch
from CelerisLab.config import LBMConfig, load_lbm_config
from CelerisLab.cuda import compiler
class ConfigContracts(unittest.TestCase):
def test_explicit_missing_config_never_falls_back(self):
with tempfile.TemporaryDirectory() as d:
with self.assertRaises(FileNotFoundError):
load_lbm_config(str(Path(d) / "missing.json"))
with self.assertRaises(FileNotFoundError):
load_lbm_config("")
def test_float_literals_preserve_selected_physics(self):
cfg = LBMConfig(rho=1.04, omega_min=.01234, outlet_blend_alpha=.712345)
macros = cfg.to_macros()
self.assertEqual(float(macros["RHO"]), cfg.rho)
self.assertEqual(float(macros["OMEGA_COLLISION_MIN"].rstrip("f")), cfg.omega_min)
self.assertEqual(float(macros["OUTLET_BLEND_ALPHA"].rstrip("f")), cfg.outlet_blend_alpha)
cfg.rho = .04
self.assertEqual(float(cfg.to_macros()["RHO"]), .04)
def test_invalid_inputs_raise_even_without_assertions(self):
for kwargs in ({"nx": 0}, {"threads_per_block": 0}, {"threads_per_block": 2048},
{"rho": -1}, {"rho": float("nan")}, {"viscosity": float("inf")},
{"inlet_profile": "missing"}, {"outlet_mode": "missing"}):
with self.subTest(kwargs=kwargs), self.assertRaises(ValueError):
LBMConfig(**kwargs).validate()
def test_d3_schema_remains_distinct_from_runtime_capability(self):
cfg = LBMConfig(lattice_model="D3Q19", nz=8)
cfg.validate()
self.assertEqual((cfg.dim, cfg.nq), (3, 19))
with self.assertRaisesRegex(NotImplementedError, "dimension-complete"):
cfg.validate_runtime()
def test_runtime_grid_requires_interior_and_ghost_cells(self):
for kwargs in ({"nx": 1}, {"nx": 2}, {"ny": 1}, {"ny": 2}, {"ny": 3}):
with self.subTest(kwargs=kwargs):
cfg = LBMConfig(**kwargs)
cfg.validate()
with self.assertRaisesRegex(ValueError, "wall-adjacent rows"):
cfg.validate_runtime()
LBMConfig(nx=3, ny=4).validate_runtime()
def test_allocation_signature_tracks_layout_not_collision(self):
cfg = LBMConfig()
signature = cfg.allocation_signature()
cfg.collision = "MRT"
cfg.threads_per_block = 128
self.assertEqual(signature, cfg.allocation_signature())
for name, value in (("nx", 1024), ("store_precision", "FP16S"),
("streaming", "esopull"), ("ddf_shifting", True)):
changed = LBMConfig(**{name: value})
self.assertNotEqual(signature, changed.allocation_signature())
def test_cpu_imports_do_not_require_pycuda(self):
# Refuse the dependency even on development machines with it installed.
code = '''
import sys
sys.modules["pycuda"] = None
from CelerisLab.config import LBMConfig
from CelerisLab.lbm.equilibrium import compute_feq_d2q9
from CelerisLab.body.geometry.circle import CircleGeometry
from CelerisLab.cuda import compiler
assert "pycuda.driver" not in sys.modules
assert abs(sum(compute_feq_d2q9(1.,0.,0.)) - 1.) < 1e-6
'''
subprocess.run([sys.executable, "-c", code], check=True)
class BuildContracts(unittest.TestCase):
@staticmethod
def fake_compile(command, check):
source = Path(command[-3])
grid = (source.parent / "config/config_grid.h").read_text()
objects = (source.parent / "config/config_objects.h").read_text()
data = {"nx": int(re.search(r"#define NX\s+(\d+)", grid)[1]),
"objects": int(re.search(r"#define N_OBJS\s+(\d+)", objects)[1])}
Path(command[-1]).write_text(json.dumps(data))
def test_parallel_builds_never_share_headers_or_outputs(self):
sources = list(Path(compiler.kernel_path("config")).glob("*.h"))
before = {p: hashlib.sha256(p.read_bytes()).hexdigest() for p in sources}
barrier = Barrier(2)
with tempfile.TemporaryDirectory() as root:
def work(nx, count):
build = compiler.generate_config(LBMConfig(nx=nx), count, build_root=root)
barrier.wait()
# Compatibility form is context-local even without explicit path.
output = compiler.compile_kernel()
data = json.loads(Path(output).read_text())
self.assertEqual(data, {"nx": nx, "objects": count})
metadata = json.loads(Path(output + ".json").read_text())
self.assertEqual(metadata["config"]["nx"], nx)
compiler.cleanup_build_dir(build)
return output
with patch.object(compiler.subprocess, "run", self.fake_compile):
with ThreadPoolExecutor(max_workers=2) as pool:
a, b = pool.submit(work, 64, 1), pool.submit(work, 256, 3)
self.assertNotEqual(a.result(), b.result())
self.assertEqual(before, {p: hashlib.sha256(p.read_bytes()).hexdigest() for p in sources})
def test_explicit_build_survives_interleaved_generation(self):
with tempfile.TemporaryDirectory() as root:
a = compiler.generate_config(LBMConfig(nx=64), 1, build_root=root)
b = compiler.generate_config(LBMConfig(nx=128), 2, build_root=root)
with patch.object(compiler.subprocess, "run", self.fake_compile):
output = compiler.compile_kernel(build_dir=a)
self.assertEqual(json.loads(Path(output).read_text())["nx"], 64)
self.assertTrue(Path(b, "config/config_grid.h").exists())
with self.assertRaises(ValueError):
compiler.compile_kernel(build_dir=a, output="../unsafe.ptx")
with self.assertRaises(ValueError):
compiler.cleanup_build_dir(compiler.kernel_path(""))
compiler.cleanup_build_dir(a)
compiler.cleanup_build_dir(a)
compiler.cleanup_build_dir(b)
with self.assertRaises(RuntimeError):
compiler.compile_kernel()
def test_failed_compile_cannot_implicitly_load_previous_artifact(self):
with tempfile.TemporaryDirectory() as root:
build = compiler.generate_config(LBMConfig(nx=64), build_root=root)
with patch.object(compiler.subprocess, "run", self.fake_compile):
previous = compiler.compile_kernel(build_dir=build)
old_bytes = Path(previous).read_bytes()
failure = subprocess.CalledProcessError(1, "nvcc")
with patch.object(compiler.subprocess, "run", side_effect=failure):
with self.assertRaises(subprocess.CalledProcessError):
compiler.compile_kernel(build_dir=build)
self.assertEqual(Path(previous).read_bytes(), old_bytes)
with self.assertRaisesRegex(RuntimeError, "No compiled PTX"):
compiler.load_module()
compiler.cleanup_build_dir(build)
if __name__ == "__main__":
unittest.main()
+90
View File
@@ -0,0 +1,90 @@
"""Context-stack logic with an explicit driver model; no CUDA initialization."""
import importlib.util
from pathlib import Path
import sys
from types import ModuleType, SimpleNamespace
import unittest
from unittest.mock import patch
class ContextOwnership(unittest.TestCase):
def setUp(self):
self.stack = []
stack = self.stack
class Context:
def __init__(self):
self.detached = False
stack.append(self)
def push(self):
if self.detached:
raise RuntimeError("detached")
stack.append(self)
@staticmethod
def pop():
stack.pop()
def detach(self):
self.detached = True
class Device:
@staticmethod
def count(): return 1
def __init__(self, device): pass
def make_context(self): return Context()
driver = ModuleType("pycuda.driver")
driver.Device, driver.init = Device, lambda: None
pycuda = ModuleType("pycuda")
pycuda.driver = driver
path = Path(__file__).resolve().parents[2] / "src/CelerisLab/cuda/context.py"
spec = importlib.util.spec_from_file_location("context_under_test", path)
self.module = importlib.util.module_from_spec(spec)
with patch.dict(sys.modules, {"pycuda": pycuda, "pycuda.driver": driver}):
spec.loader.exec_module(self.module)
def test_interleaving_and_non_lifo_close_preserve_other_current_context(self):
a, b = self.module.CudaContext(), self.module.CudaContext()
self.assertEqual(self.stack, [])
with b.activate():
with a.activate():
self.assertIs(self.stack[-1], a._ctx)
self.assertIs(self.stack[-1], b._ctx)
a.close() # closing A must not pop B
self.assertIs(self.stack[-1], b._ctx)
self.assertEqual(self.stack, [])
b.close()
b.close()
def test_exception_unwinds_and_active_close_is_rejected(self):
a = self.module.CudaContext()
with self.assertRaisesRegex(ValueError, "operation"):
with a.activate():
with self.assertRaises(RuntimeError):
a.close()
raise ValueError("operation")
self.assertEqual(self.stack, [])
a.close()
with self.assertRaises(RuntimeError):
with a.activate(): pass
def test_decorator_balances_nested_calls_and_supports_low_level_fields(self):
bound = self.module.context_bound
stack = self.stack
class Owner:
@bound
def outer(self): return self.inner()
@bound
def inner(self): return stack[-1] if stack else None
owner = Owner()
self.assertIsNone(owner.outer())
owner.ctx = self.module.CudaContext()
self.assertIs(owner.outer(), owner.ctx._ctx)
self.assertEqual(stack, [])
owner.ctx.close()
def test_context_manager_owns_lifetime(self):
with self.module.CudaContext() as ctx:
self.assertIs(self.stack[-1], ctx._ctx)
self.assertEqual(self.stack, [])
self.assertIsNone(ctx._ctx)
if __name__ == "__main__":
unittest.main()
+215
View File
@@ -0,0 +1,215 @@
"""CPU-only fine circle projection and transfer/boundary isolation contracts."""
from dataclasses import asdict, replace
import numpy as np
import pytest
from CelerisLab.body.geometry.circle import CircleGeometry
from CelerisLab.body.objects import SimObject, SurfaceActuation
from CelerisLab.config import LBMConfig
from CelerisLab.lbm import refinement
from CelerisLab.lbm.descriptors import D2Q9_EX, D2Q9_EY, FLUID, SOLID
from CelerisLab.lbm.refinement import ACTIVE, INNER_GHOST, FixedPatch2D
def setup_patch():
cfg = LBMConfig(nx=24, ny=24, viscosity=.05)
cfg.validate()
return cfg, FixedPatch2D.from_config(cfg, (4, 4, 20, 20))
def circle(center=(12.25, 12.25), radius=2.15, speed=.002):
return SimObject(0, geometry=CircleGeometry(*center, radius), center=center,
radius=radius, surface_actuation=SurfaceActuation(.7, .85, speed))
@pytest.mark.parametrize('speed', [.002, -.002])
def test_fine_projection_uses_cell_center_bridge_and_preserves_source(speed):
cfg, patch = setup_patch()
source = circle(center=(12.25, 12.25 if speed > 0 else 11.75), speed=speed)
expected_center = (20., 20. if speed > 0 else 19.)
assert patch.fine_origin == (2., 2.)
before = (source.center, source.radius, source.geometry.center,
source.geometry._radius, asdict(source.state), asdict(source.surface_actuation),
vars(cfg).copy())
plan = refinement.prepare_fine_boundary(cfg, patch, [source])
local, = plan.objects
assert local is not source and local.geometry is not source.geometry
np.testing.assert_allclose(local.center, expected_center, rtol=0, atol=1e-14)
np.testing.assert_allclose(local.geometry.center, expected_center, rtol=0, atol=1e-14)
assert local.radius == pytest.approx(4.3)
assert local.geometry._radius == pytest.approx(4.3)
assert local.surface_actuation.normal_velocity == speed # dt/dx is unchanged.
assert (source.center, source.radius, source.geometry.center,
source.geometry._radius, asdict(source.state), asdict(source.surface_actuation),
vars(cfg).copy()) == before
_, _, _, _, rx, ry, _, _ = plan.curved_host
np.testing.assert_allclose(np.hypot(rx, ry), 4.3, rtol=0, atol=6e-7)
# These world points lie on the ORIGINAL physical circle, including the +.5.
world_x = patch.fine_origin[0]+.5*(local.center[0]+rx.astype(float)+.5)
world_y = patch.fine_origin[1]+.5*(local.center[1]+ry.astype(float)+.5)
np.testing.assert_allclose(np.hypot(world_x-source.center[0], world_y-source.center[1]),
source.radius, rtol=0, atol=3e-7)
def test_fine_boundary_receivers_sources_and_reverse_coverage():
cfg, patch = setup_patch()
plan = refinement.prepare_fine_boundary(cfg, patch, [circle()])
ny, nx = patch.fine_shape
flags = plan.flags
assert flags.shape == (nx*ny,) and flags.dtype == np.uint16
solid = (flags & SOLID) != 0
assert solid.any()
assert np.all(patch.fine_kind.ravel()[solid] == ACTIVE)
receivers, directions, q, _, _, _, _, fallback = plan.curved_host
cx, cy = np.asarray(D2Q9_EX), np.asarray(D2Q9_EY)
sources = receivers.astype(np.int64)+cx[directions]+nx*cy[directions]
assert np.all((flags[receivers] & FLUID) != 0)
assert not np.any(flags[receivers] & SOLID)
assert np.all(solid[sources])
assert np.all(patch.fine_kind.ravel()[receivers] == ACTIVE)
assert np.all(patch.fine_kind.ravel()[sources] == ACTIVE)
# A source population slot has a unique writer, not just a unique cell.
assert len(set(zip(sources.tolist(), directions.tolist()))) == len(receivers)
expected = set()
for cell in np.flatnonzero((patch.fine_kind.ravel() == ACTIVE) & ~solid):
y, x = divmod(int(cell), nx)
for direction in range(1, 9):
sx, sy = x+int(cx[direction]), y+int(cy[direction])
if 0 <= sx < nx and 0 <= sy < ny and solid[sy*nx+sx]:
expected.add((int(cell), direction))
assert set(zip(receivers.tolist(), directions.tolist())) == expected
donors = receivers[q < .5].astype(np.int64)-cx[directions[q < .5]]-nx*cy[directions[q < .5]]
assert np.all(patch.fine_kind.ravel()[donors] == ACTIVE)
assert not np.any(solid[donors])
assert not fallback.any()
stencil = set(receivers.tolist()) | set(sources.tolist()) | set(donors.tolist())
assert stencil == set(plan.boundary_cells.tolist())
assert not set(plan.boundary_cells.tolist()) & set(plan.transfer_fine_cells.tolist())
assert not set(np.flatnonzero(solid).tolist()) & set(plan.transfer_fine_cells.tolist())
def test_transfer_dependencies_follow_two_pulls_and_expand_active_collision():
cfg, patch = setup_patch()
plan = refinement.prepare_fine_boundary(cfg, patch, [circle()])
ny, nx = patch.fine_shape
cells = nx*ny
# Closed form for exactly two hops, independent of the production traversal:
# the final inner ghost only pulls; its predecessor may have collided.
offsets = np.asarray(D2Q9_EX)+nx*np.asarray(D2Q9_EY)
visited = set()
for slot in patch.coalesce_fine.ravel():
direction, target = divmod(int(slot), cells)
assert patch.fine_kind.ravel()[target] == INNER_GHOST
middle = target-int(offsets[direction])
visited.update(((2, direction, target), (1, direction, middle)))
if patch.fine_kind.ravel()[middle] == ACTIVE:
visited.update((0, q, middle-int(offsets[q])) for q in range(9))
else:
assert patch.fine_kind.ravel()[middle] == INNER_GHOST
visited.add((0, direction, middle-int(offsets[direction])))
actual = {tuple(map(int, row)) for row in plan.transfer_reads}
assert actual == visited
assert len(plan.transfer_reads) == len(visited)
expected_fine = set(patch.ghost_fine.tolist()) | {cell for _, _, cell in visited}
expected_coarse = set(patch.ghost_coarse.tolist()) | set(
(patch.coalesce_coarse % (cfg.nx*cfg.ny)).tolist())
assert set(plan.transfer_fine_cells.tolist()) == expected_fine
assert set(plan.transfer_coarse_cells.tolist()) == expected_coarse
for coarse_slot, children in zip(patch.coalesce_coarse, patch.coalesce_fine):
coarse_q, coarse_cell = divmod(int(coarse_slot), cfg.nx*cfg.ny)
coarse_y, coarse_x = divmod(coarse_cell, cfg.nx)
fine_q, fine_cell = np.divmod(children, cells)
np.testing.assert_array_equal(fine_q, coarse_q)
fine_y, fine_x = np.divmod(fine_cell, nx)
physical_centers = np.column_stack((
patch.fine_origin[0]+.5*(fine_x+.5),
patch.fine_origin[1]+.5*(fine_y+.5)))
np.testing.assert_array_equal(physical_centers.mean(axis=0),
(coarse_x+.5, coarse_y+.5))
# This case must exercise the collision fan-in, not only ghost straight lines.
active_middle = [(q, cell) for stage, q, cell in visited
if stage == 1 and patch.fine_kind.ravel()[cell] == ACTIVE]
assert active_middle
_, sample = active_middle[0]
for incoming in range(9):
source = sample-int(D2Q9_EX[incoming])-nx*int(D2Q9_EY[incoming])
assert (0, incoming, source) in actual
def test_fine_plan_arrays_are_readonly_and_contiguous():
cfg, patch = setup_patch()
plan = refinement.prepare_fine_boundary(cfg, patch, [circle()])
assert len(plan.curved_host) == 8
for array in (plan.flags, *plan.curved_host, plan.boundary_cells,
plan.transfer_fine_cells, plan.transfer_coarse_cells, plan.transfer_reads):
assert array.flags.c_contiguous and not array.flags.writeable
assert array.size
with pytest.raises(ValueError):
array.flat[0] = array.flat[0]
def test_empty_objects_keep_existing_refinement_path():
cfg, patch = setup_patch()
assert refinement.prepare_fine_boundary(cfg, patch, []) is None
def test_fine_rejects_cross_level_low_q_donor_without_fallback():
cfg, patch = setup_patch()
source = circle(center=(5.05, 12.25), radius=.7)
# In fine coordinates C=(5.6,20), R=1.4. Receiver (4,20), dir +x
# has q=.2, but its required reverse donor (3,20) is not active.
local = CircleGeometry(5.6, 20., 1.4)
links = local.build_cut_links(patch.fine_shape[1], patch.fine_shape[0])
target, = [link for link in links if link.fluid_idx == 20*40+4 and link.dir == 1]
assert target.q == pytest.approx(.2)
assert patch.fine_kind[20, 3] != ACTIVE
with pytest.raises(ValueError):
refinement.prepare_fine_boundary(cfg, patch, [source])
def test_fine_rejects_active_stencil_that_still_touches_transfer():
cfg, patch = setup_patch()
source = circle(center=(6.05, 12.25), radius=.7)
# Move the cross-level example two fine cells inward: all boundary reads
# are now active, but the q=.2 reverse donor is still a transfer dependency.
local = CircleGeometry(7.6, 20., 1.4)
nx = patch.fine_shape[1]
solid = (local.build_flag_mask(nx, patch.fine_shape[0]) & SOLID) != 0
assert np.all(patch.fine_kind.ravel()[solid] == ACTIVE)
links = local.build_cut_links(nx, patch.fine_shape[0])
stencil = set()
for link in links:
offset = int(D2Q9_EX[link.dir])+nx*int(D2Q9_EY[link.dir])
stencil.update((link.fluid_idx, link.fluid_idx+offset))
if link.q < .5:
stencil.add(link.fluid_idx-offset)
assert np.all(patch.fine_kind.ravel()[list(stencil)] == ACTIVE)
with pytest.raises(ValueError, match='transfer footprint'):
refinement.prepare_fine_boundary(cfg, patch, [source])
@pytest.mark.parametrize('change', [dict(nx=25), dict(collision='TRT'), dict(viscosity=.06)])
def test_fine_rejects_mismatched_patch_configuration(change):
cfg, patch = setup_patch()
with pytest.raises(ValueError, match='matching'):
refinement.prepare_fine_boundary(replace(cfg, **change), patch, [circle()])
def test_fine_rejects_nonzero_control_even_without_motion():
cfg, patch = setup_patch()
obj = circle()
obj.control.ax = .001
with pytest.raises(ValueError, match='stationary'):
refinement.prepare_fine_boundary(cfg, patch, [obj])
@pytest.mark.parametrize('objects', [
[circle(center=(4.25, 12.25), radius=1.)],
[circle(center=(12.5, 12.5), radius=.05)],
[circle(), circle(center=(13., 12.25))],
])
def test_fine_rejects_ghost_solids_unresolved_circle_and_overlap(objects):
cfg, patch = setup_patch()
with pytest.raises(ValueError):
refinement.prepare_fine_boundary(cfg, patch, objects)
+68
View File
@@ -0,0 +1,68 @@
"""Initializer invariants on a host field; no CUDA context is required."""
from types import SimpleNamespace
import numpy as np
import pytest
from scipy.special import expi
from CelerisLab.lbm.descriptors import D2Q9_EX, D2Q9_EY
from CelerisLab.lbm.equilibrium import compute_feq_d2q9
from CelerisLab.lbm.initializers import _oseen_vortex, add_vortex
class HostField:
def __init__(self):
self.nx = self.ny = 7
self.nq = 9
self.cfg = SimpleNamespace(is_d2q9=True, viscosity=.02,
streaming="double_buffer")
self.ddf = np.repeat(compute_feq_d2q9(1.2, .04, .01)[:, None],
49, axis=1).ravel()
def download_ddf(self):
pass
def upload_ddf(self):
pass
def _read_lattice_vectors(self):
return np.asarray(D2Q9_EX), np.asarray(D2Q9_EY)
@pytest.mark.parametrize("kind", ["lamb", "oseen", "taylor"])
def test_zero_strength_preserves_moving_nonunit_density_equilibrium(kind):
field = HostField()
before = field.ddf.copy()
add_vortex(field, (3, 3), 2., 0., kind)
np.testing.assert_allclose(field.ddf, before, rtol=2e-6, atol=2e-8)
def test_oseen_center_limit_and_original_model_away_from_center():
radius, strength = 2., .02
r = np.array([0., 1e-10, 1e-5, .2, 1., 10.])
with np.errstate(all="raise"):
u, v, p = _oseen_vortex(r, np.zeros_like(r), radius, strength)
assert np.all(np.isfinite(p))
assert u[0] == v[0] == 0
np.testing.assert_allclose(p[:2], -4*radius**2*strength**2*np.log(2),
rtol=1e-12)
z = 4*r[3:]**2/radius**2
kappa = 2*np.pi*radius**2*strength
original = -kappa**2/(8*np.pi**2*r[3:]**2) * (
-2*z*(expi(-z)-expi(-2*z)) + (1-np.exp(-z))**2)
np.testing.assert_allclose(p[3:], original, rtol=2e-13)
def test_oseen_grid_center_stays_finite():
field = HostField()
add_vortex(field, (3, 3), 2., .02, "oseen")
assert np.isfinite(field.ddf).all()
def test_esopull_is_rejected_before_editing_raw_storage():
field = HostField()
field.cfg.streaming = "esopull"
before = field.ddf.copy()
with pytest.raises(NotImplementedError, match="time/parity"):
add_vortex(field, (3, 3), 2., .02)
np.testing.assert_array_equal(field.ddf, before)
+140
View File
@@ -0,0 +1,140 @@
"""Actual CUDA operators compiled with a CPU shim; no GPU/import side effects."""
from pathlib import Path
import shutil
import subprocess
import numpy as np
import pytest
ROOT = Path(__file__).resolve().parents[2]
KERNELS = ROOT / "src/CelerisLab/lbm/kernels"
CPP = Path(__file__).with_name("cpp")
def compile_run(tmp_path, source, defines):
if not shutil.which("g++"):
pytest.skip("g++ required for actual-header CPU regression")
exe = tmp_path / "operator"
command = ["g++", "-std=c++17", "-O0", f"-I{KERNELS}"]
command += [f"-D{k}={v}" for k, v in defines.items()]
subprocess.run(command + [str(CPP / source), "-o", str(exe)], check=True, capture_output=True, text=True)
return subprocess.check_output([str(exe)], text=True).splitlines()
@pytest.mark.parametrize("dim", [2, 3])
@pytest.mark.parametrize("shift", [0, 1])
@pytest.mark.parametrize("model", [0, 1, 2])
def test_collision_moments_and_inlet_closure(tmp_path, dim, shift, model):
lines = compile_run(tmp_path, "collision_regression.cpp", dict(
DIM=dim, LATTICE_MODEL=dim-2, USE_DDF_SHIFTING=shift,
COLLISION_MODEL=model, NY=8, U0=".03f"))
cx = np.array([0,1,-1,0,0,1,-1,1,-1] if dim == 2 else
[0,1,-1,0,0,0,0,1,-1,1,-1,0,0,1,-1,1,-1,0,0])
cy = np.array([0,0,0,1,-1,1,-1,-1,1] if dim == 2 else
[0,0,0,1,-1,0,0,1,-1,0,0,1,-1,-1,1,0,0,1,-1])
cz = np.zeros(9) if dim == 2 else np.array([0,0,0,0,0,1,-1,0,0,1,-1,1,-1,0,0,-1,1,-1,1])
weights = np.array([4/9]+[1/9]*4+[1/36]*4 if dim == 2 else [1/3]+[1/18]*6+[1/36]*12)
# Independent D2 moment matrix (paired direction ordering).
M = np.array([[1]*9,[-4,-1,-1,-1,-1,2,2,2,2],
[4,-2,-2,-2,-2,1,1,1,1],[0,1,-1,0,0,1,-1,1,-1],
[0,-2,2,0,0,1,-1,1,-1],[0,0,0,1,-1,1,-1,-1,1],
[0,0,0,-2,2,1,-1,-1,1],[0,1,1,-1,-1,0,0,0,0],
[0,0,0,0,0,1,1,-1,-1]], dtype=float)
for a, omega in enumerate([.7,1.25,1.8]):
before, eq, source, after = [np.fromstring(s, sep=" ") for s in lines[4*a:4*a+4]]
delta = after-before
assert abs(delta.sum()) < 5e-7
np.testing.assert_allclose([delta@cx,delta@cy], [.001,-.0007], atol=1.5e-7, rtol=0)
if dim == 3:
assert abs(delta@cz-.0003) < 1.5e-7
if model == 0:
expected=before-omega*(before-eq)+(1-omega/2)*source
elif model == 1:
opp=np.array([0]+[i+1 if i%2 else i-1 for i in range(1,len(before))])
wm=1/(.1875/(1/omega-.5)+.5)
delta_s=(before-eq+(before-eq)[opp])/2
delta_a=(before-eq-(before-eq)[opp])/2
expected=before-omega*delta_s-wm*delta_a+(1-omega/2)*(source+source[opp])/2+(1-wm/2)*(source-source[opp])/2
elif dim == 2:
rates=np.array([0,1.2,1.2,0,1.2,0,1.2,omega,omega])
expected=before+np.linalg.solve(M,-rates*(M@(before-eq))+(1-rates/2)*(M@source))
else:
expected=None # D3 projection MRT: conservation/equilibrium only.
if expected is not None:
np.testing.assert_allclose(after,expected,atol=2e-7,rtol=0)
assert float(lines[12].split()[1]) == pytest.approx(1.17, abs=3e-7)
velocities=np.stack([cx,cy,cz],axis=1)
u=np.array([.03,-.02,.01 if dim==3 else 0])
cu=velocities@u
eq=weights*1.17*(1+3*cu+4.5*cu**2-1.5*(u@u))-shift*weights
np.testing.assert_allclose(np.fromstring(lines[13],sep=" "),eq,atol=2e-7,rtol=0)
def test_shifted_mrt_weak_force_full_vector(tmp_path):
"""Small populations must retain their precision through MRT relaxation."""
lines = compile_run(tmp_path, "collision_regression.cpp", dict(
DIM=2, LATTICE_MODEL=0, USE_DDF_SHIFTING=1, COLLISION_MODEL=2,
MRT_WEAK_FORCE_CHECK=1, NY=8, U0=".03f"))
M = np.array([[1]*9,[-4,-1,-1,-1,-1,2,2,2,2],
[4,-2,-2,-2,-2,1,1,1,1],[0,1,-1,0,0,1,-1,1,-1],
[0,-2,2,0,0,1,-1,1,-1],[0,0,0,1,-1,1,-1,-1,1],
[0,0,0,-2,2,1,-1,-1,1],[0,1,1,-1,-1,0,0,0,0],
[0,0,0,0,0,1,1,-1,-1]], dtype=float)
weights = np.array([4/9]+[1/9]*4+[1/36]*4)
np.testing.assert_allclose(M @ weights, [1,-2,1,0,0,0,0,0,0], atol=3e-16)
for start in range(0, len(lines), 4):
# Nine printed digits round-trip FP32; recover those exact binary inputs
# before doing FP64 arithmetic (especially rho-1 near unit density).
omega, rho, ux, uy, fx, fy = np.fromstring(lines[start], sep=" ", dtype=np.float32).astype(float)
before, source, after = [np.fromstring(s, sep=" ", dtype=np.float32).astype(float)
for s in lines[start+1:start+4]]
# Independent LL equilibrium moments, with the exact background removed.
drho, u2 = rho-1, ux*ux+uy*uy
meq = np.array([drho, -2*drho+3*rho*u2, drho-3*rho*u2,
rho*ux, -rho*ux, rho*uy, -rho*uy,
rho*(ux*ux-uy*uy), rho*ux*uy])
rates = np.array([0, np.float32(1.2), np.float32(1.2), 0,
np.float32(1.2), 0, np.float32(1.2), omega, omega])
expected = before + np.linalg.solve(
M, rates*(meq-M@before)+(1-rates/2)*(M@source))
# FP32 roundoff is relative to the stored small populations, not to w.
budget = 8*np.finfo(np.float32).eps * max(abs(before).max(), abs(source).max()) + 1e-13
np.testing.assert_allclose(after, expected, atol=budget, rtol=0)
np.testing.assert_allclose((after-before) @ M[[0,3,5]].T,
[0,fx,fy], atol=4*budget, rtol=0)
if not before.any() and fx == 0 and fy == 0:
np.testing.assert_array_equal(after, np.zeros(9))
@pytest.mark.parametrize("shift", [0, 1])
@pytest.mark.parametrize("wall", [0, 1])
@pytest.mark.parametrize("model", [0, 1, 2])
def test_actual_semantic_and_auxiliary_kernels(tmp_path, shift, wall, model):
lines = compile_run(tmp_path, "semantic_regression.cpp", dict(USE_DDF_SHIFTING=shift,Y_WALL_BC=wall,COLLISION_MODEL=model))
assert "passed" in lines[-1]
@pytest.mark.parametrize("model", [0, 1, 2])
@pytest.mark.parametrize("shift", [0, 1])
@pytest.mark.parametrize("storage", [0, 1])
def test_d2_entrypoint_compiles_in_isolation(tmp_path, model, shift, storage):
"""Both streaming entrypoints compile; generated repository headers untouched."""
import re
nvcc = shutil.which("nvcc") or "/usr/local/cuda/bin/nvcc"
if not Path(nvcc).is_file():
pytest.skip("CUDA toolkit required for compile-only regression")
copy = tmp_path / "kernels"
shutil.copytree(KERNELS, copy)
for file, defines in [
("config_grid.h", {"LATTICE_MODEL": 0, "NX": 16, "NY": 8, "NZ": 1}),
("config_method.h", {"COLLISION_MODEL": model, "USE_DDF_SHIFTING": shift,
"STORE_PRECISION": storage, "Y_WALL_BC": shift})
]:
path=copy / "config" / file
source=path.read_text()
for key,value in defines.items():
source=re.sub(r"(#define\s+"+key+r"\s+)\S+",lambda match: match[1]+str(value),source)
path.write_text(source)
result=subprocess.run([nvcc,"-ptx","-arch=sm_70","-std=c++17",
str(copy/"kernel_v2.cu"),"-o",str(tmp_path/"kernel.ptx")],
capture_output=True,text=True)
assert result.returncode == 0, result.stderr
+95
View File
@@ -0,0 +1,95 @@
"""J1 actual-header moments and bounded host capability contracts."""
import json
from pathlib import Path
from types import SimpleNamespace
import h5py
import numpy as np
import pytest
import CelerisLab
from CelerisLab.config import LBMConfig, load_lbm_config
from CelerisLab.cuda import compiler_v2 as compiler
from CelerisLab.common.checkpoint import save_checkpoint, load_checkpoint
from .test_numerical_operators import compile_run
from .test_state_regressions import device, make_field, manager
def test_planar_helper_moments_and_known_populations(tmp_path):
root = Path(__file__).resolve().parents[2]
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'
lines = compile_run(tmp_path, 'planar_velocity.cpp', dict(WALL_NORMAL_VELOCITY='0.0f'))
cx = np.array([0,1,-1,0,0,1,-1,1,-1])
cy = np.array([0,0,0,1,-1,1,-1,-1,1])
w = np.array([4/9]+[1/9]*4+[1/36]*4)
for start in range(0, len(lines), 3):
top, rho, u, v, neq = np.fromstring(lines[start], sep=' ')
before, after = [np.fromstring(s, sep=' ') for s in lines[start+1:start+3]]
known = cy != (-1 if top else 1)
np.testing.assert_array_equal(after[known], before[known])
density = after.sum()
np.testing.assert_allclose([after@cx, after@cy], density*np.array([u,v]), atol=3e-7, rtol=0)
if not neq:
cu = cx*u+cy*v
expected = w*rho*(1+3*cu+4.5*cu**2-1.5*(u*u+v*v))
np.testing.assert_allclose(after, expected, atol=3e-7, rtol=0)
assert abs(density-rho) <= 3e-7
@pytest.mark.parametrize('override', [
dict(collision='TRT'), dict(collision='MRT'), dict(streaming='esopull'),
dict(store_precision='FP16S'), dict(ddf_shifting=True), dict(les_enabled=True),
dict(lattice_model='D3Q19'), dict(wall_normal_velocity=1.),
dict(wall_normal_velocity=float('nan')),
])
def test_planar_unsupported_configuration(override):
with pytest.raises(ValueError):
LBMConfig(y_wall_bc='on_site_velocity', **override).validate_runtime()
def test_planar_config_roundtrip_and_fixed_targets(tmp_path):
path = Path(__file__).resolve().parents[2]/'src/CelerisLab/configs/config_lbm.json'
data = json.loads(path.read_text())
data['method'].update(y_wall_bc='on_site_velocity', wall_normal_velocity=-.01,
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(str(path))
assert float(cfg.to_macros()['WALL_NORMAL_VELOCITY'][:-1]) == -.01
with pytest.raises(ValueError, match='object-free'):
compiler.generate_config(cfg, n_objects=1, build_root=tmp_path)
before = cfg.allocation_signature()
cfg.wall_normal_velocity = .01
assert cfg.allocation_signature() != before
def test_planar_force_rejected_before_state_change(device):
field = make_field(device, y_wall_bc='on_site_velocity')
before, writes = field._runtime_params.copy(), device.writes
with pytest.raises(ValueError, match='body forces'):
field.update_params(fx=.001)
assert field._runtime_params == before and device.writes == writes
@pytest.mark.parametrize('damage', ['target', 'mode', 'force'])
def test_planar_checkpoint_contract_before_writes(device, tmp_path, damage):
field = make_field(device, y_wall_bc='on_site_velocity')
bodies = manager(field)
bodies.sync_to_gpu(field)
path = save_checkpoint(field, SimpleNamespace(step_count=3), field.cfg, bodies,
str(tmp_path/'plane.h5'))
with h5py.File(path, 'a') as hf:
if damage == 'force':
hf.attrs['runtime_params_json'] = '{"fx":0.001}'
else:
cfg = json.loads(hf.attrs['config_json'])
cfg['wall_normal_velocity' if damage == 'target' else 'y_wall_bc'] = (
.01 if damage == 'target' else 'bounce_back')
hf.attrs['config_json'] = json.dumps(cfg)
writes = device.writes
with pytest.raises(ValueError):
load_checkpoint(path, field, SimpleNamespace(_step_count=0), field.cfg, bodies)
assert device.writes == writes
+167
View File
@@ -0,0 +1,167 @@
"""CPU geometry/contracts only: no time stepping, CUDA or reference edits."""
import hashlib
import importlib.util
from pathlib import Path
import sys
import unittest
import numpy as np
from CelerisLab.config import LBMConfig
from CelerisLab.lbm import refinement
from CelerisLab.lbm.descriptors import D2Q9_EX, D2Q9_EY
from CelerisLab.lbm.refinement import ACTIVE, INNER_GHOST, OUTER_GHOST, FixedPatch2D
ROOT = Path(__file__).resolve().parents[2]
def config(**kwargs):
return LBMConfig(**dict(nx=16, ny=16, viscosity=.1, **kwargs))
class FixedPatchTests(unittest.TestCase):
def test_source_and_cpu_import_boundary(self):
self.assertEqual(Path(refinement.__file__).resolve(),
ROOT / "src/CelerisLab/lbm/refinement.py")
self.assertFalse(any(name == "pycuda" or name.startswith("pycuda.") for name in sys.modules))
self.assertNotIn("CelerisLab.simulation", sys.modules)
self.assertNotIn("CelerisLab.lbm.field", sys.modules)
def test_all_tables_match_pinned_reference_without_advancing(self):
path = ROOT / "tests/validation/multigrid/cpu_reference.py"
self.assertEqual(hashlib.sha256(path.read_bytes()).hexdigest(),
"bba2a06a9f6517b32079ffff15e4060d8c3b7ed35f16ca134e1877226c9e4e1e")
spec = importlib.util.spec_from_file_location("refinement_geometry_reference", path)
ref = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = ref
spec.loader.exec_module(ref)
maps = ref.build_maps(ref.make_spec(collision="srt"))
p = FixedPatch2D.from_config(config(), (4, 4, 12, 12))
np.testing.assert_array_equal(p.fine_kind, maps.fine_class)
gy, gx = np.nonzero(~maps.fine_active)
np.testing.assert_array_equal(p.ghost_fine, gy * 24 + gx)
np.testing.assert_array_equal(p.ghost_coarse,
maps.fine_parent[gy] * 16 + maps.fine_parent[gx])
targets = [q * 256 + y * 16 + x for q, y, x in maps.coalesce]
sources = [[q * 576 + b * 24 + a
for b in (4 + 2 * (y - 4), 5 + 2 * (y - 4))
for a in (4 + 2 * (x - 4), 5 + 2 * (x - 4))]
for q, y, x in maps.coalesce]
np.testing.assert_array_equal(p.coalesce_coarse, targets)
np.testing.assert_array_equal(p.coalesce_fine, sources)
self.assertEqual(len(targets), 100)
self.assertEqual(len(set(p.coalesce_fine.ravel().tolist())), 400)
self.assertEqual(np.count_nonzero(p.fine_kind == ACTIVE), 256)
self.assertEqual(len(p.ghost_fine), 320)
self.assertEqual(self.corner_pass_count(p), 8)
@staticmethod
def corner_pass_count(p):
ny, nx = p.coarse_shape
x0, y0, x1, y1 = p.bounds
count = 0
for slot in p.coalesce_coarse.tolist():
q, cell = divmod(slot, nx * ny)
y, x = divmod(cell, nx)
sx, sy = x - D2Q9_EX[q], y - D2Q9_EY[q]
count += not (x0 <= sx < x1 and y0 <= sy < y1)
return count
def test_rectangular_shifted_patch_against_geometric_definition(self):
p = FixedPatch2D.from_config(LBMConfig(nx=22, ny=18, viscosity=.1), (7, 6, 13, 10))
expected = set()
for y in range(18):
for x in range(22):
if 7 <= x < 13 and 6 <= y < 10:
continue
for q, (cx, cy) in enumerate(zip(D2Q9_EX, D2Q9_EY)):
if q and 7 <= x + .5 - cx / 2 <= 13 and 6 <= y + .5 - cy / 2 <= 10:
expected.add(q * 22 * 18 + y * 22 + x)
self.assertEqual(set(p.coalesce_coarse.tolist()), expected)
self.assertEqual(p.fine_shape, (16, 20))
self.assertEqual(p.fine_origin, (5., 4.))
fy, fx = p.fine_shape
for coarse_slot, children in zip(p.coalesce_coarse.tolist(), p.coalesce_fine.tolist()):
q, cell = divmod(coarse_slot, 22 * 18)
y, x = divmod(cell, 22)
centers = []
for slot in children:
fq, fine_cell = divmod(slot, fy * fx)
b, a = divmod(fine_cell, fx)
self.assertEqual(fq, q)
self.assertEqual(p.fine_kind[b, a], INNER_GHOST)
centers.append((p.fine_origin[0] + (a + .5) / 2,
p.fine_origin[1] + (b + .5) / 2))
np.testing.assert_array_equal(np.mean(centers, axis=0), (x + .5, y + .5))
self.assertEqual(self.corner_pass_count(p), 8)
def test_smallest_patch_and_extreme_halo_margin(self):
p = FixedPatch2D.from_config(LBMConfig(nx=5, ny=5, viscosity=.1), (2, 2, 3, 3))
self.assertEqual(p.fine_shape, (10, 10))
self.assertEqual(p.fine_origin, (0., 0.))
self.assertEqual((int(p.ghost_coarse.min()), int(p.ghost_coarse.max())), (0, 24))
self.assertEqual(np.count_nonzero(p.fine_kind == ACTIVE), 4)
self.assertEqual(self.corner_pass_count(p), 8)
self.assertTrue(np.all(p.fine_kind.ravel()[p.coalesce_fine % 100] == INNER_GHOST))
def test_config_and_level_scaling_are_isolated(self):
cfg = config()
before = vars(cfg).copy()
p = FixedPatch2D.from_config(cfg, (4, 4, 12, 12))
self.assertEqual(vars(cfg), before)
self.assertAlmostEqual(p.tau_coarse, .8)
self.assertAlmostEqual(p.tau_fine, 1.1)
self.assertAlmostEqual((p.tau_coarse - .5) / 3,
(p.tau_fine - .5) / 3 * .5)
cfg.nx = 999
self.assertEqual(p.coarse_shape, (16, 16))
def test_tables_are_compact_contiguous_and_readonly(self):
small = FixedPatch2D.from_config(config(), (4, 4, 12, 12))
large = FixedPatch2D.from_config(LBMConfig(nx=512, ny=256, viscosity=.1), (4, 4, 12, 12))
self.assertEqual(small.table_bytes, large.table_bytes)
self.assertEqual(small.table_bytes, 7136)
for name, dtype in (("fine_kind", np.uint8), ("ghost_fine", np.uint32),
("ghost_coarse", np.uint32), ("coalesce_coarse", np.uint64),
("coalesce_fine", np.uint64)):
array = getattr(small, name)
self.assertEqual(array.dtype, dtype)
self.assertTrue(array.flags.c_contiguous)
with self.assertRaises(ValueError):
array.flat[0] = 0
def test_invalid_geometry_and_index_overflow_rejected_before_allocation(self):
for bounds in ((4, 4, 4, 12), (12, 4, 4, 12), (1, 4, 12, 12),
(4, 4, 15, 12), (4., 4, 12, 12), (True, 4, 12, 12),
(np.bool_(True), 4, 12, 12), (4, 4, 12)):
with self.subTest(bounds=bounds), self.assertRaises(ValueError):
FixedPatch2D.from_config(config(), bounds)
for nx, ny, bounds in ((2**32, 10, (2, 2, 3, 3)),
(50000, 50000, (2, 2, 49998, 49998))):
with self.assertRaisesRegex(ValueError, "uint32"):
FixedPatch2D.from_config(LBMConfig(nx=nx, ny=ny, viscosity=.1), bounds)
def test_unsupported_backend_and_fine_relaxation_rejected(self):
for kwargs in ({"collision": "TRT"}, {"collision": "MRT"}, {"streaming": "esopull"},
{"ddf_shifting": True}, {"les_enabled": True},
{"store_precision": "FP16S"}, {"lattice_model": "D3Q19"},
{"data_type": "FP64"}, {"omega_min": 1.0}):
cfg = config(**kwargs)
before = vars(cfg).copy()
with self.subTest(kwargs=kwargs), self.assertRaises(ValueError):
FixedPatch2D.from_config(cfg, (4, 4, 12, 12))
self.assertEqual(vars(cfg), before)
def test_handoff_rejects_wrong_phase_and_unsynchronized_counts(self):
p = FixedPatch2D.from_config(config(), (4, 4, 12, 12))
p.check_synchronization(0, 0, phase="postcollision_g")
p.check_synchronization(8, 16, phase="postcollision_g")
for c, f, phase in ((8, 16, "arrival_f"), (8, 15, "postcollision_g"),
(-1, -2, "postcollision_g"), (1., 2, "postcollision_g"),
(True, 2, "postcollision_g"), (1, np.bool_(True), "postcollision_g")):
with self.subTest(c=c, f=f, phase=phase), self.assertRaises(ValueError):
p.check_synchronization(c, f, phase=phase)
if __name__ == "__main__":
unittest.main(verbosity=2)
+90
View File
@@ -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
+115
View File
@@ -0,0 +1,115 @@
"""Simulation cleanup orchestration, using a CPU owner model (no GPU calls)."""
from contextlib import contextmanager
from types import SimpleNamespace
import pytest
pytest.importorskip("pycuda.driver")
from CelerisLab import simulation as module
from CelerisLab.config import LBMConfig, BodyConfig
class Owner:
def __init__(self, *args):
self.is_active = False
self.is_closed = False
self.sm_arch = "sm_70"
self.detach_failure = False
@contextmanager
def activate(self):
assert not self.is_closed
self.is_active = True
try:
yield self
finally:
self.is_active = False
def close(self):
if self.detach_failure:
self.detach_failure = False
raise RuntimeError("detach failed")
self.is_closed = True
def make_simulation(monkeypatch):
sim = module.Simulation.__new__(module.Simulation)
sim.ctx = Owner()
sim._closed = False
sim._build_dir = "private-build"
for name in ("stepper", "bodies", "field", "_module", "_stream"):
setattr(sim, name, object())
monkeypatch.setattr(module, "cuda", SimpleNamespace(
Context=SimpleNamespace(synchronize=lambda: None)))
monkeypatch.setattr(module.compiler, "cleanup_build_dir", lambda path: None)
return sim
def test_close_inside_activation_preserves_resources(monkeypatch):
sim = make_simulation(monkeypatch)
original_field = sim.field
with sim.ctx.activate():
with pytest.raises(RuntimeError, match="active CUDA"):
sim.close()
assert sim.field is original_field
assert not sim._closed
sim.close()
assert sim._closed and sim.ctx.is_closed
@pytest.mark.parametrize("failure", ["synchronize", "detach", "directory"])
def test_close_failure_is_retryable(monkeypatch, failure):
sim = make_simulation(monkeypatch)
if failure == "detach":
sim.ctx.detach_failure = True
else:
calls = []
def fail_once(*args):
calls.append(args)
if len(calls) == 1:
raise RuntimeError(failure + " failed")
if failure == "synchronize":
monkeypatch.setattr(module.cuda.Context, "synchronize", fail_once)
else:
monkeypatch.setattr(module.compiler, "cleanup_build_dir", fail_once)
with pytest.raises(RuntimeError, match="failed"):
sim.close()
assert not sim._closed
assert sim._build_dir == "private-build"
sim.close()
assert sim._closed and sim.ctx.is_closed
assert sim._build_dir is None
sim.close()
def test_constructor_cleanup_preserves_original_failure(monkeypatch):
original = RuntimeError("compile setup failed")
def fail_build(*args, **kwargs):
raise original
def fail_close(self):
raise RuntimeError("cleanup failed")
monkeypatch.setattr(module, "load_lbm_config", lambda path: LBMConfig())
monkeypatch.setattr(module, "load_body_config", lambda path: BodyConfig())
monkeypatch.setattr(module, "CudaContext", Owner)
monkeypatch.setattr(module.compiler, "generate_config", fail_build)
monkeypatch.setattr(module.Simulation, "close", fail_close)
with pytest.raises(RuntimeError) as exc:
module.Simulation()
assert exc.value is original
@pytest.mark.parametrize("name,value,error", [
("steps", -1, ValueError), ("steps", 1.5, TypeError),
("checkpoint_interval", -1, ValueError),
("checkpoint_interval", 1.5, TypeError),
])
def test_invalid_step_request_has_no_state_side_effects(monkeypatch, name, value, error):
sim = make_simulation(monkeypatch)
sim._initialized = True
calls = []
sim._assert_runtime_contracts = lambda: calls.append("accessed runtime")
kwargs = {"steps": 1, name: value}
with pytest.raises(error):
sim.run(**kwargs)
assert calls == [] # no pending edits discarded, obs cleared or GPU work queued
sim.close()
+81
View File
@@ -0,0 +1,81 @@
"""Static slot targets and checkpoint compatibility, using the actual CUDA helper."""
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
@pytest.mark.parametrize('width', [0, 16, 48])
@pytest.mark.parametrize('amplitude', [0., .002, -.002])
def test_actual_slot_targets_and_requested_volume(tmp_path, width, amplitude):
cfg = LBMConfig(nx=48, velocity=0., y_wall_bc='on_site_velocity',
wall_slot_width=width, wall_normal_velocity=amplitude)
macros = cfg.to_macros()
values = np.array(compile_run(tmp_path, 'slot_profile.cpp', {
k: macros[k] for k in ('NX', 'WALL_SLOT_WIDTH', 'WALL_NORMAL_VELOCITY')}), dtype=float)
# Independent node quadrature; includes both zero flanks and the full-width exception.
x = np.arange(48)-23.5
expected = amplitude*np.clip(1-(x/8)**2, 0, None)**2 if width == 16 else np.full(48, amplitude)
np.testing.assert_allclose(values, expected, atol=5e-10, rtol=0)
np.testing.assert_array_equal(values, values[::-1])
assert abs(values.sum()-48*cfg.top_wall_normal_velocity) <= 1e-8
assert abs(float(macros['TOP_WALL_NORMAL_VELOCITY'][:-1])-expected.mean()) < 1e-16
@pytest.mark.parametrize('change', [dict(wall_slot_width=w) for w in (-1, 1, 49, 2.5, True)]
+ [dict(y_wall_bc='bounce_back'), dict(velocity=.01)])
def test_slot_invalid_configuration(change):
values = dict(nx=48, velocity=0., y_wall_bc='on_site_velocity', wall_slot_width=16)
values.update(change)
with pytest.raises(ValueError, match='wall_slot_width'):
LBMConfig(**values).validate_runtime()
def test_slot_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', wall_slot_width=16,
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.wall_slot_width == 16
before = cfg.allocation_signature()
cfg.wall_slot_width = 18
assert before != cfg.allocation_signature()
@pytest.mark.parametrize('width, saved_width, accepted', [(6, 4, False), (6, None, False),
(0, None, True), (6, 6, True)])
def test_slot_checkpoint_width_before_device_writes(device, tmp_path, width, saved_width, accepted):
field = make_field(device, velocity=0., y_wall_bc='on_site_velocity', wall_slot_width=width)
bodies = manager(field)
bodies.sync_to_gpu(field)
stepper = SimpleNamespace(_step_count=0)
path = save_checkpoint(field, SimpleNamespace(step_count=3), field.cfg, bodies,
str(tmp_path/'slot.h5'))
with h5py.File(path, 'a') as hf:
assert hf.attrs['ckpt_version'] == 3
config = json.loads(hf.attrs['config_json'])
config.pop('wall_slot_width')
if saved_width is not None:
config['wall_slot_width'] = saved_width
hf.attrs['config_json'] = json.dumps(config)
before, writes = field.ddf.copy(), device.writes
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='wall_slot_width'):
load_checkpoint(path, field, stepper, field.cfg, bodies)
assert device.writes == writes and stepper._step_count == 0
+517
View File
@@ -0,0 +1,517 @@
"""Real host methods with bounded device-memory doubles; no CUDA context/init.
The memory double checks copy bounds and rejects pageable async transfers.
Kernel math is covered separately by test_numerical_operators; snapshot tests
here check host layout, current-parity arguments and field-only semantics.
"""
from types import SimpleNamespace
import h5py
import numpy as np
import pytest
import pycuda.driver as cuda
from CelerisLab.config import LBMConfig
from CelerisLab.body.manager import ObjectManager
from CelerisLab.body.objects import SimObject
from CelerisLab.body.geometry.circle import CircleGeometry
from CelerisLab.body.ddf_patch import patch_ddf_for_body_sync, upload_patched_ddf
from CelerisLab.common.checkpoint import save_checkpoint, load_checkpoint
from CelerisLab.lbm.field import LBMField
from CelerisLab.lbm.descriptors import FLUID, SOLID, OBSTACLE, SENSOR_FLAG, FRC_REGION
from CelerisLab.lbm.equilibrium import compute_feq_d2q9
W = np.array([4/9] + [1/9]*4 + [1/36]*4, dtype=np.float32)
class Memory:
def __init__(self, address, size):
self.address, self.data, self.freed = address, bytearray(size), False
def __int__(self):
return self.address
def free(self):
assert not self.freed
self.freed = True
class DeviceDouble:
def __init__(self):
self.blocks, self.pinned = [], []
self.writes, self.syncs = 0, 0
def alloc(self, size):
assert size > 0
block = Memory(1000000*(len(self.blocks)+1), int(size))
self.blocks.append(block)
return block
def locate(self, ptr, size):
address = int(ptr)
for block in self.blocks:
if block.address <= address < block.address+len(block.data):
offset = address-block.address
assert not block.freed and offset+size <= len(block.data)
return block, offset
raise AssertionError(f"Invalid device address: {address}")
def htod(self, ptr, array):
data = array if isinstance(array, bytes) else np.asarray(array).tobytes()
block, offset = self.locate(ptr, len(data))
block.data[offset:offset+len(data)] = data
self.writes += 1
def dtoh(self, array, ptr):
block, offset = self.locate(ptr, array.nbytes)
array[:] = np.frombuffer(block.data, dtype=array.dtype,
count=array.size, offset=offset).reshape(array.shape)
def pinned_empty(self, shape, dtype):
array = np.empty(shape, dtype=dtype)
self.pinned.append(array)
return array
def require_pinned(self, array):
assert any(np.shares_memory(array, item) for item in self.pinned), "pageable async transfer"
def htod_async(self, ptr, array, stream):
self.require_pinned(array)
self.htod(ptr, array)
def dtoh_async(self, array, ptr, stream):
self.require_pinned(array)
self.dtoh(array, ptr)
def synchronize(self):
self.syncs += 1
@pytest.fixture
def device(monkeypatch):
device = DeviceDouble()
for name, method in {
"mem_alloc": device.alloc, "memcpy_htod": device.htod,
"memcpy_dtoh": device.dtoh, "memcpy_htod_async": device.htod_async,
"memcpy_dtoh_async": device.dtoh_async, "pagelocked_empty": device.pinned_empty,
}.items():
monkeypatch.setattr(cuda, name, method)
monkeypatch.setattr(cuda, "Context", SimpleNamespace(synchronize=device.synchronize))
return device
class ModuleDouble:
def __init__(self, device):
self.device, self.globals, self.calls = device, {}, []
for name, value in {
"d_w": W,
"d_cx": np.array([0,1,-1,0,0,1,-1,1,-1], dtype=np.int32),
"d_cy": np.array([0,0,0,1,-1,1,-1,-1,1], dtype=np.int32),
"d_params": np.zeros(5, dtype=np.float32),
}.items():
ptr = device.alloc(value.nbytes)
device.htod(ptr, value)
self.globals[name] = ptr, value.nbytes
def get_global(self, name):
return self.globals[name]
def get_function(self, name):
def launch(ddf, scratch, indices, count, step, **kwargs):
self.calls.append((name, int(step)))
idx = np.frombuffer(indices.data, dtype=np.uint32)
raw = np.frombuffer(ddf.data, dtype=np.float32).reshape(9, -1)
# Deliberately simple invertible parity layout. This is a host
# protocol oracle, not a substitute for testing CUDA EsoPull math.
view = raw if int(step) % 2 == 0 else raw[::-1]
local = np.frombuffer(scratch.data, dtype=np.float32).reshape(int(count), 9)
if name == "DecodeCellsToPhysical":
local[:] = view[:, idx].T
elif name == "EncodePhysicalToCells":
view[:, idx] = local.T
else:
raise AssertionError(name)
return launch
def make_field(device, **kwargs):
cfg = LBMConfig(nx=12, ny=10, viscosity=.1, **kwargs)
cfg.validate()
field = LBMField(cfg, ModuleDouble(device))
field.ddf[:] = np.repeat(compute_feq_d2q9(1.1, .03, -.01)[:, None], field.n, axis=1).reshape(-1)
field.upload_ddf()
field.upload_flags()
return field
def obj(x=5, y=5, r=2, **kwargs):
return SimObject(-1, geometry=CircleGeometry(x, y, r), center=(x,y), radius=r, **kwargs)
def manager(field, objects=()):
bodies = ObjectManager(field.nx, field.ny, field.nz, field.nq, field.cfg)
for item in objects:
bodies.add(item)
return bodies
@pytest.mark.parametrize("precision", ["FP32", "FP16S"])
@pytest.mark.parametrize("shift", [False, True])
def test_patch_qmajor_storage_and_untouched_bytes(device, precision, shift):
field = make_field(device, store_precision=precision, ddf_shifting=shift)
old = field.ddf.copy()
before = bytes(field.ddf_gpu.data)
added = np.zeros(field.n, dtype=bool)
added[[5, 6, field.n-2]] = True
released = np.zeros_like(added)
patch_ddf_for_body_sync(field, old, field.flag, field.flag, added, released)
expected = old.reshape(9, -1).copy()
expected[:, added] = compute_feq_d2q9(field.cfg.rho, 0, 0)[:, None]
np.testing.assert_array_equal(field.ddf.reshape(9, -1), expected)
upload_patched_ddf(field, added, released)
encoded = (expected - W[:, None]) if shift else expected
encoded = (encoded * 32768).astype(np.float16) if precision == "FP16S" else encoded
np.testing.assert_array_equal(np.frombuffer(field.ddf_gpu.data, dtype=field.store_dtype).reshape(9,-1), encoded)
itemsize = field.store_bytes
for q in range(9):
for cell in np.flatnonzero(~added):
offset = (q*field.n+cell)*itemsize
assert field.ddf_gpu.data[offset:offset+itemsize] == before[offset:offset+itemsize]
assert field.ddf_gpu.data == field.temp_gpu.data
def test_released_bfs_uses_qmajor_donors(device):
field = make_field(device)
old = field.ddf.copy()
flags = np.full(field.n, FLUID, dtype=np.uint16)
released = np.zeros(field.n, dtype=bool)
released.reshape(field.ny,field.nx)[3:7,3:8] = True
flags[released] = SOLID
field.ddf.reshape(9,-1)[:,released] = -99
patch_ddf_for_body_sync(field, old, flags, field.flag,
np.zeros(field.n,dtype=bool), released)
expected = compute_feq_d2q9(1.1,.03,-.01)
np.testing.assert_allclose(field.ddf.reshape(9,-1)[:,released],
np.repeat(expected[:,None], released.sum(), axis=1), atol=7e-8)
def test_force_survives_other_body_edit_sync_and_preinit_remove(device):
field = make_field(device)
bodies = manager(field, [obj(x=3), obj(x=8, is_force_region=True)])
bodies.set_force_state(1, .004, -.003)
bodies.set_body_state(0, .2)
bodies._refresh_action_from_objects()
np.testing.assert_allclose(bodies.action[6:8], [.004,-.003])
bodies.remove(0)
assert bodies.get(0).is_force_region and bodies.get(0).obj_id == 0
np.testing.assert_allclose(bodies.action[:2], [.004,-.003])
assert bodies.add(obj(x=3)) == 1
next_objects = bodies.build_next_objects()
bodies.commit_pending(next_objects, np.zeros(2, dtype=np.int32))
bodies._refresh_action_from_objects()
np.testing.assert_allclose(bodies.action[:2], [.004,-.003])
@pytest.mark.parametrize("reverse", [False, True])
def test_overlay_preserves_occupancy_and_filters_lists(device, reverse):
field = make_field(device)
objects = [obj(), obj(r=3,is_sensor=True), obj(r=3,is_force_region=True)]
if reverse:
objects.reverse()
bodies = manager(field, objects)
flags = bodies.build_flags(field.build_channel_flags())
center = 5+5*field.nx
assert flags[center] & SOLID and not flags[center] & FLUID
assert not flags[center] & (SENSOR_FLAG | FRC_REGION)
arrays = bodies.build_compact_lists(flags)
assert all(flags[c] & FLUID and not flags[c] & SOLID for c in arrays[8])
assert all(flags[c] & FLUID and not flags[c] & SOLID for c in arrays[10])
sensor = next(o for o in bodies.objects if o.is_sensor)
assert bodies.sensor_cell_counts[sensor.obj_id] == len(arrays[8])
assert len(arrays[8]) < len(sensor.get_sensor_list(field.nx, field.ny)[0])
def test_overlapping_force_regions_rejected(device):
field = make_field(device)
bodies = manager(field, [obj(is_force_region=True), obj(x=6,is_force_region=True)])
with pytest.raises(ValueError, match="Overlapping force_region"):
bodies.build_compact_lists(bodies.build_flags(field.flag))
def test_action_upload_and_scratch_are_pinned(device):
field = make_field(device, streaming="esopull")
bodies = manager(field, [obj(is_force_region=True)])
bodies._allocate_packed_telemetry()
bodies.set_force_state(0,.001,.002)
stream = SimpleNamespace(synchronize=device.synchronize)
bodies._upload_action_async(stream)
np.testing.assert_array_equal(np.frombuffer(bodies.action_gpu.data,dtype=np.float32), bodies.action)
scratch, host, indices = field.esopull_sync_decode(np.array([3,8],dtype=np.uint32),1,stream)
field.esopull_sync_encode(scratch,host,indices,2,1,stream)
assert scratch.freed and indices.freed and device.syncs >= 2
def test_esopull_snapshot_field_only_across_parity(device):
field = make_field(device, streaming="esopull")
original = field.ddf.copy()
with pytest.raises(ValueError, match="step_id"):
field.snapshot()
field.snapshot(step_id=0)
field.ddf_gpu.data[:] = bytes(len(field.ddf_gpu.data))
field.restore(step_id=7)
result = np.frombuffer(field.ddf_gpu.data,dtype=np.float32).reshape(9,-1)[::-1]
np.testing.assert_array_equal(result, original.reshape(9,-1))
assert field.module.calls == [("DecodeCellsToPhysical",0),("EncodePhysicalToCells",7)]
assert field._host_ddf_step is None
def test_snapshot_double_buffer_preserves_field_only_semantics(device):
field = make_field(device)
original = field.ddf.copy()
field.snapshot()
field.ddf *= 2
field.upload_ddf()
field.restore()
field.download_ddf(force=True)
np.testing.assert_array_equal(field.ddf,original)
def test_checkpoint_restores_action_cache_and_observation(device,tmp_path):
field = make_field(device)
bodies = manager(field,[obj(x=3,r=1),obj(x=8,r=1,is_force_region=True)])
bodies.set_body_state(0,.125)
bodies.set_force_state(1,.001,-.002)
bodies.sync_to_gpu(field)
field.update_params(fx=.0002)
bodies.obs_pinned[:] = np.arange(bodies.obs_total_floats)
device.htod(bodies.obs_gpu,bodies.obs_pinned)
bodies._obs_accum_steps = 4
stepper = SimpleNamespace(step_count=7,_step_count=7)
path = save_checkpoint(field,stepper,field.cfg,bodies,str(tmp_path/'state.h5'))
expected = bytes(field.ddf_gpu.data)
bodies.set_body_state(0,0)
bodies.set_force_state(1,0,0)
field.ddf[:] = 99
field._host_ddf_step = 7
field.ddf_gpu.data[:] = bytes(len(field.ddf_gpu.data))
load_checkpoint(path,field,stepper,field.cfg,bodies)
assert bytes(field.ddf_gpu.data) == expected
assert field._host_ddf_step is None
field.download_ddf(step_id=7)
assert field.ddf.max() < 1
np.testing.assert_allclose(bodies.action[[5,6,7]],[.125,.001,-.002])
np.testing.assert_array_equal(np.frombuffer(bodies.action_gpu.data,dtype=np.float32),bodies.action)
assert bodies._obs_accum_steps == 4
np.testing.assert_array_equal(bodies.obs_pinned,np.arange(bodies.obs_total_floats))
assert field._runtime_params['fx'] == .0002
@pytest.mark.parametrize("periodic_x", [False, True])
@pytest.mark.parametrize("with_objects", [False, True])
def test_checkpoint_restores_actual_domain(device, tmp_path, periodic_x, with_objects):
source = make_field(device)
objects = [obj(x=2, r=1), obj(x=10, r=2, is_sensor=True)] if with_objects else []
bodies = manager(source, objects)
base = source.build_channel_flags()
if periodic_x:
base.reshape(source.ny, source.nx)[1:-1, [0, -1]] = FLUID
source.flag = bodies.build_flags(base)
source.upload_flags()
bodies.sync_to_gpu(source, rebuild_flags=False)
saved_flags = source.flag.copy()
# The device, not a possibly stale host mirror, is authoritative at save.
source.flag = source.build_channel_flags()
path = save_checkpoint(source, SimpleNamespace(step_count=3), source.cfg,
bodies, str(tmp_path / 'domain.h5'))
target = make_field(device)
target_bodies = manager(target, objects)
stepper = SimpleNamespace(_step_count=0)
load_checkpoint(path, target, stepper, target.cfg, target_bodies)
assert stepper._step_count == 3
np.testing.assert_array_equal(target.flag, saved_flags)
np.testing.assert_array_equal(np.frombuffer(target.flag_gpu.data, np.uint16), saved_flags)
assert target.ddf_gpu.data == source.ddf_gpu.data
@pytest.mark.parametrize("periodic_x", [False, True])
@pytest.mark.parametrize("change", ["geometry", "role", "ddf_shape"])
def test_checkpoint_rejects_before_device_writes(device,tmp_path,change,periodic_x):
field = make_field(device)
bodies = manager(field,[obj()])
bodies.sync_to_gpu(field)
if periodic_x:
field.flag.reshape(field.ny, field.nx)[1:-1, [0, -1]] = FLUID
field.upload_flags()
path = save_checkpoint(field,SimpleNamespace(step_count=3),field.cfg,bodies,str(tmp_path/'state.h5'))
if change == 'geometry':
bodies.get(0).geometry = CircleGeometry(6,5,2)
elif change == 'role':
bodies.get(0)._is_sensor = True
else:
with h5py.File(path,'a') as hf:
del hf['ddf']
hf.create_dataset('ddf',data=np.zeros(2,dtype=np.float32))
writes = device.writes
with pytest.raises(ValueError):
load_checkpoint(path,field,SimpleNamespace(_step_count=3),field.cfg,bodies)
assert device.writes == writes
def test_checkpoint_parameter_study_keeps_new_viscosity(device,tmp_path):
field = make_field(device)
bodies = manager(field)
bodies.sync_to_gpu(field)
path = save_checkpoint(field,SimpleNamespace(step_count=3),field.cfg,bodies,str(tmp_path/'state.h5'))
field.cfg.viscosity = .2
field.cfg.validate()
load_checkpoint(path,field,SimpleNamespace(_step_count=0),field.cfg,bodies)
assert field._runtime_params['omega'] == field.cfg.omega
@pytest.mark.parametrize('version', [1, 2])
def test_legacy_checkpoint_default_domain(device, tmp_path, version):
field = make_field(device)
bodies = manager(field, [obj()])
bodies.sync_to_gpu(field)
path = save_checkpoint(field, SimpleNamespace(step_count=3), field.cfg,
bodies, str(tmp_path / 'legacy-domain.h5'))
with h5py.File(path, 'a') as hf:
hf.attrs['ckpt_version'] = version
del hf['domain_flag']
load_checkpoint(path, field, SimpleNamespace(_step_count=0), field.cfg, bodies)
# Legacy files have no authoritative base-domain record. Keep their check.
with h5py.File(path, 'a') as hf:
hf['flag'][field.nx] = FLUID
writes = device.writes
with pytest.raises(ValueError, match='geometry'):
load_checkpoint(path, field, SimpleNamespace(_step_count=0), field.cfg, bodies)
assert device.writes == writes
@pytest.mark.parametrize('damage', ['missing', 'shape', 'dtype', 'boundary', 'hidden', 'annotation'])
def test_checkpoint_rejects_invalid_domain_before_writes(device, tmp_path, damage):
field = make_field(device)
bodies = manager(field, [obj()])
bodies.sync_to_gpu(field)
path = save_checkpoint(field, SimpleNamespace(step_count=3), field.cfg,
bodies, str(tmp_path / 'invalid-domain.h5'))
with h5py.File(path, 'a') as hf:
if damage in ('missing', 'shape', 'dtype'):
del hf['domain_flag']
if damage != 'missing':
hf.create_dataset('domain_flag', data=np.ones(
2 if damage == 'shape' else field.n,
dtype=np.uint16 if damage == 'shape' else np.float32))
elif damage == 'boundary':
hf['domain_flag'][field.nx] = FLUID
elif damage == 'hidden':
hf['domain_flag'][5 + 5 * field.nx] = SOLID
else:
# Mutate both records: overlay idempotence alone would accept this.
for name in ('domain_flag', 'flag'):
hf[name][field.nx + 1] = FLUID | SENSOR_FLAG
before = bytes(field.ddf_gpu.data), field.flag.copy()
writes = device.writes
with pytest.raises(ValueError):
load_checkpoint(path, field, SimpleNamespace(_step_count=0), field.cfg, bodies)
assert device.writes == writes
assert bytes(field.ddf_gpu.data) == before[0]
np.testing.assert_array_equal(field.flag, before[1])
@pytest.mark.parametrize('extra', [OBSTACLE | SOLID, SENSOR_FLAG, FRC_REGION])
def test_checkpoint_save_rejects_unregistered_overlay(device, tmp_path, extra):
field = make_field(device)
bodies = manager(field)
field.flag[field.nx + 1] |= extra
field.upload_flags()
path = tmp_path / 'unchanged.h5'
path.write_bytes(b'existing checkpoint')
with pytest.raises(ValueError):
save_checkpoint(field, SimpleNamespace(step_count=0), field.cfg, bodies, str(path))
assert path.read_bytes() == b'existing checkpoint'
def test_checkpoint_domain_handles_plain_solid_mask(device, tmp_path):
class PlainSolidCircle(CircleGeometry):
def build_flag_mask(self, nx, ny):
mask = super().build_flag_mask(nx, ny)
mask[(mask & SOLID) != 0] = SOLID
return mask
field = make_field(device)
body = obj()
body.geometry = PlainSolidCircle(5, 5, 2)
bodies = manager(field, [body])
bodies.sync_to_gpu(field)
path = save_checkpoint(field, SimpleNamespace(step_count=3), field.cfg,
bodies, str(tmp_path / 'plain-solid.h5'))
with h5py.File(path) as hf:
assert hf['domain_flag'][5 + 5 * field.nx] == FLUID
load_checkpoint(path, field, SimpleNamespace(_step_count=0), field.cfg, bodies)
@pytest.mark.parametrize("kind",['circle','sensor','force_region'])
def test_legacy_checkpoint_supported_scope(device,tmp_path,kind):
field = make_field(device)
bodies = manager(field,[obj(is_sensor=kind=='sensor',is_force_region=kind=='force_region')])
bodies.set_body_state(0,.12)
bodies.sync_to_gpu(field)
path = save_checkpoint(field,SimpleNamespace(step_count=3),field.cfg,bodies,str(tmp_path/'legacy.h5'))
with h5py.File(path,'a') as hf:
hf.attrs['ckpt_version'] = 1
del hf.attrs['geometry_json']
del hf.attrs['runtime_params_json']
del hf['obs']
if kind == 'force_region':
with pytest.raises(ValueError,match='Legacy v1.*force_region'):
load_checkpoint(path,field,SimpleNamespace(_step_count=0),field.cfg,bodies)
else:
bodies.set_body_state(0,0)
load_checkpoint(path,field,SimpleNamespace(_step_count=0),field.cfg,bodies)
assert bodies.action[5] == np.float32(.12)
assert bodies._obs_accum_steps == 0
def test_compact_upload_stream_uses_pinned_staging(device):
field = make_field(device)
bodies = manager(field,[obj(),obj(x=9,r=1,is_sensor=True)])
bodies.sync_to_gpu(field)
stream = SimpleNamespace(synchronize=device.synchronize)
field.upload_compact_lists(stream=stream)
assert device.syncs >= 2
np.testing.assert_array_equal(np.frombuffer(field.curved.fluid_idx_gpu.data,dtype=np.uint32),
field.curved.fluid_idx)
np.testing.assert_array_equal(np.frombuffer(field.sensors.cells_gpu.data,dtype=np.uint32),
field.sensors.cells)
@pytest.mark.parametrize('attribute,value',[
('runtime_params_json','{"fx": "not-a-number"}'),('obs_accum_steps','broken'),
])
def test_checkpoint_bad_metadata_rejected_before_writes(device,tmp_path,attribute,value):
field = make_field(device)
bodies = manager(field)
bodies.sync_to_gpu(field)
path = save_checkpoint(field,SimpleNamespace(step_count=3),field.cfg,bodies,str(tmp_path/'state.h5'))
with h5py.File(path,'a') as hf:
hf.attrs[attribute] = value
writes = device.writes
with pytest.raises(ValueError):
load_checkpoint(path,field,SimpleNamespace(_step_count=3),field.cfg,bodies)
assert device.writes == writes
def test_checkpoint_preserves_pending_vs_applied_action(device,tmp_path):
field = make_field(device)
bodies = manager(field,[obj(is_force_region=True)])
bodies.set_force_state(0,.001,.002)
bodies.sync_to_gpu(field)
applied = bodies.action.copy()
bodies.set_force_state(0,.003,.004) # Next upload target; device is unchanged.
pending = bodies.action.copy()
path = save_checkpoint(field,SimpleNamespace(step_count=3),field.cfg,bodies,str(tmp_path/'state.h5'))
bodies.set_force_state(0,0,0)
load_checkpoint(path,field,SimpleNamespace(_step_count=0),field.cfg,bodies)
np.testing.assert_array_equal(bodies.action,pending)
np.testing.assert_array_equal(np.frombuffer(bodies.action_gpu.data,dtype=np.float32),applied)
+230
View File
@@ -0,0 +1,230 @@
"""Static circle apertures: real geometry and host/device-memory protocol tests."""
from dataclasses import FrozenInstanceError, asdict
import json
import math
from types import SimpleNamespace
import h5py
import numpy as np
import pytest
from CelerisLab.body.geometry.circle import CircleGeometry
from CelerisLab.body.manager import ObjectManager
from CelerisLab.body.objects import SimObject, SurfaceActuation
from CelerisLab.common.checkpoint import load_checkpoint, save_checkpoint
from CelerisLab.config import LBMConfig
from CelerisLab.lbm.curved_links import CurvedLinkSoA
from CelerisLab.lbm.descriptors import FLUID, SOLID
from CelerisLab.simulation import Simulation
from .test_state_regressions import device, make_field, manager
def circle(spec=None, *, center=(6., 5.), radius=2.999, **kwargs):
return SimObject(0, geometry=CircleGeometry(*center, radius), center=center,
radius=radius, surface_actuation=spec, **kwargs)
def projected(obj):
cfg = LBMConfig(nx=12, ny=10, viscosity=.1)
cfg.validate()
flags = ObjectManager.build_flags_for(
[obj], np.full(cfg.nx*cfg.ny, FLUID, np.uint16), nx=cfg.nx, ny=cfg.ny, nz=1)
cl = CurvedLinkSoA()
cl.assign_host(*obj.get_curved_list(cfg.nx, cfg.ny, cfg.nq, domain_flags=flags))
cl.assign_surface([obj], cfg, flags)
return cl, cfg, flags
@pytest.mark.parametrize('speed', [.002, -.002])
def test_aperture_uses_true_hit_not_fluid_node_or_clamped_q(speed):
# Fluid (9,5), direction (-1,+1), circle radius 2.999. Actual t < .01,
# whereas the existing Bouzidi q is clipped to .01. The narrow opening
# contains the true hit but neither the fluid-node angle nor clipped hit.
t = (6-math.sqrt(36-8*(9-2.999**2)))/4
angle = math.atan2(t, 3-t)
spec = SurfaceActuation(angle, .0001, speed)
cl, _, _ = projected(circle(spec))
index, = np.flatnonzero((cl.fluid_idx == 9+5*12) & (cl.dir == 8))
assert cl.q[index] == np.float32(.01)
assert angle > spec.half_width
assert math.atan2(.01, 2.99)-angle > spec.half_width
assert cl.aperture[index]
expected = speed*np.array([3-t, t])/2.999
np.testing.assert_allclose(cl.surface_velocity[index], expected, rtol=2e-6, atol=1e-10)
np.testing.assert_allclose(np.hypot(cl.rx, cl.ry), 2.999, rtol=0, atol=3e-7)
np.testing.assert_array_equal(cl.surface_velocity[~cl.aperture], 0.)
def test_surface_zero_preserves_eight_column_geometry_and_mask():
plain, zero = circle(), circle(SurfaceActuation(0., .7, 0.))
old, new = plain.get_curved_list(12, 10, 9), zero.get_curved_list(12, 10, 9)
assert len(old) == len(new) == 8
for before, after in zip(old, new):
np.testing.assert_array_equal(before, after)
np.testing.assert_array_equal(plain.get_flag_mask(12, 10), zero.get_flag_mask(12, 10))
cl, _, _ = projected(zero)
assert cl.aperture.any()
np.testing.assert_array_equal(cl.surface_velocity, 0.)
assert projected(plain)[0].surface_velocity is None
def test_surface_spec_is_immutable_and_wraps_angles():
spec = SurfaceActuation(math.pi, .2, .002)
with pytest.raises(FrozenInstanceError):
spec.normal_velocity = .003
angles = np.array([math.pi-.1, -math.pi+.1, 0.])
velocity, mask = spec.project(np.cos(angles), np.sin(angles))
np.testing.assert_array_equal(mask, [True, True, False])
np.testing.assert_allclose(np.linalg.norm(velocity[:2], axis=1), .002*(1-.5**2)**2,
rtol=1e-6)
np.testing.assert_array_equal(velocity[2], 0.)
@pytest.mark.parametrize('values', [(0., 0., .002), (0., math.pi+.1, .002),
(0., .2, 1.), (math.nan, .2, .002),
(0., math.inf, .002), (0., .2, math.nan)])
def test_invalid_surface_spec(values):
with pytest.raises(ValueError):
SurfaceActuation(*values)
@pytest.mark.parametrize('override', [dict(streaming='esopull'), dict(store_precision='FP16S'),
dict(ddf_shifting=True), dict(lattice_model='D3Q19')])
def test_surface_rejects_unsupported_mode_before_registration(override):
cfg = LBMConfig(nx=12, ny=10, **override)
cfg.validate()
bodies = ObjectManager(cfg.nx, cfg.ny, cfg.nz, cfg.nq, cfg)
with pytest.raises(ValueError, match='Surface actuation requires'):
bodies.add(circle(SurfaceActuation(0., .7, .002)))
assert bodies.count == 0
@pytest.mark.parametrize('role', ['is_sensor', 'is_force_region'])
def test_surface_rejects_nonwall_roles(role):
with pytest.raises(ValueError):
circle(SurfaceActuation(0., .7, .002), **{role: True}).validate_surface_actuation(LBMConfig())
@pytest.mark.parametrize('key', ['omega', 'vx', 'vy'])
def test_surface_set_body_rejects_motion_before_mutation(key):
cfg = LBMConfig(nx=12, ny=10)
cfg.validate()
bodies = ObjectManager(cfg.nx, cfg.ny, cfg.nz, cfg.nq, cfg)
item = circle(SurfaceActuation(0., .7, .002))
bodies.add(item)
sim = Simulation.__new__(Simulation)
sim.lbm_cfg, sim.bodies = cfg, bodies
state, action = asdict(item.state), bodies.action.copy()
with pytest.raises(ValueError, match='stationary'):
sim.set_body(0, **{key: .01})
assert asdict(item.state) == state
np.testing.assert_array_equal(bodies.action, action)
def test_surface_projection_rejects_unresolved_aperture_and_bad_writers():
with pytest.raises(ValueError, match='no resolved'):
projected(circle(SurfaceActuation(.123456, 1e-9, .002)))
item = circle(SurfaceActuation(0., .7, .002))
cl, cfg, flags = projected(item)
flags[cl.fluid_idx[0]] = SOLID
with pytest.raises(ValueError, match='active fluid'):
cl.assign_surface([item], cfg, flags)
flags[cl.fluid_idx[0]] = FLUID
cl.fluid_idx[1], cl.dir[1] = cl.fluid_idx[0], cl.dir[0]
with pytest.raises(ValueError, match='unique'):
cl.assign_surface([item], cfg, flags)
def test_surface_public_dict_entry_and_sync_copy():
cfg = LBMConfig(nx=12, ny=10)
cfg.validate()
sim = Simulation.__new__(Simulation)
sim.lbm_cfg, sim._initialized = cfg, False
sim.bodies = ObjectManager(cfg.nx, cfg.ny, cfg.nz, cfg.nq, cfg)
spec = dict(angle=0., half_width=.7, normal_velocity=.002)
oid = sim.add_body('circle', center=(6., 5.), radius=2., surface_actuation=spec)
copied, = sim.bodies.build_next_objects()
assert asdict(copied.surface_actuation) == spec
assert copied.surface_actuation is sim.bodies.get(oid).surface_actuation
def case(device, spec):
field = make_field(device)
bodies = manager(field, [circle(spec, radius=2.)])
bodies.sync_to_gpu(field)
return field, bodies
def test_surface_gpu_buffer_lifecycle_with_memory_double(device):
field, bodies = case(device, SurfaceActuation(0., .7, .002))
cl = field.curved
velocity, exchange = cl.surface_velocity_gpu, cl.link_exchange_gpu
np.testing.assert_array_equal(np.frombuffer(velocity.data, np.float32).reshape(-1, 2),
cl.surface_velocity)
np.testing.assert_array_equal(np.frombuffer(exchange.data, np.float32), 0.)
cl.assign_surface([], field.cfg, field.flag)
cl.upload()
assert velocity.freed and exchange.freed
assert cl.surface_velocity_gpu is None and cl.link_exchange_gpu is None
cl.assign_surface(bodies.objects, field.cfg, field.flag)
cl.upload()
velocity, exchange = cl.surface_velocity_gpu, cl.link_exchange_gpu
cl.free()
assert velocity.freed and exchange.freed and cl.count == 0
@pytest.mark.parametrize('damage', ['missing_spec', 'legacy_missing_spec', 'different_spec',
'omega', 'vx', 'vy', 'applied_omega'])
def test_surface_checkpoint_rejects_mismatch_before_any_write(device, tmp_path, damage):
field, bodies = case(device, SurfaceActuation(0., .7, .002))
path = save_checkpoint(field, SimpleNamespace(step_count=3), field.cfg, bodies,
str(tmp_path/'surface.h5'))
with h5py.File(path, 'a') as hf:
assert hf.attrs['ckpt_version'] == 4
if damage in ('missing_spec', 'legacy_missing_spec'):
del hf.attrs['surface_actuation_json']
if damage == 'legacy_missing_spec':
hf.attrs['ckpt_version'] = 3
elif damage == 'different_spec':
records = json.loads(hf.attrs['surface_actuation_json'])
records[0]['normal_velocity'] *= -1
hf.attrs['surface_actuation_json'] = json.dumps(records)
elif damage == 'applied_omega':
action = hf['applied_action'][:]
action[-1] = .01
hf['applied_action'][:] = action
else:
records = json.loads(hf.attrs['objects_json'])
records[0][damage] = .01
hf.attrs['objects_json'] = json.dumps(records)
writes, state = device.writes, asdict(bodies.get(0).state)
raw = [bytes(ptr.data) for ptr in (field.ddf_gpu, field.temp_gpu, field.flag_gpu)]
stepper = SimpleNamespace(_step_count=99)
with pytest.raises(ValueError):
load_checkpoint(path, field, stepper, field.cfg, bodies)
assert device.writes == writes and stepper._step_count == 99
assert asdict(bodies.get(0).state) == state
assert raw == [bytes(ptr.data) for ptr in (field.ddf_gpu, field.temp_gpu, field.flag_gpu)]
@pytest.mark.parametrize('enabled', [False, True])
def test_surface_checkpoint_rebuild_and_legacy_absence(device, tmp_path, enabled):
spec = SurfaceActuation(0., .7, .002) if enabled else None
field, bodies = case(device, spec)
path = save_checkpoint(field, SimpleNamespace(step_count=3), field.cfg, bodies,
str(tmp_path/'surface.h5'))
with h5py.File(path, 'a') as hf:
assert hf.attrs['ckpt_version'] == (4 if enabled else 3)
if not enabled:
del hf.attrs['surface_actuation_json']
restored, restored_bodies = case(device, spec)
stepper = SimpleNamespace(_step_count=0)
load_checkpoint(path, restored, stepper, restored.cfg, restored_bodies)
assert stepper._step_count == 3
for source, target in ((field.ddf_gpu, restored.ddf_gpu), (field.temp_gpu, restored.temp_gpu),
(field.flag_gpu, restored.flag_gpu)):
assert bytes(source.data) == bytes(target.data)
if enabled:
np.testing.assert_array_equal(field.curved.surface_velocity, restored.curved.surface_velocity)
else:
assert restored.curved.surface_velocity is None