diff --git a/docs/validation/srt_inlet_bodyless_control.md b/docs/validation/srt_inlet_bodyless_control.md new file mode 100644 index 0000000..21caa02 --- /dev/null +++ b/docs/validation/srt_inlet_bodyless_control.md @@ -0,0 +1,72 @@ +# One bounded bodyless SRT inlet control + +Preparation only; review precedes any GPU admission. This test-only opt-in is based on +8e378aa047bb35ae4ec46037eff7256bd2f074e7. Historical first-health evidence and its source +manifest are untouched. The control calls the same pinned D80 config factory and uses +unchanged production `Simulation.initialize()` / `run(1)`; `--without-cylinder` changes +only the single cylinder object list to empty. No profile, wall/source plane, domain, +collision, relaxation, speed, representation, step cap, health limit or field observation +is adjusted. SRT tau=.524 and the existing inlet/outlet remain as in the failed run. + +Production already supports zero objects through `ensure_packed_buffers`. The absent +body has no force observable: the control never calls `read_force(0)`, records `force:null` +and `force_observation:not_applicable_no_body`, and does not invent a zero force. Physical +population/macro/Mach/density checks remain intact. Existing per-step diagnostics, first +health crossing, immediately preceding/current raw+decoded state capture, 128 MiB budget, +external watchdog and maximum 320 steps remain unchanged. No extra baseline run is needed. + +Removing the cylinder necessarily turns its former solid cells into fluid. Those cells +receive the same production parabolic fluid initializer; shared fluid and boundary-ghost +cells retain the original initializer and configuration. This is not a transplant of the +baseline's solid-cell state into new fluid. Removing the body also removes its cut links +and specializes the existing kernel for zero objects, as ordinary Simulation already does. +The spatial `body_near` category is retained only as the **former-cylinder neighborhood** +for comparing locations. It does not claim an actual body is present; body-solid flag +counts should be zero in the control. + +## Explicit identity and source checks + +The unchanged manifest still pins numerical source 9805383, as used by the baseline8e +harness. The new test-only HEAD does not pretend to be that historical run. Preflight, +READY and diagnostic/result records include the control label, original baseline harness +commit, new diagnostic checkout revision/dirty status, selected numerical checkout +revision/dirty status, and new diagnostic file hash. Production/helper bytes are verified +against the original manifest before either case. A later development checkout with +changed numerical files is rejected; the old pin is not silently accepted or regenerated. +The default cylinder case remains available for compatibility, but this requested control +uses only `--without-cylinder` and a distinct new output directory. + +## Interpretation before seeing outcomes + +- Similar early inlet-region failure without a cylinder: cylinder presence is not + necessary for this observed short-time instability under these settings. This supports + investigating inlet/collision/kinetic-state dynamics; it does not prove a specific + boundary defect or exclude other shared boundaries/initialization effects. +- No health violation through 320: removing the body changes the observed onset, consistent + with a body-generated transient or altered amplification interacting with the inlet. + This does not prove the cylinder is erroneous, establish inlet stability beyond320, + or certify SRT generally. +- Different failure location/time: geometry affects the transient; classify the preserved + incoming distributions and moments before assigning any mechanism. + +The baseline failed at196 on finite Mach>.2, not nonfinite state. A control can likewise +stop on a finite threshold crossing. Do not describe either as a NaN blow-up unless the +separate diagnostic flags establish that. + +CPU-only checks: + +```sh +python tests/validation/uniform_srt_diagnostic/test_diagnostic.py +python tests/validation/uniform_srt_diagnostic/diagnose_uniform_srt.py --without-cylinder --output /tmp/new-bodyless-preflight +``` + +After review and root admission, from the explicitly recorded test checkout: + +```sh +CUDA_VISIBLE_DEVICES= timeout --signal=TERM --kill-after=30s 240s python tests/validation/uniform_srt_diagnostic/diagnose_uniform_srt.py --without-cylinder --execute --gpu-admitted --wall-seconds 180 --output output/new-srt-bodyless-control +``` + +This patch performs no GPU run or remote push. Original runner gates/results and +production files are unchanged. The extra tests verify removal-only selection without +mutating inputs, reject unexpected object sets, verify absence of force readback, and +retain physical health failures when force is unavailable. diff --git a/tests/validation/uniform_srt_diagnostic/diagnose_uniform_srt.py b/tests/validation/uniform_srt_diagnostic/diagnose_uniform_srt.py index 9f427fe..40316d7 100644 --- a/tests/validation/uniform_srt_diagnostic/diagnose_uniform_srt.py +++ b/tests/validation/uniform_srt_diagnostic/diagnose_uniform_srt.py @@ -1,9 +1,11 @@ """Read-only observations of the exact failed SRT control, at most 320 steps. Default is CPU preflight. Execution requires root admission and an external watchdog. -No solver, initialization, geometry or numerical parameter modifications. +No solver or numerical parameter modifications. --without-cylinder removes only the body; +all other geometry and the production initialization remain unchanged. """ import argparse +from copy import deepcopy import hashlib import importlib import json @@ -47,6 +49,20 @@ def source_config(source): return cfg,body,p,manifest +def select_body(body, without_cylinder=False): + selected = deepcopy(body) + if without_cylinder: + if len(selected["objects"]) != 1 or selected["objects"][0]["type"] != "cylinder": + raise ValueError("Bodyless control requires exactly the original cylinder") + selected["objects"] = [] + return selected + + +def body_force(sim, body): + # No body means no body-id 0 and no force observable; never fabricate zeros. + return sim.read_force(0).astype(np.float64) if body["objects"] else None + + def categories(flags,p): """Disjoint physical categories; subtype nibble is not a bitfield.""" from CelerisLab.lbm.descriptors import FLUID,SOLID,OBSTACLE @@ -172,18 +188,18 @@ def execute(a,cfg,body,p): deadline_check(deadline) previous=current; prior_report=report sim.run(1) # Same solver; observation buffers reset/read once per step. - force=sim.read_force(0).astype(np.float64) + force=body_force(sim,body) current=snapshot(sim,p) report=diagnose(current['g'],current['macro'],force,flags,p,current['step']) if current['step']==1: - print('READY '+json.dumps(dict(step=1,all_observation_paths_exercised=True, + print('READY '+json.dumps(dict(**a.identity,step=1,all_observation_paths_exercised=True, physical_healthy=not report['physical_failure']),allow_nan=False),flush=True) if report['physical_failure']: status='first_physical_failure'; break deadline_check(deadline) except TimeoutError: status='wall_time_limit' - details=dict(status=status,executed_steps=int(sim.stepper.step_count), + details=dict(**a.identity,status=status,executed_steps=int(sim.stepper.step_count), observation_step=current['step'],previous_observation_step=None if previous is None else previous['step'], initial=initial_report,preceding=prior_report,current=report, wall_seconds=time.monotonic()-started, @@ -191,7 +207,7 @@ def execute(a,cfg,body,p): health_scope='Physical FLUID nodes and force only; ghost/body categories cannot independently trigger physical failure.', interpretation='Near-region labels locate symptoms, not the root cause. Per-step inspection changes observation cadence only. No physical acceptance.') size=save_states(a.output,previous,current,flags,details) - print(json.dumps(dict(status=status,executed_steps=int(sim.stepper.step_count),evidence_bytes=size),allow_nan=False),flush=True) + print(json.dumps(dict(**a.identity,status=status,executed_steps=int(sim.stepper.step_count),evidence_bytes=size),allow_nan=False),flush=True) return 0 if status=='step_cap_reached' else 2 @@ -200,17 +216,27 @@ def main(): parser.add_argument('--source',type=Path,default=DEFAULT_SOURCE) parser.add_argument('--output',type=Path,required=True) parser.add_argument('--execute',action='store_true') + parser.add_argument('--without-cylinder',action='store_true', + help='Remove only the cylinder for the bounded inlet control') parser.add_argument('--gpu-admitted',action='store_true') parser.add_argument('--wall-seconds',type=float) a=parser.parse_args(); a.source=a.source.resolve() cfg,body,p,manifest=source_config(a.source) + body=select_body(body,a.without_cylinder) + from source_provenance import checkout_provenance + a.identity=dict(control="without_cylinder" if a.without_cylinder else "baseline_cylinder", + force_observation="not_applicable_no_body" if a.without_cylinder else "per_step_body_reaction", + baseline_diagnostic_commit="8e378aa047bb35ae4ec46037eff7256bd2f074e7", + diagnostic_checkout=checkout_provenance(DEFAULT_SOURCE), + numerical_checkout=checkout_provenance(a.source), + diagnostic_sha256=hashlib.sha256(Path(__file__).read_bytes()).hexdigest()) if a.output.exists() and any(a.output.iterdir()): raise ValueError('Use a new/empty output directory') if a.execute and (not a.gpu_admitted or not os.environ.get('CUDA_VISIBLE_DEVICES') or a.wall_seconds is None or not math.isfinite(a.wall_seconds) or a.wall_seconds<=0): raise ValueError('Explicit root admission, device selection and finite wall ceiling required') a.output.mkdir(parents=True,exist_ok=True) - dump(a.output/'preflight.json',dict(source=manifest,config=cfg,body=body,parameters=p, - diagnostic_sha256=hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),max_steps=320, + dump(a.output/'preflight.json',dict(**a.identity,source=manifest,config=cfg,body=body,parameters=p, + max_steps=320, max_evidence_bytes=LIMIT_BYTES,executed=False)) if not a.execute: return 0 return execute(a,cfg,body,p) diff --git a/tests/validation/uniform_srt_diagnostic/test_diagnostic.py b/tests/validation/uniform_srt_diagnostic/test_diagnostic.py index fbef4a5..e8c5bfc 100644 --- a/tests/validation/uniform_srt_diagnostic/test_diagnostic.py +++ b/tests/validation/uniform_srt_diagnostic/test_diagnostic.py @@ -94,6 +94,38 @@ def test_source_guard_and_deadline(): with unittest.TestCase().assertRaises(TimeoutError): d.deadline_check(0) +def test_bodyless_selection_preserves_baseline(): + from copy import deepcopy + baseline_config, baseline_body, baseline_parameters = deepcopy((cfg,body,p)) + selected = d.select_body(body,True) + assert selected == {"objects": []} + assert (cfg,body,p) == (baseline_config,baseline_body,baseline_parameters) + assert d.select_body(body) == baseline_body + assert d.select_body(body) is not body + assert p['nx']==1761 and p['ny']==330 and p['mean_speed']==.01 + assert np.isclose(3*p['viscosity']+.5,.524) + for invalid in ({"objects":[]},{"objects":body['objects']*2}, + {"objects":[{"type":"other"}]}): + with unittest.TestCase().assertRaises(ValueError): d.select_body(invalid,True) + + +def test_absent_force_is_not_zero_or_read(): + class FakeSimulation: + def __init__(self): self.calls=[] + def read_force(self,index): + self.calls.append(index) + return np.array([1.,2.],np.float32) + sim=FakeSimulation() + assert d.body_force(sim,{"objects":[]}) is None and not sim.calls + np.testing.assert_array_equal(d.body_force(sim,body),[1.,2.]) + assert sim.calls==[0] + g,m,f,params=fixture() + report=d.diagnose(g,m,None,f,params,1) + assert report['force'] is None and not report['force_nonfinite'] + m['ux'][3,1]=.2 + assert d.diagnose(g,m,None,f,params,2)['physical_failure'] + + if __name__=='__main__': result=unittest.TextTestRunner(verbosity=2).run(unittest.TestSuite( unittest.FunctionTestCase(fn) for name,fn in sorted(globals().items()) if name.startswith('test_')))