Compare commits

...
8 changed files with 1328 additions and 0 deletions
+109
View File
@@ -0,0 +1,109 @@
# Fig. 2 material collection: modern moving plant
Research-only child of `76873d911c4e2caba1bacc25aaa1495ea768453f`.
No production/kernel/manuscript or DynamisLab edits. These are fresh common-release
paired experiments, separate from the continuous OFF/ON/OFF demonstration videos.
## Fixed plant and cases
All cases use D40, Uref=.005, nu=.004, MRT/FP32/unshifted double buffering,
the existing mean-parabolic/regularized inlet, NEQ outlet and bounce-back walls.
One transverse assembly DOF: m*=4 based on all three disks, fnD/U=.2, zeta=0.
No force filter or retuning. Existing +/-D, speed.025, nonfinite and no-donor stops.
Structure RHS is previous native-step sum of pinball CME_y+Wen_y. Policy uses
SI-mean packed CME and fixed lab probe signals; these are different force paths.
SI=3200; EMA alpha=.9**.25 resets every SI as in the acquisition contract.
Steady video used SI4000; its descriptive numbers are not the new collection.
| case | prelude/release clock | controller | collection |
|---|---|---|---|
| steady | new static pinball, 2,048,000 steps | constant wall speeds (0,-5.1,5.1)U, no PPO | 400 SI,160 D/U |
| Karman | verified saved static release at4,576,000 | historical `karman_re100` PPO; actual pinball Re_D50, scale8/bias(0,-4,4) | 400 SI,160 D/U |
| Taylor y0 | clean512,000 then static pinball512,000; clock1,024,000 | `vortex_taylor` PPO, scale4/samebias | 150 SI,60 D/U |
Zero/control share exact current/spare populations, flags, clock and zero initial
structural state. Target is a separate no-pinball reference at the SAME absolute
clocks, not a copy of a solid-containing release. Its different geometry is the
definition of the reference, not identical microscopic initial conditions.
Karman target must run to4,576,000; old target.npy is from earlier clocks and is
not reused for same-clock field errors. Model/norm files are verified by SHA256
in the driver and written with all source hashes into case.json.
## Taylor injection
Exactly one event at release in every role, inside the owning synchronized CUDA
context: centre(600,510.5), R80, strength **.000075**. Reuse the existing initializer
unchanged. It rewrites ALL interior cells to equilibrium and uploads BOTH buffers;
this removes existing nonequilibrium globally. The impulse and first two SI of
native force/displacement remain in the record. This is a declared limitation,
not an independently qualified physical vortex generator.
Strength has inverse-time units: u_theta=(s*pi/2)r exp(-r²/R²). D20 R40/U.01/s.0003
maps to D40 R80/U.005/s.000075 under diffusive scaling. Using .03*U at D40 would
double u/U and quadruple p/U²; actual-function CPU positive/negative controls lock it.
Current Horsea `acquisition.py` SHA
`ea674080501407d00ded6d23467423c516cd37e04d166d5adda16465100204d3`
defines EIGHT offsets(-20,-15,-10,-5,0,5,10,15). `legacy_test/acquire.py` SHA
`91cd29a368dfc53a0804ec37397d2e5f25f0f8721f01b560a0f4328b8fd1dd9c`
selects the controlled centre-uy event: median first20, peak over38..87 inclusive.
All roles use that common event index; own target/role peak delays are separately
reported. The earlier five-offset remote summary is superseded by actual source.
## Estimands and storage
Primary: unaligned same-clock/event velocity-vector errors, Eq1
`sqrt(mean_ROI((dux²+duy²)/Uref²))`. No target-energy denominator. Lab ROI is
front-equilibrium-centre-relative x/D=[4,24], y/D=[-5,5], equivalent to sensor-plane
x/D=[-6,14]; D40 inclusive columns1360..2160 and rows311..710. All ROI cells must
be fluid and finite in every compared field; record support coverage.
Motion uses native last80 D/U for steady/Karman, whole60 D/U for Taylor; mean,
centered RMS and half-range remain distinct. Karman complete-cycle means and
eight independently phased nearest snapshots reuse actual acquisition helpers,
centre-uy column3 and minimum crossing gap. Missing crossings yield unavailable
phase diagnostics, never fabricated phase slots. Steady retains its last snapshot
and separately labeled fixed-window mean. Field collection for steady/Karman is
the last160 SI boundaries (96.4..160 D/U); motion window remains80..160 D/U.
Taylor retains its eight common event snapshots. Fixed-window mean error differs
from mean instantaneous error; both are labeled. Native six-channel DTW is a
supplementary training-space metric, never field recovery or physical delay;
its actual legacy lag uses flat sensor column1, unlike the phase helper's column3.
Use one FP32 full-grid RAM cache per role, plus compact ROI RAM histories for
same-clock comparisons. Admit only with >=16GiB MemAvailable; no3.35GB disk field
cache. Save only selected fields, means, explicit masks, scalars and source receipts.
Nonfluid placeholders are zero-filled for helpers but always masked in plots and
scores; nonfinite FLUID is a hard failure. Serialize compilation/GPU work. Verify
compressed native histories before removing only redundant task scratch copies.
Plot standards: vorticity curl(u)D/U, five-point fluid stencil, common RdBu_r21bins
with fixed[-3,3] limits and nearest interpolation; lab-fixed coordinates and ROI.
Mean plots exclude cells that were not fluid at every collected boundary. Selected
snapshots use actual step geometry. Raw arrays and scalar errors are not clipped.
## V5 boundary
Standard V5 `kar_re100` seeds41..45 DO exist (actual pinball Re_D50), but use
2000x600 uniform/free-slip and different absolute geometry/normalization/action
contracts. These are not silently transferred onto the present Legacy-contract
plant. No verified retained trained V5 steady/Taylor bundle was found; a vortex
environment prototype is not a trained policy. Existing V5 within-case results
may be indexed separately; cross-generation scores cannot imply an identical plant.
## Execution
First run focused CPU contracts, independent source review, then each scene with
`--smoke` (three32-step SI per role; no physical scores). Only the reviewed exact
SHA may run physical cases, one at a time, new outputs outside the checkout:
```
CUDA_VISIBLE_DEVICES=3 PYTHONPATH=src python research/fig2_collection/run.py --scene karman --out /tmp/celeris-results-fig2-collection/karman
python research/fig2_collection/analyze.py /tmp/celeris-results-fig2-collection/karman
python research/fig2_collection/plot.py /tmp/celeris-results-fig2-collection/karman
```
Use the same invocation for steady/taylor with new output directories. No automatic
extension or parameter sweep. All results are developmental material, not asserted
settled/physically qualified. Preserve partial failures and distinguish missing
evidence from passing gates.
+130
View File
@@ -0,0 +1,130 @@
"""Offline Fig2 estimands; preserve raw same-clock errors beside registration.
No thresholds are fitted here. Probe DTW is the actual Legacy helper, not a
field metric; independent wake phase is a diagnostic, not a time correction.
"""
import argparse
import gc
import importlib.util
import json
from pathlib import Path
import sys
import types
import numpy as np
ROOT=Path(__file__).resolve().parents[2]
sys.path.insert(0,str(ROOT))
from research.fig2_collection import run
from research.fig2_materials.metrics import field_error, motion_summary
def frequency(signal,dt):
"""Largest non-DC rectangular-window FFT bin, descriptive resolution only."""
a=np.asarray(signal,float);a=a-a.mean()
if len(a)<3 or np.max(np.abs(a))<1e-12:return None
power=np.abs(np.fft.rfft(a))**2
i=1+int(np.argmax(power[1:]))
return dict(f_D_U=float(np.fft.rfftfreq(len(a),dt)[i]),bin_width_D_U=1/(len(a)*dt),
method='demeaned rectangular window; dominant non-DC bin; not a confidence interval')
def native_summary(rows,scene):
start=max(0,len(rows)-640000) if scene!='taylor' else 0
a=rows[start:]
y=a[:,1]/40.;cy=a[:,3:9].sum(axis=1)/(.5*.005**2*40)
return dict(window_D_U=[start*.005/40,len(rows)*.005/40],
motion=motion_summary(y),motion_frequency=frequency(y,.005/40),
structural_RHS_Cy_RMS=float(np.sqrt(np.mean(cy**2))),
structural_RHS_Cy_mean=float(cy.mean()),
max_native_delta_Cy=float(np.abs(np.diff(cy)).max()) if len(cy)>1 else None,
force_frequency=frequency(cy,.005/40),
no_donor=float(rows[:,13].sum()),eq_only=float(rows[:,12].sum()),
crossings=float(rows[:,14].sum()),
force_convention='body force, sum pinball CME_y+Wen_y; native previous-step RHS, no filtering')
def compare_selected(target,current,u0=.005):
"""Fixed lab ROI; all actual compared roles must be fluid and finite here."""
ys,xs=slice(311,711),slice(1360,2161)
t,c=target['ux'][:,ys,xs],current['ux'][:,ys,xs]
tv,cv=target['uy'][:,ys,xs],current['uy'][:,ys,xs]
if t.shape!=c.shape or tv.shape!=cv.shape:raise ValueError('selected shape mismatch')
for fields in (target,current):
if not np.all((fields['flags'][:,ys,xs]&1)!=0):raise ValueError('nonfluid selected ROI')
if not all(np.isfinite(a).all() for a in (t,c,tv,cv)):raise ValueError('nonfinite selected ROI')
e2=((c.astype(float)-t)/u0)**2+((cv.astype(float)-tv)/u0)**2
return dict(per_slot_E=np.sqrt(e2.mean(axis=(1,2))),aggregate_E=float(np.sqrt(e2.mean())))
def main():
ap=argparse.ArgumentParser(description=__doc__);ap.add_argument('directory',type=Path)
args=ap.parse_args();p=args.directory
case=json.loads((p/'case.json').read_text());term=json.loads((p/'terminal.json').read_text())
if case['plan']['smoke'] or term['status']!='three_roles_completed_not_qualified':
raise ValueError('physical analysis requires all three complete roles; smoke/partial is unscored')
helpers=run.load_helpers()
# Reuse the canonical pure module under the same isolated read-only package.
native=run.load_file('_fig2_readonly_drl.legacy_test.metrics',
run.ACQUISITION.parent/'legacy_test/metrics.py')
reports={k:json.loads((p/k/'report.json').read_text()) for k in ('target','zero','controlled')}
sig={}
result=dict(scene=case['scene'],source=case['source'],Re_D=50.,roles={},
primary='unaligned same-clock/event U-normalized vector errors; common lab ROI',
periodic_phase='independently registered centre-uy phase diagnostic, not a shared-clock comparison',
notes=['Modern moving-plant data; no Legacy numerical equivalence or physical qualification',
'Motion, native structural force, policy SI means and probe DTW are different estimands'])
for role in ('target','zero','controlled'):
with np.load(p/role/'signals.npz') as z:sig[role]={k:z[k] for k in z.files}
if role!='target':
with np.load(p/role/'native.npz') as z:
result['roles'][role]=native_summary(z['rows'],case['scene'])
t=sig['target']['sensors']
for role in ('zero','controlled'):
s=sig[role]['sensors']
if not np.array_equal(sig[role]['absolute_steps'],sig['target']['absolute_steps']):
raise ValueError('probe absolute clocks mismatch')
result['roles'][role]['unaligned_probe_area_velocity_RMS_over_U']=float(np.sqrt(np.mean(((s/156-t/156)/.005)**2)))
result['roles'][role]['probe_channel_correlations']=native.channel_correlations(t,s)
if case['scene']=='taylor':
spec=types.SimpleNamespace(scene='vortex',conv_len=30)
dtw=native.native_similarity(spec,t,s,len(s)-1)
result['roles'][role]['native_probe_DTW']=dict(similarity=dtw,definition='source vortex training-window roll; supplementary, not event field recovery',conv_len=30)
own=helpers.select_vortex_event(s,'vortex_taylor')
target_event=helpers.select_vortex_event(t,'vortex_taylor')
result['roles'][role]['own_event_peak']=int(own['peak_index'])
result['roles'][role]['event_peak_delay_D_U']=(int(own['peak_index'])-int(target_event['peak_index']))*.4
else:
result['roles'][role]['native_probe_DTW']=native.cycle_similarity(t,s,30)
result['roles'][role]['native_probe_DTW'].update(definition='canonical six-channel legacy cycle DTW, training D20 units',
lag_column=1,target_template_indices=[30,60],state_window_indices=[len(s)-30,len(s)],
registration_only=True,lag_identifiable=bool(case['scene']=='karman' and np.std(t[:,1])>1e-10))
data={}
for role in ('target','zero','controlled'):
with np.load(p/role/'fields.npz') as z:data[role]={k:z[k] for k in z.files}
for role in ('zero','controlled'):
name={'karman':'independent_phase8_diagnostic','steady':'late_snapshot','taylor':'common_event8'}[case['scene']]
if case['scene']!='karman' or all(len(data[k]['ux'])==8 for k in (role,'target')):
result['roles'][role][name]=compare_selected(data['target'],data[role])
else:
result['roles'][role][name]=dict(status='unavailable_insufficient_periodic_crossings')
result['roles'][role]['selection_metadata']=reports[role]['selection']
result['roles'][role]['field_window_tU_D']=reports[role]['field_window_tU_D']
if case['scene']=='karman' and all('cycle_mean_ux' in data[k] for k in (role,'target')):
support=np.zeros(data[role]['cycle_mean_ux'].shape,bool);support[311:711,1360:2161]=True
result['roles'][role]['E_complete_cycle_mean']=field_error(
data[role]['cycle_mean_ux'],data[role]['cycle_mean_uy'],
data['target']['cycle_mean_ux'],data['target']['cycle_mean_uy'],.005,support)
if case['scene']=='taylor':
clocks=[data[k]['absolute_steps'] for k in data]
if not all(np.array_equal(clocks[0],v) for v in clocks[1:]):raise ValueError('event snapshot clocks differ')
result['Taylor_injection']=dict(operator='all interior equilibrium rewrite; both buffers, identical event clock',
strength=.000075,center=[600.,510.5],radius=80.,impulse_window='first two SI; retained native history',
source_offsets=list(helpers.VORTEX_EVENT_OFFSETS))
result['same_clock_field_errors']=json.loads((p/'same_clock_errors.json').read_text())
run.write_json(p/'statistics.json',result)
del data;gc.collect()
print(json.dumps(dict(scene=case['scene'],statistics=str(p/'statistics.json'))))
if __name__=='__main__':main()
+93
View File
@@ -0,0 +1,93 @@
"""Shared scientific plots of retained fields; no solver or synthetic fields."""
import argparse
import json
from pathlib import Path
import sys
import numpy as np
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
from matplotlib.colors import BoundaryNorm
from matplotlib.patches import Circle,Rectangle
ROOT=Path(__file__).resolve().parents[2]
sys.path.insert(0,str(ROOT))
from research.continuous_viv.render import vorticity
def decorate(ax,scene,role,y,*,mean=False):
if not mean:
if scene=='karman':ax.add_patch(Circle((-20,0),1,facecolor='white',edgecolor='#253544',lw=.7))
if role!='target':
for x,dy in ((0,0),(1.3,.75),(1.3,-.75)):
ax.add_patch(Circle((x,dy+y),.5,facecolor='white',edgecolor='#253544',lw=.7))
ax.add_patch(Rectangle((4,-5),20,10,fill=False,ls='--',lw=.6,edgecolor='#555'))
ax.set_xlim(-23.6,27.6);ax.set_ylim(-6.4,6.4)
ax.set_xlabel('(x - pinball-front equilibrium centre) / D',fontsize=8)
ax.set_ylabel('(y - channel centre) / D',fontsize=8)
ax.tick_params(labelsize=8)
def main():
ap=argparse.ArgumentParser(description=__doc__);ap.add_argument('directory',type=Path)
args=ap.parse_args();p=args.directory
case=json.loads((p/'case.json').read_text());scene=case['scene']
cmap=plt.get_cmap('RdBu_r',21).copy();cmap.set_bad('#DDE2E5')
norm=BoundaryNorm(np.linspace(-3,3,22),21,clip=True)
prefix='SMOKE — plumbing only | ' if case['plan']['smoke'] else ''
roles=('target','zero','controlled')
data={};motion={}
for role in roles:
with np.load(p/role/'fields.npz') as z:data[role]={k:z[k] for k in z.files}
with np.load(p/role/'native.npz') as z:
motion[role]=z['rows'][:,1].copy()/40.
shape=data['target']['ux'].shape[-2:]
extent=((-0.5-1200)/40,(shape[1]-.5-1200)/40,
(-.5-510.5)/40,(shape[0]-.5-510.5)/40)
fig,axs=plt.subplots(3,1,figsize=(13,10),constrained_layout=True)
for ax,role in zip(axs,roles):
d=data[role];flags=d['common_fluid_all_boundaries'].astype(np.uint16)
w=vorticity(d['fixed_window_mean_ux'],d['fixed_window_mean_uy'],flags)
image=ax.imshow(w,origin='lower',extent=extent,cmap=cmap,norm=norm,interpolation='nearest')
decorate(ax,scene,role,0,mean=True)
label='no pinball reference' if role=='target' else ('zero rotation' if role=='zero' else ('constant rotations' if scene=='steady' else 'frozen PPO'))
ax.set_title(f'{role}: {label} | arithmetic fixed-window mean',fontsize=10)
fig.suptitle(prefix+f'{scene}: modern Re_D=50, m*=4, zero structural damping\n'
'Gray: cells outside the common fluid support; mean fields are not instantaneous geometry',fontsize=12)
fig.colorbar(image,ax=list(axs),shrink=.65,label='vorticity D/U, fixed limits [-3,3]')
fig.savefig(p/'mean-flow.png',dpi=160);plt.close(fig)
slots=max(len(data[r]['ux']) for r in roles)
fig,axs=plt.subplots(3,slots,figsize=(max(8,3*slots),7),squeeze=False,constrained_layout=True)
for row,role in enumerate(roles):
d=data[role]
for col,ax in enumerate(axs[row]):
if col>=len(d['ux']):ax.axis('off');continue
w=vorticity(d['ux'][col],d['uy'][col],d['flags'][col])
image=ax.imshow(w,origin='lower',extent=extent,cmap=cmap,norm=norm,interpolation='nearest')
step=int(d['selected_response_steps'][col]);y=float(motion[role][step-1])
decorate(ax,scene,role,y)
ax.set_xlim(-3,14);ax.set_ylim(-3,3)
ax.set_xlabel('x/D relative to equilibrium front',fontsize=7)
ax.set_title(f'{role} | slot {col+1}\ntU/D={step*.005/40:.2f}',fontsize=8)
family={'karman':'independently phased diagnostic snapshots (different real times)',
'steady':'late snapshots at matching real times',
'taylor':'common event-relative snapshots at matching real times'}[scene]
fig.suptitle(prefix+f'{scene}: {family}\nFixed lab coordinates; same vorticity normalization/limits; source '+case['source'][:8],fontsize=12)
fig.colorbar(image,ax=list(axs.ravel()),shrink=.7,label='vorticity D/U [-3,3]')
fig.savefig(p/'selected-flow.png',dpi=160);plt.close(fig)
fig,ax=plt.subplots(figsize=(11,3.5),constrained_layout=True)
for role,color in (('zero','#5A6570'),('controlled','#147D73')):
y=motion[role];stride=max(1,len(y)//12000)
time=(np.arange(len(y))+1)*.005/40
ax.plot(time[::stride],y[::stride],lw=.8,label=role,color=color)
ax.set(xlabel='response time tU/D',ylabel='transverse y/D',ylim=(-1,1))
ax.legend();ax.grid(alpha=.2)
ax.set_title(prefix+scene+': independent paired releases; full retained duration')
fig.savefig(p/'motion.png',dpi=160);plt.close(fig)
print(json.dumps(dict(scene=scene,plots=['mean-flow.png','selected-flow.png','motion.png'],
vorticity_limits=[-3,3],normalization='curl(u) D/U; five-node fluid stencil',
mean_mask='intersection over collected boundary fields',display_interpolation='nearest')))
if __name__=='__main__':main()
+557
View File
@@ -0,0 +1,557 @@
"""Research-only Fig. 2 acquisition on the modern moving plant.
These paired releases are new experiments, not Legacy numerical reproductions.
Nothing here qualifies a steady state or automatically extends/retunes a run.
GPU dependencies are lazy so the collection contracts can be checked on CPU.
"""
from __future__ import annotations
import argparse
import ast
import gc
import hashlib
import importlib.util
import json
import os
from pathlib import Path
import platform
import subprocess
import sys
import time
import types
import numpy as np
sys.dont_write_bytecode = True
ROOT = Path(__file__).resolve().parents[2]
D, U, NU, SI = 40., .005, .004, 3200
BASELINE = '76873d911c4e2caba1bacc25aaa1495ea768453f'
CENTRES = ((1200., 510.5), (1252., 540.5), (1252., 480.5))
PROBES = ((1600., 590.5), (1600., 510.5), (1600., 430.5))
BIAS = np.array([0., -4., 4.])
STEADY_SURFACE = np.array([0., -5.1, 5.1])*U
MIN_AVAILABLE = 16*1024**3
ACQUISITION = Path('/home/frank14f/DynamisLab/src/drl_pinball/acquisition.py')
K_RELEASE = Path('/tmp/celeris-results-karman-viv-516975e/pair')
ASSETS = {
'karman': (Path('/tmp/celeris-karman-viv-assets/model.zip'),
'148240c1dcb8b11d8e5a0a9e991f6874c4e799c9744707bf0fb32a8efeb0468d',
Path('/tmp/celeris-karman-viv-assets/norm.json'),
'534fda818c030a56960e95d0f3ac1e350d887d03db7f80f1d6453195a502b7f0'),
'taylor': (Path('/home/frank14f/DynamisLab/models/old/vortex_taylor.zip'),
'58882721900f1eb0779f64aea0a2e233d307a94b640b9aaf33efa036db3c79e3',
Path('/home/frank14f/DynamisLab/src/SR_analysis/data/vortex/vortex_taylor/norm.json'),
'bd38b4cf2b6da56b5cf378e646d673b97a606531c68163460313b330c9957b86'),
}
NATIVE_COLUMNS = ('response_step', 'y', 'v', 'cme_F', 'cme_U', 'cme_L',
'wen_F', 'wen_U', 'wen_L', 'node_F', 'node_U', 'node_L',
'eq_only', 'no_donor', 'crossings', 'omega_F', 'omega_U', 'omega_L')
def sha(path):
digest = hashlib.sha256()
with Path(path).open('rb') as stream:
for block in iter(lambda: stream.read(1024*1024), b''):
digest.update(block)
return digest.hexdigest()
def json_value(value):
if isinstance(value, dict):
return {str(k): json_value(v) for k, v in value.items()}
if isinstance(value, (tuple, list)):
return [json_value(v) for v in value]
if isinstance(value, np.ndarray):
return json_value(value.tolist())
if isinstance(value, np.generic):
return value.item()
if isinstance(value, Path):
return str(value.resolve())
return value
def write_json(path, value):
Path(path).write_text(json.dumps(json_value(value), indent=2, allow_nan=False)+'\n')
def load_file(name, path):
spec = importlib.util.spec_from_file_location(name, path)
module = importlib.util.module_from_spec(spec)
sys.modules[name] = module
spec.loader.exec_module(module)
return module
def load_helpers(path=ACQUISITION):
"""Load read-only CPU helpers under an isolated package; no original imports.
Empty namespace packages avoid executing application __init__ modules. The
acquisition module and its pure DTW dependency execute directly from source.
Event functions are AST-selected verbatim to avoid acquire.py's heavy imports;
offsets come from that acquisition module, never a copied/fabricated selector.
"""
path = Path(path)
package = '_fig2_readonly_drl'
for suffix, directory in (('', path.parent), ('.legacy_test', path.parent/'legacy_test'),
('.legacy_test.core', path.parent/'legacy_test/core')):
module = types.ModuleType(package+suffix)
module.__path__ = [str(directory)]
sys.modules[package+suffix] = module
acquisition = load_file(package+'.acquisition', path)
event_path = path.parent/'legacy_test/acquire.py'
source = ast.parse(event_path.read_text(), filename=str(event_path))
names = {'_vortex_event_channel', '_select_vortex_event'}
nodes = [node for node in source.body if isinstance(node, ast.FunctionDef) and node.name in names]
if {node.name for node in nodes} != names:
raise ValueError('authoritative event selector functions missing')
namespace = dict(np=np, VORTEX_EVENT_OFFSETS=acquisition.VORTEX_EVENT_OFFSETS)
exec(compile(ast.Module(body=nodes, type_ignores=[]), str(event_path), 'exec'), namespace)
acquisition.select_vortex_event = namespace['_select_vortex_event']
return acquisition
def schedule(scene, smoke=False):
if scene not in ('karman', 'steady', 'taylor'):
raise ValueError('unknown scene')
intervals = 150 if scene == 'taylor' else 400
release = {'karman': 4576000, 'steady': 2048000, 'taylor': 1024000}[scene]
return dict(si=32 if smoke else SI, intervals=3 if smoke else intervals,
field_start=0 if smoke or scene == 'taylor' else 240,
release_clock=(64 if scene == 'taylor' else 32) if smoke else release,
smoke=bool(smoke))
def available_memory(path=Path('/proc/meminfo')):
for line in Path(path).read_text().splitlines():
if line.startswith('MemAvailable:'):
return int(line.split()[1])*1024
raise RuntimeError('MemAvailable unavailable; RAM admission cannot be verified')
def admit_memory(smoke=False):
if smoke:
return None
available = available_memory()
if available < MIN_AVAILABLE:
raise MemoryError(f'MemAvailable {available} < required {MIN_AVAILABLE}')
return available
def wall_surface(scene, role, action=None):
if role == 'target' or role == 'zero':
return np.zeros(3, np.float64) # exactly zero physical rotation
if scene == 'steady':
return STEADY_SURFACE.copy()
action = np.asarray(action)
if action.shape != (3,) or not np.isfinite(action).all() or (np.abs(action) > 1).any():
raise ValueError('invalid deterministic policy action')
return ((8. if scene == 'karman' else 4.)*action+BIAS)*U
def check_structure(y, v, previous_y):
if not np.isfinite([y, v]).all() or abs(y) > D or abs(v) > .025 or abs(y-previous_y) > .025:
raise RuntimeError('nonfinite structure, travel > D or speed > .025; no retuning')
def check_clock(stepper, release_clock, completed):
if stepper.step_count != release_clock+completed:
raise RuntimeError('observed solver clock differs from recorded response clock')
def clean_field(fields, flags):
"""Canonical modern bit-0 fluid mask; hard-fail before nonfluid zero fill."""
ux, uy = np.asarray(fields['ux']), np.asarray(fields['uy'])
rho = np.asarray(fields['rho'])
if ux.ndim != 2 or uy.shape != ux.shape or rho.shape != ux.shape:
raise ValueError('canonical shared (ny,nx) macroscopic arrays required')
flags = np.asarray(flags)
if flags.size != ux.size or flags.dtype != np.uint16:
raise ValueError('modern uint16 flags matching field required')
flags = flags.reshape(ux.shape)
fluid = (flags & 1) != 0
if any(not np.isfinite(value[fluid]).all() for value in (rho, ux, uy)):
raise RuntimeError('nonfinite FLUID macroscopic field')
result = np.zeros((2, *ux.shape), np.float32)
result[0, fluid], result[1, fluid] = ux[fluid], uy[fluid]
return result, flags.copy(), fluid
def select_fields(scene, plan, helpers, times, sensors, cache, event=None):
"""Fixed-window means remain separate from independently phased diagnostics."""
mean = helpers.accumulate_mean_fields(cache[:, 0], cache[:, 1], np.arange(len(cache)))
result = dict(fixed_window_mean_ux=mean['mean_ux'], fixed_window_mean_uy=mean['mean_uy'])
metadata = dict(fixed_window_count=len(cache), periodic_phase_status='not_requested')
indices = np.array([len(cache)-1], np.int64)
if plan['smoke']:
metadata['selection'] = 'smoke_last_snapshot_not_physical'
elif scene == 'taylor':
if event is None:
event = helpers.select_vortex_event(sensors, 'vortex_taylor')
indices = np.asarray(event['field_indices'], np.int64)
if (indices < 0).any() or (indices >= len(cache)).any():
raise ValueError('authoritative event offsets exceed collected fields')
metadata.update(selection='source_vortex_event', event=event)
elif scene == 'karman':
try:
phase = helpers.assign_periodic_phase(times, sensors, minimum_crossing_interval=.5*30*SI)
complete = helpers.complete_cycle_field_indices(times, phase['crossing_times'])
chosen = helpers.select_phase_fields(times, phase['crossing_times'])
cycle_mean = helpers.accumulate_mean_fields(cache[:, 0], cache[:, 1], complete['field_indices'])
result.update(cycle_mean_ux=cycle_mean['mean_ux'], cycle_mean_uy=cycle_mean['mean_uy'])
indices = chosen['field_indices']
metadata.update(periodic_phase_status='available_diagnostic_only', phase_selection=chosen,
complete_cycles=complete, crossing_times=phase['crossing_times'],
rejected_crossing_times=phase['rejected_crossing_times'])
except ValueError as exc:
metadata.update(periodic_phase_status='insufficient_crossings', phase_reason=str(exc))
metadata['selection'] = 'independent_center_probe_uy_diagnostic'
else:
metadata.update(selection='last_snapshot', periodic_phase_status='not_assumed_steady')
result.update(ux=cache[indices, 0], uy=cache[indices, 1], selected_index=indices,
selected_response_steps=times[indices])
return result, metadata, event
def add_scene(sim, scene, *, moving=False, pinball=True):
from CelerisLab.body.motion import Translation
if scene == 'karman':
sim.add_body('circle', (400., 510.5), 40.)
probes = [sim.add_body('sensor', point, 10.) for point in PROBES]
envelope = Translation((0., 0., -D, D), .025, shared=True) if moving else None
bodies = [sim.add_body('circle', point, 20., translation=envelope) for point in CENTRES] if pinball else []
return probes, bodies
def inject_taylor(sim):
from CelerisLab.lbm.initializers import add_vortex
clock = int(sim.stepper.step_count)
with sim.ctx.activate():
sim.stream.synchronize()
add_vortex(sim.field, (600., 510.5), 80., .000075, 'taylor')
sim.stream.synchronize()
if sim.stepper.step_count != clock:
raise RuntimeError('vortex initializer advanced response clock')
return dict(absolute_clock=clock, response_clock=0, center=[600., 510.5], radius=80.,
strength=.000075, type='taylor', count=1,
operator='unmodified add_vortex: all interior reset to equilibrium; both buffers uploaded',
impulse='full equilibrium reset discards pre-existing non-equilibrium on all interior cells')
def make_release(scene, plan, cfgpath, base, out, started, cap, release_dir):
from CelerisLab import Simulation
if scene == 'karman' and not plan['smoke']:
seed, force = base.saved_release(release_dir)
return seed, force, dict(kind='verified_saved_static_release', path=release_dir,
release_npz_sha256=sha(release_dir/'release.npz'))
with Simulation(str(cfgpath)) as sim:
_, bodies = add_scene(sim, scene, pinball=scene != 'taylor')
sim.initialize()
warm = 32 if plan['smoke'] else (512000 if scene == 'taylor' else 2048000)
if scene == 'taylor':
base.warmup(sim, warm, started, cap)
first = sim.bodies.count
for point in CENTRES:
sim.add_body('circle', point, 20.) # runtime additions return staged ID -1
sim.sync_bodies()
bodies = list(range(first, first+len(CENTRES)))
base.warmup(sim, warm-1, started, cap)
sim.run(1, zero_obs=True, sync_obs=True)
force = sum(float(sim.read_force(body)[1]) for body in bodies)
seed = base.capture_seed(sim)
if seed[3] != plan['release_clock'] or not np.isfinite(force):
raise RuntimeError('unexpected static release clock/force')
np.savez_compressed(out/'release.npz', current=seed[0], spare=seed[1], flags=seed[2],
clock=seed[3], force=force)
return seed, force, dict(kind='fresh_static_full_two_buffer_release',
release_npz_sha256=sha(out/'release.npz'))
def compress_native(path, rows, completed):
"""Bounded disk memmap; preserve raw file until parent elects removal."""
rows.flush()
compressed = path.with_suffix('.npz')
np.savez_compressed(compressed, rows=rows[:completed])
with np.load(compressed) as data:
retained = data['rows']
if retained.shape != (completed, len(NATIVE_COLUMNS)):
raise RuntimeError('compressed native shape mismatch')
for start in range(0, completed, 10000):
end = min(start+10000, completed)
if not np.array_equal(retained[start:end], rows[start:end]):
raise RuntimeError('compressed native verification failed')
return dict(raw_path=path.name, compressed_path=compressed.name, verified=True,
compressed_sha256=sha(compressed), raw_preserved_for_parent_cleanup=True)
def run_role(scene, role, plan, cfgpath, base, helpers, metrics, seed, initial_force,
model, norm, out, started, cap, event):
from CelerisLab import Simulation
from CelerisLab.body.motion import MovingTables
directory = out/role
directory.mkdir()
steps = plan['si']*plan['intervals']
rows = np.lib.format.open_memmap(directory/'native.npy', mode='w+', dtype=np.float64,
shape=(steps, len(NATIVE_COLUMNS)))
signals = {key: np.zeros((plan['intervals'], width), np.float32) for key, width in
[('sensor_area_mean', 6), ('sensors', 6), ('forces', 6), ('actions', 3),
('observations', 12), ('policy_input', 12), ('raw_observation', 12)]}
structure = base.Structure(4., 2.5e-5, 0., D)
columns = {name: i for i, name in enumerate(MovingTables.DIAG)}
completed = completed_si = 0
duration = steps*U/D
report = dict(status='running', role=role, target_is_free_body=False if role == 'target' else None,
columns=NATIVE_COLUMNS,
motion_window=dict(start_tU_D=max(0., duration-80.), end_tU_D=duration,
interpretation='smoke_short_not_physical' if plan['smoke'] else
('entire_60DU_event_not_settled' if scene == 'taylor' else 'declared_last_80DU')),
structure=None if role == 'target' else dict(mass=structure.mass, fn=structure.frequency,
mass_ratio=4., zeta=0., y0=0., v0=0.))
cache, common_fluid, selected_flags, roi_cache = None, None, {}, None
obs, force = np.zeros(12, np.float32), initial_force
try:
report['MemAvailable_admission_bytes'] = admit_memory(plan['smoke'])
with Simulation(str(cfgpath)) as sim:
probes, bodies = add_scene(sim, scene, moving=role != 'target', pinball=role != 'target')
sim.initialize()
if role == 'target':
base.warmup(sim, plan['release_clock'], started, cap)
report['initialization'] = 'own no-pinball initialization at matching absolute clock'
else:
report['release_transfer'] = base.install_seed(sim, seed)
if sim.stepper.step_count != plan['release_clock']:
raise RuntimeError('role release clocks differ')
if scene == 'taylor':
report['injection'] = inject_taylor(sim)
count = plan['intervals']-plan['field_start']
shape = (sim.field.ny, sim.field.nx)
cache = np.empty((count, 2, *shape), np.float32)
common_fluid = np.ones(shape, bool)
roi = metrics.roi_slices(shape, D=D, xs=1600., y0=510.5,
downstream_solid_extent=1272.)
# Only compact ROI histories survive a role; full-grid histories never reach disk.
roi_shape = cache[(slice(None), slice(None), *roi)].shape
roi_cache = np.empty(roi_shape, np.float32)
retain = set(event['field_indices'].tolist()) if event is not None else set()
if scene == 'taylor' and event is None and not plan['smoke']:
# Authoritative selector searches 38..87; any imported offset can be selected.
retain = {i+int(offset) for i in range(38, 88) for offset in helpers.VORTEX_EVENT_OFFSETS}
# K phase unknown until the final signal: retain flags for every boundary in RAM.
flag_cache = np.empty((count, *shape), np.uint16) if scene == 'karman' else None
for interval in range(plan['intervals']):
base.check_time(started, cap)
action = np.zeros(3, np.float32)
signals['policy_input'][interval] = obs
if role == 'controlled' and scene != 'steady':
action = np.asarray(model.predict(obs, deterministic=True)[0], np.float32)
target, effective = wall_surface(scene, role, action), np.zeros(3)
sensor_sum, force_sum = np.zeros(6), np.zeros(6)
for native in range(plan['si']):
if native % 1000 == 0:
base.check_time(started, cap)
y, v = (0., 0.) if role == 'target' else structure.candidate(force)
check_structure(y, v, structure.y)
effective = .9**.25*effective + (1.-.9**.25)*target
omega = effective/20.
for body, w in zip(bodies, omega):
sim.set_body(body, omega=float(w), offset=(0., y), vy=y-structure.y)
sim.run(1, zero_obs=True, sync_obs=True)
if bodies:
diag = sim.moving_diagnostics()
cme, wen = diag[:, columns['cme_y']], diag[:, columns['wen_y']]
node = diag[:, columns['cover_jy']]-diag[:, columns['uncover_jy']]
counters = [diag[:, columns[key]].sum() for key in ('eq_only', 'no_donor', 'crossings')]
packed = np.array([value for body in bodies for value in sim.read_force(body)[:2]])
else:
cme, wen, node, counters, packed = np.zeros(3), np.zeros(3), np.zeros(3), [0., 0., 0.], np.zeros(6)
sensors = np.array([value for probe in probes for value in sim.read_sensor(probe)[:2]])
row = np.array([completed+1, y, v, *cme, *wen, *node, *counters, *omega])
if not np.isfinite(row).all() or counters[1] != 0 or not np.isfinite([packed, sensors]).all():
raise RuntimeError('nonfinite native diagnostics/observations or no_donor')
rows[completed] = row
completed += 1
structure.y, structure.v = y, v
force = float(cme.sum(dtype=np.float64)+wen.sum(dtype=np.float64))
sensor_sum += sensors
force_sum += packed
check_clock(sim.stepper, plan['release_clock'], completed)
sensors, packed = sensor_sum/plan['si'], force_sum/plan['si']
raw = np.r_[sensors*156., packed*2.].astype(np.float32)
if norm is not None:
raw, obs, clipped = base.observation(sensors, packed, 2., norm)
report['clipped_channels'] = report.get('clipped_channels', 0)+clipped
for key, value in [('sensor_area_mean', sensors), ('sensors', sensors*156.),
('forces', packed*2.), ('actions', action), ('observations', obs),
('raw_observation', raw)]:
signals[key][interval] = value
if interval >= plan['field_start']:
index = interval-plan['field_start']
clean, flags, fluid = clean_field(sim.get_macroscopic(), sim.get_flags())
if not fluid[roi].all():
raise RuntimeError('prescribed downstream ROI contains nonfluid cells')
cache[index] = clean
common_fluid &= fluid
roi_cache[index] = clean[(slice(None), *roi)]
if flag_cache is not None:
flag_cache[index] = flags
elif index in retain or index == count-1:
selected_flags[index] = flags
completed_si += 1
if completed_si % 10 == 0 or completed_si == plan['intervals']:
rows.flush()
print(json.dumps(dict(event='progress', scene=scene, role=role, interval=completed_si,
absolute_clock=sim.stepper.step_count, y_D=y/D)), flush=True)
times = np.arange(plan['field_start']+1, plan['intervals']+1, dtype=np.int64)*plan['si']
selected, selection, event = select_fields(scene, plan, helpers, times,
signals['sensors'][plan['field_start']:], cache, event)
indices = selected['selected_index']
selected['flags'] = flag_cache[indices] if flag_cache is not None else np.stack([selected_flags[int(i)] for i in indices])
selected['common_fluid_all_boundaries'] = common_fluid
selected['absolute_steps'] = selected['selected_response_steps']+plan['release_clock']
np.savez_compressed(directory/'fields.npz', **selected)
report.update(selection=selection, field_response_steps=times,
field_absolute_steps=times+plan['release_clock'],
field_window_tU_D=[int(times[0])*U/D, int(times[-1])*U/D],
status='smoke_completed_not_physical' if plan['smoke'] else 'duration_completed_not_qualified')
roi_mask = common_fluid[roi].copy()
report['motion_summary'] = metrics.motion_summary(rows[max(0, completed-640000):completed, 1]/D)
return dict(roi=roi_cache, mask=roi_mask, times=times, event=event)
except BaseException as exc:
report['status'] = f'error: {type(exc).__name__}: {exc}'
raise
finally:
report.update(completed_native_steps=completed, completed_intervals=completed_si)
np.savez_compressed(directory/'signals.npz', response_steps=np.arange(1, completed_si+1)*plan['si'],
absolute_steps=plan['release_clock']+np.arange(1, completed_si+1)*plan['si'],
**{key: value[:completed_si] for key, value in signals.items()})
if completed:
report['native_archive'] = compress_native(directory/'native.npy', rows, completed)
write_json(directory/'report.json', report)
del cache, rows
gc.collect()
def score_roles(roles, metrics):
target = roles['target']
common = np.logical_and.reduce([role['mask'] for role in roles.values()])
if not common.all():
raise ValueError('prescribed downstream ROI must be entirely common fluid')
result = dict(definition='sqrt(mean common ROI (dux^2+duy^2)/U^2)',
alignment='same absolute native clock; no lag/phase alignment',
roi_xy_inclusive=[1360, 2160, 311, 710], support_cells=int(common.sum()),
support_coverage_fraction=1.)
for name in ('zero', 'controlled'):
role = roles[name]
if not np.array_equal(role['times'], target['times']):
raise ValueError('unmatched response clocks')
errors = np.array([metrics.field_error(*a, *b, U, common) for a, b in zip(role['roi'], target['roi'])])
# FP64 reduction without duplicating entire history.
mean = [a.mean(axis=0, dtype=np.float64) for a in (role['roi'], target['roi'])]
result[name] = dict(same_clock_instantaneous_E=errors,
mean_instantaneous_E=float(errors.mean()),
E_of_fixed_window_mean=metrics.field_error(*mean[0], *mean[1], U, common))
return result, common
def source_manifest(base, helpers):
files = [Path(__file__), ROOT/'research/fig2_materials/metrics.py', Path(base.__file__),
ROOT/'research/steady_pinball/run.py', ROOT/'research/steady_pinball/config.json',
Path(helpers.__file__), Path(helpers.__file__).parent/'legacy_test/acquire.py',
Path(helpers.__file__).parent/'legacy_test/core/dtw_metrics.py']
files += sorted((ROOT/'src/CelerisLab').rglob('*.py'))
for suffix in ('*.cu', '*.cuh', '*.h'):
files += sorted((ROOT/'src/CelerisLab').rglob(suffix))
return {str(path.resolve()): sha(path) for path in files}
def main(argv=None):
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument('--scene', choices=('karman', 'steady', 'taylor'), required=True)
ap.add_argument('--out', type=Path, required=True)
ap.add_argument('--smoke', action='store_true', help='32-step warmups; 3 SI of 32; no physical score')
ap.add_argument('--D', type=int, choices=(40,), default=40)
ap.add_argument('--model', type=Path)
ap.add_argument('--norm', type=Path)
ap.add_argument('--release-dir', type=Path, default=K_RELEASE)
ap.add_argument('--acquisition', type=Path, default=ACQUISITION)
ap.add_argument('--wall-seconds', type=float, default=43200.)
args = ap.parse_args(argv)
if not np.isfinite(args.wall_seconds) or args.wall_seconds <= 0:
ap.error('positive finite wall cap required')
if args.scene == 'steady' and (args.model or args.norm):
ap.error('steady constant control has no PPO or normalizer')
out = args.out.resolve()
if out == ROOT or ROOT in out.parents:
ap.error('fresh output must be outside checkout')
sys.path.insert(0, str(ROOT))
from research.karman_viv import run as base
metrics = load_file('_fig2_metrics', ROOT/'research/fig2_materials/metrics.py')
helpers = load_helpers(args.acquisition)
model = norm = None
inputs = {}
if args.scene != 'steady':
model_path, model_sha, norm_path, norm_sha = ASSETS[args.scene]
model_path, norm_path = args.model or model_path, args.norm or norm_path
if sha(model_path) != model_sha or sha(norm_path) != norm_sha:
ap.error('model/normalizer hashes differ from frozen inputs')
norm = json.loads(norm_path.read_text())
inputs = dict(model_path=model_path, model_sha256=model_sha,
norm_path=norm_path, norm_sha256=norm_sha)
model = base.load_policy(model_path)
sources = source_manifest(base, helpers)
source = subprocess.check_output(['git', '-C', str(ROOT), 'rev-parse', 'HEAD'], text=True).strip()
dirty = subprocess.check_output(['git', '-C', str(ROOT), 'status', '--porcelain'], text=True)
plan = schedule(args.scene, args.smoke)
admit_memory(args.smoke)
out.mkdir(parents=True, exist_ok=False)
cfgpath = out/'config.json'
write_json(cfgpath, base.config(40))
receipt = dict(scene=args.scene, plan=plan, D=D, U=U, nu=NU, actual_Re_D=50.,
historical_case='karman_re100' if args.scene == 'karman' else args.scene,
expected_baseline=BASELINE, source=source, git_dirty=bool(dirty), git_status=dirty,
source_sha256=sources, inputs=inputs, centres=CENTRES, probes=PROBES,
environment=dict(python=sys.version, platform=platform.platform(), numpy=np.__version__,
conda_env=os.environ.get('CONDA_DEFAULT_ENV'),
cuda_visible_devices=os.environ.get('CUDA_VISIBLE_DEVICES')),
acquisition_event_offsets=list(helpers.VORTEX_EVENT_OFFSETS),
event_selector='source _select_vortex_event AST verbatim; actual acquisition offsets',
structure=dict(mass_ratio=4., fn_native=2.5e-5, fn_D_U=.2, zeta=0., travel_D=1., speed=.025),
rhs='previous native-step sum CME_y+Wen_y; initial force last static native step',
policy='SI mean packed CME *2; area-mean sensors *156; frozen norm; first input zeros',
control='steady constant (0,-5.1,5.1)*U; K scale8 or Taylor scale4, bias(0,-4,4)*U',
ema=dict(alpha=.9**.25, reset='every SI'),
storage='one full-grid float32 RAM cache at a time; compact ROI histories in RAM; no full history field disk',
limitation='Modern moving-plant experiment; no Legacy numerical equivalence or steady/convergence claim')
write_json(out/'case.json', receipt)
started, status = time.monotonic(), 'running'
try:
seed, force, release = make_release(args.scene, plan, cfgpath, base, out, started,
args.wall_seconds, args.release_dir)
receipt['release'] = release
receipt['release'].update(clock=int(seed[3]), initial_force=force,
current_sha256=base.array_sha(seed[0]),
spare_sha256=base.array_sha(seed[1]), flags_sha256=base.array_sha(seed[2]))
write_json(out/'case.json', receipt)
roles, event = {}, None
order = ('controlled', 'zero', 'target') if args.scene == 'taylor' else ('target', 'zero', 'controlled')
for role in order:
roles[role] = run_role(args.scene, role, plan, cfgpath, base, helpers, metrics,
seed, force, model, norm, out, started, args.wall_seconds, event)
if args.scene == 'taylor':
event = roles[role]['event']
if not args.smoke:
scores, support = score_roles(roles, metrics)
write_json(out/'same_clock_errors.json', scores)
np.savez_compressed(out/'common_roi_support.npz', support=support)
if source_manifest(base, helpers) != sources:
raise RuntimeError('source files changed during collection')
status = 'smoke_completed_not_physical' if args.smoke else 'three_roles_completed_not_qualified'
except BaseException as exc:
status = f'error: {type(exc).__name__}: {exc}'
raise
finally:
write_json(out/'terminal.json', dict(status=status, elapsed_s=time.monotonic()-started,
physical_score_claim=False if args.smoke else None))
if __name__ == '__main__':
main()
+94
View File
@@ -0,0 +1,94 @@
"""Pure NumPy Fig2 primitives; no solver, phase selection, or probe DTW.
Adapted formula and inclusive ROI bounds from DynamisLab
src/drl_pinball/eval/wake_l2.py::mean_field_metric, roi_slices, inspected
2026-11-02 at HEAD 609116ec26f19766f5e9ce2b0df427d4e24faae8
(working-tree source, not asserted clean). The explicit common-fluid/finite
mask and motion summaries are additions. legacy_test/metrics.py::cycle_similarity
was inspected only: its six probe channels are not a field recovery metric.
"""
import math
import numpy as np
def roi_slices(shape, *, D, xs, y0=None, downstream_solid_extent):
"""Lab-fixed ROI: sensor-relative x/D=-6..14, y/D=-5..5, inclusive.
Equivalent to pinball-front-relative x/D=4..24 when xs=front+10D.
Supply actual geometry, e.g. D=40, xs=1600, y0=510.5.
"""
ny, nx = shape
y0 = (ny - 1) / 2 if y0 is None else float(y0)
if D <= 0 or not all(np.isfinite(v) for v in (D, xs, y0, downstream_solid_extent)):
raise ValueError("invalid geometry")
x0, x1 = math.ceil(xs - 6 * D), math.floor(xs + 14 * D)
lo, hi = math.ceil(y0 - 5 * D), math.floor(y0 + 5 * D)
if x0 < 0 or lo < 0 or x1 >= nx or hi >= ny:
raise ValueError("ROI outside grid")
if x0 <= downstream_solid_extent:
raise ValueError("ROI not downstream of solid extent")
return slice(lo, hi + 1), slice(x0, x1 + 1)
def common_fluid_roi_mask(fluid_masks, fields, roi):
"""Intersect explicit boolean fluid masks, ROI, and finite 2-D fields.
Pass every compared role/component/sample to freeze a common support over
time. Zero velocity never implies solid. Masks must share the lab grid.
"""
fields = [np.asarray(a, dtype=float) for a in fields]
masks = [np.asarray(a) for a in fluid_masks]
if not fields or not masks:
raise ValueError("fields and explicit fluid masks required")
shape = fields[0].shape
if len(shape) != 2 or any(a.shape != shape for a in fields + masks):
raise ValueError("equal-shape 2-D arrays required")
if any(a.dtype != np.dtype(bool) for a in masks):
raise ValueError("fluid masks must be boolean")
support = np.zeros(shape, dtype=bool)
ys, xs = roi
for s, n in zip((ys, xs), shape):
if not isinstance(s, slice) or s.step not in (None, 1) or not 0 <= (s.start or 0) < (n if s.stop is None else s.stop) <= n:
raise ValueError("invalid ROI slices")
support[ys, xs] = True
for a in masks:
support &= a
for a in fields:
support &= np.isfinite(a)
if not support.any():
raise ValueError("empty common finite fluid ROI")
return support
def field_error(rux, ruy, tux, tuy, u0, support):
"""Eq1: sqrt(mean_support((dux**2 + duy**2)/U0**2)).
For E(mean field), pass arithmetic mean fields from matched windows.
Mean instantaneous E requires separate calls per snapshot and then a mean;
these operations are not interchangeable. No target-energy denominator.
"""
a = [np.asarray(v, dtype=float) for v in (rux, ruy, tux, tuy)]
support = np.asarray(support)
if a[0].ndim != 2 or any(v.shape != a[0].shape for v in a) or support.shape != a[0].shape or support.dtype != np.dtype(bool):
raise ValueError("equal-shape 2-D fields and boolean support required")
if not np.isfinite(u0) or u0 <= 0 or not support.any():
raise ValueError("positive finite U0 and nonempty support required")
if any(not np.isfinite(v[support]).all() for v in a):
raise ValueError("nonfinite field on fixed support")
du = (a[0][support] - a[2][support]) / u0
dv = (a[1][support] - a[3][support]) / u0
return float(np.sqrt(np.mean(du * du + dv * dv)))
def motion_summary(displacement):
"""Arithmetic mean, centered RMS, half peak-to-peak along time axis 0.
Input is displacement in caller-declared units; no implicit D scaling.
Use the same retained window as field evaluation. No sinusoid assumption.
"""
a = np.asarray(displacement, dtype=float)
if a.ndim == 0 or a.shape[0] == 0 or not np.isfinite(a).all():
raise ValueError("nonempty finite time samples required")
mean = a.mean(axis=0)
return {"mean": mean, "centered_rms": np.sqrt(np.mean((a - mean) ** 2, axis=0)),
"half_range": (a.max(axis=0) - a.min(axis=0)) / 2}
+36
View File
@@ -0,0 +1,36 @@
"""Independent analytic data checks for offline estimands, no CFD context."""
import unittest
import numpy as np
from research.fig2_collection.analyze import frequency, native_summary, compare_selected
class AnalysisTests(unittest.TestCase):
def test_frequency_units_and_no_dc(self):
dt=.005/40;n=640000
t=np.arange(n)*dt
result=frequency(4+np.sin(2*np.pi*.2*t),dt)
self.assertAlmostEqual(result['f_D_U'],.2)
self.assertAlmostEqual(result['bin_width_D_U'],1/80)
self.assertIsNone(frequency(np.ones(20),dt))
def test_motion_and_native_rhs_are_distinct_from_node_impulse(self):
a=np.zeros((10,18));a[:,0]=np.arange(1,11);a[:,1]=40*np.tile([.5,-.5],5)
a[:,3]=.001;a[:,6]=.002;a[:,9]=999
result=native_summary(a,'taylor')
self.assertEqual(result['motion']['centered_rms'],.5)
self.assertEqual(result['motion']['half_range'],.5)
self.assertAlmostEqual(result['structural_RHS_Cy_RMS'],6.)
self.assertEqual(result['window_D_U'],[0.,10*.005/40])
def test_selected_vector_error_uses_common_roi_and_actual_flags(self):
shape=(1,712,2162)
target=dict(ux=np.zeros(shape,np.float32),uy=np.zeros(shape,np.float32),flags=np.ones(shape,np.uint16))
current={k:v.copy() for k,v in target.items()}
current['ux'][:,311:711,1360:2161]=.003
current['uy'][:,311:711,1360:2161]=.004
self.assertAlmostEqual(compare_selected(target,current)['aggregate_E'],1.,places=6)
current['flags'][0,400,1500]=2
with self.assertRaises(ValueError):compare_selected(target,current)
if __name__=='__main__':unittest.main()
+260
View File
@@ -0,0 +1,260 @@
"""CPU contracts for Fig. 2 acquisition; no CUDA or simulation import required."""
import importlib.util
import json
from pathlib import Path
import sys
import tempfile
import types
import unittest
from unittest.mock import patch
import numpy as np
ROOT = Path(__file__).resolve().parents[2]
SPEC = importlib.util.spec_from_file_location('fig2_collection_tested', ROOT/'research/fig2_collection/run.py')
driver = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(driver)
METRIC_SPEC = importlib.util.spec_from_file_location('fig2_collection_metrics', ROOT/'research/fig2_materials/metrics.py')
metrics = importlib.util.module_from_spec(METRIC_SPEC)
METRIC_SPEC.loader.exec_module(metrics)
class CollectionContracts(unittest.TestCase):
def test_frozen_clocks_and_windows(self):
for scene, release, count, first in [('karman', 4576000, 400, 240),
('steady', 2048000, 400, 240),
('taylor', 1024000, 150, 0)]:
plan = driver.schedule(scene)
self.assertEqual((plan['release_clock'], plan['intervals'], plan['field_start']),
(release, count, first))
self.assertEqual(plan['si'], 3200)
self.assertEqual((400-240)*3200*driver.U/driver.D, 64.)
self.assertEqual(400*3200*driver.U/driver.D, 160.)
self.assertEqual(150*3200*driver.U/driver.D, 60.)
def test_smoke_has_no_physical_timing(self):
for scene in ('karman', 'steady', 'taylor'):
plan = driver.schedule(scene, True)
self.assertEqual((plan['si'], plan['intervals'], plan['field_start']), (32, 3, 0))
self.assertEqual(plan['release_clock'], 64 if scene == 'taylor' else 32)
def test_zero_is_exact_physical_zero_despite_bias(self):
for scene in ('karman', 'steady', 'taylor'):
np.testing.assert_array_equal(driver.wall_surface(scene, 'zero'), np.zeros(3))
np.testing.assert_array_equal(driver.wall_surface(scene, 'target'), np.zeros(3))
np.testing.assert_allclose(driver.wall_surface('karman', 'controlled', [.5, -.5, 1]),
[.02, -.04, .06])
np.testing.assert_allclose(driver.wall_surface('taylor', 'controlled', [.5, -.5, 1]),
[.01, -.03, .04])
np.testing.assert_allclose(driver.wall_surface('steady', 'controlled'), [0, -.0255, .0255])
with self.assertRaises(ValueError):
driver.wall_surface('karman', 'controlled', [0, 0, np.nan])
def test_structure_hard_stops(self):
driver.check_structure(40., 0., 40.)
for y, v, old in [(40.01, 0, 40), (0, .026, 0), (.026, 0, 0), (np.nan, 0, 0)]:
with self.subTest(y=y, v=v), self.assertRaises(RuntimeError):
driver.check_structure(y, v, old)
def test_fluid_nonfinite_fails_nonfluid_only_zero_filled(self):
flags = np.array([[1, 257, 2]], np.uint16)
fields = dict(rho=np.array([[1., 1., np.nan]]), ux=np.array([[0., .1, np.nan]]),
uy=np.array([[0., -.1, np.inf]]))
clean, retained, fluid = driver.clean_field(fields, flags)
np.testing.assert_array_equal(fluid, [[True, True, False]])
np.testing.assert_array_equal(retained, flags)
self.assertTrue(np.isfinite(clean).all())
self.assertEqual(clean[0, 0, 0], 0.) # zero flow remains fluid
fields['ux'][0, 1] = np.nan
with self.assertRaisesRegex(RuntimeError, 'FLUID'):
driver.clean_field(fields, flags)
def test_memory_admission_uses_available_not_free(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory)/'meminfo'
path.write_text('MemFree: 1 kB\nMemAvailable: 16777216 kB\n')
self.assertEqual(driver.available_memory(path), driver.MIN_AVAILABLE)
with patch.object(driver, 'available_memory', return_value=driver.MIN_AVAILABLE-1):
with self.assertRaises(MemoryError):
driver.admit_memory()
self.assertIsNone(driver.admit_memory(True))
def test_roi_bounds_match_full_downstream_support(self):
roi = metrics.roi_slices((1022, 2559), D=40, xs=1600, y0=510.5,
downstream_solid_extent=1272.)
self.assertEqual(roi, (slice(311, 711), slice(1360, 2161)))
def test_same_clock_error_and_mean_are_distinct(self):
shape = (2, 2, 2, 3)
target = np.zeros(shape, np.float32)
changed = target.copy()
changed[0, 0], changed[1, 0] = driver.U, -driver.U
role = lambda fields: dict(roi=fields, mask=np.ones((2, 3), bool), times=np.array([3200, 6400]))
roles = dict(target=role(target), zero=role(changed), controlled=role(target))
result, mask = driver.score_roles(roles, metrics)
self.assertAlmostEqual(result['zero']['mean_instantaneous_E'], 1., places=7)
self.assertEqual(result['zero']['E_of_fixed_window_mean'], 0.)
roles['zero']['times'] = np.array([6400, 9600])
with self.assertRaisesRegex(ValueError, 'clocks'):
driver.score_roles(roles, metrics)
roles['zero']['times'] = roles['target']['times']
roles['zero']['mask'][0, 0] = False
with self.assertRaisesRegex(ValueError, 'entirely common fluid'):
driver.score_roles(roles, metrics)
def test_native_archive_verified_raw_retained(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory)/'native.npy'
rows = np.lib.format.open_memmap(path, mode='w+', dtype=np.float64,
shape=(9, len(driver.NATIVE_COLUMNS)))
rows[:] = np.arange(rows.size).reshape(rows.shape)
report = driver.compress_native(path, rows, 7)
self.assertTrue(report['verified'])
self.assertTrue(path.exists())
with np.load(path.with_suffix('.npz')) as data:
np.testing.assert_array_equal(data['rows'], rows[:7])
def test_readonly_import_reuses_actual_offsets_and_functions(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
(root/'legacy_test/core').mkdir(parents=True)
# Package __init__ must never be executed by the isolated source loader.
for folder in (root, root/'legacy_test', root/'legacy_test/core'):
(folder/'__init__.py').write_text("raise RuntimeError('application import forbidden')\n")
(root/'legacy_test/core/dtw_metrics.py').write_text('calc_dtw_sim = None\ncalc_lag = None\n')
(root/'acquisition.py').write_text(
'from .legacy_test.core.dtw_metrics import calc_dtw_sim, calc_lag\n'
'VORTEX_EVENT_OFFSETS = (-20,-15,-10,-5,0,5,10,15)\n')
(root/'legacy_test/acquire.py').write_text(
"raise RuntimeError('heavy imports forbidden')\n"
'def _vortex_event_channel(case_name):\n return 3, "center_sensor_uy"\n'
'def _select_vortex_event(sensors, case_name):\n'
' return dict(offsets=VORTEX_EVENT_OFFSETS, channel=_vortex_event_channel(case_name))\n')
helper = driver.load_helpers(root/'acquisition.py')
result = helper.select_vortex_event(None, 'vortex_taylor')
self.assertEqual(result['offsets'], (-20,-15,-10,-5,0,5,10,15))
self.assertEqual(result['channel'], (3, 'center_sensor_uy'))
self.assertFalse(list(root.rglob('*.pyc')))
def test_geometry_and_shared_translation_envelope(self):
calls, translations = [], []
def translation(*args, **kwargs):
item = types.SimpleNamespace(args=args, kwargs=kwargs)
translations.append(item)
return item
def add_body(*args, **kwargs):
calls.append((args, kwargs))
return len(calls)-1
motion = types.ModuleType('CelerisLab.body.motion')
motion.Translation = translation
with patch.dict(sys.modules, {'CelerisLab.body.motion': motion}):
probes, bodies = driver.add_scene(types.SimpleNamespace(add_body=add_body), 'karman', moving=True)
self.assertEqual(calls[0][0], ('circle', (400.,510.5),40.))
self.assertEqual(probes, [1,2,3])
self.assertEqual(bodies, [4,5,6])
self.assertEqual(translations[0].args, ((0.,0.,-40.,40.), .025))
self.assertTrue(translations[0].kwargs['shared'])
self.assertTrue(all(calls[i][1]['translation'] is translations[0] for i in bodies))
calls.clear()
with patch.dict(sys.modules, {'CelerisLab.body.motion': motion}):
driver.add_scene(types.SimpleNamespace(add_body=add_body), 'steady', pinball=False)
self.assertEqual(len(calls), 3)
self.assertTrue(all(call[0][0] == 'sensor' for call in calls))
def test_taylor_operator_called_once_inside_context_at_response_zero(self):
events = []
class Context:
def __enter__(self):
events.append('enter')
def __exit__(self, *args):
events.append('exit')
initializer = types.ModuleType('CelerisLab.lbm.initializers')
initializer.add_vortex = lambda *args: events.append(args)
field = object()
sim = types.SimpleNamespace(field=field, stepper=types.SimpleNamespace(step_count=1024000),
ctx=types.SimpleNamespace(activate=Context),
stream=types.SimpleNamespace(synchronize=lambda: events.append('sync')))
with patch.dict(sys.modules, {'CelerisLab.lbm.initializers': initializer}):
receipt = driver.inject_taylor(sim)
self.assertEqual(events, ['enter', 'sync', (field, (600.,510.5),80.,.000075,'taylor'), 'sync', 'exit'])
self.assertEqual((receipt['response_clock'], receipt['absolute_clock'], receipt['count']),
(0,1024000,1))
class SelectionContracts(unittest.TestCase):
def test_taylor_release_uses_committed_ids_not_staged_minus_one(self):
reads=[]
class Sim:
def __init__(self,*args):
self.bodies=types.SimpleNamespace(count=0)
self.stepper=types.SimpleNamespace(step_count=0)
self.initialized=False;self.pending=0
def __enter__(self):return self
def __exit__(self,*args):pass
def add_body(self,*args,**kwargs):
if self.initialized:self.pending+=1;return -1
index=self.bodies.count;self.bodies.count+=1;return index
def initialize(self):self.initialized=True
def sync_bodies(self):self.bodies.count+=self.pending;self.pending=0
def run(self,n,**kwargs):self.stepper.step_count+=n
def read_force(self,body):
if body<0:raise IndexError('staged ID')
reads.append(body);return [0.,float(body),0.]
pkg=types.ModuleType('CelerisLab');pkg.Simulation=Sim
motion=types.ModuleType('CelerisLab.body.motion');motion.Translation=None
base=types.SimpleNamespace(warmup=lambda sim,n,*args:sim.run(n),
capture_seed=lambda sim:(np.zeros(18,np.float32),np.ones(18,np.float32),
np.ones(2,np.uint16),sim.stepper.step_count))
with tempfile.TemporaryDirectory() as directory,patch.dict(sys.modules,{'CelerisLab':pkg,'CelerisLab.body.motion':motion}):
seed,force,_=driver.make_release('taylor',driver.schedule('taylor',True),'unused',base,
Path(directory),0,60,None)
self.assertEqual(reads,[3,4,5])
self.assertEqual(force,12.)
self.assertEqual(seed[3],64)
def test_recorded_clock_must_equal_observed_solver(self):
driver.check_clock(types.SimpleNamespace(step_count=4579200),4576000,3200)
with self.assertRaisesRegex(RuntimeError,'observed solver clock'):
driver.check_clock(types.SimpleNamespace(step_count=4579201),4576000,3200)
@staticmethod
def mean(ux, uy, indices):
return dict(mean_ux=ux[indices].mean(axis=0), mean_uy=uy[indices].mean(axis=0))
def test_karman_missing_cycles_preserves_fixed_mean_and_last(self):
helper = types.SimpleNamespace(accumulate_mean_fields=self.mean)
def no_crossing(*args, **kwargs):
raise ValueError('at least two accepted rising crossings are required')
helper.assign_periodic_phase = no_crossing
cache = np.arange(24, dtype=np.float32).reshape(3, 2, 2, 2)
arrays, metadata, event = driver.select_fields('karman', dict(smoke=False), helper,
np.array([3200, 6400, 9600]), np.zeros((3, 6)), cache)
self.assertEqual(metadata['periodic_phase_status'], 'insufficient_crossings')
self.assertNotIn('cycle_mean_ux', arrays)
np.testing.assert_array_equal(arrays['ux'][0], cache[-1, 0])
np.testing.assert_allclose(arrays['fixed_window_mean_ux'], cache[:, 0].mean(axis=0))
def test_taylor_common_controlled_event_not_reselected(self):
def forbidden(*args):
raise AssertionError('other roles must not select their own event')
helper = types.SimpleNamespace(accumulate_mean_fields=self.mean, select_vortex_event=forbidden)
cache = np.zeros((150, 2, 2, 2), np.float32)
event = dict(field_indices=np.array([20,25,30,35,40,45,50,55]))
arrays, meta, returned = driver.select_fields('taylor', dict(smoke=False), helper,
np.arange(1,151)*3200, np.zeros((150,6)), cache, event)
self.assertIs(returned, event)
np.testing.assert_array_equal(arrays['selected_index'], event['field_indices'])
self.assertEqual(len(arrays['ux']), 8)
def test_smoke_selection_has_no_event_or_phase_claim(self):
helper = types.SimpleNamespace(accumulate_mean_fields=self.mean)
for scene in ('karman', 'steady', 'taylor'):
arrays, metadata, event = driver.select_fields(scene, dict(smoke=True), helper,
np.array([32,64,96]), np.zeros((3,6)),
np.zeros((3,2,2,2), np.float32))
self.assertEqual(metadata['selection'], 'smoke_last_snapshot_not_physical')
self.assertIsNone(event)
if __name__ == '__main__':
unittest.main()
+49
View File
@@ -0,0 +1,49 @@
"""CPU-only analytic checks, runnable with stdlib unittest (or pytest)."""
import unittest
import numpy as np
from research.fig2_materials.metrics import (
common_fluid_roi_mask, field_error, motion_summary, roi_slices,
)
class MetricsTests(unittest.TestCase):
def test_u0_normalization_not_target_energy(self):
zero = np.zeros((2, 2)); support = np.ones((2, 2), bool)
self.assertEqual(field_error(zero + 3, zero + 4, zero, zero, 2, support), 2.5)
self.assertEqual(field_error(zero + 13, zero + 4, zero + 10, zero, 2, support), 2.5)
for u0 in (0, -1, np.nan):
with self.assertRaises(ValueError):
field_error(zero, zero, zero, zero, u0, support)
def test_common_mask_roi_finite_and_fluid(self):
a = np.zeros((3, 4)); b = a.copy(); b[1, 2] = np.nan
m = np.ones(a.shape, bool); n = m.copy(); n[1, 1] = False
support = common_fluid_roi_mask([m, n], [a, b], (slice(1, 3), slice(1, 4)))
self.assertEqual(int(support.sum()), 4)
self.assertFalse(support[1, 1]); self.assertFalse(support[1, 2])
self.assertTrue(support[2, 1]) # zero velocity is fluid
self.assertEqual(field_error(a, a, b, a, 1, support), 0)
with self.assertRaises(ValueError):
common_fluid_roi_mask([m & False], [a], (slice(None), slice(None)))
with self.assertRaises(ValueError):
field_error(a, a, b, a, 1, m) # fixed support must be finite
def test_mean_field_differs_from_mean_instantaneous_error(self):
snapshots = np.array([[[2.]], [[-2.]]]); z = np.zeros((1, 1)); mask = np.ones((1, 1), bool)
self.assertEqual(field_error(snapshots.mean(0), z, z, z, 1, mask), 0)
self.assertEqual(np.mean([field_error(s, z, z, z, 1, mask) for s in snapshots]), 2)
def test_actual_geometry_and_motion_bias(self):
roi = roi_slices((1022, 2400), D=40, xs=1600, y0=510.5, downstream_solid_extent=1350)
self.assertEqual(roi, (slice(311, 711), slice(1360, 2161)))
m = motion_summary([8, 10, 12])
self.assertEqual(m['mean'], 10)
self.assertAlmostEqual(m['centered_rms'], np.sqrt(8 / 3))
self.assertEqual(m['half_range'], 2)
for a in ([], [np.nan]):
with self.assertRaises(ValueError):
motion_summary(a)
if __name__ == '__main__':
unittest.main()