Files
DynamisLab/src/drl_pinball/legacy_test/tests/test_acquire_roles.py
T
Frank14fandCursor 61e82ec90a feat(eval): publish cycle-mean wake acquisition
Add deterministic phase-filtered V5 and Legacy acquisition with complete-cycle mean fields, then evaluate controlled wakes against target and zero baselines offline.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 15:50:49 +08:00

233 lines
9.5 KiB
Python

from pathlib import Path
import numpy as np
import pytest
import src.drl_pinball.legacy_test.acquire as acquire
import src.drl_pinball.legacy_test.core.legacy_env_builder as builder
class RoleFlow:
DATA_TYPE = np.float32
def __init__(self, n_objects, obs_size, absolute_base=2_000_000):
self.action = np.ones(n_objects, dtype=np.float32)
self.obs = np.zeros(obs_size, dtype=np.float32)
self.intervals = 0
self.absolute_base = absolute_base
self.commands = []
self.capture_intervals = []
def restore_ddf(self):
pass
def apply_ddf(self):
pass
def run(self, steps, command):
assert steps == acquire.SI
assert np.array_equal(self.action, np.zeros_like(self.action))
self.intervals += 1
self.commands.append(command.copy())
self.action = command.copy()
phase = 2 * np.pi * self.intervals / acquire.CYCLE_LENGTH
self.obs[0:2] = [1000 + self.intervals, 2000 + self.intervals]
self.obs[2:8] = [np.sin(phase + index) for index in range(6)]
if self.obs.size >= 14:
self.obs[8:14] = np.arange(6) + self.intervals
def current_effective_action(self):
return self.action.copy()
def solver_clock_state(self):
return {"solver_absolute_lattice_clock": self.absolute_base + self.intervals * acquire.SI,
"solver_absolute_control_clock": self.intervals}
def data(n_objects=7):
phase = 2 * np.pi * np.arange(150) / acquire.CYCLE_LENGTH
target = np.column_stack([np.sin(phase + index) for index in range(6)]).astype(np.float32)
result = {
"config": {"nx": 1280, "ny": 2, "sample_interval": 800,
"n_obj_total": n_objects},
"target_states": target,
}
if n_objects == 7:
result["norm"] = {"save_states": np.zeros((150, 12), dtype=np.float32)}
return result
def norm():
return {"force_norm_fact": 1.0, "sens_deviation": np.zeros(6),
"sens_norm_fact": np.ones(6)}
def capture(flow, nx, ny):
assert flow.intervals > acquire.WARMUP_INTERVALS
flow.capture_intervals.append(flow.intervals)
return {"ux": np.zeros((ny, nx), np.float32), "uy": np.zeros((ny, nx), np.float32)}
def test_target_builder_uses_disturbance_then_three_sensors_without_pinball(monkeypatch):
class TargetFlow:
DATA_TYPE = np.float32
FIELD_SHAPE = (1280, 2, 1)
def __init__(self, field_cfg, cuda_cfg, device_id):
self.obs = np.zeros(0, np.float32)
self.objects = []
self.interval = 0
def add_cylinder(self, center, radius):
self.objects.append(("cylinder", center, radius))
self.obs = np.zeros(2 * len(self.objects), np.float32)
def add_sensor(self, center, radius):
self.objects.append(("sensor", center, radius))
self.obs = np.zeros(2 * len(self.objects), np.float32)
class Config:
def _replace(self, **kwargs):
return self
monkeypatch.setattr(builder, "FlowField", TargetFlow)
monkeypatch.setattr(builder.legacy_utils, "load_cuda_config", lambda path: object())
monkeypatch.setattr(builder.legacy_utils, "load_flow_field_config", lambda path: Config())
monkeypatch.setattr(builder, "_stabilize", lambda flow, count: None)
monkeypatch.setattr(builder, "FIFO_LEN", 2)
def interval(flow, steps, command):
flow.interval += 1
flow.obs[0:2] = [100 + flow.interval, 200 + flow.interval]
flow.obs[2:8] = np.arange(6) + 10 * flow.interval
monkeypatch.setattr(builder, "run_historical_interval", interval)
result = builder.build_karman_target(0, 100.0, sample_interval=800)
flow = result["flow_field"]
assert [item[0] for item in flow.objects] == ["cylinder", "sensor", "sensor", "sensor"]
assert flow.objects[0][1][0] == 10.0 * builder.L0
assert [item[1][1] for item in flow.objects[1:]] == [40.5, 0.5, -39.5]
assert result["config"]["n_obj_total"] == 4
assert np.array_equal(result["target_states"],
np.asarray([np.arange(6) + 10, np.arange(6) + 20], np.float32))
def test_zero_role_schedule_actions_rewards_and_fields(monkeypatch, tmp_path):
flow = RoleFlow(7, 14)
reward_calls = []
def rewards(*args):
reward_calls.append(args)
return {"reward": 0.4, "reward_cd": 0.5, "reward_cl": 0.6,
"native_legacy_dtw": 0.7}
monkeypatch.setattr(acquire, "reward_terms", rewards)
rows, fields = acquire._collect_role(
"zero", data(), None, flow, norm(), tmp_path, capture_field=capture,
)
assert flow.intervals == len(reward_calls) == 640
assert flow.capture_intervals == list(range(481, 641))
assert len(rows) == 160
assert fields["ux"].shape == fields["uy"].shape == (160, 2, 1280)
assert not list(tmp_path.glob("boundary_*.npz"))
counter_bias = np.array([0.0, 0.5, -0.5], np.float32)
assert np.array_equal(rows[0]["action_normalized"], counter_bias)
assert all(np.array_equal(command, np.zeros(7, np.float32)) for command in flow.commands)
assert np.array_equal(rows[0]["commanded_target_omega"], np.zeros(3, np.float32))
assert np.allclose(rows[0]["effective_smoothed_omega"], 0.0, atol=1e-7)
assert rows[0]["reward_raw"] == 0.4
assert rows[0]["native_reward_dtw"] == 0.7
assert rows[0]["control_index"] == 481 and rows[-1]["control_index"] == 640
def test_zero_role_metadata_names_physical_zero_counter_bias():
source = Path(acquire.__file__).read_text(encoding="utf-8")
assert "physical-zero/uncontrolled" in source
assert "counter-bias normalized action" in source
def test_target_role_ordering_nan_contract_and_schedule(monkeypatch, tmp_path):
flow = RoleFlow(4, 8)
monkeypatch.setattr(acquire, "reward_terms", lambda *args: pytest.fail("target has no reward"))
rows, fields = acquire._collect_role(
"target", data(n_objects=4), None, flow, None, tmp_path, capture_field=capture,
)
assert flow.intervals == 640
assert all(np.array_equal(command, np.zeros(4, np.float32)) for command in flow.commands)
assert len(rows) == 160
assert fields["ux"].shape == (160, 2, 1280)
first = rows[0]
phase = 2 * np.pi * 481 / acquire.CYCLE_LENGTH
expected = np.asarray([np.sin(phase + index) for index in range(6)], np.float32)
assert np.allclose(first["sensors"], expected)
assert first["sensors"][0] != flow.obs[0]
for name in ("forces", "action_normalized", "commanded_target_omega",
"effective_smoothed_omega"):
assert np.isnan(first[name]).all()
for name in ("reward_raw", "reward_cd", "reward_cl", "reward_sim",
"native_reward_dtw"):
assert np.isnan(first[name])
assert first["solver_absolute_lattice_step"] == 2_000_000 + 481 * 800
def test_target_sensor_reference_selects_legacy_target_channels():
six = np.arange(60, dtype=float).reshape(10, 6)
eight = np.column_stack((np.full((10, 2), -1.0), six))
assert np.array_equal(acquire._target_sensors(six), six)
assert np.array_equal(acquire._target_sensors(eight), six)
with pytest.raises(ValueError, match="six sensor channels"):
acquire._target_sensors(np.zeros((10, 7)))
def test_target_phase_artifacts_exclude_unavailable_nan_variables():
columns = {"sensors": np.ones((4, 6)), "forces": np.full((4, 6), np.nan),
"action_normalized": np.full((4, 3), np.nan),
"physical_time": np.arange(4), "lattice_step": np.arange(4),
"solver_absolute_lattice_step": np.arange(4),
"control_index": np.arange(4), "phase": np.arange(4),
"cycle_id": np.arange(4)}
values = acquire._phase_values(columns, "target")
assert set(values) == {"sensors"}
assert np.isfinite(values["sensors"]).all()
@pytest.mark.parametrize("role", ["target", "zero"])
def test_role_failure_transaction_preserves_existing(monkeypatch, tmp_path, role):
root = tmp_path / "out"
final = root / "legacy" / acquire.CASE_NAME / role
final.mkdir(parents=True)
marker = final / "keep.txt"
marker.write_text("old", encoding="utf-8")
model = tmp_path / "model.zip"; model.write_bytes(b"model")
norm_path = tmp_path / "norm.json"; norm_path.write_text("{}", encoding="utf-8")
bundle = {"case": acquire.get_case(acquire.CASE_NAME), "model_path": model,
"norm_path": norm_path, "norm": norm(),
"reference_path": tmp_path / "missing.npz"}
monkeypatch.setattr(acquire, "_resolve_bundle", lambda repo, *args: bundle)
monkeypatch.setattr(acquire, "_collect_role", lambda *args, **kwargs: ([], []))
def storage_validator(**kwargs):
root.mkdir(exist_ok=True)
return {"resolved_output_root": root, "device": root.stat().st_dev}
def runtime_factory(case, current_bundle, device, current_role):
n_objects = 4 if current_role == "target" else 7
return data(n_objects), None, RoleFlow(n_objects, 8 if current_role == "target" else 14)
with pytest.raises(RuntimeError, match="finalize failed"):
acquire.acquire_role(
role, output_root=root, overwrite=True, repo_root=tmp_path,
storage_validator=storage_validator, runtime_factory=runtime_factory,
finalizer=lambda *args: (_ for _ in ()).throw(RuntimeError("finalize failed")),
)
assert marker.read_text(encoding="utf-8") == "old"
assert not list(final.parent.glob(f".{role}.staging-*"))
def test_cli_exposes_all_roles():
source = Path(acquire.__file__).read_text(encoding="utf-8")
assert 'choices=ROLES' in source