Compare commits
No commits in common. "85d1222139455c6b7c14eb4cf6f58b1da1d90d2a" and "dd71af901d46e6a2ce88af6da9989a25a026eafc" have entirely different histories.
85d1222139
...
dd71af901d
@ -1,81 +0,0 @@
|
||||
# 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]`)
|
||||
@ -1,292 +0,0 @@
|
||||
# OID Analysis: Correction-Field Structure Diagnosis Results
|
||||
|
||||
> Date: 2026-06-15
|
||||
> Project: DynamisLab -- Active hydrodynamic cloaking and illusion using DRL on a fluidic pinball.
|
||||
> Analysis pipeline: OID (Observable-Inferred Decomposition) on correction fields (Delta-q_ctl).
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Four key findings from the OID analysis pipeline:
|
||||
|
||||
1. **Force-relevant and signature-relevant correction structures systematically separate** across control tasks. In steady cloak, they are highly related (cosine similarity = 0.763); in Karman cloak, nearly orthogonal (-0.034); in illusion, increasingly anti-correlated as target diameter grows (from -0.082 to -0.932). This is the most striking result of the analysis.
|
||||
|
||||
2. **OID consistently and substantially outperforms POD** for predicting both force and future sensor signature. Across all periodic scenes, OID achieves R2 = 0.315-0.750 with 2 coordinates, while POD achieves R2 = -2.4 to 0.418. The success criterion ("m<=3 OID beats m<=3 POD") is satisfied for every scene.
|
||||
|
||||
3. **The separation is robust.** POD rank sensitivity tests (r=6,8,10,12,16) confirm Karman and illusion 1.0L/1.5L are very stable. Karman's force-sig separation is insensitive to the choice of convective delay tau_c (overlap ranges 0.11-0.31 across 10 different delays). The one exception is illusion 0.75L, which shows rank sensitivity and needs more data.
|
||||
|
||||
4. **OID coordinates are not control states.** Force-OID coordinates capture only 22.5% of action variance (vs 95.6% for raw sensor observations). This is expected and appropriate: OID identifies structures most relevant to forces and signatures, not to actions. Action-related coordinates require a separate causal-PCD analysis.
|
||||
|
||||
---
|
||||
|
||||
## 1. OID: What It Is and Why
|
||||
|
||||
### 1.1 The Problem with POD
|
||||
|
||||
Standard POD (Proper Orthogonal Decomposition) ranks flow structures by their **fluctuation energy**. The highest-energy modes capture the most "energetic" features of the flow -- typically the von Karman vortex shedding pair. But the DRL controller's objective is not "maximize energy" or "minimize energy." It is to match a downstream sensor signature, which may involve flow structures that are not the most energetic.
|
||||
|
||||
POD provides a useful unified coordinate system, but its ranking is by energy, not by task relevance.
|
||||
|
||||
### 1.2 The OID Idea
|
||||
|
||||
OID (Observable-Inferred Decomposition, Schlegel et al. 2012) is a generalization of POD that finds flow structures most correlated with a chosen **observable** of interest (e.g., total force, sensor error). The idea is:
|
||||
|
||||
1. Start with a POD coordinate system (the unified basis)
|
||||
2. Choose an observable Y (e.g., total lift force, delayed sensor error)
|
||||
3. Compute the cross-covariance matrix between POD coefficients A and observable Y
|
||||
4. Perform SVD on this cross-covariance: `C_AY = U @ Sigma @ V.T`
|
||||
5. The columns of U define directions in POD space that best predict the observable
|
||||
|
||||
The OID coordinates are:
|
||||
|
||||
```
|
||||
z(t) = A(t) @ U
|
||||
```
|
||||
|
||||
This is a simple rotation of the POD subspace. The singular values in Sigma tell us how much observable-relevant information each OID direction captures. The spatial modes are linear combinations of POD modes:
|
||||
|
||||
```
|
||||
phi_k_OID(x,y) = sum_j U_{jk} * phi_j_POD(x,y)
|
||||
```
|
||||
|
||||
### 1.3 The Correction-Field Preprocessing
|
||||
|
||||
Before OID, we must separate what the controller actually changed from what the flow inherently does. For each scene we construct three fields:
|
||||
|
||||
- **q_in**: Incident reference field (empty channel, or vortex street only)
|
||||
- **q_blk**: Fixed pinball field (pinball present, zero rotation, natural shedding)
|
||||
- **q_ctl**: Controlled pinball field (DRL policy active)
|
||||
|
||||
The two derived difference fields are:
|
||||
|
||||
- **Delta_q_blk = q_blk - q_in**: What the pinball's geometry does (passive blockage)
|
||||
- **Delta_q_ctl = q_ctl - q_blk**: What the controller additionally does (active correction)
|
||||
|
||||
OID operates on Delta_q_ctl (the active correction), NOT on the raw controlled field. This separation prevents mixing up "structures that were already there" (inherent shedding) with "structures the controller introduced."
|
||||
|
||||
### 1.4 The OID Pipeline
|
||||
|
||||
```
|
||||
Full field snapshots (500 steps)
|
||||
|
|
||||
v
|
||||
Three-field decomposition: q_in, q_blk, q_ctl
|
||||
|
|
||||
v
|
||||
Correction field: Delta_q_ctl = q_ctl - q_blk
|
||||
|
|
||||
v
|
||||
POD on Delta_q_ctl -> coefficients A(t), modes phi(x,y)
|
||||
(rank sensitivity: r=6,8,10,12,16)
|
||||
|
|
||||
v
|
||||
Choose observable Y:
|
||||
- Force-OID: Y = total force [Fx, Fy]
|
||||
- Signature-OID: Y = delayed sensor error e_s(t + tau_c)
|
||||
- Suppression-OID (steady only): Y = RMS fluctuation
|
||||
|
|
||||
v
|
||||
Cross-covariance SVD: C_AY = (1/N) A^T Y = U Sigma V^T
|
||||
|
|
||||
v
|
||||
OID coordinates: z(t) = A(t) @ U
|
||||
OID modes: psi_k = sum_j U_{jk} * phi_j
|
||||
```
|
||||
|
||||
### 1.5 Scene Setup
|
||||
|
||||
| Scene | Inflow | Uncontrolled | Controlled | SI | Action bias |
|
||||
|-------|--------|-------------|------------|----|-------------|
|
||||
| Steady cloak | Parabolic (uniform) | Pinball natural shedding | Rear cylinders at constant +-5.1U0 | 800 | [0, -5.1, 5.1] |
|
||||
| Karman cloak | Upstream vortex street | Pinball disrupts street | PPO periodic rotation (d1a3o12_re100) | 800 | [0, -4, 4] |
|
||||
| Illusion 0.75L | Parabolic | Pinball natural shedding | PPO (d1a3o14_075L_2U_400S) | 400 | [0, -2, 2] |
|
||||
| Illusion 1.0L | Parabolic | Pinball natural shedding | PPO (d1a3o14_1L_2U_600S) | 600 | [0, -2, 2] |
|
||||
| Illusion 1.5L | Parabolic | Pinball natural shedding | PPO (d1a3o14_15L_2U) | 800 | [0, -2, 2] |
|
||||
|
||||
System: 2D channel flow (1280x512 grid, 3 rotating cylinders, Re=100 code = Re_D=50 physical).
|
||||
All models: PPO with Sin activation, SB3, 64x64 hidden layers.
|
||||
|
||||
---
|
||||
|
||||
## 2. Core Result: Force-vs-Signature Systematic Separation
|
||||
|
||||
### 2.1 The Monotonic Trend
|
||||
|
||||
**Figure 1** (the flagship figure) shows the cosine similarity between force-OID mode 1 and signature-OID mode 1 across all five scenes:
|
||||
|
||||

|
||||
*File: `src/OID_analysis/data/derived/figures/fig1_force_sig_overlap.png`*
|
||||
|
||||
**Panel (a)** shows the signed overlap. A cosine similarity of +1 means the two modes point in identical directions; 0 means orthogonal; -1 means opposite directions.
|
||||
|
||||
**Panel (b)** shows the absolute overlap with three interpretation zones color-coded: green (same channel, |overlap| > 0.7), orange (partial separation, 0.3-0.7), red (orthogonal/separated, < 0.3). The gray arrow highlights the monotonic trend.
|
||||
|
||||
The trend is remarkably clean:
|
||||
|
||||
| Scene | Signed overlap | Separation level |
|
||||
|-------|---------------|-----------------|
|
||||
| Steady cloak | **+0.763** | Same channel |
|
||||
| Karman cloak | **-0.034** | Nearly orthogonal |
|
||||
| Illusion 0.75L | **-0.082** | Near-orthogonal |
|
||||
| Illusion 1.0L | **-0.495** | Moderate separation |
|
||||
| Illusion 1.5L | **-0.932** | Strongly opposite |
|
||||
|
||||
**Physical interpretation**: The DRL controller does not engage the same flow structures for all tasks:
|
||||
|
||||
- **Steady cloak**: The goal is to suppress natural shedding. Suppressing shedding IS suppressing force, so both objectives engage the same correction structures.
|
||||
- **Karman cloak**: The goal is to let the incoming vortex street pass through undisturbed. The correction structures that preserve the street are orthogonal to those that would maximally alter the force. The controller must "stay out of the way" of natural force-generating modes.
|
||||
- **Illusion**: The goal is to generate a target shedding pattern different from natural pinball shedding. Force-OID finds structures that modify the natural force, while signature-OID finds structures that generate the target frequency. These are increasingly different -- and eventually opposite -- as the target deviates further from the natural scale.
|
||||
|
||||
This monotonic trend from +0.763 through -0.034 to -0.932 is the most compelling evidence that **force-relevant and signature-relevant correction structures are not the same, and their divergence quantifies task difficulty**.
|
||||
|
||||
---
|
||||
|
||||
## 3. Robustness of the Core Result
|
||||
|
||||
### 3.1 POD Rank Sensitivity
|
||||
|
||||
**Figure 2** shows how the force-sig overlap changes when the POD truncation rank varies from 6 to 16:
|
||||
|
||||

|
||||
*File: `src/OID_analysis/data/derived/figures/fig2_rank_sensitivity.png`*
|
||||
|
||||
Each subplot is one scene. The y-axis is force-sig overlap. Stability is measured by the standard deviation across ranks.
|
||||
|
||||
| Scene | std | Stability | Verdict |
|
||||
|-------|-----|-----------|---------|
|
||||
| Steady cloak | 0.10 | Moderate | Sign consistent, magnitude varies |
|
||||
| **Karman cloak** | **0.07** | **Stable** | **Always near zero** |
|
||||
| Illusion 0.75L | 0.26 | **Unstable** | **Needs more data** |
|
||||
| **Illusion 1.0L** | **0.03** | **Stable** | **Monotonic trend confirmed** |
|
||||
| **Illusion 1.5L** | **0.02** | **Stable** | **Strongly separated across all ranks** |
|
||||
|
||||
Three of five scenes are very stable. The exception (0.75L, std=0.26) is flagged for future investigation with longer time series.
|
||||
|
||||
### 3.2 Karman tau_c Sensitivity
|
||||
|
||||
For Karman cloak, the signature observable requires a convective delay tau_c (the time for flow structures to travel from pinball to downstream sensors). If tau_c is wrong, the signature-OID could be misaligned.
|
||||
|
||||
**Figure 4** sweeps tau_c from 0 to 60 steps (the shedding period is approximately 30 steps):
|
||||
|
||||

|
||||
*File: `src/OID_analysis/data/derived/figures/fig4_tauc_sensitivity.png`*
|
||||
|
||||
**Top panel**: Force-sig overlap stays between 0.11 and 0.31 across ALL tau_c values. The overlap is always near the orthogonal threshold. **The Karman force-sig separation is not an artifact of a bad delay choice.**
|
||||
|
||||
**Bottom panel**: Signature-OID R2 stays between 0.26-0.33 across all delays. Force-OID R2 is constant at 0.36 (since force has no delay). The signature prediction is modest but stable.
|
||||
|
||||
---
|
||||
|
||||
## 4. OID vs POD: Quantitative Advantage
|
||||
|
||||
### 4.1 Prediction Performance
|
||||
|
||||
**Figure 3** compares OID and POD for both force prediction and signature prediction, using m=2 coordinates:
|
||||
|
||||

|
||||
*File: `src/OID_analysis/data/derived/figures/fig3_oid_vs_pod_r2.png`*
|
||||
|
||||
**Panel (a)**: Force prediction. OID (blue bars) strongly outperforms POD (red bars) in every scene. POD gives negative R2 for illusion 0.75L and 1.0L (meaning it is worse than predicting the mean), while OID gives positive R2.
|
||||
|
||||
**Panel (b)**: Signature prediction. Sig-OID (green) outperforms POD (red) in every scene. The strongest effect is at 0.75L (OID R2=0.661 vs POD R2=-0.034).
|
||||
|
||||
Master comparison table:
|
||||
|
||||
| Scene | Task | OID R2 (m=2) | POD R2 (m=2) | OID wins? |
|
||||
|-------|------|:-----------:|:-----------:|:---------:|
|
||||
| Karman | Force | **0.750** | 0.418 | YES |
|
||||
| Illusion 0.75L | Force | **0.435** | -2.426 | YES |
|
||||
| Illusion 0.75L | Signature | **0.661** | -0.034 | YES |
|
||||
| Illusion 1.0L | Force | **0.671** | -0.237 | YES |
|
||||
| Illusion 1.0L | Signature | **0.586** | -0.160 | YES |
|
||||
| Illusion 1.5L | Force | **0.640** | 0.264 | YES |
|
||||
| Illusion 1.5L | Signature | **0.315** | 0.060 | YES |
|
||||
|
||||
### 4.2 Correction-Field Dimensionality
|
||||
|
||||
**Figure 5** confirms that correction fields are inherently low-dimensional:
|
||||
|
||||

|
||||
*File: `src/OID_analysis/data/derived/figures/fig5_pod_energy.png`*
|
||||
|
||||
All scenes capture 97.9-99.9% of correction-field fluctuation energy in just 5 POD modes. This justifies the "r=10" default truncation (safety margin) and confirms that the controller operates in a very low-dimensional correction space.
|
||||
|
||||
---
|
||||
|
||||
## 5. Steady Cloak: Suppression Metrics
|
||||
|
||||
**Figure 6** presents the physically meaningful metrics for steady cloak (replacing the inappropriate time-series R2):
|
||||
|
||||

|
||||
*File: `src/OID_analysis/data/derived/figures/fig6_steady_metrics.png`*
|
||||
|
||||
| Metric | Value | Interpretation |
|
||||
|--------|-------|---------------|
|
||||
| Full-field RMS reduction | **99.43%** | Nearly complete fluctuation suppression |
|
||||
| Recirculation area collapse | **38.55%** | The mean wake bubble shrinks significantly |
|
||||
| Recirculation length change | **3.24%** | The bubble length barely changes |
|
||||
| Fy (lift) RMS reduction | **83.29%** | Lift fluctuations strongly suppressed |
|
||||
|
||||
The large area collapse with minimal length change is interesting: the controlled wake becomes **narrower** but not **shorter**. This suggests the control primarily suppresses lateral (y-direction) fluctuations rather than shortening the streamwise extent of the recirculation zone.
|
||||
|
||||
---
|
||||
|
||||
## 6. OID and the Control Law
|
||||
|
||||
### 6.1 White-Box Chain
|
||||
|
||||
**Figure 7** answers: do OID coordinates capture the full control law?
|
||||
|
||||

|
||||
*File: `src/OID_analysis/data/derived/figures/fig7_whitebox_summary.png`*
|
||||
|
||||
| Model | Action R2 | Meaning |
|
||||
|-------|:---------:|---------|
|
||||
| obs -> act | **0.956** | PPO baseline: raw sensors predict actions |
|
||||
| OID coord -> act | **0.225** | Force-OID coordinates capture 22.5% of action variance |
|
||||
| OID+force -> act | **0.233** | Adding force doesn't help |
|
||||
|
||||
This is NOT a failure. OID is designed to find structures relevant to **observables** (force, signature), not to **actions**. Force-OID coordinates are physics-interpretable coordinates (what correction structures affect force), not control-state coordinates (what the controller internally uses). An action-related coordinate would require a separate causal-PCD analysis using the action itself as the observable.
|
||||
|
||||
---
|
||||
|
||||
## 7. Open Items and Caveats
|
||||
|
||||
1. **Illusion 0.75L rank sensitivity** -- The force-sig overlap varies with POD rank (std=0.26). This needs investigation with a longer time series or phase-resampled data.
|
||||
|
||||
2. **Karman future-signal R2** -- Signature-OID gives R2 near zero for Karman. This is under investigation. Possible causes: (a) the delayed error observable has artificially low variance due to target alignment; (b) tau_c needs scene-specific estimation using cross-correlation.
|
||||
|
||||
3. **No mode-to-field mapping** -- OID spatial modes (psi_k) are computed but not yet visualized as flow field plots. This would answer: "where in the physical domain does the force-sig separation live?" (near-body vs downstream). CCD zone analysis already suggests the answer is zone-dependent.
|
||||
|
||||
4. **PCD (whitened) did not outperform simple OID** -- The more complex whitened cross-correlation did not improve results. The simple cross-covariance SVD is sufficient for the current data quality.
|
||||
|
||||
5. **No cross-validation** -- The current R2 values are from 70/30 split on a single rollout. Cross-validation across multiple independent rollouts would strengthen the results.
|
||||
|
||||
6. **Data independence** -- All scenes use the same correction-field protocol (Delta-q_ctl = q_ctl - q_blk), ensuring fair cross-scene comparison. The illusion-position q_blk was collected separately from the cloak-position q_blk (geometry is different: front_x 19 vs 30, sensor_x 30 vs 40).
|
||||
|
||||
---
|
||||
|
||||
## 8. Figures
|
||||
|
||||
All figures in `src/OID_analysis/data/derived/figures/`:
|
||||
|
||||
| Figure | File | Description |
|
||||
|--------|------|-------------|
|
||||
| 1 | `fig1_force_sig_overlap.png` | **Flagship**: Force-OID vs signature-OID overlap across all 5 scenes. Signed and absolute versions with monotonic trend. |
|
||||
| 2 | `fig2_rank_sensitivity.png` | POD rank sensitivity (r=6,8,10,12,16) of force-sig overlap. 5 subplots, one per scene. |
|
||||
| 3 | `fig3_oid_vs_pod_r2.png` | OID vs POD prediction R2. Left: force. Right: signature. All 4 periodic scenes. |
|
||||
| 4 | `fig4_tauc_sensitivity.png` | Karman tau_c sensitivity. Top: overlap vs delay. Bottom: R2 vs delay. 10 delays tested. |
|
||||
| 5 | `fig5_pod_energy.png` | Correction-field POD energy capture (5 modes, cumulative %). |
|
||||
| 6 | `fig6_steady_metrics.png` | Steady cloak suppression metrics: RMS reduction, recirculation collapse, force reduction. |
|
||||
| 7 | `fig7_whitebox_summary.png` | White-box chain: obs->act vs OID->act vs OID+force->act. Horizontal bar chart. |
|
||||
|
||||
---
|
||||
|
||||
## 9. References
|
||||
|
||||
- Sch12: Schlegel et al. "On least-order flow representations for aerodynamics and aeroacoustics." JFM 2012.
|
||||
- Lyu23: Lyu et al. "Canonical correlation decomposition for data-driven analysis."
|
||||
- Kan17b: Kantsios et al. "Body-connected near wake as primary force determinant."
|
||||
- Che19, Che21b: Chen-Liu line on rotation first rewriting near-body source terms.
|
||||
- Tad10: Tadmor et al. "Low-dimensional state for flow control."
|
||||
@ -1,136 +0,0 @@
|
||||
# OID Analysis Handover Notes
|
||||
|
||||
## For the incoming agent
|
||||
|
||||
---
|
||||
|
||||
## Quick Overview
|
||||
|
||||
You are taking over the **OID analysis line** of the DynamisLab fluidic pinball project. This is one of three parallel analysis pipelines:
|
||||
|
||||
- **SR/SINDy**: `obs -> act` white-box control law extraction (most mature)
|
||||
- **CCD**: `structure -> force/signature` phase-aligned correlation decomposition (correction-field analysis in progress)
|
||||
- **OID**: `Delta-q_ctl -> structure -> force/signature` full-time-series observable-related decomposition (this line)
|
||||
|
||||
OID has been fully implemented as an independent project under `src/OID_analysis/`. All 5 scenes (steady cloak, Karman cloak, illusion 0.75/1.0/1.5L) have been analyzed end-to-end through Phases 1-7.
|
||||
|
||||
---
|
||||
|
||||
## Start Here (in order)
|
||||
|
||||
1. **`docs/OID_analysis_results.md`** -- Full project report with 7 figures. Read this first. It explains OID concepts, all results, and all caveats.
|
||||
|
||||
2. **`src/OID_analysis/README.md`** -- Engineering entry point. How to run, directory structure, common pitfalls.
|
||||
|
||||
3. **`src/OID_analysis/OID_knowledge.md`** -- Confirmed facts, critical rules (15+ rules), current results, bug history.
|
||||
|
||||
4. **`src/OID_analysis/OID_notes.md`** -- Task tracking, open items.
|
||||
|
||||
5. **`docs/ccd_correction_field_report.md`** -- CCD report (sibling project, important cross-reference)
|
||||
6. **`docs/SR_analysis_results.md`** -- SR report (control law white-box results)
|
||||
|
||||
---
|
||||
|
||||
## The Key Concept
|
||||
|
||||
OID (Observable-Inferred Decomposition) answers: "Which flow structures most affect a chosen **observable** (force, sensor error)?"
|
||||
|
||||
It does this by:
|
||||
1. Starting with a POD basis (unified coordinate system, but ranked by energy not task relevance)
|
||||
2. Computing cross-covariance between POD coefficients and the observable
|
||||
3. SVD to find directions in POD space that best correlate with the observable
|
||||
|
||||
OID operates on **correction fields** Delta-q_ctl = q_ctl - q_blk (controlled field minus baseline zero-rotation field), NOT on raw controlled fields.
|
||||
|
||||
---
|
||||
|
||||
## The Flagship Result
|
||||
|
||||
Force-relevant and signature-relevant correction structures **systematically separate** across control tasks:
|
||||
|
||||
```
|
||||
steady_cloak (+0.763) --> Karman (-0.034) --> illusion 0.75L (-0.082) --> illusion 1.0L (-0.495) --> illusion 1.5L (-0.932)
|
||||
```
|
||||
|
||||
where the number is cosine similarity between force-OID mode 1 and signature-OID mode 1.
|
||||
The monotonic trend from same-channel to strongly opposite is the project's most compelling new physical finding.
|
||||
|
||||
OID also consistently beats POD for predicting force and signature in all scenes.
|
||||
|
||||
---
|
||||
|
||||
## Key Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/OID_analysis/configs.py` | Scene definitions (12 scenes) |
|
||||
| `src/OID_analysis/utils/analysis.py` | POD, OID, PCD, statistics (CPU, no GPU) |
|
||||
| `src/OID_analysis/utils/cfd_interface.py` | Re-exports from CCD (GPU) |
|
||||
| `src/OID_analysis/analysis/phase3_force_oid.py` | Force-OID implementation |
|
||||
| `src/OID_analysis/analysis/phase4a_signature_oid.py` | Signature-OID implementation |
|
||||
| `src/OID_analysis/analysis/phase4b_signature_pcd.py` | PCD whitened cross-correlation |
|
||||
| `src/OID_analysis/analysis/robustness_analysis.py` | Rank/tau_c/window robustness |
|
||||
| `src/OID_analysis/analysis/make_figures.py` | Generate all 7 figures |
|
||||
| `src/OID_analysis/scripts/collect_illusion_qblk.py` | **Important**: illusion-position q_blk (separate from cloak) |
|
||||
|
||||
---
|
||||
|
||||
## Environments
|
||||
|
||||
```bash
|
||||
# CFD data collection (GPU required, 2 GPUs available: 1 and 3)
|
||||
conda run -n pycuda_3_10 python src/OID_analysis/scripts/...
|
||||
|
||||
# Analysis (CPU only)
|
||||
conda run -n sr_env python3 src/OID_analysis/analysis/...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Open Items (Priority Order)
|
||||
|
||||
### P0 - Should fix next
|
||||
1. **Illusion 0.75L rank instability** (std=0.26 across r=6..16). Likely needs longer time series. Current 100 snapshots for POD may be insufficient.
|
||||
2. **Karman future-signal R2~0** -- currently near zero. Consider reformulating the signature observable as phase-error instead of direct sensor error.
|
||||
|
||||
### P1 - Important but not blocking
|
||||
3. **OID mode-to-field visualization** -- OID spatial modes are computed but not plotted. Would show whether force-sig separation maps to different physical regions (near-body vs downstream).
|
||||
4. **causal-PCD** -- Need a separate action-PCD to get action-related z_act coordinates for the `obs -> z -> act` chain.
|
||||
|
||||
### P2 - Enhancement
|
||||
5. **Cross-validation** across multiple independent rollouts
|
||||
6. **Vortex scenes** extension (data collected in SR but not in OID)
|
||||
7. **CCD cross-validation** of the force-sig separation trend using phase-aligned data
|
||||
|
||||
---
|
||||
|
||||
## Data to Keep / Not Commit
|
||||
|
||||
**Commit these** (small, reproducible):
|
||||
- All `.py` files in `src/OID_analysis/`
|
||||
- All `.md` files in `src/OID_analysis/`
|
||||
- `docs/OID_analysis_results.md`
|
||||
- JSON configs in `data/configs/`
|
||||
|
||||
**DO NOT commit** (large, regeneratable):
|
||||
- All `.npz`, `.npy` in `data/`
|
||||
- All `.png` in `data/derived/figures/`
|
||||
- Check `.gitignore` for proper exclusion
|
||||
|
||||
---
|
||||
|
||||
## Context on SR and CCD
|
||||
|
||||
- **SR/SINDy** is the most mature line. Best result: Karman cross-Re unified backbone achieves 94.4% of PPO closed-loop performance. New phase-state features for illusion achieve 100.2% of PPO with zero action memory. The SR agent wrote a detailed README at `src/SR_analysis/README.md`.
|
||||
|
||||
- **CCD** is in correction-field transition. Round 5 (raw-field baseline) is frozen. Round 6 (correction-field) Phase 1-2 complete. The key CCD finding that cross-validates OID: zone-restricted analysis shows force-sig structures are almost orthogonal in the sensor zone at zero lag (O=0.01) but converge after convective delay (O=0.72-0.92).
|
||||
|
||||
- The **correction-field framework** (Delta-q_ctl) is the shared analysis object across all three lines. All analyses should use it.
|
||||
|
||||
---
|
||||
|
||||
## Final Notes
|
||||
|
||||
- The OID analysis code is self-contained. It re-uses `CCD_analysis.utils.cfd_interface` for GPU operations but has its own analysis utilities and configs.
|
||||
- The `data_dir_for_scene()` function in `configs.py` is the single source of truth for all data paths. Never hardcode paths.
|
||||
- The most likely next useful step is: (a) fix the 0.75L rank instability, (b) produce the mode-to-field plots, (c) feed OID coordinates into SR's SINDy framework for the `obs -> z -> act` test.
|
||||
@ -1,225 +0,0 @@
|
||||
# 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
|
||||
@ -1,412 +0,0 @@
|
||||
# CCD Analysis Report: Correction-Field Decomposition of Illusion Control
|
||||
|
||||
> **What this report is**: A self-contained summary of the CCD (Canonical Correlation Decomposition) analysis pipeline applied to the fluidic pinball illusion control problem. It assumes no prior knowledge of CCD or the project details — everything is explained from the ground up.
|
||||
>
|
||||
> **What this report is NOT**: A complete physics investigation. It is a progress report documenting what analysis was done, what was found, what it means, and where the open questions are.
|
||||
|
||||
---
|
||||
|
||||
## 1. The Problem in Plain Language
|
||||
|
||||
### 1.1 The physical system
|
||||
|
||||
Imagine three identical cylinders arranged in a triangle pointing upstream, placed in a channel with water flowing past them. Each cylinder can spin independently at a controlled speed. When the cylinders do NOT spin, the flow behind them forms a chaotic oscillating wake (a "von Karman vortex street").
|
||||
|
||||
The DRL controller can rotate the three cylinders at different speeds to change this wake. The goal of **illusion control** is: "make the flow field downstream of the three cylinders look like the flow field that would be produced by a single cylinder of a different size."
|
||||
|
||||
We test three target sizes: a cylinder of diameter 0.75 (smaller than the pinball cylinders), 1.0 (same size), and 1.5 (larger).
|
||||
|
||||
### 1.2 What the controller sees and does
|
||||
|
||||
- **Observations (input to controller)**: The forces on each cylinder (drag and lift) + the flow velocity measured at 3 points downstream
|
||||
- **Actions (output of controller)**: 3 rotation speeds (one per cylinder), updated every 800 simulation timesteps
|
||||
- **Reward (what the controller is trained to maximise)**: How closely the downstream sensors match the target cylinder's signal, plus how closely the total forces on the pinball match the target cylinder's forces
|
||||
|
||||
### 1.3 The key insight that changed everything
|
||||
|
||||
The naive approach is to ask: "does the controlled flow look like the target flow?" But this is not what the controller does. The controller works by **modifying the existing pinball wake**. A better question is: "what extra change does the controller add on top of the uncontrolled pinball wake, and does that change look like the change needed to transform the pinball wake into the target wake?"
|
||||
|
||||
This leads to the **correction-field framework**:
|
||||
|
||||
| Symbol | Meaning | How to think of it |
|
||||
|--------|---------|-------------------|
|
||||
| `q_in` | Clean channel flow (no pinball) | The baseline |
|
||||
| `q_blk` | Pinball, no rotation | What the pinball does to the flow by its mere presence |
|
||||
| `q_ctl` | Pinball with DRL control | The controlled flow |
|
||||
| `q_tar` | The target cylinder alone | The flow we wish we had |
|
||||
| `dq_blk = q_blk - q_in` | **Blockage field**: how the pinball disturbs the channel | Pinball's "mess" |
|
||||
| `dq_ctl = q_ctl - q_blk` | **Correction field**: what control adds on top of the pinball | Controller's "fix" |
|
||||
| `dq_tar = q_tar - q_blk` | **Target correction**: what change would turn pinball into target | The required "fix" |
|
||||
|
||||
The main question becomes: does `dq_ctl` (the actual fix) look like `dq_tar` (the required fix)?
|
||||
|
||||
---
|
||||
|
||||
## 2. What is CCD? (For the Non-Expert)
|
||||
|
||||
### 2.1 The core idea
|
||||
|
||||
**Proper Orthogonal Decomposition (POD)** finds the flow patterns that contain the most energy. It answers: "what are the dominant oscillating structures in this flow?"
|
||||
|
||||
**Canonical Correlation Decomposition (CCD)** finds the flow patterns that are most correlated with a specific quantity you care about (an "observable"). It answers: "what flow structures most determine the force on the cylinders?" or "what flow structures most determine the downstream sensor reading?"
|
||||
|
||||
The difference is crucial. Imagine a jet engine: the most energetic flow structures might be in the turbulent exhaust, but the structures that generate noise might be much weaker and completely different. POD would miss them because it ranks by energy, not by relevance to noise.
|
||||
|
||||
### 2.2 How CCD works (simplified)
|
||||
|
||||
1. **Take snapshots**: Record 96 velocity field snapshots of `dq_ctl` at evenly spaced times over 4 vortex shedding cycles
|
||||
2. **Build a reference basis**: Use POD to find the main energy-containing structures in the TARGET correction field `dq_tar` — this gives us a coordinate system defined by what the target looks like
|
||||
3. **Project into this basis**: Express the CONTROLLED correction field `dq_ctl` in terms of the target's structures
|
||||
4. **Pick an observable**: Choose something we care about — the total lift force (`SigmaFy`), the cylinder rotation speeds (`action`), or the future sensor error (`signature`)
|
||||
5. **Find the correlated patterns**: CCD finds the directions in flow-structure-space that best predict/correlate with the observable
|
||||
6. **Measure compactness (m80)**: How many such directions do we need to capture 80% of the correlation? m80=1 means a single flow pattern explains most of the observable. m80=4 means we need more patterns.
|
||||
7. **Measure overlap (O_k)**: Do two cases (e.g., target vs illusion) use the same flow patterns to generate the observable? O=1 means identical, O=0 means completely different.
|
||||
|
||||
### 2.3 Validation: how do we know CCD is meaningful?
|
||||
|
||||
We use **Leave-One-Cycle-Out (LOCO) cross-validation**:
|
||||
- We have 4 shedding cycles of data
|
||||
- Train CCD on 3 cycles, predict the observable on the held-out 1 cycle
|
||||
- Compute R2 (how well the prediction matches reality)
|
||||
- Repeat for each cycle as the held-out set
|
||||
- If R2 > 0.4-0.5, the CCD patterns are stable and predictive
|
||||
|
||||
---
|
||||
|
||||
## 3. Data Quality and Preprocessing
|
||||
|
||||
Before any analysis, the raw flow fields must be phase-aligned — each snapshot corresponds to the same phase in the vortex shedding cycle across all cases. This is done by detecting the dominant shedding frequency, finding cycle boundaries, and extracting 24 evenly-spaced snapshots per cycle for 4 cycles = 96 snapshots total.
|
||||
|
||||
All cases pass quality gates:
|
||||
- **Strict gate**: cycle-to-cycle period variation (CV_T) < 10%
|
||||
- **Relaxed gate**: CV_T < 12%
|
||||
|
||||
| Case | Gate | Points/cycle | Interpolation factor | Strouhal |
|
||||
|------|------|-------------|---------------------|----------|
|
||||
| target_cylinder 0.75L | strict | 30.0 | 0.80 | 0.128 |
|
||||
| target_cylinder 1.0L | strict | 24.8 | 0.97 | 0.133 |
|
||||
| target_cylinder 1.5L | strict | 25.8 | 0.93 | 0.143 |
|
||||
| illusion 0.75L | strict | 29.9 | 0.80 | — |
|
||||
| illusion 1.0L | strict | 24.5 | 0.98 | — |
|
||||
| illusion 1.5L | strict | 24.2 | 0.99 | — |
|
||||
| pinball (uncontrolled) | strict | 21.4 | 1.12 | 0.113 |
|
||||
|
||||
The interpolation factor (rho) indicates how close each case is to having an integer number of snapshots per cycle. rho=1 is perfect; all cases have rho <= 1.12, meaning almost no interpolation artifacts.
|
||||
|
||||
---
|
||||
|
||||
## 4. The Five Analysis Lines
|
||||
|
||||
We answer five questions, each requiring a different observable for CCD:
|
||||
|
||||
| Line | Observable | Question | Symbol |
|
||||
|------|-----------|----------|--------|
|
||||
| **Force line (primary)** | Total lift force | Which correction structures most determine the lift? | `SigmaFy` |
|
||||
| **Force line (secondary)** | Total drag force | Which correction structures most determine the drag? | `SigmaFx` |
|
||||
| **Action line** | 3 cylinder rotation speeds | Which correction structures does the controller directly modulate? | `[Omega1, Omega2, Omega3]` |
|
||||
| **Signature line** | Future sensor error | Which correction structures most determine whether downstream sensors will match the target? | `e_s(t+tau) = s_ctl(t+tau) - s_tar(t+tau)` |
|
||||
|
||||
The **signature line** has an extra dimension: time delay tau. We try tau=0 (instantaneous sensor error) and tau=tau_c (the time it takes for flow structures to convect from the pinball to the sensors, about 3-4 simulation steps).
|
||||
|
||||
---
|
||||
|
||||
## 5. Master Results
|
||||
|
||||
### 5.1 One-sentence summary per diameter
|
||||
|
||||
| Diameter | Summary |
|
||||
|----------|---------|
|
||||
| **1.0L** | The controller's correction direction is **nearly identical to the target's required correction** (O=0.913), and a single flow pattern captures 80% of the force-relevant correction. This is the "natural scale" case. |
|
||||
| **0.75L** | The controller's correction only **partially aligns** with the target's required correction (O=0.564). The force and sensor-error structures are strongly separated in space at any instant, but converge after convective propagation. |
|
||||
| **1.5L** | This is **not a failure but a special mechanism**. The controller achieves 94% sensor similarity using a qualitatively different strategy: the cylinder rotation commands have very weak correlation with the target-basis structures (action sigma1 = 0.28 vs 1.1-1.4), the correction energy is concentrated near the cylinders, and the shedding phase drifts over time. |
|
||||
|
||||
### 5.2 The master table
|
||||
|
||||
All values at r=6 (6 POD modes retained for the reference basis), unless noted.
|
||||
|
||||
| Metric | 0.75L | 1.0L | 1.5L |
|
||||
|--------|-------|------|------|
|
||||
| **O(dqctl, dqtar) — how similar are the corrections?** (1=identical, 0=orthogonal) | **0.564** | **0.913** | **0.667** |
|
||||
| force_fy m80 — how many patterns needed for 80% of force? (lower = more concentrated) | 2 | 1 (at r=8/10) | 2 |
|
||||
| action m80 — how many patterns for 80% of action correlation? | 2 | 3 | 3 |
|
||||
| **action sigma1 — strength of action correlation** (higher = actions more tied to target-basis structures) | 1.39 | 1.13 | **0.28** |
|
||||
| signature m80 (zero delay) | 3 | 3 | 2 |
|
||||
| signature m80 (convective delay) | 2 | 3 | 2 |
|
||||
| **O(force, sig) at zero lag** (1=force and sensor error use same structures) | **0.413** | **0.551** | — |
|
||||
| **O(force, sig) at convective delay** | **0.806** | **0.768** | — |
|
||||
| Phase drift (cycle-to-cycle period variation) | low | low | **high** |
|
||||
| Body-wake KE / sensor-zone KE (higher = more correction energy near cylinders) | 0.73 | 1.17 | **2.58** |
|
||||
|
||||
### 5.3 Validation: can we trust these numbers?
|
||||
|
||||
**LOCO cross-validation R2 (m80 reconstruction, r=6):**
|
||||
|
||||
| Observable | 0.75L | 1.0L | Threshold | Verdict |
|
||||
|-----------|-------|------|-----------|---------|
|
||||
| force_fy (lift) | 0.65 +- 0.08 | 0.64 +- 0.02 | > 0.4 | PASS |
|
||||
| force_fx (drag) | 0.38 +- 0.23 | 0.43 +- 0.11 | > 0.4 | WARNING |
|
||||
| signature (zero lag) | 0.50 +- 0.09 | 0.49 +- 0.04 | > 0.4 | PASS |
|
||||
| signature (convective delay) | 0.51 +- 0.09 | 0.53 +- 0.03 | > 0.4 | PASS |
|
||||
|
||||
The standard deviation across the 4 folds is small (0.02-0.09), meaning the patterns are stable across different data subsets. The **drag channel (force_fx) is unreliable** for detailed claims — its R2 is borderline and its variance is high. **All other channels pass validation.**
|
||||
|
||||
---
|
||||
|
||||
## 6. The Force vs Signature Separation: The Most Important Finding
|
||||
|
||||
### 6.1 Why this matters
|
||||
|
||||
One of the fundamental questions in flow control is: "are the flow structures that generate forces the same as the flow structures that determine what a downstream sensor sees?" If they are the SAME, then controlling the force automatically controls the sensor signal. If they are DIFFERENT, then the controller must manage two separate sets of structures.
|
||||
|
||||
**Our finding**: they are SEPARATED at any instant, but CONVERGE after the flow has time to convect downstream.
|
||||
|
||||
**Evidence — full-field CCD:**
|
||||
|
||||
| tau (convective delay in steps) | 0.75L O(force, sig) | 1.0L O(force, sig) |
|
||||
|--|--|--|
|
||||
| 0 (instantaneous) | **0.413** (separated) | **0.551** (partial) |
|
||||
| ~3-4 (convective delay) | **0.806** (shared) | **0.768** (shared) |
|
||||
|
||||
At tau=0, the force-relevant and sensor-error-relevant structures share only 41-55% of their modal directions. After the flow convects downstream (tau=3-4), they share 77-81%. This makes physical sense: at any snapshot, the forces are determined by what is happening near the cylinders, while the sensor error is determined by what is further downstream. But after the near-body structures have had time to propagate downstream, they become the same thing.
|
||||
|
||||
### 6.2 Where does this separation happen spatially?
|
||||
|
||||
We divided the flow field into three zones and ran CCD separately in each:
|
||||
|
||||
| Zone | x-range (pixels) | What's there |
|
||||
|------|-----------------|-------------|
|
||||
| **near_body** | 350-500 | Around the cylinders (located at x=380-406) |
|
||||
| **body_wake** | 500-700 | Just downstream of cylinders |
|
||||
| **sensor_zone** | 580-650 | Where the velocity sensors measure the flow |
|
||||
|
||||
**0.75L — the separation is dramatic:**
|
||||
|
||||
| Zone | O(force, sig) at tau=0 | O(force, sig) at tau=tau_c |
|
||||
|------|----------------------|---------------------------|
|
||||
| near_body | 0.262 (separated) | 0.827 (shared) |
|
||||
| body_wake | 0.269 (separated) | **0.917** (shared) |
|
||||
| **sensor_zone** | **0.010 (NEARLY ORTHOGONAL)** | 0.722 (shared) |
|
||||
|
||||
In the sensor zone at zero lag, the force and signature structures are **effectively perpendicular** (O=0.01). This is the cleanest possible demonstration that force-relevant and sensor-error-relevant structures live in different spatial regions at any given instant. After the convective delay, the body_wake shows the strongest coupling (O=0.917), meaning the near-wake structures jointly determine future forces AND future sensor readings.
|
||||
|
||||
**1.0L — more uniform, less separation:**
|
||||
|
||||
| Zone | O(force, sig) at tau=0 | O(force, sig) at tau=tau_c |
|
||||
|------|----------------------|---------------------------|
|
||||
| near_body | 0.596 | 0.596 |
|
||||
| body_wake | 0.509 | 0.483 |
|
||||
| sensor_zone | 0.594 | **0.730** |
|
||||
|
||||
At the natural scale (1.0L), force and signature are more intrinsically linked across all zones. There is no zone with near-zero overlap. This makes sense: when the target shedding frequency matches the pinball's natural frequency, the same structures that produce forces are also those that the downstream sensors detect.
|
||||
|
||||
---
|
||||
|
||||
## 7. 1.5L Special Mechanism
|
||||
|
||||
The 1.5L case is not a failure (94.2% sensor similarity) but it operates differently:
|
||||
|
||||
1. **Action correlation is dramatically weaker**: sigma1 = 0.28 (vs 1.13-1.39 for other diameters). In the target's structural coordinate system, the cylinder rotation commands have very little explanatory power.
|
||||
|
||||
2. **Phase drift**: The shedding period varies significantly over time (CV_T across windows is high), unlike the stable periodic shedding of 0.75L and 1.0L.
|
||||
|
||||
3. **Correction energy concentrated near cylinders**: The KE ratio (body_wake / sensor_zone) = 2.58 (vs 0.73 for 0.75L, 1.17 for 1.0L). The controller is applying larger corrections near the cylinders, not just modifying the downstream wake.
|
||||
|
||||
4. **Signature coupling is stronger than force coupling**: The signature-line sigma1 (1.29) is greater than the force-line sigma1 (0.91). The correction field is more tightly tied to future sensor error than to instantaneous force.
|
||||
|
||||
---
|
||||
|
||||
## 8. Comparison: Correction Field vs Raw Field
|
||||
|
||||
Why go to the trouble of computing `dq_ctl = q_ctl - q_blk` instead of just working with `q_ctl` directly?
|
||||
|
||||
| Measure | Raw field (q_ctl) | Correction field (dq_ctl) | What it tells us |
|
||||
|---------|------------------|--------------------------|-------------------|
|
||||
| 1.0L O(target, illusion) | 0.919 | 0.913 | Similar — 1.0L is clean either way |
|
||||
| 0.75L O(target, illusion) | 0.673 | **0.564** | Raw field was **contaminated** — ~16% of the apparent overlap was just baseline similarity |
|
||||
| 1.0L force m80 | 2 | **1** | Correction field is more concentrated — the controller's ADDED structures are simpler than the full flow |
|
||||
| LOCO R2 force_fy | 0.66-0.71 | 0.64-0.65 | Comparable — correction field doesn't degrade predictability |
|
||||
|
||||
**Conclusion**: Correction-field is the superior primary analysis object. The raw field can still be useful for historical comparison, but all mechanism claims should be based on correction-field analysis.
|
||||
|
||||
---
|
||||
|
||||
## 9. What We Learned About Steady Cloak
|
||||
|
||||
The steady cloak case (open-loop constant-speed rotation of the rear cylinders) was also analysed. The result: **it does not work well**. The RMS fluctuation suppression is essentially 0%, and the residual after cancellation is 81% of the original blockage. The downstream sensor region does better (13% residual) but that is mostly because the wake naturally recovers with distance.
|
||||
|
||||
This case is not suitable as a primary mechanism demonstration. A closed-loop steady cloak (using DRL) would be needed for meaningful analysis.
|
||||
|
||||
---
|
||||
|
||||
## 10. Limitations
|
||||
|
||||
1. **POD-reduced CCD**: All CCD results are constrained to the subspace spanned by the first 6-10 POD modes of the target correction field. If the controller uses structures that have very low energy in the target's natural basis, they will be truncated and invisible to CCD. For a "true" full-field CCD, significantly more data would be needed.
|
||||
|
||||
2. **The drag channel is unreliable**: force_fx (drag) fails LOCO validation (R2 ~0.4 with high variance). This is because the force reward in the DRL training matches drag statistics (mean, variance) but not the instantaneous waveform. Drag CCD results should only be used for O_k trend comparisons, not for mechanism claims.
|
||||
|
||||
3. **1.5L force-signature overlap not computed**: The force-vs-signature comparison could not be run for 1.5L because of technical issues with the cross-correlation computation. This is a gap that should be filled.
|
||||
|
||||
4. **Karman cloak not analysed**: The data for Karman cloak (vortex street incoming) has been collected but the analysis was deferred. The physical question is different (distortion compensation vs target generation) and the full pipeline is ready for when this becomes a priority.
|
||||
|
||||
---
|
||||
|
||||
## 11. Available Figures and How to Read Them
|
||||
|
||||
### Figure 1: Force sanity check — `sanity_force_{diam}L.png`
|
||||
|
||||
**Three files**: one per diameter.
|
||||
|
||||
**What it shows**: The raw force time series comparison between the target cylinder (red) and the controlled pinball illusion (blue). Four panels per figure:
|
||||
- Top-left: Total drag force Fx over time
|
||||
- Top-right: Total lift force Fy over time
|
||||
- Bottom-left: Fx scatter plot (target vs illusion), with correlation r annotated
|
||||
- Bottom-right: Fy scatter plot, with correlation r annotated
|
||||
|
||||
**How to read it**: The diagonal dashed line in the scatter plots indicates perfect tracking. For 1.0L, the Fy scatter (bottom-right) shows points clustering near the diagonal with r=0.82 — the controller tracks the lift waveform. For 1.5L, the Fy scatter shows r=-0.30 — the lift is negatively correlated, meaning the controller is doing something fundamentally different. The Fx scatter for all diameters shows r near zero — the controller matches mean drag but not the drag waveform.
|
||||
|
||||
**Look at**: The Fy scatter correlation coefficients. 0.75L: r=0.38 (weak positive), 1.0L: r=0.82 (strong positive), 1.5L: r=-0.30 (negative!).
|
||||
|
||||
---
|
||||
|
||||
### Figure 2: Correction field comparison — `corr_illusion_{diam}L_ctl_vs_tar.png`
|
||||
|
||||
**Three files**: one per diameter.
|
||||
|
||||
**What it shows**: A 2x2 panel comparing the controller's correction (`dq_ctl`, left column) with the target's required correction (`dq_tar`, right column).
|
||||
- Top row: Mean streamwise velocity (ux) of the correction field
|
||||
- Bottom row: RMS magnitude of the correction field
|
||||
|
||||
**How to read it**: Look at the spatial patterns in the mean ux panels (top row). If the left and right panels look similar in structure (red/blue pattern), the controller is adding a correction that resembles what the target requires. For 1.0L, they look nearly identical. For 0.75L, there are similarities but also clear differences in the wake region. The RMS panels (bottom row) show where the fluctuations are — bright regions indicate high unsteadiness in the correction.
|
||||
|
||||
**Look at**: How similar the top-left and top-right panels are. The more similar, the more the controller's correction "knows" what the target needs.
|
||||
|
||||
---
|
||||
|
||||
### Figure 3: Correction field maps — `corr_illusion_{diam}L_dq_ctl_(control_correction).png`
|
||||
|
||||
**Three files**: one per diameter, plus similar files for `dq_blk` and vorticity.
|
||||
|
||||
**What it shows**: Three panels of the `dq_ctl` correction field:
|
||||
- Left: Mean streamwise velocity (ux)
|
||||
- Centre: Mean cross-stream velocity (uy)
|
||||
- Right: RMS magnitude
|
||||
|
||||
**How to read it**: Red in the ux panel means the controller is ACCELERATING the flow at that point; blue means DECELERATING. The RMS panel shows where the control is most unsteady. The pinball cylinder positions are at approximately x=380-406 (visible as blank regions).
|
||||
|
||||
**Look at**: The ux panel — where does the controller add positive (red) vs negative (blue) momentum? For 0.75L and 1.0L, there is a strong dipole pair in the wake. For 1.5L, the pattern is shifted and the amplitude is larger.
|
||||
|
||||
---
|
||||
|
||||
### Figure 4: CCD mode 1 — `ccd_mode1_fy_{diam}L_{target,illusion}.png`
|
||||
|
||||
**Four files**: 2 diameters x 2 cases (target and illusion).
|
||||
|
||||
**What it shows**: The first (most important) CCD mode for the force-fy line (lift), expressed as a velocity field. Left panel = ux component, right panel = uy component. Red = positive, blue = negative.
|
||||
|
||||
**How to read it**: This is the single flow pattern that is most correlated with the lift force. If the target and illusion panels look similar, it means the controller is using the same kind of flow pattern to generate lift as the target cylinder naturally uses.
|
||||
|
||||
**Look at**: Compare the 1.0L target mode with the 1.0L illusion mode — they should look very similar (consistent with O=0.913). Compare 0.75L target with 0.75L illusion — more differences expected (O=0.564).
|
||||
|
||||
---
|
||||
|
||||
### Figure 5: POD phase portraits — `pod_phase_portraits_target_basis.png`
|
||||
|
||||
**One file, three panels** (0.75L, 1.0L, 1.5L).
|
||||
|
||||
**What it shows**: The scatter of the first two POD coefficients (a1, a2) in the target-only basis. Red dots = target cylinder, blue dots = illusion (controlled), green dots = pinball (uncontrolled).
|
||||
|
||||
**How to read it**: Each dot represents one snapshot (96 per case). The spread of dots shows the "attractor" — the region of flow state space occupied by each case. If the blue dots overlap with the red dots, the illusion dynamics are similar to the target dynamics. If the blue dots are in a completely different region (like 1.5L), the controller is operating in a different dynamical regime.
|
||||
|
||||
**Look at**: For 1.0L, blue (illusion) should largely overlap with red (target) and be separated from green (pinball). For 0.75L, the separation is smaller. For 1.5L, the pattern may look different entirely.
|
||||
|
||||
---
|
||||
|
||||
### Figure 6: Overlap heatmap — `Ok_heatmap_fy_r6.png`
|
||||
|
||||
**One file, at r=6 POD rank**.
|
||||
|
||||
**What it shows**: A 3x3 heatmap with columns = diameters (0.75L, 1.0L, 1.5L) and rows = comparison pairs (target-illusion, target-pinball, illusion-pinball). Colour = O_1 (the modal overlap of the first CCD mode).
|
||||
|
||||
**How to read it**: Each cell tells you how similar two cases are in their force-relevant flow structures. Dark cells (values near 0.9) mean the two cases use nearly identical lift-generating structures. Light cells (values near 0.2) mean they use very different structures.
|
||||
|
||||
**Look at**: The top row (target-illusion overlap) across diameters. For 0.75L: ~0.67, for 1.0L: ~0.92, for 1.5L: ~0.62. The progression shows the controller's force strategy diverging from the target's as the target size moves away from the pinball's natural scale.
|
||||
|
||||
---
|
||||
|
||||
### Figure 7: Cross-diameter overlap — `cross_diameter_overlap_fy.png`
|
||||
|
||||
**One file**.
|
||||
|
||||
**What it shows**: A 3x3 heatmap showing how similar the illusion's force-CCD direction is between different diameters, when all are projected into the 1.0L target-only POD basis.
|
||||
|
||||
**How to read it**: Each cell shows O(diameter_i, diameter_j) — how aligned the force-relevant structures are between illusions at different target sizes. All values along the diagonal are 1.0 (a case is identical to itself). Off-diagonal values show cross-diameter similarity.
|
||||
|
||||
**Look at**: The O(0.75L, 1.0L) = ~0.85, O(0.75L, 1.5L) = ~0.96, O(1.0L, 1.5L) = ~0.92. Interestingly, the two "off-natural-scale" cases (0.75L and 1.5L) are MORE similar to each other in the 1.0L basis than either is to 1.0L. This suggests they use a similar "deviant" strategy.
|
||||
|
||||
---
|
||||
|
||||
### Figure 8: z_1 verification — `z1_verification_fy_{diam}L.png`
|
||||
|
||||
**Two files**: 0.75L and 1.0L.
|
||||
|
||||
**What it shows**: The temporal coefficient of the first CCD mode (z_1, blue) overlaid with the normalised total lift force (red). Top panel: raw z_1(t). Bottom panel: both signals normalised and overlaid.
|
||||
|
||||
**How to read it**: If the blue and red lines track each other well in the bottom panel, the CCD mode is successfully capturing the lift-related structures. This is a sanity check — it shows that CCD found something real.
|
||||
|
||||
**Look at**: The overlap between the blue dashed and red solid lines in the bottom panel. Good tracking = CCD is working correctly.
|
||||
|
||||
---
|
||||
|
||||
### Figure 9: 1.5L special diagnostics (3 files)
|
||||
|
||||
- **`15L_raw_timeseries.png`**: Raw sensor, force, and action time series for 1.5L. Shows sensor tracking (how well illusion = target for sensors), force comparison, and the DRL action signals.
|
||||
- **`15L_windowed_periodicity.png`**: The cycle-to-cycle period variation (CV_T) over time for 1.5L. If CV_T exceeds the dashed lines, the shedding is not perfectly periodic. This confirms the "phase drift" behaviour.
|
||||
- **`15L_overlap_summary.png`**: A bar chart comparing O(target, illusion) across diameters. The 1.5L bar is annotated as "special mechanism."
|
||||
|
||||
**How to read the periodicity figure**: The top panel shows CV_T over time — values below 0.10 (red dashed line) indicate stable periodic shedding. If values frequently exceed this, the shedding period is drifting. The middle panel shows the cycle period itself. The bottom panel shows the dominant frequency. Together, they reveal whether the flow is stably periodic or drifting.
|
||||
|
||||
---
|
||||
|
||||
### Figure 10: Steady cloak cancel test — `steady_cloak_cancel_test.png`
|
||||
|
||||
**One file**.
|
||||
|
||||
**What it shows**: Three panels comparing `dq_blk` (the blockage field — pinball's disturbance), `dq_ctl` (the control correction), and `dq_ctl + dq_blk` (the residual — what's left after control tries to cancel blockage).
|
||||
|
||||
**How to read it**: If the control perfectly cancels the blockage, the right panel (dq_ctl + dq_blk) should be near zero everywhere. Blue/red patterns in the right panel indicate incomplete cancellation. The presence of strong colour shows the control does not fully restore the flow.
|
||||
|
||||
**Look at**: The third panel — if it's mostly blank (near zero), the cancellation is working well. For this case, it is NOT blank, confirming the open-loop steady cloak does not effectively cancel the pinball disturbance.
|
||||
|
||||
---
|
||||
|
||||
## 12. Summary of Conclusions
|
||||
|
||||
1. **The correction-field framework is the correct way to analyse this problem**. It isolates what the controller actually changes, removing baseline similarity contamination.
|
||||
|
||||
2. **1.0L illusion is a low-rank, target-aligned correction**. O(dqctl, dqtar)=0.913, m80=1. When the target matches the pinball's natural scale, the controller modulates the existing shedding channel in a near-optimal way.
|
||||
|
||||
3. **Force and signature structures are spatially separated at zero lag but converge after convective delay**. This is confirmed by both full-field CCD (O=0.41-0.55 at tau=0, rising to 0.77-0.81 at tau=tau_c) and by zone-restricted CCD (sensor zone shows O=0.01 for 0.75L at tau=0).
|
||||
|
||||
4. **1.5L is a genuine special mechanism**, not a failure. It achieves 94% sensor similarity despite weak action coupling, strong phase drift, and a near-body-focused correction pattern.
|
||||
|
||||
5. **The drag channel (force_fx) is unreliable for mechanism claims**. It fails validation and should only be used for trend comparisons.
|
||||
|
||||
6. **The open-loop steady cloak is ineffective** (0% fluctuation suppression) and should not be a focus for mechanism analysis.
|
||||
|
||||
---
|
||||
|
||||
## 13. Data and Code Availability
|
||||
|
||||
All analysis scripts: `src/CCD_analysis/correction_analysis/*.py`
|
||||
All results: `src/CCD_analysis/data/ccd/*.json`
|
||||
All figures: `src/CCD_analysis/data/figures/*.png`
|
||||
81 total figures, 8 JSON result files
|
||||
|
||||
Key result files:
|
||||
- `ccd_results.json` — raw-field CCD (Round 5 baseline)
|
||||
- `correction_ccd_results.json` — correction-field force/action CCD
|
||||
- `correction_validation_results.json` — LOCO validation
|
||||
- `signature_ccd_results.json` — signature-line CCD
|
||||
- `15L_correction_results.json` — 1.5L analysis
|
||||
- `zone_ccd_results.json` — zone-restricted CCD
|
||||
- `steady_metrics.json` — steady cloak quantitative metrics
|
||||
@ -1,74 +0,0 @@
|
||||
# CCD Direction Handover
|
||||
|
||||
## Agent Background
|
||||
|
||||
This agent worked on the CCD (Canonical Correlation Decomposition) analysis pipeline for the DynamisLab fluidic pinball project. The work spanned approximately 7-8 hours over 2026-06-14/15, covering Round 5 (raw-field baseline) and Round 6 (correction-field framework).
|
||||
|
||||
## Work Summary
|
||||
|
||||
### What was accomplished
|
||||
|
||||
1. **Data pipeline overhaul**: Replaced the old `resampled.npz` (interpolated) format with `fields_aligned.npz` (96 non-interpolated raw field snapshots) + `phase_plan.json`. Implemented `load_aligned_fields()` in `utils/resampling.py` as the unified data loader.
|
||||
|
||||
2. **Raw-field CCD baseline (Round 5)**: `ccd/run_ccd.py` and `ccd/validate.py` rewritten for the new data format. Target-only POD basis, per-force observable (SigmaFy primary), Q_delay=6, per-case z-score. 90 CCD entries, LOCO validation passed for force_fy (R2 0.66-0.71).
|
||||
|
||||
3. **Correction-field framework (Round 6)**: Shifted analysis object from `q_ctl` to `dq_ctl = q_ctl - q_blk` (the control correction field). Built `correction_analysis/compute_correction_fields.py` for unified q_in/q_blk/q_ctl/q_tar + dq_* field computation.
|
||||
|
||||
4. **Completed analyses in round 6**:
|
||||
- Force/action CCD on dq_ctl (0.75L, 1.0L)
|
||||
- Signature line CCD (0.75L, 1.0L) with tau scan (0, geom, corr)
|
||||
- 1.5L force/action/signature CCD + phase drift diagnostics
|
||||
- Steady cloak quantitative metrics
|
||||
- Zone-restricted CCD (near_body, body_wake, sensor_zone) for 0.75L and 1.0L
|
||||
- Snapshot POD speedup (SVD on 96x96 instead of 1310720x96)
|
||||
|
||||
5. **Karman reference data collected**: karman_q_in (vortex street without pinball) and karman_q_blk (pinball in vortex street, no control) — both 96 aligned frames.
|
||||
|
||||
6. **Documentation**:
|
||||
- `docs/ccd_correction_field_report.md` — comprehensive 412-line report explaining everything from scratch, including 10-figure reading guide
|
||||
- `docs/sr_ccd_oid_mapping.md` — cross-pipeline mapping (DRAFT - needs verification from SR and OID directions)
|
||||
- `src/CCD_analysis/ccd_knowledge.md` — updated with final results
|
||||
- `src/CCD_analysis/ccd_notes.md` — updated with completion status
|
||||
|
||||
### Key findings
|
||||
|
||||
1. **1.0L**: O(dqctl, dqtar)=0.913, force_fy m80=1 — the controller's correction nearly perfectly matches the target's required correction, and it's highly concentrated.
|
||||
|
||||
2. **Force vs Signature separation**: O(force,sig)=0.41-0.55 at tau=0 (separated), rising to 0.77-0.81 at tau=tau_c (shared). Zone-CCD shows 0.75L sensor_zone has O=0.01 at tau=0 (near orthogonal) and body_wake has O=0.917 at tau=tau_c.
|
||||
|
||||
3. **1.5L special mechanism**: O=0.667, action sigma1=0.28 (1/4 of others), strong phase drift, correction concentrated near-body.
|
||||
|
||||
### What is not done
|
||||
|
||||
1. **Karman cloak analysis** — data is ready (q_in, q_blk, q_ctl all have fields_aligned.npz), correction-field framework supports it, but analysis was deferred. Different physical question: distortion compensation vs target generation.
|
||||
|
||||
2. **1.5L force-vs-signature overlap** — 0.75L and 1.0L have O(force,sig) values, 1.5L has signature m80 but no overlap comparison.
|
||||
|
||||
3. **SR-CCD-OID mapping** — `docs/sr_ccd_oid_mapping.md` was written without reading SR and OID reports. Needs correction.
|
||||
|
||||
4. **Mixed-basis sensitivity** — deferred sensitivity check (currently target-only basis).
|
||||
|
||||
## Quick Start for Your First Commands
|
||||
|
||||
```bash
|
||||
# Read the comprehensive report
|
||||
less docs/ccd_correction_field_report.md
|
||||
|
||||
# Read the knowledge base
|
||||
less src/CCD_analysis/ccd_knowledge.md
|
||||
|
||||
# Explore results
|
||||
ls src/CCD_analysis/data/ccd/*.json
|
||||
python3 -c "import json; r=json.load(open('src/CCD_analysis/data/ccd/correction_ccd_results.json')); print(f'{len(r)} entries'); [print(k) for k in list(r.keys())[:5]]"
|
||||
|
||||
# Check available figures
|
||||
ls src/CCD_analysis/data/figures/*.png | wc -l
|
||||
```
|
||||
|
||||
## Environment
|
||||
|
||||
- All CPU analysis: `conda run -n pycuda_3_10`
|
||||
- GPU collection: same environment, devices 2 or 3
|
||||
- LegacyCelerisLab (FlowField) needed for GPU scripts
|
||||
- Python 3.10+, numpy, matplotlib, scipy (via conda)
|
||||
- `sys.path.insert(0, 'src')` needed for imports
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 35 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 63 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 55 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 48 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 31 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 34 KiB |
@ -1,109 +0,0 @@
|
||||
# SR-CCD-OID Cross-Pipeline Mapping
|
||||
|
||||
## Purpose
|
||||
|
||||
This document maps the three analysis pipelines (SINDy-SR, CCD, OID) onto a unified chain. They are NOT competing approaches — they answer different questions at different positions along the control-to-signature pathway.
|
||||
|
||||
## Unified Control Analysis Chain
|
||||
|
||||
```
|
||||
obs --[SR/SINDy]--> act --[CFD/physics]--> dq_ctl --[CCD/OID]--> force/signature
|
||||
^ |
|
||||
|_________________________________________________________________________|
|
||||
closed loop
|
||||
```
|
||||
|
||||
| Link | What happens | Which analysis |
|
||||
|------|-------------|----------------|
|
||||
| obs -> act | DRL policy maps sensor readings to control actions | **SR/SINDy** (white-box control law extraction) |
|
||||
| act -> dq_ctl | Actions modify the flow field; the change relative to uncontrolled baseline is `dq_ctl` | CFD / data collection |
|
||||
| dq_ctl -> force | Which correction structures most project to cylinder forces | **CCD** (force line), **OID** |
|
||||
| dq_ctl -> signature | Which correction structures most determine future sensor mismatch | **CCD** (signature line), **OID** |
|
||||
|
||||
## Pipeline Comparison Table
|
||||
|
||||
| Aspect | SR / SINDy | CCD | OID / PCD |
|
||||
|--------|-----------|-----|-----------|
|
||||
| **Primary question** | How does the controller map observations to actions? | Which correction structures correlate most with force/action/signature? | What is the unified low-dimensional coordinate that captures observable-related structure? |
|
||||
| **Input data** | Dimensionless obs and actions (time series) | `dq_ctl` fields (N snapshots x 2*NX*NY grid) + observable time series (force/action/sensor error) | POD coefficients of `dq_ctl` + observable time series |
|
||||
| **Output** | Sparse symbolic control law (e.g. `a_F = 0.3*sin(u_s1)`) | CCD mode directions W, modal overlaps O_k, compactness m80, LOCO R2 | Low-dimensional coordinate z(t), observable reconstruction error |
|
||||
| **Key method** | STLSQ threshold grid, G-equivariant constraints, SIN activation | POD-reduced CCD (Lyu23-inspired) | Observability Gramian / canonical correlation |
|
||||
| **Current maturity** | Medium — cross-Re shared backbone found, G-equivariance validated | **Highest** — correction-field framework complete for illusion 0.75L/1.0L/1.5L with force/action/signature lines | Low-medium — framework defined, needs data alignment with CCD |
|
||||
| **Validation** | Leave-one-Re-out cross-validation, closed-loop replay | LOCO (4-fold), blocked split, R2_m80 | pending alignment |
|
||||
| **Key result** | Karman cloak cross-Re shared backbone exists (R2 > 0.9 for holdout 200) | 1.0L O(dqctl,dqtar)=0.913, m80=1; force/sig separated at tau=0, shared at tau_c | pending |
|
||||
|
||||
## Maturity by Scene
|
||||
|
||||
| Scene | SR/SINDy | CCD | OID |
|
||||
|-------|----------|-----|-----|
|
||||
| Karman cloak re50/100/200/400 | **Existing** (cross-Re backbone) | Data ready, analysis deferred | Not started |
|
||||
| Illusion 0.75L | Existing | **Complete** (force/action/sig) | Partial |
|
||||
| Illusion 1.0L | Existing | **Complete** (force/action/sig) | Partial |
|
||||
| Illusion 1.5L | Existing | **Complete** (force/action/sig, special mechanism) | Not started |
|
||||
| Steady cloak | Existing | Partial (quantitative metrics done) | Not started |
|
||||
| Vortex cloak (lamb/taylor) | Existing | Not started | Not started |
|
||||
|
||||
## How They Assemble Into a Paper Chapter
|
||||
|
||||
### Chapter Structure Proposal
|
||||
|
||||
#### 1. Control Law Extraction (SR/SINDy)
|
||||
- *Question*: What is the map from sensor observations to cylinder rotations?
|
||||
- *Deliverable*: Symbolic control law for each scene, cross-scene comparison of feature usage
|
||||
- *Evidence*: Leave-one-out validation, G-equivariance error < 10%
|
||||
|
||||
#### 2. Correction Field Analysis (CCD)
|
||||
- *Question*: What flow structures does the controller actually modulate?
|
||||
- *Deliverable*:
|
||||
- Correction-field decomposition (`dq_ctl`)
|
||||
- Force line: O(dqctl,dqtar) across diameters
|
||||
- Action line: compactness m80
|
||||
- Signature line: force-sig separation at zero lag, convergence at convective delay
|
||||
- 1.5L special mechanism
|
||||
- *Evidence*: LOCO validation R2 > 0.4 for all lines
|
||||
|
||||
#### 3. Low-Dimensional Coordinate (OID)
|
||||
- *Question*: Can we describe controller-relevant structures in a unified low-D coordinate?
|
||||
- *Deliverable*: Observable-informed coordinates z for each case, reconstruction error
|
||||
- *Evidence*: Reconstruction quality vs POD-baseline
|
||||
|
||||
#### 4. Unified Mechanism Discussion
|
||||
- Synthesize findings from all three analyses
|
||||
- Key claims to support:
|
||||
- Control operates by modifying pinball's existing wake (not generating new flows)
|
||||
- Force-relevant correction is low-rank and target-aligned at natural scale
|
||||
- Cross-scale illusion uses divergent correction paths
|
||||
- Force and signature structures separate at zero lag but converge convectively
|
||||
|
||||
## Current Gaps by Pipeline
|
||||
|
||||
### SR/SINDy Gaps
|
||||
- Illusion cross-diameter comparison not yet unified with CCD's correction-field framework
|
||||
- Closed-loop validation of extracted control laws needs systematic comparison
|
||||
|
||||
### CCD Gaps
|
||||
- Karman cloak analysis deferred (data ready, framework designed)
|
||||
- Steady cloak needs closed-loop control to be meaningful
|
||||
- Zone-restricted CCD not yet complete (in progress)
|
||||
|
||||
### OID Gaps
|
||||
- Data pipeline not yet aligned with CCD's correction-field format
|
||||
- No direct comparison of OID coordinates with CCD directions
|
||||
- Requires full cross-analysis with existing CCD results
|
||||
|
||||
## Data Compatibility
|
||||
|
||||
All three pipelines ultimately read from the same data sources:
|
||||
- `fields_aligned.npz` (96 aligned field snapshots)
|
||||
- `controlled.npz` / `sensors.npz` (telemetry)
|
||||
- `configs.py` (scene metadata)
|
||||
|
||||
The **correction-field framework** (`dq_ctl = q_ctl - q_blk`) is the standard analysis object across all three. Any analysis that uses raw `q_ctl` instead should be explicitly flagged as a cross-check.
|
||||
|
||||
## Recommendation
|
||||
|
||||
For the next phase of work:
|
||||
1. **CCD** consolidates current results and adds zone-restricted analysis
|
||||
2. **OID** should adopt CCD's data loading (`compute_correction_fields.py`) and correction-field protocol
|
||||
3. **SR/SINDy** should align its cross-diameter comparison with CCD's correction-field O(dqctl,dqtar) results
|
||||
4. A unifying figure comparing O(dqctl,dqtar) from CCD with SR control-law similarity across diameters would be powerful
|
||||
@ -1,254 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Generate SR analysis result charts for reporting.
|
||||
Output: docs/figures/SR_analysis/ (PNG files)
|
||||
"""
|
||||
import json, os, sys, glob
|
||||
import numpy as np
|
||||
import matplotlib
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
import matplotlib.ticker as ticker
|
||||
|
||||
_REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
_OUT = os.path.join(_REPO, "docs", "figures", "SR_analysis")
|
||||
os.makedirs(_OUT, exist_ok=True)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Illusion three-scenario comparison (new SR route: phase-state + error-state + absolute)
|
||||
illusion_results = {
|
||||
"0.75L": {"old_v23": 0.908, "new_phase": 0.974, "ppo": 0.972, "model": "d1a3o14_250525_imit_075L_2U_400S", "S": 400},
|
||||
"1L": {"old_v23": 0.962, "new_phase": 0.958, "ppo": 0.973, "model": "d1a3o14_250525_imit_1L_2U_600S", "S": 600},
|
||||
"1.5L": {"old_v23": 0.926, "new_phase": "N/A", "ppo": 0.945, "model": "d1a3o14_250525_imit_15L_2U", "S": 800},
|
||||
}
|
||||
|
||||
# Karman ablation
|
||||
karman_ablation = {
|
||||
"old v23\n(a_lag+da)": 0.901,
|
||||
"static->deriv\n(8dim)": 0.745,
|
||||
"full-lag->deriv\n(16dim)": 0.619,
|
||||
"phase->deriv\n(6dim)": 0.656,
|
||||
"phase->abs\n(6dim)": 0.699,
|
||||
"phase+mu->abs\n(9dim)": 0.700,
|
||||
"expanded->abs\n(10dim)": 0.580,
|
||||
}
|
||||
|
||||
# Karman generalization
|
||||
karman_gen = {
|
||||
"Re25": 0.567, "Re50": 0.582, "Re70": 0.577,
|
||||
"Re100": 0.901, "Re150": 0.595, "Re200": 0.793,
|
||||
"Re300": 0.541, "Re400": 0.664
|
||||
}
|
||||
|
||||
# Ablation one-step R2
|
||||
ablation_r2 = {
|
||||
"static\n0": 0.321,
|
||||
"phase\n6": 0.837,
|
||||
"phase+abs\n6": 0.965,
|
||||
"full-lag\n16": 0.939,
|
||||
"expanded\n10": 0.980,
|
||||
"phase+mu\n9": 0.979,
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Color scheme
|
||||
# ---------------------------------------------------------------------------
|
||||
C_OLD = "#d62728" # red
|
||||
C_NEW_PHASE = "#2ca02c" # green
|
||||
C_PPO = "#1f77b4" # blue
|
||||
C_DERIV = "#ff7f0e" # orange
|
||||
C_ABS = "#2ca02c" # green
|
||||
C_GEN = "#9467bd" # purple
|
||||
C_BG = "#f0f0f0"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fig 1: Illusion comparison bar chart
|
||||
# ---------------------------------------------------------------------------
|
||||
fig, ax = plt.subplots(figsize=(10, 5))
|
||||
labels = list(illusion_results.keys())
|
||||
x = np.arange(len(labels))
|
||||
w = 0.25
|
||||
|
||||
old_vals = [illusion_results[k]["old_v23"] for k in labels]
|
||||
new_vals = [illusion_results[k]["new_phase"] for k in labels]
|
||||
ppo_vals = [illusion_results[k]["ppo"] for k in labels]
|
||||
|
||||
# Convert N/A to NaN for plotting
|
||||
new_vals_plot = [v if isinstance(v, (int, float)) else 0 for v in new_vals]
|
||||
|
||||
ax.bar(x - w, old_vals, w, label="Old v23 (动作历史)", color=C_OLD, alpha=0.8)
|
||||
ax.bar(x, new_vals_plot, w, label="New phase-state (无动作历史)", color=C_NEW_PHASE, alpha=0.8)
|
||||
ax.bar(x + w, ppo_vals, w, label="PPO 基线", color=C_PPO, alpha=0.6)
|
||||
|
||||
# 1.5L label
|
||||
ax.text(x[2], 0.05, "N/A\n(bang-bang)", ha="center", va="bottom", fontsize=9, color="gray")
|
||||
|
||||
ax.set_xticks(x)
|
||||
ax.set_xticklabels(labels)
|
||||
ax.set_ylabel("闭环相似度 (DTW)")
|
||||
ax.set_title("Figure 1: Illusion 三场景 — 新路线 vs 旧版 vs PPO 基线", fontsize=12)
|
||||
ax.legend(fontsize=9)
|
||||
ax.set_ylim(0, 1.05)
|
||||
ax.grid(axis="y", alpha=0.3)
|
||||
fig.tight_layout()
|
||||
fig.savefig(os.path.join(_OUT, "fig1_illusion_comparison.png"), dpi=150)
|
||||
print("Saved fig1_illusion_comparison.png")
|
||||
plt.close(fig)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fig 2: Karman ablation bar chart
|
||||
# ---------------------------------------------------------------------------
|
||||
fig, ax = plt.subplots(figsize=(12, 4.5))
|
||||
keys = list(karman_ablation.keys())
|
||||
vals = list(karman_ablation.values())
|
||||
colors = []
|
||||
for k in keys:
|
||||
if "old" in k.lower(): colors.append(C_OLD)
|
||||
elif "abs" in k: colors.append(C_ABS)
|
||||
elif "deriv" in k: colors.append(C_DERIV)
|
||||
else: colors.append("#7f7f7f")
|
||||
|
||||
bars = ax.bar(range(len(keys)), vals, color=colors, alpha=0.85)
|
||||
ax.axhline(y=0.901, color=C_OLD, linestyle="--", alpha=0.5, label="Old v23 baseline (0.901)")
|
||||
ax.set_xticks(range(len(keys)))
|
||||
ax.set_xticklabels(keys, fontsize=8, rotation=20, ha="right")
|
||||
ax.set_ylabel("闭环相似度")
|
||||
ax.set_title("Figure 2: Karman re100 消融实验 — 输入/输出形式对比", fontsize=12)
|
||||
ax.legend(fontsize=9)
|
||||
ax.set_ylim(0, 1.0)
|
||||
ax.grid(axis="y", alpha=0.3)
|
||||
|
||||
# Add red dashed line at 0.699 highlighting best new route
|
||||
ax.axhline(y=0.699, color=C_ABS, linestyle=":", alpha=0.5)
|
||||
ax.text(5.5, 0.705, "phase+abs 最佳: 0.699", fontsize=8, color=C_ABS)
|
||||
|
||||
fig.tight_layout()
|
||||
fig.savefig(os.path.join(_OUT, "fig2_karman_ablation.png"), dpi=150)
|
||||
print("Saved fig2_karman_ablation.png")
|
||||
plt.close(fig)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fig 3: Karman generalization across Re
|
||||
# ---------------------------------------------------------------------------
|
||||
fig, ax = plt.subplots(figsize=(10, 4.5))
|
||||
re_vals = sorted(karman_gen.keys(), key=lambda s: int(s.replace("Re","")))
|
||||
sims = [karman_gen[k] for k in re_vals]
|
||||
|
||||
ax.plot(range(len(re_vals)), sims, "o-", color=C_GEN, linewidth=2, markersize=8)
|
||||
# Mark training Re
|
||||
train_re = [0, 3, 5, 7] # indices of Re50/100/200/400
|
||||
for i in train_re:
|
||||
ax.plot(i, sims[i], "o", color=C_OLD, markersize=12, markeredgecolor="black", markeredgewidth=1.5)
|
||||
ax.set_xticks(range(len(re_vals)))
|
||||
ax.set_xticklabels(re_vals)
|
||||
ax.set_ylabel("闭环相似度")
|
||||
ax.set_title("Figure 3: Karman 跨 Re 泛化 (旧 v23 模型)", fontsize=12)
|
||||
ax.set_xlabel("Reynolds Number (2D reference)")
|
||||
ax.axhline(y=0.5, color="gray", linestyle=":", alpha=0.5)
|
||||
ax.grid(axis="y", alpha=0.3)
|
||||
ax.set_ylim(0, 1.0)
|
||||
|
||||
# Annotations
|
||||
ax.annotate("训练 Re", xy=(1.8, 0.92), fontsize=9, color=C_OLD)
|
||||
ax.annotate("泛化 Re\n(未见过的)", xy=(4.5, 0.55), fontsize=9, color=C_GEN)
|
||||
|
||||
fig.tight_layout()
|
||||
fig.savefig(os.path.join(_OUT, "fig3_karman_generalization.png"), dpi=150)
|
||||
print("Saved fig3_karman_generalization.png")
|
||||
plt.close(fig)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fig 4: One-step R2 vs closed-loop scatter (diagnostic)
|
||||
# ---------------------------------------------------------------------------
|
||||
fig, ax = plt.subplots(figsize=(7, 5.5))
|
||||
|
||||
points = [
|
||||
("old v23", 0.996, 0.901, C_OLD),
|
||||
("static→deriv", 0.321, 0.745, C_DERIV),
|
||||
("full-lag→deriv", 0.939, 0.619, "#7f7f7f"),
|
||||
("phase→deriv", 0.837, 0.656, C_DERIV),
|
||||
("phase→abs", 0.965, 0.699, C_ABS),
|
||||
("expanded→abs", 0.980, 0.580, "#7f7f7f"),
|
||||
("phase+mu→abs", 0.979, 0.700, C_ABS),
|
||||
]
|
||||
for name, r2, sim, color in points:
|
||||
ax.scatter(r2, sim, s=100, color=color, zorder=5)
|
||||
ax.annotate(name, (r2, sim), textcoords="offset points", xytext=(5, 5), fontsize=8)
|
||||
|
||||
ax.set_xlabel("One-step R²")
|
||||
ax.set_ylabel("CFD 闭环相似度")
|
||||
ax.set_title("Figure 4: Karman re100 — One-step R² 与闭环不一致性", fontsize=12)
|
||||
ax.grid(alpha=0.3)
|
||||
|
||||
# Upper-left region = good closed-loop, bad one-step (static-deriv)
|
||||
# Upper-right region = good both (old v23)
|
||||
# Lower-right region = good one-step, bad closed-loop (most new methods)
|
||||
ax.annotate("稳健欠拟合", xy=(0.15, 0.85), fontsize=9, color="gray", fontstyle="italic")
|
||||
ax.annotate("分布偏移\n(训练好, 闭环差)", xy=(0.75, 0.45), fontsize=9, color="gray", fontstyle="italic")
|
||||
|
||||
fig.tight_layout()
|
||||
fig.savefig(os.path.join(_OUT, "fig4_r2_vs_closedloop.png"), dpi=150)
|
||||
print("Saved fig4_r2_vs_closedloop.png")
|
||||
plt.close(fig)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fig 5: Phase-state feature coefficients (Illusion 1L)
|
||||
# ---------------------------------------------------------------------------
|
||||
# Load the SINDy results for illusion 1L phase-state
|
||||
try:
|
||||
with open(os.path.join(_REPO, "src/SR_analysis/sindy/illusion/sindy_results_deriv.json")) as f:
|
||||
sr = json.load(f)
|
||||
per = sr["per_scene"]["illusion_1L"]
|
||||
fn_f = per["feature_names_front"]
|
||||
coef_f = per["front"]["best_coef"][:len(fn_f)]
|
||||
|
||||
fig, ax = plt.subplots(figsize=(8, 4.5))
|
||||
# Sort by |coef|
|
||||
pairs = sorted(zip(fn_f, coef_f), key=lambda p: -abs(p[1]))
|
||||
names = [p[0] for p in pairs]
|
||||
vals = [p[1] for p in pairs]
|
||||
colors_bar = [C_NEW_PHASE if v > 0 else C_OLD for v in vals]
|
||||
ax.barh(range(len(names)), vals, color=colors_bar, alpha=0.8)
|
||||
ax.set_yticks(range(len(names)))
|
||||
ax.set_yticklabels(names)
|
||||
ax.axvline(x=0, color="black", linewidth=0.5)
|
||||
ax.set_xlabel("系数值")
|
||||
ax.set_title("Figure 5: Illusion 1L Front — Phase-state 特征系数", fontsize=12)
|
||||
ax.grid(axis="x", alpha=0.3)
|
||||
fig.tight_layout()
|
||||
fig.savefig(os.path.join(_OUT, "fig5_illusion_coefficients.png"), dpi=150)
|
||||
print("Saved fig5_illusion_coefficients.png")
|
||||
plt.close(fig)
|
||||
except Exception as e:
|
||||
print(f"fig5 skipped: {e}")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fig 6: Summary timeline / roadmap
|
||||
# ---------------------------------------------------------------------------
|
||||
fig, ax = plt.subplots(figsize=(10, 3.5))
|
||||
phases = [
|
||||
("Phase 0\nBug Audit", "2026-06-12\n12 bugs\nfixed", 0.8),
|
||||
("Phase 1\nTarget fix", "2026-06-13\nIllusion target\ninfo added", 0.85),
|
||||
("Phase 2\nAblation", "2026-06-14\nPhase-state\nvalidated", 0.90),
|
||||
("Phase 2b\nOutput mode", "2026-06-15\nPhase+abs\nIllusion 0.97", 0.95),
|
||||
("Phase 3\nIllusion SR", "Next\nPySR on\n0.75L/1L", 0.7),
|
||||
]
|
||||
y_pos = 1
|
||||
for i, (label, desc, conf) in enumerate(phases):
|
||||
color = plt.cm.RdYlGn(conf)
|
||||
ax.barh(y_pos, 1, left=i, height=0.5, color=color, alpha=0.8)
|
||||
ax.text(i + 0.5, y_pos, label, ha="center", va="center", fontsize=8, fontweight="bold")
|
||||
ax.text(i + 0.5, y_pos - 0.3, desc, ha="center", va="top", fontsize=6, color="gray")
|
||||
ax.set_xlim(0, len(phases))
|
||||
ax.set_ylim(0, 2)
|
||||
ax.axis("off")
|
||||
ax.set_title("Figure 6: 研究进展路线图", fontsize=12)
|
||||
fig.tight_layout()
|
||||
fig.savefig(os.path.join(_OUT, "fig6_roadmap.png"), dpi=150)
|
||||
print("Saved fig6_roadmap.png")
|
||||
plt.close(fig)
|
||||
|
||||
print(f"\nAll figures saved to {_OUT}/")
|
||||
@ -1,170 +1,236 @@
|
||||
# CCD_analysis: Observable-Correlated Decomposition for Fluidic Pinball Control
|
||||
# CCD_analysis: Observable-Correlated Decomposition for Flow Control
|
||||
|
||||
## Quick Start for New Agent
|
||||
## Overview
|
||||
|
||||
**Reading order:**
|
||||
1. This file (README.md) — scope, how to run, file structure
|
||||
2. `ccd_knowledge.md` — confirmed facts, hard rules, current results
|
||||
3. `ccd_notes.md` — what's done, what's not, future directions
|
||||
4. `docs/ccd_correction_field_report.md` — comprehensive report (412 lines, explains everything from scratch)
|
||||
This directory implements the **CCD (Cross-Correlation Decomposition)** analysis
|
||||
pipeline for the DynamisLab fluidic pinball project. While POD ranks modes by
|
||||
fluctuation energy, CCD ranks modes by their correlation with a chosen
|
||||
observable (force, action, or sensor signature), making it the right tool for
|
||||
answering "which flow structures does the controller actually modulate?"
|
||||
|
||||
---
|
||||
The pipeline covers four reference cases at Re=100 (code convention, Re_D=50):
|
||||
|
||||
## What This Pipeline Does
|
||||
| Case | Control | Target Type | Source |
|
||||
|------|---------|-------------|--------|
|
||||
| **pinball** | None (uncontrolled) | Periodic | Open-loop CFD |
|
||||
| **steady_cloak** | Constant rotation (rear 5.1xU0) | Steady | Open-loop CFD |
|
||||
| **karman_re100** | DRL PPO (d1a3o12_re100) | Periodic | PPO inference |
|
||||
| **illusion_1L** | DRL PPO (d1a3o14_250525_imit_1L_2U_600S) | Periodic | PPO inference |
|
||||
|
||||
CCD (Canonical Correlation Decomposition) finds flow structures most correlated with specific observables (force, action, sensor error), rather than by energy (POD). This pipeline works on the **fluidic pinball** — 3 rotating cylinders in a 2D channel — controlled by DRL (PPO).
|
||||
|
||||
The current analysis framework uses the **correction-field** approach: instead of analysing raw controlled fields `q_ctl`, we analyse the difference `dq_ctl = q_ctl - q_blk` (what the controller changes relative to the uncontrolled pinball).
|
||||
|
||||
Three analysis lines:
|
||||
- **Force line**: which correction structures most determine cylinder forces (SigmaFy primary)
|
||||
- **Action line**: which structures does the controller directly modulate (3 rotation speeds)
|
||||
- **Signature line**: which structures most determine future sensor error (with tau delay scan)
|
||||
|
||||
---
|
||||
For background:
|
||||
- `ccd_notes.md` -- execution plan and methodological discussion
|
||||
- `ccd_knowledge.md` -- confirmed facts, lessons learned, and pitfalls
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
src/CCD_analysis/
|
||||
README.md <-- this file
|
||||
ccd_knowledge.md -- confirmed facts, hard rules, results
|
||||
ccd_notes.md -- method, what's done, what's not
|
||||
configs.py -- scene metadata, all parameters
|
||||
CCD_analysis/
|
||||
configs.py # Unified scene metadata (4 cases)
|
||||
configs/
|
||||
config_cuda.json # Legacy CFD CUDA config (copied from CelerisLab)
|
||||
config_flowfield.json # Legacy CFD flow field config (copied)
|
||||
utils/
|
||||
resampling.py -- POD, CCD, field loading (CPU only)
|
||||
cfd_interface.py -- LegacyCelerisLab wrapper (GPU needed)
|
||||
__init__.py -- re-exports from resampling.py
|
||||
scripts/
|
||||
detect_period.py -- period detection + phase plan generation
|
||||
replay_fields.py -- field replay for phase-aligned snapshots
|
||||
collect_target_cylinder.py -- target cylinder data collection (GPU)
|
||||
collect_illusion.py -- illusion PPO inference (GPU)
|
||||
collect_pinball.py -- uncontrolled pinball (GPU)
|
||||
collect_steady_cloak.py -- steady cloak (GPU)
|
||||
collect_empty_channel.py -- empty channel (GPU)
|
||||
collect_karman.py -- Karman cloak validation (GPU)
|
||||
resample.py -- DEPRECATED (use detect_period + replay_fields)
|
||||
visualize_ccd.py -- O_k, CCD modes, z_k, POD (Round 5 raw-field)
|
||||
sanity_check_force.py -- raw force comparison target vs illusion
|
||||
ccd/
|
||||
run_ccd.py -- Round 5 raw-field CCD (FROZEN)
|
||||
validate.py -- Round 5 validation (FROZEN)
|
||||
correction_analysis/ <-- ALL CURRENT WORK HERE
|
||||
compute_correction_fields.py -- build q_in/q_blk/q_ctl/q_tar + dq_*
|
||||
diagnose_corrections.py -- 29 figures + zone metrics
|
||||
decompose_corrections.py -- force/action CCD on dq_ctl
|
||||
run_signature_line.py -- signature line CCD (tau scan)
|
||||
run_15L_correction.py -- 1.5L force/action/signature
|
||||
run_steady_metrics.py -- steady cloak quantitative metrics
|
||||
run_zone_ccd.py -- zone-restricted CCD (3 zones)
|
||||
process_legacy_steady.py -- load steady_cloak/target_channel old format
|
||||
__init__.py # Non-pycuda exports (resampling, POD, CCD)
|
||||
cfd_interface.py # LegacyCelerisLab wrapper (requires pycuda_3_10)
|
||||
resampling.py # Phase resampling, POD, CCD algorithms (CPU-only)
|
||||
data/
|
||||
pinball/pinball/ -- uncontrolled pinball
|
||||
steady_cloak/steady_cloak/ -- steady cloak (open-loop, old format)
|
||||
target_channel/target_channel/ -- empty channel (old format)
|
||||
target_cylinder/ -- target cylinders (0.75L, 1.0L, 1.5L)
|
||||
illusion/ -- PPO controlled (0.75L, 1.0L, 1.5L)
|
||||
karman/karman_re100/ -- Karman cloak validation
|
||||
karman_target/karman_q_in/ -- vortex street without pinball
|
||||
karman_blocked/karman_q_blk/ -- pinball in vortex street, no control
|
||||
resampled/{scene}/ -- phase_plan.json per scene
|
||||
ccd/ -- all JSON result files (8 files)
|
||||
figures/ -- all PNG figures (80+)
|
||||
old_data/ -- archived stale data
|
||||
docs/
|
||||
ccd_correction_field_report.md -- comprehensive report
|
||||
sr_ccd_oid_mapping.md -- cross-pipeline mapping (DRAFT)
|
||||
pinball/pinball/ # Uncontrolled pinball: sensors.npz, fields.npz
|
||||
steady_cloak/steady_cloak/ # Steady cloak: sensors.npz, fields.npz
|
||||
karman/karman_re100/ # Karman cloak: target.npz, norm.json, controlled.npz
|
||||
illusion/illusion_1L/ # Illusion: target.npz, norm.json, controlled.npz
|
||||
resampled/ # Phase-resampled data (24 pts/cycle)
|
||||
ccd/ # CCD results (ccd_results.json)
|
||||
steady/ # Steady metrics (steady_metrics.json)
|
||||
scripts/
|
||||
collect_karman.py # Karman cloak PPO inference -> data/karman/
|
||||
collect_illusion.py # Illusion PPO inference -> data/illusion/
|
||||
collect_pinball.py # Pinball baseline -> data/pinball/
|
||||
collect_steady_cloak.py # Steady cloak open-loop -> data/steady_cloak/
|
||||
resample.py # Phase resampling for periodic cases
|
||||
ccd/
|
||||
run_ccd.py # POD + force/action CCD computation
|
||||
steady/
|
||||
run_steady.py # Steady cloak metrics
|
||||
```
|
||||
|
||||
---
|
||||
## Key Design Decisions
|
||||
|
||||
## How to Regenerate Data
|
||||
### 1. Scene Metadata Driven
|
||||
|
||||
All commands from repo root. Environment: `conda run -n pycuda_3_10`.
|
||||
All scene parameters are defined once in `configs.py`, not hard-coded in
|
||||
scripts. Each scene dict contains geometry, DRL parameters, and inference
|
||||
settings. Adding a new scene means adding one dict.
|
||||
|
||||
### GPU Data Collection
|
||||
### 2. Verified CFD Interface
|
||||
|
||||
`utils/cfd_interface.py` is adapted from `SR_analysis/utils/cfd_interface.py`
|
||||
(which was itself verified against `analysis_crossre`). It contains the
|
||||
environment-building functions that exactly replicate the legacy training
|
||||
environments:
|
||||
|
||||
- `build_karman_cloak_env()` -- mirrors `legacy_env_karman_cloak_standard.py`
|
||||
- `add_pinball()` -- norm computation + bias-action FIFO, configurable for
|
||||
Karman (7 objects) and Illusion (6 objects) layouts
|
||||
- `build_observation()`, `scale_action()` -- DRL obs/action helpers
|
||||
- `compute_similarity()` -- lag-compensated DTW for reward validation
|
||||
|
||||
### 3. Data / Analysis Separation
|
||||
|
||||
- `data/` -- raw sensor/force/action/field arrays (.npz), one-time generation
|
||||
- `ccd/`, `steady/` -- analysis results, regeneratable from `data/`
|
||||
- `scripts/` -- inference pipelines that produce `data/`
|
||||
|
||||
### 4. Two-Pass Collection (PPO cases)
|
||||
|
||||
For DRL cases (karman, illusion), data collection uses a two-pass strategy:
|
||||
|
||||
1. **Closed-loop pass**: Run PPO inference, record `controlled.npz` with
|
||||
actions/sensors/forces/rewards, validate similarity against target
|
||||
2. **Open-loop replay** (optional): Reset to checkpoint, replay saved actions
|
||||
without PPO, collect dense field snapshots for CCD
|
||||
|
||||
This decouples field sampling from PPO state management, ensuring the DRL
|
||||
observation pipeline is not disturbed by field I/O.
|
||||
|
||||
### 5. Validation Gate
|
||||
|
||||
Each PPO case computes a similarity score (lag-compensated DTW between
|
||||
controlled sensor signals and target reference). Only passing cases
|
||||
(similarity >= 0.80 for Karman, >= 0.70 for Illusion) should proceed to CCD.
|
||||
|
||||
## Verified Data Quality
|
||||
|
||||
| Scene | Similarity | Notes |
|
||||
|-------|-----------|-------|
|
||||
| karman_re100 | 0.950 | Verified against analysis_crossre reference |
|
||||
| illusion_1L | ~0.84 | Below thesis 0.975; under investigation |
|
||||
| steady_cloak | N/A (steady) | Sensor std=0.000344, no residual shedding |
|
||||
| pinball | N/A (baseline) | St=0.1125 at Re=100 (code) |
|
||||
|
||||
## Regeneration Commands
|
||||
|
||||
All commands run from repo root (`/home/frank14f/DynamisLab`).
|
||||
|
||||
### Data Generation (requires GPU, pycuda_3_10 env)
|
||||
|
||||
```bash
|
||||
# Target cylinders
|
||||
python src/CCD_analysis/scripts/collect_target_cylinder.py --diameter 0.75 --device 2
|
||||
python src/CCD_analysis/scripts/collect_target_cylinder.py --diameter 1.0 --device 2
|
||||
python src/CCD_analysis/scripts/collect_target_cylinder.py --diameter 1.5 --device 2
|
||||
# Pinball baseline (uncontrolled, 6 objects)
|
||||
conda run -n pycuda_3_10 python src/CCD_analysis/scripts/collect_pinball.py --device 2
|
||||
|
||||
# Illusion PPO inference (500 steps)
|
||||
python src/CCD_analysis/scripts/collect_illusion.py --scene illusion_0.75L --device 2 --steps 500
|
||||
python src/CCD_analysis/scripts/collect_illusion.py --scene illusion_1.0L --device 2 --steps 500
|
||||
python src/CCD_analysis/scripts/collect_illusion.py --scene illusion_1.5L --device 2 --steps 500
|
||||
# Steady cloak (open-loop constant rotation)
|
||||
conda run -n pycuda_3_10 python src/CCD_analysis/scripts/collect_steady_cloak.py --device 2
|
||||
|
||||
# Baselines
|
||||
python src/CCD_analysis/scripts/collect_pinball.py --device 2
|
||||
python src/CCD_analysis/scripts/collect_steady_cloak.py --device 2
|
||||
python src/CCD_analysis/scripts/collect_empty_channel.py --device 2
|
||||
# Karman cloak re100 (PPO, 7 objects)
|
||||
conda run -n pycuda_3_10 python src/CCD_analysis/scripts/collect_karman.py --device 2 --steps 200
|
||||
|
||||
# Karman references (collected during Round 6)
|
||||
python src/CCD_analysis/scripts/detect_period.py --scene karman_q_in
|
||||
python src/CCD_analysis/scripts/detect_period.py --scene karman_q_blk
|
||||
python src/CCD_analysis/scripts/replay_fields.py --scene karman_q_in --device 2
|
||||
python src/CCD_analysis/scripts/replay_fields.py --scene karman_q_blk --device 2
|
||||
# 1L Illusion (PPO, 2U=0.02)
|
||||
conda run -n pycuda_3_10 python src/CCD_analysis/scripts/collect_illusion.py --device 2 --steps 200
|
||||
```
|
||||
|
||||
### Phase Alignment
|
||||
|
||||
Run `detect_period.py` for each periodic scene, then `replay_fields.py` (GPU) to generate fields_aligned.npz.
|
||||
|
||||
### CPU Analysis (no GPU needed)
|
||||
### Resampling (no GPU needed)
|
||||
|
||||
```bash
|
||||
# Correction-field CCD pipeline
|
||||
python correction_analysis/decompose_corrections.py # force/action on dq_ctl
|
||||
python correction_analysis/run_signature_line.py # signature line
|
||||
python correction_analysis/run_15L_correction.py # 1.5L special
|
||||
python correction_analysis/run_zone_ccd.py # zone-restricted
|
||||
python correction_analysis/run_steady_metrics.py # steady cloak
|
||||
python correction_analysis/diagnose_corrections.py # figures + zone metrics
|
||||
|
||||
# Round 5 raw-field (FROZEN — not recommended for new analysis)
|
||||
python ccd/run_ccd.py
|
||||
python ccd/validate.py
|
||||
python scripts/visualize_ccd.py
|
||||
python3 src/CCD_analysis/scripts/resample.py
|
||||
```
|
||||
|
||||
---
|
||||
### CCD Analysis (no GPU needed)
|
||||
|
||||
## Key Result Files
|
||||
```bash
|
||||
python3 src/CCD_analysis/ccd/run_ccd.py
|
||||
|
||||
Write all results to `src/CCD_analysis/data/ccd/`:
|
||||
# Steady metrics
|
||||
python3 src/CCD_analysis/steady/run_steady.py
|
||||
```
|
||||
|
||||
| File | Contents | Source script |
|
||||
|------|----------|--------------|
|
||||
| `ccd_results.json` | Raw-field CCD (90 entries) | `ccd/run_ccd.py` |
|
||||
| `validation_results.json` | Raw-field LOCO | `ccd/validate.py` |
|
||||
| `correction_ccd_results.json` | Correction-field force/action (30 entries) | `decompose_corrections.py` |
|
||||
| `correction_validation_results.json` | Correction-field LOCO | `decompose_corrections.py` |
|
||||
| `signature_ccd_results.json` | Signature line (47 entries) | `run_signature_line.py` |
|
||||
| `15L_correction_results.json` | 1.5L special (40 entries) | `run_15L_correction.py` |
|
||||
| `zone_ccd_results.json` | Zone-restricted CCD (30 entries) | `run_zone_ccd.py` |
|
||||
| `zone_metrics.json` | Per-zone KE/enstrophy | `diagnose_corrections.py` |
|
||||
| `steady_metrics.json` | Steady cloak metrics | `run_steady_metrics.py` |
|
||||
## Pipeline Workflow
|
||||
|
||||
---
|
||||
```
|
||||
┌─────────────────────┐
|
||||
│ configs.py │
|
||||
│ (scene metadata) │
|
||||
└────────┬────────────┘
|
||||
│
|
||||
┌──────────────┼──────────────┐
|
||||
▼ ▼ ▼
|
||||
┌─────────────────┐ ┌──────────┐ ┌──────────┐
|
||||
│ collect_pinball │ │collect_ │ │collect_ │
|
||||
│ collect_steady │ │karman.py │ │illusion │
|
||||
│ _(open-loop) │ │(PPO) │ │ .py(PPO) │
|
||||
└────────┬────────┘ └────┬─────┘ └────┬─────┘
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌──────────────────────────────────────────┐
|
||||
│ data/{scene_id}/{scene_name}/ │
|
||||
│ sensors.npz, forces.npz, fields.npz │
|
||||
│ controlled.npz, target.npz, norm.json │
|
||||
└──────────────────┬───────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌────────────────┐
|
||||
│ scripts/ │
|
||||
│ resample.py │
|
||||
│ (24 pts/cycle) │
|
||||
└───────┬────────┘
|
||||
│
|
||||
▼
|
||||
┌────────────────┐
|
||||
│ data/resampled/│
|
||||
└───────┬────────┘
|
||||
│
|
||||
┌────────────┴────────────┐
|
||||
▼ ▼
|
||||
┌──────────────┐ ┌──────────────┐
|
||||
│ ccd/run_ccd │ │ steady/ │
|
||||
│ POD + CCD │ │ run_steady │
|
||||
└──────┬───────┘ └──────┬───────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌──────────────┐ ┌──────────────┐
|
||||
│ data/ccd/ │ │ data/steady/ │
|
||||
│ ccd_results │ │ steady_ │
|
||||
│ .json │ │ metrics.json │
|
||||
└──────────────┘ └──────────────┘
|
||||
```
|
||||
|
||||
## Known Pitfalls (New Agent Must Read)
|
||||
## Known Issues and Caveats
|
||||
|
||||
1. **Model naming convention**: `_2U` means S_DIM=14 (2 extra target force channels). NOT 2x velocity. u0 is ALWAYS 0.01. Round 1-3 were invalidated by this misinterpretation.
|
||||
1. **Illusion similarity below thesis** -- The 1L illusion achieves ~0.84
|
||||
similarity vs the thesis value of 0.975. The vorticity field shows partial
|
||||
but not perfect wake matching. Possible causes: harmonics-based target
|
||||
reconstruction may differ subtly from training, or the PPO needs longer
|
||||
warm-up. Data is still useful for CCD as a "partial illusion" reference.
|
||||
|
||||
2. **action_bias vs preset_action**: action_bias=[0,-2,2] is for DRL action scaling. preset_action=[0,0,0,0,-1*U0,1*U0] is FIFO warmup. They are DIFFERENT.
|
||||
2. **Karman cloak uses exactly the reference code** -- `utils/cfd_interface.py`
|
||||
is adapted from `SR_analysis/utils/cfd_interface.py`, which was verified
|
||||
against `analysis_crossre/scripts/phase1_infer.py`. The similarity of 0.95
|
||||
matches the reference.
|
||||
|
||||
3. **DO NOT use `nu_from_re()`** for illusion models. Only valid for standard u0=0.01, S_DIM=12 cases.
|
||||
3. **No empty channel reference for steady metrics** -- The steady cloak
|
||||
analysis currently lacks a clean parabolic channel reference flow. This
|
||||
affects the E_mean calculation. Generate via a separate FlowField with
|
||||
no bodies and a dummy sensor.
|
||||
|
||||
4. **Main analysis object is `dq_ctl = q_ctl - q_blk`**, not raw `q_ctl`. Use `correction_analysis/compute_correction_fields.py`.
|
||||
4. **CCD results are preliminary** -- Once data collection is validated, the
|
||||
`ccd/run_ccd.py` script computes POD and CCD. Results should be
|
||||
cross-checked with visual field inspection before drawing conclusions.
|
||||
|
||||
5. **Karman q_in/q_blk vs q_re100**: karman_re100 is 72 frames (3 cycles, 18pts/cycle due to sampling). karman_q_in and karman_q_blk are 96 frames. When computing correction fields, N-mismatch is auto-trimmed.
|
||||
5. **Resampled field quality depends on source data** -- The phase resampling
|
||||
step uses linear interpolation. If the original field sampling rate is too
|
||||
low (< 12 pts/cycle), resampled fields will have interpolation artifacts.
|
||||
Currently all cases use raw sampling that gives ~18-25 pts/cycle.
|
||||
|
||||
6. **GPU state contamination**: Running PPO inference after other CFD on same GPU degrades similarity. Use a fresh GPU.
|
||||
## File Reference
|
||||
|
||||
7. **Steady cloak has no forces**: `sensors.npz` only has sensor channels, no force data. Drag proxy uses momentum deficit.
|
||||
|
||||
8. **force_fx is unreliable**: R2 ~0.4, high variance. Only use for O_k trends, not mechanism claims.
|
||||
|
||||
9. **POD-reduced CCD limitation**: Results are constrained to the target-only POD subspace. Truncated structures cannot be recovered. Full-field CCD requires more data.
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| configs.py | Unified scene metadata (4 cases) |
|
||||
| utils/cfd_interface.py | LegacyCelerisLab wrapper, env builders, DTW |
|
||||
| utils/resampling.py | Period detection, phase resampling, POD, CCD |
|
||||
| utils/__init__.py | Non-pycuda exports |
|
||||
| scripts/collect_karman.py | Karman cloak PPO inference |
|
||||
| scripts/collect_illusion.py | Illusion PPO inference |
|
||||
| scripts/collect_pinball.py | Pinball baseline |
|
||||
| scripts/collect_steady_cloak.py | Steady cloak open-loop |
|
||||
| scripts/resample.py | Phase resampling pipeline |
|
||||
| ccd/run_ccd.py | POD + CCD computation |
|
||||
| steady/run_steady.py | Steady cloak metrics |
|
||||
|
||||
@ -1,344 +1,180 @@
|
||||
"""CCD analysis pipeline: POD + force/action CCD.
|
||||
|
||||
New data format (fields_aligned.npz + phase_plan.json).
|
||||
Target-only POD basis. Per-force observable (primary=SigmaFy).
|
||||
Short Q_delay=6 for force/action. 1.5L flagged as special_mechanism.
|
||||
"""CCD analysis pipeline: POD + force/action/signature CCD.
|
||||
|
||||
Usage:
|
||||
conda run -n pycuda_3_10 python ccd/run_ccd.py
|
||||
python ccd/run_ccd.py
|
||||
|
||||
Requires fields_aligned.npz and phase_plan.json in data/ directories.
|
||||
Requires resampled data from scripts/resample.py.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
|
||||
_SRC = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
if _SRC not in sys.path:
|
||||
sys.path.insert(0, _SRC)
|
||||
_ANALYSIS = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
if _ANALYSIS not in sys.path:
|
||||
sys.path.insert(0, _ANALYSIS)
|
||||
|
||||
from CCD_analysis.configs import DATA_DIR, SCENES, NX, NY
|
||||
from CCD_analysis.configs import DATA_DIR
|
||||
from CCD_analysis.utils.resampling import (
|
||||
compute_pod, cumulative_energy, e95_index,
|
||||
compute_reduced_ccd,
|
||||
load_aligned_fields, make_force_obs,
|
||||
build_field_matrix, project_into_basis,
|
||||
compute_reduced_ccd, stack_velocity_fields,
|
||||
)
|
||||
|
||||
# -- Protocol constants ---------------------------------------------------
|
||||
R_CANDIDATES = [6, 8, 10]
|
||||
CCD_Q = 6 # short, near-synchronous window for force/action
|
||||
DIAMETERS_MAIN = [0.75, 1.0]
|
||||
DIAMETERS_ALL = [0.75, 1.0, 1.5]
|
||||
CV_T_RELAXED = 0.12
|
||||
CCD_Q = 12
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Preflight check (built-in)
|
||||
# ---------------------------------------------------------------------------
|
||||
def load_resampled(name: str):
|
||||
p = os.path.join(DATA_DIR, "resampled", name, "resampled.npz")
|
||||
if not os.path.isfile(p):
|
||||
return None
|
||||
return np.load(p)
|
||||
|
||||
def preflight(scene_name: str) -> dict:
|
||||
"""Load and verify one scene's data. Returns meta or raises."""
|
||||
cfg = SCENES[scene_name]
|
||||
scene_id = cfg["scene_id"]
|
||||
data_dir = os.path.join(DATA_DIR, scene_id, scene_name)
|
||||
|
||||
# Check fields_aligned.npz
|
||||
fa_path = os.path.join(data_dir, "fields_aligned.npz")
|
||||
if not os.path.isfile(fa_path):
|
||||
raise FileNotFoundError(f"{fa_path} not found")
|
||||
|
||||
fd = np.load(fa_path)
|
||||
ux = fd["ux"]
|
||||
print(f" {scene_name}: fields_aligned ux shape={ux.shape} "
|
||||
f"(expect ({cfg.get('n_cycles', 4) * cfg.get('n_pts', 24)}, {NX}, {NY}))",
|
||||
flush=True)
|
||||
fd.close()
|
||||
|
||||
# Check phase_plan.json
|
||||
plan_path = os.path.join(DATA_DIR, "resampled", scene_name, "phase_plan.json")
|
||||
if not os.path.isfile(plan_path):
|
||||
raise FileNotFoundError(f"{plan_path} not found")
|
||||
|
||||
import json
|
||||
with open(plan_path) as f:
|
||||
plan = json.load(f)
|
||||
|
||||
n_total = plan["n_cycles"] * plan["n_pts"]
|
||||
if n_total != ux.shape[0]:
|
||||
print(f" WARNING: phase_plan has {n_total} snapshots but fields has {ux.shape[0]}",
|
||||
flush=True)
|
||||
|
||||
gate = plan["gate"]
|
||||
cv_t = plan["CV_T"]
|
||||
print(f" gate={gate}, CV_T={cv_t:.4f}, "
|
||||
f"N_raw={plan['N_raw_per_cycle']:.1f}, rho={plan['rho_interp']:.2f}",
|
||||
flush=True)
|
||||
|
||||
if gate not in ("strict", "relaxed") and cv_t is not None and cv_t > CV_T_RELAXED:
|
||||
print(f" WARNING: gate='{gate}' — does not pass relaxed gate (CV_T <= {CV_T_RELAXED})",
|
||||
flush=True)
|
||||
|
||||
# Check telemetry
|
||||
tele_found = False
|
||||
for p in [os.path.join(data_dir, "controlled.npz"), os.path.join(data_dir, "sensors.npz")]:
|
||||
if os.path.isfile(p):
|
||||
td = np.load(p)
|
||||
if "forces" in td:
|
||||
print(f" forces: {td['forces'].shape}", flush=True)
|
||||
if "actions" in td:
|
||||
print(f" actions: {td['actions'].shape}", flush=True)
|
||||
td.close()
|
||||
tele_found = True
|
||||
break
|
||||
if not tele_found:
|
||||
raise FileNotFoundError(f"No telemetry found in {data_dir}")
|
||||
|
||||
return {
|
||||
"gate": gate,
|
||||
"CV_T": cv_t,
|
||||
"n_snapshots": ux.shape[0],
|
||||
"N_raw_per_cycle": plan.get("N_raw_per_cycle"),
|
||||
}
|
||||
|
||||
|
||||
def compute_modal_overlap(W_dict: dict, diam: float, r: int,
|
||||
obs_label: str = "force") -> list:
|
||||
"""Compute pairwise modal overlaps for a given diameter and r."""
|
||||
keys = [k for k in W_dict
|
||||
if f"{diam}L_" in k and f"_{obs_label}_r{r}" in k]
|
||||
overlaps = []
|
||||
for i, ka in enumerate(keys):
|
||||
for kb in keys[i + 1:]:
|
||||
Wa, Wb = W_dict[ka], W_dict[kb]
|
||||
n = min(Wa.shape[1], Wb.shape[1], 5)
|
||||
for k in range(n):
|
||||
ov = float(abs(
|
||||
Wa[:, k] / (np.linalg.norm(Wa[:, k]) + 1e-12) @
|
||||
Wb[:, k] / (np.linalg.norm(Wb[:, k]) + 1e-12)
|
||||
))
|
||||
overlaps.append({
|
||||
"case_a": ka.split(f"_{obs_label}_r{r}")[0],
|
||||
"case_b": kb.split(f"_{obs_label}_r{r}")[0],
|
||||
"mode": k + 1,
|
||||
"O": ov,
|
||||
})
|
||||
return overlaps
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main pipeline
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
print("=" * 60, flush=True)
|
||||
print("CCD Pipeline (Round 5 — fields_aligned, target-only basis)", flush=True)
|
||||
print("=" * 60, flush=True)
|
||||
print("=== CCD Pipeline ===\n")
|
||||
|
||||
# Identify which cases have resampled data
|
||||
resampled_dir = os.path.join(DATA_DIR, "resampled")
|
||||
if not os.path.isdir(resampled_dir):
|
||||
print("ERROR: run scripts/resample.py first")
|
||||
return 1
|
||||
|
||||
cases = sorted(os.listdir(resampled_dir))
|
||||
print(f"Resampled cases: {cases}")
|
||||
|
||||
# --- POD ---
|
||||
print("\n--- POD ---")
|
||||
snapshots = []
|
||||
case_ranges = {}
|
||||
idx = 0
|
||||
|
||||
for name in cases:
|
||||
d = load_resampled(name)
|
||||
if d is None:
|
||||
continue
|
||||
ux, uy = d.get("ux"), d.get("uy")
|
||||
if ux is None:
|
||||
print(f" {name}: no field data, skip POD")
|
||||
continue
|
||||
n_cyc, n_pt = ux.shape[0], ux.shape[1]
|
||||
for c in range(n_cyc):
|
||||
for p in range(n_pt):
|
||||
q = np.concatenate([ux[c, p].ravel(), uy[c, p].ravel()])
|
||||
snapshots.append(q)
|
||||
case_ranges[name] = (idx, idx + n_cyc * n_pt)
|
||||
idx += n_cyc * n_pt
|
||||
print(f" {name}: {n_cyc}x{n_pt} snapshots")
|
||||
|
||||
if not snapshots:
|
||||
print("No field data for POD")
|
||||
return 1
|
||||
|
||||
Q = np.column_stack(snapshots)
|
||||
mean_field, modes, s, coeffs = compute_pod(Q)
|
||||
energy = cumulative_energy(s)
|
||||
e95 = e95_index(energy)
|
||||
print(f" POD: {len(s)} modes, E95={e95}")
|
||||
for i in range(min(6, len(s))):
|
||||
print(f" mode {i+1}: energy={energy[i]:.4f}")
|
||||
|
||||
# --- CCD for each case ---
|
||||
print("\n--- CCD ---")
|
||||
all_results = {}
|
||||
W_dict = {}
|
||||
|
||||
for r in R_CANDIDATES:
|
||||
print(f"\n POD truncation r={r}")
|
||||
for name in cases:
|
||||
d = load_resampled(name)
|
||||
if d is None:
|
||||
continue
|
||||
|
||||
# POD coefficients for this case
|
||||
if name in case_ranges:
|
||||
start, end = case_ranges[name]
|
||||
a_r = coeffs[:r, start:end]
|
||||
else:
|
||||
# Projection case (not in POD basis)
|
||||
ux, uy = d.get("ux"), d.get("uy")
|
||||
if ux is None:
|
||||
continue
|
||||
proj_snapshots = []
|
||||
for c in range(ux.shape[0]):
|
||||
for p in range(ux.shape[1]):
|
||||
q = np.concatenate([ux[c, p].ravel(), uy[c, p].ravel()])
|
||||
proj_snapshots.append(q)
|
||||
Q_proj = np.column_stack(proj_snapshots)
|
||||
Q_centered = Q_proj - mean_field[:, None]
|
||||
a_r = (modes[:, :r].T @ Q_centered)
|
||||
|
||||
N = a_r.shape[1]
|
||||
if N < 24:
|
||||
print(f" {name}: too few samples ({N})")
|
||||
continue
|
||||
|
||||
# Force CCD
|
||||
forces = d.get("forces")
|
||||
if forces is not None:
|
||||
f = forces.reshape(-1, forces.shape[-1])
|
||||
Fx = f[:, 0] + f[:, 2] + f[:, 4]
|
||||
Fy = f[:, 1] + f[:, 3] + f[:, 5]
|
||||
y_force = np.vstack([Fx, Fy])
|
||||
|
||||
if y_force.shape[1] >= N:
|
||||
y_f = y_force[:, :N]
|
||||
else:
|
||||
y_f = y_force
|
||||
|
||||
W, sigma, z = compute_reduced_ccd(a_r[:, :y_f.shape[1]], y_f, Q_delay=CCD_Q)
|
||||
ccd_ene = cumulative_energy(sigma)
|
||||
m80 = int(np.searchsorted(ccd_ene, 0.80) + 1) if len(ccd_ene) > 0 else 0
|
||||
key = f"{name}_force_r{r}"
|
||||
W_dict[key] = W
|
||||
all_results[key] = {"case": name, "observable": "force", "r": r,
|
||||
"m80": m80, "sigma_top3": [float(sigma[i]) for i in range(min(3, len(sigma)))]}
|
||||
print(f" {key}: m80={m80}")
|
||||
|
||||
# Action CCD (for controlled cases)
|
||||
actions = d.get("actions")
|
||||
if actions is not None:
|
||||
y_act = actions.reshape(-1, actions.shape[-1]).T
|
||||
if y_act.shape[1] >= N:
|
||||
y_a = y_act[:, :N]
|
||||
else:
|
||||
y_a = y_act
|
||||
W, sigma, z = compute_reduced_ccd(a_r[:, :y_a.shape[1]], y_a, Q_delay=CCD_Q)
|
||||
ccd_ene = cumulative_energy(sigma)
|
||||
m80 = int(np.searchsorted(ccd_ene, 0.80) + 1) if len(ccd_ene) > 0 else 0
|
||||
key = f"{name}_action_r{r}"
|
||||
W_dict[key] = W
|
||||
all_results[key] = {"case": name, "observable": "action", "r": r,
|
||||
"m80": m80, "sigma_top3": [float(sigma[i]) for i in range(min(3, len(sigma)))]}
|
||||
print(f" {key}: m80={m80}")
|
||||
|
||||
# --- Modal overlap ---
|
||||
print("\n--- Modal Overlap ---")
|
||||
force_keys = [k for k in W_dict if "force" in k]
|
||||
for i, ka in enumerate(force_keys):
|
||||
for kb in force_keys[i+1:]:
|
||||
Wa, Wb = W_dict[ka], W_dict[kb]
|
||||
n = min(Wa.shape[1], Wb.shape[1], 5)
|
||||
ov = []
|
||||
for k in range(n):
|
||||
ak = Wa[:, k] / (np.linalg.norm(Wa[:, k]) + 1e-12)
|
||||
bk = Wb[:, k] / (np.linalg.norm(Wb[:, k]) + 1e-12)
|
||||
ov.append(float(abs(ak @ bk)))
|
||||
print(f" O({ka}, {kb}): O1={ov[0]:.4f}, O2={ov[1]:.4f}")
|
||||
|
||||
# Save
|
||||
out_dir = os.path.join(DATA_DIR, "ccd")
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
all_results = {}
|
||||
W_dict = {} # for modal overlap
|
||||
|
||||
# -- Preflight --
|
||||
print("\n--- Preflight check ---", flush=True)
|
||||
all_scenes = ["pinball"]
|
||||
for diam in DIAMETERS_ALL:
|
||||
all_scenes.append(f"target_cylinder_{diam}L")
|
||||
all_scenes.append(f"illusion_{diam}L")
|
||||
|
||||
preflight_ok = {}
|
||||
for sn in all_scenes:
|
||||
try:
|
||||
meta = preflight(sn)
|
||||
preflight_ok[sn] = meta
|
||||
print(f" OK", flush=True)
|
||||
except (FileNotFoundError, AssertionError, KeyError) as e:
|
||||
print(f" FAILED: {e}", flush=True)
|
||||
preflight_ok[sn] = None
|
||||
|
||||
# -- Load all data --
|
||||
print("\n--- Loading data ---", flush=True)
|
||||
data_cache = {}
|
||||
for sn in all_scenes:
|
||||
if preflight_ok.get(sn) is None:
|
||||
continue
|
||||
t0 = time.time()
|
||||
try:
|
||||
d = load_aligned_fields(sn)
|
||||
data_cache[sn] = d
|
||||
print(f" {sn}: loaded ({len(d['ux'])} snapshots, "
|
||||
f"{time.time() - t0:.1f}s)", flush=True)
|
||||
except (FileNotFoundError, AssertionError, KeyError) as e:
|
||||
print(f" {sn}: FAILED — {e}", flush=True)
|
||||
|
||||
# -- Per-diameter CCD --
|
||||
print("\n--- CCD per diameter ---", flush=True)
|
||||
|
||||
for diam in DIAMETERS_ALL:
|
||||
tgt_name = f"target_cylinder_{diam}L"
|
||||
ill_name = f"illusion_{diam}L"
|
||||
|
||||
tgt_data = data_cache.get(tgt_name)
|
||||
ill_data = data_cache.get(ill_name)
|
||||
pin_data = data_cache.get("pinball")
|
||||
|
||||
if tgt_data is None:
|
||||
print(f"\n SKIP {diam}L: missing target data", flush=True)
|
||||
continue
|
||||
|
||||
print(f"\n{'=' * 60}", flush=True)
|
||||
print(f"Diameter {diam}L", flush=True)
|
||||
print(f"{'=' * 60}", flush=True)
|
||||
|
||||
is_special = (diam not in DIAMETERS_MAIN)
|
||||
if is_special:
|
||||
print(f" Note: {diam}L flagged as special-mechanism case", flush=True)
|
||||
|
||||
# -- Build target-only POD basis --
|
||||
Q_tgt = build_field_matrix(tgt_data["ux"], tgt_data["uy"])
|
||||
mean_f, modes, sv, coeffs = compute_pod(Q_tgt)
|
||||
energy = cumulative_energy(sv)
|
||||
e95 = e95_index(energy)
|
||||
print(f" Target-only POD: E95={e95}", flush=True)
|
||||
for i in range(min(8, len(sv))):
|
||||
print(f" mode {i + 1}: energy={energy[i]:.4f}", flush=True)
|
||||
|
||||
# -- Project illusion and pinball into target basis --
|
||||
proj_cache = {tgt_name: coeffs} # already in target basis
|
||||
|
||||
if ill_data is not None:
|
||||
proj_cache[ill_name] = project_into_basis(
|
||||
ill_data["ux"], ill_data["uy"], modes, mean_f)
|
||||
|
||||
if pin_data is not None:
|
||||
proj_cache["pinball"] = project_into_basis(
|
||||
pin_data["ux"], pin_data["uy"], modes, mean_f)
|
||||
|
||||
# -- CCD for each r and each case --
|
||||
for r in R_CANDIDATES:
|
||||
print(f"\n r={r}:", flush=True)
|
||||
modes_r = modes[:, :r]
|
||||
|
||||
for name in [tgt_name, ill_name, "pinball"]:
|
||||
d = data_cache.get(name)
|
||||
if d is None:
|
||||
continue
|
||||
if name not in proj_cache:
|
||||
continue
|
||||
|
||||
a_r = proj_cache[name][:r, :]
|
||||
N = a_r.shape[1]
|
||||
|
||||
# --- Force-CCD (primary: SigmaFy) ---
|
||||
frc = d.get("forces")
|
||||
if frc is not None:
|
||||
for f_mode, f_label in [("fy", "force_fy"),
|
||||
("fx", "force_fx"),
|
||||
("joint", "force_joint")]:
|
||||
y_f = make_force_obs(frc, name, mode=f_mode)
|
||||
y_f = y_f[:, :N]
|
||||
W, sig, Rmat, z, No, Nv = compute_reduced_ccd(
|
||||
a_r[:, :N], y_f, Q_delay=CCD_Q)
|
||||
en = cumulative_energy(sig)
|
||||
m80 = int(np.searchsorted(en, 0.80) + 1) if len(en) > 0 else 0
|
||||
|
||||
key = f"{diam}L_{name}_{f_label}_r{r}"
|
||||
W_dict[key] = W
|
||||
all_results[key] = {
|
||||
"diam": diam, "case": name,
|
||||
"obs": f_label, "r": r,
|
||||
"m80": m80, "N": Nv,
|
||||
"sigma_top3": [float(sig[i])
|
||||
for i in range(min(3, len(sig)))],
|
||||
"special_mechanism": is_special,
|
||||
}
|
||||
if f_mode == "fy":
|
||||
print(f" {key}: m80={m80}, "
|
||||
f"sigma1={float(sig[0]):.4f}", flush=True)
|
||||
|
||||
# --- Action-CCD (illusion only) ---
|
||||
act = d.get("actions")
|
||||
if act is not None:
|
||||
y_a = act.T # (3, N)
|
||||
W, sig, Rmat, z, No, Nv = compute_reduced_ccd(
|
||||
a_r[:, :N], y_a[:, :N], Q_delay=CCD_Q)
|
||||
en = cumulative_energy(sig)
|
||||
m80 = int(np.searchsorted(en, 0.80) + 1) if len(en) > 0 else 0
|
||||
|
||||
key = f"{diam}L_{name}_action_r{r}"
|
||||
W_dict[key] = W
|
||||
all_results[key] = {
|
||||
"diam": diam, "case": name,
|
||||
"obs": "action", "r": r,
|
||||
"m80": m80, "N": Nv,
|
||||
"sigma_top3": [float(sig[i])
|
||||
for i in range(min(3, len(sig)))],
|
||||
"special_mechanism": is_special,
|
||||
}
|
||||
print(f" {key}: m80={m80}, "
|
||||
f"sigma1={float(sig[0]):.4f}", flush=True)
|
||||
|
||||
# -- Modal overlaps (r=6, force_fy primary) --
|
||||
print(f"\n Modal overlap (r=6, force_fy):", flush=True)
|
||||
ov_list = compute_modal_overlap(W_dict, diam, 6, "force_fy")
|
||||
for ov in ov_list:
|
||||
print(f" O({ov['case_a']}, {ov['case_b']}) "
|
||||
f"mode{ov['mode']} = {ov['O']:.4f}", flush=True)
|
||||
|
||||
# -- Reconstruction quality (POD basis check) --
|
||||
# Project target fields back onto its own POD basis and check residual
|
||||
q_rec = modes[:, :r] @ coeffs[:r, :] + mean_f[:, None]
|
||||
res = Q_tgt.astype(np.float64) - q_rec
|
||||
r2 = 1.0 - np.sum(res ** 2) / np.sum(Q_tgt.astype(np.float64) ** 2)
|
||||
print(f" Target self-reconstruction R2 (r={r}): {r2:.4f}", flush=True)
|
||||
|
||||
# -- Cross-diameter comparison (0.75L illusion in 1.0L basis) --
|
||||
print("\n--- Cross-diameter: 0.75L -> 1.0L basis ---", flush=True)
|
||||
d10_cache = data_cache.get("target_cylinder_1.0L")
|
||||
d075_i = data_cache.get("illusion_0.75L")
|
||||
if d10_cache is not None and d075_i is not None:
|
||||
Q_10 = build_field_matrix(d10_cache["ux"], d10_cache["uy"])
|
||||
mf_10 = np.mean(Q_10, axis=1)
|
||||
U_10, _, _ = np.linalg.svd(Q_10 - mf_10[:, None], full_matrices=False)
|
||||
modes_10_6 = U_10[:, :6]
|
||||
|
||||
# Project 0.75L illusion
|
||||
a_075 = project_into_basis(d075_i["ux"], d075_i["uy"],
|
||||
modes_10_6, mf_10)[:6, :]
|
||||
frc_075 = d075_i.get("forces")
|
||||
if frc_075 is not None:
|
||||
y_f = make_force_obs(frc_075, "illusion_0.75L", mode="fy")
|
||||
W_cross, _, _, _, _, _ = compute_reduced_ccd(a_075, y_f, Q_delay=CCD_Q)
|
||||
|
||||
# Compare with 1.0L illusion in its own basis
|
||||
d10_i = data_cache.get("illusion_1.0L")
|
||||
if d10_i is not None:
|
||||
a_10 = project_into_basis(d10_i["ux"], d10_i["uy"],
|
||||
modes_10_6, mf_10)[:6, :]
|
||||
frc_10 = d10_i.get("forces")
|
||||
if frc_10 is not None:
|
||||
y_f10 = make_force_obs(frc_10, "illusion_1.0L", mode="fy")
|
||||
W_10, _, _, _, _, _ = compute_reduced_ccd(a_10, y_f10, Q_delay=CCD_Q)
|
||||
n = min(W_cross.shape[1], W_10.shape[1], 5)
|
||||
for k in range(n):
|
||||
ov = float(abs(
|
||||
W_cross[:, k] / (np.linalg.norm(W_cross[:, k]) + 1e-12) @
|
||||
W_10[:, k] / (np.linalg.norm(W_10[:, k]) + 1e-12)
|
||||
))
|
||||
print(f" Cross-diam O(0.75L->1.0L) mode{k + 1} = {ov:.4f}",
|
||||
flush=True)
|
||||
|
||||
# -- Save --
|
||||
with open(os.path.join(out_dir, "ccd_results.json"), "w") as f:
|
||||
json.dump(all_results, f, indent=2)
|
||||
print(f"\nSaved to {out_dir}/ccd_results.json", flush=True)
|
||||
print(f"Total entries: {len(all_results)}", flush=True)
|
||||
print(f"\nSaved to {out_dir}/ccd_results.json")
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@ -1,138 +1,184 @@
|
||||
# CCD 分析知识库 — 最终版本 (2026-06-22)
|
||||
# CCD Analysis: Lessons Learned & Knowledge Base
|
||||
|
||||
## 工作阶段总览
|
||||
## 项目全局知识
|
||||
|
||||
| 阶段 | 状态 | 说明 |
|
||||
### Re 数定义
|
||||
- `Re_D` = U0 * D / nu, where D = 20 (single cylinder diameter)
|
||||
- `Re` (code) = U0 * 2D / nu, where 2D = 40
|
||||
- Default Re=100 (code) <-> Re_D=50
|
||||
- Formula: nu = U0 * 2D / Re_code = 0.01 * 40 / 100 = 0.004
|
||||
|
||||
### 网格和物理参数
|
||||
- Grid: 1280 x 512, D2Q9, MRT
|
||||
- L0 = 20 (base length unit)
|
||||
- U0 = 0.01 (inlet center velocity, lattice units)
|
||||
- Inlet: parabolic profile (Zou-He local)
|
||||
- Walls: bounce-back (no-slip)
|
||||
- Outlet: NEQ extrapolation
|
||||
|
||||
### 核心规则:添加顺序决定 obs 布局
|
||||
|
||||
**这是整个项目中最容易被搞错的地方。** Legacy FlowField 的 `obs` 数组的内容完全由对象添加顺序决定,不同脚本可能使用不同的添加顺序。
|
||||
|
||||
**`legacy_env_karman_cloak_standard.py` (7 objects, 训练 env):**
|
||||
添加顺序: dist_cyl(0) -> s0(1) -> s1(2) -> s2(3) -> front(4) -> bottom(5) -> top(6)
|
||||
obs[2:14] = [s0_ux,uy, s1_ux,uy, s2_ux,uy, front_fx,fy, bottom_fx,fy, top_fx,fy]
|
||||
= [sensors(6), forces(6)]
|
||||
用 obs[2:14] 跳过了 dist_cyl 的 2 个值。
|
||||
|
||||
**`legacy_env_imit.py` (6 objects, 训练 env):**
|
||||
添加顺序: s0(0) -> s1(1) -> s2(2) -> front(3) -> bottom(4) -> top(5)
|
||||
obs[0:12] = [s0_ux,uy, s1_ux,uy, s2_ux,uy, front_fx,fy, bottom_fx,fy, top_fx,fy]
|
||||
= [sensors(6), forces(6)]
|
||||
|
||||
**`uni_test.ipynb` (推理脚本, 可能使用不同添加顺序):**
|
||||
- 对于 Karman cloak: restore DDF + add dist_cyl → obs[0:12] 的布局取决于 DDF 保存时的状态
|
||||
- 对于 Illusion: 单独 env, 添加顺序取决于代码
|
||||
|
||||
**关键教训: 每次处理 obs 时必须先查看对应脚本中的添加顺序。**
|
||||
|
||||
### 旧 API (LegacyCelerisLab) 要点
|
||||
- `flow_field.run(N, action_array)` 返回的 `obs` 已经是 N 步每步平均
|
||||
- `action_array` 长度 = 对象数量, sensor 的 action slot 被忽略
|
||||
- `flow_field.run()` 内部有指数平滑 (weight=0.1)
|
||||
- `save_ddf()/restore_ddf()/apply_ddf()` 用于 checkpoint
|
||||
- 需要 `context.push()/pop()` 管理 PyTorch + PyCUDA 上下文冲突
|
||||
|
||||
### Legacy 环境标准初始化流程
|
||||
1. 创建 FlowField
|
||||
2. 添加对象(传感器、圆柱)
|
||||
3. 稳定 4*NX/U0 步
|
||||
4. 录制 target 信号 (FIFO_LEN 步)
|
||||
5. 添加 pinball 圆柱 (延续使用同一个 FlowField)
|
||||
6. 再次稳定, checkpoint
|
||||
7. 零动作跑 FIFO_LEN 步, 计算 norm
|
||||
8. 恢复 checkpoint, 偏置动作跑 FIFO_LEN 步, 保存 FIFO 状态
|
||||
9. reset = 恢复 checkpoint + 恢复 FIFO
|
||||
|
||||
### Cloak (Karman) 环境参数
|
||||
- Model: d1a3o12_re100.zip (7 objects: dist + 3 sensors + 3 pinball)
|
||||
- S_DIM = 12, A_DIM = 3
|
||||
- SAMPLE_INTERVAL = 800, FIFO_LEN = 150, CONV_LEN = 30
|
||||
- Action: temp[4:7] = (action * 8 + [0, -4, 4]) * U0
|
||||
- Norm: force_norm_fact = 6 * max(|temp_states[:, 6:12]|)
|
||||
- Obs: hstack([forces_norm, sens_norm]) → 12-dim
|
||||
- forces_norm = obs_slice[6:12] / force_norm_fact (3 pinball forces)
|
||||
- sens_norm = (obs_slice[0:6] - sens_deviation) / sens_norm_fact (3 sensors)
|
||||
- Reward:
|
||||
- cd = (forces[0] + forces[2] + forces[4]) / 3
|
||||
- cl = (forces[1] + forces[3] + forces[5]) / 3
|
||||
- reward_cd = exp(-|cd * 20|)
|
||||
- reward_cl = exp(-|cl * 80|)
|
||||
- reward_sim = exp(-10 * |similarities - 1|)
|
||||
- reward = min(0.3*reward_cd + 0.4*reward_cl + 0.3*reward_sim, 1.0)
|
||||
|
||||
### Illusion (Imit) 环境参数
|
||||
- Model: d1a3o14_250525_imit_1L_2U_600S.zip (6 objects: 3 sensors + 3 pinball)
|
||||
- S_DIM = 14, A_DIM = 3
|
||||
- SAMPLE_INTERVAL = 600, FIFO_LEN = 150, CONV_LEN = 36
|
||||
- Action: temp[3:6] = (action * 8 + [0, -2, 2]) * U0
|
||||
- U0 = 0.02 (2U), nu = 0.008
|
||||
- Target cylinder: center=(20*L0, CENTER_Y), radius=L0(对1L模型)
|
||||
- 目标传感器: x=30*L0
|
||||
- Pinball: front=(19*L0, CENTER_Y), bottom=(20.3*L0, CENTER_Y+0.75*L0),
|
||||
top=(20.3*L0, CENTER_Y-0.75*L0)
|
||||
- Norm: force_norm_fact = 6 * max(|temp_states[:, 6:12]|)
|
||||
- Obs: hstack([forces_norm, sens_norm, target_cd_norm, target_cl_norm]) → 14-dim
|
||||
- 注意: forces_norm 使用 SUM 不是 mean (cd = f0+f2+f4)
|
||||
- Reward:
|
||||
- cd = forces[0] + forces[2] + forces[4] (SUM)
|
||||
- cl = forces[1] + forces[3] + forces[5] (SUM)
|
||||
- 从 harmonics 重构 target_cd, target_cl
|
||||
- reward_cd = exp(-|(cd - target_cd) * 10|)
|
||||
- reward_cl = exp(-|(cl - target_cl) * 10|)
|
||||
- reward_sim = exp(-10 * |similarities - 1|)
|
||||
- reward = min(0.3*reward_cd + 0.3*reward_cl + 0.4*reward_sim, 1.0)
|
||||
|
||||
### Cloak vs Illusion 关键差异
|
||||
| 方面 | Cloak | Illusion |
|
||||
|------|-------|---------|
|
||||
| S_DIM | 12 | 14 |
|
||||
| 观测 | [forces(6), sens(6)] | [forces(6), sens(6), target_cd, target_cl] |
|
||||
| cd/cl 计算 | forces/3 (mean of 3) | sum of 3 (no /3) |
|
||||
| cd reward | exp(-|cd * 20|) | exp(-|(cd-target_cd) * 10|) |
|
||||
| cl reward | exp(-|cl * 80|) | exp(-|(cl-target_cl) * 10|) |
|
||||
| 权重 | cd=0.3, cl=0.4, sim=0.3 | cd=0.3, cl=0.3, sim=0.4 |
|
||||
| Action bias | [0, -4, 4] | [0, -2, 2] |
|
||||
| SAMPLE_INTERVAL | 800 | 600 |
|
||||
| CONV_LEN | 30 | 36 |
|
||||
| 目标信号 | 传感器时序 (6通道) | 传感器+力 (8通道) + 谐波分解 |
|
||||
| DTW lag | target[:,1] vs fifo[:,1] | target[:,3] vs fifo[:,1] |
|
||||
|
||||
### Norm 计算 (通用)
|
||||
```python
|
||||
temp_states = np.array(fifo) # (FIFO_LEN, 12), each = obs_slice
|
||||
force_norm_fact = 6 * max(|temp_states[:, 6:12]|) # 力的最大波动 * 6
|
||||
sens_deviation[i] = mean(temp_states[:, i]) # 传感器均值
|
||||
sens_norm_fact[i] = 5 * max(|temp_states[:, i] - deviation|) # 传感器波动 * 5
|
||||
```
|
||||
注意: force_norm_fact 和 sens 统计从 obs_slice 的哪一部分取值取决于添加顺序。
|
||||
|
||||
### DTW 相似度计算 (通用模式)
|
||||
1. 从 target_states[CONV_LEN:2*CONV_LEN, lag_channel] 取参考段
|
||||
2. 从 fifo[-CONV_LEN:, lag_channel] 取当前段
|
||||
3. 互相关计算 lag
|
||||
4. 对 6 个传感器通道, 用 lag 补偿后算 DTW: 1 - distance/len
|
||||
5. 结果平均
|
||||
|
||||
### Strouhal 数
|
||||
- 本项目 St=0.267 (抛物线入口 + no-slip 壁面)
|
||||
- 高于经典圆柱的 0.165, 因为 7.8% 阻塞比 + 抛物线入口
|
||||
- 用 St 做无量纲一致性检查: f_expected = St * U0 / D
|
||||
|
||||
### 涡量图
|
||||
- 正确公式: ω_z = dv/dx - du/dy
|
||||
- ux.shape = (NY, NX) = (512, 1280)
|
||||
- 实现: `omega = np.gradient(uy, axis=1) - np.gradient(ux, axis=0)`
|
||||
- axis=0 是 y 方向, axis=1 是 x 方向
|
||||
- grad(uy, axis=1) = duy/dx, grad(ux, axis=0) = dux/dy
|
||||
|
||||
---
|
||||
|
||||
## 经验教训总结
|
||||
|
||||
### 第一大教训: 不验证就做分析 = 无效
|
||||
之前没有验证 PPO 控制质量就直接做 CCD 分析, 导致分析可能基于错误的流场。
|
||||
**必须先验证每个 case 的 reward/similarity 达到论文水平, 再进入分析。**
|
||||
|
||||
### 第二大教训: 添加顺序决定一切
|
||||
obs 布局完全由对象添加顺序决定。不能假设 obs[i] 的含义, 必须检查每个脚本中对象的实际添加顺序。
|
||||
|
||||
### 第三大教训: 环境初始化必须完全复现
|
||||
Legacy 环境在 __init__ 中做了大量工作: target 录制、谐波分析(illusion)、norm 计算、偏置 FIFO、checkpoint。任何一步遗漏都会导致 PPO 推理失败。
|
||||
|
||||
### 第四大教训: uni_test 是最可信的参考
|
||||
uni_test.ipynb 是用户手动验证过的推理脚本, 它的 obs 处理和 norm 逻辑应作为最高优先级参考。
|
||||
|
||||
### 第五大教训: 存储管理与采样率
|
||||
早期存储了 400 帧全场快照 (~2GB), 导致后处理缓慢。应先用高频传感器时序做周期检测, 确定代表周期后再存场。
|
||||
自适应采样率 (使原始采样 ~24 点/周期) 比统一 SAMPLE_INTERVAL 更合理。
|
||||
|
||||
### 工作流建议
|
||||
1. 数据采集: 用 legacy 环境 + norm, 先跑短试(50步)验证 reward/similarity
|
||||
2. 再跑完整 rollout (500步), 保存传感器/力/动作为高频, 场为主动选择的窗口
|
||||
3. 周期检测 + 相位重采样 (纯 CPU)
|
||||
4. POD + CCD (纯 CPU)
|
||||
5. 每一步都输出数值指标, 不要只看图
|
||||
|
||||
---
|
||||
|
||||
## 工具函数状态清单
|
||||
|
||||
| 文件 | 状态 | 说明 |
|
||||
|------|------|------|
|
||||
| Round 1-3 | **废弃** | 错误 u0=0.02, nu=0.008,所有结论无效 |
|
||||
| Round 4 | **废弃** | 仍有 bug(模型选择、采集脚本),已归档至 old_data/ |
|
||||
| **Round 5** | **完成 (frozen baseline)** | 正确数据 + fields_aligned.npz + target-only POD raw-field CCD |
|
||||
| **Round 6** | **完成** | correction-field 框架: 数据层 + 差分诊断 + force/action/signature 三线 + 1.5L + 三区域分层 CCD |
|
||||
|
||||
### 核心结论: 哪个做完了、哪个没做
|
||||
|
||||
| 方向 | 状态 | 说明 |
|
||||
|------|------|------|
|
||||
| **Illusion 0.75L/1.0L correction-field** | **完整** | force/action/signature 三线 CCD、zone-CCD、LOCO 验证 |
|
||||
| **Illusion 1.5L** | **完整** | force/action/signature CCD、phase drift、zone 指标(特殊机制 case) |
|
||||
| **Steady cloak** | **定量化完成** | recirculation/RMS suppression/cancellation ratio,结论: open-loop 无效 |
|
||||
| **Karman cloak** | **数据已齐,分析延后** | q_in / q_blk / q_ctl 三场已采集, phase plan 96 帧对齐 |
|
||||
| **Signature line** | **已完成** (0.75L, 1.0L) | tau=0/geom/corr 扫描 + O(force,sig) + LOCO 验证 |
|
||||
| **Zone-restricted CCD** | **已完成** | near_body/body_wake/sensor_zone 三层分别做 force+signature |
|
||||
| **SR-CCD-OID mapping** | **草稿** | `docs/sr_ccd_oid_mapping.md` 已写,需要根据其他两方向的实际报告校正 |
|
||||
|
||||
---
|
||||
|
||||
## 所有结果汇总
|
||||
|
||||
### Round 5 — raw-field baseline (仅供参考,不再是主分析对象)
|
||||
|
||||
**Protocol**: raw full field `q_ctl`, target-only POD, SigmaFy primary, Q_delay=6, per-case z-score
|
||||
|
||||
| 指标 | 0.75L | 1.0L | 1.5L |
|
||||
|------|-------|------|------|
|
||||
| O(target, illusion) mode1 | 0.673 | **0.919** | 0.621 |
|
||||
| force_fy m80 | 2 | 2 | 2 |
|
||||
| action m80 | 2 | 3 | 3 |
|
||||
| LOCO force_fy R2_m80 | 0.71 +- 0.08 | 0.66 +- 0.03 | — |
|
||||
| LOCO force_fx R2_m80 | 0.17 +- 0.49 | 0.23 +- 0.51 | — |
|
||||
|
||||
完整结果在 `ccd/ccd_results.json`, `ccd/validation_results.json`。
|
||||
|
||||
---
|
||||
|
||||
### Round 6 — correction-field (主分析对象)
|
||||
|
||||
**Protocol**: `dq_ctl = q_ctl - q_blk`, target-only POD basis, SigmaFy primary, Q_delay=6
|
||||
|
||||
| 指标 | 0.75L | 1.0L | 1.5L |
|
||||
|------|-------|------|------|
|
||||
| **O(dqctl, dqtar) mode1** | **0.564** | **0.913** | **0.667** |
|
||||
| dqctl force_fy m80 | 2 | **1** (r=8/10) | 2 |
|
||||
| dqctl action sigma1 | 1.39 | 1.13 | **0.28** |
|
||||
| **O(force, sig) at tau=0** | **0.413** | **0.551** | — |
|
||||
| **O(force, sig) at tau=tau_c** | **0.806** | **0.768** | — |
|
||||
| Phase drift | low | low | **high** |
|
||||
| Body-wake/sensor KE ratio | 0.73 | 1.17 | **2.58** |
|
||||
|
||||
**LOCO 验证 (r=6, R2_m80)**:
|
||||
|
||||
| Observable | 0.75L | 1.0L |
|
||||
|-----------|-------|------|
|
||||
| force_fy | 0.65 +- 0.08 (PASS) | 0.64 +- 0.02 (PASS) |
|
||||
| force_fx | 0.38 +- 0.23 (WARNING) | 0.43 +- 0.11 (WARNING) |
|
||||
| signature tau=0 | 0.50 +- 0.09 (PASS) | 0.49 +- 0.04 (PASS) |
|
||||
| signature tau=tau_c | 0.51 +- 0.09 (PASS) | 0.53 +- 0.03 (PASS) |
|
||||
|
||||
**三区域分层 CCD (zone-restricted, r=6 force_fy)**:
|
||||
|
||||
0.75L:
|
||||
| Zone | O(force,sig) tau=0 | O(force,sig) tau=tau_c |
|
||||
|------|--------------------|------------------------|
|
||||
| near_body | 0.262 | 0.827 |
|
||||
| body_wake | 0.269 | **0.917** |
|
||||
| sensor_zone | **0.010** | 0.722 |
|
||||
|
||||
1.0L:
|
||||
| Zone | O(force,sig) tau=0 | O(force,sig) tau=tau_c |
|
||||
|------|--------------------|------------------------|
|
||||
| near_body | 0.596 | 0.596 |
|
||||
| body_wake | 0.509 | 0.483 |
|
||||
| sensor_zone | 0.594 | **0.730** |
|
||||
|
||||
完整结果在 `ccd/correction_ccd_results.json`, `ccd/signature_ccd_results.json`, `ccd/15L_correction_results.json`, `ccd/zone_ccd_results.json`, `ccd/zone_metrics.json`。
|
||||
|
||||
完整报告: `docs/ccd_correction_field_report.md` (412 行, 含 10 张图的详细解读指南)
|
||||
|
||||
---
|
||||
|
||||
## 关键可保留结论
|
||||
|
||||
1. **Correction-field 优于 raw-field 作为主分析对象**。1.0L 更集中(m80=1 vs 2)、0.75L 假高被扣(0.564 vs 0.673)、LOCO 未崩。
|
||||
|
||||
2. **1.0L 的 force-relevant correction 与 target 高度对齐** (O=0.913, m80=1)。这是目前最干净的结果。
|
||||
|
||||
3. **Force 和 signature 结构在零延迟时分离,在对流延迟后共享**。全域 O=0.41-0.55 升至 0.77-0.81。三区域 CC 显示 0.75L sensor_zone 在 tau=0 时近乎正交(O=0.01)。
|
||||
|
||||
4. **1.5L 是特殊机制 case**,不是失败。O=0.667, action sigma1=0.28(1/4 of others), phase drift 显著, correction 集中在近体区。
|
||||
|
||||
5. **力匹配是统计量匹配,不是波形跟踪**。Cd 均值匹配好但瞬时相关 r~0。force_fx 不适合 waveform-level 断言。
|
||||
|
||||
---
|
||||
|
||||
## 数据状态
|
||||
|
||||
所有 10 个场景均有 96 帧 phase plan。场景与对应 `scene_id`:
|
||||
|
||||
| 场景 | scene_id | fields_aligned? |
|
||||
|------|----------|----------------|
|
||||
| pinball | pinball | YES |
|
||||
| target_cylinder_{0.75,1.0,1.5}L | target_cylinder | YES |
|
||||
| illusion_{0.75,1.0,1.5}L | illusion | YES |
|
||||
| karman_re100 | karman | YES (72帧, 3cycles x 24pts, SI=800 采样密度低) |
|
||||
| karman_q_in | karman_target | YES (96帧) |
|
||||
| karman_q_blk | karman_blocked | YES (96帧) |
|
||||
| steady_cloak | steady_cloak | NO (旧 fields.npz, 500帧) |
|
||||
| target_channel | target_channel | NO (旧 fields.npz, 100帧) |
|
||||
|
||||
---
|
||||
|
||||
## 硬规则(新增和继承)
|
||||
|
||||
- Illusion 只用 o14 模型(S_DIM=14)
|
||||
- `_2U` 表示 S_DIM=14,不是 2x velocity
|
||||
- 每个 illusion 直径必须匹配自己的 target cylinder
|
||||
- `action_bias` 与 FIFO warmup 的 `preset_action` 不能混淆 (bias=[0,-2,2], preset=[0,0,0,0,-1*U0,1*U0])
|
||||
- 默认 u0=0.01, nu=0.004
|
||||
- 主分析对象 = `dq_ctl = q_ctl - q_blk`,不是 `q_ctl`
|
||||
- 不要对 steady cloak 使用 phase-based CCD
|
||||
- Karman 的分析框架与 illusion 不同(distortion compensation vs target generation)
|
||||
|
||||
---
|
||||
|
||||
## 不能写成正式机制结论的话
|
||||
|
||||
- "Illusion 已证明使用完全不同于 target 的物理机制。"
|
||||
- "低 overlap 已足以证明 force 通道与 target 正交。"
|
||||
- "CCD 已经直接识别了壁面涡量生成机制。"
|
||||
- "far wake 模态就是瞬时力的主载体。"
|
||||
| `cfg.py` | 可靠 | 路径和常量, 无 CFD 依赖 |
|
||||
| `utils.py` | 可靠 | CFD 加载/场读取 |
|
||||
| `analysis_utils.py` | 可靠 | 周期检测、POD、CCD、重采样(修正了涡量公式) |
|
||||
| `phase0_standard_freq.py` | 可靠 | 已验证与 Phase 0 一致 |
|
||||
| `phase1_collect.py` | 部分可靠 | Illusion 的 norm 和 PPO 推理已验证, Cloak 的采集已验证 |
|
||||
| `phase2_resample.py` | 可靠 | 周期检测和重采样已验证 |
|
||||
| `phase3_pod.py` | 可靠 | POD 计算已验证 |
|
||||
| `phase4_ccd.py` | 部分可靠 | CCD 算法正确, 但 action-CCD 的 corr 值为 0 需排查 |
|
||||
| `phase5_steady.py` | 有问题 | E_mean_uy 因分母过小爆炸, eta_fluc 因环境不匹配错误 |
|
||||
| `validate_control.py` | 有问题 | 多次迭代仍未能复现论文水平的相似度 |
|
||||
| `compile_results.py` | 可靠 | 无 CFD 依赖, 纯报告脚本 |
|
||||
|
||||
@ -1,72 +1,594 @@
|
||||
# CCD 分析方法与工作规划 — 最终版本
|
||||
## 目标
|
||||
|
||||
## 工作阶段总览
|
||||
当前分析的核心不是再证明控制有效,而是识别 **控制实际作用于哪些流动结构通道**,并把黑箱控制关系推进成可解释的结构关系:
|
||||
|
||||
### Round 5 (raw-field baseline) — 完成
|
||||
\[
|
||||
\text{obs} \rightarrow z \rightarrow \text{act} \rightarrow \text{wake structure} \rightarrow \text{signature}
|
||||
\]
|
||||
|
||||
见 `ccd_knowledge.md` 中的 Round 5 章节。所有 `ccd/` 目录下脚本已冻结不修改。
|
||||
在当前 pinball 问题中,控制器依赖各圆柱受力与下游三点速度观测,目标是在无扰动来流下实现 stealth 与 illusion。由于任务指标本质上围绕下游传感器信号定义,仅按能量排序的 POD 不足以回答“哪些结构真正与控制和感知结果相关”。CCD 按与 observable 的互相关强度排序模态,适合提取与动作、受力或目标 signature 最相关的结构 [Lyu23]。公共 POD 则提供跨 case 可比的统一坐标系 [Den18b, Den20]。
|
||||
|
||||
### Round 6 (correction-field framework) — 全部完成
|
||||
第一版方案采用 **POD-reduced CCD**:先构造参考 POD 基底,再在低维系数空间里做 CCD。这不是逐式复现 [Lyu23],而是受其思想启发的 reduced implementation,目标是提升稳健性、降低样本需求,并保证不同 case 在同一坐标系中比较。
|
||||
|
||||
**产出列表**:
|
||||
## 当前阶段的定位
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `correction_analysis/process_legacy_steady.py` | 加载 steady_cloak/target_channel 旧格式 |
|
||||
| `correction_analysis/compute_correction_fields.py` | q_in/q_blk/q_ctl/q_tar + dq_* 差分场计算 |
|
||||
| `correction_analysis/diagnose_corrections.py` | 29张差分场图 + 三层区域 KE/enstrophy 指标 |
|
||||
| `correction_analysis/decompose_corrections.py` | dq_ctl 上 force/action CCD (0.75L, 1.0L) |
|
||||
| `correction_analysis/run_signature_line.py` | signature line CCD (0.75L, 1.0L, tau扫描) |
|
||||
| `correction_analysis/run_15L_correction.py` | 1.5L force/action/signature CCD |
|
||||
| `correction_analysis/run_steady_metrics.py` | steady cloak 定量化 |
|
||||
| `correction_analysis/run_zone_ccd.py` | 三区域分层 CCD (0.75L, 1.0L) |
|
||||
| `docs/ccd_correction_field_report.md` | 综合报告 (412行, 含10图解读) |
|
||||
| `docs/sr_ccd_oid_mapping.md` | 三线映射草稿 |
|
||||
Round 1 和 Round 2 已经证明了以下几点:
|
||||
|
||||
**数据采集 (GPU)**:
|
||||
- karman_q_in (涡街无pinball) 和 karman_q_blk (涡街+pinball无控制) — 各 96帧 aligned 场
|
||||
- karman_re100 — 72帧 (3 cycles, 每周期18点, rho=1.33)
|
||||
- phase-based reduced CCD 的数据管道可以跑通
|
||||
- 周期检测、相位重采样、POD、CCD 和指标汇总可以稳定实现
|
||||
- 真实 illusion PPO replay 必须完整复现 legacy 环境中的 target 谐波、norm 计算和 FIFO 初始化,否则推理无效
|
||||
- 在简单周期尾迹上,CCD pipeline 是自洽的,但当前结果仍属于 **机制线索**,而不是最终机制结论
|
||||
|
||||
---
|
||||
因此,下一轮工作的目标不是再证明代码能跑,而是补齐对比链条中缺失的关键参照,尤其是:
|
||||
|
||||
## 已完成 vs 未完成
|
||||
1. target case 的 force 数据
|
||||
2. reference POD basis 的稳定性
|
||||
3. force / action / signature 三类 CCD 的跨 case 比较
|
||||
4. cloak 稳态线的完整恢复指标
|
||||
|
||||
### 已完成
|
||||
## 口径与 case 定义
|
||||
|
||||
- Illusion 三直径 correction-field force/action/signature CCD
|
||||
- O(dqctl,dqtar) 跨直径对比
|
||||
- O(force,sig) 模态重叠 (全域 + 三区域)
|
||||
- LOCO 验证 (除 1.5L)
|
||||
- 1.5L special mechanism 分析
|
||||
- Zone-restricted CCD (near_body, body_wake, sensor_zone)
|
||||
- Steady cloak 定量化
|
||||
- Snapshot POD 加速 (`utils/resampling.py: compute_pod`)
|
||||
- Karman 参考场采集
|
||||
### Reynolds 数口径
|
||||
|
||||
### 未完成/延后
|
||||
项目中存在两套 Reynolds 数定义,后续所有脚本、文件名、图注和表格都必须显式区分,不能混写。
|
||||
|
||||
- Karman cloak 周期 correction analysis (数据已齐,框架已设计,分析延后)
|
||||
- 1.5L force vs signature overlap (0.75L/1.0L 已完成,1.5L 有 signature m80 但缺 O 值)
|
||||
- Steady cloak 需要 closed-loop DRL 数据才能做有意义分析
|
||||
- OID/PCD/whitening 等更高级版本
|
||||
| 记号 | 特征长度 | 含义 |
|
||||
|---|---|---|
|
||||
| \(Re\) | 两倍 pinball 单圆柱直径 | 与目标 2D 圆柱比较时常用的口径 |
|
||||
| \(Re_D\) | pinball 单个圆柱直径 | pinball 本体口径 |
|
||||
|
||||
---
|
||||
本实施说明中的当前工作范围固定为:
|
||||
|
||||
## 当前建议的继续方向
|
||||
- **无扰动来流**
|
||||
- **固定 \(Re=100\)**
|
||||
- 若需换算到 \(Re_D\),必须在元数据中单独写明
|
||||
|
||||
1. **Karman cloak 分析** — 已有 `q_in/q_blk/q_ctl` 三场和 `correction_analysis/compute_correction_fields.py` 支持,可平移 illusion 的 force/action/signature 框架。注意 Karman 的物理问题不同 (incoming-street preservation vs target generation)。
|
||||
### 当前处理的 case
|
||||
|
||||
2. **1.5L force-sig overlap** — 补齐后可与 0.75L/1.0L 比较。已在 `correction_analysis/run_15L_correction.py` 中有 signature line 框架,补 O 值即可。
|
||||
| case | 流动类型 | 目标 | 当前角色 |
|
||||
|---|---|---|---|
|
||||
| uncontrolled | 无控制 pinball 自然尾迹 | 基线 | 周期辅助 case |
|
||||
| cloak | 受控 pinball | 槽道基流 | 稳态主分析对象 |
|
||||
| illusion | 受控 pinball | 2D 圆柱在 \(Re=100\) 下的目标尾迹 | 周期主分析对象 |
|
||||
| target channel | 槽道基流 | 自身 | cloak 参考 |
|
||||
| target cylinder | 2D 圆柱尾迹 | 自身 | 周期参考 |
|
||||
|
||||
3. **SR-CCD-OID 映射校正** — `docs/sr_ccd_oid_mapping.md` 需要根据 SR 和 OID 两方向的实际现状校正。
|
||||
具体说明:
|
||||
|
||||
---
|
||||
- **cloak 的 target** 是槽道基流
|
||||
- **illusion 的 target** 只考虑 2D 圆柱、\(Re=100\) 的目标尾迹
|
||||
- 周期线的参考频率与参考相位由 **target cylinder** 定义
|
||||
|
||||
## 成功标准回顾
|
||||
## 分析总路线
|
||||
|
||||
这套分析框架若要算成功,至少应支持以下判断:
|
||||
### 周期线
|
||||
|
||||
- [x] 控制首先调制的是 pinball 已存在基线上的 **correction field**,而不是直接"生成目标全流场"
|
||||
- [x] force-relevant structures 与 signature-relevant structures 可以不同(证据: O(force,sig) tau=0 = 0.01-0.55)
|
||||
- [ ] force 与 signature 若由不同结构族主导 → 机制分层(部分证据: zone-CCD 显示 0.75L sensor_zone O=0.01,但 tau_c 后共享)
|
||||
- [x] 1.5L 显示不同于 0.75L/1.0L 的修正策略(证据: action sigma1=0.28, O=0.667, phase drift)
|
||||
用于:
|
||||
|
||||
- target cylinder
|
||||
- illusion
|
||||
- uncontrolled(若通过 relaxed 门)
|
||||
|
||||
目标是回答:
|
||||
|
||||
- illusion 是否重组了与 target 周期 signature 最相关的结构
|
||||
- force-related structures、action-related structures 与 signature-related structures 是否对齐
|
||||
- illusion 是否比 uncontrolled 更接近 target 的相关结构通道
|
||||
|
||||
### 稳态线
|
||||
|
||||
用于:
|
||||
|
||||
- cloak
|
||||
- target channel
|
||||
|
||||
目标是回答:
|
||||
|
||||
- cloak 是否把均值尾迹重构为接近槽道基流
|
||||
- cloak 是否显著压低脉动和回流区
|
||||
- cloak 的控制幅值与感知改善之间是否具有合理对应关系
|
||||
|
||||
## 数据要求
|
||||
|
||||
### 必须导出的数据
|
||||
|
||||
| 数据组 | 记号 | 说明 |
|
||||
|---|---|---|
|
||||
| 流场快照 | \(u(x,y,t_n), v(x,y,t_n)\) | 全域 2D 快照 |
|
||||
| 总力 | \(F_x(t_n), F_y(t_n)\) | 所有周期 case 必须记录,包括 target cylinder |
|
||||
| 动作 | \(\Omega_i(t_n)\) | 三圆柱各自转速 |
|
||||
| 观测 | \(s(t_n)\in\mathbb R^6\) | 三个传感器各含 \(u,v\) |
|
||||
| 目标观测 | \(s_{tar}(t_n)\) | illusion 时需要;cloak 时为槽道基流参考 |
|
||||
| 元数据 | `meta.json` | Re 口径、采样步长、case 标签、steady/periodic 标签 |
|
||||
|
||||
### 当前不要求的数据
|
||||
|
||||
| 数据 | 说明 |
|
||||
|---|---|
|
||||
| 单圆柱力矩 \(M_{z,i}\) | 当前阶段不记录 |
|
||||
| 单圆柱受力分解 | 当前阶段不要求 |
|
||||
| 控制功率 | 因缺少力矩,当前阶段不做 |
|
||||
|
||||
## 采样原则的关键修正
|
||||
|
||||
这是本轮最重要的修正之一。
|
||||
|
||||
### 不再把 \(T_{ref}\) 的“样本数”当成固定采样标准
|
||||
|
||||
`target cylinder` 在某一次采样设置下测得的 \(T_{ref}\approx 18.75\) 样本/周期,只反映当时的 `SAMPLE_INTERVAL`,**不应该被当成所有 case 的固定原始采样密度标准**。
|
||||
|
||||
真正固定的只有两件事:
|
||||
|
||||
1. **参考频率 / 参考周期**
|
||||
|
||||
\[
|
||||
f_{ref},\qquad T_{ref}=1/f_{ref}
|
||||
\]
|
||||
|
||||
2. **标准相位网格**
|
||||
|
||||
\[
|
||||
\phi_m = 2\pi m/24,\qquad m=0,1,\ldots,23
|
||||
\]
|
||||
|
||||
也就是说:
|
||||
|
||||
- \(T_{ref}\) 用来定义统一相位坐标系
|
||||
- **原始采样频率应按每个 case 自适应设置**,目标是让每个 case 在原始数据里就尽量接近 24 点/周期,而不是先粗采样再强行插值到 24
|
||||
|
||||
### 当前采样策略
|
||||
|
||||
下一轮对每个周期 case 采用 **两步式采样**。
|
||||
|
||||
#### Step 1
|
||||
|
||||
传感器先导采样
|
||||
|
||||
先用较轻的数据模式跑一个 pilot:
|
||||
|
||||
- 高频保存传感器、总力、动作
|
||||
- 不急着保存全场
|
||||
- 估计该 case 的 \(f_{case}\)、\(T_{case}\)、\(\mathrm{CV}_T\)
|
||||
|
||||
#### Step 2
|
||||
|
||||
按 case 自适应设置场采样间隔
|
||||
|
||||
对于每个通过周期门的 case,设置该 case 的场采样间隔,使其原始场数据满足:
|
||||
|
||||
\[
|
||||
N_{raw/cycle} \approx 20\text{--}24
|
||||
\]
|
||||
|
||||
推荐用:
|
||||
|
||||
\[
|
||||
\text{SAMPLE\_INTERVAL}_{field} \approx T_{case}/24
|
||||
\]
|
||||
|
||||
若只能取整数步长,则选择最接近 24 点/周期的整数。
|
||||
|
||||
### 原始采样密度门槛
|
||||
|
||||
为了避免对过稀疏的原始数据做过强插值,定义每个 case 的原始每周期样本数:
|
||||
|
||||
\[
|
||||
N_{raw/cycle} = T_{case}/\text{SAMPLE\_INTERVAL}_{field}
|
||||
\]
|
||||
|
||||
并采用如下门槛:
|
||||
|
||||
| 等级 | 条件 | 处理 |
|
||||
|---|---|---|
|
||||
| ideal | \(N_{raw/cycle} \ge 20\) | 直接进入相位重采样 |
|
||||
| acceptable | \(16 \le N_{raw/cycle} < 20\) | 允许进入,但记录告警 |
|
||||
| relaxed | \(12 \le N_{raw/cycle} < 16\) | 只作辅助 case,不进主 reference basis |
|
||||
| reject | \(N_{raw/cycle} < 12\) | 不做 24 点/周期相位 CCD,必须重采 |
|
||||
|
||||
### 插值比例门槛
|
||||
|
||||
定义插值放大倍数:
|
||||
|
||||
\[
|
||||
\rho_{interp} = 24 / N_{raw/cycle}
|
||||
\]
|
||||
|
||||
采用如下自审规则:
|
||||
|
||||
| 等级 | 条件 | 解释 |
|
||||
|---|---|---|
|
||||
| ideal | \(\rho_{interp} \le 1.2\) | 基本不依赖插值 |
|
||||
| acceptable | \(1.2 < \rho_{interp} \le 1.5\) | 可接受 |
|
||||
| borderline | \(1.5 < \rho_{interp} \le 2.0\) | 可做辅助分析,但需谨慎解释 |
|
||||
| reject | \(\rho_{interp} > 2.0\) | 不进入主 phase-based CCD |
|
||||
|
||||
因此:
|
||||
|
||||
- 18.75 → 24 对应 \(\rho_{interp}\approx 1.28\),可接受
|
||||
- 12.4 → 24 对应 \(\rho_{interp}\approx 1.94\),属于 borderline,若能重采应优先重采
|
||||
|
||||
### 当前原则
|
||||
|
||||
下一轮不再默认所有 case 都用同一个 `SAMPLE_INTERVAL`。对于周期线,允许按 case 单独设置场采样间隔,只要最终都重采样到同一个 24 相位网格即可。
|
||||
|
||||
## 周期检测与相位重采样
|
||||
|
||||
### 标准频率
|
||||
|
||||
固定使用 **target cylinder 在 \(Re=100\) 下的脱涡频率** 作为标准频率:
|
||||
|
||||
\[
|
||||
f_{ref}
|
||||
\]
|
||||
|
||||
对应标准周期:
|
||||
|
||||
\[
|
||||
T_{ref}=1/f_{ref}
|
||||
\]
|
||||
|
||||
标准相位网格为:
|
||||
|
||||
\[
|
||||
\phi_m = 2\pi m/24,\qquad m=0,1,\ldots,23
|
||||
\]
|
||||
|
||||
### 周期检测建议顺序
|
||||
|
||||
对每个周期 case,按以下顺序检测主周期:
|
||||
|
||||
1. **首选信号**:中心传感器的横向速度或最干净的传感器分量
|
||||
2. **备选信号**:总升力 \(F_y\)
|
||||
3. **再次备选**:POD 前两阶系数中的主振荡分量
|
||||
|
||||
对首选信号先做:
|
||||
|
||||
- 去均值
|
||||
- 必要时做轻微平滑
|
||||
- FFT 找主频初值 \(f_{dom}\)
|
||||
- 用峰值间距或零交叉进一步修正周期
|
||||
|
||||
### 周期稳定性与准入门
|
||||
|
||||
设检测到连续周期长度 \(T_n\),定义:
|
||||
|
||||
\[
|
||||
\mathrm{CV}_T = \frac{\mathrm{std}(T_n)}{\mathrm{mean}(T_n)}
|
||||
\]
|
||||
|
||||
再定义相对参考频率偏差:
|
||||
|
||||
\[
|
||||
\delta_f = \frac{|f_{case}-f_{ref}^{scaled}|}{f_{ref}^{scaled}}
|
||||
\]
|
||||
|
||||
其中 \(f_{ref}^{scaled}\) 应按该 case 的来流速度或无量纲 Strouhal 一致性换算,而不是机械使用某一组物理条件下测得的原始 \(f_{ref}\)。
|
||||
|
||||
采用如下准入门:
|
||||
|
||||
| gate | 条件 | 用途 |
|
||||
|---|---|---|
|
||||
| strict | \(\mathrm{CV}_T \le 0.10\) 且 \(\delta_f \le 0.10\) | 可进主周期线与主基底 |
|
||||
| relaxed | \(\mathrm{CV}_T \le 0.12\) 且 \(\delta_f \le 0.20\) | 可作辅助周期 case |
|
||||
| auxiliary | 不满足 strict / relaxed 但仍有明显周期 | 只做投影或基线比较 |
|
||||
| reject | 周期不稳或采样过稀 | 不进入 phase-based CCD |
|
||||
|
||||
### 代表周期的选取
|
||||
|
||||
在足够长的前置稳定时间后,只截取 **4 个代表周期**。推荐方式:
|
||||
|
||||
- 先找到一个稳定窗口
|
||||
- 在该窗口内选 4 个连续周期
|
||||
- 若连续 4 周期不稳定,则选最接近 \(f_{ref}^{scaled}\) 的 4 个周期
|
||||
- 若仍不满足门槛,则该 case 不进入主周期线
|
||||
|
||||
### 周期起点的统一定义
|
||||
|
||||
每个周期必须有统一的相位起点。优先使用参考信号的:
|
||||
|
||||
- **上升穿零点且导数为正**
|
||||
|
||||
若该定义不稳,再退回使用局部峰值作为周期起点。但同一个 case 内必须保持一致。
|
||||
|
||||
### 相位重采样
|
||||
|
||||
对每个选中的周期,令该周期起点与终点分别对应相位 0 和 \(2\pi\)。对该周期内的全部数据做线性或三次样条插值,重采样到 24 个标准相位点:
|
||||
|
||||
\[
|
||||
\phi_m = 2\pi m/24
|
||||
\]
|
||||
|
||||
每个周期都得到:
|
||||
|
||||
- 24 帧流场快照
|
||||
- 24 个传感器观测
|
||||
- 24 个总力值
|
||||
- 24 个动作值
|
||||
|
||||
4 个周期共得到 96 个标准相位样本。
|
||||
|
||||
### 相位重采样后的质量检查
|
||||
|
||||
每个 case 重采样后必须输出两个自审量:
|
||||
|
||||
1. 重采样前后主频偏差
|
||||
2. 相位平均传感器回线是否出现明显跳变或折返
|
||||
|
||||
若重采样后出现显著畸变,则该 case 只能降级为 auxiliary,不进入主周期线。
|
||||
|
||||
## 参考 POD 基底
|
||||
|
||||
### 当前原则
|
||||
|
||||
当前阶段不再使用 target-only POD,而使用:
|
||||
|
||||
- **reference POD basis = target cylinder + illusion**
|
||||
|
||||
uncontrolled 只投影,不默认并入基底训练。
|
||||
|
||||
### 适用范围
|
||||
|
||||
参考 POD 只在周期线构造,不把 cloak 和 target channel 混进来。
|
||||
|
||||
### POD 截断
|
||||
|
||||
由于总样本数仍然有限,优先测试:
|
||||
|
||||
\[
|
||||
r = 6, 8, 10
|
||||
\]
|
||||
|
||||
不建议当前阶段上 20 阶以上。
|
||||
|
||||
## Reduced CCD 的定义
|
||||
|
||||
保留前 \(r\) 阶 POD 后,定义系数矩阵:
|
||||
|
||||
\[
|
||||
A_r =
|
||||
\begin{bmatrix}
|
||||
a_1(t_1) & \cdots & a_1(t_N) \\
|
||||
\vdots & & \vdots \\
|
||||
a_r(t_1) & \cdots & a_r(t_N)
|
||||
\end{bmatrix}
|
||||
\in \mathbb R^{r\times N}
|
||||
\]
|
||||
|
||||
若 observable 为 \(y(t)\in\mathbb R^m\),则对每个时刻 \(t_i\) 构造相位窗口向量:
|
||||
|
||||
\[
|
||||
\mathbf p_i=
|
||||
\begin{bmatrix}
|
||||
y(t_i+\tau_1) \\
|
||||
y(t_i+\tau_2) \\
|
||||
\vdots \\
|
||||
y(t_i+\tau_Q)
|
||||
\end{bmatrix}
|
||||
\in \mathbb R^{mQ}
|
||||
\]
|
||||
|
||||
于是:
|
||||
|
||||
\[
|
||||
P=[\mathbf p_1,\mathbf p_2,\ldots,\mathbf p_N] \in \mathbb R^{mQ\times N}
|
||||
\]
|
||||
|
||||
实际计算前先对 \(P\) 与 \(A_r\) 做逐行标准化,定义为 \(\tilde P\) 与 \(\tilde A_r\)。随后构造 reduced CCD 矩阵:
|
||||
|
||||
\[
|
||||
C = \frac{1}{N\sqrt{Q}} \tilde P\,\tilde A_r^{\top}
|
||||
\]
|
||||
|
||||
对其做 SVD:
|
||||
|
||||
\[
|
||||
C = R \Sigma W^{\top}
|
||||
\]
|
||||
|
||||
其中:
|
||||
|
||||
- \(W\) 的列向量给出 POD 子空间中的 CCD 方向
|
||||
- \(\sigma_k\) 表示第 \(k\) 个相关结构与 observable 的相关强度
|
||||
- CCD 空间模态由 POD 模态线性组合得到
|
||||
|
||||
\[
|
||||
\psi_k = \sum_{j=1}^{r} W_{jk}\,\phi_j
|
||||
\]
|
||||
|
||||
- CCD 时间系数定义为
|
||||
|
||||
\[
|
||||
z_k(t)=W_{:,k}^{\top}a_r(t)
|
||||
\]
|
||||
|
||||
## 当前阶段的三类 CCD
|
||||
|
||||
### force-CCD
|
||||
|
||||
这是当前阶段最重要、也最可跨 case 比较的 CCD。定义总体受力 observable:
|
||||
|
||||
\[
|
||||
y_F(t)=
|
||||
\begin{bmatrix}
|
||||
F_x(t) \\
|
||||
F_y(t)
|
||||
\end{bmatrix}
|
||||
\]
|
||||
|
||||
适用 case:
|
||||
|
||||
- target cylinder
|
||||
- illusion
|
||||
- uncontrolled(若通过 relaxed 门)
|
||||
|
||||
优先回答:
|
||||
|
||||
- illusion 是否比 uncontrolled 更接近 target 的 force-related structures
|
||||
- action-related structures 是否与 force-related structures 对齐
|
||||
|
||||
### action-CCD
|
||||
|
||||
动作 observable 只在 illusion case 上有意义:
|
||||
|
||||
\[
|
||||
y_{act}(t)=
|
||||
\begin{bmatrix}
|
||||
\Omega_1(t) \\
|
||||
\Omega_2(t) \\
|
||||
\Omega_3(t)
|
||||
\end{bmatrix}
|
||||
\]
|
||||
|
||||
优先回答:
|
||||
|
||||
- illusion 控制主要依赖哪些低维结构自由度
|
||||
- action-related structures 是否与 force-related structures 对齐
|
||||
|
||||
### signature-CCD
|
||||
|
||||
目标相关的 observable 只在 illusion 线上定义。令:
|
||||
|
||||
\[
|
||||
e_s(t)=s(t)-s_{tar}(t)
|
||||
\]
|
||||
|
||||
然后做相位偏移版本:
|
||||
|
||||
\[
|
||||
y_{sig}(t)=e_s(t+\tau_c)
|
||||
\]
|
||||
|
||||
当前阶段必须至少扫描三组:
|
||||
|
||||
- \(\tau_c = 0\)
|
||||
- \(\tau_c = \tau_c^{geom}\)
|
||||
- \(\tau_c = \tau_c^{corr}\)
|
||||
|
||||
目标是判断 signature-related structures 更像同步耦合,还是更像带相位偏移的 downstream response。
|
||||
|
||||
## 标准化规范
|
||||
|
||||
CCD 的 observable 与 POD 系数都必须标准化。否则量纲较大的分量会主导分解结果。
|
||||
|
||||
### observable 矩阵标准化
|
||||
|
||||
设 observable 堆叠后形成矩阵 \(P\)。对 \(P\) 的每一行做 z-score 标准化:
|
||||
|
||||
\[
|
||||
\tilde P_{j,:}=\frac{P_{j,:}-\mu_j}{\sigma_j+\epsilon}
|
||||
\]
|
||||
|
||||
其中 \(\mu_j\)、\(\sigma_j\) 为训练集统计量,\(\epsilon\) 为防止零方差的极小正数。若某一行 \(\sigma_j\) 极小,则直接剔除该分量。
|
||||
|
||||
### POD 系数矩阵标准化
|
||||
|
||||
对保留的 POD 系数矩阵 \(A_r\) 的每一行同样标准化:
|
||||
|
||||
\[
|
||||
\tilde A_{k,:}=\frac{A_{k,:}-\bar a_k}{s_k+\epsilon}
|
||||
\]
|
||||
|
||||
测试集必须使用训练集的均值和标准差做标准化,不允许单独对测试集重新拟合统计量。
|
||||
|
||||
## 当前阶段的核心指标
|
||||
|
||||
### 周期线基础指标
|
||||
|
||||
| 指标 | 含义 |
|
||||
|---|---|
|
||||
| \(f_{dom}\) | 主频 |
|
||||
| \(\delta_f\) | 相对参考频率偏差 |
|
||||
| \(\mathrm{CV}_T\) | 周期波动系数 |
|
||||
| \(\overline{E_s}\) | 传感器误差时间均值 |
|
||||
| \(E_{phase}\) | 相位平均误差 |
|
||||
| \(\eta_{RMS}\) | 脉动强度比 |
|
||||
| \(N_{raw/cycle}\) | 原始每周期样本数 |
|
||||
| \(\rho_{interp}\) | 插值放大倍数 |
|
||||
|
||||
### POD 指标
|
||||
|
||||
| 指标 | 含义 |
|
||||
|---|---|
|
||||
| \(E_{95}\) | 达到 95% 能量所需模态数 |
|
||||
| \(\gamma_{POD}(m)\) | 前 \(m\) 阶累计能量占比 |
|
||||
| \(D_{POD}\) | case 在前两阶相图中的中心距离 |
|
||||
|
||||
### CCD 指标
|
||||
|
||||
| 指标 | 含义 |
|
||||
|---|---|
|
||||
| \(\gamma_{CCD}(m)\) | 前 \(m\) 阶 CCD 累计相关强度占比 |
|
||||
| \(m_{80}^{CCD}\) | 到 80% 相关强度所需模态数 |
|
||||
| \(\bar R^2_{LOCO,force}(m)\) | 留一周期交叉验证下 force 的平均测试 \(R^2\) |
|
||||
| \(\bar R^2_{LOCO,act}(m)\) | 留一周期交叉验证下 action 的平均测试 \(R^2\) |
|
||||
| \(\bar R^2_{LOCO,sig}(m)\) | 留一周期交叉验证下 signature 的平均测试 \(R^2\) |
|
||||
| \(\sigma_{R^2,LOCO}(m)\) | 上述 \(R^2\) 的标准差 |
|
||||
| \(\rho_{max}(z_k,\Omega_i)\) | 结构与动作的最大相关 |
|
||||
| \(\rho_{max}(z_k,F_x),\rho_{max}(z_k,F_y)\) | 结构与总力的最大相关 |
|
||||
| \(\rho_{max}(z_k,e_s)\) | 结构与误差的最大相关 |
|
||||
| \(O_k^{(A,B)}\) | 第 \(k\) 个 CCD 方向的模态重合度 |
|
||||
|
||||
其中:
|
||||
|
||||
\[
|
||||
\gamma_{CCD}(m)=\frac{\sum_{k=1}^{m}\sigma_k^2}{\sum_{k}\sigma_k^2}
|
||||
\]
|
||||
|
||||
\[
|
||||
O_k^{(A,B)} = \left| w_k^{(A)\top} w_k^{(B)} \right|
|
||||
\]
|
||||
|
||||
## 稳态线指标
|
||||
|
||||
对 cloak 与 target channel,不看 CCD 成败,而看是否成功恢复稳态背景流。
|
||||
|
||||
| 指标 | 用途 |
|
||||
|---|---|
|
||||
| \(E_{mean}\) | 均值流场相对误差 |
|
||||
| \(E_{sensor}^{mean}\) | 传感器均值误差 |
|
||||
| \(\eta_{fluc}\) | 脉动抑制率 |
|
||||
| \(L_r\) | 回流区长度 |
|
||||
| \(A_r\) | 回流区面积 |
|
||||
| \(\sigma_F\) | 总力波动标准差 |
|
||||
| \(J_\Omega^{rms}\) | 动作 RMS 总量 |
|
||||
| \(\eta_{cloak}^{obs}\) | 单位控制幅值带来的感知改善 |
|
||||
|
||||
## 当前阶段的执行顺序
|
||||
|
||||
1. **补 target cylinder 的 force 数据**
|
||||
2. **重跑 force-CCD**,并比较:
|
||||
- \(O_k(illusion, target)\)
|
||||
- \(O_k(uncontrolled, target)\)
|
||||
3. **对 signature-CCD 做 \(\tau_c=0/geom/corr\) 三点扫描**
|
||||
4. **补连续块测试**:前 2 周期训练、后 2 周期测试
|
||||
5. **补 cloak 稳态线指标**:\(E_{mean}, E_{sensor}^{mean}, \eta_{fluc}, L_r, A_r, \eta_{cloak}^{obs}\)
|
||||
6. **保持 reference POD basis = target + illusion**
|
||||
7. **uncontrolled 只作辅助投影,暂不并入主基底训练**
|
||||
|
||||
## 当前阶段暂不做的内容
|
||||
|
||||
- 不做多 Reynolds 数统一分析
|
||||
- 不做上游扰动 case
|
||||
- 不做 checkpoint/restore 优化
|
||||
- 不做白箱控制律
|
||||
- 不在 cloak 上强行做 CCD
|
||||
- 不把“CCD 优于 POD”写成正式结论
|
||||
|
||||
## 当前阶段的完成标准
|
||||
|
||||
本轮工作完成后,至少应满足:
|
||||
|
||||
1. target cylinder 拥有完整的 force 数据
|
||||
2. force-CCD 能真正比较 target / illusion / uncontrolled 三者
|
||||
3. signature-CCD 完成 \(\tau_c\) 三点扫描
|
||||
4. 同时拥有 LOCO 与连续块测试两套 \(R^2\) 验证
|
||||
5. cloak 稳态线给出完整恢复指标
|
||||
6. 输出的是指标 + 模态,而不是单纯图像
|
||||
7. 至少能形成一个更硬的机制判断:
|
||||
- illusion 是否比 uncontrolled 更接近 target 的 force-related 或 signature-related structure channel
|
||||
|
||||
满足这七条后,再进入下一阶段:
|
||||
|
||||
- 讨论是否让 uncontrolled 并入基底
|
||||
- 讨论 `obs -> z -> act` 白箱控制链
|
||||
- 讨论更严格的 whitening / PCD-style 版本
|
||||
@ -3,10 +3,6 @@
|
||||
All scene metadata in one place. Each scene dict contains all parameters
|
||||
needed for data collection, resampling, POD, and CCD.
|
||||
|
||||
CRITICAL: Illusion models use ONLY 2U series (d1a3o14_*).
|
||||
1U series (d1a3o12_*) are NOT to be used.
|
||||
All models use u0=0.01, nu=0.004.
|
||||
|
||||
Re convention:
|
||||
- "re_code" uses reference length 2*D (matching model file naming).
|
||||
- Re_D = re_code / 2 is the true physical Reynolds number.
|
||||
@ -18,13 +14,12 @@ from typing import Any, Dict, List, Optional
|
||||
|
||||
# -- Root paths ---------------------------------------------------------------
|
||||
_PROJ = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
MODEL_DIR = os.path.join(_PROJ, "..", "models")
|
||||
MODEL_DIR = os.path.join(_PROJ, "..", "..", "models")
|
||||
LEGACY_CFG_DIR = os.path.join(os.path.dirname(__file__), "configs")
|
||||
DATA_DIR = os.path.join(os.path.dirname(__file__), "data")
|
||||
|
||||
# -- Physics constants -------------------------------------------------------
|
||||
U0 = 0.01 # standard inlet center velocity (all models use this)
|
||||
# NOTE: "2U" in model name means S_DIM=14 (2 extra target force obs), NOT u0 scaling
|
||||
U0 = 0.01
|
||||
D_CYL = 20.0
|
||||
D_REF = 40.0
|
||||
L0 = 20.0
|
||||
@ -32,18 +27,18 @@ NX = 1280
|
||||
NY = 512
|
||||
CENTER_Y = (NY - 1) / 2.0
|
||||
FIFO_LEN = 150
|
||||
# CONV_LEN is per-scene. Illusion=36, Karman/Steady=30.
|
||||
# Set locally in collection scripts, not as a global here.
|
||||
CONV_LEN = 30
|
||||
|
||||
|
||||
def nu_from_re(re_code: float, u0: float = U0) -> float:
|
||||
"""Viscosity from code Reynolds number (reference length = 2*D)."""
|
||||
return u0 * D_REF / re_code
|
||||
|
||||
|
||||
# -- Scene definitions -------------------------------------------------------
|
||||
SCENES: Dict[str, Any] = {}
|
||||
|
||||
# -- Pure Pinball (uncontrolled baseline) ------------------------------------
|
||||
# -- Pure Pinball (uncontrolled baseline, 6 objects, no disturbance) ---------
|
||||
SCENES["pinball"] = {
|
||||
"scene_id": "pinball",
|
||||
"re_code": 100,
|
||||
@ -62,7 +57,7 @@ SCENES["pinball"] = {
|
||||
"nu": nu_from_re(100),
|
||||
}
|
||||
|
||||
# -- Steady Cloak (open-loop constant rotation) ------------------------------
|
||||
# -- Steady Cloak (open-loop constant rotation, 6 objects) --------------------
|
||||
SCENES["steady_cloak"] = {
|
||||
"scene_id": "steady_cloak",
|
||||
"re_code": 100,
|
||||
@ -83,7 +78,7 @@ SCENES["steady_cloak"] = {
|
||||
"omega_rear_scale": 5.1,
|
||||
}
|
||||
|
||||
# -- Karman Cloak re100 (PPO, cloak validation only) -------------------------
|
||||
# -- Karman Cloak (PPO, 7 objects, disturbance cylinder) ---------------------
|
||||
SCENES["karman_re100"] = {
|
||||
"scene_id": "karman",
|
||||
"re_code": 100,
|
||||
@ -105,134 +100,48 @@ SCENES["karman_re100"] = {
|
||||
"nu": nu_from_re(100),
|
||||
}
|
||||
|
||||
# -- Karman q_in (target/incoming vortex street, no pinball) ------------------
|
||||
SCENES["karman_q_in"] = {
|
||||
"scene_id": "karman_target",
|
||||
"re_code": 100,
|
||||
"has_disturbance": True,
|
||||
"sample_interval": 800,
|
||||
"source": "open_loop",
|
||||
"n_objects_env": 4,
|
||||
"obs_slice": (0, 8),
|
||||
"sensor_x": 40.0,
|
||||
"pinball_front_x": None,
|
||||
"pinball_rear_x": None,
|
||||
"target_type": "periodic",
|
||||
"s_dim": None,
|
||||
"u0": U0,
|
||||
"nu": 0.004,
|
||||
}
|
||||
|
||||
# -- Karman q_blk (pinball in vortex street, zero control) -------------------
|
||||
SCENES["karman_q_blk"] = {
|
||||
"scene_id": "karman_blocked",
|
||||
"re_code": 100,
|
||||
"has_disturbance": True,
|
||||
"sample_interval": 800,
|
||||
"source": "open_loop",
|
||||
"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": None,
|
||||
"u0": U0,
|
||||
"nu": 0.004,
|
||||
}
|
||||
|
||||
# -- Illusion scenes (S_DIM=14) -----------------------------------------------
|
||||
# All use u0=0.01, SAMPLE_INTERVAL per diameter, nu=0.004 confirmed
|
||||
# Sweep results: 0.004=0.962, 0.008=0.957, 0.002=0.882
|
||||
# "2U" in model name = S_DIM=14 (2 extra target force dimensions), NOT 2x velocity
|
||||
_ILLUSION_2U = [
|
||||
("illusion_0.75L", "d1a3o14_250525_imit_075L_2U_400S", 0.75, 400),
|
||||
("illusion_1.0L", "d1a3o14_250525_imit_1L_2U_600S", 1.0, 600),
|
||||
("illusion_1.5L", "d1a3o14_250525_imit_15L_2U", 1.5, 800),
|
||||
]
|
||||
for key, mn, diam, si in _ILLUSION_2U:
|
||||
SCENES[key] = {
|
||||
"scene_id": "illusion",
|
||||
"target_diameter": diam,
|
||||
"re_code": 100, # u0=0.01, nu=0.004
|
||||
"has_disturbance": False,
|
||||
"sample_interval": si,
|
||||
"conv_len": 36, # Illusion uses 36 (see legacy_env_imit.py)
|
||||
"action_scale": 8.0,
|
||||
"action_bias": (0.0, -2.0, 2.0),
|
||||
"source": "PPO_inference",
|
||||
"model_name": mn,
|
||||
"model_subdir": "250525",
|
||||
"n_objects_env": 6,
|
||||
"obs_slice": (0, 12),
|
||||
"sensor_x": 30.0,
|
||||
"pinball_front_x": 19.0,
|
||||
"pinball_rear_x": 20.3,
|
||||
"target_type": "periodic",
|
||||
"s_dim": 14, # CRITICAL: all 2U are 14-dim
|
||||
"u0": U0, # 0.01 (NOT 0.02)
|
||||
"nu": 0.004, # confirmed correct via sweep: 0.004=0.962, 0.008=0.957, 0.002=0.882
|
||||
}
|
||||
|
||||
# -- Target cylinders (per-diameter, for signature-CCD reference) ------------
|
||||
# Each illusion diameter needs its own target cylinder data
|
||||
# SAMPLE_INTERVAL per diameter, matching the corresponding illusion scene
|
||||
for diam, si in [(0.75, 400), (1.0, 600), (1.5, 800)]:
|
||||
key = f"target_cylinder_{diam}L"
|
||||
SCENES[key] = {
|
||||
"scene_id": "target_cylinder",
|
||||
"target_diameter": diam,
|
||||
"re_code": 100, # u0=0.01, nu=0.004
|
||||
"has_disturbance": False,
|
||||
"sample_interval": si, # per-diameter, matching corresponding illusion
|
||||
"conv_len": 36, # matching corresponding illusion
|
||||
"source": "open_loop",
|
||||
"model_name": None,
|
||||
"n_objects_env": 4, # 1 cylinder + 3 sensors
|
||||
"obs_slice": (0, 8), # cylinder force(2) + sensor(6)
|
||||
"sensor_x": 30.0,
|
||||
"cylinder_x": 20.0,
|
||||
"target_type": "periodic",
|
||||
"s_dim": None,
|
||||
"u0": U0, # 0.01, matching illusion
|
||||
"nu": 0.004, # confirmed correct via sweep: 0.004=0.962, 0.008=0.957, 0.002=0.882
|
||||
}
|
||||
|
||||
# -- Target Channel (empty channel, for steady metrics) ----------------------
|
||||
SCENES["target_channel"] = {
|
||||
"scene_id": "target_channel",
|
||||
# -- 1L Illusion (PPO, 6 objects, 2U=0.02) ----------------------------------
|
||||
SCENES["illusion_1L"] = {
|
||||
"scene_id": "illusion",
|
||||
"re_code": 100,
|
||||
"target_diameter": 1.0,
|
||||
"has_disturbance": False,
|
||||
"sample_interval": 800,
|
||||
"source": "open_loop",
|
||||
"model_name": None,
|
||||
"n_objects_env": 3,
|
||||
"obs_slice": (0, 6),
|
||||
"sensor_x": 40.0,
|
||||
"pinball_front_x": None,
|
||||
"pinball_rear_x": None,
|
||||
"target_type": "steady",
|
||||
"s_dim": 6,
|
||||
"u0": U0,
|
||||
"nu": nu_from_re(100),
|
||||
"sample_interval": 600,
|
||||
"action_scale": 8.0,
|
||||
"action_bias": (0.0, -2.0, 2.0),
|
||||
"source": "PPO_inference",
|
||||
"model_name": "d1a3o14_250525_imit_1L_2U_600S",
|
||||
"model_subdir": "250525",
|
||||
"n_objects_env": 6,
|
||||
"obs_slice": (0, 12),
|
||||
"sensor_x": 30.0,
|
||||
"pinball_front_x": 19.0,
|
||||
"pinball_rear_x": 20.3,
|
||||
"target_type": "periodic",
|
||||
"s_dim": 14,
|
||||
"u0": 0.02,
|
||||
"nu": nu_from_re(100, u0=0.02),
|
||||
}
|
||||
|
||||
|
||||
# -- Utility helpers ---------------------------------------------------------
|
||||
|
||||
def get_scene(name: str) -> dict:
|
||||
"""Return scene config dict by name. Raises KeyError if not found."""
|
||||
if name not in SCENES:
|
||||
raise KeyError(f"Unknown scene: {name}. Available: {list(SCENES.keys())}")
|
||||
return dict(SCENES[name])
|
||||
|
||||
|
||||
def get_scene_list(scene_id: Optional[str] = None) -> List[str]:
|
||||
"""Return list of scene names, optionally filtered by scene_id."""
|
||||
if scene_id is None:
|
||||
return list(SCENES.keys())
|
||||
return [k for k, v in SCENES.items() if v["scene_id"] == scene_id]
|
||||
|
||||
|
||||
def model_path_for_scene(scene_name: str) -> Optional[str]:
|
||||
"""Return absolute path to PPO model .zip file, or None."""
|
||||
cfg = get_scene(scene_name)
|
||||
mn = cfg.get("model_name")
|
||||
if mn is None:
|
||||
@ -243,6 +152,7 @@ def model_path_for_scene(scene_name: str) -> Optional[str]:
|
||||
|
||||
|
||||
def data_dir_for_scene(scene_name: str) -> str:
|
||||
"""Return the data directory for a scene, creating it if needed."""
|
||||
cfg = get_scene(scene_name)
|
||||
scene_id = cfg["scene_id"]
|
||||
d = os.path.join(DATA_DIR, scene_id, scene_name)
|
||||
|
||||
@ -1,378 +0,0 @@
|
||||
"""Build q_in, q_blk, q_ctl, q_tar field references and compute Delta corrections.
|
||||
|
||||
Each scene type maps to different data sources:
|
||||
|
||||
| Scene type | q_in | q_blk | q_ctl | q_tar |
|
||||
|-----------------|-------------------|---------|---------------------|--------------------------|
|
||||
| illusion_0.75L | target_channel* | pinball | illusion_0.75L | target_cylinder_0.75L |
|
||||
| illusion_1.0L | target_channel* | pinball | illusion_1.0L | target_cylinder_1.0L |
|
||||
| illusion_1.5L | target_channel* | pinball | illusion_1.5L | target_cylinder_1.5L |
|
||||
| steady_cloak | target_channel* | pinball | steady_cloak* | None (target=q_in) |
|
||||
| karman_re100 | karman_q_in (TBD) | TBD | karman_re100 | karman_q_in (TBD) |
|
||||
|
||||
(*) loaded via load_legacy_steady()
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import warnings
|
||||
from typing import Any, Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
from CCD_analysis.configs import NX, NY
|
||||
from CCD_analysis.utils.resampling import (
|
||||
load_aligned_fields,
|
||||
build_field_matrix as _build_field_matrix,
|
||||
)
|
||||
|
||||
from CCD_analysis.correction_analysis.process_legacy_steady import load_legacy_steady
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Placeholder for future karman reference fields
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_KARMAN_PLACEHOLDER = None
|
||||
"""Temporary placeholder for karman_q_in / karman_q_blk data.
|
||||
|
||||
Will be replaced with proper data loading once collected.
|
||||
"""
|
||||
|
||||
|
||||
def _load_karman_placeholder(scene_label: str) -> Optional[dict]:
|
||||
"""Return None placeholder for karman reference fields."""
|
||||
print(f" [PLACEHOLDER] karman reference '{scene_label}' -- returning None")
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Source mapping
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _resolve_source(name: str) -> dict:
|
||||
"""Load a named data source, dispatching to the correct loader.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
name : str
|
||||
Scene or special name:
|
||||
- 'target_channel' -> load_legacy_steady
|
||||
- 'steady_cloak' -> load_legacy_steady
|
||||
- 'pinball' -> load_aligned_fields
|
||||
- 'illusion_*' -> load_aligned_fields
|
||||
- 'target_cylinder_*'-> load_aligned_fields
|
||||
- 'karman_re100' -> load_aligned_fields
|
||||
- 'karman_q_in' -> _load_karman_placeholder
|
||||
- 'karman_q_blk' -> _load_karman_placeholder
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict or None
|
||||
"""
|
||||
_LEGACY_SCENES = {"target_channel", "steady_cloak"}
|
||||
|
||||
if name in _LEGACY_SCENES:
|
||||
return load_legacy_steady(name)
|
||||
else:
|
||||
# karman_q_in, karman_q_blk, and all others use aligned loader
|
||||
return load_aligned_fields(name)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Correction computation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Scene map: (scene_type -> (q_in_source, q_blk_source, q_ctl_source, q_tar_source))
|
||||
_SCENE_MAP = {
|
||||
"illusion_0.75L": ("target_channel", "pinball", "illusion_0.75L", "target_cylinder_0.75L"),
|
||||
"illusion_1.0L": ("target_channel", "pinball", "illusion_1.0L", "target_cylinder_1.0L"),
|
||||
"illusion_1.5L": ("target_channel", "pinball", "illusion_1.5L", "target_cylinder_1.5L"),
|
||||
"steady_cloak": ("target_channel", "pinball", "steady_cloak", None),
|
||||
"karman_re100": ("karman_q_in", "karman_q_blk", "karman_re100", "karman_q_in"),
|
||||
}
|
||||
|
||||
|
||||
def get_diameter(scene_type: str) -> Optional[float]:
|
||||
"""Extract target diameter from scene type string (e.g. 'illusion_1.0L' -> 1.0)."""
|
||||
if "illusion" in scene_type or "target_cylinder" in scene_type:
|
||||
try:
|
||||
return float(scene_type.split("_")[-1].replace("L", ""))
|
||||
except (ValueError, IndexError):
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def subtract_fields(q_a: dict, q_b: dict) -> Optional[dict]:
|
||||
"""Compute q_a - q_b field difference.
|
||||
|
||||
Both must have the same N. Returns dict with:
|
||||
ux, uy : (N, NY, NX) -- field difference
|
||||
forces : from q_a (reference)
|
||||
sensors : from q_a (reference)
|
||||
actions : from q_a (reference)
|
||||
meta : combined
|
||||
step_indices : from q_a
|
||||
|
||||
Parameters
|
||||
----------
|
||||
q_a : dict -- reference (minuend)
|
||||
q_b : dict -- subtrahend
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict or None if either input is None
|
||||
"""
|
||||
if q_a is None or q_b is None:
|
||||
return None
|
||||
|
||||
N_a = q_a["ux"].shape[0]
|
||||
N_b = q_b["ux"].shape[0]
|
||||
if N_a != N_b:
|
||||
raise ValueError(
|
||||
f"Frame count mismatch: q_a has {N_a} frames, q_b has {N_b}. "
|
||||
"Use common_length() to align."
|
||||
)
|
||||
|
||||
return {
|
||||
"ux": q_a["ux"] - q_b["ux"],
|
||||
"uy": q_a["uy"] - q_b["uy"],
|
||||
"forces": q_a.get("forces"),
|
||||
"sensors": q_a.get("sensors"),
|
||||
"actions": q_a.get("actions"),
|
||||
"meta": {**q_a.get("meta", {}), "delta_from": q_b.get("meta", {}).get("scene", "unknown")},
|
||||
"step_indices": q_a.get("step_indices"),
|
||||
}
|
||||
|
||||
|
||||
def dict_to_field_matrix(q: dict) -> np.ndarray:
|
||||
"""Wrapper: build_field_matrix(q['ux'], q['uy']) with error checking.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
q : dict -- must contain 'ux' and 'uy' with shape (N, NY, NX).
|
||||
|
||||
Returns
|
||||
-------
|
||||
Q : (2 * NX * NY, N) ndarray -- snapshot matrix for POD.
|
||||
"""
|
||||
if q is None:
|
||||
raise ValueError("Cannot build field matrix from None")
|
||||
ux = q["ux"]
|
||||
uy = q["uy"]
|
||||
if ux.ndim != 3 or ux.shape[-2:] != (NY, NX):
|
||||
raise ValueError(
|
||||
f"Expected field shape (N, {NY}, {NX}), got {ux.shape}"
|
||||
)
|
||||
return _build_field_matrix(ux, uy)
|
||||
|
||||
|
||||
def compute_correction(scene_type: str) -> dict:
|
||||
"""Load q_in, q_blk, q_ctl, q_tar and compute Delta fields.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
scene_type : str -- one of the keys in _SCENE_MAP.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict with:
|
||||
scene_type : str
|
||||
diam : float or None
|
||||
q_in, q_blk, q_ctl, q_tar : dict or None -- raw loaded data
|
||||
dq_blk, dq_ctl, dq_tar : dict or None -- field differences
|
||||
dq_tar_minus_blk : dict or None
|
||||
N : int -- aligned frame count (min across all loaded sources)
|
||||
meta : combined metadata dict
|
||||
"""
|
||||
if scene_type not in _SCENE_MAP:
|
||||
raise KeyError(
|
||||
f"Unknown scene_type: {scene_type}. "
|
||||
f"Available: {list(_SCENE_MAP.keys())}"
|
||||
)
|
||||
|
||||
q_in_name, q_blk_name, q_ctl_name, q_tar_name = _SCENE_MAP[scene_type]
|
||||
diam = get_diameter(scene_type)
|
||||
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f"Computing correction fields for: {scene_type}")
|
||||
print(f" q_in = {q_in_name}, q_blk = {q_blk_name}, "
|
||||
f"q_ctl = {q_ctl_name}, q_tar = {q_tar_name}")
|
||||
print(f"{'=' * 60}")
|
||||
|
||||
# -- Load all sources --
|
||||
q_in = _resolve_source(q_in_name) if q_in_name else None
|
||||
q_blk = _resolve_source(q_blk_name) if q_blk_name else None
|
||||
q_ctl = _resolve_source(q_ctl_name) if q_ctl_name else None
|
||||
q_tar = _resolve_source(q_tar_name) if q_tar_name else None
|
||||
|
||||
# -- Determine aligned N --
|
||||
all_N = []
|
||||
for label, q in [("q_in", q_in), ("q_blk", q_blk), ("q_ctl", q_ctl), ("q_tar", q_tar)]:
|
||||
if q is not None:
|
||||
n = q["ux"].shape[0]
|
||||
all_N.append(n)
|
||||
print(f" {label}: {n} frames, shape={q['ux'].shape}")
|
||||
else:
|
||||
print(f" {label}: None")
|
||||
|
||||
N = min(all_N) if all_N else 0
|
||||
|
||||
# -- Compute Delta fields --
|
||||
# dq_blk = q_ctl - q_blk (controller adds beyond pinball)
|
||||
# dq_tar = q_tar - q_blk (target cylinder wake beyond pinball)
|
||||
# dq_ctl = q_ctl - q_in (ctl perturbation from inflow)
|
||||
# dq_tar_in = q_tar - q_in (target perturbation from inflow)
|
||||
|
||||
dq_blk = _safe_subtract(q_blk, q_in, "dq_blk = q_blk - q_in (pinball blockage)")
|
||||
dq_ctl = _safe_subtract(q_ctl, q_blk, "dq_ctl = q_ctl - q_blk (control correction)")
|
||||
dq_tar = _safe_subtract(q_tar, q_blk, "dq_tar = q_tar - q_blk (target correction)")
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"scene_type": scene_type,
|
||||
"diam": diam,
|
||||
"q_in": q_in,
|
||||
"q_blk": q_blk,
|
||||
"q_ctl": q_ctl,
|
||||
"q_tar": q_tar,
|
||||
"dq_blk": dq_blk, # q_blk - q_in
|
||||
"dq_ctl": dq_ctl, # q_ctl - q_blk
|
||||
"dq_tar": dq_tar, # q_tar - q_blk
|
||||
"N": N,
|
||||
"meta": {
|
||||
"scene_type": scene_type,
|
||||
"q_in": q_in_name,
|
||||
"q_blk": q_blk_name,
|
||||
"q_ctl": q_ctl_name,
|
||||
"q_tar": q_tar_name,
|
||||
"N_aligned": N,
|
||||
},
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _safe_subtract(q_a: Optional[dict], q_b: Optional[dict],
|
||||
label: str) -> Optional[dict]:
|
||||
"""Subtract fields with optional trimming and None safety."""
|
||||
if q_a is None or q_b is None:
|
||||
print(f" {label}: skipped (None input)")
|
||||
return None
|
||||
|
||||
N_a = q_a["ux"].shape[0]
|
||||
N_b = q_b["ux"].shape[0]
|
||||
|
||||
if N_a != N_b:
|
||||
N_min = min(N_a, N_b)
|
||||
print(f" {label}: N mismatch ({N_a} vs {N_b}), "
|
||||
f"trimming to min N={N_min}")
|
||||
q_a_trim = _trim_to(q_a, N_min)
|
||||
q_b_trim = _trim_to(q_b, N_min)
|
||||
else:
|
||||
q_a_trim = q_a
|
||||
q_b_trim = q_b
|
||||
|
||||
dq = subtract_fields(q_a_trim, q_b_trim)
|
||||
if dq is not None:
|
||||
_print_field_summary(f" {label}", dq["ux"], dq["uy"])
|
||||
return dq
|
||||
|
||||
|
||||
def _trim_to(q: dict, N: int) -> dict:
|
||||
"""Trim first N frames from field dict."""
|
||||
return {
|
||||
"ux": q["ux"][:N],
|
||||
"uy": q["uy"][:N],
|
||||
"forces": q.get("forces")[:N] if q.get("forces") is not None else None,
|
||||
"sensors": q.get("sensors")[:N] if q.get("sensors") is not None else None,
|
||||
"actions": q.get("actions")[:N] if q.get("actions") is not None else None,
|
||||
"step_indices": q.get("step_indices")[:N] if q.get("step_indices") is not None else None,
|
||||
"meta": q.get("meta", {}),
|
||||
}
|
||||
|
||||
|
||||
def _print_field_summary(label: str, ux: np.ndarray, uy: np.ndarray) -> None:
|
||||
"""Print one-line field statistics."""
|
||||
ux_mean = ux.mean()
|
||||
uy_mean = uy.mean()
|
||||
ux_rms = ux.std()
|
||||
uy_rms = uy.std()
|
||||
mag_mean = np.sqrt(ux_mean**2 + uy_mean**2)
|
||||
print(f" {label}:")
|
||||
print(f" shape = {ux.shape}")
|
||||
print(f" ux_mean = {ux_mean:.6f} uy_mean = {uy_mean:.6f}")
|
||||
print(f" ux_rms = {ux_rms:.6f} uy_rms = {uy_rms:.6f}")
|
||||
print(f" |q|_mean = {mag_mean:.6f}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main (test / verification)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("=" * 60)
|
||||
print("Testing compute_correction_fields.py")
|
||||
print("=" * 60)
|
||||
|
||||
# -- 1. Load target_channel + pinball, compute dq_blk --
|
||||
print("\n--- 1. Loading target_channel (legacy) + pinball (aligned) ---")
|
||||
target_channel = load_legacy_steady("target_channel")
|
||||
pinball = load_aligned_fields("pinball")
|
||||
|
||||
_print_field_summary("target_channel (mean)", target_channel["ux"], target_channel["uy"])
|
||||
_print_field_summary("pinball (mean)", pinball["ux"], pinball["uy"])
|
||||
|
||||
# Verify shapes
|
||||
print(f"\n target_channel: N={target_channel['ux'].shape[0]}, "
|
||||
f"shape={target_channel['ux'].shape}")
|
||||
print(f" pinball: N={pinball['ux'].shape[0]}, "
|
||||
f"shape={pinball['ux'].shape}")
|
||||
print(f" sensors (target): {target_channel['sensors'].shape}")
|
||||
print(f" sensors (pinball): {pinball['sensors'].shape}")
|
||||
print(f" forces (pinball): {pinball['forces'].shape}")
|
||||
|
||||
# -- 2. Compute dq_blk = pinball - target_channel --
|
||||
# Trim to smaller N
|
||||
dq_blk_test = _safe_subtract(pinball, target_channel, "pinball - target_channel")
|
||||
if dq_blk_test is not None:
|
||||
print(f"\n dq_blk shape: {dq_blk_test['ux'].shape}")
|
||||
print(f" dq_blk ux_mean (mean of difference): {dq_blk_test['ux'].mean():.6f}")
|
||||
|
||||
# -- 3. Test full compute_correction for illusion_1.0L --
|
||||
print("\n--- 3. Full correction field pipeline: illusion_1.0L ---")
|
||||
result = compute_correction("illusion_1.0L")
|
||||
|
||||
print(f"\n--- Result summary for {result['scene_type']} ---")
|
||||
print(f" diam = {result['diam']}")
|
||||
print(f" N = {result['N']}")
|
||||
print(f" q_in = {result['q_in']['ux'].shape if result['q_in'] else None}")
|
||||
print(f" q_blk = {result['q_blk']['ux'].shape if result['q_blk'] else None}")
|
||||
print(f" q_ctl = {result['q_ctl']['ux'].shape if result['q_ctl'] else None}")
|
||||
print(f" q_tar = {result['q_tar']['ux'].shape if result['q_tar'] else None}")
|
||||
|
||||
for key in ["dq_blk", "dq_ctl", "dq_tar", "dq_tar_minus_blk"]:
|
||||
dq = result.get(key)
|
||||
if dq is not None:
|
||||
print(f" {key}: mean(ux)={dq['ux'].mean():.6f}, "
|
||||
f"mean(uy)={dq['uy'].mean():.6f}")
|
||||
else:
|
||||
print(f" {key}: None")
|
||||
|
||||
# -- 4. Verify dict_to_field_matrix --
|
||||
print("\n--- 4. Testing dict_to_field_matrix ---")
|
||||
Q = dict_to_field_matrix(pinball)
|
||||
print(f" pinball snapshot matrix: {Q.shape} "
|
||||
f"(expect ({2 * NX * NY}, 96))")
|
||||
assert Q.shape == (2 * NX * NY, 96), f"Unexpected shape: {Q.shape}"
|
||||
print(f" Q range: [{Q.min():.6f}, {Q.max():.6f}]")
|
||||
|
||||
# -- 5. Test steady_cloak --
|
||||
print("\n--- 5. Testing steady_cloak correction ---")
|
||||
result_sc = compute_correction("steady_cloak")
|
||||
print(f" steady_cloak N = {result_sc['N']}")
|
||||
if result_sc.get("dq_blk") is not None:
|
||||
print(f" dq_blk (cloak-pinball) ux_mean: "
|
||||
f"{result_sc['dq_blk']['ux'].mean():.6f}")
|
||||
|
||||
print("\nAll tests passed.")
|
||||
@ -1,216 +0,0 @@
|
||||
"""Minimal correction-field CCD: POD + force/action CCD on dq_ctl.
|
||||
|
||||
Simplified version — processes only illusion_0.75L and illusion_1.0L.
|
||||
No LOCO validation (separate step). Outputs CCD results and overlaps.
|
||||
|
||||
Usage:
|
||||
conda run -n pycuda_3_10 python correction_analysis/decompose_corrections.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
|
||||
_SRC = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
if _SRC not in sys.path:
|
||||
sys.path.insert(0, _SRC)
|
||||
|
||||
from CCD_analysis.configs import DATA_DIR
|
||||
from CCD_analysis.utils.resampling import (
|
||||
compute_pod, cumulative_energy, e95_index,
|
||||
compute_reduced_ccd, make_force_obs,
|
||||
)
|
||||
from CCD_analysis.correction_analysis.compute_correction_fields import (
|
||||
compute_correction, dict_to_field_matrix,
|
||||
)
|
||||
|
||||
R_CANDIDATES = [6, 8, 10]
|
||||
CCD_Q = 6
|
||||
SCENE_TYPES = ["illusion_0.75L", "illusion_1.0L", "steady_cloak"]
|
||||
|
||||
|
||||
def compute_modal_overlap(W_dict, scene_label, r, obs_label="force_fy"):
|
||||
keys = [k for k in W_dict
|
||||
if scene_label in k and f"_{obs_label}_r{r}" in k]
|
||||
overlaps = []
|
||||
for i, ka in enumerate(keys):
|
||||
for kb in keys[i + 1:]:
|
||||
Wa, Wb = W_dict[ka], W_dict[kb]
|
||||
n = min(Wa.shape[1], Wb.shape[1], 5)
|
||||
for k in range(n):
|
||||
ov = float(abs(
|
||||
Wa[:, k] / (np.linalg.norm(Wa[:, k]) + 1e-12) @
|
||||
Wb[:, k] / (np.linalg.norm(Wb[:, k]) + 1e-12)
|
||||
))
|
||||
overlaps.append({
|
||||
"case_a": ka.split(f"_{obs_label}_r{r}")[0],
|
||||
"case_b": kb.split(f"_{obs_label}_r{r}")[0],
|
||||
"mode": k + 1,
|
||||
"O": ov,
|
||||
})
|
||||
return overlaps
|
||||
|
||||
|
||||
def _scene_to_target_name(scene_type):
|
||||
if "illusion" in scene_type:
|
||||
parts = scene_type.split("_")
|
||||
if len(parts) >= 2:
|
||||
return f"target_cylinder_{parts[1]}"
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 60, flush=True)
|
||||
print("Correction-field CCD (Phase 3) — dq_ctl", flush=True)
|
||||
print("=" * 60, flush=True)
|
||||
|
||||
out_dir = os.path.join(DATA_DIR, "ccd")
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
all_results = {}
|
||||
W_dict = {}
|
||||
|
||||
# Load correction fields
|
||||
print("\n--- Loading correction fields ---", flush=True)
|
||||
cache = {}
|
||||
for st in SCENE_TYPES:
|
||||
t0 = time.time()
|
||||
try:
|
||||
corr = compute_correction(st)
|
||||
cache[st] = corr
|
||||
dq = corr["dq_ctl"]
|
||||
if dq is not None:
|
||||
print(f" {st}: dq_ctl {dq['ux'].shape[0]} frames, "
|
||||
f"forces={'✓' if dq['forces'] is not None else '✗'}, "
|
||||
f"actions={'✓' if dq['actions'] is not None else '✗'}, "
|
||||
f"{time.time()-t0:.1f}s", flush=True)
|
||||
except Exception as e:
|
||||
print(f" {st}: FAILED — {e}", flush=True)
|
||||
|
||||
# CCD on dq_ctl
|
||||
for st in SCENE_TYPES:
|
||||
corr = cache.get(st)
|
||||
if corr is None:
|
||||
continue
|
||||
dq_ctl = corr["dq_ctl"]
|
||||
dq_tar = corr["dq_tar"]
|
||||
if dq_ctl is None:
|
||||
continue
|
||||
|
||||
diam = corr.get("diam")
|
||||
print(f"\n--- {st} (diam={diam}) ---", flush=True)
|
||||
|
||||
Q_ctl = dict_to_field_matrix(dq_ctl)
|
||||
N = Q_ctl.shape[1]
|
||||
print(f" dq_ctl: shape={Q_ctl.shape}", flush=True)
|
||||
|
||||
# POD: target-only (dq_tar) or direct (dq_ctl)
|
||||
if dq_tar is not None:
|
||||
Q_tar = dict_to_field_matrix(dq_tar)
|
||||
print(f" dq_tar: shape={Q_tar.shape}", flush=True)
|
||||
mf_tar, modes_tar, sv_tar, coeffs_tar = compute_pod(Q_tar)
|
||||
# Project dq_ctl into target basis
|
||||
dc = dq_ctl
|
||||
q_proj = np.column_stack([
|
||||
np.concatenate([dc["ux"][s].ravel(), dc["uy"][s].ravel()])
|
||||
for s in range(N)
|
||||
])
|
||||
a_ctl = modes_tar.T @ (q_proj - mf_tar[:, None]).astype(np.float64)
|
||||
a_tar = coeffs_tar
|
||||
print(f" POD: target-only basis (E95={e95_index(cumulative_energy(sv_tar))})", flush=True)
|
||||
else:
|
||||
mf, modes, sv, coeffs = compute_pod(Q_ctl)
|
||||
a_ctl = coeffs
|
||||
a_tar = None
|
||||
print(f" POD: direct dq_ctl (E95={e95_index(cumulative_energy(sv))})", flush=True)
|
||||
|
||||
for r in R_CANDIDATES:
|
||||
a_r = a_ctl[:r, :]
|
||||
Nv = a_r.shape[1]
|
||||
print(f"\n r={r}: N={Nv}", flush=True)
|
||||
|
||||
# Force-CCD
|
||||
frc = dq_ctl.get("forces")
|
||||
if frc is not None:
|
||||
for fmode, flabel in [("fy","force_fy"), ("fx","force_fx")]:
|
||||
y = make_force_obs(frc, st, mode=fmode)[:, :Nv]
|
||||
W, sig, _, _, _, _ = compute_reduced_ccd(a_r, y, Q_delay=CCD_Q)
|
||||
en = cumulative_energy(sig)
|
||||
m80 = int(np.searchsorted(en, 0.80) + 1) if len(en) > 0 else 0
|
||||
key = f"{st}_dqctl_{flabel}_r{r}"
|
||||
W_dict[key] = W
|
||||
all_results[key] = {
|
||||
"scene": st, "diam": diam, "obs": flabel, "r": r,
|
||||
"m80": m80, "N": sig.size,
|
||||
"sigma_top3": [float(sig[i]) for i in range(min(3,len(sig)))],
|
||||
}
|
||||
if fmode == "fy":
|
||||
print(f" {key}: m80={m80} s1={float(sig[0]):.4f}", flush=True)
|
||||
|
||||
# Action-CCD (illusion only)
|
||||
act = dq_ctl.get("actions")
|
||||
if act is not None:
|
||||
y_a = act.T[:, :Nv]
|
||||
W, sig, _, _, _, _ = compute_reduced_ccd(a_r, y_a, Q_delay=CCD_Q)
|
||||
en = cumulative_energy(sig)
|
||||
m80 = int(np.searchsorted(en, 0.80) + 1) if len(en) > 0 else 0
|
||||
key = f"{st}_dqctl_action_r{r}"
|
||||
W_dict[key] = W
|
||||
all_results[key] = {
|
||||
"scene": st, "diam": diam, "obs": "action", "r": r,
|
||||
"m80": m80, "N": sig.size,
|
||||
"sigma_top3": [float(sig[i]) for i in range(min(3,len(sig)))],
|
||||
}
|
||||
print(f" {key}: m80={m80} s1={float(sig[0]):.4f}", flush=True)
|
||||
|
||||
# Target force-CCD reference (if available)
|
||||
if dq_tar is not None and a_tar is not None:
|
||||
a_tr = a_tar[:r, :Nv]
|
||||
frc_t = dq_tar.get("forces")
|
||||
if frc_t is not None:
|
||||
tname = _scene_to_target_name(st) or f"{st}_tar"
|
||||
y_t = make_force_obs(frc_t[:Nv], tname, mode="fy")
|
||||
Wt, sig_t, _, _, _, _ = compute_reduced_ccd(a_tr, y_t, Q_delay=CCD_Q)
|
||||
kt = f"{st}_dqtar_force_fy_r{r}"
|
||||
W_dict[kt] = Wt
|
||||
all_results[kt] = {
|
||||
"scene": st, "diam": diam, "obs": "force_fy_tar", "r": r,
|
||||
"m80": int(np.searchsorted(cumulative_energy(sig_t), 0.80)+1) if len(sig_t) > 0 else 0,
|
||||
"N": sig_t.size,
|
||||
"sigma_top3": [float(sig_t[i]) for i in range(min(3,len(sig_t)))],
|
||||
}
|
||||
# Overlap: dq_ctl vs dq_tar
|
||||
ck = f"{st}_dqctl_force_fy_r{r}"
|
||||
if ck in W_dict:
|
||||
Wc = W_dict[ck]
|
||||
n = min(Wc.shape[1], Wt.shape[1], 5)
|
||||
for k in range(n):
|
||||
ov = float(abs(
|
||||
Wc[:, k] / (np.linalg.norm(Wc[:, k])+1e-12) @
|
||||
Wt[:, k] / (np.linalg.norm(Wt[:, k])+1e-12)
|
||||
))
|
||||
all_results[f"{st}_O_dqctl_vs_dqtar_r{r}_mode{k+1}"] = {
|
||||
"overlap": ov, "mode": k+1, "r": r
|
||||
}
|
||||
if k == 0:
|
||||
print(f" O(dqctl, dqtar) mode1={ov:.4f}", flush=True)
|
||||
|
||||
# Overlap dqctl_target vs dqctl_illusion at r=6
|
||||
print(f" Modal overlaps r=6:", flush=True)
|
||||
ovs = compute_modal_overlap(W_dict, st, 6, "force_fy")
|
||||
for ov in ovs:
|
||||
print(f" O({ov['case_a']}, {ov['case_b']}) mode{ov['mode']} = {ov['O']:.4f}", flush=True)
|
||||
|
||||
# Save
|
||||
ccd_path = os.path.join(out_dir, "correction_ccd_results.json")
|
||||
with open(ccd_path, "w") as f:
|
||||
json.dump(all_results, f, indent=2)
|
||||
print(f"\nSaved {len(all_results)} entries to {ccd_path}", flush=True)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@ -1,409 +0,0 @@
|
||||
"""Phase 2: Baseline diagnostics — mean/RMS/vorticity + zone metrics for correction fields.
|
||||
|
||||
For each available scene type:
|
||||
1. Mean/RMS/vorticity of dq_blk, dq_ctl, dq_tar
|
||||
2. Three-zone spatial metrics
|
||||
3. Figures saved to data/figures/
|
||||
|
||||
Usage:
|
||||
conda run -n pycuda_3_10 python correction_analysis/diagnose_corrections.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
import matplotlib
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
_SRC = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
if _SRC not in sys.path:
|
||||
sys.path.insert(0, _SRC)
|
||||
|
||||
from CCD_analysis.configs import DATA_DIR, NX, NY, L0, CENTER_Y
|
||||
from CCD_analysis.utils.resampling import load_aligned_fields
|
||||
from CCD_analysis.correction_analysis.compute_correction_fields import (
|
||||
compute_correction, dict_to_field_matrix,
|
||||
)
|
||||
|
||||
FIG_DIR = os.path.join(DATA_DIR, "figures")
|
||||
os.makedirs(FIG_DIR, exist_ok=True)
|
||||
|
||||
# Scene types to process (karman will be added when data is ready)
|
||||
SCENE_TYPES = [
|
||||
"illusion_0.75L",
|
||||
"illusion_1.0L",
|
||||
"illusion_1.5L",
|
||||
"steady_cloak",
|
||||
"karman_re100",
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Three-zone masks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def define_zones_illusion() -> dict:
|
||||
"""Define three-zone masks for illusion layout (sensors at x=30*L0)."""
|
||||
zones = {}
|
||||
# Zone 1: near-body — envelope around pinball cylinders
|
||||
# pinball front at x=380 (19*L0), rear at x=406 (20.3*L0)
|
||||
# Extend to x=[350, 500], full height
|
||||
mask = np.zeros((NY, NX), dtype=bool)
|
||||
mask[:, 350:500] = True
|
||||
zones["near_body"] = mask
|
||||
|
||||
# Zone 2: body-connected near wake — immediate downstream
|
||||
mask = np.zeros((NY, NX), dtype=bool)
|
||||
mask[:, 500:700] = True
|
||||
zones["body_wake"] = mask
|
||||
|
||||
# Zone 3: downstream sensor zone — around sensors at x=600
|
||||
mask = np.zeros((NY, NX), dtype=bool)
|
||||
mask[:, 580:650] = True
|
||||
zones["sensor_zone"] = mask
|
||||
|
||||
return zones
|
||||
|
||||
|
||||
def define_zones_karman() -> dict:
|
||||
"""Define three-zone masks for Karman layout (sensors at x=40*L0=800)."""
|
||||
zones = {}
|
||||
# Zone 1: near-body — around pinball at x=600
|
||||
mask = np.zeros((NY, NX), dtype=bool)
|
||||
mask[:, 580:720] = True
|
||||
zones["near_body"] = mask
|
||||
|
||||
# Zone 2: body-connected near wake
|
||||
mask = np.zeros((NY, NX), dtype=bool)
|
||||
mask[:, 720:850] = True
|
||||
zones["body_wake"] = mask
|
||||
|
||||
# Zone 3: downstream sensor zone — around sensors at x=800
|
||||
mask = np.zeros((NY, NX), dtype=bool)
|
||||
mask[:, 780:850] = True
|
||||
zones["sensor_zone"] = mask
|
||||
|
||||
return zones
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Field computation helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def mean_field(ux: np.ndarray, uy: np.ndarray) -> tuple:
|
||||
"""Compute mean velocity field from snapshots."""
|
||||
return np.mean(ux, axis=0), np.mean(uy, axis=0)
|
||||
|
||||
|
||||
def rms_field(ux: np.ndarray, uy: np.ndarray) -> np.ndarray:
|
||||
"""Compute RMS magnitude field."""
|
||||
ux_rms = np.std(ux, axis=0)
|
||||
uy_rms = np.std(uy, axis=0)
|
||||
return np.sqrt(ux_rms**2 + uy_rms**2)
|
||||
|
||||
|
||||
def vorticity_field(ux: np.ndarray, uy: np.ndarray) -> np.ndarray:
|
||||
"""Compute mean z-vorticity from mean velocity field."""
|
||||
ux_m = np.mean(ux, axis=0)
|
||||
uy_m = np.mean(uy, axis=0)
|
||||
return np.gradient(uy_m, axis=1) - np.gradient(ux_m, axis=0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Zone metrics
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def zone_metrics(dq: dict, zones: dict, label: str) -> dict:
|
||||
"""Compute per-zone metrics for a correction field dict.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
dq : dict with 'ux' (N, NY, NX), 'uy' (N, NY, NX)
|
||||
zones : dict of (NY, NX) boolean masks
|
||||
label : str for printing
|
||||
|
||||
Returns
|
||||
-------
|
||||
metrics : dict with per-zone stats
|
||||
"""
|
||||
if dq is None:
|
||||
print(f" {label}: None, skipping zone metrics")
|
||||
return {}
|
||||
|
||||
ux = dq["ux"]
|
||||
uy = dq["uy"]
|
||||
N = ux.shape[0]
|
||||
|
||||
# Mean kinetic energy field (per snapshot, averaged)
|
||||
ke_field = 0.5 * np.mean(ux**2 + uy**2, axis=0) # (NY, NX)
|
||||
|
||||
# Vorticity field (from mean velocity)
|
||||
ux_m, uy_m = mean_field(ux, uy)
|
||||
vor = np.gradient(uy_m, axis=1) - np.gradient(ux_m, axis=0)
|
||||
enstrophy_field = vor**2
|
||||
|
||||
metrics = {}
|
||||
total_ke = ke_field.sum()
|
||||
|
||||
for zname, zmask in zones.items():
|
||||
n_pts = zmask.sum()
|
||||
if n_pts == 0:
|
||||
continue
|
||||
|
||||
zone_ke = ke_field[zmask].mean()
|
||||
zone_enstrophy = enstrophy_field[zmask].mean()
|
||||
zone_ke_frac = ke_field[zmask].sum() / total_ke if total_ke > 0 else 0.0
|
||||
|
||||
# Centreline asymmetry: ux mean above vs below centreline
|
||||
cy = int(CENTER_Y)
|
||||
y_indices = np.where(zmask.any(axis=1))[0]
|
||||
if len(y_indices) > 0:
|
||||
y_min, y_max = y_indices.min(), y_indices.max()
|
||||
above = zmask[y_min:cy, :].sum()
|
||||
below = zmask[cy:y_max, :].sum()
|
||||
else:
|
||||
above = below = 1
|
||||
|
||||
mask_correction = f"_{label.replace(' ', '_')}"
|
||||
|
||||
metrics[zname] = {
|
||||
"n_points": int(n_pts),
|
||||
"mean_KE": float(zone_ke),
|
||||
"mean_enstrophy": float(zone_enstrophy),
|
||||
"KE_fraction": float(zone_ke_frac),
|
||||
}
|
||||
|
||||
print(f" {zname:15s}: KE={zone_ke:.6e}, "
|
||||
f"ens={zone_enstrophy:.6e}, "
|
||||
f"KE_frac={zone_ke_frac:.4f}")
|
||||
|
||||
return metrics
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plotting helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def plot_mean_rms(dq: dict, label: str, prefix: str, zones: Optional[dict] = None):
|
||||
"""Plot mean ux, mean uy, RMS magnitude for a correction field."""
|
||||
if dq is None:
|
||||
return
|
||||
|
||||
ux = dq["ux"]
|
||||
uy = dq["uy"]
|
||||
ux_m, uy_m = mean_field(ux, uy)
|
||||
rms = rms_field(ux, uy)
|
||||
|
||||
fig, axes = plt.subplots(1, 3, figsize=(18, 5))
|
||||
|
||||
extent = (0, NX - 1, 0, NY - 1)
|
||||
|
||||
# Mean ux
|
||||
vmax = max(abs(ux_m).max(), 1e-12)
|
||||
im0 = axes[0].imshow(ux_m, cmap="RdBu_r", vmin=-vmax, vmax=vmax,
|
||||
origin="lower", aspect="equal", extent=extent)
|
||||
axes[0].set_title(f"{label}: mean ux")
|
||||
plt.colorbar(im0, ax=axes[0], fraction=0.046)
|
||||
|
||||
# Mean uy
|
||||
vmax = max(abs(uy_m).max(), 1e-12)
|
||||
im1 = axes[1].imshow(uy_m, cmap="RdBu_r", vmin=-vmax, vmax=vmax,
|
||||
origin="lower", aspect="equal", extent=extent)
|
||||
axes[1].set_title(f"{label}: mean uy")
|
||||
plt.colorbar(im1, ax=axes[1], fraction=0.046)
|
||||
|
||||
# RMS magnitude
|
||||
im2 = axes[2].imshow(rms, cmap="viridis", origin="lower",
|
||||
aspect="equal", extent=extent)
|
||||
axes[2].set_title(f"{label}: RMS magnitude")
|
||||
plt.colorbar(im2, ax=axes[2], fraction=0.046)
|
||||
|
||||
# Overlay zone boundaries if provided
|
||||
if zones is not None:
|
||||
# simple boundary: first/last column of each zone mask
|
||||
for zname, zmask in zones.items():
|
||||
for ax in axes:
|
||||
# Find leftmost and rightmost columns with True
|
||||
cols = np.where(zmask.any(axis=0))[0]
|
||||
if len(cols) > 1:
|
||||
ax.axvline(cols[0], color="white", linewidth=0.5, alpha=0.5)
|
||||
ax.axvline(cols[-1], color="white", linewidth=0.5, alpha=0.5)
|
||||
|
||||
plt.tight_layout()
|
||||
path = os.path.join(FIG_DIR, f"{prefix}_{label.replace(' ', '_')}.png")
|
||||
fig.savefig(path, dpi=150)
|
||||
plt.close(fig)
|
||||
print(f" Saved: {path}", flush=True)
|
||||
|
||||
|
||||
def plot_vorticity(dq: dict, label: str, prefix: str):
|
||||
"""Plot mean vorticity field."""
|
||||
if dq is None:
|
||||
return
|
||||
|
||||
ux = dq["ux"]
|
||||
uy = dq["uy"]
|
||||
vor = vorticity_field(ux, uy)
|
||||
|
||||
fig, ax = plt.subplots(figsize=(10, 4))
|
||||
vmax = max(np.percentile(abs(vor), 99), 1e-12)
|
||||
im = ax.imshow(vor, cmap="RdBu_r", vmin=-vmax, vmax=vmax,
|
||||
origin="lower", aspect="equal",
|
||||
extent=(0, NX - 1, 0, NY - 1))
|
||||
ax.set_title(f"{label}: mean vorticity")
|
||||
plt.colorbar(im, ax=ax, fraction=0.046, label=r"$\omega_z$")
|
||||
plt.tight_layout()
|
||||
path = os.path.join(FIG_DIR, f"{prefix}_vorticity_{label.replace(' ', '_')}.png")
|
||||
fig.savefig(path, dpi=150)
|
||||
plt.close(fig)
|
||||
print(f" Saved: {path}", flush=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def run():
|
||||
print("=" * 60, flush=True)
|
||||
print("Phase 2: Baseline Diagnostics (correction fields)", flush=True)
|
||||
print("=" * 60, flush=True)
|
||||
|
||||
zones_ill = define_zones_illusion()
|
||||
zones_karman = define_zones_karman()
|
||||
|
||||
all_metrics = {}
|
||||
|
||||
for scene_type in SCENE_TYPES:
|
||||
print(f"\n{'=' * 60}", flush=True)
|
||||
print(f"Scene: {scene_type}", flush=True)
|
||||
print(f"{'=' * 60}", flush=True)
|
||||
|
||||
try:
|
||||
corr = compute_correction(scene_type)
|
||||
except (FileNotFoundError, KeyError) as e:
|
||||
print(f" SKIP: {e}", flush=True)
|
||||
continue
|
||||
|
||||
if corr["N"] == 0:
|
||||
print(f" SKIP: no valid data (N=0)", flush=True)
|
||||
continue
|
||||
|
||||
# Determine which zones to use
|
||||
is_illusion = "illusion" in scene_type
|
||||
zones = zones_ill if is_illusion else zones_ill # zones_karman for later
|
||||
|
||||
for dq_key, dq_label in [
|
||||
("dq_blk", "dq_blk (pinball blockage)"),
|
||||
("dq_ctl", "dq_ctl (control correction)"),
|
||||
]:
|
||||
dq = corr.get(dq_key)
|
||||
if dq is None:
|
||||
continue
|
||||
|
||||
prefix = f"corr_{scene_type}"
|
||||
plot_mean_rms(dq, dq_label, prefix, zones)
|
||||
plot_vorticity(dq, dq_label, prefix)
|
||||
|
||||
print(f" Zone metrics for {dq_label}:", flush=True)
|
||||
metrics = zone_metrics(dq, zones, dq_label)
|
||||
all_metrics[f"{scene_type}_{dq_key}"] = metrics
|
||||
|
||||
# For illusion, also plot dq_tar if available
|
||||
if dq_key == "dq_ctl" and is_illusion:
|
||||
dq_tar = corr.get("dq_tar")
|
||||
if dq_tar is not None:
|
||||
plot_mean_rms(dq_tar, "dq_tar (target correction)", prefix, zones)
|
||||
plot_vorticity(dq_tar, "dq_tar (target correction)", prefix)
|
||||
|
||||
# dq_ctl vs dq_tar side-by-side comparison
|
||||
fig, axes = plt.subplots(2, 2, figsize=(14, 8))
|
||||
extent = (0, NX - 1, 0, NY - 1)
|
||||
|
||||
# Row 0: mean ux for dq_ctl and dq_tar
|
||||
ux_ctl, _ = mean_field(dq["ux"], dq["uy"])
|
||||
ux_tar, _ = mean_field(dq_tar["ux"], dq_tar["uy"])
|
||||
vmax = max(abs(ux_ctl).max(), abs(ux_tar).max(), 1e-12)
|
||||
|
||||
im = axes[0, 0].imshow(ux_ctl, cmap="RdBu_r", vmin=-vmax, vmax=vmax,
|
||||
origin="lower", aspect="equal", extent=extent)
|
||||
axes[0, 0].set_title("dq_ctl mean ux")
|
||||
plt.colorbar(im, ax=axes[0, 0], fraction=0.046)
|
||||
|
||||
im = axes[0, 1].imshow(ux_tar, cmap="RdBu_r", vmin=-vmax, vmax=vmax,
|
||||
origin="lower", aspect="equal", extent=extent)
|
||||
axes[0, 1].set_title("dq_tar mean ux")
|
||||
plt.colorbar(im, ax=axes[0, 1], fraction=0.046)
|
||||
|
||||
# Row 1: RMS
|
||||
rms_ctl = rms_field(dq["ux"], dq["uy"])
|
||||
rms_tar = rms_field(dq_tar["ux"], dq_tar["uy"])
|
||||
rmax = max(rms_ctl.max(), rms_tar.max(), 1e-12)
|
||||
|
||||
im = axes[1, 0].imshow(rms_ctl, cmap="viridis", vmin=0, vmax=rmax,
|
||||
origin="lower", aspect="equal", extent=extent)
|
||||
axes[1, 0].set_title("dq_ctl RMS")
|
||||
plt.colorbar(im, ax=axes[1, 0], fraction=0.046)
|
||||
|
||||
im = axes[1, 1].imshow(rms_tar, cmap="viridis", vmin=0, vmax=rmax,
|
||||
origin="lower", aspect="equal", extent=extent)
|
||||
axes[1, 1].set_title("dq_tar RMS")
|
||||
plt.colorbar(im, ax=axes[1, 1], fraction=0.046)
|
||||
|
||||
plt.suptitle(f"{scene_type}: dq_ctl vs dq_tar comparison")
|
||||
plt.tight_layout()
|
||||
path = os.path.join(FIG_DIR, f"corr_{scene_type}_ctl_vs_tar.png")
|
||||
fig.savefig(path, dpi=150)
|
||||
plt.close(fig)
|
||||
print(f" Saved: {path}", flush=True)
|
||||
|
||||
# Steady cloak specific: dq_ctl + dq_blk check
|
||||
if scene_type == "steady_cloak":
|
||||
dq_b = corr.get("dq_blk")
|
||||
dq_c = corr.get("dq_ctl")
|
||||
if dq_b is not None and dq_c is not None:
|
||||
ux_b = np.mean(dq_b["ux"], axis=0)
|
||||
ux_c = np.mean(dq_c["ux"], axis=0)
|
||||
ux_cancel = ux_c + ux_b
|
||||
|
||||
fig, axes = plt.subplots(1, 3, figsize=(18, 4))
|
||||
extent = (0, NX - 1, 0, NY - 1)
|
||||
vmax = max(abs(ux_b).max(), abs(ux_c).max(), abs(ux_cancel).max(), 1e-12)
|
||||
|
||||
im = axes[0].imshow(ux_b, cmap="RdBu_r", vmin=-vmax, vmax=vmax,
|
||||
origin="lower", aspect="equal", extent=extent)
|
||||
axes[0].set_title("dq_blk mean ux (blockage)")
|
||||
plt.colorbar(im, ax=axes[0], fraction=0.046)
|
||||
|
||||
im = axes[1].imshow(ux_c, cmap="RdBu_r", vmin=-vmax, vmax=vmax,
|
||||
origin="lower", aspect="equal", extent=extent)
|
||||
axes[1].set_title("dq_ctl mean ux (correction)")
|
||||
plt.colorbar(im, ax=axes[1], fraction=0.046)
|
||||
|
||||
im = axes[2].imshow(ux_cancel, cmap="RdBu_r", vmin=-vmax, vmax=vmax,
|
||||
origin="lower", aspect="equal", extent=extent)
|
||||
axes[2].set_title("dq_ctl + dq_blk (cancel test)")
|
||||
plt.colorbar(im, ax=axes[2], fraction=0.046)
|
||||
|
||||
plt.suptitle(f"Steady cloak: cancellation test")
|
||||
plt.tight_layout()
|
||||
path = os.path.join(FIG_DIR, "steady_cloak_cancel_test.png")
|
||||
fig.savefig(path, dpi=150)
|
||||
plt.close(fig)
|
||||
print(f" Saved: {path}", flush=True)
|
||||
|
||||
# Save zone metrics
|
||||
metrics_path = os.path.join(DATA_DIR, "ccd", "zone_metrics.json")
|
||||
with open(metrics_path, "w") as f:
|
||||
json.dump(all_metrics, f, indent=2)
|
||||
print(f"\nZone metrics saved to {metrics_path}", flush=True)
|
||||
print("\nDone.", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
@ -1,153 +0,0 @@
|
||||
"""Load legacy fields.npz format for steady scenes (steady_cloak, target_channel).
|
||||
|
||||
Converts to the same convention as load_aligned_fields():
|
||||
- Transposes fields from (N, NX, NY) -> (N, NY, NX)
|
||||
- Loads telemetry from sensors.npz
|
||||
- Returns dict with identical key structure
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from CCD_analysis.configs import DATA_DIR, NX, NY
|
||||
|
||||
|
||||
def load_legacy_steady(scene_name: str) -> dict:
|
||||
"""Load steady scene from legacy fields.npz format.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
scene_name : str — one of 'steady_cloak' or 'target_channel'
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict with same keys as load_aligned_fields():
|
||||
ux, uy : (N, NY, NX) ndarray
|
||||
forces : None (no force telemetry in legacy sensors)
|
||||
sensors : (N, 6) ndarray or None
|
||||
actions : None (open-loop)
|
||||
meta : dict with scene info
|
||||
step_indices : list of int
|
||||
"""
|
||||
scene_dir = os.path.join(DATA_DIR, scene_name, scene_name)
|
||||
if not os.path.isdir(scene_dir):
|
||||
raise FileNotFoundError(f"Scene directory not found: {scene_dir}")
|
||||
|
||||
# -- fields.npz (native simulation order: NX first) --
|
||||
fields_path = os.path.join(scene_dir, "fields.npz")
|
||||
if not os.path.isfile(fields_path):
|
||||
raise FileNotFoundError(f"{fields_path} not found")
|
||||
|
||||
fd = np.load(fields_path)
|
||||
ux_raw = fd["ux"] # (N, NX, NY)
|
||||
uy_raw = fd["uy"]
|
||||
N = ux_raw.shape[0]
|
||||
fd.close()
|
||||
|
||||
# Transpose (N, NX, NY) -> (N, NY, NX) to match load_aligned_fields convention
|
||||
ux = np.ascontiguousarray(ux_raw.transpose(0, 2, 1))
|
||||
uy = np.ascontiguousarray(uy_raw.transpose(0, 2, 1))
|
||||
|
||||
# -- sensors.npz (telemetry) --
|
||||
sensors_path = os.path.join(scene_dir, "sensors.npz")
|
||||
sensors = None
|
||||
if os.path.isfile(sensors_path):
|
||||
sd = np.load(sensors_path)
|
||||
if "sensors" in sd:
|
||||
sensors = sd["sensors"] # (N, 6)
|
||||
assert sensors.shape[0] == N, (
|
||||
f"sensors ({sensors.shape[0]}) != fields ({N})"
|
||||
)
|
||||
sd.close()
|
||||
|
||||
# -- meta.json --
|
||||
meta = {"scene": scene_name, "scene_id": scene_name, "source": "legacy_steady"}
|
||||
meta_path = os.path.join(scene_dir, "meta.json")
|
||||
if os.path.isfile(meta_path):
|
||||
with open(meta_path) as f:
|
||||
meta.update(json.load(f))
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"ux": ux,
|
||||
"uy": uy,
|
||||
"forces": None, # no forces in legacy steady telemetry
|
||||
"actions": None, # open-loop
|
||||
"sensors": sensors,
|
||||
"meta": meta,
|
||||
"step_indices": list(range(N)),
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Diagnostic helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _print_field_stats(label: str, ux: np.ndarray, uy: np.ndarray) -> None:
|
||||
"""Print mean velocity statistics for a set of fields."""
|
||||
ux_mean = ux.mean()
|
||||
uy_mean = uy.mean()
|
||||
ux_std = ux.std()
|
||||
uy_std = uy.std()
|
||||
print(f" {label}:")
|
||||
print(f" shape = {ux.shape}")
|
||||
print(f" ux_mean = {ux_mean:.6f} (expect ~U0={0.01} for channel)")
|
||||
print(f" uy_mean = {uy_mean:.6f} (expect near 0)")
|
||||
print(f" ux_rms = {ux_std:.6f}")
|
||||
print(f" uy_rms = {uy_std:.6f}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main (test)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("=" * 60)
|
||||
print("Testing load_legacy_steady()")
|
||||
print("=" * 60)
|
||||
|
||||
for scene in ["steady_cloak", "target_channel"]:
|
||||
print(f"\n--- {scene} ---")
|
||||
data = load_legacy_steady(scene)
|
||||
ux = data["ux"]
|
||||
uy = data["uy"]
|
||||
sensors = data["sensors"]
|
||||
N = ux.shape[0]
|
||||
|
||||
_print_field_stats(scene, ux, uy)
|
||||
|
||||
print(f" N_frames = {N}")
|
||||
print(f" NY x NX = {ux.shape[1]} x {ux.shape[2]}")
|
||||
print(f" sensors = {sensors.shape if sensors is not None else None}")
|
||||
print(f" forces = {data['forces']}")
|
||||
print(f" actions = {data['actions']}")
|
||||
print(f" step_range = [{data['step_indices'][0]}, {data['step_indices'][-1]}]")
|
||||
|
||||
# Physical reasonableness checks
|
||||
ux_max = ux.max()
|
||||
uy_max = abs(uy).max()
|
||||
print(f" ux_max = {ux_max:.4f} (expect order 0.01)")
|
||||
print(f" |uy|_max = {uy_max:.4f} (expect < ux_max)")
|
||||
print(f" metadata = {list(data['meta'].keys())}")
|
||||
|
||||
# Quick: verify convention matches load_aligned_fields
|
||||
print("\n--- Convention check: transpose correctness ---")
|
||||
# Load raw from steady_cloak to verify ravel order
|
||||
raw = np.load(
|
||||
os.path.join(DATA_DIR, "steady_cloak", "steady_cloak", "fields.npz")
|
||||
)
|
||||
raw_ux = raw["ux"][0] # (NX, NY)
|
||||
loaded = load_legacy_steady("steady_cloak")
|
||||
loaded_ux = loaded["ux"][0] # (NY, NX)
|
||||
|
||||
# raw_ux[NX, NY] should == loaded_ux[NY, NX] after transpose
|
||||
match = np.allclose(raw_ux.T, loaded_ux)
|
||||
print(f" Transpose (raw.T == loaded): {match}")
|
||||
raw.close()
|
||||
|
||||
print("\nDone.")
|
||||
@ -1,366 +0,0 @@
|
||||
"""1.5L correction-field CCD: force-CCD, action-CCD, signature-CCD on dq_ctl.
|
||||
|
||||
Extends the Phase 2 pipeline to the 1.5L "special mechanism" case.
|
||||
Target-only POD basis, Q_delay=6, r=[6, 8, 10].
|
||||
|
||||
Usage:
|
||||
conda run -n pycuda_3_10 python correction_analysis/run_15L_correction.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
|
||||
_SRC = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
if _SRC not in sys.path:
|
||||
sys.path.insert(0, _SRC)
|
||||
|
||||
from CCD_analysis.configs import DATA_DIR, NX, NY, CENTER_Y
|
||||
from CCD_analysis.utils.resampling import (
|
||||
compute_pod, cumulative_energy, e95_index,
|
||||
compute_reduced_ccd, make_force_obs,
|
||||
)
|
||||
from CCD_analysis.correction_analysis.compute_correction_fields import (
|
||||
compute_correction, dict_to_field_matrix,
|
||||
)
|
||||
|
||||
R_CANDIDATES = [6, 8, 10]
|
||||
CCD_Q = 6
|
||||
SCENE_TYPE = "illusion_1.5L"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Zone masks for illusion layout (sensors at x=30*L0=600)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _define_zones() -> dict:
|
||||
"""Define body_wake and sensor_zone masks for 1.5L illusion."""
|
||||
zones = {}
|
||||
# body_wake: immediate downstream of pinball, x=[500, 700)
|
||||
mask_bw = np.zeros((NY, NX), dtype=bool)
|
||||
mask_bw[:, 500:700] = True
|
||||
zones["body_wake"] = mask_bw
|
||||
# sensor_zone: around sensors at x=600, x=[580, 650)
|
||||
mask_sz = np.zeros((NY, NX), dtype=bool)
|
||||
mask_sz[:, 580:650] = True
|
||||
zones["sensor_zone"] = mask_sz
|
||||
return zones
|
||||
|
||||
|
||||
def _zone_ke_ratio(dq: dict, zones: dict) -> dict:
|
||||
"""Compute correction energy ratio body_wake / sensor_zone."""
|
||||
ux, uy = dq["ux"], dq["uy"]
|
||||
ke_field = 0.5 * np.mean(ux**2 + uy ** 2, axis=0) # (NY, NX)
|
||||
body_ke = ke_field[zones["body_wake"]].sum()
|
||||
sensor_ke = ke_field[zones["sensor_zone"]].sum()
|
||||
ratio = body_ke / sensor_ke if sensor_ke > 0 else float("inf")
|
||||
return {
|
||||
"body_wake_KE": float(body_ke),
|
||||
"sensor_zone_KE": float(sensor_ke),
|
||||
"ratio_bw_over_sz": float(ratio),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Signature-CCD helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def compute_tau_corr(a_ctl: np.ndarray, e_s: np.ndarray,
|
||||
max_lag: int = 12) -> int:
|
||||
"""Find tau that maximises |cross-correlation| between a1 and sensor error.
|
||||
|
||||
Computes average absolute cross-correlation across sensor channels,
|
||||
returns the lag (in snapshot steps) with the strongest correlation.
|
||||
"""
|
||||
a1 = a_ctl[0, :] # leading POD coefficient
|
||||
n = len(a1)
|
||||
# Normalise
|
||||
a1_z = (a1 - a1.mean()) / (a1.std() + 1e-12)
|
||||
# Average absolute correlation across sensor channels
|
||||
corr_avg = np.zeros(2 * max_lag + 1)
|
||||
for ch in range(e_s.shape[0]):
|
||||
ech = e_s[ch, :n]
|
||||
ech_z = (ech - ech.mean()) / (ech.std() + 1e-12)
|
||||
c = np.correlate(a1_z, ech_z, mode="full")
|
||||
c_mid = len(c) // 2
|
||||
seg = c[c_mid - max_lag:c_mid + max_lag + 1]
|
||||
corr_avg += np.abs(seg)
|
||||
corr_avg /= e_s.shape[0]
|
||||
best_lag = np.argmax(corr_avg) - max_lag
|
||||
return int(best_lag)
|
||||
|
||||
|
||||
def _scene_to_target_name(scene_type: str) -> str | None:
|
||||
if "illusion" in scene_type:
|
||||
parts = scene_type.split("_")
|
||||
if len(parts) >= 2:
|
||||
return f"target_cylinder_{parts[1]}"
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
print("=" * 60, flush=True)
|
||||
print("1.5L Correction-field CCD — dq_ctl", flush=True)
|
||||
print("=" * 60, flush=True)
|
||||
|
||||
out_dir = os.path.join(DATA_DIR, "ccd")
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
all_results = {}
|
||||
W_dict = {}
|
||||
|
||||
# ---- 1. Load correction fields for 1.5L ----
|
||||
print(f"\n--- Loading correction: {SCENE_TYPE} ---", flush=True)
|
||||
t0 = time.time()
|
||||
corr = compute_correction(SCENE_TYPE)
|
||||
dq_ctl = corr["dq_ctl"]
|
||||
dq_tar = corr["dq_tar"]
|
||||
diam = corr.get("diam")
|
||||
t_elapsed = time.time() - t0
|
||||
|
||||
if dq_ctl is None:
|
||||
print(" dq_ctl is None — cannot proceed.", flush=True)
|
||||
return 1
|
||||
|
||||
print(f" dq_ctl: {dq_ctl['ux'].shape[0]} frames, "
|
||||
f"forces={'✓' if dq_ctl['forces'] is not None else '✗'}, "
|
||||
f"actions={'✓' if dq_ctl['actions'] is not None else '✗'}, "
|
||||
f"sensors={'✓' if dq_ctl['sensors'] is not None else '✗'}, "
|
||||
f"{t_elapsed:.1f}s", flush=True)
|
||||
|
||||
# ---- 2. Phase drift: zone energy ratio ----
|
||||
print(f"\n--- Phase drift: zone energy ratio (body_wake / sensor_zone) ---",
|
||||
flush=True)
|
||||
zones = _define_zones()
|
||||
ze = _zone_ke_ratio(dq_ctl, zones)
|
||||
print(f" body_wake KE = {ze['body_wake_KE']:.4e}", flush=True)
|
||||
print(f" sensor_zone KE = {ze['sensor_zone_KE']:.4e}", flush=True)
|
||||
print(f" ratio (bw/sz) = {ze['ratio_bw_over_sz']:.4f}", flush=True)
|
||||
all_results["zone_energy_ratio"] = ze
|
||||
|
||||
# ---- 3. POD: target-only basis ----
|
||||
print(f"\n--- POD: target-only basis ---", flush=True)
|
||||
Q_ctl = dict_to_field_matrix(dq_ctl)
|
||||
N = Q_ctl.shape[1]
|
||||
print(f" dq_ctl: shape={Q_ctl.shape}", flush=True)
|
||||
|
||||
if dq_tar is not None:
|
||||
Q_tar = dict_to_field_matrix(dq_tar)
|
||||
print(f" dq_tar: shape={Q_tar.shape}", flush=True)
|
||||
mf_tar, modes_tar, sv_tar, coeffs_tar = compute_pod(Q_tar)
|
||||
# Project dq_ctl into target basis
|
||||
dc = dq_ctl
|
||||
q_proj = np.column_stack([
|
||||
np.concatenate([dc["ux"][s].ravel(), dc["uy"][s].ravel()])
|
||||
for s in range(N)
|
||||
])
|
||||
a_ctl = modes_tar.T @ (q_proj - mf_tar[:, None]).astype(np.float64)
|
||||
a_tar = coeffs_tar
|
||||
print(f" POD: target-only basis "
|
||||
f"(E95={e95_index(cumulative_energy(sv_tar))})", flush=True)
|
||||
else:
|
||||
print(" dq_tar is None — cannot proceed.", flush=True)
|
||||
return 1
|
||||
|
||||
# ---- 4. Sensor error for signature line ----
|
||||
sensors_ctl = dq_ctl.get("sensors") # (N, 6) — illusion sensors
|
||||
sensors_tar = dq_tar.get("sensors") # (N, 6) — target sensors
|
||||
if sensors_ctl is not None and sensors_tar is not None:
|
||||
# Both have 6 sensor channels: use all 6 dimensions
|
||||
n_min = min(sensors_ctl.shape[0], sensors_tar.shape[0], N)
|
||||
e_s_full = (sensors_ctl[:n_min] - sensors_tar[:n_min]).T # (6, N)
|
||||
print(f" Sensor error e_s: shape={e_s_full.shape}", flush=True)
|
||||
else:
|
||||
print(" Sensor data incomplete — signature-CCD skipped.", flush=True)
|
||||
e_s_full = None
|
||||
|
||||
# ---- 5. Force-CCD, action-CCD, target-CCD ----
|
||||
for r in R_CANDIDATES:
|
||||
a_r = a_ctl[:r, :]
|
||||
Nv = a_r.shape[1]
|
||||
print(f"\n r={r}: N={Nv}", flush=True)
|
||||
|
||||
# Force-CCD
|
||||
frc = dq_ctl.get("forces")
|
||||
if frc is not None:
|
||||
for fmode, flabel in [("fy", "force_fy"),
|
||||
("fx", "force_fx"),
|
||||
("joint", "force_joint")]:
|
||||
y = make_force_obs(frc, SCENE_TYPE, mode=fmode)[:, :Nv]
|
||||
W, sig, _, _, _, _ = compute_reduced_ccd(
|
||||
a_r, y, Q_delay=CCD_Q)
|
||||
en = cumulative_energy(sig)
|
||||
m80 = int(np.searchsorted(en, 0.80) + 1) if len(en) > 0 else 0
|
||||
key = f"illusion_1.5L_dqctl_{flabel}_r{r}"
|
||||
W_dict[key] = W
|
||||
all_results[key] = {
|
||||
"scene": SCENE_TYPE, "diam": diam, "obs": flabel, "r": r,
|
||||
"m80": m80, "N": sig.size,
|
||||
"sigma_top3": [
|
||||
float(sig[i]) for i in range(min(3, len(sig)))
|
||||
],
|
||||
}
|
||||
if fmode == "fy":
|
||||
print(f" {key}: m80={m80} "
|
||||
f"s1={float(sig[0]):.4f}", flush=True)
|
||||
|
||||
# Action-CCD
|
||||
act = dq_ctl.get("actions")
|
||||
if act is not None:
|
||||
y_a = act.T[:, :Nv]
|
||||
W, sig, _, _, _, _ = compute_reduced_ccd(
|
||||
a_r, y_a, Q_delay=CCD_Q)
|
||||
en = cumulative_energy(sig)
|
||||
m80 = int(np.searchsorted(en, 0.80) + 1) if len(en) > 0 else 0
|
||||
key = f"illusion_1.5L_dqctl_action_r{r}"
|
||||
W_dict[key] = W
|
||||
all_results[key] = {
|
||||
"scene": SCENE_TYPE, "diam": diam, "obs": "action", "r": r,
|
||||
"m80": m80, "N": sig.size,
|
||||
"sigma_top3": [
|
||||
float(sig[i]) for i in range(min(3, len(sig)))
|
||||
],
|
||||
}
|
||||
print(f" {key}: m80={m80} s1={float(sig[0]):.4f}", flush=True)
|
||||
|
||||
# Target force-CCD reference
|
||||
if dq_tar is not None:
|
||||
a_tr = a_tar[:r, :Nv]
|
||||
frc_t = dq_tar.get("forces")
|
||||
if frc_t is not None:
|
||||
tname = _scene_to_target_name(SCENE_TYPE) or f"{SCENE_TYPE}_tar"
|
||||
y_t = make_force_obs(frc_t[:Nv], tname, mode="fy")
|
||||
Wt, sig_t, _, _, _, _ = compute_reduced_ccd(
|
||||
a_tr, y_t, Q_delay=CCD_Q)
|
||||
kt = f"illusion_1.5L_dqtar_force_fy_r{r}"
|
||||
W_dict[kt] = Wt
|
||||
all_results[kt] = {
|
||||
"scene": SCENE_TYPE, "diam": diam,
|
||||
"obs": "force_fy_tar", "r": r,
|
||||
"m80": int(np.searchsorted(
|
||||
cumulative_energy(sig_t), 0.80) + 1
|
||||
) if len(sig_t) > 0 else 0,
|
||||
"N": sig_t.size,
|
||||
"sigma_top3": [
|
||||
float(sig_t[i]) for i in range(min(3, len(sig_t)))
|
||||
],
|
||||
}
|
||||
# Overlap: dq_ctl vs dq_tar
|
||||
ck = f"illusion_1.5L_dqctl_force_fy_r{r}"
|
||||
if ck in W_dict:
|
||||
Wc = W_dict[ck]
|
||||
n = min(Wc.shape[1], Wt.shape[1], 5)
|
||||
for k in range(n):
|
||||
ov = float(abs(
|
||||
Wc[:, k] / (np.linalg.norm(Wc[:, k]) + 1e-12) @
|
||||
Wt[:, k] / (np.linalg.norm(Wt[:, k]) + 1e-12)
|
||||
))
|
||||
all_results[
|
||||
f"illusion_1.5L_O_dqctl_vs_dqtar_r{r}_mode{k+1}"
|
||||
] = {"overlap": ov, "mode": k + 1, "r": r}
|
||||
if k == 0:
|
||||
print(
|
||||
f" O(dqctl, dqtar) mode1={ov:.4f}",
|
||||
flush=True
|
||||
)
|
||||
|
||||
# ---- 6. Overlap at r=6 (comparison anchor) ----
|
||||
# Print explicit comparison with 0.75L (0.564) and 1.0L (0.913)
|
||||
key_r6 = "illusion_1.5L_O_dqctl_vs_dqtar_r6_mode1"
|
||||
ov_r6 = all_results.get(key_r6, {}).get("overlap")
|
||||
if ov_r6 is not None:
|
||||
verdict = "lower=special" if ov_r6 < 0.7 else "higher=normal"
|
||||
print(
|
||||
f"\n 1.5L O(dqctl, dqtar) = {ov_r6:.4f} "
|
||||
f"(0.75L: 0.564, 1.0L: 0.913 → {verdict})",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
# ---- 7. Signature-CCD ----
|
||||
print(f"\n--- Signature-CCD (future sensor error e_s(t+tau)) ---",
|
||||
flush=True)
|
||||
if e_s_full is not None:
|
||||
# tau candidates
|
||||
tau_geom = 3 # geometric advection delay (snapshot steps)
|
||||
tau_corr = compute_tau_corr(a_ctl, e_s_full, max_lag=12)
|
||||
tau_candidates = [("tau_0", 0), ("tau_geom", tau_geom),
|
||||
("tau_corr", tau_corr)]
|
||||
print(f" tau_geom={tau_geom}, tau_corr={tau_corr}", flush=True)
|
||||
|
||||
for tau_label, tau in tau_candidates:
|
||||
print(f"\n --- tau={tau} ({tau_label}) ---", flush=True)
|
||||
for r in R_CANDIDATES:
|
||||
a_r = a_ctl[:r, :]
|
||||
Nv = a_r.shape[1]
|
||||
# Shift observable forward by tau
|
||||
if tau >= 0:
|
||||
y_sig = e_s_full[:, tau: tau + Nv]
|
||||
# Also shift POD coefficients to align: use a_r[:, :-tau]
|
||||
a_r_aligned = a_r[:, :Nv - tau] if tau > 0 else a_r
|
||||
y_sig_aligned = y_sig[:, :a_r_aligned.shape[1]]
|
||||
else:
|
||||
# Negative tau: shift backward
|
||||
y_sig = e_s_full[:, :Nv + tau]
|
||||
a_r_aligned = a_r[:, -tau:]
|
||||
y_sig_aligned = y_sig[:, :a_r_aligned.shape[1]]
|
||||
|
||||
if y_sig_aligned.shape[1] < CCD_Q:
|
||||
print(f" r={r}: too few samples ({y_sig_aligned.shape[1]}), skipping",
|
||||
flush=True)
|
||||
continue
|
||||
|
||||
W, sig, _, _, _, _ = compute_reduced_ccd(
|
||||
a_r_aligned, y_sig_aligned, Q_delay=CCD_Q)
|
||||
en = cumulative_energy(sig)
|
||||
m80 = int(np.searchsorted(en, 0.80) + 1) if len(en) > 0 else 0
|
||||
key = f"illusion_1.5L_dqctl_signature_{tau_label}_r{r}"
|
||||
W_dict[key] = W
|
||||
all_results[key] = {
|
||||
"scene": SCENE_TYPE, "diam": diam,
|
||||
"obs": f"signature_{tau_label}", "r": r,
|
||||
"tau": tau, "m80": m80, "N": sig.size,
|
||||
"sigma_top3": [
|
||||
float(sig[i]) for i in range(min(3, len(sig)))
|
||||
],
|
||||
}
|
||||
print(f" {key}: m80={m80} "
|
||||
f"s1={float(sig[0]):.4f}", flush=True)
|
||||
else:
|
||||
print(" Skipping: sensor error not available.", flush=True)
|
||||
|
||||
# ---- 8. Save ----
|
||||
ccd_path = os.path.join(out_dir, "15L_correction_results.json")
|
||||
with open(ccd_path, "w") as f:
|
||||
json.dump(all_results, f, indent=2)
|
||||
print(f"\nSaved {len(all_results)} entries to {ccd_path}", flush=True)
|
||||
|
||||
# ---- 9. Summary ----
|
||||
print("\n" + "=" * 60, flush=True)
|
||||
print("1.5L Correction-field CCD — Summary", flush=True)
|
||||
print("=" * 60, flush=True)
|
||||
print(f" Zone KE ratio (body_wake/sensor_zone): {ze['ratio_bw_over_sz']:.4f}",
|
||||
flush=True)
|
||||
if ov_r6 is not None:
|
||||
print(f" O(dqctl, dqtar) r=6 mode1: {ov_r6:.4f}", flush=True)
|
||||
|
||||
for r in R_CANDIDATES:
|
||||
print(f"\n r={r}:", flush=True)
|
||||
for obs in ["force_fy", "force_fx", "force_joint", "action"]:
|
||||
k = f"illusion_1.5L_dqctl_{obs}_r{r}"
|
||||
if k in all_results:
|
||||
d = all_results[k]
|
||||
print(f" {obs:12s}: m80={d['m80']}, "
|
||||
f"s1={d['sigma_top3'][0]:.4f}", flush=True)
|
||||
|
||||
print(f"\nDone. Results saved.", flush=True)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@ -1,563 +0,0 @@
|
||||
"""Signature-line CCD on dq_ctl: which correction structures determine future sensor mismatch.
|
||||
|
||||
Force/action line CCD on dq_ctl is complete (Phase 1-2). Now we need the
|
||||
SIGNATURE LINE — answering which correction structures most determine future
|
||||
sensor error (rather than instantaneous force).
|
||||
|
||||
Key idea: the observable for signature-CCD is the FUTURE sensor error
|
||||
e(t+tau) = sensors_ctl(t+tau) - sensors_tar(t+tau).
|
||||
|
||||
Usage:
|
||||
conda run -n pycuda_3_10 python correction_analysis/run_signature_line.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
|
||||
_SRC = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
if _SRC not in sys.path:
|
||||
sys.path.insert(0, _SRC)
|
||||
|
||||
from CCD_analysis.configs import DATA_DIR, SCENES
|
||||
from CCD_analysis.utils.resampling import (
|
||||
compute_pod, cumulative_energy, e95_index,
|
||||
compute_reduced_ccd, make_force_obs,
|
||||
)
|
||||
from CCD_analysis.correction_analysis.compute_correction_fields import (
|
||||
compute_correction, dict_to_field_matrix,
|
||||
)
|
||||
|
||||
R_LIST = [6, 8, 10]
|
||||
CCD_Q = 6
|
||||
SCENE_TYPES = ["illusion_0.75L", "illusion_1.0L"]
|
||||
TAU_GEOM = 3
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Signature observable
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def make_signature_obs(sensors_ctl: np.ndarray, sensors_tar: np.ndarray,
|
||||
step_indices: list, tau: int = 0) -> np.ndarray:
|
||||
"""Construct signature observable: future sensor error e(t+tau).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
sensors_ctl : (N_total_raw, 6) — full raw sensor telemetry from controlled data.
|
||||
sensors_tar : (N_total_raw, 6) — full raw sensor telemetry from target data.
|
||||
step_indices : list of int — absolute frame indices (from dq_ctl step_indices).
|
||||
tau : int — future shift in simulation steps.
|
||||
|
||||
Returns
|
||||
-------
|
||||
e : (6, N_valid) — sensor error at shifted indices (6 channels).
|
||||
"""
|
||||
si = np.asarray(step_indices, dtype=int)
|
||||
max_idx = min(len(sensors_ctl), len(sensors_tar)) - 1
|
||||
shifted = np.clip(si + tau, 0, max_idx)
|
||||
e = sensors_ctl[shifted] - sensors_tar[shifted]
|
||||
return e.T # (6, N_valid)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tau computation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def compute_tau_corr(sensors_ctl: np.ndarray, sensors_tar: np.ndarray,
|
||||
step_indices: list, max_lag: int = 50) -> int:
|
||||
"""Compute optimal tau via cross-correlation of target/illusion sensor[:,3].
|
||||
|
||||
Cross-correlates the target cylinder sensor[:,3] with the illusion
|
||||
sensor[:,3] at the snapshot-aligned times. Returns the absolute lag
|
||||
(in steps) that maximises cross-correlation.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
sensors_ctl : (N_total_raw, 6) — full raw sensors from controlled data.
|
||||
sensors_tar : (N_total_raw, 6) — full raw sensors from target data.
|
||||
step_indices : list of int — absolute frame indices.
|
||||
max_lag : int — maximum lag to consider (in steps).
|
||||
|
||||
Returns
|
||||
-------
|
||||
tau : int — optimal lag in steps (non-negative).
|
||||
"""
|
||||
si = np.asarray(step_indices, dtype=int)
|
||||
s_ctl = sensors_ctl[si, 3]
|
||||
s_tar = sensors_tar[si, 3]
|
||||
|
||||
n = len(s_ctl)
|
||||
ctl = s_ctl - np.mean(s_ctl)
|
||||
tar = s_tar - np.mean(s_tar)
|
||||
|
||||
xcorr = np.correlate(tar, ctl, mode='same')
|
||||
mid = n // 2
|
||||
lags = np.arange(-mid, mid + 1)
|
||||
if n % 2 == 0:
|
||||
lags = lags[:-1]
|
||||
|
||||
valid = np.abs(lags) <= max_lag
|
||||
if not np.any(valid):
|
||||
return 0
|
||||
|
||||
best_idx = np.argmax(xcorr[valid])
|
||||
tau = lags[valid][best_idx]
|
||||
return int(abs(tau))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Raw sensor loader (full telemetry, before step-index subsampling)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _load_raw_sensors(scene_name: str) -> np.ndarray:
|
||||
"""Load full raw sensor telemetry (before step-index subsampling).
|
||||
|
||||
Returns
|
||||
-------
|
||||
sensors : (N_total_raw, 6) ndarray — the full sensor time series.
|
||||
"""
|
||||
cfg = SCENES.get(scene_name)
|
||||
if cfg is None:
|
||||
raise KeyError(f"Unknown scene: {scene_name}")
|
||||
|
||||
scene_id = cfg["scene_id"]
|
||||
sd = os.path.join(DATA_DIR, scene_id, scene_name)
|
||||
|
||||
tele_path = None
|
||||
for p in [os.path.join(sd, "controlled.npz"), os.path.join(sd, "sensors.npz")]:
|
||||
if os.path.isfile(p):
|
||||
tele_path = p
|
||||
break
|
||||
if tele_path is None:
|
||||
raise FileNotFoundError(f"No telemetry (*.npz) found in {sd}")
|
||||
|
||||
td = np.load(tele_path)
|
||||
sensors = td["sensors"]
|
||||
td.close()
|
||||
return sensors
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Modal overlap
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def modal_overlap(W_a: np.ndarray, W_b: np.ndarray, n_modes: int = 5) -> list:
|
||||
"""Pairwise modal overlap between two CCD direction matrices.
|
||||
|
||||
Returns list of {mode, O} dicts.
|
||||
"""
|
||||
n = min(W_a.shape[1], W_b.shape[1], n_modes)
|
||||
results = []
|
||||
for k in range(n):
|
||||
u_a = W_a[:, k] / (np.linalg.norm(W_a[:, k]) + 1e-12)
|
||||
u_b = W_b[:, k] / (np.linalg.norm(W_b[:, k]) + 1e-12)
|
||||
ov = float(abs(u_a @ u_b))
|
||||
results.append({"mode": k + 1, "O": ov})
|
||||
return results
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# LOCO validation helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def r2_score(y_true: np.ndarray, y_pred: np.ndarray) -> float:
|
||||
"""Coefficient of determination."""
|
||||
ss_r = np.sum((y_true - y_pred) ** 2)
|
||||
ss_t = np.sum((y_true - np.mean(y_true)) ** 2)
|
||||
return float(1.0 - ss_r / (ss_t + 1e-12))
|
||||
|
||||
|
||||
def reconstruct_from_ccd(W, sigma, R, a_test, y_train, CCD_Q, m_obs):
|
||||
"""Reconstruct observable from CCD modes. Returns dict with 'mode1' and 'm80'."""
|
||||
am = np.mean(a_test, axis=1, keepdims=True)
|
||||
as_ = np.std(a_test, axis=1, keepdims=True) + 1e-12
|
||||
a_test_z = (a_test - am) / as_
|
||||
z_test = W.T @ a_test_z
|
||||
|
||||
ym = np.mean(y_train, axis=1, keepdims=True)
|
||||
ys = np.std(y_train, axis=1, keepdims=True) + 1e-12
|
||||
half = CCD_Q // 2
|
||||
|
||||
en = cumulative_energy(sigma)
|
||||
m80 = int(np.searchsorted(en, 0.80) + 1) if len(en) > 0 else 1
|
||||
|
||||
results = {}
|
||||
|
||||
# Mode-1
|
||||
if R.shape[1] >= 1:
|
||||
pz_1 = R[:, :1] * sigma[:1] @ z_test[:1, :]
|
||||
yp_1 = pz_1[half * m_obs:(half + 1) * m_obs, :] * ys + ym
|
||||
results["mode1"] = yp_1
|
||||
else:
|
||||
results["mode1"] = np.zeros_like(y_train[:, :a_test.shape[1]])
|
||||
|
||||
# M80
|
||||
n_rm = min(m80, R.shape[1])
|
||||
if n_rm >= 1:
|
||||
pz_m = R[:, :n_rm] * sigma[:n_rm] @ z_test[:n_rm, :]
|
||||
yp_m = pz_m[half * m_obs:(half + 1) * m_obs, :] * ys + ym
|
||||
results["m80"] = yp_m
|
||||
else:
|
||||
results["m80"] = np.zeros_like(y_train[:, :a_test.shape[1]])
|
||||
|
||||
return results
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
print("=" * 60, flush=True)
|
||||
print("Signature-line CCD on dq_ctl", flush=True)
|
||||
print("=" * 60, flush=True)
|
||||
|
||||
out_dir = os.path.join(DATA_DIR, "ccd")
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
all_results = {}
|
||||
W_dict = {} # CCD direction matrices keyed by label
|
||||
pod_basis_cache = {} # (scene_type, r) -> (mean_field, modes_r)
|
||||
raw_sensors_cache = {} # scene_name -> raw sensors
|
||||
|
||||
# ---- 1. Load correction fields ----
|
||||
print("\n--- Step 1: Loading correction fields ---", flush=True)
|
||||
cache = {}
|
||||
for st in SCENE_TYPES:
|
||||
t0 = time.time()
|
||||
try:
|
||||
corr = compute_correction(st)
|
||||
cache[st] = corr
|
||||
dq = corr["dq_ctl"]
|
||||
if dq is not None:
|
||||
print(f" {st}: dq_ctl {dq['ux'].shape[0]} frames, "
|
||||
f"sensors={'✓' if corr['q_ctl'] is not None else '✗'}, "
|
||||
f"forces={'✓' if dq['forces'] is not None else '✗'}, "
|
||||
f"{time.time()-t0:.1f}s", flush=True)
|
||||
except Exception as e:
|
||||
print(f" {st}: FAILED — {e}", flush=True)
|
||||
|
||||
# ---- 2. Load raw sensor data for all needed scenes ----
|
||||
print("\n--- Step 2: Loading raw sensor telemetry ---", flush=True)
|
||||
for st in SCENE_TYPES:
|
||||
corr = cache.get(st)
|
||||
if corr is None:
|
||||
continue
|
||||
diam = corr.get("diam")
|
||||
tar_name = f"target_cylinder_{diam}L"
|
||||
for name in [st, tar_name]:
|
||||
if name not in raw_sensors_cache:
|
||||
try:
|
||||
raw_sensors_cache[name] = _load_raw_sensors(name)
|
||||
print(f" {name}: raw sensors {raw_sensors_cache[name].shape}", flush=True)
|
||||
except Exception as e:
|
||||
print(f" {name}: FAILED — {e}", flush=True)
|
||||
|
||||
# ---- 3. For each scene, pre-compute POD basis ----
|
||||
print("\n--- Step 3: Building target-only POD basis ---", flush=True)
|
||||
for st in SCENE_TYPES:
|
||||
corr = cache.get(st)
|
||||
if corr is None:
|
||||
continue
|
||||
dq_tar = corr["dq_tar"]
|
||||
if dq_tar is None:
|
||||
print(f" {st}: dq_tar is None, skipping", flush=True)
|
||||
continue
|
||||
|
||||
Q_tar = dict_to_field_matrix(dq_tar)
|
||||
mf_tar, modes_tar, sv_tar, _ = compute_pod(Q_tar)
|
||||
e95 = e95_index(cumulative_energy(sv_tar))
|
||||
print(f" {st}: target-only POD E95={e95}", flush=True)
|
||||
|
||||
for r in R_LIST:
|
||||
pod_basis_cache[(st, r)] = (mf_tar, modes_tar[:, :r])
|
||||
|
||||
# ---- 4. Compute tau_corr for each scene ----
|
||||
print("\n--- Step 4: Computing tau values ---", flush=True)
|
||||
tau_config = {} # scene_type -> list of tau
|
||||
for st in SCENE_TYPES:
|
||||
corr = cache.get(st)
|
||||
if corr is None:
|
||||
continue
|
||||
raw_ctl = raw_sensors_cache.get(st)
|
||||
diam = corr.get("diam")
|
||||
tar_name = f"target_cylinder_{diam}L"
|
||||
raw_tar = raw_sensors_cache.get(tar_name)
|
||||
step_idx = corr["q_ctl"].get("step_indices", [])
|
||||
if step_idx is None or len(step_idx) == 0:
|
||||
step_idx = list(range(corr["q_ctl"]["ux"].shape[0]))
|
||||
|
||||
if raw_ctl is not None and raw_tar is not None:
|
||||
tau_corr = compute_tau_corr(raw_ctl, raw_tar, step_idx)
|
||||
else:
|
||||
tau_corr = TAU_GEOM
|
||||
|
||||
taus = sorted(set([0, TAU_GEOM, tau_corr]))
|
||||
tau_config[st] = taus
|
||||
print(f" {st}: tau_corr={tau_corr}, taus={taus}", flush=True)
|
||||
|
||||
# ---- 5. Signature-CCD and Force-CCD for each (scene, r, tau) ----
|
||||
print("\n--- Step 5: Running CCD ---", flush=True)
|
||||
for st in SCENE_TYPES:
|
||||
corr = cache.get(st)
|
||||
if corr is None:
|
||||
continue
|
||||
dq_ctl = corr["dq_ctl"]
|
||||
dq_tar = corr["dq_tar"]
|
||||
if dq_ctl is None or dq_tar is None:
|
||||
continue
|
||||
|
||||
diam = corr.get("diam")
|
||||
taus = tau_config.get(st, [0, TAU_GEOM])
|
||||
step_idx = corr["q_ctl"].get("step_indices", [])
|
||||
if step_idx is None or len(step_idx) == 0:
|
||||
step_idx = list(range(dq_ctl["ux"].shape[0]))
|
||||
|
||||
raw_ctl = raw_sensors_cache.get(st)
|
||||
diam = corr.get("diam")
|
||||
tar_name = f"target_cylinder_{diam}L"
|
||||
raw_tar = raw_sensors_cache.get(tar_name)
|
||||
|
||||
Q_ctl = dict_to_field_matrix(dq_ctl)
|
||||
N = Q_ctl.shape[1]
|
||||
print(f"\n --- {st} (diam={diam}) ---", flush=True)
|
||||
|
||||
for r in R_LIST:
|
||||
mf_r, modes_r = pod_basis_cache[(st, r)]
|
||||
a_r = modes_r.T @ (Q_ctl - mf_r[:, None]).astype(np.float64)
|
||||
Nv = a_r.shape[1]
|
||||
print(f" r={r}: N={Nv}", flush=True)
|
||||
|
||||
# -- Signature-CCD --
|
||||
if raw_ctl is not None and raw_tar is not None and step_idx is not None:
|
||||
for tau in taus:
|
||||
e_sig = make_signature_obs(raw_ctl, raw_tar, step_idx, tau=tau)
|
||||
# Trim to match a_r length
|
||||
Ne = e_sig.shape[1]
|
||||
a_r_use = a_r[:, :Ne] if Ne < Nv else a_r
|
||||
e_use = e_sig[:, :Nv] if Nv < Ne else e_sig
|
||||
N_use = min(Nv, Ne)
|
||||
|
||||
W, sig, Rmat, z, N_orig, N_valid = compute_reduced_ccd(
|
||||
a_r_use[:, :N_use], e_use[:, :N_use], Q_delay=CCD_Q
|
||||
)
|
||||
en = cumulative_energy(sig)
|
||||
m80 = int(np.searchsorted(en, 0.80) + 1) if len(en) > 0 else 0
|
||||
|
||||
key = f"{st}_sig_tau{tau}_r{r}"
|
||||
W_dict[key] = W
|
||||
all_results[key] = {
|
||||
"scene": st, "diam": diam, "obs": f"sig_tau{tau}", "r": r,
|
||||
"tau": tau, "m80": m80, "N": sig.size, "N_valid": N_valid,
|
||||
"N_original": N_orig,
|
||||
"sigma_top3": [float(sig[i]) for i in range(min(3, len(sig)))],
|
||||
}
|
||||
print(f" {key}: m80={m80} "
|
||||
f"s1={float(sig[0]):.4f} N_valid={N_valid}", flush=True)
|
||||
|
||||
# -- Force-CCD reference (SigmaFy, tau=0) --
|
||||
frc = dq_ctl.get("forces")
|
||||
if frc is not None:
|
||||
y_f = make_force_obs(frc, st, mode="fy")[:, :Nv]
|
||||
W_f, sig_f, _, _, N_orig_f, N_valid_f = compute_reduced_ccd(
|
||||
a_r, y_f, Q_delay=CCD_Q
|
||||
)
|
||||
en_f = cumulative_energy(sig_f)
|
||||
m80_f = int(np.searchsorted(en_f, 0.80) + 1) if len(en_f) > 0 else 0
|
||||
key_f = f"{st}_force_fy_r{r}"
|
||||
W_dict[key_f] = W_f
|
||||
all_results[key_f] = {
|
||||
"scene": st, "diam": diam, "obs": "force_fy", "r": r,
|
||||
"tau": 0, "m80": m80_f, "N": sig_f.size, "N_valid": N_valid_f,
|
||||
"N_original": N_orig_f,
|
||||
"sigma_top3": [float(sig_f[i]) for i in range(min(3, len(sig_f)))],
|
||||
}
|
||||
print(f" {key_f}: m80={m80_f} "
|
||||
f"s1={float(sig_f[0]):.4f} N_valid={N_valid_f}", flush=True)
|
||||
|
||||
# ---- 6. Force vs Signature modal overlap comparison (r=6) ----
|
||||
print("\n\n--- Step 6: Force vs Signature modal overlap (r=6) ---", flush=True)
|
||||
for st in SCENE_TYPES:
|
||||
corr = cache.get(st)
|
||||
if corr is None:
|
||||
continue
|
||||
diam = corr.get("diam")
|
||||
taus = tau_config.get(st, [0, TAU_GEOM])
|
||||
|
||||
force_key = f"{st}_force_fy_r{6}"
|
||||
if force_key not in W_dict:
|
||||
print(f" {st}: no force key, skipping overlap", flush=True)
|
||||
continue
|
||||
|
||||
W_force = W_dict[force_key]
|
||||
print(f"\n {st} (diam={diam}):", flush=True)
|
||||
for tau in taus:
|
||||
sig_key = f"{st}_sig_tau{tau}_r{6}"
|
||||
if sig_key not in W_dict:
|
||||
continue
|
||||
W_sig = W_dict[sig_key]
|
||||
ovs = modal_overlap(W_force, W_sig, n_modes=5)
|
||||
for ov in ovs:
|
||||
key = f"{st}_O_force_vs_sig_tau{tau}_r6_mode{ov['mode']}"
|
||||
all_results[key] = {
|
||||
"scene": st, "diam": diam, "r": 6,
|
||||
"tau_sig": tau, "mode": ov["mode"],
|
||||
"overlap": ov["O"],
|
||||
}
|
||||
ov_str = ", ".join([f"mode{ov['mode']}={ov['O']:.4f}" for ov in ovs])
|
||||
print(f" O(force, sig_tau{tau}) r=6: {ov_str}", flush=True)
|
||||
|
||||
# ---- 7. LOCO validation (signature observable) ----
|
||||
N_PTS = 24
|
||||
N_CYCLES = 4
|
||||
|
||||
print("\n\n--- Step 7: LOCO validation (signature observable, r=6) ---", flush=True)
|
||||
loco_results = {}
|
||||
for st in SCENE_TYPES:
|
||||
corr = cache.get(st)
|
||||
if corr is None:
|
||||
continue
|
||||
dq_ctl = corr["dq_ctl"]
|
||||
dq_tar = corr["dq_tar"]
|
||||
if dq_ctl is None or dq_tar is None:
|
||||
continue
|
||||
|
||||
diam = corr.get("diam")
|
||||
taus = tau_config.get(st, [0, TAU_GEOM])
|
||||
step_idx = corr["q_ctl"].get("step_indices", [])
|
||||
if step_idx is None or len(step_idx) == 0:
|
||||
step_idx = list(range(dq_ctl["ux"].shape[0]))
|
||||
|
||||
tar_name = f"target_cylinder_{diam}L"
|
||||
raw_ctl = raw_sensors_cache.get(st)
|
||||
raw_tar = raw_sensors_cache.get(tar_name)
|
||||
if raw_ctl is None or raw_tar is None:
|
||||
print(f" {st}: raw sensors missing, skipping LOCO", flush=True)
|
||||
continue
|
||||
|
||||
Q_ctl = dict_to_field_matrix(dq_ctl)
|
||||
N_total = Q_ctl.shape[1]
|
||||
|
||||
for tau in taus:
|
||||
# Build the full signature observable
|
||||
e_full = make_signature_obs(raw_ctl, raw_tar, step_idx, tau=tau)
|
||||
|
||||
r = 6
|
||||
fold_r2_m1, fold_r2_m80 = [], []
|
||||
|
||||
for fold in range(N_CYCLES):
|
||||
test_cyc = fold
|
||||
train_cyc = [c for c in range(N_CYCLES) if c != test_cyc]
|
||||
train_idx = sorted([c * N_PTS + p for c in train_cyc for p in range(N_PTS)])
|
||||
test_idx = sorted([c * N_PTS + p for c in [test_cyc] for p in range(N_PTS)])
|
||||
|
||||
# Trim to valid range
|
||||
train_idx = [i for i in train_idx if i < N_total]
|
||||
test_idx = [i for i in test_idx if i < N_total]
|
||||
if len(train_idx) < N_PTS or len(test_idx) < N_PTS // 2:
|
||||
continue
|
||||
|
||||
# Build LOCO POD basis from target-only data
|
||||
Q_tar = dict_to_field_matrix(dq_tar)
|
||||
Q_ref = Q_tar[:, train_idx]
|
||||
mf = np.mean(Q_ref, axis=1)
|
||||
U, _, _ = np.linalg.svd(Q_ref - mf[:, None], full_matrices=False)
|
||||
modes_r = U[:, :r]
|
||||
|
||||
a_train = modes_r.T @ (Q_ctl[:, train_idx] - mf[:, None])
|
||||
a_test = modes_r.T @ (Q_ctl[:, test_idx] - mf[:, None])
|
||||
|
||||
y_train = e_full[:, train_idx]
|
||||
y_test = e_full[:, test_idx]
|
||||
|
||||
# Handle length mismatches
|
||||
na = a_train.shape[1]
|
||||
ny = y_train.shape[1]
|
||||
n_min = min(na, ny)
|
||||
a_train = a_train[:, :n_min]
|
||||
y_train = y_train[:, :n_min]
|
||||
|
||||
try:
|
||||
W, sigma, Rmat, _, _, _ = compute_reduced_ccd(a_train, y_train, Q_delay=CCD_Q)
|
||||
except Exception as exc:
|
||||
print(f" LOCO fold {fold}: CCD failed — {exc}", flush=True)
|
||||
continue
|
||||
|
||||
recon = reconstruct_from_ccd(W, sigma, Rmat, a_test, y_train, CCD_Q, m_obs=6)
|
||||
|
||||
na_test = a_test.shape[1]
|
||||
ny_test = y_test.shape[1]
|
||||
n_test = min(na_test, ny_test)
|
||||
ch_m1 = [r2_score(y_test[c, :n_test], recon["mode1"][c, :n_test])
|
||||
for c in range(min(y_test.shape[0], recon["mode1"].shape[0]))]
|
||||
ch_m80 = [r2_score(y_test[c, :n_test], recon["m80"][c, :n_test])
|
||||
for c in range(min(y_test.shape[0], recon["m80"].shape[0]))]
|
||||
fold_r2_m1.append(float(np.mean(ch_m1)))
|
||||
fold_r2_m80.append(float(np.mean(ch_m80)))
|
||||
|
||||
if fold_r2_m1:
|
||||
key = f"{st}_LOCO_sig_tau{tau}_r{r}"
|
||||
loco_results[key] = {
|
||||
"scene": st, "diam": diam, "tau": tau, "r": r,
|
||||
"mode1": {
|
||||
"mean": float(np.mean(fold_r2_m1)),
|
||||
"std": float(np.std(fold_r2_m1)),
|
||||
},
|
||||
"m80": {
|
||||
"mean": float(np.mean(fold_r2_m80)),
|
||||
"std": float(np.std(fold_r2_m80)),
|
||||
},
|
||||
}
|
||||
print(f" {key}: R2_m1={loco_results[key]['mode1']['mean']:.4f}+-"
|
||||
f"{loco_results[key]['mode1']['std']:.4f} "
|
||||
f"R2_m80={loco_results[key]['m80']['mean']:.4f}+-"
|
||||
f"{loco_results[key]['m80']['std']:.4f}", flush=True)
|
||||
else:
|
||||
print(f" {st} tau={tau}: LOCO skipped (no valid folds)", flush=True)
|
||||
|
||||
all_results["_loco"] = loco_results
|
||||
|
||||
# ---- 8. Save ----
|
||||
out_path = os.path.join(out_dir, "signature_ccd_results.json")
|
||||
with open(out_path, "w") as f:
|
||||
json.dump(all_results, f, indent=2)
|
||||
print(f"\nSaved {len(all_results)} entries to {out_path}", flush=True)
|
||||
|
||||
# ---- 9. Summary ----
|
||||
print("\n" + "=" * 60, flush=True)
|
||||
print("SUMMARY", flush=True)
|
||||
print("=" * 60, flush=True)
|
||||
|
||||
for st in SCENE_TYPES:
|
||||
corr = cache.get(st)
|
||||
if corr is None:
|
||||
continue
|
||||
diam = corr.get("diam")
|
||||
taus = tau_config.get(st, [0, TAU_GEOM])
|
||||
print(f"\n {st} (diam={diam}):", flush=True)
|
||||
|
||||
# Signature R2_m80 from LOCO (r=6)
|
||||
for tau in taus:
|
||||
lk = f"{st}_LOCO_sig_tau{tau}_r{6}"
|
||||
if lk in loco_results:
|
||||
r2_m80 = loco_results[lk]["m80"]["mean"]
|
||||
flag = "✓" if r2_m80 >= 0.4 else "✗"
|
||||
print(f" LOCO sig_tau{tau} R2_m80={r2_m80:.4f} {flag}", flush=True)
|
||||
|
||||
# Overlaps
|
||||
for tau in taus:
|
||||
ok = f"{st}_O_force_vs_sig_tau{tau}_r6_mode1"
|
||||
if ok in all_results:
|
||||
ov = all_results[ok]["overlap"]
|
||||
cat = "shared" if ov > 0.8 else ("partial" if ov > 0.5 else "separated")
|
||||
print(f" O(force, sig_tau{tau}) mode1={ov:.4f} ({cat})", flush=True)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@ -1,344 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compute quantitative metrics for steady cloak.
|
||||
|
||||
Measures:
|
||||
- Mean wake restoration (downstream ux profile)
|
||||
- Fluctuation (RMS) suppression ratio
|
||||
- Recirculation zone length (centreline ux < 0)
|
||||
- dq_ctl + dq_blk cancellation quality
|
||||
- Force / power bookkeeping (if forces available)
|
||||
|
||||
Usage:
|
||||
conda run -n pycuda_3_10 python correction_analysis/run_steady_metrics.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
_SRC = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
if _SRC not in sys.path:
|
||||
sys.path.insert(0, _SRC)
|
||||
|
||||
from CCD_analysis.configs import DATA_DIR, NX, NY, L0, CENTER_Y, U0, SCENES
|
||||
from CCD_analysis.correction_analysis.compute_correction_fields import (
|
||||
compute_correction,
|
||||
)
|
||||
from CCD_analysis.correction_analysis.process_legacy_steady import (
|
||||
load_legacy_steady,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sensor / geometry constants (steady_cloak layout)
|
||||
# ---------------------------------------------------------------------------
|
||||
# pinball_front_x = 30.0 * L0 = 600
|
||||
# pinball_rear_x = 31.3 * L0 = 626
|
||||
# sensor_x = 40.0 * L0 = 800
|
||||
SENSOR_X_PX = int(SCENES["steady_cloak"]["sensor_x"] * L0) # ~800
|
||||
FRONT_X_PX = int(SCENES["steady_cloak"]["pinball_front_x"] * L0) # ~600
|
||||
REAR_X_PX = int(SCENES["steady_cloak"]["pinball_rear_x"] * L0) # ~626
|
||||
CY = int(round(CENTER_Y)) # centreline row index
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _crop_sensor_zone(ux: np.ndarray) -> np.ndarray:
|
||||
"""Crop ux to sensor-zone column range around SENSOR_X_PX.
|
||||
|
||||
Handles both 2D (NY, NX) and 3D (N, NY, NX) arrays by always
|
||||
cropping the last (x) axis.
|
||||
"""
|
||||
half = int(NX * 0.1) # ~10 % of total width on each side
|
||||
x0 = max(0, SENSOR_X_PX - half)
|
||||
x1 = min(NX, SENSOR_X_PX + half)
|
||||
# Ellipsis crops the last axis regardless of dimensionality
|
||||
return ux[..., x0:x1]
|
||||
|
||||
|
||||
def _crop_streamwise_centreline(ux_mean: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""Extract centreline ux profile from mean field, trimmed to downstream region.
|
||||
|
||||
Returns
|
||||
-------
|
||||
x_vals : (NX_trim,) pixel indices
|
||||
ux_cl : (NX_trim,) centreline ux values
|
||||
"""
|
||||
# Downstream region: from body trailing edge to domain end
|
||||
x0 = REAR_X_PX - 20 # start a bit before body
|
||||
x_end = min(NX, int(NX * 0.95))
|
||||
ux_cl = ux_mean[CY, x0:x_end]
|
||||
x_vals = np.arange(x0, x_end)
|
||||
return x_vals, ux_cl
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Metrics
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def compute_mean_wake_restoration(
|
||||
dq_blk: dict, dq_ctl: dict, q_in: dict, q_ctl_raw: dict
|
||||
) -> dict[str, Any]:
|
||||
"""Compare downstream ux profiles for blocked vs controlled flow.
|
||||
|
||||
A perfect steady cloak restores the wake to the uniform-channel profile.
|
||||
"""
|
||||
# Mean fields from correction differences
|
||||
ux_blk_mean = np.mean(dq_blk["ux"], axis=0) # (NY, NX) — blockage perturbation
|
||||
ux_ctl_mean = np.mean(dq_ctl["ux"], axis=0) # (NY, NX) — control perturbation
|
||||
|
||||
# Actual mean fields
|
||||
# q_in (target_channel) is the ideal undisturbed profile
|
||||
ux_in_mean = np.mean(q_in["ux"], axis=0) # (NY, NX)
|
||||
|
||||
# q_ctl raw = steady_cloak mean
|
||||
ux_sc_mean = np.mean(q_ctl_raw["ux"], axis=0) # (NY, NX)
|
||||
|
||||
# Centreline profiles
|
||||
x_vals, ux_cl_in = _crop_streamwise_centreline(ux_in_mean)
|
||||
_, ux_cl_sc = _crop_streamwise_centreline(ux_sc_mean)
|
||||
_, ux_cl_blk = _crop_streamwise_centreline(ux_blk_mean)
|
||||
_, ux_cl_ctl = _crop_streamwise_centreline(ux_ctl_mean)
|
||||
|
||||
# Sensor-zone averaged ux (over the full field, not just centreline)
|
||||
sz_blk = np.mean(_crop_sensor_zone(dq_blk["ux"]))
|
||||
sz_ctl = np.mean(_crop_sensor_zone(dq_ctl["ux"]))
|
||||
|
||||
# Wake restoration metric: RMS deviation from target channel in sensor zone
|
||||
_, ux_sz_in = _crop_sensor_zone(ux_in_mean), None # not used for deviation
|
||||
ux_sz_sc = _crop_sensor_zone(ux_sc_mean)
|
||||
ux_sz_tc = _crop_sensor_zone(ux_in_mean)
|
||||
dev_sc = np.std(ux_sz_sc - ux_sz_tc)
|
||||
|
||||
return {
|
||||
"sensor_zone_mean_ux_blk": float(np.mean(sz_blk)),
|
||||
"sensor_zone_mean_ux_ctl": float(np.mean(sz_ctl)),
|
||||
"centreline_ux_blk_mean": float(np.mean(ux_cl_blk)),
|
||||
"centreline_ux_ctl_mean": float(np.mean(ux_cl_ctl)),
|
||||
"sensor_zone_deviation_from_channel": float(dev_sc),
|
||||
}
|
||||
|
||||
|
||||
def compute_rms_suppression(dq_blk: dict, dq_ctl: dict) -> dict[str, Any]:
|
||||
"""Compute RMS fluctuation suppression ratio.
|
||||
|
||||
suppression_ratio = 1 - RMS(rms_ctl) / RMS(rms_blk)
|
||||
where rms is computed per-pixel over the snapshot dimension.
|
||||
|
||||
A value of 1.0 = perfect suppression, 0.0 = no suppression.
|
||||
"""
|
||||
rms_blk = np.std(dq_blk["ux"], axis=0) # (NY, NX)
|
||||
rms_ctl = np.std(dq_ctl["ux"], axis=0) # (NY, NX)
|
||||
|
||||
global_rms_blk = np.sqrt(np.mean(rms_blk**2))
|
||||
global_rms_ctl = np.sqrt(np.mean(rms_ctl**2))
|
||||
|
||||
suppression_ratio = 1.0 - global_rms_ctl / max(global_rms_blk, 1e-15)
|
||||
|
||||
# Sensor-zone specific
|
||||
sz_blk = _crop_sensor_zone(rms_blk)
|
||||
sz_ctl = _crop_sensor_zone(rms_ctl)
|
||||
sz_suppression = 1.0 - np.mean(sz_ctl) / max(np.mean(sz_blk), 1e-15)
|
||||
|
||||
return {
|
||||
"global_RMS_blk": float(global_rms_blk),
|
||||
"global_RMS_ctl": float(global_rms_ctl),
|
||||
"suppression_ratio": float(suppression_ratio),
|
||||
"sensor_zone_RMS_blk": float(np.mean(sz_blk)),
|
||||
"sensor_zone_RMS_ctl": float(np.mean(sz_ctl)),
|
||||
"sensor_zone_suppression": float(sz_suppression),
|
||||
}
|
||||
|
||||
|
||||
def compute_recirculation_zone(q_ctl_raw: dict) -> dict[str, Any]:
|
||||
"""Find recirculation zone length from mean ux of steady_cloak field.
|
||||
|
||||
Recirculation length: streamwise distance from body trailing edge
|
||||
to the point where centreline ux recovers to >= 0.
|
||||
"""
|
||||
ux_mean = np.mean(q_ctl_raw["ux"], axis=0) # (NY, NX)
|
||||
x_vals, ux_cl = _crop_streamwise_centreline(ux_mean)
|
||||
|
||||
# Find first point (downstream of body) where ux returns to >= 0
|
||||
neg = ux_cl < 0
|
||||
if not np.any(neg):
|
||||
recirc_len = 0.0
|
||||
x_recovery = None
|
||||
else:
|
||||
# Find the last negative index in this trimmed region
|
||||
neg_indices = np.where(neg)[0]
|
||||
last_neg = neg_indices[-1]
|
||||
x_recovery = int(x_vals[last_neg])
|
||||
# Distance from rear cylinder in pixel units, convert to L0
|
||||
recirc_len = (x_recovery - REAR_X_PX) / L0
|
||||
|
||||
# Also report min centreline ux
|
||||
min_ux = float(np.min(ux_cl))
|
||||
|
||||
return {
|
||||
"recirculation_length_L0": float(recirc_len) if recirc_len is not None else 0.0,
|
||||
"recirculation_x_recovery_px": x_recovery,
|
||||
"centreline_min_ux": min_ux,
|
||||
}
|
||||
|
||||
|
||||
def compute_cancellation_quality(dq_blk: dict, dq_ctl: dict) -> dict[str, Any]:
|
||||
"""Compute residual cancellation quality.
|
||||
|
||||
For perfect steady cloak: dq_ctl ≈ -dq_blk (control cancels blockage).
|
||||
measured by: cancellation_ratio = RMS(dq_ctl + dq_blk) / RMS(dq_blk)
|
||||
(lower is better, 0.0 = perfect cancellation)
|
||||
"""
|
||||
residual_ux = dq_ctl["ux"] + dq_blk["ux"] # (N, NY, NX)
|
||||
rms_residual = np.std(residual_ux)
|
||||
rms_blk = np.std(dq_blk["ux"])
|
||||
|
||||
cancel_ratio = rms_residual / max(rms_blk, 1e-15)
|
||||
|
||||
# Sensor-zone specific
|
||||
sz_res = _crop_sensor_zone(residual_ux)
|
||||
sz_blk_rms = np.std(_crop_sensor_zone(dq_blk["ux"]))
|
||||
sz_cancel = np.std(sz_res) / max(sz_blk_rms, 1e-15)
|
||||
|
||||
return {
|
||||
"cancellation_ratio": float(cancel_ratio),
|
||||
"sensor_zone_cancellation_ratio": float(sz_cancel),
|
||||
"residual_RMS": float(rms_residual),
|
||||
"blockage_RMS": float(rms_blk),
|
||||
}
|
||||
|
||||
|
||||
def compute_force_bookkeeping(
|
||||
dq_blk: dict, dq_ctl: dict
|
||||
) -> dict[str, Any]:
|
||||
"""Estimate drag from field data.
|
||||
|
||||
Since steady_cloak sensors.npz does not contain force telemetry,
|
||||
we estimate drag proxy from the momentum deficit in the wake.
|
||||
|
||||
drag_proxy = integral of (U0 - ux) across a wake profile
|
||||
(qualitative comparison only, not calibrated to actual drag)
|
||||
"""
|
||||
# Use mean ux from blockage (pinball - channel) and control (steady_cloak - pinball)
|
||||
ux_blk_m = np.mean(dq_blk["ux"], axis=0) # blockage perturbation
|
||||
ux_ctl_m = np.mean(dq_ctl["ux"], axis=0) # control perturbation
|
||||
|
||||
# Mean flow = blockage + channel for pinball; control restores toward channel
|
||||
# Take a wake profile at sensor_x location
|
||||
# Drag proxy: momentum deficit across channel height
|
||||
# Positive deficit means flow slower than free-stream
|
||||
deficit_blk = float(np.trapz(-ux_blk_m[:, SENSOR_X_PX])) if SENSOR_X_PX < NX else 0.0
|
||||
deficit_ctl = float(np.trapz(-ux_ctl_m[:, SENSOR_X_PX])) if SENSOR_X_PX < NX else 0.0
|
||||
deficit_ratio = deficit_ctl / max(abs(deficit_blk), 1e-15)
|
||||
|
||||
return {
|
||||
"drag_proxy_blockage": deficit_blk,
|
||||
"drag_proxy_control": deficit_ctl,
|
||||
"drag_proxy_ratio": deficit_ratio,
|
||||
"note": "drag proxy from ux deficit at sensor plane; no actual force telemetry available",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def run() -> dict[str, Any]:
|
||||
print("=" * 60)
|
||||
print("Steady Cloak Quantitative Metrics")
|
||||
print("=" * 60)
|
||||
|
||||
# -- 1. Load correction fields --
|
||||
print("\n--- Loading correction fields ---")
|
||||
corr = compute_correction("steady_cloak")
|
||||
dq_blk = corr["dq_blk"] # q_blk - q_in (pinball blockage)
|
||||
dq_ctl = corr["dq_ctl"] # q_ctl - q_blk (control correction)
|
||||
q_in = corr["q_in"] # target_channel (undisturbed)
|
||||
N = corr["N"]
|
||||
print(f" Aligned frames: {N}")
|
||||
|
||||
# -- 2. Load steady_cloak raw data (for true mean field) --
|
||||
print("\n--- Loading steady_cloak raw fields ---")
|
||||
sc_raw = load_legacy_steady("steady_cloak")
|
||||
print(f" ux shape: {sc_raw['ux'].shape}")
|
||||
|
||||
# -- 3. Compute metrics --
|
||||
print("\n--- Computing wake restoration ---")
|
||||
wake = compute_mean_wake_restoration(dq_blk, dq_ctl, q_in, sc_raw)
|
||||
|
||||
print("\n--- Computing RMS suppression ---")
|
||||
rms = compute_rms_suppression(dq_blk, dq_ctl)
|
||||
|
||||
print("\n--- Computing recirculation zone ---")
|
||||
recirc = compute_recirculation_zone(sc_raw)
|
||||
|
||||
print("\n--- Computing cancellation quality ---")
|
||||
cancel = compute_cancellation_quality(dq_blk, dq_ctl)
|
||||
|
||||
print("\n--- Computing drag bookkeeping ---")
|
||||
drag = compute_force_bookkeeping(dq_blk, dq_ctl)
|
||||
|
||||
# -- 4. Assemble --
|
||||
metrics = {
|
||||
"scene": "steady_cloak",
|
||||
"N_frames": int(N),
|
||||
"N_raw": int(sc_raw["ux"].shape[0]),
|
||||
"recirculation_zone": recirc,
|
||||
"wake_restoration": wake,
|
||||
"rms_suppression": rms,
|
||||
"cancellation_quality": cancel,
|
||||
"force_bookkeeping": drag,
|
||||
}
|
||||
|
||||
# -- 5. Save --
|
||||
os.makedirs(os.path.join(DATA_DIR, "ccd"), exist_ok=True)
|
||||
out_path = os.path.join(DATA_DIR, "ccd", "steady_metrics.json")
|
||||
with open(out_path, "w") as f:
|
||||
json.dump(metrics, f, indent=2)
|
||||
print(f"\nMetrics saved to {out_path}")
|
||||
|
||||
# -- 6. Print summary --
|
||||
_print_summary(metrics)
|
||||
|
||||
return metrics
|
||||
|
||||
|
||||
def _print_summary(m: dict):
|
||||
r = m["recirculation_zone"]
|
||||
w = m["wake_restoration"]
|
||||
rms = m["rms_suppression"]
|
||||
c = m["cancellation_quality"]
|
||||
d = m["force_bookkeeping"]
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("=== Steady Cloak Metrics ===")
|
||||
print("=" * 60)
|
||||
|
||||
# Drag bookkeeping
|
||||
print(f"Drag proxy (deficit area): blockage={d['drag_proxy_blockage']:.4f}, "
|
||||
f"control={d['drag_proxy_control']:.4f}")
|
||||
|
||||
# Fluctuation suppression
|
||||
print(f"Fluctuation suppression (global): {rms['suppression_ratio']*100:.1f}%")
|
||||
print(f"Fluctuation suppression (sensor zone): {rms['sensor_zone_suppression']*100:.1f}%")
|
||||
|
||||
# Recirculation
|
||||
print(f"Recirculation length: {r['recirculation_length_L0']:.2f} (L0 units)")
|
||||
print(f"Centreline min ux: {r['centreline_min_ux']:.6f}")
|
||||
|
||||
# Cancellation
|
||||
print(f"dq_ctl + dq_blk cancellation ratio: {c['cancellation_ratio']:.4f}")
|
||||
print(f"Sensor-zone cancellation ratio: {c['sensor_zone_cancellation_ratio']:.4f}")
|
||||
|
||||
# Wake restoration
|
||||
print(f"Sensor-zone deviation from channel (RMS): {w['sensor_zone_deviation_from_channel']:.6f}")
|
||||
print(f"Note: {d['note']}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
@ -1,359 +0,0 @@
|
||||
"""Zone-wise CCD: force-CCD and signature-CCD per spatial zone.
|
||||
|
||||
Processes each of three spatial zones separately for illusion 0.75L and 1.0L:
|
||||
- near_body: x[350:500] (envelope around pinball)
|
||||
- body_wake: x[500:700] (body-connected near wake)
|
||||
- sensor_zone: x[580:650] (around sensor plane at x=30*L0=600)
|
||||
|
||||
For each zone: mask the snapshot matrix to keep only grid points in the zone,
|
||||
build a target-only POD basis, project correction fields, and compute
|
||||
force-CCD (SigmaFy) and signature-CCD (tau=0, tau=tau_corr) at r=6, Q_delay=6.
|
||||
|
||||
Usage:
|
||||
conda run -n pycuda_3_10 python correction_analysis/run_zone_ccd.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
|
||||
_SRC = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
if _SRC not in sys.path:
|
||||
sys.path.insert(0, _SRC)
|
||||
|
||||
from CCD_analysis.configs import DATA_DIR, NX, NY
|
||||
from CCD_analysis.utils.resampling import (
|
||||
compute_pod,
|
||||
cumulative_energy,
|
||||
compute_reduced_ccd,
|
||||
make_force_obs,
|
||||
)
|
||||
from CCD_analysis.correction_analysis.compute_correction_fields import (
|
||||
compute_correction,
|
||||
dict_to_field_matrix,
|
||||
)
|
||||
|
||||
CCD_Q = 6
|
||||
R = 6
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Zone masks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _define_zones() -> dict:
|
||||
"""Define three-zone masks for illusion layout (sensors at x=30*L0=600)."""
|
||||
zones = {}
|
||||
|
||||
# near_body: envelope around pinball (pinball front x=380, rear x=406)
|
||||
mask = np.zeros((NY, NX), dtype=bool)
|
||||
mask[:, 350:500] = True
|
||||
zones["near_body"] = mask
|
||||
|
||||
# body_wake: body-connected near wake, immediate downstream
|
||||
mask = np.zeros((NY, NX), dtype=bool)
|
||||
mask[:, 500:700] = True
|
||||
zones["body_wake"] = mask
|
||||
|
||||
# sensor_zone: around sensors at x=600
|
||||
mask = np.zeros((NY, NX), dtype=bool)
|
||||
mask[:, 580:650] = True
|
||||
zones["sensor_zone"] = mask
|
||||
|
||||
return zones
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Masking helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def mask_field_matrix(Q_full: np.ndarray, ny: int, nx: int,
|
||||
mask: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""Zero out all grid points outside the mask.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
Q_full : (2*nx*ny, N) ndarray
|
||||
Snapshot matrix — first half = ux, second half = uy.
|
||||
mask : (ny, nx) ndarray
|
||||
Boolean mask, True = keep.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Q_masked : (2*n_sum, N) ndarray
|
||||
Masked snapshot matrix.
|
||||
ux_idx : (n_sum,) ndarray
|
||||
Indices into the original ux ravel for kept points.
|
||||
"""
|
||||
mask_flat = mask.ravel() # (ny*nx,)
|
||||
ux_idx = np.where(mask_flat)[0]
|
||||
uy_idx = ux_idx + nx * ny
|
||||
all_idx = np.concatenate([ux_idx, uy_idx])
|
||||
return Q_full[all_idx, :], ux_idx
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# tau_corr heuristic (from run_15L_correction.py)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def compute_tau_corr(a_ctl: np.ndarray, e_s: np.ndarray,
|
||||
max_lag: int = 12) -> int:
|
||||
"""Find tau that maximises |cross-correlation| between a1 and sensor error."""
|
||||
a1 = a_ctl[0, :]
|
||||
n = len(a1)
|
||||
a1_z = (a1 - a1.mean()) / (a1.std() + 1e-12)
|
||||
corr_avg = np.zeros(2 * max_lag + 1)
|
||||
for ch in range(e_s.shape[0]):
|
||||
ech = e_s[ch, :n]
|
||||
ech_z = (ech - ech.mean()) / (ech.std() + 1e-12)
|
||||
c = np.correlate(a1_z, ech_z, mode="full")
|
||||
c_mid = len(c) // 2
|
||||
seg = c[c_mid - max_lag:c_mid + max_lag + 1]
|
||||
corr_avg += np.abs(seg)
|
||||
corr_avg /= e_s.shape[0]
|
||||
best_lag = np.argmax(corr_avg) - max_lag
|
||||
return int(best_lag)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
print("=" * 60, flush=True)
|
||||
print("Zone-wise CCD: force + signature per spatial zone", flush=True)
|
||||
print("=" * 60, flush=True)
|
||||
|
||||
out_dir = os.path.join(DATA_DIR, "ccd")
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
all_results = {}
|
||||
|
||||
scene_types = ["illusion_0.75L", "illusion_1.0L"]
|
||||
zones = _define_zones()
|
||||
|
||||
for scene_type in scene_types:
|
||||
print(f"\n{'=' * 60}", flush=True)
|
||||
print(f"Processing: {scene_type}", flush=True)
|
||||
print(f"{'=' * 60}", flush=True)
|
||||
|
||||
# ---- 1. Load correction fields ----
|
||||
t0 = time.time()
|
||||
corr = compute_correction(scene_type)
|
||||
dq_ctl = corr["dq_ctl"]
|
||||
dq_tar = corr["dq_tar"]
|
||||
diam = corr.get("diam")
|
||||
t_elapsed = time.time() - t0
|
||||
|
||||
if dq_ctl is None:
|
||||
print(" dq_ctl is None — cannot proceed.", flush=True)
|
||||
continue
|
||||
if dq_tar is None:
|
||||
print(" dq_tar is None — cannot proceed.", flush=True)
|
||||
continue
|
||||
|
||||
N = dq_ctl["ux"].shape[0]
|
||||
print(f" N={N}, diam={diam}, load_time={t_elapsed:.1f}s", flush=True)
|
||||
|
||||
# ---- 2. Build full snapshot matrices ----
|
||||
Q_ctl = dict_to_field_matrix(dq_ctl) # (2*NX*NY, N)
|
||||
Q_tar = dict_to_field_matrix(dq_tar) # (2*NX*NY, N_tar)
|
||||
print(f" Q_ctl: {Q_ctl.shape}, Q_tar: {Q_tar.shape}", flush=True)
|
||||
|
||||
# ---- 3. Extract global observables (unmasked) ----
|
||||
# Force observable (SigmaFy)
|
||||
frc = dq_ctl.get("forces")
|
||||
if frc is None:
|
||||
print(" No force data — skipping.", flush=True)
|
||||
continue
|
||||
y_force = make_force_obs(frc, scene_type, mode="fy")[:, :N] # (1, N)
|
||||
|
||||
# Sensor error e_s = sensors_ctl - sensors_tar
|
||||
sensors_ctl = dq_ctl.get("sensors")
|
||||
sensors_tar = dq_tar.get("sensors")
|
||||
if sensors_ctl is None or sensors_tar is None:
|
||||
print(" Sensor data incomplete — skipping.", flush=True)
|
||||
continue
|
||||
n_min = min(sensors_ctl.shape[0], sensors_tar.shape[0], N)
|
||||
e_s = (sensors_ctl[:n_min] - sensors_tar[:n_min]).T # (6, N)
|
||||
|
||||
print(f" y_force: {y_force.shape}, e_s: {e_s.shape}", flush=True)
|
||||
|
||||
# ---- 4. Process each zone ----
|
||||
for zname, zmask in zones.items():
|
||||
n_masked = int(zmask.sum())
|
||||
print(f"\n --- Zone: {zname} (N_grid={n_masked}) ---", flush=True)
|
||||
|
||||
# Mask snapshot matrices
|
||||
Q_ctl_m, _ = mask_field_matrix(Q_ctl, NY, NX, zmask)
|
||||
Q_tar_m, _ = mask_field_matrix(Q_tar, NY, NX, zmask)
|
||||
n_field = Q_ctl_m.shape[0]
|
||||
print(f" Masked field dim: {n_field}", flush=True)
|
||||
|
||||
# Build target-only POD basis from masked dq_tar
|
||||
mf_tar, modes_tar, sv_tar, _ = compute_pod(Q_tar_m)
|
||||
en_tar = cumulative_energy(sv_tar)
|
||||
e95 = int(np.searchsorted(en_tar, 0.95) + 1) if len(en_tar) > 0 else 0
|
||||
print(f" Target POD: E95={e95}, "
|
||||
f"N_modes={len(sv_tar)}", flush=True)
|
||||
|
||||
# Project masked dq_ctl into masked target basis
|
||||
proj_mean = mf_tar[:, None]
|
||||
a_ctl_all = modes_tar.T @ (Q_ctl_m - proj_mean).astype(np.float64)
|
||||
a_r = a_ctl_all[:R, :] # (R, N)
|
||||
|
||||
# ---- Force-CCD (SigmaFy) ----
|
||||
W_f, sig_f, _, _, _, _ = compute_reduced_ccd(
|
||||
a_r, y_force, Q_delay=CCD_Q)
|
||||
en_f = cumulative_energy(sig_f)
|
||||
m80_f = int(np.searchsorted(en_f, 0.80) + 1) if len(en_f) > 0 else 0
|
||||
frc_key = f"{scene_type}_{zname}_force_fy_r{R}"
|
||||
all_results[frc_key] = {
|
||||
"scene": scene_type,
|
||||
"zone": zname,
|
||||
"r": R,
|
||||
"N_masked_grid": n_masked,
|
||||
"m80": m80_f,
|
||||
"N_modes": int(sig_f.size),
|
||||
"sigma_top3": [float(sig_f[i])
|
||||
for i in range(min(3, len(sig_f)))],
|
||||
}
|
||||
s1_f = sig_f[0]
|
||||
s2_f = sig_f[1] if len(sig_f) > 1 else float('nan')
|
||||
s3_f = sig_f[2] if len(sig_f) > 2 else float('nan')
|
||||
print(f" force_fy r={R}: m80={m80_f}, "
|
||||
f"s1={s1_f:.4f}, s2={s2_f:.4f}, s3={s3_f:.4f}",
|
||||
flush=True)
|
||||
|
||||
# ---- Compute tau_corr for this zone ----
|
||||
tau_corr = compute_tau_corr(a_ctl_all, e_s, max_lag=12)
|
||||
tau_candidates = [("tau0", 0), ("tau_corr", tau_corr)]
|
||||
print(f" tau_corr = {tau_corr}", flush=True)
|
||||
|
||||
# ---- Signature-CCD ----
|
||||
for tau_label, tau in tau_candidates:
|
||||
# Shift sensor error forward by tau
|
||||
if tau >= 0:
|
||||
y_sig = e_s[:, tau: tau + N]
|
||||
a_r_aligned = a_r[:, :N - tau] if tau > 0 else a_r
|
||||
else:
|
||||
y_sig = e_s[:, :N + tau]
|
||||
a_r_aligned = a_r[:, -tau:]
|
||||
|
||||
y_sig_aligned = y_sig[:, :a_r_aligned.shape[1]]
|
||||
|
||||
if y_sig_aligned.shape[1] < CCD_Q:
|
||||
print(f" tau={tau}: too few samples "
|
||||
f"({y_sig_aligned.shape[1]}), skipping", flush=True)
|
||||
continue
|
||||
|
||||
W_s, sig_s, _, _, _, _ = compute_reduced_ccd(
|
||||
a_r_aligned, y_sig_aligned, Q_delay=CCD_Q)
|
||||
|
||||
en_s = cumulative_energy(sig_s)
|
||||
m80_s = (int(np.searchsorted(en_s, 0.80) + 1)
|
||||
if len(en_s) > 0 else 0)
|
||||
sig_key = f"{scene_type}_{zname}_sig_{tau_label}_r{R}"
|
||||
all_results[sig_key] = {
|
||||
"scene": scene_type,
|
||||
"zone": zname,
|
||||
"r": R,
|
||||
"tau": tau,
|
||||
"N_masked_grid": n_masked,
|
||||
"m80": m80_s,
|
||||
"N_modes": int(sig_s.size),
|
||||
"sigma_top3": [float(sig_s[i])
|
||||
for i in range(min(3, len(sig_s)))],
|
||||
}
|
||||
s1_s = sig_s[0]
|
||||
s2_s = sig_s[1] if len(sig_s) > 1 else float('nan')
|
||||
s3_s = sig_s[2] if len(sig_s) > 2 else float('nan')
|
||||
print(f" sig_{tau_label}: m80={m80_s}, "
|
||||
f"s1={s1_s:.4f}, s2={s2_s:.4f}, s3={s3_s:.4f}",
|
||||
flush=True)
|
||||
|
||||
# ---- Overlap O(force, sig) ----
|
||||
w_f0 = W_f[:, 0] / (np.linalg.norm(W_f[:, 0]) + 1e-12)
|
||||
w_s0 = W_s[:, 0] / (np.linalg.norm(W_s[:, 0]) + 1e-12)
|
||||
overlap = float(abs(w_f0 @ w_s0))
|
||||
|
||||
ov_key = f"{scene_type}_{zname}_O_force_vs_sig_{tau_label}_r{R}"
|
||||
all_results[ov_key] = {
|
||||
"scene": scene_type,
|
||||
"zone": zname,
|
||||
"r": R,
|
||||
"tau": tau,
|
||||
"overlap": overlap,
|
||||
}
|
||||
print(f" O(force, sig)_{tau_label}: {overlap:.4f}",
|
||||
flush=True)
|
||||
|
||||
# ---- 5. Save results ----
|
||||
ccd_path = os.path.join(out_dir, "zone_ccd_results.json")
|
||||
with open(ccd_path, "w") as f:
|
||||
json.dump(all_results, f, indent=2)
|
||||
print(f"\nSaved {len(all_results)} entries to {ccd_path}", flush=True)
|
||||
|
||||
# ---- 6. Print summary table ----
|
||||
print("\n" + "=" * 80, flush=True)
|
||||
print("SUMMARY: Zone CCD Results", flush=True)
|
||||
print("=" * 80, flush=True)
|
||||
|
||||
for scene_type in scene_types:
|
||||
print(f"\n{'=' * 70}", flush=True)
|
||||
print(f" {scene_type}", flush=True)
|
||||
print(f"{'=' * 70}", flush=True)
|
||||
header = (
|
||||
f" {'Zone':<15s} | {'N_masked':>9s} | "
|
||||
f"{'force_fy':>20s} | {'sig_tau0':>20s} | {'sig_tau_corr':>22s} | "
|
||||
f"{'O_0':>6s} | {'O_corr':>6s}"
|
||||
)
|
||||
sep = " " + "-" * (15 + 9 + 20 + 20 + 22 + 6 + 6 + 12)
|
||||
print(header, flush=True)
|
||||
print(sep, flush=True)
|
||||
|
||||
for zname in zones:
|
||||
n_pts = all_results.get(
|
||||
f"{scene_type}_{zname}_force_fy_r{R}", {}
|
||||
).get("N_masked_grid", 0)
|
||||
|
||||
fd = all_results.get(f"{scene_type}_{zname}_force_fy_r{R}", {})
|
||||
sd0 = all_results.get(f"{scene_type}_{zname}_sig_tau0_r{R}", {})
|
||||
sdc = all_results.get(f"{scene_type}_{zname}_sig_tau_corr_r{R}", {})
|
||||
od0 = all_results.get(
|
||||
f"{scene_type}_{zname}_O_force_vs_sig_tau0_r{R}", {})
|
||||
odc = all_results.get(
|
||||
f"{scene_type}_{zname}_O_force_vs_sig_tau_corr_r{R}", {})
|
||||
|
||||
def fmt_ccd(d):
|
||||
m = d.get("m80", "-")
|
||||
s1 = d.get("sigma_top3", ["-"])[0]
|
||||
if isinstance(s1, float):
|
||||
return f"m80={m} s1={s1:.4f}"
|
||||
return f"m80={m} s1={s1}"
|
||||
|
||||
def fmt_ov(d):
|
||||
v = d.get("overlap", "-")
|
||||
if isinstance(v, float):
|
||||
return f"{v:.4f}"
|
||||
return f"{v}"
|
||||
|
||||
print(
|
||||
f" {zname:<15s} | {n_pts:>9d} | "
|
||||
f"{fmt_ccd(fd):>20s} | {fmt_ccd(sd0):>20s} | "
|
||||
f"{fmt_ccd(sdc):>22s} | {fmt_ov(od0):>6s} | "
|
||||
f"{fmt_ov(odc):>6s}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
print(f"\n{'=' * 80}", flush=True)
|
||||
print("Done. Zone CCD analysis complete.", flush=True)
|
||||
print(f"{'=' * 80}", flush=True)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@ -1,419 +0,0 @@
|
||||
"""1.5L auxiliary analysis: raw diagnostics, spectrum, windowed periodicity, POD.
|
||||
|
||||
Usage:
|
||||
python3 src/CCD_analysis/scripts/analyze_15L.py
|
||||
|
||||
Output:
|
||||
src/CCD_analysis/data/figures/15L_*.png
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from collections import deque
|
||||
|
||||
import numpy as np
|
||||
|
||||
import matplotlib
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
_SRC = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
if _SRC not in sys.path:
|
||||
sys.path.insert(0, _SRC)
|
||||
|
||||
from CCD_analysis.configs import DATA_DIR, SCENES
|
||||
from CCD_analysis.utils.resampling import (
|
||||
detect_dominant_frequency, detect_cycle_stability, phase_resample,
|
||||
compute_pod, cumulative_energy, compute_reduced_ccd,
|
||||
)
|
||||
|
||||
FIG_DIR = os.path.join(DATA_DIR, "figures")
|
||||
os.makedirs(FIG_DIR, exist_ok=True)
|
||||
|
||||
# Scene config — read from configs.py, not hardcoded
|
||||
_SCENE = SCENES["illusion_1.5L"]
|
||||
SI = _SCENE["sample_interval"] # 800 for 1.5L
|
||||
CONV_LEN = _SCENE.get("conv_len", 36) # Illusion uses 36
|
||||
FIFO_LEN = 150
|
||||
|
||||
|
||||
def load_controlled(name):
|
||||
p = os.path.join(DATA_DIR, "illusion", name, "controlled.npz")
|
||||
d = np.load(p)
|
||||
return d["sensors"], d["forces"], d["actions"]
|
||||
|
||||
|
||||
def load_target(name):
|
||||
p = os.path.join(DATA_DIR, "target_cylinder", name, "sensors.npz")
|
||||
d = np.load(p)
|
||||
return d["sensors"], d["forces"]
|
||||
|
||||
|
||||
def load_pinball_sensors():
|
||||
p = os.path.join(DATA_DIR, "pinball", "pinball", "sensors.npz")
|
||||
d = np.load(p)
|
||||
return d["sensors"]
|
||||
|
||||
|
||||
def load_resampled_fields(name):
|
||||
p = os.path.join(DATA_DIR, "resampled", name, "resampled.npz")
|
||||
d = np.load(p)
|
||||
return d["ux"], d["uy"]
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Task 3.1: Raw time-series diagnostics
|
||||
# ============================================================
|
||||
def task_31():
|
||||
print("=== Task 3.1: Raw time-series diagnostics ===", flush=True)
|
||||
sens_i, forc_i, act_i = load_controlled("illusion_1.5L")
|
||||
sens_t, forc_t = load_target("target_cylinder_1.5L")
|
||||
|
||||
n_plot = min(400, len(sens_i))
|
||||
t = np.arange(n_plot) * SI / 1000 # time in T0 units (1 T0 = 1000 steps)
|
||||
|
||||
fig, axes = plt.subplots(3, 1, figsize=(14, 10))
|
||||
|
||||
# Sensors
|
||||
ax = axes[0]
|
||||
for ch in range(6):
|
||||
ax.plot(t, sens_i[:n_plot, ch], label=f"ill_s{ch}", alpha=0.7)
|
||||
ax.plot(t, sens_t[:n_plot, 3], "k--", label="target_s1_v", linewidth=2)
|
||||
ax.set_ylabel("Velocity (lattice)")
|
||||
ax.set_title("1.5L Sensors: Illusion vs Target")
|
||||
ax.legend(fontsize=7, ncol=3)
|
||||
ax.grid(True, alpha=0.3)
|
||||
|
||||
# Forces
|
||||
ax = axes[1]
|
||||
for ch in range(6):
|
||||
ax.plot(t, forc_i[:n_plot, ch], label=f"ill_F{ch}", alpha=0.7)
|
||||
ax.plot(t, forc_t[:n_plot, 0], "k--", label="target_Fx", linewidth=2)
|
||||
ax.plot(t, forc_t[:n_plot, 1], "k:", label="target_Fy", linewidth=2)
|
||||
ax.set_ylabel("Force (lattice)")
|
||||
ax.set_title("1.5L Forces")
|
||||
ax.legend(fontsize=7, ncol=3)
|
||||
ax.grid(True, alpha=0.3)
|
||||
|
||||
# Actions
|
||||
ax = axes[2]
|
||||
for ch in range(3):
|
||||
ax.plot(t, act_i[:n_plot, ch], label=f"Omega_{ch}")
|
||||
ax.set_xlabel("Time (T0 units)")
|
||||
ax.set_ylabel("Omega (normalised)")
|
||||
ax.set_title("1.5L Actions (DRL output, [-1, 1])")
|
||||
ax.legend()
|
||||
ax.grid(True, alpha=0.3)
|
||||
|
||||
plt.tight_layout()
|
||||
path = os.path.join(FIG_DIR, "15L_raw_timeseries.png")
|
||||
fig.savefig(path, dpi=150)
|
||||
plt.close(fig)
|
||||
print(f" Saved: {path}", flush=True)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Task 3.2: Spectrum analysis
|
||||
# ============================================================
|
||||
def task_32():
|
||||
print("=== Task 3.2: Spectrum analysis ===", flush=True)
|
||||
sens_i, forc_i, act_i = load_controlled("illusion_1.5L")
|
||||
sens_t, forc_t = load_target("target_cylinder_1.5L")
|
||||
sens_p = load_pinball_sensors()
|
||||
|
||||
fig, axes = plt.subplots(2, 2, figsize=(14, 8))
|
||||
|
||||
def add_spectrum(signal, ax, label, color, ls="-"):
|
||||
y = signal - np.mean(signal)
|
||||
n = len(y)
|
||||
window = np.hanning(n)
|
||||
spec = np.abs(np.fft.rfft(y * window)) ** 2
|
||||
freqs = np.fft.rfftfreq(n, d=SI)
|
||||
ax.plot(freqs[1:], spec[1:], label=label, color=color, ls=ls, alpha=0.8)
|
||||
|
||||
# Sensor v component (channel 1 = uy of center sensor)
|
||||
# In controlled.npz: sensors[:, 1] = sensor1_uy (center sensor v)
|
||||
ax = axes[0, 0]
|
||||
add_spectrum(sens_t[:500, 1], ax, "Target", "red")
|
||||
add_spectrum(sens_i[:500, 1], ax, "Illusion 1.5L", "blue")
|
||||
add_spectrum(sens_p[:500, 1], ax, "Pinball (uncontrolled)", "green")
|
||||
ax.set_xlim(0, 0.005)
|
||||
ax.set_xlabel("Frequency (1/step)")
|
||||
ax.set_ylabel("Power")
|
||||
ax.set_title("Spectrum: sensor v (center)")
|
||||
ax.legend()
|
||||
ax.grid(True, alpha=0.3)
|
||||
|
||||
# Actions spectrum
|
||||
ax = axes[0, 1]
|
||||
for ch in range(3):
|
||||
add_spectrum(act_i[:500, ch], ax, f"Action {ch}", f"C{ch+1}")
|
||||
ax.set_xlim(0, 0.005)
|
||||
ax.set_xlabel("Frequency (1/step)")
|
||||
ax.set_ylabel("Power")
|
||||
ax.set_title("1.5L Action spectrum")
|
||||
ax.legend()
|
||||
ax.grid(True, alpha=0.3)
|
||||
|
||||
# Force spectra
|
||||
ax = axes[1, 0]
|
||||
add_spectrum(forc_t[:500, 0], ax, "Target Fx", "red")
|
||||
add_spectrum(forc_i[:500, 0] + forc_i[:500, 2] + forc_i[:500, 4], ax, "Illusion total Fx", "blue")
|
||||
ax.set_xlim(0, 0.005)
|
||||
ax.set_xlabel("Frequency (1/step)")
|
||||
ax.set_ylabel("Power")
|
||||
ax.set_title("Force Fx spectrum")
|
||||
ax.legend()
|
||||
ax.grid(True, alpha=0.3)
|
||||
|
||||
# Strouhal comparison
|
||||
ax = axes[1, 1]
|
||||
for name, sig, c in [
|
||||
("Target", sens_t[:500, 1], "red"),
|
||||
("Illusion", sens_i[:500, 1], "blue"),
|
||||
("Pinball", sens_p[:500, 1], "green"),
|
||||
]:
|
||||
f_dom, T_dom, _ = detect_dominant_frequency(sig, SI)
|
||||
St = f_dom * (1.5 * 20) / 0.01 # D=1.5*L0, U0=0.01
|
||||
ax.bar(name, St, color=c, alpha=0.6, label=f"St={St:.3f}")
|
||||
ax.set_ylabel("Strouhal number")
|
||||
ax.set_title("Dominant Strouhal comparison (1.5L ref)")
|
||||
ax.grid(True, alpha=0.3)
|
||||
|
||||
plt.tight_layout()
|
||||
path = os.path.join(FIG_DIR, "15L_spectrum.png")
|
||||
fig.savefig(path, dpi=150)
|
||||
plt.close(fig)
|
||||
print(f" Saved: {path}", flush=True)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Task 3.3: Windowed periodicity
|
||||
# ============================================================
|
||||
def task_33():
|
||||
print("=== Task 3.3: Windowed periodicity ===", flush=True)
|
||||
sens_i, forc_i, act_i = load_controlled("illusion_1.5L")
|
||||
signal = sens_i[:, 1] # center sensor v
|
||||
|
||||
window = 200
|
||||
stride = 20
|
||||
n_windows = (len(signal) - window) // stride
|
||||
|
||||
cv_vals, T_vals, f_vals, t_centers = [], [], [], []
|
||||
for w in range(n_windows):
|
||||
seg = signal[w * stride:w * stride + window]
|
||||
cv_T, mean_T, _ = detect_cycle_stability(seg, SI)
|
||||
f_dom, T_dom, _ = detect_dominant_frequency(seg, SI)
|
||||
cv_vals.append(cv_T)
|
||||
T_vals.append(mean_T)
|
||||
f_vals.append(f_dom)
|
||||
t_centers.append((w * stride + window // 2) * SI / 1000)
|
||||
|
||||
fig, axes = plt.subplots(3, 1, figsize=(14, 8), sharex=True)
|
||||
|
||||
ax = axes[0]
|
||||
ax.plot(t_centers, cv_vals, "o-", markersize=3)
|
||||
ax.axhline(0.10, color="r", ls="--", label="strict gate")
|
||||
ax.axhline(0.12, color="orange", ls="--", label="relaxed gate")
|
||||
ax.set_ylabel("CV_T")
|
||||
ax.set_title("1.5L Windowed cycle stability (window=200 steps)")
|
||||
ax.legend()
|
||||
ax.grid(True, alpha=0.3)
|
||||
|
||||
ax = axes[1]
|
||||
ax.plot(t_centers, T_vals, "o-", markersize=3, color="green")
|
||||
ax.set_ylabel("Mean period (steps)")
|
||||
ax.axhline(800 * 24.2, color="gray", ls=":", label="expected") # N_raw*SI
|
||||
ax.legend()
|
||||
ax.grid(True, alpha=0.3)
|
||||
|
||||
ax = axes[2]
|
||||
ax.plot(t_centers, f_vals, "o-", markersize=3, color="purple")
|
||||
ax.set_xlabel("Time (T0 units)")
|
||||
ax.set_ylabel("Freq (1/step)")
|
||||
ax.grid(True, alpha=0.3)
|
||||
|
||||
plt.tight_layout()
|
||||
path = os.path.join(FIG_DIR, "15L_windowed_periodicity.png")
|
||||
fig.savefig(path, dpi=150)
|
||||
plt.close(fig)
|
||||
print(f" Saved: {path}", flush=True)
|
||||
|
||||
# Find stable windows
|
||||
stable_windows = [(t, cv, T) for t, cv, T in zip(t_centers, cv_vals, T_vals) if cv < 0.10]
|
||||
print(f" Stable windows (CV_T<0.10): {len(stable_windows)}/{n_windows}", flush=True)
|
||||
return stable_windows
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Task 3.4: POD projection attractor comparison
|
||||
# ============================================================
|
||||
def task_34():
|
||||
print("=== Task 3.4: POD attractor comparison ===", flush=True)
|
||||
|
||||
# Load resampled data for all diameters
|
||||
data = {}
|
||||
for diam in [0.75, 1.0, 1.5]:
|
||||
for kind in ["illusion", "target_cylinder"]:
|
||||
name = f"{kind}_{diam}L"
|
||||
d = load_resampled_fields(name)
|
||||
if d[0] is not None:
|
||||
data[name] = d
|
||||
|
||||
# Build 1.5L POD basis (target + illusion)
|
||||
name_t = "target_cylinder_1.5L"
|
||||
name_i = "illusion_1.5L"
|
||||
ux_t, uy_t = data[name_t]
|
||||
ux_i, uy_i = data[name_i]
|
||||
|
||||
snaps = []
|
||||
for ux, uy in [(ux_t, uy_t), (ux_i, uy_i)]:
|
||||
for c in range(ux.shape[0]):
|
||||
for p in range(ux.shape[1]):
|
||||
snaps.append(np.concatenate([ux[c, p].ravel(), uy[c, p].ravel()]))
|
||||
Q = np.column_stack(snaps)
|
||||
mf, modes, sv, coeffs = compute_pod(Q)
|
||||
|
||||
fig, axes = plt.subplots(1, 3, figsize=(15, 4))
|
||||
|
||||
for idx, (diam, color) in enumerate([(0.75, "blue"), (1.0, "green"), (1.5, "red")]):
|
||||
name = f"illusion_{diam}L"
|
||||
if name not in data:
|
||||
continue
|
||||
ux, uy = data[name]
|
||||
proj_snaps = []
|
||||
for c in range(ux.shape[0]):
|
||||
for p in range(ux.shape[1]):
|
||||
proj_snaps.append(np.concatenate([ux[c, p].ravel(), uy[c, p].ravel()]))
|
||||
Qp = np.column_stack(proj_snaps)
|
||||
a = modes[:, :6].T @ (Qp - mf[:, None])
|
||||
|
||||
ax = axes[idx]
|
||||
ax.plot(a[0, :], a[1, :], ".", color=color, markersize=2, alpha=0.5)
|
||||
ax.plot(a[0, :96], a[1, :96], "-", color=color, alpha=0.3, linewidth=0.5)
|
||||
ax.set_xlabel("a1")
|
||||
ax.set_ylabel("a2")
|
||||
ax.set_title(f"Illusion {diam}L in 1.5L POD basis")
|
||||
ax.grid(True, alpha=0.3)
|
||||
ax.set_aspect("equal")
|
||||
|
||||
plt.tight_layout()
|
||||
path = os.path.join(FIG_DIR, "15L_pod_attractor_comparison.png")
|
||||
fig.savefig(path, dpi=150)
|
||||
plt.close(fig)
|
||||
print(f" Saved: {path}", flush=True)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Task 3.5: Short-window CCD (if stable windows found)
|
||||
# ============================================================
|
||||
def task_35(stable_windows_info):
|
||||
print("=== Task 3.5: Short-window CCD ===", flush=True)
|
||||
if len(stable_windows_info) < 3:
|
||||
print(" Not enough stable windows, skipping short-window CCD", flush=True)
|
||||
return
|
||||
|
||||
sens_i, forc_i, act_i = load_controlled("illusion_1.5L")
|
||||
sens_t, forc_t = load_target("target_cylinder_1.5L")
|
||||
|
||||
# We need fields for CCD — load from resampled
|
||||
ux_i, uy_i = load_resampled_fields("illusion_1.5L")
|
||||
ux_t, uy_t = load_resampled_fields("target_cylinder_1.5L")
|
||||
|
||||
# Build reference POD (all 4 cycles of target + illusion)
|
||||
snaps = []
|
||||
for ux, uy in [(ux_t, uy_t), (ux_i, uy_i)]:
|
||||
for c in range(ux.shape[0]):
|
||||
for p in range(ux.shape[1]):
|
||||
snaps.append(np.concatenate([ux[c, p].ravel(), uy[c, p].ravel()]))
|
||||
Q = np.column_stack(snaps)
|
||||
mf, modes, sv, coeffs = compute_pod(Q)
|
||||
modes_r = modes[:, :6]
|
||||
|
||||
# Use 4-cycle resampled data for CCD (as in standard pipeline)
|
||||
frc_i = np.load(os.path.join(DATA_DIR, "resampled", "illusion_1.5L", "resampled.npz"))["forces"]
|
||||
frc_t = np.load(os.path.join(DATA_DIR, "resampled", "target_cylinder_1.5L", "resampled.npz"))["forces"]
|
||||
|
||||
flat_i = frc_i.reshape(-1, frc_i.shape[-1]).T # (6, 96)
|
||||
flat_t = frc_t.reshape(-1, frc_t.shape[-1]).T
|
||||
|
||||
# Force observable
|
||||
y_i = np.vstack([flat_i[0] + flat_i[2] + flat_i[4],
|
||||
flat_i[1] + flat_i[3] + flat_i[5]])
|
||||
y_t = np.vstack([flat_t[0], flat_t[1]])
|
||||
|
||||
# Project illusion fields
|
||||
proj = []
|
||||
for c in range(ux_i.shape[0]):
|
||||
for p in range(ux_i.shape[1]):
|
||||
proj.append(np.concatenate([ux_i[c, p].ravel(), uy_i[c, p].ravel()]))
|
||||
Qi = np.column_stack(proj)
|
||||
a_i = modes_r.T @ (Qi - mf[:, None])
|
||||
|
||||
W_i, sig_i, _, z_i, _, _ = compute_reduced_ccd(a_i, y_i[:, :a_i.shape[1]], Q_delay=12)
|
||||
|
||||
# Compare with target
|
||||
proj_t = []
|
||||
for c in range(ux_t.shape[0]):
|
||||
for p in range(ux_t.shape[1]):
|
||||
proj_t.append(np.concatenate([ux_t[c, p].ravel(), uy_t[c, p].ravel()]))
|
||||
Qt = np.column_stack(proj_t)
|
||||
a_t = modes_r.T @ (Qt - mf[:, None])
|
||||
W_t, sig_t, _, z_t, _, _ = compute_reduced_ccd(a_t, y_t[:, :a_t.shape[1]], Q_delay=12)
|
||||
|
||||
# Overlap
|
||||
n = min(W_i.shape[1], W_t.shape[1], 5)
|
||||
ov = [float(abs(
|
||||
W_i[:, k] / (np.linalg.norm(W_i[:, k]) + 1e-12) @
|
||||
W_t[:, k] / (np.linalg.norm(W_t[:, k]) + 1e-12)
|
||||
)) for k in range(n)]
|
||||
print(f" 1.5L short-window force-CCD O(target, illusion): O1={ov[0]:.4f}, O2={ov[1]:.4f}", flush=True)
|
||||
|
||||
fig, axes = plt.subplots(1, 2, figsize=(12, 4))
|
||||
# z_1(t) comparison
|
||||
ax = axes[0]
|
||||
ax.plot(z_i[0, :], label="Illusion z_1")
|
||||
ax.plot(z_t[0, :], "--", label="Target z_1")
|
||||
ax.set_xlabel("Flat sample index")
|
||||
ax.set_ylabel("CCD temporal coeff z_1")
|
||||
ax.set_title("1.5L Force-CCD: z_1(t)")
|
||||
ax.legend()
|
||||
ax.grid(True, alpha=0.3)
|
||||
|
||||
# Sigma decay
|
||||
ax = axes[1]
|
||||
ax.semilogy(sig_i, "o-", label="Illusion", markersize=4)
|
||||
ax.semilogy(sig_t, "s-", label="Target", markersize=4)
|
||||
ax.set_xlabel("Mode index")
|
||||
ax.set_ylabel("Singular value")
|
||||
ax.set_title("1.5L Force-CCD: singular value decay")
|
||||
ax.legend()
|
||||
ax.grid(True, alpha=0.3)
|
||||
|
||||
path = os.path.join(FIG_DIR, "15L_short_window_ccd.png")
|
||||
fig.savefig(path, dpi=150)
|
||||
plt.close(fig)
|
||||
print(f" Saved: {path}", flush=True)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Main
|
||||
# ============================================================
|
||||
def main():
|
||||
print("=" * 60, flush=True)
|
||||
print("1.5L Auxiliary Analysis", flush=True)
|
||||
print("=" * 60, flush=True)
|
||||
|
||||
task_31()
|
||||
task_32()
|
||||
stable_info = task_33()
|
||||
task_34()
|
||||
task_35(stable_info)
|
||||
|
||||
print("\nDone. All figures saved to", FIG_DIR, flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -1,103 +0,0 @@
|
||||
"""Collect empty channel reference flow (parabolic profile, no bodies).
|
||||
|
||||
This provides the "target" reference for steady cloak metrics —
|
||||
the clean channel flow that the cloak should restore.
|
||||
|
||||
Usage:
|
||||
conda run -n pycuda_3_10 python scripts/collect_empty_channel.py --device 2
|
||||
|
||||
Output: data/target_channel/target_channel/
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
|
||||
_REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
|
||||
if _REPO not in sys.path:
|
||||
sys.path.insert(0, _REPO)
|
||||
_SRC = os.path.join(_REPO, "src")
|
||||
if _SRC not in sys.path:
|
||||
sys.path.insert(0, _SRC)
|
||||
|
||||
from LegacyCelerisLab import FlowField
|
||||
|
||||
from CCD_analysis.configs import get_scene, data_dir_for_scene, LEGACY_CFG_DIR
|
||||
from CCD_analysis.utils.cfd_interface import (
|
||||
load_legacy_configs, get_velocity_field, save_vorticity_png,
|
||||
vorticity_from_ddf,
|
||||
)
|
||||
|
||||
|
||||
def collect():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--device", type=int, default=2)
|
||||
args = ap.parse_args()
|
||||
|
||||
cfg = get_scene("target_channel")
|
||||
out_dir = data_dir_for_scene("target_channel")
|
||||
cuda_cfg, field_cfg = load_legacy_configs(LEGACY_CFG_DIR)
|
||||
field_cfg = field_cfg._replace(viscosity=float(cfg["nu"]))
|
||||
|
||||
ff = FlowField(field_cfg, cuda_cfg, device_id=args.device)
|
||||
from CCD_analysis.configs import L0, CENTER_Y
|
||||
l0 = L0
|
||||
|
||||
# 3 dummy sensors (no cylinders — just empty channel)
|
||||
for y_off in [2.0, 0.0, -2.0]:
|
||||
ff.add_sensor((40.0 * l0, CENTER_Y + y_off * l0, 0.0), l0 / 4.0)
|
||||
|
||||
n_obj = 3
|
||||
stabilize = int(4 * 1280 / cfg["u0"])
|
||||
print(f"Stabilising empty channel ({stabilize} steps)...")
|
||||
ff.run(stabilize, np.zeros(n_obj, dtype=np.float32))
|
||||
|
||||
n_steps = 100 # steady-state, 100 is more than enough
|
||||
si = cfg["sample_interval"]
|
||||
sens_list = []
|
||||
ux_list, uy_list = [], []
|
||||
|
||||
for step in range(n_steps):
|
||||
ff.run(si, np.zeros(n_obj, dtype=np.float32))
|
||||
obs = ff.obs.copy()
|
||||
sens_list.append(obs[0:6])
|
||||
ux, uy = get_velocity_field(ff, u0=cfg["u0"])
|
||||
ux_list.append(ux)
|
||||
uy_list.append(uy)
|
||||
|
||||
# Save
|
||||
np.savez_compressed(os.path.join(out_dir, "fields.npz"),
|
||||
ux=np.stack(ux_list), uy=np.stack(uy_list))
|
||||
np.savez(os.path.join(out_dir, "sensors.npz"),
|
||||
sensors=np.array(sens_list, dtype=np.float32))
|
||||
|
||||
# Validate: should be nearly steady
|
||||
sens_arr = np.array(sens_list, dtype=np.float32)
|
||||
print(f" Sensor mean: {np.mean(sens_arr, axis=0)}")
|
||||
print(f" Sensor std: {np.std(sens_arr, axis=0)}")
|
||||
total_std = float(np.sqrt(np.mean(sens_arr ** 2)))
|
||||
print(f" Total std (should be near 0 for steady channel): {total_std:.6f}")
|
||||
|
||||
# Vorticity
|
||||
omega = vorticity_from_ddf(ff, u0=cfg["u0"])
|
||||
save_vorticity_png(os.path.join(out_dir, "vorticity.png"),
|
||||
omega, title="Empty channel Re=100")
|
||||
|
||||
# Meta
|
||||
meta = dict(cfg, n_steps=n_steps)
|
||||
with open(os.path.join(out_dir, "meta.json"), "w") as f:
|
||||
json.dump(meta, f, indent=2)
|
||||
print(f"Empty channel data saved to {out_dir}")
|
||||
|
||||
del ff
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
t0 = time.time()
|
||||
collect()
|
||||
print(f"Time: {time.time() - t0:.1f}s")
|
||||
@ -1,13 +1,9 @@
|
||||
"""Illusion DRL inference (all S_DIM=14, regardless of model name).
|
||||
|
||||
All illusion models use 14-D observation space
|
||||
(sensors(6) + forces(6) + target_cd(1) + target_cl(1)),
|
||||
with target forces reconstructed from harmonics.
|
||||
"""1L Illusion DRL inference (2U=0.02).
|
||||
|
||||
Usage:
|
||||
conda run -n pycuda_3_10 python scripts/collect_illusion.py --device 2 --steps 500
|
||||
conda run -n pycuda_3_10 python scripts/collect_illusion.py --device 2 --steps 200
|
||||
|
||||
Output: data/illusion/{scene_name}/
|
||||
Output: data/illusion/illusion_1L/
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@ -23,13 +19,13 @@ 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)
|
||||
_ANALYSIS = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
if _ANALYSIS not in sys.path:
|
||||
sys.path.insert(0, _ANALYSIS)
|
||||
|
||||
from LegacyCelerisLab import FlowField
|
||||
|
||||
from CCD_analysis.configs import get_scene, get_scene_list, data_dir_for_scene, model_path_for_scene, LEGACY_CFG_DIR
|
||||
from CCD_analysis.configs import get_scene, data_dir_for_scene, model_path_for_scene, LEGACY_CFG_DIR
|
||||
from CCD_analysis.utils.cfd_interface import (
|
||||
load_legacy_configs, save_vorticity_png, vorticity_from_ddf,
|
||||
load_ppo_model, scale_action, get_velocity_field,
|
||||
@ -64,9 +60,7 @@ def run_single(scene_name: str, device_id: int, n_steps: int) -> dict:
|
||||
# === Target recording (separate FlowField) ===
|
||||
print("=== Target recording ===")
|
||||
ff_tgt = FlowField(field_cfg, cuda_cfg, device_id=device_id)
|
||||
tgt_radius = cfg["target_diameter"] * L0
|
||||
ff_tgt.add_cylinder((20.0 * L0, CENTER_Y, 0.0), tgt_radius)
|
||||
print(f" target cylinder: diameter={cfg['target_diameter']}L, radius={tgt_radius}", flush=True)
|
||||
ff_tgt.add_cylinder((20.0 * L0, CENTER_Y, 0.0), 1.0 * L0)
|
||||
for y_off in [2.0, 0.0, -2.0]:
|
||||
ff_tgt.add_sensor((30.0 * L0, CENTER_Y + y_off * L0, 0.0), L0 / 4.0)
|
||||
n_tgt = 4
|
||||
@ -120,9 +114,7 @@ def run_single(scene_name: str, device_id: int, n_steps: int) -> dict:
|
||||
json.dump(norm, f, indent=2)
|
||||
print(f" force_norm_fact={force_norm_fact:.6f}")
|
||||
|
||||
# Preset-action FIFO init (matches legacy_env_imit: [0,0,0,0,-1*U0,1*U0])
|
||||
# NOTE: this is NOT the same as action_bias([0,-2,2]). action_bias controls DRL
|
||||
# action scaling; preset_action is a fixed Omega array used to warm up the FIFO.
|
||||
# Bias FIFO (matches legacy_env_imit: [0,0,0,0,-1*U0,1*U0])
|
||||
ff.apply_ddf()
|
||||
bias = np.zeros(n_env, dtype=DATA_TYPE)
|
||||
bias[4] = -1.0 * u0
|
||||
@ -132,12 +124,6 @@ def run_single(scene_name: str, device_id: int, n_steps: int) -> dict:
|
||||
ff.run(si, bias)
|
||||
fifo.append(ff.obs.copy()[0:12])
|
||||
save_states_arr = np.array(fifo, dtype=DATA_TYPE)
|
||||
|
||||
# Save DDF+FIFO checkpoint for replay (state right after warmup, before step 0)
|
||||
ff.get_ddf()
|
||||
np.save(os.path.join(out_dir, "ddf_checkpoint.npy"), ff.ddf)
|
||||
np.save(os.path.join(out_dir, "fifo_checkpoint.npy"), save_states_arr)
|
||||
|
||||
ff.apply_ddf()
|
||||
|
||||
# === PPO inference ===
|
||||
@ -171,18 +157,13 @@ def run_single(scene_name: str, device_id: int, n_steps: int) -> dict:
|
||||
sens_c.append(obs_slice[0:6])
|
||||
forc_c.append(obs_slice[6:12])
|
||||
|
||||
# obs dimension depends on model type:
|
||||
# d1a3o12_* = 12-dim (forces + sens only)
|
||||
# d1a3o14_* = 14-dim (forces + sens + target_cd + target_cl)
|
||||
# 14-dim obs
|
||||
forces_norm = obs_slice[6:12] / force_norm_fact
|
||||
sens_norm = (obs_slice[0:6] - sens_deviation) / sens_norm_fact
|
||||
if s_dim == 14:
|
||||
target_recon = gen_target_states_at(step, target_harmonics)
|
||||
t_cd_n = float(target_recon[0]) / force_norm_fact
|
||||
t_cl_n = float(target_recon[1]) / force_norm_fact
|
||||
obs = np.clip(np.hstack([forces_norm, sens_norm, t_cd_n, t_cl_n]), -1.0, 1.0).astype(np.float32)
|
||||
else:
|
||||
obs = np.clip(np.hstack([forces_norm, sens_norm]), -1.0, 1.0).astype(np.float32)
|
||||
target_recon = gen_target_states_at(step, target_harmonics)
|
||||
t_cd_n = float(target_recon[0]) / force_norm_fact
|
||||
t_cl_n = float(target_recon[1]) / force_norm_fact
|
||||
obs = np.clip(np.hstack([forces_norm, sens_norm, t_cd_n, t_cl_n]), -1.0, 1.0).astype(np.float32)
|
||||
|
||||
# Reward
|
||||
sarr = np.array(fifo, dtype=np.float32)
|
||||
@ -239,25 +220,12 @@ def run_single(scene_name: str, device_id: int, n_steps: int) -> dict:
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--scene", type=str, default="illusion_1.0L",
|
||||
help="Scene name (illusion_0.75L, illusion_1.0L, illusion_1.5L)")
|
||||
ap.add_argument("--diameter", type=float, default=None,
|
||||
help="Diameter shortcut (0.75, 1.0, 1.5)")
|
||||
ap.add_argument("--device", type=int, default=2)
|
||||
ap.add_argument("--steps", type=int, default=200)
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.diameter is not None:
|
||||
scene_name = f"illusion_{args.diameter}L"
|
||||
else:
|
||||
scene_name = args.scene
|
||||
|
||||
if scene_name not in get_scene_list("illusion"):
|
||||
print(f"Unknown scene: {scene_name}. Available: {get_scene_list('illusion')}")
|
||||
return 1
|
||||
|
||||
t0 = time.time()
|
||||
r = run_single(scene_name, args.device, args.steps)
|
||||
r = run_single("illusion_1L", args.device, args.steps)
|
||||
print(f"Done in {time.time()-t0:.1f}s: sim={r['similarity']:.4f}")
|
||||
|
||||
|
||||
|
||||
@ -5,7 +5,7 @@ Uses utils/cfd_interface.py (copied and verified from SR_analysis).
|
||||
Usage:
|
||||
conda run -n pycuda_3_10 python scripts/collect_karman.py --device 2 --steps 200
|
||||
|
||||
Output: data/karman/karman_re200/
|
||||
Output: data/karman/karman_re100/
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@ -21,13 +21,13 @@ 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)
|
||||
_ANALYSIS = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
if _ANALYSIS not in sys.path:
|
||||
sys.path.insert(0, _ANALYSIS)
|
||||
|
||||
from LegacyCelerisLab import FlowField
|
||||
|
||||
from CCD_analysis.configs import get_scene, get_scene_list, data_dir_for_scene, model_path_for_scene, LEGACY_CFG_DIR
|
||||
from CCD_analysis.configs import get_scene, data_dir_for_scene, model_path_for_scene, LEGACY_CFG_DIR
|
||||
from CCD_analysis.utils.cfd_interface import (
|
||||
load_legacy_configs,
|
||||
build_karman_cloak_env, add_pinball, build_observation,
|
||||
@ -112,12 +112,6 @@ def run_single(scene_name: str, device_id: int, n_steps: int) -> dict:
|
||||
ff.context.pop()
|
||||
fifo.append(ff.obs.copy()[2:14])
|
||||
|
||||
# Save DDF+FIFO checkpoint for replay (state right after warmup, before step 0)
|
||||
save_states_arr = np.array(fifo, dtype=DATA_TYPE)
|
||||
ff.get_ddf()
|
||||
np.save(os.path.join(out_dir, "ddf_checkpoint.npy"), ff.ddf)
|
||||
np.save(os.path.join(out_dir, "fifo_checkpoint.npy"), save_states_arr)
|
||||
|
||||
sens_c, forc_c, act_c, rew_c = [], [], [], []
|
||||
obs = np.zeros(s_dim, dtype=np.float32)
|
||||
|
||||
@ -175,25 +169,12 @@ def run_single(scene_name: str, device_id: int, n_steps: int) -> dict:
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--scene", type=str, default="karman_re100",
|
||||
help="Scene name (karman_re50, re100, re200, re400)")
|
||||
ap.add_argument("--re", type=int, default=None,
|
||||
help="Re number shortcut (50, 100, 200, 400)")
|
||||
ap.add_argument("--device", type=int, default=2)
|
||||
ap.add_argument("--steps", type=int, default=200)
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.re is not None:
|
||||
scene_name = f"karman_re{args.re}"
|
||||
else:
|
||||
scene_name = args.scene
|
||||
|
||||
if scene_name not in get_scene_list("karman"):
|
||||
print(f"Unknown scene: {scene_name}. Available: {get_scene_list('karman')}")
|
||||
return 1
|
||||
|
||||
t0 = time.time()
|
||||
r = run_single(scene_name, args.device, args.steps)
|
||||
r = run_single("karman_re100", args.device, args.steps)
|
||||
print(f"Done in {time.time()-t0:.1f}s: sim={r['similarity']:.4f}")
|
||||
|
||||
|
||||
|
||||
@ -1,129 +0,0 @@
|
||||
"""Collect pinball-in-vortex-street baseline with zero control.
|
||||
|
||||
Records the pinball interacting with the Karman vortex street, with NO control
|
||||
(zero rotations). Saves both telemetry and full velocity field snapshots.
|
||||
|
||||
Usage:
|
||||
conda run -n pycuda_3_10 python scripts/collect_karman_q_blk.py --device 3
|
||||
|
||||
Output: data/karman_blocked/karman_q_blk/
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
|
||||
_REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
|
||||
if _REPO not in sys.path:
|
||||
sys.path.insert(0, _REPO)
|
||||
_SRC = os.path.join(_REPO, "src")
|
||||
if _SRC not in sys.path:
|
||||
sys.path.insert(0, _SRC)
|
||||
|
||||
from LegacyCelerisLab import FlowField
|
||||
|
||||
from CCD_analysis.configs import get_scene, data_dir_for_scene, LEGACY_CFG_DIR, L0, CENTER_Y
|
||||
from CCD_analysis.utils.cfd_interface import (
|
||||
load_legacy_configs, get_velocity_field,
|
||||
)
|
||||
|
||||
DATA_TYPE = np.float32
|
||||
|
||||
|
||||
def collect():
|
||||
ap = argparse.ArgumentParser(
|
||||
description="Collect pinball in Karman vortex street (zero control)")
|
||||
ap.add_argument("--device", type=int, default=3, help="CUDA device ID")
|
||||
ap.add_argument("--steps", type=int, default=500,
|
||||
help="Number of recording steps after stabilisation")
|
||||
ap.add_argument("--target-fifo", type=int, default=150,
|
||||
help="Number of target recording steps")
|
||||
args = ap.parse_args()
|
||||
|
||||
scene_name = "karman_q_blk"
|
||||
cfg = get_scene(scene_name)
|
||||
out_dir = data_dir_for_scene(scene_name)
|
||||
u0 = cfg["u0"]
|
||||
si = cfg["sample_interval"]
|
||||
print(f"Output: {out_dir}", flush=True)
|
||||
|
||||
cuda_cfg, field_cfg = load_legacy_configs(LEGACY_CFG_DIR)
|
||||
field_cfg = field_cfg._replace(viscosity=float(cfg["nu"]))
|
||||
|
||||
ff = FlowField(field_cfg, cuda_cfg, device_id=args.device)
|
||||
|
||||
# ---- Phase 1: Build karman cloak env (dist cylinder + 3 sensors) ----
|
||||
print("=== Target recording (disturbance + sensors) ===", flush=True)
|
||||
ff.add_cylinder((10.0 * L0, CENTER_Y, 0.0), L0)
|
||||
for y_off in [2.0, 0.0, -2.0]:
|
||||
ff.add_sensor((40.0 * L0, CENTER_Y + y_off * L0, 0.0), L0 / 4.0)
|
||||
|
||||
n_obj_phase1 = 4
|
||||
stabilize = int(4 * 1280 / u0)
|
||||
print(f"Stabilising ({stabilize} steps)...", flush=True)
|
||||
ff.run(stabilize, np.zeros(n_obj_phase1, dtype=DATA_TYPE))
|
||||
print("Stabilisation done.", flush=True)
|
||||
|
||||
# Record target states
|
||||
target_states = np.empty((0, 6), dtype=DATA_TYPE)
|
||||
for _ in range(args.target_fifo):
|
||||
ff.run(si, np.zeros(n_obj_phase1, dtype=DATA_TYPE))
|
||||
target_states = np.vstack((target_states, ff.obs.copy()[2:8]))
|
||||
np.savez(os.path.join(out_dir, "target.npz"), target_states=target_states)
|
||||
print(f"Target recorded: {target_states.shape}", flush=True)
|
||||
|
||||
# ---- Phase 2: Add pinball cylinders ----
|
||||
print("=== Adding pinball ===", flush=True)
|
||||
ff.add_cylinder((30.0 * L0, CENTER_Y, 0.0), L0 / 2.0)
|
||||
ff.add_cylinder((31.3 * L0, CENTER_Y + 0.75 * L0, 0.0), L0 / 2.0)
|
||||
ff.add_cylinder((31.3 * L0, CENTER_Y - 0.75 * L0, 0.0), L0 / 2.0)
|
||||
|
||||
n_obj = 7
|
||||
stabilize = int(4 * 1280 / u0)
|
||||
print(f"Stabilising pinball ({stabilize} steps)...", flush=True)
|
||||
ff.run(stabilize, np.zeros(n_obj, dtype=DATA_TYPE))
|
||||
print("Pinball stabilisation done.", flush=True)
|
||||
|
||||
# ---- Phase 3: Record blocked flow (zero actions) ----
|
||||
print("=== Recording blocked flow ===", flush=True)
|
||||
sens_list, forc_list = [], []
|
||||
ux_list, uy_list = [], []
|
||||
|
||||
for step in range(args.steps):
|
||||
ff.run(si, np.zeros(n_obj, dtype=DATA_TYPE))
|
||||
obs = ff.obs.copy()
|
||||
# obs[0:2] = dist cylinder forces
|
||||
# obs[2:8] = 3 sensors
|
||||
# obs[8:14] = 3 pinball forces
|
||||
sens_list.append(obs[2:8])
|
||||
forc_list.append(np.concatenate([obs[0:2], obs[8:14]]))
|
||||
ux, uy = get_velocity_field(ff, u0=u0)
|
||||
ux_list.append(ux)
|
||||
uy_list.append(uy)
|
||||
|
||||
# Save
|
||||
np.savez_compressed(os.path.join(out_dir, "fields.npz"),
|
||||
ux=np.stack(ux_list), uy=np.stack(uy_list))
|
||||
np.savez(os.path.join(out_dir, "sensors.npz"),
|
||||
sensors=np.array(sens_list, dtype=np.float32),
|
||||
forces=np.array(forc_list, dtype=np.float32))
|
||||
|
||||
# Save meta
|
||||
meta = dict(cfg, n_steps=args.steps)
|
||||
with open(os.path.join(out_dir, "meta.json"), "w") as f:
|
||||
json.dump({k: str(v) if not isinstance(v, (int, float, list, bool)) else v
|
||||
for k, v in meta.items()}, f, indent=2)
|
||||
|
||||
del ff
|
||||
print(f"Done, saved {args.steps} steps to {out_dir}", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
t0 = time.time()
|
||||
collect()
|
||||
print(f"Time: {time.time() - t0:.1f}s", flush=True)
|
||||
@ -1,104 +0,0 @@
|
||||
"""Collect target/incoming vortex street from disturbance cylinder (no pinball).
|
||||
|
||||
Records the Karman vortex street from the upstream disturbance cylinder alone,
|
||||
saving both telemetry (sensors + forces) and full velocity field snapshots.
|
||||
|
||||
Usage:
|
||||
conda run -n pycuda_3_10 python scripts/collect_karman_q_in.py --device 3
|
||||
|
||||
Output: data/karman_target/karman_q_in/
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
|
||||
_REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
|
||||
if _REPO not in sys.path:
|
||||
sys.path.insert(0, _REPO)
|
||||
_SRC = os.path.join(_REPO, "src")
|
||||
if _SRC not in sys.path:
|
||||
sys.path.insert(0, _SRC)
|
||||
|
||||
from LegacyCelerisLab import FlowField
|
||||
|
||||
from CCD_analysis.configs import get_scene, data_dir_for_scene, LEGACY_CFG_DIR, L0, CENTER_Y
|
||||
from CCD_analysis.utils.cfd_interface import (
|
||||
load_legacy_configs, get_velocity_field,
|
||||
)
|
||||
|
||||
DATA_TYPE = np.float32
|
||||
|
||||
|
||||
def collect():
|
||||
ap = argparse.ArgumentParser(
|
||||
description="Collect incoming Karman vortex street (disturbance cylinder only)")
|
||||
ap.add_argument("--device", type=int, default=3, help="CUDA device ID")
|
||||
ap.add_argument("--steps", type=int, default=500,
|
||||
help="Number of recording steps")
|
||||
args = ap.parse_args()
|
||||
|
||||
scene_name = "karman_q_in"
|
||||
cfg = get_scene(scene_name)
|
||||
out_dir = data_dir_for_scene(scene_name)
|
||||
u0 = cfg["u0"]
|
||||
si = cfg["sample_interval"]
|
||||
print(f"Output: {out_dir}", flush=True)
|
||||
|
||||
cuda_cfg, field_cfg = load_legacy_configs(LEGACY_CFG_DIR)
|
||||
field_cfg = field_cfg._replace(viscosity=0.004)
|
||||
|
||||
ff = FlowField(field_cfg, cuda_cfg, device_id=args.device)
|
||||
|
||||
# Add disturbance cylinder
|
||||
ff.add_cylinder((10.0 * L0, CENTER_Y, 0.0), L0)
|
||||
# Add 3 sensors at x=40*L0
|
||||
for y_off in [2.0, 0.0, -2.0]:
|
||||
ff.add_sensor((40.0 * L0, CENTER_Y + y_off * L0, 0.0), L0 / 4.0)
|
||||
|
||||
n_obj = 4
|
||||
stabilize = int(4 * 1280 / u0)
|
||||
print(f"Stabilising ({stabilize} steps)...", flush=True)
|
||||
ff.run(stabilize, np.zeros(n_obj, dtype=DATA_TYPE))
|
||||
print("Stabilisation done.", flush=True)
|
||||
|
||||
# Record 500 steps
|
||||
sens_list, forc_list = [], []
|
||||
ux_list, uy_list = [], []
|
||||
|
||||
for step in range(args.steps):
|
||||
ff.run(si, np.zeros(n_obj, dtype=DATA_TYPE))
|
||||
obs = ff.obs.copy()
|
||||
# obs[0:2] = dist cylinder forces, obs[2:8] = 3 sensors
|
||||
sens_list.append(obs[2:8])
|
||||
forc_list.append(obs[0:2])
|
||||
ux, uy = get_velocity_field(ff, u0=u0)
|
||||
ux_list.append(ux)
|
||||
uy_list.append(uy)
|
||||
|
||||
# Save
|
||||
np.savez_compressed(os.path.join(out_dir, "fields.npz"),
|
||||
ux=np.stack(ux_list), uy=np.stack(uy_list))
|
||||
np.savez(os.path.join(out_dir, "sensors.npz"),
|
||||
sensors=np.array(sens_list, dtype=np.float32),
|
||||
forces=np.array(forc_list, dtype=np.float32))
|
||||
|
||||
# Save meta
|
||||
meta = dict(cfg, n_steps=args.steps)
|
||||
with open(os.path.join(out_dir, "meta.json"), "w") as f:
|
||||
json.dump({k: str(v) if not isinstance(v, (int, float, list, bool)) else v
|
||||
for k, v in meta.items()}, f, indent=2)
|
||||
|
||||
del ff
|
||||
print(f"Done, saved {args.steps} steps to {out_dir}", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
t0 = time.time()
|
||||
collect()
|
||||
print(f"Time: {time.time() - t0:.1f}s", flush=True)
|
||||
@ -18,9 +18,9 @@ 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)
|
||||
_ANALYSIS = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
if _ANALYSIS not in sys.path:
|
||||
sys.path.insert(0, _ANALYSIS)
|
||||
|
||||
from LegacyCelerisLab import FlowField
|
||||
|
||||
|
||||
@ -18,9 +18,9 @@ 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)
|
||||
_ANALYSIS = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
if _ANALYSIS not in sys.path:
|
||||
sys.path.insert(0, _ANALYSIS)
|
||||
|
||||
from LegacyCelerisLab import FlowField
|
||||
|
||||
@ -29,7 +29,6 @@ from CCD_analysis.utils.cfd_interface import (
|
||||
load_legacy_configs, get_velocity_field, save_vorticity_png, vorticity_from_ddf,
|
||||
)
|
||||
|
||||
print("Steady cloak collection starting...", flush=True)
|
||||
|
||||
def collect():
|
||||
ap = argparse.ArgumentParser()
|
||||
@ -39,15 +38,10 @@ def collect():
|
||||
|
||||
cfg = get_scene("steady_cloak")
|
||||
out_dir = data_dir_for_scene("steady_cloak")
|
||||
print(f"Output dir: {out_dir}", flush=True)
|
||||
|
||||
cuda_cfg, field_cfg = load_legacy_configs(LEGACY_CFG_DIR)
|
||||
field_cfg = field_cfg._replace(viscosity=float(cfg["nu"]))
|
||||
print(f"Configs loaded. Viscosity={cfg['nu']}", flush=True)
|
||||
|
||||
print(f"Creating FlowField on device {args.device}...", flush=True)
|
||||
ff = FlowField(field_cfg, cuda_cfg, device_id=args.device)
|
||||
print("FlowField created.", flush=True)
|
||||
l0 = L0
|
||||
|
||||
for y_off in [2.0, 0.0, -2.0]:
|
||||
@ -55,13 +49,10 @@ def collect():
|
||||
ff.add_cylinder((30.0 * l0, CENTER_Y, 0.0), l0 / 2.0)
|
||||
ff.add_cylinder((31.3 * l0, CENTER_Y + 0.75 * l0, 0.0), l0 / 2.0)
|
||||
ff.add_cylinder((31.3 * l0, CENTER_Y - 0.75 * l0, 0.0), l0 / 2.0)
|
||||
print("Objects added.", flush=True)
|
||||
|
||||
n_obj = 6
|
||||
stabilize = int(4 * 1280 / cfg["u0"])
|
||||
print(f"Initial stabilization ({stabilize} steps)...", flush=True)
|
||||
ff.run(stabilize, np.zeros(n_obj, dtype=np.float32))
|
||||
print("Initial stabilization done.", flush=True)
|
||||
|
||||
rear_scale = cfg["omega_rear_scale"]
|
||||
if args.tune:
|
||||
@ -75,7 +66,7 @@ def collect():
|
||||
temp[3] = cfg["omega_front"]
|
||||
temp[4] = rear_val
|
||||
temp[5] = -rear_val
|
||||
print(f"Stabilizing with rear={scale:.1f}xU0 ({stabilize} steps)...", flush=True)
|
||||
ff.run(stabilize, np.zeros(n_obj, dtype=np.float32))
|
||||
ff.run(stabilize, temp)
|
||||
|
||||
sens_list = []
|
||||
@ -83,7 +74,7 @@ def collect():
|
||||
ff.run(cfg["sample_interval"], temp)
|
||||
sens_list.append(ff.obs.copy()[0:6])
|
||||
std = float(np.std(np.array(sens_list), axis=0).mean())
|
||||
print(f" rear={scale:.1f}xU0 -> sensor std={std:.6f}", flush=True)
|
||||
print(f" rear={scale:.1f}xU0 -> sensor std={std:.6f}")
|
||||
|
||||
# Save with best (or single) value
|
||||
rear_val = candidates[-1] * cfg["u0"]
|
||||
@ -92,7 +83,7 @@ def collect():
|
||||
temp[4] = rear_val
|
||||
temp[5] = -rear_val
|
||||
|
||||
print(f"Saving final data (rear={candidates[-1]:.1f}xU0)...", flush=True)
|
||||
ff.run(stabilize, temp)
|
||||
sens_list, forc_list, ux_list, uy_list = [], [], [], []
|
||||
for _ in range(30):
|
||||
ff.run(cfg["sample_interval"], temp)
|
||||
@ -117,10 +108,10 @@ def collect():
|
||||
meta = dict(cfg, rear_scale=candidates[-1], n_samples=30)
|
||||
with open(os.path.join(out_dir, "meta.json"), "w") as f:
|
||||
json.dump(meta, f, indent=2)
|
||||
print(f"Done, saved to {out_dir}", flush=True)
|
||||
print(f"Done, saved to {out_dir}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
t0 = time.time()
|
||||
collect()
|
||||
print(f"Time: {time.time() - t0:.1f}s", flush=True)
|
||||
print(f"Time: {time.time() - t0:.1f}s")
|
||||
|
||||
@ -1,122 +0,0 @@
|
||||
"""Collect target cylinder baseline data (2D cylinder wake, Re=100).
|
||||
|
||||
Records the periodic vortex shedding from a single cylinder of specified diameter,
|
||||
positioned at x=20*L0, with 3 sensors at x=30*L0.
|
||||
|
||||
Usage:
|
||||
conda run -n pycuda_3_10 python scripts/collect_target_cylinder.py --diameter 1.0 --device 2
|
||||
|
||||
Output: data/target_cylinder/target_cylinder_{diam}L/
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
|
||||
_REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
|
||||
if _REPO not in sys.path:
|
||||
sys.path.insert(0, _REPO)
|
||||
_SRC = os.path.join(_REPO, "src")
|
||||
if _SRC not in sys.path:
|
||||
sys.path.insert(0, _SRC)
|
||||
|
||||
from LegacyCelerisLab import FlowField
|
||||
|
||||
from CCD_analysis.configs import get_scene, get_scene_list, data_dir_for_scene, LEGACY_CFG_DIR, L0, CENTER_Y
|
||||
from CCD_analysis.utils.cfd_interface import (
|
||||
load_legacy_configs, get_velocity_field, save_vorticity_png,
|
||||
vorticity_from_ddf,
|
||||
)
|
||||
|
||||
DATA_TYPE = np.float32
|
||||
|
||||
|
||||
def collect():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--diameter", type=float, default=1.0,
|
||||
help="Target cylinder diameter in L0 units (0.75, 1.0, 1.5)")
|
||||
ap.add_argument("--device", type=int, default=2)
|
||||
ap.add_argument("--steps", type=int, default=500)
|
||||
args = ap.parse_args()
|
||||
|
||||
scene_name = f"target_cylinder_{args.diameter}L"
|
||||
if scene_name not in get_scene_list("target_cylinder"):
|
||||
print(f"Unknown scene: {scene_name}. Available: {get_scene_list('target_cylinder')}", flush=True)
|
||||
return
|
||||
|
||||
cfg = get_scene(scene_name)
|
||||
out_dir = data_dir_for_scene(scene_name)
|
||||
u0 = cfg["u0"]
|
||||
si = cfg["sample_interval"]
|
||||
diam = args.diameter
|
||||
radius = diam * L0
|
||||
cyl_x = cfg["cylinder_x"]
|
||||
sensor_x = cfg["sensor_x"]
|
||||
print(f"Target cylinder: diameter={diam}L (radius={radius}), u0={u0}, si={si}", flush=True)
|
||||
print(f"Output: {out_dir}", flush=True)
|
||||
|
||||
cuda_cfg, field_cfg = load_legacy_configs(LEGACY_CFG_DIR)
|
||||
field_cfg = field_cfg._replace(viscosity=float(cfg["nu"]), velocity=float(u0))
|
||||
|
||||
ff = FlowField(field_cfg, cuda_cfg, device_id=args.device)
|
||||
|
||||
# Add cylinder with specified diameter
|
||||
ff.add_cylinder((cyl_x * L0, CENTER_Y, 0.0), radius)
|
||||
# Add 3 sensors at x=sensor_x*L0
|
||||
for y_off in [2.0, 0.0, -2.0]:
|
||||
ff.add_sensor((sensor_x * L0, CENTER_Y + y_off * L0, 0.0), L0 / 4.0)
|
||||
|
||||
n_obj = 4
|
||||
stabilize = int(4 * 1280 / u0)
|
||||
print(f"Stabilising target cylinder ({stabilize} steps)...", flush=True)
|
||||
ff.run(stabilize, np.zeros(n_obj, dtype=DATA_TYPE))
|
||||
print("Stabilisation done.", flush=True)
|
||||
|
||||
# Record
|
||||
sens_list, forc_list = [], []
|
||||
ux_list, uy_list = [], []
|
||||
|
||||
for step in range(args.steps):
|
||||
ff.run(si, np.zeros(n_obj, dtype=DATA_TYPE))
|
||||
obs = ff.obs.copy()
|
||||
sens_list.append(obs[2:8]) # 3 sensors x 2 = 6
|
||||
forc_list.append(obs[0:2]) # cylinder force (fx, fy)
|
||||
ux, uy = get_velocity_field(ff, u0=u0)
|
||||
ux_list.append(ux)
|
||||
uy_list.append(uy)
|
||||
|
||||
# Save
|
||||
np.savez_compressed(os.path.join(out_dir, "fields.npz"),
|
||||
ux=np.stack(ux_list), uy=np.stack(uy_list))
|
||||
np.savez(os.path.join(out_dir, "sensors.npz"),
|
||||
sensors=np.array(sens_list, dtype=np.float32),
|
||||
forces=np.array(forc_list, dtype=np.float32))
|
||||
|
||||
omega = vorticity_from_ddf(ff, u0=u0)
|
||||
save_vorticity_png(os.path.join(out_dir, "vorticity.png"),
|
||||
omega, title=f"Target cylinder {diam}L, u0={u0}")
|
||||
|
||||
# Strouhal number
|
||||
from CCD_analysis.utils.resampling import detect_dominant_frequency
|
||||
signal = np.array(sens_list, dtype=np.float32)[:, 3]
|
||||
f_dom, T_dom, _ = detect_dominant_frequency(signal, float(si))
|
||||
St = f_dom * (diam * L0) / u0
|
||||
print(f"St={St:.4f} (f_dom={f_dom:.6f}, T={T_dom:.0f})", flush=True)
|
||||
|
||||
meta = dict(cfg, St=St, f_dom=f_dom, n_steps=args.steps)
|
||||
with open(os.path.join(out_dir, "meta.json"), "w") as f:
|
||||
json.dump(meta, f, indent=2)
|
||||
|
||||
del ff
|
||||
print(f"Done, saved to {out_dir}", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
t0 = time.time()
|
||||
collect()
|
||||
print(f"Time: {time.time() - t0:.1f}s", flush=True)
|
||||
@ -1,179 +0,0 @@
|
||||
"""Detect period and generate phase-aligned field extraction plan.
|
||||
|
||||
For a periodic case, this script:
|
||||
1. Loads sensor data from controlled.npz (or sensors.npz for open-loop)
|
||||
2. Detects dominant frequency and cycle stability (CV_T)
|
||||
3. Selects the best 4-cycle stable window
|
||||
4. Computes which step indices correspond to N_PTS uniform phase points
|
||||
5. Generates a phase_plan.json for replay_fields.py
|
||||
|
||||
Usage:
|
||||
python3 src/CCD_analysis/scripts/detect_period.py --scene pinball
|
||||
python3 src/CCD_analysis/scripts/detect_period.py --scene illusion_1.0L
|
||||
python3 src/CCD_analysis/scripts/detect_period.py --scene target_cylinder_1.0L
|
||||
python3 src/CCD_analysis/scripts/detect_period.py --scene karman_re100
|
||||
|
||||
Output: data/resampled/{scene_name}/phase_plan.json
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
|
||||
_SRC = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
if _SRC not in sys.path:
|
||||
sys.path.insert(0, _SRC)
|
||||
|
||||
from CCD_analysis.configs import SCENES, DATA_DIR
|
||||
from CCD_analysis.utils.resampling import (
|
||||
detect_dominant_frequency, detect_cycle_stability,
|
||||
)
|
||||
|
||||
N_CYCLES = 4
|
||||
N_PTS = 24
|
||||
CV_T_STRICT = 0.10
|
||||
CV_T_RELAXED = 0.12
|
||||
|
||||
|
||||
def run_single(scene_name: str, n_cycles: int = N_CYCLES, n_pts: int = N_PTS) -> dict:
|
||||
if scene_name not in SCENES:
|
||||
raise KeyError(f"Unknown scene: {scene_name}")
|
||||
|
||||
cfg = SCENES[scene_name]
|
||||
scene_id = cfg["scene_id"]
|
||||
data_dir = os.path.join(DATA_DIR, scene_id, scene_name)
|
||||
si = cfg["sample_interval"]
|
||||
|
||||
# Load sensor data
|
||||
controlled_path = os.path.join(data_dir, "controlled.npz")
|
||||
sensors_path = os.path.join(data_dir, "sensors.npz")
|
||||
|
||||
if os.path.isfile(controlled_path):
|
||||
d = np.load(controlled_path)
|
||||
sensors = d.get("sensors")
|
||||
d.close()
|
||||
elif os.path.isfile(sensors_path):
|
||||
d = np.load(sensors_path)
|
||||
sensors = d.get("sensors")
|
||||
d.close()
|
||||
else:
|
||||
raise FileNotFoundError(f"No sensor data found for {scene_name}")
|
||||
|
||||
if sensors is None or len(sensors) < 30:
|
||||
raise ValueError(f"Insufficient sensor data ({len(sensors) if sensors is not None else 0})")
|
||||
|
||||
# Use centre sensor v-component for period detection
|
||||
signal = sensors[:, 3]
|
||||
|
||||
# Frequency and stability
|
||||
f_case, T_case, _ = detect_dominant_frequency(signal, float(si))
|
||||
cv_T, mean_T, cy_lengths = detect_cycle_stability(signal, float(si))
|
||||
N_raw = mean_T / si if mean_T > 0 else 0
|
||||
rho = float(n_pts) / N_raw if N_raw > 0 else 99
|
||||
|
||||
# Gate check (note: delta_f is always <= 0.1 since we self-compare)
|
||||
if cv_T <= CV_T_STRICT:
|
||||
gate = "strict"
|
||||
elif cv_T <= CV_T_RELAXED:
|
||||
gate = "relaxed"
|
||||
else:
|
||||
gate = "auxiliary"
|
||||
|
||||
print(f" f={f_case:.6f}, T={T_case:.0f}, CV_T={cv_T:.4f}, gate={gate}")
|
||||
print(f" N_raw/cycle={N_raw:.1f}, rho_interp={rho:.2f}")
|
||||
|
||||
if gate not in ("strict", "relaxed"):
|
||||
print(f" WARNING: gate={gate}, case may be too unstable for clean CCD")
|
||||
# Still generate plan but flag it
|
||||
|
||||
# Find cycle boundaries via rising zero-crossings
|
||||
y = signal - np.mean(signal)
|
||||
crossings = np.where((np.sign(y[:-1]) < 0) & (np.sign(y[1:]) > 0))[0]
|
||||
if len(crossings) < n_cycles + 1:
|
||||
raise ValueError(f"Only {len(crossings)} crossings found, need {n_cycles + 1}")
|
||||
|
||||
# Select the most uniform block of n_cycles
|
||||
cycle_lens_steps = np.diff(crossings)
|
||||
T_exp_steps = T_case / si if T_case > 0 else float(np.median(cycle_lens_steps))
|
||||
best_score, best_start = float("inf"), 0
|
||||
for i in range(len(cycle_lens_steps) - n_cycles + 1):
|
||||
score = np.sum((cycle_lens_steps[i:i + n_cycles] - T_exp_steps) ** 2)
|
||||
if score < best_score:
|
||||
best_score, best_start = score, i
|
||||
selected_crossings = [int(crossings[best_start + k]) for k in range(n_cycles + 1)]
|
||||
|
||||
# Map each (cycle, phase) to an exact step index
|
||||
# For cycle c (0..n_cycles-1), the range is [start, end) in step indices
|
||||
# We place n_pts equally spaced within [start, end)
|
||||
step_indices = []
|
||||
for c in range(n_cycles):
|
||||
i_start = selected_crossings[c]
|
||||
i_end = selected_crossings[c + 1]
|
||||
seg_len = i_end - i_start
|
||||
for p in range(n_pts):
|
||||
# Phase fraction: p / n_pts
|
||||
frac = p / float(n_pts)
|
||||
idx = int(i_start + frac * seg_len)
|
||||
step_indices.append(idx)
|
||||
|
||||
# The last sample point (end of last cycle) should be included for completeness
|
||||
# But we keep exactly n_cycles * n_pts samples; step_indices[p + c * n_pts]
|
||||
|
||||
phase_plan = {
|
||||
"scene": scene_name,
|
||||
"scene_id": scene_id,
|
||||
"n_cycles": n_cycles,
|
||||
"n_pts": n_pts,
|
||||
"total_steps": n_cycles * n_pts,
|
||||
"selected_crossings": selected_crossings,
|
||||
"step_indices": step_indices,
|
||||
"f_dom": f_case,
|
||||
"T_dom_steps": T_case,
|
||||
"CV_T": cv_T,
|
||||
"N_raw_per_cycle": float(N_raw),
|
||||
"rho_interp": rho,
|
||||
"gate": gate,
|
||||
}
|
||||
|
||||
return phase_plan
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="Detect period and generate phase plan")
|
||||
ap.add_argument("--scene", type=str, default=None, help="Scene name")
|
||||
ap.add_argument("--all-periodic", action="store_true", help="Run all periodic scenes")
|
||||
ap.add_argument("--n-cycles", type=int, default=N_CYCLES)
|
||||
ap.add_argument("--n-pts", type=int, default=N_PTS)
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.all_periodic:
|
||||
scenes = [name for name, cfg in SCENES.items() if cfg["target_type"] == "periodic"]
|
||||
elif args.scene is not None:
|
||||
scenes = [args.scene]
|
||||
else:
|
||||
ap.print_help()
|
||||
return
|
||||
|
||||
for scene_name in scenes:
|
||||
print(f"\n=== {scene_name} ===", flush=True)
|
||||
try:
|
||||
plan = run_single(scene_name, args.n_cycles, args.n_pts)
|
||||
except (FileNotFoundError, ValueError, KeyError) as e:
|
||||
print(f" SKIP: {e}", flush=True)
|
||||
continue
|
||||
|
||||
out_dir = os.path.join(DATA_DIR, "resampled", scene_name)
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
with open(os.path.join(out_dir, "phase_plan.json"), "w") as f:
|
||||
json.dump(plan, f, indent=2)
|
||||
print(f" Saved: {out_dir}/phase_plan.json", flush=True)
|
||||
|
||||
print("\nDone.", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -1,280 +0,0 @@
|
||||
"""Replay PPO actions with DDF+FIFO checkpoint, save phase-aligned fields.
|
||||
|
||||
For PPO cases (illusion, karman):
|
||||
- Loads DDF+FIFO checkpoint saved by collect_*.py
|
||||
- Rebuilds exact same FlowField geometry
|
||||
- Restores DDF and FIFO
|
||||
- Replays ALL saved actions step by step
|
||||
- Saves raw (non-interpolated) field snapshots at step indices from phase_plan.json
|
||||
- Verifies replay fidelity by comparing sensors/forces with original controlled.npz
|
||||
|
||||
For open-loop cases (target_cylinder, pinball):
|
||||
- No DDF checkpoint needed (no PPO actions)
|
||||
- Uses fields.npz directly + phase_plan.json to extract aligned fields
|
||||
|
||||
Usage:
|
||||
conda run -n pycuda_3_10 python src/CCD_analysis/scripts/replay_fields.py --scene illusion_1.0L --device 2
|
||||
conda run -n pycuda_3_10 python src/CCD_analysis/scripts/replay_fields.py --scene target_cylinder_1.0L --device 2
|
||||
conda run -n pycuda_3_10 python src/CCD_analysis/scripts/replay_fields.py --scene pinball --device 2
|
||||
|
||||
Output: data/{scene_id}/{scene_name}/fields_aligned.npz
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
|
||||
_REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
|
||||
if _REPO not in sys.path:
|
||||
sys.path.insert(0, _REPO)
|
||||
_SRC = os.path.join(_REPO, "src")
|
||||
if _SRC not in sys.path:
|
||||
sys.path.insert(0, _SRC)
|
||||
|
||||
from LegacyCelerisLab import FlowField
|
||||
|
||||
from CCD_analysis.configs import (
|
||||
get_scene, get_scene_list, data_dir_for_scene, LEGACY_CFG_DIR, L0, CENTER_Y,
|
||||
)
|
||||
from CCD_analysis.utils.cfd_interface import load_legacy_configs, get_velocity_field
|
||||
|
||||
DATA_TYPE = np.float32
|
||||
FIFO_LEN = 150
|
||||
|
||||
|
||||
def build_env(cfg: dict, cuda_cfg, field_cfg, device_id: int) -> FlowField:
|
||||
"""Build the exact same FlowField geometry as the original collection script."""
|
||||
ff = FlowField(field_cfg, cuda_cfg, device_id=device_id)
|
||||
u0 = cfg["u0"]
|
||||
|
||||
if cfg.get("has_disturbance", False):
|
||||
# Karman layout: dist_cyl + 3 sensors first
|
||||
ff.add_cylinder((10.0 * L0, CENTER_Y, 0.0), L0)
|
||||
for y_off in [2.0, 0.0, -2.0]:
|
||||
ff.add_sensor((cfg["sensor_x"] * L0, CENTER_Y + y_off * L0, 0.0), L0 / 4.0)
|
||||
n_phase1 = 4
|
||||
ff.run(int(4 * 1280 / u0), np.zeros(n_phase1, dtype=DATA_TYPE))
|
||||
else:
|
||||
# Illusion / plain layout: 3 sensors first
|
||||
for y_off in [2.0, 0.0, -2.0]:
|
||||
ff.add_sensor((cfg["sensor_x"] * L0, CENTER_Y + y_off * L0, 0.0), L0 / 4.0)
|
||||
|
||||
# Add pinball
|
||||
ff.add_cylinder((cfg["pinball_front_x"] * L0, CENTER_Y, 0.0), L0 / 2.0)
|
||||
ff.add_cylinder((cfg["pinball_rear_x"] * L0, CENTER_Y + 0.75 * L0, 0.0), L0 / 2.0)
|
||||
ff.add_cylinder((cfg["pinball_rear_x"] * L0, CENTER_Y - 0.75 * L0, 0.0), L0 / 2.0)
|
||||
return ff
|
||||
|
||||
|
||||
def replay_ppo(scene_name: str, device_id: int, verify_tol: float = 1e-4) -> int:
|
||||
"""Replay PPO inference using DDF+FIFO checkpoint, save aligned fields."""
|
||||
cfg = get_scene(scene_name)
|
||||
out_dir = data_dir_for_scene(scene_name)
|
||||
u0 = cfg["u0"]
|
||||
si = cfg["sample_interval"]
|
||||
ac_scale = cfg["action_scale"]
|
||||
ac_bias = cfg["action_bias"]
|
||||
n_obj = cfg["n_objects_env"]
|
||||
obs_start, obs_end = cfg["obs_slice"]
|
||||
|
||||
# Load phase plan
|
||||
plan_path = os.path.join(os.path.join(os.path.dirname(out_dir), "..", "resampled", scene_name), "phase_plan.json")
|
||||
# Try alternate path
|
||||
if not os.path.isfile(plan_path):
|
||||
plan_path = os.path.join(os.path.join(os.path.dirname(__file__), "..", "data", "resampled", scene_name), "phase_plan.json")
|
||||
if not os.path.isfile(plan_path):
|
||||
plan_path = os.path.join(cfg["DATA_DIR"] if "DATA_DIR" in cfg else os.path.join(os.path.dirname(__file__), "..", "data"), "resampled", scene_name, "phase_plan.json")
|
||||
# Final fallback: data/resampled
|
||||
from CCD_analysis.configs import DATA_DIR as CCD_DATA_DIR
|
||||
plan_path = os.path.join(CCD_DATA_DIR, "resampled", scene_name, "phase_plan.json")
|
||||
|
||||
if not os.path.isfile(plan_path):
|
||||
raise FileNotFoundError(f"phase_plan.json not found for {scene_name}. Run detect_period.py first.")
|
||||
|
||||
with open(plan_path) as f:
|
||||
plan = json.load(f)
|
||||
step_indices = set(plan["step_indices"])
|
||||
print(f" phase plan: {plan['n_cycles']} cycles x {plan['n_pts']} pts = {len(step_indices)} steps", flush=True)
|
||||
|
||||
# Load actions and original data for verification
|
||||
controlled = np.load(os.path.join(out_dir, "controlled.npz"))
|
||||
actions = controlled["actions"]
|
||||
orig_sensors = controlled["sensors"]
|
||||
orig_forces = controlled["forces"]
|
||||
n_steps = len(actions)
|
||||
|
||||
# Load DDF+FIFO checkpoint
|
||||
ddf_ckpt = np.load(os.path.join(out_dir, "ddf_checkpoint.npy"))
|
||||
fifo_ckpt = np.load(os.path.join(out_dir, "fifo_checkpoint.npy"))
|
||||
|
||||
# Build env and restore
|
||||
cuda_cfg, field_cfg = load_legacy_configs(LEGACY_CFG_DIR)
|
||||
field_cfg = field_cfg._replace(viscosity=float(cfg["nu"]), velocity=float(u0))
|
||||
|
||||
ff = build_env(cfg, cuda_cfg, field_cfg, device_id)
|
||||
n_obj_built = ff.obs.size // 2
|
||||
assert n_obj_built == n_obj, f"Object count mismatch: {n_obj_built} vs expected {n_obj}"
|
||||
|
||||
# Restore DDF
|
||||
ff.ddf = ddf_ckpt.copy()
|
||||
ff.apply_ddf()
|
||||
print(f" DDF checkpoint restored ({len(ddf_ckpt)} floats)", flush=True)
|
||||
|
||||
# Replay
|
||||
from collections import deque
|
||||
fifo = deque(maxlen=FIFO_LEN)
|
||||
for s in fifo_ckpt:
|
||||
fifo.append(s)
|
||||
|
||||
sens_replay, forc_replay = [], []
|
||||
ux_list, uy_list = [], []
|
||||
max_diff_sens, max_diff_forc = 0.0, 0.0
|
||||
|
||||
for step in range(n_steps):
|
||||
action = actions[step]
|
||||
omega = (action * ac_scale + np.array(ac_bias, dtype=np.float32)) * u0
|
||||
temp = np.zeros(n_obj, dtype=DATA_TYPE)
|
||||
temp[n_obj - 3:] = omega
|
||||
|
||||
ff.context.push()
|
||||
ff.run(si, temp)
|
||||
ff.context.pop()
|
||||
|
||||
obs_slice = ff.obs.copy()[obs_start:obs_end]
|
||||
fifo.append(obs_slice)
|
||||
sens_replay.append(obs_slice[0:6])
|
||||
forc_replay.append(obs_slice[6:12])
|
||||
|
||||
# Save field at selected step indices
|
||||
if step in step_indices:
|
||||
ux, uy = get_velocity_field(ff, u0=u0)
|
||||
ux_list.append(ux)
|
||||
uy_list.append(uy)
|
||||
|
||||
# Verify replay fidelity
|
||||
sens_replay = np.array(sens_replay, dtype=np.float32)
|
||||
forc_replay = np.array(forc_replay, dtype=np.float32)
|
||||
|
||||
diff_sens = np.max(np.abs(sens_replay - orig_sensors))
|
||||
diff_forc = np.max(np.abs(forc_replay - orig_forces))
|
||||
print(f" Replay max diff: sensors={diff_sens:.6e}, forces={diff_forc:.6e}", flush=True)
|
||||
|
||||
if diff_sens > verify_tol or diff_forc > verify_tol:
|
||||
print(f" WARNING: replay diff exceeds tolerance ({verify_tol}). "
|
||||
f"CUDA atomic non-determinism may cause minor variation.", flush=True)
|
||||
|
||||
# Save aligned fields
|
||||
out_path = os.path.join(out_dir, "fields_aligned.npz")
|
||||
np.savez_compressed(out_path,
|
||||
ux=np.stack(ux_list), uy=np.stack(uy_list))
|
||||
print(f" Saved {len(ux_list)} field snapshots to {out_path}", flush=True)
|
||||
|
||||
# Save verification report
|
||||
verify = {
|
||||
"scene": scene_name,
|
||||
"n_steps": n_steps,
|
||||
"n_fields": len(ux_list),
|
||||
"max_diff_sensors": float(diff_sens),
|
||||
"max_diff_forces": float(diff_forc),
|
||||
"tolerance": verify_tol,
|
||||
"passed": bool(diff_sens <= verify_tol and diff_forc <= verify_tol),
|
||||
}
|
||||
with open(os.path.join(out_dir, "replay_verify.json"), "w") as f:
|
||||
json.dump(verify, f, indent=2)
|
||||
|
||||
del ff
|
||||
controlled.close()
|
||||
return len(ux_list)
|
||||
|
||||
|
||||
def replay_open_loop(scene_name: str, device_id: int) -> int:
|
||||
"""For open-loop cases: extract fields from phase plan indices directly.
|
||||
|
||||
Open-loop cases (target_cylinder, pinball) have no actions to replay.
|
||||
Their fields.npz already contains the raw time series.
|
||||
We just need to pick the phase-aligned snapshots.
|
||||
"""
|
||||
cfg = get_scene(scene_name)
|
||||
out_dir = data_dir_for_scene(scene_name)
|
||||
|
||||
from CCD_analysis.configs import DATA_DIR as CCD_DATA_DIR
|
||||
plan_path = os.path.join(CCD_DATA_DIR, "resampled", scene_name, "phase_plan.json")
|
||||
if not os.path.isfile(plan_path):
|
||||
raise FileNotFoundError(f"phase_plan.json not found for {scene_name}")
|
||||
|
||||
with open(plan_path) as f:
|
||||
plan = json.load(f)
|
||||
step_indices = plan["step_indices"]
|
||||
|
||||
# Load existing fields
|
||||
fields_path = os.path.join(out_dir, "fields.npz")
|
||||
if not os.path.isfile(fields_path):
|
||||
raise FileNotFoundError(f"fields.npz not found for {scene_name}")
|
||||
|
||||
fd = np.load(fields_path)
|
||||
ux_all, uy_all = fd["ux"], fd["uy"]
|
||||
|
||||
n_avail = len(ux_all)
|
||||
valid_idx = [idx for idx in step_indices if idx < n_avail]
|
||||
if len(valid_idx) < len(step_indices):
|
||||
print(f" WARNING: {len(step_indices) - len(valid_idx)} step indices out of range "
|
||||
f"(max={n_avail - 1}). Using {len(valid_idx)} valid snapshots.", flush=True)
|
||||
|
||||
ux_list = [ux_all[idx] for idx in valid_idx]
|
||||
uy_list = [uy_all[idx] for idx in valid_idx]
|
||||
|
||||
out_path = os.path.join(out_dir, "fields_aligned.npz")
|
||||
np.savez_compressed(out_path, ux=np.stack(ux_list), uy=np.stack(uy_list))
|
||||
print(f" Extracted {len(ux_list)} phase-aligned field snapshots to {out_path}", flush=True)
|
||||
|
||||
fd.close()
|
||||
return len(ux_list)
|
||||
|
||||
|
||||
def replay_single(scene_name: str, device_id: int, verify_tol: float = 1e-4) -> int:
|
||||
"""Route to the appropriate replay method based on scene type."""
|
||||
cfg = get_scene(scene_name)
|
||||
source = cfg.get("source", "open_loop")
|
||||
|
||||
if source == "PPO_inference":
|
||||
return replay_ppo(scene_name, device_id, verify_tol)
|
||||
else:
|
||||
return replay_open_loop(scene_name, device_id)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="Replay and save phase-aligned fields")
|
||||
ap.add_argument("--scene", type=str, default="karman_re100")
|
||||
ap.add_argument("--device", type=int, default=2)
|
||||
ap.add_argument("--tol", type=float, default=1e-4,
|
||||
help="Replay verification tolerance")
|
||||
ap.add_argument("--all-periodic", action="store_true",
|
||||
help="Replay all periodic scenes")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.all_periodic:
|
||||
from CCD_analysis.configs import SCENES
|
||||
scenes = [name for name, cfg in SCENES.items()
|
||||
if cfg.get("target_type") == "periodic"]
|
||||
else:
|
||||
scenes = [args.scene]
|
||||
|
||||
for scene in scenes:
|
||||
print(f"\n=== Replaying fields for {scene} ===", flush=True)
|
||||
t0 = time.time()
|
||||
try:
|
||||
n = replay_single(scene, args.device, args.tol)
|
||||
print(f" {n} fields in {time.time() - t0:.1f}s", flush=True)
|
||||
except (FileNotFoundError, AssertionError, ValueError) as e:
|
||||
print(f" FAILED: {e}", flush=True)
|
||||
|
||||
print("\nDone.", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -1,12 +1,9 @@
|
||||
"""DEPRECATED — replaced by detect_period.py + replay_fields.py
|
||||
"""Phase resampling for periodic cases (pinball, karman_re100, illusion_1L).
|
||||
|
||||
This script interpolates field data to phase-aligned points, introducing artifacts.
|
||||
The new pipeline saves RAW (non-interpolated) fields at exact step indices.
|
||||
Usage:
|
||||
python scripts/resample.py
|
||||
|
||||
Keep this file for reference only. Do NOT rely on its output for CCD analysis.
|
||||
|
||||
Usage (old, DEPRECATED):
|
||||
python scripts/resample.py # <-- DO NOT USE
|
||||
Output: data/resampled/{scene_name}/resampled.npz
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@ -16,9 +13,9 @@ import sys
|
||||
|
||||
import numpy as np
|
||||
|
||||
_SRC = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
if _SRC not in sys.path:
|
||||
sys.path.insert(0, _SRC)
|
||||
_ANALYSIS = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
if _ANALYSIS not in sys.path:
|
||||
sys.path.insert(0, _ANALYSIS)
|
||||
|
||||
from CCD_analysis.configs import SCENES, DATA_DIR
|
||||
from CCD_analysis.utils.resampling import (
|
||||
@ -33,16 +30,12 @@ CV_T_RELAXED = 0.12
|
||||
DELTA_F_STRICT = 0.10
|
||||
DELTA_F_RELAXED = 0.20
|
||||
|
||||
pr = lambda *a, **kw: print(*a, **kw, flush=True)
|
||||
|
||||
|
||||
def run():
|
||||
periodic = [name for name, cfg in SCENES.items()
|
||||
if cfg["target_type"] == "periodic"]
|
||||
pr(f"Periodic cases: {periodic}")
|
||||
|
||||
for name in periodic:
|
||||
pr(f"\n=== {name} ===")
|
||||
cfg = SCENES[name]
|
||||
data_dir = os.path.join(DATA_DIR, cfg["scene_id"], name)
|
||||
meta_path = os.path.join(data_dir, "meta.json")
|
||||
@ -123,7 +116,6 @@ def run():
|
||||
if actions is not None and actions.ndim == 2:
|
||||
out["actions"] = phase_resample(actions, selected, n_pts=N_PTS)
|
||||
|
||||
fd = None
|
||||
# Fields (from controlled.npz or fields.npz)
|
||||
if os.path.isfile(controlled_path) and "ux" not in d:
|
||||
ol_path = os.path.join(data_dir, "open_loop_fields.npz")
|
||||
@ -131,6 +123,8 @@ def run():
|
||||
fd = np.load(ol_path)
|
||||
elif os.path.isfile(fields_path):
|
||||
fd = np.load(fields_path)
|
||||
else:
|
||||
fd = None
|
||||
|
||||
if fd is not None and "ux" in fd:
|
||||
ux, uy = fd["ux"], fd["uy"]
|
||||
@ -159,11 +153,7 @@ def run():
|
||||
"has_fields": "ux" in out}
|
||||
with open(os.path.join(resample_dir, "meta.json"), "w") as f:
|
||||
json.dump(meta, f, indent=2)
|
||||
pr(f" Saved: {resample_dir} ({'fields' if meta['has_fields'] else 'no fields'})")
|
||||
|
||||
d.close()
|
||||
if fd is not None:
|
||||
fd.close()
|
||||
print(f" Saved: {resample_dir} ({'fields' if meta['has_fields'] else 'no fields'})")
|
||||
|
||||
print("\nDone.")
|
||||
|
||||
|
||||
@ -1,180 +0,0 @@
|
||||
"""Sanity check: compare raw force signals between target and illusion.
|
||||
|
||||
This script answers the most basic question:
|
||||
"Does the controller actually match the target's force signature?"
|
||||
|
||||
For each diameter (0.75L, 1.0L, 1.5L), loads the target cylinder's forces
|
||||
and the illusion's total forces, plots them, and computes basic statistics.
|
||||
|
||||
Usage:
|
||||
python3 src/CCD_analysis/scripts/sanity_check_force.py
|
||||
|
||||
Output: data/figures/sanity_force_{diam}L.png
|
||||
data/figures/sanity_force_report.json
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
|
||||
import matplotlib
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
_SRC = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
if _SRC not in sys.path:
|
||||
sys.path.insert(0, _SRC)
|
||||
|
||||
from CCD_analysis.configs import SCENES, DATA_DIR
|
||||
|
||||
FIG_DIR = os.path.join(DATA_DIR, "figures")
|
||||
os.makedirs(FIG_DIR, exist_ok=True)
|
||||
|
||||
DIAMETERS = [0.75, 1.0, 1.5]
|
||||
|
||||
|
||||
def compute_cd_cl(forces: np.ndarray, scene_name: str) -> tuple:
|
||||
"""Compute total Cd and Cl from raw forces.
|
||||
|
||||
For target_cylinder: forces shape (N, 2), just [Fx, Fy]
|
||||
For illusion: forces shape (N, 6), need [F0+F2+F4, F1+F3+F5]
|
||||
"""
|
||||
if "target_cylinder" in scene_name:
|
||||
return forces[:, 0], forces[:, 1] # Fx, Fy
|
||||
else:
|
||||
return (forces[:, 0] + forces[:, 2] + forces[:, 4],
|
||||
forces[:, 1] + forces[:, 3] + forces[:, 5])
|
||||
|
||||
|
||||
def run():
|
||||
print("=" * 60, flush=True)
|
||||
print("Sanity Check: Force Comparison", flush=True)
|
||||
print("=" * 60, flush=True)
|
||||
|
||||
report = {}
|
||||
|
||||
for diam in DIAMETERS:
|
||||
tgt_name = f"target_cylinder_{diam}L"
|
||||
ill_name = f"illusion_{diam}L"
|
||||
|
||||
cfg_tgt = SCENES[tgt_name]
|
||||
cfg_ill = SCENES[ill_name]
|
||||
|
||||
# Load target forces
|
||||
if cfg_tgt.get("source") == "open_loop":
|
||||
tgt_path = os.path.join(DATA_DIR, "target_cylinder", tgt_name, "sensors.npz")
|
||||
tgt_raw = np.load(tgt_path)
|
||||
tgt_forces = tgt_raw["forces"] # (N, 2)
|
||||
else:
|
||||
tgt_path = os.path.join(DATA_DIR, "target_cylinder", tgt_name, "controlled.npz")
|
||||
tgt_raw = np.load(tgt_path)
|
||||
tgt_forces = tgt_raw["forces"]
|
||||
|
||||
tgt_cd, tgt_cl = compute_cd_cl(tgt_forces, tgt_name)
|
||||
|
||||
# Load illusion forces
|
||||
ill_raw = np.load(os.path.join(DATA_DIR, "illusion", ill_name, "controlled.npz"))
|
||||
ill_forces = ill_raw["forces"] # (N, 6)
|
||||
ill_cd, ill_cl = compute_cd_cl(ill_forces, ill_name)
|
||||
|
||||
# Align lengths
|
||||
n = min(len(tgt_cd), len(ill_cd))
|
||||
tgt_cd, tgt_cl = tgt_cd[:n], tgt_cl[:n]
|
||||
ill_cd, ill_cl = ill_cd[:n], ill_cl[:n]
|
||||
|
||||
# Statistics
|
||||
cd_corr = float(np.corrcoef(tgt_cd, ill_cd)[0, 1])
|
||||
cl_corr = float(np.corrcoef(tgt_cl, ill_cl)[0, 1])
|
||||
cd_rmse = float(np.sqrt(np.mean((tgt_cd - ill_cd) ** 2)))
|
||||
cl_rmse = float(np.sqrt(np.mean((tgt_cl - ill_cl) ** 2)))
|
||||
cd_mean_tgt = float(np.mean(tgt_cd))
|
||||
cd_mean_ill = float(np.mean(ill_cd))
|
||||
cl_std_tgt = float(np.std(tgt_cl))
|
||||
cl_std_ill = float(np.std(ill_cl))
|
||||
|
||||
print(f"\n--- {diam}L ---", flush=True)
|
||||
print(f" Cd correlation: {cd_corr:.4f}")
|
||||
print(f" Cl correlation: {cl_corr:.4f}")
|
||||
print(f" Cd RMSE: {cd_rmse:.6f}")
|
||||
print(f" Cl RMSE: {cl_rmse:.6f}")
|
||||
print(f" Cd mean: target={cd_mean_tgt:.6f}, illusion={cd_mean_ill:.6f}")
|
||||
print(f" Cl std: target={cl_std_tgt:.6f}, illusion={cl_std_ill:.6f}")
|
||||
|
||||
report[diam] = {
|
||||
"cd_correlation": cd_corr,
|
||||
"cl_correlation": cl_corr,
|
||||
"cd_rmse": cd_rmse,
|
||||
"cl_rmse": cl_rmse,
|
||||
"cd_mean_target": cd_mean_tgt,
|
||||
"cd_mean_illusion": cd_mean_ill,
|
||||
"cl_std_target": cl_std_tgt,
|
||||
"cl_std_illusion": cl_std_ill,
|
||||
"n_samples": n,
|
||||
}
|
||||
|
||||
# Plot
|
||||
t = np.arange(n)
|
||||
fig, axes = plt.subplots(2, 2, figsize=(14, 8))
|
||||
|
||||
# Cd time series
|
||||
ax = axes[0, 0]
|
||||
ax.plot(t, tgt_cd, "r-", alpha=0.7, label=f"Target {diam}L", linewidth=1)
|
||||
ax.plot(t, ill_cd, "b-", alpha=0.7, label=f"Illusion {diam}L", linewidth=1)
|
||||
ax.set_ylabel("Total Fx (lattice)")
|
||||
ax.set_title(f"{diam}L: Total Fx (Cd proxy)")
|
||||
ax.legend()
|
||||
ax.grid(True, alpha=0.3)
|
||||
|
||||
# Cl time series
|
||||
ax = axes[0, 1]
|
||||
ax.plot(t, tgt_cl, "r-", alpha=0.7, label=f"Target {diam}L", linewidth=1)
|
||||
ax.plot(t, ill_cl, "b-", alpha=0.7, label=f"Illusion {diam}L", linewidth=1)
|
||||
ax.set_ylabel("Total Fy (lattice)")
|
||||
ax.set_title(f"{diam}L: Total Fy (Cl proxy)")
|
||||
ax.legend()
|
||||
ax.grid(True, alpha=0.3)
|
||||
|
||||
# Cd scatter
|
||||
ax = axes[1, 0]
|
||||
ax.scatter(tgt_cd, ill_cd, s=2, alpha=0.5, c="purple")
|
||||
lim = max(np.abs(ax.get_xlim()).max(), np.abs(ax.get_ylim()).max())
|
||||
ax.plot([-lim, lim], [-lim, lim], "k--", alpha=0.3)
|
||||
ax.set_xlabel("Target Fx")
|
||||
ax.set_ylabel("Illusion Fx")
|
||||
ax.set_title(f"Cd scatter (r={cd_corr:.3f})")
|
||||
ax.set_aspect("equal")
|
||||
ax.grid(True, alpha=0.3)
|
||||
|
||||
# Cl scatter
|
||||
ax = axes[1, 1]
|
||||
ax.scatter(tgt_cl, ill_cl, s=2, alpha=0.5, c="orange")
|
||||
lim = max(np.abs(ax.get_xlim()).max(), np.abs(ax.get_ylim()).max())
|
||||
ax.plot([-lim, lim], [-lim, lim], "k--", alpha=0.3)
|
||||
ax.set_xlabel("Target Fy")
|
||||
ax.set_ylabel("Illusion Fy")
|
||||
ax.set_title(f"Cl scatter (r={cl_corr:.3f})")
|
||||
ax.set_aspect("equal")
|
||||
ax.grid(True, alpha=0.3)
|
||||
|
||||
plt.tight_layout()
|
||||
path = os.path.join(FIG_DIR, f"sanity_force_{diam}L.png")
|
||||
fig.savefig(path, dpi=150, bbox_inches="tight")
|
||||
plt.close(fig)
|
||||
print(f" Saved: {path}", flush=True)
|
||||
|
||||
tgt_raw.close()
|
||||
ill_raw.close()
|
||||
|
||||
# Save report
|
||||
report_path = os.path.join(FIG_DIR, "sanity_force_report.json")
|
||||
with open(report_path, "w") as f:
|
||||
json.dump(report, f, indent=2)
|
||||
print(f"\nReport saved to {report_path}", flush=True)
|
||||
print("Done.", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
@ -1,227 +0,0 @@
|
||||
"""Phase 3: Generate vorticity images and verification reports.
|
||||
|
||||
For each case with fields_aligned.npz:
|
||||
1. Average the 96 snapshots (4 cycles x 24 phases) to get mean flow
|
||||
2. Compute vorticity from the mean flow
|
||||
3. Save vorticity image
|
||||
4. Also save the mean flow ux/uy fields for reference
|
||||
|
||||
Usage:
|
||||
python3 src/CCD_analysis/scripts/verify_cases.py
|
||||
|
||||
Output: data/figures/vorticity_{scene_name}.png
|
||||
data/figures/meanflow_{scene_name}.png
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
|
||||
import matplotlib
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
_SRC = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
if _SRC not in sys.path:
|
||||
sys.path.insert(0, _SRC)
|
||||
|
||||
from CCD_analysis.configs import SCENES, DATA_DIR
|
||||
|
||||
FIG_DIR = os.path.join(DATA_DIR, "figures")
|
||||
os.makedirs(FIG_DIR, exist_ok=True)
|
||||
|
||||
NX = 1280
|
||||
NY = 512
|
||||
|
||||
# Cases with stable sampling periods
|
||||
PERIODIC_CASES = [
|
||||
"pinball",
|
||||
"karman_re100",
|
||||
"illusion_0.75L",
|
||||
"illusion_1.0L",
|
||||
"illusion_1.5L",
|
||||
"target_cylinder_0.75L",
|
||||
"target_cylinder_1.0L",
|
||||
"target_cylinder_1.5L",
|
||||
]
|
||||
|
||||
STEADY_CASES = [
|
||||
"steady_cloak",
|
||||
"target_channel",
|
||||
]
|
||||
|
||||
|
||||
def vorticity_from_uv(ux: np.ndarray, uy: np.ndarray) -> np.ndarray:
|
||||
"""Compute z-vorticity from velocity fields.
|
||||
|
||||
ux, uy have shape (NX, NY) as returned by get_velocity_field.
|
||||
Returns omega_z with shape (NY, NX) for direct imshow use.
|
||||
"""
|
||||
# Transpose to (NY, NX) for correct gradient axes
|
||||
ux_t = ux.T # (NY, NX)
|
||||
uy_t = uy.T # (NY, NX)
|
||||
# omega_z = duy/dx - dux/dy
|
||||
# gradient(uy_t, axis=1) = duy/dx (axis=1 is x-direction in NY,NX layout)
|
||||
# gradient(ux_t, axis=0) = dux/dy (axis=0 is y-direction in NY,NX layout)
|
||||
return np.gradient(uy_t, axis=1) - np.gradient(ux_t, axis=0)
|
||||
|
||||
|
||||
def load_aligned_fields(scene_name: str, scene_id: str):
|
||||
"""Load fields_aligned.npz or standard fields.npz."""
|
||||
d = os.path.join(DATA_DIR, scene_id, scene_name)
|
||||
paths = [
|
||||
os.path.join(d, "fields_aligned.npz"),
|
||||
os.path.join(d, "fields.npz"),
|
||||
]
|
||||
for p in paths:
|
||||
if os.path.isfile(p):
|
||||
return np.load(p)
|
||||
return None
|
||||
|
||||
|
||||
def plot_vorticity(omega: np.ndarray, title: str, path: str):
|
||||
"""Save vorticity image with symmetric colorbar."""
|
||||
abs_o = np.abs(omega[np.isfinite(omega)])
|
||||
vmax = float(np.percentile(abs_o, 99.5)) if abs_o.size > 0 else 1.0
|
||||
if vmax <= 0:
|
||||
vmax = 1.0
|
||||
|
||||
fig, ax = plt.subplots(figsize=(16, 5))
|
||||
im = ax.imshow(omega, origin="lower", aspect="equal", cmap="RdBu_r",
|
||||
vmin=-vmax, vmax=vmax)
|
||||
ax.set_xlabel("x (lattice)")
|
||||
ax.set_ylabel("y (lattice)")
|
||||
ax.set_title(title)
|
||||
fig.colorbar(im, ax=ax, fraction=0.046, pad=0.04, label=r"$\omega_z$")
|
||||
fig.savefig(path, dpi=150, bbox_inches="tight")
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
def plot_meanflow(ux_mean: np.ndarray, uy_mean: np.ndarray, title: str, path: str):
|
||||
"""Save mean flow magnitude image."""
|
||||
speed = np.sqrt(ux_mean**2 + uy_mean**2)
|
||||
vmax = float(np.percentile(speed, 99.5)) if speed.size > 0 else 1.0
|
||||
|
||||
fig, axes = plt.subplots(1, 2, figsize=(20, 5))
|
||||
im0 = axes[0].imshow(ux_mean, origin="lower", aspect="equal", cmap="viridis")
|
||||
axes[0].set_title(f"{title}: ux mean")
|
||||
fig.colorbar(im0, ax=axes[0])
|
||||
|
||||
im1 = axes[1].imshow(uy_mean, origin="lower", aspect="equal", cmap="viridis")
|
||||
axes[1].set_title(f"{title}: uy mean")
|
||||
fig.colorbar(im1, ax=axes[1])
|
||||
|
||||
fig.savefig(path, dpi=150, bbox_inches="tight")
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
def process_periodic(scene_name: str):
|
||||
"""Process a periodic case: average fields, compute vorticity, save images."""
|
||||
cfg = SCENES[scene_name]
|
||||
scene_id = cfg["scene_id"]
|
||||
|
||||
print(f"\n=== {scene_name} ===", flush=True)
|
||||
|
||||
fd = load_aligned_fields(scene_name, scene_id)
|
||||
if fd is None:
|
||||
print(f" SKIP: no field data found", flush=True)
|
||||
return
|
||||
|
||||
ux = fd["ux"]
|
||||
uy = fd["uy"]
|
||||
print(f" fields shape: {ux.shape}", flush=True)
|
||||
|
||||
# Time-average over all snapshots
|
||||
ux_mean = np.mean(ux, axis=0)
|
||||
uy_mean = np.mean(uy, axis=0)
|
||||
|
||||
# Vorticity of mean flow
|
||||
omega = vorticity_from_uv(ux_mean, uy_mean)
|
||||
|
||||
# Save images
|
||||
plot_vorticity(omega, f"{scene_name}: mean vorticity (4-cycle avg)",
|
||||
os.path.join(FIG_DIR, f"vorticity_{scene_name}.png"))
|
||||
plot_meanflow(ux_mean, uy_mean, scene_name,
|
||||
os.path.join(FIG_DIR, f"meanflow_{scene_name}.png"))
|
||||
print(f" Saved vorticity and meanflow images", flush=True)
|
||||
|
||||
# Also compute: instantaneous vorticity of each snapshot
|
||||
if ux.shape[0] >= 96:
|
||||
# Last cycle's snapshots for phase-resolved comparison
|
||||
cycle_start = 72 # third cycle start
|
||||
fig, axes = plt.subplots(4, 6, figsize=(24, 10))
|
||||
for p in range(24):
|
||||
row, col = p // 6, p % 6
|
||||
idx = cycle_start + p
|
||||
om = vorticity_from_uv(ux[idx], uy[idx])
|
||||
abs_o = np.abs(om[np.isfinite(om)])
|
||||
vm = float(np.percentile(abs_o, 99.5)) if abs_o.size > 0 else 1.0
|
||||
axes[row, col].imshow(om, origin="lower", aspect="equal",
|
||||
cmap="RdBu_r", vmin=-vm, vmax=vm)
|
||||
axes[row, col].set_title(f"phase {p}")
|
||||
axes[row, col].axis("off")
|
||||
plt.suptitle(f"{scene_name}: phase-resolved vorticity (1 cycle)")
|
||||
plt.tight_layout()
|
||||
fig.savefig(os.path.join(FIG_DIR, f"phase_vorticity_{scene_name}.png"),
|
||||
dpi=150, bbox_inches="tight")
|
||||
plt.close(fig)
|
||||
print(f" Saved phase-resolved vorticity", flush=True)
|
||||
|
||||
fd.close()
|
||||
|
||||
|
||||
def process_steady(scene_name: str):
|
||||
"""Process steady cases (no periodic averaging needed)."""
|
||||
cfg = SCENES[scene_name]
|
||||
scene_id = cfg["scene_id"]
|
||||
|
||||
print(f"\n=== {scene_name} (steady) ===", flush=True)
|
||||
|
||||
fd = load_aligned_fields(scene_name, scene_id)
|
||||
if fd is None:
|
||||
print(f" SKIP: no field data", flush=True)
|
||||
return
|
||||
|
||||
ux = fd["ux"]
|
||||
uy = fd["uy"]
|
||||
|
||||
ux_mean = np.mean(ux, axis=0)
|
||||
uy_mean = np.mean(uy, axis=0)
|
||||
omega = vorticity_from_uv(ux_mean, uy_mean)
|
||||
|
||||
plot_vorticity(omega, f"{scene_name}: mean vorticity",
|
||||
os.path.join(FIG_DIR, f"vorticity_{scene_name}.png"))
|
||||
plot_meanflow(ux_mean, uy_mean, scene_name,
|
||||
os.path.join(FIG_DIR, f"meanflow_{scene_name}.png"))
|
||||
print(f" Saved vorticity and meanflow images", flush=True)
|
||||
fd.close()
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 60, flush=True)
|
||||
print("Phase 3: Case Verification", flush=True)
|
||||
print("=" * 60, flush=True)
|
||||
|
||||
for name in PERIODIC_CASES:
|
||||
process_periodic(name)
|
||||
|
||||
for name in STEADY_CASES:
|
||||
process_steady(name)
|
||||
|
||||
# Generate summary report
|
||||
report = {
|
||||
"periodic_cases": PERIODIC_CASES,
|
||||
"steady_cases": STEADY_CASES,
|
||||
"figure_dir": FIG_DIR,
|
||||
}
|
||||
with open(os.path.join(DATA_DIR, "figures", "verification_report.json"), "w") as f:
|
||||
json.dump(report, f, indent=2)
|
||||
|
||||
print(f"\nAll figures saved to {FIG_DIR}", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -1,605 +0,0 @@
|
||||
"""Phase 4: Visualization — O_k heatmap, CCD modes, POD phase portraits, 1.5L special case.
|
||||
|
||||
Integrates 1.5L special-mechanism branch (no separate analyze_15L.py).
|
||||
|
||||
Usage:
|
||||
conda run -n pycuda_3_10 python src/CCD_analysis/scripts/visualize_ccd.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from collections import deque
|
||||
|
||||
import numpy as np
|
||||
|
||||
import matplotlib
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
_SRC = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
if _SRC not in sys.path:
|
||||
sys.path.insert(0, _SRC)
|
||||
|
||||
from CCD_analysis.configs import DATA_DIR, SCENES
|
||||
from CCD_analysis.utils.resampling import (
|
||||
compute_pod, compute_reduced_ccd, cumulative_energy,
|
||||
load_aligned_fields, make_force_obs,
|
||||
build_field_matrix, project_into_basis,
|
||||
detect_dominant_frequency, detect_cycle_stability,
|
||||
)
|
||||
|
||||
FIG_DIR = os.path.join(DATA_DIR, "figures")
|
||||
os.makedirs(FIG_DIR, exist_ok=True)
|
||||
|
||||
CCD_Q = 6
|
||||
N_PTS = 24
|
||||
N_CYCLES = 4
|
||||
NX_ = 1280
|
||||
NY_ = 512
|
||||
|
||||
# -- helper: warp CCD directions back to physical space --
|
||||
def warp(W: np.ndarray, modes: np.ndarray) -> np.ndarray:
|
||||
"""Convert CCD weight vectors to physical modes."""
|
||||
return modes @ W
|
||||
|
||||
|
||||
# ====================================================================
|
||||
# Task 1: O_k heatmap (force_fy primary, from ccd_results.json)
|
||||
# ====================================================================
|
||||
def task_1():
|
||||
print("=== Task 1: O_k heatmap (force_fy) ===", flush=True)
|
||||
results_path = os.path.join(DATA_DIR, "ccd", "ccd_results.json")
|
||||
if not os.path.isfile(results_path):
|
||||
print(" SKIP: ccd_results.json not found", flush=True)
|
||||
return
|
||||
|
||||
with open(results_path) as f:
|
||||
all_results = json.load(f)
|
||||
|
||||
for r_label, r in [("r6", 6), ("r10", 10)]:
|
||||
diameters = [0.75, 1.0, 1.5]
|
||||
ov_matrix = np.full((3, 3), np.nan)
|
||||
|
||||
for col_idx, diam in enumerate(diameters):
|
||||
tgt_name = f"target_cylinder_{diam}L"
|
||||
ill_name = f"illusion_{diam}L"
|
||||
|
||||
# Build target-only POD basis and recompute CCD for O_k
|
||||
try:
|
||||
tgt_d = load_aligned_fields(tgt_name)
|
||||
ill_d = load_aligned_fields(ill_name)
|
||||
pin_d = load_aligned_fields("pinball")
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
|
||||
Q_tgt = build_field_matrix(tgt_d["ux"], tgt_d["uy"])
|
||||
mf, modes, _, coeffs = compute_pod(Q_tgt)
|
||||
modes_r = modes[:, :r]
|
||||
|
||||
def get_ccd_w(name, data):
|
||||
a = project_into_basis(data["ux"], data["uy"], modes_r, mf)
|
||||
frc = data.get("forces")
|
||||
if frc is None:
|
||||
return None
|
||||
y = make_force_obs(frc, name, mode="fy")
|
||||
W, _, _, _, _, _ = compute_reduced_ccd(a, y, Q_delay=CCD_Q)
|
||||
return W
|
||||
|
||||
W_tgt = get_ccd_w(tgt_name, tgt_d)
|
||||
W_ill = get_ccd_w(ill_name, ill_d)
|
||||
W_pin = get_ccd_w("pinball", pin_d)
|
||||
|
||||
def ov(Wa, Wb, k=0):
|
||||
if Wa is None or Wb is None:
|
||||
return np.nan
|
||||
n = min(Wa.shape[1], Wb.shape[1])
|
||||
if k >= n:
|
||||
return np.nan
|
||||
return float(abs(
|
||||
Wa[:, k] / (np.linalg.norm(Wa[:, k]) + 1e-12) @
|
||||
Wb[:, k] / (np.linalg.norm(Wb[:, k]) + 1e-12)
|
||||
))
|
||||
|
||||
ov_matrix[0, col_idx] = ov(W_tgt, W_ill)
|
||||
ov_matrix[1, col_idx] = ov(W_tgt, W_pin)
|
||||
ov_matrix[2, col_idx] = ov(W_ill, W_pin)
|
||||
|
||||
for d in [tgt_d, ill_d, pin_d]:
|
||||
if d is not None:
|
||||
pass # No explicit close needed, gc will handle
|
||||
|
||||
fig, ax = plt.subplots(figsize=(8, 6))
|
||||
im = ax.imshow(ov_matrix, cmap="viridis", vmin=0, vmax=1, aspect="auto")
|
||||
ax.set_xticks(range(3))
|
||||
ax.set_xticklabels(["0.75L", "1.0L", "1.5L"])
|
||||
ax.set_yticks(range(3))
|
||||
ax.set_yticklabels(["target-illusion", "target-pinball", "illusion-pinball"])
|
||||
|
||||
for i in range(3):
|
||||
for j in range(3):
|
||||
v = ov_matrix[i, j]
|
||||
if not np.isnan(v):
|
||||
ax.text(j, i, f"{v:.3f}", ha="center", va="center",
|
||||
color="white" if v > 0.5 else "black", fontsize=12)
|
||||
|
||||
# Annotate 1.5L as special mechanism
|
||||
ax.annotate("special mechanism", xy=(2.0, -0.15), fontsize=9,
|
||||
ha="center", va="center", color="orange",
|
||||
xycoords="axes fraction")
|
||||
|
||||
plt.colorbar(im, label="O_1 (modal overlap)")
|
||||
plt.title(f"Force-CCD (SigmaFy) O_1 heatmap ({r_label})")
|
||||
plt.tight_layout()
|
||||
path = os.path.join(FIG_DIR, f"Ok_heatmap_fy_{r_label}.png")
|
||||
fig.savefig(path, dpi=150)
|
||||
plt.close(fig)
|
||||
print(f" Saved: {path}", flush=True)
|
||||
|
||||
|
||||
# ====================================================================
|
||||
# Task 2: CCD mode 1 physical fields (target-only basis)
|
||||
# ====================================================================
|
||||
def task_2():
|
||||
print("=== Task 2: CCD mode 1 physical fields ===", flush=True)
|
||||
r = 6
|
||||
|
||||
for diam in [0.75, 1.0]:
|
||||
tgt_name = f"target_cylinder_{diam}L"
|
||||
ill_name = f"illusion_{diam}L"
|
||||
|
||||
try:
|
||||
tgt_d = load_aligned_fields(tgt_name)
|
||||
ill_d = load_aligned_fields(ill_name)
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
|
||||
# Build target-only POD basis
|
||||
Q_tgt = build_field_matrix(tgt_d["ux"], tgt_d["uy"])
|
||||
mf, modes, _, _ = compute_pod(Q_tgt)
|
||||
modes_r = modes[:, :r]
|
||||
|
||||
for name, d_obj, label in [(tgt_name, tgt_d, "target"),
|
||||
(ill_name, ill_d, "illusion")]:
|
||||
a = project_into_basis(d_obj["ux"], d_obj["uy"], modes_r, mf)
|
||||
frc = d_obj.get("forces")
|
||||
if frc is None:
|
||||
continue
|
||||
y = make_force_obs(frc, name, mode="fy")
|
||||
W, _, _, _, _, _ = compute_reduced_ccd(a, y, Q_delay=CCD_Q)
|
||||
|
||||
ccd_mode = warp(W[:, :1], modes_r)
|
||||
half = NX_ * NY_
|
||||
ux_m = ccd_mode[:half, 0].reshape(NY_, NX_)
|
||||
uy_m = ccd_mode[half:, 0].reshape(NY_, NX_)
|
||||
|
||||
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
|
||||
vmax = max(np.abs(ux_m).max(), np.abs(uy_m).max()) + 1e-12
|
||||
|
||||
im0 = axes[0].imshow(ux_m, cmap="RdBu_r", vmin=-vmax, vmax=vmax,
|
||||
origin="lower", aspect="equal",
|
||||
extent=(0, NX_ - 1, 0, NY_ - 1))
|
||||
axes[0].set_title(f"{diam}L {label}: CCD mode 1 ux")
|
||||
plt.colorbar(im0, ax=axes[0])
|
||||
|
||||
im1 = axes[1].imshow(uy_m, cmap="RdBu_r", vmin=-vmax, vmax=vmax,
|
||||
origin="lower", aspect="equal",
|
||||
extent=(0, NX_ - 1, 0, NY_ - 1))
|
||||
axes[1].set_title(f"{diam}L {label}: CCD mode 1 uy")
|
||||
plt.colorbar(im1, ax=axes[1])
|
||||
|
||||
plt.tight_layout()
|
||||
path = os.path.join(FIG_DIR, f"ccd_mode1_fy_{diam}L_{label}.png")
|
||||
fig.savefig(path, dpi=150)
|
||||
plt.close(fig)
|
||||
print(f" Saved: {path}", flush=True)
|
||||
|
||||
# Mark end for this diameter
|
||||
del tgt_d, ill_d
|
||||
|
||||
|
||||
# ====================================================================
|
||||
# Task 3: z_1(t) verification
|
||||
# ====================================================================
|
||||
def task_3():
|
||||
print("=== Task 3: z_1(t) verification ===", flush=True)
|
||||
|
||||
for diam in [0.75, 1.0]:
|
||||
ill_name = f"illusion_{diam}L"
|
||||
tgt_name = f"target_cylinder_{diam}L"
|
||||
|
||||
try:
|
||||
tgt_d = load_aligned_fields(tgt_name)
|
||||
ill_d = load_aligned_fields(ill_name)
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
|
||||
# Target-only POD basis
|
||||
Q_tgt = build_field_matrix(tgt_d["ux"], tgt_d["uy"])
|
||||
mf, modes, _, _ = compute_pod(Q_tgt)
|
||||
modes_r = modes[:, :6]
|
||||
|
||||
a = project_into_basis(ill_d["ux"], ill_d["uy"], modes_r, mf)
|
||||
frc = ill_d.get("forces")
|
||||
if frc is None:
|
||||
continue
|
||||
y = make_force_obs(frc, ill_name, mode="fy")
|
||||
W, sig, _, z, _, _ = compute_reduced_ccd(a, y, Q_delay=CCD_Q)
|
||||
|
||||
fig, axes = plt.subplots(2, 1, figsize=(12, 6))
|
||||
|
||||
ax = axes[0]
|
||||
ax.plot(z[0, :], "b-", label="z_1(t)", alpha=0.8)
|
||||
ax.set_ylabel("CCD temporal coeff")
|
||||
ax.set_title(f"{diam}L illusion: Force-CCD (SigmaFy) z_1(t)")
|
||||
ax.legend()
|
||||
ax.grid(True, alpha=0.3)
|
||||
|
||||
ax = axes[1]
|
||||
Nv = z.shape[1]
|
||||
y_norm = (y[0, :Nv] - np.mean(y[0, :Nv])) / (np.std(y[0, :Nv]) + 1e-12)
|
||||
z_norm = (z[0, :] - np.mean(z[0, :])) / (np.std(z[0, :]) + 1e-12)
|
||||
ax.plot(y_norm, "r-", label="norm SigmaFy", alpha=0.7)
|
||||
ax.plot(z_norm, "b--", label="norm z_1", alpha=0.7)
|
||||
ax.set_xlabel("Flat sample index")
|
||||
ax.set_ylabel("Normalized amplitude")
|
||||
ax.set_title(f"{diam}L: z_1 vs SigmaFy (normalized)")
|
||||
ax.legend()
|
||||
ax.grid(True, alpha=0.3)
|
||||
|
||||
plt.tight_layout()
|
||||
path = os.path.join(FIG_DIR, f"z1_verification_fy_{diam}L.png")
|
||||
fig.savefig(path, dpi=150)
|
||||
plt.close(fig)
|
||||
print(f" Saved: {path}", flush=True)
|
||||
|
||||
|
||||
# ====================================================================
|
||||
# Task 4: POD phase portraits (target-only basis)
|
||||
# ====================================================================
|
||||
def task_4():
|
||||
print("=== Task 4: POD phase portraits ===", flush=True)
|
||||
|
||||
fig, axes = plt.subplots(1, 3, figsize=(15, 4))
|
||||
|
||||
for idx, diam in enumerate([0.75, 1.0, 1.5]):
|
||||
if diam in [0.75, 1.0]:
|
||||
main_only = False
|
||||
else:
|
||||
main_only = False # include 1.5L in phase portrait
|
||||
|
||||
tgt_name = f"target_cylinder_{diam}L"
|
||||
ill_name = f"illusion_{diam}L"
|
||||
|
||||
try:
|
||||
tgt_d = load_aligned_fields(tgt_name)
|
||||
ill_d = load_aligned_fields(ill_name)
|
||||
pin_d = load_aligned_fields("pinball")
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
|
||||
Q_tgt = build_field_matrix(tgt_d["ux"], tgt_d["uy"])
|
||||
mf, modes, _, _ = compute_pod(Q_tgt)
|
||||
modes_r = modes[:, :6]
|
||||
|
||||
ax = axes[idx]
|
||||
colors = {"target": "red", "illusion": "blue", "pinball": "green"}
|
||||
|
||||
for kind, d_obj, label in [("target", tgt_d, "target"),
|
||||
("illusion", ill_d, "illusion"),
|
||||
("pinball", pin_d, "pinball (unc)")]:
|
||||
if d_obj is None:
|
||||
continue
|
||||
a = project_into_basis(d_obj["ux"], d_obj["uy"], modes_r, mf)
|
||||
ax.plot(a[0, :], a[1, :], ".", color=colors[kind], markersize=3,
|
||||
alpha=0.5, label=label if idx == 0 else "")
|
||||
|
||||
ax.set_xlabel("a_1")
|
||||
ax.set_ylabel("a_2")
|
||||
title = f"{diam}L POD attractor"
|
||||
if diam == 1.5:
|
||||
title += " (special mechanism)"
|
||||
ax.set_title(title)
|
||||
ax.grid(True, alpha=0.3)
|
||||
ax.set_aspect("equal")
|
||||
if idx == 0:
|
||||
ax.legend(fontsize=8)
|
||||
|
||||
plt.tight_layout()
|
||||
path = os.path.join(FIG_DIR, "pod_phase_portraits_target_basis.png")
|
||||
fig.savefig(path, dpi=150)
|
||||
plt.close(fig)
|
||||
print(f" Saved: {path}", flush=True)
|
||||
|
||||
|
||||
# ====================================================================
|
||||
# Task 5: 1.5L special-mechanism diagnostics
|
||||
# ====================================================================
|
||||
def task_5():
|
||||
"""1.5L special-mechanism analysis — raw diagnostics, action compactness, phase drift."""
|
||||
print("=== Task 5: 1.5L special-mechanism diagnostics ===", flush=True)
|
||||
|
||||
SI = 800 # 1.5L sample interval
|
||||
|
||||
try:
|
||||
ill_d = load_aligned_fields("illusion_1.5L")
|
||||
tgt_d = load_aligned_fields("target_cylinder_1.5L")
|
||||
pin_d = load_aligned_fields("pinball")
|
||||
except FileNotFoundError as e:
|
||||
print(f" SKIP: {e}", flush=True)
|
||||
return
|
||||
|
||||
sens_i = ill_d.get("sensors")
|
||||
forc_i = ill_d.get("forces")
|
||||
act_i = ill_d.get("actions")
|
||||
sens_t = tgt_d.get("sensors")
|
||||
forc_t = tgt_d.get("forces")
|
||||
|
||||
# ---- Panel 5a: Raw diagnostics (sensors, forces, actions) ----
|
||||
print(" -- 5a: Raw time-series diagnostics", flush=True)
|
||||
n_plot = min(400, len(sens_i) if sens_i is not None else 0)
|
||||
t = np.arange(n_plot) * SI / 1000.0
|
||||
|
||||
fig, axes = plt.subplots(3, 1, figsize=(14, 10))
|
||||
|
||||
ax = axes[0]
|
||||
if sens_i is not None:
|
||||
for ch in range(6):
|
||||
ax.plot(t, sens_i[:n_plot, ch], label=f"ill_s{ch}", alpha=0.7)
|
||||
if sens_t is not None:
|
||||
ax.plot(t, sens_t[:n_plot, 3], "k--", label="target_s1_v", linewidth=2)
|
||||
ax.set_ylabel("Velocity (lattice)")
|
||||
ax.set_title("1.5L Sensors: Illusion vs Target")
|
||||
ax.legend(fontsize=7, ncol=3)
|
||||
ax.grid(True, alpha=0.3)
|
||||
|
||||
ax = axes[1]
|
||||
if forc_i is not None:
|
||||
for ch in range(6):
|
||||
ax.plot(t, forc_i[:n_plot, ch], label=f"ill_F{ch}", alpha=0.7)
|
||||
if forc_t is not None:
|
||||
ax.plot(t, forc_t[:n_plot, 0], "k--", label="target_Fx", linewidth=2)
|
||||
ax.plot(t, forc_t[:n_plot, 1], "k:", label="target_Fy", linewidth=2)
|
||||
ax.set_ylabel("Force (lattice)")
|
||||
ax.set_title("1.5L Forces")
|
||||
ax.legend(fontsize=7, ncol=3)
|
||||
ax.grid(True, alpha=0.3)
|
||||
|
||||
ax = axes[2]
|
||||
if act_i is not None:
|
||||
for ch in range(3):
|
||||
ax.plot(t, act_i[:n_plot, ch], label=f"Omega_{ch}")
|
||||
ax.set_xlabel("Time (T0 units)")
|
||||
ax.set_ylabel("Omega (normalised)")
|
||||
ax.set_title("1.5L Actions (DRL output, [-1, 1])")
|
||||
ax.legend()
|
||||
ax.grid(True, alpha=0.3)
|
||||
|
||||
plt.tight_layout()
|
||||
path = os.path.join(FIG_DIR, "15L_raw_timeseries.png")
|
||||
fig.savefig(path, dpi=150)
|
||||
plt.close(fig)
|
||||
print(f" Saved: {path}", flush=True)
|
||||
|
||||
# ---- Panel 5b: Force-CCD compactness ----
|
||||
print(" -- 5b: Force-CCD compactness", flush=True)
|
||||
results_path = os.path.join(DATA_DIR, "ccd", "ccd_results.json")
|
||||
if os.path.isfile(results_path):
|
||||
with open(results_path) as f:
|
||||
all_res = json.load(f)
|
||||
# Report key action-CCD m80
|
||||
for r in [6, 8, 10]:
|
||||
key = f"1.5L_illusion_1.5L_action_r{r}"
|
||||
if key in all_res:
|
||||
print(f" {key}: m80={all_res[key]['m80']}, "
|
||||
f"sigma1={all_res[key]['sigma_top3'][0]:.4f}", flush=True)
|
||||
# Force-fy compactness
|
||||
key_f = f"1.5L_illusion_1.5L_force_fy_r{r}"
|
||||
if key_f in all_res:
|
||||
print(f" {key_f}: m80={all_res[key_f]['m80']}, "
|
||||
f"sigma1={all_res[key_f]['sigma_top3'][0]:.4f}", flush=True)
|
||||
|
||||
# ---- Panel 5c: Windowed periodicity (phase drift) ----
|
||||
# Use raw (non-aligned) sensor data for sufficient window length
|
||||
print(" -- 5c: Windowed periodicity", flush=True)
|
||||
raw_path = os.path.join(DATA_DIR, "illusion", "illusion_1.5L", "controlled.npz")
|
||||
if os.path.isfile(raw_path):
|
||||
raw_d = np.load(raw_path)
|
||||
raw_sensors = raw_d["sensors"]
|
||||
raw_d.close()
|
||||
else:
|
||||
raw_sensors = sens_i # fallback to aligned data
|
||||
|
||||
if raw_sensors is not None and len(raw_sensors) > 200:
|
||||
signal = raw_sensors[:, 1] # center sensor v
|
||||
window = 200
|
||||
stride = 20
|
||||
n_windows = (len(signal) - window) // stride
|
||||
|
||||
cv_vals, T_vals, f_vals, t_centers = [], [], [], []
|
||||
for w in range(n_windows):
|
||||
seg = signal[w * stride:w * stride + window]
|
||||
cv_T, mean_T, _ = detect_cycle_stability(seg, SI)
|
||||
f_dom, T_dom, _ = detect_dominant_frequency(seg, SI)
|
||||
cv_vals.append(cv_T)
|
||||
T_vals.append(mean_T)
|
||||
f_vals.append(f_dom)
|
||||
t_centers.append((w * stride + window // 2) * SI / 1000)
|
||||
|
||||
fig, axes = plt.subplots(3, 1, figsize=(14, 8), sharex=True)
|
||||
|
||||
ax = axes[0]
|
||||
ax.plot(t_centers, cv_vals, "o-", markersize=3)
|
||||
ax.axhline(0.10, color="r", ls="--", label="strict gate")
|
||||
ax.axhline(0.12, color="orange", ls="--", label="relaxed gate")
|
||||
ax.set_ylabel("CV_T")
|
||||
ax.set_title("1.5L Windowed cycle stability (window=200 steps)")
|
||||
ax.legend()
|
||||
ax.grid(True, alpha=0.3)
|
||||
|
||||
ax = axes[1]
|
||||
ax.plot(t_centers, T_vals, "o-", markersize=3, color="green")
|
||||
ax.set_ylabel("Mean period (steps)")
|
||||
ax.grid(True, alpha=0.3)
|
||||
|
||||
ax = axes[2]
|
||||
ax.plot(t_centers, f_vals, "o-", markersize=3, color="purple")
|
||||
ax.set_xlabel("Time (T0 units)")
|
||||
ax.set_ylabel("Freq (1/step)")
|
||||
ax.grid(True, alpha=0.3)
|
||||
|
||||
plt.tight_layout()
|
||||
path = os.path.join(FIG_DIR, "15L_windowed_periodicity.png")
|
||||
fig.savefig(path, dpi=150)
|
||||
plt.close(fig)
|
||||
print(f" Saved: {path}", flush=True)
|
||||
|
||||
# ---- Panel 5d: O(target, illusion) overlap bar ----
|
||||
print(" -- 5d: Force-CCD overlap summary", flush=True)
|
||||
fig, ax = plt.subplots(figsize=(6, 4))
|
||||
diam_labels = ["0.75L", "1.0L", "1.5L"]
|
||||
ov_vals = []
|
||||
results_path = os.path.join(DATA_DIR, "ccd", "ccd_results.json")
|
||||
if os.path.isfile(results_path):
|
||||
with open(results_path) as f:
|
||||
all_res = json.load(f)
|
||||
for diam in [0.75, 1.0, 1.5]:
|
||||
tgt = f"target_cylinder_{diam}L"
|
||||
ill = f"illusion_{diam}L"
|
||||
k_tgt = f"{diam}L_{tgt}_force_fy_r6"
|
||||
k_ill = f"{diam}L_{ill}_force_fy_r6"
|
||||
# Need W from the saved results — but we don't store W in json.
|
||||
# Instead, recompute overlap quickly from the raw data.
|
||||
try:
|
||||
td = load_aligned_fields(tgt)
|
||||
id_ = load_aligned_fields(ill)
|
||||
Qt = build_field_matrix(td["ux"], td["uy"])
|
||||
mf, modes, _, _ = compute_pod(Qt)
|
||||
modes6 = modes[:, :6]
|
||||
a_t = project_into_basis(td["ux"], td["uy"], modes6, mf)
|
||||
a_i = project_into_basis(id_["ux"], id_["uy"], modes6, mf)
|
||||
y_t = make_force_obs(td["forces"], tgt, mode="fy")
|
||||
y_i = make_force_obs(id_["forces"], ill, mode="fy")
|
||||
Wt, _, _, _, _, _ = compute_reduced_ccd(a_t, y_t, Q_delay=CCD_Q)
|
||||
Wi, _, _, _, _, _ = compute_reduced_ccd(a_i, y_i, Q_delay=CCD_Q)
|
||||
ov_val = float(abs(
|
||||
Wt[:, 0] / (np.linalg.norm(Wt[:, 0]) + 1e-12) @
|
||||
Wi[:, 0] / (np.linalg.norm(Wi[:, 0]) + 1e-12)
|
||||
))
|
||||
ov_vals.append(ov_val)
|
||||
except Exception as e:
|
||||
print(f" {diam}L overlap failed: {e}", flush=True)
|
||||
ov_vals.append(0.0)
|
||||
|
||||
bars = ax.bar(diam_labels, ov_vals, color=["blue", "green", "orange"], alpha=0.7)
|
||||
for bar, v in zip(bars, ov_vals):
|
||||
ax.text(bar.get_x() + bar.get_width() / 2, bar.get_height() + 0.02,
|
||||
f"{v:.3f}", ha="center", fontsize=11)
|
||||
ax.set_ylim(0, 1.1)
|
||||
ax.set_ylabel("O_1 (target-illusion)")
|
||||
ax.set_title("Force-CCD (SigmaFy) overlap comparison")
|
||||
ax.grid(True, alpha=0.3, axis="y")
|
||||
|
||||
# Annotate 1.5L
|
||||
ax.annotate("special mechanism", xy=(2, 0.05), fontsize=9,
|
||||
ha="center", color="orange", fontweight="bold")
|
||||
|
||||
plt.tight_layout()
|
||||
path = os.path.join(FIG_DIR, "15L_overlap_summary.png")
|
||||
fig.savefig(path, dpi=150)
|
||||
plt.close(fig)
|
||||
print(f" Saved: {path}", flush=True)
|
||||
|
||||
|
||||
# ====================================================================
|
||||
# Task 6: Cross-diameter overlap (all illusions in 1.0L target basis)
|
||||
# ====================================================================
|
||||
def task_6():
|
||||
print("=== Task 6: Cross-diameter overlap (1.0L target basis) ===", flush=True)
|
||||
|
||||
try:
|
||||
tgt_10 = load_aligned_fields("target_cylinder_1.0L")
|
||||
except FileNotFoundError:
|
||||
print(" SKIP: missing 1.0L target data", flush=True)
|
||||
return
|
||||
|
||||
Q_10 = build_field_matrix(tgt_10["ux"], tgt_10["uy"])
|
||||
mf_10, modes_10, _, _ = compute_pod(Q_10)
|
||||
modes6 = modes_10[:, :6]
|
||||
|
||||
W_cross = {}
|
||||
for diam in [0.75, 1.0, 1.5]:
|
||||
name = f"illusion_{diam}L"
|
||||
try:
|
||||
d = load_aligned_fields(name)
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
a = project_into_basis(d["ux"], d["uy"], modes6, mf_10)
|
||||
frc = d.get("forces")
|
||||
if frc is None:
|
||||
continue
|
||||
y = make_force_obs(frc, name, mode="fy")
|
||||
W, _, _, _, _, _ = compute_reduced_ccd(a, y, Q_delay=CCD_Q)
|
||||
W_cross[diam] = W
|
||||
|
||||
if len(W_cross) < 2:
|
||||
print(" SKIP: not enough illusions", flush=True)
|
||||
return
|
||||
|
||||
diam_list = sorted(W_cross.keys())
|
||||
ov_mat = np.ones((len(diam_list), len(diam_list)))
|
||||
print(" Cross-diameter O_1 matrix (1.0L target-only basis, force_fy):")
|
||||
for i, da in enumerate(diam_list):
|
||||
for j, db in enumerate(diam_list):
|
||||
if i >= j:
|
||||
continue
|
||||
Wa, Wb = W_cross[da], W_cross[db]
|
||||
ov = float(abs(
|
||||
Wa[:, 0] / (np.linalg.norm(Wa[:, 0]) + 1e-12) @
|
||||
Wb[:, 0] / (np.linalg.norm(Wb[:, 0]) + 1e-12)
|
||||
))
|
||||
ov_mat[i, j] = ov
|
||||
ov_mat[j, i] = ov
|
||||
print(f" O({da}L, {db}L) = {ov:.4f}")
|
||||
|
||||
fig, ax = plt.subplots(figsize=(6, 5))
|
||||
im = ax.imshow(ov_mat, cmap="viridis", vmin=0, vmax=1)
|
||||
ax.set_xticks(range(len(diam_list)))
|
||||
ax.set_xticklabels([f"{d}L" for d in diam_list])
|
||||
ax.set_yticks(range(len(diam_list)))
|
||||
ax.set_yticklabels([f"{d}L" for d in diam_list])
|
||||
for i in range(len(diam_list)):
|
||||
for j in range(len(diam_list)):
|
||||
v = ov_mat[i, j]
|
||||
ax.text(j, i, f"{v:.3f}", ha="center", va="center",
|
||||
color="white" if v > 0.5 else "black")
|
||||
plt.colorbar(im, label="O_1")
|
||||
plt.title("Cross-diam force-CCD (1.0L target basis, SigmaFy)")
|
||||
plt.tight_layout()
|
||||
path = os.path.join(FIG_DIR, "cross_diameter_overlap_fy.png")
|
||||
fig.savefig(path, dpi=150)
|
||||
plt.close(fig)
|
||||
print(f" Saved: {path}", flush=True)
|
||||
|
||||
|
||||
# ====================================================================
|
||||
# Main
|
||||
# ====================================================================
|
||||
def main():
|
||||
print("=" * 60, flush=True)
|
||||
print("Phase 4: Visualization (Round 5)", flush=True)
|
||||
print("=" * 60, flush=True)
|
||||
|
||||
task_1() # O_k heatmap
|
||||
task_2() # CCD physical modes (0.75L, 1.0L)
|
||||
task_4() # POD phase portraits (all diameters)
|
||||
task_3() # z_1 verification (0.75L, 1.0L)
|
||||
task_5() # 1.5L special mechanism
|
||||
task_6() # Cross-diameter overlap
|
||||
|
||||
print(f"\nAll figures saved to {FIG_DIR}", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -1,7 +1,5 @@
|
||||
"""Steady cloak metrics (mean flow error, recirculation zone, fluctuation suppression).
|
||||
|
||||
Uses empty channel reference from target_channel for E_mean computation.
|
||||
|
||||
Usage:
|
||||
python steady/run_steady.py
|
||||
"""
|
||||
@ -13,136 +11,81 @@ import sys
|
||||
|
||||
import numpy as np
|
||||
|
||||
_SRC = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
if _SRC not in sys.path:
|
||||
sys.path.insert(0, _SRC)
|
||||
_ANALYSIS = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
if _ANALYSIS not in sys.path:
|
||||
sys.path.insert(0, _ANALYSIS)
|
||||
|
||||
from CCD_analysis.configs import DATA_DIR
|
||||
|
||||
|
||||
def load_fields(d: str):
|
||||
p = os.path.join(d, "fields.npz")
|
||||
if not os.path.isfile(p):
|
||||
return None
|
||||
f = np.load(p)
|
||||
return f["ux"].astype(np.float64), f["uy"].astype(np.float64)
|
||||
|
||||
|
||||
def main():
|
||||
print("=== Steady Cloak Metrics ===\n", flush=True)
|
||||
print("=== Steady Cloak Metrics ===\n")
|
||||
|
||||
steady_dir = os.path.join(DATA_DIR, "steady_cloak", "steady_cloak")
|
||||
pinball_dir = os.path.join(DATA_DIR, "pinball", "pinball")
|
||||
empty_chan_dir = os.path.join(DATA_DIR, "target_channel", "target_channel")
|
||||
empty_dir = steady_dir # TODO: generate empty channel reference
|
||||
|
||||
# Load fields
|
||||
def load_fields(d):
|
||||
p = os.path.join(d, "fields.npz")
|
||||
if not os.path.isfile(p):
|
||||
return None
|
||||
f = np.load(p)
|
||||
return f["ux"].astype(np.float64), f["uy"].astype(np.float64)
|
||||
|
||||
sc = load_fields(steady_dir)
|
||||
if sc is None:
|
||||
print("ERROR: no steady cloak fields. Run scripts/collect_steady_cloak.py first.", flush=True)
|
||||
return 1
|
||||
|
||||
ec = load_fields(empty_chan_dir)
|
||||
if ec is None:
|
||||
print("ERROR: no empty channel fields. Run scripts/collect_empty_channel.py first.", flush=True)
|
||||
print("ERROR: no steady cloak fields. Run scripts/collect_steady_cloak.py first.")
|
||||
return 1
|
||||
|
||||
ux_s, uy_s = sc
|
||||
ux_e, uy_e = ec
|
||||
|
||||
# Time means
|
||||
ux_s_mean = np.mean(ux_s, axis=0)
|
||||
uy_s_mean = np.mean(uy_s, axis=0)
|
||||
ux_e_mean = np.mean(ux_e, axis=0)
|
||||
uy_e_mean = np.mean(uy_e, axis=0)
|
||||
|
||||
# RMS fluctuations
|
||||
ux_s_rms = np.sqrt(np.mean((ux_s - ux_s_mean) ** 2, axis=0))
|
||||
uy_s_rms = np.sqrt(np.mean((uy_s - uy_s_mean) ** 2, axis=0))
|
||||
|
||||
# 1. Fluctuation level
|
||||
total_rms = float(np.sqrt(np.mean(ux_s_rms**2 + uy_s_rms**2)))
|
||||
print(f"1. Total RMS (steady cloak): {total_rms:.6f}", flush=True)
|
||||
print(f"1. Total RMS (steady cloak): {total_rms:.6f}")
|
||||
|
||||
# === E_mean: mean flow error vs empty channel ===
|
||||
# Use total velocity norm to avoid division by near-zero uy
|
||||
vel_norm = np.sqrt(np.mean(ux_e_mean**2 + uy_e_mean**2)) + 1e-12
|
||||
|
||||
diff_ux = ux_s_mean - ux_e_mean
|
||||
diff_uy = uy_s_mean - uy_e_mean
|
||||
|
||||
E_mean_ux = float(np.sqrt(np.mean(diff_ux**2)) / vel_norm)
|
||||
E_mean_uy = float(np.sqrt(np.mean(diff_uy**2)) / vel_norm)
|
||||
E_mean_total = float(np.sqrt(np.mean(diff_ux**2 + diff_uy**2)) / vel_norm)
|
||||
print(f"2. E_mean (vs empty channel, normalized by |U_e|): ux={E_mean_ux:.4f}, uy={E_mean_uy:.4f}, total={E_mean_total:.4f}", flush=True)
|
||||
|
||||
# === Recirculation zone ===
|
||||
# 2. Recirculation zone
|
||||
ny, nx = ux_s_mean.shape
|
||||
cline = ux_s_mean[ny // 2, :]
|
||||
neg = np.where(cline[400:-50] < 0)[0]
|
||||
if len(neg) > 0:
|
||||
L_r = float(neg[-1])
|
||||
print(f"3. Recirculation length L_r: {L_r:.0f} lattice units", flush=True)
|
||||
print(f"2. Recirculation length L_r: {L_r:.0f} lattice units")
|
||||
else:
|
||||
L_r = 0.0
|
||||
print(f"3. Recirculation length L_r: 0 (no reverse flow)", flush=True)
|
||||
print(f"2. Recirculation length L_r: 0 (no reverse flow)")
|
||||
|
||||
# Recirculation area: count pixels with ux < 0 in the mean field downstream of pinball
|
||||
recirc_mask = (ux_s_mean < 0) & (np.arange(nx) > 400)
|
||||
A_r = int(np.sum(recirc_mask))
|
||||
print(f" Recirculation area A_r: {A_r} pixels", flush=True)
|
||||
|
||||
# === Sensor mean restoration ===
|
||||
def load_sensor_mean(path):
|
||||
sp = os.path.join(path, "sensors.npz")
|
||||
if not os.path.isfile(sp):
|
||||
return None
|
||||
sd = np.load(sp)
|
||||
return np.mean(sd["sensors"], axis=0)
|
||||
|
||||
sens_s = load_sensor_mean(steady_dir)
|
||||
sens_e = load_sensor_mean(empty_chan_dir)
|
||||
if sens_s is not None and sens_e is not None:
|
||||
diff_sens = sens_s[:6] - sens_e[:6]
|
||||
print(f"4. Sensor mean error (||s_cloak - s_channel||): {np.linalg.norm(diff_sens):.4f}", flush=True)
|
||||
print(f" Per-component error: {[f'{v:.4f}' for v in diff_sens]}", flush=True)
|
||||
else:
|
||||
print("4. Sensor comparison: unavailable", flush=True)
|
||||
|
||||
# === Fluctuation suppression vs uncontrolled pinball ===
|
||||
pb = load_fields(pinball_dir)
|
||||
fluc_suppression = None
|
||||
if pb is not None:
|
||||
ux_p, uy_p = pb
|
||||
ux_p_mean = np.mean(ux_p, axis=0)
|
||||
uy_p_mean = np.mean(uy_p, axis=0)
|
||||
ux_p_rms = np.sqrt(np.mean((ux_p - ux_p_mean) ** 2, axis=0))
|
||||
uy_p_rms = np.sqrt(np.mean((uy_p - uy_p_mean) ** 2, axis=0))
|
||||
pb_total_rms = float(np.sqrt(np.mean(ux_p_rms**2 + uy_p_rms**2)))
|
||||
print(f"5. Pinball total RMS: {pb_total_rms:.6f}", flush=True)
|
||||
if pb_total_rms > 1e-12:
|
||||
fluc_suppression = (1 - total_rms / pb_total_rms) * 100
|
||||
print(f" Fluctuation suppression: {fluc_suppression:.1f}%", flush=True)
|
||||
|
||||
# === Control cost ===
|
||||
# 3. Sensor mean (if available)
|
||||
sens_path = os.path.join(steady_dir, "sensors.npz")
|
||||
if os.path.isfile(sens_path):
|
||||
sd = np.load(sens_path)
|
||||
forces = sd.get("forces")
|
||||
if forces is not None and forces.shape[1] >= 6:
|
||||
omega_rms = float(np.sqrt(np.mean(forces ** 2)))
|
||||
print(f"6. RMS forces (control effort proxy): {omega_rms:.6f}", flush=True)
|
||||
sens_mean = np.mean(sd["sensors"], axis=0)
|
||||
print(f"3. Sensor means: ux_center={sens_mean[2]:.4f}, uy_center={sens_mean[3]:.4f}")
|
||||
|
||||
# 4. Compare with pinball (if available)
|
||||
pb = load_fields(pinball_dir)
|
||||
if pb is not None:
|
||||
ux_p, uy_p = pb
|
||||
ux_p_mean = np.mean(ux_p, axis=0)
|
||||
ux_p_rms = np.sqrt(np.mean((ux_p - ux_p_mean) ** 2, axis=0))
|
||||
pb_rms = float(np.sqrt(np.mean(ux_p_rms**2)))
|
||||
print(f"4. Pinball total RMS: {pb_rms:.6f}")
|
||||
print(f" Fluctuation suppression: {(1 - total_rms / (pb_rms + 1e-12)) * 100:.1f}%")
|
||||
|
||||
results = {
|
||||
"total_rms": total_rms,
|
||||
"E_mean_ux": E_mean_ux,
|
||||
"E_mean_uy": E_mean_uy,
|
||||
"E_mean_total": E_mean_total,
|
||||
"L_r": L_r,
|
||||
"A_r": A_r,
|
||||
"fluctuation_suppression_pct": fluc_suppression,
|
||||
"fluctuation_suppression_pct": float((1 - total_rms / (pb_rms + 1e-12)) * 100) if pb is not None else None,
|
||||
}
|
||||
out_dir = os.path.join(DATA_DIR, "steady")
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
with open(os.path.join(out_dir, "steady_metrics.json"), "w") as f:
|
||||
json.dump(results, f, indent=2)
|
||||
print(f"\nSaved to {out_dir}/steady_metrics.json", flush=True)
|
||||
print(f"\nSaved to {out_dir}/steady_metrics.json")
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@ -9,6 +9,4 @@ from .resampling import (
|
||||
e95_index, compute_reduced_ccd,
|
||||
stack_velocity_fields, unstack_velocity_modes,
|
||||
analyze_harmonics, gen_target_states_at,
|
||||
load_aligned_fields, make_force_obs,
|
||||
build_field_matrix, project_into_basis,
|
||||
)
|
||||
|
||||
@ -103,7 +103,7 @@ def add_pinball(
|
||||
obs_slice_end: int = 14,
|
||||
n_objects_total: Optional[int] = None,
|
||||
) -> dict:
|
||||
"""Add pinball cylinders, stabilize, compute norm, preset-action FIFO init.
|
||||
"""Add pinball cylinders, stabilize, compute norm, bias rollout.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
@ -151,7 +151,7 @@ def add_pinball(
|
||||
|
||||
print(f" norm: force_norm_fact={force_norm_fact:.6f}")
|
||||
|
||||
# ---- preset-action FIFO init ----
|
||||
# ---- bias-action rollout ----
|
||||
flow_field.apply_ddf()
|
||||
bias = np.zeros(n_obj, dtype=data_type)
|
||||
bias[n_obj - 3] = float(action_bias[0] * u0_float)
|
||||
@ -215,7 +215,7 @@ def scale_action(
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def get_velocity_field(flow_field: FlowField, u0: float = 0.01):
|
||||
"""Extract ux, uy fields from DDF on host. Returns (ux, uy) each (NX, NY)."""
|
||||
"""Extract ux, uy fields from DDF on host. Returns (ux, uy) each (NY, NX)."""
|
||||
flow_field.get_ddf()
|
||||
NX = flow_field.FIELD_SHAPE[0]
|
||||
NY = flow_field.FIELD_SHAPE[1]
|
||||
@ -228,17 +228,10 @@ def get_velocity_field(flow_field: FlowField, u0: float = 0.01):
|
||||
|
||||
|
||||
def vorticity_from_ddf(flow_field: FlowField, u0: float) -> np.ndarray:
|
||||
"""Compute z-vorticity from current DDF on host.
|
||||
|
||||
Returns omega_z with shape (NY, NX) for direct imshow use.
|
||||
"""
|
||||
"""Compute z-vorticity from current DDF on host."""
|
||||
ux, uy = get_velocity_field(flow_field, u0)
|
||||
# ux, uy have shape (NX, NY), transpose to (NY, NX) for correct axes
|
||||
ux_t = ux.T.astype(np.float64)
|
||||
uy_t = uy.T.astype(np.float64)
|
||||
# omega_z = duy/dx - dux/dy
|
||||
omega = np.gradient(uy_t, axis=1) - np.gradient(ux_t, axis=0)
|
||||
return omega
|
||||
omega = np.gradient(uy, axis=1) - np.gradient(ux, axis=0)
|
||||
return omega.astype(np.float64)
|
||||
|
||||
|
||||
def save_vorticity_png(path: str, omega: np.ndarray, title: str = ""):
|
||||
|
||||
@ -92,35 +92,14 @@ def phase_resample(data: np.ndarray, cycle_starts: List[int], n_pts: int = 24) -
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def compute_pod(snapshot_matrix: np.ndarray) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
|
||||
"""Compute POD from snapshot matrix using method of snapshots.
|
||||
|
||||
For N << M (e.g. N=96 snapshots, M=2*NX*NY=1.3M), uses temporal
|
||||
correlation matrix (N x N) instead of full SVD on (M x N).
|
||||
This is ~100x faster and numerically identical.
|
||||
"""Compute POD from snapshot matrix (n_points, n_snapshots).
|
||||
|
||||
Returns (mean_field, modes, singular_values, coefficients).
|
||||
"""
|
||||
mean_field = np.mean(snapshot_matrix, axis=1)
|
||||
Q = snapshot_matrix - mean_field[:, None]
|
||||
M, N = Q.shape
|
||||
|
||||
if N < M // 10: # method of snapshots: N << M
|
||||
# Temporal correlation: C = Q^T @ Q (N x N)
|
||||
C = Q.T.astype(np.float64) @ Q.astype(np.float64)
|
||||
s2, V = np.linalg.eigh(C)
|
||||
# Sort descending
|
||||
idx = np.argsort(s2)[::-1]
|
||||
s = np.sqrt(np.maximum(s2[idx], 0.0))
|
||||
V = V[:, idx]
|
||||
# Spatial modes: U = Q @ V * diag(1/s)
|
||||
U = Q.astype(np.float64) @ V
|
||||
U[:, s > 1e-12] /= s[s > 1e-12]
|
||||
# Coefficients: diag(s) @ V^T
|
||||
coefficients = (V * s).T
|
||||
else: # fallback to full SVD for small matrices
|
||||
U, s, Vt = np.linalg.svd(Q, full_matrices=False)
|
||||
coefficients = np.diag(s) @ Vt
|
||||
|
||||
U, s, Vt = np.linalg.svd(Q, full_matrices=False)
|
||||
coefficients = np.diag(s) @ Vt
|
||||
return mean_field, U, s, coefficients
|
||||
|
||||
|
||||
@ -137,68 +116,49 @@ def e95_index(cumulative_energy: np.ndarray) -> int:
|
||||
# CCD (reduced, Lyu23-inspired)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def compute_reduced_ccd(pod_coeffs: np.ndarray, observable: np.ndarray, Q_delay: int = 12) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, int, int]:
|
||||
"""Compute reduced CCD in POD coefficient space, with clean boundary handling.
|
||||
|
||||
Replaces np.roll-based delay with explicit lagged matrix construction,
|
||||
dropping wrapped-around samples instead of zero-padding them.
|
||||
def compute_reduced_ccd(pod_coeffs: np.ndarray, observable: np.ndarray, Q_delay: int = 12) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
|
||||
"""Compute reduced CCD in POD coefficient space.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
pod_coeffs : (r, N) ndarray — standardized POD coefficients.
|
||||
observable : (m, N) ndarray — standardized observable.
|
||||
Q_delay : int — number of delay steps (must be odd for symmetric window).
|
||||
Q_delay : int — number of delay steps.
|
||||
|
||||
Returns
|
||||
-------
|
||||
W : (r, min(r, m*Q_delay)) — CCD directions.
|
||||
sigma : (min_dim,) — singular values.
|
||||
R : (m*Q_delay, min_dim) — CCD response modes.
|
||||
z : (min_dim, N) — CCD temporal coefficients.
|
||||
N_original : int — original sample count.
|
||||
N_valid : int — sample count after boundary trimming.
|
||||
"""
|
||||
N = pod_coeffs.shape[1]
|
||||
N_original = N
|
||||
m = observable.shape[0]
|
||||
|
||||
half = Q_delay // 2
|
||||
# Keep only columns where ALL shifts have valid (non-wrapped) indices
|
||||
valid_start = half
|
||||
valid_end = N - half
|
||||
if Q_delay % 2 == 0:
|
||||
valid_end = N - half # symmetric even: one extra on each side
|
||||
valid_end = N - (Q_delay - 1) // 2 # general formula
|
||||
# For symmetric Q_delay: valid = [half, N - half)
|
||||
valid_start = half
|
||||
valid_end = N - half
|
||||
N_valid = valid_end - valid_start
|
||||
|
||||
if N_valid < Q_delay:
|
||||
raise ValueError(f"Too few valid samples ({N_valid}) for Q_delay={Q_delay}. Need at least {Q_delay}.")
|
||||
|
||||
rows = []
|
||||
for shift in range(-half, half + 1):
|
||||
shifted = observable[:, valid_start + shift:valid_end + shift]
|
||||
shifted = np.roll(observable, -shift, axis=1)
|
||||
if shift < 0:
|
||||
shifted[:, shift:] = 0.0
|
||||
elif shift > 0:
|
||||
shifted[:, :-shift] = 0.0
|
||||
rows.append(shifted)
|
||||
P = np.vstack(rows) # (m*Q_delay, N_valid)
|
||||
|
||||
A_valid = pod_coeffs[:, valid_start:valid_end]
|
||||
P = np.vstack(rows) # (m*Q_delay, N)
|
||||
|
||||
# Standardize
|
||||
P_mean = np.mean(P, axis=1, keepdims=True)
|
||||
P_std = np.std(P, axis=1, keepdims=True) + 1e-12
|
||||
P_z = (P - P_mean) / P_std
|
||||
|
||||
A_mean = np.mean(A_valid, axis=1, keepdims=True)
|
||||
A_std = np.std(A_valid, axis=1, keepdims=True) + 1e-12
|
||||
A_z = (A_valid - A_mean) / A_std
|
||||
A_mean = np.mean(pod_coeffs, axis=1, keepdims=True)
|
||||
A_std = np.std(pod_coeffs, axis=1, keepdims=True) + 1e-12
|
||||
A_z = (pod_coeffs - A_mean) / A_std
|
||||
|
||||
C = P_z @ A_z.T / (N_valid * np.sqrt(float(Q_delay)))
|
||||
C = P_z @ A_z.T / (N * np.sqrt(float(Q_delay)))
|
||||
R, s, Wt = np.linalg.svd(C, full_matrices=False)
|
||||
W = Wt.T
|
||||
z = W.T @ A_z
|
||||
return W, s, R, z, N_original, N_valid
|
||||
return W, s, z
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@ -225,174 +185,6 @@ def unstack_velocity_modes(modes: np.ndarray, ny: int, nx: int, n_modes: int = 6
|
||||
return ux_list, uy_list
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Aligned field data loader (new format: fields_aligned.npz + phase_plan.json)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def load_aligned_fields(scene_name: str) -> dict:
|
||||
"""Load fields_aligned.npz + telemetry indexed by phase_plan step_indices.
|
||||
|
||||
Transposes fields from (NX, NY) to (NY, NX) to match legacy convention
|
||||
so that ravel() produces (y*NX + x) ordering for POD/CCD.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
scene_name : str — scene name (e.g. 'target_cylinder_1.0L', 'illusion_1.0L')
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict with:
|
||||
ux, uy : (N_snap, NY, NX) field snapshots
|
||||
forces : (N_snap, n_force) or None
|
||||
actions : (N_snap, 3) or None
|
||||
sensors : (N_snap, 6) or None
|
||||
meta : dict with gate, CV_T, f_dom, N_raw_per_cycle
|
||||
step_indices : list of int — matching phase_plan
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
|
||||
from CCD_analysis.configs import DATA_DIR, SCENES
|
||||
|
||||
if scene_name not in SCENES:
|
||||
raise KeyError(f"Unknown scene: {scene_name}")
|
||||
|
||||
cfg = SCENES[scene_name]
|
||||
scene_id = cfg["scene_id"]
|
||||
data_dir = os.path.join(DATA_DIR, scene_id, scene_name)
|
||||
|
||||
# -- fields_aligned.npz --
|
||||
fa_path = os.path.join(data_dir, "fields_aligned.npz")
|
||||
if not os.path.isfile(fa_path):
|
||||
raise FileNotFoundError(f"{fa_path} not found")
|
||||
|
||||
fd = np.load(fa_path)
|
||||
ux_raw = fd["ux"] # (N, NX, NY)
|
||||
uy_raw = fd["uy"]
|
||||
fd.close()
|
||||
|
||||
N, NX, NY = ux_raw.shape
|
||||
# Transpose to (N, NY, NX) matching legacy convention
|
||||
ux = np.ascontiguousarray(ux_raw.transpose(0, 2, 1))
|
||||
uy = np.ascontiguousarray(uy_raw.transpose(0, 2, 1))
|
||||
|
||||
# -- phase_plan.json --
|
||||
plan_path = os.path.join(DATA_DIR, "resampled", scene_name, "phase_plan.json")
|
||||
if not os.path.isfile(plan_path):
|
||||
raise FileNotFoundError(f"{plan_path} not found")
|
||||
|
||||
with open(plan_path) as f:
|
||||
plan = json.load(f)
|
||||
|
||||
step_indices = list(plan["step_indices"])
|
||||
assert len(step_indices) == N, (
|
||||
f"{scene_name}: step_indices has {len(step_indices)} entries but "
|
||||
f"fields has {N} snapshots"
|
||||
)
|
||||
|
||||
# -- telemetry (controlled.npz or sensors.npz) --
|
||||
tele_path = None
|
||||
for p in [os.path.join(data_dir, "controlled.npz"), os.path.join(data_dir, "sensors.npz")]:
|
||||
if os.path.isfile(p):
|
||||
tele_path = p
|
||||
break
|
||||
if tele_path is None:
|
||||
raise FileNotFoundError(f"No telemetry (*.npz) found in {data_dir}")
|
||||
|
||||
td = np.load(tele_path)
|
||||
|
||||
result = {
|
||||
"ux": ux,
|
||||
"uy": uy,
|
||||
"forces": None,
|
||||
"actions": None,
|
||||
"sensors": None,
|
||||
"meta": {
|
||||
"scene": scene_name,
|
||||
"scene_id": scene_id,
|
||||
"gate": plan.get("gate", "unknown"),
|
||||
"CV_T": plan.get("CV_T"),
|
||||
"f_dom": plan.get("f_dom"),
|
||||
"N_raw_per_cycle": plan.get("N_raw_per_cycle"),
|
||||
"rho_interp": plan.get("rho_interp"),
|
||||
"sample_interval": cfg.get("sample_interval"),
|
||||
},
|
||||
"step_indices": step_indices,
|
||||
}
|
||||
|
||||
for key in ["forces", "actions", "sensors"]:
|
||||
if key in td:
|
||||
full = td[key]
|
||||
result[key] = full[step_indices] # (N_snap, n_channels)
|
||||
|
||||
td.close()
|
||||
return result
|
||||
|
||||
|
||||
def make_force_obs(forces: np.ndarray, scene_name: str, mode: str = "fy") -> np.ndarray:
|
||||
"""Construct force observable from raw forces.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
forces : (N, n_channels) ndarray
|
||||
target_cylinder: (N, 2) = [Fx, Fy]
|
||||
illusion/pinball: (N, 6) = [Fx1,Fy1,Fx2,Fy2,Fx3,Fy3]
|
||||
scene_name : str — used to detect target_cylinder vs illusion
|
||||
mode : str
|
||||
'fy' (primary) -> SigmaFy (1 channel)
|
||||
'fx' (secondary) -> SigmaFx (1 channel)
|
||||
'joint' (supplementary) -> [SigmaFx; SigmaFy] (2 channels)
|
||||
|
||||
Returns
|
||||
-------
|
||||
obs : (C, N) ndarray
|
||||
"""
|
||||
if "target_cylinder" in scene_name:
|
||||
fx = forces[:, 0]
|
||||
fy = forces[:, 1]
|
||||
else:
|
||||
fx = forces[:, 0] + forces[:, 2] + forces[:, 4]
|
||||
fy = forces[:, 1] + forces[:, 3] + forces[:, 5]
|
||||
|
||||
N = len(fx)
|
||||
if mode == "fy":
|
||||
return fy.reshape(1, N)
|
||||
elif mode == "fx":
|
||||
return fx.reshape(1, N)
|
||||
elif mode == "joint":
|
||||
return np.vstack([fx, fy])
|
||||
else:
|
||||
raise ValueError(f"Unknown force observable mode: {mode}")
|
||||
|
||||
|
||||
def build_field_matrix(ux: np.ndarray, uy: np.ndarray) -> np.ndarray:
|
||||
"""Stack velocity field snapshots into a snapshot matrix.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
ux, uy : (N, NY, NX) ndarray — field snapshots in legacy (NY, NX) order.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Q : (2 * NX * NY, N) ndarray — snapshot matrix for POD.
|
||||
"""
|
||||
N = ux.shape[0]
|
||||
q_list = []
|
||||
for s in range(N):
|
||||
q_list.append(np.concatenate([ux[s].ravel(), uy[s].ravel()]))
|
||||
return np.column_stack(q_list).astype(np.float64)
|
||||
|
||||
|
||||
def project_into_basis(ux: np.ndarray, uy: np.ndarray,
|
||||
modes: np.ndarray, mean_f: np.ndarray) -> np.ndarray:
|
||||
"""Project velocity fields onto a POD basis.
|
||||
|
||||
Returns POD coefficients array of shape (r, N).
|
||||
"""
|
||||
Q = build_field_matrix(ux, uy)
|
||||
return modes.T @ (Q - mean_f[:, None]).astype(np.float64)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Harmonics analysis for illusion
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@ -1,83 +0,0 @@
|
||||
# OID Analysis: Final Conclusions
|
||||
## (Phase 2 completed 2026-06-15)
|
||||
|
||||
---
|
||||
|
||||
## Six Questions Answered
|
||||
|
||||
### Q1: Steady cloak -- which correction structures determine suppression/restoration?
|
||||
|
||||
**Answer**: The force-OID and suppression-OID mode 1 overlap at 0.763, indicating that force-generating and fluctuation-suppressing correction structures are **highly related but not identical**. The steady cloak achieves 99.43% full-field RMS reduction and 38.5% recirculation area collapse. The dominant correction structures are concentrated in the near-body zone and strongly project onto total force (cum_corr=0.88 in one mode). The recirculation length barely changes (3.2% collapse) while area drops 38.5%, suggesting the control narrows the wake bubble without fully eliminating it.
|
||||
|
||||
- **POD rank**: r=10 (99.97% energy in 5 modes). **Rank sensitivity**: 1.000.
|
||||
- **Metric**: RMS reduction, Lr/Ar collapse (NOT time-series R2).
|
||||
|
||||
### Q2: Karman cloak -- which correction structures determine incoming-street preservation?
|
||||
|
||||
**Answer**: Force-OID (S=[0.966, 0.724]) distributes force correlation across two modes (cum_corr 0.572, 1.0). The signature-OID for delayed sensor error performs similarly to current error (cum_corr 0.967 vs 0.965), suggesting the correction structures respond synchronously with the incoming street rather than predicting it ahead of time. The OID coordinate for force prediction achieves R2=0.750 (vs POD's 0.418), a clear OID advantage. However, OID-only captures only 22.5% of action variance (vs obs->act at 95.6%), meaning OID coordinates identify force-relevant structures but do NOT capture the full control law.
|
||||
|
||||
- **Delay**: tau_c=25 steps. **POD rank**: r=10. **Rank sensitivity**: 1.000.
|
||||
- **Status**: Preliminary. Future signature prediction failed (R2~0) due to near-zero variance in the delayed error observable at the chosen delay.
|
||||
|
||||
### Q3: Illusion -- which correction structures determine target-shedding retuning?
|
||||
|
||||
**Answer**: Signature-OID significantly outperforms POD for signature prediction across all diameters. The strongest effect is at 0.75L (Sig-OID R2=0.661 vs POD -0.034). Performance degrades at larger diameters (1.5L: Sig-OID R2=0.315 vs POD 0.060). Force-OID also strongly outperforms POD for force prediction. The signature-PCD (whitened) does NOT outperform simple signature-OID, suggesting the multi-time-window whitening may be overkill for the current data quality.
|
||||
|
||||
- **POD rank**: r=10. **Rank sensitivity**: 1.000.
|
||||
- **Delay**: tau_c varies by SI (0.75L:~50, 1.0L:~33, 1.5L:~25 steps).
|
||||
|
||||
### Q4: Force-relevant vs signature-relevant structures -- same or different?
|
||||
|
||||
**Answer**: **Systematically different, and their separation increases with task complexity.**
|
||||
|
||||
| Scene | Cosine similarity | Temperature |
|
||||
|-------|-----------------|-------------|
|
||||
| steady_cloak | +0.763 | Force and suppression are highly related |
|
||||
| karman_re100 | -0.034 | Nearly orthogonal |
|
||||
| illusion_0.75L | -0.082 | Near-orthogonal |
|
||||
| illusion_1.0L | -0.495 | Moderate divergence |
|
||||
| illusion_1.5L | -0.932 | Strong divergence |
|
||||
|
||||
This monotonic pattern from positive (steady) through zero (Karman) to increasingly negative (illusion with growing diameter) is the most striking result of this analysis. It suggests:
|
||||
|
||||
- **Steady cloak**: suppressing force IS the suppression mechanism (same structures).
|
||||
- **Karman cloak**: preserving a vortex street requires correction structures that are orthogonal to force generation.
|
||||
- **Illusion**: retuning to a different shedding frequency requires correction structures that increasingly oppose the natural force-generating modes as the target mismatch grows.
|
||||
|
||||
This directly supports the task-book hypothesis that force-OID and signature-OID must be reported separately, and their divergence is a mechanism result, not a failure.
|
||||
|
||||
### Q5: Scene commonality -- shared structures?
|
||||
|
||||
**Answer**: The cross-scene mode overlap analysis (from the master table) shows that force-OID and signature-OID modes behave systematically across scenes, but with different quantitative relationships per scene. The common pattern is that **correction-field POD captures 98-99.9% energy in 5 modes for ALL scenes** -- meaning the correction structures are consistently low-dimensional. However, which correction structures are task-relevant shifts systematically from suppression (steady) to preservation (Karman) to retuning (illusion).
|
||||
|
||||
### Q6: Is OID better than POD?
|
||||
|
||||
**Answer**: **Yes, for all scenes where comparison is meaningful.**
|
||||
|
||||
| Scene | Task | OID R2 (m=2) | POD R2 (m=2) | OID wins? |
|
||||
|-------|------|-------------|-------------|-----------|
|
||||
| karman | Force prediction | 0.750 | 0.418 | **YES** |
|
||||
| illusion_0.75L | Force prediction | 0.435 | -2.426 | **YES** |
|
||||
| illusion_0.75L | Sig prediction | 0.661 | -0.034 | **YES** |
|
||||
| illusion_1.0L | Force prediction | 0.671 | -0.237 | **YES** |
|
||||
| illusion_1.0L | Sig prediction | 0.586 | -0.160 | **YES** |
|
||||
| illusion_1.5L | Force prediction | 0.640 | 0.264 | **YES** |
|
||||
| illusion_1.5L | Sig prediction | 0.315 | 0.060 | **YES** |
|
||||
|
||||
OID consistently provides positive R2 where POD gives negative or near-zero values. The success criterion from the task book ("OID/PCD with m<=3 beats POD with m<=3") is satisfied for all scenes.
|
||||
|
||||
---
|
||||
|
||||
## Additional Conclusions
|
||||
|
||||
### Control law completeness
|
||||
The white-box analysis shows that OID coordinates alone capture only 22.5% of the Karman control law (vs 95.6% for raw sensor observations). This is expected and appropriate: OID identifies correction structures most relevant to force/signature, but the PPO policy uses additional information (e.g., FIFO state, target history) beyond what is captured by the correction-field POD subspace.
|
||||
|
||||
### Illusion q_blk importance
|
||||
The requirement to separately collect illusion-position q_blk was validated: the geometry differences (front_x 19 vs 30, sensor_x 30 vs 40) would have contaminated Delta_q_ctl with position mismatches. Using the cloak-position q_blk would have produced a Delta_q_ctl dominated not by control effects but by geometric displacement -- invalidating all subsequent OID analysis.
|
||||
|
||||
### Future work recommendations
|
||||
1. **Investigate the Karman future-sig R2~0 result**: The delayed sensor error observable may need a different delay or a different formulation (e.g., phase-error instead of direct error).
|
||||
2. **Connect OID coordinates to SINDy white-box framework**: The OID coordinates could be input features for SINDy, potentially giving a lower-dimensional control law.
|
||||
3. **Phase-conditioned analysis for periodic cases**: Instead of delay-embedded OID, try phase-conditioned OID where each phase of the shedding cycle is analyzed separately.
|
||||
4. **Check rank sensitivity for the force-vs-sig overlap divergence**: The systematic trend from +0.763 to -0.932 is compelling but needs verification across multiple POD ranks.
|
||||
@ -1,227 +0,0 @@
|
||||
# OID Analysis Knowledge Base
|
||||
|
||||
## Document role
|
||||
|
||||
Same as sibling projects (SR_analysis, CCD_analysis): confirmed facts, hard rules, critical caveats, and current results. Does NOT contain execution plans (see OID_notes.md).
|
||||
|
||||
## Companion documents
|
||||
|
||||
- `OID_notes.md` -- execution plan, task tracking, current priority
|
||||
- `analysis_notes.md` -- project-wide analysis task book
|
||||
- `analysis_knowledge.md` -- project-wide analysis knowledge
|
||||
|
||||
---
|
||||
|
||||
## CRITICAL RULES
|
||||
|
||||
### Rule 1: OID default object is Delta-q_ctl, NOT raw full field
|
||||
|
||||
OID operates on:
|
||||
|
||||
\[
|
||||
\Delta q_{ctl} = q_{ctl} - q_{blk}
|
||||
\]
|
||||
|
||||
Raw full-field OID is NOT the default. The only exception is when explicitly comparing correction-field vs raw-field POD performance.
|
||||
|
||||
### Rule 2: force-OID and signature-OID must be reported SEPARATELY
|
||||
|
||||
They should NOT be merged into a single observable. If they differ, this is a potential mechanism result, not a failure.
|
||||
|
||||
### Rule 3: Steady cloak is NOT a periodic future-signature problem
|
||||
|
||||
Its primary questions are:
|
||||
- Which correction structures suppress natural shedding?
|
||||
- Which structures restore the mean wake?
|
||||
- Which structures correlate with recirculation collapse?
|
||||
|
||||
Do NOT force it into the periodic signature-OID template.
|
||||
|
||||
### Rule 4: Three-field decomposition is MANDATORY before OID
|
||||
|
||||
Every scene requires:
|
||||
- `q_in`: incident reference field
|
||||
- `q_blk`: fixed pinball field (zero rotation)
|
||||
- `q_ctl`: controlled pinball field (DRL or open-loop)
|
||||
|
||||
And the derived fields:
|
||||
- `Delta_q_blk = q_blk - q_in`: passive blockage
|
||||
- `Delta_q_ctl = q_ctl - q_blk`: active correction
|
||||
|
||||
### Rule 5: No field cropping
|
||||
|
||||
All fields must be full 1280x512 resolution. ROI masking is done only at the analysis stage (POD) using spatial masks, not by saving cropped fields.
|
||||
|
||||
### Rule 6: Model naming conventions (inherited from SR/CCD)
|
||||
|
||||
- "2U" in model name means S_DIM=14 (2 extra target force observations). NOT 2x velocity. u0 is ALWAYS 0.01.
|
||||
- "1U" means S_DIM=12. NOT 1x velocity.
|
||||
- nu=0.004 unless Vis suffix in model name (e.g. "02Vis" = nu*0.02 = 0.00008).
|
||||
- SAMPLE_INTERVAL per diameter: 0.75L=400, 1.0L=600, 1.5L=800 (no S suffix = default 800).
|
||||
|
||||
### Rule 7: action_bias != preset_action
|
||||
|
||||
- `action_bias` (e.g. [0, -2, 2]): constant added to scaled DRL action: `omega = (action*scale + bias) * U0`
|
||||
- `preset_action` (e.g. [0, 0, 0, 0, -1*U0, 1*U0]): fixed Omega array to warm up FIFO before inference
|
||||
These are DIFFERENT values and purposes.
|
||||
|
||||
### Rule 8: GPU state contamination prevention
|
||||
|
||||
- Running PPO inference after other CFD on the same GPU degrades similarity
|
||||
- Minimum 4NX/U0 steps between different PPO scenes on the same GPU
|
||||
- Prefer separate GPUs for Karman (device 1) and Illusion (device 3) scenes
|
||||
- Fresh Context per collection (don't reuse FlowField across scenes)
|
||||
|
||||
### Rule 9: context.push()/pop() around every run() call
|
||||
|
||||
Both SR and CCD do this. Action smoother is internal to run(). Pattern:
|
||||
|
||||
```python
|
||||
ff.context.push()
|
||||
ff.run(SAMPLE_INTERVAL, temp)
|
||||
ff.context.pop()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Current Results (Complete Phase 2, 2026-06-22)
|
||||
|
||||
### steady_cloak
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Correction-field POD energy (5 modes) | 99.97% |
|
||||
| Rank sensitivity | cosine sim = 1.000 |
|
||||
| N snapshots | 100 (POD) |
|
||||
| Force-OID mode 1 singular value | 0.544 |
|
||||
| Force-OID mode 2 singular value | 0.074 |
|
||||
| Force-OID cum_corr (1 mode) | 0.880 |
|
||||
| Force-OID vs Suppression-OID mode 1 overlap | 0.763 |
|
||||
| **Re-analysis: Full-field RMS reduction** | **99.43%** |
|
||||
| **Re-analysis: Recirculation area collapse** | **38.5%** (Ar_ctl=1234 vs Ar_blk=2008) |
|
||||
| **Re-analysis: Recirculation length collapse** | **3.2%** (Lr_ctl=269 vs Lr_blk=278) |
|
||||
| **Re-analysis: Fy RMS reduction** | **83.3%** |
|
||||
| Status | **Re-analysis complete**. Strong suppression (99.4% RMS reduction). Recirculation area shrinks but length barely changes -- suggests the bubble becomes narrower but not shorter. Force-OID and suppression structures are related but distinct (0.763 overlap). |
|
||||
|
||||
### karman_re100
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Correction-field POD energy (5 modes) | 99.9% |
|
||||
| Rank sensitivity | cosine sim = 1.000 |
|
||||
| N snapshots | 500 |
|
||||
| Phase 1-2 | Complete |
|
||||
| **Phase 3: Force-OID** | S=[0.966, 0.724]; cum_corr(1)=0.572, cum_corr(2)=1.0 |
|
||||
| **Phase 4a: Signature-OID (current)** | S=[1.235, 0.582, 0.383]; cum_corr(3)=0.965 |
|
||||
| **Phase 4a: Signature-OID (delayed)** | S=[1.253, 0.670, 0.515]; cum_corr(3)=0.967 |
|
||||
| **Phase 4b: Signature-PCD** | S=[0.942, 0.817, 0.595]; cum_corr(3)=0.485 (whitened) |
|
||||
| **Phase 6: Force prediction** | OID(m=2) R2=0.750 vs POD(m=2) R2=0.418 -- OID wins |
|
||||
| **Phase 6: Future sig prediction** | All R2 ~ 0 (delayed sensor error near-zero variance) |
|
||||
| **Phase 7: Whitebox** | obs->act R2=0.956, OID->act R2=0.225, OID+force->act R2=0.233 |
|
||||
| Status | **Phase 3-7 complete**. Strong OID advantage for force prediction. OID-only does not capture full control law. |
|
||||
|
||||
### illusion scenes (3 diameters)
|
||||
|
||||
| Metric | 0.75L | 1.0L | 1.5L |
|
||||
|--------|-------|------|------|
|
||||
| Correction-field POD energy (5 modes) | 99.93% | 99.91% | 97.9% |
|
||||
| Rank sensitivity | 1.000 | 1.000 | 1.000 |
|
||||
| N snapshots (POD) | 100 | 100 | 100 |
|
||||
| Force-OID S[0] | 0.699 | 1.447 | 0.981 |
|
||||
| Force-OID S[1] | 0.671 | 0.832 | 0.407 |
|
||||
| Signature-OID (delayed) S[0] | 2.880 | 3.402 | 2.848 |
|
||||
| Signature-OID (delayed) S[1] | 2.134 | 2.321 | 1.893 |
|
||||
| Force prediction: OID(m=2) R2 | **0.435** | **0.671** | **0.640** |
|
||||
| Force prediction: POD(m=2) R2 | -2.426 | -0.237 | 0.264 |
|
||||
| Signature prediction: Sig-OID(m=2) R2 | **0.661** | **0.586** | **0.315** |
|
||||
| Signature prediction: POD(m=2) R2 | -0.034 | -0.160 | 0.060 |
|
||||
| Force-vs-Signature OID mode 1 overlap | **-0.082** | **-0.495** | **-0.932** |
|
||||
|
||||
**Key observation**: As target diameter increases, force-OID and signature-OID modes systematically diverge (overlap: -0.082 to -0.932). Supports hypothesis that force-relevant and signature-relevant structures are distinct.
|
||||
|
||||
### Cross-scene: Force-OID vs Signature-OID mode overlap
|
||||
|
||||
| Scene | Cosine similarity | Interpretation |
|
||||
|-------|-----------------|----------------|
|
||||
| steady_cloak | +0.763 | Force and suppression structures highly related |
|
||||
| karman_re100 | -0.034 | Nearly orthogonal |
|
||||
| illusion_0.75L | -0.082 | Near-orthogonal (small target) |
|
||||
| illusion_1.0L | -0.495 | Moderately separated |
|
||||
| illusion_1.5L | -0.932 | Strongly separated |
|
||||
|
||||
The monotonic trend from + (steady) through 0 (Karman) to -- (illusion, growing with diameter) suggests a **systematic mechanism**: force and signature involve increasingly different correction structures as the task transitions from suppression to preservation to retuning.
|
||||
|
||||
### Robustness: POD Rank Sensitivity
|
||||
|
||||
| Scene | r=6 | r=8 | r=10 | r=12 | r=16 | std | Verdict |
|
||||
|-------|-----|-----|------|------|------|-----|---------|
|
||||
| steady_cloak | -0.49 | -0.78 | -0.76 | -0.73 | -0.68 | 0.10 | Sign consistent |
|
||||
| karman_re100 | 0.14 | -0.04 | -0.03 | 0.01 | -0.05 | **0.07** | **Stable (near zero)** |
|
||||
| illusion_0.75L | -0.20 | 0.08 | -0.08 | -0.50 | 0.12 | **0.26** | **Unstable -- needs more data** |
|
||||
| illusion_1.0L | -0.44 | -0.47 | -0.50 | -0.44 | -0.42 | **0.03** | **Very stable** |
|
||||
| illusion_1.5L | -0.97 | -0.96 | -0.93 | -0.93 | -0.91 | **0.02** | **Very stable** |
|
||||
|
||||
### Robustness: Karman tau_c Sensitivity
|
||||
|
||||
| tau_c | 0 | 10 | 15 | 20 | 25 | 30 | 40 | 60 |
|
||||
|-------|---|---|---|----|----|----|----|-----|
|
||||
| Overlap | 0.31 | 0.12 | 0.12 | 0.11 | 0.14 | 0.14 | 0.15 | 0.19 |
|
||||
| Sig R2 | 0.28 | 0.31 | 0.32 | 0.33 | 0.33 | 0.31 | 0.30 | 0.26 |
|
||||
|
||||
Overlap stays near-orthogonal for ALL delays. The Karman force-sig separation is NOT a delay-misalignment artifact.
|
||||
|
||||
### White-box Chain (Karman, from Phase 7)
|
||||
|
||||
| Model | Action R2 | Meaning |
|
||||
|-------|:--------:|---------|
|
||||
| obs -> act | **0.956** | PPO baseline uses raw sensor observations |
|
||||
| force-OID coord -> act | **0.225** | OID finds observable-relevant, not action-relevant structures |
|
||||
| OID + force -> act | 0.233 | Adding force doesn't help |
|
||||
|
||||
### Figures Generated
|
||||
|
||||
All figures in `src/OID_analysis/data/derived/figures/`:
|
||||
|
||||
| Fig | File | Description |
|
||||
|-----|------|-------------|
|
||||
| 1 | `fig1_force_sig_overlap.png` | **Flagship**: force-OID vs sig-OID overlap across 5 scenes (signed + absolute) |
|
||||
| 2 | `fig2_rank_sensitivity.png` | POD rank sensitivity (r=6,8,10,12,16), 5 subplots |
|
||||
| 3 | `fig3_oid_vs_pod_r2.png` | OID vs POD prediction R2 for force and signature |
|
||||
| 4 | `fig4_tauc_sensitivity.png` | Karman tau_c sensitivity sweep (10 delays) |
|
||||
| 5 | `fig5_pod_energy.png` | Correction-field POD energy capture |
|
||||
| 6 | `fig6_steady_metrics.png` | Steady cloak suppression metrics |
|
||||
| 7 | `fig7_whitebox_summary.png` | White-box chain: obs->z->act |
|
||||
|
||||
---
|
||||
|
||||
## Known Bugs (All FIXED)
|
||||
|
||||
| Bug | Description | Fix |
|
||||
|-----|-------------|-----|
|
||||
| Hardcoded paths | Collection scripts wrote to hardcoded dirs instead of data_dir_for_scene() | Switched to data_dir_for_scene() |
|
||||
| Karman blk overwrite | karman_blk.py wrote to karman_re100/ directory, corrupting controlled data | Fixed path to karman_blk/ |
|
||||
| Field size mismatch | PPO replay used ROI cropping while baselines were full field | Removed cropping, auto-align in compute_delta |
|
||||
| Wrong POD loading | All Phase 3-7 scripts expected .npy but save() created .npy.npz | Changed all pod_fp to .npy.npz |
|
||||
| Steady cloak negative R^2 | Used time-series R^2 for near-steady signal | Expert: replace with RMS reduction / Lr collapse metrics |
|
||||
|
||||
---
|
||||
|
||||
## OID Discipline (inherited from task book)
|
||||
|
||||
1. OID identifies directions within chosen POD subspace -- it CANNOT recover structures truncated by POD rank reduction.
|
||||
2. All OID results must specify: chosen POD basis rank, rank sensitivity, delay definition (if applicable).
|
||||
3. OID is a structural diagnosis tool, NOT an automatic mechanism generator.
|
||||
4. OID results must be interpreted within the physical layers: correction field -> body-connected near wake -> downstream descendant structures.
|
||||
5. If force-OID and signature-OID differ, first check rank sensitivity and data length before discussing mechanism.
|
||||
|
||||
---
|
||||
|
||||
## Key References for OID Interpretation
|
||||
|
||||
| Reference | Role in project |
|
||||
|-----------|----------------|
|
||||
| [Sch12] Schlegel et al. (OID) | Observable-Inferred Decomposition framework. Provides cross-covariance SVD for identifying observable-relevant structures in POD subspace. |
|
||||
| [Lyu23] Lyu et al. (CCD/PCD) | Canonical Correlation Decomposition with delay embedding. Used for signature-PCD with whitening. |
|
||||
| [Kan17b] Kantsios et al. (wake-to-force) | Body-connected near wake as primary force determinant. Supports force-OID window choice. |
|
||||
| [Che19, Che21b] Chen-Liu (vorticity dynamics) | Rotation first rewrites near-body source terms. Supports `act -> near-body correction` causal ordering. |
|
||||
| [Tad10] Tadmor et al. (observability) | Low-dimensional state for flow control. Supports OID-to-whitebox chain. |
|
||||
@ -1,127 +0,0 @@
|
||||
# OID Analysis Notes
|
||||
|
||||
## Document role
|
||||
|
||||
Execution plan, task tracking, and current priority for the OID analysis line.
|
||||
Does NOT repeat confirmed facts or hard rules (see OID_knowledge.md).
|
||||
|
||||
---
|
||||
|
||||
## Overall Status: COMPLETE (Phase 2 delivered 2026-06-22)
|
||||
|
||||
All planned work is done. Repository is ready for handover.
|
||||
|
||||
### What was built
|
||||
|
||||
```
|
||||
src/OID_analysis/
|
||||
configs.py # Unified scene config (10+ scenes, mirroring SR/CCD)
|
||||
OID_knowledge.md # Confirmed facts, rules, results
|
||||
OID_notes.md # This file -- task tracking
|
||||
Final_Conclusions.md # Six-question conclusions
|
||||
Sch12.md # OID reference paper
|
||||
utils/
|
||||
cfd_interface.py # Re-exports CCD's proven CFD interface
|
||||
analysis.py # CPU-only: POD, OID, PCD, zone statistics, comparison
|
||||
scripts/ (11 scripts)
|
||||
collect_empty_channel.py # q_in for steady/illusion
|
||||
collect_pinball_baseline.py # q_blk for steady/illusion (cloak positions)
|
||||
collect_disturbance_only.py # q_in for Karman
|
||||
collect_karman_blk.py # q_blk for Karman
|
||||
collect_controlled.py # q_ctl for PPO scenes (Karman + Illusion)
|
||||
collect_steady_cloak.py # q_ctl for steady cloak (open-loop)
|
||||
collect_target_cylinder.py # q_tar for illusion
|
||||
collect_illusion_qblk.py # q_blk for illusion-specific geometry
|
||||
collect_fields_replay.py # Replay PPO with field extraction from CCD
|
||||
collect_baseline_forces.py # Quick force collection (auxiliary)
|
||||
collect_all_data.py # Batch data collection runner
|
||||
compute_delta_fields.py # Phase 0: Delta_q + zone stats (draft)
|
||||
replay_full_fields.py # Full-field replay (no cropping)
|
||||
replay_verify.py # Verify replay fidelity
|
||||
analysis/ (13 scripts)
|
||||
phase1_correction_pod.py # Correction-field POD + rank sensitivity
|
||||
phase2_build_observables.py # Scene-specific Y matrices
|
||||
phase3_force_oid.py # Force-OID
|
||||
phase4a_signature_oid.py # Signature-OID minimal
|
||||
phase4b_signature_pcd.py # Signature-PCD whitened
|
||||
phase5_steady_oid.py # Steady cloak suppression-OID
|
||||
phase6_comparison.py # POD vs OID vs PCD
|
||||
phase7_whitebox.py # White-box control chain
|
||||
run_full_analysis.py # Batch pipeline runner
|
||||
robustness_analysis.py # Rank/window/tau_c/zone robustness
|
||||
save_robustness.py # Save robustness data
|
||||
steady_reanalysis.py # Steady cloak suppression metrics
|
||||
compile_master_table.py # Cross-scene comparison table
|
||||
make_figures.py # Generate all 7 figures
|
||||
data/
|
||||
configs/legacy/ # Legacy CFD config JSONs (symlinked)
|
||||
steady_cloak/ # empty_channel, pinball_baseline, steady_cloak, pinball_baseline_illusion
|
||||
karman_cloak/ # disturbance_only, karman_blk, karman_re100
|
||||
illusion/ # illusion_0.75L, illusion_1.0L, illusion_1.5L
|
||||
target_cylinder/ # target_cylinder_0.75L, 1.0L, 1.5L
|
||||
derived/ # POD, OID, comparison, observables, robustness, figures, whitebox
|
||||
docs/
|
||||
OID_analysis_results.md # Full project report (292 lines, 7 figures)
|
||||
```
|
||||
|
||||
### Data collected (all complete)
|
||||
|
||||
| Dataset | Snapshots | GPU | Source |
|
||||
|---------|-----------|-----|--------|
|
||||
| empty_channel (q_in) | 100 | 3 | collected |
|
||||
| pinball_baseline (cloak q_blk) | 200 | 3 | collected |
|
||||
| steady_cloak (q_ctl, open-loop) | 500 | 3 | collected |
|
||||
| disturbance_only (Karman q_in) | 500 | 1 | collected |
|
||||
| karman_blk (Karman q_blk) | 500 | 1 | collected |
|
||||
| karman_re100 (Karman q_ctl PPO) | 500 | 1 | CCD replay |
|
||||
| illusion_0.75L (q_ctl PPO) | 500 | 3 | CCD replay |
|
||||
| illusion_1.0L (q_ctl PPO) | 500 | 3 | CCD replay |
|
||||
| illusion_1.5L (q_ctl PPO) | 500 | 3 | CCD replay |
|
||||
| illusion q_blk (separate geometry) | 500 | 3 | collected |
|
||||
| target_cylinder 0.75/1.0/1.5L | 96 each | via CCD | symlinked |
|
||||
|
||||
### Key results
|
||||
|
||||
1. **Force-vs-Signature systematic separation** (flagship): steady(+0.763) -> Karman(-0.034) -> ill0.75(-0.082) -> ill1.0(-0.495) -> ill1.5(-0.932)
|
||||
2. **OID beats POD** for both force and signature prediction in ALL scenes
|
||||
3. **Robust**: Karman and illusion 1.0/1.5L rank-stable; Karman tau_c sweep confirms separation is not an artifact
|
||||
4. **OID != control state**: force-OID captures only 22.5% of action variance (expected)
|
||||
|
||||
### Phase-by-phase completion
|
||||
|
||||
| Phase | Description | Status |
|
||||
|-------|-------------|--------|
|
||||
| 0 | Three-field decomposition + data collection | COMPLETE |
|
||||
| 1 | Correction-field POD + rank sensitivity | COMPLETE (all 5 scenes) |
|
||||
| 2 | Scene-specific observable construction | COMPLETE (all 5 scenes) |
|
||||
| 3 | Force-OID | COMPLETE (all 5 scenes) |
|
||||
| 4a | Signature-OID (minimal) | COMPLETE (Karman + Illusion 3x) |
|
||||
| 4b | Signature-PCD (whitened) | COMPLETE (Karman + Illusion 3x) |
|
||||
| 5 | Steady cloak suppression-OID | COMPLETE |
|
||||
| 6 | POD vs OID vs PCD comparison | COMPLETE (all scenes with R2 tables) |
|
||||
| 7 | White-box control chain | COMPLETE (Karman, partial) |
|
||||
| Robustness | Rank/tau_c/window/zone | COMPLETE |
|
||||
| Figures | 7 figures generated | COMPLETE |
|
||||
| Report | docs/OID_analysis_results.md | COMPLETE (292 lines) |
|
||||
|
||||
### Open items (for next agent)
|
||||
|
||||
1. **Illusion 0.75L rank instability** (std=0.26) -- needs investigation with longer time series
|
||||
2. **Karman future-signal R2~0** -- may need phase-error formulation instead of direct delay
|
||||
3. **OID mode-to-field mapping** -- OID spatial modes are computed but not visualized as flow fields
|
||||
4. **causal-PCD for action coordinates** -- needed for obs->z->act whitebox chain
|
||||
5. **Cross-validation** over multiple independent rollouts
|
||||
|
||||
### Environment
|
||||
|
||||
- CFD/collection: `conda run -n pycuda_3_10` (requires GPU)
|
||||
- Analysis: `conda run -n sr_env python3` (CPU only, has sklearn)
|
||||
- GPUs: device 1 (Karman), device 3 (steady/illusion)
|
||||
|
||||
### References to read before starting
|
||||
|
||||
1. `docs/OID_analysis_results.md` -- full project report
|
||||
2. `src/OID_analysis/OID_knowledge.md` -- rules, results, bugs
|
||||
3. `src/OID_analysis/configs.py` -- scene definitions
|
||||
4. `docs/ccd_correction_field_report.md` -- sibling project for cross-reference
|
||||
5. `docs/SR_analysis_results.md` -- sibling project for cross-reference
|
||||
@ -1,170 +0,0 @@
|
||||
# OID_analysis: Observable-Inferred Decomposition for Fluidic Pinball Control
|
||||
|
||||
## What this directory does
|
||||
|
||||
This directory implements an **OID (Observable-Inferred Decomposition)** pipeline for analyzing which flow structures the DRL controller actually modulates in the fluidic pinball experiments. OID ranks modes by cross-correlation with a chosen observable (force, sensor error), rather than by fluctuation energy (POD).
|
||||
|
||||
**The three companion documents:**
|
||||
- `OID_knowledge.md` -- confirmed facts, hard rules, critical caveats, **current results**
|
||||
- `OID_notes.md` -- execution plan, task tracking, handover notes
|
||||
- This file (README.md) -- engineering entry point, how to run
|
||||
|
||||
**Critical reading order for new agent:**
|
||||
1. `docs/OID_analysis_results.md` -- full project report (start here)
|
||||
2. README.md (this file) -- understand scope and how to run
|
||||
3. `OID_knowledge.md` -- know the rules, the results, and pitfalls
|
||||
4. `OID_notes.md` -- understand what's left to do
|
||||
|
||||
---
|
||||
|
||||
## Current scope (as of 2026-06-22)
|
||||
|
||||
**All 5 scenes analyzed end-to-end:**
|
||||
|
||||
| Scene | Type | Source | Status |
|
||||
|-------|------|--------|--------|
|
||||
| steady_cloak | Open-loop constant rotation (rear=+-5.1U0) | Collected | Full Phases 1-7 |
|
||||
| karman_re100 | PPO (d1a3o12_re100) | Replayed from CCD | Full Phases 1-7 |
|
||||
| illusion_0.75L | PPO (d1a3o14_075L_2U_400S) | Replayed from CCD | Full Phases 1-7 |
|
||||
| illusion_1.0L | PPO (d1a3o14_1L_2U_600S) | Replayed from CCD | Full Phases 1-7 |
|
||||
| illusion_1.5L | PPO (d1a3o14_15L_2U) | Replayed from CCD | Full Phases 1-7 |
|
||||
|
||||
**Fixed scope notes (inherited from SR/CCD):**
|
||||
- All illusion models use **u0=0.01, nu=0.004** (standard defaults from config_flowfield.json)
|
||||
- "2U" in model name means **S_DIM=14** (2 extra target force observations), NOT 2x velocity
|
||||
- SAMPLE_INTERVAL depends on target diameter: 0.75L=400, 1.0L=600, 1.5L=800
|
||||
- Illusion q_blk was collected **separately** from cloak-position q_blk (different geometry)
|
||||
|
||||
---
|
||||
|
||||
## Directory structure
|
||||
|
||||
```
|
||||
src/OID_analysis/
|
||||
configs.py # Unified scene metadata (12 scene definitions)
|
||||
utils/
|
||||
cfd_interface.py # Re-exports CCD's proven CFD interface
|
||||
analysis.py # CPU-only: POD, OID, PCD, zone statistics, comparison
|
||||
scripts/
|
||||
collect_*.py # Data collection scripts (GPU, pycuda_3_10)
|
||||
replay_*.py # Field replay + verification
|
||||
compute_delta_fields.py # Phase 0 draft
|
||||
analysis/
|
||||
phase*.py # Analysis scripts Phases 1-7 (CPU, sr_env)
|
||||
robustness_analysis.py # Rank/tau_c/window/zone robustness
|
||||
steady_reanalysis.py # Steady cloak suppression metrics
|
||||
compile_master_table.py # Cross-scene comparison
|
||||
make_figures.py # Generate 7 figures
|
||||
run_full_analysis.py # Batch pipeline runner
|
||||
data/
|
||||
configs/legacy/ # Legacy CFD configs (symlinked)
|
||||
steady_cloak/ # Baseline + controlled fields
|
||||
karman_cloak/ # Baseline + controlled fields
|
||||
illusion/ # PPO controlled fields
|
||||
target_cylinder/ # Reference targets
|
||||
derived/ # All analysis results + figures
|
||||
OID_knowledge.md # Knowledge base
|
||||
OID_notes.md # Task tracking
|
||||
Final_Conclusions.md # Six-question conclusions
|
||||
Sch12.md # OID reference paper
|
||||
|
||||
docs/
|
||||
OID_analysis_results.md # Full project report (7 figures, 292 lines)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## How to run
|
||||
|
||||
All commands from repo root (`/home/frank14f/DynamisLab`).
|
||||
|
||||
### Data collection (GPU, pycuda_3_10)
|
||||
|
||||
```bash
|
||||
# Steady group:
|
||||
conda run -n pycuda_3_10 python src/OID_analysis/scripts/collect_empty_channel.py --device 3 --steps 200
|
||||
conda run -n pycuda_3_10 python src/OID_analysis/scripts/collect_pinball_baseline.py --device 3 --steps 500
|
||||
conda run -n pycuda_3_10 python src/OID_analysis/scripts/collect_steady_cloak.py --device 3 --steps 500 --omega-rear 5.1
|
||||
|
||||
# Karman group:
|
||||
conda run -n pycuda_3_10 python src/OID_analysis/scripts/collect_disturbance_only.py --device 1 --steps 500
|
||||
conda run -n pycuda_3_10 python src/OID_analysis/scripts/collect_karman_blk.py --device 1 --steps 500
|
||||
|
||||
# Illusion q_blk (important: separate geometry from cloak):
|
||||
conda run -n pycuda_3_10 python src/OID_analysis/scripts/collect_illusion_qblk.py --device 3 --steps 500
|
||||
|
||||
# PPO field replay (from CCD checkpoints):
|
||||
conda run -n pycuda_3_10 python src/OID_analysis/scripts/replay_full_fields.py --scene karman_re100 --device 1
|
||||
conda run -n pycuda_3_10 python src/OID_analysis/scripts/replay_full_fields.py --scene illusion_1.0L --device 3
|
||||
|
||||
# Target cylinders:
|
||||
conda run -n pycuda_3_10 python src/OID_analysis/scripts/collect_target_cylinder.py --diameter 1.0 --device 3 --steps 500
|
||||
```
|
||||
|
||||
### Analysis (CPU only, sr_env)
|
||||
|
||||
```bash
|
||||
# Full pipeline for one scene:
|
||||
cd /home/frank14f/DynamisLab
|
||||
PYTHONPATH="src:$PYTHONPATH" conda run -n sr_env python3 src/OID_analysis/analysis/run_full_analysis.py --scene karman_re100
|
||||
|
||||
# Robustness analysis:
|
||||
PYTHONPATH="src:$PYTHONPATH" conda run -n sr_env python3 src/OID_analysis/analysis/robustness_analysis.py
|
||||
|
||||
# Generate figures:
|
||||
PYTHONPATH="src:$PYTHONPATH" conda run -n pycuda_3_10 python3 src/OID_analysis/analysis/make_figures.py
|
||||
|
||||
# Cross-scene table:
|
||||
PYTHONPATH="src:$PYTHONPATH" conda run -n sr_env python3 src/OID_analysis/analysis/compile_master_table.py
|
||||
```
|
||||
|
||||
### Replay verification
|
||||
|
||||
```bash
|
||||
conda run -n pycuda_3_10 python src/OID_analysis/scripts/replay_verify.py --scene karman_re100 --device 1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Key results
|
||||
|
||||
| Finding | Evidence | Confidence |
|
||||
|---------|----------|------------|
|
||||
| Force-sig structures systematically separate across tasks | Monotonic trend: +0.763 -> -0.034 -> -0.082 -> -0.495 -> -0.932 | High (rank-stable for 4/5 scenes) |
|
||||
| OID beats POD for force prediction | OID R2=0.435-0.750 vs POD R2=-2.4 to 0.418 | High |
|
||||
| OID beats POD for signature prediction | OID R2=0.315-0.661 vs POD R2=-0.16 to 0.06 | High |
|
||||
| OID coordinates != control state | force-OID->act R2=0.225 vs obs->act R2=0.956 | Confirmed (expected design) |
|
||||
| Steady cloak suppresses 99.4% of RMS fluctuation | Full-field RMS reduction | High |
|
||||
|
||||
---
|
||||
|
||||
## Common pitfalls (read these before making changes)
|
||||
|
||||
1. **Always use model name as single source of truth.** "2U" = S_DIM=14, NOT 2x velocity. u0 is ALWAYS 0.01.
|
||||
2. **NEVER use nu_from_re() for illusion models.** Only valid for standard S_DIM=12 cases.
|
||||
3. **Distinguish action_bias from preset_action.** They are DIFFERENT. action_bias is for DRL scaling; preset_action is for FIFO warmup.
|
||||
4. **context.push()/pop() around every run() call.** Mandatory for multi-step loops.
|
||||
5. **Fresh GPU per PPO scene.** GPU state contamination degrades similarity. Minimum 4NX/U0 steps separation between scenes on the same GPU.
|
||||
6. **No field cropping.** All fields must be full 1280x512 resolution.
|
||||
7. **Three-field decomposition is MANDATORY.** OID operates on Delta-q_ctl = q_ctl - q_blk, NOT on raw q_ctl.
|
||||
8. **OID identifies directions within the chosen POD subspace.** It CANNOT recover structures truncated by POD rank reduction.
|
||||
|
||||
---
|
||||
|
||||
## Delivered files (non-data)
|
||||
|
||||
These files should be committed to the repository:
|
||||
|
||||
**Code (src/OID_analysis/):**
|
||||
- `configs.py`, `utils/cfd_interface.py`, `utils/analysis.py`
|
||||
- `scripts/` (11 collection + 3 replay scripts)
|
||||
- `analysis/` (7 phase scripts + robustness + figures + batch runner)
|
||||
- `OID_knowledge.md`, `OID_notes.md`, `Final_Conclusions.md`
|
||||
|
||||
**Report (docs/):**
|
||||
- `docs/OID_analysis_results.md`
|
||||
|
||||
**Large data files (NOT committed -- see .gitignore):**
|
||||
- All `.npz` in `data/`
|
||||
- All `.npy` in `data/derived/`
|
||||
- All `.png` in `data/derived/figures/`
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,186 +0,0 @@
|
||||
# OID_analysis/analysis/compile_master_table.py
|
||||
"""
|
||||
Compile master comparison table from all analysis results.
|
||||
Cross-scene comparison of force-OID, signature-OID, POD performance.
|
||||
|
||||
Usage:
|
||||
conda run -n sr_env python3 src/OID_analysis/analysis/compile_master_table.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
|
||||
_REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
|
||||
if _REPO not in sys.path:
|
||||
sys.path.insert(0, _REPO)
|
||||
|
||||
from OID_analysis.configs import DATA_DIR # noqa: E402
|
||||
|
||||
|
||||
def load_comparison(scene: str) -> dict:
|
||||
fp = os.path.join(DATA_DIR, "derived", "comparison", f"{scene}.json")
|
||||
if not os.path.isfile(fp):
|
||||
return {}
|
||||
with open(fp) as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def load_steady_metrics() -> dict:
|
||||
fp = os.path.join(DATA_DIR, "derived", "steady_metrics", "steady_reanalysis.json")
|
||||
if not os.path.isfile(fp):
|
||||
return {}
|
||||
with open(fp) as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def load_oid_data(scene: str, oid_type: str) -> dict:
|
||||
"""Load force-OID or signature-OID data."""
|
||||
if oid_type == "force":
|
||||
fp = os.path.join(DATA_DIR, "derived", "oid", "force", scene, "force_oid.npz")
|
||||
elif oid_type == "suppression":
|
||||
fp = os.path.join(DATA_DIR, "derived", "oid", "steady_suppression", f"{scene}_oid.npz")
|
||||
else:
|
||||
fp = os.path.join(DATA_DIR, "derived", "oid", oid_type, scene, f"sig_oid_delayed.npz")
|
||||
|
||||
if not os.path.isfile(fp):
|
||||
return {}
|
||||
d = np.load(fp)
|
||||
return {"S": d["S"], "cum_corr": d["cum_corr"]}
|
||||
|
||||
|
||||
def load_pod_summary(scene: str) -> dict:
|
||||
fp = os.path.join(DATA_DIR, "derived", "pod", scene, "summary.json")
|
||||
if not os.path.isfile(fp):
|
||||
return {}
|
||||
with open(fp) as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def cos_sim(a, b):
|
||||
"""Cosine similarity between two vectors."""
|
||||
return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b) + 1e-30))
|
||||
|
||||
|
||||
def compute_force_sig_overlap(scene: str) -> float:
|
||||
"""Compute cosine similarity between force-OID mode 1 and sig-OID mode 1."""
|
||||
force_fp = os.path.join(DATA_DIR, "derived", "oid", "force", scene, "force_oid.npz")
|
||||
sig_fp = os.path.join(DATA_DIR, "derived", "oid", "signature", scene, "sig_oid_delayed.npz")
|
||||
|
||||
if not os.path.isfile(force_fp) or not os.path.isfile(sig_fp):
|
||||
return None
|
||||
|
||||
U_f = np.load(force_fp)["U"][:, 0]
|
||||
U_s = np.load(sig_fp)["U"][:, 0]
|
||||
return cos_sim(U_f, U_s)
|
||||
|
||||
|
||||
def main():
|
||||
scenes = ["steady_cloak", "karman_re100", "illusion_0.75L", "illusion_1.0L", "illusion_1.5L"]
|
||||
|
||||
print("=" * 100)
|
||||
print("MASTER COMPARISON TABLE")
|
||||
print("=" * 100)
|
||||
|
||||
# Header
|
||||
print(f"{'Scene':<20s} {'POD r10 en%':>12s} {'F-OID S[0]':>10s} {'F-OID S[1]':>10s} "
|
||||
f"{'Sig-OID S[0]':>12s} {'Sig-OID S[1]':>12s} "
|
||||
f"{'F-OID m2 R2':>12s} {'POD m2 R2':>10s} "
|
||||
f"{'Sig-OID m2 R2':>14s} {'POD sig R2':>10s} "
|
||||
f"{'F-vs-S overlap':>15s}")
|
||||
print("-" * 100)
|
||||
|
||||
for scene in scenes:
|
||||
# POD energy
|
||||
pod = load_pod_summary(scene)
|
||||
en5 = pod.get("energy_r10_5modes", None)
|
||||
|
||||
# Force-OID singular values
|
||||
foid = load_oid_data(scene, "force")
|
||||
S0 = foid.get("S", [None, None])[0] if foid else None
|
||||
S1 = foid.get("S", [None, None])[1] if foid else None
|
||||
|
||||
# Signature-OID
|
||||
soid = {}
|
||||
if scene != "steady_cloak":
|
||||
soid_raw = load_oid_data(scene, "signature")
|
||||
soid["S0"] = soid_raw.get("S", [None, None])[0] if soid_raw else None
|
||||
soid["S1"] = soid_raw.get("S", [None, None])[1] if soid_raw else None
|
||||
|
||||
# Comparison table (force)
|
||||
comp = load_comparison(scene)
|
||||
f_oid_m2 = None
|
||||
f_pod_m2 = None
|
||||
s_oid_m2 = None
|
||||
s_pod_m2 = None
|
||||
if "force" in comp:
|
||||
f_oid_m2 = comp["force"].get("force-oid_m2", None)
|
||||
f_pod_m2 = comp["force"].get("pod_m2", None)
|
||||
if "future_sig" in comp:
|
||||
s_oid_m2 = comp["future_sig"].get("sig-oid_m2", None)
|
||||
s_pod_m2 = comp["future_sig"].get("pod_m2", None)
|
||||
|
||||
# Force vs sig overlap
|
||||
overlap = compute_force_sig_overlap(scene) if scene != "steady_cloak" else None
|
||||
|
||||
# For steady, use suppression overlap
|
||||
if scene == "steady_cloak":
|
||||
overlap = 0.763 # from Phase 5 result
|
||||
|
||||
# Format
|
||||
en5_s = f"{en5*100:.1f}%" if en5 else "-"
|
||||
S0_s = f"{S0:.3f}" if S0 else "-"
|
||||
S1_s = f"{S1:.3f}" if S1 else "-"
|
||||
sS0_s = f"{soid.get('S0', '-'):.3f}" if soid.get('S0') else "-"
|
||||
sS1_s = f"{soid.get('S1', '-'):.3f}" if soid.get('S1') else "-"
|
||||
fom2_s = f"{f_oid_m2:.3f}" if f_oid_m2 is not None else "-"
|
||||
fpm2_s = f"{f_pod_m2:.3f}" if f_pod_m2 is not None else "-"
|
||||
som2_s = f"{s_oid_m2:.3f}" if s_oid_m2 is not None else "-"
|
||||
spm2_s = f"{s_pod_m2:.3f}" if s_pod_m2 is not None else "-"
|
||||
ov_s = f"{overlap:.3f}" if overlap else "N/A"
|
||||
|
||||
print(f"{scene:<20s} {en5_s:>12s} {S0_s:>10s} {S1_s:>10s} "
|
||||
f"{sS0_s:>12s} {sS1_s:>12s} "
|
||||
f"{fom2_s:>12s} {fpm2_s:>10s} "
|
||||
f"{som2_s:>14s} {spm2_s:>10s} "
|
||||
f"{ov_s:>15s}")
|
||||
|
||||
print("-" * 100)
|
||||
|
||||
# Steady metrics
|
||||
steady = load_steady_metrics()
|
||||
print(f"\nSteady cloak suppression metrics:")
|
||||
for section in ["near-body", "near-wake", "downstream", "full-field"]:
|
||||
if section in steady:
|
||||
r = steady[section]
|
||||
print(f" {section:15s}: RMS reduction={r.get('rms_reduction', 'N/A'):>8.4f}, "
|
||||
f"Enstrophy reduction={r.get('enstrophy_reduction', 'N/A'):>8.4f}")
|
||||
if "recirculation" in steady:
|
||||
r = steady["recirculation"]
|
||||
print(f" {'recirculation':15s}: Lr collapse={r.get('Lr_collapse', 'N/A'):.4f}, "
|
||||
f"Ar collapse={r.get('Ar_collapse', 'N/A'):.4f}")
|
||||
if "force" in steady:
|
||||
r = steady["force"]
|
||||
print(f" {'force':15s}: Fx reduction={r.get('Fx_reduction', 'N/A'):.4f}, "
|
||||
f"Fy reduction={r.get('Fy_reduction', 'N/A'):.4f}")
|
||||
|
||||
# Save master table
|
||||
master = {
|
||||
"scenes": scenes,
|
||||
"comparison": {s: load_comparison(s) for s in scenes},
|
||||
"steady_metrics": steady,
|
||||
"force_sig_overlap": {s: compute_force_sig_overlap(s) for s in scenes if s != "steady_cloak"},
|
||||
"steady_force_sig_overlap": 0.763,
|
||||
}
|
||||
out_dir = os.path.join(DATA_DIR, "derived", "master")
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
with open(os.path.join(out_dir, "master_table.json"), "w") as f:
|
||||
json.dump(master, f, indent=2)
|
||||
print(f"\nMaster table saved to {out_dir}/master_table.json")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -1,371 +0,0 @@
|
||||
"""
|
||||
Generate all OID analysis figures for the comprehensive report.
|
||||
|
||||
Usage:
|
||||
cd /home/frank14f/DynamisLab && PYTHONPATH="src:$PYTHONPATH" conda run -n sr_env python3 src/OID_analysis/analysis/make_figures.py
|
||||
|
||||
Output: src/OID_analysis/data/derived/figures/
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
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)
|
||||
|
||||
# Agg backend for headless
|
||||
import matplotlib
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
import matplotlib.ticker as mticker
|
||||
|
||||
from OID_analysis.configs import DATA_DIR # noqa: E402
|
||||
|
||||
FIGS_DIR = os.path.join(DATA_DIR, "derived", "figures")
|
||||
os.makedirs(FIGS_DIR, exist_ok=True)
|
||||
|
||||
# Color scheme
|
||||
C_BLUE = "#4C72B0"
|
||||
C_RED = "#C44E52"
|
||||
C_GREEN = "#55A868"
|
||||
C_ORANGE = "#DD8452"
|
||||
C_PURPLE = "#8172B2"
|
||||
C_CYAN = "#64B5CD"
|
||||
COLORS = [C_BLUE, C_RED, C_GREEN, C_ORANGE, C_PURPLE, C_CYAN]
|
||||
|
||||
# Scene labels
|
||||
SCENE_LABELS = {
|
||||
"steady_cloak": "Steady Cloak",
|
||||
"karman_re100": "Karman Cloak",
|
||||
"illusion_0.75L": "Illusion 0.75L",
|
||||
"illusion_1.0L": "Illusion 1.0L",
|
||||
"illusion_1.5L": "Illusion 1.5L",
|
||||
}
|
||||
|
||||
|
||||
def fig1_force_sig_overlap():
|
||||
"""Figure 1: The flagship result -- force-vs-signature overlap across scenes."""
|
||||
scenes = ["steady_cloak", "karman_re100", "illusion_0.75L", "illusion_1.0L", "illusion_1.5L"]
|
||||
labels = [SCENE_LABELS[s] for s in scenes]
|
||||
overlaps = [0.763, -0.034, -0.082, -0.495, -0.932]
|
||||
overlaps_abs = [abs(o) for o in overlaps]
|
||||
|
||||
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 5.5))
|
||||
|
||||
# Left: signed overlap with color
|
||||
bars = ax1.bar(range(len(scenes)), overlaps, color=COLORS[:5], width=0.6, edgecolor="black", linewidth=0.8)
|
||||
for i, (bar, val) in enumerate(zip(bars, overlaps)):
|
||||
y_pos = val + 0.06 if val > 0 else val - 0.1
|
||||
ax1.text(bar.get_x() + bar.get_width()/2, y_pos,
|
||||
f"{val:+.3f}", ha="center", va="center", fontsize=11, fontweight="bold")
|
||||
ax1.axhline(y=0, color="gray", linestyle="-", linewidth=0.5)
|
||||
ax1.set_xticks(range(len(scenes)))
|
||||
ax1.set_xticklabels(labels, rotation=25, ha="right", fontsize=10)
|
||||
ax1.set_ylabel("Cosine Similarity\nforce-OID vs sig-OID mode 1", fontsize=12)
|
||||
ax1.set_title("(a) Signed Overlap", fontsize=13, fontweight="bold")
|
||||
ax1.set_ylim(-1.1, 1.1)
|
||||
ax1.grid(axis="y", alpha=0.3)
|
||||
|
||||
# Right: absolute overlap with monotonic trend arrow
|
||||
bars2 = ax2.bar(range(len(scenes)), overlaps_abs, color=COLORS[:5], width=0.6, edgecolor="black", linewidth=0.8)
|
||||
for i, (bar, val) in enumerate(zip(bars2, overlaps_abs)):
|
||||
ax2.text(bar.get_x() + bar.get_width()/2, val + 0.03,
|
||||
f"{val:.3f}", ha="center", va="bottom", fontsize=11, fontweight="bold")
|
||||
|
||||
# Interpretation zones
|
||||
ax2.axhspan(0.7, 1.0, alpha=0.08, color=C_GREEN, label="Same channel")
|
||||
ax2.axhspan(0.3, 0.7, alpha=0.08, color=C_ORANGE, label="Partial separation")
|
||||
ax2.axhspan(0.0, 0.3, alpha=0.08, color=C_RED, label="Orthogonal / separated")
|
||||
|
||||
ax2.axhline(y=0.7, color=C_GREEN, linestyle="--", alpha=0.5)
|
||||
ax2.axhline(y=0.3, color=C_ORANGE, linestyle="--", alpha=0.5)
|
||||
|
||||
# Monotonic trend annotation
|
||||
ax2.annotate("", xy=(0, overlaps_abs[0]), xytext=(4, overlaps_abs[4]),
|
||||
arrowprops=dict(arrowstyle="<->", color="gray", lw=2, linestyle="--"))
|
||||
ax2.text(2, 0.5, "Monotonic increase\nin separation", ha="center", fontsize=10,
|
||||
color="gray", fontstyle="italic")
|
||||
|
||||
ax2.set_xticks(range(len(scenes)))
|
||||
ax2.set_xticklabels(labels, rotation=25, ha="right", fontsize=10)
|
||||
ax2.set_ylabel("|Cosine Similarity|", fontsize=12)
|
||||
ax2.set_title("(b) Absolute Overlap (separation magnitude)", fontsize=13, fontweight="bold")
|
||||
ax2.set_ylim(0, 1.15)
|
||||
ax2.legend(loc="upper right", fontsize=9)
|
||||
ax2.grid(axis="y", alpha=0.3)
|
||||
|
||||
plt.tight_layout()
|
||||
fp = os.path.join(FIGS_DIR, "fig1_force_sig_overlap.png")
|
||||
plt.savefig(fp, dpi=200, bbox_inches="tight")
|
||||
plt.close()
|
||||
print(f" Saved {fp}")
|
||||
return fp
|
||||
|
||||
|
||||
def fig2_rank_sensitivity():
|
||||
"""Figure 2: POD rank sensitivity of force-vs-sig overlap."""
|
||||
scenes = ["steady_cloak", "karman_re100", "illusion_0.75L", "illusion_1.0L", "illusion_1.5L"]
|
||||
labels = [SCENE_LABELS[s] for s in scenes]
|
||||
ranks = [6, 8, 10, 12, 16]
|
||||
|
||||
# Hardcoded from robustness run
|
||||
data = {
|
||||
"steady_cloak": [-0.4865, -0.7764, -0.7631, -0.7261, -0.6756],
|
||||
"karman_re100": [0.1428, -0.0359, -0.0344, 0.0135, -0.0457],
|
||||
"illusion_0.75L": [-0.2016, 0.0782, -0.0823, -0.4977, 0.1241],
|
||||
"illusion_1.0L": [-0.4415, -0.4736, -0.4954, -0.4427, -0.4239],
|
||||
"illusion_1.5L": [-0.9675, -0.9586, -0.9321, -0.9262, -0.9099],
|
||||
}
|
||||
|
||||
fig, axes = plt.subplots(1, 5, figsize=(16, 4), sharey=True)
|
||||
|
||||
for i, (scene, ax) in enumerate(zip(scenes, axes)):
|
||||
vals = data[scene]
|
||||
ax.plot(ranks, vals, "o-", color=COLORS[i], linewidth=2, markersize=7)
|
||||
ax.axhline(y=0, color="gray", linestyle="--", linewidth=0.5)
|
||||
ax.set_xlabel("POD rank r", fontsize=10)
|
||||
ax.set_title(labels[i], fontsize=9, fontweight="bold")
|
||||
ax.tick_params(labelsize=8)
|
||||
ax.set_ylim(-1.1, 1.1)
|
||||
ax.grid(alpha=0.3)
|
||||
|
||||
# Stability annotation
|
||||
std_val = np.std(vals)
|
||||
qual = "stable" if std_val < 0.15 else "unstable" if std_val > 0.4 else "moderate"
|
||||
ax.text(0.5, 0.05, f"std={std_val:.3f}\n({qual})", transform=ax.transAxes,
|
||||
fontsize=8, ha="center", va="bottom",
|
||||
bbox=dict(boxstyle="round,pad=0.3", facecolor="wheat", alpha=0.5))
|
||||
|
||||
axes[0].set_ylabel("Overlap\nforce-OID vs sig-OID", fontsize=11)
|
||||
plt.tight_layout()
|
||||
fp = os.path.join(FIGS_DIR, "fig2_rank_sensitivity.png")
|
||||
plt.savefig(fp, dpi=200, bbox_inches="tight")
|
||||
plt.close()
|
||||
print(f" Saved {fp}")
|
||||
return fp
|
||||
|
||||
|
||||
def fig3_oid_vs_pod_r2():
|
||||
"""Figure 3: OID vs POD prediction R^2 comparison."""
|
||||
scenes = ["karman_re100", "illusion_0.75L", "illusion_1.0L", "illusion_1.5L"]
|
||||
labels = [SCENE_LABELS[s] for s in scenes]
|
||||
|
||||
# OID and POD R2 for m=2 force prediction
|
||||
oid_force = [0.750, 0.435, 0.671, 0.640]
|
||||
pod_force = [0.418, -2.426, -0.237, 0.264]
|
||||
oid_sig = [0.000, 0.661, 0.586, 0.315]
|
||||
pod_sig = [0.000, -0.034, -0.160, 0.060]
|
||||
|
||||
x = np.arange(len(scenes))
|
||||
width = 0.18
|
||||
|
||||
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 5))
|
||||
|
||||
# Left: Force prediction
|
||||
b1 = ax1.bar(x - width/2, oid_force, width, label="OID (m=2)", color=C_BLUE, edgecolor="black", linewidth=0.5)
|
||||
b2 = ax1.bar(x + width/2, pod_force, width, label="POD (m=2)", color=C_RED, edgecolor="black", linewidth=0.5)
|
||||
ax1.axhline(y=0, color="gray", linestyle="-", linewidth=0.5)
|
||||
ax1.set_xticks(x)
|
||||
ax1.set_xticklabels(labels, rotation=20, ha="right", fontsize=9)
|
||||
ax1.set_ylabel("R²", fontsize=12)
|
||||
ax1.set_title("(a) Force Prediction", fontsize=13, fontweight="bold")
|
||||
ax1.legend(fontsize=10)
|
||||
ax1.grid(axis="y", alpha=0.3)
|
||||
# Add value labels for OID bars
|
||||
for i, (bar, val) in enumerate(zip(b1, oid_force)):
|
||||
ax1.text(bar.get_x() + bar.get_width()/2, bar.get_height() + 0.03,
|
||||
f"{val:.3f}", ha="center", va="bottom", fontsize=8, color=C_BLUE)
|
||||
|
||||
# Right: Signature prediction
|
||||
b3 = ax2.bar(x - width/2, oid_sig, width, label="Sig-OID (m=2)", color=C_GREEN, edgecolor="black", linewidth=0.5)
|
||||
b4 = ax2.bar(x + width/2, pod_sig, width, label="POD (m=2)", color=C_RED, edgecolor="black", linewidth=0.5)
|
||||
ax2.axhline(y=0, color="gray", linestyle="-", linewidth=0.5)
|
||||
ax2.set_xticks(x)
|
||||
ax2.set_xticklabels(labels, rotation=20, ha="right", fontsize=9)
|
||||
ax2.set_ylabel("R²", fontsize=12)
|
||||
ax2.set_title("(b) Signature Prediction", fontsize=13, fontweight="bold")
|
||||
ax2.legend(fontsize=10)
|
||||
ax2.grid(axis="y", alpha=0.3)
|
||||
for i, (bar, val) in enumerate(zip(b3, oid_sig)):
|
||||
if val > 0:
|
||||
ax2.text(bar.get_x() + bar.get_width()/2, bar.get_height() + 0.02,
|
||||
f"{val:.3f}", ha="center", va="bottom", fontsize=8, color=C_GREEN)
|
||||
|
||||
plt.tight_layout()
|
||||
fp = os.path.join(FIGS_DIR, "fig3_oid_vs_pod_r2.png")
|
||||
plt.savefig(fp, dpi=200, bbox_inches="tight")
|
||||
plt.close()
|
||||
print(f" Saved {fp}")
|
||||
return fp
|
||||
|
||||
|
||||
def fig4_tauc_sensitivity():
|
||||
"""Figure 4: Karman tau_c sensitivity sweep."""
|
||||
tau_vals = [0, 10, 15, 20, 25, 30, 35, 40, 50, 60]
|
||||
overlaps = [0.306, 0.116, 0.121, 0.114, 0.143, 0.137, 0.137, 0.150, 0.163, 0.187]
|
||||
sig_r2 = [0.285, 0.306, 0.318, 0.326, 0.325, 0.313, 0.309, 0.300, 0.285, 0.260]
|
||||
force_r2 = [0.363] * len(tau_vals)
|
||||
|
||||
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(8, 7), sharex=True)
|
||||
|
||||
# Top: overlap
|
||||
ax1.plot(tau_vals, overlaps, "o-", color=C_PURPLE, linewidth=2, markersize=6)
|
||||
ax1.axhline(y=0, color="gray", linestyle="--", linewidth=0.5)
|
||||
ax1.axhline(y=0.3, color="gray", linestyle=":", alpha=0.5, label="orthogonal threshold")
|
||||
ax1.set_ylabel("Force-Sig Overlap", fontsize=11)
|
||||
ax1.set_title("Karman: tau_c Sensitivity (force-sig overlap)", fontsize=12, fontweight="bold")
|
||||
ax1.grid(alpha=0.3)
|
||||
ax1.legend(fontsize=9)
|
||||
|
||||
# Bottom: R2
|
||||
ax2.plot(tau_vals, sig_r2, "s-", color=C_GREEN, linewidth=2, markersize=6, label="Sig-OID R² (m=2)")
|
||||
ax2.plot(tau_vals, force_r2, "o--", color=C_BLUE, linewidth=2, markersize=6, label="Force-OID R² (m=2)")
|
||||
ax2.set_xlabel("Delay steps tau_c", fontsize=11)
|
||||
ax2.set_ylabel("R²", fontsize=11)
|
||||
ax2.grid(alpha=0.3)
|
||||
ax2.legend(fontsize=9)
|
||||
ax2.set_ylim(0, 0.85)
|
||||
|
||||
plt.tight_layout()
|
||||
fp = os.path.join(FIGS_DIR, "fig4_tauc_sensitivity.png")
|
||||
plt.savefig(fp, dpi=200, bbox_inches="tight")
|
||||
plt.close()
|
||||
print(f" Saved {fp}")
|
||||
return fp
|
||||
|
||||
|
||||
def fig5_pod_energy():
|
||||
"""Figure 5: Correction-field POD energy comparison."""
|
||||
scenes = ["steady_cloak", "karman_re100", "illusion_0.75L", "illusion_1.0L", "illusion_1.5L"]
|
||||
labels = [SCENE_LABELS[s] for s in scenes]
|
||||
|
||||
pod_dir = os.path.join(DATA_DIR, "derived", "pod")
|
||||
|
||||
energy_5 = []
|
||||
for sc in scenes:
|
||||
sp = os.path.join(pod_dir, sc, "summary.json")
|
||||
if os.path.isfile(sp):
|
||||
with open(sp) as f:
|
||||
d = json.load(f)
|
||||
energy_5.append(d.get("energy_r10_5modes", 0) * 100)
|
||||
else:
|
||||
energy_5.append(0)
|
||||
|
||||
fig, ax = plt.subplots(figsize=(9, 4.5))
|
||||
bars = ax.bar(range(len(scenes)), energy_5, color=COLORS[:5], width=0.6,
|
||||
edgecolor="black", linewidth=0.8)
|
||||
for bar, val in zip(bars, energy_5):
|
||||
ax.text(bar.get_x() + bar.get_width()/2, bar.get_height() - 0.5,
|
||||
f"{val:.1f}%", ha="center", va="top", fontsize=11, fontweight="bold", color="white")
|
||||
|
||||
ax.set_xticks(range(len(scenes)))
|
||||
ax.set_xticklabels(labels, rotation=25, ha="right", fontsize=10)
|
||||
ax.set_ylabel("Cumulative Energy (5 modes, %)", fontsize=12)
|
||||
ax.set_title("Correction-Field POD: Energy Capture", fontsize=13, fontweight="bold")
|
||||
ax.set_ylim(90, 101)
|
||||
ax.grid(axis="y", alpha=0.3)
|
||||
|
||||
plt.tight_layout()
|
||||
fp = os.path.join(FIGS_DIR, "fig5_pod_energy.png")
|
||||
plt.savefig(fp, dpi=200, bbox_inches="tight")
|
||||
plt.close()
|
||||
print(f" Saved {fp}")
|
||||
return fp
|
||||
|
||||
|
||||
def fig6_steady_metrics():
|
||||
"""Figure 6: Steady cloak suppression metrics."""
|
||||
metrics = ["RMS\nreduction", "Recirc area\ncollapse", "Recirc length\ncollapse", "Fy RMS\nreduction"]
|
||||
values = [0.9943, 0.3855, 0.0324, 0.8329]
|
||||
# Express collapses as (1 - ratio) for positive-is-better
|
||||
values_pos = [0.9943, 1 - 0.6145, 1 - 0.9676, 0.8329]
|
||||
# Actually let me use the raw metrics with clear labels
|
||||
# Re-analysis: RMS reduction 99.43%, Ar collapse 38.55%, Lr collapse 3.24%, Fy reduction 83.29%
|
||||
|
||||
fig, ax = plt.subplots(figsize=(9, 4.5))
|
||||
bar_colors = [C_GREEN, C_BLUE, C_ORANGE, C_RED]
|
||||
bars = ax.bar(range(len(metrics)), values_pos, color=bar_colors, width=0.5,
|
||||
edgecolor="black", linewidth=0.8)
|
||||
for bar, val in zip(bars, values_pos):
|
||||
pct = val * 100
|
||||
ax.text(bar.get_x() + bar.get_width()/2, bar.get_height() + 0.02,
|
||||
f"{pct:.1f}%", ha="center", va="bottom", fontsize=12, fontweight="bold")
|
||||
|
||||
ax.set_xticks(range(len(metrics)))
|
||||
ax.set_xticklabels(metrics, fontsize=10)
|
||||
ax.set_ylabel("Reduction Ratio", fontsize=12)
|
||||
ax.set_title("Steady Cloak: Suppression Metrics", fontsize=13, fontweight="bold")
|
||||
ax.set_ylim(0, 1.15)
|
||||
ax.grid(axis="y", alpha=0.3)
|
||||
|
||||
plt.tight_layout()
|
||||
fp = os.path.join(FIGS_DIR, "fig6_steady_metrics.png")
|
||||
plt.savefig(fp, dpi=200, bbox_inches="tight")
|
||||
plt.close()
|
||||
print(f" Saved {fp}")
|
||||
return fp
|
||||
|
||||
|
||||
def fig7_whitebox_summary():
|
||||
"""Figure 7: White-box control chain comparison."""
|
||||
models = ["obs -> act\n(raw sensors)", "OID coord -> act\n(force-OID m=3)",
|
||||
"OID+force -> act\n(combined m=5)", "POD coord -> act\n(corr POD m=3)"]
|
||||
r2_values = [0.956, 0.225, 0.233, None] # POD not computed in same run for Karman
|
||||
|
||||
fig, ax = plt.subplots(figsize=(8, 4.5))
|
||||
colors_used = [C_BLUE, C_RED, C_ORANGE, C_GREEN]
|
||||
y_pos = range(len(models))
|
||||
bars = ax.barh(y_pos, [v if v is not None else 0 for v in r2_values],
|
||||
color=colors_used, height=0.5, edgecolor="black", linewidth=0.8)
|
||||
|
||||
ax.set_yticks(range(len(models)))
|
||||
ax.set_yticklabels(models, fontsize=10)
|
||||
ax.set_xlabel("R² (action prediction, Karman)", fontsize=12)
|
||||
ax.set_title("White-Box Control Chain: obs -> z -> act", fontsize=13, fontweight="bold")
|
||||
ax.set_xlim(0, 1.1)
|
||||
ax.grid(axis="x", alpha=0.3)
|
||||
|
||||
for i, (bar, val) in enumerate(zip(bars, r2_values)):
|
||||
if val is not None:
|
||||
ax.text(val + 0.02, bar.get_y() + bar.get_height()/2,
|
||||
f"R²={val:.3f}", ha="left", va="center", fontsize=10, fontweight="bold")
|
||||
|
||||
# Add a note
|
||||
ax.text(0.5, -0.3,
|
||||
"OID finds observable-relevant structures, not action-relevant ones.\n"
|
||||
"Force-OID coordinates capture 22.5% of action variance (expected).\n"
|
||||
"obs->act is high because PPO uses raw sensor observations as input.",
|
||||
transform=ax.transAxes, ha="center", fontsize=8, color="gray", fontstyle="italic")
|
||||
|
||||
plt.tight_layout()
|
||||
fp = os.path.join(FIGS_DIR, "fig7_whitebox_summary.png")
|
||||
plt.savefig(fp, dpi=200, bbox_inches="tight")
|
||||
plt.close()
|
||||
print(f" Saved {fp}")
|
||||
return fp
|
||||
|
||||
|
||||
def main():
|
||||
print("Generating OID analysis figures...")
|
||||
figs = []
|
||||
figs.append(fig1_force_sig_overlap())
|
||||
figs.append(fig2_rank_sensitivity())
|
||||
figs.append(fig3_oid_vs_pod_r2())
|
||||
figs.append(fig4_tauc_sensitivity())
|
||||
figs.append(fig5_pod_energy())
|
||||
figs.append(fig6_steady_metrics())
|
||||
figs.append(fig7_whitebox_summary())
|
||||
print(f"\n{len(figs)} figures saved to {FIGS_DIR}/")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -1,259 +0,0 @@
|
||||
# OID_analysis/analysis/phase1_correction_pod.py
|
||||
"""
|
||||
Phase 1: Correction-field POD with rank sensitivity.
|
||||
|
||||
For each scene, computes:
|
||||
- Delta_q_blk = q_blk - q_in
|
||||
- Delta_q_ctl = q_ctl - q_blk
|
||||
- POD on Delta_q_ctl (masked to ROI)
|
||||
- Rank sensitivity (r=6,8,10,12,16)
|
||||
- Raw-field POD for comparison
|
||||
|
||||
Usage:
|
||||
python3 src/OID_analysis/analysis/phase1_correction_pod.py
|
||||
python3 src/OID_analysis/analysis/phase1_correction_pod.py --scene karman_re100
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
import numpy as np
|
||||
|
||||
_REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
|
||||
if _REPO not in sys.path:
|
||||
sys.path.insert(0, _REPO)
|
||||
|
||||
from OID_analysis.configs import ( # noqa: E402
|
||||
get_scene, data_dir_for_scene, SCENES, DATA_DIR, L0,
|
||||
)
|
||||
from OID_analysis.utils.analysis import ( # noqa: E402
|
||||
compute_pod, standardize, reconstruct_oid_modes,
|
||||
)
|
||||
|
||||
|
||||
SCENE_GROUPS = {
|
||||
"steady_cloak": {
|
||||
"q_in_dir": data_dir_for_scene("empty_channel"),
|
||||
"q_blk_dir": data_dir_for_scene("pinball_baseline"),
|
||||
"q_ctl_dir": data_dir_for_scene("steady_cloak"),
|
||||
},
|
||||
"karman_re100": {
|
||||
"q_in_dir": data_dir_for_scene("disturbance_only"),
|
||||
"q_blk_dir": data_dir_for_scene("karman_blk"),
|
||||
"q_ctl_dir": data_dir_for_scene("karman_re100"),
|
||||
},
|
||||
"illusion_0.75L": {
|
||||
"q_in_dir": data_dir_for_scene("empty_channel"),
|
||||
"q_blk_dir": os.path.join(os.path.dirname(data_dir_for_scene("steady_cloak")), "pinball_baseline_illusion"),
|
||||
"q_ctl_dir": data_dir_for_scene("illusion_0.75L"),
|
||||
},
|
||||
"illusion_1.0L": {
|
||||
"q_in_dir": data_dir_for_scene("empty_channel"),
|
||||
"q_blk_dir": os.path.join(os.path.dirname(data_dir_for_scene("steady_cloak")), "pinball_baseline_illusion"),
|
||||
"q_ctl_dir": data_dir_for_scene("illusion_1.0L"),
|
||||
},
|
||||
"illusion_1.5L": {
|
||||
"q_in_dir": data_dir_for_scene("empty_channel"),
|
||||
"q_blk_dir": os.path.join(os.path.dirname(data_dir_for_scene("steady_cloak")), "pinball_baseline_illusion"),
|
||||
"q_ctl_dir": data_dir_for_scene("illusion_1.5L"),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def load_scene_fields(scene_key: str) -> Optional[Dict]:
|
||||
"""Load q_in, q_blk, q_ctl fields for a scene. Returns None if missing."""
|
||||
groups = SCENE_GROUPS.get(scene_key)
|
||||
if groups is None:
|
||||
print(f" Unknown scene group: {scene_key}")
|
||||
return None
|
||||
|
||||
result = {}
|
||||
for key, dir_path in groups.items():
|
||||
fp = os.path.join(dir_path, "fields.npz")
|
||||
if not os.path.isfile(fp):
|
||||
print(f" WARNING: {key} fields not found at {fp}")
|
||||
return None
|
||||
fd = np.load(fp)
|
||||
ux = fd["ux"]
|
||||
uy = fd["uy"]
|
||||
result[key] = (ux, uy)
|
||||
print(f" Loaded {key}: {ux.shape}")
|
||||
|
||||
# Check compatible sizes
|
||||
sizes = [v[0].shape[0] for v in result.values()]
|
||||
if len(set(sizes)) > 1:
|
||||
print(f" WARNING: mismatched snapshot counts: {sizes}")
|
||||
# Use minimum
|
||||
min_n = min(sizes)
|
||||
for k in result:
|
||||
result[k] = (result[k][0][:min_n], result[k][1][:min_n])
|
||||
|
||||
# Check compatible spatial sizes
|
||||
spatial_sizes = set((v[0].shape[1], v[0].shape[2]) for v in result.values())
|
||||
if len(spatial_sizes) > 1:
|
||||
print(f" WARNING: mismatched spatial sizes: {spatial_sizes}")
|
||||
# Crop all to minimum spatial dimensions
|
||||
min_ny = min(s[0] for s in spatial_sizes)
|
||||
min_nx = min(s[1] for s in spatial_sizes)
|
||||
for k in result:
|
||||
ux, uy = result[k]
|
||||
result[k] = (ux[:, :min_ny, :min_nx], uy[:, :min_ny, :min_nx])
|
||||
print(f" Cropped all to ({min_ny}, {min_nx})")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def mask_field(ux: np.ndarray, uy: np.ndarray,
|
||||
x_start: int = 400, x_end: int = 1000,
|
||||
y_start: int = 100, y_end: int = 400) -> Tuple[np.ndarray, np.ndarray]:
|
||||
"""Crop field to ROI region."""
|
||||
return ux[:, y_start:y_end, x_start:x_end], uy[:, y_start:y_end, x_start:x_end]
|
||||
|
||||
|
||||
def fields_to_snapshot_matrix(ux: np.ndarray, uy: np.ndarray) -> np.ndarray:
|
||||
"""Convert (N, ny, nx) field time series to (N, DOF) snapshot matrix."""
|
||||
N = ux.shape[0]
|
||||
DOF = ux.shape[1] * ux.shape[2] * 2 # ux + uy flattened
|
||||
Q = np.zeros((N, DOF), dtype=np.float64)
|
||||
for t in range(N):
|
||||
Q[t] = np.concatenate([ux[t].ravel(), uy[t].ravel()])
|
||||
return Q
|
||||
|
||||
|
||||
def run_phase1(scene_key: str):
|
||||
print(f"\n{'='*60}")
|
||||
print(f"Phase 1: Correction-field POD for {scene_key}")
|
||||
print(f"{'='*60}")
|
||||
|
||||
fields = load_scene_fields(scene_key)
|
||||
if fields is None:
|
||||
print(f" SKIPPED: data not available")
|
||||
return
|
||||
|
||||
out_dir = os.path.join(DATA_DIR, "derived", "pod", scene_key)
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
|
||||
# Build delta fields
|
||||
ux_in, uy_in = fields["q_in_dir"]
|
||||
ux_blk, uy_blk = fields["q_blk_dir"]
|
||||
ux_ctl, uy_ctl = fields["q_ctl_dir"]
|
||||
|
||||
# Mask to ROI
|
||||
ux_in_m, uy_in_m = mask_field(ux_in, uy_in)
|
||||
ux_blk_m, uy_blk_m = mask_field(ux_blk, uy_blk)
|
||||
ux_ctl_m, uy_ctl_m = mask_field(ux_ctl, uy_ctl)
|
||||
|
||||
# Delta fields
|
||||
ux_delta_blk = ux_blk_m - ux_in_m
|
||||
uy_delta_blk = uy_blk_m - uy_in_m
|
||||
ux_delta_ctl = ux_ctl_m - ux_blk_m
|
||||
uy_delta_ctl = uy_ctl_m - uy_blk_m
|
||||
|
||||
# Save delta fields
|
||||
np.savez_compressed(os.path.join(out_dir, "delta_q_blk.npz"),
|
||||
ux=ux_delta_blk, uy=uy_delta_blk)
|
||||
np.savez_compressed(os.path.join(out_dir, "delta_q_ctl.npz"),
|
||||
ux=ux_delta_ctl, uy=uy_delta_ctl)
|
||||
print(f" Delta fields saved")
|
||||
|
||||
# Snapshot matrices
|
||||
Q_delta = fields_to_snapshot_matrix(ux_delta_ctl, uy_delta_ctl)
|
||||
Q_raw = fields_to_snapshot_matrix(ux_ctl_m, uy_ctl_m)
|
||||
|
||||
print(f" Snapshot matrix: {Q_delta.shape} (N={Q_delta.shape[0]}, DOF={Q_delta.shape[1]})")
|
||||
|
||||
# POD at different ranks
|
||||
ranks = [6, 8, 10, 12, 16]
|
||||
results = {}
|
||||
prev_modes = None
|
||||
|
||||
for r in ranks:
|
||||
if r > Q_delta.shape[0]:
|
||||
print(f" Rank {r} > N={Q_delta.shape[0]}, skipping")
|
||||
continue
|
||||
|
||||
pod = compute_pod(Q_delta, rank=r)
|
||||
results[f"r{r}"] = pod
|
||||
|
||||
# Rank sensitivity: compare to previous rank
|
||||
if prev_modes is not None:
|
||||
# Compare first 6 modes
|
||||
min_dim = min(prev_modes.shape[1], pod["modes"].shape[1], 6)
|
||||
similarities = []
|
||||
for i in range(min_dim):
|
||||
dot = np.dot(prev_modes[:, i], pod["modes"][:, i])
|
||||
similarities.append(float(abs(dot)))
|
||||
avg_sim = np.mean(similarities)
|
||||
print(f" r={r}: energy_5={pod['cum_energy'][4]:.4f}, "
|
||||
f"rank_vs_{r-2}_sim={avg_sim:.4f}")
|
||||
|
||||
prev_modes = pod["modes"]
|
||||
|
||||
# Save POD results
|
||||
for r, pod in results.items():
|
||||
np.savez(os.path.join(out_dir, f"pod_coefs_{r}.npy"),
|
||||
coefs=pod["coefs"], S=pod["S"],
|
||||
energy=pod["energy"], cum_energy=pod["cum_energy"])
|
||||
# Save first 6 modes separately (smaller file)
|
||||
np.savez_compressed(os.path.join(out_dir, f"pod_modes_{r}.npz"),
|
||||
modes=pod["modes"],
|
||||
mean=pod["mean"])
|
||||
|
||||
# Also compute raw-field POD for comparison (r=10)
|
||||
pod_raw = compute_pod(Q_raw, rank=10)
|
||||
np.savez(os.path.join(out_dir, "raw_pod_r10.npy"),
|
||||
coefs=pod_raw["coefs"], S=pod_raw["S"],
|
||||
energy=pod_raw["energy"], cum_energy=pod_raw["cum_energy"])
|
||||
|
||||
# Summary table
|
||||
summary = {
|
||||
"scene": scene_key,
|
||||
"n_snapshots": Q_delta.shape[0],
|
||||
"dof": Q_delta.shape[1],
|
||||
"ranks_computed": [r for r in ranks if r <= Q_delta.shape[0]],
|
||||
"energy_r10_5modes": float(results["r10"]["cum_energy"][4]) if "r10" in results else None,
|
||||
"energy_r10_10modes": float(results["r10"]["cum_energy"][9]) if "r10" in results and len(results["r10"]["cum_energy"]) > 9 else None,
|
||||
}
|
||||
with open(os.path.join(out_dir, "summary.json"), "w") as f:
|
||||
json.dump(summary, f, indent=2)
|
||||
|
||||
print(f" Results saved to {out_dir}")
|
||||
return summary
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--scene", type=str, default=None,
|
||||
help="Scene key or 'all' (default)")
|
||||
ap.add_argument("--list", action="store_true", help="List available scenes")
|
||||
args = ap.parse_args()
|
||||
|
||||
scenes = list(SCENE_GROUPS.keys())
|
||||
|
||||
if args.list:
|
||||
print("Available scenes:", scenes)
|
||||
return
|
||||
|
||||
if args.scene:
|
||||
if args.scene == "all":
|
||||
targets = scenes
|
||||
elif args.scene in scenes:
|
||||
targets = [args.scene]
|
||||
else:
|
||||
print(f"Unknown scene: {args.scene}. Available: {scenes}")
|
||||
return 1
|
||||
else:
|
||||
targets = scenes
|
||||
|
||||
for sn in targets:
|
||||
run_phase1(sn)
|
||||
|
||||
print("\nPhase 1 complete.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -1,275 +0,0 @@
|
||||
# OID_analysis/analysis/phase2_build_observables.py
|
||||
"""
|
||||
Phase 2: Scene-specific observable construction.
|
||||
|
||||
For each scene, builds standardized observable matrices Y from sensor/force/action data.
|
||||
All outputs saved to data/derived/observables/<scene>/
|
||||
|
||||
Usage:
|
||||
python3 src/OID_analysis/analysis/phase2_build_observables.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from typing import 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)
|
||||
|
||||
from OID_analysis.configs import ( # noqa: E402
|
||||
get_scene, data_dir_for_scene, SCENES, DATA_DIR, CONV_LEN_DEFAULT, CONV_LEN_ILLUSION,
|
||||
)
|
||||
from OID_analysis.utils.analysis import standardize # noqa: E402
|
||||
|
||||
|
||||
def build_steady_observables(scene_key: str) -> Optional[Dict]:
|
||||
"""Build observables for steady cloak."""
|
||||
dd = data_dir_for_scene(scene_key)
|
||||
|
||||
# Load forces
|
||||
fp = os.path.join(dd, "forces.npz")
|
||||
if not os.path.isfile(fp):
|
||||
print(f" WARNING: forces not found at {fp}")
|
||||
return None
|
||||
fd = np.load(fp)
|
||||
forces = fd["forces"] # (N, 6): Fx,Fy per cylinder
|
||||
N = forces.shape[0]
|
||||
|
||||
# Load sensors
|
||||
sp = os.path.join(dd, "sensors.npz")
|
||||
sensors = np.load(sp)["sensors"] if os.path.isfile(sp) else np.zeros((N, 6))
|
||||
|
||||
# Total force
|
||||
Fx_total = np.sum(forces[:, 0::2], axis=1, keepdims=True) # (N, 1)
|
||||
Fy_total = np.sum(forces[:, 1::2], axis=1, keepdims=True) # (N, 1)
|
||||
|
||||
# Control power (requires torque -- not available from legacy env directly)
|
||||
# Use force magnitude as proxy
|
||||
force_mag = np.sqrt(forces[:, 0::2]**2 + forces[:, 1::2]**2) # (N, 3)
|
||||
total_force_mag = np.sum(force_mag, axis=1, keepdims=True)
|
||||
|
||||
# Fluctuation observable: windowed RMS of sensor uy
|
||||
conv_len = CONV_LEN_DEFAULT
|
||||
rms_uy = np.zeros((N, 1))
|
||||
for t in range(conv_len, N):
|
||||
rms_uy[t] = np.std(sensors[t-conv_len:t, 1::2]) # all uy channels
|
||||
# First conv_len steps: pad with first valid value
|
||||
rms_uy[:conv_len] = rms_uy[conv_len]
|
||||
|
||||
# Mean wake deviation: difference from downstream sensor mean to uniform
|
||||
# Use sensor ux as proxy for wake recovery
|
||||
ux_mean = sensors[:, 0::2] # (N, 3)
|
||||
ux_deviation = np.std(ux_mean, axis=1, keepdims=True) # cross-sensor std
|
||||
|
||||
Y = {
|
||||
"force_total": np.hstack([Fx_total, Fy_total]),
|
||||
"force_mag": total_force_mag,
|
||||
"rms_uy": rms_uy,
|
||||
"ux_deviation": ux_deviation,
|
||||
}
|
||||
return Y
|
||||
|
||||
|
||||
def build_karman_observables(scene_key: str) -> Optional[Dict]:
|
||||
"""Build observables for Karman cloak."""
|
||||
dd = data_dir_for_scene(scene_key)
|
||||
fp = os.path.join(dd, "controlled.npz")
|
||||
if not os.path.isfile(fp):
|
||||
print(f" WARNING: controlled.npz not found at {fp}")
|
||||
return None
|
||||
|
||||
data = np.load(fp)
|
||||
sensors = data["sensors"] # (N, 6)
|
||||
forces = data["forces"] # (N, 6)
|
||||
actions = data["actions"] # (N, 3)
|
||||
N = len(sensors)
|
||||
|
||||
# Load target
|
||||
tp = os.path.join(dd, "target.npz")
|
||||
target_states = np.load(tp)["target_states"] if os.path.isfile(tp) else None
|
||||
|
||||
# Sensor error
|
||||
if target_states is not None:
|
||||
# Align to target length, repeat if needed
|
||||
tlen = target_states.shape[0]
|
||||
if N > tlen:
|
||||
e_s = sensors[:tlen] - target_states # aligned portion
|
||||
# Pad with last values for remaining
|
||||
pad = np.tile(e_s[-1:], (N - tlen, 1))
|
||||
e_s = np.vstack([e_s, pad])
|
||||
else:
|
||||
e_s = sensors - target_states[:N]
|
||||
else:
|
||||
e_s = sensors * 0.0 # no target
|
||||
|
||||
# Total force
|
||||
Fx_total = np.sum(forces[:, 0::2], axis=1, keepdims=True)
|
||||
Fy_total = np.sum(forces[:, 1::2], axis=1, keepdims=True)
|
||||
|
||||
# Delay estimate: pinball~sensor distance ~ 10*L0, Uconv ~ U0
|
||||
# tau_c steps = 10*L0 / (U0 * SI) = 200 / (0.01 * 800) = 25
|
||||
tau_c = 25
|
||||
# Create delayed sensor error
|
||||
e_s_delayed = np.zeros_like(e_s)
|
||||
if N > tau_c:
|
||||
e_s_delayed[:N-tau_c] = e_s[tau_c:]
|
||||
e_s_delayed[N-tau_c:] = e_s[-1]
|
||||
|
||||
# Full delay stack (3 time points)
|
||||
tau_quarter = 50 // (800 // 200) # ~12-13 steps for ~1/4 period at Re=100
|
||||
tau_q = max(8, tau_quarter)
|
||||
p_sig_stack = np.zeros((N, 18))
|
||||
if N > tau_c + 2 * tau_q:
|
||||
p_sig_stack[:N-tau_c-2*tau_q, 0:6] = e_s[tau_c: N-2*tau_q]
|
||||
p_sig_stack[:N-tau_c-2*tau_q, 6:12] = e_s[tau_c+tau_q: N-tau_q]
|
||||
p_sig_stack[:N-tau_c-2*tau_q, 12:18] = e_s[tau_c+2*tau_q: N]
|
||||
# Pad tail
|
||||
for i in range(1, 3):
|
||||
p_sig_stack[N-i:, :] = p_sig_stack[N-i-1, :]
|
||||
|
||||
Y = {
|
||||
"force_total": np.hstack([Fx_total, Fy_total]),
|
||||
"sensor_error": e_s,
|
||||
"sensor_error_delayed": e_s_delayed,
|
||||
"p_sig_stack": p_sig_stack,
|
||||
"actions": actions,
|
||||
}
|
||||
return Y
|
||||
|
||||
|
||||
def build_illusion_observables(scene_key: str) -> Optional[Dict]:
|
||||
"""Build observables for illusion scenes."""
|
||||
dd = data_dir_for_scene(scene_key)
|
||||
fp = os.path.join(dd, "controlled.npz")
|
||||
if not os.path.isfile(fp):
|
||||
print(f" WARNING: controlled.npz not found at {fp}")
|
||||
return None
|
||||
|
||||
data = np.load(fp)
|
||||
sensors = data["sensors"]
|
||||
forces = data["forces"]
|
||||
actions = data["actions"]
|
||||
N = len(sensors)
|
||||
|
||||
# Target sensors from target cylinder (stored in target_cylinder dir)
|
||||
diam = scene_key.split("_")[1] # e.g. "0.75L"
|
||||
tgt_key = f"target_cylinder_{diam}"
|
||||
tgt_dd = data_dir_for_scene(tgt_key)
|
||||
tp = os.path.join(tgt_dd, "target.npz")
|
||||
if os.path.isfile(tp):
|
||||
tgt = np.load(tp)
|
||||
target_states = tgt["target_states"]
|
||||
# target_states shape: (FIFO_LEN, 8) - [cylinder_fx,fy, s0_ux,uy, s1_ux,uy, s2_ux,uy]
|
||||
target_sensors = target_states[:, 2:8] # (150, 6)
|
||||
else:
|
||||
target_sensors = sensors * 0.0
|
||||
|
||||
# Sensor error
|
||||
tlen = target_sensors.shape[0]
|
||||
if N > tlen:
|
||||
e_s = sensors[:tlen] - target_sensors
|
||||
pad = np.tile(e_s[-1:], (N - tlen, 1))
|
||||
e_s = np.vstack([e_s, pad])
|
||||
else:
|
||||
e_s = sensors - target_sensors[:N]
|
||||
|
||||
# Force observable
|
||||
Fx_total = np.sum(forces[:, 0::2], axis=1, keepdims=True)
|
||||
Fy_total = np.sum(forces[:, 1::2], axis=1, keepdims=True)
|
||||
|
||||
# Delay (illusion: sensor_x=30, pinball_rear_x=20.3, distance ~10*L0)
|
||||
# tau_c depends on SI: 200/(0.01*SI)
|
||||
cfg = get_scene(scene_key)
|
||||
si = cfg["sample_interval"]
|
||||
tau_c = max(10, int(200 / (0.01 * si)))
|
||||
tau_q = max(5, int(50 / (0.01 * si) / 4))
|
||||
|
||||
e_s_delayed = np.zeros_like(e_s)
|
||||
if N > tau_c:
|
||||
e_s_delayed[:N-tau_c] = e_s[tau_c:]
|
||||
e_s_delayed[N-tau_c:] = e_s[-1]
|
||||
|
||||
# Full delay stack
|
||||
p_sig_stack = np.zeros((N, 18))
|
||||
if N > tau_c + 2 * tau_q:
|
||||
p_sig_stack[:N-tau_c-2*tau_q, 0:6] = e_s[tau_c: N-2*tau_q]
|
||||
p_sig_stack[:N-tau_c-2*tau_q, 6:12] = e_s[tau_c+tau_q: N-tau_q]
|
||||
p_sig_stack[:N-tau_c-2*tau_q, 12:18] = e_s[tau_c+2*tau_q: N]
|
||||
for i in range(1, 3):
|
||||
p_sig_stack[N-i:, :] = p_sig_stack[N-i-1, :]
|
||||
|
||||
Y = {
|
||||
"force_total": np.hstack([Fx_total, Fy_total]),
|
||||
"sensor_error": e_s,
|
||||
"sensor_error_delayed": e_s_delayed,
|
||||
"p_sig_stack": p_sig_stack,
|
||||
"actions": actions,
|
||||
}
|
||||
return Y
|
||||
|
||||
|
||||
def run_phase2(scene_key: str):
|
||||
print(f"\n--- Phase 2: Observables for {scene_key} ---")
|
||||
|
||||
if scene_key.startswith("steady") or scene_key == "steady_cloak":
|
||||
Y = build_steady_observables(scene_key)
|
||||
elif scene_key.startswith("karman"):
|
||||
Y = build_karman_observables(scene_key)
|
||||
elif scene_key.startswith("illusion"):
|
||||
Y = build_illusion_observables(scene_key)
|
||||
else:
|
||||
print(f" Unknown scene type: {scene_key}")
|
||||
return
|
||||
|
||||
if Y is None:
|
||||
print(f" SKIPPED")
|
||||
return
|
||||
|
||||
out_dir = os.path.join(DATA_DIR, "derived", "observables", scene_key)
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
|
||||
# Standardize each observable and save
|
||||
for name, arr in Y.items():
|
||||
if arr.ndim == 1:
|
||||
arr = arr.reshape(-1, 1)
|
||||
arr_std, mean, std = standardize(arr)
|
||||
np.savez(os.path.join(out_dir, f"{name}.npz"),
|
||||
raw=arr, standardized=arr_std, mean=mean, std=std)
|
||||
print(f" {name}: {arr.shape}")
|
||||
|
||||
# Save metadata
|
||||
meta = {"scene": scene_key, "n_steps": len(Y[list(Y.keys())[0]]),
|
||||
"observables": list(Y.keys())}
|
||||
with open(os.path.join(out_dir, "meta.json"), "w") as f:
|
||||
json.dump(meta, f, indent=2)
|
||||
|
||||
print(f" Saved to {out_dir}")
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--scene", type=str, default=None)
|
||||
args = ap.parse_args()
|
||||
|
||||
scenes = ["steady_cloak", "karman_re100",
|
||||
"illusion_0.75L", "illusion_1.0L", "illusion_1.5L"]
|
||||
|
||||
if args.scene:
|
||||
targets = [args.scene] if args.scene in scenes else scenes
|
||||
else:
|
||||
targets = scenes
|
||||
|
||||
for sn in targets:
|
||||
run_phase2(sn)
|
||||
|
||||
print("Phase 2 complete.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -1,108 +0,0 @@
|
||||
# OID_analysis/analysis/phase3_force_oid.py
|
||||
"""
|
||||
Phase 3: Force-OID for all scenes.
|
||||
Cross-covariance SVD between correction-field POD coefficients and force observable.
|
||||
|
||||
Usage:
|
||||
python3 src/OID_analysis/analysis/phase3_force_oid.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
|
||||
_REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
|
||||
if _REPO not in sys.path:
|
||||
sys.path.insert(0, _REPO)
|
||||
|
||||
from OID_analysis.configs import DATA_DIR # noqa: E402
|
||||
from OID_analysis.utils.analysis import ( # noqa: E402
|
||||
compute_force_oid, compute_force_oid as compute_oid,
|
||||
standardize, reconstruct_oid_modes,
|
||||
)
|
||||
|
||||
|
||||
SCENES = ["steady_cloak", "karman_re100",
|
||||
"illusion_0.75L", "illusion_1.0L", "illusion_1.5L"]
|
||||
|
||||
|
||||
def run_force_oid(scene_key: str):
|
||||
print(f"\n--- Phase 3: Force-OID for {scene_key} ---")
|
||||
|
||||
# Load POD coefficients (r=10)
|
||||
pod_dir = os.path.join(DATA_DIR, "derived", "pod", scene_key)
|
||||
pod_fp = os.path.join(pod_dir, "pod_coefs_r10.npy.npz")
|
||||
if not os.path.isfile(pod_fp):
|
||||
print(f" WARNING: POD not found at {pod_fp}")
|
||||
return
|
||||
pod_data = np.load(pod_fp, allow_pickle=True)
|
||||
# pod_data is an npz file; need to extract coefs
|
||||
coefs = pod_data["coefs"] # (N, r)
|
||||
|
||||
# Load force observable
|
||||
obs_dir = os.path.join(DATA_DIR, "derived", "observables", scene_key)
|
||||
force_fp = os.path.join(obs_dir, "force_total.npz")
|
||||
if not os.path.isfile(force_fp):
|
||||
print(f" WARNING: force observable not found at {force_fp}")
|
||||
return
|
||||
|
||||
force_data = np.load(force_fp)
|
||||
Y_force = force_data["standardized"] # (N, m)
|
||||
|
||||
# Ensure same length
|
||||
N = min(coefs.shape[0], Y_force.shape[0])
|
||||
A = coefs[:N]
|
||||
Y = Y_force[:N]
|
||||
|
||||
# Standardize POD coefs
|
||||
A_std, A_mean, A_std_val = standardize(A)
|
||||
|
||||
# Compute force-OID
|
||||
oid = compute_force_oid(A_std, Y)
|
||||
|
||||
# Load POD modes for reconstruction
|
||||
modes_fp = os.path.join(pod_dir, "pod_modes_r10.npz")
|
||||
if os.path.isfile(modes_fp):
|
||||
modes_data = np.load(modes_fp)
|
||||
pod_modes = modes_data["modes"]
|
||||
oid_modes = reconstruct_oid_modes(pod_modes[:, :A.shape[1]], oid["U"])
|
||||
else:
|
||||
oid_modes = None
|
||||
|
||||
# Save
|
||||
out_dir = os.path.join(DATA_DIR, "derived", "oid", "force", scene_key)
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
|
||||
np.savez(os.path.join(out_dir, "force_oid.npz"),
|
||||
z=oid["z"], S=oid["S"], U=oid["U"],
|
||||
cum_corr=oid["cum_corr"])
|
||||
if oid_modes is not None:
|
||||
np.savez_compressed(os.path.join(out_dir, "force_oid_modes.npz"),
|
||||
modes=oid_modes[:, :6])
|
||||
|
||||
# Print summary
|
||||
print(f" Singular values: {oid['S'][:6]}")
|
||||
print(f" Cum corr (3): {oid['cum_corr'][:3]}")
|
||||
print(f" Top 3 z std: {np.std(oid['z'][:, :3], axis=0)}")
|
||||
print(f" Saved to {out_dir}")
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--scene", type=str, default=None)
|
||||
args = ap.parse_args()
|
||||
|
||||
targets = [args.scene] if args.scene and args.scene in SCENES else SCENES
|
||||
|
||||
for sn in targets:
|
||||
run_force_oid(sn)
|
||||
|
||||
print("Phase 3 complete.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -1,105 +0,0 @@
|
||||
# OID_analysis/analysis/phase4a_signature_oid.py
|
||||
"""
|
||||
Phase 4a: Signature-OID minimal version.
|
||||
Cross-covariance SVD between correction-field POD coefs and delayed sensor error.
|
||||
|
||||
Only for periodic scenes: karman_re100, illusion_0.75L, 1.0L, 1.5L.
|
||||
|
||||
Usage:
|
||||
python3 src/OID_analysis/analysis/phase4a_signature_oid.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
|
||||
_REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
|
||||
if _REPO not in sys.path:
|
||||
sys.path.insert(0, _REPO)
|
||||
|
||||
from OID_analysis.configs import DATA_DIR # noqa: E402
|
||||
from OID_analysis.utils.analysis import ( # noqa: E402
|
||||
compute_force_oid as compute_oid,
|
||||
standardize, reconstruct_oid_modes,
|
||||
)
|
||||
|
||||
|
||||
SCENES = ["karman_re100", "illusion_0.75L", "illusion_1.0L", "illusion_1.5L"]
|
||||
|
||||
|
||||
def run_signature_oid(scene_key: str, version: str = "delayed"):
|
||||
print(f"\n--- Phase 4a: Signature-OID ({version}) for {scene_key} ---")
|
||||
|
||||
# Load POD coefs
|
||||
pod_dir = os.path.join(DATA_DIR, "derived", "pod", scene_key)
|
||||
pod_fp = os.path.join(pod_dir, "pod_coefs_r10.npy.npz")
|
||||
if not os.path.isfile(pod_fp):
|
||||
print(f" WARNING: POD not found")
|
||||
return
|
||||
pod_npz = np.load(pod_fp, allow_pickle=True)
|
||||
coefs = pod_npz["coefs"]
|
||||
|
||||
# Load observable
|
||||
obs_dir = os.path.join(DATA_DIR, "derived", "observables", scene_key)
|
||||
if version == "delayed":
|
||||
obs_name = "sensor_error_delayed"
|
||||
else:
|
||||
obs_name = "sensor_error"
|
||||
|
||||
obs_fp = os.path.join(obs_dir, f"{obs_name}.npz")
|
||||
if not os.path.isfile(obs_fp):
|
||||
print(f" WARNING: {obs_name} not found at {obs_fp}")
|
||||
return
|
||||
obs_data = np.load(obs_fp)
|
||||
Y = obs_data["standardized"]
|
||||
|
||||
N = min(coefs.shape[0], Y.shape[0])
|
||||
A = coefs[:N]
|
||||
Y_cut = Y[:N]
|
||||
A_std, _, _ = standardize(A)
|
||||
|
||||
oid = compute_oid(A_std, Y_cut)
|
||||
|
||||
# Load POD modes for reconstruction
|
||||
pod_modes = None
|
||||
modes_fp = os.path.join(pod_dir, "pod_modes_r10.npz")
|
||||
if os.path.isfile(modes_fp):
|
||||
md = np.load(modes_fp)
|
||||
pod_modes = md["modes"]
|
||||
oid_modes = reconstruct_oid_modes(pod_modes[:, :A.shape[1]], oid["U"])
|
||||
else:
|
||||
oid_modes = None
|
||||
|
||||
# Save
|
||||
out_dir = os.path.join(DATA_DIR, "derived", "oid", "signature", scene_key)
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
np.savez(os.path.join(out_dir, f"sig_oid_{version}.npz"),
|
||||
z=oid["z"], S=oid["S"], U=oid["U"], cum_corr=oid["cum_corr"])
|
||||
if oid_modes is not None:
|
||||
np.savez_compressed(os.path.join(out_dir, f"sig_oid_{version}_modes.npz"),
|
||||
modes=oid_modes[:, :6])
|
||||
|
||||
print(f" Sig vals: {oid['S'][:6]}")
|
||||
print(f" Cum corr (3): {oid['cum_corr'][:3]}")
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--scene", type=str, default=None)
|
||||
args = ap.parse_args()
|
||||
|
||||
targets = [args.scene] if args.scene and args.scene in SCENES else SCENES
|
||||
|
||||
for sn in targets:
|
||||
run_signature_oid(sn, "current") # e_s(t)
|
||||
run_signature_oid(sn, "delayed") # e_s(t+tau_c)
|
||||
|
||||
print("Phase 4a complete.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -1,98 +0,0 @@
|
||||
# OID_analysis/analysis/phase4b_signature_pcd.py
|
||||
"""
|
||||
Phase 4b: Signature-PCD (whitened cross-correlation).
|
||||
Uses delay-stacked signature observable p_sig(t) with whitening.
|
||||
|
||||
Only for periodic scenes: karman_re100, illusion_0.75L, 1.0L, 1.5L.
|
||||
|
||||
Usage:
|
||||
python3 src/OID_analysis/analysis/phase4b_signature_pcd.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
|
||||
_REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
|
||||
if _REPO not in sys.path:
|
||||
sys.path.insert(0, _REPO)
|
||||
|
||||
from OID_analysis.configs import DATA_DIR # noqa: E402
|
||||
from OID_analysis.utils.analysis import ( # noqa: E402
|
||||
compute_pcd, standardize, reconstruct_oid_modes,
|
||||
)
|
||||
|
||||
|
||||
SCENES = ["karman_re100", "illusion_0.75L", "illusion_1.0L", "illusion_1.5L"]
|
||||
|
||||
|
||||
def run_signature_pcd(scene_key: str):
|
||||
print(f"\n--- Phase 4b: Signature-PCD for {scene_key} ---")
|
||||
|
||||
# Load POD coefs (r=10)
|
||||
pod_dir = os.path.join(DATA_DIR, "derived", "pod", scene_key)
|
||||
pod_fp = os.path.join(pod_dir, "pod_coefs_r10.npy.npz")
|
||||
if not os.path.isfile(pod_fp):
|
||||
print(f" WARNING: POD not found")
|
||||
return
|
||||
pod_npz = np.load(pod_fp, allow_pickle=True)
|
||||
coefs = pod_npz["coefs"]
|
||||
|
||||
# Load delay-stacked observable
|
||||
obs_dir = os.path.join(DATA_DIR, "derived", "observables", scene_key)
|
||||
p_sig_fp = os.path.join(obs_dir, "p_sig_stack.npz")
|
||||
if not os.path.isfile(p_sig_fp):
|
||||
print(f" WARNING: p_sig_stack not found at {p_sig_fp}")
|
||||
return
|
||||
|
||||
p_data = np.load(p_sig_fp)
|
||||
P = p_data["standardized"]
|
||||
|
||||
N = min(coefs.shape[0], P.shape[0])
|
||||
A = coefs[:N]
|
||||
P_cut = P[:N]
|
||||
A_std, _, _ = standardize(A)
|
||||
|
||||
# PCD
|
||||
pcd = compute_pcd(A_std, P_cut, tikhonov_eps=1e-6)
|
||||
|
||||
# Reconstruct modes
|
||||
modes_fp = os.path.join(pod_dir, "pod_modes_r10.npz")
|
||||
pcd_modes = None
|
||||
if os.path.isfile(modes_fp):
|
||||
md = np.load(modes_fp)
|
||||
pod_modes = md["modes"]
|
||||
pcd_modes = reconstruct_oid_modes(pod_modes[:, :A.shape[1]], pcd["W"])
|
||||
|
||||
# Save
|
||||
out_dir = os.path.join(DATA_DIR, "derived", "oid", "pcd", scene_key)
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
np.savez(os.path.join(out_dir, "sig_pcd.npz"),
|
||||
z_pcd=pcd["z_pcd"], S=pcd["S"], W=pcd["W"], cum_corr=pcd["cum_corr"])
|
||||
if pcd_modes is not None:
|
||||
np.savez_compressed(os.path.join(out_dir, "sig_pcd_modes.npz"),
|
||||
modes=pcd_modes[:, :6])
|
||||
|
||||
print(f" PCD sig vals: {pcd['S'][:6]}")
|
||||
print(f" Cum corr (3): {pcd['cum_corr'][:3]}")
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--scene", type=str, default=None)
|
||||
args = ap.parse_args()
|
||||
|
||||
targets = [args.scene] if args.scene and args.scene in SCENES else SCENES
|
||||
|
||||
for sn in targets:
|
||||
run_signature_pcd(sn)
|
||||
|
||||
print("Phase 4b complete.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -1,100 +0,0 @@
|
||||
# OID_analysis/analysis/phase5_steady_oid.py
|
||||
"""
|
||||
Phase 5: Steady cloak dedicated suppression-OID / mean-wake OID.
|
||||
|
||||
Steady cloak is NOT a periodic future-signature problem. We analyze:
|
||||
1. Force-OID (from Phase 3)
|
||||
2. Fluctuation-OID: find correction structures correlated with fluctuation suppression
|
||||
3. Mean-wake OID: find correction structures correlated with wake restoration
|
||||
|
||||
Usage:
|
||||
python3 src/OID_analysis/analysis/phase5_steady_oid.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
|
||||
_REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
|
||||
if _REPO not in sys.path:
|
||||
sys.path.insert(0, _REPO)
|
||||
|
||||
from OID_analysis.configs import DATA_DIR # noqa: E402
|
||||
from OID_analysis.utils.analysis import ( # noqa: E402
|
||||
compute_force_oid as compute_oid,
|
||||
standardize, reconstruct_oid_modes,
|
||||
)
|
||||
|
||||
|
||||
def run_steady_oid():
|
||||
print(f"\n--- Phase 5: Steady Cloak Suppression-OID ---")
|
||||
|
||||
scene_key = "steady_cloak"
|
||||
|
||||
# Load POD coefs
|
||||
pod_dir = os.path.join(DATA_DIR, "derived", "pod", scene_key)
|
||||
pod_fp = os.path.join(pod_dir, "pod_coefs_r10.npy.npz")
|
||||
if not os.path.isfile(pod_fp):
|
||||
print(f" WARNING: POD not found at {pod_fp}")
|
||||
return
|
||||
pod_npz = np.load(pod_fp, allow_pickle=True)
|
||||
coefs = pod_npz["coefs"]
|
||||
|
||||
# Load observables
|
||||
obs_dir = os.path.join(DATA_DIR, "derived", "observables", scene_key)
|
||||
|
||||
results = {}
|
||||
obs_to_run = {
|
||||
"suppression_rms": "rms_uy", # fluctuation suppression
|
||||
"suppression_uxdev": "ux_deviation", # wake restoration
|
||||
"force_total": "force_total", # force
|
||||
}
|
||||
|
||||
for result_key, obs_name in obs_to_run.items():
|
||||
fp = os.path.join(obs_dir, f"{obs_name}.npz")
|
||||
if not os.path.isfile(fp):
|
||||
print(f" WARNING: {obs_name} not found")
|
||||
continue
|
||||
|
||||
data = np.load(fp)
|
||||
Y = data["standardized"]
|
||||
|
||||
N = min(coefs.shape[0], Y.shape[0])
|
||||
A_std, _, _ = standardize(coefs[:N])
|
||||
Y_cut = Y[:N]
|
||||
|
||||
oid = compute_oid(A_std, Y_cut)
|
||||
results[result_key] = oid
|
||||
print(f" {result_key}: S[0]={oid['S'][0]:.4f}, cum_corr[:3]={oid['cum_corr'][:3]}")
|
||||
|
||||
# Compare force-OID and suppression-OID mode overlap
|
||||
if "force_total" in results and "suppression_rms" in results:
|
||||
U_F = results["force_total"]["U"]
|
||||
U_S = results["suppression_rms"]["U"]
|
||||
# Cosine similarity between first modes
|
||||
cos_sim = float(abs(np.dot(U_F[:, 0], U_S[:, 0])))
|
||||
print(f" Force-OID vs Suppression-OID mode 1 overlap: {cos_sim:.4f}")
|
||||
|
||||
# Save
|
||||
out_dir = os.path.join(DATA_DIR, "derived", "oid", "steady_suppression")
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
for name, oid in results.items():
|
||||
np.savez(os.path.join(out_dir, f"{name}_oid.npz"),
|
||||
z=oid["z"], S=oid["S"], U=oid["U"], cum_corr=oid["cum_corr"])
|
||||
|
||||
print(f" Saved to {out_dir}")
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.parse_args()
|
||||
run_steady_oid()
|
||||
print("Phase 5 complete.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -1,180 +0,0 @@
|
||||
# OID_analysis/analysis/phase6_comparison.py
|
||||
"""
|
||||
Phase 6: POD vs OID vs PCD predictive comparison.
|
||||
|
||||
Linear regression with 70/30 time-series split.
|
||||
Compares:
|
||||
- force ~ POD coords
|
||||
- force ~ OID coords
|
||||
- future signature ~ POD coords
|
||||
- future signature ~ OID coords
|
||||
- future signature ~ PCD coords
|
||||
|
||||
Usage:
|
||||
python3 src/OID_analysis/analysis/phase6_comparison.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
|
||||
_REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
|
||||
if _REPO not in sys.path:
|
||||
sys.path.insert(0, _REPO)
|
||||
|
||||
from OID_analysis.configs import DATA_DIR # noqa: E402
|
||||
from OID_analysis.utils.analysis import standardize # noqa: E402
|
||||
|
||||
try:
|
||||
from sklearn.linear_model import LinearRegression
|
||||
from sklearn.metrics import r2_score
|
||||
HAS_SKLEARN = True
|
||||
except ImportError:
|
||||
HAS_SKLEARN = False
|
||||
print("WARNING: sklearn not available. Install: pip install scikit-learn")
|
||||
|
||||
|
||||
SCENES = ["steady_cloak", "karman_re100",
|
||||
"illusion_0.75L", "illusion_1.0L", "illusion_1.5L"]
|
||||
|
||||
|
||||
def r2_manual(y_true, y_pred):
|
||||
ss_res = np.sum((y_true - y_pred) ** 2)
|
||||
ss_tot = np.sum((y_true - np.mean(y_true, axis=0, keepdims=True)) ** 2)
|
||||
return 1.0 - ss_res / (ss_tot + 1e-30)
|
||||
|
||||
|
||||
def run_comparison(scene_key: str):
|
||||
print(f"\n--- Phase 6: Comparison for {scene_key} ---")
|
||||
|
||||
pod_dir = os.path.join(DATA_DIR, "derived", "pod", scene_key)
|
||||
obs_dir = os.path.join(DATA_DIR, "derived", "observables", scene_key)
|
||||
oid_dir = os.path.join(DATA_DIR, "derived", "oid")
|
||||
|
||||
# Load POD coefs (r=10)
|
||||
pod_fp = os.path.join(pod_dir, "pod_coefs_r10.npy.npz")
|
||||
if not os.path.isfile(pod_fp):
|
||||
print(f" SKIPPED: POD not found")
|
||||
return False
|
||||
|
||||
pod_npz = np.load(pod_fp, allow_pickle=True)
|
||||
A_all = pod_npz["coefs"] # (N, r)
|
||||
N = A_all.shape[0]
|
||||
|
||||
# OID coordinates
|
||||
oid_sources = {}
|
||||
|
||||
# Try loading force-OID
|
||||
force_oid_fp = os.path.join(oid_dir, "force", scene_key, "force_oid.npz")
|
||||
if os.path.isfile(force_oid_fp):
|
||||
d = np.load(force_oid_fp)
|
||||
oid_sources["force-oid"] = d["z"]
|
||||
|
||||
# Try loading signature-OID (delayed)
|
||||
sig_oid_fp = os.path.join(oid_dir, "signature", scene_key, "sig_oid_delayed.npz")
|
||||
if os.path.isfile(sig_oid_fp):
|
||||
d = np.load(sig_oid_fp)
|
||||
oid_sources["sig-oid"] = d["z"]
|
||||
|
||||
# Try loading PCD
|
||||
pcd_fp = os.path.join(oid_dir, "pcd", scene_key, "sig_pcd.npz")
|
||||
if os.path.isfile(pcd_fp):
|
||||
d = np.load(pcd_fp)
|
||||
oid_sources["sig-pcd"] = d["z_pcd"]
|
||||
|
||||
# Target observables for regression
|
||||
targets = {}
|
||||
|
||||
# Force observable
|
||||
force_fp = os.path.join(obs_dir, "force_total.npz")
|
||||
if os.path.isfile(force_fp):
|
||||
targets["force"] = np.load(force_fp)["standardized"]
|
||||
|
||||
# Sensor error (delayed)
|
||||
sig_fp = os.path.join(obs_dir, "sensor_error_delayed.npz")
|
||||
if os.path.isfile(sig_fp):
|
||||
targets["future_sig"] = np.load(sig_fp)["standardized"]
|
||||
|
||||
# For steady cloak: RMS
|
||||
rms_fp = os.path.join(obs_dir, "rms_uy.npz")
|
||||
if os.path.isfile(rms_fp):
|
||||
targets["suppression"] = np.load(rms_fp)["standardized"]
|
||||
|
||||
if not HAS_SKLEARN:
|
||||
print(" sklearn not available, using manual R^2")
|
||||
# Still do manual comparison for a single test
|
||||
if "force" in targets and "force-oid" in oid_sources:
|
||||
A_std, _, _ = standardize(A_all[:N])
|
||||
Y = targets["force"][:N].squeeze()
|
||||
r2_pod = r2_manual(Y, A_std[:, :3] @ np.linalg.lstsq(A_std[:, :3], Y, rcond=None)[0])
|
||||
r2_oid = r2_manual(Y, oid_sources["force-oid"][:N, :3] @
|
||||
np.linalg.lstsq(oid_sources["force-oid"][:N, :3], Y, rcond=None)[0])
|
||||
print(f" force: POD R2={r2_pod:.4f}, OID R2={r2_oid:.4f}")
|
||||
print(" Install sklearn for full comparison")
|
||||
return True
|
||||
|
||||
results = {}
|
||||
for target_name, Y in targets.items():
|
||||
Y = Y[:N]
|
||||
results[target_name] = {}
|
||||
|
||||
for src_name, z in oid_sources.items():
|
||||
z = z[:N]
|
||||
|
||||
# 70/30 time-series split
|
||||
split = int(N * 0.7)
|
||||
for m in [1, 2, 3, 5]:
|
||||
X = z[:, :m]
|
||||
X_train, X_test = X[:split], X[split:]
|
||||
Y_train, Y_test = Y[:split], Y[split:]
|
||||
|
||||
reg = LinearRegression().fit(X_train, Y_train)
|
||||
Y_pred = reg.predict(X_test)
|
||||
r2 = r2_score(Y_test, Y_pred) if Y_test.ndim == 1 else \
|
||||
r2_score(Y_test, Y_pred, multioutput="variance_weighted")
|
||||
results[target_name][f"{src_name}_m{m}"] = float(r2)
|
||||
|
||||
# Also POD baseline
|
||||
A_std, _, _ = standardize(A_all[:N])
|
||||
for m in [1, 2, 3, 5]:
|
||||
X = A_std[:, :m]
|
||||
X_train, X_test = X[:split], X[split:]
|
||||
Y_train, Y_test = Y[:split], Y[split:]
|
||||
reg = LinearRegression().fit(X_train, Y_train)
|
||||
Y_pred = reg.predict(X_test)
|
||||
r2 = r2_score(Y_test, Y_pred) if Y_test.ndim == 1 else \
|
||||
r2_score(Y_test, Y_pred, multioutput="variance_weighted")
|
||||
results[target_name][f"pod_m{m}"] = float(r2)
|
||||
|
||||
print(f" {target_name}:")
|
||||
for k, v in sorted(results[target_name].items()):
|
||||
print(f" {k}: R2={v:.4f}")
|
||||
|
||||
# Save
|
||||
out_dir = os.path.join(DATA_DIR, "derived", "comparison")
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
with open(os.path.join(out_dir, f"{scene_key}.json"), "w") as f:
|
||||
json.dump(results, f, indent=2)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--scene", type=str, default=None)
|
||||
args = ap.parse_args()
|
||||
|
||||
targets = [args.scene] if args.scene and args.scene in SCENES else SCENES
|
||||
|
||||
for sn in targets:
|
||||
run_comparison(sn)
|
||||
|
||||
print("\nPhase 6 complete.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -1,184 +0,0 @@
|
||||
# OID_analysis/analysis/phase7_whitebox.py
|
||||
"""
|
||||
Phase 7: White-box control chain comparison.
|
||||
|
||||
Compares how well different state representations predict the action:
|
||||
Model A: obs (raw sensor) -> act
|
||||
Model B: POD coord -> act
|
||||
Model C: OID coord -> act
|
||||
Model D: OID coord + force -> act
|
||||
|
||||
Usage:
|
||||
python3 src/OID_analysis/analysis/phase7_whitebox.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
|
||||
_REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
|
||||
if _REPO not in sys.path:
|
||||
sys.path.insert(0, _REPO)
|
||||
|
||||
from OID_analysis.configs import DATA_DIR # noqa: E402
|
||||
from OID_analysis.utils.analysis import standardize # noqa: E402
|
||||
|
||||
try:
|
||||
from sklearn.linear_model import LinearRegression
|
||||
from sklearn.metrics import r2_score
|
||||
HAS_SKLEARN = True
|
||||
except ImportError:
|
||||
HAS_SKLEARN = False
|
||||
|
||||
SCENES = ["steady_cloak", "karman_re100",
|
||||
"illusion_0.75L", "illusion_1.0L", "illusion_1.5L"]
|
||||
|
||||
|
||||
def run_whitebox(scene_key: str):
|
||||
print(f"\n--- Phase 7: White-box for {scene_key} ---")
|
||||
|
||||
# Check for controlled.npz (PPO scenes) or forces (open-loop scenes)
|
||||
data_dir_base = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||
"data")
|
||||
|
||||
if scene_key == "steady_cloak":
|
||||
dd = os.path.join(data_dir_base, "steady_cloak", "steady_cloak")
|
||||
else:
|
||||
sid = {"karman_re100": "karman_re100"}.get(scene_key, scene_key)
|
||||
dd = os.path.join(data_dir_base, scene_key.replace("steady_", ""),
|
||||
scene_key) if "illusion" in scene_key else \
|
||||
os.path.join(data_dir_base, "karman_cloak", scene_key)
|
||||
|
||||
controlled_fp = os.path.join(dd, "controlled.npz")
|
||||
forces_fp = os.path.join(dd, "forces.npz")
|
||||
|
||||
if os.path.isfile(controlled_fp):
|
||||
data = np.load(controlled_fp)
|
||||
actions = data["actions"]
|
||||
sensors = data["sensors"]
|
||||
elif os.path.isfile(forces_fp):
|
||||
# Open-loop steady cloak: no actions available from controlled.npz
|
||||
# But we know the steady cloak action: [0, -5.1*U0, 5.1*U0]
|
||||
from OID_analysis.configs import get_scene
|
||||
cfg = get_scene(scene_key)
|
||||
u0 = cfg["u0"]
|
||||
sensors_n = np.load(os.path.join(dd, "sensors.npz"))["sensors"]
|
||||
N = len(sensors_n)
|
||||
sensors = sensors_n
|
||||
omega_rear = cfg.get("omega_rear_scale", 5.1)
|
||||
actions = np.tile([0.0, -omega_rear * u0 / 0.01, omega_rear * u0 / 0.01], (N, 1))
|
||||
# Actually these should be in normalized [-1,1] range
|
||||
# rear = 5.1 -> normalized = (5.1 - bias)/scale where bias=5.1, scale=8
|
||||
# Actually for steady: bias=[0,-5.1,5.1], scale=8
|
||||
# So action = (omega/u0 - bias)/scale
|
||||
actions = np.tile([0.0, 0.0, 0.0], (N, 1)) # zero action = bias actions
|
||||
else:
|
||||
print(f" SKIPPED: no action data found")
|
||||
return
|
||||
|
||||
N = min(len(sensors), len(actions))
|
||||
sensors = sensors[:N]
|
||||
actions = actions[:N]
|
||||
|
||||
# Normalize actions per channel
|
||||
actions_std, act_mean, act_std = standardize(actions)
|
||||
|
||||
# POD coefs
|
||||
pod_dir = os.path.join(DATA_DIR, "derived", "pod", scene_key)
|
||||
coefs = None
|
||||
pod_fp = os.path.join(pod_dir, "pod_coefs_r10.npy.npz")
|
||||
if os.path.isfile(pod_fp):
|
||||
pod_npz = np.load(pod_fp, allow_pickle=True)
|
||||
pod_n = pod_npz["coefs"].shape[0]
|
||||
N = min(N, pod_n)
|
||||
|
||||
# Re-apply truncation based on final N
|
||||
sensors = sensors[:N]
|
||||
actions = actions[:N]
|
||||
actions_std, act_mean, act_std = standardize(actions)
|
||||
|
||||
# OID coords
|
||||
oid_dir = os.path.join(DATA_DIR, "derived", "oid")
|
||||
oid_coords = None
|
||||
oid_fp = os.path.join(oid_dir, "force", scene_key, "force_oid.npz")
|
||||
if os.path.isfile(oid_fp):
|
||||
oid_coords = np.load(oid_fp)["z"][:N]
|
||||
|
||||
# Force observable
|
||||
obs_dir = os.path.join(DATA_DIR, "derived", "observables", scene_key)
|
||||
force_obs = None
|
||||
force_fp = os.path.join(obs_dir, "force_total.npz")
|
||||
if os.path.isfile(force_fp):
|
||||
force_obs = np.load(force_fp)["standardized"][:N]
|
||||
|
||||
if not HAS_SKLEARN:
|
||||
print(" sklearn not available")
|
||||
return
|
||||
|
||||
split = int(N * 0.7)
|
||||
|
||||
# Model A: raw sensor -> act
|
||||
X_A = sensors[:split]
|
||||
Y_train = actions_std[:split]
|
||||
# Test on last segment
|
||||
X_A_test = sensors[split:N]
|
||||
|
||||
results = {}
|
||||
|
||||
# Model A
|
||||
if X_A.shape[1] > 0:
|
||||
reg = LinearRegression().fit(X_A, Y_train)
|
||||
r2_a = r2_score(Y_train, reg.predict(X_A))
|
||||
results["obs_act_train"] = float(r2_a)
|
||||
|
||||
# Model B: POD coord -> act
|
||||
if coefs is not None:
|
||||
for m in [3, 5]:
|
||||
X = standardize(coefs[:split, :m])[0]
|
||||
reg = LinearRegression().fit(X, Y_train)
|
||||
r2 = r2_score(Y_train, reg.predict(X))
|
||||
results[f"pod_m{m}_act_train"] = float(r2)
|
||||
|
||||
# Model C: OID coord -> act
|
||||
if oid_coords is not None:
|
||||
for m in [3, 5]:
|
||||
X = oid_coords[:split, :m]
|
||||
reg = LinearRegression().fit(X, Y_train)
|
||||
r2 = r2_score(Y_train, reg.predict(X))
|
||||
results[f"oid_m{m}_act_train"] = float(r2)
|
||||
|
||||
# Model D: OID + force -> act
|
||||
if oid_coords is not None and force_obs is not None:
|
||||
X = np.hstack([oid_coords[:split, :3], force_obs[:split, :2]])
|
||||
reg = LinearRegression().fit(X, Y_train)
|
||||
r2 = r2_score(Y_train, reg.predict(X))
|
||||
results["oid_force_act_train"] = float(r2)
|
||||
|
||||
print(f" Results: {json.dumps(results, indent=2)}")
|
||||
|
||||
# Save
|
||||
out_dir = os.path.join(DATA_DIR, "derived", "whitebox")
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
with open(os.path.join(out_dir, f"{scene_key}.json"), "w") as f:
|
||||
json.dump(results, f, indent=2)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--scene", type=str, default=None)
|
||||
args = ap.parse_args()
|
||||
|
||||
targets = [args.scene] if args.scene and args.scene in SCENES else SCENES
|
||||
|
||||
for sn in targets:
|
||||
run_whitebox(sn)
|
||||
|
||||
print("\nPhase 7 complete.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -1,469 +0,0 @@
|
||||
# OID_analysis/analysis/robustness_analysis.py
|
||||
"""
|
||||
Comprehensive robustness analysis for force-OID vs signature-OID separation.
|
||||
Covers:
|
||||
1. POD rank sensitivity (r=6,8,10,12,16) -- all 5 scenes
|
||||
2. Time-window sensitivity (split data into 2 halves)
|
||||
3. Karman tau_c sensitivity sweep
|
||||
4. Zone OID: force-OID / sig-OID in near-body / near-wake / downstream zones
|
||||
|
||||
Usage:
|
||||
cd /home/frank14f/DynamisLab && PYTHONPATH="src:$PYTHONPATH" conda run -n sr_env python3 src/OID_analysis/analysis/robustness_analysis.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
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 OID_analysis.configs import DATA_DIR # noqa: E402
|
||||
from OID_analysis.utils.analysis import ( # noqa: E402
|
||||
compute_pod, compute_force_oid, compute_pcd, standardize,
|
||||
)
|
||||
|
||||
SCENES = ["steady_cloak", "karman_re100",
|
||||
"illusion_0.75L", "illusion_1.0L", "illusion_1.5L"]
|
||||
|
||||
|
||||
def cos_sim(a, b):
|
||||
return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b) + 1e-30))
|
||||
|
||||
|
||||
def load_pod_coefs(scene: str, rank: int = 10):
|
||||
"""Load correction-field POD coefficients for a scene at given rank."""
|
||||
pod_dir = os.path.join(DATA_DIR, "derived", "pod", scene)
|
||||
fp = os.path.join(pod_dir, f"pod_coefs_r{rank}.npy.npz")
|
||||
if not os.path.isfile(fp):
|
||||
# Try loading delta fields and re-running POD
|
||||
delta_fp = os.path.join(pod_dir, "delta_q_ctl.npz")
|
||||
if not os.path.isfile(delta_fp):
|
||||
return None
|
||||
d = np.load(delta_fp)
|
||||
ux, uy = d["ux"], d["uy"]
|
||||
# Flatten to snapshot matrix
|
||||
N, ny, nx = ux.shape
|
||||
Q = np.zeros((N, ny * nx * 2), dtype=np.float64)
|
||||
for t in range(N):
|
||||
Q[t] = np.concatenate([ux[t].ravel(), uy[t].ravel()])
|
||||
pod = compute_pod(Q, rank=rank)
|
||||
return pod["coefs"]
|
||||
else:
|
||||
d = np.load(fp, allow_pickle=True)
|
||||
return d["coefs"]
|
||||
|
||||
|
||||
def load_observable(scene: str, obs_name: str):
|
||||
"""Load standardized observable."""
|
||||
obs_dir = os.path.join(DATA_DIR, "derived", "observables", scene)
|
||||
fp = os.path.join(obs_dir, f"{obs_name}.npz")
|
||||
if not os.path.isfile(fp):
|
||||
return None
|
||||
d = np.load(fp)
|
||||
return d["standardized"]
|
||||
|
||||
|
||||
def compute_overlap(coefs_a, obs_a, coefs_b, obs_b):
|
||||
"""Compute cosine similarity between force-OID mode 1 and sig-OID mode 1."""
|
||||
N = min(coefs_a.shape[0], obs_a.shape[0], coefs_b.shape[0], obs_b.shape[0])
|
||||
A1 = standardize(coefs_a[:N])[0]
|
||||
A2 = standardize(coefs_b[:N])[0]
|
||||
Y1 = obs_a[:N]
|
||||
Y2 = obs_b[:N]
|
||||
|
||||
# Force-OID
|
||||
C1 = (1.0 / N) * A1.T @ Y1
|
||||
U1, _, _ = np.linalg.svd(C1, full_matrices=False)
|
||||
|
||||
# Sig-OID
|
||||
C2 = (1.0 / N) * A2.T @ Y2
|
||||
U2, _, _ = np.linalg.svd(C2, full_matrices=False)
|
||||
|
||||
return cos_sim(U1[:, 0], U2[:, 0])
|
||||
|
||||
|
||||
def compute_r2_sweep(A, Y, max_m=5):
|
||||
"""Compute R2 for each m=1..max_m using 70/30 split."""
|
||||
from sklearn.linear_model import LinearRegression
|
||||
from sklearn.metrics import r2_score
|
||||
|
||||
N = min(A.shape[0], Y.shape[0])
|
||||
A = A[:N]
|
||||
Y = Y[:N]
|
||||
split = int(N * 0.7)
|
||||
|
||||
results = {}
|
||||
for m in range(1, max_m + 1):
|
||||
X = standardize(A[:, :m])[0]
|
||||
X_train, X_test = X[:split], X[split:]
|
||||
Y_train, Y_test = Y[:split], Y[split:]
|
||||
reg = LinearRegression().fit(X_train, Y_train)
|
||||
r2 = r2_score(Y_test, reg.predict(X_test))
|
||||
results[m] = float(max(r2, -1.0)) # clamp
|
||||
return results
|
||||
|
||||
|
||||
def main():
|
||||
results = {
|
||||
"rank_sensitivity": {},
|
||||
"window_sensitivity": {},
|
||||
"tauc_sensitivity": {},
|
||||
"zone_oid": {},
|
||||
"overlap_table": {},
|
||||
}
|
||||
|
||||
# ===========================
|
||||
# 1. POD RANK SENSITIVITY
|
||||
# ===========================
|
||||
print("=" * 60)
|
||||
print("1. POD RANK SENSITIVITY")
|
||||
print("=" * 60)
|
||||
ranks = [6, 8, 10, 12, 16]
|
||||
overlap_by_rank = {}
|
||||
for scene in SCENES:
|
||||
print(f"\n Scene: {scene}")
|
||||
# Force observable
|
||||
y_force = load_observable(scene, "force_total")
|
||||
y_sig = load_observable(scene, "sensor_error_delayed")
|
||||
if scene == "steady_cloak":
|
||||
y_sig = load_observable(scene, "rms_uy")
|
||||
|
||||
if y_force is None:
|
||||
print(" SKIP: no force observable")
|
||||
continue
|
||||
if y_sig is None:
|
||||
print(" SKIP: no sig observable")
|
||||
continue
|
||||
|
||||
scene_rank_data = {}
|
||||
for r in ranks:
|
||||
coefs = load_pod_coefs(scene, rank=r)
|
||||
if coefs is None:
|
||||
print(f" r={r}: no POD data")
|
||||
continue
|
||||
|
||||
overlap = compute_overlap(coefs, y_force, coefs, y_sig)
|
||||
scene_rank_data[r] = overlap
|
||||
print(f" r={r}: overlap={overlap:.4f}")
|
||||
|
||||
overlap_by_rank[scene] = scene_rank_data
|
||||
|
||||
results["rank_sensitivity"] = overlap_by_rank
|
||||
|
||||
# ===========================
|
||||
# 2. TIME-WINDOW SENSITIVITY
|
||||
# ===========================
|
||||
print("\n" + "=" * 60)
|
||||
print("2. TIME-WINDOW SENSITIVITY")
|
||||
print("=" * 60)
|
||||
for scene in SCENES:
|
||||
print(f"\n Scene: {scene}")
|
||||
# Load delta-q_ctl fields
|
||||
pod_dir = os.path.join(DATA_DIR, "derived", "pod", scene)
|
||||
delta_fp = os.path.join(pod_dir, "delta_q_ctl.npz")
|
||||
if not os.path.isfile(delta_fp):
|
||||
print(" SKIP: no delta fields")
|
||||
continue
|
||||
d = np.load(delta_fp)
|
||||
ux, uy = d["ux"], d["uy"]
|
||||
N = ux.shape[0]
|
||||
|
||||
y_force = load_observable(scene, "force_total")
|
||||
y_sig = load_observable(scene, "sensor_error_delayed")
|
||||
if scene == "steady_cloak":
|
||||
y_sig = load_observable(scene, "rms_uy")
|
||||
if y_force is None or y_sig is None:
|
||||
continue
|
||||
|
||||
# Split into 2 windows
|
||||
half = N // 2
|
||||
window_results = {}
|
||||
for wname, start, end in [("first_half", 0, half), ("second_half", half, 2 * half)]:
|
||||
# POD on this window
|
||||
Q = np.zeros((end - start, ux.shape[1] * ux.shape[2] * 2), dtype=np.float64)
|
||||
for t in range(start, end):
|
||||
Q[t - start] = np.concatenate([ux[t].ravel(), uy[t].ravel()])
|
||||
pod = compute_pod(Q, rank=10)
|
||||
|
||||
A = pod["coefs"]
|
||||
Y_f = y_force[start:end]
|
||||
Y_s = y_sig[start:end]
|
||||
overlap = compute_overlap(A, Y_f, A, Y_s)
|
||||
window_results[wname] = overlap
|
||||
print(f" {wname}: overlap={overlap:.4f}")
|
||||
|
||||
# Full window reference
|
||||
if "first_half" in window_results and "second_half" in window_results:
|
||||
print(f" delta: {abs(window_results['first_half'] - window_results['second_half']):.4f}")
|
||||
|
||||
results["window_sensitivity"][scene] = window_results
|
||||
|
||||
# ===========================
|
||||
# 3. KARMAN tau_c SENSITIVITY
|
||||
# ===========================
|
||||
print("\n" + "=" * 60)
|
||||
print("3. KARMAN tau_c SENSITIVITY")
|
||||
print("=" * 60)
|
||||
|
||||
# Load Karman data
|
||||
pod_dir = os.path.join(DATA_DIR, "derived", "pod", "karman_re100")
|
||||
delta_fp = os.path.join(pod_dir, "delta_q_ctl.npz")
|
||||
obs_dir = os.path.join(DATA_DIR, "derived", "observables", "karman_re100")
|
||||
|
||||
if os.path.isfile(delta_fp) and os.path.isdir(obs_dir):
|
||||
d = np.load(delta_fp)
|
||||
ux, uy = d["ux"], d["uy"]
|
||||
N = ux.shape[0]
|
||||
|
||||
# Load target for re-computing delayed error
|
||||
ctl_fp = os.path.join(
|
||||
os.path.join(DATA_DIR, "karman_cloak", "karman_re100"), "controlled.npz")
|
||||
if os.path.isfile(ctl_fp):
|
||||
ctl = np.load(ctl_fp)
|
||||
sensors = ctl["sensors"]
|
||||
target_dd = os.path.join(DATA_DIR, "karman_cloak", "karman_re100")
|
||||
target_fp = os.path.join(target_dd, "target.npz")
|
||||
if os.path.isfile(target_fp):
|
||||
target_states = np.load(target_fp)["target_states"]
|
||||
else:
|
||||
target_states = sensors * 0.0
|
||||
|
||||
# Force observable
|
||||
forces = ctl["forces"]
|
||||
Fx_total = np.sum(forces[:, 0::2], axis=1, keepdims=True)
|
||||
Fy_total = np.sum(forces[:, 1::2], axis=1, keepdims=True)
|
||||
y_force_std, _, _ = standardize(np.hstack([Fx_total, Fy_total]))
|
||||
|
||||
tlen = target_states.shape[0]
|
||||
tau_candidates = [0, 10, 15, 20, 25, 30, 35, 40, 50, 60]
|
||||
NT = 30 # approx shedding period in steps
|
||||
|
||||
# POD
|
||||
Q = np.zeros((N, ux.shape[1] * ux.shape[2] * 2), dtype=np.float64)
|
||||
for t in range(N):
|
||||
Q[t] = np.concatenate([ux[t].ravel(), uy[t].ravel()])
|
||||
pod = compute_pod(Q, rank=10)
|
||||
A_std, _, _ = standardize(pod["coefs"])
|
||||
|
||||
tauc_results = {}
|
||||
for tc in tau_candidates:
|
||||
e_s_delayed = np.zeros_like(sensors)
|
||||
if N > tc:
|
||||
# Create a reference that matches N length
|
||||
ref_full = np.zeros((N, 6), dtype=np.float32)
|
||||
for i in range(6):
|
||||
if target_states.shape[1] > i:
|
||||
t_col = target_states[:, i]
|
||||
if len(t_col) < N:
|
||||
# tile the target to match N
|
||||
repeats = (N + len(t_col) - 1) // len(t_col)
|
||||
ref_full[:, i] = np.tile(t_col, repeats)[:N]
|
||||
else:
|
||||
ref_full[:, i] = t_col[:N]
|
||||
else:
|
||||
ref_full[:, i] = sensors[:, i]
|
||||
e_s_delayed[tc:, i] = sensors[tc:, i] - ref_full[tc:, i]
|
||||
e_s_delayed[:tc, i] = 0.0
|
||||
|
||||
y_sig_std, _, _ = standardize(e_s_delayed)
|
||||
|
||||
# Compute overlap
|
||||
N_use = min(A_std.shape[0], y_force_std.shape[0], y_sig_std.shape[0])
|
||||
A_u = A_std[:N_use]
|
||||
Y_f = y_force_std[:N_use]
|
||||
Y_s = y_sig_std[:N_use]
|
||||
|
||||
C_f = (1.0 / N_use) * A_u.T @ Y_f
|
||||
C_s = (1.0 / N_use) * A_u.T @ Y_s
|
||||
U_f, _, _ = np.linalg.svd(C_f, full_matrices=False)
|
||||
U_s, _, _ = np.linalg.svd(C_s, full_matrices=False)
|
||||
overlap = cos_sim(U_f[:, 0], U_s[:, 0])
|
||||
|
||||
# R2 for force prediction
|
||||
r2_force = compute_r2_sweep(A_u, Y_f, max_m=3)
|
||||
r2_sig = compute_r2_sweep(A_u, Y_s, max_m=3)
|
||||
|
||||
tauc_results[tc] = {
|
||||
"overlap": overlap,
|
||||
"r2_force_m2": r2_force.get(2, None),
|
||||
"r2_sig_m2": r2_sig.get(2, None),
|
||||
}
|
||||
print(f" tau_c={tc:3d}: overlap={overlap:.4f}, "
|
||||
f"force_R2={r2_force.get(2, None):.4f}, sig_R2={r2_sig.get(2, None):.4f}")
|
||||
|
||||
results["tauc_sensitivity"]["karman_re100"] = tauc_results
|
||||
|
||||
# ===========================
|
||||
# 4. ZONE OID
|
||||
# ===========================
|
||||
print("\n" + "=" * 60)
|
||||
print("4. ZONE OID")
|
||||
print("=" * 60)
|
||||
|
||||
# Zone masks (in lattice coords for 1280x512)
|
||||
ny, nx = 512, 1280
|
||||
zones = {
|
||||
"near-body": (200, 310, 580, 660),
|
||||
"near-wake": (180, 330, 660, 800),
|
||||
"downstream": (180, 330, 790, 810),
|
||||
}
|
||||
|
||||
for scene in SCENES:
|
||||
print(f"\n Scene: {scene}")
|
||||
pod_dir = os.path.join(DATA_DIR, "derived", "pod", scene)
|
||||
delta_fp = os.path.join(pod_dir, "delta_q_ctl.npz")
|
||||
if not os.path.isfile(delta_fp):
|
||||
print(" SKIP: no delta fields")
|
||||
continue
|
||||
d = np.load(delta_fp)
|
||||
ux, uy = d["ux"], d["uy"]
|
||||
N = ux.shape[0]
|
||||
ny_a, nx_a = ux.shape[1], ux.shape[2]
|
||||
print(f" field shape: ({ny_a}, {nx_a})")
|
||||
|
||||
y_force = load_observable(scene, "force_total")
|
||||
y_sig = load_observable(scene, "sensor_error_delayed")
|
||||
if scene == "steady_cloak":
|
||||
y_sig = load_observable(scene, "rms_uy")
|
||||
if y_force is None or y_sig is None:
|
||||
print(" SKIP: missing observable")
|
||||
continue
|
||||
|
||||
try:
|
||||
for zname, (y0, y1, x0, x1) in zones.items():
|
||||
y0 = max(0, y0); y1 = min(ny_a, y1); x0 = max(0, x0); x1 = min(nx_a, x1)
|
||||
if y1 <= y0 or x1 <= x0: continue
|
||||
|
||||
ux_z = ux[:, y0:y1, x0:x1]
|
||||
uy_z = uy[:, y0:y1, x0:x1]
|
||||
DOF = ux_z.shape[1] * ux_z.shape[2] * 2
|
||||
if DOF == 0: continue
|
||||
|
||||
Q = np.zeros((N, DOF), dtype=np.float64)
|
||||
for t in range(N):
|
||||
Q[t] = np.concatenate([ux_z[t].ravel(), uy_z[t].ravel()])
|
||||
pod = compute_pod(Q, rank=10)
|
||||
A = pod["coefs"]
|
||||
min_n = min(A.shape[0], y_force.shape[0], y_sig.shape[0])
|
||||
A_s = A[:min_n]; Y_f = y_force[:min_n]; Y_s = y_sig[:min_n]
|
||||
C_f = (1.0 / min_n) * A_s.T @ Y_f
|
||||
C_s = (1.0 / min_n) * A_s.T @ Y_s
|
||||
U_f, S_f, _ = np.linalg.svd(C_f, full_matrices=False)
|
||||
U_s, S_s, _ = np.linalg.svd(C_s, full_matrices=False)
|
||||
overlap = cos_sim(U_f[:, 0], U_s[:, 0])
|
||||
scene_zone_results[zname] = {"overlap": overlap,
|
||||
"force_S0": float(S_f[0]) if len(S_f) > 0 else None,
|
||||
"sig_S0": float(S_s[0]) if len(S_s) > 0 else None}
|
||||
print(f" {zname:15s}: overlap={overlap:.4f}, force_S0={S_f[0]:.4f}")
|
||||
except Exception as e:
|
||||
print(f" Zone analysis error: {e}")
|
||||
for t in range(N):
|
||||
Q[t] = np.concatenate([ux_z[t].ravel(), uy_z[t].ravel()])
|
||||
|
||||
pod = compute_pod(Q, rank=10)
|
||||
A = pod["coefs"]
|
||||
|
||||
# Limit observable length
|
||||
min_n = min(A.shape[0], y_force.shape[0], y_sig.shape[0])
|
||||
A_s = A[:min_n]
|
||||
Y_f = y_force[:min_n]
|
||||
Y_s = y_sig[:min_n]
|
||||
|
||||
C_f = (1.0 / min_n) * A_s.T @ Y_f
|
||||
C_s = (1.0 / min_n) * A_s.T @ Y_s
|
||||
U_f, S_f, _ = np.linalg.svd(C_f, full_matrices=False)
|
||||
U_s, S_s, _ = np.linalg.svd(C_s, full_matrices=False)
|
||||
|
||||
overlap = cos_sim(U_f[:, 0], U_s[:, 0])
|
||||
scene_zone_results[zname] = {
|
||||
"overlap": overlap,
|
||||
"force_S0": float(S_f[0]) if len(S_f) > 0 else None,
|
||||
"sig_S0": float(S_s[0]) if len(S_s) > 0 else None,
|
||||
}
|
||||
print(f" {zname:15s}: overlap={overlap:.4f}, "
|
||||
f"force_S0={S_f[0]:.4f}, sig_S0={S_s[0]:.4f}")
|
||||
|
||||
results["zone_oid"][scene] = scene_zone_results
|
||||
|
||||
# ===========================
|
||||
# FINAL OVERLAP TABLE (r=10)
|
||||
# ===========================
|
||||
print("\n" + "=" * 60)
|
||||
print("FINAL OVERLAP TABLE (r=10, full data)")
|
||||
print("=" * 60)
|
||||
|
||||
print(f"\n{'Scene':<20s} {'Overlap':>8s} {'Force_R2_m2':>12s} {'Sig_R2_m2':>12s}")
|
||||
print("-" * 52)
|
||||
overlap_table = {}
|
||||
for scene in SCENES:
|
||||
y_force = load_observable(scene, "force_total")
|
||||
y_sig = load_observable(scene, "sensor_error_delayed")
|
||||
if scene == "steady_cloak":
|
||||
y_sig = load_observable(scene, "rms_uy")
|
||||
if y_force is None or y_sig is None:
|
||||
continue
|
||||
|
||||
coefs = load_pod_coefs(scene, rank=10)
|
||||
if coefs is None:
|
||||
continue
|
||||
|
||||
N = min(coefs.shape[0], y_force.shape[0], y_sig.shape[0])
|
||||
A_s, _, _ = standardize(coefs[:N])
|
||||
Y_f = y_force[:N]
|
||||
Y_s = y_sig[:N]
|
||||
|
||||
C_f = (1.0 / N) * A_s.T @ Y_f
|
||||
C_s = (1.0 / N) * A_s.T @ Y_s
|
||||
U_f, _, _ = np.linalg.svd(C_f, full_matrices=False)
|
||||
U_s, _, _ = np.linalg.svd(C_s, full_matrices=False)
|
||||
overlap = cos_sim(U_f[:, 0], U_s[:, 0])
|
||||
|
||||
r2_f = compute_r2_sweep(A_s, Y_f, max_m=3)
|
||||
r2_s = compute_r2_sweep(A_s, Y_s, max_m=3)
|
||||
|
||||
overlap_table[scene] = {
|
||||
"overlap": overlap,
|
||||
"force_R2_m2": r2_f.get(2, None),
|
||||
"force_R2_m3": r2_f.get(3, None),
|
||||
"sig_R2_m2": r2_s.get(2, None),
|
||||
"sig_R2_m3": r2_s.get(3, None),
|
||||
}
|
||||
print(f"{scene:<20s} {overlap:>8.4f} {r2_f.get(2, 0):>12.4f} {r2_s.get(2, 0):>12.4f}")
|
||||
|
||||
results["overlap_table"] = overlap_table
|
||||
|
||||
# ===========================
|
||||
# SAVE
|
||||
# ===========================
|
||||
out_dir = os.path.join(DATA_DIR, "derived", "robustness")
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
|
||||
# Convert numpy arrays to floats
|
||||
def clean(obj):
|
||||
if isinstance(obj, np.floating):
|
||||
return float(obj)
|
||||
if isinstance(obj, np.integer):
|
||||
return int(obj)
|
||||
if isinstance(obj, dict):
|
||||
return {k: clean(v) for k, v in obj.items()}
|
||||
if isinstance(obj, (list, tuple)):
|
||||
return [clean(v) for v in obj]
|
||||
return obj
|
||||
|
||||
results_clean = clean(results)
|
||||
with open(os.path.join(out_dir, "robustness_results.json"), "w") as f:
|
||||
json.dump(results_clean, f, indent=2)
|
||||
|
||||
print(f"\nAll results saved to {out_dir}/robustness_results.json")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -1,188 +0,0 @@
|
||||
# OID_analysis/analysis/run_full_analysis.py
|
||||
"""
|
||||
Run the full OID analysis pipeline for a scene.
|
||||
Checks data availability first, runs all phases that have prerequisites.
|
||||
|
||||
Usage:
|
||||
# Run all available scenes
|
||||
python3 src/OID_analysis/analysis/run_full_analysis.py
|
||||
|
||||
# Run specific scene
|
||||
python3 src/OID_analysis/analysis/run_full_analysis.py --scene karman_re100
|
||||
|
||||
# Force re-run
|
||||
python3 src/OID_analysis/analysis/run_full_analysis.py --scene steady_cloak --force
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
_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 OID_analysis.configs import data_dir_for_scene, SCENES # noqa: E402
|
||||
|
||||
ANALYSIS_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
def scene_prerequisites_met(scene_key: str, verbose: bool = True) -> bool:
|
||||
"""Check if all prerequisite data exists for a scene."""
|
||||
# Determine which fields are needed
|
||||
if scene_key == "steady_cloak":
|
||||
needed = {
|
||||
"q_in (empty_channel)": lambda: os.path.isfile(os.path.join(data_dir_for_scene("empty_channel"), "fields.npz")),
|
||||
"q_blk (pinball_baseline)": lambda: os.path.isfile(os.path.join(data_dir_for_scene("pinball_baseline"), "fields.npz")),
|
||||
"q_ctl (steady_cloak)": lambda: os.path.isfile(os.path.join(data_dir_for_scene("steady_cloak"), "fields.npz")),
|
||||
"forces": lambda: os.path.isfile(os.path.join(data_dir_for_scene("pinball_baseline"), "forces.npz")),
|
||||
}
|
||||
elif scene_key.startswith("karman"):
|
||||
needed = {
|
||||
"q_in (disturbance_only)": lambda: os.path.isfile(os.path.join(data_dir_for_scene("disturbance_only"), "fields.npz")),
|
||||
"q_blk (karman_blk)": lambda: os.path.isfile(os.path.join(data_dir_for_scene("karman_blk"), "fields.npz")),
|
||||
"q_ctl (karman_re100)": lambda: os.path.isfile(os.path.join(data_dir_for_scene("karman_re100"), "fields.npz")),
|
||||
"controlled": lambda: os.path.isfile(os.path.join(data_dir_for_scene("karman_re100"), "controlled.npz")),
|
||||
}
|
||||
elif scene_key.startswith("illusion"):
|
||||
needed = {
|
||||
"q_ctl": lambda: os.path.isfile(os.path.join(data_dir_for_scene(scene_key), "fields.npz")),
|
||||
"controlled": lambda: os.path.isfile(os.path.join(data_dir_for_scene(scene_key), "controlled.npz")),
|
||||
}
|
||||
else:
|
||||
needed = {}
|
||||
|
||||
all_met = True
|
||||
for name, check_fn in needed.items():
|
||||
met = check_fn()
|
||||
if not met:
|
||||
if verbose:
|
||||
print(f" MISSING: {name}")
|
||||
all_met = False
|
||||
elif verbose:
|
||||
print(f" OK: {name}")
|
||||
|
||||
return all_met
|
||||
|
||||
|
||||
def run_phase(script_name: str, scene_key: str, force: bool = False):
|
||||
"""Run one phase script for a scene."""
|
||||
script_path = os.path.join(ANALYSIS_DIR, script_name)
|
||||
if not os.path.isfile(script_path):
|
||||
print(f" SKIP: {script_name} not found")
|
||||
return 0
|
||||
|
||||
env = os.environ.copy()
|
||||
env["PYTHONPATH"] = f"{_SRC}:{env.get('PYTHONPATH', '')}"
|
||||
|
||||
cmd = [sys.executable, script_path]
|
||||
if scene_key:
|
||||
cmd.extend(["--scene", scene_key])
|
||||
|
||||
print(f"\n--- Running: {' '.join(cmd)} ---")
|
||||
t0 = time.time()
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, env=env)
|
||||
elapsed = time.time() - t0
|
||||
|
||||
# Print output
|
||||
for line in result.stdout.split("\n"):
|
||||
if line.strip():
|
||||
print(f" {line}")
|
||||
if result.stderr.strip():
|
||||
for line in result.stderr.split("\n"):
|
||||
if line.strip() and "WARNING" in line:
|
||||
print(f" ! {line.strip()}")
|
||||
|
||||
if result.returncode != 0:
|
||||
print(f" FAILED ({elapsed:.0f}s), code={result.returncode}")
|
||||
else:
|
||||
print(f" OK ({elapsed:.0f}s)")
|
||||
|
||||
return result.returncode
|
||||
|
||||
|
||||
def run_full(scene_key: str, force: bool = False):
|
||||
print(f"\n{'='*70}")
|
||||
print(f"Full OID Analysis Pipeline: {scene_key}")
|
||||
print(f"{'='*70}")
|
||||
|
||||
if not scene_prerequisites_met(scene_key):
|
||||
print(f" PREREQUISITES NOT MET. Skipping.")
|
||||
return False
|
||||
|
||||
# Phase order
|
||||
phases = [
|
||||
("phase1_correction_pod.py", "Phase 1: Correction-field POD"),
|
||||
("phase2_build_observables.py", "Phase 2: Observable construction"),
|
||||
("phase3_force_oid.py", "Phase 3: Force-OID"),
|
||||
]
|
||||
|
||||
# Phase 4-5 (conditional)
|
||||
if scene_key.startswith("karman") or scene_key.startswith("illusion"):
|
||||
phases.append(("phase4a_signature_oid.py", "Phase 4a: Signature-OID"))
|
||||
phases.append(("phase4b_signature_pcd.py", "Phase 4b: Signature-PCD"))
|
||||
elif scene_key == "steady_cloak":
|
||||
phases.append(("phase5_steady_oid.py", "Phase 5: Steady suppression-OID"))
|
||||
|
||||
# Phase 6-7
|
||||
phases.append(("phase6_comparison.py", "Phase 6: Comparison"))
|
||||
phases.append(("phase7_whitebox.py", "Phase 7: White-box chain"))
|
||||
|
||||
errors = 0
|
||||
for script, desc in phases:
|
||||
print(f"\n>>> {desc}")
|
||||
errors += run_phase(script, scene_key, force)
|
||||
|
||||
print(f"\n{'='*70}")
|
||||
print(f"Pipeline complete for {scene_key}. Errors: {errors}")
|
||||
print(f"{'='*70}")
|
||||
return errors == 0
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--scene", type=str, default=None,
|
||||
help="Scene key or 'all'")
|
||||
ap.add_argument("--force", action="store_true",
|
||||
help="Force re-run")
|
||||
ap.add_argument("--check", action="store_true",
|
||||
help="Only check prerequisites")
|
||||
ap.add_argument("--list", action="store_true",
|
||||
help="List available scenes")
|
||||
args = ap.parse_args()
|
||||
|
||||
all_scenes = ["steady_cloak", "karman_re100",
|
||||
"illusion_0.75L", "illusion_1.0L", "illusion_1.5L"]
|
||||
|
||||
if args.list:
|
||||
for sn in all_scenes:
|
||||
met = scene_prerequisites_met(sn, verbose=False)
|
||||
status = "READY" if met else "MISSING DATA"
|
||||
print(f" {sn:30s} {status}")
|
||||
return
|
||||
|
||||
if args.check:
|
||||
for sn in all_scenes:
|
||||
print(f"\n--- {sn} ---")
|
||||
scene_prerequisites_met(sn)
|
||||
return
|
||||
|
||||
targets = all_scenes if (args.scene == "all" or args.scene is None) else [args.scene]
|
||||
for sn in targets:
|
||||
if sn not in all_scenes:
|
||||
print(f"Unknown scene: {sn}")
|
||||
continue
|
||||
run_full(sn, args.force)
|
||||
|
||||
print("\nAll pipelines complete.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -1,40 +0,0 @@
|
||||
"""Save robustness results and write comprehensive report."""
|
||||
import json, os, sys
|
||||
_REPO = "/home/frank14f/DynamisLab"
|
||||
sys.path.insert(0, os.path.join(_REPO, "src"))
|
||||
from OID_analysis.configs import DATA_DIR
|
||||
|
||||
results = {
|
||||
"rank_sensitivity": {
|
||||
"steady_cloak": {"r6": -0.4865, "r8": -0.7764, "r10": -0.7631, "r12": -0.7261, "r16": -0.6756},
|
||||
"karman_re100": {"r6": 0.1428, "r8": -0.0359, "r10": -0.0344, "r12": 0.0135, "r16": -0.0457},
|
||||
"illusion_0.75L": {"r6": -0.2016, "r8": 0.0782, "r10": -0.0823, "r12": -0.4977, "r16": 0.1241},
|
||||
"illusion_1.0L": {"r6": -0.4415, "r8": -0.4736, "r10": -0.4954, "r12": -0.4427, "r16": -0.4239},
|
||||
"illusion_1.5L": {"r6": -0.9675, "r8": -0.9586, "r10": -0.9321, "r12": -0.9262, "r16": -0.9099},
|
||||
},
|
||||
"tauc_sensitivity": {
|
||||
0: {"overlap": 0.306, "sig_R2": 0.285},
|
||||
10: {"overlap": 0.116, "sig_R2": 0.306},
|
||||
15: {"overlap": 0.121, "sig_R2": 0.318},
|
||||
20: {"overlap": 0.114, "sig_R2": 0.326},
|
||||
25: {"overlap": 0.143, "sig_R2": 0.325},
|
||||
30: {"overlap": 0.137, "sig_R2": 0.313},
|
||||
35: {"overlap": 0.137, "sig_R2": 0.309},
|
||||
40: {"overlap": 0.150, "sig_R2": 0.300},
|
||||
50: {"overlap": 0.163, "sig_R2": 0.285},
|
||||
60: {"overlap": 0.187, "sig_R2": 0.260},
|
||||
},
|
||||
"overlap_table": {
|
||||
"steady_cloak": {"overlap": -0.763, "force_R2_m2": None, "sig_R2_m2": None},
|
||||
"karman_re100": {"overlap": -0.034, "force_R2_m2": 0.750, "sig_R2_m2": 0.000},
|
||||
"illusion_0.75L": {"overlap": -0.082, "force_R2_m2": 0.435, "sig_R2_m2": 0.661},
|
||||
"illusion_1.0L": {"overlap": -0.495, "force_R2_m2": 0.671, "sig_R2_m2": 0.586},
|
||||
"illusion_1.5L": {"overlap": -0.932, "force_R2_m2": 0.640, "sig_R2_m2": 0.315},
|
||||
},
|
||||
}
|
||||
|
||||
out_dir = os.path.join(DATA_DIR, "derived", "robustness")
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
with open(os.path.join(out_dir, "robustness_results.json"), "w") as f:
|
||||
json.dump(results, f, indent=2)
|
||||
print("Saved.")
|
||||
@ -1,208 +0,0 @@
|
||||
# OID_analysis/analysis/steady_reanalysis.py
|
||||
"""
|
||||
Steady cloak re-analysis with suppression/restoration metrics.
|
||||
Replaces R^2 with physically meaningful metrics:
|
||||
- RMS reduction per zone
|
||||
- Recirculation length/area collapse
|
||||
- Enstrophy reduction
|
||||
- Force RMS
|
||||
|
||||
Usage:
|
||||
conda run -n sr_env python3 src/OID_analysis/analysis/steady_reanalysis.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
|
||||
_REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
|
||||
if _REPO not in sys.path:
|
||||
sys.path.insert(0, _REPO)
|
||||
|
||||
from OID_analysis.configs import DATA_DIR, data_dir_for_scene # noqa: E402
|
||||
|
||||
|
||||
def compute_rms_reduction(ux_ctl, uy_ctl, ux_blk, uy_blk, zone_mask=None):
|
||||
"""Compute RMS reduction ratio: 1 - RMS(ctl)/RMS(blk)"""
|
||||
if zone_mask is not None:
|
||||
ux_ctl = ux_ctl[:, zone_mask]
|
||||
uy_ctl = uy_ctl[:, zone_mask]
|
||||
ux_blk = ux_blk[:, zone_mask]
|
||||
uy_blk = uy_blk[:, zone_mask]
|
||||
|
||||
rms_ctl_u = np.std(ux_ctl, axis=0)
|
||||
rms_ctl_v = np.std(uy_ctl, axis=0)
|
||||
rms_blk_u = np.std(ux_blk, axis=0)
|
||||
rms_blk_v = np.std(uy_blk, axis=0)
|
||||
|
||||
rms_ctl = np.sqrt(np.mean(rms_ctl_u**2 + rms_ctl_v**2))
|
||||
rms_blk = np.sqrt(np.mean(rms_blk_u**2 + rms_blk_v**2))
|
||||
|
||||
reduction = 1.0 - rms_ctl / (rms_blk + 1e-30)
|
||||
return float(reduction), float(rms_ctl), float(rms_blk)
|
||||
|
||||
|
||||
def compute_enstrophy_reduction(ux_ctl, uy_ctl, ux_blk, uy_blk, zone_mask=None):
|
||||
"""Compute enstrophy reduction ratio."""
|
||||
def zonal_enstrophy(ux, uy):
|
||||
omega = np.zeros((ux.shape[0], ux.shape[1], ux.shape[2]), dtype=np.float64)
|
||||
for t in range(min(ux.shape[0], 100)): # subsample for speed
|
||||
omega[t] = np.gradient(uy[t].astype(np.float64), axis=1) - \
|
||||
np.gradient(ux[t].astype(np.float64), axis=0)
|
||||
if zone_mask is not None:
|
||||
area = max(np.sum(zone_mask), 1)
|
||||
return np.mean(0.5 * omega[:, zone_mask]**2)
|
||||
return np.mean(0.5 * omega**2)
|
||||
|
||||
ens_ctl = zonal_enstrophy(ux_ctl, uy_ctl)
|
||||
ens_blk = zonal_enstrophy(ux_blk, uy_blk)
|
||||
reduction = 1.0 - ens_ctl / (ens_blk + 1e-30)
|
||||
return float(reduction), float(ens_ctl), float(ens_blk)
|
||||
|
||||
|
||||
def compute_recirculation_metrics(mean_u):
|
||||
"""Compute Lr and Ar from mean u field."""
|
||||
ny, nx = mean_u.shape
|
||||
center_y = (ny - 1) / 2.0
|
||||
cl_y = int(center_y)
|
||||
|
||||
# Lr: furthest downstream x on centerline where mean_u < 0
|
||||
u_cl = mean_u[cl_y, :]
|
||||
neg_idx = np.where(u_cl < 0)[0]
|
||||
Lr = float(neg_idx[-1]) if len(neg_idx) > 0 else 0.0
|
||||
|
||||
# Ar: pixels where mean_u < 0
|
||||
Ar = float(np.sum(mean_u < 0))
|
||||
|
||||
return Lr, Ar
|
||||
|
||||
|
||||
def analyze_steady():
|
||||
print("=== Steady Cloak Re-Analysis ===")
|
||||
|
||||
# Load fields
|
||||
blk_dir = data_dir_for_scene("pinball_baseline")
|
||||
ctl_dir = data_dir_for_scene("steady_cloak")
|
||||
|
||||
f_blk = np.load(os.path.join(blk_dir, "fields.npz"))
|
||||
ux_blk, uy_blk = f_blk["ux"], f_blk["uy"]
|
||||
|
||||
f_ctl = np.load(os.path.join(ctl_dir, "fields.npz"))
|
||||
ux_ctl, uy_ctl = f_ctl["ux"], f_ctl["uy"]
|
||||
|
||||
# Equalize lengths
|
||||
N = min(ux_blk.shape[0], ux_ctl.shape[0])
|
||||
ux_blk, uy_blk = ux_blk[:N], uy_blk[:N]
|
||||
ux_ctl, uy_ctl = ux_ctl[:N], uy_ctl[:N]
|
||||
|
||||
print(f"\nN snapshots: {N} (min of blk={ux_blk.shape[0]}, ctl={ux_ctl.shape[0]})")
|
||||
|
||||
# Zone masks
|
||||
ny, nx = ux_blk.shape[1], ux_blk.shape[2]
|
||||
|
||||
# Near-body: x=[580,660], y=[200,310] in lattice
|
||||
nb_mask = np.zeros((ny, nx), dtype=bool)
|
||||
nb_mask[200:310, 580:660] = True
|
||||
|
||||
# Near-wake: x=[660,800], y=[180,330]
|
||||
nw_mask = np.zeros((ny, nx), dtype=bool)
|
||||
nw_mask[180:330, 660:800] = True
|
||||
|
||||
# Downstream sensor zone: x=[790,810]
|
||||
ds_mask = np.zeros((ny, nx), dtype=bool)
|
||||
for sy in [215, 255, 295]:
|
||||
y0, y1 = max(0, sy-10), min(ny, sy+10)
|
||||
ds_mask[y0:y1, 790:810] = True
|
||||
|
||||
zones = [
|
||||
("near-body", nb_mask),
|
||||
("near-wake", nw_mask),
|
||||
("downstream", ds_mask),
|
||||
("full-field", None),
|
||||
]
|
||||
|
||||
results = {}
|
||||
for zname, zmask in zones:
|
||||
print(f"\n Zone: {zname}")
|
||||
rms_red, rms_c, rms_b = compute_rms_reduction(
|
||||
ux_ctl, uy_ctl, ux_blk, uy_blk, zmask)
|
||||
ens_red, ens_c, ens_b = compute_enstrophy_reduction(
|
||||
ux_ctl, uy_ctl, ux_blk, uy_blk, zmask)
|
||||
results[zname] = {
|
||||
"rms_reduction": rms_red,
|
||||
"rms_ctl": rms_c,
|
||||
"rms_blk": rms_b,
|
||||
"enstrophy_reduction": ens_red,
|
||||
"enstrophy_ctl": ens_c,
|
||||
"enstrophy_blk": ens_b,
|
||||
}
|
||||
print(f" RMS reduction: {rms_red:.4f} (ctl={rms_c:.6f}, blk={rms_b:.6f})")
|
||||
print(f" Enstrophy reduction: {ens_red:.4f} (ctl={ens_c:.6f}, blk={ens_b:.6f})")
|
||||
|
||||
# Recirculation metrics for full field
|
||||
mean_u_ctl = np.mean(ux_ctl, axis=0)
|
||||
mean_u_blk = np.mean(ux_blk, axis=0)
|
||||
Lr_ctl, Ar_ctl = compute_recirculation_metrics(mean_u_ctl)
|
||||
Lr_blk, Ar_blk = compute_recirculation_metrics(mean_u_blk)
|
||||
results["recirculation"] = {
|
||||
"Lr_ctl_lattice": Lr_ctl,
|
||||
"Lr_blk_lattice": Lr_blk,
|
||||
"Lr_collapse": Lr_ctl / (Lr_blk + 1e-30),
|
||||
"Ar_ctl": Ar_ctl,
|
||||
"Ar_blk": Ar_blk,
|
||||
"Ar_collapse": Ar_ctl / (Ar_blk + 1e-30),
|
||||
}
|
||||
print(f"\n Recirculation:")
|
||||
print(f" Lr: ctl={Lr_ctl:.0f}, blk={Lr_blk:.0f}, collapse={Lr_ctl/(Lr_blk+1e-30):.4f}")
|
||||
print(f" Ar: ctl={Ar_ctl:.0f}, blk={Ar_blk:.0f}, collapse={Ar_ctl/(Ar_blk+1e-30):.4f}")
|
||||
|
||||
# Force metrics
|
||||
fp = os.path.join(blk_dir, "forces.npz")
|
||||
forces_blk = np.load(fp)["forces"][:N]
|
||||
fp_ctl = os.path.join(ctl_dir, "forces.npz")
|
||||
forces_ctl = np.load(fp_ctl)["forces"][:N]
|
||||
|
||||
Fx_blk_rms = np.std(np.sum(forces_blk[:, 0::2], axis=1))
|
||||
Fy_blk_rms = np.std(np.sum(forces_blk[:, 1::2], axis=1))
|
||||
Fx_ctl_rms = np.std(np.sum(forces_ctl[:, 0::2], axis=1))
|
||||
Fy_ctl_rms = np.std(np.sum(forces_ctl[:, 1::2], axis=1))
|
||||
|
||||
results["force"] = {
|
||||
"Fx_rms_blk": float(Fx_blk_rms),
|
||||
"Fx_rms_ctl": float(Fx_ctl_rms),
|
||||
"Fx_reduction": float(1.0 - Fx_ctl_rms / (Fx_blk_rms + 1e-30)),
|
||||
"Fy_rms_blk": float(Fy_blk_rms),
|
||||
"Fy_rms_ctl": float(Fy_ctl_rms),
|
||||
"Fy_reduction": float(1.0 - Fy_ctl_rms / (Fy_blk_rms + 1e-30)),
|
||||
}
|
||||
print(f"\n Force:")
|
||||
print(f" Fx RMS: blk={Fx_blk_rms:.6f}, ctl={Fx_ctl_rms:.6f}, reduction={results['force']['Fx_reduction']:.4f}")
|
||||
print(f" Fy RMS: blk={Fy_blk_rms:.6f}, ctl={Fy_ctl_rms:.6f}, reduction={results['force']['Fy_reduction']:.4f}")
|
||||
|
||||
# Save
|
||||
out_dir = os.path.join(DATA_DIR, "derived", "steady_metrics")
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
with open(os.path.join(out_dir, "steady_reanalysis.json"), "w") as f:
|
||||
json.dump(results, f, indent=2)
|
||||
print(f"\nSaved to {out_dir}/steady_reanalysis.json")
|
||||
|
||||
# Summary table
|
||||
print(f"\n{'='*60}")
|
||||
print(f"STEADY CLOAK RE-ANALYSIS SUMMARY")
|
||||
print(f"{'='*60}")
|
||||
print(f"{'Metric':<30s} {'Uncontrolled':>12s} {'Controlled':>12s} {'Reduction':>12s}")
|
||||
print(f"{'-'*66}")
|
||||
for zname in ["near-body", "near-wake", "downstream"]:
|
||||
zr = results[zname]
|
||||
print(f"RMS ({zname}){'':<12s} {zr['rms_blk']:12.6f} {zr['rms_ctl']:12.6f} {zr['rms_reduction']:12.4f}")
|
||||
print(f"Lr (recirc length) {Lr_blk:12.0f} {Lr_ctl:12.0f} {results['recirculation']['Lr_collapse']:12.4f}")
|
||||
print(f"Ar (recirc area) {Ar_blk:12.0f} {Ar_ctl:12.0f} {results['recirculation']['Ar_collapse']:12.4f}")
|
||||
print(f"Fx RMS {Fx_blk_rms:12.6f} {Fx_ctl_rms:12.6f} {results['force']['Fx_reduction']:12.4f}")
|
||||
print(f"Fy RMS {Fy_blk_rms:12.6f} {Fy_ctl_rms:12.6f} {results['force']['Fy_reduction']:12.4f}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
analyze_steady()
|
||||
@ -1,266 +0,0 @@
|
||||
"""Unified scene configuration for OID_analysis.
|
||||
|
||||
Mirrors SR_analysis/configs.py and CCD_analysis/configs.py pattern.
|
||||
All scene metadata in one place.
|
||||
|
||||
Re convention:
|
||||
- "re_code" uses reference length 2*D (matching model file naming).
|
||||
- Re_D = re_code / 2 is the true physical Reynolds number.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
# -- Root paths ---------------------------------------------------------------
|
||||
_PROJ = os.path.abspath(os.path.dirname(__file__))
|
||||
MODEL_DIR = os.path.join(_PROJ, "..", "..", "models")
|
||||
LEGACY_CFG_DIR = os.path.join(os.path.dirname(__file__), "data", "configs", "legacy")
|
||||
DATA_DIR = os.path.join(os.path.dirname(__file__), "data")
|
||||
|
||||
# -- Physics constants -------------------------------------------------------
|
||||
U0 = 0.01 # standard inlet center velocity (all models use this)
|
||||
D_CYL = 20.0 # single cylinder diameter (lattice units)
|
||||
D_REF = 40.0 # reference length = 2*D for code Re
|
||||
L0 = 20.0 # base length unit
|
||||
NX = 1280
|
||||
NY = 512
|
||||
CENTER_Y = (NY - 1) / 2.0
|
||||
FIFO_LEN = 150
|
||||
CONV_LEN_DEFAULT = 30 # Karman/Steady
|
||||
CONV_LEN_ILLUSION = 36 # Illusion
|
||||
|
||||
|
||||
def nu_from_re(re_code: float, u0: float = U0) -> float:
|
||||
"""Viscosity from code Reynolds number (reference length = 2*D)."""
|
||||
return u0 * D_REF / re_code
|
||||
|
||||
|
||||
# -- Scene definitions -------------------------------------------------------
|
||||
SCENES: Dict[str, Any] = {}
|
||||
|
||||
# -- Empty Channel (reference) -----------------------------------------------
|
||||
SCENES["empty_channel"] = {
|
||||
"scene_id": "empty_channel",
|
||||
"re_code": 100,
|
||||
"nu": 0.004,
|
||||
"has_disturbance": False,
|
||||
"sample_interval": 800,
|
||||
"source": "open_loop",
|
||||
"n_objects_env": 3,
|
||||
"obs_slice": (0, 6),
|
||||
"sensor_x": 40.0,
|
||||
"target_type": "steady",
|
||||
"u0": U0,
|
||||
}
|
||||
|
||||
# -- Pure Pinball (uncontrolled baseline, steady/illusion positions) ---------
|
||||
SCENES["pinball_baseline"] = {
|
||||
"scene_id": "pinball_baseline",
|
||||
"re_code": 100,
|
||||
"nu": 0.004,
|
||||
"has_disturbance": False,
|
||||
"sample_interval": 800,
|
||||
"action_scale": 8.0,
|
||||
"action_bias": (0.0, -4.0, 4.0),
|
||||
"source": "open_loop",
|
||||
"n_objects_env": 6,
|
||||
"obs_slice": (0, 12),
|
||||
"sensor_x": 40.0,
|
||||
"pinball_front_x": 30.0,
|
||||
"pinball_rear_x": 31.3,
|
||||
"target_type": "periodic",
|
||||
"s_dim": 12,
|
||||
"u0": U0,
|
||||
}
|
||||
|
||||
# -- Disturbance Only (Karman inflow) ----------------------------------------
|
||||
SCENES["disturbance_only"] = {
|
||||
"scene_id": "disturbance_only",
|
||||
"re_code": 100,
|
||||
"nu": 0.004,
|
||||
"has_disturbance": True,
|
||||
"sample_interval": 800,
|
||||
"source": "open_loop",
|
||||
"n_objects_env": 4,
|
||||
"obs_slice": (2, 8),
|
||||
"sensor_x": 40.0,
|
||||
"disturbance_x": 10.0,
|
||||
"disturbance_radius": 1.0, # L0 units
|
||||
"target_type": "periodic",
|
||||
"u0": U0,
|
||||
}
|
||||
|
||||
# -- Disturbance + Pinball (Karman q_blk) ------------------------------------
|
||||
SCENES["karman_blk"] = {
|
||||
"scene_id": "karman_blk",
|
||||
"re_code": 100,
|
||||
"nu": 0.004,
|
||||
"has_disturbance": True,
|
||||
"sample_interval": 800,
|
||||
"action_scale": 8.0,
|
||||
"action_bias": (0.0, -4.0, 4.0),
|
||||
"source": "open_loop",
|
||||
"n_objects_env": 7,
|
||||
"obs_slice": (2, 14),
|
||||
"sensor_x": 40.0,
|
||||
"pinball_front_x": 30.0,
|
||||
"pinball_rear_x": 31.3,
|
||||
"target_type": "periodic",
|
||||
"s_dim": 12,
|
||||
"u0": U0,
|
||||
}
|
||||
|
||||
# -- Steady Cloak (open-loop constant rotation) ------------------------------
|
||||
SCENES["steady_cloak"] = {
|
||||
"scene_id": "steady_cloak",
|
||||
"re_code": 100,
|
||||
"nu": 0.004,
|
||||
"has_disturbance": False,
|
||||
"sample_interval": 800,
|
||||
"source": "open_loop",
|
||||
"n_objects_env": 6,
|
||||
"obs_slice": (0, 12),
|
||||
"sensor_x": 40.0,
|
||||
"pinball_front_x": 30.0,
|
||||
"pinball_rear_x": 31.3,
|
||||
"target_type": "steady",
|
||||
"s_dim": 12,
|
||||
"u0": U0,
|
||||
"omega_front": 0.0,
|
||||
"omega_rear_scale": 5.1,
|
||||
}
|
||||
|
||||
# -- Karman Cloak re100 (PPO) ------------------------------------------------
|
||||
SCENES["karman_re100"] = {
|
||||
"scene_id": "karman",
|
||||
"re_code": 100,
|
||||
"nu": 0.004,
|
||||
"has_disturbance": True,
|
||||
"sample_interval": 800,
|
||||
"action_scale": 8.0,
|
||||
"action_bias": (0.0, -4.0, 4.0),
|
||||
"source": "PPO_inference",
|
||||
"model_name": "d1a3o12_re100",
|
||||
"model_subdir": "old",
|
||||
"n_objects_env": 7,
|
||||
"obs_slice": (2, 14),
|
||||
"sensor_x": 40.0,
|
||||
"pinball_front_x": 30.0,
|
||||
"pinball_rear_x": 31.3,
|
||||
"target_type": "periodic",
|
||||
"s_dim": 12,
|
||||
"u0": U0,
|
||||
}
|
||||
|
||||
# -- Illusion scenes (S_DIM=14, 3 diameters) ---------------------------------
|
||||
_ILLUSION_SCENES = [
|
||||
("illusion_0.75L", "d1a3o14_250525_imit_075L_2U_400S", 0.75, 400),
|
||||
("illusion_1.0L", "d1a3o14_250525_imit_1L_2U_600S", 1.0, 600),
|
||||
("illusion_1.5L", "d1a3o14_250525_imit_15L_2U", 1.5, 800),
|
||||
]
|
||||
for key, mn, diam, si in _ILLUSION_SCENES:
|
||||
SCENES[key] = {
|
||||
"scene_id": "illusion",
|
||||
"target_diameter": diam,
|
||||
"re_code": 100,
|
||||
"nu": 0.004,
|
||||
"has_disturbance": False,
|
||||
"sample_interval": si,
|
||||
"conv_len": CONV_LEN_ILLUSION,
|
||||
"action_scale": 8.0,
|
||||
"action_bias": (0.0, -2.0, 2.0),
|
||||
"source": "PPO_inference",
|
||||
"model_name": mn,
|
||||
"model_subdir": "250525",
|
||||
"n_objects_env": 6,
|
||||
"obs_slice": (0, 12),
|
||||
"sensor_x": 30.0,
|
||||
"pinball_front_x": 19.0,
|
||||
"pinball_rear_x": 20.3,
|
||||
"target_type": "periodic",
|
||||
"s_dim": 14,
|
||||
"u0": U0,
|
||||
}
|
||||
|
||||
# -- Target cylinders (for illusion comparison) ------------------------------
|
||||
for diam, si in [(0.75, 400), (1.0, 600), (1.5, 800)]:
|
||||
key = f"target_cylinder_{diam}L"
|
||||
SCENES[key] = {
|
||||
"scene_id": "target_cylinder",
|
||||
"target_diameter": diam,
|
||||
"re_code": 100,
|
||||
"nu": 0.004,
|
||||
"has_disturbance": False,
|
||||
"sample_interval": si,
|
||||
"conv_len": CONV_LEN_ILLUSION,
|
||||
"source": "open_loop",
|
||||
"n_objects_env": 4,
|
||||
"obs_slice": (0, 8),
|
||||
"sensor_x": 30.0,
|
||||
"cylinder_x": 20.0,
|
||||
"target_type": "periodic",
|
||||
"u0": U0,
|
||||
}
|
||||
|
||||
|
||||
# -- Utility helpers ---------------------------------------------------------
|
||||
|
||||
def get_scene(name: str) -> dict:
|
||||
if name not in SCENES:
|
||||
raise KeyError(f"Unknown scene: {name}. Available: {list(SCENES.keys())}")
|
||||
return dict(SCENES[name])
|
||||
|
||||
|
||||
def get_scene_list(scene_id: Optional[str] = None) -> List[str]:
|
||||
if scene_id is None:
|
||||
return list(SCENES.keys())
|
||||
return [k for k, v in SCENES.items() if v["scene_id"] == scene_id]
|
||||
|
||||
|
||||
def model_path_for_scene(scene_name: str) -> Optional[str]:
|
||||
"""Return full path to PPO model zip, or None if open-loop."""
|
||||
cfg = get_scene(scene_name)
|
||||
if cfg.get("source") != "PPO_inference":
|
||||
return None
|
||||
mn = cfg.get("model_name")
|
||||
if mn is None:
|
||||
return None
|
||||
subdir = cfg.get("model_subdir", "old")
|
||||
return os.path.join(MODEL_DIR, subdir, f"{mn}.zip")
|
||||
|
||||
|
||||
def data_dir_for_scene(scene_name: str) -> str:
|
||||
"""Return data directory for a given scene name.
|
||||
|
||||
Each scene config gets its OWN directory based on scene_name
|
||||
to prevent filename collisions (different fields all save fields.npz).
|
||||
"""
|
||||
cfg = get_scene(scene_name)
|
||||
sid = cfg["scene_id"]
|
||||
|
||||
# Steady group: q_in=q_in, q_blk=q_blk, q_ctl=q_ctl
|
||||
if sid == "empty_channel":
|
||||
return os.path.join(DATA_DIR, "steady_cloak", "empty_channel")
|
||||
elif sid == "pinball_baseline":
|
||||
return os.path.join(DATA_DIR, "steady_cloak", "pinball_baseline")
|
||||
elif sid == "steady_cloak":
|
||||
return os.path.join(DATA_DIR, "steady_cloak", "steady_cloak")
|
||||
# Karman group: separate dirs for q_in, q_blk, q_ctl
|
||||
elif sid == "disturbance_only":
|
||||
return os.path.join(DATA_DIR, "karman_cloak", "disturbance_only")
|
||||
elif sid == "karman_blk":
|
||||
return os.path.join(DATA_DIR, "karman_cloak", "karman_blk")
|
||||
elif sid == "karman":
|
||||
return os.path.join(DATA_DIR, "karman_cloak", "karman_re100")
|
||||
# Target cylinders get their own dirs under target_cylinders
|
||||
elif sid == "target_cylinder":
|
||||
diam = cfg["target_diameter"]
|
||||
# Preserve one decimal: 0.75->"0.75", 1.0->"1.0", 1.5->"1.5"
|
||||
ds = f"{diam:.1f}" if diam == int(diam) else f"{diam:.2f}".rstrip("0")
|
||||
return os.path.join(DATA_DIR, "target_cylinder", f"target_cylinder_{ds}L")
|
||||
# Illusion scenes
|
||||
elif sid == "illusion":
|
||||
return os.path.join(DATA_DIR, "illusion", scene_name)
|
||||
else:
|
||||
return os.path.join(DATA_DIR, scene_name)
|
||||
@ -1 +0,0 @@
|
||||
/home/frank14f/DynamisLab/configs/legacy_configs/config_cuda.json
|
||||
@ -1 +0,0 @@
|
||||
/home/frank14f/DynamisLab/configs/legacy_configs/config_flowfield.json
|
||||
@ -1,38 +0,0 @@
|
||||
{
|
||||
"force": {
|
||||
"force-oid_m1": -0.0065221308964502865,
|
||||
"force-oid_m2": 0.43535277661654437,
|
||||
"force-oid_m3": 0.43535277661654437,
|
||||
"force-oid_m5": 0.43535277661654437,
|
||||
"sig-oid_m1": 0.017340158959890415,
|
||||
"sig-oid_m2": 0.30180744104636803,
|
||||
"sig-oid_m3": 0.09790190212866957,
|
||||
"sig-oid_m5": 0.02065845627823741,
|
||||
"sig-pcd_m1": -0.03521104267963627,
|
||||
"sig-pcd_m2": 0.20732705633252407,
|
||||
"sig-pcd_m3": 0.11988520616706469,
|
||||
"sig-pcd_m5": -1.5172646863328378,
|
||||
"pod_m1": -2.45640794420206,
|
||||
"pod_m2": -2.4260527205648605,
|
||||
"pod_m3": -3.5262547873981513,
|
||||
"pod_m5": -3.0459602065033202
|
||||
},
|
||||
"future_sig": {
|
||||
"force-oid_m1": 0.013511471239959334,
|
||||
"force-oid_m2": 0.07098337174417249,
|
||||
"force-oid_m3": 0.07098337174417249,
|
||||
"force-oid_m5": 0.07098337174417249,
|
||||
"sig-oid_m1": 0.3740715508827751,
|
||||
"sig-oid_m2": 0.6608883811088201,
|
||||
"sig-oid_m3": 0.5592259563419594,
|
||||
"sig-oid_m5": 0.533343435056657,
|
||||
"sig-pcd_m1": 0.20205641028110888,
|
||||
"sig-pcd_m2": 0.4672590946761527,
|
||||
"sig-pcd_m3": 0.4468990482184305,
|
||||
"sig-pcd_m5": 0.41968732641466205,
|
||||
"pod_m1": -0.2540973280602146,
|
||||
"pod_m2": -0.0339567217960513,
|
||||
"pod_m3": 0.054785729407538376,
|
||||
"pod_m5": 0.3000378545113639
|
||||
}
|
||||
}
|
||||
@ -1,38 +0,0 @@
|
||||
{
|
||||
"force": {
|
||||
"force-oid_m1": -0.22355143786066206,
|
||||
"force-oid_m2": 0.6705941647225692,
|
||||
"force-oid_m3": 0.6705941647225692,
|
||||
"force-oid_m5": 0.6705941647225692,
|
||||
"sig-oid_m1": -2.7646669027021566,
|
||||
"sig-oid_m2": -2.539151608216361,
|
||||
"sig-oid_m3": -1.47692206321327,
|
||||
"sig-oid_m5": -1.5110272636915942,
|
||||
"sig-pcd_m1": -1.6681874811094162,
|
||||
"sig-pcd_m2": -1.342076853642359,
|
||||
"sig-pcd_m3": 0.04249559758899473,
|
||||
"sig-pcd_m5": -0.3512030294634511,
|
||||
"pod_m1": -0.34310245755591257,
|
||||
"pod_m2": -0.23704245972213284,
|
||||
"pod_m3": -0.0736581270661334,
|
||||
"pod_m5": -0.09978939220221528
|
||||
},
|
||||
"future_sig": {
|
||||
"force-oid_m1": -0.688583875677999,
|
||||
"force-oid_m2": 0.0977498946249901,
|
||||
"force-oid_m3": 0.0977498946249901,
|
||||
"force-oid_m5": 0.0977498946249901,
|
||||
"sig-oid_m1": 0.3400013732837159,
|
||||
"sig-oid_m2": 0.5855599713349928,
|
||||
"sig-oid_m3": 0.6757301882995801,
|
||||
"sig-oid_m5": 0.6051731015609549,
|
||||
"sig-pcd_m1": -0.045583209960261946,
|
||||
"sig-pcd_m2": -0.07349660070560707,
|
||||
"sig-pcd_m3": 0.534579564047348,
|
||||
"sig-pcd_m5": 0.6365887267870092,
|
||||
"pod_m1": -0.3737860307570295,
|
||||
"pod_m2": -0.1596051084511593,
|
||||
"pod_m3": 0.08266261398865987,
|
||||
"pod_m5": -0.33155756162258626
|
||||
}
|
||||
}
|
||||
@ -1,38 +0,0 @@
|
||||
{
|
||||
"force": {
|
||||
"force-oid_m1": 0.5712134182396399,
|
||||
"force-oid_m2": 0.6397818250190341,
|
||||
"force-oid_m3": 0.6397818250190341,
|
||||
"force-oid_m5": 0.6397818250190341,
|
||||
"sig-oid_m1": 0.5371119596459986,
|
||||
"sig-oid_m2": 0.5689626851549741,
|
||||
"sig-oid_m3": 0.5480702090166246,
|
||||
"sig-oid_m5": 0.49764490473273426,
|
||||
"sig-pcd_m1": 0.02922230950403174,
|
||||
"sig-pcd_m2": 0.4747650262191032,
|
||||
"sig-pcd_m3": 0.5480885671190363,
|
||||
"sig-pcd_m5": 0.4953579764560622,
|
||||
"pod_m1": 0.03135560469147149,
|
||||
"pod_m2": 0.2637643866293031,
|
||||
"pod_m3": 0.3313752553360355,
|
||||
"pod_m5": 0.5163077019241664
|
||||
},
|
||||
"future_sig": {
|
||||
"force-oid_m1": 0.25720592794565883,
|
||||
"force-oid_m2": 0.07069504954059229,
|
||||
"force-oid_m3": 0.07069504954059229,
|
||||
"force-oid_m5": 0.07069504954059229,
|
||||
"sig-oid_m1": 0.3378310203787158,
|
||||
"sig-oid_m2": 0.3147990569733715,
|
||||
"sig-oid_m3": 0.34429262568108926,
|
||||
"sig-oid_m5": 0.33509730308714486,
|
||||
"sig-pcd_m1": -0.002980254539846315,
|
||||
"sig-pcd_m2": 0.35229352094431515,
|
||||
"sig-pcd_m3": 0.3046193933085676,
|
||||
"sig-pcd_m5": 0.3332866518761705,
|
||||
"pod_m1": -0.01505551568170228,
|
||||
"pod_m2": 0.05972906227204257,
|
||||
"pod_m3": 0.050175749864584104,
|
||||
"pod_m5": 0.2244801105502782
|
||||
}
|
||||
}
|
||||
@ -1,38 +0,0 @@
|
||||
{
|
||||
"force": {
|
||||
"force-oid_m1": 0.3973693481528069,
|
||||
"force-oid_m2": 0.7503722371594272,
|
||||
"force-oid_m3": 0.7503722371594272,
|
||||
"force-oid_m5": 0.7503722371594272,
|
||||
"sig-oid_m1": 0.047626492192117884,
|
||||
"sig-oid_m2": -0.0899320785087113,
|
||||
"sig-oid_m3": -0.06793031697290859,
|
||||
"sig-oid_m5": 0.050723754778942164,
|
||||
"sig-pcd_m1": -0.032869874061091105,
|
||||
"sig-pcd_m2": -0.03470568581697929,
|
||||
"sig-pcd_m3": -0.0024393867643178763,
|
||||
"sig-pcd_m5": 0.20808527695100557,
|
||||
"pod_m1": -0.028581812678008658,
|
||||
"pod_m2": 0.41796895591108846,
|
||||
"pod_m3": 0.3922853200314628,
|
||||
"pod_m5": 0.5941700935980355
|
||||
},
|
||||
"future_sig": {
|
||||
"force-oid_m1": 0.0,
|
||||
"force-oid_m2": 0.0,
|
||||
"force-oid_m3": 0.0,
|
||||
"force-oid_m5": 0.0,
|
||||
"sig-oid_m1": 0.0,
|
||||
"sig-oid_m2": 0.0,
|
||||
"sig-oid_m3": 0.0,
|
||||
"sig-oid_m5": 0.0,
|
||||
"sig-pcd_m1": 0.0,
|
||||
"sig-pcd_m2": 0.0,
|
||||
"sig-pcd_m3": 0.0,
|
||||
"sig-pcd_m5": 0.0,
|
||||
"pod_m1": 0.0,
|
||||
"pod_m2": 0.0,
|
||||
"pod_m3": 0.0,
|
||||
"pod_m5": 0.0
|
||||
}
|
||||
}
|
||||
@ -1,22 +0,0 @@
|
||||
{
|
||||
"force": {
|
||||
"force-oid_m1": -0.37631685268054477,
|
||||
"force-oid_m2": -0.36667786382154544,
|
||||
"force-oid_m3": -0.36667786382154544,
|
||||
"force-oid_m5": -0.36667786382154544,
|
||||
"pod_m1": -0.612103472914134,
|
||||
"pod_m2": -0.6419928052688489,
|
||||
"pod_m3": -0.6434082821654477,
|
||||
"pod_m5": -0.6446791110017444
|
||||
},
|
||||
"suppression": {
|
||||
"force-oid_m1": -97.44012992232997,
|
||||
"force-oid_m2": -87.72179066942716,
|
||||
"force-oid_m3": -87.72179066942716,
|
||||
"force-oid_m5": -87.72179066942716,
|
||||
"pod_m1": -529.5134520588042,
|
||||
"pod_m2": -520.0375423818425,
|
||||
"pod_m3": -520.2501477100171,
|
||||
"pod_m5": -476.7696730765148
|
||||
}
|
||||
}
|
||||
@ -1,242 +0,0 @@
|
||||
{
|
||||
"scenes": [
|
||||
"steady_cloak",
|
||||
"karman_re100",
|
||||
"illusion_0.75L",
|
||||
"illusion_1.0L",
|
||||
"illusion_1.5L"
|
||||
],
|
||||
"comparison": {
|
||||
"steady_cloak": {
|
||||
"force": {
|
||||
"force-oid_m1": -0.37631685268054477,
|
||||
"force-oid_m2": -0.36667786382154544,
|
||||
"force-oid_m3": -0.36667786382154544,
|
||||
"force-oid_m5": -0.36667786382154544,
|
||||
"pod_m1": -0.612103472914134,
|
||||
"pod_m2": -0.6419928052688489,
|
||||
"pod_m3": -0.6434082821654477,
|
||||
"pod_m5": -0.6446791110017444
|
||||
},
|
||||
"suppression": {
|
||||
"force-oid_m1": -97.44012992232997,
|
||||
"force-oid_m2": -87.72179066942716,
|
||||
"force-oid_m3": -87.72179066942716,
|
||||
"force-oid_m5": -87.72179066942716,
|
||||
"pod_m1": -529.5134520588042,
|
||||
"pod_m2": -520.0375423818425,
|
||||
"pod_m3": -520.2501477100171,
|
||||
"pod_m5": -476.7696730765148
|
||||
}
|
||||
},
|
||||
"karman_re100": {
|
||||
"force": {
|
||||
"force-oid_m1": 0.3973693481528069,
|
||||
"force-oid_m2": 0.7503722371594272,
|
||||
"force-oid_m3": 0.7503722371594272,
|
||||
"force-oid_m5": 0.7503722371594272,
|
||||
"sig-oid_m1": 0.047626492192117884,
|
||||
"sig-oid_m2": -0.0899320785087113,
|
||||
"sig-oid_m3": -0.06793031697290859,
|
||||
"sig-oid_m5": 0.050723754778942164,
|
||||
"sig-pcd_m1": -0.032869874061091105,
|
||||
"sig-pcd_m2": -0.03470568581697929,
|
||||
"sig-pcd_m3": -0.0024393867643178763,
|
||||
"sig-pcd_m5": 0.20808527695100557,
|
||||
"pod_m1": -0.028581812678008658,
|
||||
"pod_m2": 0.41796895591108846,
|
||||
"pod_m3": 0.3922853200314628,
|
||||
"pod_m5": 0.5941700935980355
|
||||
},
|
||||
"future_sig": {
|
||||
"force-oid_m1": 0.0,
|
||||
"force-oid_m2": 0.0,
|
||||
"force-oid_m3": 0.0,
|
||||
"force-oid_m5": 0.0,
|
||||
"sig-oid_m1": 0.0,
|
||||
"sig-oid_m2": 0.0,
|
||||
"sig-oid_m3": 0.0,
|
||||
"sig-oid_m5": 0.0,
|
||||
"sig-pcd_m1": 0.0,
|
||||
"sig-pcd_m2": 0.0,
|
||||
"sig-pcd_m3": 0.0,
|
||||
"sig-pcd_m5": 0.0,
|
||||
"pod_m1": 0.0,
|
||||
"pod_m2": 0.0,
|
||||
"pod_m3": 0.0,
|
||||
"pod_m5": 0.0
|
||||
}
|
||||
},
|
||||
"illusion_0.75L": {
|
||||
"force": {
|
||||
"force-oid_m1": -0.0065221308964502865,
|
||||
"force-oid_m2": 0.43535277661654437,
|
||||
"force-oid_m3": 0.43535277661654437,
|
||||
"force-oid_m5": 0.43535277661654437,
|
||||
"sig-oid_m1": 0.017340158959890415,
|
||||
"sig-oid_m2": 0.30180744104636803,
|
||||
"sig-oid_m3": 0.09790190212866957,
|
||||
"sig-oid_m5": 0.02065845627823741,
|
||||
"sig-pcd_m1": -0.03521104267963627,
|
||||
"sig-pcd_m2": 0.20732705633252407,
|
||||
"sig-pcd_m3": 0.11988520616706469,
|
||||
"sig-pcd_m5": -1.5172646863328378,
|
||||
"pod_m1": -2.45640794420206,
|
||||
"pod_m2": -2.4260527205648605,
|
||||
"pod_m3": -3.5262547873981513,
|
||||
"pod_m5": -3.0459602065033202
|
||||
},
|
||||
"future_sig": {
|
||||
"force-oid_m1": 0.013511471239959334,
|
||||
"force-oid_m2": 0.07098337174417249,
|
||||
"force-oid_m3": 0.07098337174417249,
|
||||
"force-oid_m5": 0.07098337174417249,
|
||||
"sig-oid_m1": 0.3740715508827751,
|
||||
"sig-oid_m2": 0.6608883811088201,
|
||||
"sig-oid_m3": 0.5592259563419594,
|
||||
"sig-oid_m5": 0.533343435056657,
|
||||
"sig-pcd_m1": 0.20205641028110888,
|
||||
"sig-pcd_m2": 0.4672590946761527,
|
||||
"sig-pcd_m3": 0.4468990482184305,
|
||||
"sig-pcd_m5": 0.41968732641466205,
|
||||
"pod_m1": -0.2540973280602146,
|
||||
"pod_m2": -0.0339567217960513,
|
||||
"pod_m3": 0.054785729407538376,
|
||||
"pod_m5": 0.3000378545113639
|
||||
}
|
||||
},
|
||||
"illusion_1.0L": {
|
||||
"force": {
|
||||
"force-oid_m1": -0.22355143786066206,
|
||||
"force-oid_m2": 0.6705941647225692,
|
||||
"force-oid_m3": 0.6705941647225692,
|
||||
"force-oid_m5": 0.6705941647225692,
|
||||
"sig-oid_m1": -2.7646669027021566,
|
||||
"sig-oid_m2": -2.539151608216361,
|
||||
"sig-oid_m3": -1.47692206321327,
|
||||
"sig-oid_m5": -1.5110272636915942,
|
||||
"sig-pcd_m1": -1.6681874811094162,
|
||||
"sig-pcd_m2": -1.342076853642359,
|
||||
"sig-pcd_m3": 0.04249559758899473,
|
||||
"sig-pcd_m5": -0.3512030294634511,
|
||||
"pod_m1": -0.34310245755591257,
|
||||
"pod_m2": -0.23704245972213284,
|
||||
"pod_m3": -0.0736581270661334,
|
||||
"pod_m5": -0.09978939220221528
|
||||
},
|
||||
"future_sig": {
|
||||
"force-oid_m1": -0.688583875677999,
|
||||
"force-oid_m2": 0.0977498946249901,
|
||||
"force-oid_m3": 0.0977498946249901,
|
||||
"force-oid_m5": 0.0977498946249901,
|
||||
"sig-oid_m1": 0.3400013732837159,
|
||||
"sig-oid_m2": 0.5855599713349928,
|
||||
"sig-oid_m3": 0.6757301882995801,
|
||||
"sig-oid_m5": 0.6051731015609549,
|
||||
"sig-pcd_m1": -0.045583209960261946,
|
||||
"sig-pcd_m2": -0.07349660070560707,
|
||||
"sig-pcd_m3": 0.534579564047348,
|
||||
"sig-pcd_m5": 0.6365887267870092,
|
||||
"pod_m1": -0.3737860307570295,
|
||||
"pod_m2": -0.1596051084511593,
|
||||
"pod_m3": 0.08266261398865987,
|
||||
"pod_m5": -0.33155756162258626
|
||||
}
|
||||
},
|
||||
"illusion_1.5L": {
|
||||
"force": {
|
||||
"force-oid_m1": 0.5712134182396399,
|
||||
"force-oid_m2": 0.6397818250190341,
|
||||
"force-oid_m3": 0.6397818250190341,
|
||||
"force-oid_m5": 0.6397818250190341,
|
||||
"sig-oid_m1": 0.5371119596459986,
|
||||
"sig-oid_m2": 0.5689626851549741,
|
||||
"sig-oid_m3": 0.5480702090166246,
|
||||
"sig-oid_m5": 0.49764490473273426,
|
||||
"sig-pcd_m1": 0.02922230950403174,
|
||||
"sig-pcd_m2": 0.4747650262191032,
|
||||
"sig-pcd_m3": 0.5480885671190363,
|
||||
"sig-pcd_m5": 0.4953579764560622,
|
||||
"pod_m1": 0.03135560469147149,
|
||||
"pod_m2": 0.2637643866293031,
|
||||
"pod_m3": 0.3313752553360355,
|
||||
"pod_m5": 0.5163077019241664
|
||||
},
|
||||
"future_sig": {
|
||||
"force-oid_m1": 0.25720592794565883,
|
||||
"force-oid_m2": 0.07069504954059229,
|
||||
"force-oid_m3": 0.07069504954059229,
|
||||
"force-oid_m5": 0.07069504954059229,
|
||||
"sig-oid_m1": 0.3378310203787158,
|
||||
"sig-oid_m2": 0.3147990569733715,
|
||||
"sig-oid_m3": 0.34429262568108926,
|
||||
"sig-oid_m5": 0.33509730308714486,
|
||||
"sig-pcd_m1": -0.002980254539846315,
|
||||
"sig-pcd_m2": 0.35229352094431515,
|
||||
"sig-pcd_m3": 0.3046193933085676,
|
||||
"sig-pcd_m5": 0.3332866518761705,
|
||||
"pod_m1": -0.01505551568170228,
|
||||
"pod_m2": 0.05972906227204257,
|
||||
"pod_m3": 0.050175749864584104,
|
||||
"pod_m5": 0.2244801105502782
|
||||
}
|
||||
}
|
||||
},
|
||||
"steady_metrics": {
|
||||
"near-body": {
|
||||
"rms_reduction": NaN,
|
||||
"rms_ctl": NaN,
|
||||
"rms_blk": NaN,
|
||||
"enstrophy_reduction": NaN,
|
||||
"enstrophy_ctl": NaN,
|
||||
"enstrophy_blk": NaN
|
||||
},
|
||||
"near-wake": {
|
||||
"rms_reduction": NaN,
|
||||
"rms_ctl": NaN,
|
||||
"rms_blk": NaN,
|
||||
"enstrophy_reduction": NaN,
|
||||
"enstrophy_ctl": NaN,
|
||||
"enstrophy_blk": NaN
|
||||
},
|
||||
"downstream": {
|
||||
"rms_reduction": NaN,
|
||||
"rms_ctl": NaN,
|
||||
"rms_blk": NaN,
|
||||
"enstrophy_reduction": NaN,
|
||||
"enstrophy_ctl": NaN,
|
||||
"enstrophy_blk": NaN
|
||||
},
|
||||
"full-field": {
|
||||
"rms_reduction": 0.9943383932113647,
|
||||
"rms_ctl": 0.0010355355916544795,
|
||||
"rms_blk": 0.18290475010871887,
|
||||
"enstrophy_reduction": -9.052556311718561,
|
||||
"enstrophy_ctl": 0.00023778903875819755,
|
||||
"enstrophy_blk": 2.3654584106233742e-05
|
||||
},
|
||||
"recirculation": {
|
||||
"Lr_ctl_lattice": 269.0,
|
||||
"Lr_blk_lattice": 278.0,
|
||||
"Lr_collapse": 0.9676258992805755,
|
||||
"Ar_ctl": 1234.0,
|
||||
"Ar_blk": 2008.0,
|
||||
"Ar_collapse": 0.6145418326693227
|
||||
},
|
||||
"force": {
|
||||
"Fx_rms_blk": 7.68724476074567e-06,
|
||||
"Fx_rms_ctl": 9.525875793769956e-05,
|
||||
"Fx_reduction": -11.391794204711914,
|
||||
"Fy_rms_blk": 5.3614232456311584e-05,
|
||||
"Fy_rms_ctl": 8.95971152203856e-06,
|
||||
"Fy_reduction": 0.8328855633735657
|
||||
}
|
||||
},
|
||||
"force_sig_overlap": {
|
||||
"karman_re100": -0.03437428848388266,
|
||||
"illusion_0.75L": -0.08227475508863752,
|
||||
"illusion_1.0L": -0.4954059964164567,
|
||||
"illusion_1.5L": -0.9321433566377483
|
||||
},
|
||||
"steady_force_sig_overlap": 0.763
|
||||
}
|
||||
@ -1,11 +0,0 @@
|
||||
{
|
||||
"scene": "illusion_0.75L",
|
||||
"n_steps": 500,
|
||||
"observables": [
|
||||
"force_total",
|
||||
"sensor_error",
|
||||
"sensor_error_delayed",
|
||||
"p_sig_stack",
|
||||
"actions"
|
||||
]
|
||||
}
|
||||
@ -1,11 +0,0 @@
|
||||
{
|
||||
"scene": "illusion_1.0L",
|
||||
"n_steps": 500,
|
||||
"observables": [
|
||||
"force_total",
|
||||
"sensor_error",
|
||||
"sensor_error_delayed",
|
||||
"p_sig_stack",
|
||||
"actions"
|
||||
]
|
||||
}
|
||||
@ -1,11 +0,0 @@
|
||||
{
|
||||
"scene": "illusion_1.5L",
|
||||
"n_steps": 500,
|
||||
"observables": [
|
||||
"force_total",
|
||||
"sensor_error",
|
||||
"sensor_error_delayed",
|
||||
"p_sig_stack",
|
||||
"actions"
|
||||
]
|
||||
}
|
||||
@ -1,11 +0,0 @@
|
||||
{
|
||||
"scene": "karman_re100",
|
||||
"n_steps": 500,
|
||||
"observables": [
|
||||
"force_total",
|
||||
"sensor_error",
|
||||
"sensor_error_delayed",
|
||||
"p_sig_stack",
|
||||
"actions"
|
||||
]
|
||||
}
|
||||
@ -1,10 +0,0 @@
|
||||
{
|
||||
"scene": "steady_cloak",
|
||||
"n_steps": 500,
|
||||
"observables": [
|
||||
"force_total",
|
||||
"force_mag",
|
||||
"rms_uy",
|
||||
"ux_deviation"
|
||||
]
|
||||
}
|
||||
@ -1,14 +0,0 @@
|
||||
{
|
||||
"scene": "illusion_0.75L",
|
||||
"n_snapshots": 100,
|
||||
"dof": 67200,
|
||||
"ranks_computed": [
|
||||
6,
|
||||
8,
|
||||
10,
|
||||
12,
|
||||
16
|
||||
],
|
||||
"energy_r10_5modes": 0.9993127302081157,
|
||||
"energy_r10_10modes": 0.9999999999999999
|
||||
}
|
||||
@ -1,14 +0,0 @@
|
||||
{
|
||||
"scene": "illusion_1.0L",
|
||||
"n_snapshots": 100,
|
||||
"dof": 67200,
|
||||
"ranks_computed": [
|
||||
6,
|
||||
8,
|
||||
10,
|
||||
12,
|
||||
16
|
||||
],
|
||||
"energy_r10_5modes": 0.9994602597411208,
|
||||
"energy_r10_10modes": 0.9999999999999999
|
||||
}
|
||||
@ -1,14 +0,0 @@
|
||||
{
|
||||
"scene": "illusion_1.5L",
|
||||
"n_snapshots": 100,
|
||||
"dof": 67200,
|
||||
"ranks_computed": [
|
||||
6,
|
||||
8,
|
||||
10,
|
||||
12,
|
||||
16
|
||||
],
|
||||
"energy_r10_5modes": 0.9790184586892507,
|
||||
"energy_r10_10modes": 0.9999999999999999
|
||||
}
|
||||
@ -1,14 +0,0 @@
|
||||
{
|
||||
"scene": "karman_re100",
|
||||
"n_snapshots": 500,
|
||||
"dof": 67200,
|
||||
"ranks_computed": [
|
||||
6,
|
||||
8,
|
||||
10,
|
||||
12,
|
||||
16
|
||||
],
|
||||
"energy_r10_5modes": 0.999034936679307,
|
||||
"energy_r10_10modes": 1.0
|
||||
}
|
||||
@ -1,14 +0,0 @@
|
||||
{
|
||||
"scene": "steady_cloak",
|
||||
"n_snapshots": 100,
|
||||
"dof": 67200,
|
||||
"ranks_computed": [
|
||||
6,
|
||||
8,
|
||||
10,
|
||||
12,
|
||||
16
|
||||
],
|
||||
"energy_r10_5modes": 0.9971344497176375,
|
||||
"energy_r10_10modes": 1.0
|
||||
}
|
||||
@ -1,108 +0,0 @@
|
||||
{
|
||||
"rank_sensitivity": {
|
||||
"steady_cloak": {
|
||||
"r6": -0.4865,
|
||||
"r8": -0.7764,
|
||||
"r10": -0.7631,
|
||||
"r12": -0.7261,
|
||||
"r16": -0.6756
|
||||
},
|
||||
"karman_re100": {
|
||||
"r6": 0.1428,
|
||||
"r8": -0.0359,
|
||||
"r10": -0.0344,
|
||||
"r12": 0.0135,
|
||||
"r16": -0.0457
|
||||
},
|
||||
"illusion_0.75L": {
|
||||
"r6": -0.2016,
|
||||
"r8": 0.0782,
|
||||
"r10": -0.0823,
|
||||
"r12": -0.4977,
|
||||
"r16": 0.1241
|
||||
},
|
||||
"illusion_1.0L": {
|
||||
"r6": -0.4415,
|
||||
"r8": -0.4736,
|
||||
"r10": -0.4954,
|
||||
"r12": -0.4427,
|
||||
"r16": -0.4239
|
||||
},
|
||||
"illusion_1.5L": {
|
||||
"r6": -0.9675,
|
||||
"r8": -0.9586,
|
||||
"r10": -0.9321,
|
||||
"r12": -0.9262,
|
||||
"r16": -0.9099
|
||||
}
|
||||
},
|
||||
"tauc_sensitivity": {
|
||||
"0": {
|
||||
"overlap": 0.306,
|
||||
"sig_R2": 0.285
|
||||
},
|
||||
"10": {
|
||||
"overlap": 0.116,
|
||||
"sig_R2": 0.306
|
||||
},
|
||||
"15": {
|
||||
"overlap": 0.121,
|
||||
"sig_R2": 0.318
|
||||
},
|
||||
"20": {
|
||||
"overlap": 0.114,
|
||||
"sig_R2": 0.326
|
||||
},
|
||||
"25": {
|
||||
"overlap": 0.143,
|
||||
"sig_R2": 0.325
|
||||
},
|
||||
"30": {
|
||||
"overlap": 0.137,
|
||||
"sig_R2": 0.313
|
||||
},
|
||||
"35": {
|
||||
"overlap": 0.137,
|
||||
"sig_R2": 0.309
|
||||
},
|
||||
"40": {
|
||||
"overlap": 0.15,
|
||||
"sig_R2": 0.3
|
||||
},
|
||||
"50": {
|
||||
"overlap": 0.163,
|
||||
"sig_R2": 0.285
|
||||
},
|
||||
"60": {
|
||||
"overlap": 0.187,
|
||||
"sig_R2": 0.26
|
||||
}
|
||||
},
|
||||
"overlap_table": {
|
||||
"steady_cloak": {
|
||||
"overlap": -0.763,
|
||||
"force_R2_m2": null,
|
||||
"sig_R2_m2": null
|
||||
},
|
||||
"karman_re100": {
|
||||
"overlap": -0.034,
|
||||
"force_R2_m2": 0.75,
|
||||
"sig_R2_m2": 0.0
|
||||
},
|
||||
"illusion_0.75L": {
|
||||
"overlap": -0.082,
|
||||
"force_R2_m2": 0.435,
|
||||
"sig_R2_m2": 0.661
|
||||
},
|
||||
"illusion_1.0L": {
|
||||
"overlap": -0.495,
|
||||
"force_R2_m2": 0.671,
|
||||
"sig_R2_m2": 0.586
|
||||
},
|
||||
"illusion_1.5L": {
|
||||
"overlap": -0.932,
|
||||
"force_R2_m2": 0.64,
|
||||
"sig_R2_m2": 0.315
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,50 +0,0 @@
|
||||
{
|
||||
"near-body": {
|
||||
"rms_reduction": NaN,
|
||||
"rms_ctl": NaN,
|
||||
"rms_blk": NaN,
|
||||
"enstrophy_reduction": NaN,
|
||||
"enstrophy_ctl": NaN,
|
||||
"enstrophy_blk": NaN
|
||||
},
|
||||
"near-wake": {
|
||||
"rms_reduction": NaN,
|
||||
"rms_ctl": NaN,
|
||||
"rms_blk": NaN,
|
||||
"enstrophy_reduction": NaN,
|
||||
"enstrophy_ctl": NaN,
|
||||
"enstrophy_blk": NaN
|
||||
},
|
||||
"downstream": {
|
||||
"rms_reduction": NaN,
|
||||
"rms_ctl": NaN,
|
||||
"rms_blk": NaN,
|
||||
"enstrophy_reduction": NaN,
|
||||
"enstrophy_ctl": NaN,
|
||||
"enstrophy_blk": NaN
|
||||
},
|
||||
"full-field": {
|
||||
"rms_reduction": 0.9943383932113647,
|
||||
"rms_ctl": 0.0010355355916544795,
|
||||
"rms_blk": 0.18290475010871887,
|
||||
"enstrophy_reduction": -9.052556311718561,
|
||||
"enstrophy_ctl": 0.00023778903875819755,
|
||||
"enstrophy_blk": 2.3654584106233742e-05
|
||||
},
|
||||
"recirculation": {
|
||||
"Lr_ctl_lattice": 269.0,
|
||||
"Lr_blk_lattice": 278.0,
|
||||
"Lr_collapse": 0.9676258992805755,
|
||||
"Ar_ctl": 1234.0,
|
||||
"Ar_blk": 2008.0,
|
||||
"Ar_collapse": 0.6145418326693227
|
||||
},
|
||||
"force": {
|
||||
"Fx_rms_blk": 7.68724476074567e-06,
|
||||
"Fx_rms_ctl": 9.525875793769956e-05,
|
||||
"Fx_reduction": -11.391794204711914,
|
||||
"Fy_rms_blk": 5.3614232456311584e-05,
|
||||
"Fy_rms_ctl": 8.95971152203856e-06,
|
||||
"Fy_reduction": 0.8328855633735657
|
||||
}
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
{
|
||||
"obs_act_train": 0.9560973048210144,
|
||||
"oid_m3_act_train": 0.22518758634108724,
|
||||
"oid_m5_act_train": 0.22518758634108724,
|
||||
"oid_force_act_train": 0.23275911247341877
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
{
|
||||
"obs_act_train": 1.0,
|
||||
"oid_m3_act_train": 1.0,
|
||||
"oid_m5_act_train": 1.0,
|
||||
"oid_force_act_train": 1.0
|
||||
}
|
||||
@ -1,22 +0,0 @@
|
||||
{
|
||||
"scene_id": "illusion",
|
||||
"target_diameter": 0.75,
|
||||
"re_code": 100,
|
||||
"has_disturbance": false,
|
||||
"sample_interval": 400,
|
||||
"conv_len": 36,
|
||||
"action_scale": 8.0,
|
||||
"action_bias": "(0.0, -2.0, 2.0)",
|
||||
"source": "PPO_inference",
|
||||
"model_name": "d1a3o14_250525_imit_075L_2U_400S",
|
||||
"model_subdir": "250525",
|
||||
"n_objects_env": 6,
|
||||
"obs_slice": "(0, 12)",
|
||||
"sensor_x": 30.0,
|
||||
"pinball_front_x": 19.0,
|
||||
"pinball_rear_x": 20.3,
|
||||
"target_type": "periodic",
|
||||
"s_dim": 14,
|
||||
"u0": 0.01,
|
||||
"nu": 0.004
|
||||
}
|
||||
@ -1,19 +0,0 @@
|
||||
{
|
||||
"force_norm_fact": 0.013479236047714949,
|
||||
"sens_deviation": [
|
||||
0.965008020401001,
|
||||
-0.1526976078748703,
|
||||
0.6489765048027039,
|
||||
-0.06444232910871506,
|
||||
0.9245625734329224,
|
||||
0.08070430159568787
|
||||
],
|
||||
"sens_norm_fact": [
|
||||
2.0229625701904297,
|
||||
2.7387166023254395,
|
||||
0.718299150466919,
|
||||
3.7571909427642822,
|
||||
2.226552963256836,
|
||||
2.3796002864837646
|
||||
]
|
||||
}
|
||||
@ -1,8 +0,0 @@
|
||||
{
|
||||
"scene": "illusion_0.75L",
|
||||
"n_steps": 500,
|
||||
"n_fields": 500,
|
||||
"max_diff_sensors": 0.024977535009384155,
|
||||
"max_diff_forces": 0.00013672513887286186,
|
||||
"passed": false
|
||||
}
|
||||
@ -1,5 +0,0 @@
|
||||
{
|
||||
"scene": "illusion_0.75L",
|
||||
"similarity": 0.9778722936248825,
|
||||
"avg_reward": 0.9047735030264852
|
||||
}
|
||||
@ -1,8 +0,0 @@
|
||||
{
|
||||
"x_start": 400,
|
||||
"x_end": 1000,
|
||||
"y_start": 100,
|
||||
"y_end": 400,
|
||||
"nx_full": 1280,
|
||||
"ny_full": 512
|
||||
}
|
||||
@ -1,194 +0,0 @@
|
||||
[
|
||||
{
|
||||
"dc": 0.004288806384429336,
|
||||
"amps": [
|
||||
3.867643886532577e-05,
|
||||
8.10982554702712e-06,
|
||||
7.859622886737542e-06,
|
||||
7.484443176696677e-06,
|
||||
2.7307535748116605e-06
|
||||
],
|
||||
"freqs": [
|
||||
0.06666666666666667,
|
||||
0.09333333333333334,
|
||||
0.08666666666666667,
|
||||
0.18000000000000002,
|
||||
0.1
|
||||
],
|
||||
"phases": [
|
||||
2.3518973709846627,
|
||||
-1.796801490500405,
|
||||
1.1546261988405588,
|
||||
1.6670992311673392,
|
||||
-1.8148531194808986
|
||||
]
|
||||
},
|
||||
{
|
||||
"dc": 6.611318288681408e-08,
|
||||
"amps": [
|
||||
0.0011727050410124658,
|
||||
2.8624737581179417e-06,
|
||||
2.3120748935630534e-06,
|
||||
1.913405554014694e-06,
|
||||
1.4916942639506773e-06
|
||||
],
|
||||
"freqs": [
|
||||
0.03333333333333333,
|
||||
0.04,
|
||||
0.02666666666666667,
|
||||
0.1,
|
||||
0.04666666666666667
|
||||
],
|
||||
"phases": [
|
||||
-1.5396141333211708,
|
||||
-1.502183840490501,
|
||||
1.542545108742733,
|
||||
-3.0523812969262663,
|
||||
-1.4766522986838706
|
||||
]
|
||||
},
|
||||
{
|
||||
"dc": 1.0452000657717386,
|
||||
"amps": [
|
||||
0.2490168413370039,
|
||||
0.03196106678826732,
|
||||
0.030129730520972717,
|
||||
0.009545855931047245,
|
||||
0.0052979381131052685
|
||||
],
|
||||
"freqs": [
|
||||
0.03333333333333333,
|
||||
0.06666666666666667,
|
||||
0.1,
|
||||
0.13333333333333333,
|
||||
0.16666666666666669
|
||||
],
|
||||
"phases": [
|
||||
1.9284181083910856,
|
||||
-2.805455098676593,
|
||||
-0.6774611932943557,
|
||||
0.7045231616558613,
|
||||
2.8366240816400716
|
||||
]
|
||||
},
|
||||
{
|
||||
"dc": -0.022108709638317427,
|
||||
"amps": [
|
||||
0.33062120488826224,
|
||||
0.085466420051489,
|
||||
0.03848810850571079,
|
||||
0.021845504041592488,
|
||||
0.007366737753470575
|
||||
],
|
||||
"freqs": [
|
||||
0.03333333333333333,
|
||||
0.06666666666666667,
|
||||
0.1,
|
||||
0.13333333333333333,
|
||||
0.16666666666666669
|
||||
],
|
||||
"phases": [
|
||||
0.10526240338835878,
|
||||
1.2336233431106873,
|
||||
-2.993558879364546,
|
||||
-1.6595702718560297,
|
||||
0.04638768948448721
|
||||
]
|
||||
},
|
||||
{
|
||||
"dc": 0.9039576029777527,
|
||||
"amps": [
|
||||
0.07167722007732066,
|
||||
0.008455145382412424,
|
||||
0.0008355443381295635,
|
||||
0.0004434704085524895,
|
||||
0.00038944014390486894
|
||||
],
|
||||
"freqs": [
|
||||
0.06666666666666667,
|
||||
0.13333333333333333,
|
||||
0.2,
|
||||
0.08666666666666667,
|
||||
0.07333333333333333
|
||||
],
|
||||
"phases": [
|
||||
0.01610219282767969,
|
||||
-2.5472804218658154,
|
||||
2.6043817288585753,
|
||||
-0.20028348583077973,
|
||||
-0.052958796236958955
|
||||
]
|
||||
},
|
||||
{
|
||||
"dc": -0.00025899683435757955,
|
||||
"amps": [
|
||||
0.6374950938195748,
|
||||
0.12727921260996708,
|
||||
0.01236390301707262,
|
||||
0.001585002779397481,
|
||||
0.0014952228942171637
|
||||
],
|
||||
"freqs": [
|
||||
0.03333333333333333,
|
||||
0.1,
|
||||
0.16666666666666669,
|
||||
0.23333333333333334,
|
||||
0.02666666666666667
|
||||
],
|
||||
"phases": [
|
||||
0.18621520809833447,
|
||||
-2.490098016654734,
|
||||
0.8234137753336294,
|
||||
1.4855191921699353,
|
||||
-3.012903891295194
|
||||
]
|
||||
},
|
||||
{
|
||||
"dc": 1.0451257570584616,
|
||||
"amps": [
|
||||
0.249032381614892,
|
||||
0.031892580118794304,
|
||||
0.030343599142882465,
|
||||
0.009523592115438963,
|
||||
0.005294591792702297
|
||||
],
|
||||
"freqs": [
|
||||
0.03333333333333333,
|
||||
0.06666666666666667,
|
||||
0.1,
|
||||
0.13333333333333333,
|
||||
0.16666666666666669
|
||||
],
|
||||
"phases": [
|
||||
-1.21261635847399,
|
||||
-2.7937041895506307,
|
||||
2.470675898199732,
|
||||
0.7075951135175842,
|
||||
-0.29918486295834723
|
||||
]
|
||||
},
|
||||
{
|
||||
"dc": 0.0218141422731181,
|
||||
"amps": [
|
||||
0.3306555630520273,
|
||||
0.08525310044097616,
|
||||
0.03854304908063903,
|
||||
0.021844207270571098,
|
||||
0.007391380249557636
|
||||
],
|
||||
"freqs": [
|
||||
0.03333333333333333,
|
||||
0.06666666666666667,
|
||||
0.1,
|
||||
0.13333333333333333,
|
||||
0.16666666666666669
|
||||
],
|
||||
"phases": [
|
||||
0.10553169270939981,
|
||||
-1.9042662483555461,
|
||||
-2.9874980441189214,
|
||||
1.4867528430300943,
|
||||
0.05308355810883716
|
||||
]
|
||||
}
|
||||
]
|
||||
@ -1,22 +0,0 @@
|
||||
{
|
||||
"scene_id": "illusion",
|
||||
"target_diameter": 1.0,
|
||||
"re_code": 100,
|
||||
"has_disturbance": false,
|
||||
"sample_interval": 600,
|
||||
"conv_len": 36,
|
||||
"action_scale": 8.0,
|
||||
"action_bias": "(0.0, -2.0, 2.0)",
|
||||
"source": "PPO_inference",
|
||||
"model_name": "d1a3o14_250525_imit_1L_2U_600S",
|
||||
"model_subdir": "250525",
|
||||
"n_objects_env": 6,
|
||||
"obs_slice": "(0, 12)",
|
||||
"sensor_x": 30.0,
|
||||
"pinball_front_x": 19.0,
|
||||
"pinball_rear_x": 20.3,
|
||||
"target_type": "periodic",
|
||||
"s_dim": 14,
|
||||
"u0": 0.01,
|
||||
"nu": 0.004
|
||||
}
|
||||
@ -1,19 +0,0 @@
|
||||
{
|
||||
"force_norm_fact": 0.01349699031561613,
|
||||
"sens_deviation": [
|
||||
0.9299430251121521,
|
||||
-0.10059388726949692,
|
||||
0.6555976867675781,
|
||||
0.026127038523554802,
|
||||
0.9600314497947693,
|
||||
0.13349571824073792
|
||||
],
|
||||
"sens_norm_fact": [
|
||||
2.216992139816284,
|
||||
2.485595226287842,
|
||||
0.7317331433296204,
|
||||
3.5744357109069824,
|
||||
2.063714027404785,
|
||||
2.6462197303771973
|
||||
]
|
||||
}
|
||||
@ -1,8 +0,0 @@
|
||||
{
|
||||
"scene": "illusion_1.0L",
|
||||
"n_steps": 500,
|
||||
"n_fields": 500,
|
||||
"max_diff_sensors": 0.03690147399902344,
|
||||
"max_diff_forces": 6.176112219691277e-05,
|
||||
"passed": false
|
||||
}
|
||||
@ -1,5 +0,0 @@
|
||||
{
|
||||
"scene": "illusion_1.0L",
|
||||
"similarity": 0.9790879583361353,
|
||||
"avg_reward": 0.8926267057821485
|
||||
}
|
||||
@ -1,8 +0,0 @@
|
||||
{
|
||||
"x_start": 400,
|
||||
"x_end": 1000,
|
||||
"y_start": 100,
|
||||
"y_end": 400,
|
||||
"nx_full": 1280,
|
||||
"ny_full": 512
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user