Canonicalize V5 case identities and preserve the SR evidence chain while replacing ambiguous diagnostics with reproducible tables, phase-matched flow fields, and presentation-ready summaries. Co-authored-by: Cursor <cursoragent@cursor.com>
434 lines
25 KiB
Python
Executable File
434 lines
25 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Export a phase-matched Target/PPO/SR Kármán vorticity comparison.
|
|
|
|
CUDA-backed modules are imported only after argument validation. The exporter
|
|
uses the canonical Stage-3 environment and policy constructors for controlled
|
|
runs and mirrors ``build_karman_cloak_env`` exactly for the target-only run.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import platform
|
|
import subprocess
|
|
import sys
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any, Mapping, Sequence
|
|
|
|
import numpy as np
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[3]
|
|
SRC_ROOT = REPO_ROOT / "src"
|
|
for root in (REPO_ROOT, SRC_ROOT):
|
|
if str(root) not in sys.path:
|
|
sys.path.insert(0, str(root))
|
|
|
|
from SR_analysis.configs import FIFO_LEN, LEGACY_CFG_DIR, get_scene # noqa: E402
|
|
from SR_analysis.stage_3_validate import ( # noqa: E402
|
|
DATA_TYPE,
|
|
ValidationPlan,
|
|
build_karman_environment,
|
|
build_policy,
|
|
load_formula_pair,
|
|
prepare_plan,
|
|
)
|
|
from SR_analysis.utils.provenance import atomic_write_json, hash_file, hash_json # noqa: E402
|
|
|
|
STEM = "07_flow_field_comparison_karman_re100"
|
|
SCHEMA = "sr-flow-comparison-v2"
|
|
CANDIDATE_DTYPE = np.dtype(np.float32)
|
|
DEFAULT_PACKAGE = REPO_ROOT / "src/SR_analysis/results/runs/article2-plotting-package-20260721"
|
|
DEFAULT_FORMULAS = REPO_ROOT / "src/SR_analysis/results/runs/article-refit-karman-topology-a-20260718/formulas"
|
|
DEFAULT_ALIGNMENT = DEFAULT_PACKAGE / "phase_alignment.json"
|
|
D_LATTICE = 20.0
|
|
SENSOR_LAYOUT = ("upper_ux", "upper_uy", "center_ux", "center_uy", "lower_ux", "lower_uy")
|
|
|
|
|
|
def sha256_bytes(data: bytes) -> str:
|
|
return hashlib.sha256(data).hexdigest()
|
|
|
|
|
|
def git_sha() -> str | None:
|
|
try:
|
|
return subprocess.run(["git", "-C", str(REPO_ROOT), "rev-parse", "HEAD"], check=True, capture_output=True, text=True).stdout.strip()
|
|
except (OSError, subprocess.CalledProcessError):
|
|
return None
|
|
|
|
|
|
def wrapped_phase_difference(a: np.ndarray | float, b: np.ndarray | float) -> np.ndarray:
|
|
"""Signed shortest angular difference ``a-b`` in [-pi, pi]."""
|
|
delta = np.asarray(a, dtype=np.float64) - np.asarray(b, dtype=np.float64)
|
|
return np.arctan2(np.sin(delta), np.cos(delta))
|
|
|
|
|
|
def standardized_center_phase(trace: np.ndarray, start: int, stop: int) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
"""Return center-sensor phase from per-trajectory stable-window z scores."""
|
|
sensors = np.asarray(trace, dtype=np.float64)
|
|
if sensors.ndim != 2 or sensors.shape[1] < 4 or not 0 <= start < stop <= len(sensors):
|
|
raise ValueError("trace/window must provide center (ux,uy) channels")
|
|
center = sensors[start:stop, 2:4]
|
|
mean = center.mean(axis=0)
|
|
scale = center.std(axis=0, ddof=0)
|
|
if np.any(~np.isfinite(scale)) or np.any(scale <= np.finfo(float).eps):
|
|
raise ValueError("stable-window center-sensor scale is zero or non-finite")
|
|
z = (center - mean) / scale
|
|
return np.arctan2(z[:, 1], z[:, 0]), mean, scale
|
|
|
|
|
|
def select_joint_phase_match(
|
|
target_trace: np.ndarray,
|
|
ppo_trace: np.ndarray,
|
|
sr_trace: np.ndarray,
|
|
start: int,
|
|
stop: int,
|
|
*,
|
|
temporal_weight: float = 0.001,
|
|
) -> dict[str, Any]:
|
|
"""Jointly match downstream center-sensor limit-cycle phase.
|
|
|
|
Exhaustive stable-window search minimizes the two wrapped phase errors plus
|
|
``temporal_weight * (|i_ppo-i_target| + |i_sr-i_target|)``. Requiring the
|
|
same local phase direction rejects branch-reversed phase-portrait matches.
|
|
"""
|
|
if temporal_weight < 0:
|
|
raise ValueError("temporal_weight must be non-negative")
|
|
phases, means, scales, directions = [], {}, {}, []
|
|
for label, trace in (("Target", target_trace), ("PPO", ppo_trace), ("SR", sr_trace)):
|
|
theta, mean, scale = standardized_center_phase(trace, start, stop)
|
|
phases.append(theta); means[label] = mean.tolist(); scales[label] = scale.tolist()
|
|
directions.append(np.sign(np.gradient(np.unwrap(theta))))
|
|
best = None
|
|
for ti, target_index in enumerate(range(start, stop)):
|
|
for pi, ppo_index in enumerate(range(start, stop)):
|
|
if directions[1][pi] != directions[0][ti]:
|
|
continue
|
|
for si, sr_index in enumerate(range(start, stop)):
|
|
if directions[2][si] != directions[0][ti]:
|
|
continue
|
|
ppo_error = abs(float(wrapped_phase_difference(phases[1][pi], phases[0][ti])))
|
|
sr_error = abs(float(wrapped_phase_difference(phases[2][si], phases[0][ti])))
|
|
temporal_distance = abs(ppo_index-target_index) + abs(sr_index-target_index)
|
|
penalty = temporal_weight * temporal_distance
|
|
objective = ppo_error + sr_error + penalty
|
|
key = (objective, temporal_distance, target_index, ppo_index, sr_index)
|
|
if best is None or key < best[0]:
|
|
best = (key, ti, pi, si, ppo_error, sr_error, penalty)
|
|
if best is None:
|
|
raise ValueError("no same-direction phase match in stable window")
|
|
key, ti, pi, si, ppo_error, sr_error, penalty = best
|
|
indices = {"Target": start+ti, "PPO": start+pi, "SR": start+si}
|
|
return {
|
|
"indices": indices,
|
|
"phase_angles_rad": {"Target": float(phases[0][ti]), "PPO": float(phases[1][pi]), "SR": float(phases[2][si])},
|
|
"wrapped_angle_errors_rad": {"PPO": ppo_error, "SR": sr_error},
|
|
"local_phase_direction": {"Target": int(directions[0][ti]), "PPO": int(directions[1][pi]), "SR": int(directions[2][si])},
|
|
"stable_window_center_mean": means,
|
|
"stable_window_center_std": scales,
|
|
"objective_terms": {"angle_error_sum_rad": ppo_error+sr_error, "temporal_distance_samples": int(key[1]), "temporal_weight_rad_per_sample": temporal_weight, "temporal_penalty_rad": penalty, "objective": float(key[0])},
|
|
"candidate_count_per_trajectory": stop-start,
|
|
}
|
|
|
|
|
|
def orient_vorticity_xy_to_yx(omega_xy: np.ndarray, field_shape: Sequence[int]) -> np.ndarray:
|
|
"""Convert vorticity_from_ddf's (NX, NY) result to image (NY, NX)."""
|
|
nx, ny = map(int, field_shape[:2])
|
|
omega = np.asarray(omega_xy)
|
|
if omega.shape != (nx, ny):
|
|
raise ValueError(f"expected vorticity shape {(nx, ny)}, got {omega.shape}")
|
|
return omega.T.copy()
|
|
|
|
|
|
def crop_yx(field_yx: np.ndarray, xlim_d: Sequence[float], ylim_d: Sequence[float], *, d_lattice: float = D_LATTICE, center_y_lattice: float | None = None) -> tuple[np.ndarray, dict[str, Any]]:
|
|
"""Crop an image-oriented field using physical x/D and centered y/D."""
|
|
field = np.asarray(field_yx)
|
|
if field.ndim != 2:
|
|
raise ValueError("field must be 2-D in (y, x) order")
|
|
ny, nx = field.shape
|
|
cy = (ny - 1) / 2 if center_y_lattice is None else float(center_y_lattice)
|
|
x0 = max(0, int(np.ceil(float(xlim_d[0]) * d_lattice)))
|
|
x1 = min(nx, int(np.floor(float(xlim_d[1]) * d_lattice)) + 1)
|
|
y0 = max(0, int(np.ceil(cy + float(ylim_d[0]) * d_lattice)))
|
|
y1 = min(ny, int(np.floor(cy + float(ylim_d[1]) * d_lattice)) + 1)
|
|
if x0 >= x1 or y0 >= y1:
|
|
raise ValueError("requested crop does not intersect field")
|
|
extent = ((x0 / d_lattice), ((x1 - 1) / d_lattice), ((y0 - cy) / d_lattice), ((y1 - 1 - cy) / d_lattice))
|
|
return field[y0:y1, x0:x1].copy(), {"x_slice": [x0, x1], "y_slice": [y0, y1], "extent_xD_yD": list(extent)}
|
|
|
|
|
|
def manifest_artifacts(package_dir: Path, repo_root: Path = REPO_ROOT) -> list[dict[str, str]]:
|
|
suffixes = {".csv", ".png", ".pdf", ".md", ".json", ".npz"}
|
|
return [
|
|
{"path": str(path.relative_to(repo_root)), "sha256": hash_file(path)}
|
|
for path in sorted(package_dir.rglob("*"))
|
|
if path.is_file() and path.suffix.lower() in suffixes and path.name != "manifest.json"
|
|
]
|
|
|
|
|
|
def update_package_manifest(package_dir: Path, repo_root: Path = REPO_ROOT) -> None:
|
|
path = package_dir / "manifest.json"
|
|
old = json.loads(path.read_text(encoding="utf-8")) if path.is_file() else {}
|
|
summary = dict(old.get("summary", {}))
|
|
summary["publication_figures"] = max(5, int(summary.get("publication_figures", 0)))
|
|
summary["flow_field_npz"] = 1
|
|
summary["presentation_pages"] = 2
|
|
atomic_write_json(path, {"schema_version": "sr-plotting-package-v3", "source_policy": old.get("source_policy", "immutable article artifacts; no scientific refit"), "summary": summary, "artifacts": manifest_artifacts(package_dir, repo_root)})
|
|
|
|
|
|
def _runtime_cfd() -> tuple[Any, Any, Any]:
|
|
from LegacyCelerisLab import FlowField
|
|
from SR_analysis.utils.cfd_interface import load_legacy_configs, vorticity_from_ddf
|
|
return FlowField, load_legacy_configs, vorticity_from_ddf
|
|
|
|
|
|
def _candidate_index_array(start: int, stop: int) -> np.ndarray:
|
|
"""Return the exact trace indices represented by stable-window fields."""
|
|
if not 0 <= start < stop:
|
|
raise ValueError("candidate window must satisfy 0 <= start < stop")
|
|
return np.arange(start, stop, dtype=np.int64)
|
|
|
|
|
|
def select_exact_candidate_field(
|
|
candidates: np.ndarray,
|
|
candidate_trace_indices: np.ndarray,
|
|
selected_trace_index: int,
|
|
*,
|
|
expected_start: int,
|
|
expected_stop: int,
|
|
) -> tuple[np.ndarray, int]:
|
|
"""Select a field by its same-run trace index, rejecting mapping drift."""
|
|
fields = np.asarray(candidates)
|
|
indices = np.asarray(candidate_trace_indices)
|
|
expected = _candidate_index_array(expected_start, expected_stop)
|
|
if fields.ndim != 3 or fields.dtype != CANDIDATE_DTYPE:
|
|
raise ValueError("candidate fields must be a float32 (sample,y,x) array")
|
|
if indices.ndim != 1 or not np.issubdtype(indices.dtype, np.integer):
|
|
raise ValueError("candidate trace indices must be a one-dimensional integer array")
|
|
if fields.shape[0] != len(indices):
|
|
raise ValueError("candidate field/index counts differ")
|
|
if not np.array_equal(indices, expected):
|
|
raise ValueError("candidate trace-index mapping does not exactly cover the stable window")
|
|
matches = np.flatnonzero(indices == int(selected_trace_index))
|
|
if len(matches) != 1:
|
|
raise IndexError("selected trace index has no unique same-run candidate field")
|
|
candidate_index = int(matches[0])
|
|
if candidate_index != int(selected_trace_index) - expected_start:
|
|
raise AssertionError("candidate offset and trace index disagree")
|
|
return fields[candidate_index], candidate_index
|
|
|
|
|
|
def _allocate_candidates(field_shape: Sequence[int], start: int, stop: int) -> np.ndarray:
|
|
nx, ny = map(int, field_shape[:2])
|
|
return np.empty((stop - start, ny, nx), dtype=CANDIDATE_DTYPE)
|
|
|
|
|
|
def _store_candidate(
|
|
candidates: np.ndarray,
|
|
trace_index: int,
|
|
start: int,
|
|
omega_xy: np.ndarray,
|
|
field_shape: Sequence[int],
|
|
) -> None:
|
|
candidate_index = trace_index - start
|
|
if not 0 <= candidate_index < len(candidates):
|
|
raise IndexError("candidate trace index lies outside allocated stable window")
|
|
field = D_LATTICE * orient_vorticity_xy_to_yx(omega_xy, field_shape)
|
|
candidates[candidate_index] = np.asarray(field, dtype=CANDIDATE_DTYPE)
|
|
|
|
|
|
def _target_trace_and_candidates(
|
|
cfg: Mapping[str, Any], device: int, n_samples: int, candidate_start: int
|
|
) -> tuple[np.ndarray, np.ndarray, np.ndarray, tuple[int, int]]:
|
|
"""Run Target once and couple every stable trace sample to its field."""
|
|
FlowField, load_configs, vorticity_from_ddf = _runtime_cfd()
|
|
cuda_cfg, field_cfg = load_configs(LEGACY_CFG_DIR)
|
|
ff = FlowField(field_cfg._replace(viscosity=float(cfg["nu"])), cuda_cfg, device_id=device)
|
|
try:
|
|
cy = (ff.FIELD_SHAPE[1] - 1) / 2
|
|
ff.add_cylinder((10.0 * D_LATTICE, cy, 0.0), D_LATTICE)
|
|
for y_off in (2.0, 0.0, -2.0):
|
|
ff.add_sensor((40.0 * D_LATTICE, cy + y_off * D_LATTICE, 0.0), D_LATTICE / 4.0)
|
|
n_obj = ff.obs.size // 2
|
|
zero = np.zeros(n_obj, dtype=DATA_TYPE)
|
|
ff.run(int(4 * ff.FIELD_SHAPE[0] / float(cfg["u0"])), zero)
|
|
rows: list[np.ndarray] = []
|
|
candidates = _allocate_candidates(ff.FIELD_SHAPE, candidate_start, n_samples)
|
|
candidate_indices = _candidate_index_array(candidate_start, n_samples)
|
|
for index in range(n_samples):
|
|
ff.run(int(cfg["sample_interval"]), zero)
|
|
rows.append(ff.obs.copy()[2:8].astype(np.float64))
|
|
if index >= candidate_start:
|
|
_store_candidate(candidates, index, candidate_start, vorticity_from_ddf(ff, float(cfg["u0"])), ff.FIELD_SHAPE)
|
|
if len(rows) != n_samples or len(candidates) != n_samples - candidate_start:
|
|
raise AssertionError("Target trace/candidate collection is incomplete")
|
|
return np.asarray(rows), candidates, candidate_indices, tuple(map(int, ff.FIELD_SHAPE[:2]))
|
|
finally:
|
|
del ff
|
|
|
|
|
|
def _controlled_trace_and_candidates(
|
|
plan: ValidationPlan, device: int, n_samples: int, candidate_start: int
|
|
) -> tuple[np.ndarray, np.ndarray, np.ndarray, tuple[int, int]]:
|
|
"""Run one controller once and couple stable trace samples to fields."""
|
|
env = build_karman_environment(plan, device)
|
|
try:
|
|
policy = build_policy(plan)
|
|
raw = np.asarray(env.current_raw, dtype=np.float64)
|
|
rows: list[np.ndarray] = []
|
|
candidates = _allocate_candidates(env.ff.FIELD_SHAPE, candidate_start, n_samples)
|
|
candidate_indices = _candidate_index_array(candidate_start, n_samples)
|
|
_, _, vorticity_from_ddf = _runtime_cfd()
|
|
for index in range(n_samples):
|
|
omega, _, _ = policy.action(raw, index)
|
|
raw = env.step(omega)
|
|
policy.observe(omega)
|
|
rows.append(raw[:6].copy())
|
|
if index >= candidate_start:
|
|
_store_candidate(candidates, index, candidate_start, vorticity_from_ddf(env.ff, float(plan.cfg["u0"])), env.ff.FIELD_SHAPE)
|
|
if len(rows) != n_samples or len(candidates) != n_samples - candidate_start:
|
|
raise AssertionError("controlled trace/candidate collection is incomplete")
|
|
return np.asarray(rows), candidates, candidate_indices, tuple(map(int, env.ff.FIELD_SHAPE[:2]))
|
|
finally:
|
|
env.close()
|
|
|
|
|
|
def _make_plan(scene: str, mode: str, n_steps: int, pair: Any | None, model_device: str) -> ValidationPlan:
|
|
plan = prepare_plan(scene=scene, mode=mode, n_steps=n_steps, run_id="flow-comparison-in-memory", output_root=REPO_ROOT / ".flow-comparison-unused", formula_pair=pair)
|
|
return ValidationPlan(**{**plan.__dict__, "cfg": {**plan.cfg, "model_device": model_device}})
|
|
|
|
|
|
def _plot(fields: Mapping[str, np.ndarray], crop_meta: Mapping[str, Any], cfg: Mapping[str, Any], output_dir: Path, vmax: float) -> None:
|
|
import matplotlib
|
|
matplotlib.use("Agg")
|
|
import matplotlib.pyplot as plt
|
|
from matplotlib.patches import Circle
|
|
|
|
fig, axes = plt.subplots(1, 3, figsize=(14.5, 4.3), sharex=True, sharey=True, constrained_layout=True)
|
|
extent = crop_meta["extent_xD_yD"]
|
|
for ax, label in zip(axes, ("Target", "PPO", "SR")):
|
|
ax.imshow(fields[label], origin="lower", extent=extent, aspect="equal", cmap="RdBu_r", vmin=-vmax, vmax=vmax, interpolation="nearest")
|
|
geometry = [(10.0, 0.0, 1.0, "disturbance")] if label == "Target" else [
|
|
(10.0, 0.0, 1.0, "disturbance"),
|
|
(float(cfg["pinball_front_x"]), 0.0, 0.5, "front"),
|
|
(float(cfg["pinball_rear_x"]), 0.75, 0.5, "upper"),
|
|
(float(cfg["pinball_rear_x"]), -0.75, 0.5, "lower"),
|
|
]
|
|
for x, y, radius, name in geometry:
|
|
ax.add_patch(Circle((x, y), radius, facecolor="white", edgecolor="black", linewidth=0.8, zorder=4))
|
|
ax.scatter([40.0] * 3, [2.0, 0.0, -2.0], s=12, marker="x", color="black", linewidths=0.8, zorder=5)
|
|
ax.set_title(label)
|
|
ax.set_xlabel(r"$x/D$")
|
|
axes[0].set_ylabel(r"$y/D$")
|
|
for suffix in ("png", "pdf"):
|
|
fig.savefig(output_dir / f"{STEM}.{suffix}", dpi=300 if suffix == "png" else None)
|
|
plt.close(fig)
|
|
|
|
|
|
def build_parser() -> argparse.ArgumentParser:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--scene", default="karman_re100", choices=("karman_re100",), help="explicit canonical scene")
|
|
parser.add_argument("--package-dir", type=Path, default=DEFAULT_PACKAGE)
|
|
parser.add_argument("--alignment-metadata", type=Path, default=DEFAULT_ALIGNMENT)
|
|
parser.add_argument("--formula-front", type=Path, default=DEFAULT_FORMULAS / "joint_front.json")
|
|
parser.add_argument("--formula-rear", type=Path, default=DEFAULT_FORMULAS / "joint_rear_shared_upper.json")
|
|
parser.add_argument("--temporal-weight", type=float, default=0.001, help="phase objective penalty in radians per sample of temporal separation")
|
|
parser.add_argument("--device", type=int, default=0, help="logical CFD device after CUDA_VISIBLE_DEVICES masking")
|
|
parser.add_argument("--model-device", choices=("cpu",), default="cpu")
|
|
parser.add_argument("--xlim", nargs=2, type=float, default=(7.0, 48.0), metavar=("XMIN_D", "XMAX_D"))
|
|
parser.add_argument("--ylim", nargs=2, type=float, default=(-6.0, 6.0), metavar=("YMIN_D", "YMAX_D"))
|
|
parser.add_argument("--replace", action="store_true")
|
|
return parser
|
|
|
|
|
|
def main(argv: Sequence[str] | None = None) -> int:
|
|
args = build_parser().parse_args(argv)
|
|
package = args.package_dir.resolve()
|
|
outputs = [package / f"{STEM}.npz", package / f"{STEM}.json", package / "figures" / f"{STEM}.png", package / "figures" / f"{STEM}.pdf"]
|
|
existing = [p for p in outputs if p.exists()]
|
|
if existing and not args.replace:
|
|
raise FileExistsError("refusing to overwrite: " + ", ".join(map(str, existing)))
|
|
alignment = json.loads(args.alignment_metadata.read_text(encoding="utf-8"))
|
|
start, stop = int(alignment["target_index_start_inclusive"]), int(alignment["target_index_stop_exclusive"])
|
|
cfg = get_scene(args.scene)
|
|
if cfg["scene_id"] != "karman":
|
|
raise ValueError("only the Kármán environment is supported")
|
|
pair = load_formula_pair(args.formula_front, args.formula_rear)
|
|
n_samples = stop
|
|
ppo_plan = _make_plan(args.scene, "ppo", n_samples, None, args.model_device)
|
|
sr_plan = _make_plan(args.scene, "pysr", n_samples, pair, args.model_device)
|
|
|
|
# Each condition is initialized and run exactly once. The three float32
|
|
# stable-window buffers remain in host memory until joint phase selection.
|
|
target_trace, target_candidates, target_candidate_indices, shape = _target_trace_and_candidates(
|
|
cfg, args.device, n_samples, start
|
|
)
|
|
ppo_trace, ppo_candidates, ppo_candidate_indices, ppo_shape = _controlled_trace_and_candidates(
|
|
ppo_plan, args.device, n_samples, start
|
|
)
|
|
sr_trace, sr_candidates, sr_candidate_indices, sr_shape = _controlled_trace_and_candidates(
|
|
sr_plan, args.device, n_samples, start
|
|
)
|
|
if shape != ppo_shape or shape != sr_shape:
|
|
raise ValueError("field shapes differ between cases")
|
|
|
|
phase_match = select_joint_phase_match(target_trace, ppo_trace, sr_trace, start, stop, temporal_weight=args.temporal_weight)
|
|
selected_indices = phase_match["indices"]
|
|
candidate_sets = {
|
|
"Target": (target_candidates, target_candidate_indices),
|
|
"PPO": (ppo_candidates, ppo_candidate_indices),
|
|
"SR": (sr_candidates, sr_candidate_indices),
|
|
}
|
|
full: dict[str, np.ndarray] = {}
|
|
selected_candidate_indices: dict[str, int] = {}
|
|
for label, (candidates, trace_indices) in candidate_sets.items():
|
|
field, candidate_index = select_exact_candidate_field(
|
|
candidates, trace_indices, selected_indices[label], expected_start=start, expected_stop=stop
|
|
)
|
|
full[label] = field.copy()
|
|
selected_candidate_indices[label] = candidate_index
|
|
candidate_field_bytes = int(sum(candidates.nbytes for candidates, _ in candidate_sets.values()))
|
|
cropped: dict[str, np.ndarray] = {}
|
|
crop_meta = None
|
|
for label, field in full.items():
|
|
cropped[label], meta = crop_yx(field, args.xlim, args.ylim)
|
|
crop_meta = crop_meta or meta
|
|
if meta != crop_meta:
|
|
raise AssertionError("inconsistent crop metadata")
|
|
finite = np.concatenate([np.abs(value[np.isfinite(value)]) for value in cropped.values()])
|
|
if finite.size == 0:
|
|
raise FloatingPointError("captured fields contain no finite vorticity")
|
|
vmax = float(np.percentile(finite, 99.5))
|
|
if not np.isfinite(vmax) or vmax <= 0:
|
|
raise FloatingPointError("invalid shared color normalization")
|
|
|
|
package.mkdir(parents=True, exist_ok=True)
|
|
(package / "figures").mkdir(parents=True, exist_ok=True)
|
|
npz_path = outputs[0]
|
|
np.savez_compressed(npz_path, target_vorticity_yx=full["Target"], ppo_vorticity_yx=full["PPO"], sr_vorticity_yx=full["SR"], target_sensors=target_trace, ppo_sensors=ppo_trace, sr_sensors=sr_trace, selected_indices=np.asarray([selected_indices[x] for x in ("Target", "PPO", "SR")]), phase_angles_rad=np.asarray([phase_match["phase_angles_rad"][x] for x in ("Target", "PPO", "SR")]), wrapped_angle_errors_rad=np.asarray([0.0, phase_match["wrapped_angle_errors_rad"]["PPO"], phase_match["wrapped_angle_errors_rad"]["SR"]]), local_phase_direction=np.asarray([phase_match["local_phase_direction"][x] for x in ("Target", "PPO", "SR")]), selected_candidate_indices=np.asarray([selected_candidate_indices[x] for x in ("Target", "PPO", "SR")]), extent_xD_yD=np.asarray(crop_meta["extent_xD_yD"]), crop_x_slice=np.asarray(crop_meta["x_slice"]), crop_y_slice=np.asarray(crop_meta["y_slice"]), field_shape_xy=np.asarray(shape), sensor_layout=np.asarray(SENSOR_LAYOUT))
|
|
_plot(cropped, crop_meta, cfg, package / "figures", vmax)
|
|
|
|
dt = float(cfg["control_dt"])
|
|
model_path = Path(ppo_plan.model_path) if ppo_plan.model_path else None
|
|
metadata = {
|
|
"schema_version": SCHEMA, "scene": args.scene, "description": "single deterministic downstream center-sensor limit-cycle phase snapshot; not full-field identity or an ensemble",
|
|
"phase_matching": {"method": "one run per condition with same-sample stable-window field capture; exhaustive joint phase search", "phase_definition": "theta=atan2(z(center_uy), z(center_ux)); each trajectory standardized separately over stable window", "objective_formula": "|wrap(theta_PPO-theta_Target)| + |wrap(theta_SR-theta_Target)| + w*(|i_PPO-i_Target|+|i_SR-i_Target|)", "target_window": [start, stop], "direction_check": "all selected local unwrapped-phase derivatives have the same sign", "figure06_alignment_context_only": alignment, **phase_match},
|
|
"selected": {label: {"index": int(selected_indices[label]), "candidate_index": int(selected_candidate_indices[label]), "t_D_over_U0": float(selected_indices[label] * dt), "phase_angle_rad": float(phase_match["phase_angles_rad"][label]), "wrapped_phase_error_rad": 0.0 if label == "Target" else float(phase_match["wrapped_angle_errors_rad"][label])} for label in ("Target", "PPO", "SR")},
|
|
"sample_coupling": {"contract": "for every condition, the selected field and phase-diagnostic sensor values come from the same CFD sample in the same run", "candidate_trace_index_mapping": "candidate_index = trace_index - stable_window_start; exact contiguous mapping asserted before selection", "candidate_window": [start, stop], "candidate_count_per_condition": stop-start, "candidate_dtype": CANDIDATE_DTYPE.name, "candidate_field_shape_yx": [int(shape[1]), int(shape[0])], "candidate_host_memory_bytes": candidate_field_bytes, "candidate_host_memory_mib": candidate_field_bytes / (1024**2), "disk_contract": "only the three selected full fields and complete sensor traces are stored; stable-window candidates are memory-only"},
|
|
"field": {"quantity": "omega_z D/U0", "source": "vorticity_from_ddf", "source_shape_order": "(NX,NY)", "stored_shape_order": "(NY,NX)", "crop": crop_meta, "shared_symmetric_vmax_percentile": {"percentile": 99.5, "vmax": vmax}, "geometry_D": {"disturbance": [10.0, 0.0, 1.0], "pinball": [[cfg["pinball_front_x"], 0.0, 0.5], [cfg["pinball_rear_x"], 0.75, 0.5], [cfg["pinball_rear_x"], -0.75, 0.5]], "sensors": [[40.0, 2.0], [40.0, 0.0], [40.0, -2.0]]}},
|
|
"contracts": {"target": "exact build_karman_cloak_env geometry, stabilization, and sample interval; target has no pinball", "controlled": "stage_3_validate.prepare_plan/build_karman_environment/build_policy", "ppo_model_device": args.model_device, "cfd_logical_device": args.device},
|
|
"hashes": {"config": hash_json(cfg), "formula_front": hash_file(args.formula_front), "formula_rear": hash_file(args.formula_rear), "formula_pair": pair.pair_hash, "ppo_model": hash_file(model_path) if model_path and model_path.is_file() else None, "alignment_metadata": hash_file(args.alignment_metadata), "npz": hash_file(npz_path), "png": hash_file(outputs[2]), "pdf": hash_file(outputs[3]), "exporter": hash_file(Path(__file__))},
|
|
"paths": {"npz": str(npz_path.relative_to(REPO_ROOT)), "formula_front": str(args.formula_front.resolve()), "formula_rear": str(args.formula_rear.resolve()), "ppo_model": str(model_path) if model_path else None},
|
|
"provenance": {"git_sha": git_sha(), "command": " ".join(sys.argv), "created_utc": datetime.now(timezone.utc).isoformat(), "python": sys.version, "platform": platform.platform(), "numpy": np.__version__, "CUDA_VISIBLE_DEVICES": os.environ.get("CUDA_VISIBLE_DEVICES")},
|
|
}
|
|
metadata["record_hash"] = hash_json(metadata)
|
|
atomic_write_json(outputs[1], metadata)
|
|
update_package_manifest(package)
|
|
print(json.dumps({"outputs": [str(p) for p in outputs], "selected": metadata["selected"], "vmax": vmax}, indent=2))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|