第二轮:整理两个工作目录
@ -17,9 +17,9 @@
|
||||
#define NZ 1
|
||||
#define DIM 2
|
||||
#define NQ 9
|
||||
#define VIS 0.008
|
||||
#define VIS 0.004
|
||||
#define RHO 1.0
|
||||
#define U0 0.02
|
||||
#define U0 0.01
|
||||
|
||||
// constants
|
||||
#define PI 3.141592653589793238
|
||||
@ -33,5 +33,5 @@
|
||||
#define V_TAYLOR 0b00000001
|
||||
|
||||
// variables
|
||||
#define N_OBJS 7
|
||||
#define N_OBJS 6
|
||||
// #define N_SENS 2
|
||||
236
src/CCD_analysis/README.md
Normal 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 |
|
||||
182
src/CCD_analysis/ccd/run_ccd.py
Normal 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
@ -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
|
||||
13
src/CCD_analysis/output_redux/illusion/meta.json
Normal 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
|
||||
}
|
||||
25
src/CCD_analysis/output_redux/illusion/norm.json
Normal 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
|
||||
]
|
||||
}
|
||||
194
src/CCD_analysis/output_redux/illusion/target_harmonics.json
Normal 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
|
||||
]
|
||||
}
|
||||
]
|
||||
@ -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": {}
|
||||
}
|
||||
BIN
src/CCD_analysis/output_redux/illusion/vorticity_controlled.png
Normal file
|
After Width: | Height: | Size: 207 KiB |
BIN
src/CCD_analysis/output_redux/illusion/vorticity_target.png
Normal file
|
After Width: | Height: | Size: 212 KiB |
|
After Width: | Height: | Size: 241 KiB |
13
src/CCD_analysis/output_redux/karman/meta.json
Normal 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
|
||||
}
|
||||
24
src/CCD_analysis/output_redux/karman/norm.json
Normal 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
|
||||
]
|
||||
}
|
||||
15
src/CCD_analysis/output_redux/karman/validation_report.json
Normal 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
|
||||
}
|
||||
}
|
||||
BIN
src/CCD_analysis/output_redux/karman/vorticity_controlled.png
Normal file
|
After Width: | Height: | Size: 212 KiB |
BIN
src/CCD_analysis/output_redux/karman/vorticity_target.png
Normal file
|
After Width: | Height: | Size: 233 KiB |
BIN
src/CCD_analysis/output_redux/karman/vorticity_uncontrolled.png
Normal file
|
After Width: | Height: | Size: 265 KiB |
11
src/CCD_analysis/output_redux/pinball/meta.json
Normal 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
|
||||
}
|
||||
BIN
src/CCD_analysis/output_redux/pinball/vorticity.png
Normal file
|
After Width: | Height: | Size: 203 KiB |
211
src/CCD_analysis/output_redux/review_report.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
20
src/CCD_analysis/output_redux/steady_cloak/meta.json
Normal 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
|
||||
}
|
||||
BIN
src/CCD_analysis/output_redux/steady_cloak/vorticity.png
Normal file
|
After Width: | Height: | Size: 91 KiB |
@ -1 +0,0 @@
|
||||
# CCD_analysis scripts package
|
||||
233
src/CCD_analysis/scripts/collect_illusion.py
Normal 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())
|
||||
182
src/CCD_analysis/scripts/collect_karman.py
Normal 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())
|
||||
104
src/CCD_analysis/scripts/collect_pinball.py
Normal 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")
|
||||
117
src/CCD_analysis/scripts/collect_steady_cloak.py
Normal 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")
|
||||
@ -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()
|
||||
@ -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())
|
||||
@ -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())
|
||||
@ -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())
|
||||
@ -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())
|
||||
@ -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())
|
||||
@ -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())
|
||||
162
src/CCD_analysis/scripts/resample.py
Normal 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()
|
||||
@ -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)
|
||||
@ -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()
|
||||
93
src/CCD_analysis/steady/run_steady.py
Normal 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())
|
||||
12
src/CCD_analysis/utils/__init__.py
Normal 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,
|
||||
)
|
||||
334
src/CCD_analysis/utils/cfd_interface.py
Normal 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
|
||||
@ -1,9 +1,7 @@
|
||||
# CCD_analysis/scripts/analysis_utils.py
|
||||
"""CPU-only analysis utilities for Phase 2, 3, 4.
|
||||
"""CPU-only analysis utilities for CCD pipeline.
|
||||
|
||||
No pycuda or LegacyCelerisLab dependency — can run with plain python3.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
@ -15,22 +13,8 @@ import numpy as np
|
||||
# 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.
|
||||
|
||||
Returns
|
||||
-------
|
||||
f_dom, period, peak_power
|
||||
"""
|
||||
def detect_dominant_frequency(signal: np.ndarray, sample_dt: float) -> Tuple[float, float, float]:
|
||||
"""Detect dominant frequency via FFT. Returns (f_dom, period, peak_power)."""
|
||||
n = len(signal)
|
||||
if n < 16:
|
||||
return 0.0, 0.0, 0.0
|
||||
@ -44,18 +28,10 @@ def detect_dominant_frequency(
|
||||
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.
|
||||
|
||||
Uses rising zero-crossings of (signal - mean) for cycle detection.
|
||||
|
||||
Returns (cv_T, mean_T, cycle_lengths).
|
||||
"""
|
||||
def detect_cycle_stability(signal: np.ndarray, sample_dt: float) -> Tuple[float, float, list]:
|
||||
"""Detect cycle lengths using rising zero-crossings. Returns (CV_T, mean_T, lengths)."""
|
||||
y = signal - np.mean(signal)
|
||||
sign = np.sign(y)
|
||||
crossings = np.where((sign[:-1] < 0) & (sign[1:] > 0))[0]
|
||||
crossings = np.where((np.sign(y[:-1]) < 0) & (np.sign(y[1:]) > 0))[0]
|
||||
if len(crossings) < 2:
|
||||
return 0.0, 0.0, []
|
||||
cycle_lengths = np.diff(crossings).astype(float) * sample_dt
|
||||
@ -71,23 +47,16 @@ def detect_cycle_stability(
|
||||
# Phase resampling
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def phase_resample(
|
||||
data: np.ndarray,
|
||||
cycle_starts: List[int],
|
||||
n_pts: int = 24,
|
||||
) -> np.ndarray:
|
||||
def phase_resample(data: np.ndarray, cycle_starts: List[int], n_pts: int = 24) -> np.ndarray:
|
||||
"""Resample a multi-channel signal to uniform phase points per cycle.
|
||||
|
||||
Uses piecewise linear interpolation (no scipy dependency).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
data : (T, C) ndarray
|
||||
Multi-channel time series.
|
||||
cycle_starts : list of int
|
||||
Indices where each cycle starts.
|
||||
n_pts : int
|
||||
Number of phase points per cycle.
|
||||
data : (T, C) ndarray — multi-channel time series.
|
||||
cycle_starts : list of int — indices where each cycle starts.
|
||||
n_pts : int — number of phase points per cycle.
|
||||
|
||||
Returns
|
||||
-------
|
||||
@ -99,7 +68,6 @@ def phase_resample(
|
||||
|
||||
if data.ndim == 1:
|
||||
data = data[:, None]
|
||||
|
||||
C = data.shape[1]
|
||||
out = np.zeros((n_cycles, n_pts, C), dtype=np.float64)
|
||||
|
||||
@ -111,10 +79,8 @@ def phase_resample(
|
||||
if seg_len < 2:
|
||||
continue
|
||||
|
||||
# Linear interpolation from original phase grid to uniform grid
|
||||
old_idx = np.linspace(0, 1, seg_len)
|
||||
new_idx = np.linspace(0, 1, n_pts, endpoint=False)
|
||||
|
||||
for ch in range(C):
|
||||
out[c, :, ch] = np.interp(new_idx, old_idx, segment[:, ch])
|
||||
|
||||
@ -125,38 +91,24 @@ def phase_resample(
|
||||
# POD
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def compute_pod(
|
||||
snapshot_matrix: np.ndarray
|
||||
) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
|
||||
"""Compute POD from snapshot matrix.
|
||||
def compute_pod(snapshot_matrix: np.ndarray) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
|
||||
"""Compute POD from snapshot matrix (n_points, n_snapshots).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
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)
|
||||
Returns (mean_field, modes, singular_values, coefficients).
|
||||
"""
|
||||
mean_field = np.mean(snapshot_matrix, axis=1)
|
||||
Q = snapshot_matrix - mean_field[:, None]
|
||||
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
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@ -164,32 +116,24 @@ def e95_index(cumulative_energy: np.ndarray) -> int:
|
||||
# CCD (reduced, Lyu23-inspired)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def compute_reduced_ccd(
|
||||
pod_coeffs: np.ndarray,
|
||||
observable: np.ndarray,
|
||||
Q_delay: int = 12,
|
||||
) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
|
||||
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.
|
||||
pod_coeffs : (r, N) ndarray — standardized POD coefficients.
|
||||
observable : (m, N) ndarray — standardized observable.
|
||||
Q_delay : int — number of delay steps.
|
||||
|
||||
Returns
|
||||
-------
|
||||
W : (r, min(r, m*Q_delay))
|
||||
sigma : (min_dim,)
|
||||
z : (min_dim, N)
|
||||
W : (r, min(r, m*Q_delay)) — CCD directions.
|
||||
sigma : (min_dim,) — singular values.
|
||||
z : (min_dim, N) — CCD temporal coefficients.
|
||||
"""
|
||||
N = pod_coeffs.shape[1]
|
||||
m = observable.shape[0]
|
||||
|
||||
# Build delay matrix: for each time step, P includes Q_delay shifted versions
|
||||
half = Q_delay // 2
|
||||
rows = []
|
||||
for shift in range(-half, half + 1):
|
||||
@ -201,7 +145,7 @@ def compute_reduced_ccd(
|
||||
rows.append(shifted)
|
||||
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_std = np.std(P, axis=1, keepdims=True) + 1e-12
|
||||
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_z = (pod_coeffs - A_mean) / A_std
|
||||
|
||||
# CCD matrix
|
||||
C = P_z @ A_z.T / (N * np.sqrt(float(Q_delay)))
|
||||
|
||||
# SVD
|
||||
R, s, Wt = np.linalg.svd(C, full_matrices=False)
|
||||
W = Wt.T # (r, min_dim)
|
||||
|
||||
# CCD coefficients
|
||||
z = W.T @ A_z # (min_dim, N)
|
||||
|
||||
W = Wt.T
|
||||
z = W.T @ A_z
|
||||
return W, s, z
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stack velocity fields into snapshot matrix
|
||||
# Field stacking helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
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, ux and uy are concatenated.
|
||||
Returns (2*nx*ny, N) matrix.
|
||||
"""
|
||||
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 (2*nx*ny, N)."""
|
||||
snapshots = []
|
||||
for ux, uy in zip(ux_fields, uy_fields):
|
||||
q = np.concatenate([ux.ravel(), uy.ravel()])
|
||||
@ -243,24 +174,8 @@ def stack_velocity_fields(
|
||||
return np.column_stack(snapshots)
|
||||
|
||||
|
||||
def unstack_velocity_modes(
|
||||
modes: np.ndarray, ny: int, nx: int, n_modes: int = 6
|
||||
) -> 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).
|
||||
"""
|
||||
def unstack_velocity_modes(modes: np.ndarray, ny: int, nx: int, n_modes: int = 6) -> Tuple[List[np.ndarray], List[np.ndarray]]:
|
||||
"""Unstack POD/CCD modes back into ux, uy fields."""
|
||||
ux_list, uy_list = [], []
|
||||
half = nx * ny
|
||||
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))
|
||||
uy_list.append(mode[half:].reshape(ny, nx))
|
||||
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
@ -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 |
|
||||
185
src/SR_analysis/compare/illusion_shared_core.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
218
src/SR_analysis/compare/karman_shared_core.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
152
src/SR_analysis/compare/shared_core.py
Normal 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()
|
||||
158
src/SR_analysis/compare/support_overlap.py
Normal 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
@ -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
|
||||
21
src/SR_analysis/data/illusion/illusion_0.75L/config.json
Normal 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
|
||||
}
|
||||
24
src/SR_analysis/data/illusion/illusion_0.75L/norm.json
Normal 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
|
||||
]
|
||||
}
|
||||
5
src/SR_analysis/data/illusion/illusion_0.75L/result.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"scene": "illusion_0.75L",
|
||||
"controlled": true,
|
||||
"similarity": 0.18393706196948187
|
||||
}
|
||||
21
src/SR_analysis/data/illusion/illusion_1.5L/config.json
Normal 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
|
||||
}
|
||||
24
src/SR_analysis/data/illusion/illusion_1.5L/norm.json
Normal 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
|
||||
]
|
||||
}
|
||||
5
src/SR_analysis/data/illusion/illusion_1.5L/result.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"scene": "illusion_15L",
|
||||
"controlled": true,
|
||||
"similarity": 0.30179914651024675
|
||||
}
|
||||
21
src/SR_analysis/data/illusion/illusion_1L/config.json
Normal 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
|
||||
}
|
||||
24
src/SR_analysis/data/illusion/illusion_1L/norm.json
Normal 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
|
||||
]
|
||||
}
|
||||
5
src/SR_analysis/data/illusion/illusion_1L/result.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"scene": "illusion_1.0L",
|
||||
"controlled": true,
|
||||
"similarity": 0.5543174409436081
|
||||
}
|
||||
20
src/SR_analysis/data/karman/karman_re100/config.json
Normal 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
|
||||
}
|
||||
24
src/SR_analysis/data/karman/karman_re100/norm.json
Normal 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
|
||||
]
|
||||
}
|
||||
6
src/SR_analysis/data/karman/karman_re100/result.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"scene": "karman_re100",
|
||||
"controlled": true,
|
||||
"avg_reward_last100": 0.6665451352155793,
|
||||
"similarity": 0.9538050162761162
|
||||
}
|
||||
|
After Width: | Height: | Size: 206 KiB |
|
After Width: | Height: | Size: 266 KiB |
20
src/SR_analysis/data/karman/karman_re200/config.json
Normal 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
|
||||
}
|
||||
24
src/SR_analysis/data/karman/karman_re200/norm.json
Normal 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
|
||||
]
|
||||
}
|
||||
6
src/SR_analysis/data/karman/karman_re200/result.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"scene": "karman_re200",
|
||||
"controlled": true,
|
||||
"avg_reward_last100": 0.3298547239579881,
|
||||
"similarity": 0.8842106151498026
|
||||
}
|
||||
|
After Width: | Height: | Size: 232 KiB |
|
After Width: | Height: | Size: 270 KiB |
10
src/SR_analysis/data/karman/karman_re400/config.json
Normal 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"
|
||||
}
|
||||
24
src/SR_analysis/data/karman/karman_re400/norm.json
Normal 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
|
||||
]
|
||||
}
|
||||
7
src/SR_analysis/data/karman/karman_re400/result.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"re_code": 400,
|
||||
"uncontrolled": true,
|
||||
"controlled": true,
|
||||
"avg_reward_last100": 0.4389868174760177,
|
||||
"similarity": 0.7950085552241137
|
||||
}
|
||||
|
After Width: | Height: | Size: 250 KiB |
|
After Width: | Height: | Size: 274 KiB |
20
src/SR_analysis/data/karman/karman_re50/config.json
Normal 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
|
||||
}
|
||||
24
src/SR_analysis/data/karman/karman_re50/norm.json
Normal 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
|
||||
]
|
||||
}
|
||||
6
src/SR_analysis/data/karman/karman_re50/result.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"scene": "karman_re50",
|
||||
"controlled": true,
|
||||
"avg_reward_last100": 0.5020737935656798,
|
||||
"similarity": 0.9614716421942122
|
||||
}
|
||||
BIN
src/SR_analysis/data/karman/karman_re50/vorticity_controlled.png
Normal file
|
After Width: | Height: | Size: 188 KiB |
|
After Width: | Height: | Size: 264 KiB |
23
src/SR_analysis/data/vortex/vortex_lamb/config.json
Normal 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
|
||||
}
|
||||
24
src/SR_analysis/data/vortex/vortex_lamb/norm.json
Normal 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
|
||||
]
|
||||
}
|
||||
5
src/SR_analysis/data/vortex/vortex_lamb/result.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"scene": "vortex_lamb",
|
||||
"controlled": true,
|
||||
"similarity": 0.9421189523977421
|
||||
}
|
||||
23
src/SR_analysis/data/vortex/vortex_taylor/config.json
Normal 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
|
||||
}
|
||||
24
src/SR_analysis/data/vortex/vortex_taylor/norm.json
Normal 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
|
||||
]
|
||||
}
|
||||
5
src/SR_analysis/data/vortex/vortex_taylor/result.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"scene": "vortex_taylor",
|
||||
"controlled": true,
|
||||
"similarity": 0.9158487825490536
|
||||
}
|
||||
842
src/SR_analysis/pysr.md
Normal 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:
|
||||
|
||||

|
||||
|
||||
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).
|
||||
309
src/SR_analysis/scripts/infer_illusion.py
Normal 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()
|
||||
253
src/SR_analysis/scripts/infer_karman.py
Normal 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()
|
||||
300
src/SR_analysis/scripts/infer_vortex.py
Normal 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()
|
||||
102
src/SR_analysis/sindy/illusion/pareto_illusion_1.5L.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
110
src/SR_analysis/sindy/illusion/pareto_illusion_1L.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
1486
src/SR_analysis/sindy/illusion/sindy_results.json
Normal file
118
src/SR_analysis/sindy/karman/pareto_karman_re100.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
2031
src/SR_analysis/sindy/karman/sindy_results.json
Normal file
133
src/SR_analysis/sindy/run_illusion.py
Normal 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()
|
||||
159
src/SR_analysis/sindy/run_karman.py
Normal 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()
|
||||
148
src/SR_analysis/sindy/run_pareto.py
Normal 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()
|
||||
133
src/SR_analysis/sindy/run_vortex.py
Normal 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()
|
||||
110
src/SR_analysis/sindy/vortex/pareto_vortex_lamb.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
58
src/SR_analysis/sindy/vortex/pareto_vortex_taylor.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||