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:
Frank14f
2026-08-08 15:50:49 +08:00
co-authored by Cursor
parent b144d62920
commit 61e82ec90a
21 changed files with 5022 additions and 574 deletions
+143 -2
View File
@@ -198,6 +198,127 @@ def assign_phase(times, sensors):
return {"phase": phase, "cycle_id": ids, "crossing_times": crossings}
def smooth_center_uy(uy):
"""One-pass three-point binomial smoother ``[1, 2, 1]/4`` with copied endpoints."""
signal = np.asarray(uy, dtype=np.float64)
if signal.ndim != 1 or len(signal) < 1 or not np.all(np.isfinite(signal)):
raise ValueError("center uy must be a finite one-dimensional array")
if len(signal) == 1:
return signal.copy()
if len(signal) == 2:
return signal.copy()
out = np.empty_like(signal)
out[0] = signal[0]
out[-1] = signal[-1]
out[1:-1] = 0.25 * signal[:-2] + 0.5 * signal[1:-1] + 0.25 * signal[2:]
return out
def _filter_crossings_by_minimum_interval(crossings, minimum_interval):
crossings = np.asarray(crossings, dtype=np.float64)
if crossings.ndim != 1 or len(crossings) < 1 or not np.all(np.isfinite(crossings)):
raise ValueError("crossing times must be finite and one-dimensional")
if type(minimum_interval) not in (int, float) or not np.isfinite(minimum_interval) or minimum_interval < 0:
raise ValueError("minimum_interval must be a non-negative finite number")
if minimum_interval == 0 or len(crossings) == 1:
return crossings.copy(), np.asarray([], dtype=np.float64)
accepted = [float(crossings[0])]
rejected = []
for value in crossings[1:]:
if float(value) - accepted[-1] < float(minimum_interval):
rejected.append(float(value))
else:
accepted.append(float(value))
return np.asarray(accepted, dtype=np.float64), np.asarray(rejected, dtype=np.float64)
def assign_periodic_phase(times, sensors, *, minimum_crossing_interval):
"""Standard periodic phase from smoothed center uy with minimum crossing gap.
Leaves :func:`assign_phase` unchanged for Erase/special callers. Uses one-pass
``[1,2,1]/4`` smoothing, then interpolated rising zero crossings, then drops
crossings closer than ``minimum_crossing_interval`` to the previous accepted one.
"""
time, sensor = _times(times, "times"), np.asarray(sensors)
if sensor.ndim != 2 or sensor.shape[0] != len(time) or sensor.shape[1] < 4:
raise ValueError("sensors must have shape (time, >=4)")
raw = np.asarray(sensor[:, 3], dtype=np.float64)
if not np.all(np.isfinite(raw)):
raise ValueError("center uy must be finite")
smoothed = smooth_center_uy(raw)
left = np.flatnonzero((smoothed[:-1] <= 0.0) & (smoothed[1:] > 0.0))
if len(left) < 2:
raise ValueError("at least two rising crossings are required")
weight = -smoothed[left] / (smoothed[left + 1] - smoothed[left])
raw_crossings = time[left] + weight * (time[left + 1] - time[left])
crossings, rejected = _filter_crossings_by_minimum_interval(
raw_crossings, minimum_crossing_interval
)
if len(crossings) < 2:
raise ValueError("at least two accepted rising crossings are required")
cycle = np.searchsorted(crossings, time, side="right") - 1
valid = (cycle >= 0) & (cycle < len(crossings) - 1) & (time < crossings[-1])
ids = np.full(len(time), -1, dtype=np.int64)
phase = np.full(len(time), np.nan, dtype=np.float64)
ids[valid] = cycle[valid]
start = crossings[cycle[valid]]
phase[valid] = TAU * (time[valid] - start) / (crossings[cycle[valid] + 1] - start)
return {
"phase": phase,
"cycle_id": ids,
"crossing_times": crossings,
"raw_crossing_times": np.asarray(raw_crossings, dtype=np.float64),
"rejected_crossing_times": rejected,
"smoothed_center_uy": smoothed,
"smoothing_kernel": [0.25, 0.5, 0.25],
"minimum_crossing_interval": float(minimum_crossing_interval),
"accepted_crossing_count": int(len(crossings)),
"rejected_crossing_count": int(len(rejected)),
"complete_cycle_count": int(len(crossings) - 1),
}
def complete_cycle_field_indices(field_times, crossing_times):
"""Return indices with ``t_first <= field_time < t_last`` for accepted crossings."""
times = _times(field_times, "field_times")
crossings = _times(crossing_times, "crossing_times")
if len(crossings) < 2:
raise ValueError("at least two accepted rising crossings are required")
first, last = float(crossings[0]), float(crossings[-1])
indices = np.flatnonzero((times >= first) & (times < last)).astype(np.int64)
if len(indices) < 1:
raise ValueError("no field boundaries fall within complete cycles")
return {
"field_indices": indices,
"first_crossing_time": first,
"last_crossing_time": last,
"complete_cycle_count": int(len(crossings) - 1),
"mean_field_count": int(len(indices)),
}
def accumulate_mean_fields(ux, uy, field_indices):
"""Arithmetic mean over complete-cycle boundary fields; returns 2-D arrays."""
ux = np.asarray(ux)
uy = np.asarray(uy)
indices = np.asarray(field_indices, dtype=np.int64)
if ux.ndim != 3 or uy.shape != ux.shape or ux.dtype.kind not in "iuf" or uy.dtype.kind not in "iuf":
raise ValueError("ux/uy must be numeric arrays with shape (n, ny, nx)")
if indices.ndim != 1 or len(indices) < 1:
raise ValueError("field_indices must be a non-empty one-dimensional array")
if np.any(indices < 0) or np.any(indices >= ux.shape[0]):
raise ValueError("field_indices out of range")
selected_ux = np.asarray(ux[indices], dtype=np.float64)
selected_uy = np.asarray(uy[indices], dtype=np.float64)
if not np.all(np.isfinite(selected_ux)) or not np.all(np.isfinite(selected_uy)):
raise ValueError("mean fields require finite selected samples")
return {
"mean_ux": selected_ux.mean(axis=0).astype(np.float32, copy=False),
"mean_uy": selected_uy.mean(axis=0).astype(np.float32, copy=False),
"mean_field_count": int(len(indices)),
}
def select_phase_fields(field_times, crossing_times, phases=EIGHT_PHASES):
"""Select one boundary globally for each target phase across complete cycles."""
times = _times(field_times, "field_times")
@@ -461,8 +582,28 @@ def write_folded_artifacts(npz_path, csv_path, folded):
def publish_selected_fields(destination, arrays):
"""Atomic no-replace NPZ publication at a caller-supplied path."""
data, _ = _arrays(arrays)
"""Atomic no-replace NPZ publication at a caller-supplied path.
Stacked phase/event snapshots must share a common first dimension. Optional
complete-cycle ``mean_ux``/``mean_uy`` are stored as plain 2-D fields whose
shape matches the stacked spatial trailing dimensions.
"""
raw = {name: np.asarray(value) for name, value in arrays.items()}
means = {}
for name in ("mean_ux", "mean_uy"):
if name in raw:
means[name] = raw.pop(name)
data, _ = _arrays(raw)
if means:
spatial = data["ux"].shape[1:] if "ux" in data else data["uy"].shape[1:]
for name, value in means.items():
if value.ndim != 2 or value.dtype.kind not in "iuf":
raise ValueError(f"{name} must be a numeric two-dimensional array")
if value.shape != spatial:
raise ValueError(f"{name} spatial shape must match stacked fields")
if not np.all(np.isfinite(value)):
raise ValueError(f"{name} must be finite")
data.update(means)
target = Path(destination)
target.parent.mkdir(parents=True, exist_ok=True)
if target.exists():
+89
View File
@@ -0,0 +1,89 @@
"""Canonical registry for active V5 scratch-training cases.
Canonical case IDs do not encode the training method. Historical transfer
(``_tr``) names are archive-only and are intentionally absent here.
"""
from __future__ import annotations
import argparse
from dataclasses import dataclass
from pathlib import Path
from typing import Mapping
REPO_ROOT = Path(__file__).resolve().parents[2]
TRAIN_DIR = REPO_ROOT / "src" / "drl_pinball" / "train"
CONFIG_DIR = REPO_ROOT / "configs"
@dataclass(frozen=True)
class CaseSpec:
case_id: str
config: str
calibration: str
trainer: str
si: int
seeds: tuple[int, ...]
scene_type: str
target_diam: float | None = None
@property
def config_path(self) -> Path:
return CONFIG_DIR / self.config
@property
def calibration_path(self) -> Path:
return TRAIN_DIR / "calibrations" / self.calibration / "calibration.json"
@property
def trainer_path(self) -> Path:
return TRAIN_DIR / self.trainer
def model_dir(self, seed: int) -> Path:
return TRAIN_DIR / "output" / f"{self.case_id}_seed{seed}" / "models"
_CASES = (
CaseSpec("kar_re100", "config_lbm_karman_2000x600.json", "kar_re100", "train_karman.py", 800, (41, 42, 43, 44, 45), "karman"),
CaseSpec("kar_d075", "config_lbm_karman_2000x600.json", "kar_d075", "train_karman.py", 800, (44,), "karman"),
CaseSpec("kar_d15", "config_lbm_karman_2000x600.json", "kar_d15", "train_karman.py", 800, (45,), "karman"),
CaseSpec("kar_d2", "config_lbm_karman_2000x600.json", "kar_d2", "train_karman.py", 800, (45,), "karman"),
CaseSpec("ill_075L", "config_lbm_karman_2000x600.json", "ill_075L", "train_illusion.py", 1100, (43,), "illusion", 0.75),
CaseSpec("ill_1L", "config_lbm_karman_2000x600.json", "ill_1L", "train_illusion.py", 1200, (43,), "illusion", 1.0),
CaseSpec("ill_15L", "config_lbm_karman_2000x600.json", "ill_15L", "train_illusion.py", 1200, (43,), "illusion", 1.5),
CaseSpec("ill_2L", "config_lbm_karman_2000x600.json", "ill_2L", "train_illusion.py", 1200, (43,), "illusion", 2.0),
CaseSpec("kar_re60", "config_lbm_karman_2000x600_re60.json", "kar_re60", "train_karman.py", 800, (43,), "karman"),
CaseSpec("kar_re200", "config_lbm_karman_2000x600_re200.json", "kar_re200", "train_karman.py", 500, (43,), "karman"),
CaseSpec("kar_re400", "config_lbm_karman_2000x600_re400.json", "kar_re400", "train_karman.py", 400, (43,), "karman"),
)
CASE_REGISTRY: Mapping[str, CaseSpec] = {case.case_id: case for case in _CASES}
CASE_IDS = tuple(CASE_REGISTRY)
def get_case(case_id: str) -> CaseSpec:
try:
return CASE_REGISTRY[case_id]
except KeyError as exc:
raise KeyError(f"Unknown canonical V5 case {case_id!r}; expected one of {CASE_IDS}") from exc
def main() -> int:
parser = argparse.ArgumentParser(description="Query the canonical V5 case registry")
parser.add_argument("case_id", choices=CASE_IDS)
parser.add_argument("field", choices=("config", "calibration", "trainer", "si", "seeds", "scene_type"))
args = parser.parse_args()
case = get_case(args.case_id)
values = {
"config": case.config_path,
"calibration": case.calibration_path,
"trainer": case.trainer_path,
"si": case.si,
"seeds": ",".join(map(str, case.seeds)),
"scene_type": case.scene_type,
}
print(values[args.field])
return 0
if __name__ == "__main__":
raise SystemExit(main())
+560
View File
@@ -0,0 +1,560 @@
"""V5 controlled-role phase acquisition; CFD imports remain lazy."""
from __future__ import annotations
import argparse
import json
import os
import shutil
from pathlib import Path
from typing import Any, Callable
import numpy as np
from drl_pinball.acquisition import (
accumulate_mean_fields, assign_periodic_phase, cleanup_scratch,
complete_cycle_field_indices, create_scratch, default_reproduction_mapping,
dual_cycle_dtw, pooled_phase_bins, prepare_role_output, publish_role_output,
publish_selected_fields,
select_phase_fields, validate_modern_fields, validate_output_storage,
write_boundary_artifacts, write_phase_cycle_artifacts,
)
from drl_pinball.case_registry import CASE_IDS, get_case
from drl_pinball.eval import infer_train
ROLES = ("controlled", "target", "zero")
WARMUP_CONTROL_STEPS, COLLECT_BOUNDARIES = 750, 250
# Reviewed from run-local target center-sensor uy crossings; this controls only
# the rolling DTW window. Phase itself always comes from measured crossings.
CYCLE_WINDOWS = {
"kar_re100": 30, "kar_d075": 24, "kar_d15": 39, "kar_d2": 47,
"ill_075L": 18, "ill_1L": 19, "ill_15L": 26, "ill_2L": 31,
"kar_re60": 39, "kar_re200": 40, "kar_re400": 45,
}
CYCLE_WINDOW_REL_TOL = 0.15
MIN_FREE_BYTES = 4 * 1024**3
def _atomic_json(path: Path, payload: dict[str, Any]) -> None:
temporary = path.with_name(f".{path.name}.tmp")
with temporary.open("w", encoding="utf-8") as stream:
json.dump(payload, stream, indent=2, allow_nan=False)
stream.write("\n")
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, path)
def _unwrap_step(step_result):
obs, reward, done, info = step_result
item = info[0] if isinstance(info, (list, tuple)) else info
ended = bool(np.asarray(done).reshape(-1)[0])
return obs, float(np.asarray(reward).reshape(-1)[0]), ended, item
def _raw_sample(raw_env, scene_type):
raw = np.asarray(raw_env._read_obs(), dtype=np.float32)
sample = raw[2:14] if scene_type == "karman" else raw[0:12]
if raw.ndim != 1 or sample.shape != (12,) or not np.all(np.isfinite(sample)):
raise ValueError(f"{scene_type} raw observation must expose finite 6-sensor/6-force data")
return sample
def _raw_boundary(raw_env, scene_type, action, reward, info, control_index):
sample = _raw_sample(raw_env, scene_type)
target_omega = np.asarray(raw_env._action_to_omega(action), dtype=np.float32).reshape(3)
effective = np.asarray(raw_env.smoother._state, dtype=np.float32).reshape(3).copy()
lattice = int(raw_env.sim.stepper.step_count)
return {
"physical_time": lattice * float(raw_env._cal["U0"]) / int(raw_env._cal["grid"]["nx"]),
"lattice_step": lattice, "control_index": int(control_index),
"sensors": sample[:6].copy(), "forces": sample[6:12].copy(),
"action_normalized": np.asarray(action, dtype=np.float32).reshape(3).copy(),
"commanded_target_omega": target_omega, "effective_smoothed_omega": effective,
"reward_raw": float(reward), "cd": float(info.get("cd", np.nan)),
"cl": float(info.get("cl", np.nan)), "r_cd": float(info.get("r_cd", np.nan)),
"r_cl": float(info.get("r_cl", np.nan)), "r_sim": float(info.get("r_sim", np.nan)),
"floor_pen": float(info.get("floor_pen", np.nan)),
"native_reward_dtw": float(info["sim"]),
}
def _capture_fields(raw_env):
holder = {}
raw_env._gpu_block(lambda: holder.update(raw_env.sim.get_macroscopic()))
nx, ny = int(raw_env.sim.lbm_cfg.nx), int(raw_env.sim.lbm_cfg.ny)
return validate_modern_fields(
{name: np.asarray(holder[name], dtype=np.float32) for name in ("rho", "ux", "uy")},
nx=nx, ny=ny,
)
def _collect_controlled(model, vec_env, raw_env, scratch: Path,
capture_fields: Callable = _capture_fields, *, scene_type="karman"):
obs = vec_env.reset()
for step in range(WARMUP_CONTROL_STEPS):
action, _ = model.predict(obs, deterministic=True)
obs, _, ended, _ = _unwrap_step(vec_env.step(action))
if ended:
raise RuntimeError(f"controlled acquisition terminated during warmup at step {step + 1}")
rows = []
field_buffer = None
for boundary in range(COLLECT_BOUNDARIES):
action, _ = model.predict(obs, deterministic=True)
obs, reward, ended, info = _unwrap_step(vec_env.step(action))
if ended:
raise RuntimeError(f"controlled acquisition terminated at boundary {boundary}")
rows.append(_raw_boundary(raw_env, scene_type, action, reward, info, WARMUP_CONTROL_STEPS + boundary + 1))
fields = capture_fields(raw_env)
if field_buffer is None:
field_buffer = _allocate_field_buffer(fields["ux"].shape)
_store_field_candidate(field_buffer, boundary, fields)
return rows, field_buffer
def _allocate_field_buffer(shape_yx):
ny, nx = map(int, shape_yx)
return {
"ux": np.empty((COLLECT_BOUNDARIES, ny, nx), dtype=np.float32),
"uy": np.empty((COLLECT_BOUNDARIES, ny, nx), dtype=np.float32),
}
def _store_field_candidate(field_buffer, boundary: int, fields):
field_buffer["ux"][boundary] = np.asarray(fields["ux"], dtype=np.float32)
field_buffer["uy"][boundary] = np.asarray(fields["uy"], dtype=np.float32)
def _physical_zero_action(raw_env):
bias = np.asarray(raw_env._cal["ACTION_BIAS"], dtype=np.float32)
scale = float(raw_env._cal["ACTION_SCALE"])
if bias.shape != (3,) or not np.all(np.isfinite(bias)) or not np.isfinite(scale) or scale <= 0:
raise ValueError("ACTION_BIAS/ACTION_SCALE must define a finite three-action mapping")
action = (-bias / np.float32(scale)).reshape(1, 3)
if np.any(action < -1.0) or np.any(action > 1.0):
raise ValueError("physical-zero counterbias falls outside normalized action bounds")
omega = np.asarray(raw_env._action_to_omega(action), dtype=np.float64).reshape(3)
if not np.all(np.isfinite(omega)) or not np.allclose(omega, 0.0, rtol=0.0, atol=1e-12):
raise ValueError("normalized counterbias does not produce physical zero omega")
return action
def _collect_zero(vec_env, raw_env, scratch: Path,
capture_fields: Callable = _capture_fields, *, scene_type="karman"):
obs = vec_env.reset()
zero = _physical_zero_action(raw_env)
for step in range(WARMUP_CONTROL_STEPS):
obs, _, ended, _ = _unwrap_step(vec_env.step(zero))
if ended:
raise RuntimeError(f"zero acquisition terminated during warmup at step {step + 1}")
rows = []
field_buffer = None
for boundary in range(COLLECT_BOUNDARIES):
obs, reward, ended, info = _unwrap_step(vec_env.step(zero))
if ended:
raise RuntimeError(f"zero acquisition terminated at boundary {boundary}")
rows.append(_raw_boundary(raw_env, scene_type, zero, reward, info,
WARMUP_CONTROL_STEPS + boundary + 1))
fields = capture_fields(raw_env)
if field_buffer is None:
field_buffer = _allocate_field_buffer(fields["ux"].shape)
_store_field_candidate(field_buffer, boundary, fields)
return rows, field_buffer
class _TargetRuntime:
def __init__(self, sim, sensor_ids, calibration):
self.sim, self.sensor_ids, self.calibration = sim, sensor_ids, calibration
def close(self):
self.sim.close()
def _create_target_runtime(case, bundle, device_id, simulation_factory=None,
spinup_runner=None):
if simulation_factory is None:
from CelerisLab import Simulation
simulation_factory = Simulation
cal = bundle["calibration"]
nx, ny = int(cal["grid"]["nx"]), int(cal["grid"]["ny"])
center_y = (ny - 1) / 2.0
l0 = float(cal.get("L0", 20.0))
sim = simulation_factory(lbm_config_path=str(bundle["config_path"]), device_id=device_id)
sim._assert_object_count_contract = lambda *args, **kwargs: None
if case.scene_type == "karman":
body_x, sensor_x = 600.0, 1200.0
radius = float(cal.get("dist_radius", 1.0)) * l0
else:
body_x, sensor_x = 400.0, 600.0
radius = float(case.target_diam) * l0
if not np.isfinite(radius) or radius <= 0:
raise ValueError("target radius must be finite and positive")
cylinder_id = sim.add_body("circle", center=(body_x, center_y, 0.0), radius=radius)
sensors = [sim.add_body("sensor", center=(sensor_x, center_y + dy, 0.0), radius=5.0)
for dy in (40.0, 0.0, -40.0)]
if cylinder_id != 0 or sensors != [1, 2, 3] or sim.bodies.count != 4:
raise ValueError("target body order must be cylinder id0 then sensor ids1..3")
cylinder = sim.bodies.get(0)
sensor_bodies = [sim.bodies.get(index) for index in sensors]
if bool(getattr(cylinder, "_is_sensor", True)) or any(
not bool(getattr(body, "_is_sensor", False)) for body in sensor_bodies):
raise ValueError("target body types do not match cylinder/sensor order")
sim.initialize()
if sim.bodies.count != 4 or [sim.bodies.get(i).obj_id for i in range(4)] != list(range(4)):
raise ValueError("initialized target body registry changed id/order/count")
spinup_steps = int(4 * nx / float(cal["U0"]))
if spinup_steps <= 0:
raise ValueError("target physical spin-up must be positive")
if spinup_runner is None:
sim.run(spinup_steps, zero_obs=True)
else:
spinup_runner(sim, spinup_steps)
if sim.bodies.count != 4:
raise ValueError("target body count changed during physical spin-up")
return _TargetRuntime(sim, sensors, cal)
def _capture_target_fields(runtime):
holder = {}
runtime.sim.ctx._ctx.push()
try:
holder.update(runtime.sim.get_macroscopic())
finally:
runtime.sim.ctx._ctx.pop()
nx, ny = int(runtime.sim.lbm_cfg.nx), int(runtime.sim.lbm_cfg.ny)
return validate_modern_fields(
{name: np.asarray(holder[name], dtype=np.float32) for name in ("rho", "ux", "uy")},
nx=nx, ny=ny,
)
def _target_boundary(runtime, control_index):
sensor = []
for sensor_id in runtime.sensor_ids:
value = runtime.sim.read_sensor(sensor_id, normalize=True)
sensor.extend((float(value[0]), float(value[1])))
lattice = int(runtime.sim.stepper.step_count)
nan3, nan6 = np.full(3, np.nan, dtype=np.float32), np.full(6, np.nan, dtype=np.float32)
return {
"physical_time": lattice * float(runtime.calibration["U0"]) / int(runtime.calibration["grid"]["nx"]),
"lattice_step": lattice, "control_index": int(control_index),
"sensors": np.asarray(sensor, dtype=np.float32), "forces": nan6,
"action_normalized": nan3, "commanded_target_omega": nan3.copy(),
"effective_smoothed_omega": nan3.copy(), "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,
}
def _collect_target(runtime, scratch: Path, si: int,
capture_fields: Callable = _capture_target_fields):
for _ in range(WARMUP_CONTROL_STEPS):
runtime.sim.run(si, zero_obs=True, sync_obs=True)
rows = []
field_buffer = None
for boundary in range(COLLECT_BOUNDARIES):
runtime.sim.run(si, zero_obs=True, sync_obs=True)
rows.append(_target_boundary(runtime, WARMUP_CONTROL_STEPS + boundary + 1))
fields = capture_fields(runtime)
if field_buffer is None:
field_buffer = _allocate_field_buffer(fields["ux"].shape)
_store_field_candidate(field_buffer, boundary, fields)
return rows, field_buffer
def _columns(rows):
return {name: np.asarray([row[name] for row in rows]) for name in rows[0]}
def _phase_values(columns):
excluded = {"physical_time", "lattice_step", "control_index", "phase", "cycle_id"}
return {name: value for name, value in columns.items() if name not in excluded}
def _finite_mean_or_none(values):
values = np.asarray(values, dtype=np.float64)
finite = values[np.isfinite(values)]
return None if not len(finite) else float(np.mean(finite))
def _minimum_crossing_interval(physical_time, cycle_length):
times = np.asarray(physical_time, dtype=np.float64)
if len(times) < 2:
raise ValueError("physical_time must contain at least two boundaries")
dt = float(np.median(np.diff(times)))
if not np.isfinite(dt) or dt <= 0:
raise ValueError("physical_time spacing must be positive and finite")
return 0.5 * float(cycle_length) * dt
PHASE_FIELD_KEYS = {
"ux", "uy", "mean_ux", "mean_uy", "field_indices", "cycle_id",
"target_phase", "actual_phase", "phase_error",
}
def _finalize(role_dir: Path, scratch: Path, rows, field_buffer, bundle, storage,
sensor_dtw_factor: float, role: str, unavailable_fields, *, case, seed,
cycle_length):
columns = _columns(rows)
dtw = dual_cycle_dtw(
bundle["target_states"], columns["sensors"] * sensor_dtw_factor,
columns["native_reward_dtw"], cycle_length=cycle_length, lag_channel=3,
)
columns.update(target_normalized_dtw=dtw["target_normalized_dtw"],
target_normalized_dtw_lag=dtw["target_normalized_dtw_lag"])
min_gap = _minimum_crossing_interval(columns["physical_time"], cycle_length)
phase = assign_periodic_phase(
columns["physical_time"], columns["sensors"], minimum_crossing_interval=min_gap,
)
if len(phase["crossing_times"]) < 4:
raise ValueError("at least three complete center-uy cycles are required")
columns.update(phase=phase["phase"], cycle_id=phase["cycle_id"])
write_boundary_artifacts(role_dir / "timeseries.npz", role_dir / "timeseries.csv", columns)
phase_values = _phase_values(columns)
write_phase_cycle_artifacts(
role_dir / "phase_cycle.npz", role_dir / "phase_cycle.csv",
pooled_phase_bins(phase["phase"], phase_values, bins=32),
)
selected = select_phase_fields(columns["physical_time"], phase["crossing_times"])
mean_span = complete_cycle_field_indices(columns["physical_time"], phase["crossing_times"])
mean = accumulate_mean_fields(field_buffer["ux"], field_buffer["uy"], mean_span["field_indices"])
published = {
"ux": np.asarray(field_buffer["ux"][selected["field_indices"]], dtype=np.float32),
"uy": np.asarray(field_buffer["uy"][selected["field_indices"]], dtype=np.float32),
"mean_ux": mean["mean_ux"],
"mean_uy": mean["mean_uy"],
}
published.update(selected)
field_path = publish_selected_fields(role_dir / "phase_fields.npz", published)
with np.load(field_path, allow_pickle=False) as saved:
if (saved["ux"].shape[0] != 8 or saved["mean_ux"].shape != saved["ux"].shape[1:]
or saved["mean_uy"].shape != saved["uy"].shape[1:]
or set(saved.files) != PHASE_FIELD_KEYS):
raise ValueError("published phase fields failed exact-key validation")
_atomic_json(role_dir / "dtw_summary.json", {
"native_pipeline": ("unavailable for target role" if role == "target" else
"info[sim] copied exactly at each post-step boundary"),
"target_normalized_dtw": dtw["metadata"]["definition"],
"cycle_length": cycle_length, "lag_channel": 3,
"target_scale": dtw["metadata"]["scale"].tolist(),
"native_mean": _finite_mean_or_none(columns["native_reward_dtw"]),
"target_normalized_dtw_finite_mean": _finite_mean_or_none(columns["target_normalized_dtw"]),
})
cleanup_scratch(scratch, root=role_dir / "scratch")
scratch.parent.rmdir()
_atomic_json(role_dir / "metadata.json", {
"schema": "drl-pinball-v5-acquisition-v2", "case_id": case.case_id, "role": role,
"seed": (seed if role == "controlled" else None), "warmup_control_steps": WARMUP_CONTROL_STEPS,
"collected_post_step_boundaries": COLLECT_BOUNDARIES, "si": case.si,
"cycle_window": cycle_length, "cycle_window_source": "reviewed explicit CYCLE_WINDOWS mapping",
"units": {"physical_time": "tU0/NX",
"lattice_step": "completed solver lattice steps",
"control_index": "one-based completed policy boundary index",
"sensors": "raw Celeris normalized sensor velocity", "forces": "raw Celeris normalized force",
"action_normalized": "dimensionless policy action [-1,1]",
"commanded_target_omega": "lattice radians per lattice time",
"effective_smoothed_omega": "lattice radians per lattice time",
"reward_raw": "native environment scalar", "dtw": "dimensionless similarity",
"ux": "lattice velocity", "uy": "lattice velocity"},
"phase_contract": "smoothed rising crossings of sensors[:,3] with min-gap filter; complete half-open cycles",
"phase_smoothing_kernel": phase["smoothing_kernel"],
"minimum_crossing_interval": phase["minimum_crossing_interval"],
"accepted_crossing_count": phase["accepted_crossing_count"],
"rejected_crossing_count": phase["rejected_crossing_count"],
"complete_cycle_count": phase["complete_cycle_count"],
"mean_field_count": mean_span["mean_field_count"],
"mean_first_crossing_time": mean_span["first_crossing_time"],
"mean_last_crossing_time": mean_span["last_crossing_time"],
"dtw_contract": "native info[sim] preserved; separate target-max-abs-normalized rolling six-channel DTW",
"dtw_lag_channel": 3,
"phase_variables": list(phase_values), "sensor_dtw_factor": sensor_dtw_factor,
"sensor_dtw_conversion_contract": "persist raw native sensors; multiply only the in-memory DTW input",
"model": (infer_train._file_identity(bundle["model_path"]) if role == "controlled" else None),
"normalizer": (infer_train._file_identity(bundle["vecnormalize_path"]) if role == "controlled" else None),
"artifact_resolution": infer_train._bundle_metadata(bundle),
"config": infer_train._file_identity(bundle["config_path"]),
"resolved_optane_path": str(storage["resolved_output_root"]),
"scratch_cleanup": {"complete": not scratch.exists(), "path": str(scratch)},
"role_semantics": ({
"kind": "physical-zero/uncontrolled",
"normalized_action": "counterbias -ACTION_BIAS/ACTION_SCALE",
"commanded_omega": "validated numerically zero before rollout",
} if role == "zero" else ({
"kind": "target disturbance-cylinder trajectory",
} if role == "target" else {"kind": "frozen-policy controlled trajectory"})),
"unavailable_fields": list(unavailable_fields),
"deliberately_not_persisted_fields": ["rho (returned by get_macroscopic and used only for in-memory field validation)"],
"candidate_field_storage": "single-role in-memory FP32 ux/uy buffer; no boundary_*.npz scratch",
})
def _validate_staged_role(role_dir: Path) -> None:
expected = {"timeseries.npz", "timeseries.csv", "phase_cycle.npz", "phase_cycle.csv",
"phase_fields.npz", "dtw_summary.json", "metadata.json"}
actual = {path.name for path in role_dir.iterdir()}
if actual != expected:
raise ValueError(f"staged role files mismatch: expected {sorted(expected)}, got {sorted(actual)}")
with np.load(role_dir / "timeseries.npz", allow_pickle=False) as saved:
expected_series = {
"physical_time", "lattice_step", "control_index", "sensors", "forces",
"action_normalized", "commanded_target_omega", "effective_smoothed_omega",
"reward_raw", "cd", "cl", "r_cd", "r_cl", "r_sim", "floor_pen",
"native_reward_dtw", "target_normalized_dtw",
"target_normalized_dtw_lag", "phase", "cycle_id",
}
if set(saved.files) != expected_series or saved["sensors"].shape != (COLLECT_BOUNDARIES, 6):
raise ValueError("timeseries exact schema failed")
with np.load(role_dir / "phase_fields.npz", allow_pickle=False) as saved:
if (set(saved.files) != PHASE_FIELD_KEYS or saved["ux"].shape[0] != 8
or saved["uy"].shape != saved["ux"].shape
or saved["mean_ux"].shape != saved["ux"].shape[1:]
or saved["mean_uy"].shape != saved["uy"].shape[1:]):
raise ValueError("phase fields exact schema failed")
for name in ("dtw_summary.json", "metadata.json"):
with (role_dir / name).open(encoding="utf-8") as stream:
json.load(stream)
def _validate_acquisition_bundle(case, seed, bundle):
if set(CYCLE_WINDOWS) != set(CASE_IDS):
raise ValueError("reviewed cycle-window mapping must exactly cover the case registry")
if case.scene_type not in {"karman", "illusion"}:
raise ValueError(f"unsupported V5 scene type {case.scene_type!r}")
if seed not in case.seeds or int(bundle["seed"]) != seed:
raise ValueError("resolved bundle seed does not match the registered request")
if Path(bundle["config_path"]).resolve() != case.config_path.resolve():
raise ValueError("resolved config is not the registry config")
calibration = bundle["calibration"]
source_calibration = infer_train._load_json(bundle["calibration_path"])
if int(calibration.get("SI", -1)) != case.si or int(source_calibration.get("SI", -1)) != case.si:
raise ValueError("run-local and resolved SI must match the case registry")
grid = calibration.get("grid")
if not isinstance(grid, dict) or int(grid.get("nx", 0)) <= 0 or int(grid.get("ny", 0)) <= 0:
raise ValueError("calibration must declare a positive two-dimensional grid")
config = infer_train._load_json(bundle["config_path"])
config_grid = config.get("grid", {})
if (int(config_grid.get("nx", 0)), int(config_grid.get("ny", 0))) != (int(grid["nx"]), int(grid["ny"])):
raise ValueError("config and calibration grids do not match")
u0 = float(calibration.get("U0", np.nan))
if not np.isfinite(u0) or u0 <= 0 or not np.isclose(
u0, float(config.get("physics", {}).get("velocity", np.nan)), rtol=0.0, atol=1e-12):
raise ValueError("config and calibration U0 do not match")
target = np.asarray(bundle["target_states"])
if target.shape != (150, 6) or target.dtype.kind != "f" or not np.all(np.isfinite(target)):
raise ValueError("resolved target must be finite floating shape (150, 6)")
center_uy = np.asarray(target[:, 3], dtype=np.float64)
left = np.flatnonzero((center_uy[:-1] <= 0.0) & (center_uy[1:] > 0.0))
if len(left) < 3:
raise ValueError("target center-sensor uy must expose at least two cycle periods")
crossings = left - center_uy[left] / (center_uy[left + 1] - center_uy[left])
measured = float(np.median(np.diff(crossings)))
reviewed = CYCLE_WINDOWS[case.case_id]
if not np.isclose(measured, reviewed, rtol=CYCLE_WINDOW_REL_TOL, atol=0.0):
raise ValueError(
f"reviewed cycle window {reviewed} disagrees with target center-uy period {measured:.3f}"
)
if case.scene_type == "illusion" and case.target_diam is None:
raise ValueError("Illusion case must declare target geometry")
return reviewed
def _validate_shared_role_identity(case, requested_seed, requested_bundle):
"""Prove target/zero physical inputs are seed-invariant before one case output."""
requested_calibration = dict(requested_bundle["calibration"])
requested_calibration.pop("config_path", None)
requested_target = np.asarray(requested_bundle["target_states"])
for seed in case.seeds:
bundle = requested_bundle if seed == requested_seed else infer_train._resolve_seed_artifacts(case, seed)
_validate_acquisition_bundle(case, seed, bundle)
calibration = dict(bundle["calibration"])
calibration.pop("config_path", None)
if calibration != requested_calibration:
raise ValueError(f"{case.case_id} seed {seed} has different physical calibration")
if not np.allclose(
np.asarray(bundle["target_states"]), requested_target, rtol=0.0, atol=1e-6):
raise ValueError(f"{case.case_id} seed {seed} has a different physical target")
if Path(bundle["config_path"]).resolve() != Path(requested_bundle["config_path"]).resolve():
raise ValueError(f"{case.case_id} seed {seed} has a different registry config")
def acquire_role(role, *, case_id="kar_re100", seed=None, output_root=None,
overwrite=False, device_id=0,
storage_validator=validate_output_storage, runtime_factory=None,
target_runtime_factory=None, finalizer=_finalize):
if role not in ROLES:
raise ValueError(f"unknown V5 acquisition role {role!r}")
case = get_case(case_id)
seed = case.seeds[0] if seed is None else seed
# Artifact, calibration, target, config, and reviewed-window checks precede
# storage mutation and all solver construction for every role.
bundle = infer_train._resolve_seed_artifacts(case, seed)
cycle_length = _validate_acquisition_bundle(case, seed, bundle)
if role != "controlled":
_validate_shared_role_identity(case, seed, bundle)
storage = storage_validator(repo_mapping=default_reproduction_mapping(infer_train.REPO_ROOT),
output_root=output_root, min_free_bytes=MIN_FREE_BYTES)
output_case = f"{case.case_id}_seed{seed}" if role == "controlled" else case.case_id
prepared = prepare_role_output(storage, "v5", output_case, role, overwrite=overwrite)
role_dir, scratch_root = prepared["role_dir"], prepared["scratch_root"]
scratch = create_scratch(scratch_root)
runtime = None
try:
if role == "target":
runtime = (_create_target_runtime(case, bundle, device_id)
if target_runtime_factory is None
else target_runtime_factory(case, bundle, device_id))
rows, fields = _collect_target(runtime, scratch, case.si)
sensor_factor = float(bundle["calibration"].get("SENSOR_CC", 1.0))
unavailable = ["forces", "action_normalized", "commanded_target_omega",
"effective_smoothed_omega", "reward_raw", "cd", "cl", "r_cd",
"r_cl", "r_sim", "floor_pen", "native_reward_dtw"]
else:
if runtime_factory is None:
env = infer_train._create_env(case, bundle, device_id)
model, vec_env = infer_train._wrap_and_load(env, bundle)
raw_env = infer_train._get_raw_env(vec_env)
runtime = env
else:
runtime, model, vec_env, raw_env = runtime_factory(case, bundle, device_id)
if role == "controlled":
rows, fields = _collect_controlled(
model, vec_env, raw_env, scratch, scene_type=case.scene_type)
else:
rows, fields = _collect_zero(vec_env, raw_env, scratch, scene_type=case.scene_type)
sensor_factor = float(getattr(raw_env, "_dtw_sensor_factor", 1.0))
unavailable = []
finalizer(role_dir, scratch, rows, fields, bundle, storage, sensor_factor,
role, unavailable, case=case, seed=seed, cycle_length=cycle_length)
_validate_staged_role(role_dir)
return publish_role_output(prepared)
except Exception:
if prepared["staging_dir"].exists():
shutil.rmtree(prepared["staging_dir"])
raise
finally:
if runtime is not None:
runtime.close()
def acquire_controlled(**kwargs):
return acquire_role("controlled", **kwargs)
def main() -> int:
parser = argparse.ArgumentParser(description="Acquire canonical V5 phase evidence")
parser.add_argument("--case", choices=CASE_IDS, default="kar_re100")
parser.add_argument("--seed", type=int)
parser.add_argument("--role", choices=ROLES, required=True)
parser.add_argument("--device-id", type=int, default=0)
parser.add_argument("--output-root", type=Path)
parser.add_argument("--overwrite", action="store_true")
args = parser.parse_args()
path = acquire_role(args.role, case_id=args.case, seed=args.seed,
output_root=args.output_root, overwrite=args.overwrite,
device_id=args.device_id)
print(path)
return 0
if __name__ == "__main__":
raise SystemExit(main())
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,87 @@
import csv,json
import numpy as np
import pytest
from drl_pinball.eval import wake_l2
def test_formulas_and_wrap():
z=np.zeros((8,4,5)); ux=np.r_[np.ones((4,4,5)),-np.ones((4,4,5))]; uy=np.full_like(z,2)
g=wake_l2.field_metrics(ux,uy,z,z,2,(slice(None),slice(None)),periodic=True)
assert g["E_phase8"]==pytest.approx(np.sqrt(5)/2); assert g["E_mean"] is None; assert g["E_snapshot"]==pytest.approx(np.sqrt(5)/2)
assert wake_l2.mean_field_metric(np.ones((4,5)),np.full((4,5),2.),np.zeros((4,5)),np.zeros((4,5)),2,(slice(None),slice(None)))==pytest.approx(np.sqrt(5)/2)
assert wake_l2.wrapped_phase_delta([2*np.pi-.1,.1],[0,2*np.pi-.1])==pytest.approx([-.1,.2])
def test_steady_and_geometry_guards():
z=np.zeros((1,3,4)); g=wake_l2.field_metrics(np.ones_like(z),z,z,z,2,(slice(None),slice(None)),periodic=False)
assert g=={"E_mean":.5,"E_snapshot":.5,"E_slot":None,"E_phase8":None}
with pytest.raises(ValueError,match="outside"): wake_l2.roi_slices((10,10),D=2,xs=4,downstream_solid_extent=-1)
with pytest.raises(ValueError,match="not downstream"): wake_l2.roi_slices((30,50),D=1,xs=20,downstream_solid_extent=14)
def test_frequency_and_fail_closed():
t=np.arange(41.); s=np.zeros((41,6)); s[:,3]=np.sin(2*np.pi*(t-1)/10); g=wake_l2.frequency_diagnostics(t,s)
assert g["median_period"]==pytest.approx(10); assert g["dominant_frequency"]==pytest.approx(.1)
assert g["crossing_count"]==4; assert g["period_cv"]==pytest.approx(0)
with pytest.raises(ValueError,match="three rising"): wake_l2.frequency_diagnostics(t[:10],s[:10])
s[2,3]=np.nan
with pytest.raises(ValueError,match="finite"): wake_l2.frequency_diagnostics(t,s)
def metadata(rd,role,u=.5):
rd.mkdir(parents=True); (rd/"metadata.json").write_text(json.dumps({"schema":"drl-pinball-legacy-acquisition-v2","case_id":"steady","role":role,"source_provenance":{"builder_config":{"values":{"u0":u}}}}))
def test_synthetic_steady_and_atomic_output(tmp_path):
b=tmp_path/"legacy"/"steady"
for role,val in (("target",0),("constant",1),("zero",2)):
rd=b/role; metadata(rd,role); a=np.full((1,9,30),val,dtype=np.float32); np.savez(rd/"late_field.npz",ux=a,uy=np.zeros_like(a),field_indices=[0])
row=wake_l2._evaluate(**wake_l2.discover(tmp_path,allow_partial=True)[0],geometry_override={"D":.5,"xs":10,"y0":4,"downstream_solid_extent":6})
assert row["E_mean_ctl"]==pytest.approx(2); assert row["E_mean_zero"]==pytest.approx(4); assert row["eta_mean"]==pytest.approx(.5); assert row["E_phase8_ctl"] is None
assert row["E_mean_ctl"]==row["E_snapshot_ctl"]
assert row["nonperiodic_metric_kind"]=="late_snapshot_error"
payload={"schema":wake_l2.SCHEMA,"rows":[row]}; jp,cp=tmp_path/"a.json",tmp_path/"a.csv"; wake_l2.write_outputs(payload,jp,cp)
assert json.loads(jp.read_text())["rows"][0]["eta_mean"]==pytest.approx(.5)
with cp.open(newline="") as f:
csv_row=next(csv.DictReader(f)); assert csv_row["case_id"]=="steady"; assert csv_row["nonperiodic_metric_kind"]=="late_snapshot_error"
with pytest.raises(FileExistsError): wake_l2.write_outputs(payload,jp,cp)
def test_schema_and_matrix_fail_closed(tmp_path):
p=tmp_path/"bad.npz"; a=np.zeros((8,2,2)); np.savez(p,ux=a,uy=a,mean_ux=np.zeros((2,2)),mean_uy=np.zeros((2,2)),field_indices=range(8),cycle_id=range(8),target_phase=wake_l2.PHASES,actual_phase=wake_l2.PHASES,phase_error=np.zeros(8),extra=1)
with pytest.raises(ValueError,match="exact key"): wake_l2._field(p,True)
p2=tmp_path/"old.npz"; np.savez(p2,ux=a,uy=a,field_indices=range(8),cycle_id=range(8),target_phase=wake_l2.PHASES,actual_phase=wake_l2.PHASES,phase_error=np.zeros(8))
with pytest.raises(ValueError,match="exact key"): wake_l2._field(p2,True)
(tmp_path/"v5").mkdir(); (tmp_path/"legacy").mkdir()
with pytest.raises(ValueError,match="full matrix"): wake_l2.discover(tmp_path)
def test_v2_interpretation_and_periodic_selection_diagnostics(tmp_path,monkeypatch):
monkeypatch.setattr(wake_l2,"discover",lambda root,allow_partial=False: [])
payload=wake_l2.evaluate_root(tmp_path,allow_partial=True)
assert payload["schema"]=="drl-pinball-wake-l2-v2"; assert payload["version"]==2
assert payload["roi"]["x_over_D"]==[-6,14]
assert "not the mean of the eight phase snapshots" in payload["metric_interpretation"]["E_mean"]
assert "complete-cycle" in payload["metric_interpretation"]["E_mean"]
assert "not a repeated-crossing" in payload["metric_interpretation"]["E_phase8"]
assert "smoothed rising crossings" in payload["metric_interpretation"]["phase_reference"]
assert payload["mask_provenance"]["solver_exact_mask_persisted"] is False
assert payload["mask_provenance"]["velocity_zero_mask_inference"] is False
assert payload["frequency_interpretation"]["dominant_frequency_unit"]=="NX/(lattice_time*U0)"
assert payload["formula"]["E_mean"].startswith("RMS difference of complete-cycle mean fields")
b=tmp_path/"periodic"
t=np.arange(41.); sensors=np.zeros((41,6)); sensors[:,3]=np.sin(2*np.pi*(t-1)/10)
for role,val,indices,cycles,mean_val in (
("target",0,range(8),range(8),0.0),
("controlled",1,[0,1,1,3,4,5,6,7],[2,2,3,3,4,4,5,5],3.0),
("zero",2,range(8),np.zeros(8,int),4.0),
):
rd=b/role; metadata(rd,role); a=np.full((8,9,30),val,dtype=np.float32)
mean=np.full((9,30),mean_val,dtype=np.float32)
np.savez(rd/"phase_fields.npz",ux=a,uy=np.zeros_like(a),mean_ux=mean,mean_uy=np.zeros_like(mean),
field_indices=indices,cycle_id=cycles,target_phase=wake_l2.PHASES,actual_phase=wake_l2.PHASES,phase_error=np.zeros(8))
np.savez(rd/"timeseries.npz",physical_time=t,sensors=sensors)
row=wake_l2._evaluate(b/"controlled",b/"target",b/"zero",generation="legacy",case_id="steady",role="controlled",seed=None,periodic=True,geometry_override={"D":.5,"xs":10,"y0":4,"downstream_solid_extent":6})
assert row["nonperiodic_metric_kind"]=="eight_phase_nearest_snapshot_diagnostic"
assert row["role_field_indices"]==[0,1,1,3,4,5,6,7]; assert row["role_duplicate_field_index"] is True
assert row["role_unique_cycle_count"]==4; assert row["target_unique_cycle_count"]==8; assert row["zero_unique_cycle_count"]==1
assert row["role_cycle_id"]==[2,2,3,3,4,4,5,5]
assert row["role_phase_delta_slots"]==pytest.approx(np.zeros(8))
assert row["role_crossing_count"]==4; assert row["role_period_cv"]==pytest.approx(0)
assert all(np.isfinite(row[k]) for k in ("role_period_cv","target_period_cv","zero_period_cv","role_dominant_frequency"))
# Counterexample: eight-phase slot mean differs from complete-cycle mean fields.
# Controlled phase snapshots are all 1 => slot-mean error vs target 0 would be 2,
# but complete-cycle mean_ux=3 => E_mean_ctl = |3-0|/u0 = 6.
assert row["E_mean_ctl"]==pytest.approx(6)
assert row["E_mean_zero"]==pytest.approx(8)
assert row["eta_mean"]==pytest.approx(0.25)
assert row["E_phase8_ctl"]==pytest.approx(2)
assert row["E_mean_ctl"] != pytest.approx(row["E_phase8_ctl"])
+146
View File
@@ -0,0 +1,146 @@
"""Offline wake-field L2 evaluation; never imports CFD/GPU modules."""
from __future__ import annotations
import argparse,csv,json,math,os,tempfile
from pathlib import Path
import numpy as np
SCHEMA="drl-pinball-wake-l2-v2"
PHASES=np.arange(8)*np.pi/4
V5={"kar_re100","kar_d075","kar_d15","kar_d2","ill_075L","ill_1L","ill_15L","ill_2L","kar_re60","kar_re200","kar_re400"}
LEGACY={"karman_re50","karman_re100","karman_re200","karman_re400","illusion_075L","illusion_1L","illusion_15L"}
EXPECTED=(15,11,7,1)
PHASE_KEYS={"ux","uy","mean_ux","mean_uy","field_indices","cycle_id","target_phase","actual_phase","phase_error"}; STEADY_KEYS={"ux","uy","field_indices"}
CSV_FIELDS=("generation","case_id","seed","role","u0","roi_x0","roi_x1","roi_y0","roi_y1","nonperiodic_metric_kind","E_mean_ctl","E_mean_zero","eta_mean","E_phase8_ctl","E_phase8_zero","eta_phase8","E_snapshot_ctl","E_snapshot_zero","eta_snapshot","role_phase_delta_max_abs","role_phase_delta_rms","zero_phase_delta_max_abs","zero_phase_delta_rms","role_phase_error_max_abs","target_phase_error_max_abs","zero_phase_error_max_abs","role_unique_cycle_count","target_unique_cycle_count","zero_unique_cycle_count","role_duplicate_field_index","target_duplicate_field_index","zero_duplicate_field_index","role_median_period","target_median_period","zero_median_period","role_period_cv","target_period_cv","zero_period_cv","role_crossing_count","target_crossing_count","zero_crossing_count","role_dominant_frequency","target_dominant_frequency","zero_dominant_frequency","role_relative_frequency_mismatch","zero_relative_frequency_mismatch")
def wrapped_phase_delta(a,b):
a,b=np.asarray(a,float),np.asarray(b,float)
if a.shape!=b.shape or not np.all(np.isfinite(a)) or not np.all(np.isfinite(b)): raise ValueError("phase arrays must be equal-shape finite arrays")
return (a-b+np.pi)%(2*np.pi)-np.pi
def roi_slices(shape,*,D,xs,y0=None,downstream_solid_extent):
ny,nx=map(int,shape); y0=(ny-1)/2 if y0 is None else float(y0)
if D<=0 or not all(np.isfinite(v) for v in (D,xs,y0,downstream_solid_extent)): raise ValueError("invalid geometry")
x0,x1=math.ceil(xs-6*D),math.floor(xs+14*D); ylo,yhi=math.ceil(y0-5*D),math.floor(y0+5*D)
if x0<0 or ylo<0 or x1>=nx or yhi>=ny: raise ValueError("ROI outside grid")
if x0<=downstream_solid_extent: raise ValueError("ROI not downstream of solid extent")
return slice(ylo,yhi+1),slice(x0,x1+1)
def mean_field_metric(rux,ruy,tux,tuy,u0,roi):
a=[np.asarray(v,float) for v in (rux,ruy,tux,tuy)]
if len({v.shape for v in a})!=1 or a[0].ndim!=2: raise ValueError("mean fields must be equal-shape 2-D arrays")
if u0<=0 or not np.isfinite(u0) or any(not np.all(np.isfinite(v)) for v in a): raise ValueError("non-finite mean field/U0")
ys,xs=roi; du=(a[0][ys,xs]-a[2][ys,xs])/u0; dv=(a[1][ys,xs]-a[3][ys,xs])/u0
return float(np.sqrt(np.mean(du*du+dv*dv)))
def field_metrics(rux,ruy,tux,tuy,u0,roi,*,periodic):
a=[np.asarray(v) for v in (rux,ruy,tux,tuy)]; k=8 if periodic else 1
if len({v.shape for v in a})!=1 or a[0].ndim!=3 or a[0].shape[0]!=k: raise ValueError("invalid field shapes")
if u0<=0 or not np.isfinite(u0) or any(not np.all(np.isfinite(v)) for v in a): raise ValueError("non-finite field/U0")
ys,xs=roi; du=(a[0][:,ys,xs].astype(float)-a[2][:,ys,xs])/u0; dv=(a[1][:,ys,xs].astype(float)-a[3][:,ys,xs])/u0
slots=np.sqrt(np.mean(du*du+dv*dv,axis=(1,2)))
# Periodic E_mean is computed separately from complete-cycle mean_ux/mean_uy.
if periodic:
return {"E_mean":None,"E_snapshot":float(slots[0]),"E_slot":slots.tolist(),"E_phase8":float(np.sqrt(np.mean(du*du+dv*dv)))}
return {"E_mean":float(slots[0]),"E_snapshot":float(slots[0]),"E_slot":None,"E_phase8":None}
def frequency_diagnostics(times,sensors):
t,s=np.asarray(times,float),np.asarray(sensors,float)
if t.ndim!=1 or s.ndim!=2 or s.shape[0]!=len(t) or s.shape[1]<4 or not np.all(np.isfinite(t)) or not np.all(np.isfinite(s)) or np.any(np.diff(t)<=0): raise ValueError("timeseries must be finite and ordered")
y=s[:,3]; i=np.flatnonzero((y[:-1]<=0)&(y[1:]>0))
if len(i)<3: raise ValueError("at least three rising crossings required")
c=t[i]-y[i]*(t[i+1]-t[i])/(y[i+1]-y[i]); periods=np.diff(c); p=float(np.median(periods)); cv=float(np.std(periods)/np.mean(periods))
if p<=0 or not np.isfinite(p) or not np.isfinite(cv): raise ValueError("invalid period")
return {"median_period":p,"dominant_frequency":1/p,"crossing_count":int(len(c)),"period_cv":cv}
def _json(p):
with Path(p).open(encoding="utf8") as f: d=json.load(f)
if not isinstance(d,dict): raise ValueError(f"{p}: JSON object required")
return d
def _meta(rd,generation,case,role):
d=_json(rd/"metadata.json"); schema=f"drl-pinball-{'v5' if generation=='v5' else 'legacy'}-acquisition-v2"
if (d.get("schema"),d.get("case_id"),d.get("role"))!=(schema,case,role): raise ValueError(f"{rd}: metadata mismatch")
if generation=="v5":
cp=Path(d.get("config",{}).get("path","")); cp=cp if cp.is_absolute() else (rd/cp).resolve(); u=_json(cp).get("physics",{}).get("velocity")
else: u=d.get("source_provenance",{}).get("builder_config",{}).get("values",{}).get("u0")
try: u=float(u)
except (TypeError,ValueError) as e: raise ValueError(f"{rd}: invalid U0") from e
if u<=0 or not np.isfinite(u): raise ValueError(f"{rd}: invalid U0")
return d,u
def _field(path,periodic):
keys=PHASE_KEYS if periodic else STEADY_KEYS
with np.load(path,allow_pickle=False) as z:
if set(z.files)!=keys: raise ValueError(f"{path}: exact key schema mismatch")
d={k:np.array(z[k]) for k in z.files}
stacked=("ux","uy","field_indices")+ (("cycle_id","target_phase","actual_phase","phase_error") if periodic else ())
if d["ux"].shape!=d["uy"].shape or d["ux"].ndim!=3 or d["ux"].shape[0]!=(8 if periodic else 1) or any(not np.all(np.isfinite(d[k])) for k in stacked): raise ValueError(f"{path}: shape/finite validation failed")
if periodic:
if d["mean_ux"].shape!=d["ux"].shape[1:] or d["mean_uy"].shape!=d["uy"].shape[1:] or d["mean_ux"].ndim!=2: raise ValueError(f"{path}: mean field shape mismatch")
if not np.all(np.isfinite(d["mean_ux"])) or not np.all(np.isfinite(d["mean_uy"])): raise ValueError(f"{path}: non-finite mean fields")
if d["target_phase"].shape!=(8,) or not np.allclose(d["target_phase"],PHASES,rtol=0,atol=1e-7): raise ValueError(f"{path}: noncanonical phases")
return d
def _freq(rd):
with np.load(rd/"timeseries.npz",allow_pickle=False) as z:
if not {"physical_time","sensors"}<=set(z.files): raise ValueError(f"{rd}: timeseries keys missing")
return frequency_diagnostics(z["physical_time"],z["sensors"])
def _pdiag(f,reference):
d=wrapped_phase_delta(f["actual_phase"],reference["actual_phase"]); return d.tolist(),float(np.max(abs(d))),float(np.sqrt(np.mean(d*d))),float(np.max(abs(f["phase_error"])))
def _selection_diag(f):
indices=np.asarray(f["field_indices"]); cycles=np.asarray(f["cycle_id"])
return {"field_indices":indices.tolist(),"cycle_id":cycles.tolist(),"unique_cycle_count":int(len(np.unique(cycles))),"duplicate_field_index":bool(len(np.unique(indices))!=len(indices))}
def _geometry(g,c):
return ((600.,440.) if c.startswith("ill_") else (1200.,1036.)) if g=="v5" else ((600.,440.) if c.startswith("illusion_") else (800.,636.))
def _evaluate(role_dir,target_dir,zero_dir,*,generation,case_id,role,seed,periodic,geometry_override=None):
_,u=_meta(role_dir,generation,case_id,role); _,ut=_meta(target_dir,generation,case_id,"target"); _,uz=_meta(zero_dir,generation,case_id,"zero")
if not u==ut==uz: raise ValueError("role/target/zero U0 mismatch")
fn="phase_fields.npz" if periodic else "late_field.npz"; rf,tf,zf=(_field(p/fn,periodic) for p in (role_dir,target_dir,zero_dir))
if len({rf["ux"].shape,tf["ux"].shape,zf["ux"].shape})!=1: raise ValueError("role field shapes differ")
if periodic and (not np.array_equal(rf["target_phase"],tf["target_phase"]) or not np.array_equal(zf["target_phase"],tf["target_phase"])): raise ValueError("target_phase mismatch")
xs,extent=_geometry(generation,case_id); o=geometry_override or {}; roi=roi_slices(rf["ux"].shape[1:],D=float(o.get("D",20)),xs=float(o.get("xs",xs)),y0=o.get("y0"),downstream_solid_extent=float(o.get("downstream_solid_extent",extent)))
ctl=field_metrics(rf["ux"],rf["uy"],tf["ux"],tf["uy"],u,roi,periodic=periodic); zero=field_metrics(zf["ux"],zf["uy"],tf["ux"],tf["uy"],u,roi,periodic=periodic)
if periodic:
ctl["E_mean"]=mean_field_metric(rf["mean_ux"],rf["mean_uy"],tf["mean_ux"],tf["mean_uy"],u,roi)
zero["E_mean"]=mean_field_metric(zf["mean_ux"],zf["mean_uy"],tf["mean_ux"],tf["mean_uy"],u,roi)
def eta(k):
if zero[k] is None or zero[k]<=0: raise ValueError(f"zero {k} must be positive")
return 1-ctl[k]/zero[k]
row={"generation":generation,"case_id":case_id,"seed":seed,"role":role,"u0":u,"roi_x0":roi[1].start,"roi_x1":roi[1].stop-1,"roi_y0":roi[0].start,"roi_y1":roi[0].stop-1,"nonperiodic_metric_kind":"eight_phase_nearest_snapshot_diagnostic" if periodic else "late_snapshot_error","E_mean_ctl":ctl["E_mean"],"E_mean_zero":zero["E_mean"],"eta_mean":eta("E_mean"),"E_phase8_ctl":ctl["E_phase8"],"E_phase8_zero":zero["E_phase8"],"eta_phase8":eta("E_phase8") if periodic else None,"E_snapshot_ctl":ctl["E_snapshot"],"E_snapshot_zero":zero["E_snapshot"],"eta_snapshot":eta("E_snapshot"),"E_slot_ctl":ctl["E_slot"],"E_slot_zero":zero["E_slot"]}
for p in ("role","target","zero"):
for k in ("phase_delta_slots","phase_delta_max_abs","phase_delta_rms","phase_error_max_abs","field_indices","cycle_id","unique_cycle_count","duplicate_field_index","median_period","period_cv","crossing_count","dominant_frequency","relative_frequency_mismatch"): row[f"{p}_{k}"]=None
if periodic:
fs={"role":rf,"target":tf,"zero":zf}; fq={p:_freq(d) for p,d in (("role",role_dir),("target",target_dir),("zero",zero_dir))}; targetf=fq["target"]["dominant_frequency"]
for p,f in fs.items():
row[f"{p}_phase_delta_slots"],row[f"{p}_phase_delta_max_abs"],row[f"{p}_phase_delta_rms"],row[f"{p}_phase_error_max_abs"]=_pdiag(f,tf)
for k,v in _selection_diag(f).items(): row[f"{p}_{k}"]=v
for k in ("median_period","period_cv","crossing_count","dominant_frequency"): row[f"{p}_{k}"]=fq[p][k]
row[f"{p}_relative_frequency_mismatch"]=abs(fq[p]["dominant_frequency"]-targetf)/targetf
return row
def discover(root,*,allow_partial=False):
root=Path(root); v5,legacy=root/"v5",root/"legacy"; ctl=[]; seen=set()
for rd in sorted(v5.glob("*_seed*/controlled")):
m=_json(rd/"metadata.json"); key=(m.get("case_id"),m.get("seed"))
if key[0] not in V5 or not isinstance(key[1],int) or key in seen: raise ValueError(f"{rd}: invalid/duplicate mapping")
seen.add(key); ctl.append((rd,*key))
vc={c for _,c,_ in ctl}; lp={p.name for p in legacy.iterdir() if p.is_dir() and p.name in LEGACY}; counts=(len(ctl),len(vc),len(lp),int((legacy/"steady").is_dir()))
if not allow_partial and (counts!=EXPECTED or vc!=V5 or lp!=LEGACY): raise ValueError(f"full matrix discovery failed: {counts} != {EXPECTED}")
jobs=[dict(role_dir=r,target_dir=v5/c/"target",zero_dir=v5/c/"zero",generation="v5",case_id=c,role="controlled",seed=s,periodic=True) for r,c,s in ctl]
for c in sorted(lp):
b=legacy/c; jobs.append(dict(role_dir=b/"controlled",target_dir=b/"target",zero_dir=b/"zero",generation="legacy",case_id=c,role="controlled",seed=None,periodic=True))
if (legacy/"steady").is_dir():
b=legacy/"steady"; jobs.append(dict(role_dir=b/"constant",target_dir=b/"target",zero_dir=b/"zero",generation="legacy",case_id="steady",role="constant",seed=None,periodic=False))
if not jobs: raise ValueError("no evaluable jobs")
return jobs
def evaluate_root(root,*,allow_partial=False):
return {"schema":SCHEMA,"version":2,"roi":{"D":20,"x_over_D":[-6,14],"abs_y_over_D_max":5,"coordinates":"inclusive lattice cells; y0=(ny-1)/2","geometry_guard":"Every included scenario has ROI x lower bound strictly downstream of its conservative solid extent; the registered -6..14D ROI is therefore expected to contain fluid only."},"metric_interpretation":{"E_mean":"For periodic cases, U0-normalized ROI RMS difference of complete-cycle arithmetic mean fields (mean_ux/mean_uy) over accepted rising crossings [t_first,t_last). This is not the mean of the eight phase snapshots. For steady, E_mean and E_snapshot are the same late-snapshot error.","E_phase8":"Discrete phase-resolved diagnostic from eight independently phase-referenced nearest-boundary snapshots; not a repeated-crossing phase-conditioned ensemble metric.","phase_reference":"Target, controlled, and zero are independently phased by smoothed rising crossings of each role's center-sensor uy with a minimum-gap filter. A shared target_phase slot is a common mathematical phase coordinate, not independent evidence that physical events are exactly equivalent across roles.","phase_alignment":"No global phase minimization or interpolation is performed.","steady":"nonperiodic_metric_kind=late_snapshot_error.","periodic":"nonperiodic_metric_kind=eight_phase_nearest_snapshot_diagnostic."},"mask_provenance":{"status":"geometry-guarded ROI diagnostic","solver_exact_mask_persisted":False,"known_solid_cells_expected_in_roi":False,"basis":"All included ROI x lower bounds are strictly downstream of the conservative solid extent.","claim_limit":"Without a persisted solver-exact mask, this is not a solver-mask-proven exact common-fluid metric.","velocity_zero_mask_inference":False,"prohibition":"velocity==0 is not used or permitted as a fluid/solid mask inference."},"frequency_interpretation":{"physical_time_input_unit":"lattice_time*U0/NX (tU0/NX)","dominant_frequency_unit":"NX/(lattice_time*U0)","use":"Relative mismatch only among target, controlled, and zero within the same case.","cross_generation_limit":"Not for Legacy/V5 physical-frequency comparison."},"formula":{"slot":"sqrt(mean_ROI((du_x^2+du_y^2)/U0^2))","E_phase8":"RMS over eight slots and cells","E_mean":"RMS difference of complete-cycle mean fields mean_ux/mean_uy","eta":"1-E_role/E_zero","period_cv":"std(rising-crossing periods)/mean(rising-crossing periods)"},"limitations":["Each phase slot is one snapshot, not a repeated-crossing average.","phi0 is the canonical slot-0 snapshot.","No global phase minimization or interpolation.","E_mean uses only complete cycles between first and last accepted rising crossings."],"exclusions":["legacy erase","legacy vortex","other directories"],"rows":[_evaluate(**j) for j in discover(root,allow_partial=allow_partial)]}
def _atomic(path,writer):
path=Path(path); path.parent.mkdir(parents=True,exist_ok=True); fd,tmp=tempfile.mkstemp(prefix="."+path.name+".",suffix=".tmp",dir=path.parent)
try:
with os.fdopen(fd,"w",encoding="utf8",newline="") as f: writer(f); f.flush(); os.fsync(f.fileno())
os.replace(tmp,path)
except BaseException:
try: os.unlink(tmp)
except FileNotFoundError: pass
raise
def write_outputs(payload,json_path,csv_path,*,overwrite=False):
jp,cp=Path(json_path),Path(csv_path)
if jp.resolve()==cp.resolve(): raise ValueError("outputs must differ")
if not overwrite and any(p.exists() for p in (jp,cp)): raise FileExistsError("refusing overwrite")
def wj(f): json.dump(payload,f,indent=2,allow_nan=False); f.write("\n")
def wc(f):
w=csv.DictWriter(f,fieldnames=CSV_FIELDS,extrasaction="ignore"); w.writeheader(); w.writerows(payload["rows"])
_atomic(jp,wj); _atomic(cp,wc)
def main(argv=None):
default=Path(__file__).resolve().parents[1]/"data"/"reproduction"; p=argparse.ArgumentParser(); p.add_argument("--root",type=Path,default=default); p.add_argument("--output-json",type=Path); p.add_argument("--output-csv",type=Path); p.add_argument("--overwrite",action="store_true"); p.add_argument("--allow-partial",action="store_true"); a=p.parse_args(argv); payload=evaluate_root(a.root,allow_partial=a.allow_partial); write_outputs(payload,a.output_json or a.root/"wake_l2_summary.json",a.output_csv or a.root/"wake_l2_summary.csv",overwrite=a.overwrite); return 0
if __name__=="__main__": raise SystemExit(main())
+4
View File
@@ -0,0 +1,4 @@
from .run import main
if __name__ == "__main__":
main()
+819
View File
@@ -0,0 +1,819 @@
"""Legacy Karman Re100 role acquisition; solver imports stay lazy."""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import shutil
from collections import deque
from pathlib import Path
from typing import Any, Callable
import numpy as np
from drl_pinball.acquisition import (
accumulate_mean_fields, assign_phase, assign_periodic_phase,
cleanup_scratch, complete_cycle_field_indices, create_scratch,
default_reproduction_mapping, dual_cycle_dtw, pooled_phase_bins,
prepare_role_output, publish_role_output, publish_selected_fields,
select_phase_fields, validate_output_storage, write_boundary_artifacts,
write_phase_cycle_artifacts,
)
from .cases import get_case
from .metrics import erase_reward_terms, frozen_reference_comparison, reward_terms, sha256_file
from .core.dtw_metrics import gen_target_states_at
from .runtime import erase_policy_observation, load_policy_norm, policy_observation, reset_runtime, run_historical_interval
CASE_NAME, ROLE = "karman_re100", "controlled" # compatibility defaults
PERIODIC_CASES = ("karman_re50", "karman_re100", "karman_re200", "karman_re400",
"illusion_075L", "illusion_1L", "illusion_15L")
SUPPORTED_CASES = PERIODIC_CASES + ("steady", "vortex_lamb", "vortex_taylor", "erase")
PERIODIC_ROLES = ("controlled", "target", "zero")
STEADY_ROLES = ("target", "constant", "zero")
ROLES = PERIODIC_ROLES
WARMUP_INTERVALS, COLLECT_BOUNDARIES = 480, 160
NX, U0, SI, CYCLE_LENGTH = 1280, 0.01, 800, 30 # compatibility defaults
MIN_FREE_BYTES = 4 * 1024**3
def _atomic_json(path: Path, payload: dict[str, Any]) -> None:
temporary = path.with_name(f".{path.name}.tmp")
with temporary.open("w", encoding="utf-8") as stream:
json.dump(payload, stream, indent=2)
stream.write("\n")
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, path)
def _identity(path: Path) -> dict[str, Any]:
return {"path": str(path.resolve()), "sha256": sha256_file(str(path)), "bytes": path.stat().st_size}
def _canonical_json_value(value):
"""Convert builder contract values to deterministic JSON-native values."""
if isinstance(value, dict):
return {str(key): _canonical_json_value(current) for key, current in value.items()}
if isinstance(value, (list, tuple)):
return [_canonical_json_value(current) for current in value]
if isinstance(value, np.ndarray):
return _canonical_json_value(value.tolist())
if isinstance(value, np.generic):
return value.item()
if value is None or isinstance(value, (str, int, float, bool)):
return value
raise TypeError(f"builder config contains non-JSON value {type(value).__name__}")
def _optional_file_provenance(path: Path | None):
if path is None:
return None
path = Path(path)
return {"path": str(path.resolve()), "exists": path.is_file(),
"file_identity": _identity(path) if path.is_file() else None}
def _generated_array_identity(value) -> dict[str, Any]:
"""Identify a run-generated numeric array by dtype, shape, and C-order content."""
array = np.asarray(value)
if array.dtype.hasobject:
raise TypeError("run-generated identity does not support object arrays")
descriptor = {"dtype": array.dtype.str, "shape": list(array.shape), "order": "C"}
header = json.dumps(descriptor, sort_keys=True, separators=(",", ":"),
allow_nan=False).encode("ascii")
content = np.ascontiguousarray(array).tobytes(order="C")
digest = hashlib.sha256(header + b"\0" + content).hexdigest()
return {"identity_kind": "run-generated-array-content", **descriptor,
"content_sha256": digest, "bytes": len(content)}
def _generated_config_identity(config) -> dict[str, Any]:
"""Identify a run-generated builder config by canonical JSON content."""
values = _canonical_json_value(config)
canonical = json.dumps(values, sort_keys=True, separators=(",", ":"),
ensure_ascii=True, allow_nan=False)
return {"identity_kind": "run-generated-builder-config", "values": values,
"canonical_json_sha256": hashlib.sha256(canonical.encode("ascii")).hexdigest()}
def _role_semantics(role: str) -> str:
return {
"controlled": "frozen-policy controlled trajectory",
"target": "live builder-generated target trajectory; actions, rewards, and policy normalization unavailable",
"zero": "physical-zero/uncontrolled trajectory using counter-bias normalized action; frozen norm used only for native reward evaluation",
"constant": "established steady constant control at case.action_bias*U0 from normalized action zero",
}[role]
def _controlled_reference(role, bundle, columns, conv_len):
if role != "controlled":
return None
return frozen_reference_comparison(
str(bundle["reference_path"]), columns["sensors"], columns["forces"],
columns["action_normalized"], conv_len,
)
def _case_roles(case):
if case.name not in SUPPORTED_CASES:
raise ValueError(f"unsupported Legacy acquisition case {case.name!r}")
return STEADY_ROLES if case.scene == "steady" else PERIODIC_ROLES
def _resolve_bundle(
repo_root: Path, case_name: str = CASE_NAME, role: str = "controlled",
) -> dict[str, Any]:
"""Resolve immutable CPU artifacts before solver construction."""
case = get_case(case_name)
_case_roles(case)
frozen = repo_root / "src" / "SR_analysis" / "data" / case.scene / (case.reference_case or case.name)
needs_model = case.model is not None and role == "controlled"
needs_frozen_policy = (case.model is not None and role != "target" and
case.scene != "erase" and
not (case.scene == "vortex" and role == "zero"))
norm_path = frozen / "norm.json" if needs_frozen_policy else None
norm = load_policy_norm(str(norm_path)) if norm_path is not None else None
model = None
if needs_model:
from .core.model_loader import model_path
model = Path(model_path(case.model))
return {"case": case, "model_path": model, "norm_path": norm_path, "norm": norm,
"reference_path": frozen / "controlled.npz"}
def _default_runtime(case, bundle, device_id, role="controlled"):
from .core.legacy_env_builder import (
build_illusion, build_illusion_target, build_karman_cloak, build_karman_target,
build_erase, build_steady_cloak, build_steady_target, build_vortex,
)
if case.scene == "vortex":
data = build_vortex(device_id, case.re_code, vortex_type=case.vortex_type, action_scale=case.action_scale, target_only=(role == "target"))
elif case.scene == "erase":
data = build_erase(device_id, case.re_code, target_only=(role == "target"))
elif case.scene == "karman":
data = (build_karman_target(device_id, case.re_code, sample_interval=case.sample_interval)
if role == "target" else
build_karman_cloak(device_id, case.re_code, sample_interval=case.sample_interval))
elif case.scene == "illusion":
kwargs = dict(device_id=device_id, re_code=case.re_code,
target_diameter_L=case.target_radius_l, sample_interval=case.sample_interval)
data = build_illusion_target(**kwargs) if role == "target" else build_illusion(**kwargs)
elif case.scene == "steady":
data = (build_steady_target(device_id, case.re_code, sample_interval=case.sample_interval)
if role == "target" else build_steady_cloak(device_id, case.re_code))
else:
raise ValueError(f"unsupported Legacy acquisition scene {case.scene!r}")
model = None
if role == "controlled":
from .core.model_loader import load_model
model = load_model(case.model)
return data, model, data["flow_field"]
def _run_interval(ff, steps: int, command: np.ndarray) -> None:
context = getattr(ff, "context", None)
if context is None:
run_historical_interval(ff, steps, command)
return
context.push()
try:
run_historical_interval(ff, steps, command)
finally:
context.pop()
def _command(case, action, n_objects, dtype):
action = np.asarray(action, dtype=np.float32).reshape(3)
command = np.zeros(n_objects, dtype=dtype)
command[-3:] = (action * case.action_scale + np.asarray(case.action_bias)) * U0
return action, command
def _clock(ff, completed_intervals: int, sample_interval: int = SI) -> tuple[int, int, int | None]:
"""Return relative policy clocks plus the solver's absolute lattice clock."""
lattice_step = completed_intervals * sample_interval
absolute = None
if hasattr(ff, "solver_clock_state"):
state = ff.solver_clock_state()
absolute = int(state["solver_absolute_lattice_clock"])
if absolute < lattice_step:
raise ValueError("solver lattice clock regressed below acquisition timeline")
return lattice_step, completed_intervals, absolute
def _capture_boundary_field(ff, nx: int, ny: int) -> dict[str, np.ndarray]:
"""Capture public Legacy q/U0, convert to physical q/RHO_ref, and canonicalize."""
velocity = ff.current_step_velocity_field()
if not isinstance(velocity, tuple) or len(velocity) != 2:
raise ValueError("public Legacy velocity accessor must return (ux, uy)")
flags = np.asarray(ff.completed_flags_xy())
if flags.dtype != np.uint8 or flags.shape != (nx, ny):
raise ValueError("completed Legacy flags must be exact uint8 (nx,ny)")
fluid_xy = (flags & np.uint8(0b00000001)) != 0
u0 = float(ff.field_config.velocity)
if not np.isfinite(u0) or u0 <= 0:
raise ValueError("Legacy field_config.velocity U0 must be positive and finite")
result = {}
for name, field in zip(("ux", "uy"), velocity):
value = np.asarray(field)
if value.shape != (nx, ny) or value.dtype.kind != "f":
raise ValueError("public Legacy velocity must be floating (nx,ny) q/U0")
if not np.isfinite(value[fluid_xy]).all():
raise ValueError("public Legacy fluid velocity contains non-finite values")
if not np.array_equal(value[~fluid_xy], np.zeros(np.count_nonzero(~fluid_xy), value.dtype)):
raise ValueError("public Legacy velocity must be exact zero on nonfluid cells")
result[name] = np.asarray((value * u0).T, dtype=np.float32)
return result
def _target_sensors(target_states):
target = np.asarray(target_states)
if target.ndim != 2:
raise ValueError("target_states must be two-dimensional")
if target.shape[1] == 6:
return target
if target.shape[1] == 8:
return target[:, 2:8]
raise ValueError("target_states must expose exactly six sensor channels")
def _observation_slices(case, role, boundary_obs):
if role == "target":
return ((boundary_obs[2:8], np.full(6, np.nan, np.float32))
if case.scene in ("karman", "illusion") else
(boundary_obs[:6], np.full(6, np.nan, np.float32)))
raw = boundary_obs[:12] if case.scene in ("illusion", "steady") else boundary_obs[2:14]
return raw[:6], raw[6:12]
def _collect_role(role, data, model, ff, policy_norm, scratch: Path,
capture_field: Callable = _capture_boundary_field, case_name: str = CASE_NAME):
case = get_case(case_name)
if role not in _case_roles(case):
raise ValueError(f"unknown acquisition role {role!r} for {case.name}")
config = data["config"]; si = int(case.sample_interval)
expected_objects = 4 if role == "target" and case.scene in ("karman", "illusion") else (3 if role == "target" else (7 if case.scene == "karman" else 6))
if (int(config["nx"]), int(config["sample_interval"]), int(config["n_obj_total"])) != (NX, si, expected_objects):
raise ValueError(f"built Legacy {case.name} {role} lattice/control contract changed")
if role == "target":
fifo = obs = None
else:
saved = np.asarray(data["norm"]["save_states"], dtype=np.float32)
obs_dim = 14 if case.scene == "illusion" else 12
fifo, obs = reset_runtime(ff, saved, obs_dim)
if not isinstance(fifo, deque) or len(fifo) != len(saved):
raise ValueError("canonical reset FIFO contract failed")
rows = []
field_buffer = None
total = WARMUP_INTERVALS + COLLECT_BOUNDARIES
harmonics = data.get("target_harmonics")
steady = case.scene == "steady"
for interval in range(total):
if role == "controlled":
action, _ = model.predict(obs, deterministic=True)
action, command = _command(case, action, expected_objects, ff.DATA_TYPE)
elif role == "zero":
action = -np.asarray(case.action_bias, np.float32) / np.float32(case.action_scale)
action, command = _command(case, action, expected_objects, ff.DATA_TYPE)
if not np.allclose(command[-3:], 0.0, rtol=0.0, atol=np.finfo(ff.DATA_TYPE).eps):
raise ValueError("Legacy zero role counter-bias did not cancel the affine command")
command[-3:] = np.zeros(3, dtype=ff.DATA_TYPE)
elif role == "constant":
action = np.zeros(3, np.float32)
action, command = _command(case, action, expected_objects, ff.DATA_TYPE)
else:
action = np.full(3, np.nan, np.float32); command = np.zeros(expected_objects, ff.DATA_TYPE)
_run_interval(ff, si, command)
boundary_obs = np.asarray(ff.obs, np.float32).copy()
sensors, forces = _observation_slices(case, role, boundary_obs)
if role == "target":
effective = np.full(3, np.nan, np.float32)
terms = {name: np.nan for name in ("reward", "reward_cd", "reward_cl", "native_legacy_dtw")}
else:
raw = np.concatenate((sensors, forces)).astype(np.float32)
fifo.append(raw)
if steady:
terms = {name: np.nan for name in ("reward", "reward_cd", "reward_cl", "native_legacy_dtw")}
else:
terms = reward_terms(case, data["target_states"], harmonics, np.asarray(fifo), policy_norm["force_norm_fact"], interval)
if not steady:
target_force = gen_target_states_at(interval + 1, harmonics)[:2] if harmonics is not None else None
obs = policy_observation(raw, policy_norm, target_force=target_force)
effective = np.asarray(ff.current_effective_action(), np.float32)[-3:].copy()
if interval < WARMUP_INTERVALS:
continue
boundary = interval - WARMUP_INTERVALS
lattice_step, control_clock, absolute = _clock(ff, interval + 1, si)
native = float(terms["native_legacy_dtw"])
row = {"physical_time": lattice_step * U0 / NX, "lattice_step": lattice_step,
"control_index": control_clock, "sensors": sensors.copy(), "forces": forces.copy(),
"action_normalized": action.copy(),
"commanded_target_omega": (np.full(3, np.nan, np.float32) if role == "target" else command[-3:].astype(np.float32, copy=True)),
"effective_smoothed_omega": effective, "reward_raw": float(terms["reward"]),
"reward_cd": float(terms["reward_cd"]), "reward_cl": float(terms["reward_cl"]),
"reward_sim": float(np.exp(-10 * abs(native - 1))) if np.isfinite(native) else np.nan,
"native_reward_dtw": native}
if absolute is not None: row["solver_absolute_lattice_step"] = absolute
rows.append(row)
if not steady or boundary == COLLECT_BOUNDARIES - 1:
fields = capture_field(ff, int(config["nx"]), int(config["ny"]))
if field_buffer is None:
count = 1 if steady else COLLECT_BOUNDARIES
field_buffer = _allocate_field_buffer(fields["ux"].shape, count)
store_at = 0 if steady else boundary
_store_field_candidate(field_buffer, store_at, fields)
return rows, field_buffer
def _collect(data, model, ff, policy_norm, scratch: Path, capture_field: Callable = _capture_boundary_field):
return _collect_role("controlled", data, model, ff, policy_norm, scratch, capture_field=capture_field)
def _allocate_field_buffer(shape_yx, count: int = COLLECT_BOUNDARIES):
ny, nx = map(int, shape_yx)
return {
"ux": np.empty((int(count), ny, nx), dtype=np.float32),
"uy": np.empty((int(count), ny, nx), dtype=np.float32),
}
def _store_field_candidate(field_buffer, boundary: int, fields):
field_buffer["ux"][boundary] = np.asarray(fields["ux"], dtype=np.float32)
field_buffer["uy"][boundary] = np.asarray(fields["uy"], dtype=np.float32)
def _columns(rows):
return {name: np.asarray([row[name] for row in rows]) for name in rows[0]}
def _require_periodic_crossings(phase, minimum: int = 4):
count = len(phase["crossing_times"])
if count < minimum:
raise ValueError(f"periodic publication requires at least {minimum} center-uy rising crossings")
return count
def _phase_values(columns, role="controlled"):
excluded = {"physical_time", "lattice_step", "solver_absolute_lattice_step",
"control_index", "phase", "cycle_id"}
if role == "target":
return {"sensors": columns["sensors"]}
return {name: value for name, value in columns.items() if name not in excluded}
def _minimum_crossing_interval(physical_time, cycle_length):
times = np.asarray(physical_time, dtype=np.float64)
if len(times) < 2:
raise ValueError("physical_time must contain at least two boundaries")
dt = float(np.median(np.diff(times)))
if not np.isfinite(dt) or dt <= 0:
raise ValueError("physical_time spacing must be positive and finite")
return 0.5 * float(cycle_length) * dt
PHASE_FIELD_KEYS = {
"ux", "uy", "mean_ux", "mean_uy", "field_indices", "cycle_id",
"target_phase", "actual_phase", "phase_error",
}
def _finalize(role_dir: Path, scratch: Path, rows, field_buffer, data, bundle, storage, role="controlled"):
case = bundle["case"]; columns = _columns(rows)
phase_meta = {}
if case.scene == "steady":
write_boundary_artifacts(role_dir / "timeseries.npz", role_dir / "timeseries.csv", columns)
publish_selected_fields(role_dir / "late_field.npz", {
"ux": np.asarray(field_buffer["ux"][:1], dtype=np.float32),
"uy": np.asarray(field_buffer["uy"][:1], dtype=np.float32),
"field_indices": np.asarray([len(rows) - 1], dtype=np.int64),
})
phase_values = {}
else:
dtw = dual_cycle_dtw(_target_sensors(data["target_states"]), columns["sensors"],
columns["native_reward_dtw"], cycle_length=case.conv_len, lag_channel=3)
columns.update(target_normalized_dtw=dtw["target_normalized_dtw"],
target_normalized_dtw_lag=dtw["target_normalized_dtw_lag"])
min_gap = _minimum_crossing_interval(columns["physical_time"], case.conv_len)
phase = assign_periodic_phase(
columns["physical_time"], columns["sensors"], minimum_crossing_interval=min_gap,
)
_require_periodic_crossings(phase)
columns.update(phase=phase["phase"], cycle_id=phase["cycle_id"])
write_boundary_artifacts(role_dir / "timeseries.npz", role_dir / "timeseries.csv", columns)
phase_values = _phase_values(columns, role)
write_phase_cycle_artifacts(role_dir / "phase_cycle.npz", role_dir / "phase_cycle.csv", pooled_phase_bins(phase["phase"], phase_values, bins=32))
selected = select_phase_fields(columns["physical_time"], phase["crossing_times"])
mean_span = complete_cycle_field_indices(columns["physical_time"], phase["crossing_times"])
mean = accumulate_mean_fields(field_buffer["ux"], field_buffer["uy"], mean_span["field_indices"])
published = {
"ux": np.asarray(field_buffer["ux"][selected["field_indices"]], dtype=np.float32),
"uy": np.asarray(field_buffer["uy"][selected["field_indices"]], dtype=np.float32),
"mean_ux": mean["mean_ux"],
"mean_uy": mean["mean_uy"],
}
published.update(selected)
field_path = publish_selected_fields(role_dir / "phase_fields.npz", published)
with np.load(field_path, allow_pickle=False) as saved:
if (saved["ux"].shape[0] != 8 or set(saved.files) != PHASE_FIELD_KEYS
or saved["mean_ux"].shape != saved["ux"].shape[1:]
or saved["mean_uy"].shape != saved["uy"].shape[1:]):
raise ValueError("published phase fields failed exact-key validation")
reference = _controlled_reference(role, bundle, columns, case.conv_len)
_atomic_json(role_dir / "dtw_summary.json", {"native_pipeline": "unavailable for target role" if role == "target" else "original Legacy reward DTW",
"target_normalized_dtw": dtw["metadata"]["definition"], "historical_window": case.conv_len,
"lag_channel": 3, "target_scale": dtw["metadata"]["scale"].tolist(),
"native_mean": (None if role == "target" else float(np.nanmean(columns["native_reward_dtw"]))),
"target_normalized_dtw_finite_mean": float(np.nanmean(columns["target_normalized_dtw"])),
"crossing_count": int(len(phase["crossing_times"])),
"frozen_reference_comparison": reference})
phase_meta = {
"phase_contract": "smoothed rising crossings of sensors[:,3] with min-gap filter; complete half-open cycles",
"phase_smoothing_kernel": phase["smoothing_kernel"],
"minimum_crossing_interval": phase["minimum_crossing_interval"],
"accepted_crossing_count": phase["accepted_crossing_count"],
"rejected_crossing_count": phase["rejected_crossing_count"],
"complete_cycle_count": phase["complete_cycle_count"],
"mean_field_count": mean_span["mean_field_count"],
"mean_first_crossing_time": mean_span["first_crossing_time"],
"mean_last_crossing_time": mean_span["last_crossing_time"],
"candidate_field_storage": "single-role in-memory FP32 ux/uy buffer; no boundary_*.npz scratch",
}
cleanup_scratch(scratch, root=role_dir / "scratch"); scratch.parent.rmdir()
_atomic_json(role_dir / "metadata.json", {"schema": "drl-pinball-legacy-acquisition-v2", "case_id": case.name, "scene": case.scene, "role": role,
"warmup_control_steps": WARMUP_INTERVALS, "collected_post_step_boundaries": COLLECT_BOUNDARIES,
"si": case.sample_interval, "historical_window": case.conv_len, "phase_sensor": "fixed center sensor uy (sensors[:,3])" if case.scene != "steady" else None,
"field_contract": {"quantity": "q/RHO_ref", "RHO_ref": 1, "source": "public current_step_velocity_field q/U0 multiplied by field_config.velocity U0"},
"field_count": 1 if case.scene == "steady" else 8, "nonperiodic": case.scene == "steady",
"role_semantics": _role_semantics(role),
"timeline_contract": "V5 lattice_step is an absolute solver count; Legacy physical_time is derived only from relative Legacy lattice_step",
"source_provenance": {
"model_file": _identity(bundle["model_path"]) if role == "controlled" else None,
"normalizer_file": (_identity(bundle["norm_path"]) if bundle["norm_path"] is not None else None),
"frozen_reference_file": (_optional_file_provenance(bundle["reference_path"])
if role == "controlled" else None),
"target_states": _generated_array_identity(data["target_states"]),
"builder_config": _generated_config_identity(data["config"]),
},
"model": _identity(bundle["model_path"]) if role == "controlled" else None,
"normalizer": (_identity(bundle["norm_path"]) if bundle["norm_path"] is not None else None),
"normalization_source": ("frozen_policy" if bundle["norm_path"] is not None else
("builder_recomputed" if role != "target" else "not_applicable")),
"scratch_cleanup": {"complete": not scratch.exists(), "path": str(scratch)},
"resolved_optane_path": str(storage["resolved_output_root"]), "phase_variables": list(phase_values),
**phase_meta})
def _validate_staged_role(role_dir: Path, case_name: str = CASE_NAME) -> None:
steady = get_case(case_name).scene == "steady"
expected = ({"timeseries.npz", "timeseries.csv", "late_field.npz", "metadata.json"} if steady else
{"timeseries.npz", "timeseries.csv", "phase_cycle.npz", "phase_cycle.csv", "phase_fields.npz", "dtw_summary.json", "metadata.json"})
actual = {path.name for path in role_dir.iterdir()}
if actual != expected: raise ValueError(f"staged role files mismatch: expected {sorted(expected)}, got {sorted(actual)}")
field_name = "late_field.npz" if steady else "phase_fields.npz"
with np.load(role_dir / field_name, allow_pickle=False) as saved:
count = 1 if steady else 8
if saved["ux"].shape[0] != count or saved["uy"].shape[0] != count: raise ValueError("field count contract failed")
if not steady:
if (set(saved.files) != PHASE_FIELD_KEYS
or saved["mean_ux"].shape != saved["ux"].shape[1:]
or saved["mean_uy"].shape != saved["uy"].shape[1:]):
raise ValueError("phase fields exact schema failed")
with (role_dir / "metadata.json").open(encoding="utf-8") as stream: json.load(stream)
def _acquire_standard(role, *, case_name=CASE_NAME, output_root=None, overwrite=False, device_id=0,
repo_root=None, storage_validator=validate_output_storage,
runtime_factory=None, finalizer=_finalize, **unused):
case = get_case(case_name)
if role not in _case_roles(case): raise ValueError(f"unknown acquisition role {role!r} for {case_name}")
repo = Path(repo_root) if repo_root is not None else Path(__file__).resolve().parents[3]
storage = storage_validator(repo_mapping=default_reproduction_mapping(repo),
output_root=output_root, min_free_bytes=MIN_FREE_BYTES)
if case_name == CASE_NAME and role == "controlled":
bundle = _resolve_bundle(repo)
else:
bundle = _resolve_bundle(repo, case_name, role)
prepared = prepare_role_output(storage, "legacy", case_name, role, overwrite=overwrite)
scratch = create_scratch(prepared["scratch_root"])
ff = None
try:
if runtime_factory is None:
data, model, ff = _default_runtime(bundle["case"], bundle, device_id, role)
else:
data, model, ff = runtime_factory(bundle["case"], bundle, device_id, role)
policy_norm = (None if role == "target" else
(data["norm"] if bundle["case"].scene == "steady" else bundle["norm"]))
rows, fields = _collect_role(role, data, model, ff, policy_norm, scratch, case_name=case_name)
finalizer(prepared["role_dir"], scratch, rows, fields, data, bundle, storage, role)
_validate_staged_role(prepared["role_dir"], case_name)
return publish_role_output(prepared)
except Exception:
if prepared["staging_dir"].exists():
shutil.rmtree(prepared["staging_dir"])
raise
finally:
if ff is not None and hasattr(ff, "close"):
ff.close()
def _vortex_offset(case_name, value):
if type(value) is not int:
raise ValueError("vortex y offset must be an integer L0 multiple")
allowed = {0} if case_name == "vortex_lamb" else {-2, -1, 0, 1, 2}
if value not in allowed:
raise ValueError(f"{case_name} vortex y offset must be one of {sorted(allowed)}")
return value
def _vortex_scenario(case_name, offset):
_vortex_offset(case_name, offset)
label = {0:"y000", -2:"ym2L", -1:"ym1L", 1:"yp1L", 2:"yp2L"}[offset]
return f"{case_name}_{label}"
def _vortex_event_channel(case_name):
if case_name == "vortex_lamb":
return 1, "upper_sensor_uy"
if case_name == "vortex_taylor":
return 3, "center_sensor_uy"
raise ValueError(f"unknown Vortex case {case_name!r}")
def _select_vortex_event(sensors, case_name):
sensors = np.asarray(sensors, np.float64)
if sensors.ndim != 2 or sensors.shape[1:] != (6,) or len(sensors) != 150 or not np.all(np.isfinite(sensors)):
raise ValueError("Vortex event selection requires finite sensors shape (150,6)")
channel, channel_name = _vortex_event_channel(case_name)
uy = sensors[:, channel]
baseline = float(np.median(uy[:20]))
score = np.abs(uy - baseline)
lower, upper = 38, 87
peak = lower + int(np.argmax(score[lower:upper + 1]))
indices = peak + np.asarray([-10, -5, 0, 5, 10], np.int64)
return {"baseline_uy": baseline, "event_score": score,
"selected_event_score": score[indices], "peak_index": np.int64(peak),
"field_indices": indices,
"relative_offsets": np.asarray([-10, -5, 0, 5, 10], np.int64),
"search_lower_boundary": lower, "search_upper_boundary": upper,
"event_channel_index": channel, "event_channel": channel_name}
def _canonical_vortex_source(case_name):
return "vortex_lamb_y000" if case_name == "vortex_lamb" else "vortex_taylor_y000"
def _load_vortex_event_source(storage, case_name):
source = Path(storage["resolved_output_root"]) / "legacy" / _canonical_vortex_source(case_name) / "controlled" / "event_summary.json"
if not source.is_file():
raise FileNotFoundError(f"canonical Vortex event source is required before this run: {source}")
try:
with source.open(encoding="utf-8") as stream:
summary = json.load(stream)
except (OSError, json.JSONDecodeError) as exc:
raise ValueError(f"malformed canonical Vortex event source {source}") from exc
channel, channel_name = _vortex_event_channel(case_name)
expected_source = {"scenario": _canonical_vortex_source(case_name), "role": "controlled",
"channel_index": channel, "channel": channel_name}
required = {"schema", "event_source", "common_peak_index", "field_indices", "relative_offsets"}
if not isinstance(summary, dict) or not required.issubset(summary):
raise ValueError(f"malformed canonical Vortex event source {source}")
try:
peak = int(summary["common_peak_index"])
indices = np.asarray(summary["field_indices"], np.int64)
offsets = np.asarray(summary["relative_offsets"], np.int64)
except (TypeError, ValueError) as exc:
raise ValueError(f"malformed canonical Vortex event source {source}") from exc
if (summary["schema"] != "drl-pinball-legacy-vortex-event-v2" or
summary["event_source"] != expected_source or indices.shape != (5,) or
not np.array_equal(offsets, [-10, -5, 0, 5, 10]) or
not np.array_equal(indices, peak + offsets) or peak < 38 or peak > 87):
raise ValueError(f"malformed canonical Vortex event source {source}")
return {"source_path": source, "event_source": expected_source,
"peak_index": peak, "field_indices": indices, "relative_offsets": offsets}
def _erase_phase(times, center_uy, disturbance_force_y):
try:
phase = _assign_scalar_phase(times, center_uy)
if len(phase["crossing_times"]) >= 4:
return phase, "center_sensor_uy", False
except ValueError as exc:
if "rising crossings" not in str(exc): raise
phase = _assign_scalar_phase(times, disturbance_force_y)
if len(phase["crossing_times"]) < 4:
raise ValueError("Erase publication requires at least 4 rising crossings in fixed disturbance-force fallback")
return phase, "disturbance_force_y", True
def _assign_scalar_phase(times, probe):
probe=np.asarray(probe,np.float64); times=np.asarray(times,np.float64)
if probe.shape != times.shape or probe.ndim != 1 or not np.all(np.isfinite(probe)):
raise ValueError("phase probe must be one-dimensional, aligned, and finite")
synthetic=np.zeros((len(times),4),np.float64); synthetic[:,3]=probe
return assign_phase(times,synthetic)
def _special_observation(case, role, raw):
raw=np.asarray(raw,np.float32)
if case.scene == "erase":
if role == "target": return raw[:6], np.full(2,np.nan,np.float32), np.full(6,np.nan,np.float32), raw[:6]
if raw.shape != (14,): raise ValueError("Erase observation must contain exactly 14 channels")
return raw[:6], raw[6:8], raw[8:14], raw
if raw.shape != (6 if role == "target" else 12,): raise ValueError("Vortex observation inventory changed")
return raw[:6], np.full(2,np.nan,np.float32), (np.full(6,np.nan,np.float32) if role=="target" else raw[6:12]), raw
def _special_schedule(case, target):
if case.scene == "erase":
return (0 if target else 480), 160
return 0, 150
def _special_timeline_metadata(case, target):
warmup, collected = _special_schedule(case, target)
return {"warmup_control_steps": warmup,
"collected_post_step_boundaries": collected,
"historical_episode_steps": 150 if case.scene == "vortex" else None}
def _collect_special(role,data,model,ff,norm,scratch,case,capture_field=_capture_boundary_field):
target = role == "target"; expected = 3 if target else (7 if case.scene=="erase" else 6)
if int(data["config"]["n_obj_total"]) != expected: raise ValueError("special scene object inventory changed")
if target: fifo=None; obs=None
else:
saved=np.asarray(data["norm"]["save_states"],np.float32)
fifo,obs=reset_runtime(ff,saved,14 if case.scene=="erase" else 12)
if case.scene == "erase":
if saved.ndim != 2 or saved.shape[1] != 14:
raise ValueError("Erase reset FIFO must retain exactly 14 raw channels")
obs=np.zeros(12,dtype=np.float32)
warmup,collect=_special_schedule(case,target)
rows=[]; fields=[]
for interval in range(warmup+collect):
if role=="controlled": action,_=model.predict(obs,deterministic=True); action,command=_command(case,action,expected,ff.DATA_TYPE)
elif role=="zero":
action=-np.asarray(case.action_bias,np.float32)/np.float32(case.action_scale); action,command=_command(case,action,expected,ff.DATA_TYPE); command[-3:]=0
else: action=np.full(3,np.nan,np.float32); command=np.zeros(expected,ff.DATA_TYPE)
_run_interval(ff,case.sample_interval,command); sensors,disturbance,pinball,raw=_special_observation(case,role,np.asarray(ff.obs,np.float32).copy())
if target: terms={}; effective=np.full(3,np.nan,np.float32)
else:
fifo.append(raw.copy())
if case.scene=="erase": terms=erase_reward_terms(np.asarray(fifo),np.mean(data["target_states"],axis=0),norm,case.conv_len); obs=erase_policy_observation(raw,norm)
else: terms=reward_terms(case,data["target_states"],None,np.asarray(fifo),norm["force_norm_fact"],interval); obs=policy_observation(raw,norm)
effective=np.asarray(ff.current_effective_action(),np.float32)[-3:].copy()
if interval < warmup: continue
lattice,clock,absolute=_clock(ff,interval+1,case.sample_interval)
row={"physical_time":lattice*U0/NX,"lattice_step":lattice,"control_index":clock,"sensors":sensors.copy(),
"disturbance_force":disturbance.copy(),"pinball_forces":pinball.copy(),"action_normalized":action.copy(),
"commanded_target_omega":np.full(3,np.nan,np.float32) if target else command[-3:].astype(np.float32),
"effective_smoothed_omega":effective,"reward_raw":float(terms.get("reward",np.nan)),
"reward_u":float(terms.get("reward_u",np.nan)),"reward_v":float(terms.get("reward_v",np.nan)),
"native_reward_dtw":float(terms.get("native_legacy_dtw",np.nan)),"native_lag":float(terms.get("native_lag",np.nan)),
"native_component_similarity":np.asarray(terms.get("native_component_similarity",[np.nan,np.nan]))}
if absolute is not None: row["solver_absolute_lattice_step"]=absolute
rows.append(row); value=capture_field(ff,int(data["config"]["nx"]),int(data["config"]["ny"])); path=scratch/f"boundary_{len(rows)-1:04d}.npz"; np.savez(path,**value); fields.append(path)
return rows,fields
def _publish_fields(path,field_paths,selection):
result={"ux":[],"uy":[]}; indices=np.asarray(selection["field_indices"],np.int64)
for index in indices:
with np.load(field_paths[int(index)],allow_pickle=False) as item:
result["ux"].append(item["ux"]); result["uy"].append(item["uy"])
result={key:np.stack(value) for key,value in result.items()}; result.update(selection); publish_selected_fields(path,result)
def _special_normalization_source(case, role):
if role == "target":
return "not_applicable"
if case.scene == "erase":
return "builder_recomputed_historical"
if case.scene == "vortex" and role == "zero":
return "builder_recomputed_zero_runtime"
return "frozen_policy"
def _special_normalizer_file(bundle):
return _identity(bundle["norm_path"]) if bundle["norm_path"] is not None else None
def _finalize_special(role_dir,scratch,rows,field_paths,data,bundle,storage,role,event_source=None):
case=bundle["case"]; columns=_columns(rows); target=role=="target"; event_peak_index=None; event_diagnostics=None
if case.scene=="vortex":
channel, channel_name = _vortex_event_channel(case.name)
own_uy = np.asarray(columns["sensors"][:, channel], np.float64)
own_baseline = float(np.median(own_uy[:20]))
own_score = np.abs(own_uy - own_baseline)
columns["event_score"] = own_score
if event_source is None:
local = _select_vortex_event(columns["sensors"], case.name)
event_source = {"event_source": {"scenario": _canonical_vortex_source(case.name),
"role": "controlled", "channel_index": channel, "channel": channel_name},
"peak_index": int(local["peak_index"]),
"field_indices": local["field_indices"],
"relative_offsets": local["relative_offsets"]}
indices = np.asarray(event_source["field_indices"], np.int64)
offsets = np.asarray(event_source["relative_offsets"], np.int64)
event_selection = {"field_indices": indices, "relative_offsets": offsets,
"selected_event_score": own_score[indices]}
write_boundary_artifacts(role_dir/"timeseries.npz",role_dir/"timeseries.csv",columns)
_publish_fields(role_dir/"event_fields.npz",field_paths,event_selection)
event_peak_index = int(event_source["peak_index"])
event_diagnostics = {"search_lower_boundary": 38, "search_upper_boundary": 87,
"diagnostic_channel_index": channel, "diagnostic_channel": channel_name}
_atomic_json(role_dir/"event_summary.json", {"schema":"drl-pinball-legacy-vortex-event-v2",
"definition":"canonical controlled fixed-channel absolute deviation from median first 20; earliest argmax in inclusive boundaries 38..87",
"event_source":event_source["event_source"], "common_peak_index":event_peak_index,
"field_indices":indices.tolist(), "relative_offsets":offsets.tolist(),
"selected_event_score":own_score[indices].tolist(), "diagnostic_baseline_uy":own_baseline,
"diagnostic_peak_score":float(own_score[event_peak_index]), **event_diagnostics})
field_count=5; nonperiodic=True; probe=None
elif target:
write_boundary_artifacts(role_dir/"timeseries.npz",role_dir/"timeseries.csv",columns); _publish_fields(role_dir/"late_field.npz",field_paths,{"field_indices":np.asarray([len(rows)-1],np.int64)}); field_count=1; nonperiodic=True; probe=None
err=columns["sensors"]-np.mean(data["target_states"],axis=0); _atomic_json(role_dir/"erase_summary.json",{"native_metrics":"unavailable for clean target","clean_sensor_rmse":float(np.sqrt(np.mean(err*err))),"phase_probe":None,"nonperiodic_clean_reference":True})
else:
phase,probe,fallback=_erase_phase(columns["physical_time"],columns["sensors"][:,3],columns["disturbance_force"][:,1])
columns.update(phase=phase["phase"],cycle_id=phase["cycle_id"]); write_boundary_artifacts(role_dir/"timeseries.npz",role_dir/"timeseries.csv",columns)
values=_phase_values(columns,role); write_phase_cycle_artifacts(role_dir/"phase_cycle.npz",role_dir/"phase_cycle.csv",pooled_phase_bins(phase["phase"],values,bins=32)); _publish_fields(role_dir/"phase_fields.npz",field_paths,select_phase_fields(columns["physical_time"],phase["crossing_times"])); field_count=8; nonperiodic=False
err=columns["sensors"]-np.mean(data["target_states"],axis=0); _atomic_json(role_dir/"erase_summary.json",{"native_reward_mean":float(np.mean(columns["reward_raw"])),"reward_u_mean":float(np.mean(columns["reward_u"])),"reward_v_mean":float(np.mean(columns["reward_v"])),"native_reward_dtw_mean":float(np.mean(columns["native_reward_dtw"])),"clean_sensor_rmse":float(np.sqrt(np.mean(err*err))),"phase_probe":probe,"phase_fallback_used":fallback,"force_balance_diagnostic":"native enhanced formula is scale invariant; normalized diagnostic equals native by definition"})
cleanup_scratch(scratch,root=role_dir/"scratch"); scratch.parent.rmdir()
timeline=_special_timeline_metadata(case,target)
_atomic_json(role_dir/"metadata.json",{"schema":"drl-pinball-legacy-nonstandard-acquisition-v1","case_id":case.name,"scene":case.scene,"role":role,**timeline,"field_count":field_count,"nonperiodic":nonperiodic,"phase_probe":probe,"event_peak_index":event_peak_index,"event_source":(event_source["event_source"] if event_source else None),"event_diagnostics":event_diagnostics,"normalization_source":_special_normalization_source(case,role),"role_semantics":(("physical-zero/uncontrolled trajectory using counter-bias normalized action; builder-recomputed historical norm" if role=="zero" else "frozen-policy controlled trajectory using builder-recomputed historical norm") if case.scene=="erase" and not target else ("physical-zero/uncontrolled trajectory using counter-bias normalized action; builder-recomputed zero-runtime norm" if case.scene=="vortex" and role=="zero" else _role_semantics(role))),"field_contract":{"quantity":"q/RHO_ref","RHO_ref":1},"source_provenance":{"model_file":_identity(bundle["model_path"]) if role=="controlled" else None,"normalizer_file":_special_normalizer_file(bundle),"target_states":_generated_array_identity(data["target_states"]),"builder_config":_generated_config_identity(data["config"])},"resolved_optane_path":str(storage["resolved_output_root"])})
def _validate_special(role_dir,case,role):
if case.scene=="vortex": expected={"timeseries.npz","timeseries.csv","event_fields.npz","event_summary.json","metadata.json"}; field="event_fields.npz"; count=5
elif role=="target": expected={"timeseries.npz","timeseries.csv","late_field.npz","erase_summary.json","metadata.json"}; field="late_field.npz"; count=1
else: expected={"timeseries.npz","timeseries.csv","phase_cycle.npz","phase_cycle.csv","phase_fields.npz","erase_summary.json","metadata.json"}; field="phase_fields.npz"; count=8
actual={x.name for x in role_dir.iterdir()}
if actual!=expected: raise ValueError(f"staged role files mismatch: expected {sorted(expected)}, got {sorted(actual)}")
with np.load(role_dir/field,allow_pickle=False) as saved:
expected_keys = ({"ux", "uy", "field_indices", "relative_offsets", "selected_event_score"}
if case.scene == "vortex" else None)
if expected_keys is not None and set(saved.files) != expected_keys:
raise ValueError(f"Vortex event field keys mismatch: expected {sorted(expected_keys)}, got {sorted(saved.files)}")
if any(np.asarray(saved[name]).ndim < 1 or np.asarray(saved[name]).shape[0] != count
for name in saved.files):
raise ValueError("special field arrays must be non-scalar with exact field count")
if case.scene == "vortex":
field_indices = np.asarray(saved["field_indices"], np.int64)
relative_offsets = np.asarray(saved["relative_offsets"], np.int64)
if case.scene == "vortex":
try:
with (role_dir/"event_summary.json").open(encoding="utf-8") as stream:
summary = json.load(stream)
channel, channel_name = _vortex_event_channel(case.name)
expected_source = {"scenario": _canonical_vortex_source(case.name), "role": "controlled",
"channel_index": channel, "channel": channel_name}
peak = int(summary["common_peak_index"])
if (summary["schema"] != "drl-pinball-legacy-vortex-event-v2" or
summary["event_source"] != expected_source or
not np.array_equal(field_indices, peak + relative_offsets) or
not np.array_equal(field_indices, np.asarray(summary["field_indices"], np.int64)) or
not np.array_equal(relative_offsets, [-10, -5, 0, 5, 10])):
raise ValueError
except (OSError, KeyError, TypeError, ValueError, json.JSONDecodeError) as exc:
raise ValueError("Vortex event summary/source contract failed") from exc
def _acquire_special(role,case_name,output_root,overwrite,device_id,repo_root,storage_validator,runtime_factory,vortex_y_offset_l0):
case=get_case(case_name); repo=Path(repo_root) if repo_root else Path(__file__).resolve().parents[3]; offset=_vortex_offset(case_name,vortex_y_offset_l0) if case.scene=="vortex" else 0
storage=storage_validator(repo_mapping=default_reproduction_mapping(repo),output_root=output_root,min_free_bytes=MIN_FREE_BYTES); bundle=_resolve_bundle(repo,case_name,role)
scenario=_vortex_scenario(case_name,offset) if case.scene=="vortex" else case_name
source_run = case.scene == "vortex" and role == "controlled" and offset == 0
event_source = None if not case.scene == "vortex" or source_run else _load_vortex_event_source(storage, case_name)
prepared=prepare_role_output(storage,"legacy",scenario,role,overwrite=overwrite); scratch=create_scratch(prepared["scratch_root"]); ff=None
try:
if runtime_factory is not None: data,model,ff=runtime_factory(case,bundle,device_id,role)
else:
from .core.legacy_env_builder import build_erase,build_vortex
data=build_vortex(device_id,case.re_code,vortex_type=case.vortex_type,action_scale=case.action_scale,y_offset_l0=offset,target_only=role=="target",role=role) if case.scene=="vortex" else build_erase(device_id,case.re_code,target_only=role=="target")
model=None
if role=="controlled":
from .core.model_loader import load_model
model=load_model(case.model)
ff=data["flow_field"]
norm=None if role=="target" else (data["norm"] if case.scene=="erase" or (case.scene=="vortex" and role=="zero") else bundle["norm"]); rows,fields=_collect_special(role,data,model,ff,norm,scratch,case); _finalize_special(prepared["role_dir"],scratch,rows,fields,data,bundle,storage,role,event_source); _validate_special(prepared["role_dir"],case,role); return publish_role_output(prepared)
except Exception:
if prepared["staging_dir"].exists(): shutil.rmtree(prepared["staging_dir"])
raise
finally:
if ff is not None and hasattr(ff,"close"): ff.close()
def acquire_role(role, *, case_name=CASE_NAME, output_root=None, overwrite=False, device_id=0, repo_root=None, storage_validator=validate_output_storage, runtime_factory=None, finalizer=_finalize, vortex_y_offset_l0=0):
case=get_case(case_name)
if role not in _case_roles(case): raise ValueError(f"unknown acquisition role {role!r} for {case_name}")
if case.scene in ("vortex","erase"):
return _acquire_special(role,case_name,output_root,overwrite,device_id,repo_root,storage_validator,runtime_factory,vortex_y_offset_l0)
return _acquire_standard(role,case_name=case_name,output_root=output_root,overwrite=overwrite,device_id=device_id,repo_root=repo_root,storage_validator=storage_validator,runtime_factory=runtime_factory,finalizer=finalizer)
def acquire_controlled(**kwargs):
return acquire_role("controlled", **kwargs)
def main() -> int:
parser = argparse.ArgumentParser(description="Acquire Legacy full-matrix evidence")
parser.add_argument("case", choices=SUPPORTED_CASES)
parser.add_argument("--role", choices=ROLES + ("constant",), required=True)
parser.add_argument("--device-id", type=int, default=0)
parser.add_argument("--vortex-y-offset-l0", type=int, default=0)
parser.add_argument("--output-root", type=Path); parser.add_argument("--overwrite", action="store_true")
args = parser.parse_args()
path = acquire_role(args.role, case_name=args.case, output_root=args.output_root, overwrite=args.overwrite, device_id=args.device_id, vortex_y_offset_l0=args.vortex_y_offset_l0)
print(path); return 0
if __name__ == "__main__":
raise SystemExit(main())
+36
View File
@@ -0,0 +1,36 @@
"""Canonical case matrix for LegacyCelerisLab reproduction."""
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class Case:
name: str
scene: str
model: str | None
re_code: float = 100.0
sample_interval: int = 800
steps: int = 200
action_scale: float = 8.0
action_bias: tuple[float, float, float] = (0.0, -4.0, 4.0)
conv_len: int = 30
target_radius_l: float | None = None
vortex_type: str | None = None
reference_case: str | None = None
CASES = {
**{f"karman_re{re}": Case(f"karman_re{re}", "karman", f"d1a3o12_re{re}", re_code=float(re)) for re in (50, 100, 200, 400)},
"illusion_075L": Case("illusion_075L", "illusion", "d1a3o14_250525_imit_075L_2U_400S", sample_interval=400, action_bias=(0.0, -2.0, 2.0), conv_len=36, target_radius_l=0.75, reference_case="illusion_0.75L"),
"illusion_1L": Case("illusion_1L", "illusion", "d1a3o14_250525_imit_1L_2U_600S", sample_interval=600, action_bias=(0.0, -2.0, 2.0), conv_len=36, target_radius_l=1.0),
"illusion_15L": Case("illusion_15L", "illusion", "d1a3o14_250525_imit_15L_2U", action_bias=(0.0, -2.0, 2.0), conv_len=36, target_radius_l=1.5, reference_case="illusion_1.5L"),
"vortex_lamb": Case("vortex_lamb", "vortex", "vortex_lamb", steps=150, action_scale=4.0, vortex_type="lamb"),
"vortex_taylor": Case("vortex_taylor", "vortex", "vortex_taylor", steps=150, action_scale=4.0, vortex_type="taylor"),
"erase": Case("erase", "erase", "d1a3o12_250729_250326_erase", sample_interval=600, action_scale=8.0, action_bias=(0.0, -8.0, 8.0), conv_len=36),
"steady": Case("steady", "steady", None, action_bias=(0.0, -5.1, 5.1)),
}
ERASE_UNSUPPORTED = "" # compatibility name; Erase is acquisition-only and supported.
def get_case(name: str) -> Case:
try:
return CASES[name]
except KeyError as exc:
raise KeyError(f"unknown case {name!r}; choose from {sorted(CASES)} or erase") from exc
@@ -35,6 +35,7 @@ if _REPO not in sys.path:
from LegacyCelerisLab import FlowField # noqa: E402
from LegacyCelerisLab import utils as legacy_utils # noqa: E402
from ..runtime import run_historical_interval # noqa: E402
# ---------------------------------------------------------------------------
# Constants
@@ -58,9 +59,20 @@ def _center_y(ff: FlowField) -> float:
return (ff.FIELD_SHAPE[1] - 1) / 2.0
def _field_xy(ff: FlowField) -> Tuple[int, int]:
"""Return Legacy's 2-D lattice dimensions from its 3-D shape contract."""
shape = tuple(ff.FIELD_SHAPE)
if len(shape) != 3:
raise ValueError(f"Legacy FlowField.FIELD_SHAPE must be (nx, ny, nz), got {shape}")
nx, ny, nz = shape
if nz != 1:
raise ValueError(f"Legacy builder requires nz=1, got FIELD_SHAPE={shape}")
return int(nx), int(ny)
def _stabilize(ff: FlowField, n_obj: int) -> None:
steps = int(4 * ff.FIELD_SHAPE[0] / U0)
ff.run(steps, np.zeros(n_obj, dtype=DATA_TYPE))
run_historical_interval(ff, steps, np.zeros(n_obj, dtype=DATA_TYPE))
def _compute_karman_norm(fifo: np.ndarray) -> Dict[str, Any]:
@@ -82,6 +94,37 @@ def _compute_karman_norm(fifo: np.ndarray) -> Dict[str, Any]:
# Karman Cloak (dist-cyl + 3 sensors + 3 pinball = 7 objects)
# ---------------------------------------------------------------------------
def build_karman_target(
device_id: int = 0,
re_code: float = 100.0,
*,
sample_interval: int = 800,
) -> Dict[str, Any]:
"""Build only the canonical disturbance-cylinder Karman target stage."""
viscosity = _nu_from_re(re_code)
cuda_cfg = legacy_utils.load_cuda_config(os.path.join(CONFIG_DIR, "config_cuda.json"))
field_cfg = legacy_utils.load_flow_field_config(os.path.join(CONFIG_DIR, "config_flowfield.json"))
field_cfg = field_cfg._replace(viscosity=float(viscosity))
ff = FlowField(field_cfg, cuda_cfg, device_id=device_id)
cy = _center_y(ff)
nx, ny = _field_xy(ff)
ff.add_cylinder((10.0 * L0, cy, 0.0), 1.0 * L0)
for y_off in (2.0, 0.0, -2.0):
ff.add_sensor((40.0 * L0, cy + y_off * L0, 0.0), SENSOR_RADIUS)
if ff.obs.size // 2 != 4:
raise ValueError("canonical Karman target requires disturbance plus three sensors")
_stabilize(ff, 4)
target = np.empty((FIFO_LEN, 6), dtype=DATA_TYPE)
for index in range(FIFO_LEN):
run_historical_interval(ff, sample_interval, np.zeros(4, dtype=DATA_TYPE))
target[index] = ff.obs.copy()[2:8]
config = {"device_id": device_id, "viscosity": viscosity, "re_code": re_code,
"u0": U0, "sample_interval": sample_interval, "fifo_len": FIFO_LEN,
"conv_len": CONV_LEN, "nx": nx, "ny": ny, "n_obj_total": 4,
"obs_slice": (2, 8), "s_dim": 6}
return {"flow_field": ff, "target_states": target, "config": config}
def build_karman_cloak(
device_id: int = 0,
re_code: float = 100.0,
@@ -119,7 +162,7 @@ def build_karman_cloak(
# Record target (sensor signals only, no pinball)
target = np.empty((0, 6), dtype=DATA_TYPE)
for _ in range(FIFO_LEN):
ff.run(sample_interval, np.zeros(4, dtype=DATA_TYPE))
run_historical_interval(ff, sample_interval, np.zeros(4, dtype=DATA_TYPE))
target = np.vstack((target, ff.obs.copy()[2:8]))
# Phase 2: Add pinball
@@ -139,7 +182,7 @@ def build_karman_cloak(
# Zero-action norm collection
fifo = deque(maxlen=FIFO_LEN)
for _ in range(FIFO_LEN):
ff.run(sample_interval, np.zeros(7, dtype=DATA_TYPE))
run_historical_interval(ff, sample_interval, np.zeros(7, dtype=DATA_TYPE))
fifo.append(ff.obs.copy()[2:14])
norm = _compute_karman_norm(np.array(fifo, dtype=DATA_TYPE))
@@ -152,7 +195,7 @@ def build_karman_cloak(
fifo.clear()
for _ in range(FIFO_LEN):
ff.run(sample_interval, bias_arr)
run_historical_interval(ff, sample_interval, bias_arr)
fifo.append(ff.obs.copy()[2:14])
save_states = np.array(list(fifo), dtype=DATA_TYPE)
ff.apply_ddf()
@@ -181,6 +224,29 @@ def build_karman_cloak(
return {"flow_field": ff, "target_states": target, "norm": norm, "config": config}
def build_steady_target(
device_id: int = 0, re_code: float = 100.0, *, sample_interval: int = 800,
) -> Dict[str, Any]:
"""Build the clean-channel three-sensor steady target."""
viscosity = _nu_from_re(re_code)
cuda_cfg = legacy_utils.load_cuda_config(os.path.join(CONFIG_DIR, "config_cuda.json"))
field_cfg = legacy_utils.load_flow_field_config(os.path.join(CONFIG_DIR, "config_flowfield.json"))._replace(viscosity=float(viscosity))
ff = FlowField(field_cfg, cuda_cfg, device_id=device_id)
cy = _center_y(ff); nx, ny = _field_xy(ff)
for y_off in (2.0, 0.0, -2.0):
ff.add_sensor((40.0 * L0, cy + y_off * L0, 0.0), SENSOR_RADIUS)
_stabilize(ff, 3)
target = np.empty((FIFO_LEN, 6), dtype=DATA_TYPE)
for index in range(FIFO_LEN):
run_historical_interval(ff, sample_interval, np.zeros(3, dtype=DATA_TYPE))
target[index] = ff.obs.copy()[:6]
config = {"device_id": device_id, "viscosity": viscosity, "re_code": re_code,
"u0": U0, "sample_interval": sample_interval, "fifo_len": FIFO_LEN,
"conv_len": CONV_LEN, "nx": nx, "ny": ny, "n_obj_total": 3,
"obs_slice": (0, 6), "s_dim": 6}
return {"flow_field": ff, "target_states": target, "config": config}
# ---------------------------------------------------------------------------
# Steady Cloak (3 sensors + 3 pinball = 6 objects, no dist-cyl)
# ---------------------------------------------------------------------------
@@ -230,7 +296,7 @@ def build_steady_cloak(
target = np.empty((0, 6), dtype=DATA_TYPE)
for _ in range(FIFO_LEN):
ff2.run(800, np.zeros(3, dtype=DATA_TYPE))
run_historical_interval(ff2, 800, np.zeros(3, dtype=DATA_TYPE))
target = np.vstack((target, ff2.obs.copy()[0:6]))
del ff2
@@ -241,7 +307,7 @@ def build_steady_cloak(
# Norm
fifo = deque(maxlen=FIFO_LEN)
for _ in range(FIFO_LEN):
ff.run(800, np.zeros(6, dtype=DATA_TYPE))
run_historical_interval(ff, 800, np.zeros(6, dtype=DATA_TYPE))
fifo.append(ff.obs.copy()[0:12])
temp = np.array(fifo, dtype=DATA_TYPE)
force_norm_fact = 6.0 * float(np.max(np.abs(temp[:, 6:12])))
@@ -259,7 +325,7 @@ def build_steady_cloak(
fifo.clear()
for _ in range(FIFO_LEN):
ff.run(800, bias_arr)
run_historical_interval(ff, 800, bias_arr)
fifo.append(ff.obs.copy()[0:12])
save_states = np.array(list(fifo), dtype=DATA_TYPE)
ff.apply_ddf()
@@ -293,6 +359,33 @@ def build_steady_cloak(
return {"flow_field": ff, "target_states": target, "norm": norm, "config": config}
def build_illusion_target(
device_id: int = 0, re_code: float = 100.0, *, target_diameter_L: float = 1.0,
sample_interval: int = 600,
) -> Dict[str, Any]:
"""Build the target-cylinder stage and retain its public flow field."""
viscosity = _nu_from_re(re_code)
cuda_cfg = legacy_utils.load_cuda_config(os.path.join(CONFIG_DIR, "config_cuda.json"))
field_cfg = legacy_utils.load_flow_field_config(os.path.join(CONFIG_DIR, "config_flowfield.json"))._replace(viscosity=float(viscosity))
ff = FlowField(field_cfg, cuda_cfg, device_id=device_id)
cy = _center_y(ff); nx, ny = _field_xy(ff)
ff.add_cylinder((20.0 * L0, cy, 0.0), target_diameter_L * L0)
for y_off in (2.0, 0.0, -2.0):
ff.add_sensor((30.0 * L0, cy + y_off * L0, 0.0), SENSOR_RADIUS)
_stabilize(ff, 4)
target = np.empty((FIFO_LEN, 8), dtype=DATA_TYPE)
for index in range(FIFO_LEN):
run_historical_interval(ff, sample_interval, np.zeros(4, dtype=DATA_TYPE))
target[index] = ff.obs.copy()[:8]
from .dtw_metrics import analyze_harmonics
harmonics = analyze_harmonics(target, n_harmonics=5)
config = {"device_id": device_id, "viscosity": viscosity, "re_code": re_code,
"u0": U0, "sample_interval": sample_interval, "fifo_len": FIFO_LEN,
"conv_len": 36, "nx": nx, "ny": ny, "n_obj_total": 4,
"obs_slice": (0, 8), "s_dim": 8, "target_diameter_L": target_diameter_L}
return {"flow_field": ff, "target_states": target, "target_harmonics": harmonics, "config": config}
# ---------------------------------------------------------------------------
# Illusion (target cylinder + 3 sensors at illusion positions, then pinball + sensors)
# ---------------------------------------------------------------------------
@@ -333,7 +426,7 @@ def build_illusion(
# Record target (8 channels: cyl_force[2] + sensors[6])
target_states = np.empty((0, 8), dtype=DATA_TYPE)
for _ in range(FIFO_LEN):
ff_target.run(sample_interval, np.zeros(4, dtype=DATA_TYPE))
run_historical_interval(ff_target, sample_interval, np.zeros(4, dtype=DATA_TYPE))
target_states = np.vstack((target_states, ff_target.obs.copy()[0:8]))
# Harmonics analysis (FFT)
@@ -364,7 +457,7 @@ def build_illusion(
# Norm
fifo = deque(maxlen=FIFO_LEN)
for _ in range(FIFO_LEN):
ff.run(sample_interval, np.zeros(6, dtype=DATA_TYPE))
run_historical_interval(ff, sample_interval, np.zeros(6, dtype=DATA_TYPE))
fifo.append(ff.obs.copy()[0:12])
norm = _compute_karman_norm(np.array(fifo, dtype=DATA_TYPE))
@@ -378,10 +471,12 @@ def build_illusion(
fifo.clear()
for _ in range(FIFO_LEN):
ff.run(sample_interval, bias_arr)
run_historical_interval(ff, sample_interval, bias_arr)
fifo.append(ff.obs.copy()[0:12])
save_states = np.array(list(fifo), dtype=DATA_TYPE)
ff.apply_ddf()
# Original Illusion CustomEnv overwrites the checkpoint after bias FIFO.
ff.get_ddf()
ff.save_ddf()
norm["save_states"] = save_states
norm["action_bias"] = list(action_bias)
@@ -419,128 +514,73 @@ def build_illusion(
# ---------------------------------------------------------------------------
def build_vortex(
device_id: int = 0,
re_code: float = 100.0,
*,
vortex_type: str = "lamb",
action_scale: float = 4.0,
action_bias: Tuple[float, float, float] = (0.0, -4.0, 4.0),
device_id: int = 0, re_code: float = 100.0, *, vortex_type: str = "lamb",
action_scale: float = 4.0, action_bias: Tuple[float, float, float] = (0.0, -4.0, 4.0),
y_offset_l0: int = 0, target_only: bool = False, role: Optional[str] = None,
) -> Dict[str, Any]:
"""Build vortex cloaking environment.
Target phase: vortex at x=10*L0 + 3 sensors.
Pinball phase: vortex at x=15*L0 + pinball + 3 sensors.
Object order (pinball phase): sensor0(0), sensor1(1), sensor2(2),
front(3), top(4), bottom(5).
MAX_STEPS = 150 (transient event).
"""
"""Build Vortex with an explicit role-bound runtime handoff."""
if role is None:
role = "target" if target_only else "controlled"
if role not in ("controlled", "target", "zero"):
raise ValueError(f"invalid Vortex builder role {role!r}")
if target_only != (role == "target"):
raise ValueError("Vortex target_only and role semantics disagree")
allowed = {0} if vortex_type == "lamb" else {-2, -1, 0, 1, 2} if vortex_type == "taylor" else set()
if type(y_offset_l0) is not int or y_offset_l0 not in allowed:
raise ValueError(f"invalid {vortex_type} vortex y offset {y_offset_l0}; allowed {sorted(allowed)}")
viscosity = _nu_from_re(re_code)
cuda_cfg = legacy_utils.load_cuda_config(os.path.join(CONFIG_DIR, "config_cuda.json"))
field_cfg = legacy_utils.load_flow_field_config(os.path.join(CONFIG_DIR, "config_flowfield.json"))
field_cfg = field_cfg._replace(viscosity=float(viscosity))
vortex_strength = 0.5 * U0 if vortex_type == "lamb" else 0.03 * U0
# Phase 1: Sensors-only env -> record clean channel -> add vortex -> record target
ff_sensors = FlowField(field_cfg, cuda_cfg, device_id=device_id)
cy_s = _center_y(ff_sensors)
for y_off in [2.0, 0.0, -2.0]:
ff_sensors.add_sensor((40.0 * L0, cy_s + y_off * L0, 0.0), SENSOR_RADIUS)
n_sens = ff_sensors.obs.size // 2
assert n_sens == 3
_stabilize(ff_sensors, 3)
# Record clean channel baseline
ff_sensors.get_ddf()
ff_sensors.save_ddf()
# Add vortex at x=10*L0 and record target
ff_sensors.add_vortex(
(10.0 * L0, cy_s, 0.0),
2.0 * L0,
vortex_strength,
0.0,
vortex_type,
)
target_states = np.empty((0, 6), dtype=DATA_TYPE)
for _ in range(FIFO_LEN):
ff_sensors.run(800, np.zeros(3, dtype=DATA_TYPE))
target_states = np.vstack((target_states, ff_sensors.obs.copy()[0:6]))
del ff_sensors
# Phase 2: Pinball + sensors + vortex
field_cfg = legacy_utils.load_flow_field_config(os.path.join(CONFIG_DIR, "config_flowfield.json"))._replace(viscosity=float(viscosity))
ff = FlowField(field_cfg, cuda_cfg, device_id=device_id)
cy = _center_y(ff)
NX, NY = ff.FIELD_SHAPE[0], ff.FIELD_SHAPE[1]
for y_off in [2.0, 0.0, -2.0]:
ff.add_sensor((40.0 * L0, cy + y_off * L0, 0.0), SENSOR_RADIUS) # id=0,1,2
ff.add_cylinder((30.0 * L0, cy, 0.0), PINBALL_RADIUS) # front, id=3
ff.add_cylinder((31.3 * L0, cy + 0.75 * L0, 0.0), PINBALL_RADIUS) # top, id=4
ff.add_cylinder((31.3 * L0, cy - 0.75 * L0, 0.0), PINBALL_RADIUS) # bottom, id=5
n_total = ff.obs.size // 2
assert n_total == 6, f"Expected 6 objects, got {n_total}"
_stabilize(ff, 6)
ff.get_ddf()
cy = _center_y(ff); nx, ny = _field_xy(ff)
for y_off in (2.0, 0.0, -2.0):
ff.add_sensor((40.0 * L0, cy + y_off * L0, 0.0), SENSOR_RADIUS)
run_historical_interval(ff, int(nx / U0), np.zeros(3, dtype=DATA_TYPE))
ff.get_ddf(); ff.save_ddf()
strength = (0.5 if vortex_type == "lamb" else 0.03) * U0
target_vortex = (10.0 * L0, cy + y_offset_l0 * L0, 0.0)
ff.add_vortex(target_vortex, 2.0 * L0, strength, 0.0, vortex_type)
target = np.empty((0, 6), dtype=DATA_TYPE)
for _ in range(FIFO_LEN):
run_historical_interval(ff, 800, np.zeros(3, dtype=DATA_TYPE)); target = np.vstack((target, ff.obs.copy()))
target_config = {"device_id": device_id, "viscosity": viscosity, "re_code": re_code, "u0": U0, "sample_interval": 800, "fifo_len": FIFO_LEN, "conv_len": CONV_LEN, "nx": nx, "ny": ny, "n_obj_total": 3, "obs_slice": (0, 6), "s_dim": 6, "max_steps": 150, "vortex_type": vortex_type, "vortex_y_offset_l0": y_offset_l0, "vortex_role": role, "runtime_handoff": "fresh_second_injection_from_saved_pre_injection_sensors_state"}
if target_only:
ff.restore_ddf(); ff.apply_ddf()
ff.add_vortex(target_vortex, 2.0 * L0, strength, 0.0, vortex_type)
return {"flow_field": ff, "target_states": target, "config": target_config}
ff.restore_ddf(); ff.apply_ddf()
ff.add_cylinder((30.0 * L0, cy, 0.0), PINBALL_RADIUS)
ff.add_cylinder((31.3 * L0, cy + 0.75 * L0, 0.0), PINBALL_RADIUS)
ff.add_cylinder((31.3 * L0, cy - 0.75 * L0, 0.0), PINBALL_RADIUS)
run_historical_interval(ff, int(nx / U0), np.zeros(6, dtype=DATA_TYPE))
bias = np.zeros(6, dtype=DATA_TYPE); bias[3:] = np.asarray(action_bias, dtype=DATA_TYPE) * U0
if role == "controlled":
run_historical_interval(ff, int(nx / U0), bias)
acquisition_vortex = (15.0 * L0, cy + y_offset_l0 * L0, 0.0)
pretrigger_development_steps = int(2 * nx / U0)
if role == "zero":
ff.add_vortex(acquisition_vortex, 2.0 * L0, strength, 0.0, vortex_type)
run_historical_interval(
ff, pretrigger_development_steps, np.zeros(6, dtype=DATA_TYPE)
)
ff.add_vortex(acquisition_vortex, 2.0 * L0, strength, 0.0, vortex_type)
if role == "zero":
# Zero needs a true zero-base + fresh-vortex checkpoint. Controlled
# deliberately retains the historical stale-host save semantics.
ff.get_ddf()
ff.save_ddf()
# Norm
fifo = deque(maxlen=FIFO_LEN)
for _ in range(FIFO_LEN):
ff.run(800, np.zeros(6, dtype=DATA_TYPE))
fifo.append(ff.obs.copy()[0:12])
norm = _compute_karman_norm(np.array(fifo, dtype=DATA_TYPE))
# Bias FIFO
ff.apply_ddf()
bias_arr = np.zeros(6, dtype=DATA_TYPE)
bias_arr[3] = float(action_bias[0] * U0)
bias_arr[4] = float(action_bias[1] * U0)
bias_arr[5] = float(action_bias[2] * U0)
fifo.clear()
run_historical_interval(ff, 800, np.zeros(6, dtype=DATA_TYPE)); fifo.append(ff.obs.copy())
norm = _compute_karman_norm(np.asarray(fifo, dtype=DATA_TYPE))
ff.apply_ddf(); fifo.clear()
runtime_command = bias if role == "controlled" else np.zeros(6, dtype=DATA_TYPE)
for _ in range(FIFO_LEN):
ff.run(800, bias_arr)
fifo.append(ff.obs.copy()[0:12])
save_states = np.array(list(fifo), dtype=DATA_TYPE)
ff.apply_ddf()
norm["save_states"] = save_states
norm["action_bias"] = list(action_bias)
norm["n_obj_total"] = 6
norm["vortex_type"] = vortex_type
norm["vortex_strength"] = vortex_strength
config = {
"device_id": device_id,
"viscosity": viscosity,
"re_code": re_code,
"u0": U0,
"sample_interval": 800,
"fifo_len": FIFO_LEN,
"conv_len": CONV_LEN,
"nx": NX,
"ny": NY,
"n_obj_total": 6,
"action_scale": action_scale,
"action_bias": list(action_bias),
"obs_slice": (0, 12),
"s_dim": 12,
"max_steps": 150,
"vortex_type": vortex_type,
}
return {"flow_field": ff, "target_states": target_states, "norm": norm, "config": config}
run_historical_interval(ff, 800, runtime_command); fifo.append(ff.obs.copy())
saved = np.asarray(fifo, dtype=DATA_TYPE); ff.apply_ddf()
norm.update(save_states=saved, action_bias=list(action_bias), n_obj_total=6, vortex_type=vortex_type, vortex_strength=strength)
config = {"device_id": device_id, "viscosity": viscosity, "re_code": re_code, "u0": U0, "sample_interval": 800, "fifo_len": FIFO_LEN, "conv_len": CONV_LEN, "nx": nx, "ny": ny, "n_obj_total": 6, "action_scale": action_scale, "action_bias": list(action_bias), "obs_slice": (0, 12), "s_dim": 12, "max_steps": 150, "vortex_type": vortex_type, "vortex_y_offset_l0": y_offset_l0, "vortex_role": role, "pre_injection_stabilization": "zero_then_bias" if role == "controlled" else "exact_physical_zero_with_vortex_pretrigger_2NX_over_U0", "pretrigger_vortex": role == "zero", "pretrigger_development_steps": pretrigger_development_steps if role == "zero" else 0, "runtime_handoff": "saved_fresh_injection_checkpoint"}
return {"flow_field": ff, "target_states": target, "norm": norm, "config": config}
# ---------------------------------------------------------------------------
# Erase (sensors(0-2) + dist-cyl(r=0.75L, id=3) + pinball(4-6) = 7 objects)
@@ -551,6 +591,7 @@ def build_erase(
re_code: float = 100.0,
*,
action_bias: Tuple[float, float, float] = (0.0, -8.0, 8.0),
target_only: bool = False,
) -> Dict[str, Any]:
"""Build erase environment (cancel upstream disturbance to clean flow).
@@ -572,14 +613,17 @@ def build_erase(
n_clean = ff_clean.obs.size // 2
assert n_clean == 3
_stabilize(ff_clean, 3)
run_historical_interval(ff_clean, int(2 * ff_clean.FIELD_SHAPE[0] / U0), np.zeros(3, dtype=DATA_TYPE))
target = np.empty((0, 6), dtype=DATA_TYPE)
for _ in range(FIFO_LEN):
ff_clean.run(600, np.zeros(3, dtype=DATA_TYPE))
run_historical_interval(ff_clean, 600, np.zeros(3, dtype=DATA_TYPE))
target = np.vstack((target, ff_clean.obs.copy()[0:6]))
# Target = mean (steady, not periodic)
target_mean = np.mean(target, axis=0, dtype=DATA_TYPE)
if target_only:
nx, ny = _field_xy(ff_clean)
return {"flow_field": ff_clean, "target_states": target, "config": {"device_id": device_id, "viscosity": viscosity, "re_code": re_code, "u0": U0, "sample_interval": 600, "fifo_len": FIFO_LEN, "conv_len": 36, "nx": nx, "ny": ny, "n_obj_total": 3, "obs_slice": (0, 6), "s_dim": 6}}
del ff_clean
# Phase 2: Full erase env
@@ -605,7 +649,7 @@ def build_erase(
# Norm (erase-specific: full obs[0:14], force_norm uses pinball only)
fifo = deque(maxlen=FIFO_LEN)
for _ in range(FIFO_LEN):
ff.run(600, np.zeros(7, dtype=DATA_TYPE))
run_historical_interval(ff, 600, np.zeros(7, dtype=DATA_TYPE))
fifo.append(ff.obs.copy()[0:14])
temp = np.array(fifo, dtype=DATA_TYPE)
@@ -624,7 +668,7 @@ def build_erase(
fifo.clear()
for _ in range(FIFO_LEN):
ff.run(600, bias_arr)
run_historical_interval(ff, 600, bias_arr)
fifo.append(ff.obs.copy()[0:14])
save_states = np.array(list(fifo), dtype=DATA_TYPE)
ff.apply_ddf()
@@ -1,40 +1,82 @@
# legacy_test/core/model_loader.py
"""PPO model loader for legacy test scripts.
"""Canonical, non-archive PPO loader for legacy evaluation scripts.
Wraps the reproduce ModelInventory to provide a simpler interface for
Track A test scripts that only need to load models onto CPU.
Only the models explicitly used by ``legacy_test/test_*.py`` are accepted.
Paths resolve directly under repository-root ``models/``; no discovery scan and
no archived Python inventory participates in evaluation.
"""
from __future__ import annotations
import os
import sys
from dataclasses import dataclass
from typing import Optional
import numpy as np
_REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..", ".."))
_SRC = os.path.join(_REPO, "src")
for p in [_REPO, _SRC]:
if p not in sys.path:
sys.path.insert(0, p)
from drl_pinball.reproduce.configs.model_inventory import ModelInventory # noqa: E402
_inventory = ModelInventory()
_MODEL_ROOT = os.path.join(_REPO, "models")
def load_model(name: str) -> "PPO":
"""Load a PPO model onto CPU for inference.
Returns a PPO model loaded from ``models/{subdir}/{name}.zip``
with Sin activation and correct observation/action spaces.
Delegates to ModelInventory.load(name, device="cpu").
"""
return _inventory.load(name, device="cpu")
@dataclass(frozen=True)
class ModelSpec:
subdir: str
s_dim: int
scene: str
def list_models(scene: Optional[str] = None) -> list:
"""List available model names, optionally filtered by scene."""
return _inventory.list_models(scene)
# S_DIM and names are the canonical legacy-test README/script contracts.
MODEL_SPECS = {
"d1a3o12_re50": ModelSpec("old", 12, "karman"),
"d1a3o12_re100": ModelSpec("old", 12, "karman"),
"d1a3o12_re200": ModelSpec("old", 12, "karman"),
"d1a3o12_re400": ModelSpec("old", 12, "karman"),
"vortex_lamb": ModelSpec("old", 12, "vortex"),
"vortex_taylor": ModelSpec("old", 12, "vortex"),
"d1a3o14_250525_imit_075L_2U_400S": ModelSpec("250525", 14, "illusion"),
"d1a3o14_250525_imit_1L_2U_600S": ModelSpec("250525", 14, "illusion"),
"d1a3o14_250525_imit_15L_2U": ModelSpec("250525", 14, "illusion"),
"d1a3o12_250729_250326_erase": ModelSpec("250729", 12, "erase"),
}
def model_path(name: str) -> str:
"""Resolve one allowlisted model to an explicit root-model zip path."""
try:
spec = MODEL_SPECS[name]
except KeyError as exc:
raise KeyError(f"unknown canonical legacy model {name!r}") from exc
path = os.path.join(_MODEL_ROOT, spec.subdir, f"{name}.zip")
if not os.path.isfile(path):
raise FileNotFoundError(f"canonical legacy model not found: {path}")
return path
def _dummy_env(s_dim: int):
import gymnasium as gym
import numpy as np
from gymnasium import spaces
class LegacyInferenceEnv(gym.Env):
observation_space = spaces.Box(-1.0, 1.0, shape=(s_dim,), dtype=np.float32)
action_space = spaces.Box(-1.0, 1.0, shape=(3,), dtype=np.float32)
def reset(self, *, seed=None, options=None):
super().reset(seed=seed)
return np.zeros(s_dim, np.float32), {}
def step(self, action):
return np.zeros(s_dim, np.float32), 0.0, False, False, {}
return LegacyInferenceEnv()
def load_model(name: str):
"""Load one canonical legacy PPO zip on CPU with its correct S_DIM."""
from stable_baselines3 import PPO
spec = MODEL_SPECS.get(name)
if spec is None:
raise KeyError(f"unknown canonical legacy model {name!r}")
return PPO.load(model_path(name), env=_dummy_env(spec.s_dim), device="cpu")
def list_models(scene: Optional[str] = None) -> list[str]:
"""List allowlisted model names, optionally filtered by canonical scene."""
return sorted(name for name, spec in MODEL_SPECS.items()
if scene is None or spec.scene == scene)
+144
View File
@@ -0,0 +1,144 @@
"""Legacy reward DTW and normalized offline target-scale metrics."""
from __future__ import annotations
import hashlib
import os
import numpy as np
from .core.dtw_metrics import calc_dtw_sim, calc_lag, compute_similarity_vortex, gen_target_states_at
def target_scale_cycle_similarity(target, state, conv_len, *, target_start=0):
target = np.asarray(target, dtype=np.float64)[:, target_start:target_start + 6]
state = np.asarray(state, dtype=np.float64)[:, :6]
scale = np.maximum(np.max(np.abs(target), axis=0), 1e-12)
result = cycle_similarity(target / scale, state / scale, conv_len)
result["scale"] = scale.tolist()
result["definition"] = "offline target-channel max-abs normalized legacy cycle DTW"
return result
def cycle_similarity(target, state, conv_len):
target = np.asarray(target, dtype=np.float64); state = np.asarray(state, dtype=np.float64)
lag = calc_lag(target[conv_len:2*conv_len, 1], state[-conv_len:, 1])
scores = [calc_dtw_sim(np.roll(target[:, i], -lag)[conv_len:2*conv_len], state[-conv_len:, i]) for i in range(6)]
return {"similarity": float(np.mean(scores)), "per_channel": {str(i): float(v) for i,v in enumerate(scores)}, "lag": int(lag)}
def native_similarity(case, target, fifo, step):
if case.scene == "vortex":
return compute_similarity_vortex(target, fifo[:, :6], step, case.conv_len)
start = 2 if case.scene == "illusion" else 0
return cycle_similarity(np.asarray(target)[:, start:start + 6], np.asarray(fifo)[:, :6], case.conv_len)["similarity"]
def reward_terms(case, target, harmonics, fifo, force_norm, step):
fifo = np.asarray(fifo, dtype=np.float64)
forces = fifo[-1, 6:12] / force_norm
sim = native_similarity(case, target, fifo, step)
if case.scene == "illusion":
desired = gen_target_states_at(step, harmonics)[:2] / force_norm
cd, cl = forces[0::2].sum(), forces[1::2].sum()
r_cd, r_cl, r_sim = np.exp(-abs((cd-desired[0])*10)), np.exp(-abs((cl-desired[1])*10)), np.exp(-10*abs(sim-1))
reward = .3*r_cd + .3*r_cl + .4*r_sim
else:
cd, cl = forces[0::2].mean(), forces[1::2].mean()
r_cd, r_cl, r_sim = np.exp(-abs(cd*20)), np.exp(-abs(cl*80)), np.exp(-10*abs(sim-1))
weights = (.2, .3, .5) if case.scene == "vortex" else (.3, .4, .3)
reward = weights[0]*r_cd + weights[1]*r_cl + weights[2]*r_sim
return {"reward": float(min(reward, 1.0)), "reward_cd": float(r_cd), "reward_cl": float(r_cl), "native_legacy_dtw": float(sim)}
def sha256_file(path):
digest = hashlib.sha256()
with open(path, "rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def channel_correlations(reference, current):
reference = np.asarray(reference, dtype=np.float64)
current = np.asarray(current, dtype=np.float64)
n = min(len(reference), len(current))
reference, current = reference[:n], current[:n]
result = []
for index in range(reference.shape[1]):
x = reference[:, index] - reference[:, index].mean()
y = current[:, index] - current[:, index].mean()
denominator = np.sqrt(np.sum(x*x) * np.sum(y*y))
result.append(float(np.sum(x*y) / denominator) if denominator >= 1e-12 else 0.0)
return result
def rms_error(reference, current):
reference = np.asarray(reference, dtype=np.float64)
current = np.asarray(current, dtype=np.float64)
n = min(len(reference), len(current))
return float(np.sqrt(np.mean((reference[:n] - current[:n]) ** 2)))
def aggregate_rewards(reward_rows, conv_len):
if not reward_rows:
return None
keys = ("reward", "reward_cd", "reward_cl", "native_legacy_dtw")
values = {key: np.asarray([row[key] for row in reward_rows], dtype=np.float64) for key in keys}
tail = min(int(conv_len), len(reward_rows))
return {
"mean": float(values["reward"].mean()),
"tail": float(values["reward"][-tail:].mean()),
"final": float(values["reward"][-1]),
"tail_reward_cd": float(values["reward_cd"][-tail:].mean()),
"tail_reward_cl": float(values["reward_cl"][-tail:].mean()),
"tail_native_legacy_dtw": float(values["native_legacy_dtw"][-tail:].mean()),
"tail_length": tail,
}
def frozen_reference_comparison(path, sensors, forces, actions, conv_len, threshold=0.95):
if not os.path.isfile(path):
return None
with np.load(path) as frozen:
reference = {name: np.asarray(frozen[name], dtype=np.float64) for name in ("sensors", "forces", "actions")}
sensor_count = min(len(reference["sensors"]), len(sensors))
sensor_dtw = cycle_similarity(
reference["sensors"][:sensor_count], np.asarray(sensors)[:sensor_count], conv_len
)
return {
"reference_path": os.path.abspath(path),
"reference_sha256": sha256_file(path),
"sensor_dtw": sensor_dtw,
"sensor_correlations": channel_correlations(reference["sensors"], sensors),
"force_correlations": channel_correlations(reference["forces"], forces),
"action_correlations": channel_correlations(reference["actions"], actions),
"sensor_rms": rms_error(reference["sensors"], sensors),
"force_rms": rms_error(reference["forces"], forces),
"action_rms": rms_error(reference["actions"], actions),
"threshold": float(threshold),
"passed": bool(sensor_dtw["similarity"] > threshold),
}
def _erase_enhanced_similarity(target, state):
target = np.asarray(target, np.float64); state = np.asarray(state, np.float64)
target_std, state_std = max(float(target.std()), 1e-8), max(float(state.std()), 1e-8)
amplitude = min(target_std, state_std) / max(target_std, state_std)
mean_diff = abs(float(target.mean() - state.mean()))
mean_scale = max(abs(float(target.mean())), abs(float(state.mean())), 1e-8)
n, m = len(target), len(state); matrix = np.full((n + 1, m + 1), np.inf); matrix[0, 0] = 0.0
for i in range(1, n + 1):
for j in range(1, m + 1):
matrix[i, j] = abs(target[i-1]-state[j-1]) + min(matrix[i-1,j], matrix[i,j-1], matrix[i-1,j-1])
scale = max(float(np.max(np.abs(target))), float(np.max(np.abs(state))), 1e-8)
dtw = max(0.0, 1.0 - matrix[n,m] / (n * scale))
return .8*dtw + .1*amplitude + .1/(1.0 + mean_diff/mean_scale*10.0)
def erase_reward_terms(fifo, target_sensors, norm, conv_len=36):
"""Exact rolling same-rollout reward from legacy_env_erase.py."""
states = np.asarray(fifo, np.float64)
if states.ndim != 2 or states.shape[1] != 14 or len(states) < 2*conv_len:
raise ValueError("Erase reward requires a (time,14) FIFO of at least 2*conv_len")
sens_norm=np.asarray(norm["sens_norm_fact"],np.float64); sens_dev=np.asarray(norm["sens_deviation"],np.float64)
target_sensors=np.asarray(target_sensors,np.float64); sensors=(states[-1,:6]-sens_dev)/sens_norm; target=(target_sensors-sens_dev)/sens_norm
reference,current=-states[conv_len:2*conv_len,7],states[-conv_len:,9]
corr=np.correlate(reference-reference.mean(),current-current.mean(),"full"); lag=int(np.arange(-len(reference)+1,len(reference))[np.argmax(corr)])
components=[]
for component in range(2):
wanted=-np.roll(states[:,component+6],-lag)[conv_len:2*conv_len]
actual=states[-conv_len:,component+8]+states[-conv_len:,component+10]+states[-conv_len:,component+12]
components.append(float(_erase_enhanced_similarity(wanted,actual)))
sim=float(np.mean(components)); diff_u=float(np.mean(np.abs(sensors[[0,2,4]]-target[[0,2,4]])))
diff_v=float(np.mean(np.abs(sensors[[1,3,5]]-target[[1,3,5]]))); amp_v=float(np.std(states[:,1]+states[:,3]+states[:,5])/sens_norm[3])
reward_u=float(np.exp(-abs(diff_u*40.0))); reward_v=float(.7*np.exp(-abs(amp_v*20.0))+.3*np.exp(-abs(diff_v*20.0)))
reward=float(min(.4*reward_u+.4*reward_v+.2*sim,1.0))
return {"reward":reward,"reward_u":reward_u,"reward_v":reward_v,"native_legacy_dtw":sim,"native_lag":lag,"native_component_similarity":np.asarray(components)}
+64
View File
@@ -0,0 +1,64 @@
"""Runtime contracts shared by every Legacy reproduction case."""
from __future__ import annotations
from collections import deque
import json
import os
import numpy as np
def run_historical_interval(flow_field, num_steps, action_target):
"""Run one interval with the pre-persistence Legacy EMA seed contract."""
shape = np.asarray(flow_field.action).shape
flow_field.action = np.zeros(shape, dtype=flow_field.DATA_TYPE)
flow_field.run(num_steps, action_target)
def load_policy_norm(path):
"""Load and validate one explicitly mapped frozen training normalization."""
if not os.path.isfile(path):
raise FileNotFoundError(f"Frozen policy normalization not found: {path}")
with open(path, encoding="utf-8") as handle:
document = json.load(handle)
required = {"force_norm_fact", "sens_deviation", "sens_norm_fact"}
missing = required - set(document)
if missing:
raise ValueError(f"Frozen policy normalization missing keys: {sorted(missing)}")
force = float(document["force_norm_fact"])
deviation = np.asarray(document["sens_deviation"], dtype=np.float32)
scale = np.asarray(document["sens_norm_fact"], dtype=np.float32)
if not np.isfinite(force) or force <= 0:
raise ValueError("Frozen force_norm_fact must be finite and positive")
if deviation.shape != (6,) or scale.shape != (6,):
raise ValueError("Frozen sensor normalization arrays must each have shape (6,)")
if not np.all(np.isfinite(deviation)) or not np.all(np.isfinite(scale)) or np.any(scale <= 0):
raise ValueError("Frozen sensor normalization values must be finite and scales positive")
return {"force_norm_fact": force, "sens_deviation": deviation, "sens_norm_fact": scale}
def compute_norm(rows):
rows = np.asarray(rows, dtype=np.float32)
return {"force_norm_fact": float(6*np.max(np.abs(rows[:, 6:12]))),
"sens_deviation": rows[:, :6].mean(axis=0),
"sens_norm_fact": 5*np.max(np.abs(rows[:, :6]-rows[:, :6].mean(axis=0)), axis=0)}
def reset_runtime(flow_field, saved_fifo, s_dim):
flow_field.restore_ddf()
flow_field.apply_ddf()
fifo = deque((np.asarray(x, dtype=np.float32).copy() for x in saved_fifo), maxlen=len(saved_fifo))
return fifo, np.zeros(s_dim, dtype=np.float32)
def policy_observation(raw, norm, *, target_force=None):
raw = np.asarray(raw, dtype=np.float32)
force = raw[6:12] / norm["force_norm_fact"]
sensors = (raw[:6] - norm["sens_deviation"]) / norm["sens_norm_fact"]
result = np.hstack((force, sensors))
if target_force is not None:
result = np.hstack((result, np.asarray(target_force)/norm["force_norm_fact"]))
return np.clip(result, -1, 1).astype(np.float32)
def erase_policy_observation(raw, norm):
"""Original Erase ordering: pinball forces then normalized sensors."""
raw = np.asarray(raw, dtype=np.float32)
if raw.shape != (14,) or not np.all(np.isfinite(raw)):
raise ValueError("Erase raw observation must be 14 finite channels")
result = np.hstack((raw[8:14] / norm["force_norm_fact"],
(raw[:6] - norm["sens_deviation"]) / norm["sens_norm_fact"]))
return np.clip(result, -1, 1).astype(np.float32)
@@ -0,0 +1,202 @@
from pathlib import Path
import numpy as np
import pytest
import src.drl_pinball.legacy_test.acquire as acquire
class FakeModel:
def __init__(self):
self.calls = 0
def predict(self, obs, deterministic):
assert deterministic is True
self.calls += 1
return np.array([0.25, -0.5, 0.75], dtype=np.float32), None
class FakeFlow:
DATA_TYPE = np.float32
def __init__(self):
self.action = np.ones(7, dtype=np.float32)
self.obs = np.zeros(14, dtype=np.float32)
self.intervals = 0
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(7, dtype=np.float32))
self.intervals += 1
self.action = command.copy()
phase = 2 * np.pi * self.intervals / 30
self.obs[2:8] = [np.sin(phase + index) for index in range(6)]
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": 1_000_000 + self.intervals * acquire.SI,
"solver_absolute_control_clock": self.intervals}
def _data():
target = np.column_stack([
np.sin(2 * np.pi * np.arange(150) / 30 + index) for index in range(6)
]).astype(np.float32)
return {
"config": {"nx": 1280, "ny": 2, "sample_interval": 800, "n_obj_total": 7},
"norm": {"save_states": np.zeros((150, 12), dtype=np.float32)},
"target_states": target,
}
def test_controlled_schedule_zero_ema_and_post_interval_field_capture(monkeypatch, tmp_path):
ff, model = FakeFlow(), FakeModel()
monkeypatch.setattr(acquire, "reward_terms", lambda *args: {
"reward": 0.5, "reward_cd": 0.6, "reward_cl": 0.7, "native_legacy_dtw": 0.8,
})
def capture(flow, nx, ny):
assert flow.intervals > acquire.WARMUP_INTERVALS
flow.capture_intervals.append(flow.intervals)
return {"ux": np.full((ny, nx), flow.intervals, np.float32),
"uy": np.full((ny, nx), -flow.intervals, np.float32)}
rows, fields = acquire._collect(_data(), model, ff, {
"force_norm_fact": 1.0, "sens_deviation": np.zeros(6), "sens_norm_fact": np.ones(6),
}, tmp_path, capture_field=capture)
assert (acquire.WARMUP_INTERVALS, acquire.COLLECT_BOUNDARIES) == (480, 160)
assert ff.intervals == model.calls == 640
assert len(rows) == 160
assert fields["ux"].shape == fields["uy"].shape == (160, 2, 1280)
assert ff.capture_intervals == list(range(481, 641))
assert rows[0]["control_index"] == 481 and rows[-1]["control_index"] == 640
assert rows[0]["lattice_step"] == 481 * 800
assert rows[-1]["lattice_step"] == 640 * 800
assert rows[0]["solver_absolute_lattice_step"] == 1_000_000 + 481 * 800
assert rows[0]["solver_absolute_lattice_step"] != rows[0]["lattice_step"]
assert rows[0]["physical_time"] == rows[0]["lattice_step"] * acquire.U0 / acquire.NX
assert np.allclose(rows[0]["commanded_target_omega"], [0.02, -0.08, 0.1])
assert np.array_equal(rows[0]["commanded_target_omega"], rows[0]["effective_smoothed_omega"])
assert np.allclose(fields["ux"][0], 481) and np.allclose(fields["uy"][-1], -640)
assert not list(tmp_path.glob("boundary_*.npz"))
assert set(rows[0]) == {
"physical_time", "lattice_step", "solver_absolute_lattice_step", "control_index",
"sensors", "forces",
"action_normalized", "commanded_target_omega", "effective_smoothed_omega",
"reward_raw", "reward_cd", "reward_cl", "reward_sim", "native_reward_dtw",
}
def test_legacy_metadata_schema_and_timeline_wording_are_explicit():
source = Path(acquire.__file__).read_text(encoding="utf-8")
assert '"schema": "drl-pinball-legacy-acquisition-v2"' in source
assert '"schema": "drl-pinball-v5-acquisition-v1"' not in source
assert '"solver_absolute_lattice_step"' in source
assert 'V5 lattice_step is an absolute solver count' in source
assert 'derived only from relative Legacy lattice_step' in source
def test_capture_uses_public_q_over_u0_and_transposes_to_physical_velocity():
ny, nx = 2, 3
class PublicFlow:
class Config:
velocity = 0.01
field_config = Config()
def current_step_velocity_field(self):
return (np.array([[1, 2], [3, 4], [0, 6]], dtype=np.float32),
np.array([[-1, -2], [-3, -4], [0, -6]], dtype=np.float32))
def completed_flags_xy(self):
return np.array([[1, 1], [0b00010001, 1], [2, 1]], dtype=np.uint8)
fields = acquire._capture_boundary_field(PublicFlow(), nx, ny)
assert fields["ux"].shape == fields["uy"].shape == (ny, nx)
assert np.allclose(fields["ux"], [[0.01, 0.03, 0.0], [0.02, 0.04, 0.06]])
assert np.allclose(fields["uy"], -fields["ux"])
assert fields["ux"].dtype == fields["uy"].dtype == np.float32
def test_capture_rejects_nonfluid_public_velocity():
class BadFlow:
class Config:
velocity = 0.01
field_config = Config()
def current_step_velocity_field(self):
return np.ones((1, 1), np.float32), np.zeros((1, 1), np.float32)
def completed_flags_xy(self):
return np.zeros((1, 1), np.uint8)
with pytest.raises(ValueError, match="exact zero on nonfluid"):
acquire._capture_boundary_field(BadFlow(), 1, 1)
def test_staged_exact_keys(tmp_path):
role = tmp_path / "role"
role.mkdir()
for name in ("timeseries.csv", "phase_cycle.csv"):
(role / name).write_text("x\n", encoding="ascii")
required = {
"physical_time": np.arange(2), "lattice_step": np.arange(2),
"solver_absolute_lattice_step": np.arange(2) + 100,
"control_index": np.arange(2), "sensors": np.zeros((2, 6)),
"forces": np.zeros((2, 6)), "action_normalized": np.zeros((2, 3)),
"commanded_target_omega": np.zeros((2, 3)),
"effective_smoothed_omega": np.zeros((2, 3)), "reward_raw": np.zeros(2),
"reward_cd": np.zeros(2), "reward_cl": np.zeros(2), "reward_sim": np.zeros(2),
"native_reward_dtw": np.zeros(2), "target_normalized_dtw": np.zeros(2),
"target_normalized_dtw_lag": np.zeros(2), "phase": np.zeros(2),
"cycle_id": np.zeros(2),
}
np.savez(role / "timeseries.npz", **required)
np.savez(role / "phase_cycle.npz", x=np.zeros(2))
np.savez(role / "phase_fields.npz", ux=np.zeros((8, 2, 3)), uy=np.zeros((8, 2, 3)),
mean_ux=np.zeros((2, 3)), mean_uy=np.zeros((2, 3)),
field_indices=np.arange(8), cycle_id=np.arange(8), target_phase=np.arange(8),
actual_phase=np.arange(8), phase_error=np.zeros(8))
(role / "dtw_summary.json").write_text("{}", encoding="utf-8")
(role / "metadata.json").write_text("{}", encoding="utf-8")
acquire._validate_staged_role(role)
def test_failure_removes_transaction_and_preserves_existing(monkeypatch, tmp_path):
root = tmp_path / "out"
final = root / "legacy" / acquire.CASE_NAME / acquire.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 = tmp_path / "norm.json"; norm.write_text("{}", encoding="utf-8")
bundle = {"case": acquire.get_case(acquire.CASE_NAME), "model_path": model,
"norm_path": norm, "norm": {}, "reference_path": tmp_path / "missing.npz"}
monkeypatch.setattr(acquire, "_resolve_bundle", lambda repo: 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, role):
assert role == "controlled"
return _data(), FakeModel(), FakeFlow()
def fail(*args):
raise RuntimeError("finalize failed")
with pytest.raises(RuntimeError, match="finalize failed"):
acquire.acquire_controlled(output_root=root, overwrite=True, repo_root=tmp_path,
storage_validator=storage_validator,
runtime_factory=runtime_factory, finalizer=fail)
assert marker.read_text(encoding="utf-8") == "old"
assert not list(final.parent.glob(".controlled.staging-*"))
@@ -0,0 +1,217 @@
import numpy as np
import pytest
import src.drl_pinball.legacy_test.acquire as acquire
def test_allowlist_roles_and_case_sample_intervals():
assert acquire.SUPPORTED_CASES == (
"karman_re50", "karman_re100", "karman_re200", "karman_re400",
"illusion_075L", "illusion_1L", "illusion_15L", "steady", "vortex_lamb", "vortex_taylor", "erase",
)
assert [acquire.get_case(name).sample_interval for name in
("karman_re50", "illusion_075L", "illusion_1L", "illusion_15L", "steady")] == [800, 400, 600, 800, 800]
assert acquire._case_roles(acquire.get_case("steady")) == ("target", "constant", "zero")
assert acquire._case_roles(acquire.get_case("illusion_075L")) == ("controlled", "target", "zero")
def test_scene_observation_slices_are_exact():
obs14 = np.arange(14, dtype=np.float32)
sensors, forces = acquire._observation_slices(acquire.get_case("karman_re50"), "controlled", obs14)
assert np.array_equal(sensors, obs14[2:8]) and np.array_equal(forces, obs14[8:14])
obs12 = np.arange(12, dtype=np.float32)
for name in ("illusion_075L", "steady"):
sensors, forces = acquire._observation_slices(acquire.get_case(name), "zero", obs12)
assert np.array_equal(sensors, obs12[:6]) and np.array_equal(forces, obs12[6:12])
target8 = np.arange(8, dtype=np.float32)
sensors, forces = acquire._observation_slices(acquire.get_case("illusion_075L"), "target", target8)
assert np.array_equal(sensors, target8[2:8]) and np.isnan(forces).all()
def test_illusion_policy_observation_is_exact_14d_next_harmonic_step(monkeypatch, tmp_path):
case = acquire.get_case("illusion_075L")
class Model:
def predict(self, obs, deterministic=True):
assert obs.shape == (14,)
return np.zeros(3, np.float32), None
class Flow:
DATA_TYPE = np.float32
def __init__(self): self.obs=np.zeros(12,np.float32); self.action=np.zeros(6,np.float32); self.i=0
def restore_ddf(self): pass
def apply_ddf(self): pass
def run(self, steps, command): self.i += 1; self.action=command.copy(); self.obs=np.arange(12,dtype=np.float32)
def current_effective_action(self): return self.action
data={"config":{"nx":1280,"ny":1,"sample_interval":400,"n_obj_total":6},
"norm":{"save_states":np.zeros((2,12),np.float32)}, "target_states":np.zeros((80,8)),
"target_harmonics":[{}]*8}
seen=[]
monkeypatch.setattr(acquire, "gen_target_states_at", lambda step, harmonics: seen.append(step) or np.arange(8,dtype=float)+step)
monkeypatch.setattr(acquire, "reward_terms", lambda *args: {"reward":1.,"reward_cd":1.,"reward_cl":1.,"native_legacy_dtw":1.})
monkeypatch.setattr(acquire, "policy_observation", lambda raw,norm,target_force=None: np.r_[raw,target_force].astype(np.float32))
monkeypatch.setattr(acquire, "WARMUP_INTERVALS", 1); monkeypatch.setattr(acquire, "COLLECT_BOUNDARIES", 1)
acquire._collect_role("controlled", data, Model(), Flow(), {"force_norm_fact":1.}, tmp_path,
capture_field=lambda *args: {"ux":np.zeros((1,1280)),"uy":np.zeros((1,1280))},
case_name=case.name)
assert seen == [1, 2]
def test_steady_staging_has_no_phase_or_dtw_artifacts(tmp_path):
role=tmp_path/"role"; role.mkdir()
np.savez(role/"timeseries.npz", x=np.zeros(1)); (role/"timeseries.csv").write_text("x\n0\n")
np.savez(role/"late_field.npz", ux=np.zeros((1,2,3)), uy=np.zeros((1,2,3)), field_indices=np.array([159]))
(role/"metadata.json").write_text("{}")
acquire._validate_staged_role(role, "steady")
assert not any("phase" in path.name or "dtw" in path.name for path in role.iterdir())
def test_nonstandard_cases_are_allowlisted():
for name in ("vortex_lamb", "vortex_taylor", "erase"):
assert acquire.get_case(name).name == name
assert acquire._case_roles(acquire.get_case(name)) == ("controlled", "target", "zero")
def test_target_and_steady_bundle_resolution_do_not_require_frozen_norm(monkeypatch, tmp_path):
monkeypatch.setattr(acquire, "load_policy_norm", lambda path: pytest.fail("frozen norm loaded"))
target = acquire._resolve_bundle(tmp_path, "illusion_075L", "target")
steady = acquire._resolve_bundle(tmp_path, "steady", "constant")
assert target["norm"] is target["norm_path"] is None
assert steady["norm"] is steady["norm_path"] is None
def test_vortex_zero_bundle_uses_builder_norm_when_frozen_norm_is_absent(monkeypatch, tmp_path):
monkeypatch.setattr(acquire, "load_policy_norm", lambda path: pytest.fail("frozen norm loaded"))
bundle = acquire._resolve_bundle(tmp_path, "vortex_lamb", "zero")
assert bundle["norm"] is bundle["norm_path"] is None
assert acquire._special_normalization_source(bundle["case"], "zero") == (
"builder_recomputed_zero_runtime"
)
assert acquire._special_normalizer_file(bundle) is None
def test_vortex_controlled_bundle_still_requires_frozen_norm(tmp_path):
with pytest.raises(FileNotFoundError, match="Frozen policy normalization not found"):
acquire._resolve_bundle(tmp_path, "vortex_lamb", "controlled")
def test_steady_constant_and_zero_match_active_physical_commands(monkeypatch, tmp_path):
case = acquire.get_case("steady")
class Flow:
DATA_TYPE = np.float32
def __init__(self):
self.obs = np.zeros(12, np.float32); self.action = np.zeros(6, np.float32); self.commands = []
def restore_ddf(self): pass
def apply_ddf(self): pass
def run(self, steps, command): self.commands.append(command.copy()); self.action = command.copy()
def current_effective_action(self): return self.action
data = {"config":{"nx":1280,"ny":1,"sample_interval":800,"n_obj_total":6},
"norm":{"save_states":np.zeros((2,12),np.float32)}}
norm = {"force_norm_fact":1., "sens_deviation":np.zeros(6), "sens_norm_fact":np.ones(6)}
monkeypatch.setattr(acquire, "WARMUP_INTERVALS", 1); monkeypatch.setattr(acquire, "COLLECT_BOUNDARIES", 1)
for role, expected in (("constant", np.asarray(case.action_bias) * acquire.U0),
("zero", np.zeros(3))):
flow = Flow()
rows, _ = acquire._collect_role(role, data, None, flow, norm, tmp_path,
capture_field=lambda *args: {"ux":np.zeros((1,1280)), "uy":np.zeros((1,1280))},
case_name="steady")
assert len(flow.commands) == 2
assert all(np.allclose(command[-3:], expected) for command in flow.commands)
assert np.allclose(rows[0]["commanded_target_omega"], expected)
def test_periodic_publication_requires_four_crossings():
with pytest.raises(ValueError, match="at least 4 center-uy rising crossings"):
acquire._require_periodic_crossings({"crossing_times": np.arange(3)})
assert acquire._require_periodic_crossings({"crossing_times": np.arange(4)}) == 4
def test_illusion_collector_actions_exactly_match_active_run_convention(monkeypatch, tmp_path):
case = acquire.get_case("illusion_075L")
predicted = [np.array([.25, -.5, .75], np.float32),
np.array([-.125, .375, -.625], np.float32),
np.array([.5, 0., -.25], np.float32)]
class Model:
def __init__(self): self.index = 0
def predict(self, obs, deterministic=True):
result = predicted[self.index]; self.index += 1; return result, None
class Flow:
DATA_TYPE = np.float32
def __init__(self):
self.obs=np.zeros(12,np.float32); self.action=np.zeros(6,np.float32); self.commands=[]
def restore_ddf(self): pass
def apply_ddf(self): pass
def run(self, steps, command): self.commands.append(command.copy()); self.action=command.copy(); self.obs += 1
def current_effective_action(self): return self.action
data={"config":{"nx":1280,"ny":1,"sample_interval":400,"n_obj_total":6},
"norm":{"save_states":np.zeros((2,12),np.float32)}, "target_states":np.zeros((80,8)),
"target_harmonics":[{}]*8}
norm={"force_norm_fact":1., "sens_deviation":np.zeros(6), "sens_norm_fact":np.ones(6)}
monkeypatch.setattr(acquire, "WARMUP_INTERVALS", 1); monkeypatch.setattr(acquire, "COLLECT_BOUNDARIES", 2)
monkeypatch.setattr(acquire, "reward_terms", lambda *args: {"reward":1.,"reward_cd":1.,"reward_cl":1.,"native_legacy_dtw":1.})
monkeypatch.setattr(acquire, "gen_target_states_at", lambda step, harmonics: np.arange(8,dtype=float)+step)
flow=Flow()
acquire._collect_role("controlled", data, Model(), flow, norm, tmp_path,
capture_field=lambda *args: {"ux":np.zeros((1,1280)),"uy":np.zeros((1,1280))},
case_name=case.name)
# Exact convention in active run.py: (normalized_action * action_scale + action_bias) * U0.
expected=[(action * case.action_scale + np.asarray(case.action_bias)) * acquire.U0 for action in predicted]
assert all(np.allclose(command[-3:], wanted) for command, wanted in zip(flow.commands, expected))
def test_generated_array_identity_binds_dtype_shape_and_content():
value = np.arange(12, dtype=np.float32).reshape(3, 4)
first = acquire._generated_array_identity(value)
assert first == acquire._generated_array_identity(value.copy())
assert first["identity_kind"] == "run-generated-array-content"
assert first["dtype"] == "<f4" and first["shape"] == [3, 4] and first["order"] == "C"
assert len(first["content_sha256"]) == 64 and first["bytes"] == value.nbytes
assert first["content_sha256"] != acquire._generated_array_identity(value.astype(np.float64))["content_sha256"]
assert first["content_sha256"] != acquire._generated_array_identity(value.reshape(4, 3))["content_sha256"]
changed = value.copy(); changed[0, 0] = -1
assert first["content_sha256"] != acquire._generated_array_identity(changed)["content_sha256"]
def test_generated_config_identity_is_json_canonical_and_exposes_values():
left = {"sample_interval": np.int64(400), "obs_slice": (0, 12),
"action_bias": np.array([0., -2., 2.], np.float32)}
right = {"action_bias": [0., -2., 2.], "obs_slice": [0, 12], "sample_interval": 400}
first = acquire._generated_config_identity(left)
assert first == acquire._generated_config_identity(right)
assert first["identity_kind"] == "run-generated-builder-config"
assert first["values"] == right
assert len(first["canonical_json_sha256"]) == 64
assert first["canonical_json_sha256"] != acquire._generated_config_identity({**right, "sample_interval": 600})["canonical_json_sha256"]
@pytest.mark.parametrize("role", ["target", "zero"])
def test_frozen_reference_comparison_only_runs_for_controlled(monkeypatch, role):
monkeypatch.setattr(acquire, "frozen_reference_comparison", lambda *args: pytest.fail("comparison called"))
columns = {"sensors": np.zeros((2, 6)), "forces": np.zeros((2, 6)),
"action_normalized": np.zeros((2, 3))}
assert acquire._controlled_reference(role, {"reference_path": "unused"}, columns, 30) is None
def test_controlled_reference_comparison_remains_enabled(monkeypatch):
sentinel = object()
monkeypatch.setattr(acquire, "frozen_reference_comparison", lambda *args: sentinel)
columns = {"sensors": np.zeros((2, 6)), "forces": np.zeros((2, 6)),
"action_normalized": np.zeros((2, 3))}
assert acquire._controlled_reference("controlled", {"reference_path": "reference.npz"}, columns, 30) is sentinel
def test_role_semantics_are_explicit():
assert "frozen-policy" in acquire._role_semantics("controlled")
assert "builder-generated target" in acquire._role_semantics("target")
assert "frozen norm used only for native reward" in acquire._role_semantics("zero")
assert "case.action_bias*U0" in acquire._role_semantics("constant")
def test_optional_frozen_reference_provenance_records_absence(tmp_path):
missing = tmp_path / "controlled.npz"
assert acquire._optional_file_provenance(None) is None
absent = acquire._optional_file_provenance(missing)
assert absent == {"path": str(missing.resolve()), "exists": False, "file_identity": None}
missing.write_bytes(b"reference")
present = acquire._optional_file_provenance(missing)
assert present["exists"] is True
assert present["file_identity"]["path"] == str(missing.resolve())
assert len(present["file_identity"]["sha256"]) == 64
@@ -0,0 +1,232 @@
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
@@ -0,0 +1,287 @@
import ast
from pathlib import Path
import numpy as np
import src.drl_pinball.legacy_test.metrics as legacy_metrics
import src.drl_pinball.legacy_test.render as legacy_render
import src.drl_pinball.legacy_test.run as legacy_run
from src.drl_pinball.legacy_test.cases import CASES, ERASE_UNSUPPORTED, get_case
from src.drl_pinball.legacy_test.metrics import (
aggregate_rewards, frozen_reference_comparison, reward_terms, sha256_file,
target_scale_cycle_similarity,
)
from src.drl_pinball.legacy_test.render import ddf_to_velocity
from src.drl_pinball.legacy_test.core.legacy_env_builder import _field_xy
from src.drl_pinball.legacy_test.runtime import (
compute_norm, load_policy_norm, policy_observation, reset_runtime,
run_historical_interval,
)
def test_config_matrix():
assert set(CASES)=={"karman_re50","karman_re100","karman_re200","karman_re400","illusion_075L","illusion_1L","illusion_15L","vortex_lamb","vortex_taylor","erase","steady"}
assert [CASES[f"karman_re{x}"].re_code for x in (50,100,200,400)] == [50,100,200,400]
assert [CASES[x].sample_interval for x in ("illusion_075L","illusion_1L","illusion_15L")] == [400,600,800]
assert get_case("erase").model == "d1a3o12_250729_250326_erase"
def test_target_scale_metric_is_scale_invariant():
t=np.column_stack([np.sin(np.arange(90)/7+i) * (i+1) for i in range(6)])
result=target_scale_cycle_similarity(t,t,30)
factors=np.arange(1,7,dtype=np.float64)
scaled=target_scale_cycle_similarity(t*factors,t*factors,30)
assert np.isclose(result["similarity"],scaled["similarity"])
assert len(result["scale"])==6
def test_norm_recomputed_from_rows():
rows=np.arange(120,dtype=np.float32).reshape(10,12)
norm=compute_norm(rows)
assert norm["force_norm_fact"] == 6*np.max(np.abs(rows[:,6:12]))
assert not np.allclose(norm["sens_deviation"],0)
class FakeFlow:
def __init__(self): self.calls=[]
def restore_ddf(self): self.calls.append("restore")
def apply_ddf(self): self.calls.append("apply")
def test_reset_restores_ddf_fifo_and_zero_observation():
ff=FakeFlow(); saved=np.arange(36,dtype=np.float32).reshape(3,12)
fifo,obs=reset_runtime(ff,saved,12)
assert ff.calls==["restore","apply"]
assert np.array_equal(np.asarray(fifo),saved)
assert np.array_equal(obs,np.zeros(12,dtype=np.float32))
def test_ddf_conversion_uses_reference_density_not_population_sum():
ny,nx=3,4; f=np.zeros((ny,nx,9),dtype=np.float32); f[:,:,0]=7; f[:,:,1]=.01
flags=np.ones((nx,ny),dtype=np.uint8)
ux,uy=ddf_to_velocity(f.transpose(2,0,1).reshape(-1),flags,nx,ny)
assert np.allclose(ux,.01)
assert np.allclose(uy,0)
def test_render_final_accepts_real_legacy_3d_shape(monkeypatch, tmp_path):
class RenderFlow:
FIELD_SHAPE = (4, 3, 1)
def __init__(self):
f = np.zeros((3, 4, 9), dtype=np.float32)
f[:, :, 0] = 1.0
f[:, :, 1] = 0.25
self.ddf = f.transpose(2, 0, 1).reshape(-1)
self.flag = np.ones(12, dtype=np.uint8)
self.downloaded = False
def get_ddf(self):
self.downloaded = True
def completed_flags_xy(self):
return self.flag.reshape((3, 4)).T.copy()
captured = {}
def fake_compute_vorticity(ux, uy):
captured["velocity_shape"] = (ux.shape, uy.shape)
return np.zeros_like(ux)
def fake_render(vort, **kwargs):
captured["vorticity_shape"] = vort.shape
captured["render_kwargs"] = kwargs
return {"image_path": kwargs["out_path"]}
monkeypatch.setattr(legacy_render, "compute_vorticity", fake_compute_vorticity)
monkeypatch.setattr(legacy_render, "render_vorticity_field", fake_render)
flow = RenderFlow()
out_path = tmp_path / "final_vorticity.png"
result = legacy_render.render_final(flow, str(out_path))
assert flow.downloaded
assert captured["velocity_shape"] == ((3, 4), (3, 4))
assert captured["vorticity_shape"] == (3, 4)
assert captured["render_kwargs"] == {
"nx": 4,
"ny": 3,
"out_path": str(out_path),
"vmin": -0.001,
"vmax": 0.001,
}
assert result == {"image_path": str(out_path)}
def test_reward_aggregation_uses_case_tail():
rows = [
{"reward": float(i), "reward_cd": float(i + 1), "reward_cl": float(i + 2),
"native_legacy_dtw": float(i + 3)}
for i in range(5)
]
summary = aggregate_rewards(rows, 3)
assert summary == {
"mean": 2.0,
"tail": 3.0,
"final": 4.0,
"tail_reward_cd": 4.0,
"tail_reward_cl": 5.0,
"tail_native_legacy_dtw": 6.0,
"tail_length": 3,
}
def test_frozen_reference_comparison_reports_exact_metrics(tmp_path):
time = np.arange(90, dtype=np.float64)
phase = 2.0 * np.pi * time / 30.0
sensors = np.column_stack([np.sin(phase + index) for index in range(6)])
forces = np.column_stack([np.cos(phase + index) for index in range(6)])
actions = np.column_stack([np.sin(phase + index) for index in range(3)])
path = tmp_path / "controlled.npz"
np.savez_compressed(path, sensors=sensors, forces=forces, actions=actions, rewards=time)
result = frozen_reference_comparison(str(path), sensors, forces, actions, 30)
assert result["reference_path"] == str(path.resolve())
assert len(result["reference_sha256"]) == 64
assert np.isclose(result["sensor_dtw"]["similarity"], 1.0)
assert np.allclose(result["sensor_correlations"], 1.0)
assert np.allclose(result["force_correlations"], 1.0)
assert np.allclose(result["action_correlations"], 1.0)
assert result["sensor_rms"] == result["force_rms"] == result["action_rms"] == 0.0
assert result["threshold"] == 0.95
assert result["passed"] is True
def test_historical_interval_zeros_each_ema_seed_and_forwards_target():
class IntervalFlow:
DATA_TYPE = np.float32
def __init__(self):
self.action = np.array([4.0, -3.0], dtype=np.float32)
self.calls = []
def run(self, steps, target):
self.calls.append((self.action.copy(), steps, target.copy()))
self.action[:] = 9.0
flow = IntervalFlow()
first = np.array([1.0, 2.0], dtype=np.float32)
second = np.array([-1.0, 3.0], dtype=np.float32)
run_historical_interval(flow, 8, first)
run_historical_interval(flow, 12, second)
assert np.array_equal(flow.calls[0][0], np.zeros(2, dtype=np.float32))
assert np.array_equal(flow.calls[1][0], np.zeros(2, dtype=np.float32))
assert flow.calls[0][1] == 8
assert flow.calls[1][1] == 12
assert np.array_equal(flow.calls[0][2], first)
assert np.array_equal(flow.calls[1][2], second)
assert flow.calls[0][0].dtype == flow.calls[1][0].dtype == np.float32
def test_active_builder_and_runner_use_historical_interval_adapter_only():
root = Path(__file__).resolve().parents[1]
for relative in ("run.py", "core/legacy_env_builder.py"):
tree = ast.parse((root / relative).read_text())
direct_calls = [
node for node in ast.walk(tree)
if isinstance(node, ast.Call)
and isinstance(node.func, ast.Attribute)
and node.func.attr == "run"
]
assert direct_calls == [], f"{relative} must route all FlowField intervals through adapter"
def test_frozen_policy_norm_drives_observation_reward_and_provenance(monkeypatch, tmp_path):
path = tmp_path / "norm.json"
path.write_text(
'{"force_norm_fact": 2.0, "sens_deviation": [1, 1, 1, 1, 1, 1], '
'"sens_norm_fact": [2, 2, 2, 2, 2, 2]}',
encoding="utf-8",
)
frozen = load_policy_norm(str(path))
recomputed = {
"force_norm_fact": 8.0,
"sens_deviation": np.zeros(6, dtype=np.float32),
"sens_norm_fact": np.ones(6, dtype=np.float32),
}
raw = np.array([3.0] * 6 + [1.0] * 6, dtype=np.float32)
observation = policy_observation(raw, frozen)
recomputed_observation = policy_observation(raw, recomputed)
assert np.allclose(observation[:6], 0.5)
assert np.allclose(observation[6:], 1.0)
assert not np.array_equal(observation, recomputed_observation)
monkeypatch.setattr(legacy_metrics, "native_similarity", lambda *args: 1.0)
reward = reward_terms(
CASES["karman_re50"], np.zeros((60, 6)), None,
np.tile(raw, (30, 1)), frozen["force_norm_fact"], 0,
)
expected_cd = np.exp(-abs(0.5 * 20.0))
expected_cl = np.exp(-abs(0.5 * 80.0))
assert np.isclose(reward["reward_cd"], expected_cd)
assert np.isclose(reward["reward_cl"], expected_cl)
assert len(sha256_file(str(path))) == 64
def test_missing_frozen_policy_norm_fails_closed(tmp_path):
missing = tmp_path / "missing" / "norm.json"
try:
load_policy_norm(str(missing))
except FileNotFoundError as exc:
assert str(missing) in str(exc)
else:
raise AssertionError("missing mapped policy norm must fail closed")
def test_metrics_only_output_helpers_have_no_filesystem_or_render(monkeypatch, tmp_path):
calls = []
monkeypatch.setattr(legacy_run.os.path, "isdir", lambda path: calls.append(("isdir", path)))
monkeypatch.setattr(legacy_run.shutil, "rmtree", lambda path: calls.append(("rmtree", path)))
monkeypatch.setattr(legacy_run.os, "makedirs", lambda path: calls.append(("makedirs", path)))
monkeypatch.setattr(legacy_run.np, "savez_compressed", lambda *args, **kwargs: calls.append(("save", args[0])))
monkeypatch.setattr(legacy_run, "_json", lambda path, value: calls.append(("json", path)))
monkeypatch.setattr(legacy_run, "render_final", lambda ff, path: calls.append(("render", path)))
out = str(tmp_path / "case")
legacy_run._prepare_case_output(out, metrics_only=True)
legacy_run._write_case_output(
out, True, object(), np.zeros((1, 6)), np.zeros((1, 6)),
np.zeros((1, 3)), [], np.zeros((1, 6)), np.zeros((1, 12)), {}, {},
)
assert calls == []
def test_normal_output_helpers_write_defined_artifacts(monkeypatch, tmp_path):
calls = []
out = str(tmp_path / "case")
monkeypatch.setattr(legacy_run.np, "savez_compressed", lambda *args, **kwargs: calls.append(Path(args[0]).name))
monkeypatch.setattr(legacy_run, "_json", lambda path, value: calls.append(Path(path).name))
monkeypatch.setattr(legacy_run, "render_final", lambda ff, path: calls.append(Path(path).name))
legacy_run._write_case_output(
out, False, object(), np.zeros((1, 6)), np.zeros((1, 6)),
np.zeros((1, 3)), [], np.zeros((1, 6)), np.zeros((1, 12)), {}, {},
)
assert calls == [
"signals.npz", "reset_contract.npz", "metrics.json", "norm.json",
"final_vorticity.png",
]
def test_builder_uses_first_two_legacy_shape_dimensions_and_validates_z():
class ShapeFlow:
def __init__(self, shape):
self.FIELD_SHAPE = shape
assert _field_xy(ShapeFlow((1280, 512, 1))) == (1280, 512)
try:
_field_xy(ShapeFlow((1280, 512, 2)))
except ValueError as exc:
assert "nz=1" in str(exc)
else:
raise AssertionError("builder must reject non-singleton z shape")
try:
_field_xy(ShapeFlow((1280, 512)))
except ValueError as exc:
assert "(nx, ny, nz)" in str(exc)
else:
raise AssertionError("builder must reject two-dimensional shape")
@@ -0,0 +1,312 @@
import numpy as np
import pytest
from src.drl_pinball.legacy_test import acquire
from src.drl_pinball.legacy_test.core import legacy_env_builder as builder
from src.drl_pinball.legacy_test.metrics import erase_reward_terms
from src.drl_pinball.legacy_test.runtime import erase_policy_observation
def test_vortex_labels_and_offsets_are_exact():
assert acquire._vortex_scenario("vortex_lamb",0)=="vortex_lamb_y000"
assert [acquire._vortex_scenario("vortex_taylor",x) for x in (-2,-1,0,1,2)] == ["vortex_taylor_ym2L","vortex_taylor_ym1L","vortex_taylor_y000","vortex_taylor_yp1L","vortex_taylor_yp2L"]
for name,value in (("vortex_lamb",1),("vortex_taylor",3)):
with pytest.raises(ValueError): acquire._vortex_offset(name,value)
def test_lamb_event_uses_only_upper_uy_and_earliest_exact_five():
sensors=np.zeros((150,6)); sensors[62,1]=4; sensors[60,3]=100; sensors[63,5]=200
event=acquire._select_vortex_event(sensors,"vortex_lamb")
assert int(event["peak_index"])==62
assert event["event_channel_index"]==1
assert np.array_equal(event["field_indices"],[52,57,62,67,72])
assert (event["search_lower_boundary"],event["search_upper_boundary"])==(38,87)
def test_taylor_event_uses_only_center_uy_y0_source_channel():
sensors=np.zeros((150,6)); sensors[55,1]=100; sensors[70,3]=4; sensors[65,5]=200
event=acquire._select_vortex_event(sensors,"vortex_taylor")
assert int(event["peak_index"])==70
assert event["event_channel_index"]==3
assert np.array_equal(event["field_indices"],[60,65,70,75,80])
def test_event_baseline_window_and_earliest_tie_are_exact():
sensors=np.zeros((150,6)); sensors[:20,1]=2; sensors[50,1]=5; sensors[51,1]=-1
event=acquire._select_vortex_event(sensors,"vortex_lamb")
assert event["baseline_uy"]==2
assert int(event["peak_index"])==50
sensors[149,1]=100
assert int(acquire._select_vortex_event(sensors,"vortex_lamb")["peak_index"])==50
def test_vortex_schedule_matches_historical_episode():
lamb=acquire.get_case("vortex_lamb"); taylor=acquire.get_case("vortex_taylor")
for target in (False,True):
assert acquire._special_schedule(lamb,target)==(0,150)
assert acquire._special_schedule(taylor,target)==(0,150)
assert lamb.steps==taylor.steps==150
assert acquire._special_timeline_metadata(taylor,False)=={
"warmup_control_steps":0,"collected_post_step_boundaries":150,
"historical_episode_steps":150}
def test_erase_observation_inventory_and_policy_order():
raw=np.arange(14,dtype=np.float32); norm={"force_norm_fact":2.,"sens_deviation":np.zeros(6),"sens_norm_fact":np.ones(6)*100}
obs=erase_policy_observation(raw,norm)
assert np.array_equal(obs[:4],np.clip(raw[8:12]/2,-1,1))
sensors,dist,pinball,full=acquire._special_observation(acquire.get_case("erase"),"controlled",raw)
assert np.array_equal(sensors,raw[:6]) and np.array_equal(dist,raw[6:8]) and np.array_equal(pinball,raw[8:14]) and np.array_equal(full,raw)
def test_erase_controlled_reset_policy_obs_is_exact_12d_zero(monkeypatch,tmp_path):
case=acquire.get_case("erase")
norm={"force_norm_fact":2.,"sens_deviation":np.zeros(6,dtype=np.float32),
"sens_norm_fact":np.ones(6,dtype=np.float32),
"save_states":np.full((150,14),7,dtype=np.float32)}
data={"config":{"n_obj_total":7,"nx":2,"ny":1},"norm":norm,
"target_states":np.zeros((150,6),dtype=np.float32)}
class Model:
def __init__(self): self.observations=[]
def predict(self,obs,deterministic=True):
self.observations.append(np.asarray(obs).copy())
return np.zeros(3,dtype=np.float32),None
class Flow:
DATA_TYPE=np.float32
def __init__(self):
self.action=np.zeros(7,dtype=np.float32); self.obs=np.zeros(14,dtype=np.float32); self.step=0
def restore_ddf(self): pass
def apply_ddf(self): pass
def run(self,steps,command):
self.step+=1; self.action=command.copy()
self.obs=np.arange(14,dtype=np.float32)+self.step
self.obs[6:8]=[1000+self.step,2000+self.step]
def current_effective_action(self): return self.action
monkeypatch.setattr(acquire,"_special_schedule",lambda case,target:(0,2))
monkeypatch.setattr(acquire,"erase_reward_terms",lambda *args,**kwargs:{"reward":0.,"reward_u":0.,"reward_v":0.,"native_legacy_dtw":0.})
model=Model(); flow=Flow()
acquire._collect_special("controlled",data,model,flow,norm,tmp_path,case,
capture_field=lambda *args:{"ux":np.zeros((1,2),np.float32),"uy":np.zeros((1,2),np.float32)})
assert len(model.observations)==2
assert model.observations[0].shape==(12,)
assert model.observations[0].dtype==np.float32
assert np.array_equal(model.observations[0],np.zeros(12,dtype=np.float32))
raw=np.arange(14,dtype=np.float32)+1; raw[6:8]=[1001,2001]
expected=erase_policy_observation(raw,norm)
assert model.observations[1].shape==(12,)
assert np.array_equal(model.observations[1],expected)
assert np.array_equal(model.observations[1][:6],np.clip(raw[8:14]/2,-1,1))
assert not np.any(np.isin(model.observations[1],raw[6:8]))
def test_erase_reward_formula_exact_simple_fixture():
t=np.arange(150,dtype=float); states=np.zeros((150,14)); states[:,6]=np.sin(t/5); states[:,7]=np.cos(t/5)
states[:,8]=-.5*states[:,6]; states[:,10]=-.25*states[:,6]; states[:,12]=-.25*states[:,6]
states[:,9]=-.5*states[:,7]; states[:,11]=-.25*states[:,7]; states[:,13]=-.25*states[:,7]
norm={"force_norm_fact":1.,"sens_deviation":np.zeros(6),"sens_norm_fact":np.ones(6)}
result=erase_reward_terms(states,np.zeros(6),norm)
assert 0 <= result["native_legacy_dtw"] <= 1
assert np.isclose(result["reward"],.4*result["reward_u"]+.4*result["reward_v"]+.2*result["native_legacy_dtw"])
assert result["native_component_similarity"].shape==(2,)
def test_erase_phase_fixed_probe_then_fallback():
times=np.arange(160,dtype=float); center=np.ones(160); disturbance=np.sin(2*np.pi*times/20)
phase,probe,fallback=acquire._erase_phase(times,center,disturbance)
assert len(phase["crossing_times"])>=4 and probe=="disturbance_force_y" and fallback
def _field_candidates(tmp_path, count=5):
paths=[]
for index in range(count):
path=tmp_path/f"candidate_{index}.npz"
np.savez(path,ux=np.full((2,3),index),uy=np.full((2,3),-index))
paths.append(path)
return paths
def test_publish_fields_reproduces_scalar_selection_rejection(tmp_path):
destination=tmp_path/"event_fields.npz"
selection={"field_indices":np.arange(5),"relative_offsets":np.arange(5),
"selected_event_score":np.arange(5,dtype=float),"peak_index":np.int64(2)}
with pytest.raises(ValueError,match="named numeric non-scalar arrays required"):
acquire._publish_fields(destination,_field_candidates(tmp_path),selection)
assert not destination.exists()
def test_publish_fields_writes_only_aligned_event_arrays(tmp_path):
destination=tmp_path/"event_fields.npz"
selection={"field_indices":np.arange(5),"relative_offsets":np.array([-10,-5,0,5,10]),
"selected_event_score":np.linspace(0,1,5)}
acquire._publish_fields(destination,_field_candidates(tmp_path),selection)
with np.load(destination,allow_pickle=False) as saved:
assert set(saved.files)=={"ux","uy","field_indices","relative_offsets","selected_event_score"}
assert all(saved[name].ndim>=1 and saved[name].shape[0]==5 for name in saved.files)
assert "peak_index" not in saved.files and "event_score" not in saved.files
def test_special_exact_inventory_validation(tmp_path):
role=tmp_path/"role"; role.mkdir()
np.savez(role/"timeseries.npz",x=np.zeros(5))
np.savez(role/"event_fields.npz",ux=np.zeros((5,1,1)),uy=np.zeros((5,1,1)),
field_indices=np.array([-8,-3,2,7,12]),relative_offsets=np.array([-10,-5,0,5,10]),
selected_event_score=np.zeros(5))
(role/"timeseries.csv").write_text("x\n")
(role/"event_summary.json").write_text(__import__("json").dumps({
"schema":"drl-pinball-legacy-vortex-event-v2",
"event_source":{"scenario":"vortex_lamb_y000","role":"controlled","channel_index":1,"channel":"upper_sensor_uy"},
"common_peak_index":2,"field_indices":[-8,-3,2,7,12],"relative_offsets":[-10,-5,0,5,10]}))
(role/"metadata.json").write_text("{}")
acquire._validate_special(role,acquire.get_case("vortex_lamb"),"controlled")
with np.load(role/"event_fields.npz") as saved:
assert set(saved.files)=={"ux","uy","field_indices","relative_offsets","selected_event_score"}
np.savez(role/"event_fields.npz",ux=np.zeros((5,1,1)),uy=np.zeros((5,1,1)),
field_indices=np.arange(5),relative_offsets=np.arange(5),
selected_event_score=np.zeros(5),peak_index=np.asarray(2))
with pytest.raises(ValueError,match="Vortex event field keys mismatch"):
acquire._validate_special(role,acquire.get_case("vortex_lamb"),"controlled")
np.savez(role/"event_fields.npz",ux=np.zeros((5,1,1)),uy=np.zeros((5,1,1)),
field_indices=np.arange(5),relative_offsets=np.arange(5),
selected_event_score=np.zeros(5))
(role/"extra").write_text("")
with pytest.raises(ValueError,match="staged role files mismatch"): acquire._validate_special(role,acquire.get_case("vortex_lamb"),"controlled")
def test_special_failure_transaction_preserves_existing(monkeypatch,tmp_path):
root=tmp_path/"out"; final=root/"legacy"/"vortex_lamb_y000"/"controlled"
final.mkdir(parents=True); marker=final/"keep.txt"; marker.write_text("old")
model=tmp_path/"model.zip"; model.write_bytes(b"model")
norm_path=tmp_path/"norm.json"; norm_path.write_text("{}")
bundle={"case":acquire.get_case("vortex_lamb"),"model_path":model,"norm_path":norm_path,
"norm":{"force_norm_fact":1.},"reference_path":tmp_path/"missing.npz"}
monkeypatch.setattr(acquire,"_resolve_bundle",lambda *args:bundle)
monkeypatch.setattr(acquire,"_collect_special",lambda *args,**kwargs:([],[]))
monkeypatch.setattr(acquire,"_finalize_special",lambda *args:(_ for _ in ()).throw(RuntimeError("scalar publication failed")))
class Flow:
def close(self): pass
def storage_validator(**kwargs):
root.mkdir(exist_ok=True); return {"resolved_output_root":root,"device":root.stat().st_dev}
def runtime_factory(*args): return {"norm":{}},object(),Flow()
with pytest.raises(RuntimeError,match="scalar publication failed"):
acquire.acquire_role("controlled",case_name="vortex_lamb",output_root=root,overwrite=True,
repo_root=tmp_path,storage_validator=storage_validator,runtime_factory=runtime_factory)
assert marker.read_text()=="old"
assert not list(final.parent.glob(".controlled.staging-*"))
def _write_event_source(root, case_name="vortex_lamb", peak=62):
scenario=acquire._canonical_vortex_source(case_name)
channel,name=acquire._vortex_event_channel(case_name)
path=root/"legacy"/scenario/"controlled"/"event_summary.json"
path.parent.mkdir(parents=True)
path.write_text(__import__("json").dumps({"schema":"drl-pinball-legacy-vortex-event-v2",
"event_source":{"scenario":scenario,"role":"controlled","channel_index":channel,"channel":name},
"common_peak_index":peak,"field_indices":[peak-10,peak-5,peak,peak+5,peak+10],
"relative_offsets":[-10,-5,0,5,10]}))
return path
def test_common_source_indices_reused_across_roles_and_taylor_offsets(tmp_path):
storage={"resolved_output_root":tmp_path}
_write_event_source(tmp_path,"vortex_lamb",61)
_write_event_source(tmp_path,"vortex_taylor",73)
assert np.array_equal(acquire._load_vortex_event_source(storage,"vortex_lamb")["field_indices"],[51,56,61,66,71])
for offset in (-2,-1,0,1,2):
source=acquire._load_vortex_event_source(storage,"vortex_taylor")
assert np.array_equal(source["field_indices"],[63,68,73,78,83])
assert source["event_source"]["scenario"]=="vortex_taylor_y000"
def test_missing_or_malformed_common_source_fails_closed(tmp_path):
storage={"resolved_output_root":tmp_path}
with pytest.raises(FileNotFoundError,match="required before this run"):
acquire._load_vortex_event_source(storage,"vortex_lamb")
path=_write_event_source(tmp_path,"vortex_lamb")
path.write_text("{}")
with pytest.raises(ValueError,match="malformed canonical"):
acquire._load_vortex_event_source(storage,"vortex_lamb")
class _VortexBuilderFlow:
DATA_TYPE=np.float32
FIELD_SHAPE=(1280,2,1)
def __init__(self,*args,**kwargs):
self.obs=np.zeros(0,np.float32); self.action=np.zeros(0,np.float32)
self.runs=[]; self.vortices=[]; self.actions=[]; self.restore_count=0; self.active_injection=0
def add_sensor(self,*args): self.obs=np.zeros(self.obs.size+2,np.float32); self.action=np.zeros(self.obs.size//2,np.float32)
def add_cylinder(self,*args): self.obs=np.zeros(self.obs.size+2,np.float32); self.action=np.zeros(self.obs.size//2,np.float32)
def add_vortex(self,*args):
self.vortices.append(args); self.actions.append(("add_vortex",args)); self.active_injection+=1
def get_ddf(self): pass
def save_ddf(self): pass
def restore_ddf(self): self.restore_count+=1; self.active_injection=0
def apply_ddf(self): pass
class _BuilderConfig:
def _replace(self,**kwargs): return self
def _patch_vortex_builder(monkeypatch):
monkeypatch.setattr(builder,"FlowField",_VortexBuilderFlow)
monkeypatch.setattr(builder.legacy_utils,"load_cuda_config",lambda path:object())
monkeypatch.setattr(builder.legacy_utils,"load_flow_field_config",lambda path:_BuilderConfig())
monkeypatch.setattr(builder,"FIFO_LEN",2)
def interval(flow,steps,command):
copied=np.asarray(command).copy()
flow.runs.append((steps,copied)); flow.actions.append(("run",steps,copied))
flow.action=copied
flow.obs[:]=flow.active_injection
monkeypatch.setattr(builder,"run_historical_interval",interval)
def test_target_builder_handoff_is_fresh_second_injection(monkeypatch):
_patch_vortex_builder(monkeypatch)
result=builder.build_vortex(target_only=True,role="target")
flow=result["flow_field"]
long_step=int(1280/builder.U0)
assert np.array_equal(result["target_states"],np.ones((2,6),np.float32))
assert len(flow.vortices)==2 and flow.restore_count==1 and flow.active_injection==1
assert [action[0] for action in flow.actions]==["run","add_vortex","run","run","add_vortex"]
assert flow.actions[0][1]==long_step and len(flow.actions[0][2])==3
assert all(action[1]==800 and len(action[2])==3 for action in flow.actions[2:4])
assert flow.vortices[0]==flow.vortices[1]
assert result["config"]["runtime_handoff"]=="fresh_second_injection_from_saved_pre_injection_sensors_state"
def test_zero_builder_pretriggers_then_formally_injects_at_exact_zero(monkeypatch):
_patch_vortex_builder(monkeypatch)
zero=builder.build_vortex(role="zero",vortex_type="taylor",y_offset_l0=2)
flow=zero["flow_field"]
long_step=int(1280/builder.U0); development_steps=int(2*1280/builder.U0)
six_object_actions=[action for action in flow.actions
if action[0]=="add_vortex" and action[1][0][0]==15*builder.L0
or action[0]=="run" and len(action[2])==6 and action[1] in (long_step,development_steps)]
assert [action[0] for action in six_object_actions]==["run","add_vortex","run","add_vortex"]
assert six_object_actions[0][1]==long_step
assert np.array_equal(six_object_actions[0][2],np.zeros(6,np.float32))
assert six_object_actions[2][1]==development_steps
assert np.array_equal(six_object_actions[2][2],np.zeros(6,np.float32))
assert six_object_actions[1][1]==six_object_actions[3][1]
vortex=six_object_actions[1][1]
assert vortex[0]==(15*builder.L0,1/2+2*builder.L0,0.0)
assert vortex[1]==2*builder.L0 and vortex[2]==0.03*builder.U0 and vortex[4]=="taylor"
assert zero["config"]["pre_injection_stabilization"]=="exact_physical_zero_with_vortex_pretrigger_2NX_over_U0"
assert zero["config"]["pretrigger_vortex"] is True
assert zero["config"]["pretrigger_development_steps"]==development_steps
def test_controlled_builder_keeps_zero_bias_then_one_formal_vortex(monkeypatch):
_patch_vortex_builder(monkeypatch)
controlled=builder.build_vortex(role="controlled")
flow=controlled["flow_field"]
long_step=int(1280/builder.U0)
acquisition_actions=[action for action in flow.actions
if action[0]=="add_vortex" and action[1][0][0]==15*builder.L0
or action[0]=="run" and len(action[2])==6 and action[1]==long_step]
assert [action[0] for action in acquisition_actions]==["run","run","add_vortex"]
assert np.array_equal(acquisition_actions[0][2],np.zeros(6,np.float32))
assert np.array_equal(acquisition_actions[1][2][3:],np.asarray([0,-4,4],np.float32)*builder.U0)
assert sum(vortex[0][0]==15*builder.L0 for vortex in flow.vortices)==1
assert controlled["config"]["pre_injection_stabilization"]=="zero_then_bias"
assert controlled["config"]["pretrigger_vortex"] is False
assert controlled["config"]["pretrigger_development_steps"]==0
def test_vortex_builder_role_and_target_only_are_bound(monkeypatch):
_patch_vortex_builder(monkeypatch)
with pytest.raises(ValueError,match="semantics disagree"):
builder.build_vortex(target_only=True,role="controlled")
+57
View File
@@ -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]),
+271
View File
@@ -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)
+565
View File
@@ -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"