CCD analysis: correction-field framework complete (Round 6)

- Shift analysis from raw-field q_ctl to correction-field dq_ctl = q_ctl - q_blk
- Force/action/signature CCD for illusion 0.75L, 1.0L, 1.5L
- Zone-restricted CCD (near_body/body_wake/sensor_zone) with spatial separation evidence
- 1.5L identified as special mechanism (low action coupling, phase drift)
- Karman reference data collected (q_in, q_blk)
- Snapshot POD speedup (96x96 instead of 1310720x96)
- Comprehensive report: docs/ccd_correction_field_report.md (412 lines)
- Handover document: docs/ccd_handover.md

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Frank14f 2026-06-22 19:30:16 +08:00
parent 92845d6026
commit 85d1222139
36 changed files with 6918 additions and 1223 deletions

View File

@ -0,0 +1,412 @@
# 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

74
docs/ccd_handover.md Normal file
View File

@ -0,0 +1,74 @@
# 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

109
docs/sr_ccd_oid_mapping.md Normal file
View File

@ -0,0 +1,109 @@
# 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

View File

@ -1,236 +1,170 @@
# CCD_analysis: Observable-Correlated Decomposition for Flow Control
# CCD_analysis: Observable-Correlated Decomposition for Fluidic Pinball Control
## Overview
## Quick Start for New Agent
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?"
**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)
The pipeline covers four reference cases at Re=100 (code convention, Re_D=50):
---
| Case | Control | Target Type | Source |
|------|---------|-------------|--------|
| **pinball** | None (uncontrolled) | Periodic | Open-loop CFD |
| **steady_cloak** | Constant rotation (rear 5.1xU0) | Steady | Open-loop CFD |
| **karman_re100** | DRL PPO (d1a3o12_re100) | Periodic | PPO inference |
| **illusion_1L** | DRL PPO (d1a3o14_250525_imit_1L_2U_600S) | Periodic | PPO inference |
## What This Pipeline Does
For background:
- `ccd_notes.md` -- execution plan and methodological discussion
- `ccd_knowledge.md` -- confirmed facts, lessons learned, and pitfalls
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)
---
## Directory Structure
```
CCD_analysis/
configs.py # Unified scene metadata (4 cases)
configs/
config_cuda.json # Legacy CFD CUDA config (copied from CelerisLab)
config_flowfield.json # Legacy CFD flow field config (copied)
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
utils/
__init__.py # Non-pycuda exports (resampling, POD, CCD)
cfd_interface.py # LegacyCelerisLab wrapper (requires pycuda_3_10)
resampling.py # Phase resampling, POD, CCD algorithms (CPU-only)
data/
pinball/pinball/ # Uncontrolled pinball: sensors.npz, fields.npz
steady_cloak/steady_cloak/ # Steady cloak: sensors.npz, fields.npz
karman/karman_re100/ # Karman cloak: target.npz, norm.json, controlled.npz
illusion/illusion_1L/ # Illusion: target.npz, norm.json, controlled.npz
resampled/ # Phase-resampled data (24 pts/cycle)
ccd/ # CCD results (ccd_results.json)
steady/ # Steady metrics (steady_metrics.json)
resampling.py -- POD, CCD, field loading (CPU only)
cfd_interface.py -- LegacyCelerisLab wrapper (GPU needed)
__init__.py -- re-exports from resampling.py
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
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 # POD + force/action CCD computation
steady/
run_steady.py # Steady cloak metrics
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
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)
```
## Key Design Decisions
---
### 1. Scene Metadata Driven
## How to Regenerate Data
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.
All commands from repo root. Environment: `conda run -n pycuda_3_10`.
### 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)
### GPU Data Collection
```bash
# Pinball baseline (uncontrolled, 6 objects)
conda run -n pycuda_3_10 python src/CCD_analysis/scripts/collect_pinball.py --device 2
# 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
# Steady cloak (open-loop constant rotation)
conda run -n pycuda_3_10 python src/CCD_analysis/scripts/collect_steady_cloak.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
# Karman cloak re100 (PPO, 7 objects)
conda run -n pycuda_3_10 python src/CCD_analysis/scripts/collect_karman.py --device 2 --steps 200
# 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
# 1L Illusion (PPO, 2U=0.02)
conda run -n pycuda_3_10 python src/CCD_analysis/scripts/collect_illusion.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
```
### Resampling (no GPU needed)
### 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)
```bash
python3 src/CCD_analysis/scripts/resample.py
# 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
```
### CCD Analysis (no GPU needed)
---
```bash
python3 src/CCD_analysis/ccd/run_ccd.py
## Key Result Files
# Steady metrics
python3 src/CCD_analysis/steady/run_steady.py
```
Write all results to `src/CCD_analysis/data/ccd/`:
## Pipeline Workflow
| 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` |
```
┌─────────────────────┐
│ configs.py │
│ (scene metadata) │
└────────┬────────────┘
┌──────────────┼──────────────┐
▼ ▼ ▼
┌─────────────────┐ ┌──────────┐ ┌──────────┐
│ collect_pinball │ │collect_ │ │collect_ │
│ collect_steady │ │karman.py │ │illusion │
│ _(open-loop) │ │(PPO) │ │ .py(PPO) │
└────────┬────────┘ └────┬─────┘ └────┬─────┘
│ │ │
▼ ▼ ▼
┌──────────────────────────────────────────┐
│ data/{scene_id}/{scene_name}/ │
│ sensors.npz, forces.npz, fields.npz │
│ controlled.npz, target.npz, norm.json │
└──────────────────┬───────────────────────┘
┌────────────────┐
│ scripts/ │
│ resample.py │
│ (24 pts/cycle) │
└───────┬────────┘
┌────────────────┐
│ data/resampled/│
└───────┬────────┘
┌────────────┴────────────┐
▼ ▼
┌──────────────┐ ┌──────────────┐
│ ccd/run_ccd │ │ steady/ │
│ POD + CCD │ │ run_steady │
└──────┬───────┘ └──────┬───────┘
│ │
▼ ▼
┌──────────────┐ ┌──────────────┐
│ data/ccd/ │ │ data/steady/ │
│ ccd_results │ │ steady_ │
│ .json │ │ metrics.json │
└──────────────┘ └──────────────┘
```
---
## Known Issues and Caveats
## Known Pitfalls (New Agent Must Read)
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.
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.
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.
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.
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.
3. **DO NOT use `nu_from_re()`** for illusion models. Only valid for standard u0=0.01, S_DIM=12 cases.
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.
4. **Main analysis object is `dq_ctl = q_ctl - q_blk`**, not raw `q_ctl`. Use `correction_analysis/compute_correction_fields.py`.
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.
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.
## File Reference
6. **GPU state contamination**: Running PPO inference after other CFD on same GPU degrades similarity. Use a fresh GPU.
| 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 |
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.

View File

@ -1,180 +1,344 @@
"""CCD analysis pipeline: POD + force/action/signature CCD.
"""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.
Usage:
python ccd/run_ccd.py
conda run -n pycuda_3_10 python ccd/run_ccd.py
Requires resampled data from scripts/resample.py.
Requires fields_aligned.npz and phase_plan.json in data/ directories.
"""
from __future__ import annotations
import json
import os
import sys
import time
import numpy as np
_ANALYSIS = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if _ANALYSIS not in sys.path:
sys.path.insert(0, _ANALYSIS)
_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.configs import DATA_DIR, SCENES, NX, NY
from CCD_analysis.utils.resampling import (
compute_pod, cumulative_energy, e95_index,
compute_reduced_ccd, stack_velocity_fields,
compute_reduced_ccd,
load_aligned_fields, make_force_obs,
build_field_matrix, project_into_basis,
)
# -- Protocol constants ---------------------------------------------------
R_CANDIDATES = [6, 8, 10]
CCD_Q = 12
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
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)
# ---------------------------------------------------------------------------
# Preflight check (built-in)
# ---------------------------------------------------------------------------
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 main():
print("=== CCD Pipeline ===\n")
# Identify which cases have resampled data
resampled_dir = os.path.join(DATA_DIR, "resampled")
if not os.path.isdir(resampled_dir):
print("ERROR: run scripts/resample.py first")
return 1
cases = sorted(os.listdir(resampled_dir))
print(f"Resampled cases: {cases}")
# --- POD ---
print("\n--- POD ---")
snapshots = []
case_ranges = {}
idx = 0
for name in cases:
d = load_resampled(name)
if d is None:
continue
ux, uy = d.get("ux"), d.get("uy")
if ux is None:
print(f" {name}: no field data, skip POD")
continue
n_cyc, n_pt = ux.shape[0], ux.shape[1]
for c in range(n_cyc):
for p in range(n_pt):
q = np.concatenate([ux[c, p].ravel(), uy[c, p].ravel()])
snapshots.append(q)
case_ranges[name] = (idx, idx + n_cyc * n_pt)
idx += n_cyc * n_pt
print(f" {name}: {n_cyc}x{n_pt} snapshots")
if not snapshots:
print("No field data for POD")
return 1
Q = np.column_stack(snapshots)
mean_field, modes, s, coeffs = compute_pod(Q)
energy = cumulative_energy(s)
e95 = e95_index(energy)
print(f" POD: {len(s)} modes, E95={e95}")
for i in range(min(6, len(s))):
print(f" mode {i+1}: energy={energy[i]:.4f}")
# --- CCD for each case ---
print("\n--- CCD ---")
all_results = {}
W_dict = {}
for r in R_CANDIDATES:
print(f"\n POD truncation r={r}")
for name in cases:
d = load_resampled(name)
if d is None:
continue
# POD coefficients for this case
if name in case_ranges:
start, end = case_ranges[name]
a_r = coeffs[:r, start:end]
else:
# Projection case (not in POD basis)
ux, uy = d.get("ux"), d.get("uy")
if ux is None:
continue
proj_snapshots = []
for c in range(ux.shape[0]):
for p in range(ux.shape[1]):
q = np.concatenate([ux[c, p].ravel(), uy[c, p].ravel()])
proj_snapshots.append(q)
Q_proj = np.column_stack(proj_snapshots)
Q_centered = Q_proj - mean_field[:, None]
a_r = (modes[:, :r].T @ Q_centered)
N = a_r.shape[1]
if N < 24:
print(f" {name}: too few samples ({N})")
continue
# Force CCD
forces = d.get("forces")
if forces is not None:
f = forces.reshape(-1, forces.shape[-1])
Fx = f[:, 0] + f[:, 2] + f[:, 4]
Fy = f[:, 1] + f[:, 3] + f[:, 5]
y_force = np.vstack([Fx, Fy])
if y_force.shape[1] >= N:
y_f = y_force[:, :N]
else:
y_f = y_force
W, sigma, z = compute_reduced_ccd(a_r[:, :y_f.shape[1]], y_f, Q_delay=CCD_Q)
ccd_ene = cumulative_energy(sigma)
m80 = int(np.searchsorted(ccd_ene, 0.80) + 1) if len(ccd_ene) > 0 else 0
key = f"{name}_force_r{r}"
W_dict[key] = W
all_results[key] = {"case": name, "observable": "force", "r": r,
"m80": m80, "sigma_top3": [float(sigma[i]) for i in range(min(3, len(sigma)))]}
print(f" {key}: m80={m80}")
# Action CCD (for controlled cases)
actions = d.get("actions")
if actions is not None:
y_act = actions.reshape(-1, actions.shape[-1]).T
if y_act.shape[1] >= N:
y_a = y_act[:, :N]
else:
y_a = y_act
W, sigma, z = compute_reduced_ccd(a_r[:, :y_a.shape[1]], y_a, Q_delay=CCD_Q)
ccd_ene = cumulative_energy(sigma)
m80 = int(np.searchsorted(ccd_ene, 0.80) + 1) if len(ccd_ene) > 0 else 0
key = f"{name}_action_r{r}"
W_dict[key] = W
all_results[key] = {"case": name, "observable": "action", "r": r,
"m80": m80, "sigma_top3": [float(sigma[i]) for i in range(min(3, len(sigma)))]}
print(f" {key}: m80={m80}")
# --- Modal overlap ---
print("\n--- Modal Overlap ---")
force_keys = [k for k in W_dict if "force" in k]
for i, ka in enumerate(force_keys):
for kb in force_keys[i+1:]:
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)
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}")
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)
# 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")
print(f"\nSaved to {out_dir}/ccd_results.json", flush=True)
print(f"Total entries: {len(all_results)}", flush=True)
return 0

View File

@ -1,184 +1,138 @@
# CCD Analysis: Lessons Learned & Knowledge Base
# CCD 分析知识库 — 最终版本 (2026-06-22)
## 项目全局知识
## 工作阶段总览
### 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. 每一步都输出数值指标, 不要只看图
---
## 工具函数状态清单
| 文件 | 状态 | 说明 |
| 阶段 | 状态 | 说明 |
|------|------|------|
| `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 依赖, 纯报告脚本 |
| 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 模态就是瞬时力的主载体。"

