refactor(eval): consolidate drl-pinball reproduction
Retire duplicate reproduction paths in favor of the canonical V5 and Legacy runners, while preserving historical tooling in archives and publishing audited summary plots. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Plot the validated V5 and Legacy three-role reproduction summaries."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import matplotlib
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
import numpy as np
|
||||
|
||||
L0 = 20.0
|
||||
VORTICITY_LIMIT = 0.001
|
||||
SENSOR_PAIRS = ((0, 1, "upper"), (2, 3, "center"), (4, 5, "lower"))
|
||||
SENSOR_COLORS = ("#0072B2", "#D55E00", "#009E73")
|
||||
V5_GROUPS = {
|
||||
"ill_075L_seed43": "ill_075L", "ill_1L_seed43": "ill_1L",
|
||||
"ill_15L_seed43": "ill_15L", "ill_2L_seed43": "ill_2L",
|
||||
"kar_d075_seed44": "kar_d075", "kar_d15_seed45": "kar_d15",
|
||||
"kar_d2_seed45": "kar_d2", "kar_re60_seed43": "kar_re60",
|
||||
"kar_re100_seed41": "kar_re100", "kar_re100_seed42": "kar_re100",
|
||||
"kar_re100_seed43": "kar_re100", "kar_re100_seed44": "kar_re100",
|
||||
"kar_re100_seed45": "kar_re100", "kar_re200_seed43": "kar_re200",
|
||||
"kar_re400_seed43": "kar_re400",
|
||||
}
|
||||
LEGACY_PERIODIC = (
|
||||
"illusion_075L", "illusion_1L", "illusion_15L", "karman_re50",
|
||||
"karman_re100", "karman_re200", "karman_re400",
|
||||
)
|
||||
LEGACY_VORTEX = (
|
||||
"vortex_lamb_y000", "vortex_taylor_ym2L", "vortex_taylor_ym1L",
|
||||
"vortex_taylor_y000", "vortex_taylor_yp1L", "vortex_taylor_yp2L",
|
||||
)
|
||||
LEGACY_GROUPS = LEGACY_PERIODIC + ("steady",) + LEGACY_VORTEX + ("erase",)
|
||||
|
||||
|
||||
def fail(message: str) -> None:
|
||||
raise RuntimeError(message)
|
||||
|
||||
|
||||
def require_exact_groups(root: Path) -> None:
|
||||
v5_controlled = {p.parent.name for p in (root / "v5").glob("*/controlled") if p.is_dir()}
|
||||
if v5_controlled != set(V5_GROUPS):
|
||||
fail(f"V5 controlled groups differ: missing={set(V5_GROUPS)-v5_controlled}, extra={v5_controlled-set(V5_GROUPS)}")
|
||||
legacy = {p.name for p in (root / "legacy").iterdir() if p.is_dir()}
|
||||
if legacy != set(LEGACY_GROUPS):
|
||||
fail(f"Legacy groups differ: missing={set(LEGACY_GROUPS)-legacy}, extra={legacy-set(LEGACY_GROUPS)}")
|
||||
|
||||
|
||||
def roles_for(root: Path, pipeline: str, group: str):
|
||||
if pipeline == "v5":
|
||||
case = V5_GROUPS[group]
|
||||
return (("target", root / "v5" / case / "target"),
|
||||
("controlled", root / "v5" / group / "controlled"),
|
||||
("physical-zero", root / "v5" / case / "zero"))
|
||||
middle = "constant" if group == "steady" else "controlled"
|
||||
return (("target", root / "legacy" / group / "target"),
|
||||
(middle, root / "legacy" / group / middle),
|
||||
("physical-zero", root / "legacy" / group / "zero"))
|
||||
|
||||
|
||||
def field_spec(pipeline: str, group: str, role_dir: Path):
|
||||
if pipeline == "v5" or group in LEGACY_PERIODIC or (group == "erase" and role_dir.name != "target"):
|
||||
return "phase_fields.npz", "target_phase", 0.0
|
||||
if group in LEGACY_VORTEX:
|
||||
return "event_fields.npz", "relative_offsets", -10
|
||||
return "late_field.npz", "field_indices", None
|
||||
|
||||
|
||||
def load_role(role_dir: Path, pipeline: str, group: str):
|
||||
if not role_dir.is_dir():
|
||||
fail(f"missing role directory: {role_dir}")
|
||||
required = {"metadata.json", "timeseries.csv"}
|
||||
names = {p.name for p in role_dir.iterdir() if p.is_file()}
|
||||
if not required <= names:
|
||||
fail(f"missing required files in {role_dir}: {required-names}")
|
||||
field_name, selector_name, selector_value = field_spec(pipeline, group, role_dir)
|
||||
field_candidates = names & {"phase_fields.npz", "event_fields.npz", "late_field.npz"}
|
||||
if field_candidates != {field_name}:
|
||||
fail(f"field files differ in {role_dir}: expected {field_name}, found {sorted(field_candidates)}")
|
||||
metadata = json.loads((role_dir / "metadata.json").read_text())
|
||||
units = metadata.get("units", {}).get("sensors", "raw sensor units")
|
||||
data = np.genfromtxt(role_dir / "timeseries.csv", delimiter=",", names=True)
|
||||
if data.ndim != 1 or data.size == 0:
|
||||
fail(f"invalid timeseries shape in {role_dir}: {data.shape}")
|
||||
expected_sensors = {f"sensors_{i}" for i in range(6)}
|
||||
actual_sensors = {n for n in (data.dtype.names or ()) if n.startswith("sensors_")}
|
||||
if actual_sensors != expected_sensors:
|
||||
fail(f"sensor columns differ in {role_dir}: {actual_sensors}")
|
||||
sensors = np.column_stack([data[f"sensors_{i}"] for i in range(6)])
|
||||
if sensors.shape != (data.size, 6) or not np.isfinite(sensors).all():
|
||||
fail(f"invalid sensor values in {role_dir}: {sensors.shape}")
|
||||
field_path = role_dir / field_name
|
||||
with np.load(field_path) as z:
|
||||
required_keys = {"ux", "uy", selector_name}
|
||||
if not required_keys <= set(z.files):
|
||||
fail(f"missing field keys in {field_path}: {required_keys-set(z.files)}")
|
||||
ux, uy = np.array(z["ux"]), np.array(z["uy"])
|
||||
selector = np.array(z[selector_name])
|
||||
if ux.ndim != 3 or ux.shape != uy.shape or ux.shape[0] != selector.shape[0]:
|
||||
fail(f"invalid canonical field shapes in {field_path}: ux={ux.shape}, uy={uy.shape}, selector={selector.shape}")
|
||||
expected_count = 8 if field_name == "phase_fields.npz" else 5 if field_name == "event_fields.npz" else 1
|
||||
if ux.shape[0] != expected_count or selector.shape != (expected_count,):
|
||||
fail(f"unexpected retained field count in {field_path}: {ux.shape[0]}")
|
||||
if selector_value is not None and not np.isclose(selector[0], selector_value, atol=1e-12, rtol=0):
|
||||
fail(f"slot 0 selector in {field_path} is {selector[0]}, expected {selector_value}")
|
||||
if not np.isfinite(ux[0]).all() or not np.isfinite(uy[0]).all():
|
||||
fail(f"nonfinite field in {field_path} slot 0")
|
||||
omega = np.gradient(uy[0], axis=1) - np.gradient(ux[0], axis=0)
|
||||
return omega, sensors, units, field_path, selector_name, selector[0]
|
||||
|
||||
|
||||
def padded_limits(values):
|
||||
lo, hi = float(np.min(values)), float(np.max(values))
|
||||
if not np.isfinite([lo, hi]).all():
|
||||
fail("nonfinite sensor limits")
|
||||
span = hi - lo
|
||||
pad = 0.05 * span if span > 0 else max(abs(lo) * 0.05, 1e-12)
|
||||
return [lo - pad, hi + pad]
|
||||
|
||||
|
||||
def plot_group(stage: Path, data_root: Path, pipeline: str, group: str):
|
||||
roles = roles_for(data_root, pipeline, group)
|
||||
loaded = [load_role(path, pipeline, group) for _, path in roles]
|
||||
shapes = {x[0].shape for x in loaded}
|
||||
if len(shapes) != 1:
|
||||
fail(f"role field shapes differ for {pipeline}/{group}: {shapes}")
|
||||
limit = VORTICITY_LIMIT
|
||||
all_sensors = np.concatenate([x[1] for x in loaded], axis=0)
|
||||
xlim = padded_limits(all_sensors[:, [0, 2, 4]])
|
||||
ylim = padded_limits(all_sensors[:, [1, 3, 5]])
|
||||
ny, nx = next(iter(shapes))
|
||||
extent = (0, (nx - 1) / L0, 0, (ny - 1) / L0)
|
||||
fig, axes = plt.subplots(2, 3, figsize=(18, 8), constrained_layout=True)
|
||||
images = []
|
||||
for col, ((role, _), (omega, sensors, units, _, _, _)) in enumerate(zip(roles, loaded)):
|
||||
images.append(axes[0, col].imshow(omega, origin="lower", extent=extent, cmap="RdBu_r", vmin=-limit, vmax=limit, aspect="equal"))
|
||||
axes[0, col].set_title(f"{pipeline.upper()} / {group} — {role}")
|
||||
axes[0, col].set_xlabel("x/L0")
|
||||
axes[0, col].set_ylabel("y/L0")
|
||||
axes[0, col].set_xlim(extent[:2]); axes[0, col].set_ylim(extent[2:])
|
||||
for (u, v, label), color in zip(SENSOR_PAIRS, SENSOR_COLORS):
|
||||
axes[1, col].plot(sensors[:, u], sensors[:, v], color=color, lw=1.1, alpha=0.85, label=label)
|
||||
axes[1, col].axhline(0, color="0.35", lw=0.7); axes[1, col].axvline(0, color="0.35", lw=0.7)
|
||||
axes[1, col].grid(True, alpha=0.25)
|
||||
axes[1, col].set_xlim(xlim); axes[1, col].set_ylim(ylim)
|
||||
axes[1, col].set_xlabel(f"sensor u ({units})"); axes[1, col].set_ylabel(f"sensor v ({units})")
|
||||
axes[1, col].set_box_aspect(0.8)
|
||||
axes[1, col].legend(loc="best", frameon=False)
|
||||
cbar = fig.colorbar(images[0], ax=axes[0, :], orientation="vertical", shrink=0.92, pad=0.015)
|
||||
cbar.set_label(r"$\omega_z$ (lattice$^{-1}$)")
|
||||
output = stage / pipeline / f"{group}.png"
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
fig.savefig(output, dpi=160)
|
||||
plt.close(fig)
|
||||
return {
|
||||
"pipeline": pipeline, "group": group, "plot": f"{pipeline}/{group}.png",
|
||||
"roles": [{"role": role, "source": str(path), "field_file": str(item[3]),
|
||||
"field_slot": 0, "selector": item[4], "selector_value": float(item[5])}
|
||||
for (role, path), item in zip(roles, loaded)],
|
||||
"vorticity_limit": limit, "sensor_limits": {"u": xlim, "v": ylim},
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
here = Path(__file__).resolve().parent
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--data-root", type=Path, default=here / "data" / "reproduction")
|
||||
parser.add_argument("--output-root", type=Path, default=here / "data" / "reproduction_plots")
|
||||
args = parser.parse_args()
|
||||
data_root, output_root = args.data_root.resolve(), args.output_root.absolute()
|
||||
require_exact_groups(data_root)
|
||||
if output_root.is_symlink():
|
||||
fail(f"refusing symlink output root: {output_root}")
|
||||
output_root.parent.mkdir(parents=True, exist_ok=True)
|
||||
stage = Path(tempfile.mkdtemp(prefix=f".{output_root.name}.staging-", dir=output_root.parent))
|
||||
backup = output_root.with_name(f".{output_root.name}.old-{os.getpid()}")
|
||||
try:
|
||||
entries = [plot_group(stage, data_root, "v5", group) for group in sorted(V5_GROUPS)]
|
||||
entries += [plot_group(stage, data_root, "legacy", group) for group in LEGACY_GROUPS]
|
||||
if len(entries) != 30:
|
||||
fail(f"expected 30 plots, got {len(entries)}")
|
||||
(stage / "manifest.json").write_text(json.dumps({
|
||||
"schema": "drl-pinball-reproduction-plots-v1",
|
||||
"plot_count": 30,
|
||||
"vorticity_limit": VORTICITY_LIMIT,
|
||||
"vorticity_contract": "fixed symmetric [-0.001, +0.001] for every vorticity panel",
|
||||
"plots": entries,
|
||||
}, indent=2) + "\n")
|
||||
if output_root.exists():
|
||||
os.replace(output_root, backup)
|
||||
os.replace(stage, output_root)
|
||||
if backup.exists():
|
||||
shutil.rmtree(backup)
|
||||
except Exception:
|
||||
shutil.rmtree(stage, ignore_errors=True)
|
||||
if backup.exists() and not output_root.exists():
|
||||
os.replace(backup, output_root)
|
||||
raise
|
||||
print(f"wrote 30 plots and manifest to {output_root}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user