viv.py: exact undamped harmonic update under the last completed force, y = 0 response cycles with eligibility, 10-cycle blocks, settle/score with deterministic restart, conditional velocity seed, scoring metrics. run.py: Simulation-API driver (set_body + run(1)), RHS = CME + Wen from the moving diagnostics, raw CME/obs/node terms logged, fail-closed bounds, caps and input rejection, outputs outside the checkout. CPU tests for the logic and the real config loader. Predeclare: stale failure sentence removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
89 lines
3.2 KiB
Python
89 lines
3.2 KiB
Python
"""CPU tests of the predeclared VIV logic (research/viv_re150/viv.py; stdlib only)."""
|
|
import importlib.util
|
|
import math
|
|
from pathlib import Path
|
|
|
|
_spec = importlib.util.spec_from_file_location(
|
|
"viv_logic", Path(__file__).resolve().parents[2] / "research/viv_re150/viv.py")
|
|
viv = importlib.util.module_from_spec(_spec)
|
|
_spec.loader.exec_module(viv)
|
|
|
|
D, U = 40, 0.04
|
|
|
|
|
|
def _feed(cyc, amps, period=100):
|
|
"""Feed sine cycles of the given amplitudes (in D); return the first non-None result."""
|
|
n = 0
|
|
for a in amps:
|
|
for i in range(period):
|
|
out = cyc.update(a * D * math.sin(2 * math.pi * (i + 1) / period))
|
|
n += 1
|
|
if out:
|
|
return out, n
|
|
return None, n
|
|
|
|
|
|
def test_structure_exact_update_conserves_free_oscillation_and_equilibrium():
|
|
s = viv.Structure(D, U)
|
|
assert math.isclose(1 / s.f_n, 5000.0) and math.isclose(s.m, 2 * D * D)
|
|
s.y, s.v = 0.3 * D, 0.0
|
|
e0 = 0.5 * s.k * s.y ** 2
|
|
for _ in range(12345):
|
|
s.y, s.v = s.candidate(0.0)
|
|
assert math.isclose(0.5 * s.k * s.y ** 2 + 0.5 * s.m * s.v ** 2, e0, rel_tol=1e-9)
|
|
s.y, s.v = 2.0 / s.k, 0.0
|
|
assert all(math.isclose(a, b, abs_tol=1e-12) for a, b in zip(s.candidate(2.0), (2.0 / s.k, 0.0)))
|
|
assert math.isclose(s.seed_velocity(D) / s.w, viv.ELIGIBLE * D)
|
|
|
|
|
|
def test_constant_amplitude_settles_then_scores_twenty_cycles():
|
|
cyc = viv.Cycles(D)
|
|
out, _ = _feed(cyc, [0.5] * 60)
|
|
assert out == "scored" and len(cyc.window) == 20 and cyc.settled_at == 1
|
|
r = viv.score(cyc.window)
|
|
assert math.isclose(r["A_max"], 0.5, rel_tol=1e-3) and abs(r["offset"]) < 1e-3
|
|
assert r["T_resp"] == 100 and r["within_band"] # |.500 - .520| <= .052
|
|
|
|
|
|
def test_small_cycles_are_not_counted_and_y0_is_not_a_crossing():
|
|
cyc = viv.Cycles(D)
|
|
assert cyc.update(0.0) is None and cyc.log == []
|
|
out, _ = _feed(cyc, [0.005] * 50)
|
|
assert out is None and cyc.counted == 0 and cyc.log and not any(c["eligible"] for c in cyc.log)
|
|
|
|
|
|
def test_scoring_failure_restarts_from_the_failing_block():
|
|
cyc = viv.Cycles(D)
|
|
amps = [0.5] * 20 + [0.55] * 10 + [0.55] * 40 # settle at block 1, block 2 jumps 10 %
|
|
out, _ = _feed(cyc, amps)
|
|
assert out == "scored"
|
|
assert all(math.isclose(c["amp"], 0.55, rel_tol=1e-3) for c in cyc.window)
|
|
|
|
|
|
def test_seed_reset_discards_incomplete_cycle_and_proof():
|
|
cyc = viv.Cycles(D)
|
|
_feed(cyc, [0.5] * 15)
|
|
cyc.update(0.2 * D) # inside an incomplete cycle
|
|
counted = cyc.counted
|
|
cyc.discard_current()
|
|
cyc.reset_proof()
|
|
assert cyc.amps == [] and cyc.blocks == [] and cyc.counted == counted
|
|
|
|
|
|
def test_viv_config_passes_the_real_loader(tmp_path):
|
|
import json
|
|
import sys
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "research/viv_re150"))
|
|
try:
|
|
import run
|
|
finally:
|
|
sys.path.pop(0)
|
|
from CelerisLab.config import load_lbm_config
|
|
cfg_dict, nx, ny = run.config()
|
|
path = tmp_path / "config_lbm.json"
|
|
path.write_text(json.dumps(cfg_dict))
|
|
cfg = load_lbm_config(str(path))
|
|
assert (cfg.nx, cfg.ny) == (nx, ny) == (3000, 2400)
|
|
assert cfg.collision == "MRT" and cfg.y_wall_bc == "free_slip" and cfg.velocity == .04
|
|
assert math.isclose(cfg.viscosity, .04 * 40 / 150)
|