feat(eval): publish cycle-mean wake acquisition

Add deterministic phase-filtered V5 and Legacy acquisition with complete-cycle mean fields, then evaluate controlled wakes against target and zero baselines offline.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Frank14f
2026-08-08 15:50:49 +08:00
co-authored by Cursor
parent b144d62920
commit 61e82ec90a
21 changed files with 5022 additions and 574 deletions
+4
View File
@@ -0,0 +1,4 @@
from .run import main
if __name__ == "__main__":
main()
+819
View File
@@ -0,0 +1,819 @@
"""Legacy Karman Re100 role acquisition; solver imports stay lazy."""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import shutil
from collections import deque
from pathlib import Path
from typing import Any, Callable
import numpy as np
from drl_pinball.acquisition import (
accumulate_mean_fields, assign_phase, assign_periodic_phase,
cleanup_scratch, complete_cycle_field_indices, create_scratch,
default_reproduction_mapping, dual_cycle_dtw, pooled_phase_bins,
prepare_role_output, publish_role_output, publish_selected_fields,
select_phase_fields, validate_output_storage, write_boundary_artifacts,
write_phase_cycle_artifacts,
)
from .cases import get_case
from .metrics import erase_reward_terms, frozen_reference_comparison, reward_terms, sha256_file
from .core.dtw_metrics import gen_target_states_at
from .runtime import erase_policy_observation, load_policy_norm, policy_observation, reset_runtime, run_historical_interval
CASE_NAME, ROLE = "karman_re100", "controlled" # compatibility defaults
PERIODIC_CASES = ("karman_re50", "karman_re100", "karman_re200", "karman_re400",
"illusion_075L", "illusion_1L", "illusion_15L")
SUPPORTED_CASES = PERIODIC_CASES + ("steady", "vortex_lamb", "vortex_taylor", "erase")
PERIODIC_ROLES = ("controlled", "target", "zero")
STEADY_ROLES = ("target", "constant", "zero")
ROLES = PERIODIC_ROLES
WARMUP_INTERVALS, COLLECT_BOUNDARIES = 480, 160
NX, U0, SI, CYCLE_LENGTH = 1280, 0.01, 800, 30 # compatibility defaults
MIN_FREE_BYTES = 4 * 1024**3
def _atomic_json(path: Path, payload: dict[str, Any]) -> None:
temporary = path.with_name(f".{path.name}.tmp")
with temporary.open("w", encoding="utf-8") as stream:
json.dump(payload, stream, indent=2)
stream.write("\n")
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, path)
def _identity(path: Path) -> dict[str, Any]:
return {"path": str(path.resolve()), "sha256": sha256_file(str(path)), "bytes": path.stat().st_size}
def _canonical_json_value(value):
"""Convert builder contract values to deterministic JSON-native values."""
if isinstance(value, dict):
return {str(key): _canonical_json_value(current) for key, current in value.items()}
if isinstance(value, (list, tuple)):
return [_canonical_json_value(current) for current in value]
if isinstance(value, np.ndarray):
return _canonical_json_value(value.tolist())
if isinstance(value, np.generic):
return value.item()
if value is None or isinstance(value, (str, int, float, bool)):
return value
raise TypeError(f"builder config contains non-JSON value {type(value).__name__}")
def _optional_file_provenance(path: Path | None):
if path is None:
return None
path = Path(path)
return {"path": str(path.resolve()), "exists": path.is_file(),
"file_identity": _identity(path) if path.is_file() else None}
def _generated_array_identity(value) -> dict[str, Any]:
"""Identify a run-generated numeric array by dtype, shape, and C-order content."""
array = np.asarray(value)
if array.dtype.hasobject:
raise TypeError("run-generated identity does not support object arrays")
descriptor = {"dtype": array.dtype.str, "shape": list(array.shape), "order": "C"}
header = json.dumps(descriptor, sort_keys=True, separators=(",", ":"),
allow_nan=False).encode("ascii")
content = np.ascontiguousarray(array).tobytes(order="C")
digest = hashlib.sha256(header + b"\0" + content).hexdigest()
return {"identity_kind": "run-generated-array-content", **descriptor,
"content_sha256": digest, "bytes": len(content)}
def _generated_config_identity(config) -> dict[str, Any]:
"""Identify a run-generated builder config by canonical JSON content."""
values = _canonical_json_value(config)
canonical = json.dumps(values, sort_keys=True, separators=(",", ":"),
ensure_ascii=True, allow_nan=False)
return {"identity_kind": "run-generated-builder-config", "values": values,
"canonical_json_sha256": hashlib.sha256(canonical.encode("ascii")).hexdigest()}
def _role_semantics(role: str) -> str:
return {
"controlled": "frozen-policy controlled trajectory",
"target": "live builder-generated target trajectory; actions, rewards, and policy normalization unavailable",
"zero": "physical-zero/uncontrolled trajectory using counter-bias normalized action; frozen norm used only for native reward evaluation",
"constant": "established steady constant control at case.action_bias*U0 from normalized action zero",
}[role]
def _controlled_reference(role, bundle, columns, conv_len):
if role != "controlled":
return None
return frozen_reference_comparison(
str(bundle["reference_path"]), columns["sensors"], columns["forces"],
columns["action_normalized"], conv_len,
)
def _case_roles(case):
if case.name not in SUPPORTED_CASES:
raise ValueError(f"unsupported Legacy acquisition case {case.name!r}")
return STEADY_ROLES if case.scene == "steady" else PERIODIC_ROLES
def _resolve_bundle(
repo_root: Path, case_name: str = CASE_NAME, role: str = "controlled",
) -> dict[str, Any]:
"""Resolve immutable CPU artifacts before solver construction."""
case = get_case(case_name)
_case_roles(case)
frozen = repo_root / "src" / "SR_analysis" / "data" / case.scene / (case.reference_case or case.name)
needs_model = case.model is not None and role == "controlled"
needs_frozen_policy = (case.model is not None and role != "target" and
case.scene != "erase" and
not (case.scene == "vortex" and role == "zero"))
norm_path = frozen / "norm.json" if needs_frozen_policy else None
norm = load_policy_norm(str(norm_path)) if norm_path is not None else None
model = None
if needs_model:
from .core.model_loader import model_path
model = Path(model_path(case.model))
return {"case": case, "model_path": model, "norm_path": norm_path, "norm": norm,
"reference_path": frozen / "controlled.npz"}
def _default_runtime(case, bundle, device_id, role="controlled"):
from .core.legacy_env_builder import (
build_illusion, build_illusion_target, build_karman_cloak, build_karman_target,
build_erase, build_steady_cloak, build_steady_target, build_vortex,
)
if case.scene == "vortex":
data = build_vortex(device_id, case.re_code, vortex_type=case.vortex_type, action_scale=case.action_scale, target_only=(role == "target"))
elif case.scene == "erase":
data = build_erase(device_id, case.re_code, target_only=(role == "target"))
elif case.scene == "karman":
data = (build_karman_target(device_id, case.re_code, sample_interval=case.sample_interval)
if role == "target" else
build_karman_cloak(device_id, case.re_code, sample_interval=case.sample_interval))
elif case.scene == "illusion":
kwargs = dict(device_id=device_id, re_code=case.re_code,
target_diameter_L=case.target_radius_l, sample_interval=case.sample_interval)
data = build_illusion_target(**kwargs) if role == "target" else build_illusion(**kwargs)
elif case.scene == "steady":
data = (build_steady_target(device_id, case.re_code, sample_interval=case.sample_interval)
if role == "target" else build_steady_cloak(device_id, case.re_code))
else:
raise ValueError(f"unsupported Legacy acquisition scene {case.scene!r}")
model = None
if role == "controlled":
from .core.model_loader import load_model
model = load_model(case.model)
return data, model, data["flow_field"]
def _run_interval(ff, steps: int, command: np.ndarray) -> None:
context = getattr(ff, "context", None)
if context is None:
run_historical_interval(ff, steps, command)
return
context.push()
try:
run_historical_interval(ff, steps, command)
finally:
context.pop()
def _command(case, action, n_objects, dtype):
action = np.asarray(action, dtype=np.float32).reshape(3)
command = np.zeros(n_objects, dtype=dtype)
command[-3:] = (action * case.action_scale + np.asarray(case.action_bias)) * U0
return action, command
def _clock(ff, completed_intervals: int, sample_interval: int = SI) -> tuple[int, int, int | None]:
"""Return relative policy clocks plus the solver's absolute lattice clock."""
lattice_step = completed_intervals * sample_interval
absolute = None
if hasattr(ff, "solver_clock_state"):
state = ff.solver_clock_state()
absolute = int(state["solver_absolute_lattice_clock"])
if absolute < lattice_step:
raise ValueError("solver lattice clock regressed below acquisition timeline")
return lattice_step, completed_intervals, absolute
def _capture_boundary_field(ff, nx: int, ny: int) -> dict[str, np.ndarray]:
"""Capture public Legacy q/U0, convert to physical q/RHO_ref, and canonicalize."""
velocity = ff.current_step_velocity_field()
if not isinstance(velocity, tuple) or len(velocity) != 2:
raise ValueError("public Legacy velocity accessor must return (ux, uy)")
flags = np.asarray(ff.completed_flags_xy())
if flags.dtype != np.uint8 or flags.shape != (nx, ny):
raise ValueError("completed Legacy flags must be exact uint8 (nx,ny)")
fluid_xy = (flags & np.uint8(0b00000001)) != 0
u0 = float(ff.field_config.velocity)
if not np.isfinite(u0) or u0 <= 0:
raise ValueError("Legacy field_config.velocity U0 must be positive and finite")
result = {}
for name, field in zip(("ux", "uy"), velocity):
value = np.asarray(field)
if value.shape != (nx, ny) or value.dtype.kind != "f":
raise ValueError("public Legacy velocity must be floating (nx,ny) q/U0")
if not np.isfinite(value[fluid_xy]).all():
raise ValueError("public Legacy fluid velocity contains non-finite values")
if not np.array_equal(value[~fluid_xy], np.zeros(np.count_nonzero(~fluid_xy), value.dtype)):
raise ValueError("public Legacy velocity must be exact zero on nonfluid cells")
result[name] = np.asarray((value * u0).T, dtype=np.float32)
return result
def _target_sensors(target_states):
target = np.asarray(target_states)
if target.ndim != 2:
raise ValueError("target_states must be two-dimensional")
if target.shape[1] == 6:
return target
if target.shape[1] == 8:
return target[:, 2:8]
raise ValueError("target_states must expose exactly six sensor channels")
def _observation_slices(case, role, boundary_obs):
if role == "target":
return ((boundary_obs[2:8], np.full(6, np.nan, np.float32))
if case.scene in ("karman", "illusion") else
(boundary_obs[:6], np.full(6, np.nan, np.float32)))
raw = boundary_obs[:12] if case.scene in ("illusion", "steady") else boundary_obs[2:14]
return raw[:6], raw[6:12]
def _collect_role(role, data, model, ff, policy_norm, scratch: Path,
capture_field: Callable = _capture_boundary_field, case_name: str = CASE_NAME):
case = get_case(case_name)
if role not in _case_roles(case):
raise ValueError(f"unknown acquisition role {role!r} for {case.name}")
config = data["config"]; si = int(case.sample_interval)
expected_objects = 4 if role == "target" and case.scene in ("karman", "illusion") else (3 if role == "target" else (7 if case.scene == "karman" else 6))
if (int(config["nx"]), int(config["sample_interval"]), int(config["n_obj_total"])) != (NX, si, expected_objects):
raise ValueError(f"built Legacy {case.name} {role} lattice/control contract changed")
if role == "target":
fifo = obs = None
else:
saved = np.asarray(data["norm"]["save_states"], dtype=np.float32)
obs_dim = 14 if case.scene == "illusion" else 12
fifo, obs = reset_runtime(ff, saved, obs_dim)
if not isinstance(fifo, deque) or len(fifo) != len(saved):
raise ValueError("canonical reset FIFO contract failed")
rows = []
field_buffer = None
total = WARMUP_INTERVALS + COLLECT_BOUNDARIES
harmonics = data.get("target_harmonics")
steady = case.scene == "steady"
for interval in range(total):
if role == "controlled":
action, _ = model.predict(obs, deterministic=True)
action, command = _command(case, action, expected_objects, ff.DATA_TYPE)
elif role == "zero":
action = -np.asarray(case.action_bias, np.float32) / np.float32(case.action_scale)
action, command = _command(case, action, expected_objects, ff.DATA_TYPE)
if not np.allclose(command[-3:], 0.0, rtol=0.0, atol=np.finfo(ff.DATA_TYPE).eps):
raise ValueError("Legacy zero role counter-bias did not cancel the affine command")
command[-3:] = np.zeros(3, dtype=ff.DATA_TYPE)
elif role == "constant":
action = np.zeros(3, np.float32)
action, command = _command(case, action, expected_objects, ff.DATA_TYPE)
else:
action = np.full(3, np.nan, np.float32); command = np.zeros(expected_objects, ff.DATA_TYPE)
_run_interval(ff, si, command)
boundary_obs = np.asarray(ff.obs, np.float32).copy()
sensors, forces = _observation_slices(case, role, boundary_obs)
if role == "target":
effective = np.full(3, np.nan, np.float32)
terms = {name: np.nan for name in ("reward", "reward_cd", "reward_cl", "native_legacy_dtw")}
else:
raw = np.concatenate((sensors, forces)).astype(np.float32)
fifo.append(raw)
if steady:
terms = {name: np.nan for name in ("reward", "reward_cd", "reward_cl", "native_legacy_dtw")}
else:
terms = reward_terms(case, data["target_states"], harmonics, np.asarray(fifo), policy_norm["force_norm_fact"], interval)
if not steady:
target_force = gen_target_states_at(interval + 1, harmonics)[:2] if harmonics is not None else None
obs = policy_observation(raw, policy_norm, target_force=target_force)
effective = np.asarray(ff.current_effective_action(), np.float32)[-3:].copy()
if interval < WARMUP_INTERVALS:
continue
boundary = interval - WARMUP_INTERVALS
lattice_step, control_clock, absolute = _clock(ff, interval + 1, si)
native = float(terms["native_legacy_dtw"])
row = {"physical_time": lattice_step * U0 / NX, "lattice_step": lattice_step,
"control_index": control_clock, "sensors": sensors.copy(), "forces": forces.copy(),
"action_normalized": action.copy(),
"commanded_target_omega": (np.full(3, np.nan, np.float32) if role == "target" else command[-3:].astype(np.float32, copy=True)),
"effective_smoothed_omega": effective, "reward_raw": float(terms["reward"]),
"reward_cd": float(terms["reward_cd"]), "reward_cl": float(terms["reward_cl"]),
"reward_sim": float(np.exp(-10 * abs(native - 1))) if np.isfinite(native) else np.nan,
"native_reward_dtw": native}
if absolute is not None: row["solver_absolute_lattice_step"] = absolute
rows.append(row)
if not steady or boundary == COLLECT_BOUNDARIES - 1:
fields = capture_field(ff, int(config["nx"]), int(config["ny"]))
if field_buffer is None:
count = 1 if steady else COLLECT_BOUNDARIES
field_buffer = _allocate_field_buffer(fields["ux"].shape, count)
store_at = 0 if steady else boundary
_store_field_candidate(field_buffer, store_at, fields)
return rows, field_buffer
def _collect(data, model, ff, policy_norm, scratch: Path, capture_field: Callable = _capture_boundary_field):
return _collect_role("controlled", data, model, ff, policy_norm, scratch, capture_field=capture_field)
def _allocate_field_buffer(shape_yx, count: int = COLLECT_BOUNDARIES):
ny, nx = map(int, shape_yx)
return {
"ux": np.empty((int(count), ny, nx), dtype=np.float32),
"uy": np.empty((int(count), ny, nx), dtype=np.float32),
}
def _store_field_candidate(field_buffer, boundary: int, fields):
field_buffer["ux"][boundary] = np.asarray(fields["ux"], dtype=np.float32)
field_buffer["uy"][boundary] = np.asarray(fields["uy"], dtype=np.float32)
def _columns(rows):
return {name: np.asarray([row[name] for row in rows]) for name in rows[0]}
def _require_periodic_crossings(phase, minimum: int = 4):
count = len(phase["crossing_times"])
if count < minimum:
raise ValueError(f"periodic publication requires at least {minimum} center-uy rising crossings")
return count
def _phase_values(columns, role="controlled"):
excluded = {"physical_time", "lattice_step", "solver_absolute_lattice_step",
"control_index", "phase", "cycle_id"}
if role == "target":
return {"sensors": columns["sensors"]}
return {name: value for name, value in columns.items() if name not in excluded}
def _minimum_crossing_interval(physical_time, cycle_length):
times = np.asarray(physical_time, dtype=np.float64)
if len(times) < 2:
raise ValueError("physical_time must contain at least two boundaries")
dt = float(np.median(np.diff(times)))
if not np.isfinite(dt) or dt <= 0:
raise ValueError("physical_time spacing must be positive and finite")
return 0.5 * float(cycle_length) * dt
PHASE_FIELD_KEYS = {
"ux", "uy", "mean_ux", "mean_uy", "field_indices", "cycle_id",
"target_phase", "actual_phase", "phase_error",
}
def _finalize(role_dir: Path, scratch: Path, rows, field_buffer, data, bundle, storage, role="controlled"):
case = bundle["case"]; columns = _columns(rows)
phase_meta = {}
if case.scene == "steady":
write_boundary_artifacts(role_dir / "timeseries.npz", role_dir / "timeseries.csv", columns)
publish_selected_fields(role_dir / "late_field.npz", {
"ux": np.asarray(field_buffer["ux"][:1], dtype=np.float32),
"uy": np.asarray(field_buffer["uy"][:1], dtype=np.float32),
"field_indices": np.asarray([len(rows) - 1], dtype=np.int64),
})
phase_values = {}
else:
dtw = dual_cycle_dtw(_target_sensors(data["target_states"]), columns["sensors"],
columns["native_reward_dtw"], cycle_length=case.conv_len, lag_channel=3)
columns.update(target_normalized_dtw=dtw["target_normalized_dtw"],
target_normalized_dtw_lag=dtw["target_normalized_dtw_lag"])
min_gap = _minimum_crossing_interval(columns["physical_time"], case.conv_len)
phase = assign_periodic_phase(
columns["physical_time"], columns["sensors"], minimum_crossing_interval=min_gap,
)
_require_periodic_crossings(phase)
columns.update(phase=phase["phase"], cycle_id=phase["cycle_id"])
write_boundary_artifacts(role_dir / "timeseries.npz", role_dir / "timeseries.csv", columns)
phase_values = _phase_values(columns, role)
write_phase_cycle_artifacts(role_dir / "phase_cycle.npz", role_dir / "phase_cycle.csv", pooled_phase_bins(phase["phase"], phase_values, bins=32))
selected = select_phase_fields(columns["physical_time"], phase["crossing_times"])
mean_span = complete_cycle_field_indices(columns["physical_time"], phase["crossing_times"])
mean = accumulate_mean_fields(field_buffer["ux"], field_buffer["uy"], mean_span["field_indices"])
published = {
"ux": np.asarray(field_buffer["ux"][selected["field_indices"]], dtype=np.float32),
"uy": np.asarray(field_buffer["uy"][selected["field_indices"]], dtype=np.float32),
"mean_ux": mean["mean_ux"],
"mean_uy": mean["mean_uy"],
}
published.update(selected)
field_path = publish_selected_fields(role_dir / "phase_fields.npz", published)
with np.load(field_path, allow_pickle=False) as saved:
if (saved["ux"].shape[0] != 8 or set(saved.files) != PHASE_FIELD_KEYS
or saved["mean_ux"].shape != saved["ux"].shape[1:]
or saved["mean_uy"].shape != saved["uy"].shape[1:]):
raise ValueError("published phase fields failed exact-key validation")
reference = _controlled_reference(role, bundle, columns, case.conv_len)
_atomic_json(role_dir / "dtw_summary.json", {"native_pipeline": "unavailable for target role" if role == "target" else "original Legacy reward DTW",
"target_normalized_dtw": dtw["metadata"]["definition"], "historical_window": case.conv_len,
"lag_channel": 3, "target_scale": dtw["metadata"]["scale"].tolist(),
"native_mean": (None if role == "target" else float(np.nanmean(columns["native_reward_dtw"]))),
"target_normalized_dtw_finite_mean": float(np.nanmean(columns["target_normalized_dtw"])),
"crossing_count": int(len(phase["crossing_times"])),
"frozen_reference_comparison": reference})
phase_meta = {
"phase_contract": "smoothed rising crossings of sensors[:,3] with min-gap filter; complete half-open cycles",
"phase_smoothing_kernel": phase["smoothing_kernel"],
"minimum_crossing_interval": phase["minimum_crossing_interval"],
"accepted_crossing_count": phase["accepted_crossing_count"],
"rejected_crossing_count": phase["rejected_crossing_count"],
"complete_cycle_count": phase["complete_cycle_count"],
"mean_field_count": mean_span["mean_field_count"],
"mean_first_crossing_time": mean_span["first_crossing_time"],
"mean_last_crossing_time": mean_span["last_crossing_time"],
"candidate_field_storage": "single-role in-memory FP32 ux/uy buffer; no boundary_*.npz scratch",
}
cleanup_scratch(scratch, root=role_dir / "scratch"); scratch.parent.rmdir()
_atomic_json(role_dir / "metadata.json", {"schema": "drl-pinball-legacy-acquisition-v2", "case_id": case.name, "scene": case.scene, "role": role,
"warmup_control_steps": WARMUP_INTERVALS, "collected_post_step_boundaries": COLLECT_BOUNDARIES,
"si": case.sample_interval, "historical_window": case.conv_len, "phase_sensor": "fixed center sensor uy (sensors[:,3])" if case.scene != "steady" else None,
"field_contract": {"quantity": "q/RHO_ref", "RHO_ref": 1, "source": "public current_step_velocity_field q/U0 multiplied by field_config.velocity U0"},
"field_count": 1 if case.scene == "steady" else 8, "nonperiodic": case.scene == "steady",
"role_semantics": _role_semantics(role),
"timeline_contract": "V5 lattice_step is an absolute solver count; Legacy physical_time is derived only from relative Legacy lattice_step",
"source_provenance": {
"model_file": _identity(bundle["model_path"]) if role == "controlled" else None,
"normalizer_file": (_identity(bundle["norm_path"]) if bundle["norm_path"] is not None else None),
"frozen_reference_file": (_optional_file_provenance(bundle["reference_path"])
if role == "controlled" else None),
"target_states": _generated_array_identity(data["target_states"]),
"builder_config": _generated_config_identity(data["config"]),
},
"model": _identity(bundle["model_path"]) if role == "controlled" else None,
"normalizer": (_identity(bundle["norm_path"]) if bundle["norm_path"] is not None else None),
"normalization_source": ("frozen_policy" if bundle["norm_path"] is not None else
("builder_recomputed" if role != "target" else "not_applicable")),
"scratch_cleanup": {"complete": not scratch.exists(), "path": str(scratch)},
"resolved_optane_path": str(storage["resolved_output_root"]), "phase_variables": list(phase_values),
**phase_meta})
def _validate_staged_role(role_dir: Path, case_name: str = CASE_NAME) -> None:
steady = get_case(case_name).scene == "steady"
expected = ({"timeseries.npz", "timeseries.csv", "late_field.npz", "metadata.json"} if steady else
{"timeseries.npz", "timeseries.csv", "phase_cycle.npz", "phase_cycle.csv", "phase_fields.npz", "dtw_summary.json", "metadata.json"})
actual = {path.name for path in role_dir.iterdir()}
if actual != expected: raise ValueError(f"staged role files mismatch: expected {sorted(expected)}, got {sorted(actual)}")
field_name = "late_field.npz" if steady else "phase_fields.npz"
with np.load(role_dir / field_name, allow_pickle=False) as saved:
count = 1 if steady else 8
if saved["ux"].shape[0] != count or saved["uy"].shape[0] != count: raise ValueError("field count contract failed")
if not steady:
if (set(saved.files) != PHASE_FIELD_KEYS
or saved["mean_ux"].shape != saved["ux"].shape[1:]
or saved["mean_uy"].shape != saved["uy"].shape[1:]):
raise ValueError("phase fields exact schema failed")
with (role_dir / "metadata.json").open(encoding="utf-8") as stream: json.load(stream)
def _acquire_standard(role, *, case_name=CASE_NAME, output_root=None, overwrite=False, device_id=0,
repo_root=None, storage_validator=validate_output_storage,
runtime_factory=None, finalizer=_finalize, **unused):
case = get_case(case_name)
if role not in _case_roles(case): raise ValueError(f"unknown acquisition role {role!r} for {case_name}")
repo = Path(repo_root) if repo_root is not None else Path(__file__).resolve().parents[3]
storage = storage_validator(repo_mapping=default_reproduction_mapping(repo),
output_root=output_root, min_free_bytes=MIN_FREE_BYTES)
if case_name == CASE_NAME and role == "controlled":
bundle = _resolve_bundle(repo)
else:
bundle = _resolve_bundle(repo, case_name, role)
prepared = prepare_role_output(storage, "legacy", case_name, role, overwrite=overwrite)
scratch = create_scratch(prepared["scratch_root"])
ff = None
try:
if runtime_factory is None:
data, model, ff = _default_runtime(bundle["case"], bundle, device_id, role)
else:
data, model, ff = runtime_factory(bundle["case"], bundle, device_id, role)
policy_norm = (None if role == "target" else
(data["norm"] if bundle["case"].scene == "steady" else bundle["norm"]))
rows, fields = _collect_role(role, data, model, ff, policy_norm, scratch, case_name=case_name)
finalizer(prepared["role_dir"], scratch, rows, fields, data, bundle, storage, role)
_validate_staged_role(prepared["role_dir"], case_name)
return publish_role_output(prepared)
except Exception:
if prepared["staging_dir"].exists():
shutil.rmtree(prepared["staging_dir"])
raise
finally:
if ff is not None and hasattr(ff, "close"):
ff.close()
def _vortex_offset(case_name, value):
if type(value) is not int:
raise ValueError("vortex y offset must be an integer L0 multiple")
allowed = {0} if case_name == "vortex_lamb" else {-2, -1, 0, 1, 2}
if value not in allowed:
raise ValueError(f"{case_name} vortex y offset must be one of {sorted(allowed)}")
return value
def _vortex_scenario(case_name, offset):
_vortex_offset(case_name, offset)
label = {0:"y000", -2:"ym2L", -1:"ym1L", 1:"yp1L", 2:"yp2L"}[offset]
return f"{case_name}_{label}"
def _vortex_event_channel(case_name):
if case_name == "vortex_lamb":
return 1, "upper_sensor_uy"
if case_name == "vortex_taylor":
return 3, "center_sensor_uy"
raise ValueError(f"unknown Vortex case {case_name!r}")
def _select_vortex_event(sensors, case_name):
sensors = np.asarray(sensors, np.float64)
if sensors.ndim != 2 or sensors.shape[1:] != (6,) or len(sensors) != 150 or not np.all(np.isfinite(sensors)):
raise ValueError("Vortex event selection requires finite sensors shape (150,6)")
channel, channel_name = _vortex_event_channel(case_name)
uy = sensors[:, channel]
baseline = float(np.median(uy[:20]))
score = np.abs(uy - baseline)
lower, upper = 38, 87
peak = lower + int(np.argmax(score[lower:upper + 1]))
indices = peak + np.asarray([-10, -5, 0, 5, 10], np.int64)
return {"baseline_uy": baseline, "event_score": score,
"selected_event_score": score[indices], "peak_index": np.int64(peak),
"field_indices": indices,
"relative_offsets": np.asarray([-10, -5, 0, 5, 10], np.int64),
"search_lower_boundary": lower, "search_upper_boundary": upper,
"event_channel_index": channel, "event_channel": channel_name}
def _canonical_vortex_source(case_name):
return "vortex_lamb_y000" if case_name == "vortex_lamb" else "vortex_taylor_y000"
def _load_vortex_event_source(storage, case_name):
source = Path(storage["resolved_output_root"]) / "legacy" / _canonical_vortex_source(case_name) / "controlled" / "event_summary.json"
if not source.is_file():
raise FileNotFoundError(f"canonical Vortex event source is required before this run: {source}")
try:
with source.open(encoding="utf-8") as stream:
summary = json.load(stream)
except (OSError, json.JSONDecodeError) as exc:
raise ValueError(f"malformed canonical Vortex event source {source}") from exc
channel, channel_name = _vortex_event_channel(case_name)
expected_source = {"scenario": _canonical_vortex_source(case_name), "role": "controlled",
"channel_index": channel, "channel": channel_name}
required = {"schema", "event_source", "common_peak_index", "field_indices", "relative_offsets"}
if not isinstance(summary, dict) or not required.issubset(summary):
raise ValueError(f"malformed canonical Vortex event source {source}")
try:
peak = int(summary["common_peak_index"])
indices = np.asarray(summary["field_indices"], np.int64)
offsets = np.asarray(summary["relative_offsets"], np.int64)
except (TypeError, ValueError) as exc:
raise ValueError(f"malformed canonical Vortex event source {source}") from exc
if (summary["schema"] != "drl-pinball-legacy-vortex-event-v2" or
summary["event_source"] != expected_source or indices.shape != (5,) or
not np.array_equal(offsets, [-10, -5, 0, 5, 10]) or
not np.array_equal(indices, peak + offsets) or peak < 38 or peak > 87):
raise ValueError(f"malformed canonical Vortex event source {source}")
return {"source_path": source, "event_source": expected_source,
"peak_index": peak, "field_indices": indices, "relative_offsets": offsets}
def _erase_phase(times, center_uy, disturbance_force_y):
try:
phase = _assign_scalar_phase(times, center_uy)
if len(phase["crossing_times"]) >= 4:
return phase, "center_sensor_uy", False
except ValueError as exc:
if "rising crossings" not in str(exc): raise
phase = _assign_scalar_phase(times, disturbance_force_y)
if len(phase["crossing_times"]) < 4:
raise ValueError("Erase publication requires at least 4 rising crossings in fixed disturbance-force fallback")
return phase, "disturbance_force_y", True
def _assign_scalar_phase(times, probe):
probe=np.asarray(probe,np.float64); times=np.asarray(times,np.float64)
if probe.shape != times.shape or probe.ndim != 1 or not np.all(np.isfinite(probe)):
raise ValueError("phase probe must be one-dimensional, aligned, and finite")
synthetic=np.zeros((len(times),4),np.float64); synthetic[:,3]=probe
return assign_phase(times,synthetic)
def _special_observation(case, role, raw):
raw=np.asarray(raw,np.float32)
if case.scene == "erase":
if role == "target": return raw[:6], np.full(2,np.nan,np.float32), np.full(6,np.nan,np.float32), raw[:6]
if raw.shape != (14,): raise ValueError("Erase observation must contain exactly 14 channels")
return raw[:6], raw[6:8], raw[8:14], raw
if raw.shape != (6 if role == "target" else 12,): raise ValueError("Vortex observation inventory changed")
return raw[:6], np.full(2,np.nan,np.float32), (np.full(6,np.nan,np.float32) if role=="target" else raw[6:12]), raw
def _special_schedule(case, target):
if case.scene == "erase":
return (0 if target else 480), 160
return 0, 150
def _special_timeline_metadata(case, target):
warmup, collected = _special_schedule(case, target)
return {"warmup_control_steps": warmup,
"collected_post_step_boundaries": collected,
"historical_episode_steps": 150 if case.scene == "vortex" else None}
def _collect_special(role,data,model,ff,norm,scratch,case,capture_field=_capture_boundary_field):
target = role == "target"; expected = 3 if target else (7 if case.scene=="erase" else 6)
if int(data["config"]["n_obj_total"]) != expected: raise ValueError("special scene object inventory changed")
if target: fifo=None; obs=None
else:
saved=np.asarray(data["norm"]["save_states"],np.float32)
fifo,obs=reset_runtime(ff,saved,14 if case.scene=="erase" else 12)
if case.scene == "erase":
if saved.ndim != 2 or saved.shape[1] != 14:
raise ValueError("Erase reset FIFO must retain exactly 14 raw channels")
obs=np.zeros(12,dtype=np.float32)
warmup,collect=_special_schedule(case,target)
rows=[]; fields=[]
for interval in range(warmup+collect):
if role=="controlled": action,_=model.predict(obs,deterministic=True); action,command=_command(case,action,expected,ff.DATA_TYPE)
elif role=="zero":
action=-np.asarray(case.action_bias,np.float32)/np.float32(case.action_scale); action,command=_command(case,action,expected,ff.DATA_TYPE); command[-3:]=0
else: action=np.full(3,np.nan,np.float32); command=np.zeros(expected,ff.DATA_TYPE)
_run_interval(ff,case.sample_interval,command); sensors,disturbance,pinball,raw=_special_observation(case,role,np.asarray(ff.obs,np.float32).copy())
if target: terms={}; effective=np.full(3,np.nan,np.float32)
else:
fifo.append(raw.copy())
if case.scene=="erase": terms=erase_reward_terms(np.asarray(fifo),np.mean(data["target_states"],axis=0),norm,case.conv_len); obs=erase_policy_observation(raw,norm)
else: terms=reward_terms(case,data["target_states"],None,np.asarray(fifo),norm["force_norm_fact"],interval); obs=policy_observation(raw,norm)
effective=np.asarray(ff.current_effective_action(),np.float32)[-3:].copy()
if interval < warmup: continue
lattice,clock,absolute=_clock(ff,interval+1,case.sample_interval)
row={"physical_time":lattice*U0/NX,"lattice_step":lattice,"control_index":clock,"sensors":sensors.copy(),
"disturbance_force":disturbance.copy(),"pinball_forces":pinball.copy(),"action_normalized":action.copy(),
"commanded_target_omega":np.full(3,np.nan,np.float32) if target else command[-3:].astype(np.float32),
"effective_smoothed_omega":effective,"reward_raw":float(terms.get("reward",np.nan)),
"reward_u":float(terms.get("reward_u",np.nan)),"reward_v":float(terms.get("reward_v",np.nan)),
"native_reward_dtw":float(terms.get("native_legacy_dtw",np.nan)),"native_lag":float(terms.get("native_lag",np.nan)),
"native_component_similarity":np.asarray(terms.get("native_component_similarity",[np.nan,np.nan]))}
if absolute is not None: row["solver_absolute_lattice_step"]=absolute
rows.append(row); value=capture_field(ff,int(data["config"]["nx"]),int(data["config"]["ny"])); path=scratch/f"boundary_{len(rows)-1:04d}.npz"; np.savez(path,**value); fields.append(path)
return rows,fields
def _publish_fields(path,field_paths,selection):
result={"ux":[],"uy":[]}; indices=np.asarray(selection["field_indices"],np.int64)
for index in indices:
with np.load(field_paths[int(index)],allow_pickle=False) as item:
result["ux"].append(item["ux"]); result["uy"].append(item["uy"])
result={key:np.stack(value) for key,value in result.items()}; result.update(selection); publish_selected_fields(path,result)
def _special_normalization_source(case, role):
if role == "target":
return "not_applicable"
if case.scene == "erase":
return "builder_recomputed_historical"
if case.scene == "vortex" and role == "zero":
return "builder_recomputed_zero_runtime"
return "frozen_policy"
def _special_normalizer_file(bundle):
return _identity(bundle["norm_path"]) if bundle["norm_path"] is not None else None
def _finalize_special(role_dir,scratch,rows,field_paths,data,bundle,storage,role,event_source=None):
case=bundle["case"]; columns=_columns(rows); target=role=="target"; event_peak_index=None; event_diagnostics=None
if case.scene=="vortex":
channel, channel_name = _vortex_event_channel(case.name)
own_uy = np.asarray(columns["sensors"][:, channel], np.float64)
own_baseline = float(np.median(own_uy[:20]))
own_score = np.abs(own_uy - own_baseline)
columns["event_score"] = own_score
if event_source is None:
local = _select_vortex_event(columns["sensors"], case.name)
event_source = {"event_source": {"scenario": _canonical_vortex_source(case.name),
"role": "controlled", "channel_index": channel, "channel": channel_name},
"peak_index": int(local["peak_index"]),
"field_indices": local["field_indices"],
"relative_offsets": local["relative_offsets"]}
indices = np.asarray(event_source["field_indices"], np.int64)
offsets = np.asarray(event_source["relative_offsets"], np.int64)
event_selection = {"field_indices": indices, "relative_offsets": offsets,
"selected_event_score": own_score[indices]}
write_boundary_artifacts(role_dir/"timeseries.npz",role_dir/"timeseries.csv",columns)
_publish_fields(role_dir/"event_fields.npz",field_paths,event_selection)
event_peak_index = int(event_source["peak_index"])
event_diagnostics = {"search_lower_boundary": 38, "search_upper_boundary": 87,
"diagnostic_channel_index": channel, "diagnostic_channel": channel_name}
_atomic_json(role_dir/"event_summary.json", {"schema":"drl-pinball-legacy-vortex-event-v2",
"definition":"canonical controlled fixed-channel absolute deviation from median first 20; earliest argmax in inclusive boundaries 38..87",
"event_source":event_source["event_source"], "common_peak_index":event_peak_index,
"field_indices":indices.tolist(), "relative_offsets":offsets.tolist(),
"selected_event_score":own_score[indices].tolist(), "diagnostic_baseline_uy":own_baseline,
"diagnostic_peak_score":float(own_score[event_peak_index]), **event_diagnostics})
field_count=5; nonperiodic=True; probe=None
elif target:
write_boundary_artifacts(role_dir/"timeseries.npz",role_dir/"timeseries.csv",columns); _publish_fields(role_dir/"late_field.npz",field_paths,{"field_indices":np.asarray([len(rows)-1],np.int64)}); field_count=1; nonperiodic=True; probe=None
err=columns["sensors"]-np.mean(data["target_states"],axis=0); _atomic_json(role_dir/"erase_summary.json",{"native_metrics":"unavailable for clean target","clean_sensor_rmse":float(np.sqrt(np.mean(err*err))),"phase_probe":None,"nonperiodic_clean_reference":True})
else:
phase,probe,fallback=_erase_phase(columns["physical_time"],columns["sensors"][:,3],columns["disturbance_force"][:,1])
columns.update(phase=phase["phase"],cycle_id=phase["cycle_id"]); write_boundary_artifacts(role_dir/"timeseries.npz",role_dir/"timeseries.csv",columns)
values=_phase_values(columns,role); write_phase_cycle_artifacts(role_dir/"phase_cycle.npz",role_dir/"phase_cycle.csv",pooled_phase_bins(phase["phase"],values,bins=32)); _publish_fields(role_dir/"phase_fields.npz",field_paths,select_phase_fields(columns["physical_time"],phase["crossing_times"])); field_count=8; nonperiodic=False
err=columns["sensors"]-np.mean(data["target_states"],axis=0); _atomic_json(role_dir/"erase_summary.json",{"native_reward_mean":float(np.mean(columns["reward_raw"])),"reward_u_mean":float(np.mean(columns["reward_u"])),"reward_v_mean":float(np.mean(columns["reward_v"])),"native_reward_dtw_mean":float(np.mean(columns["native_reward_dtw"])),"clean_sensor_rmse":float(np.sqrt(np.mean(err*err))),"phase_probe":probe,"phase_fallback_used":fallback,"force_balance_diagnostic":"native enhanced formula is scale invariant; normalized diagnostic equals native by definition"})
cleanup_scratch(scratch,root=role_dir/"scratch"); scratch.parent.rmdir()
timeline=_special_timeline_metadata(case,target)
_atomic_json(role_dir/"metadata.json",{"schema":"drl-pinball-legacy-nonstandard-acquisition-v1","case_id":case.name,"scene":case.scene,"role":role,**timeline,"field_count":field_count,"nonperiodic":nonperiodic,"phase_probe":probe,"event_peak_index":event_peak_index,"event_source":(event_source["event_source"] if event_source else None),"event_diagnostics":event_diagnostics,"normalization_source":_special_normalization_source(case,role),"role_semantics":(("physical-zero/uncontrolled trajectory using counter-bias normalized action; builder-recomputed historical norm" if role=="zero" else "frozen-policy controlled trajectory using builder-recomputed historical norm") if case.scene=="erase" and not target else ("physical-zero/uncontrolled trajectory using counter-bias normalized action; builder-recomputed zero-runtime norm" if case.scene=="vortex" and role=="zero" else _role_semantics(role))),"field_contract":{"quantity":"q/RHO_ref","RHO_ref":1},"source_provenance":{"model_file":_identity(bundle["model_path"]) if role=="controlled" else None,"normalizer_file":_special_normalizer_file(bundle),"target_states":_generated_array_identity(data["target_states"]),"builder_config":_generated_config_identity(data["config"])},"resolved_optane_path":str(storage["resolved_output_root"])})
def _validate_special(role_dir,case,role):
if case.scene=="vortex": expected={"timeseries.npz","timeseries.csv","event_fields.npz","event_summary.json","metadata.json"}; field="event_fields.npz"; count=5
elif role=="target": expected={"timeseries.npz","timeseries.csv","late_field.npz","erase_summary.json","metadata.json"}; field="late_field.npz"; count=1
else: expected={"timeseries.npz","timeseries.csv","phase_cycle.npz","phase_cycle.csv","phase_fields.npz","erase_summary.json","metadata.json"}; field="phase_fields.npz"; count=8
actual={x.name for x in role_dir.iterdir()}
if actual!=expected: raise ValueError(f"staged role files mismatch: expected {sorted(expected)}, got {sorted(actual)}")
with np.load(role_dir/field,allow_pickle=False) as saved:
expected_keys = ({"ux", "uy", "field_indices", "relative_offsets", "selected_event_score"}
if case.scene == "vortex" else None)
if expected_keys is not None and set(saved.files) != expected_keys:
raise ValueError(f"Vortex event field keys mismatch: expected {sorted(expected_keys)}, got {sorted(saved.files)}")
if any(np.asarray(saved[name]).ndim < 1 or np.asarray(saved[name]).shape[0] != count
for name in saved.files):
raise ValueError("special field arrays must be non-scalar with exact field count")
if case.scene == "vortex":
field_indices = np.asarray(saved["field_indices"], np.int64)
relative_offsets = np.asarray(saved["relative_offsets"], np.int64)
if case.scene == "vortex":
try:
with (role_dir/"event_summary.json").open(encoding="utf-8") as stream:
summary = json.load(stream)
channel, channel_name = _vortex_event_channel(case.name)
expected_source = {"scenario": _canonical_vortex_source(case.name), "role": "controlled",
"channel_index": channel, "channel": channel_name}
peak = int(summary["common_peak_index"])
if (summary["schema"] != "drl-pinball-legacy-vortex-event-v2" or
summary["event_source"] != expected_source or
not np.array_equal(field_indices, peak + relative_offsets) or
not np.array_equal(field_indices, np.asarray(summary["field_indices"], np.int64)) or
not np.array_equal(relative_offsets, [-10, -5, 0, 5, 10])):
raise ValueError
except (OSError, KeyError, TypeError, ValueError, json.JSONDecodeError) as exc:
raise ValueError("Vortex event summary/source contract failed") from exc
def _acquire_special(role,case_name,output_root,overwrite,device_id,repo_root,storage_validator,runtime_factory,vortex_y_offset_l0):
case=get_case(case_name); repo=Path(repo_root) if repo_root else Path(__file__).resolve().parents[3]; offset=_vortex_offset(case_name,vortex_y_offset_l0) if case.scene=="vortex" else 0
storage=storage_validator(repo_mapping=default_reproduction_mapping(repo),output_root=output_root,min_free_bytes=MIN_FREE_BYTES); bundle=_resolve_bundle(repo,case_name,role)
scenario=_vortex_scenario(case_name,offset) if case.scene=="vortex" else case_name
source_run = case.scene == "vortex" and role == "controlled" and offset == 0
event_source = None if not case.scene == "vortex" or source_run else _load_vortex_event_source(storage, case_name)
prepared=prepare_role_output(storage,"legacy",scenario,role,overwrite=overwrite); scratch=create_scratch(prepared["scratch_root"]); ff=None
try:
if runtime_factory is not None: data,model,ff=runtime_factory(case,bundle,device_id,role)
else:
from .core.legacy_env_builder import build_erase,build_vortex
data=build_vortex(device_id,case.re_code,vortex_type=case.vortex_type,action_scale=case.action_scale,y_offset_l0=offset,target_only=role=="target",role=role) if case.scene=="vortex" else build_erase(device_id,case.re_code,target_only=role=="target")
model=None
if role=="controlled":
from .core.model_loader import load_model
model=load_model(case.model)
ff=data["flow_field"]
norm=None if role=="target" else (data["norm"] if case.scene=="erase" or (case.scene=="vortex" and role=="zero") else bundle["norm"]); rows,fields=_collect_special(role,data,model,ff,norm,scratch,case); _finalize_special(prepared["role_dir"],scratch,rows,fields,data,bundle,storage,role,event_source); _validate_special(prepared["role_dir"],case,role); return publish_role_output(prepared)
except Exception:
if prepared["staging_dir"].exists(): shutil.rmtree(prepared["staging_dir"])
raise
finally:
if ff is not None and hasattr(ff,"close"): ff.close()
def acquire_role(role, *, case_name=CASE_NAME, output_root=None, overwrite=False, device_id=0, repo_root=None, storage_validator=validate_output_storage, runtime_factory=None, finalizer=_finalize, vortex_y_offset_l0=0):
case=get_case(case_name)
if role not in _case_roles(case): raise ValueError(f"unknown acquisition role {role!r} for {case_name}")
if case.scene in ("vortex","erase"):
return _acquire_special(role,case_name,output_root,overwrite,device_id,repo_root,storage_validator,runtime_factory,vortex_y_offset_l0)
return _acquire_standard(role,case_name=case_name,output_root=output_root,overwrite=overwrite,device_id=device_id,repo_root=repo_root,storage_validator=storage_validator,runtime_factory=runtime_factory,finalizer=finalizer)
def acquire_controlled(**kwargs):
return acquire_role("controlled", **kwargs)
def main() -> int:
parser = argparse.ArgumentParser(description="Acquire Legacy full-matrix evidence")
parser.add_argument("case", choices=SUPPORTED_CASES)
parser.add_argument("--role", choices=ROLES + ("constant",), required=True)
parser.add_argument("--device-id", type=int, default=0)
parser.add_argument("--vortex-y-offset-l0", type=int, default=0)
parser.add_argument("--output-root", type=Path); parser.add_argument("--overwrite", action="store_true")
args = parser.parse_args()
path = acquire_role(args.role, case_name=args.case, output_root=args.output_root, overwrite=args.overwrite, device_id=args.device_id, vortex_y_offset_l0=args.vortex_y_offset_l0)
print(path); return 0
if __name__ == "__main__":
raise SystemExit(main())
+36
View File
@@ -0,0 +1,36 @@
"""Canonical case matrix for LegacyCelerisLab reproduction."""
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class Case:
name: str
scene: str
model: str | None
re_code: float = 100.0
sample_interval: int = 800
steps: int = 200
action_scale: float = 8.0
action_bias: tuple[float, float, float] = (0.0, -4.0, 4.0)
conv_len: int = 30
target_radius_l: float | None = None
vortex_type: str | None = None
reference_case: str | None = None
CASES = {
**{f"karman_re{re}": Case(f"karman_re{re}", "karman", f"d1a3o12_re{re}", re_code=float(re)) for re in (50, 100, 200, 400)},
"illusion_075L": Case("illusion_075L", "illusion", "d1a3o14_250525_imit_075L_2U_400S", sample_interval=400, action_bias=(0.0, -2.0, 2.0), conv_len=36, target_radius_l=0.75, reference_case="illusion_0.75L"),
"illusion_1L": Case("illusion_1L", "illusion", "d1a3o14_250525_imit_1L_2U_600S", sample_interval=600, action_bias=(0.0, -2.0, 2.0), conv_len=36, target_radius_l=1.0),
"illusion_15L": Case("illusion_15L", "illusion", "d1a3o14_250525_imit_15L_2U", action_bias=(0.0, -2.0, 2.0), conv_len=36, target_radius_l=1.5, reference_case="illusion_1.5L"),
"vortex_lamb": Case("vortex_lamb", "vortex", "vortex_lamb", steps=150, action_scale=4.0, vortex_type="lamb"),
"vortex_taylor": Case("vortex_taylor", "vortex", "vortex_taylor", steps=150, action_scale=4.0, vortex_type="taylor"),
"erase": Case("erase", "erase", "d1a3o12_250729_250326_erase", sample_interval=600, action_scale=8.0, action_bias=(0.0, -8.0, 8.0), conv_len=36),
"steady": Case("steady", "steady", None, action_bias=(0.0, -5.1, 5.1)),
}
ERASE_UNSUPPORTED = "" # compatibility name; Erase is acquisition-only and supported.
def get_case(name: str) -> Case:
try:
return CASES[name]
except KeyError as exc:
raise KeyError(f"unknown case {name!r}; choose from {sorted(CASES)} or erase") from exc
@@ -35,6 +35,7 @@ if _REPO not in sys.path:
from LegacyCelerisLab import FlowField # noqa: E402
from LegacyCelerisLab import utils as legacy_utils # noqa: E402
from ..runtime import run_historical_interval # noqa: E402
# ---------------------------------------------------------------------------
# Constants
@@ -58,9 +59,20 @@ def _center_y(ff: FlowField) -> float:
return (ff.FIELD_SHAPE[1] - 1) / 2.0
def _field_xy(ff: FlowField) -> Tuple[int, int]:
"""Return Legacy's 2-D lattice dimensions from its 3-D shape contract."""
shape = tuple(ff.FIELD_SHAPE)
if len(shape) != 3:
raise ValueError(f"Legacy FlowField.FIELD_SHAPE must be (nx, ny, nz), got {shape}")
nx, ny, nz = shape
if nz != 1:
raise ValueError(f"Legacy builder requires nz=1, got FIELD_SHAPE={shape}")
return int(nx), int(ny)
def _stabilize(ff: FlowField, n_obj: int) -> None:
steps = int(4 * ff.FIELD_SHAPE[0] / U0)
ff.run(steps, np.zeros(n_obj, dtype=DATA_TYPE))
run_historical_interval(ff, steps, np.zeros(n_obj, dtype=DATA_TYPE))
def _compute_karman_norm(fifo: np.ndarray) -> Dict[str, Any]:
@@ -82,6 +94,37 @@ def _compute_karman_norm(fifo: np.ndarray) -> Dict[str, Any]:
# Karman Cloak (dist-cyl + 3 sensors + 3 pinball = 7 objects)
# ---------------------------------------------------------------------------
def build_karman_target(
device_id: int = 0,
re_code: float = 100.0,
*,
sample_interval: int = 800,
) -> Dict[str, Any]:
"""Build only the canonical disturbance-cylinder Karman target stage."""
viscosity = _nu_from_re(re_code)
cuda_cfg = legacy_utils.load_cuda_config(os.path.join(CONFIG_DIR, "config_cuda.json"))
field_cfg = legacy_utils.load_flow_field_config(os.path.join(CONFIG_DIR, "config_flowfield.json"))
field_cfg = field_cfg._replace(viscosity=float(viscosity))
ff = FlowField(field_cfg, cuda_cfg, device_id=device_id)
cy = _center_y(ff)
nx, ny = _field_xy(ff)
ff.add_cylinder((10.0 * L0, cy, 0.0), 1.0 * L0)
for y_off in (2.0, 0.0, -2.0):
ff.add_sensor((40.0 * L0, cy + y_off * L0, 0.0), SENSOR_RADIUS)
if ff.obs.size // 2 != 4:
raise ValueError("canonical Karman target requires disturbance plus three sensors")
_stabilize(ff, 4)
target = np.empty((FIFO_LEN, 6), dtype=DATA_TYPE)
for index in range(FIFO_LEN):
run_historical_interval(ff, sample_interval, np.zeros(4, dtype=DATA_TYPE))
target[index] = ff.obs.copy()[2:8]
config = {"device_id": device_id, "viscosity": viscosity, "re_code": re_code,
"u0": U0, "sample_interval": sample_interval, "fifo_len": FIFO_LEN,
"conv_len": CONV_LEN, "nx": nx, "ny": ny, "n_obj_total": 4,
"obs_slice": (2, 8), "s_dim": 6}
return {"flow_field": ff, "target_states": target, "config": config}
def build_karman_cloak(
device_id: int = 0,
re_code: float = 100.0,
@@ -119,7 +162,7 @@ def build_karman_cloak(
# Record target (sensor signals only, no pinball)
target = np.empty((0, 6), dtype=DATA_TYPE)
for _ in range(FIFO_LEN):
ff.run(sample_interval, np.zeros(4, dtype=DATA_TYPE))
run_historical_interval(ff, sample_interval, np.zeros(4, dtype=DATA_TYPE))
target = np.vstack((target, ff.obs.copy()[2:8]))
# Phase 2: Add pinball
@@ -139,7 +182,7 @@ def build_karman_cloak(
# Zero-action norm collection
fifo = deque(maxlen=FIFO_LEN)
for _ in range(FIFO_LEN):
ff.run(sample_interval, np.zeros(7, dtype=DATA_TYPE))
run_historical_interval(ff, sample_interval, np.zeros(7, dtype=DATA_TYPE))
fifo.append(ff.obs.copy()[2:14])
norm = _compute_karman_norm(np.array(fifo, dtype=DATA_TYPE))
@@ -152,7 +195,7 @@ def build_karman_cloak(
fifo.clear()
for _ in range(FIFO_LEN):
ff.run(sample_interval, bias_arr)
run_historical_interval(ff, sample_interval, bias_arr)
fifo.append(ff.obs.copy()[2:14])
save_states = np.array(list(fifo), dtype=DATA_TYPE)
ff.apply_ddf()
@@ -181,6 +224,29 @@ def build_karman_cloak(
return {"flow_field": ff, "target_states": target, "norm": norm, "config": config}
def build_steady_target(
device_id: int = 0, re_code: float = 100.0, *, sample_interval: int = 800,
) -> Dict[str, Any]:
"""Build the clean-channel three-sensor steady target."""
viscosity = _nu_from_re(re_code)
cuda_cfg = legacy_utils.load_cuda_config(os.path.join(CONFIG_DIR, "config_cuda.json"))
field_cfg = legacy_utils.load_flow_field_config(os.path.join(CONFIG_DIR, "config_flowfield.json"))._replace(viscosity=float(viscosity))
ff = FlowField(field_cfg, cuda_cfg, device_id=device_id)
cy = _center_y(ff); nx, ny = _field_xy(ff)
for y_off in (2.0, 0.0, -2.0):
ff.add_sensor((40.0 * L0, cy + y_off * L0, 0.0), SENSOR_RADIUS)
_stabilize(ff, 3)
target = np.empty((FIFO_LEN, 6), dtype=DATA_TYPE)
for index in range(FIFO_LEN):
run_historical_interval(ff, sample_interval, np.zeros(3, dtype=DATA_TYPE))
target[index] = ff.obs.copy()[:6]
config = {"device_id": device_id, "viscosity": viscosity, "re_code": re_code,
"u0": U0, "sample_interval": sample_interval, "fifo_len": FIFO_LEN,
"conv_len": CONV_LEN, "nx": nx, "ny": ny, "n_obj_total": 3,
"obs_slice": (0, 6), "s_dim": 6}
return {"flow_field": ff, "target_states": target, "config": config}
# ---------------------------------------------------------------------------
# Steady Cloak (3 sensors + 3 pinball = 6 objects, no dist-cyl)
# ---------------------------------------------------------------------------
@@ -230,7 +296,7 @@ def build_steady_cloak(
target = np.empty((0, 6), dtype=DATA_TYPE)
for _ in range(FIFO_LEN):
ff2.run(800, np.zeros(3, dtype=DATA_TYPE))
run_historical_interval(ff2, 800, np.zeros(3, dtype=DATA_TYPE))
target = np.vstack((target, ff2.obs.copy()[0:6]))
del ff2
@@ -241,7 +307,7 @@ def build_steady_cloak(
# Norm
fifo = deque(maxlen=FIFO_LEN)
for _ in range(FIFO_LEN):
ff.run(800, np.zeros(6, dtype=DATA_TYPE))
run_historical_interval(ff, 800, np.zeros(6, dtype=DATA_TYPE))
fifo.append(ff.obs.copy()[0:12])
temp = np.array(fifo, dtype=DATA_TYPE)
force_norm_fact = 6.0 * float(np.max(np.abs(temp[:, 6:12])))
@@ -259,7 +325,7 @@ def build_steady_cloak(
fifo.clear()
for _ in range(FIFO_LEN):
ff.run(800, bias_arr)
run_historical_interval(ff, 800, bias_arr)
fifo.append(ff.obs.copy()[0:12])
save_states = np.array(list(fifo), dtype=DATA_TYPE)
ff.apply_ddf()
@@ -293,6 +359,33 @@ def build_steady_cloak(
return {"flow_field": ff, "target_states": target, "norm": norm, "config": config}
def build_illusion_target(
device_id: int = 0, re_code: float = 100.0, *, target_diameter_L: float = 1.0,
sample_interval: int = 600,
) -> Dict[str, Any]:
"""Build the target-cylinder stage and retain its public flow field."""
viscosity = _nu_from_re(re_code)
cuda_cfg = legacy_utils.load_cuda_config(os.path.join(CONFIG_DIR, "config_cuda.json"))
field_cfg = legacy_utils.load_flow_field_config(os.path.join(CONFIG_DIR, "config_flowfield.json"))._replace(viscosity=float(viscosity))
ff = FlowField(field_cfg, cuda_cfg, device_id=device_id)
cy = _center_y(ff); nx, ny = _field_xy(ff)
ff.add_cylinder((20.0 * L0, cy, 0.0), target_diameter_L * L0)
for y_off in (2.0, 0.0, -2.0):
ff.add_sensor((30.0 * L0, cy + y_off * L0, 0.0), SENSOR_RADIUS)
_stabilize(ff, 4)
target = np.empty((FIFO_LEN, 8), dtype=DATA_TYPE)
for index in range(FIFO_LEN):
run_historical_interval(ff, sample_interval, np.zeros(4, dtype=DATA_TYPE))
target[index] = ff.obs.copy()[:8]
from .dtw_metrics import analyze_harmonics
harmonics = analyze_harmonics(target, n_harmonics=5)
config = {"device_id": device_id, "viscosity": viscosity, "re_code": re_code,
"u0": U0, "sample_interval": sample_interval, "fifo_len": FIFO_LEN,
"conv_len": 36, "nx": nx, "ny": ny, "n_obj_total": 4,
"obs_slice": (0, 8), "s_dim": 8, "target_diameter_L": target_diameter_L}
return {"flow_field": ff, "target_states": target, "target_harmonics": harmonics, "config": config}
# ---------------------------------------------------------------------------
# Illusion (target cylinder + 3 sensors at illusion positions, then pinball + sensors)
# ---------------------------------------------------------------------------
@@ -333,7 +426,7 @@ def build_illusion(
# Record target (8 channels: cyl_force[2] + sensors[6])
target_states = np.empty((0, 8), dtype=DATA_TYPE)
for _ in range(FIFO_LEN):
ff_target.run(sample_interval, np.zeros(4, dtype=DATA_TYPE))
run_historical_interval(ff_target, sample_interval, np.zeros(4, dtype=DATA_TYPE))
target_states = np.vstack((target_states, ff_target.obs.copy()[0:8]))
# Harmonics analysis (FFT)
@@ -364,7 +457,7 @@ def build_illusion(
# Norm
fifo = deque(maxlen=FIFO_LEN)
for _ in range(FIFO_LEN):
ff.run(sample_interval, np.zeros(6, dtype=DATA_TYPE))
run_historical_interval(ff, sample_interval, np.zeros(6, dtype=DATA_TYPE))
fifo.append(ff.obs.copy()[0:12])
norm = _compute_karman_norm(np.array(fifo, dtype=DATA_TYPE))
@@ -378,10 +471,12 @@ def build_illusion(
fifo.clear()
for _ in range(FIFO_LEN):
ff.run(sample_interval, bias_arr)
run_historical_interval(ff, sample_interval, bias_arr)
fifo.append(ff.obs.copy()[0:12])
save_states = np.array(list(fifo), dtype=DATA_TYPE)
ff.apply_ddf()
# Original Illusion CustomEnv overwrites the checkpoint after bias FIFO.
ff.get_ddf()
ff.save_ddf()
norm["save_states"] = save_states
norm["action_bias"] = list(action_bias)
@@ -419,128 +514,73 @@ def build_illusion(
# ---------------------------------------------------------------------------
def build_vortex(
device_id: int = 0,
re_code: float = 100.0,
*,
vortex_type: str = "lamb",
action_scale: float = 4.0,
action_bias: Tuple[float, float, float] = (0.0, -4.0, 4.0),
device_id: int = 0, re_code: float = 100.0, *, vortex_type: str = "lamb",
action_scale: float = 4.0, action_bias: Tuple[float, float, float] = (0.0, -4.0, 4.0),
y_offset_l0: int = 0, target_only: bool = False, role: Optional[str] = None,
) -> Dict[str, Any]:
"""Build vortex cloaking environment.
Target phase: vortex at x=10*L0 + 3 sensors.
Pinball phase: vortex at x=15*L0 + pinball + 3 sensors.
Object order (pinball phase): sensor0(0), sensor1(1), sensor2(2),
front(3), top(4), bottom(5).
MAX_STEPS = 150 (transient event).
"""
"""Build Vortex with an explicit role-bound runtime handoff."""
if role is None:
role = "target" if target_only else "controlled"
if role not in ("controlled", "target", "zero"):
raise ValueError(f"invalid Vortex builder role {role!r}")
if target_only != (role == "target"):
raise ValueError("Vortex target_only and role semantics disagree")
allowed = {0} if vortex_type == "lamb" else {-2, -1, 0, 1, 2} if vortex_type == "taylor" else set()
if type(y_offset_l0) is not int or y_offset_l0 not in allowed:
raise ValueError(f"invalid {vortex_type} vortex y offset {y_offset_l0}; allowed {sorted(allowed)}")
viscosity = _nu_from_re(re_code)
cuda_cfg = legacy_utils.load_cuda_config(os.path.join(CONFIG_DIR, "config_cuda.json"))
field_cfg = legacy_utils.load_flow_field_config(os.path.join(CONFIG_DIR, "config_flowfield.json"))
field_cfg = field_cfg._replace(viscosity=float(viscosity))
vortex_strength = 0.5 * U0 if vortex_type == "lamb" else 0.03 * U0
# Phase 1: Sensors-only env -> record clean channel -> add vortex -> record target
ff_sensors = FlowField(field_cfg, cuda_cfg, device_id=device_id)
cy_s = _center_y(ff_sensors)
for y_off in [2.0, 0.0, -2.0]:
ff_sensors.add_sensor((40.0 * L0, cy_s + y_off * L0, 0.0), SENSOR_RADIUS)
n_sens = ff_sensors.obs.size // 2
assert n_sens == 3
_stabilize(ff_sensors, 3)
# Record clean channel baseline
ff_sensors.get_ddf()
ff_sensors.save_ddf()
# Add vortex at x=10*L0 and record target
ff_sensors.add_vortex(
(10.0 * L0, cy_s, 0.0),
2.0 * L0,
vortex_strength,
0.0,
vortex_type,
)
target_states = np.empty((0, 6), dtype=DATA_TYPE)
for _ in range(FIFO_LEN):
ff_sensors.run(800, np.zeros(3, dtype=DATA_TYPE))
target_states = np.vstack((target_states, ff_sensors.obs.copy()[0:6]))
del ff_sensors
# Phase 2: Pinball + sensors + vortex
field_cfg = legacy_utils.load_flow_field_config(os.path.join(CONFIG_DIR, "config_flowfield.json"))._replace(viscosity=float(viscosity))
ff = FlowField(field_cfg, cuda_cfg, device_id=device_id)
cy = _center_y(ff)
NX, NY = ff.FIELD_SHAPE[0], ff.FIELD_SHAPE[1]
for y_off in [2.0, 0.0, -2.0]:
ff.add_sensor((40.0 * L0, cy + y_off * L0, 0.0), SENSOR_RADIUS) # id=0,1,2
ff.add_cylinder((30.0 * L0, cy, 0.0), PINBALL_RADIUS) # front, id=3
ff.add_cylinder((31.3 * L0, cy + 0.75 * L0, 0.0), PINBALL_RADIUS) # top, id=4
ff.add_cylinder((31.3 * L0, cy - 0.75 * L0, 0.0), PINBALL_RADIUS) # bottom, id=5
n_total = ff.obs.size // 2
assert n_total == 6, f"Expected 6 objects, got {n_total}"
_stabilize(ff, 6)
ff.get_ddf()
cy = _center_y(ff); nx, ny = _field_xy(ff)
for y_off in (2.0, 0.0, -2.0):
ff.add_sensor((40.0 * L0, cy + y_off * L0, 0.0), SENSOR_RADIUS)
run_historical_interval(ff, int(nx / U0), np.zeros(3, dtype=DATA_TYPE))
ff.get_ddf(); ff.save_ddf()
strength = (0.5 if vortex_type == "lamb" else 0.03) * U0
target_vortex = (10.0 * L0, cy + y_offset_l0 * L0, 0.0)
ff.add_vortex(target_vortex, 2.0 * L0, strength, 0.0, vortex_type)
target = np.empty((0, 6), dtype=DATA_TYPE)
for _ in range(FIFO_LEN):
run_historical_interval(ff, 800, np.zeros(3, dtype=DATA_TYPE)); target = np.vstack((target, ff.obs.copy()))
target_config = {"device_id": device_id, "viscosity": viscosity, "re_code": re_code, "u0": U0, "sample_interval": 800, "fifo_len": FIFO_LEN, "conv_len": CONV_LEN, "nx": nx, "ny": ny, "n_obj_total": 3, "obs_slice": (0, 6), "s_dim": 6, "max_steps": 150, "vortex_type": vortex_type, "vortex_y_offset_l0": y_offset_l0, "vortex_role": role, "runtime_handoff": "fresh_second_injection_from_saved_pre_injection_sensors_state"}
if target_only:
ff.restore_ddf(); ff.apply_ddf()
ff.add_vortex(target_vortex, 2.0 * L0, strength, 0.0, vortex_type)
return {"flow_field": ff, "target_states": target, "config": target_config}
ff.restore_ddf(); ff.apply_ddf()
ff.add_cylinder((30.0 * L0, cy, 0.0), PINBALL_RADIUS)
ff.add_cylinder((31.3 * L0, cy + 0.75 * L0, 0.0), PINBALL_RADIUS)
ff.add_cylinder((31.3 * L0, cy - 0.75 * L0, 0.0), PINBALL_RADIUS)
run_historical_interval(ff, int(nx / U0), np.zeros(6, dtype=DATA_TYPE))
bias = np.zeros(6, dtype=DATA_TYPE); bias[3:] = np.asarray(action_bias, dtype=DATA_TYPE) * U0
if role == "controlled":
run_historical_interval(ff, int(nx / U0), bias)
acquisition_vortex = (15.0 * L0, cy + y_offset_l0 * L0, 0.0)
pretrigger_development_steps = int(2 * nx / U0)
if role == "zero":
ff.add_vortex(acquisition_vortex, 2.0 * L0, strength, 0.0, vortex_type)
run_historical_interval(
ff, pretrigger_development_steps, np.zeros(6, dtype=DATA_TYPE)
)
ff.add_vortex(acquisition_vortex, 2.0 * L0, strength, 0.0, vortex_type)
if role == "zero":
# Zero needs a true zero-base + fresh-vortex checkpoint. Controlled
# deliberately retains the historical stale-host save semantics.
ff.get_ddf()
ff.save_ddf()
# Norm
fifo = deque(maxlen=FIFO_LEN)
for _ in range(FIFO_LEN):
ff.run(800, np.zeros(6, dtype=DATA_TYPE))
fifo.append(ff.obs.copy()[0:12])
norm = _compute_karman_norm(np.array(fifo, dtype=DATA_TYPE))
# Bias FIFO
ff.apply_ddf()
bias_arr = np.zeros(6, dtype=DATA_TYPE)
bias_arr[3] = float(action_bias[0] * U0)
bias_arr[4] = float(action_bias[1] * U0)
bias_arr[5] = float(action_bias[2] * U0)
fifo.clear()
run_historical_interval(ff, 800, np.zeros(6, dtype=DATA_TYPE)); fifo.append(ff.obs.copy())
norm = _compute_karman_norm(np.asarray(fifo, dtype=DATA_TYPE))
ff.apply_ddf(); fifo.clear()
runtime_command = bias if role == "controlled" else np.zeros(6, dtype=DATA_TYPE)
for _ in range(FIFO_LEN):
ff.run(800, bias_arr)
fifo.append(ff.obs.copy()[0:12])
save_states = np.array(list(fifo), dtype=DATA_TYPE)
ff.apply_ddf()
norm["save_states"] = save_states
norm["action_bias"] = list(action_bias)
norm["n_obj_total"] = 6
norm["vortex_type"] = vortex_type
norm["vortex_strength"] = vortex_strength
config = {
"device_id": device_id,
"viscosity": viscosity,
"re_code": re_code,
"u0": U0,
"sample_interval": 800,
"fifo_len": FIFO_LEN,
"conv_len": CONV_LEN,
"nx": NX,
"ny": NY,
"n_obj_total": 6,
"action_scale": action_scale,
"action_bias": list(action_bias),
"obs_slice": (0, 12),
"s_dim": 12,
"max_steps": 150,
"vortex_type": vortex_type,
}
return {"flow_field": ff, "target_states": target_states, "norm": norm, "config": config}
run_historical_interval(ff, 800, runtime_command); fifo.append(ff.obs.copy())
saved = np.asarray(fifo, dtype=DATA_TYPE); ff.apply_ddf()
norm.update(save_states=saved, action_bias=list(action_bias), n_obj_total=6, vortex_type=vortex_type, vortex_strength=strength)
config = {"device_id": device_id, "viscosity": viscosity, "re_code": re_code, "u0": U0, "sample_interval": 800, "fifo_len": FIFO_LEN, "conv_len": CONV_LEN, "nx": nx, "ny": ny, "n_obj_total": 6, "action_scale": action_scale, "action_bias": list(action_bias), "obs_slice": (0, 12), "s_dim": 12, "max_steps": 150, "vortex_type": vortex_type, "vortex_y_offset_l0": y_offset_l0, "vortex_role": role, "pre_injection_stabilization": "zero_then_bias" if role == "controlled" else "exact_physical_zero_with_vortex_pretrigger_2NX_over_U0", "pretrigger_vortex": role == "zero", "pretrigger_development_steps": pretrigger_development_steps if role == "zero" else 0, "runtime_handoff": "saved_fresh_injection_checkpoint"}
return {"flow_field": ff, "target_states": target, "norm": norm, "config": config}
# ---------------------------------------------------------------------------
# Erase (sensors(0-2) + dist-cyl(r=0.75L, id=3) + pinball(4-6) = 7 objects)
@@ -551,6 +591,7 @@ def build_erase(
re_code: float = 100.0,
*,
action_bias: Tuple[float, float, float] = (0.0, -8.0, 8.0),
target_only: bool = False,
) -> Dict[str, Any]:
"""Build erase environment (cancel upstream disturbance to clean flow).
@@ -572,14 +613,17 @@ def build_erase(
n_clean = ff_clean.obs.size // 2
assert n_clean == 3
_stabilize(ff_clean, 3)
run_historical_interval(ff_clean, int(2 * ff_clean.FIELD_SHAPE[0] / U0), np.zeros(3, dtype=DATA_TYPE))
target = np.empty((0, 6), dtype=DATA_TYPE)
for _ in range(FIFO_LEN):
ff_clean.run(600, np.zeros(3, dtype=DATA_TYPE))
run_historical_interval(ff_clean, 600, np.zeros(3, dtype=DATA_TYPE))
target = np.vstack((target, ff_clean.obs.copy()[0:6]))
# Target = mean (steady, not periodic)
target_mean = np.mean(target, axis=0, dtype=DATA_TYPE)
if target_only:
nx, ny = _field_xy(ff_clean)
return {"flow_field": ff_clean, "target_states": target, "config": {"device_id": device_id, "viscosity": viscosity, "re_code": re_code, "u0": U0, "sample_interval": 600, "fifo_len": FIFO_LEN, "conv_len": 36, "nx": nx, "ny": ny, "n_obj_total": 3, "obs_slice": (0, 6), "s_dim": 6}}
del ff_clean
# Phase 2: Full erase env
@@ -605,7 +649,7 @@ def build_erase(
# Norm (erase-specific: full obs[0:14], force_norm uses pinball only)
fifo = deque(maxlen=FIFO_LEN)
for _ in range(FIFO_LEN):
ff.run(600, np.zeros(7, dtype=DATA_TYPE))
run_historical_interval(ff, 600, np.zeros(7, dtype=DATA_TYPE))
fifo.append(ff.obs.copy()[0:14])
temp = np.array(fifo, dtype=DATA_TYPE)
@@ -624,7 +668,7 @@ def build_erase(
fifo.clear()
for _ in range(FIFO_LEN):
ff.run(600, bias_arr)
run_historical_interval(ff, 600, bias_arr)
fifo.append(ff.obs.copy()[0:14])
save_states = np.array(list(fifo), dtype=DATA_TYPE)
ff.apply_ddf()
@@ -1,40 +1,82 @@
# legacy_test/core/model_loader.py
"""PPO model loader for legacy test scripts.
"""Canonical, non-archive PPO loader for legacy evaluation scripts.
Wraps the reproduce ModelInventory to provide a simpler interface for
Track A test scripts that only need to load models onto CPU.
Only the models explicitly used by ``legacy_test/test_*.py`` are accepted.
Paths resolve directly under repository-root ``models/``; no discovery scan and
no archived Python inventory participates in evaluation.
"""
from __future__ import annotations
import os
import sys
from dataclasses import dataclass
from typing import Optional
import numpy as np
_REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..", ".."))
_SRC = os.path.join(_REPO, "src")
for p in [_REPO, _SRC]:
if p not in sys.path:
sys.path.insert(0, p)
from drl_pinball.reproduce.configs.model_inventory import ModelInventory # noqa: E402
_inventory = ModelInventory()
_MODEL_ROOT = os.path.join(_REPO, "models")
def load_model(name: str) -> "PPO":
"""Load a PPO model onto CPU for inference.
Returns a PPO model loaded from ``models/{subdir}/{name}.zip``
with Sin activation and correct observation/action spaces.
Delegates to ModelInventory.load(name, device="cpu").
"""
return _inventory.load(name, device="cpu")
@dataclass(frozen=True)
class ModelSpec:
subdir: str
s_dim: int
scene: str
def list_models(scene: Optional[str] = None) -> list:
"""List available model names, optionally filtered by scene."""
return _inventory.list_models(scene)
# S_DIM and names are the canonical legacy-test README/script contracts.
MODEL_SPECS = {
"d1a3o12_re50": ModelSpec("old", 12, "karman"),
"d1a3o12_re100": ModelSpec("old", 12, "karman"),
"d1a3o12_re200": ModelSpec("old", 12, "karman"),
"d1a3o12_re400": ModelSpec("old", 12, "karman"),
"vortex_lamb": ModelSpec("old", 12, "vortex"),
"vortex_taylor": ModelSpec("old", 12, "vortex"),
"d1a3o14_250525_imit_075L_2U_400S": ModelSpec("250525", 14, "illusion"),
"d1a3o14_250525_imit_1L_2U_600S": ModelSpec("250525", 14, "illusion"),
"d1a3o14_250525_imit_15L_2U": ModelSpec("250525", 14, "illusion"),
"d1a3o12_250729_250326_erase": ModelSpec("250729", 12, "erase"),
}
def model_path(name: str) -> str:
"""Resolve one allowlisted model to an explicit root-model zip path."""
try:
spec = MODEL_SPECS[name]
except KeyError as exc:
raise KeyError(f"unknown canonical legacy model {name!r}") from exc
path = os.path.join(_MODEL_ROOT, spec.subdir, f"{name}.zip")
if not os.path.isfile(path):
raise FileNotFoundError(f"canonical legacy model not found: {path}")
return path
def _dummy_env(s_dim: int):
import gymnasium as gym
import numpy as np
from gymnasium import spaces
class LegacyInferenceEnv(gym.Env):
observation_space = spaces.Box(-1.0, 1.0, shape=(s_dim,), dtype=np.float32)
action_space = spaces.Box(-1.0, 1.0, shape=(3,), dtype=np.float32)
def reset(self, *, seed=None, options=None):
super().reset(seed=seed)
return np.zeros(s_dim, np.float32), {}
def step(self, action):
return np.zeros(s_dim, np.float32), 0.0, False, False, {}
return LegacyInferenceEnv()
def load_model(name: str):
"""Load one canonical legacy PPO zip on CPU with its correct S_DIM."""
from stable_baselines3 import PPO
spec = MODEL_SPECS.get(name)
if spec is None:
raise KeyError(f"unknown canonical legacy model {name!r}")
return PPO.load(model_path(name), env=_dummy_env(spec.s_dim), device="cpu")
def list_models(scene: Optional[str] = None) -> list[str]:
"""List allowlisted model names, optionally filtered by canonical scene."""
return sorted(name for name, spec in MODEL_SPECS.items()
if scene is None or spec.scene == scene)
+144
View File
@@ -0,0 +1,144 @@
"""Legacy reward DTW and normalized offline target-scale metrics."""
from __future__ import annotations
import hashlib
import os
import numpy as np
from .core.dtw_metrics import calc_dtw_sim, calc_lag, compute_similarity_vortex, gen_target_states_at
def target_scale_cycle_similarity(target, state, conv_len, *, target_start=0):
target = np.asarray(target, dtype=np.float64)[:, target_start:target_start + 6]
state = np.asarray(state, dtype=np.float64)[:, :6]
scale = np.maximum(np.max(np.abs(target), axis=0), 1e-12)
result = cycle_similarity(target / scale, state / scale, conv_len)
result["scale"] = scale.tolist()
result["definition"] = "offline target-channel max-abs normalized legacy cycle DTW"
return result
def cycle_similarity(target, state, conv_len):
target = np.asarray(target, dtype=np.float64); state = np.asarray(state, dtype=np.float64)
lag = calc_lag(target[conv_len:2*conv_len, 1], state[-conv_len:, 1])
scores = [calc_dtw_sim(np.roll(target[:, i], -lag)[conv_len:2*conv_len], state[-conv_len:, i]) for i in range(6)]
return {"similarity": float(np.mean(scores)), "per_channel": {str(i): float(v) for i,v in enumerate(scores)}, "lag": int(lag)}
def native_similarity(case, target, fifo, step):
if case.scene == "vortex":
return compute_similarity_vortex(target, fifo[:, :6], step, case.conv_len)
start = 2 if case.scene == "illusion" else 0
return cycle_similarity(np.asarray(target)[:, start:start + 6], np.asarray(fifo)[:, :6], case.conv_len)["similarity"]
def reward_terms(case, target, harmonics, fifo, force_norm, step):
fifo = np.asarray(fifo, dtype=np.float64)
forces = fifo[-1, 6:12] / force_norm
sim = native_similarity(case, target, fifo, step)
if case.scene == "illusion":
desired = gen_target_states_at(step, harmonics)[:2] / force_norm
cd, cl = forces[0::2].sum(), forces[1::2].sum()
r_cd, r_cl, r_sim = np.exp(-abs((cd-desired[0])*10)), np.exp(-abs((cl-desired[1])*10)), np.exp(-10*abs(sim-1))
reward = .3*r_cd + .3*r_cl + .4*r_sim
else:
cd, cl = forces[0::2].mean(), forces[1::2].mean()
r_cd, r_cl, r_sim = np.exp(-abs(cd*20)), np.exp(-abs(cl*80)), np.exp(-10*abs(sim-1))
weights = (.2, .3, .5) if case.scene == "vortex" else (.3, .4, .3)
reward = weights[0]*r_cd + weights[1]*r_cl + weights[2]*r_sim
return {"reward": float(min(reward, 1.0)), "reward_cd": float(r_cd), "reward_cl": float(r_cl), "native_legacy_dtw": float(sim)}
def sha256_file(path):
digest = hashlib.sha256()
with open(path, "rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def channel_correlations(reference, current):
reference = np.asarray(reference, dtype=np.float64)
current = np.asarray(current, dtype=np.float64)
n = min(len(reference), len(current))
reference, current = reference[:n], current[:n]
result = []
for index in range(reference.shape[1]):
x = reference[:, index] - reference[:, index].mean()
y = current[:, index] - current[:, index].mean()
denominator = np.sqrt(np.sum(x*x) * np.sum(y*y))
result.append(float(np.sum(x*y) / denominator) if denominator >= 1e-12 else 0.0)
return result
def rms_error(reference, current):
reference = np.asarray(reference, dtype=np.float64)
current = np.asarray(current, dtype=np.float64)
n = min(len(reference), len(current))
return float(np.sqrt(np.mean((reference[:n] - current[:n]) ** 2)))
def aggregate_rewards(reward_rows, conv_len):
if not reward_rows:
return None
keys = ("reward", "reward_cd", "reward_cl", "native_legacy_dtw")
values = {key: np.asarray([row[key] for row in reward_rows], dtype=np.float64) for key in keys}
tail = min(int(conv_len), len(reward_rows))
return {
"mean": float(values["reward"].mean()),
"tail": float(values["reward"][-tail:].mean()),
"final": float(values["reward"][-1]),
"tail_reward_cd": float(values["reward_cd"][-tail:].mean()),
"tail_reward_cl": float(values["reward_cl"][-tail:].mean()),
"tail_native_legacy_dtw": float(values["native_legacy_dtw"][-tail:].mean()),
"tail_length": tail,
}
def frozen_reference_comparison(path, sensors, forces, actions, conv_len, threshold=0.95):
if not os.path.isfile(path):
return None
with np.load(path) as frozen:
reference = {name: np.asarray(frozen[name], dtype=np.float64) for name in ("sensors", "forces", "actions")}
sensor_count = min(len(reference["sensors"]), len(sensors))
sensor_dtw = cycle_similarity(
reference["sensors"][:sensor_count], np.asarray(sensors)[:sensor_count], conv_len
)
return {
"reference_path": os.path.abspath(path),
"reference_sha256": sha256_file(path),
"sensor_dtw": sensor_dtw,
"sensor_correlations": channel_correlations(reference["sensors"], sensors),
"force_correlations": channel_correlations(reference["forces"], forces),
"action_correlations": channel_correlations(reference["actions"], actions),
"sensor_rms": rms_error(reference["sensors"], sensors),
"force_rms": rms_error(reference["forces"], forces),
"action_rms": rms_error(reference["actions"], actions),
"threshold": float(threshold),
"passed": bool(sensor_dtw["similarity"] > threshold),
}
def _erase_enhanced_similarity(target, state):
target = np.asarray(target, np.float64); state = np.asarray(state, np.float64)
target_std, state_std = max(float(target.std()), 1e-8), max(float(state.std()), 1e-8)
amplitude = min(target_std, state_std) / max(target_std, state_std)
mean_diff = abs(float(target.mean() - state.mean()))
mean_scale = max(abs(float(target.mean())), abs(float(state.mean())), 1e-8)
n, m = len(target), len(state); matrix = np.full((n + 1, m + 1), np.inf); matrix[0, 0] = 0.0
for i in range(1, n + 1):
for j in range(1, m + 1):
matrix[i, j] = abs(target[i-1]-state[j-1]) + min(matrix[i-1,j], matrix[i,j-1], matrix[i-1,j-1])
scale = max(float(np.max(np.abs(target))), float(np.max(np.abs(state))), 1e-8)
dtw = max(0.0, 1.0 - matrix[n,m] / (n * scale))
return .8*dtw + .1*amplitude + .1/(1.0 + mean_diff/mean_scale*10.0)
def erase_reward_terms(fifo, target_sensors, norm, conv_len=36):
"""Exact rolling same-rollout reward from legacy_env_erase.py."""
states = np.asarray(fifo, np.float64)
if states.ndim != 2 or states.shape[1] != 14 or len(states) < 2*conv_len:
raise ValueError("Erase reward requires a (time,14) FIFO of at least 2*conv_len")
sens_norm=np.asarray(norm["sens_norm_fact"],np.float64); sens_dev=np.asarray(norm["sens_deviation"],np.float64)
target_sensors=np.asarray(target_sensors,np.float64); sensors=(states[-1,:6]-sens_dev)/sens_norm; target=(target_sensors-sens_dev)/sens_norm
reference,current=-states[conv_len:2*conv_len,7],states[-conv_len:,9]
corr=np.correlate(reference-reference.mean(),current-current.mean(),"full"); lag=int(np.arange(-len(reference)+1,len(reference))[np.argmax(corr)])
components=[]
for component in range(2):
wanted=-np.roll(states[:,component+6],-lag)[conv_len:2*conv_len]
actual=states[-conv_len:,component+8]+states[-conv_len:,component+10]+states[-conv_len:,component+12]
components.append(float(_erase_enhanced_similarity(wanted,actual)))
sim=float(np.mean(components)); diff_u=float(np.mean(np.abs(sensors[[0,2,4]]-target[[0,2,4]])))
diff_v=float(np.mean(np.abs(sensors[[1,3,5]]-target[[1,3,5]]))); amp_v=float(np.std(states[:,1]+states[:,3]+states[:,5])/sens_norm[3])
reward_u=float(np.exp(-abs(diff_u*40.0))); reward_v=float(.7*np.exp(-abs(amp_v*20.0))+.3*np.exp(-abs(diff_v*20.0)))
reward=float(min(.4*reward_u+.4*reward_v+.2*sim,1.0))
return {"reward":reward,"reward_u":reward_u,"reward_v":reward_v,"native_legacy_dtw":sim,"native_lag":lag,"native_component_similarity":np.asarray(components)}
+64
View File
@@ -0,0 +1,64 @@
"""Runtime contracts shared by every Legacy reproduction case."""
from __future__ import annotations
from collections import deque
import json
import os
import numpy as np
def run_historical_interval(flow_field, num_steps, action_target):
"""Run one interval with the pre-persistence Legacy EMA seed contract."""
shape = np.asarray(flow_field.action).shape
flow_field.action = np.zeros(shape, dtype=flow_field.DATA_TYPE)
flow_field.run(num_steps, action_target)
def load_policy_norm(path):
"""Load and validate one explicitly mapped frozen training normalization."""
if not os.path.isfile(path):
raise FileNotFoundError(f"Frozen policy normalization not found: {path}")
with open(path, encoding="utf-8") as handle:
document = json.load(handle)
required = {"force_norm_fact", "sens_deviation", "sens_norm_fact"}
missing = required - set(document)
if missing:
raise ValueError(f"Frozen policy normalization missing keys: {sorted(missing)}")
force = float(document["force_norm_fact"])
deviation = np.asarray(document["sens_deviation"], dtype=np.float32)
scale = np.asarray(document["sens_norm_fact"], dtype=np.float32)
if not np.isfinite(force) or force <= 0:
raise ValueError("Frozen force_norm_fact must be finite and positive")
if deviation.shape != (6,) or scale.shape != (6,):
raise ValueError("Frozen sensor normalization arrays must each have shape (6,)")
if not np.all(np.isfinite(deviation)) or not np.all(np.isfinite(scale)) or np.any(scale <= 0):
raise ValueError("Frozen sensor normalization values must be finite and scales positive")
return {"force_norm_fact": force, "sens_deviation": deviation, "sens_norm_fact": scale}
def compute_norm(rows):
rows = np.asarray(rows, dtype=np.float32)
return {"force_norm_fact": float(6*np.max(np.abs(rows[:, 6:12]))),
"sens_deviation": rows[:, :6].mean(axis=0),
"sens_norm_fact": 5*np.max(np.abs(rows[:, :6]-rows[:, :6].mean(axis=0)), axis=0)}
def reset_runtime(flow_field, saved_fifo, s_dim):
flow_field.restore_ddf()
flow_field.apply_ddf()
fifo = deque((np.asarray(x, dtype=np.float32).copy() for x in saved_fifo), maxlen=len(saved_fifo))
return fifo, np.zeros(s_dim, dtype=np.float32)
def policy_observation(raw, norm, *, target_force=None):
raw = np.asarray(raw, dtype=np.float32)
force = raw[6:12] / norm["force_norm_fact"]
sensors = (raw[:6] - norm["sens_deviation"]) / norm["sens_norm_fact"]
result = np.hstack((force, sensors))
if target_force is not None:
result = np.hstack((result, np.asarray(target_force)/norm["force_norm_fact"]))
return np.clip(result, -1, 1).astype(np.float32)
def erase_policy_observation(raw, norm):
"""Original Erase ordering: pinball forces then normalized sensors."""
raw = np.asarray(raw, dtype=np.float32)
if raw.shape != (14,) or not np.all(np.isfinite(raw)):
raise ValueError("Erase raw observation must be 14 finite channels")
result = np.hstack((raw[8:14] / norm["force_norm_fact"],
(raw[:6] - norm["sens_deviation"]) / norm["sens_norm_fact"]))
return np.clip(result, -1, 1).astype(np.float32)
@@ -0,0 +1,202 @@
from pathlib import Path
import numpy as np
import pytest
import src.drl_pinball.legacy_test.acquire as acquire
class FakeModel:
def __init__(self):
self.calls = 0
def predict(self, obs, deterministic):
assert deterministic is True
self.calls += 1
return np.array([0.25, -0.5, 0.75], dtype=np.float32), None
class FakeFlow:
DATA_TYPE = np.float32
def __init__(self):
self.action = np.ones(7, dtype=np.float32)
self.obs = np.zeros(14, dtype=np.float32)
self.intervals = 0
self.capture_intervals = []
def restore_ddf(self):
pass
def apply_ddf(self):
pass
def run(self, steps, command):
assert steps == acquire.SI
assert np.array_equal(self.action, np.zeros(7, dtype=np.float32))
self.intervals += 1
self.action = command.copy()
phase = 2 * np.pi * self.intervals / 30
self.obs[2:8] = [np.sin(phase + index) for index in range(6)]
self.obs[8:14] = np.arange(6) + self.intervals
def current_effective_action(self):
return self.action.copy()
def solver_clock_state(self):
return {"solver_absolute_lattice_clock": 1_000_000 + self.intervals * acquire.SI,
"solver_absolute_control_clock": self.intervals}
def _data():
target = np.column_stack([
np.sin(2 * np.pi * np.arange(150) / 30 + index) for index in range(6)
]).astype(np.float32)
return {
"config": {"nx": 1280, "ny": 2, "sample_interval": 800, "n_obj_total": 7},
"norm": {"save_states": np.zeros((150, 12), dtype=np.float32)},
"target_states": target,
}
def test_controlled_schedule_zero_ema_and_post_interval_field_capture(monkeypatch, tmp_path):
ff, model = FakeFlow(), FakeModel()
monkeypatch.setattr(acquire, "reward_terms", lambda *args: {
"reward": 0.5, "reward_cd": 0.6, "reward_cl": 0.7, "native_legacy_dtw": 0.8,
})
def capture(flow, nx, ny):
assert flow.intervals > acquire.WARMUP_INTERVALS
flow.capture_intervals.append(flow.intervals)
return {"ux": np.full((ny, nx), flow.intervals, np.float32),
"uy": np.full((ny, nx), -flow.intervals, np.float32)}
rows, fields = acquire._collect(_data(), model, ff, {
"force_norm_fact": 1.0, "sens_deviation": np.zeros(6), "sens_norm_fact": np.ones(6),
}, tmp_path, capture_field=capture)
assert (acquire.WARMUP_INTERVALS, acquire.COLLECT_BOUNDARIES) == (480, 160)
assert ff.intervals == model.calls == 640
assert len(rows) == 160
assert fields["ux"].shape == fields["uy"].shape == (160, 2, 1280)
assert ff.capture_intervals == list(range(481, 641))
assert rows[0]["control_index"] == 481 and rows[-1]["control_index"] == 640
assert rows[0]["lattice_step"] == 481 * 800
assert rows[-1]["lattice_step"] == 640 * 800
assert rows[0]["solver_absolute_lattice_step"] == 1_000_000 + 481 * 800
assert rows[0]["solver_absolute_lattice_step"] != rows[0]["lattice_step"]
assert rows[0]["physical_time"] == rows[0]["lattice_step"] * acquire.U0 / acquire.NX
assert np.allclose(rows[0]["commanded_target_omega"], [0.02, -0.08, 0.1])
assert np.array_equal(rows[0]["commanded_target_omega"], rows[0]["effective_smoothed_omega"])
assert np.allclose(fields["ux"][0], 481) and np.allclose(fields["uy"][-1], -640)
assert not list(tmp_path.glob("boundary_*.npz"))
assert set(rows[0]) == {
"physical_time", "lattice_step", "solver_absolute_lattice_step", "control_index",
"sensors", "forces",
"action_normalized", "commanded_target_omega", "effective_smoothed_omega",
"reward_raw", "reward_cd", "reward_cl", "reward_sim", "native_reward_dtw",
}
def test_legacy_metadata_schema_and_timeline_wording_are_explicit():
source = Path(acquire.__file__).read_text(encoding="utf-8")
assert '"schema": "drl-pinball-legacy-acquisition-v2"' in source
assert '"schema": "drl-pinball-v5-acquisition-v1"' not in source
assert '"solver_absolute_lattice_step"' in source
assert 'V5 lattice_step is an absolute solver count' in source
assert 'derived only from relative Legacy lattice_step' in source
def test_capture_uses_public_q_over_u0_and_transposes_to_physical_velocity():
ny, nx = 2, 3
class PublicFlow:
class Config:
velocity = 0.01
field_config = Config()
def current_step_velocity_field(self):
return (np.array([[1, 2], [3, 4], [0, 6]], dtype=np.float32),
np.array([[-1, -2], [-3, -4], [0, -6]], dtype=np.float32))
def completed_flags_xy(self):
return np.array([[1, 1], [0b00010001, 1], [2, 1]], dtype=np.uint8)
fields = acquire._capture_boundary_field(PublicFlow(), nx, ny)
assert fields["ux"].shape == fields["uy"].shape == (ny, nx)
assert np.allclose(fields["ux"], [[0.01, 0.03, 0.0], [0.02, 0.04, 0.06]])
assert np.allclose(fields["uy"], -fields["ux"])
assert fields["ux"].dtype == fields["uy"].dtype == np.float32
def test_capture_rejects_nonfluid_public_velocity():
class BadFlow:
class Config:
velocity = 0.01
field_config = Config()
def current_step_velocity_field(self):
return np.ones((1, 1), np.float32), np.zeros((1, 1), np.float32)
def completed_flags_xy(self):
return np.zeros((1, 1), np.uint8)
with pytest.raises(ValueError, match="exact zero on nonfluid"):
acquire._capture_boundary_field(BadFlow(), 1, 1)
def test_staged_exact_keys(tmp_path):
role = tmp_path / "role"
role.mkdir()
for name in ("timeseries.csv", "phase_cycle.csv"):
(role / name).write_text("x\n", encoding="ascii")
required = {
"physical_time": np.arange(2), "lattice_step": np.arange(2),
"solver_absolute_lattice_step": np.arange(2) + 100,
"control_index": np.arange(2), "sensors": np.zeros((2, 6)),
"forces": np.zeros((2, 6)), "action_normalized": np.zeros((2, 3)),
"commanded_target_omega": np.zeros((2, 3)),
"effective_smoothed_omega": np.zeros((2, 3)), "reward_raw": np.zeros(2),
"reward_cd": np.zeros(2), "reward_cl": np.zeros(2), "reward_sim": np.zeros(2),
"native_reward_dtw": np.zeros(2), "target_normalized_dtw": np.zeros(2),
"target_normalized_dtw_lag": np.zeros(2), "phase": np.zeros(2),
"cycle_id": np.zeros(2),
}
np.savez(role / "timeseries.npz", **required)
np.savez(role / "phase_cycle.npz", x=np.zeros(2))
np.savez(role / "phase_fields.npz", ux=np.zeros((8, 2, 3)), uy=np.zeros((8, 2, 3)),
mean_ux=np.zeros((2, 3)), mean_uy=np.zeros((2, 3)),
field_indices=np.arange(8), cycle_id=np.arange(8), target_phase=np.arange(8),
actual_phase=np.arange(8), phase_error=np.zeros(8))
(role / "dtw_summary.json").write_text("{}", encoding="utf-8")
(role / "metadata.json").write_text("{}", encoding="utf-8")
acquire._validate_staged_role(role)
def test_failure_removes_transaction_and_preserves_existing(monkeypatch, tmp_path):
root = tmp_path / "out"
final = root / "legacy" / acquire.CASE_NAME / acquire.ROLE
final.mkdir(parents=True)
marker = final / "keep.txt"
marker.write_text("old", encoding="utf-8")
model = tmp_path / "model.zip"; model.write_bytes(b"model")
norm = tmp_path / "norm.json"; norm.write_text("{}", encoding="utf-8")
bundle = {"case": acquire.get_case(acquire.CASE_NAME), "model_path": model,
"norm_path": norm, "norm": {}, "reference_path": tmp_path / "missing.npz"}
monkeypatch.setattr(acquire, "_resolve_bundle", lambda repo: bundle)
monkeypatch.setattr(acquire, "_collect_role", lambda *args, **kwargs: ([], []))
def storage_validator(**kwargs):
root.mkdir(exist_ok=True)
return {"resolved_output_root": root, "device": root.stat().st_dev}
def runtime_factory(case, current_bundle, device, role):
assert role == "controlled"
return _data(), FakeModel(), FakeFlow()
def fail(*args):
raise RuntimeError("finalize failed")
with pytest.raises(RuntimeError, match="finalize failed"):
acquire.acquire_controlled(output_root=root, overwrite=True, repo_root=tmp_path,
storage_validator=storage_validator,
runtime_factory=runtime_factory, finalizer=fail)
assert marker.read_text(encoding="utf-8") == "old"
assert not list(final.parent.glob(".controlled.staging-*"))
@@ -0,0 +1,217 @@
import numpy as np
import pytest
import src.drl_pinball.legacy_test.acquire as acquire
def test_allowlist_roles_and_case_sample_intervals():
assert acquire.SUPPORTED_CASES == (
"karman_re50", "karman_re100", "karman_re200", "karman_re400",
"illusion_075L", "illusion_1L", "illusion_15L", "steady", "vortex_lamb", "vortex_taylor", "erase",
)
assert [acquire.get_case(name).sample_interval for name in
("karman_re50", "illusion_075L", "illusion_1L", "illusion_15L", "steady")] == [800, 400, 600, 800, 800]
assert acquire._case_roles(acquire.get_case("steady")) == ("target", "constant", "zero")
assert acquire._case_roles(acquire.get_case("illusion_075L")) == ("controlled", "target", "zero")
def test_scene_observation_slices_are_exact():
obs14 = np.arange(14, dtype=np.float32)
sensors, forces = acquire._observation_slices(acquire.get_case("karman_re50"), "controlled", obs14)
assert np.array_equal(sensors, obs14[2:8]) and np.array_equal(forces, obs14[8:14])
obs12 = np.arange(12, dtype=np.float32)
for name in ("illusion_075L", "steady"):
sensors, forces = acquire._observation_slices(acquire.get_case(name), "zero", obs12)
assert np.array_equal(sensors, obs12[:6]) and np.array_equal(forces, obs12[6:12])
target8 = np.arange(8, dtype=np.float32)
sensors, forces = acquire._observation_slices(acquire.get_case("illusion_075L"), "target", target8)
assert np.array_equal(sensors, target8[2:8]) and np.isnan(forces).all()
def test_illusion_policy_observation_is_exact_14d_next_harmonic_step(monkeypatch, tmp_path):
case = acquire.get_case("illusion_075L")
class Model:
def predict(self, obs, deterministic=True):
assert obs.shape == (14,)
return np.zeros(3, np.float32), None
class Flow:
DATA_TYPE = np.float32
def __init__(self): self.obs=np.zeros(12,np.float32); self.action=np.zeros(6,np.float32); self.i=0
def restore_ddf(self): pass
def apply_ddf(self): pass
def run(self, steps, command): self.i += 1; self.action=command.copy(); self.obs=np.arange(12,dtype=np.float32)
def current_effective_action(self): return self.action
data={"config":{"nx":1280,"ny":1,"sample_interval":400,"n_obj_total":6},
"norm":{"save_states":np.zeros((2,12),np.float32)}, "target_states":np.zeros((80,8)),
"target_harmonics":[{}]*8}
seen=[]
monkeypatch.setattr(acquire, "gen_target_states_at", lambda step, harmonics: seen.append(step) or np.arange(8,dtype=float)+step)
monkeypatch.setattr(acquire, "reward_terms", lambda *args: {"reward":1.,"reward_cd":1.,"reward_cl":1.,"native_legacy_dtw":1.})
monkeypatch.setattr(acquire, "policy_observation", lambda raw,norm,target_force=None: np.r_[raw,target_force].astype(np.float32))
monkeypatch.setattr(acquire, "WARMUP_INTERVALS", 1); monkeypatch.setattr(acquire, "COLLECT_BOUNDARIES", 1)
acquire._collect_role("controlled", data, Model(), Flow(), {"force_norm_fact":1.}, tmp_path,
capture_field=lambda *args: {"ux":np.zeros((1,1280)),"uy":np.zeros((1,1280))},
case_name=case.name)
assert seen == [1, 2]
def test_steady_staging_has_no_phase_or_dtw_artifacts(tmp_path):
role=tmp_path/"role"; role.mkdir()
np.savez(role/"timeseries.npz", x=np.zeros(1)); (role/"timeseries.csv").write_text("x\n0\n")
np.savez(role/"late_field.npz", ux=np.zeros((1,2,3)), uy=np.zeros((1,2,3)), field_indices=np.array([159]))
(role/"metadata.json").write_text("{}")
acquire._validate_staged_role(role, "steady")
assert not any("phase" in path.name or "dtw" in path.name for path in role.iterdir())
def test_nonstandard_cases_are_allowlisted():
for name in ("vortex_lamb", "vortex_taylor", "erase"):
assert acquire.get_case(name).name == name
assert acquire._case_roles(acquire.get_case(name)) == ("controlled", "target", "zero")
def test_target_and_steady_bundle_resolution_do_not_require_frozen_norm(monkeypatch, tmp_path):
monkeypatch.setattr(acquire, "load_policy_norm", lambda path: pytest.fail("frozen norm loaded"))
target = acquire._resolve_bundle(tmp_path, "illusion_075L", "target")
steady = acquire._resolve_bundle(tmp_path, "steady", "constant")
assert target["norm"] is target["norm_path"] is None
assert steady["norm"] is steady["norm_path"] is None
def test_vortex_zero_bundle_uses_builder_norm_when_frozen_norm_is_absent(monkeypatch, tmp_path):
monkeypatch.setattr(acquire, "load_policy_norm", lambda path: pytest.fail("frozen norm loaded"))
bundle = acquire._resolve_bundle(tmp_path, "vortex_lamb", "zero")
assert bundle["norm"] is bundle["norm_path"] is None
assert acquire._special_normalization_source(bundle["case"], "zero") == (
"builder_recomputed_zero_runtime"
)
assert acquire._special_normalizer_file(bundle) is None
def test_vortex_controlled_bundle_still_requires_frozen_norm(tmp_path):
with pytest.raises(FileNotFoundError, match="Frozen policy normalization not found"):
acquire._resolve_bundle(tmp_path, "vortex_lamb", "controlled")
def test_steady_constant_and_zero_match_active_physical_commands(monkeypatch, tmp_path):
case = acquire.get_case("steady")
class Flow:
DATA_TYPE = np.float32
def __init__(self):
self.obs = np.zeros(12, np.float32); self.action = np.zeros(6, np.float32); self.commands = []
def restore_ddf(self): pass
def apply_ddf(self): pass
def run(self, steps, command): self.commands.append(command.copy()); self.action = command.copy()
def current_effective_action(self): return self.action
data = {"config":{"nx":1280,"ny":1,"sample_interval":800,"n_obj_total":6},
"norm":{"save_states":np.zeros((2,12),np.float32)}}
norm = {"force_norm_fact":1., "sens_deviation":np.zeros(6), "sens_norm_fact":np.ones(6)}
monkeypatch.setattr(acquire, "WARMUP_INTERVALS", 1); monkeypatch.setattr(acquire, "COLLECT_BOUNDARIES", 1)
for role, expected in (("constant", np.asarray(case.action_bias) * acquire.U0),
("zero", np.zeros(3))):
flow = Flow()
rows, _ = acquire._collect_role(role, data, None, flow, norm, tmp_path,
capture_field=lambda *args: {"ux":np.zeros((1,1280)), "uy":np.zeros((1,1280))},
case_name="steady")
assert len(flow.commands) == 2
assert all(np.allclose(command[-3:], expected) for command in flow.commands)
assert np.allclose(rows[0]["commanded_target_omega"], expected)
def test_periodic_publication_requires_four_crossings():
with pytest.raises(ValueError, match="at least 4 center-uy rising crossings"):
acquire._require_periodic_crossings({"crossing_times": np.arange(3)})
assert acquire._require_periodic_crossings({"crossing_times": np.arange(4)}) == 4
def test_illusion_collector_actions_exactly_match_active_run_convention(monkeypatch, tmp_path):
case = acquire.get_case("illusion_075L")
predicted = [np.array([.25, -.5, .75], np.float32),
np.array([-.125, .375, -.625], np.float32),
np.array([.5, 0., -.25], np.float32)]
class Model:
def __init__(self): self.index = 0
def predict(self, obs, deterministic=True):
result = predicted[self.index]; self.index += 1; return result, None
class Flow:
DATA_TYPE = np.float32
def __init__(self):
self.obs=np.zeros(12,np.float32); self.action=np.zeros(6,np.float32); self.commands=[]
def restore_ddf(self): pass
def apply_ddf(self): pass
def run(self, steps, command): self.commands.append(command.copy()); self.action=command.copy(); self.obs += 1
def current_effective_action(self): return self.action
data={"config":{"nx":1280,"ny":1,"sample_interval":400,"n_obj_total":6},
"norm":{"save_states":np.zeros((2,12),np.float32)}, "target_states":np.zeros((80,8)),
"target_harmonics":[{}]*8}
norm={"force_norm_fact":1., "sens_deviation":np.zeros(6), "sens_norm_fact":np.ones(6)}
monkeypatch.setattr(acquire, "WARMUP_INTERVALS", 1); monkeypatch.setattr(acquire, "COLLECT_BOUNDARIES", 2)
monkeypatch.setattr(acquire, "reward_terms", lambda *args: {"reward":1.,"reward_cd":1.,"reward_cl":1.,"native_legacy_dtw":1.})
monkeypatch.setattr(acquire, "gen_target_states_at", lambda step, harmonics: np.arange(8,dtype=float)+step)
flow=Flow()
acquire._collect_role("controlled", data, Model(), flow, norm, tmp_path,
capture_field=lambda *args: {"ux":np.zeros((1,1280)),"uy":np.zeros((1,1280))},
case_name=case.name)
# Exact convention in active run.py: (normalized_action * action_scale + action_bias) * U0.
expected=[(action * case.action_scale + np.asarray(case.action_bias)) * acquire.U0 for action in predicted]
assert all(np.allclose(command[-3:], wanted) for command, wanted in zip(flow.commands, expected))
def test_generated_array_identity_binds_dtype_shape_and_content():
value = np.arange(12, dtype=np.float32).reshape(3, 4)
first = acquire._generated_array_identity(value)
assert first == acquire._generated_array_identity(value.copy())
assert first["identity_kind"] == "run-generated-array-content"
assert first["dtype"] == "<f4" and first["shape"] == [3, 4] and first["order"] == "C"
assert len(first["content_sha256"]) == 64 and first["bytes"] == value.nbytes
assert first["content_sha256"] != acquire._generated_array_identity(value.astype(np.float64))["content_sha256"]
assert first["content_sha256"] != acquire._generated_array_identity(value.reshape(4, 3))["content_sha256"]
changed = value.copy(); changed[0, 0] = -1
assert first["content_sha256"] != acquire._generated_array_identity(changed)["content_sha256"]
def test_generated_config_identity_is_json_canonical_and_exposes_values():
left = {"sample_interval": np.int64(400), "obs_slice": (0, 12),
"action_bias": np.array([0., -2., 2.], np.float32)}
right = {"action_bias": [0., -2., 2.], "obs_slice": [0, 12], "sample_interval": 400}
first = acquire._generated_config_identity(left)
assert first == acquire._generated_config_identity(right)
assert first["identity_kind"] == "run-generated-builder-config"
assert first["values"] == right
assert len(first["canonical_json_sha256"]) == 64
assert first["canonical_json_sha256"] != acquire._generated_config_identity({**right, "sample_interval": 600})["canonical_json_sha256"]
@pytest.mark.parametrize("role", ["target", "zero"])
def test_frozen_reference_comparison_only_runs_for_controlled(monkeypatch, role):
monkeypatch.setattr(acquire, "frozen_reference_comparison", lambda *args: pytest.fail("comparison called"))
columns = {"sensors": np.zeros((2, 6)), "forces": np.zeros((2, 6)),
"action_normalized": np.zeros((2, 3))}
assert acquire._controlled_reference(role, {"reference_path": "unused"}, columns, 30) is None
def test_controlled_reference_comparison_remains_enabled(monkeypatch):
sentinel = object()
monkeypatch.setattr(acquire, "frozen_reference_comparison", lambda *args: sentinel)
columns = {"sensors": np.zeros((2, 6)), "forces": np.zeros((2, 6)),
"action_normalized": np.zeros((2, 3))}
assert acquire._controlled_reference("controlled", {"reference_path": "reference.npz"}, columns, 30) is sentinel
def test_role_semantics_are_explicit():
assert "frozen-policy" in acquire._role_semantics("controlled")
assert "builder-generated target" in acquire._role_semantics("target")
assert "frozen norm used only for native reward" in acquire._role_semantics("zero")
assert "case.action_bias*U0" in acquire._role_semantics("constant")
def test_optional_frozen_reference_provenance_records_absence(tmp_path):
missing = tmp_path / "controlled.npz"
assert acquire._optional_file_provenance(None) is None
absent = acquire._optional_file_provenance(missing)
assert absent == {"path": str(missing.resolve()), "exists": False, "file_identity": None}
missing.write_bytes(b"reference")
present = acquire._optional_file_provenance(missing)
assert present["exists"] is True
assert present["file_identity"]["path"] == str(missing.resolve())
assert len(present["file_identity"]["sha256"]) == 64
@@ -0,0 +1,232 @@
from pathlib import Path
import numpy as np
import pytest
import src.drl_pinball.legacy_test.acquire as acquire
import src.drl_pinball.legacy_test.core.legacy_env_builder as builder
class RoleFlow:
DATA_TYPE = np.float32
def __init__(self, n_objects, obs_size, absolute_base=2_000_000):
self.action = np.ones(n_objects, dtype=np.float32)
self.obs = np.zeros(obs_size, dtype=np.float32)
self.intervals = 0
self.absolute_base = absolute_base
self.commands = []
self.capture_intervals = []
def restore_ddf(self):
pass
def apply_ddf(self):
pass
def run(self, steps, command):
assert steps == acquire.SI
assert np.array_equal(self.action, np.zeros_like(self.action))
self.intervals += 1
self.commands.append(command.copy())
self.action = command.copy()
phase = 2 * np.pi * self.intervals / acquire.CYCLE_LENGTH
self.obs[0:2] = [1000 + self.intervals, 2000 + self.intervals]
self.obs[2:8] = [np.sin(phase + index) for index in range(6)]
if self.obs.size >= 14:
self.obs[8:14] = np.arange(6) + self.intervals
def current_effective_action(self):
return self.action.copy()
def solver_clock_state(self):
return {"solver_absolute_lattice_clock": self.absolute_base + self.intervals * acquire.SI,
"solver_absolute_control_clock": self.intervals}
def data(n_objects=7):
phase = 2 * np.pi * np.arange(150) / acquire.CYCLE_LENGTH
target = np.column_stack([np.sin(phase + index) for index in range(6)]).astype(np.float32)
result = {
"config": {"nx": 1280, "ny": 2, "sample_interval": 800,
"n_obj_total": n_objects},
"target_states": target,
}
if n_objects == 7:
result["norm"] = {"save_states": np.zeros((150, 12), dtype=np.float32)}
return result
def norm():
return {"force_norm_fact": 1.0, "sens_deviation": np.zeros(6),
"sens_norm_fact": np.ones(6)}
def capture(flow, nx, ny):
assert flow.intervals > acquire.WARMUP_INTERVALS
flow.capture_intervals.append(flow.intervals)
return {"ux": np.zeros((ny, nx), np.float32), "uy": np.zeros((ny, nx), np.float32)}
def test_target_builder_uses_disturbance_then_three_sensors_without_pinball(monkeypatch):
class TargetFlow:
DATA_TYPE = np.float32
FIELD_SHAPE = (1280, 2, 1)
def __init__(self, field_cfg, cuda_cfg, device_id):
self.obs = np.zeros(0, np.float32)
self.objects = []
self.interval = 0
def add_cylinder(self, center, radius):
self.objects.append(("cylinder", center, radius))
self.obs = np.zeros(2 * len(self.objects), np.float32)
def add_sensor(self, center, radius):
self.objects.append(("sensor", center, radius))
self.obs = np.zeros(2 * len(self.objects), np.float32)
class Config:
def _replace(self, **kwargs):
return self
monkeypatch.setattr(builder, "FlowField", TargetFlow)
monkeypatch.setattr(builder.legacy_utils, "load_cuda_config", lambda path: object())
monkeypatch.setattr(builder.legacy_utils, "load_flow_field_config", lambda path: Config())
monkeypatch.setattr(builder, "_stabilize", lambda flow, count: None)
monkeypatch.setattr(builder, "FIFO_LEN", 2)
def interval(flow, steps, command):
flow.interval += 1
flow.obs[0:2] = [100 + flow.interval, 200 + flow.interval]
flow.obs[2:8] = np.arange(6) + 10 * flow.interval
monkeypatch.setattr(builder, "run_historical_interval", interval)
result = builder.build_karman_target(0, 100.0, sample_interval=800)
flow = result["flow_field"]
assert [item[0] for item in flow.objects] == ["cylinder", "sensor", "sensor", "sensor"]
assert flow.objects[0][1][0] == 10.0 * builder.L0
assert [item[1][1] for item in flow.objects[1:]] == [40.5, 0.5, -39.5]
assert result["config"]["n_obj_total"] == 4
assert np.array_equal(result["target_states"],
np.asarray([np.arange(6) + 10, np.arange(6) + 20], np.float32))
def test_zero_role_schedule_actions_rewards_and_fields(monkeypatch, tmp_path):
flow = RoleFlow(7, 14)
reward_calls = []
def rewards(*args):
reward_calls.append(args)
return {"reward": 0.4, "reward_cd": 0.5, "reward_cl": 0.6,
"native_legacy_dtw": 0.7}
monkeypatch.setattr(acquire, "reward_terms", rewards)
rows, fields = acquire._collect_role(
"zero", data(), None, flow, norm(), tmp_path, capture_field=capture,
)
assert flow.intervals == len(reward_calls) == 640
assert flow.capture_intervals == list(range(481, 641))
assert len(rows) == 160
assert fields["ux"].shape == fields["uy"].shape == (160, 2, 1280)
assert not list(tmp_path.glob("boundary_*.npz"))
counter_bias = np.array([0.0, 0.5, -0.5], np.float32)
assert np.array_equal(rows[0]["action_normalized"], counter_bias)
assert all(np.array_equal(command, np.zeros(7, np.float32)) for command in flow.commands)
assert np.array_equal(rows[0]["commanded_target_omega"], np.zeros(3, np.float32))
assert np.allclose(rows[0]["effective_smoothed_omega"], 0.0, atol=1e-7)
assert rows[0]["reward_raw"] == 0.4
assert rows[0]["native_reward_dtw"] == 0.7
assert rows[0]["control_index"] == 481 and rows[-1]["control_index"] == 640
def test_zero_role_metadata_names_physical_zero_counter_bias():
source = Path(acquire.__file__).read_text(encoding="utf-8")
assert "physical-zero/uncontrolled" in source
assert "counter-bias normalized action" in source
def test_target_role_ordering_nan_contract_and_schedule(monkeypatch, tmp_path):
flow = RoleFlow(4, 8)
monkeypatch.setattr(acquire, "reward_terms", lambda *args: pytest.fail("target has no reward"))
rows, fields = acquire._collect_role(
"target", data(n_objects=4), None, flow, None, tmp_path, capture_field=capture,
)
assert flow.intervals == 640
assert all(np.array_equal(command, np.zeros(4, np.float32)) for command in flow.commands)
assert len(rows) == 160
assert fields["ux"].shape == (160, 2, 1280)
first = rows[0]
phase = 2 * np.pi * 481 / acquire.CYCLE_LENGTH
expected = np.asarray([np.sin(phase + index) for index in range(6)], np.float32)
assert np.allclose(first["sensors"], expected)
assert first["sensors"][0] != flow.obs[0]
for name in ("forces", "action_normalized", "commanded_target_omega",
"effective_smoothed_omega"):
assert np.isnan(first[name]).all()
for name in ("reward_raw", "reward_cd", "reward_cl", "reward_sim",
"native_reward_dtw"):
assert np.isnan(first[name])
assert first["solver_absolute_lattice_step"] == 2_000_000 + 481 * 800
def test_target_sensor_reference_selects_legacy_target_channels():
six = np.arange(60, dtype=float).reshape(10, 6)
eight = np.column_stack((np.full((10, 2), -1.0), six))
assert np.array_equal(acquire._target_sensors(six), six)
assert np.array_equal(acquire._target_sensors(eight), six)
with pytest.raises(ValueError, match="six sensor channels"):
acquire._target_sensors(np.zeros((10, 7)))
def test_target_phase_artifacts_exclude_unavailable_nan_variables():
columns = {"sensors": np.ones((4, 6)), "forces": np.full((4, 6), np.nan),
"action_normalized": np.full((4, 3), np.nan),
"physical_time": np.arange(4), "lattice_step": np.arange(4),
"solver_absolute_lattice_step": np.arange(4),
"control_index": np.arange(4), "phase": np.arange(4),
"cycle_id": np.arange(4)}
values = acquire._phase_values(columns, "target")
assert set(values) == {"sensors"}
assert np.isfinite(values["sensors"]).all()
@pytest.mark.parametrize("role", ["target", "zero"])
def test_role_failure_transaction_preserves_existing(monkeypatch, tmp_path, role):
root = tmp_path / "out"
final = root / "legacy" / acquire.CASE_NAME / role
final.mkdir(parents=True)
marker = final / "keep.txt"
marker.write_text("old", encoding="utf-8")
model = tmp_path / "model.zip"; model.write_bytes(b"model")
norm_path = tmp_path / "norm.json"; norm_path.write_text("{}", encoding="utf-8")
bundle = {"case": acquire.get_case(acquire.CASE_NAME), "model_path": model,
"norm_path": norm_path, "norm": norm(),
"reference_path": tmp_path / "missing.npz"}
monkeypatch.setattr(acquire, "_resolve_bundle", lambda repo, *args: bundle)
monkeypatch.setattr(acquire, "_collect_role", lambda *args, **kwargs: ([], []))
def storage_validator(**kwargs):
root.mkdir(exist_ok=True)
return {"resolved_output_root": root, "device": root.stat().st_dev}
def runtime_factory(case, current_bundle, device, current_role):
n_objects = 4 if current_role == "target" else 7
return data(n_objects), None, RoleFlow(n_objects, 8 if current_role == "target" else 14)
with pytest.raises(RuntimeError, match="finalize failed"):
acquire.acquire_role(
role, output_root=root, overwrite=True, repo_root=tmp_path,
storage_validator=storage_validator, runtime_factory=runtime_factory,
finalizer=lambda *args: (_ for _ in ()).throw(RuntimeError("finalize failed")),
)
assert marker.read_text(encoding="utf-8") == "old"
assert not list(final.parent.glob(f".{role}.staging-*"))
def test_cli_exposes_all_roles():
source = Path(acquire.__file__).read_text(encoding="utf-8")
assert 'choices=ROLES' in source
@@ -0,0 +1,287 @@
import ast
from pathlib import Path
import numpy as np
import src.drl_pinball.legacy_test.metrics as legacy_metrics
import src.drl_pinball.legacy_test.render as legacy_render
import src.drl_pinball.legacy_test.run as legacy_run
from src.drl_pinball.legacy_test.cases import CASES, ERASE_UNSUPPORTED, get_case
from src.drl_pinball.legacy_test.metrics import (
aggregate_rewards, frozen_reference_comparison, reward_terms, sha256_file,
target_scale_cycle_similarity,
)
from src.drl_pinball.legacy_test.render import ddf_to_velocity
from src.drl_pinball.legacy_test.core.legacy_env_builder import _field_xy
from src.drl_pinball.legacy_test.runtime import (
compute_norm, load_policy_norm, policy_observation, reset_runtime,
run_historical_interval,
)
def test_config_matrix():
assert set(CASES)=={"karman_re50","karman_re100","karman_re200","karman_re400","illusion_075L","illusion_1L","illusion_15L","vortex_lamb","vortex_taylor","erase","steady"}
assert [CASES[f"karman_re{x}"].re_code for x in (50,100,200,400)] == [50,100,200,400]
assert [CASES[x].sample_interval for x in ("illusion_075L","illusion_1L","illusion_15L")] == [400,600,800]
assert get_case("erase").model == "d1a3o12_250729_250326_erase"
def test_target_scale_metric_is_scale_invariant():
t=np.column_stack([np.sin(np.arange(90)/7+i) * (i+1) for i in range(6)])
result=target_scale_cycle_similarity(t,t,30)
factors=np.arange(1,7,dtype=np.float64)
scaled=target_scale_cycle_similarity(t*factors,t*factors,30)
assert np.isclose(result["similarity"],scaled["similarity"])
assert len(result["scale"])==6
def test_norm_recomputed_from_rows():
rows=np.arange(120,dtype=np.float32).reshape(10,12)
norm=compute_norm(rows)
assert norm["force_norm_fact"] == 6*np.max(np.abs(rows[:,6:12]))
assert not np.allclose(norm["sens_deviation"],0)
class FakeFlow:
def __init__(self): self.calls=[]
def restore_ddf(self): self.calls.append("restore")
def apply_ddf(self): self.calls.append("apply")
def test_reset_restores_ddf_fifo_and_zero_observation():
ff=FakeFlow(); saved=np.arange(36,dtype=np.float32).reshape(3,12)
fifo,obs=reset_runtime(ff,saved,12)
assert ff.calls==["restore","apply"]
assert np.array_equal(np.asarray(fifo),saved)
assert np.array_equal(obs,np.zeros(12,dtype=np.float32))
def test_ddf_conversion_uses_reference_density_not_population_sum():
ny,nx=3,4; f=np.zeros((ny,nx,9),dtype=np.float32); f[:,:,0]=7; f[:,:,1]=.01
flags=np.ones((nx,ny),dtype=np.uint8)
ux,uy=ddf_to_velocity(f.transpose(2,0,1).reshape(-1),flags,nx,ny)
assert np.allclose(ux,.01)
assert np.allclose(uy,0)
def test_render_final_accepts_real_legacy_3d_shape(monkeypatch, tmp_path):
class RenderFlow:
FIELD_SHAPE = (4, 3, 1)
def __init__(self):
f = np.zeros((3, 4, 9), dtype=np.float32)
f[:, :, 0] = 1.0
f[:, :, 1] = 0.25
self.ddf = f.transpose(2, 0, 1).reshape(-1)
self.flag = np.ones(12, dtype=np.uint8)
self.downloaded = False
def get_ddf(self):
self.downloaded = True
def completed_flags_xy(self):
return self.flag.reshape((3, 4)).T.copy()
captured = {}
def fake_compute_vorticity(ux, uy):
captured["velocity_shape"] = (ux.shape, uy.shape)
return np.zeros_like(ux)
def fake_render(vort, **kwargs):
captured["vorticity_shape"] = vort.shape
captured["render_kwargs"] = kwargs
return {"image_path": kwargs["out_path"]}
monkeypatch.setattr(legacy_render, "compute_vorticity", fake_compute_vorticity)
monkeypatch.setattr(legacy_render, "render_vorticity_field", fake_render)
flow = RenderFlow()
out_path = tmp_path / "final_vorticity.png"
result = legacy_render.render_final(flow, str(out_path))
assert flow.downloaded
assert captured["velocity_shape"] == ((3, 4), (3, 4))
assert captured["vorticity_shape"] == (3, 4)
assert captured["render_kwargs"] == {
"nx": 4,
"ny": 3,
"out_path": str(out_path),
"vmin": -0.001,
"vmax": 0.001,
}
assert result == {"image_path": str(out_path)}
def test_reward_aggregation_uses_case_tail():
rows = [
{"reward": float(i), "reward_cd": float(i + 1), "reward_cl": float(i + 2),
"native_legacy_dtw": float(i + 3)}
for i in range(5)
]
summary = aggregate_rewards(rows, 3)
assert summary == {
"mean": 2.0,
"tail": 3.0,
"final": 4.0,
"tail_reward_cd": 4.0,
"tail_reward_cl": 5.0,
"tail_native_legacy_dtw": 6.0,
"tail_length": 3,
}
def test_frozen_reference_comparison_reports_exact_metrics(tmp_path):
time = np.arange(90, dtype=np.float64)
phase = 2.0 * np.pi * time / 30.0
sensors = np.column_stack([np.sin(phase + index) for index in range(6)])
forces = np.column_stack([np.cos(phase + index) for index in range(6)])
actions = np.column_stack([np.sin(phase + index) for index in range(3)])
path = tmp_path / "controlled.npz"
np.savez_compressed(path, sensors=sensors, forces=forces, actions=actions, rewards=time)
result = frozen_reference_comparison(str(path), sensors, forces, actions, 30)
assert result["reference_path"] == str(path.resolve())
assert len(result["reference_sha256"]) == 64
assert np.isclose(result["sensor_dtw"]["similarity"], 1.0)
assert np.allclose(result["sensor_correlations"], 1.0)
assert np.allclose(result["force_correlations"], 1.0)
assert np.allclose(result["action_correlations"], 1.0)
assert result["sensor_rms"] == result["force_rms"] == result["action_rms"] == 0.0
assert result["threshold"] == 0.95
assert result["passed"] is True
def test_historical_interval_zeros_each_ema_seed_and_forwards_target():
class IntervalFlow:
DATA_TYPE = np.float32
def __init__(self):
self.action = np.array([4.0, -3.0], dtype=np.float32)
self.calls = []
def run(self, steps, target):
self.calls.append((self.action.copy(), steps, target.copy()))
self.action[:] = 9.0
flow = IntervalFlow()
first = np.array([1.0, 2.0], dtype=np.float32)
second = np.array([-1.0, 3.0], dtype=np.float32)
run_historical_interval(flow, 8, first)
run_historical_interval(flow, 12, second)
assert np.array_equal(flow.calls[0][0], np.zeros(2, dtype=np.float32))
assert np.array_equal(flow.calls[1][0], np.zeros(2, dtype=np.float32))
assert flow.calls[0][1] == 8
assert flow.calls[1][1] == 12
assert np.array_equal(flow.calls[0][2], first)
assert np.array_equal(flow.calls[1][2], second)
assert flow.calls[0][0].dtype == flow.calls[1][0].dtype == np.float32
def test_active_builder_and_runner_use_historical_interval_adapter_only():
root = Path(__file__).resolve().parents[1]
for relative in ("run.py", "core/legacy_env_builder.py"):
tree = ast.parse((root / relative).read_text())
direct_calls = [
node for node in ast.walk(tree)
if isinstance(node, ast.Call)
and isinstance(node.func, ast.Attribute)
and node.func.attr == "run"
]
assert direct_calls == [], f"{relative} must route all FlowField intervals through adapter"
def test_frozen_policy_norm_drives_observation_reward_and_provenance(monkeypatch, tmp_path):
path = tmp_path / "norm.json"
path.write_text(
'{"force_norm_fact": 2.0, "sens_deviation": [1, 1, 1, 1, 1, 1], '
'"sens_norm_fact": [2, 2, 2, 2, 2, 2]}',
encoding="utf-8",
)
frozen = load_policy_norm(str(path))
recomputed = {
"force_norm_fact": 8.0,
"sens_deviation": np.zeros(6, dtype=np.float32),
"sens_norm_fact": np.ones(6, dtype=np.float32),
}
raw = np.array([3.0] * 6 + [1.0] * 6, dtype=np.float32)
observation = policy_observation(raw, frozen)
recomputed_observation = policy_observation(raw, recomputed)
assert np.allclose(observation[:6], 0.5)
assert np.allclose(observation[6:], 1.0)
assert not np.array_equal(observation, recomputed_observation)
monkeypatch.setattr(legacy_metrics, "native_similarity", lambda *args: 1.0)
reward = reward_terms(
CASES["karman_re50"], np.zeros((60, 6)), None,
np.tile(raw, (30, 1)), frozen["force_norm_fact"], 0,
)
expected_cd = np.exp(-abs(0.5 * 20.0))
expected_cl = np.exp(-abs(0.5 * 80.0))
assert np.isclose(reward["reward_cd"], expected_cd)
assert np.isclose(reward["reward_cl"], expected_cl)
assert len(sha256_file(str(path))) == 64
def test_missing_frozen_policy_norm_fails_closed(tmp_path):
missing = tmp_path / "missing" / "norm.json"
try:
load_policy_norm(str(missing))
except FileNotFoundError as exc:
assert str(missing) in str(exc)
else:
raise AssertionError("missing mapped policy norm must fail closed")
def test_metrics_only_output_helpers_have_no_filesystem_or_render(monkeypatch, tmp_path):
calls = []
monkeypatch.setattr(legacy_run.os.path, "isdir", lambda path: calls.append(("isdir", path)))
monkeypatch.setattr(legacy_run.shutil, "rmtree", lambda path: calls.append(("rmtree", path)))
monkeypatch.setattr(legacy_run.os, "makedirs", lambda path: calls.append(("makedirs", path)))
monkeypatch.setattr(legacy_run.np, "savez_compressed", lambda *args, **kwargs: calls.append(("save", args[0])))
monkeypatch.setattr(legacy_run, "_json", lambda path, value: calls.append(("json", path)))
monkeypatch.setattr(legacy_run, "render_final", lambda ff, path: calls.append(("render", path)))
out = str(tmp_path / "case")
legacy_run._prepare_case_output(out, metrics_only=True)
legacy_run._write_case_output(
out, True, object(), np.zeros((1, 6)), np.zeros((1, 6)),
np.zeros((1, 3)), [], np.zeros((1, 6)), np.zeros((1, 12)), {}, {},
)
assert calls == []
def test_normal_output_helpers_write_defined_artifacts(monkeypatch, tmp_path):
calls = []
out = str(tmp_path / "case")
monkeypatch.setattr(legacy_run.np, "savez_compressed", lambda *args, **kwargs: calls.append(Path(args[0]).name))
monkeypatch.setattr(legacy_run, "_json", lambda path, value: calls.append(Path(path).name))
monkeypatch.setattr(legacy_run, "render_final", lambda ff, path: calls.append(Path(path).name))
legacy_run._write_case_output(
out, False, object(), np.zeros((1, 6)), np.zeros((1, 6)),
np.zeros((1, 3)), [], np.zeros((1, 6)), np.zeros((1, 12)), {}, {},
)
assert calls == [
"signals.npz", "reset_contract.npz", "metrics.json", "norm.json",
"final_vorticity.png",
]
def test_builder_uses_first_two_legacy_shape_dimensions_and_validates_z():
class ShapeFlow:
def __init__(self, shape):
self.FIELD_SHAPE = shape
assert _field_xy(ShapeFlow((1280, 512, 1))) == (1280, 512)
try:
_field_xy(ShapeFlow((1280, 512, 2)))
except ValueError as exc:
assert "nz=1" in str(exc)
else:
raise AssertionError("builder must reject non-singleton z shape")
try:
_field_xy(ShapeFlow((1280, 512)))
except ValueError as exc:
assert "(nx, ny, nz)" in str(exc)
else:
raise AssertionError("builder must reject two-dimensional shape")
@@ -0,0 +1,312 @@
import numpy as np
import pytest
from src.drl_pinball.legacy_test import acquire
from src.drl_pinball.legacy_test.core import legacy_env_builder as builder
from src.drl_pinball.legacy_test.metrics import erase_reward_terms
from src.drl_pinball.legacy_test.runtime import erase_policy_observation
def test_vortex_labels_and_offsets_are_exact():
assert acquire._vortex_scenario("vortex_lamb",0)=="vortex_lamb_y000"
assert [acquire._vortex_scenario("vortex_taylor",x) for x in (-2,-1,0,1,2)] == ["vortex_taylor_ym2L","vortex_taylor_ym1L","vortex_taylor_y000","vortex_taylor_yp1L","vortex_taylor_yp2L"]
for name,value in (("vortex_lamb",1),("vortex_taylor",3)):
with pytest.raises(ValueError): acquire._vortex_offset(name,value)
def test_lamb_event_uses_only_upper_uy_and_earliest_exact_five():
sensors=np.zeros((150,6)); sensors[62,1]=4; sensors[60,3]=100; sensors[63,5]=200
event=acquire._select_vortex_event(sensors,"vortex_lamb")
assert int(event["peak_index"])==62
assert event["event_channel_index"]==1
assert np.array_equal(event["field_indices"],[52,57,62,67,72])
assert (event["search_lower_boundary"],event["search_upper_boundary"])==(38,87)
def test_taylor_event_uses_only_center_uy_y0_source_channel():
sensors=np.zeros((150,6)); sensors[55,1]=100; sensors[70,3]=4; sensors[65,5]=200
event=acquire._select_vortex_event(sensors,"vortex_taylor")
assert int(event["peak_index"])==70
assert event["event_channel_index"]==3
assert np.array_equal(event["field_indices"],[60,65,70,75,80])
def test_event_baseline_window_and_earliest_tie_are_exact():
sensors=np.zeros((150,6)); sensors[:20,1]=2; sensors[50,1]=5; sensors[51,1]=-1
event=acquire._select_vortex_event(sensors,"vortex_lamb")
assert event["baseline_uy"]==2
assert int(event["peak_index"])==50
sensors[149,1]=100
assert int(acquire._select_vortex_event(sensors,"vortex_lamb")["peak_index"])==50
def test_vortex_schedule_matches_historical_episode():
lamb=acquire.get_case("vortex_lamb"); taylor=acquire.get_case("vortex_taylor")
for target in (False,True):
assert acquire._special_schedule(lamb,target)==(0,150)
assert acquire._special_schedule(taylor,target)==(0,150)
assert lamb.steps==taylor.steps==150
assert acquire._special_timeline_metadata(taylor,False)=={
"warmup_control_steps":0,"collected_post_step_boundaries":150,
"historical_episode_steps":150}
def test_erase_observation_inventory_and_policy_order():
raw=np.arange(14,dtype=np.float32); norm={"force_norm_fact":2.,"sens_deviation":np.zeros(6),"sens_norm_fact":np.ones(6)*100}
obs=erase_policy_observation(raw,norm)
assert np.array_equal(obs[:4],np.clip(raw[8:12]/2,-1,1))
sensors,dist,pinball,full=acquire._special_observation(acquire.get_case("erase"),"controlled",raw)
assert np.array_equal(sensors,raw[:6]) and np.array_equal(dist,raw[6:8]) and np.array_equal(pinball,raw[8:14]) and np.array_equal(full,raw)
def test_erase_controlled_reset_policy_obs_is_exact_12d_zero(monkeypatch,tmp_path):
case=acquire.get_case("erase")
norm={"force_norm_fact":2.,"sens_deviation":np.zeros(6,dtype=np.float32),
"sens_norm_fact":np.ones(6,dtype=np.float32),
"save_states":np.full((150,14),7,dtype=np.float32)}
data={"config":{"n_obj_total":7,"nx":2,"ny":1},"norm":norm,
"target_states":np.zeros((150,6),dtype=np.float32)}
class Model:
def __init__(self): self.observations=[]
def predict(self,obs,deterministic=True):
self.observations.append(np.asarray(obs).copy())
return np.zeros(3,dtype=np.float32),None
class Flow:
DATA_TYPE=np.float32
def __init__(self):
self.action=np.zeros(7,dtype=np.float32); self.obs=np.zeros(14,dtype=np.float32); self.step=0
def restore_ddf(self): pass
def apply_ddf(self): pass
def run(self,steps,command):
self.step+=1; self.action=command.copy()
self.obs=np.arange(14,dtype=np.float32)+self.step
self.obs[6:8]=[1000+self.step,2000+self.step]
def current_effective_action(self): return self.action
monkeypatch.setattr(acquire,"_special_schedule",lambda case,target:(0,2))
monkeypatch.setattr(acquire,"erase_reward_terms",lambda *args,**kwargs:{"reward":0.,"reward_u":0.,"reward_v":0.,"native_legacy_dtw":0.})
model=Model(); flow=Flow()
acquire._collect_special("controlled",data,model,flow,norm,tmp_path,case,
capture_field=lambda *args:{"ux":np.zeros((1,2),np.float32),"uy":np.zeros((1,2),np.float32)})
assert len(model.observations)==2
assert model.observations[0].shape==(12,)
assert model.observations[0].dtype==np.float32
assert np.array_equal(model.observations[0],np.zeros(12,dtype=np.float32))
raw=np.arange(14,dtype=np.float32)+1; raw[6:8]=[1001,2001]
expected=erase_policy_observation(raw,norm)
assert model.observations[1].shape==(12,)
assert np.array_equal(model.observations[1],expected)
assert np.array_equal(model.observations[1][:6],np.clip(raw[8:14]/2,-1,1))
assert not np.any(np.isin(model.observations[1],raw[6:8]))
def test_erase_reward_formula_exact_simple_fixture():
t=np.arange(150,dtype=float); states=np.zeros((150,14)); states[:,6]=np.sin(t/5); states[:,7]=np.cos(t/5)
states[:,8]=-.5*states[:,6]; states[:,10]=-.25*states[:,6]; states[:,12]=-.25*states[:,6]
states[:,9]=-.5*states[:,7]; states[:,11]=-.25*states[:,7]; states[:,13]=-.25*states[:,7]
norm={"force_norm_fact":1.,"sens_deviation":np.zeros(6),"sens_norm_fact":np.ones(6)}
result=erase_reward_terms(states,np.zeros(6),norm)
assert 0 <= result["native_legacy_dtw"] <= 1
assert np.isclose(result["reward"],.4*result["reward_u"]+.4*result["reward_v"]+.2*result["native_legacy_dtw"])
assert result["native_component_similarity"].shape==(2,)
def test_erase_phase_fixed_probe_then_fallback():
times=np.arange(160,dtype=float); center=np.ones(160); disturbance=np.sin(2*np.pi*times/20)
phase,probe,fallback=acquire._erase_phase(times,center,disturbance)
assert len(phase["crossing_times"])>=4 and probe=="disturbance_force_y" and fallback
def _field_candidates(tmp_path, count=5):
paths=[]
for index in range(count):
path=tmp_path/f"candidate_{index}.npz"
np.savez(path,ux=np.full((2,3),index),uy=np.full((2,3),-index))
paths.append(path)
return paths
def test_publish_fields_reproduces_scalar_selection_rejection(tmp_path):
destination=tmp_path/"event_fields.npz"
selection={"field_indices":np.arange(5),"relative_offsets":np.arange(5),
"selected_event_score":np.arange(5,dtype=float),"peak_index":np.int64(2)}
with pytest.raises(ValueError,match="named numeric non-scalar arrays required"):
acquire._publish_fields(destination,_field_candidates(tmp_path),selection)
assert not destination.exists()
def test_publish_fields_writes_only_aligned_event_arrays(tmp_path):
destination=tmp_path/"event_fields.npz"
selection={"field_indices":np.arange(5),"relative_offsets":np.array([-10,-5,0,5,10]),
"selected_event_score":np.linspace(0,1,5)}
acquire._publish_fields(destination,_field_candidates(tmp_path),selection)
with np.load(destination,allow_pickle=False) as saved:
assert set(saved.files)=={"ux","uy","field_indices","relative_offsets","selected_event_score"}
assert all(saved[name].ndim>=1 and saved[name].shape[0]==5 for name in saved.files)
assert "peak_index" not in saved.files and "event_score" not in saved.files
def test_special_exact_inventory_validation(tmp_path):
role=tmp_path/"role"; role.mkdir()
np.savez(role/"timeseries.npz",x=np.zeros(5))
np.savez(role/"event_fields.npz",ux=np.zeros((5,1,1)),uy=np.zeros((5,1,1)),
field_indices=np.array([-8,-3,2,7,12]),relative_offsets=np.array([-10,-5,0,5,10]),
selected_event_score=np.zeros(5))
(role/"timeseries.csv").write_text("x\n")
(role/"event_summary.json").write_text(__import__("json").dumps({
"schema":"drl-pinball-legacy-vortex-event-v2",
"event_source":{"scenario":"vortex_lamb_y000","role":"controlled","channel_index":1,"channel":"upper_sensor_uy"},
"common_peak_index":2,"field_indices":[-8,-3,2,7,12],"relative_offsets":[-10,-5,0,5,10]}))
(role/"metadata.json").write_text("{}")
acquire._validate_special(role,acquire.get_case("vortex_lamb"),"controlled")
with np.load(role/"event_fields.npz") as saved:
assert set(saved.files)=={"ux","uy","field_indices","relative_offsets","selected_event_score"}
np.savez(role/"event_fields.npz",ux=np.zeros((5,1,1)),uy=np.zeros((5,1,1)),
field_indices=np.arange(5),relative_offsets=np.arange(5),
selected_event_score=np.zeros(5),peak_index=np.asarray(2))
with pytest.raises(ValueError,match="Vortex event field keys mismatch"):
acquire._validate_special(role,acquire.get_case("vortex_lamb"),"controlled")
np.savez(role/"event_fields.npz",ux=np.zeros((5,1,1)),uy=np.zeros((5,1,1)),
field_indices=np.arange(5),relative_offsets=np.arange(5),
selected_event_score=np.zeros(5))
(role/"extra").write_text("")
with pytest.raises(ValueError,match="staged role files mismatch"): acquire._validate_special(role,acquire.get_case("vortex_lamb"),"controlled")
def test_special_failure_transaction_preserves_existing(monkeypatch,tmp_path):
root=tmp_path/"out"; final=root/"legacy"/"vortex_lamb_y000"/"controlled"
final.mkdir(parents=True); marker=final/"keep.txt"; marker.write_text("old")
model=tmp_path/"model.zip"; model.write_bytes(b"model")
norm_path=tmp_path/"norm.json"; norm_path.write_text("{}")
bundle={"case":acquire.get_case("vortex_lamb"),"model_path":model,"norm_path":norm_path,
"norm":{"force_norm_fact":1.},"reference_path":tmp_path/"missing.npz"}
monkeypatch.setattr(acquire,"_resolve_bundle",lambda *args:bundle)
monkeypatch.setattr(acquire,"_collect_special",lambda *args,**kwargs:([],[]))
monkeypatch.setattr(acquire,"_finalize_special",lambda *args:(_ for _ in ()).throw(RuntimeError("scalar publication failed")))
class Flow:
def close(self): pass
def storage_validator(**kwargs):
root.mkdir(exist_ok=True); return {"resolved_output_root":root,"device":root.stat().st_dev}
def runtime_factory(*args): return {"norm":{}},object(),Flow()
with pytest.raises(RuntimeError,match="scalar publication failed"):
acquire.acquire_role("controlled",case_name="vortex_lamb",output_root=root,overwrite=True,
repo_root=tmp_path,storage_validator=storage_validator,runtime_factory=runtime_factory)
assert marker.read_text()=="old"
assert not list(final.parent.glob(".controlled.staging-*"))
def _write_event_source(root, case_name="vortex_lamb", peak=62):
scenario=acquire._canonical_vortex_source(case_name)
channel,name=acquire._vortex_event_channel(case_name)
path=root/"legacy"/scenario/"controlled"/"event_summary.json"
path.parent.mkdir(parents=True)
path.write_text(__import__("json").dumps({"schema":"drl-pinball-legacy-vortex-event-v2",
"event_source":{"scenario":scenario,"role":"controlled","channel_index":channel,"channel":name},
"common_peak_index":peak,"field_indices":[peak-10,peak-5,peak,peak+5,peak+10],
"relative_offsets":[-10,-5,0,5,10]}))
return path
def test_common_source_indices_reused_across_roles_and_taylor_offsets(tmp_path):
storage={"resolved_output_root":tmp_path}
_write_event_source(tmp_path,"vortex_lamb",61)
_write_event_source(tmp_path,"vortex_taylor",73)
assert np.array_equal(acquire._load_vortex_event_source(storage,"vortex_lamb")["field_indices"],[51,56,61,66,71])
for offset in (-2,-1,0,1,2):
source=acquire._load_vortex_event_source(storage,"vortex_taylor")
assert np.array_equal(source["field_indices"],[63,68,73,78,83])
assert source["event_source"]["scenario"]=="vortex_taylor_y000"
def test_missing_or_malformed_common_source_fails_closed(tmp_path):
storage={"resolved_output_root":tmp_path}
with pytest.raises(FileNotFoundError,match="required before this run"):
acquire._load_vortex_event_source(storage,"vortex_lamb")
path=_write_event_source(tmp_path,"vortex_lamb")
path.write_text("{}")
with pytest.raises(ValueError,match="malformed canonical"):
acquire._load_vortex_event_source(storage,"vortex_lamb")
class _VortexBuilderFlow:
DATA_TYPE=np.float32
FIELD_SHAPE=(1280,2,1)
def __init__(self,*args,**kwargs):
self.obs=np.zeros(0,np.float32); self.action=np.zeros(0,np.float32)
self.runs=[]; self.vortices=[]; self.actions=[]; self.restore_count=0; self.active_injection=0
def add_sensor(self,*args): self.obs=np.zeros(self.obs.size+2,np.float32); self.action=np.zeros(self.obs.size//2,np.float32)
def add_cylinder(self,*args): self.obs=np.zeros(self.obs.size+2,np.float32); self.action=np.zeros(self.obs.size//2,np.float32)
def add_vortex(self,*args):
self.vortices.append(args); self.actions.append(("add_vortex",args)); self.active_injection+=1
def get_ddf(self): pass
def save_ddf(self): pass
def restore_ddf(self): self.restore_count+=1; self.active_injection=0
def apply_ddf(self): pass
class _BuilderConfig:
def _replace(self,**kwargs): return self
def _patch_vortex_builder(monkeypatch):
monkeypatch.setattr(builder,"FlowField",_VortexBuilderFlow)
monkeypatch.setattr(builder.legacy_utils,"load_cuda_config",lambda path:object())
monkeypatch.setattr(builder.legacy_utils,"load_flow_field_config",lambda path:_BuilderConfig())
monkeypatch.setattr(builder,"FIFO_LEN",2)
def interval(flow,steps,command):
copied=np.asarray(command).copy()
flow.runs.append((steps,copied)); flow.actions.append(("run",steps,copied))
flow.action=copied
flow.obs[:]=flow.active_injection
monkeypatch.setattr(builder,"run_historical_interval",interval)
def test_target_builder_handoff_is_fresh_second_injection(monkeypatch):
_patch_vortex_builder(monkeypatch)
result=builder.build_vortex(target_only=True,role="target")
flow=result["flow_field"]
long_step=int(1280/builder.U0)
assert np.array_equal(result["target_states"],np.ones((2,6),np.float32))
assert len(flow.vortices)==2 and flow.restore_count==1 and flow.active_injection==1
assert [action[0] for action in flow.actions]==["run","add_vortex","run","run","add_vortex"]
assert flow.actions[0][1]==long_step and len(flow.actions[0][2])==3
assert all(action[1]==800 and len(action[2])==3 for action in flow.actions[2:4])
assert flow.vortices[0]==flow.vortices[1]
assert result["config"]["runtime_handoff"]=="fresh_second_injection_from_saved_pre_injection_sensors_state"
def test_zero_builder_pretriggers_then_formally_injects_at_exact_zero(monkeypatch):
_patch_vortex_builder(monkeypatch)
zero=builder.build_vortex(role="zero",vortex_type="taylor",y_offset_l0=2)
flow=zero["flow_field"]
long_step=int(1280/builder.U0); development_steps=int(2*1280/builder.U0)
six_object_actions=[action for action in flow.actions
if action[0]=="add_vortex" and action[1][0][0]==15*builder.L0
or action[0]=="run" and len(action[2])==6 and action[1] in (long_step,development_steps)]
assert [action[0] for action in six_object_actions]==["run","add_vortex","run","add_vortex"]
assert six_object_actions[0][1]==long_step
assert np.array_equal(six_object_actions[0][2],np.zeros(6,np.float32))
assert six_object_actions[2][1]==development_steps
assert np.array_equal(six_object_actions[2][2],np.zeros(6,np.float32))
assert six_object_actions[1][1]==six_object_actions[3][1]
vortex=six_object_actions[1][1]
assert vortex[0]==(15*builder.L0,1/2+2*builder.L0,0.0)
assert vortex[1]==2*builder.L0 and vortex[2]==0.03*builder.U0 and vortex[4]=="taylor"
assert zero["config"]["pre_injection_stabilization"]=="exact_physical_zero_with_vortex_pretrigger_2NX_over_U0"
assert zero["config"]["pretrigger_vortex"] is True
assert zero["config"]["pretrigger_development_steps"]==development_steps
def test_controlled_builder_keeps_zero_bias_then_one_formal_vortex(monkeypatch):
_patch_vortex_builder(monkeypatch)
controlled=builder.build_vortex(role="controlled")
flow=controlled["flow_field"]
long_step=int(1280/builder.U0)
acquisition_actions=[action for action in flow.actions
if action[0]=="add_vortex" and action[1][0][0]==15*builder.L0
or action[0]=="run" and len(action[2])==6 and action[1]==long_step]
assert [action[0] for action in acquisition_actions]==["run","run","add_vortex"]
assert np.array_equal(acquisition_actions[0][2],np.zeros(6,np.float32))
assert np.array_equal(acquisition_actions[1][2][3:],np.asarray([0,-4,4],np.float32)*builder.U0)
assert sum(vortex[0][0]==15*builder.L0 for vortex in flow.vortices)==1
assert controlled["config"]["pre_injection_stabilization"]=="zero_then_bias"
assert controlled["config"]["pretrigger_vortex"] is False
assert controlled["config"]["pretrigger_development_steps"]==0
def test_vortex_builder_role_and_target_only_are_bound(monkeypatch):
_patch_vortex_builder(monkeypatch)
with pytest.raises(ValueError,match="semantics disagree"):
builder.build_vortex(target_only=True,role="controlled")