View File

@ -1,594 +1,72 @@
## 目标
# CCD 分析方法与工作规划 — 最终版本
当前分析的核心不是再证明控制有效,而是识别 **控制实际作用于哪些流动结构通道**,并把黑箱控制关系推进成可解释的结构关系:
## 工作阶段总览
\[
\text{obs} \rightarrow z \rightarrow \text{act} \rightarrow \text{wake structure} \rightarrow \text{signature}
\]
### Round 5 (raw-field baseline) — 完成
在当前 pinball 问题中,控制器依赖各圆柱受力与下游三点速度观测,目标是在无扰动来流下实现 stealth 与 illusion。由于任务指标本质上围绕下游传感器信号定义仅按能量排序的 POD 不足以回答“哪些结构真正与控制和感知结果相关”。CCD 按与 observable 的互相关强度排序模态,适合提取与动作、受力或目标 signature 最相关的结构 [Lyu23]。公共 POD 则提供跨 case 可比的统一坐标系 [Den18b, Den20]
`ccd_knowledge.md` 中的 Round 5 章节。所有 `ccd/` 目录下脚本已冻结不修改。
第一版方案采用 **POD-reduced CCD**:先构造参考 POD 基底,再在低维系数空间里做 CCD。这不是逐式复现 [Lyu23],而是受其思想启发的 reduced implementation目标是提升稳健性、降低样本需求并保证不同 case 在同一坐标系中比较。
### Round 6 (correction-field framework) — 全部完成
## 当前阶段的定位
**产出列表**:
Round 1 和 Round 2 已经证明了以下几点:
| 文件 | 说明 |
|------|------|
| `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` | 三线映射草稿 |
- phase-based reduced CCD 的数据管道可以跑通
- 周期检测、相位重采样、POD、CCD 和指标汇总可以稳定实现
- 真实 illusion PPO replay 必须完整复现 legacy 环境中的 target 谐波、norm 计算和 FIFO 初始化,否则推理无效
- 在简单周期尾迹上CCD pipeline 是自洽的,但当前结果仍属于 **机制线索**,而不是最终机制结论
**数据采集 (GPU)**:
- karman_q_in (涡街无pinball) 和 karman_q_blk (涡街+pinball无控制) — 各 96帧 aligned 场
- karman_re100 — 72帧 (3 cycles, 每周期18点, rho=1.33)
因此,下一轮工作的目标不是再证明代码能跑,而是补齐对比链条中缺失的关键参照,尤其是:
---
1. target case 的 force 数据
2. reference POD basis 的稳定性
3. force / action / signature 三类 CCD 的跨 case 比较
4. cloak 稳态线的完整恢复指标
## 已完成 vs 未完成
## 口径与 case 定义
### 已完成
### Reynolds 数口径
- 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 数定义,后续所有脚本、文件名、图注和表格都必须显式区分,不能混写。
### 未完成/延后
| 记号 | 特征长度 | 含义 |
|---|---|---|
| \(Re\) | 两倍 pinball 单圆柱直径 | 与目标 2D 圆柱比较时常用的口径 |
| \(Re_D\) | pinball 单个圆柱直径 | pinball 本体口径 |
- 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=100\)**
- 若需换算到 \(Re_D\),必须在元数据中单独写明
## 当前建议的继续方向
### 当前处理的 case
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 | 流动类型 | 目标 | 当前角色 |
|---|---|---|---|
| uncontrolled | 无控制 pinball 自然尾迹 | 基线 | 周期辅助 case |
| cloak | 受控 pinball | 槽道基流 | 稳态主分析对象 |
| illusion | 受控 pinball | 2D 圆柱在 \(Re=100\) 下的目标尾迹 | 周期主分析对象 |
| target channel | 槽道基流 | 自身 | cloak 参考 |
| target cylinder | 2D 圆柱尾迹 | 自身 | 周期参考 |
2. **1.5L force-sig overlap** — 补齐后可与 0.75L/1.0L 比较。已在 `correction_analysis/run_15L_correction.py` 中有 signature line 框架,补 O 值即可。
具体说明:
3. **SR-CCD-OID 映射校正**`docs/sr_ccd_oid_mapping.md` 需要根据 SR 和 OID 两方向的实际现状校正。
- **cloak 的 target** 是槽道基流
- **illusion 的 target** 只考虑 2D 圆柱、\(Re=100\) 的目标尾迹
- 周期线的参考频率与参考相位由 **target cylinder** 定义
---
## 分析总路线
## 成功标准回顾
### 周期线
这套分析框架若要算成功,至少应支持以下判断:
用于:
- 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 版本
- [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

View File

@ -3,6 +3,10 @@
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.
@ -14,12 +18,13 @@ 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
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
D_CYL = 20.0
D_REF = 40.0
L0 = 20.0
@ -27,18 +32,18 @@ NX = 1280
NY = 512
CENTER_Y = (NY - 1) / 2.0
FIFO_LEN = 150
CONV_LEN = 30
# CONV_LEN is per-scene. Illusion=36, Karman/Steady=30.
# Set locally in collection scripts, not as a global here.
def nu_from_re(re_code: float, u0: float = U0) -> float:
"""Viscosity from code Reynolds number (reference length = 2*D)."""
return u0 * D_REF / re_code
# -- Scene definitions -------------------------------------------------------
SCENES: Dict[str, Any] = {}
# -- Pure Pinball (uncontrolled baseline, 6 objects, no disturbance) ---------
# -- Pure Pinball (uncontrolled baseline) ------------------------------------
SCENES["pinball"] = {
"scene_id": "pinball",
"re_code": 100,
@ -57,7 +62,7 @@ SCENES["pinball"] = {
"nu": nu_from_re(100),
}
# -- Steady Cloak (open-loop constant rotation, 6 objects) --------------------
# -- Steady Cloak (open-loop constant rotation) ------------------------------
SCENES["steady_cloak"] = {
"scene_id": "steady_cloak",
"re_code": 100,
@ -78,7 +83,7 @@ SCENES["steady_cloak"] = {
"omega_rear_scale": 5.1,
}
# -- Karman Cloak (PPO, 7 objects, disturbance cylinder) ---------------------
# -- Karman Cloak re100 (PPO, cloak validation only) -------------------------
SCENES["karman_re100"] = {
"scene_id": "karman",
"re_code": 100,
@ -100,48 +105,134 @@ SCENES["karman_re100"] = {
"nu": nu_from_re(100),
}
# -- 1L Illusion (PPO, 6 objects, 2U=0.02) ----------------------------------
SCENES["illusion_1L"] = {
"scene_id": "illusion",
# -- Karman q_in (target/incoming vortex street, no pinball) ------------------
SCENES["karman_q_in"] = {
"scene_id": "karman_target",
"re_code": 100,
"target_diameter": 1.0,
"has_disturbance": False,
"sample_interval": 600,
"action_scale": 8.0,
"action_bias": (0.0, -2.0, 2.0),
"source": "PPO_inference",
"model_name": "d1a3o14_250525_imit_1L_2U_600S",
"model_subdir": "250525",
"n_objects_env": 6,
"obs_slice": (0, 12),
"sensor_x": 30.0,
"pinball_front_x": 19.0,
"pinball_rear_x": 20.3,
"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": 14,
"u0": 0.02,
"nu": nu_from_re(100, u0=0.02),
"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",
"re_code": 100,
"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),
}
# -- 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:
@ -152,7 +243,6 @@ 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)

View File

@ -0,0 +1,378 @@
"""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.")

View File

@ -0,0 +1,216 @@
"""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())

View File

@ -0,0 +1,409 @@
"""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()

View File

@ -0,0 +1,153 @@
"""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.")

View File

@ -0,0 +1,366 @@
"""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())

View File

@ -0,0 +1,563 @@
"""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())

View File

@ -0,0 +1,344 @@
#!/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()

View File

@ -0,0 +1,359 @@
"""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())

View File

@ -0,0 +1,419 @@
"""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()

View File

@ -0,0 +1,103 @@
"""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")

View File

@ -1,9 +1,13 @@
"""1L Illusion DRL inference (2U=0.02).
"""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.
Usage:
conda run -n pycuda_3_10 python scripts/collect_illusion.py --device 2 --steps 200
conda run -n pycuda_3_10 python scripts/collect_illusion.py --device 2 --steps 500
Output: data/illusion/illusion_1L/
Output: data/illusion/{scene_name}/
"""
from __future__ import annotations
@ -19,13 +23,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)
_ANALYSIS = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if _ANALYSIS not in sys.path:
sys.path.insert(0, _ANALYSIS)
_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, model_path_for_scene, LEGACY_CFG_DIR
from CCD_analysis.configs import get_scene, get_scene_list, 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,
@ -60,7 +64,9 @@ 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)
ff_tgt.add_cylinder((20.0 * L0, CENTER_Y, 0.0), 1.0 * L0)
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)
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
@ -114,7 +120,9 @@ 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}")
# Bias FIFO (matches legacy_env_imit: [0,0,0,0,-1*U0,1*U0])
# 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.
ff.apply_ddf()
bias = np.zeros(n_env, dtype=DATA_TYPE)
bias[4] = -1.0 * u0
@ -124,6 +132,12 @@ 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 ===
@ -157,13 +171,18 @@ 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])
# 14-dim obs
# obs dimension depends on model type:
# d1a3o12_* = 12-dim (forces + sens only)
# d1a3o14_* = 14-dim (forces + sens + target_cd + target_cl)
forces_norm = obs_slice[6:12] / force_norm_fact
sens_norm = (obs_slice[0:6] - sens_deviation) / sens_norm_fact
target_recon = gen_target_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)
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)
# Reward
sarr = np.array(fifo, dtype=np.float32)
@ -220,12 +239,25 @@ 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("illusion_1L", args.device, args.steps)
r = run_single(scene_name, args.device, args.steps)
print(f"Done in {time.time()-t0:.1f}s: sim={r['similarity']:.4f}")

