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

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

820 lines
48 KiB
Python

"""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())