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>
This commit is contained in:
@@ -9,8 +9,11 @@ import numpy as np
|
||||
import pytest
|
||||
|
||||
from drl_pinball.acquisition import (
|
||||
accumulate_mean_fields,
|
||||
assign_phase,
|
||||
assign_periodic_phase,
|
||||
cleanup_scratch,
|
||||
complete_cycle_field_indices,
|
||||
create_scratch,
|
||||
default_reproduction_mapping,
|
||||
decode_legacy_physical_velocity,
|
||||
@@ -20,6 +23,7 @@ from drl_pinball.acquisition import (
|
||||
publish_role_output,
|
||||
publish_selected_fields,
|
||||
select_phase_fields,
|
||||
smooth_center_uy,
|
||||
validate_output_storage,
|
||||
validate_modern_fields,
|
||||
write_boundary_artifacts,
|
||||
@@ -184,6 +188,59 @@ def test_selection_is_global_eight_with_metadata_and_index_tie():
|
||||
assert np.allclose(result["phase_error"], [0.0] * 8, atol=1e-15)
|
||||
|
||||
|
||||
def test_smooth_center_uy_binomial_and_endpoint_copy():
|
||||
assert np.allclose(smooth_center_uy([1.0, 5.0, 1.0]), [1.0, 3.0, 1.0])
|
||||
assert np.allclose(smooth_center_uy([2.0, 4.0]), [2.0, 4.0])
|
||||
|
||||
|
||||
def test_assign_phase_default_behavior_unchanged_with_jitter():
|
||||
times = np.arange(12, dtype=float)
|
||||
sensors = np.zeros((12, 6))
|
||||
sensors[:, 3] = [-1, 1, -0.1, 0.1, -1, 1, -1, 1, -1, 1, -1, 1]
|
||||
result = assign_phase(times, sensors)
|
||||
assert len(result["crossing_times"]) == 6
|
||||
assert set(result) == {"phase", "cycle_id", "crossing_times"}
|
||||
|
||||
|
||||
def test_assign_periodic_phase_filters_jitter_with_minimum_interval():
|
||||
times = np.arange(20, dtype=float)
|
||||
sensors = np.zeros((20, 6))
|
||||
# Deep brief negative between rises so smoothing keeps a close false crossing.
|
||||
sensors[:, 3] = [-2, -2, -1, 1, 2, -3, 1, 2, 2, -2, -1, 1, 2, 2, -2, -1, 1, 2, 2, -2]
|
||||
raw = assign_phase(times, sensors)
|
||||
filtered = assign_periodic_phase(times, sensors, minimum_crossing_interval=4.0)
|
||||
assert len(raw["crossing_times"]) >= 4
|
||||
assert filtered["rejected_crossing_count"] >= 1
|
||||
assert 5.75 in filtered["rejected_crossing_times"]
|
||||
assert filtered["accepted_crossing_count"] == len(filtered["crossing_times"])
|
||||
assert filtered["complete_cycle_count"] == filtered["accepted_crossing_count"] - 1
|
||||
assert np.all(np.diff(filtered["crossing_times"]) >= 4.0 - 1e-12)
|
||||
assert filtered["smoothing_kernel"] == [0.25, 0.5, 0.25]
|
||||
first, last = filtered["crossing_times"][0], filtered["crossing_times"][-1]
|
||||
valid = (times >= first) & (times < last)
|
||||
assert np.all(filtered["cycle_id"][valid] >= 0)
|
||||
assert np.all(filtered["cycle_id"][~valid] < 0)
|
||||
|
||||
|
||||
def test_complete_cycle_mean_excludes_outside_first_last_crossing():
|
||||
times = np.arange(10, dtype=float)
|
||||
crossings = np.array([2.0, 6.0, 9.0])
|
||||
selected = complete_cycle_field_indices(times, crossings)
|
||||
assert selected["field_indices"].tolist() == [2, 3, 4, 5, 6, 7, 8]
|
||||
assert selected["mean_field_count"] == 7
|
||||
assert selected["complete_cycle_count"] == 2
|
||||
ux = np.arange(10, dtype=np.float32).reshape(10, 1, 1)
|
||||
uy = (2 * np.arange(10)).astype(np.float32).reshape(10, 1, 1)
|
||||
mean = accumulate_mean_fields(ux, uy, selected["field_indices"])
|
||||
assert mean["mean_ux"].shape == (1, 1)
|
||||
assert mean["mean_uy"].shape == (1, 1)
|
||||
assert float(mean["mean_ux"][0, 0]) == pytest.approx(5.0)
|
||||
assert float(mean["mean_uy"][0, 0]) == pytest.approx(10.0)
|
||||
# Eight-slot average of selected phase snapshots must remain a different concept.
|
||||
eight = accumulate_mean_fields(ux, uy, np.array([2, 3, 4, 5, 6, 7, 8, 8]))
|
||||
assert float(eight["mean_ux"][0, 0]) != pytest.approx(float(mean["mean_ux"][0, 0]))
|
||||
|
||||
|
||||
def test_pooled_32_bins_population_std_and_original_indices():
|
||||
result = pooled_phase_bins(
|
||||
np.array([np.pi / 32, 3 * np.pi / 32, np.pi / 32, np.nan]),
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import csv
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SOURCE = ROOT / "src/drl_pinball/eval/infer_train.py"
|
||||
|
||||
|
||||
def _load_helpers(*names: str):
|
||||
tree = ast.parse(SOURCE.read_text())
|
||||
selected = [node for node in tree.body if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name in names]
|
||||
namespace = {
|
||||
"Path": Path, "Dict": dict, "Any": object, "List": list, "Tuple": tuple,
|
||||
"np": np, "json": json, "csv": csv, "hashlib": __import__("hashlib"), "CaseSpec": object, "shutil": __import__("shutil"),
|
||||
"NUM_STEPS": 360, "TAIL_WINDOW": 180, "_POLICY_SENSOR_UNITS": "legacy-policy-v1",
|
||||
"REFERENCE_TABLE_ATOL": 1e-6, "REPRO_REWARD_ATOL": 0.02,
|
||||
"REPRO_COMPONENT_ATOL": 0.02, "REPRO_DTW_ATOL": 0.02,
|
||||
"REPRO_ACTION_MEAN_ATOL": 0.03, "OUT_BASE": ROOT / "src/drl_pinball/eval/output/train",
|
||||
}
|
||||
exec(compile(ast.Module(body=selected, type_ignores=[]), str(SOURCE), "exec"), namespace)
|
||||
return [namespace[name] for name in names]
|
||||
|
||||
|
||||
def test_vecnormalize_is_frozen_for_canonical_inference():
|
||||
(freeze,) = _load_helpers("_freeze_vecnormalize")
|
||||
class FakeVecNormalize:
|
||||
training = True
|
||||
norm_reward = True
|
||||
vec = freeze(FakeVecNormalize())
|
||||
assert vec.training is False
|
||||
assert vec.norm_reward is False
|
||||
|
||||
|
||||
def test_seed_signal_serialization_contains_only_compact_series(tmp_path):
|
||||
(save_signals,) = _load_helpers("_save_seed_signals")
|
||||
values = np.ones(4, dtype=np.float32)
|
||||
result = {
|
||||
"seed": "43", "sensors": np.ones((4, 6)), "forces": np.ones((4, 6)),
|
||||
"actions": np.ones((4, 3)), "rewards": values, "r_cd_series": values,
|
||||
"r_cl_series": values, "r_sim_series": values, "sim_raw_series": values,
|
||||
}
|
||||
path = save_signals(tmp_path, result)
|
||||
with np.load(path) as artifact:
|
||||
assert set(artifact.files) == {"sensors", "forces", "actions", "rewards", "r_cd", "r_cl", "r_sim", "sim_raw"}
|
||||
assert not ({"ux", "uy", "vorticity"} & set(artifact.files))
|
||||
|
||||
|
||||
def test_evaluator_contract_is_canonical_and_gpu_imports_are_lazy():
|
||||
source = SOURCE.read_text()
|
||||
assert "scene_manifest" not in source
|
||||
assert "provenance" not in source
|
||||
assert "from drl_pinball.case_registry import" in source
|
||||
assert "NUM_STEPS = 360" in source
|
||||
assert "TAIL_WINDOW = 180" in source
|
||||
assert "deterministic=True" in source
|
||||
assert "vmin=-0.001, vmax=0.001" in source
|
||||
tree = ast.parse(source)
|
||||
top_imports = [node for node in tree.body if isinstance(node, (ast.Import, ast.ImportFrom))]
|
||||
assert all("pycuda" not in ast.unparse(node) for node in top_imports)
|
||||
|
||||
|
||||
def test_prepare_output_preserves_existing_baseline(tmp_path):
|
||||
(prepare,) = _load_helpers("_prepare_output")
|
||||
existing = tmp_path / "kar_re100"
|
||||
existing.mkdir()
|
||||
sentinel = existing / "metrics.json"
|
||||
sentinel.write_text("baseline")
|
||||
try:
|
||||
prepare("kar_re100", False, tmp_path)
|
||||
except FileExistsError as exc:
|
||||
assert "--overwrite" in str(exc)
|
||||
else:
|
||||
raise AssertionError("existing baseline must be protected")
|
||||
assert prepare("kar_re100", True, tmp_path) == existing
|
||||
assert not sentinel.exists()
|
||||
|
||||
|
||||
def test_illusion_bundle_uses_verified_calibration_harmonics(tmp_path):
|
||||
load_json, sha256, schema, adapt, lineage, resolve = _load_helpers(
|
||||
"_load_json", "_sha256", "_calibration_schema",
|
||||
"_adapt_native_illusion_products", "_normalizer_lineage", "_resolve_seed_artifacts")
|
||||
lineage.__globals__["CASE_IDS"] = ("ill_1L",)
|
||||
resolve.__globals__.update(
|
||||
_sha256=sha256, _load_json=load_json, _calibration_schema=schema,
|
||||
_adapt_native_illusion_products=adapt, _normalizer_lineage=lineage)
|
||||
class Case:
|
||||
case_id = "ill_1L"
|
||||
seeds = (43,)
|
||||
scene_type = "illusion"
|
||||
si = 1200
|
||||
config_path = tmp_path / "config.json"
|
||||
calibration_path = tmp_path / "calibrations/ill_1L/calibration.json"
|
||||
def model_dir(self, seed):
|
||||
return tmp_path / f"ill_1L_seed{seed}/models"
|
||||
case = Case()
|
||||
run = case.model_dir(43).parent
|
||||
(run / "models").mkdir(parents=True)
|
||||
case.calibration_path.parent.mkdir(parents=True)
|
||||
case.calibration_path.write_text(json.dumps({
|
||||
"schema_version": "drl-pinball-calibration-v2",
|
||||
"sensor_units": "celeris-area-time-average",
|
||||
}))
|
||||
(run / "models/best_model.zip").write_bytes(b"model")
|
||||
(run / "vec_normalize.pkl").write_bytes(b"normalizer")
|
||||
np.save(run / "target.npy", np.ones((150, 6), dtype=np.float32))
|
||||
np.save(case.calibration_path.parent / "target.npy", np.ones((150, 6), dtype=np.float32))
|
||||
(run / "calibration.json").write_text(json.dumps({"SENSOR_CC": 78.0}))
|
||||
|
||||
try:
|
||||
resolve(case, 43)
|
||||
except FileNotFoundError as exc:
|
||||
assert "target_harmonics.json" in str(exc)
|
||||
assert "registry calibration bundle" in str(exc)
|
||||
else:
|
||||
raise AssertionError("missing calibration harmonics must fail")
|
||||
|
||||
harmonics_path = case.calibration_path.parent / "target_harmonics.json"
|
||||
harmonics = [
|
||||
{"dc": float(i + 1), "amps": [float(i + 2)], "freqs": [0.1], "phases": [0.2]}
|
||||
for i in range(8)
|
||||
]
|
||||
harmonics_path.write_text(json.dumps(harmonics))
|
||||
np.save(case.calibration_path.parent / "target.npy", np.zeros((150, 6), dtype=np.float32))
|
||||
try:
|
||||
resolve(case, 43)
|
||||
except ValueError as exc:
|
||||
assert "target mismatch" in str(exc)
|
||||
assert "SHA256" in str(exc)
|
||||
else:
|
||||
raise AssertionError("mismatched calibration target must fail")
|
||||
|
||||
np.save(case.calibration_path.parent / "target.npy", np.ones((150, 6), dtype=np.float32))
|
||||
bundle = resolve(case, 43)
|
||||
assert bundle["target_path"] == (run / "target.npy").resolve()
|
||||
assert bundle["harmonics_path"] == harmonics_path.resolve()
|
||||
assert np.array_equal(bundle["target_states"], np.full((150, 6), 78.0, dtype=np.float32))
|
||||
adapted = bundle["target_harmonics"]
|
||||
assert adapted[0]["dc"] == 78.0 and adapted[0]["amps"] == [156.0]
|
||||
assert adapted[0]["freqs"] == [0.1] and adapted[0]["phases"] == [0.2]
|
||||
assert adapted[5]["dc"] == 468.0
|
||||
assert adapted[6] == harmonics[6] and adapted[7] == harmonics[7]
|
||||
metadata = bundle["illusion_native_to_legacy_adaptation"]
|
||||
assert metadata["applied"] is True and metadata["factor"] == 78.0
|
||||
assert metadata["source_target_sha256"] == metadata["registry_target_sha256"]
|
||||
|
||||
|
||||
|
||||
def test_normalizer_lineage_selects_d075_best_and_others_final(tmp_path):
|
||||
(lineage,) = _load_helpers("_normalizer_lineage")
|
||||
lineage.__globals__["CASE_IDS"] = ("kar_d075", "kar_re60")
|
||||
d075_path, d075_reason = lineage("kar_d075", tmp_path)
|
||||
other_path, other_reason = lineage("kar_re60", tmp_path)
|
||||
assert d075_path == tmp_path / "best_vecnormalize.pkl"
|
||||
assert "A/B" in d075_reason
|
||||
assert other_path == tmp_path / "vec_normalize.pkl"
|
||||
assert "final compatibility alias" in other_reason
|
||||
try:
|
||||
lineage("unknown", tmp_path)
|
||||
except ValueError as exc:
|
||||
assert "No retained-artifact normalizer lineage" in str(exc)
|
||||
else:
|
||||
raise AssertionError("unregistered lineage must fail closed")
|
||||
|
||||
|
||||
def test_action_means_use_tail_window_only():
|
||||
(build_metrics,) = _load_helpers("_build_metrics")
|
||||
actions = np.vstack((np.full((180, 3), 10.0), np.array([[1.0, 2.0, 3.0]] * 180)))
|
||||
best = {
|
||||
"seed": "45", "avg_reward": 0.9, "r_cd": 0.8, "r_cl": 0.7,
|
||||
"r_sim": 0.6, "sim_raw": 0.5, "actions": actions,
|
||||
}
|
||||
metrics = build_metrics("kar_re100", best)
|
||||
assert (metrics["aF_mean"], metrics["aT_mean"], metrics["aB_mean"]) == (1.0, 2.0, 3.0)
|
||||
assert metrics["action_mean_window"] == 180
|
||||
|
||||
|
||||
def test_metrics_only_returns_before_signals_and_vorticity_paths():
|
||||
source = SOURCE.read_text()
|
||||
tree = ast.parse(source)
|
||||
evaluate = next(node for node in tree.body if isinstance(node, ast.FunctionDef) and node.name == "evaluate_case")
|
||||
metrics_guard = next(
|
||||
node for node in evaluate.body
|
||||
if isinstance(node, ast.If) and ast.unparse(node.test) == "metrics_only"
|
||||
)
|
||||
assert isinstance(metrics_guard.body[-1], ast.Return)
|
||||
guard_line = metrics_guard.lineno
|
||||
save_seed_line = next(node.lineno for node in ast.walk(evaluate) if isinstance(node, ast.Call) and ast.unparse(node.func) == "_save_seed_signals")
|
||||
replay_lines = [node.lineno for node in ast.walk(evaluate) if isinstance(node, ast.Call) and ast.unparse(node.func) in {"_save_vorticity", "_generate_target_vorticity"}]
|
||||
assert save_seed_line < guard_line
|
||||
save_parent = next(node for node in ast.walk(evaluate) if isinstance(node, ast.If) and any(getattr(child, "lineno", -1) == save_seed_line for child in ast.walk(node)))
|
||||
assert ast.unparse(save_parent.test) == "not metrics_only"
|
||||
assert replay_lines and all(line > guard_line for line in replay_lines)
|
||||
|
||||
def _configure_validation(validate, load_json, tmp_path, metrics, csv_values, json_values):
|
||||
metrics_dir = tmp_path / "out/kar_re100"
|
||||
metrics_dir.mkdir(parents=True)
|
||||
(metrics_dir / "metrics.json").write_text(json.dumps(metrics))
|
||||
fields = [
|
||||
"case_id", "selected_seed", "eval_reward_mean", "eval_r_cd_mean",
|
||||
"eval_r_cl_mean", "eval_r_sim_mean", "dtw_similarity",
|
||||
"action_front_mean", "action_top_mean", "action_bottom_mean",
|
||||
]
|
||||
csv_path = tmp_path / "latest.csv"
|
||||
csv_path.write_text(
|
||||
",".join(fields) + "\n" +
|
||||
",".join(str(csv_values[field]) for field in fields) + "\n"
|
||||
)
|
||||
json_path = tmp_path / "latest.json"
|
||||
json_path.write_text(json.dumps({
|
||||
"scope": {"eval_steps": 360, "action_tail_steps": 180},
|
||||
"evaluation": [json_values],
|
||||
}))
|
||||
validate.__globals__.update(
|
||||
OUT_BASE=tmp_path / "out", REFERENCE_CSV=csv_path,
|
||||
REFERENCE_JSON=json_path, _load_json=load_json,
|
||||
)
|
||||
|
||||
|
||||
def test_validation_accepts_documented_fresh_re100_deltas_and_reports_them(tmp_path):
|
||||
load_json, validate = _load_helpers("_load_json", "validate_outputs")
|
||||
reference = {
|
||||
"case_id": "kar_re100", "selected_seed": 45,
|
||||
"eval_reward_mean": .931258, "eval_r_cd_mean": .981787,
|
||||
"eval_r_cl_mean": .980641, "eval_r_sim_mean": .856324,
|
||||
"dtw_similarity": .918458, "action_front_mean": .015262,
|
||||
"action_top_mean": -.239834, "action_bottom_mean": .226212,
|
||||
}
|
||||
fresh = {
|
||||
"best_seed": "45", "reward_mean": .937275, "r_cd_mean": .980295,
|
||||
"r_cl_mean": .988632, "r_sim_mean": .866492, "dtw_sim_v5": .923246,
|
||||
"aF_mean": .033458, "aT_mean": -.243172, "aB_mean": .214681,
|
||||
}
|
||||
_configure_validation(validate, load_json, tmp_path, fresh, reference, reference)
|
||||
reports, failures = validate(("kar_re100",))
|
||||
assert failures == []
|
||||
assert len(reports) == 8
|
||||
assert all("delta=" in report and report.endswith("PASS") for report in reports)
|
||||
|
||||
|
||||
def test_validation_keeps_strict_tables_and_fails_reproduction_gate(tmp_path):
|
||||
load_json, validate = _load_helpers("_load_json", "validate_outputs")
|
||||
reference = {
|
||||
"case_id": "kar_re100", "selected_seed": 45,
|
||||
"eval_reward_mean": 1.0, "eval_r_cd_mean": 2.0,
|
||||
"eval_r_cl_mean": 3.0, "eval_r_sim_mean": 4.0,
|
||||
"dtw_similarity": 5.0, "action_front_mean": 0.1,
|
||||
"action_top_mean": 0.2, "action_bottom_mean": 0.3,
|
||||
}
|
||||
csv_reference = dict(reference, eval_reward_mean=1.000002)
|
||||
fresh = {
|
||||
"best_seed": 44, "reward_mean": 1.021, "r_cd_mean": 2.0,
|
||||
"r_cl_mean": 3.0, "r_sim_mean": 4.0, "dtw_sim_v5": 5.0,
|
||||
"aF_mean": 0.1, "aT_mean": 0.2, "aB_mean": 0.3,
|
||||
}
|
||||
_configure_validation(validate, load_json, tmp_path, fresh, csv_reference, reference)
|
||||
reports, failures = validate(("kar_re100",))
|
||||
assert reports == []
|
||||
assert any("CSV/JSON disagree" in failure and "strict atol=1e-06" in failure for failure in failures)
|
||||
|
||||
csv_reference["eval_reward_mean"] = reference["eval_reward_mean"]
|
||||
_configure_validation(validate, load_json, tmp_path / "second", fresh, csv_reference, reference)
|
||||
reports, failures = validate(("kar_re100",))
|
||||
assert any(report.endswith("FAIL") and "reward_mean" in report for report in reports)
|
||||
assert any("best_seed=44" in failure for failure in failures)
|
||||
assert any("exceeds reproduction atol=0.020" in failure for failure in failures)
|
||||
@@ -0,0 +1,565 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from drl_pinball.eval import acquire_v5
|
||||
|
||||
|
||||
class FakeModel:
|
||||
def __init__(self):
|
||||
self.calls = 0
|
||||
|
||||
def predict(self, obs, deterministic):
|
||||
assert deterministic is True
|
||||
self.calls += 1
|
||||
return np.array([[0.1, -0.2, 0.3]], dtype=np.float32), None
|
||||
|
||||
|
||||
class FakeVecEnv:
|
||||
def __init__(self, raw):
|
||||
self.raw = raw
|
||||
self.steps = 0
|
||||
self.events = []
|
||||
|
||||
def reset(self):
|
||||
self.events.append("reset")
|
||||
return np.zeros((1, 12), dtype=np.float32)
|
||||
|
||||
def step(self, action):
|
||||
self.steps += 1
|
||||
self.raw.control_step = self.steps
|
||||
self.raw.sim.stepper.step_count = self.steps * 800
|
||||
self.raw.smoother._state = self.raw._action_to_omega(action) * 0.5
|
||||
self.events.append(("step", self.steps))
|
||||
info = {"sim": self.steps / 1000, "r_cd": 1, "r_cl": 2, "r_sim": 3, "floor_pen": 4}
|
||||
return np.zeros((1, 12)), np.array([5.0]), np.array([False]), [info]
|
||||
|
||||
|
||||
class FakeRaw:
|
||||
def __init__(self):
|
||||
self.control_step = 0
|
||||
self._cal = {"U0": 0.01, "grid": {"nx": 2000, "ny": 600},
|
||||
"ACTION_BIAS": [0.0, 0.0, 0.0], "ACTION_SCALE": 12.0}
|
||||
self.smoother = type("Smoother", (), {"_state": np.zeros(3)})()
|
||||
self.sim = type("Sim", (), {})()
|
||||
self.sim.stepper = type("Stepper", (), {"step_count": 0})()
|
||||
|
||||
def _action_to_omega(self, action):
|
||||
return np.asarray(action).reshape(3) * 2
|
||||
|
||||
def _read_obs(self):
|
||||
return np.arange(14, dtype=np.float32) + self.control_step
|
||||
|
||||
|
||||
def test_controlled_schedule_is_reset_750_warmup_then_250_post_step_fields(tmp_path):
|
||||
raw, model = FakeRaw(), FakeModel()
|
||||
vec = FakeVecEnv(raw)
|
||||
captures = []
|
||||
|
||||
def capture(env):
|
||||
captures.append((env.control_step, len(vec.events)))
|
||||
value = np.full((2, 3), env.control_step, dtype=np.float32)
|
||||
return {"rho": value, "ux": value, "uy": value}
|
||||
|
||||
rows, buffer = acquire_v5._collect_controlled(model, vec, raw, tmp_path, capture)
|
||||
assert vec.events[0] == "reset"
|
||||
assert vec.steps == model.calls == 1000
|
||||
assert len(rows) == len(captures) == 250
|
||||
assert buffer["ux"].shape == buffer["uy"].shape == (250, 2, 3)
|
||||
assert captures[0][0] == rows[0]["control_index"] == 751
|
||||
assert captures[-1][0] == rows[-1]["control_index"] == 1000
|
||||
assert all(event_count == step + 1 for step, event_count in captures)
|
||||
assert rows[0]["native_reward_dtw"] == pytest.approx(0.751)
|
||||
assert np.allclose(rows[0]["commanded_target_omega"], [0.2, -0.4, 0.6])
|
||||
assert np.allclose(rows[0]["effective_smoothed_omega"], [0.1, -0.2, 0.3])
|
||||
assert np.allclose(buffer["ux"][0], 751) and np.allclose(buffer["uy"][-1], 1000)
|
||||
assert not list(tmp_path.glob("boundary_*.npz"))
|
||||
|
||||
|
||||
def test_field_capture_runs_inside_env_cuda_context_and_validates_shape():
|
||||
events = []
|
||||
raw = type("Raw", (), {})()
|
||||
raw.sim = type("Sim", (), {})()
|
||||
raw.sim.lbm_cfg = type("Cfg", (), {"nx": 3, "ny": 2})()
|
||||
|
||||
def macro():
|
||||
events.append("macro")
|
||||
value = np.ones((2, 3), dtype=np.float32)
|
||||
return {"rho": value, "ux": value, "uy": value}
|
||||
|
||||
raw.sim.get_macroscopic = macro
|
||||
raw._gpu_block = lambda fn: (events.append("push"), fn(), events.append("pop"))
|
||||
result = acquire_v5._capture_fields(raw)
|
||||
assert events == ["push", "macro", "pop"]
|
||||
assert result["ux"].shape == (2, 3)
|
||||
assert set(result) == {"rho", "ux", "uy"}
|
||||
|
||||
|
||||
def test_zero_uses_full_vec_step_schedule_and_zero_action(tmp_path):
|
||||
raw = FakeRaw()
|
||||
vec = FakeVecEnv(raw)
|
||||
rows, buffer = acquire_v5._collect_zero(vec, raw, tmp_path, lambda env: {
|
||||
name: np.ones((2, 3), dtype=np.float32) for name in ("rho", "ux", "uy")
|
||||
})
|
||||
assert vec.events[0] == "reset"
|
||||
assert vec.steps == 1000 and len(rows) == 250
|
||||
assert buffer["ux"].shape == (250, 2, 3)
|
||||
assert np.array_equal(rows[0]["action_normalized"], np.zeros(3, dtype=np.float32))
|
||||
assert rows[0]["native_reward_dtw"] == pytest.approx(0.751)
|
||||
assert rows[-1]["control_index"] == 1000
|
||||
assert set(rows[0]) == set(acquire_v5._target_boundary(type("Target", (), {
|
||||
"sensor_ids": (0, 1, 2), "calibration": {"U0": 0.01, "grid": {"nx": 2000}},
|
||||
"sim": type("Sim", (), {"stepper": type("Stepper", (), {"step_count": 800})(),
|
||||
"read_sensor": lambda self, sid, normalize: (0.0, 0.0)})()
|
||||
})(), 1))
|
||||
|
||||
|
||||
def test_target_geometry_schedule_order_and_nan_contract(tmp_path):
|
||||
class Sim:
|
||||
def __init__(self):
|
||||
self.added, self.runs, self.closed = [], [], False
|
||||
self._objects = []
|
||||
self.bodies = type("Bodies", (), {
|
||||
"get": lambda owner, index: self._objects[index],
|
||||
"count": property(lambda owner: len(self._objects)),
|
||||
})()
|
||||
self.stepper = type("Stepper", (), {"step_count": 0})()
|
||||
self.lbm_cfg = type("Cfg", (), {"nx": 3, "ny": 2})()
|
||||
context = type("Context", (), {"push": lambda self: None, "pop": lambda self: None})()
|
||||
self.ctx = type("Cuda", (), {"_ctx": context})()
|
||||
|
||||
def add_body(self, kind, **kwargs):
|
||||
self.added.append((kind, kwargs))
|
||||
body_id = len(self.added) - 1
|
||||
self._objects.append(type("Body", (), {
|
||||
"obj_id": body_id, "_is_sensor": kind == "sensor",
|
||||
})())
|
||||
return body_id
|
||||
|
||||
def initialize(self):
|
||||
self.initialized = True
|
||||
|
||||
def run(self, steps, **kwargs):
|
||||
self.runs.append((steps, kwargs))
|
||||
self.stepper.step_count += steps
|
||||
|
||||
def read_sensor(self, sensor_id, normalize=True):
|
||||
assert normalize is True
|
||||
return np.array([sensor_id + 0.1, sensor_id + 0.2])
|
||||
|
||||
def get_macroscopic(self):
|
||||
value = np.ones((2, 3), dtype=np.float32)
|
||||
return {"rho": value, "ux": value, "uy": value}
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
sim = Sim()
|
||||
bundle = {"calibration": {"grid": {"nx": 3, "ny": 2}, "dist_radius": 1.25,
|
||||
"L0": 20.0, "U0": 0.01},
|
||||
"config_path": Path("config.json")}
|
||||
case = type("Case", (), {"scene_type": "karman", "target_diam": None})()
|
||||
spinups = []
|
||||
runtime = acquire_v5._create_target_runtime(
|
||||
case, bundle, 2, simulation_factory=lambda **_: sim,
|
||||
spinup_runner=lambda target_sim, steps: spinups.append((target_sim, steps)),
|
||||
)
|
||||
assert sim.added == [
|
||||
("circle", {"center": (600.0, 0.5, 0.0), "radius": 25.0}),
|
||||
("sensor", {"center": (1200.0, 40.5, 0.0), "radius": 5.0}),
|
||||
("sensor", {"center": (1200.0, 0.5, 0.0), "radius": 5.0}),
|
||||
("sensor", {"center": (1200.0, -39.5, 0.0), "radius": 5.0}),
|
||||
]
|
||||
assert spinups == [(sim, 1200)]
|
||||
rows, buffer = acquire_v5._collect_target(runtime, tmp_path, 800)
|
||||
assert sim.runs == [(800, {"zero_obs": True, "sync_obs": True})] * 1000
|
||||
assert len(rows) == 250
|
||||
assert buffer["ux"].shape == buffer["uy"].shape == (250, 2, 3)
|
||||
assert np.allclose(buffer["ux"][0], 1.0)
|
||||
assert not list(tmp_path.glob("boundary_*.npz"))
|
||||
assert np.allclose(rows[0]["sensors"], [1.1, 1.2, 2.1, 2.2, 3.1, 3.2])
|
||||
for name in ("forces", "action_normalized", "commanded_target_omega",
|
||||
"effective_smoothed_omega"):
|
||||
assert np.all(np.isnan(rows[0][name]))
|
||||
for name in ("reward_raw", "cd", "cl", "r_cd", "r_cl", "r_sim", "floor_pen",
|
||||
"native_reward_dtw"):
|
||||
assert np.isnan(rows[0][name])
|
||||
runtime.close()
|
||||
assert sim.closed
|
||||
|
||||
|
||||
def test_finalize_converts_sensors_only_for_dtw(tmp_path, monkeypatch):
|
||||
captured = {}
|
||||
n = 150
|
||||
times = np.arange(n, dtype=float)
|
||||
sensors = np.column_stack([np.sin(2 * np.pi * times / 30 + i) for i in range(6)])
|
||||
rows = []
|
||||
for i in range(n):
|
||||
rows.append({"physical_time": float(i), "lattice_step": i * 800,
|
||||
"control_index": i + 1, "sensors": sensors[i], "forces": np.ones(6),
|
||||
"action_normalized": np.zeros(3), "commanded_target_omega": np.zeros(3),
|
||||
"effective_smoothed_omega": np.zeros(3), "reward_raw": 1.0,
|
||||
"cd": 1.0, "cl": 1.0, "r_cd": 1.0, "r_cl": 1.0, "r_sim": 1.0,
|
||||
"floor_pen": 0.0, "native_reward_dtw": 1.0})
|
||||
scratch_root = tmp_path / "scratch"
|
||||
scratch = scratch_root / "candidate"
|
||||
scratch.mkdir(parents=True)
|
||||
fields = {
|
||||
"ux": np.ones((n, 2, 3), dtype=np.float32),
|
||||
"uy": np.ones((n, 2, 3), dtype=np.float32),
|
||||
}
|
||||
identity = tmp_path / "identity"
|
||||
identity.write_bytes(b"read-only")
|
||||
bundle = {"target_states": sensors * 7.0, "model_path": identity,
|
||||
"vecnormalize_path": identity, "config_path": identity}
|
||||
original = acquire_v5.dual_cycle_dtw
|
||||
|
||||
def observe(target, state, native, **kwargs):
|
||||
captured["state"] = state.copy()
|
||||
captured["lag_channel"] = kwargs["lag_channel"]
|
||||
return original(target, state, native, **kwargs)
|
||||
|
||||
monkeypatch.setattr(acquire_v5, "dual_cycle_dtw", observe)
|
||||
monkeypatch.setattr(acquire_v5.infer_train, "_file_identity", lambda path: {"path": str(path)})
|
||||
monkeypatch.setattr(acquire_v5.infer_train, "_bundle_metadata", lambda bundle: {})
|
||||
case = type("Case", (), {"case_id": "kar_re100", "si": 800})()
|
||||
acquire_v5._finalize(tmp_path, scratch, rows, fields, bundle,
|
||||
{"resolved_output_root": tmp_path}, 7.0, "zero", [],
|
||||
case=case, seed=45, cycle_length=30)
|
||||
assert np.allclose(captured["state"], sensors * 7.0)
|
||||
assert captured["lag_channel"] == 3
|
||||
import json
|
||||
assert json.loads((tmp_path / "dtw_summary.json").read_text())["lag_channel"] == 3
|
||||
assert json.loads((tmp_path / "metadata.json").read_text())["dtw_lag_channel"] == 3
|
||||
with np.load(tmp_path / "timeseries.npz", allow_pickle=False) as saved:
|
||||
assert np.allclose(saved["sensors"], sensors)
|
||||
assert identity.read_bytes() == b"read-only"
|
||||
|
||||
|
||||
def test_collection_failure_cleans_only_transaction_scratch(tmp_path):
|
||||
scratch_root = tmp_path / "scratch"
|
||||
scratch_root.mkdir()
|
||||
scratch = acquire_v5.create_scratch(scratch_root)
|
||||
sibling = tmp_path / "immutable-model.zip"
|
||||
sibling.write_bytes(b"model")
|
||||
(scratch / "partial.npz").write_bytes(b"partial")
|
||||
acquire_v5.cleanup_scratch(scratch, root=scratch_root)
|
||||
assert not scratch.exists()
|
||||
assert sibling.read_bytes() == b"model"
|
||||
|
||||
|
||||
|
||||
def test_acquire_finalize_failure_leaves_no_partial_role(tmp_path, monkeypatch):
|
||||
final_role = tmp_path / "v5" / "karman_re100" / "controlled"
|
||||
sentinel = tmp_path / "immutable-model.zip"
|
||||
sentinel.write_bytes(b"model")
|
||||
storage = {"resolved_output_root": tmp_path, "device": tmp_path.stat().st_dev}
|
||||
bundle = {"model_path": sentinel, "vecnormalize_path": sentinel}
|
||||
monkeypatch.setattr(acquire_v5, "get_case", lambda _: type(
|
||||
"Case", (), {"case_id": "kar_re100", "scene_type": "karman", "si": 800,
|
||||
"seeds": (45,)})())
|
||||
monkeypatch.setattr(acquire_v5.infer_train, "_resolve_seed_artifacts", lambda *_: bundle)
|
||||
monkeypatch.setattr(acquire_v5, "_validate_acquisition_bundle", lambda *_: 30)
|
||||
monkeypatch.setattr(acquire_v5, "_validate_shared_role_identity", lambda *_: None)
|
||||
monkeypatch.setattr(acquire_v5, "_collect_controlled", lambda *_ , **__: ([], []))
|
||||
|
||||
class Env:
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
runtime = lambda *_: (Env(), object(), object(), type("Raw", (), {})())
|
||||
|
||||
def fail(staging, *_, **__):
|
||||
(staging / "timeseries.npz").write_bytes(b"partial")
|
||||
raise RuntimeError("injected finalize failure")
|
||||
|
||||
with pytest.raises(RuntimeError, match="injected finalize failure"):
|
||||
acquire_v5.acquire_controlled(
|
||||
output_root=tmp_path, overwrite=True,
|
||||
storage_validator=lambda **_: storage, runtime_factory=runtime, finalizer=fail,
|
||||
)
|
||||
assert not final_role.exists()
|
||||
case_dir = final_role.parent
|
||||
assert not case_dir.exists() or list(case_dir.iterdir()) == []
|
||||
assert sentinel.read_bytes() == b"model"
|
||||
|
||||
|
||||
|
||||
def test_target_full_finalize_exact_products_and_unavailable_metadata(tmp_path, monkeypatch):
|
||||
n = 150
|
||||
times = np.arange(n, dtype=float)
|
||||
sensors = np.column_stack([np.sin(2 * np.pi * times / 30 + i) for i in range(6)])
|
||||
rows = []
|
||||
for i in range(n):
|
||||
nan3, nan6 = np.full(3, np.nan), np.full(6, np.nan)
|
||||
rows.append({"physical_time": float(i), "lattice_step": i * 800,
|
||||
"control_index": i + 1, "sensors": sensors[i], "forces": nan6,
|
||||
"action_normalized": nan3, "commanded_target_omega": nan3,
|
||||
"effective_smoothed_omega": nan3, "reward_raw": np.nan,
|
||||
"cd": np.nan, "cl": np.nan, "r_cd": np.nan, "r_cl": np.nan,
|
||||
"r_sim": np.nan, "floor_pen": np.nan, "native_reward_dtw": np.nan})
|
||||
role_dir = tmp_path / "role"
|
||||
role_dir.mkdir()
|
||||
scratch = role_dir / "scratch" / "candidate"
|
||||
scratch.mkdir(parents=True)
|
||||
fields = {
|
||||
"ux": np.ones((n, 2, 3), dtype=np.float32),
|
||||
"uy": np.ones((n, 2, 3), dtype=np.float32),
|
||||
}
|
||||
identity = tmp_path / "identity"
|
||||
identity.write_bytes(b"read-only")
|
||||
unavailable = ["forces", "action_normalized", "reward_raw", "native_reward_dtw"]
|
||||
monkeypatch.setattr(acquire_v5.infer_train, "_file_identity", lambda path: {"path": str(path)})
|
||||
monkeypatch.setattr(acquire_v5.infer_train, "_bundle_metadata", lambda bundle: {})
|
||||
acquire_v5._finalize(
|
||||
role_dir, scratch, rows, fields,
|
||||
{"target_states": sensors, "model_path": identity, "vecnormalize_path": identity,
|
||||
"config_path": identity},
|
||||
{"resolved_output_root": tmp_path}, 1.0, "target", unavailable,
|
||||
case=type("Case", (), {"case_id": "kar_re100", "si": 800})(),
|
||||
seed=45, cycle_length=30,
|
||||
)
|
||||
monkeypatch.setattr(acquire_v5, "COLLECT_BOUNDARIES", n)
|
||||
acquire_v5._validate_staged_role(role_dir)
|
||||
expected = {"timeseries.npz", "timeseries.csv", "phase_cycle.npz", "phase_cycle.csv",
|
||||
"phase_fields.npz", "dtw_summary.json", "metadata.json", "identity"}
|
||||
expected.remove("identity")
|
||||
assert {path.name for path in role_dir.iterdir()} == expected
|
||||
import json
|
||||
metadata = json.loads((role_dir / "metadata.json").read_text())
|
||||
summary = json.loads((role_dir / "dtw_summary.json").read_text())
|
||||
assert metadata["role"] == "target" and metadata["seed"] is None
|
||||
assert metadata["unavailable_fields"] == unavailable
|
||||
assert metadata["candidate_field_storage"].startswith("single-role in-memory")
|
||||
assert metadata["phase_smoothing_kernel"] == [0.25, 0.5, 0.25]
|
||||
assert metadata["mean_field_count"] > 0
|
||||
assert summary["native_mean"] is None
|
||||
with np.load(role_dir / "timeseries.npz", allow_pickle=False) as saved:
|
||||
assert np.all(np.isnan(saved["native_reward_dtw"]))
|
||||
assert np.allclose(saved["sensors"], sensors)
|
||||
with np.load(role_dir / "phase_cycle.npz", allow_pickle=False) as saved:
|
||||
assert "sensors_pooled" in saved.files and "reward_raw_mean" in saved.files
|
||||
assert len(saved["sensors_pooled"]) > 0
|
||||
with np.load(role_dir / "phase_fields.npz", allow_pickle=False) as saved:
|
||||
assert set(saved.files) == acquire_v5.PHASE_FIELD_KEYS
|
||||
assert saved["mean_ux"].shape == saved["ux"].shape[1:]
|
||||
assert saved["mean_uy"].shape == saved["uy"].shape[1:]
|
||||
|
||||
def test_cli_enables_all_roles_without_replay_rejection():
|
||||
source = Path(acquire_v5.__file__).read_text()
|
||||
assert 'parser.add_argument("--case", choices=CASE_IDS' in source
|
||||
assert 'parser.add_argument("--seed", type=int)' in source
|
||||
assert 'parser.add_argument("--role", choices=ROLES' in source
|
||||
assert "replay is not implemented" not in source
|
||||
assert "acquire_role(args.role" in source
|
||||
|
||||
|
||||
def test_target_unavailable_summary_is_strict_json(tmp_path):
|
||||
path = tmp_path / "summary.json"
|
||||
acquire_v5._atomic_json(path, {"native_mean": None, "unavailable_fields": ["reward_raw"]})
|
||||
text = path.read_text()
|
||||
assert "NaN" not in text and '"native_mean": null' in text
|
||||
|
||||
|
||||
|
||||
def test_scene_aware_raw_sample_layouts():
|
||||
karman = type("Raw", (), {"_read_obs": lambda self: np.arange(14, dtype=np.float32)})()
|
||||
illusion = type("Raw", (), {"_read_obs": lambda self: np.arange(12, dtype=np.float32)})()
|
||||
assert np.array_equal(acquire_v5._raw_sample(karman, "karman"), np.arange(2, 14))
|
||||
assert np.array_equal(acquire_v5._raw_sample(illusion, "illusion"), np.arange(12))
|
||||
with pytest.raises(ValueError, match="6-sensor/6-force"):
|
||||
acquire_v5._raw_sample(illusion, "karman")
|
||||
|
||||
|
||||
def test_illusion_target_geometry_and_case_si(tmp_path):
|
||||
class Sim:
|
||||
def __init__(self):
|
||||
self.added, self.runs = [], []
|
||||
self._objects = []
|
||||
self.bodies = type("Bodies", (), {
|
||||
"get": lambda owner, index: self._objects[index],
|
||||
"count": property(lambda owner: len(self._objects)),
|
||||
})()
|
||||
self.stepper = type("Stepper", (), {"step_count": 0})()
|
||||
self.lbm_cfg = type("Cfg", (), {"nx": 3, "ny": 2})()
|
||||
def add_body(self, kind, **kwargs):
|
||||
self.added.append((kind, kwargs))
|
||||
body_id = len(self.added) - 1
|
||||
self._objects.append(type("Body", (), {
|
||||
"obj_id": body_id, "_is_sensor": kind == "sensor",
|
||||
})())
|
||||
return body_id
|
||||
def initialize(self): pass
|
||||
def run(self, steps, **kwargs):
|
||||
self.runs.append(steps); self.stepper.step_count += steps
|
||||
def read_sensor(self, sensor_id, normalize=True): return (0.1, 0.2)
|
||||
def get_macroscopic(self):
|
||||
value = np.ones((2, 3), dtype=np.float32)
|
||||
return {name: value for name in ("rho", "ux", "uy")}
|
||||
def close(self): pass
|
||||
sim = Sim()
|
||||
context = type("Context", (), {"push": lambda self: None, "pop": lambda self: None})()
|
||||
sim.ctx = type("Cuda", (), {"_ctx": context})()
|
||||
case = type("Case", (), {"scene_type": "illusion", "target_diam": 1.5})()
|
||||
bundle = {"calibration": {"grid": {"nx": 3, "ny": 2}, "L0": 20.0, "U0": 0.01},
|
||||
"config_path": Path("config.json")}
|
||||
spinups = []
|
||||
runtime = acquire_v5._create_target_runtime(
|
||||
case, bundle, 0, lambda **_: sim,
|
||||
spinup_runner=lambda target_sim, steps: spinups.append((target_sim, steps)),
|
||||
)
|
||||
assert sim.added[0] == ("circle", {"center": (400.0, 0.5, 0.0), "radius": 30.0})
|
||||
assert [item[1]["center"][0] for item in sim.added[1:]] == [600.0] * 3
|
||||
assert spinups == [(sim, 1200)]
|
||||
acquire_v5._collect_target(runtime, tmp_path, 1200)
|
||||
assert sim.runs == [1200] * 1000
|
||||
|
||||
|
||||
def test_bundle_validation_covers_registry_and_fails_before_storage(tmp_path, monkeypatch):
|
||||
target = np.zeros((150, 6), dtype=np.float32)
|
||||
phase = 2 * np.pi * np.arange(150) / 30
|
||||
target[:, 3] = np.sin(phase)
|
||||
config = tmp_path / "config.json"
|
||||
calibration = tmp_path / "calibration.json"
|
||||
config.write_text('{"grid":{"nx":2000,"ny":600},"physics":{"velocity":0.01}}')
|
||||
calibration.write_text('{"SI":800}')
|
||||
case = type("Case", (), {
|
||||
"case_id": "kar_re100", "scene_type": "karman", "si": 800,
|
||||
"seeds": (45,), "target_diam": None, "config_path": config,
|
||||
})()
|
||||
bundle = {"seed": "45", "config_path": config, "calibration_path": calibration,
|
||||
"calibration": {"SI": 800, "U0": 0.01, "grid": {"nx": 2000, "ny": 600}},
|
||||
"target_states": target}
|
||||
assert set(acquire_v5.CYCLE_WINDOWS) == set(acquire_v5.CASE_IDS)
|
||||
assert acquire_v5._validate_acquisition_bundle(case, 45, bundle) == 30
|
||||
calibration.write_text('{"SI":500}')
|
||||
with pytest.raises(ValueError, match="SI"):
|
||||
acquire_v5._validate_acquisition_bundle(case, 45, bundle)
|
||||
|
||||
|
||||
def test_output_paths_seed_qualify_controlled_only(tmp_path, monkeypatch):
|
||||
case = type("Case", (), {"case_id": "kar_re100", "scene_type": "karman",
|
||||
"si": 800, "seeds": (45,), "target_diam": None})()
|
||||
bundle = {"seed": "45"}
|
||||
storage = {"resolved_output_root": tmp_path, "device": tmp_path.stat().st_dev}
|
||||
monkeypatch.setattr(acquire_v5, "get_case", lambda _: case)
|
||||
monkeypatch.setattr(acquire_v5.infer_train, "_resolve_seed_artifacts", lambda *_: bundle)
|
||||
monkeypatch.setattr(acquire_v5, "_validate_acquisition_bundle", lambda *_: 30)
|
||||
monkeypatch.setattr(acquire_v5, "_validate_staged_role", lambda *_: None)
|
||||
monkeypatch.setattr(acquire_v5, "publish_role_output", lambda prepared: prepared["final_role_dir"])
|
||||
class Env:
|
||||
def close(self): pass
|
||||
monkeypatch.setattr(acquire_v5, "_collect_controlled", lambda *_, **__: ([], []))
|
||||
def finalize(role_dir, scratch, *args, **kwargs):
|
||||
acquire_v5.cleanup_scratch(scratch, root=role_dir / "scratch")
|
||||
scratch.parent.rmdir()
|
||||
result = acquire_v5.acquire_role(
|
||||
"controlled", case_id="kar_re100", seed=45, output_root=tmp_path,
|
||||
storage_validator=lambda **_: storage,
|
||||
runtime_factory=lambda *_: (Env(), object(), object(), object()), finalizer=finalize,
|
||||
)
|
||||
assert result == tmp_path / "v5/kar_re100_seed45/controlled"
|
||||
|
||||
|
||||
|
||||
def test_shared_roles_require_seed_invariant_physical_identity(monkeypatch):
|
||||
case = type("Case", (), {"case_id": "kar_re100", "seeds": (41, 42)})()
|
||||
target = np.ones((150, 6), dtype=np.float32)
|
||||
base = {"seed": "41", "calibration": {"SI": 800, "config_path": "old"},
|
||||
"target_states": target, "config_path": Path("config.json")}
|
||||
other = {"seed": "42", "calibration": {"SI": 800, "config_path": "new"},
|
||||
"target_states": target.copy(), "config_path": Path("config.json")}
|
||||
monkeypatch.setattr(acquire_v5, "_validate_acquisition_bundle", lambda *_: 30)
|
||||
monkeypatch.setattr(acquire_v5.infer_train, "_resolve_seed_artifacts", lambda *_: other)
|
||||
acquire_v5._validate_shared_role_identity(case, 41, base)
|
||||
other["target_states"] = target + np.float32(1e-3)
|
||||
with pytest.raises(ValueError, match="different physical target"):
|
||||
acquire_v5._validate_shared_role_identity(case, 41, base)
|
||||
|
||||
|
||||
|
||||
def test_target_runtime_fails_closed_on_body_id_order_and_count():
|
||||
class Bodies:
|
||||
def __init__(self, sim): self.sim = sim
|
||||
@property
|
||||
def count(self): return len(self.sim.objects)
|
||||
def get(self, index): return self.sim.objects[index]
|
||||
class BadSim:
|
||||
def __init__(self):
|
||||
self.objects = []
|
||||
self.bodies = Bodies(self)
|
||||
def add_body(self, kind, **kwargs):
|
||||
body_id = len(self.objects) + 1
|
||||
self.objects.append(type("Body", (), {
|
||||
"obj_id": body_id, "_is_sensor": kind == "sensor",
|
||||
})())
|
||||
return body_id
|
||||
def initialize(self): raise AssertionError("must fail before initialize")
|
||||
case = type("Case", (), {"scene_type": "karman", "target_diam": None})()
|
||||
bundle = {"calibration": {"grid": {"nx": 2000, "ny": 600}, "U0": 0.01,
|
||||
"L0": 20.0}, "config_path": Path("config.json")}
|
||||
with pytest.raises(ValueError, match="body order"):
|
||||
acquire_v5._create_target_runtime(case, bundle, 0, lambda **_: BadSim())
|
||||
|
||||
|
||||
def test_physical_zero_counterbias_shape_and_rollout(tmp_path):
|
||||
class BiasedRaw(FakeRaw):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._cal.update(ACTION_BIAS=[1.5, -3.0, 0.75], ACTION_SCALE=6.0)
|
||||
def _action_to_omega(self, action):
|
||||
action = np.asarray(action, dtype=np.float32).reshape(3)
|
||||
return action * self._cal["ACTION_SCALE"] + np.asarray(
|
||||
self._cal["ACTION_BIAS"], dtype=np.float32)
|
||||
raw = BiasedRaw()
|
||||
vec = FakeVecEnv(raw)
|
||||
actions = []
|
||||
original_step = vec.step
|
||||
def step(action):
|
||||
actions.append(np.asarray(action).copy())
|
||||
return original_step(action)
|
||||
vec.step = step
|
||||
acquire_v5._collect_zero(vec, raw, tmp_path, lambda env: {
|
||||
name: np.ones((2, 3), dtype=np.float32) for name in ("rho", "ux", "uy")
|
||||
})
|
||||
expected = np.array([[-0.25, 0.5, -0.125]], dtype=np.float32)
|
||||
assert actions and all(action.shape == (1, 3) for action in actions)
|
||||
assert all(np.array_equal(action, expected) for action in actions)
|
||||
|
||||
|
||||
def test_acquire_role_propagates_case_seed_si_and_scene(tmp_path, monkeypatch):
|
||||
case = type("Case", (), {"case_id": "ill_1L", "scene_type": "illusion",
|
||||
"si": 1200, "seeds": (43,), "target_diam": 1.0})()
|
||||
bundle = {"seed": "43"}
|
||||
storage = {"resolved_output_root": tmp_path, "device": tmp_path.stat().st_dev}
|
||||
observed = {}
|
||||
monkeypatch.setattr(acquire_v5, "get_case", lambda case_id: case)
|
||||
monkeypatch.setattr(acquire_v5.infer_train, "_resolve_seed_artifacts",
|
||||
lambda selected_case, seed: bundle)
|
||||
monkeypatch.setattr(acquire_v5, "_validate_acquisition_bundle", lambda *args: 19)
|
||||
monkeypatch.setattr(acquire_v5, "_validate_staged_role", lambda *_: None)
|
||||
monkeypatch.setattr(acquire_v5, "publish_role_output", lambda prepared: prepared["final_role_dir"])
|
||||
class Env:
|
||||
def close(self): pass
|
||||
def collect(model, vec, raw, scratch, **kwargs):
|
||||
observed["scene_type"] = kwargs["scene_type"]
|
||||
return [], []
|
||||
monkeypatch.setattr(acquire_v5, "_collect_controlled", collect)
|
||||
def finalize(role_dir, scratch, *args, **kwargs):
|
||||
observed.update(case=kwargs["case"], seed=kwargs["seed"], cycle=kwargs["cycle_length"])
|
||||
acquire_v5.cleanup_scratch(scratch, root=role_dir / "scratch")
|
||||
scratch.parent.rmdir()
|
||||
result = acquire_v5.acquire_role(
|
||||
"controlled", case_id="ill_1L", seed=43, output_root=tmp_path,
|
||||
storage_validator=lambda **_: storage,
|
||||
runtime_factory=lambda selected_case, selected_bundle, device: (
|
||||
Env(), object(), object(), type("Raw", (), {"_dtw_sensor_factor": 78.0})()),
|
||||
finalizer=finalize,
|
||||
)
|
||||
assert observed == {"scene_type": "illusion", "case": case, "seed": 43, "cycle": 19}
|
||||
assert result == tmp_path / "v5/ill_1L_seed43/controlled"
|
||||
Reference in New Issue
Block a user