View File

@ -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_re100/
Output: data/karman/karman_re200/
"""
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)
_ANALYSIS = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if _ANALYSIS not in sys.path:
sys.path.insert(0, _ANALYSIS)
_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, model_path_for_scene, LEGACY_CFG_DIR
from CCD_analysis.configs import get_scene, get_scene_list, 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,6 +112,12 @@ 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)
@ -169,12 +175,25 @@ 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("karman_re100", args.device, args.steps)
r = run_single(scene_name, args.device, args.steps)
print(f"Done in {time.time()-t0:.1f}s: sim={r['similarity']:.4f}")

View File

@ -0,0 +1,129 @@
"""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)

View File

@ -0,0 +1,104 @@
"""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)

View File

@ -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)
_ANALYSIS = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if _ANALYSIS not in sys.path:
sys.path.insert(0, _ANALYSIS)
_SRC = os.path.join(_REPO, "src")
if _SRC not in sys.path:
sys.path.insert(0, _SRC)
from LegacyCelerisLab import FlowField

View File

@ -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)
_ANALYSIS = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if _ANALYSIS not in sys.path:
sys.path.insert(0, _ANALYSIS)
_SRC = os.path.join(_REPO, "src")
if _SRC not in sys.path:
sys.path.insert(0, _SRC)
from LegacyCelerisLab import FlowField
@ -29,6 +29,7 @@ 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()
@ -38,10 +39,15 @@ 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]:
@ -49,10 +55,13 @@ 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:
@ -66,7 +75,7 @@ def collect():
temp[3] = cfg["omega_front"]
temp[4] = rear_val
temp[5] = -rear_val
ff.run(stabilize, np.zeros(n_obj, dtype=np.float32))
print(f"Stabilizing with rear={scale:.1f}xU0 ({stabilize} steps)...", flush=True)
ff.run(stabilize, temp)
sens_list = []
@ -74,7 +83,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}")
print(f" rear={scale:.1f}xU0 -> sensor std={std:.6f}", flush=True)
# Save with best (or single) value
rear_val = candidates[-1] * cfg["u0"]
@ -83,7 +92,7 @@ def collect():
temp[4] = rear_val
temp[5] = -rear_val
ff.run(stabilize, temp)
print(f"Saving final data (rear={candidates[-1]:.1f}xU0)...", flush=True)
sens_list, forc_list, ux_list, uy_list = [], [], [], []
for _ in range(30):
ff.run(cfg["sample_interval"], temp)
@ -108,10 +117,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}")
print(f"Done, saved to {out_dir}", flush=True)
if __name__ == "__main__":
t0 = time.time()
collect()
print(f"Time: {time.time() - t0:.1f}s")
print(f"Time: {time.time() - t0:.1f}s", flush=True)

View File

@ -0,0 +1,122 @@
"""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)

