feat(SR): publish canonical analysis package
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>
This commit is contained in:
+433
@@ -0,0 +1,433 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user