chore: SR pipeline restructuring + DRL training infrastructure
SR_analysis restructured: - Stage 1/2/3/4 unified pipeline with per-stage docs - Old scripts/sindy/validate moved to old/archive - Results reorganized: formulas/validations/figures with scene_registry.json - Added diagnostic plots and PIPELINE.md drl_pinball/train: - New illusion training pipeline: env_illusion.py, train_illusion.py - Cross-Re transfer: crossre_transfer.sh, extend_transfer.sh - Calibration data: 0.75L/1L/1.5L/2L + re60/re100/re200/re400 - Launch scripts for multi-GPU training Cleaned 100+ intermediate/dead files from SR_analysis. Co-authored-by: Cursor <cursoragent@cursor.com>
@ -1,50 +0,0 @@
|
||||
{
|
||||
"_doc": "Karman Cloak Re50: uniform inlet, free-slip walls, 2000x600 grid. Pinball centered.",
|
||||
"grid": {
|
||||
"lattice_model": "D2Q9",
|
||||
"nx": 2000,
|
||||
"ny": 600,
|
||||
"nz": 1
|
||||
},
|
||||
"physics": {
|
||||
"data_type": "FP32",
|
||||
"viscosity": 0.008,
|
||||
"velocity": 0.01,
|
||||
"rho": 1.0
|
||||
},
|
||||
"method": {
|
||||
"collision": "MRT",
|
||||
"streaming": "double_buffer",
|
||||
"store_precision": "FP32",
|
||||
"ddf_shifting": false,
|
||||
"les": {
|
||||
"enabled": false,
|
||||
"cs": 0.16,
|
||||
"closed_form": true
|
||||
},
|
||||
"trt": {
|
||||
"magic_param": 0.1875
|
||||
},
|
||||
"inlet": {
|
||||
"profile": "uniform",
|
||||
"scheme": "regularized",
|
||||
"trt_neq_damp": 0.5,
|
||||
"regularized_neq_damp": 0.5
|
||||
},
|
||||
"outlet": {
|
||||
"mode": "neq_extrap",
|
||||
"backflow_clamp": true,
|
||||
"blend_alpha": 0.7,
|
||||
"srt_neq_damp": 0.5
|
||||
},
|
||||
"y_wall_bc": "free_slip",
|
||||
"omega_guard": {
|
||||
"min": 0.01,
|
||||
"max": 1.99
|
||||
}
|
||||
},
|
||||
"cuda": {
|
||||
"threads_per_block": 256,
|
||||
"compute_capability": "auto"
|
||||
}
|
||||
}
|
||||
@ -1,254 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Generate SR analysis result charts for reporting.
|
||||
Output: docs/figures/SR_analysis/ (PNG files)
|
||||
"""
|
||||
import json, os, sys, glob
|
||||
import numpy as np
|
||||
import matplotlib
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
import matplotlib.ticker as ticker
|
||||
|
||||
_REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
_OUT = os.path.join(_REPO, "docs", "figures", "SR_analysis")
|
||||
os.makedirs(_OUT, exist_ok=True)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Illusion three-scenario comparison (new SR route: phase-state + error-state + absolute)
|
||||
illusion_results = {
|
||||
"0.75L": {"old_v23": 0.908, "new_phase": 0.974, "ppo": 0.972, "model": "d1a3o14_250525_imit_075L_2U_400S", "S": 400},
|
||||
"1L": {"old_v23": 0.962, "new_phase": 0.958, "ppo": 0.973, "model": "d1a3o14_250525_imit_1L_2U_600S", "S": 600},
|
||||
"1.5L": {"old_v23": 0.926, "new_phase": "N/A", "ppo": 0.945, "model": "d1a3o14_250525_imit_15L_2U", "S": 800},
|
||||
}
|
||||
|
||||
# Karman ablation
|
||||
karman_ablation = {
|
||||
"old v23\n(a_lag+da)": 0.901,
|
||||
"static->deriv\n(8dim)": 0.745,
|
||||
"full-lag->deriv\n(16dim)": 0.619,
|
||||
"phase->deriv\n(6dim)": 0.656,
|
||||
"phase->abs\n(6dim)": 0.699,
|
||||
"phase+mu->abs\n(9dim)": 0.700,
|
||||
"expanded->abs\n(10dim)": 0.580,
|
||||
}
|
||||
|
||||
# Karman generalization
|
||||
karman_gen = {
|
||||
"Re25": 0.567, "Re50": 0.582, "Re70": 0.577,
|
||||
"Re100": 0.901, "Re150": 0.595, "Re200": 0.793,
|
||||
"Re300": 0.541, "Re400": 0.664
|
||||
}
|
||||
|
||||
# Ablation one-step R2
|
||||
ablation_r2 = {
|
||||
"static\n0": 0.321,
|
||||
"phase\n6": 0.837,
|
||||
"phase+abs\n6": 0.965,
|
||||
"full-lag\n16": 0.939,
|
||||
"expanded\n10": 0.980,
|
||||
"phase+mu\n9": 0.979,
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Color scheme
|
||||
# ---------------------------------------------------------------------------
|
||||
C_OLD = "#d62728" # red
|
||||
C_NEW_PHASE = "#2ca02c" # green
|
||||
C_PPO = "#1f77b4" # blue
|
||||
C_DERIV = "#ff7f0e" # orange
|
||||
C_ABS = "#2ca02c" # green
|
||||
C_GEN = "#9467bd" # purple
|
||||
C_BG = "#f0f0f0"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fig 1: Illusion comparison bar chart
|
||||
# ---------------------------------------------------------------------------
|
||||
fig, ax = plt.subplots(figsize=(10, 5))
|
||||
labels = list(illusion_results.keys())
|
||||
x = np.arange(len(labels))
|
||||
w = 0.25
|
||||
|
||||
old_vals = [illusion_results[k]["old_v23"] for k in labels]
|
||||
new_vals = [illusion_results[k]["new_phase"] for k in labels]
|
||||
ppo_vals = [illusion_results[k]["ppo"] for k in labels]
|
||||
|
||||
# Convert N/A to NaN for plotting
|
||||
new_vals_plot = [v if isinstance(v, (int, float)) else 0 for v in new_vals]
|
||||
|
||||
ax.bar(x - w, old_vals, w, label="Old v23 (动作历史)", color=C_OLD, alpha=0.8)
|
||||
ax.bar(x, new_vals_plot, w, label="New phase-state (无动作历史)", color=C_NEW_PHASE, alpha=0.8)
|
||||
ax.bar(x + w, ppo_vals, w, label="PPO 基线", color=C_PPO, alpha=0.6)
|
||||
|
||||
# 1.5L label
|
||||
ax.text(x[2], 0.05, "N/A\n(bang-bang)", ha="center", va="bottom", fontsize=9, color="gray")
|
||||
|
||||
ax.set_xticks(x)
|
||||
ax.set_xticklabels(labels)
|
||||
ax.set_ylabel("闭环相似度 (DTW)")
|
||||
ax.set_title("Figure 1: Illusion 三场景 — 新路线 vs 旧版 vs PPO 基线", fontsize=12)
|
||||
ax.legend(fontsize=9)
|
||||
ax.set_ylim(0, 1.05)
|
||||
ax.grid(axis="y", alpha=0.3)
|
||||
fig.tight_layout()
|
||||
fig.savefig(os.path.join(_OUT, "fig1_illusion_comparison.png"), dpi=150)
|
||||
print("Saved fig1_illusion_comparison.png")
|
||||
plt.close(fig)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fig 2: Karman ablation bar chart
|
||||
# ---------------------------------------------------------------------------
|
||||
fig, ax = plt.subplots(figsize=(12, 4.5))
|
||||
keys = list(karman_ablation.keys())
|
||||
vals = list(karman_ablation.values())
|
||||
colors = []
|
||||
for k in keys:
|
||||
if "old" in k.lower(): colors.append(C_OLD)
|
||||
elif "abs" in k: colors.append(C_ABS)
|
||||
elif "deriv" in k: colors.append(C_DERIV)
|
||||
else: colors.append("#7f7f7f")
|
||||
|
||||
bars = ax.bar(range(len(keys)), vals, color=colors, alpha=0.85)
|
||||
ax.axhline(y=0.901, color=C_OLD, linestyle="--", alpha=0.5, label="Old v23 baseline (0.901)")
|
||||
ax.set_xticks(range(len(keys)))
|
||||
ax.set_xticklabels(keys, fontsize=8, rotation=20, ha="right")
|
||||
ax.set_ylabel("闭环相似度")
|
||||
ax.set_title("Figure 2: Karman re100 消融实验 — 输入/输出形式对比", fontsize=12)
|
||||
ax.legend(fontsize=9)
|
||||
ax.set_ylim(0, 1.0)
|
||||
ax.grid(axis="y", alpha=0.3)
|
||||
|
||||
# Add red dashed line at 0.699 highlighting best new route
|
||||
ax.axhline(y=0.699, color=C_ABS, linestyle=":", alpha=0.5)
|
||||
ax.text(5.5, 0.705, "phase+abs 最佳: 0.699", fontsize=8, color=C_ABS)
|
||||
|
||||
fig.tight_layout()
|
||||
fig.savefig(os.path.join(_OUT, "fig2_karman_ablation.png"), dpi=150)
|
||||
print("Saved fig2_karman_ablation.png")
|
||||
plt.close(fig)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fig 3: Karman generalization across Re
|
||||
# ---------------------------------------------------------------------------
|
||||
fig, ax = plt.subplots(figsize=(10, 4.5))
|
||||
re_vals = sorted(karman_gen.keys(), key=lambda s: int(s.replace("Re","")))
|
||||
sims = [karman_gen[k] for k in re_vals]
|
||||
|
||||
ax.plot(range(len(re_vals)), sims, "o-", color=C_GEN, linewidth=2, markersize=8)
|
||||
# Mark training Re
|
||||
train_re = [0, 3, 5, 7] # indices of Re50/100/200/400
|
||||
for i in train_re:
|
||||
ax.plot(i, sims[i], "o", color=C_OLD, markersize=12, markeredgecolor="black", markeredgewidth=1.5)
|
||||
ax.set_xticks(range(len(re_vals)))
|
||||
ax.set_xticklabels(re_vals)
|
||||
ax.set_ylabel("闭环相似度")
|
||||
ax.set_title("Figure 3: Karman 跨 Re 泛化 (旧 v23 模型)", fontsize=12)
|
||||
ax.set_xlabel("Reynolds Number (2D reference)")
|
||||
ax.axhline(y=0.5, color="gray", linestyle=":", alpha=0.5)
|
||||
ax.grid(axis="y", alpha=0.3)
|
||||
ax.set_ylim(0, 1.0)
|
||||
|
||||
# Annotations
|
||||
ax.annotate("训练 Re", xy=(1.8, 0.92), fontsize=9, color=C_OLD)
|
||||
ax.annotate("泛化 Re\n(未见过的)", xy=(4.5, 0.55), fontsize=9, color=C_GEN)
|
||||
|
||||
fig.tight_layout()
|
||||
fig.savefig(os.path.join(_OUT, "fig3_karman_generalization.png"), dpi=150)
|
||||
print("Saved fig3_karman_generalization.png")
|
||||
plt.close(fig)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fig 4: One-step R2 vs closed-loop scatter (diagnostic)
|
||||
# ---------------------------------------------------------------------------
|
||||
fig, ax = plt.subplots(figsize=(7, 5.5))
|
||||
|
||||
points = [
|
||||
("old v23", 0.996, 0.901, C_OLD),
|
||||
("static→deriv", 0.321, 0.745, C_DERIV),
|
||||
("full-lag→deriv", 0.939, 0.619, "#7f7f7f"),
|
||||
("phase→deriv", 0.837, 0.656, C_DERIV),
|
||||
("phase→abs", 0.965, 0.699, C_ABS),
|
||||
("expanded→abs", 0.980, 0.580, "#7f7f7f"),
|
||||
("phase+mu→abs", 0.979, 0.700, C_ABS),
|
||||
]
|
||||
for name, r2, sim, color in points:
|
||||
ax.scatter(r2, sim, s=100, color=color, zorder=5)
|
||||
ax.annotate(name, (r2, sim), textcoords="offset points", xytext=(5, 5), fontsize=8)
|
||||
|
||||
ax.set_xlabel("One-step R²")
|
||||
ax.set_ylabel("CFD 闭环相似度")
|
||||
ax.set_title("Figure 4: Karman re100 — One-step R² 与闭环不一致性", fontsize=12)
|
||||
ax.grid(alpha=0.3)
|
||||
|
||||
# Upper-left region = good closed-loop, bad one-step (static-deriv)
|
||||
# Upper-right region = good both (old v23)
|
||||
# Lower-right region = good one-step, bad closed-loop (most new methods)
|
||||
ax.annotate("稳健欠拟合", xy=(0.15, 0.85), fontsize=9, color="gray", fontstyle="italic")
|
||||
ax.annotate("分布偏移\n(训练好, 闭环差)", xy=(0.75, 0.45), fontsize=9, color="gray", fontstyle="italic")
|
||||
|
||||
fig.tight_layout()
|
||||
fig.savefig(os.path.join(_OUT, "fig4_r2_vs_closedloop.png"), dpi=150)
|
||||
print("Saved fig4_r2_vs_closedloop.png")
|
||||
plt.close(fig)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fig 5: Phase-state feature coefficients (Illusion 1L)
|
||||
# ---------------------------------------------------------------------------
|
||||
# Load the SINDy results for illusion 1L phase-state
|
||||
try:
|
||||
with open(os.path.join(_REPO, "src/SR_analysis/sindy/illusion/sindy_results_deriv.json")) as f:
|
||||
sr = json.load(f)
|
||||
per = sr["per_scene"]["illusion_1L"]
|
||||
fn_f = per["feature_names_front"]
|
||||
coef_f = per["front"]["best_coef"][:len(fn_f)]
|
||||
|
||||
fig, ax = plt.subplots(figsize=(8, 4.5))
|
||||
# Sort by |coef|
|
||||
pairs = sorted(zip(fn_f, coef_f), key=lambda p: -abs(p[1]))
|
||||
names = [p[0] for p in pairs]
|
||||
vals = [p[1] for p in pairs]
|
||||
colors_bar = [C_NEW_PHASE if v > 0 else C_OLD for v in vals]
|
||||
ax.barh(range(len(names)), vals, color=colors_bar, alpha=0.8)
|
||||
ax.set_yticks(range(len(names)))
|
||||
ax.set_yticklabels(names)
|
||||
ax.axvline(x=0, color="black", linewidth=0.5)
|
||||
ax.set_xlabel("系数值")
|
||||
ax.set_title("Figure 5: Illusion 1L Front — Phase-state 特征系数", fontsize=12)
|
||||
ax.grid(axis="x", alpha=0.3)
|
||||
fig.tight_layout()
|
||||
fig.savefig(os.path.join(_OUT, "fig5_illusion_coefficients.png"), dpi=150)
|
||||
print("Saved fig5_illusion_coefficients.png")
|
||||
plt.close(fig)
|
||||
except Exception as e:
|
||||
print(f"fig5 skipped: {e}")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fig 6: Summary timeline / roadmap
|
||||
# ---------------------------------------------------------------------------
|
||||
fig, ax = plt.subplots(figsize=(10, 3.5))
|
||||
phases = [
|
||||
("Phase 0\nBug Audit", "2026-06-12\n12 bugs\nfixed", 0.8),
|
||||
("Phase 1\nTarget fix", "2026-06-13\nIllusion target\ninfo added", 0.85),
|
||||
("Phase 2\nAblation", "2026-06-14\nPhase-state\nvalidated", 0.90),
|
||||
("Phase 2b\nOutput mode", "2026-06-15\nPhase+abs\nIllusion 0.97", 0.95),
|
||||
("Phase 3\nIllusion SR", "Next\nPySR on\n0.75L/1L", 0.7),
|
||||
]
|
||||
y_pos = 1
|
||||
for i, (label, desc, conf) in enumerate(phases):
|
||||
color = plt.cm.RdYlGn(conf)
|
||||
ax.barh(y_pos, 1, left=i, height=0.5, color=color, alpha=0.8)
|
||||
ax.text(i + 0.5, y_pos, label, ha="center", va="center", fontsize=8, fontweight="bold")
|
||||
ax.text(i + 0.5, y_pos - 0.3, desc, ha="center", va="top", fontsize=6, color="gray")
|
||||
ax.set_xlim(0, len(phases))
|
||||
ax.set_ylim(0, 2)
|
||||
ax.axis("off")
|
||||
ax.set_title("Figure 6: 研究进展路线图", fontsize=12)
|
||||
fig.tight_layout()
|
||||
fig.savefig(os.path.join(_OUT, "fig6_roadmap.png"), dpi=150)
|
||||
print("Saved fig6_roadmap.png")
|
||||
plt.close(fig)
|
||||
|
||||
print(f"\nAll figures saved to {_OUT}/")
|
||||
@ -1,42 +1,24 @@
|
||||
# SR Analysis Pipeline
|
||||
# SR_analysis Pipeline
|
||||
|
||||
> Symbolic regression pipeline for extracting interpretable DRL control laws (obs → act) from the fluidic pinball.
|
||||
> Four independent stages: inference → fitting → validation → analysis.
|
||||
> Symbolic regression pipeline for extracting interpretable DRL control laws (obs -> act)
|
||||
> from the fluidic pinball. Four independent stages: inference -> fitting -> validation -> analysis.
|
||||
|
||||
## Pipeline Architecture
|
||||
|
||||
```
|
||||
[PPO 推理] [PySR 拟合] [CFD 验证] [分析/画图]
|
||||
stage_1_infer.py stage_2_fit.py stage_3_validate.py stage_4_analyze.py
|
||||
↓ ↓ ↓ ↓
|
||||
controlled.npz formulas/*.json validations/*.json data/figures/*.png
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# 1. Generate PPO data
|
||||
conda run -n pycuda_3_10 python stage_1_infer.py --scene karman_re100 --device 2
|
||||
|
||||
# 2. Fit PySR formula
|
||||
conda run -n sr_env python stage_2_fit.py --scene karman_re100 --mode per-scene
|
||||
|
||||
# 3. Validate in CFD
|
||||
conda run -n pycuda_3_10 python stage_3_validate.py \
|
||||
--scene karman_re100 --device 2 --mode pysr \
|
||||
--formula-front results/formulas/karman_re100_front.json \
|
||||
--formula-top results/formulas/karman_re100_top.json
|
||||
|
||||
# 4. Analyze results
|
||||
conda run -n pycuda_3_10 python stage_4_analyze.py --scene karman_re100 --mode ppo-viz
|
||||
[PPO Inference] [PySR Fitting] [CFD Validation] [Analysis/Figures]
|
||||
stage_1_infer.py -> stage_2_fit.py -> stage_3_validate.py -> stage_4_analyze.py
|
||||
| | | |
|
||||
controlled.npz formulas/*.json validations/*.json figures/*.png+pdf
|
||||
target.npz FIGURE_INDEX.md
|
||||
```
|
||||
|
||||
## Environments
|
||||
|
||||
| Env | Used For |
|
||||
|-----|----------|
|
||||
| `pycuda_3_10` | Stage 1 (CFD inference), Stage 3 (CFD validation), Stage 4 (analysis) |
|
||||
| `sr_env` | Stage 2 (PySR symbolic regression) |
|
||||
| Env | Used For | Key Packages |
|
||||
|-----|----------|-------------|
|
||||
| `pycuda_3_10` | Stage 1 (CFD inference), Stage 3 (CFD validation), Stage 4 (analysis) | pycuda, numpy, matplotlib, torch, stable-baselines3 |
|
||||
| `sr_env` | Stage 2 (PySR symbolic regression) | pysr, numpy, sympy |
|
||||
|
||||
GPU: device 2 recommended (device 0 may conflict with PyTorch).
|
||||
|
||||
@ -45,100 +27,184 @@ GPU: device 2 recommended (device 0 may conflict with PyTorch).
|
||||
### Reynolds Number
|
||||
- Code Re uses reference length 2D = 40: `Re = U0 * 40 / nu`
|
||||
- Physical Re_D uses D = 20: `Re_D = Re / 2`
|
||||
- Default: Re_code=100 → Re_D=50, nu=0.004
|
||||
- Default: Re_code=100 -> Re_D=50, nu=0.004
|
||||
|
||||
### Action
|
||||
- `controlled.npz` stores actions as **normalized [-1, +1]** (not physical omega)
|
||||
- Physical omega: `omega = (action * scale + bias) * U0`, then divided by radius for angular velocity
|
||||
- Fitting target: **non-dimensional alpha = omega / U0** (not omega)
|
||||
- Physical omega: `omega = (action * scale + bias) * U0`
|
||||
- Fitting target: **non-dimensional alpha = (omega * radius) / (surface_vel * U0)**
|
||||
|
||||
### Action Bias vs FIFO Bias
|
||||
- **DRL action decoder bias**: `action * scale + bias` → physical omega. Karman: [0,-4,4], Illusion: [0,-2,2], Vortex: [0,-4,4]
|
||||
- **FIFO initialization bias** (environment warmup): different values! Illusion FIFO uses [0, -U0, U0] (1U scale), not [0, -2U0, 2U0]
|
||||
### Action Decoder Bias (scene-specific)
|
||||
| Scene | Scale | Bias |
|
||||
|-------|:-----:|------|
|
||||
| Karman | 8 | [0, -4, 4] |
|
||||
| Illusion | 8 | [0, -2, 2] |
|
||||
| Vortex | 4 | [0, -4, 4] |
|
||||
|
||||
### G-Mirror Symmetry
|
||||
- Correct: `[aF, aT, aB] -> [-aF, -aB, -aT]`
|
||||
- v23 structure: Front no-bias, rear shared-head (alpha_B = -Top composed with G)
|
||||
|
||||
### Inlet
|
||||
- Parabolic velocity profile (not uniform). Top/bottom walls are no-slip bounce-back.
|
||||
- U0 = 0.01 at centerline (lattice units)
|
||||
|
||||
### G-mirror
|
||||
- Correct: `[aF, aT, aB] → [-aF, -aB, -aT]` (not the old buggy version)
|
||||
- v23 structure: Front no-bias (α_F = 0 when features = 0), rear shared-head (α_B = -Top∘G)
|
||||
|
||||
### Norm
|
||||
- Each scene computes its own force/sensor normalization during environment initialization
|
||||
- Must use the **same norm values** during inference and during validation
|
||||
- Norm values are saved in `norm.json` in each data directory
|
||||
|
||||
### Sample Interval & Steps
|
||||
| Scene | SI | Validation Steps |
|
||||
|-------|:--:|:----------------:|
|
||||
### Sample Interval & Validation Steps
|
||||
| Scene | SI | Validation Steps (rule: >= NX/U0/SI) |
|
||||
|-------|:--:|:-------------------------------------:|
|
||||
| Karman | 800 | 160-200 |
|
||||
| Illusion 0.75L | 400 | 320 |
|
||||
| Illusion 1L | 600 | 214 |
|
||||
| Illusion 1.5L | 800 | 160 |
|
||||
| Vortex | 800 | 150 (transient) |
|
||||
|
||||
Rule: steps ≥ NX/U0/SI = 1280/0.01/SI ≈ 128000/SI
|
||||
---
|
||||
|
||||
## Results Index
|
||||
## Stage 1: PPO Inference Data Generation
|
||||
|
||||
All results indexed in [`scene_registry.json`](scene_registry.json). Canonical formulas in `results/formulas/`, CFD validations in `results/validations/`.
|
||||
Generates `controlled.npz` (sensors, forces, actions) and `target.npz` for all scenes.
|
||||
|
||||
### Canonical Formulas
|
||||
```bash
|
||||
# Karman cloak (all trained Reynolds numbers)
|
||||
for re in 50 100 200 400; do
|
||||
conda run -n pycuda_3_10 python stage_1_infer.py --scene karman_re${re} --device 2
|
||||
done
|
||||
|
||||
| Formula File | Scene | Formula |
|
||||
|-------------|-------|---------|
|
||||
| `results/formulas/karman_joint_front.json` | Karman cross-Re (joint) | `daF_dt - 14.952*mu*Cl_tot` |
|
||||
| `results/formulas/karman_joint_top.json` | Karman cross-Re (joint) | `3.414` (constant) |
|
||||
| `results/formulas/illusion_joint_front.json` | Illusion joint (0.75L+1L) | `Cd_tot - (Cd_err + 5.428) - 0.00978*(du_a_dt + u_a)` |
|
||||
| `results/formulas/illusion_joint_top.json` | Illusion joint (0.75L+1L) | `(Cd_err - (Cd_rear - Cl_err))*0.535 + 2.782` |
|
||||
# Illusion (trained diameters)
|
||||
for d in 0.75 1.0; do
|
||||
conda run -n pycuda_3_10 python stage_1_infer.py --scene illusion_${d}L --device 2
|
||||
done
|
||||
|
||||
### Key CFD Results
|
||||
# Illusion target-only (generalization diameters -- no PPO model, just target recording)
|
||||
for d in 0.5 0.6 0.8 1.2 1.5 2.0; do
|
||||
conda run -n pycuda_3_10 python stage_1_infer.py --scene illusion_${d}L --target-only --device 2
|
||||
done
|
||||
|
||||
| Scene | Formula | Similarity |
|
||||
|-------|---------|:----------:|
|
||||
| Karman cross-Re avg | Joint | 0.847 |
|
||||
| Illusion 0.75L | Joint | 0.978 |
|
||||
| Illusion 1L | Joint | 0.970 |
|
||||
| Vortex lamb | Karman joint | 0.949 (exceeds PPO 0.942) |
|
||||
| Illusion 0.6L | Joint (generalization) | 0.939 |
|
||||
| Illusion 0.8L | Joint (generalization) | 0.908 |
|
||||
| Illusion 1.2L | Joint (generalization) | 0.849 |
|
||||
| Illusion 2L | Joint (generalization) | 0.676 |
|
||||
# Vortex
|
||||
for v in lamb taylor; do
|
||||
conda run -n pycuda_3_10 python stage_1_infer.py --scene vortex_${v} --device 2
|
||||
done
|
||||
```
|
||||
|
||||
### Feature Sets
|
||||
**Output per scene** (in `data/{scene_id}/{scene_name}/`):
|
||||
- `controlled.npz`: actions [N,3], sensors [N,6], forces [N,6]
|
||||
- `target.npz`: target sensor signals [FIFO_LEN, 6]
|
||||
- `norm.json`: normalization factors
|
||||
- `result.json`: similarity + reward summary
|
||||
- `target_harmonics.json` (Illusion only): FFT harmonics for force reconstruction
|
||||
|
||||
---
|
||||
|
||||
## Stage 2: PySR Symbolic Regression Fitting
|
||||
|
||||
Runs PySR on `controlled.npz` data to discover interpretable control laws.
|
||||
|
||||
```bash
|
||||
# Illusion joint (0.75L + 1.0L) -- primary contribution
|
||||
conda run -n sr_env python stage_2_fit.py \
|
||||
--scenes illusion_0.75L,illusion_1L --mode joint --deep
|
||||
|
||||
# Karman cross-Re joint (re50-400)
|
||||
conda run -n sr_env python stage_2_fit.py \
|
||||
--scenes karman_re50,karman_re100,karman_re200,karman_re400 --mode joint --deep
|
||||
|
||||
# Per-scene individual fitting (optional, for comparison)
|
||||
conda run -n sr_env python stage_2_fit.py --scene karman_re100 --mode per-scene --deep
|
||||
conda run -n sr_env python stage_2_fit.py --scene illusion_0.75L --mode per-scene --deep
|
||||
```
|
||||
|
||||
**Feature sets**:
|
||||
| Name | Features | Dim | Used For |
|
||||
|------|----------|:---:|----------|
|
||||
| PHASE_STATE_KEYS | u_a, du_a_dt, Cl_tot, dCl_tot_dt, Cd_tot, Cd_rear | 6 | Karman per-Re |
|
||||
| ILLUSION_PHASE_KEYS | phase-state + Cd_err, Cl_err, dCd_err_dt, dCl_err_dt | 10 | Illusion |
|
||||
| PHYS_DADT | physics + daF_dt, daB_dt, daT_dt + mu | 17 | Karman joint/deep |
|
||||
| ILLUSION_PHASE | u_a, du_a/dt, Cl_tot, dCl_tot/dt, Cd_tot, Cd_rear, Cd_err, Cl_err, dCd_err/dt, dCl_err/dt | 10 | Illusion |
|
||||
| PHYS_DADT+mu | Static + daF/dt, daB/dt, daT/dt + mu terms | 17 | Karman joint |
|
||||
|
||||
## Key Documentation
|
||||
**Constraints (v23)**:
|
||||
- Front no-bias: alpha_F = 0 when features = 0
|
||||
- Rear shared-head: alpha_B = -Top composed with G-mirror
|
||||
|
||||
| File | Content |
|
||||
|------|---------|
|
||||
| `PIPELINE.md` | This file — overview, environment, conventions |
|
||||
| `sindy_sr_knowledge.md` | Bug history, confirmed facts, known limitations |
|
||||
| `sindy_sr_notes.md` | Task list, current status |
|
||||
| `STAGE_1_INFER.md` | Stage 1: PPO data generation |
|
||||
| `STAGE_2_FIT.md` | Stage 2: PySR fitting |
|
||||
| `STAGE_3_VALIDATE.md` | Stage 3: CFD validation |
|
||||
| `STAGE_4_ANALYZE.md` | Stage 4: Analysis & visualization |
|
||||
| `docs/SR_analysis_report.md` | Full report (465+ lines) |
|
||||
| `docs/illusion_joint_formula_analysis.md` | Illusion joint formula deep dive |
|
||||
**Output**: `results/formulas/{scene}_{channel}.json`
|
||||
|
||||
## Stage 0 Audit (2026-06-28)
|
||||
---
|
||||
|
||||
All imports verified in both conda environments:
|
||||
## Stage 3: CFD Closed-Loop Validation
|
||||
|
||||
| Script | pycuda_3_10 | sr_env |
|
||||
|--------|:-----------:|:------:|
|
||||
| `stage_1_infer.py` (infer_karman / infer_illusion / infer_vortex) | OK | — |
|
||||
| `stage_2_fit.py` (PySR) | — | OK |
|
||||
| `stage_3_validate.py` (closed-loop) | OK | — |
|
||||
| `stage_4_analyze.py` (analysis) | OK | — |
|
||||
| `core/features.py` (feature_builder) | OK | OK |
|
||||
| `core/cfd.py` (cfd_interface) | OK | — |
|
||||
Validates PySR formulas or PPO baselines in closed-loop CFD. This is the final arbiter.
|
||||
|
||||
No broken imports. All dependencies available.
|
||||
```bash
|
||||
# Karman cross-Re (trained + generalization)
|
||||
for re in 50 100 200 400 25 70 150 300; do
|
||||
conda run -n pycuda_3_10 python stage_3_validate.py \
|
||||
--scene karman_re${re} --device 2 --mode pysr \
|
||||
--formula-front results/formulas/karman_joint_front.json \
|
||||
--formula-top results/formulas/karman_joint_top.json
|
||||
done
|
||||
|
||||
# Illusion trained (joint formula)
|
||||
for d in 0.75 1.0; do
|
||||
conda run -n pycuda_3_10 python stage_3_validate.py \
|
||||
--scene illusion_${d}L --device 2 --mode pysr \
|
||||
--formula-front results/formulas/illusion_joint_front.json \
|
||||
--formula-top results/formulas/illusion_joint_top.json
|
||||
done
|
||||
|
||||
# Illusion generalization (joint formula on unseen diameters)
|
||||
for d in 0.5 0.6 0.8 1.2 2.0; do
|
||||
conda run -n pycuda_3_10 python stage_3_validate.py \
|
||||
--scene illusion_${d}L --device 2 --mode pysr \
|
||||
--formula-front results/formulas/illusion_joint_front.json \
|
||||
--formula-top results/formulas/illusion_joint_top.json
|
||||
done
|
||||
|
||||
# PPO baselines
|
||||
for d in 0.75 1.0 1.5; do
|
||||
conda run -n pycuda_3_10 python stage_3_validate.py \
|
||||
--scene illusion_${d}L --device 2 --mode ppo
|
||||
done
|
||||
|
||||
# Vortex generalization
|
||||
for v in lamb taylor; do
|
||||
conda run -n pycuda_3_10 python stage_3_validate.py \
|
||||
--scene vortex_${v} --device 2 --mode pysr \
|
||||
--formula-front results/formulas/karman_joint_front.json \
|
||||
--formula-top results/formulas/karman_joint_top.json
|
||||
done
|
||||
```
|
||||
|
||||
**Output**: `results/validations/{scene_name}.json` with similarity scores.
|
||||
|
||||
---
|
||||
|
||||
## Stage 4: Publication Figures
|
||||
|
||||
Generates all figures and the figure index. No CLI arguments needed.
|
||||
|
||||
```bash
|
||||
conda run -n pycuda_3_10 python stage_4_analyze.py
|
||||
```
|
||||
|
||||
**Output** (in `results/figures/`):
|
||||
- `fig_illusion_degradation.png/pdf` — Main result: cross-diameter generalization
|
||||
- `fig_karman_cross_re.png/pdf` — Karman cross-Re validation bars
|
||||
- `fig_formula_comparison.png/pdf` — Formula structure diagram
|
||||
- `fig_vortex_generalization.png/pdf` — Vortex cross-scene transfer
|
||||
- `fig_action_comparison.png/pdf` — PPO action timeseries
|
||||
- `fig_master_table.png/pdf` — Complete results table
|
||||
- `FIGURE_INDEX.md` — Figure catalog with paper-ready captions
|
||||
|
||||
---
|
||||
|
||||
## Canonical Formulas
|
||||
|
||||
| Formula File | Scene | Formula | CFD Similarity |
|
||||
|-------------|-------|---------|:---:|
|
||||
| `results/formulas/karman_joint_front.json` | Karman cross-Re | `daF_dt - 14.952*mu*Cl_tot` | avg 0.847 |
|
||||
| `results/formulas/karman_joint_top.json` | Karman cross-Re | `3.414` (constant) | — |
|
||||
| `results/formulas/illusion_joint_front.json` | Illusion joint | `Cd_tot - (Cd_err + 5.428) - (-0.00978)*(du_a_dt + u_a)` | 0.978/0.970 |
|
||||
| `results/formulas/illusion_joint_top.json` | Illusion joint | `(Cd_err - (Cd_rear - Cl_err))*0.535 + 2.782` | — |
|
||||
|
||||
## Known Limitations
|
||||
|
||||
- **Karman rear formula is constant** (alpha_T = 3.414): rear control information not fully utilized by the joint formula.
|
||||
- **Illusion 1.5L is non-fittable**: PPO policy uses high-frequency modulation (5.6x shedding frequency) that the current feature set cannot capture.
|
||||
- **daB_dt in Karman front formula** must be removed for deployment: it's a training distribution artifact (PPO trajectories have correlated front/rear actions, but rear is constant at deployment).
|
||||
|
||||
@ -1,84 +1,81 @@
|
||||
# SR_analysis: Symbolic Regression Analysis Pipeline
|
||||
# SR_analysis: Symbolic Regression for DRL Flow Control
|
||||
|
||||
Extracts interpretable control laws (`obs -> act`) from DRL-trained policies for the
|
||||
fluidic pinball. Uses **PySR symbolic regression** on dimensionless physical features with
|
||||
G-equivariant structural constraints (v23: front no-bias, rear shared-head).
|
||||
Extracts interpretable control laws (obs -> act) from DRL-trained PPO policies
|
||||
for the fluidic pinball using PySR symbolic regression. Validates all formulas
|
||||
in CFD closed-loop and produces publication-quality figures.
|
||||
|
||||
## Pipeline
|
||||
|
||||
```
|
||||
stage_1_infer.py -> stage_2_fit.py -> stage_3_validate.py -> stage_4_analyze.py
|
||||
(PPO data) (PySR formula) (CFD closed-loop) (paper figures)
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# 1. Generate PPO data
|
||||
# Generate PPO inference data
|
||||
conda run -n pycuda_3_10 python stage_1_infer.py --scene karman_re100 --device 2
|
||||
|
||||
# 2. Fit formula
|
||||
conda run -n sr_env python stage_2_fit.py --scene karman_re100 --mode per-scene
|
||||
# Fit symbolic formula
|
||||
conda run -n sr_env python stage_2_fit.py --scenes illusion_0.75L,illusion_1L --mode joint --deep
|
||||
|
||||
# 3. Validate in CFD
|
||||
conda run -n pycuda_3_10 python stage_3_validate.py \\
|
||||
--scene karman_re100 --device 2 --mode pysr \\
|
||||
--formula-front results/formulas/karman_joint_front.json \\
|
||||
--formula-top results/formulas/karman_joint_top.json
|
||||
# Validate in CFD
|
||||
conda run -n pycuda_3_10 python stage_3_validate.py \
|
||||
--scene illusion_1L --device 2 --mode pysr \
|
||||
--formula-front results/formulas/illusion_joint_front.json \
|
||||
--formula-top results/formulas/illusion_joint_top.json
|
||||
|
||||
# 4. Analyze
|
||||
conda run -n pycuda_3_10 python stage_4_analyze.py --scene karman_re100 --mode ppo-viz
|
||||
```
|
||||
|
||||
## Pipeline Architecture
|
||||
|
||||
```
|
||||
stage_1_infer.py → stage_2_fit.py → stage_3_validate.py → stage_4_analyze.py
|
||||
(PPO数据) (PySR拟合) (CFD闭环验证) (分析/画图)
|
||||
# Generate figures
|
||||
conda run -n pycuda_3_10 python stage_4_analyze.py
|
||||
```
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
SR_analysis/
|
||||
PIPELINE.md # 总览文档 (入口)
|
||||
README.md # 本文件
|
||||
sindy_sr_knowledge.md # 知识库 (bugs, 事实, 结果)
|
||||
sindy_sr_notes.md # 任务清单
|
||||
scene_registry.json # 所有场景的规范结果索引
|
||||
configs.py # 场景注册表
|
||||
core/ # 共享工具库
|
||||
features.py # 特征构建 (无量纲化+phase-state)
|
||||
fitting.py # STLSQ拟合+特征矩阵
|
||||
cfd.py # LegacyCelerisLab接口
|
||||
g_operator.py # G-mirror变换
|
||||
data/ # 运行时生成的.npz数据
|
||||
stage_1_infer.py # PPO inference (all scenes)
|
||||
stage_2_fit.py # PySR symbolic regression fitting
|
||||
stage_3_validate.py # CFD closed-loop validation
|
||||
stage_4_analyze.py # Publication figure generation
|
||||
configs.py # All scene definitions (19 scenes)
|
||||
scene_registry.json # Canonical results registry
|
||||
utils/ # Shared library (features, CFD, fitting)
|
||||
data/ # Runtime .npz data per scene
|
||||
results/
|
||||
formulas/ # 规范公式JSON
|
||||
validations/ # CFD闭环验证结果
|
||||
archive/ # 归档的中间文件
|
||||
stage_1_infer.py # Stage 1: 统一PPO推理入口
|
||||
STAGE_1_INFER.md
|
||||
stage_2_fit.py # Stage 2: 统一PySR拟合入口
|
||||
STAGE_2_FIT.md
|
||||
stage_3_validate.py # Stage 3: 统一CFD闭环验证入口
|
||||
STAGE_3_VALIDATE.md
|
||||
stage_4_analyze.py # Stage 4: 统一分析/画图入口
|
||||
STAGE_4_ANALYZE.md
|
||||
formulas/ # Canonical formula JSONs
|
||||
validations/ # CFD validation outputs
|
||||
figures/ # Publication-quality PNG/PDF
|
||||
FIGURE_INDEX.md # Figure catalog with captions
|
||||
README.md # Formula + validation index
|
||||
docs/
|
||||
SR_analysis_report.md # Full analysis report
|
||||
illusion_joint_formula_analysis.md
|
||||
PIPELINE.md # Detailed reproduction guide
|
||||
literature_note.md # Paper writing + literature positioning
|
||||
old/ # Archived historical files
|
||||
```
|
||||
|
||||
---
|
||||
## Key Results
|
||||
|
||||
## Key Design Decisions
|
||||
| Scene | Formula | CFD Similarity |
|
||||
|-------|---------|:---:|
|
||||
| Karman cross-Re (joint) | alpha_F = daF_dt - 14.95*mu*Cl_tot | avg 0.847 |
|
||||
| Illusion joint (0.75L+1L) | alpha_F = Cd_tot - Cd_err - 5.43 + 0.01*(du_a_dt+u_a) | 0.978/0.970 |
|
||||
| Vortex lamb (Karman formula) | Karman joint, zero retraining | 0.949 (exceeds PPO) |
|
||||
|
||||
1. **Feature levels**: Static (8-dim) -> Phase-state (6-dim) -> Illusion-phase (10-dim)
|
||||
2. **Output target**: Non-dimensional alpha, not physical omega
|
||||
3. **v23 structure**: Front no-bias, rear shared-head (Bottom = -Top(Gx))
|
||||
4. **Final judge**: CFD closed-loop similarity, not one-step R2
|
||||
## Environments
|
||||
|
||||
## Key Documentation
|
||||
- `pycuda_3_10`: CFD + DRL model loading + visualization (stages 1, 3, 4)
|
||||
- `sr_env`: PySR symbolic regression (stage 2)
|
||||
- GPU: device 2 recommended
|
||||
|
||||
## Documentation
|
||||
|
||||
| File | Content |
|
||||
|------|---------|
|
||||
| `PIPELINE.md` | **Primary entry** — pipeline overview, environment, conventions |
|
||||
| `sindy_sr_knowledge.md` | Bug history, confirmed facts, known limitations |
|
||||
| `sindy_sr_notes.md` | Task list, current status |
|
||||
| `docs/SR_analysis_report.md` | Full report (465+ lines) |
|
||||
| `docs/illusion_joint_formula_analysis.md` | Illusion joint formula deep dive |
|
||||
|
||||
## Core Files (≤20)
|
||||
|
||||
`stage_1_infer.py`, `stage_2_fit.py`, `stage_3_validate.py`, `stage_4_analyze.py`, `configs.py`, `scene_registry.json`, `core/features.py`, `core/fitting.py`, `core/cfd.py`, `core/g_operator.py` + 8 docs.
|
||||
| `PIPELINE.md` | Full reproduction guide with all commands |
|
||||
| `docs/SR_analysis_report.md` | Complete methodology + results + discussion |
|
||||
| `results/figures/FIGURE_INDEX.md` | All figures with paper-ready captions |
|
||||
| `literature_note.md` | Literature positioning + writing guidance |
|
||||
| `results/README.md` | Formula + validation file index |
|
||||
|
||||
@ -16,7 +16,7 @@ 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")
|
||||
LEGACY_CFG_DIR = os.path.join(_PROJ, "..", "configs", "legacy_configs")
|
||||
|
||||
# -- Physics constants -------------------------------------------------------
|
||||
U0 = 0.01 # default inlet velocity (lattice)
|
||||
|
||||
@ -1,403 +0,0 @@
|
||||
"""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)
|
||||
# CRITICAL: save DDF again AFTER bias FIFO, so restore_ddf() goes
|
||||
# to the post-bias state (consistent with saved FIFO).
|
||||
# Without this, reset() restores to a bare stabilized state with
|
||||
# no bias history, invalidating the FIFO.
|
||||
flow_field.get_ddf()
|
||||
flow_field.save_ddf()
|
||||
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=0) - np.gradient(ux, axis=1)
|
||||
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
|
||||
@ -1,386 +0,0 @@
|
||||
"""Unified feature builder for all cloak scenes.
|
||||
|
||||
Produces dimensionless features with consistent G-equivariant structure.
|
||||
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
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
# -- Physical constants ------------------------------------------------------
|
||||
U0 = 0.01 # inlet velocity (lattice units)
|
||||
D_CYL = 20.0 # cylinder diameter (lattice)
|
||||
|
||||
|
||||
# -- Dimensionless conversion ------------------------------------------------
|
||||
|
||||
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 [f0_fx,f0_fy, f1_fx,f1_fy, f2_fx,f2_fy]
|
||||
u0: float = U0,
|
||||
d: float = D_CYL,
|
||||
rho: float = 1.0,
|
||||
) -> Dict[str, np.ndarray]:
|
||||
"""Convert raw lattice CFD data to dimensionless quantities.
|
||||
|
||||
Sensor order: [s0_ux,s0_uy, s1_ux,s1_uy, s2_ux,s2_uy]
|
||||
where s0=top(y=+2L0), s1=mid(y=0), s2=bottom(y=-2L0)
|
||||
Force order: [front_fx,front_fy, bottom_fx,bottom_fy, top_fx,top_fy]
|
||||
|
||||
Returns:
|
||||
u_hat_B, u_hat_C, u_hat_T: nondim streamwise velocity (bottom/centre/top)
|
||||
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
|
||||
"""
|
||||
s = np.asarray(sensors, dtype=np.float64)
|
||||
f = np.asarray(forces, dtype=np.float64)
|
||||
|
||||
# Sensor positions: s0=top, s1=centre, s2=bottom
|
||||
# Convention: B=bottom=s2, C=centre=s1, T=top=s0
|
||||
return {
|
||||
"u_hat_T": s[:, 0] / u0,
|
||||
"v_hat_T": s[:, 1] / u0,
|
||||
"u_hat_C": s[:, 2] / u0,
|
||||
"v_hat_C": s[:, 3] / u0,
|
||||
"u_hat_B": s[:, 4] / u0,
|
||||
"v_hat_B": s[:, 5] / u0,
|
||||
"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),
|
||||
"Cl_B": 2.0 * f[:, 3] / (rho * u0**2 * d),
|
||||
"Cd_T": 2.0 * f[:, 4] / (rho * u0**2 * d),
|
||||
"Cl_T": 2.0 * f[:, 5] / (rho * u0**2 * d),
|
||||
}
|
||||
|
||||
|
||||
# -- G operator (corrected) --------------------------------------------------
|
||||
|
||||
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_x(dim: Dict[str, np.ndarray],
|
||||
a_prev: np.ndarray,
|
||||
a_prev2: np.ndarray) -> Tuple[Dict, np.ndarray, np.ndarray]:
|
||||
"""Apply G to dimensionless state.
|
||||
|
||||
Returns (G_dim, G_a_prev, G_a_prev2) with corrected sign rules.
|
||||
"""
|
||||
G_dim = {
|
||||
"u_hat_B": dim["u_hat_T"], "u_hat_C": dim["u_hat_C"], "u_hat_T": dim["u_hat_B"],
|
||||
"v_hat_B": -dim["v_hat_T"], "v_hat_C": -dim["v_hat_C"], "v_hat_T": -dim["v_hat_B"],
|
||||
"Cd_F": dim["Cd_F"], "Cd_T": dim["Cd_B"], "Cd_B": dim["Cd_T"],
|
||||
"Cl_F": -dim["Cl_F"], "Cl_T": -dim["Cl_B"], "Cl_B": -dim["Cl_T"],
|
||||
}
|
||||
G_a_prev = np.column_stack([-a_prev[:, 0], -a_prev[:, 2], -a_prev[:, 1]])
|
||||
G_a_prev2 = np.column_stack([-a_prev2[:, 0], -a_prev2[:, 2], -a_prev2[:, 1]])
|
||||
return G_dim, G_a_prev, G_a_prev2
|
||||
|
||||
|
||||
# -- Feature key definitions -------------------------------------------------
|
||||
|
||||
# Original feature set (includes sin_ua/cos_ua)
|
||||
CORE_FEAT_KEYS = [
|
||||
"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",
|
||||
]
|
||||
|
||||
# V2 core features: no sin_ua/cos_ua, no mu (for single-scene fitting)
|
||||
CORE_FEAT_KEYS_V2 = [
|
||||
"u_m", "u_a", "u_c",
|
||||
"v_a",
|
||||
"Cd_tot", "Cd_rear",
|
||||
"Cl_tot", "Cl_diff",
|
||||
"aF_lag1", "aB_lag1", "aT_lag1",
|
||||
"daF", "daB", "daT",
|
||||
]
|
||||
|
||||
# Time-explicit features: da/dt_c (for cross-scene coefficient comparison)
|
||||
TIME_FEAT_KEYS = [
|
||||
"daF_dt", "daB_dt", "daT_dt",
|
||||
]
|
||||
|
||||
MU_FEAT_KEYS = ["mu", "mu_u_a", "mu_v_a", "mu_Cd_tot", "mu_Cl_diff"]
|
||||
|
||||
# Illusion target force features (for scenes where we know the target Cd/Cl)
|
||||
ILLUSION_TARGET_KEYS = ["target_Cd", "target_Cl"]
|
||||
ILLUSION_FEAT_KEYS_V2 = CORE_FEAT_KEYS_V2 + ILLUSION_TARGET_KEYS
|
||||
ILLUSION_ALL_FEAT_KEYS_V2 = ILLUSION_FEAT_KEYS_V2 + MU_FEAT_KEYS # with mu for joint
|
||||
|
||||
# Physics-only feature keys: NO action history terms (no aF_lag1, no daF, etc.)
|
||||
# These are the clean inputs for learning d(alpha)/dt as a function of physics state.
|
||||
PHYSICS_FEAT_KEYS = [
|
||||
"u_m", "u_a", "u_c", "v_a",
|
||||
"Cd_tot", "Cd_rear", "Cl_tot", "Cl_diff",
|
||||
]
|
||||
|
||||
# Illusion error-state: physics features + force error (not raw target forces)
|
||||
ILLUSION_ERR_KEYS = PHYSICS_FEAT_KEYS + ["Cd_err", "Cl_err"]
|
||||
|
||||
# Observation lag (1-step delayed) and derivative (time-normalized) features
|
||||
# These add temporal/phasing information to the otherwise static physics features.
|
||||
LAG_FEAT_KEYS = [
|
||||
"u_m_lag1", "u_a_lag1", "u_c_lag1", "v_a_lag1",
|
||||
"Cd_tot_lag1", "Cd_rear_lag1", "Cl_tot_lag1", "Cl_diff_lag1",
|
||||
]
|
||||
DERIV_FEAT_KEYS = [
|
||||
"du_m_dt", "du_a_dt", "du_c_dt", "dv_a_dt",
|
||||
"dCd_tot_dt", "dCd_rear_dt", "dCl_tot_dt", "dCl_diff_dt",
|
||||
]
|
||||
# Action lag (for ablation level 4 — comparing against old approach)
|
||||
ACTION_LAG_KEYS = ["aF_lag1", "aB_lag1", "aT_lag1"]
|
||||
|
||||
# Augmented levels for ablation study:
|
||||
# Level 0 = x_n (PHYSICS_FEAT_KEYS, no memory at all)
|
||||
# Level 1 = x_n + x_{n-1} (current + 1-step lag)
|
||||
# Level 2 = x_n + dx/dt (current + derivative)
|
||||
# Level 3 = x_n + x_{n-1} + dx/dt (full temporal context)
|
||||
# Level 4 = Level 3 + a_{n-1} (add action history for comparison)
|
||||
AUG_LEVEL_1_KEYS = PHYSICS_FEAT_KEYS + LAG_FEAT_KEYS
|
||||
AUG_LEVEL_2_KEYS = PHYSICS_FEAT_KEYS + DERIV_FEAT_KEYS
|
||||
AUG_LEVEL_3_KEYS = PHYSICS_FEAT_KEYS + LAG_FEAT_KEYS + DERIV_FEAT_KEYS
|
||||
AUG_LEVEL_4_KEYS = AUG_LEVEL_3_KEYS + ACTION_LAG_KEYS
|
||||
|
||||
# Illusion equivalents with error-state
|
||||
ILLUSION_LAG_KEYS = [
|
||||
"Cd_err_lag1", "Cl_err_lag1",
|
||||
]
|
||||
ILLUSION_DERIV_KEYS = [
|
||||
"dCd_err_dt", "dCl_err_dt",
|
||||
]
|
||||
ILLUSION_AUG_LEVEL_1_KEYS = ILLUSION_ERR_KEYS + LAG_FEAT_KEYS + ILLUSION_LAG_KEYS
|
||||
ILLUSION_AUG_LEVEL_2_KEYS = ILLUSION_ERR_KEYS + DERIV_FEAT_KEYS + ILLUSION_DERIV_KEYS
|
||||
ILLUSION_AUG_LEVEL_3_KEYS = ILLUSION_AUG_LEVEL_1_KEYS + DERIV_FEAT_KEYS + ILLUSION_DERIV_KEYS
|
||||
ILLUSION_AUG_LEVEL_4_KEYS = ILLUSION_AUG_LEVEL_3_KEYS + ACTION_LAG_KEYS
|
||||
|
||||
# Phase-state features: low-dimensional dynamic state z = [phase_obs, phase_deriv, static_force]
|
||||
# This replaces the full x_n + x_{n-1} with a compact representation.
|
||||
# The idea: u_a + du_a/dt encodes oscillation phase, Cl_tot + dCl_tot/dt encodes lift dynamics,
|
||||
# and Cd_tot/Cd_rear provide static force feedback.
|
||||
PHASE_STATE_KEYS = [
|
||||
"u_a", "du_a_dt", # oscillation phase (cross-stream asymmetry + rate)
|
||||
"Cl_tot", "dCl_tot_dt", # lift dynamics
|
||||
"Cd_tot", "Cd_rear", # static drag feedback
|
||||
]
|
||||
|
||||
# Karman expanded: phase-state + supplementary static quantities
|
||||
# For Karman, 6-dim phase-state may not capture enough information.
|
||||
# Adding u_m (mean streamwise), u_c (center sensor), v_a (cross asymmetry), Cl_diff (lift distribution)
|
||||
KARMAN_EXPANDED_KEYS = PHASE_STATE_KEYS + [
|
||||
"u_m", "u_c", "v_a", "Cl_diff",
|
||||
]
|
||||
|
||||
# Illusion phase-state: error-based + dynamics
|
||||
ILLUSION_PHASE_KEYS = PHASE_STATE_KEYS + [
|
||||
"Cd_err", "Cl_err", "dCd_err_dt", "dCl_err_dt",
|
||||
]
|
||||
|
||||
ALL_FEAT_KEYS = CORE_FEAT_KEYS + MU_FEAT_KEYS
|
||||
# V2 all features (for cross-Re joint fitting with mu)
|
||||
ALL_FEAT_KEYS_V2 = CORE_FEAT_KEYS_V2 + MU_FEAT_KEYS
|
||||
|
||||
|
||||
# -- Feature computation -----------------------------------------------------
|
||||
|
||||
def compute_features(
|
||||
dim: Dict[str, np.ndarray],
|
||||
actions_prev: np.ndarray, # (T, 3) physical omega(t-1) or nondim alpha(t-1)
|
||||
actions_prev2: np.ndarray, # (T, 3) physical omega(t-2)
|
||||
mu: float,
|
||||
alpha_mode: bool = False, # if True, actions_prev are already nondim alpha
|
||||
include_mu: bool = True,
|
||||
include_cos_sin: bool = True, # if True, include sin_ua/cos_ua features
|
||||
dt_c: float = 1.0, # control interval in T0 units (for time normalization)
|
||||
u0: float = U0, # inlet velocity for omega->alpha conversion
|
||||
target_forces: Optional[np.ndarray] = None, # (T, 2) raw lattice target forces [fx,fy]
|
||||
rho: float = 1.0, # fluid density for Cd/Cl conversion
|
||||
sensors_raw: Optional[np.ndarray] = None, # (T, 6) raw lattice sensors, for obs dynamics
|
||||
forces_raw: Optional[np.ndarray] = None, # (T, 6) raw lattice forces, for obs dynamics
|
||||
) -> Dict[str, np.ndarray]:
|
||||
"""Compute unified feature dictionary from dimensionless primitives.
|
||||
|
||||
Args:
|
||||
dim: from compute_dimensionless()
|
||||
actions_prev: lagged actions (physical omega or nondim alpha)
|
||||
actions_prev2: twice-lagged actions
|
||||
mu: 1/Re_D
|
||||
alpha_mode: if True, actions are already nondim; else convert
|
||||
include_mu: include mu modulation terms
|
||||
include_cos_sin: include sin_ua/cos_ua phase encoding
|
||||
dt_c: control interval (in T0 = D/U0 units), for time-normalized deltas
|
||||
u0: inlet velocity (lattice), used only when alpha_mode=False
|
||||
|
||||
Returns dict with all features as (T,) or (T,3) arrays.
|
||||
"""
|
||||
T = actions_prev.shape[0]
|
||||
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"]
|
||||
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"]
|
||||
|
||||
# If actions are in physical omega, convert to nondim alpha
|
||||
if alpha_mode:
|
||||
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
|
||||
|
||||
sym = {}
|
||||
|
||||
# Sensor combinations (nondim)
|
||||
sym["u_m"] = (u_B + u_C + u_T) / 3.0
|
||||
sym["u_a"] = (u_T - u_B) / 2.0
|
||||
sym["u_c"] = u_C.copy()
|
||||
sym["v_a"] = (v_T - v_B) / 2.0
|
||||
|
||||
# Force combinations (dimensionless Cd/Cl)
|
||||
sym["Cd_tot"] = Cd_F + Cd_T + Cd_B
|
||||
sym["Cd_rear"] = Cd_T + Cd_B
|
||||
sym["Cl_tot"] = Cl_F + Cl_T + Cl_B
|
||||
sym["Cl_diff"] = Cl_T - Cl_B
|
||||
|
||||
# Phase (optional, may obscure linear structure)
|
||||
if include_cos_sin:
|
||||
sym["sin_ua"] = np.sin(np.pi * sym["u_a"])
|
||||
sym["cos_ua"] = np.cos(np.pi * sym["u_a"])
|
||||
|
||||
# Memory (nondim alpha) -- discrete version
|
||||
sym["aF_lag1"] = a[:, 0]
|
||||
sym["aB_lag1"] = a[:, 1]
|
||||
sym["aT_lag1"] = a[:, 2]
|
||||
sym["daF"] = a[:, 0] - a2[:, 0]
|
||||
sym["daB"] = a[:, 1] - a2[:, 1]
|
||||
sym["daT"] = a[:, 2] - a2[:, 2]
|
||||
|
||||
# Time-normalized deltas (for cross-scene coefficient comparison)
|
||||
sym["daF_dt"] = sym["daF"] / dt_c
|
||||
sym["daB_dt"] = sym["daB"] / dt_c
|
||||
sym["daT_dt"] = sym["daT"] / dt_c
|
||||
|
||||
# Target forces (for illusion scenes) — convert lattice forces to Cd/Cl
|
||||
if target_forces is not None:
|
||||
tf = np.asarray(target_forces, dtype=np.float64)
|
||||
if tf.ndim == 1:
|
||||
tf = tf.reshape(1, -1)
|
||||
sym["target_Cd"] = 2.0 * tf[:, 0] / (rho * u0**2 * D_CYL)
|
||||
sym["target_Cl"] = 2.0 * tf[:, 1] / (rho * u0**2 * D_CYL)
|
||||
# Error-state: deviation between actual and target force
|
||||
sym["Cd_err"] = sym["Cd_tot"] - sym["target_Cd"]
|
||||
sym["Cl_err"] = sym["Cl_tot"] - sym["target_Cl"]
|
||||
|
||||
# Mu modulation
|
||||
if include_mu:
|
||||
sym["mu"] = np.full(T, mu, dtype=np.float64)
|
||||
sym["mu_u_a"] = sym["u_a"] * mu
|
||||
sym["mu_v_a"] = sym["v_a"] * mu
|
||||
sym["mu_Cd_tot"] = sym["Cd_tot"] * mu
|
||||
sym["mu_Cl_diff"] = sym["Cl_diff"] * mu
|
||||
sym["mu_Cl_tot"] = sym["Cl_tot"] * mu # additional for phase-state
|
||||
|
||||
# Observation dynamics: 1-step lag + time-normalized derivative
|
||||
# These add temporal/phase info that static features lack.
|
||||
if sensors_raw is not None and forces_raw is not None:
|
||||
sr = np.asarray(sensors_raw, dtype=np.float64)
|
||||
fr = np.asarray(forces_raw, dtype=np.float64)
|
||||
# Lag-1 observations (shift by 1, pad first with current)
|
||||
s_lag1 = np.zeros_like(sr)
|
||||
f_lag1 = np.zeros_like(fr)
|
||||
s_lag1[1:] = sr[:-1]
|
||||
f_lag1[1:] = fr[:-1]
|
||||
# Compute dim for lag-1
|
||||
dim_lag1 = compute_dimensionless(s_lag1, f_lag1, u0=u0, d=D_CYL, rho=rho)
|
||||
|
||||
# Compute combined features for lag-1
|
||||
uB_1, uC_1, uT_1 = dim_lag1["u_hat_B"], dim_lag1["u_hat_C"], dim_lag1["u_hat_T"]
|
||||
vB_1, vC_1, vT_1 = dim_lag1["v_hat_B"], dim_lag1["v_hat_C"], dim_lag1["v_hat_T"]
|
||||
CdF_1, CdT_1, CdB_1 = dim_lag1["Cd_F"], dim_lag1["Cd_T"], dim_lag1["Cd_B"]
|
||||
ClF_1, ClT_1, ClB_1 = dim_lag1["Cl_F"], dim_lag1["Cl_T"], dim_lag1["Cl_B"]
|
||||
|
||||
u_m_1 = (uB_1 + uC_1 + uT_1) / 3.0
|
||||
u_a_1 = (uT_1 - uB_1) / 2.0
|
||||
u_c_1 = uC_1.copy()
|
||||
v_a_1 = (vT_1 - vB_1) / 2.0
|
||||
Cd_tot_1 = CdF_1 + CdT_1 + CdB_1
|
||||
Cd_rear_1 = CdT_1 + CdB_1
|
||||
Cl_tot_1 = ClF_1 + ClT_1 + ClB_1
|
||||
Cl_diff_1 = ClT_1 - ClB_1
|
||||
|
||||
# Trim to T (in case raw arrays are longer — e.g. validator passes 2 rows)
|
||||
def _trim(x):
|
||||
return x[-T:] if x.shape[0] > T else x
|
||||
|
||||
# Store lag-1 features (trimmed to T)
|
||||
sym["u_m_lag1"] = _trim(u_m_1)
|
||||
sym["u_a_lag1"] = _trim(u_a_1)
|
||||
sym["u_c_lag1"] = _trim(u_c_1)
|
||||
sym["v_a_lag1"] = _trim(v_a_1)
|
||||
sym["Cd_tot_lag1"] = _trim(Cd_tot_1)
|
||||
sym["Cd_rear_lag1"] = _trim(Cd_rear_1)
|
||||
sym["Cl_tot_lag1"] = _trim(Cl_tot_1)
|
||||
sym["Cl_diff_lag1"] = _trim(Cl_diff_1)
|
||||
|
||||
# Time-normalized derivatives: (current - lag1) / dt_c
|
||||
eps = 1e-12
|
||||
sym["du_m_dt"] = _trim((sym["u_m"] - u_m_1) / (dt_c + eps))
|
||||
sym["du_a_dt"] = _trim((sym["u_a"] - u_a_1) / (dt_c + eps))
|
||||
sym["du_c_dt"] = _trim((sym["u_c"] - u_c_1) / (dt_c + eps))
|
||||
sym["dv_a_dt"] = _trim((sym["v_a"] - v_a_1) / (dt_c + eps))
|
||||
sym["dCd_tot_dt"] = _trim((sym["Cd_tot"] - Cd_tot_1) / (dt_c + eps))
|
||||
sym["dCd_rear_dt"] = _trim((sym["Cd_rear"] - Cd_rear_1) / (dt_c + eps))
|
||||
sym["dCl_tot_dt"] = _trim((sym["Cl_tot"] - Cl_tot_1) / (dt_c + eps))
|
||||
sym["dCl_diff_dt"] = _trim((sym["Cl_diff"] - Cl_diff_1) / (dt_c + eps))
|
||||
|
||||
# Illusion error-state dynamics
|
||||
if target_forces is not None:
|
||||
tf = np.asarray(target_forces, dtype=np.float64)
|
||||
tf_lag1 = np.zeros_like(tf)
|
||||
tf_lag1[1:] = tf[:-1]
|
||||
tCd_1 = 2.0 * tf_lag1[:, 0] / (rho * u0**2 * D_CYL)
|
||||
tCl_1 = 2.0 * tf_lag1[:, 1] / (rho * u0**2 * D_CYL)
|
||||
sym["Cd_err_lag1"] = _trim(Cd_tot_1 - tCd_1)
|
||||
sym["Cl_err_lag1"] = _trim(Cl_tot_1 - tCl_1)
|
||||
sym["dCd_err_dt"] = _trim((sym["Cd_err"] - sym["Cd_err_lag1"]) / (dt_c + eps))
|
||||
sym["dCl_err_dt"] = _trim((sym["Cl_err"] - sym["Cl_err_lag1"]) / (dt_c + eps))
|
||||
|
||||
return sym
|
||||
|
||||
|
||||
def build_feature_matrix(
|
||||
sym: Dict[str, np.ndarray],
|
||||
feat_keys: List[str],
|
||||
add_bias: bool = True,
|
||||
) -> np.ndarray:
|
||||
"""Build feature matrix (T, N) from symbol dict."""
|
||||
cols = []
|
||||
if add_bias:
|
||||
cols.append(np.ones(sym[feat_keys[0]].shape[0], dtype=np.float64))
|
||||
for k in feat_keys:
|
||||
if k in sym:
|
||||
cols.append(np.asarray(sym[k], dtype=np.float64))
|
||||
else:
|
||||
# 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)
|
||||
|
||||
|
||||
def get_feature_names(feat_keys: List[str], add_bias: bool = True) -> List[str]:
|
||||
"""Get feature names matching build_feature_matrix output."""
|
||||
names = []
|
||||
if add_bias:
|
||||
names.append("bias")
|
||||
names.extend(feat_keys)
|
||||
return names
|
||||
@ -1,503 +0,0 @@
|
||||
"""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)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Weighted STLSQ with Huber-like robust regression
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _huber_weights(residuals: np.ndarray, c: float = 1.345) -> np.ndarray:
|
||||
"""Compute Huber-like weights from residuals.
|
||||
|
||||
Args:
|
||||
residuals: (T,) residual array.
|
||||
c: tuning constant (default 1.345 gives 95% efficiency for Normal errors).
|
||||
|
||||
Returns:
|
||||
weights: (T,) weight array in [0, 1].
|
||||
"""
|
||||
s = np.median(np.abs(residuals)) * 1.4826 # robust scale estimate (MAD)
|
||||
if s < 1e-12:
|
||||
s = 1.0
|
||||
r = np.abs(residuals) / s
|
||||
w = np.where(r <= c, 1.0, c / r)
|
||||
return np.asarray(w, dtype=np.float64)
|
||||
|
||||
|
||||
def fit_sindy_weighted(
|
||||
Theta: np.ndarray,
|
||||
y: np.ndarray,
|
||||
thresholds: Optional[List[float]] = None,
|
||||
alpha: float = 1e-4,
|
||||
max_iter: int = 25,
|
||||
sample_weights: Optional[np.ndarray] = None,
|
||||
n_robust_passes: int = 2,
|
||||
) -> List[dict]:
|
||||
"""Run SINDy with threshold grid and optional robust weighting.
|
||||
|
||||
Two-stage robust fitting:
|
||||
1. First pass: OLS, compute residuals, compute Huber weights
|
||||
2. Second pass: weighted STLSQ with Huber weights
|
||||
|
||||
Args:
|
||||
Theta: (T, N) feature matrix.
|
||||
y: (T,) target.
|
||||
thresholds: list of threshold values.
|
||||
alpha: ridge regularization.
|
||||
max_iter: max STLSQ iterations.
|
||||
sample_weights: optional (T,) pre-defined sample weights.
|
||||
n_robust_passes: number of robust re-weighting passes (1 = skip).
|
||||
|
||||
Returns:
|
||||
results: list of dict per threshold with keys:
|
||||
threshold, nz, r2, mae, coef, weights_used
|
||||
"""
|
||||
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
|
||||
|
||||
# Initialize weights
|
||||
if sample_weights is not None:
|
||||
w = np.asarray(sample_weights, dtype=np.float64).flatten()
|
||||
w = w / np.mean(w) # normalize to mean=1
|
||||
else:
|
||||
w = np.ones(Theta.shape[0], dtype=np.float64)
|
||||
|
||||
# Robust re-weighting passes
|
||||
for _ in range(n_robust_passes - 1):
|
||||
# OLS on weighted data
|
||||
Theta_w = Theta_s * np.sqrt(w)[:, None]
|
||||
y_w = y * np.sqrt(w)
|
||||
coef_ols, _, _, _ = np.linalg.lstsq(Theta_w, y_w, rcond=None)
|
||||
coef_ols = coef_ols.flatten() / std
|
||||
|
||||
resid = y - Theta @ coef_ols
|
||||
w_new = _huber_weights(resid)
|
||||
if sample_weights is not None:
|
||||
w = w * w_new
|
||||
else:
|
||||
w = w_new
|
||||
w = w / np.mean(w)
|
||||
|
||||
results = []
|
||||
for th in thresholds:
|
||||
if np.max(w) > 1e-8:
|
||||
# Weighted STLSQ: apply weights via sample_weight
|
||||
opt = ps.STLSQ(threshold=th, alpha=alpha, max_iter=max_iter)
|
||||
opt.fit(Theta_s, y, sample_weight=w)
|
||||
coef = np.asarray(opt.coef_, dtype=np.float64).flatten() / std
|
||||
else:
|
||||
coef = np.zeros(Theta.shape[1], dtype=np.float64)
|
||||
|
||||
y_pred = Theta @ coef
|
||||
# Weighted R2
|
||||
ssr = float(np.sum(w * (y - y_pred) ** 2))
|
||||
sst = float(np.sum(w * (y - np.average(y, weights=w)) ** 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],
|
||||
"weights_min": float(np.min(w)),
|
||||
"weights_max": float(np.max(w)),
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# V2 feature matrix builder (configurable feature sets, time normalization)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def get_feature_matrix_v2(
|
||||
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 = False, # default False for single-scene
|
||||
include_cos_sin: bool = False, # default False (avoid masking linear structure)
|
||||
use_time_norm: bool = False, # if True, use time-normalized deltas (da/dt_c)
|
||||
feat_keys: Optional[List[str]] = None, # custom feat keys (default CORE_FEAT_KEYS_V2)
|
||||
dt_c: float = 1.0, # control interval in T0 units
|
||||
n_warmup: int = 2,
|
||||
target_forces: Optional[np.ndarray] = None, # (T, 2) raw lattice target forces
|
||||
) -> Tuple[np.ndarray, np.ndarray, np.ndarray, List[str], List[str]]:
|
||||
"""Build feature matrices with configurable feature sets.
|
||||
|
||||
Uses CORE_FEAT_KEYS_V2 by default (no sin_ua/cos_ua, no mu).
|
||||
For Illusion scenes with target forces, provide target_forces and
|
||||
feat_keys=ILLUSION_FEAT_KEYS_V2 (or pass None to auto-detect).
|
||||
For cross-Re joint fitting, set include_mu=True.
|
||||
For time-normalized deltas, set use_time_norm=True and provide dt_c.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Theta_front : (T-warmup, N_front) NO bias column
|
||||
Theta_rear : (T-warmup, N_rear) WITH bias column
|
||||
Y : (T-warmup, 3) target actions (physical omega)
|
||||
feat_names_front, feat_names_rear
|
||||
"""
|
||||
from .feature_builder import (
|
||||
CORE_FEAT_KEYS_V2, TIME_FEAT_KEYS, MU_FEAT_KEYS, ALL_FEAT_KEYS_V2,
|
||||
ILLUSION_FEAT_KEYS_V2, ILLUSION_ALL_FEAT_KEYS_V2,
|
||||
)
|
||||
|
||||
if feat_keys is None:
|
||||
if target_forces is not None:
|
||||
# Illusion scenes: include target force features
|
||||
feat_keys = ILLUSION_FEAT_KEYS_V2
|
||||
elif use_time_norm:
|
||||
# Replace discrete da with time-normalized da/dt
|
||||
feat_keys = [k for k in CORE_FEAT_KEYS_V2 if not k.startswith("da")]
|
||||
feat_keys += TIME_FEAT_KEYS
|
||||
else:
|
||||
feat_keys = CORE_FEAT_KEYS_V2
|
||||
|
||||
if include_mu:
|
||||
# Add mu features if not already in feat_keys
|
||||
for mk in MU_FEAT_KEYS:
|
||||
if mk not in feat_keys:
|
||||
feat_keys = feat_keys + [mk]
|
||||
|
||||
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,
|
||||
include_cos_sin=include_cos_sin,
|
||||
dt_c=dt_c,
|
||||
u0=u0,
|
||||
target_forces=target_forces,
|
||||
)
|
||||
|
||||
Theta_f = build_feature_matrix(sym, feat_keys, add_bias=False)
|
||||
Theta_r = build_feature_matrix(sym, feat_keys, add_bias=True)
|
||||
|
||||
feat_names_front = get_feature_names(feat_keys, add_bias=False)
|
||||
feat_names_rear = get_feature_names(feat_keys, add_bias=True)
|
||||
|
||||
return (Theta_f[n_warmup:], Theta_r[n_warmup:],
|
||||
actions_phys[n_warmup:],
|
||||
feat_names_front, feat_names_rear)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Derivative-mode feature builders: fit d(alpha)/dt = g(physics_state)
|
||||
# No action history in input features; Y is time-normalized action derivative.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def compute_action_deriv(
|
||||
actions_phys: np.ndarray, # (T, 3) physical omega
|
||||
dt_c: float, # control interval in T0 units
|
||||
u0: float = U0,
|
||||
center_diff: bool = False, # if True, use (alpha(t) - alpha(t-2))/(2*dt_c)
|
||||
) -> np.ndarray:
|
||||
"""Compute time-normalized action derivative d(alpha)/dt.
|
||||
|
||||
forward_diff: d(alpha)/dt ~ (alpha(t) - alpha(t-1)) / dt_c
|
||||
center_diff: d(alpha)/dt ~ (alpha(t) - alpha(t-2)) / (2*dt_c)
|
||||
|
||||
Returns (T, 3) array, with first row(s) zero-padded.
|
||||
"""
|
||||
alpha = np.asarray(actions_phys, dtype=np.float64) / u0 # non-dim
|
||||
T = alpha.shape[0]
|
||||
deriv = np.zeros_like(alpha)
|
||||
if center_diff and T >= 3:
|
||||
deriv[2:] = (alpha[2:] - alpha[:-2]) / (2.0 * dt_c)
|
||||
elif T >= 2:
|
||||
deriv[1:] = (alpha[1:] - alpha[:-1]) / dt_c
|
||||
return deriv
|
||||
|
||||
|
||||
def get_feature_matrix_deriv(
|
||||
sensors: np.ndarray, # (T, 6)
|
||||
forces: np.ndarray, # (T, 6)
|
||||
actions_phys: np.ndarray, # (T, 3) physical omega
|
||||
mu: float,
|
||||
u0: float = U0,
|
||||
dt_c: float = 1.0, # control interval in T0 units
|
||||
feat_keys: Optional[List[str]] = None, # default PHYSICS_FEAT_KEYS
|
||||
include_mu: bool = False, # add mu modulation features (for cross-Re joint)
|
||||
target_forces: Optional[np.ndarray] = None, # (T, 2) for Illusion
|
||||
n_warmup: int = 2,
|
||||
center_diff: bool = False,
|
||||
augment_level: int = 0, # 0=static, 1=+lags, 2=+derivs, 3=both, 4=+action_lag
|
||||
output_mode: str = "deriv", # "deriv": predict d(alpha)/dt; "absolute": predict alpha directly
|
||||
) -> Tuple[np.ndarray, np.ndarray, np.ndarray, List[str], List[str]]:
|
||||
"""Build feature matrices for phase-state SINDy.
|
||||
|
||||
Input features: physics state with optional temporal context.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
output_mode : str
|
||||
"deriv": Y = d(alpha)/dt (time-normalized). Closed-loop needs integration.
|
||||
"absolute": Y = alpha (non-dimensional action). No integration needed.
|
||||
|
||||
augment_level : int
|
||||
0: PHYSICS_FEAT_KEYS only (static, no memory)
|
||||
1: + lag-1 obs features (adds temporal context)
|
||||
2: + obs derivative features (adds rate information)
|
||||
3: + lag-1 + derivative (full temporal context)
|
||||
4: + action lag (aF_lag1 etc., for ablation comparison against old approach)
|
||||
"""
|
||||
from .feature_builder import (
|
||||
PHYSICS_FEAT_KEYS, ILLUSION_ERR_KEYS, MU_FEAT_KEYS,
|
||||
AUG_LEVEL_1_KEYS, AUG_LEVEL_2_KEYS,
|
||||
AUG_LEVEL_3_KEYS, AUG_LEVEL_4_KEYS,
|
||||
ILLUSION_AUG_LEVEL_1_KEYS, ILLUSION_AUG_LEVEL_2_KEYS,
|
||||
ILLUSION_AUG_LEVEL_3_KEYS, ILLUSION_AUG_LEVEL_4_KEYS,
|
||||
)
|
||||
|
||||
# Select feature key set based on augment_level
|
||||
_AUG_MAP = {0: PHYSICS_FEAT_KEYS, 1: AUG_LEVEL_1_KEYS,
|
||||
2: AUG_LEVEL_2_KEYS, 3: AUG_LEVEL_3_KEYS, 4: AUG_LEVEL_4_KEYS}
|
||||
_AUG_ILLUSION_MAP = {0: ILLUSION_ERR_KEYS, 1: ILLUSION_AUG_LEVEL_1_KEYS,
|
||||
2: ILLUSION_AUG_LEVEL_2_KEYS, 3: ILLUSION_AUG_LEVEL_3_KEYS,
|
||||
4: ILLUSION_AUG_LEVEL_4_KEYS}
|
||||
|
||||
if feat_keys is None:
|
||||
if target_forces is not None:
|
||||
feat_keys = _AUG_ILLUSION_MAP.get(augment_level, ILLUSION_AUG_LEVEL_3_KEYS)
|
||||
else:
|
||||
feat_keys = _AUG_MAP.get(augment_level, AUG_LEVEL_3_KEYS)
|
||||
|
||||
if include_mu:
|
||||
for mk in MU_FEAT_KEYS:
|
||||
if mk not in feat_keys:
|
||||
feat_keys = feat_keys + [mk]
|
||||
|
||||
if include_mu:
|
||||
for mk in MU_FEAT_KEYS:
|
||||
if mk not in feat_keys:
|
||||
feat_keys = feat_keys + [mk]
|
||||
|
||||
T = sensors.shape[0]
|
||||
# a_prev/a_prev2 only used for computing the DERIVATIVE target Y, not in Theta
|
||||
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=False, include_mu=include_mu,
|
||||
include_cos_sin=False, dt_c=dt_c, u0=u0,
|
||||
target_forces=target_forces,
|
||||
sensors_raw=sensors, forces_raw=forces,
|
||||
)
|
||||
|
||||
Theta_f = build_feature_matrix(sym, feat_keys, add_bias=False)
|
||||
Theta_r = build_feature_matrix(sym, feat_keys, add_bias=True)
|
||||
|
||||
feat_names_front = get_feature_names(feat_keys, add_bias=False)
|
||||
feat_names_rear = get_feature_names(feat_keys, add_bias=True)
|
||||
|
||||
# Y: depends on output_mode
|
||||
if output_mode == "absolute":
|
||||
Y = np.asarray(actions_phys, dtype=np.float64) / u0 # non-dim alpha (absolute action)
|
||||
else:
|
||||
Y = compute_action_deriv(actions_phys, dt_c, u0=u0, center_diff=center_diff)
|
||||
|
||||
return (Theta_f[n_warmup:], Theta_r[n_warmup:],
|
||||
Y[n_warmup:],
|
||||
feat_names_front, feat_names_rear)
|
||||
@ -1,191 +0,0 @@
|
||||
"""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,
|
||||
}
|
||||
97
src/SR_analysis/literature_note.md
Normal file
@ -0,0 +1,97 @@
|
||||
# SR 文献定位与写作草稿
|
||||
|
||||
当前 SR 部分最稳的文献定位,不是宣称“首次把符号回归用于流动控制”,而是把本工作放在三条已有传统的交叉处:一是流动控制中的显式机器学习控制律,主要来自 genetic programming / linear genetic programming;二是流体中的稀疏动力学与传感器低维建模,主要来自 SINDy 与 sparse ROM;三是 DRL 流动控制的黑箱策略学习。[Gau14, Li16, Loi16, Loi17, Rab18b, Par20, Cas22, Zol25] 当前工作的真正新意更接近于:**对已训练 DRL 策略做后验白箱提取,并在闭环 CFD 中验证其跨场景骨架与适用边界**,而不是单纯“搜索一个显式控制律”。[Cas22, Zol25]
|
||||
|
||||
## 一组最该引用的核心文献
|
||||
|
||||
| 角色 | 文献 | 期刊 | 当前用途 |
|
||||
|---|---|---|---|
|
||||
| 最接近的方法学邻居 | [Zol25] | Nature Communications | 解释 SINDy-RL 如何把稀疏模型、奖励与策略放进同一可解释 RL 框架;用于说明“可解释 RL + 流动控制”已有高水平先例,但其重点更偏 model-based RL,而不是对已训练 PPO 做后验蒸馏 |
|
||||
| 显式闭环控制律先例 | [Gau14] | Journal of Fluid Mechanics | 证明流动控制中可以直接得到符号化、可解释的闭环控制律;适合作为“白箱控制律并非空白”的经典前史 |
|
||||
| MIMO 显式控制律先例 | [Li16] | Experiments in Fluids | 说明多传感器、多执行器条件下,linear genetic programming 可以自动做传感器选择并产生显式反馈律 |
|
||||
| DRL 与可解释控制律的直接对照 | [Cas22] | Physics of Fluids | 最适合用来强调:DRL 更鲁棒,但 LGPC 更紧凑、更可解释,是本工作立题最直接的动机 |
|
||||
| 稀疏流体建模方法学基础 | [Loi16] | Journal of Fluid Mechanics | 说明 SINDy 类方法在流体中首先成功的是受物理约束的低维动力学识别,而非直接控制律提取 |
|
||||
| 传感器到低维状态的关键桥梁 | [Loi17] | Journal of Fluid Mechanics | 说明传感器特征、稀疏动态状态和 coherent structures 可以被连起来;是后续接 CCD/OID 的方法论支撑 |
|
||||
| DRL 流动控制应用背景 | [Rab18b] | Journal of Fluid Mechanics | 作为 DRL 做 wake/cylinder flow control 的奠基背景,突出其高性能但黑箱 |
|
||||
| DRL 鲁棒性与少传感器背景 | [Par20] | Journal of Fluid Mechanics | 用来说明黑箱 DRL 在 sensor sparsity、robustness、Re variation 上已有成熟结果,但策略本身仍不透明 |
|
||||
|
||||
## 论文里可采用的文献分组写法
|
||||
|
||||
### 1. 显式机器学习控制律的前史
|
||||
|
||||
这一组文献说明,流动控制领域早已有“让机器直接给出显式控制律”的传统,但主要路线不是 DRL 后验蒸馏,而是 genetic programming / machine learning control。最经典的例子是 backward-facing step 分离控制 [Gau14],它通过 genetic programming 直接搜索闭环控制律,并从结果中识别出与回流泡低频摆动相关的新控制机制。Ahmed body 拖曳控制的 linear genetic programming 工作 [Li16] 则进一步表明,在 MIMO 场景中,显式控制律还能自动做传感器筛选。更近的 cylinder wake 对比研究 [Cas22] 直接指出:DRL 在噪声和初值扰动下更稳,而 LGPC 更容易得到紧凑、可解释的控制律。[Gau14, Li16, Cas22]
|
||||
|
||||
这一组文献在写法上最适合承担两件事:
|
||||
|
||||
- 证明“显式白箱控制律”在流动控制里已有扎实先例,不是凭空提出的新口号。[Gau14, Li16]
|
||||
- 同时说明这些工作大多是**直接搜索控制律**,而不是对一个已成功的 DRL 策略做后验结构提取,因此与当前工作的技术问题并不相同。[Cas22]
|
||||
|
||||
### 2. 稀疏建模与低维状态识别的主线
|
||||
|
||||
SINDy 在流体中的核心成功先例主要不是控制律,而是低维动力学和 reduced-order models。受约束 sparse Galerkin regression [Loi16] 说明,在物理约束下做稀疏动力学识别可以得到比纯黑箱回归更可信的 ROM。Loiseau 等人的 sensor-based sparse ROM 工作 [Loi17] 更接近当前任务,因为它明确从传感器出发,构建动态特征状态,并把稀疏动力学和 coherent structures 联系起来。这一脉络非常适合用来支撑本文中的一个关键论点:**动作历史之所以常被模型滥用,往往是因为状态表示还不充分;真正的目标不是记住动作,而是找到足够的低维状态。** [Loi16, Loi17]
|
||||
|
||||
这一组文献在论文中应作为方法论背景,而不是写成“他们已经做过本文想做的事”。更稳的表述是:
|
||||
|
||||
- 这些工作证明了稀疏建模在流体低维表示中的价值。[Loi16, Loi17]
|
||||
- 但它们主要识别的是植物模型或状态动力学,而不是 `obs -> act` 的显式反馈律。[Loi17]
|
||||
|
||||
### 3. 黑箱 DRL 流动控制的应用背景
|
||||
|
||||
Rabault 等人的 cylinder wake DRL 工作 [Rab18b] 是最经典的“DRL 能自己发现有效流动控制策略”的证明之一,但作者也明确指出策略本身难以直接解释。Paris 等人的后续工作 [Par20] 则把焦点放到鲁棒性、传感器布置和 practical implementation 上,说明黑箱 DRL 已经可以在 Reynolds 数变化和传感器噪声下保持性能。它们的意义在于提供本文的应用背景:**黑箱策略已经有效,但解释与压缩仍是缺口。** [Rab18b, Par20]
|
||||
|
||||
### 4. 最接近本文的“可解释 RL”文献
|
||||
|
||||
SINDy-RL [Zol25] 是当前最重要的方法学邻居。它将稀疏动力学、符号奖励与策略表示纳入同一 RL 框架,并包含流动控制例子。最需要写清的一点是:这篇工作虽然也涉及 symbolic policy,但其核心更偏 **model-based RL + symbolic surrogate learning**,而不是对一个已经训练好的 DRL 控制器做后验蒸馏与闭环验证。这个区别很重要,因为本文的贡献恰恰落在后者:从已成功的 PPO 控制策略中提炼低维、可执行、可比较的显式控制律。[Zol25]
|
||||
|
||||
## 当前工作与现有文献最稳的关系定位
|
||||
|
||||
当前工作最稳的 literature positioning 可以写成下面三点:
|
||||
|
||||
- **不是**首次在流动控制中寻找显式控制律,因为 genetic programming / LGPC 的机器学习控制工作早已做到这一点。[Gau14, Li16, Cas22]
|
||||
- **不是**首次把 SINDy 与 RL 联系起来,因为 SINDy-RL 已经给出了高水平先例。[Zol25]
|
||||
- **更可能是**首次或少见地把“已训练 DRL 策略的后验白箱提取”作为流动控制核心问题,并把结果放到闭环 CFD、跨场景 shared backbone 与 regime boundary 的框架里系统比较。[Cas22, Zol25]
|
||||
|
||||
这个口径比“首次使用 SR”更稳,也更容易经得起审稿。
|
||||
|
||||
## 对当前 SR 章节最值得参考的写法
|
||||
|
||||
### 可直接借用的写作角度
|
||||
|
||||
| 角度 | 适合参考的文献 | 可借的写法 |
|
||||
|---|---|---|
|
||||
| 黑箱 vs 白箱张力 | [Cas22, Rab18b, Par20] | 先承认 DRL 性能与鲁棒性,再指出策略不可解释,顺势引出 white-box distillation 的必要性 |
|
||||
| 显式控制律的先例 | [Gau14, Li16] | 把 symbolic / GP 控制律写成流动控制中的既有传统,而非本文特有发明 |
|
||||
| 稀疏状态而非动作记忆 | [Loi17] | 强调应优先找足够的低维状态与特征,而不是让动作历史充当隐藏状态代理 |
|
||||
| 物理约束与模型可信度 | [Loi16] | 论证为何闭环可运行、符合结构约束、具有物理一致性的公式比单纯拟合优度更重要 |
|
||||
| 可解释 RL 邻近工作 | [Zol25] | 对比“联合学习 symbolic model/policy”与“后验蒸馏已训练 PPO policy”的区别 |
|
||||
|
||||
### 当前 SR 章节最稳的主叙事
|
||||
|
||||
比较稳的叙事不是“找到一个统一总公式”,而是:
|
||||
|
||||
1. 黑箱 DRL 在流动控制中已能取得高性能,但其策略结构通常不透明。[Rab18b, Par20]
|
||||
2. 流动控制中已有显式机器学习控制律的传统,尤其来自 genetic programming / LGPC,但其与 DRL 后验蒸馏并不等同。[Gau14, Li16, Cas22]
|
||||
3. 稀疏建模和传感器低维状态识别为白箱策略提取提供了方法学基础。[Loi16, Loi17]
|
||||
4. 本文的工作重点是在已训练 DRL 策略之上,提取可闭环执行、可比较、可分析 shared-backbone 与 regime split 的显式控制律。[Zol25]
|
||||
|
||||
## 哪些表述要避免
|
||||
|
||||
- 不要写“本文首次将符号回归用于流动控制”。这会直接被 [Gau14]、[Li16]、[Cas22] 否定。
|
||||
- 不要写“本文首次将 SINDy 用于 RL 可解释化”。这会被 [Zol25] 否定。
|
||||
- 不要把 [Loi16]、[Loi17] 写成直接的控制律提取先例,它们主要是低维动力学识别,不是 `obs -> act` policy distillation。
|
||||
- 不要把所有 symbolic / sparse literature 混成同一类。最好明确区分“控制律搜索”“动力学识别”“黑箱 DRL 背景”三条线。[Gau14, Loi16, Rab18b]
|
||||
|
||||
## 当前最建议进正文的引用组合
|
||||
|
||||
如果正文篇幅有限,SR 章节最核心的一组文献可压缩为:
|
||||
|
||||
- **显式控制律先例**:[Gau14, Li16, Cas22]
|
||||
- **稀疏方法学基础**:[Loi16, Loi17]
|
||||
- **DRL 背景**:[Rab18b, Par20]
|
||||
- **最接近的可解释 RL 邻居**:[Zol25]
|
||||
|
||||
这一组已经足够支撑 SR 部分的立题、方法定位与新意边界。
|
||||
|
||||
## 当前文稿草稿可直接使用的一段话
|
||||
|
||||
Machine-learning-based flow control already has two partially separate traditions. One seeks explicit feedback laws directly, most notably through genetic programming or linear genetic programming, and has shown that compact symbolic controllers can be discovered for separation control, drag reduction, and wake manipulation [Gau14, Li16, Cas22]. The other uses deep reinforcement learning to discover effective but largely opaque policies in canonical flow-control benchmarks [Rab18b, Par20]. In parallel, sparse-regression methods such as SINDy have provided a strong basis for identifying reduced-order fluid dynamics and sensor-based low-dimensional state representations, but these works generally target plant modeling rather than direct extraction of closed-loop feedback laws [Loi16, Loi17]. The closest recent bridge is SINDy-RL, which combines sparse model discovery and reinforcement learning within a model-based framework [Zol25]. The present work is best positioned as post hoc white-box extraction of low-dimensional control laws from successful DRL policies, with CFD closed-loop validation and cross-scene comparison as the central tests.
|
||||
|
Before Width: | Height: | Size: 144 KiB After Width: | Height: | Size: 144 KiB |
|
Before Width: | Height: | Size: 143 KiB After Width: | Height: | Size: 143 KiB |
|
Before Width: | Height: | Size: 139 KiB After Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 142 KiB After Width: | Height: | Size: 142 KiB |
|
Before Width: | Height: | Size: 145 KiB After Width: | Height: | Size: 145 KiB |
|
Before Width: | Height: | Size: 145 KiB After Width: | Height: | Size: 145 KiB |
|
Before Width: | Height: | Size: 142 KiB After Width: | Height: | Size: 142 KiB |
|
Before Width: | Height: | Size: 141 KiB After Width: | Height: | Size: 141 KiB |
|
Before Width: | Height: | Size: 141 KiB After Width: | Height: | Size: 141 KiB |