diff --git a/docs/karman_viv.md b/docs/karman_viv.md index add7e77..1b16e3e 100644 --- a/docs/karman_viv.md +++ b/docs/karman_viv.md @@ -81,3 +81,40 @@ CUDA_VISIBLE_DEVICES=0 PYTHONPATH=src python research/karman_viv/run.py \ First `--smoke`: same D40 geometry, warmup32/target2x8/warmup32, 4x8 response steps each arm; strictly plumbing only. CPU source checks and independent exact-tip review precede GPU. Preserve original DynamisLab files. + +## One predeclared m*=4 exploration (2026-10-31) + +The m*=2 attempt at `516975ef6482f983ace347cb10de7450d2fbd680` stopped +at the declared travel guard in both arms: zero 10.469375 D/U, PPO 27.5525 D/U. +Its principal 80–160 D/U paired window is unavailable. Early attenuation is +not sustained suppression; control-only DTW is not a paired cloaking result. +Those results remain unchanged and retained. + +The sole next candidate doubles M and K together: m*=4, M=15079.644737231, +K=0.000372075320164, fn=0.000025, C=zeta=0. This increases inertia and stiffness, +not damping. Half response under an identical prescribed external force is +the selection rationale only; coupled flow feedback can invalidate it. This +is an exploratory development case, not independent validation or a sweep. + +Reuse the exact saved static release, config, target, model and normalizer. +`--release-dir` checks precursor metadata, config equality, both DDF/flags +hashes and clock against the original transfer receipt. Each fresh arm still +checks its upload against that seed. No warmup, moving restart or new release +file is generated. All fluid/geometry/EMA/SI/RHS settings, 400SI per arm, +zero damping, ±D guard and other stop conditions above remain unchanged. +The original failure stops the remaining run rule also remains unchanged. + +``` +CUDA_VISIBLE_DEVICES=0 PYTHONPATH=src python research/karman_viv/run.py \ + --model /tmp/celeris-karman-viv-assets/model.zip \ + --norm /tmp/celeris-karman-viv-assets/norm.json --out /tmp/NEW_M4_OUTPUT \ + --mass-ratio 4 --release-dir /tmp/celeris-results-karman-viv-516975e/pair +``` + +Report matched valid windows only, separately retaining mean offset, +centered RMS, half-range, interval/native forces, crossing/diagnostic counts +and paired DTW only where both arms have sufficient data. The larger +controlled force excursions remain unresolved: retained m*=2 data strongly +associate them with the post-action-reset ramp, not exclusively current-step +node switching. No force smoothing, action-contract change, enlarged travel +band or further parameter adjustment follows an unsuccessful outcome. diff --git a/research/karman_viv/run.py b/research/karman_viv/run.py index d899369..e27f385 100644 --- a/research/karman_viv/run.py +++ b/research/karman_viv/run.py @@ -22,6 +22,7 @@ from research.steady_pinball.run import Structure, config, scaled_points, CENTRE BIAS = np.array([0., -4., 4.]) MODEL_SHA = '148240c1dcb8b11d8e5a0a9e991f6874c4e799c9744707bf0fb32a8efeb0468d' NORM_SHA = '534fda818c030a56960e95d0f3ac1e350d887d03db7f80f1d6453195a502b7f0' +RELEASE_SOURCE = '516975ef6482f983ace347cb10de7450d2fbd680' STEP_COLUMNS = (*COLUMNS, 'omega_F', 'omega_U', 'omega_L') @@ -161,12 +162,12 @@ def precursor(cfgpath, scale, warm, si, targets, out, start, cap): return seed, force -def run_arm(name, cfgpath, scale, si, intervals, seed, initial_force, model, norm, out, start, cap): +def run_arm(name, cfgpath, scale, si, intervals, seed, initial_force, model, norm, out, start, cap, *, mass_ratio=2.): from CelerisLab import Simulation from CelerisLab.body.motion import MovingTables cols = {k:i for i, k in enumerate(MovingTables.DIAG)} diameter = 20.*scale - structure = Structure(2., 1.e-4/scale**2, 0., diameter) + structure = Structure(mass_ratio, 1.e-4/scale**2, 0., diameter) arm = out/name arm.mkdir() rows = np.lib.format.open_memmap(arm/'steps.npy', mode='w+', dtype=np.float64, @@ -244,16 +245,45 @@ def run_arm(name, cfgpath, scale, si, intervals, seed, initial_force, model, nor (arm/'report.json').write_text(json.dumps(report,indent=2)+'\n') +def saved_release(directory): + """Read the retained m*=2 static release; not a moving-state restart.""" + case = json.loads((directory/'case.json').read_text()) + expected = dict(source=RELEASE_SOURCE, model_sha256=MODEL_SHA, norm_sha256=NORM_SHA, + smoke=False, D=40, U=.005, nu=.004, warmup_each=2048000, + target_intervals=150, SI=3200) + if any(case.get(k) != v for k, v in expected.items()): + raise ValueError('saved release case does not match the frozen precursor') + if json.loads((directory/'config.json').read_text()) != config(40): + raise ValueError('saved release config differs') + with np.load(directory/'release.npz') as data: + seed = (data['current'], data['spare'], data['flags'], int(data['clock'])) + force = float(data['force']) + transfer = json.loads((directory/'zero/report.json').read_text())['release_transfer'] + if seed[3] != transfer['clock'] or seed[3] != 4576000 or not np.isfinite(force): + raise ValueError('saved release clock/force differs') + if [array_sha(a) for a in seed[:3]] != [transfer[k] for k in ('current','spare','flags')]: + raise ValueError('saved release buffer/flags hash differs') + target = np.load(directory/'target.npy') + if target.shape != (150,6) or not np.isfinite(target).all(): + raise ValueError('saved target is incomplete or nonfinite') + return seed, force + + def main(): ap = argparse.ArgumentParser(description=__doc__) ap.add_argument('--model', type=Path, required=True) ap.add_argument('--norm', type=Path, required=True) ap.add_argument('--out', type=Path, required=True) ap.add_argument('--smoke', action='store_true', help='short plumbing only; no physical conclusion') + ap.add_argument('--mass-ratio', type=float, choices=(2.,4.), default=2., + help='4 is the single declared exploratory candidate; fn and damping unchanged') + ap.add_argument('--release-dir', type=Path, help='retained m*=2 precursor; skip warmup') ap.add_argument('--wall-seconds', type=float, default=14400.) args = ap.parse_args() if not np.isfinite(args.wall_seconds) or args.wall_seconds<=0: ap.error('positive finite wall cap required') + if args.release_dir is not None and args.smoke: + ap.error('saved physical release cannot be used for the plumbing smoke') if sha(args.model)!=MODEL_SHA or sha(args.norm)!=NORM_SHA: ap.error('model/normalizer must match frozen provenance') out = args.out.resolve() @@ -272,14 +302,24 @@ def main(): response_steps=si*intervals,response_tU_D=si*intervals*.005/40, rhs='previous native step sum CME_y+Wen_y; policy uses interval packed CME', probe_frame='lab-fixed; fine area mean * 2 * trained 78 cells', - ema_native_alpha=.9**.25, structural_damping=0., columns=STEP_COLUMNS) + ema_native_alpha=.9**.25, structural_damping=0., mass_ratio=args.mass_ratio, + exploratory=args.mass_ratio==4., columns=STEP_COLUMNS) + loaded = None + if args.release_dir is not None: + loaded = saved_release(args.release_dir) + cfgpath.write_bytes((args.release_dir/'config.json').read_bytes()) + (out/'target.npy').write_bytes((args.release_dir/'target.npy').read_bytes()) + receipt.update(release_dir=str(args.release_dir.resolve()), release_source=RELEASE_SOURCE, + release_sha256=sha(args.release_dir/'release.npz'), + warmup_executed=0, target_sha256=sha(out/'target.npy')) (out/'case.json').write_text(json.dumps(receipt,indent=2)+'\n') start = time.monotonic() status = 'running' try: - seed, force = precursor(cfgpath,scale,warm,si,targets,out,start,args.wall_seconds) + seed, force = loaded if loaded is not None else precursor(cfgpath,scale,warm,si,targets,out,start,args.wall_seconds) for name in ('zero','controlled'): - run_arm(name,cfgpath,scale,si,intervals,seed,force,model,norm,out,start,args.wall_seconds) + run_arm(name,cfgpath,scale,si,intervals,seed,force,model,norm,out,start,args.wall_seconds, + mass_ratio=args.mass_ratio) status = 'smoke_completed' if args.smoke else 'pair_completed_not_qualified' except BaseException as exc: status = f'error: {type(exc).__name__}: {exc}' diff --git a/tests/unit/test_karman_viv.py b/tests/unit/test_karman_viv.py index efab574..ecec734 100644 --- a/tests/unit/test_karman_viv.py +++ b/tests/unit/test_karman_viv.py @@ -68,14 +68,15 @@ def test_observation_clipping_and_nonfinite(): m.observation(np.full(6,np.nan),np.zeros(6),2,norm) -def test_arm_step_order_and_native_rhs(tmp_path): +@pytest.mark.parametrize('mass_ratio', [2.,4.]) +def test_arm_step_order_and_native_rhs(tmp_path, mass_ratio): import CelerisLab from CelerisLab.body.motion import MovingTables from contextlib import nullcontext - log=[];candidates=[] + log=[];candidates=[];structure_args=[] class Structure: y=v=0.;mass=2.;stiffness=3.;frequency=.1 - def __init__(self,*args): pass + def __init__(self,*args): structure_args.append(args) def candidate(self,f): candidates.append(f) return self.y+.001,self.v+.002 @@ -100,7 +101,8 @@ def test_arm_step_order_and_native_rhs(tmp_path): with patch.object(CelerisLab,'Simulation',Sim),patch.object(m,'Structure',Structure), \ patch.object(m,'add_scene',return_value=([1,2,3],[4,5,6])),patch.object(m,'install_seed',return_value={}), \ patch.object(m,'check_time'): - m.run_arm('controlled','cfg',2,2,2,None,7.,model,norm,tmp_path,0,1) + m.run_arm('controlled','cfg',2,2,2,None,7.,model,norm,tmp_path,0,1,mass_ratio=mass_ratio) + assert structure_args == [(mass_ratio,2.5e-5,0.,40.)] assert candidates==[7.,33.,66.,99.] # previous native force, NOT SI mean or node impulse assert not seen[0].any() and len(seen)==2 assert [e[0] for e in log]==['set','set','set','run']*4 @@ -113,3 +115,69 @@ def test_arm_step_order_and_native_rhs(tmp_path): np.testing.assert_array_equal(rows[0,15:],rows[2,15:]) # EMA resets at SI boundary signals=np.load(tmp_path/'controlled/signals.npz') np.testing.assert_array_equal(signals['forces'][:,1],[3.,7.]) # interval CME *2, not CME+Wen + + +def test_mass4_changes_inertia_and_stiffness_not_frequency_or_damping(): + a=m.Structure(2,2.5e-5,0,40);b=m.Structure(4,2.5e-5,0,40) + assert b.mass==2*a.mass and b.stiffness==2*a.stiffness + assert b.frequency==a.frequency and b.damping==a.damping==0 + np.testing.assert_allclose(b.candidate(.001),np.asarray(a.candidate(.001))/2,rtol=0,atol=0) + + +def make_release(path): + import json + (path/'zero').mkdir() + case=dict(source=m.RELEASE_SOURCE,model_sha256=m.MODEL_SHA,norm_sha256=m.NORM_SHA, + smoke=False,D=40,U=.005,nu=.004,warmup_each=2048000,target_intervals=150,SI=3200) + (path/'case.json').write_text(json.dumps(case)) + (path/'config.json').write_text(json.dumps(m.config(40))) + seed=(np.arange(18,dtype=np.float32),np.arange(18,dtype=np.float32)+1,np.zeros(2,np.int32)) + np.savez(path/'release.npz',current=seed[0],spare=seed[1],flags=seed[2],clock=4576000,force=.003) + np.save(path/'target.npy',np.zeros((150,6),np.float32)) + transfer=dict(zip(('current','spare','flags'),map(m.array_sha,seed)),clock=4576000) + (path/'zero/report.json').write_text(json.dumps(dict(release_transfer=transfer))) + return seed + + +@pytest.mark.parametrize('damage', [None,'source','config','buffer','target','clock']) +def test_saved_release_checks_provenance_and_exact_inputs(tmp_path, damage): + import json + seed=make_release(tmp_path) + if damage in ('source','config'): + file=tmp_path/('case.json' if damage=='source' else 'config.json') + data=json.loads(file.read_text());data['source' if damage=='source' else 'extra']='changed' + file.write_text(json.dumps(data)) + elif damage in ('buffer','clock'): + with np.load(tmp_path/'release.npz') as z: data={k:z[k] for k in z.files} + if damage=='buffer': data['current'][0]+=1 + else: data['clock']=4576001 + np.savez(tmp_path/'release.npz',**data) + elif damage=='target': np.save(tmp_path/'target.npy',np.zeros((149,6),np.float32)) + if damage: + with pytest.raises(ValueError): m.saved_release(tmp_path) + else: + loaded,force=m.saved_release(tmp_path) + assert loaded[3]==4576000 and force==.003 + for actual,expected in zip(loaded[:3],seed): np.testing.assert_array_equal(actual,expected) + + +def test_mass4_cli_uses_saved_release_without_warmup(tmp_path, monkeypatch): + import json + original=tmp_path/'original';original.mkdir();make_release(original) + norm=tmp_path/'norm.json';norm.write_text('{}');model=tmp_path/'model.zip' + out=tmp_path/'output';calls=[] + monkeypatch.setattr(m.sys,'argv',['run.py','--model',str(model),'--norm',str(norm), + '--out',str(out),'--mass-ratio','4','--release-dir',str(original)]) + monkeypatch.setattr(m,'sha',lambda path:m.MODEL_SHA if path==model else m.NORM_SHA if path==norm else 'fixture-sha') + monkeypatch.setattr(m,'load_policy',lambda path:object()) + monkeypatch.setattr(m.subprocess,'check_output',lambda *a,**k:'candidate\n') + monkeypatch.setattr(m,'precursor',lambda *a:pytest.fail('saved release must skip precursor')) + monkeypatch.setattr(m,'run_arm',lambda *a,**k:calls.append((a,k))) + m.main() + assert [a[0] for a,k in calls]==['zero','controlled'] + assert all(a[2:5]==(2.,3200,400) and k=={'mass_ratio':4.} for a,k in calls) + receipt=json.loads((out/'case.json').read_text()) + assert receipt['mass_ratio']==4 and receipt['structural_damping']==0 and receipt['warmup_executed']==0 + assert receipt['exploratory'] and receipt['response_tU_D']==160 + assert (out/'target.npy').read_bytes()==(original/'target.npy').read_bytes() + assert (out/'config.json').read_bytes()==(original/'config.json').read_bytes()