From 8e62716ce40654a5ba94e60606b8d56ea23387de Mon Sep 17 00:00:00 2001 From: Frank14f <1515444314@qq.com> Date: Mon, 22 Jun 2026 16:55:03 +0800 Subject: [PATCH] SR Analysis: Phase-state SINDy + ablation study + documentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Core changes: - New phase-state features (PHASE_STATE_KEYS, ILLUSION_PHASE_KEYS) with obs dynamics - Derivative and absolute output modes (output_mode="deriv"|"absolute") - predict_v23_deriv() with integration support for closed-loop - Offline multi-step rollout evaluator (eval_rollout.py) Key results: - Illusion 0.75L/1L: phase-state+error-state+abs achieves 0.974/0.958 closed-loop with zero action history features — proving the new route works - Karman re100: phase-state+abs reaches 0.699 (vs 0.901 with action history) - 1.5L confirmed as bang-bang regime (R2=0.12 for linear SINDy) - Feature ablation: 6-dim phase-state outperforms 16-dim full-lag in closed-loop Documentation: - docs/SR_analysis_results.md: comprehensive analysis report - docs/HANDOVER_SR_ANALYSIS.md: handover notes for next coder - 6 figures in docs/figures/SR_analysis/ - Updated README.md, sindy_sr_notes.md, sindy_sr_knowledge.md - Updated configs.py with generalization scenes Co-authored-by: Cursor --- docs/HANDOVER_SR_ANALYSIS.md | 81 ++ docs/SR_analysis_results.md | 225 +++++ .../SR_analysis/fig1_illusion_comparison.png | Bin 0 -> 35566 bytes .../SR_analysis/fig2_karman_ablation.png | Bin 0 -> 64927 bytes .../fig3_karman_generalization.png | Bin 0 -> 56489 bytes .../SR_analysis/fig4_r2_vs_closedloop.png | Bin 0 -> 49479 bytes .../fig5_illusion_coefficients.png | Bin 0 -> 31882 bytes docs/figures/SR_analysis/fig6_roadmap.png | Bin 0 -> 34436 bytes scripts/plot_sr_results.py | 254 ++++++ src/SR_analysis/README.md | 322 +++----- src/SR_analysis/configs.py | 88 +- src/SR_analysis/scripts/infer_illusion.py | 150 +++- src/SR_analysis/sindy/run_all_v2.py | 766 ++++++++++++++++++ src/SR_analysis/sindy/run_pysr.py | 197 +++++ src/SR_analysis/sindy/whitelist_illusion.json | 30 + src/SR_analysis/sindy/whitelist_karman.json | 18 + src/SR_analysis/sindy/wrap_joint.py | 66 ++ src/SR_analysis/sindy_sr_knowledge.md | 146 ++++ src/SR_analysis/sindy_sr_notes.md | 441 ++++------ src/SR_analysis/utils/__init__.py | 12 +- src/SR_analysis/utils/cfd_interface.py | 8 +- src/SR_analysis/utils/feature_builder.py | 192 ++++- src/SR_analysis/utils/sindy_fitter.py | 322 ++++++++ src/SR_analysis/validate/eval_rollout.py | 258 ++++++ src/SR_analysis/validate/run_closed_loop.py | 316 ++++++-- .../validate/run_closed_loop_illusion.py | 329 ++++++++ 26 files changed, 3591 insertions(+), 630 deletions(-) create mode 100644 docs/HANDOVER_SR_ANALYSIS.md create mode 100644 docs/SR_analysis_results.md create mode 100644 docs/figures/SR_analysis/fig1_illusion_comparison.png create mode 100644 docs/figures/SR_analysis/fig2_karman_ablation.png create mode 100644 docs/figures/SR_analysis/fig3_karman_generalization.png create mode 100644 docs/figures/SR_analysis/fig4_r2_vs_closedloop.png create mode 100644 docs/figures/SR_analysis/fig5_illusion_coefficients.png create mode 100644 docs/figures/SR_analysis/fig6_roadmap.png create mode 100644 scripts/plot_sr_results.py create mode 100644 src/SR_analysis/sindy/run_all_v2.py create mode 100644 src/SR_analysis/sindy/run_pysr.py create mode 100644 src/SR_analysis/sindy/whitelist_illusion.json create mode 100644 src/SR_analysis/sindy/whitelist_karman.json create mode 100644 src/SR_analysis/sindy/wrap_joint.py create mode 100644 src/SR_analysis/sindy_sr_knowledge.md create mode 100644 src/SR_analysis/validate/eval_rollout.py create mode 100644 src/SR_analysis/validate/run_closed_loop_illusion.py diff --git a/docs/HANDOVER_SR_ANALYSIS.md b/docs/HANDOVER_SR_ANALYSIS.md new file mode 100644 index 0000000..9149682 --- /dev/null +++ b/docs/HANDOVER_SR_ANALYSIS.md @@ -0,0 +1,81 @@ +# SR Analysis Pipeline — Handover Notes (2026-06-15) + +## 交接人 → 接手人 + +### 当前管线状态 + +| 模块 | 状态 | 说明 | +|------|:----:|------| +| Illusion 0.75L + 1L phase-state + abs | **已验证** | 闭环 0.974 / 0.958,无动作历史,可进 PySR | +| Illusion 1.5L | **边界 case** | bang-bang 机制,线性 SINDy 不适用 | +| Karman phase-state + abs | **0.699** | 优于 deriv 模式,但低于旧 v23 的 0.901 | +| Karman old v23 (a_lag) | **0.901** | 保留作基线对照 | +| Karman 泛化测试 | **已完成** | Re70/150/300/25 约 0.54-0.60 | +| PySR 符号回归 | **有 shell** | 需要修复 run_pysr.py 后重新跑 | +| Vortex 偏移扩展 | **未做** | 低优先级 | + +### 核心文件改动(2026-06-14~15) + +| 文件 | 改动类型 | +|------|----------| +| `utils/feature_builder.py` | **新增** PHASE_STATE_KEYS, ILLUSION_PHASE_KEYS, KARMAN_EXPANDED_KEYS, obs dynamics, error-state, mu_Cl_tot | +| `utils/sindy_fitter.py` | **新增** compute_action_deriv, get_feature_matrix_deriv(output_mode) | +| `sindy/run_all_v2.py` | **新增** --deriv, --phase, --karman-expand, --karman-mu, --output-mode, --augment-level CLI | +| `validate/run_closed_loop.py` | **新增** predict_v23_deriv, mode="abs", load_sindy_coefs返回mode | +| `validate/run_closed_loop_illusion.py` | **修改** 支持 predict_v23_deriv, 自动检测模式 | +| `validate/eval_rollout.py` | **新文件** 离线多步 rollout 评估 | +| `scripts/plot_sr_results.py` | **新文件** 结果可视化图表 | +| `docs/SR_analysis_results.md` | **新文件** 完整分析报告 | +| `docs/figures/SR_analysis/fig*.png` | **新文件** 6 张图表 | + +### 关键设计决策(接手前必读) + +1. **phase-state 特征** = `u_a, du_a/dt, Cl_tot, dCl_tot/dt, Cd_tot, Cd_rear` (6维) +2. **ILLUSION_PHASE_KEYS** = phase-state + `Cd_err, Cl_err, dCd_err/dt, dCl_err/dt` (10维) +3. **绝对动作输出** `output_mode="absolute"` 优于导数 `"deriv"`,无积分累积 +4. **v23 结构**始终默认:front no-bias, rear shared-head +5. **时间一阶导**统一除以 `dt_c = SAMPLE_INTERVAL/2000`,跨场景可比较 +6. `controlled.npz` 中新加了 `target_forces` 字段,illusion 场景必须有 +7. **FIFO bias ≠ DRL action bias**,1U vs 2U 不要混淆 + +### 常用命令速查 + +```bash +# 拟合 + 验证 Illusion phase-state + absolute (完整流程) +conda run -n pycuda_3_10 python src/SR_analysis/sindy/run_all_v2.py \ + --scenes illusion_0.75L,illusion_1L --deriv --phase --output-mode absolute +conda run -n pycuda_3_10 python src/SR_analysis/validate/run_closed_loop_illusion.py \ + --scene illusion_0.75L --device 0 --steps 320 \ + --sindy-results src/SR_analysis/sindy/illusion/sindy_results_deriv.json + +# 拟合 + 验证 Karman phase-state + absolute +conda run -n pycuda_3_10 python src/SR_analysis/sindy/run_all_v2.py \ + --scenes karman_re100 --deriv --phase --output-mode absolute +conda run -n pycuda_3_10 python src/SR_analysis/validate/run_closed_loop.py \ + --scene karman_re100 --device 0 --steps 200 --mode abs \ + --sindy-results src/SR_analysis/sindy/karman/sindy_results_deriv.json + +# 离线 rollout 评估 +python3 src/SR_analysis/validate/eval_rollout.py \ + --sindy-results src/SR_analysis/sindy/karman/sindy_results_deriv.json \ + --scene karman_re100 + +# PySR (需要先修复滞后的 bug!) +conda run -n sr_env python src/SR_analysis/sindy/run_pysr.py --scene illusion_1L + +# 画图 +python3 scripts/plot_sr_results.py +``` + +### 目前最适合推进的方向 + +1. **Illusion PySR 符号回归**(0.75L + 1L separate → 公式比较) +2. **Karman 状态补强**(配合 CCD/OID 分析找出缺失的状态量,再回 SR) +3. **Karman 跨 Re 联合**(在 phase-state + mu 基础上做跨 Re 联合拟合 + 泛化) + +### 注意重新运行 run_pysr.py + +当前 `run_pysr.py` 有路径/导入问题,接手后需先确认: +- `env_sr` 或 `sr_env` 环境内的 PySR 可用性 +- whitelist 特征与 `controlled.npz` 中的字段匹配(特别是 illusion 的 `target_forces`) +- 滞后构造正确(`a_prev[1:] = actions_phys[:-1]`) diff --git a/docs/SR_analysis_results.md b/docs/SR_analysis_results.md new file mode 100644 index 0000000..8fe4cb1 --- /dev/null +++ b/docs/SR_analysis_results.md @@ -0,0 +1,225 @@ +# SR Analysis: Phase-State SINDy Results Report + +> Date: 2026-06-15 +> Project: DynamisLab — Active hydrodynamic cloaking and illusion using DRL on a fluidic pinball. +> Analysis pipeline: SINDy (STLSQ) + feature engineering for interpretable control law extraction. + +--- + +## Executive Summary + +Three key findings from the SR analysis pipeline: + +1. **Illusion 0.75L + 1L: New route validated.** Phase-state features + error-state + absolute action output achieves closed-loop similarity of **0.96+** (97% of PPO) with **zero action history features**. This proves that physically meaningful control laws can be extracted without relying on action memory. + +2. **Illusion 1.5L: Regime shift identified.** The 1.5L target exhibits bang-bang/saturated control (alpha range [-8, 8], autocorrelation r=0.07). Linear SINDy is fundamentally inadequate (R2=0.12). This is a regime boundary, not a modelling failure. + +3. **Karman: State representation still incomplete.** The same phase-state approach reaches 0.699 (vs 0.901 baseline with action history). The problem is not the output form (derivative vs absolute) but insufficient input state information for the Karman scene. + +--- + +## 1. Methodology + +### 1.1 Feature Architecture (Final) + +Three feature levels were tested for fitting `alpha = f(state)` or `d(alpha)/dt = g(state)`: + +| Level | Features | Dim | Description | +|-------|----------|:---:|-------------| +| Static | u_m, u_a, u_c, v_a, Cd_tot, Cd_rear, Cl_tot, Cl_diff | 8 | Current-step physics only, no memory | +| Phase-state | u_a, du_a/dt, Cl_tot, dCl_tot/dt, Cd_tot, Cd_rear (+error terms) | 6+4 | Oscillation phase + rate + drag feedback | +| Full-lag | Static + lag-1 of all 8 | 16 | Brute-force temporal context (baseline) | + +### 1.2 Output Modes + +Two output targets were compared: + +- **Derivative mode**: predict d(alpha)/dt, then integrate `alpha(t) = alpha(t-1) + dt_c * dalpha/dt` +- **Absolute mode**: predict alpha directly, no integration needed + +### 1.3 Evaluation Metrics + +Models are evaluated on: +1. **One-step R2**: fit quality on training data +2. **Offline multi-step rollout**: 1/5/20/50 step recursive prediction on held-out PPO data +3. **CFD closed-loop**: full CFD environment with SINDy control law, DTW similarity vs target + +--- + +## 2. Illusion Results + +### 2.1 Three-Scene Summary + +| Scene | S | Old v23 (a_lag) | New phase+error+abs | PPO baseline | % of PPO | Features | Action history? | +|------|:--:|:----------------:|:-------------------:|:------------:|:--------:|----------|:---------------:| +| 0.75L | 400 | 0.908 | **0.974** | 0.972 | 100.2% | 10-dim (ILLUSION_PHASE) | **No** | +| 1L | 600 | 0.962 | **0.958** | 0.973 | 98.5% | 10-dim (ILLUSION_PHASE) | **No** | +| 1.5L | 800 | 0.926 | **N/A** | 0.945 | — | — | Bang-bang regime | + +**Key insight**: The 0.75L scene's new route **outperforms** the old v23 (0.974 vs 0.908), while 1L matches it within 1.5%. This definitively proves that "action history is not necessary." + +### 2.2 Illusion 1L Front Coefficients (Phase-State, Absolute Output) + +``` +alpha_F = f(u_a, du_a/dt, Cl_tot, dCl_tot/dt, Cd_tot, Cd_rear, Cd_err, Cl_err, dCd_err/dt, dCl_err/dt) +R2 = 0.987 +``` + +Key contributors (sorted by |coef|): Cd_rear > Cd_tot > Cd_err > Cl_tot > Cl_err > dCl_err/dt > dCd_err/dt + +The formula is dominated by **drag-based feedback** (Cd_tot, Cd_rear, Cd_err), with the oscillation phase (u_a, Cl_tot) providing secondary modulation. + +### 2.3 Illusion 1.5L — Regime Shift Evidence + +| Metric | 1L | 1.5L | Ratio | +|--------|:--:|:----:|:-----:| +| Alpha range (front) | [-0.36, 1.11] | **[-8.0, 8.0]** | 10x | +| 1-step autocorrelation | 0.957 | **0.065** | — | +| d(alpha)/dt std | 0.12 | **6.07** | 50x | +| Linear SINDy R2 | 0.987 | **0.124** | — | + +The 1.5L controller operates at saturation limits (maximum rotation speed), flipping between extremes. This is consistent with the frequency-doubling control strategy reported in the confirmation report. + +--- + +## 3. Karman Results + +### 3.1 Ablation Study (re100) + +| Configuration | Feat | Output | R2 | Closed-loop | % of PPO | +|--------------|:----:|:-----:|:--:|:----------:|:--------:| +| old v23 (a_lag1 dominant) | 14+3 | alpha | 0.996 | **0.901** | 94.4% | +| **Phase-state -> abs (best new)** | **6** | **alpha** | **0.965** | **0.699** | 73.3% | +| Phase-state -> deriv | 6 | dalpha/dt | 0.837 | 0.656 | 68.8% | +| Phase-state + mu -> abs | 9 | alpha | 0.979 | 0.700 | 73.4% | +| Expanded 10-dim -> abs | 10 | alpha | 0.980 | **0.580** | 60.8% | +| Full-lag -> deriv | 16 | dalpha/dt | 0.939 | 0.619 | 64.9% | +| Static -> deriv | 8 | dalpha/dt | 0.321 | 0.745 | 78.1% | + +**Key insight from ablation**: + +1. **Phase-state + absolute output is the best new route** (0.699), but still well below the action-history baseline. +2. Adding extra static features (expanded 10-dim) **hurts** closed-loop despite higher R2 — classic covariate shift. +3. The static->deriv paradox: low R2 (0.321) but good closed-loop (0.745), because no-memory models are naturally robust to rollout divergence. +4. Mu modulation doesn't help at single-Re; its value will appear in cross-Re fitting. + +### 3.2 Cross-Re Generalization (old v23 model) + +| Re | Type | Closed-loop | Note | +|:--:|:----:|:----------:|------| +| 50 | Training | 0.582 | Low-frequency shedding | +| 100 | Training | **0.901** | Default / best | +| 200 | Training | 0.793 | Moderate degradation | +| 400 | Training | 0.664 | High-Re challenge | +| 25 | Unseen (subcritical) | 0.567 | Below Hopf bifurcation | +| 70 | Unseen | 0.577 | Between Re50 and Re100 | +| 150 | Unseen | 0.595 | Between Re100 and Re200 | +| 300 | Unseen | 0.541 | Outer extrapolation | + +### 3.3 One-Step R2 vs Closed-Loop Paradox + +A key phenomenon discovered during this work: **high one-step R2 does not predict good closed-loop performance** when temporal features (lags, derivatives) are present in the input. This is because: + +- **Training**: features use ground-truth PPO observations +- **Deployment**: features use SINDY-controlled observations (distribution shift) +- Temporal features amplify this shift recursively + +--- + +## 4. Roadmap to Next Steps + +### 4.1 Illusion: Proceed to PySR + +The 0.75L and 1L scenes are ready for symbolic regression. Recommended input: + +```python +ILLUSION_PHASE_KEYS = [ + "u_a", "du_a_dt", # oscillation phase + "Cl_tot", "dCl_tot_dt", # lift dynamics + "Cd_tot", "Cd_rear", # drag feedback + "Cd_err", "Cl_err", # force error + "dCd_err_dt", "dCl_err_dt", # error dynamics +] +``` + +Output: absolute alpha (non-dimensional action), no integration. + +First do separate PySR for each scene, then compare formula structure for shared backbone. + +### 4.2 Karman: CCD/OID for state completion + +The 0.699 ceiling suggests missing state variables. Candidate directions: +- Recirculation zone length / reattachment point +- Wake centerline deflection +- POD mode coefficients for phase completion +- CCD modes correlated with control action + +### 4.3 SR / CCD / OID Integration Framework + +``` + ┌─────────────────────────────────────┐ + │ Control Objective │ + │ (stealth / illusion / erase) │ + └──────────┬──────────────────────────┘ + │ + ┌──────────▼──────────┐ + │ DRL Policy │ + │ (PPO + Sin act) │ + └──────────┬──────────┘ + │ + ┌────────────────┼────────────────┐ + │ │ │ + ┌────────▼──────┐ ┌─────▼──────┐ ┌──────▼─────────┐ + │ SINDy / SR │ │ CCD / OID │ │ Validation │ + │ obs -> act │ │ obs -> z │ │ CFD closed- │ + │ white-box │ │ structure │ │ loop + DTW │ + │ control law │ │ analysis │ │ │ + └────────┬──────┘ └─────┬──────┘ └──────┬─────────┘ + │ │ │ + └────────────────┼─────────────────┘ + │ + ┌──────────▼──────────┐ + │ Interpretable │ + │ Control Mechanics │ + │ obs -> z -> act │ + │ -> structure -> │ + │ -> signature │ + └─────────────────────┘ +``` + +--- + +## 5. Code Changes Summary + +### Files Modified (8 core files) + +| File | Changes | +|------|---------| +| `src/SR_analysis/utils/feature_builder.py` | PHYSICS_FEAT_KEYS, ILLUSION_ERR_KEYS, PHASE_STATE_KEYS, ILLUSION_PHASE_KEYS, KARMAN_EXPANDED_KEYS; obs dynamics (lag1 + derivative); error-state computation; 1D target_forces support | +| `src/SR_analysis/utils/sindy_fitter.py` | compute_action_deriv(); get_feature_matrix_deriv() with output_mode="deriv"|"absolute"; include_mu support | +| `src/SR_analysis/sindy/run_all_v2.py` | run_single_scene_deriv(); run_joint_karman_deriv(); --deriv, --phase, --karman-expand, --karman-mu, --output-mode, --augment-level CLI | +| `src/SR_analysis/validate/run_closed_loop.py` | predict_v23_deriv() with output_mode; mode="abs" branch; load_sindy_coefs returns "mode" | +| `src/SR_analysis/validate/run_closed_loop_illusion.py` | Support predict_v23_deriv; auto-detect SINDy mode from coefs | +| `src/SR_analysis/utils/__init__.py` | Export new constants and functions | +| `src/SR_analysis/sindy/wrap_joint.py` | Parameterized for karman/illusion | +| `src/SR_analysis/validate/eval_rollout.py` | **New**: offline multi-step rollout evaluation | + +### Key Design Principles + +1. **No action history in features** for all new routes (PHASE_STATE_KEYS, ILLUSION_PHASE_KEYS) +2. **Time-normalized derivatives** for cross-scene compatibility: `dx/dt = (x(t) - x(t-1)) / dt_c` +3. **Error-state** for Illusion: Cd_err, Cl_err encode current-to-target deviation +4. **Output flexibility**: both derivative and absolute modes supported + +--- + +## 6. Figures + +All figures in `docs/figures/SR_analysis/`: + +- **fig1_illusion_comparison.png**: Illusion 3-scene comparison bar chart +- **fig2_karman_ablation.png**: Karman re100 ablation across 7 configurations +- **fig3_karman_generalization.png**: Karman cross-Re generalization (training vs unseen) +- **fig4_r2_vs_closedloop.png**: One-step R2 vs closed-loop paradox scatter +- **fig5_illusion_coefficients.png**: Illusion 1L front feature coefficients +- **fig6_roadmap.png**: Research progress roadmap diff --git a/docs/figures/SR_analysis/fig1_illusion_comparison.png b/docs/figures/SR_analysis/fig1_illusion_comparison.png new file mode 100644 index 0000000000000000000000000000000000000000..ca8b2835f701e690534db0acd9ce55c2308808fd GIT binary patch literal 35566 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 literal 0 HcmV?d00001 diff --git a/docs/figures/SR_analysis/fig2_karman_ablation.png b/docs/figures/SR_analysis/fig2_karman_ablation.png new file mode 100644 index 0000000000000000000000000000000000000000..15074bf161ad9ef5fde986ba4f2c9d0ef4ab7423 GIT binary patch literal 64927 zcmeFZXH?Va`!~qw*hR)sL6Bkr3kaxyNXG&i5b3>%fCxw@^fneyszi#?rMD!~JE({V zNGF7jNG|~?0V#pKZfC~%pMA5>?%5Z6p0hc>-xwf#%Y9$vQ?4)1mG0c6-^;R>j*gBV zbL)l*9o??obadO2cJGAm?6K9|5C4cb%4j*N+T3?^HnKORQ!sM0eQ4wO(EQ$UCsTU| za~o>`zAJne&L4l^=xFO8%Fl20>jQi?_GbJ-IR#lik9AAifsT&pPxSxa%;MYNRJ8q= z8&}m_VrKfC9o0w!pXSZlc=m8g?|$Q#xck{23*K03Z^K$m0i z$37Y97nzT49h_9Sy6yMfZF^owyNS(l*vb077QA(db9`MpfJp@82)z=%nXQ9RK~-m$#qz{PF9H3kSDd`}M`U8@qS^_Uo%d|2zGX zQli#*M(y*Aw1ozK0^g3GH<@r_!4`||_X6?xp)*rYnG8OYOMHzE+X&(hhlIZ4y|_cJ}$ zmvzcravnW;lWKHTcnf!gI8ar39QhzUJq(@3r^yf#sHs)`}io2?q)`uw%e+m+^ z=s4SNSIf2|j+`PNxkM!!GNm{37WE~TVy7cMS8~0lF7?dxm8b?C6V|wKP6Q2N!I