View File

@ -0,0 +1,179 @@
"""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()

View File

@ -0,0 +1,280 @@
"""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()

View File

@ -1,9 +1,12 @@
"""Phase resampling for periodic cases (pinball, karman_re100, illusion_1L).
"""DEPRECATED — replaced by detect_period.py + replay_fields.py
Usage:
python scripts/resample.py
This script interpolates field data to phase-aligned points, introducing artifacts.
The new pipeline saves RAW (non-interpolated) fields at exact step indices.
Output: data/resampled/{scene_name}/resampled.npz
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
"""
from __future__ import annotations
@ -13,9 +16,9 @@ import sys
import numpy as np
_ANALYSIS = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if _ANALYSIS not in sys.path:
sys.path.insert(0, _ANALYSIS)
_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 (
@ -30,12 +33,16 @@ 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")
@ -116,6 +123,7 @@ 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")
@ -123,8 +131,6 @@ 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"]
@ -153,7 +159,11 @@ 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)
print(f" Saved: {resample_dir} ({'fields' if meta['has_fields'] else 'no fields'})")
pr(f" Saved: {resample_dir} ({'fields' if meta['has_fields'] else 'no fields'})")
d.close()
if fd is not None:
fd.close()
print("\nDone.")

View File

@ -0,0 +1,180 @@
"""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()

