Files
CelerisLab/tests/unit/test_viv_logic.py
T
ClaudeandClaude Opus 5.5 707fa8b5fc research: predeclared Re150 VIV driver and logic (research-only)
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>
2026-10-30 15:16:23 +00:00

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)