Add jet, surface and fixed-patch tests and runnable examples
This commit is contained in:
1 parent
ed1a6ee54e
commit
cdbb9c42f6
48 files changed
+9267
-210
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -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("");
|
||||
}
|
||||
}
|
||||
@@ -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("");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Reference in new issue
Block a user