View File

@ -0,0 +1,227 @@
"""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()

View File

@ -0,0 +1,605 @@
"""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()

View File

@ -1,5 +1,7 @@
"""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
"""
@ -11,81 +13,136 @@ import sys
import numpy as np
_ANALYSIS = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if _ANALYSIS not in sys.path:
sys.path.insert(0, _ANALYSIS)
_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
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")
print("=== Steady Cloak Metrics ===\n", flush=True)
steady_dir = os.path.join(DATA_DIR, "steady_cloak", "steady_cloak")
pinball_dir = os.path.join(DATA_DIR, "pinball", "pinball")
empty_dir = steady_dir # TODO: generate empty channel reference
# Load fields
def load_fields(d):
p = os.path.join(d, "fields.npz")
if not os.path.isfile(p):
return None
f = np.load(p)
return f["ux"].astype(np.float64), f["uy"].astype(np.float64)
empty_chan_dir = os.path.join(DATA_DIR, "target_channel", "target_channel")
sc = load_fields(steady_dir)
if sc is None:
print("ERROR: no steady cloak fields. Run scripts/collect_steady_cloak.py first.")
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)
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}")
print(f"1. Total RMS (steady cloak): {total_rms:.6f}", flush=True)
# 2. Recirculation zone
# === 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 ===
ny, nx = ux_s_mean.shape
cline = ux_s_mean[ny // 2, :]
neg = np.where(cline[400:-50] < 0)[0]
if len(neg) > 0:
L_r = float(neg[-1])
print(f"2. Recirculation length L_r: {L_r:.0f} lattice units")
print(f"3. Recirculation length L_r: {L_r:.0f} lattice units", flush=True)
else:
L_r = 0.0
print(f"2. Recirculation length L_r: 0 (no reverse flow)")
print(f"3. Recirculation length L_r: 0 (no reverse flow)", flush=True)
# 3. Sensor mean (if available)
sens_path = os.path.join(steady_dir, "sensors.npz")
if os.path.isfile(sens_path):
sd = np.load(sens_path)
sens_mean = np.mean(sd["sensors"], axis=0)
print(f"3. Sensor means: ux_center={sens_mean[2]:.4f}, uy_center={sens_mean[3]:.4f}")
# 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)
# 4. Compare with pinball (if available)
# === 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))
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}%")
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 ===
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)
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,
"fluctuation_suppression_pct": float((1 - total_rms / (pb_rms + 1e-12)) * 100) if pb is not None else None,
"A_r": A_r,
"fluctuation_suppression_pct": fluc_suppression,
}
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")
print(f"\nSaved to {out_dir}/steady_metrics.json", flush=True)
return 0

