diff --git a/LegacyCelerisLab/kernels/macros.h b/LegacyCelerisLab/kernels/macros.h index a6b1542..d3af419 100644 --- a/LegacyCelerisLab/kernels/macros.h +++ b/LegacyCelerisLab/kernels/macros.h @@ -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 \ No newline at end of file diff --git a/src/CCD_analysis/README.md b/src/CCD_analysis/README.md new file mode 100644 index 0000000..51c1468 --- /dev/null +++ b/src/CCD_analysis/README.md @@ -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 | diff --git a/src/CCD_analysis/ccd/run_ccd.py b/src/CCD_analysis/ccd/run_ccd.py new file mode 100644 index 0000000..667e68d --- /dev/null +++ b/src/CCD_analysis/ccd/run_ccd.py @@ -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()) diff --git a/src/CCD_analysis/knowledge.md b/src/CCD_analysis/ccd_knowledge.md similarity index 100% rename from src/CCD_analysis/knowledge.md rename to src/CCD_analysis/ccd_knowledge.md diff --git a/src/CCD_analysis/configs.py b/src/CCD_analysis/configs.py new file mode 100644 index 0000000..eaf721c --- /dev/null +++ b/src/CCD_analysis/configs.py @@ -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 diff --git a/src/CCD_analysis/output_redux/illusion/meta.json b/src/CCD_analysis/output_redux/illusion/meta.json new file mode 100644 index 0000000..0e38017 --- /dev/null +++ b/src/CCD_analysis/output_redux/illusion/meta.json @@ -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 +} \ No newline at end of file diff --git a/src/CCD_analysis/output_redux/illusion/norm.json b/src/CCD_analysis/output_redux/illusion/norm.json new file mode 100644 index 0000000..d0bf615 --- /dev/null +++ b/src/CCD_analysis/output_redux/illusion/norm.json @@ -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 + ] +} \ No newline at end of file diff --git a/src/CCD_analysis/output_redux/illusion/target_harmonics.json b/src/CCD_analysis/output_redux/illusion/target_harmonics.json new file mode 100644 index 0000000..df57eb9 --- /dev/null +++ b/src/CCD_analysis/output_redux/illusion/target_harmonics.json @@ -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 + ] + } +] \ No newline at end of file diff --git a/src/CCD_analysis/output_redux/illusion/validation_report.json b/src/CCD_analysis/output_redux/illusion/validation_report.json new file mode 100644 index 0000000..bac05f4 --- /dev/null +++ b/src/CCD_analysis/output_redux/illusion/validation_report.json @@ -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": {} +} \ No newline at end of file diff --git a/src/CCD_analysis/output_redux/illusion/vorticity_controlled.png b/src/CCD_analysis/output_redux/illusion/vorticity_controlled.png new file mode 100644 index 0000000..486dd7f Binary files /dev/null and b/src/CCD_analysis/output_redux/illusion/vorticity_controlled.png differ diff --git a/src/CCD_analysis/output_redux/illusion/vorticity_target.png b/src/CCD_analysis/output_redux/illusion/vorticity_target.png new file mode 100644 index 0000000..2f46296 Binary files /dev/null and b/src/CCD_analysis/output_redux/illusion/vorticity_target.png differ diff --git a/src/CCD_analysis/output_redux/illusion/vorticity_uncontrolled.png b/src/CCD_analysis/output_redux/illusion/vorticity_uncontrolled.png new file mode 100644 index 0000000..e8449a1 Binary files /dev/null and b/src/CCD_analysis/output_redux/illusion/vorticity_uncontrolled.png differ diff --git a/src/CCD_analysis/output_redux/karman/meta.json b/src/CCD_analysis/output_redux/karman/meta.json new file mode 100644 index 0000000..212d478 --- /dev/null +++ b/src/CCD_analysis/output_redux/karman/meta.json @@ -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 +} \ No newline at end of file diff --git a/src/CCD_analysis/output_redux/karman/norm.json b/src/CCD_analysis/output_redux/karman/norm.json new file mode 100644 index 0000000..b51b9b7 --- /dev/null +++ b/src/CCD_analysis/output_redux/karman/norm.json @@ -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 + ] +} \ No newline at end of file diff --git a/src/CCD_analysis/output_redux/karman/validation_report.json b/src/CCD_analysis/output_redux/karman/validation_report.json new file mode 100644 index 0000000..f09f4ca --- /dev/null +++ b/src/CCD_analysis/output_redux/karman/validation_report.json @@ -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 + } +} \ No newline at end of file diff --git a/src/CCD_analysis/output_redux/karman/vorticity_controlled.png b/src/CCD_analysis/output_redux/karman/vorticity_controlled.png new file mode 100644 index 0000000..d121523 Binary files /dev/null and b/src/CCD_analysis/output_redux/karman/vorticity_controlled.png differ diff --git a/src/CCD_analysis/output_redux/karman/vorticity_target.png b/src/CCD_analysis/output_redux/karman/vorticity_target.png new file mode 100644 index 0000000..086ff2c Binary files /dev/null and b/src/CCD_analysis/output_redux/karman/vorticity_target.png differ diff --git a/src/CCD_analysis/output_redux/karman/vorticity_uncontrolled.png b/src/CCD_analysis/output_redux/karman/vorticity_uncontrolled.png new file mode 100644 index 0000000..d10b733 Binary files /dev/null and b/src/CCD_analysis/output_redux/karman/vorticity_uncontrolled.png differ diff --git a/src/CCD_analysis/output_redux/pinball/meta.json b/src/CCD_analysis/output_redux/pinball/meta.json new file mode 100644 index 0000000..05a5cc3 --- /dev/null +++ b/src/CCD_analysis/output_redux/pinball/meta.json @@ -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 +} \ No newline at end of file diff --git a/src/CCD_analysis/output_redux/pinball/vorticity.png b/src/CCD_analysis/output_redux/pinball/vorticity.png new file mode 100644 index 0000000..8c85feb Binary files /dev/null and b/src/CCD_analysis/output_redux/pinball/vorticity.png differ diff --git a/src/CCD_analysis/output_redux/review_report.json b/src/CCD_analysis/output_redux/review_report.json new file mode 100644 index 0000000..d6343e7 --- /dev/null +++ b/src/CCD_analysis/output_redux/review_report.json @@ -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 + } + } + } +} \ No newline at end of file diff --git a/src/CCD_analysis/output_redux/steady_cloak/meta.json b/src/CCD_analysis/output_redux/steady_cloak/meta.json new file mode 100644 index 0000000..12861d2 --- /dev/null +++ b/src/CCD_analysis/output_redux/steady_cloak/meta.json @@ -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 +} \ No newline at end of file diff --git a/src/CCD_analysis/output_redux/steady_cloak/vorticity.png b/src/CCD_analysis/output_redux/steady_cloak/vorticity.png new file mode 100644 index 0000000..ef5baf7 Binary files /dev/null and b/src/CCD_analysis/output_redux/steady_cloak/vorticity.png differ diff --git a/src/CCD_analysis/scripts/__init__.py b/src/CCD_analysis/scripts/__init__.py deleted file mode 100644 index 13c62c1..0000000 --- a/src/CCD_analysis/scripts/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# CCD_analysis scripts package diff --git a/src/CCD_analysis/scripts/collect_illusion.py b/src/CCD_analysis/scripts/collect_illusion.py new file mode 100644 index 0000000..f924e04 --- /dev/null +++ b/src/CCD_analysis/scripts/collect_illusion.py @@ -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()) diff --git a/src/CCD_analysis/scripts/collect_karman.py b/src/CCD_analysis/scripts/collect_karman.py new file mode 100644 index 0000000..c7e8156 --- /dev/null +++ b/src/CCD_analysis/scripts/collect_karman.py @@ -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()) diff --git a/src/CCD_analysis/scripts/collect_pinball.py b/src/CCD_analysis/scripts/collect_pinball.py new file mode 100644 index 0000000..d551447 --- /dev/null +++ b/src/CCD_analysis/scripts/collect_pinball.py @@ -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") diff --git a/src/CCD_analysis/scripts/collect_steady_cloak.py b/src/CCD_analysis/scripts/collect_steady_cloak.py new file mode 100644 index 0000000..f107593 --- /dev/null +++ b/src/CCD_analysis/scripts/collect_steady_cloak.py @@ -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") diff --git a/src/CCD_analysis/scripts/compile_results.py b/src/CCD_analysis/scripts/compile_results.py deleted file mode 100644 index 1f5644c..0000000 --- a/src/CCD_analysis/scripts/compile_results.py +++ /dev/null @@ -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() diff --git a/src/CCD_analysis/scripts/phase0_standard_freq.py b/src/CCD_analysis/scripts/phase0_standard_freq.py deleted file mode 100644 index cd6b93a..0000000 --- a/src/CCD_analysis/scripts/phase0_standard_freq.py +++ /dev/null @@ -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()) diff --git a/src/CCD_analysis/scripts/phase1_collect.py b/src/CCD_analysis/scripts/phase1_collect.py deleted file mode 100644 index 7bfc6d2..0000000 --- a/src/CCD_analysis/scripts/phase1_collect.py +++ /dev/null @@ -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()) diff --git a/src/CCD_analysis/scripts/phase2_resample.py b/src/CCD_analysis/scripts/phase2_resample.py deleted file mode 100644 index 4cc0301..0000000 --- a/src/CCD_analysis/scripts/phase2_resample.py +++ /dev/null @@ -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()) diff --git a/src/CCD_analysis/scripts/phase3_pod.py b/src/CCD_analysis/scripts/phase3_pod.py deleted file mode 100644 index a4672a5..0000000 --- a/src/CCD_analysis/scripts/phase3_pod.py +++ /dev/null @@ -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()) diff --git a/src/CCD_analysis/scripts/phase4_ccd.py b/src/CCD_analysis/scripts/phase4_ccd.py deleted file mode 100644 index c07c4e8..0000000 --- a/src/CCD_analysis/scripts/phase4_ccd.py +++ /dev/null @@ -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()) diff --git a/src/CCD_analysis/scripts/phase5_steady.py b/src/CCD_analysis/scripts/phase5_steady.py deleted file mode 100644 index edfc97d..0000000 --- a/src/CCD_analysis/scripts/phase5_steady.py +++ /dev/null @@ -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()) diff --git a/src/CCD_analysis/scripts/resample.py b/src/CCD_analysis/scripts/resample.py new file mode 100644 index 0000000..edde550 --- /dev/null +++ b/src/CCD_analysis/scripts/resample.py @@ -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() diff --git a/src/CCD_analysis/scripts/utils.py b/src/CCD_analysis/scripts/utils.py deleted file mode 100644 index a53a263..0000000 --- a/src/CCD_analysis/scripts/utils.py +++ /dev/null @@ -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) diff --git a/src/CCD_analysis/scripts/validate_control.py b/src/CCD_analysis/scripts/validate_control.py deleted file mode 100644 index 1f46e42..0000000 --- a/src/CCD_analysis/scripts/validate_control.py +++ /dev/null @@ -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() diff --git a/src/CCD_analysis/steady/run_steady.py b/src/CCD_analysis/steady/run_steady.py new file mode 100644 index 0000000..4b57d5a --- /dev/null +++ b/src/CCD_analysis/steady/run_steady.py @@ -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()) diff --git a/src/CCD_analysis/utils/__init__.py b/src/CCD_analysis/utils/__init__.py new file mode 100644 index 0000000..042aa93 --- /dev/null +++ b/src/CCD_analysis/utils/__init__.py @@ -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, +) diff --git a/src/CCD_analysis/utils/cfd_interface.py b/src/CCD_analysis/utils/cfd_interface.py new file mode 100644 index 0000000..13674ff --- /dev/null +++ b/src/CCD_analysis/utils/cfd_interface.py @@ -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 diff --git a/src/CCD_analysis/scripts/analysis_utils.py b/src/CCD_analysis/utils/resampling.py similarity index 58% rename from src/CCD_analysis/scripts/analysis_utils.py rename to src/CCD_analysis/utils/resampling.py index c81596b..b983c13 100644 --- a/src/CCD_analysis/scripts/analysis_utils.py +++ b/src/CCD_analysis/utils/resampling.py @@ -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 diff --git a/src/SR_analysis/README.md b/src/SR_analysis/README.md new file mode 100644 index 0000000..a7a96b5 --- /dev/null +++ b/src/SR_analysis/README.md @@ -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 | diff --git a/src/SR_analysis/compare/illusion_shared_core.json b/src/SR_analysis/compare/illusion_shared_core.json new file mode 100644 index 0000000..11740d7 --- /dev/null +++ b/src/SR_analysis/compare/illusion_shared_core.json @@ -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" + ] + } + } + } +} \ No newline at end of file diff --git a/src/SR_analysis/compare/karman_shared_core.json b/src/SR_analysis/compare/karman_shared_core.json new file mode 100644 index 0000000..4268603 --- /dev/null +++ b/src/SR_analysis/compare/karman_shared_core.json @@ -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" + ] + } + } + } +} \ No newline at end of file diff --git a/src/SR_analysis/compare/shared_core.py b/src/SR_analysis/compare/shared_core.py new file mode 100644 index 0000000..092ae11 --- /dev/null +++ b/src/SR_analysis/compare/shared_core.py @@ -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() diff --git a/src/SR_analysis/compare/support_overlap.py b/src/SR_analysis/compare/support_overlap.py new file mode 100644 index 0000000..b1023c9 --- /dev/null +++ b/src/SR_analysis/compare/support_overlap.py @@ -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() diff --git a/src/SR_analysis/configs.py b/src/SR_analysis/configs.py new file mode 100644 index 0000000..93ba369 --- /dev/null +++ b/src/SR_analysis/configs.py @@ -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 diff --git a/src/analysis_crossre/configs/config_cuda.json b/src/SR_analysis/configs/legacy/config_cuda.json similarity index 100% rename from src/analysis_crossre/configs/config_cuda.json rename to src/SR_analysis/configs/legacy/config_cuda.json diff --git a/src/analysis_crossre/configs/config_flowfield.json b/src/SR_analysis/configs/legacy/config_flowfield.json similarity index 100% rename from src/analysis_crossre/configs/config_flowfield.json rename to src/SR_analysis/configs/legacy/config_flowfield.json diff --git a/src/SR_analysis/data/illusion/illusion_0.75L/config.json b/src/SR_analysis/data/illusion/illusion_0.75L/config.json new file mode 100644 index 0000000..83c0c18 --- /dev/null +++ b/src/SR_analysis/data/illusion/illusion_0.75L/config.json @@ -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 +} \ No newline at end of file diff --git a/src/SR_analysis/data/illusion/illusion_0.75L/norm.json b/src/SR_analysis/data/illusion/illusion_0.75L/norm.json new file mode 100644 index 0000000..baf6ff2 --- /dev/null +++ b/src/SR_analysis/data/illusion/illusion_0.75L/norm.json @@ -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 + ] +} \ No newline at end of file diff --git a/src/SR_analysis/data/illusion/illusion_0.75L/result.json b/src/SR_analysis/data/illusion/illusion_0.75L/result.json new file mode 100644 index 0000000..f889587 --- /dev/null +++ b/src/SR_analysis/data/illusion/illusion_0.75L/result.json @@ -0,0 +1,5 @@ +{ + "scene": "illusion_0.75L", + "controlled": true, + "similarity": 0.18393706196948187 +} \ No newline at end of file diff --git a/src/SR_analysis/data/illusion/illusion_1.5L/config.json b/src/SR_analysis/data/illusion/illusion_1.5L/config.json new file mode 100644 index 0000000..42cf6c9 --- /dev/null +++ b/src/SR_analysis/data/illusion/illusion_1.5L/config.json @@ -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 +} \ No newline at end of file diff --git a/src/SR_analysis/data/illusion/illusion_1.5L/norm.json b/src/SR_analysis/data/illusion/illusion_1.5L/norm.json new file mode 100644 index 0000000..554e8ee --- /dev/null +++ b/src/SR_analysis/data/illusion/illusion_1.5L/norm.json @@ -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 + ] +} \ No newline at end of file diff --git a/src/SR_analysis/data/illusion/illusion_1.5L/result.json b/src/SR_analysis/data/illusion/illusion_1.5L/result.json new file mode 100644 index 0000000..400ab0a --- /dev/null +++ b/src/SR_analysis/data/illusion/illusion_1.5L/result.json @@ -0,0 +1,5 @@ +{ + "scene": "illusion_15L", + "controlled": true, + "similarity": 0.30179914651024675 +} \ No newline at end of file diff --git a/src/SR_analysis/data/illusion/illusion_1L/config.json b/src/SR_analysis/data/illusion/illusion_1L/config.json new file mode 100644 index 0000000..0b459d6 --- /dev/null +++ b/src/SR_analysis/data/illusion/illusion_1L/config.json @@ -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 +} \ No newline at end of file diff --git a/src/SR_analysis/data/illusion/illusion_1L/norm.json b/src/SR_analysis/data/illusion/illusion_1L/norm.json new file mode 100644 index 0000000..cc1bc9e --- /dev/null +++ b/src/SR_analysis/data/illusion/illusion_1L/norm.json @@ -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 + ] +} \ No newline at end of file diff --git a/src/SR_analysis/data/illusion/illusion_1L/result.json b/src/SR_analysis/data/illusion/illusion_1L/result.json new file mode 100644 index 0000000..d25afb3 --- /dev/null +++ b/src/SR_analysis/data/illusion/illusion_1L/result.json @@ -0,0 +1,5 @@ +{ + "scene": "illusion_1.0L", + "controlled": true, + "similarity": 0.5543174409436081 +} \ No newline at end of file diff --git a/src/SR_analysis/data/karman/karman_re100/config.json b/src/SR_analysis/data/karman/karman_re100/config.json new file mode 100644 index 0000000..2333fee --- /dev/null +++ b/src/SR_analysis/data/karman/karman_re100/config.json @@ -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 +} \ No newline at end of file diff --git a/src/SR_analysis/data/karman/karman_re100/norm.json b/src/SR_analysis/data/karman/karman_re100/norm.json new file mode 100644 index 0000000..de2a17b --- /dev/null +++ b/src/SR_analysis/data/karman/karman_re100/norm.json @@ -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 + ] +} \ No newline at end of file diff --git a/src/SR_analysis/data/karman/karman_re100/result.json b/src/SR_analysis/data/karman/karman_re100/result.json new file mode 100644 index 0000000..dbdc6a6 --- /dev/null +++ b/src/SR_analysis/data/karman/karman_re100/result.json @@ -0,0 +1,6 @@ +{ + "scene": "karman_re100", + "controlled": true, + "avg_reward_last100": 0.6665451352155793, + "similarity": 0.9538050162761162 +} \ No newline at end of file diff --git a/src/SR_analysis/data/karman/karman_re100/vorticity_controlled.png b/src/SR_analysis/data/karman/karman_re100/vorticity_controlled.png new file mode 100644 index 0000000..05904a7 Binary files /dev/null and b/src/SR_analysis/data/karman/karman_re100/vorticity_controlled.png differ diff --git a/src/SR_analysis/data/karman/karman_re100/vorticity_uncontrolled.png b/src/SR_analysis/data/karman/karman_re100/vorticity_uncontrolled.png new file mode 100644 index 0000000..33faf93 Binary files /dev/null and b/src/SR_analysis/data/karman/karman_re100/vorticity_uncontrolled.png differ diff --git a/src/SR_analysis/data/karman/karman_re200/config.json b/src/SR_analysis/data/karman/karman_re200/config.json new file mode 100644 index 0000000..3f9ce92 --- /dev/null +++ b/src/SR_analysis/data/karman/karman_re200/config.json @@ -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 +} \ No newline at end of file diff --git a/src/SR_analysis/data/karman/karman_re200/norm.json b/src/SR_analysis/data/karman/karman_re200/norm.json new file mode 100644 index 0000000..87af52d --- /dev/null +++ b/src/SR_analysis/data/karman/karman_re200/norm.json @@ -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 + ] +} \ No newline at end of file diff --git a/src/SR_analysis/data/karman/karman_re200/result.json b/src/SR_analysis/data/karman/karman_re200/result.json new file mode 100644 index 0000000..71b69f3 --- /dev/null +++ b/src/SR_analysis/data/karman/karman_re200/result.json @@ -0,0 +1,6 @@ +{ + "scene": "karman_re200", + "controlled": true, + "avg_reward_last100": 0.3298547239579881, + "similarity": 0.8842106151498026 +} \ No newline at end of file diff --git a/src/SR_analysis/data/karman/karman_re200/vorticity_controlled.png b/src/SR_analysis/data/karman/karman_re200/vorticity_controlled.png new file mode 100644 index 0000000..2f24593 Binary files /dev/null and b/src/SR_analysis/data/karman/karman_re200/vorticity_controlled.png differ diff --git a/src/SR_analysis/data/karman/karman_re200/vorticity_uncontrolled.png b/src/SR_analysis/data/karman/karman_re200/vorticity_uncontrolled.png new file mode 100644 index 0000000..6819faa Binary files /dev/null and b/src/SR_analysis/data/karman/karman_re200/vorticity_uncontrolled.png differ diff --git a/src/SR_analysis/data/karman/karman_re400/config.json b/src/SR_analysis/data/karman/karman_re400/config.json new file mode 100644 index 0000000..b69a249 --- /dev/null +++ b/src/SR_analysis/data/karman/karman_re400/config.json @@ -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" +} \ No newline at end of file diff --git a/src/SR_analysis/data/karman/karman_re400/norm.json b/src/SR_analysis/data/karman/karman_re400/norm.json new file mode 100644 index 0000000..352192a --- /dev/null +++ b/src/SR_analysis/data/karman/karman_re400/norm.json @@ -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 + ] +} \ No newline at end of file diff --git a/src/SR_analysis/data/karman/karman_re400/result.json b/src/SR_analysis/data/karman/karman_re400/result.json new file mode 100644 index 0000000..79569e0 --- /dev/null +++ b/src/SR_analysis/data/karman/karman_re400/result.json @@ -0,0 +1,7 @@ +{ + "re_code": 400, + "uncontrolled": true, + "controlled": true, + "avg_reward_last100": 0.4389868174760177, + "similarity": 0.7950085552241137 +} \ No newline at end of file diff --git a/src/SR_analysis/data/karman/karman_re400/vorticity_controlled.png b/src/SR_analysis/data/karman/karman_re400/vorticity_controlled.png new file mode 100644 index 0000000..12664b9 Binary files /dev/null and b/src/SR_analysis/data/karman/karman_re400/vorticity_controlled.png differ diff --git a/src/SR_analysis/data/karman/karman_re400/vorticity_uncontrolled.png b/src/SR_analysis/data/karman/karman_re400/vorticity_uncontrolled.png new file mode 100644 index 0000000..39d00cb Binary files /dev/null and b/src/SR_analysis/data/karman/karman_re400/vorticity_uncontrolled.png differ diff --git a/src/SR_analysis/data/karman/karman_re50/config.json b/src/SR_analysis/data/karman/karman_re50/config.json new file mode 100644 index 0000000..d59a53c --- /dev/null +++ b/src/SR_analysis/data/karman/karman_re50/config.json @@ -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 +} \ No newline at end of file diff --git a/src/SR_analysis/data/karman/karman_re50/norm.json b/src/SR_analysis/data/karman/karman_re50/norm.json new file mode 100644 index 0000000..eaed78a --- /dev/null +++ b/src/SR_analysis/data/karman/karman_re50/norm.json @@ -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 + ] +} \ No newline at end of file diff --git a/src/SR_analysis/data/karman/karman_re50/result.json b/src/SR_analysis/data/karman/karman_re50/result.json new file mode 100644 index 0000000..ed32140 --- /dev/null +++ b/src/SR_analysis/data/karman/karman_re50/result.json @@ -0,0 +1,6 @@ +{ + "scene": "karman_re50", + "controlled": true, + "avg_reward_last100": 0.5020737935656798, + "similarity": 0.9614716421942122 +} \ No newline at end of file diff --git a/src/SR_analysis/data/karman/karman_re50/vorticity_controlled.png b/src/SR_analysis/data/karman/karman_re50/vorticity_controlled.png new file mode 100644 index 0000000..37226ca Binary files /dev/null and b/src/SR_analysis/data/karman/karman_re50/vorticity_controlled.png differ diff --git a/src/SR_analysis/data/karman/karman_re50/vorticity_uncontrolled.png b/src/SR_analysis/data/karman/karman_re50/vorticity_uncontrolled.png new file mode 100644 index 0000000..062e0b5 Binary files /dev/null and b/src/SR_analysis/data/karman/karman_re50/vorticity_uncontrolled.png differ diff --git a/src/SR_analysis/data/vortex/vortex_lamb/config.json b/src/SR_analysis/data/vortex/vortex_lamb/config.json new file mode 100644 index 0000000..0bd6f7c --- /dev/null +++ b/src/SR_analysis/data/vortex/vortex_lamb/config.json @@ -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 +} \ No newline at end of file diff --git a/src/SR_analysis/data/vortex/vortex_lamb/norm.json b/src/SR_analysis/data/vortex/vortex_lamb/norm.json new file mode 100644 index 0000000..4bd09f6 --- /dev/null +++ b/src/SR_analysis/data/vortex/vortex_lamb/norm.json @@ -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 + ] +} \ No newline at end of file diff --git a/src/SR_analysis/data/vortex/vortex_lamb/result.json b/src/SR_analysis/data/vortex/vortex_lamb/result.json new file mode 100644 index 0000000..11e2dbf --- /dev/null +++ b/src/SR_analysis/data/vortex/vortex_lamb/result.json @@ -0,0 +1,5 @@ +{ + "scene": "vortex_lamb", + "controlled": true, + "similarity": 0.9421189523977421 +} \ No newline at end of file diff --git a/src/SR_analysis/data/vortex/vortex_taylor/config.json b/src/SR_analysis/data/vortex/vortex_taylor/config.json new file mode 100644 index 0000000..a7191f8 --- /dev/null +++ b/src/SR_analysis/data/vortex/vortex_taylor/config.json @@ -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 +} \ No newline at end of file diff --git a/src/SR_analysis/data/vortex/vortex_taylor/norm.json b/src/SR_analysis/data/vortex/vortex_taylor/norm.json new file mode 100644 index 0000000..83ca993 --- /dev/null +++ b/src/SR_analysis/data/vortex/vortex_taylor/norm.json @@ -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 + ] +} \ No newline at end of file diff --git a/src/SR_analysis/data/vortex/vortex_taylor/result.json b/src/SR_analysis/data/vortex/vortex_taylor/result.json new file mode 100644 index 0000000..b6c321e --- /dev/null +++ b/src/SR_analysis/data/vortex/vortex_taylor/result.json @@ -0,0 +1,5 @@ +{ + "scene": "vortex_taylor", + "controlled": true, + "similarity": 0.9158487825490536 +} \ No newline at end of file diff --git a/src/SR_analysis/pysr.md b/src/SR_analysis/pysr.md new file mode 100644 index 0000000..e068efa --- /dev/null +++ b/src/SR_analysis/pysr.md @@ -0,0 +1,842 @@ +# Toy Examples with Code + +## Preamble + +```python +import numpy as np +from pysr import * +``` + +## 1. Simple search + +Here's a simple example where we +find the expression `2 cos(x3) + x0^2 - 2`. + +```python +X = 2 * np.random.randn(100, 5) +y = 2 * np.cos(X[:, 3]) + X[:, 0] ** 2 - 2 +model = PySRRegressor(binary_operators=["+", "-", "*", "/"]) +model.fit(X, y) +print(model) +``` + +## 2. Custom operator + +Here, we define a custom operator and use it to find an expression: + +```python +X = 2 * np.random.randn(100, 5) +y = 1 / X[:, 0] +model = PySRRegressor( + binary_operators=["+", "*"], + unary_operators=["inv(x) = 1/x"], + extra_sympy_mappings={"inv": lambda x: 1/x}, +) +model.fit(X, y) +print(model) +``` + +## 3. Multiple outputs + +Here, we do the same thing, but with multiple expressions at once, +each requiring a different feature. + +```python +X = 2 * np.random.randn(100, 5) +y = 1 / X[:, [0, 1, 2]] +model = PySRRegressor( + binary_operators=["+", "*"], + unary_operators=["inv(x) = 1/x"], + extra_sympy_mappings={"inv": lambda x: 1/x}, +) +model.fit(X, y) +``` + +## 4. Plotting an expression + +For now, let's consider the expressions for output 0. +We can see the LaTeX version of this with: + +```python +model.latex()[0] +``` + +or output 1 with `model.latex()[1]`. + +Let's plot the prediction against the truth: + +```python +from matplotlib import pyplot as plt +plt.scatter(y[:, 0], model.predict(X)[:, 0]) +plt.xlabel('Truth') +plt.ylabel('Prediction') +plt.show() +``` + +Which gives us: + +![Truth vs Prediction](/images/example_plot.png) + +We may also plot the output of a particular expression +by passing the index of the expression to `predict` (or +`sympy` or `latex` as well) + +## 5. Feature selection + +PySR and evolution-based symbolic regression in general performs +very poorly when the number of features is large. +Even, say, 10 features might be too much for a typical equation search. + +If you are dealing with high-dimensional data with a particular type of structure, +you might consider using deep learning to break the problem into +smaller "chunks" which can then be solved by PySR, as explained in the paper +[2006.11287](https://arxiv.org/abs/2006.11287). + +For tabular datasets, this is a bit trickier. Luckily, PySR has a built-in feature +selection mechanism. Simply declare the parameter `select_k_features=5`, for selecting +the most important 5 features. + +Here is an example. Let's say we have 30 input features and 300 data points, but only 2 +of those features are actually used: + +```python +X = np.random.randn(300, 30) +y = X[:, 3]**2 - X[:, 19]**2 + 1.5 +``` + +Let's create a model with the feature selection argument set up: + +```python +model = PySRRegressor( + binary_operators=["+", "-", "*", "/"], + unary_operators=["exp"], + select_k_features=5, +) +``` + +Now let's fit this: + +```python +model.fit(X, y) +``` + +Before the Julia backend is launched, you can see the string: + +```text +Using features ['x3', 'x5', 'x7', 'x19', 'x21'] +``` + +which indicates that the feature selection (powered by a gradient-boosting tree) +has successfully selected the relevant two features. + +This fit should find the solution quickly, whereas with the huge number of features, +it would have struggled. + +This simple preprocessing step is enough to simplify our tabular dataset, +but again, for more structured datasets, you should try the deep learning +approach mentioned above. + +## 6. Denoising + +Many datasets, especially in the observational sciences, +contain intrinsic noise. PySR is noise robust itself, as it is simply optimizing a loss function, +but there are still some additional steps you can take to reduce the effect of noise. + +One thing you could do, which we won't detail here, is to create a custom log-likelihood +given some assumed noise model. By passing weights to the fit function, and +defining a custom loss function such as `elementwise_loss="myloss(x, y, w) = w * (x - y)^2"`, +you can define any sort of log-likelihood you wish. (However, note that it must be bounded at zero) + +However, the simplest thing to do is preprocessing, just like for feature selection. To do this, +set the parameter `denoise=True`. This will fit a Gaussian process (containing a white noise kernel) +to the input dataset, and predict new targets (which are assumed to be denoised) from that Gaussian process. + +For example: + +```python +X = np.random.randn(100, 5) +noise = np.random.randn(100) * 0.1 +y = np.exp(X[:, 0]) + X[:, 1] + X[:, 2] + noise +``` + +Let's create and fit a model with the denoising argument set up: + +```python +model = PySRRegressor( + binary_operators=["+", "-", "*", "/"], + unary_operators=["exp"], + denoise=True, +) +model.fit(X, y) +print(model) +``` + +If all goes well, you should find that it predicts the correct input equation, without the noise term! + +## 7. Julia packages and types + +PySR uses [SymbolicRegression.jl](https://github.com/MilesCranmer/SymbolicRegression.jl) +as its search backend. This is a pure Julia package, and so can interface easily with any other +Julia package. +For some tasks, it may be necessary to load such a package. + +For example, let's say we wish to discovery the following relationship: + +$$ y = p_{3x + 1} - 5, $$ + +where $p_i$ is the $i$th prime number, and $x$ is the input feature. + +Let's see if we can discover this using +the [Primes.jl](https://github.com/JuliaMath/Primes.jl) package. + +First, let's get the Julia backend: + +```python +from pysr import jl +``` + +`jl` stores the Julia runtime. + +Now, let's run some Julia code to add the Primes.jl +package to the PySR environment: + +```python +jl.seval(""" +import Pkg +Pkg.add("Primes") +""") +``` + +This imports the Julia package manager, and uses it to install +`Primes.jl`. Now let's import `Primes.jl`: + +```python +jl.seval("import Primes") +``` + +Now, we define a custom operator: + +```python +jl.seval(""" +function p(i::T) where T + if (0.5 < i < 1000) + return T(Primes.prime(round(Int, i))) + else + return T(NaN) + end +end +""") +``` + +We have created a a function `p`, which takes an arbitrary number as input. +`p` first checks whether the input is between 0.5 and 1000. +If out-of-bounds, it returns `NaN`. +If in-bounds, it rounds it to the nearest integer, compures the corresponding prime number, and then +converts it to the same type as input. + +Next, let's generate a list of primes for our test dataset. +Since we are using juliacall, we can just call `p` directly to do this: + +```python +primes = {i: jl.p(i*1.0) for i in range(1, 999)} +``` + +Next, let's use this list of primes to create a dataset of $x, y$ pairs: + +```python +import numpy as np + +X = np.random.randint(0, 100, 100)[:, None] +y = [primes[3*X[i, 0] + 1] - 5 + np.random.randn()*0.001 for i in range(100)] +``` + +Note that we have also added a tiny bit of noise to the dataset. + +Finally, let's create a PySR model, and pass the custom operator. We also need to define the sympy equivalent, which we can leave as a placeholder for now: + +```python +from pysr import PySRRegressor +import sympy + +class sympy_p(sympy.Function): + pass + +model = PySRRegressor( + binary_operators=["+", "-", "*", "/"], + unary_operators=["p"], + niterations=100, + extra_sympy_mappings={"p": sympy_p} +) +``` + +We are all set to go! Let's see if we can find the true relation: + +```python +model.fit(X, y) +``` + +if all works out, you should be able to see the true relation (note that the constant offset might not be exactly 1, since it is allowed to round to the nearest integer). +You can get the sympy version of the best equation with: + +```python +model.sympy() +``` + +## 8. Complex numbers + +PySR can also search for complex-valued expressions. Simply pass +data with a complex datatype (e.g., `np.complex128`), +and PySR will automatically search for complex-valued expressions: + +```python +import numpy as np + +X = np.random.randn(100, 1) + 1j * np.random.randn(100, 1) +y = (1 + 2j) * np.cos(X[:, 0] * (0.5 - 0.2j)) + +model = PySRRegressor( + binary_operators=["+", "-", "*"], unary_operators=["cos"], niterations=100, +) + +model.fit(X, y) +``` + +You can see that all of the learned constants are now complex numbers. +We can get the sympy version of the best equation with: + +```python +model.sympy() +``` + +We can also make predictions normally, by passing complex data: + +```python +model.predict(X, -1) +``` + +to make predictions with the most accurate expression. + +## 9. Custom objectives + +You can also pass a custom objectives as a snippet of Julia code, +which might include symbolic manipulations or custom functional forms. +These do not even need to be differentiable! First, let's look at the +default objective used (a simplified version, without weights +and with mean square error), so that you can see how to write your own: + +```julia +function default_objective(tree, dataset::Dataset{T,L}, options)::L where {T,L} + (prediction, completion) = eval_tree_array(tree, dataset.X, options) + if !completion + return L(Inf) + end + + diffs = prediction .- dataset.y + + return sum(diffs .^ 2) / length(diffs) +end +``` + +Here, the `where {T,L}` syntax defines the function for arbitrary types `T` and `L`. +If you have `precision=32` (default) and pass in regular floating point data, +then both `T` and `L` will be equal to `Float32`. If you pass in complex data, +then `T` will be `ComplexF32` and `L` will be `Float32` (since we need to return +a real number from the loss function). But, you don't need to worry about this, just +make sure to return a scalar number of type `L`. + +The `tree` argument is the current expression being evaluated. You can read +about the `tree` fields [here](https://ai.damtp.cam.ac.uk/symbolicregression/stable/types/). + +For example, let's fix a symbolic form of an expression, +as a rational function. i.e., $P(X)/Q(X)$ for polynomials $P$ and $Q$. + +```python +objective = """ +function my_custom_objective(tree, dataset::Dataset{T,L}, options) where {T,L} + # Require root node to be binary, so we can split it, + # otherwise return a large loss: + tree.degree != 2 && return L(Inf) + + P = tree.l + Q = tree.r + + # Evaluate numerator: + P_prediction, flag = eval_tree_array(P, dataset.X, options) + !flag && return L(Inf) + + # Evaluate denominator: + Q_prediction, flag = eval_tree_array(Q, dataset.X, options) + !flag && return L(Inf) + + # Impose functional form: + prediction = P_prediction ./ Q_prediction + + diffs = prediction .- dataset.y + + return sum(diffs .^ 2) / length(diffs) +end +""" + +model = PySRRegressor( + niterations=100, + binary_operators=["*", "+", "-"], + loss_function=objective, +) +``` + +> **Warning**: When using a custom objective like this that performs symbolic +> manipulations, many functionalities of PySR will not work, such as `.sympy()`, +> `.predict()`, etc. This is because the SymPy parsing does not know about +> how you are manipulating the expression, so you will need to do this yourself. + +Note how we did not pass `/` as a binary operator; it will just be implicit +in the functional form. + +Let's generate an equation of the form $\frac{x_0^2 x_1 - 2}{x_2^2 + 1}$: + +```python +X = np.random.randn(1000, 3) +y = (X[:, 0]**2 * X[:, 1] - 2) / (X[:, 2]**2 + 1) +``` + +Finally, let's fit: + +```python +model.fit(X, y) +``` + +> Note that the printed equation is not the same as the evaluated equation, +> because the printing functionality does not know about the functional form. + +We can get the string format with: + +```python +model.get_best().equation +``` + +(or, you could use `model.equations_.iloc[-1].equation`) + +For me, this equation was: + +```text +(((2.3554819 + -0.3554746) - (x1 * (x0 * x0))) - (-1.0000019 - (x2 * x2))) +``` + +looking at the bracket structure of the equation, we can see that the outermost +bracket is split at the `-` operator (note that we ignore the root operator in +the evaluation, as we simply evaluated each argument and divided the result) into +`((2.3554819 + -0.3554746) - (x1 * (x0 * x0)))` and +`(-1.0000019 - (x2 * x2))`, meaning that our discovered equation is +equal to: +$\frac{x_0^2 x_1 - 2.0000073}{x_2^2 + 1.0000019}$, which +is nearly the same as the true equation! + +## 10. Dimensional constraints + +One other feature we can exploit is dimensional analysis. +Say that we know the physical units of each feature and output, +and we want to find an expression that is dimensionally consistent. + +We can do this as follows, using `DynamicQuantities.jl` to assign units, +passing a string specifying the units for each variable. +First, let's make some data on Newton's law of gravitation, using +astropy for units: + +```python +import numpy as np +from astropy import units as u, constants as const + +M = (np.random.rand(100) + 0.1) * const.M_sun +m = 100 * (np.random.rand(100) + 0.1) * u.kg +r = (np.random.rand(100) + 0.1) * const.R_earth +G = const.G + +F = G * M * m / r**2 +``` + +We can see the units of `F` with `F.unit`. + +Now, let's create our model. +Since this data has such a large dynamic range, +let's also create a custom loss function +that looks at the error in log-space: + +```python +elementwise_loss = """function loss_fnc(prediction, target) + scatter_loss = abs(log((abs(prediction)+1e-20) / (abs(target)+1e-20))) + sign_loss = 10 * (sign(prediction) - sign(target))^2 + return scatter_loss + sign_loss +end +""" +``` + +Now let's define our model: + +```python +model = PySRRegressor( + binary_operators=["+", "-", "*", "/"], + unary_operators=["square"], + elementwise_loss=elementwise_loss, + complexity_of_constants=2, + maxsize=25, + niterations=100, + populations=50, + # Amount to penalize dimensional violations: + dimensional_constraint_penalty=10**5, +) +``` + +and fit it, passing the unit information. +To do this, we need to use the format of [DynamicQuantities.jl](https://symbolicml.org/DynamicQuantities.jl/dev/#Usage). + +```python +# Get numerical arrays to fit: +X = pd.DataFrame(dict( + M=M.to("M_sun").value, + m=m.to("kg").value, + r=r.to("R_earth").value, +)) +y = F.value + +model.fit( + X, + y, + X_units=["Constants.M_sun", "kg", "Constants.R_earth"], + y_units="kg * m / s^2" +) +``` + +You can observe that all expressions with a loss under +our penalty are dimensionally consistent! +(The `"[⋅]"` indicates free units in a constant, which can cancel out other units in the expression.) +For example, + +```julia +"y[m s⁻² kg] = (M[kg] * 2.6353e-22[⋅])" +``` + +would indicate that the expression is dimensionally consistent, with +a constant `"2.6353e-22[m s⁻²]"`. + +Note that this expression has a large dynamic range so may be difficult to find. Consider searching with a larger `niterations` if needed. + +Note that you can also search for exclusively dimensionless constants by settings +`dimensionless_constants_only` to `true`. + +## 11. Expression Specifications + +PySR 1.0 introduces powerful expression specifications that allow you to define structured equations. Here are two examples: + +### Template Expressions + +`TemplateExpressionSpec` allows you to define a specific structure for the equation. +For example, let's say we want to learn an equation of the form: + +$$ y = \sin(f(x_1, x_2)) + g(x_3) $$ + +We can do this as follows: + +```python +import numpy as np +from pysr import PySRRegressor, TemplateExpressionSpec + +# Create data +X = np.random.randn(1000, 3) +y = np.sin(X[:, 0] + X[:, 1]) + X[:, 2]**2 + +# Define template: we want sin(f(x1, x2)) + g(x3) +template = TemplateExpressionSpec( + expressions=["f", "g"], + variable_names=["x1", "x2", "x3"], + combine="sin(f(x1, x2)) + g(x3)", +) + +model = PySRRegressor( + expression_spec=template, + binary_operators=["+", "*", "-", "/"], + unary_operators=["sin"], + maxsize=10, +) +model.fit(X, y) +``` + +### Parametric Expressions + +When your data has categories with shared equation structure but different parameters, +you can use the `parameters` argument of `TemplateExpressionSpec` to specify learned category-specific parameters. + +For example, let's say we want to learn an equation of the form: + +$$ y = \alpha \sin(x_1) + \beta $$ + +where $\alpha$ and $\beta$ are different for each category. + +Further, let's say we have 3 categories, +with $\alpha \in \{0.1, 1.5, -0.5\}$ and $\beta \in \{1.0, 2.0, 0.5\}$. + +```python +import numpy as np +from pysr import PySRRegressor, TemplateExpressionSpec + +# Create data with 2 features and 3 categories +X = np.random.uniform(-3, 3, (1000, 2)) +category = np.random.randint(0, 3, 1000) + +# Parameters for each category +offsets = [0.1, 1.5, -0.5] +scales = [1.0, 2.0, 0.5] + +# y = scale[category] * sin(x1) + offset[category] +y = np.array([ + scales[c] * np.sin(x1) + offsets[c] + for x1, c in zip(X[:, 0], category) +]) +``` + +Now, let's define our parametric expression: + +```python +template = TemplateExpressionSpec( + expressions=["f"], + variable_names=["x1", "x2", "category"], + parameters={"p1": 3, "p2": 3}, # One parameter per category + combine="f(x1, x2, p1[category], p2[category])" +) +``` + +Next, we pass the category as a _column_ in `X` +corresponding to the index we defined in `variable_names`. + +**Note that because Julia is 1-indexed, we need to add 1 to the category index.** + +```python +category_p_one = category + 1 +X_with_category = np.column_stack([X, category]) +``` + +Now, we can fit our model: + +```python +model = PySRRegressor( + expression_spec=template, + binary_operators=["+", "*", "-", "/"], + unary_operators=["sin"], + maxsize=10, +) +model.fit(X_with_category, y) + +# Predicting on new data +# model.predict(X_test_with_category) +``` + +See [Expression Specifications](/api/#expression-specifications) for more details. + +You can use this approach for more complex cases, +where you have multiple expressions in the template and parameters that vary by category. + + +## 12. Using TensorBoard for Logging + +You can use TensorBoard to visualize the search progress, as well as +record hyperparameters and final metrics (like `min_loss` and `pareto_volume` - the latter of which +is a performance measure of the entire Pareto front). + +```python +import numpy as np +from pysr import PySRRegressor, TensorBoardLoggerSpec + +rstate = np.random.RandomState(42) + +# Uniform dist between -3 and 3: +X = rstate.uniform(-3, 3, (1000, 2)) +y = np.exp(X[:, 0]) + X[:, 1] + +# Create a logger that writes to "logs/run*": +logger_spec = TensorBoardLoggerSpec( + log_dir="logs/run", + log_interval=10, # Log every 10 iterations +) + +model = PySRRegressor( + binary_operators=["+", "*", "-", "/"], + logger_spec=logger_spec, +) +model.fit(X, y) +``` + +You can then view the logs with: + +```bash +tensorboard --logdir logs/ +``` + +## 13. Vector-valued expressions + +You can use `TemplateExpressionSpec` to find expressions for vector-valued data, +where each component might share a common structure. +The trick is to put each vector element into your feature matrix `X`, +and then use a template expression to define the relationships. + +For example, say we have 3-dimensional vectors where each component +follows a pattern with a shared term. Say the true model is: + +$$\begin{align*} +y_1 &= \exp(x_1) + x_2^2 \\ +y_2 &= \exp(x_1) + \sin(x_3) \\ +y_3 &= \exp(x_1) + x_1 \cdot x_2 +\end{align*}$$ + +Let's set this up: + +```python +import numpy as np +from pysr import PySRRegressor, TemplateExpressionSpec + +n = 200 +rstate = np.random.RandomState(0) +x1 = rstate.uniform(-2, 2, n) +x2 = rstate.uniform(-2, 2, n) +x3 = rstate.uniform(-2, 2, n) + +# True model with shared component exp(x1): +y1 = np.exp(x1) + x2**2 +y2 = np.exp(x1) + np.sin(x3) +y3 = np.exp(x1) + x1 * x2 + +# Add some noise +y1 += 0.05 * rstate.randn(n) +y2 += 0.05 * rstate.randn(n) +y3 += 0.05 * rstate.randn(n) +``` + +Now, we put everything in `X`; BOTH features and targets: + +```python +X = np.column_stack([x1, x2, x3, y1, y2, y3]) +``` + +Now, we can define our template expression: + +```python +spec = TemplateExpressionSpec( + expressions=["f1", "f2", "f3", "shared"], + variable_names=["x1", "x2", "x3", "y1", "y2", "y3"], + combine=""" + v = shared(x1, x2, x3) + y1_predicted = v + f1(x1, x2, x3) + y2_predicted = v + f2(x1, x2, x3) + y3_predicted = v + f3(x1, x2, x3) + + residuals = ( + abs2(y1 - y1_predicted) + + abs2(y2 - y2_predicted) + + abs2(y3 - y3_predicted) + ) + + residuals + """ +) +``` + +Now, we can fit our model using this template. Since +we already computed the per-row squared error inside the template, +we can pass a dummy `y` to the `fit` method, and also define +an `elementwise_loss` that simply returns the residuals (which get +summed over the data): + +```python +model = PySRRegressor( + expression_spec=spec, + binary_operators=["+", "-", "*", "/"], + unary_operators=["exp", "sin"], + maxsize=20, + niterations=50, + elementwise_loss="(pred, target) -> pred", +) + +dummy_y = np.zeros(n) +model.fit(X, dummy_y) +``` + +After running, PySR should find both the shared component (`exp(x1)`) as well as individual components (`square(x2)`, `sin(x3)`, and `x1 * x2`). + +You can access the individual expressions through the Julia objects: + +```python +# Simply get the expression with the highest score: +idx = model.equations_.score.idxmax() + +# Extract the Julia object: +julia_expr = model.equations_.loc[idx, 'julia_expression'] + +# Access individual subexpressions: +for name in ['f1', 'f2', 'f3', 'shared']: + tree = getattr(julia_expr.trees, name) + print(f"{name}: {tree}") +``` + +We can also evaluate individual expressions: + +```python +from pysr import jl +from pysr.julia_helpers import jl_array + +SR = jl.SymbolicRegression + +# Get individual trees +f1_tree = julia_expr.trees.f1 +shared_tree = julia_expr.trees.shared + +# Evaluate at specific points (x1=1, x2=2, x3=3) +test_inputs = jl_array(np.array([[1.0], [2.0], [3.0]])) +f1_result, _ = SR.eval_tree_array(f1_tree, test_inputs, model.julia_options_) +shared_result, _ = SR.eval_tree_array(shared_tree, test_inputs, model.julia_options_) + +print(f"f1 at (1,2,3): {f1_result[0]}") # Should be ~4.0 for x2^2 +print(f"shared at (1,2,3): {shared_result[0]}") # Should be ~2.718 for exp(1) +``` + +## 14. Using differential operators + +As part of the `TemplateExpressionSpec` described above, +you can also use differential operators within the template. +The operator for this is `D` which takes an expression as the first argument, +and the argument _index_ we are differentiating as the second argument. +This lets you compute integrals via evolution. + +For example, let's say we wish to find the integral of $\frac{1}{x^2 \sqrt{x^2 - 1}}$ +in the range $x > 1$. +We can compute the derivative of a function $f(x)$, and compare that +to numerical samples of $\frac{1}{x^2\sqrt{x^2-1}}$. Then, by extension, +$f(x)$ represents the indefinite integral of it with some constant offset! + +```python +import numpy as np +from pysr import PySRRegressor, TemplateExpressionSpec + +x = np.random.uniform(1, 10, (1000,)) # Integrand sampling points +y = 1 / (x**2 * np.sqrt(x**2 - 1)) # Evaluation of the integrand + +expression_spec = TemplateExpressionSpec( + expressions=["f"], + variable_names=["x"], + combine="df = D(f, 1); df(x)", +) + +model = PySRRegressor( + binary_operators=["+", "-", "*", "/"], + unary_operators=["sqrt"], + expression_spec=expression_spec, + maxsize=20, +) +model.fit(x[:, np.newaxis], y) +``` + +If everything works, you should find something that simplifies to $\frac{\sqrt{x^2 - 1}}{x}$. + +Here, we write out a full function in Julia. + +## 15. Additional features + +For the many other features available in PySR, please +read the [Options section](options.md). \ No newline at end of file diff --git a/src/SR_analysis/scripts/infer_illusion.py b/src/SR_analysis/scripts/infer_illusion.py new file mode 100644 index 0000000..72dc90c --- /dev/null +++ b/src/SR_analysis/scripts/infer_illusion.py @@ -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() diff --git a/src/SR_analysis/scripts/infer_karman.py b/src/SR_analysis/scripts/infer_karman.py new file mode 100644 index 0000000..8c8e818 --- /dev/null +++ b/src/SR_analysis/scripts/infer_karman.py @@ -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() diff --git a/src/SR_analysis/scripts/infer_vortex.py b/src/SR_analysis/scripts/infer_vortex.py new file mode 100644 index 0000000..60c514b --- /dev/null +++ b/src/SR_analysis/scripts/infer_vortex.py @@ -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() diff --git a/src/SR_analysis/sindy/illusion/pareto_illusion_1.5L.json b/src/SR_analysis/sindy/illusion/pareto_illusion_1.5L.json new file mode 100644 index 0000000..61074a8 --- /dev/null +++ b/src/SR_analysis/sindy/illusion/pareto_illusion_1.5L.json @@ -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 + } + ] + } + ] +} \ No newline at end of file diff --git a/src/SR_analysis/sindy/illusion/pareto_illusion_1L.json b/src/SR_analysis/sindy/illusion/pareto_illusion_1L.json new file mode 100644 index 0000000..28c5306 --- /dev/null +++ b/src/SR_analysis/sindy/illusion/pareto_illusion_1L.json @@ -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 + } + ] + } + ] +} \ No newline at end of file diff --git a/src/SR_analysis/sindy/illusion/sindy_results.json b/src/SR_analysis/sindy/illusion/sindy_results.json new file mode 100644 index 0000000..08da146 --- /dev/null +++ b/src/SR_analysis/sindy/illusion/sindy_results.json @@ -0,0 +1,1486 @@ +{ + "thresholds": [ + 0.0, + 0.001, + 0.002, + 0.005, + 0.01, + 0.015, + 0.02, + 0.03, + 0.05, + 0.1 + ], + "per_scene": { + "illusion_0.75L": { + "scene": "illusion_0.75L", + "re_code": 100, + "mu": 0.02, + "n_samples": 198, + "feature_names_front": [ + "u_m", + "u_a", + "u_c", + "v_a", + "Cd_tot", + "Cd_rear", + "Cl_tot", + "Cl_diff", + "sin_ua", + "cos_ua", + "aF_lag1", + "aB_lag1", + "aT_lag1", + "daF", + "daB", + "daT", + "mu", + "mu_u_a", + "mu_v_a", + "mu_Cd_tot", + "mu_Cl_diff" + ], + "feature_names_rear": [ + "bias", + "u_m", + "u_a", + "u_c", + "v_a", + "Cd_tot", + "Cd_rear", + "Cl_tot", + "Cl_diff", + "sin_ua", + "cos_ua", + "aF_lag1", + "aB_lag1", + "aT_lag1", + "daF", + "daB", + "daT", + "mu", + "mu_u_a", + "mu_v_a", + "mu_Cd_tot", + "mu_Cl_diff" + ], + "front": { + "results": [ + { + "threshold": 0.0, + "nz": 21, + "r2": 0.9909465284522048, + "mae": 0.05094065174612723 + }, + { + "threshold": 0.001, + "nz": 21, + "r2": 0.9909465284522048, + "mae": 0.05094065174612723 + }, + { + "threshold": 0.002, + "nz": 20, + "r2": 0.9909430457024224, + "mae": 0.050945930749331014 + }, + { + "threshold": 0.005, + "nz": 16, + "r2": 0.9908937551739696, + "mae": 0.05128124987424275 + }, + { + "threshold": 0.01, + "nz": 9, + "r2": 0.9898341055991694, + "mae": 0.055264266764081534 + }, + { + "threshold": 0.015, + "nz": 9, + "r2": 0.9898341055991694, + "mae": 0.055264266764081534 + }, + { + "threshold": 0.02, + "nz": 9, + "r2": 0.9898341055991694, + "mae": 0.055264266764081534 + }, + { + "threshold": 0.03, + "nz": 8, + "r2": 0.9898288495680647, + "mae": 0.055155906733191644 + }, + { + "threshold": 0.05, + "nz": 5, + "r2": 0.98665094142035, + "mae": 0.06301110954333106 + }, + { + "threshold": 0.1, + "nz": 5, + "r2": 0.98665094142035, + "mae": 0.06301110954333106 + } + ], + "best": { + "threshold": 0.0, + "nz": 21, + "r2": 0.9909465284522048, + "mae": 0.05094065174612723 + }, + "best_coef": [ + -0.0023426435297987107, + 0.0008365957610012816, + 0.0027553624491435634, + -0.0047259800501638005, + 0.01704406300423785, + -0.016686092571858158, + -0.02545829534436663, + -0.004755964010297516, + -0.003266275487292385, + 0.001975562644606543, + -0.006176692114023299, + -0.0010323697851936536, + 0.0017004700195221813, + 0.0074989563568796215, + -0.002077607415377545, + -0.00041418286306169955, + -5.325014552992803, + 0.041829787951577636, + -0.23629901063473757, + 0.852203136137665, + -0.23779821359027198 + ], + "sparsity_curve": [ + [ + 0.0, + 21, + 0.9909465284522048 + ], + [ + 0.001, + 21, + 0.9909465284522048 + ], + [ + 0.002, + 20, + 0.9909430457024224 + ], + [ + 0.005, + 16, + 0.9908937551739696 + ], + [ + 0.01, + 9, + 0.9898341055991694 + ], + [ + 0.015, + 9, + 0.9898341055991694 + ], + [ + 0.02, + 9, + 0.9898341055991694 + ], + [ + 0.03, + 8, + 0.9898288495680647 + ], + [ + 0.05, + 5, + 0.98665094142035 + ], + [ + 0.1, + 5, + 0.98665094142035 + ] + ] + }, + "top": { + "results": [ + { + "threshold": 0.0, + "nz": 22, + "r2": 0.9887763593711291, + "mae": 0.06283273947275413 + }, + { + "threshold": 0.001, + "nz": 18, + "r2": 0.988775321449124, + "mae": 0.06288394973308468 + }, + { + "threshold": 0.002, + "nz": 18, + "r2": 0.988775321449124, + "mae": 0.06288394973308468 + }, + { + "threshold": 0.005, + "nz": 16, + "r2": 0.988707375792865, + "mae": 0.06294479368786962 + }, + { + "threshold": 0.01, + "nz": 14, + "r2": 0.9885592097656332, + "mae": 0.06300979647343911 + }, + { + "threshold": 0.015, + "nz": 11, + "r2": 0.9881798345632827, + "mae": 0.06447407333836004 + }, + { + "threshold": 0.02, + "nz": 11, + "r2": 0.9881798345632827, + "mae": 0.06447407333836004 + }, + { + "threshold": 0.03, + "nz": 11, + "r2": 0.9881798345632827, + "mae": 0.06447407333836004 + }, + { + "threshold": 0.05, + "nz": 10, + "r2": 0.9881798345577711, + "mae": 0.06447407408311571 + }, + { + "threshold": 0.1, + "nz": 6, + "r2": 0.9820648552739768, + "mae": 0.08150587332104019 + } + ], + "best": { + "threshold": 0.0, + "nz": 22, + "r2": 0.9887763593711291, + "mae": 0.06283273947275413 + }, + "best_coef": [ + 1.5684596958510442, + -0.020700259949253907, + -0.0004527951159400761, + 0.004845291178284056, + 0.003072265623141176, + -0.0006949200411133702, + -0.06463454014637952, + 0.05319377957208298, + -0.014855985173694481, + 0.0006903315763141385, + 0.0008652655797213287, + -0.0008902352695294026, + 0.000955409277246681, + -0.007927563698867319, + -0.005438313671066585, + 0.0007396507327110274, + 0.0037059258728651292, + 0.031369193892798825, + -0.0226397552810937, + 0.1536132648586609, + -0.034745982702969365, + -0.7427999157338147 + ], + "sparsity_curve": [ + [ + 0.0, + 22, + 0.9887763593711291 + ], + [ + 0.001, + 18, + 0.988775321449124 + ], + [ + 0.002, + 18, + 0.988775321449124 + ], + [ + 0.005, + 16, + 0.988707375792865 + ], + [ + 0.01, + 14, + 0.9885592097656332 + ], + [ + 0.015, + 11, + 0.9881798345632827 + ], + [ + 0.02, + 11, + 0.9881798345632827 + ], + [ + 0.03, + 11, + 0.9881798345632827 + ], + [ + 0.05, + 10, + 0.9881798345577711 + ], + [ + 0.1, + 6, + 0.9820648552739768 + ] + ] + }, + "bottom": { + "results": [ + { + "threshold": 0.0, + "nz": 22, + "r2": 0.989514746507742, + "mae": 0.03831313498308542 + }, + { + "threshold": 0.001, + "nz": 18, + "r2": 0.9895142317806471, + "mae": 0.03829616737070379 + }, + { + "threshold": 0.002, + "nz": 17, + "r2": 0.9895040023426144, + "mae": 0.038479439964511426 + }, + { + "threshold": 0.005, + "nz": 13, + "r2": 0.989392722059127, + "mae": 0.038719668155506086 + }, + { + "threshold": 0.01, + "nz": 13, + "r2": 0.989392722059127, + "mae": 0.038719668155506086 + }, + { + "threshold": 0.015, + "nz": 9, + "r2": 0.9885693960000789, + "mae": 0.0413480346306186 + }, + { + "threshold": 0.02, + "nz": 9, + "r2": 0.988627052369129, + "mae": 0.03989893325558781 + }, + { + "threshold": 0.03, + "nz": 4, + "r2": 0.983281914289706, + "mae": 0.05660430216643521 + }, + { + "threshold": 0.05, + "nz": 4, + "r2": 0.983281914289706, + "mae": 0.05660430216643521 + }, + { + "threshold": 0.1, + "nz": 4, + "r2": 0.983281914289706, + "mae": 0.05660430216643521 + } + ], + "best": { + "threshold": 0.0, + "nz": 22, + "r2": 0.989514746507742, + "mae": 0.03831313498308542 + }, + "best_coef": [ + 0.03345586870310438, + 0.002877847482429273, + -0.0002985767673932116, + -0.0026897044886079902, + 0.0014437683545771682, + -0.0006980005653320855, + 0.029243591757204826, + 0.010534863777499352, + 0.012141985678234239, + -0.002515440886629909, + 0.00039997504522283644, + 0.012670626604622901, + 0.0015803327721451027, + 0.004362950459497657, + -0.01091490529815909, + -0.000371889441711546, + -0.0024757863078204334, + 0.0006691173775753851, + -0.014928838218088275, + 0.07218843032131834, + -0.03489998074530567, + 0.6070993919053495 + ], + "sparsity_curve": [ + [ + 0.0, + 22, + 0.989514746507742 + ], + [ + 0.001, + 18, + 0.9895142317806471 + ], + [ + 0.002, + 17, + 0.9895040023426144 + ], + [ + 0.005, + 13, + 0.989392722059127 + ], + [ + 0.01, + 13, + 0.989392722059127 + ], + [ + 0.015, + 9, + 0.9885693960000789 + ], + [ + 0.02, + 9, + 0.988627052369129 + ], + [ + 0.03, + 4, + 0.983281914289706 + ], + [ + 0.05, + 4, + 0.983281914289706 + ], + [ + 0.1, + 4, + 0.983281914289706 + ] + ] + } + }, + "illusion_1L": { + "scene": "illusion_1L", + "re_code": 100, + "mu": 0.02, + "n_samples": 198, + "feature_names_front": [ + "u_m", + "u_a", + "u_c", + "v_a", + "Cd_tot", + "Cd_rear", + "Cl_tot", + "Cl_diff", + "sin_ua", + "cos_ua", + "aF_lag1", + "aB_lag1", + "aT_lag1", + "daF", + "daB", + "daT", + "mu", + "mu_u_a", + "mu_v_a", + "mu_Cd_tot", + "mu_Cl_diff" + ], + "feature_names_rear": [ + "bias", + "u_m", + "u_a", + "u_c", + "v_a", + "Cd_tot", + "Cd_rear", + "Cl_tot", + "Cl_diff", + "sin_ua", + "cos_ua", + "aF_lag1", + "aB_lag1", + "aT_lag1", + "daF", + "daB", + "daT", + "mu", + "mu_u_a", + "mu_v_a", + "mu_Cd_tot", + "mu_Cl_diff" + ], + "front": { + "results": [ + { + "threshold": 0.0, + "nz": 21, + "r2": 0.9793036424523165, + "mae": 0.09277044629956963 + }, + { + "threshold": 0.001, + "nz": 21, + "r2": 0.9793036424523165, + "mae": 0.09277044629956963 + }, + { + "threshold": 0.002, + "nz": 21, + "r2": 0.9793036424523165, + "mae": 0.09277044629956963 + }, + { + "threshold": 0.005, + "nz": 18, + "r2": 0.9792726941557117, + "mae": 0.0927253068458578 + }, + { + "threshold": 0.01, + "nz": 15, + "r2": 0.9791278336272012, + "mae": 0.09206462799386003 + }, + { + "threshold": 0.015, + "nz": 12, + "r2": 0.9785388919553549, + "mae": 0.09290548776991826 + }, + { + "threshold": 0.02, + "nz": 12, + "r2": 0.9785388919553549, + "mae": 0.09290548776991826 + }, + { + "threshold": 0.03, + "nz": 11, + "r2": 0.9785538576893853, + "mae": 0.09312316413246428 + }, + { + "threshold": 0.05, + "nz": 6, + "r2": 0.9752101462860064, + "mae": 0.09474239683950911 + }, + { + "threshold": 0.1, + "nz": 4, + "r2": 0.9718953011650306, + "mae": 0.0996443230339278 + } + ], + "best": { + "threshold": 0.0, + "nz": 21, + "r2": 0.9793036424523165, + "mae": 0.09277044629956963 + }, + "best_coef": [ + -0.0067984301503516776, + 0.0007224017438983323, + -0.0008198929742780362, + -0.0004515688488200135, + 0.044152835672606076, + -0.09561380171919635, + -0.0527522811617295, + -0.010402447544178616, + -0.00897327654746638, + -0.006263925100416925, + -0.015926304317240264, + -0.007455785759325441, + -0.000834576628878348, + 0.010530014839568622, + 0.0018841414240539588, + -0.0009188123295783498, + 23.663898368547343, + 0.03612008722596084, + -0.022578436653397618, + 2.2076417965839896, + -0.5201221350686689 + ], + "sparsity_curve": [ + [ + 0.0, + 21, + 0.9793036424523165 + ], + [ + 0.001, + 21, + 0.9793036424523165 + ], + [ + 0.002, + 21, + 0.9793036424523165 + ], + [ + 0.005, + 18, + 0.9792726941557117 + ], + [ + 0.01, + 15, + 0.9791278336272012 + ], + [ + 0.015, + 12, + 0.9785388919553549 + ], + [ + 0.02, + 12, + 0.9785388919553549 + ], + [ + 0.03, + 11, + 0.9785538576893853 + ], + [ + 0.05, + 6, + 0.9752101462860064 + ], + [ + 0.1, + 4, + 0.9718953011650306 + ] + ] + }, + "top": { + "results": [ + { + "threshold": 0.0, + "nz": 22, + "r2": 0.9838580289472151, + "mae": 0.07357959820060181 + }, + { + "threshold": 0.001, + "nz": 22, + "r2": 0.9838580289472151, + "mae": 0.07357959820060181 + }, + { + "threshold": 0.002, + "nz": 22, + "r2": 0.9838580289472151, + "mae": 0.07357959820060181 + }, + { + "threshold": 0.005, + "nz": 16, + "r2": 0.9837742843659423, + "mae": 0.07359061413409958 + }, + { + "threshold": 0.01, + "nz": 12, + "r2": 0.9835181072357578, + "mae": 0.07455642953808926 + }, + { + "threshold": 0.015, + "nz": 11, + "r2": 0.9833618417657272, + "mae": 0.07500751689493244 + }, + { + "threshold": 0.02, + "nz": 10, + "r2": 0.9828568398768021, + "mae": 0.07729160561553666 + }, + { + "threshold": 0.03, + "nz": 10, + "r2": 0.9828568398768021, + "mae": 0.07729160561553666 + }, + { + "threshold": 0.05, + "nz": 10, + "r2": 0.9828568398768021, + "mae": 0.07729160561553666 + }, + { + "threshold": 0.1, + "nz": 7, + "r2": 0.9786186299815743, + "mae": 0.08379325994127507 + } + ], + "best": { + "threshold": 0.0, + "nz": 22, + "r2": 0.9838580289472151, + "mae": 0.07357959820060181 + }, + "best_coef": [ + 0.2949090708624881, + -0.0023117924286657626, + -0.0003828244355984205, + 0.0015109707432584647, + 0.0005517428817005867, + -0.04824815922545747, + 0.05175447708136916, + 0.09415648101989169, + 0.0026324788619953208, + 0.004484014336760393, + -0.0032523004729917426, + 0.008657642998352484, + 0.0023285984189202265, + 0.00016685531892354495, + -0.0029709541908135946, + 0.0026021476453728745, + 0.002760441724205776, + 0.00589818139829948, + -0.0191412217778359, + 0.027587147761689995, + -2.4124080088752256, + 0.13162402418325947 + ], + "sparsity_curve": [ + [ + 0.0, + 22, + 0.9838580289472151 + ], + [ + 0.001, + 22, + 0.9838580289472151 + ], + [ + 0.002, + 22, + 0.9838580289472151 + ], + [ + 0.005, + 16, + 0.9837742843659423 + ], + [ + 0.01, + 12, + 0.9835181072357578 + ], + [ + 0.015, + 11, + 0.9833618417657272 + ], + [ + 0.02, + 10, + 0.9828568398768021 + ], + [ + 0.03, + 10, + 0.9828568398768021 + ], + [ + 0.05, + 10, + 0.9828568398768021 + ], + [ + 0.1, + 7, + 0.9786186299815743 + ] + ] + }, + "bottom": { + "results": [ + { + "threshold": 0.0, + "nz": 22, + "r2": 0.983658612471297, + "mae": 0.07685406195137372 + }, + { + "threshold": 0.001, + "nz": 18, + "r2": 0.9836582680775273, + "mae": 0.07682229638376842 + }, + { + "threshold": 0.002, + "nz": 18, + "r2": 0.9836582680775273, + "mae": 0.07682229638376842 + }, + { + "threshold": 0.005, + "nz": 15, + "r2": 0.9836126332791877, + "mae": 0.07733721124290385 + }, + { + "threshold": 0.01, + "nz": 12, + "r2": 0.983291741316277, + "mae": 0.07752968436989101 + }, + { + "threshold": 0.015, + "nz": 11, + "r2": 0.9831202415012674, + "mae": 0.07770893236490248 + }, + { + "threshold": 0.02, + "nz": 8, + "r2": 0.9822685326747084, + "mae": 0.08047465930732595 + }, + { + "threshold": 0.03, + "nz": 7, + "r2": 0.9817463485828739, + "mae": 0.08232686367980104 + }, + { + "threshold": 0.05, + "nz": 6, + "r2": 0.9816905531339832, + "mae": 0.08263277920596242 + }, + { + "threshold": 0.1, + "nz": 4, + "r2": 0.9698703453821834, + "mae": 0.10740070050690212 + } + ], + "best": { + "threshold": 0.0, + "nz": 22, + "r2": 0.983658612471297, + "mae": 0.07685406195137372 + }, + "best_coef": [ + -0.16584728761620943, + -4.201453025796267e-05, + -0.0003367324554175852, + 4.016691436380766e-05, + 5.353518571849713e-06, + 0.022869237235247378, + -0.02264922099610255, + 0.06498016941622513, + -0.004433098114314794, + 0.014134069152157487, + 0.016562425157270626, + 0.011757111854371217, + 0.0038905291826823634, + 0.0007358026485088596, + -0.010520048365673735, + -0.00319045129607923, + 0.0018144493491918025, + -0.0033169457469189724, + -0.01683662274934422, + 0.0002676750898822899, + 1.1434618843744861, + -0.22165490297479395 + ], + "sparsity_curve": [ + [ + 0.0, + 22, + 0.983658612471297 + ], + [ + 0.001, + 18, + 0.9836582680775273 + ], + [ + 0.002, + 18, + 0.9836582680775273 + ], + [ + 0.005, + 15, + 0.9836126332791877 + ], + [ + 0.01, + 12, + 0.983291741316277 + ], + [ + 0.015, + 11, + 0.9831202415012674 + ], + [ + 0.02, + 8, + 0.9822685326747084 + ], + [ + 0.03, + 7, + 0.9817463485828739 + ], + [ + 0.05, + 6, + 0.9816905531339832 + ], + [ + 0.1, + 4, + 0.9698703453821834 + ] + ] + } + }, + "illusion_1.5L": { + "scene": "illusion_1.5L", + "re_code": 100, + "mu": 0.02, + "n_samples": 198, + "feature_names_front": [ + "u_m", + "u_a", + "u_c", + "v_a", + "Cd_tot", + "Cd_rear", + "Cl_tot", + "Cl_diff", + "sin_ua", + "cos_ua", + "aF_lag1", + "aB_lag1", + "aT_lag1", + "daF", + "daB", + "daT", + "mu", + "mu_u_a", + "mu_v_a", + "mu_Cd_tot", + "mu_Cl_diff" + ], + "feature_names_rear": [ + "bias", + "u_m", + "u_a", + "u_c", + "v_a", + "Cd_tot", + "Cd_rear", + "Cl_tot", + "Cl_diff", + "sin_ua", + "cos_ua", + "aF_lag1", + "aB_lag1", + "aT_lag1", + "daF", + "daB", + "daT", + "mu", + "mu_u_a", + "mu_v_a", + "mu_Cd_tot", + "mu_Cl_diff" + ], + "front": { + "results": [ + { + "threshold": 0.0, + "nz": 21, + "r2": 0.9594009004329207, + "mae": 0.08804217459392509 + }, + { + "threshold": 0.001, + "nz": 18, + "r2": 0.9593997378572243, + "mae": 0.08802273179959752 + }, + { + "threshold": 0.002, + "nz": 17, + "r2": 0.9593925359990215, + "mae": 0.088071298819546 + }, + { + "threshold": 0.005, + "nz": 17, + "r2": 0.9593925359990215, + "mae": 0.088071298819546 + }, + { + "threshold": 0.01, + "nz": 17, + "r2": 0.9593925359990215, + "mae": 0.088071298819546 + }, + { + "threshold": 0.015, + "nz": 12, + "r2": 0.958013912668809, + "mae": 0.09054285470077583 + }, + { + "threshold": 0.02, + "nz": 12, + "r2": 0.958013912668809, + "mae": 0.09054285470077583 + }, + { + "threshold": 0.03, + "nz": 7, + "r2": 0.9468038158796819, + "mae": 0.10399269283510436 + }, + { + "threshold": 0.05, + "nz": 6, + "r2": 0.9467043546594699, + "mae": 0.10371836117227738 + }, + { + "threshold": 0.1, + "nz": 5, + "r2": 0.9393791282052957, + "mae": 0.11137259508026535 + } + ], + "best": { + "threshold": 0.0, + "nz": 21, + "r2": 0.9594009004329207, + "mae": 0.08804217459392509 + }, + "best_coef": [ + -0.0013758935930989948, + -0.0009735340972173541, + 0.0011792051284624523, + -3.754109932375392e-05, + 0.01397702439625137, + -0.06273881432629332, + -0.050425992006787164, + -0.028219158524262294, + -0.002459229305654499, + -0.01570435992144311, + -0.011751481630158269, + 0.007176655585364336, + 9.473111158928966e-05, + 0.015640330998297442, + -0.010408273473869306, + 0.01343085054833511, + 0.03410050608346959, + -0.04867670489622209, + -0.0018770552352797077, + 0.6988512149742004, + -1.4109579136787602 + ], + "sparsity_curve": [ + [ + 0.0, + 21, + 0.9594009004329207 + ], + [ + 0.001, + 18, + 0.9593997378572243 + ], + [ + 0.002, + 17, + 0.9593925359990215 + ], + [ + 0.005, + 17, + 0.9593925359990215 + ], + [ + 0.01, + 17, + 0.9593925359990215 + ], + [ + 0.015, + 12, + 0.958013912668809 + ], + [ + 0.02, + 12, + 0.958013912668809 + ], + [ + 0.03, + 7, + 0.9468038158796819 + ], + [ + 0.05, + 6, + 0.9467043546594699 + ], + [ + 0.1, + 5, + 0.9393791282052957 + ] + ] + }, + "top": { + "results": [ + { + "threshold": 0.0, + "nz": 22, + "r2": 0.9283646632651096, + "mae": 0.05215658902466236 + }, + { + "threshold": 0.001, + "nz": 22, + "r2": 0.9283646632651096, + "mae": 0.05215658902466236 + }, + { + "threshold": 0.002, + "nz": 22, + "r2": 0.9283646632651096, + "mae": 0.05215658902466236 + }, + { + "threshold": 0.005, + "nz": 13, + "r2": 0.9260439832735614, + "mae": 0.052741418727710765 + }, + { + "threshold": 0.01, + "nz": 13, + "r2": 0.926100716890473, + "mae": 0.05268392499023593 + }, + { + "threshold": 0.015, + "nz": 11, + "r2": 0.9227131504032893, + "mae": 0.05331679654853205 + }, + { + "threshold": 0.02, + "nz": 6, + "r2": 0.9196961922610852, + "mae": 0.055030429613343874 + }, + { + "threshold": 0.03, + "nz": 5, + "r2": 0.9176885699701556, + "mae": 0.05598503629680088 + }, + { + "threshold": 0.05, + "nz": 5, + "r2": 0.9176885699701556, + "mae": 0.05598503629680088 + }, + { + "threshold": 0.1, + "nz": 2, + "r2": 0.8636623835462103, + "mae": 0.06880520268324045 + } + ], + "best": { + "threshold": 0.0, + "nz": 22, + "r2": 0.9283646632651096, + "mae": 0.05215658902466236 + }, + "best_coef": [ + -0.3416938600093622, + 0.001777908335611285, + -7.85271498486283e-05, + -0.0010919269861829318, + 0.0005526135823700634, + 0.014452673054780839, + -0.02370863294535305, + 0.06255501072457896, + 0.0034304284328878325, + 0.003210708201980426, + 0.0035930090557772616, + 0.004975736851950115, + -0.0036452714207644483, + 0.006562432007959982, + -0.002818135731897136, + 0.00784582965046677, + -0.0011858311602445523, + -0.0068338772053994825, + -0.003926357554471845, + 0.02763067988569561, + 0.7226335673020766, + 0.1715214249991526 + ], + "sparsity_curve": [ + [ + 0.0, + 22, + 0.9283646632651096 + ], + [ + 0.001, + 22, + 0.9283646632651096 + ], + [ + 0.002, + 22, + 0.9283646632651096 + ], + [ + 0.005, + 13, + 0.9260439832735614 + ], + [ + 0.01, + 13, + 0.926100716890473 + ], + [ + 0.015, + 11, + 0.9227131504032893 + ], + [ + 0.02, + 6, + 0.9196961922610852 + ], + [ + 0.03, + 5, + 0.9176885699701556 + ], + [ + 0.05, + 5, + 0.9176885699701556 + ], + [ + 0.1, + 2, + 0.8636623835462103 + ] + ] + }, + "bottom": { + "results": [ + { + "threshold": 0.0, + "nz": 22, + "r2": 0.9318647363961818, + "mae": 0.06604836681404201 + }, + { + "threshold": 0.001, + "nz": 22, + "r2": 0.9318647363961818, + "mae": 0.06604836681404201 + }, + { + "threshold": 0.002, + "nz": 21, + "r2": 0.9318647363961834, + "mae": 0.06604836683119712 + }, + { + "threshold": 0.005, + "nz": 16, + "r2": 0.9315566670173666, + "mae": 0.06614520537789033 + }, + { + "threshold": 0.01, + "nz": 11, + "r2": 0.929107795801212, + "mae": 0.06823125337790198 + }, + { + "threshold": 0.015, + "nz": 7, + "r2": 0.9258419638948643, + "mae": 0.07023662973339334 + }, + { + "threshold": 0.02, + "nz": 7, + "r2": 0.9228297598555016, + "mae": 0.07110544042753793 + }, + { + "threshold": 0.03, + "nz": 7, + "r2": 0.9228297598555016, + "mae": 0.07110544042753793 + }, + { + "threshold": 0.05, + "nz": 6, + "r2": 0.9223640246817683, + "mae": 0.0710732649389863 + }, + { + "threshold": 0.1, + "nz": 3, + "r2": 0.7872211556048209, + "mae": 0.10987339162316778 + } + ], + "best": { + "threshold": 0.002, + "nz": 21, + "r2": 0.9318647363961834, + "mae": 0.06604836683119712 + }, + "best_coef": [ + -0.08301359024674829, + 0.0020567789778479194, + -0.0002022635683761973, + -0.00019905587080910179, + -0.0006454663349790739, + 0.012421466930646724, + -0.0137825550480083, + 0.09140586140888565, + 0.0022582241780764035, + 0.004002738711410701, + 0.0038426249190553906, + 0.008124189782808395, + 0.0010826475906677, + 0.0035228624771008617, + -0.004190104147090137, + 0.014820503258654734, + -0.009492549319697353, + 0.0, + -0.010113178468304862, + -0.032273316312398936, + 0.6210733519412643, + 0.11291119628026249 + ], + "sparsity_curve": [ + [ + 0.0, + 22, + 0.9318647363961818 + ], + [ + 0.001, + 22, + 0.9318647363961818 + ], + [ + 0.002, + 21, + 0.9318647363961834 + ], + [ + 0.005, + 16, + 0.9315566670173666 + ], + [ + 0.01, + 11, + 0.929107795801212 + ], + [ + 0.015, + 7, + 0.9258419638948643 + ], + [ + 0.02, + 7, + 0.9228297598555016 + ], + [ + 0.03, + 7, + 0.9228297598555016 + ], + [ + 0.05, + 6, + 0.9223640246817683 + ], + [ + 0.1, + 3, + 0.7872211556048209 + ] + ] + } + } + } +} \ No newline at end of file diff --git a/src/SR_analysis/sindy/karman/pareto_karman_re100.json b/src/SR_analysis/sindy/karman/pareto_karman_re100.json new file mode 100644 index 0000000..de29c70 --- /dev/null +++ b/src/SR_analysis/sindy/karman/pareto_karman_re100.json @@ -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 + } + ] + } + ] +} \ No newline at end of file diff --git a/src/SR_analysis/sindy/karman/sindy_results.json b/src/SR_analysis/sindy/karman/sindy_results.json new file mode 100644 index 0000000..b83c35d --- /dev/null +++ b/src/SR_analysis/sindy/karman/sindy_results.json @@ -0,0 +1,2031 @@ +{ + "thresholds": [ + 0.0, + 0.001, + 0.002, + 0.005, + 0.01, + 0.015, + 0.02, + 0.03, + 0.05, + 0.1 + ], + "all_feature_names_front": [ + "u_m", + "u_a", + "u_c", + "v_a", + "Cd_tot", + "Cd_rear", + "Cl_tot", + "Cl_diff", + "sin_ua", + "cos_ua", + "aF_lag1", + "aB_lag1", + "aT_lag1", + "daF", + "daB", + "daT", + "mu", + "mu_u_a", + "mu_v_a", + "mu_Cd_tot", + "mu_Cl_diff" + ], + "all_feature_names_rear": [ + "bias", + "u_m", + "u_a", + "u_c", + "v_a", + "Cd_tot", + "Cd_rear", + "Cl_tot", + "Cl_diff", + "sin_ua", + "cos_ua", + "aF_lag1", + "aB_lag1", + "aT_lag1", + "daF", + "daB", + "daT", + "mu", + "mu_u_a", + "mu_v_a", + "mu_Cd_tot", + "mu_Cl_diff" + ], + "per_scene": { + "karman_re50": { + "scene": "karman_re50", + "re_code": 50, + "mu": 0.04, + "n_samples": 198, + "n_features_front": 21, + "n_features_rear": 22, + "feature_names_front": [ + "u_m", + "u_a", + "u_c", + "v_a", + "Cd_tot", + "Cd_rear", + "Cl_tot", + "Cl_diff", + "sin_ua", + "cos_ua", + "aF_lag1", + "aB_lag1", + "aT_lag1", + "daF", + "daB", + "daT", + "mu", + "mu_u_a", + "mu_v_a", + "mu_Cd_tot", + "mu_Cl_diff" + ], + "feature_names_rear": [ + "bias", + "u_m", + "u_a", + "u_c", + "v_a", + "Cd_tot", + "Cd_rear", + "Cl_tot", + "Cl_diff", + "sin_ua", + "cos_ua", + "aF_lag1", + "aB_lag1", + "aT_lag1", + "daF", + "daB", + "daT", + "mu", + "mu_u_a", + "mu_v_a", + "mu_Cd_tot", + "mu_Cl_diff" + ], + "front": { + "results": [ + { + "threshold": 0.0, + "nz": 21, + "r2": 0.998319769966323, + "mae": 0.007229121645243238 + }, + { + "threshold": 0.001, + "nz": 19, + "r2": 0.9983129034537462, + "mae": 0.00723533900123122 + }, + { + "threshold": 0.002, + "nz": 18, + "r2": 0.9982861124400884, + "mae": 0.00729235470189541 + }, + { + "threshold": 0.005, + "nz": 4, + "r2": 0.9974439573320747, + "mae": 0.008776250231333573 + }, + { + "threshold": 0.01, + "nz": 3, + "r2": 0.9967827391886754, + "mae": 0.010102579672034205 + }, + { + "threshold": 0.015, + "nz": 3, + "r2": 0.9967827391886754, + "mae": 0.010102579672034205 + }, + { + "threshold": 0.02, + "nz": 3, + "r2": 0.9967827391886754, + "mae": 0.010102579672034205 + }, + { + "threshold": 0.03, + "nz": 3, + "r2": 0.9967827391886754, + "mae": 0.010102579672034205 + }, + { + "threshold": 0.05, + "nz": 3, + "r2": 0.9967827391886754, + "mae": 0.010102579672034205 + }, + { + "threshold": 0.1, + "nz": 1, + "r2": 0.9213114343048161, + "mae": 0.05630183130409067 + } + ], + "best": { + "threshold": 0.0, + "nz": 21, + "r2": 0.998319769966323, + "mae": 0.007229121645243238 + }, + "best_coef": [ + -0.0031319599705785356, + 0.0009005213487458124, + 0.0021387422607032666, + -0.0005899816205869717, + 0.008569841953090828, + -0.009200996175780681, + 0.00613986870304347, + -0.0049747369106812895, + 0.0016847031130064957, + -0.0008914713562130392, + 0.009136994459848035, + -0.0015464725053784005, + 0.0002406988360957919, + 0.008618191191472493, + 3.5643410959938684e-05, + -0.0012826092968625834, + 0.5373930579297568, + 0.02251303312255063, + -0.01474954032019323, + 0.21424605058608626, + -0.12436843773164996 + ], + "sparsity_curve": [ + [ + 0.0, + 21, + 0.998319769966323 + ], + [ + 0.001, + 19, + 0.9983129034537462 + ], + [ + 0.002, + 18, + 0.9982861124400884 + ], + [ + 0.005, + 4, + 0.9974439573320747 + ], + [ + 0.01, + 3, + 0.9967827391886754 + ], + [ + 0.015, + 3, + 0.9967827391886754 + ], + [ + 0.02, + 3, + 0.9967827391886754 + ], + [ + 0.03, + 3, + 0.9967827391886754 + ], + [ + 0.05, + 3, + 0.9967827391886754 + ], + [ + 0.1, + 1, + 0.9213114343048161 + ] + ] + }, + "top": { + "results": [ + { + "threshold": 0.0, + "nz": 22, + "r2": 0.9893628989724597, + "mae": 0.008399062895981057 + }, + { + "threshold": 0.001, + "nz": 18, + "r2": 0.9893537060208671, + "mae": 0.008403249019293277 + }, + { + "threshold": 0.002, + "nz": 18, + "r2": 0.9893537060208671, + "mae": 0.008403249019293277 + }, + { + "threshold": 0.005, + "nz": 15, + "r2": 0.9887822320892092, + "mae": 0.008699231098152847 + }, + { + "threshold": 0.01, + "nz": 2, + "r2": 0.9434555237401117, + "mae": 0.020489945342854143 + }, + { + "threshold": 0.015, + "nz": 2, + "r2": 0.9434555237401117, + "mae": 0.020489945342854143 + }, + { + "threshold": 0.02, + "nz": 2, + "r2": 0.9434555237401117, + "mae": 0.020489945342854143 + }, + { + "threshold": 0.03, + "nz": 1, + "r2": 0.767474266747063, + "mae": 0.04368785439174068 + }, + { + "threshold": 0.05, + "nz": 1, + "r2": 0.767474266747063, + "mae": 0.04368785439174068 + }, + { + "threshold": 0.1, + "nz": 0, + "r2": -0.24002106699459924, + "mae": 0.09447869485375857 + } + ], + "best": { + "threshold": 0.0, + "nz": 22, + "r2": 0.9893628989724597, + "mae": 0.008399062895981057 + }, + "best_coef": [ + 0.009006304945500938, + -0.0013656895504682409, + 0.0009413896509799071, + -0.00039458277456129134, + -0.003513369230902717, + -0.01007127794832462, + 0.03628955219839411, + 0.005437814717321779, + -0.010370704341076153, + -6.79650067476857e-05, + 0.00024014904867861983, + -0.0003024802497459079, + -0.00022386382334619865, + 0.0060467393319888684, + -0.00012099892398619243, + 0.0024340238439430804, + 0.004483434102350851, + 0.00036025218585711294, + 0.023534741132533694, + -0.08783423089354239, + -0.25178195059980246, + -0.25926759753886264 + ], + "sparsity_curve": [ + [ + 0.0, + 22, + 0.9893628989724597 + ], + [ + 0.001, + 18, + 0.9893537060208671 + ], + [ + 0.002, + 18, + 0.9893537060208671 + ], + [ + 0.005, + 15, + 0.9887822320892092 + ], + [ + 0.01, + 2, + 0.9434555237401117 + ], + [ + 0.015, + 2, + 0.9434555237401117 + ], + [ + 0.02, + 2, + 0.9434555237401117 + ], + [ + 0.03, + 1, + 0.767474266747063 + ], + [ + 0.05, + 1, + 0.767474266747063 + ], + [ + 0.1, + 0, + -0.24002106699459924 + ] + ] + }, + "bottom": { + "results": [ + { + "threshold": 0.0, + "nz": 22, + "r2": 0.9964975415527493, + "mae": 0.0064379526638040155 + }, + { + "threshold": 0.001, + "nz": 20, + "r2": 0.996489370927338, + "mae": 0.00645906250002319 + }, + { + "threshold": 0.002, + "nz": 16, + "r2": 0.9964291093418592, + "mae": 0.0065655743861391825 + }, + { + "threshold": 0.005, + "nz": 10, + "r2": 0.9953098170863071, + "mae": 0.007493523579985216 + }, + { + "threshold": 0.01, + "nz": 4, + "r2": 0.9892509229731086, + "mae": 0.011711342942241802 + }, + { + "threshold": 0.015, + "nz": 3, + "r2": 0.9864981790968609, + "mae": 0.01350374385082598 + }, + { + "threshold": 0.02, + "nz": 2, + "r2": 0.9764723466980298, + "mae": 0.019381200759336845 + }, + { + "threshold": 0.03, + "nz": 1, + "r2": 0.8506889998403568, + "mae": 0.044906642626509306 + }, + { + "threshold": 0.05, + "nz": 1, + "r2": 0.8506889998403568, + "mae": 0.044906642626509306 + }, + { + "threshold": 0.1, + "nz": 0, + "r2": -0.1924891558656865, + "mae": 0.12604671778307872 + } + ], + "best": { + "threshold": 0.0, + "nz": 22, + "r2": 0.9964975415527493, + "mae": 0.0064379526638040155 + }, + "best_coef": [ + -0.05202015913990901, + 0.003985033852842102, + 0.0006739594919587617, + -0.002812076318050239, + 0.003286418720861537, + -0.0014206057573313623, + 0.0041256725776505215, + 0.010138751465280819, + 0.0035878309521334235, + 0.0003261963639967548, + 0.0005596011621300944, + -0.0005079578594815991, + 0.006109843511268079, + -0.00046788962018232675, + -0.003415058638294254, + 0.004795594046438008, + 0.001080165195723476, + -0.00208080634455238, + 0.016848987253660148, + 0.08216046763939658, + -0.03551514238315014, + 0.08969580568091838 + ], + "sparsity_curve": [ + [ + 0.0, + 22, + 0.9964975415527493 + ], + [ + 0.001, + 20, + 0.996489370927338 + ], + [ + 0.002, + 16, + 0.9964291093418592 + ], + [ + 0.005, + 10, + 0.9953098170863071 + ], + [ + 0.01, + 4, + 0.9892509229731086 + ], + [ + 0.015, + 3, + 0.9864981790968609 + ], + [ + 0.02, + 2, + 0.9764723466980298 + ], + [ + 0.03, + 1, + 0.8506889998403568 + ], + [ + 0.05, + 1, + 0.8506889998403568 + ], + [ + 0.1, + 0, + -0.1924891558656865 + ] + ] + } + }, + "karman_re100": { + "scene": "karman_re100", + "re_code": 100, + "mu": 0.02, + "n_samples": 198, + "n_features_front": 21, + "n_features_rear": 22, + "feature_names_front": [ + "u_m", + "u_a", + "u_c", + "v_a", + "Cd_tot", + "Cd_rear", + "Cl_tot", + "Cl_diff", + "sin_ua", + "cos_ua", + "aF_lag1", + "aB_lag1", + "aT_lag1", + "daF", + "daB", + "daT", + "mu", + "mu_u_a", + "mu_v_a", + "mu_Cd_tot", + "mu_Cl_diff" + ], + "feature_names_rear": [ + "bias", + "u_m", + "u_a", + "u_c", + "v_a", + "Cd_tot", + "Cd_rear", + "Cl_tot", + "Cl_diff", + "sin_ua", + "cos_ua", + "aF_lag1", + "aB_lag1", + "aT_lag1", + "daF", + "daB", + "daT", + "mu", + "mu_u_a", + "mu_v_a", + "mu_Cd_tot", + "mu_Cl_diff" + ], + "front": { + "results": [ + { + "threshold": 0.0, + "nz": 21, + "r2": 0.9950013415086292, + "mae": 0.009297861947373046 + }, + { + "threshold": 0.001, + "nz": 18, + "r2": 0.9949963098276752, + "mae": 0.009274493189192809 + }, + { + "threshold": 0.002, + "nz": 13, + "r2": 0.9947660566975605, + "mae": 0.00982566021256978 + }, + { + "threshold": 0.005, + "nz": 12, + "r2": 0.9939890287801123, + "mae": 0.011077058192444884 + }, + { + "threshold": 0.01, + "nz": 6, + "r2": 0.9908017426802015, + "mae": 0.013580429645776177 + }, + { + "threshold": 0.015, + "nz": 4, + "r2": 0.989378747581318, + "mae": 0.014496650604934345 + }, + { + "threshold": 0.02, + "nz": 3, + "r2": 0.9692414821446799, + "mae": 0.02439129142750941 + }, + { + "threshold": 0.03, + "nz": 3, + "r2": 0.9692414821446799, + "mae": 0.02439129142750941 + }, + { + "threshold": 0.05, + "nz": 3, + "r2": 0.9692414821446799, + "mae": 0.02439129142750941 + }, + { + "threshold": 0.1, + "nz": 2, + "r2": 0.8681678005649112, + "mae": 0.05348411227986087 + } + ], + "best": { + "threshold": 0.0, + "nz": 21, + "r2": 0.9950013415086292, + "mae": 0.009297861947373046 + }, + "best_coef": [ + 0.002913453467073268, + -0.0010034473094889056, + -0.00015119435077007708, + 0.0012453743753331277, + 0.003746067771916422, + 0.012365721744701548, + -0.004422233294377436, + 0.016310385466661095, + -0.0003123427809473034, + 0.0004968569560360933, + 0.006918585044605247, + 0.0013427595425374512, + -0.003486854051080906, + 0.002723391394259924, + -0.0021322606391810995, + 0.0015689229718389174, + 0.9234972689284461, + -0.050172366027705036, + 0.0622687182995332, + 0.18730338573081926, + 0.8155192599082862 + ], + "sparsity_curve": [ + [ + 0.0, + 21, + 0.9950013415086292 + ], + [ + 0.001, + 18, + 0.9949963098276752 + ], + [ + 0.002, + 13, + 0.9947660566975605 + ], + [ + 0.005, + 12, + 0.9939890287801123 + ], + [ + 0.01, + 6, + 0.9908017426802015 + ], + [ + 0.015, + 4, + 0.989378747581318 + ], + [ + 0.02, + 3, + 0.9692414821446799 + ], + [ + 0.03, + 3, + 0.9692414821446799 + ], + [ + 0.05, + 3, + 0.9692414821446799 + ], + [ + 0.1, + 2, + 0.8681678005649112 + ] + ] + }, + "top": { + "results": [ + { + "threshold": 0.0, + "nz": 22, + "r2": 0.9928439394510484, + "mae": 0.014260238272228571 + }, + { + "threshold": 0.001, + "nz": 20, + "r2": 0.992824536423302, + "mae": 0.014349208218446834 + }, + { + "threshold": 0.002, + "nz": 17, + "r2": 0.9927843560231949, + "mae": 0.01439557962946284 + }, + { + "threshold": 0.005, + "nz": 17, + "r2": 0.9927843560231949, + "mae": 0.01439557962946284 + }, + { + "threshold": 0.01, + "nz": 12, + "r2": 0.9909377708950154, + "mae": 0.0157296791540154 + }, + { + "threshold": 0.015, + "nz": 12, + "r2": 0.9909377708950154, + "mae": 0.0157296791540154 + }, + { + "threshold": 0.02, + "nz": 12, + "r2": 0.9909377708950154, + "mae": 0.0157296791540154 + }, + { + "threshold": 0.03, + "nz": 6, + "r2": 0.9812946866555053, + "mae": 0.02411761595354287 + }, + { + "threshold": 0.05, + "nz": 2, + "r2": 0.9285296099294669, + "mae": 0.04588409913352275 + }, + { + "threshold": 0.1, + "nz": 1, + "r2": 0.44188145385324007, + "mae": 0.12621912517460543 + } + ], + "best": { + "threshold": 0.0, + "nz": 22, + "r2": 0.9928439394510484, + "mae": 0.014260238272228571 + }, + "best_coef": [ + 0.9660827819346605, + -0.008297125761372143, + -0.003911945137930959, + 0.0034814956807657227, + -0.0007517110088463216, + -0.02136581381381709, + 0.033416355828994854, + 0.09090888202182665, + 0.021342153859962566, + -0.0008885042212805717, + 0.0011163004543444928, + 0.003413122761270848, + 0.009655389486436414, + 0.0031436366855075283, + -0.00041107548453157643, + -0.003568754683600915, + 0.0051461956943505425, + 0.019321655644143756, + -0.19559725673163528, + -0.03758555104936961, + -1.0682906975825734, + 1.0671077959431223 + ], + "sparsity_curve": [ + [ + 0.0, + 22, + 0.9928439394510484 + ], + [ + 0.001, + 20, + 0.992824536423302 + ], + [ + 0.002, + 17, + 0.9927843560231949 + ], + [ + 0.005, + 17, + 0.9927843560231949 + ], + [ + 0.01, + 12, + 0.9909377708950154 + ], + [ + 0.015, + 12, + 0.9909377708950154 + ], + [ + 0.02, + 12, + 0.9909377708950154 + ], + [ + 0.03, + 6, + 0.9812946866555053 + ], + [ + 0.05, + 2, + 0.9285296099294669 + ], + [ + 0.1, + 1, + 0.44188145385324007 + ] + ] + }, + "bottom": { + "results": [ + { + "threshold": 0.0, + "nz": 22, + "r2": 0.9965335484991993, + "mae": 0.011523372809828718 + }, + { + "threshold": 0.001, + "nz": 18, + "r2": 0.9965311727162228, + "mae": 0.011494093354528386 + }, + { + "threshold": 0.002, + "nz": 16, + "r2": 0.9964335809851655, + "mae": 0.011697604600527748 + }, + { + "threshold": 0.005, + "nz": 13, + "r2": 0.9963877299663628, + "mae": 0.011836893357054774 + }, + { + "threshold": 0.01, + "nz": 10, + "r2": 0.9962551509064745, + "mae": 0.012131237037309098 + }, + { + "threshold": 0.015, + "nz": 10, + "r2": 0.9962551509064745, + "mae": 0.012131237037309098 + }, + { + "threshold": 0.02, + "nz": 8, + "r2": 0.9947803148283133, + "mae": 0.015270565294053379 + }, + { + "threshold": 0.03, + "nz": 8, + "r2": 0.9918438643725791, + "mae": 0.01736415070626163 + }, + { + "threshold": 0.05, + "nz": 2, + "r2": 0.9030386794798415, + "mae": 0.06608098355156587 + }, + { + "threshold": 0.1, + "nz": 1, + "r2": 0.8456588970543057, + "mae": 0.08685250875748249 + } + ], + "best": { + "threshold": 0.0, + "nz": 22, + "r2": 0.9965335484991993, + "mae": 0.011523372809828718 + }, + "best_coef": [ + -0.22933046011719774, + 0.00441915928491626, + -0.0020938732373856363, + -0.0029770176320210247, + 0.00021839723288964485, + -0.005410413891374701, + -0.016521988203524282, + 0.061471417818599695, + -0.00320616015626958, + -0.00022241266774999688, + 7.600088428371662e-05, + 0.0032437831416083558, + 0.00994611146006311, + 0.0019356852272298807, + -0.00287614144408484, + 0.004118949072765166, + -0.0026572329887406816, + -0.004586609213013399, + -0.10469366140552798, + 0.010919861645215943, + -0.2705206993407136, + -0.16030801380832282 + ], + "sparsity_curve": [ + [ + 0.0, + 22, + 0.9965335484991993 + ], + [ + 0.001, + 18, + 0.9965311727162228 + ], + [ + 0.002, + 16, + 0.9964335809851655 + ], + [ + 0.005, + 13, + 0.9963877299663628 + ], + [ + 0.01, + 10, + 0.9962551509064745 + ], + [ + 0.015, + 10, + 0.9962551509064745 + ], + [ + 0.02, + 8, + 0.9947803148283133 + ], + [ + 0.03, + 8, + 0.9918438643725791 + ], + [ + 0.05, + 2, + 0.9030386794798415 + ], + [ + 0.1, + 1, + 0.8456588970543057 + ] + ] + } + }, + "karman_re200": { + "scene": "karman_re200", + "re_code": 200, + "mu": 0.01, + "n_samples": 198, + "n_features_front": 21, + "n_features_rear": 22, + "feature_names_front": [ + "u_m", + "u_a", + "u_c", + "v_a", + "Cd_tot", + "Cd_rear", + "Cl_tot", + "Cl_diff", + "sin_ua", + "cos_ua", + "aF_lag1", + "aB_lag1", + "aT_lag1", + "daF", + "daB", + "daT", + "mu", + "mu_u_a", + "mu_v_a", + "mu_Cd_tot", + "mu_Cl_diff" + ], + "feature_names_rear": [ + "bias", + "u_m", + "u_a", + "u_c", + "v_a", + "Cd_tot", + "Cd_rear", + "Cl_tot", + "Cl_diff", + "sin_ua", + "cos_ua", + "aF_lag1", + "aB_lag1", + "aT_lag1", + "daF", + "daB", + "daT", + "mu", + "mu_u_a", + "mu_v_a", + "mu_Cd_tot", + "mu_Cl_diff" + ], + "front": { + "results": [ + { + "threshold": 0.0, + "nz": 21, + "r2": 0.9565565952948164, + "mae": 0.048992270309083256 + }, + { + "threshold": 0.001, + "nz": 21, + "r2": 0.9565565952948164, + "mae": 0.048992270309083256 + }, + { + "threshold": 0.002, + "nz": 21, + "r2": 0.9565565952948164, + "mae": 0.048992270309083256 + }, + { + "threshold": 0.005, + "nz": 16, + "r2": 0.9556995055314772, + "mae": 0.049235757837804 + }, + { + "threshold": 0.01, + "nz": 13, + "r2": 0.9534652153648002, + "mae": 0.05019629912762377 + }, + { + "threshold": 0.015, + "nz": 12, + "r2": 0.9528134556742605, + "mae": 0.0506080965623989 + }, + { + "threshold": 0.02, + "nz": 10, + "r2": 0.9438381771460185, + "mae": 0.053768522266514816 + }, + { + "threshold": 0.03, + "nz": 6, + "r2": 0.9207971555743621, + "mae": 0.059392328951668336 + }, + { + "threshold": 0.05, + "nz": 3, + "r2": 0.8886657714941744, + "mae": 0.07146255905466381 + }, + { + "threshold": 0.1, + "nz": 3, + "r2": 0.8886657714941744, + "mae": 0.07146255905466381 + } + ], + "best": { + "threshold": 0.0, + "nz": 21, + "r2": 0.9565565952948164, + "mae": 0.048992270309083256 + }, + "best_coef": [ + 0.0025250328345394857, + -0.00030552517099191216, + -0.0005157197565304931, + -0.0009358937691519228, + 0.00811834023902148, + 0.004698768808610153, + -0.013179199045188592, + 0.008022774095407696, + 0.005748990258117735, + -0.014393040251094861, + 0.00404638728191461, + -0.0036851254015715326, + 0.0008965462342368713, + 0.007272101305605151, + 0.004468137310301827, + -0.0011281026248114966, + -2.0964314329520084, + -0.030552516911066304, + -0.09358937546373172, + 0.8118340246648522, + 0.8022774000432843 + ], + "sparsity_curve": [ + [ + 0.0, + 21, + 0.9565565952948164 + ], + [ + 0.001, + 21, + 0.9565565952948164 + ], + [ + 0.002, + 21, + 0.9565565952948164 + ], + [ + 0.005, + 16, + 0.9556995055314772 + ], + [ + 0.01, + 13, + 0.9534652153648002 + ], + [ + 0.015, + 12, + 0.9528134556742605 + ], + [ + 0.02, + 10, + 0.9438381771460185 + ], + [ + 0.03, + 6, + 0.9207971555743621 + ], + [ + 0.05, + 3, + 0.8886657714941744 + ], + [ + 0.1, + 3, + 0.8886657714941744 + ] + ] + }, + "top": { + "results": [ + { + "threshold": 0.0, + "nz": 22, + "r2": 0.9136716302531197, + "mae": 0.13271717446398762 + }, + { + "threshold": 0.001, + "nz": 22, + "r2": 0.9136716302531197, + "mae": 0.13271717446398762 + }, + { + "threshold": 0.002, + "nz": 22, + "r2": 0.9136716302531197, + "mae": 0.13271717446398762 + }, + { + "threshold": 0.005, + "nz": 21, + "r2": 0.9136497787734177, + "mae": 0.1326615832490538 + }, + { + "threshold": 0.01, + "nz": 18, + "r2": 0.9127667005882467, + "mae": 0.13294926009165733 + }, + { + "threshold": 0.015, + "nz": 16, + "r2": 0.9123943175152608, + "mae": 0.13340774414743775 + }, + { + "threshold": 0.02, + "nz": 15, + "r2": 0.9119635062655607, + "mae": 0.13360066993766545 + }, + { + "threshold": 0.03, + "nz": 14, + "r2": 0.9095049725410859, + "mae": 0.13476999297969008 + }, + { + "threshold": 0.05, + "nz": 11, + "r2": 0.8970840818843505, + "mae": 0.14441084992627343 + }, + { + "threshold": 0.1, + "nz": 7, + "r2": 0.8674848759406946, + "mae": 0.16355708965721857 + } + ], + "best": { + "threshold": 0.0, + "nz": 22, + "r2": 0.9136716302531197, + "mae": 0.13271717446398762 + }, + "best_coef": [ + 0.6033643953730624, + -0.013001318623264728, + -0.0006888231500980483, + 0.002963263039873873, + 0.002162667508855548, + -0.017487929496577476, + -0.007804043064953329, + 0.0622637004757959, + -0.0171539523606414, + 0.0036374475559951282, + 0.04268070514006053, + 0.008784345015259603, + 0.008202019029225465, + 0.0035048209369071123, + -0.013928400471627302, + -0.010661946445881886, + 0.004826772877718546, + 0.006033643934077788, + -0.06888231591094213, + 0.21626674897761955, + -1.7487929508371736, + -1.7153951478157978 + ], + "sparsity_curve": [ + [ + 0.0, + 22, + 0.9136716302531197 + ], + [ + 0.001, + 22, + 0.9136716302531197 + ], + [ + 0.002, + 22, + 0.9136716302531197 + ], + [ + 0.005, + 21, + 0.9136497787734177 + ], + [ + 0.01, + 18, + 0.9127667005882467 + ], + [ + 0.015, + 16, + 0.9123943175152608 + ], + [ + 0.02, + 15, + 0.9119635062655607 + ], + [ + 0.03, + 14, + 0.9095049725410859 + ], + [ + 0.05, + 11, + 0.8970840818843505 + ], + [ + 0.1, + 7, + 0.8674848759406946 + ] + ] + }, + "bottom": { + "results": [ + { + "threshold": 0.0, + "nz": 22, + "r2": 0.918041512638942, + "mae": 0.09546886302890771 + }, + { + "threshold": 0.001, + "nz": 22, + "r2": 0.918041512638942, + "mae": 0.09546886302890771 + }, + { + "threshold": 0.002, + "nz": 22, + "r2": 0.918041512638942, + "mae": 0.09546886302890771 + }, + { + "threshold": 0.005, + "nz": 20, + "r2": 0.9180326957867992, + "mae": 0.09546002257462356 + }, + { + "threshold": 0.01, + "nz": 16, + "r2": 0.9163833397981528, + "mae": 0.09655749047162396 + }, + { + "threshold": 0.015, + "nz": 13, + "r2": 0.9144139186225387, + "mae": 0.09821641028624538 + }, + { + "threshold": 0.02, + "nz": 13, + "r2": 0.9144139186225387, + "mae": 0.09821641028624538 + }, + { + "threshold": 0.03, + "nz": 8, + "r2": 0.9073317807110457, + "mae": 0.10069456239205128 + }, + { + "threshold": 0.05, + "nz": 8, + "r2": 0.9073317807110457, + "mae": 0.10069456239205128 + }, + { + "threshold": 0.1, + "nz": 3, + "r2": 0.8132853582236775, + "mae": 0.1444794079589489 + } + ], + "best": { + "threshold": 0.0, + "nz": 22, + "r2": 0.918041512638942, + "mae": 0.09546886302890771 + }, + "best_coef": [ + 0.5194681644420974, + -0.007268574871488442, + -0.0001382882015259102, + 0.0014122761737770733, + 0.0019941493569474024, + -0.02173278599186409, + 0.005402769951884052, + 0.039885891947331276, + -0.00479604352418292, + -0.007473801916727194, + 0.029612326854934444, + 0.006640105319541523, + 0.01211780817575797, + -0.001886710806821879, + -0.01013667114742123, + -0.004177246747561825, + 0.0009568252957939229, + 0.005194681632060589, + -0.013828820328458407, + 0.19941493407141364, + -2.1732785992241563, + -0.4796043074818968 + ], + "sparsity_curve": [ + [ + 0.0, + 22, + 0.918041512638942 + ], + [ + 0.001, + 22, + 0.918041512638942 + ], + [ + 0.002, + 22, + 0.918041512638942 + ], + [ + 0.005, + 20, + 0.9180326957867992 + ], + [ + 0.01, + 16, + 0.9163833397981528 + ], + [ + 0.015, + 13, + 0.9144139186225387 + ], + [ + 0.02, + 13, + 0.9144139186225387 + ], + [ + 0.03, + 8, + 0.9073317807110457 + ], + [ + 0.05, + 8, + 0.9073317807110457 + ], + [ + 0.1, + 3, + 0.8132853582236775 + ] + ] + } + }, + "karman_re400": { + "scene": "karman_re400", + "re_code": 400, + "mu": 0.005, + "n_samples": 148, + "n_features_front": 21, + "n_features_rear": 22, + "feature_names_front": [ + "u_m", + "u_a", + "u_c", + "v_a", + "Cd_tot", + "Cd_rear", + "Cl_tot", + "Cl_diff", + "sin_ua", + "cos_ua", + "aF_lag1", + "aB_lag1", + "aT_lag1", + "daF", + "daB", + "daT", + "mu", + "mu_u_a", + "mu_v_a", + "mu_Cd_tot", + "mu_Cl_diff" + ], + "feature_names_rear": [ + "bias", + "u_m", + "u_a", + "u_c", + "v_a", + "Cd_tot", + "Cd_rear", + "Cl_tot", + "Cl_diff", + "sin_ua", + "cos_ua", + "aF_lag1", + "aB_lag1", + "aT_lag1", + "daF", + "daB", + "daT", + "mu", + "mu_u_a", + "mu_v_a", + "mu_Cd_tot", + "mu_Cl_diff" + ], + "front": { + "results": [ + { + "threshold": 0.0, + "nz": 21, + "r2": 0.9910766698736291, + "mae": 0.011629558855658013 + }, + { + "threshold": 0.001, + "nz": 19, + "r2": 0.9910498397066849, + "mae": 0.011571370408140063 + }, + { + "threshold": 0.002, + "nz": 16, + "r2": 0.9907584709220429, + "mae": 0.011474480357144053 + }, + { + "threshold": 0.005, + "nz": 10, + "r2": 0.9884902032543673, + "mae": 0.012859069934565787 + }, + { + "threshold": 0.01, + "nz": 8, + "r2": 0.9865445971795084, + "mae": 0.013656070741254706 + }, + { + "threshold": 0.015, + "nz": 4, + "r2": 0.9754219455809757, + "mae": 0.017727590048987045 + }, + { + "threshold": 0.02, + "nz": 4, + "r2": 0.9754219455809757, + "mae": 0.017727590048987045 + }, + { + "threshold": 0.03, + "nz": 3, + "r2": 0.9703483630041645, + "mae": 0.019892056694056134 + }, + { + "threshold": 0.05, + "nz": 2, + "r2": 0.8086045777820393, + "mae": 0.05911817904399702 + }, + { + "threshold": 0.1, + "nz": 1, + "r2": -1.1864322013721562e-07, + "mae": 0.13795475986425845 + } + ], + "best": { + "threshold": 0.0, + "nz": 21, + "r2": 0.9910766698736291, + "mae": 0.011629558855658013 + }, + "best_coef": [ + 0.00012236151003792048, + 0.0005527515683824865, + 0.0001884345591579067, + -0.00019884208585139524, + -0.003254090054098988, + 0.004501560609074065, + 0.004254394757178572, + 0.0010083415349592146, + -0.0011145408470664684, + -0.00027208919025581185, + 0.004903963122854036, + -0.0008667057347028575, + -0.0009156023532667489, + -0.002244069252286602, + -0.0007849054200991619, + -0.0006688340291467981, + -0.3783093474577987, + 0.11055031386114175, + -0.03976841722790901, + -0.6508180110262577, + 0.20166830270639363 + ], + "sparsity_curve": [ + [ + 0.0, + 21, + 0.9910766698736291 + ], + [ + 0.001, + 19, + 0.9910498397066849 + ], + [ + 0.002, + 16, + 0.9907584709220429 + ], + [ + 0.005, + 10, + 0.9884902032543673 + ], + [ + 0.01, + 8, + 0.9865445971795084 + ], + [ + 0.015, + 4, + 0.9754219455809757 + ], + [ + 0.02, + 4, + 0.9754219455809757 + ], + [ + 0.03, + 3, + 0.9703483630041645 + ], + [ + 0.05, + 2, + 0.8086045777820393 + ], + [ + 0.1, + 1, + -1.1864322013721562e-07 + ] + ] + }, + "top": { + "results": [ + { + "threshold": 0.0, + "nz": 22, + "r2": 0.9791617707213738, + "mae": 0.04494529906498399 + }, + { + "threshold": 0.001, + "nz": 21, + "r2": 0.979161770721373, + "mae": 0.044945299064320744 + }, + { + "threshold": 0.002, + "nz": 20, + "r2": 0.9791418532403751, + "mae": 0.0450021479471988 + }, + { + "threshold": 0.005, + "nz": 15, + "r2": 0.9788691394557797, + "mae": 0.04504818600796471 + }, + { + "threshold": 0.01, + "nz": 11, + "r2": 0.9778604380183816, + "mae": 0.04673322295463206 + }, + { + "threshold": 0.015, + "nz": 10, + "r2": 0.9749252195935764, + "mae": 0.048414314214308786 + }, + { + "threshold": 0.02, + "nz": 10, + "r2": 0.9749252195935764, + "mae": 0.048414314214308786 + }, + { + "threshold": 0.03, + "nz": 6, + "r2": 0.9696026027584206, + "mae": 0.05150605405202583 + }, + { + "threshold": 0.05, + "nz": 3, + "r2": 0.9550986794209745, + "mae": 0.06435430676226091 + }, + { + "threshold": 0.1, + "nz": 2, + "r2": 0.9259624571185212, + "mae": 0.0837515425066929 + } + ], + "best": { + "threshold": 0.0, + "nz": 22, + "r2": 0.9791617707213738, + "mae": 0.04494529906498399 + }, + "best_coef": [ + 0.08671233316981866, + -0.0014078616575270183, + -0.0011893114948202201, + -0.00027494239940701806, + 0.0005636347911606242, + 0.0018931610984057356, + 0.010079781145559281, + 0.002243578571231687, + -0.0018733090911482563, + 0.006358127163967422, + 0.0029488047574932914, + 0.012900969453655643, + 0.0018585854990862327, + 0.010593421792038704, + 0.0046178581381171825, + 0.001966279273844412, + 0.0025327701360673286, + 0.0004335616645658923, + -0.23786229795947839, + 0.11272695662098822, + 0.37863223658197603, + -0.37466179544216405 + ], + "sparsity_curve": [ + [ + 0.0, + 22, + 0.9791617707213738 + ], + [ + 0.001, + 21, + 0.979161770721373 + ], + [ + 0.002, + 20, + 0.9791418532403751 + ], + [ + 0.005, + 15, + 0.9788691394557797 + ], + [ + 0.01, + 11, + 0.9778604380183816 + ], + [ + 0.015, + 10, + 0.9749252195935764 + ], + [ + 0.02, + 10, + 0.9749252195935764 + ], + [ + 0.03, + 6, + 0.9696026027584206 + ], + [ + 0.05, + 3, + 0.9550986794209745 + ], + [ + 0.1, + 2, + 0.9259624571185212 + ] + ] + }, + "bottom": { + "results": [ + { + "threshold": 0.0, + "nz": 22, + "r2": 0.9686581410934367, + "mae": 0.04868261400704479 + }, + { + "threshold": 0.001, + "nz": 22, + "r2": 0.9686581410934367, + "mae": 0.04868261400704479 + }, + { + "threshold": 0.002, + "nz": 17, + "r2": 0.9685946535884615, + "mae": 0.04886158384662259 + }, + { + "threshold": 0.005, + "nz": 12, + "r2": 0.968134863052741, + "mae": 0.04937227005620415 + }, + { + "threshold": 0.01, + "nz": 11, + "r2": 0.9677608817383357, + "mae": 0.04973438224463947 + }, + { + "threshold": 0.015, + "nz": 10, + "r2": 0.9646891014423458, + "mae": 0.051622150117990234 + }, + { + "threshold": 0.02, + "nz": 7, + "r2": 0.9613283621164924, + "mae": 0.05383371646931655 + }, + { + "threshold": 0.03, + "nz": 7, + "r2": 0.9613283621164924, + "mae": 0.05383371646931655 + }, + { + "threshold": 0.05, + "nz": 5, + "r2": 0.9540490870907875, + "mae": 0.05855323697586902 + }, + { + "threshold": 0.1, + "nz": 4, + "r2": 0.9489267712729627, + "mae": 0.06030133862863786 + } + ], + "best": { + "threshold": 0.0, + "nz": 22, + "r2": 0.9686581410934367, + "mae": 0.04868261400704479 + }, + "best_coef": [ + 0.31963161225069453, + -0.0014417826804902915, + -0.0009488399798131276, + -0.00016301389284321925, + 0.00019367331652549443, + -0.0013907167791004245, + -0.0010719152858884842, + 0.009642492550627655, + 0.0005982386331633066, + 0.009400066467505037, + 0.005412675334329632, + 0.011942792611222953, + 0.006608645806628951, + 0.005356341917455254, + 0.002757244045268886, + 0.006597290057495822, + -0.0020024705507485736, + 0.0015981580589943245, + -0.18976799549284054, + 0.03873466257245657, + -0.2781433523877088, + 0.11964768680720898 + ], + "sparsity_curve": [ + [ + 0.0, + 22, + 0.9686581410934367 + ], + [ + 0.001, + 22, + 0.9686581410934367 + ], + [ + 0.002, + 17, + 0.9685946535884615 + ], + [ + 0.005, + 12, + 0.968134863052741 + ], + [ + 0.01, + 11, + 0.9677608817383357 + ], + [ + 0.015, + 10, + 0.9646891014423458 + ], + [ + 0.02, + 7, + 0.9613283621164924 + ], + [ + 0.03, + 7, + 0.9613283621164924 + ], + [ + 0.05, + 5, + 0.9540490870907875 + ], + [ + 0.1, + 4, + 0.9489267712729627 + ] + ] + } + } + } +} \ No newline at end of file diff --git a/src/SR_analysis/sindy/run_illusion.py b/src/SR_analysis/sindy/run_illusion.py new file mode 100644 index 0000000..fed2e34 --- /dev/null +++ b/src/SR_analysis/sindy/run_illusion.py @@ -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() diff --git a/src/SR_analysis/sindy/run_karman.py b/src/SR_analysis/sindy/run_karman.py new file mode 100644 index 0000000..77b549f --- /dev/null +++ b/src/SR_analysis/sindy/run_karman.py @@ -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() diff --git a/src/SR_analysis/sindy/run_pareto.py b/src/SR_analysis/sindy/run_pareto.py new file mode 100644 index 0000000..d8523f0 --- /dev/null +++ b/src/SR_analysis/sindy/run_pareto.py @@ -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() diff --git a/src/SR_analysis/sindy/run_vortex.py b/src/SR_analysis/sindy/run_vortex.py new file mode 100644 index 0000000..a50f03f --- /dev/null +++ b/src/SR_analysis/sindy/run_vortex.py @@ -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() diff --git a/src/SR_analysis/sindy/vortex/pareto_vortex_lamb.json b/src/SR_analysis/sindy/vortex/pareto_vortex_lamb.json new file mode 100644 index 0000000..0970f36 --- /dev/null +++ b/src/SR_analysis/sindy/vortex/pareto_vortex_lamb.json @@ -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 + } + ] + } + ] +} \ No newline at end of file diff --git a/src/SR_analysis/sindy/vortex/pareto_vortex_taylor.json b/src/SR_analysis/sindy/vortex/pareto_vortex_taylor.json new file mode 100644 index 0000000..debc8df --- /dev/null +++ b/src/SR_analysis/sindy/vortex/pareto_vortex_taylor.json @@ -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 + } + ] + } + ] +} \ No newline at end of file diff --git a/src/SR_analysis/sindy/vortex/sindy_results.json b/src/SR_analysis/sindy/vortex/sindy_results.json new file mode 100644 index 0000000..864c72e --- /dev/null +++ b/src/SR_analysis/sindy/vortex/sindy_results.json @@ -0,0 +1,996 @@ +{ + "thresholds": [ + 0.0, + 0.001, + 0.002, + 0.005, + 0.01, + 0.015, + 0.02, + 0.03, + 0.05, + 0.1 + ], + "per_scene": { + "vortex_lamb": { + "scene": "vortex_lamb", + "re_code": 100, + "mu": 0.02, + "n_samples": 148, + "feature_names_front": [ + "u_m", + "u_a", + "u_c", + "v_a", + "Cd_tot", + "Cd_rear", + "Cl_tot", + "Cl_diff", + "sin_ua", + "cos_ua", + "aF_lag1", + "aB_lag1", + "aT_lag1", + "daF", + "daB", + "daT", + "mu", + "mu_u_a", + "mu_v_a", + "mu_Cd_tot", + "mu_Cl_diff" + ], + "feature_names_rear": [ + "bias", + "u_m", + "u_a", + "u_c", + "v_a", + "Cd_tot", + "Cd_rear", + "Cl_tot", + "Cl_diff", + "sin_ua", + "cos_ua", + "aF_lag1", + "aB_lag1", + "aT_lag1", + "daF", + "daB", + "daT", + "mu", + "mu_u_a", + "mu_v_a", + "mu_Cd_tot", + "mu_Cl_diff" + ], + "front": { + "results": [ + { + "threshold": 0.0, + "nz": 21, + "r2": 0.9035051679446613, + "mae": 0.05502827225008196 + }, + { + "threshold": 0.001, + "nz": 20, + "r2": 0.9035028044287374, + "mae": 0.054966606007031876 + }, + { + "threshold": 0.002, + "nz": 20, + "r2": 0.9035028044287374, + "mae": 0.054966606007031876 + }, + { + "threshold": 0.005, + "nz": 16, + "r2": 0.9017891237504362, + "mae": 0.053710513734883655 + }, + { + "threshold": 0.01, + "nz": 9, + "r2": 0.8985847902462778, + "mae": 0.054491226319598914 + }, + { + "threshold": 0.015, + "nz": 8, + "r2": 0.8957625139671737, + "mae": 0.05306021520916354 + }, + { + "threshold": 0.02, + "nz": 8, + "r2": 0.8957625139671737, + "mae": 0.05306021520916354 + }, + { + "threshold": 0.03, + "nz": 8, + "r2": 0.8957625139671737, + "mae": 0.05306021520916354 + }, + { + "threshold": 0.05, + "nz": 3, + "r2": 0.8536388609680176, + "mae": 0.048637995761707624 + }, + { + "threshold": 0.1, + "nz": 3, + "r2": 0.8536388609680176, + "mae": 0.048637995761707624 + } + ], + "best": { + "threshold": 0.0, + "nz": 21, + "r2": 0.9035051679446613, + "mae": 0.05502827225008196 + }, + "best_coef": [ + 0.0072499249196507284, + -0.013406647625693383, + -0.00038101504054493135, + -0.01577043426582744, + -0.021357793291808133, + 0.09538149016633536, + -0.17597430070013861, + 0.01105460767691196, + 0.013467531810937875, + -0.008447877114831191, + -0.009842767133851753, + 0.019209968175028087, + -0.036801729600496, + 0.0036358387699529284, + 0.006079481319894673, + 0.003342294121453103, + -0.2971343137386559, + -0.6703323813926846, + -0.7885217073102587, + -1.0678896759007668, + 0.5527301613931733 + ], + "sparsity_curve": [ + [ + 0.0, + 21, + 0.9035051679446613 + ], + [ + 0.001, + 20, + 0.9035028044287374 + ], + [ + 0.002, + 20, + 0.9035028044287374 + ], + [ + 0.005, + 16, + 0.9017891237504362 + ], + [ + 0.01, + 9, + 0.8985847902462778 + ], + [ + 0.015, + 8, + 0.8957625139671737 + ], + [ + 0.02, + 8, + 0.8957625139671737 + ], + [ + 0.03, + 8, + 0.8957625139671737 + ], + [ + 0.05, + 3, + 0.8536388609680176 + ], + [ + 0.1, + 3, + 0.8536388609680176 + ] + ] + }, + "top": { + "results": [ + { + "threshold": 0.0, + "nz": 22, + "r2": 0.979810012198325, + "mae": 0.009405479490894075 + }, + { + "threshold": 0.001, + "nz": 20, + "r2": 0.9797408009698567, + "mae": 0.009464924709826631 + }, + { + "threshold": 0.002, + "nz": 19, + "r2": 0.9796029724451923, + "mae": 0.009503048001896178 + }, + { + "threshold": 0.005, + "nz": 6, + "r2": 0.9752589669369754, + "mae": 0.009083428201966606 + }, + { + "threshold": 0.01, + "nz": 5, + "r2": 0.9685659511773673, + "mae": 0.01027038394304665 + }, + { + "threshold": 0.015, + "nz": 2, + "r2": 0.9602153300731789, + "mae": 0.012152564325363832 + }, + { + "threshold": 0.02, + "nz": 2, + "r2": 0.9602153300731789, + "mae": 0.012152564325363832 + }, + { + "threshold": 0.03, + "nz": 2, + "r2": 0.9602153300731789, + "mae": 0.012152564325363832 + }, + { + "threshold": 0.05, + "nz": 1, + "r2": 0.9262664550660489, + "mae": 0.013270022046144844 + }, + { + "threshold": 0.1, + "nz": 0, + "r2": -0.2926478447353347, + "mae": 0.10377883511859723 + } + ], + "best": { + "threshold": 0.0, + "nz": 22, + "r2": 0.979810012198325, + "mae": 0.009405479490894075 + }, + "best_coef": [ + 1.7326565320927485, + -0.021510750075876994, + 0.0007162636287450304, + 0.003690011764588065, + -0.001255780396435901, + 0.008069876064011276, + -0.027165643005235728, + 0.019732103606457083, + -0.007023240411992188, + -0.00413143280625655, + 0.0016423995129504244, + 0.0008079534031552331, + -0.005050487340309005, + 0.011582526033145867, + 0.0010455718346845003, + 0.0012590379370475096, + 0.0019097817325384912, + 0.0346531306245988, + 0.03581318435812541, + -0.06278853147020641, + 0.40349380438892807, + -0.3511614171629877 + ], + "sparsity_curve": [ + [ + 0.0, + 22, + 0.979810012198325 + ], + [ + 0.001, + 20, + 0.9797408009698567 + ], + [ + 0.002, + 19, + 0.9796029724451923 + ], + [ + 0.005, + 6, + 0.9752589669369754 + ], + [ + 0.01, + 5, + 0.9685659511773673 + ], + [ + 0.015, + 2, + 0.9602153300731789 + ], + [ + 0.02, + 2, + 0.9602153300731789 + ], + [ + 0.03, + 2, + 0.9602153300731789 + ], + [ + 0.05, + 1, + 0.9262664550660489 + ], + [ + 0.1, + 0, + -0.2926478447353347 + ] + ] + }, + "bottom": { + "results": [ + { + "threshold": 0.0, + "nz": 22, + "r2": 0.9334422885170565, + "mae": 0.0062053698726041735 + }, + { + "threshold": 0.001, + "nz": 15, + "r2": 0.9318911728011019, + "mae": 0.00612106433470266 + }, + { + "threshold": 0.002, + "nz": 11, + "r2": 0.9294742132351927, + "mae": 0.006263040564511156 + }, + { + "threshold": 0.005, + "nz": 7, + "r2": 0.8963211646824344, + "mae": 0.006779203944819282 + }, + { + "threshold": 0.01, + "nz": 5, + "r2": 0.8721441125489685, + "mae": 0.007291293800356046 + }, + { + "threshold": 0.015, + "nz": 1, + "r2": 0.6935730348615337, + "mae": 0.009771975563999018 + }, + { + "threshold": 0.02, + "nz": 1, + "r2": 0.6935730348615337, + "mae": 0.009771975563999018 + }, + { + "threshold": 0.03, + "nz": 1, + "r2": 3.300804074513053e-12, + "mae": 0.029445380091027484 + }, + { + "threshold": 0.05, + "nz": 1, + "r2": 3.300804074513053e-12, + "mae": 0.029445380091027484 + }, + { + "threshold": 0.1, + "nz": 0, + "r2": -5.4349952892154265, + "mae": 0.0796928089615461 + } + ], + "best": { + "threshold": 0.0, + "nz": 22, + "r2": 0.9334422885170565, + "mae": 0.0062053698726041735 + }, + "best_coef": [ + 0.6365299396095444, + -0.00680014903595817, + -0.0014763845224131282, + 0.0008331648138976364, + -0.0013963333722837737, + 0.0026865900945637236, + -0.014425589645304323, + -0.008472370593784707, + -6.004332296000475e-05, + -0.0014926889561804521, + -0.0004258139447154254, + -0.0018644416734531306, + 0.006364757842372448, + -0.003199874412701356, + 0.001370932025212518, + 0.002115447057480394, + 0.0019992859663057654, + 0.012730598783987558, + -0.07381922521108229, + -0.069816526655473, + 0.13432950141437106, + -0.0030019765287261236 + ], + "sparsity_curve": [ + [ + 0.0, + 22, + 0.9334422885170565 + ], + [ + 0.001, + 15, + 0.9318911728011019 + ], + [ + 0.002, + 11, + 0.9294742132351927 + ], + [ + 0.005, + 7, + 0.8963211646824344 + ], + [ + 0.01, + 5, + 0.8721441125489685 + ], + [ + 0.015, + 1, + 0.6935730348615337 + ], + [ + 0.02, + 1, + 0.6935730348615337 + ], + [ + 0.03, + 1, + 3.300804074513053e-12 + ], + [ + 0.05, + 1, + 3.300804074513053e-12 + ], + [ + 0.1, + 0, + -5.4349952892154265 + ] + ] + } + }, + "vortex_taylor": { + "scene": "vortex_taylor", + "re_code": 100, + "mu": 0.02, + "n_samples": 148, + "feature_names_front": [ + "u_m", + "u_a", + "u_c", + "v_a", + "Cd_tot", + "Cd_rear", + "Cl_tot", + "Cl_diff", + "sin_ua", + "cos_ua", + "aF_lag1", + "aB_lag1", + "aT_lag1", + "daF", + "daB", + "daT", + "mu", + "mu_u_a", + "mu_v_a", + "mu_Cd_tot", + "mu_Cl_diff" + ], + "feature_names_rear": [ + "bias", + "u_m", + "u_a", + "u_c", + "v_a", + "Cd_tot", + "Cd_rear", + "Cl_tot", + "Cl_diff", + "sin_ua", + "cos_ua", + "aF_lag1", + "aB_lag1", + "aT_lag1", + "daF", + "daB", + "daT", + "mu", + "mu_u_a", + "mu_v_a", + "mu_Cd_tot", + "mu_Cl_diff" + ], + "front": { + "results": [ + { + "threshold": 0.0, + "nz": 21, + "r2": 0.9603622630700551, + "mae": 5.5722956333111334e-05 + }, + { + "threshold": 0.001, + "nz": 0, + "r2": -1762.7026716770156, + "mae": 0.019129430850011273 + }, + { + "threshold": 0.002, + "nz": 0, + "r2": -1762.7026716770156, + "mae": 0.019129430850011273 + }, + { + "threshold": 0.005, + "nz": 0, + "r2": -1762.7026716770156, + "mae": 0.019129430850011273 + }, + { + "threshold": 0.01, + "nz": 0, + "r2": -1762.7026716770156, + "mae": 0.019129430850011273 + }, + { + "threshold": 0.015, + "nz": 0, + "r2": -1762.7026716770156, + "mae": 0.019129430850011273 + }, + { + "threshold": 0.02, + "nz": 0, + "r2": -1762.7026716770156, + "mae": 0.019129430850011273 + }, + { + "threshold": 0.03, + "nz": 0, + "r2": -1762.7026716770156, + "mae": 0.019129430850011273 + }, + { + "threshold": 0.05, + "nz": 0, + "r2": -1762.7026716770156, + "mae": 0.019129430850011273 + }, + { + "threshold": 0.1, + "nz": 0, + "r2": -1762.7026716770156, + "mae": 0.019129430850011273 + } + ], + "best": { + "threshold": 0.0, + "nz": 21, + "r2": 0.9603622630700551, + "mae": 5.5722956333111334e-05 + }, + "best_coef": [ + 0.00013185241334928363, + 0.0013869486093058166, + -2.0689454061431898e-05, + 0.0003650495654675631, + -7.646260510377518e-05, + 0.0016834710794748663, + 0.002138870811614627, + 0.0009672545588015969, + -0.0011958663539923654, + -0.00015017143903531285, + 0.007403929744140764, + -0.0015484862269750206, + -0.0003240821985992223, + -0.0010978795962499714, + -0.0002774795287867582, + -0.00041159658606479107, + 0.00011190538695352297, + 0.06934742736821846, + 0.018252511507361284, + -0.0038231311262574906, + 0.04836265570630274 + ], + "sparsity_curve": [ + [ + 0.0, + 21, + 0.9603622630700551 + ], + [ + 0.001, + 0, + -1762.7026716770156 + ], + [ + 0.002, + 0, + -1762.7026716770156 + ], + [ + 0.005, + 0, + -1762.7026716770156 + ], + [ + 0.01, + 0, + -1762.7026716770156 + ], + [ + 0.015, + 0, + -1762.7026716770156 + ], + [ + 0.02, + 0, + -1762.7026716770156 + ], + [ + 0.03, + 0, + -1762.7026716770156 + ], + [ + 0.05, + 0, + -1762.7026716770156 + ], + [ + 0.1, + 0, + -1762.7026716770156 + ] + ] + }, + "top": { + "results": [ + { + "threshold": 0.0, + "nz": 22, + "r2": 0.809824114603052, + "mae": 0.00019280734797529785 + }, + { + "threshold": 0.001, + "nz": 1, + "r2": 4.909409545561516e-09, + "mae": 0.0004627442799109909 + }, + { + "threshold": 0.002, + "nz": 1, + "r2": 4.909409545561516e-09, + "mae": 0.0004627442799109909 + }, + { + "threshold": 0.005, + "nz": 0, + "r2": -3813.3981416722418, + "mae": 0.06224470555379584 + }, + { + "threshold": 0.01, + "nz": 0, + "r2": -3813.3981416722418, + "mae": 0.06224470555379584 + }, + { + "threshold": 0.015, + "nz": 0, + "r2": -3813.3981416722418, + "mae": 0.06224470555379584 + }, + { + "threshold": 0.02, + "nz": 0, + "r2": -3813.3981416722418, + "mae": 0.06224470555379584 + }, + { + "threshold": 0.03, + "nz": 0, + "r2": -3813.3981416722418, + "mae": 0.06224470555379584 + }, + { + "threshold": 0.05, + "nz": 0, + "r2": -3813.3981416722418, + "mae": 0.06224470555379584 + }, + { + "threshold": 0.1, + "nz": 0, + "r2": -3813.3981416722418, + "mae": 0.06224470555379584 + } + ], + "best": { + "threshold": 0.0, + "nz": 22, + "r2": 0.809824114603052, + "mae": 0.00019280734797529785 + }, + "best_coef": [ + -0.003855648809544881, + -0.00016402917649747364, + -0.00930469925984968, + 0.0003725286141146794, + -0.0018143741612594137, + -0.0006228672933056743, + -0.004123657830294623, + 0.009617379353839065, + -0.0020693580870050163, + 0.0064472830467429834, + -0.0009363904689749538, + 0.002848747579355727, + 0.008718629599353982, + 0.006611618826853361, + 0.0026442625498493562, + -0.000822158875902874, + -0.00019526846815097482, + -7.71129751901901e-05, + -0.4652349584088788, + -0.09071890000133181, + -0.031143453311218216, + -0.10346632929440941 + ], + "sparsity_curve": [ + [ + 0.0, + 22, + 0.809824114603052 + ], + [ + 0.001, + 1, + 4.909409545561516e-09 + ], + [ + 0.002, + 1, + 4.909409545561516e-09 + ], + [ + 0.005, + 0, + -3813.3981416722418 + ], + [ + 0.01, + 0, + -3813.3981416722418 + ], + [ + 0.015, + 0, + -3813.3981416722418 + ], + [ + 0.02, + 0, + -3813.3981416722418 + ], + [ + 0.03, + 0, + -3813.3981416722418 + ], + [ + 0.05, + 0, + -3813.3981416722418 + ], + [ + 0.1, + 0, + -3813.3981416722418 + ] + ] + }, + "bottom": { + "results": [ + { + "threshold": 0.0, + "nz": 22, + "r2": 0.6431303693566448, + "mae": 0.00012115305583366485 + }, + { + "threshold": 0.001, + "nz": 1, + "r2": 2.5346330034814457e-08, + "mae": 0.00029202565622359403 + }, + { + "threshold": 0.002, + "nz": 1, + "r2": 2.5346330034814457e-08, + "mae": 0.00029202565622359403 + }, + { + "threshold": 0.005, + "nz": 1, + "r2": 2.5346330034814457e-08, + "mae": 0.00029202565622359403 + }, + { + "threshold": 0.01, + "nz": 1, + "r2": 2.5346330034814457e-08, + "mae": 0.00029202565622359403 + }, + { + "threshold": 0.015, + "nz": 1, + "r2": 2.5346330034814457e-08, + "mae": 0.00029202565622359403 + }, + { + "threshold": 0.02, + "nz": 1, + "r2": 2.5346330034814457e-08, + "mae": 0.00029202565622359403 + }, + { + "threshold": 0.03, + "nz": 0, + "r2": -11389.175969107222, + "mae": 0.05019249195686063 + }, + { + "threshold": 0.05, + "nz": 0, + "r2": -11389.175969107222, + "mae": 0.05019249195686063 + }, + { + "threshold": 0.1, + "nz": 0, + "r2": -11389.175969107222, + "mae": 0.05019249195686063 + } + ], + "best": { + "threshold": 0.0, + "nz": 22, + "r2": 0.6431303693566448, + "mae": 0.00012115305583366485 + }, + "best_coef": [ + 0.024327554221764875, + -0.0005249278989068122, + -0.0012993577286068104, + 2.2680966036791235e-07, + -0.00010725143675978186, + -6.83864821520398e-05, + 0.0010096871646746155, + 0.010716542986185212, + 0.0009908500661735154, + 0.0011374615793514392, + 0.0001758268322788275, + -0.0008997304730805852, + -0.0002053415257431248, + 0.0003868365622255378, + 0.005272105474899691, + 0.0033010584355117824, + 0.0027208004849191268, + 0.0004865510823514911, + -0.06496786884052097, + -0.005364174292120229, + -0.003419346453559459, + 0.04954205911186436 + ], + "sparsity_curve": [ + [ + 0.0, + 22, + 0.6431303693566448 + ], + [ + 0.001, + 1, + 2.5346330034814457e-08 + ], + [ + 0.002, + 1, + 2.5346330034814457e-08 + ], + [ + 0.005, + 1, + 2.5346330034814457e-08 + ], + [ + 0.01, + 1, + 2.5346330034814457e-08 + ], + [ + 0.015, + 1, + 2.5346330034814457e-08 + ], + [ + 0.02, + 1, + 2.5346330034814457e-08 + ], + [ + 0.03, + 0, + -11389.175969107222 + ], + [ + 0.05, + 0, + -11389.175969107222 + ], + [ + 0.1, + 0, + -11389.175969107222 + ] + ] + } + } + } +} \ No newline at end of file diff --git a/src/SR_analysis/sindy_sr_knoeledge.md b/src/SR_analysis/sindy_sr_knoeledge.md new file mode 100644 index 0000000..496a101 --- /dev/null +++ b/src/SR_analysis/sindy_sr_knoeledge.md @@ -0,0 +1,339 @@ +# SINDy 与 SR 背景知识 + +## 文档作用 + +这份文档只负责一件事:**给正在工作的 coder 提供背景知识、已经确认的经验、已踩过的坑和当前结论强度。** + +它不是任务清单,不直接安排“下一步做什么”。凡是执行顺序、阶段划分、最小交付物,统一写在 `sindy_sr_notes`。这份 knowledge 只保留: + +- 已确认的技术事实 +- 历史错误与纠正 +- 结果该如何理解 +- 哪些话可以说,哪些话现在还不能说 +- 代码和实验上最容易踩的坑 + +--- + +## 一、这条线在项目里的位置 + +SINDy 与 SR 不是独立课题,而是 pinball 后处理主线中的一段工具链。项目真正要解释的是: + +\[ +\text{obs} \rightarrow \text{act} \rightarrow \text{flow structure} \rightarrow \text{signature} +\] + +SINDy 与 SR 当前只直接处理其中的 `obs -> act` 白箱化,但它们的价值在于: + +- 检验控制是否真的依赖少数物理量 +- 识别不同 cloak 场景中是否复用了同一类反馈结构 +- 为后续把控制律与 force、mean wake、observable-related structure 接起来提供接口 + +因此,任何 SINDy/SR 结果都不应脱离项目总体物理主线单独解读。 + +--- + +## 二、当前已经确认的技术事实 + +### 1. Kármán cloak 的跨 \(Re_D\) 统一骨架存在 + +这是目前最硬的一批证据之一。跨 Re 的 leave-one-Re-out,尤其 holdout_200,已经显示: + +- 用 Re50 + Re100 拟合,可高精度预测 Re200 +- 这说明统一骨架不是偶然的特征工程产物,而是真实存在于 PPO 策略中的共享结构 + +### 2. 对称性问题已经纠偏 + +最重要的 bug 是镜像变换 \(G\) 对动作的写法错误。 + +**错误版本**: + +\[ +[a_F,a_T,a_B] \mapsto [-a_F,a_B,a_T] +\] + +**正确版本**: + +\[ +[a_F,a_T,a_B] \mapsto [-a_F,-a_B,-a_T] +\] + +也就是说: + +- top / bottom 不仅交换 +- 三个动作都要变号 + +修正后,rear equivariance 误差从约 100% 降到约 10%,原来“PPO 不尊重交换对称性”的结论应正式撤回。 + +当前应保留的结构关系是: + +\[ +\alpha_F(Gx) \approx -\alpha_F(x) +\] + +\[ +\alpha_B(x) \approx -\alpha_T(Gx) +\] + +### 3. front no-bias 被数据支持 + +front 通道不需要常偏置。去掉 bias 后: + +- one-step 基本不变 +- 关键闭环也基本不变 + +因此,front odd structure 现在可以作为默认先验,而不是可选修饰。 + +### 4. rear shared-head 不是纯粹美学约束,而是有效结构 + +`bottom(x) = -top(Gx)` 的结构不是只让模型更优雅,它在闭环里确实提供了稳健性。v23 的结果说明: + +- 结构约束有助于防止 rear 两通道在闭环中各走各路 +- 它比无结构的独立 rear 拟合更适合作为解释模型 + +### 5. 无量纲化不是问题根源 + +这件事已经确认,不应再反复争论。 + +- \(u \to u/U_0\) +- \(F \to C_D, C_L\) +- \(\Omega \to \alpha\) + +这些都是可逆缩放,不会丢失信息。早期 v3 崩坏来自: + +- 错误 \(G\) +- 多项改动一次性叠加 + +而不是无量纲化本身。 + +### 6. one-step 与闭环是两回事 + +这是这条线最重要的工作方法教训之一: + +\[ +\text{one-step R² 高} \not\Rightarrow \text{闭环好} +\] + +早期 v3(old) 就是明确反例。因此: + +- one-step 只能说明局部拟合能力 +- 闭环验证是核心证据,不是附加项 + +### 7. PySR 现在可用 + +之前关于“PySR 不可用”的说法应删除。当前已知: + +- `sr_env` 下 PySR 可用 +- 后续 SR 主工具应优先考虑 PySR +- threshold Pareto 扫描仍有价值,但不能再混称为完整 SR + +--- + +## 三、哪些结论现在还不能说得太满 + +### 1. “所有 cloak 已经共享同一骨架” + +还不能这么说。当前最强证据只够支持: + +- Kármán cloak 跨 \(Re_D\) 统一骨架成立 +- steady 初步显示出明显简化版结构 + +但 all-cloak 统一骨架仍是当前主问题,不是已证结论。 + +### 2. “steady cloak 已经严格证明是 Kármán 的子模型” + +这个说法也太满。更稳的表述是: + +- 在当前 steady 数据定义下,steady 的 support 呈现出 Kármán support 的明显简化版 +- 这支持 `shared backbone + scene-specific activation` 方向 + +但 steady 当前的证据强度仍弱于 Kármán,因为 steady 不是同类 DRL 闭环策略数据。 + +### 3. “高 Re 退化已经证明是采样率问题” + +现在还不能这么写。更稳的说法是: + +- 这是一个强工作假设 +- 需要在时间尺度显式化后重新检验 + +### 4. “SR 已经做完一轮” + +如果实际做的只是 threshold 网格 + Pareto 分析,就不能写成“完整 SR 已完成”。 + +要区分: + +- `threshold Pareto scan` +- `true constrained SR` + +--- + +## 四、统一变量与约束的背景知识 + +### 1. primitive variables 应统一 + +后续所有 cloak 场景都应基于同一批 primitive variables: + +- \(\hat u, \hat v\) +- \(C_D, C_L\) +- \(\alpha\) +- lagged \(\alpha\) +- \(\Delta\alpha\) +- \(\mu = 1/Re_D\) +- scene metadata 与 \(\Delta t_c\) + +### 2. \(G\) 算子必须在 primitive level 定义 + +不要再直接对压缩特征猜符号。统一规则为: + +| 量 | 变换 | +|---|---| +| \((u_B,u_C,u_T)\) | \((u_T,u_C,u_B)\) | +| \((v_B,v_C,v_T)\) | \((-v_T,-v_C,-v_B)\) | +| \((C_{D,F},C_{D,T},C_{D,B})\) | \((C_{D,F},C_{D,B},C_{D,T})\) | +| \((C_{L,F},C_{L,T},C_{L,B})\) | \((-C_{L,F},-C_{L,B},-C_{L,T})\) | +| \((\alpha_F,\alpha_T,\alpha_B)\) | \((-\alpha_F,-\alpha_B,-\alpha_T)\) | +| lag / increment | 同动作规则 | +| \(\mu\) | 不变 | + +并且: + +\[ +G(G(x)) = x +\] + +必须作为基本测试。 + +### 3. 默认结构约束 + +当前最稳的默认结构是: + +- front no-bias +- front odd structure +- rear shared-head + +即: + +\[ +\alpha_T(x)=g_R(x), +\qquad +\alpha_B(x)=-g_R(Gx) +\] + +不再把三通道完全独立当默认。 + +--- + +## 五、SINDy 与 SR 的正确分工 + +### SINDy 负责什么 + +SINDy 的主要价值是: + +- 在受限物理库上识别主项 +- 给出 support 证据 +- 支持跨场景比较 +- 给 SR 提供 whitelist + +SINDy 是骨架识别器,不是最终公式生成器。 + +### SR 负责什么 + +SR 的价值不止是压短公式。它还可以: + +- 吸收若干看似分散的 SINDy 项 +- 暴露不同场景是否存在同形公式 +- 给出比 threshold scan 更强的闭式线索 + +因此 SR 应该在受限物理库上做,而不是在 raw feature 上自由乱搜。 + +--- + +## 六、时间尺度问题的背景知识 + +### 1. 当前公式混有采样周期信息 + +lagged action 和 \(\Delta a\) 都隐式绑定了 control interval。也就是说,当前公式里混着: + +- 物理骨架 +- 离散实现方式 +- 采样周期 + +因此时间尺度问题是结构问题,不是附带工程问题。 + +### 2. 控制频率测试的严格原则 + +不能把在 800-step cadence 下拟合出的系数,直接拿到 400-step 或 200-step cadence 下执行,并把结果当正式证据。因为此时: + +- 输入分布变了 +- memory 项的物理意义变了 +- \(\Delta a\) 的尺度也变了 + +因此,如果要比较不同 control interval,必须: + +1. 在目标 \(\Delta t_c\) 下重新采集特征 +2. 重新拟合模型 +3. 再比较 support、系数结构与闭环 + +之前的频率扫描结果最多只能当线索,不能当结论。 + +--- + +## 七、steady 结果应该怎样理解 + +当前 steady 的结果有启发性,但需要克制解释。 + +可以说: + +- steady front 全零很合理 +- steady rear 比 Kármán 更简单 +- steady 当前 support 呈现出 Kármán 的明显简化版 + +不宜说: + +- steady 已经严格证明是 Kármán 的子模型 +- steady 与 Kármán 现在证据强度相同 + +因为 steady 当前的数据来源与 Kármán 不完全对等。 + +--- + +## 八、代码与工程层面的已知经验 + +### 1. 环境分工 + +- `pycuda_3_10`:CFD、DRL 模型加载、数据采集、SINDy +- `sr_env`:PySR 与 SR 相关工作 + +### 2. 常见坑 + +- feature names 与矩阵列顺序不一致 +- JSON 保存时未统一处理 numpy 类型 +- 不同脚本用不同 channel 命名规则 +- 没有统一 validator,导致 \(G\) 与闭环输入错位难以及早发现 + +### 3. 推荐工程习惯 + +- 任何场景都先过 validator,再进拟合 +- separate fit 的结果按场景 × 方法存储 +- support、公式、闭环三类结果必须一起保存 + +--- + +## 九、当前最值得牢记的判断 + +这条线现在最稳的总结是: + +\[ +\boxed{ +\text{Kármán cloak 的跨 }Re_D\text{ 统一骨架已确认,且满足明确的镜像等变结构;v23 是当前最可信的解释模型。} +} +\] + +同时必须保留另一句: + +\[ +\boxed{ +\text{all-cloak 的最终 shared backbone 还没有定论;steady、单涡、时间尺度显式化与真正的受限 SR 仍在探索中。} +} +\] + +这两句话一起保留,能避免后续工作再次滑向“把局部结果写成全局结论”。 \ No newline at end of file diff --git a/src/SR_analysis/sindy_sr_notes.md b/src/SR_analysis/sindy_sr_notes.md new file mode 100644 index 0000000..f64dd37 --- /dev/null +++ b/src/SR_analysis/sindy_sr_notes.md @@ -0,0 +1,347 @@ +# SINDy 与 SR 执行计划 + +## 文档作用 + +这份文档只回答一件事:**接下来要做什么。** + +- 只写执行路线、阶段目标、优先级、输出要求 +- 不长篇复述历史争论 +- 不把背景知识和任务顺序混在一起 +- 凡是历史 bug、经验教训、哪些结论已经成立、哪些还不能说,统一放到 `sindy_sr_knowladge` + +--- + +## 当前总目标 + +把 **所有 cloak 场景** 纳入同一受约束分析框架,使用 **SINDy + SR 并行** 探索控制骨架,先分别拟合,再横向比较,从而判断: + +- 哪些项是 shared core +- 哪些项是 scene-specific activation +- 哪些差异来自时间尺度写法,而不是物理骨架本身 + +当前默认路线不是“先强行做统一总公式”,而是: + +\[ +\text{separate fit} \rightarrow \text{compare} \rightarrow \text{shared-backbone test} +\] + +--- + +## 当前工作原则 + +后续默认遵守: + +1. **所有场景共用同一套 primitive variables 与同一套 \(G\) 规则** +2. **front 默认 no-bias + odd structure** +3. **rear 默认 shared-head** +4. **SINDy 与 SR 并行推进**,SR 不是替换 SINDy,而是并行工具 +5. **先分场景拟合,再做横向比较** +6. **闭环验证不可省略**,但不要求每个场景第一轮都做全套闭环 +7. **时间尺度问题必须显式进入特征定义** +8. **不再只围绕跨 Re 的 Kármán 单线深挖**;跨 Re 现在是已站住的第一证据,不是全部主线 +9. **当前不讨论功率与能量分析**,这不是这条线的优先任务 +10. **当前不把时延作为主要矛盾**;对稳定周期控制,当前第一矛盾是变量骨架与时间尺度写法 + +--- + +## 当前场景优先级 + +### 第一层:本轮重点场景 + +- Kármán cloak +- steady cloak + +这两个场景本轮必须做完整输出,因为它们最适合先建立场景间比较模板。 + +### 第二层:下一轮扩展场景 + +- 单涡 cloak(monopole / taylor / lamb 等已有单涡场景) +- erase +- 其他已有 cloak 场景 + +这批先做轻量版 separate fit,再决定哪些值得补完整闭环与深挖。 + +--- + +## 阶段 0 + +## 统一接口 + +### 0.1 统一 primitive variables + +所有场景统一输出: + +- 无量纲 sensor:\(\hat u, \hat v\) +- 力系数:\(C_D, C_L\) +- 无量纲动作:\(\alpha\) +- lagged \(\alpha\) +- \(\Delta \alpha\) 或显式含 \(\Delta t_c\) 的版本 +- \(\mu = 1/Re_D\) +- scene metadata:scene id、\(Re_D\)、control interval \(\Delta t_c\)、target type、采样设置 + +### 0.2 固定 \(G\) 算子 + +统一使用同一套 \(G\) 规则,不允许不同场景临时改写。动作必须满足: + +\[ +(\alpha_F,\alpha_T,\alpha_B) \mapsto (-\alpha_F,-\alpha_B,-\alpha_T) +\] + +### 0.3 统一 feature builder + +统一生成三层特征: + +| 层级 | 内容 | 用途 | +|---|---|---| +| core | \(\hat u, \hat v, C_D, C_L, \alpha^-, \Delta\alpha^-, \mu\) | 所有场景共用 | +| derived | 对称/反对称组合、总量/差量 | SINDy 主库 | +| time-scale | 显式含 \(\Delta t_c\) 的版本 | 时间尺度分析 | + +### 0.4 必做测试 + +任何场景进入拟合前,先过: + +- \(G(G(x)) = x\) +- feature names 与矩阵列顺序一致 +- 闭环预测器输入维度一致 +- front / rear 的结构约束在数据接口层正确落地 + +### 阶段 0 输出 + +- 统一 `feature_builder` +- 统一 `symmetry` +- 统一 `time_scale` +- 统一 `validators` +- 场景级最小数据摘要(样本数、变量范围、control interval) + +--- + +## 阶段 1 + +## Kármán 与 steady 的第一轮 separate SINDy + +### 目标 + +在统一变量与统一约束下,先得到两个重点场景各自最可信的稀疏 support。 + +### 默认约束 + +- front no-bias +- front odd structure +- rear shared-head +- correct \(G\) consistency + +### 每个场景必须输出 + +| 输出 | 说明 | +|---|---| +| best support | 最优 support 列表 | +| sparsity curve | 稀疏度-误差曲线 | +| front / rear 主项表 | 主导项与系数 | +| one-step metrics | R²、RMSE | +| key closed-loop result | 至少一个关键闭环指标 | +| support stability | threshold / window / bootstrap 稳定性 | +| contribution table | 主要项贡献度,不只看是否出现 | + +### 本阶段比较项 + +Kármán 与 steady 做第一轮 support overlap: + +- 哪些项共同出现 +- 哪些项只在 Kármán 激活 +- steady 是否表现为明显简化版 +- overlap 不能只看布尔出现,还要看贡献量级与稳定性 + +--- + +## 阶段 2 + +## Kármán 与 steady 的第一轮受限 SR + +### 目标 + +不是追求最终公式,而是看: + +- 在受限物理库上,是否能压出更短闭式 +- SINDy 中看似不同的项,是否会被更统一的表达吸收 +- Kármán 与 steady 是否出现同形公式 + +### SR 输入规则 + +SR 只能使用: + +- 阶段 0 的统一变量 +- 阶段 1 的 SINDy 已筛出主项及其邻近项 +- 受限运算集合 + +### 当前允许的运算 + +- 加减乘 +- protected divide +- 少量 square +- 必要时有限放开 tanh + +### 当前不允许的运算 + +- raw trig 乱搜 +- 高次幂 +- 指数 +- 深层嵌套 + +### 每个场景必须输出 + +| 输出 | 说明 | +|---|---| +| shortest acceptable formula | 最短可接受公式 | +| complexity-error pareto | 复杂度 vs 误差 | +| 和 SINDy 的关系 | 压缩了什么、保留了什么、吸收了什么 | +| key closed-loop result | 最佳 SR 公式至少一版关键闭环 | +| formula family notes | 同一场景内是否存在多种同等可接受闭式 | + +--- + +## 阶段 3 + +## 横向比较 + +当 Kármán 与 steady 的 SINDy + SR 都出来后,立即做横向比较。 + +### 3.1 support 比较 + +不要只看“是否出现”,必须同时看: + +- 是否出现 +- 系数或贡献量级 +- 稳定性 +- SR 是否把它吸收到更高层表达里 + +### 3.2 公式形态比较 + +至少检查: + +- 是否都包含同类 force feedback 核心 +- 是否都包含同类 memory 核心 +- steady 是否只是删掉了周期相关项 +- 是否存在同形结构 + 少量场景激活项 + +### 3.3 第一轮 shared-backbone 判断 + +这一轮只回答: + +1. 是否存在 Kármán 与 steady 的 shared core +2. steady 是否可以视为 Kármán 的明显简化版 +3. 哪些项更像 scene-specific activation,而不是 backbone 本身 + +本阶段不急着拟合 all-cloak 联合总公式。 + +--- + +## 阶段 4 + +## 扩展到单涡与其他 cloak + +在 Kármán 与 steady 的第一轮比较完成后,再把单涡 cloak 与其他场景纳入。 + +### 轻量版输出要求 + +- separate SINDy +- separate SR +- one-step metrics +- support 形态 +- 必要时补关键闭环 + +### 比较目标 + +重点看: + +- 单涡是否保留 shared core +- 单涡是否主要新增 history / transient 项 +- 是否开始出现子家族结构 + +--- + +## 阶段 5 + +## 时间尺度显式化 + +这条线并行推进,但先不抢在全部场景前面。 + +### 当前目标 + +- 让 \(\Delta t_c\) 显式进入特征 +- 不再把“1 个采样步”默认当物理可比量 +- 比较显式化前后,support 是否更收敛 +- 为后面严肃讨论采样率影响扫清接口问题 + +### 第一批测试场景 + +- Kármán cloak +- steady cloak + +### 第一批对比内容 + +- 旧的 discrete lag / \(\Delta a\) +- 显式含 \(\Delta t_c\) 的版本 + +### 关注结果 + +- support 是否变化 +- SR 公式是否更统一 +- 跨场景比较是否更干净 + +注意:当前阶段的目标是**时间尺度显式化**,不是立刻给出高采样率优于 PPO 的最终结论。 + +--- + +## 本轮必须完成的最小任务 + +1. 统一 Kármán 与 steady 的 feature builder +2. 统一 Kármán 与 steady 的 \(G\) / time-scale / validators +3. 跑 Kármán 与 steady 的第一轮 separate SINDy +4. 跑 Kármán 与 steady 的第一轮受限 SR(PySR) +5. 输出 Kármán vs steady 的: + - support overlap + - 公式形态比较 + - shared core / scene-specific 的初步分类 + +--- + +## 本轮结束时应交付的结果包 + +每个重点场景至少有一张 summary 表: + +| method | sparsity | one-step | closed-loop | key terms | notes | +|---|---:|---:|---:|---|---| +| SINDy | | | | | | +| SR | | | | | | + +以及一个跨场景比较表: + +| comparison | shared core | scene-enhanced | scene-specific | notes | +|---|---|---|---|---| +| Kármán vs steady | | | | | + +--- + +## 当前不该做的事 + +- 不继续围绕单一 Kármán across Re 版本号升级 +- 不把 threshold 网格或简单 Pareto 扫描直接当成完整 SR +- 不在 raw feature 上做自由 SR +- 不在不同采样间隔下直接复用旧系数并据此下正式结论 +- 不在场景比较证据还弱时,提前宣布 all-cloak 统一总公式 +- 不把功率、能量、时延这些非当前主矛盾问题拉入本轮 SINDy/SR 主线 + +--- + +## 当前这份 notes 的直接收束 + +接下来核心任务不是“继续优化某个跨 Re 模型”,而是: + +\[ +\boxed{ +\text{把 Kármán 与 steady 先做成可比较的 separate SINDy + separate SR 结果包,然后以它们为模板扩到单涡与其他 cloak。} +} +\] + +这一步完成后,才进入更严肃的 all-cloak shared-backbone 判断。 \ No newline at end of file diff --git a/src/SR_analysis/utils/__init__.py b/src/SR_analysis/utils/__init__.py new file mode 100644 index 0000000..dc9391e --- /dev/null +++ b/src/SR_analysis/utils/__init__.py @@ -0,0 +1,9 @@ +from .feature_builder import ( + compute_dimensionless, compute_features, build_feature_matrix, + get_feature_names, apply_G_alpha, apply_G_x, + CORE_FEAT_KEYS, MU_FEAT_KEYS, ALL_FEAT_KEYS, +) +from .sindy_fitter import ( + fit_channel, fit_sindy, print_control_law, + get_active_support, get_feature_matrix_from_data, +) diff --git a/src/SR_analysis/utils/cfd_interface.py b/src/SR_analysis/utils/cfd_interface.py new file mode 100644 index 0000000..5c68c6c --- /dev/null +++ b/src/SR_analysis/utils/cfd_interface.py @@ -0,0 +1,397 @@ +"""CFD interface for LegacyCelerisLab (pycuda_3_10 env). + +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 SR_analysis.utils +because it requires pycuda. Other utils (sindy_fitter, feature_builder, g_operator) +do NOT require pycuda and can be imported from the __init__. +""" +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 + +# -- Import legacy CFD ------------------------------------------------------- +# LegacyCelerisLab lives at the repo root; SR_analysis is at repo_root/src/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 LegacyCelerisLab import utils as legacy_utils # noqa: E402 + +# --------------------------------------------------------------------------- +# Action-smoothing constant (legacy run() internal) +# --------------------------------------------------------------------------- +ACTION_SMOOTH_WEIGHT = 0.1 # used by FlowField.run() internally + + +def nu_from_re(re_code: float, u0: float = 0.01, d_ref: float = 40.0) -> float: + """Return kinematic viscosity for a given code Reynolds number. + + ``re_code`` uses reference length *2*D* = 40.0 (matching model file naming). + """ + return u0 * d_ref / re_code + + +def load_legacy_configs(config_dir: str) -> Tuple[Any, Any]: + """Load and return 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 + + +# --------------------------------------------------------------------------- +# Environment helpers -- Karman cloak (disturbance cylinder + pinball) +# --------------------------------------------------------------------------- + +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]: + """Phase 0-1: 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 : ndarray (FIFO_LEN, 6) -- sensor0/1/2 ux,uy + info : dict with n_objects, NX, NY + """ + # dist cylinder + center = (10.0 * l0, (flow_field.FIELD_SHAPE[1] - 1) / 2, 0.0) + flow_field.add_cylinder(center, l0) + + # sensors + for y_off in [2.0, 0.0, -2.0]: + sc = (40.0 * l0, (flow_field.FIELD_SHAPE[1] - 1) / 2 + y_off * l0, 0.0) + flow_field.add_sensor(sc, l0 / 4.0) + + n_obj = flow_field.obs.size // 2 + + # stabilize + 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)) + + # record target (only sensor signals = obs[2:8]) + 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)) + new_state = flow_field.obs.copy()[2:8] + target_states = np.vstack((target_states, new_state)) + + 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]} + + +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. + + Steps: + 1. add front, bottom, top cylinders + 2. stabilize run(4*NX/U0, zero-action) + 3. get_ddf() + save_ddf() (checkpoint) + 4. FIFO_LEN x run(SAMPLE_INTERVAL, zero) -> compute norm + 5. apply_ddf() (restore pre-bias state) + 6. FIFO_LEN x run(SAMPLE_INTERVAL, bias-action) -> save_states + 7. apply_ddf() + + Parameters + ---------- + pinball_front_x, pinball_rear_x : pinball geometry (L0 units). + Default 30.0/31.3 for Karman; 19.0/20.3 for Illusion. + obs_slice_start, obs_slice_end : slice of obs for norm. + Default [2:14] for Karman (7 objects); [0:12] for Illusion (6 objects). + n_objects_total : if provided, used for bias array length. + Default: inferred from flow_field after adding cylinders. + + Returns dict with norm values. + """ + if action_bias is None: + action_bias = (0.0, -4.0, 4.0) + + u0_float = float(u0) + + # add 3 pinball cylinders + 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 + 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)) + + # checkpoint DDF + 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 indices [6:12] relative to the slice end + 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}") + print(f" norm: sens_deviation={sens_deviation}") + print(f" norm: sens_norm_fact={sens_norm_fact}") + + # --- 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) + print(f" bias action: {bias}") + + 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, + } + + +def build_observation( + obs_slice: np.ndarray, + norm: dict, +) -> np.ndarray: + """Assemble normalised DRL observation (12-dim) from a single 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 action_to_physical( + action_norm: np.ndarray, + *, + scale: float = 8.0, + bias: Tuple[float, float, float] = (0.0, -4.0, 4.0), + u0: float = 0.01, +) -> np.ndarray: + """Convert normalized action [-1,1] to physical omega (lattice units). + + physical_omega[i] = (action_norm[i] * scale + bias[i]) * u0 + """ + action_norm = np.asarray(action_norm, dtype=np.float64).reshape(-1, 3) + bias_arr = np.array(bias, dtype=np.float64) + return (action_norm * scale + bias_arr) * u0 + + +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 vorticity_from_ddf(flow_field: FlowField, u0: float) -> np.ndarray: + """Compute z-vorticity from current DDF on host.""" + flow_field.get_ddf() + ddf = flow_field.ddf.copy().reshape((9, flow_field.FIELD_SHAPE[1], + flow_field.FIELD_SHAPE[0])).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 + 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: + """Find lag that maximises cross-correlation between two 1-D signals.""" + 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: + """DTW-based similarity: 1 - (DTW distance / len(target)).""" + 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: + """Compute 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) + + +# --------------------------------------------------------------------------- +# Dummy env for loading SB3 models +# --------------------------------------------------------------------------- + +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 diff --git a/src/analysis_cloak/common/feature_builder.py b/src/SR_analysis/utils/feature_builder.py similarity index 86% rename from src/analysis_cloak/common/feature_builder.py rename to src/SR_analysis/utils/feature_builder.py index 8023b73..3aaef70 100644 --- a/src/analysis_cloak/common/feature_builder.py +++ b/src/SR_analysis/utils/feature_builder.py @@ -1,11 +1,13 @@ """Unified feature builder for all cloak scenes. Produces dimensionless features with consistent G-equivariant structure. -All scenes (Karman, steady, vortex, erase) use this same builder. +All scenes (Karman, steady, vortex, illusion) use this same builder. + +Copy of analysis_cloak/common/feature_builder.py -- kept as canonical source. """ from __future__ import annotations -from typing import Dict, List, Optional, Tuple +from typing import Dict, List, Tuple import numpy as np @@ -108,6 +110,7 @@ def compute_features( mu: float, alpha_mode: bool = False, # if True, actions_prev are already nondim alpha include_mu: bool = True, + u0: float = U0, # inlet velocity for omega->alpha conversion ) -> Dict[str, np.ndarray]: """Compute unified feature dictionary from dimensionless primitives. @@ -118,6 +121,7 @@ def compute_features( mu: 1/Re_D alpha_mode: if True, actions are already nondim; else convert include_mu: include mu modulation terms + u0: inlet velocity (lattice), used only when alpha_mode=False Returns dict with all features as (T,) or (T,3) arrays. """ @@ -132,8 +136,8 @@ def compute_features( a = actions_prev.astype(np.float64) a2 = actions_prev2.astype(np.float64) else: - a = actions_prev.astype(np.float64) / U0 - a2 = actions_prev2.astype(np.float64) / U0 + a = actions_prev.astype(np.float64) / u0 + a2 = actions_prev2.astype(np.float64) / u0 sym = {} @@ -185,7 +189,7 @@ def build_feature_matrix( if k in sym: cols.append(np.asarray(sym[k], dtype=np.float64)) else: - # Missing key (e.g. mu terms when include_mu=False) -> zero + # Missing key -> zero T = sym.get("u_m", np.ones(1)).shape[0] cols.append(np.zeros(T, dtype=np.float64)) return np.column_stack(cols) @@ -198,17 +202,3 @@ def get_feature_names(feat_keys: List[str], add_bias: bool = True) -> List[str]: names.append("bias") names.extend(feat_keys) return names - - -# -- Scene metadata ---------------------------------------------------------- - -def get_scene_metadata(scene: str) -> dict: - """Return default metadata for a cloak scene.""" - meta = { - "karman": {"scene_id": "karman", "control_interval": 800, "target_type": "periodic"}, - "steady": {"scene_id": "steady", "control_interval": 800, "target_type": "steady"}, - "vortex_lamb": {"scene_id": "vortex_lamb", "control_interval": 800, "target_type": "transient"}, - "vortex_taylor": {"scene_id": "vortex_taylor", "control_interval": 800, "target_type": "transient"}, - "erase": {"scene_id": "erase", "control_interval": 600, "target_type": "periodic"}, - } - return meta.get(scene, {"scene_id": scene, "control_interval": 800, "target_type": "unknown"}) diff --git a/src/SR_analysis/utils/g_operator.py b/src/SR_analysis/utils/g_operator.py new file mode 100644 index 0000000..0a2f956 --- /dev/null +++ b/src/SR_analysis/utils/g_operator.py @@ -0,0 +1,191 @@ +"""G-operator and equivariance tools. + +Provides G-operator transformations, dimensionless conversion, +and equivariance diagnostics for PPO control laws. +""" +from __future__ import annotations + +from typing import Any, Dict, Optional, Tuple + +import numpy as np + +from .feature_builder import compute_dimensionless as _compute_dimless + + +def apply_G_alpha(alpha: np.ndarray) -> np.ndarray: + """Apply mirror G to action: [aF, aT, aB] -> [-aF, -aB, -aT].""" + return np.array([-alpha[0], -alpha[2], -alpha[1]], dtype=alpha.dtype) + + +def apply_G_raw(obs_slice: np.ndarray, + a_prev: np.ndarray, + a_prev2: np.ndarray) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: + """Apply G to raw obs slice [sensor(6)+force(6)] and action arrays. + + Parameters + ---------- + obs_slice : (12,) raw obs [s0_ux,uy, s1_ux,uy, s2_ux,uy, f0_fx,fy, f1_fx,fy, f2_fx,fy] + a_prev : (3,) physical omega at t-1 + a_prev2 : (3,) physical omega at t-2 + + Returns + ------- + G_obs : (12,) transformed obs slice + G_a_prev : (3,) transformed a_prev + G_a_prev2 : (3,) transformed a_prev2 + """ + G_obs = np.zeros(12, dtype=np.float64) + # sensors: swap top(0,1) <-> bottom(4,5), negate v + G_obs[0] = obs_slice[4] + G_obs[1] = -obs_slice[5] + G_obs[2] = obs_slice[2] + G_obs[3] = -obs_slice[3] + G_obs[4] = obs_slice[0] + G_obs[5] = -obs_slice[1] + # forces: swap bottom(2,3) <-> top(4,5), negate fy + G_obs[6] = obs_slice[6] + G_obs[7] = -obs_slice[7] + G_obs[8] = obs_slice[10] + G_obs[9] = -obs_slice[11] + G_obs[10] = obs_slice[8] + G_obs[11] = -obs_slice[9] + + G_a_prev = np.array([-a_prev[0], -a_prev[2], -a_prev[1]], dtype=np.float64) + G_a_prev2 = np.array([-a_prev2[0], -a_prev2[2], -a_prev2[1]], dtype=np.float64) + return G_obs, G_a_prev, G_a_prev2 + + +def check_equivariance( + model: Any, + obs_slice_series: np.ndarray, # (T, 12) raw obs + actions_phys: np.ndarray, # (T, 3) physical omega + norm: dict, + action_scale: float = 8.0, + action_bias: Tuple[float, float, float] = (0.0, -4.0, 4.0), + u0: float = 0.01, +) -> Dict[str, float]: + """Check G-equivariance of a PPO model over a time series. + + Returns dict with front/rear equivariance errors. + """ + from .cfd_interface import build_observation, action_to_physical + + T = min(obs_slice_series.shape[0], actions_phys.shape[0]) + ef, eb, et = [], [], [] + + for t in range(2, T): + # Get current obs + osl = obs_slice_series[t] + a_prev = actions_phys[t - 1] if t > 0 else actions_phys[0] + a_prev2 = actions_phys[t - 2] if t > 1 else actions_phys[0] + + # Predict action for current state + obs = build_observation(osl, norm) + act, _ = model.predict(obs, deterministic=True) + act = act.astype(np.float32).flatten() + alpha = action_to_physical(act.reshape(1, 3), + scale=action_scale, bias=action_bias, u0=u0).flatten() + + # Apply G to state + G_obs, _, _ = apply_G_raw(osl, a_prev, a_prev2) + obs_G = build_observation(G_obs, norm) + act_G, _ = model.predict(obs_G, deterministic=True) + act_G = act_G.astype(np.float32).flatten() + alpha_G = action_to_physical(act_G.reshape(1, 3), + scale=action_scale, bias=action_bias, u0=u0).flatten() + + # Expected: G(alpha) = [-aF, -aB, -aT] + expected = apply_G_alpha(alpha) + + ef.append(abs(float(alpha_G[0]) - float(expected[0]))) + eb.append(abs(float(alpha_G[1]) - float(expected[1]))) + et.append(abs(float(alpha_G[2]) - float(expected[2]))) + + ef_arr = np.array(ef) + eb_arr = np.array(eb) + et_arr = np.array(et) + alpha_range = float(np.max(np.abs(actions_phys[2:]))) + + return { + "front_mean_abs_error": float(np.mean(ef_arr)), + "front_rel_error": float(np.mean(ef_arr) / (alpha_range + 1e-12)), + "rear_bottom_rel_error": float(np.mean(eb_arr) / (alpha_range + 1e-12)), + "rear_top_rel_error": float(np.mean(et_arr) / (alpha_range + 1e-12)), + "alpha_range": alpha_range, + } + + +def diagnose_one_re(model, ff, target_states, norm, config, n_steps=150) -> dict: + """Run PPO inference and check equivariance. + + Parameters + ---------- + model : loaded PPO model + ff : FlowField instance (must be at saved checkpoint state) + target_states : (FIFO_LEN, 6) target sensor signals + norm : norm dict + config : scene config dict with action_scale, action_bias, u0, etc. + + Returns + ------- + dict with equivariance metrics. + """ + from collections import deque + from .cfd_interface import (build_observation, scale_action, + action_to_physical, compute_similarity) + + action_scale = config.get("action_scale", 8.0) + action_bias = config.get("action_bias", (0.0, -4.0, 4.0)) + u0 = config.get("u0", 0.01) + sample_interval = config.get("sample_interval", 800) + fifo_len = config.get("fifo_len", 150) + n_obj_total = config.get("n_objects_total", 7) + + ff.restore_ddf() + ff.apply_ddf() + + # Bias FIFO init + fifo = deque(maxlen=fifo_len) + bias_arr = 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.run(sample_interval, bias_arr) + fifo.append(ff.obs.copy()[2:14]) + + # Inference + obs_array = [] + action_array = [] + obs = np.zeros(12, dtype=np.float32) + + for _ in range(n_steps): + act, _ = model.predict(obs, deterministic=True) + act = act.astype(np.float32).flatten() + action_array.append(act.copy()) + + action_arr = scale_action(act, 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) + obs_array.append(obs_slice) + obs = build_observation(obs_slice, norm) + + obs_series = np.array(obs_array, dtype=np.float64) + actions_phys = action_to_physical(np.array(action_array), + scale=action_scale, bias=action_bias, u0=u0) + states_arr = np.array(list(fifo), dtype=np.float32) + sim = compute_similarity(target_states, states_arr[:, 0:6], + config.get("conv_len", 30)) + + # Equivariance check + eq = check_equivariance(model, obs_series, actions_phys, norm, + action_scale, action_bias, u0) + + return { + "similarity": sim, + "equivariance": eq, + } diff --git a/src/SR_analysis/utils/sindy_fitter.py b/src/SR_analysis/utils/sindy_fitter.py new file mode 100644 index 0000000..6f97b39 --- /dev/null +++ b/src/SR_analysis/utils/sindy_fitter.py @@ -0,0 +1,181 @@ +"""SINDy fitting utilities: STLSQ threshold grid, feature matrix building. + +All features are built using the unified feature_builder module. +""" +from __future__ import annotations + +from typing import Dict, List, Optional, Tuple + +import numpy as np + +from .feature_builder import ( + compute_dimensionless, compute_features, build_feature_matrix, + get_feature_names, ALL_FEAT_KEYS, U0, +) + +# Default thresholds used across all scenes +DEFAULT_THRESHOLDS = [0.0, 0.001, 0.002, 0.005, 0.01, 0.015, 0.02, 0.03, 0.05, 0.1] + + +def fit_channel( + Theta: np.ndarray, + y: np.ndarray, + thresholds: Optional[List[float]] = None, + alpha: float = 1e-4, + max_iter: int = 25, +) -> Tuple[List[dict], dict]: + """Fit a single channel (one cylinder) with STLSQ threshold grid. + + Returns + ------- + rows : list of dict per threshold + best : dict with best threshold entry (highest R2) + """ + import pysindy as ps + + if thresholds is None: + thresholds = DEFAULT_THRESHOLDS + + # Normalise features for thresholding stability + std = np.std(Theta, axis=0) + std = np.where(std < 1e-8, 1.0, std) + Theta_s = Theta / std + + best = None + rows = [] + for th in thresholds: + opt = ps.STLSQ(threshold=th, alpha=alpha, max_iter=max_iter) + opt.fit(Theta_s, y) + coef = np.asarray(opt.coef_, dtype=np.float64).flatten() / std + y_pred = Theta @ coef + ssr = float(np.sum((y - y_pred) ** 2)) + sst = float(np.sum((y - np.mean(y)) ** 2) + 1e-12) + r2 = 1.0 - ssr / sst + mae = float(np.mean(np.abs(y - y_pred))) + nz = int(np.sum(np.abs(coef) > 1e-8)) + entry = {"threshold": float(th), "nz": nz, "r2": r2, "mae": mae, "coef": coef} + rows.append(entry) + if best is None or r2 > best["r2"]: + best = entry + return rows, best + + +def fit_sindy( + Theta: np.ndarray, + y: np.ndarray, + thresholds: Optional[List[float]] = None, +) -> List[dict]: + """Run SINDy with threshold grid, return results list. + + Each result dict has keys: threshold, nz, r2, mae, coef. + """ + if thresholds is None: + thresholds = DEFAULT_THRESHOLDS + + std = np.std(Theta, axis=0) + std = np.where(std < 1e-8, 1.0, std) + Theta_s = Theta / std + + results = [] + for th in thresholds: + import pysindy as ps + opt = ps.STLSQ(threshold=th, alpha=1e-4, max_iter=25) + opt.fit(Theta_s, y) + coef = np.asarray(opt.coef_, dtype=np.float64).flatten() / std + + y_pred = Theta @ coef + ssr = float(np.sum((y - y_pred) ** 2)) + sst = float(np.sum((y - np.mean(y)) ** 2) + 1e-12) + r2 = 1.0 - ssr / sst + mae = float(np.mean(np.abs(y - y_pred))) + nz = int(np.sum(np.abs(coef) > 1e-8)) + + results.append({ + "threshold": float(th), "nz": nz, "r2": r2, + "mae": mae, "coef": [float(c) for c in coef], + }) + + return results + + +def print_control_law(feature_names: List[str], coef: np.ndarray, channel_label: str = "ch"): + """Pretty-print a sparse control law.""" + terms = [] + for i, c in enumerate(coef): + if abs(c) > 1e-8: + terms.append(f"{c:.6f} * {feature_names[i]}") + print(f" {channel_label}: {' + '.join(terms)}") + nz = sum(1 for c in coef if abs(c) > 1e-8) + print(f" non-zero terms: {nz}") + + +def get_active_support( + coef: np.ndarray, + feat_names: List[str], + relative_threshold: float = 0.02, +) -> Dict[str, float]: + """Extract active features from coefficient vector. + + Features with |coef| / max(|coef|) >= relative_threshold are considered active. + """ + max_c = np.max(np.abs(coef)) + if max_c < 1e-12: + return {} + active = {} + for name, c in zip(feat_names, coef): + if abs(c) / max_c >= relative_threshold: + active[name] = float(c) + return active + + +def get_feature_matrix_from_data( + sensors: np.ndarray, # (T, 6) + forces: np.ndarray, # (T, 6) + actions_phys: np.ndarray, # (T, 3) physical omega + mu: float, + u0: float = U0, + alpha_mode: bool = False, + include_mu: bool = True, + n_warmup: int = 2, +) -> Tuple[np.ndarray, np.ndarray, np.ndarray, List[str], List[str]]: + """Build feature matrices from raw CFD data. + + Constructs dimensionless features via feature_builder, creates front (no bias) + and rear (with bias) feature matrices, and returns them aligned with Y. + + Parameters + ---------- + sensors, forces, actions_phys : raw data arrays. + mu : 1/Re_D. + u0 : inlet velocity (lattice units). + alpha_mode : if True, actions_phys are already nondim alpha. + include_mu : include mu modulation features. + n_warmup : number of warmup steps to discard (default 2 for lag/da). + + Returns + ------- + Theta_front : (T-warmup, N_front) feature matrix, NO bias column + Theta_rear : (T-warmup, N_rear) feature matrix, WITH bias column + Y : (T-warmup, 3) target action matrix + feat_names_front : list of feature names for front + feat_names_rear : list of feature names for rear + """ + T = sensors.shape[0] + a_prev = np.zeros((T, 3), dtype=np.float64) + a_prev2 = np.zeros((T, 3), dtype=np.float64) + a_prev[1:] = actions_phys[:-1] + a_prev2[2:] = actions_phys[:-2] + + dim = compute_dimensionless(sensors, forces, u0=u0, d=20.0) + sym = compute_features(dim, a_prev, a_prev2, mu, + alpha_mode=alpha_mode, include_mu=include_mu, u0=u0) + + Theta_f = build_feature_matrix(sym, ALL_FEAT_KEYS, add_bias=False) + Theta_r = build_feature_matrix(sym, ALL_FEAT_KEYS, add_bias=True) + + feat_names_front = get_feature_names(ALL_FEAT_KEYS, add_bias=False) + feat_names_rear = get_feature_names(ALL_FEAT_KEYS, add_bias=True) + + return (Theta_f[n_warmup:], Theta_r[n_warmup:], + actions_phys[n_warmup:], + feat_names_front, feat_names_rear) diff --git a/src/SR_analysis/validate/run_closed_loop.py b/src/SR_analysis/validate/run_closed_loop.py new file mode 100644 index 0000000..3b56b9f --- /dev/null +++ b/src/SR_analysis/validate/run_closed_loop.py @@ -0,0 +1,320 @@ +"""Unified closed-loop validator for SINDy control laws. + +Validates a SINDy-derived control law by running it in a closed-loop CFD +environment and measuring similarity to the target flow. + +Two modes: + - v23 (default): front no-bias + rear shared-head [bottom=-top(Gx)] + - unstructured: front with bias + rear independent + +Usage: + conda run -n pycuda_3_10 python validate/run_closed_loop.py \\ + --scene karman_re70 --device 2 \\ + --sindy-results sindy/karman/sindy_results.json +""" +from __future__ import annotations + +import argparse +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 SR_analysis.utils.cfd_interface import ( + nu_from_re, load_legacy_configs, build_karman_cloak_env, add_pinball, + scale_action, action_to_physical, compute_similarity, + load_ppo_model, +) +from SR_analysis.utils.sindy_fitter import get_feature_matrix_from_data +from SR_analysis.utils.feature_builder import ( + compute_dimensionless, compute_features, build_feature_matrix, + apply_G_x, ALL_FEAT_KEYS, U0, +) +from SR_analysis.utils.g_operator import apply_G_raw +from SR_analysis.configs import ( + get_scene, SCENES, LEGACY_CFG_DIR, FIFO_LEN, CONV_LEN, +) + +DATA_TYPE = np.float32 + + +def build_feature_vector( + obs_slice: np.ndarray, + a_prev: np.ndarray, + a_prev2: np.ndarray, + mu: float, + u0: float, + add_bias: bool, +) -> np.ndarray: + """Build a single-row feature vector from raw obs and action state. + + Matches the feature_builder logic but for a single time step. + """ + sensors = obs_slice[0:6].astype(np.float64).reshape(1, 6) + forces = obs_slice[6:12].astype(np.float64).reshape(1, 6) + ap = a_prev.astype(np.float64).reshape(1, 3) + ap2 = a_prev2.astype(np.float64).reshape(1, 3) + + dim = compute_dimensionless(sensors, forces, u0=u0, d=20.0) + sym = compute_features(dim, ap, ap2, mu, alpha_mode=False, include_mu=True, u0=u0) + + feat = build_feature_matrix(sym, ALL_FEAT_KEYS, add_bias=add_bias) + return feat[0] # single row + + +def predict_v23( + obs_slice: np.ndarray, + a_prev: np.ndarray, + a_prev2: np.ndarray, + mu: float, + u0: float, + front_coef: np.ndarray, + top_coef: np.ndarray, + feat_names_front: List[str], + feat_names_rear: List[str], +) -> np.ndarray: + """Predict actions using v23: front no-bias + rear shared-head. + + Returns (3,) physical omega array: [front, bottom, top]. + """ + # Front channel: no bias + front = float(np.dot( + build_feature_vector(obs_slice, a_prev, a_prev2, mu, u0, add_bias=False), + front_coef)) + + # Top channel: with bias + top = float(np.dot( + build_feature_vector(obs_slice, a_prev, a_prev2, mu, u0, add_bias=True), + top_coef)) + + # Bottom = -top(Gx) using shared-head + G_obs, G_a_prev, G_a_prev2 = apply_G_raw(obs_slice, a_prev, a_prev2) + bottom = -float(np.dot( + build_feature_vector(G_obs, G_a_prev, G_a_prev2, mu, u0, add_bias=True), + top_coef)) + + return np.array([front, bottom, top], dtype=np.float64) + + +def predict_unstructured( + obs_slice: np.ndarray, + a_prev: np.ndarray, + a_prev2: np.ndarray, + mu: float, + u0: float, + front_coef: np.ndarray, + bottom_coef: np.ndarray, + top_coef: np.ndarray, + feat_names: List[str], +) -> np.ndarray: + """Predict actions using unstructured: each channel independent with bias.""" + feat = build_feature_vector(obs_slice, a_prev, a_prev2, mu, u0, add_bias=True) + front = float(np.dot(feat, front_coef)) + bottom = float(np.dot(feat, bottom_coef)) + top = float(np.dot(feat, top_coef)) + return np.array([front, bottom, top], dtype=np.float64) + + +def load_sindy_coefs(sindy_path: str, scene_name: str) -> Dict[str, Any]: + """Load SINDy coefficients for a scene from results JSON. + + Returns dict with keys: front_coef, top_coef, bottom_coef, + feat_names_front, feat_names_rear, front_bias_mode. + """ + with open(sindy_path) as f: + data = json.load(f) + + per = data["per_scene"].get(scene_name) + if per is None: + raise KeyError(f"Scene {scene_name} not found in {sindy_path}") + + fn_f = per["feature_names_front"] + fn_r = per["feature_names_rear"] + + front_coef = np.array(per["front"]["best_coef"], dtype=np.float64) + top_coef = np.array(per["top"]["best_coef"], dtype=np.float64) + bottom_coef = np.array(per["bottom"]["best_coef"], dtype=np.float64) + + # Detect if front was fitted with bias (fn_f has "bias") or without + front_has_bias = fn_f[0] == "bias" if len(fn_f) > 0 else False + + return { + "front_coef": front_coef, + "top_coef": top_coef, + "bottom_coef": bottom_coef, + "feat_names_front": fn_f, + "feat_names_rear": fn_r, + "front_has_bias": front_has_bias, + } + + +def run_validation( + scene_name: str, + coefs: Dict[str, Any], + device_id: int, + n_steps: int = 100, + mode: str = "v23", +) -> dict: + """Run closed-loop validation using a SINDy control law. + + Parameters + ---------- + scene_name : e.g. "karman_re70" + coefs : dict from load_sindy_coefs() + device_id : GPU device + n_steps : number of closed-loop steps + mode : "v23" (rear shared-head) or "unstructured" + + Returns dict with similarity, actions range, etc. + """ + cfg = get_scene(scene_name) + re_code = cfg["re_code"] + nu = cfg["nu"] + u0 = cfg["u0"] + mu = cfg["mu"] + 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"] + + print(f"\n=== Validating {scene_name} (mode={mode}, device={device_id}) ===") + + # Build environment + 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) + + # Record target, add pinball, compute norm + target_states, _ = build_karman_cloak_env( + ff, u0=u0, l0=l0, sample_interval=sample_interval, + fifo_len=FIFO_LEN, data_type=DATA_TYPE, + ) + 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], + ) + + # Reset to checkpoint + ff.restore_ddf() + ff.apply_ddf() + + # Bias FIFO init + fifo = deque(maxlen=FIFO_LEN) + bias_arr = 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.run(sample_interval, bias_arr) + fifo.append(ff.obs.copy()[2:14]) + + # Closed-loop with SINDy law + sens_list = [] + actions_list = [] + a_prev = action_to_physical(np.zeros((1, 3), dtype=np.float32), + scale=action_scale, bias=action_bias, u0=u0).flatten() + a_prev2 = a_prev.copy() + + for _ in range(n_steps): + obs = fifo[-1] if fifo else np.zeros(12, dtype=np.float32) + + # Predict using SINDy law + if mode == "v23": + omega = predict_v23( + obs, a_prev, a_prev2, mu, u0, + coefs["front_coef"], coefs["top_coef"], + coefs["feat_names_front"], coefs["feat_names_rear"]) + elif mode == "unstructured": + omega = predict_unstructured( + obs, a_prev, a_prev2, mu, u0, + coefs["front_coef"], coefs["bottom_coef"], coefs["top_coef"], + coefs["feat_names_rear"]) + else: + raise ValueError(f"Unknown mode: {mode}") + + # Clip to valid action range + norm_a = (omega / u0 - np.array(action_bias, dtype=np.float64)) / action_scale + norm_a = np.clip(norm_a, -1.0, 1.0).astype(np.float32) + + # Apply to CFD + action_arr = scale_action(norm_a, scale=action_scale, bias=action_bias, + u0=u0, n_total_bodies=n_obj_total) + ff.run(sample_interval, action_arr) + + obs_new = ff.obs.copy()[2:14] + fifo.append(obs_new) + sens_list.append(obs_new[0:6]) + actions_list.append(omega.copy()) + + a_prev2 = a_prev.copy() + a_prev = omega.copy() + + # Evaluate + sens_arr = np.array(sens_list, dtype=np.float32) + actions_arr = np.array(actions_list, dtype=np.float64) + sim = compute_similarity(target_states, sens_arr, CONV_LEN) + action_range = float(np.max(np.abs(actions_arr))) + + print(f" similarity={sim:.4f} action_range={action_range:.4f}") + + del ff + + return { + "scene": scene_name, + "mode": mode, + "similarity": sim, + "action_range": action_range, + "n_steps": n_steps, + } + + +def main(): + ap = argparse.ArgumentParser(description="Closed-loop SINDy validation") + ap.add_argument("--scene", type=str, required=True, help="Scene name") + ap.add_argument("--device", type=int, default=2, help="GPU device") + ap.add_argument("--steps", type=int, default=100) + ap.add_argument("--mode", type=str, default="v23", + choices=["v23", "unstructured"]) + ap.add_argument("--sindy-results", type=str, default=None, + help="Path to sindy_results.json") + ap.add_argument("--out", type=str, default=None, + help="Output directory for result JSON") + args = ap.parse_args() + + if args.sindy_results is None: + args.sindy_results = os.path.join( + os.path.dirname(__file__), "..", "sindy", "karman", "sindy_results.json") + + coefs = load_sindy_coefs(args.sindy_results, args.scene) + result = run_validation(args.scene, coefs, args.device, + n_steps=args.steps, mode=args.mode) + + if args.out is None: + out_dir = os.path.join(os.path.dirname(__file__), "..", + "validate", "results") + else: + out_dir = args.out + os.makedirs(out_dir, exist_ok=True) + out_path = os.path.join(out_dir, f"{args.scene}_{args.mode}.json") + with open(out_path, "w") as f: + json.dump(result, f, indent=2) + print(f"Saved: {out_path}") + + +if __name__ == "__main__": + main() diff --git a/src/analysis_cloak/__init__.py b/src/analysis_cloak/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/analysis_cloak/common/__init__.py b/src/analysis_cloak/common/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/analysis_cloak/common/prebuild_features.py b/src/analysis_cloak/common/prebuild_features.py deleted file mode 100644 index ada2f99..0000000 --- a/src/analysis_cloak/common/prebuild_features.py +++ /dev/null @@ -1,81 +0,0 @@ -"""Pre-build feature matrices for SR (run in pycuda_3_10). - -Saves pickle files that run_sr_gplearn.py can load in sr_env. - -Usage: - conda run -n pycuda_3_10 python prebuild_features.py -""" -from __future__ import annotations - -import os -import pickle -import sys - -import numpy as np - -_THIS = os.path.abspath(os.path.dirname(__file__)) -_PARENT = os.path.abspath(os.path.join(_THIS, "..")) # src/analysis_cloak/ -_REPO = os.path.abspath(os.path.join(_THIS, "..", "..", "..")) -for p in [_PARENT, os.path.join(_PARENT, "..", "analysis_crossre", "scripts"), _REPO]: - if p not in sys.path: - sys.path.insert(0, p) - -from common.feature_builder import ( - compute_dimensionless, compute_features, build_feature_matrix, - get_feature_names, ALL_FEAT_KEYS, U0, -) -from utils import action_to_physical -from cfg import OUTPUT_DIR, ACTION_SCALE, ACTION_BIAS - - -def build(scene: str): - X_f_l, X_r_l, y_l = [], [], [] - if scene == "karman": - for rc in [50, 100, 200]: - npz = np.load(os.path.join(OUTPUT_DIR, f"re{rc}", "controlled.npz")) - s, f = npz["sensors"].astype(np.float64), npz["forces"].astype(np.float64) - ap = action_to_physical(npz["actions"].astype(np.float64), - scale=ACTION_SCALE, bias=ACTION_BIAS, u0=U0) - mu = 2.0 / rc - T = s.shape[0] - a1 = np.zeros((T, 3), dtype=np.float64) - a2 = np.zeros((T, 3), dtype=np.float64) - a1[1:] = ap[:-1]; a2[2:] = ap[:-2] - dim = compute_dimensionless(s, f, u0=U0, d=20.0) - sym = compute_features(dim, a1, a2, mu, alpha_mode=True, include_mu=True) - X_f_l.append(build_feature_matrix(sym, ALL_FEAT_KEYS, add_bias=False)[2:]) - X_r_l.append(build_feature_matrix(sym, ALL_FEAT_KEYS, add_bias=True)[2:]) - y_l.append(ap[2:]) - else: - npz = np.load(os.path.join(_REPO, "src", "analysis_cloak", "scenes", - "steady", "closed_loop", "steady_data.npz")) - s, f = npz["sensors"].astype(np.float64), npz["forces"].astype(np.float64) - ap = npz["actions"].astype(np.float64) - mu = 2.0 / 100 - T = s.shape[0] - a1 = np.zeros((T, 3), dtype=np.float64) - a2 = np.zeros((T, 3), dtype=np.float64) - a1[1:] = ap[:-1]; a2[2:] = ap[:-2] - dim = compute_dimensionless(s, f, u0=U0, d=20.0) - sym = compute_features(dim, a1, a2, mu, alpha_mode=True, include_mu=True) - X_f_l.append(build_feature_matrix(sym, ALL_FEAT_KEYS, add_bias=False)[2:]) - X_r_l.append(build_feature_matrix(sym, ALL_FEAT_KEYS, add_bias=True)[2:]) - y_l.append(ap[2:]) - - data = { - "X_f": np.vstack(X_f_l), - "X_r": np.vstack(X_r_l), - "y": np.vstack(y_l), - "fn_f": get_feature_names(ALL_FEAT_KEYS, add_bias=False), - "fn_r": get_feature_names(ALL_FEAT_KEYS, add_bias=True), - } - out = os.path.join(_REPO, "src", "analysis_cloak", "scenes", scene, "sr", f"{scene}_features.pkl") - os.makedirs(os.path.dirname(out), exist_ok=True) - with open(out, "wb") as f: - pickle.dump(data, f) - print(f"Saved {scene} features: X_f={data['X_f'].shape}, X_r={data['X_r'].shape}") - - -if __name__ == "__main__": - for s in ["karman", "steady"]: - build(s) diff --git a/src/analysis_cloak/common/run_sindy.py b/src/analysis_cloak/common/run_sindy.py deleted file mode 100644 index 16f1193..0000000 --- a/src/analysis_cloak/common/run_sindy.py +++ /dev/null @@ -1,253 +0,0 @@ -"""Unified SINDy fitting for all cloak scenes. - -Usage: - conda run -n pycuda_3_10 python run_sindy.py --scene karman - conda run -n pycuda_3_10 python run_sindy.py --scene 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) - -from src.analysis_cloak.common.feature_builder import ( - compute_dimensionless, - compute_features, - build_feature_matrix, - get_feature_names, - apply_G_x, - CORE_FEAT_KEYS, - MU_FEAT_KEYS, - ALL_FEAT_KEYS, - U0, - get_scene_metadata, -) -from src.analysis_crossre.scripts.cfg import OUTPUT_DIR as CROSSRE_OUTPUT -from src.analysis_crossre.scripts.utils import action_to_physical, fit_channel -from src.analysis_crossre.scripts.cfg import ACTION_SCALE, ACTION_BIAS - -THRESHOLDS = [0.0, 0.001, 0.002, 0.005, 0.01, 0.015, 0.02, 0.03, 0.05, 0.1] - - -def load_karman_data(re_code: int) -> Tuple: - """Load Karman cloak data for a single Re.""" - case_dir = os.path.join(CROSSRE_OUTPUT, f"re{re_code}") - npz_path = os.path.join(case_dir, "controlled.npz") - if not os.path.isfile(npz_path): - raise FileNotFoundError(f"Missing {npz_path}") - data = np.load(npz_path) - sensors = data["sensors"].astype(np.float64) - forces = data["forces"].astype(np.float64) - actions_norm = data["actions"].astype(np.float64) - actions_phys = action_to_physical( - actions_norm, scale=ACTION_SCALE, bias=ACTION_BIAS, u0=U0) - mu = 2.0 / re_code - return sensors, forces, actions_phys, mu - - -def load_steady_data(): - """Load steady cloak data.""" - steady_dir = os.path.join(_REPO, "src", "analysis_cloak", "scenes", "steady", "closed_loop") - npz_path = os.path.join(steady_dir, "steady_data.npz") - if not os.path.isfile(npz_path): - raise FileNotFoundError(f"Missing {npz_path}. Run gen_steady_data.py first.") - data = np.load(npz_path) - sensors = data["sensors"].astype(np.float64) - forces = data["forces"].astype(np.float64) - actions_phys = data["actions"].astype(np.float64) - mu = 2.0 / 100 # Re=100 for steady (default Re) - return sensors, forces, actions_phys, mu - - -def build_scene_data(scene: str, re_codes: List[int] = None): - """Build feature matrices for a scene. - - Returns (Theta_front, Theta_rear_input, Y, feat_names_front, feat_names_rear, scene_info) - where Theta_rear_input is the feature matrix for the rear shared-head model (top channel). - """ - all_feats_front = [] - all_feats_rear = [] - all_Y = [] - all_info = [] - - if scene == "karman": - if re_codes is None: - re_codes = [50, 100, 200] - for rc in re_codes: - sensors, forces, actions_phys, mu = load_karman_data(rc) - _append_scene_data(sensors, forces, actions_phys, mu, rc, - all_feats_front, all_feats_rear, all_Y, all_info) - elif scene == "steady": - sensors, forces, actions_phys, mu = load_steady_data() - _append_scene_data(sensors, forces, actions_phys, mu, 100, - all_feats_front, all_feats_rear, all_Y, all_info) - else: - raise ValueError(f"Unknown scene: {scene}") - - # Stack all data - Theta_front = np.vstack(all_feats_front) - Theta_rear = np.vstack(all_feats_rear) - Y = np.vstack(all_Y) - - # Feature names (front has no bias) - feat_names_front = get_feature_names(ALL_FEAT_KEYS, add_bias=False) - feat_names_rear = get_feature_names(ALL_FEAT_KEYS, add_bias=True) - - return Theta_front, Theta_rear, Y, feat_names_front, feat_names_rear, all_info - - -def _append_scene_data(sensors, forces, actions_phys, mu, re_code, - all_feats_front, all_feats_rear, all_Y, all_info): - """Helper: compute features and append to lists.""" - T = sensors.shape[0] - - # Memory terms - a_prev = np.zeros((T, 3), dtype=np.float64) - a_prev2 = np.zeros((T, 3), dtype=np.float64) - a_prev[1:] = actions_phys[:-1] - a_prev2[2:] = actions_phys[:-2] - - # Dimensionless - dim = compute_dimensionless(sensors, forces, u0=U0, d=20.0) - - # Compute features - sym = compute_features(dim, a_prev, a_prev2, mu, alpha_mode=False, include_mu=True) - - # Front model: no bias - T_eff = min(sensors.shape[0], actions_phys.shape[0]) - Theta_f = build_feature_matrix(sym, ALL_FEAT_KEYS, add_bias=False) - - # Rear model (top channel): with bias - Theta_r = build_feature_matrix(sym, ALL_FEAT_KEYS, add_bias=True) - - # Remove warmup (2 steps needed for lag/da) - all_feats_front.append(Theta_f[2:]) - all_feats_rear.append(Theta_r[2:]) - all_Y.append(actions_phys[2:]) - all_info.append({"re_code": re_code, "mu": mu, "n_samples": T - 2}) - - -def fit_sindy(Theta, y, thresholds, output_prefix=""): - """Run SINDy with threshold grid, return results.""" - import pysindy as ps - - std = np.std(Theta, axis=0) - std = np.where(std < 1e-8, 1.0, std) - Theta_s = Theta / std - - results = [] - for th in thresholds: - opt = ps.STLSQ(threshold=th, alpha=1e-4, max_iter=25) - opt.fit(Theta_s, y) - coef = np.asarray(opt.coef_, dtype=np.float64).flatten() / std - - y_pred = Theta @ coef - ssr = float(np.sum((y - y_pred) ** 2)) - sst = float(np.sum((y - np.mean(y)) ** 2) + 1e-12) - r2 = 1.0 - ssr / sst - mae = float(np.mean(np.abs(y - y_pred))) - nz = int(np.sum(np.abs(coef) > 1e-8)) - - results.append({ - "threshold": float(th), "nz": nz, "r2": r2, - "mae": mae, "coef": [float(c) for c in coef], - }) - - return results - - -def run(scene: str, out_dir: str, re_codes: List[int] = None): - """Run SINDy fitting for a scene.""" - print(f"\n{'='*60}") - print(f"Scene: {scene}") - print(f"{'='*60}") - - Theta_f, Theta_r, Y, fn_f, fn_r, info = build_scene_data(scene, re_codes) - print(f" Front features: {Theta_f.shape}") - print(f" Rear features: {Theta_r.shape}") - print(f" Y shape: {Y.shape}") - - # Rear shared-head: fit top channel (ci=2), bottom = -top(Gx) - # We fit both channels independently first for comparison - # 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}") - - # Rear shared: fit top (ci=2) - 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 (for comparison): fit independently - print(f"\n --- Bottom (independent, for comparison) ---") - 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}") - - # Save results - result = { - "scene": scene, - "re_codes": re_codes or "default", - "n_samples_front": 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], - }, - "scene_info": info, - } - - os.makedirs(out_dir, exist_ok=True) - out_path = os.path.join(out_dir, f"{scene}_sindy.json") - 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("--scene", type=str, required=True, choices=["karman", "steady", "all"]) - ap.add_argument("--out-dir", type=str, - default=os.path.join(_REPO, "src", "analysis_cloak", "scenes")) - ap.add_argument("--re-codes", type=str, default=None, - help="Comma-separated Re codes for Karman (default: 50,100,200)") - args = ap.parse_args() - - re_codes = [int(r) for r in args.re_codes.split(",")] if args.re_codes else None - scenes = ["karman", "steady"] if args.scene == "all" else [args.scene] - - for scene in scenes: - sindy_dir = os.path.join(args.out_dir, scene, "sindy") - run(scene, sindy_dir, re_codes) - - -if __name__ == "__main__": - main() diff --git a/src/analysis_cloak/common/run_sr_pareto.py b/src/analysis_cloak/common/run_sr_pareto.py deleted file mode 100644 index 7898362..0000000 --- a/src/analysis_cloak/common/run_sr_pareto.py +++ /dev/null @@ -1,84 +0,0 @@ -"""Pareto analysis of SINDy threshold grid. -No external SR library. Runs in any env. - -Usage: python run_sr_pareto.py --scene karman -""" -from __future__ import annotations - -import argparse -import json -import os -import sys -from typing import List, Tuple - -import numpy as np - -_THIS = os.path.abspath(os.path.dirname(__file__)) -_REPO = os.path.abspath(os.path.join(_THIS, "..", "..", "..")) - - -def load_sindy(scene: str): - path = os.path.join(_REPO, "src", "analysis_cloak", "scenes", scene, "sindy", f"{scene}_sindy.json") - with open(path) as f: - return json.load(f) - - -def pareto(points): - 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, coef, th): - 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 >= th - 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, fn, ch): - grid = ch["results"] - pts = [(g["nz"], 1.0 - g["r2"]) for g in grid] - front = pareto(pts) - best = ch["best"] - coef = ch["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: - print(f" {fmt(fn, coef, th)[: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", required=True, choices=["karman", "steady"]) - args = ap.parse_args() - s = load_sindy(args.scene) - print(f"Pareto SR: {args.scene}") - chs = [("front", s["feature_names_front"], s["front"]), - ("top", s["feature_names_rear"], s["top"]), - ("bottom", s["feature_names_rear"], s["bottom"])] - results = {"scene": args.scene, "channels": [analyze(*c) for c in chs]} - out = os.path.join(_REPO, "src", "analysis_cloak", "scenes", args.scene, "sr", - f"{args.scene}_sr_pareto.json") - os.makedirs(os.path.dirname(out), exist_ok=True) - with open(out, "w") as f: - json.dump(results, f, indent=2) - print(f"Saved: {out}") - - -if __name__ == "__main__": - main() diff --git a/src/analysis_cloak/comparisons/__init__.py b/src/analysis_cloak/comparisons/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/analysis_cloak/comparisons/support_overlap/__init__.py b/src/analysis_cloak/comparisons/support_overlap/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/analysis_cloak/comparisons/support_overlap/run_overlap.py b/src/analysis_cloak/comparisons/support_overlap/run_overlap.py deleted file mode 100644 index 97c8e86..0000000 --- a/src/analysis_cloak/comparisons/support_overlap/run_overlap.py +++ /dev/null @@ -1,84 +0,0 @@ -"""Support Overlap Analysis: Karman vs Steady. - -Compares SINDy support sets at a given sparsity threshold. -""" -from __future__ import annotations - -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) - -THRESHOLD = 0.002 - - -def load_sindy(scene: str) -> dict: - path = os.path.join(_REPO, "src", "analysis_cloak", "scenes", scene, "sindy", f"{scene}_sindy.json") - with open(path) as f: - return json.load(f) - - -def get_active(feat_names: List[str], coef: List[float], threshold: float) -> Dict[str, float]: - scale = max(abs(c) for c in coef) if coef and max(abs(c) for c in coef) > 1e-12 else 1.0 - active = {} - for name, c in zip(feat_names, coef): - if abs(c) / scale >= threshold: - active[name] = c - return active - - -def classify(k_active: Dict, s_active: Dict) -> Tuple[List, List, List]: - k_set = set(k_active.keys()) - s_set = set(s_active.keys()) - return sorted(k_set & s_set), sorted(k_set - s_set), sorted(s_set - k_set) - - -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 in ("Cd_tot", "Cd_rear", "Cl_tot", "Cl_diff"): 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 main(): - print("=" * 60) - print(f"Support Overlap: Karman vs Steady (th={THRESHOLD})") - print("=" * 60) - - k = load_sindy("karman") - s = load_sindy("steady") - - channels = [ - ("Front", "feature_names_front", "front"), - ("Top (shared)", "feature_names_rear", "top"), - ("Bottom", "feature_names_rear", "bottom"), - ] - - for label, fn_key, ch_key in channels: - print(f"\n--- {label} ---") - fn_k, fn_s = k[fn_key], s[fn_key] - ka = get_active(fn_k, k[ch_key]["best_coef"], THRESHOLD) - sa = get_active(fn_s, s[ch_key]["best_coef"], THRESHOLD) - shared, ko, so = classify(ka, sa) - - print(f" Karman nz={len(ka)} Steady nz={len(sa)} Shared={len(shared)}") - for name in shared: - g = feat_group(name) - print(f" {name:20s} K={ka[name]:+9.6f} S={sa[name]:+9.6f} [{g}]") - for name in ko: - print(f" K-ONLY {name:20s} K={ka[name]:+9.6f} [{feat_group(name)}]") - for name in so: - print(f" S-ONLY {name:20s} S={sa[name]:+9.6f} [{feat_group(name)}]") - - -if __name__ == "__main__": - main() diff --git a/src/analysis_cloak/scenes/karman/closed_loop/re200_freq_test.json b/src/analysis_cloak/scenes/karman/closed_loop/re200_freq_test.json deleted file mode 100644 index 4661bcb..0000000 --- a/src/analysis_cloak/scenes/karman/closed_loop/re200_freq_test.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "si800": 0.7044314206319138, - "si400": 0.7877798695065495, - "si200": 0.6587465172737008 -} \ No newline at end of file diff --git a/src/analysis_cloak/scenes/karman/closed_loop/test_re200_freq.py b/src/analysis_cloak/scenes/karman/closed_loop/test_re200_freq.py deleted file mode 100644 index 3804512..0000000 --- a/src/analysis_cloak/scenes/karman/closed_loop/test_re200_freq.py +++ /dev/null @@ -1,182 +0,0 @@ -"""Re200 control frequency experiment. - -Tests if higher control frequency improves Re200 performance. -Uses v23 predictor (v2 coeffs + front bias=0 + rear shared-head). - -Usage: - conda run -n pycuda_3_10 python test_re200_freq.py --device 2 -""" -import argparse -import json -import os -import sys -from collections import deque - -import numpy as np - -_PROJ = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..", "..", "..")) -if _PROJ not in sys.path: - sys.path.insert(0, _PROJ) -from LegacyCelerisLab import FlowField - -from src.analysis_crossre.scripts.utils import ( - nu_from_re, action_to_physical, scale_action, build_karman_cloak_env, - add_pinball, compute_physical_symbols, save_vorticity_png, - vorticity_from_ddf, compute_similarity, load_legacy_configs, -) -from src.analysis_crossre.scripts.cfg import ( - OUTPUT_DIR, FIFO_LEN, CONV_LEN, S_DIM, ACTION_SCALE, ACTION_BIAS, U0, CONFIG_DIR, -) - -DATA_TYPE = np.float32 -RE_CODE = 200 -V2_RESULTS = os.path.join(OUTPUT_DIR, "sindy", "sindy_results_v2.json") - -# Feature keys matching v2 layout -V2_FEAT_KEYS = [ - "u_m", "u_a", "u_c", "v_a", - "Fx_tot", "Fx_rear", "Fy_tot", "Fy_diff", - "sin_ua", "cos_ua", - "a0_lag1", "a1_lag1", "a2_lag1", - "da0", "da1", "da2", -] -V2_MU_KEYS = ["mu", "mu_u_a", "mu_v_a", "mu_Fx_tot", "mu_Fy_diff", "mu_Fy_tot"] - - -def apply_G_raw(obs_slice, a_prev, a_prev2): - G_obs = np.zeros(12, dtype=np.float64) - G_obs[0] = obs_slice[4] - G_obs[1] = -obs_slice[5] - G_obs[2] = obs_slice[2] - G_obs[3] = -obs_slice[3] - G_obs[4] = obs_slice[0] - G_obs[5] = -obs_slice[1] - G_obs[6] = obs_slice[6] - G_obs[7] = -obs_slice[7] - G_obs[8] = obs_slice[10] - G_obs[9] = -obs_slice[11] - G_obs[10] = obs_slice[8] - G_obs[11] = -obs_slice[9] - G_a_prev = np.array([-a_prev[0], -a_prev[2], -a_prev[1]], dtype=np.float64) - G_a_prev2 = np.array([-a_prev2[0], -a_prev2[2], -a_prev2[1]], dtype=np.float64) - return G_obs, G_a_prev, G_a_prev2 - - -def build_feat_vec(obs_slice, a_prev, a_prev2, mu, add_bias): - sensors = obs_slice[0:6].astype(np.float64).reshape(1, 6) - forces = obs_slice[6:12].astype(np.float64).reshape(1, 6) - ap = a_prev.astype(np.float64).reshape(1, 3) - ap2 = a_prev2.astype(np.float64).reshape(1, 3) - sym = compute_physical_symbols(sensors, forces, ap, ap2) - sym["mu"] = np.array([mu]) - sym["mu_u_a"] = sym["u_a"] * mu - sym["mu_v_a"] = sym["v_a"] * mu - sym["mu_Fx_tot"] = sym["Fx_tot"] * mu - sym["mu_Fy_diff"] = sym["Fy_diff"] * mu - sym["mu_Fy_tot"] = sym["Fy_tot"] * mu - vals = [] - if add_bias: - vals.append(1.0) - for k in V2_FEAT_KEYS: - vals.append(float(sym[k][0])) - for k in V2_MU_KEYS: - vals.append(float(sym[k][0])) - return np.array(vals, dtype=np.float64) - - -def load_coefs(path): - with open(path) as f: - data = json.load(f) - coefs_list = data["cross_re"]["channels"] - coefs_list[0]["best_coef"][0] = 0.0 # zero front bias - return { - "front": {"coef": np.array(coefs_list[0]["best_coef"], dtype=np.float64), "has_bias": True}, - "top": {"coef": np.array(coefs_list[2]["best_coef"], dtype=np.float64), "has_bias": True}, - } - - -def predict(obs_slice, a_prev, a_prev2, coefs, mu): - feat = build_feat_vec(obs_slice, a_prev, a_prev2, mu, add_bias=True) - front = float(feat @ coefs["front"]["coef"]) - top = float(feat @ coefs["top"]["coef"]) - G_obs, G_a_prev, G_a_prev2 = apply_G_raw(obs_slice, a_prev, a_prev2) - feat_G = build_feat_vec(G_obs, G_a_prev, G_a_prev2, mu, add_bias=True) - bottom = -float(feat_G @ coefs["top"]["coef"]) - return np.array([front, bottom, top], dtype=np.float64) - - -def run(re_code, sample_interval, device_id, output_dir): - mu = 2.0 / re_code - label = f"Re{re_code}_si{sample_interval}" - os.makedirs(output_dir, exist_ok=True) - - coefs = load_coefs(V2_RESULTS) - cuda_cfg, field_cfg = load_legacy_configs(CONFIG_DIR) - field_cfg = field_cfg._replace(viscosity=float(nu_from_re(re_code, u0=U0))) - ff = FlowField(field_cfg, cuda_cfg, device_id=device_id) - - target_states, _ = build_karman_cloak_env(ff, u0=U0, l0=20.0, - sample_interval=sample_interval, fifo_len=FIFO_LEN, data_type=DATA_TYPE) - norm = add_pinball(ff, l0=20.0, u0=U0, sample_interval=sample_interval, - fifo_len=FIFO_LEN, data_type=DATA_TYPE, action_bias=ACTION_BIAS) - - ff.restore_ddf(); - ff.apply_ddf() - 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=7) - for _ in range(FIFO_LEN): - ff.run(sample_interval, bias_action) - fifo.append(ff.obs.copy()[2:14]) - - sens_sc = [] - a_prev = action_to_physical(np.zeros((1,3), dtype=np.float32), - scale=ACTION_SCALE, bias=ACTION_BIAS, u0=U0).flatten() - a_prev2 = a_prev.copy() - n_steps = 100 - - for _ in range(n_steps): - obs = fifo[-1] if len(fifo) > 0 else np.zeros(12, dtype=np.float32) - omega = predict(obs, a_prev, a_prev2, coefs, mu) - norm_a = (omega / U0 - np.array(ACTION_BIAS, dtype=np.float64)) / ACTION_SCALE - norm_a = np.clip(norm_a, -1.0, 1.0).astype(np.float32) - action_arr = scale_action(norm_a, scale=ACTION_SCALE, bias=ACTION_BIAS, u0=U0, n_total_bodies=7) - ff.run(sample_interval, action_arr) - obs_new = ff.obs.copy()[2:14] - fifo.append(obs_new) - sens_sc.append(obs_new[0:6]) - a_prev2 = a_prev.copy() - a_prev = omega.copy() - - sens_arr = np.array(sens_sc, dtype=np.float32) - sim = compute_similarity(target_states, sens_arr, CONV_LEN) - print(f" {label}: similarity={sim:.4f}") - - del ff - return sim - - -def main(): - ap = argparse.ArgumentParser() - ap.add_argument("--device", type=int, default=2) - args = ap.parse_args() - - out_dir = os.path.join(_PROJ, "src", "analysis_cloak", "scenes", "karman", "closed_loop") - os.makedirs(out_dir, exist_ok=True) - - intervals = [800, 400, 200] - results = {} - for si in intervals: - sim = run(RE_CODE, si, args.device, out_dir) - results[f"si{si}"] = sim - - print(f"\nRe200 frequency test results:") - for si, sim in results.items(): - print(f" SAMPLE_INTERVAL={si}: similarity={sim:.4f}") - - with open(os.path.join(out_dir, "re200_freq_test.json"), "w") as f: - json.dump(results, f, indent=2) - - -if __name__ == "__main__": - main() diff --git a/src/analysis_cloak/scenes/karman/sindy/karman_sindy.json b/src/analysis_cloak/scenes/karman/sindy/karman_sindy.json deleted file mode 100644 index 6c163a8..0000000 --- a/src/analysis_cloak/scenes/karman/sindy/karman_sindy.json +++ /dev/null @@ -1,508 +0,0 @@ -{ - "scene": "karman", - "re_codes": "default", - "n_samples_front": 444, - "n_features_front": 21, - "n_features_rear": 22, - "feature_names_front": [ - "u_m", - "u_a", - "u_c", - "v_a", - "Cd_tot", - "Cd_rear", - "Cl_tot", - "Cl_diff", - "sin_ua", - "cos_ua", - "aF_lag1", - "aB_lag1", - "aT_lag1", - "daF", - "daB", - "daT", - "mu", - "mu_u_a", - "mu_v_a", - "mu_Cd_tot", - "mu_Cl_diff" - ], - "feature_names_rear": [ - "bias", - "u_m", - "u_a", - "u_c", - "v_a", - "Cd_tot", - "Cd_rear", - "Cl_tot", - "Cl_diff", - "sin_ua", - "cos_ua", - "aF_lag1", - "aB_lag1", - "aT_lag1", - "daF", - "daB", - "daT", - "mu", - "mu_u_a", - "mu_v_a", - "mu_Cd_tot", - "mu_Cl_diff" - ], - "front": { - "results": [ - { - "threshold": 0.0, - "nz": 21, - "r2": 0.9654966682363757, - "mae": 0.0023056666760656796 - }, - { - "threshold": 0.001, - "nz": 5, - "r2": 0.9536016197206115, - "mae": 0.002601817745080991 - }, - { - "threshold": 0.002, - "nz": 2, - "r2": 0.9452383376955145, - "mae": 0.00274020474447065 - }, - { - "threshold": 0.005, - "nz": 2, - "r2": 0.9452383376955145, - "mae": 0.00274020474447065 - }, - { - "threshold": 0.01, - "nz": 1, - "r2": 0.860607207574008, - "mae": 0.005171636101202507 - }, - { - "threshold": 0.015, - "nz": 0, - "r2": -0.025600612306448944, - "mae": 0.01601045471490235 - }, - { - "threshold": 0.02, - "nz": 0, - "r2": -0.025600612306448944, - "mae": 0.01601045471490235 - }, - { - "threshold": 0.03, - "nz": 0, - "r2": -0.025600612306448944, - "mae": 0.01601045471490235 - }, - { - "threshold": 0.05, - "nz": 0, - "r2": -0.025600612306448944, - "mae": 0.01601045471490235 - }, - { - "threshold": 0.1, - "nz": 0, - "r2": -0.025600612306448944, - "mae": 0.01601045471490235 - } - ], - "best": { - "threshold": 0.0, - "nz": 21, - "r2": 0.9654966682363757, - "mae": 0.0023056666760656796 - }, - "best_coef": [ - -5.192955717959975e-05, - 2.8758845410456916e-05, - -1.1774957671598127e-05, - -3.25172225327354e-05, - 0.0015201286832842583, - 0.0003656613747736437, - -0.0007681231453227206, - 0.0003090074931666537, - 1.2582646790399684e-07, - 0.0002743780546501911, - 0.007096991322989564, - -0.001425750496064129, - 0.0006433749857293151, - 0.008791801878378852, - 0.0028160283947971055, - -0.0005095282162885374, - -0.16160246819614302, - 0.0007252560276568708, - -0.002241307134772526, - -0.021184890828600162, - -0.021767971799948535 - ], - "sparsity_curve": [ - [ - 0.0, - 21, - 0.9654966682363757 - ], - [ - 0.001, - 5, - 0.9536016197206115 - ], - [ - 0.002, - 2, - 0.9452383376955145 - ], - [ - 0.005, - 2, - 0.9452383376955145 - ], - [ - 0.01, - 1, - 0.860607207574008 - ], - [ - 0.015, - 0, - -0.025600612306448944 - ], - [ - 0.02, - 0, - -0.025600612306448944 - ], - [ - 0.03, - 0, - -0.025600612306448944 - ], - [ - 0.05, - 0, - -0.025600612306448944 - ], - [ - 0.1, - 0, - -0.025600612306448944 - ] - ] - }, - "top": { - "results": [ - { - "threshold": 0.0, - "nz": 22, - "r2": 0.862596324781397, - "mae": 0.00633210084008296 - }, - { - "threshold": 0.001, - "nz": 19, - "r2": 0.8613618595140851, - "mae": 0.006368110213095571 - }, - { - "threshold": 0.002, - "nz": 14, - "r2": 0.8493750057676241, - "mae": 0.006730015193486602 - }, - { - "threshold": 0.005, - "nz": 7, - "r2": 0.7827214013687178, - "mae": 0.00802796108799867 - }, - { - "threshold": 0.01, - "nz": 1, - "r2": 0.6121198810123357, - "mae": 0.009530874364852419 - }, - { - "threshold": 0.015, - "nz": 1, - "r2": 0.6121198810123357, - "mae": 0.009530874364852419 - }, - { - "threshold": 0.02, - "nz": 1, - "r2": 3.654743174763553e-12, - "mae": 0.0173810294687437 - }, - { - "threshold": 0.03, - "nz": 1, - "r2": 3.654743174763553e-12, - "mae": 0.0173810294687437 - }, - { - "threshold": 0.05, - "nz": 0, - "r2": -2.2359943125676613, - "mae": 0.039575029794999335 - }, - { - "threshold": 0.1, - "nz": 0, - "r2": -2.2359943125676613, - "mae": 0.039575029794999335 - } - ], - "best": { - "threshold": 0.0, - "nz": 22, - "r2": 0.862596324781397, - "mae": 0.00633210084008296 - }, - "best_coef": [ - -0.05500806404528806, - 0.0007420012135347702, - -0.0005289839138368902, - -7.059719059238487e-05, - 0.0006778963140888031, - -0.001096838965444243, - -0.0006586342747236175, - 0.0034452827223076256, - -0.0015323585728228434, - -0.0006424234277769932, - -0.0007755764497972763, - 0.005024202346778029, - 0.0034193717761402237, - 0.00640250610361064, - -0.005852277491977138, - -0.003193207096374315, - 0.0032222577962603585, - -0.35037148676340113, - 0.023157523040313266, - -0.04322885440841332, - -0.050453986820807865, - -0.051262980383116864 - ], - "sparsity_curve": [ - [ - 0.0, - 22, - 0.862596324781397 - ], - [ - 0.001, - 19, - 0.8613618595140851 - ], - [ - 0.002, - 14, - 0.8493750057676241 - ], - [ - 0.005, - 7, - 0.7827214013687178 - ], - [ - 0.01, - 1, - 0.6121198810123357 - ], - [ - 0.015, - 1, - 0.6121198810123357 - ], - [ - 0.02, - 1, - 3.654743174763553e-12 - ], - [ - 0.03, - 1, - 3.654743174763553e-12 - ], - [ - 0.05, - 0, - -2.2359943125676613 - ], - [ - 0.1, - 0, - -2.2359943125676613 - ] - ] - }, - "bottom": { - "results": [ - { - "threshold": 0.0, - "nz": 22, - "r2": 0.8993440589380792, - "mae": 0.004870578129840853 - }, - { - "threshold": 0.001, - "nz": 10, - "r2": 0.8959720170147872, - "mae": 0.004940764682366889 - }, - { - "threshold": 0.002, - "nz": 8, - "r2": 0.8924474832818312, - "mae": 0.004917054661924924 - }, - { - "threshold": 0.005, - "nz": 5, - "r2": 0.8580026242147389, - "mae": 0.0059864202818160055 - }, - { - "threshold": 0.01, - "nz": 1, - "r2": 0.7104836293852221, - "mae": 0.008387153194050426 - }, - { - "threshold": 0.015, - "nz": 1, - "r2": 0.7104836293852221, - "mae": 0.008387153194050426 - }, - { - "threshold": 0.02, - "nz": 1, - "r2": 0.7104836293852221, - "mae": 0.008387153194050426 - }, - { - "threshold": 0.03, - "nz": 0, - "r2": -2.8272225894084078, - "mae": 0.038829327024821496 - }, - { - "threshold": 0.05, - "nz": 0, - "r2": -2.8272225894084078, - "mae": 0.038829327024821496 - }, - { - "threshold": 0.1, - "nz": 0, - "r2": -2.8272225894084078, - "mae": 0.038829327024821496 - } - ], - "best": { - "threshold": 0.0, - "nz": 22, - "r2": 0.8993440589380792, - "mae": 0.004870578129840853 - }, - "best_coef": [ - -0.010075042034336805, - 0.000127456261090308, - -0.00018640871260677575, - -1.0786662376718973e-05, - 0.00013209953181097446, - -0.0029808562650467663, - -0.0003882575275073206, - 0.0021351638754212236, - -0.0005317242032502552, - -1.4526187037145631e-05, - -0.0004792609214251783, - 0.0035849480411721288, - 0.009840224256264653, - -0.0008193716182580219, - -0.005477208879386436, - 0.00248443011612523, - -0.001596550946452102, - 0.10495659563156284, - 0.0036966535058393694, - -0.006256407760146263, - 0.05741274816660643, - 0.028241944772701748 - ], - "sparsity_curve": [ - [ - 0.0, - 22, - 0.8993440589380792 - ], - [ - 0.001, - 10, - 0.8959720170147872 - ], - [ - 0.002, - 8, - 0.8924474832818312 - ], - [ - 0.005, - 5, - 0.8580026242147389 - ], - [ - 0.01, - 1, - 0.7104836293852221 - ], - [ - 0.015, - 1, - 0.7104836293852221 - ], - [ - 0.02, - 1, - 0.7104836293852221 - ], - [ - 0.03, - 0, - -2.8272225894084078 - ], - [ - 0.05, - 0, - -2.8272225894084078 - ], - [ - 0.1, - 0, - -2.8272225894084078 - ] - ] - }, - "scene_info": [ - { - "re_code": 50, - "mu": 0.04, - "n_samples": 148 - }, - { - "re_code": 100, - "mu": 0.02, - "n_samples": 148 - }, - { - "re_code": 200, - "mu": 0.01, - "n_samples": 148 - } - ] -} \ No newline at end of file diff --git a/src/analysis_cloak/scenes/karman/sr/karman_sr_pareto.json b/src/analysis_cloak/scenes/karman/sr/karman_sr_pareto.json deleted file mode 100644 index c283263..0000000 --- a/src/analysis_cloak/scenes/karman/sr/karman_sr_pareto.json +++ /dev/null @@ -1,94 +0,0 @@ -{ - "scene": "karman", - "channels": [ - { - "channel": "front", - "best_r2": 0.9654966682363757, - "best_nz": 21, - "pareto": [ - { - "nz": 0, - "r2": -0.025600612306448944 - }, - { - "nz": 1, - "r2": 0.860607207574008 - }, - { - "nz": 2, - "r2": 0.9452383376955145 - }, - { - "nz": 5, - "r2": 0.9536016197206115 - }, - { - "nz": 21, - "r2": 0.9654966682363757 - } - ] - }, - { - "channel": "top", - "best_r2": 0.862596324781397, - "best_nz": 22, - "pareto": [ - { - "nz": 0, - "r2": -2.2359943125676613 - }, - { - "nz": 1, - "r2": 0.6121198810123357 - }, - { - "nz": 7, - "r2": 0.7827214013687178 - }, - { - "nz": 14, - "r2": 0.8493750057676241 - }, - { - "nz": 19, - "r2": 0.8613618595140851 - }, - { - "nz": 22, - "r2": 0.862596324781397 - } - ] - }, - { - "channel": "bottom", - "best_r2": 0.8993440589380792, - "best_nz": 22, - "pareto": [ - { - "nz": 0, - "r2": -2.8272225894084078 - }, - { - "nz": 1, - "r2": 0.7104836293852221 - }, - { - "nz": 5, - "r2": 0.8580026242147389 - }, - { - "nz": 8, - "r2": 0.8924474832818312 - }, - { - "nz": 10, - "r2": 0.8959720170147872 - }, - { - "nz": 22, - "r2": 0.8993440589380792 - } - ] - } - ] -} \ No newline at end of file diff --git a/src/analysis_cloak/scenes/steady/closed_loop/gen_steady_data.py b/src/analysis_cloak/scenes/steady/closed_loop/gen_steady_data.py deleted file mode 100644 index 621ae70..0000000 --- a/src/analysis_cloak/scenes/steady/closed_loop/gen_steady_data.py +++ /dev/null @@ -1,170 +0,0 @@ -"""Generate steady cloak data. - -Steady cloak uses open-loop constant rotation (no PPO model). -The environment has pinball cylinders but no disturbance cylinder. -Target is clean parabolic inflow (constant sensor values). - -Usage: - conda run -n pycuda_3_10 python gen_steady_data.py --device 2 -""" -import argparse -import os -import sys -from collections import deque - -import numpy as np - -_PROJ = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..", "..", "..")) -if _PROJ not in sys.path: - sys.path.insert(0, _PROJ) -from LegacyCelerisLab import FlowField -from LegacyCelerisLab import utils as legacy_utils - -# Point to analysis_crossre configs -CONFIG_DIR = os.path.join(_PROJ, "src", "analysis_crossre", "configs") -OUTPUT_DIR = os.path.join(_PROJ, "src", "analysis_cloak", "scenes", "steady", "closed_loop") - -U0 = 0.01 -L0 = 20 -SAMPLE_INTERVAL = 800 -FIFO_LEN = 150 -DATA_TYPE = np.float32 - - -def main(): - ap = argparse.ArgumentParser() - ap.add_argument("--device", type=int, default=2) - ap.add_argument("--action-top", type=float, default=5.1, help="Top cylinder omega factor") - ap.add_argument("--action-bottom", type=float, default=-5.1, help="Bottom cylinder omega factor") - ap.add_argument("--n-steps", type=int, default=200) - args = ap.parse_args() - - os.makedirs(OUTPUT_DIR, exist_ok=True) - - 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")) - - ff = FlowField(field_cfg, cuda_cfg, device_id=args.device) - NY = ff.FIELD_SHAPE[1] - NX = ff.FIELD_SHAPE[0] - - # Step 1: Add only pinball cylinders (NO disturbance cylinder) - # Front cylinder - ff.add_cylinder((30 * L0, (NY - 1) / 2, 0), L0 / 2) - # Bottom cylinder - ff.add_cylinder((31.3 * L0, (NY - 1) / 2 - 0.75 * L0, 0), L0 / 2) - # Top cylinder - ff.add_cylinder((31.3 * L0, (NY - 1) / 2 + 0.75 * L0, 0), L0 / 2) - # 3 sensors at x=40*L0 - ff.add_sensor((40 * L0, (NY - 1) / 2 + 2 * L0, 0), L0 / 4) - ff.add_sensor((40 * L0, (NY - 1) / 2, 0), L0 / 4) - ff.add_sensor((40 * L0, (NY - 1) / 2 - 2 * L0, 0), L0 / 4) - - n_bodies = ff.obs.size // 2 - print(f"Bodies: {n_bodies} (3 pinball + 3 sensors)") - - # Step 2: Stabilize with zero action - stabilize_steps = int(4 * NX / U0) - print(f"Stabilising ({stabilize_steps} steps)...") - ff.run(stabilize_steps, np.zeros(n_bodies, dtype=DATA_TYPE)) - ff.get_ddf() - ff.save_ddf() - - # Step 3: Record target (steady inflow, no pinball) - we already have clean inflow as target - # For steady cloak, target is just the mean of clean parabolic inflow - # But since we built env WITH pinball, we need target separately - # Actually for steady cloak, target is just the inlet profile at sensor positions - # Let's compute it: run with all bodies but minimal disturbance - # The "target" is simply the flow that would exist without pinball - # Since this is a parabolic channel, the sensors would read the undisturbed parabolic profile - - # For simplicity, record the initial steady-state sensor values as target - ff.restore_ddf() - ff.apply_ddf() - target_list = [] - for i in range(FIFO_LEN): - ff.run(SAMPLE_INTERVAL, np.zeros(n_bodies, dtype=DATA_TYPE)) - obs_slice = ff.obs.copy() - # obs for 6 bodies: [sensor0(2), sensor1(2), sensor2(2), cyl0(2), cyl1(2), cyl2(2)] - # We only want sensor values: indices 0..5 - target_list.append(obs_slice[0:6].copy()) - - target_states = np.array(target_list, dtype=np.float32) - target_mean = np.mean(target_states, axis=0) - print(f"Target mean (steady inflow at sensors): {target_mean}") - - # Step 4: Run with constant rotation - ff.restore_ddf() - ff.apply_ddf() - - constant_action = np.zeros(n_bodies, dtype=DATA_TYPE) - constant_action[n_bodies - 3] = 0.0 * U0 # front - constant_action[n_bodies - 2] = float(args.action_bottom * U0) # bottom - constant_action[n_bodies - 1] = float(args.action_top * U0) # top - print(f"Constant action: {constant_action}") - - # Stabilize to the controlled state - print("Running controlled steady state...") - for i in range(FIFO_LEN): - ff.run(SAMPLE_INTERVAL, constant_action) - - # Record controlled data - sensors_list = [] - forces_list = [] - actions_list = [] - - for step in range(args.n_steps): - ff.run(SAMPLE_INTERVAL, constant_action) - obs_slice = ff.obs.copy() - sensors_list.append(obs_slice[0:6].copy()) - forces_list.append(obs_slice[6:12].copy()) # pinball forces are at indices 6-11 - actions_list.append(constant_action[n_bodies - 3:n_bodies].copy()) - - sensors_arr = np.array(sensors_list, dtype=np.float32) - forces_arr = np.array(forces_list, dtype=np.float32) - actions_arr = np.array(actions_list, dtype=np.float32) - - print(f"Sensors shape: {sensors_arr.shape}") - print(f"Forces shape: {forces_arr.shape}") - print(f"Sensor mean (controlled): {np.mean(sensors_arr, axis=0)}") - print(f"Force mean (controlled): {np.mean(forces_arr, axis=0)}") - - # Save - out_path = os.path.join(OUTPUT_DIR, "steady_data.npz") - np.savez(out_path, sensors=sensors_arr, forces=forces_arr, - actions=actions_arr, target_states=target_states, - action_constant=constant_action) - print(f"Saved: {out_path}") - - # Also save vorticity of final state - ff.get_ddf() - ddf = ff.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 - omega = np.gradient(uy, axis=1) - np.gradient(ux, axis=0) - - try: - 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 - fig, ax = plt.subplots(figsize=(12, 5)) - im = ax.imshow(omega, origin="lower", aspect="equal", cmap="RdBu_r", - vmin=-vmax, vmax=vmax, extent=(0, NX - 1, 0, NY - 1)) - ax.set_title(f"Steady cloak: action=[0, {args.action_bottom}, {args.action_top}]*U0") - fig.colorbar(im, ax=ax, fraction=0.046, pad=0.04) - fig.savefig(os.path.join(OUTPUT_DIR, "vorticity_steady.png"), dpi=150, bbox_inches="tight") - plt.close(fig) - print(f"Vorticity saved to {OUTPUT_DIR}/vorticity_steady.png") - except ImportError: - print("matplotlib not available, skipping vorticity PNG") - - del ff - print("Done") - - -if __name__ == "__main__": - main() diff --git a/src/analysis_cloak/scenes/steady/closed_loop/vorticity_steady.png b/src/analysis_cloak/scenes/steady/closed_loop/vorticity_steady.png deleted file mode 100644 index 0f95b80..0000000 Binary files a/src/analysis_cloak/scenes/steady/closed_loop/vorticity_steady.png and /dev/null differ diff --git a/src/analysis_cloak/scenes/steady/sindy/steady_sindy.json b/src/analysis_cloak/scenes/steady/sindy/steady_sindy.json deleted file mode 100644 index 19142a2..0000000 --- a/src/analysis_cloak/scenes/steady/sindy/steady_sindy.json +++ /dev/null @@ -1,498 +0,0 @@ -{ - "scene": "steady", - "re_codes": "default", - "n_samples_front": 198, - "n_features_front": 21, - "n_features_rear": 22, - "feature_names_front": [ - "u_m", - "u_a", - "u_c", - "v_a", - "Cd_tot", - "Cd_rear", - "Cl_tot", - "Cl_diff", - "sin_ua", - "cos_ua", - "aF_lag1", - "aB_lag1", - "aT_lag1", - "daF", - "daB", - "daT", - "mu", - "mu_u_a", - "mu_v_a", - "mu_Cd_tot", - "mu_Cl_diff" - ], - "feature_names_rear": [ - "bias", - "u_m", - "u_a", - "u_c", - "v_a", - "Cd_tot", - "Cd_rear", - "Cl_tot", - "Cl_diff", - "sin_ua", - "cos_ua", - "aF_lag1", - "aB_lag1", - "aT_lag1", - "daF", - "daB", - "daT", - "mu", - "mu_u_a", - "mu_v_a", - "mu_Cd_tot", - "mu_Cl_diff" - ], - "front": { - "results": [ - { - "threshold": 0.0, - "nz": 0, - "r2": 1.0, - "mae": 0.0 - }, - { - "threshold": 0.001, - "nz": 0, - "r2": 1.0, - "mae": 0.0 - }, - { - "threshold": 0.002, - "nz": 0, - "r2": 1.0, - "mae": 0.0 - }, - { - "threshold": 0.005, - "nz": 0, - "r2": 1.0, - "mae": 0.0 - }, - { - "threshold": 0.01, - "nz": 0, - "r2": 1.0, - "mae": 0.0 - }, - { - "threshold": 0.015, - "nz": 0, - "r2": 1.0, - "mae": 0.0 - }, - { - "threshold": 0.02, - "nz": 0, - "r2": 1.0, - "mae": 0.0 - }, - { - "threshold": 0.03, - "nz": 0, - "r2": 1.0, - "mae": 0.0 - }, - { - "threshold": 0.05, - "nz": 0, - "r2": 1.0, - "mae": 0.0 - }, - { - "threshold": 0.1, - "nz": 0, - "r2": 1.0, - "mae": 0.0 - } - ], - "best": { - "threshold": 0.0, - "nz": 0, - "r2": 1.0, - "mae": 0.0 - }, - "best_coef": [ - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0 - ], - "sparsity_curve": [ - [ - 0.0, - 0, - 1.0 - ], - [ - 0.001, - 0, - 1.0 - ], - [ - 0.002, - 0, - 1.0 - ], - [ - 0.005, - 0, - 1.0 - ], - [ - 0.01, - 0, - 1.0 - ], - [ - 0.015, - 0, - 1.0 - ], - [ - 0.02, - 0, - 1.0 - ], - [ - 0.03, - 0, - 1.0 - ], - [ - 0.05, - 0, - 1.0 - ], - [ - 0.1, - 0, - 1.0 - ] - ] - }, - "top": { - "results": [ - { - "threshold": 0.0, - "nz": 10, - "r2": 0.9583284280391652, - "mae": 1.15718787598632e-08 - }, - { - "threshold": 0.001, - "nz": 0, - "r2": -514997980738.5937, - "mae": 0.050999999046325684 - }, - { - "threshold": 0.002, - "nz": 0, - "r2": -514997980738.5937, - "mae": 0.050999999046325684 - }, - { - "threshold": 0.005, - "nz": 0, - "r2": -514997980738.5937, - "mae": 0.050999999046325684 - }, - { - "threshold": 0.01, - "nz": 0, - "r2": -514997980738.5937, - "mae": 0.050999999046325684 - }, - { - "threshold": 0.015, - "nz": 0, - "r2": -514997980738.5937, - "mae": 0.050999999046325684 - }, - { - "threshold": 0.02, - "nz": 0, - "r2": -514997980738.5937, - "mae": 0.050999999046325684 - }, - { - "threshold": 0.03, - "nz": 0, - "r2": -514997980738.5937, - "mae": 0.050999999046325684 - }, - { - "threshold": 0.05, - "nz": 0, - "r2": -514997980738.5937, - "mae": 0.050999999046325684 - }, - { - "threshold": 0.1, - "nz": 0, - "r2": -514997980738.5937, - "mae": 0.050999999046325684 - } - ], - "best": { - "threshold": 0.0, - "nz": 10, - "r2": 0.9583284280391652, - "mae": 1.15718787598632e-08 - }, - "best_coef": [ - 9.472058267524708e-09, - -0.0002241008167953442, - -0.003224155981542969, - 7.459967868655183e-05, - 0.0002720854703143245, - -1.1691407051761946e-10, - 9.171553339138634e-11, - -6.477660414852548e-10, - -5.860206010691412e-11, - -0.0009623592127892139, - 0.05095243656815282, - 0.0, - -4.8247913174868025e-08, - 4.8247908281752745e-08, - 0.0, - 0.0, - 0.0, - 1.8886475698738897e-10, - -0.16119707097288422, - 0.013584418707664971, - -4.12272941645026e-09, - -2.9297936037658187e-09 - ], - "sparsity_curve": [ - [ - 0.0, - 10, - 0.9583284280391652 - ], - [ - 0.001, - 0, - -514997980738.5937 - ], - [ - 0.002, - 0, - -514997980738.5937 - ], - [ - 0.005, - 0, - -514997980738.5937 - ], - [ - 0.01, - 0, - -514997980738.5937 - ], - [ - 0.015, - 0, - -514997980738.5937 - ], - [ - 0.02, - 0, - -514997980738.5937 - ], - [ - 0.03, - 0, - -514997980738.5937 - ], - [ - 0.05, - 0, - -514997980738.5937 - ], - [ - 0.1, - 0, - -514997980738.5937 - ] - ] - }, - "bottom": { - "results": [ - { - "threshold": 0.0, - "nz": 10, - "r2": 0.9583284280391652, - "mae": 1.15718787598632e-08 - }, - { - "threshold": 0.001, - "nz": 0, - "r2": -514997980738.5937, - "mae": 0.050999999046325684 - }, - { - "threshold": 0.002, - "nz": 0, - "r2": -514997980738.5937, - "mae": 0.050999999046325684 - }, - { - "threshold": 0.005, - "nz": 0, - "r2": -514997980738.5937, - "mae": 0.050999999046325684 - }, - { - "threshold": 0.01, - "nz": 0, - "r2": -514997980738.5937, - "mae": 0.050999999046325684 - }, - { - "threshold": 0.015, - "nz": 0, - "r2": -514997980738.5937, - "mae": 0.050999999046325684 - }, - { - "threshold": 0.02, - "nz": 0, - "r2": -514997980738.5937, - "mae": 0.050999999046325684 - }, - { - "threshold": 0.03, - "nz": 0, - "r2": -514997980738.5937, - "mae": 0.050999999046325684 - }, - { - "threshold": 0.05, - "nz": 0, - "r2": -514997980738.5937, - "mae": 0.050999999046325684 - }, - { - "threshold": 0.1, - "nz": 0, - "r2": -514997980738.5937, - "mae": 0.050999999046325684 - } - ], - "best": { - "threshold": 0.0, - "nz": 10, - "r2": 0.9583284280391652, - "mae": 1.15718787598632e-08 - }, - "best_coef": [ - -9.472058267524708e-09, - 0.0002241008167953442, - 0.003224155981542969, - -7.459967868655183e-05, - -0.0002720854703143245, - 1.1691407051761946e-10, - -9.171553339138634e-11, - 6.477660414852548e-10, - 5.860206010691412e-11, - 0.0009623592127892139, - -0.05095243656815282, - 0.0, - 4.8247913174868025e-08, - -4.8247908281752745e-08, - 0.0, - 0.0, - 0.0, - -1.8886475698738897e-10, - 0.16119707097288422, - -0.013584418707664971, - 4.12272941645026e-09, - 2.9297936037658187e-09 - ], - "sparsity_curve": [ - [ - 0.0, - 10, - 0.9583284280391652 - ], - [ - 0.001, - 0, - -514997980738.5937 - ], - [ - 0.002, - 0, - -514997980738.5937 - ], - [ - 0.005, - 0, - -514997980738.5937 - ], - [ - 0.01, - 0, - -514997980738.5937 - ], - [ - 0.015, - 0, - -514997980738.5937 - ], - [ - 0.02, - 0, - -514997980738.5937 - ], - [ - 0.03, - 0, - -514997980738.5937 - ], - [ - 0.05, - 0, - -514997980738.5937 - ], - [ - 0.1, - 0, - -514997980738.5937 - ] - ] - }, - "scene_info": [ - { - "re_code": 100, - "mu": 0.02, - "n_samples": 198 - } - ] -} \ No newline at end of file diff --git a/src/analysis_cloak/scenes/steady/sr/steady_sr_pareto.json b/src/analysis_cloak/scenes/steady/sr/steady_sr_pareto.json deleted file mode 100644 index 72fcbc4..0000000 --- a/src/analysis_cloak/scenes/steady/sr/steady_sr_pareto.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "scene": "steady", - "channels": [ - { - "channel": "front", - "best_r2": 1.0, - "best_nz": 0, - "pareto": [ - { - "nz": 0, - "r2": 1.0 - } - ] - }, - { - "channel": "top", - "best_r2": 0.9583284280391652, - "best_nz": 10, - "pareto": [ - { - "nz": 0, - "r2": -514997980738.5937 - }, - { - "nz": 10, - "r2": 0.9583284280391652 - } - ] - }, - { - "channel": "bottom", - "best_r2": 0.9583284280391652, - "best_nz": 10, - "pareto": [ - { - "nz": 0, - "r2": -514997980738.5937 - }, - { - "nz": 10, - "r2": 0.9583284280391652 - } - ] - } - ] -} \ No newline at end of file diff --git a/src/analysis_crossre/analysis_notes.md b/src/analysis_crossre/analysis_notes.md deleted file mode 100644 index fe56a61..0000000 --- a/src/analysis_crossre/analysis_notes.md +++ /dev/null @@ -1,405 +0,0 @@ -当前这条线的目标,已经不再是“继续把 Kármán cloak 的跨 \(Re_D\) 拟合做得更精致”,而是:**把所有 cloak 任务纳入同一受约束分析框架,用 SINDy 与 SR 并行探索 shared backbone 的几种可能结构。** 这里的关键词不是“立刻统一”,而是“先分开拟合、横向比较、再判断共享到什么程度”。对 coder 来说,接下来最重要的不是继续沿着单一版本号推进,而是切换到**探索型工作流**。 - -## 一句话总目标 - -\[ -\boxed{ -\text{对所有 cloak 场景,在统一变量、统一对称规则、尽量时间尺度显式化的前提下,分别做 SINDy 与 SR,再比较公式、support 与闭环表现,从而识别 shared backbone、scene-specific activation 与 time-scale effects。} -} -\] - -## 现在已经比较确定的结论 - -| 结论 | 当前状态 | 对后续工作的含义 | -|---|---|---| -| Kármán cloak 跨 \(Re_D\) 统一骨架存在 | 已确认 | 可作为第一批强证据,不必再单线深挖很久 | -| 控制律满足镜像等变结构 | 已确认 | 所有 cloak 后续都应沿用这套 \(G\) 规则 | -| front 不需要 bias | 已确认 | front 默认 odd 结构 | -| rear shared-head 有价值 | 已确认 | rear 默认优先尝试共享头,而不是无约束独立 | -| 无量纲化不是问题根源 | 已确认 | 变量统一应继续保留 | -| 所有 cloak 是否共享同一骨架 | 未知 | 当前最核心的新问题 | -| 采样周期是否掩盖了高 Re 或某些 cloak 的潜力 | 未知 | 要纳入时间尺度探索线,而不是忽略 | - -## 思路转变 - -之前的工作方式更像: - -1. 选一个场景 -2. 试一个版本 -3. 比较 one-step 与闭环 -4. 再改下一个版本 - -这个方式在“证明跨 Re 骨架存在”阶段是有效的,但接下来不够用了。现在需要转成: - -1. 先统一所有场景共享的变量与约束 -2. 每个场景分别做 SINDy 与 SR -3. 输出可横向比较的结果包 -4. 再在比较结果上判断哪些是 backbone,哪些是场景特有项 - -也就是说,**版本号思路要让位给实验矩阵思路**。 - -## 后续要探索的几种结构可能 - -后面的代码与结果组织,不应默认只有一种真相,而要明确服务于几种可能。 - -### 可能性 A - -所有 cloak 共享同一个核心骨架,只是系数不同。 - -### 可能性 B - -所有 cloak 共享同一批核心项,但不同场景会激活不同附加项。 - -### 可能性 C - -表面上不同 cloak 的 support 不同,但经过时间尺度显式化后会明显收敛。 - -### 可能性 D - -cloak family 不是单一骨架,而是分成若干子家族,例如 steady/Kármán 一类,单涡/erase 一类。 - -后续计划必须让每种可能都能被检验,而不是在一开始就把工作锁死在某个预设答案上。 - -## 新的执行总框架 - -对 coder 来说,接下来最实用的组织方式是三条并行主线。 - -| 线 | 目标 | 输出 | -|---|---|---| -| A. 统一表征线 | 统一变量、约束、时间尺度写法 | 所有场景共享的 feature builder | -| B. 分场景拟合线 | 每个 cloak 分别做 SINDy 与 SR | 每个场景的 support、公式、闭环表现 | -| C. 横向比较线 | 比较场景之间的共性与差异 | overlap 表、shared-backbone 假设检验 | - -注意:这三条线不是先后串行,而是 A 先到可用、B 尽快开始、C 随第一批结果启动。 - -## 具体执行路线图 - -## 阶段 0 - -## 固定统一接口 - -这一阶段的目标不是新结果,而是防止后面每个场景又各写一套特征逻辑。 - -### 0.1 统一 primitive variables - -所有 cloak 场景统一输出以下 primitive variables: - -- 无量纲 sensor:\(\hat u, \hat v\) -- 力系数:\(C_D, C_L\) -- 无量纲控制:\(\alpha\) -- lagged \(\alpha\) -- action increment 或其时间尺度显式化版本 -- \(\mu=1/Re_D\) -- 场景元数据:scene id、Re、control interval、target type - -### 0.2 固定正确的 \(G\) 算子 - -所有场景都用同一套 \(G\) 规则: - -\[ -(\alpha_F,\alpha_T,\alpha_B) \mapsto (-\alpha_F,-\alpha_B,-\alpha_T) -\] - -且其 lag、increment、sensor、force 的符号规则必须与此保持一致。coder 后续不能再为某个单独场景临时改 \(G\)。 - -### 0.3 时间尺度先显式化,不急于一步到位 - -当前不要求立刻找到最终完美的时间尺度写法,但要求先把离散 cadence 从“隐变量”变成“显变量”。最低要求: - -- 每个场景记录 control interval \(\Delta t_c\) -- lag 与 \(\Delta a\) 的定义显式绑定 \(\Delta t_c\) -- 后续比较不同场景、不同采样率时,不允许再把“1 个采样步”当成无条件可比的量 - -### 0.4 统一 feature builder - -统一生成三类特征: - -| 层级 | 内容 | 用途 | -|---|---|---| -| core | 无量纲 sensor、\(C_D,C_L\)、\(\alpha^-\)、\(\Delta\alpha^-\)、\(\mu\) | 所有模型共用 | -| derived | 对称/反对称组合、总量/差量 | SINDy 主库 | -| time-scale | 显式含 \(\Delta t_c\) 的增量或导数近似 | 采样率影响分析 | - -这一阶段结束的标志不是拟合结果,而是:**所有 cloak 场景可以通过同一个 builder 产生可比较特征。** - -## 阶段 1 - -## 所有 cloak 分开做第一轮 SINDy - -这一步是当前最先应该全面展开的。 - -### 场景优先级 - -建议按两层推进,不要求一次所有场景做到同等深度。 - -#### 第一层重点场景 - -- Kármán cloak -- steady cloak - -这两个场景要做完整输出: - -- front odd + rear shared-head 约束 SINDy -- one-step -- 关键闭环 -- support 稳定性 - -#### 第二层扩展场景 - -- 单涡 cloak -- erase -- 其他已有 cloak - -这批先做轻量版: - -- 同一变量空间下的 separate fit -- one-step -- support 与公式形态 -- 必要时再补闭环 - -### 每个场景必须输出什么 - -每个场景第一轮 SINDy 结果,必须统一输出下面这些文件或表: - -| 输出 | 说明 | -|---|---| -| best support | 最优 support 列表 | -| sparsity curve | 稀疏度-误差曲线 | -| front / rear 主项表 | 各通道的主导项与系数 | -| one-step metrics | R²、RMSE | -| selected closed-loop metric | similarity 或等价指标 | -| support stability | 对 threshold / window / bootstrap 的稳定性 | - -### 约束默认值 - -除非某个场景明确被数据否定,否则第一轮都默认: - -- front no-bias -- front odd structure -- rear shared-head -- correct \(G\) consistency - -也就是说,现在不再把“独立三通道”当默认,而是把“有结构约束”当默认。 - -## 阶段 2 - -## 所有 cloak 分开做第一轮 SR - -SR 现在与 SINDy 同步启动,但它的任务是“受限压缩”,不是自由乱搜。 - -### SR 输入规则 - -SR 只能使用: - -- 阶段 0 的统一变量 -- 阶段 1 的 SINDy 已筛出主项及其邻近项 -- 受限运算集合 - -建议 SR 运算集合仍限制为: - -- 加减乘 -- protected divide -- 少量 square -- 如确有必要,再有限放开 tanh - -暂不允许: - -- raw trig 乱搜 -- 高次幂 -- 指数 -- 深层嵌套 - -### 每个场景 SR 必须输出什么 - -| 输出 | 说明 | -|---|---| -| shortest acceptable formula | 最短可接受公式 | -| pareto front | 复杂度 vs 误差 | -| 与 SINDy 支持集的关系 | 是否压缩、是否改写、是否合并了主项 | -| 闭环表现 | 至少对最佳 SR 公式做一版关键闭环 | - -SR 的第一轮目标不是直接找到最终公式,而是回答: - -- 某些场景是否能被更短闭式描述 -- 某些场景之间是否出现同形公式 -- 某些 SINDy 重要项是否在 SR 中被统一吸收 - -## 阶段 3 - -## 横向比较与 shared-backbone 检验 - -当第一批场景的 SINDy 和 SR 都有结果后,马上进入横向比较,不要等所有场景都做完才比较。 - -### 3.1 support overlap 分析 - -至少做下面三种 overlap: - -- Kármán vs steady -- Kármán vs 单涡 -- steady vs 单涡 - -比较结果建议分成三类: - -| 类别 | 含义 | -|---|---| -| shared core | 多数场景共同出现 | -| scene-enhanced | 多场景可见,但某场景更强 | -| scene-specific | 只在个别场景出现 | - -### 3.2 SR 公式形态比较 - -不能只比 support,还要比公式结构。例如检查: - -- 是否都包含同类 force feedback 核心 -- 是否都包含同类 memory 核心 -- 是否只是在某些场景多出周期项或瞬态项 - -### 3.3 shared-backbone 假设检验 - -当横向比较有了第一批证据后,再分别检验: - -- steady 是否是 Kármán 的子模型 -- 单涡是否是 core + history augmentation -- 是否存在所有 cloak 共用的最小 core -- 是否更适合分成 2~3 个子家族 - -注意:这一步才开始认真讨论“共享到什么程度”,不是一开始就联合拟合总公式。 - -## 阶段 4 - -## 时间尺度探索线 - -这条线现在应并行启动,但不必抢在全部场景前面。 - -### 当前目标 - -不是立即得到采样率最终结论,而是先做两件事: - -1. 让现有特征显式包含 \(\Delta t_c\) -2. 看显式化后,不同场景的 support 是否更收敛 - -### 第一批测试建议 - -先在最关键两个场景上做: - -- Kármán cloak -- steady cloak - -对比: - -- 旧的 discrete lag / \(\Delta a\) -- 显式带 \(\Delta t_c\) 的版本 - -看: - -- support 是否变化 -- SR 公式是否更收敛 -- 不同采样率下是否更容易迁移 - -这条线的定位是:**逐步把采样率实现方式从物理骨架里剥离出来。** - -## coder 现在最应该换掉的习惯 - -下面几条是执行层面的明确要求。 - -### 不再做的事 - -- 不再只围绕 Kármán across Re 单线深挖 -- 不再把版本号升级当成主要组织方式 -- 不再只看 one-step 就判断某条线值不值得做 -- 不再在 raw feature 上做自由 SR - -### 接下来默认要做的事 - -- 所有 cloak 进入同一 builder -- 每个场景都做 separate SINDy + separate SR -- 每个结果都输出 support、公式、闭环三类信息 -- 结果出来后立即做横向比较 - -## 建议的文件与结果组织方式 - -建议从“按版本存结果”改成“按场景 × 方法存结果”。 - -### 建议目录 - -```text -analysis_cloak/ - common/ - feature_builder.py - symmetry.py - time_scale.py - scenes/ - karman/ - sindy/ - sr/ - closed_loop/ - steady/ - sindy/ - sr/ - closed_loop/ - monopole/ - sindy/ - sr/ - closed_loop/ - erase/ - sindy/ - sr/ - closed_loop/ - comparisons/ - support_overlap/ - formula_shape/ - shared_backbone_tests/ -``` - -### 每个场景的统一结果表 - -每个场景至少生成一张 summary 表,包含: - -| method | sparsity | one-step | closed-loop | key terms | notes | -|---|---:|---:|---:|---|---| -| SINDy | | | | | | -| SR | | | | | | - -这样后面比较时不会再陷入“某个版本某次试验表现不错,但很难横向放一起看”的状态。 - -## 最小可执行计划 - -如果 coder 需要一个最小可执行版本,按下面顺序做即可。 - -### 本轮必须完成 - -1. 统一所有 cloak 的 feature builder -2. 跑 Kármán 与 steady 的第一轮 separate SINDy -3. 跑 Kármán 与 steady 的第一轮受限 SR -4. 输出 Kármán vs steady 的 support overlap 与公式形态比较 - -### 下一轮扩展 - -5. 把单涡 cloak 纳入同样流程 -6. 对 lag / \(\Delta a\) 做 \(\Delta t_c\) 显式化版本 -7. 检查显式化前后 support 是否更收敛 - -### 再下一轮 - -8. 开始 shared-backbone hypothesis tests -9. 决定是“统一骨架 + 场景调制”,还是“若干子家族骨架” -10. 再决定是否需要拟联合总公式 - -## 当前工作的直接收束 - -因此,接下来给 coder 的总原则应明确改写为: - -\[ -\boxed{ -\text{当前阶段的重点不是继续优化某一个跨 Re 版本,而是切换到场景化探索:所有 cloak 先分开做 SINDy 与 SR,再横向比较 support、公式与闭环,从而判断 shared backbone 到底成立到什么程度。} -} -\] - -这意味着后续真正要回答的问题已经变成: - -- 哪些项是所有 cloak 的 shared core -- 哪些项是 Kármán、steady、单涡等场景的激活差异 -- 时间尺度写法会不会改变这种比较结果 -- SR 能否把若干场景的公式压缩成更统一的形态 - -只有这样,后面的统一控制律才不会停留在“跨 Re 的局部现象”,而会真正推进到你现在更关心的问题:**cloak family 的物理骨架到底是什么。** \ No newline at end of file diff --git a/src/analysis_crossre/scripts/cfg.py b/src/analysis_crossre/scripts/cfg.py deleted file mode 100644 index 0073b1c..0000000 --- a/src/analysis_crossre/scripts/cfg.py +++ /dev/null @@ -1,91 +0,0 @@ -# analysis_crossre/scripts/cfg.py -"""Configuration constants for the cross-Re Karman cloak analysis.""" - -import os -from typing import List, Dict, Tuple - -# -- Paths ------------------------------------------------------------------- -_PROJ_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..")) -ANALYSIS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) -CONFIG_DIR = os.path.join(ANALYSIS_DIR, "configs") -MODEL_DIR = os.path.join(_PROJ_ROOT, "models") -OUTPUT_DIR = os.path.join(_PROJ_ROOT, "output", "analysis_crossre") -LEGACY_CFD_DIR = os.path.join(_PROJ_ROOT, "LegacyCelerisLab") - -# -- GPU config -------------------------------------------------------------- -DEVICE_ID = 0 # default, override via --device flag - -# -- Legacy CFD config paths ------------------------------------------------- -CONFIG_CUDA = os.path.join(CONFIG_DIR, "config_cuda.json") -CONFIG_FLOWFIELD_BASE = os.path.join(CONFIG_DIR, "config_flowfield.json") - -# -- Physics constants ------------------------------------------------------- -U0 = 0.01 # inlet velocity (lattice units) -D_CYL = 20.0 # single cylinder diameter (lattice units) -D_REF = 40.0 # reference length = 2 * D (used for code "Re") -L0 = 20.0 # base length unit (lattice) - -# -- Geometry (Karman cloak standard, all in lattice units) ------------------ -# Legacy grid: 1280 x 512 -NX = 1280 -NY = 512 -CENTER_Y = (NY - 1) / 2.0 - -# Disturbance cylinder -DIST_CENTER = (10.0 * L0, CENTER_Y) # (200, 255.5) -DIST_RADIUS = L0 # 20 - -# Downstream sensors (at x = 40*L0) -SENSOR_RADIUS = L0 / 4 # 5 -SENSOR_CENTERS = [ - (40.0 * L0, CENTER_Y + 2.0 * L0), # sensor0 (top) - (40.0 * L0, CENTER_Y), # sensor1 (mid) - (40.0 * L0, CENTER_Y - 2.0 * L0), # sensor2 (bottom) -] - -# Pinball cylinders -PINBALL_RADIUS = L0 / 2 # 10 -FRONT_CENTER = (30.0 * L0, CENTER_Y) # (600, 255.5) -BOTTOM_CENTER = (31.3 * L0, CENTER_Y - 0.75 * L0) # (626, 240.5) -TOP_CENTER = (31.3 * L0, CENTER_Y + 0.75 * L0) # (626, 270.5) - -# -- Sampling parameters ---------------------------------------------------- -SAMPLE_INTERVAL = 800 -FIFO_LEN = 150 -CONV_LEN = 30 -STABILIZE_STEPS = int(4 * NX / U0) - -# -- DRL parameters ---------------------------------------------------------- -S_DIM = 12 # sensor[6] + force[6] -A_DIM = 3 -ACTION_SCALE = 8.0 -ACTION_BIAS = [0.0, -4.0, 4.0] # front, bottom, top (multiply by U0 later) - -# -- Re definition ---------------------------------------------------------- -# "Re_code" uses reference length 2*D (D_REF = 40), matching model file naming. -# The true physical Reynolds number is Re_D = Re_code / 2. -# Re_code=100 -> Re_D=50 (default case) -def nu_from_re(re_code: float) -> float: - """Calculate kinematic viscosity from code Reynolds number.""" - return U0 * D_REF / re_code - -# Re cases: (re_code, model_name) -- None for validation cases with no model -RE_CASES_TRAIN: List[Tuple[int, str]] = [ - (50, "d1a3o12_re50"), - (100, "d1a3o12_re100"), - (200, "d1a3o12_re200"), - (400, "d1a3o12_re400"), -] - -RE_CASES_VALIDATION: List[int] = [35, 70, 150] -ALL_RE_CODES: List[int] = [c[0] for c in RE_CASES_TRAIN] + RE_CASES_VALIDATION - -RE_LABEL_MAP: Dict[int, str] = { - 50: "Re50 (Re_D=25)", - 100: "Re100 (Re_D=50)", - 200: "Re200 (Re_D=100)", - 400: "Re400 (Re_D=200)", - 35: "Re35 (Re_D=17.5)", - 70: "Re70 (Re_D=35)", - 150: "Re150 (Re_D=75)", -} diff --git a/src/analysis_crossre/scripts/phase1_infer.py b/src/analysis_crossre/scripts/phase1_infer.py deleted file mode 100644 index 6fdde3b..0000000 --- a/src/analysis_crossre/scripts/phase1_infer.py +++ /dev/null @@ -1,329 +0,0 @@ -# analysis_crossre/scripts/phase1_infer.py -"""Phase 1: verify legacy-CFD + PPO inference across Karman cloak Re cases. - -Usage:: - - conda run -n pycuda_3_10 python phase1_infer.py \\ - --re 100 --device 0 - - conda run -n pycuda_3_10 python phase1_infer.py \\ - --re all --device 2 - -Output for each Re case under ``output/analysis_crossre/re{re_code}/``: - -- ``config.json`` — runtime parameters -- ``norm.json`` — normalisation factors -- ``target.npz`` — target sensor signal (disturbance only, no pinball) -- ``uncontrolled.npz`` — sensor / force / obs time series (zero-action) -- ``controlled.npz`` — sensor / force / obs / action time series (PPO) -- ``vorticity_uncontrolled.png`` — final-step vorticity, uncontrolled -- ``vorticity_controlled.png`` — final-step vorticity, controlled -- ``rewards.npz`` — step-by-step reward for controlled rollout -""" -from __future__ import annotations - -import argparse -import json -import os -import sys -import time -from collections import deque - -import numpy as np - -# Add workspace root so LegacyCelerisLab is importable -_PROJ = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..")) -if _PROJ not in sys.path: - sys.path.insert(0, _PROJ) - -from LegacyCelerisLab import FlowField # noqa: E402 - -from utils import ( # noqa: E402 - 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 cfg import ( # noqa: E402 - CONFIG_DIR, - CONFIG_CUDA, - CONFIG_FLOWFIELD_BASE, - OUTPUT_DIR, - MODEL_DIR, - SAMPLE_INTERVAL, - FIFO_LEN, - CONV_LEN, - S_DIM, - A_DIM, - ACTION_SCALE, - ACTION_BIAS, - U0, - STABILIZE_STEPS, - RE_CASES_TRAIN, - RE_CASES_VALIDATION, - RE_LABEL_MAP, -) - -DATA_TYPE = np.float32 - - -def run_single_re( - re_code: int, - model_path: Optional[str], - device_id: int, - output_root: str, - *, - n_infer_steps: int = 200, -) -> dict: - """Run full inference pipeline for one Re case. - - Parameters - ---------- - re_code : int - Code Reynolds number (reference length = 2D). - model_path : str or None - Path to PPO .zip file. None = only uncontrolled runs. - device_id : int - GPU device ID. - output_root : str - Root output directory for this case. - n_infer_steps : int - Number of inference steps (each = SAMPLE_INTERVAL LBM steps). - - Returns summary dict with similarity scores etc. - """ - os.makedirs(output_root, exist_ok=True) - - # -- 0. Prepare config --------------------------------------------------- - nu = nu_from_re(re_code, u0=U0) - label = RE_LABEL_MAP.get(re_code, f"Re{re_code}") - print(f"\n{'='*60}") - print(f"Case: {label} viscosity={nu:.6f} device={device_id}") - print(f"{'='*60}") - - # Save run config - with open(os.path.join(output_root, "config.json"), "w") as f: - json.dump({ - "re_code": re_code, - "nu": nu, - "u0": U0, - "sample_interval": SAMPLE_INTERVAL, - "fifo_len": FIFO_LEN, - "conv_len": CONV_LEN, - "device_id": device_id, - "model_path": model_path, - }, f, indent=2) - - # -- 1. Load legacy CFD configs, adjust viscosity ------------------------ - cuda_cfg, field_cfg = load_legacy_configs(CONFIG_DIR) - # Override viscosity for this Re - field_cfg = field_cfg._replace(viscosity=float(nu)) - - # -- 2. Build flow field + dist + 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=20.0, 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) - - # -- 3. Add pinball, compute norm, bias rollout -------------------------- - norm = add_pinball( - ff, l0=20.0, u0=U0, sample_interval=SAMPLE_INTERVAL, - fifo_len=FIFO_LEN, data_type=DATA_TYPE, - action_bias=ACTION_BIAS, - ) - save_states = norm.pop("save_states", None) - - # Save norm (without save_states which is a big array) - 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) - print(f" norm saved to {output_root}/norm.json") - - # -- 4. Uncontrolled rollout --------------------------------------------- - print(" uncontrolled rollout ...") - ff.restore_ddf() - ff.apply_ddf() - fifo = deque(maxlen=FIFO_LEN) - sens_list, forc_list, obs_list = [], [], [] - - for step in range(n_infer_steps): - ff.run(SAMPLE_INTERVAL, np.zeros(7, dtype=DATA_TYPE)) - obs_slice = ff.obs.copy()[2:14] - fifo.append(obs_slice) - sens_list.append(obs_slice[0:6]) - forc_list.append(obs_slice[6:12]) - obs = build_observation(obs_slice, norm) - obs_list.append(obs) - - 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), - obs=np.array(obs_list, dtype=np.float32)) - - # Vorticity at final step - omega_unc = vorticity_from_ddf(ff, u0=U0) - save_vorticity_png( - os.path.join(output_root, "vorticity_uncontrolled.png"), - omega_unc, - title=f"{label} uncontrolled", - ) - # Also save macro field snapshot - macro_unc = {"ux": [], "uy": [], "rho": []} # placeholder; vorticity PNG is enough for Phase 1 - - # -- 5. Controlled rollout (if model available) -------------------------- - result = {"re_code": re_code, "uncontrolled": True, "controlled": False} - - if model_path is not None and os.path.isfile(model_path): - print(f" loading model: {model_path}") - model = load_ppo_model(model_path, device=f"cuda:{device_id}") - model.set_random_seed(0) - - print(f" controlled rollout ({n_infer_steps} steps) ...") - ff.restore_ddf() - ff.apply_ddf() - # Re-bias the FIFO (as in env.__init__) - bias_action = scale_action( - np.array([0.0, 0.0, 0.0], dtype=np.float32), - scale=ACTION_SCALE, bias=ACTION_BIAS, u0=U0, n_total_bodies=7, - ) - for i in range(FIFO_LEN): - ff.context.push() - try: - ff.run(SAMPLE_INTERVAL, bias_action) - finally: - ff.context.pop() - fifo.append(ff.obs.copy()[2:14]) - - sens_list_c, forc_list_c, obs_list_c = [], [], [] - action_list_c, reward_list_c = [], [] - - 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_c.append(action.copy()) - - # Convert to legacy action array - action_arr = scale_action( - action, scale=ACTION_SCALE, bias=ACTION_BIAS, - u0=U0, n_total_bodies=7, - ) - - # Run CFD with proper context management (PyTorch shadows legacy ctx) - ff.context.push() - try: - ff.run(SAMPLE_INTERVAL, action_arr) - finally: - 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) - obs_list_c.append(obs) - - # Compute reward (matching env logic) - 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)) - else: - reward_list_c.append(0.0) - - 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), - obs=np.array(obs_list_c, dtype=np.float32), - actions=np.array(action_list_c, dtype=np.float32), - rewards=np.array(reward_list_c, dtype=np.float32)) - - # Vorticity at final controlled step - omega_con = vorticity_from_ddf(ff, u0=U0) - save_vorticity_png( - os.path.join(output_root, "vorticity_controlled.png"), - omega_con, - title=f"{label} controlled (PPO)", - ) - - 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 Re{re_code}, skipping controlled rollout") - - # -- 6. Cleanup ---------------------------------------------------------- - del ff - - # Save result summary - 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="Phase 1: cross-Re 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") - args = ap.parse_args() - - # Select Re list - selection = args.re.lower() - if selection == "all": - re_list = [c[0] for c in RE_CASES_TRAIN] - elif selection == "validation": - re_list = RE_CASES_VALIDATION - else: - re_list = [int(selection)] - - t_start = time.time() - - for re_code in re_list: - # Find model path - model_path = None - for rc, mn in RE_CASES_TRAIN: - if rc == re_code: - model_path = os.path.join(MODEL_DIR, "old", f"{mn}.zip") - break - - out_dir = os.path.join(OUTPUT_DIR, f"re{re_code}") - result = run_single_re( - re_code, model_path, args.device, out_dir, - n_infer_steps=args.steps, - ) - print(f" Done: re{re_code} -> {out_dir}") - - elapsed = time.time() - t_start - print(f"\nTotal time: {elapsed:.1f}s") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/src/analysis_crossre/scripts/utils.py b/src/analysis_crossre/scripts/utils.py deleted file mode 100644 index 0bfdd11..0000000 --- a/src/analysis_crossre/scripts/utils.py +++ /dev/null @@ -1,851 +0,0 @@ -# analysis_crossre/scripts/utils.py -"""Shared utilities for the cross-Re Karman cloak analysis. - -All functions use the LegacyCelerisLab (old) CFD API via ``from LegacyCelerisLab import FlowField``. -Must be run inside ``conda run -n pycuda_3_10``. -""" -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 - -# -- Import legacy CFD ------------------------------------------------------- -# LegacyCelerisLab lives at the repo root; analysis scripts are at -# src/analysis_crossre/scripts/. We need repo root on sys.path. -_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 - -# --------------------------------------------------------------------------- -# Action-smoothing constant (legacy run() internal) -# --------------------------------------------------------------------------- -ACTION_SMOOTH_WEIGHT = 0.1 # used by FlowField.run() internally - - -def nu_from_re(re_code: float, u0: float = 0.01, d_ref: float = 40.0) -> float: - """Return kinematic viscosity for a given code Reynolds number. - - ``re_code`` uses reference length *2*D* = 40.0 (matching model file naming). - """ - return u0 * d_ref / re_code - - -def load_legacy_configs(config_dir: str) -> Tuple[Any, Any]: - """Load and return 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 - - -# --------------------------------------------------------------------------- -# Environment helpers – follow env_karman_cloak_standard.py exactly -# --------------------------------------------------------------------------- - -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]: - """Phase 0-1: add dist-cylinder & 3 sensors, stabilize, record target. - - Steps (mirrors env.__init__ lines 64-86): - - 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 × run(SAMPLE_INTERVAL, zero[4]), collect obs[2:8] - - Returns - ------- - target_states : ndarray (FIFO_LEN, 6) — sensor0/1/2 ux,uy - info : dict with n_objects, NX, NY - """ - n_objects_before = flow_field.flag.size # not used, just a marker - - # dist cylinder - center = (10.0 * l0, (flow_field.FIELD_SHAPE[1] - 1) / 2, 0.0) - flow_field.add_cylinder(center, l0) - - # sensors - for y_off in [2.0, 0.0, -2.0]: - sc = (40.0 * l0, (flow_field.FIELD_SHAPE[1] - 1) / 2 + y_off * l0, 0.0) - flow_field.add_sensor(sc, l0 / 4.0) - - n_obj = flow_field.obs.size // 2 # obs is (n_obj, DIM) flat - print(f" bodies before pinball: {n_obj}") - - # stabilize - 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)) - - # record target (only sensor signals = obs[2:8]) - target_states = np.empty((0, 6), dtype=data_type) - for i in range(fifo_len): - flow_field.run(sample_interval, np.zeros(n_obj, dtype=data_type)) - new_state = flow_field.obs.copy()[2:8] # sensor0+1+2 velocities - target_states = np.vstack((target_states, new_state)) - - 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]} - - -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, -) -> dict: - """Phase 2-3: add pinball cylinders, stabilize, compute norm, bias rollout. - - Steps (mirrors env.__init__ lines 88-117): - - 1. add front, bottom, top cylinders (id=4,5,6) - 2. stabilize run(4*NX/U0, zero-action[7]) - 3. get_ddf() + save_ddf() (checkpoint of stabilised state) - 4. FIFO_LEN × run(SAMPLE_INTERVAL, zero[7]) → compute norm - 5. apply_ddf() (restore pre-bias state) - 6. FIFO_LEN × run(SAMPLE_INTERVAL, bias-action[7]) → save_states - 7. apply_ddf() - - Returns dict with norm values. - """ - if action_bias is None: - action_bias = (0.0, -4.0, 4.0) # default cloak bias: front=0, bottom=-4U0, top=4U0 - - u0_float = float(u0) - n_obj_before = flow_field.obs.size // 2 - - # add 3 pinball cylinders - ny = flow_field.FIELD_SHAPE[1] - centers = [ - (30.0 * l0, (ny - 1) / 2, 0.0), - (31.3 * l0, (ny - 1) / 2 + 0.75 * l0, 0.0), - (31.3 * 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 - print(f" bodies after pinball: {n_obj}") - - # stabilize - 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)) - - # checkpoint DDF - flow_field.get_ddf() - flow_field.save_ddf() - - # --- norm phase (zero-action) --- - fifo = deque(maxlen=fifo_len) - for i in range(fifo_len): - flow_field.run(sample_interval, np.zeros(n_obj, dtype=data_type)) - fifo.append(flow_field.obs.copy()[2:14]) # sensor[6]+force[6] - - 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]))) - - print(f" norm: force_norm_fact={force_norm_fact:.6f}") - print(f" norm: sens_deviation={sens_deviation}") - print(f" norm: sens_norm_fact={sens_norm_fact}") - - # --- 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) # front - bias[n_obj - 2] = float(action_bias[1] * u0_float) # bottom - bias[n_obj - 1] = float(action_bias[2] * u0_float) # top - print(f" bias action: {bias}") - - fifo.clear() - for i in range(fifo_len): - flow_field.run(sample_interval, bias) - fifo.append(flow_field.obs.copy()[2:14]) - - 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, - } - - -def build_observation( - obs_slice: np.ndarray, - norm: dict, -) -> np.ndarray: - """Assemble normalised DRL observation (12-dim) from a single obs[2:14] 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 action_to_physical( - action_norm: np.ndarray, - *, - scale: float = 8.0, - bias: Tuple[float, float, float] = (0.0, -4.0, 4.0), - u0: float = 0.01, -) -> np.ndarray: - """Convert normalized action [-1,1] to physical omega (lattice units). - - physical_omega[i] = (action_norm[i] * scale + bias[i]) * u0 - """ - action_norm = np.asarray(action_norm, dtype=np.float64).reshape(-1, 3) - bias_arr = np.array(bias, dtype=np.float64) - return (action_norm * scale + bias_arr) * u0 - - -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 vorticity_from_ddf(flow_field: FlowField, u0: float) -> np.ndarray: - """Compute z-vorticity from current DDF on host.""" - flow_field.get_ddf() - ddf = flow_field.ddf.copy().reshape((9, flow_field.FIELD_SHAPE[1], flow_field.FIELD_SHAPE[0])).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 - # vorticity = dv/dx - du/dy - 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: - """Find lag that maximises cross-correlation between two 1-D signals.""" - 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: - """DTW-based similarity: 1 - (DTW distance / len(target)). - - Both are 1-D arrays of possibly different lengths. - """ - 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: - """Compute lag-compensated DTW similarity over *conv_len* window. - - Matches the reward logic in env_karman_cloak_standard.step(). - """ - # lag from middle sensor - 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): # 6 sensor dimensions - 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) - - -# --------------------------------------------------------------------------- -# Dummy env for loading SB3 models -# --------------------------------------------------------------------------- - -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 - - -# --------------------------------------------------------------------------- -# SINDy feature library – all in physical units -# --------------------------------------------------------------------------- - -def build_sindy_feature_library( - n_sensor: int = 6, - n_force: int = 6, -) -> list: - """Return list of feature names for SINDy regression (for reference). - - Features are built on-the-fly in ``build_sindy_features()``. - """ - names = [] - # bias - names.append("bias") - # raw sensor velocities (6 channels) - for i in range(n_sensor): - names.append(f"s{i}") - # raw forces (6 channels) - for i in range(n_force): - names.append(f"f{i}") - return names - - -def build_sindy_features( - sensors: np.ndarray, # (T, 6) raw sensor velocities - forces: np.ndarray, # (T, 6) raw forces - actions_prev: np.ndarray, # (T, 3) previous-step physical omegas (ch0_lag1) - include_extra: bool = True, -) -> np.ndarray: - """Construct feature matrix Theta(t) for SINDy. - - All quantities are in physical lattice units (not DRL-normalised). - - Base library (always included): - bias(1), s0..s5, f0..f5 - - Extra features (if ``include_extra=True``, default): - sin(pi*s0), cos(pi*s0), # phase info from middle sensor u - ds0..ds5, # forward difference of sensor signals - a0_lag1, a1_lag1, a2_lag1 # previous-step action (memory) - - Returns - ------- - Theta : (T, n_features) array - """ - T = sensors.shape[0] - cols = [] - - # 1. bias - cols.append(np.ones(T, dtype=np.float64)) - - # 2. raw sensors (6) - for i in range(6): - cols.append(sensors[:, i].astype(np.float64)) - - # 3. raw forces (6) - for i in range(6): - cols.append(forces[:, i].astype(np.float64)) - - # 4. sin/cos of middle-sensor u-velocity (phase info) - s0 = sensors[:, 0].astype(np.float64) # sensor0_ux - cols.append(np.sin(np.pi * s0)) - cols.append(np.cos(np.pi * s0)) - - # 5. sensor differences - for i in range(6): - diff = np.diff(sensors[:, i].astype(np.float64), prepend=sensors[0, i]) - cols.append(diff) - - # 6. previous action (lag-1 memory) - cols.append(actions_prev[:, 0].astype(np.float64)) # ch0 - cols.append(actions_prev[:, 1].astype(np.float64)) # ch1 - cols.append(actions_prev[:, 2].astype(np.float64)) # ch2 - - Theta = np.column_stack(cols) - return Theta - - -def get_sindy_feature_names(include_extra: bool = True) -> list: - """Return list of feature names matching ``build_sindy_features()`` output.""" - names = ["bias"] - for i in range(6): - names.append(f"s{i}") - for i in range(6): - names.append(f"f{i}") - if include_extra: - names += ["sin_s0", "cos_s0"] - for i in range(6): - names.append(f"ds{i}") - names += ["a0_lag1", "a1_lag1", "a2_lag1"] - return names - - -SINDY_DEFAULT_FEATURE_NAMES = get_sindy_feature_names(include_extra=True) - - -def fit_channel( - Theta: np.ndarray, - y: np.ndarray, - thresholds: list, - alpha: float = 1e-4, - max_iter: int = 25, -) -> tuple: - """Fit a single channel (one cylinder) with STLSQ threshold grid. - - Returns - ------- - rows : list of dict per threshold - best : dict with best threshold entry - """ - import pysindy as ps - - # Normalise features for thresholding stability - std = np.std(Theta, axis=0) - std = np.where(std < 1e-8, 1.0, std) - Theta_s = Theta / std - - best = None - rows = [] - for th in thresholds: - opt = ps.STLSQ(threshold=th, alpha=alpha, max_iter=max_iter) - opt.fit(Theta_s, y) - coef = np.asarray(opt.coef_, dtype=np.float64).flatten() / std - y_pred = Theta @ coef - ssr = float(np.sum((y - y_pred) ** 2)) - sst = float(np.sum((y - np.mean(y)) ** 2) + 1e-12) - r2 = 1.0 - ssr / sst - mae = float(np.mean(np.abs(y - y_pred))) - nz = int(np.sum(np.abs(coef) > 1e-8)) - entry = {"threshold": float(th), "nz": nz, "r2": r2, "mae": mae, "coef": coef} - rows.append(entry) - if best is None or r2 > best["r2"]: - best = entry - return rows, best - - -def print_control_law(feature_names: list, coef: np.ndarray, channel_label: str = "ch"): - """Pretty-print a sparse control law.""" - terms = [] - for i, c in enumerate(coef): - if abs(c) > 1e-8: - terms.append(f"{c:.6f} * {feature_names[i]}") - print(f" {channel_label}: {' + '.join(terms)}") - nz = sum(1 for c in coef if abs(c) > 1e-8) - print(f" non-zero terms: {nz}") - - -# --------------------------------------------------------------------------- -# Physics-guided feature library (v2 — replaces raw obs features) -# --------------------------------------------------------------------------- - -def compute_physical_symbols( - sensors: np.ndarray, # (T, 6) [s0_ux, s0_uy, s1_ux, s1_uy, s2_ux, s2_uy] - forces: np.ndarray, # (T, 6) [cyl0_fx, cyl0_fy, cyl1_fx, cyl1_fy, cyl2_fx, cyl2_fy] - actions_prev: np.ndarray, # (T, 3) previous-step physical omega - actions_prev2: np.ndarray, # (T, 3) omega(t-2), for delta terms -) -> dict: - """Compute physics-guided symbols from raw CFD data. - - Returns a dict of 1-D arrays (T,) keyed by symbol name. - """ - T = sensors.shape[0] - s = sensors.astype(np.float64) - f = forces.astype(np.float64) - a_prev = np.asarray(actions_prev, dtype=np.float64) - a_prev2 = np.asarray(actions_prev2, dtype=np.float64) - - # -- sensor symbols ---------------------------------------------------- - # streamwise - u0, u1, u2 = s[:, 0], s[:, 2], s[:, 4] - # cross-stream - v0, v1, v2 = s[:, 1], s[:, 3], s[:, 5] - - sym = {} - - sym["u_m"] = (u0 + u1 + u2) / 3.0 # mean wake deficit - sym["u_a"] = (u2 - u0) / 2.0 # antisymmetric (vortex street) - sym["u_c"] = u1.copy() # centreline streamwise - sym["u_curv"] = u0 - 2.0 * u1 + u2 # transverse curvature - - sym["v_m"] = (v0 + v1 + v2) / 3.0 # mean cross-stream - sym["v_a"] = (v2 - v0) / 2.0 # antisymmetric cross - sym["v_c"] = v1.copy() # centre cross - sym["v_curv"] = v0 - 2.0 * v1 + v2 # cross curvature - - # vortex phase encoding (keep on u_a which captures asymmetry) - sym["sin_ua"] = np.sin(np.pi * sym["u_a"]) - sym["cos_ua"] = np.cos(np.pi * sym["u_a"]) - - # -- force symbols ----------------------------------------------------- - # cylinders: [front_fx, front_fy, bottom_fx, bottom_fy, top_fx, top_fy] - fx_front, fy_front = f[:, 0], f[:, 1] - fx_bot, fy_bot = f[:, 2], f[:, 3] - fx_top, fy_top = f[:, 4], f[:, 5] - - sym["Fx_tot"] = fx_front + fx_bot + fx_top - sym["Fx_rear"] = fx_bot + fx_top - sym["Fx_diff"] = fx_top - fx_bot - - sym["Fy_tot"] = fy_front + fy_bot + fy_top - sym["Fy_rear"] = fy_bot + fy_top - sym["Fy_diff"] = fy_top - fy_bot - - # -- memory symbols ---------------------------------------------------- - sym["a0_lag1"] = a_prev[:, 0] - sym["a1_lag1"] = a_prev[:, 1] - sym["a2_lag1"] = a_prev[:, 2] - - sym["da0"] = a_prev[:, 0] - a_prev2[:, 0] - sym["da1"] = a_prev[:, 1] - a_prev2[:, 1] - sym["da2"] = a_prev[:, 2] - a_prev2[:, 2] - - return sym - - -# Which physical symbols to always include in the base library -PHYSICAL_BASE_SYMBOLS = [ - "bias", - "u_m", "u_a", "u_c", - "v_a", - "Fx_tot", "Fx_rear", "Fy_tot", "Fy_diff", - "sin_ua", "cos_ua", - "a0_lag1", "a1_lag1", "a2_lag1", - "da0", "da1", "da2", -] - -# Which symbols get mu modulation -PHYSICAL_MU_SYMBOLS = [ - "u_a", "v_a", "Fx_tot", "Fy_diff", "Fy_tot", -] - - -def build_physical_features( - sensors: np.ndarray, - forces: np.ndarray, - actions_prev: np.ndarray, - actions_prev2: np.ndarray, - mu: float = 0.0, - include_mu: bool = True, -) -> tuple: - """Construct physics-guided feature matrix. - - Returns - ------- - Theta : (T, n_feat) ndarray - names : list of feature names - """ - sym = compute_physical_symbols(sensors, forces, actions_prev, actions_prev2) - T = sensors.shape[0] - cols = [] - names = [] - - # 1. bias - cols.append(np.ones(T, dtype=np.float64)) - names.append("bias") - - # 2. base physical symbols - for key in PHYSICAL_BASE_SYMBOLS[1:]: # skip "bias" since we already added it - if key in sym: - cols.append(sym[key]) - names.append(key) - - # 3. mu and mu-modulated terms - if include_mu and mu > 0: - cols.append(np.full(T, mu, dtype=np.float64)) - names.append("mu") - for key in PHYSICAL_MU_SYMBOLS: - if key in sym: - cols.append(sym[key] * mu) - names.append(f"mu_{key}") - - Theta = np.column_stack(cols) - return Theta, names - - -def get_physical_feature_names(mu: float = 0.0, include_mu: bool = True) -> list: - """Return feature names matching ``build_physical_features()``.""" - names = ["bias"] + PHYSICAL_BASE_SYMBOLS[1:] - if include_mu and mu > 0: - names.append("mu") - for key in PHYSICAL_MU_SYMBOLS: - names.append(f"mu_{key}") - return names - - -# --------------------------------------------------------------------------- -# Dimensionless conversion and G operator (v3) -# --------------------------------------------------------------------------- - -def compute_dimensionless( - sensors: np.ndarray, # (T, 6) raw lattice [s0_ux, s0_uy, s1_ux, s1_uy, s2_ux, s2_uy] - forces: np.ndarray, # (T, 6) raw lattice [cyl0_fx, cyl0_fy, cyl1_fx, cyl1_fy, cyl2_fx, cyl2_fy] - u0: float = 0.01, - d: float = 20.0, - rho: float = 1.0, -) -> dict: - """Convert raw lattice CFD data to dimensionless physical quantities. - - Returns dict with keys: - u_hat_B, u_hat_C, u_hat_T : nondim streamwise velocity at 3 sensors - v_hat_B, v_hat_C, v_hat_T : nondim crosswise velocity - Cd_F, Cd_T, Cd_B : drag coefficient per cylinder - Cl_F, Cl_T, Cl_B : lift coefficient per cylinder - """ - T = sensors.shape[0] - s = sensors.astype(np.float64) - f = forces.astype(np.float64) - - # Sensor velocity ordering from legacy env: [sensor0_ux, sensor0_uy, sensor1_ux, sensor1_uy, sensor2_ux, sensor2_uy] - # Sensor positions: sensor0=top(y=+2L0), sensor1=mid(y=0), sensor2=bottom(y=-2L0) - # So top = sensor0 = s[:,0:2], mid = sensor1 = s[:,2:4], bottom = sensor2 = s[:,4:6] - # Per G-operator convention: B=bottom=sensor2, C=centre=sensor1, T=top=sensor0 - u_hat_T = s[:, 0] / u0 # top - v_hat_T = s[:, 1] / u0 - u_hat_C = s[:, 2] / u0 # centre - v_hat_C = s[:, 3] / u0 - u_hat_B = s[:, 4] / u0 # bottom - v_hat_B = s[:, 5] / u0 - - # Force ordering: [front_fx, front_fy, bottom_fx, bottom_fy, top_fx, top_fy] - # front=cyl0, bottom=cyl1, top=cyl2 - Cd_F = 2.0 * f[:, 0] / (rho * u0**2 * d) - Cl_F = 2.0 * f[:, 1] / (rho * u0**2 * d) - Cd_B = 2.0 * f[:, 2] / (rho * u0**2 * d) # bottom - Cl_B = 2.0 * f[:, 3] / (rho * u0**2 * d) - Cd_T = 2.0 * f[:, 4] / (rho * u0**2 * d) # top - Cl_T = 2.0 * f[:, 5] / (rho * u0**2 * d) - - return { - "u_hat_B": u_hat_B, "u_hat_C": u_hat_C, "u_hat_T": u_hat_T, - "v_hat_B": v_hat_B, "v_hat_C": v_hat_C, "v_hat_T": v_hat_T, - "Cd_F": Cd_F, "Cd_T": Cd_T, "Cd_B": Cd_B, - "Cl_F": Cl_F, "Cl_T": Cl_T, "Cl_B": Cl_B, - } - - -def apply_G_x( - u_hat_B: np.ndarray, u_hat_C: np.ndarray, u_hat_T: np.ndarray, - v_hat_B: np.ndarray, v_hat_C: np.ndarray, v_hat_T: np.ndarray, - Cd_F: np.ndarray, Cd_T: np.ndarray, Cd_B: np.ndarray, - Cl_F: np.ndarray, Cl_T: np.ndarray, Cl_B: np.ndarray, - aF_lag1: np.ndarray, aT_lag1: np.ndarray, aB_lag1: np.ndarray, - daF: np.ndarray, daT: np.ndarray, daB: np.ndarray, -): - """Apply mirror operator G to input state. - - CORRECTED: all rotation-related quantities (action, lag, delta) - change sign under mirror (y -> -y). - - G flips y -> -y: - - u_x: reorder only (B<->T), no sign change - - v_y: reorder (B<->T) AND sign change - - Cd: no sign change, B<->T - - Cl: sign change, B<->T - - aF: sign change - - aT <-> aB (swap AND sign change on the swapped values) - - da: same as a - - Returns dict with same keys as inputs, values are G-transformed. - """ - return { - "u_hat_B": u_hat_T, "u_hat_C": u_hat_C, "u_hat_T": u_hat_B, - "v_hat_B": -v_hat_T, "v_hat_C": -v_hat_C, "v_hat_T": -v_hat_B, - "Cd_F": Cd_F, "Cd_T": Cd_B, "Cd_B": Cd_T, - "Cl_F": -Cl_F, "Cl_T": -Cl_B, "Cl_B": -Cl_T, - "aF_lag1": -aF_lag1, "aT_lag1": -aB_lag1, "aB_lag1": -aT_lag1, - "daF": -daF, "daT": -daB, "daB": -daT, - } - - -def apply_G_alpha(alpha: np.ndarray) -> np.ndarray: - """Apply G to output action: [aF, aT, aB] -> [-aF, -aB, -aT].""" - return np.array([-alpha[0], -alpha[2], -alpha[1]], dtype=alpha.dtype) - - -# --------------------------------------------------------------------------- -# v3 physical symbols (dimensionless + equivariant-compatible) -# --------------------------------------------------------------------------- - -def compute_v3_symbols( - dim: dict, # from compute_dimensionless() - actions_prev: np.ndarray, # (T, 3) physical omega(t-1) - actions_prev2: np.ndarray, # (T, 3) physical omega(t-2) - mu: float, - include_mu: bool = True, -) -> tuple: - """Compute v3 physics-guided symbols from dimensionless quantities. - - Returns - ------- - Theta_front : np.ndarray (T, n_feat_front) — no bias column - Theta_top : np.ndarray (T, n_feat_top) — with bias column - names : list (common feature names, first is "bias" for top) - """ - T = actions_prev.shape[0] - - # Sensor combinations (nondim) - u_B, u_C, u_T = dim["u_hat_B"], dim["u_hat_C"], dim["u_hat_T"] - v_B, v_C, v_T = dim["v_hat_B"], dim["v_hat_C"], dim["v_hat_T"] - - u_m = (u_B + u_C + u_T) / 3.0 - u_a = (u_T - u_B) / 2.0 # antisymmetric (T - B) - u_c = u_C.copy() - v_a = (v_T - v_B) / 2.0 # antisymmetric (T - B) - - # Force combinations (dimensionless Cd/Cl) - Cd_F, Cd_T, Cd_B = dim["Cd_F"], dim["Cd_T"], dim["Cd_B"] - Cl_F, Cl_T, Cl_B = dim["Cl_F"], dim["Cl_T"], dim["Cl_B"] - - Cd_tot = Cd_F + Cd_T + Cd_B - Cd_rear = Cd_T + Cd_B - Cl_tot = Cl_F + Cl_T + Cl_B - Cl_diff = Cl_T - Cl_B - - # Phase - sin_ua = np.sin(np.pi * u_a) - cos_ua = np.cos(np.pi * u_a) - - # Memory (all 3 cylinders now that we dropped exchange equivariance) - aF_lag1 = actions_prev[:, 0] - aB_lag1 = actions_prev[:, 1] - aT_lag1 = actions_prev[:, 2] - daF = actions_prev[:, 0] - actions_prev2[:, 0] - daB = actions_prev[:, 1] - actions_prev2[:, 1] - daT = actions_prev[:, 2] - actions_prev2[:, 2] - - # Base features (common to all cylinders) - base_feats = { - "u_m": u_m, "u_a": u_a, "u_c": u_c, "v_a": v_a, - "Cd_tot": Cd_tot, "Cd_rear": Cd_rear, - "Cl_tot": Cl_tot, "Cl_diff": Cl_diff, - "sin_ua": sin_ua, "cos_ua": cos_ua, - "aF_lag1": aF_lag1, "aB_lag1": aB_lag1, "aT_lag1": aT_lag1, - "daF": daF, "daB": daB, "daT": daT, - } - - # Build feature arrays - base_names = list(base_feats.keys()) - cols_base = [base_feats[k] for k in base_names] - - # Mu modulation - mu_names = [] - if include_mu and mu > 0: - mu_feats = { - "mu": np.full(T, mu, dtype=np.float64), - "mu_u_a": u_a * mu, - "mu_v_a": v_a * mu, - "mu_Cd_tot": Cd_tot * mu, - "mu_Cl_diff": Cl_diff * mu, - } - mu_names = list(mu_feats.keys()) - cols_mu = [mu_feats[k] for k in mu_names] - else: - cols_mu = [] - - # Front model: NO bias - Theta_front = np.column_stack(cols_base + cols_mu) - - # Top model: WITH bias - cols_top = [np.ones(T, dtype=np.float64)] + cols_base + cols_mu - - Theta_top = np.column_stack(cols_top) - names = ["bias"] + base_names + mu_names - - return Theta_front, Theta_top, names diff --git a/src/analysis_crossre/steady_cloak_plan.md b/src/analysis_crossre/steady_cloak_plan.md deleted file mode 100644 index f0621a9..0000000 --- a/src/analysis_crossre/steady_cloak_plan.md +++ /dev/null @@ -1,121 +0,0 @@ -# Steady Cloak Analysis Plan - -## Objective - -Extend the Karman cloak unified control framework to steady cloak, testing whether the same physical skeleton (features, symmetry, front no-bias, rear shared-head) applies across different cloak tasks. - -## Data Availability Check - -### Trained Models - -Looking at the models directory and legacy training scripts: - -| Model | Task | Status | -|-------|------|--------| -| d1a3o12_re50/re100/re200/re400 | Karman cloak | Phase 1 data exists | -| d1a3o12_250326 | Karman cloak (Re100) | Same as re100 | -| d1a3o12_250421_* | Reduced obs Karman | Different env | -| d1a3o14_250525_imit_* | Illusion | Different task | -| d1a3o12_250729_erase_* | Erase | Different task | - -**No dedicated steady cloak model found in models/.** - -The steady cloak results in the Confirmation report (Section 4.1) used the same d1a3o12_re100 architecture but trained from scratch with steady inflow target. - -### Key Differences: Steady vs Karman Cloak - -| Aspect | Karman Cloak | Steady Cloak | -|--------|-------------|--------------| -| Upstream disturbance | Cylinder (D=20) generating Karman vortex street | None (clean parabolic inflow) | -| Target signal | 3-sensor time series of undisturbed vortex street | Constant mean inlet velocity | -| Sensor output | Periodic (oscillating around mean) | Steady (near-constant) | -| PPO model | Re50-400 series | Unknown model name | -| Sample interval | 800 | Likely same | -| Action bias | [0, -4U0, +4U0] | Likely [0, 0, 0] or different | - -### Feature Implications for Steady Cloak - -When target is steady inflow: -- `u_a` (antisymmetric velocity) -> ~0 (symmetry at mean) -- `v_a` -> ~0 -- `sin_ua`, `cos_ua` -> ~0 (no oscillation to encode) -- `Fy_tot`, `Fy_diff` -> ~0 (no lift oscillation) -- `Fx_tot`, `Fx_rear` -> non-zero (base drag) -- Memory terms -> non-zero (action smoothing still needed) -- Mu modulation -> applicable - -**Many sensor-side features vanish!** This means the steady cloak likely relies mainly on force feedback + memory, not sensor feedback. This is a critical structural difference. - -### Hypothesis - -If the shared cloak skeleton exists, then: -1. The feature set for steady cloak should be a SUBSET of Karman cloak features -2. Force feedback + memory terms should be present in both -3. Sensor asymmetry terms (u_a, v_a, sin/cos) are Karman-specific -4. Front no-bias and rear shared-head should still apply - -## Execution Steps - -### Step 1: Identify/Obtain Steady Cloak Model - -**Action**: Check if steady cloak model exists under a different name, or was trained as part of the d1a3o12_250326 series. - -**Look for**: -- Legacy training scripts that train with steady target (no disturbance cylinder) -- In `legacy_train/` check for any script that uses `env_karman_cloak_standard.py` or similar but without the disturbance cylinder - -### Step 2: Phase 1 Inference (Steady) - -If model found, run Phase 1 inference for steady cloak (similar to `phase1_infer.py` but with steady target): - -1. Build env WITHOUT disturbance cylinder -2. Record target: clean parabolic inflow (constant sensor values) -3. Add pinball -4. Compute norm -5. Run uncontrolled + controlled (PPO) rollout - -**If no model exists**: -- Option A: Train a steady cloak PPO from scratch (1-2 days GPU time) -- Option B: Use the existing d1a3o12_re100 model with steady target - test if the Karman cloak model generalizes to steady inflow -- Option C: Use open-loop control from the report (discovered constant rotation) - -Option B is fastest: test `d1a3o12_re100` with steady inflow, record performance. If similarity > 0.8, the model transfers. - -### Step 3: Feature Support Analysis - -Compare active features between steady and Karman cloak: - -1. Fit SINDy on steady cloak data with same feature library -2. Compare support set (which features have non-zero coefficients) -3. Check G equivariance (should still hold) -4. Check front no-bias (should still hold) -5. Test rear shared-head (v23 structure) - -### Step 4: Cross-task Unified Fit - -If supports overlap: -1. Stack steady + Karman + (optionally) vortex data -2. Fit unified model with task-modulated parameters -3. Test in closed-loop - -## Resource Requirements - -| Step | Data needed | CFD inference | GPU time | -|------|------------|---------------|----------| -| 1 | None | No | ~0 | -| 2 | Steady model | Yes (~200s/Re) | ~3min | -| 3 | Steady rollout NPZ | No | ~0 | -| 4 | All NPZ data | No | ~1min | - -## Risk Assessment - -| Risk | Likelihood | Impact | Mitigation | -|------|-----------|--------|-----------| -| No steady cloak model exists | HIGH | HIGH | Option B (transfer Karman model) | -| Steady cloak uses different obs/action scaling | MEDIUM | MEDIUM | Check norm.json before fitting | -| Sensor features vanish (u_a, v_a ~0) | CERTAIN | LOW | This is expected; force+memory should dominate | -| G equivariance test fails for steady | LOW | MEDIUM | Steady flow is symmetric by construction | - -## Next Step - -Begin with Step 1: find the steady cloak model. Check `d1a3o12_250326.zip` and any other models. If none found, test `d1a3o12_re100.zip` on steady inflow as Option B.