第二轮:整理两个工作目录

This commit is contained in:
Frank14f 2026-06-10 15:59:52 +08:00
parent d1b9922c6b
commit 096d9dcd0f
130 changed files with 13171 additions and 7263 deletions

View File

@ -17,9 +17,9 @@
#define NZ 1 #define NZ 1
#define DIM 2 #define DIM 2
#define NQ 9 #define NQ 9
#define VIS 0.008 #define VIS 0.004
#define RHO 1.0 #define RHO 1.0
#define U0 0.02 #define U0 0.01
// constants // constants
#define PI 3.141592653589793238 #define PI 3.141592653589793238
@ -33,5 +33,5 @@
#define V_TAYLOR 0b00000001 #define V_TAYLOR 0b00000001
// variables // variables
#define N_OBJS 7 #define N_OBJS 6
// #define N_SENS 2 // #define N_SENS 2

236
src/CCD_analysis/README.md Normal file
View File

@ -0,0 +1,236 @@
# CCD_analysis: Observable-Correlated Decomposition for Flow Control
## Overview
This directory implements the **CCD (Cross-Correlation Decomposition)** analysis
pipeline for the DynamisLab fluidic pinball project. While POD ranks modes by
fluctuation energy, CCD ranks modes by their correlation with a chosen
observable (force, action, or sensor signature), making it the right tool for
answering "which flow structures does the controller actually modulate?"
The pipeline covers four reference cases at Re=100 (code convention, Re_D=50):
| Case | Control | Target Type | Source |
|------|---------|-------------|--------|
| **pinball** | None (uncontrolled) | Periodic | Open-loop CFD |
| **steady_cloak** | Constant rotation (rear 5.1xU0) | Steady | Open-loop CFD |
| **karman_re100** | DRL PPO (d1a3o12_re100) | Periodic | PPO inference |
| **illusion_1L** | DRL PPO (d1a3o14_250525_imit_1L_2U_600S) | Periodic | PPO inference |
For background:
- `ccd_notes.md` -- execution plan and methodological discussion
- `ccd_knowledge.md` -- confirmed facts, lessons learned, and pitfalls
## Directory Structure
```
CCD_analysis/
configs.py # Unified scene metadata (4 cases)
configs/
config_cuda.json # Legacy CFD CUDA config (copied from CelerisLab)
config_flowfield.json # Legacy CFD flow field config (copied)
utils/
__init__.py # Non-pycuda exports (resampling, POD, CCD)
cfd_interface.py # LegacyCelerisLab wrapper (requires pycuda_3_10)
resampling.py # Phase resampling, POD, CCD algorithms (CPU-only)
data/
pinball/pinball/ # Uncontrolled pinball: sensors.npz, fields.npz
steady_cloak/steady_cloak/ # Steady cloak: sensors.npz, fields.npz
karman/karman_re100/ # Karman cloak: target.npz, norm.json, controlled.npz
illusion/illusion_1L/ # Illusion: target.npz, norm.json, controlled.npz
resampled/ # Phase-resampled data (24 pts/cycle)
ccd/ # CCD results (ccd_results.json)
steady/ # Steady metrics (steady_metrics.json)
scripts/
collect_karman.py # Karman cloak PPO inference -> data/karman/
collect_illusion.py # Illusion PPO inference -> data/illusion/
collect_pinball.py # Pinball baseline -> data/pinball/
collect_steady_cloak.py # Steady cloak open-loop -> data/steady_cloak/
resample.py # Phase resampling for periodic cases
ccd/
run_ccd.py # POD + force/action CCD computation
steady/
run_steady.py # Steady cloak metrics
```
## Key Design Decisions
### 1. Scene Metadata Driven
All scene parameters are defined once in `configs.py`, not hard-coded in
scripts. Each scene dict contains geometry, DRL parameters, and inference
settings. Adding a new scene means adding one dict.
### 2. Verified CFD Interface
`utils/cfd_interface.py` is adapted from `SR_analysis/utils/cfd_interface.py`
(which was itself verified against `analysis_crossre`). It contains the
environment-building functions that exactly replicate the legacy training
environments:
- `build_karman_cloak_env()` -- mirrors `legacy_env_karman_cloak_standard.py`
- `add_pinball()` -- norm computation + bias-action FIFO, configurable for
Karman (7 objects) and Illusion (6 objects) layouts
- `build_observation()`, `scale_action()` -- DRL obs/action helpers
- `compute_similarity()` -- lag-compensated DTW for reward validation
### 3. Data / Analysis Separation
- `data/` -- raw sensor/force/action/field arrays (.npz), one-time generation
- `ccd/`, `steady/` -- analysis results, regeneratable from `data/`
- `scripts/` -- inference pipelines that produce `data/`
### 4. Two-Pass Collection (PPO cases)
For DRL cases (karman, illusion), data collection uses a two-pass strategy:
1. **Closed-loop pass**: Run PPO inference, record `controlled.npz` with
actions/sensors/forces/rewards, validate similarity against target
2. **Open-loop replay** (optional): Reset to checkpoint, replay saved actions
without PPO, collect dense field snapshots for CCD
This decouples field sampling from PPO state management, ensuring the DRL
observation pipeline is not disturbed by field I/O.
### 5. Validation Gate
Each PPO case computes a similarity score (lag-compensated DTW between
controlled sensor signals and target reference). Only passing cases
(similarity >= 0.80 for Karman, >= 0.70 for Illusion) should proceed to CCD.
## Verified Data Quality
| Scene | Similarity | Notes |
|-------|-----------|-------|
| karman_re100 | 0.950 | Verified against analysis_crossre reference |
| illusion_1L | ~0.84 | Below thesis 0.975; under investigation |
| steady_cloak | N/A (steady) | Sensor std=0.000344, no residual shedding |
| pinball | N/A (baseline) | St=0.1125 at Re=100 (code) |
## Regeneration Commands
All commands run from repo root (`/home/frank14f/DynamisLab`).
### Data Generation (requires GPU, pycuda_3_10 env)
```bash
# Pinball baseline (uncontrolled, 6 objects)
conda run -n pycuda_3_10 python src/CCD_analysis/scripts/collect_pinball.py --device 2
# Steady cloak (open-loop constant rotation)
conda run -n pycuda_3_10 python src/CCD_analysis/scripts/collect_steady_cloak.py --device 2
# Karman cloak re100 (PPO, 7 objects)
conda run -n pycuda_3_10 python src/CCD_analysis/scripts/collect_karman.py --device 2 --steps 200
# 1L Illusion (PPO, 2U=0.02)
conda run -n pycuda_3_10 python src/CCD_analysis/scripts/collect_illusion.py --device 2 --steps 200
```
### Resampling (no GPU needed)
```bash
python3 src/CCD_analysis/scripts/resample.py
```
### CCD Analysis (no GPU needed)
```bash
python3 src/CCD_analysis/ccd/run_ccd.py
# Steady metrics
python3 src/CCD_analysis/steady/run_steady.py
```
## Pipeline Workflow
```
┌─────────────────────┐
│ configs.py │
│ (scene metadata) │
└────────┬────────────┘
┌──────────────┼──────────────┐
▼ ▼ ▼
┌─────────────────┐ ┌──────────┐ ┌──────────┐
│ collect_pinball │ │collect_ │ │collect_ │
│ collect_steady │ │karman.py │ │illusion │
│ _(open-loop) │ │(PPO) │ │ .py(PPO) │
└────────┬────────┘ └────┬─────┘ └────┬─────┘
│ │ │
▼ ▼ ▼
┌──────────────────────────────────────────┐
│ data/{scene_id}/{scene_name}/ │
│ sensors.npz, forces.npz, fields.npz │
│ controlled.npz, target.npz, norm.json │
└──────────────────┬───────────────────────┘
┌────────────────┐
│ scripts/ │
│ resample.py │
│ (24 pts/cycle) │
└───────┬────────┘
┌────────────────┐
│ data/resampled/│
└───────┬────────┘
┌────────────┴────────────┐
▼ ▼
┌──────────────┐ ┌──────────────┐
│ ccd/run_ccd │ │ steady/ │
│ POD + CCD │ │ run_steady │
└──────┬───────┘ └──────┬───────┘
│ │
▼ ▼
┌──────────────┐ ┌──────────────┐
│ data/ccd/ │ │ data/steady/ │
│ ccd_results │ │ steady_ │
│ .json │ │ metrics.json │
└──────────────┘ └──────────────┘
```
## Known Issues and Caveats
1. **Illusion similarity below thesis** -- The 1L illusion achieves ~0.84
similarity vs the thesis value of 0.975. The vorticity field shows partial
but not perfect wake matching. Possible causes: harmonics-based target
reconstruction may differ subtly from training, or the PPO needs longer
warm-up. Data is still useful for CCD as a "partial illusion" reference.
2. **Karman cloak uses exactly the reference code** -- `utils/cfd_interface.py`
is adapted from `SR_analysis/utils/cfd_interface.py`, which was verified
against `analysis_crossre/scripts/phase1_infer.py`. The similarity of 0.95
matches the reference.
3. **No empty channel reference for steady metrics** -- The steady cloak
analysis currently lacks a clean parabolic channel reference flow. This
affects the E_mean calculation. Generate via a separate FlowField with
no bodies and a dummy sensor.
4. **CCD results are preliminary** -- Once data collection is validated, the
`ccd/run_ccd.py` script computes POD and CCD. Results should be
cross-checked with visual field inspection before drawing conclusions.
5. **Resampled field quality depends on source data** -- The phase resampling
step uses linear interpolation. If the original field sampling rate is too
low (< 12 pts/cycle), resampled fields will have interpolation artifacts.
Currently all cases use raw sampling that gives ~18-25 pts/cycle.
## File Reference
| File | Purpose |
|------|---------|
| configs.py | Unified scene metadata (4 cases) |
| utils/cfd_interface.py | LegacyCelerisLab wrapper, env builders, DTW |
| utils/resampling.py | Period detection, phase resampling, POD, CCD |
| utils/__init__.py | Non-pycuda exports |
| scripts/collect_karman.py | Karman cloak PPO inference |
| scripts/collect_illusion.py | Illusion PPO inference |
| scripts/collect_pinball.py | Pinball baseline |
| scripts/collect_steady_cloak.py | Steady cloak open-loop |
| scripts/resample.py | Phase resampling pipeline |
| ccd/run_ccd.py | POD + CCD computation |
| steady/run_steady.py | Steady cloak metrics |

View File

@ -0,0 +1,182 @@
"""CCD analysis pipeline: POD + force/action/signature CCD.
Usage:
python ccd/run_ccd.py
Requires resampled data from scripts/resample.py.
"""
from __future__ import annotations
import json
import os
import sys
import numpy as np
_ANALYSIS = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if _ANALYSIS not in sys.path:
sys.path.insert(0, _ANALYSIS)
from CCD_analysis.configs import DATA_DIR
from CCD_analysis.utils.resampling import (
compute_pod, cumulative_energy, e95_index,
compute_reduced_ccd, stack_velocity_fields,
)
R_CANDIDATES = [6, 8, 10]
CCD_Q = 12
def load_resampled(name: str):
p = os.path.join(DATA_DIR, "resampled", name, "resampled.npz")
if not os.path.isfile(p):
return None
return np.load(p)
def main():
print("=== CCD Pipeline ===\n")
# Identify which cases have resampled data
resampled_dir = os.path.join(DATA_DIR, "resampled")
if not os.path.isdir(resampled_dir):
print("ERROR: run scripts/resample.py first")
return 1
cases = sorted(os.listdir(resampled_dir))
print(f"Resampled cases: {cases}")
# --- POD ---
print("\n--- POD ---")
snapshots = []
case_ranges = {}
idx = 0
for name in cases:
d = load_resampled(name)
if d is None:
continue
ux, uy = d.get("ux"), d.get("uy")
if ux is None:
print(f" {name}: no field data, skip POD")
continue
n_cyc, n_pt = ux.shape[0], ux.shape[1]
for c in range(n_cyc):
for p in range(n_pt):
q = np.concatenate([ux[c, p].ravel(), uy[c, p].ravel()])
snapshots.append(q)
case_ranges[name] = (idx, idx + n_cyc * n_pt)
idx += n_cyc * n_pt
print(f" {name}: {n_cyc}x{n_pt} snapshots")
if not snapshots:
print("No field data for POD")
return 1
Q = np.column_stack(snapshots)
mean_field, modes, s, coeffs = compute_pod(Q)
energy = cumulative_energy(s)
e95 = e95_index(energy)
print(f" POD: {len(s)} modes, E95={e95}")
for i in range(min(6, len(s))):
print(f" mode {i+1}: energy={energy[i]:.4f}")
# --- CCD for each case ---
print("\n--- CCD ---")
all_results = {}
W_dict = {}
for r in R_CANDIDATES:
print(f"\n POD truncation r={r}")
for name in cases:
d = load_resampled(name)
if d is None:
continue
# POD coefficients for this case
if name in case_ranges:
start, end = case_ranges[name]
a_r = coeffs[:r, start:end]
else:
# Projection case (not in POD basis)
ux, uy = d.get("ux"), d.get("uy")
if ux is None:
continue
proj_snapshots = []
for c in range(ux.shape[0]):
for p in range(ux.shape[1]):
q = np.concatenate([ux[c, p].ravel(), uy[c, p].ravel()])
proj_snapshots.append(q)
Q_proj = np.column_stack(proj_snapshots)
Q_centered = Q_proj - mean_field[:, None]
a_r = (modes[:, :r].T @ Q_centered)
N = a_r.shape[1]
if N < 24:
print(f" {name}: too few samples ({N})")
continue
# Force CCD
forces = d.get("forces")
if forces is not None:
f = forces.reshape(-1, forces.shape[-1])
Fx = f[:, 0] + f[:, 2] + f[:, 4]
Fy = f[:, 1] + f[:, 3] + f[:, 5]
y_force = np.vstack([Fx, Fy])
if y_force.shape[1] >= N:
y_f = y_force[:, :N]
else:
y_f = y_force
W, sigma, z = compute_reduced_ccd(a_r[:, :y_f.shape[1]], y_f, Q_delay=CCD_Q)
ccd_ene = cumulative_energy(sigma)
m80 = int(np.searchsorted(ccd_ene, 0.80) + 1) if len(ccd_ene) > 0 else 0
key = f"{name}_force_r{r}"
W_dict[key] = W
all_results[key] = {"case": name, "observable": "force", "r": r,
"m80": m80, "sigma_top3": [float(sigma[i]) for i in range(min(3, len(sigma)))]}
print(f" {key}: m80={m80}")
# Action CCD (for controlled cases)
actions = d.get("actions")
if actions is not None:
y_act = actions.reshape(-1, actions.shape[-1]).T
if y_act.shape[1] >= N:
y_a = y_act[:, :N]
else:
y_a = y_act
W, sigma, z = compute_reduced_ccd(a_r[:, :y_a.shape[1]], y_a, Q_delay=CCD_Q)
ccd_ene = cumulative_energy(sigma)
m80 = int(np.searchsorted(ccd_ene, 0.80) + 1) if len(ccd_ene) > 0 else 0
key = f"{name}_action_r{r}"
W_dict[key] = W
all_results[key] = {"case": name, "observable": "action", "r": r,
"m80": m80, "sigma_top3": [float(sigma[i]) for i in range(min(3, len(sigma)))]}
print(f" {key}: m80={m80}")
# --- Modal overlap ---
print("\n--- Modal Overlap ---")
force_keys = [k for k in W_dict if "force" in k]
for i, ka in enumerate(force_keys):
for kb in force_keys[i+1:]:
Wa, Wb = W_dict[ka], W_dict[kb]
n = min(Wa.shape[1], Wb.shape[1], 5)
ov = []
for k in range(n):
ak = Wa[:, k] / (np.linalg.norm(Wa[:, k]) + 1e-12)
bk = Wb[:, k] / (np.linalg.norm(Wb[:, k]) + 1e-12)
ov.append(float(abs(ak @ bk)))
print(f" O({ka}, {kb}): O1={ov[0]:.4f}, O2={ov[1]:.4f}")
# Save
out_dir = os.path.join(DATA_DIR, "ccd")
os.makedirs(out_dir, exist_ok=True)
with open(os.path.join(out_dir, "ccd_results.json"), "w") as f:
json.dump(all_results, f, indent=2)
print(f"\nSaved to {out_dir}/ccd_results.json")
return 0
if __name__ == "__main__":
sys.exit(main())

160
src/CCD_analysis/configs.py Normal file
View File

@ -0,0 +1,160 @@
"""Unified scene configuration for CCD_analysis.
All scene metadata in one place. Each scene dict contains all parameters
needed for data collection, resampling, POD, and CCD.
Re convention:
- "re_code" uses reference length 2*D (matching model file naming).
- Re_D = re_code / 2 is the true physical Reynolds number.
"""
from __future__ import annotations
import os
from typing import Any, Dict, List, Optional
# -- Root paths ---------------------------------------------------------------
_PROJ = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
MODEL_DIR = os.path.join(_PROJ, "..", "..", "models")
LEGACY_CFG_DIR = os.path.join(os.path.dirname(__file__), "configs")
DATA_DIR = os.path.join(os.path.dirname(__file__), "data")
# -- Physics constants -------------------------------------------------------
U0 = 0.01
D_CYL = 20.0
D_REF = 40.0
L0 = 20.0
NX = 1280
NY = 512
CENTER_Y = (NY - 1) / 2.0
FIFO_LEN = 150
CONV_LEN = 30
def nu_from_re(re_code: float, u0: float = U0) -> float:
"""Viscosity from code Reynolds number (reference length = 2*D)."""
return u0 * D_REF / re_code
# -- Scene definitions -------------------------------------------------------
SCENES: Dict[str, Any] = {}
# -- Pure Pinball (uncontrolled baseline, 6 objects, no disturbance) ---------
SCENES["pinball"] = {
"scene_id": "pinball",
"re_code": 100,
"has_disturbance": False,
"sample_interval": 800,
"source": "open_loop",
"model_name": None,
"n_objects_env": 6,
"obs_slice": (0, 12),
"sensor_x": 40.0,
"pinball_front_x": 30.0,
"pinball_rear_x": 31.3,
"target_type": "periodic",
"s_dim": 12,
"u0": U0,
"nu": nu_from_re(100),
}
# -- Steady Cloak (open-loop constant rotation, 6 objects) --------------------
SCENES["steady_cloak"] = {
"scene_id": "steady_cloak",
"re_code": 100,
"has_disturbance": False,
"sample_interval": 800,
"source": "open_loop",
"model_name": None,
"n_objects_env": 6,
"obs_slice": (0, 12),
"sensor_x": 40.0,
"pinball_front_x": 30.0,
"pinball_rear_x": 31.3,
"target_type": "steady",
"s_dim": 12,
"u0": U0,
"nu": nu_from_re(100),
"omega_front": 0.0,
"omega_rear_scale": 5.1,
}
# -- Karman Cloak (PPO, 7 objects, disturbance cylinder) ---------------------
SCENES["karman_re100"] = {
"scene_id": "karman",
"re_code": 100,
"has_disturbance": True,
"sample_interval": 800,
"action_scale": 8.0,
"action_bias": (0.0, -4.0, 4.0),
"source": "PPO_inference",
"model_name": "d1a3o12_re100",
"model_subdir": "old",
"n_objects_env": 7,
"obs_slice": (2, 14),
"sensor_x": 40.0,
"pinball_front_x": 30.0,
"pinball_rear_x": 31.3,
"target_type": "periodic",
"s_dim": 12,
"u0": U0,
"nu": nu_from_re(100),
}
# -- 1L Illusion (PPO, 6 objects, 2U=0.02) ----------------------------------
SCENES["illusion_1L"] = {
"scene_id": "illusion",
"re_code": 100,
"target_diameter": 1.0,
"has_disturbance": False,
"sample_interval": 600,
"action_scale": 8.0,
"action_bias": (0.0, -2.0, 2.0),
"source": "PPO_inference",
"model_name": "d1a3o14_250525_imit_1L_2U_600S",
"model_subdir": "250525",
"n_objects_env": 6,
"obs_slice": (0, 12),
"sensor_x": 30.0,
"pinball_front_x": 19.0,
"pinball_rear_x": 20.3,
"target_type": "periodic",
"s_dim": 14,
"u0": 0.02,
"nu": nu_from_re(100, u0=0.02),
}
# -- Utility helpers ---------------------------------------------------------
def get_scene(name: str) -> dict:
"""Return scene config dict by name. Raises KeyError if not found."""
if name not in SCENES:
raise KeyError(f"Unknown scene: {name}. Available: {list(SCENES.keys())}")
return dict(SCENES[name])
def get_scene_list(scene_id: Optional[str] = None) -> List[str]:
"""Return list of scene names, optionally filtered by scene_id."""
if scene_id is None:
return list(SCENES.keys())
return [k for k, v in SCENES.items() if v["scene_id"] == scene_id]
def model_path_for_scene(scene_name: str) -> Optional[str]:
"""Return absolute path to PPO model .zip file, or None."""
cfg = get_scene(scene_name)
mn = cfg.get("model_name")
if mn is None:
return None
subdir = cfg.get("model_subdir", "old")
p = os.path.join(MODEL_DIR, subdir, f"{mn}.zip")
return p if os.path.isfile(p) else None
def data_dir_for_scene(scene_name: str) -> str:
"""Return the data directory for a scene, creating it if needed."""
cfg = get_scene(scene_name)
scene_id = cfg["scene_id"]
d = os.path.join(DATA_DIR, scene_id, scene_name)
os.makedirs(d, exist_ok=True)
return d

View File

@ -0,0 +1,13 @@
{
"case": "illusion_1L",
"model": "/home/frank14f/DynamisLab/models/250525/d1a3o14_250525_imit_1L_2U_600S.zip",
"U0": 0.02,
"viscosity": 0.008,
"sample_interval": 600,
"n_infer_steps": 500,
"mean_reward_last100": 0.50427141107983,
"mean_similarity_last100": 0.8369960935939864,
"force_norm_fact": 0.05429763346910477,
"validation_passed": false,
"n_obj": 6
}

View File

@ -0,0 +1,25 @@
{
"force_norm_fact": 0.05429763346910477,
"sens_deviation": [
1.893601894378662,
-0.2520896792411804,
1.3097574710845947,
-0.04255330562591553,
1.897708535194397,
0.2153952717781067
],
"sens_norm_fact": [
4.2874250411987305,
5.249192714691162,
1.472514271736145,
7.114207744598389,
4.274000644683838,
5.054762363433838
],
"action_scale": 8.0,
"action_bias": [
0.0,
-2.0,
2.0
]
}

View File

@ -0,0 +1,194 @@
[
{
"dc": 0.02266536364952723,
"amps": [
0.00038379516744314115,
9.514000233530361e-05,
8.349139917453543e-05,
7.792522654399734e-05,
7.433183588746336e-05
],
"freqs": [
0.16,
0.16666666666666669,
0.13333333333333333,
0.26666666666666666,
0.15333333333333335
],
"phases": [
-1.1237148062087887,
1.9611885389112236,
1.6703234092960584,
-0.3613027596994091,
-1.1120847569223562
]
},
{
"dc": 5.844893375372825e-05,
"amps": [
0.008381073411089025,
0.0009710627254597952,
0.0008309252043062449,
0.000440363650103523,
0.0004196318731941633
],
"freqs": [
0.08,
0.08666666666666667,
0.07333333333333333,
0.06666666666666667,
0.09333333333333334
],
"phases": [
-0.20583713241972612,
2.9062320022605928,
-0.19244834395419047,
-0.06340420123785095,
2.797172757014809
]
},
{
"dc": 2.023785818417867,
"amps": [
0.6206119106073832,
0.08359220819570973,
0.0772933966332921,
0.05856013170212723,
0.044579995576176853
],
"freqs": [
0.08,
0.24000000000000002,
0.08666666666666667,
0.07333333333333333,
0.16
],
"phases": [
-1.2902033342248966,
2.247158738027268,
1.8698785846127643,
-1.3230065080948876,
-2.7643028382054635
]
},
{
"dc": -0.07650716103613377,
"amps": [
0.8881293616425711,
0.19361219712346026,
0.11776456319197573,
0.1056433212084406,
0.08416293765029953
],
"freqs": [
0.08,
0.16,
0.24000000000000002,
0.08666666666666667,
0.07333333333333333
],
"phases": [
-3.1077828808645815,
0.9383438720400492,
0.0795160787541243,
0.03419287750174611,
-3.1125200854751167
]
},
{
"dc": 1.8414087001482646,
"amps": [
0.1497365430682559,
0.040132780833895966,
0.026889484355317024,
0.02298740258772072,
0.01741055707849075
],
"freqs": [
0.16,
0.16666666666666669,
0.15333333333333335,
0.32,
0.17333333333333334
],
"phases": [
-0.27142943807213477,
2.865319464251447,
-0.27348869296327505,
-3.041810616039961,
2.803592539777442
]
},
{
"dc": -0.008232745975255966,
"amps": [
1.5054029642098454,
0.30809832902221,
0.17612766731044907,
0.14518758298142478,
0.13409758532624938
],
"freqs": [
0.08,
0.24000000000000002,
0.08666666666666667,
0.07333333333333333,
0.24666666666666667
],
"phases": [
-3.035701468779973,
0.44692729545584287,
0.096649357931439,
-3.035027928495052,
-2.7069943130770233
]
},
{
"dc": 2.0234219272931417,
"amps": [
0.6202491421096321,
0.0839598074044769,
0.0773465705446304,
0.05861231122571406,
0.04506359085306284
],
"freqs": [
0.08,
0.24000000000000002,
0.08666666666666667,
0.07333333333333333,
0.16
],
"phases": [
1.8537647038281104,
-0.8723353223902255,
-1.2906675657274453,
1.8392270400405824,
-2.377576985448929
]
},
{
"dc": 0.06527064591646195,
"amps": [
0.8933136072736296,
0.1854083343143704,
0.1218220099415223,
0.10078312046223797,
0.09011504483851066
],
"freqs": [
0.08,
0.16,
0.24000000000000002,
0.08666666666666667,
0.07333333333333333
],
"phases": [
-3.1049896733668625,
-2.145674517803205,
0.14371115551840652,
0.007693943504592867,
-3.0961205975207657
]
}
]

View File

@ -0,0 +1,11 @@
{
"case": "illusion",
"output_dir": "/home/frank14f/DynamisLab/src/CCD_analysis/output_redux/illusion",
"checks": {
"reward": false,
"similarity": false,
"vorticity_png": true
},
"all_pass": false,
"extra": {}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 241 KiB

View File

@ -0,0 +1,13 @@
{
"case": "karman_cloak",
"model": "/home/frank14f/DynamisLab/models/old/d1a3o12_re100.zip",
"viscosity": 0.004,
"U0": 0.01,
"sample_interval": 800,
"n_infer_steps": 200,
"mean_reward_last100": 0.6534655690193176,
"overall_similarity": 0.9503773416909905,
"force_norm_fact": 0.019220656715333462,
"validation_passed": true,
"n_obj": 7
}

View File

@ -0,0 +1,24 @@
{
"force_norm_fact": 0.019220656715333462,
"sens_deviation": [
0.7905515432357788,
-0.11469581723213196,
0.24619626998901367,
0.01082652248442173,
0.8161152601242065,
0.12503524124622345
],
"sens_norm_fact": [
3.1592841148376465,
3.0264341831207275,
1.8399379253387451,
3.451172351837158,
3.055002450942993,
2.9197099208831787
],
"action_bias": [
0.0,
-4.0,
4.0
]
}

View File

@ -0,0 +1,15 @@
{
"case": "karman",
"output_dir": "/home/frank14f/DynamisLab/src/CCD_analysis/output_redux/karman",
"checks": {
"overall_similarity": true,
"mean_similarity_last100": true,
"vorticity_png": true
},
"all_pass": true,
"extra": {
"overall_similarity": 0.9503773416909905,
"mean_reward_last100": 0.6534655690193176,
"mean_sim_last100": 0.9482672810554504
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

View File

@ -0,0 +1,11 @@
{
"case": "pinball",
"U0": 0.01,
"viscosity": 0.004,
"n_steps": 200,
"sample_interval": 800,
"n_obj": 6,
"f_dom": 5.6250000000000005e-05,
"T_dom_steps": 17777.777777777777,
"St": 0.11250000000000002
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

View File

@ -0,0 +1,211 @@
{
"pinball": {
"meta": {
"case": "pinball",
"U0": 0.01,
"viscosity": 0.004,
"n_steps": 200,
"sample_interval": 800,
"n_obj": 6,
"f_dom": 5.6250000000000005e-05,
"T_dom_steps": 17777.777777777777,
"St": 0.11250000000000002
},
"validation": {},
"files": {
"sensors.npz": {
"exists": true,
"size_mb": 0.01
},
"fields.npz": {
"exists": true,
"size_mb": 890.44
},
"vorticity.png": {
"exists": true,
"size_mb": 0.2
},
"meta.json": {
"exists": true,
"size_mb": 0.0
}
}
},
"steady_cloak": {
"meta": {
"case": "steady_cloak",
"U0": 0.01,
"viscosity": 0.004,
"omega_front": 0.0,
"omega_bottom": 0.051,
"omega_top": -0.051,
"rear_omega_scale": 5.1,
"n_samples": 30,
"sample_interval": 800,
"sensor_mean": [
1.1140856742858887,
-0.01482248492538929,
1.1645309925079346,
3.380884905368475e-08,
1.1140861511230469,
0.014822724275290966
],
"sensor_std": 0.0003435599210206419
},
"validation": {},
"files": {
"sensors.npz": {
"exists": true,
"size_mb": 0.0
},
"fields.npz": {
"exists": true,
"size_mb": 128.28
},
"vorticity.png": {
"exists": true,
"size_mb": 0.09
},
"meta.json": {
"exists": true,
"size_mb": 0.0
}
}
},
"karman": {
"meta": {
"case": "karman_cloak",
"model": "/home/frank14f/DynamisLab/models/old/d1a3o12_re100.zip",
"viscosity": 0.004,
"U0": 0.01,
"sample_interval": 800,
"n_infer_steps": 200,
"mean_reward_last100": 0.6534655690193176,
"overall_similarity": 0.9503773416909905,
"force_norm_fact": 0.019220656715333462,
"validation_passed": true,
"n_obj": 7
},
"validation": {
"case": "karman",
"output_dir": "/home/frank14f/DynamisLab/src/CCD_analysis/output_redux/karman",
"checks": {
"overall_similarity": true,
"mean_similarity_last100": true,
"vorticity_png": true
},
"all_pass": true,
"extra": {
"overall_similarity": 0.9503773416909905,
"mean_reward_last100": 0.6534655690193176,
"mean_sim_last100": 0.9482672810554504
}
},
"files": {
"vorticity_target.png": {
"exists": true,
"size_mb": 0.23
},
"vorticity_controlled.png": {
"exists": true,
"size_mb": 0.21
},
"vorticity_uncontrolled.png": {
"exists": true,
"size_mb": 0.26
},
"meta.json": {
"exists": true,
"size_mb": 0.0
},
"validation_report.json": {
"exists": true,
"size_mb": 0.0
},
"norm.json": {
"exists": true,
"size_mb": 0.0
},
"target.npz": {
"exists": true,
"size_mb": 0.0
},
"save_states.npz": {
"exists": true,
"size_mb": 0.01
},
"controlled.npz": {
"exists": true,
"size_mb": 0.02
},
"open_loop_fields.npz": {
"exists": true,
"size_mb": 900.46
}
}
},
"illusion": {
"meta": {
"case": "illusion_1L",
"model": "/home/frank14f/DynamisLab/models/250525/d1a3o14_250525_imit_1L_2U_600S.zip",
"U0": 0.02,
"viscosity": 0.008,
"sample_interval": 600,
"n_infer_steps": 500,
"mean_reward_last100": 0.50427141107983,
"mean_similarity_last100": 0.8369960935939864,
"force_norm_fact": 0.05429763346910477,
"validation_passed": false,
"n_obj": 6
},
"validation": {
"case": "illusion",
"output_dir": "/home/frank14f/DynamisLab/src/CCD_analysis/output_redux/illusion",
"checks": {
"reward": false,
"similarity": false,
"vorticity_png": true
},
"all_pass": false,
"extra": {}
},
"files": {
"vorticity_target.png": {
"exists": true,
"size_mb": 0.21
},
"vorticity_controlled.png": {
"exists": true,
"size_mb": 0.2
},
"vorticity_uncontrolled.png": {
"exists": true,
"size_mb": 0.24
},
"meta.json": {
"exists": true,
"size_mb": 0.0
},
"validation_report.json": {
"exists": true,
"size_mb": 0.0
},
"norm.json": {
"exists": true,
"size_mb": 0.0
},
"target.npz": {
"exists": true,
"size_mb": 0.0
},
"save_states.npz": {
"exists": true,
"size_mb": 0.01
},
"controlled.npz": {
"exists": true,
"size_mb": 0.03
}
}
}
}

View File

@ -0,0 +1,20 @@
{
"case": "steady_cloak",
"U0": 0.01,
"viscosity": 0.004,
"omega_front": 0.0,
"omega_bottom": 0.051,
"omega_top": -0.051,
"rear_omega_scale": 5.1,
"n_samples": 30,
"sample_interval": 800,
"sensor_mean": [
1.1140856742858887,
-0.01482248492538929,
1.1645309925079346,
3.380884905368475e-08,
1.1140861511230469,
0.014822724275290966
],
"sensor_std": 0.0003435599210206419
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

View File

@ -1 +0,0 @@
# CCD_analysis scripts package

View File

@ -0,0 +1,233 @@
"""1L Illusion DRL inference (2U=0.02).
Usage:
conda run -n pycuda_3_10 python scripts/collect_illusion.py --device 2 --steps 200
Output: data/illusion/illusion_1L/
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import time
from collections import deque
import numpy as np
_REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
if _REPO not in sys.path:
sys.path.insert(0, _REPO)
_ANALYSIS = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if _ANALYSIS not in sys.path:
sys.path.insert(0, _ANALYSIS)
from LegacyCelerisLab import FlowField
from CCD_analysis.configs import get_scene, data_dir_for_scene, model_path_for_scene, LEGACY_CFG_DIR
from CCD_analysis.utils.cfd_interface import (
load_legacy_configs, save_vorticity_png, vorticity_from_ddf,
load_ppo_model, scale_action, get_velocity_field,
calc_lag, calc_dtw_sim,
)
from CCD_analysis.utils.resampling import analyze_harmonics, gen_target_states_at
DATA_TYPE = np.float32
L0 = 20.0
CENTER_Y = (512 - 1) / 2.0
FIFO_LEN = 150
CONV_LEN = 36
def run_single(scene_name: str, device_id: int, n_steps: int) -> dict:
cfg = get_scene(scene_name)
out_dir = data_dir_for_scene(scene_name)
u0 = cfg["u0"]
si = cfg["sample_interval"]
ac_scale = cfg["action_scale"]
ac_bias = cfg["action_bias"]
n_obj = cfg["n_objects_env"]
s_dim = cfg["s_dim"]
cuda_cfg, field_cfg = load_legacy_configs(LEGACY_CFG_DIR)
field_cfg = field_cfg._replace(viscosity=float(cfg["nu"]), velocity=float(u0))
with open(os.path.join(out_dir, "config.json"), "w") as f:
json.dump({k: str(v) if not isinstance(v, (int, float, list, bool)) else v
for k, v in cfg.items()}, f, indent=2)
# === Target recording (separate FlowField) ===
print("=== Target recording ===")
ff_tgt = FlowField(field_cfg, cuda_cfg, device_id=device_id)
ff_tgt.add_cylinder((20.0 * L0, CENTER_Y, 0.0), 1.0 * L0)
for y_off in [2.0, 0.0, -2.0]:
ff_tgt.add_sensor((30.0 * L0, CENTER_Y + y_off * L0, 0.0), L0 / 4.0)
n_tgt = 4
ff_tgt.run(int(4 * 1280 / u0), np.zeros(n_tgt, dtype=DATA_TYPE))
target_states = np.empty((0, 8), dtype=DATA_TYPE)
for _ in range(FIFO_LEN):
ff_tgt.run(si, np.zeros(n_tgt, dtype=DATA_TYPE))
target_states = np.vstack((target_states, ff_tgt.obs.copy()[0:8]))
target_harmonics = analyze_harmonics(target_states, n_harmonics=5)
np.savez(os.path.join(out_dir, "target.npz"), target_states=target_states)
harm_save = [{k: v for k, v in h.items()} for h in target_harmonics]
with open(os.path.join(out_dir, "target_harmonics.json"), "w") as f:
json.dump(harm_save, f, indent=2)
save_vorticity_png(os.path.join(out_dir, "vorticity_target.png"),
vorticity_from_ddf(ff_tgt, u0=u0),
title="Illusion target cylinder")
del ff_tgt
# === Control env (6 objects) ===
print("=== Pinball env + norm ===")
ff = FlowField(field_cfg, cuda_cfg, device_id=device_id)
for y_off in [2.0, 0.0, -2.0]:
ff.add_sensor((30.0 * L0, CENTER_Y + y_off * L0, 0.0), L0 / 4.0)
ff.add_cylinder((19.0 * L0, CENTER_Y, 0.0), L0 / 2.0)
ff.add_cylinder((20.3 * L0, CENTER_Y + 0.75 * L0, 0.0), L0 / 2.0)
ff.add_cylinder((20.3 * L0, CENTER_Y - 0.75 * L0, 0.0), L0 / 2.0)
n_env = 6
ff.run(int(4 * 1280 / u0), np.zeros(n_env, dtype=DATA_TYPE))
ff.get_ddf()
ff.save_ddf()
# Norm
fifo = deque(maxlen=FIFO_LEN)
for _ in range(FIFO_LEN):
ff.run(si, np.zeros(n_env, dtype=DATA_TYPE))
fifo.append(ff.obs.copy()[0:12])
temp = np.array(fifo, dtype=DATA_TYPE)
force_norm_fact = 6.0 * float(np.max(np.abs(temp[:, 6:12])))
sens_deviation = np.mean(temp[:, 0:6], axis=0).astype(DATA_TYPE)
sens_norm_fact = np.zeros(6, dtype=DATA_TYPE)
for i in range(6):
sens_norm_fact[i] = 5.0 * float(np.max(np.abs(temp[:, i] - sens_deviation[i])))
norm = {"force_norm_fact": force_norm_fact,
"sens_deviation": sens_deviation.tolist(),
"sens_norm_fact": sens_norm_fact.tolist()}
with open(os.path.join(out_dir, "norm.json"), "w") as f:
json.dump(norm, f, indent=2)
print(f" force_norm_fact={force_norm_fact:.6f}")
# Bias FIFO (matches legacy_env_imit: [0,0,0,0,-1*U0,1*U0])
ff.apply_ddf()
bias = np.zeros(n_env, dtype=DATA_TYPE)
bias[4] = -1.0 * u0
bias[5] = 1.0 * u0
fifo.clear()
for _ in range(FIFO_LEN):
ff.run(si, bias)
fifo.append(ff.obs.copy()[0:12])
save_states_arr = np.array(fifo, dtype=DATA_TYPE)
ff.apply_ddf()
# === PPO inference ===
print("=== PPO inference ===")
model = load_ppo_model(model_path_for_scene(scene_name),
device=f"cuda:{device_id}", s_dim=s_dim, a_dim=3)
model.set_random_seed(19)
fifo = deque(maxlen=FIFO_LEN)
for s in save_states_arr:
fifo.append(np.array(s, dtype=DATA_TYPE))
obs = np.zeros(s_dim, dtype=np.float32)
sens_c, forc_c, act_c, rew_c, sim_c = [], [], [], [], []
for step in range(n_steps):
action, _ = model.predict(obs, deterministic=True)
action = action.astype(np.float32).flatten()
act_c.append(action.copy())
temp_a = np.zeros(n_env, dtype=DATA_TYPE)
omega = (action * ac_scale + np.array(ac_bias, dtype=np.float32)) * u0
temp_a[3:6] = omega
ff.context.push()
ff.run(si, temp_a)
ff.context.pop()
obs_slice = ff.obs.copy()[0:12]
fifo.append(obs_slice)
sens_c.append(obs_slice[0:6])
forc_c.append(obs_slice[6:12])
# 14-dim obs
forces_norm = obs_slice[6:12] / force_norm_fact
sens_norm = (obs_slice[0:6] - sens_deviation) / sens_norm_fact
target_recon = gen_target_states_at(step, target_harmonics)
t_cd_n = float(target_recon[0]) / force_norm_fact
t_cl_n = float(target_recon[1]) / force_norm_fact
obs = np.clip(np.hstack([forces_norm, sens_norm, t_cd_n, t_cl_n]), -1.0, 1.0).astype(np.float32)
# Reward
sarr = np.array(fifo, dtype=np.float32)
if len(sarr) >= CONV_LEN:
f = sarr[-1, 6:12] / force_norm_fact
cd = float(f[0] + f[2] + f[4])
cl = float(f[1] + f[3] + f[5])
# DTW
ref_seq = target_states[CONV_LEN:2*CONV_LEN, 3]
cur_seq = sarr[-CONV_LEN:, 1]
lag = calc_lag(ref_seq, cur_seq)
sim_sum = 0.0
for i in range(6):
t_seq = np.roll(target_states[:, i+2], -lag)[CONV_LEN:2*CONV_LEN]
s_seq = sarr[-CONV_LEN:, i]
sim_sum += calc_dtw_sim(t_seq, s_seq) / 6.0
similarities = float(sim_sum)
sim_c.append(similarities)
t_recon = gen_target_states_at(step, target_harmonics)
t_cd = float(t_recon[0]) / force_norm_fact
t_cl = float(t_recon[1]) / force_norm_fact
r_cd = np.exp(-abs((cd - t_cd) * 10))
r_cl = np.exp(-abs((cl - t_cl) * 10))
r_sim = np.exp(-10 * abs(similarities - 1))
reward = float(min(0.3*r_cd + 0.3*r_cl + 0.4*r_sim, 1.0))
rew_c.append(reward)
sens_arr = np.array(sens_c, dtype=np.float32)
forc_arr = np.array(forc_c, dtype=np.float32)
act_arr = np.array(act_c, dtype=np.float32)
np.savez(os.path.join(out_dir, "controlled.npz"),
sensors=sens_arr, forces=forc_arr, actions=act_arr,
rewards=np.array(rew_c, dtype=np.float32))
save_vorticity_png(os.path.join(out_dir, "vorticity_controlled.png"),
vorticity_from_ddf(ff, u0=u0),
title=f"{scene_name} controlled")
tail = min(100, len(rew_c))
avg_reward = float(np.mean(rew_c[-tail:])) if tail > 0 else 0.0
avg_sim = float(np.mean(sim_c[-tail:])) if sim_c else 0.0
print(f" reward={avg_reward:.4f} similarity={avg_sim:.4f}")
result = {"scene": scene_name, "similarity": avg_sim, "avg_reward": avg_reward}
with open(os.path.join(out_dir, "result.json"), "w") as f:
json.dump(result, f, indent=2)
del ff, model
return result
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--device", type=int, default=2)
ap.add_argument("--steps", type=int, default=200)
args = ap.parse_args()
t0 = time.time()
r = run_single("illusion_1L", args.device, args.steps)
print(f"Done in {time.time()-t0:.1f}s: sim={r['similarity']:.4f}")
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,182 @@
"""Karman cloak DRL inference (Re=100).
Uses utils/cfd_interface.py (copied and verified from SR_analysis).
Usage:
conda run -n pycuda_3_10 python scripts/collect_karman.py --device 2 --steps 200
Output: data/karman/karman_re100/
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import time
from collections import deque
import numpy as np
_REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
if _REPO not in sys.path:
sys.path.insert(0, _REPO)
_ANALYSIS = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if _ANALYSIS not in sys.path:
sys.path.insert(0, _ANALYSIS)
from LegacyCelerisLab import FlowField
from CCD_analysis.configs import get_scene, data_dir_for_scene, model_path_for_scene, LEGACY_CFG_DIR
from CCD_analysis.utils.cfd_interface import (
load_legacy_configs,
build_karman_cloak_env, add_pinball, build_observation,
scale_action, load_ppo_model, save_vorticity_png,
vorticity_from_ddf, compute_similarity,
)
DATA_TYPE = np.float32
L0 = 20.0
def run_single(scene_name: str, device_id: int, n_steps: int) -> dict:
cfg = get_scene(scene_name)
out_dir = data_dir_for_scene(scene_name)
u0 = cfg["u0"]
si = cfg["sample_interval"]
ac_scale = cfg["action_scale"]
ac_bias = cfg["action_bias"]
n_obj = cfg["n_objects_env"]
cuda_cfg, field_cfg = load_legacy_configs(LEGACY_CFG_DIR)
field_cfg = field_cfg._replace(viscosity=float(cfg["nu"]))
# Save config
with open(os.path.join(out_dir, "config.json"), "w") as f:
json.dump({k: str(v) if not isinstance(v, (int, float, list, bool)) else v
for k, v in cfg.items()}, f, indent=2)
# --- Target recording ---
print("=== Target recording ===")
ff = FlowField(field_cfg, cuda_cfg, device_id=device_id)
target_states, _ = build_karman_cloak_env(
ff, u0=u0, l0=L0, sample_interval=si, fifo_len=150, data_type=DATA_TYPE)
np.savez(os.path.join(out_dir, "target.npz"), target_states=target_states)
# --- Add pinball + norm ---
print("=== Norm ===")
norm = add_pinball(
ff, l0=L0, u0=u0, sample_interval=si, fifo_len=150, data_type=DATA_TYPE,
action_bias=ac_bias,
pinball_front_x=cfg["pinball_front_x"],
pinball_rear_x=cfg["pinball_rear_x"],
obs_slice_start=cfg["obs_slice"][0], obs_slice_end=cfg["obs_slice"][1],
)
# Save norm (without save_states array)
norm_json = {k: v for k, v in norm.items() if not isinstance(v, np.ndarray)}
with open(os.path.join(out_dir, "norm.json"), "w") as f:
json.dump(norm_json, f, indent=2)
# --- Uncontrolled rollout ---
print("=== Uncontrolled ===")
ff.restore_ddf()
ff.apply_ddf()
sens_u, forc_u = [], []
for _ in range(n_steps):
ff.run(si, np.zeros(n_obj, dtype=DATA_TYPE))
obs_slice = ff.obs.copy()[2:14]
sens_u.append(obs_slice[0:6])
forc_u.append(obs_slice[6:12])
np.savez(os.path.join(out_dir, "uncontrolled.npz"),
sensors=np.array(sens_u, dtype=np.float32),
forces=np.array(forc_u, dtype=np.float32))
save_vorticity_png(os.path.join(out_dir, "vorticity_uncontrolled.png"),
vorticity_from_ddf(ff, u0=u0),
title=f"{scene_name} uncontrolled")
# --- Controlled rollout ---
print("=== Controlled ===")
model_path = model_path_for_scene(scene_name)
s_dim = cfg.get("s_dim", 12)
model = load_ppo_model(model_path, device=f"cuda:{device_id}", s_dim=s_dim)
model.set_random_seed(0)
ff.restore_ddf()
ff.apply_ddf()
fifo = deque(maxlen=150)
bias_action = scale_action(np.zeros(3, dtype=np.float32),
scale=ac_scale, bias=ac_bias, u0=u0, n_total_bodies=n_obj)
for _ in range(150):
ff.context.push()
ff.run(si, bias_action)
ff.context.pop()
fifo.append(ff.obs.copy()[2:14])
sens_c, forc_c, act_c, rew_c = [], [], [], []
obs = np.zeros(s_dim, dtype=np.float32)
for step in range(n_steps):
action, _ = model.predict(obs, deterministic=True)
action = action.astype(np.float32).flatten()
act_c.append(action.copy())
action_arr = scale_action(action, scale=ac_scale, bias=ac_bias,
u0=u0, n_total_bodies=n_obj)
ff.context.push()
ff.run(si, action_arr)
ff.context.pop()
obs_slice = ff.obs.copy()[2:14]
fifo.append(obs_slice)
sens_c.append(obs_slice[0:6])
forc_c.append(obs_slice[6:12])
obs = build_observation(obs_slice, norm)
# Reward
sarr = np.array(fifo, dtype=np.float32)
if len(sarr) >= 30:
f = sarr[-1, 6:12] / norm["force_norm_fact"]
cd = float((f[0] + f[2] + f[4]) / 3.0)
cl = float((f[1] + f[3] + f[5]) / 3.0)
sim = compute_similarity(target_states, sarr[:, 0:6], 30)
r = min(0.3 * np.exp(-abs(cd * 20)) + 0.4 * np.exp(-abs(cl * 80))
+ 0.3 * np.exp(-10 * abs(sim - 1)), 1.0)
rew_c.append(float(r))
sens_arr = np.array(sens_c, dtype=np.float32)
forc_arr = np.array(forc_c, dtype=np.float32)
act_arr = np.array(act_c, dtype=np.float32)
rew_arr = np.array(rew_c, dtype=np.float32)
np.savez(os.path.join(out_dir, "controlled.npz"),
sensors=sens_arr, forces=forc_arr, actions=act_arr, rewards=rew_arr)
save_vorticity_png(os.path.join(out_dir, "vorticity_controlled.png"),
vorticity_from_ddf(ff, u0=u0),
title=f"{scene_name} controlled")
avg_reward = float(np.mean(rew_arr[-100:])) if len(rew_arr) >= 100 else 0.0
sim_score = compute_similarity(target_states, sens_arr, 30)
print(f" reward={avg_reward:.4f} similarity={sim_score:.4f}")
result = {"scene": scene_name, "similarity": sim_score, "avg_reward": avg_reward}
with open(os.path.join(out_dir, "result.json"), "w") as f:
json.dump(result, f, indent=2)
del ff, model
return result
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--device", type=int, default=2)
ap.add_argument("--steps", type=int, default=200)
args = ap.parse_args()
t0 = time.time()
r = run_single("karman_re100", args.device, args.steps)
print(f"Done in {time.time()-t0:.1f}s: sim={r['similarity']:.4f}")
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,104 @@
"""Collect pinball baseline data.
Usage:
conda run -n pycuda_3_10 python scripts/collect_pinball.py --device 2
Output: data/pinball/pinball/
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import time
import numpy as np
_REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
if _REPO not in sys.path:
sys.path.insert(0, _REPO)
_ANALYSIS = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if _ANALYSIS not in sys.path:
sys.path.insert(0, _ANALYSIS)
from LegacyCelerisLab import FlowField
from CCD_analysis.configs import get_scene, data_dir_for_scene, LEGACY_CFG_DIR
from CCD_analysis.utils.cfd_interface import (
load_legacy_configs, get_velocity_field, save_vorticity_png,
)
def collect():
ap = argparse.ArgumentParser()
ap.add_argument("--device", type=int, default=2)
args = ap.parse_args()
cfg = get_scene("pinball")
out_dir = data_dir_for_scene("pinball")
cuda_cfg, field_cfg = load_legacy_configs(LEGACY_CFG_DIR)
field_cfg = field_cfg._replace(viscosity=float(cfg["nu"]))
ff = FlowField(field_cfg, cuda_cfg, device_id=args.device)
for sc_y in [40.0, 40.0, 40.0]:
pass # sensor positions handled below
from CCD_analysis.configs import L0, CENTER_Y
l0 = L0
# sensors
for y_off in [2.0, 0.0, -2.0]:
ff.add_sensor((40.0 * l0, CENTER_Y + y_off * l0, 0.0), l0 / 4.0)
# pinball
ff.add_cylinder((30.0 * l0, CENTER_Y, 0.0), l0 / 2.0)
ff.add_cylinder((31.3 * l0, CENTER_Y + 0.75 * l0, 0.0), l0 / 2.0)
ff.add_cylinder((31.3 * l0, CENTER_Y - 0.75 * l0, 0.0), l0 / 2.0)
n_obj = 6
stabilize = int(4 * 1280 / cfg["u0"])
print(f"Stabilising ({stabilize} steps)...")
ff.run(stabilize, np.zeros(n_obj, dtype=np.float32))
n_steps = 200
si = cfg["sample_interval"]
sens_list, forc_list = [], []
ux_list, uy_list = [], []
for step in range(n_steps):
ff.run(si, np.zeros(n_obj, dtype=np.float32))
obs = ff.obs.copy()
sens_list.append(obs[0:6])
forc_list.append(obs[6:12])
ux, uy = get_velocity_field(ff, u0=cfg["u0"])
ux_list.append(ux)
uy_list.append(uy)
# Save
np.savez_compressed(os.path.join(out_dir, "fields.npz"),
ux=np.stack(ux_list), uy=np.stack(uy_list))
np.savez(os.path.join(out_dir, "sensors.npz"),
sensors=np.array(sens_list, dtype=np.float32),
forces=np.array(forc_list, dtype=np.float32))
# Vorticity
from CCD_analysis.utils.cfd_interface import vorticity_from_ddf
omega = vorticity_from_ddf(ff, u0=cfg["u0"])
save_vorticity_png(os.path.join(out_dir, "vorticity.png"),
omega, title="Pinball uncontrolled Re=100")
# Meta
from CCD_analysis.utils.resampling import detect_dominant_frequency
signal = np.array(sens_list, dtype=np.float32)[:, 3]
f_dom, T_dom, _ = detect_dominant_frequency(signal, float(si))
St = f_dom * 20.0 / cfg["u0"]
meta = dict(cfg, St=St, f_dom=f_dom, n_steps=n_steps)
with open(os.path.join(out_dir, "meta.json"), "w") as f:
json.dump(meta, f, indent=2)
print(f"St={St:.4f}, done")
del ff
if __name__ == "__main__":
t0 = time.time()
collect()
print(f"Time: {time.time() - t0:.1f}s")

View File

@ -0,0 +1,117 @@
"""Collect steady cloak (open-loop constant rotation).
Usage:
conda run -n pycuda_3_10 python scripts/collect_steady_cloak.py --device 2
Output: data/steady_cloak/steady_cloak/
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import time
import numpy as np
_REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
if _REPO not in sys.path:
sys.path.insert(0, _REPO)
_ANALYSIS = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if _ANALYSIS not in sys.path:
sys.path.insert(0, _ANALYSIS)
from LegacyCelerisLab import FlowField
from CCD_analysis.configs import get_scene, data_dir_for_scene, LEGACY_CFG_DIR, L0, CENTER_Y
from CCD_analysis.utils.cfd_interface import (
load_legacy_configs, get_velocity_field, save_vorticity_png, vorticity_from_ddf,
)
def collect():
ap = argparse.ArgumentParser()
ap.add_argument("--device", type=int, default=2)
ap.add_argument("--tune", action="store_true", help="scan rear omega")
args = ap.parse_args()
cfg = get_scene("steady_cloak")
out_dir = data_dir_for_scene("steady_cloak")
cuda_cfg, field_cfg = load_legacy_configs(LEGACY_CFG_DIR)
field_cfg = field_cfg._replace(viscosity=float(cfg["nu"]))
ff = FlowField(field_cfg, cuda_cfg, device_id=args.device)
l0 = L0
for y_off in [2.0, 0.0, -2.0]:
ff.add_sensor((40.0 * l0, CENTER_Y + y_off * l0, 0.0), l0 / 4.0)
ff.add_cylinder((30.0 * l0, CENTER_Y, 0.0), l0 / 2.0)
ff.add_cylinder((31.3 * l0, CENTER_Y + 0.75 * l0, 0.0), l0 / 2.0)
ff.add_cylinder((31.3 * l0, CENTER_Y - 0.75 * l0, 0.0), l0 / 2.0)
n_obj = 6
stabilize = int(4 * 1280 / cfg["u0"])
ff.run(stabilize, np.zeros(n_obj, dtype=np.float32))
rear_scale = cfg["omega_rear_scale"]
if args.tune:
candidates = [4.7, 4.9, 5.1, 5.3, 5.5]
else:
candidates = [rear_scale]
for scale in candidates:
rear_val = scale * cfg["u0"]
temp = np.zeros(n_obj, dtype=np.float32)
temp[3] = cfg["omega_front"]
temp[4] = rear_val
temp[5] = -rear_val
ff.run(stabilize, np.zeros(n_obj, dtype=np.float32))
ff.run(stabilize, temp)
sens_list = []
for _ in range(30):
ff.run(cfg["sample_interval"], temp)
sens_list.append(ff.obs.copy()[0:6])
std = float(np.std(np.array(sens_list), axis=0).mean())
print(f" rear={scale:.1f}xU0 -> sensor std={std:.6f}")
# Save with best (or single) value
rear_val = candidates[-1] * cfg["u0"]
temp = np.zeros(n_obj, dtype=np.float32)
temp[3] = cfg["omega_front"]
temp[4] = rear_val
temp[5] = -rear_val
ff.run(stabilize, temp)
sens_list, forc_list, ux_list, uy_list = [], [], [], []
for _ in range(30):
ff.run(cfg["sample_interval"], temp)
obs = ff.obs.copy()
sens_list.append(obs[0:6])
forc_list.append(obs[6:12])
ux, uy = get_velocity_field(ff, u0=cfg["u0"])
ux_list.append(ux)
uy_list.append(uy)
np.savez_compressed(os.path.join(out_dir, "fields.npz"),
ux=np.stack(ux_list), uy=np.stack(uy_list))
np.savez(os.path.join(out_dir, "sensors.npz"),
sensors=np.array(sens_list, dtype=np.float32),
forces=np.array(forc_list, dtype=np.float32))
omega = vorticity_from_ddf(ff, u0=cfg["u0"])
save_vorticity_png(os.path.join(out_dir, "vorticity.png"),
omega, title="Steady Cloak Re=100")
del ff
meta = dict(cfg, rear_scale=candidates[-1], n_samples=30)
with open(os.path.join(out_dir, "meta.json"), "w") as f:
json.dump(meta, f, indent=2)
print(f"Done, saved to {out_dir}")
if __name__ == "__main__":
t0 = time.time()
collect()
print(f"Time: {time.time() - t0:.1f}s")

View File

@ -1,176 +0,0 @@
# CCD_analysis/scripts/compile_results.py
"""Compile all Round 3 results into a structured summary."""
from __future__ import annotations
import json
import os
import sys
from datetime import datetime
import numpy as np
_ANALYSIS = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if _ANALYSIS not in sys.path:
sys.path.insert(0, _ANALYSIS)
from scripts.cfg import OUTPUT_DIR
def load_json(path):
if os.path.exists(path):
with open(path) as f:
return json.load(f)
return {}
def main():
print("=== Compiling Round 3 Results ===\n")
# Phase 0
p0 = load_json(os.path.join(OUTPUT_DIR, "target_cylinder", "meta.json"))
print("Phase 0: Standard Frequency")
print(f" f_ref={p0.get('f_ref', 'N/A'):.6f}, T_ref={p0.get('T_ref', 'N/A'):.0f}")
print(f" St={p0.get('St', 'N/A'):.4f}, CV_T={p0.get('CV_T', 'N/A'):.4f}")
# Phase 1
print("\nPhase 1: Data Collected")
for case in ["target_cylinder", "illusion", "cloak", "uncontrolled", "empty_channel"]:
meta = load_json(os.path.join(OUTPUT_DIR, case, "meta.json"))
if meta:
print(f" {case}: U0={meta.get('U0', '?')}, nu={meta.get('viscosity', '?')}", end="")
if meta.get("n_dense_samples"):
print(f", dense={meta['n_dense_samples']}samp, dt={meta.get('dense_dt','?')}", end="")
if meta.get("N_raw_per_cycle"):
print(f", pts/cycle={meta.get('N_raw_per_cycle', '?'):.0f}", end="")
print()
# Phase 2: Period stability (new gate format)
stab = load_json(os.path.join(OUTPUT_DIR, "resampled", "stability_report.json"))
print("\nPhase 2: Period Stability")
for c in stab.get("cases", []):
gate = c.get("gate", "unknown").upper()
print(f" {c['case']}: {gate} f={c['f_case']:.6f} "
f"CV_T={c['CV_T']:.4f} delta_f={c['delta_f']:.4f} "
f"N_raw/cycle={c.get('N_raw_per_cycle', '?'):.1f} "
f"interp={c.get('interp_quality', '?')}")
# Phase 3: Reference POD
pod_m = load_json(os.path.join(OUTPUT_DIR, "pod", "pod_metrics.json"))
print("\nPhase 3: Reference POD (target + illusion, E95=3)")
print(f" Energy ratio (first 6): {pod_m.get('energy_ratio', [])[:6]}")
centroids = pod_m.get("case_centroids", {})
for case, c in centroids.items():
print(f" {case} centroid: a1={c[0]:.4f}, a2={c[1]:.4f}")
# Phase 4: CCD
ccd_m = load_json(os.path.join(OUTPUT_DIR, "ccd", "ccd_metrics.json"))
print("\nPhase 4: CCD Metrics")
ccd_entries = []
for key, m in ccd_m.items():
if key == "modal_overlaps":
continue
sig_str = ", ".join(f"{s:.3f}" for s in m.get("sigma", [])[:3])
ccd_entries.append({
"key": key,
"case": m.get("case", ""),
"observable": m.get("observable", ""),
"r": m.get("r", 0),
"m80": m.get("m80", 0),
"sigma": m.get("sigma", []),
})
print(f" {key}: m80={m.get('m80', '?')}, sigma=[{sig_str}], "
f"corr_CCD={m.get('corr_CCD_obs', 0):.4f}")
overlap = ccd_m.get("modal_overlaps", {})
print("\nModal Overlap O_k:")
for pk, ov in overlap.items():
print(f" {pk}: {[f'{v:.4f}' for v in ov[:3]]}")
# Phase 5: Steady metrics
steady_m = load_json(os.path.join(OUTPUT_DIR, "steady", "steady_metrics.json"))
print("\nPhase 5: Cloak Steady-Line")
print(f" E_mean_ux={steady_m.get('E_mean_ux', '?'):.4f}")
print(f" E_sensor_mean={steady_m.get('E_sensor_mean', '?'):.4f}")
print(f" eta_fluc={steady_m.get('eta_fluc', '?'):.4f}")
print(f" L_r={steady_m.get('L_r_cloak', '?')}, A_r={steady_m.get('A_r_cloak', '?')}")
print(f" J_omega_rms={steady_m.get('J_omega_rms', '?'):.4f}")
print(f" eta_cloak_obs={steady_m.get('eta_cloak_obs', '?'):.4f}")
# Build JSON
summary = {
"timestamp": datetime.now().isoformat(),
"phase0_standard_frequency": {
"f_ref": p0.get("f_ref"),
"T_ref_steps": p0.get("T_ref"),
"Strouhal": p0.get("St"),
"CV_T": p0.get("CV_T"),
},
"phase2_period_stability": stab.get("cases", []),
"phase3_reference_pod": {
"E95": pod_m.get("E95"),
"energy_first_2": pod_m.get("energy_first_2"),
"energy_ratio": pod_m.get("energy_ratio", []),
"case_centroids": centroids,
},
"phase4_ccd": ccd_entries,
"phase4_modal_overlap": overlap,
"phase5_cloak_steady": steady_m,
"phase1_metadata": {
"target_cylinder_has_force": True,
"illusion_dense_sampling": "ideal (25.2 pts/cycle, rho_interp=0.96)",
},
"notes": [
"Round 3: target force recorded, illusion adaptive sampling (ideal)",
"Period gates corrected: strict/relaxed/auxiliary",
"Reference POD E95=3 (target + illusion, with adaptive sampling)",
"Force-CCD covers all 3 cases (target/illusion/uncontrolled), m80=2",
"Action-CCD working (illusion, m80=2-3)",
"Signature-CCD: m80=2 (tau_c=0 only)",
"O1(target vs illusion force)=0.21 (r=6) -- modest overlap",
"O1(action vs target_cylinder-force)=0.49 (r=6) -- action aligns with target force",
"Steady-line: preliminary metrics computed, needs refinement",
],
}
out_path = os.path.join(OUTPUT_DIR, "analysis_summary.json")
with open(out_path, "w") as f:
json.dump(summary, f, indent=2)
print(f"\nFull summary saved to {out_path}")
# Completion checklist (7 items)
print(f"\n{'='*60}")
print("Round 3 Completion Checklist")
print(f"{'='*60}")
checks = [
("Target cylinder has complete force data", True),
("Force-CCD compares target / illusion / uncontrolled", True),
("Signature-CCD computed (tau_c=0)", True),
("Action-CCD computed (illusion)", True),
("Reference POD includes target + illusion", True),
("Period gates corrected with interpolation check", True),
("Cloak steady-line metrics computed (preliminary)", True),
]
for desc, ok in checks:
print(f" [{'x' if ok else ' '}] {desc}")
print(f"\n{'='*60}")
print("Key Findings")
print(f"{'='*60}")
print("1. Force-CCD: all 3 cases m80=2 (consistent low-rank)")
print("2. Action-CCD: m80=2-3 (slightly higher, as expected)")
print("3. Signature-CCD: m80=2 (tau_c=0)")
print("4. O1(target vs illusion force)=0.21 (r=6)")
print("5. O1(action vs target_cylinder-force)=0.49 (r=6)")
print("6. O1(action vs illusion-force)=0.40 (r=6)")
print("7. Reference POD: E95=3 (improved from Round 2)")
print("8. Illusion adaptive: 25.2 pts/cycle, rho_interp=0.96 (ideal)")
print("\nStill missing:")
print(" - Signature-CCD tau_c scan (tau_geom, tau_corr)")
print(" - Block test (continuous split)")
print(" - Steady metrics need refinement (E_mean_uy, eta_fluc)")
print(" - Action-CCD corr values (currently 0.0 due to degenerate y predictions)")
if __name__ == "__main__":
main()

View File

@ -1,214 +0,0 @@
# CCD_analysis/scripts/phase0_standard_freq.py
"""Phase 0: Run 2D cylinder Re=100, compute standard frequency f_ref and period T_ref.
Usage::
conda run -n pycuda_3_10 python phase0_standard_freq.py --device 2
Output::
Prints f_ref, T_ref, St to stdout.
Saves metadata to output/target_cylinder/meta.json
Saves raw sensor data to output/target_cylinder/raw_sensors.npz
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import time
import numpy as np
# Add project root
_REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
if _REPO not in sys.path:
sys.path.insert(0, _REPO)
from LegacyCelerisLab import FlowField # noqa: E402
# Add analysis dir for imports
_ANALYSIS = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if _ANALYSIS not in sys.path:
sys.path.insert(0, _ANALYSIS)
from scripts.cfg import ( # noqa: E402
CONFIG_DIR, OUTPUT_DIR, U0, L0, NX, NY, CENTER_Y, DATA_TYPE,
TARGET_CYLINDER_CENTER, TARGET_CYLINDER_RADIUS, SENSOR_RADIUS,
SENSOR_CENTERS_CLOAK, SAMPLE_INTERVAL, nu_from_re,
)
from scripts.utils import load_configs, get_velocity_field, \
detect_dominant_frequency, detect_cycle_stability # noqa: E402
# ---------------------------------------------------------------------------
# Phase 0 implementation
# ---------------------------------------------------------------------------
def run_phase0(device_id: int) -> dict:
"""Run 2D cylinder at Re=100, compute standard frequency.
Returns dict with f_ref, T_ref, St, and raw data path.
"""
viscosity = nu_from_re(100.0) # Re=100 code -> nu=0.004
# Load configs
cuda_cfg, field_cfg = load_configs(CONFIG_DIR)
field_cfg = field_cfg._replace(viscosity=float(viscosity))
# Create FlowField
ff = FlowField(field_cfg, cuda_cfg, device_id=device_id)
NX = ff.FIELD_SHAPE[0]
NY = ff.FIELD_SHAPE[1]
print(f"Grid: {NX} x {NY}, viscosity={viscosity:.6f}, U0={U0}")
# Add single cylinder and 3 sensors
# Object order: cylinder(0), sensor0(1), sensor1(2), sensor2(3)
ff.add_cylinder((TARGET_CYLINDER_CENTER[0], TARGET_CYLINDER_CENTER[1], 0.0),
TARGET_CYLINDER_RADIUS)
n_obj = ff.obs.size // 2
print(f"Objects after cylinder: {n_obj}")
for sc in SENSOR_CENTERS_CLOAK:
ff.add_sensor((sc[0], sc[1], 0.0), SENSOR_RADIUS)
n_obj = ff.obs.size // 2
print(f"Objects after sensors: {n_obj}")
# Stabilize
stabilize_steps = int(4 * NX / U0)
print(f"Stabilising ({stabilize_steps} steps)...")
ff.run(stabilize_steps, np.zeros(n_obj, dtype=np.float32))
# Record sensor time series for frequency detection
n_record_steps = 300 # enough for reliable FFT
sensor_list = []
force_list = []
field_list_ux = []
field_list_uy = []
print(f"Recording {n_record_steps} steps x {SAMPLE_INTERVAL} LBM steps each...")
print(f" (this will take a few minutes)")
for step in range(n_record_steps):
ff.run(SAMPLE_INTERVAL, np.zeros(n_obj, dtype=np.float32))
# Target cylinder env: 4 objects (cylinder id=0, sensors id=1,2,3)
# obs layout: [cyl_fx, cyl_fy, s0_ux, s0_uy, s1_ux, s1_uy, s2_ux, s2_uy]
sensor_list.append(ff.obs.copy()[2:8]) # 3 sensors x 2 = 6 values
force_list.append(ff.obs.copy()[0:2]) # cylinder force
# Save field every 3 steps to keep memory manageable
if step % 3 == 0:
ux, uy = get_velocity_field(ff, u0=U0)
field_list_ux.append(ux)
field_list_uy.append(uy)
sensors = np.array(sensor_list, dtype=np.float32)
forces = np.array(force_list, dtype=np.float32)
print(f"Sensors shape: {sensors.shape}, Forces shape: {forces.shape}")
# --- Frequency detection ---
# Use centre sensor v-component (sensor1_uy = index 3 in obs[0:6])
mid_sensor_vy = sensors[:, 3]
f_dom, T_dom, peak_power = detect_dominant_frequency(mid_sensor_vy, SAMPLE_INTERVAL)
cv_T, mean_T, cycle_lengths = detect_cycle_stability(mid_sensor_vy, SAMPLE_INTERVAL)
# Strouhal number (using single cylinder diameter)
St = f_dom * TARGET_CYLINDER_RADIUS * 2 / U0 # D=2*R=40, wait no...
# Let me recalculate: D = 2 * radius = 2 * L0 = 40 lattice units
# But wait, TARGET_CYLINDER_RADIUS = L0, so D = 2*L0 = 40
# And U0 = 0.01
# St = f_dom * D / U0
# But in the code Re uses D_REF=2D=40, and the single cylinder D=20...
# Let me check: knowledge.md says D (single cylinder) = 20 lattice units
# Actually TARGET_CYLINDER_RADIUS = 1*L0 = 20, so D = 40? No...
# Wait, radius=20 means diameter=40. But knowledge.md says single cylinder D=20...
# Let me re-check. L0=20. TARGET_CYLINDER_RADIUS = 1.0*L0 = 20.
# So the cylinder "diameter" in lattice units is 2*radius = 40.
# But knowledge.md says D=20... Let me check the legacy_env_imit_target.py
# It says `self.flow_field.add_cylinder(center, 1*L0)` where L0=20
# So radius=20, diameter=40.
# For Re=100 (code), D_REF=40, so this matches.
# For single cylinder diameter in St definition:
# The diameter is the cylinder's diameter = 2*radius = 40
# St = f * D / U0 = f * 40 / 0.01
D_cylinder = float(TARGET_CYLINDER_RADIUS * 2) # diameter = 40
St = f_dom * D_cylinder / U0
result = {
"f_ref": float(f_dom),
"T_ref": float(T_dom),
"T_ref_steps": float(T_dom / SAMPLE_INTERVAL) if T_dom > 0 else 0,
"St": float(St),
"peak_power": float(peak_power),
"CV_T": float(cv_T),
"mean_T_samples": float(mean_T / SAMPLE_INTERVAL) if mean_T > 0 else 0,
"viscosity": float(viscosity),
"U0": float(U0),
"cylinder_radius": float(TARGET_CYLINDER_RADIUS),
"cylinder_diameter": float(D_cylinder),
"grid": [NX, NY],
"sample_interval": SAMPLE_INTERVAL,
"n_record_steps": n_record_steps,
}
print(f"\n=== Phase 0 Results ===")
print(f" f_ref = {f_dom:.6f} (cycles per LBM step)")
print(f" T_ref = {T_dom:.2f} LBM steps")
print(f" T_ref_samples = {T_dom/SAMPLE_INTERVAL:.2f} samples")
print(f" St = {St:.4f}")
print(f" CV_T = {cv_T:.4f}")
print(f" Mean T in samples = {result['mean_T_samples']:.2f}")
if cv_T > 0.05:
print(" WARNING: CV_T > 0.05, cycle stability is marginal")
if St < 0.10 or St > 0.20:
print(" WARNING: Strouhal number out of expected range [0.10, 0.20]")
# Strip field data before saving (too large)
result_no_fields = {k: v for k, v in result.items()
if not isinstance(v, np.ndarray)}
# Save metadata
out_dir = os.path.join(OUTPUT_DIR, "target_cylinder")
os.makedirs(out_dir, exist_ok=True)
with open(os.path.join(out_dir, "meta.json"), "w") as f:
json.dump(result_no_fields, f, indent=2)
# Save raw sensors and forces
np.savez(os.path.join(out_dir, "raw_sensors.npz"),
sensors=sensors,
forces=forces,
sample_interval=SAMPLE_INTERVAL)
# Save fields (keep in memory, also save for later use)
ux_all = np.stack(field_list_ux, axis=0)
uy_all = np.stack(field_list_uy, axis=0)
np.savez_compressed(os.path.join(out_dir, "fields.npz"),
ux=ux_all, uy=uy_all)
# Cleanup
del ff
return result
def main():
ap = argparse.ArgumentParser(description="Phase 0: Standard frequency")
ap.add_argument("--device", type=int, default=2, help="GPU device ID")
args = ap.parse_args()
t0 = time.time()
result = run_phase0(device_id=args.device)
elapsed = time.time() - t0
print(f"\nPhase 0 complete in {elapsed:.1f}s")
print(f"f_ref = {result['f_ref']:.6f}")
print(f"T_ref = {result['T_ref']:.2f} LBM steps = {result['T_ref_steps']:.2f} samples")
print(f"St = {result['St']:.4f}")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -1,769 +0,0 @@
# CCD_analysis/scripts/phase1_collect.py
"""Phase 1: Data collection for all 4 analysis cases.
Usage::
conda run -n pycuda_3_10 python phase1_collect.py --case illusion --device 2
conda run -n pycuda_3_10 python phase1_collect.py --case cloak --device 3
conda run -n pycuda_3_10 python phase1_collect.py --case uncontrolled --device 3
conda run -n pycuda_3_10 python phase1_collect.py --case target_cylinder --device 2
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import time
from collections import deque
from typing import Any, Dict, List, Optional, Tuple
import numpy as np
# Add project root
_REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
if _REPO not in sys.path:
sys.path.insert(0, _REPO)
# Add analysis dir
_ANALYSIS = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if _ANALYSIS not in sys.path:
sys.path.insert(0, _ANALYSIS)
from LegacyCelerisLab import FlowField # noqa: E402
from scripts.cfg import ( # noqa: E402
CONFIG_DIR, OUTPUT_DIR, U0, L0, NX, NY, CENTER_Y, DATA_TYPE,
PINBALL_RADIUS, FRONT_CENTER, BOTTOM_CENTER, TOP_CENTER,
ILLUSION_FRONT, ILLUSION_BOTTOM, ILLUSION_TOP,
SENSOR_RADIUS, SENSOR_CENTERS_CLOAK, SENSOR_CENTERS_ILLUSION,
TARGET_CYLINDER_CENTER, TARGET_CYLINDER_RADIUS,
SAMPLE_INTERVAL, SAMPLE_INTERVAL_ILLUSION,
ACTION_SCALE_CLOAK, ACTION_BIAS_CLOAK,
ACTION_SCALE_ILLUSION, ACTION_BIAS_ILLUSION,
MODEL_CLOAK_RE100, MODEL_ILLUSION_1L,
STABILIZE_STEPS, FIFO_LEN, N_PTS_PER_CYCLE,
nu_from_re,
)
from scripts.utils import ( # noqa: E402
load_configs, get_velocity_field, detect_cycle_stability,
)
# ---------------------------------------------------------------------------
# PPO model loader (with Sin activation)
# ---------------------------------------------------------------------------
def _load_ppo_model(model_path: str, device: str, s_dim: int = 12, a_dim: int = 3):
"""Load PPO model with Sin activation."""
import torch
from torch.nn import Module
from stable_baselines3 import PPO
import gymnasium as gym
from gymnasium import spaces
class Sin(Module):
def forward(self, x):
return torch.sin(x)
class DummyEnv(gym.Env):
def __init__(self):
super().__init__()
self.observation_space = spaces.Box(
low=-1, high=1, shape=(s_dim,), dtype=np.float32)
self.action_space = spaces.Box(
low=-1, high=1, shape=(a_dim,), dtype=np.float32)
def reset(self, seed=None):
return np.zeros(s_dim, dtype=np.float32), {}
def step(self, action):
return np.zeros(s_dim, dtype=np.float32), 0.0, False, False, {}
def render(self):
pass
dummy = DummyEnv()
model = PPO.load(model_path, env=dummy, device=device)
return model
# ---------------------------------------------------------------------------
# Field saving interval calculator
# ---------------------------------------------------------------------------
def _calc_save_interval(T_ref: float, n_pts_per_cycle: int = 24) -> int:
"""Calculate field save interval to get ~n_pts_per_cycle per cycle."""
interval = int(T_ref / n_pts_per_cycle)
return max(1, interval)
# ---------------------------------------------------------------------------
# Phase 1a: Illusion
# ---------------------------------------------------------------------------
def collect_illusion(device_id: int, data: dict) -> dict:
"""Collect illusion case data with proper norm computation and PPO inference.
Follows legacy_env_imit.py __init__ + step() logic exactly:
1. Target cylinder recording (separate FlowField)
2. FFT harmonics on target signals
3. Pinball env with norm computation
4. Bias-action FIFO initialization
5. PPO deterministic rollout with 14-dim normalized observations
"""
actual_U0 = 0.02 # model is 2U
viscosity = nu_from_re(100.0, u0=actual_U0)
sample_interval = SAMPLE_INTERVAL_ILLUSION # 600
fifo_len = 150
conv_len = 36
# ---- Step 1: Target cylinder recording ----
print("--- Record target cylinder ---")
target_U0 = actual_U0
target_nu = viscosity
cuda_cfg, field_cfg = load_configs(CONFIG_DIR)
field_cfg = field_cfg._replace(viscosity=float(target_nu),
velocity=float(target_U0))
ff_target = FlowField(field_cfg, cuda_cfg, device_id=device_id)
# Target cylinder: center=(20*L0, CENTER_Y), radius=1.0*L0
L0 = 20.0
ff_target.add_cylinder(
(20.0 * L0, (512 - 1) / 2, 0.0), 1.0 * L0
)
# 3 sensors at x=30*L0
for y_off in [2.0, 0.0, -2.0]:
ff_target.add_sensor(
(30.0 * L0, (512 - 1) / 2 + y_off * L0, 0.0), L0 / 4.0
)
n_obj_target = ff_target.obs.size // 2 # 4
# Stabilize
ff_target.run(int(4 * 1280 / target_U0), np.zeros(n_obj_target, dtype=np.float32))
# Record 150 steps of obs[0:8] (3 sensors + 1 cylinder force)
target_states = np.empty((0, 8), dtype=np.float32)
for _ in range(fifo_len):
ff_target.run(sample_interval, np.zeros(n_obj_target, dtype=np.float32))
new_state = ff_target.obs.copy()[0:8]
target_states = np.vstack((target_states, new_state))
# FFT harmonics analysis
def analyze_harmonics(states, n_harmonics=5):
N, D = states.shape
result = []
for d in range(D):
y = states[:, d]
fft_coef = np.fft.rfft(y)
freqs = np.fft.rfftfreq(N, d=1)
amps = 2.0 * np.abs(fft_coef) / N
phases = np.angle(fft_coef)
idx = np.argsort(amps[1:])[::-1][:n_harmonics] + 1
harmonics = {
'dc': float(np.real(fft_coef[0]) / N),
'amps': amps[idx].tolist(),
'freqs': freqs[idx].tolist(),
'phases': phases[idx].tolist(),
}
result.append(harmonics)
return result
target_harmonics = analyze_harmonics(target_states, n_harmonics=5)
del ff_target
print(f" target harmonics computed for {len(target_harmonics)} channels")
# ---- Step 2: Pinball env creation ----
print("--- Build pinball env ---")
ff = FlowField(field_cfg, cuda_cfg, device_id=device_id)
for y_off in [2.0, 0.0, -2.0]:
ff.add_sensor(
(30.0 * L0, (512 - 1) / 2 + y_off * L0, 0.0), L0 / 4.0
)
ff.add_cylinder((19.0 * L0, (512 - 1) / 2, 0.0), L0 / 2.0)
ff.add_cylinder((20.3 * L0, (512 - 1) / 2 + 0.75 * L0, 0.0), L0 / 2.0)
ff.add_cylinder((20.3 * L0, (512 - 1) / 2 - 0.75 * L0, 0.0), L0 / 2.0)
n_obj = ff.obs.size // 2 # 6
assert n_obj == 6, f"Expected 6 objects, got {n_obj}"
# Stabilize
ff.run(int(4 * 1280 / actual_U0), np.zeros(n_obj, dtype=np.float32))
ff.get_ddf()
ff.save_ddf() # checkpoint
# ---- Step 3: Norm computation (zero-action rollout) ----
print("--- Compute norm ---")
fifo = deque(maxlen=fifo_len)
for _ in range(fifo_len):
ff.run(sample_interval, np.zeros(n_obj, dtype=np.float32))
fifo.append(ff.obs.copy()[0:12])
temp_states = np.array(fifo, dtype=np.float32)
force_norm_fact = 6.0 * float(np.max(np.abs(temp_states[:, 6:12])))
sens_deviation = np.mean(temp_states[:, 0:6], axis=0).astype(np.float32)
sens_norm_fact = np.zeros(6, dtype=np.float32)
for i in range(6):
sens_norm_fact[i] = 5.0 * float(np.max(np.abs(temp_states[:, i] - sens_deviation[i])))
print(f" force_norm_fact={force_norm_fact:.6f}")
print(f" sens_deviation={sens_deviation}")
print(f" sens_norm_fact={sens_norm_fact}")
# ---- Step 4: Bias-action FIFO initialization ----
print("--- Bias-action FIFO init ---")
ff.apply_ddf()
# bias action from legacy env: [0, 0, 0, 0, -1*U0, 1*U0]
bias_arr = np.zeros(n_obj, dtype=np.float32)
bias_arr[4] = -1.0 * actual_U0 # bottom
bias_arr[5] = 1.0 * actual_U0 # top
fifo.clear()
for _ in range(fifo_len):
ff.run(sample_interval, bias_arr)
fifo.append(ff.obs.copy()[0:12])
save_states = list(fifo)
ff.apply_ddf() # restore checkpoint for reset
# ---- Step 5: PPO inference with adaptive sampling ----
print("--- PPO deterministic rollout (adaptive sampling) ---")
import torch
device_str = f"cuda:{device_id}" if torch.cuda.is_available() else "cpu"
model = _load_ppo_model(MODEL_ILLUSION_1L, device=device_str, s_dim=14, a_dim=3)
model.set_random_seed(19)
n_steps = 200
# Compute adaptive field sampling interval from expected period
# St = 0.267, D = 40, expected f = St * U0 / D
f_expected = 0.2667 * actual_U0 / 40.0
T_expected = int(1.0 / f_expected) if f_expected > 0 else 7500
field_interval = max(1, int(T_expected / N_PTS_PER_CYCLE))
print(f" T_expected={T_expected} steps, field_interval={field_interval} "
f"(~{T_expected/field_interval:.0f} pts/cycle)")
# Data at PPO-action cadence (once per 600 steps, for PPO state only)
ppo_actions = []
ppo_sensors_600 = []
# Dense data at field_interval cadence (for phase analysis)
dense_sensors = []
dense_forces = []
dense_ux = []
dense_uy = []
# Re-initialize FIFO for inference
fifo = deque(maxlen=fifo_len)
for state in save_states:
fifo.append(np.array(state, dtype=np.float32))
obs = np.zeros(14, dtype=np.float32)
for step in range(n_steps):
# PPO action
action, _states = model.predict(obs, deterministic=True)
action = action.astype(np.float32).flatten()
ppo_actions.append(action.copy())
# Convert to physical omega
temp = np.zeros(n_obj, dtype=np.float32)
omega = (action * ACTION_SCALE_ILLUSION
+ np.array(ACTION_BIAS_ILLUSION, dtype=np.float32)) * actual_U0
temp[3:6] = omega
# Run CFD with dense intra-step sampling
ff.context.push()
try:
# First chunk
ff.run(field_interval, temp)
ux, uy = get_velocity_field(ff, u0=actual_U0)
dense_ux.append(ux)
dense_uy.append(uy)
dense_sensors.append(ff.obs.copy()[0:6])
dense_forces.append(ff.obs.copy()[6:12])
# Second chunk (remaining)
remaining = sample_interval - field_interval
if remaining > 0:
ff.run(remaining, temp)
ux, uy = get_velocity_field(ff, u0=actual_U0)
dense_ux.append(ux)
dense_uy.append(uy)
dense_sensors.append(ff.obs.copy()[0:6])
dense_forces.append(ff.obs.copy()[6:12])
finally:
ff.context.pop()
# PPO state: use last obs_slice
last_sens = dense_sensors[-1]
last_force = dense_forces[-1]
obs_slice = np.concatenate([last_sens, last_force])
fifo.append(obs_slice)
ppo_sensors_600.append(obs_slice)
# Build normalized 14-dim observation for next PPO step
forces_norm = last_force / force_norm_fact
sens_norm = (last_sens - sens_deviation) / sens_norm_fact
target_recon = _gen_target_states_at(step, target_harmonics)
target_cd_norm = float(target_recon[0]) / force_norm_fact
target_cl_norm = float(target_recon[1]) / force_norm_fact
obs = np.clip(
np.hstack([forces_norm, sens_norm, target_cd_norm, target_cl_norm]),
-1.0, 1.0,
).astype(np.float32)
if step % 20 == 0:
print(f" step {step}/{n_steps}, action={action[0]:.3f} {action[1]:.3f} {action[2]:.3f}")
# Save dense data (for phase resampling)
ux_all = np.stack(dense_ux, axis=0)
uy_all = np.stack(dense_uy, axis=0)
dense_sensors_arr = np.array(dense_sensors, dtype=np.float32)
dense_forces_arr = np.array(dense_forces, dtype=np.float32)
ppo_actions_arr = np.array(ppo_actions, dtype=np.float32)
n_dense_per_step = len(dense_sensors) // n_steps
dense_dt = sample_interval / n_dense_per_step if n_dense_per_step > 0 else sample_interval
print(f" Dense sampling: {len(dense_sensors)} samples, "
f"{n_dense_per_step} per PPO step, dt={dense_dt:.0f} LBM steps")
out_dir = os.path.join(OUTPUT_DIR, "illusion")
os.makedirs(out_dir, exist_ok=True)
np.savez_compressed(os.path.join(out_dir, "fields.npz"), ux=ux_all, uy=uy_all)
np.savez(os.path.join(out_dir, "dense_sensors.npz"),
sensors=dense_sensors_arr, forces=dense_forces_arr,
dense_dt=dense_dt,
sample_interval=sample_interval)
# Save PPO-step-cadence data and metadata
np.savez(os.path.join(out_dir, "sensors.npz"),
sensors=dense_sensors_arr.reshape(n_steps, -1, 6)[:, -1],
forces=dense_forces_arr.reshape(n_steps, -1, 6)[:, -1],
actions=ppo_actions_arr,
sample_interval=sample_interval,
force_norm_fact=np.array([force_norm_fact], dtype=np.float32),
sens_deviation=np.array(sens_deviation, dtype=np.float32),
sens_norm_fact=np.array(sens_norm_fact, dtype=np.float32))
# Save target data for later use
np.savez(os.path.join(out_dir, "target_harmonics.npz"),
target_states=target_states,
harmonics_data=np.array(target_harmonics, dtype=object))
meta = {
"case": "illusion",
"model": str(MODEL_ILLUSION_1L),
"n_steps": n_steps,
"n_fields": len(dense_ux),
"n_dense_samples": len(dense_sensors),
"dense_dt": dense_dt,
"T_expected": T_expected,
"field_interval": field_interval,
"sample_interval": sample_interval,
"action_scale": ACTION_SCALE_ILLUSION,
"action_bias": list(ACTION_BIAS_ILLUSION),
"U0": actual_U0,
"viscosity": viscosity,
"n_obj": n_obj,
"force_norm_fact": force_norm_fact,
"sens_deviation": sens_deviation.tolist(),
"sens_norm_fact": sens_norm_fact.tolist(),
}
with open(os.path.join(out_dir, "meta.json"), "w") as f:
json.dump(meta, f, indent=2)
print(f" Saved {len(dense_ux)} fields, {len(dense_sensors)} dense samples")
del ff, model
return meta
def _gen_target_states_at(t, harmonics):
"""Reconstruct target observable at step index t from harmonics.
Mirrors legacy_env_imit.py gen_target_states_at().
"""
t = np.asarray(t)
D = len(harmonics)
result = np.zeros((t.size, D), dtype=np.float32)
for d, h in enumerate(harmonics):
val = np.full(t.shape, h['dc'], dtype=np.float32)
amps = h['amps']
freqs = h['freqs']
phases = h['phases']
for amp, freq, phase in zip(amps, freqs, phases):
val += amp * np.cos(2 * np.pi * freq * t + phase)
result[:, d] = val
if result.shape[0] == 1:
return result[0]
return result
# ---------------------------------------------------------------------------
# Phase 1b: Cloak (steady flow case)
# ---------------------------------------------------------------------------
def collect_cloak(device_id: int, data: dict) -> dict:
"""Collect cloak case data (PPO -> steady action -> mean flow)."""
viscosity = nu_from_re(100.0)
sample_interval = SAMPLE_INTERVAL
import torch
device_str = f"cuda:{device_id}" if torch.cuda.is_available() else "cpu"
model = _load_ppo_model(MODEL_CLOAK_RE100, device=device_str, s_dim=12, a_dim=3)
model.set_random_seed(0)
# Create env: 6 objects (3 sensors + 3 pinball, NO disturbance cylinder)
cuda_cfg, field_cfg = load_configs(CONFIG_DIR)
field_cfg = field_cfg._replace(viscosity=float(viscosity))
ff = FlowField(field_cfg, cuda_cfg, device_id=device_id)
for sc in SENSOR_CENTERS_CLOAK:
ff.add_sensor((sc[0], sc[1], 0.0), SENSOR_RADIUS)
ff.add_cylinder((FRONT_CENTER[0], FRONT_CENTER[1], 0.0), PINBALL_RADIUS)
ff.add_cylinder((BOTTOM_CENTER[0], BOTTOM_CENTER[1], 0.0), PINBALL_RADIUS)
ff.add_cylinder((TOP_CENTER[0], TOP_CENTER[1], 0.0), PINBALL_RADIUS)
n_obj = ff.obs.size // 2
assert n_obj == 6, f"Expected 6 objects for cloak, got {n_obj}"
# Stabilize
ff.run(STABILIZE_STEPS, np.zeros(n_obj, dtype=np.float32))
# ---- PPO deterministic rollout to find steady action ----
n_ppo_steps = 200
print(f"Running {n_ppo_steps} PPO steps to extract steady action...")
obs = np.zeros(12, dtype=np.float32)
actions_list = []
sensors_list = []
forces_list = []
for step in range(n_ppo_steps):
action, _states = model.predict(obs, deterministic=True)
action = action.astype(np.float32).flatten()
actions_list.append(action.copy())
temp = np.zeros(n_obj, dtype=np.float32)
omega = (action * ACTION_SCALE_CLOAK
+ np.array(ACTION_BIAS_CLOAK, dtype=np.float32)) * U0
temp[3:6] = omega
ff.context.push()
try:
ff.run(sample_interval, temp)
finally:
ff.context.pop()
obs_slice = ff.obs.copy()[0:12]
sensors_list.append(obs_slice[0:6].copy())
forces_list.append(obs_slice[6:12].copy())
# Build observation for next step
obs = np.clip(np.hstack([obs_slice[6:12], obs_slice[0:6]]),
-10.0, 10.0).astype(np.float32)
# Extract steady action (average of last 100 steps)
actions_arr = np.array(actions_list, dtype=np.float32)
steady_action = np.mean(actions_arr[-100:], axis=0)
print(f" Steady action ([-1,1]): {steady_action[0]:.4f} {steady_action[1]:.4f} {steady_action[2]:.4f}")
print(f" Steady omega (U0 multiples): "
f"{(steady_action*ACTION_SCALE_CLOAK+np.array(ACTION_BIAS_CLOAK))[0]:.4f} "
f"{(steady_action*ACTION_SCALE_CLOAK+np.array(ACTION_BIAS_CLOAK))[1]:.4f} "
f"{(steady_action*ACTION_SCALE_CLOAK+np.array(ACTION_BIAS_CLOAK))[2]:.4f}")
# ---- Apply steady action and record mean flow ----
print("Applying steady action and recording...")
temp_steady = np.zeros(n_obj, dtype=np.float32)
omega_steady = (steady_action * ACTION_SCALE_CLOAK
+ np.array(ACTION_BIAS_CLOAK, dtype=np.float32)) * U0
temp_steady[3:6] = omega_steady
# Re-stabilize with steady action (4x NX/U0)
ff.context.push()
try:
ff.run(STABILIZE_STEPS, temp_steady)
finally:
ff.context.pop()
# Record steady state fields and sensors
n_steady_samples = 30
steady_sensors = []
steady_forces = []
steady_ux = []
steady_uy = []
for i in range(n_steady_samples):
ff.context.push()
try:
ff.run(sample_interval, temp_steady)
finally:
ff.context.pop()
obs_slice = ff.obs.copy()[0:12]
steady_sensors.append(obs_slice[0:6])
steady_forces.append(obs_slice[6:12])
ux, uy = get_velocity_field(ff, u0=U0)
steady_ux.append(ux)
steady_uy.append(uy)
steady_sensors_arr = np.array(steady_sensors, dtype=np.float32)
steady_forces_arr = np.array(steady_forces, dtype=np.float32)
ux_all = np.stack(steady_ux, axis=0)
uy_all = np.stack(steady_uy, axis=0)
out_dir = os.path.join(OUTPUT_DIR, "cloak")
os.makedirs(out_dir, exist_ok=True)
np.savez_compressed(os.path.join(out_dir, "fields.npz"),
ux=ux_all, uy=uy_all)
np.savez(os.path.join(out_dir, "sensors.npz"),
sensors=steady_sensors_arr, forces=steady_forces_arr)
np.savez(os.path.join(out_dir, "ppo_rollout.npz"),
actions=actions_arr,
sensors=np.array(sensors_list, dtype=np.float32),
forces=np.array(forces_list, dtype=np.float32),
steady_action=steady_action)
meta = {
"case": "cloak",
"model": str(MODEL_CLOAK_RE100),
"sample_interval": sample_interval,
"action_scale": ACTION_SCALE_CLOAK,
"action_bias": list(ACTION_BIAS_CLOAK),
"steady_action_norm": steady_action.tolist(),
"steady_omega_U0": (steady_action * ACTION_SCALE_CLOAK
+ np.array(ACTION_BIAS_CLOAK)).tolist(),
"U0": U0,
"viscosity": viscosity,
"n_obj": n_obj,
"n_steady_samples": n_steady_samples,
}
with open(os.path.join(out_dir, "meta.json"), "w") as f:
json.dump(meta, f, indent=2)
print(f" Steady action recorded. Mean sensors: "
f"{np.mean(steady_sensors_arr, axis=0)}")
print(f" Mean total force: "
f"Fx={np.mean(steady_forces_arr[:, 0::2]):.6f} "
f"Fy={np.mean(steady_forces_arr[:, 1::2]):.6f}")
del ff, model
return meta
# ---------------------------------------------------------------------------
# Phase 1c: Uncontrolled
# ---------------------------------------------------------------------------
def collect_uncontrolled(device_id: int, data: dict) -> dict:
"""Collect uncontrolled case data (zero-action baseline)."""
viscosity = nu_from_re(100.0)
sample_interval = SAMPLE_INTERVAL
T_ref = data.get("T_ref", 15000.0)
save_interval = _calc_save_interval(T_ref)
cuda_cfg, field_cfg = load_configs(CONFIG_DIR)
field_cfg = field_cfg._replace(viscosity=float(viscosity))
ff = FlowField(field_cfg, cuda_cfg, device_id=device_id)
for sc in SENSOR_CENTERS_CLOAK:
ff.add_sensor((sc[0], sc[1], 0.0), SENSOR_RADIUS)
ff.add_cylinder((FRONT_CENTER[0], FRONT_CENTER[1], 0.0), PINBALL_RADIUS)
ff.add_cylinder((BOTTOM_CENTER[0], BOTTOM_CENTER[1], 0.0), PINBALL_RADIUS)
ff.add_cylinder((TOP_CENTER[0], TOP_CENTER[1], 0.0), PINBALL_RADIUS)
n_obj = ff.obs.size // 2
assert n_obj == 6
# Stabilize
ff.run(STABILIZE_STEPS, np.zeros(n_obj, dtype=np.float32))
# Run uncontrolled
n_steps = 200
sensors_list = []
forces_list = []
ux_fields = []
uy_fields = []
for step in range(n_steps):
ff.context.push()
try:
remaining = sample_interval
while remaining > 0:
chunk = min(remaining, save_interval)
ff.run(chunk, np.zeros(n_obj, dtype=np.float32))
remaining -= chunk
ux, uy = get_velocity_field(ff, u0=U0)
ux_fields.append(ux)
uy_fields.append(uy)
finally:
ff.context.pop()
obs_slice = ff.obs.copy()[0:12]
sensors_list.append(obs_slice[0:6])
forces_list.append(obs_slice[6:12])
sensors = np.array(sensors_list, dtype=np.float32)
forces = np.array(forces_list, dtype=np.float32)
ux_all = np.stack(ux_fields, axis=0)
uy_all = np.stack(uy_fields, axis=0)
out_dir = os.path.join(OUTPUT_DIR, "uncontrolled")
os.makedirs(out_dir, exist_ok=True)
np.savez_compressed(os.path.join(out_dir, "fields.npz"),
ux=ux_all, uy=uy_all)
np.savez(os.path.join(out_dir, "sensors.npz"),
sensors=sensors, forces=forces)
meta = {
"case": "uncontrolled",
"U0": U0,
"viscosity": viscosity,
"n_steps": n_steps,
"n_fields": len(ux_fields),
"sample_interval": sample_interval,
"n_obj": n_obj,
}
with open(os.path.join(out_dir, "meta.json"), "w") as f:
json.dump(meta, f, indent=2)
print(f" Saved {len(ux_fields)} fields, {len(sensors)} sensor steps")
del ff
return meta
# ---------------------------------------------------------------------------
# Phase 1d: Target cylinder (reference for period detection)
# ---------------------------------------------------------------------------
def collect_target_cylinder(device_id: int, data: dict) -> dict:
"""Collect target 2D cylinder reference data.
Most data was already collected in Phase 0. Here we just ensure
the fields are properly saved with the right naming.
"""
# Phase 0 already saved data to output/target_cylinder/
# Just verify it exists and copy meta
out_dir = os.path.join(OUTPUT_DIR, "target_cylinder")
meta_path = os.path.join(out_dir, "meta.json")
if not os.path.exists(meta_path):
raise RuntimeError(
"Phase 0 must be run first. No target_cylinder data found."
)
with open(meta_path, "r") as f:
meta = json.load(f)
print(f"Target cylinder data found at {out_dir}")
print(f" f_ref={meta['f_ref']:.6f}, T_ref={meta['T_ref']:.0f}, St={meta['St']:.4f}")
print(f" CV_T={meta['CV_T']:.4f}")
return meta
# ---------------------------------------------------------------------------
# Empty channel (target steady flow for cloak comparison)
# ---------------------------------------------------------------------------
def collect_empty_channel(device_id: int) -> dict:
"""Run empty channel (no bodies) and record steady parabolic flow."""
viscosity = nu_from_re(100.0)
cuda_cfg, field_cfg = load_configs(CONFIG_DIR)
field_cfg = field_cfg._replace(viscosity=float(viscosity))
ff = FlowField(field_cfg, cuda_cfg, device_id=device_id)
# Need at least one sensor (legacy API requirement)
ff.add_sensor((NX - 10, CENTER_Y, 0.0), SENSOR_RADIUS)
n_obj = ff.obs.size // 2
ff.run(STABILIZE_STEPS, np.zeros(n_obj, dtype=np.float32))
# Record a few fields
ux_list, uy_list = [], []
for i in range(5):
ff.run(SAMPLE_INTERVAL, np.zeros(n_obj, dtype=np.float32))
ux, uy = get_velocity_field(ff, u0=U0)
ux_list.append(ux)
uy_list.append(uy)
ux_all = np.stack(ux_list, axis=0)
uy_all = np.stack(uy_list, axis=0)
out_dir = os.path.join(OUTPUT_DIR, "empty_channel")
os.makedirs(out_dir, exist_ok=True)
np.savez_compressed(os.path.join(out_dir, "fields.npz"),
ux=ux_all, uy=uy_all)
meta = {
"case": "empty_channel",
"U0": U0,
"viscosity": viscosity,
"n_fields": len(ux_list),
}
with open(os.path.join(out_dir, "meta.json"), "w") as f:
json.dump(meta, f, indent=2)
print("Empty channel flow recorded")
del ff
return meta
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
ap = argparse.ArgumentParser(description="Phase 1: Data collection")
ap.add_argument("--case", type=str, required=True,
choices=["all", "illusion", "cloak", "uncontrolled",
"target_cylinder", "empty_channel"],
help="Case to collect")
ap.add_argument("--device", type=int, default=2, help="GPU device ID")
args = ap.parse_args()
# Load Phase 0 data for f_ref / T_ref
f_ref_path = os.path.join(OUTPUT_DIR, "target_cylinder", "meta.json")
if os.path.exists(f_ref_path):
with open(f_ref_path, "r") as f:
phase0_data = json.load(f)
else:
phase0_data = {"T_ref": 15000.0, "f_ref": 6.67e-5}
print("WARNING: Phase 0 not found, using default T_ref=15000")
t0 = time.time()
results = {}
if args.case in ("all", "illusion"):
print("=" * 60)
print("Collecting Illusion case...")
print("=" * 60)
phase0_data["illusion_2u"] = True
results["illusion"] = collect_illusion(args.device, phase0_data)
if args.case in ("all", "cloak"):
print("=" * 60)
print("Collecting Cloak case...")
print("=" * 60)
results["cloak"] = collect_cloak(args.device, phase0_data)
if args.case in ("all", "uncontrolled"):
print("=" * 60)
print("Collecting Uncontrolled case...")
print("=" * 60)
results["uncontrolled"] = collect_uncontrolled(args.device, phase0_data)
if args.case in ("all", "target_cylinder"):
print("=" * 60)
print("Collecting Target Cylinder...")
print("=" * 60)
results["target_cylinder"] = collect_target_cylinder(
args.device, phase0_data)
if args.case in ("all", "empty_channel"):
print("=" * 60)
print("Collecting Empty Channel (steady target)...")
print("=" * 60)
results["empty_channel"] = collect_empty_channel(args.device)
elapsed = time.time() - t0
print(f"\nPhase 1 complete in {elapsed:.1f}s")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -1,514 +0,0 @@
# CCD_analysis/scripts/phase2_resample.py
"""Phase 2: Period detection and phase resampling for periodic cases.
Usage::
python phase2_resample.py
Output::
- output/resampled/ phase-resampled data for each qualifying case
- Console report of period stability for all periodic cases
"""
from __future__ import annotations
import json
import os
import sys
import time
import numpy as np
_ANALYSIS = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if _ANALYSIS not in sys.path:
sys.path.insert(0, _ANALYSIS)
from scripts.cfg import ( # noqa: E402
OUTPUT_DIR, U0, SAMPLE_INTERVAL, SAMPLE_INTERVAL_ILLUSION,
N_TARGET_CYCLES, N_PTS_PER_CYCLE, TOTAL_PHASE_FRAMES,
)
from scripts.analysis_utils import ( # noqa: E402
detect_dominant_frequency, detect_cycle_stability, phase_resample,
)
# ---------------------------------------------------------------------------
# Gate criteria
# ---------------------------------------------------------------------------
CV_T_THRESHOLD_STRICT = 0.10
CV_T_THRESHOLD_RELAXED = 0.12
DELTA_F_THRESHOLD_STRICT = 0.10
DELTA_F_THRESHOLD_RELAXED = 0.20
# ---------------------------------------------------------------------------
# Load raw data for a case
# ---------------------------------------------------------------------------
def load_case_raw(case_name: str) -> dict:
"""Load raw sensor/force/action data for a case."""
case_dir = os.path.join(OUTPUT_DIR, case_name)
meta_path = os.path.join(case_dir, "meta.json")
if not os.path.exists(meta_path):
return {"exists": False, "error": f"{case_dir}/meta.json not found"}
with open(meta_path, "r") as f:
meta = json.load(f)
result = {"exists": True, "meta": meta, "name": case_name}
# Load sensors (check both naming conventions)
sens_path = os.path.join(case_dir, "sensors.npz")
dense_sens_path = os.path.join(case_dir, "dense_sensors.npz")
raw_sens_path = os.path.join(case_dir, "raw_sensors.npz")
if os.path.exists(dense_sens_path):
load_path = dense_sens_path
elif os.path.exists(sens_path):
load_path = sens_path
elif os.path.exists(raw_sens_path):
load_path = raw_sens_path
else:
load_path = None
if load_path is not None:
data = np.load(load_path)
if "sensors" in data:
result["sensors"] = data["sensors"]
if "forces" in data:
result["forces"] = data["forces"]
if "actions" in data:
result["actions"] = data["actions"]
# Determine sample interval (dense data may use dense_dt)
if "dense_dt" in data:
result["sample_interval"] = int(data["dense_dt"])
elif "sample_interval" in data:
result["sample_interval"] = int(data["sample_interval"])
# If we loaded dense data but missing actions, try sensors.npz
if load_path == dense_sens_path and "actions" not in result:
if os.path.exists(sens_path):
extra = np.load(sens_path)
if "actions" in extra:
result["actions"] = extra["actions"]
print(f" loaded actions from sensors.npz: {result['actions'].shape}")
# Determine sample interval from meta if not in data
if "sample_interval" not in result:
result["sample_interval"] = meta.get("sample_interval", SAMPLE_INTERVAL)
# Get U0 from meta
result["U0"] = meta.get("U0", 0.01)
# Load fields (lazy — only when accessed)
fields_path = os.path.join(case_dir, "fields.npz")
if os.path.exists(fields_path):
result["_fields_path"] = fields_path
result["_fields_loader"] = lambda p=fields_path: np.load(p)
return result
# ---------------------------------------------------------------------------
# Check period stability for a case
# ---------------------------------------------------------------------------
def check_period_stability(
case_data: dict, f_ref: float, T_ref: float, St: float = 0.2667,
case_U0: float = 0.01
) -> dict:
"""Check period stability and return gate result.
delta_f computed relative to expected frequency from Strouhal number:
f_expected = St * U0 / D_cylinder
This handles cases at different U0 (e.g. illusion 2U at U0=0.02).
Returns dict with gate info.
"""
sensors = case_data.get("sensors")
if sensors is None or len(sensors) < 30:
return {"gate": "no_data", "reason": "insufficient sensor data"}
sample_interval = case_data.get("sample_interval", SAMPLE_INTERVAL)
D_cyl = 40.0 # cylinder diameter in lattice units (2*L0)
signal = sensors[:, 3]
# Detect frequency
f_case, T_case, peak_power = detect_dominant_frequency(signal, sample_interval)
# Detect cycle stability
cv_T, mean_T, cycle_lengths = detect_cycle_stability(signal, sample_interval)
# Expected frequency from Strouhal number at this U0
f_expected = St * case_U0 / D_cyl
# Gate — compare to expected frequency from target cylinder St
delta_f = abs(f_case - f_expected) / f_expected if f_expected > 0 else 1.0
# Gate determination with new thresholds
if cv_T <= CV_T_THRESHOLD_STRICT and delta_f <= DELTA_F_THRESHOLD_STRICT:
gate = "strict"
elif cv_T <= CV_T_THRESHOLD_RELAXED and delta_f <= DELTA_F_THRESHOLD_RELAXED:
gate = "relaxed"
else:
gate = "auxiliary"
# Interpolation quality check
N_raw_per_cycle = mean_T / sample_interval if mean_T > 0 else 0
rho_interp = 24.0 / N_raw_per_cycle if N_raw_per_cycle > 0 else 99.0
if rho_interp > 2.0:
interp_quality = "reject"
elif rho_interp > 1.5:
interp_quality = "borderline"
elif rho_interp > 1.2:
interp_quality = "acceptable"
else:
interp_quality = "ideal"
# Also check signal quality
signal_range = float(np.max(signal) - np.min(signal))
signal_rms = float(np.std(signal))
result = {
"case": case_data.get("name", "unknown"),
"gate": gate,
"f_case": float(f_case),
"f_expected": float(f_expected),
"T_case": float(T_case),
"T_case_samples": float(T_case / sample_interval),
"CV_T": float(cv_T),
"delta_f": float(delta_f),
"mean_T_samples": float(mean_T / sample_interval),
"N_raw_per_cycle": float(N_raw_per_cycle),
"rho_interp": float(rho_interp),
"interp_quality": interp_quality,
"n_cycles_detected": len(cycle_lengths),
"signal_range": signal_range,
"signal_rms": signal_rms,
"n_raw_samples": len(sensors),
"St": St,
"U0": float(case_U0),
}
return result
# ---------------------------------------------------------------------------
# Extract cycles and resample
# ---------------------------------------------------------------------------
def extract_and_resample(
case_data: dict, f_ref: float, T_ref: float, St: float = 0.2667,
n_cycles: int = N_TARGET_CYCLES,
n_pts: int = N_PTS_PER_CYCLE,
) -> dict:
"""Extract cycles and resample to uniform phase grid.
Parameters
----------
case_data : dict
Raw case data with sensors, forces, actions, ux, uy.
f_ref, T_ref : float
Reference frequency and period (from Phase 0 at U0=0.01).
St : float
Strouhal number (U0-invariant reference).
n_cycles, n_pts : int
Number of cycles and points per cycle.
Returns
-------
dict with resampled fields, sensors, forces, actions.
"""
sensors = case_data.get("sensors")
sample_interval = case_data.get("sample_interval", SAMPLE_INTERVAL)
case_U0 = case_data.get("U0", 0.01)
D_cyl = 40.0
# Compute expected T for this case's U0
f_expected = St * case_U0 / D_cyl
T_expected = 1.0 / f_expected if f_expected > 0 else T_ref
if sensors is None or len(sensors) < 30:
return {"resampled": False, "reason": "insufficient data"}
signal = sensors[:, 3] # centre sensor v
# Find rising zero-crossings to define cycle boundaries
y = signal - np.mean(signal)
sign = np.sign(y)
crossings = np.where((sign[:-1] < 0) & (sign[1:] > 0))[0]
if len(crossings) < n_cycles + 1:
print(f" Only {len(crossings)} crossings found, need {n_cycles + 1}")
return {"resampled": False, "reason": f"need {n_cycles + 1} crossings, got {len(crossings)}"}
# Select the most representative n_cycles (use expected period)
cycle_lengths = np.diff(crossings)
T_exp_samples = T_expected / sample_interval
# Score each window of n_cycles consecutive cycles
best_score = float("inf")
best_start = 0
for i in range(len(cycle_lengths) - n_cycles + 1):
window = cycle_lengths[i:i + n_cycles]
score = np.sum((window - T_exp_samples) ** 2)
if score < best_score:
best_score = score
best_start = i
selected_crossings = list(crossings[best_start:best_start + n_cycles + 1])
# Phase resample sensors
if sensors.ndim == 2:
resampled_sensors = phase_resample(
sensors, selected_crossings, n_pts=n_pts
)
else:
resampled_sensors = phase_resample(
sensors[:, None], selected_crossings, n_pts=n_pts
)
result = {
"resampled": True,
"selected_crossings": selected_crossings,
"sensors": resampled_sensors, # (n_cycles, n_pts, 6)
}
# Resample forces
forces = case_data.get("forces")
if forces is not None and forces.ndim == 2:
result["forces"] = phase_resample(
forces, selected_crossings, n_pts=n_pts
)
# Resample actions
actions = case_data.get("actions")
if actions is not None and actions.ndim == 2:
result["actions"] = phase_resample(
actions, selected_crossings, n_pts=n_pts
)
# Resample field data (lazy-loaded if available)
ux = None
uy = None
loader = case_data.get("_fields_loader")
if loader is not None:
fields_npz = loader()
if "ux" in fields_npz and "uy" in fields_npz:
ux = fields_npz["ux"]
uy = fields_npz["uy"]
if ux is not None and uy is not None:
n_fields = len(ux)
n_sensors = len(sensors)
# Fields and sensors are sampled at different rates
# Fields are saved at save_interval within each PPO step
# Sensors are saved once per PPO step
# The ratio is approximately T_ref / n_pts / sample_interval
# Convert crossing indices from sensor-space to field-space
# Simple approach: resample fields using the same crossing indices
# Since fields may have different count, use normalized indices
field_crossings = [
int(c * n_fields / n_sensors) for c in selected_crossings
]
field_crossings = [max(0, min(c, n_fields - 1)) for c in field_crossings]
# Deduplicate and ensure > n_cycles+1 entries
field_crossings = sorted(set(field_crossings))
if len(field_crossings) >= 2:
# Stack ux and uy into single array
nx, ny = ux.shape[1], ux.shape[2]
field_flat = np.column_stack([
ux.reshape(n_fields, -1), uy.reshape(n_fields, -1)
])
resampled_fields = phase_resample(
field_flat, field_crossings[:n_cycles + 1], n_pts=n_pts
)
# Unflatten
n_cycle_actual, n_pt, n_dim = resampled_fields.shape
half = n_dim // 2
result["ux"] = resampled_fields[:, :, :half].reshape(
n_cycle_actual, n_pt, ny, nx
)
result["uy"] = resampled_fields[:, :, half:].reshape(
n_cycle_actual, n_pt, ny, nx
)
return result
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
# Load Phase 0 reference data
ref_path = os.path.join(OUTPUT_DIR, "target_cylinder", "meta.json")
if not os.path.exists(ref_path):
print("ERROR: Phase 0 data not found. Run phase0_standard_freq.py first.")
return 1
with open(ref_path, "r") as f:
ref = json.load(f)
f_ref = ref["f_ref"]
T_ref = ref["T_ref"]
print(f"Reference: f_ref={f_ref:.6f}, T_ref={T_ref:.0f} steps, St={ref['St']:.4f}")
print()
# Load all periodic cases
periodic_cases = ["target_cylinder", "illusion", "uncontrolled"]
all_raw = {}
for case in periodic_cases:
print(f"Loading {case}...")
all_raw[case] = load_case_raw(case)
if all_raw[case].get("exists"):
nf = "lazy"
ns = len(all_raw[case].get("sensors", []))
print(f" fields={nf}, sensors={ns}")
# Check period stability
print("\n=== Period Stability Check ===")
results = []
for case in periodic_cases:
data = all_raw[case]
if not data.get("exists"):
print(f" {case}: NO DATA, skipping")
continue
r = check_period_stability(
data, f_ref, T_ref, St=ref["St"],
case_U0=data.get("U0", 0.01),
)
results.append(r)
status = r["gate"].upper()
print(f" {case}: {status} f_case={r['f_case']:.6f} "
f"CV_T={r['CV_T']:.4f} delta_f={r['delta_f']:.4f} "
f"T_samples={r['mean_T_samples']:.1f} "
f"N_raw/cycle={r.get('N_raw_per_cycle', '?'):.1f} "
f"interp={r.get('interp_quality', '?')}")
# Save stability report
os.makedirs(os.path.join(OUTPUT_DIR, "resampled"), exist_ok=True)
with open(os.path.join(OUTPUT_DIR, "resampled", "stability_report.json"), "w") as f:
json.dump({
"f_ref": f_ref,
"T_ref": T_ref,
"St": ref["St"],
"thresholds": {
"CV_T_strict": CV_T_THRESHOLD_STRICT,
"CV_T_relaxed": CV_T_THRESHOLD_RELAXED,
"delta_f_strict": DELTA_F_THRESHOLD_STRICT,
"delta_f_relaxed": DELTA_F_THRESHOLD_RELAXED,
},
"cases": results,
}, f, indent=2)
# Phase resample for qualifying cases
print("\n=== Phase Resampling ===")
qualifying = [r for r in results if r.get("gate") in ("strict", "relaxed")]
falling = [r for r in results if r.get("gate") not in ("strict", "relaxed")]
strict_cases = [r["case"] for r in results if r.get("gate") == "strict"]
relaxed_cases = [r["case"] for r in results if r.get("gate") == "relaxed"]
print(f"Strict (main POD basis): {strict_cases}")
print(f"Relaxed (projection): {relaxed_cases}")
print(f"Auxiliary/falling: {[r['case'] for r in falling]}")
for r in qualifying:
case_name = r["case"]
print(f"\nResampling {case_name} (strict)...")
data = all_raw[case_name]
result = extract_and_resample(data, f_ref, T_ref, St=ref["St"])
if result.get("resampled"):
out_dir = os.path.join(OUTPUT_DIR, "resampled", case_name)
os.makedirs(out_dir, exist_ok=True)
# Save resampled data
save_dict = {
"sensors": result["sensors"], # (n_cycles, n_pts, 6)
"n_cycles": result["sensors"].shape[0],
"n_pts": result["sensors"].shape[1],
}
if "forces" in result:
save_dict["forces"] = result["forces"]
if "actions" in result:
save_dict["actions"] = result["actions"]
if "ux" in result and "uy" in result:
save_dict["ux"] = result["ux"]
save_dict["uy"] = result["uy"]
np.savez_compressed(os.path.join(out_dir, "resampled.npz"), **save_dict)
# Also save metadata
meta = {
"case": case_name,
"f_ref": f_ref,
"T_ref": T_ref,
"n_cycles": int(result["sensors"].shape[0]),
"n_pts": int(result["sensors"].shape[1]),
"selected_crossings": [int(c) for c in result["selected_crossings"]],
"has_fields": "ux" in result,
}
with open(os.path.join(out_dir, "meta.json"), "w") as f:
json.dump(meta, f, indent=2)
sh = result["sensors"].shape
print(f" Resampled: {sh}, fields={'yes' if meta['has_fields'] else 'no'}")
else:
print(f" Resampling failed: {result.get('reason', 'unknown')}")
# Also resample relaxed cases (Scheme A — projection only, no common POD)
# Also resample relaxed cases (projection only, no POD basis training)
for r in results:
if r.get("gate") != "relaxed":
continue
case_name = r["case"]
if case_name in [q["case"] for q in qualifying]:
continue # already done above
print(f"\nResampling {case_name} (relaxed, projection)...")
data = all_raw[case_name]
result = extract_and_resample(data, f_ref, T_ref, St=ref["St"])
if result.get("resampled"):
out_dir = os.path.join(OUTPUT_DIR, "resampled", case_name)
os.makedirs(out_dir, exist_ok=True)
save_dict = {
"sensors": result["sensors"],
"n_cycles": result["sensors"].shape[0],
"n_pts": result["sensors"].shape[1],
}
if "forces" in result:
save_dict["forces"] = result["forces"]
if "actions" in result:
save_dict["actions"] = result["actions"]
if "ux" in result and "uy" in result:
save_dict["ux"] = result["ux"]
save_dict["uy"] = result["uy"]
np.savez_compressed(os.path.join(out_dir, "resampled.npz"), **save_dict)
meta = {"case": case_name, "f_ref": f_ref, "T_ref": T_ref,
"n_cycles": int(result["sensors"].shape[0]),
"n_pts": int(result["sensors"].shape[1]),
"selected_crossings": [int(c) for c in result["selected_crossings"]],
"gate": "relaxed"}
with open(os.path.join(out_dir, "meta.json"), "w") as f:
json.dump(meta, f, indent=2)
print(f" Resampled (relaxed): {result['sensors'].shape}")
else:
print(f" Resampling failed: {result.get('reason', 'unknown')}")
# Save resampling summary
summary = {"strict": strict_cases,
"relaxed": relaxed_cases,
"auxiliary": [r["case"] for r in results if r.get("gate") not in ("strict", "relaxed")]}
with open(os.path.join(OUTPUT_DIR, "resampled", "summary.json"), "w") as f:
json.dump(summary, f, indent=2)
print("\nPhase 2 complete.")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -1,218 +0,0 @@
# CCD_analysis/scripts/phase3_pod.py
"""Phase 3: Reference POD basis on phase-resampled periodic cases.
Builds POD basis from strict-qualifying cases (target_cylinder + illusion).
Non-qualifying cases (uncontrolled) are projected onto this basis.
Output::
- output/pod/reference_pod_results.npz
- output/pod/reference_pod_metrics.json
"""
from __future__ import annotations
import json
import os
import sys
import time
import numpy as np
_ANALYSIS = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if _ANALYSIS not in sys.path:
sys.path.insert(0, _ANALYSIS)
from scripts.cfg import OUTPUT_DIR, NX, NY
from scripts.analysis_utils import (
compute_pod, cumulative_energy, e95_index,
stack_velocity_fields, unstack_velocity_modes,
)
R_CANDIDATES = [6, 8, 10] # POD truncation levels to test
def load_resampled(case_name: str) -> dict:
"""Load resampled data for a case. Returns dict or None."""
resample_dir = os.path.join(OUTPUT_DIR, "resampled", case_name)
data_path = os.path.join(resample_dir, "resampled.npz")
meta_path = os.path.join(resample_dir, "meta.json")
if not os.path.exists(data_path):
print(f" {case_name}: no resampled data at {data_path}")
return None
data = np.load(data_path)
meta = {}
if os.path.exists(meta_path):
with open(meta_path) as f:
meta = json.load(f)
return {"data": data, "meta": meta, "name": case_name}
def main():
print("=== Phase 3: Common POD ===\n")
# Load summary from Phase 2
summary_path = os.path.join(OUTPUT_DIR, "resampled", "summary.json")
if not os.path.exists(summary_path):
print("ERROR: Run Phase 2 first.")
return 1
with open(summary_path) as f:
summary = json.load(f)
strict_cases = summary.get("strict", [])
relaxed_cases = summary.get("relaxed", [])
failed_cases = summary.get("failed", [])
print(f" Strict: {strict_cases}")
print(f" Relaxed (projected): {relaxed_cases}")
print(f" Failed: {failed_cases}")
if not strict_cases:
print("ERROR: No strict-qualifying cases for common POD.")
return 1
# Load all resampled data
all_data = {}
for case in strict_cases + relaxed_cases:
d = load_resampled(case)
if d is not None:
all_data[case] = d
# Build snapshot matrix from strict cases
print("\nBuilding common POD snapshot matrix...")
snapshots = []
case_ranges = {} # {case_name: (start_idx, end_idx)}
current_idx = 0
for case in strict_cases:
if case not in all_data:
continue
data = all_data[case]["data"]
ux = data.get("ux")
uy = data.get("uy")
if ux is None or uy is None:
print(f" WARNING: {case} has no field data, skipping")
continue
# Flatten each resampled snapshot
n_cycles, n_pts = ux.shape[0], ux.shape[1]
for c in range(n_cycles):
for p in range(n_pts):
q = np.concatenate([
ux[c, p].ravel(),
uy[c, p].ravel(),
])
snapshots.append(q)
case_ranges[case] = (current_idx, current_idx + n_cycles * n_pts)
current_idx += n_cycles * n_pts
print(f" {case}: {n_cycles}x{n_pts} = {n_cycles*n_pts} snapshots")
if not snapshots:
print("ERROR: No field data for POD.")
return 1
Q = np.column_stack(snapshots) # (2*nx*ny, N)
print(f" Snapshot matrix: {Q.shape[0]} x {Q.shape[1]}")
# Compute POD
print("\nComputing POD...")
mean_field, modes, s, coeffs = compute_pod(Q)
energy = cumulative_energy(s)
e95 = e95_index(energy)
print(f" Modes: {len(s)}")
print(f" E95 = {e95}")
for i in range(min(10, len(s))):
print(f" mode {i+1}: energy={energy[i]:.4f}, sigma={s[i]:.4e}")
# Project relaxed cases onto the POD basis
projection_coeffs = {} # {case_name: coeffs_matrix}
for case in relaxed_cases:
if case not in all_data:
continue
data = all_data[case]["data"]
ux = data.get("ux")
uy = data.get("uy")
if ux is None or uy is None:
print(f" WARNING: {case} has no field data for projection")
continue
proj_snapshots = []
n_cycles, n_pts = ux.shape[0], ux.shape[1]
for c in range(n_cycles):
for p in range(n_pts):
q = np.concatenate([ux[c, p].ravel(), uy[c, p].ravel()])
proj_snapshots.append(q)
Q_proj = np.column_stack(proj_snapshots)
# Remove mean field (from strict POD)
Q_proj_centered = Q_proj - mean_field[:, None]
# Project: coefficients = modes^T @ Q
coeffs_proj = modes[:, :R_CANDIDATES[-1]].T @ Q_proj_centered
projection_coeffs[case] = coeffs_proj
print(f" {case}: projected {Q_proj.shape[1]} snapshots")
# Compute case centroids in a1-a2 phase space
print("\nCase centroids (a1, a2):")
centroids = {}
for case in strict_cases:
if case not in case_ranges:
continue
start, end = case_ranges[case]
a1 = np.mean(coeffs[0, start:end])
a2 = np.mean(coeffs[1, start:end])
centroids[case] = [float(a1), float(a2)]
print(f" {case}: a1={a1:.4f}, a2={a2:.4f}")
for case, coeffs_p in projection_coeffs.items():
a1 = np.mean(coeffs_p[0])
a2 = np.mean(coeffs_p[1])
centroids[case] = [float(a1), float(a2)]
print(f" {case}: a1={a1:.4f}, a2={a2:.4f}")
# Save results
out_dir = os.path.join(OUTPUT_DIR, "pod")
os.makedirs(out_dir, exist_ok=True)
# POD results
np.savez_compressed(os.path.join(out_dir, "pod_results.npz"),
mean_field=mean_field,
modes=modes[:, :R_CANDIDATES[-1]],
singular_values=s,
coefficients=coeffs[:R_CANDIDATES[-1]],
energy_ratio=energy[:R_CANDIDATES[-1]],
)
# Save projection coefficients for relaxed cases
for case, c in projection_coeffs.items():
np.savez(os.path.join(out_dir, f"projection_{case}.npz"),
coefficients=c)
# Metrics
pod_metrics = {
"n_total_modes": len(s),
"E95": int(e95),
"energy_first_2": float(energy[1]) if len(energy) > 1 else float(energy[0]),
"energy_first_6": float(energy[min(5, len(energy)-1)]),
"singular_values": [float(v) for v in s[:R_CANDIDATES[-1]]],
"energy_ratio": [float(v) for v in energy[:R_CANDIDATES[-1]]],
"case_centroids": centroids,
"case_ranges": {k: [int(v[0]), int(v[1])] for k, v in case_ranges.items()},
"n_strict_cases": len(strict_cases),
"strict_cases": strict_cases,
"relaxed_cases": relaxed_cases,
}
with open(os.path.join(out_dir, "pod_metrics.json"), "w") as f:
json.dump(pod_metrics, f, indent=2)
print(f"\nResults saved to {out_dir}")
print("Phase 3 complete.")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -1,332 +0,0 @@
# CCD_analysis/scripts/phase4_ccd.py
"""Phase 4: CCD analysis on POD coefficients.
Computes:
- force-CCD (all periodic cases): total Fx, Fy
- action-CCD (illusion only): [Omega_front, Omega_bottom, Omega_top]
- signature-CCD (illusion only): e_s(t+tau_c)
- Modal overlap O_k between case pairs
"""
from __future__ import annotations
import json
import os
import sys
import numpy as np
_ANALYSIS = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if _ANALYSIS not in sys.path:
sys.path.insert(0, _ANALYSIS)
from scripts.cfg import OUTPUT_DIR
from scripts.analysis_utils import compute_reduced_ccd, cumulative_energy
R_CANDIDATES = [6, 8, 10]
CCD_Q = 12
def load_resampled_coeffs(case_name: str, r: int) -> np.ndarray:
"""Load POD coefficients from projection or strict POD."""
proj_path = os.path.join(OUTPUT_DIR, "pod", f"projection_{case_name}.npz")
if os.path.exists(proj_path):
data = np.load(proj_path)
return data["coefficients"][:r]
pod_path = os.path.join(OUTPUT_DIR, "pod", "pod_results.npz")
metrics_path = os.path.join(OUTPUT_DIR, "pod", "pod_metrics.json")
if os.path.exists(pod_path) and os.path.exists(metrics_path):
pod = np.load(pod_path)
with open(metrics_path) as f:
metrics = json.load(f)
cr = metrics.get("case_ranges", {})
if case_name in cr:
start, end = cr[case_name]
return pod["coefficients"][:r, start:end]
return None
def load_resampled_signals(case_name: str, key: str = "sensors", n_channels: int = 6) -> np.ndarray:
"""Load resampled signal and return (n_channels, N)."""
path = os.path.join(OUTPUT_DIR, "resampled", case_name, "resampled.npz")
if not os.path.exists(path):
return None
data = np.load(path)
arr = data.get(key)
if arr is None:
return None
if key == "forces":
arr_2d = arr.reshape(-1, arr.shape[-1])
if arr_2d.shape[1] == 2:
return arr_2d.T
f = arr_2d
Fx = (f[:, 0] + f[:, 2] + f[:, 4])
Fy = (f[:, 1] + f[:, 3] + f[:, 5])
return np.vstack([Fx, Fy])
if key == "actions":
return arr.reshape(-1, arr.shape[-1]).T
return arr.reshape(-1, arr.shape[-1]).T
def compute_ccd_metrics(case: str, coeffs: np.ndarray, observable: np.ndarray, obs_name: str) -> dict:
"""Compute CCD metrics for a single case-observable pair."""
N = coeffs.shape[1]
N_train = N * 3 // 4
a_train = coeffs[:, :N_train]
a_test = coeffs[:, N_train:]
y_train = observable[:, :N_train]
y_test = observable[:, N_train:]
r = coeffs.shape[0]
# CCD
W, sigma, z = compute_reduced_ccd(coeffs, observable, Q_delay=CCD_Q)
ccd_ene = cumulative_energy(sigma)
m80 = int(np.searchsorted(ccd_ene, 0.80) + 1) if len(ccd_ene) > 0 else 0
# POD regression
W_pod = y_train @ a_train.T @ np.linalg.pinv(a_train @ a_train.T + 1e-8 * np.eye(r))
y_pred_pod = W_pod @ a_test
y_test_r = y_test.ravel()
y_pred_pod_r = y_pred_pod.ravel()
if np.std(y_test_r) > 1e-12 and np.std(y_pred_pod_r) > 1e-12:
corr_pod = float(np.corrcoef(y_pred_pod_r, y_test_r)[0, 1])
else:
corr_pod = 0.0
# CCD regression
n_ccd = min(r, z.shape[0])
z_train = z[:n_ccd, :N_train]
z_test = z[:n_ccd, N_train:]
W_reg_ccd = y_train @ z_train.T @ np.linalg.pinv(z_train @ z_train.T + 1e-8 * np.eye(n_ccd))
y_pred_ccd = W_reg_ccd @ z_test
y_pred_ccd_r = y_pred_ccd.ravel()
if np.std(y_test_r) > 1e-12 and np.std(y_pred_ccd_r) > 1e-12:
corr_ccd = float(np.corrcoef(y_pred_ccd_r, y_test_r)[0, 1])
else:
corr_ccd = 0.0
# Top CCD mode correlations with observable
n_corr = min(5, len(sigma))
corr_z = []
for k in range(n_corr):
zk_train = z[k, :N_train]
if np.std(zk_train) > 1e-12:
rho = float(np.corrcoef(zk_train, observable[0, :N_train])[0, 1])
else:
rho = 0.0
corr_z.append(rho)
return {
"case": case,
"observable": obs_name,
"r": r,
"sigma": [float(s) for s in sigma[:n_corr]],
"ccd_energy": [float(e) for e in ccd_ene[:n_corr]],
"m80": int(m80),
"corr_POD_obs": corr_pod,
"corr_CCD_obs": corr_ccd,
"corr_z_top5": corr_z,
"N_total": int(N),
"N_train": int(N_train),
"N_test": int(N - N_train),
}
def compute_modal_overlap(W_dict: dict, case_pairs: list) -> dict:
"""Compute modal overlap O_k between cases.
O_k(A, B) = |W[:,k]_A^T @ W[:,k]_B|
Higher means the k-th CCD direction is more aligned between cases.
"""
overlap = {}
for case_a, case_b in case_pairs:
if case_a not in W_dict or case_b not in W_dict:
continue
Wa = W_dict[case_a]
Wb = W_dict[case_b]
n = min(Wa.shape[1], Wb.shape[1])
pair_key = f"O_{case_a}_{case_b}"
pair_vals = []
for k in range(min(n, 5)):
ak = Wa[:, k] / (np.linalg.norm(Wa[:, k]) + 1e-12)
bk = Wb[:, k] / (np.linalg.norm(Wb[:, k]) + 1e-12)
pair_vals.append(float(abs(ak @ bk)))
overlap[pair_key] = pair_vals
return overlap
def load_target_signals(case_name: str) -> np.ndarray:
"""Load target_cylinder resampled sensors as reference signature.
Since target_cylinder is at U0=0.01 and illusion at U0=0.02,
signals are amplitude-normalized by their respective RMS.
"""
# Use target_cylinder resampled sensors as reference
ref_path = os.path.join(OUTPUT_DIR, "resampled", "target_cylinder", "resampled.npz")
if not os.path.exists(ref_path):
return None
ref = np.load(ref_path)
sensors_ref = ref.get("sensors") # (n_cycles, n_pts, 6)
if sensors_ref is None:
return None
return sensors_ref.reshape(-1, sensors_ref.shape[-1]).T # (6, N)
def main():
print("=== Phase 4: CCD Analysis ===\n")
metrics_path = os.path.join(OUTPUT_DIR, "pod", "pod_metrics.json")
if not os.path.exists(metrics_path):
print("ERROR: Run Phase 3 first.")
return 1
with open(metrics_path) as f:
pod_metrics = json.load(f)
all_cases = pod_metrics.get("strict_cases", []) + pod_metrics.get("relaxed_cases", [])
all_results = {}
W_dict = {} # {case_observable_r: W_matrix}
for r in R_CANDIDATES:
print(f"\n{'='*60}")
print(f"POD truncation r={r}")
print(f"{'='*60}")
# ----- 1. Force-CCD (all cases) -----
print("\n--- Force-CCD ---")
for case in all_cases:
coeffs = load_resampled_coeffs(case, r)
if coeffs is None:
continue
y_force = load_resampled_signals(case, "forces", 2)
if y_force is None:
print(f" {case}: no force data")
continue
if y_force.shape[-1] != coeffs.shape[1]:
print(f" {case}: force length mismatch, skipping")
continue
# CCD computation
W, sigma, z = compute_reduced_ccd(coeffs, y_force, Q_delay=CCD_Q)
ccd_ene = cumulative_energy(sigma)
m80 = int(np.searchsorted(ccd_ene, 0.80) + 1)
key = f"{case}_force_r{r}"
W_dict[key] = W
all_results[key] = compute_ccd_metrics(case, coeffs, y_force, "force_CCD")
print(f" {case}: m80={all_results[key]['m80']}, "
f"sigma={sigma[0]:.3f},{sigma[1]:.3f}")
# ----- 2. Action-CCD (illusion only) -----
print("\n--- Action-CCD (illusion only) ---")
if "illusion" in all_cases:
coeffs = load_resampled_coeffs("illusion", r)
if coeffs is not None:
y_act = load_resampled_signals("illusion", "actions", 3)
if y_act is not None and y_act.shape[-1] == coeffs.shape[1]:
key = f"illusion_action_r{r}"
W, sigma, z = compute_reduced_ccd(coeffs, y_act, Q_delay=CCD_Q)
W_dict[key] = W
all_results[key] = compute_ccd_metrics(
"illusion", coeffs, y_act, "action_CCD")
print(f" illusion: m80={all_results[key]['m80']}, "
f"sigma={sigma[0]:.3f},{sigma[1]:.3f}")
else:
print(f" illusion: no action data (y={y_act.shape if y_act is not None else None}, "
f"N={coeffs.shape[1]})")
# ----- 3. Signature-CCD (illusion only, tau_c=0) -----
print("\n--- Signature-CCD (illusion only, tau_c=0) ---")
if "illusion" in all_cases:
coeffs = load_resampled_coeffs("illusion", r)
if coeffs is not None:
# Load actual sensors
sensors = load_resampled_signals("illusion", "sensors", 6)
# Load target sensors
target_sensors = load_target_signals("illusion")
if sensors is not None and target_sensors is not None and sensors.shape == target_sensors.shape:
# e_s(t) = s(t) - s_tar(t)
e_s = sensors - target_sensors
key = f"illusion_signature_r{r}"
W, sigma, z = compute_reduced_ccd(coeffs, e_s, Q_delay=CCD_Q)
W_dict[key] = W
all_results[key] = compute_ccd_metrics(
"illusion", coeffs, e_s, "signature_CCD")
print(f" illusion: m80={all_results[key]['m80']}, "
f"sigma={sigma[0]:.3f},{sigma[1]:.3f}")
else:
print(f" illusion: signature data issue "
f"(sensors={sensors.shape if sensors is not None else None}, "
f"target={target_sensors.shape if target_sensors is not None else None})")
# ----- Modal Overlap O_k -----
print("\n--- Modal Overlap O_k ---")
all_obs_keys = [k for k in all_results.keys()]
pair_results = {}
for r in R_CANDIDATES:
# Force-CCD overlap between strict cases
strict_cases = pod_metrics.get("strict_cases", [])
for i, ca in enumerate(strict_cases):
for cb in strict_cases[i+1:]:
key_a = f"{ca}_force_r{r}"
key_b = f"{cb}_force_r{r}"
if key_a in W_dict and key_b in W_dict:
Wa = W_dict[key_a]
Wb = W_dict[key_b]
n = min(Wa.shape[1], Wb.shape[1], 5)
ov = []
for k in range(n):
ak = Wa[:, k] / (np.linalg.norm(Wa[:, k]) + 1e-12)
bk = Wb[:, k] / (np.linalg.norm(Wb[:, k]) + 1e-12)
ov.append(float(abs(ak @ bk)))
pk = f"O_{ca}_{cb}_force_r{r}"
pair_results[pk] = ov
print(f" {ca} vs {cb} (force, r={r}): "
f"O1={ov[0]:.4f}, O2={ov[1]:.4f}")
# Also compare illusion to target in action and signature space
if "illusion" in all_cases:
ia_key = f"illusion_action_r{r}"
is_key = f"illusion_signature_r{r}"
for tc in strict_cases:
tf_key = f"{tc}_force_r{r}"
if ia_key in W_dict and tf_key in W_dict:
Wa = W_dict[ia_key]
Wb = W_dict[tf_key]
n = min(Wa.shape[1], Wb.shape[1], 5)
ov = []
for k in range(n):
ak = Wa[:, k] / (np.linalg.norm(Wa[:, k]) + 1e-12)
bk = Wb[:, k] / (np.linalg.norm(Wb[:, k]) + 1e-12)
ov.append(float(abs(ak @ bk)))
pk = f"O_action_vs_force_{tc}_r{r}"
pair_results[pk] = ov
print(f" action vs {tc}-force (r={r}): "
f"O1={ov[0]:.4f}, O2={ov[1]:.4f}")
# Save
out_dir = os.path.join(OUTPUT_DIR, "ccd")
os.makedirs(out_dir, exist_ok=True)
all_results["modal_overlaps"] = pair_results
with open(os.path.join(out_dir, "ccd_metrics.json"), "w") as f:
json.dump(all_results, f, indent=2)
# Summary
print(f"\n{'='*70}")
print("Summary: CCD metrics")
print(f"{'='*70}")
for key in sorted([k for k in all_results.keys() if k != "modal_overlaps"]):
m = all_results[key]
sig = m.get("sigma", [0, 0])
print(f" {key:<40} m80={m['m80']} "
f"sigma=[{sig[0]:.3f},{sig[1] if len(sig)>1 else 0:.3f}] "
f"corr_CCD={m['corr_CCD_obs']:.4f}")
print(f"\nSaved to {out_dir}/ccd_metrics.json")
print("Phase 4 complete.")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -1,232 +0,0 @@
# CCD_analysis/scripts/phase5_steady.py
"""
WARNING: This script has known issues.
- E_mean_uy calculation explodes because empty_channel uy ~ 0 (denominator near zero)
- eta_fluc is negative because cloak and uncontrolled use different pinball positions
(cloak uses standard layout FRONT/BOTTOM/TOP, uncontrolled may differ)
- L_r (recirculation zone length) = 0, which may indicate incorrect u=0 detection logic
Need to verify against actual flow fields.
Use this as reference only. Do NOT use resulting metrics for conclusions without validation.
"""
from __future__ import annotations
import json
import os
import sys
import numpy as np
_ANALYSIS = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if _ANALYSIS not in sys.path:
sys.path.insert(0, _ANALYSIS)
from scripts.cfg import OUTPUT_DIR, NX, NY
def load_fields(case_name: str):
"""Load fields.npz for a case, return (ux, uy) as (N, NY, NX)."""
path = os.path.join(OUTPUT_DIR, case_name, "fields.npz")
if not os.path.exists(path):
return None
data = np.load(path)
return data["ux"].astype(np.float64), data["uy"].astype(np.float64)
def load_sensors(case_name: str):
"""Load sensors.npz for a case."""
path = os.path.join(OUTPUT_DIR, case_name, "sensors.npz")
if not os.path.exists(path):
return None
return np.load(path)
def compute_mean_rms(ux, uy):
"""Compute mean and RMS fields from time series."""
ux_mean = np.mean(ux, axis=0)
uy_mean = np.mean(uy, axis=0)
ux_rms = np.sqrt(np.mean((ux - ux_mean) ** 2, axis=0))
uy_rms = np.sqrt(np.mean((uy - uy_mean) ** 2, axis=0))
return ux_mean, uy_mean, ux_rms, uy_rms
def recirculation_metrics(ux_mean):
"""Extract recirculation zone from mean ux field.
Returns (L_r, A_r) where:
L_r = length of recirculation zone along centerline
A_r = area of recirculation zone (u < 0)
"""
ny, nx = ux_mean.shape
center_y = ny // 2
margin = 10
# Centerline ux
cline = ux_mean[center_y, :]
# Find u=0 crossings behind the pinball (x > 400 or so)
start_x = 400
end_x = nx - 50
roi = cline[start_x:end_x]
neg = np.where(roi < 0)[0]
if len(neg) > 0:
# Recirculation length = distance from end of negative region
neg_end = neg[-1] + start_x
L_r = float(neg_end - 400)
else:
L_r = 0.0
# Area: count cells with u < 0 in the wake region
wake_region = ux_mean[:, 300:800]
area_mask = wake_region < 0
A_r = float(np.sum(area_mask) * 1.0) # in lattice cells
return L_r, A_r
def main():
print("=== Phase 5: Cloak Steady-Line Analysis ===\n")
# Load data
cloak_fields = load_fields("cloak")
if cloak_fields is None:
print("ERROR: No cloak fields found. Run Phase 1b first.")
return 1
ux_c, uy_c = cloak_fields
channel_fields = load_fields("empty_channel")
if channel_fields is None:
print("ERROR: No empty_channel fields found. Run Phase 1d first.")
return 1
ux_ch, uy_ch = channel_fields
unc_fields = load_fields("uncontrolled")
if unc_fields is None:
print("WARNING: No uncontrolled fields for comparison.")
unc_fields = None
cloak_sens = load_sensors("cloak")
if cloak_sens is None:
print("ERROR: No cloak sensors found.")
return 1
# Compute mean and RMS
ux_c_mean, uy_c_mean, ux_c_rms, uy_c_rms = compute_mean_rms(ux_c, uy_c)
ux_ch_mean, uy_ch_mean, _, _ = compute_mean_rms(ux_ch, uy_ch)
# 1. E_mean: mean flow relative error (vs empty channel), normalized by U0
ux_err = np.mean((ux_c_mean - ux_ch_mean) ** 2)
ux_ref = np.mean(ux_ch_mean ** 2) + 1e-12
E_mean_ux = float(ux_err / ux_ref)
E_mean_uy = float(np.mean((uy_c_mean - uy_ch_mean) ** 2)) / (np.mean(uy_ch_mean ** 2) + 1e-12)
E_mean = (E_mean_ux + E_mean_uy) / 2.0
print(f"1. E_mean (rel. to empty channel):")
print(f" ux error={E_mean_ux:.4f}, uy error={E_mean_uy:.4f}, avg={E_mean:.4f}")
# 2. E_sensor_mean
sensors = cloak_sens["sensors"]
forces = cloak_sens["forces"]
sensor_mean = np.mean(sensors, axis=0)
# Target empty channel: U0=0.01 parabolic, sensor ux should be near U0, uy near 0
sensor_target_mean = np.array([1.0, 0.0, 1.0, 0.0, 1.0, 0.0], dtype=np.float32)
E_sensor_mean = float(np.mean(np.abs(sensor_mean - sensor_target_mean)))
print(f"2. E_sensor_mean = {E_sensor_mean:.4f} "
f"(sensor_mean={sensor_mean[:3].tolist()})")
# 3. eta_fluc: fluctuation suppression ratio
if unc_fields is not None:
ux_unc, uy_unc = unc_fields
# Use first 30 frames of uncontrolled for fair comparison
n_compare = min(len(ux_c), len(ux_unc), 30)
ux_c_sub, uy_c_sub = ux_c[:n_compare], uy_c[:n_compare]
ux_unc_sub, uy_unc_sub = ux_unc[:n_compare], uy_unc[:n_compare]
# RMS in wake region only (300-800, full height)
wake_x_start, wake_x_end = 300, 800
ux_c_mean_s, uy_c_mean_s = np.mean(ux_c_sub, axis=0), np.mean(uy_c_sub, axis=0)
ux_unc_mean_s, uy_unc_mean_s = np.mean(ux_unc_sub, axis=0), np.mean(uy_unc_sub, axis=0)
rms_c = np.sqrt(np.mean((ux_c_sub - ux_c_mean_s) ** 2 + (uy_c_sub - uy_c_mean_s) ** 2, axis=0))
rms_unc = np.sqrt(np.mean((ux_unc_sub - ux_unc_mean_s) ** 2 + (uy_unc_sub - uy_unc_mean_s) ** 2, axis=0))
# Integrate RMS over wake region
int_rms_c = float(np.sum(rms_c[:, wake_x_start:wake_x_end]))
int_rms_unc = float(np.sum(rms_unc[:, wake_x_start:wake_x_end]))
eps = 1e-12
eta_fluc = 1.0 - int_rms_c / (int_rms_unc + eps)
print(f"3. eta_fluc = {eta_fluc:.4f} (wake region, first {n_compare} frames)")
else:
eta_fluc = 0.0
print("3. eta_fluc: skipped (no uncontrolled data)")
# 4. Recirculation zone
L_r_c, A_r_c = recirculation_metrics(ux_c_mean)
if unc_fields is not None:
ux_unc_mean = np.mean(ux_unc_sub, axis=0)
L_r_unc, A_r_unc = recirculation_metrics(ux_unc_mean)
print(f"4. Recirculation: cloak L_r={L_r_c:.0f}, A_r={A_r_c:.0f} "
f"vs uncontrolled L_r={L_r_unc:.0f}, A_r={A_r_unc:.0f}")
else:
print(f"4. Recirculation: cloak L_r={L_r_c:.0f}, A_r={A_r_c:.0f}")
# 5. Force statistics
cloak_forces = forces
if len(cloak_forces) > 0:
fx = cloak_forces[:, 0::2]
fy = cloak_forces[:, 1::2]
sigma_F = float(np.std(np.sum(fx, axis=1) + np.sum(fy, axis=1)))
mean_Fx = float(np.mean(fx))
print(f"5. Force stats: sigma_F={sigma_F:.6f}, mean_Fx={mean_Fx:.6f}")
# 6. Control amplitude
ppo_rollout_path = os.path.join(OUTPUT_DIR, "cloak", "ppo_rollout.npz")
if os.path.exists(ppo_rollout_path):
pr = np.load(ppo_rollout_path)
steady_action = pr.get("steady_action")
if steady_action is not None:
J_omega_rms = float(np.sum(np.abs(steady_action)))
print(f"6. J_omega_rms = {J_omega_rms:.4f} (norm action sum abs)")
# 7. eta_cloak_obs (control efficiency proxy)
if unc_fields is not None:
unc_sens = load_sensors("uncontrolled")
if unc_sens is not None:
unc_sensor_mean = np.mean(unc_sens["sensors"], axis=0)
E_unc = float(np.mean(np.abs(unc_sensor_mean - sensor_target_mean)))
E_cloak = E_sensor_mean
J_omega = J_omega_rms if 'J_omega_rms' in dir() else 1.0
eta_cloak_obs = (E_unc - E_cloak) / (J_omega + 1e-12)
print(f"7. eta_cloak_obs = {eta_cloak_obs:.4f} "
f"(E_unc={E_unc:.4f}, E_cloak={E_cloak:.4f})")
# Compile and save
steady_metrics = {
"E_mean_ux": E_mean_ux,
"E_mean_uy": E_mean_uy,
"E_mean_avg": E_mean,
"E_sensor_mean": E_sensor_mean,
"sensor_mean": sensor_mean.tolist(),
"eta_fluc": eta_fluc,
"L_r_cloak": L_r_c,
"A_r_cloak": A_r_c,
"sigma_F": sigma_F if 'sigma_F' in dir() else 0,
"mean_Fx": mean_Fx if 'mean_Fx' in dir() else 0,
"J_omega_rms": J_omega_rms if 'J_omega_rms' in dir() else 0,
"eta_cloak_obs": eta_cloak_obs if 'eta_cloak_obs' in dir() else 0,
"L_r_uncontrolled": L_r_unc if unc_fields is not None and 'L_r_unc' in dir() else None,
"A_r_uncontrolled": A_r_unc if unc_fields is not None and 'A_r_unc' in dir() else None,
}
out_dir = os.path.join(OUTPUT_DIR, "steady")
os.makedirs(out_dir, exist_ok=True)
with open(os.path.join(out_dir, "steady_metrics.json"), "w") as f:
json.dump(steady_metrics, f, indent=2)
print(f"\nSaved to {out_dir}/steady_metrics.json")
print("Phase 5 complete.")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,162 @@
"""Phase resampling for periodic cases (pinball, karman_re100, illusion_1L).
Usage:
python scripts/resample.py
Output: data/resampled/{scene_name}/resampled.npz
"""
from __future__ import annotations
import json
import os
import sys
import numpy as np
_ANALYSIS = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if _ANALYSIS not in sys.path:
sys.path.insert(0, _ANALYSIS)
from CCD_analysis.configs import SCENES, DATA_DIR
from CCD_analysis.utils.resampling import (
detect_dominant_frequency, detect_cycle_stability, phase_resample,
)
N_CYCLES = 4
N_PTS = 24
CV_T_STRICT = 0.10
CV_T_RELAXED = 0.12
DELTA_F_STRICT = 0.10
DELTA_F_RELAXED = 0.20
def run():
periodic = [name for name, cfg in SCENES.items()
if cfg["target_type"] == "periodic"]
for name in periodic:
cfg = SCENES[name]
data_dir = os.path.join(DATA_DIR, cfg["scene_id"], name)
meta_path = os.path.join(data_dir, "meta.json")
sens_path = os.path.join(data_dir, "sensors.npz")
fields_path = os.path.join(data_dir, "fields.npz")
controlled_path = os.path.join(data_dir, "controlled.npz")
print(f"\n=== {name} ===")
# Load sensor data
if os.path.isfile(controlled_path):
d = np.load(controlled_path)
elif os.path.isfile(sens_path):
d = np.load(sens_path)
else:
print(f" SKIP: no sensor data")
continue
sensors = d.get("sensors")
if sensors is None or len(sensors) < 30:
print(f" SKIP: insufficient sensor data ({len(sensors) if sensors is not None else 0})")
continue
si = cfg["sample_interval"]
signal = sensors[:, 3] # centre sensor v
# Frequency and stability
f_case, T_case, _ = detect_dominant_frequency(signal, float(si))
cv_T, mean_T, cy_lengths = detect_cycle_stability(signal, float(si))
print(f" f={f_case:.6f}, T={T_case:.0f}, CV_T={cv_T:.4f}")
# Gate check
N_raw = mean_T / si if mean_T > 0 else 0
rho = 24.0 / N_raw if N_raw > 0 else 99
delta_f = abs(f_case - f_case) / (f_case + 1e-12)
if cv_T <= CV_T_STRICT and delta_f <= DELTA_F_STRICT:
gate = "strict"
elif cv_T <= CV_T_RELAXED and delta_f <= DELTA_F_RELAXED:
gate = "relaxed"
else:
gate = "auxiliary"
print(f" gate={gate}, N_raw/cycle={N_raw:.1f}, rho_interp={rho:.2f}")
if gate not in ("strict", "relaxed"):
print(f" SKIP: does not pass period gate")
continue
# Find cycles
y = signal - np.mean(signal)
crossings = np.where((np.sign(y[:-1]) < 0) & (np.sign(y[1:]) > 0))[0]
if len(crossings) < N_CYCLES + 1:
print(f" SKIP: only {len(crossings)} cycles")
continue
# Select best N_CYCLES
cycle_lens = np.diff(crossings)
T_exp = T_case / si if T_case > 0 else N_raw
best_score, best_start = float("inf"), 0
for i in range(len(cycle_lens) - N_CYCLES + 1):
score = np.sum((cycle_lens[i:i+N_CYCLES] - T_exp) ** 2)
if score < best_score:
best_score, best_start = score, i
selected = list(crossings[best_start:best_start + N_CYCLES + 1])
# Resample
rs_sensors = phase_resample(sensors, selected, n_pts=N_PTS)
out = {"sensors": rs_sensors, "n_cycles": N_CYCLES, "n_pts": N_PTS,
"gate": gate, "selected_crossings": selected}
# Forces
forces = d.get("forces")
if forces is not None and forces.ndim == 2:
out["forces"] = phase_resample(forces, selected, n_pts=N_PTS)
# Actions
actions = d.get("actions")
if actions is not None and actions.ndim == 2:
out["actions"] = phase_resample(actions, selected, n_pts=N_PTS)
# Fields (from controlled.npz or fields.npz)
if os.path.isfile(controlled_path) and "ux" not in d:
ol_path = os.path.join(data_dir, "open_loop_fields.npz")
if os.path.isfile(ol_path):
fd = np.load(ol_path)
elif os.path.isfile(fields_path):
fd = np.load(fields_path)
else:
fd = None
if fd is not None and "ux" in fd:
ux, uy = fd["ux"], fd["uy"]
if len(ux) >= selected[-1]:
nx, ny = ux.shape[2], ux.shape[1]
field_flat = np.column_stack([ux.reshape(len(ux), -1),
uy.reshape(len(uy), -1)])
rs_fields = phase_resample(field_flat, selected, n_pts=N_PTS)
half = rs_fields.shape[-1] // 2
out["ux"] = rs_fields[:, :, :half].reshape(N_CYCLES, N_PTS, ny, nx)
out["uy"] = rs_fields[:, :, half:].reshape(N_CYCLES, N_PTS, ny, nx)
# Save
resample_dir = os.path.join(DATA_DIR, "resampled", name)
os.makedirs(resample_dir, exist_ok=True)
save_dict = {"sensors": out["sensors"], "n_cycles": N_CYCLES, "n_pts": N_PTS}
for k in ["forces", "actions", "ux", "uy"]:
if k in out:
save_dict[k] = out[k]
np.savez_compressed(os.path.join(resample_dir, "resampled.npz"), **save_dict)
meta = {"case": name, "gate": gate, "f_case": f_case, "CV_T": cv_T,
"N_raw_per_cycle": N_raw, "rho_interp": rho,
"n_cycles": N_CYCLES, "n_pts": N_PTS,
"has_fields": "ux" in out}
with open(os.path.join(resample_dir, "meta.json"), "w") as f:
json.dump(meta, f, indent=2)
print(f" Saved: {resample_dir} ({'fields' if meta['has_fields'] else 'no fields'})")
print("\nDone.")
if __name__ == "__main__":
run()

View File

@ -1,308 +0,0 @@
# CCD_analysis/scripts/utils.py
"""Shared utilities for CCD analysis pipeline.
All CFD uses LegacyCelerisLab (old API). Must run under conda pycuda_3_10.
"""
from __future__ import annotations
import json
import os
import sys
from typing import Any, Dict, List, Optional, Tuple
import numpy as np
# Add project root for LegacyCelerisLab import
_REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
if _REPO not in sys.path:
sys.path.insert(0, _REPO)
from LegacyCelerisLab import FlowField # noqa: E402
from LegacyCelerisLab import utils as legacy_utils # noqa: E402
# ---------------------------------------------------------------------------
# Config loading
# ---------------------------------------------------------------------------
def load_configs(config_dir: str) -> Tuple[Any, Any]:
"""Load legacy (cuda_config, field_config) from config_dir."""
cuda_cfg = legacy_utils.load_cuda_config(
os.path.join(config_dir, "config_cuda.json")
)
field_cfg = legacy_utils.load_flow_field_config(
os.path.join(config_dir, "config_flowfield.json")
)
return cuda_cfg, field_cfg
# ---------------------------------------------------------------------------
# Field I/O from DDF (matches uni_test pattern)
# ---------------------------------------------------------------------------
def get_velocity_field(flow_field: FlowField, u0: float = 0.01) -> Tuple[np.ndarray, np.ndarray]:
"""Extract ux, uy fields from DDF on host. Returns (ux, uy) each (NY, NX)."""
flow_field.get_ddf()
NX = flow_field.FIELD_SHAPE[0]
NY = flow_field.FIELD_SHAPE[1]
ddf = flow_field.ddf.copy().reshape((9, NY, NX)).transpose(2, 1, 0)
ux = (ddf[:, :, 1] + ddf[:, :, 5] + ddf[:, :, 8]
- ddf[:, :, 3] - ddf[:, :, 6] - ddf[:, :, 7]) / u0
uy = (ddf[:, :, 2] + ddf[:, :, 5] + ddf[:, :, 6]
- ddf[:, :, 4] - ddf[:, :, 7] - ddf[:, :, 8]) / u0
return ux.astype(np.float32), uy.astype(np.float32)
def vorticity_from_fields(ux: np.ndarray, uy: np.ndarray) -> np.ndarray:
"""Compute z-vorticity omega = dv/dx - du/dy."""
return (np.gradient(uy, axis=1) - np.gradient(ux, axis=0)).astype(np.float64)
# ---------------------------------------------------------------------------
# Period detection helpers
# ---------------------------------------------------------------------------
def detect_dominant_frequency(
signal: np.ndarray, sample_dt: float
) -> Tuple[float, float, float]:
"""Detect dominant frequency via FFT.
Parameters
----------
signal : 1D array
Time series to analyse.
sample_dt : float
Time between samples (in same units as desired frequency).
Returns
-------
f_dom : float
Dominant frequency.
period : float
Corresponding period (1/f_dom).
peak_power : float
Power at dominant frequency.
"""
n = len(signal)
if n < 16:
return 0.0, 0.0, 0.0
y = signal - np.mean(signal)
window = np.hanning(n)
spec = np.abs(np.fft.rfft(y * window)) ** 2
freqs = np.fft.rfftfreq(n, d=sample_dt)
# Skip DC
idx = 1 + np.argmax(spec[1:])
f_dom = float(freqs[idx])
period = 1.0 / f_dom if f_dom > 0 else 0.0
return f_dom, period, float(spec[idx])
def detect_cycle_stability(
signal: np.ndarray, sample_dt: float
) -> Tuple[float, float, List[float]]:
"""Detect cycle lengths and compute stability metrics.
Parameters
----------
signal : 1D array
sample_dt : float
Returns
-------
cv_T : float
Coefficient of variation of detected cycle lengths.
mean_T : float
Mean cycle length in time units.
cycle_lengths : list of float
Detected cycle lengths.
"""
y = signal - np.mean(signal)
# Find rising zero-crossings
sign = np.sign(y)
crossings = np.where((sign[:-1] < 0) & (sign[1:] > 0))[0]
if len(crossings) < 2:
return 0.0, 0.0, []
cycle_lengths = np.diff(crossings).astype(float) * sample_dt
if len(cycle_lengths) < 2:
return 0.0, float(cycle_lengths[0]) if len(cycle_lengths) > 0 else 0.0, cycle_lengths.tolist()
mean_T = float(np.mean(cycle_lengths))
std_T = float(np.std(cycle_lengths))
cv_T = std_T / mean_T if mean_T > 0 else 0.0
return cv_T, mean_T, cycle_lengths.tolist()
# ---------------------------------------------------------------------------
# Phase resampling
# ---------------------------------------------------------------------------
def phase_resample(
data: np.ndarray,
cycle_starts: List[int],
n_pts: int = 24,
kind: str = "linear",
) -> np.ndarray:
"""Resample a multi-channel signal to uniform phase points per cycle.
Parameters
----------
data : (T, C) ndarray
Multi-channel time series.
cycle_starts : list of int
Indices where each cycle starts (rising zero-crossings).
n_pts : int
Number of phase points per cycle.
kind : str
Interpolation kind ('linear' or 'cubic').
Returns
-------
resampled : (n_cycles, n_pts, C) ndarray
"""
from scipy import interpolate
n_cycles = len(cycle_starts) - 1
if n_cycles < 1:
raise ValueError("Need at least 2 cycle starts")
C = data.shape[1] if data.ndim > 1 else 1
out = np.zeros((n_cycles, n_pts, C), dtype=np.float64)
for c in range(n_cycles):
i_start = cycle_starts[c]
i_end = cycle_starts[c + 1]
segment = data[i_start:i_end + 1]
seg_len = len(segment)
if seg_len < 2:
continue
old_phase = np.linspace(0, 2 * np.pi, seg_len)
new_phase = np.linspace(0, 2 * np.pi, n_pts, endpoint=False)
for ch in range(C):
interp = interpolate.interp1d(
old_phase, segment[:, ch] if segment.ndim > 1 else segment,
kind=kind, bounds_error=False, fill_value="extrapolate",
)
out[c, :, ch] = interp(new_phase)
return out
# ---------------------------------------------------------------------------
# POD
# ---------------------------------------------------------------------------
def compute_pod(snapshot_matrix: np.ndarray) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
"""Compute POD from snapshot matrix.
Parameters
----------
snapshot_matrix : (n_points, n_snapshots) ndarray
Each column is one flattened snapshot.
Returns
-------
mean_field : (n_points,) ndarray
modes : (n_points, min(n_points, n_snapshots)) ndarray
Spatial modes (columns of U from SVD).
singular_values : (min_dim,) ndarray
coefficients : (min_dim, n_snapshots) ndarray
Temporal coefficients (Sigma V^T).
"""
mean_field = np.mean(snapshot_matrix, axis=1)
Q = snapshot_matrix - mean_field[:, None] # remove mean
U, s, Vt = np.linalg.svd(Q, full_matrices=False)
coeffs = (U.T @ Q) # alternative: np.diag(s) @ Vt
return mean_field, U, s, coeffs
def cumulative_energy(singular_values: np.ndarray) -> np.ndarray:
"""Return cumulative energy fraction."""
e = singular_values ** 2
return np.cumsum(e) / np.sum(e)
def e95_index(cumulative_energy: np.ndarray) -> int:
"""Return first index where cumulative energy >= 95%."""
return int(np.searchsorted(cumulative_energy, 0.95) + 1)
# ---------------------------------------------------------------------------
# CCD (reduced version, Lyu23-inspired)
# ---------------------------------------------------------------------------
def compute_reduced_ccd(
pod_coeffs: np.ndarray,
observable: np.ndarray,
Q_delay: int = 12,
) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
"""Compute reduced CCD in POD coefficient space.
Parameters
----------
pod_coeffs : (r, N) ndarray
Standardized POD coefficients (r modes, N time steps).
observable : (m, N) ndarray
Standardized observable (m channels, N time steps).
Q_delay : int
Number of delay steps.
Returns
-------
W : (r, min(r, m*Q_delay)) ndarray
CCD directions in POD coefficient space.
sigma : (min_dim,) ndarray
Singular values (correlation strengths).
z : (min_dim, N) ndarray
CCD temporal coefficients.
"""
N = pod_coeffs.shape[1]
m = observable.shape[0]
# Build delay matrix P
# For each time t_i, p_i = [y(t_i+τ_1), ..., y(t_i+τ_Q)]
# where τ_j spans -Q_delay/2 to +Q_delay/2
half = Q_delay // 2
P_rows = []
for shift in range(-half, half + 1):
shifted = np.roll(observable, -shift, axis=1)
if shift < 0:
shifted[:, shift:] = 0 # zero pad edges
elif shift > 0:
shifted[:, :-shift] = 0
P_rows.append(shifted)
P = np.vstack(P_rows) # (m*Q_delay, N)
# CCD matrix: C = P * A^T / (N * sqrt(Q))
C = P @ pod_coeffs.T / (N * np.sqrt(Q_delay))
# SVD
R, s, Wt = np.linalg.svd(C, full_matrices=False)
W = Wt.T # (r, min_dim)
# CCD coefficients
z = W.T @ pod_coeffs # (min_dim, N)
return W, s, z
# ---------------------------------------------------------------------------
# Field stacking
# ---------------------------------------------------------------------------
def stack_velocity_fields(
ux_fields: List[np.ndarray],
uy_fields: List[np.ndarray],
) -> np.ndarray:
"""Stack list of (ux, uy) field pairs into snapshot matrix.
Each field is flattened, then ux and uy are interleaved.
Returns (2*nx*ny, N) matrix.
"""
snapshots = []
for ux, uy in zip(ux_fields, uy_fields):
q = np.concatenate([ux.ravel(), uy.ravel()])
snapshots.append(q)
return np.column_stack(snapshots)

View File

@ -1,561 +0,0 @@
# CCD_analysis/scripts/validate_control.py
"""
WARNING: This validation script has NOT produced correct results.
Despite multiple iterations, the PPO inference does not reproduce thesis-level
reward/similarity values (cloak sim ~0.19 vs expected 0.90, illusion sim ~0.82 vs 0.98).
Known issues that need to be investigated:
1. The norm values seem reasonable but may not match training-time distributions
2. Obs layout (what obs[i] means) depends on object add order, which differs between
legacy_env_karman_cloak_standard.py and uni_test.ipynb -- need to determine which
was actually used during training
3. The FIFO initialization may not exactly match training-time behavior
4. Action smoothing (legacy FlowField.run() uses exponential smoothing weight=0.1)
may affect dynamics in ways not accounted for
DO NOT USE these results for analysis. Fix the PPO replay first.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import time
from collections import deque
import numpy as np
_REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
if _REPO not in sys.path:
sys.path.insert(0, _REPO)
_ANALYSIS = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if _ANALYSIS not in sys.path:
sys.path.insert(0, _ANALYSIS)
from LegacyCelerisLab import FlowField
from scripts.cfg import CONFIG_DIR, OUTPUT_DIR, L0, NX, NY, CENTER_Y
from scripts.utils import load_configs, get_velocity_field
# -- Constants --
FIFO_LEN = 150
DATA_TYPE = np.float32
U0 = 0.01
U0_ILLUSION = 0.02
SAMPLE_INTERVAL = 800
SAMPLE_INTERVAL_ILL = 600
MODEL_CLOAK = os.path.join(_REPO, "models", "old", "d1a3o12_re100.zip")
MODEL_ILLUSION = os.path.join(_REPO, "models", "250525", "d1a3o14_250525_imit_1L_2U_600S.zip")
# Geometry
PR = L0 / 2.0 # pinball radius = 10
SR = L0 / 4.0 # sensor radius = 5
# Cloak layout (lattice units)
DIST_POS = (10.0 * L0, CENTER_Y, 0.0)
SENSOR_X = 40.0 * L0
SENSOR_YS = [CENTER_Y + 2.0 * L0, CENTER_Y, CENTER_Y - 2.0 * L0]
FRONT_POS = (30.0 * L0, CENTER_Y, 0.0)
BOTTOM_POS = (31.3 * L0, CENTER_Y - 0.75 * L0, 0.0)
TOP_POS = (31.3 * L0, CENTER_Y + 0.75 * L0, 0.0)
# Illusion layout
ILL_SENSOR_X = 30.0 * L0
ILL_SENSOR_YS = [CENTER_Y + 2.0 * L0, CENTER_Y, CENTER_Y - 2.0 * L0]
ILL_FRONT = (19.0 * L0, CENTER_Y, 0.0)
ILL_BOTTOM = (20.3 * L0, CENTER_Y + 0.75 * L0, 0.0)
ILL_TOP = (20.3 * L0, CENTER_Y - 0.75 * L0, 0.0)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _calc_lag(t, s):
tm = float(np.mean(t))
sm = float(np.mean(s))
corr = np.correlate(t - tm, s - sm, mode="full")
lags = np.arange(-len(t) + 1, len(t))
return int(lags[np.argmax(corr)])
def _calc_dtw_sim(t, s):
n, m = len(t), len(s)
dtw = np.full((n + 1, m + 1), np.inf)
dtw[0, 0] = 0.0
for i in range(1, n + 1):
for j in range(1, m + 1):
cost = abs(float(t[i - 1]) - float(s[j - 1]))
dtw[i, j] = cost + min(dtw[i - 1, j], dtw[i, j - 1], dtw[i - 1, j - 1])
return float(1.0 - dtw[n, m] / n)
def _save_vorticity_png(ff, path, title="", u0=U0):
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
ux, uy = get_velocity_field(ff, u0=u0)
omega = np.gradient(uy, axis=1) - np.gradient(ux, axis=0) # dv/dx - du/dy
abs_o = np.abs(omega[np.isfinite(omega)])
vmax = float(np.percentile(abs_o, 99.5)) if abs_o.size > 0 else 1.0
if vmax <= 0:
vmax = 1.0
ny, nx = omega.shape
fig, ax = plt.subplots(figsize=(min(18, max(8, nx / 60)), min(10, max(3, ny / 40))))
im = ax.imshow(omega, origin="lower", aspect="equal", cmap="RdBu_r",
vmin=-vmax, vmax=vmax, extent=(0, nx - 1, 0, ny - 1))
ax.set_xlabel("x (lattice)")
ax.set_ylabel("y (lattice)")
if title:
ax.set_title(title)
fig.colorbar(im, ax=ax, fraction=0.046, pad=0.04, label=r"$\omega_z$")
fig.tight_layout()
fig.savefig(path, dpi=150, bbox_inches="tight")
plt.close(fig)
def _load_ppo(model_path, device, s_dim=12, a_dim=3):
import torch
from torch.nn import Module
from stable_baselines3 import PPO
import gymnasium as gym
from gymnasium import spaces
class Sin(Module):
def forward(self, x):
return torch.sin(x)
class DummyEnv(gym.Env):
def __init__(self):
super().__init__()
self.observation_space = spaces.Box(low=-1, high=1, shape=(s_dim,), dtype=np.float32)
self.action_space = spaces.Box(low=-1, high=1, shape=(a_dim,), dtype=np.float32)
def reset(self, seed=None):
return np.zeros(s_dim, dtype=np.float32), {}
def step(self, action):
return np.zeros(s_dim, dtype=np.float32), 0.0, False, False, {}
def render(self):
pass
return PPO.load(model_path, env=DummyEnv(), device=device)
def _analyze_harmonics(states, n=5):
N, D = states.shape
r = []
for d in range(D):
y = states[:, d]
fc = np.fft.rfft(y)
fr = np.fft.rfftfreq(N, d=1)
amps = 2.0 * np.abs(fc) / N
ph = np.angle(fc)
idx = np.argsort(amps[1:])[::-1][:n] + 1
r.append({'dc': float(np.real(fc[0]) / N), 'amps': amps[idx].tolist(),
'freqs': fr[idx].tolist(), 'phases': ph[idx].tolist()})
return r
def _gen_target(t, harm):
t = np.asarray(t)
D = len(harm)
r = np.zeros((t.size, D), dtype=np.float32)
for d, h in enumerate(harm):
v = np.full(t.shape, h['dc'], dtype=np.float32)
for a, f, p in zip(h['amps'], h['freqs'], h['phases']):
v += a * np.cos(2 * np.pi * f * t + p)
r[:, d] = v
if r.shape[0] == 1:
return r[0]
return r
# ---------------------------------------------------------------------------
# Cloak validation (EXACTLY matching analysis_crossre + legacy_env)
# ---------------------------------------------------------------------------
def validate_cloak(device_id, out_dir):
print("=" * 60)
print("Validating Cloak (Karman)")
print("=" * 60)
os.makedirs(out_dir, exist_ok=True)
cuda_cfg, field_cfg = load_configs(CONFIG_DIR)
field_cfg = field_cfg._replace(viscosity=0.004, velocity=U0)
ff = FlowField(field_cfg, cuda_cfg, device_id=device_id)
# -- Phase 1: target recording (dist_cyl + 3 sensors) --
print("\n--- Target recording ---")
ff.add_cylinder(DIST_POS, L0) # dist(0)
for y in SENSOR_YS:
ff.add_sensor((SENSOR_X, y, 0.0), SR) # sensors(1,2,3)
n_obj = ff.obs.size // 2 # 4
ff.run(int(4 * NX / U0), np.zeros(n_obj, dtype=DATA_TYPE))
target_states = np.empty((0, 6), dtype=DATA_TYPE)
for _ in range(FIFO_LEN):
ff.run(SAMPLE_INTERVAL, np.zeros(n_obj, dtype=DATA_TYPE))
# obs layout: dist(0) + 3 sensors(1,2,3) → 4×2=8 values
# obs[2:8] = s0_ux,uy, s1_ux,uy, s2_ux,uy = 6 sensors
target_states = np.vstack((target_states, ff.obs.copy()[2:8]))
print(f" target: {target_states.shape}")
# -- Phase 2: add pinball (ids 4,5,6) --
print("\n--- Add pinball ---")
ff.add_cylinder(FRONT_POS, PR) # front(4)
ff.add_cylinder(BOTTOM_POS, PR) # bottom(5)
ff.add_cylinder(TOP_POS, PR) # top(6)
n_obj = ff.obs.size // 2 # 7
ff.run(int(4 * NX / U0), np.zeros(n_obj, dtype=DATA_TYPE))
ff.get_ddf()
ff.save_ddf() # checkpoint
# -- Phase 3: Norm computation --
print("\n--- Norm (obs[2:14]) ---")
fifo = deque(maxlen=FIFO_LEN)
for _ in range(FIFO_LEN):
ff.run(SAMPLE_INTERVAL, np.zeros(n_obj, dtype=DATA_TYPE))
fifo.append(ff.obs.copy()[2:14]) # [sensors(6), forces(6)]
temp = np.array(fifo, dtype=DATA_TYPE)
force_norm_fact = 6.0 * float(np.max(np.abs(temp[:, 6:12])))
sens_deviation = np.mean(temp[:, 0:6], axis=0).astype(DATA_TYPE)
sens_norm_fact = np.zeros(6, dtype=DATA_TYPE)
for i in range(6):
sens_norm_fact[i] = 5.0 * float(np.max(np.abs(temp[:, i] - sens_deviation[i])))
print(f" force_norm_fact={force_norm_fact:.6f}")
# -- Phase 4: Bias-action FIFO (uni_test: [0,0,0,0,0,-4*U0,4*U0]) --
print("\n--- Bias FIFO ---")
ff.apply_ddf()
bias = np.zeros(n_obj, dtype=DATA_TYPE)
bias[5] = -4.0 * U0 # bottom
bias[6] = 4.0 * U0 # top
fifo.clear()
for _ in range(FIFO_LEN):
ff.run(SAMPLE_INTERVAL, bias)
fifo.append(ff.obs.copy()[2:14])
save_states = list(fifo)
ff.apply_ddf()
# -- Phase 5: PPO inference --
print("\n--- PPO inference (500 steps) ---")
import torch
dev = f"cuda:{device_id}" if torch.cuda.is_available() else "cpu"
model = _load_ppo(MODEL_CLOAK, device=dev, s_dim=12, a_dim=3)
model.set_random_seed(0)
n_steps = 500
fifo = deque(maxlen=FIFO_LEN)
for s in save_states:
fifo.append(np.array(s, dtype=DATA_TYPE))
obs = np.zeros(12, dtype=DATA_TYPE)
rewards, sims, cds, cls = [], [], [], []
for step in range(n_steps):
action, _ = model.predict(obs, deterministic=True)
action = action.astype(DATA_TYPE).flatten()
# Action: legacy pattern, pinball at indices 4,5,6
temp_a = np.zeros(n_obj, dtype=DATA_TYPE)
temp_a[4:7] = (action * 8.0 + np.array([0.0, -4.0, 4.0], dtype=DATA_TYPE)) * U0
ff.context.push()
try:
ff.run(SAMPLE_INTERVAL, temp_a)
finally:
ff.context.pop()
obs_slice = ff.obs.copy()[2:14] # [sensors(6), forces(6)]
fifo.append(obs_slice)
# Observation: [forces_norm(6), sens_norm(6)] ← matches analysis_crossre order
forces_norm = obs_slice[6:12] / force_norm_fact
sens_norm = (obs_slice[0:6] - sens_deviation) / sens_norm_fact
obs = np.clip(np.hstack([forces_norm, sens_norm]), -1.0, 1.0).astype(DATA_TYPE)
# Reward (legacy env style: cd/cl from forces in obs_slice[6:12])
sarr = np.array(fifo, dtype=DATA_TYPE)
if len(sarr) >= 30:
f = sarr[-1, 6:12] / force_norm_fact
cd = float((f[0] + f[2] + f[4]) / 3.0)
cl = float((f[1] + f[3] + f[5]) / 3.0)
# DTW: lag from middle sensor (index 1 in sensor block = obs[4] in obs[2:14] = sensor1_uy)
ref = target_states[30:60, 1]
cur = sarr[-30:, 1]
lag = _calc_lag(ref, cur)
sim = 0.0
for i in range(6):
t_seq = np.roll(target_states[:, i], -lag)[30:60]
s_seq = sarr[-30:, i]
sim += _calc_dtw_sim(t_seq, s_seq) / 6.0
r_cd = float(np.exp(-abs(cd * 20.0)))
r_cl = float(np.exp(-abs(cl * 80.0)))
r_sim = float(np.exp(-10.0 * abs(sim - 1.0)))
reward = float(min(0.3 * r_cd + 0.4 * r_cl + 0.3 * r_sim, 1.0))
else:
cd, cl, sim, reward = 0.0, 0.0, 0.0, 0.0
rewards.append(reward)
sims.append(sim)
cds.append(cd)
cls.append(cl)
_save_vorticity_png(ff, os.path.join(out_dir, "cloak_vorticity_final.png"),
title="Cloak (Karman) Re=100", u0=U0)
tail = 100
result = {
"case": "cloak_karman", "n_steps": n_steps,
"mean_reward_last100": float(np.mean(rewards[-tail:])),
"std_reward_last100": float(np.std(rewards[-tail:])),
"mean_similarity_last100": float(np.mean(sims[-tail:])),
"mean_cd_last100": float(np.mean(cds[-tail:])),
"mean_cl_last100": float(np.mean(cls[-tail:])),
"force_norm_fact": force_norm_fact,
"vorticity_png": "cloak_vorticity_final.png",
}
print(f"\n mean_reward={result['mean_reward_last100']:.4f} "
f"sim={result['mean_similarity_last100']:.4f} cd={result['mean_cd_last100']:.4f}")
del ff, model
return result
# ---------------------------------------------------------------------------
# Illusion validation
# ---------------------------------------------------------------------------
def validate_illusion(device_id, out_dir):
print("=" * 60)
print("Validating Illusion (1L)")
print("=" * 60)
os.makedirs(out_dir, exist_ok=True)
cuda_cfg, field_cfg = load_configs(CONFIG_DIR)
field_cfg = field_cfg._replace(viscosity=0.008, velocity=U0_ILLUSION)
# -- Target recording --
print("\n--- Target recording ---")
ff_tgt = FlowField(field_cfg, cuda_cfg, device_id=device_id)
ff_tgt.add_cylinder((20.0 * L0, CENTER_Y, 0.0), L0) # target cylinder
for y in ILL_SENSOR_YS:
ff_tgt.add_sensor((ILL_SENSOR_X, y, 0.0), SR)
n_tgt = ff_tgt.obs.size // 2 # 4
ff_tgt.run(int(4 * NX / U0_ILLUSION), np.zeros(n_tgt, dtype=DATA_TYPE))
target_states = np.empty((0, 8), dtype=DATA_TYPE)
for _ in range(FIFO_LEN):
ff_tgt.run(SAMPLE_INTERVAL_ILL, np.zeros(n_tgt, dtype=DATA_TYPE))
target_states = np.vstack((target_states, ff_tgt.obs.copy()[0:8]))
harmonics = _analyze_harmonics(target_states, 5)
print(f" target: {target_states.shape}, harmonics: {len(harmonics)} ch")
del ff_tgt
# -- Pinball env (6 objects: 3 sensors + 3 pinball) --
# Add sensors first, then pinball (matches legacy_env_imit.py)
print("\n--- Build pinball env ---")
ff = FlowField(field_cfg, cuda_cfg, device_id=device_id)
for y in ILL_SENSOR_YS:
ff.add_sensor((ILL_SENSOR_X, y, 0.0), SR) # sensors(0,1,2)
ff.add_cylinder(ILL_FRONT, PR) # front(3)
ff.add_cylinder(ILL_BOTTOM, PR) # bottom(4)
ff.add_cylinder(ILL_TOP, PR) # top(5)
n_obj = ff.obs.size // 2 # 6
ff.run(int(4 * NX / U0_ILLUSION), np.zeros(n_obj, dtype=DATA_TYPE))
ff.get_ddf()
ff.save_ddf()
# -- Norm (obs[0:12] = [sensors(6), forces(6)]) --
print("\n--- Norm ---")
fifo = deque(maxlen=FIFO_LEN)
for _ in range(FIFO_LEN):
ff.run(SAMPLE_INTERVAL_ILL, np.zeros(n_obj, dtype=DATA_TYPE))
fifo.append(ff.obs.copy()[0:12])
temp = np.array(fifo, dtype=DATA_TYPE)
force_norm_fact = 6.0 * float(np.max(np.abs(temp[:, 6:12])))
sens_deviation = np.mean(temp[:, 0:6], axis=0).astype(DATA_TYPE)
sens_norm_fact = np.zeros(6, dtype=DATA_TYPE)
for i in range(6):
sens_norm_fact[i] = 5.0 * float(np.max(np.abs(temp[:, i] - sens_deviation[i])))
print(f" force_norm_fact={force_norm_fact:.6f}")
# -- Bias FIFO --
print("\n--- Bias FIFO ---")
ff.apply_ddf()
bias = np.zeros(n_obj, dtype=DATA_TYPE)
bias[4] = -1.0 * U0_ILLUSION
bias[5] = 1.0 * U0_ILLUSION
fifo.clear()
for _ in range(FIFO_LEN):
ff.run(SAMPLE_INTERVAL_ILL, bias)
fifo.append(ff.obs.copy()[0:12])
save_states = list(fifo)
ff.apply_ddf()
# -- PPO inference --
print("\n--- PPO inference (500 steps) ---")
import torch
dev = f"cuda:{device_id}" if torch.cuda.is_available() else "cpu"
model = _load_ppo(MODEL_ILLUSION, device=dev, s_dim=14, a_dim=3)
model.set_random_seed(19)
n_steps = 500
fifo = deque(maxlen=FIFO_LEN)
for s in save_states:
fifo.append(np.array(s, dtype=DATA_TYPE))
obs = np.zeros(14, dtype=DATA_TYPE)
rewards, sims, cds, cls = [], [], [], []
for step in range(n_steps):
action, _ = model.predict(obs, deterministic=True)
action = action.astype(DATA_TYPE).flatten()
temp_a = np.zeros(n_obj, dtype=DATA_TYPE)
temp_a[3:6] = (action * 8.0 + np.array([0.0, -2.0, 2.0], dtype=DATA_TYPE)) * U0_ILLUSION
ff.context.push()
try:
ff.run(SAMPLE_INTERVAL_ILL, temp_a)
finally:
ff.context.pop()
obs_slice = ff.obs.copy()[0:12]
fifo.append(obs_slice)
# 14-dim obs: [forces_norm(6), sens_norm(6), target_cd, target_cl]
forces_norm = obs_slice[6:12] / force_norm_fact
sens_norm = (obs_slice[0:6] - sens_deviation) / sens_norm_fact
target_recon = _gen_target(step, harmonics)
t_cd_n = float(target_recon[0]) / force_norm_fact
t_cl_n = float(target_recon[1]) / force_norm_fact
obs = np.clip(np.hstack([forces_norm, sens_norm, t_cd_n, t_cl_n]),
-1.0, 1.0).astype(DATA_TYPE)
# Reward
sarr = np.array(fifo, dtype=DATA_TYPE)
if len(sarr) >= 36:
f = sarr[-1, 6:12] / force_norm_fact
cd = float(f[0] + f[2] + f[4]) # SUM
cl = float(f[1] + f[3] + f[5])
ref = target_states[36:72, 3]
cur = sarr[-36:, 3]
lag = _calc_lag(ref, cur)
sim = 0.0
for i in range(6):
t_seq = np.roll(target_states[:, i + 2], -lag)[36:72]
s_seq = sarr[-36:, i]
sim += _calc_dtw_sim(t_seq, s_seq) / 6.0
t_cd = float(target_recon[0]) / force_norm_fact
t_cl = float(target_recon[1]) / force_norm_fact
r_cd = float(np.exp(-abs((cd - t_cd) * 10.0)))
r_cl = float(np.exp(-abs((cl - t_cl) * 10.0)))
r_sim = float(np.exp(-10.0 * abs(sim - 1.0)))
reward = float(min(0.3 * r_cd + 0.3 * r_cl + 0.4 * r_sim, 1.0))
else:
cd, cl, sim, reward = 0.0, 0.0, 0.0, 0.0
rewards.append(reward)
sims.append(sim)
cds.append(cd)
cls.append(cl)
_save_vorticity_png(ff, os.path.join(out_dir, "illusion_vorticity_final.png"),
title="Illusion (1L) Re=100", u0=U0_ILLUSION)
tail = 100
result = {
"case": "illusion_1L", "n_steps": n_steps,
"mean_reward_last100": float(np.mean(rewards[-tail:])),
"std_reward_last100": float(np.std(rewards[-tail:])),
"mean_similarity_last100": float(np.mean(sims[-tail:])),
"mean_cd_last100": float(np.mean(cds[-tail:])),
"mean_cl_last100": float(np.mean(cls[-tail:])),
"force_norm_fact": force_norm_fact,
"vorticity_png": "illusion_vorticity_final.png",
}
print(f"\n mean_reward={result['mean_reward_last100']:.4f} "
f"sim={result['mean_similarity_last100']:.4f} cd={result['mean_cd_last100']:.4f}")
del ff, model
return result
# ---------------------------------------------------------------------------
# Baseline
# ---------------------------------------------------------------------------
def validate_uncontrolled(device_id, out_dir):
cuda_cfg, field_cfg = load_configs(CONFIG_DIR)
field_cfg = field_cfg._replace(viscosity=0.004, velocity=U0)
ff = FlowField(field_cfg, cuda_cfg, device_id=device_id)
for y in SENSOR_YS:
ff.add_sensor((SENSOR_X, y, 0.0), SR)
ff.add_cylinder(FRONT_POS, PR)
ff.add_cylinder(BOTTOM_POS, PR)
ff.add_cylinder(TOP_POS, PR)
ff.run(int(4 * NX / U0), np.zeros(6, dtype=DATA_TYPE))
for _ in range(200):
ff.run(SAMPLE_INTERVAL, np.zeros(6, dtype=DATA_TYPE))
_save_vorticity_png(ff, os.path.join(out_dir, "uncontrolled_vorticity_final.png"),
title="Uncontrolled Pinball Re=100", u0=U0)
del ff
return {"case": "uncontrolled", "vorticity_png": "uncontrolled_vorticity_final.png"}
def validate_target(device_id, out_dir):
cuda_cfg, field_cfg = load_configs(CONFIG_DIR)
field_cfg = field_cfg._replace(viscosity=0.004, velocity=U0)
ff = FlowField(field_cfg, cuda_cfg, device_id=device_id)
ff.add_cylinder((20.0 * L0, CENTER_Y, 0.0), L0)
for y in SENSOR_YS:
ff.add_sensor((SENSOR_X, y, 0.0), SR)
ff.run(int(4 * NX / U0), np.zeros(4, dtype=DATA_TYPE))
for _ in range(200):
ff.run(SAMPLE_INTERVAL, np.zeros(4, dtype=DATA_TYPE))
_save_vorticity_png(ff, os.path.join(out_dir, "target_vorticity_final.png"),
title="Target 2D Cylinder Re=100", u0=U0)
del ff
return {"case": "target_cylinder", "vorticity_png": "target_vorticity_final.png"}
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
ap = argparse.ArgumentParser(description="Validate control quality")
ap.add_argument("--device", type=int, default=2)
ap.add_argument("--case", type=str, required=True,
choices=["all", "cloak", "illusion", "uncontrolled", "target"])
args = ap.parse_args()
out_dir = os.path.join(OUTPUT_DIR, "validation")
os.makedirs(out_dir, exist_ok=True)
t0 = time.time()
results = {}
if args.case in ("all", "cloak"):
results["cloak"] = validate_cloak(args.device, out_dir)
if args.case in ("all", "illusion"):
results["illusion"] = validate_illusion(args.device, out_dir)
if args.case in ("all", "uncontrolled"):
results["uncontrolled"] = validate_uncontrolled(args.device, out_dir)
if args.case in ("all", "target"):
results["target"] = validate_target(args.device, out_dir)
with open(os.path.join(out_dir, "validation_results.json"), "w") as f:
json.dump({"device": args.device, "elapsed_sec": time.time() - t0,
"results": results}, f, indent=2)
print(f"\n{'='*60}\nSummary\n{'='*60}")
for c, r in results.items():
if "mean_reward_last100" in r:
print(f" {c}: reward={r['mean_reward_last100']:.4f} sim={r['mean_similarity_last100']:.4f}")
else:
print(f" {c}: baseline")
print(f"\nVorticity: {out_dir}/ | Total: {time.time()-t0:.1f}s")
return 0
if __name__ == "__main__":
main()

View File

@ -0,0 +1,93 @@
"""Steady cloak metrics (mean flow error, recirculation zone, fluctuation suppression).
Usage:
python steady/run_steady.py
"""
from __future__ import annotations
import json
import os
import sys
import numpy as np
_ANALYSIS = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if _ANALYSIS not in sys.path:
sys.path.insert(0, _ANALYSIS)
from CCD_analysis.configs import DATA_DIR
def main():
print("=== Steady Cloak Metrics ===\n")
steady_dir = os.path.join(DATA_DIR, "steady_cloak", "steady_cloak")
pinball_dir = os.path.join(DATA_DIR, "pinball", "pinball")
empty_dir = steady_dir # TODO: generate empty channel reference
# Load fields
def load_fields(d):
p = os.path.join(d, "fields.npz")
if not os.path.isfile(p):
return None
f = np.load(p)
return f["ux"].astype(np.float64), f["uy"].astype(np.float64)
sc = load_fields(steady_dir)
if sc is None:
print("ERROR: no steady cloak fields. Run scripts/collect_steady_cloak.py first.")
return 1
ux_s, uy_s = sc
ux_s_mean = np.mean(ux_s, axis=0)
uy_s_mean = np.mean(uy_s, axis=0)
ux_s_rms = np.sqrt(np.mean((ux_s - ux_s_mean) ** 2, axis=0))
uy_s_rms = np.sqrt(np.mean((uy_s - uy_s_mean) ** 2, axis=0))
# 1. Fluctuation level
total_rms = float(np.sqrt(np.mean(ux_s_rms**2 + uy_s_rms**2)))
print(f"1. Total RMS (steady cloak): {total_rms:.6f}")
# 2. Recirculation zone
ny, nx = ux_s_mean.shape
cline = ux_s_mean[ny // 2, :]
neg = np.where(cline[400:-50] < 0)[0]
if len(neg) > 0:
L_r = float(neg[-1])
print(f"2. Recirculation length L_r: {L_r:.0f} lattice units")
else:
L_r = 0.0
print(f"2. Recirculation length L_r: 0 (no reverse flow)")
# 3. Sensor mean (if available)
sens_path = os.path.join(steady_dir, "sensors.npz")
if os.path.isfile(sens_path):
sd = np.load(sens_path)
sens_mean = np.mean(sd["sensors"], axis=0)
print(f"3. Sensor means: ux_center={sens_mean[2]:.4f}, uy_center={sens_mean[3]:.4f}")
# 4. Compare with pinball (if available)
pb = load_fields(pinball_dir)
if pb is not None:
ux_p, uy_p = pb
ux_p_mean = np.mean(ux_p, axis=0)
ux_p_rms = np.sqrt(np.mean((ux_p - ux_p_mean) ** 2, axis=0))
pb_rms = float(np.sqrt(np.mean(ux_p_rms**2)))
print(f"4. Pinball total RMS: {pb_rms:.6f}")
print(f" Fluctuation suppression: {(1 - total_rms / (pb_rms + 1e-12)) * 100:.1f}%")
results = {
"total_rms": total_rms,
"L_r": L_r,
"fluctuation_suppression_pct": float((1 - total_rms / (pb_rms + 1e-12)) * 100) if pb is not None else None,
}
out_dir = os.path.join(DATA_DIR, "steady")
os.makedirs(out_dir, exist_ok=True)
with open(os.path.join(out_dir, "steady_metrics.json"), "w") as f:
json.dump(results, f, indent=2)
print(f"\nSaved to {out_dir}/steady_metrics.json")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,12 @@
"""CCD_analysis utilities — non-pycuda exports.
cfd_interface.py requires pycuda_3_10 and is NOT exported here.
Import it directly: from CCD_analysis.utils.cfd_interface import ...
"""
from .resampling import (
detect_dominant_frequency, detect_cycle_stability,
phase_resample, compute_pod, cumulative_energy,
e95_index, compute_reduced_ccd,
stack_velocity_fields, unstack_velocity_modes,
analyze_harmonics, gen_target_states_at,
)

View File

@ -0,0 +1,334 @@
"""CFD interface for LegacyCelerisLab (pycuda_3_10 env).
Copied and adapted from SR_analysis/utils/cfd_interface.py (verified working).
All functions use the LegacyCelerisLab (old) CFD API via:
from LegacyCelerisLab import FlowField
Must be run inside: conda run -n pycuda_3_10
NOTE: This module should be imported directly, not through utils/__init__.py,
because it requires pycuda. Other utils (resampling) do NOT require pycuda.
"""
from __future__ import annotations
import json
import os
import sys
from collections import deque
from typing import Any, Dict, List, Optional, Tuple
import numpy as np
_REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
if _REPO not in sys.path:
sys.path.insert(0, _REPO)
_SRC = os.path.join(_REPO, "src")
if _SRC not in sys.path:
sys.path.insert(0, _SRC)
from LegacyCelerisLab import FlowField # noqa: E402
from LegacyCelerisLab import utils as legacy_utils # noqa: E402
ACTION_SMOOTH_WEIGHT = 0.1 # used by FlowField.run() internally
def load_legacy_configs(config_dir: str) -> Tuple[Any, Any]:
"""Load and return legacy (cuda_config, field_config)."""
cuda_cfg = legacy_utils.load_cuda_config(
os.path.join(config_dir, "config_cuda.json"))
field_cfg = legacy_utils.load_flow_field_config(
os.path.join(config_dir, "config_flowfield.json"))
return cuda_cfg, field_cfg
# ---------------------------------------------------------------------------
# Karman cloak env builder (disturbance cylinder + 3 sensors)
# ---------------------------------------------------------------------------
def build_karman_cloak_env(
flow_field: FlowField,
*,
u0: float,
l0: float,
sample_interval: int,
fifo_len: int,
data_type: type,
) -> Tuple[np.ndarray, dict]:
"""Add dist-cylinder & 3 sensors, stabilize, record target.
Steps (mirrors env_karman_cloak_standard.__init__):
1. add dist_cylinder (id=0)
2. add 3 sensors (id=1,2,3)
3. stabilize run(4*NX/U0, zero-action[4])
4. record FIFO_LEN x run(SAMPLE_INTERVAL, zero[4]), collect obs[2:8]
Returns (target_states, info_dict).
"""
cy = (flow_field.FIELD_SHAPE[1] - 1) / 2.0
flow_field.add_cylinder((10.0 * l0, cy, 0.0), l0)
for y_off in [2.0, 0.0, -2.0]:
flow_field.add_sensor((40.0 * l0, cy + y_off * l0, 0.0), l0 / 4.0)
n_obj = flow_field.obs.size // 2
stabilize_steps = int(4 * flow_field.FIELD_SHAPE[0] / u0)
print(f" stabilising ({stabilize_steps} steps)...")
flow_field.run(stabilize_steps, np.zeros(n_obj, dtype=data_type))
target_states = np.empty((0, 6), dtype=data_type)
for _ in range(fifo_len):
flow_field.run(sample_interval, np.zeros(n_obj, dtype=data_type))
target_states = np.vstack((target_states, flow_field.obs.copy()[2:8]))
print(f" target recorded: {target_states.shape}")
return target_states, {"n_objects": n_obj, "NX": flow_field.FIELD_SHAPE[0],
"NY": flow_field.FIELD_SHAPE[1]}
# ---------------------------------------------------------------------------
# Pinball adder + norm computation (configurable for Karman / Illusion)
# ---------------------------------------------------------------------------
def add_pinball(
flow_field: FlowField,
*,
l0: float,
u0: float,
sample_interval: int,
fifo_len: int,
data_type: type,
action_bias: Optional[Tuple[float, float, float]] = None,
pinball_front_x: float = 30.0,
pinball_rear_x: float = 31.3,
obs_slice_start: int = 2,
obs_slice_end: int = 14,
n_objects_total: Optional[int] = None,
) -> dict:
"""Add pinball cylinders, stabilize, compute norm, bias rollout.
Parameters
----------
pinball_front_x, pinball_rear_x : pinball geometry (L0 units).
obs_slice_start, obs_slice_end : slice of obs for norm.
Returns dict with norm values and save_states.
"""
if action_bias is None:
action_bias = (0.0, -4.0, 4.0)
u0_float = float(u0)
ny = flow_field.FIELD_SHAPE[1]
centers = [
(pinball_front_x * l0, (ny - 1) / 2, 0.0),
(pinball_rear_x * l0, (ny - 1) / 2 + 0.75 * l0, 0.0),
(pinball_rear_x * l0, (ny - 1) / 2 - 0.75 * l0, 0.0),
]
for c in centers:
flow_field.add_cylinder(c, l0 / 2.0)
n_obj = flow_field.obs.size // 2 if n_objects_total is None else n_objects_total
print(f" bodies after pinball: {n_obj}")
stabilize_steps = int(4 * flow_field.FIELD_SHAPE[0] / u0_float)
print(f" stabilising pinball ({stabilize_steps} steps)...")
flow_field.run(stabilize_steps, np.zeros(n_obj, dtype=data_type))
flow_field.get_ddf()
flow_field.save_ddf()
# ---- norm phase (zero-action) ----
fifo = deque(maxlen=fifo_len)
for _ in range(fifo_len):
flow_field.run(sample_interval, np.zeros(n_obj, dtype=data_type))
fifo.append(flow_field.obs.copy()[obs_slice_start:obs_slice_end])
temp_states = np.array(fifo, dtype=data_type)
# forces are at the last 6 positions of the slice
force_start = obs_slice_end - obs_slice_start - 6
force_end = force_start + 6
force_norm_fact = 6.0 * float(np.max(np.abs(temp_states[:, force_start:force_end])))
sens_deviation = np.mean(temp_states[:, 0:6], axis=0).astype(data_type)
sens_norm_fact = np.zeros(6, dtype=data_type)
for i in range(6):
sens_norm_fact[i] = 5.0 * float(np.max(np.abs(temp_states[:, i] - sens_deviation[i])))
print(f" norm: force_norm_fact={force_norm_fact:.6f}")
# ---- bias-action rollout ----
flow_field.apply_ddf()
bias = np.zeros(n_obj, dtype=data_type)
bias[n_obj - 3] = float(action_bias[0] * u0_float)
bias[n_obj - 2] = float(action_bias[1] * u0_float)
bias[n_obj - 1] = float(action_bias[2] * u0_float)
fifo.clear()
for _ in range(fifo_len):
flow_field.run(sample_interval, bias)
fifo.append(flow_field.obs.copy()[obs_slice_start:obs_slice_end])
save_states = np.array(list(fifo), dtype=data_type)
flow_field.apply_ddf()
return {
"force_norm_fact": force_norm_fact,
"sens_deviation": sens_deviation.tolist(),
"sens_norm_fact": sens_norm_fact.tolist(),
"action_bias": list(action_bias),
"save_states": save_states,
}
# ---------------------------------------------------------------------------
# Observation builder and action helpers
# ---------------------------------------------------------------------------
def build_observation(obs_slice: np.ndarray, norm: dict) -> np.ndarray:
"""Assemble normalised DRL observation (12-dim) from obs slice.
obs_slice is 12-element: sensor[0:6] + force[6:12].
Returns clipped 12-dim array in [-1, 1].
"""
forces = obs_slice[6:12] / norm["force_norm_fact"]
sens = (obs_slice[0:6] - norm["sens_deviation"]) / norm["sens_norm_fact"]
obs = np.clip(np.hstack([forces, sens]), -1.0, 1.0).astype(np.float32)
return obs
def scale_action(
action_norm: np.ndarray,
*,
scale: float = 8.0,
bias: Tuple[float, float, float] = (0.0, -4.0, 4.0),
u0: float = 0.01,
n_total_bodies: int = 7,
) -> np.ndarray:
"""Convert normalised action ([-1,1]^3) to legacy CFD action array.
Returns array of length n_total_bodies with cylinders' omegas at the last 3 slots.
"""
a = np.zeros(n_total_bodies, dtype=np.float32)
omega = (np.array(action_norm, dtype=np.float32) * scale
+ np.array(bias, dtype=np.float32)) * u0
a[n_total_bodies - 3:] = omega
return a
# ---------------------------------------------------------------------------
# Vorticity & field export
# ---------------------------------------------------------------------------
def get_velocity_field(flow_field: FlowField, u0: float = 0.01):
"""Extract ux, uy fields from DDF on host. Returns (ux, uy) each (NY, NX)."""
flow_field.get_ddf()
NX = flow_field.FIELD_SHAPE[0]
NY = flow_field.FIELD_SHAPE[1]
ddf = flow_field.ddf.copy().reshape((9, NY, NX)).transpose(2, 1, 0)
ux = (ddf[:, :, 1] + ddf[:, :, 5] + ddf[:, :, 8]
- ddf[:, :, 3] - ddf[:, :, 6] - ddf[:, :, 7]) / u0
uy = (ddf[:, :, 2] + ddf[:, :, 5] + ddf[:, :, 6]
- ddf[:, :, 4] - ddf[:, :, 7] - ddf[:, :, 8]) / u0
return ux.astype(np.float32), uy.astype(np.float32)
def vorticity_from_ddf(flow_field: FlowField, u0: float) -> np.ndarray:
"""Compute z-vorticity from current DDF on host."""
ux, uy = get_velocity_field(flow_field, u0)
omega = np.gradient(uy, axis=1) - np.gradient(ux, axis=0)
return omega.astype(np.float64)
def save_vorticity_png(path: str, omega: np.ndarray, title: str = ""):
"""Save vorticity field as a PNG with symmetric colour bar."""
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
abs_o = np.abs(omega[np.isfinite(omega)])
vmax = float(np.percentile(abs_o, 99.5)) if abs_o.size > 0 else 1.0
if vmax <= 0:
vmax = 1.0
ny, nx = omega.shape
fig, ax = plt.subplots(figsize=(min(18, max(8, nx / 60)), min(10, max(3, ny / 40))))
im = ax.imshow(omega, origin="lower", aspect="equal", cmap="RdBu_r",
vmin=-vmax, vmax=vmax, extent=(0, nx - 1, 0, ny - 1))
ax.set_xlabel("x (lattice)")
ax.set_ylabel("y (lattice)")
if title:
ax.set_title(title)
fig.colorbar(im, ax=ax, fraction=0.046, pad=0.04, label=r"$\omega_z$")
fig.tight_layout()
fig.savefig(path, dpi=150, bbox_inches="tight")
plt.close(fig)
# ---------------------------------------------------------------------------
# DTW similarity
# ---------------------------------------------------------------------------
def calc_lag(target: np.ndarray, state: np.ndarray) -> int:
t = target - np.mean(target)
s = state - np.mean(state)
corr = np.correlate(t, s, mode="full")
lags = np.arange(-len(target) + 1, len(target))
return int(lags[np.argmax(corr)])
def calc_dtw_sim(target: np.ndarray, state: np.ndarray) -> float:
n, m = len(target), len(state)
dtw = np.full((n + 1, m + 1), np.inf)
dtw[0, 0] = 0.0
for i in range(1, n + 1):
for j in range(1, m + 1):
cost = abs(float(target[i - 1]) - float(state[j - 1]))
dtw[i, j] = cost + min(dtw[i - 1, j], dtw[i, j - 1], dtw[i - 1, j - 1])
return float(1.0 - dtw[n, m] / n)
def compute_similarity(target_states: np.ndarray, state_series: np.ndarray, conv_len: int) -> float:
"""Lag-compensated DTW similarity over conv_len window."""
ref = target_states[conv_len:2 * conv_len, 1]
cur = state_series[-conv_len:, 1]
lag = calc_lag(ref, cur)
sim_sum = 0.0
for i in range(6):
target_seq = np.roll(target_states[:, i], -lag)[conv_len:2 * conv_len]
state_seq = state_series[-conv_len:, i]
sim_sum += calc_dtw_sim(target_seq, state_seq) / 6.0
return float(sim_sum)
# ---------------------------------------------------------------------------
# PPO model loading (DummyEnv + Sin activation)
# ---------------------------------------------------------------------------
def create_dummy_env(s_dim: int = 12, a_dim: int = 3):
"""Return a gym.Env with correct observation/action spaces for model loading."""
import gymnasium as gym
from gymnasium import spaces
class DummyEnv(gym.Env):
def __init__(self):
super().__init__()
self.observation_space = spaces.Box(low=-1, high=1, shape=(s_dim,), dtype=np.float32)
self.action_space = spaces.Box(low=-1, high=1, shape=(a_dim,), dtype=np.float32)
def reset(self, seed=None):
return np.zeros(s_dim, dtype=np.float32), {}
def step(self, action):
return np.zeros(s_dim, dtype=np.float32), 0.0, False, False, {}
def render(self):
pass
return DummyEnv()
def load_ppo_model(model_path: str, device: str = "cuda:0", s_dim: int = 12, a_dim: int = 3):
"""Load a PPO model with Sin activation."""
import torch
from torch.nn import Module
from stable_baselines3 import PPO
class Sin(Module):
def forward(self, x):
return torch.sin(x)
dummy_env = create_dummy_env(s_dim, a_dim)
model = PPO.load(model_path, env=dummy_env, device=device)
return model

View File

@ -1,9 +1,7 @@
# CCD_analysis/scripts/analysis_utils.py """CPU-only analysis utilities for CCD pipeline.
"""CPU-only analysis utilities for Phase 2, 3, 4.
No pycuda or LegacyCelerisLab dependency can run with plain python3. No pycuda or LegacyCelerisLab dependency can run with plain python3.
""" """
from __future__ import annotations from __future__ import annotations
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
@ -15,22 +13,8 @@ import numpy as np
# Period detection helpers # Period detection helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def detect_dominant_frequency( def detect_dominant_frequency(signal: np.ndarray, sample_dt: float) -> Tuple[float, float, float]:
signal: np.ndarray, sample_dt: float """Detect dominant frequency via FFT. Returns (f_dom, period, peak_power)."""
) -> Tuple[float, float, float]:
"""Detect dominant frequency via FFT.
Parameters
----------
signal : 1D array
Time series to analyse.
sample_dt : float
Time between samples.
Returns
-------
f_dom, period, peak_power
"""
n = len(signal) n = len(signal)
if n < 16: if n < 16:
return 0.0, 0.0, 0.0 return 0.0, 0.0, 0.0
@ -44,18 +28,10 @@ def detect_dominant_frequency(
return f_dom, period, float(spec[idx]) return f_dom, period, float(spec[idx])
def detect_cycle_stability( def detect_cycle_stability(signal: np.ndarray, sample_dt: float) -> Tuple[float, float, list]:
signal: np.ndarray, sample_dt: float """Detect cycle lengths using rising zero-crossings. Returns (CV_T, mean_T, lengths)."""
) -> Tuple[float, float, List[float]]:
"""Detect cycle lengths and compute stability metrics.
Uses rising zero-crossings of (signal - mean) for cycle detection.
Returns (cv_T, mean_T, cycle_lengths).
"""
y = signal - np.mean(signal) y = signal - np.mean(signal)
sign = np.sign(y) crossings = np.where((np.sign(y[:-1]) < 0) & (np.sign(y[1:]) > 0))[0]
crossings = np.where((sign[:-1] < 0) & (sign[1:] > 0))[0]
if len(crossings) < 2: if len(crossings) < 2:
return 0.0, 0.0, [] return 0.0, 0.0, []
cycle_lengths = np.diff(crossings).astype(float) * sample_dt cycle_lengths = np.diff(crossings).astype(float) * sample_dt
@ -71,23 +47,16 @@ def detect_cycle_stability(
# Phase resampling # Phase resampling
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def phase_resample( def phase_resample(data: np.ndarray, cycle_starts: List[int], n_pts: int = 24) -> np.ndarray:
data: np.ndarray,
cycle_starts: List[int],
n_pts: int = 24,
) -> np.ndarray:
"""Resample a multi-channel signal to uniform phase points per cycle. """Resample a multi-channel signal to uniform phase points per cycle.
Uses piecewise linear interpolation (no scipy dependency). Uses piecewise linear interpolation (no scipy dependency).
Parameters Parameters
---------- ----------
data : (T, C) ndarray data : (T, C) ndarray multi-channel time series.
Multi-channel time series. cycle_starts : list of int indices where each cycle starts.
cycle_starts : list of int n_pts : int number of phase points per cycle.
Indices where each cycle starts.
n_pts : int
Number of phase points per cycle.
Returns Returns
------- -------
@ -99,7 +68,6 @@ def phase_resample(
if data.ndim == 1: if data.ndim == 1:
data = data[:, None] data = data[:, None]
C = data.shape[1] C = data.shape[1]
out = np.zeros((n_cycles, n_pts, C), dtype=np.float64) out = np.zeros((n_cycles, n_pts, C), dtype=np.float64)
@ -111,10 +79,8 @@ def phase_resample(
if seg_len < 2: if seg_len < 2:
continue continue
# Linear interpolation from original phase grid to uniform grid
old_idx = np.linspace(0, 1, seg_len) old_idx = np.linspace(0, 1, seg_len)
new_idx = np.linspace(0, 1, n_pts, endpoint=False) new_idx = np.linspace(0, 1, n_pts, endpoint=False)
for ch in range(C): for ch in range(C):
out[c, :, ch] = np.interp(new_idx, old_idx, segment[:, ch]) out[c, :, ch] = np.interp(new_idx, old_idx, segment[:, ch])
@ -125,38 +91,24 @@ def phase_resample(
# POD # POD
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def compute_pod( def compute_pod(snapshot_matrix: np.ndarray) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
snapshot_matrix: np.ndarray """Compute POD from snapshot matrix (n_points, n_snapshots).
) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
"""Compute POD from snapshot matrix.
Parameters Returns (mean_field, modes, singular_values, coefficients).
----------
snapshot_matrix : (n_points, n_snapshots) ndarray
Each column is one flattened snapshot.
Returns
-------
mean_field : (n_points,)
modes : (n_points, min(n_points, n_snapshots))
singular_values : (min_dim,)
coefficients : (min_dim, n_snapshots)
""" """
mean_field = np.mean(snapshot_matrix, axis=1) mean_field = np.mean(snapshot_matrix, axis=1)
Q = snapshot_matrix - mean_field[:, None] Q = snapshot_matrix - mean_field[:, None]
U, s, Vt = np.linalg.svd(Q, full_matrices=False) U, s, Vt = np.linalg.svd(Q, full_matrices=False)
coefficients = np.diag(s) @ Vt # (min_dim, N) coefficients = np.diag(s) @ Vt
return mean_field, U, s, coefficients return mean_field, U, s, coefficients
def cumulative_energy(singular_values: np.ndarray) -> np.ndarray: def cumulative_energy(singular_values: np.ndarray) -> np.ndarray:
"""Return cumulative energy fraction."""
e = singular_values ** 2 e = singular_values ** 2
return np.cumsum(e) / np.sum(e) return np.cumsum(e) / np.sum(e)
def e95_index(cumulative_energy: np.ndarray) -> int: def e95_index(cumulative_energy: np.ndarray) -> int:
"""Return first index where cumulative energy >= 95%."""
return int(np.searchsorted(cumulative_energy, 0.95) + 1) return int(np.searchsorted(cumulative_energy, 0.95) + 1)
@ -164,32 +116,24 @@ def e95_index(cumulative_energy: np.ndarray) -> int:
# CCD (reduced, Lyu23-inspired) # CCD (reduced, Lyu23-inspired)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def compute_reduced_ccd( def compute_reduced_ccd(pod_coeffs: np.ndarray, observable: np.ndarray, Q_delay: int = 12) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
pod_coeffs: np.ndarray,
observable: np.ndarray,
Q_delay: int = 12,
) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
"""Compute reduced CCD in POD coefficient space. """Compute reduced CCD in POD coefficient space.
Parameters Parameters
---------- ----------
pod_coeffs : (r, N) ndarray pod_coeffs : (r, N) ndarray standardized POD coefficients.
Standardized POD coefficients (r modes, N time steps). observable : (m, N) ndarray standardized observable.
observable : (m, N) ndarray Q_delay : int number of delay steps.
Standardized observable (m channels, N time steps).
Q_delay : int
Number of delay steps.
Returns Returns
------- -------
W : (r, min(r, m*Q_delay)) W : (r, min(r, m*Q_delay)) CCD directions.
sigma : (min_dim,) sigma : (min_dim,) singular values.
z : (min_dim, N) z : (min_dim, N) CCD temporal coefficients.
""" """
N = pod_coeffs.shape[1] N = pod_coeffs.shape[1]
m = observable.shape[0] m = observable.shape[0]
# Build delay matrix: for each time step, P includes Q_delay shifted versions
half = Q_delay // 2 half = Q_delay // 2
rows = [] rows = []
for shift in range(-half, half + 1): for shift in range(-half, half + 1):
@ -201,7 +145,7 @@ def compute_reduced_ccd(
rows.append(shifted) rows.append(shifted)
P = np.vstack(rows) # (m*Q_delay, N) P = np.vstack(rows) # (m*Q_delay, N)
# Standardize P and pod_coeffs rows (z-score) # Standardize
P_mean = np.mean(P, axis=1, keepdims=True) P_mean = np.mean(P, axis=1, keepdims=True)
P_std = np.std(P, axis=1, keepdims=True) + 1e-12 P_std = np.std(P, axis=1, keepdims=True) + 1e-12
P_z = (P - P_mean) / P_std P_z = (P - P_mean) / P_std
@ -210,32 +154,19 @@ def compute_reduced_ccd(
A_std = np.std(pod_coeffs, axis=1, keepdims=True) + 1e-12 A_std = np.std(pod_coeffs, axis=1, keepdims=True) + 1e-12
A_z = (pod_coeffs - A_mean) / A_std A_z = (pod_coeffs - A_mean) / A_std
# CCD matrix
C = P_z @ A_z.T / (N * np.sqrt(float(Q_delay))) C = P_z @ A_z.T / (N * np.sqrt(float(Q_delay)))
# SVD
R, s, Wt = np.linalg.svd(C, full_matrices=False) R, s, Wt = np.linalg.svd(C, full_matrices=False)
W = Wt.T # (r, min_dim) W = Wt.T
z = W.T @ A_z
# CCD coefficients
z = W.T @ A_z # (min_dim, N)
return W, s, z return W, s, z
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Stack velocity fields into snapshot matrix # Field stacking helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def stack_velocity_fields( def stack_velocity_fields(ux_fields: List[np.ndarray], uy_fields: List[np.ndarray]) -> np.ndarray:
ux_fields: List[np.ndarray], """Stack list of (ux, uy) field pairs into snapshot matrix (2*nx*ny, N)."""
uy_fields: List[np.ndarray],
) -> np.ndarray:
"""Stack list of (ux, uy) field pairs into snapshot matrix.
Each field is flattened, ux and uy are concatenated.
Returns (2*nx*ny, N) matrix.
"""
snapshots = [] snapshots = []
for ux, uy in zip(ux_fields, uy_fields): for ux, uy in zip(ux_fields, uy_fields):
q = np.concatenate([ux.ravel(), uy.ravel()]) q = np.concatenate([ux.ravel(), uy.ravel()])
@ -243,24 +174,8 @@ def stack_velocity_fields(
return np.column_stack(snapshots) return np.column_stack(snapshots)
def unstack_velocity_modes( def unstack_velocity_modes(modes: np.ndarray, ny: int, nx: int, n_modes: int = 6) -> Tuple[List[np.ndarray], List[np.ndarray]]:
modes: np.ndarray, ny: int, nx: int, n_modes: int = 6 """Unstack POD/CCD modes back into ux, uy fields."""
) -> Tuple[List[np.ndarray], List[np.ndarray]]:
"""Unstack POD/CCD modes back into ux, uy fields.
Parameters
----------
modes : (2*nx*ny, n_modes_total) ndarray
ny, nx : int
Grid dimensions.
n_modes : int
Number of modes to extract.
Returns
-------
ux_modes, uy_modes : list of ndarray
Each element is (ny, nx).
"""
ux_list, uy_list = [], [] ux_list, uy_list = [], []
half = nx * ny half = nx * ny
for i in range(min(n_modes, modes.shape[1])): for i in range(min(n_modes, modes.shape[1])):
@ -268,3 +183,43 @@ def unstack_velocity_modes(
ux_list.append(mode[:half].reshape(ny, nx)) ux_list.append(mode[:half].reshape(ny, nx))
uy_list.append(mode[half:].reshape(ny, nx)) uy_list.append(mode[half:].reshape(ny, nx))
return ux_list, uy_list return ux_list, uy_list
# ---------------------------------------------------------------------------
# Harmonics analysis for illusion
# ---------------------------------------------------------------------------
def analyze_harmonics(states: np.ndarray, n_harmonics: int = 5) -> list:
"""FFT harmonics analysis. Returns list of dicts per channel."""
N, D = states.shape
result = []
for d in range(D):
y = states[:, d]
fft_coef = np.fft.rfft(y)
freqs = np.fft.rfftfreq(N, d=1)
amps = 2.0 * np.abs(fft_coef) / N
phases = np.angle(fft_coef)
idx = np.argsort(amps[1:])[::-1][:n_harmonics] + 1
harmonics = {
'dc': float(np.real(fft_coef[0]) / N),
'amps': amps[idx].tolist(),
'freqs': freqs[idx].tolist(),
'phases': phases[idx].tolist(),
}
result.append(harmonics)
return result
def gen_target_states_at(t, harmonics):
"""Reconstruct target observable at step index t from harmonics."""
t = np.asarray(t)
D = len(harmonics)
result = np.zeros((t.size, D), dtype=np.float32)
for d, h in enumerate(harmonics):
val = np.full(t.shape, h['dc'], dtype=np.float32)
for amp, freq, phase in zip(h['amps'], h['freqs'], h['phases']):
val += amp * np.cos(2 * np.pi * freq * t + phase)
result[:, d] = val
if result.shape[0] == 1:
return result[0]
return result

289
src/SR_analysis/README.md Normal file
View File

@ -0,0 +1,289 @@
# SR_analysis: Unified SINDy-SR Analysis Pipeline
## Overview
This directory consolidates the SINDy-and-symbolic-regression analysis pipeline
for the DynamisLab fluidic pinball project. It replaces the old
`src/analysis_crossre/` and `src/analysis_cloak/` directories with a unified
structure.
The pipeline fits **sparse interpretable control laws** (`obs -> act`) for all
cloak and illusion scenes, using dimensionless physical features,
G-equivariant structural constraints, and STLSQ threshold grids.
For background, see:
- `src/sindy_sr_notes.md` -- execution plan
- `src/sindy_sr_knoeledge.md` -- confirmed facts and known pitfalls
## Directory Structure
```
SR_analysis/
configs.py # Unified scene metadata (all 10 scenes)
configs/
legacy/ # Legacy CFD configs (config_cuda.json, config_flowfield.json)
utils/
__init__.py # Selective exports (no pycuda dependency)
feature_builder.py # Dimensionless features + G-operator (from analysis_cloak)
sindy_fitter.py # STLSQ threshold grid, feature matrix builder
cfd_interface.py # LegacyCelerisLab wrapper (requires pycuda_3_10)
g_operator.py # Equivariance diagnostics
data/
karman/ # Karman cloak: karman_re50, re100, re200, re400
steady/ # Steady cloak: steady_data.npz
illusion/ # Illusion: illusion_0.75L, illusion_1L, illusion_1.5L
vortex/ # Vortex cloak: vortex_lamb, vortex_taylor
scripts/
infer_karman.py # Inference: LegacyCFD + PPO -> controlled.npz
infer_illusion.py # Inference: for 0.75L, 1L, 1.5L diameters
infer_vortex.py # Inference: for Lamb dipole + Taylor monopole
sindy/
run_karman.py # SINDy fitting for Karman scenes
run_illusion.py # SINDy fitting for Illusion scenes
run_vortex.py # SINDy fitting for Vortex scenes
run_pareto.py # Pareto-front analysis from SINDy results
karman/ # Output: sindy_results.json, pareto_*.json
illusion/ # Output: sindy_results.json, pareto_*.json
vortex/ # Output: sindy_results.json, pareto_*.json
validate/
run_closed_loop.py # Unified closed-loop validator (v23 + unstructured modes)
compare/
support_overlap.py # Pairwise support set comparison
shared_core.py # Multi-scene shared-core detection
```
## Key Design Decisions
### 1. Scene Metadata Driven
All scene parameters (Re, action scaling, geometry, model paths) are defined
once in `configs.py`, not hard-coded in scripts. Adding a new scene means
adding one dict to `configs.py`.
### 2. Data / Features / Models Separation
- `data/` -- raw sensor/force/action arrays (.npz), one-time generation
- `sindy/` -- SINDy fitting results (JSON), reusable for comparison
- `scripts/` -- inference pipelines that produce `data/`
### 3. Unified Feature Builder
Every scene uses the same `utils/feature_builder.py`, which produces
21 dimensionless features from raw lattice-unit sensor/force data:
**Sensor features (nondim):**
- `u_m`, `u_a`, `u_c` -- streamwise: mean, antisymmetric, centre
- `v_a` -- antisymmetric cross-stream
- `sin_ua`, `cos_ua` -- phase encoding via u_a
**Force features (Cd/Cl):**
- `Cd_tot`, `Cd_rear` -- total and rear-cylinder drag
- `Cl_tot`, `Cl_diff` -- total and differential lift
**Memory features (nondim alpha):**
- `aF_lag1`, `aB_lag1`, `aT_lag1` -- lagged actions (t-1)
- `daF`, `daB`, `daT` -- action increments (t-1)-(t-2)
**Reynolds modulation:**
- `mu` (= 1/Re_D), `mu_u_a`, `mu_v_a`, `mu_Cd_tot`, `mu_Cl_diff`
### 4. G-Equivariant Structure (v23)
Default control law structure (confirmed as the best v23 model):
```
Front(t) = f_front(x(t)) # no bias, odd under G
Top(t) = f_rear(x(t)) # with bias
Bottom(t) = -f_rear(G[x(t)]) # shared-head: bottom = -top(Gx)
```
Where G is the mirror operator (y -> -y) with corrected sign rules:
- `[aF, aT, aB] -> [-aF, -aB, -aT]`
- Sensor swap: top <-> bottom, negate v
- Force swap: front unchanged, bottom <-> top, negate Cl
### 5. STLSQ Threshold Grid
Default thresholds: `[0, 0.001, 0.002, 0.005, 0.01, 0.015, 0.02, 0.03, 0.05, 0.1]`
Per-channel: front (no bias), top (shared-head), bottom (independent, for comparison)
## Scene Inventory
| Scene Name | Description | Re_code | Sample Interval | Action | U0 |
|---|---|---|---|---|---|
| karman_re50 | Karman cloak at low Re | 50 | 800 | 8x + [0,-4,4] | 0.01 |
| karman_re100 | Karman cloak (default) | 100 | 800 | 8x + [0,-4,4] | 0.01 |
| karman_re200 | Karman cloak at high Re | 200 | 800 | 8x + [0,-4,4] | 0.01 |
| karman_re400 | Karman cloak at highest Re | 400 | 800 | 8x + [0,-4,4] | 0.01 |
| steady | Open-loop constant rotation | 100 | 800 | 8x + [0,-5.1,5.1] | 0.01 |
| illusion_0.75L | Imitate 0.75D cylinder | 100 | 600 | 8x + [0,-2,2] | 0.01 |
| illusion_1L | Imitate 1.0D cylinder | 100 | 600 | 8x + [0,-2,2] | 0.01 |
| illusion_1.5L | Imitate 1.5D cylinder | 100 | 600 | 8x + [0,-2,2] | 0.02 |
| vortex_lamb | Cloak Lamb dipole | 100 | 800 | 4x + [0,-4,4] | 0.01 |
| vortex_taylor | Cloak Taylor monopole | 100 | 800 | 4x + [0,-4,4] | 0.01 |
Note: "Re_code" uses reference length 2*D (code convention).
Physical Re_D = Re_code / 2. E.g. Re_code=100 -> Re_D=50.
## Re-generation Commands
All commands run from repo root (`/home/frank14f/DynamisLab`).
### Data Generation (requires GPU, pycuda_3_10 env)
```bash
# Karman cloak -- all 4 training Re
conda run -n pycuda_3_10 python src/SR_analysis/scripts/infer_karman.py --re all --device 0
# Karman cloak -- single Re
conda run -n pycuda_3_10 python src/SR_analysis/scripts/infer_karman.py --re 100 --device 0 --steps 200
# Illusion -- all 3 diameters
conda run -n pycuda_3_10 python src/SR_analysis/scripts/infer_illusion.py --diameter all --device 0
# Vortex -- both types
conda run -n pycuda_3_10 python src/SR_analysis/scripts/infer_vortex.py --type all --device 0
```
### SINDy Fitting (no GPU needed, pycuda_3_10 env for pysindy)
```bash
conda run -n pycuda_3_10 python src/SR_analysis/sindy/run_karman.py
conda run -n pycuda_3_10 python src/SR_analysis/sindy/run_illusion.py
conda run -n pycuda_3_10 python src/SR_analysis/sindy/run_vortex.py
```
### Pareto Analysis (no GPU, no conda needed)
```bash
python3 src/SR_analysis/sindy/run_pareto.py --scene karman_re100
python3 src/SR_analysis/sindy/run_pareto.py --scene illusion_1L
```
### Closed-loop Validation (requires GPU)
```bash
conda run -n pycuda_3_10 python src/SR_analysis/validate/run_closed_loop.py \
--scene karman_re70 --device 2 \
--sindy-results src/SR_analysis/sindy/karman/sindy_results.json
# With custom mode
conda run -n pycuda_3_10 python src/SR_analysis/validate/run_closed_loop.py \
--scene karman_re70 --device 2 --mode unstructured
```
### Cross-scene Comparison (no GPU)
```bash
# Pairwise support overlap
python3 src/SR_analysis/compare/support_overlap.py \
--sindy-results src/SR_analysis/sindy/karman/sindy_results.json \
--scenes karman_re100 illusion_1L
# Multi-scene shared core
python3 src/SR_analysis/compare/shared_core.py \
--sindy-results src/SR_analysis/sindy/karman/sindy_results.json \
--scenes karman_re50 karman_re100 karman_re200 karman_re400
```
## Key Results Summary
### Data Quality (similarity scores)
| Scene | PPO Similarity |
|---|---|
| karman_re50 | 0.962 |
| karman_re100 | 0.954 |
| karman_re200 | 0.884 |
| karman_re400 | 0.795 (inferred, not verified) |
| vortex_lamb | 0.942 |
| vortex_taylor | 0.916 |
| illusion_1L | ~0.55 (metric not directly comparable) |
### SINDy Fit Quality (R2 scores for one-step prediction)
| Scene | Front | Top (shared) | Bottom |
|---|---|---|---|
| karman_re50 | 0.998 | 0.989 | 0.996 |
| karman_re100 | 0.995 | 0.993 | 0.997 |
| karman_re200 | 0.957 | 0.914 | 0.918 |
| karman_re400 | 0.991 | 0.979 | 0.969 |
| illusion_0.75L | 0.991 | 0.989 | 0.990 |
| illusion_1L | 0.979 | 0.984 | 0.984 |
| illusion_1.5L | 0.959 | 0.928 | 0.932 |
| vortex_lamb | 0.904 | 0.980 | 0.933 |
| vortex_taylor | 0.960 | 0.810 | 0.643 |
### Shared Core Features
**Karman cross-Re (active in all re50/100/200):**
- Front core: `mu`, `mu_Cd_tot`, `mu_Cl_diff`, `mu_v_a` (mu-modulated terms dominate)
- Top core: `Cl_tot`, `bias`, `mu_Cd_tot`, `mu_Cl_diff`, `mu_u_a`, `mu_v_a`
- Scene-specific: lower-Re scenes have additional `Cd_tot`, `Cl_diff`, `aT_lag1` etc.
**Illusion cross-diameter (active in all 0.75L/1L/1.5L):**
- Front core: `mu`, `mu_Cd_tot`, `mu_Cl_diff` (same structure as Karman front!)
- Top core: `Cd_rear`, `Cl_tot`, `bias`, `mu_Cd_tot`, `mu_Cl_diff`
- This suggests a **shared mu-modulated feedback structure** exists across both scenes
## Known Issues and Caveats
1. **Vortex Taylor rear channels** have low R2 (0.64-0.81). The weak monopole
produces near-zero rear action, making SINDy fitting noisy. Use Lamb as the
primary vortex reference.
2. **Closed-loop validator** (`validate/run_closed_loop.py`) has been ported but
NOT yet tested end-to-end. The original `validate_v23.py` verified Karman
but the new unified version has not been run.
3. **Illusion similarity scores** use the Karman CONV_LEN=30 metric, giving
lower raw numbers. The controlled.npz data itself is valid for SINDy.
4. **Steady cloak** is open-loop constant rotation, not PPO-derived. It serves
as a physical consistency check, not a primary comparison scene.
5. **SINDy one-step R2 is not sufficient** -- a high R2 does not guarantee good
closed-loop performance. Always validate via `validate/run_closed_loop.py`.
6. **Scene key naming**: keys like `illusion_1L`, `illusion_1.5L` use the short
float format from Python (1.0 -> "1L", 1.5 -> "1.5L", 0.75 -> "0.75L").
## Next Steps (Future Work)
1. **PySR symbolic regression** -- Run PySR on the SINDy-identified active
features (in `sr_env` conda env) to find closed-form formulas. Essential
reading: `src/pysr.md`.
2. **Closed-loop validation of all new scenes** -- Run
`validate/run_closed_loop.py` for illusion and vortex scenes using their
SINDy coefficients.
3. **Cross-scene shared backbone test** -- Fit a single SINDy model on merged
Karman + Illusion data, test if it performs on both.
4. **Time-scale explicit formulation** -- Make the sample interval an explicit
feature to compare control laws across different frequencies.
5. **Steady as consistency check** -- Validate that Karman-derived control laws
can reproduce the steady cloak result as a sanity check.
## File Reference
| File | Lines | Purpose |
|---|---|---|
| configs.py | ~205 | Unified scene metadata |
| utils/feature_builder.py | ~212 | Dimensionless features + G-op |
| utils/sindy_fitter.py | ~175 | STLSQ fitting, feature matrix builder |
| utils/cfd_interface.py | ~370 | LegacyCelerisLab wrapper |
| utils/g_operator.py | ~170 | Equivariance diagnostics |
| utils/__init__.py | ~10 | Selective exports |
| scripts/infer_karman.py | ~250 | Karman inference pipeline |
| scripts/infer_illusion.py | ~270 | Illusion inference pipeline |
| scripts/infer_vortex.py | ~280 | Vortex inference pipeline |
| sindy/run_karman.py | ~160 | Karman SINDy fitting |
| sindy/run_illusion.py | ~110 | Illusion SINDy fitting |
| sindy/run_vortex.py | ~110 | Vortex SINDy fitting |
| sindy/run_pareto.py | ~140 | Pareto analysis |
| validate/run_closed_loop.py | ~270 | Closed-loop validator |
| compare/support_overlap.py | ~150 | Pairwise support comparison |
| compare/shared_core.py | ~140 | Multi-scene shared core detection |

View File

@ -0,0 +1,185 @@
{
"scenes": [
"illusion_0.75L",
"illusion_1L",
"illusion_1.5L"
],
"threshold": 0.02,
"channels": {
"front": {
"n_scenes": 3,
"n_core": 3,
"core_features": {
"mu": {
"group": "mu_mod",
"coef": {
"mean": 6.124328107212669,
"std": 12.593845646937403,
"per_scene": {
"illusion_0.75L": -5.325014552992803,
"illusion_1L": 23.663898368547343,
"illusion_1.5L": 0.03410050608346959
}
}
},
"mu_Cd_tot": {
"group": "mu_mod",
"coef": {
"mean": 1.2528987158986185,
"std": 0.6780019496130162,
"per_scene": {
"illusion_0.75L": 0.852203136137665,
"illusion_1L": 2.2076417965839896,
"illusion_1.5L": 0.6988512149742004
}
}
},
"mu_Cl_diff": {
"group": "mu_mod",
"coef": {
"mean": -0.7229594207792337,
"std": 0.4999554272357686,
"per_scene": {
"illusion_0.75L": -0.23779821359027198,
"illusion_1L": -0.5201221350686689,
"illusion_1.5L": -1.4109579136787602
}
}
}
},
"scene_specific": {
"illusion_0.75L": [
"mu_v_a"
],
"illusion_1.5L": [
"Cd_rear",
"Cl_diff",
"Cl_tot",
"mu_u_a"
]
}
},
"top": {
"n_scenes": 3,
"n_core": 5,
"core_features": {
"Cd_rear": {
"group": "force",
"coef": {
"mean": -0.012196232003454469,
"std": 0.04820789952138249,
"per_scene": {
"illusion_0.75L": -0.06463454014637952,
"illusion_1L": 0.05175447708136916,
"illusion_1.5L": -0.02370863294535305
}
}
},
"Cl_tot": {
"group": "force",
"coef": {
"mean": 0.06996842377218454,
"std": 0.01752531021573054,
"per_scene": {
"illusion_0.75L": 0.05319377957208298,
"illusion_1L": 0.09415648101989169,
"illusion_1.5L": 0.06255501072457896
}
}
},
"bias": {
"group": "bias",
"coef": {
"mean": 0.50722496890139,
"std": 0.7941369244496769,
"per_scene": {
"illusion_0.75L": 1.5684596958510442,
"illusion_1L": 0.2949090708624881,
"illusion_1.5L": -0.3416938600093622
}
}
},
"mu_Cd_tot": {
"group": "mu_mod",
"coef": {
"mean": -0.5748401414253728,
"std": 1.3356390970379715,
"per_scene": {
"illusion_0.75L": -0.034745982702969365,
"illusion_1L": -2.4124080088752256,
"illusion_1.5L": 0.7226335673020766
}
}
},
"mu_Cl_diff": {
"group": "mu_mod",
"coef": {
"mean": -0.14655148885046754,
"std": 0.42192581541357527,
"per_scene": {
"illusion_0.75L": -0.7427999157338147,
"illusion_1L": 0.13162402418325947,
"illusion_1.5L": 0.1715214249991526
}
}
}
},
"scene_specific": {
"illusion_1.5L": [
"Cd_tot"
]
}
},
"bottom": {
"n_scenes": 3,
"n_core": 3,
"core_features": {
"bias": {
"group": "bias",
"coef": {
"mean": -0.07180166971995111,
"std": 0.08175050418248041,
"per_scene": {
"illusion_0.75L": 0.03345586870310438,
"illusion_1L": -0.16584728761620943,
"illusion_1.5L": -0.08301359024674829
}
}
},
"mu_Cd_tot": {
"group": "mu_mod",
"coef": {
"mean": 0.5765450851901482,
"std": 0.4820935221835714,
"per_scene": {
"illusion_0.75L": -0.03489998074530567,
"illusion_1L": 1.1434618843744861,
"illusion_1.5L": 0.6210733519412643
}
}
},
"mu_Cl_diff": {
"group": "mu_mod",
"coef": {
"mean": 0.1661185617369393,
"std": 0.34042296056983895,
"per_scene": {
"illusion_0.75L": 0.6070993919053495,
"illusion_1L": -0.22165490297479395,
"illusion_1.5L": 0.11291119628026249
}
}
}
},
"scene_specific": {
"illusion_0.75L": [
"aF_lag1",
"mu_u_a"
],
"illusion_1.5L": [
"daB"
]
}
}
}
}

View File

@ -0,0 +1,218 @@
{
"scenes": [
"karman_re50",
"karman_re100",
"karman_re200"
],
"threshold": 0.02,
"channels": {
"front": {
"n_scenes": 3,
"n_core": 4,
"core_features": {
"mu": {
"group": "mu_mod",
"coef": {
"mean": -0.2118470353646019,
"std": 1.3418924147536682,
"per_scene": {
"karman_re50": 0.5373930579297568,
"karman_re100": 0.9234972689284461,
"karman_re200": -2.0964314329520084
}
}
},
"mu_Cd_tot": {
"group": "mu_mod",
"coef": {
"mean": 0.40446115366058594,
"std": 0.2882660447342232,
"per_scene": {
"karman_re50": 0.21424605058608626,
"karman_re100": 0.18730338573081926,
"karman_re200": 0.8118340246648522
}
}
},
"mu_Cl_diff": {
"group": "mu_mod",
"coef": {
"mean": 0.49780940740664015,
"std": 0.4399793858420079,
"per_scene": {
"karman_re50": -0.12436843773164996,
"karman_re100": 0.8155192599082862,
"karman_re200": 0.8022774000432843
}
}
},
"mu_v_a": {
"group": "mu_mod",
"coef": {
"mean": -0.015356732494797251,
"std": 0.0636302488843807,
"per_scene": {
"karman_re50": -0.01474954032019323,
"karman_re100": 0.0622687182995332,
"karman_re200": -0.09358937546373172
}
}
}
},
"scene_specific": {}
},
"top": {
"n_scenes": 3,
"n_core": 6,
"core_features": {
"Cl_tot": {
"group": "force",
"coef": {
"mean": 0.052870132404981444,
"std": 0.03551999603386398,
"per_scene": {
"karman_re50": 0.005437814717321779,
"karman_re100": 0.09090888202182665,
"karman_re200": 0.0622637004757959
}
}
},
"bias": {
"group": "bias",
"coef": {
"mean": 0.5261511607510746,
"std": 0.3945210247055603,
"per_scene": {
"karman_re50": 0.009006304945500938,
"karman_re100": 0.9660827819346605,
"karman_re200": 0.6033643953730624
}
}
},
"mu_Cd_tot": {
"group": "mu_mod",
"coef": {
"mean": -1.0229551996731832,
"std": 0.6119923552005874,
"per_scene": {
"karman_re50": -0.25178195059980246,
"karman_re100": -1.0682906975825734,
"karman_re200": -1.7487929508371736
}
}
},
"mu_Cl_diff": {
"group": "mu_mod",
"coef": {
"mean": -0.3025183164705127,
"std": 1.1363636819618512,
"per_scene": {
"karman_re50": -0.25926759753886264,
"karman_re100": 1.0671077959431223,
"karman_re200": -1.7153951478157978
}
}
},
"mu_u_a": {
"group": "mu_mod",
"coef": {
"mean": -0.08031494383668124,
"std": 0.08982478076029217,
"per_scene": {
"karman_re50": 0.023534741132533694,
"karman_re100": -0.19559725673163528,
"karman_re200": -0.06888231591094213
}
}
},
"mu_v_a": {
"group": "mu_mod",
"coef": {
"mean": 0.030282322344902513,
"std": 0.13310118373865282,
"per_scene": {
"karman_re50": -0.08783423089354239,
"karman_re100": -0.03758555104936961,
"karman_re200": 0.21626674897761955
}
}
}
},
"scene_specific": {
"karman_re50": [
"Cd_tot",
"Cl_diff",
"aT_lag1"
],
"karman_re200": [
"cos_ua"
]
}
},
"bottom": {
"n_scenes": 3,
"n_core": 4,
"core_features": {
"bias": {
"group": "bias",
"coef": {
"mean": 0.07937251506166355,
"std": 0.3195026042260621,
"per_scene": {
"karman_re50": -0.05202015913990901,
"karman_re100": -0.22933046011719774,
"karman_re200": 0.5194681644420974
}
}
},
"mu_Cd_tot": {
"group": "mu_mod",
"coef": {
"mean": -0.8264381469826733,
"std": 0.9571803403036669,
"per_scene": {
"karman_re50": -0.03551514238315014,
"karman_re100": -0.2705206993407136,
"karman_re200": -2.1732785992241563
}
}
},
"mu_Cl_diff": {
"group": "mu_mod",
"coef": {
"mean": -0.18340550520310042,
"std": 0.23298894865543504,
"per_scene": {
"karman_re50": 0.08969580568091838,
"karman_re100": -0.16030801380832282,
"karman_re200": -0.4796043074818968
}
}
},
"mu_v_a": {
"group": "mu_mod",
"coef": {
"mean": 0.09749842111867539,
"std": 0.07771330943469408,
"per_scene": {
"karman_re50": 0.08216046763939658,
"karman_re100": 0.010919861645215943,
"karman_re200": 0.19941493407141364
}
}
}
},
"scene_specific": {
"karman_re50": [
"Cl_diff",
"daB",
"daF",
"mu",
"u_c",
"u_m",
"v_a"
]
}
}
}
}

View File

@ -0,0 +1,152 @@
"""Shared core detection across scenes.
Finds features that are active across ALL scenes in a group (e.g. all Karman Re,
all Illusion diameters) and identifies the cross-scene shared core.
Usage:
python compare/shared_core.py --sindy-results sindy/karman/sindy_results.json \\
--scenes karman_re50 karman_re100 karman_re200 karman_re400
python compare/shared_core.py \\
--sindy-results sindy/results.json \\
--scenes karman_re100 illusion_1L vortex_lamb steady
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from typing import Dict, List, Tuple
import numpy as np
_REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
if _REPO not in sys.path:
sys.path.insert(0, _REPO)
_SRC = os.path.join(_REPO, "src")
if _SRC not in sys.path:
sys.path.insert(0, _SRC)
from SR_analysis.utils.sindy_fitter import get_active_support
RELATIVE_THRESHOLD = 0.02
def feat_group(name: str) -> str:
if name == "bias":
return "bias"
if name in ("u_m", "u_a", "u_c", "v_a", "sin_ua", "cos_ua"):
return "sensor"
if name.startswith("Cd") or name.startswith("Cl"):
return "force"
if "lag1" in name:
return "memory_lag"
if name.startswith("da"):
return "memory_delta"
if name == "mu" or name.startswith("mu_"):
return "mu_mod"
return "other"
def detect_core(scene_data: Dict[str, dict], channels: List[str],
threshold: float) -> dict:
"""Find features active in ALL scenes for each channel."""
scene_names = list(scene_data.keys())
results = {}
for ch_name in channels:
fn_key = f"feature_names_{'front' if ch_name == 'front' else 'rear'}"
# Collect active sets per scene
active_per_scene = {}
for sn in scene_names:
fn = scene_data[sn][fn_key]
coef = scene_data[sn][ch_name]["best_coef"]
active = get_active_support(np.array(coef, dtype=np.float64)[:len(fn)],
fn, threshold)
active_per_scene[sn] = set(active.keys())
# Intersection = shared core
core_keys = set.intersection(*active_per_scene.values()) if active_per_scene else set()
# Union for scene-specific detection
all_keys = set.union(*active_per_scene.values()) if active_per_scene else set()
scene_specific = {}
for sn in scene_names:
others = set.union(*[v for k, v in active_per_scene.items() if k != sn])
diff = active_per_scene[sn] - others
if diff:
scene_specific[sn] = sorted(diff)
# Coef means for core features
core_coefs = {}
for k in sorted(core_keys):
vals = []
for sn in scene_names:
fn = scene_data[sn][fn_key]
coef = scene_data[sn][ch_name]["best_coef"]
if k in fn:
idx = fn.index(k)
vals.append(float(coef[idx]))
core_coefs[k] = {
"mean": float(np.mean(vals)),
"std": float(np.std(vals)),
"per_scene": {sn: vals[i] for i, sn in enumerate(scene_names)},
}
results[ch_name] = {
"n_scenes": len(scene_names),
"n_core": len(core_keys),
"core_features": {k: {"group": feat_group(k), "coef": v}
for k, v in core_coefs.items()},
"scene_specific": {sn: sorted(v) for sn, v in scene_specific.items()},
}
return results
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--sindy-results", type=str, required=True)
ap.add_argument("--scenes", type=str, nargs="+", required=True)
ap.add_argument("--threshold", type=float, default=RELATIVE_THRESHOLD)
ap.add_argument("--out", type=str, default=None)
args = ap.parse_args()
with open(args.sindy_results) as f:
all_data = json.load(f)
per = all_data.get("per_scene", {})
scene_data = {sn: per[sn] for sn in args.scenes if sn in per}
if len(scene_data) < 2:
print(f"Need >=2 scenes. Found: {list(scene_data.keys())}")
return 1
print(f"Shared Core Detection: {len(scene_data)} scenes")
for sn in scene_data:
print(f" {sn}")
print(f" threshold={args.threshold}")
results = detect_core(scene_data, ["front", "top", "bottom"], args.threshold)
for ch_name, ch_data in results.items():
print(f"\n--- {ch_name} ---")
print(f" Core features: {ch_data['n_core']}")
for k, v in ch_data["core_features"].items():
c = v["coef"]
print(f" {k:20s} mean={c['mean']:+.6f} std={c['std']:.6f} [{v['group']}]")
for sn, keys in ch_data["scene_specific"].items():
if keys:
print(f" {sn} specific: {', '.join(keys)}")
if args.out:
output = {"scenes": args.scenes, "threshold": args.threshold,
"channels": results}
os.makedirs(os.path.dirname(args.out), exist_ok=True)
with open(args.out, "w") as f:
json.dump(output, f, indent=2)
print(f"\nSaved: {args.out}")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,158 @@
"""Cross-scene support overlap analysis.
Compares SINDy support sets across scenes at a given relative threshold.
Usage:
python compare/support_overlap.py --sindy-results sindy/karman/sindy_results.json \\
--scenes karman_re100 karman_re200 illusion_1L vortex_lamb
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from typing import Dict, List, Tuple
import numpy as np
_REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
if _REPO not in sys.path:
sys.path.insert(0, _REPO)
_SRC = os.path.join(_REPO, "src")
if _SRC not in sys.path:
sys.path.insert(0, _SRC)
from SR_analysis.utils.sindy_fitter import get_active_support
RELATIVE_THRESHOLD = 0.02 # default: 2% of max coefficient
def load_sindy_scenes(sindy_path: str, scenes: List[str]) -> dict:
"""Load sindy results for the given scene names."""
with open(sindy_path) as f:
data = json.load(f)
result = {}
for sn in scenes:
per = data["per_scene"].get(sn)
if per is None:
print(f"WARNING: {sn} not found in {sindy_path}")
continue
result[sn] = per
return result
def feat_group(name: str) -> str:
"""Classify a feature into a group."""
if name == "bias":
return "bias"
if name in ("u_m", "u_a", "u_c", "v_a", "sin_ua", "cos_ua"):
return "sensor"
if name.startswith("Cd") or name.startswith("Cl"):
return "force"
if "lag1" in name:
return "memory_lag"
if name.startswith("da"):
return "memory_delta"
if name == "mu" or name.startswith("mu_"):
return "mu_mod"
return "other"
def classify(
a_active: Dict[str, float],
b_active: Dict[str, float],
) -> Tuple[List[Tuple[str, float, float]], List[Tuple[str, float]], List[Tuple[str, float]]]:
"""Classify features as shared, A-only, B-only.
Returns (shared, A_only, B_only) where shared has feature name + both coeffs.
"""
a_keys = set(a_active.keys())
b_keys = set(b_active.keys())
shared = sorted(a_keys & b_keys)
a_only = sorted(a_keys - b_keys)
b_only = sorted(b_keys - a_keys)
shared_out = [(k, a_active[k], b_active[k]) for k in shared]
a_out = [(k, a_active[k]) for k in a_only]
b_out = [(k, b_active[k]) for k in b_only]
return shared_out, a_out, b_out
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--sindy-results", type=str, required=True)
ap.add_argument("--scenes", type=str, nargs="+", required=True,
help="Scene names to compare")
ap.add_argument("--threshold", type=float, default=RELATIVE_THRESHOLD)
ap.add_argument("--channels", type=str, nargs="+",
default=["front", "top", "bottom"],
help="Which channels to compare")
ap.add_argument("--out", type=str, default=None)
args = ap.parse_args()
data = load_sindy_scenes(args.sindy_results, args.scenes)
if len(data) < 2:
print("Need at least 2 scenes to compare")
return 1
scene_names = list(data.keys())
scene_a, scene_b = scene_names[0], scene_names[1]
print(f"Support Overlap: {scene_a} vs {scene_b} (th={args.threshold})")
print("=" * 60)
all_results = {}
for ch_name in args.channels:
# Map "front" -> "feature_names_front", etc
fn_key = f"feature_names_{'front' if ch_name == 'front' else 'rear'}"
fn_a = data[scene_a][fn_key]
fn_b = data[scene_b][fn_key]
fn_min = min(len(fn_a), len(fn_b))
fn_a_trim = fn_a[:fn_min]
fn_b_trim = fn_b[:fn_min]
ch_a = get_active_support(np.array(data[scene_a][ch_name]["best_coef"])[:fn_min],
fn_a_trim, args.threshold)
ch_b = get_active_support(np.array(data[scene_b][ch_name]["best_coef"])[:fn_min],
fn_b_trim, args.threshold)
shared, a_only, b_only = classify(ch_a, ch_b)
print(f"\n--- {ch_name} ---")
print(f" {scene_a} nz={len(ch_a)} {scene_b} nz={len(ch_b)} Shared={len(shared)}")
for name, ca, cb in shared:
print(f" {name:20s} A={ca:+9.6f} B={cb:+9.6f} [{feat_group(name)}]")
for name, ca in a_only:
print(f" {scene_a[:10]:>10s} {name:20s} A={ca:+9.6f} [{feat_group(name)}]")
for name, cb in b_only:
print(f" {scene_b[:10]:>10s} {name:20s} B={cb:+9.6f} [{feat_group(name)}]")
all_results[ch_name] = {
"scene_a_nz": len(ch_a),
"scene_b_nz": len(ch_b),
"shared_nz": len(shared),
"shared": [{"name": n, "coef_a": ca, "coef_b": cb} for n, ca, cb in shared],
f"{scene_a}_only": [{"name": n, "coef": ca} for n, ca in a_only],
f"{scene_b}_only": [{"name": n, "coef": cb} for n, cb in b_only],
}
if args.out:
output = {"scene_a": scene_a, "scene_b": scene_b,
"threshold": args.threshold,
"channels": all_results}
os.makedirs(os.path.dirname(args.out), exist_ok=True)
with open(args.out, "w") as f:
json.dump(output, f, indent=2)
print(f"\nSaved: {args.out}")
if __name__ == "__main__":
main()

219
src/SR_analysis/configs.py Normal file
View File

@ -0,0 +1,219 @@
"""Unified scene configuration for SR_analysis.
All scene metadata in one place. Each scene dict contains all parameters
needed for data generation, SINDy fitting, and validation.
Re convention:
- "re_code" uses reference length 2*D (matching model file naming).
- mu = 1/Re_D = 2/re_code.
- Re_D = re_code / 2 is the true physical Reynolds number.
"""
from __future__ import annotations
import os
from typing import Any, Dict, List, Optional, Tuple
# -- Root paths (resolved when configs.py is imported) -----------------------
_PROJ = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
MODEL_DIR = os.path.join(_PROJ, "..", "models")
LEGACY_CFG_DIR = os.path.join(os.path.dirname(__file__), "configs", "legacy")
# -- Physics constants -------------------------------------------------------
U0 = 0.01 # default inlet velocity (lattice)
D_CYL = 20.0
D_REF = 40.0
L0 = 20.0
NX = 1280
NY = 512
CENTER_Y = (NY - 1) / 2.0
FIFO_LEN = 150
CONV_LEN = 30
def nu_from_re(re_code: float, u0: float = U0) -> float:
"""Viscosity from code Reynolds number (reference length = 2*D)."""
return u0 * D_REF / re_code
# -- Scene definitions -------------------------------------------------------
# Each scene dict has fields:
# scene_id, re_code, mu, nu, has_disturbance, sample_interval,
# action_scale, action_bias (tuple), source ("PPO_inference"|"open_loop"),
# model_name (str or None), n_objects_env, obs_slice [start,end],
# sensor_x, pinball_front_x, pinball_rear_x,
# target_type ("periodic"|"steady"|"transient"),
# s_dim (DRL observation dim, 12 or 14)
SCENES: Dict[str, Any] = {}
# -- Karman Cloak (cross-Re) ------------------------------------------------
for rc, mn in [(50, "d1a3o12_re50"), (100, "d1a3o12_re100"),
(200, "d1a3o12_re200"), (400, "d1a3o12_re400")]:
key = f"karman_re{rc}"
SCENES[key] = {
"scene_id": "karman",
"re_code": rc,
"mu": 2.0 / rc,
"nu": nu_from_re(rc),
"has_disturbance": True,
"sample_interval": 800,
"action_scale": 8.0,
"action_bias": (0.0, -4.0, 4.0),
"source": "PPO_inference",
"model_name": mn,
"n_objects_env": 7,
"obs_slice": (2, 14),
"sensor_x": 40.0,
"pinball_front_x": 30.0,
"pinball_rear_x": 31.3,
"target_type": "periodic",
"s_dim": 12,
"u0": U0,
}
# -- Steady Cloak (open-loop constant rotation) -----------------------------
SCENES["steady"] = {
"scene_id": "steady",
"re_code": 100,
"mu": 2.0 / 100,
"nu": nu_from_re(100),
"has_disturbance": False,
"sample_interval": 800,
"action_scale": 8.0,
"action_bias": (0.0, -5.1, 5.1), # from gen_steady_data.py defaults
"source": "open_loop",
"model_name": None,
"n_objects_env": 6,
"obs_slice": (0, 12),
"sensor_x": 40.0,
"pinball_front_x": 30.0,
"pinball_rear_x": 31.3,
"target_type": "steady",
"s_dim": 12,
"u0": U0,
}
# -- Illusion (cylinder imitation, 3 diameters, 1U=0.01) --------------------
def _illusion_key(diam: float) -> str:
"""Generate clean illusion scene key."""
s = f"{diam:.3f}".rstrip("0").rstrip(".")
return f"illusion_{s}L"
_ILLUSION_1U = [
(0.75, "d1a3o12_250525_imit_075L_1U"),
(1.0, "d1a3o12_250525_imit_1L_1U"),
]
for diam, mn in _ILLUSION_1U:
key = _illusion_key(diam)
SCENES[key] = {
"scene_id": "illusion",
"target_diameter": diam,
"re_code": 100,
"mu": 2.0 / 100,
"nu": nu_from_re(100),
"has_disturbance": False,
"sample_interval": 600,
"action_scale": 8.0,
"action_bias": (0.0, -2.0, 2.0),
"source": "PPO_inference",
"model_name": mn,
"n_objects_env": 6,
"obs_slice": (0, 12),
"sensor_x": 30.0,
"pinball_front_x": 19.0,
"pinball_rear_x": 20.3,
"target_type": "periodic",
"s_dim": 12,
"u0": U0,
}
# 1.5L Illusion (2U=0.02 model)
SCENES[_illusion_key(1.5)] = {
"scene_id": "illusion",
"target_diameter": 1.5,
"re_code": 100,
"mu": 2.0 / 100,
"nu": nu_from_re(100, u0=0.02),
"has_disturbance": False,
"sample_interval": 600,
"action_scale": 8.0,
"action_bias": (0.0, -2.0, 2.0),
"source": "PPO_inference",
"model_name": "d1a3o14_250525_imit_15L_2U",
"n_objects_env": 6,
"obs_slice": (0, 12),
"sensor_x": 30.0,
"pinball_front_x": 19.0,
"pinball_rear_x": 20.3,
"target_type": "periodic",
"s_dim": 14,
"u0": 0.02,
}
# -- Vortex Cloak (Lamb dipole + Taylor monopole) --------------------------
_SCENES_VORTEX = [
("lamb", "vortex_lamb", 0.5),
("taylor", "vortex_taylor", 0.03),
]
for vtype, mn, strength in _SCENES_VORTEX:
key = f"vortex_{vtype}"
SCENES[key] = {
"scene_id": "vortex",
"vortex_type": vtype,
"vortex_strength": strength,
"re_code": 100,
"mu": 2.0 / 100,
"nu": nu_from_re(100),
"has_disturbance": False,
"sample_interval": 800,
"max_steps": 150,
"action_scale": 4.0,
"action_bias": (0.0, -4.0, 4.0),
"source": "PPO_inference",
"model_name": mn,
"n_objects_env": 6,
"obs_slice": (0, 12),
"sensor_x": 40.0,
"pinball_front_x": 30.0,
"pinball_rear_x": 31.3,
"target_type": "transient",
"s_dim": 12,
"u0": U0,
}
# -- Utility helpers ---------------------------------------------------------
def get_scene(name: str) -> dict:
"""Return scene config dict by name. Raises KeyError if not found."""
if name not in SCENES:
raise KeyError(f"Unknown scene: {name}. Available: {list(SCENES.keys())}")
return dict(SCENES[name])
def get_scene_list(scene_id: Optional[str] = None) -> List[str]:
"""Return list of scene names, optionally filtered by scene_id."""
if scene_id is None:
return list(SCENES.keys())
return [k for k, v in SCENES.items() if v["scene_id"] == scene_id]
def model_path_for_scene(scene_name: str) -> Optional[str]:
"""Return absolute path to PPO model .zip file, or None."""
cfg = get_scene(scene_name)
mn = cfg.get("model_name")
if mn is None:
return None
# Check model directories in priority order
candidate_dirs = [
os.path.join(MODEL_DIR, "old"),
os.path.join(MODEL_DIR, "250525"),
os.path.join(MODEL_DIR, "250729"),
os.path.join(MODEL_DIR, "250326"),
]
for d in candidate_dirs:
p = os.path.join(d, f"{mn}.zip")
if os.path.isfile(p):
return p
return None

View File

@ -0,0 +1,21 @@
{
"scene_id": "illusion",
"target_diameter": 0.75,
"re_code": 100,
"mu": 0.02,
"nu": 0.004,
"has_disturbance": false,
"sample_interval": 600,
"action_scale": 8.0,
"action_bias": "(0.0, -2.0, 2.0)",
"source": "PPO_inference",
"model_name": "d1a3o12_250525_imit_075L_1U",
"n_objects_env": 6,
"obs_slice": "(0, 12)",
"sensor_x": 30.0,
"pinball_front_x": 19.0,
"pinball_rear_x": 20.3,
"target_type": "periodic",
"s_dim": 12,
"u0": 0.01
}

View File

@ -0,0 +1,24 @@
{
"force_norm_fact": 0.013476977124810219,
"sens_deviation": [
0.962617814540863,
-0.12039308249950409,
0.6415857672691345,
0.011103342287242413,
0.9339056611061096,
0.11935960501432419
],
"sens_norm_fact": [
2.0483264923095703,
2.5809006690979004,
0.7443606853485107,
3.4969263076782227,
2.1811583042144775,
2.5745153427124023
],
"action_bias": [
0.0,
-2.0,
2.0
]
}

View File

@ -0,0 +1,5 @@
{
"scene": "illusion_0.75L",
"controlled": true,
"similarity": 0.18393706196948187
}

View File

@ -0,0 +1,21 @@
{
"scene_id": "illusion",
"target_diameter": 1.5,
"re_code": 100,
"mu": 0.02,
"nu": 0.008,
"has_disturbance": false,
"sample_interval": 600,
"action_scale": 8.0,
"action_bias": "(0.0, -2.0, 2.0)",
"source": "PPO_inference",
"model_name": "d1a3o14_250525_imit_15L_2U",
"n_objects_env": 6,
"obs_slice": "(0, 12)",
"sensor_x": 30.0,
"pinball_front_x": 19.0,
"pinball_rear_x": 20.3,
"target_type": "periodic",
"s_dim": 14,
"u0": 0.02
}

View File

@ -0,0 +1,24 @@
{
"force_norm_fact": 0.054184265434741974,
"sens_deviation": [
1.9146838188171387,
-0.23843951523303986,
1.305143117904663,
0.0009254463366232812,
1.8709181547164917,
0.2255263477563858
],
"sens_norm_fact": [
4.165492057800293,
5.171088695526123,
1.5217405557632446,
6.904999732971191,
4.384937763214111,
5.106513023376465
],
"action_bias": [
0.0,
-2.0,
2.0
]
}

View File

@ -0,0 +1,5 @@
{
"scene": "illusion_15L",
"controlled": true,
"similarity": 0.30179914651024675
}

View File

@ -0,0 +1,21 @@
{
"scene_id": "illusion",
"target_diameter": 1.0,
"re_code": 100,
"mu": 0.02,
"nu": 0.004,
"has_disturbance": false,
"sample_interval": 600,
"action_scale": 8.0,
"action_bias": "(0.0, -2.0, 2.0)",
"source": "PPO_inference",
"model_name": "d1a3o12_250525_imit_1L_1U",
"n_objects_env": 6,
"obs_slice": "(0, 12)",
"sensor_x": 30.0,
"pinball_front_x": 19.0,
"pinball_rear_x": 20.3,
"target_type": "periodic",
"s_dim": 12,
"u0": 0.01
}

View File

@ -0,0 +1,24 @@
{
"force_norm_fact": 0.013487594202160835,
"sens_deviation": [
0.9391862154006958,
-0.09573575109243393,
0.6525679230690002,
0.03420928493142128,
0.949753999710083,
0.13210755586624146
],
"sens_norm_fact": [
2.1654911041259766,
2.459106683731079,
0.7431825995445251,
3.613541603088379,
2.1102607250213623,
2.6434059143066406
],
"action_bias": [
0.0,
-2.0,
2.0
]
}

View File

@ -0,0 +1,5 @@
{
"scene": "illusion_1.0L",
"controlled": true,
"similarity": 0.5543174409436081
}

View File

@ -0,0 +1,20 @@
{
"scene_id": "karman",
"re_code": 100,
"mu": 0.02,
"nu": 0.004,
"has_disturbance": true,
"sample_interval": 800,
"action_scale": 8.0,
"action_bias": "(0.0, -4.0, 4.0)",
"source": "PPO_inference",
"model_name": "d1a3o12_re100",
"n_objects_env": 7,
"obs_slice": "(2, 14)",
"sensor_x": 40.0,
"pinball_front_x": 30.0,
"pinball_rear_x": 31.3,
"target_type": "periodic",
"s_dim": 12,
"u0": 0.01
}

View File

@ -0,0 +1,24 @@
{
"force_norm_fact": 0.019199129194021225,
"sens_deviation": [
0.8231719732284546,
-0.12661591172218323,
0.24832786619663239,
-0.01064519677311182,
0.7844515442848206,
0.1161285787820816
],
"sens_norm_fact": [
3.3014419078826904,
3.2062995433807373,
1.8544995784759521,
3.4928226470947266,
3.1099960803985596,
2.815072774887085
],
"action_bias": [
0.0,
-4.0,
4.0
]
}

View File

@ -0,0 +1,6 @@
{
"scene": "karman_re100",
"controlled": true,
"avg_reward_last100": 0.6665451352155793,
"similarity": 0.9538050162761162
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

View File

@ -0,0 +1,20 @@
{
"scene_id": "karman",
"re_code": 200,
"mu": 0.01,
"nu": 0.002,
"has_disturbance": true,
"sample_interval": 800,
"action_scale": 8.0,
"action_bias": "(0.0, -4.0, 4.0)",
"source": "PPO_inference",
"model_name": "d1a3o12_re200",
"n_objects_env": 7,
"obs_slice": "(2, 14)",
"sensor_x": 40.0,
"pinball_front_x": 30.0,
"pinball_rear_x": 31.3,
"target_type": "periodic",
"s_dim": 12,
"u0": 0.01
}

View File

@ -0,0 +1,24 @@
{
"force_norm_fact": 0.02405486349016428,
"sens_deviation": [
0.761349618434906,
-0.10393908619880676,
-0.0060332342982292175,
-0.01062991376966238,
0.7603892087936401,
0.08925710618495941
],
"sens_norm_fact": [
2.458379030227661,
2.4950430393218994,
0.986889123916626,
2.259662389755249,
2.737121820449829,
2.521576404571533
],
"action_bias": [
0.0,
-4.0,
4.0
]
}

View File

@ -0,0 +1,6 @@
{
"scene": "karman_re200",
"controlled": true,
"avg_reward_last100": 0.3298547239579881,
"similarity": 0.8842106151498026
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 KiB

View File

@ -0,0 +1,10 @@
{
"re_code": 400,
"nu": 0.001,
"u0": 0.01,
"sample_interval": 800,
"fifo_len": 150,
"conv_len": 30,
"device_id": 2,
"model_path": "/home/frank14f/DynamisLab/models/old/d1a3o12_re400.zip"
}

View File

@ -0,0 +1,24 @@
{
"force_norm_fact": 0.030264523811638355,
"sens_deviation": [
0.8747888207435608,
-0.024021463468670845,
0.5912007689476013,
0.017280835658311844,
0.9475194215774536,
0.07682034373283386
],
"sens_norm_fact": [
5.08115291595459,
5.131664276123047,
3.3446834087371826,
5.320921897888184,
4.4046711921691895,
5.202882766723633
],
"action_bias": [
0.0,
-4.0,
4.0
]
}

View File

@ -0,0 +1,7 @@
{
"re_code": 400,
"uncontrolled": true,
"controlled": true,
"avg_reward_last100": 0.4389868174760177,
"similarity": 0.7950085552241137
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 274 KiB

View File

@ -0,0 +1,20 @@
{
"scene_id": "karman",
"re_code": 50,
"mu": 0.04,
"nu": 0.008,
"has_disturbance": true,
"sample_interval": 800,
"action_scale": 8.0,
"action_bias": "(0.0, -4.0, 4.0)",
"source": "PPO_inference",
"model_name": "d1a3o12_re50",
"n_objects_env": 7,
"obs_slice": "(2, 14)",
"sensor_x": 40.0,
"pinball_front_x": 30.0,
"pinball_rear_x": 31.3,
"target_type": "periodic",
"s_dim": 12,
"u0": 0.01
}

View File

@ -0,0 +1,24 @@
{
"force_norm_fact": 0.015911692287772894,
"sens_deviation": [
0.6078279614448547,
-0.04162348061800003,
0.07135022431612015,
-0.0008823801181279123,
0.6027681827545166,
0.0418982058763504
],
"sens_norm_fact": [
0.789494514465332,
1.1795930862426758,
0.18662318587303162,
1.1806247234344482,
0.8472481369972229,
1.2091511487960815
],
"action_bias": [
0.0,
-4.0,
4.0
]
}

View File

@ -0,0 +1,6 @@
{
"scene": "karman_re50",
"controlled": true,
"avg_reward_last100": 0.5020737935656798,
"similarity": 0.9614716421942122
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

View File

@ -0,0 +1,23 @@
{
"scene_id": "vortex",
"vortex_type": "lamb",
"vortex_strength": 0.5,
"re_code": 100,
"mu": 0.02,
"nu": 0.004,
"has_disturbance": false,
"sample_interval": 800,
"max_steps": 150,
"action_scale": 4.0,
"action_bias": "(0.0, -4.0, 4.0)",
"source": "PPO_inference",
"model_name": "vortex_lamb",
"n_objects_env": 6,
"obs_slice": "(0, 12)",
"sensor_x": 40.0,
"pinball_front_x": 30.0,
"pinball_rear_x": 31.3,
"target_type": "transient",
"s_dim": 12,
"u0": 0.01
}

View File

@ -0,0 +1,24 @@
{
"force_norm_fact": 0.032297031953930855,
"sens_deviation": [
0.8963788747787476,
-0.10795877873897552,
-0.014931724406778812,
1.4363668924488593e-05,
0.8963659405708313,
0.10797087103128433
],
"sens_norm_fact": [
1.4594829082489014,
1.0411686897277832,
5.520665168762207,
0.0022848353255540133,
1.4571585655212402,
1.0411614179611206
],
"action_bias": [
0.0,
-4.0,
4.0
]
}

View File

@ -0,0 +1,5 @@
{
"scene": "vortex_lamb",
"controlled": true,
"similarity": 0.9421189523977421
}

View File

@ -0,0 +1,23 @@
{
"scene_id": "vortex",
"vortex_type": "taylor",
"vortex_strength": 0.03,
"re_code": 100,
"mu": 0.02,
"nu": 0.004,
"has_disturbance": false,
"sample_interval": 800,
"max_steps": 150,
"action_scale": 4.0,
"action_bias": "(0.0, -4.0, 4.0)",
"source": "PPO_inference",
"model_name": "vortex_taylor",
"n_objects_env": 6,
"obs_slice": "(0, 12)",
"sensor_x": 40.0,
"pinball_front_x": 30.0,
"pinball_rear_x": 31.3,
"target_type": "transient",
"s_dim": 12,
"u0": 0.01
}

View File

@ -0,0 +1,24 @@
{
"force_norm_fact": 0.03310442715883255,
"sens_deviation": [
0.9501951336860657,
-0.15176598727703094,
0.49544230103492737,
-0.009104072116315365,
1.0117771625518799,
0.1462564468383789
],
"sens_norm_fact": [
4.190938949584961,
2.9906840324401855,
3.5891337394714355,
4.104613780975342,
3.012289047241211,
3.3520655632019043
],
"action_bias": [
0.0,
-4.0,
4.0
]
}

View File

@ -0,0 +1,5 @@
{
"scene": "vortex_taylor",
"controlled": true,
"similarity": 0.9158487825490536
}

842
src/SR_analysis/pysr.md Normal file
View File

@ -0,0 +1,842 @@
# Toy Examples with Code
## Preamble
```python
import numpy as np
from pysr import *
```
## 1. Simple search
Here's a simple example where we
find the expression `2 cos(x3) + x0^2 - 2`.
```python
X = 2 * np.random.randn(100, 5)
y = 2 * np.cos(X[:, 3]) + X[:, 0] ** 2 - 2
model = PySRRegressor(binary_operators=["+", "-", "*", "/"])
model.fit(X, y)
print(model)
```
## 2. Custom operator
Here, we define a custom operator and use it to find an expression:
```python
X = 2 * np.random.randn(100, 5)
y = 1 / X[:, 0]
model = PySRRegressor(
binary_operators=["+", "*"],
unary_operators=["inv(x) = 1/x"],
extra_sympy_mappings={"inv": lambda x: 1/x},
)
model.fit(X, y)
print(model)
```
## 3. Multiple outputs
Here, we do the same thing, but with multiple expressions at once,
each requiring a different feature.
```python
X = 2 * np.random.randn(100, 5)
y = 1 / X[:, [0, 1, 2]]
model = PySRRegressor(
binary_operators=["+", "*"],
unary_operators=["inv(x) = 1/x"],
extra_sympy_mappings={"inv": lambda x: 1/x},
)
model.fit(X, y)
```
## 4. Plotting an expression
For now, let's consider the expressions for output 0.
We can see the LaTeX version of this with:
```python
model.latex()[0]
```
or output 1 with `model.latex()[1]`.
Let's plot the prediction against the truth:
```python
from matplotlib import pyplot as plt
plt.scatter(y[:, 0], model.predict(X)[:, 0])
plt.xlabel('Truth')
plt.ylabel('Prediction')
plt.show()
```
Which gives us:
![Truth vs Prediction](/images/example_plot.png)
We may also plot the output of a particular expression
by passing the index of the expression to `predict` (or
`sympy` or `latex` as well)
## 5. Feature selection
PySR and evolution-based symbolic regression in general performs
very poorly when the number of features is large.
Even, say, 10 features might be too much for a typical equation search.
If you are dealing with high-dimensional data with a particular type of structure,
you might consider using deep learning to break the problem into
smaller "chunks" which can then be solved by PySR, as explained in the paper
[2006.11287](https://arxiv.org/abs/2006.11287).
For tabular datasets, this is a bit trickier. Luckily, PySR has a built-in feature
selection mechanism. Simply declare the parameter `select_k_features=5`, for selecting
the most important 5 features.
Here is an example. Let's say we have 30 input features and 300 data points, but only 2
of those features are actually used:
```python
X = np.random.randn(300, 30)
y = X[:, 3]**2 - X[:, 19]**2 + 1.5
```
Let's create a model with the feature selection argument set up:
```python
model = PySRRegressor(
binary_operators=["+", "-", "*", "/"],
unary_operators=["exp"],
select_k_features=5,
)
```
Now let's fit this:
```python
model.fit(X, y)
```
Before the Julia backend is launched, you can see the string:
```text
Using features ['x3', 'x5', 'x7', 'x19', 'x21']
```
which indicates that the feature selection (powered by a gradient-boosting tree)
has successfully selected the relevant two features.
This fit should find the solution quickly, whereas with the huge number of features,
it would have struggled.
This simple preprocessing step is enough to simplify our tabular dataset,
but again, for more structured datasets, you should try the deep learning
approach mentioned above.
## 6. Denoising
Many datasets, especially in the observational sciences,
contain intrinsic noise. PySR is noise robust itself, as it is simply optimizing a loss function,
but there are still some additional steps you can take to reduce the effect of noise.
One thing you could do, which we won't detail here, is to create a custom log-likelihood
given some assumed noise model. By passing weights to the fit function, and
defining a custom loss function such as `elementwise_loss="myloss(x, y, w) = w * (x - y)^2"`,
you can define any sort of log-likelihood you wish. (However, note that it must be bounded at zero)
However, the simplest thing to do is preprocessing, just like for feature selection. To do this,
set the parameter `denoise=True`. This will fit a Gaussian process (containing a white noise kernel)
to the input dataset, and predict new targets (which are assumed to be denoised) from that Gaussian process.
For example:
```python
X = np.random.randn(100, 5)
noise = np.random.randn(100) * 0.1
y = np.exp(X[:, 0]) + X[:, 1] + X[:, 2] + noise
```
Let's create and fit a model with the denoising argument set up:
```python
model = PySRRegressor(
binary_operators=["+", "-", "*", "/"],
unary_operators=["exp"],
denoise=True,
)
model.fit(X, y)
print(model)
```
If all goes well, you should find that it predicts the correct input equation, without the noise term!
## 7. Julia packages and types
PySR uses [SymbolicRegression.jl](https://github.com/MilesCranmer/SymbolicRegression.jl)
as its search backend. This is a pure Julia package, and so can interface easily with any other
Julia package.
For some tasks, it may be necessary to load such a package.
For example, let's say we wish to discovery the following relationship:
$$ y = p_{3x + 1} - 5, $$
where $p_i$ is the $i$th prime number, and $x$ is the input feature.
Let's see if we can discover this using
the [Primes.jl](https://github.com/JuliaMath/Primes.jl) package.
First, let's get the Julia backend:
```python
from pysr import jl
```
`jl` stores the Julia runtime.
Now, let's run some Julia code to add the Primes.jl
package to the PySR environment:
```python
jl.seval("""
import Pkg
Pkg.add("Primes")
""")
```
This imports the Julia package manager, and uses it to install
`Primes.jl`. Now let's import `Primes.jl`:
```python
jl.seval("import Primes")
```
Now, we define a custom operator:
```python
jl.seval("""
function p(i::T) where T
if (0.5 < i < 1000)
return T(Primes.prime(round(Int, i)))
else
return T(NaN)
end
end
""")
```
We have created a a function `p`, which takes an arbitrary number as input.
`p` first checks whether the input is between 0.5 and 1000.
If out-of-bounds, it returns `NaN`.
If in-bounds, it rounds it to the nearest integer, compures the corresponding prime number, and then
converts it to the same type as input.
Next, let's generate a list of primes for our test dataset.
Since we are using juliacall, we can just call `p` directly to do this:
```python
primes = {i: jl.p(i*1.0) for i in range(1, 999)}
```
Next, let's use this list of primes to create a dataset of $x, y$ pairs:
```python
import numpy as np
X = np.random.randint(0, 100, 100)[:, None]
y = [primes[3*X[i, 0] + 1] - 5 + np.random.randn()*0.001 for i in range(100)]
```
Note that we have also added a tiny bit of noise to the dataset.
Finally, let's create a PySR model, and pass the custom operator. We also need to define the sympy equivalent, which we can leave as a placeholder for now:
```python
from pysr import PySRRegressor
import sympy
class sympy_p(sympy.Function):
pass
model = PySRRegressor(
binary_operators=["+", "-", "*", "/"],
unary_operators=["p"],
niterations=100,
extra_sympy_mappings={"p": sympy_p}
)
```
We are all set to go! Let's see if we can find the true relation:
```python
model.fit(X, y)
```
if all works out, you should be able to see the true relation (note that the constant offset might not be exactly 1, since it is allowed to round to the nearest integer).
You can get the sympy version of the best equation with:
```python
model.sympy()
```
## 8. Complex numbers
PySR can also search for complex-valued expressions. Simply pass
data with a complex datatype (e.g., `np.complex128`),
and PySR will automatically search for complex-valued expressions:
```python
import numpy as np
X = np.random.randn(100, 1) + 1j * np.random.randn(100, 1)
y = (1 + 2j) * np.cos(X[:, 0] * (0.5 - 0.2j))
model = PySRRegressor(
binary_operators=["+", "-", "*"], unary_operators=["cos"], niterations=100,
)
model.fit(X, y)
```
You can see that all of the learned constants are now complex numbers.
We can get the sympy version of the best equation with:
```python
model.sympy()
```
We can also make predictions normally, by passing complex data:
```python
model.predict(X, -1)
```
to make predictions with the most accurate expression.
## 9. Custom objectives
You can also pass a custom objectives as a snippet of Julia code,
which might include symbolic manipulations or custom functional forms.
These do not even need to be differentiable! First, let's look at the
default objective used (a simplified version, without weights
and with mean square error), so that you can see how to write your own:
```julia
function default_objective(tree, dataset::Dataset{T,L}, options)::L where {T,L}
(prediction, completion) = eval_tree_array(tree, dataset.X, options)
if !completion
return L(Inf)
end
diffs = prediction .- dataset.y
return sum(diffs .^ 2) / length(diffs)
end
```
Here, the `where {T,L}` syntax defines the function for arbitrary types `T` and `L`.
If you have `precision=32` (default) and pass in regular floating point data,
then both `T` and `L` will be equal to `Float32`. If you pass in complex data,
then `T` will be `ComplexF32` and `L` will be `Float32` (since we need to return
a real number from the loss function). But, you don't need to worry about this, just
make sure to return a scalar number of type `L`.
The `tree` argument is the current expression being evaluated. You can read
about the `tree` fields [here](https://ai.damtp.cam.ac.uk/symbolicregression/stable/types/).
For example, let's fix a symbolic form of an expression,
as a rational function. i.e., $P(X)/Q(X)$ for polynomials $P$ and $Q$.
```python
objective = """
function my_custom_objective(tree, dataset::Dataset{T,L}, options) where {T,L}
# Require root node to be binary, so we can split it,
# otherwise return a large loss:
tree.degree != 2 && return L(Inf)
P = tree.l
Q = tree.r
# Evaluate numerator:
P_prediction, flag = eval_tree_array(P, dataset.X, options)
!flag && return L(Inf)
# Evaluate denominator:
Q_prediction, flag = eval_tree_array(Q, dataset.X, options)
!flag && return L(Inf)
# Impose functional form:
prediction = P_prediction ./ Q_prediction
diffs = prediction .- dataset.y
return sum(diffs .^ 2) / length(diffs)
end
"""
model = PySRRegressor(
niterations=100,
binary_operators=["*", "+", "-"],
loss_function=objective,
)
```
> **Warning**: When using a custom objective like this that performs symbolic
> manipulations, many functionalities of PySR will not work, such as `.sympy()`,
> `.predict()`, etc. This is because the SymPy parsing does not know about
> how you are manipulating the expression, so you will need to do this yourself.
Note how we did not pass `/` as a binary operator; it will just be implicit
in the functional form.
Let's generate an equation of the form $\frac{x_0^2 x_1 - 2}{x_2^2 + 1}$:
```python
X = np.random.randn(1000, 3)
y = (X[:, 0]**2 * X[:, 1] - 2) / (X[:, 2]**2 + 1)
```
Finally, let's fit:
```python
model.fit(X, y)
```
> Note that the printed equation is not the same as the evaluated equation,
> because the printing functionality does not know about the functional form.
We can get the string format with:
```python
model.get_best().equation
```
(or, you could use `model.equations_.iloc[-1].equation`)
For me, this equation was:
```text
(((2.3554819 + -0.3554746) - (x1 * (x0 * x0))) - (-1.0000019 - (x2 * x2)))
```
looking at the bracket structure of the equation, we can see that the outermost
bracket is split at the `-` operator (note that we ignore the root operator in
the evaluation, as we simply evaluated each argument and divided the result) into
`((2.3554819 + -0.3554746) - (x1 * (x0 * x0)))` and
`(-1.0000019 - (x2 * x2))`, meaning that our discovered equation is
equal to:
$\frac{x_0^2 x_1 - 2.0000073}{x_2^2 + 1.0000019}$, which
is nearly the same as the true equation!
## 10. Dimensional constraints
One other feature we can exploit is dimensional analysis.
Say that we know the physical units of each feature and output,
and we want to find an expression that is dimensionally consistent.
We can do this as follows, using `DynamicQuantities.jl` to assign units,
passing a string specifying the units for each variable.
First, let's make some data on Newton's law of gravitation, using
astropy for units:
```python
import numpy as np
from astropy import units as u, constants as const
M = (np.random.rand(100) + 0.1) * const.M_sun
m = 100 * (np.random.rand(100) + 0.1) * u.kg
r = (np.random.rand(100) + 0.1) * const.R_earth
G = const.G
F = G * M * m / r**2
```
We can see the units of `F` with `F.unit`.
Now, let's create our model.
Since this data has such a large dynamic range,
let's also create a custom loss function
that looks at the error in log-space:
```python
elementwise_loss = """function loss_fnc(prediction, target)
scatter_loss = abs(log((abs(prediction)+1e-20) / (abs(target)+1e-20)))
sign_loss = 10 * (sign(prediction) - sign(target))^2
return scatter_loss + sign_loss
end
"""
```
Now let's define our model:
```python
model = PySRRegressor(
binary_operators=["+", "-", "*", "/"],
unary_operators=["square"],
elementwise_loss=elementwise_loss,
complexity_of_constants=2,
maxsize=25,
niterations=100,
populations=50,
# Amount to penalize dimensional violations:
dimensional_constraint_penalty=10**5,
)
```
and fit it, passing the unit information.
To do this, we need to use the format of [DynamicQuantities.jl](https://symbolicml.org/DynamicQuantities.jl/dev/#Usage).
```python
# Get numerical arrays to fit:
X = pd.DataFrame(dict(
M=M.to("M_sun").value,
m=m.to("kg").value,
r=r.to("R_earth").value,
))
y = F.value
model.fit(
X,
y,
X_units=["Constants.M_sun", "kg", "Constants.R_earth"],
y_units="kg * m / s^2"
)
```
You can observe that all expressions with a loss under
our penalty are dimensionally consistent!
(The `"[⋅]"` indicates free units in a constant, which can cancel out other units in the expression.)
For example,
```julia
"y[m s⁻² kg] = (M[kg] * 2.6353e-22[⋅])"
```
would indicate that the expression is dimensionally consistent, with
a constant `"2.6353e-22[m s⁻²]"`.
Note that this expression has a large dynamic range so may be difficult to find. Consider searching with a larger `niterations` if needed.
Note that you can also search for exclusively dimensionless constants by settings
`dimensionless_constants_only` to `true`.
## 11. Expression Specifications
PySR 1.0 introduces powerful expression specifications that allow you to define structured equations. Here are two examples:
### Template Expressions
`TemplateExpressionSpec` allows you to define a specific structure for the equation.
For example, let's say we want to learn an equation of the form:
$$ y = \sin(f(x_1, x_2)) + g(x_3) $$
We can do this as follows:
```python
import numpy as np
from pysr import PySRRegressor, TemplateExpressionSpec
# Create data
X = np.random.randn(1000, 3)
y = np.sin(X[:, 0] + X[:, 1]) + X[:, 2]**2
# Define template: we want sin(f(x1, x2)) + g(x3)
template = TemplateExpressionSpec(
expressions=["f", "g"],
variable_names=["x1", "x2", "x3"],
combine="sin(f(x1, x2)) + g(x3)",
)
model = PySRRegressor(
expression_spec=template,
binary_operators=["+", "*", "-", "/"],
unary_operators=["sin"],
maxsize=10,
)
model.fit(X, y)
```
### Parametric Expressions
When your data has categories with shared equation structure but different parameters,
you can use the `parameters` argument of `TemplateExpressionSpec` to specify learned category-specific parameters.
For example, let's say we want to learn an equation of the form:
$$ y = \alpha \sin(x_1) + \beta $$
where $\alpha$ and $\beta$ are different for each category.
Further, let's say we have 3 categories,
with $\alpha \in \{0.1, 1.5, -0.5\}$ and $\beta \in \{1.0, 2.0, 0.5\}$.
```python
import numpy as np
from pysr import PySRRegressor, TemplateExpressionSpec
# Create data with 2 features and 3 categories
X = np.random.uniform(-3, 3, (1000, 2))
category = np.random.randint(0, 3, 1000)
# Parameters for each category
offsets = [0.1, 1.5, -0.5]
scales = [1.0, 2.0, 0.5]
# y = scale[category] * sin(x1) + offset[category]
y = np.array([
scales[c] * np.sin(x1) + offsets[c]
for x1, c in zip(X[:, 0], category)
])
```
Now, let's define our parametric expression:
```python
template = TemplateExpressionSpec(
expressions=["f"],
variable_names=["x1", "x2", "category"],
parameters={"p1": 3, "p2": 3}, # One parameter per category
combine="f(x1, x2, p1[category], p2[category])"
)
```
Next, we pass the category as a _column_ in `X`
corresponding to the index we defined in `variable_names`.
**Note that because Julia is 1-indexed, we need to add 1 to the category index.**
```python
category_p_one = category + 1
X_with_category = np.column_stack([X, category])
```
Now, we can fit our model:
```python
model = PySRRegressor(
expression_spec=template,
binary_operators=["+", "*", "-", "/"],
unary_operators=["sin"],
maxsize=10,
)
model.fit(X_with_category, y)
# Predicting on new data
# model.predict(X_test_with_category)
```
See [Expression Specifications](/api/#expression-specifications) for more details.
You can use this approach for more complex cases,
where you have multiple expressions in the template and parameters that vary by category.
## 12. Using TensorBoard for Logging
You can use TensorBoard to visualize the search progress, as well as
record hyperparameters and final metrics (like `min_loss` and `pareto_volume` - the latter of which
is a performance measure of the entire Pareto front).
```python
import numpy as np
from pysr import PySRRegressor, TensorBoardLoggerSpec
rstate = np.random.RandomState(42)
# Uniform dist between -3 and 3:
X = rstate.uniform(-3, 3, (1000, 2))
y = np.exp(X[:, 0]) + X[:, 1]
# Create a logger that writes to "logs/run*":
logger_spec = TensorBoardLoggerSpec(
log_dir="logs/run",
log_interval=10, # Log every 10 iterations
)
model = PySRRegressor(
binary_operators=["+", "*", "-", "/"],
logger_spec=logger_spec,
)
model.fit(X, y)
```
You can then view the logs with:
```bash
tensorboard --logdir logs/
```
## 13. Vector-valued expressions
You can use `TemplateExpressionSpec` to find expressions for vector-valued data,
where each component might share a common structure.
The trick is to put each vector element into your feature matrix `X`,
and then use a template expression to define the relationships.
For example, say we have 3-dimensional vectors where each component
follows a pattern with a shared term. Say the true model is:
$$\begin{align*}
y_1 &= \exp(x_1) + x_2^2 \\
y_2 &= \exp(x_1) + \sin(x_3) \\
y_3 &= \exp(x_1) + x_1 \cdot x_2
\end{align*}$$
Let's set this up:
```python
import numpy as np
from pysr import PySRRegressor, TemplateExpressionSpec
n = 200
rstate = np.random.RandomState(0)
x1 = rstate.uniform(-2, 2, n)
x2 = rstate.uniform(-2, 2, n)
x3 = rstate.uniform(-2, 2, n)
# True model with shared component exp(x1):
y1 = np.exp(x1) + x2**2
y2 = np.exp(x1) + np.sin(x3)
y3 = np.exp(x1) + x1 * x2
# Add some noise
y1 += 0.05 * rstate.randn(n)
y2 += 0.05 * rstate.randn(n)
y3 += 0.05 * rstate.randn(n)
```
Now, we put everything in `X`; BOTH features and targets:
```python
X = np.column_stack([x1, x2, x3, y1, y2, y3])
```
Now, we can define our template expression:
```python
spec = TemplateExpressionSpec(
expressions=["f1", "f2", "f3", "shared"],
variable_names=["x1", "x2", "x3", "y1", "y2", "y3"],
combine="""
v = shared(x1, x2, x3)
y1_predicted = v + f1(x1, x2, x3)
y2_predicted = v + f2(x1, x2, x3)
y3_predicted = v + f3(x1, x2, x3)
residuals = (
abs2(y1 - y1_predicted) +
abs2(y2 - y2_predicted) +
abs2(y3 - y3_predicted)
)
residuals
"""
)
```
Now, we can fit our model using this template. Since
we already computed the per-row squared error inside the template,
we can pass a dummy `y` to the `fit` method, and also define
an `elementwise_loss` that simply returns the residuals (which get
summed over the data):
```python
model = PySRRegressor(
expression_spec=spec,
binary_operators=["+", "-", "*", "/"],
unary_operators=["exp", "sin"],
maxsize=20,
niterations=50,
elementwise_loss="(pred, target) -> pred",
)
dummy_y = np.zeros(n)
model.fit(X, dummy_y)
```
After running, PySR should find both the shared component (`exp(x1)`) as well as individual components (`square(x2)`, `sin(x3)`, and `x1 * x2`).
You can access the individual expressions through the Julia objects:
```python
# Simply get the expression with the highest score:
idx = model.equations_.score.idxmax()
# Extract the Julia object:
julia_expr = model.equations_.loc[idx, 'julia_expression']
# Access individual subexpressions:
for name in ['f1', 'f2', 'f3', 'shared']:
tree = getattr(julia_expr.trees, name)
print(f"{name}: {tree}")
```
We can also evaluate individual expressions:
```python
from pysr import jl
from pysr.julia_helpers import jl_array
SR = jl.SymbolicRegression
# Get individual trees
f1_tree = julia_expr.trees.f1
shared_tree = julia_expr.trees.shared
# Evaluate at specific points (x1=1, x2=2, x3=3)
test_inputs = jl_array(np.array([[1.0], [2.0], [3.0]]))
f1_result, _ = SR.eval_tree_array(f1_tree, test_inputs, model.julia_options_)
shared_result, _ = SR.eval_tree_array(shared_tree, test_inputs, model.julia_options_)
print(f"f1 at (1,2,3): {f1_result[0]}") # Should be ~4.0 for x2^2
print(f"shared at (1,2,3): {shared_result[0]}") # Should be ~2.718 for exp(1)
```
## 14. Using differential operators
As part of the `TemplateExpressionSpec` described above,
you can also use differential operators within the template.
The operator for this is `D` which takes an expression as the first argument,
and the argument _index_ we are differentiating as the second argument.
This lets you compute integrals via evolution.
For example, let's say we wish to find the integral of $\frac{1}{x^2 \sqrt{x^2 - 1}}$
in the range $x > 1$.
We can compute the derivative of a function $f(x)$, and compare that
to numerical samples of $\frac{1}{x^2\sqrt{x^2-1}}$. Then, by extension,
$f(x)$ represents the indefinite integral of it with some constant offset!
```python
import numpy as np
from pysr import PySRRegressor, TemplateExpressionSpec
x = np.random.uniform(1, 10, (1000,)) # Integrand sampling points
y = 1 / (x**2 * np.sqrt(x**2 - 1)) # Evaluation of the integrand
expression_spec = TemplateExpressionSpec(
expressions=["f"],
variable_names=["x"],
combine="df = D(f, 1); df(x)",
)
model = PySRRegressor(
binary_operators=["+", "-", "*", "/"],
unary_operators=["sqrt"],
expression_spec=expression_spec,
maxsize=20,
)
model.fit(x[:, np.newaxis], y)
```
If everything works, you should find something that simplifies to $\frac{\sqrt{x^2 - 1}}{x}$.
Here, we write out a full function in Julia.
## 15. Additional features
For the many other features available in PySR, please
read the [Options section](options.md).

View File

@ -0,0 +1,309 @@
"""Inference pipeline for Illusion (cylinder imitation) scenes.
Generates controlled data for a given target cylinder diameter using
LegacyCelerisLab + trained PPO model.
Usage:
conda run -n pycuda_3_10 python scripts/infer_illusion.py \\
--diameter 1.0 --device 0
conda run -n pycuda_3_10 python scripts/infer_illusion.py \\
--diameter all --device 2
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import time
from collections import deque
from typing import Optional
import numpy as np
_REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
if _REPO not in sys.path:
sys.path.insert(0, _REPO)
_SRC = os.path.join(_REPO, "src")
if _SRC not in sys.path:
sys.path.insert(0, _SRC)
from LegacyCelerisLab import FlowField # noqa: E402
from LegacyCelerisLab import utils as legacy_utils # noqa: E402
from SR_analysis.utils.cfd_interface import (
load_legacy_configs, build_observation,
scale_action, load_ppo_model, compute_similarity,
)
from SR_analysis.configs import (
get_scene, get_scene_list, model_path_for_scene,
LEGACY_CFG_DIR, FIFO_LEN, CONV_LEN,
)
DATA_TYPE = np.float32
def run_single_illusion(
scene_name: str,
device_id: int,
output_root: str,
n_infer_steps: int = 200,
) -> dict:
"""Run full inference pipeline for one Illusion scene."""
cfg = get_scene(scene_name)
nu = cfg["nu"]
u0 = cfg["u0"]
l0 = 20.0
sample_interval = cfg["sample_interval"]
action_scale = cfg["action_scale"]
action_bias = cfg["action_bias"]
n_obj_total = cfg["n_objects_env"]
sensor_x = cfg["sensor_x"] # 30.0 for illusion
front_x = cfg["pinball_front_x"] # 19.0
rear_x = cfg["pinball_rear_x"] # 20.3
target_diam = cfg["target_diameter"]
os.makedirs(output_root, exist_ok=True)
print(f"\n{'='*60}")
print(f"Scene: {scene_name} Diam={target_diam}L u0={u0} device={device_id}")
print(f"{'='*60}")
# Save config
with open(os.path.join(output_root, "config.json"), "w") as f:
json.dump({k: str(v) if not isinstance(v, (int, float, list, bool))
else v for k, v in cfg.items()}, f, indent=2)
# Load legacy CFD configs with overridden viscosity and velocity
cuda_cfg, field_cfg = load_legacy_configs(LEGACY_CFG_DIR)
field_cfg = field_cfg._replace(viscosity=float(nu))
if u0 != 0.01:
field_cfg = field_cfg._replace(velocity=float(u0))
# -- Phase 1: Target recording (target cylinder + 3 sensors) ------------
ff = FlowField(field_cfg, cuda_cfg, device_id=device_id)
ny = ff.FIELD_SHAPE[1]
# Add target cylinder at x=20*L0, with radius = target_diam * L0
print(f" Adding target cylinder: diam={target_diam}L, pos=({20*l0:.0f}, {ny/2:.0f})")
ff.add_cylinder((20.0 * l0, (ny - 1) / 2, 0.0), target_diam * l0)
# Add 3 sensors at x = sensor_x * L0
for y_off in [2.0, 0.0, -2.0]:
sc = (sensor_x * l0, (ny - 1) / 2 + y_off * l0, 0.0)
ff.add_sensor(sc, l0 / 4.0)
n_obj_phase1 = ff.obs.size // 2
print(f" Phase 1 objects: {n_obj_phase1}")
# Stabilize
stabilize_steps = int(4 * ff.FIELD_SHAPE[0] / u0)
print(f" Stabilising ({stabilize_steps} steps)...")
ff.run(stabilize_steps, np.zeros(n_obj_phase1, dtype=DATA_TYPE))
# Record target
target_states = np.empty((0, 8), dtype=DATA_TYPE)
for _ in range(FIFO_LEN):
ff.run(sample_interval, np.zeros(n_obj_phase1, dtype=DATA_TYPE))
new_state = ff.obs.copy()[0:8] # sensor[6] + cylinder force[2]
target_states = np.vstack((target_states, new_state))
print(f" Target recorded: {target_states.shape}")
# Save target
np.savez(os.path.join(output_root, "target.npz"), target_states=target_states)
# Clean up and create pinball env
del ff
ff = FlowField(field_cfg, cuda_cfg, device_id=device_id)
# -- Phase 2: Pinball env -----------------------------------------------
# Add 3 sensors (same positions as target phase)
for y_off in [2.0, 0.0, -2.0]:
sc = (sensor_x * l0, (ny - 1) / 2 + y_off * l0, 0.0)
ff.add_sensor(sc, l0 / 4.0)
# Add 3 pinball cylinders (illusion positions)
# Front at x=front_x*L0, rear at x=rear_x*L0
ff.add_cylinder((front_x * l0, (ny - 1) / 2, 0.0), l0 / 2.0)
ff.add_cylinder((rear_x * l0, (ny - 1) / 2 + 0.75 * l0, 0.0), l0 / 2.0)
ff.add_cylinder((rear_x * l0, (ny - 1) / 2 - 0.75 * l0, 0.0), l0 / 2.0)
n_obj = ff.obs.size // 2
print(f" Pinball env objects: {n_obj}")
assert n_obj == 6, f"Expected 6 objects, got {n_obj}"
# Stabilize with zero action
print(f" Stabilising pinball ({stabilize_steps} steps)...")
ff.run(stabilize_steps, np.zeros(n_obj, dtype=DATA_TYPE))
# Checkpoint
ff.get_ddf()
ff.save_ddf()
# Norm collection (zero action)
fifo = deque(maxlen=FIFO_LEN)
for _ in range(FIFO_LEN):
ff.run(sample_interval, np.zeros(n_obj, dtype=DATA_TYPE))
fifo.append(ff.obs.copy()[0:12])
temp_states = np.array(fifo, dtype=DATA_TYPE)
force_norm_fact = 6.0 * float(np.max(np.abs(temp_states[:, 6:12])))
sens_deviation = np.mean(temp_states[:, 0:6], axis=0).astype(DATA_TYPE)
sens_norm_fact = np.zeros(6, dtype=DATA_TYPE)
for i in range(6):
sens_norm_fact[i] = 5.0 * float(np.max(np.abs(temp_states[:, i] - sens_deviation[i])))
norm = {
"force_norm_fact": force_norm_fact,
"sens_deviation": sens_deviation.tolist(),
"sens_norm_fact": sens_norm_fact.tolist(),
"action_bias": list(action_bias),
}
print(f" norm: force_norm_fact={force_norm_fact:.6f}")
# Bias-action rollout
ff.apply_ddf()
bias_arr = np.zeros(n_obj, dtype=DATA_TYPE)
bias_arr[3] = float(action_bias[0] * u0)
bias_arr[4] = float(action_bias[1] * u0)
bias_arr[5] = float(action_bias[2] * u0)
print(f" bias action: {bias_arr}")
fifo.clear()
for _ in range(FIFO_LEN):
ff.run(sample_interval, bias_arr)
fifo.append(ff.obs.copy()[0:12])
save_states = np.array(list(fifo), dtype=DATA_TYPE)
norm["save_states"] = save_states
ff.apply_ddf()
# Save norm
norm_json = {k: v for k, v in norm.items() if not isinstance(v, np.ndarray)}
with open(os.path.join(output_root, "norm.json"), "w") as f:
json.dump(norm_json, f, indent=2)
# -- Phase 3: Controlled inference ---------------------------------------
result = {"scene": scene_name, "controlled": False}
model_path = model_path_for_scene(scene_name)
if model_path is not None:
s_dim = cfg.get("s_dim", 12)
print(f" loading model: {model_path} (s_dim={s_dim})")
model = load_ppo_model(model_path, device=f"cuda:{device_id}", s_dim=s_dim)
model.set_random_seed(0)
print(f" controlled rollout ({n_infer_steps} steps) ...")
ff.restore_ddf()
ff.apply_ddf()
# Re-bias FIFO
fifo = deque(maxlen=FIFO_LEN)
for _ in range(FIFO_LEN):
ff.context.push()
ff.run(sample_interval, bias_arr)
ff.context.pop()
fifo.append(ff.obs.copy()[0:12])
sens_list, forc_list, action_list = [], [], []
obs = np.zeros(s_dim, dtype=np.float32)
for step in range(n_infer_steps):
action, _states = model.predict(obs, deterministic=True)
action = action.astype(np.float32).flatten()
action_list.append(action.copy())
# Convert to legacy action array (6 objects: sensors[3] + pinball[3])
temp = np.zeros(n_obj, dtype=DATA_TYPE)
temp[3:6] = np.array(
(action * action_scale + list(action_bias)) * u0,
dtype=DATA_TYPE)
ff.context.push()
ff.run(sample_interval, temp)
ff.context.pop()
obs_slice = ff.obs.copy()[0:12]
fifo.append(obs_slice)
sens_list.append(obs_slice[0:6])
forc_list.append(obs_slice[6:12])
# Build normalized obs (just forces + sens for S_DIM=12)
forces_norm = obs_slice[6:12] / force_norm_fact
sens_norm = (obs_slice[0:6] - sens_deviation) / sens_norm_fact
obs12 = np.clip(np.hstack([forces_norm, sens_norm]), -1.0, 1.0).astype(np.float32)
if s_dim == 14:
# Need target values -- for inference we zero-pad
obs = np.zeros(14, dtype=np.float32)
obs[:12] = obs12
else:
obs = obs12
np.savez(os.path.join(output_root, "controlled.npz"),
sensors=np.array(sens_list, dtype=np.float32),
forces=np.array(forc_list, dtype=np.float32),
actions=np.array(action_list, dtype=np.float32))
# Compute similarity (use the target cylinder's sensor-only signals)
# For comparison, compute similarity between controlled sensors and target
target_sensors = target_states[:, 0:6]
sim = compute_similarity(target_sensors,
np.array(sens_list, dtype=np.float32), CONV_LEN)
print(f" similarity (vs target cylinder) = {sim:.4f}")
result["controlled"] = True
result["similarity"] = sim
else:
print(f" WARNING: no model for {scene_name}")
del ff
with open(os.path.join(output_root, "result.json"), "w") as f:
json.dump(result, f, indent=2)
return result
def main():
ap = argparse.ArgumentParser(description="Illusion inference")
ap.add_argument("--diameter", type=str, default="1.0",
help='Diameter: 0.75, 1.0, 1.5, or "all"')
ap.add_argument("--device", type=int, default=0, help="GPU device ID")
ap.add_argument("--steps", type=int, default=200)
ap.add_argument("--out-root", type=str, default=None)
args = ap.parse_args()
# Determine scene names
if args.diameter.lower() == "all":
scene_names = get_scene_list("illusion")
else:
d = float(args.diameter)
# Match by target_diameter field
scene_names = []
for sn in get_scene_list("illusion"):
cfg = get_scene(sn)
if abs(cfg["target_diameter"] - d) < 0.01:
scene_names.append(sn)
if not scene_names:
print(f"ERROR: no illusion scene found for diameter={d}")
return 1
if args.out_root is None:
out_root = os.path.join(os.path.dirname(__file__), "..", "data", "illusion")
else:
out_root = args.out_root
t_start = time.time()
for sn in scene_names:
case_dir = os.path.join(out_root, sn)
result = run_single_illusion(sn, args.device, case_dir,
n_infer_steps=args.steps)
print(f" Done: {sn} -> {case_dir}")
elapsed = time.time() - t_start
print(f"\nTotal time: {elapsed:.1f}s")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,253 @@
"""Inference pipeline for Karman cloak across Re.
Generates controlled/uncontrolled data for a given Re case using
LegacyCelerisLab + trained PPO model.
Usage:
conda run -n pycuda_3_10 python scripts/infer_karman.py --re 100 --device 0
conda run -n pycuda_3_10 python scripts/infer_karman.py --re all --device 2
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import time
from collections import deque
import numpy as np
# Add repo root for LegacyCelerisLab and src/ for SR_analysis
_REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
if _REPO not in sys.path:
sys.path.insert(0, _REPO)
_SRC = os.path.join(_REPO, "src")
if _SRC not in sys.path:
sys.path.insert(0, _SRC)
from LegacyCelerisLab import FlowField # noqa: E402
from SR_analysis.utils.cfd_interface import (
nu_from_re, load_legacy_configs,
build_karman_cloak_env, add_pinball, build_observation,
scale_action, load_ppo_model, save_vorticity_png,
vorticity_from_ddf, compute_similarity, ACTION_SMOOTH_WEIGHT,
)
from SR_analysis.configs import (
SCENES, get_scene, get_scene_list, model_path_for_scene,
LEGACY_CFG_DIR, FIFO_LEN, CONV_LEN,
)
DATA_TYPE = np.float32
def run_single_re(
scene_name: str,
device_id: int,
output_root: str,
n_infer_steps: int = 200,
) -> dict:
"""Run full inference pipeline for one Karman Re case."""
cfg = get_scene(scene_name)
re_code = cfg["re_code"]
nu = cfg["nu"]
u0 = cfg["u0"]
l0 = 20.0
sample_interval = cfg["sample_interval"]
action_scale = cfg["action_scale"]
action_bias = cfg["action_bias"]
n_obj_total = cfg["n_objects_env"]
os.makedirs(output_root, exist_ok=True)
print(f"\n{'='*60}")
print(f"Scene: {scene_name} Re_code={re_code} nu={nu:.6f} device={device_id}")
print(f"{'='*60}")
# Save config
with open(os.path.join(output_root, "config.json"), "w") as f:
json.dump({k: str(v) if not isinstance(v, (int, float, list, bool))
else v for k, v in cfg.items()}, f, indent=2)
# Load legacy CFD configs with overridden viscosity
cuda_cfg, field_cfg = load_legacy_configs(LEGACY_CFG_DIR)
field_cfg = field_cfg._replace(viscosity=float(nu))
# Build env: dist cylinder + sensors, record target
ff = FlowField(field_cfg, cuda_cfg, device_id=device_id)
target_states, env_info = build_karman_cloak_env(
ff, u0=u0, l0=l0, sample_interval=sample_interval,
fifo_len=FIFO_LEN, data_type=DATA_TYPE,
)
np.savez(os.path.join(output_root, "target.npz"), target_states=target_states)
# Add pinball, compute norm
norm = add_pinball(
ff, l0=l0, u0=u0, sample_interval=sample_interval,
fifo_len=FIFO_LEN, data_type=DATA_TYPE,
action_bias=action_bias, pinball_front_x=cfg["pinball_front_x"],
pinball_rear_x=cfg["pinball_rear_x"],
obs_slice_start=cfg["obs_slice"][0], obs_slice_end=cfg["obs_slice"][1],
)
norm_for_json = {k: v for k, v in norm.items()
if not isinstance(v, np.ndarray)}
with open(os.path.join(output_root, "norm.json"), "w") as f:
json.dump(norm_for_json, f, indent=2)
# Uncontrolled rollout
print(" uncontrolled rollout ...")
ff.restore_ddf()
ff.apply_ddf()
sens_list, forc_list = [], []
for _ in range(n_infer_steps):
ff.run(sample_interval, np.zeros(n_obj_total, dtype=DATA_TYPE))
obs_slice = ff.obs.copy()[2:14]
sens_list.append(obs_slice[0:6])
forc_list.append(obs_slice[6:12])
np.savez(os.path.join(output_root, "uncontrolled.npz"),
sensors=np.array(sens_list, dtype=np.float32),
forces=np.array(forc_list, dtype=np.float32))
omega_unc = vorticity_from_ddf(ff, u0=u0)
save_vorticity_png(os.path.join(output_root, "vorticity_uncontrolled.png"),
omega_unc, title=f"{scene_name} uncontrolled")
# Controlled rollout
result = {"scene": scene_name, "controlled": False}
model_path = model_path_for_scene(scene_name)
if model_path is not None:
s_dim = cfg.get("s_dim", 12)
print(f" loading model: {model_path} (s_dim={s_dim})")
model = load_ppo_model(model_path, device=f"cuda:{device_id}", s_dim=s_dim)
model.set_random_seed(0)
print(f" controlled rollout ({n_infer_steps} steps) ...")
ff.restore_ddf()
ff.apply_ddf()
# Bias FIFO init
fifo = deque(maxlen=FIFO_LEN)
bias_action = scale_action(
np.zeros(3, dtype=np.float32),
scale=action_scale, bias=action_bias, u0=u0,
n_total_bodies=n_obj_total,
)
for _ in range(FIFO_LEN):
ff.context.push()
ff.run(sample_interval, bias_action)
ff.context.pop()
fifo.append(ff.obs.copy()[2:14])
sens_list_c, forc_list_c, action_list_c = [], [], []
reward_list_c = []
obs = np.zeros(12, dtype=np.float32)
for step in range(n_infer_steps):
action, _states = model.predict(obs, deterministic=True)
action = action.astype(np.float32).flatten()
action_list_c.append(action.copy())
action_arr = scale_action(
action, scale=action_scale, bias=action_bias,
u0=u0, n_total_bodies=n_obj_total,
)
ff.context.push()
ff.run(sample_interval, action_arr)
ff.context.pop()
obs_slice = ff.obs.copy()[2:14]
fifo.append(obs_slice)
sens_list_c.append(obs_slice[0:6])
forc_list_c.append(obs_slice[6:12])
obs = build_observation(obs_slice, norm)
# Compute reward
states_arr = np.array(list(fifo), dtype=np.float32)
if len(states_arr) >= CONV_LEN:
forces = states_arr[-1, 6:12] / norm["force_norm_fact"]
cd = float((forces[0] + forces[2] + forces[4]) / 3.0)
cl = float((forces[1] + forces[3] + forces[5]) / 3.0)
sim = compute_similarity(target_states, states_arr[:, 0:6], CONV_LEN)
r_cd = np.exp(-abs(cd * 20.0))
r_cl = np.exp(-abs(cl * 80.0))
r_sim = np.exp(-10.0 * abs(sim - 1.0))
reward = min(0.3 * r_cd + 0.4 * r_cl + 0.3 * r_sim, 1.0)
reward_list_c.append(float(reward))
np.savez(os.path.join(output_root, "controlled.npz"),
sensors=np.array(sens_list_c, dtype=np.float32),
forces=np.array(forc_list_c, dtype=np.float32),
actions=np.array(action_list_c, dtype=np.float32),
rewards=np.array(reward_list_c, dtype=np.float32))
omega_con = vorticity_from_ddf(ff, u0=u0)
save_vorticity_png(os.path.join(output_root, "vorticity_controlled.png"),
omega_con, title=f"{scene_name} controlled")
avg_reward = (float(np.mean(reward_list_c[-100:]))
if len(reward_list_c) >= 100
else float(np.mean(reward_list_c)))
sim_score = compute_similarity(
target_states, np.array(sens_list_c, dtype=np.float32), CONV_LEN)
result["controlled"] = True
result["avg_reward_last100"] = avg_reward
result["similarity"] = sim_score
print(f" avg_reward(last100)={avg_reward:.4f} similarity={sim_score:.4f}")
else:
print(f" no model for {scene_name}, skipping controlled rollout")
del ff
with open(os.path.join(output_root, "result.json"), "w") as f:
json.dump(result, f, indent=2)
return result
def main():
ap = argparse.ArgumentParser(description="Karman cloak inference")
ap.add_argument("--re", type=str, default="100",
help='Re case: 50,100,200,400, or "all", or "validation"')
ap.add_argument("--device", type=int, default=0, help="GPU device ID")
ap.add_argument("--steps", type=int, default=200,
help="Number of inference steps per rollout")
ap.add_argument("--out-root", type=str, default=None,
help="Output root (default: SR_analysis/data/karman)")
args = ap.parse_args()
# Determine scene names
selection = args.re.lower()
if selection == "all":
scene_names = get_scene_list("karman")
elif selection == "validation":
scene_names = [f"karman_re{rc}" for rc in [35, 70, 150]]
# Check which are defined
scene_names = [s for s in scene_names if s in SCENES]
else:
rc = int(selection)
scene_names = [f"karman_re{rc}"]
if args.out_root is None:
out_root = os.path.join(os.path.dirname(__file__), "..", "data", "karman")
else:
out_root = args.out_root
t_start = time.time()
for sn in scene_names:
case_dir = os.path.join(out_root, sn)
result = run_single_re(sn, args.device, case_dir,
n_infer_steps=args.steps)
print(f" Done: {sn} -> {case_dir}")
elapsed = time.time() - t_start
print(f"\nTotal time: {elapsed:.1f}s")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,300 @@
"""Inference pipeline for Vortex cloak (Lamb dipole + Taylor monopole).
Generates controlled data for vortex cloak scenes using
LegacyCelerisLab + trained PPO model.
Vortex env characteristics:
- No disturbance cylinder
- Vortex is added AFTER DDF checkpoint
- Transient: MAX_STEPS=150
- Action scaling: action*4 + [0,-4,4]
Usage:
conda run -n pycuda_3_10 python scripts/infer_vortex.py \\
--type lamb --device 0
conda run -n pycuda_3_10 python scripts/infer_vortex.py \\
--type all --device 2
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import time
from collections import deque
from typing import Optional
import numpy as np
_REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
if _REPO not in sys.path:
sys.path.insert(0, _REPO)
_SRC = os.path.join(_REPO, "src")
if _SRC not in sys.path:
sys.path.insert(0, _SRC)
from LegacyCelerisLab import FlowField # noqa: E402
from SR_analysis.utils.cfd_interface import (
load_legacy_configs, build_observation,
scale_action, load_ppo_model, compute_similarity,
)
from SR_analysis.configs import (
get_scene, get_scene_list, model_path_for_scene,
LEGACY_CFG_DIR, FIFO_LEN, CONV_LEN,
)
DATA_TYPE = np.float32
def run_single_vortex(
scene_name: str,
device_id: int,
output_root: str,
n_infer_steps: Optional[int] = None,
) -> dict:
"""Run full inference pipeline for one Vortex scene."""
cfg = get_scene(scene_name)
nu = cfg["nu"]
u0 = cfg["u0"]
l0 = 20.0
sample_interval = cfg["sample_interval"]
action_scale = cfg["action_scale"]
action_bias = cfg["action_bias"]
n_obj_pinball = cfg["n_objects_env"]
max_steps = cfg.get("max_steps", 150)
vtype = cfg["vortex_type"]
vstrength = cfg["vortex_strength"]
if n_infer_steps is None:
n_infer_steps = max_steps # transient -- use full episode
os.makedirs(output_root, exist_ok=True)
print(f"\n{'='*60}")
print(f"Scene: {scene_name} Vortex={vtype} strength={vstrength} device={device_id}")
print(f"{'='*60}")
# Save config
with open(os.path.join(output_root, "config.json"), "w") as f:
json.dump({k: str(v) if not isinstance(v, (int, float, list, bool))
else v for k, v in cfg.items()}, f, indent=2)
# Load legacy CFD configs
cuda_cfg, field_cfg = load_legacy_configs(LEGACY_CFG_DIR)
field_cfg = field_cfg._replace(viscosity=float(nu))
ff = FlowField(field_cfg, cuda_cfg, device_id=device_id)
ny = ff.FIELD_SHAPE[1]
# -- Phase 1: Sensors only env, record target with vortex -----------------
# Add 3 sensors at x=40*L0
for y_off in [2.0, 0.0, -2.0]:
sc = (40.0 * l0, (ny - 1) / 2 + y_off * l0, 0.0)
ff.add_sensor(sc, l0 / 4.0)
n_obj_sensors = ff.obs.size // 2
print(f" Sensor-only objects: {n_obj_sensors}")
# Short stabilize (1*NX/U0 instead of 4* for vortex)
stabilize_steps_short = int(1 * ff.FIELD_SHAPE[0] / u0)
ff.run(stabilize_steps_short, np.zeros(n_obj_sensors, dtype=DATA_TYPE))
# Save clean flow DDF (for later restore)
ff.get_ddf()
ff.save_ddf()
# Add vortex
print(f" Adding vortex: type={vtype}, center=({10*l0:.0f}, {ny/2:.0f})")
ff.add_vortex((10.0 * l0, (ny - 1) / 2, 0.0),
2.0 * l0, vstrength * u0, 0, vtype)
# Record target (vortex evolving through sensor-only env)
target_states = np.empty((0, 6), dtype=DATA_TYPE)
for _ in range(max_steps):
ff.run(sample_interval, np.zeros(n_obj_sensors, dtype=DATA_TYPE))
target_states = np.vstack((target_states, ff.obs.copy()))
print(f" Target recorded: {target_states.shape}")
np.savez(os.path.join(output_root, "target.npz"), target_states=target_states)
# -- Phase 2: Restore clean flow, add pinball, add vortex, record norm ----
ff.restore_ddf()
ff.apply_ddf()
# Add 3 pinball cylinders
ff.add_cylinder((30.0 * l0, (ny - 1) / 2, 0.0), l0 / 2.0)
ff.add_cylinder((31.3 * l0, (ny - 1) / 2 + 0.75 * l0, 0.0), l0 / 2.0)
ff.add_cylinder((31.3 * l0, (ny - 1) / 2 - 0.75 * l0, 0.0), l0 / 2.0)
n_obj = ff.obs.size // 2
print(f" Pinball env objects: {n_obj}")
assert n_obj == 6, f"Expected 6, got {n_obj}"
# Stabilize with zero action
ff.run(stabilize_steps_short, np.zeros(n_obj, dtype=DATA_TYPE))
# Stabilize with bias action (following vortex env code)
bias_arr = np.zeros(n_obj, dtype=DATA_TYPE)
bias_arr[3] = float(action_bias[0] * u0)
bias_arr[4] = float(action_bias[1] * u0)
bias_arr[5] = float(action_bias[2] * u0)
ff.run(stabilize_steps_short, bias_arr)
# Add vortex at x=15*L0 (pinball env) and save DDF
print(f" Adding vortex for pinball env: type={vtype}")
ff.add_vortex((15.0 * l0, (ny - 1) / 2, 0.0),
2.0 * l0, vstrength * u0, 0, vtype)
# SAVE DDF AFTER vortex (vortex env reset restores this mid-transient state)
ff.get_ddf()
ff.save_ddf()
print(" DDF saved with vortex active")
# Norm collection (zero action)
fifo = deque(maxlen=FIFO_LEN)
for _ in range(FIFO_LEN):
ff.run(sample_interval, np.zeros(n_obj, dtype=DATA_TYPE))
fifo.append(ff.obs.copy())
temp_states = np.array(fifo, dtype=DATA_TYPE)
force_norm_fact = 6.0 * float(np.max(np.abs(temp_states[:, 6:12])))
sens_deviation = np.mean(temp_states[:, 0:6], axis=0).astype(DATA_TYPE)
sens_norm_fact = np.zeros(6, dtype=DATA_TYPE)
for i in range(6):
sens_norm_fact[i] = 5.0 * float(np.max(np.abs(temp_states[:, i] - sens_deviation[i])))
norm = {
"force_norm_fact": force_norm_fact,
"sens_deviation": sens_deviation.tolist(),
"sens_norm_fact": sens_norm_fact.tolist(),
"action_bias": list(action_bias),
}
print(f" norm: force_norm_fact={force_norm_fact:.6f}")
# Bias rollout (restore before vortex was added, then add vortex again)
ff.apply_ddf() # restore to DDF with vortex active
fifo.clear()
for _ in range(FIFO_LEN):
ff.run(sample_interval, bias_arr)
fifo.append(ff.obs.copy())
save_states = np.array(list(fifo), dtype=DATA_TYPE)
norm["save_states"] = save_states
ff.apply_ddf()
norm_json = {k: v for k, v in norm.items() if not isinstance(v, np.ndarray)}
with open(os.path.join(output_root, "norm.json"), "w") as f:
json.dump(norm_json, f, indent=2)
# -- Phase 3: Controlled inference ----------------------------------------
result = {"scene": scene_name, "controlled": False}
model_path = model_path_for_scene(scene_name)
if model_path is not None:
print(f" loading model: {model_path}")
model = load_ppo_model(model_path, device=f"cuda:{device_id}", s_dim=12)
model.set_random_seed(0)
print(f" controlled rollout ({n_infer_steps} steps) ...")
ff.restore_ddf()
ff.apply_ddf()
# Bias FIFO init (restore with vortex active)
fifo = deque(maxlen=FIFO_LEN)
for _ in range(FIFO_LEN):
ff.context.push()
ff.run(sample_interval, bias_arr)
ff.context.pop()
fifo.append(ff.obs.copy())
sens_list, forc_list, action_list = [], [], []
obs = np.zeros(12, dtype=np.float32)
for step in range(n_infer_steps):
action, _states = model.predict(obs, deterministic=True)
action = action.astype(np.float32).flatten()
action_list.append(action.copy())
# Action: action*4 + [0,-4,4]
temp = np.zeros(n_obj, dtype=DATA_TYPE)
temp[3:6] = np.array(
(action * action_scale + list(action_bias)) * u0,
dtype=DATA_TYPE)
ff.context.push()
ff.run(sample_interval, temp)
ff.context.pop()
obs_slice = ff.obs.copy()
fifo.append(obs_slice)
sens_list.append(obs_slice[0:6])
forc_list.append(obs_slice[6:12])
forces_norm = obs_slice[6:12] / force_norm_fact
sens_norm = (obs_slice[0:6] - sens_deviation) / sens_norm_fact
obs = np.clip(np.hstack([forces_norm, sens_norm]), -1.0, 1.0).astype(np.float32)
np.savez(os.path.join(output_root, "controlled.npz"),
sensors=np.array(sens_list, dtype=np.float32),
forces=np.array(forc_list, dtype=np.float32),
actions=np.array(action_list, dtype=np.float32))
# Similarity: align by step index (no lag for transient)
states_arr = np.array(sens_list, dtype=np.float32)
target_arr = target_states[:n_infer_steps, :] if n_infer_steps <= max_steps else target_states
n_align = min(states_arr.shape[0], target_arr.shape[0])
if n_align >= CONV_LEN:
sim = compute_similarity(target_arr, states_arr[:n_align], CONV_LEN)
else:
sim = 0.0
print(f" similarity (vs target) = {sim:.4f}")
result["controlled"] = True
result["similarity"] = sim
else:
print(f" WARNING: no model for {scene_name}")
del ff
with open(os.path.join(output_root, "result.json"), "w") as f:
json.dump(result, f, indent=2)
return result
def main():
ap = argparse.ArgumentParser(description="Vortex cloak inference")
ap.add_argument("--type", type=str, default="lamb",
help='Vortex type: lamb, taylor, or "all"')
ap.add_argument("--device", type=int, default=0, help="GPU device ID")
ap.add_argument("--steps", type=int, default=None,
help="Inference steps (default: max_steps for scene)")
ap.add_argument("--out-root", type=str, default=None)
args = ap.parse_args()
if args.type.lower() == "all":
scene_names = get_scene_list("vortex")
else:
scene_names = [f"vortex_{args.type.lower()}"]
if args.out_root is None:
out_root = os.path.join(os.path.dirname(__file__), "..", "data", "vortex")
else:
out_root = args.out_root
t_start = time.time()
for sn in scene_names:
case_dir = os.path.join(out_root, sn)
result = run_single_vortex(sn, args.device, case_dir,
n_infer_steps=args.steps)
print(f" Done: {sn} -> {case_dir}")
elapsed = time.time() - t_start
print(f"\nTotal time: {elapsed:.1f}s")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,102 @@
{
"scene": "illusion_1.5L",
"channels": [
{
"channel": "front",
"best_r2": 0.9594009004329207,
"best_nz": 21,
"pareto": [
{
"nz": 5,
"r2": 0.9393791282052957
},
{
"nz": 6,
"r2": 0.9467043546594699
},
{
"nz": 7,
"r2": 0.9468038158796819
},
{
"nz": 12,
"r2": 0.958013912668809
},
{
"nz": 17,
"r2": 0.9593925359990215
},
{
"nz": 18,
"r2": 0.9593997378572243
},
{
"nz": 21,
"r2": 0.9594009004329207
}
]
},
{
"channel": "top",
"best_r2": 0.9283646632651096,
"best_nz": 22,
"pareto": [
{
"nz": 2,
"r2": 0.8636623835462103
},
{
"nz": 5,
"r2": 0.9176885699701556
},
{
"nz": 6,
"r2": 0.9196961922610852
},
{
"nz": 11,
"r2": 0.9227131504032893
},
{
"nz": 13,
"r2": 0.926100716890473
},
{
"nz": 22,
"r2": 0.9283646632651096
}
]
},
{
"channel": "bottom",
"best_r2": 0.9318647363961834,
"best_nz": 21,
"pareto": [
{
"nz": 3,
"r2": 0.7872211556048209
},
{
"nz": 6,
"r2": 0.9223640246817683
},
{
"nz": 7,
"r2": 0.9258419638948643
},
{
"nz": 11,
"r2": 0.929107795801212
},
{
"nz": 16,
"r2": 0.9315566670173666
},
{
"nz": 21,
"r2": 0.9318647363961834
}
]
}
]
}

View File

@ -0,0 +1,110 @@
{
"scene": "illusion_1L",
"channels": [
{
"channel": "front",
"best_r2": 0.9793036424523165,
"best_nz": 21,
"pareto": [
{
"nz": 4,
"r2": 0.9718953011650306
},
{
"nz": 6,
"r2": 0.9752101462860064
},
{
"nz": 11,
"r2": 0.9785538576893853
},
{
"nz": 15,
"r2": 0.9791278336272012
},
{
"nz": 18,
"r2": 0.9792726941557117
},
{
"nz": 21,
"r2": 0.9793036424523165
}
]
},
{
"channel": "top",
"best_r2": 0.9838580289472151,
"best_nz": 22,
"pareto": [
{
"nz": 7,
"r2": 0.9786186299815743
},
{
"nz": 10,
"r2": 0.9828568398768021
},
{
"nz": 11,
"r2": 0.9833618417657272
},
{
"nz": 12,
"r2": 0.9835181072357578
},
{
"nz": 16,
"r2": 0.9837742843659423
},
{
"nz": 22,
"r2": 0.9838580289472151
}
]
},
{
"channel": "bottom",
"best_r2": 0.983658612471297,
"best_nz": 22,
"pareto": [
{
"nz": 4,
"r2": 0.9698703453821834
},
{
"nz": 6,
"r2": 0.9816905531339832
},
{
"nz": 7,
"r2": 0.9817463485828739
},
{
"nz": 8,
"r2": 0.9822685326747084
},
{
"nz": 11,
"r2": 0.9831202415012674
},
{
"nz": 12,
"r2": 0.983291741316277
},
{
"nz": 15,
"r2": 0.9836126332791877
},
{
"nz": 18,
"r2": 0.9836582680775273
},
{
"nz": 22,
"r2": 0.983658612471297
}
]
}
]
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,118 @@
{
"scene": "karman_re100",
"channels": [
{
"channel": "front",
"best_r2": 0.9950013415086292,
"best_nz": 21,
"pareto": [
{
"nz": 2,
"r2": 0.8681678005649112
},
{
"nz": 3,
"r2": 0.9692414821446799
},
{
"nz": 4,
"r2": 0.989378747581318
},
{
"nz": 6,
"r2": 0.9908017426802015
},
{
"nz": 12,
"r2": 0.9939890287801123
},
{
"nz": 13,
"r2": 0.9947660566975605
},
{
"nz": 18,
"r2": 0.9949963098276752
},
{
"nz": 21,
"r2": 0.9950013415086292
}
]
},
{
"channel": "top",
"best_r2": 0.9928439394510484,
"best_nz": 22,
"pareto": [
{
"nz": 1,
"r2": 0.44188145385324007
},
{
"nz": 2,
"r2": 0.9285296099294669
},
{
"nz": 6,
"r2": 0.9812946866555053
},
{
"nz": 12,
"r2": 0.9909377708950154
},
{
"nz": 17,
"r2": 0.9927843560231949
},
{
"nz": 20,
"r2": 0.992824536423302
},
{
"nz": 22,
"r2": 0.9928439394510484
}
]
},
{
"channel": "bottom",
"best_r2": 0.9965335484991993,
"best_nz": 22,
"pareto": [
{
"nz": 1,
"r2": 0.8456588970543057
},
{
"nz": 2,
"r2": 0.9030386794798415
},
{
"nz": 8,
"r2": 0.9947803148283133
},
{
"nz": 10,
"r2": 0.9962551509064745
},
{
"nz": 13,
"r2": 0.9963877299663628
},
{
"nz": 16,
"r2": 0.9964335809851655
},
{
"nz": 18,
"r2": 0.9965311727162228
},
{
"nz": 22,
"r2": 0.9965335484991993
}
]
}
]
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,133 @@
"""SINDy fitting for Illusion scenes.
Usage:
conda run -n pycuda_3_10 python sindy/run_illusion.py
conda run -n pycuda_3_10 python sindy/run_illusion.py --diameters 0.75,1.0
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from typing import List, Optional
import numpy as np
_REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
if _REPO not in sys.path:
sys.path.insert(0, _REPO)
_SRC = os.path.join(_REPO, "src")
if _SRC not in sys.path:
sys.path.insert(0, _SRC)
from SR_analysis.utils.sindy_fitter import fit_sindy, get_feature_matrix_from_data
from SR_analysis.configs import get_scene, get_scene_list
SINDY_DIR = os.path.join(os.path.dirname(__file__), "..", "sindy", "illusion")
THRESHOLDS = [0.0, 0.001, 0.002, 0.005, 0.01, 0.015, 0.02, 0.03, 0.05, 0.1]
def load_data(scene_name: str) -> tuple:
data_dir = os.path.join(os.path.dirname(__file__), "..", "data", "illusion", scene_name)
npz = np.load(os.path.join(data_dir, "controlled.npz"))
sensors = npz["sensors"].astype(np.float64)
forces = npz["forces"].astype(np.float64)
actions = npz["actions"].astype(np.float64)
return sensors, forces, actions
def run(scene_names: Optional[List[str]] = None):
if scene_names is None:
scene_names = get_scene_list("illusion")
per_scene = {}
for sn in scene_names:
print(f"\n{'='*60}")
print(f"Scene: {sn}")
print(f"{'='*60}")
cfg = get_scene(sn)
sensors, forces, actions_phys = load_data(sn)
mu = cfg["mu"]
print(f" T={sensors.shape[0]}, mu={mu:.6f}")
Theta_f, Theta_r, Y, fn_f, fn_r = get_feature_matrix_from_data(
sensors, forces, actions_phys, mu, u0=cfg["u0"],
alpha_mode=False, include_mu=True, n_warmup=2,
)
print(f" Front: {Theta_f.shape}, Rear: {Theta_r.shape}")
# Front channel
print(f"\n --- Front (no bias) ---")
front_results = fit_sindy(Theta_f, Y[:, 0], THRESHOLDS)
best_f = max(front_results, key=lambda r: r["r2"])
print(f" Best: th={best_f['threshold']:.4f} nz={best_f['nz']:2d} R2={best_f['r2']:.6f}")
# Top channel (rear shared-head)
print(f"\n --- Top (rear shared-head) ---")
top_results = fit_sindy(Theta_r, Y[:, 2], THRESHOLDS)
best_t = max(top_results, key=lambda r: r["r2"])
print(f" Best: th={best_t['threshold']:.4f} nz={best_t['nz']:2d} R2={best_t['r2']:.6f}")
# Bottom (independent)
print(f"\n --- Bottom (independent) ---")
bot_results = fit_sindy(Theta_r, Y[:, 1], THRESHOLDS)
best_b = max(bot_results, key=lambda r: r["r2"])
print(f" Best: th={best_b['threshold']:.4f} nz={best_b['nz']:2d} R2={best_b['r2']:.6f}")
per_scene[sn] = {
"scene": sn,
"re_code": cfg["re_code"],
"mu": mu,
"n_samples": Theta_f.shape[0],
"feature_names_front": fn_f,
"feature_names_rear": fn_r,
"front": {
"results": [{k: v for k, v in r.items() if k != "coef"} for r in front_results],
"best": {k: v for k, v in best_f.items() if k != "coef"},
"best_coef": best_f["coef"],
"sparsity_curve": [(r["threshold"], r["nz"], r["r2"]) for r in front_results],
},
"top": {
"results": [{k: v for k, v in r.items() if k != "coef"} for r in top_results],
"best": {k: v for k, v in best_t.items() if k != "coef"},
"best_coef": best_t["coef"],
"sparsity_curve": [(r["threshold"], r["nz"], r["r2"]) for r in top_results],
},
"bottom": {
"results": [{k: v for k, v in r.items() if k != "coef"} for r in bot_results],
"best": {k: v for k, v in best_b.items() if k != "coef"},
"best_coef": best_b["coef"],
"sparsity_curve": [(r["threshold"], r["nz"], r["r2"]) for r in bot_results],
},
}
os.makedirs(SINDY_DIR, exist_ok=True)
out_path = os.path.join(SINDY_DIR, "sindy_results.json")
result = {"thresholds": THRESHOLDS, "per_scene": per_scene}
with open(out_path, "w") as f:
json.dump(result, f, indent=2)
print(f"\nSaved: {out_path}")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--diameters", type=str, default=None,
help="Comma-separated diameters (e.g. 0.75,1.0,1.5)")
ap.add_argument("--scene-names", type=str, default=None)
args = ap.parse_args()
if args.scene_names:
names = [s.strip() for s in args.scene_names.split(",")]
elif args.diameters:
names = [f"illusion_{d.strip()}L" for d in args.diameters.split(",")]
else:
names = None
run(names)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,159 @@
"""SINDy fitting for Karman cloak scenes.
Runs STLSQ threshold grid for Karman scenes (training Re or all Re).
Usage:
conda run -n pycuda_3_10 python sindy/run_karman.py --re-codes 50,100,200
conda run -n pycuda_3_10 python sindy/run_karman.py
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from typing import List, Optional
import numpy as np
_REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
if _REPO not in sys.path:
sys.path.insert(0, _REPO)
_SRC = os.path.join(_REPO, "src")
if _SRC not in sys.path:
sys.path.insert(0, _SRC)
from SR_analysis.utils.sindy_fitter import fit_sindy, get_feature_matrix_from_data
from SR_analysis.utils.feature_builder import ALL_FEAT_KEYS, U0
from SR_analysis.configs import get_scene, get_scene_list, SCENES
SINDY_DIR = os.path.join(os.path.dirname(__file__), "..", "sindy", "karman")
THRESHOLDS = [0.0, 0.001, 0.002, 0.005, 0.01, 0.015, 0.02, 0.03, 0.05, 0.1]
def load_data(scene_name: str, scene_subdir: str = "karman") -> tuple:
"""Load sensors/forces/actions from a scene's controlled.npz."""
data_dir = os.path.join(os.path.dirname(__file__), "..", "data", scene_subdir, scene_name)
npz = np.load(os.path.join(data_dir, "controlled.npz"))
sensors = npz["sensors"].astype(np.float64)
forces = npz["forces"].astype(np.float64)
actions = npz["actions"].astype(np.float64)
return sensors, forces, actions
def run(scene_names: Optional[List[str]] = None):
"""Run SINDy fitting for given scene names."""
if scene_names is None:
scene_names = get_scene_list("karman")
per_scene = {}
for sn in scene_names:
print(f"\n{'='*60}")
print(f"Scene: {sn}")
print(f"{'='*60}")
cfg = get_scene(sn)
sensors, forces, actions_phys = load_data(sn, cfg["scene_id"])
T = sensors.shape[0]
mu = cfg["mu"]
print(f" T={T}, mu={mu:.6f}")
# Build feature matrices
Theta_f, Theta_r, Y, fn_f, fn_r = get_feature_matrix_from_data(
sensors, forces, actions_phys, mu, u0=cfg["u0"],
alpha_mode=False, include_mu=True, n_warmup=2,
)
print(f" Front features: {Theta_f.shape}")
print(f" Rear features: {Theta_r.shape}")
print(f" Y: {Y.shape}")
# Front channel (ci=0, no bias)
print(f"\n --- Front (no bias) ---")
front_results = fit_sindy(Theta_f, Y[:, 0], THRESHOLDS)
best_f = max(front_results, key=lambda r: r["r2"])
print(f" Best: th={best_f['threshold']:.4f} nz={best_f['nz']:2d} R2={best_f['r2']:.6f}")
# Top channel (ci=2, rear shared-head)
print(f"\n --- Top (rear shared-head) ---")
top_results = fit_sindy(Theta_r, Y[:, 2], THRESHOLDS)
best_t = max(top_results, key=lambda r: r["r2"])
print(f" Best: th={best_t['threshold']:.4f} nz={best_t['nz']:2d} R2={best_t['r2']:.6f}")
# Bottom (independent, for comparison)
print(f"\n --- Bottom (independent) ---")
bot_results = fit_sindy(Theta_r, Y[:, 1], THRESHOLDS)
best_b = max(bot_results, key=lambda r: r["r2"])
print(f" Best: th={best_b['threshold']:.4f} nz={best_b['nz']:2d} R2={best_b['r2']:.6f}")
per_scene[sn] = {
"scene": sn,
"re_code": cfg["re_code"],
"mu": mu,
"n_samples": Theta_f.shape[0],
"n_features_front": Theta_f.shape[1],
"n_features_rear": Theta_r.shape[1],
"feature_names_front": fn_f,
"feature_names_rear": fn_r,
"front": {
"results": [{k: v for k, v in r.items() if k != "coef"}
for r in front_results],
"best": {k: v for k, v in best_f.items() if k != "coef"},
"best_coef": best_f["coef"],
"sparsity_curve": [(r["threshold"], r["nz"], r["r2"])
for r in front_results],
},
"top": {
"results": [{k: v for k, v in r.items() if k != "coef"}
for r in top_results],
"best": {k: v for k, v in best_t.items() if k != "coef"},
"best_coef": best_t["coef"],
"sparsity_curve": [(r["threshold"], r["nz"], r["r2"])
for r in top_results],
},
"bottom": {
"results": [{k: v for k, v in r.items() if k != "coef"}
for r in bot_results],
"best": {k: v for k, v in best_b.items() if k != "coef"},
"best_coef": best_b["coef"],
"sparsity_curve": [(r["threshold"], r["nz"], r["r2"])
for r in bot_results],
},
}
# Save
os.makedirs(SINDY_DIR, exist_ok=True)
out_path = os.path.join(SINDY_DIR, "sindy_results.json")
result = {
"thresholds": THRESHOLDS,
"all_feature_names_front": fn_f,
"all_feature_names_rear": fn_r,
"per_scene": per_scene,
}
with open(out_path, "w") as f:
json.dump(result, f, indent=2)
print(f"\nSaved: {out_path}")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--re-codes", type=str, default=None,
help="Comma-separated Re codes (default: all karman)")
ap.add_argument("--scene-names", type=str, default=None,
help="Comma-separated scene names (overrides --re-codes)")
args = ap.parse_args()
if args.scene_names:
names = [s.strip() for s in args.scene_names.split(",")]
elif args.re_codes:
codes = [int(r) for r in args.re_codes.split(",")]
names = [f"karman_re{rc}" for rc in codes]
else:
names = None # all karman
run(names)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,148 @@
"""Pareto analysis of SINDy threshold grid for any scene.
Loads sindy_results.json and prints Pareto-optimal tradeoffs.
Usage:
python sindy/run_pareto.py --scene karman_re100
python sindy/run_pareto.py --scene illusion_1L
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from typing import List, Tuple
import numpy as np
_REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
if _REPO not in sys.path:
sys.path.insert(0, _REPO)
_SRC = os.path.join(_REPO, "src")
if _SRC not in sys.path:
sys.path.insert(0, _SRC)
def load_sindy(scene_name: str, sindy_dir: str) -> dict:
"""Load sindy_results.json for a scene."""
path = os.path.join(sindy_dir, scene_name, "sindy_results.json")
if os.path.isfile(path):
return json.load(path)
raise FileNotFoundError(f"Missing {path}")
def pareto(points: List[Tuple[float, float]]) -> List[Tuple[float, float]]:
"""Compute Pareto frontier: lower nz and lower error is better."""
sp = sorted(points, key=lambda x: (x[0], x[1]))
front, best = [], float("inf")
for c, e in sp:
if e < best:
front.append((c, e))
best = e
return front
def fmt(fn: List[str], coef: List[float], threshold: float) -> str:
"""Format a control law string, showing terms above relative threshold."""
ca = np.array(coef, dtype=np.float64)
sc = np.max(np.abs(ca)) if np.max(np.abs(ca)) > 0 else 1.0
mask = np.abs(ca) / sc >= threshold
terms = [f"{ca[i]:+.4f}*{fn[i]}" for i in range(len(fn)) if mask[i]]
return " ".join(terms) if terms else "0"
def analyze(name: str, feat_names: List[str], channel_data: dict) -> dict:
"""Print and return Pareto analysis for one channel."""
grid = channel_data["results"]
pts = [(g["nz"], 1.0 - g["r2"]) for g in grid]
front = pareto(pts)
best = channel_data["best"]
coef = channel_data["best_coef"]
print(f"\n {name}:")
for nz, err in front:
r2 = 1.0 - err
for g in grid:
if g["nz"] == nz and abs(1.0 - g["r2"] - err) < 1e-10:
th = g["threshold"]
print(f" nz={nz:2d} R2={r2:.6f} th={th:.4f}")
if nz <= 8:
s = fmt(feat_names, coef, th)
print(f" {s[:120]}")
print(f" Best: R2={best['r2']:.6f}")
return {
"channel": name,
"best_r2": best["r2"],
"best_nz": sum(1 for c in coef if abs(float(c)) > 1e-8),
"pareto": [{"nz": nz, "r2": 1.0 - e} for nz, e in front],
}
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--scene", type=str, required=True,
help="Scene name (e.g. karman_re100, illusion_1L)")
ap.add_argument("--sindy-dir", type=str, default=None,
help="SINDy results directory")
ap.add_argument("--out", type=str, default=None,
help="Output JSON path")
args = ap.parse_args()
if args.sindy_dir is None:
args.sindy_dir = os.path.join(os.path.dirname(__file__), "..", "sindy")
# Map scene name to subdirectory
# Extract series prefix: karman_* -> karman, illusion_* -> illusion, etc.
first_part = args.scene.split("_")[0]
known_series = {"karman": "karman", "illusion": "illusion", "vortex": "vortex", "steady": "steady"}
series_dir = known_series.get(first_part, first_part)
# Try sindy/{series}/sindy_results.json
json_path = os.path.join(args.sindy_dir, series_dir, "sindy_results.json")
if not os.path.isfile(json_path):
# Fallback: try flat file
json_path = os.path.join(args.sindy_dir, "sindy_results.json")
if not os.path.isfile(json_path):
print(f"ERROR: No sindy results found for {args.scene}")
print(f" Tried: {os.path.join(args.sindy_dir, series_dir, 'sindy_results.json')}")
print(f" Tried: {json_path}")
return 1
with open(json_path) as f:
data = json.load(f)
# Look up the scene in per_scene (multi-scene format)
per = data.get("per_scene", {}).get(args.scene)
if per is not None:
fn_f = per["feature_names_front"]
fn_r = per["feature_names_rear"]
chs = [("front", fn_f, per["front"]),
("top", fn_r, per["top"]),
("bottom", fn_r, per["bottom"])]
else:
# Single-scene format
fn_f = data.get("feature_names_front")
fn_r = data.get("feature_names_rear")
if fn_f is None:
print(f"ERROR: No scene data found for {args.scene} in {json_path}")
return 1
chs = [("front", fn_f, data["front"]),
("top", fn_r, data["top"]),
("bottom", fn_r, data["bottom"])]
print(f"Pareto SR: {args.scene}")
results = {"scene": args.scene,
"channels": [analyze(*c) for c in chs]}
if args.out:
os.makedirs(os.path.dirname(args.out), exist_ok=True)
with open(args.out, "w") as f:
json.dump(results, f, indent=2)
print(f"Saved: {args.out}")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,133 @@
"""SINDy fitting for Vortex scenes.
Usage:
conda run -n pycuda_3_10 python sindy/run_vortex.py
conda run -n pycuda_3_10 python sindy/run_vortex.py --vortex-types lamb,taylor
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from typing import List, Optional
import numpy as np
_REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
if _REPO not in sys.path:
sys.path.insert(0, _REPO)
_SRC = os.path.join(_REPO, "src")
if _SRC not in sys.path:
sys.path.insert(0, _SRC)
from SR_analysis.utils.sindy_fitter import fit_sindy, get_feature_matrix_from_data
from SR_analysis.configs import get_scene, get_scene_list
SINDY_DIR = os.path.join(os.path.dirname(__file__), "..", "sindy", "vortex")
THRESHOLDS = [0.0, 0.001, 0.002, 0.005, 0.01, 0.015, 0.02, 0.03, 0.05, 0.1]
def load_data(scene_name: str) -> tuple:
data_dir = os.path.join(os.path.dirname(__file__), "..", "data", "vortex", scene_name)
npz = np.load(os.path.join(data_dir, "controlled.npz"))
sensors = npz["sensors"].astype(np.float64)
forces = npz["forces"].astype(np.float64)
actions = npz["actions"].astype(np.float64)
return sensors, forces, actions
def run(scene_names: Optional[List[str]] = None):
if scene_names is None:
scene_names = get_scene_list("vortex")
per_scene = {}
for sn in scene_names:
print(f"\n{'='*60}")
print(f"Scene: {sn}")
print(f"{'='*60}")
cfg = get_scene(sn)
sensors, forces, actions_phys = load_data(sn)
mu = cfg["mu"]
print(f" T={sensors.shape[0]}, mu={mu:.6f}")
Theta_f, Theta_r, Y, fn_f, fn_r = get_feature_matrix_from_data(
sensors, forces, actions_phys, mu, u0=cfg["u0"],
alpha_mode=False, include_mu=True, n_warmup=2,
)
print(f" Front: {Theta_f.shape}, Rear: {Theta_r.shape}")
# Front channel
print(f"\n --- Front (no bias) ---")
front_results = fit_sindy(Theta_f, Y[:, 0], THRESHOLDS)
best_f = max(front_results, key=lambda r: r["r2"])
print(f" Best: th={best_f['threshold']:.4f} nz={best_f['nz']:2d} R2={best_f['r2']:.6f}")
# Top channel
print(f"\n --- Top (rear shared-head) ---")
top_results = fit_sindy(Theta_r, Y[:, 2], THRESHOLDS)
best_t = max(top_results, key=lambda r: r["r2"])
print(f" Best: th={best_t['threshold']:.4f} nz={best_t['nz']:2d} R2={best_t['r2']:.6f}")
# Bottom
print(f"\n --- Bottom (independent) ---")
bot_results = fit_sindy(Theta_r, Y[:, 1], THRESHOLDS)
best_b = max(bot_results, key=lambda r: r["r2"])
print(f" Best: th={best_b['threshold']:.4f} nz={best_b['nz']:2d} R2={best_b['r2']:.6f}")
per_scene[sn] = {
"scene": sn,
"re_code": cfg["re_code"],
"mu": mu,
"n_samples": Theta_f.shape[0],
"feature_names_front": fn_f,
"feature_names_rear": fn_r,
"front": {
"results": [{k: v for k, v in r.items() if k != "coef"} for r in front_results],
"best": {k: v for k, v in best_f.items() if k != "coef"},
"best_coef": best_f["coef"],
"sparsity_curve": [(r["threshold"], r["nz"], r["r2"]) for r in front_results],
},
"top": {
"results": [{k: v for k, v in r.items() if k != "coef"} for r in top_results],
"best": {k: v for k, v in best_t.items() if k != "coef"},
"best_coef": best_t["coef"],
"sparsity_curve": [(r["threshold"], r["nz"], r["r2"]) for r in top_results],
},
"bottom": {
"results": [{k: v for k, v in r.items() if k != "coef"} for r in bot_results],
"best": {k: v for k, v in best_b.items() if k != "coef"},
"best_coef": best_b["coef"],
"sparsity_curve": [(r["threshold"], r["nz"], r["r2"]) for r in bot_results],
},
}
os.makedirs(SINDY_DIR, exist_ok=True)
out_path = os.path.join(SINDY_DIR, "sindy_results.json")
result = {"thresholds": THRESHOLDS, "per_scene": per_scene}
with open(out_path, "w") as f:
json.dump(result, f, indent=2)
print(f"\nSaved: {out_path}")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--vortex-types", type=str, default=None,
help="Comma-separated vortex types (e.g. lamb,taylor)")
ap.add_argument("--scene-names", type=str, default=None)
args = ap.parse_args()
if args.scene_names:
names = [s.strip() for s in args.scene_names.split(",")]
elif args.vortex_types:
names = [f"vortex_{v.strip()}" for v in args.vortex_types.split(",")]
else:
names = None
run(names)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,110 @@
{
"scene": "vortex_lamb",
"channels": [
{
"channel": "front",
"best_r2": 0.9035051679446613,
"best_nz": 21,
"pareto": [
{
"nz": 3,
"r2": 0.8536388609680176
},
{
"nz": 8,
"r2": 0.8957625139671737
},
{
"nz": 9,
"r2": 0.8985847902462778
},
{
"nz": 16,
"r2": 0.9017891237504362
},
{
"nz": 20,
"r2": 0.9035028044287374
},
{
"nz": 21,
"r2": 0.9035051679446613
}
]
},
{
"channel": "top",
"best_r2": 0.979810012198325,
"best_nz": 22,
"pareto": [
{
"nz": 0,
"r2": -0.2926478447353347
},
{
"nz": 1,
"r2": 0.9262664550660489
},
{
"nz": 2,
"r2": 0.9602153300731789
},
{
"nz": 5,
"r2": 0.9685659511773673
},
{
"nz": 6,
"r2": 0.9752589669369754
},
{
"nz": 19,
"r2": 0.9796029724451923
},
{
"nz": 20,
"r2": 0.9797408009698567
},
{
"nz": 22,
"r2": 0.979810012198325
}
]
},
{
"channel": "bottom",
"best_r2": 0.9334422885170565,
"best_nz": 22,
"pareto": [
{
"nz": 0,
"r2": -5.4349952892154265
},
{
"nz": 1,
"r2": 0.6935730348615337
},
{
"nz": 5,
"r2": 0.8721441125489685
},
{
"nz": 7,
"r2": 0.8963211646824344
},
{
"nz": 11,
"r2": 0.9294742132351927
},
{
"nz": 15,
"r2": 0.9318911728011019
},
{
"nz": 22,
"r2": 0.9334422885170565
}
]
}
]
}

View File

@ -0,0 +1,58 @@
{
"scene": "vortex_taylor",
"channels": [
{
"channel": "front",
"best_r2": 0.9603622630700551,
"best_nz": 21,
"pareto": [
{
"nz": 0,
"r2": -1762.7026716770156
},
{
"nz": 21,
"r2": 0.9603622630700551
}
]
},
{
"channel": "top",
"best_r2": 0.809824114603052,
"best_nz": 22,
"pareto": [
{
"nz": 0,
"r2": -3813.3981416722418
},
{
"nz": 1,
"r2": 4.909409545561516e-09
},
{
"nz": 22,
"r2": 0.809824114603052
}
]
},
{
"channel": "bottom",
"best_r2": 0.6431303693566448,
"best_nz": 22,
"pareto": [
{
"nz": 0,
"r2": -11389.175969107222
},
{
"nz": 1,
"r2": 2.5346330034814457e-08
},
{
"nz": 22,
"r2": 0.6431303693566448
}
]
}
]
}

Some files were not shown because too many files have changed in this diff Show More