View File

@ -9,4 +9,6 @@ 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,
)

View File

@ -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, bias rollout.
"""Add pinball cylinders, stabilize, compute norm, preset-action FIFO init.
Parameters
----------
@ -151,7 +151,7 @@ def add_pinball(
print(f" norm: force_norm_fact={force_norm_fact:.6f}")
# ---- bias-action rollout ----
# ---- preset-action FIFO init ----
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 (NY, NX)."""
"""Extract ux, uy fields from DDF on host. Returns (ux, uy) each (NX, NY)."""
flow_field.get_ddf()
NX = flow_field.FIELD_SHAPE[0]
NY = flow_field.FIELD_SHAPE[1]
@ -228,10 +228,17 @@ 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."""
"""Compute z-vorticity from current DDF on host.
Returns omega_z with shape (NY, NX) for direct imshow use.
"""
ux, uy = get_velocity_field(flow_field, u0)
omega = np.gradient(uy, axis=1) - np.gradient(ux, axis=0)
return omega.astype(np.float64)
# 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
def save_vorticity_png(path: str, omega: np.ndarray, title: str = ""):

View File

@ -92,14 +92,35 @@ 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 (n_points, n_snapshots).
"""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.
Returns (mean_field, modes, singular_values, coefficients).
"""
mean_field = np.mean(snapshot_matrix, axis=1)
Q = snapshot_matrix - mean_field[:, None]
U, s, Vt = np.linalg.svd(Q, full_matrices=False)
coefficients = np.diag(s) @ Vt
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
return mean_field, U, s, coefficients
@ -116,49 +137,68 @@ 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]:
"""Compute reduced CCD in POD coefficient space.
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.
Parameters
----------
pod_coeffs : (r, N) ndarray standardized POD coefficients.
observable : (m, N) ndarray standardized observable.
Q_delay : int number of delay steps.
Q_delay : int number of delay steps (must be odd for symmetric window).
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 = np.roll(observable, -shift, axis=1)
if shift < 0:
shifted[:, shift:] = 0.0
elif shift > 0:
shifted[:, :-shift] = 0.0
shifted = observable[:, valid_start + shift:valid_end + shift]
rows.append(shifted)
P = np.vstack(rows) # (m*Q_delay, N)
P = np.vstack(rows) # (m*Q_delay, N_valid)
A_valid = pod_coeffs[:, valid_start:valid_end]
# 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(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
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
C = P_z @ A_z.T / (N * np.sqrt(float(Q_delay)))
C = P_z @ A_z.T / (N_valid * 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, z
return W, s, R, z, N_original, N_valid
# ---------------------------------------------------------------------------
@ -185,6 +225,174 @@ 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
# ---------------------------------------------------------------------------