((>8p|rHm(9n?4da3)$tgM`zTre$? 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

TW@jJb;Q860_Pom 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

^%&jryry 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 literal 0 HcmV?d00001 diff --git a/docs/figures/SR_analysis/fig4_r2_vs_closedloop.png b/docs/figures/SR_analysis/fig4_r2_vs_closedloop.png new file mode 100644 index 0000000000000000000000000000000000000000..b2a6956e57f3a614bd6ff4856bbf340a787dd457 GIT binary patch literal 49479 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 literal 0 HcmV?d00001 diff --git a/docs/figures/SR_analysis/fig5_illusion_coefficients.png b/docs/figures/SR_analysis/fig5_illusion_coefficients.png new file mode 100644 index 0000000000000000000000000000000000000000..23e088d9c33c7bd047effca474266755d1c68a1b GIT binary patch literal 31882 zcmdSC2UJwqwl!R8n^T(w2?kIks)z~-n6&^UM@fo+B#|r`bDKa*Nh(3f86;;E1rZU+ zl0+qE$&&xMtJK@~e*NC}-PiBEF@DCleQmp_I%l7~*P3h2xmMkjIeBdH0_Fu23S}|% z_+dE;<+oT0WoE#rzgfw!C0$YH4h6X1%qpxrKr0 zc|ra?{M&ffpR=^QU?CzPVDjq){HEsm0zz?#5qOjN7mll1P$+AblmE@o_bbIm*^f{U zA5ggXq^sUmMX`Bmdb;Z9=8~PWjx1ZHzi=*xzU+n2cQcBfK9TEEQS*808ocYR!5-E8 zfS{pvl_lpo3bt+)(trJGhmgM9_EisAmg_H*)t;RCmOmw7eD8Fw=9>XC4fXc4UaOc9 zwXVd1H6v-sueU8age$?13sqP+jqyjy`YpeyF#fnrXeKY?PhQM3Rx|!2b(gY`@u%RU zF0&bblHZgwll~`{uw~ja=zq#7pNC~5KiY=>+i!P|@Am7Bw_iUC4dpuKd3oQh+qdJL zroVpuYF^(mPcLHbw)lJZeplsPeeK4Ly=f~clv@wu18_?#?|%DsVRFE4`kkUy?zRP~ zkL(YrsCc!!^R}pRNi+%vHSzjxGiS~$k5Cpk<|)6vI@9JM zzlPeUPoE4+o?Y{`ZDA4}{;0DgH2UlxfBYe%uKwiU!GkN8FPAfRyZ^vvuZ>~rdmq~x z{iZkfUt4QYM#S)JxGrU6W?EFlI8BbxIF>GTd&wK}(Za??d9=Sd-lW*XOGYj=B}GNc zh@YQ-N%UsZm$|tXtImi>c<<0vNHVTAuC*`}eEjCk@gi@*V~1`m;TxWzG-u0}EsBvU zC1WEC*YI=k^Pj}6swJGeKQ)}3-5wE>^;I&o6g5q=QibYlrCcaucVVrkK#hZ{F;WYIVprWcn^_DpS^S43Z0yP@R5nXT~*8 zUtd4bxLS@!IlBDHQn66nUt4^!7}>h}dA}~9ym?W-+DuwXsvLJ2u9j$6P+za17_K0j z?KGKSR29uK;`YZM_gOvU+%`bL#Of6S)}8OobqcRqou15~oj}_8x0UF*_t5 za#Hcah1iUYjHZr`@VD+f5y~-|YMFLcY|(ErWBeP|wGY3`Z1uH2In+_{;Kq#`5}y1~ zxYaPl2qnA5yStyiOL|+hvBy(Dt1`VeD`IMG}`Q0r_TJiXHm?p_}JKOWUF7hRLoQIXEo8#fBi#Hp|c z)x@5@l4jKzd}|fYTP(^_vG4vTy6VQMM7kNi_ZG|_bIep0Klcm^UO5!A zgjFOEt5)5mzxI08SO*VT`R2FoWsW2DA(c_5j6P-9R=ZBN`Z*g`Myhtyrzf*Hd^_MS zpruJ})JMc>r^ELPuV1}tQmK6;WK^DRV>&uE_O_xz5$_=%rIt9@7T{)B8c0pM@a1yR zPf_~5wnr0QX%Mm>HZJlL*K!;iEU>;)79!2F zZJSK?^i+CXeSLFNQy`ASIV`YRx^>F)*KyKv8lP9=tTVY|{DaTgv zslKHrymuTa?H&(v80}B9Y^UuyUl)3J-EKoX*jsn*l;ajWyUnI1d!3EyQp{+9{mq4) zEk%z-EZgoO3MJYO=tZ4Mes3p`o|g8uw)Pa+?f7luXyMk54#VO??FCs`S(V~ispg!8 zcDe=z26kU>ZW*k%X(*_!R%K&jBjGm5q&C>3HmUv1{Y|^7mNW0QHV(F`#c>NMFIQj{ zwNp6e##WJGJMcWy?~~(*#tE-&FUD+^E?vrW{b^&qTPH%bTJm`x>@E-8%#fE3>?b89 z<*TAK63^F*bSB|)dA4q)uHjdgF*c4|&8HSfc0uX%DX!_4*B0V1cobQE?U@kXarVvc zM!&mnAFhZ{w)*z@0PDVsGRu}NQ;R!u?egWzk&gX#i`s|ha%YTuvaej)^zGZX54i0F z(+08b+GOc?y`oZFZ$*MZ**>jJloaJ{vldeB=_A?5HDo#BEN->6wQa98%~q6?qw#BH zMP8Y^l$(p|I0@B*BJX`gBb+B1*_lNwLr*^0^AwMKtFW-DY4&6iM|)|Iq}^c2F{`FG z%dEOS%8E~Y51>7GVAWpAsgY($eXvDZsrtF@(Cal?ZDrEpMK-MKb{WfL+6^`J^hBFC zzYUWOILa(!_;85bh_hg!K!*Rrhbu+L+5&2_owLt>$_Tr?Mt~b1H9pd-eDPw6VR`8N z&c0~P%y2eQJFX2I4j_ zO44WaixYDwWQ`jlxedF1xs<^VDbv zzh(wG80|hbpYB`QA8q!TA&D4n+47D_bf{dOc0NYf2I*c(%cLe@Yoz4YZA^leysK~b z;DGRU`#DdgK700T<+5c@aZhd7A0oE>fp&v!8>n{ElOrZsb1NcM9&p#pI)LAP*q=C_v% zRWBF5bYh=sn4Su;>Z~MrNKo%xY~_O|Pa+YVJFp>&R_5tuIPK6BM6N98w+mN_dX(s# zdwsEx+1JC9!?mVqPLpZ;8fk%eZj=3m0v)A6x5@qq@wJmEC@2^e%iH4T@XV&cQCYXp zqXOACl*9sDNL7qh)q9`46>&Ppe8e2po<4nA{XTD&%kmc#%ENgpHcM^eV4^KDHSIOd zUc$0RyM9Z+bnuDCJMIo4bFZT!wRaf^XPmIY;*G}pe`QU^m=L!0EPQ{%$(%4lUuUa$GU8~={ zf|b>$^HF@fL0RxVM`^*alV01N=Hw>{od;Zco>`Uq@@3>?uLGa7k4Qy}7glOF+r`f_ z4$-!2=TIIy2A}jXWYy1iW%aJk5(?b?E%cOW4)t{#q z1B%|+q5F0%?GAFuS;K`(mLzHBW6SDQk07O9cXM;wP;e2OYC!!Iu5=MK&%QU)(_kEd zXEF6CPHF#6&C}US#iu6*u5&b|w)z&9IWbkmtsl`eGz>3EG1ECj>%#+S?OY4AB)D(i zKJs89wX#3y`QiPTmWofrZYc2Dp(_#H`4|xOOLwX7@2cSg^u>A6JwnnP?5gbHCz(1>jmJ?*pSBsrwVq@da z-F9|uo@n3q&&VJ8j&)sw?Pbj1` ziMey6=E{{T@!bPeLNW^uA)*5*pNsFt5s9DD*~`nzTPPxC^Y$XgCNVKhzEer>hWdbi znt5ySzzJTOJ$G(|YP=qbpE9rQI^`kK?5eyT>TVh=1StYwl_H6i2T88E%OWg?C6O-+ zJ~5DZw4~_q?x<->Z(XWI(j>WYs+^r&nss-L?g0Ccu%nivgM;P7_deq_e*Et@OL^T3 z8OnRK+tjyvgcK*VKmud&{CAB%1R%z-E}s}3h?<;!+t46hXt$rq!NGx}-FHIs<8pR( zzwYtNug{bK%L*ZfKJK=$KOtGIm5*a%I5yah>PiXwC0Zlhqq+59-XjYp6d@acP)S(A z-IgQiSEO3#BV&lyT5#PO`GS`k0=A6->I@+W8NIo;VF5W!}u0t!j&Fw_%Q$G$Ny%@wxpImrL|J{Ou zqOM$fpD0yBrXA7U^*!BpdXF8*S`!u)Cd{@e8>=0S^j+gvhhWaF)wx?(SUBv=>syuC z>FMc(KvH~a2{Nc#*_^(AF&-)EH5$VOmhr@7oY5HI)7jv(d-rZ?@)a6#*xC68NNr9) zXwzjmmu9V6u_C}z(~er&eWa*1*)#?jLXH$|>gwu|<0}Up_oVM{ec#Eta%G^K*w}HB z=#*kKO(qW?($dnh>ZwybbbV1AlVNLRido}eUq1V(Y0Yqjt%{*CFPH~4)2&ZXsnk?o z_hDtGDlS}cnTT3-Tx;=G3fFl;6O6XBF&%N9Tp6-g_c!*^?qiauYHvRalmb9AnH-4I z$(K0nqKE;?h>_MT$cKxc0Lj)m|2rVKsw<6 zd}XO#n)D~9ag)sHsqsL+G*kGisi31ofWsHH>MPkq6xLUGww=(YV@m2_BI8N2hu zeUnppT`uc(pFdl>C3|}Py9=$RJtMshTK;ho%k3RZf!hZ^>bSMz3M!D=tq^`m2>`rW z_4@W2p$nh)*8?O+C+_=}Gpo{>t7WHA%lo9g`tdngJM{GHP3Fkw1t{oEXuf*=TD8h2 zzE%0~;j01Ce99iYs?QPXanw7#;^O3dt>=g6$JsDMO%@-^ zK>DFgHHaU)RfBLH>HJ=WtFCK7qrC;9xxwe0ON2oPtc~Y3A8->=R#p}|U#CJ#Kx#1_ zkJ8H4Ebaar$??wI;-OyABWfC}%P9zZ&~sA-cazM{JO_|-oMO-MYy^DU10 zU~{#YlaHBrvl7Z^A*80eY@%wV-KvsIETdv|p|YDChboi}Ym-bo(gN!;eRo7FBhs2A zt-Jn|Wc+mJ^RD-08*rH_4YiOu-2!D|cG48}N5N|tsfsyrW` zd#`$Blf6ZNUpGh%^y8`L4R;ru%S+A}rfWB_t)wZVP02wZ|1^OGrz% z4lVUROemSAYI$ia~4l##g?Sj)Z)42@mq z6ybcgd%e9PTT$_sfF{kvK&;ApDO{zDv&Pr-4!+cv~Q@V z{uHoCk%AExbmOK?c#1ro;>HA-StK1od4m#wceUjG=qnE%Ty`*Z8f)J|HjI3*RLP4g zbE9q_#*_4O8lx7APiG9R1q6AkC`$eeULy=}s~xeh3Roqwv8N$h%T09H%avW6AG?m6 z@7W?F6Dg=Am# z5vC1UmG}B%$f*>a`rbG;))^z{_*GLv;ufiKou|heXy@k^*l@oaIZXv8;z>wJ@@VB# z0K#UIYp$$*>>S0@I5e2MRQ&At8~eN=aAodDbE2IGyRp`#*;7LTnS-U0)tWJNYyqey zAMG)F#$f`A5rOxr_(`yM6uvywS67kgV^{rLHnoB7uAxspP<)>d>oqwHU zlYr?Y)8uZGnukH%pI=-#ieHa%tgEZLgR<(>bcVyIxlt{a`HAjyeY(v$@FjkUZpeVL z!BR}X{N3Fqk?N_XHP*l?QsbzUYww*c50!0-CIP8 zAB`0tSO5^pE?8S9okw1M(%0xF-V)|JIXFQZ(YWX zNyYP;F~S<1IIsG);_ogt*nR(U<1UJw>d5Z*BkQO~0Wt5+WxJRYtHN7pLP$O{05RTt z-pG+Jdt>r_dbc~XCS(5`RZ~+7o;;(Yv)IPQ25a5aboQdX{V6T!Ez-U_7v*jPL0|yF zl)tdE9<}DZvzDEeMHj(RKF_R76+U|1O?+xkS@BYw0Q#btnH5;_gG??QO7#BcVq`pe zPtU^7pYj*+Qub$|n%oamUj~+oS6p12>G}qIxG1P8MEJ*68cW-U^JAtJ_wRQ}GHr;) z9Y)~sA)I*Pha7iA+;vZGCl1Vox6eOUJcupZbg$Gt8~6 zm2dzJtD>Vo336}VoHwSzv!J1=;6b8Uo8v(srBAr!F zreIHez@mndy9WLyANaMgaTV}v=?Ua zAYy1Z>cbt24&g!f_w~u1I~VG~uMrMhq3=B#!A}EB3Kb+mxJul!_kQB0j-o-q!4fn; z$^`3fH864q2nBxo_U%QvGFZ1k!bgqwW*7IFwbij~$~B6~3`VWhUZJd|NG>1b6e4w) zarJYsjH!99tPv#e&YHXA-GIy0OZtpN^GO{zr* zSTxT1CDBfkL&`&KC5OP9K@%WV46FK9{DRBZ_thm(#cqWZdJ1$B%mp~a-99|F6H8Q? zcn-+~nI-V^XYFGi^C|mpS^)x~E?XWi;9|cH65Dz$MAIO!h0=KE57;r0?6)-|l=#SV zAki6{RB)~`ezRaDGc&iARs;Pv*>9)JB9}|aDOUl7NdNgSsB`+q*0|WqBd`$W4$+^k zj^WK@ek1%M7>hPQVr%xtOe$O}f#(Se8c?`@zB{lHw$(Au87qxYrHr9OJ-haLfaHu@ zLQb1!%+7cG?c*`oI!Qun;^*m|qD8zbZ{6`$-oAbNlIY(!WzU>>#!*mMSop%7^12F1 zs=2W-06G5c>({SKnklbWi$8F=+$c7)D}bv9fMELII$Qwb9{+!;PyXl6$HjQDy6}e* zPV%Z>%IWU^c_B9H)m#;5SkF~>Z{50OZn^bZG=~rU5KvNV9)71Rq`f|K-_Or4ppdcl zu{Zxh2j6@WAd*r|7(o+M;b)@aLPhr6T7siIYX{4ozQ7FEu{Ap>tR#FIWSmwp*s zhVNbZ{ka^5tM%>LgFF>bbAtH5W|HizicyYGiXz1E#wbn*nW!>6larH~cb@YH)*)MY ztUbh!aIUN(mdC$*`7+8TF3_qLa*}m1tB8U{%R5={e&xxgT1Ka?%vs#8!8lM`7?(27H6)P(r z)vmu_OMC+1*zslr0N+aEJ#y?A4^%+siM|`>>ryu4ZnGR4G@zCu5A}-%pUtvh2>e`s z*o)=7RmsS(8|v6qOXSSJr|+8!JuAR7nbf6huNA{z6;*w7-Bab|wf1rCf%rMNz8pP*@^_W4aup z8GB6cLh)P`F)=Y(AQ+a%-CIsRc{6AOO?FPE>MeV4{Uli{F3y?9@`dfmDRY`L>FEey4gJR$@y?;4_S zfUQ{*>WkNPK#h|pVtN8-mC*eVC?1Q}_Z1#|ry^?~ zJa{nlmM7+{EyoA^HTHBVsl-NG!M--6SxLrZjfJBI(Db*%jtN0RGXTBmog@u84Qd28 zx#X%vxM?B=g2PwvZ)9wYmDlkQP)T?Y@Lms{8QWF{TdX46*|{Q2{@C0lEHW@6#CHw6 z-`o49pn!_1mT?3v6BpB#9EimyVfSGFkaRV07 zG$faBsA#k2%rX4-`319>Lkdyy)`b4>fY_(D&f=vKg@{xQE-n;oBO{dS3+B%+K`GnR z-tHabj7uAW<``i+&_Y<_U8bKRm7>%P@K|7x;F00Op`xo_sJ93_!&#+)`6e>rhYufS z-sNkaROf6*kx>pE#0ERW5X(d=cco}`)z4~-4bt`EOw-7wUKf_{r=Kp=; zFBgmO-ZYo5{NPxXCm}}cG$;*3u?RT;kcld`q;v}L_xcL80t|sq%FKTxE>OE<{;mrtZ zN&jj2Z#Lz$bhW|S<6?`PBy;k8^>mie>5*$kR2JjIB)ESr-T#W&r|eJZ>1`=kcRu20 zfc?fDPZ*UUQK!j<9c(KRx%f>xcnTyGlG%c#O9dW%J;8wtu=@6q4I4JN@-u?kdTA+k zZQU)qb}8Y(l@c0v@w#210J@gf$0^&n=k^RhU%JS;Uvr|DSA(fRwAxl|&Gpv1VgF=G4r z_4{E-C;@V4?(U94&{L@;>bdhL7*8OvK1GR%C(F&jaTs=@(kd!Q#ag-h%o&jX!RK+_6J)8d{PeD3+RZ zn-~<35h7L{Dz&)9C#^qY?VKBPRp5ML7Sy|gt(9OmWY{^g9hx8VTxJ63cBx65-cNkg z1&bE(mWtR9bp#;@@*!A*^NmeyvxQziXEAdiuG_Ki^&0Pk0bAvR+t{bS-x`_2HxEex)$#fy_wx$+YC`f8 zo%oguqsRx;fn82rM7Bn9@Q9C(Cwk`M7!E>TDT80@a+0FIqfB+b{Mzkt^S{5c^v1x*AO1sv~q>js_XI9Z3Y@s7wM-ec8e_~3_7v5_Xj0VPyx z<}O;RJ)*<#4z8SiL;RpuoJ@Mu2Tq-FyfV@mLMOqv z4vScytcu(A>eZ`{bv?bkT3q>zlfvnitFNv5E_AFj;Bs|=O5MbJnL^_pYkok;j5nQD zBvg{fi|Zc!{{1)&g|bPK-yXA#WewO#+*G)<65$e1NqOBYo0rNW?o_8|pC_yYJn_NR zr?zt*{nusG_w37AuwVg=gHJPKkK`#BL?D$uFsMDWRFu@j#2OKB%TOzPqr==1}ju&(WQR-nTIO{PC9z3tbX;?&z{(1XP zn_YAf!rmo@iJrPt@724dPoAuVrt@rYaFBOg%1=xKOv58l`y@iwb#7VlsU5}w1>}Bg z)dlGl9X}urJTY+6sMesMr`p=v8x?x+jo~u|6HoYvDDJiHx)&1@<9qSz{LbM!-tsq= zvh&NB5PyLGfh)N5aAi%~BU`s_txN*;f?x5-u95hvKE|P6a65O6S9KEylsX+FG9LR(+5M~MXBj??>${$_0n)e=5 zT`MUNMuf73ilcxN(A3*YK4a_7oeH2PY{N~V8rEdk#$!_kqofUoOh}*?!7C7q+dxjg zd0tHD1Uw?*c>>P5hm3-S)DgvaKw`se;`;~CEU^$6_NCd=^`l!9{#UdiFBUV|0?kw`z zt1PJh{$16^A#4*Om|b7W9t~CD$kC%65NPqdVs=8fcs&07`}Z~w?ef545VV2E^$bv+ zh&YZ}`noM;?ESU8NAvQlW>{kt1*o@f-ZZIlQczS%}f#g2<&)-mE}ct-gsA zhuUPYE56te=m8GuB)Ea1z=?5g-fY<4RABfq_9^yVW1HOV#3wTk(|?sBc){9oM$!`z zD+#3eG7z+3u(m=Mqr&y-J!V{&B1=k`zQ%l31v+&C4({^SS>y<-_&E=WGS-0OwnMHJSFyI7$Y&dki4IH5A7;*E#+)Vyn2oe%YzNLkQJWBH& zCX$F(P`VW$N<-a@z?puu&-VGE+}J`}b*SCFS>w?tk6_7OO3b?`OW|RASGxNb2u(9@ zzD3pyB|LbpBSgvJ7#$rgI1~3~+ajXv;Lh&Da0<70zapVd<}6yfURqk(o$r(qNYxV9 z>r93dLDV1Hw%4+4Jq$jRaC#K}49Hzk$NxQaqmK>->D4J$qCx zUAmr~%;90>S+dk^(NwBf(SYHoYDkfR8Ps zDlkq|N{dehqG~;dUBs=GmBC>E*^|VZ7bg#w#-7d{gvM{s^)Zf^`kKFf-AEO&?($-D z9F)x*syx+ElNiCg`}`AwY{=FT@aB>KNK6!ub)4Z-tjV-bo|v2*&}bl@N%*tm1v0)} zMr7Z-C8Tymm)<+Cr}}=5yCkQIV!%B8$-iQHE4c?wNf*E_4(JaVOX-<8j1~md=l}qhex<#rJ|<-j%%O>@Ft8d-*I7 z&3Wr3t7}wAeSf-FIzo5G>9_s#PD)bj0;!`!rzC-Cb+0)Zw!z#Yp(VSM* z$;j?9>MQMB2saIJln>P!=_h;cxAy|mRS>ZPo4E7q>)WYQ90%Fi+39R$(QsE% zs|SD;M;Np-J6Q7LRQad_5B*J*?E7x4hnxm9gBlFL^)bNKxOTz%jB>?rXQ7l-- zab}1D!4U?4(LXd)0q@6CB2-B>055L#)4y;ESd){AK9kwZA+Z1)N;D$U!eF{p(%!nY zx1)ob3PGGN<0~VgF7W^GACgyGqN1XOVsM2L1x>Hmmzd!5#tN?5jG-W?2tKhcN+s@P zn#rp_meB%B0*-Ye9#wYR4|kQIaz%F!0Q5A?tM%0@MK*k-3At50~ICYgrI&TvL z8c|t@Z5N7av84uFcTrGt3R;|)1e2Gri>Y&1G0K7_#iw&s;KC{;iW3UxP$)tjb*b@V zBfT;Bh&EW4(DY#M;ppJ#mSffPxohcC|H`?oKo>?`{4J7ZHW!=|JK z!fkB;K)qnzJQ+*Ngk8oT{n4NV7&8%?)t3GDa(AyG|a-&BVNj0~ZXPTMKq+)l0>>Fo?m5QU+~N5%OI3 z$GAh-E+_zHP`3>skQ<>;n=N+IiL4@dA(X=~63EC~L8Lo-Hqh6uW9#GbeW)9W@ekoH z5mFu@@BzKbaR6?o*fF+O?SF(6tEUuX{5kgg#5F-TvY$kwQX+cK62%#uEB>VS1igGW z^VZp)fajjpFFiyEJj-V;Tsz6DiVd)Z@SujiuyE{lTiZDVx=qTCq$-X5h#nXA zz@bThyZ=I`+fPIno@zv0nznhNUxp#VPDw_Zt1TDi|dU}kk-Lz_k$9zx+5;SlVdhwGu`=iXeVfKei{s4j) zq40^>#ZVkIU{>A_#Yc+w>-mFdf4k2C|C^q{UR0pO0}x{-fIb3vB}opKyyy;I0E^UY zq|Uh3mxo<+4}#EAKq;Q+JncjpnJT-X72AOkw1TqiaQ75`k@R_?jQ)T|u&COy(o%Z$ zdFjU*1}xL|R)Ft{BTU*)tOIQ5!9#}>)2&Td_gvVIf_;>^3@t#gktxFMPT(Xu!NLI= z!sSC;r^XwmI}no-8X50J`8aom2Nkrg6f#03Y5F6sQRq&DH6SD_{HYrn8neK=5sDYk zE|RfBSvq%Pz5?-?^l~j?y^bN}j7cbEVcl z#0H)%t(O^0xpn9QE~kHy9CF}jq5u4;sg}fs_0y=Mg@6*(hfEOj0lc-|tYSRFkL)v) zwqyN;(5blvh47Ut^BLW>`{Uq@y@Sf(rqFl#OLH<^h5kDlZ=rzT8nrbSrt#f_L4zIu z_s=J0oPWLazjx|?yxk*u3$B04-cKPh0g|!lw>33g`N<8%{vrzg=Ngao#wa|X=~21L z%F3$N0N4egn=};Q3#OL)$f<8V1!B;LNt~UCEbq$2H*P$LS0havk#JHWN zNA0sByI$qxg(J0-t)GZ^0M_*sYz<0)A>=uO=mV!G1D)B-GAy{YwROww*989tNqz{hyS%Zy?fl{{y|0Jg8_z`(CYp7f{B-35!q62X1bj9MJkZJ^lQ&vRsMKgneil>~(1f?Y+?W zHRV9Zu4G~1#knJXT?njlP*`>hO@6t-&Ot?9kcBV!Rz@X|01A*3h|%{Eb`eE}z>2mJ z4>$3Tqb{d6at!Zv_#O)=!;fnr<|?$hJj8}i6a|Y*>UK9bWjwK&vt|ugIS~{DPaG+x zP$3;fl&%`?uB~{kD+w>qCiw=SU?Vhnk#D*L)I?m}iQcxRv-gLhur zv5;lZe^)JEzL840wNTsyt>RHS+mQ|Sk}9XItu5;r4mV+_R<;u<0Z>+uMK`KN#SL(_ zn@$U&`c&7@5bPd@3!n||+H|qerWMe2-lmD-5zt6W9oiYyz!a82iS>&aBTae)EAjE2 z06Y2tWf}(+sy8s{MUUCt>tKyQX^O0ilHsQG76w}$-1P0(Jbjqrz!(yg$*f7Gftf(s z^Jp&XgRSeQzOc33I!gcjlM*OgJFY3y%F&)deC=aE*3Yk=FYUGyM+>3UmkKS=%|Z=d z+zEZIf(S}%p^^Ak;k|o}Py=$GN;1yMfA30CC1DgFUeZxDI zP*Pvg%?(JC)~eIKal6W35?qj-lKtjlT@13E@UWzm)Vt?==>vr(_UD^g)M`$_Ut81- z%Z0T#gU#gf#uNT2Kg^#u?*r&n!E0(qII-Zga!|!A`tsEaQeX%~J$Jr4^?sERLk5&# zfu=w#bPy9DN&uYK^D_JnBe#2?IGx{cL^_-#_Zj zY)wh}nQ{4=^TkM8H*#I##lEk+CUJ3b7(XB#Y(jjHa1(|@oI*eo$&WLI;{oeb6IF@+ z07)7=(A~R&MX^!>=paP=si_laT(Lr4UVdQ=18ccB;7R;Rkf=d}jTP~uYuYxi3WCyE zt&daE-``&flAl=9Rm8o&og_V|fN?qlD`pHBp>QOoz^S1aX9E;vs;eJLgHc1*k^8-S z+Q~XCN1!>;`EcMQGJe3yUQO*0siF{+tLp?HnqV8>4m`=Y(~JujU0}84b41H)npxv@ z#1ntqOT-UYK&q#n`gHy3{HiKt>@HbAL(&Qj3NsX5d*(eC{6SJ|g-ihNkP*~3;zWWpdqyhPWRuo_w{8+j%ZtuE|{(%#(MycD+eAW$8P zS@y_45353{MmixMkE1C_f<|g$m@LncI#ArnLDmDtMEu<2120P3X;{t$7V(t%F*=bhUJo&jT#HfU7AA&_W{z#iGPJ-smq2WtdGhFv4J zxGgYWro?rrhRi`4sX#Xb|G~o`P-BbEObiL{$ytI*kdyk;(wE13oQ{ev)4pEx%W~y^ z(Zg?N+x|r$g!F)$Ls+#G-XHh6n8W{biW_B|>y~}kws?yCyC1a`<@upslSM3$2wivn z*?OVSB(J0oDy^-pO?`+J{23fwGCzUR%FN}g|75}VwsC3fvw#301Sa*N#S0hyoK!Lr zh!-N#38!6UIdb?g5%EiO=?{alXQk19eTWIeFTvGg$glCWZF$%G$w7nm!gg1F!u8yH z^$R|+{P&JiVZbL!i_nGU{ldZn2E5d+H0@RS;`iP0D0}k4I|j}Rmb+A z%MOY^=@U5VyRY4an}N%F7VkUgwG`fi62PqH&d!M44Gx0&Sws}Ur7v`NhdP!l18t}N zdlmv}sf~jpsh+V zF6Ai#gc4AeA*u_GFqG9`11nI2Rlp-D)d)T=?VmKQj^iI)c!X*+l_qrSLX+KbY=>cEQC#Od51l^BTZ z>SixuZ%k%~kd3^QRJfh7bqeyB=?@Ksg8|fKS|H>YT>}e1Rb-JJLkyagYl+t-_obkY z;dF^ba%gRn+fkqrSYKRR42^#{o4-Z#cRFRPzgv*828(jDjJtL=_e|W% zP*(a!=15_Ep2Fcy3xwI7M@XpRMa6_T-KmHht~Cbv=*fW+Ip~ccV_$J`&5U$tgP4sl zi4^CL_KNlSq$9mxczb#Vj);4p@u7HBcvMf*_;iAK zvqa}GYWGaky9AY?i(x>@Z~w*Q?Tp>ba`#Bx0FkYUch*nLk%V1h4M0uofow-62mmK^ zAgPlJ04LjJ{-0{=;hLFgSrh$|&^m~kM3j4E)?mP^cI>DA;o(Xoe8IcbZ~%g7ppy1Y z)CJ(+IZ;Q54!jd2LWV}i54+sFnUOQk;Vqn|Cv8r^@&JQ<6?{BoDg;r*NU}vwCh6FO z%b=#)3;UZ+%7f@_gJnn&E?H2-&>Xm_%=<3J;$wn{)C_-qDcF-|+=}6CIG9zBO|!yq zm3qv@=yC@DFJEVx@f5XACyIxMr`N*w@zW+4ti63v?gh(bDI;|EVSVNF%>Iqrx69Hn zfTsDggv-}XbgvUk*P!dz-1P3&td#U?^=m}Zev|*Izg*@*-7y!(m9H}uyi_UE$H=%b z{QPNgQ)FUp<`;k`}MKc-! z2LFz^@F!<@AbB=DSraye>kFB4L;v~HRsOe(`yx4>BE!PcTUG_z`;m@ko}K}83>*1B z_K`nfz(3QtJsUxbp|ejGHnS#_v#=V2Av%ElU%9t8BVfgrlTC(CPz;laN>{J0JpY2R z9wTe9VA^^ld4WN+f`i)j;>Az|Ub4lJ3leqmmk>H1f=wK$un~Rsru|^d?3d3_+V$_{ zsJhyDvBsF(2EAPgpc~_KgwQj`>xfQY;`Jq31zNEz_y1r9IAXb78-ZJv6!)ly$%G@qY@+O`iAsXNMhew%t?Vq+D~f0aqIV2J|A`;G z`-`dtnH~TrUI=9U{{4HRmvB%S7JxL{fnYLtM+mK3NOLG(JnIdAo@G5;62cKx4iUPk z>&dvJ<bAMPt^PfW)s)gN$xWsm^-I6HhRN;%*U(s}(9TCxIq9cf5{iJy+K*&Zet z5k07p@T2S2jKHAyVTJU*r?VHXep6pBTKEx<r^fe)vzWZjI@j+7Z@Nr zs193!T!27id|jDI3_^{=HRYde0gqba7<<-uL(cOT+wSb^jYq2<;%x*9T*PZTk73j& zD?@`xH7%E9BtLuhY%0yV>*M_(zHQsK4+S&kGj4<1P6zoLRM?p<` z{EoS@$iflLQ9m{KlF;iYekG_>S!jva^D$_LkN{P~cR`dCxHxhv+1R3SezuB;sFC!G zU{s#vXirGZvCs|l&6|_?^2}KW2R7|v*w(Z_w4BUUzD(mn>MiY-sn&EIX*WuaL&FF1 z2-}o-8ag_*qDS|b=d6{duJsAd=Q8+H(fL;vcG6N5_-MP%brg;k6*lN024X(2L7Bma zX`lQ}Wyx~bDnstkkoNRf9y*^*4XXhQfbgA^Z24`Jc|V9QRp3EWPP6&tc%B_oB&s{r^cB z*$7$hRy@)zsn=j#Od#`cfG}Zr?L_bR&vF7Cvns^e1h=7mv)T%z1kQIQ<;h8RFpp+Vs|G zz;%%}18dPKl#$4n>(Mo&NJ_)LKC}wwu3WRmGpN_6^SntzmIhcFPdF)%a=@DfV8kf_ zBQO)86eQLHK-^{wKtm1oD%y$>;hN?OqL@PwMhk>uSWumMPT&hj{vV@1-XVxb3S_$BgR<#DfnuM%A$S!kA4SxIgmc3(-|8=? z?{|vvM%*PXRFVeX{Pti9go>(r8xE1)J_LZ1K-0-d#FYv=Lj~>+JoPXy>C!*N?l^cK zeZ7wj>`~lv5QqPt5@@DOG2@2T3!oJG0&0~Oh>?`5+s(u$2jw*|RtbKU;+2}zQhXK( zbC4oX8EdG%OazxkIMhOGW3=o+>g6l`;vQA4NwXYmlLegEE6r$@qkMeHs3Z@$reh2s zRSsOBNz&Y3DKH9|4-!SSD=UVUB2LykrCTy#$~-eiN$etq^`WjoZcx9^wp-`^Fyw&N zj{+zv3zM*bttrLzHSsG}tkAKrw-$_NtW^Ik3J*I>eq<~H7&=K{I{xR4!9hWA0-i%# zhVXFwDz^IWlKqvLj6zcHh>QDDP$Xs0cA`;xcA}2se~v_l%>*NkkX45QkX-a}R|c>A z`CKGwSZE?d0wH4}U(981`Z$6ss}|GqR3`K|2bQTucOU(FT)w*Cda$?fY`4Jg4F01N zqr{YvrGmvUp%u;9%J4rL^N0JgW4|cD7&!#@upwIHRBHs(Q~amLv!~?&N;O;=DMEV= z2oh;9nrhijnM67vvvPoGh)V`dELND{)m@h=4~LFB(k7|9Jav%%m61ow&?}E>EgXVJ zC$1W`7qplv!f_H?J)Ab&Xn4{1b)Ubf%>IyMa*B+)d?e;b8X}{yt=gfRKv0MzGCw?p zB=_LhCDv|-&K?Cdu0wo!)D0gP0<>O$M315hbmBd7r8tze-4_{;$yM)Xsdv_4`LoRb zYo*6k4VQ&iujA-HH=E^5-)B6uTJ|FX@lb5&M#lfsu0p=f$#|E1+@W zu8S45euQ@!xnpJ;&*`o$tC%l*HnwKX@lVH7rn!t+1&@qamyI#T^Oln2a>EPr}v zE|NqcS~W0IK?d%KAv9990a>LoZ~3KYUOUC$D|1AzQabqwF$L7wL0Wsr2 z6hkHoadL3%iN$nq{J7Y!LA|&r_X7?GNm1ws0>s)N$qvSZOtJyMD}@b{Rfd!P@|;Y! zWAozU{>|cVb*S&Z^jmkvxJ?tr4;B*{bjvkC83sfn85a5USg;e6@Nf*);4r|s_0C(g za3POOCj9~^!!H*_8ef8ryW%m5ld--E-ns^_XX-405Ofw;jET4Lc4AL$w5YYhr zAv+s}ew#geE7U}&^!H(e1h#uVxE+-@AT}<7@NcACs8l^gS~!UElRepON?O+u+n^2$ z%mzY7j3k6>(Jzt>Ps9w>E>#Xfxu4d2N+^c8oZ-KFrXyMDUK)(=H8nFtF4jQDZl6PB*s zmctgLOZt9jfn*jaMiag*oq|tEzk?C3Wmvw=JG~1nua@7xoknutuNo(gdm1W9VUuLf zJB{O`qt*1rrD8UeN5I$uF;GFkL2y= zB@nNvr3lOL4n2y%JV6HzYF}U9u3A`WPy2oCF`u9_sKucX*kRYy7}w<@x=dR)08bP> zXv$=&7{-pVjB8?yB(f`+V?$p}~V1nfwL!J)1BLpH`XVW&vp(}_ew>NgBbhvq2>JdxyzvCQFUlQ@S= zPrOmUbp{2YS#%F z1`-w-bt|zJll~{7heN?HMJ^{TS-d*$At->KNgy>QLc=*Ul%UJY;*t@A#EhML+pN_> z&&B5-o#WI`taJv_-zD1CUXu3KSG1GP;{HV$Nqk@Dr)ewpPhDW2F)X`sMDDcSjGyx5 zpE*U9SM`^O@Bc5wO0T6eJZ6#Bd4?l(_3EF!+Y%gX8!U+xgX#MHh!~iGk9q#kWq$t~ zd6-zba*P?mq|E+*vaa=nNK0y+gy9vB(S_qz0&e=tjQL-;fRX$U$P#mjXYShq?*^M0 zqlyKg9MrM0{*Ws?Gj0j=Yzk*7C75Sr-Fz zqBiY|wstf{g<>!p<~$MKw-5@J^m5GcZ)$A~0k-bIm~};@IFjYT2&#_%haF7`!Y!yI zh{K}|2%XGQRRDAmGAKPfJyw;CHWpftGv-C2o&X`X3XCesTjDu8dGcg68>5!<=W_pp zNd*6m7Dph6z*pvu#t9g=tT3XRxH!r9J4q8%;v@w+P4pcjLvB%} zlW#QOz-&(?V%{Yc0t$(a`^VTy@RmccGIZYD;`;%`WGHwoedbmJElXG)qm;V>`9q-OGk3RE%~50~F9R|`x=rf(tAkr`dyHFy-kq!Ay?R|SeH)5Q;o zQJ<99#MDFVIhX`}YGjm*Oa!saQ8uQDhNcQMJ0zl|m&9x+tOauUpd$k?7_gTR@5XZ& zngYdDZX?@2FloOqAwqDQart41g<%~`7wk{0O|=t zag3C5Cb*hRzAbg08ek6!3Npa38}H#xAevxs(PRu6X@sSbdHmIpPe}s=Ot}3lnxo_6 zwG&8a=X~=R^vIi&H@z_tA#hBOlDidei&UBtbgq1b`)+XMmr134AH>*t>uf2|YByIX<*VSlb+Eug+ zPxwmzpA0ny(UMVagvJE303Go{^l-sE7vmzQ=cHbKOx(;IRA3JBB^A%>8!(AztQCG6 z($+}$9x`_wDJlYM24#&*TgJBVFo|{7Ul7^tSw01n02cYyP zoK(d;{T4@?M#d9mPV^V1*65=zjuwb9u?I<~bvYRO?c=L|^s671c(t+TuQ&ko@0R>) z^SM(4&cSJS9(=uo0&OJ|lXs^h+4)q=m{;>i0!v*T^n=xWhD+uBJFtmwa6eJWh^d0u znDA{aidfXbavZp5q;+l)k(xBpHiP@hp4d?qa2ejM60+M5Mlph^e=N?RWLoBuN>o^+ za}(?WCi+PbrHIU5fa!~%$d2mx5U}?KJ}-!(O=jT$0TZ8zkaiA*d_4llz!YB$tvDEC zi}lfp_Tt6CNNl-3~&Z9K^lHYD>hnBcAkFu8~G9td=|~p{?g!o%#jb; zWoBkVCEInvcb_UwO9g@k9#I%%3o@Al&0w~{N7`{tLl6jwOhbmSqx&ZYG0gy+#!rOu z+T-<`9-VfVncqf6JUK9la8JtehldFw*x@X(#U(=+v?+jjxs9rpu&`$+61DYTChCN7 zuOofEfoRhCF3zY=DgWYWuj2vwx!=BGhQ$ti5d)}A|9d=)GAv~`L^mq>odC=pL}X#| z$j|Ja`fAfv>*dRqVH%bB5DcBIW$G9ZC4su>hH)b0~8QyhhioSkXv8_HDfV(jLwqe!!lQ<%0ii6GL9e z??1VvyFRr{LfVn=$C4dSdJeI zBqf2G%%^Ydm|+n+;z-m0vZ&Bw>2HMy36N=o$j&VvdG>EOG?I{mzIG97DROH|gDzIgq(SM9<5mhoV^qX3V{pP?$(+~6{&N0+J?DA+GSB0g?{~i6 z&*%MlzhCdy>-Ap$7?v{=Q&V4K%p1GR)YP;UjjNyf#AcfjLZ6cE_;D8tqRhS~1tZeZ zXboPh`9w50gBdtDF_{ENv=U2I`x@$jwZtcRW0L@;4;N%eOK}^3hD3~;Xt%7x%Sne^ zA{zT+e>f#Vxa2^L=?`1n)k!Od3VM4~fk=|OW?y{VGyYZ{2bzIoQe4W_*0egJmW`3`j=o*P{ zEfA1VUmnwn2(%hmsGR&9{@v2u-5qQISpaFKAhuU5?z3?~_j(#`PMDgO?aAGJI~L>4 zsJr@2evoawj_x?8^spR)>oTJhPD;ih?em+B zjrXxh^A2!k(A-lwzOVb zVMUu1v&f48(~sIgGH5cokLY-C69lLvI~&OhDGEJ`?88gMYU${U>v4dU!fD5`P?ie| zDUcG7P>l6bh^8NowV=|A!51X49W13jr3_W8J9X;wLssWR@h@ z8%+%jC5{7%!9nfwq1fhNN?n3K=M|Xv2(H;*wyGFro+my6gj5S+C7Dp?$|lM&@RTID z66rkK%NJsojNfn8y#cmjwI5xV!tN3x05TeZ*JLyI7G@Xp$Pn<4-xr_6&L*Ty6lzEC zI0q^4inAEYE-p68lg9HsM|Y9cG$cvJ*+YIiajrypCq}W`nEczR(>)SGRF5P|qfb;?8O6uKMz7 z*Rs#H{FeIYjAMb*ck0#;&VC;GWO+?vs^*HrG&LMG-G#vV;rWluqH*PVt#+AcGOB4!&CQ@o&V% z7mhuQ`-;{tN9n-j@!sQc6s znw;8Nn~?nIeP;J(->qEx1BwY6HVqm^bubSx)IAin+q6W(r1QD5Iu1cf@ceU`n;Czw zI8)_iYfz#*88l~M)MJeiskGIQWy=Qj=Cr5w@`Dmo@$>vVo>+pamRNe`=q)Q&^3t(LiVC;yRW>3{l)gA0lClHF zIQxgE5G6$}x>)82JdF^U9m;FkyE}~L*3_STs2a~1tUMYH%R;QwOKrNYzBjDHA|gC^ z{`!-M#N|fRfPjGOo#KgAw5aQ)@8?Q+JWKdU1mmh7z=&dn8PqVM5dK!?#btb2af(jb zye+_V3veh7;rv`L5_NZ9%*@O*YQpKZQyiP^`UUoJGwymEvg-> z*{ZCpOoOw-B4CoYcW}uC48DrALq$lV6a zo~f0v{kuL}_*-XXCh+N8?2nMBsO>npR`2KnV8cBIG7E2x|akkjit#ByWc}0%( zz$TDji?cC|9VSP_;(ucKWV>EtZ!N#OiHrfm?~8rmq{~0^qmTcsuo0MJ><1Cn!$!(| z)!jXuqPl){Y%*-d{s_`vzCYK$FzZeI^$UzwJv~@ZvLI{_G+LTq`cVL`X&i&`9_buab<-KRB=fH%AWHBfa2WU+?wx7v0KM~p24a+ zcKF2GwCMTqzFLaDzW&NyRRv-ad+I0>U(ajaYpR)e6o#TAMi;GNwEy*Mo3~7eh0<2w z2c5d|C#`(*r6p^$|HViDkG`I`P>dc7b*HBXbK zAFs!Mg}Ma>dHMO?R8UvAe(f*M;9&nCO+`i6|8awYU!aGgN-Y)x406i>I^*0){Vqo9~ms(arQi=^ZOrnQU&9lK7IFC->SBfKGNDs z5hQrT`>v0I;34t*mDLcd+XlbijoTs}FZd_#w{M6M37cr-Z&I&}%DMmEh=!!6-&~$v z&Ox}VH7!Rj>CU*?lW-!;Dfy5`{c<$CMKf4+Ho?Bw)+?z+zLOzS`Q zocPP1`@gTdIDVu5=i@)0c8UJyn)CN&&i}rC$Z;J0pO4S{Z#TI56{%z+;I|yO+Gnl$ zXq{b>eO2v8&-yfeEz=`!w0=YvaSxVjzRX1*N*XE9kKNj*)=-Ilo%mYU-Y4~w=PzEY z`1I*6Bm@tN_*oEpMu&8;x0#xo8}*r{75wuZw5B}`ra0dS4A`ulk>hk5*iom@tP}LN zZ{L>rWM^l~8O6oNCrh-m51B%m!9QbYKls1P)wC6s3+)TH9NQ|$S2c}TqPe}nLXdWalE#+_bVmu5~aKzSR1)kOehzhsO?s zn6##Pm=^3buoL2X#{IzP94GFZnc;0LbR;Vtr7??=bfCad^`5sVY3~q6k;cnj#J7sjY~*?ZGK-_ zS*fOtjg48fu;K*dl%w{84Gawj9@qY)B+*b$9DqSy^T2 zMzsnmI!6l5-VYkl*~y_$sVc!gzY0G3_w)uh|Fzg*;xkU}@lGVNeWPDaGh(Y;qN26c zA!vCR|7)%r`|!ix@!yh=2MgjtdeLni`xH6+`1qH{waSjjUq5b3N=Z>_d&R~mcnrwN z>6*B>IGIdd!PInoOOde|UU^mu0)h4qHb*3l^!EBJhAY-av^Pz&^<VfWF$UeXm_ zQBy-OQ=2JXUG)K$z7nt4VF?1I_V@QU_|57j2&ovuXF>>EYlhCwd3j;;r+{xD{ey$H zUS1X6-rifdS(Z7<*1-Xdsctpq<`)}J1v^*ZFAjMY5?Oh9{OsYj+TPyY3WMN6 z0M-^^*Gp;(uLm|mQbqe}P*0pdmMSdkv4+pnL7ZU&kqxjI(SJQS zy`{XoT$Qbn{B(EBLJ#EajW(4^?%16_4MDOP)tTggfB=VP|ANa`t|)HxTEbve)i}Cl z`{u9~WxAzaqVI8SIbw&7D(*ZWD;ir^5RO*q*U7T$>+9Rt+Deh&I&p%s-YZUEu5~IJ z?QiC@uVoK~e(u%v|M9f=MV-dcaqO-yVEyOr;3p>fXJ#OVNn&`w$EtwCYfU@~LO*(A zkHX6jrS$Z4!jhh}2Q0roMf`jWCbMX%y-Y0(F)}hsPx)5%w8WkiC6HTpSOK z)MjsFWQ4|G#@csCm@3Dn#_zp1N3^e$jmeaah4)8n|GRgPDi^P#wbji9YmPvf$@~VQ zH{DIMVV@I45zf8p=SU=ykh=dv40W46gPEUyZ1imgJ}Y0}LJOBpO-)4=zkYoN63`>y z5$qG&eW_v&x3#r3PH;h1Wn}zG*uSLz;wQDH)2_z3{m4`G*n1;cYJQ}PG+|)ME`IL+ zGH;XHx1UK==*RNG8dtv8E&zt)>FJq$J0di+k;>>h%rg%OVO$g^&d$y<_Ls8#d<7fx z$7@{;XF`dv2x?VRgU_VWByC%eKZFYy^;h@3f1|&ChG}W2&SAwzC~k%xk(-?j4Kv#- z)w{a2GFCPeDop_FzM{PR{@JrV{Oy94?56Tr=t)8A-=`!>~i8X4%6nNYr?Ziyqf+zuiaa?ky&7PJy8jTR#f`+cxqp zzc8B?#oRVR9Y22jypWK0m>w`LM}y+OIVdMR+qcyS)eyn(22QlPCjx^J^9t%KNN|PAM zylDsoF8K)$H#e{C)k#z_;D7rm{J#ISm!Ir)kM?9hzG=E}cQ;HU*L=pmn|Ggrsc}*n z+gY2f@fs_YQB!LgqG^Rtimi(78KI6M0D;p$(hIQ8tm}{d-q_q6!R2={XV%8s5%5VL zRkJ*8`=d~vrE?2gS!o)7L2&{CpD9^fT&yRt&sk^}0H$MKWd}jfuv5(eMbMdvDn}LA zlrjhm9v~7OJK3x?M<4d!&5*5i!eAw~CIA1{OQ^jnc#r(3(0*>rabV56bC>_kGvV>^ z@nh8x+}6*cBw1BeJHY9INRV~I^@XbMbotvWhOJX4xVW(2b!B9Xveo=1s+vgXmoHCh zi0~bSXxSs^0Jqjb z1nIF%n_F7ECMxZ-RGuyj2JwmLh#(%AOlHx zA72xVAJ#dtrrN8rG!~Ap~W=uDiDdjAp1T43e+G$qEAuD@}^#7b1ve9 zQ#4;xt8?tKk7w=lJnd5FZ~;YK$~jj`s`YbWDUGS4Z6w;uz?HL9j1BHRtFv3R@^VP? zj+eCsTgfGH`0a(Bu^JqI7We0Ldpqvr!Bw|w-wOw0938JUg|!SwYukblP%7M&8>j|Akv-nHuA%7gS*EPw{GA^JdQ#y^#qh zc-sTN&i4-tcgW?%@9yA1AsgH4mSw(kLMkQs)i{shX`PqgQ4w}h*Tq^p`w*X<08wI; zlOepAnx;29O@b(;bkBY5?aZm-r1H0>O;)OwtbMWEPdsC`KM69dT?$Ul27EZOwM|FOSuguoLsGHrxMM%gud0N|YP~6{`y86NbcB$`Gjfh zoNqqZ%E@?F+u3@rQ8SxQezr;l=_p~O*%nx{@2qy;ilx~U#GilA?_p$gk%BMfG=8 z?0KEW{NsqbO;Gk3Hzk>?)yaju+cuVKe1TJG=sBo^`wh+XHb+aCl$rjdGB!tI{$O;H@dn!+yc(q;rm84vhH?x?53nqFI*gYwpnYH zuPVY1+Zf$qmTHj7vnEuE5z8wV18@0bCvlH!8V8B!gCZ`pOwHRN?_`U2Nt!+I@h?O} zG_~sim^jt%ARh(G@z{H$Na|-%OoFnPqD~$!~cp>}hp#KzRouC`KeSwo&nWfsr1L z;?#MMW4Ujr+bqBa)xeCGmo-v+BSBsp@CKGH!dDVm=j?Ub#uj2-@k8N}P~e(+h|oA5 zzw~p8W$f6XUve>~&ThQl&LbK8wCN)PZaF^$zN%RpkAXozRgDMml0=wBA-a$!b5JUr zYxku@Ux`A_paxHx10pnmp4fJddFL9DvD-PQJ=4p{iYRx(6i`|mg=m;ceOW`-!WXO2CsB1pvEfR>E@2p zO3!ZaoK~+_{Ie^3{Z}xd*ztuYNei0Vp=W_gX}nTz@Pqzdi!n)+V+^eh$;y;lE{mOQ zT?QwsTyH7E47K{fq3f$d{n{BdfM4M4VLT@{tZEK(Ll!KOQ~eFpz(n+hLpyW+F$@xN zD<<5nE@O!2sA6zp%1UR^PpB25mgdesZ;#1O{oRZT!F~%haSB z*xos_f1`i#+|LD`>7=!xZ(97Fa?s>I&TL2El&qCf-l7UdnL)w$ZrE(77p6&O`BpVG z>dDTR&jcMI7ja_L>bMKgnOhuxJ~{yhSClsQGdg}ftnOgVcx0__vo?}=P!eQ%|Mpl$Jq^e&p!bZIJWb;GHy-X+cklk}V9v&;cDAgG^vhH;)Y9_IFLWowbF&D>m_pJ3{tJ88i*5~}(l zqAp~lMk>E&IT&48ZtT)Av)fiHb2GMopp2~kVnFzrmN|yxP?I~mYJK=Nl2A8%O5Q|$ zoG?M9BS{tJ>$%G8m>MjV2&p3wu~0Ac=V=uO)On;^+aFR}HuuYZ59bcUbTm5SI?V6~ z7X-gNf_t*0g(1(FZ>tQQdb1cCw7{KEu)(7kCAetvj5}O(XFxAX2AT?TK6K97>ukCz zC37!T5|p)xYw5q=_cd#WfMzd#a=#s6qvEC#_+7Mm$N4Zp+TFP$;X+QT*>P=-3;9*U zrTPP*7>V|PCHUR^M0p~90@}ZMQ{e1{DlB`~F1C|+#jqU`3#NmHf*7GlstG=gaVFrN^)GtY9B7m!6QK7 zm0z&e%HgqTv`U817EV~;`xOT!$*D94>lHY)+Hap;>Pt`?znmyEVPn;e=#$obK<~&4 z90$ONlo?{yNeYPGTQNk)GlZ45dv|Sw0kq4nI=yEMGJ!HqrH`bIO@xWnGqHYdH5JL( zHD##0wtF9Z<=DxSEcB@PoSe=h27to;#cm(TpM8dB#7LlwWgq;qomS^Os} zKbyl+PV}%sfc)m##7669D{xJ8l6f+ca8h(XEPz>)w5Hwi9ri(=)`%;X<`*k6tkLTAnWu@-g%Gee0oRoNG02 z+8dpHb;aVC$mH|ibsQK1D#i#zWA7YTux?peq{s6}wC@CUnMU}ymmZaUxVj*So%U;1wBON*V%Q`7cGs`?8)k=IGaXHg zk&#c^$^L3_gXK8kCmu|@1>Vc}g!!X^N__FGTp2fxm%4uWmfWm!$rWNEy71kd#&gnE z(gGr|GYoyr6LLT}-$+`d;?%$Z)cWV+TS%_NOl=obulx2H`o5widHu5Jp-(#i zgZi3WUtWH(osyl7&}ZiS0nQY5Oa(cz)tio{G>}>G5NXx|9(>n7fqDrY4f!~}R$%1O zApbNdy)&S5LyhiNtL zfPX&4cw0&J( z0Hc68M*Vnwd}ktZeL@J2pP1j^ z9L_oa4896v$6D@avbB0J`k0tZsY>9aFgkJ0F51kPKz_2vx}8FU#SiO(%)*#GSGkS!aze zXFaQGJVpN&c6*tQR~MYLcx@1Zw;MvPMuGAlA9(J&{`l!)ibr4Qv|E20Nn@ec!}$d* z)Ob4(umA9Cy`v)O5z1`}3n#jseJaiupN_+XMxGbSA@>3FIAUsjlY*v-*O&%wH#Z-y<*zAa5k&5ro6PW7N!Ot13aalKy@C*n*^XUv^tE4WLKVBQHEB4% z0VS|>c#JX$wlKLEK{M^u;8-3Z67(SvcPaf;ZcmD675gi`1+h?ER7wU@DtXD}Xt}D0 z`OgGU&TOdj6PR@?WxbPB+{t>Jm{>~o{=gJ=8qjH<4p~kHue@NQK-OXI;a1^&nF(&4 zd&{tW2Brz!*6aZ7G{Orbo&LfN71ZcWm-pR_MI&a@;M(&WfN7}mW@Hr$v;Dg-HZ?Wd z3(Ynti5`5JxKjOI2Z%!Su)R<&_(^NhTqKLyh-~tlEr2(O?d?8FC8x@;J)gxTU4V9% z34=1M%6r%k3je|Uu}D}fmep1@O^Vv~#6$(<6~4>J#$^{G4CSDHX%aGP_w!o$d@@9{ z5#{TR<-_Z_4l`n1F-Nug=R-=?O<%vpoQP}v=6(J#v=6n9S}9347sUn2QCr8O4%rUF z5htluJ^U$ z{$AAUs?Mj9a>5fV>a!;qQ^jenRsr6nay~m$5%e(&h~nBy4v@Q`H!FFq$y5BRy!92A z4jZh209xCw7^tq?6sD@ttAEgt$kf7npZl)7U%yleWHe<89^pVEX1+FHfb2@zc2nal zw7aDqd-6-_hKAJt#orZk?nY%(#B3D#Vw0NVV40IkS{j*$7^V4}++hKRe34QE)M+sG zn1}onGvpMZYj)@j06_5?lIT7~o9#GT{9-EQ=Q345x7o0sTN+`=<@M|4O-G&W%-0%? zLYVE2+6QjTdP$=KBM6=;eeTJ+ddL$iN2g2qU;Z*eVf&u&@UzqSA8#M+Ce*QC#M$-%3Zz$lq5SSF2I510dz9DlV=*4(mmCXKgkjG0J6IDN(|~ zum#geH^0a-fT3_(8yQ_vY;4}w`jvHTd2{`k)8(v01F3p@x-%UuQ~fQi3EVgpJ%9vL z00IHvX5x%j0}HpAuQD;_73lYOo{8$87a{tmTddSxh8=X0(F*94Zg+6F`K#^W+0qgA zY&LpO4$An`%pTwx9qr_y$=pnJQFDA62-8^Uz%Ud0=j4yrf$`vLbs28!fnbPNDqnP4 zBYvA(*P*D*jGNq`1=QThh4;2lBYhXEhF{d3?HK*Ap=BjoDvNo*oOP6e8g)Og%>|5|b{7J*g%(o(O5K_}xC#$g<3_5UNr#b!u;38~mL#*Z&)151DK(+-PFGe?iz5>|8Z9&jV1d^A2L;Wp0zV zInt|3&#B7`*0;6k*jif_z+IfCy_vw*%8*%lTDLFwqW~mTTg6KBu`oS2R5Mq0P{tdSGtNzbO2LpEmmeU99{J`e!Z#xbjJ{QY5h})<)cF_53B;HYHJD@vTy88Mu zP{hkBR(vZ=Qj^)VbbXc0cf(^Rhv#7TMz`n%v-CsA;sr`sf1xp1Y!JVh%$7Ccp?=%% zYCueiRYk+iFx2ChkDs3`di1015Kf@x#?Gc<`_UodRfN{0iTse+6Bgpd4R|lj_lNI* zs(Oo1p{U}hw_b3HCyCe370RI}@pe6TQFI=YH7_e5-baYN>6|LT70Tm&ee2t0{a?fj&f_EPiWd#OdsBL#xi{fMcdEWqBGqvBVQ3oQ( zM14>#QT#qZQ7sFMv`1qE+a%l+rmF{IhNunOh%l69A{9QtJn>ccc&lNbPM0B2`l2bBC$fMC`l&P`;DdvbvvysI}gb7~2E(*XTQR{Zpi(?h5 zQ(+TF>| zLF*nHF|j#>mujAQnTkt_)#hx&BLa~L?0l*tOdL7$SBTKGS;yRAU05?fd!_k>J&iCo z{o0W3(MS8Viupwn4PHRfZDye-WZ&t(2MER%1mfyD*O#q{iDMZq=E)X6D|#<;r1QgF z6nPJ62|PwWFUL2aU;5Ts_6Ya%5bSU8*pVVcLFRqroeAJF-_pK=5)Lv^G)}9sG z#a)aOJEvIl>sIFUwYsp@0j>`nJrT4ySD~Gd(BVeN=DzwB$l;4GA;rwdh%!qPVpA1xu`PXxbGnH>% zKDF7V^{;R$UvKfKca6Eb3;p$-v`?P(8&y0ET`CFj-@C4%`0(~*r_bJ4%#;d8PVMWu zn+VHu98UK$-6qD=jz2wJ+4_b%jMEcoVG-xGroi!3s`qgJ@745+|F3| zKR#!7FhN!`r;H|EnYz`zC-zjlXr?9#Q@Iw3Bf;};IO({*_MC9vT+;*fkrq(JqctBF*bmb0QiE0a8}uJ@EHz| z>e6>kle08yuc-i_`2=M5#j`((p3>jOiHX(#r1CJ7jg2Aq6W_a_7Ks|BkBlG*KesQ+ znQEZXt}&?MFLUi=0YQJOBxHy8=>aUcNgR->B0??alL^?Mq^H|Eb8;q(QOtnke>y0m zkN3_gl8Rrc@uo@4s8GVm9;wEt4vqZ-E*f#5bJui0*YC^U&KWElNj_@y?IgG+Rd8+M zZ+_LEzw&(Ww)W(O<&v)7T|!na7Cbt~^qx<6?gT@IP}wpeW25dwT=;|Ye=ODfBiBlq zFs!x(`g<3cSAJZ+;@>%_vC4-}AzTgeC{=>Hqm`R%lf`q#Ni!%3py=`%fg`fyiM7`a z7U;rq$$jJI?Eqx3qV6BN68iAkGcEs%7))hI%);m^$gsLdlij#y-_0PmrYVIdI~+Mz za9HOSVLoNYwD!Q7)>-YgnGu5xHF3M`{ui6YZ+3^vc=wJusdG8GgxqL%S^|!-k?V1Na06~H} zc#~w0VKWy9LusHCJ4|7NLvPagFLr)@g3V2z?L5HIp%rP!7X9oFlQHP*fz#Y*4vv*^ zw%MihgsIuL1Z4AV#zaKb!J2GU1RPuZ;uh2T)bvhd6L{iWy^fhwGJxZZ>r7HQu0SB% z&TZ!g0qfr1T+(xHSU9E^dejx3WR~6}m30K;G`Es&Y=t#WMYM#s2)9}krML^WeB4gt z#CdPHxPJk93KBhut5vt*iM}KBW9jW}3ETh|1`TsXEv9&H4A|D~G~b!mYJ$$1FFSb* zbE<2lW^zrjhXXZed`8yGeVpTS{deuPXF$yhFZrSx91

(Xc`;0I4NU(*P~Iwzi* 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 n&zk(lPX6ON|7YS@kL7V`lYxF_n!PWcj~Fz literal 0 HcmV?d00001 diff --git a/scripts/plot_sr_results.py b/scripts/plot_sr_results.py new file mode 100644 index 0000000..f042ee0 --- /dev/null +++ b/scripts/plot_sr_results.py @@ -0,0 +1,254 @@ +#!/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}/") diff --git a/src/SR_analysis/README.md b/src/SR_analysis/README.md index a7a96b5..79bda9f 100644 --- a/src/SR_analysis/README.md +++ b/src/SR_analysis/README.md @@ -12,278 +12,174 @@ cloak and illusion scenes, using dimensionless physical features, G-equivariant structural constraints, and STLSQ threshold grids. For background, see: -- `src/sindy_sr_notes.md` -- execution plan -- `src/sindy_sr_knoeledge.md` -- confirmed facts and known pitfalls +- `sindy_sr_notes.md` -- execution plan and task tracking +- `sindy_sr_knowledge.md` -- confirmed facts and known pitfalls +- `../../docs/SR_analysis_results.md` -- comprehensive results report + +--- ## Directory Structure ``` SR_analysis/ - configs.py # Unified scene metadata (all 10 scenes) + configs.py # Unified scene metadata (all 10+ scenes) configs/ - legacy/ # Legacy CFD configs (config_cuda.json, config_flowfield.json) + legacy/ # Legacy CFD configs utils/ __init__.py # Selective exports (no pycuda dependency) - feature_builder.py # Dimensionless features + G-operator (from analysis_cloak) - sindy_fitter.py # STLSQ threshold grid, feature matrix builder + feature_builder.py # Dimensionless features + G-operator + phase-state features + sindy_fitter.py # STLSQ + feature matrices + derivative/absolute modes cfd_interface.py # LegacyCelerisLab wrapper (requires pycuda_3_10) g_operator.py # Equivariance diagnostics data/ - karman/ # Karman cloak: karman_re50, re100, re200, re400 - steady/ # Steady cloak: steady_data.npz - illusion/ # Illusion: illusion_0.75L, illusion_1L, illusion_1.5L - vortex/ # Vortex cloak: vortex_lamb, vortex_taylor + karman/ # Karman cloak: karman_re50/100/200/400 + steady/ # Steady cloak + illusion/ # Illusion: illusion_0.75L/1L/1.5L + vortex/ # Vortex cloak scripts/ infer_karman.py # Inference: LegacyCFD + PPO -> controlled.npz - infer_illusion.py # Inference: for 0.75L, 1L, 1.5L diameters - infer_vortex.py # Inference: for Lamb dipole + Taylor monopole + infer_illusion.py # Inference for illusion scenes + infer_vortex.py # Inference for vortex scenes sindy/ - run_karman.py # SINDy fitting for Karman scenes - run_illusion.py # SINDy fitting for Illusion scenes - run_vortex.py # SINDy fitting for Vortex scenes - run_pareto.py # Pareto-front analysis from SINDy results - karman/ # Output: sindy_results.json, pareto_*.json - illusion/ # Output: sindy_results.json, pareto_*.json - vortex/ # Output: sindy_results.json, pareto_*.json + run_all_v2.py # Unified SINDy fitting (supports --deriv, --phase, --output-mode etc.) + run_pysr.py # Restricted PySR symbolic regression + wrap_joint.py # Joint model -> wrapped format for validator + compare_v2.py # Cross-scene comparison report + karman/illusion/vortex/ # SINDy output JSONs validate/ - run_closed_loop.py # Unified closed-loop validator (v23 + unstructured modes) + run_closed_loop.py # Karman closed-loop validator (v23/deriv/abs modes) + run_closed_loop_illusion.py # Illusion closed-loop validator + eval_rollout.py # Offline multi-step rollout evaluation + results/ # Validation result JSONs compare/ - support_overlap.py # Pairwise support set comparison - shared_core.py # Multi-scene shared-core detection + support_overlap.py # Support set comparison + shared_core.py # Shared core detection ``` +--- + ## Key Design Decisions ### 1. Scene Metadata Driven -All scene parameters (Re, action scaling, geometry, model paths) are defined -once in `configs.py`, not hard-coded in scripts. Adding a new scene means -adding one dict to `configs.py`. +All scene parameters defined once in `configs.py`. -### 2. Data / Features / Models Separation +### 2. Feature Levels -- `data/` -- raw sensor/force/action arrays (.npz), one-time generation -- `sindy/` -- SINDy fitting results (JSON), reusable for comparison -- `scripts/` -- inference pipelines that produce `data/` +| Level | Features | Dim | Description | +|-------|----------|:---:|-------------| +| Static | u_m, u_a, u_c, v_a, Cd_tot, Cd_rear, Cl_tot, Cl_diff | 8 | Current-step only | +| **Phase-state** | u_a, du_a/dt, Cl_tot, dCl_tot/dt, Cd_tot, Cd_rear | **6** | Oscillation phase + rate | +| Illusion-phase | Phase-state + Cd_err, Cl_err, dCd_err/dt, dCl_err/dt | **10** | Phase + error-state | +| Karman-expanded | Phase-state + u_m, u_c, v_a, Cl_diff | **10** | Phase + supplementary | +| Full-lag | Static + lag-1 | 16 | Full temporal context | -### 3. Unified Feature Builder +### 3. Output Modes -Every scene uses the same `utils/feature_builder.py`, which produces -21 dimensionless features from raw lattice-unit sensor/force data: - -**Sensor features (nondim):** -- `u_m`, `u_a`, `u_c` -- streamwise: mean, antisymmetric, centre -- `v_a` -- antisymmetric cross-stream -- `sin_ua`, `cos_ua` -- phase encoding via u_a - -**Force features (Cd/Cl):** -- `Cd_tot`, `Cd_rear` -- total and rear-cylinder drag -- `Cl_tot`, `Cl_diff` -- total and differential lift - -**Memory features (nondim alpha):** -- `aF_lag1`, `aB_lag1`, `aT_lag1` -- lagged actions (t-1) -- `daF`, `daB`, `daT` -- action increments (t-1)-(t-2) - -**Reynolds modulation:** -- `mu` (= 1/Re_D), `mu_u_a`, `mu_v_a`, `mu_Cd_tot`, `mu_Cl_diff` +- **deriv**: predict `d(alpha)/dt`, then `alpha(t) = alpha(t-1) + dt_c * dalpha/dt` +- **absolute**: predict `alpha(t)` directly (no integration drift) ### 4. G-Equivariant Structure (v23) -Default control law structure (confirmed as the best v23 model): - ``` -Front(t) = f_front(x(t)) # no bias, odd under G -Top(t) = f_rear(x(t)) # with bias -Bottom(t) = -f_rear(G[x(t)]) # shared-head: bottom = -top(Gx) +Front(t) = f_front(x(t)) # no bias, odd under G +Top(t) = f_rear(x(t)) # with bias +Bottom(t) = -f_rear(G[x(t)]) # shared-head ``` -Where G is the mirror operator (y -> -y) with corrected sign rules: -- `[aF, aT, aB] -> [-aF, -aB, -aT]` -- Sensor swap: top <-> bottom, negate v -- Force swap: front unchanged, bottom <-> top, negate Cl +--- -### 5. STLSQ Threshold Grid +## Current Best Results (2026-06-15) -Default thresholds: `[0, 0.001, 0.002, 0.005, 0.01, 0.015, 0.02, 0.03, 0.05, 0.1]` -Per-channel: front (no bias), top (shared-head), bottom (independent, for comparison) +### Illusion — New Route: Phase-state + Error-state + Absolute Action -## Scene Inventory +| Scene | Closed-loop | % of PPO | Action history? | Features | +|-------|:----------:|:--------:|:---------------:|----------| +| 0.75L | **0.974** | 100.2% | **No** | ILLUSION_PHASE (10dim) | +| 1L | **0.958** | 98.5% | **No** | ILLUSION_PHASE (10dim) | +| 1.5L | N/A | — | **No** | Bang-bang regime | -| Scene Name | Description | Re_code | Sample Interval | Action | U0 | -|---|---|---|---|---|---| -| karman_re50 | Karman cloak at low Re | 50 | 800 | 8x + [0,-4,4] | 0.01 | -| karman_re100 | Karman cloak (default) | 100 | 800 | 8x + [0,-4,4] | 0.01 | -| karman_re200 | Karman cloak at high Re | 200 | 800 | 8x + [0,-4,4] | 0.01 | -| karman_re400 | Karman cloak at highest Re | 400 | 800 | 8x + [0,-4,4] | 0.01 | -| steady | Open-loop constant rotation | 100 | 800 | 8x + [0,-5.1,5.1] | 0.01 | -| illusion_0.75L | Imitate 0.75D cylinder | 100 | 600 | 8x + [0,-2,2] | 0.01 | -| illusion_1L | Imitate 1.0D cylinder | 100 | 600 | 8x + [0,-2,2] | 0.01 | -| illusion_1.5L | Imitate 1.5D cylinder | 100 | 600 | 8x + [0,-2,2] | 0.02 | -| vortex_lamb | Cloak Lamb dipole | 100 | 800 | 4x + [0,-4,4] | 0.01 | -| vortex_taylor | Cloak Taylor monopole | 100 | 800 | 4x + [0,-4,4] | 0.01 | +### Karman re100 — Ablation -Note: "Re_code" uses reference length 2*D (code convention). -Physical Re_D = Re_code / 2. E.g. Re_code=100 -> Re_D=50. +| Config | Feat | Output | R2 | Closed-loop | Note | +|--------|:----:|:-----:|:--:|:----------:|------| +| old v23 (a_lag) | 14+3 | alpha | 0.996 | **0.901** | Baseline | +| **Phase->abs** | **6** | **alpha** | **0.965** | **0.699** | Best new route | +| Phase->deriv | 6 | dalpha/dt | 0.837 | 0.656 | | +| Phase+mu->abs | 9 | alpha | 0.979 | 0.700 | mu helps cross-Re | +| Expanded->abs | 10 | alpha | 0.980 | 0.580 | Overfitting | -## Re-generation Commands +--- -All commands run from repo root (`/home/frank14f/DynamisLab`). +## Commands -### Data Generation (requires GPU, pycuda_3_10 env) +All from repo root (`/home/frank14f/DynamisLab`). + +### SINDy Fitting ```bash -# Karman cloak -- all 4 training Re -conda run -n pycuda_3_10 python src/SR_analysis/scripts/infer_karman.py --re all --device 0 +# Illusion phase-state + absolute (recommended for 0.75L/1L) +conda run -n pycuda_3_10 python src/SR_analysis/sindy/run_all_v2.py \ + --scenes illusion_0.75L,illusion_1L --deriv --phase --output-mode absolute -# Karman cloak -- single Re -conda run -n pycuda_3_10 python src/SR_analysis/scripts/infer_karman.py --re 100 --device 0 --steps 200 +# Karman phase-state + absolute +conda run -n pycuda_3_10 python src/SR_analysis/sindy/run_all_v2.py \ + --scenes karman_re100 --deriv --phase --output-mode absolute -# Illusion -- all 3 diameters -conda run -n pycuda_3_10 python src/SR_analysis/scripts/infer_illusion.py --diameter all --device 0 +# Karman expanded (10 dim) +conda run -n pycuda_3_10 python src/SR_analysis/sindy/run_all_v2.py \ + --scenes karman_re100 --deriv --karman-expand --output-mode absolute -# Vortex -- both types -conda run -n pycuda_3_10 python src/SR_analysis/scripts/infer_vortex.py --type all --device 0 +# Karman with mu modulation +conda run -n pycuda_3_10 python src/SR_analysis/sindy/run_all_v2.py \ + --scenes karman_re100 --deriv --karman-mu --output-mode absolute + +# Old-style (v2, with action history) +conda run -n pycuda_3_10 python src/SR_analysis/sindy/run_all_v2.py \ + --scenes karman_re50,karman_re100 --joint ``` -### SINDy Fitting (no GPU needed, pycuda_3_10 env for pysindy) - -```bash -conda run -n pycuda_3_10 python src/SR_analysis/sindy/run_karman.py -conda run -n pycuda_3_10 python src/SR_analysis/sindy/run_illusion.py -conda run -n pycuda_3_10 python src/SR_analysis/sindy/run_vortex.py -``` - -### Pareto Analysis (no GPU, no conda needed) - -```bash -python3 src/SR_analysis/sindy/run_pareto.py --scene karman_re100 -python3 src/SR_analysis/sindy/run_pareto.py --scene illusion_1L -``` - -### Closed-loop Validation (requires GPU) +### Closed-loop Validation ```bash +# Karman with absolute action conda run -n pycuda_3_10 python src/SR_analysis/validate/run_closed_loop.py \ - --scene karman_re70 --device 2 \ - --sindy-results src/SR_analysis/sindy/karman/sindy_results.json + --scene karman_re100 --device 0 --steps 200 --mode abs \ + --sindy-results src/SR_analysis/sindy/karman/sindy_results_deriv.json -# With custom mode +# Karman old v23 conda run -n pycuda_3_10 python src/SR_analysis/validate/run_closed_loop.py \ - --scene karman_re70 --device 2 --mode unstructured + --scene karman_re100 --device 0 --steps 200 --mode v23 \ + --sindy-results src/SR_analysis/sindy/karman/sindy_joint_wrapped.json + +# Illusion with absolute action +conda run -n pycuda_3_10 python src/SR_analysis/validate/run_closed_loop_illusion.py \ + --scene illusion_1L --device 0 --steps 320 \ + --sindy-results src/SR_analysis/sindy/illusion/sindy_results_deriv.json ``` -### Cross-scene Comparison (no GPU) +### PySR Symbolic Regression ```bash -# Pairwise support overlap -python3 src/SR_analysis/compare/support_overlap.py \ - --sindy-results src/SR_analysis/sindy/karman/sindy_results.json \ - --scenes karman_re100 illusion_1L - -# Multi-scene shared core -python3 src/SR_analysis/compare/shared_core.py \ - --sindy-results src/SR_analysis/sindy/karman/sindy_results.json \ - --scenes karman_re50 karman_re100 karman_re200 karman_re400 +conda run -n sr_env python src/SR_analysis/sindy/run_pysr.py --scene illusion_1L ``` -## Key Results Summary +### Offline Rollout Evaluation -### Data Quality (similarity scores) +```bash +python3 src/SR_analysis/validate/eval_rollout.py \ + --sindy-results src/SR_analysis/sindy/karman/sindy_results_deriv.json \ + --scene karman_re100 +``` -| Scene | PPO Similarity | -|---|---| -| karman_re50 | 0.962 | -| karman_re100 | 0.954 | -| karman_re200 | 0.884 | -| karman_re400 | 0.795 (inferred, not verified) | -| vortex_lamb | 0.942 | -| vortex_taylor | 0.916 | -| illusion_1L | ~0.55 (metric not directly comparable) | +--- -### SINDy Fit Quality (R2 scores for one-step prediction) +## Important Reminders -| Scene | Front | Top (shared) | Bottom | -|---|---|---|---| -| karman_re50 | 0.998 | 0.989 | 0.996 | -| karman_re100 | 0.995 | 0.993 | 0.997 | -| karman_re200 | 0.957 | 0.914 | 0.918 | -| karman_re400 | 0.991 | 0.979 | 0.969 | -| illusion_0.75L | 0.991 | 0.989 | 0.990 | -| illusion_1L | 0.979 | 0.984 | 0.984 | -| illusion_1.5L | 0.959 | 0.928 | 0.932 | -| vortex_lamb | 0.904 | 0.980 | 0.933 | -| vortex_taylor | 0.960 | 0.810 | 0.643 | - -### Shared Core Features - -**Karman cross-Re (active in all re50/100/200):** -- Front core: `mu`, `mu_Cd_tot`, `mu_Cl_diff`, `mu_v_a` (mu-modulated terms dominate) -- Top core: `Cl_tot`, `bias`, `mu_Cd_tot`, `mu_Cl_diff`, `mu_u_a`, `mu_v_a` -- Scene-specific: lower-Re scenes have additional `Cd_tot`, `Cl_diff`, `aT_lag1` etc. - -**Illusion cross-diameter (active in all 0.75L/1L/1.5L):** -- Front core: `mu`, `mu_Cd_tot`, `mu_Cl_diff` (same structure as Karman front!) -- Top core: `Cd_rear`, `Cl_tot`, `bias`, `mu_Cd_tot`, `mu_Cl_diff` -- This suggests a **shared mu-modulated feedback structure** exists across both scenes - -## Known Issues and Caveats - -1. **Vortex Taylor rear channels** have low R2 (0.64-0.81). The weak monopole - produces near-zero rear action, making SINDy fitting noisy. Use Lamb as the - primary vortex reference. - -2. **Closed-loop validator** (`validate/run_closed_loop.py`) has been ported but - NOT yet tested end-to-end. The original `validate_v23.py` verified Karman - but the new unified version has not been run. - -3. **Illusion similarity scores** use the Karman CONV_LEN=30 metric, giving - lower raw numbers. The controlled.npz data itself is valid for SINDy. - -4. **Steady cloak** is open-loop constant rotation, not PPO-derived. It serves - as a physical consistency check, not a primary comparison scene. - -5. **SINDy one-step R2 is not sufficient** -- a high R2 does not guarantee good - closed-loop performance. Always validate via `validate/run_closed_loop.py`. - -6. **Scene key naming**: keys like `illusion_1L`, `illusion_1.5L` use the short - float format from Python (1.0 -> "1L", 1.5 -> "1.5L", 0.75 -> "0.75L"). - -## Next Steps (Future Work) - -1. **PySR symbolic regression** -- Run PySR on the SINDy-identified active - features (in `sr_env` conda env) to find closed-form formulas. Essential - reading: `src/pysr.md`. - -2. **Closed-loop validation of all new scenes** -- Run - `validate/run_closed_loop.py` for illusion and vortex scenes using their - SINDy coefficients. - -3. **Cross-scene shared backbone test** -- Fit a single SINDy model on merged - Karman + Illusion data, test if it performs on both. - -4. **Time-scale explicit formulation** -- Make the sample interval an explicit - feature to compare control laws across different frequencies. - -5. **Steady as consistency check** -- Validate that Karman-derived control laws - can reproduce the steady cloak result as a sanity check. - -## File Reference - -| File | Lines | Purpose | -|---|---|---| -| configs.py | ~205 | Unified scene metadata | -| utils/feature_builder.py | ~212 | Dimensionless features + G-op | -| utils/sindy_fitter.py | ~175 | STLSQ fitting, feature matrix builder | -| utils/cfd_interface.py | ~370 | LegacyCelerisLab wrapper | -| utils/g_operator.py | ~170 | Equivariance diagnostics | -| utils/__init__.py | ~10 | Selective exports | -| scripts/infer_karman.py | ~250 | Karman inference pipeline | -| scripts/infer_illusion.py | ~270 | Illusion inference pipeline | -| scripts/infer_vortex.py | ~280 | Vortex inference pipeline | -| sindy/run_karman.py | ~160 | Karman SINDy fitting | -| sindy/run_illusion.py | ~110 | Illusion SINDy fitting | -| sindy/run_vortex.py | ~110 | Vortex SINDy fitting | -| sindy/run_pareto.py | ~140 | Pareto analysis | -| validate/run_closed_loop.py | ~270 | Closed-loop validator | -| compare/support_overlap.py | ~150 | Pairwise support comparison | -| compare/shared_core.py | ~140 | Multi-scene shared core detection | +- `controlled.npz` actions are **normalized [-1,1]** — must convert via `(norm * scale + bias) * u0` +- **FIFO bias ≠ DRL action bias** for Illusion: FIFO=[0, -0.01, 0.01], decode=[0, -0.02, 0.02] +- "2U" in model name = S_DIM=14 (not 2x velocity), u0 always 0.01 +- SAMPLE_INTERVAL: 0.75L=400, 1L=600, 1.5L/Karman=800 +- Closed-loop steps auto-set: S=400→320, S=600→214, S=800→160 +- One-step R² high ≠ closed-loop good — always validate +- For phase-state features, always pass `sensors_raw`/`forces_raw` to enable derivative computation diff --git a/src/SR_analysis/configs.py b/src/SR_analysis/configs.py index 93ba369..9974f84 100644 --- a/src/SR_analysis/configs.py +++ b/src/SR_analysis/configs.py @@ -27,7 +27,7 @@ NX = 1280 NY = 512 CENTER_Y = (NY - 1) / 2.0 FIFO_LEN = 150 -CONV_LEN = 30 +CONV_LEN = 30 # default; per-scene conv_len overrides this (Illusion=36) def nu_from_re(re_code: float, u0: float = U0) -> float: @@ -72,7 +72,30 @@ for rc, mn in [(50, "d1a3o12_re50"), (100, "d1a3o12_re100"), "u0": U0, } -# -- Steady Cloak (open-loop constant rotation) ----------------------------- +# -- Karman Cloak generalization (unseen Re, no PPO model) -------------------- +for rc in [25, 70, 150, 300]: + key = f"karman_re{rc}" + SCENES[key] = { + "scene_id": "karman", + "re_code": rc, + "mu": 2.0 / rc, + "nu": nu_from_re(rc), + "has_disturbance": True, + "sample_interval": 800, + "action_scale": 8.0, + "action_bias": (0.0, -4.0, 4.0), + "source": "generalization", + "model_name": None, + "n_objects_env": 7, + "obs_slice": (2, 14), + "sensor_x": 40.0, + "pinball_front_x": 30.0, + "pinball_rear_x": 31.3, + "target_type": "periodic", + "s_dim": 12, + "u0": U0, + } + SCENES["steady"] = { "scene_id": "steady", "re_code": 100, @@ -94,26 +117,29 @@ SCENES["steady"] = { "u0": U0, } -# -- Illusion (cylinder imitation, 3 diameters, 1U=0.01) -------------------- +# -- Illusion (cylinder imitation, 3 diameters) ---------------------------- +# "2U" in model name means S_DIM=14 (2 extra target force channels), NOT 2x velocity. +# u0 is always 0.01, nu is always 0.004 (default) unless Vis suffix present. def _illusion_key(diam: float) -> str: """Generate clean illusion scene key.""" s = f"{diam:.3f}".rstrip("0").rstrip(".") return f"illusion_{s}L" -_ILLUSION_1U = [ - (0.75, "d1a3o12_250525_imit_075L_1U"), - (1.0, "d1a3o12_250525_imit_1L_1U"), +_ILLUSION_BEST = [ + (0.75, "d1a3o14_250525_imit_075L_2U_400S", 400, 14), + (1.0, "d1a3o14_250525_imit_1L_2U_600S", 600, 14), ] -for diam, mn in _ILLUSION_1U: +for diam, mn, si, sd in _ILLUSION_BEST: key = _illusion_key(diam) SCENES[key] = { "scene_id": "illusion", "target_diameter": diam, "re_code": 100, "mu": 2.0 / 100, - "nu": nu_from_re(100), + "nu": 0.004, # default, no Vis suffix in model name "has_disturbance": False, - "sample_interval": 600, + "sample_interval": si, + "conv_len": 36, # Illusion uses 36, not default 30 "action_scale": 8.0, "action_bias": (0.0, -2.0, 2.0), "source": "PPO_inference", @@ -124,19 +150,20 @@ for diam, mn in _ILLUSION_1U: "pinball_front_x": 19.0, "pinball_rear_x": 20.3, "target_type": "periodic", - "s_dim": 12, - "u0": U0, + "s_dim": sd, + "u0": U0, # always 0.01 } -# 1.5L Illusion (2U=0.02 model) +# 1.5L Illusion (SAMPLE_INTERVAL=800, default, no S suffix in name) SCENES[_illusion_key(1.5)] = { "scene_id": "illusion", "target_diameter": 1.5, "re_code": 100, "mu": 2.0 / 100, - "nu": nu_from_re(100, u0=0.02), + "nu": 0.004, # default, no Vis suffix "has_disturbance": False, - "sample_interval": 600, + "sample_interval": 800, # no S suffix in name = default 800 + "conv_len": 36, # Illusion uses 36 "action_scale": 8.0, "action_bias": (0.0, -2.0, 2.0), "source": "PPO_inference", @@ -148,7 +175,7 @@ SCENES[_illusion_key(1.5)] = { "pinball_rear_x": 20.3, "target_type": "periodic", "s_dim": 14, - "u0": 0.02, + "u0": U0, # always 0.01 } # -- Vortex Cloak (Lamb dipole + Taylor monopole) -------------------------- @@ -183,6 +210,37 @@ for vtype, mn, strength in _SCENES_VORTEX: } +# -- Illusion generalization (unseen diameters, no PPO model) ---------------- +for diam, mn, si in [ + (0.5, None, 400), + (1.2, None, 600), + (2.0, None, 800), +]: + key = _illusion_key(diam) + SCENES[key] = { + "scene_id": "illusion", + "target_diameter": diam, + "re_code": 100, + "mu": 2.0 / 100, + "nu": 0.004, + "has_disturbance": False, + "sample_interval": si, + "conv_len": 36, + "action_scale": 8.0, + "action_bias": (0.0, -2.0, 2.0), + "source": "generalization", + "model_name": mn, + "n_objects_env": 6, + "obs_slice": (0, 12), + "sensor_x": 30.0, + "pinball_front_x": 19.0, + "pinball_rear_x": 20.3, + "target_type": "periodic", + "s_dim": 14, + "u0": U0, + } + + # -- Utility helpers --------------------------------------------------------- def get_scene(name: str) -> dict: diff --git a/src/SR_analysis/scripts/infer_illusion.py b/src/SR_analysis/scripts/infer_illusion.py index 72dc90c..3278384 100644 --- a/src/SR_analysis/scripts/infer_illusion.py +++ b/src/SR_analysis/scripts/infer_illusion.py @@ -4,9 +4,9 @@ Generates controlled data for a given target cylinder diameter using LegacyCelerisLab + trained PPO model. Usage: - conda run -n pycuda_3_10 python scripts/infer_illusion.py \\ + conda run -n pycuda_3_10 python scripts/infer_illusion.py \ --diameter 1.0 --device 0 - conda run -n pycuda_3_10 python scripts/infer_illusion.py \\ + conda run -n pycuda_3_10 python scripts/infer_illusion.py \ --diameter all --device 2 """ from __future__ import annotations @@ -37,12 +37,61 @@ from SR_analysis.utils.cfd_interface import ( ) from SR_analysis.configs import ( get_scene, get_scene_list, model_path_for_scene, - LEGACY_CFG_DIR, FIFO_LEN, CONV_LEN, + LEGACY_CFG_DIR, FIFO_LEN, ) DATA_TYPE = np.float32 +def analyze_harmonics(states: np.ndarray, n_harmonics: int = 5) -> list: + """FFT-based harmonics analysis matching legacy_env_imit.py exactly. + + Args: + states: (T, D) time series + n_harmonics: number of harmonics (excluding DC) to keep + + Returns: + list of dict per channel: {dc, amps, freqs, phases} + """ + N, D = states.shape + result = [] + for d in range(D): + y = states[:, d] + fft_coef = np.fft.rfft(y) + freqs = np.fft.rfftfreq(N, d=1) + amps = 2 * np.abs(fft_coef) / N + phases = np.angle(fft_coef) + idx = np.argsort(amps[1:])[::-1][:n_harmonics] + 1 + harmonics = { + 'dc': float(np.real(fft_coef[0]) / N), + 'amps': amps[idx].tolist(), + 'freqs': freqs[idx].tolist(), + 'phases': phases[idx].tolist(), + } + result.append(harmonics) + return result + + +def gen_target_states_at(t: int, harmonics: list) -> np.ndarray: + """Reconstruct target forces at step t from harmonics. + + Args: + t: current step index (0-based) + harmonics: from analyze_harmonics() + + Returns: + ndarray of shape (D,) with reconstructed values + """ + D = len(harmonics) + result = np.zeros(D, dtype=np.float32) + for d, h in enumerate(harmonics): + val = h['dc'] + for amp, freq, phase in zip(h['amps'], h['freqs'], h['phases']): + val += amp * np.cos(2 * np.pi * freq * t + phase) + result[d] = float(val) + return result + + def run_single_illusion( scene_name: str, device_id: int, @@ -55,13 +104,15 @@ def run_single_illusion( u0 = cfg["u0"] l0 = 20.0 sample_interval = cfg["sample_interval"] + conv_len = cfg.get("conv_len", 30) # Illusion uses 36 action_scale = cfg["action_scale"] - action_bias = cfg["action_bias"] + action_bias = cfg["action_bias"] # (front, bottom, top) for DRL decoding n_obj_total = cfg["n_objects_env"] sensor_x = cfg["sensor_x"] # 30.0 for illusion front_x = cfg["pinball_front_x"] # 19.0 rear_x = cfg["pinball_rear_x"] # 20.3 target_diam = cfg["target_diameter"] + s_dim = cfg["s_dim"] # 14 for all best illusion models os.makedirs(output_root, exist_ok=True) @@ -74,17 +125,15 @@ def run_single_illusion( json.dump({k: str(v) if not isinstance(v, (int, float, list, bool)) else v for k, v in cfg.items()}, f, indent=2) - # Load legacy CFD configs with overridden viscosity and velocity + # Load legacy CFD configs with overridden viscosity cuda_cfg, field_cfg = load_legacy_configs(LEGACY_CFG_DIR) field_cfg = field_cfg._replace(viscosity=float(nu)) - if u0 != 0.01: - field_cfg = field_cfg._replace(velocity=float(u0)) # -- Phase 1: Target recording (target cylinder + 3 sensors) ------------ ff = FlowField(field_cfg, cuda_cfg, device_id=device_id) ny = ff.FIELD_SHAPE[1] - # Add target cylinder at x=20*L0, with radius = target_diam * L0 + # Add target cylinder at x=20*L0 print(f" Adding target cylinder: diam={target_diam}L, pos=({20*l0:.0f}, {ny/2:.0f})") ff.add_cylinder((20.0 * l0, (ny - 1) / 2, 0.0), target_diam * l0) @@ -101,16 +150,30 @@ def run_single_illusion( print(f" Stabilising ({stabilize_steps} steps)...") ff.run(stabilize_steps, np.zeros(n_obj_phase1, dtype=DATA_TYPE)) - # Record target + # Record target: 8 channels = [cylinder_fx, cylinder_fy, sensor0_ux,uy, sensor1_ux,uy, sensor2_ux,uy] + fifo_len = FIFO_LEN # 150 target_states = np.empty((0, 8), dtype=DATA_TYPE) - for _ in range(FIFO_LEN): + for _ in range(fifo_len): ff.run(sample_interval, np.zeros(n_obj_phase1, dtype=DATA_TYPE)) - new_state = ff.obs.copy()[0:8] # sensor[6] + cylinder force[2] + new_state = ff.obs.copy()[0:8] # cylinder[2] + sensor[6] = 8 channels target_states = np.vstack((target_states, new_state)) print(f" Target recorded: {target_states.shape}") - # Save target - np.savez(os.path.join(output_root, "target.npz"), target_states=target_states) + # Analyze harmonics of force channels (channel 0,1 = force; 2..7 = sensors) + # Legacy env uses target_states[:, 2:8] (sensors only) for DTW comparison, + # and target_states[:, 0:2] (cylinder force) for target_cd/target_cl via harmonics. + target_sensors = target_states[:, 2:8].copy() # (150, 6) for similarity + target_forces = target_states[:, 0:2].copy() # (150, 2) for harmonics + target_harmonics = analyze_harmonics(target_forces, n_harmonics=5) + print(f" Target harmonics computed: {len(target_harmonics)} channels") + print(f" DC: {target_harmonics[0]['dc']:.6f}, {target_harmonics[1]['dc']:.6f}") + + # Save target data + np.savez(os.path.join(output_root, "target.npz"), + target_states=target_states, + target_sensors=target_sensors) + with open(os.path.join(output_root, "target_harmonics.json"), "w") as f: + json.dump(target_harmonics, f, indent=2) # Clean up and create pinball env del ff @@ -123,7 +186,6 @@ def run_single_illusion( ff.add_sensor(sc, l0 / 4.0) # Add 3 pinball cylinders (illusion positions) - # Front at x=front_x*L0, rear at x=rear_x*L0 ff.add_cylinder((front_x * l0, (ny - 1) / 2, 0.0), l0 / 2.0) ff.add_cylinder((rear_x * l0, (ny - 1) / 2 + 0.75 * l0, 0.0), l0 / 2.0) ff.add_cylinder((rear_x * l0, (ny - 1) / 2 - 0.75 * l0, 0.0), l0 / 2.0) @@ -140,9 +202,9 @@ def run_single_illusion( ff.get_ddf() ff.save_ddf() - # Norm collection (zero action) - fifo = deque(maxlen=FIFO_LEN) - for _ in range(FIFO_LEN): + # Norm collection (zero action) — matches legacy_env_imit.py + fifo = deque(maxlen=fifo_len) + for _ in range(fifo_len): ff.run(sample_interval, np.zeros(n_obj, dtype=DATA_TYPE)) fifo.append(ff.obs.copy()[0:12]) @@ -161,21 +223,28 @@ def run_single_illusion( } print(f" norm: force_norm_fact={force_norm_fact:.6f}") - # Bias-action rollout + # Bias-action rollout — match legacy_env_imit.py EXACTLY: + # legacy line 142-143: np.array([0.0, 0.0, 0.0, 0.0, -1*U0, 1*U0]) + # This is NOT action_bias (which is [0, -2, 2]*U0). + # It's a specific weaker bias: [0, -U0, U0] = [0, -0.01, 0.01] ff.apply_ddf() bias_arr = np.zeros(n_obj, dtype=DATA_TYPE) - bias_arr[3] = float(action_bias[0] * u0) - bias_arr[4] = float(action_bias[1] * u0) - bias_arr[5] = float(action_bias[2] * u0) + bias_arr[3] = 0.0 # front = 0 + bias_arr[4] = -1.0 * u0 # bottom = -0.01 (cf. action_bias bottom = -2*u0 = -0.02) + bias_arr[5] = 1.0 * u0 # top = 0.01 (cf. action_bias top = 2*u0 = 0.02) print(f" bias action: {bias_arr}") fifo.clear() - for _ in range(FIFO_LEN): + for _ in range(fifo_len): ff.run(sample_interval, bias_arr) fifo.append(ff.obs.copy()[0:12]) save_states = np.array(list(fifo), dtype=DATA_TYPE) norm["save_states"] = save_states - ff.apply_ddf() + + # Save DDF AFTER bias rollout (so reset restores post-bias state) + ff.get_ddf() + ff.save_ddf() + ff.apply_ddf() # restore to checkpoint for inference start # Save norm norm_json = {k: v for k, v in norm.items() if not isinstance(v, np.ndarray)} @@ -183,11 +252,10 @@ def run_single_illusion( json.dump(norm_json, f, indent=2) # -- Phase 3: Controlled inference --------------------------------------- - result = {"scene": scene_name, "controlled": False} + result = {"scene": scene_name, "controlled": False, "similarity": 0.0} model_path = model_path_for_scene(scene_name) if model_path is not None: - s_dim = cfg.get("s_dim", 12) print(f" loading model: {model_path} (s_dim={s_dim})") model = load_ppo_model(model_path, device=f"cuda:{device_id}", s_dim=s_dim) model.set_random_seed(0) @@ -196,15 +264,16 @@ def run_single_illusion( ff.restore_ddf() ff.apply_ddf() - # Re-bias FIFO - fifo = deque(maxlen=FIFO_LEN) - for _ in range(FIFO_LEN): + # Re-bias FIFO (using same bias action as Phase 2) + fifo = deque(maxlen=fifo_len) + for _ in range(fifo_len): ff.context.push() ff.run(sample_interval, bias_arr) ff.context.pop() fifo.append(ff.obs.copy()[0:12]) sens_list, forc_list, action_list = [], [], [] + target_forces_list = [] obs = np.zeros(s_dim, dtype=np.float32) for step in range(n_infer_steps): @@ -227,28 +296,34 @@ def run_single_illusion( sens_list.append(obs_slice[0:6]) forc_list.append(obs_slice[6:12]) - # Build normalized obs (just forces + sens for S_DIM=12) + # Build normalized obs forces_norm = obs_slice[6:12] / force_norm_fact sens_norm = (obs_slice[0:6] - sens_deviation) / sens_norm_fact obs12 = np.clip(np.hstack([forces_norm, sens_norm]), -1.0, 1.0).astype(np.float32) if s_dim == 14: - # Need target values -- for inference we zero-pad + # Reconstruct target_cd, target_cl from harmonics (matching legacy) + target_vals = gen_target_states_at(step, target_harmonics) + target_cd = target_vals[0] / force_norm_fact + target_cl = target_vals[1] / force_norm_fact + target_forces_list.append(target_vals.copy()) # raw lattice-unit target forces obs = np.zeros(14, dtype=np.float32) obs[:12] = obs12 + obs[12] = np.clip(target_cd, -1.0, 1.0) + obs[13] = np.clip(target_cl, -1.0, 1.0) else: obs = obs12 np.savez(os.path.join(output_root, "controlled.npz"), sensors=np.array(sens_list, dtype=np.float32), forces=np.array(forc_list, dtype=np.float32), - actions=np.array(action_list, dtype=np.float32)) + actions=np.array(action_list, dtype=np.float32), + target_forces=np.array(target_forces_list, dtype=np.float32)) - # Compute similarity (use the target cylinder's sensor-only signals) - # For comparison, compute similarity between controlled sensors and target - target_sensors = target_states[:, 0:6] - sim = compute_similarity(target_sensors, - np.array(sens_list, dtype=np.float32), CONV_LEN) + # Compute similarity: compare controlled sensors vs target sensors + # target_sensors = target_states[:, 2:8] (sensor only, 6 channels) + sens_arr = np.array(sens_list, dtype=np.float32) + sim = compute_similarity(target_sensors, sens_arr, conv_len) print(f" similarity (vs target cylinder) = {sim:.4f}") result["controlled"] = True @@ -265,7 +340,7 @@ def run_single_illusion( def main(): - ap = argparse.ArgumentParser(description="Illusion inference") + ap = argparse.ArgumentParser(description="Illusion inference (corrected)") ap.add_argument("--diameter", type=str, default="1.0", help='Diameter: 0.75, 1.0, 1.5, or "all"') ap.add_argument("--device", type=int, default=0, help="GPU device ID") @@ -278,7 +353,6 @@ def main(): scene_names = get_scene_list("illusion") else: d = float(args.diameter) - # Match by target_diameter field scene_names = [] for sn in get_scene_list("illusion"): cfg = get_scene(sn) diff --git a/src/SR_analysis/sindy/run_all_v2.py b/src/SR_analysis/sindy/run_all_v2.py new file mode 100644 index 0000000..7a7dd4e --- /dev/null +++ b/src/SR_analysis/sindy/run_all_v2.py @@ -0,0 +1,766 @@ +#!/usr/bin/env python3 +"""Unified SINDy fitting v2 for all scenes. + +Uses V2 feature builder: no sin_ua/cos_ua, optional mu, weighted STLSQ. +Generates separate per-scene results and cross-scene comparisons. + +Usage: + conda run -n pycuda_3_10 python sindy/run_all_v2.py \\ + --scenes karman_re50,karman_re100,steady + + # Karman cross-Re only + conda run -n pycuda_3_10 python sindy/run_all_v2.py --family karman + + # Cloak family (Karman + steady + vortex) + conda run -n pycuda_3_10 python sindy/run_all_v2.py --family cloak + + # All scenes + conda run -n pycuda_3_10 python sindy/run_all_v2.py --family all +""" +from __future__ import annotations + +import argparse +import json +import os +import sys +from typing import List, Optional + +import numpy as np + +_REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..")) +if _REPO not in sys.path: + sys.path.insert(0, _REPO) +_SRC = os.path.join(_REPO, "src") +if _SRC not in sys.path: + sys.path.insert(0, _SRC) + +from SR_analysis.utils.sindy_fitter import ( + fit_sindy_weighted, get_feature_matrix_v2, get_active_support, + get_feature_matrix_deriv, compute_action_deriv, +) +from SR_analysis.utils.feature_builder import CORE_FEAT_KEYS_V2, ALL_FEAT_KEYS_V2, PHYSICS_FEAT_KEYS +from SR_analysis.configs import get_scene, get_scene_list, SCENES + +# Base output directory +SINDY_DIR = os.path.join(os.path.dirname(__file__)) + +# Threshold grid (more granular at low end for better Pareto selection) +THRESHOLDS = [0.0, 0.001, 0.002, 0.003, 0.005, 0.007, 0.01, 0.015, 0.02, 0.03, 0.05, 0.08, 0.1] + +FAMILIES = { + "cloak": ["karman", "steady", "vortex"], + "karman": ["karman"], + "illusion": ["illusion"], + "vortex": ["vortex"], + "all": None, # all scenes +} + + +def load_data(scene_name: str) -> tuple: + """Load sensors/forces/actions from scene's controlled.npz. + + Returns actions in PHYSICAL omega (lattice units). + Also returns target_forces if available (for Illusion scenes). + """ + cfg = get_scene(scene_name) + data_dir = os.path.join( + os.path.dirname(__file__), "..", "data", + cfg["scene_id"], scene_name, + ) + npz = np.load(os.path.join(data_dir, "controlled.npz")) + sensors = npz["sensors"].astype(np.float64) + forces = npz["forces"].astype(np.float64) + # actions in .npz are normalized [-1,1]; convert to physical omega + actions_norm = npz["actions"].astype(np.float64) + scale = cfg["action_scale"] + bias = np.array(cfg["action_bias"], dtype=np.float64) + u0 = cfg["u0"] + actions_phys = (actions_norm * scale + bias) * u0 + # Load target_forces if available (Illusion scenes) + target_forces = None + if "target_forces" in npz: + target_forces = npz["target_forces"].astype(np.float64) + return sensors, forces, actions_phys, cfg, target_forces + + +def compute_scene_weight(scene_name: str, cfg: dict) -> float: + """Compute scene quality weight from PPO similarity.""" + result_path = os.path.join( + os.path.dirname(__file__), "..", "data", + cfg["scene_id"], scene_name, "result.json", + ) + if os.path.isfile(result_path): + with open(result_path) as f: + r = json.load(f) + sim = r.get("similarity", r.get("avg_reward_last100", 0.5)) + return float(sim) ** 2 + return 1.0 + + +def run_single_scene( + scene_name: str, + thresholds: Optional[List[float]] = None, + verbose: bool = True, +) -> dict: + """Run SINDy v2 on a single scene. Returns result dict.""" + if thresholds is None: + thresholds = THRESHOLDS + + sensors, forces, actions_phys, cfg, target_forces = load_data(scene_name) + mu = cfg["mu"] + u0 = cfg["u0"] + scene_id = cfg["scene_id"] + T = sensors.shape[0] + + if verbose: + print(f"\n{'='*60}") + print(f"Scene: {scene_name} ({scene_id}) T={T} mu={mu:.4f} u0={u0}") + print(f"{'='*60}") + + # Determine if this is a single-scene (no mu) or cross-Re (with mu) + # For cross-Re, we'll handle separately; for single-scene, no mu + # But steady is special: it needs mu=constant, still no mu in features + # Single scene: no mu, no sin/cos + is_single_re = True # will be overridden for joint fitting + + # Build feature matrix (V2: no sin/cos, no mu for single-scene) + # Use dt_c=sample_interval/T0 in T0 units + t0_steps = 2000 # T0 = D/U0 = 20/0.01 = 2000 LBM steps + dt_c = cfg.get("sample_interval", 800) / t0_steps + + Theta_f, Theta_r, Y, fn_f, fn_r = get_feature_matrix_v2( + sensors, forces, actions_phys, mu, + u0=u0, include_mu=False, include_cos_sin=False, + use_time_norm=False, dt_c=dt_c, n_warmup=2, + target_forces=target_forces, + ) + + if verbose: + print(f" Features: front {Theta_f.shape[1]-1} + bias?0, rear {Theta_r.shape[1]-1} + bias?1") + print(f" dt_c={dt_c:.4f} (T0 units)") + + # Scene quality weight for STLSQ + w_scene = compute_scene_weight(scene_name, cfg) + if verbose: + print(f" Scene quality weight: {w_scene:.4f}") + + # ---------- Front channel (no bias) ---------- + if verbose: + print(f"\n --- Front (no bias) ---") + front_results = fit_sindy_weighted(Theta_f, Y[:, 0], thresholds, + n_robust_passes=2) + # Find best by R2 (include th=0.0 for dense coefficients) + front_best = max(front_results, key=lambda r: r["r2"]) + + if verbose: + _print_active(fn_f, front_best["coef"]) + + # ---------- Top channel (rear shared-head, with bias) ---------- + if verbose: + print(f"\n --- Top (rear shared-head, with bias) ---") + top_results = fit_sindy_weighted(Theta_r, Y[:, 2], thresholds, + n_robust_passes=2) + top_best = max(top_results, key=lambda r: r["r2"]) + if verbose: + _print_active(fn_r, top_best["coef"]) + + # ---------- Bottom (independent, for comparison) ---------- + if verbose: + print(f"\n --- Bottom (independent, with bias) ---") + bot_results = fit_sindy_weighted(Theta_r, Y[:, 1], thresholds, + n_robust_passes=2) + bot_best = max(bot_results, key=lambda r: r["r2"]) + if verbose: + _print_active(fn_r, bot_best["coef"]) + + # Compute additional metrics for the best thresholds + n_warmup = 2 + n_samples = Theta_f.shape[0] + + result = { + "scene": scene_name, + "scene_id": scene_id, + "re_code": cfg.get("re_code", 100), + "mu": mu, + "u0": u0, + "dt_c": dt_c, + "n_samples": n_samples, + "thresholds": thresholds, + "feature_names_front": fn_f, + "feature_names_rear": fn_r, + "front": { + "results": _clean_results(front_results), + "best": _clean_single(front_best), + "best_coef": front_best["coef"], + "sparsity_curve": [(r["threshold"], r["nz"], r["r2"]) for r in front_results], + }, + "top": { + "results": _clean_results(top_results), + "best": _clean_single(top_best), + "best_coef": top_best["coef"], + "sparsity_curve": [(r["threshold"], r["nz"], r["r2"]) for r in top_results], + }, + "bottom": { + "results": _clean_results(bot_results), + "best": _clean_single(bot_best), + "best_coef": bot_best["coef"], + "sparsity_curve": [(r["threshold"], r["nz"], r["r2"]) for r in bot_results], + }, + } + + if verbose: + print(f"\n Summary:") + print(f" Front: th={front_best['threshold']:.4f} nz={front_best['nz']:2d} R2={front_best['r2']:.4f}") + print(f" Top: th={top_best['threshold']:.4f} nz={top_best['nz']:2d} R2={top_best['r2']:.4f}") + print(f" Bottom: th={bot_best['threshold']:.4f} nz={bot_best['nz']:2d} R2={bot_best['r2']:.4f}") + + return result + + +def _print_active(names: List[str], coef: list, rtol: float = 1e-4): + """Print active features from coefficient vector.""" + for i, c in enumerate(coef): + if abs(c) > rtol and i < len(names): + print(f" {names[i]:20s} = {c:+.6f}") + + +def _clean_results(results: List[dict]) -> List[dict]: + """Keep all keys including coef.""" + return [dict(r) for r in results] + + +def _clean_single(entry: dict) -> dict: + """Remove coef from single entry.""" + return {k: v for k, v in entry.items() if k != "coef"} + + +def run_joint_karman( + scene_names: List[str], + thresholds: Optional[List[float]] = None, +) -> dict: + """Joint Karman cross-Re SINDy with mu terms and scene weighting.""" + if thresholds is None: + thresholds = THRESHOLDS + + print(f"\n{'='*60}") + print(f"Joint Karman cross-Re: {scene_names}") + print(f"{'='*60}") + + # Load and concatenate all Karman scenes + all_sensors, all_forces, all_actions = [], [], [] + all_weights = [] + t0_steps = 2000 + + for sn in scene_names: + sensors, forces, actions, cfg, _ = load_data(sn) + mu = cfg["mu"] + u0 = cfg["u0"] + dt_c = cfg.get("sample_interval", 800) / t0_steps + + # Build features WITH mu terms + Theta_f, Theta_r, Y, fn_f, fn_r = get_feature_matrix_v2( + sensors, forces, actions, mu, + u0=u0, include_mu=True, include_cos_sin=False, + use_time_norm=False, dt_c=dt_c, n_warmup=2, + ) + + w = compute_scene_weight(sn, cfg) + n = Theta_f.shape[0] + + print(f" {sn}: {n} samples, weight={w:.4f}") + + all_weights.append(np.full(n, w)) + all_sensors.append(Theta_f) + all_forces.append(Theta_r) # reuse variable names for concat + all_actions.append(Y) + + # Concatenate + Theta_f_j = np.vstack(all_sensors) + Theta_r_j = np.vstack(all_forces) + Y_j = np.vstack(all_actions) + W_j = np.concatenate(all_weights) + + print(f" Joint: front {Theta_f_j.shape}, rear {Theta_r_j.shape}") + print(f" Weights: min={W_j.min():.4f} max={W_j.max():.4f}") + + # ---------- Front (no bias, all features including mu) ---------- + print(f"\n --- Joint Front (no bias, with mu) ---") + front_results = fit_sindy_weighted(Theta_f_j, Y_j[:, 0], thresholds, + sample_weights=W_j, n_robust_passes=2) + front_best = max(front_results, key=lambda r: r["r2"]) + _print_active(fn_f, front_best["coef"]) + + # ---------- Top (with bias, all features including mu) ---------- + print(f"\n --- Joint Top (with bias, with mu) ---") + top_results = fit_sindy_weighted(Theta_r_j, Y_j[:, 2], thresholds, + sample_weights=W_j, n_robust_passes=2) + top_best = max(top_results, key=lambda r: r["r2"]) + _print_active(fn_r, top_best["coef"]) + + # ---------- Bottom (independent) ---------- + print(f"\n --- Joint Bottom (independent) ---") + bot_results = fit_sindy_weighted(Theta_r_j, Y_j[:, 1], thresholds, + sample_weights=W_j, n_robust_passes=2) + bot_best = max(bot_results, key=lambda r: r["r2"]) + _print_active(fn_r, bot_best["coef"]) + + return { + "joint_scenes": scene_names, + "thresholds": thresholds, + "n_samples_total": Theta_f_j.shape[0], + "feature_names_front": fn_f, + "feature_names_rear": fn_r, + "front": { + "results": _clean_results(front_results), + "best": _clean_single(front_best), + "best_coef": front_best["coef"], + "sparsity_curve": [(r["threshold"], r["nz"], r["r2"]) for r in front_results], + }, + "top": { + "results": _clean_results(top_results), + "best": _clean_single(top_best), + "best_coef": top_best["coef"], + "sparsity_curve": [(r["threshold"], r["nz"], r["r2"]) for r in top_results], + }, + "bottom": { + "results": _clean_results(bot_results), + "best": _clean_single(bot_best), + "best_coef": bot_best["coef"], + "sparsity_curve": [(r["threshold"], r["nz"], r["r2"]) for r in bot_results], + }, + } + + +def run_joint_illusion( + scene_names: List[str], + thresholds: Optional[List[float]] = None, +) -> dict: + """Joint Illusion cross-diameter SINDy with target features and scene weighting.""" + if thresholds is None: + thresholds = THRESHOLDS + + print(f"\n{'='*60}") + print(f"Joint Illusion cross-diameter: {scene_names}") + print(f"{'='*60}") + + all_sensors, all_forces, all_actions = [], [], [] + all_weights = [] + t0_steps = 2000 + + for sn in scene_names: + sensors, forces, actions, cfg, target_forces = load_data(sn) + mu = cfg["mu"] + u0 = cfg["u0"] + dt_c = cfg.get("sample_interval", 600) / t0_steps + + # Build features WITH target features, no mu + Theta_f, Theta_r, Y, fn_f, fn_r = get_feature_matrix_v2( + sensors, forces, actions, mu, + u0=u0, include_mu=False, include_cos_sin=False, + use_time_norm=False, dt_c=dt_c, n_warmup=2, + target_forces=target_forces, + ) + + w = compute_scene_weight(sn, cfg) + n = Theta_f.shape[0] + + print(f" {sn}: {n} samples, dt_c={dt_c:.4f}, weight={w:.4f}") + + all_weights.append(np.full(n, w)) + all_sensors.append(Theta_f) + all_forces.append(Theta_r) + all_actions.append(Y) + + Theta_f_j = np.vstack(all_sensors) + Theta_r_j = np.vstack(all_forces) + Y_j = np.vstack(all_actions) + W_j = np.concatenate(all_weights) + + print(f" Joint: front {Theta_f_j.shape}, rear {Theta_r_j.shape}") + print(f" Weights: min={W_j.min():.4f} max={W_j.max():.4f}") + + # Front (no bias) + print(f"\n --- Joint Front (no bias) ---") + front_results = fit_sindy_weighted(Theta_f_j, Y_j[:, 0], thresholds, + sample_weights=W_j, n_robust_passes=2) + front_best = max(front_results, key=lambda r: r["r2"]) + _print_active(fn_f, front_best["coef"]) + + # Top (with bias) + print(f"\n --- Joint Top (with bias) ---") + top_results = fit_sindy_weighted(Theta_r_j, Y_j[:, 2], thresholds, + sample_weights=W_j, n_robust_passes=2) + top_best = max(top_results, key=lambda r: r["r2"]) + _print_active(fn_r, top_best["coef"]) + + # Bottom (independent) + print(f"\n --- Joint Bottom (independent) ---") + bot_results = fit_sindy_weighted(Theta_r_j, Y_j[:, 1], thresholds, + sample_weights=W_j, n_robust_passes=2) + bot_best = max(bot_results, key=lambda r: r["r2"]) + _print_active(fn_r, bot_best["coef"]) + + return { + "joint_scenes": scene_names, + "thresholds": thresholds, + "n_samples_total": Theta_f_j.shape[0], + "feature_names_front": fn_f, + "feature_names_rear": fn_r, + "front": { + "results": _clean_results(front_results), + "best": _clean_single(front_best), + "best_coef": front_best["coef"], + "sparsity_curve": [(r["threshold"], r["nz"], r["r2"]) for r in front_results], + }, + "top": { + "results": _clean_results(top_results), + "best": _clean_single(top_best), + "best_coef": top_best["coef"], + "sparsity_curve": [(r["threshold"], r["nz"], r["r2"]) for r in top_results], + }, + "bottom": { + "results": _clean_results(bot_results), + "best": _clean_single(bot_best), + "best_coef": bot_best["coef"], + "sparsity_curve": [(r["threshold"], r["nz"], r["r2"]) for r in bot_results], + }, + } + + +def run_single_scene_deriv( + scene_name: str, + thresholds: Optional[List[float]] = None, + verbose: bool = True, + center_diff: bool = False, + augment_level: int = 0, + feat_keys: Optional[List[str]] = None, + output_mode: str = "deriv", +) -> dict: + """Run SINDy in phase-state mode. + + output_mode: + "deriv": Y = d(alpha)/dt (time-normalized). Closed-loop needs integration. + "absolute": Y = alpha (non-dimensional absolute action). No integration. + """ + if thresholds is None: + thresholds = THRESHOLDS + + sensors, forces, actions_phys, cfg, target_forces = load_data(scene_name) + mu = cfg["mu"] + u0 = cfg["u0"] + scene_id = cfg["scene_id"] + T = sensors.shape[0] + t0_steps = 2000 + dt_c = cfg.get("sample_interval", 800) / t0_steps + + if verbose: + print(f"\n{'='*60}") + print(f"{'ABSOLUTE' if output_mode == 'absolute' else 'DERIV'} Scene: {scene_name} dt_c={dt_c:.4f} u0={u0}") + print(f"{'='*60}") + + Theta_f, Theta_r, Y, fn_f, fn_r = get_feature_matrix_deriv( + sensors, forces, actions_phys, mu, + u0=u0, dt_c=dt_c, + target_forces=target_forces, + center_diff=center_diff, + augment_level=augment_level, + feat_keys=feat_keys, + output_mode=output_mode, + include_mu=feat_keys is not None and any("mu_" in k for k in feat_keys), + ) + + y_label = "alpha (abs)" if output_mode == "absolute" else "d(alpha)/dt" + if verbose: + print(f" Features: front {Theta_f.shape[1]} (no bias), rear {Theta_r.shape[1]} (with bias)") + print(f" Fitting {y_label}: {Y.shape}") + + w_scene = compute_scene_weight(scene_name, cfg) + + # Front (no bias) + if verbose: + print(f"\n --- Front (no bias, target = {y_label}_F) ---") + front_results = fit_sindy_weighted(Theta_f, Y[:, 0], thresholds, n_robust_passes=2) + front_best = max(front_results, key=lambda r: r["r2"]) + if verbose: + _print_active(fn_f, front_best["coef"]) + print(f" R2={front_best['r2']:.4f} nz={front_best['nz']}") + + # Top (with bias, rear shared-head) + if verbose: + print(f"\n --- Top (with bias, target = {y_label}_T) ---") + top_results = fit_sindy_weighted(Theta_r, Y[:, 2], thresholds, n_robust_passes=2) + top_best = max(top_results, key=lambda r: r["r2"]) + if verbose: + _print_active(fn_r, top_best["coef"]) + print(f" R2={top_best['r2']:.4f} nz={top_best['nz']}") + + # Bottom (independent) + if verbose: + print(f"\n --- Bottom (independent, target = {y_label}_B) ---") + bot_results = fit_sindy_weighted(Theta_r, Y[:, 1], thresholds, n_robust_passes=2) + bot_best = max(bot_results, key=lambda r: r["r2"]) + if verbose: + _print_active(fn_r, bot_best["coef"]) + print(f" R2={bot_best['r2']:.4f} nz={bot_best['nz']}") + + result = { + "scene": scene_name, + "scene_id": scene_id, + "mode": output_mode, + "dt_c": dt_c, + "center_diff": center_diff, + "re_code": cfg.get("re_code", 100), + "mu": mu, + "u0": u0, + "n_samples": Theta_f.shape[0], + "thresholds": thresholds, + "feature_names_front": fn_f, + "feature_names_rear": fn_r, + "front": { + "results": _clean_results(front_results), + "best": _clean_single(front_best), + "best_coef": front_best["coef"], + "sparsity_curve": [(r["threshold"], r["nz"], r["r2"]) for r in front_results], + }, + "top": { + "results": _clean_results(top_results), + "best": _clean_single(top_best), + "best_coef": top_best["coef"], + "sparsity_curve": [(r["threshold"], r["nz"], r["r2"]) for r in top_results], + }, + "bottom": { + "results": _clean_results(bot_results), + "best": _clean_single(bot_best), + "best_coef": bot_best["coef"], + "sparsity_curve": [(r["threshold"], r["nz"], r["r2"]) for r in bot_results], + }, + } + return result + + +def run_joint_karman_deriv( + scene_names: List[str], + thresholds: Optional[List[float]] = None, + augment_level: int = 0, +) -> dict: + """Joint Karman cross-Re derivative-mode SINDy.""" + if thresholds is None: + thresholds = THRESHOLDS + + print(f"\n{'='*60}") + print(f"DERIV Joint Karman: {scene_names}") + print(f"{'='*60}") + + t0_steps = 2000 + all_Theta_f, all_Theta_r, all_Y = [], [], [] + all_weights = [] + + for sn in scene_names: + sensors, forces, actions, cfg, _ = load_data(sn) + mu = cfg["mu"] + u0 = cfg["u0"] + dt_c = cfg.get("sample_interval", 800) / t0_steps + + Theta_f, Theta_r, Y, fn_f, fn_r = get_feature_matrix_deriv( + sensors, forces, actions, mu, + u0=u0, dt_c=dt_c, include_mu=True, + target_forces=None, augment_level=augment_level, + ) + w = compute_scene_weight(sn, cfg) + n = Theta_f.shape[0] + print(f" {sn}: {n} samples, dt_c={dt_c:.4f}, weight={w:.4f}") + all_weights.append(np.full(n, w)) + all_Theta_f.append(Theta_f) + all_Theta_r.append(Theta_r) + all_Y.append(Y) + + Theta_f_j = np.vstack(all_Theta_f) + Theta_r_j = np.vstack(all_Theta_r) + Y_j = np.vstack(all_Y) + W_j = np.concatenate(all_weights) + + print(f" Joint: front {Theta_f_j.shape}, rear {Theta_r_j.shape}") + + # Front + print(f"\n --- Joint Front ---") + front_results = fit_sindy_weighted(Theta_f_j, Y_j[:, 0], thresholds, sample_weights=W_j, n_robust_passes=2) + front_best = max(front_results, key=lambda r: r["r2"]) + _print_active(fn_f, front_best["coef"]) + print(f" R2={front_best['r2']:.4f}") + + # Top + print(f"\n --- Joint Top ---") + top_results = fit_sindy_weighted(Theta_r_j, Y_j[:, 2], thresholds, sample_weights=W_j, n_robust_passes=2) + top_best = max(top_results, key=lambda r: r["r2"]) + _print_active(fn_r, top_best["coef"]) + print(f" R2={top_best['r2']:.4f}") + + # Bottom + print(f"\n --- Joint Bottom ---") + bot_results = fit_sindy_weighted(Theta_r_j, Y_j[:, 1], thresholds, sample_weights=W_j, n_robust_passes=2) + bot_best = max(bot_results, key=lambda r: r["r2"]) + _print_active(fn_r, bot_best["coef"]) + print(f" R2={bot_best['r2']:.4f}") + + return { + "joint_scenes": scene_names, + "mode": "deriv", + "thresholds": thresholds, + "n_samples_total": Theta_f_j.shape[0], + "feature_names_front": fn_f, + "feature_names_rear": fn_r, + "front": { + "results": _clean_results(front_results), + "best": _clean_single(front_best), + "best_coef": front_best["coef"], + "sparsity_curve": [(r["threshold"], r["nz"], r["r2"]) for r in front_results], + }, + "top": { + "results": _clean_results(top_results), + "best": _clean_single(top_best), + "best_coef": top_best["coef"], + "sparsity_curve": [(r["threshold"], r["nz"], r["r2"]) for r in top_results], + }, + "bottom": { + "results": _clean_results(bot_results), + "best": _clean_single(bot_best), + "best_coef": bot_best["coef"], + "sparsity_curve": [(r["threshold"], r["nz"], r["r2"]) for r in bot_results], + }, + } + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--scenes", type=str, default=None, + help="Comma-separated scene names") + ap.add_argument("--family", type=str, default=None, + choices=list(FAMILIES.keys()) + [None], + help="Scene family") + ap.add_argument("--out", type=str, default=None, + help="Output directory (default: sindy//") + ap.add_argument("--joint", action="store_true", + help="Run joint Karman cross-Re fitting") + ap.add_argument("--deriv", action="store_true", + help="Use derivative mode: fit d(alpha)/dt = g(physics_state)") + ap.add_argument("--center-diff", action="store_true", + help="Use centered difference for derivative (vs forward diff)") + ap.add_argument("--augment-level", type=int, default=0, + help="Observation augmentation: 0=static, 1=+lags, 2=+derivs, 3=both, 4=+action_lag") + ap.add_argument("--phase", action="store_true", + help="Use phase-state features: [u_a, du_a/dt, Cl_tot, dCl_tot/dt, Cd_tot, Cd_rear]") + ap.add_argument("--karman-expand", action="store_true", + help="Karman expanded: phase-state + u_m/u_c/v_a/Cl_diff (10 dim)") + ap.add_argument("--karman-mu", action="store_true", + help="Karman phase-state + mu modulation: 6-dim + mu*Cl_tot/mu*Cd_tot/mu*u_a (9 dim)") + ap.add_argument("--output-mode", type=str, default="deriv", + choices=["deriv", "absolute"], + help="'deriv': predict d(alpha)/dt; 'absolute': predict alpha directly") + args = ap.parse_args() + + # Resolve scene list + if args.scenes: + scene_names = [s.strip() for s in args.scenes.split(",")] + elif args.family and args.family in FAMILIES: + family_ids = FAMILIES[args.family] + if family_ids is None: + scene_names = get_scene_list() # all + else: + scene_names = [] + for fid in family_ids: + scene_names.extend(get_scene_list(fid)) + else: + scene_names = get_scene_list("karman") + + # Determine fitting function based on mode + if args.deriv: + om = args.output_mode + if args.phase: + from SR_analysis.utils.feature_builder import PHASE_STATE_KEYS, ILLUSION_PHASE_KEYS + def _phase_func(sn): + from SR_analysis.configs import get_scene + cfg = get_scene(sn) + feat_keys = ILLUSION_PHASE_KEYS if cfg["scene_id"] == "illusion" else PHASE_STATE_KEYS + return run_single_scene_deriv(sn, center_diff=args.center_diff, + augment_level=args.augment_level, + feat_keys=feat_keys, output_mode=om) + fit_func = _phase_func + elif args.karman_expand: + from SR_analysis.utils.feature_builder import KARMAN_EXPANDED_KEYS + fit_func = lambda sn: run_single_scene_deriv(sn, center_diff=args.center_diff, + augment_level=args.augment_level, + feat_keys=list(KARMAN_EXPANDED_KEYS), + output_mode=om) + elif args.karman_mu: + from SR_analysis.utils.feature_builder import PHASE_STATE_KEYS + # Phase-state + 3 selected mu modulation features + mu_feat = ["mu_Cl_tot", "mu_Cd_tot", "mu_u_a"] + feat_keys = list(PHASE_STATE_KEYS) + mu_feat + fit_func = lambda sn: run_single_scene_deriv(sn, center_diff=args.center_diff, + augment_level=args.augment_level, + feat_keys=feat_keys, + output_mode=om) + else: + fit_func = lambda sn: run_single_scene_deriv(sn, center_diff=args.center_diff, + augment_level=args.augment_level, + output_mode=om) + joint_func_karman = lambda names: run_joint_karman_deriv(names, augment_level=args.augment_level) + else: + fit_func = run_single_scene + joint_func_karman = run_joint_karman + + print(f"Running SINDy {'deriv' if args.deriv else 'v2'} for scenes: {scene_names}") + + # Run per-scene + for sn in scene_names: + result = fit_func(sn) + + # Save per-scene to its scene_id directory + scene_id = result["scene_id"] + out_dir = os.path.join(SINDY_DIR, scene_id) + os.makedirs(out_dir, exist_ok=True) + suffix = "_deriv" if args.deriv else "_v2" + out_path = os.path.join(out_dir, f"sindy_results{suffix}.json") + + # Load existing if any, update per_scene + if os.path.isfile(out_path): + with open(out_path) as f: + existing = json.load(f) + else: + existing = { + "thresholds": THRESHOLDS, + "all_feature_names_front": result["feature_names_front"], + "all_feature_names_rear": result["feature_names_rear"], + "per_scene": {}, + } + + existing["per_scene"][sn] = result + with open(out_path, "w") as f: + json.dump(existing, f, indent=2) + print(f" Saved: {out_path}") + + # Joint Karman cross-Re (training Re only: 50, 100, 200, 400) + if args.joint: + karman_train = ["karman_re50", "karman_re100", "karman_re200", "karman_re400"] + joint_result = joint_func_karman(karman_train) + + joint_suffix = "_joint_deriv" if args.deriv else "_joint_v2" + out_path = os.path.join(SINDY_DIR, "karman", f"sindy{joint_suffix}.json") + with open(out_path, "w") as f: + json.dump(joint_result, f, indent=2) + print(f"\nSaved joint: {out_path}") + + # Joint Illusion (only in non-deriv mode for now) + if args.joint and not args.deriv: + illusion_names = get_scene_list("illusion") + joint_illusion = run_joint_illusion(illusion_names) + + out_path = os.path.join(SINDY_DIR, "illusion", "sindy_joint_v2.json") + os.makedirs(os.path.dirname(out_path), exist_ok=True) + with open(out_path, "w") as f: + json.dump(joint_illusion, f, indent=2) + print(f"\nSaved joint illusion: {out_path}") + + +if __name__ == "__main__": + main() diff --git a/src/SR_analysis/sindy/run_pysr.py b/src/SR_analysis/sindy/run_pysr.py new file mode 100644 index 0000000..68eb7bb --- /dev/null +++ b/src/SR_analysis/sindy/run_pysr.py @@ -0,0 +1,197 @@ +#!/usr/bin/env python3 +"""Restricted PySR on SINDy whitelist features. + +Uses the frozen whitelists (whitelist_karman.json, whitelist_illusion.json) +and the controlled CFD data to search for compact closed-form control laws. + +Environment: env_sr (conda). + +Usage: + conda run -n env_sr python src/SR_analysis/sindy/run_pysr.py --scene karman_re100 + conda run -n env_sr python src/SR_analysis/sindy/run_pysr.py --scene illusion_1L + conda run -n env_sr python src/SR_analysis/sindy/run_pysr.py --scene all +""" +from __future__ import annotations + +import argparse +import json +import os +import sys +from typing import Dict, List, Optional + +import numpy as np + +# PySR +from pysr import PySRRegressor + +_REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..")) +sys.path.insert(0, _REPO) +_SRC = os.path.join(_REPO, "src") +sys.path.insert(0, _SRC) + +from SR_analysis.utils.feature_builder import ( + compute_dimensionless, compute_features, build_feature_matrix, + CORE_FEAT_KEYS_V2, +) +from SR_analysis.configs import get_scene + +SINDY_DIR = os.path.join(os.path.dirname(__file__)) + + +def load_controlled_data(scene_name: str): + """Load controlled data for a scene, returning sensors, forces, actions_phys, target_forces.""" + cfg = get_scene(scene_name) + scene_id = cfg["scene_id"] + data_dir = os.path.join(SINDY_DIR, "..", "data", scene_id, scene_name) + npz = np.load(os.path.join(data_dir, "controlled.npz")) + sensors = npz["sensors"].astype(np.float64) + forces = npz["forces"].astype(np.float64) + actions_norm = npz["actions"].astype(np.float64) + actions_phys = (actions_norm * cfg["action_scale"] + np.array(cfg["action_bias"])) * cfg["u0"] + target_forces = npz["target_forces"].astype(np.float64) if "target_forces" in npz else None + return sensors, forces, actions_phys, target_forces, cfg + + +def run_pysr_scene(scene_name: str, out_dir: str): + """Run PySR on a single scene.""" + # Select whitelist based on scene family + cfg = get_scene(scene_name) + scene_id = cfg["scene_id"] + + if scene_id == "karman": + whitelist_path = os.path.join(SINDY_DIR, "whitelist_karman.json") + elif scene_id == "illusion": + whitelist_path = os.path.join(SINDY_DIR, "whitelist_illusion.json") + else: + print(f" SKIP: no whitelist for scene_id={scene_id}") + return + + with open(whitelist_path) as f: + wl = json.load(f) + + sensors, forces, actions_phys, target_forces, cfg = load_controlled_data(scene_name) + mu = cfg["mu"] + u0 = cfg["u0"] + sample_interval = cfg.get("sample_interval", 800) + t0_steps = 2000 # T0 = D/U0 = 20/0.01 = 2000 LBM steps + dt_c = sample_interval / t0_steps + + front_keys = wl["front_active"] + rear_keys = [k for k in wl["rear_active"] if k != "bias"] + + # Build features with PROPER lags (matching sindy_fitter.py) + 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) + has_mu = any("mu" in k for k in front_keys) + sym = compute_features( + dim, a_prev, a_prev2, mu, + alpha_mode=False, include_mu=has_mu, + include_cos_sin=False, u0=u0, + target_forces=target_forces, + dt_c=dt_c, + ) + + n_warmup = 2 + X_front = build_feature_matrix(sym, front_keys, add_bias=False)[n_warmup:] + X_rear = build_feature_matrix(sym, rear_keys, add_bias=True)[n_warmup:] + Y = actions_phys[n_warmup:] # target = omega(t) for t >= n_warmup + + print(f"\n{'='*60}") + print(f"PySR: {scene_name}") + print(f" Front features ({len(front_keys)}): {front_keys}") + print(f" Rear features ({len(rear_keys)}): {rear_keys}") + print(f" Samples: {X_front.shape[0]}") + print(f"{'='*60}") + + # --- Front channel --- + print(f"\n=== PySR: {scene_name} Front ===") + model_f = PySRRegressor( + binary_operators=["+", "-", "*", "/"], + unary_operators=["square"], + niterations=40, + populations=30, + maxsize=15, + complexity_of_constants=2, + parsimony=0.01, + extra_sympy_mappings={}, + batching=False, + ) + model_f.fit(X_front, Y[:, 0], variable_names=front_keys) + results = { + "scene": scene_name, + "channel": "front", + "feature_names": front_keys, + "equations": model_f.equations_.to_dict(orient="records"), + "best_sympy": str(model_f.sympy()), + "best_score": float(model_f.score(X_front, Y[:, 0])), + } + out_path = os.path.join(out_dir, f"pysr_{scene_name}_front.json") + with open(out_path, "w") as f: + json.dump(results, f, indent=2, default=str) + print(f" Best: {model_f.sympy()}") + print(f" Score: {results['best_score']:.4f}") + print(f" Saved: {out_path}") + + # --- Rear/Top channel --- + print(f"\n=== PySR: {scene_name} Top ===") + model_t = PySRRegressor( + binary_operators=["+", "-", "*", "/"], + unary_operators=["square"], + niterations=40, + populations=30, + maxsize=15, + complexity_of_constants=2, + parsimony=0.01, + extra_sympy_mappings={}, + batching=False, + ) + model_t.fit(X_rear, Y[:, 2], variable_names=["bias"] + rear_keys) + results_t = { + "scene": scene_name, + "channel": "top", + "feature_names": ["bias"] + rear_keys, + "equations": model_t.equations_.to_dict(orient="records"), + "best_sympy": str(model_t.sympy()), + "best_score": float(model_t.score(X_rear, Y[:, 2])), + } + out_path = os.path.join(out_dir, f"pysr_{scene_name}_top.json") + with open(out_path, "w") as f: + json.dump(results_t, f, indent=2, default=str) + print(f" Best: {model_t.sympy()}") + print(f" Score: {results_t['best_score']:.4f}") + print(f" Saved: {out_path}") + + +def main(): + ap = argparse.ArgumentParser(description="Restricted PySR on SINDy whitelists") + ap.add_argument("--scene", type=str, default="all", + help='Scene name like "karman_re100" or "illusion_1L", or "all"') + ap.add_argument("--out", type=str, default=None) + args = ap.parse_args() + + if args.out is None: + args.out = os.path.join(SINDY_DIR, "..", "validate", "results") + os.makedirs(args.out, exist_ok=True) + + if args.scene == "all": + scenes = ["karman_re100", "illusion_0.75L", "illusion_1L", "illusion_1.5L"] + else: + scenes = [args.scene] + + for sn in scenes: + try: + run_pysr_scene(sn, args.out) + except Exception as e: + print(f" ERROR on {sn}: {e}") + import traceback; traceback.print_exc() + + print("\nDone.") + + +if __name__ == "__main__": + main() diff --git a/src/SR_analysis/sindy/whitelist_illusion.json b/src/SR_analysis/sindy/whitelist_illusion.json new file mode 100644 index 0000000..8257020 --- /dev/null +++ b/src/SR_analysis/sindy/whitelist_illusion.json @@ -0,0 +1,30 @@ +{ + "scene": "illusion_joint", + "threshold": 0.001, + "selection": "Cleaned features: removed aF_lag1/daF etc, replaced with daF_dt (time-normalized). Features from union across 0.75L/1L/1.5L dense fits with target_Cd/target_Cl.", + "front_active": [ + "Cd_tot", + "Cd_rear", + "Cl_tot", + "Cl_diff", + "daF_dt", + "daB_dt", + "daT_dt", + "target_Cd", + "target_Cl" + ], + "rear_active": [ + "bias", + "Cd_tot", + "Cd_rear", + "Cl_tot", + "Cl_diff", + "daF_dt", + "daB_dt", + "daT_dt", + "target_Cd", + "target_Cl" + ], + "feature_count": 16, + "note": "Cleaned Illusion whitelist: no discrete lags, only time-derivatives + physical features. Separate SINDy closed-loop sims: 0.75L=0.908, 1L=0.962, 1.5L=0.926." +} diff --git a/src/SR_analysis/sindy/whitelist_karman.json b/src/SR_analysis/sindy/whitelist_karman.json new file mode 100644 index 0000000..5a070de --- /dev/null +++ b/src/SR_analysis/sindy/whitelist_karman.json @@ -0,0 +1,18 @@ +{ + "scene": "karman_re100", + "threshold": 0.003, + "front_active": [ + "daF_dt" + ], + "rear_active": [ + "bias", + "u_a", + "Cd_rear", + "Cl_diff", + "daF_dt", + "daB_dt", + "daT_dt" + ], + "feature_count": 14, + "note": "Cleaned Karman whitelist: removed aF_lag1/daF, replaced with daF_dt (time-normalized). Based on th=0.003 support from joint fit. Separate SINDy closed-loop sim=0.901 (94.4% of PPO)." +} diff --git a/src/SR_analysis/sindy/wrap_joint.py b/src/SR_analysis/sindy/wrap_joint.py new file mode 100644 index 0000000..2d2e088 --- /dev/null +++ b/src/SR_analysis/sindy/wrap_joint.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +"""Wrap joint model into per_scene format. +Usage: python3 src/SR_analysis/sindy/wrap_joint.py [--scene illusion] +""" +import json, sys, os +sys.path.insert(0, os.path.abspath(".")) +sys.path.insert(0, os.path.abspath("src")) + +import argparse +from SR_analysis.configs import get_scene_list + +def wrap_joint(scene_id): + joint_path = f"src/SR_analysis/sindy/{scene_id}/sindy_joint_v2.json" + out_path = f"src/SR_analysis/sindy/{scene_id}/sindy_joint_wrapped.json" + + with open(joint_path) as f: + joint = json.load(f) + + fn_f = joint["feature_names_front"] + fn_r = joint["feature_names_rear"] + + wrapped = { + "thresholds": joint["thresholds"], + "feature_names_front": fn_f, + "feature_names_rear": fn_r, + "per_scene": {}, + } + + # Include all scenes in the family (training + generalization) + all_scene_names = get_scene_list(scene_id) + + for scene_name in all_scene_names: + wrapped["per_scene"][scene_name] = { + "scene": scene_name, "re_code": "0", "mu": 0.0, + "feature_names_front": fn_f, "feature_names_rear": fn_r, + "front": { + "results": joint["front"]["results"], + "best": joint["front"]["best"], + "best_coef": joint["front"]["best_coef"], + "sparsity_curve": joint["front"]["sparsity_curve"], + }, + "top": { + "results": joint["top"]["results"], + "best": joint["top"]["best"], + "best_coef": joint["top"]["best_coef"], + "sparsity_curve": joint["top"]["sparsity_curve"], + }, + "bottom": { + "results": joint["bottom"]["results"], + "best": joint["bottom"]["best"], + "best_coef": joint["bottom"]["best_coef"], + "sparsity_curve": joint["bottom"]["sparsity_curve"], + }, + } + + with open(out_path, "w") as f: + json.dump(wrapped, f, indent=2) + print(f"Saved {out_path}") + print(f" Scenes ({len(all_scene_names)}): {all_scene_names}") + +if __name__ == "__main__": + ap = argparse.ArgumentParser() + ap.add_argument("--scene", type=str, default="karman", + choices=["karman", "illusion"]) + args = ap.parse_args() + wrap_joint(args.scene) diff --git a/src/SR_analysis/sindy_sr_knowledge.md b/src/SR_analysis/sindy_sr_knowledge.md new file mode 100644 index 0000000..b774b39 --- /dev/null +++ b/src/SR_analysis/sindy_sr_knowledge.md @@ -0,0 +1,146 @@ +# SINDy 与 SR 背景知识 + +## 文档作用 + +这份文档只负责一件事:**给正在工作的 coder 提供背景知识、已经确认的经验、已踩过的坑和当前结论强度。** + +它不是任务清单,不直接安排"下一步做什么"。凡是执行顺序、阶段划分、最小交付物,统一写在 `sindy_sr_notes`。这份 knowledge 只保留: + +- 已确认的技术事实 +- 历史错误与纠正 +- 结果该如何理解 +- 哪些话可以说,哪些话现在还不能说 +- 代码和实验上最容易踩的坑 + +--- + +## 一、这条线在项目里的位置 + +SINDy 与 SR 不是独立课题,而是 pinball 后处理主线中的一段工具链。项目真正要解释的是: + +\[ +\text{obs} \rightarrow \text{act} \rightarrow \text{flow structure} \rightarrow \text{signature} +\] + +SINDy 与 SR 当前只直接处理其中的 `obs -> act` 白箱化,但它们的价值在于: + +- 检验控制是否真的依赖少数物理量 +- 识别不同 cloak 场景中是否复用了同一类反馈结构 +- 为后续把控制律与 force、mean wake、observable-related structure 接起来提供接口 + +因此,任何 SINDy/SR 结果都不应脱离项目总体物理主线单独解读。 + +--- + +## 二、当前已经确认的技术事实 + +### 1. Kármán cloak 的跨 Re_D 统一骨架存在 + +这是目前最硬的一批证据之一。跨 Re 的 leave-one-Re-out,尤其 holdout_200,已经显示: +- 用 Re50 + Re100 拟合,可高精度预测 Re200 +- 这说明统一骨架不是偶然的特征工程产物,而是真实存在于 PPO 策略中的共享结构 + +### 2. 对称性问题已经纠偏 + +最重要的 bug 是镜像变换 G 对动作的写法错误。 + +**错误版本**: +\[ +[a_F,a_T,a_B] \mapsto [-a_F,a_B,a_T] +\] + +**正确版本**: +\[ +[a_F,a_T,a_B] \mapsto [-a_F,-a_B,-a_T] +\] + +修正后,rear equivariance 误差从约 100% 降到约 10%,原来"PPO 不尊重交换对称性"的结论应正式撤回。 + +### 3. front no-bias 被数据支持,rear shared-head 是有效结构 + +### 4. one-step R² 与闭环是两回事 + +\[ +\text{one-step R}^2 \gg 0.95 \not\Rightarrow \text{闭环好} +\] + +**关键证据**:full-lag 16-dim 模型 R²=0.939 但闭环仅 0.619;static 8-dim 模型 R²=0.321 但闭环 0.745。 + +### 5. 时间特征加剧 rollout mismatch(2026-06-15 确认) + +训练时使用 `x(t-1)` 或 `dx/dt` 特征让 one-step R² 跃升,但闭环验证下降。原因是**分布偏移**:训练时特征来自 PPO 真实轨迹,部署时来自 SINDy 控制轨迹。带时间记忆的模型更容易在自由滚动时暴露分布偏移。 + +### 6. phase-state + absolute action 路线已被验证(2026-06-15 核心结论) + +Illusion 0.75L 和 1L 已证明**不需要动作历史特征**也能达到 PPO 的 96%+ 闭环性能: +- 输入:相位状态 `u_a, du_a/dt, Cl_tot, dCl_tot/dt, Cd_tot, Cd_rear` + error-state +- 输出:直接预测 absolute alpha,不做导数积分 +- 结构:v23(front no-bias, rear shared-head) + +### 7. Illusion 1.5L 是独立机制(2026-06-15 确认) + +1.5L 动作饱和到 [-8,8] 范围,自相关 r=0.07,线性 SINDy 不适用。 + +--- + +## 三、哪些结论现在还不能说得太满 + +### 1. "所有 cloak 已经共享同一骨架" +还不能这么说。all-cloak 统一骨架仍是当前主问题。 + +### 2. "Karman 新路线已经成功" +不能。Karman 无动作历史最佳仅 0.699(旧 v23 为 0.901),说明状态还不充分。 + +### 3. "高 Re 退化已经证明是采样率问题" +这是一个强工作假设,需要在时间尺度显式化后重新检验。 + +### 4. "SR 已经做完一轮" +如果实际做的只是 threshold 网格 + Pareto 分析,不能写成"完整 SR 已完成"。 + +--- + +## 四、代码与工程层面的已知经验 + +### 1. 环境分工 +- `pycuda_3_10`:CFD、DRL 模型加载、数据采集、SINDy(pysindy) +- `sr_env`:PySR 与 SR 相关工作 + +### 2. 常见坑 +- **actions.npz 是归一化动作 [-1,1],不是物理 omega**。所有 SINDy 拟合代码需通过 `(norm * scale + bias) * u0` 转换。 +- **Illusion "2U" 误解**:"2U" 在模型名中表示 S_DIM=14(多了2维目标力观测),不是两倍来流速度。u0 始终为 0.01。 +- **FIFO bias ≠ DRL action bias**:FIFO bias 用 1U `[0, -U0, U0]` 填充历史;DRL action decoder 用 2U `action*8+[0,-2,2]`。 +- **SAMPLE_INTERVAL 因场景而异**:0.75L=400, 1L=600, 1.5L=800, Karman=800。 +- **验证器 `n_steps` 不能过短**:需保证控制传播到传感器,S=400→320步, S=600→214步, S=800→160步。 +- **保存 `controlled.npz` 时必须包含 `target_forces` 字段**(Illusion 场景的 s_dim=14 推理数据)。 +- **单步 validator(predict_v23_deriv)中需要传入 `sensors_raw`/`forces_raw`** 以计算相位特征中的导数项。 + +### 3. 当前可信结果(2026-06-15) + +| 场景 | 路线 | 特征 | 输出 | 是否有动作历史 | 闭环 sim | % of PPO | +|------|------|------|:----:|:-------------:|:--------:|:--------:| +| illusion_0.75L | phase+error | ILLUSION_PHASE (10dim) | alpha | **否** | **0.974** | 100.2% | +| illusion_1L | phase+error | ILLUSION_PHASE (10dim) | alpha | **否** | **0.958** | 98.5% | +| illusion_1.5L | phase+error | ILLUSION_PHASE (10dim) | alpha | **否** | **N/A** | bang-bang | +| karman_re100 | phase-state | PHASE_STATE (6dim) | alpha | **否** | **0.699** | 73.3% | +| karman_re100 | old v23 | CORE_FEAT_KEYS_V2 + a_lag | alpha | **是** | **0.901** | 94.4% | + +--- + +## 五、Bug Audit 补充(2026-06-14~15) + +### 新发现的 Bug + +| # | Bug | 文件 | 严重度 | 修复 | +|---|-----|------|--------|------| +| 13 | `run_pysr.py` 滞后构造错误:`actions_phys` 直接当 `actions_prev` 传入 `compute_features`,导致 `aF_lag1 = alpha(t)` 而非 `alpha(t-1)` — 拟合恒等式 | sindy/run_pysr.py | CRITICAL | 改为正确滞后:`a_prev[1:]=actions_phys[:-1]` | +| 14 | `predict_v23_deriv` 中 `needs_aug` 只检测 `"lag1"` 关键字,忽略 `"_dt"` 结尾的导数特征 → 闭环中 phase-state 的 `du_a_dt` 等始终为零 | validate/run_closed_loop.py | CRITICAL | `needs_aug = any(k.endswith("_dt") or k.endswith("_lag1") for k in ...)` | +| 15 | `compute_features` 中 `target_forces` 未处理 1D 输入(单步验证时 shape 为 (2,) 而非 (1,2)) | utils/feature_builder.py | 中 | `if tf.ndim == 1: tf = tf.reshape(1, -1)` | + +### 关键方法教训(2026-06-15 更新) + +1. **PPO 验证优先于 SINDy**。在确认 PPO 推理复现正确之前,SINDy 结果无意义。 +2. **控制时长必须 >= NX/U0**。50 步验证结果不可靠。 +3. **DDF 保存位置决定验证器起始状态**。正确模式:`stabilize → save_ddf(1) → norm → apply_ddf → bias FIFO → save_ddf(2) → apply_ddf`。 +4. **状态消融比输出消融更重要**:`x_n` 不够 → 加 `x_{n-1}` 大幅提升 → 加 `a_{n-1}` 边际仅 3% → 说明缺的是相位状态,不是动作历史。 +5. **离线 rollout 好 ≠ CFD 闭环好**。phase-state 模型 offline 50 步漂移仅 11%,但 CFD 闭环 -7% vs 静态。最终判据是 CFD 短闭环。 +6. **如遇数值错误,先检查 obs 切片索引、添加顺序、target_forces 维度**。 \ No newline at end of file diff --git a/src/SR_analysis/sindy_sr_notes.md b/src/SR_analysis/sindy_sr_notes.md index f64dd37..a4075b1 100644 --- a/src/SR_analysis/sindy_sr_notes.md +++ b/src/SR_analysis/sindy_sr_notes.md @@ -1,4 +1,4 @@ -# SINDy 与 SR 执行计划 +# SINDy 与 SR 执行计划(2026-06-15 修订版 v4) ## 文档作用 @@ -6,342 +6,183 @@ - 只写执行路线、阶段目标、优先级、输出要求 - 不长篇复述历史争论 -- 不把背景知识和任务顺序混在一起 -- 凡是历史 bug、经验教训、哪些结论已经成立、哪些还不能说,统一放到 `sindy_sr_knowladge` +- 凡是背景知识、已知结论、踩坑记录,统一放到 `sindy_sr_knowledge.md` --- ## 当前总目标 -把 **所有 cloak 场景** 纳入同一受约束分析框架,使用 **SINDy + SR 并行** 探索控制骨架,先分别拟合,再横向比较,从而判断: +用 SINDy + SR 在所有 cloak / illusion 场景上产出有物理结论的控制律公式,并验证其跨场景泛化能力。 -- 哪些项是 shared core -- 哪些项是 scene-specific activation -- 哪些差异来自时间尺度写法,而不是物理骨架本身 - -当前默认路线不是“先强行做统一总公式”,而是: - -\[ -\text{separate fit} \rightarrow \text{compare} \rightarrow \text{shared-backbone test} -\] +回答三个问题: +1. **哪些物理量是控制的核心变量?**(力、速度、相位、误差之间的取舍) +2. **不同场景是否共享同一公式骨架?**(family 内 -> family 间) +3. **白箱控制律能否在未训练工况工作?**(泛化性 + 失效边界) --- -## 当前工作原则 +## 已完成的工作(2026-06-14~15) -后续默认遵守: +### 核心成果 -1. **所有场景共用同一套 primitive variables 与同一套 \(G\) 规则** -2. **front 默认 no-bias + odd structure** -3. **rear 默认 shared-head** -4. **SINDy 与 SR 并行推进**,SR 不是替换 SINDy,而是并行工具 -5. **先分场景拟合,再做横向比较** -6. **闭环验证不可省略**,但不要求每个场景第一轮都做全套闭环 -7. **时间尺度问题必须显式进入特征定义** -8. **不再只围绕跨 Re 的 Kármán 单线深挖**;跨 Re 现在是已站住的第一证据,不是全部主线 -9. **当前不讨论功率与能量分析**,这不是这条线的优先任务 -10. **当前不把时延作为主要矛盾**;对稳定周期控制,当前第一矛盾是变量骨架与时间尺度写法 +1. **Phase-state 特征体系**:`PHASE_STATE_KEYS` (6维) + `ILLUSION_PHASE_KEYS` (10维) — 无需动作历史 +2. **绝对动作输出模式**:`get_feature_matrix_deriv(output_mode="absolute")` — 无积分累积 +3. **闭合验证器支持**:`predict_v23_deriv(output_mode="absolute")` + 闭环 `mode="abs"` +4. **离线 rollout 评估**:`validate/eval_rollout.py` — 多步滚动误差分析 +5. **消融实验**:5 种输入配置 × 2 种输出模式 × CFD 闭环验证 + +### Illusion 新路线成功 + +| 场景 | 闭环 sim | 动作历史 | 结论 | +|------|:--------:|:--------:|------| +| 0.75L | **0.974** | 无 | 新路线成立 | +| 1L | **0.958** | 无 | 新路线成立 | +| 1.5L | N/A | 无 | bang-bang/不同机制 | + +### Karman 需要补状态 + +| 配置 | 闭环 sim | 对比旧 v23 | +|------|:--------:|:----------:| +| 旧 v23 (a_lag) | 0.901 | 基线 | +| phase-state → abs (最佳新) | **0.699** | -22% | +| phase-state → deriv | 0.656 | -27% | --- -## 当前场景优先级 +## 当前最值得优先做的实验 -### 第一层:本轮重点场景 +### 第一优先级:Illusion 0.75L / 1L → separate PySR → 公式比较 -- Kármán cloak -- steady cloak +输入特征(10 维): +```python +ILLUSION_PHASE_KEYS = [ + "u_a", "du_a_dt", # 振荡相位 + "Cl_tot", "dCl_tot_dt", # 升力动力学 + "Cd_tot", "Cd_rear", # 阻力反馈 + "Cd_err", "Cl_err", # 力误差 + "dCd_err_dt", "dCl_err_dt", # 误差动力学 +] +``` -这两个场景本轮必须做完整输出,因为它们最适合先建立场景间比较模板。 +输出:absolute alpha(非维动作,不积分) +环境:`conda run -n sr_env` +代码:`sindy/run_pysr.py` -### 第二层:下一轮扩展场景 +三个产出要求: +- 最短可接受公式 +- 闭环可运行公式 +- 0.75L 与 1L 的公式形态比较 / 同形骨架判断 -- 单涡 cloak(monopole / taylor / lamb 等已有单涡场景) -- erase -- 其他已有 cloak 场景 +### 第二优先级:Karman 状态补强(CCD/OID 进场) -这批先做轻量版 separate fit,再决定哪些值得补完整闭环与深挖。 +当前 Karman 新路线最佳 0.699,需要在 phase-state 基础上补充缺失状态量: +- 候选:回流区长度、尾迹中心线偏移、POD 模态系数 +- 方法:CCD(Correlation-based Decomposition)或 OID(Observer-based Identification) +- 目标:在无动作历史前提下将 Karman 闭环提升到 0.85+ + +### 第三优先级:Illusion 跨直径联合 + +条件:0.75L 和 1L 的 PySR 公式确认同形骨架后 +方法:error-state 特征 + 目标 signature 描述符(target St、amplitude) --- -## 阶段 0 +## 当前暂不建议做的事 -## 统一接口 - -### 0.1 统一 primitive variables - -所有场景统一输出: - -- 无量纲 sensor:\(\hat u, \hat v\) -- 力系数:\(C_D, C_L\) -- 无量纲动作:\(\alpha\) -- lagged \(\alpha\) -- \(\Delta \alpha\) 或显式含 \(\Delta t_c\) 的版本 -- \(\mu = 1/Re_D\) -- scene metadata:scene id、\(Re_D\)、control interval \(\Delta t_c\)、target type、采样设置 - -### 0.2 固定 \(G\) 算子 - -统一使用同一套 \(G\) 规则,不允许不同场景临时改写。动作必须满足: - -\[ -(\alpha_F,\alpha_T,\alpha_B) \mapsto (-\alpha_F,-\alpha_B,-\alpha_T) -\] - -### 0.3 统一 feature builder - -统一生成三层特征: - -| 层级 | 内容 | 用途 | -|---|---|---| -| core | \(\hat u, \hat v, C_D, C_L, \alpha^-, \Delta\alpha^-, \mu\) | 所有场景共用 | -| derived | 对称/反对称组合、总量/差量 | SINDy 主库 | -| time-scale | 显式含 \(\Delta t_c\) 的版本 | 时间尺度分析 | - -### 0.4 必做测试 - -任何场景进入拟合前,先过: - -- \(G(G(x)) = x\) -- feature names 与矩阵列顺序一致 -- 闭环预测器输入维度一致 -- front / rear 的结构约束在数据接口层正确落地 - -### 阶段 0 输出 - -- 统一 `feature_builder` -- 统一 `symmetry` -- 统一 `time_scale` -- 统一 `validators` -- 场景级最小数据摘要(样本数、变量范围、control interval) +- 不回退到动作历史主导(Illusion 已经证明不需要) +- 不先做跨 Re Karman 联合拟合(单 Re 还没站稳) +- 不先做 vortex 扩展(不是当前瓶颈) +- 不先上 PySR 到 Karman(状态还有问题,公式不会更物理) --- -## 阶段 1 +## 常用命令 -## Kármán 与 steady 的第一轮 separate SINDy +### SINDy 拟合 +```bash +# Illusion phase-state + absolute action +conda run -n pycuda_3_10 python src/SR_analysis/sindy/run_all_v2.py \ + --scenes illusion_0.75L,illusion_1L --deriv --phase --output-mode absolute -### 目标 +# Karman phase-state + absolute action +conda run -n pycuda_3_10 python src/SR_analysis/sindy/run_all_v2.py \ + --scenes karman_re100 --deriv --phase --output-mode absolute -在统一变量与统一约束下,先得到两个重点场景各自最可信的稀疏 support。 +# Karman expanded features +conda run -n pycuda_3_10 python src/SR_analysis/sindy/run_all_v2.py \ + --scenes karman_re100 --deriv --karman-expand --output-mode absolute -### 默认约束 +# Karman phase-state + mu modulation +conda run -n pycuda_3_10 python src/SR_analysis/sindy/run_all_v2.py \ + --scenes karman_re100 --deriv --karman-mu --output-mode absolute -- front no-bias -- front odd structure -- rear shared-head -- correct \(G\) consistency +# Illusion separate with error-state (old style for comparison) +conda run -n pycuda_3_10 python src/SR_analysis/sindy/run_all_v2.py \ + --scenes illusion_0.75L,illusion_1L,illusion_1.5L -### 每个场景必须输出 +# Karman cross-Re joint +conda run -n pycuda_3_10 python src/SR_analysis/sindy/run_all_v2.py \ + --scenes "karman_re50,karman_re100,karman_re200,karman_re400" --joint +``` -| 输出 | 说明 | -|---|---| -| best support | 最优 support 列表 | -| sparsity curve | 稀疏度-误差曲线 | -| front / rear 主项表 | 主导项与系数 | -| one-step metrics | R²、RMSE | -| key closed-loop result | 至少一个关键闭环指标 | -| support stability | threshold / window / bootstrap 稳定性 | -| contribution table | 主要项贡献度,不只看是否出现 | +### CFD 闭环验证 +```bash +# Illusion phase-state + absolute action +conda run -n pycuda_3_10 python src/SR_analysis/validate/run_closed_loop_illusion.py \ + --scene illusion_0.75L --device 0 --steps 320 \ + --sindy-results src/SR_analysis/sindy/illusion/sindy_results_deriv.json -### 本阶段比较项 +# Karman phase-state + absolute action +conda run -n pycuda_3_10 python src/SR_analysis/validate/run_closed_loop.py \ + --scene karman_re100 --device 1 --steps 200 --mode abs \ + --sindy-results src/SR_analysis/sindy/karman/sindy_results_deriv.json -Kármán 与 steady 做第一轮 support overlap: +# Karman old v23 for comparison +conda run -n pycuda_3_10 python src/SR_analysis/validate/run_closed_loop.py \ + --scene karman_re100 --device 1 --steps 200 --mode v23 \ + --sindy-results src/SR_analysis/sindy/karman/sindy_joint_wrapped.json +``` -- 哪些项共同出现 -- 哪些项只在 Kármán 激活 -- steady 是否表现为明显简化版 -- overlap 不能只看布尔出现,还要看贡献量级与稳定性 +### 离线 rollout 评估 +```bash +python3 src/SR_analysis/validate/eval_rollout.py \ + --sindy-results src/SR_analysis/sindy/karman/sindy_results_deriv.json \ + --scene karman_re100 +``` + +### PySR 符号回归 +```bash +conda run -n sr_env python src/SR_analysis/sindy/run_pysr.py \ + --scene illusion_1L +``` --- -## 阶段 2 - -## Kármán 与 steady 的第一轮受限 SR - -### 目标 - -不是追求最终公式,而是看: - -- 在受限物理库上,是否能压出更短闭式 -- SINDy 中看似不同的项,是否会被更统一的表达吸收 -- Kármán 与 steady 是否出现同形公式 - -### SR 输入规则 - -SR 只能使用: - -- 阶段 0 的统一变量 -- 阶段 1 的 SINDy 已筛出主项及其邻近项 -- 受限运算集合 - -### 当前允许的运算 - -- 加减乘 -- protected divide -- 少量 square -- 必要时有限放开 tanh - -### 当前不允许的运算 - -- raw trig 乱搜 -- 高次幂 -- 指数 -- 深层嵌套 - -### 每个场景必须输出 - -| 输出 | 说明 | -|---|---| -| shortest acceptable formula | 最短可接受公式 | -| complexity-error pareto | 复杂度 vs 误差 | -| 和 SINDy 的关系 | 压缩了什么、保留了什么、吸收了什么 | -| key closed-loop result | 最佳 SR 公式至少一版关键闭环 | -| formula family notes | 同一场景内是否存在多种同等可接受闭式 | - ---- - -## 阶段 3 - -## 横向比较 - -当 Kármán 与 steady 的 SINDy + SR 都出来后,立即做横向比较。 - -### 3.1 support 比较 - -不要只看“是否出现”,必须同时看: - -- 是否出现 -- 系数或贡献量级 -- 稳定性 -- SR 是否把它吸收到更高层表达里 - -### 3.2 公式形态比较 - -至少检查: - -- 是否都包含同类 force feedback 核心 -- 是否都包含同类 memory 核心 -- steady 是否只是删掉了周期相关项 -- 是否存在同形结构 + 少量场景激活项 - -### 3.3 第一轮 shared-backbone 判断 - -这一轮只回答: - -1. 是否存在 Kármán 与 steady 的 shared core -2. steady 是否可以视为 Kármán 的明显简化版 -3. 哪些项更像 scene-specific activation,而不是 backbone 本身 - -本阶段不急着拟合 all-cloak 联合总公式。 - ---- - -## 阶段 4 - -## 扩展到单涡与其他 cloak - -在 Kármán 与 steady 的第一轮比较完成后,再把单涡 cloak 与其他场景纳入。 - -### 轻量版输出要求 - -- separate SINDy -- separate SR -- one-step metrics -- support 形态 -- 必要时补关键闭环 - -### 比较目标 - -重点看: - -- 单涡是否保留 shared core -- 单涡是否主要新增 history / transient 项 -- 是否开始出现子家族结构 - ---- - -## 阶段 5 - -## 时间尺度显式化 - -这条线并行推进,但先不抢在全部场景前面。 - -### 当前目标 - -- 让 \(\Delta t_c\) 显式进入特征 -- 不再把“1 个采样步”默认当物理可比量 -- 比较显式化前后,support 是否更收敛 -- 为后面严肃讨论采样率影响扫清接口问题 - -### 第一批测试场景 - -- Kármán cloak -- steady cloak - -### 第一批对比内容 - -- 旧的 discrete lag / \(\Delta a\) -- 显式含 \(\Delta t_c\) 的版本 - -### 关注结果 - -- support 是否变化 -- SR 公式是否更统一 -- 跨场景比较是否更干净 - -注意:当前阶段的目标是**时间尺度显式化**,不是立刻给出高采样率优于 PPO 的最终结论。 - ---- - -## 本轮必须完成的最小任务 - -1. 统一 Kármán 与 steady 的 feature builder -2. 统一 Kármán 与 steady 的 \(G\) / time-scale / validators -3. 跑 Kármán 与 steady 的第一轮 separate SINDy -4. 跑 Kármán 与 steady 的第一轮受限 SR(PySR) -5. 输出 Kármán vs steady 的: - - support overlap - - 公式形态比较 - - shared core / scene-specific 的初步分类 - ---- - -## 本轮结束时应交付的结果包 - -每个重点场景至少有一张 summary 表: - -| method | sparsity | one-step | closed-loop | key terms | notes | -|---|---:|---:|---:|---|---| -| SINDy | | | | | | -| SR | | | | | | - -以及一个跨场景比较表: - -| comparison | shared core | scene-enhanced | scene-specific | notes | -|---|---|---|---|---| -| Kármán vs steady | | | | | - ---- - -## 当前不该做的事 - -- 不继续围绕单一 Kármán across Re 版本号升级 -- 不把 threshold 网格或简单 Pareto 扫描直接当成完整 SR -- 不在 raw feature 上做自由 SR -- 不在不同采样间隔下直接复用旧系数并据此下正式结论 -- 不在场景比较证据还弱时,提前宣布 all-cloak 统一总公式 -- 不把功率、能量、时延这些非当前主矛盾问题拉入本轮 SINDy/SR 主线 - ---- - -## 当前这份 notes 的直接收束 - -接下来核心任务不是“继续优化某个跨 Re 模型”,而是: - -\[ -\boxed{ -\text{把 Kármán 与 steady 先做成可比较的 separate SINDy + separate SR 结果包,然后以它们为模板扩到单涡与其他 cloak。} -} -\] - -这一步完成后,才进入更严肃的 all-cloak shared-backbone 判断。 \ No newline at end of file +## 关键文件索引 + +| 文件 | 用途 | +|------|------| +| `configs.py` | 统一场景元数据(10+场景) | +| `utils/feature_builder.py` | 特征工程:PHYSICS_FEAT_KEYS, PHASE_STATE_KEYS, ILLUSION_PHASE_KEYS, 导数/滞后特征 | +| `utils/sindy_fitter.py` | STLSQ拟合:get_feature_matrix_v2, get_feature_matrix_deriv, compute_action_deriv | +| `utils/cfd_interface.py` | LegacyCelerisLab封装(GPU, 推理, norm) | +| `utils/g_operator.py` | 等变性诊断 | +| `scripts/infer_*.py` | Karman/Illusion/Vortex 推理管线 | +| `sindy/run_all_v2.py` | 统一 SINDy 拟合入口(支持 --deriv, --phase, --output-mode, --karman-expand 等) | +| `sindy/run_pysr.py` | 受限 PySR 符号回归 | +| `sindy/wrap_joint.py` | 联合模型 → wrapped 格式 | +| `validate/run_closed_loop.py` | Karman 闭环验证器(v23, deriv, abs 模式) | +| `validate/run_closed_loop_illusion.py` | Illusion 闭环验证器 | +| `validate/eval_rollout.py` | **新**:离线多步 rollout 评估 | +| `compare/support_overlap.py` | 跨场景 support 比较 | +| `compare/shared_core.py` | 多场景 shared core 检测 | +| `docs/SR_analysis_results.md` | **新**:完整分析报告 | +| `docs/figures/SR_analysis/fig*.png` | **新**:结果图表(6张) | + +## 环境 + +| 环境 | conda 名 | 用途 | +|------|---------|------| +| CFD + DRL + SINDy | `pycuda_3_10` | infer, run_all_v2, closed-loop | +| PySR 符号回归 | `sr_env` | run_pysr.py | +| GPU | device 0, device 1 | | diff --git a/src/SR_analysis/utils/__init__.py b/src/SR_analysis/utils/__init__.py index dc9391e..d8f4d98 100644 --- a/src/SR_analysis/utils/__init__.py +++ b/src/SR_analysis/utils/__init__.py @@ -1,9 +1,19 @@ from .feature_builder import ( compute_dimensionless, compute_features, build_feature_matrix, get_feature_names, apply_G_alpha, apply_G_x, - CORE_FEAT_KEYS, MU_FEAT_KEYS, ALL_FEAT_KEYS, + CORE_FEAT_KEYS, CORE_FEAT_KEYS_V2, TIME_FEAT_KEYS, + MU_FEAT_KEYS, ALL_FEAT_KEYS, ALL_FEAT_KEYS_V2, + ILLUSION_TARGET_KEYS, ILLUSION_FEAT_KEYS_V2, ILLUSION_ALL_FEAT_KEYS_V2, + PHYSICS_FEAT_KEYS, ILLUSION_ERR_KEYS, + LAG_FEAT_KEYS, DERIV_FEAT_KEYS, ACTION_LAG_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, + PHASE_STATE_KEYS, ILLUSION_PHASE_KEYS, KARMAN_EXPANDED_KEYS, ) from .sindy_fitter import ( fit_channel, fit_sindy, print_control_law, get_active_support, get_feature_matrix_from_data, + fit_sindy_weighted, get_feature_matrix_v2, + compute_action_deriv, get_feature_matrix_deriv, ) diff --git a/src/SR_analysis/utils/cfd_interface.py b/src/SR_analysis/utils/cfd_interface.py index 5c68c6c..a0b0f5e 100644 --- a/src/SR_analysis/utils/cfd_interface.py +++ b/src/SR_analysis/utils/cfd_interface.py @@ -208,6 +208,12 @@ def add_pinball( 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 { @@ -283,7 +289,7 @@ def vorticity_from_ddf(flow_field: FlowField, u0: float) -> np.ndarray: - ddf[:, :, 3] - ddf[:, :, 6] - ddf[:, :, 7]) / u0 uy = (ddf[:, :, 2] + ddf[:, :, 5] + ddf[:, :, 6] - ddf[:, :, 4] - ddf[:, :, 7] - ddf[:, :, 8]) / u0 - omega = np.gradient(uy, axis=1) - np.gradient(ux, axis=0) + omega = np.gradient(uy, axis=0) - np.gradient(ux, axis=1) return omega.astype(np.float64) diff --git a/src/SR_analysis/utils/feature_builder.py b/src/SR_analysis/utils/feature_builder.py index 3aaef70..8713f50 100644 --- a/src/SR_analysis/utils/feature_builder.py +++ b/src/SR_analysis/utils/feature_builder.py @@ -7,7 +7,7 @@ Copy of analysis_cloak/common/feature_builder.py -- kept as canonical source. """ from __future__ import annotations -from typing import Dict, List, Tuple +from typing import Dict, List, Optional, Tuple import numpy as np @@ -86,6 +86,7 @@ def apply_G_x(dim: Dict[str, np.ndarray], # -- Feature key definitions ------------------------------------------------- +# Original feature set (includes sin_ua/cos_ua) CORE_FEAT_KEYS = [ "u_m", "u_a", "u_c", "v_a", @@ -96,9 +97,99 @@ CORE_FEAT_KEYS = [ "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 ----------------------------------------------------- @@ -110,7 +201,13 @@ def compute_features( 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. @@ -121,6 +218,8 @@ def compute_features( 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. @@ -153,11 +252,12 @@ def compute_features( sym["Cl_tot"] = Cl_F + Cl_T + Cl_B sym["Cl_diff"] = Cl_T - Cl_B - # Phase - sym["sin_ua"] = np.sin(np.pi * sym["u_a"]) - sym["cos_ua"] = np.cos(np.pi * sym["u_a"]) + # 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) + # Memory (nondim alpha) -- discrete version sym["aF_lag1"] = a[:, 0] sym["aB_lag1"] = a[:, 1] sym["aT_lag1"] = a[:, 2] @@ -165,6 +265,22 @@ def compute_features( 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) @@ -172,6 +288,72 @@ def compute_features( 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 diff --git a/src/SR_analysis/utils/sindy_fitter.py b/src/SR_analysis/utils/sindy_fitter.py index 6f97b39..db5cc50 100644 --- a/src/SR_analysis/utils/sindy_fitter.py +++ b/src/SR_analysis/utils/sindy_fitter.py @@ -179,3 +179,325 @@ def get_feature_matrix_from_data( 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) diff --git a/src/SR_analysis/validate/eval_rollout.py b/src/SR_analysis/validate/eval_rollout.py new file mode 100644 index 0000000..b941a9b --- /dev/null +++ b/src/SR_analysis/validate/eval_rollout.py @@ -0,0 +1,258 @@ +#!/usr/bin/env python3 +"""Offline multi-step rollout evaluation for SINDy models. + +Evaluates a SINDy model by doing recursive multi-step prediction on +offline PPO data, WITHOUT running CFD. This isolates the model's +rollout behavior from CFD initialization stochasticity. + +Usage: + python3 src/SR_analysis/validate/eval_rollout.py \\ + --sindy-results src/SR_analysis/sindy/karman/sindy_results_deriv.json \\ + --scene karman_re100 + + # Compare multiple feature presets + python3 src/SR_analysis/validate/eval_rollout.py \\ + --scene karman_re100 --preset static --preset phase --preset full_lag +""" +from __future__ import annotations + +import argparse +import json +import os +import sys +from typing import List, Optional + +import numpy as np + +_REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..")) +if _REPO not in sys.path: + sys.path.insert(0, _REPO) +_SRC = os.path.join(_REPO, "src") +if _SRC not in sys.path: + sys.path.insert(0, _SRC) + +from SR_analysis.utils.feature_builder import ( + compute_dimensionless, compute_features, build_feature_matrix, + PHYSICS_FEAT_KEYS, LAG_FEAT_KEYS, DERIV_FEAT_KEYS, + AUG_LEVEL_1_KEYS, PHASE_STATE_KEYS, ILLUSION_ERR_KEYS, ILLUSION_PHASE_KEYS, +) +from SR_analysis.utils.sindy_fitter import compute_action_deriv +from SR_analysis.configs import get_scene + + +def load_model_data(scene_name: str): + """Load controlled data and scene config.""" + cfg = get_scene(scene_name) + scene_id = cfg["scene_id"] + data_dir = os.path.join( + os.path.dirname(__file__), "..", "data", scene_id, scene_name, + ) + npz = np.load(os.path.join(data_dir, "controlled.npz")) + sensors = npz["sensors"].astype(np.float64) + forces = npz["forces"].astype(np.float64) + actions_norm = npz["actions"].astype(np.float64) + scale = cfg["action_scale"] + bias = np.array(cfg["action_bias"], dtype=np.float64) + u0 = cfg["u0"] + actions_phys = (actions_norm * scale + bias) * u0 + target_forces = npz["target_forces"].astype(np.float64) if "target_forces" in npz else None + return sensors, forces, actions_phys, target_forces, cfg + + +def build_sindy_features(sensors, forces, actions_phys, mu, u0, dt_c, + target_forces, feat_keys): + """Build Theta_f, Theta_r, Y from data using given feat_keys. + + Returns (Theta_f, Theta_r, Y_deriv, fn_f, fn_r, a_prev_phys, a_prev2_phys). + """ + 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=False, include_mu=False, + 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) + Y_deriv = compute_action_deriv(actions_phys, dt_c, u0=u0) + + return (Theta_f, Theta_r, Y_deriv, + feat_keys, ["bias"] + feat_keys) + + +def compute_rollout( + sensors, forces, actions_phys, target_forces, cfg, + front_coef, top_coef, bottom_coef, + feat_keys_front, feat_keys_rear, + max_steps: int = 50, + center_diff: bool = False, +) -> dict: + """Compute offline multi-step rollout error.""" + mu = cfg["mu"] + u0 = cfg["u0"] + dt_c = cfg.get("sample_interval", 800) / 2000.0 + T = sensors.shape[0] + + # True PPO alpha(t) + alpha_true = actions_phys / u0 + + # SINDy predicted alpha(t) via recursive rollout + alpha_pred = np.zeros_like(alpha_true) + + # Step 0, 1: initialize from PPO data (first 2 steps use true values) + alpha_pred[0] = alpha_true[0] + alpha_pred[1] = alpha_true[1] + + # Build feature history for augmented features + fbs = {} # feature build state: sensors_raw, forces_raw + + for t in range(2, min(T, max_steps + 2)): + # Build current obs: need sensor/force from the actual data + obs_slice = np.hstack([sensors[t], forces[t]]) + + # For augmented features, also need obs[t-1] + obs_prev = np.hstack([sensors[t-1], forces[t-1]]) + + # Check if augmented features needed + needs_aug = any(k.endswith("_dt") or k.endswith("_lag1") for k in feat_keys_front) + + if needs_aug: + sensors_raw = np.vstack([sensors[t-1:t+1]]) + forces_raw = np.vstack([forces[t-1:t+1]]) + sensors_curr = sensors[t:t+1] + forces_curr = forces[t:t+1] + else: + sensors_raw = sensors[t:t+1] + forces_raw = forces[t:t+1] + sensors_curr = sensors[t:t+1] + forces_curr = forces[t:t+1] + + # Build features for current step + a_prev = alpha_pred[t-1:t] * u0 # use PREDICTED a(t-1) + a_prev2 = alpha_pred[t-2:t-1] * u0 # use PREDICTED a(t-2) + + dim = compute_dimensionless(sensors_curr, forces_curr, u0=u0, d=20.0) + tf = target_forces[t:t+1] if target_forces is not None else None + sym = compute_features(dim, a_prev, a_prev2, mu, + alpha_mode=False, include_mu=False, + include_cos_sin=False, dt_c=dt_c, u0=u0, + target_forces=tf, + sensors_raw=sensors_raw, forces_raw=forces_raw) + + # Front: no bias + fv_f = build_feature_matrix(sym, feat_keys_front, add_bias=False)[0] + d_alphaF = float(np.dot(fv_f, front_coef)) + + # Top: with bias + fv_r = build_feature_matrix(sym, feat_keys_rear, add_bias=True)[0] + d_alphaT = float(np.dot(fv_r, top_coef)) + + # Bottom: from fit (independent since we're rolling out) + d_alphaB = float(np.dot(fv_r, bottom_coef)) + + # Integrate + alpha_pred[t] = alpha_pred[t-1] + dt_c * np.array([d_alphaF, d_alphaB, d_alphaT]) + + # Compute errors at various rollout horizons + n = min(T, max_steps + 2) + # Use full-range alpha range for all horizons (stable denominator) + full_alpha_range = alpha_true[2:n].max(axis=0) - alpha_true[2:n].min(axis=0) + results = {} + for horizon in [1, 5, 20, 50]: + if horizon + 2 > n: + horizon = n - 2 + if horizon <= 0: + continue + err = alpha_pred[2:2+horizon] - alpha_true[2:2+horizon] + rmse_per_channel = np.sqrt(np.mean(err**2, axis=0)) + rel_err = rmse_per_channel / (full_alpha_range + 1e-12) + results[f"{horizon}step_rmse"] = rmse_per_channel.tolist() + results[f"{horizon}step_rel"] = rel_err.tolist() + + results["alpha_range"] = full_alpha_range.tolist() + results["n_samples"] = n - 2 + + return results + + +def main(): + ap = argparse.ArgumentParser(description="Offline SINDy rollout evaluation") + ap.add_argument("--sindy-results", type=str, required=True, + help="Path to sindy_results JSON") + ap.add_argument("--scene", type=str, required=True, + help="Scene name (e.g. karman_re100)") + ap.add_argument("--threshold", type=float, default=None, + help="SINDy threshold (default: best R2)") + args = ap.parse_args() + + # Load SINDy model + with open(args.sindy_results) as f: + data = json.load(f) + + per = data.get("per_scene", data).get(args.scene) + if per is None: + # Try top-level (joint model format) + per = data + + fn_f = per.get("feature_names_front", []) + fn_r = per.get("feature_names_rear", []) + + def _coef(ch_name): + ch = per.get(ch_name, {}) + if args.threshold is not None: + for r in ch.get("results", []): + if abs(r["threshold"] - args.threshold) < 1e-6: + return np.array(r.get("coef", ch["best_coef"]), dtype=np.float64) + return np.array(ch["best_coef"], dtype=np.float64) + + front_coef = _coef("front")[:len(fn_f)] + top_coef = _coef("top")[:len(fn_r)] + bottom_coef = _coef("bottom")[:len(fn_r)] + feat_keys_front = [k for k in fn_f if k != "bias"] + feat_keys_rear = [k for k in fn_r if k != "bias"] + + sensors, forces, actions_phys, target_forces, cfg = load_model_data(args.scene) + + print(f"Rollout eval: {args.scene}") + print(f" Features: front {len(feat_keys_front)}, rear {len(feat_keys_rear)}") + print(f" Front keys: {feat_keys_front}") + print() + + results = compute_rollout( + sensors, forces, actions_phys, target_forces, cfg, + front_coef, top_coef, bottom_coef, + feat_keys_front, feat_keys_rear, + ) + + print(f" alpha_range = [{results['alpha_range'][0]:.3f}, {results['alpha_range'][1]:.3f}, {results['alpha_range'][2]:.3f}]") + print() + print(f" {'Horizon':>8s} {'Front RMSE':>12s} {'Bottom RMSE':>12s} {'Top RMSE':>12s} {'Front rel':>10s} {'Bottom rel':>10s} {'Top rel':>10s}") + print(f" {'-'*8} {'-'*12} {'-'*12} {'-'*12} {'-'*10} {'-'*10} {'-'*10}") + for h in [1, 5, 20, 50]: + rmse_key = f"{h}step_rmse" + rel_key = f"{h}step_rel" + if rmse_key in results: + rmse = results[rmse_key] + rel = results[rel_key] + print(f" {h:>8d} {rmse[0]:>12.4f} {rmse[1]:>12.4f} {rmse[2]:>12.4f} {rel[0]:>10.3f} {rel[1]:>10.3f} {rel[2]:>10.3f}") + + print() + print("Interpretation:") + print(" 1-step = pure one-step prediction quality") + print(" 5-step = short rollout (control propagation ~1/40 of domain)") + print(" 20-step = medium rollout (~1/10 of domain)") + print(" 50-step = long rollout (~1/4 of domain)") + print(f" Relative error = RMSE / alpha_range (lower is better)") + print(f" If 5-step error >> 1-step error: model has rollout instability") + + +if __name__ == "__main__": + main() diff --git a/src/SR_analysis/validate/run_closed_loop.py b/src/SR_analysis/validate/run_closed_loop.py index 3b56b9f..6f8534f 100644 --- a/src/SR_analysis/validate/run_closed_loop.py +++ b/src/SR_analysis/validate/run_closed_loop.py @@ -40,7 +40,7 @@ from SR_analysis.utils.cfd_interface import ( from SR_analysis.utils.sindy_fitter import get_feature_matrix_from_data from SR_analysis.utils.feature_builder import ( compute_dimensionless, compute_features, build_feature_matrix, - apply_G_x, ALL_FEAT_KEYS, U0, + apply_G_x, ALL_FEAT_KEYS, ALL_FEAT_KEYS_V2, CORE_FEAT_KEYS_V2, U0, ) from SR_analysis.utils.g_operator import apply_G_raw from SR_analysis.configs import ( @@ -57,10 +57,18 @@ def build_feature_vector( mu: float, u0: float, add_bias: bool, + feat_keys: Optional[List[str]] = None, + use_v2: bool = False, + target_forces: Optional[np.ndarray] = None, ) -> np.ndarray: """Build a single-row feature vector from raw obs and action state. Matches the feature_builder logic but for a single time step. + + Args: + feat_keys: custom feature keys. If None, uses ALL_FEAT_KEYS_V2 or ALL_FEAT_KEYS. + use_v2: if True, use CORE_FEAT_KEYS_V2 (no sin/cos). Ignored if feat_keys given. + target_forces: (2,) raw lattice target forces [fx,fy], for Illusion scenes. """ sensors = obs_slice[0:6].astype(np.float64).reshape(1, 6) forces = obs_slice[6:12].astype(np.float64).reshape(1, 6) @@ -68,9 +76,22 @@ def build_feature_vector( ap2 = a_prev2.astype(np.float64).reshape(1, 3) dim = compute_dimensionless(sensors, forces, u0=u0, d=20.0) - sym = compute_features(dim, ap, ap2, mu, alpha_mode=False, include_mu=True, u0=u0) + # Detect whether to include mu terms based on feat_keys + has_mu = feat_keys is not None and "mu" in feat_keys + has_cos_sin = feat_keys is not None and "sin_ua" in feat_keys + tf = target_forces.reshape(1, 2) if target_forces is not None else None + sym = compute_features(dim, ap, ap2, mu, alpha_mode=False, + include_mu=has_mu, + include_cos_sin=has_cos_sin, + u0=u0, target_forces=tf) - feat = build_feature_matrix(sym, ALL_FEAT_KEYS, add_bias=add_bias) + if feat_keys is None: + if use_v2: + feat_keys = CORE_FEAT_KEYS_V2 + else: + feat_keys = ALL_FEAT_KEYS + + feat = build_feature_matrix(sym, feat_keys, add_bias=add_bias) return feat[0] # single row @@ -84,26 +105,42 @@ def predict_v23( top_coef: np.ndarray, feat_names_front: List[str], feat_names_rear: List[str], + feat_keys_front: Optional[List[str]] = None, + feat_keys_rear: Optional[List[str]] = None, + target_forces: Optional[np.ndarray] = None, ) -> np.ndarray: """Predict actions using v23: front no-bias + rear shared-head. Returns (3,) physical omega array: [front, bottom, top]. """ # Front channel: no bias - front = float(np.dot( - build_feature_vector(obs_slice, a_prev, a_prev2, mu, u0, add_bias=False), - front_coef)) + fv_front = build_feature_vector( + obs_slice, a_prev, a_prev2, mu, u0, add_bias=False, + feat_keys=feat_keys_front, + target_forces=target_forces, + ) + front = float(np.dot(fv_front, front_coef)) # Top channel: with bias - top = float(np.dot( - build_feature_vector(obs_slice, a_prev, a_prev2, mu, u0, add_bias=True), - top_coef)) + fv_top = build_feature_vector( + obs_slice, a_prev, a_prev2, mu, u0, add_bias=True, + feat_keys=feat_keys_rear, + target_forces=target_forces, + ) + top = float(np.dot(fv_top, top_coef)) # Bottom = -top(Gx) using shared-head G_obs, G_a_prev, G_a_prev2 = apply_G_raw(obs_slice, a_prev, a_prev2) - bottom = -float(np.dot( - build_feature_vector(G_obs, G_a_prev, G_a_prev2, mu, u0, add_bias=True), - top_coef)) + # G transforms forces: swap bottom<->top, negate fy. Target force G transform: + # target_Cd is a drag-like quantity that is even under G (stays), + # target_Cl is a lift-like quantity that is odd under G (negates). + G_target = np.array([target_forces[0], -target_forces[1]]) if target_forces is not None else None + fv_bot = build_feature_vector( + G_obs, G_a_prev, G_a_prev2, mu, u0, add_bias=True, + feat_keys=feat_keys_rear, + target_forces=G_target, + ) + bottom = -float(np.dot(fv_bot, top_coef)) return np.array([front, bottom, top], dtype=np.float64) @@ -118,20 +155,125 @@ def predict_unstructured( bottom_coef: np.ndarray, top_coef: np.ndarray, feat_names: List[str], + feat_keys: Optional[List[str]] = None, ) -> np.ndarray: """Predict actions using unstructured: each channel independent with bias.""" - feat = build_feature_vector(obs_slice, a_prev, a_prev2, mu, u0, add_bias=True) + feat = build_feature_vector( + obs_slice, a_prev, a_prev2, mu, u0, add_bias=True, + feat_keys=feat_keys, + ) front = float(np.dot(feat, front_coef)) bottom = float(np.dot(feat, bottom_coef)) + bottom = float(np.dot(feat, bottom_coef)) top = float(np.dot(feat, top_coef)) return np.array([front, bottom, top], dtype=np.float64) -def load_sindy_coefs(sindy_path: str, scene_name: str) -> Dict[str, Any]: +def predict_v23_deriv( + obs_slice: np.ndarray, + obs_prev: np.ndarray, + a_prev_phys: np.ndarray, + a_prev2_phys: np.ndarray, + mu: float, + u0: float, + dt_c: float, + front_coef: np.ndarray, + top_coef: np.ndarray, + feat_keys_front: Optional[List[str]] = None, + feat_keys_rear: Optional[List[str]] = None, + target_forces: Optional[np.ndarray] = None, + output_mode: str = "deriv", +) -> np.ndarray: + """Predict action using phase-state features, v23 structure. + + Two output modes: + "deriv": Predict d(alpha)/dt, then integrate: + omega_new = (alpha_prev + dt_c * d_alpha/dt_pred) * u0 + "absolute": Predict alpha directly: + omega_new = alpha_pred * u0 + + Returns (3,) physical omega array: [front, bottom, top]. + """ + from SR_analysis.utils.feature_builder import PHYSICS_FEAT_KEYS + + if feat_keys_front is None: + feat_keys_front = PHYSICS_FEAT_KEYS + if feat_keys_rear is None: + feat_keys_rear = PHYSICS_FEAT_KEYS + + # Check if augmented features (derivatives or lags) are needed + needs_aug = any(k.endswith("_dt") or k.endswith("_lag1") for k in feat_keys_front) + + # Build sensor/force arrays: if augmented features needed, stack prev+current + if needs_aug: + sensors_raw = np.vstack([obs_prev[0:6], obs_slice[0:6]]).astype(np.float64) + forces_raw = np.vstack([obs_prev[6:12], obs_slice[6:12]]).astype(np.float64) + # Current dim uses only the last row + sensors_curr = obs_slice[0:6].astype(np.float64).reshape(1, 6) + forces_curr = obs_slice[6:12].astype(np.float64).reshape(1, 6) + else: + sensors_raw = sensors_curr = obs_slice[0:6].astype(np.float64).reshape(1, 6) + forces_raw = forces_curr = obs_slice[6:12].astype(np.float64).reshape(1, 6) + + ap = a_prev_phys.astype(np.float64).reshape(1, 3) + ap2 = a_prev2_phys.astype(np.float64).reshape(1, 3) + + dim = compute_dimensionless(sensors_curr, forces_curr, u0=u0, d=20.0) + has_mu = any("mu" in k for k in feat_keys_front) + tf = target_forces.reshape(1, 2) if target_forces is not None else None + sym = compute_features(dim, ap, ap2, mu, alpha_mode=False, + include_mu=has_mu, + include_cos_sin=False, + u0=u0, target_forces=tf, + sensors_raw=sensors_raw, forces_raw=forces_raw) + + # Front: no bias, predict d(alpha_F)/dt + fv_front = build_feature_matrix(sym, feat_keys_front, add_bias=False)[0] + pred_F = float(np.dot(fv_front, front_coef)) + + # Top: with bias, predict d(alpha_T)/dt + fv_top = build_feature_matrix(sym, feat_keys_rear, add_bias=True)[0] + pred_T = float(np.dot(fv_top, top_coef)) + + # Bottom = -top(Gx) using shared-head + G_obs, G_a_prev, G_a_prev2 = apply_G_raw(obs_slice, a_prev_phys, a_prev2_phys) + G_target = np.array([target_forces[0], -target_forces[1]]) if target_forces is not None else None + G_sensors = G_obs[0:6].astype(np.float64).reshape(1, 6) + G_forces = G_obs[6:12].astype(np.float64).reshape(1, 6) + G_dim = compute_dimensionless(G_sensors, G_forces, u0=u0, d=20.0) + G_sym = compute_features(G_dim, G_a_prev.reshape(1, 3), G_a_prev2.reshape(1, 3), + mu, alpha_mode=False, include_mu=has_mu, + include_cos_sin=False, u0=u0, target_forces=G_target) + fv_bot = build_feature_matrix(G_sym, feat_keys_rear, add_bias=True)[0] + pred_B = -float(np.dot(fv_bot, top_coef)) + + # Convert to omega based on output_mode + if output_mode == "absolute": + # Direct prediction: pred = alpha, omega = alpha * u0 + alpha_new = np.array([pred_F, pred_B, pred_T]) + else: + # Derivative + integrate: pred = d(alpha)/dt + # omega = (alpha_prev + dt_c * d_alpha/dt) * u0 + alpha_prev = a_prev_phys / u0 + alpha_new = alpha_prev + dt_c * np.array([pred_F, pred_B, pred_T]) + omega_new = alpha_new * u0 + + return omega_new.astype(np.float64) + + +def load_sindy_coefs(sindy_path: str, scene_name: str, + threshold: Optional[float] = None) -> Dict[str, Any]: """Load SINDy coefficients for a scene from results JSON. + If threshold is given (e.g. 0.007), uses coefficients at that threshold + instead of the best_R2 ones. + + Auto-detects v1 (21 features, sin_ua/cos_ua, mu) vs v2 (14 features, no sin/cos) + feature sets based on the length of feature_names_front. + Returns dict with keys: front_coef, top_coef, bottom_coef, - feat_names_front, feat_names_rear, front_bias_mode. + feat_names_front, feat_names_rear, front_bias_mode, + feat_keys_front, feat_keys_rear, is_v2. """ with open(sindy_path) as f: data = json.load(f) @@ -143,20 +285,55 @@ def load_sindy_coefs(sindy_path: str, scene_name: str) -> Dict[str, Any]: fn_f = per["feature_names_front"] fn_r = per["feature_names_rear"] - front_coef = np.array(per["front"]["best_coef"], dtype=np.float64) - top_coef = np.array(per["top"]["best_coef"], dtype=np.float64) - bottom_coef = np.array(per["bottom"]["best_coef"], dtype=np.float64) + def _coef_at_threshold(ch_data, th): + """Get coef at given threshold, or best if th=None.""" + if th is None: + return np.array(ch_data["best_coef"], dtype=np.float64) + for r in ch_data.get("results", []): + if abs(r["threshold"] - th) < 1e-6: + return np.array(r.get("coef", ch_data["best_coef"]), dtype=np.float64) + # Fallback: best + return np.array(ch_data["best_coef"], dtype=np.float64) - # Detect if front was fitted with bias (fn_f has "bias") or without + front_coef = _coef_at_threshold(per["front"], threshold) + top_coef = _coef_at_threshold(per["top"], threshold) + bottom_coef = _coef_at_threshold(per["bottom"], threshold) + + # Trim coefficients to match feature names + front_coef = front_coef[:len(fn_f)] + top_coef = top_coef[:len(fn_r)] + bottom_coef = bottom_coef[:len(fn_r)] + + # Detect v1 vs v2: v2 has 14 features (no sin_ua/cos_ua), v1 has 16 + # v2 joint has 19 (14 core + 5 mu: mu, mu_u_a, mu_v_a, mu_Cd_tot, mu_Cl_diff) + # v1 joint has 21 (16 core + 5 mu) + # Illusion v2 has 16 (14 core + 2 target: target_Cd, target_Cl) + # Illusion v2 + mu has 21 (14 core + 2 target + 5 mu) + # Deriv mode: PHYSICS_FEAT_KEYS has 8, ILLUSION_ERR_KEYS has 10 + is_v2 = len(fn_f) in [8, 10, 14, 16, 19, 21] + + # Strip "bias" from feature keys (build_feature_matrix adds it via add_bias flag) + feat_keys_front = [k for k in fn_f if k != "bias"] + feat_keys_rear = [k for k in fn_r if k != "bias"] + + # Detect if front was fitted with bias or without front_has_bias = fn_f[0] == "bias" if len(fn_f) > 0 else False + # Extract mode from per_scene metadata (default: "v23" for old-style results) + sindy_mode = per.get("mode", "v23") + return { "front_coef": front_coef, "top_coef": top_coef, "bottom_coef": bottom_coef, "feat_names_front": fn_f, "feat_names_rear": fn_r, + "feat_keys_front": feat_keys_front, + "feat_keys_rear": feat_keys_rear, "front_has_bias": front_has_bias, + "is_v2": is_v2, + "mode": sindy_mode, + "threshold_used": threshold if threshold is not None else "best", } @@ -164,20 +341,14 @@ def run_validation( scene_name: str, coefs: Dict[str, Any], device_id: int, - n_steps: int = 100, + n_steps: int = 0, mode: str = "v23", ) -> dict: """Run closed-loop validation using a SINDy control law. Parameters ---------- - scene_name : e.g. "karman_re70" - coefs : dict from load_sindy_coefs() - device_id : GPU device - n_steps : number of closed-loop steps - mode : "v23" (rear shared-head) or "unstructured" - - Returns dict with similarity, actions range, etc. + n_steps : int, default=0 (auto: >= NX/U0 / sample_interval) """ cfg = get_scene(scene_name) re_code = cfg["re_code"] @@ -189,8 +360,17 @@ def run_validation( action_scale = cfg["action_scale"] action_bias = cfg["action_bias"] n_obj_total = cfg["n_objects_env"] + t0_steps = 2000 + dt_c = sample_interval / t0_steps - print(f"\n=== Validating {scene_name} (mode={mode}, device={device_id}) ===") + # Auto-set steps: control must propagate at least 1*NX/U0 LBM steps + NX = 1280 + min_steps = int(1 * NX / u0 / sample_interval) + if n_steps == 0 or n_steps < min_steps: + n_steps = max(min_steps, 200) + print(f" auto-set steps={n_steps} (min_steps={min_steps})") + + print(f"\n=== Validating {scene_name} (mode={mode}, device={device_id}, steps={n_steps}) ===") # Build environment cuda_cfg, field_cfg = load_legacy_configs(LEGACY_CFG_DIR) @@ -211,58 +391,76 @@ def run_validation( obs_slice_start=cfg["obs_slice"][0], obs_slice_end=cfg["obs_slice"][1], ) - # Reset to checkpoint - ff.restore_ddf() - ff.apply_ddf() - - # Bias FIFO init + # Now ff is at save_ddf(2) = post-bias state (add_pinball saves after bias rollout). + # Norm dict contains save_states from the bias FIFO. 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]) + for row in norm["save_states"]: + fifo.append(row) # Closed-loop with SINDy law sens_list = [] actions_list = [] - a_prev = action_to_physical(np.zeros((1, 3), dtype=np.float32), - scale=action_scale, bias=action_bias, u0=u0).flatten() + # For Karman: bias action = DRL action_bias = [0, -4, 4]. + # Normalized zero action [0,0,0] decodes to the same bias. + a_prev = np.zeros(3, dtype=np.float64) a_prev2 = a_prev.copy() for _ in range(n_steps): obs = fifo[-1] if fifo else np.zeros(12, dtype=np.float32) + obs_prev = list(fifo)[-2] if len(fifo) >= 2 else obs # for augmented features - # Predict using SINDy law + # Convert a_prev from normalized to physical omega for feature builder + # (SINDy was trained on physical omega Y = actions_phys) + a_prev_phys = (a_prev * action_scale + np.array(action_bias, dtype=np.float64)) * u0 + a_prev2_phys = (a_prev2 * action_scale + np.array(action_bias, dtype=np.float64)) * u0 + + # Predict using SINDy law -- returns physical omega if mode == "v23": - omega = predict_v23( - obs, a_prev, a_prev2, mu, u0, + omega_phys = predict_v23( + obs, a_prev_phys, a_prev2_phys, mu, u0, coefs["front_coef"], coefs["top_coef"], - coefs["feat_names_front"], coefs["feat_names_rear"]) + coefs["feat_names_front"], coefs["feat_names_rear"], + feat_keys_front=coefs["feat_keys_front"], + feat_keys_rear=coefs["feat_keys_rear"]) + elif mode == "deriv": + omega_phys = predict_v23_deriv( + obs, obs_prev, a_prev_phys, a_prev2_phys, mu, u0, dt_c, + coefs["front_coef"], coefs["top_coef"], + feat_keys_front=coefs["feat_keys_front"], + feat_keys_rear=coefs["feat_keys_rear"], + output_mode="deriv") + elif mode == "abs": + omega_phys = predict_v23_deriv( + obs, obs_prev, a_prev_phys, a_prev2_phys, mu, u0, dt_c, + coefs["front_coef"], coefs["top_coef"], + feat_keys_front=coefs["feat_keys_front"], + feat_keys_rear=coefs["feat_keys_rear"], + output_mode="absolute") elif mode == "unstructured": - omega = predict_unstructured( - obs, a_prev, a_prev2, mu, u0, + omega_phys = predict_unstructured( + obs, a_prev_phys, a_prev2_phys, mu, u0, coefs["front_coef"], coefs["bottom_coef"], coefs["top_coef"], - coefs["feat_names_rear"]) + coefs["feat_names_rear"], + feat_keys=coefs["feat_keys_rear"]) else: raise ValueError(f"Unknown mode: {mode}") - # Clip to valid action range - norm_a = (omega / u0 - np.array(action_bias, dtype=np.float64)) / action_scale + # Convert physical omega -> normalized action -> legacy array + norm_a = (omega_phys / u0 - np.array(action_bias, dtype=np.float64)) / action_scale norm_a = np.clip(norm_a, -1.0, 1.0).astype(np.float32) - - # Apply to CFD action_arr = scale_action(norm_a, scale=action_scale, bias=action_bias, u0=u0, n_total_bodies=n_obj_total) + ff.run(sample_interval, action_arr) obs_new = ff.obs.copy()[2:14] fifo.append(obs_new) sens_list.append(obs_new[0:6]) - actions_list.append(omega.copy()) + actions_list.append(omega_phys.copy()) + # Keep a_prev in normalized form for next iteration's memory features a_prev2 = a_prev.copy() - a_prev = omega.copy() + a_prev = norm_a.copy().astype(np.float64) # Evaluate sens_arr = np.array(sens_list, dtype=np.float32) @@ -289,28 +487,32 @@ def main(): ap.add_argument("--device", type=int, default=2, help="GPU device") ap.add_argument("--steps", type=int, default=100) ap.add_argument("--mode", type=str, default="v23", - choices=["v23", "unstructured"]) + choices=["v23", "unstructured", "deriv", "abs"], + help="Control law mode: v23 (default), unstructured, deriv, or abs") ap.add_argument("--sindy-results", type=str, default=None, help="Path to sindy_results.json") + ap.add_argument("--threshold", type=float, default=None, + help="SINDy threshold for sparsity (default: best_R2)") ap.add_argument("--out", type=str, default=None, help="Output directory for result JSON") args = ap.parse_args() if args.sindy_results is None: args.sindy_results = os.path.join( - os.path.dirname(__file__), "..", "sindy", "karman", "sindy_results.json") + os.path.dirname(__file__), "..", "sindy", "karman", "sindy_results_v2.json") - coefs = load_sindy_coefs(args.sindy_results, args.scene) + coefs = load_sindy_coefs(args.sindy_results, args.scene, threshold=args.threshold) result = run_validation(args.scene, coefs, args.device, n_steps=args.steps, mode=args.mode) + th_str = f"_th{args.threshold}" if args.threshold is not None else "" if args.out is None: out_dir = os.path.join(os.path.dirname(__file__), "..", "validate", "results") else: out_dir = args.out os.makedirs(out_dir, exist_ok=True) - out_path = os.path.join(out_dir, f"{args.scene}_{args.mode}.json") + out_path = os.path.join(out_dir, f"{args.scene}_{args.mode}{th_str}.json") with open(out_path, "w") as f: json.dump(result, f, indent=2) print(f"Saved: {out_path}") diff --git a/src/SR_analysis/validate/run_closed_loop_illusion.py b/src/SR_analysis/validate/run_closed_loop_illusion.py new file mode 100644 index 0000000..e1aa0b5 --- /dev/null +++ b/src/SR_analysis/validate/run_closed_loop_illusion.py @@ -0,0 +1,329 @@ +"""Closed-loop validator for Illusion scenes. + +Builds an illusion environment (no disturbance cylinder, 6 objects, obs[0:12]), +loads target data from existing target.npz, and validates SINDy control laws +in closed-loop CFD with v23 mode (front no-bias + rear shared-head). + +Usage (from repo root): + conda run -n pycuda_3_10 python src/SR_analysis/validate/run_closed_loop_illusion.py \\ + --scene illusion_1L --device 0 --steps 200 + +Default steps = max(320, 2*NX/U0/sample_interval) to ensure control propagates. +""" +from __future__ import annotations + +import argparse +import json +import os +import sys +from collections import deque +from typing import Any, Dict, Optional + +import numpy as np + +_REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..")) +if _REPO not in sys.path: + sys.path.insert(0, _REPO) +_SRC = os.path.join(_REPO, "src") +if _SRC not in sys.path: + sys.path.insert(0, _SRC) + +from LegacyCelerisLab import FlowField # noqa: E402 + +from SR_analysis.utils.cfd_interface import ( + load_legacy_configs, compute_similarity, scale_action, + vorticity_from_ddf, save_vorticity_png, +) +from SR_analysis.configs import get_scene, LEGACY_CFG_DIR, FIFO_LEN + +from SR_analysis.validate.run_closed_loop import ( + predict_v23, predict_v23_deriv, load_sindy_coefs, +) + +# Import target force harmonic reconstruction from inference pipeline +from SR_analysis.scripts.infer_illusion import gen_target_states_at + +DATA_TYPE = np.float32 +NX = 1280 + + +def run_validation_illusion( + scene_name: str, + sindy_path: str, + device_id: int, + n_steps: int = 0, + threshold: Optional[float] = None, + out_dir: Optional[str] = None, +) -> dict: + """Run closed-loop validation for an Illusion scene. + + Parameters + ---------- + n_steps : int, default=0 (auto: >= 2*NX/U0 / sample_interval) + """ + cfg = get_scene(scene_name) + u0 = cfg["u0"] + mu = cfg["mu"] + l0 = 20.0 + sample_interval = cfg["sample_interval"] + conv_len = cfg.get("conv_len", 36) + action_scale = cfg["action_scale"] + action_bias = cfg["action_bias"] + n_obj_total = cfg["n_objects_env"] + sensor_x = cfg["sensor_x"] + front_x = cfg["pinball_front_x"] + rear_x = cfg["pinball_rear_x"] + + # Auto-set steps: control must propagate at least 1*NX/U0 LBM steps + min_steps = int(1 * NX / u0 / sample_interval) + if n_steps == 0 or n_steps < min_steps: + n_steps = max(min_steps, 200) + print(f" auto-set steps={n_steps} (min_steps={min_steps})") + + print(f"\n=== Validating {scene_name} (device={device_id}, steps={n_steps}) ===") + + if out_dir is None: + out_dir = os.path.join(os.path.dirname(__file__), "results") + os.makedirs(out_dir, exist_ok=True) + + # Load SINDy coefficients + coefs = load_sindy_coefs(sindy_path, scene_name, threshold=threshold) + threshold_str = f"_th{threshold}" if threshold is not None else "" + + # Load target data (sensors for similarity, harmonics for target force reconstruction) + data_dir = os.path.join( + os.path.dirname(__file__), "..", "data", "illusion", scene_name, + ) + target_npz = np.load(os.path.join(data_dir, "target.npz")) + if "target_sensors" in target_npz: + target_states = target_npz["target_sensors"] + else: + target_states = target_npz["target_states"][:, 2:8] + print(f" target loaded: {target_states.shape}") + + # Load target harmonics for real-time target force reconstruction + target_harmonics = None + harm_path = os.path.join(data_dir, "target_harmonics.json") + if os.path.isfile(harm_path): + with open(harm_path) as f: + target_harmonics = json.load(f) + print(f" target harmonics loaded: {len(target_harmonics)} channels") + + # Build environment + cuda_cfg, field_cfg = load_legacy_configs(LEGACY_CFG_DIR) + field_cfg = field_cfg._replace(viscosity=float(cfg["nu"])) + + ff = FlowField(field_cfg, cuda_cfg, device_id=device_id) + ny = ff.FIELD_SHAPE[1] + + # Add 3 sensors, then 3 pinball cylinders (illusion positions) + for y_off in [2.0, 0.0, -2.0]: + sc = (sensor_x * l0, (ny - 1) / 2 + y_off * l0, 0.0) + ff.add_sensor(sc, l0 / 4.0) + ff.add_cylinder((front_x * l0, (ny - 1) / 2, 0.0), l0 / 2.0) + ff.add_cylinder((rear_x * l0, (ny - 1) / 2 + 0.75 * l0, 0.0), l0 / 2.0) + ff.add_cylinder((rear_x * l0, (ny - 1) / 2 - 0.75 * l0, 0.0), l0 / 2.0) + + n_obj = ff.obs.size // 2 + assert n_obj == 6, f"Expected 6 objects, got {n_obj}" + + # Stabilize with zero action (4*NX/U0 = 512000 steps) + stabilize_steps = int(4 * NX / u0) + print(f" stabilising pinball ({stabilize_steps} steps)...") + ff.run(stabilize_steps, np.zeros(n_obj, dtype=DATA_TYPE)) + + # --- save_ddf(1): post-stabilization checkpoint --- + ff.get_ddf() + ff.save_ddf() + + # Norm collection (zero action) + print(f" norm collection ({FIFO_LEN} steps)...") + fifo = deque(maxlen=FIFO_LEN) + for _ in range(FIFO_LEN): + ff.run(sample_interval, np.zeros(n_obj, dtype=DATA_TYPE)) + fifo.append(ff.obs.copy()[0:12]) + + temp_states = np.array(fifo, dtype=DATA_TYPE) + force_norm_fact = 6.0 * float(np.max(np.abs(temp_states[:, 6:12]))) + sens_deviation = np.mean(temp_states[:, 0:6], axis=0).astype(DATA_TYPE) + sens_norm_fact = np.zeros(6, dtype=DATA_TYPE) + for i in range(6): + sens_norm_fact[i] = 5.0 * float(np.max(np.abs(temp_states[:, i] - sens_deviation[i]))) + print(f" norm: force_norm_fact={force_norm_fact:.6f}") + + # --- Bias FIFO rollout --- + # Match legacy_env_imit.py line 142-143: [0, -U0, U0] + ff.apply_ddf() # restore save_ddf(1) + bias_arr = np.zeros(n_obj, dtype=DATA_TYPE) + bias_arr[3] = 0.0 + bias_arr[4] = -1.0 * u0 + bias_arr[5] = 1.0 * u0 + + fifo.clear() + for _ in range(FIFO_LEN): + ff.run(sample_interval, bias_arr) + fifo.append(ff.obs.copy()[0:12]) + + # --- save_ddf(2): post-bias checkpoint — consistent with FIFO --- + ff.get_ddf() + ff.save_ddf() + ff.apply_ddf() # restore to post-bias state for inference + + # Closed-loop inference + sens_list, actions_list, force_list = [], [], [] + bias_norm = np.array([0.0, 0.125, -0.125], dtype=np.float64) + a_prev = bias_norm.copy() + a_prev2 = a_prev.copy() + + # Determine mode from SINDy results metadata (default: v23) + sindy_mode = coefs.get("mode", "v23") + + for step in range(n_steps): + obs = fifo[-1] if fifo else np.zeros(12, dtype=np.float32) + obs_prev = list(fifo)[-2] if len(fifo) >= 2 else obs + + a_prev_phys = (a_prev * action_scale + np.array(action_bias, dtype=np.float64)) * u0 + a_prev2_phys = (a_prev2 * action_scale + np.array(action_bias, dtype=np.float64)) * u0 + + # Reconstruct target forces from harmonics (if available) + target_forces_step = None + if target_harmonics is not None: + target_forces_step = gen_target_states_at(step, target_harmonics) + + if sindy_mode == "absolute": + omega_phys = predict_v23_deriv( + obs, obs_prev, a_prev_phys, a_prev2_phys, mu, u0, sample_interval/2000.0, + coefs["front_coef"], coefs["top_coef"], + feat_keys_front=coefs["feat_keys_front"], + feat_keys_rear=coefs["feat_keys_rear"], + target_forces=target_forces_step, + output_mode="absolute") + else: + omega_phys = predict_v23( + obs, a_prev_phys, a_prev2_phys, mu, u0, + coefs["front_coef"], coefs["top_coef"], + coefs["feat_names_front"], coefs["feat_names_rear"], + feat_keys_front=coefs["feat_keys_front"], + feat_keys_rear=coefs["feat_keys_rear"], + target_forces=target_forces_step) + + norm_a = (omega_phys / u0 - np.array(action_bias, dtype=np.float64)) / action_scale + norm_a = np.clip(norm_a, -1.0, 1.0).astype(np.float32) + action_arr = scale_action(norm_a, scale=action_scale, bias=action_bias, + u0=u0, n_total_bodies=n_obj_total) + + ff.context.push() + ff.run(sample_interval, action_arr) + ff.context.pop() + + obs_new = ff.obs.copy()[0:12] + fifo.append(obs_new) + sens_list.append(obs_new[0:6]) + force_list.append(obs_new[6:12]) + actions_list.append(omega_phys.copy()) + + a_prev2 = a_prev.copy() + a_prev = norm_a.copy().astype(np.float64) + + # Evaluate similarity over full run + sens_arr = np.array(sens_list, dtype=np.float32) + actions_arr = np.array(actions_list, dtype=np.float64) + # Use last 2*conv_len steps for similarity (after control has propagated) + tail_sens = sens_arr[-min(2*conv_len, n_steps):] + tail_target = target_states[-min(2*conv_len, len(target_states)):] + sim_tail = compute_similarity(tail_target, tail_sens, min(conv_len, len(tail_sens)//2)) + sim_full = compute_similarity(target_states, sens_arr, conv_len) + action_range = float(np.max(np.abs(actions_arr))) + + print(f" full-run similarity={sim_full:.4f} tail similarity={sim_tail:.4f} action_range={action_range:.4f}") + + # Save controlled vorticity + try: + omega_ctrl = vorticity_from_ddf(ff, u0) + img_path = os.path.join(out_dir, f"{scene_name}_vorticity{threshold_str}.png") + save_vorticity_png(img_path, omega_ctrl, + title=f"{scene_name} SINDy closed-loop (v23, sim={sim_tail:.3f})") + print(f" controlled vorticity saved: {img_path}") + + # Uncontrolled: restore from same post-bias DDF, run zero-action for same duration + ff.restore_ddf() + ff.apply_ddf() + # Run bias steps then zero-action for same duration as controlled + for _ in range(FIFO_LEN): + ff.run(sample_interval, bias_arr) + # Reset FIFO then run zero-action for n_steps + for _ in range(n_steps): + ff.run(sample_interval, np.zeros(n_obj, dtype=DATA_TYPE)) + omega_unc = vorticity_from_ddf(ff, u0) + img_unc_path = os.path.join(out_dir, f"{scene_name}_uncontrolled{threshold_str}.png") + save_vorticity_png(img_unc_path, omega_unc, + title=f"{scene_name} uncontrolled") + print(f" uncontrolled vorticity saved: {img_unc_path}") + except Exception as e: + print(f" WARNING: controlled/uncontrolled vorticity export failed: {e}") + import traceback; traceback.print_exc() + + del ff + + # Target cylinder reference (separate env, fully stabilized) + try: + ff_tgt = FlowField(field_cfg, cuda_cfg, device_id=device_id) + tgt_diam = cfg["target_diameter"] + ff_tgt.add_cylinder((20.0 * l0, (ny - 1) / 2, 0.0), tgt_diam * l0) + for y_off in [2.0, 0.0, -2.0]: + sc = (sensor_x * l0, (ny - 1) / 2 + y_off * l0, 0.0) + ff_tgt.add_sensor(sc, l0 / 4.0) + n_obj_tgt = ff_tgt.obs.size // 2 + ff_tgt.run(int(5 * NX / u0), np.zeros(n_obj_tgt, dtype=DATA_TYPE)) + omega_tgt = vorticity_from_ddf(ff_tgt, u0) + img_tgt_path = os.path.join(out_dir, f"{scene_name}_target{threshold_str}.png") + save_vorticity_png(img_tgt_path, omega_tgt, + title=f"{scene_name} target cylinder ({tgt_diam}L)") + print(f" target vorticity saved: {img_tgt_path}") + del ff_tgt + except Exception as e: + print(f" WARNING: target vorticity export failed: {e}") + import traceback; traceback.print_exc() + + return { + "scene": scene_name, + "mode": "v23", + "similarity_full": sim_full, + "similarity_tail": sim_tail, + "action_range": action_range, + "n_steps": n_steps, + "threshold": threshold if threshold is not None else "best", + } + + +def main(): + ap = argparse.ArgumentParser(description="Illusion closed-loop SINDy validation") + ap.add_argument("--scene", type=str, required=True) + ap.add_argument("--device", type=int, default=0, help="GPU device") + ap.add_argument("--steps", type=int, default=0, + help="Steps (default: auto-set to cover 1*NX/U0)") + ap.add_argument("--threshold", type=float, default=None) + ap.add_argument("--sindy-results", type=str, default=None) + ap.add_argument("--out", type=str, default=None) + args = ap.parse_args() + + if args.sindy_results is None: + args.sindy_results = os.path.join( + os.path.dirname(__file__), "..", "sindy", "illusion", "sindy_results_v2.json") + + result = run_validation_illusion( + args.scene, args.sindy_results, args.device, + n_steps=args.steps, threshold=args.threshold, out_dir=args.out, + ) + + th_str = f"_th{args.threshold}" if args.threshold is not None else "" + out_dir = args.out or os.path.join(os.path.dirname(__file__), "results") + os.makedirs(out_dir, exist_ok=True) + out_path = os.path.join(out_dir, f"{args.scene}_v23{th_str}.json") + with open(out_path, "w") as f: + json.dump(result, f, indent=2) + print(f"Saved: {out_path}") + + +if __name__ == "__main__": + main()