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:
+130
-196
@@ -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.
|
||||
|
||||
+311
-147
@@ -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
|
||||
|
||||
|
||||
|
||||
+135
-181
@@ -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 模态就是瞬时力的主载体。"
|
||||
|
||||
+50
-572
@@ -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)
|
||||
|
||||
+120
-30
@@ -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)
|
||||
|
||||
@@ -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.")
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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.")
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
@@ -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}")
|
||||
|
||||
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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.")
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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 = ""):
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user