feat(drl): publish writing-ready documentation and training tables

Add indexed DRL pinball documentation, provenance-aware training CSV export, first-phase flow rendering, and audited reproduction assets so the retained results are ready for manuscript analysis.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Frank14f
2026-08-09 22:10:05 +08:00
co-authored by Cursor
parent 7eab04869c
commit 86157c6f31
28 changed files with 8745 additions and 87 deletions
+14
View File
@@ -0,0 +1,14 @@
# DRL pinball asset status
| Layer | Paths | Authority/use |
|---|---|---|
| Active V5 code | `case_registry.py`, `train/`, `eval/` | Executable contract; changes require focused tests. |
| Active V5 targets/calibration | `train/calibrations/`, `configs/` | Matched target/config provenance; do not substitute files by name alone. |
| Retained training bundles | `train/output/*_seed*/` | Compact policy/normalizer and metadata; logs/TensorBoard may be local/ignored. |
| Reader training tables | `train/results/figures/`, `train/results/latest/`, `train/results/training_csv/` | Derived views; not new evidence. |
| Reproduction roles | `data/reproduction/` | Metadata/manifests and NPZ/CSV role artifacts; may resolve through nonportable symlink. |
| Legacy compatibility | `legacy_test/`, `legacy_env/`, `legacy_train/` | Separate Legacy plant and historical contracts. |
| Historical analysis | `train/CROSSRE_ANALYSIS.md`, `train/VARDIST_ANALYSIS.md`, `eval/archive/` | Context only; archived claims do not outrank current contracts. |
| External storage | resolved symlinks and `/home/frank14f/optane/...` | Machine-local authority; not portable Git input. |
Never move or overwrite hash-bound evidence to improve readability; add an index or a new derived sibling.
+31
View File
@@ -0,0 +1,31 @@
# DRL pinball claim ledger
This ledger governs writing language. Repository artifacts and manifests remain the evidence; this file records status and limits.
## Status vocabulary
- **Supported/bounded:** directly supported at named cases, windows, metrics, and artifact paths.
- **Retained historical:** reproducible reader evidence, but not current-contract retraining authority.
- **Diagnostic:** useful for understanding behavior, not a causal result.
- **Contextual:** calibration or background evidence.
- **Negative:** evidence against a proposed interpretation.
- **Unsupported/withdrawn:** do not state without new evidence.
## Current bounded claims
| ID | Status | Safe statement | Evidence | Limits |
|---|---|---|---|---|
| DRL-C1 | Retained historical | The 15-run V5 scratch matrix contains five Re100 seeds and one seed for each other canonical case. | `train/case_registry.py`, `train/output/*/meta.json` | Only Re100 supports seed spread; others are n=1 demonstrations. |
| DRL-C2 | Retained historical | Re100 repeatedly discovered high-reward checkpoints, while late-policy retention varied by seed. | `train/results/figures/scratch_training_summary.csv`, `train/results/latest/figures/fig01*`, `fig02*` | Not robust convergence, asymptotic stability, or uncertainty. |
| DRL-C3 | Retained historical | Retained cross-Re and variable-disturbance evaluations are case-specific demonstrations. | `train/results/latest/tables/latest_eval_summary.csv`, `HOW_TO_READ.md` | Historical compatibility path; variable-disturbance learning rates are confounded. |
| DRL-C4 | Retained historical | Retained Illusion artifacts show case-dependent reward/force behavior and high action saturation in several cases. | `latest_eval_summary.csv`, `fig09*` | DTW similarity is not field error, delay, causality, or mechanism. |
| DRL-C5 | Supported artifact contract | V5 training uses calibrated targets, native calibration-v2 scaling, online observation `VecNormalize`, zero action bias, and explicit action smoothing. | `train/TRAIN_PIPELINE.md`, `train/env_karman.py`, `train/env_illusion.py` | This describes the active code contract, not proof that every retained policy was trained under it. |
| DRL-C6 | Reader artifact | `train/export_training_csv.py` exports 7,000 per-iteration rows from 14 conflict-free runs from retained TensorBoard evaluation scalars. | `train/results/training_csv/manifest.json` | Re60 is excluded fail-closed; TensorBoard is auxiliary; `meta.json` remains best-value authority. |
## Explicit non-claims
Do not claim universal Reynolds-number generalization, distribution-wide robustness, causal wake mechanism, asymptotic stability, independent uncertainty for n=1 cases, post-fix complete retraining, or physical response delay from DTW. Do not pool V5 with Legacy, SR, CCD, or steady-theory numbers without a declared contract and question.
## Reproduction-field boundary
Eight periodic phase fields are retained per role, but current flow plotting selects only phase slot 0. Vortex event fields and steady late fields are separate semantics. A plot is a derived reader view and does not create new CFD evidence.
+35
View File
@@ -0,0 +1,35 @@
# DRL pinball data dictionary
## Units and conventions
- `L0=20` lattice units; V5 fields are on a `2000×600` lattice, Legacy fields on `1280×512`.
- `U0=0.01` is the lattice inlet velocity in the active V5 contract.
- Code Reynolds number uses reference length `2L0=40`; therefore code `re100` corresponds to single-cylinder `Re_D=50`.
- Action order is `[front, top, bottom]` in V5 retained evaluation. Do not infer Legacy order without its role metadata.
- A “training iteration” is one PPO learn chunk plus deterministic evaluation, normally 2048 learn steps followed by up to 360 evaluation steps. It is not a terminating Gym episode.
## Training artifacts
`train/output/<case>_seed<seed>/` contains compact retained bundles and, when available locally, `tb/`, `train.log`, and model checkpoints. `meta.json` is the authority for `best_reward`, `best_episode`, `total_episodes`, seed, case, and learning rate. `best_model.zip` must be paired with `best_vecnormalize.pkl`; the normalizer is part of the policy artifact.
`train/results/figures/scratch_training_summary.csv` is one row per retained run. It is a summary table, not a training trajectory.
`train/results/training_csv/training_iterations.csv` is the plotting table. Each row is one TensorBoard evaluation step from a conflict-free run and includes `eval_reward`, `eval_r_cd`, `eval_r_cl`, `eval_r_sim`, running-best reward, exploratory drawdown, and a flag for the `meta.json` authoritative best iteration. Its source is explicitly `TensorBoard eval/* scalars`; values at the best point are checked against metadata during generation. `current_contract_retraining_evidence=false` is intentional for retained historical runs. Re60 is excluded because resumed TensorBoard branches conflict and include mixed configuration provenance.
## Reproduction role artifacts
The reproduction tree is organized as `v5/<case-or-seeded-run>/<role>/` and `legacy/<scenario>/<role>/`. Common roles are `target`, `controlled`, `zero`; Legacy additionally contains `sr`, `constant`, and special variants.
- `phase_fields.npz`: eight retained periodic fields, arrays `ux`, `uy` with shape `(8, ny, nx)`, phase selectors, and normally complete-cycle `mean_ux`, `mean_uy`.
- `event_fields.npz`: five event-relative fields, selected by `relative_offsets`; never label these as phase slots.
- `late_field.npz`: one late snapshot for steady roles and special targets.
- `timeseries.csv`: retained sensor/force/action or role time series according to the role contract.
- `metadata.json`: schema, role, case, units, clocks, source hashes, and acquisition provenance.
For phase plots, the current publication instruction is slot `0` only. The eight slots remain in the source artifact for later analysis; plotting all slots is not the default.
## Metrics and evidence status
Reward components (`r_cd`, `r_cl`, `r_sim`) are training/evaluation objective components, not field error. DTW is signal similarity, not causal delay or a physical mechanism. `E_phase8` and `E_mean` in evaluation artifacts are separate field comparisons with their declared window and mask. Never compare V5 and Legacy scores without reconciling solver, units, normalization, target, and metric formulas.
Every numerical claim in writing should identify case, seed/realization count, window, comparator, metric formula, artifact path, and whether it is current, retained historical, diagnostic, or negative evidence.
+36
View File
@@ -0,0 +1,36 @@
# DRL pinball history and lessons
## Why this package has two plants
The project contains modern V5 CelerisLab PPO workflows and historical LegacyCelerisLab workflows. They share fluidic-pinball concepts but not automatically the same sensor units, normalization, action persistence, solver contract, target construction, or reward formula. Pooling them by filename or plotting style is unsafe.
## Chronology
1. **Legacy PPO era:** Kármán cloak, Illusion, vortex, erase, reduced-observation, and steady scripts established the original scene families and compatibility artifacts.
2. **Modern V5 migration:** CelerisLab moved to a `2000×600` grid, calibration-v2/native units, explicit case registry, zero-bias actions, `VecNormalize`, and scratch PPO training.
3. **Canonical scratch matrix:** 15 retained scratch runs across 11 cases were retained for writing. Re100 has five seeds; other cases have one.
4. **Evidence consolidation:** evaluation and plotting were separated from training. Retained results are historical writing inputs, not proof of post-fix retraining.
5. **Reproduction acquisition:** V5/Legacy role artifacts, eight periodic phase fields, complete-cycle means, CSV time series, event fields, and late snapshots were standardized with provenance and fail-closed checks.
6. **Current writing preparation:** reader documentation, data dictionaries, and per-iteration CSV export make the package indexable without reopening CFD.
## Lessons that control interpretation
- Best discovery is not stable retention. Re100 seeds repeatedly reach high best rewards but can fall sharply by the final or late window.
- An “episode” in training logs is a PPO chunk plus evaluation, not a Gym episode.
- `meta.json` is authoritative for best training values; TensorBoard supplies reconstructable per-iteration curves and must be validated at the metadata best point.
- A policy without its same-selection normalizer is incomplete for inference.
- Calibration artifacts are part of the target contract; a target array alone is not enough.
- `VecNormalize` is online observation whitening, distinct from physical calibration scales.
- Symmetry is default-off in active V5. Historical configured probabilities must not be described as effective augmentation without checking wrapper timing.
- `kar_d075` and `kar_d2` learning-rate differences confound a pure disturbance-geometry ablation.
- DTW similarity is not physical response time, causality, or mechanism evidence.
- V5 and Legacy are separate evidence chains. SR and CCD results answer different questions and are not interchangeable DRL scores.
- The first stored periodic phase is a plotting selection, not a claim that phase 0 is scientifically privileged.
## Retired or bounded routes
Transfer runs, old `_sc` naming, schema-less normalization, and archived evaluation compatibility paths remain useful for historical provenance but are not active V5 retraining authority. Legacy SR Illusion is historical/negative evidence; do not turn its runtime capability into target-tracking claims.
## Durable engineering rules
Use one canonical entrypoint per contract, discover identities from metadata, preserve source hashes, stage derived outputs transactionally, never overwrite evidence, and put scope/metric/window/comparator beside every number. Documentation is a reader layer; immutable artifacts remain the evidence layer.
+52
View File
@@ -0,0 +1,52 @@
# DRL pinball
## Five-minute navigation
This package contains the modern V5 CelerisLab PPO workflow, retained V5 training/evaluation evidence, the historical Legacy compatibility/acquisition path, and reader-facing plots/data. It is a research reproduction and writing package; it is not a claim that every retained policy was retrained after the latest source-contract changes.
Read in this order:
1. `README.md` — scope, authority, and navigation.
2. `CLAIMS.md` — claim-status ledger and prohibited interpretations.
3. `RESULTS.md` — conclusion-to-result-family summary.
4. `WRITING_HANDOFF.md` — manuscript-ready wording, evidence boundaries, and known caveats.
5. `DATA_DICTIONARY.md` — directory, artifact, CSV, units, and metric definitions.
6. `ASSET_STATUS.md` — active, retained, derived, legacy, archive, and external layers.
7. `HISTORY_AND_LESSONS.md` — chronology, retired routes, and lessons that prevent misreading.
8. `knowledge.md` — legacy historical notes; non-authoritative for active V5 and to be read only after current contracts.
6. `train/README.md``train/TRAIN_PIPELINE.md``train/DESIGN_DECISIONS.md` — active V5 execution contracts.
7. `eval/README.md` — retained-policy evaluation and compatibility boundary.
8. `plot_reproduction_summary.py` and `plot_flow_fields.py` — CPU/offline plotting entrypoints.
9. `train/results/latest/HOW_TO_READ.md` — current retained-results figure and table index.
## Scientific scope
The active DRL scope is modern V5 Kármán cloaking and hydrodynamic illusion. Legacy Kármán, Illusion, steady, vortex, erase, and SR roles are preserved for historical comparison, acquisition compatibility, or contextual calibration; they must not be silently pooled with V5.
The retained V5 scratch matrix contains 15 retained runs across 11 canonical cases; the clean per-iteration CSV contains 14 runs because Re60 is quarantined: five seeds for `kar_re100`, and one seed for each other active case. Only Re100 supports a seed-level spread statement. All other cases are demonstrations (`n=1`).
The most defensible retained-training wording is: **Re100 repeatedly discovers high-reward policies, but late-policy retention is seed-dependent**. Do not call this universal robustness, post-fix retraining, or stable convergence. Variable-disturbance comparisons are confounded because `kar_d075` and `kar_d2` use learning rate `1e-4`, while most runs use `3e-4`.
## Evidence authority
- Training best reward and best iteration: each run's `meta.json`.
- Same-iteration reward components: `train.log`, when present.
- Per-iteration curves: retained TensorBoard `tb/`, exported to `train/results/training_csv/training_iterations.csv`; this is an auxiliary time series, not a replacement for `meta.json`.
- Retained deterministic evaluation: `eval/output/train/*/metrics.json` and `all_seeds.json`, evaluated through the explicit `legacy-policy-v1` compatibility path.
- Figures/tables: derived reader views; they do not create new CFD evidence.
- Repository data symlinks may resolve to external storage and are intentionally nonportable; manifests and metadata remain the provenance authority.
## Reproduce reader outputs without CFD
```bash
PYTHONPATH="$PWD/src" PYTHONNOUSERSITE=1 conda run -n pycuda_3_10 \
python src/drl_pinball/train/export_training_csv.py
PYTHONPATH="$PWD/src" PYTHONNOUSERSITE=1 conda run -n pycuda_3_10 \
python src/drl_pinball/plot_reproduction_summary.py --campaign ppo
```
The first command regenerates only `training_iterations.csv` and its manifest from retained training artifacts; it does not touch figures or evaluation tables. The second renders compact role summaries. Neither command runs CFD. For retained flow-field plots, `plot_flow_fields.py --phase-index 0` deliberately renders only the first of the eight stored periodic phase slots; event and late-field semantics remain separate.
## Environment and safety
Use `pycuda_3_10` with `PYTHONNOUSERSITE=1` on the GPU training/acquisition path. Do not run CFD merely to read or document retained artifacts. Never overwrite evidence directories; use a fresh output path for new derived packages. Keep V5 CelerisLab and LegacyCelerisLab evidence as separate plants and normalization contracts.
+16
View File
@@ -0,0 +1,16 @@
# DRL pinball retained results
## Bottom line
The retained V5 evidence supports a bounded training observation: Re100 scratch runs repeatedly found high-reward checkpoints, but late-policy retention was seed-dependent. Cross-Re, disturbance-size, and Illusion points are named-condition demonstrations, not a continuous law or robustness study.
## Result families
1. **Training discovery/retention:** use `train/results/figures/scratch_training_summary.csv` and `train/results/latest/figures/fig01*``fig05*`. Best values resolve to `meta.json`; late retention comes from the retained TensorBoard stream.
2. **Retained deterministic evaluation:** use `train/results/latest/tables/latest_eval_summary.csv` and `HOW_TO_READ.md`. This is a separate historical-policy compatibility stream, not the training-best measurement.
3. **Per-iteration plotting data:** use `train/results/training_csv/training_iterations.csv`; read its manifest before analysis. Re60 is excluded because its resumed TensorBoard branches conflict and include mixed-config history.
4. **Flow-role reproduction:** use `data/reproduction/*/metadata.json`, role manifests, `plot_flow_fields.py`, and the wake-L2 summaries. Phase 0 is the current default plot selection; event/late roles are not phase roles.
## Safe interpretation
Reward and DTW are objective/signal metrics. They are not interchangeable with field error, causal effect, mechanism, response time, or stability. Only Re100 has n=5. `kar_d075` and `kar_d2` use `1e-4` learning rate while most runs use `3e-4`, so the disturbance sweep is not a pure one-factor ablation.
+25
View File
@@ -0,0 +1,25 @@
# DRL pinball writing handoff
## Safe manuscript positioning
The retained modern V5 package documents a calibrated PPO workflow and a bounded scratch-training matrix. It supports descriptive statements about policy discovery, case-dependent reward components, retained evaluation artifacts, action saturation, and field/phase comparisons at named conditions. It does not by itself support universal Reynolds-number generalization, uncertainty bounds outside Re100, post-fix retraining claims, asymptotic stability, causal mechanism, or physical delay from DTW.
Recommended sentence: “Across five Re100 scratch seeds, high-reward checkpoints were repeatedly discovered, while late-policy retention varied substantially between seeds.”
Avoid: “The controller converged robustly,” “the method generalizes across Reynolds number,” or “DTW demonstrates a delayed causal response.”
## Evidence chains
Training: `train/output/*/meta.json` → same-iteration `train.log` components → TensorBoard curves exported to `train/results/latest/tables/training_iterations.csv`.
Retained evaluation: `eval/output/train/*/metrics.json` + `all_seeds.json` → explicit legacy-policy-v1 compatibility → figures/tables under `train/results/latest`.
Flow roles: `data/reproduction` metadata/manifests → phase/event/late NPZ and CSV → `plot_flow_fields.py` or `plot_reproduction_summary.py`. V5 and Legacy remain separate.
## Writing checklist
For every result, record: case ID, scene, seed/role, n, target/reference, metric formula, time window, units/normalization, artifact path, and evidence status. Separate training best, late retention, deterministic retained evaluation, and field error. State when a value is a reader-derived product.
## Agent restart
Read this file, then `README.md`, `DATA_DICTIONARY.md`, `HISTORY_AND_LESSONS.md`, and the relevant `train/` or `eval/` document. Search `case_registry.py` before adding cases. Recall the durable DRL memory before changing claims or contracts. Do not start CFD for documentation work.
@@ -1,33 +1,30 @@
# SR reproduction plots
This directory is a derived diagnostic plotting package for standardized Legacy acquisition. It is not the acquisition payload and it is not, by itself, scientific evidence that every case or role completed.
This directory is a derived diagnostic plotting package for the standardized Legacy acquisition. It is not the acquisition payload and it is not, by itself, evidence that every campaign role completed.
## Current status: partial diagnostic
## Current status: partial Kármán package
`manifest.json` currently declares `allow_partial: true`, `plot_count: 2`, and `expected_plot_count: 23`. The two rendered groups are Legacy `karman_re100` and `illusion_1L`. Therefore this directory is a **2/23 partial diagnostic**, not a complete SR reproduction package and not sufficient evidence for campaign completeness, generalization, deletion necessity, or a current Illusion result. Its absolute source paths and recorded SHA-256 values identify the inputs used for those two plots; they do not imply that the other 21 expected plots exist or passed acquisition validation.
`manifest.json` currently records 11 rendered plots out of the 23-role SR matrix (`allow_partial: true`). The package contains the available Kármán target/SR/zero comparisons and registered SR-term deletion diagnostics. The invalid Illusion SR role directories were removed from the reproduction store after the Illusion SR route was closed; Illusion is historical/negative evidence and is not regenerated here.
The current standardized reproduction claim is active for Kármán SR. The included Illusion panel is historical/negative diagnostic evidence only: it can expose behavior or incompatibility, but it must not be cited as a current validated Illusion SR reproduction. A visually plausible panel cannot override missing campaign roles, partial-manifest status, or acquisition/provenance gates.
The missing entries are expected to remain absent until their acquisition inputs exist. This package must not be relabelled complete by changing the manifest alone. Absolute source paths and SHA-256 values identify the inputs used for each rendered plot.
## Acquisition contract represented by a complete package
## Plot contract
For standard periodic Kármán and Illusion acquisition, `legacy_test/acquire.py` distinguishes:
Training cases contain `Target`, `PPO`, `SR`, and physical `Zero`; generalization cases intentionally contain `Target`, `SR`, and physical `Zero` only. Variant plots contain `Target`, the same-case parent SR, the named variant SR, and physical `Zero`.
- `Target`: live target-only trajectory, with no policy action, policy normalization, or native reward metric.
- `PPO`: `controlled`, the frozen-policy Legacy trajectory.
- `Zero (physical, no control)`: normalized counter-bias that yields zero physical cylinder rotation; it is not normalized-action zero when a bias exists.
- `SR`: the canonical causal symbolic policy.
- named SR deletion diagnostics: one registered term removed and bound to the same-case canonical SR parent.
Each periodic role uses the validated phase-field artifact and plots the first phase-aligned field snapshot together with the six-channel sensor phase portrait. Every vorticity panel uses the fixed symmetric range `[-0.001, +0.001]`.
Each standard role has 480 warm-up intervals followed by exactly 160 retained post-step boundaries. Role-local phase comes from smoothed rising zero crossings of center `uy` (`sensors[:,3]`) with a minimum-gap filter. The field artifact contains exactly eight nearest boundary snapshots at the eight requested phases; these are unaveraged snapshots selected across complete cycles. Its `mean_ux` and `mean_uy` are arithmetic means over all retained boundary fields in complete half-open cycles, not means of those eight snapshots.
The renderer records metadata, telemetry, and field-file hashes in the manifest. It stages the complete derived package and replaces the output atomically; `--allow-partial` is diagnostic mode and must remain explicitly labelled as partial.
Telemetry keeps the native Legacy reward DTW where available and adds a distinct target-channel-max-abs-normalized rolling six-channel cycle DTW. These must remain separately labelled; target trajectories do not have native reward DTW.
## Regeneration
Acquisition publishes only after same-filesystem staging and exact schema validation. Outputs are no-clobber unless overwrite is explicit, prior output is restored if atomic replacement fails, and failed runs remove staging rather than publishing partial evidence.
From the repository root:
Generalization cases intentionally contain `Target`, `SR`, and physical `Zero` only; absence of PPO is part of the acquisition design, not missing data. Canonical SR and deletion variants share `SR_analysis.stage_3_validate.SymbolicPolicy`, while metadata binds formula-file hashes, artifact IDs, the pair hash, and deployment-expression hashes.
```bash
PYTHONPATH=src python3 -m drl_pinball.plot_reproduction_summary \
--campaign sr --allow-partial \
--data-root src/drl_pinball/data/reproduction \
--output-root src/drl_pinball/data/reproduction_plots_sr
```
## Operational warning
Legacy compiler configuration and PTX output are shared files (`LegacyCelerisLab/kernels/macros.h` and `kernel.ptx`). CFD configurations must not compile concurrently. Run them serially and stagger shared-file configuration changes so the previous GPU context and compilation have fully released the files before the next case starts.
Regenerating this directory should use the plotting command's complete-campaign mode for evidence publication. `--allow-partial` is appropriate only for diagnostics and must continue to be labelled partial.
The Legacy compiler writes shared PTX/configuration files during initialization. Do not initialize CFD cases concurrently; stagger GPU launches until the previous compilation has released those files.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 534 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 636 KiB

