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:
Frank14f
2026-08-02 21:59:18 +08:00
co-authored by Cursor
parent 213956d964
commit 2cf38b6cf9
82 changed files with 1058 additions and 639 deletions
@@ -1,5 +1,5 @@
{
"case_name": "ill_075L_sc",
"case_name": "ill_075L",
"scene_type": "illusion",
"seed": 43,
"SI": 400,
@@ -1,5 +1,5 @@
{
"case_name": "ill_15L_sc",
"case_name": "ill_15L",
"scene_type": "illusion",
"seed": 43,
"SI": 800,
@@ -1,5 +1,5 @@
{
"case_name": "ill_1L_sc",
"case_name": "ill_1L",
"scene_type": "illusion",
"seed": 43,
"SI": 600,
@@ -1,5 +1,5 @@
{
"case_name": "ill_2L_sc",
"case_name": "ill_2L",
"scene_type": "illusion",
"seed": 43,
"SI": 800,
@@ -1,5 +1,5 @@
{
"case_name": "kar_d075_sc",
"case_name": "kar_d075",
"scene_type": "karman",
"seed": 44,
"SI": 800,
@@ -1,5 +1,5 @@
{
"case_name": "kar_d15_sc",
"case_name": "kar_d15",
"scene_type": "karman",
"seed": 45,
"SI": 800,
@@ -1,5 +1,5 @@
{
"case_name": "kar_d2_sc",
"case_name": "kar_d2",
"scene_type": "karman",
"seed": 45,
"SI": 800,
@@ -1,5 +1,5 @@
{
"case_name": "kar_re100_sc",
"case_name": "kar_re100",
"scene_type": "karman",
"seed": 45,
"SI": 800,
+46 -71
View File
@@ -9,19 +9,19 @@ For each effective V5 case:
5. Creates SR_analysis/data/v5/{case}/ with controlled.npz, target.npz, etc.
Effective cases (with best seed and DTW):
- kar_re100_sc (seed45, dtw=0.918) — baseline
- kar_d075_sc (seed44, dtw=0.911) — vardist
- kar_d15_sc (seed45, dtw=0.892) — vardist
- kar_d2_sc (seed45, dtw=0.816) — vardist, borderline
- ill_075L_sc (seed43, dtw=0.807)
- ill_1L_sc (seed43, dtw=0.900)
- ill_15L_sc (seed43, dtw=0.906)
- ill_2L_sc (seed43, dtw=0.800)
- kar_re100 (seed45, dtw=0.918) — baseline
- kar_d075 (seed44, dtw=0.911) — vardist
- kar_d15 (seed45, dtw=0.892) — vardist
- kar_d2 (seed45, dtw=0.816) — vardist, borderline
- ill_075L (seed43, dtw=0.807)
- ill_1L (seed43, dtw=0.900)
- ill_15L (seed43, dtw=0.906)
- ill_2L (seed43, dtw=0.800)
Usage:
PYTHONPATH=src conda run -n pycuda_3_10 python -m SR_analysis.experiments.v5.bridge_v5
PYTHONPATH=src conda run -n pycuda_3_10 python -m SR_analysis.experiments.v5.bridge_v5 --include-failed
PYTHONPATH=src conda run -n pycuda_3_10 python -m SR_analysis.experiments.v5.bridge_v5 --cases kar_re100_sc,ill_1L_sc
PYTHONPATH=src conda run -n pycuda_3_10 python -m SR_analysis.experiments.v5.bridge_v5 --cases kar_re100,ill_1L
"""
from __future__ import annotations
@@ -32,6 +32,8 @@ from pathlib import Path
import numpy as np
from drl_pinball.case_registry import CASE_REGISTRY
_SR_ROOT = Path(__file__).resolve().parents[2]
_REPO = _SR_ROOT.parents[1]
_EVAL_OUT = _REPO / "src" / "drl_pinball" / "eval" / "output" / "train"
@@ -42,71 +44,44 @@ _SR_DATA = _SR_ROOT / "data" / "v5"
L0 = 20.0
U0 = 0.01
# Best seeds from eval metrics.json
EFFECTIVE_CASES = {
"kar_re100_sc": {"seed": 45, "dtw": 0.918, "scene_type": "karman",
"cal_name": "kar_re100",
"si": 800, "action_scale": 12.0, "action_bias": (0.0, 0.0, 0.0),
"s_dim": 12, "conv_len": 30,
"obs_slice": (2, 14), "n_objects": 7},
"kar_d075_sc": {"seed": 44, "dtw": 0.911, "scene_type": "karman",
"cal_name": "kar_d075_sc",
"si": 800, "action_scale": 12.0, "action_bias": (0.0, 0.0, 0.0),
"s_dim": 12, "conv_len": 30,
"obs_slice": (2, 14), "n_objects": 7},
"kar_d15_sc": {"seed": 45, "dtw": 0.892, "scene_type": "karman",
"cal_name": "kar_d15_sc",
"si": 800, "action_scale": 12.0, "action_bias": (0.0, 0.0, 0.0),
"s_dim": 12, "conv_len": 30,
"obs_slice": (2, 14), "n_objects": 7},
"kar_d2_sc": {"seed": 45, "dtw": 0.816, "scene_type": "karman",
"cal_name": "kar_d2_sc",
"si": 800, "action_scale": 12.0, "action_bias": (0.0, 0.0, 0.0),
"s_dim": 12, "conv_len": 30,
"obs_slice": (2, 14), "n_objects": 7},
"ill_075L_sc": {"seed": 43, "dtw": 0.807, "scene_type": "illusion",
"cal_name": "ill_075L",
"si": 400, "action_scale": 12.0, "action_bias": (0.0, 0.0, 0.0),
"s_dim": 14, "conv_len": 36,
"target_diam": 0.75,
"obs_slice": (0, 12), "n_objects": 6},
"ill_1L_sc": {"seed": 43, "dtw": 0.900, "scene_type": "illusion",
"cal_name": "ill_1L",
"si": 600, "action_scale": 12.0, "action_bias": (0.0, 0.0, 0.0),
"s_dim": 14, "conv_len": 36,
"target_diam": 1.0,
"obs_slice": (0, 12), "n_objects": 6},
"ill_15L_sc": {"seed": 43, "dtw": 0.906, "scene_type": "illusion",
"cal_name": "ill_15L",
"si": 800, "action_scale": 12.0, "action_bias": (0.0, 0.0, 0.0),
"s_dim": 14, "conv_len": 36,
"target_diam": 1.5,
"obs_slice": (0, 12), "n_objects": 6},
"ill_2L_sc": {"seed": 43, "dtw": 0.800, "scene_type": "illusion",
"cal_name": "ill_2L",
"si": 800, "action_scale": 12.0, "action_bias": (0.0, 0.0, 0.0),
"s_dim": 14, "conv_len": 36,
"target_diam": 2.0,
"obs_slice": (0, 12), "n_objects": 6},
# Evaluation attributes not supplied by the canonical registry.
_CASE_METRICS = {
"kar_re100": (45, 0.918, 30, (2, 14), 7),
"kar_d075": (44, 0.911, 30, (2, 14), 7),
"kar_d15": (45, 0.892, 30, (2, 14), 7),
"kar_d2": (45, 0.816, 30, (2, 14), 7),
"ill_075L": (43, 0.807, 36, (0, 12), 6),
"ill_1L": (43, 0.900, 36, (0, 12), 6),
"ill_15L": (43, 0.906, 36, (0, 12), 6),
"ill_2L": (43, 0.800, 36, (0, 12), 6),
"kar_re60": (43, 0.364, 30, (2, 14), 7),
"kar_re200": (43, 0.712, 30, (2, 14), 7),
"kar_re400": (43, 0.565, 30, (2, 14), 7),
}
FAILED_CASES = {
"kar_re60_sc": {"seed": 43, "dtw": 0.364, "scene_type": "karman",
"cal_name": "kar_re60_sc",
"si": 800, "action_scale": 12.0, "action_bias": (0.0, 0.0, 0.0),
"s_dim": 12, "conv_len": 30,
"obs_slice": (2, 14), "n_objects": 7},
"kar_re200_sc": {"seed": 43, "dtw": 0.712, "scene_type": "karman",
"cal_name": "kar_re200_sc",
"si": 500, "action_scale": 12.0, "action_bias": (0.0, 0.0, 0.0),
"s_dim": 12, "conv_len": 30,
"obs_slice": (2, 14), "n_objects": 7},
"kar_re400_sc": {"seed": 43, "dtw": 0.565, "scene_type": "karman",
"cal_name": "kar_re400_sc",
"si": 400, "action_scale": 12.0, "action_bias": (0.0, 0.0, 0.0),
"s_dim": 12, "conv_len": 30,
"obs_slice": (2, 14), "n_objects": 7},
def _bridge_config(case_id: str) -> dict:
case = CASE_REGISTRY[case_id]
seed, dtw, conv_len, obs_slice, n_objects = _CASE_METRICS[case_id]
cfg = {
"seed": seed, "dtw": dtw, "scene_type": case.scene_type,
"cal_name": case.calibration, "si": case.si,
"action_scale": 12.0, "action_bias": (0.0, 0.0, 0.0),
"s_dim": 14 if case.scene_type == "illusion" else 12,
"conv_len": conv_len, "obs_slice": obs_slice, "n_objects": n_objects,
}
if case.target_diam is not None:
cfg["target_diam"] = case.target_diam
return cfg
EFFECTIVE_CASES = {case_id: _bridge_config(case_id) for case_id in (
"kar_re100", "kar_d075", "kar_d15", "kar_d2",
"ill_075L", "ill_1L", "ill_15L", "ill_2L",
)}
FAILED_CASES = {case_id: _bridge_config(case_id) for case_id in (
"kar_re60", "kar_re200", "kar_re400",
)}
def bridge_case(case_name: str, cfg: dict, out_dir: Path) -> bool:
@@ -9,10 +9,10 @@ Supports per-scene individual and cross-scene joint fitting.
Usage:
# Per-scene (fast, niter=40)
PYTHONPATH=src conda run -n sr_env python -m SR_analysis.experiments.v5.stage_2_fit_v5 --scene kar_re100_sc
PYTHONPATH=src conda run -n sr_env python -m SR_analysis.experiments.v5.stage_2_fit_v5 --scene kar_re100
# Per-scene deep search (niter=120)
PYTHONPATH=src conda run -n sr_env python -m SR_analysis.experiments.v5.stage_2_fit_v5 --scene kar_re100_sc --deep
PYTHONPATH=src conda run -n sr_env python -m SR_analysis.experiments.v5.stage_2_fit_v5 --scene kar_re100 --deep
# Joint: all effective Karman cases
PYTHONPATH=src conda run -n sr_env python -m SR_analysis.experiments.v5.stage_2_fit_v5 --group karman_v5 --mode joint
@@ -21,7 +21,7 @@ Usage:
PYTHONPATH=src conda run -n sr_env python -m SR_analysis.experiments.v5.stage_2_fit_v5 --group illusion_v5 --mode joint
# Joint: mixed V5 + legacy (cross-solver)
PYTHONPATH=src conda run -n sr_env python -m SR_analysis.experiments.v5.stage_2_fit_v5 --scenes kar_re100_sc,kar_d075_sc,kar_d15_sc,karman_re100,karman_re200 --mode joint --output-label mixed_karman
PYTHONPATH=src conda run -n sr_env python -m SR_analysis.experiments.v5.stage_2_fit_v5 --scenes kar_re100,kar_d075,kar_d15,karman_re100,karman_re200 --mode joint --output-label mixed_karman
"""
from __future__ import annotations
@@ -46,6 +46,7 @@ from SR_analysis.utils.feature_builder import (
PHASE_STATE_KEYS, ILLUSION_PHASE_KEYS, CORE_FEAT_KEYS_V2, MU_FEAT_KEYS,
)
from SR_analysis.configs import get_scene # for legacy scenes
from drl_pinball.case_registry import CASE_REGISTRY
ALL_MU = list(MU_FEAT_KEYS) + ["mu_Cl_tot"]
PHYS = [k for k in CORE_FEAT_KEYS_V2 if not k.startswith(("aF_","aB_","aT_","daF","daB","daT"))]
@@ -68,15 +69,10 @@ V5_ILLUSION_CFG = {
"action_bias": V5_ACTION_BIAS,
}
# SI overrides per case
V5_SI_OVERRIDE = {
"ill_075L_sc": 400, "ill_1L_sc": 600, "ill_15L_sc": 800, "ill_2L_sc": 800,
"kar_re60_sc": 800, "kar_re200_sc": 500, "kar_re400_sc": 400,
}
# V5 case attributes come from drl_pinball.case_registry.
def is_v5_scene(name: str) -> bool:
return "_sc" in name or "_tr" in name
return name in CASE_REGISTRY
def load_controlled_v5(case_name: str):
@@ -97,8 +93,8 @@ def load_controlled_v5(case_name: str):
cfg["si_actual"] = v5_cfg.get("SI", 800)
cfg["scene_type"] = v5_cfg.get("scene_type", "")
# Apply SI override
si = V5_SI_OVERRIDE.get(case_name, cfg.get("si_actual", 800))
# Apply canonical training/evaluation SI.
si = CASE_REGISTRY[case_name].si
cfg["sample_interval"] = si
cfg["si_actual"] = si # for feature builder
@@ -148,8 +144,8 @@ FORMULA_DIR = _SR_ROOT / "results" / "formulas_v5"
os.makedirs(FORMULA_DIR, exist_ok=True)
FIT_GROUPS_V5 = {
"karman_v5": ["kar_re100_sc", "kar_d075_sc", "kar_d15_sc", "kar_d2_sc"],
"illusion_v5": ["ill_075L_sc", "ill_1L_sc", "ill_15L_sc", "ill_2L_sc"],
"karman_v5": ["kar_re100", "kar_d075", "kar_d15", "kar_d2"],
"illusion_v5": ["ill_075L", "ill_1L", "ill_15L", "ill_2L"],
}
+4 -1
View File
@@ -59,8 +59,11 @@ It adds, without refitting formulas:
- `term_contributions_sr_closed_loop_400.csv`: additive terms on 400-step SR trajectories, with exact next-action reconstruction;
- `ablation_summary.csv` and `scaling_summary.csv`;
- seven constant-rotation steady time-series exports;
- publication-starting-point PNG/PDF diagnostics under `figures/`;
- four publication PNG/PDF figures under `figures/` (performance/duration, pointwise generalization, steady calibration, and a phase-aligned Re100 example);
- publication tables under `tables/` for offline next-action RMSE and 40-step term deletion, replacing the former figure versions;
- package-level `README.md` and `phase_alignment.json` documenting scope and the exact example-window alignment;
- a SHA-256 manifest for the complete derived package.
- a GPU-generated `07_flow_field_comparison_karman_re100` exporter/output contract: target, PPO, and canonical SR vorticity are selected by one-run-per-condition exact same-sample center-sensor phase matching and share one physical crop/color scale. The retained field comparison is one matched snapshot per controller, not an ensemble or robustness result; no field artifact exists until the exporter is run.
The 400-step PPO similarities are `0.954856`, `0.946973`, `0.900280`, `0.845378` for Kármán Re50/100/200/400 and `0.976328`, `0.975728`, `0.926701` for Illusion 0.75L/1L/1.5L. All completed with finite telemetry. PPO inference runs on CPU while physical GPU 2 remains dedicated to PyCUDA CFD, avoiding PyTorch/PyCUDA context conflicts.
@@ -1,5 +1,5 @@
{
"scene": "ill_075L_sc_ill_1L_sc_ill_15L_sc_ill_2L_sc",
"scene": "ill_075L_ill_1L_ill_15L_ill_2L",
"channel": "front",
"output": "alpha",
"feature_keys": [
@@ -1,5 +1,5 @@
{
"scene": "ill_1L_sc",
"scene": "ill_1L",
"channel": "front",
"output": "alpha",
"feature_keys": [
@@ -1,5 +1,5 @@
{
"scene": "ill_1L_sc",
"scene": "ill_1L",
"channel": "top",
"output": "alpha",
"feature_keys": [
@@ -1,5 +1,5 @@
{
"scene": "kar_d075_sc",
"scene": "kar_d075",
"channel": "front",
"output": "alpha",
"feature_keys": [
@@ -1,5 +1,5 @@
{
"scene": "kar_d075_sc",
"scene": "kar_d075",
"channel": "top",
"output": "alpha",
"feature_keys": [
@@ -1,5 +1,5 @@
{
"scene": "kar_d15_sc",
"scene": "kar_d15",
"channel": "front",
"output": "alpha",
"feature_keys": [
@@ -1,5 +1,5 @@
{
"scene": "kar_d15_sc",
"scene": "kar_d15",
"channel": "top",
"output": "alpha",
"feature_keys": [
@@ -1,5 +1,5 @@
{
"scene": "kar_d2_sc",
"scene": "kar_d2",
"channel": "front",
"output": "alpha",
"feature_keys": [
@@ -1,5 +1,5 @@
{
"scene": "kar_d2_sc",
"scene": "kar_d2",
"channel": "top",
"output": "alpha",
"feature_keys": [
@@ -1,5 +1,5 @@
{
"scene": "kar_re100_sc",
"scene": "kar_re100",
"channel": "front",
"output": "alpha",
"feature_keys": [
@@ -1,5 +1,5 @@
{
"scene": "kar_re100_sc_kar_d075_sc_kar_d15_sc_kar_d2_sc",
"scene": "kar_re100_kar_d075_kar_d15_kar_d2",
"channel": "front",
"output": "alpha",
"feature_keys": [
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,13 @@
# SR publication plotting package
This package contains five generated publication figures, two replacement tables, and two PPT-ready 16:9 summary pages. All values are derived from frozen canonical artifacts without formula refitting.
- `01_training_case_performance`: panel (a) compares PPO and SR absolute closed-loop legacy DTW similarity at **400 control steps** for all seven training cases; panel (b) reports each controller's 400-minus-200 change. Here 200 and 400 are run durations in control steps, not Reynolds numbers.
- `02_pointwise_generalization`: 200-step SR results at training conditions and sampled unseen interpolation/extrapolation conditions. Each unseen point is one realization. Categories are unconnected because the evidence does not establish a continuous parameter law or statistical robustness.
- `03_steady_rotation_calibration`: the retained disturbance-free constant rear-rotation sweep diagnostic.
- `06_example_timeseries_karman_re100`: late-window phase portraits and PPO/SR actions over approximately three target cycles. Target indices 96145 (`t_D=38.4``58.0`) are used. PPO and SR segments at the same indices are circularly aligned to target `sensors_center_uy` by mean-centered normalized cross-correlation; deterministic roll lags are PPO=0 and SR=-4 samples. No target actions exist or are plotted.
- `07_flow_field_comparison_karman_re100` (generated by `tools/export_flow_comparison.py`): target wake, PPO-controlled pinball, and canonical SR-controlled pinball vorticity at one deterministic stable-cycle phase. Over indices [96,146), each trajectory standardizes downstream center-sensor (ux,uy) separately and defines theta=atan2(v_z,u_z). An exhaustive joint search minimizes the two wrapped angular errors plus 0.001 rad/sample times total temporal separation, with a same-direction branch check. Each condition runs once through index 145 while all 50 stable-window fields are held as float32 host-memory candidates (375 MiB total for three 512x1280 buffers); the selected field and phase diagnostic are therefore the exact same CFD sample from the same run. This matches center-sensor limit-cycle phase; it does not assert six-sensor state equality or exact full-field identity. It stores one compressed NPZ, JSON provenance, and PNG/PDF. This is a single snapshot, not an ensemble or uncertainty estimate.
- `tables/offline_action_rmse.*`: offline SR-vs-PPO next-action RMSE on causally aligned PPO-visited states. This is not closed-loop performance.
- `tables/term_deletion.*`: actual 40-step closed-loop deletion results, canonical formula identities, same-window parent comparisons, and aggregate mean/min summaries.
The `presentation/` directory contains two white-background 16:9 PNG/PDF pages and its source-artifact README. PNG and PDF files share each retained figure stem. `phase_alignment.json` records the exact Figure 06 selection/alignment contract. `manifest.json` hashes every package CSV, Markdown, JSON/NPZ metadata or field artifact, and publication figure.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 428 KiB

After

Width:  |  Height:  |  Size: 736 KiB

File diff suppressed because one or more lines are too long
@@ -0,0 +1,19 @@
{
"scene": "karman_re100",
"source_package": "article2-timeseries-csv-20260720",
"target_index_start_inclusive": 96,
"target_index_stop_exclusive": 146,
"target_t_D_start": 38.4,
"target_t_D_end": 58.0,
"samples": 50,
"displayed_target_cycles": 3,
"alignment_signal": "sensors_center_uy",
"alignment_method": "mean-centered normalized circular cross-correlation; smallest maximizing roll selected",
"roll_lags_samples": {
"Target": 0,
"PPO": 0,
"SR": -4
},
"control_dt_D_over_U0": 0.4,
"target_actions_plotted": false
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 293 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 336 KiB

@@ -0,0 +1,10 @@
# SR presentation graphics
- `01_sr_method_and_formulas`: a 16:9 methodology-to-formula summary. Formula coefficients and expressions are read dynamically from the canonical Kármán and Illusion JSON artifacts. The page states the front odd projection, lower-cylinder symmetry map, and evidence-bounded physical interpretation.
- `02_sr_quantitative_evidence`: a 16:9 summary of frozen closed-loop evidence: 400-control-step SR/PPO training-case ranges and means, 200-step pointwise generalization ranges, and 40-step parent-relative term-deletion effects. It deliberately excludes offline imitation RMSE from article-performance claims.
Reproduce both PNG and PDF pages with:
`conda run -n base python src/SR_analysis/tools/plot_sr_presentation.py`
Sources: canonical formula JSONs in `article-refit-{karman,illusion}-topology-a-20260718`; 400-step SR and PPO DTW convergence CSVs; `article2-generalization-summary-20260720/generalization.csv`; and `tables/term_deletion.csv`. All similarities use `legacy_dtw_v1_abs_n_unclipped`. Windows and limitations are printed on the pages.
@@ -0,0 +1,8 @@
objective,training_case,n_causally_aligned_states,rmse_alpha_front,rmse_alpha_upper,rmse_alpha_lower,aggregate_rmse_alpha
karman,karman_re50,197,1.73860463,1.39458231,1.13278257,1.44346830
karman,karman_re100,197,0.51356311,1.24024221,1.32638969,1.08953467
karman,karman_re200,197,1.47993917,2.29449643,2.86751651,2.28601434
karman,karman_re400,197,0.40632824,2.55717318,2.72429918,2.16994956
illusion,illusion_0.75L,197,0.82228198,1.77041216,1.43962577,1.40036058
illusion,illusion_1L,197,0.85035894,1.20967232,0.58682215,0.91847295
illusion,illusion_1.5L,197,3.20534050,2.22584755,1.74754340,2.46863748
1 objective training_case n_causally_aligned_states rmse_alpha_front rmse_alpha_upper rmse_alpha_lower aggregate_rmse_alpha
2 karman karman_re50 197 1.73860463 1.39458231 1.13278257 1.44346830
3 karman karman_re100 197 0.51356311 1.24024221 1.32638969 1.08953467
4 karman karman_re200 197 1.47993917 2.29449643 2.86751651 2.28601434
5 karman karman_re400 197 0.40632824 2.55717318 2.72429918 2.16994956
6 illusion illusion_0.75L 197 0.82228198 1.77041216 1.43962577 1.40036058
7 illusion illusion_1L 197 0.85035894 1.20967232 0.58682215 0.91847295
8 illusion illusion_1.5L 197 3.20534050 2.22584755 1.74754340 2.46863748
@@ -0,0 +1,15 @@
# Offline SR-vs-PPO next-action RMSE
RMSE is evaluated on causally aligned PPO-visited states: each post-action state predicts the next PPO action. This is an offline imitation diagnostic, not closed-loop performance.
| Objective | Training case | States | Front RMSE | Upper RMSE | Lower RMSE | Aggregate RMSE |
|---|---|---:|---:|---:|---:|---:|
| karman | karman_re50 | 197 | 1.73860463 | 1.39458231 | 1.13278257 | 1.44346830 |
| karman | karman_re100 | 197 | 0.51356311 | 1.24024221 | 1.32638969 | 1.08953467 |
| karman | karman_re200 | 197 | 1.47993917 | 2.29449643 | 2.86751651 | 2.28601434 |
| karman | karman_re400 | 197 | 0.40632824 | 2.55717318 | 2.72429918 | 2.16994956 |
| illusion | illusion_0.75L | 197 | 0.82228198 | 1.77041216 | 1.43962577 | 1.40036058 |
| illusion | illusion_1L | 197 | 0.85035894 | 1.20967232 | 0.58682215 | 0.91847295 |
| illusion | illusion_1.5L | 197 | 3.20534050 | 2.22584755 | 1.74754340 | 2.46863748 |
All action errors are in dimensionless surface-speed α. Aggregate RMSE pools the three action channels and all aligned states within a case.
@@ -0,0 +1,25 @@
objective,head,variant,parent_formula,deleted_term,retained_formula,case,steps,legacy_dtw,parent_legacy_dtw_40_step,delta_vs_parent_40_step,variant_mean,variant_min
karman,front odd projection,k_front0,-0.3813914338074549 * Cd_rear_a,-0.3813914338074549*Cd_rear_a,0,karman_re50,40,0.93936105,0.94588134,-0.00652029,0.88656666,0.81328922
karman,front odd projection,k_front0,-0.3813914338074549 * Cd_rear_a,-0.3813914338074549*Cd_rear_a,0,karman_re100,40,0.91218229,0.91160360,+0.00057869,0.88656666,0.81328922
karman,front odd projection,k_front0,-0.3813914338074549 * Cd_rear_a,-0.3813914338074549*Cd_rear_a,0,karman_re200,40,0.88143408,0.88661335,-0.00517927,0.88656666,0.81328922
karman,front odd projection,k_front0,-0.3813914338074549 * Cd_rear_a,-0.3813914338074549*Cd_rear_a,0,karman_re400,40,0.81328922,0.83848301,-0.02519379,0.88656666,0.81328922
karman,upper/lower shared-symmetry rear,k_rear0,1.3077817865589976 * Cl_rear_s - 3.431208680510616,1.3077817865589976*Cl_rear_s,-3.431208680510616,karman_re50,40,0.91712633,0.94588134,-0.02875501,0.85046363,0.78953465
karman,upper/lower shared-symmetry rear,k_rear0,1.3077817865589976 * Cl_rear_s - 3.431208680510616,1.3077817865589976*Cl_rear_s,-3.431208680510616,karman_re100,40,0.86994335,0.91160360,-0.04166025,0.85046363,0.78953465
karman,upper/lower shared-symmetry rear,k_rear0,1.3077817865589976 * Cl_rear_s - 3.431208680510616,1.3077817865589976*Cl_rear_s,-3.431208680510616,karman_re200,40,0.82525019,0.88661335,-0.06136315,0.85046363,0.78953465
karman,upper/lower shared-symmetry rear,k_rear0,1.3077817865589976 * Cl_rear_s - 3.431208680510616,1.3077817865589976*Cl_rear_s,-3.431208680510616,karman_re400,40,0.78953465,0.83848301,-0.04894836,0.85046363,0.78953465
karman,upper/lower shared-symmetry rear,k_rear1,1.3077817865589976 * Cl_rear_s - 3.431208680510616,-3.431208680510616,1.3077817865589976*Cl_rear_s,karman_re50,40,0.83934413,0.94588134,-0.10653720,0.75314220,0.64740091
karman,upper/lower shared-symmetry rear,k_rear1,1.3077817865589976 * Cl_rear_s - 3.431208680510616,-3.431208680510616,1.3077817865589976*Cl_rear_s,karman_re100,40,0.81773500,0.91160360,-0.09386860,0.75314220,0.64740091
karman,upper/lower shared-symmetry rear,k_rear1,1.3077817865589976 * Cl_rear_s - 3.431208680510616,-3.431208680510616,1.3077817865589976*Cl_rear_s,karman_re200,40,0.70808876,0.88661335,-0.17852458,0.75314220,0.64740091
karman,upper/lower shared-symmetry rear,k_rear1,1.3077817865589976 * Cl_rear_s - 3.431208680510616,-3.431208680510616,1.3077817865589976*Cl_rear_s,karman_re400,40,0.64740091,0.83848301,-0.19108210,0.75314220,0.64740091
illusion,front odd projection,i_front0,-1.8266041890847688 * Cd_rear_a + 2.064492712013321 * Cl_F,-1.8266041890847688*Cd_rear_a,2.064492712013321*Cl_F,illusion_0.75L,40,0.93937493,0.95545614,-0.01608120,0.91931368,0.88508469
illusion,front odd projection,i_front0,-1.8266041890847688 * Cd_rear_a + 2.064492712013321 * Cl_F,-1.8266041890847688*Cd_rear_a,2.064492712013321*Cl_F,illusion_1L,40,0.93348141,0.94330800,-0.00982659,0.91931368,0.88508469
illusion,front odd projection,i_front0,-1.8266041890847688 * Cd_rear_a + 2.064492712013321 * Cl_F,-1.8266041890847688*Cd_rear_a,2.064492712013321*Cl_F,illusion_1.5L,40,0.88508469,0.87939766,+0.00568703,0.91931368,0.88508469
illusion,front odd projection,i_front1,-1.8266041890847688 * Cd_rear_a + 2.064492712013321 * Cl_F,2.064492712013321*Cl_F,-1.8266041890847688*Cd_rear_a,illusion_0.75L,40,0.96006748,0.95545614,+0.00461134,0.90788838,0.83656314
illusion,front odd projection,i_front1,-1.8266041890847688 * Cd_rear_a + 2.064492712013321 * Cl_F,2.064492712013321*Cl_F,-1.8266041890847688*Cd_rear_a,illusion_1L,40,0.92703454,0.94330800,-0.01627347,0.90788838,0.83656314
illusion,front odd projection,i_front1,-1.8266041890847688 * Cd_rear_a + 2.064492712013321 * Cl_F,2.064492712013321*Cl_F,-1.8266041890847688*Cd_rear_a,illusion_1.5L,40,0.83656314,0.87939766,-0.04283452,0.90788838,0.83656314
illusion,upper/lower shared-symmetry rear,i_rear0,1.2544396437730243 * Cd_rear_a - 1.5280742226055937 * Cl_F,1.2544396437730243*Cd_rear_a,-1.5280742226055937*Cl_F,illusion_0.75L,40,0.95448757,0.95545614,-0.00096857,0.92631380,0.88760304
illusion,upper/lower shared-symmetry rear,i_rear0,1.2544396437730243 * Cd_rear_a - 1.5280742226055937 * Cl_F,1.2544396437730243*Cd_rear_a,-1.5280742226055937*Cl_F,illusion_1L,40,0.93685081,0.94330800,-0.00645719,0.92631380,0.88760304
illusion,upper/lower shared-symmetry rear,i_rear0,1.2544396437730243 * Cd_rear_a - 1.5280742226055937 * Cl_F,1.2544396437730243*Cd_rear_a,-1.5280742226055937*Cl_F,illusion_1.5L,40,0.88760304,0.87939766,+0.00820537,0.92631380,0.88760304
illusion,upper/lower shared-symmetry rear,i_rear1,1.2544396437730243 * Cd_rear_a - 1.5280742226055937 * Cl_F,-1.5280742226055937*Cl_F,1.2544396437730243*Cd_rear_a,illusion_0.75L,40,0.95464065,0.95545614,-0.00081549,0.91197030,0.85081581
illusion,upper/lower shared-symmetry rear,i_rear1,1.2544396437730243 * Cd_rear_a - 1.5280742226055937 * Cl_F,-1.5280742226055937*Cl_F,1.2544396437730243*Cd_rear_a,illusion_1L,40,0.93045444,0.94330800,-0.01285357,0.91197030,0.85081581
illusion,upper/lower shared-symmetry rear,i_rear1,1.2544396437730243 * Cd_rear_a - 1.5280742226055937 * Cl_F,-1.5280742226055937*Cl_F,1.2544396437730243*Cd_rear_a,illusion_1.5L,40,0.85081581,0.87939766,-0.02858185,0.91197030,0.85081581
1 objective head variant parent_formula deleted_term retained_formula case steps legacy_dtw parent_legacy_dtw_40_step delta_vs_parent_40_step variant_mean variant_min
2 karman front odd projection k_front0 -0.3813914338074549 * Cd_rear_a -0.3813914338074549*Cd_rear_a 0 karman_re50 40 0.93936105 0.94588134 -0.00652029 0.88656666 0.81328922
3 karman front odd projection k_front0 -0.3813914338074549 * Cd_rear_a -0.3813914338074549*Cd_rear_a 0 karman_re100 40 0.91218229 0.91160360 +0.00057869 0.88656666 0.81328922
4 karman front odd projection k_front0 -0.3813914338074549 * Cd_rear_a -0.3813914338074549*Cd_rear_a 0 karman_re200 40 0.88143408 0.88661335 -0.00517927 0.88656666 0.81328922
5 karman front odd projection k_front0 -0.3813914338074549 * Cd_rear_a -0.3813914338074549*Cd_rear_a 0 karman_re400 40 0.81328922 0.83848301 -0.02519379 0.88656666 0.81328922
6 karman upper/lower shared-symmetry rear k_rear0 1.3077817865589976 * Cl_rear_s - 3.431208680510616 1.3077817865589976*Cl_rear_s -3.431208680510616 karman_re50 40 0.91712633 0.94588134 -0.02875501 0.85046363 0.78953465
7 karman upper/lower shared-symmetry rear k_rear0 1.3077817865589976 * Cl_rear_s - 3.431208680510616 1.3077817865589976*Cl_rear_s -3.431208680510616 karman_re100 40 0.86994335 0.91160360 -0.04166025 0.85046363 0.78953465
8 karman upper/lower shared-symmetry rear k_rear0 1.3077817865589976 * Cl_rear_s - 3.431208680510616 1.3077817865589976*Cl_rear_s -3.431208680510616 karman_re200 40 0.82525019 0.88661335 -0.06136315 0.85046363 0.78953465
9 karman upper/lower shared-symmetry rear k_rear0 1.3077817865589976 * Cl_rear_s - 3.431208680510616 1.3077817865589976*Cl_rear_s -3.431208680510616 karman_re400 40 0.78953465 0.83848301 -0.04894836 0.85046363 0.78953465
10 karman upper/lower shared-symmetry rear k_rear1 1.3077817865589976 * Cl_rear_s - 3.431208680510616 -3.431208680510616 1.3077817865589976*Cl_rear_s karman_re50 40 0.83934413 0.94588134 -0.10653720 0.75314220 0.64740091
11 karman upper/lower shared-symmetry rear k_rear1 1.3077817865589976 * Cl_rear_s - 3.431208680510616 -3.431208680510616 1.3077817865589976*Cl_rear_s karman_re100 40 0.81773500 0.91160360 -0.09386860 0.75314220 0.64740091
12 karman upper/lower shared-symmetry rear k_rear1 1.3077817865589976 * Cl_rear_s - 3.431208680510616 -3.431208680510616 1.3077817865589976*Cl_rear_s karman_re200 40 0.70808876 0.88661335 -0.17852458 0.75314220 0.64740091
13 karman upper/lower shared-symmetry rear k_rear1 1.3077817865589976 * Cl_rear_s - 3.431208680510616 -3.431208680510616 1.3077817865589976*Cl_rear_s karman_re400 40 0.64740091 0.83848301 -0.19108210 0.75314220 0.64740091
14 illusion front odd projection i_front0 -1.8266041890847688 * Cd_rear_a + 2.064492712013321 * Cl_F -1.8266041890847688*Cd_rear_a 2.064492712013321*Cl_F illusion_0.75L 40 0.93937493 0.95545614 -0.01608120 0.91931368 0.88508469
15 illusion front odd projection i_front0 -1.8266041890847688 * Cd_rear_a + 2.064492712013321 * Cl_F -1.8266041890847688*Cd_rear_a 2.064492712013321*Cl_F illusion_1L 40 0.93348141 0.94330800 -0.00982659 0.91931368 0.88508469
16 illusion front odd projection i_front0 -1.8266041890847688 * Cd_rear_a + 2.064492712013321 * Cl_F -1.8266041890847688*Cd_rear_a 2.064492712013321*Cl_F illusion_1.5L 40 0.88508469 0.87939766 +0.00568703 0.91931368 0.88508469
17 illusion front odd projection i_front1 -1.8266041890847688 * Cd_rear_a + 2.064492712013321 * Cl_F 2.064492712013321*Cl_F -1.8266041890847688*Cd_rear_a illusion_0.75L 40 0.96006748 0.95545614 +0.00461134 0.90788838 0.83656314
18 illusion front odd projection i_front1 -1.8266041890847688 * Cd_rear_a + 2.064492712013321 * Cl_F 2.064492712013321*Cl_F -1.8266041890847688*Cd_rear_a illusion_1L 40 0.92703454 0.94330800 -0.01627347 0.90788838 0.83656314
19 illusion front odd projection i_front1 -1.8266041890847688 * Cd_rear_a + 2.064492712013321 * Cl_F 2.064492712013321*Cl_F -1.8266041890847688*Cd_rear_a illusion_1.5L 40 0.83656314 0.87939766 -0.04283452 0.90788838 0.83656314
20 illusion upper/lower shared-symmetry rear i_rear0 1.2544396437730243 * Cd_rear_a - 1.5280742226055937 * Cl_F 1.2544396437730243*Cd_rear_a -1.5280742226055937*Cl_F illusion_0.75L 40 0.95448757 0.95545614 -0.00096857 0.92631380 0.88760304
21 illusion upper/lower shared-symmetry rear i_rear0 1.2544396437730243 * Cd_rear_a - 1.5280742226055937 * Cl_F 1.2544396437730243*Cd_rear_a -1.5280742226055937*Cl_F illusion_1L 40 0.93685081 0.94330800 -0.00645719 0.92631380 0.88760304
22 illusion upper/lower shared-symmetry rear i_rear0 1.2544396437730243 * Cd_rear_a - 1.5280742226055937 * Cl_F 1.2544396437730243*Cd_rear_a -1.5280742226055937*Cl_F illusion_1.5L 40 0.88760304 0.87939766 +0.00820537 0.92631380 0.88760304
23 illusion upper/lower shared-symmetry rear i_rear1 1.2544396437730243 * Cd_rear_a - 1.5280742226055937 * Cl_F -1.5280742226055937*Cl_F 1.2544396437730243*Cd_rear_a illusion_0.75L 40 0.95464065 0.95545614 -0.00081549 0.91197030 0.85081581
24 illusion upper/lower shared-symmetry rear i_rear1 1.2544396437730243 * Cd_rear_a - 1.5280742226055937 * Cl_F -1.5280742226055937*Cl_F 1.2544396437730243*Cd_rear_a illusion_1L 40 0.93045444 0.94330800 -0.01285357 0.91197030 0.85081581
25 illusion upper/lower shared-symmetry rear i_rear1 1.2544396437730243 * Cd_rear_a - 1.5280742226055937 * Cl_F -1.5280742226055937*Cl_F 1.2544396437730243*Cd_rear_a illusion_1.5L 40 0.85081581 0.87939766 -0.02858185 0.91197030 0.85081581
@@ -0,0 +1,34 @@
# Forty-step closed-loop term deletion
Every comparison uses the same 40-control-step window and the legacy DTW metric. Parent values come from the scientifically comparable parent L2 runs; deltas are deletion minus parent. Formula identities are read from the canonical parent and deletion JSON artifacts.
Front formulas are scalar generators deployed through the odd projection `α_F(x) = ½[f(x) f(Gx)]`. Rear formulas define the upper action; the lower action is mapped by `α_L(x) = −α_U(Gx)`.
| Objective | Head | Variant | Parent formula | Deleted term | Retained formula | Case | DTW | Parent DTW | Δ | Mean | Min |
|---|---|---|---|---|---|---|---:|---:|---:|---:|---:|
| karman | front odd projection | k_front0 | `-0.3813914338074549 · Cd_rear_a` | `-0.3813914338074549·Cd_rear_a` | `0` | karman_re50 | 0.93936105 | 0.94588134 | -0.00652029 | 0.88656666 | 0.81328922 |
| karman | front odd projection | k_front0 | `-0.3813914338074549 · Cd_rear_a` | `-0.3813914338074549·Cd_rear_a` | `0` | karman_re100 | 0.91218229 | 0.91160360 | +0.00057869 | 0.88656666 | 0.81328922 |
| karman | front odd projection | k_front0 | `-0.3813914338074549 · Cd_rear_a` | `-0.3813914338074549·Cd_rear_a` | `0` | karman_re200 | 0.88143408 | 0.88661335 | -0.00517927 | 0.88656666 | 0.81328922 |
| karman | front odd projection | k_front0 | `-0.3813914338074549 · Cd_rear_a` | `-0.3813914338074549·Cd_rear_a` | `0` | karman_re400 | 0.81328922 | 0.83848301 | -0.02519379 | 0.88656666 | 0.81328922 |
| karman | upper/lower shared-symmetry rear | k_rear0 | `1.3077817865589976 · Cl_rear_s - 3.431208680510616` | `1.3077817865589976·Cl_rear_s` | `-3.431208680510616` | karman_re50 | 0.91712633 | 0.94588134 | -0.02875501 | 0.85046363 | 0.78953465 |
| karman | upper/lower shared-symmetry rear | k_rear0 | `1.3077817865589976 · Cl_rear_s - 3.431208680510616` | `1.3077817865589976·Cl_rear_s` | `-3.431208680510616` | karman_re100 | 0.86994335 | 0.91160360 | -0.04166025 | 0.85046363 | 0.78953465 |
| karman | upper/lower shared-symmetry rear | k_rear0 | `1.3077817865589976 · Cl_rear_s - 3.431208680510616` | `1.3077817865589976·Cl_rear_s` | `-3.431208680510616` | karman_re200 | 0.82525019 | 0.88661335 | -0.06136315 | 0.85046363 | 0.78953465 |
| karman | upper/lower shared-symmetry rear | k_rear0 | `1.3077817865589976 · Cl_rear_s - 3.431208680510616` | `1.3077817865589976·Cl_rear_s` | `-3.431208680510616` | karman_re400 | 0.78953465 | 0.83848301 | -0.04894836 | 0.85046363 | 0.78953465 |
| karman | upper/lower shared-symmetry rear | k_rear1 | `1.3077817865589976 · Cl_rear_s - 3.431208680510616` | `-3.431208680510616` | `1.3077817865589976·Cl_rear_s` | karman_re50 | 0.83934413 | 0.94588134 | -0.10653720 | 0.75314220 | 0.64740091 |
| karman | upper/lower shared-symmetry rear | k_rear1 | `1.3077817865589976 · Cl_rear_s - 3.431208680510616` | `-3.431208680510616` | `1.3077817865589976·Cl_rear_s` | karman_re100 | 0.81773500 | 0.91160360 | -0.09386860 | 0.75314220 | 0.64740091 |
| karman | upper/lower shared-symmetry rear | k_rear1 | `1.3077817865589976 · Cl_rear_s - 3.431208680510616` | `-3.431208680510616` | `1.3077817865589976·Cl_rear_s` | karman_re200 | 0.70808876 | 0.88661335 | -0.17852458 | 0.75314220 | 0.64740091 |
| karman | upper/lower shared-symmetry rear | k_rear1 | `1.3077817865589976 · Cl_rear_s - 3.431208680510616` | `-3.431208680510616` | `1.3077817865589976·Cl_rear_s` | karman_re400 | 0.64740091 | 0.83848301 | -0.19108210 | 0.75314220 | 0.64740091 |
| illusion | front odd projection | i_front0 | `-1.8266041890847688 · Cd_rear_a + 2.064492712013321 · Cl_F` | `-1.8266041890847688·Cd_rear_a` | `2.064492712013321·Cl_F` | illusion_0.75L | 0.93937493 | 0.95545614 | -0.01608120 | 0.91931368 | 0.88508469 |
| illusion | front odd projection | i_front0 | `-1.8266041890847688 · Cd_rear_a + 2.064492712013321 · Cl_F` | `-1.8266041890847688·Cd_rear_a` | `2.064492712013321·Cl_F` | illusion_1L | 0.93348141 | 0.94330800 | -0.00982659 | 0.91931368 | 0.88508469 |
| illusion | front odd projection | i_front0 | `-1.8266041890847688 · Cd_rear_a + 2.064492712013321 · Cl_F` | `-1.8266041890847688·Cd_rear_a` | `2.064492712013321·Cl_F` | illusion_1.5L | 0.88508469 | 0.87939766 | +0.00568703 | 0.91931368 | 0.88508469 |
| illusion | front odd projection | i_front1 | `-1.8266041890847688 · Cd_rear_a + 2.064492712013321 · Cl_F` | `2.064492712013321·Cl_F` | `-1.8266041890847688·Cd_rear_a` | illusion_0.75L | 0.96006748 | 0.95545614 | +0.00461134 | 0.90788838 | 0.83656314 |
| illusion | front odd projection | i_front1 | `-1.8266041890847688 · Cd_rear_a + 2.064492712013321 · Cl_F` | `2.064492712013321·Cl_F` | `-1.8266041890847688·Cd_rear_a` | illusion_1L | 0.92703454 | 0.94330800 | -0.01627347 | 0.90788838 | 0.83656314 |
| illusion | front odd projection | i_front1 | `-1.8266041890847688 · Cd_rear_a + 2.064492712013321 · Cl_F` | `2.064492712013321·Cl_F` | `-1.8266041890847688·Cd_rear_a` | illusion_1.5L | 0.83656314 | 0.87939766 | -0.04283452 | 0.90788838 | 0.83656314 |
| illusion | upper/lower shared-symmetry rear | i_rear0 | `1.2544396437730243 · Cd_rear_a - 1.5280742226055937 · Cl_F` | `1.2544396437730243·Cd_rear_a` | `-1.5280742226055937·Cl_F` | illusion_0.75L | 0.95448757 | 0.95545614 | -0.00096857 | 0.92631380 | 0.88760304 |
| illusion | upper/lower shared-symmetry rear | i_rear0 | `1.2544396437730243 · Cd_rear_a - 1.5280742226055937 · Cl_F` | `1.2544396437730243·Cd_rear_a` | `-1.5280742226055937·Cl_F` | illusion_1L | 0.93685081 | 0.94330800 | -0.00645719 | 0.92631380 | 0.88760304 |
| illusion | upper/lower shared-symmetry rear | i_rear0 | `1.2544396437730243 · Cd_rear_a - 1.5280742226055937 · Cl_F` | `1.2544396437730243·Cd_rear_a` | `-1.5280742226055937·Cl_F` | illusion_1.5L | 0.88760304 | 0.87939766 | +0.00820537 | 0.92631380 | 0.88760304 |
| illusion | upper/lower shared-symmetry rear | i_rear1 | `1.2544396437730243 · Cd_rear_a - 1.5280742226055937 · Cl_F` | `-1.5280742226055937·Cl_F` | `1.2544396437730243·Cd_rear_a` | illusion_0.75L | 0.95464065 | 0.95545614 | -0.00081549 | 0.91197030 | 0.85081581 |
| illusion | upper/lower shared-symmetry rear | i_rear1 | `1.2544396437730243 · Cd_rear_a - 1.5280742226055937 · Cl_F` | `-1.5280742226055937·Cl_F` | `1.2544396437730243·Cd_rear_a` | illusion_1L | 0.93045444 | 0.94330800 | -0.01285357 | 0.91197030 | 0.85081581 |
| illusion | upper/lower shared-symmetry rear | i_rear1 | `1.2544396437730243 · Cd_rear_a - 1.5280742226055937 · Cl_F` | `-1.5280742226055937·Cl_F` | `1.2544396437730243·Cd_rear_a` | illusion_1.5L | 0.85081581 | 0.87939766 | -0.02858185 | 0.91197030 | 0.85081581 |
Interpretation: Kármán deletion effects are term-dependent, with deleting the rear constant producing the largest degradation; the tested front term is weak over this short window. Illusion deletions remain stable and comparatively close to their parent, so these runs do not establish uniqueness of every term. Means and minima summarize the listed training cases only.
@@ -0,0 +1,96 @@
from __future__ import annotations
import importlib.util
import json
from pathlib import Path
import numpy as np
MODULE_PATH = Path(__file__).resolve().parents[1] / "tools/export_flow_comparison.py"
spec = importlib.util.spec_from_file_location("export_flow_comparison", MODULE_PATH)
module = importlib.util.module_from_spec(spec)
assert spec.loader
spec.loader.exec_module(module)
def test_wrapped_phase_difference_crosses_branch_cut():
delta = module.wrapped_phase_difference(-np.pi + 0.02, np.pi - 0.03)
assert np.isclose(delta, 0.05)
def test_joint_phase_selection_is_deterministic_and_nearby():
n = 20
phase = np.linspace(0, 4 * np.pi, n, endpoint=False)
def trace(offset: float) -> np.ndarray:
out = np.zeros((n, 6))
out[:, 2] = 2.0 + 3.0 * np.cos(phase + offset)
out[:, 3] = -1.0 + 0.5 * np.sin(phase + offset)
return out
first = module.select_joint_phase_match(trace(0), trace(0.05), trace(-0.04), 0, n, temporal_weight=0.001)
second = module.select_joint_phase_match(trace(0), trace(0.05), trace(-0.04), 0, n, temporal_weight=0.001)
assert first == second
assert len(set(first["indices"].values())) == 1
assert np.isclose(first["wrapped_angle_errors_rad"]["PPO"], 0.05)
assert np.isclose(first["wrapped_angle_errors_rad"]["SR"], 0.04)
assert first["objective_terms"]["temporal_distance_samples"] == 0
def test_exact_candidate_selection_uses_trace_index_mapping():
candidates = np.empty((4, 2, 3), dtype=np.float32)
for candidate_index, trace_index in enumerate(range(96, 100)):
candidates[candidate_index].fill(trace_index)
field, candidate_index = module.select_exact_candidate_field(
candidates, np.arange(96, 100), 98, expected_start=96, expected_stop=100
)
assert candidate_index == 2
np.testing.assert_array_equal(field, np.full((2, 3), 98, dtype=np.float32))
def test_exact_candidate_selection_rejects_mapping_mismatch():
candidates = np.zeros((4, 2, 3), dtype=np.float32)
mismatched = np.array([96, 97, 99, 100])
with np.testing.assert_raises_regex(ValueError, "does not exactly cover"):
module.select_exact_candidate_field(
candidates, mismatched, 99, expected_start=96, expected_stop=100
)
def test_exact_candidate_selection_requires_float32():
candidates = np.zeros((4, 2, 3), dtype=np.float64)
with np.testing.assert_raises_regex(ValueError, "float32"):
module.select_exact_candidate_field(
candidates, np.arange(96, 100), 98, expected_start=96, expected_stop=100
)
def test_orientation_transposes_xy_to_image_yx():
omega_xy = np.arange(12).reshape(4, 3)
oriented = module.orient_vorticity_xy_to_yx(omega_xy, (4, 3))
assert oriented.shape == (3, 4)
np.testing.assert_array_equal(oriented[:, 2], omega_xy[2, :])
def test_crop_uses_physical_x_and_centered_y_coordinates():
field = np.arange(7 * 10).reshape(7, 10)
cropped, meta = module.crop_yx(field, (1.0, 3.0), (-1.0, 1.0), d_lattice=2.0, center_y_lattice=3.0)
np.testing.assert_array_equal(cropped, field[1:6, 2:7])
assert meta["x_slice"] == [2, 7]
assert meta["y_slice"] == [1, 6]
assert meta["extent_xD_yD"] == [1.0, 3.0, -1.0, 1.0]
def test_manifest_includes_npz(tmp_path: Path):
repo = tmp_path
package = repo / "package"
package.mkdir()
(package / "data.npz").write_bytes(b"npz")
(package / "figure.png").write_bytes(b"png")
(package / "ignored.txt").write_text("ignored", encoding="utf-8")
(package / "manifest.json").write_text(json.dumps({"source_policy": "test", "summary": {}}), encoding="utf-8")
module.update_package_manifest(package, repo)
manifest = json.loads((package / "manifest.json").read_text(encoding="utf-8"))
paths = {entry["path"] for entry in manifest["artifacts"]}
assert "package/data.npz" in paths
assert "package/figure.png" in paths
assert "package/ignored.txt" not in paths
assert manifest["summary"]["flow_field_npz"] == 1
@@ -20,10 +20,10 @@ from SR_analysis.utils.provenance import atomic_write_json_below, hash_json, saf
FORMULA_DIR = Path(__file__).parents[1] / "results" / "formulas_v5"
CANONICAL_FORMULAS = (
"ill_1L_sc_front.json",
"ill_1L_sc_top.json",
"kar_d075_sc_front.json",
"kar_d075_sc_top.json",
"ill_1L_front.json",
"ill_1L_top.json",
"kar_d075_front.json",
"kar_d075_top.json",
)
@@ -0,0 +1,22 @@
from __future__ import annotations
import importlib.util
from pathlib import Path
import numpy as np
MODULE_PATH = Path(__file__).resolve().parents[1] / "tools/plot_sr_diagnostics.py"
spec = importlib.util.spec_from_file_location("plot_sr_diagnostics", MODULE_PATH)
module = importlib.util.module_from_spec(spec); assert spec.loader; spec.loader.exec_module(module)
def test_circular_lag_recovers_roll():
reference = np.array([0.0, 1.0, 0.0, -1.0, 0.2])
signal = np.roll(reference, 2)
assert module.circular_lag(reference, signal) == -2
def test_generalization_includes_all_training_conditions():
repo = Path(__file__).resolve().parents[3]
points = module.generalization_points(repo)
training = [p for p in points if p["classification"] == "training"]
assert {p["scene"] for p in training} == set(module.TRAINING_SCENES)
assert len(training) == 7
+433
View File
@@ -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())
+219 -150
View File
@@ -1,11 +1,13 @@
#!/usr/bin/env python3
"""Render compact diagnostic figures from the SR plotting CSV packages."""
"""Render publication SR figures and tables from frozen article CSV/JSON artifacts."""
from __future__ import annotations
import argparse
import csv
import hashlib
import json
from pathlib import Path
from typing import Sequence
from typing import Any, Mapping, Sequence
import matplotlib.pyplot as plt
import numpy as np
@@ -15,6 +17,11 @@ TRAINING_SCENES = (
"illusion_0.75L", "illusion_1L", "illusion_1.5L",
)
LABELS = ("K50", "K100", "K200", "K400", "I0.75L", "I1L", "I1.5L")
ACTIONS = ("front", "upper", "lower")
PACKAGE = "article2-plotting-package-20260721"
STANDARD = "article2-timeseries-csv-20260720"
LONG_SR = "article2-long-timeseries-csv-20260720"
COLORS = {"Target": "#222222", "PPO": "#2878B5", "SR": "#D95319"}
def read_rows(path: Path) -> list[dict[str, str]]:
@@ -22,187 +29,249 @@ def read_rows(path: Path) -> list[dict[str, str]]:
return list(csv.DictReader(handle))
def write_rows(path: Path, fieldnames: Sequence[str], rows: Sequence[Mapping[str, Any]]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", newline="", encoding="utf-8") as handle:
writer = csv.DictWriter(handle, fieldnames=fieldnames, lineterminator="\n")
writer.writeheader()
writer.writerows(rows)
def final_similarity(root: Path, package: str, scene: str, source: str) -> float:
rows = read_rows(root / package / scene / f"{source}_dtw_convergence.csv")
return float(rows[-1]["similarity"])
return float(read_rows(root / package / scene / f"{source}_dtw_convergence.csv")[-1]["similarity"])
def save(fig: plt.Figure, output: Path, stem: str) -> None:
output.mkdir(parents=True, exist_ok=True)
fig.savefig(output / f"{stem}.png", dpi=220, bbox_inches="tight")
fig.savefig(output / f"{stem}.png", dpi=300, bbox_inches="tight")
fig.savefig(output / f"{stem}.pdf", bbox_inches="tight")
plt.close(fig)
def plot_performance(root: Path, output: Path) -> None:
standard = "article2-timeseries-csv-20260720"
long_sr = "article2-long-timeseries-csv-20260720"
ppo200 = [final_similarity(root, standard, scene, "ppo") for scene in TRAINING_SCENES]
sr200 = [final_similarity(root, standard, scene, "sr") for scene in TRAINING_SCENES]
sr400 = [final_similarity(root, long_sr, scene, "sr") for scene in TRAINING_SCENES]
long_ppo_root = root / "article2-plotting-package-20260721/long_ppo"
ppo400 = []
for scene in TRAINING_SCENES:
path = long_ppo_root / scene / "ppo_dtw_convergence.csv"
ppo400.append(float(read_rows(path)[-1]["similarity"]) if path.is_file() else np.nan)
ppo200 = np.array([final_similarity(root, STANDARD, s, "ppo") for s in TRAINING_SCENES])
sr200 = np.array([final_similarity(root, STANDARD, s, "sr") for s in TRAINING_SCENES])
ppo400 = np.array([final_similarity(root / PACKAGE / "long_ppo", "", s, "ppo") for s in TRAINING_SCENES])
sr400 = np.array([final_similarity(root, LONG_SR, s, "sr") for s in TRAINING_SCENES])
x = np.arange(len(LABELS)); width = 0.36
fig, axes = plt.subplots(1, 2, figsize=(12.2, 4.7), gridspec_kw={"width_ratios": (1.15, 1)})
axes[0].bar(x - width / 2, ppo400, width, label="PPO — 400 control steps", color=COLORS["PPO"])
axes[0].bar(x + width / 2, sr400, width, label="SR — 400 control steps", color=COLORS["SR"])
axes[0].set_ylabel("Legacy DTW similarity"); axes[0].set_ylim(0.75, 1.005)
axes[0].set_title("(a) Closed-loop performance at 400 control steps")
axes[0].legend(frameon=False, fontsize=9)
axes[1].axhline(0, color="0.25", linewidth=1)
axes[1].scatter(x - 0.08, ppo400 - ppo200, label="PPO: 400 200 control steps", marker="o", s=48, color=COLORS["PPO"])
axes[1].scatter(x + 0.08, sr400 - sr200, label="SR: 400 200 control steps", marker="s", s=48, color=COLORS["SR"])
axes[1].set_ylabel("Change in legacy DTW similarity")
axes[1].set_title("(b) Duration stability from 200 to 400 steps")
axes[1].legend(frameon=False, fontsize=9)
for ax in axes:
ax.set_xticks(x, LABELS); ax.set_xlabel("Training case"); ax.grid(axis="y", alpha=0.25)
fig.tight_layout(); save(fig, output, "01_training_case_performance")
x = np.arange(len(LABELS))
width = 0.2
fig, ax = plt.subplots(figsize=(11, 4.8))
ax.bar(x - 1.5 * width, ppo200, width, label="PPO 200")
ax.bar(x - 0.5 * width, sr200, width, label="SR 200")
ax.bar(x + 0.5 * width, ppo400, width, label="PPO 400")
ax.bar(x + 1.5 * width, sr400, width, label="SR 400")
ax.set_ylabel("Legacy DTW similarity")
ax.set_xlabel("Training case")
ax.set_xticks(x, LABELS)
ax.set_ylim(0.65, 1.01)
ax.grid(axis="y", alpha=0.25)
ax.legend(ncol=4, frameon=False)
ax.set_title("Closed-loop PPO and symbolic-controller performance")
fig.tight_layout()
save(fig, output, "01_training_case_performance")
def generalization_points(repo_root: Path) -> list[dict[str, Any]]:
path = repo_root / "src/SR_analysis/results/runs/article2-generalization-summary-20260720/generalization.csv"
points: list[dict[str, Any]] = [dict(row) for row in read_rows(path)]
run_root = repo_root / "src/SR_analysis/results/runs"
for scene in TRAINING_SCENES:
objective = "karman" if scene.startswith("karman") else "illusion"
parameter = float(scene.removeprefix("karman_re").removeprefix("illusion_").removesuffix("L"))
points.append({"objective": objective, "scene": scene, "parameter": parameter,
"classification": "training", "legacy_dtw": final_similarity(run_root, STANDARD, scene, "sr")})
return points
def plot_generalization(repo_root: Path, output: Path) -> None:
path = repo_root / "src/SR_analysis/results/runs/article2-generalization-summary-20260720/generalization.csv"
rows = read_rows(path)
fig, axes = plt.subplots(1, 2, figsize=(10.5, 4.2))
for ax, objective, xlabel in zip(axes, ("karman", "illusion"), ("Code Reynolds label", "Target-size label L")):
subset = sorted((row for row in rows if row["objective"] == objective), key=lambda row: float(row["parameter"]))
interpolation = [row for row in subset if row["classification"] == "interpolation"]
extrapolation = [row for row in subset if row["classification"] == "extrapolation"]
ax.plot([float(r["parameter"]) for r in subset], [float(r["legacy_dtw"]) for r in subset], color="0.6", linewidth=1)
ax.scatter([float(r["parameter"]) for r in interpolation], [float(r["legacy_dtw"]) for r in interpolation], label="Interpolation", s=48)
ax.scatter([float(r["parameter"]) for r in extrapolation], [float(r["legacy_dtw"]) for r in extrapolation], label="Extrapolation", marker="s", s=48)
ax.set_xlabel(xlabel)
ax.set_ylabel("Legacy DTW similarity")
ax.set_ylim(0.7, 1.01)
ax.grid(alpha=0.25)
ax.legend(frameon=False)
ax.set_title("Kármán unseen Re" if objective == "karman" else "Illusion unseen target size")
fig.tight_layout()
save(fig, output, "02_pointwise_generalization")
rows = generalization_points(repo_root)
fig, axes = plt.subplots(1, 2, figsize=(11, 4.4))
styles = {"training": ("o", "#222222", "Training condition"),
"interpolation": ("^", "#2E8B57", "Unseen interpolation"),
"extrapolation": ("s", "#A23B72", "Unseen extrapolation")}
for ax, objective, xlabel in zip(axes, ("karman", "illusion"), ("Reynolds-number label", "Target-size label, L")):
subset = [r for r in rows if r["objective"] == objective]
for category, (marker, color, label) in styles.items():
selected = sorted((r for r in subset if r["classification"] == category), key=lambda r: float(r["parameter"]))
ax.scatter([float(r["parameter"]) for r in selected], [float(r["legacy_dtw"]) for r in selected],
marker=marker, color=color, edgecolor="white", linewidth=0.6, s=65, label=label, zorder=3)
ax.set_xlabel(xlabel); ax.set_ylabel("Legacy DTW similarity"); ax.set_ylim(0.7, 1.01)
ax.grid(alpha=0.25); ax.legend(frameon=False, fontsize=8.5)
ax.set_title("Kármán conditions" if objective == "karman" else "Illusion conditions")
fig.suptitle("Frozen SR controller: training and pointwise unseen 200-step realizations", y=1.01)
fig.tight_layout(); save(fig, output, "02_pointwise_generalization")
def plot_steady(repo_root: Path, output: Path) -> None:
path = repo_root / "src/SR_analysis/results/runs/article2-steady-analysis-20260720/steady_rotation_sweep.csv"
rows = read_rows(path)
magnitude = np.asarray([float(row["rear_alpha_magnitude"]) for row in rows])
similarity = np.asarray([float(row["legacy_dtw"]) for row in rows])
error = np.asarray([float(row["mean_abs_sensor_error"]) for row in rows])
fig, ax = plt.subplots(figsize=(7.2, 4.5))
secondary = ax.twinx()
rows = read_rows(repo_root / "src/SR_analysis/results/runs/article2-steady-analysis-20260720/steady_rotation_sweep.csv")
magnitude = np.asarray([float(r["rear_alpha_magnitude"]) for r in rows])
similarity = np.asarray([float(r["legacy_dtw"]) for r in rows])
error = np.asarray([float(r["mean_abs_sensor_error"]) for r in rows])
fig, ax = plt.subplots(figsize=(7.2, 4.5)); secondary = ax.twinx()
ax.plot(magnitude, similarity, marker="o", label="DTW similarity")
secondary.plot(magnitude, error, marker="s", linestyle="--", label="Mean sensor error", color="tab:orange")
ax.axvline(3.4312086805, linestyle=":", color="0.35", label="SR rear constant 3.4312")
ax.set_xlabel("Rear counter-rotation magnitude |α|")
ax.set_ylabel("Legacy DTW similarity")
secondary.set_ylabel("Mean absolute sensor error")
ax.set_ylim(0.6, 1.01)
ax.grid(alpha=0.25)
lines = ax.get_lines() + secondary.get_lines()
ax.legend(lines, [line.get_label() for line in lines], frameon=False, loc="center right")
ax.set_title("Steady-cloak constant-rotation calibration")
fig.tight_layout()
ax.set_xlabel("Rear counter-rotation magnitude |α|"); ax.set_ylabel("Legacy DTW similarity")
secondary.set_ylabel("Mean absolute sensor error"); ax.set_ylim(0.6, 1.01); ax.grid(alpha=0.25)
lines = ax.get_lines() + secondary.get_lines(); ax.legend(lines, [line.get_label() for line in lines], frameon=False, loc="center right")
ax.set_title("Steady-cloak constant-rotation calibration"); fig.tight_layout()
save(fig, output, "03_steady_rotation_calibration")
def plot_offline_residuals(root: Path, output: Path) -> None:
rows = read_rows(root / "article2-plotting-package-20260721/offline_predictions.csv")
rmse = {scene: [] for scene in TRAINING_SCENES}
def build_offline_table(root: Path, tables: Path) -> None:
source = root / PACKAGE / "offline_predictions.csv"; rows = read_rows(source); output = []
for scene in TRAINING_SCENES:
subset = [row for row in rows if row["scene"] == scene]
for action in ("front", "upper", "lower"):
residual = np.asarray([float(row[f"residual_alpha_{action}"]) for row in subset])
rmse[scene].append(float(np.sqrt(np.mean(residual**2))))
fig, ax = plt.subplots(figsize=(10.5, 4.5))
x = np.arange(len(LABELS))
width = 0.25
for index, action in enumerate(("Front", "Upper", "Lower")):
ax.bar(x + (index - 1) * width, [rmse[scene][index] for scene in TRAINING_SCENES], width, label=action)
ax.set_xticks(x, LABELS)
ax.set_xlabel("Training case")
ax.set_ylabel("Offline action RMSE in α")
ax.set_title("SR imitation error on PPO-visited causal states")
ax.grid(axis="y", alpha=0.25)
ax.legend(frameon=False, ncol=3)
fig.tight_layout()
save(fig, output, "04_offline_action_rmse")
subset = [r for r in rows if r["scene"] == scene]
values = {a: float(np.sqrt(np.mean([float(r[f"residual_alpha_{a}"]) ** 2 for r in subset]))) for a in ACTIONS}
aggregate = float(np.sqrt(np.mean([float(r[f"residual_alpha_{a}"]) ** 2 for r in subset for a in ACTIONS])))
output.append({"objective": subset[0]["objective"], "training_case": scene, "n_causally_aligned_states": len(subset),
**{f"rmse_alpha_{a}": f"{values[a]:.8f}" for a in ACTIONS}, "aggregate_rmse_alpha": f"{aggregate:.8f}"})
fields = ("objective", "training_case", "n_causally_aligned_states", *(f"rmse_alpha_{a}" for a in ACTIONS), "aggregate_rmse_alpha")
write_rows(tables / "offline_action_rmse.csv", fields, output)
lines = ["# Offline SR-vs-PPO next-action RMSE", "",
"RMSE is evaluated on causally aligned PPO-visited states: each post-action state predicts the next PPO action. This is an offline imitation diagnostic, not closed-loop performance.", "",
"| Objective | Training case | States | Front RMSE | Upper RMSE | Lower RMSE | Aggregate RMSE |",
"|---|---|---:|---:|---:|---:|---:|"]
for r in output:
lines.append(f"| {r['objective']} | {r['training_case']} | {r['n_causally_aligned_states']} | {r['rmse_alpha_front']} | {r['rmse_alpha_upper']} | {r['rmse_alpha_lower']} | {r['aggregate_rmse_alpha']} |")
lines += ["", "All action errors are in dimensionless surface-speed α. Aggregate RMSE pools the three action channels and all aligned states within a case."]
(tables / "offline_action_rmse.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
def plot_ablation(root: Path, output: Path) -> None:
rows = read_rows(root / "article2-plotting-package-20260721/ablation_summary.csv")
karman_variants = ("k_front0", "k_rear0", "k_rear1")
karman_scenes = ("karman_re50", "karman_re100", "karman_re200", "karman_re400")
illusion_variants = ("i_front0", "i_front1", "i_rear0", "i_rear1")
illusion_scenes = ("illusion_0.75L", "illusion_1L", "illusion_1.5L")
fig, axes = plt.subplots(1, 2, figsize=(11.5, 4.5))
for ax, variants, scenes, title in (
(axes[0], karman_variants, karman_scenes, "Kármán 40-step term deletion"),
(axes[1], illusion_variants, illusion_scenes, "Illusion 40-step term deletion"),
):
x = np.arange(len(scenes))
width = 0.8 / len(variants)
for index, variant in enumerate(variants):
values = [float(next(row["similarity"] for row in rows if row["variant"] == variant and row["scene"] == scene)) for scene in scenes]
ax.bar(x + (index - (len(variants) - 1) / 2) * width, values, width, label=variant)
ax.set_xticks(x, [scene.replace("karman_", "").replace("illusion_", "") for scene in scenes])
ax.set_xlabel("Case")
ax.set_ylabel("Legacy DTW similarity")
ax.set_ylim(0.6, 1.0)
ax.grid(axis="y", alpha=0.25)
ax.legend(frameon=False, fontsize=8)
ax.set_title(title)
fig.tight_layout()
save(fig, output, "05_term_deletion")
def _load_json(path: Path) -> dict[str, Any]:
return json.loads(path.read_text(encoding="utf-8"))
def plot_example_timeseries(root: Path, output: Path) -> None:
package = root / "article2-timeseries-csv-20260720/karman_re100"
sr = read_rows(package / "sr_wide.csv")
ppo = read_rows(package / "ppo_wide.csv")
target = read_rows(package / "target_wide.csv")
fig, axes = plt.subplots(2, 1, figsize=(10.5, 6.2), sharex=False)
axes[0].plot([float(r["t_D"]) for r in target], [float(r["sensors_center_ux"]) for r in target], label="Target reference")
axes[0].plot([float(r["t_D"]) for r in ppo], [float(r["sensors_center_ux"]) for r in ppo], label="PPO")
axes[0].plot([float(r["t_D"]) for r in sr], [float(r["sensors_center_ux"]) for r in sr], label="SR")
axes[0].set_ylabel("Center sensor ux / U0")
axes[0].set_xlabel("t U0 / D")
axes[0].legend(frameon=False, ncol=3)
axes[0].grid(alpha=0.25)
axes[0].set_title("Kármán Re100 observer signal")
for action, label in (("front", "Front"), ("upper", "Upper"), ("lower", "Lower")):
axes[1].plot([float(r["t_D"]) for r in sr], [float(r[f"actions_alpha_{action}"]) for r in sr], label=label)
axes[1].set_ylabel("SR action α")
axes[1].set_xlabel("t U0 / D")
axes[1].legend(frameon=False, ncol=3)
axes[1].grid(alpha=0.25)
fig.tight_layout()
save(fig, output, "06_example_timeseries_karman_re100")
def build_term_deletion_table(repo_root: Path, root: Path, tables: Path) -> None:
formula_root = repo_root / "src/SR_analysis/results/runs/article-ablation-formulas-v2-20260718/formulas"
parent_root = repo_root / "src/SR_analysis/results/runs"
specs = {
"k_front0": ("karman", "front odd projection", "karman_front__delete_t0.json", "article-refit-karman-topology-a-20260718/formulas/joint_front.json", "article-L2-karman-20260718"),
"k_rear0": ("karman", "upper/lower shared-symmetry rear", "karman_rear__delete_t0.json", "article-refit-karman-topology-a-20260718/formulas/joint_rear_shared_upper.json", "article-L2-karman-20260718"),
"k_rear1": ("karman", "upper/lower shared-symmetry rear", "karman_rear__delete_t1.json", "article-refit-karman-topology-a-20260718/formulas/joint_rear_shared_upper.json", "article-L2-karman-20260718"),
"i_front0": ("illusion", "front odd projection", "illusion_front__delete_t0.json", "article-refit-illusion-topology-a-20260718/formulas/joint_front.json", "article-L2-illusionA-20260718"),
"i_front1": ("illusion", "front odd projection", "illusion_front__delete_t1.json", "article-refit-illusion-topology-a-20260718/formulas/joint_front.json", "article-L2-illusionA-20260718"),
"i_rear0": ("illusion", "upper/lower shared-symmetry rear", "illusion_rear__delete_t0.json", "article-refit-illusion-topology-a-20260718/formulas/joint_rear_shared_upper.json", "article-L2-illusionA-20260718"),
"i_rear1": ("illusion", "upper/lower shared-symmetry rear", "illusion_rear__delete_t1.json", "article-refit-illusion-topology-a-20260718/formulas/joint_rear_shared_upper.json", "article-L2-illusionA-20260718"),
}
ablations = read_rows(root / PACKAGE / "ablation_summary.csv"); output = []
for variant, (objective, head, variant_file, parent_file, parent_run) in specs.items():
variant_json = _load_json(formula_root / variant_file); parent_json = _load_json(parent_root / parent_file)
deleted = variant_json["variant_metadata"]["term_identity"]
variant_rows = [r for r in ablations if r["variant"] == variant]
similarities = [float(r["similarity"]) for r in variant_rows]
parent_metrics = {}
for path in (parent_root / parent_run / "validations").glob("*.json"):
record = _load_json(path); parent_metrics[record["scene"]] = float(record["metrics"]["legacy_reference_cycle_vs_last_recorded_cycle"]["similarity"])
for r in sorted(variant_rows, key=lambda x: TRAINING_SCENES.index(x["scene"])):
parent_value = parent_metrics[r["scene"]]; value = float(r["similarity"])
output.append({"objective": objective, "head": head, "variant": variant,
"parent_formula": parent_json["deployment_expression"], "deleted_term": deleted,
"retained_formula": variant_json["deployment_expression"], "case": r["scene"], "steps": r["steps"],
"legacy_dtw": f"{value:.8f}", "parent_legacy_dtw_40_step": f"{parent_value:.8f}",
"delta_vs_parent_40_step": f"{value-parent_value:+.8f}", "variant_mean": f"{np.mean(similarities):.8f}",
"variant_min": f"{np.min(similarities):.8f}"})
fields = tuple(output[0]); write_rows(tables / "term_deletion.csv", fields, output)
lines = ["# Forty-step closed-loop term deletion", "",
"Every comparison uses the same 40-control-step window and the legacy DTW metric. Parent values come from the scientifically comparable parent L2 runs; deltas are deletion minus parent. Formula identities are read from the canonical parent and deletion JSON artifacts.", "",
"Front formulas are scalar generators deployed through the odd projection `α_F(x) = ½[f(x) f(Gx)]`. Rear formulas define the upper action; the lower action is mapped by `α_L(x) = −α_U(Gx)`.", "",
"| Objective | Head | Variant | Parent formula | Deleted term | Retained formula | Case | DTW | Parent DTW | Δ | Mean | Min |",
"|---|---|---|---|---|---|---|---:|---:|---:|---:|---:|"]
for r in output:
fmt = lambda s: f"`{s.replace('*', '·')}`"
lines.append(f"| {r['objective']} | {r['head']} | {r['variant']} | {fmt(r['parent_formula'])} | {fmt(r['deleted_term'])} | {fmt(r['retained_formula'])} | {r['case']} | {r['legacy_dtw']} | {r['parent_legacy_dtw_40_step']} | {r['delta_vs_parent_40_step']} | {r['variant_mean']} | {r['variant_min']} |")
lines += ["", "Interpretation: Kármán deletion effects are term-dependent, with deleting the rear constant producing the largest degradation; the tested front term is weak over this short window. Illusion deletions remain stable and comparatively close to their parent, so these runs do not establish uniqueness of every term. Means and minima summarize the listed training cases only."]
(tables / "term_deletion.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--repo-root", type=Path, default=Path(__file__).resolve().parents[3])
parser.add_argument("--output-dir", type=Path, required=True)
return parser
def circular_lag(reference: np.ndarray, signal: np.ndarray) -> int:
"""Return deterministic roll applied to signal to maximize normalized circular correlation."""
ref = np.asarray(reference, dtype=float) - np.mean(reference); sig = np.asarray(signal, dtype=float) - np.mean(signal)
if len(ref) != len(sig) or len(ref) == 0: raise ValueError("signals must have equal non-zero length")
scores = np.array([np.dot(ref, np.roll(sig, lag)) for lag in range(len(ref))]) / (np.linalg.norm(ref) * np.linalg.norm(sig))
lag = int(np.flatnonzero(np.isclose(scores, scores.max(), rtol=0, atol=1e-12))[0])
return lag if lag <= len(ref) // 2 else lag - len(ref)
def plot_example_timeseries(root: Path, output: Path, package_dir: Path) -> dict[str, Any]:
source = root / STANDARD / "karman_re100"
data = {name: read_rows(source / f"{name.lower()}_wide.csv") for name in ("Target", "PPO", "SR")}
start, stop = 96, 146 # 50 samples, approximately three target cycles, late in the 150-row target record.
phase = np.linspace(0, 6 * np.pi, stop - start, endpoint=False)
signal_name = "sensors_center_uy"
reference = np.array([float(r[signal_name]) for r in data["Target"][start:stop]])
lags = {"Target": 0}
aligned: dict[str, list[dict[str, str]]] = {"Target": data["Target"][start:stop]}
for name in ("PPO", "SR"):
segment = data[name][start:stop]
lag = circular_lag(reference, np.array([float(r[signal_name]) for r in segment]))
lags[name] = lag; aligned[name] = list(np.roll(np.asarray(segment, dtype=object), lag))
fig, axes = plt.subplots(2, 3, figsize=(12, 7.2))
sensors = (("upper", "Upper sensor"), ("center", "Center sensor"), ("lower", "Lower sensor"))
styles = {"Target": "-", "PPO": "--", "SR": "-."}
for col, (sensor, title) in enumerate(sensors):
for name in ("Target", "PPO", "SR"):
rows = aligned[name]
axes[0, col].plot([float(r[f"sensors_{sensor}_ux"]) for r in rows], [float(r[f"sensors_{sensor}_uy"]) for r in rows],
color=COLORS[name], linestyle=styles[name], linewidth=1.7, label=name)
axes[0, col].set_xlabel(r"$u/U_0$"); axes[0, col].set_ylabel(r"$v/U_0$"); axes[0, col].set_title(title); axes[0, col].grid(alpha=0.22)
axes[0, col].scatter(float(aligned["Target"][0][f"sensors_{sensor}_ux"]), float(aligned["Target"][0][f"sensors_{sensor}_uy"]), color=COLORS["Target"], marker="o", s=20, zorder=4)
axes[0, 0].legend(frameon=False, ncol=3, fontsize=9)
for col, action in enumerate(ACTIONS):
for name in ("PPO", "SR"):
axes[1, col].plot(phase / (2 * np.pi), [float(r[f"actions_alpha_{action}"]) for r in aligned[name]], color=COLORS[name], linestyle=styles[name], linewidth=1.6, label=name)
axes[1, col].set_xlabel("Target phase / 2π"); axes[1, col].set_ylabel("Action α"); axes[1, col].set_title(f"{action.capitalize()} action"); axes[1, col].set_xticks((0, 1, 2, 3)); axes[1, col].grid(alpha=0.22)
axes[1, 0].legend(frameon=False, ncol=2, fontsize=9)
fig.suptitle("Kármán Re100: three stable target cycles with phase-aligned controllers", y=0.995)
fig.tight_layout(); save(fig, output, "06_example_timeseries_karman_re100")
metadata = {"scene": "karman_re100", "source_package": STANDARD, "target_index_start_inclusive": start,
"target_index_stop_exclusive": stop, "target_t_D_start": float(data["Target"][start]["t_D"]),
"target_t_D_end": float(data["Target"][stop-1]["t_D"]), "samples": stop-start, "displayed_target_cycles": 3,
"alignment_signal": signal_name, "alignment_method": "mean-centered normalized circular cross-correlation; smallest maximizing roll selected",
"roll_lags_samples": lags, "control_dt_D_over_U0": 0.4, "target_actions_plotted": False}
(package_dir / "phase_alignment.json").write_text(json.dumps(metadata, indent=2) + "\n", encoding="utf-8")
return metadata
def update_manifest(repo_root: Path, package_dir: Path) -> None:
old = _load_json(package_dir / "manifest.json"); summary = dict(old.get("summary", {}))
summary.pop("diagnostic_figures", None); summary.update({"publication_figures": 5, "publication_tables": 2, "presentation_pages": 2})
suffixes = {".csv", ".png", ".pdf", ".md", ".json", ".npz"}; artifacts = []
for path in sorted(p for p in package_dir.rglob("*") if p.is_file() and p.suffix in suffixes and p.name != "manifest.json"):
artifacts.append({"path": str(path.relative_to(repo_root)), "sha256": hashlib.sha256(path.read_bytes()).hexdigest()})
record = {"schema_version": "sr-plotting-package-v3", "source_policy": old["source_policy"], "summary": summary, "artifacts": artifacts}
(package_dir / "manifest.json").write_text(json.dumps(record, indent=2) + "\n", encoding="utf-8")
def write_package_readme(package_dir: Path, metadata: Mapping[str, Any]) -> None:
text = f"""# SR publication plotting package
This package contains five generated publication figures, two replacement tables, and two PPT-ready 16:9 summary pages. All values are derived from frozen canonical artifacts without formula refitting.
- `01_training_case_performance`: panel (a) compares PPO and SR absolute closed-loop legacy DTW similarity at **400 control steps** for all seven training cases; panel (b) reports each controller's 400-minus-200 change. Here 200 and 400 are run durations in control steps, not Reynolds numbers.
- `02_pointwise_generalization`: 200-step SR results at training conditions and sampled unseen interpolation/extrapolation conditions. Each unseen point is one realization. Categories are unconnected because the evidence does not establish a continuous parameter law or statistical robustness.
- `03_steady_rotation_calibration`: the retained disturbance-free constant rear-rotation sweep diagnostic.
- `06_example_timeseries_karman_re100`: late-window phase portraits and PPO/SR actions over approximately three target cycles. Target indices {metadata['target_index_start_inclusive']}{metadata['target_index_stop_exclusive'] - 1} (`t_D={metadata['target_t_D_start']:.1f}``{metadata['target_t_D_end']:.1f}`) are used. PPO and SR segments at the same indices are circularly aligned to target `sensors_center_uy` by mean-centered normalized cross-correlation; deterministic roll lags are PPO={metadata['roll_lags_samples']['PPO']} and SR={metadata['roll_lags_samples']['SR']} samples. No target actions exist or are plotted.
- `07_flow_field_comparison_karman_re100`: a deterministic stable-cycle snapshot trio. Over [96,146), downstream center-sensor `(ux,uy)` is standardized separately per trajectory; an exhaustive joint search minimizes wrapped phase errors plus a 0.001 rad/sample temporal-separation penalty with a same-direction branch check. This matches center-sensor limit-cycle phase, not six-sensor state equality or exact full-field identity.
- `tables/offline_action_rmse.*`: offline SR-vs-PPO next-action RMSE on causally aligned PPO-visited states. This is not closed-loop performance.
- `tables/term_deletion.*`: actual 40-step closed-loop deletion results, canonical formula identities, same-window parent comparisons, and aggregate mean/min summaries.
The `presentation/` directory contains two white-background 16:9 PNG/PDF pages and a source-artifact README. PNG and PDF files share each retained figure stem. `phase_alignment.json` records the exact Figure 06 selection/alignment contract. `manifest.json` hashes every package CSV, Markdown, JSON/NPZ metadata or field artifact, and publication figure.
"""
(package_dir / "README.md").write_text(text, encoding="utf-8")
def main(argv: Sequence[str] | None = None) -> int:
args = build_parser().parse_args(argv)
repo_root = args.repo_root.resolve()
run_root = repo_root / "src/SR_analysis/results/runs"
output = args.output_dir if args.output_dir.is_absolute() else repo_root / args.output_dir
plt.style.use("seaborn-v0_8-whitegrid")
plot_performance(run_root, output)
plot_generalization(repo_root, output)
plot_steady(repo_root, output)
plot_offline_residuals(run_root, output)
plot_ablation(run_root, output)
plot_example_timeseries(run_root, output)
parser = argparse.ArgumentParser(description=__doc__); parser.add_argument("--repo-root", type=Path, default=Path(__file__).resolve().parents[3]); parser.add_argument("--output-dir", type=Path, required=True)
args = parser.parse_args(argv); repo_root = args.repo_root.resolve(); root = repo_root / "src/SR_analysis/results/runs"
output = args.output_dir if args.output_dir.is_absolute() else repo_root / args.output_dir; package_dir = root / PACKAGE
plt.style.use("seaborn-v0_8-whitegrid"); plt.rcParams.update({"font.size": 10, "axes.titlesize": 11, "axes.labelsize": 10, "legend.fontsize": 9, "pdf.fonttype": 42})
for obsolete in ("04_offline_action_rmse", "05_term_deletion"):
for suffix in (".png", ".pdf"): (output / f"{obsolete}{suffix}").unlink(missing_ok=True)
plot_performance(root, output); plot_generalization(repo_root, output); plot_steady(repo_root, output)
tables = package_dir / "tables"; build_offline_table(root, tables); build_term_deletion_table(repo_root, root, tables)
metadata = plot_example_timeseries(root, output, package_dir); write_package_readme(package_dir, metadata); update_manifest(repo_root, package_dir)
return 0
if __name__ == "__main__":
raise SystemExit(main())
if __name__ == "__main__": raise SystemExit(main())
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env python3
"""Render two 16:9 presentation pages from frozen canonical SR artifacts."""
from __future__ import annotations
import argparse, csv, json, re
from pathlib import Path
from typing import Any
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
BLUE, ORANGE, INK, MUTED, PALE = "#1769AA", "#D65F1E", "#17202A", "#52606D", "#F3F7FA"
def load_json(path: Path) -> dict[str, Any]: return json.loads(path.read_text(encoding="utf-8"))
def rows(path: Path) -> list[dict[str,str]]:
with path.open(newline="", encoding="utf-8") as f: return list(csv.DictReader(f))
def similarity(path: Path) -> float: return float(rows(path)[-1]["similarity"])
def expression(root: Path, family: str, head: str) -> str:
p=root/f"article-refit-{family}-topology-a-20260718/formulas/{head}.json"
return load_json(p)["deployment_expression"]
def pretty(expr: str) -> str:
rounded = re.sub(r"(?<![\w.])[-+]?(?:\d+\.\d*|\.\d+)(?:[eE][+-]?\d+)?", lambda match: f"{float(match.group()):.4f}", expr)
return rounded.replace(" * ", r"\,").replace("Cd_rear_a", r"C_{D,r}^{a}").replace("Cl_rear_s", r"C_{L,r}^{s}").replace("Cl_F", r"C_{L,F}")
def save(fig: plt.Figure, out: Path, stem: str) -> None:
out.mkdir(parents=True, exist_ok=True); fig.savefig(out/f"{stem}.png", dpi=200, facecolor="white"); fig.savefig(out/f"{stem}.pdf", facecolor="white"); plt.close(fig)
def base(title: str, subtitle: str):
fig=plt.figure(figsize=(16,9), facecolor="white"); fig.text(.05,.94,title,fontsize=28,weight="bold",color=INK,va="top"); fig.text(.05,.885,subtitle,fontsize=13,color=MUTED,va="top"); return fig
def page_method(root: Path, out: Path) -> None:
fig=base("From PPO trajectories to an executable symbolic controller", "Causal alignment, symmetry, all-case refitting, then closed-loop CFD—not offline fit alone")
steps=[("1","PPO trajectories","states • forces • actions"),("2","Causal dataset","post-action state → next action"),("3","Topology discovery","G-symmetry-aware symbolic search"),("4","Coefficient refit","fixed topology • all family cases"),("5","CFD validation","40 / 200 / 400 steps • deletion")]
for i,(n,h,d) in enumerate(steps):
x=.05+i*.19; fig.patches.append(plt.Rectangle((x,.67),.16,.13,transform=fig.transFigure,fc=PALE,ec=BLUE,lw=1.5))
fig.text(x+.012,.765,n,fontsize=20,weight="bold",color=BLUE);fig.text(x+.042,.765,h,fontsize=14,weight="bold",color=INK);fig.text(x+.012,.705,d,fontsize=10.5,color=MUTED)
if i<4: fig.text(x+.17,.725,"",fontsize=24,color=ORANGE,ha="center")
formulas={f:(pretty(expression(root,f,"joint_front")),pretty(expression(root,f,"joint_rear_shared_upper"))) for f in ("karman","illusion")}
for x,f,title in ((.05,"karman","Kármán shared family"),(.52,"illusion","Illusion shared numerical family")):
fig.patches.append(plt.Rectangle((x,.25),.43,.34,transform=fig.transFigure,fc="white",ec="#CAD5DF",lw=1.2))
fig.text(x+.025,.545,title,fontsize=18,weight="bold",color=BLUE if f=="karman" else ORANGE)
front,rear=formulas[f]
fig.text(x+.025,.485,r"$\alpha_F(x)=\frac{1}{2}[f(x)-f(Gx)]$",fontsize=14,color=INK)
fig.text(x+.025,.435,rf"$f(x)={front}$",fontsize=14,color=INK)
fig.text(x+.025,.375,rf"$\alpha_U(x)={rear}$",fontsize=14,color=INK)
fig.text(x+.025,.325,r"$\alpha_L(x)=-\alpha_U(Gx)$",fontsize=14,color=INK)
note=("Persistent rear counter-rotation constant dominates;\nrear lift correction is secondary." if f=="karman" else "Deployable shared family; deletion evidence does not\nestablish term uniqueness.")
fig.text(x+.025,.285,note,fontsize=12,color=MUTED,linespacing=1.35)
fig.text(.05,.12,"G mirrors upper/lower quantities and applies the signed symmetry map. α is dimensionless cylinder surface speed.",fontsize=11.5,color=MUTED)
save(fig,out,"01_sr_method_and_formulas")
def page_evidence(root: Path, out: Path) -> None:
fig=base("Quantitative evidence at a glance", "Legacy DTW similarity; deterministic frozen artifacts; control-step windows stated explicitly")
pkg=root/"article2-plotting-package-20260721"; standard=root/"article2-timeseries-csv-20260720"; longsr=root/"article2-long-timeseries-csv-20260720"
scenes={"Kármán":["karman_re50","karman_re100","karman_re200","karman_re400"],"Illusion":["illusion_0.75L","illusion_1L","illusion_1.5L"]}
vals={}
for fam,ss in scenes.items():
sr=np.array([similarity(longsr/s/"sr_dtw_convergence.csv") for s in ss]); ppo=np.array([similarity(pkg/"long_ppo"/s/"ppo_dtw_convergence.csv") for s in ss]); vals[fam]=(sr,ppo)
gen=rows(root/"article2-generalization-summary-20260720/generalization.csv")
deletion=rows(pkg/"tables/term_deletion.csv")
cards=[(.05,.61,.42,.20,"400-step training cases",[(f"Kármán SR range",f"{vals['Kármán'][0].min():.3f}{vals['Kármán'][0].max():.3f}"),("Kármán mean SR / PPO",f"{vals['Kármán'][0].mean():.3f} / {vals['Kármán'][1].mean():.3f}"),("Illusion SR range",f"{vals['Illusion'][0].min():.3f}{vals['Illusion'][0].max():.3f}"),("Illusion mean SR / PPO",f"{vals['Illusion'][0].mean():.3f} / {vals['Illusion'][1].mean():.3f}")]),
(.52,.61,.43,.20,"200-step unseen conditions",[("Kármán: 4 points",f"{min(float(r['legacy_dtw']) for r in gen if r['objective']=='karman'):.3f}{max(float(r['legacy_dtw']) for r in gen if r['objective']=='karman'):.3f}"),("Illusion: 5 points",f"{min(float(r['legacy_dtw']) for r in gen if r['objective']=='illusion'):.3f}{max(float(r['legacy_dtw']) for r in gen if r['objective']=='illusion'):.3f}"),("Evidence level","one realization / condition"),("Scope","sampled interpolation + extrapolation")])]
for x,y,w,h,title,items in cards:
fig.patches.append(plt.Rectangle((x,y),w,h,transform=fig.transFigure,fc=PALE,ec="#CAD5DF"));fig.text(x+.02,y+h-.045,title,fontsize=17,weight="bold",color=INK)
for j,(k,v) in enumerate(items): fig.text(x+.02,y+h-.09-.032*j,k,fontsize=11,color=MUTED);fig.text(x+w-.02,y+h-.09-.032*j,v,fontsize=11.5,weight="bold",color=BLUE,ha="right")
variants=[("K front term","k_front0"),("K rear lift","k_rear0"),("K rear constant","k_rear1"),("I worst mean deletion","i_front1")]
fig.text(.05,.52,"40-step term deletion: mean parent-relative change",fontsize=18,weight="bold",color=INK)
for i,(label,var) in enumerate(variants):
sub=[r for r in deletion if r["variant"]==var]; delta=np.mean([float(r["delta_vs_parent_40_step"]) for r in sub]);x=.05+i*.225
fig.patches.append(plt.Rectangle((x,.35),.20,.12,transform=fig.transFigure,fc="white",ec="#CAD5DF"));fig.text(x+.015,.43,label,fontsize=11.5,color=MUTED);fig.text(x+.015,.375,f"{delta:+.3f}",fontsize=22,weight="bold",color=ORANGE if delta<-.03 else BLUE)
fig.text(.05,.235,"Read with care",fontsize=17,weight="bold",color=INK)
cautions=["40-step deletion, 200-step generalization, and 400-step training summaries answer different questions.","Legacy DTW is an aligned trajectory-similarity metric—not causal proof or a physical delay estimate.","Unseen conditions are pointwise deterministic runs; no continuous parameter law or uncertainty band is claimed.","Offline action RMSE is intentionally omitted: imitation on PPO-visited states is not article performance."]
for i,t in enumerate(cautions): fig.text(.07,.195-.038*i,""+t,fontsize=11.5,color=MUTED)
save(fig,out,"02_sr_quantitative_evidence")
def main() -> int:
ap=argparse.ArgumentParser();ap.add_argument("--repo-root",type=Path,default=Path(__file__).resolve().parents[3]);ap.add_argument("--output-dir",type=Path,default=None);a=ap.parse_args();root=a.repo_root.resolve()/"src/SR_analysis/results/runs";out=a.output_dir or root/"article2-plotting-package-20260721/presentation";page_method(root,out);page_evidence(root,out);return 0
if __name__=="__main__": raise SystemExit(main())
@@ -321,8 +321,9 @@ def build_manifest(repo_root: Path, output_dir: Path, summary: Mapping[str, Any]
artifacts = []
for path in sorted(
candidate
for pattern in ("*.csv", "*.png", "*.pdf")
for pattern in ("*.csv", "*.png", "*.pdf", "*.md", "*.json", "*.npz")
for candidate in output_dir.rglob(pattern)
if candidate.name != "manifest.json"
):
artifacts.append({"path": str(path.relative_to(repo_root)), "sha256": hash_file(path)})
atomic_write_json(