288 lines
17 KiB
Python
288 lines
17 KiB
Python
"""Host-only ownership/geometry tests, never a GPU or accuracy admission."""
|
|
import copy
|
|
import json
|
|
import sys
|
|
import unittest
|
|
|
|
import numpy as np
|
|
from CelerisLab.config import LBMConfig
|
|
from CelerisLab.lbm.blocks import BlockLayout2D
|
|
from CelerisLab.lbm.refinement import FixedPatch2D, FixedPatchSet2D
|
|
from spacetime_union_plan import UnionPlan, UnresolvedTopology, owned_slots, validate_ownership
|
|
import spacetime_reference as ref
|
|
import spacetime_gpu as frozen # pack_plan only: no CUDA import/owner construction
|
|
from spacetime_table_guard import exact_weights
|
|
|
|
MATRIX=[]
|
|
ENUMERATION={}
|
|
CASES={
|
|
'rectangle': ([(4,4,12,12)],True),
|
|
'two_separated': ([(3,3,6,6),(9,9,12,12)],True),
|
|
'one_cell_face_gap': ([(3,3,6,10),(7,3,10,10)],True),
|
|
'one_cell_diagonal_gap': ([(3,3,6,6),(7,7,10,10)],True),
|
|
'staggered_gap': ([(3,3,6,8),(7,5,11,10)],True),
|
|
'thin_rectangle': ([(4,4,5,11)],True),
|
|
'concave_L': ([(4,4,5,7),(4,5,7,7)],True),
|
|
'corner_contact': ([(3,3,6,6),(6,6,9,9)],True),
|
|
'cross': ([(3,6,13,9),(6,3,9,13)],True),
|
|
'narrow_notch': ([(3,3,6,11),(7,3,10,11),(3,9,10,11)],True),
|
|
'hole': ([(3,3,13,5),(3,11,13,13),(3,5,5,11),(11,5,13,11)],True),
|
|
}
|
|
|
|
|
|
def pair(rects,n=16,ny=None):
|
|
cfg=LBMConfig(nx=n,ny=n if ny is None else ny,viscosity=.0192,velocity=0.)
|
|
return FixedPatchSet2D.from_config(cfg,rects)
|
|
|
|
|
|
def assert_same(test,a,b):
|
|
# Includes event-child order, zero padding, integer dtype/shape and FP64 bits.
|
|
aa,bb=frozen.pack_plan(a),frozen.pack_plan(b)
|
|
test.assertEqual(aa.keys(),bb.keys())
|
|
for key in aa:
|
|
test.assertEqual((aa[key].dtype,aa[key].shape,aa[key].tobytes()),
|
|
(bb[key].dtype,bb[key].shape,bb[key].tobytes()),key)
|
|
for x,y in zip(a.groups,b.groups):
|
|
test.assertEqual((x.q,x.source,x.receiver,x.kind,x.points,x.events),
|
|
(y.q,y.source,y.receiver,y.kind,y.points,y.events))
|
|
# Frozen pack_plan has no transit column and is NOT a union runtime format.
|
|
np.testing.assert_array_equal(getattr(a,'transit',np.empty((0,3),int)),
|
|
getattr(b,'transit',np.empty((0,3),int)))
|
|
|
|
|
|
class UnionContract(unittest.TestCase):
|
|
def test_single_rectangle_exact_707afe6(self):
|
|
frozen.check_sources()
|
|
for n,b in ((12,(3,3,9,8)),(16,(4,5,11,12)),(16,(4,4,5,7)),(64,(8,8,56,48))):
|
|
with self.subTest(n=n,bounds=b):
|
|
assert_same(self,UnionPlan(pair([b],n)),ref.Plan(n,b))
|
|
self.assertNotIn('pycuda.driver',sys.modules)
|
|
|
|
def test_union_decomposition_and_permutation_invariance(self):
|
|
baseline=UnionPlan(pair([(4,4,12,12)]))
|
|
decompositions=[[(4,4,8,12),(8,4,12,12)],[(4,4,10,12),(6,4,12,12)],
|
|
[(4,4,12,8),(4,8,12,12)],[(4,4,12,12),(4,4,12,12)]]
|
|
for rectangles in decompositions:
|
|
for order in (rectangles,list(reversed(rectangles))):
|
|
assert_same(self,baseline,UnionPlan(pair(order)))
|
|
two=CASES['two_separated'][0]
|
|
assert_same(self,UnionPlan(pair(two)),UnionPlan(pair(list(reversed(two)))))
|
|
lshape=CASES['concave_L'][0]
|
|
for decomposition in (list(reversed(lshape)),[(4,4,5,5),(4,5,7,7)],lshape+lshape):
|
|
assert_same(self,UnionPlan(pair(lshape)),UnionPlan(pair(decomposition)))
|
|
|
|
def test_supported_unresolved_matrix(self):
|
|
for name,(rectangles,supported) in CASES.items():
|
|
geometry=pair(rectangles) # Existing production accepts EVERY fixture.
|
|
row=dict(case=name,rectangles=rectangles,production_geometry_accepted=True,
|
|
runtime_supported=False,interpolation_accuracy='unqualified_for_new_unions')
|
|
if supported:
|
|
plan=UnionPlan(geometry); row.update(plan.proof)
|
|
self.assertEqual(plan.proof['receiver_quarter_packets'],4*len(plan.receivers))
|
|
else:
|
|
with self.assertRaises(UnresolvedTopology) as caught: UnionPlan(geometry)
|
|
row.update(topology='unresolved',code=caught.exception.code,witness=caught.exception.detail)
|
|
MATRIX.append(row)
|
|
|
|
def test_L_witness_and_reflections(self):
|
|
rectangles=CASES['concave_L'][0]
|
|
plan=UnionPlan(pair(rectangles))
|
|
self.assertIn((5,8*32+9,9*32+10),map(tuple,plan.transit))
|
|
directions=set()
|
|
for reflect in (False,True):
|
|
transformed=[(16-x1,y0,16-x0,y1) if reflect else (x0,y0,x1,y1) for x0,y0,x1,y1 in rectangles]
|
|
for _ in range(4):
|
|
plan=UnionPlan(pair(transformed)); self.assertEqual(len(plan.transit),2)
|
|
for q,source,ghost in plan.transit:
|
|
directions.add(q); p=np.array(divmod(source,32)); c=np.array((ref.cpu.CY[q],ref.cpu.CX[q]))
|
|
self.assertEqual(ghost,(p+c)[0]*32+(p+c)[1])
|
|
self.assertEqual([bool(plan.maps.fine_active[tuple(p+j*c)]) for j in range(3)],[True,False,True])
|
|
transformed=[(16-y1,x0,16-y0,x1) for x0,y0,x1,y1 in transformed]
|
|
self.assertEqual(directions,{5,6,7,8})
|
|
|
|
def test_Fig19_two_hop_transport_and_quarter_budget(self):
|
|
# Guzik §3.5.4 Fig.19: https://www.osti.gov/servlets/purl/1226215 .
|
|
# Isolated tagged transport only: no collision law or union evolution.
|
|
for name in ('concave_L','corner_contact','hole'):
|
|
p=UnionPlan(pair(CASES[name][0])); q,source,ghost=p.transit.T
|
|
stride=ref.cpu.CY[q]*p.nf+ref.cpu.CX[q]; target=ghost+stride
|
|
tags=(np.arange(len(q),dtype=np.float32)+1)/256
|
|
active=p.maps.fine_active
|
|
def run(shifted,mutation=None):
|
|
scratch=np.full((9,p.nf,p.nf),np.nan,np.float32)
|
|
scratch.reshape(9,-1)[q,source]=tags
|
|
def moment(field):
|
|
value=field.astype(float)+(ref.cpu.W[:,None,None] if shifted else 0)
|
|
return .25*np.where(np.isfinite(field)&active,value,0).sum(axis=(1,2))
|
|
def pull(field):
|
|
moved=np.stack([np.roll(field[j],(ref.cpu.CY[j],ref.cpu.CX[j]),axis=(0,1)) for j in range(9)])
|
|
moved[:,~active]=np.nan
|
|
return moved
|
|
initial=moment(scratch)
|
|
carry=[(int(j),int(h),v.copy()) for j,h,v in zip(q,ghost,scratch.reshape(9,-1)[q,source])]
|
|
if mutation=='drop': carry.pop()
|
|
if mutation=='duplicate': carry.append(carry[0])
|
|
first=pull(scratch) # m0 ghost arrivals are outside the ACTIVE mask.
|
|
inventory=np.zeros(9)
|
|
for j,h,value in carry: inventory[j]+=.25*(float(value)+(ref.cpu.W[j] if shifted else 0))
|
|
np.testing.assert_allclose(moment(first)+inventory,initial,rtol=0,atol=2e-14)
|
|
if mutation=='early':
|
|
for j,h,value in carry: scratch.reshape(9,-1)[j,h]=value
|
|
scratch[:]=np.nan # m1 collision scratch reuse destroys an early injection.
|
|
while carry:
|
|
j,h,value=carry.pop()
|
|
if mutation!='early': scratch.reshape(9,-1)[j,h]=value
|
|
final=pull(scratch)
|
|
self.assertEqual(carry,[]) # Consumed route inventory, not subtraction of identical sums.
|
|
np.testing.assert_allclose(moment(final),initial,rtol=0,atol=2e-14)
|
|
arrivals=final.reshape(9,-1)[q,target]
|
|
np.testing.assert_array_equal(arrivals.view(np.uint32),tags.view(np.uint32))
|
|
for shifted in (False,True):
|
|
run(shifted)
|
|
for mutation in ('drop','duplicate','early'):
|
|
with self.assertRaises(AssertionError): run(shifted,mutation)
|
|
for direction,slot,_ in p.transit:
|
|
self.assertNotIn((direction,slot),{tuple(row[:2]) for row in p.outgoing[0]})
|
|
for change in (lambda b:setattr(b,'transit',b.transit[1:]),
|
|
lambda b:setattr(b,'transit',np.vstack((b.transit,b.transit[0]))),
|
|
lambda b:b.transit.__setitem__((0,2),b.transit[0,2]+1),
|
|
lambda b:b.transit.__setitem__((0,0),1)):
|
|
bad=copy.deepcopy(p); change(bad)
|
|
with self.assertRaises(ValueError): validate_ownership(bad)
|
|
bad=copy.deepcopy(p); direction,slot,_=p.transit[0]
|
|
y,x=divmod(slot,p.nf); cy,cx=ref.cpu.CY[direction],ref.cpu.CX[direction]
|
|
covered_target=((y+2*cy)//2)*p.n+(x+2*cx)//2
|
|
bad.outgoing[0]=np.vstack((bad.outgoing[0],[direction,slot,covered_target]))
|
|
with self.assertRaises(ValueError): validate_ownership(bad)
|
|
|
|
def test_Fig13_first_entry_and_wrong_crossing(self):
|
|
p=UnionPlan(pair(CASES['concave_L'][0]))
|
|
g=next(g for g in p.groups if g.source==4*16+5 and g.q==8)
|
|
self.assertEqual(g.kind,'corner')
|
|
self.assertEqual(sorted(point[-1] for point in g.points),[-.25,-.25,-.25,.25])
|
|
# For a concave T first entry is min, not the frozen convex max.
|
|
damaged=copy.deepcopy(p); wrong=next(x for x in damaged.groups if x.source==g.source and x.q==g.q)
|
|
origin=np.array((4,5)); c=np.array((ref.cpu.CY[g.q],ref.cpu.CX[g.q])); points=[]
|
|
for child in ((0,0),(0,1),(1,0),(1,1)):
|
|
position=origin+(np.array(child)+.5)/2
|
|
time=max((origin+ (c>0)-position)/c); offset=position+c*time-(origin+.5+c*.5)
|
|
points.append((float(offset[1]),float(offset[0]),float(time-.5)))
|
|
wrong.points=tuple(points)
|
|
with self.assertRaises(ValueError): validate_ownership(damaged)
|
|
|
|
def test_all_511_small_masks(self):
|
|
counts=dict(masks=0,with_transit=0,transit_paths=0)
|
|
for bits in range(1,512):
|
|
rectangles=[(4+x,4+y,5+x,5+y) for y in range(3) for x in range(3) if bits & (1<<(3*y+x))]
|
|
p=UnionPlan(pair(rectangles)); counts['masks']+=1
|
|
counts['with_transit']+=bool(len(p.transit)); counts['transit_paths']+=len(p.transit)
|
|
# Independent first-entry check: fine-cell membership before/after
|
|
# each quarter crossing, without the plan's plane min/max helper.
|
|
for g in p.groups:
|
|
c=np.array((ref.cpu.CY[g.q],ref.cpu.CX[g.q])); origin=np.array(divmod(g.source,p.n))
|
|
for k,child in enumerate(((0,0),(0,1),(1,0),(1,1))):
|
|
start=2*origin+child; hits=[]
|
|
for t4 in (1,3):
|
|
before=(4*start+2+c*(2*t4-1))//4
|
|
after=(4*start+2+c*(2*t4+1))//4
|
|
if not p.maps.fine_active[tuple(before)] and p.maps.fine_active[tuple(after)]: hits.append(t4/4)
|
|
if hits: self.assertEqual(g.points[k][-1]+.5,hits[0])
|
|
self.assertEqual(counts,dict(masks=511,with_transit=384,transit_paths=2048)); ENUMERATION.update(counts)
|
|
|
|
def test_ordered_provenance_and_packet_mutations_fail(self):
|
|
baseline=UnionPlan(pair(CASES['two_separated'][0]))
|
|
def reject(change):
|
|
bad=copy.deepcopy(baseline); change(bad)
|
|
with self.assertRaises((ValueError,KeyError)): validate_ownership(bad)
|
|
reject(lambda p:setattr(p.groups[0],'source',p.groups[0].source+1))
|
|
reject(lambda p:setattr(p.groups[0],'samples',p.groups[0].samples+1))
|
|
reject(lambda p:setattr(p.groups[0],'events',tuple(reversed(p.groups[0].events))))
|
|
reject(lambda p:setattr(p.groups[0],'receiver',p.groups[0].receiver+1))
|
|
reject(lambda p:p.outgoing.__setitem__(0,p.outgoing[0][1:]))
|
|
reject(lambda p:p.outgoing.__setitem__(1,np.vstack((p.outgoing[1],p.outgoing[1][0]))))
|
|
reject(lambda p:setattr(p,'receivers',p.receivers[1:]))
|
|
def balanced(p):
|
|
p.groups[0].weights[:2,:2]+=1e-5*np.array([[1,-1],[-1,1]])
|
|
reject(balanced) # Preserves row/column sums, corrupts the actual operator.
|
|
|
|
def test_coefficients_and_directional_transfer_budget(self):
|
|
rng=np.random.default_rng(31)
|
|
for name in CASES:
|
|
p=UnionPlan(pair(CASES[name][0])); total=np.zeros(9)
|
|
receiver={tuple(row):[] for row in p.receivers}; first_leftover=None
|
|
for g in p.groups:
|
|
samples,a,e,coeff=ref.interpolation(g.kind,g.points)
|
|
np.testing.assert_allclose(g.weights,exact_weights(g.kind,g.points),rtol=0,atol=2e-14)
|
|
np.testing.assert_allclose(g.weights@np.ones(len(samples)),1,rtol=0,atol=2e-14)
|
|
if g.kind=='face': beta=np.array([1.,.03,.02,0.])
|
|
else:
|
|
beta=np.array([1.,.03,.02,-ref.cpu.CX[g.q]*.03-ref.cpu.CY[g.q]*.02])
|
|
self.assertLess(abs((coeff@(a@np.array([1.,.03,.02,0.])))[3]),2e-14)
|
|
np.testing.assert_allclose(g.weights@(a@beta),e@beta,rtol=0,atol=2e-14)
|
|
values=rng.normal(size=len(samples)); h=g.weights@values
|
|
origin=values[0 if g.kind=='corner' else 1]
|
|
# Fine incoming plus coarse leftovers must exactly split one
|
|
# coarse directional source budget, regardless of sample values.
|
|
incoming=sum(h[k] for k,event in enumerate(g.events) if event is not None)
|
|
leftover=sum(h[k] for k,event in enumerate(g.events) if event is None)
|
|
self.assertLess(abs(.25*(incoming+leftover)-origin),2e-14)
|
|
total[g.q]+=.25*incoming-origin
|
|
for k,event in enumerate(g.events):
|
|
if event is None:
|
|
receiver[(g.q,g.receiver)].append(h[k])
|
|
if first_leftover is None: first_leftover=(g.q,h[k])
|
|
# Tagged collision-free boundary-flux ledger, not a solver trajectory:
|
|
# every outgoing fine debit is credited to its actual receiver.
|
|
for rows in p.outgoing:
|
|
for q,slot,target in rows:
|
|
value=float(rng.normal()); total[q]-=.25*value
|
|
receiver[(q,target)].append(value)
|
|
for (q,target),values in receiver.items():
|
|
self.assertEqual(len(values),4); total[q]+=.25*sum(values)
|
|
np.testing.assert_allclose(total,0,rtol=0,atol=2e-14)
|
|
damaged=total.copy(); q,value=first_leftover; damaged[q]-=.25*value
|
|
self.assertGreater(float(abs(damaged).max()),1e-5)
|
|
self.assertEqual(validate_ownership(p)['topology'],'supported')
|
|
|
|
def test_existing_block_addressing_and_sparse_holes(self):
|
|
geometry=pair(CASES['two_separated'][0]); p=UnionPlan(geometry)
|
|
layouts=(BlockLayout2D.build(16,16,4,4),geometry.fine_layout((4,4)))
|
|
for fine,layout in enumerate(layouts):
|
|
cells=layout.shape[0]*layout.shape[1]
|
|
dense=np.arange(9*cells,dtype=np.float64).reshape(9,*layout.shape)
|
|
blocked=layout.to_blocked(dense).ravel()
|
|
refs=[]
|
|
if not fine:
|
|
refs=[(g.q,int(k)) for g in p.groups for k in [g.source,g.receiver,*g.samples]]
|
|
else:
|
|
refs=[(g.q,slot) for g in p.groups for event in g.events if event is not None for _,slot in [event]]
|
|
refs += [(int(q),int(slot)) for rows in p.outgoing for q,slot,_ in rows]
|
|
q,k=np.array(refs).T
|
|
np.testing.assert_array_equal(blocked[owned_slots(layout,q,k)],dense.reshape(9,-1)[q,k])
|
|
holes=np.flatnonzero(~layouts[1].owned)
|
|
self.assertGreater(len(holes),0)
|
|
with self.assertRaises(UnresolvedTopology): owned_slots(layouts[1],np.array([1]),holes[:1])
|
|
|
|
def test_deferred_layouts_and_phase_are_explicit(self):
|
|
with self.assertRaises(UnresolvedTopology) as caught: UnionPlan(pair([(3,3,8,8)],16,12))
|
|
self.assertEqual(caught.exception.code,'requires_level_wide_square_pair')
|
|
cfg=LBMConfig(nx=16,ny=16,viscosity=.0192,velocity=0.)
|
|
scalar=FixedPatch2D.from_config(cfg,(4,4,8,8))
|
|
with self.assertRaises(UnresolvedTopology): UnionPlan(scalar)
|
|
self.assertEqual(UnionPlan.phase,'arrival_f'); self.assertFalse(UnionPlan.runtime_supported)
|
|
# Restriction and collision do not commute, even at omega=1. This is
|
|
# why postcollision G storage cannot be relabeled/inverted into this F contract.
|
|
w=ref.cpu.W; cx,cy=ref.cpu.CX,ref.cpu.CY
|
|
def equilibrium(u):
|
|
cu=cx*u;return w*(1+3*cu+4.5*cu*cu-1.5*u*u)
|
|
children=np.array([equilibrium(u) for u in (.03,-.03,.03,-.03)])
|
|
self.assertAlmostEqual(float(abs(w-children.mean(axis=0)).max()),.0006,places=14)
|
|
|
|
|
|
if __name__=='__main__':
|
|
result=unittest.TextTestRunner(verbosity=2).run(unittest.defaultTestLoader.loadTestsFromTestCase(UnionContract))
|
|
print(json.dumps(dict(passed=result.wasSuccessful(),tests=result.testsRun,
|
|
scope='host-only topology/provenance/coefficient checks; no runtime or accuracy admission',matrix=MATRIX,
|
|
enumeration=ENUMERATION),indent=2))
|
|
sys.exit(not result.wasSuccessful())
|