After

Width:  |  Height:  |  Size: 726 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 822 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 669 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 910 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 579 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 488 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 727 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 715 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 792 KiB

File diff suppressed because it is too large Load Diff
@@ -0,0 +1,49 @@
import json
import numpy as np
from drl_pinball import plot_reproduction_summary as plots
def test_sr_plot_matrix_roles_and_no_ppo(tmp_path):
specs=plots.sr_plot_specs(tmp_path)
assert len(specs)==23
training=[roles for group,roles,name in specs if group in plots.SR_TRAINING and not name.startswith("variants/")]
generalization=[roles for group,roles,name in specs if group in plots.SR_GENERALIZATION]
variants=[roles for _,roles,name in specs if name.startswith("variants/")]
assert len(training)==7 and all([label for label,_ in roles]==["Target","PPO","SR","Zero (physical, no control)"] for roles in training)
assert len(generalization)==9 and all([label for label,_ in roles]==["Target","SR","Zero (physical, no control)"] for roles in generalization)
assert len(variants)==7 and all([label for label,_ in roles][:2]==["Target","Parent SR"] for roles in variants)
assert all("PPO" not in [label for label,_ in roles] for roles in generalization)
def test_sr_partial_renderer_and_source_hash(tmp_path,monkeypatch):
data=tmp_path/"data"; stage=tmp_path/"stage"
first=plots.sr_plot_specs(data)[0]
for _,path in first[1]: path.mkdir(parents=True)
calls=[]
monkeypatch.setattr(plots,"plot_role_set",lambda stage,data_root,group,roles,output_name:calls.append((group,roles,output_name)) or {"group":group})
entries=plots.render_sr_package(stage,data,allow_partial=True)
assert entries==[{"group":first[0]}] and len(calls)==1
source=tmp_path/"source.bin"; source.write_bytes(b"canonical")
assert plots.sha256_file(source)=="0deeb8fa1dbbee4c0dbe7f5e3c9183940139f26d22797ee8ab07c00557a4c2ff"
def test_npz_renderer_hashes_every_source(tmp_path):
data=tmp_path/"data"; stage=tmp_path/"stage"; case="karman_re25"
roles=[]
for label,role in (("Target","target"),("SR","sr"),("Zero (physical, no control)","zero")):
rd=data/"legacy"/case/role; rd.mkdir(parents=True)
(rd/"metadata.json").write_text(json.dumps({"units":{"sensors":"lattice"}}))
values=np.linspace(-1,1,10)
columns=["physical_time"]+[f"sensors_{index}" for index in range(6)]
table=np.column_stack((np.arange(10),*[values*(index+1) for index in range(6)]))
np.savetxt(rd/"timeseries.csv",table,delimiter=",",header=",".join(columns),comments="")
field=np.zeros((8,4,5),np.float32); field[:,1,2]=1e-3
np.savez(rd/"phase_fields.npz",ux=field,uy=field,target_phase=np.arange(8)*np.pi/4)
roles.append((label,rd))
entry=plots.plot_role_set(stage,data,case,tuple(roles),case)
assert (stage/entry["plot"]).is_file()
assert entry["sensor_limits"]["u"]==entry["sensor_limits"]["u"]
assert all(len(source["sha256"])==64 for role in entry["roles"] for source in role["source_files"])
assert all(len(role["source_files"])==3 for role in entry["roles"])
+195
View File
@@ -0,0 +1,195 @@
#!/usr/bin/env python3
"""Render every retained reproduction flow-field artifact."""
from __future__ import annotations
import argparse, hashlib, json, os, shutil, tempfile
from dataclasses import dataclass
from pathlib import Path
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.colors import BoundaryNorm, ListedColormap
from matplotlib.patches import Circle, FancyArrowPatch, Rectangle
from matplotlib.colorbar import ColorbarBase
from matplotlib.ticker import FuncFormatter
import numpy as np
L0=20.0; U0=.01; PINBALL_RADIUS=10.0
VORT_CMAP="RdBu_r"; ERROR_CMAP="Greys"; STREAM_DENSITY=2.4; ROI_STREAM_DENSITY=1.2
V5_KARMAN_RADII={"kar_re60":1.0,"kar_re100":1.0,"kar_re200":1.0,"kar_re400":1.0,"kar_d075":.75,"kar_d15":1.5,"kar_d2":2.0}
ILLUSION_RADII={"ill_075L":.75,"ill_1L":1.0,"ill_15L":1.5,"ill_2L":2.0,"illusion_05L":.5,"illusion_06L":.6,"illusion_075L":.75,"illusion_08L":.8,"illusion_1L":1.0,"illusion_12L":1.2,"illusion_15L":1.5,"illusion_2L":2.0}
@dataclass(frozen=True)
class Role:
label:str; path:Path; generation:str; case_id:str; scene:str; scenario:str; role_id:str
def sha256(path):
h=hashlib.sha256()
with path.open("rb") as f:
for b in iter(lambda:f.read(1<<20),b""): h.update(b)
return h.hexdigest()
def meta(path):
q=path/"metadata.json"
return json.loads(q.read_text()) if q.is_file() else {}
def artifact(path):
for name in ("phase_fields.npz","event_fields.npz","late_field.npz"):
if (path/name).is_file(): return path/name
return None
def discover(root, generation=None, case=None):
result=[]
for p in sorted(root.glob("**/metadata.json")):
d=p.parent; f=artifact(d)
if f is None: continue
md=meta(d); gen=d.relative_to(root).parts[0]
if generation and gen!=generation: continue
case_id=str(md.get("case_id") or d.relative_to(root).parts[1])
if case and case_id!=case: continue
scene=str(md.get("scene") or ("illusion" if case_id.startswith("ill_") else "karman"))
scenario=d.relative_to(root).parts[1]
role=str(md.get("role") or d.name)
result.append(Role(role,d,gen,case_id,scene,scenario,d.name))
if not result: raise ValueError(f"no supported field artifacts under {root}")
return result
def load_artifact(role, cache=None, phase_index=0):
path=artifact(role.path)
source_info=cache.get(path) if cache is not None else None
if source_info is None:
source_info={"source_sha256":sha256(path)}
with np.load(path,allow_pickle=False) as z:
keys=set(z.files); ux=np.asarray(z["ux"]); uy=np.asarray(z["uy"])
if "phase_fields.npz" in path.name:
required={"ux","uy","field_indices","cycle_id","target_phase","actual_phase","phase_error"}
if not required<=keys or ux.ndim!=3 or uy.shape!=ux.shape or ux.shape[0]!=8: raise ValueError(f"{path}: invalid phase schema")
if not np.isfinite(ux).all() or not np.isfinite(uy).all(): raise ValueError(f"{path}: nonfinite field")
offsets=np.asarray(z["actual_phase"])-np.asarray(z["target_phase"])
if not 0 <= phase_index < 8: raise ValueError(f"phase index must be in [0, 7], got {phase_index}")
i = int(phase_index)
result=(path,ux[i:i+1],uy[i:i+1],[{"mode":"phase","index":i,"offset":float(offsets[i])}],bool({"mean_ux","mean_uy"}<=keys),source_info,sorted(keys))
if cache is not None: cache[path]={"source_sha256":source_info["source_sha256"],"schema":sorted(keys)}
return result
if "event_fields.npz" in path.name:
required={"ux","uy","field_indices","relative_offsets"}
if not required<=keys or ux.ndim!=3 or uy.shape!=ux.shape: raise ValueError(f"{path}: invalid event schema")
offsets=np.asarray(z["relative_offsets"])
result=(path,ux,uy,[{"mode":"event","index":int(i),"offset":float(offsets[i])} for i in range(len(ux))],False,source_info,sorted(keys))
if cache is not None: cache[path]={"source_sha256":source_info["source_sha256"],"schema":sorted(keys)}
return result
if ux.ndim!=3 or uy.shape!=ux.shape or ux.shape[0]!=1: raise ValueError(f"{path}: invalid late schema")
result=(path,ux,uy,[{"mode":"late","index":0,"offset":None}],False,source_info,sorted(keys))
if cache is not None: cache[path]={"source_sha256":source_info["source_sha256"],"schema":sorted(keys)}
return result
def geometry(role,shape):
ny,nx=shape; cy=(ny-1)/2
scene=role.scene; target=role.label=="target"
sensors_x=600 if scene=="illusion" and role.generation=="v5" else (1200 if scene=="karman" and role.generation=="v5" else 600 if scene=="illusion" else 800)
sensors=[(sensors_x,cy+y) for y in (40,0,-40)]
if scene=="karman":
if role.generation=="v5":
if role.case_id not in V5_KARMAN_RADII:
raise ValueError(f"no canonical V5 Karman radius for {role.case_id}")
radius=V5_KARMAN_RADII[role.case_id]*L0
bodies=[(600,cy,radius)] if target else [(600,cy,radius),(1000,cy,10),(1026,cy+15,10),(1026,cy-15,10)]
else: bodies=[(200,cy,20)] if target else [(200,cy,20),(600,cy,10),(626,cy+15,10),(626,cy-15,10)]
elif scene=="illusion":
if role.case_id not in ILLUSION_RADII:
raise ValueError(f"no canonical illusion radius for {role.case_id}")
r=ILLUSION_RADII[role.case_id]*L0 if target else 10.0
bodies=[(400 if role.generation=="v5" else 400,cy,r)] if target else [(380 if role.generation!="v5" else 380,cy,10),(406 if role.generation!="v5" else 406,cy+15,10),(406,cy-15,10)]
elif scene=="vortex": bodies=[] if target else [(600,cy,10),(626,cy+15,10),(626,cy-15,10)]
elif scene=="erase": bodies=[] if target else [(200,cy,15),(600,cy,10),(626,cy+15,10),(626,cy-15,10)]
elif scene=="steady": bodies=[] if target else [(600,cy,10),(626,cy+15,10),(626,cy-15,10)]
else: raise ValueError(f"unsupported scene {scene}")
return bodies,sensors,"analytic builder geometry"
def masks(role,labels,shape):
yy,xx=np.ogrid[:shape[0],:shape[1]]; solid=np.zeros(shape,bool); sensor=np.zeros(shape,bool)
for r in labels:
bodies,sensors,_=geometry(r,shape)
for x,y,rad in bodies: solid|=(xx-x)**2+(yy-y)**2<=rad**2
for x,y in sensors: sensor|=(xx-x)**2+(yy-y)**2<=25
return solid,sensor
def roi(role,shape):
ny,nx=shape; _,sensors,_=geometry(role,shape); xs=sensors[1][0]; cy=(ny-1)/2; D=20
x0=max(0,int(xs-6*D)); x1=min(nx,int(xs+14*D)+1); y0=max(0,int(cy-5*D)); y1=min(ny,int(cy+5*D)+1)
return slice(y0,y1),slice(x0,x1)
def vorticity(ux,uy):
out=np.zeros_like(ux,dtype=np.float64); out[1:-1,1:-1]=(uy[1:-1,2:]-uy[1:-1,:-2])/2-(ux[2:,1:-1]-ux[:-2,1:-1])/2; return out
def discrete(name,lo,hi,bins):
edges=np.linspace(lo,hi,bins+1); colors=plt.get_cmap(name)(np.linspace(0,1,bins))
if name==VORT_CMAP and bins%2: colors[bins//2]=[1,1,1,1]
return edges,BoundaryNorm(edges,bins),ListedColormap(colors)
def new_axes(nx,ny):
fig=plt.figure(figsize=(nx/100,ny/100),dpi=100,facecolor="white"); ax=fig.add_axes([0,0,1,1]); ax.set_xlim(-.5,nx-.5); ax.set_ylim(-.5,ny-.5); ax.set_aspect("equal"); ax.axis("off"); return fig,ax
def overlays(ax,role,labels,shape,fill=False):
for r in labels:
bodies,sensors,_=geometry(r,shape)
for x,y,rad in bodies: ax.add_patch(Circle((x,y),rad,facecolor="white" if fill else "none",edgecolor="black",linewidth=.7,zorder=5))
if r is labels[0]:
for x,y in sensors: ax.add_patch(Circle((x,y),5,facecolor="#00a650" if fill else "none",edgecolor="#00a650",linewidth=.8,zorder=6))
ys,xs=roi(role,shape); ax.add_patch(Rectangle((xs.start-.5,ys.start-.5),xs.stop-xs.start,ys.stop-ys.start,fill=False,edgecolor="black",linewidth=.8,zorder=7))
def save_field(data,out,role,labels,lo,hi,bins,kind="vorticity",zero_contour=False):
ny,nx=data.shape; edges,norm,cmap=discrete(VORT_CMAP if kind=="vorticity" else ERROR_CMAP,lo,hi,bins); fig,ax=new_axes(nx,ny); rgba=cmap(norm(data)); solid,sensor=masks(role,labels,(ny,nx)); rgba[solid]=[1,1,1,1]; rgba[sensor]=[0,.65,.31,1]; ax.imshow(rgba,origin="lower",interpolation="nearest",extent=(-.5,nx-.5,-.5,ny-.5))
if zero_contour:
ax.contour(np.ma.array(data,mask=solid|sensor),levels=[0],colors=["#777"],linewidths=.45,corner_mask=False); cover=np.zeros_like(rgba); cover[solid]=[1,1,1,1]; cover[sensor]=[0,.65,.31,1]; ax.imshow(cover,origin="lower",interpolation="nearest",extent=(-.5,nx-.5,-.5,ny-.5),zorder=4)
overlays(ax,role,labels,(ny,nx)); fig.savefig(out,dpi=100,pad_inches=0); plt.close(fig); return edges
def stream_no_arrows(ax,*args,**kw):
s=ax.streamplot(*args,**kw); s.arrows.set_visible(False)
for p in tuple(ax.patches):
if isinstance(p,FancyArrowPatch): p.remove()
return s.lines
def save_stream(ux,uy,out,role,labels):
ny,nx=ux.shape; fig,ax=new_axes(nx,ny); solid,_=masks(role,labels,(ny,nx)); x=np.arange(nx); y=np.arange(ny); mu=np.ma.array(ux,mask=solid); mv=np.ma.array(uy,mask=solid); stream_no_arrows(ax,x,y,mu,mv,density=STREAM_DENSITY,color="black",linewidth=.45); ys,xs=roi(role,(ny,nx)); lines=stream_no_arrows(ax,x[xs],y[ys],mu[ys,xs],mv[ys,xs],density=ROI_STREAM_DENSITY,color="black",linewidth=.45); lines.set_clip_path(Rectangle((xs.start-.5,ys.start-.5),xs.stop-xs.start,ys.stop-ys.start,transform=ax.transData)); overlays(ax,role,labels,(ny,nx),True); fig.savefig(out,dpi=100,pad_inches=0); plt.close(fig)
def colorbar_ticks(edges,max_ticks,include_zero):
if include_zero and len(edges)%2==0:
n=len(edges)-1; center=n//2; candidates=np.linspace(0,len(edges)-1,min(max_ticks,len(edges))).round().astype(int); candidates=[i for i in np.unique(candidates) if i not in (center,center+1)]
ticks=list(edges[candidates]); ticks.append(0.0); return np.asarray(sorted(set(ticks)))
ids=np.rint(np.linspace(0,len(edges)-1,min(max_ticks,len(edges)))).astype(int); ticks=list(edges[np.unique(ids)])
if include_zero and edges[0]<0<edges[-1] and not any(np.isclose(t,0) for t in ticks): ticks.append(0.)
return np.asarray(sorted(ticks))
def colorbar(out,kind,lo,hi,bins,orientation):
edges,norm,cmap=discrete(VORT_CMAP if kind=="vorticity" else ERROR_CMAP,lo,hi,bins); fig=plt.figure(figsize=((1.6,4) if orientation=="vertical" else (10,1.2)),dpi=100,facecolor="white"); ax=fig.add_axes(([.18,.08,.18,.84] if orientation=="vertical" else [.06,.46,.88,.24])); cb=ColorbarBase(ax,cmap=cmap,norm=norm,boundaries=edges,orientation=orientation); cb.set_ticks(colorbar_ticks(edges,min(11,bins+1) if orientation=="vertical" else min(7,bins+1),kind=="vorticity")); axis=cb.ax.xaxis if orientation=="horizontal" else cb.ax.yaxis; axis.set_major_formatter(FuncFormatter(lambda v,_: f"{v:.1e}" if kind=="vorticity" else f"{v:g}")); cb.set_label(kind); fig.savefig(out,dpi=100,pad_inches=0); plt.close(fig)
def main(argv=None):
here=Path(__file__).resolve().parent; p=argparse.ArgumentParser(description=__doc__); p.add_argument("--data-root",type=Path,default=here/"data/reproduction"); p.add_argument("--output-root",type=Path,default=here/"data/reproduction_flow_plots/all"); p.add_argument("--generation",choices=("v5","legacy")); p.add_argument("--case"); p.add_argument("--phase-index",type=int,default=0,help="phase slot to render from each 8-phase cycle (default: 0)"); p.add_argument("--v5-vorticity-limit",type=float,default=.0005); p.add_argument("--legacy-vorticity-limit",type=float,default=.0015); p.add_argument("--vorticity-bins",type=int,default=21); p.add_argument("--error-limit",type=float,default=100); p.add_argument("--error-bins",type=int,default=10); a=p.parse_args(argv); root=a.data_root.resolve(); out=a.output_root.resolve(); roles=discover(root,a.generation,a.case); out.parent.mkdir(parents=True,exist_ok=True); stage=Path(tempfile.mkdtemp(prefix=".all-",dir=out.parent)); entries=[]; groups={}; source_cache={}
try:
for r in roles:
group_key=(r.generation,r.case_id,r.scene,r.scenario) if r.scene=="vortex" else (r.generation,r.case_id,r.scene)
groups.setdefault(group_key,[]).append(r)
for key,group in sorted(groups.items()):
gen,case,scene,*group_scenarios=key; target=next((r for r in group if r.label=="target"),None); target_fields={}; target_has_mean=False
if target:
_,tu,tv,td,target_has_mean,target_info,target_schema=load_artifact(target, source_cache, a.phase_index); target_fields={x["index"]:(tu[i],tv[i],x) for i,x in enumerate(td)}
for r in group:
path,uxs,uys,descs,has_mean,source_info,schema=load_artifact(r, source_cache, a.phase_index); base=stage/gen/case/r.scenario/r.role_id
for i,d in enumerate(descs):
ux,uy=uxs[i],uys[i]; stem=f'{d["mode"]}{d["index"]}'+(f'_offset{d["offset"]:+.3f}' if d["offset"] is not None else ''); vp=base/"vorticity"/(stem+".png"); sp=base/"streamlines"/(stem+".png"); vp.parent.mkdir(parents=True,exist_ok=True); sp.parent.mkdir(parents=True,exist_ok=True); limit=a.v5_vorticity_limit if gen=="v5" else a.legacy_vorticity_limit; ve=save_field(vorticity(ux,uy),vp,r,[r],-limit,limit,a.vorticity_bins,"vorticity",gen=="legacy"); save_stream(ux,uy,sp,r,[r]); e={"generation":gen,"case_id":case,"scenario":r.scenario,"role":r.label,"role_id":r.role_id,"scene":scene,"field_mode":d["mode"],"field_index":d["index"],"relative_offset":d["offset"],"source":str(path),"source_sha256":source_info["source_sha256"],"schema":schema,"shape_xy":[ux.shape[1],ux.shape[0]],"image_dimensions":[ux.shape[1],ux.shape[0]],"vorticity":str(vp.relative_to(stage)),"streamlines":str(sp.relative_to(stage)),"vorticity_edges":ve.tolist(),"geometry_provenance":geometry(r,ux.shape)[2]}
ref=target_fields.get(d["index"]) if target and scene not in ("erase",) else target_fields.get(0) if target and scene=="erase" else None
if r.label!="target" and ref is not None and ref[0].shape==ux.shape:
err=np.minimum(a.error_limit,100*np.hypot(ux-ref[0],uy-ref[1])/U0); ep=base/"error"/(stem+".png"); ep.parent.mkdir(parents=True,exist_ok=True); e["error"]=str(ep.relative_to(stage)); e["error_edges"]=save_field(err,ep,r,[r,target],0,a.error_limit,a.error_bins,"error",False).tolist()
elif r.label!="target": e["error_skipped_reason"]="no safely aligned target field"
entries.append(e)
if has_mean and target_has_mean and r.label!="target" and target and target_fields:
with np.load(path,allow_pickle=False) as z: mu,mv=np.asarray(z["mean_ux"]),np.asarray(z["mean_uy"])
with np.load(artifact(target.path),allow_pickle=False) as z: tu,tv=np.asarray(z["mean_ux"]),np.asarray(z["mean_uy"])
if mu.shape==tu.shape:
err=np.minimum(a.error_limit,100*np.hypot(mu-tu,mv-tv)/U0); mp=base/"error"/"mean.png"; mp.parent.mkdir(parents=True,exist_ok=True); entries[-1]["mean_error"]=str(mp.relative_to(stage)); entries[-1]["mean_error_edges"]=save_field(err,mp,r,[r,target],0,a.error_limit,a.error_bins,"error",False).tolist()
cbbase=stage/gen/case; cbbase.mkdir(parents=True,exist_ok=True); colorbar(cbbase/"vorticity_colorbar.png","vorticity",-(a.v5_vorticity_limit if gen=="v5" else a.legacy_vorticity_limit),a.v5_vorticity_limit if gen=="v5" else a.legacy_vorticity_limit,a.vorticity_bins,"vertical"); colorbar(cbbase/"vorticity_colorbar_horizontal.png","vorticity",-(a.v5_vorticity_limit if gen=="v5" else a.legacy_vorticity_limit),a.v5_vorticity_limit if gen=="v5" else a.legacy_vorticity_limit,a.vorticity_bins,"horizontal"); colorbar(cbbase/"error_colorbar.png","error",0,a.error_limit,a.error_bins,"vertical"); colorbar(cbbase/"error_colorbar_horizontal.png","error",0,a.error_limit,a.error_bins,"horizontal")
manifest={"schema":"drl-pinball-flow-plots-v2","entries":entries,"settings":{"U0":U0,"L0":L0,"vorticity_bins":a.vorticity_bins,"error_bins":a.error_bins,"stream_density":STREAM_DENSITY,"roi_density":ROI_STREAM_DENSITY,"atomic_publish":True,"phase_index":a.phase_index},"inventory":{"field_entries":len(entries),"vorticity":len(entries),"streamlines":len(entries),"errors":sum("error" in e for e in entries),"mean_errors":sum("mean_error" in e for e in entries),"skipped_errors":sum("error_skipped_reason" in e for e in entries),"unique_sources":len(source_cache),"unique_cases":len({(e["generation"],e["case_id"]) for e in entries}),"unique_scenarios":len({(e["generation"],e["case_id"],e["scenario"]) for e in entries})}}; (stage/"manifest.json").write_text(json.dumps(manifest,indent=2)+"\n"); out.parent.mkdir(parents=True,exist_ok=True)
if out.exists(): shutil.rmtree(out)
os.replace(stage,out)
except Exception: shutil.rmtree(stage,ignore_errors=True); raise
print(f"wrote {len(entries)} field entries to {out}")
if __name__=="__main__": main()
+104 -11
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import argparse
import hashlib
import json
import os
import shutil
@@ -37,6 +38,15 @@ LEGACY_VORTEX = (
"vortex_taylor_y000", "vortex_taylor_yp1L", "vortex_taylor_yp2L",
)
LEGACY_GROUPS = LEGACY_PERIODIC + ("steady",) + LEGACY_VORTEX + ("erase",)
SR_TRAINING = LEGACY_PERIODIC
SR_GENERALIZATION = (
"karman_re25", "karman_re70", "karman_re150", "karman_re300",
"illusion_05L", "illusion_06L", "illusion_08L", "illusion_12L", "illusion_2L",
)
SR_VARIANT_CASE = {
"k_front0": "karman_re100", "k_rear0": "karman_re100", "k_rear1": "karman_re100",
"i_front0": "illusion_15L", "i_front1": "illusion_15L", "i_rear0": "illusion_15L", "i_rear1": "illusion_15L",
}
def fail(message: str) -> None:
@@ -65,7 +75,7 @@ def roles_for(root: Path, pipeline: str, group: str):
def field_spec(pipeline: str, group: str, role_dir: Path):
if pipeline == "v5" or group in LEGACY_PERIODIC or (group == "erase" and role_dir.name != "target"):
if pipeline == "v5" or group in LEGACY_PERIODIC + SR_GENERALIZATION or (group == "erase" and role_dir.name != "target"):
return "phase_fields.npz", "target_phase", 0.0
if group in LEGACY_VORTEX:
return "event_fields.npz", "relative_offsets", -10
@@ -124,6 +134,78 @@ def padded_limits(values):
return [lo - pad, hi + pad]
def sha256_file(path: Path):
digest = hashlib.sha256()
with path.open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def plot_role_set(stage: Path, data_root: Path, group: str, roles, output_name: str):
loaded = [load_role(path, "legacy", group) for _, path in roles]
shapes = {item[0].shape for item in loaded}
if len(shapes) != 1:
fail(f"role field shapes differ for SR/{group}: {shapes}")
all_sensors = np.concatenate([item[1] for item in loaded], axis=0)
xlim = padded_limits(all_sensors[:, [0, 2, 4]])
ylim = padded_limits(all_sensors[:, [1, 3, 5]])
ny, nx = next(iter(shapes)); extent = (0, (nx - 1) / L0, 0, (ny - 1) / L0)
fig, axes = plt.subplots(2, len(roles), figsize=(6 * len(roles), 8), constrained_layout=True, squeeze=False)
images = []
for col, ((label, _), (omega, sensors, units, _, _, _)) in enumerate(zip(roles, loaded)):
images.append(axes[0, col].imshow(omega, origin="lower", extent=extent, cmap="RdBu_r", vmin=-VORTICITY_LIMIT, vmax=VORTICITY_LIMIT, aspect="equal"))
axes[0, col].set_title(f"LEGACY / {group}{label}")
axes[0, col].set_xlabel("x/L0"); axes[0, col].set_ylabel("y/L0")
axes[0, col].set_xlim(extent[:2]); axes[0, col].set_ylim(extent[2:])
for (u, v, sensor_label), color in zip(SENSOR_PAIRS, SENSOR_COLORS):
axes[1, col].plot(sensors[:, u], sensors[:, v], color=color, lw=1.1, alpha=0.85, label=sensor_label)
axes[1, col].axhline(0, color="0.35", lw=0.7); axes[1, col].axvline(0, color="0.35", lw=0.7)
axes[1, col].grid(True, alpha=0.25); axes[1, col].set_xlim(xlim); axes[1, col].set_ylim(ylim)
axes[1, col].set_xlabel(f"sensor u ({units})"); axes[1, col].set_ylabel(f"sensor v ({units})")
axes[1, col].set_box_aspect(0.8); axes[1, col].legend(loc="best", frameon=False)
cbar = fig.colorbar(images[0], ax=axes[0, :], orientation="vertical", shrink=0.92, pad=0.015)
cbar.set_label(r"$\omega_z$ (lattice$^{-1}$)")
output = stage / "legacy" / f"{output_name}.png"; output.parent.mkdir(parents=True, exist_ok=True)
fig.savefig(output, dpi=160); plt.close(fig)
role_entries = []
for (label, path), item in zip(roles, loaded):
sources = [path / "metadata.json", path / "timeseries.csv", item[3]]
role_entries.append({"role": label, "source": str(path), "field_file": str(item[3]), "field_slot": 0,
"selector": item[4], "selector_value": float(item[5]),
"source_files": [{"path": str(source), "sha256": sha256_file(source), "bytes": source.stat().st_size} for source in sources]})
return {"pipeline": "legacy-sr", "group": group, "plot": f"legacy/{output_name}.png", "roles": role_entries,
"vorticity_limit": VORTICITY_LIMIT, "sensor_limits": {"u": xlim, "v": ylim}}
def sr_plot_specs(data_root: Path):
legacy = data_root / "legacy"; specs = []
for case in SR_TRAINING:
base = legacy / case
specs.append((case, (("Target", base / "target"), ("PPO", base / "controlled"), ("SR", base / "sr"), ("Zero (physical, no control)", base / "zero")), case))
for case in SR_GENERALIZATION:
base = legacy / case
specs.append((case, (("Target", base / "target"), ("SR", base / "sr"), ("Zero (physical, no control)", base / "zero")), case))
for variant, case in SR_VARIANT_CASE.items():
base = legacy / case
specs.append((case, (("Target", base / "target"), ("Parent SR", base / "sr"), (f"Variant SR ({variant})", base / f"sr_{variant}"), ("Zero (physical, no control)", base / "zero")), f"variants/{case}_{variant}"))
return specs
def render_sr_package(stage: Path, data_root: Path, allow_partial=False):
entries = []
for group, roles, output_name in sr_plot_specs(data_root):
missing = [str(path) for _, path in roles if not path.is_dir()]
if missing:
if allow_partial:
continue
fail(f"missing SR plot roles for {group}: {missing}")
entries.append(plot_role_set(stage, data_root, group, roles, output_name))
if not allow_partial and len(entries) != 23:
fail(f"expected 23 SR plots, got {len(entries)}")
return entries
def plot_group(stage: Path, data_root: Path, pipeline: str, group: str):
roles = roles_for(data_root, pipeline, group)
loaded = [load_role(path, pipeline, group) for _, path in roles]
@@ -171,27 +253,38 @@ def main() -> None:
here = Path(__file__).resolve().parent
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--data-root", type=Path, default=here / "data" / "reproduction")
parser.add_argument("--output-root", type=Path, default=here / "data" / "reproduction_plots")
parser.add_argument("--output-root", type=Path)
parser.add_argument("--campaign", choices=("ppo", "sr"), default="ppo")
parser.add_argument("--allow-partial", action="store_true")
args = parser.parse_args()
data_root, output_root = args.data_root.resolve(), args.output_root.absolute()
require_exact_groups(data_root)
data_root = args.data_root.resolve()
output_root = (args.output_root or here / "data" / ("reproduction_plots_sr" if args.campaign == "sr" else "reproduction_plots")).absolute()
if args.campaign == "ppo": require_exact_groups(data_root)
if output_root.is_symlink():
fail(f"refusing symlink output root: {output_root}")
output_root.parent.mkdir(parents=True, exist_ok=True)
stage = Path(tempfile.mkdtemp(prefix=f".{output_root.name}.staging-", dir=output_root.parent))
backup = output_root.with_name(f".{output_root.name}.old-{os.getpid()}")
try:
if args.campaign == "sr":
entries = render_sr_package(stage, data_root, allow_partial=args.allow_partial)
manifest = {"schema": "drl-pinball-sr-reproduction-plots-v1", "plot_count": len(entries),
"expected_plot_count": 23, "allow_partial": args.allow_partial,
"no_ppo_cases": list(SR_GENERALIZATION),
"no_ppo_contract": "Generalization conditions intentionally contain Target/SR/Zero only; PPO is absent by acquisition design.",
"variant_contract": "Each diagnostic binds the named variant to its same-case parent SR.",
"vorticity_limit": VORTICITY_LIMIT,
"vorticity_contract": "fixed symmetric [-0.001, +0.001] for every vorticity panel",
"plots": entries}
else:
entries = [plot_group(stage, data_root, "v5", group) for group in sorted(V5_GROUPS)]
entries += [plot_group(stage, data_root, "legacy", group) for group in LEGACY_GROUPS]
if len(entries) != 30:
fail(f"expected 30 plots, got {len(entries)}")
(stage / "manifest.json").write_text(json.dumps({
"schema": "drl-pinball-reproduction-plots-v1",
"plot_count": 30,
manifest = {"schema": "drl-pinball-reproduction-plots-v1", "plot_count": 30,
"vorticity_limit": VORTICITY_LIMIT,
"vorticity_contract": "fixed symmetric [-0.001, +0.001] for every vorticity panel",
"plots": entries,
}, indent=2) + "\n")
"vorticity_contract": "fixed symmetric [-0.001, +0.001] for every vorticity panel", "plots": entries}
(stage / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n")
if output_root.exists():
os.replace(output_root, backup)
os.replace(stage, output_root)
@@ -202,7 +295,7 @@ def main() -> None:
if backup.exists() and not output_root.exists():
os.replace(backup, output_root)
raise
print(f"wrote 30 plots and manifest to {output_root}")
print(f"wrote {len(entries)} plots and manifest to {output_root}")
if __name__ == "__main__":
@@ -0,0 +1,73 @@
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
_TRAIN = Path(__file__).resolve().parents[1] / "train"
sys.path.insert(0, str(_TRAIN))
from env_karman import compute_similarity
from normalization import (
CALIBRATION_SCHEMA_NATIVE_V2,
LEGACY_SENSOR_FACTOR,
POLICY_SENSOR_UNITS_LEGACY,
SENSOR_UNITS_NATIVE,
convert_legacy_calibration,
convert_legacy_harmonics,
convert_legacy_target,
dtw_sensor_factor,
policy_sensor_scale,
)
def _legacy_calibration():
return {"SENSOR_CC": 78.0, "SENS_SCALE": 0.78, "dtw_norm_scale": 0.234}
def test_calibration_conversion_and_policy_schema_behavior():
legacy = _legacy_calibration()
native = convert_legacy_calibration(legacy)
assert native["schema_version"] == CALIBRATION_SCHEMA_NATIVE_V2
assert native["sensor_units"] == SENSOR_UNITS_NATIVE
assert "SENSOR_CC" not in native
assert np.isclose(native["SENS_SCALE"], 0.01)
assert np.isclose(native["dtw_norm_scale"], 0.003)
assert dtw_sensor_factor(legacy) == LEGACY_SENSOR_FACTOR
assert dtw_sensor_factor(native) == 1.0
assert np.isclose(policy_sensor_scale(native), 0.01)
assert np.isclose(policy_sensor_scale(native, POLICY_SENSOR_UNITS_LEGACY), 0.78)
assert np.isclose(policy_sensor_scale(legacy), 0.78)
def test_target_and_harmonics_conversion_only_scale_sensor_channels():
target = np.arange(24, dtype=np.float32).reshape(3, 8)
converted = convert_legacy_target(target)
np.testing.assert_allclose(converted[:, :6], target[:, :6] / 78.0)
np.testing.assert_allclose(converted[:, 6:], target[:, 6:])
harmonics = [
{"dc": float(i + 1), "amps": [float(i + 2)], "freqs": [0.1], "phases": [0.2]}
for i in range(8)
]
converted_h = convert_legacy_harmonics(harmonics)
for i in range(6):
assert np.isclose(converted_h[i]["dc"], harmonics[i]["dc"] / 78.0)
assert np.isclose(converted_h[i]["amps"][0], harmonics[i]["amps"][0] / 78.0)
assert converted_h[6:] == harmonics[6:]
def test_dtw_similarity_is_invariant_under_unit_conversion():
rng = np.random.default_rng(7)
target = rng.normal(size=(150, 6))
state = rng.normal(size=(90, 6))
legacy_scale = 0.25
legacy_sim = compute_similarity(target, state, 30, legacy_scale)
native_sim = compute_similarity(
target / LEGACY_SENSOR_FACTOR,
state / LEGACY_SENSOR_FACTOR,
30,
legacy_scale / LEGACY_SENSOR_FACTOR,
)
assert np.isclose(native_sim, legacy_sim, atol=1e-12)
@@ -0,0 +1,49 @@
#!/usr/bin/env python3
"""Export retained V5 per-iteration TensorBoard metrics to CSV without redrawing figures."""
from __future__ import annotations
import argparse, csv, hashlib, json
from pathlib import Path
import numpy as np
from tensorboard.backend.event_processing.event_accumulator import EventAccumulator
from drl_pinball.train import plot_scratch_results as source
FIELDS = ("run","case_id","seed","training_iteration","eval_reward","eval_r_cd","eval_r_cl","eval_r_sim","running_best_reward","drawdown_from_running_best","is_authoritative_best_iteration","source_stream","source_role","current_contract_retraining_evidence")
def sha256(path: Path) -> str:
h=hashlib.sha256();
with path.open("rb") as f:
for chunk in iter(lambda:f.read(1<<20), b""): h.update(chunk)
return h.hexdigest()
def tensorboard_conflicts(tb_dir: Path) -> dict[str, int]:
accumulator=EventAccumulator(str(tb_dir),size_guidance={"scalars":0}); accumulator.Reload()
result={}
for short,tag in source.TAGS.items():
grouped={}
for event in accumulator.Scalars(tag): grouped.setdefault(int(event.step),[]).append(float(event.value))
result[short]=sum(len(values)>1 and len(set(values))>1 for values in grouped.values())
return result
def main() -> int:
here=Path(__file__).resolve().parent
ap=argparse.ArgumentParser(description=__doc__)
ap.add_argument("--train-root",type=Path,default=here/"output")
ap.add_argument("--output-dir",type=Path,default=here/"results"/"training_csv")
args=ap.parse_args(); args.output_dir.mkdir(parents=True,exist_ok=True)
discovered=source.discover_runs(args.train_root); rows=[]; runs=[]; excluded=[]
for run in sorted(discovered,key=lambda x:x.name):
conflicts=tensorboard_conflicts(run.path/"tb")
if any(conflicts.values()):
excluded.append({"run":run.name,"reason":"conflicting duplicate TensorBoard steps from resumed branches; excluded fail-closed","conflicting_steps_by_metric":conflicts,"known_provenance_warning":"kar_re60 includes mixed-config resume history, including a Re100 base-config segment"})
continue
runs.append(run)
meta=json.loads((run.path/"meta.json").read_text()); best=int(meta["best_episode"]); incumbent=np.maximum.accumulate(run.reward)
for i,step in enumerate(run.steps):
rows.append({"run":run.name,"case_id":run.case,"seed":run.seed,"training_iteration":int(step),"eval_reward":float(run.metrics["reward"][i]),"eval_r_cd":float(run.metrics["r_cd"][i]),"eval_r_cl":float(run.metrics["r_cl"][i]),"eval_r_sim":float(run.metrics["r_sim"][i]),"running_best_reward":float(incumbent[i]),"drawdown_from_running_best":float(incumbent[i]-run.reward[i]),"is_authoritative_best_iteration":int(step)==best,"source_stream":"TensorBoard eval/* scalars","source_role":"auxiliary per-iteration series; best point checked against meta.json","current_contract_retraining_evidence":False})
out=args.output_dir/"training_iterations.csv"
with out.open("w",newline="") as f:
w=csv.DictWriter(f,fieldnames=FIELDS,lineterminator="\n"); w.writeheader(); w.writerows(rows)
manifest={"schema":"drl-pinball-training-iterations-csv-v2","row_count":len(rows),"included_run_count":len(runs),"discovered_run_count":len(discovered),"case_count":len({r.case for r in runs}),"excluded_runs":excluded,"duplicate_policy":"fail closed: exclude any run with conflicting duplicate values at a TensorBoard step","source":"retained TensorBoard eval scalars","authority":"meta.json for best reward/iteration; TensorBoard is auxiliary","current_contract_retraining_evidence":False,"files":{"training_iterations.csv":{"sha256":sha256(out),"bytes":out.stat().st_size}}}
(args.output_dir/"manifest.json").write_text(json.dumps(manifest,indent=2)+"\n")
print(f"wrote {len(rows)} rows for {len(runs)} clean runs; excluded {len(excluded)} ambiguous runs -> {args.output_dir}"); return 0
if __name__=="__main__": raise SystemExit(main())
@@ -0,0 +1,30 @@
{
"schema": "drl-pinball-training-iterations-csv-v2",
"row_count": 7000,
"included_run_count": 14,
"discovered_run_count": 15,
"case_count": 10,
"excluded_runs": [
{
"run": "kar_re60_seed43",
"reason": "conflicting duplicate TensorBoard steps from resumed branches; excluded fail-closed",
"conflicting_steps_by_metric": {
"reward": 213,
"r_cd": 213,
"r_cl": 213,
"r_sim": 213
},
"known_provenance_warning": "kar_re60 includes mixed-config resume history, including a Re100 base-config segment"
}
],
"duplicate_policy": "fail closed: exclude any run with conflicting duplicate values at a TensorBoard step",
"source": "retained TensorBoard eval scalars",
"authority": "meta.json for best reward/iteration; TensorBoard is auxiliary",
"current_contract_retraining_evidence": false,
"files": {
"training_iterations.csv": {
"sha256": "8b75fdb6a32f13ef85f7e313f7ae75ce352dd8367c5679f3a3ce8fa262a5a490",
"bytes": 1777579
}
}
}
File diff suppressed because it is too large Load Diff