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:
@@ -0,0 +1,4 @@
|
||||
from .run import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
|
||||
@@ -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)}
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user