feat(ccd): freeze dynamic-increment analysis pipeline
Replace the legacy CCD workspace with acquisition, direct-dq, original and lagged CCD contracts so the DRL-versus-constant-mean mechanism is reproducible and fail-closed. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -9,6 +9,7 @@ from typing import List, Tuple, Union, Optional
|
||||
from . import utils
|
||||
from . import preprocess as preproc
|
||||
from . import compiler
|
||||
from src.CCD_analysis.acquisition.solver_state import copy_ping_pong_ddf, d2q9_q_over_u0_xy
|
||||
|
||||
FLUID = 0b00000001
|
||||
SOLID = 0b00000010
|
||||
@@ -112,6 +113,10 @@ class FlowField:
|
||||
self.action = np.zeros(0, dtype=self.DATA_TYPE)
|
||||
self.obs = np.zeros(0, dtype=self.DATA_TYPE)
|
||||
self._control_interval = None
|
||||
self._last_completed_observation = None
|
||||
self._last_effective_action = None
|
||||
self._completed_lattice_steps = 0
|
||||
self._completed_control_intervals = 0
|
||||
|
||||
initflow(
|
||||
self.flag_gpu,
|
||||
@@ -126,6 +131,23 @@ class FlowField:
|
||||
cuda.memcpy_dtoh(self.flag, self.flag_gpu)
|
||||
cuda.memcpy_dtoh(self.ddf, self.ddf_gpu)
|
||||
|
||||
def completed_flags_xy(self) -> np.ndarray:
|
||||
"""Return a read-only copy of configured flags in canonical ``(NX, NY)`` order.
|
||||
|
||||
The solver stores flags flat with ``k = x + y * NX``. No bit is
|
||||
interpreted or rewritten here, so FLUID/SOLID and auxiliary bits are
|
||||
preserved exactly.
|
||||
"""
|
||||
flat = np.asarray(self.flag)
|
||||
expected = int(self.FIELD_SHAPE[0]) * int(self.FIELD_SHAPE[1]) * int(self.FIELD_SHAPE[2])
|
||||
if flat.dtype != np.dtype("uint8") or flat.ndim != 1 or flat.size != expected:
|
||||
raise RuntimeError("configured solver flag storage is not canonical uint8 flat data")
|
||||
if int(self.FIELD_SHAPE[2]) != 1:
|
||||
raise RuntimeError("canonical CCD flag export supports completed D2 geometry only")
|
||||
result = np.ascontiguousarray(flat.reshape((self.FIELD_SHAPE[1], self.FIELD_SHAPE[0])).T)
|
||||
result.setflags(write=False)
|
||||
return result
|
||||
|
||||
def add_cylinder(self, center: Tuple[float, float, float], radius: float, id_obj: Optional[int] = None):
|
||||
x_c, y_c, z_c = center
|
||||
|
||||
@@ -224,6 +246,7 @@ class FlowField:
|
||||
self.objects[id_object] = {
|
||||
"type": "sensor",
|
||||
"center": center,
|
||||
"radius": radius,
|
||||
}
|
||||
|
||||
self.action = np.zeros(len(self.objects), dtype=self.DATA_TYPE)
|
||||
@@ -409,6 +432,133 @@ class FlowField:
|
||||
cuda.memset_d32_async(self.obs_gpu, 0, self.obs.size, stream)
|
||||
stream.synchronize()
|
||||
state["completed_steps"] += num_steps
|
||||
self._completed_lattice_steps += num_steps
|
||||
|
||||
def current_step_observation(self):
|
||||
"""Return raw telemetry for the latest completed lattice step.
|
||||
|
||||
During an active split interval this is the latest synchronized step. At
|
||||
a completed control boundary it is the persisted final raw step, not the
|
||||
interval-averaged public ``obs``.
|
||||
"""
|
||||
state = self._control_interval
|
||||
if state is not None and state["completed_steps"] >= 1:
|
||||
return state["obs_steps"][state["completed_steps"] - 1].copy()
|
||||
if self._last_completed_observation is None:
|
||||
raise RuntimeError("no completed lattice step is available")
|
||||
return self._last_completed_observation.copy()
|
||||
|
||||
def current_effective_action(self):
|
||||
"""Return the latest EMA action, including at a completed boundary."""
|
||||
state = self._control_interval
|
||||
if state is not None and state["completed_steps"] >= 1:
|
||||
return np.asarray(state["action"]).copy()
|
||||
if self._last_effective_action is None:
|
||||
raise RuntimeError("no completed lattice step is available")
|
||||
return self._last_effective_action.copy()
|
||||
|
||||
def _require_completed_split_step(self):
|
||||
state = self._control_interval
|
||||
if state is None or state["completed_steps"] < 1:
|
||||
raise RuntimeError("no completed step is available in the active control interval")
|
||||
|
||||
def current_step_velocity_field(self):
|
||||
"""Return completed Legacy nondimensional velocity ``q/U0`` as ``(NX, NY)``."""
|
||||
if self._control_interval is not None:
|
||||
self._require_completed_split_step()
|
||||
elif self._last_completed_observation is None:
|
||||
raise RuntimeError("no completed Legacy step is available")
|
||||
# run_control_segment synchronizes before returning. After its pointer swap,
|
||||
# ddf_gpu is the completed state and temp_gpu is the previous/work buffer.
|
||||
cuda.memcpy_dtoh(self.ddf, self.ddf_gpu)
|
||||
flags = self.completed_flags_xy()
|
||||
return d2q9_q_over_u0_xy(
|
||||
self.ddf, int(self.FIELD_SHAPE[0]), int(self.FIELD_SHAPE[1]), flags,
|
||||
float(self.field_config.velocity),
|
||||
)
|
||||
|
||||
def current_step_velocity_probe(self, lattice_index: Tuple[int, int]):
|
||||
"""Read one synchronized Legacy nondimensional ``(ux/U0, uy/U0)`` pair."""
|
||||
self._require_completed_split_step()
|
||||
if (not isinstance(lattice_index, tuple) or len(lattice_index) != 2
|
||||
or any(type(value) is not int for value in lattice_index)):
|
||||
raise ValueError("lattice_index must be an (x, y) integer tuple")
|
||||
x, y = lattice_index
|
||||
if not (0 <= x < self.FIELD_SHAPE[0] and 0 <= y < self.FIELD_SHAPE[1]):
|
||||
raise ValueError("velocity probe is outside the lattice")
|
||||
ux, uy = self.current_step_velocity_field()
|
||||
return np.asarray([ux[x, y], uy[x, y]], dtype=self.DATA_TYPE)
|
||||
|
||||
def active_step_clock_state(self):
|
||||
"""Return solver lineage during a split interval after a completed step.
|
||||
|
||||
The control clock is the number of fully completed control intervals; it
|
||||
therefore identifies the active interval's zero-based absolute index.
|
||||
"""
|
||||
state = self._control_interval
|
||||
if state is None or state["completed_steps"] < 1:
|
||||
raise RuntimeError("active-step clocks require a split interval with a completed step")
|
||||
return {
|
||||
"solver_absolute_lattice_clock": int(self._completed_lattice_steps),
|
||||
"solver_absolute_control_clock": int(self._completed_control_intervals),
|
||||
}
|
||||
|
||||
def solver_clock_state(self):
|
||||
"""Return public absolute solver lineage clocks at the current boundary."""
|
||||
if self._control_interval is not None:
|
||||
raise RuntimeError("solver clocks are boundary-safe only")
|
||||
return {
|
||||
"solver_absolute_lattice_clock": int(self._completed_lattice_steps),
|
||||
"solver_absolute_control_clock": int(self._completed_control_intervals),
|
||||
}
|
||||
|
||||
def full_state_checkpoint(self):
|
||||
"""Capture exact restart state only at a completed control boundary."""
|
||||
if self._control_interval is not None:
|
||||
raise RuntimeError("full checkpoint requires a completed control boundary")
|
||||
ddf = self.current_step_ddf_checkpoint()
|
||||
return {
|
||||
**ddf,
|
||||
"action": self.action.copy(),
|
||||
"last_effective_action": None if self._last_effective_action is None else self._last_effective_action.copy(),
|
||||
"raw_observation": None if self._last_completed_observation is None else self._last_completed_observation.copy(),
|
||||
"boundary_observation": self.obs.copy(),
|
||||
"solver_absolute_lattice_clock": int(self._completed_lattice_steps),
|
||||
"solver_absolute_control_clock": int(self._completed_control_intervals),
|
||||
}
|
||||
|
||||
def restore_full_state(self, checkpoint):
|
||||
"""Restore both ping-pong DDFs and solver-side boundary lifecycle state."""
|
||||
if self._control_interval is not None:
|
||||
raise RuntimeError("cannot restore during an active control interval")
|
||||
current = np.asarray(checkpoint["current_ddf"]); temp = np.asarray(checkpoint["temp_ddf"])
|
||||
action = np.asarray(checkpoint["action"]); boundary = np.asarray(checkpoint["boundary_observation"])
|
||||
raw = checkpoint["raw_observation"]; effective = checkpoint["last_effective_action"]
|
||||
if current.dtype != self.DATA_TYPE or temp.dtype != self.DATA_TYPE or current.shape != self.ddf.shape or temp.shape != self.ddf.shape:
|
||||
raise ValueError("checkpoint DDF storage mismatch")
|
||||
if action.dtype != self.DATA_TYPE or action.shape != self.action.shape or boundary.dtype != self.DATA_TYPE or boundary.shape != self.obs.shape:
|
||||
raise ValueError("checkpoint action/observation mismatch")
|
||||
if raw is not None and (np.asarray(raw).dtype != self.DATA_TYPE or np.asarray(raw).shape != self.obs.shape): raise ValueError("checkpoint raw observation mismatch")
|
||||
if effective is not None and (np.asarray(effective).dtype != self.DATA_TYPE or np.asarray(effective).shape != self.action.shape): raise ValueError("checkpoint effective action mismatch")
|
||||
cuda.memcpy_htod(self.ddf_gpu, current); cuda.memcpy_htod(self.temp_gpu, temp)
|
||||
self.ddf = current.copy(); self.action = action.copy(); self.obs = boundary.copy()
|
||||
self._last_completed_observation = None if raw is None else np.asarray(raw).copy()
|
||||
self._last_effective_action = None if effective is None else np.asarray(effective).copy()
|
||||
self._completed_lattice_steps = int(checkpoint["solver_absolute_lattice_clock"])
|
||||
self._completed_control_intervals = int(checkpoint["solver_absolute_control_clock"])
|
||||
cuda.memcpy_htod(self.action_gpu, self.action)
|
||||
|
||||
def current_step_ddf_checkpoint(self):
|
||||
"""Return copies/hashes of current(completed) and temp(previous/work) buffers.
|
||||
|
||||
Synchronous device-to-host copies make this safe both at a completed split
|
||||
step and at a completed control boundary; no solver state is modified.
|
||||
"""
|
||||
return copy_ping_pong_ddf(
|
||||
lambda host: cuda.memcpy_dtoh(host, self.ddf_gpu),
|
||||
lambda host: cuda.memcpy_dtoh(host, self.temp_gpu),
|
||||
int(self.FIELD_SIZE * self.LATTICE),
|
||||
)
|
||||
|
||||
def end_control_interval(self):
|
||||
"""Publish obs once, only at the original policy-control boundary."""
|
||||
@@ -423,6 +573,13 @@ class FlowField:
|
||||
self.obs = (self.obs / state["total_steps"]).astype(self.DATA_TYPE)
|
||||
cuda.memcpy_dtoh(self.error_flag, self.error_flag_gpu)
|
||||
self.last_error_flag = int(self.error_flag[0])
|
||||
# Persist the final lattice-step state before clearing the split lifecycle.
|
||||
# The next begin_control_interval therefore starts its EMA from this exact
|
||||
# action, preserving the historical uninterrupted-run semantics.
|
||||
self.action = np.asarray(state["action"], dtype=self.DATA_TYPE).copy()
|
||||
self._last_effective_action = self.action.copy()
|
||||
self._last_completed_observation = state["obs_steps"][-1].copy()
|
||||
self._completed_control_intervals += 1
|
||||
self._control_interval = None
|
||||
return self.obs
|
||||
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
# Legacy 双时钟流场采样
|
||||
|
||||
## 目的
|
||||
|
||||
Legacy DRL 模型只应在固定控制周期边界接收 observation 并预测下一动作。例如:
|
||||
|
||||
- Illusion:每 600 lattice steps 决策一次;
|
||||
- Kármán:每 800 lattice steps 决策一次。
|
||||
|
||||
流场分析通常需要另一套保存时钟,例如每 257 或 317 lattice steps 保存一次 DDF。这两个时钟不必整除,也不应相互改变。
|
||||
|
||||
错误做法是在每个流场保存点调用普通 `ff.run(segment_steps, action)`。Legacy `run()` 每次都会:
|
||||
|
||||
1. 从 `ff.action` 重新初始化 action smoothing;
|
||||
2. 清空 observation 累计;
|
||||
3. 将该段 observation 除以段长度并发布。
|
||||
|
||||
这样会把流场保存点错误地变成控制边界,改变闭环控制。
|
||||
|
||||
## 正确语义
|
||||
|
||||
一个 DRL 控制周期只执行一次以下生命周期:
|
||||
|
||||
```python
|
||||
ff.begin_control_interval(sample_interval, action_target)
|
||||
ff.run_control_segment(first_length)
|
||||
ff.get_ddf() # 只读流场
|
||||
ff.run_control_segment(second_length)
|
||||
ff.get_ddf()
|
||||
# ...累计恰好 sample_interval steps...
|
||||
obs = ff.end_control_interval()
|
||||
```
|
||||
|
||||
其中:
|
||||
|
||||
- `begin_control_interval()` 只在真实控制边界调用一次;
|
||||
- `run_control_segment()` 可以按任意正整数长度调用多次;
|
||||
- segment 之间延续同一个 interval-local action smoothing;
|
||||
- segment 之间延续同一个 raw observation 累计;
|
||||
- `get_ddf()` 只将当前 GPU DDF 复制到 host,不更新 observation、FIFO、reward、action 或 policy;
|
||||
- 只有累计推进完整控制周期后才能调用 `end_control_interval()`;
|
||||
- `end_control_interval()` 发布完整控制周期的平均 observation;
|
||||
- FIFO、observation normalization 和下一次 `model.predict()` 仍然只在控制边界执行一次。
|
||||
|
||||
普通 `ff.run(total_steps, action_target)` 保持兼容,它等价于 begin、一次完整 segment、end。
|
||||
|
||||
## 推荐复用接口
|
||||
|
||||
通用实现位于:
|
||||
|
||||
- `LegacyCelerisLab/driver.py`:control-interval session;
|
||||
- `src/CCD_analysis/utils/dual_clock.py`:绝对 field-step 调度和 `ux/uy` 保存;
|
||||
- `src/CCD_analysis/scripts/collect_illusion.py`、`collect_karman.py`:实际接入示例。
|
||||
|
||||
OID 若使用同一个 Legacy `FlowField`,应直接复用 `CCD_analysis.utils.dual_clock.DualClockCollector`,不要复制一套控制循环。
|
||||
|
||||
### 固定流场间隔
|
||||
|
||||
```python
|
||||
from CCD_analysis.utils.dual_clock import (
|
||||
DualClockCollector,
|
||||
field_steps_from_interval,
|
||||
)
|
||||
|
||||
control_interval = 600
|
||||
control_count = 200
|
||||
field_interval = 257
|
||||
|
||||
collector = DualClockCollector(
|
||||
ff,
|
||||
control_interval=control_interval,
|
||||
control_count=control_count,
|
||||
u0=u0,
|
||||
field_steps=field_steps_from_interval(
|
||||
control_interval * control_count,
|
||||
field_interval,
|
||||
),
|
||||
)
|
||||
|
||||
obs = initial_obs
|
||||
for control_index in range(control_count):
|
||||
action, _ = model.predict(obs, deterministic=True)
|
||||
action_target = decode_action(action)
|
||||
|
||||
# 此调用内部可能在一个控制周期内读取多次或零次 DDF,
|
||||
# 但始终只结束一个真实控制周期。
|
||||
raw_obs = collector.run_interval(control_index, action_target)
|
||||
|
||||
fifo.append(select_observation(raw_obs))
|
||||
obs = normalize(select_observation(raw_obs))
|
||||
|
||||
collector.save("fields.npz")
|
||||
```
|
||||
|
||||
### 任意、不均匀保存位置
|
||||
|
||||
`field_steps` 使用从 rollout 开始计数的绝对 lattice step,并且必须严格递增:
|
||||
|
||||
```python
|
||||
collector = DualClockCollector(
|
||||
ff,
|
||||
control_interval=800,
|
||||
control_count=200,
|
||||
u0=u0,
|
||||
field_steps=[137, 800, 913, 1721, 2400],
|
||||
)
|
||||
```
|
||||
|
||||
位于控制边界的 field step 只保存一次,同时仍只产生一次完整控制 observation。
|
||||
|
||||
## 输出合同
|
||||
|
||||
`collector.save("fields.npz")` 保存:
|
||||
|
||||
- `ux`, `uy`:读取时刻的无量纲速度场;
|
||||
- `lattice_steps`:每个场对应的绝对 lattice step;
|
||||
- `control_indices`:所属的零基控制周期;
|
||||
- `control_offsets`:在该控制周期内的位置,范围为 `1..control_interval`;
|
||||
- `control_interval`:模型固定控制周期。
|
||||
|
||||
后续 CCD/OID 对齐必须使用 `lattice_steps`,不能通过数组下标猜测时间。
|
||||
|
||||
## 命令示例
|
||||
|
||||
所有 Legacy GPU 采集使用 `pycuda_3_10` 和 GPU 2:
|
||||
|
||||
```bash
|
||||
PYTHONPATH=src:. conda run -n pycuda_3_10 python \
|
||||
src/CCD_analysis/scripts/collect_illusion.py \
|
||||
--scene illusion_1.0L --device 2 --steps 200 \
|
||||
--field-interval 257 --output-dir /path/to/run/illusion_1.0L
|
||||
```
|
||||
|
||||
```bash
|
||||
PYTHONPATH=src:. conda run -n pycuda_3_10 python \
|
||||
src/CCD_analysis/scripts/collect_karman.py \
|
||||
--scene karman_re100 --device 2 --steps 200 \
|
||||
--field-interval 317 --output-dir /path/to/run/karman_re100
|
||||
```
|
||||
|
||||
不提供 `--field-interval` 时使用原来的单时钟 `ff.run()` 路径,不保存 `fields.npz`。
|
||||
|
||||
## 实际闭环验证
|
||||
|
||||
2026-07-22 在 `pycuda_3_10`、物理 GPU 2 上进行了 200 个控制周期的 A/B:baseline 不读取中间 DDF,实验组使用与控制周期不整除的流场间隔,因此读取位置持续在控制周期内漂移。
|
||||
|
||||
### Illusion 1.0L
|
||||
|
||||
- 控制周期:600;流场间隔:257;
|
||||
- baseline 末 100 周期 DTW similarity:0.973265;
|
||||
- DDF 读取组:0.976044;
|
||||
- 差值:+0.002779;
|
||||
- 平均 reward 差值:-0.001633。
|
||||
|
||||
### Kármán Re=100
|
||||
|
||||
- 控制周期:800;流场间隔:317;
|
||||
- baseline DTW similarity:0.954562;
|
||||
- DDF 读取组:0.953442;
|
||||
- 差值:-0.001119(约 -0.12%);
|
||||
- 两组末 100 周期传感器序列经相位补偿后的相互 DTW similarity:0.965680。
|
||||
|
||||
最终受控流场保持相同类型的尾流结构,没有出现控制失效。因此对同一条连续在线闭环轨迹,可以认为控制期间独立读取 DDF 不会造成有意义的控制效果下降。
|
||||
|
||||
## 边界和注意事项
|
||||
|
||||
- 该方法解决的是同一条连续闭环轨迹中的独立流场采样。
|
||||
- 它不保证不同 checkpoint restore 分支 bitwise 相同;Legacy CUDA kernel 本身存在数值非确定性。
|
||||
- 不要把两个独立 restore 分支当成逐点 bitwise 配对数据。
|
||||
- 不要在 active control interval 中调用普通 `ff.run()`。
|
||||
- 不要提前调用 `end_control_interval()`。
|
||||
- 不要在同一 control interval 内切换 action target。
|
||||
- DDF 读取后必须继续同一个 session,直至原固定控制边界。
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,38 @@
|
||||
# CCD three-part reset: final result boundary
|
||||
|
||||
## Direct-dq facts
|
||||
|
||||
Two schema-v3, three-role, 450-control acquisitions completed with the corrected Legacy `q/U0` decoder and exact acquisition-relative timelines:
|
||||
|
||||
- Kármán `karman_re100`: field interval 2000; retain exactly the 120 snapshots with relative lattice step `>120000` (122000–360000). The weighted vector-RMS of `e_target = q_ctl - q_target` is `0.15965716015602738`. Algebraic closure `e_target = dq_ctl - dq_tar` has maximum absolute residual `9.5367431640625e-07`.
|
||||
- Illusion `illusion_1.0L`: field interval 1250; retain exactly the 144 snapshots with relative lattice step `>90000` (91250–270000). The weighted vector-RMS target error is `0.08031177071338107`. Maximum closure residual is `1.7881393432617188e-07`.
|
||||
|
||||
Both results preserve full-resolution instantaneous role and difference fields, means, solver-mask intersection, exact x/D=35/40/45 profiles, mask-aware vorticity, and declared prefix/suffix convergence diagnostics. The 90/120 Kármán and 108/144 Illusion windows show decreasing mean-field deviations, but one trajectory cannot provide independent-realization uncertainty or by itself prove asymptotic convergence.
|
||||
|
||||
These are same-time direct differences. They are not phase-conditioned results. `dq_ctl` and `dq_tar` share `-q_blk`; similarity between them is descriptive and is not causal mechanism evidence. The momentum-flux quantity is explicitly an incomplete proxy, not a complete momentum balance.
|
||||
|
||||
Authoritative immutable results:
|
||||
|
||||
- `evidence/direct-dq-karman-burn120000/`
|
||||
- `evidence/direct-dq-illusion-authorized-burn90000/`
|
||||
|
||||
A validated reload depends on the immutable live acquisition roots recorded by absolute path in each result; the result directories are not standalone portable evidence.
|
||||
|
||||
## Original CCD capability
|
||||
|
||||
`original_ccd/` implements the original full-field Lyu operator `A = P U†/(N sqrt(LQ))`, weighted modes, exact lag/block handling, coefficients, lag functions, and rank-selected reconstruction/residual. It does not use preliminary POD, row standardization, or whitening. The published-scale synthetic derivation and production-reference tests pass.
|
||||
|
||||
The frozen real-case observable decision was executed for Kármán only. The authoritative Q=1, tau=0 result is `evidence/real-ccd-karman-q1-tau0-burn120000-v1/` (manifest SHA-256 `97d5cb300d642295bd3dffedd85d940fde06c8cc1c236fade390db78c3e3810e`). Its singular values are `0.14676751183519246`, `0.05920814023903697`, and `0.0063884442355860915`; squared cross-correlation strengths are `0.02154070253029336`, `0.003505603870565469`, and `0.00004081221975119316`. The three action means (front/upper/lower, native units) are `-0.004750394590640402`, `-0.0417400509895136`, and `0.03969304291531443`. The result has numerical rank 3, no degenerate singular blocks, modes shape `(1299184, 3)`, coefficients shape `(3, 120)`, and weighted relative residuals `0.7823805118116768`, `0.4698397615223296`, `0.45542496362972573` after complete blocks 1, 2, 3. Fresh-process live-provenance reload and essential identity recomputation passed. The singular values are cross-correlation strengths, not field energy, explained variance, canonical coefficients, causal effects, mechanism evidence, uncertainty, or a CCD-versus-POD comparison. Illusion CCD was not run and requires a separate user authorization decision.
|
||||
|
||||
|
||||
## Karman figure subset
|
||||
|
||||
The deterministic CPU artifact-derived package is `evidence/real-ccd-karman-figures-v1/`. It uses only the public verified real-CCD loader, retains the 1280 x 512 grid without downsampling, and publishes PNG/PDF figures plus quantitative Markdown/JSON interpretation. Selected reconstruction snapshots are acquisition-relative lattice steps 122000, 242000, and 360000; no full MxN reconstruction is persisted. The governing two-case figures review remains partial/in progress because Illusion was not run and awaits user authorization. The unique next entry remains that authorization decision.
|
||||
|
||||
## Kármán dynamic-increment final publication
|
||||
|
||||
The accepted-plan final stage is complete at `data/karman-dynamic/karman-dynamic-v1-production/publication-v2/`. Six artifact-only PNG/PDF figure pairs and concise `RESULTS.json`/`RESULTS.md` report the four-role mean performance decomposition, DRL-versus-constant 10-bin phase statistics and centered phase difference, temporal negative-lag CCD spectrum/modes/left lag functions/common-support sensitivity, and an explicit phase-domain **DOWNGRADE**. Zero appears only in mean statistics; no zero phase figure or claim exists because its phase gate failed.
|
||||
|
||||
Mean target errors are zero `0.2177701`, constant mean `0.1680192`, and DRL `0.1599946`. Thus zero-to-constant reduces error by `0.0497509`; constant-to-DRL contributes a further `0.00802465`, about `13.9%` of the total zero-to-DRL mean-error reduction. Constant/DRL cycle-mean 10-bin phase target errors are `0.2126461`/`0.1821272`. Temporal leading strengths are `2.77997e-3`, `1.91480e-5`, and `1.32484e-7`; common-support leading-three principal cosines remain at least `0.999998746`, while native support is sample-support-sensitive. Phase-domain CCD remains DOWNGRADE because first-harmonic rank is 2 instead of primary rank 3.
|
||||
|
||||
Strict code/science/claim review passed after one allowed remediation: `publication-v1` omitted the target bar despite the four-role requirement; immutable `publication-v2` fixes it. Dense fields were not deleted role-by-role: DRL is required for independent temporal recomputation, constant_mean and target for independent source-level mean/statistics recomputation, and zero has no compact field replacement after its failed phase gate. Focused Kármán tests: 26 passed; full active CCD suite: 178 passed; changed-file lints and scoped diff whitespace checks passed; old real-CCD/direct-dq loaders remained isolated from the publication process.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,164 +0,0 @@
|
||||
# CCD Analysis Pipeline
|
||||
|
||||
> Correction-field CCD analysis for fluidic pinball DRL control.
|
||||
> Core question: does `dq_ctl` (what the controller adds) match `dq_tar` (what the target requires)?
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
cd src/CCD_analysis
|
||||
|
||||
# Panorama comparison figure (primary output)
|
||||
conda run -n pycuda_3_10 python3 correction_analysis/compare_dqctl_scenes.py
|
||||
|
||||
# CCD quantitative decomposition
|
||||
conda run -n pycuda_3_10 python3 correction_analysis/decompose_corrections.py
|
||||
|
||||
# Single-scene zone diagnostics
|
||||
conda run -n pycuda_3_10 python3 correction_analysis/diagnose_corrections.py
|
||||
```
|
||||
|
||||
## Pipeline Architecture
|
||||
|
||||
```
|
||||
[Data Collection] [Phase Alignment] [Correction Fields] [Analysis]
|
||||
scripts/collect_*.py → detect_period.py → compute_correction_ → compare_dqctl_scenes.py
|
||||
(GPU, device 2) replay_fields.py fields.py decompose_corrections.py
|
||||
(CPU/GPU) (CPU) diagnose_corrections.py
|
||||
(CPU)
|
||||
```
|
||||
|
||||
## Dual-clock field sampling
|
||||
|
||||
Legacy DRL 的控制时钟与 DDF 保存时钟必须独立:模型仍只在固定控制周期边界读取完整 observation 和预测动作,DDF 可以在周期内任意 lattice step 读取。通用 API、OID 复用方式、输出时间轴合同和 GPU A/B 结果见 [`DUAL_CLOCK_SAMPLING.md`](DUAL_CLOCK_SAMPLING.md)。
|
||||
|
||||
## Key Conventions
|
||||
|
||||
### Geometry (unified 2026-06-25)
|
||||
- **All scenes**: pinball center ≈ 613 px, sensors at 800 px (40×L0)
|
||||
- Collected at source — no post-processing translation needed
|
||||
|
||||
### Correction Fields
|
||||
- **`dq_blk = q_blk − q_in`**: pinball blockage (passive)
|
||||
- **`dq_ctl = q_ctl − q_blk`**: control correction (active) — **primary analysis object**
|
||||
- **`dq_tar = q_tar − q_blk`**: target correction (theoretical)
|
||||
- Core question: **O(dq_ctl, dq_tar)** — how well does control match theory?
|
||||
|
||||
### Observation Normalization
|
||||
- **Force-first**: `obs = [forces/force_norm, sensors/sens_norm]`
|
||||
- Each scene computes its own `force_norm_fact`, `sens_deviation`, `sens_norm_fact` during collection
|
||||
- Same norm values MUST be used during inference
|
||||
|
||||
### Reynolds Number
|
||||
- Code Re uses reference length 2D = 40: `Re = U0×40/ν`
|
||||
- Physical Re_D uses D = 20: `Re_D = Re/2`
|
||||
- Default: Re=100 → Re_D=50, nu=0.004
|
||||
|
||||
### Inlet
|
||||
- Parabolic velocity profile (not uniform)
|
||||
- Top/bottom walls: no-slip bounce-back
|
||||
- U0 = 0.01 (centerline, lattice units)
|
||||
|
||||
### Vortex Bug History (2026-06-29)
|
||||
Three bugs in `collect_vortex.py` caused incorrect Lamb data:
|
||||
1. **Cylinder order**: add order is front→TOP(+y)→BOTTOM(−y); reversed caused wrong bias mapping
|
||||
2. **Obs swap**: used `[sensors/force_norm, forces/sens_norm]` instead of force-first `[forces/force_norm, sensors/sens_norm]`
|
||||
3. **Missing fade-in/out**: 25-step transition from steady-cloak bias to PPO action required
|
||||
|
||||
See `collect_vortex.py` header and `ccd_knowledge.md` §12 for full details.
|
||||
|
||||
## Results Index
|
||||
|
||||
All figures in `results/figures/`; CCD JSON in `results/ccd/`.
|
||||
|
||||
### Panorama (main deliverable)
|
||||
|
||||
| # | Figure | Content |
|
||||
|---|--------|---------|
|
||||
| 01 | `01_panorama_all_scenes.png` | 7 scenes × 4 quantities (ux_mean, uy_mean, RMS, vorticity) |
|
||||
| 02 | `02_cloak_comparison.png` | 4 cloak scenes (steady, karman, vortex_lamb, vortex_taylor) |
|
||||
| 03 | `03_illusion_comparison.png` | 3 illusion scenes (0.75L, 1.0L, 1.5L) |
|
||||
|
||||
### Per-Scene dq_ctl vs dq_tar
|
||||
|
||||
| # | Figure | Scene |
|
||||
|---|--------|-------|
|
||||
| 04 | `04_steady_cloak_cancel.png` | Steady cloak cancellation test |
|
||||
| 05 | `05_illusion_075L_ctl_vs_tar.png` | Illusion 0.75L |
|
||||
| 06 | `06_illusion_10L_ctl_vs_tar.png` | Illusion 1.0L |
|
||||
| 07 | `07_illusion_15L_ctl_vs_tar.png` | Illusion 1.5L |
|
||||
| 08 | `08_karman_ctl_vs_tar.png` | Karman cloak re100 |
|
||||
| 09 | `09_vortex_lamb_ctl_vs_tar.png` | Vortex Lamb |
|
||||
| 10 | `10_vortex_taylor_ctl_vs_tar.png` | Vortex Taylor |
|
||||
|
||||
### Vortex Diagnosis
|
||||
|
||||
| # | Figure | Content |
|
||||
|---|--------|---------|
|
||||
| 11 | `11_vortex_lamb_diagnosis.png` | Lamb sensor + action comparison |
|
||||
| 12 | `12_vortex_taylor_diagnosis.png` | Taylor sensor + action comparison |
|
||||
| 13 | `13_vortex_lamb_vorticity.png` | Lamb vorticity field evolution |
|
||||
| 14 | `14_vortex_taylor_vorticity.png` | Taylor vorticity field evolution |
|
||||
| 15 | `15_vortex_lamb_target_vorticity.png` | Lamb target (no pinball) reference |
|
||||
| 16 | `16_vortex_taylor_target_vorticity.png` | Taylor target (no pinball) reference |
|
||||
|
||||
### Key Numerical Results
|
||||
|
||||
**Correction-field CCD (2026-06-28, unified geometry)**
|
||||
|
||||
| Metric | 0.75L | 1.0L | 1.5L |
|
||||
|--------|:-----:|:----:|:----:|
|
||||
| O(dq_ctl, dq_tar) mode1 (r=6) | 0.383 | **0.926** | **0.922** |
|
||||
| O(dq_ctl, dq_tar) mode1 (r=10) | 0.320 | 0.684 | 0.661 |
|
||||
| Force-CCD m80 (r=6) | 2 | 2 | 1 |
|
||||
| Action sigma1 (r=6) | 1.49 | 1.17 | **0.20** |
|
||||
|
||||
**Cloak dq_ctl RMS (cropped x=[300,1100])**
|
||||
|
||||
| Scene | RMS | Type |
|
||||
|-------|:---:|------|
|
||||
| steady_cloak | 0.196 | Steady, open-loop |
|
||||
| karman_re100 | 0.397 | Periodic, PPO closed-loop |
|
||||
| vortex_lamb | 0.146 | Transient, PPO closed-loop |
|
||||
| vortex_taylor | 0.188 | Transient, PPO closed-loop |
|
||||
|
||||
**Key findings:**
|
||||
- Cloak mechanism is **independent of upstream condition** (steady/vortex street/transient vortex all share the same dq_ctl structure)
|
||||
- Illusion 1.0L achieves near-perfect overlap (O=0.926) via "cloak physics + target frequency modulation"
|
||||
- Illusion 1.5L uses a fundamentally different mechanism (high-freq modulation, action sigma1=0.20 vs 1.17-1.49)
|
||||
- 0.75L overlap dropped from 0.564→0.383 after fixing geometry alignment — old number was inflated
|
||||
|
||||
### CCD Quantitative (JSON)
|
||||
|
||||
| File | Content |
|
||||
|------|---------|
|
||||
| `results/ccd/correction_ccd_results.json` | Force/Action-CCD per scene (r=6,8,10), O(dq_ctl,dq_tar) per mode |
|
||||
| `results/ccd/zone_metrics.json` | Per-zone KE and enstrophy |
|
||||
|
||||
## Full Documentation
|
||||
|
||||
| File | Content |
|
||||
|------|---------|
|
||||
| `PIPELINE.md` | This file — pipeline overview, conventions, results index |
|
||||
| `README.md` | Quick start |
|
||||
| `ccd_knowledge.md` | Complete knowledge base (theory, methodology, detailed results, bug history) |
|
||||
| `Lyu23.md` | CCD method paper (Lyu 2023) |
|
||||
|
||||
## Adding New Training Results
|
||||
|
||||
When new DRL models are trained (e.g. on new CelerisLab solver):
|
||||
|
||||
1. **Collect fields**: Run the appropriate `scripts/collect_*.py` with the new model path
|
||||
2. **Phase alignment**: `detect_period.py` → `replay_fields.py` (for periodic scenes)
|
||||
3. **Correction fields**: Already handled by `compute_correction_fields.py` — just ensure the scene name is registered in `configs.py` `_SCENE_MAP`
|
||||
4. **Regenerate figures**: `compare_dqctl_scenes.py` and `diagnose_corrections.py`
|
||||
5. **Regenerate CCD**: `decompose_corrections.py`
|
||||
6. **Symlink**: Add new figures to `results/figures/`
|
||||
|
||||
The `configs.py` `SCENES` dict is the single source of truth — add new scenes there and all analysis scripts automatically pick them up.
|
||||
|
||||
## Environment
|
||||
|
||||
- **Conda**: `pycuda_3_10`
|
||||
- **GPU**: Device 2 (check with `nvidia-smi` before collection)
|
||||
- **CPU-only steps**: POD, CCD, analysis scripts
|
||||
+97
-40
@@ -1,57 +1,114 @@
|
||||
# CCD_analysis: Correction-Field CCD Pipeline
|
||||
# CCD analysis: Kármán dynamic-increment campaign
|
||||
|
||||
Analyzes DRL-controlled fluidic pinball using **correction-field decomposition** + **Canonical Correlation Decomposition (CCD/Lyu23)**. Core question: does `dq_ctl` (what the controller adds) match `dq_tar` (what the target requires)?
|
||||
This is the active authority. It supersedes the Illusion-next-entry checkpoint. The unique scientific question is the Legacy `karman_re100` (code Re100, physical `Re_D=50`) increment of time-varying DRL over constant control fixed to the same fresh DRL run's retained, three-channel `effective_applied_action` mean. `zero` is only the passive baseline; `target` only defines cloaking error. Neither is the dynamic CCD subtraction.
|
||||
|
||||
## Start Here
|
||||
The frozen identity is `q_D(phi)-q_C(phi) = (mean(q_D)-mean(q_C)) + [(q_D(phi)-mean(q_D))-(q_C(phi)-mean(q_C))]`. The first term is a policy-induced statistical mean change; the second is phase-coherent unsteady change. Independent phase-conditioned trajectories are not pointwise counterfactuals, response measurements, or causal effects. Historical acquisition/direct-dq/Q=1 results below remain immutable evidence, but are superseded as the next execution direction.
|
||||
|
||||
**→ [`PIPELINE.md`](PIPELINE.md)** — pipeline overview, results index, conventions, new training integration guide.
|
||||
`karman_dynamic/` is the campaign-specific schema and launcher. Roles are `target`, `zero`, `drl`, `constant_mean`; execution is DRL first, then constant_mean, target, zero. It wraps the unchanged active schema-v3 acquisition payload and reuses its exact clocks, q/U0 decoder, solver mask, requested/effective actions, and fresh role runtime. Campaign telemetry explicitly stores center-sensor `uy=sensors[:,3]`. Constant mean is hash-bound to the fresh DRL wrapper and retained effective actions, without hand entry or symmetry forcing.
|
||||
|
||||
**→ [`DUAL_CLOCK_SAMPLING.md`](DUAL_CLOCK_SAMPLING.md)** — 在固定 DRL 控制周期内按独立时钟读取/保存 DDF;CCD 与 OID 共用。
|
||||
CFD execution requires `CONDA_DEFAULT_ENV=pycuda_3_10`, exactly one `CUDA_VISIBLE_DEVICES` token, CPU PPO inference, an exclusive campaign lease, a stable exact Optane symlink, fresh no-clobber roots, semantic reload after each child, and at least 30 s between starts (default 120 s). Failure quarantines the campaign and stops later roles. Do not run real CFD from `pinball_math`.
|
||||
|
||||
## Quick Commands
|
||||
Current status: all four production roles were fresh-loaded. Immutable phase publications exist for every role at sibling `ROLE-phase-compact-v1` paths. DRL, constant_mean, and target pass; zero fails closed (period CV `0.0828308 > 0.05`, amplitude CV `0.101389 > 0.10`) and therefore publishes metrics only, with no compact fields. DRL–constant phase differences remain authorized because those two independent gates pass; zero phase-target metrics are unavailable. The immutable four-role result is `data/karman-dynamic/karman-dynamic-v1-production/dynamic-increment-v2`. Mean target errors are zero `0.2177701`, constant_mean `0.1680192`, and DRL `0.1599946`; corresponding target-error reductions are `0.0497509` and `0.00802465`. Dense `payload/fields.npz` remains intact for all roles and deletion is not yet authorized because zero has no sufficient compact replacement. The earlier `dynamic-increment-v1` is immutable partial-stage evidence superseded by v2.
|
||||
|
||||
|
||||
The authoritative primary temporal result is `data/karman-dynamic/karman-dynamic-v1-production/drl-temporal-negative-lag-ccd-v1`. It uses full-resolution mask-compressed `q_DRL(t)-mean(q_DRL)` and exact same-boundary three-channel effective-action fluctuations at 800-step cadence, with complete phase cycles as non-crossing blocks. The predeclared grid is `tau/800 = -17,...,0` (`Q=18`, about one measured shedding period), giving `N=21` complete columns and `M=1,299,184`. Leading cross-correlation strengths are `2.77997e-3`, `1.91480e-5`, and `1.32484e-7`; mode-1 left-lag energy peaks at `tau/800=-6` and has centroid `-7.969`, while modes 2/3 peak at `0/-5`. On fixed common support, dropping the oldest one/two lags changes the first three strengths by at most `1.58%`, `4.33%`, and `6.06%`, with leading-three weighted-subspace principal cosines at least `0.9999987`. Native endpoint support grows to `N=40/59` and substantially changes strengths and the third leading subspace direction, so those native-window comparisons are sample-support-sensitive and are not interpreted as timing evidence. This is closed-loop temporal co-variation only, with no causal or response-time claim.
|
||||
|
||||
The final deterministic artifact-only publication is `data/karman-dynamic/karman-dynamic-v1-production/publication-v2`. It contains six concise PNG/PDF figure pairs plus hash-bound JSON/Markdown, generated after fresh live-provenance reloads and essential recomputation. `publication-v1` is immutable superseded pre-review evidence; final review found that its first panel omitted the target reference, and the single remediation added the explicit fourth role in v2. No figure reports zero phase results.
|
||||
|
||||
Dense-field deletion decision: **RETAIN ALL FOUR ROLE SOURCES**. DRL dense fields remain required to independently recompute the temporal CCD and its provenance; constant_mean and target dense fields remain required to independently recompute their mean/statistical products from source; zero failed the phase gate and has no compact field artifact, so its dense source is the only source-level basis for its accepted mean/statistics. The compact products suffice for currently published downstream figures, but not for every source-level downstream recomputation/provenance contract; therefore deletion is not independently justified.
|
||||
|
||||
The exploratory phase-domain CCD is complete at `data/karman-dynamic/karman-dynamic-v1-production/drl-constant-phase-domain-ccd-v1` and is **DOWNGRADED**, not retained as a stable three-direction result. It uses only the separately centered phase-coherent difference `Δq′_phase(φ)=(q_DRL(φ)-mean(q_DRL))-(q_constant_mean(φ)-mean(q_constant_mean))` and the DRL phase-conditioned effective-action fluctuation in the literal weighted Lyu `Q=1` operator. The 10-bin strengths are `0.0420404`, `0.0157939`, and `0.00414469` (rank 3). The 8/12-bin and half-bin-origin projectors are stable (minimum leading-three cosine `0.995038`), and left functions are stable (minimum absolute cosine `0.999867` across those bin tests), but first-harmonic truncation has rank 2 rather than 3; therefore rank stability fails. Circular offsets change strengths but are phase offsets only, never time-response lags. The immutable artifact is published because its scientific contract and downgrade boundary are explicit; it supports only low-order exploratory circular co-variation.
|
||||
|
||||
Recommended smoke plan command (prints fresh child command; no CFD):
|
||||
|
||||
```bash
|
||||
cd src/CCD_analysis
|
||||
|
||||
# Panorama comparison (main output)
|
||||
conda run -n pycuda_3_10 python3 correction_analysis/compare_dqctl_scenes.py
|
||||
|
||||
# CCD quantitative analysis
|
||||
conda run -n pycuda_3_10 python3 correction_analysis/decompose_corrections.py
|
||||
|
||||
# Zone diagnostics
|
||||
conda run -n pycuda_3_10 python3 correction_analysis/diagnose_corrections.py
|
||||
PYTHONPATH="$PWD/src" conda run -n pinball_math python -m CCD_analysis.karman_dynamic orchestrate --campaign-id karman-dynamic-v1 --root src/CCD_analysis/data/karman-dynamic/karman-dynamic-v1-smoke --warmup-intervals 1 --collect-boundaries 2 --launch-delay-seconds 120 --smoke
|
||||
```
|
||||
|
||||
## Directory
|
||||
Recommended exact DRL smoke execution command (real CUDA CFD; run only after Optane mapping/lease preflight):
|
||||
|
||||
```
|
||||
CCD_analysis/
|
||||
README.md # This file
|
||||
PIPELINE.md # Primary entry — overview, results, conventions
|
||||
ccd_knowledge.md # Full knowledge base (theory, methods, bug history)
|
||||
Lyu23.md # CCD method paper
|
||||
configs.py # Scene registry (single source of truth)
|
||||
results/ # Canonical outputs
|
||||
figures/ # Symlinks to all core figures (numbered)
|
||||
ccd/ # Final CCD JSON results
|
||||
correction_analysis/ # All analysis scripts
|
||||
scripts/ # GPU data collection + phase alignment
|
||||
utils/ # Core algorithms (POD, CCD, field loading)
|
||||
data/ # Raw data + generated figures
|
||||
ccd/ # Round 5 frozen baseline (do not modify)
|
||||
```bash
|
||||
CONDA_DEFAULT_ENV=pycuda_3_10 CUDA_VISIBLE_DEVICES=0 PYTHONPATH="$PWD/src" python -m CCD_analysis.karman_dynamic role --campaign-id karman-dynamic-v1 --role drl --output src/CCD_analysis/data/karman-dynamic/karman-dynamic-v1-smoke/drl --warmup-intervals 1 --collect-boundaries 2 --launch-delay-seconds 120 --smoke
|
||||
```
|
||||
|
||||
## Key Documentation
|
||||
## Superseded historical authority (immutable evidence)
|
||||
|
||||
| File | Content |
|
||||
|------|---------|
|
||||
| `PIPELINE.md` | **Primary entry** — pipeline, convention, results index |
|
||||
| `ccd_knowledge.md` | Complete knowledge base (509 lines) |
|
||||
| `results/figures/` | All core figures with numerical prefix ordering |
|
||||
This is the active authoritative surface. Historical material under `archive/2026-08-03-pre-three-part-reset/payload/` is immutable, non-authoritative, and never imported by active code.
|
||||
|
||||
## Environment
|
||||
## Acquisition status
|
||||
|
||||
`acquisition/` now defines CPU-import-safe contract/artifact schema v3 for exactly `karman_re100` and `illusion_1.0L`, each with roles `q_target`, `q_blk`, and `q_ctl`. It includes frozen geometry, signed front/upper/lower action identities, exact absolute lattice clocks, same-step telemetry, solver-derived masks, full-grid coordinates/velocity fields, controller/history identity, no-clobber publication, and fresh-process sequential orchestration.
|
||||
|
||||
Kármán's code label Re100 uses the historical `2D` reference and is physically `Re_D=50`. Its front/rear/sensor x locations are 30/31.3/40 D. Illusion uses the strict +11D deployment geometry: front/rear/sensors/target x = 30/31.3/41/31 D, rear y = ±0.75 D, sensors y = +2/0/-2 D. This Illusion deployment differs from training geometry; replay/history smoke is mandatory before production.
|
||||
|
||||
Velocity fields are the Legacy solver's nondimensional velocity `q/U0`, decoded exactly on solver-flagged fluid cells as `ux=(f1+f5+f8-f3-f6-f7)/u0` and `uy=(f2+f5+f6-f4-f7-f8)/u0`. This is not momentum divided by density. Nonfluid cells are exact zero and their unused populations may be ignored. The decoder schema and formula hash are mandatory artifact identities.
|
||||
|
||||
All six artifacts under `evidence/smoke-20260804/` were produced with the incorrect density-normalized decoder. They are withdrawn, invalid under the current schema, and retained only as immutable negative evidence; they must not be overwritten. Those v2 artifacts are also structurally obsolete because they lack complete control-boundary lineage. The corrected schema-v3 smoke and the two 450-control three-role production acquisitions have completed. Schema v3 persists the exact pre-action `(150,12)` FIFO, every interval-average `(control_count,12)` boundary observation independently of field cadence, every policy source/input observation and source hash, zero-origin policy harmonic phase indices, and complete requested-action control histories. Current production roots are `evidence/production-20260804-q-over-u0-v3-karman-450-fi2000/` and `evidence/production-20260804-q-over-u0-v3-illusion-authorized-450-fi1250/`.
|
||||
|
||||
`q_target` is a desired reference generated with different bodies. It is not a same-checkpoint counterfactual. Equal absolute time establishes same-time sampling only; `phase_reference` is evidence for later phase validation and does not itself prove same phase.
|
||||
|
||||
### Commands
|
||||
|
||||
CPU tests (no CFD):
|
||||
|
||||
```bash
|
||||
PYTHONPATH="$PWD/src" conda run -n pinball_math python -m pytest src/CCD_analysis/tests/test_acquisition.py -q
|
||||
```
|
||||
conda run -n pycuda_3_10
|
||||
|
||||
Inspect the sequential fresh-process command plan without running CFD:
|
||||
|
||||
```bash
|
||||
PYTHONPATH="$PWD/src" conda run -n pinball_math python -m CCD_analysis.acquisition orchestrate --case karman_re100 --output /fresh/path --control-count 450 --field-interval 2000
|
||||
```
|
||||
|
||||
CFD entry points require `CONDA_DEFAULT_ENV=pycuda_3_10` and fail otherwise. Do not use `conda run` if it does not propagate that variable correctly. The first independent gate failed on its no-op runner/EMA lifecycle, and the second re-review failed on exact initialization and controller-reference semantics. Both sets are now remediated. Initialization is explicitly `stabilize → full current/temp/lifecycle checkpoint → zero-action normalization trajectory → exact restore → FIFO warmup`, with zero post-stabilization EMA restored before warmup. Karman's first PPO input is exactly zero. Illusion PPO consumes only the frozen training normalization (`9ec5dd…`) and two target-force harmonics (`f13566…`) from the frozen Illusion training-reference files; newly measured +11D eight-channel harmonics are phase evidence only, never controller input. The deployment/reference mismatch still requires the strict +11D compatibility replay/history smoke. The third review then failed only because solver, rollout, and policy-phase clocks were conflated. They are now explicit: solver-absolute lattice/control lineage comes from the public solver accessor; acquisition-relative lattice/control starts at zero; policy harmonic phase independently starts at zero. `lattice_steps`/`sample_ids` are solver-absolute, while `acquisition_relative_lattice_steps`, rollout-relative `control_indices`, and solver-absolute control indices are persisted separately. A final static review found that snapshots called the boundary-only clock API during an active split. Legacy now has a distinct `active_step_clock_state()` valid only after at least one completed split step; snapshots use it, while initialization and post-interval checks retain boundary-only `solver_clock_state()`. The corrected q/U0 decoder, complete lineage contract, and explicit orchestration schedule passed fresh CUDA smoke before the two production acquisitions. Kármán used 450 controls with field interval 2000; Illusion used 450 controls with field interval 1250. These runs establish successful acquisition and exact artifact lineage, not physical-phase equality or a mechanism claim.
|
||||
|
||||
## Direct-dq status
|
||||
|
||||
`direct_dq/` is the completed CPU-only strict same-time analysis core. It accepts three explicit completed active acquisition artifact directories, revalidates each manifest plus file/config/state-array hashes, and requires exact case/role/schema, finite float32 `(time,x,y)` fields, exact float32 coordinates, a common grid, and exact full acquisition-relative timelines before selection. Solver-absolute origins may differ by role when each lineage is internally valid. A required exclusive `--start-after-relative-step` burn-in bound and optional inclusive `--end-at-relative-step` select samples by exact integer physical-step inequalities only; no boundary lookup, nearest match, or index trimming is allowed. A supplied end must be the terminal selected sample, and the interval must be nonempty. There is no trimming, nearest-time/station substitution, phase guessing, crop, translation, or coordinate-generated mask.
|
||||
|
||||
Only selected role columns enter the analysis. Results preserve the original common timeline, exact selection bounds, selected original indices, selected steps, and selected count. The transparent analysis domain is the intersection of the three preserved solver-derived fluid masks. Outputs include instantaneous and full-resolution time-mean `e_target`, `dq_ctl`, and `dq_tar`; all three role means; exact-station streamwise profiles; weighted vector RMS target error; signed target-relative streamwise deficit; an explicitly incomplete momentum-flux proxy; positive-deficit wake area/centroid/width; mask-aware nonuniform-coordinate vorticity; and declared nested prefix/suffix convergence diagnostics. `dq_ctl` and `dq_tar` share `-q_blk`, so agreement is not mechanism evidence. Phase-conditioned output fails closed because cross-role physical-phase equality has not been independently proven. Prefix/suffix windows are not independent-realization uncertainty.
|
||||
|
||||
Run on completed artifacts in `pinball_math` (repeat station/window options as needed; station tokens are preserved and canonically converted to exact float32 grid values without nearest/tolerance matching; the final window must equal the full sample count):
|
||||
|
||||
```bash
|
||||
PYTHONPATH="$PWD/src" conda run -n pinball_math python -m CCD_analysis.direct_dq \
|
||||
--case karman_re100 --q-target /path/q_target --q-blk /path/q_blk --q-ctl /path/q_ctl \
|
||||
--output /fresh/result --start-after-relative-step 120000 \
|
||||
--station-x-D 35 --station-x-D 40 --station-x-D 45 \
|
||||
--window-size 30 --window-size 60 --window-size 90 --window-size 120
|
||||
```
|
||||
|
||||
For the completed Illusion acquisition, use burn step `90000`, stations `35,40,45`, and windows `36,72,108,144`. The canonical production grids contain each station exactly once as float32. Convergence windows are validated against the selected count.
|
||||
|
||||
Results are immutable/no-clobber atomic directories containing `arrays.npz`, `summary.json`, `config.json`, `input_hashes.json`, and a hash manifest. Provenance-validated `load_result()` always rereads all three acquisition directories at their recorded absolute paths, revalidates their complete semantics and hashes, and requires their masks/grid/full timeline to equal the persisted result and each selected instantaneous role array to equal the exact selected live input columns. Therefore results are not portable by themselves: moving, deleting, or changing an acquisition directory makes provenance validation fail closed. `load_result_metadata_unverified()` is explicitly metadata/internal-science-only and cannot support a provenance claim; publication and CLI never use it as success validation. The completed real results are `evidence/direct-dq-karman-burn120000/` (120 retained fields) and `evidence/direct-dq-illusion-authorized-burn90000/` (144 retained fields). Their summaries report weighted vector-RMS target errors of 0.1596571602 and 0.08031177071338107, respectively. These are direct same-time estimands after the declared burn-in selections; they are not phase-conditioned, independent-realization uncertainty, or causal mechanism evidence. Tests additionally use synthetic artifacts written through the active acquisition writer.
|
||||
|
||||
## Original CCD production status
|
||||
|
||||
`original_ccd/ORIGINAL_CCD_MATH.md` is the active mathematical contract for the original full-field Lyu CCD. The public CPU package now implements literal full-field `U`, lag-stacked `P`, `A = P U†/(N sqrt(LQ))`, direct rectangular SVD, physical diagonal or dense complex-HPD weighting, separately declared `U`/`P` centering, exact timestamp/block lag construction, weighted modes and physical-amplitude coefficients, and selected-mode reconstruction/residuals. There is no POD pre-reduction, whitening, row standardization, nearest-time matching, or implicit centering.
|
||||
|
||||
`build_lagged_observables()` preserves exact admitted field-column indices, and `fit()` applies that mapping to `U`. Reconstruction is the weighted projection of field snapshots onto selected CCD modes, not observable prediction. Exactly degenerate singular blocks identify subspaces/projectors rather than unique individual modes; the optional deterministic phase convention does not resolve that non-uniqueness. See `original_ccd/README.md` for API usage.
|
||||
|
||||
Production tests compare against the private literal derivation reference for hand, random real/complex, diagonal/dense weighted, centered, multiobservable, and exact-lag cases; verify singular equations, chunk invariance, weighted orthogonality, full supported-basis reconstruction, truncation residual/projector behavior, validation failures, and a smaller Lyu equations (3.1)-(3.2) production case. The exact published-scale stochastic derivation test remains in the full suite and is not redundantly rerun in a second production test. The production API tests run no CFD. The real Kármán CCD result is reported below; Illusion CCD was not run. No observable prediction, causal result, or CCD-versus-POD claim is included.
|
||||
|
||||
## Real-case CCD contract status
|
||||
|
||||
`original_ccd/REAL_CASE_CCD_CONTRACT.md` freezes the first real-data estimand for both cases: centered full-resolution `dq_ctl` on the authoritative solver-mask intersection; centered q_ctl `effective_applied_action` front/upper/lower channels at each exact field time in native units; and the literal weighted original CCD with `Q=1`, `tau=0`. It fixes component-major mask flattening, coordinate quadrature, mean-action algebra, prohibited preprocessing, provenance validation, OOM-safe passes, immutable result requirements, and claim limits. The active `real_ccd/` package implements mandatory direct-dq/live-acquisition provenance validation, exact q_ctl field-time effective actions, component-major mask compression, explicit two-sided centering and coordinate weighting, a three-pass 3-by-M thin-SVD decomposition, streamed coefficients/block residuals, conservative fail-closed RAM/scratch accounting, an immutable verified result loader, and a CPU-only `preflight`/`run` CLI with explicit direct-dq root, chunk size, safe host admission budget, fresh output, and no-clobber publication.
|
||||
|
||||
The authoritative Kármán Q=1, tau=0 decomposition is complete at `evidence/real-ccd-karman-q1-tau0-burn120000-v1/`, using the immutable `evidence/direct-dq-karman-burn120000/` input, chunk size 8, 120 samples, and 1,299,184 spatial degrees of freedom. Singular values (cross-correlation strengths) are `[0.14676751183519246, 0.05920814023903697, 0.0063884442355860915]`; their squares are `[0.02154070253029336, 0.003505603870565469, 0.00004081221975119316]`. The numerical rank is 3 with no degenerate blocks, and weighted relative residuals after complete blocks 1/2/3 are `[0.7823805118116768, 0.4698397615223296, 0.45542496362972573]`. These values are not field energy, explained variance, or canonical coefficients. The fresh-process loader passed live provenance and essential-identity recomputation. Illusion CCD was not run and is not authorized by this completion; the unique next entry is a user decision on `real-ccd-illusion` authorization.
|
||||
|
||||
The Kármán-only figure subset is complete at `evidence/real-ccd-karman-figures-v1/`: full-resolution PNG/PDF spectrum, authoritative mean and three physical modes, zero-lag action left vectors, exact-step coefficients, complete-block residuals, and on-demand rank reconstructions at relative steps 122000/242000/360000. The two-case figures todo remains `in_progress`: Illusion figures await the same user authorization decision as Illusion CCD, which remains the unique next entry.
|
||||
|
||||
Run CPU tests (no CFD):
|
||||
|
||||
```bash
|
||||
PYTHONPATH="$PWD/src" conda run -n pinball_math python -m pytest src/CCD_analysis/tests -q
|
||||
```
|
||||
|
||||
## Other active parts
|
||||
|
||||
- `original_ccd/` — production original full-field CCD API and its frozen mathematical contract.
|
||||
- `tests/` — active CPU tests only.
|
||||
- `evidence/` — machine-readable review evidence.
|
||||
|
||||
`EXECUTION_CHECKPOINT.json` is the authoritative resumable execution state.
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Active three-part CCD analysis surface.
|
||||
|
||||
Historical implementations are isolated under ``archive/`` and are not part of
|
||||
this package's import graph.
|
||||
"""
|
||||
@@ -0,0 +1,7 @@
|
||||
"""CPU-safe exact-time acquisition contract package."""
|
||||
from .contracts import CASES, ROLES, case_snapshot
|
||||
from .dual_clock import DualClockCollector, ExactTelemetry, field_steps, solver_fluid_mask
|
||||
from .runtime import preflight, role_spec, run_role_acquisition
|
||||
from .solver_state import copy_ping_pong_ddf, d2q9_q_over_u0_xy
|
||||
from .validation import validate_acquisition_semantics
|
||||
__all__=["CASES","ROLES","case_snapshot","DualClockCollector","ExactTelemetry","field_steps","solver_fluid_mask","preflight","role_spec","run_role_acquisition","copy_ping_pong_ddf","d2q9_q_over_u0_xy","validate_acquisition_semantics"]
|
||||
@@ -0,0 +1,2 @@
|
||||
from .cli import main
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Immutable, fsync-backed acquisition artifact transactions."""
|
||||
from __future__ import annotations
|
||||
import ctypes
|
||||
import errno
|
||||
from hashlib import sha256
|
||||
import json, os, shutil, uuid
|
||||
from pathlib import Path
|
||||
from typing import Mapping, Any
|
||||
import numpy as np
|
||||
from .contracts import ARTIFACT_SCHEMA_ID, canonical_json
|
||||
from .validation import array_sha256, validate_acquisition_semantics
|
||||
|
||||
|
||||
def file_sha256(path: Path) -> str:
|
||||
digest = sha256()
|
||||
with path.open("rb") as stream:
|
||||
for block in iter(lambda: stream.read(8 * 1024 * 1024), b""):
|
||||
digest.update(block)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def rename_noreplace(source: Path, destination: Path) -> None:
|
||||
"""Atomically publish a directory without replacing any existing inode."""
|
||||
libc = ctypes.CDLL(None, use_errno=True)
|
||||
renameat2 = getattr(libc, "renameat2", None)
|
||||
if renameat2 is None:
|
||||
raise RuntimeError("atomic no-replace publication unavailable: libc renameat2 is missing")
|
||||
renameat2.argtypes = [ctypes.c_int, ctypes.c_char_p, ctypes.c_int, ctypes.c_char_p, ctypes.c_uint]
|
||||
renameat2.restype = ctypes.c_int
|
||||
result = renameat2(-100, os.fsencode(source), -100, os.fsencode(destination), 1)
|
||||
if result == 0:
|
||||
return
|
||||
code = ctypes.get_errno()
|
||||
if code in (errno.EEXIST, errno.ENOTEMPTY):
|
||||
raise FileExistsError(destination)
|
||||
if code in (errno.ENOSYS, errno.EINVAL, errno.ENOTSUP):
|
||||
raise RuntimeError("atomic no-replace publication unavailable; refusing unsafe fallback") from OSError(code, os.strerror(code))
|
||||
raise OSError(code, os.strerror(code), destination)
|
||||
|
||||
|
||||
def _hash_array(value: np.ndarray) -> str:
|
||||
return sha256(np.ascontiguousarray(value).tobytes()).hexdigest()
|
||||
|
||||
|
||||
def _fsync_file(path: Path) -> None:
|
||||
with path.open("rb") as stream:
|
||||
os.fsync(stream.fileno())
|
||||
|
||||
|
||||
def _validate_sha(value: np.ndarray, key: str) -> None:
|
||||
if value.ndim != 0 or value.dtype.kind not in "SU": raise ValueError(f"{key} must be a scalar string")
|
||||
text = str(value.item())
|
||||
if len(text) != 64: raise ValueError(f"{key} must be SHA256")
|
||||
int(text, 16)
|
||||
|
||||
|
||||
class ArtifactTransaction:
|
||||
def __init__(self, destination: str | Path):
|
||||
self.destination = Path(destination)
|
||||
self.partial = self.destination.with_name(f".{self.destination.name}.partial.{os.getpid()}.{uuid.uuid4().hex}")
|
||||
self.active = False
|
||||
|
||||
def __enter__(self):
|
||||
if self.destination.exists(): raise FileExistsError(self.destination)
|
||||
self.destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.partial.mkdir(); self.active = True
|
||||
return self
|
||||
|
||||
def write(self, *, arrays: Mapping[str, Any], config: dict, state: Mapping[str, Any]):
|
||||
if not self.active: raise RuntimeError("transaction inactive")
|
||||
data, state_arrays = validate_acquisition_semantics(arrays=arrays, config=config, state=state)
|
||||
np.savez_compressed(self.partial/"fields.npz", **data)
|
||||
(self.partial/"config.json").write_bytes(canonical_json(config))
|
||||
np.savez_compressed(self.partial/"controller_state.npz", **state_arrays)
|
||||
for path in self.partial.iterdir():
|
||||
if path.is_file(): _fsync_file(path)
|
||||
files = {path.name:file_sha256(path) for path in sorted(self.partial.iterdir()) if path.is_file()}
|
||||
manifest = {"schema_id":ARTIFACT_SCHEMA_ID,"complete":True,"files":files,"state_array_sha256":{key:array_sha256(value) for key,value in state_arrays.items()},"config_sha256":sha256(canonical_json(config)).hexdigest(),"field_count":data["ux"].shape[0]}
|
||||
validate_acquisition_semantics(arrays=data, config=config, state=state_arrays, manifest=manifest)
|
||||
(self.partial/"manifest.json").write_bytes(canonical_json(manifest)); _fsync_file(self.partial/"manifest.json")
|
||||
return manifest
|
||||
|
||||
def publish(self):
|
||||
manifest = json.loads((self.partial/"manifest.json").read_text())
|
||||
if not manifest.get("complete") or manifest.get("schema_id") != ARTIFACT_SCHEMA_ID: raise RuntimeError("partial artifact is not complete")
|
||||
for name, digest in manifest["files"].items():
|
||||
if file_sha256(self.partial/name) != digest: raise RuntimeError("artifact hash validation failed")
|
||||
with np.load(self.partial/"fields.npz", allow_pickle=False) as fields, np.load(self.partial/"controller_state.npz", allow_pickle=False) as state:
|
||||
config=json.loads((self.partial/"config.json").read_text())
|
||||
validate_acquisition_semantics(arrays={key:fields[key] for key in fields.files},config=config,state={key:state[key] for key in state.files},manifest=manifest)
|
||||
rename_noreplace(self.partial, self.destination)
|
||||
directory_fd = os.open(self.destination.parent, os.O_RDONLY)
|
||||
try: os.fsync(directory_fd)
|
||||
finally: os.close(directory_fd)
|
||||
self.active = False
|
||||
return self.destination
|
||||
|
||||
def __exit__(self, typ, value, tb):
|
||||
if self.active: shutil.rmtree(self.partial, ignore_errors=True); self.active=False
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Acquisition CLI and fresh-process sequential role orchestration."""
|
||||
from __future__ import annotations
|
||||
import argparse, os, subprocess, sys
|
||||
from pathlib import Path
|
||||
from .contracts import CASES, ROLES, case_snapshot
|
||||
from .compatibility import publish_certificate, validate_certificate
|
||||
from .runtime import preflight, require_cfd_environment, run_role_acquisition
|
||||
|
||||
def _validate_schedule(case:str,control_count:int,field_interval:int)->tuple[int,...]:
|
||||
if case not in CASES: raise ValueError("unsupported case")
|
||||
if type(control_count) is not int or control_count < 1:
|
||||
raise ValueError("control_count must be a positive integer")
|
||||
if type(field_interval) is not int or field_interval < 1:
|
||||
raise ValueError("field_interval must be a positive integer")
|
||||
horizon = CASES[case].sample_interval * control_count
|
||||
if horizon % field_interval:
|
||||
raise ValueError("horizon must be exactly divisible by field_interval")
|
||||
schedule = tuple(range(field_interval, horizon + 1, field_interval))
|
||||
if not schedule or schedule[-1] != horizon:
|
||||
raise ValueError("field schedule must be nonempty and include the terminal step")
|
||||
return schedule
|
||||
|
||||
|
||||
def role_command(case:str,role:str,output:Path,*,control_count:int,field_interval:int)->list[str]:
|
||||
_validate_schedule(case,control_count,field_interval)
|
||||
if role not in ROLES: raise ValueError("unsupported role")
|
||||
return [sys.executable,"-m","CCD_analysis.acquisition","role","--case",case,"--role",role,"--output",str(output),"--control-count",str(control_count),"--field-interval",str(field_interval)]
|
||||
|
||||
|
||||
def orchestrate(case:str,output:Path,*,control_count:int,field_interval:int,run:bool=False,compatibility_certificate:Path|None=None)->list[list[str]]:
|
||||
_validate_schedule(case,control_count,field_interval)
|
||||
if output.exists(): raise FileExistsError(output)
|
||||
if CASES[case].history_smoke_required:
|
||||
if compatibility_certificate is None: raise ValueError("Illusion production requires --compatibility-certificate")
|
||||
validate_certificate(compatibility_certificate)
|
||||
commands=[role_command(case,r,output/r,control_count=control_count,field_interval=field_interval) for r in ROLES]
|
||||
if run:
|
||||
require_cfd_environment(); env=dict(os.environ)
|
||||
for command in commands: subprocess.run(command,check=True,env=env)
|
||||
return commands
|
||||
def main(argv:list[str]|None=None)->int:
|
||||
p=argparse.ArgumentParser(); sub=p.add_subparsers(dest="command",required=True)
|
||||
role=sub.add_parser("role"); role.add_argument("--case",choices=CASES,required=True); role.add_argument("--role",choices=ROLES,required=True); role.add_argument("--output",type=Path,required=True); role.add_argument("--control-count",type=int,required=True); role.add_argument("--field-interval",type=int,required=True)
|
||||
orch=sub.add_parser("orchestrate"); orch.add_argument("--case",choices=CASES,required=True); orch.add_argument("--output",type=Path,required=True); orch.add_argument("--control-count",type=int,required=True); orch.add_argument("--field-interval",type=int,required=True); orch.add_argument("--compatibility-certificate",type=Path); orch.add_argument("--execute",action="store_true")
|
||||
cert=sub.add_parser("certify-illusion-pilot"); cert.add_argument("--pilot",type=Path,required=True); cert.add_argument("--output",type=Path,required=True)
|
||||
check=sub.add_parser("preflight"); check.add_argument("--case",choices=CASES)
|
||||
args=p.parse_args(argv)
|
||||
if args.command=="preflight":
|
||||
import json
|
||||
report=preflight(args.case); print(json.dumps(report,indent=2)); return 0 if report["ready_for_independent_pre_cfd_gate"] else 2
|
||||
if args.command=="certify-illusion-pilot":
|
||||
print(publish_certificate(args.pilot,args.output)); return 0
|
||||
if args.command=="orchestrate":
|
||||
for command in orchestrate(args.case,args.output,control_count=args.control_count,field_interval=args.field_interval,run=args.execute,compatibility_certificate=args.compatibility_certificate): print(" ".join(command))
|
||||
return 0
|
||||
require_cfd_environment(); case_snapshot(args.case,args.role)
|
||||
run_role_acquisition(case=args.case,role=args.role,output=args.output,control_count=args.control_count,field_interval=args.field_interval)
|
||||
return 0
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Fail-closed Illusion +11D policy compatibility certificates."""
|
||||
from __future__ import annotations
|
||||
from hashlib import sha256
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
import numpy as np
|
||||
from .artifacts import file_sha256, rename_noreplace
|
||||
from .contracts import canonical_json
|
||||
from CCD_analysis.direct_dq.io import load_acquisition_artifact
|
||||
|
||||
COMPATIBILITY_SCHEMA_ID = "ccd-illusion-policy-compatibility/v1"
|
||||
CONTRACT = {
|
||||
"schema_id": COMPATIBILITY_SCHEMA_ID,
|
||||
"case_id": "illusion_1.0L",
|
||||
"role": "q_ctl",
|
||||
"required_control_count": 150,
|
||||
"required_field_interval": 600,
|
||||
"required_policy_input_width": 14,
|
||||
"normalized_action_absolute_limit": 1.0,
|
||||
"physical_action_absolute_limit": 0.1,
|
||||
"field_q_over_u0_absolute_limit": 5.0,
|
||||
"raw_observation_absolute_limit": 5.0,
|
||||
"policy_input_absolute_limit": 1.0,
|
||||
"acceptance": "all active acquisition semantics pass; exact 150-control FIFO/policy lineage; all listed finite declared bounds pass",
|
||||
}
|
||||
CONTRACT_SHA256 = sha256(canonical_json(CONTRACT)).hexdigest()
|
||||
|
||||
def evaluate(pilot: str | Path) -> dict[str, Any]:
|
||||
root = Path(pilot).resolve()
|
||||
artifact = load_acquisition_artifact(root, expected_case="illusion_1.0L", expected_role="q_ctl")
|
||||
with np.load(root / "controller_state.npz", allow_pickle=False) as z:
|
||||
state = {key: z[key] for key in z.files}
|
||||
arrays = artifact.fields
|
||||
checks = {
|
||||
"control_count_exact": int(artifact.config["clock_domains"]["acquisition_relative_control_final"]) == 150,
|
||||
"field_interval_exact": int(artifact.config["acquisition"]["field_interval"]) == 600,
|
||||
"initial_fifo_shape_exact": state["initial_fifo_history"].shape == (150, 12),
|
||||
"boundary_history_shape_exact": state["boundary_observation_history"].shape == (150, 12),
|
||||
"policy_input_shape_exact": state["policy_input_observation_history"].shape == (150, 14),
|
||||
"policy_source_hash_count_exact": state["policy_source_observation_sha256"].shape == (150,),
|
||||
"normalized_action_bound": float(np.max(np.abs(state["requested_normalized_action_history"]))) <= 1.0,
|
||||
"physical_action_bound": float(np.max(np.abs(state["requested_physical_action_history"]))) <= 0.1,
|
||||
"field_bound": max(float(np.max(np.abs(arrays["ux"]))), float(np.max(np.abs(arrays["uy"])))) <= 5.0,
|
||||
"raw_observation_bound": max(float(np.max(np.abs(state["initial_fifo_history"]))), float(np.max(np.abs(state["boundary_observation_history"])))) <= 5.0,
|
||||
"policy_input_bound": float(np.max(np.abs(state["policy_input_observation_history"]))) <= 1.0,
|
||||
}
|
||||
metrics = {
|
||||
"normalized_action_max_abs": float(np.max(np.abs(state["requested_normalized_action_history"]))),
|
||||
"physical_action_max_abs": float(np.max(np.abs(state["requested_physical_action_history"]))),
|
||||
"field_q_over_u0_max_abs": max(float(np.max(np.abs(arrays["ux"]))), float(np.max(np.abs(arrays["uy"])))),
|
||||
"raw_observation_max_abs": max(float(np.max(np.abs(state["initial_fifo_history"]))), float(np.max(np.abs(state["boundary_observation_history"])))),
|
||||
"policy_input_max_abs": float(np.max(np.abs(state["policy_input_observation_history"]))),
|
||||
}
|
||||
passed = all(checks.values())
|
||||
return {"schema_id": COMPATIBILITY_SCHEMA_ID, "status": "PASS" if passed else "FAIL", "production_authorized": passed, "contract": CONTRACT, "contract_sha256": CONTRACT_SHA256, "pilot_path": str(root), "pilot_manifest_sha256": file_sha256(root / "manifest.json"), "checks": checks, "metrics": metrics, "claim_boundary": "runtime compatibility only; not accuracy, stability, physical-phase, or mechanism validation"}
|
||||
|
||||
def publish_certificate(pilot: str | Path, output: str | Path) -> Path:
|
||||
destination = Path(output)
|
||||
report = evaluate(pilot)
|
||||
if report["status"] != "PASS": raise RuntimeError("Illusion compatibility contract failed")
|
||||
partial = destination.with_name(f".{destination.name}.partial")
|
||||
if destination.exists() or partial.exists(): raise FileExistsError(destination)
|
||||
partial.write_bytes(canonical_json(report))
|
||||
rename_noreplace(partial, destination)
|
||||
return destination
|
||||
|
||||
def validate_certificate(path: str | Path) -> dict[str, Any]:
|
||||
certificate = Path(path).resolve(); report = json.loads(certificate.read_bytes())
|
||||
if certificate.read_bytes() != canonical_json(report) or report != evaluate(report.get("pilot_path", "")) or report.get("status") != "PASS" or report.get("production_authorized") is not True:
|
||||
raise ValueError("Illusion compatibility certificate is invalid or stale")
|
||||
return report
|
||||
@@ -0,0 +1,195 @@
|
||||
"""Frozen CPU-only acquisition contracts for the active CCD cases."""
|
||||
from __future__ import annotations
|
||||
from dataclasses import asdict, dataclass
|
||||
from hashlib import sha256
|
||||
import json
|
||||
import numpy as np
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
Role = Literal["q_target", "q_blk", "q_ctl"]
|
||||
ROLES: tuple[Role, ...] = ("q_target", "q_blk", "q_ctl")
|
||||
SCHEMA_ID = "ccd-acquisition-contract/v3"
|
||||
ARTIFACT_SCHEMA_ID = "ccd-acquisition-artifact/v3"
|
||||
ACTION_IDENTITIES = ("front_ccw_positive", "upper_ccw_positive", "lower_ccw_positive")
|
||||
BODY_ORDER = ("sensor_upper", "sensor_center", "sensor_lower", "front", "upper", "lower")
|
||||
COORDINATE_FRAME_SCHEMA_ID = "ccd-lattice-coordinate-frame/v1"
|
||||
COORDINATE_REFERENCE_LENGTH_LATTICE = 20.0
|
||||
COORDINATE_AXIS_KEYS = {"count", "origin_lattice", "spacing_lattice"}
|
||||
COORDINATE_FRAME_KEYS = {"schema_id", "dtype", "axis_order", "reference_length_lattice", "x", "y"}
|
||||
VELOCITY_DECODER_SCHEMA_ID = "legacy-d2q9-q-over-u0/v1"
|
||||
VELOCITY_DECODER_FORMULA = (
|
||||
"ux=(f1+f5+f8-f3-f6-f7)/u0; "
|
||||
"uy=(f2+f5+f6-f4-f7-f8)/u0; fluid cells only; nonfluid=0"
|
||||
)
|
||||
VELOCITY_DECODER_FORMULA_SHA256 = sha256(VELOCITY_DECODER_FORMULA.encode()).hexdigest()
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Geometry:
|
||||
front_x_D: float
|
||||
rear_x_D: float
|
||||
sensor_x_D: float
|
||||
target_x_D: float | None
|
||||
rear_y_D: tuple[float, float] = (0.75, -0.75)
|
||||
sensor_y_D: tuple[float, float, float] = (2.0, 0.0, -2.0)
|
||||
body_order: tuple[str, ...] = BODY_ORDER
|
||||
action_identities: tuple[str, ...] = ACTION_IDENTITIES
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CaseContract:
|
||||
case_id: str
|
||||
code_reynolds: int
|
||||
physical_re_D: float
|
||||
sample_interval: int
|
||||
u0: float
|
||||
viscosity: float
|
||||
geometry: Geometry
|
||||
target_kind: str
|
||||
q_target_caveat: str
|
||||
deployment_warning: str | None
|
||||
history_smoke_required: bool
|
||||
|
||||
KARMAN = CaseContract(
|
||||
"karman_re100", 100, 50.0, 800, 0.01, 0.004,
|
||||
Geometry(30.0, 31.3, 40.0, None), "disturbance-cylinder wake",
|
||||
"q_target has different bodies and is a reference trajectory, not a same-checkpoint counterfactual.",
|
||||
None, False,
|
||||
)
|
||||
ILLUSION = CaseContract(
|
||||
"illusion_1.0L", 100, 50.0, 600, 0.01, 0.004,
|
||||
Geometry(30.0, 31.3, 41.0, 31.0), "single 1.0D target-cylinder wake",
|
||||
"q_target has different bodies and is a reference trajectory, not a same-checkpoint counterfactual.",
|
||||
"Strict +11D deployment geometry differs from policy training geometry; replay/history smoke is mandatory before production.",
|
||||
True,
|
||||
)
|
||||
CASES = {c.case_id: c for c in (KARMAN, ILLUSION)}
|
||||
ROLE_SEMANTICS = {
|
||||
"q_target": "desired reference flow with case-specific target bodies",
|
||||
"q_blk": "passive pinball flow with exactly zero requested actuation",
|
||||
"q_ctl": "controlled pinball flow driven by the frozen policy contract",
|
||||
}
|
||||
|
||||
def canonical_json(value: object) -> bytes:
|
||||
return (json.dumps(value, sort_keys=True, separators=(",", ":"), allow_nan=False) + "\n").encode()
|
||||
|
||||
def canonical_coordinate_axis(count: int, *, origin_lattice: float, spacing_lattice: float, reference_length_lattice: float) -> np.ndarray:
|
||||
"""Generate the exact persisted float32 lattice coordinate sequence."""
|
||||
if type(count) is not int or count < 1:
|
||||
raise ValueError("coordinate count must be a positive integer")
|
||||
origin = np.float32(origin_lattice); spacing = np.float32(spacing_lattice); reference = np.float32(reference_length_lattice)
|
||||
if not np.isfinite(origin) or not np.isfinite(spacing) or not np.isfinite(reference) or spacing <= 0 or reference <= 0:
|
||||
raise ValueError("coordinate frame must be finite with positive spacing/reference length")
|
||||
values = (origin + np.arange(count, dtype=np.float32) * spacing) / reference
|
||||
if not np.isfinite(values).all(): raise ValueError("generated coordinate sequence must be finite")
|
||||
return values
|
||||
|
||||
|
||||
def canonical_coordinate_frame(nx: int, ny: int, *, x_origin_lattice: float = 0.0, y_origin_lattice: float | None = None, spacing_lattice: float = 1.0, reference_length_lattice: float = COORDINATE_REFERENCE_LENGTH_LATTICE) -> dict:
|
||||
if y_origin_lattice is None: y_origin_lattice = -(ny - 1) / 2
|
||||
frame = {"schema_id": COORDINATE_FRAME_SCHEMA_ID, "dtype": "float32", "axis_order": "time,x,y", "reference_length_lattice": float(reference_length_lattice), "x": {"count": nx, "origin_lattice": float(x_origin_lattice), "spacing_lattice": float(spacing_lattice)}, "y": {"count": ny, "origin_lattice": float(y_origin_lattice), "spacing_lattice": float(spacing_lattice)}}
|
||||
for axis in ("x", "y"):
|
||||
spec = frame[axis]; canonical_coordinate_axis(spec["count"], origin_lattice=spec["origin_lattice"], spacing_lattice=spec["spacing_lattice"], reference_length_lattice=frame["reference_length_lattice"])
|
||||
return frame
|
||||
|
||||
|
||||
def validate_coordinate_arrays(x_D: np.ndarray, y_D: np.ndarray, frame: object) -> tuple[np.ndarray, np.ndarray]:
|
||||
if not isinstance(frame, dict) or set(frame) != COORDINATE_FRAME_KEYS or frame.get("schema_id") != COORDINATE_FRAME_SCHEMA_ID or frame.get("dtype") != "float32" or frame.get("axis_order") != "time,x,y": raise ValueError("coordinate frame schema is not exact")
|
||||
reference = frame["reference_length_lattice"]
|
||||
if type(reference) not in (int, float) or not np.isfinite(reference) or reference <= 0: raise ValueError("coordinate reference length is invalid")
|
||||
actual = {"x": np.asarray(x_D), "y": np.asarray(y_D)}
|
||||
for axis in ("x", "y"):
|
||||
spec = frame[axis]
|
||||
if not isinstance(spec, dict) or set(spec) != COORDINATE_AXIS_KEYS or type(spec["count"]) is not int or spec["count"] < 1 or any(type(spec[key]) not in (int, float) or not np.isfinite(spec[key]) for key in ("origin_lattice", "spacing_lattice")) or spec["spacing_lattice"] <= 0: raise ValueError(f"{axis} coordinate declaration is invalid")
|
||||
value = actual[axis]; expected = canonical_coordinate_axis(spec["count"], origin_lattice=spec["origin_lattice"], spacing_lattice=spec["spacing_lattice"], reference_length_lattice=reference)
|
||||
if value.dtype != np.float32 or value.ndim != 1 or not np.isfinite(value).all() or not np.array_equal(value, expected): raise ValueError(f"{axis}_D must exactly equal the declared canonical float32 lattice sequence")
|
||||
return actual["x"], actual["y"]
|
||||
|
||||
|
||||
def case_snapshot(case_id: str, role: Role) -> dict:
|
||||
if case_id not in CASES: raise ValueError(f"case must be one of {tuple(CASES)}")
|
||||
if role not in ROLES: raise ValueError(f"role must be one of {ROLES}")
|
||||
case=CASES[case_id]
|
||||
value={"schema_id":SCHEMA_ID,"case":asdict(case),"role":role,"role_semantics":ROLE_SEMANTICS[role],
|
||||
"timeline_semantics":"absolute lattice steps; equal time does not establish equal physical phase",
|
||||
"phase_reference_semantics":"sampled evidence only; phase equality requires separate validation",
|
||||
"action_semantics":{"requested_normalized":"policy output", "requested_physical":"full solver body command",
|
||||
"effective_applied":"solver EMA command at the completed lattice step"},
|
||||
"velocity_decoder":{"schema_id":VELOCITY_DECODER_SCHEMA_ID,"quantity":"nondimensional velocity q/U0",
|
||||
"formula":VELOCITY_DECODER_FORMULA,"formula_sha256":VELOCITY_DECODER_FORMULA_SHA256}}
|
||||
value["geometry_sha256"]=sha256(canonical_json(asdict(case.geometry))).hexdigest()
|
||||
value["config_sha256"]=sha256(canonical_json(value)).hexdigest()
|
||||
return value
|
||||
|
||||
|
||||
# Dependency-safe frozen runtime authority. This module imports no solver/GPU code.
|
||||
REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
ACTION_FORMULA = "physical[-3:]=(normalized*8+bias)*u0; legacy EMA a_next=0.9*a_prev+0.1*target"
|
||||
ACTION_FORMULA_SHA256 = sha256(ACTION_FORMULA.encode()).hexdigest()
|
||||
MODEL_BINDINGS = {
|
||||
"karman_re100": ("models/old/d1a3o12_re100.zip", "148240c1dcb8b11d8e5a0a9e991f6874c4e799c9744707bf0fb32a8efeb0468d"),
|
||||
"illusion_1.0L": ("models/250525/d1a3o14_250525_imit_1L_2U_600S.zip", "570fbdb08fba1170cbab53de9ede372a9536e0bb8419dda0f0f2db20fd20583d"),
|
||||
}
|
||||
CONFIG_BINDINGS = {
|
||||
"configs/legacy_configs/config_cuda.json": "77140dc4b4983a783e601c145761c4d0a7d9b79e821d3925a8a10a59055c8565",
|
||||
"configs/legacy_configs/config_flowfield.json": "a0428733b94a5dc4b01a75cd55a935c51cddea49feb61cb85e59e2b04a865cce",
|
||||
}
|
||||
ILLUSION_TRAINING_BINDINGS = {
|
||||
"normalization_path": "src/SR_analysis/data/illusion/illusion_1L/norm.json",
|
||||
"normalization_sha256": "9ec5ddbe68fb441cdb660c17b51bf84ac348ca87d565081a21cb092a5245e4fc",
|
||||
"harmonics_path": "src/SR_analysis/data/illusion/illusion_1L/target_harmonics.json",
|
||||
"harmonics_sha256": "f135660ee3533c81d7dd175a4530f83e728ba52ec9485738ec0ad953e4a6378d",
|
||||
}
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ObjectSpec:
|
||||
kind: str
|
||||
identity: str
|
||||
center_D: tuple[float, float]
|
||||
radius_D: float
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RoleSpec:
|
||||
case_id: str
|
||||
role: str
|
||||
objects: tuple[ObjectSpec, ...]
|
||||
control_interval: int
|
||||
physical_action_width: int
|
||||
observation_slices: dict[str, tuple[int, int] | None]
|
||||
controller: str
|
||||
fifo_len: int
|
||||
harmonic_channels: int
|
||||
model_path: str | None
|
||||
|
||||
|
||||
def role_spec(case_id: str, role: str) -> RoleSpec:
|
||||
if case_id not in CASES or role not in ROLES: raise ValueError("unsupported case/role")
|
||||
case=CASES[case_id]; g=case.geometry
|
||||
sensors=tuple(ObjectSpec("sensor",name,(g.sensor_x_D,y),.25) for name,y in zip(("sensor_upper","sensor_center","sensor_lower"),g.sensor_y_D))
|
||||
pinball=(ObjectSpec("cylinder","front",(g.front_x_D,0.),.5),ObjectSpec("cylinder","upper",(g.rear_x_D,g.rear_y_D[0]),.5),ObjectSpec("cylinder","lower",(g.rear_x_D,g.rear_y_D[1]),.5))
|
||||
if case_id=="karman_re100":
|
||||
objects=(ObjectSpec("cylinder","disturbance",(10.,0.),1.),)+sensors+(() if role=="q_target" else pinball)
|
||||
slices={"disturbance_force":(0,2),"sensors":(2,8),"pinball_forces":None if role=="q_target" else (8,14)}; harmonics=0
|
||||
else:
|
||||
target=(ObjectSpec("cylinder","target",(float(g.target_x_D),0.),.5),)
|
||||
objects=target+sensors if role=="q_target" else sensors+pinball
|
||||
slices={"disturbance_force":(0,2),"sensors":(2,8),"pinball_forces":None} if role=="q_target" else {"disturbance_force":None,"sensors":(0,6),"pinball_forces":(6,12)}
|
||||
harmonics=8 if role=="q_target" else (2 if role=="q_ctl" else 0)
|
||||
model_path=str(REPO_ROOT/MODEL_BINDINGS[case_id][0]) if role=="q_ctl" else None
|
||||
return RoleSpec(case_id,role,objects,case.sample_interval,len(objects),slices,"ppo_history" if role=="q_ctl" else "zero",150,harmonics,model_path)
|
||||
|
||||
|
||||
def role_spec_identity(spec: RoleSpec) -> str:
|
||||
return sha256(canonical_json(asdict(spec))).hexdigest()
|
||||
|
||||
|
||||
def expected_source_bindings(case_id: str, role: str) -> dict[str, str]:
|
||||
if case_id not in CASES or role not in ROLES: raise ValueError("unsupported case/role")
|
||||
result=dict(CONFIG_BINDINGS)
|
||||
if role=="q_ctl": result[MODEL_BINDINGS[case_id][0]]=MODEL_BINDINGS[case_id][1]
|
||||
return result
|
||||
|
||||
|
||||
def expected_controller_identity(case_id: str, role: str) -> dict[str, object]:
|
||||
if case_id=="illusion_1.0L" and role=="q_ctl": return dict(ILLUSION_TRAINING_BINDINGS)
|
||||
if role=="q_ctl": return {"kind":"generated_karman_normalization"}
|
||||
return {"kind":"not_policy_consumed","measured_phase_evidence":case_id=="illusion_1.0L" and role=="q_target"}
|
||||
@@ -0,0 +1,95 @@
|
||||
"""CPU-safe exact-time dual-clock collection."""
|
||||
from __future__ import annotations
|
||||
from collections.abc import Callable, Iterable, Mapping
|
||||
from typing import Any
|
||||
import numpy as np
|
||||
|
||||
TELEMETRY_KEYS=("sample_ids","acquisition_relative_lattice_steps","solver_absolute_control_indices","requested_normalized_action","requested_physical_action","effective_applied_action","disturbance_force","pinball_forces","sensors","phase_reference")
|
||||
|
||||
def field_steps(total_steps:int, interval:int, *, absolute_origin:int=0)->tuple[int,...]:
|
||||
if type(total_steps) is not int or total_steps<1 or type(interval) is not int or interval<1 or type(absolute_origin) is not int or absolute_origin<0: raise ValueError("timeline values must be positive integers and origin non-negative")
|
||||
return tuple(absolute_origin+i for i in range(interval,total_steps+1,interval))
|
||||
|
||||
def velocity_from_public_accessor(flow:Any)->tuple[np.ndarray,np.ndarray]:
|
||||
accessor=getattr(flow,"current_step_velocity_field",None)
|
||||
if not callable(accessor): raise RuntimeError("solver lacks safe public current_step_velocity_field accessor")
|
||||
ux,uy=(np.asarray(v) for v in accessor())
|
||||
if ux.dtype!=np.float32 or uy.dtype!=np.float32 or ux.shape!=uy.shape or ux.ndim!=2 or not np.isfinite(ux).all() or not np.isfinite(uy).all(): raise ValueError("velocity accessor must return finite float32 matching 2D fields")
|
||||
return ux.copy(),uy.copy()
|
||||
|
||||
def solver_fluid_mask(flow:Any)->np.ndarray:
|
||||
accessor=getattr(flow,"completed_flags_xy",None)
|
||||
if not callable(accessor): raise RuntimeError("solver-derived mask unavailable; coordinate fallback is forbidden")
|
||||
flags=np.asarray(accessor())
|
||||
if flags.dtype!=np.uint8 or flags.ndim!=2: raise ValueError("solver flags must be uint8 2D")
|
||||
mask=(flags & np.uint8(1)) != 0
|
||||
if not mask.any(): raise ValueError("solver fluid mask is empty")
|
||||
return mask.copy()
|
||||
|
||||
class ExactTelemetry:
|
||||
def __init__(self, *, role:str, observation_slices:Mapping[str,tuple[int,int]|None], phase_probe:Callable[[Any],Any], physical_width:int):
|
||||
if role not in {"q_target","q_blk","q_ctl"}: raise ValueError("invalid role")
|
||||
if type(physical_width) is not int or physical_width < 1: raise ValueError("physical_width must be positive")
|
||||
if set(observation_slices) != {"disturbance_force","pinball_forces","sensors"}: raise ValueError("observation slices must be exact")
|
||||
for bounds in observation_slices.values():
|
||||
if bounds is not None and (not isinstance(bounds,tuple) or len(bounds)!=2 or any(type(v) is not int for v in bounds) or bounds[0]<0 or bounds[1]<=bounds[0]): raise ValueError("invalid observation slice")
|
||||
self.role,self.slices,self.phase_probe,self.physical_width=role,dict(observation_slices),phase_probe,physical_width
|
||||
self.normalized=np.zeros(3,np.float32); self.physical=np.zeros(physical_width,np.float32)
|
||||
def set_requested(self, normalized:Any, physical:Any)->None:
|
||||
n,p=np.asarray(normalized),np.asarray(physical)
|
||||
if n.dtype!=np.float32 or n.shape!=(3,) or p.dtype!=np.float32 or p.shape!=(self.physical_width,) or not np.isfinite(n).all() or not np.isfinite(p).all(): raise ValueError("requested actions have wrong exact dtype/shape/finiteness")
|
||||
if np.any(n < -1) or np.any(n > 1): raise ValueError("normalized action is outside [-1,1]")
|
||||
if self.role!="q_ctl" and (np.any(n) or np.any(p)): raise ValueError("reference/passive requested action must be exactly zero")
|
||||
self.normalized,self.physical=n.copy(),p.copy()
|
||||
def __call__(self, flow:Any, solver_absolute_step:int, acquisition_relative_step:int, solver_absolute_control_index:int)->dict[str,np.ndarray]:
|
||||
obs_access=getattr(flow,"current_step_observation",None); action_access=getattr(flow,"current_effective_action",None)
|
||||
if not callable(obs_access) or not callable(action_access): raise RuntimeError("same-step telemetry accessors unavailable")
|
||||
raw=np.asarray(obs_access()); out={}
|
||||
if raw.dtype != np.float32 or raw.ndim != 1 or not np.isfinite(raw).all(): raise ValueError("raw observation must be finite float32 rank-1")
|
||||
for key,bounds in self.slices.items():
|
||||
width = 6 if key != "disturbance_force" else 2
|
||||
out[key]=np.zeros(width,np.float32) if bounds is None else raw[slice(*bounds)].copy()
|
||||
if out[key].dtype != np.float32 or out[key].shape != (width,) or not np.isfinite(out[key]).all(): raise ValueError(f"{key} has invalid width/dtype/finiteness")
|
||||
phase=np.asarray(self.phase_probe(flow)); effective=np.asarray(action_access())
|
||||
if phase.dtype!=np.float32 or phase.shape!=(1,) or effective.dtype!=np.float32 or effective.shape!=(self.physical_width,) or not np.isfinite(phase).all() or not np.isfinite(effective).all(): raise ValueError("phase/effective accessor contract violated")
|
||||
out.update(sample_ids=np.asarray(solver_absolute_step,np.int64),acquisition_relative_lattice_steps=np.asarray(acquisition_relative_step,np.int64),solver_absolute_control_indices=np.asarray(solver_absolute_control_index,np.int64),requested_normalized_action=self.normalized.copy(),requested_physical_action=self.physical.copy(),effective_applied_action=effective.copy(),phase_reference=phase.copy())
|
||||
if set(out)!=set(TELEMETRY_KEYS): raise ValueError("telemetry schema mismatch")
|
||||
return out
|
||||
|
||||
class DualClockCollector:
|
||||
def __init__(self, flow:Any, *, control_interval:int, control_count:int, absolute_origin:int, field_steps_absolute:Iterable[int], telemetry:Callable[[Any,int],Mapping[str,Any]], velocity_reader:Callable[[Any],tuple[np.ndarray,np.ndarray]]=velocity_from_public_accessor):
|
||||
self.flow=flow; self.control_interval=control_interval; self.control_count=control_count; self.origin=absolute_origin; self.steps=tuple(field_steps_absolute); self.telemetry=telemetry; self.velocity_reader=velocity_reader
|
||||
horizon=absolute_origin+control_interval*control_count
|
||||
if control_interval<1 or control_count<1 or tuple(sorted(set(self.steps)))!=self.steps or any(s<=absolute_origin or s>horizon for s in self.steps): raise ValueError("invalid absolute field timeline")
|
||||
clocks=flow.solver_clock_state()
|
||||
if clocks["solver_absolute_lattice_clock"]!=absolute_origin: raise ValueError("collector origin contradicts solver absolute lattice clock")
|
||||
self.solver_control_origin=clocks["solver_absolute_control_clock"]
|
||||
self.next_control=0; self.next_field=0; self.ux=[]; self.uy=[]; self.saved_steps=[]; self.snapshots={k:[] for k in TELEMETRY_KEYS}
|
||||
def run_interval(self,index:int,target:np.ndarray)->np.ndarray:
|
||||
if index!=self.next_control: raise ValueError("control intervals must be sequential")
|
||||
start=self.origin+index*self.control_interval; stop=start+self.control_interval; due=[]
|
||||
while self.next_field<len(self.steps) and self.steps[self.next_field]<=stop:
|
||||
if self.steps[self.next_field]>start: due.append(self.steps[self.next_field])
|
||||
self.next_field+=1
|
||||
self.flow.begin_control_interval(self.control_interval,target); current=start
|
||||
for boundary in due if due and due[-1]==stop else [*due,stop]:
|
||||
if boundary>current: self.flow.run_control_segment(boundary-current); current=boundary
|
||||
if boundary in due:
|
||||
clocks=self.flow.active_step_clock_state()
|
||||
expected_control=self.solver_control_origin+index
|
||||
if clocks["solver_absolute_lattice_clock"]!=boundary or clocks["solver_absolute_control_clock"]!=expected_control: raise RuntimeError("solver clock lineage mismatch at sampled step")
|
||||
ux,uy=self.velocity_reader(self.flow); snap=self.telemetry(self.flow,boundary,boundary-self.origin,expected_control)
|
||||
self.ux.append(ux); self.uy.append(uy); self.saved_steps.append(boundary)
|
||||
for key in TELEMETRY_KEYS:self.snapshots[key].append(np.asarray(snap[key]).copy())
|
||||
obs=np.asarray(self.flow.end_control_interval()).copy()
|
||||
clocks=self.flow.solver_clock_state()
|
||||
if clocks!={"solver_absolute_lattice_clock":stop,"solver_absolute_control_clock":self.solver_control_origin+index+1}: raise RuntimeError("solver clocks did not advance exactly one control interval")
|
||||
self.next_control+=1; return obs
|
||||
def arrays(self)->dict[str,np.ndarray]:
|
||||
if self.next_control!=self.control_count or tuple(self.saved_steps)!=self.steps: raise RuntimeError("rollout is incomplete")
|
||||
steps=np.asarray(self.saved_steps,np.int64); rel=steps-self.origin
|
||||
out={"ux":np.asarray(self.ux,np.float32),"uy":np.asarray(self.uy,np.float32),"lattice_steps":steps,
|
||||
"control_indices":(rel-1)//self.control_interval,"control_offsets":(rel-1)%self.control_interval+1}
|
||||
out.update({k:np.stack(v) for k,v in self.snapshots.items()})
|
||||
if not np.array_equal(out["sample_ids"],steps) or not np.array_equal(out["acquisition_relative_lattice_steps"],rel): raise RuntimeError("telemetry clock domains are not exact-time aligned")
|
||||
return out
|
||||
@@ -0,0 +1,514 @@
|
||||
"""Role-specific CFD runtime boundary; importing this module is CPU/CUDA safe."""
|
||||
from __future__ import annotations
|
||||
from dataclasses import asdict
|
||||
from hashlib import sha256
|
||||
import ast
|
||||
import json
|
||||
import zipfile
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Protocol
|
||||
import numpy as np
|
||||
from .contracts import (ACTION_FORMULA, ACTION_FORMULA_SHA256, CASES, CONFIG_BINDINGS, ILLUSION_TRAINING_BINDINGS, MODEL_BINDINGS, ROLES, CaseContract, ObjectSpec, RoleSpec, canonical_coordinate_axis, canonical_coordinate_frame, canonical_json, case_snapshot, expected_controller_identity, expected_source_bindings, role_spec, role_spec_identity, VELOCITY_DECODER_FORMULA, VELOCITY_DECODER_FORMULA_SHA256, VELOCITY_DECODER_SCHEMA_ID)
|
||||
from .artifacts import ArtifactTransaction, file_sha256
|
||||
from .dual_clock import DualClockCollector, ExactTelemetry, field_steps, solver_fluid_mask
|
||||
|
||||
REQUIRED_CFD_ENV = "pycuda_3_10"
|
||||
POLICY_DEVICE = "cpu"
|
||||
REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
LEGACY_CONFIG_DIR = REPO_ROOT / "configs" / "legacy_configs"
|
||||
MODEL_FILES = {case: REPO_ROOT/path for case,(path,_) in MODEL_BINDINGS.items()}
|
||||
EXPECTED_SHA256 = {**CONFIG_BINDINGS, **{path:digest for path,digest in MODEL_BINDINGS.values()}}
|
||||
ILLUSION_TRAINING_NORM = REPO_ROOT / ILLUSION_TRAINING_BINDINGS["normalization_path"]
|
||||
ILLUSION_TRAINING_HARMONICS = REPO_ROOT / ILLUSION_TRAINING_BINDINGS["harmonics_path"]
|
||||
ILLUSION_TRAINING_NORM_SHA256 = ILLUSION_TRAINING_BINDINGS["normalization_sha256"]
|
||||
ILLUSION_TRAINING_HARMONICS_SHA256 = ILLUSION_TRAINING_BINDINGS["harmonics_sha256"]
|
||||
REQUIRED_SOLVER_APIS = (
|
||||
"begin_control_interval", "run_control_segment", "current_step_observation",
|
||||
"current_effective_action", "current_step_velocity_field", "current_step_ddf_checkpoint",
|
||||
"completed_flags_xy", "active_step_clock_state", "solver_clock_state", "full_state_checkpoint", "restore_full_state", "end_control_interval",
|
||||
)
|
||||
|
||||
|
||||
def require_cfd_environment() -> None:
|
||||
actual = os.environ.get("CONDA_DEFAULT_ENV")
|
||||
if actual != REQUIRED_CFD_ENV:
|
||||
raise RuntimeError(f"CFD acquisition requires CONDA_DEFAULT_ENV={REQUIRED_CFD_ENV}; got {actual!r}")
|
||||
|
||||
|
||||
class FlowProtocol(Protocol):
|
||||
def begin_control_interval(self, total_steps: int, action_target: np.ndarray) -> None: ...
|
||||
def run_control_segment(self, num_steps: int) -> None: ...
|
||||
def current_step_observation(self) -> np.ndarray: ...
|
||||
def current_effective_action(self) -> np.ndarray: ...
|
||||
def current_step_velocity_field(self) -> tuple[np.ndarray, np.ndarray]: ...
|
||||
def current_step_ddf_checkpoint(self) -> dict[str, Any]: ...
|
||||
def full_state_checkpoint(self) -> dict[str, Any]: ...
|
||||
def restore_full_state(self, checkpoint: dict[str, Any]) -> None: ...
|
||||
def completed_flags_xy(self) -> np.ndarray: ...
|
||||
def active_step_clock_state(self) -> dict[str, int]: ...
|
||||
def solver_clock_state(self) -> dict[str, int]: ...
|
||||
def end_control_interval(self) -> np.ndarray: ...
|
||||
|
||||
|
||||
|
||||
def _load_legacy_configs() -> tuple[Any, Any]:
|
||||
from LegacyCelerisLab import utils
|
||||
cuda_cfg = utils.load_cuda_config(str(LEGACY_CONFIG_DIR / "config_cuda.json"))
|
||||
field_cfg = utils.load_flow_field_config(str(LEGACY_CONFIG_DIR / "config_flowfield.json"))
|
||||
return cuda_cfg, field_cfg
|
||||
|
||||
|
||||
def _load_policy(path: str, *, policy_device: str, s_dim: int):
|
||||
"""Load PPO for CPU inference only; CUDA belongs exclusively to the CFD solver."""
|
||||
if policy_device != POLICY_DEVICE:
|
||||
raise ValueError(f"policy_device must be {POLICY_DEVICE!r}; got {policy_device!r}")
|
||||
import torch
|
||||
from torch.nn import Module
|
||||
from stable_baselines3 import PPO
|
||||
import gymnasium as gym
|
||||
from gymnasium import spaces
|
||||
class Sin(Module):
|
||||
def forward(self, x):
|
||||
return torch.sin(x)
|
||||
class Dummy(gym.Env):
|
||||
observation_space = spaces.Box(-1, 1, shape=(s_dim,), dtype=np.float32)
|
||||
action_space = spaces.Box(-1, 1, shape=(3,), dtype=np.float32)
|
||||
def reset(self, *, seed=None, options=None):
|
||||
return np.zeros(s_dim, np.float32), {}
|
||||
def step(self, action):
|
||||
return np.zeros(s_dim, np.float32), 0.0, False, False, {}
|
||||
return PPO.load(path, env=Dummy(), device=POLICY_DEVICE)
|
||||
|
||||
|
||||
class RoleRuntime:
|
||||
"""Constructed one-role adapter; CFD advancement remains an explicit caller action."""
|
||||
def __init__(self, flow: FlowProtocol, spec: RoleSpec, case: CaseContract, policy: Any = None, *, cfd_device: int = 0, policy_device: str = POLICY_DEVICE):
|
||||
if type(cfd_device) is not int or cfd_device < 0: raise ValueError("cfd_device must be a nonnegative logical device")
|
||||
if policy_device != POLICY_DEVICE: raise ValueError(f"policy_device must be {POLICY_DEVICE!r}")
|
||||
self.flow, self.spec, self.case, self.policy = flow, spec, case, policy
|
||||
self.cfd_device, self.policy_device = cfd_device, policy_device
|
||||
self.fifo: list[np.ndarray] = []
|
||||
self.normalization: dict[str, np.ndarray | float] | None = None
|
||||
self.harmonics: list[dict] | None = None
|
||||
self.solver_absolute_control_clock = 0
|
||||
self.solver_absolute_lattice_clock = 0
|
||||
self.acquisition_relative_control_index = 0
|
||||
self.acquisition_relative_lattice_clock = 0
|
||||
self.policy_harmonic_phase_index = 0
|
||||
self.requested_normalized = np.zeros(3, np.float32)
|
||||
self.requested_physical = np.zeros(spec.physical_action_width, np.float32)
|
||||
self.initial_fifo_history: np.ndarray | None = None
|
||||
self.boundary_observation_history: list[np.ndarray] = []
|
||||
self.policy_source_observation_history: list[np.ndarray] = []
|
||||
self.policy_input_observation_history: list[np.ndarray] = []
|
||||
self.policy_harmonic_phase_indices: list[int] = []
|
||||
self.requested_normalized_action_history: list[np.ndarray] = []
|
||||
self.requested_physical_action_history: list[np.ndarray] = []
|
||||
|
||||
def set_controller_state(self, *, fifo_history: np.ndarray, normalization: dict[str, Any],
|
||||
harmonics: list[dict] | None = None,
|
||||
solver_absolute_control_clock: int = 0,
|
||||
solver_absolute_lattice_clock: int = 0,
|
||||
policy_harmonic_phase_index: int = 0) -> None:
|
||||
fifo = np.asarray(fifo_history)
|
||||
if fifo.dtype != np.float32 or fifo.shape != (self.spec.fifo_len, 12) or not np.isfinite(fifo).all():
|
||||
raise ValueError("controller FIFO must be finite float32 (150,12)")
|
||||
norm = validate_normalization(normalization)
|
||||
checked_harmonics = validate_harmonics(harmonics, self.spec.harmonic_channels)
|
||||
clocks=(solver_absolute_control_clock,solver_absolute_lattice_clock,policy_harmonic_phase_index)
|
||||
if any(type(value) is not int or value<0 for value in clocks): raise ValueError("clock domains must be nonnegative integers")
|
||||
self.fifo = [row.copy() for row in fifo]
|
||||
self.initial_fifo_history = fifo.copy()
|
||||
self.boundary_observation_history = []
|
||||
self.policy_source_observation_history = []
|
||||
self.policy_input_observation_history = []
|
||||
self.policy_harmonic_phase_indices = []
|
||||
self.requested_normalized_action_history = []
|
||||
self.requested_physical_action_history = []
|
||||
self.normalization = norm
|
||||
self.harmonics = checked_harmonics
|
||||
self.solver_absolute_control_clock=solver_absolute_control_clock; self.solver_absolute_lattice_clock=solver_absolute_lattice_clock
|
||||
self.acquisition_relative_control_index=0; self.acquisition_relative_lattice_clock=0
|
||||
self.policy_harmonic_phase_index=policy_harmonic_phase_index
|
||||
|
||||
def requested_action(self) -> tuple[np.ndarray, np.ndarray]:
|
||||
source = np.asarray(self.fifo[-1], np.float32).copy()
|
||||
phase_index = self.policy_harmonic_phase_index
|
||||
if self.spec.role != "q_ctl":
|
||||
self.requested_normalized.fill(0); self.requested_physical.fill(0)
|
||||
obs = np.zeros(12 if self.spec.case_id == "karman_re100" else 14, np.float32)
|
||||
self.policy_source_observation_history.append(np.zeros(12, np.float32))
|
||||
self.policy_input_observation_history.append(obs)
|
||||
self.policy_harmonic_phase_indices.append(phase_index)
|
||||
self.requested_normalized_action_history.append(self.requested_normalized.copy())
|
||||
self.requested_physical_action_history.append(self.requested_physical.copy())
|
||||
return self.requested_normalized.copy(), self.requested_physical.copy()
|
||||
if self.policy is None or self.normalization is None or len(self.fifo) != self.spec.fifo_len:
|
||||
raise RuntimeError("controlled role requires loaded policy and complete history/normalization")
|
||||
raw = self.fifo[-1]
|
||||
if self.spec.case_id=="karman_re100" and self.policy_harmonic_phase_index==0:
|
||||
obs=np.zeros(12,np.float32)
|
||||
else:
|
||||
force = raw[6:12] / self.normalization["force_norm_fact"]
|
||||
sensor = (raw[:6] - self.normalization["sens_deviation"]) / self.normalization["sens_norm_fact"]
|
||||
values = [*force, *sensor]
|
||||
if self.spec.case_id == "illusion_1.0L":
|
||||
if self.harmonics is None: raise RuntimeError("Illusion harmonics are unavailable")
|
||||
target = reconstruct_harmonics(self.policy_harmonic_phase_index, self.harmonics)[:2]
|
||||
values.extend((target / self.normalization["force_norm_fact"]).tolist())
|
||||
obs = np.clip(np.asarray(values, np.float32), -1, 1)
|
||||
self.last_policy_observation=obs.copy()
|
||||
self.policy_source_observation_history.append(source)
|
||||
self.policy_input_observation_history.append(obs.copy())
|
||||
self.policy_harmonic_phase_indices.append(phase_index)
|
||||
predicted = np.asarray(self.policy.predict(obs, deterministic=True)[0])
|
||||
if predicted.dtype != np.float32 or predicted.shape not in {(3,), (1,3)} or not np.isfinite(predicted).all(): raise ValueError("policy output must be finite float32 width 3")
|
||||
normalized = predicted.reshape(3)
|
||||
if np.any(normalized < -1) or np.any(normalized > 1): raise ValueError("policy output is outside [-1,1]")
|
||||
bias = np.asarray((0., -4., 4.) if self.spec.case_id == "karman_re100" else (0., -2., 2.), np.float32)
|
||||
physical = np.zeros(self.spec.physical_action_width, np.float32)
|
||||
physical[-3:] = (normalized * 8 + bias) * self.case.u0
|
||||
self.requested_normalized, self.requested_physical = normalized, physical
|
||||
self.requested_normalized_action_history.append(normalized.copy())
|
||||
self.requested_physical_action_history.append(physical.copy())
|
||||
return normalized.copy(), physical.copy()
|
||||
|
||||
def controller_state_identity(self) -> dict[str, Any]:
|
||||
if not self.fifo or self.normalization is None:
|
||||
raise RuntimeError("controller state is incomplete")
|
||||
checkpoint = self.flow.current_step_ddf_checkpoint()
|
||||
if self.initial_fifo_history is None:
|
||||
raise RuntimeError("initial controller FIFO was not retained")
|
||||
boundaries = np.asarray(self.boundary_observation_history, np.float32)
|
||||
sources = np.asarray(self.policy_source_observation_history, np.float32)
|
||||
policy_inputs = np.asarray(self.policy_input_observation_history, np.float32)
|
||||
phases = np.asarray(self.policy_harmonic_phase_indices, np.int64)
|
||||
normalized_history = np.asarray(self.requested_normalized_action_history, np.float32)
|
||||
physical_history = np.asarray(self.requested_physical_action_history, np.float32)
|
||||
source_hashes = np.asarray([sha256(np.ascontiguousarray(row).tobytes()).hexdigest() for row in sources])
|
||||
norm_hash = sha256(canonical_json({k: np.asarray(v).tolist() for k, v in self.normalization.items()})).hexdigest()
|
||||
harmonics_hash = sha256(canonical_json(self.harmonics or [])).hexdigest()
|
||||
if self.spec.case_id=="illusion_1.0L" and self.spec.role=="q_ctl":
|
||||
norm_hash=ILLUSION_TRAINING_NORM_SHA256; harmonics_hash=ILLUSION_TRAINING_HARMONICS_SHA256
|
||||
model_hash = sha256(Path(self.spec.model_path).read_bytes()).hexdigest() if self.spec.model_path else sha256(b"zero-controller").hexdigest()
|
||||
return {
|
||||
"current_ddf": checkpoint["current_ddf"], "temp_ddf": checkpoint["temp_ddf"],
|
||||
"current_raw_observation": np.asarray(self.flow.current_step_observation(), np.float32),
|
||||
"fifo_history": np.asarray(self.fifo, np.float32),
|
||||
"initial_fifo_history": self.initial_fifo_history.copy(),
|
||||
"boundary_observation_history": boundaries,
|
||||
"policy_source_observation_history": sources,
|
||||
"policy_source_observation_sha256": source_hashes,
|
||||
"policy_input_observation_history": policy_inputs,
|
||||
"policy_harmonic_phase_indices": phases,
|
||||
"requested_normalized_action_history": normalized_history,
|
||||
"requested_physical_action_history": physical_history,
|
||||
"persisted_effective_ema_action": np.asarray(self.flow.current_effective_action(), np.float32),
|
||||
"policy_harmonic_phase_index": np.asarray(self.policy_harmonic_phase_index,np.int64),
|
||||
"solver_absolute_control_clock": np.asarray(self.solver_absolute_control_clock,np.int64),
|
||||
"solver_absolute_lattice_clock": np.asarray(self.solver_absolute_lattice_clock,np.int64),
|
||||
"acquisition_relative_control_index": np.asarray(self.acquisition_relative_control_index,np.int64),
|
||||
"acquisition_relative_lattice_clock": np.asarray(self.acquisition_relative_lattice_clock,np.int64),
|
||||
"normalization_hash": np.asarray(norm_hash),
|
||||
"harmonics_hash": np.asarray(harmonics_hash),
|
||||
"model_hash": np.asarray(model_hash),
|
||||
"cuda_config_hash": np.asarray(EXPECTED_SHA256["configs/legacy_configs/config_cuda.json"]),
|
||||
"flow_config_hash": np.asarray(EXPECTED_SHA256["configs/legacy_configs/config_flowfield.json"]),
|
||||
"config_hash": np.asarray(role_spec_identity(self.spec)),
|
||||
"geometry_hash": np.asarray(sha256(canonical_json(asdict(self.case.geometry))).hexdigest()),
|
||||
"action_formula_hash": np.asarray(ACTION_FORMULA_SHA256),
|
||||
"velocity_decoder_formula_hash": np.asarray(VELOCITY_DECODER_FORMULA_SHA256),
|
||||
}
|
||||
|
||||
|
||||
def validate_normalization(value: dict[str, Any]) -> dict[str, np.ndarray]:
|
||||
if set(value) != {"force_norm_fact","sens_deviation","sens_norm_fact"}: raise ValueError("normalization keys are not exact")
|
||||
force=np.asarray(value["force_norm_fact"]); deviation=np.asarray(value["sens_deviation"]); scale=np.asarray(value["sens_norm_fact"])
|
||||
if force.dtype != np.float32 or force.ndim != 0 or not np.isfinite(force) or force <= 0: raise ValueError("force normalization must be positive finite float32 scalar")
|
||||
if deviation.dtype != np.float32 or deviation.shape != (6,) or not np.isfinite(deviation).all(): raise ValueError("sensor deviation invalid")
|
||||
if scale.dtype != np.float32 or scale.shape != (6,) or not np.isfinite(scale).all() or np.any(scale <= 0): raise ValueError("sensor scales must be positive finite float32")
|
||||
return {"force_norm_fact":force.copy(),"sens_deviation":deviation.copy(),"sens_norm_fact":scale.copy()}
|
||||
|
||||
|
||||
def validate_harmonics(value: list[dict] | None, channels: int) -> list[dict] | None:
|
||||
if channels == 0:
|
||||
if value not in (None, []): raise ValueError("harmonics forbidden for this role")
|
||||
return None
|
||||
if not isinstance(value,list) or len(value)!=channels: raise ValueError(f"harmonic reference requires exactly {channels} channels")
|
||||
checked=[]
|
||||
for harmonic in value:
|
||||
if set(harmonic)!={"dc","amps","freqs","phases"}: raise ValueError("harmonic keys invalid")
|
||||
arrays=[np.asarray(harmonic[key],np.float64) for key in ("amps","freqs","phases")]
|
||||
if not arrays[0].ndim==arrays[1].ndim==arrays[2].ndim==1 or not arrays[0].shape==arrays[1].shape==arrays[2].shape or not all(np.isfinite(a).all() for a in arrays) or not np.isfinite(float(harmonic["dc"])): raise ValueError("harmonic arrays invalid")
|
||||
checked.append({"dc":float(harmonic["dc"]),"amps":arrays[0].tolist(),"freqs":arrays[1].tolist(),"phases":arrays[2].tolist()})
|
||||
return checked
|
||||
|
||||
|
||||
def analyze_harmonics(states: np.ndarray, count: int = 5) -> list[dict]:
|
||||
states=np.asarray(states)
|
||||
if states.dtype!=np.float32 or states.shape!=(150,8) or not np.isfinite(states).all(): raise ValueError("target harmonic source must be float32 (150,8)")
|
||||
result=[]
|
||||
for channel in range(8):
|
||||
transform=np.fft.rfft(states[:,channel]); frequencies=np.fft.rfftfreq(150); amplitudes=2*np.abs(transform)/150
|
||||
indices=np.argsort(amplitudes[1:])[::-1][:count]+1
|
||||
result.append({"dc":float(transform[0].real/150),"amps":amplitudes[indices].tolist(),"freqs":frequencies[indices].tolist(),"phases":np.angle(transform)[indices].tolist()})
|
||||
return result
|
||||
|
||||
|
||||
def verify_policy_spaces(policy: Any, observation_width: int) -> None:
|
||||
obs=getattr(policy,"observation_space",None); action=getattr(policy,"action_space",None)
|
||||
if getattr(obs,"shape",None)!=(observation_width,) or getattr(action,"shape",None)!=(3,): raise ValueError("loaded policy spaces mismatch")
|
||||
if np.dtype(getattr(obs,"dtype",None))!=np.float32 or np.dtype(getattr(action,"dtype",None))!=np.float32: raise ValueError("loaded policy space dtype mismatch")
|
||||
if not np.allclose(obs.low,-1) or not np.allclose(obs.high,1) or not np.allclose(action.low,-1) or not np.allclose(action.high,1): raise ValueError("loaded policy bounds mismatch")
|
||||
device = getattr(policy, "device", None)
|
||||
if device is None or str(device) != POLICY_DEVICE: raise ValueError("loaded policy device must be CPU")
|
||||
|
||||
|
||||
def verify_solver_objects(flow: Any, spec: RoleSpec) -> list[dict[str,Any]]:
|
||||
table=getattr(flow,"objects",None)
|
||||
if not isinstance(table,dict) or list(table.keys()) != list(range(len(spec.objects))): raise ValueError("solver object IDs are not exact contiguous insertion order")
|
||||
center_y=(int(flow.FIELD_SHAPE[1])-1)/2; diameter=20.0; persisted=[]
|
||||
for index,obj in enumerate(spec.objects):
|
||||
actual=table[index]; center=(obj.center_D[0]*diameter,center_y+obj.center_D[1]*diameter,0.0); radius=obj.radius_D*diameter
|
||||
if actual.get("type")!=obj.kind or tuple(actual.get("center",()))!=center or float(actual.get("radius",-1))!=radius: raise ValueError(f"solver object {index} contradicts role spec")
|
||||
persisted.append({"id":index,"identity":obj.identity,"kind":obj.kind,"center_lattice":center,"radius_lattice":radius})
|
||||
if np.asarray(getattr(flow,"action",None)).shape != (spec.physical_action_width,): raise ValueError("solver action width contradicts role spec")
|
||||
return persisted
|
||||
|
||||
def reconstruct_harmonics(t: int, harmonics: list[dict]) -> np.ndarray:
|
||||
result = np.zeros(len(harmonics), np.float32)
|
||||
for i, harmonic in enumerate(harmonics):
|
||||
value = float(harmonic["dc"])
|
||||
for amp, freq, phase in zip(harmonic["amps"], harmonic["freqs"], harmonic["phases"]):
|
||||
value += float(amp) * np.cos(2 * np.pi * float(freq) * t + float(phase))
|
||||
result[i] = value
|
||||
return result
|
||||
|
||||
|
||||
def build_role_runtime(*, case: str, role: str, output: Path | None = None,
|
||||
device_id: int = 0, policy_device: str = POLICY_DEVICE, flow_factory: Any = None,
|
||||
config_loader: Any = None, policy_loader: Any = None) -> RoleRuntime:
|
||||
"""Build exactly one role in one process; does not advance CFD or write output."""
|
||||
require_cfd_environment()
|
||||
if policy_device != POLICY_DEVICE: raise ValueError(f"policy_device must be {POLICY_DEVICE!r}; CUDA policy inference is forbidden")
|
||||
spec = role_spec(case, role)
|
||||
using_default_config = config_loader is None
|
||||
config_loader = config_loader or _load_legacy_configs
|
||||
if using_default_config:
|
||||
for relative in ("configs/legacy_configs/config_cuda.json","configs/legacy_configs/config_flowfield.json"):
|
||||
if file_sha256(REPO_ROOT/relative) != EXPECTED_SHA256[relative]: raise RuntimeError(f"bound source digest mismatch: {relative}")
|
||||
if flow_factory is None:
|
||||
from LegacyCelerisLab import FlowField
|
||||
flow_factory = FlowField
|
||||
cuda_cfg, field_cfg = config_loader()
|
||||
field_cfg = field_cfg._replace(viscosity=CASES[case].viscosity, velocity=CASES[case].u0)
|
||||
flow = flow_factory(field_cfg, cuda_cfg, device_id=device_id)
|
||||
center_y = (int(flow.FIELD_SHAPE[1]) - 1) / 2
|
||||
diameter = 20.0
|
||||
for obj in spec.objects:
|
||||
center = (obj.center_D[0] * diameter, center_y + obj.center_D[1] * diameter, 0.0)
|
||||
if obj.kind == "sensor": flow.add_sensor(center, obj.radius_D * diameter)
|
||||
else: flow.add_cylinder(center, obj.radius_D * diameter)
|
||||
policy = None
|
||||
if role == "q_ctl":
|
||||
model_relative=str(Path(spec.model_path).relative_to(REPO_ROOT))
|
||||
if file_sha256(Path(spec.model_path)) != EXPECTED_SHA256[model_relative]: raise RuntimeError("bound policy model digest mismatch")
|
||||
loader = policy_loader or _load_policy
|
||||
policy = loader(spec.model_path, policy_device=policy_device, s_dim=12 if case == "karman_re100" else 14)
|
||||
verify_policy_spaces(policy, 12 if case == "karman_re100" else 14)
|
||||
if hasattr(policy, "set_random_seed"): policy.set_random_seed(0 if case == "karman_re100" else 19)
|
||||
verify_solver_objects(flow, spec)
|
||||
return RoleRuntime(flow, spec, CASES[case], policy, cfd_device=device_id, policy_device=policy_device)
|
||||
|
||||
|
||||
|
||||
def _canonical_history_observation(raw: np.ndarray, spec: RoleSpec) -> np.ndarray:
|
||||
raw=np.asarray(raw)
|
||||
if raw.dtype!=np.float32 or raw.ndim!=1 or not np.isfinite(raw).all(): raise ValueError("boundary observation invalid")
|
||||
result=np.zeros(12,np.float32)
|
||||
sensors=spec.observation_slices["sensors"]
|
||||
forces=spec.observation_slices["pinball_forces"]
|
||||
if sensors is not None: result[:6]=raw[slice(*sensors)]
|
||||
if forces is not None: result[6:12]=raw[slice(*forces)]
|
||||
return result
|
||||
|
||||
|
||||
def _normalization_from_history(history: np.ndarray) -> dict[str,np.ndarray]:
|
||||
history=np.asarray(history,np.float32)
|
||||
force=float(6*np.max(np.abs(history[:,6:12])))
|
||||
deviation=np.mean(history[:,:6],axis=0,dtype=np.float32)
|
||||
scales=5*np.max(np.abs(history[:,:6]-deviation),axis=0)
|
||||
# Fail closed: a zero observed scale cannot define policy normalization.
|
||||
return validate_normalization({"force_norm_fact":np.asarray(force,np.float32),"sens_deviation":deviation.astype(np.float32),"sens_norm_fact":scales.astype(np.float32)})
|
||||
|
||||
|
||||
|
||||
def load_illusion_training_reference(*, repo_root:Path=REPO_ROOT) -> tuple[dict[str,np.ndarray],list[dict],dict[str,str]]:
|
||||
norm_path=repo_root/ILLUSION_TRAINING_NORM.relative_to(REPO_ROOT); harmonics_path=repo_root/ILLUSION_TRAINING_HARMONICS.relative_to(REPO_ROOT)
|
||||
if file_sha256(norm_path)!=ILLUSION_TRAINING_NORM_SHA256 or file_sha256(harmonics_path)!=ILLUSION_TRAINING_HARMONICS_SHA256: raise RuntimeError("frozen Illusion training reference digest mismatch")
|
||||
norm_doc=json.loads(norm_path.read_text()); normalization=validate_normalization({key:np.asarray(norm_doc[key],np.float32) for key in ("force_norm_fact","sens_deviation","sens_norm_fact")})
|
||||
harmonics_doc=json.loads(harmonics_path.read_text()); harmonics=validate_harmonics(harmonics_doc.get("harmonics",harmonics_doc) if isinstance(harmonics_doc,dict) else harmonics_doc,2)
|
||||
identity={"normalization_path":str(norm_path),"normalization_sha256":ILLUSION_TRAINING_NORM_SHA256,"harmonics_path":str(harmonics_path),"harmonics_sha256":ILLUSION_TRAINING_HARMONICS_SHA256}
|
||||
return normalization,harmonics,identity
|
||||
|
||||
def initialize_role(runtime: RoleRuntime, *, target_harmonics: list[dict] | None = None,
|
||||
stabilization_steps: int | None = None) -> None:
|
||||
"""Stabilize; checkpoint; measure norm; exact restore; then warm FIFO."""
|
||||
flow,spec,case=runtime.flow,runtime.spec,runtime.case
|
||||
stabilize=int(4*int(flow.FIELD_SHAPE[0])/case.u0) if stabilization_steps is None else stabilization_steps
|
||||
if type(stabilize) is not int or stabilize<1: raise ValueError("stabilization_steps must be positive")
|
||||
zero=np.zeros(spec.physical_action_width,np.float32); flow.run(stabilize,zero)
|
||||
if np.any(np.asarray(flow.current_effective_action())): raise RuntimeError("post-stabilization EMA must be exactly zero")
|
||||
checkpoint=flow.full_state_checkpoint()
|
||||
norm_history=[]; measured_target=[]
|
||||
for _ in range(spec.fifo_len):
|
||||
flow.run(spec.control_interval,zero); raw=np.asarray(flow.obs)
|
||||
norm_history.append(_canonical_history_observation(raw,spec))
|
||||
if spec.case_id=="illusion_1.0L" and spec.role=="q_target": measured_target.append(raw.copy())
|
||||
measured_harmonics=analyze_harmonics(np.asarray(measured_target,np.float32)) if measured_target else None
|
||||
flow.restore_full_state(checkpoint)
|
||||
if np.any(np.asarray(flow.current_effective_action())) or np.any(np.asarray(flow.action)): raise RuntimeError("restored post-stabilization EMA/action must be exactly zero")
|
||||
if spec.role=="q_ctl" and spec.case_id=="illusion_1.0L":
|
||||
normalization,harmonics,identity=load_illusion_training_reference(); runtime.controller_reference_identity=identity
|
||||
elif spec.role=="q_ctl":
|
||||
normalization=_normalization_from_history(np.asarray(norm_history,np.float32)); harmonics=None; runtime.controller_reference_identity={"kind":"generated_karman_normalization"}
|
||||
else:
|
||||
normalization={"force_norm_fact":np.asarray(1,np.float32),"sens_deviation":np.zeros(6,np.float32),"sens_norm_fact":np.ones(6,np.float32)}
|
||||
harmonics=measured_harmonics if spec.role=="q_target" else None
|
||||
runtime.controller_reference_identity={"kind":"not_policy_consumed","measured_phase_evidence":bool(measured_harmonics)}
|
||||
warm=np.zeros(spec.physical_action_width,np.float32)
|
||||
if spec.role=="q_ctl": warm[-3:]=np.asarray((0,-4,4) if spec.case_id=="karman_re100" else (0,-1,1),np.float32)*case.u0
|
||||
history=[]
|
||||
for _ in range(spec.fifo_len):
|
||||
flow.run(spec.control_interval,warm); history.append(_canonical_history_observation(np.asarray(flow.obs),spec))
|
||||
runtime.set_controller_state(fifo_history=np.asarray(history,np.float32),normalization=normalization,harmonics=harmonics,solver_absolute_control_clock=flow.solver_clock_state()["solver_absolute_control_clock"],solver_absolute_lattice_clock=flow.solver_clock_state()["solver_absolute_lattice_clock"],policy_harmonic_phase_index=0)
|
||||
runtime.measured_target_harmonics=measured_harmonics
|
||||
runtime.initial_policy_observation = np.zeros(12,np.float32) if spec.case_id=="karman_re100" and spec.role=="q_ctl" else None
|
||||
|
||||
|
||||
def _phase_probe(runtime: RoleRuntime):
|
||||
spec=runtime.spec; flow=runtime.flow
|
||||
center_y=int((int(flow.FIELD_SHAPE[1])-1)/2)
|
||||
x=int(round(runtime.case.geometry.sensor_x_D*20.0))
|
||||
def probe(_:Any)->np.ndarray:
|
||||
velocity=np.asarray(flow.current_step_velocity_probe((x,center_y)))
|
||||
if velocity.dtype!=np.float32 or velocity.shape!=(2,) or not np.isfinite(velocity).all(): raise ValueError("phase velocity probe invalid")
|
||||
return velocity[:1].copy()
|
||||
return probe
|
||||
|
||||
|
||||
def _runtime_config(runtime:RoleRuntime, *, solver_lattice_origin:int, solver_control_origin:int, field_interval:int, objects:list[dict], coordinate_frame:dict) -> dict:
|
||||
config=case_snapshot(runtime.spec.case_id,runtime.spec.role)
|
||||
config["runtime"]={"role_spec":asdict(runtime.spec),"role_spec_sha256":role_spec_identity(runtime.spec),"physical_action_width":runtime.spec.physical_action_width,"solver_objects":objects,"coordinate_frame":coordinate_frame,"policy_device":runtime.policy_device,"cfd_device":runtime.cfd_device,"action_formula":ACTION_FORMULA,"action_formula_sha256":ACTION_FORMULA_SHA256,"velocity_decoder":{"schema_id":VELOCITY_DECODER_SCHEMA_ID,"quantity":"nondimensional velocity q/U0","u0":float(runtime.case.u0),"formula":VELOCITY_DECODER_FORMULA,"formula_sha256":VELOCITY_DECODER_FORMULA_SHA256}}
|
||||
normalization={key:np.asarray(value).tolist() for key,value in runtime.normalization.items()}
|
||||
config["controller_sources"]={"normalization":normalization,"normalization_content_sha256":sha256(canonical_json(normalization)).hexdigest(),"controller_harmonics":runtime.harmonics or [],"controller_harmonics_content_sha256":sha256(canonical_json(runtime.harmonics or [])).hexdigest(),"identity":getattr(runtime,"controller_reference_identity",expected_controller_identity(runtime.spec.case_id,runtime.spec.role)),"measured_plus11_phase_harmonics":getattr(runtime,"measured_target_harmonics",None),"compatibility":"Illusion PPO always consumes frozen training reference; +11D measured target harmonics are phase evidence only and require replay/history compatibility smoke"}
|
||||
config["source_sha256"]=expected_source_bindings(runtime.spec.case_id,runtime.spec.role)
|
||||
config["clock_domains"]={"solver_absolute_lattice_origin":solver_lattice_origin,"solver_absolute_control_origin":solver_control_origin,"solver_absolute_lattice_final":runtime.solver_absolute_lattice_clock,"solver_absolute_control_final":runtime.solver_absolute_control_clock,"acquisition_relative_lattice_final":runtime.acquisition_relative_lattice_clock,"acquisition_relative_control_final":runtime.acquisition_relative_control_index,"policy_harmonic_phase_final":runtime.policy_harmonic_phase_index}
|
||||
config["acquisition"]={"field_interval":field_interval,"checkpoint_lifecycle":"after final completed control boundary; raw observation is final lattice step, public obs is interval average; DDF current is completed and temp is previous/work","control_history":"complete boundary-average lineage independent of field cadence","policy_input_contract":"reconstruct_from_prior_boundary_history" if runtime.spec.role=="q_ctl" else "not_applicable_explicit_zero"}
|
||||
return config
|
||||
|
||||
|
||||
def run_role_acquisition(*, case:str, role:str, output:Path, control_count:int,
|
||||
field_interval:int, runtime:RoleRuntime|None=None,
|
||||
runtime_builder:Any=build_role_runtime,
|
||||
initializer:Any=initialize_role,
|
||||
target_harmonics:list[dict]|None=None,
|
||||
stabilization_steps:int|None=None) -> Path:
|
||||
"""Execute, validate and atomically publish one complete role acquisition."""
|
||||
if output.exists(): raise FileExistsError(output)
|
||||
if type(control_count) is not int or control_count<1 or type(field_interval) is not int or field_interval<1: raise ValueError("counts/intervals must be positive")
|
||||
horizon=CASES[case].sample_interval*control_count
|
||||
if horizon%field_interval!=0: raise ValueError("horizon must be divisible by field_interval; terminal field is required")
|
||||
schedule=field_steps(horizon,field_interval)
|
||||
if not schedule or schedule[-1]!=horizon: raise ValueError("field schedule must be nonempty and include terminal boundary")
|
||||
runtime=runtime or runtime_builder(case=case,role=role,output=output)
|
||||
if runtime.spec.case_id!=case or runtime.spec.role!=role: raise ValueError("runtime role mismatch")
|
||||
objects=verify_solver_objects(runtime.flow,runtime.spec)
|
||||
initializer(runtime,target_harmonics=target_harmonics,stabilization_steps=stabilization_steps)
|
||||
live=runtime.flow.solver_clock_state()
|
||||
if live!={"solver_absolute_lattice_clock":runtime.solver_absolute_lattice_clock,"solver_absolute_control_clock":runtime.solver_absolute_control_clock}: raise RuntimeError("runtime clocks contradict live solver after warmup")
|
||||
telemetry=ExactTelemetry(role=role,observation_slices=runtime.spec.observation_slices,phase_probe=_phase_probe(runtime),physical_width=runtime.spec.physical_action_width)
|
||||
horizon=runtime.spec.control_interval*control_count
|
||||
collector=DualClockCollector(runtime.flow,control_interval=runtime.spec.control_interval,control_count=control_count,absolute_origin=runtime.solver_absolute_lattice_clock,field_steps_absolute=field_steps(horizon,field_interval,absolute_origin=runtime.solver_absolute_lattice_clock),telemetry=telemetry)
|
||||
solver_lattice_origin=runtime.solver_absolute_lattice_clock; solver_control_origin=runtime.solver_absolute_control_clock
|
||||
for index in range(control_count):
|
||||
normalized,physical=runtime.requested_action(); telemetry.set_requested(normalized,physical)
|
||||
boundary=np.asarray(collector.run_interval(index,physical))
|
||||
canonical_boundary=_canonical_history_observation(boundary,runtime.spec)
|
||||
runtime.boundary_observation_history.append(canonical_boundary.copy())
|
||||
runtime.fifo.pop(0); runtime.fifo.append(canonical_boundary)
|
||||
runtime.acquisition_relative_control_index+=1; runtime.acquisition_relative_lattice_clock+=runtime.spec.control_interval; runtime.policy_harmonic_phase_index+=1
|
||||
runtime.solver_absolute_control_clock+=1; runtime.solver_absolute_lattice_clock+=runtime.spec.control_interval
|
||||
if runtime.flow.solver_clock_state()!={"solver_absolute_lattice_clock":runtime.solver_absolute_lattice_clock,"solver_absolute_control_clock":runtime.solver_absolute_control_clock}: raise RuntimeError("runtime and solver clocks diverged after interval")
|
||||
arrays=collector.arrays(); flags=solver_fluid_mask(runtime.flow)
|
||||
nx,ny=flags.shape
|
||||
coordinate_frame=canonical_coordinate_frame(nx,ny)
|
||||
reference=coordinate_frame["reference_length_lattice"]
|
||||
arrays.update(x_D=canonical_coordinate_axis(nx,origin_lattice=coordinate_frame["x"]["origin_lattice"],spacing_lattice=coordinate_frame["x"]["spacing_lattice"],reference_length_lattice=reference),y_D=canonical_coordinate_axis(ny,origin_lattice=coordinate_frame["y"]["origin_lattice"],spacing_lattice=coordinate_frame["y"]["spacing_lattice"],reference_length_lattice=reference),fluid_mask=flags)
|
||||
config=_runtime_config(runtime,solver_lattice_origin=solver_lattice_origin,solver_control_origin=solver_control_origin,field_interval=field_interval,objects=objects,coordinate_frame=coordinate_frame)
|
||||
state=runtime.controller_state_identity()
|
||||
state["config_hash"]=np.asarray(sha256(canonical_json(config)).hexdigest())
|
||||
with ArtifactTransaction(output) as transaction:
|
||||
transaction.write(arrays=arrays,config=config,state=state)
|
||||
return transaction.publish()
|
||||
|
||||
def _solver_methods_from_source(path: Path) -> set[str]:
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"))
|
||||
for node in tree.body:
|
||||
if isinstance(node, ast.ClassDef) and node.name == "FlowField":
|
||||
return {child.name for child in node.body if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef))}
|
||||
return set()
|
||||
|
||||
|
||||
def preflight(case: str | None = None, *, repo_root: Path = REPO_ROOT) -> dict[str, Any]:
|
||||
"""CPU-only, fail-closed readiness inspection. It never imports CUDA or PPO."""
|
||||
cases = tuple(CASES) if case is None else (case,)
|
||||
if any(item not in CASES for item in cases): raise ValueError("unsupported case")
|
||||
checks: list[dict[str, Any]] = []
|
||||
def record(name: str, ok: bool, detail: Any): checks.append({"name": name, "ok": bool(ok), "detail": detail})
|
||||
for relative in ("configs/legacy_configs/config_cuda.json","configs/legacy_configs/config_flowfield.json"):
|
||||
path=repo_root/relative; actual=file_sha256(path) if path.is_file() else None
|
||||
record(relative,actual==EXPECTED_SHA256[relative],{"path":str(path),"expected_sha256":EXPECTED_SHA256[relative],"actual_sha256":actual})
|
||||
for path,expected,name in ((repo_root/ILLUSION_TRAINING_NORM.relative_to(REPO_ROOT),ILLUSION_TRAINING_NORM_SHA256,"illusion.training_norm"),(repo_root/ILLUSION_TRAINING_HARMONICS.relative_to(REPO_ROOT),ILLUSION_TRAINING_HARMONICS_SHA256,"illusion.training_harmonics")):
|
||||
actual=file_sha256(path) if path.is_file() else None; record(name,actual==expected,{"path":str(path),"expected_sha256":expected,"actual_sha256":actual})
|
||||
methods = _solver_methods_from_source(repo_root / "LegacyCelerisLab/driver.py")
|
||||
missing = sorted(set(REQUIRED_SOLVER_APIS) - methods)
|
||||
record("solver_read_only_apis", not missing, {"required": REQUIRED_SOLVER_APIS, "missing": missing})
|
||||
for case_id in cases:
|
||||
model = repo_root / MODEL_FILES[case_id].relative_to(REPO_ROOT); relative=str(MODEL_FILES[case_id].relative_to(REPO_ROOT))
|
||||
actual=file_sha256(model) if model.is_file() else None
|
||||
record(f"{case_id}.model",actual==EXPECTED_SHA256[relative],{"path":str(model),"expected_sha256":EXPECTED_SHA256[relative],"actual_sha256":actual})
|
||||
try:
|
||||
with zipfile.ZipFile(model) as archive: metadata=json.loads(archive.read("data"))
|
||||
observation_shape=tuple(metadata["observation_space"]["_shape"]); action_shape=tuple(metadata["action_space"]["_shape"])
|
||||
spaces_ok=observation_shape==((12,) if case_id=="karman_re100" else (14,)) and action_shape==(3,) and metadata["observation_space"]["dtype"]==metadata["action_space"]["dtype"]=="float32"
|
||||
except Exception as error:
|
||||
spaces_ok=False; observation_shape=action_shape=(); metadata_error=repr(error)
|
||||
record(f"{case_id}.policy_archive_spaces",spaces_ok,{"observation_shape":observation_shape,"action_shape":action_shape,"error":locals().get("metadata_error")})
|
||||
specs = [role_spec(case_id, role) for role in ROLES]
|
||||
record(f"{case_id}.role_specs", all(s.objects and s.physical_action_width == len(s.objects) for s in specs),
|
||||
[{"role": s.role, "objects": [o.identity for o in s.objects], "identity": role_spec_identity(s)} for s in specs])
|
||||
contract = CASES[case_id]
|
||||
record(f"{case_id}.history", specs[2].fifo_len == 150, {"fifo_len": specs[2].fifo_len})
|
||||
record(f"{case_id}.harmonics", case_id != "illusion_1.0L" or (specs[0].harmonic_channels == 8 and specs[2].harmonic_channels == 2),
|
||||
{"measured_q_target_channels": specs[0].harmonic_channels, "frozen_q_ctl_target_force_channels": specs[2].harmonic_channels})
|
||||
geometry = asdict(contract.geometry)
|
||||
expected = ({"front_x_D": 30.0, "rear_x_D": 31.3, "sensor_x_D": 40.0, "target_x_D": None}
|
||||
if case_id == "karman_re100" else
|
||||
{"front_x_D": 30.0, "rear_x_D": 31.3, "sensor_x_D": 41.0, "target_x_D": 31.0})
|
||||
geometry_ok = all(geometry[key] == value for key, value in expected.items())
|
||||
geometry_ok = geometry_ok and tuple(geometry["rear_y_D"]) == (.75, -.75) and tuple(geometry["sensor_y_D"]) == (2., 0., -2.)
|
||||
record(f"{case_id}.geometry", geometry_ok, geometry)
|
||||
record("initialization_contract", all(token in Path(__file__).read_text() for token in ("full_state_checkpoint()","restore_full_state(checkpoint)","post-stabilization EMA must be exactly zero")),{"sequence":"stabilize -> full checkpoint -> zero normalization trajectory -> exact restore -> FIFO warmup"})
|
||||
record("clock_domain_contract",all(token in Path(__file__).read_text() for token in ("solver_absolute_lattice_clock","solver_absolute_control_clock","acquisition_relative_lattice_clock","acquisition_relative_control_index","policy_harmonic_phase_index")),{"solver":"absolute lifecycle lineage","acquisition":"relative rollout zero-origin","policy_phase":"independent zero-origin"})
|
||||
record("policy_device_contract", POLICY_DEVICE=="cpu" and "device=POLICY_DEVICE" in Path(__file__).read_text() and "device=f\"cuda:" not in Path(__file__).read_text(), {"policy_device":POLICY_DEVICE,"cfd_device":"separate logical device selected at runtime","cuda_initialized":False})
|
||||
record("policy_initial_state_contract", "self.policy_harmonic_phase_index==0" in Path(__file__).read_text() and "reconstruct_harmonics(self.policy_harmonic_phase_index" in Path(__file__).read_text(),{"karman_first":"exact zero 12-vector","illusion_first":"warmup boundary normalization + frozen training harmonic phase zero"})
|
||||
record("runner_reachable",callable(run_role_acquisition) and "transaction.publish()" in Path(__file__).read_text(),{"callable":True,"publishes_artifact":True})
|
||||
record("action_formula",ACTION_FORMULA_SHA256==sha256(ACTION_FORMULA.encode()).hexdigest(),{"formula":ACTION_FORMULA,"sha256":ACTION_FORMULA_SHA256})
|
||||
record("velocity_decoder",VELOCITY_DECODER_FORMULA_SHA256==sha256(VELOCITY_DECODER_FORMULA.encode()).hexdigest(),{"schema_id":VELOCITY_DECODER_SCHEMA_ID,"quantity":"q/U0","formula":VELOCITY_DECODER_FORMULA,"sha256":VELOCITY_DECODER_FORMULA_SHA256})
|
||||
ok = all(check["ok"] for check in checks)
|
||||
blockers = [check["name"] for check in checks if not check["ok"]]
|
||||
return {"schema_id": "ccd-acquisition-preflight/v1", "ready_for_independent_pre_cfd_gate": ok,
|
||||
"cfd_executed": False, "runtime_success_claimed": False, "checks": checks, "blockers": blockers,
|
||||
"production_blockers": ["Illusion +11D replay/history smoke not run"] if "illusion_1.0L" in cases else []}
|
||||
@@ -0,0 +1,91 @@
|
||||
"""CPU-testable solver extraction and checkpoint-copy primitives."""
|
||||
from __future__ import annotations
|
||||
from hashlib import sha256
|
||||
from collections.abc import Callable
|
||||
import numpy as np
|
||||
|
||||
|
||||
LEGACY_FLUID_BIT = np.uint8(0b00000001)
|
||||
|
||||
|
||||
def _fluid_mask_xy(mask_or_flags: np.ndarray, nx: int, ny: int) -> np.ndarray:
|
||||
"""Validate an exact mask or decode Legacy's FLUID bit from solver flags."""
|
||||
source = np.asarray(mask_or_flags)
|
||||
if source.shape != (nx, ny):
|
||||
raise ValueError("solver fluid mask/flags must match the exact (nx, ny) grid")
|
||||
if source.dtype == np.bool_:
|
||||
mask = source
|
||||
elif source.dtype == np.uint8:
|
||||
# Legacy driver.py and kernels/macros.h both define FLUID as bit 0.
|
||||
mask = (source & LEGACY_FLUID_BIT) != 0
|
||||
else:
|
||||
raise ValueError("solver fluid mask must be bool or Legacy flags must be uint8")
|
||||
if not mask.any():
|
||||
raise ValueError("solver fluid mask is empty")
|
||||
return np.ascontiguousarray(mask, dtype=np.bool_)
|
||||
|
||||
|
||||
def d2q9_q_over_u0_xy(flat_ddf: np.ndarray, nx: int, ny: int,
|
||||
fluid_mask_or_flags: np.ndarray, u0: float
|
||||
) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""Decode Legacy nondimensional velocity ``q/U0`` on fluid cells.
|
||||
|
||||
This is the exact archived Legacy formula: D2Q9 momentum components are
|
||||
divided by the configured reference velocity ``u0``. No density division
|
||||
is performed. Nonfluid populations are ignored and export exact zero.
|
||||
"""
|
||||
if type(nx) is not int or type(ny) is not int or nx < 1 or ny < 1:
|
||||
raise ValueError("nx and ny must be positive integers")
|
||||
if type(u0) not in (int, float) or not np.isfinite(u0) or u0 <= 0:
|
||||
raise ValueError("u0 must be positive and finite")
|
||||
flat = np.asarray(flat_ddf)
|
||||
if flat.dtype != np.float32 or flat.ndim != 1 or flat.size != 9 * nx * ny:
|
||||
raise ValueError("D2Q9 storage must be flat float32 with size 9*nx*ny")
|
||||
mask = _fluid_mask_xy(fluid_mask_or_flags, nx, ny)
|
||||
populations = flat.reshape(9, ny, nx).transpose(2, 1, 0)
|
||||
fluid = populations[mask]
|
||||
if not np.isfinite(fluid).all():
|
||||
raise ValueError("D2Q9 fluid populations contain non-finite values")
|
||||
ux = np.zeros((nx, ny), dtype=np.float32)
|
||||
uy = np.zeros((nx, ny), dtype=np.float32)
|
||||
ux[mask] = ((fluid[:, 1] + fluid[:, 5] + fluid[:, 8]
|
||||
- fluid[:, 3] - fluid[:, 6] - fluid[:, 7]) / np.float32(u0)).astype(np.float32)
|
||||
uy[mask] = ((fluid[:, 2] + fluid[:, 5] + fluid[:, 6]
|
||||
- fluid[:, 4] - fluid[:, 7] - fluid[:, 8]) / np.float32(u0)).astype(np.float32)
|
||||
if not np.isfinite(ux[mask]).all() or not np.isfinite(uy[mask]).all():
|
||||
raise ValueError("decoded fluid velocity is non-finite")
|
||||
return ux, uy
|
||||
|
||||
|
||||
def copy_ping_pong_ddf(copy_current: Callable[[np.ndarray], None],
|
||||
copy_temp: Callable[[np.ndarray], None], size: int
|
||||
) -> dict[str, np.ndarray | str]:
|
||||
"""Copy current(completed) then temp(previous/work) without changing either."""
|
||||
if type(size) is not int or size < 1:
|
||||
raise ValueError("checkpoint size must be a positive integer")
|
||||
current = np.empty(size, dtype=np.float32)
|
||||
temp = np.empty(size, dtype=np.float32)
|
||||
copy_current(current)
|
||||
copy_temp(temp)
|
||||
if not np.isfinite(current).all() or not np.isfinite(temp).all():
|
||||
raise ValueError("checkpoint DDF buffers must be finite")
|
||||
return {
|
||||
"current_ddf": current,
|
||||
"temp_ddf": temp,
|
||||
"current_sha256": sha256(current.tobytes()).hexdigest(),
|
||||
"temp_sha256": sha256(temp.tobytes()).hexdigest(),
|
||||
}
|
||||
|
||||
|
||||
def ema_step(previous: np.ndarray, target: np.ndarray, steps: int, weight: float = 0.1) -> np.ndarray:
|
||||
"""Pure reference for Legacy's per-lattice-step action EMA."""
|
||||
old = np.asarray(previous)
|
||||
goal = np.asarray(target)
|
||||
if old.dtype != np.float32 or goal.dtype != np.float32 or old.shape != goal.shape:
|
||||
raise ValueError("EMA arrays must have matching float32 shape")
|
||||
if type(steps) is not int or steps < 0 or not np.isfinite(weight) or not 0 < weight <= 1:
|
||||
raise ValueError("invalid EMA step contract")
|
||||
result = old.copy()
|
||||
for _ in range(steps):
|
||||
result = ((1.0 - weight) * result + weight * goal).astype(np.float32)
|
||||
return result
|
||||
@@ -0,0 +1,251 @@
|
||||
"""Authoritative semantic validator for active acquisition artifacts."""
|
||||
from __future__ import annotations
|
||||
from dataclasses import asdict
|
||||
from hashlib import sha256
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping
|
||||
import json
|
||||
import numpy as np
|
||||
from .contracts import (ACTION_FORMULA, ACTION_FORMULA_SHA256, ARTIFACT_SCHEMA_ID, CASES, CONFIG_BINDINGS, ILLUSION_TRAINING_BINDINGS, MODEL_BINDINGS, ROLES, SCHEMA_ID, canonical_json, case_snapshot, expected_controller_identity, expected_source_bindings, role_spec, role_spec_identity, validate_coordinate_arrays, VELOCITY_DECODER_FORMULA, VELOCITY_DECODER_FORMULA_SHA256, VELOCITY_DECODER_SCHEMA_ID)
|
||||
from .dual_clock import TELEMETRY_KEYS
|
||||
|
||||
|
||||
def _reconstruct_harmonics(t: int, harmonics: list[dict]) -> np.ndarray:
|
||||
result = np.zeros(len(harmonics), np.float32)
|
||||
for index, harmonic in enumerate(harmonics):
|
||||
value = float(harmonic["dc"])
|
||||
for amp, freq, phase in zip(harmonic["amps"], harmonic["freqs"], harmonic["phases"]):
|
||||
value += float(amp) * np.cos(2 * np.pi * float(freq) * t + float(phase))
|
||||
result[index] = value
|
||||
return result
|
||||
|
||||
FIELD_KEYS = {"ux", "uy", "x_D", "y_D", "fluid_mask", "lattice_steps", "control_indices", "control_offsets", *TELEMETRY_KEYS}
|
||||
STATE_KEYS = {"current_ddf", "temp_ddf", "current_raw_observation", "fifo_history", "initial_fifo_history", "boundary_observation_history", "policy_source_observation_history", "policy_source_observation_sha256", "policy_input_observation_history", "policy_harmonic_phase_indices", "requested_normalized_action_history", "requested_physical_action_history", "persisted_effective_ema_action", "policy_harmonic_phase_index", "solver_absolute_control_clock", "solver_absolute_lattice_clock", "acquisition_relative_control_index", "acquisition_relative_lattice_clock", "normalization_hash", "harmonics_hash", "model_hash", "cuda_config_hash", "flow_config_hash", "config_hash", "geometry_hash", "action_formula_hash", "velocity_decoder_formula_hash"}
|
||||
CONFIG_KEYS = {"schema_id", "case", "role", "role_semantics", "timeline_semantics", "phase_reference_semantics", "action_semantics", "velocity_decoder", "geometry_sha256", "config_sha256", "runtime", "controller_sources", "source_sha256", "clock_domains", "acquisition"}
|
||||
LEGACY_ZERO_ROLE_RUNTIME_KEYS = {"role_spec", "role_spec_sha256", "physical_action_width", "solver_objects", "coordinate_frame", "action_formula", "action_formula_sha256", "velocity_decoder"}
|
||||
RUNTIME_KEYS = LEGACY_ZERO_ROLE_RUNTIME_KEYS | {"policy_device", "cfd_device"}
|
||||
ROLE_SPEC_KEYS = {"case_id", "role", "objects", "control_interval", "physical_action_width", "observation_slices", "controller", "fifo_len", "harmonic_channels", "model_path"}
|
||||
CLOCK_KEYS = {"solver_absolute_lattice_origin", "solver_absolute_control_origin", "solver_absolute_lattice_final", "solver_absolute_control_final", "acquisition_relative_lattice_final", "acquisition_relative_control_final", "policy_harmonic_phase_final"}
|
||||
CONTROLLER_KEYS = {"normalization", "normalization_content_sha256", "controller_harmonics", "controller_harmonics_content_sha256", "identity", "measured_plus11_phase_harmonics", "compatibility"}
|
||||
ACQUISITION_KEYS = {"field_interval", "checkpoint_lifecycle", "control_history", "policy_input_contract"}
|
||||
MANIFEST_KEYS = {"schema_id", "complete", "files", "state_array_sha256", "config_sha256", "field_count"}
|
||||
|
||||
|
||||
def array_sha256(value: np.ndarray) -> str:
|
||||
return sha256(np.ascontiguousarray(value).tobytes()).hexdigest()
|
||||
|
||||
|
||||
def require_sha256(value: Any, label: str) -> str:
|
||||
if not isinstance(value, str) or len(value) != 64:
|
||||
raise ValueError(f"{label} must be SHA256")
|
||||
try:
|
||||
int(value, 16)
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"{label} must be SHA256") from exc
|
||||
return value
|
||||
|
||||
|
||||
def _state_sha(value: np.ndarray, label: str) -> str:
|
||||
if value.ndim != 0 or value.dtype.kind not in "SU":
|
||||
raise ValueError(f"{label} must be a scalar string")
|
||||
return require_sha256(str(value.item()), label)
|
||||
|
||||
|
||||
def _finite_json(value: Any, label: str) -> None:
|
||||
try:
|
||||
canonical_json(value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError(f"{label} must be finite canonical-JSON compatible") from exc
|
||||
|
||||
|
||||
def validate_acquisition_semantics(*, arrays: Mapping[str, Any], config: Mapping[str, Any], state: Mapping[str, Any], manifest: Mapping[str, Any] | None = None, expected_case: str | None = None, expected_role: str | None = None) -> tuple[dict[str, np.ndarray], dict[str, np.ndarray]]:
|
||||
"""Validate content semantics, not merely internal artifact hashes."""
|
||||
if not isinstance(config, Mapping) or set(config) != CONFIG_KEYS:
|
||||
raise ValueError("acquisition config top-level schema is not exact")
|
||||
case_id, role = config.get("case", {}).get("case_id"), config.get("role")
|
||||
if case_id not in CASES or role not in ROLES or (expected_case is not None and case_id != expected_case) or (expected_role is not None and role != expected_role):
|
||||
raise ValueError("artifact exact case/role contract mismatch")
|
||||
config_json = json.loads(canonical_json(config))
|
||||
frozen = json.loads(canonical_json(case_snapshot(case_id, role)))
|
||||
for key in ("schema_id", "case", "role", "role_semantics", "timeline_semantics", "phase_reference_semantics", "action_semantics", "velocity_decoder", "geometry_sha256", "config_sha256"):
|
||||
if config_json.get(key) != frozen[key]:
|
||||
raise ValueError(f"artifact exact case/role contract mismatch: {key}")
|
||||
if config["schema_id"] != SCHEMA_ID:
|
||||
raise ValueError("acquisition contract schema mismatch")
|
||||
|
||||
runtime, clocks, acquisition, controller = config["runtime"], config["clock_domains"], config["acquisition"], config["controller_sources"]
|
||||
runtime_keys = set(runtime) if isinstance(runtime, Mapping) else set()
|
||||
allowed_runtime_schema = runtime_keys == RUNTIME_KEYS or (role in {"q_target", "q_blk"} and runtime_keys == LEGACY_ZERO_ROLE_RUNTIME_KEYS)
|
||||
if not allowed_runtime_schema or not isinstance(runtime["role_spec"], Mapping) or set(runtime["role_spec"]) != ROLE_SPEC_KEYS:
|
||||
raise ValueError("runtime/role-spec config schema is not exact")
|
||||
spec = runtime["role_spec"]
|
||||
authoritative_spec = json.loads(canonical_json(asdict(role_spec(case_id, role))))
|
||||
if json.loads(canonical_json(spec)) != authoritative_spec or runtime["role_spec_sha256"] != role_spec_identity(role_spec(case_id, role)):
|
||||
raise ValueError("runtime role spec differs from frozen authoritative role_spec")
|
||||
width = runtime["physical_action_width"]
|
||||
objects, persisted_objects = spec["objects"], runtime["solver_objects"]
|
||||
if type(width) is not int or width < 1 or not isinstance(objects, (list, tuple)) or len(objects) != width or not isinstance(persisted_objects, list) or len(persisted_objects) != width:
|
||||
raise ValueError("runtime physical action/object width is invalid")
|
||||
inferred_center_y = None
|
||||
for index, (declared, persisted) in enumerate(zip(objects, persisted_objects)):
|
||||
if not isinstance(declared, Mapping) or set(declared) != {"kind", "identity", "center_D", "radius_D"} or not isinstance(persisted, Mapping) or set(persisted) != {"id", "identity", "kind", "center_lattice", "radius_lattice"}: raise ValueError("solver object schema is not exact")
|
||||
center_D, center_lattice = declared["center_D"], persisted["center_lattice"]
|
||||
if not isinstance(center_D, (list, tuple)) or len(center_D) != 2 or not isinstance(center_lattice, (list, tuple)) or len(center_lattice) != 3 or not all(type(v) in (int, float) and np.isfinite(v) for v in (*center_D, *center_lattice)): raise ValueError("solver object coordinates are invalid")
|
||||
candidate_center_y = float(center_lattice[1]) - float(center_D[1]) * 20.0
|
||||
inferred_center_y = candidate_center_y if inferred_center_y is None else inferred_center_y
|
||||
if persisted["id"] != index or persisted["identity"] != declared["identity"] or persisted["kind"] != declared["kind"] or float(center_lattice[0]) != float(center_D[0]) * 20.0 or float(center_lattice[2]) != 0.0 or candidate_center_y != inferred_center_y or float(persisted["radius_lattice"]) != float(declared["radius_D"]) * 20.0: raise ValueError("solver object semantics contradict role spec")
|
||||
if runtime_keys == RUNTIME_KEYS and (runtime["policy_device"] != "cpu" or type(runtime["cfd_device"]) is not int or runtime["cfd_device"] < 0):
|
||||
raise ValueError("runtime compute-device roles are invalid")
|
||||
if runtime["action_formula"] != ACTION_FORMULA or runtime["action_formula_sha256"] != ACTION_FORMULA_SHA256:
|
||||
raise ValueError("runtime action formula differs from frozen authority")
|
||||
decoder = runtime["velocity_decoder"]
|
||||
if (not isinstance(decoder, Mapping) or set(decoder) != {"schema_id", "quantity", "u0", "formula", "formula_sha256"}
|
||||
or decoder["schema_id"] != VELOCITY_DECODER_SCHEMA_ID
|
||||
or decoder["quantity"] != "nondimensional velocity q/U0"
|
||||
or type(decoder["u0"]) not in (int, float) or not np.isfinite(decoder["u0"]) or decoder["u0"] <= 0
|
||||
or float(decoder["u0"]) != float(config["case"]["u0"])
|
||||
or decoder["formula"] != VELOCITY_DECODER_FORMULA
|
||||
or decoder["formula_sha256"] != VELOCITY_DECODER_FORMULA_SHA256):
|
||||
raise ValueError("runtime velocity decoder differs from frozen q/U0 authority")
|
||||
if not isinstance(clocks, Mapping) or set(clocks) != CLOCK_KEYS or any(type(clocks[key]) is not int or clocks[key] < 0 for key in CLOCK_KEYS):
|
||||
raise ValueError("clock-domain config schema is not exact")
|
||||
if not isinstance(acquisition, Mapping) or set(acquisition) != ACQUISITION_KEYS or type(acquisition["field_interval"]) is not int or acquisition["field_interval"] < 1 or not isinstance(acquisition["checkpoint_lifecycle"], str) or acquisition["control_history"] != "complete boundary-average lineage independent of field cadence" or acquisition["policy_input_contract"] != ("reconstruct_from_prior_boundary_history" if role == "q_ctl" else "not_applicable_explicit_zero"):
|
||||
|
||||
raise ValueError("acquisition config schema is not exact")
|
||||
if not isinstance(controller, Mapping) or set(controller) != CONTROLLER_KEYS:
|
||||
raise ValueError("controller-source config schema is not exact")
|
||||
normalization = controller["normalization"]
|
||||
if not isinstance(normalization, Mapping) or set(normalization) != {"force_norm_fact", "sens_deviation", "sens_norm_fact"}:
|
||||
raise ValueError("controller normalization schema is not exact")
|
||||
force = np.asarray(normalization["force_norm_fact"]); deviation = np.asarray(normalization["sens_deviation"]); scale = np.asarray(normalization["sens_norm_fact"])
|
||||
if force.ndim != 0 or not np.isfinite(force) or float(force) <= 0 or deviation.shape != (6,) or scale.shape != (6,) or not np.isfinite(deviation).all() or not np.isfinite(scale).all() or np.any(scale <= 0):
|
||||
raise ValueError("controller normalization semantics are invalid")
|
||||
if sha256(canonical_json(normalization)).hexdigest() != require_sha256(controller["normalization_content_sha256"], "normalization_content_sha256"):
|
||||
raise ValueError("normalization content hash mismatch")
|
||||
harmonics = controller["controller_harmonics"]
|
||||
if not isinstance(harmonics, list) or sha256(canonical_json(harmonics)).hexdigest() != require_sha256(controller["controller_harmonics_content_sha256"], "controller_harmonics_content_sha256"):
|
||||
raise ValueError("controller harmonics identity mismatch")
|
||||
if not isinstance(controller["identity"], Mapping) or not isinstance(controller["compatibility"], str):
|
||||
raise ValueError("controller source identity/compatibility is invalid")
|
||||
expected_identity = expected_controller_identity(case_id, role)
|
||||
if case_id == "illusion_1.0L" and role == "q_ctl":
|
||||
actual_identity = dict(controller["identity"]); actual_identity["normalization_path"] = str(Path(actual_identity.get("normalization_path", "")).resolve().relative_to(Path(__file__).resolve().parents[3])) if Path(actual_identity.get("normalization_path", "")).is_absolute() else actual_identity.get("normalization_path")
|
||||
actual_identity["harmonics_path"] = str(Path(actual_identity.get("harmonics_path", "")).resolve().relative_to(Path(__file__).resolve().parents[3])) if Path(actual_identity.get("harmonics_path", "")).is_absolute() else actual_identity.get("harmonics_path")
|
||||
if actual_identity != expected_identity: raise ValueError("Illusion training reference paths/hashes differ from frozen authority")
|
||||
elif controller["identity"] != expected_identity:
|
||||
raise ValueError("controller source identity differs from frozen authority")
|
||||
_finite_json(controller["measured_plus11_phase_harmonics"], "measured phase harmonics")
|
||||
if config["source_sha256"] != expected_source_bindings(case_id, role):
|
||||
raise ValueError("source paths/hashes differ from frozen case/role authority")
|
||||
|
||||
if set(arrays) != FIELD_KEYS:
|
||||
raise ValueError("artifact arrays do not match unified schema")
|
||||
data = {key: np.asarray(value) for key, value in arrays.items()}
|
||||
ux, uy, x, y, mask = data["ux"], data["uy"], data["x_D"], data["y_D"], data["fluid_mask"]
|
||||
if ux.dtype != np.float32 or uy.dtype != np.float32 or ux.ndim != 3 or ux.shape != uy.shape or not np.isfinite(ux).all() or not np.isfinite(uy).all():
|
||||
raise ValueError("fields must be finite matching float32 (time,x,y)")
|
||||
count, nx, ny = ux.shape
|
||||
if count < 1: raise ValueError("field time axis must be nonempty")
|
||||
validate_coordinate_arrays(x, y, runtime["coordinate_frame"])
|
||||
if x.shape != (nx,) or y.shape != (ny,): raise ValueError("declared coordinates must match field axes")
|
||||
if mask.dtype != np.bool_ or mask.shape != (nx, ny) or not mask.any():
|
||||
raise ValueError("saved solver fluid mask must match nonempty (x,y) grid")
|
||||
if np.any(ux[:, ~mask] != np.float32(0)) or np.any(uy[:, ~mask] != np.float32(0)):
|
||||
raise ValueError("solid-cell velocities must be exact float32 zero")
|
||||
integer_keys = ("lattice_steps", "control_indices", "control_offsets", "sample_ids", "acquisition_relative_lattice_steps", "solver_absolute_control_indices")
|
||||
for key in integer_keys:
|
||||
if data[key].dtype != np.int64 or data[key].shape != (count,): raise ValueError(f"{key} must be int64 length time")
|
||||
steps, relative = data["lattice_steps"], data["acquisition_relative_lattice_steps"]
|
||||
interval, origin, control_origin = config["case"]["sample_interval"], clocks["solver_absolute_lattice_origin"], clocks["solver_absolute_control_origin"]
|
||||
expected_controls = (relative - 1) // interval
|
||||
if np.any(np.diff(steps) <= 0) or np.any(relative <= 0) or not np.array_equal(data["sample_ids"], steps) or not np.array_equal(relative, steps - origin) or not np.array_equal(data["control_indices"], expected_controls) or not np.array_equal(data["control_offsets"], (relative - 1) % interval + 1) or not np.array_equal(data["solver_absolute_control_indices"], control_origin + expected_controls):
|
||||
raise ValueError("solver/acquisition timeline relations are invalid")
|
||||
if relative[-1] != clocks["acquisition_relative_lattice_final"] or steps[-1] != clocks["solver_absolute_lattice_final"] or clocks["solver_absolute_control_final"] - control_origin != clocks["acquisition_relative_control_final"] or clocks["policy_harmonic_phase_final"] != clocks["acquisition_relative_control_final"]:
|
||||
raise ValueError("final clock domains contradict timeline/control lineage")
|
||||
shapes = {"requested_normalized_action": (count, 3), "requested_physical_action": (count, width), "effective_applied_action": (count, width), "disturbance_force": (count, 2), "pinball_forces": (count, 6), "sensors": (count, 6), "phase_reference": (count, 1)}
|
||||
for key, shape in shapes.items():
|
||||
value = data[key]
|
||||
if value.dtype != np.float32 or value.shape != shape or not np.isfinite(value).all(): raise ValueError(f"{key} has invalid dtype/shape/finiteness")
|
||||
normalized, physical, effective = data["requested_normalized_action"], data["requested_physical_action"], data["effective_applied_action"]
|
||||
if np.any(normalized < -1) or np.any(normalized > 1): raise ValueError("normalized actions outside bounds")
|
||||
if role != "q_ctl":
|
||||
if np.any(normalized) or np.any(physical) or np.any(effective): raise ValueError("q_target/q_blk requested and effective actions must be exactly zero")
|
||||
else:
|
||||
bias = np.asarray((0., -4., 4.) if case_id == "karman_re100" else (0., -2., 2.), np.float32)
|
||||
u0 = np.float32(config["case"]["u0"]); expected_physical = np.zeros_like(physical); expected_physical[:, -3:] = (normalized * np.float32(8) + bias) * u0
|
||||
lower = (np.asarray([-8., -8., -8.], np.float32) + bias) * u0; upper = (np.asarray([8., 8., 8.], np.float32) + bias) * u0
|
||||
if not np.array_equal(physical, expected_physical): raise ValueError("q_ctl requested physical actions contradict frozen formula")
|
||||
if np.any(physical[:, :-3]) or np.any(effective[:, :-3]) or np.any(effective[:, -3:] < lower) or np.any(effective[:, -3:] > upper): raise ValueError("q_ctl effective EMA actions violate actuated-channel semantics/bounds")
|
||||
|
||||
if set(state) != STATE_KEYS:
|
||||
raise ValueError("controller state identity inventory is not exact")
|
||||
state_data = {key: np.asarray(value) for key, value in state.items()}
|
||||
ddf_size = 9 * nx * ny
|
||||
for key in ("current_ddf", "temp_ddf"):
|
||||
if state_data[key].dtype != np.float32 or state_data[key].shape != (ddf_size,) or not np.isfinite(state_data[key]).all(): raise ValueError(f"{key} has invalid D2Q9 storage")
|
||||
if state_data["current_raw_observation"].dtype != np.float32 or state_data["current_raw_observation"].ndim != 1 or state_data["current_raw_observation"].size < 1 or not np.isfinite(state_data["current_raw_observation"]).all(): raise ValueError("raw observation invalid")
|
||||
fifo = state_data["fifo_history"]
|
||||
initial_fifo = state_data["initial_fifo_history"]
|
||||
boundaries = state_data["boundary_observation_history"]
|
||||
sources = state_data["policy_source_observation_history"]
|
||||
source_hashes = state_data["policy_source_observation_sha256"]
|
||||
policy_inputs = state_data["policy_input_observation_history"]
|
||||
phase_indices = state_data["policy_harmonic_phase_indices"]
|
||||
normalized_controls = state_data["requested_normalized_action_history"]
|
||||
physical_controls = state_data["requested_physical_action_history"]
|
||||
control_count = clocks["acquisition_relative_control_final"]
|
||||
s_dim = 12 if case_id == "karman_re100" else 14
|
||||
for label, value, shape in (("FIFO/history", fifo, (150, 12)), ("initial FIFO/history", initial_fifo, (150, 12)), ("boundary observation history", boundaries, (control_count, 12)), ("policy source observation history", sources, (control_count, 12)), ("policy input observation history", policy_inputs, (control_count, s_dim)), ("requested normalized control history", normalized_controls, (control_count, 3)), ("requested physical control history", physical_controls, (control_count, width))):
|
||||
if value.dtype != np.float32 or value.shape != shape or not np.isfinite(value).all(): raise ValueError(f"{label} has invalid dtype/shape/finiteness")
|
||||
if phase_indices.dtype != np.int64 or phase_indices.shape != (control_count,) or not np.array_equal(phase_indices, np.arange(control_count, dtype=np.int64)): raise ValueError("policy harmonic phase indices must be exact zero-origin control lineage")
|
||||
if source_hashes.dtype.kind not in "SU" or source_hashes.shape != (control_count,) or any(str(value) != array_sha256(sources[index]) for index, value in enumerate(source_hashes.tolist())): raise ValueError("policy source observation hashes mismatch")
|
||||
expected_fifo = np.concatenate((initial_fifo, boundaries), axis=0)[-150:]
|
||||
if not np.array_equal(fifo, expected_fifo): raise ValueError("terminal FIFO must equal rolling append of initial FIFO and all boundaries")
|
||||
expected_sources = np.zeros_like(sources)
|
||||
if role == "q_ctl":
|
||||
expected_sources[0] = initial_fifo[-1]
|
||||
if control_count > 1: expected_sources[1:] = boundaries[:-1]
|
||||
if not np.array_equal(sources, expected_sources): raise ValueError("policy source observations must use the appropriate prior FIFO/boundary history or explicit not-applicable zeros")
|
||||
expected_inputs = np.zeros_like(policy_inputs)
|
||||
if role == "q_ctl":
|
||||
for index, raw in enumerate(expected_sources):
|
||||
if not (case_id == "karman_re100" and index == 0):
|
||||
force_values = raw[6:12] / np.float32(force)
|
||||
sensor_values = (raw[:6] - deviation.astype(np.float32)) / scale.astype(np.float32)
|
||||
values = [*force_values, *sensor_values]
|
||||
if case_id == "illusion_1.0L":
|
||||
target = _reconstruct_harmonics(index, harmonics)[:2] / np.float32(force)
|
||||
values.extend(target.tolist())
|
||||
expected_inputs[index] = np.clip(np.asarray(values, np.float32), -1, 1)
|
||||
if not np.array_equal(policy_inputs, expected_inputs): raise ValueError("policy inputs do not reconstruct exactly from prior history, normalization, harmonics, and initial semantics")
|
||||
if not np.array_equal(normalized, normalized_controls[data["control_indices"]]) or not np.array_equal(physical, physical_controls[data["control_indices"]]):
|
||||
raise ValueError("field-time requested actions contradict complete control action histories")
|
||||
expected_control_physical = np.zeros_like(physical_controls)
|
||||
if role == "q_ctl":
|
||||
bias = np.asarray((0., -4., 4.) if case_id == "karman_re100" else (0., -2., 2.), np.float32)
|
||||
expected_control_physical[:, -3:] = (normalized_controls * np.float32(8) + bias) * np.float32(config["case"]["u0"])
|
||||
if not np.array_equal(physical_controls, expected_control_physical): raise ValueError("control action histories contradict frozen role/action formula")
|
||||
if state_data["persisted_effective_ema_action"].dtype != np.float32 or state_data["persisted_effective_ema_action"].shape != (width,) or not np.isfinite(state_data["persisted_effective_ema_action"]).all() or not np.array_equal(state_data["persisted_effective_ema_action"], effective[-1]): raise ValueError("persisted EMA action must equal terminal sampled effective action")
|
||||
clock_pairs = (("solver_absolute_lattice_clock", "solver_absolute_lattice_final"), ("solver_absolute_control_clock", "solver_absolute_control_final"), ("acquisition_relative_lattice_clock", "acquisition_relative_lattice_final"), ("acquisition_relative_control_index", "acquisition_relative_control_final"), ("policy_harmonic_phase_index", "policy_harmonic_phase_final"))
|
||||
for state_key, config_key in clock_pairs:
|
||||
value = state_data[state_key]
|
||||
if value.dtype != np.int64 or value.ndim != 0 or int(value) != clocks[config_key]: raise ValueError(f"{state_key} does not exactly match final clock domain")
|
||||
state_hash_values = {key: _state_sha(state_data[key], key) for key in STATE_KEYS if key.endswith("_hash")}
|
||||
if state_hash_values["geometry_hash"] != config["geometry_sha256"] or state_hash_values["action_formula_hash"] != runtime["action_formula_sha256"] or state_hash_values["velocity_decoder_formula_hash"] != runtime["velocity_decoder"]["formula_sha256"] or state_hash_values["config_hash"] != sha256(canonical_json(config)).hexdigest():
|
||||
raise ValueError("state semantic identity hashes contradict config")
|
||||
identity_values = {value for key, value in controller["identity"].items() if key.endswith("_sha256") and isinstance(value, str)}
|
||||
if state_hash_values["normalization_hash"] not in {controller["normalization_content_sha256"], *identity_values} or state_hash_values["harmonics_hash"] not in {controller["controller_harmonics_content_sha256"], *identity_values}:
|
||||
raise ValueError("state controller-source hashes contradict config")
|
||||
if state_hash_values["cuda_config_hash"] != CONFIG_BINDINGS["configs/legacy_configs/config_cuda.json"] or state_hash_values["flow_config_hash"] != CONFIG_BINDINGS["configs/legacy_configs/config_flowfield.json"]:
|
||||
raise ValueError("state solver config hashes differ from frozen authority")
|
||||
expected_model_hash = MODEL_BINDINGS[case_id][1] if role == "q_ctl" else sha256(b"zero-controller").hexdigest()
|
||||
if state_hash_values["model_hash"] != expected_model_hash: raise ValueError("state model hash differs from frozen authority")
|
||||
if case_id == "illusion_1.0L" and role == "q_ctl" and (state_hash_values["normalization_hash"] != ILLUSION_TRAINING_BINDINGS["normalization_sha256"] or state_hash_values["harmonics_hash"] != ILLUSION_TRAINING_BINDINGS["harmonics_sha256"]): raise ValueError("state Illusion training reference hashes differ from frozen authority")
|
||||
|
||||
if manifest is not None:
|
||||
if not isinstance(manifest, Mapping) or set(manifest) != MANIFEST_KEYS or manifest["schema_id"] != ARTIFACT_SCHEMA_ID or manifest["complete"] is not True or manifest["field_count"] != count:
|
||||
raise ValueError("artifact manifest semantic schema is not exact")
|
||||
if set(manifest["state_array_sha256"]) != STATE_KEYS or any(array_sha256(state_data[key]) != manifest["state_array_sha256"][key] for key in STATE_KEYS):
|
||||
raise ValueError("manifest state array hash inventory is invalid")
|
||||
if manifest["config_sha256"] != sha256(canonical_json(config)).hexdigest(): raise ValueError("manifest config hash mismatch")
|
||||
return data, state_data
|
||||
@@ -1,346 +0,0 @@
|
||||
"""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:
|
||||
conda run -n pycuda_3_10 python ccd/run_ccd.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
|
||||
|
||||
_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, NX, NY
|
||||
from CCD_analysis.utils.resampling import (
|
||||
compute_pod, cumulative_energy, e95_index,
|
||||
compute_reduced_ccd,
|
||||
load_aligned_fields, make_force_obs,
|
||||
build_field_matrix, project_into_basis,
|
||||
)
|
||||
|
||||
# -- Protocol constants ---------------------------------------------------
|
||||
R_CANDIDATES = [6, 8, 10]
|
||||
CCD_Q = 6 # short, near-synchronous window for force/action
|
||||
DIAMETERS_MAIN = [0.75, 1.0]
|
||||
DIAMETERS_ALL = [0.75, 1.0, 1.5]
|
||||
CV_T_RELAXED = 0.12
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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 compute_modal_overlap(W_dict: dict, diam: float, r: int,
|
||||
obs_label: str = "force") -> list:
|
||||
"""Compute pairwise modal overlaps for a given diameter and r."""
|
||||
keys = [k for k in W_dict
|
||||
if f"{diam}L_" in k and f"_{obs_label}_r{r}" in k]
|
||||
overlaps = []
|
||||
for i, ka in enumerate(keys):
|
||||
for kb in keys[i + 1:]:
|
||||
Wa, Wb = W_dict[ka], W_dict[kb]
|
||||
n = min(Wa.shape[1], Wb.shape[1], 5)
|
||||
for k in range(n):
|
||||
ov = float(abs(
|
||||
Wa[:, k] / (np.linalg.norm(Wa[:, k]) + 1e-12) @
|
||||
Wb[:, k] / (np.linalg.norm(Wb[:, k]) + 1e-12)
|
||||
))
|
||||
overlaps.append({
|
||||
"case_a": ka.split(f"_{obs_label}_r{r}")[0],
|
||||
"case_b": kb.split(f"_{obs_label}_r{r}")[0],
|
||||
"mode": k + 1,
|
||||
"O": ov,
|
||||
})
|
||||
return overlaps
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main pipeline
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
print("=" * 60, flush=True)
|
||||
print("CCD Pipeline (Round 5 — fields_aligned, target-only basis)", flush=True)
|
||||
print("=" * 60, flush=True)
|
||||
|
||||
out_dir = os.path.join(DATA_DIR, "ccd")
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
all_results = {}
|
||||
W_dict = {} # for modal overlap
|
||||
|
||||
# -- Preflight --
|
||||
print("\n--- Preflight check ---", flush=True)
|
||||
all_scenes = ["pinball"]
|
||||
for diam in DIAMETERS_ALL:
|
||||
all_scenes.append(f"target_cylinder_{diam}L")
|
||||
all_scenes.append(f"illusion_{diam}L")
|
||||
|
||||
preflight_ok = {}
|
||||
for sn in all_scenes:
|
||||
try:
|
||||
meta = preflight(sn)
|
||||
preflight_ok[sn] = meta
|
||||
print(f" OK", flush=True)
|
||||
except (FileNotFoundError, AssertionError, KeyError) as e:
|
||||
print(f" FAILED: {e}", flush=True)
|
||||
preflight_ok[sn] = None
|
||||
|
||||
# -- Load all data --
|
||||
print("\n--- Loading data ---", flush=True)
|
||||
data_cache = {}
|
||||
for sn in all_scenes:
|
||||
if preflight_ok.get(sn) is None:
|
||||
continue
|
||||
t0 = time.time()
|
||||
try:
|
||||
d = load_aligned_fields(sn)
|
||||
data_cache[sn] = d
|
||||
print(f" {sn}: loaded ({len(d['ux'])} snapshots, "
|
||||
f"{time.time() - t0:.1f}s)", flush=True)
|
||||
except (FileNotFoundError, AssertionError, KeyError) as e:
|
||||
print(f" {sn}: FAILED — {e}", flush=True)
|
||||
|
||||
# -- Per-diameter CCD --
|
||||
print("\n--- CCD per diameter ---", flush=True)
|
||||
|
||||
for diam in DIAMETERS_ALL:
|
||||
tgt_name = f"target_cylinder_{diam}L"
|
||||
ill_name = f"illusion_{diam}L"
|
||||
|
||||
tgt_data = data_cache.get(tgt_name)
|
||||
ill_data = data_cache.get(ill_name)
|
||||
pin_data = data_cache.get("pinball")
|
||||
|
||||
if tgt_data is None:
|
||||
print(f"\n SKIP {diam}L: missing target data", flush=True)
|
||||
continue
|
||||
|
||||
print(f"\n{'=' * 60}", flush=True)
|
||||
print(f"Diameter {diam}L", flush=True)
|
||||
print(f"{'=' * 60}", flush=True)
|
||||
|
||||
is_special = (diam not in DIAMETERS_MAIN)
|
||||
if is_special:
|
||||
print(f" Note: {diam}L flagged as special-mechanism case", flush=True)
|
||||
|
||||
# -- Build target-only POD basis --
|
||||
Q_tgt = build_field_matrix(tgt_data["ux"], tgt_data["uy"])
|
||||
mean_f, modes, sv, coeffs = compute_pod(Q_tgt)
|
||||
energy = cumulative_energy(sv)
|
||||
e95 = e95_index(energy)
|
||||
print(f" Target-only POD: E95={e95}", flush=True)
|
||||
for i in range(min(8, len(sv))):
|
||||
print(f" mode {i + 1}: energy={energy[i]:.4f}", flush=True)
|
||||
|
||||
# -- Project illusion and pinball into target basis --
|
||||
proj_cache = {tgt_name: coeffs} # already in target basis
|
||||
|
||||
if ill_data is not None:
|
||||
proj_cache[ill_name] = project_into_basis(
|
||||
ill_data["ux"], ill_data["uy"], modes, mean_f)
|
||||
|
||||
if pin_data is not None:
|
||||
proj_cache["pinball"] = project_into_basis(
|
||||
pin_data["ux"], pin_data["uy"], modes, mean_f)
|
||||
|
||||
# -- CCD for each r and each case --
|
||||
for r in R_CANDIDATES:
|
||||
print(f"\n r={r}:", flush=True)
|
||||
modes_r = modes[:, :r]
|
||||
|
||||
for name in [tgt_name, ill_name, "pinball"]:
|
||||
d = data_cache.get(name)
|
||||
if d is None:
|
||||
continue
|
||||
if name not in proj_cache:
|
||||
continue
|
||||
|
||||
a_r = proj_cache[name][:r, :]
|
||||
N = a_r.shape[1]
|
||||
|
||||
# --- Force-CCD (primary: SigmaFy) ---
|
||||
frc = d.get("forces")
|
||||
if frc is not None:
|
||||
for f_mode, f_label in [("fy", "force_fy"),
|
||||
("fx", "force_fx"),
|
||||
("joint", "force_joint")]:
|
||||
y_f = make_force_obs(frc, name, mode=f_mode)
|
||||
y_f = y_f[:, :N]
|
||||
W, sig, Rmat, z, No, Nv = compute_reduced_ccd(
|
||||
a_r[:, :N], y_f, Q_delay=CCD_Q)
|
||||
en = cumulative_energy(sig)
|
||||
m80 = int(np.searchsorted(en, 0.80) + 1) if len(en) > 0 else 0
|
||||
|
||||
key = f"{diam}L_{name}_{f_label}_r{r}"
|
||||
W_dict[key] = W
|
||||
all_results[key] = {
|
||||
"diam": diam, "case": name,
|
||||
"obs": f_label, "r": r,
|
||||
"m80": m80, "N": Nv,
|
||||
"sigma_top3": [float(sig[i])
|
||||
for i in range(min(3, len(sig)))],
|
||||
"special_mechanism": is_special,
|
||||
}
|
||||
if f_mode == "fy":
|
||||
print(f" {key}: m80={m80}, "
|
||||
f"sigma1={float(sig[0]):.4f}", flush=True)
|
||||
|
||||
# --- Action-CCD (illusion only) ---
|
||||
act = d.get("actions")
|
||||
if act is not None:
|
||||
y_a = act.T # (3, N)
|
||||
W, sig, Rmat, z, No, Nv = compute_reduced_ccd(
|
||||
a_r[:, :N], y_a[:, :N], Q_delay=CCD_Q)
|
||||
en = cumulative_energy(sig)
|
||||
m80 = int(np.searchsorted(en, 0.80) + 1) if len(en) > 0 else 0
|
||||
|
||||
key = f"{diam}L_{name}_action_r{r}"
|
||||
W_dict[key] = W
|
||||
all_results[key] = {
|
||||
"diam": diam, "case": name,
|
||||
"obs": "action", "r": r,
|
||||
"m80": m80, "N": Nv,
|
||||
"sigma_top3": [float(sig[i])
|
||||
for i in range(min(3, len(sig)))],
|
||||
"special_mechanism": is_special,
|
||||
}
|
||||
print(f" {key}: m80={m80}, "
|
||||
f"sigma1={float(sig[0]):.4f}", flush=True)
|
||||
|
||||
# -- Modal overlaps (r=6, force_fy primary) --
|
||||
print(f"\n Modal overlap (r=6, force_fy):", flush=True)
|
||||
ov_list = compute_modal_overlap(W_dict, diam, 6, "force_fy")
|
||||
for ov in ov_list:
|
||||
print(f" O({ov['case_a']}, {ov['case_b']}) "
|
||||
f"mode{ov['mode']} = {ov['O']:.4f}", flush=True)
|
||||
|
||||
# -- Reconstruction quality (POD basis check) --
|
||||
# Project target fields back onto its own POD basis and check residual
|
||||
q_rec = modes[:, :r] @ coeffs[:r, :] + mean_f[:, None]
|
||||
res = Q_tgt.astype(np.float64) - q_rec
|
||||
r2 = 1.0 - np.sum(res ** 2) / np.sum(Q_tgt.astype(np.float64) ** 2)
|
||||
print(f" Target self-reconstruction R2 (r={r}): {r2:.4f}", flush=True)
|
||||
|
||||
# -- Cross-diameter comparison (0.75L illusion in 1.0L basis) --
|
||||
print("\n--- Cross-diameter: 0.75L -> 1.0L basis ---", flush=True)
|
||||
d10_cache = data_cache.get("target_cylinder_1.0L")
|
||||
d075_i = data_cache.get("illusion_0.75L")
|
||||
if d10_cache is not None and d075_i is not None:
|
||||
Q_10 = build_field_matrix(d10_cache["ux"], d10_cache["uy"])
|
||||
mf_10 = np.mean(Q_10, axis=1)
|
||||
U_10, _, _ = np.linalg.svd(Q_10 - mf_10[:, None], full_matrices=False)
|
||||
modes_10_6 = U_10[:, :6]
|
||||
|
||||
# Project 0.75L illusion
|
||||
a_075 = project_into_basis(d075_i["ux"], d075_i["uy"],
|
||||
modes_10_6, mf_10)[:6, :]
|
||||
frc_075 = d075_i.get("forces")
|
||||
if frc_075 is not None:
|
||||
y_f = make_force_obs(frc_075, "illusion_0.75L", mode="fy")
|
||||
W_cross, _, _, _, _, _ = compute_reduced_ccd(a_075, y_f, Q_delay=CCD_Q)
|
||||
|
||||
# Compare with 1.0L illusion in its own basis
|
||||
d10_i = data_cache.get("illusion_1.0L")
|
||||
if d10_i is not None:
|
||||
a_10 = project_into_basis(d10_i["ux"], d10_i["uy"],
|
||||
modes_10_6, mf_10)[:6, :]
|
||||
frc_10 = d10_i.get("forces")
|
||||
if frc_10 is not None:
|
||||
y_f10 = make_force_obs(frc_10, "illusion_1.0L", mode="fy")
|
||||
W_10, _, _, _, _, _ = compute_reduced_ccd(a_10, y_f10, Q_delay=CCD_Q)
|
||||
n = min(W_cross.shape[1], W_10.shape[1], 5)
|
||||
for k in range(n):
|
||||
ov = float(abs(
|
||||
W_cross[:, k] / (np.linalg.norm(W_cross[:, k]) + 1e-12) @
|
||||
W_10[:, k] / (np.linalg.norm(W_10[:, k]) + 1e-12)
|
||||
))
|
||||
print(f" Cross-diam O(0.75L->1.0L) mode{k + 1} = {ov:.4f}",
|
||||
flush=True)
|
||||
|
||||
# -- Save --
|
||||
with open(os.path.join(out_dir, "ccd_results.json"), "w") as f:
|
||||
json.dump(all_results, f, indent=2)
|
||||
print(f"\nSaved to {out_dir}/ccd_results.json", flush=True)
|
||||
print(f"Total entries: {len(all_results)}", flush=True)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,270 +0,0 @@
|
||||
"""LOCO and blocked-split validation for CCD (Round 5).
|
||||
|
||||
Reuses shared data loader from resampling.py.
|
||||
Target-only POD basis. Q_delay=6 for force/action.
|
||||
|
||||
0.75L and 1.0L only. 1.5L excluded from validation.
|
||||
|
||||
Usage:
|
||||
conda run -n pycuda_3_10 python ccd/validate.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_reduced_ccd, cumulative_energy,
|
||||
load_aligned_fields, make_force_obs,
|
||||
build_field_matrix, project_into_basis,
|
||||
)
|
||||
|
||||
R_LIST = [6, 8, 10]
|
||||
CCD_Q = 6
|
||||
N_CYCLES = 4
|
||||
N_PTS = 24
|
||||
DIAMETERS = [0.75, 1.0]
|
||||
|
||||
|
||||
def r2(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_observable(W, sigma, R, a_test, y_train):
|
||||
"""Reconstruct observable from CCD modes.
|
||||
|
||||
Returns dict with 'mode1' and 'm80' reconstructions.
|
||||
"""
|
||||
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
|
||||
m_obs = y_train.shape[0]
|
||||
|
||||
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
|
||||
|
||||
|
||||
def run_single_diameter(diam: float, scene_data: dict) -> dict:
|
||||
"""Run validation for one diameter. Returns results dict."""
|
||||
tgt_key = f"target_cylinder_{diam}L"
|
||||
ill_key = f"illusion_{diam}L"
|
||||
|
||||
tgt_d = scene_data[tgt_key]
|
||||
ill_d = scene_data[ill_key]
|
||||
unc_d = scene_data["pinball"]
|
||||
|
||||
# Pre-build target-only field matrices
|
||||
tgt_f = build_field_matrix(tgt_d["ux"], tgt_d["uy"])
|
||||
ill_f = build_field_matrix(ill_d["ux"], ill_d["uy"])
|
||||
unc_f = build_field_matrix(unc_d["ux"], unc_d["uy"])
|
||||
|
||||
diam_results = {}
|
||||
pod_cache = {}
|
||||
|
||||
# Pre-compute target-only POD for each fold
|
||||
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)])
|
||||
for r in R_LIST:
|
||||
Q_ref = tgt_f[:, train_idx]
|
||||
mf = np.mean(Q_ref, axis=1)
|
||||
U, _, _ = np.linalg.svd(Q_ref - mf[:, None], full_matrices=False)
|
||||
pod_cache[("loco", fold, r)] = (mf, U[:, :r])
|
||||
|
||||
# Blocked split
|
||||
train_idx_full = list(range(0, 2 * N_PTS))
|
||||
for r in R_LIST:
|
||||
Q_ref = tgt_f[:, train_idx_full]
|
||||
mf = np.mean(Q_ref, axis=1)
|
||||
U, _, _ = np.linalg.svd(Q_ref - mf[:, None], full_matrices=False)
|
||||
pod_cache[("blocked", 0, r)] = (mf, U[:, :r])
|
||||
|
||||
# -- LOCO --
|
||||
print("\n--- LOCO (4-fold) ---", flush=True)
|
||||
loco_results = {}
|
||||
for r in R_LIST:
|
||||
for obs in ["force_fy", "force_fx", "action"]:
|
||||
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)])
|
||||
|
||||
mf, modes_r = pod_cache[("loco", fold, r)]
|
||||
|
||||
for name, d, fld in [
|
||||
(tgt_key, tgt_d, tgt_f), (ill_key, ill_d, ill_f), ("pinball", unc_d, unc_f)
|
||||
]:
|
||||
if obs == "action" and "illusion" not in name:
|
||||
continue
|
||||
if d.get("forces") is None and "force" in obs:
|
||||
continue
|
||||
|
||||
a_train = modes_r.T @ (fld[:, train_idx] - mf[:, None])
|
||||
a_test = modes_r.T @ (fld[:, test_idx] - mf[:, None])
|
||||
|
||||
if "force" in obs:
|
||||
f_mode = obs.split("_")[1] # "fy" or "fx"
|
||||
y_train = make_force_obs(d["forces"][train_idx], name, mode=f_mode)
|
||||
y_test = make_force_obs(d["forces"][test_idx], name, mode=f_mode)
|
||||
else:
|
||||
y_train = d["actions"][train_idx, :].T
|
||||
y_test = d["actions"][test_idx, :].T
|
||||
|
||||
W, sigma, Rmat, _, _, _ = compute_reduced_ccd(a_train, y_train, Q_delay=CCD_Q)
|
||||
recon = reconstruct_observable(W, sigma, Rmat, a_test, y_train)
|
||||
|
||||
ch_m1 = [r2(y_test[c], recon["mode1"][c]) for c in range(y_test.shape[0])]
|
||||
ch_m80 = [r2(y_test[c], recon["m80"][c]) for c in range(y_test.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"LOCO_{obs}_r{r}"
|
||||
loco_results[key] = {
|
||||
"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)
|
||||
|
||||
# -- Blocked split --
|
||||
print("\n--- Blocked Split (train=0-47, test=48-95) ---", flush=True)
|
||||
blocked = {}
|
||||
test_idx_full = list(range(2 * N_PTS, 4 * N_PTS))
|
||||
for r in R_LIST:
|
||||
for obs in ["force_fy", "force_fx", "action"]:
|
||||
mf, modes_r = pod_cache[("blocked", 0, r)]
|
||||
per_case_m1, per_case_m80 = {}, {}
|
||||
|
||||
for name, d, fld in [
|
||||
(tgt_key, tgt_d, tgt_f), (ill_key, ill_d, ill_f), ("pinball", unc_d, unc_f)
|
||||
]:
|
||||
if obs == "action" and "illusion" not in name:
|
||||
continue
|
||||
if d.get("forces") is None and "force" in obs:
|
||||
continue
|
||||
|
||||
a_train = modes_r.T @ (fld[:, train_idx_full] - mf[:, None])
|
||||
a_test = modes_r.T @ (fld[:, test_idx_full] - mf[:, None])
|
||||
|
||||
if "force" in obs:
|
||||
f_mode = obs.split("_")[1]
|
||||
y_train = make_force_obs(d["forces"][train_idx_full], name, mode=f_mode)
|
||||
y_test = make_force_obs(d["forces"][test_idx_full], name, mode=f_mode)
|
||||
else:
|
||||
y_train = d["actions"][train_idx_full, :].T
|
||||
y_test = d["actions"][test_idx_full, :].T
|
||||
|
||||
W, sigma, Rmat, _, _, _ = compute_reduced_ccd(a_train, y_train, Q_delay=CCD_Q)
|
||||
recon = reconstruct_observable(W, sigma, Rmat, a_test, y_train)
|
||||
|
||||
ch_m1 = [r2(y_test[c], recon["mode1"][c]) for c in range(y_test.shape[0])]
|
||||
ch_m80 = [r2(y_test[c], recon["m80"][c]) for c in range(y_test.shape[0])]
|
||||
per_case_m1[name] = float(np.mean(ch_m1))
|
||||
per_case_m80[name] = float(np.mean(ch_m80))
|
||||
|
||||
key = f"blocked_{obs}_r{r}"
|
||||
blocked[key] = {
|
||||
"mode1": {
|
||||
"mean": float(np.mean(list(per_case_m1.values()))),
|
||||
"per_case": per_case_m1,
|
||||
},
|
||||
"m80": {
|
||||
"mean": float(np.mean(list(per_case_m80.values()))),
|
||||
"per_case": per_case_m80,
|
||||
},
|
||||
}
|
||||
print(f" {key}: R2_m1={blocked[key]['mode1']['mean']:.4f} "
|
||||
f"R2_m80={blocked[key]['m80']['mean']:.4f}", flush=True)
|
||||
|
||||
diam_results["LOCO"] = loco_results
|
||||
diam_results["blocked_split"] = blocked
|
||||
return diam_results
|
||||
|
||||
|
||||
def run():
|
||||
print("=" * 60, flush=True)
|
||||
print("CCD Validation (Round 5)", flush=True)
|
||||
print("=" * 60, flush=True)
|
||||
|
||||
t_start = time.time()
|
||||
all_results = {}
|
||||
|
||||
for diam in DIAMETERS:
|
||||
tgt_key = f"target_cylinder_{diam}L"
|
||||
ill_key = f"illusion_{diam}L"
|
||||
print(f"\n{'=' * 60}", flush=True)
|
||||
print(f"Diameter {diam}L", flush=True)
|
||||
print(f"{'=' * 60}", flush=True)
|
||||
|
||||
t0 = time.time()
|
||||
scene_data = {
|
||||
tgt_key: load_aligned_fields(tgt_key),
|
||||
ill_key: load_aligned_fields(ill_key),
|
||||
"pinball": load_aligned_fields("pinball"),
|
||||
}
|
||||
print(f" Data loaded in {time.time() - t0:.0f}s", flush=True)
|
||||
|
||||
t1 = time.time()
|
||||
all_results[f"{diam}L"] = run_single_diameter(diam, scene_data)
|
||||
print(f" Analysis done in {time.time() - t1:.0f}s", flush=True)
|
||||
|
||||
out_dir = os.path.join(DATA_DIR, "ccd")
|
||||
with open(os.path.join(out_dir, "validation_results.json"), "w") as f:
|
||||
json.dump(all_results, f, indent=2)
|
||||
|
||||
print(f"\nTotal: {time.time() - t_start:.0f}s", flush=True)
|
||||
print(f"Saved to {out_dir}/validation_results.json", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
@@ -1,509 +0,0 @@
|
||||
# CCD Analysis Knowledge Base — 论文知识库 (2026-06-25)
|
||||
|
||||
> 统一文档。整合了 Round 5-6 的完整分析结果、correction-field 框架、Vortex/Cloak/Illusion 对比、几何对齐方案、以及所有操作流程。写论文时以本文档为准。
|
||||
|
||||
---
|
||||
|
||||
## 1. 项目概览
|
||||
|
||||
### 1.1 物理系统
|
||||
|
||||
**Fluidic Pinball**: 三个等间距圆柱(直径 D=20 格点,中心间距 0.75D)在二维通道内呈倒三角排列(间距 30°)。每个圆柱可独立旋转。DRL 控制器(PPO)每 SAMPLE_INTERVAL 步输出三个转速动作 [-1,1]^3,映射到物理表面速度。
|
||||
|
||||
**场景分类**:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph Cloak [Cloak:涡街伪装]
|
||||
Steady[均匀来流 → 下游恢复均匀]
|
||||
Karman[涡街来流 → 下游维持涡街]
|
||||
Vortex[瞬态涡事件 → 涡不受畸变]
|
||||
end
|
||||
subgraph Illusion [Illusion:流场欺骗]
|
||||
I075[0.75L: 伪装成更小圆柱]
|
||||
I10[1.0L: 伪装成等大圆柱]
|
||||
I15[1.5L: 伪装成更大圆柱]
|
||||
end
|
||||
```
|
||||
|
||||
**控制机制**(经 SR 和 correction-field 双重验证):
|
||||
- 后两圆柱(Top/Bottom): 恒速反向旋转(cloak 约 ±0.313),补偿 pinball 后速度亏损
|
||||
- 前端圆柱(Front): 动态调节,控制升力($\alpha_F = \Delta a_F/\Delta t - 14.952\mu C_{l,\text{tot}}$)
|
||||
|
||||
### 1.2 核心方法:Correction-field 框架
|
||||
|
||||
**基本思想**: 不直接分析受控流场 `q_ctl`,而是分析**控制施加的修正**:
|
||||
|
||||
| 符号 | 含义 | 计算方式 |
|
||||
|------|------|----------|
|
||||
| `q_in` | 均匀来流(干净通道) | 空通道采集 |
|
||||
| `q_blk` | pinball 无控制 | 原始 pinball 涡街 |
|
||||
| `q_ctl` | pinball + DRL 控制 | PPO 推理采集 |
|
||||
| `q_tar` | 目标流场 | 目标圆柱/涡街参考 |
|
||||
| `dq_blk = q_blk - q_in` | pinball 阻塞场 | pinball 对来流的畸变 |
|
||||
| `dq_ctl = q_ctl - q_blk` | **控制修正场** | 控制器在 pinball 基础上加的改变 |
|
||||
| `dq_tar = q_tar - q_blk` | **目标修正场** | 理论需达到的修正 |
|
||||
|
||||
**核心问题**: `dq_ctl` 是否等于 `dq_tar`?即控制做的"修正"是否就是理论上需要的"修正"?
|
||||
|
||||
### 1.3 关键发现
|
||||
|
||||
1. **Cloak 机制统一**:Steady/Karman/Vortex 三种场景的 dq_ctl 高度一致——补偿速度亏损(正 ux)+ 偶极子效应。控制策略相同,不论上游来流条件。
|
||||
|
||||
2. **Illusion 1.0L 与 Cloak 共享机制**:dq_ctl 结构定性一致。Illusion 本质是通过"Cloak 机制"让下游看起来像目标圆柱。
|
||||
|
||||
3. **Illusion 0.75L 效率降低**:O(dqctl,dqtar)=0.564,控制效率低于 1.0L
|
||||
|
||||
4. **Illusion 1.5L 特殊机制**:O(dqctl,dqtar)=0.667,action sigma1=0.28(其他 1.13-1.39),强相位漂移,修正集中在近体区
|
||||
|
||||
5. **Force/Signature 分离**:力相关结构和传感器相关结构在瞬时 tau=0 时分离(O=0.01-0.55),对流延迟后共享(O=0.72-0.81)
|
||||
|
||||
---
|
||||
|
||||
## 2. 方法细节
|
||||
|
||||
### 2.1 物理参数
|
||||
|
||||
| 参数 | 值 | 说明 |
|
||||
|------|-----|------|
|
||||
| NX, NY | 1280, 512 | LBM 格点数 |
|
||||
| L0 | 20 | 基准长度单位(1 圆柱直径) |
|
||||
| U0 | 0.01 | 入口中心流速(抛物线分布) |
|
||||
| nu | 0.004 | 默认运动粘度 |
|
||||
| Re_code | 100 | 参考长度 2×D_cyl → Re_D=50 |
|
||||
| D_cyl | L0=20 | 单一圆柱直径 |
|
||||
| CENTER_Y | 255.5 | 通道中心 y 坐标 |
|
||||
|
||||
**Reynolds 数约定**:
|
||||
- `re_code` 使用参考长度 D_REF = 2*D = 40(匹配模型文件命名)
|
||||
- `Re_D = re_code / 2` 是真实物理雷诺数(使用单圆柱直径)
|
||||
- 默认场景 `re_code=100` → `Re_D=50`
|
||||
|
||||
### 2.2 场景几何位置(2026-06-25 统一更新)
|
||||
|
||||
**所有场景已统一几何**:采集端直接在 `configs.py` 中设置统一坐标,无需后处理平移。
|
||||
|
||||
| 组 | 场景 | pinball 中心 | sensor x | 来源 configs |
|
||||
|----|------|-------------|--------|-------------|
|
||||
| **所有场景** | pinball, steady, karman, vortex, illusion_*, target_cylinder_* | **613 px** | **800 px** | configs.py UNIFIED 注释 |
|
||||
|
||||
- pinball: front x=30×L0=600, rear x=31.3×L0=626 → 中心 ≈ 613 px
|
||||
- target cylinder: x=30.65×L0=613 px
|
||||
- sensors: x=40×L0=800 px
|
||||
|
||||
### 2.3 几何对齐方案(已废弃,仅保留备用)
|
||||
|
||||
统一几何后不再需要后处理平移。`utils/field_translate.py` 保留作为可选工具,但不参与默认 pipeline。
|
||||
|
||||
<details>
|
||||
<summary>旧方案(参考)</summary>
|
||||
|
||||
```python
|
||||
SHIFT_ILLUSION_TO_CLOAK = +220 px # 旧 illusion → cloak
|
||||
SHIFT_CLOAK_TO_ILLUSION = -220 px
|
||||
```
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 3. 数据采集
|
||||
|
||||
### 3.1 GPU 采集脚本
|
||||
|
||||
| 脚本 | 功能 | 输出目录 |
|
||||
|------|------|----------|
|
||||
| `scripts/collect_target_cylinder.py` | 目标圆柱(单圆柱涡街) | `data/target_cylinder/{diam}L/` |
|
||||
| `scripts/collect_pinball.py` | pinball 无控制基线 | `data/pinball/pinball/` |
|
||||
| `scripts/collect_empty_channel.py` | 空通道 | `data/target_channel/target_channel/` |
|
||||
| `scripts/collect_illusion.py` | Illusion PPO 推理 | `data/illusion/{scene}/` |
|
||||
| `scripts/collect_karman.py` | Karman cloak PPO 推理 | `data/karman/karman_re100/` |
|
||||
| `scripts/collect_karman_q_in.py` | 涡街无 pinball(Karman 参考) | `data/karman_target/karman_q_in/` |
|
||||
| `scripts/collect_karman_q_blk.py` | 涡街+pinball 无控制(Karman 参考) | `data/karman_blocked/karman_q_blk/` |
|
||||
| `scripts/collect_steady_cloak.py` | 稳态 cloak(开环恒速) | `data/steady_cloak/steady_cloak/` |
|
||||
| `scripts/collect_vortex.py` | Vortex cloak(Taylor/Lamb) | `data/vortex_{type}/` |
|
||||
|
||||
### 3.2 采集流程(以 Karman 为例)
|
||||
|
||||
```
|
||||
Phase 1: Target recording
|
||||
FlowField → add_sensor(40*L0) ×3 → stabilize → add_vortex/cylinder
|
||||
→ run(SI, zero) × F来O_LEN → save target.npz
|
||||
|
||||
Phase 2: Add pinball + Norm
|
||||
restore → add_cylinder(pinball) ×3 → stabilize
|
||||
→ run(SI, zero) × FIFO_LEN → compute norm
|
||||
→ run(SI, bias_action) × FIFO_LEN → save ddf+fifo checkpoint
|
||||
|
||||
Phase 3: Controlled PPO inference
|
||||
restore + warmup → for step in range(n_steps):
|
||||
model.predict(obs) → action → run(SI, action_arr) → save telemetry + field
|
||||
```
|
||||
|
||||
### 3.3 Norm 计算
|
||||
|
||||
```
|
||||
force_norm_fact = 6 × max|forces|
|
||||
sens_deviation = mean(sensors, axis=0)
|
||||
sens_norm_fact[i] = 5 × max|sensors[:,i] - sens_deviation[i]|
|
||||
obs = clip[forces/force_norm, (sens - deviation)/sens_norm], to [-1, 1]
|
||||
```
|
||||
|
||||
**注意**:Norm 是在 scene-specific 采集时计算的,不同场景的 norm 值不同。推理时必须使用对应场景的 norm。
|
||||
|
||||
### 3.4 Phase Alignment 流程
|
||||
|
||||
```
|
||||
detect_period.py:
|
||||
sensors[:, 3] → FFT → dominant frequency + period
|
||||
zero-crossing detection → cycle boundaries (CV_T)
|
||||
select best 4-cycle window → map 4×24=96 uniform phase points
|
||||
→ save phase_plan.json
|
||||
|
||||
replay_fields.py:
|
||||
load phase_plan + ddf_checkpoint + actions
|
||||
replay PPO → at each phase_plan step_index → save velocity field
|
||||
→ save fields_aligned.npz + replay_verify.json
|
||||
```
|
||||
|
||||
### 3.5 数据状态
|
||||
|
||||
| 场景 | scene_id | 帧数 | 格式 |
|
||||
|------|----------|------|------|
|
||||
| pinball | pinball | 96 | fields_aligned.npz |
|
||||
| target_cylinder_{0.75,1.0,1.5}L | target_cylinder | 96 | fields_aligned.npz |
|
||||
| illusion_{0.75,1.0,1.5}L | illusion | 96 | fields_aligned.npz |
|
||||
| karman_re100 | karman | 72 (3周期) | fields_aligned.npz |
|
||||
| karman_q_in | karman_target | 96 | fields_aligned.npz |
|
||||
| karman_q_blk | karman_blocked | 96 | fields_aligned.npz |
|
||||
| steady_cloak | steady_cloak | 500 | fields.npz (旧格式) |
|
||||
| target_channel | target_channel | 100 | fields.npz (旧格式) |
|
||||
| vortex_{lamb/taylor}/target/uncontrolled | 各自 scene_id | 150 | fields.npz (瞬态) |
|
||||
|
||||
---
|
||||
|
||||
## 4. CCD 方法
|
||||
|
||||
### 4.1 算法原理(Lyu23)
|
||||
|
||||
CCD(Canonical Correlation Decomposition)通过 CCA 在流场和可观测量之间找到相关性最大的方向:
|
||||
|
||||
```python
|
||||
# Reduced CCD in POD coefficient space
|
||||
def compute_reduced_ccd(pod_coeffs, observable, Q_delay=6):
|
||||
# 1. Construct lagged observable matrix P (with symmetric delay window)
|
||||
# 2. Standardize P and A (POD coefficients)
|
||||
# 3. Cross-correlation matrix: C = P @ A^T / (N * sqrt(Q))
|
||||
# 4. SVD(C) → R, sigma, W
|
||||
# W = CCD directions in POD space
|
||||
# sigma = correlation strength (sorted descending)
|
||||
# z = CCD temporal coefficients
|
||||
```
|
||||
|
||||
**物理意义**:
|
||||
- sigma[0] = 第一 CCD 模式的相关系数
|
||||
- m80 = 需要多少模式来捕获 80% 的总相关性(compactness 指标)
|
||||
- O_k = 两个 case 之间第 k 模的重叠(内积绝对值)
|
||||
|
||||
### 4.2 三条分析线
|
||||
|
||||
| 线 | Observable | 问题 | 物理意义 |
|
||||
|----|-----------|------|----------|
|
||||
| **Force** (主) | SigmaFy = sum(Fy_i) | 哪些修正结构决定升力? | 力相关流场结构 |
|
||||
| **Force** (次) | SigmaFx | 哪些修正结构决定阻力? | 不可靠(R2~0.4) |
|
||||
| **Action** | [omega1, omega2, omega3] | 控制器直接调制哪些结构? | 动作相关流场结构 |
|
||||
| **Signature** | e(t+tau) = s_ctl(t+tau) - s_tar(t+tau) | 哪些结构决定未来传感器误差? | 传感器相关流场结构 |
|
||||
|
||||
### 4.3 验证方法
|
||||
|
||||
**LOCO (Leave-One-Cycle-Out)**:
|
||||
- 4 个涡街周期,留 1 做测试,3 做训练
|
||||
- 训练 CCD → 预测可观测量 → 计算 R2
|
||||
- 通过阈值:R2_m80 > 0.4
|
||||
|
||||
| Observable | 0.75L R2_m80 | 1.0L R2_m80 | 结论 |
|
||||
|-----------|-------------|-------------|------|
|
||||
| force_fy | 0.65 ± 0.08 | 0.64 ± 0.02 | PASS |
|
||||
| force_fx | 0.38 ± 0.23 | 0.43 ± 0.11 | WARNING |
|
||||
| signature tau=0 | 0.50 ± 0.09 | 0.49 ± 0.04 | PASS |
|
||||
| signature tau=tau_c | 0.51 ± 0.09 | 0.53 ± 0.03 | PASS |
|
||||
|
||||
### 4.4 Zone-Restricted CCD
|
||||
|
||||
统一几何后所有场景共用一套 zone 定义(定义见 `diagnose_corrections.py` 的 `define_zones_karman()`):
|
||||
|
||||
| 区域 | x 范围 (像素) | 包含 |
|
||||
|------|-------------|------|
|
||||
| near_body | 580-720 | pinball 周围 |
|
||||
| body_wake | 720-850 | 近尾流 |
|
||||
| sensor_zone | 780-850 | 传感器区域 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 结果
|
||||
|
||||
### 5.1 Correction-field CCD 主表(2026-06-28 更新,统一几何后重跑)
|
||||
|
||||
| 指标 | 0.75L | 1.0L | 1.5L |
|
||||
|------|-------|------|------|
|
||||
| **O(dqctl, dqtar) mode1 (r=6)** | **0.383** | **0.926** | **0.922** |
|
||||
| **O(dqctl, dqtar) mode1 (r=10)** | **0.320** | **0.684** | **0.661** |
|
||||
| force_fy m80 (r=6) | 2 | 2 | **1** |
|
||||
| action sigma1 (r=6) | 1.49 | 1.17 | **0.20** |
|
||||
| Phase drift | low | low | **high** |
|
||||
| 1.5L special: rank sensitivity | — | — | O drops 0.922→0.661 (r=6→10) |
|
||||
|
||||
**关键变化**(vs 6月15日旧版,Illusion 旧几何 pinball x≈393px):
|
||||
- 0.75L O 从 0.564 → **0.383**(-32%):旧几何的空间错位虚高了 overlap。统一几何后揭示真实匹配度远低于预期。
|
||||
- 1.0L O 从 0.913 → **0.926**(+1.4%):基本不变,1.0L 的控制修正与目标一致。
|
||||
- 1.5L O(r=6)=**0.922**(首次获得):dominant mode 匹配度高,但更高阶 mode 快速发散(r=10 时降至 0.661),反映多尺度控制策略。
|
||||
- 1.5L action sigma1=**0.20**(远低于 0.75L 的 1.49 和 1.0L 的 1.17):确认高频调制机制下动作与流场结构的映射极其分散。
|
||||
|
||||
以下 force-sig overlap 和 zone 数据来自 6 月 15 日旧版(待用统一几何和新 zone 定义重跑):
|
||||
|
||||
### 5.2 三区域 Force-Signature Overlap
|
||||
|
||||
**0.75L** — sensor_zone 在 tau=0 时近乎正交:
|
||||
|
||||
| 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** |
|
||||
|
||||
### 5.3 Cloak 全景对比(Steady / Karman / Vortex dq_ctl)
|
||||
|
||||
所有 cloak 场景的 dq_ctl 展现了**一致的物理机制**:
|
||||
1. **速度亏损补偿**:pinball 后方正 ux(红色),控制加速尾流
|
||||
2. **偶极子效应**:圆柱附近旋转产生的偶极子模式
|
||||
3. **涡量结构**:三种场景涡量分布高度相似
|
||||
|
||||
量化指标:
|
||||
|
||||
| 场景 | dq_ctl RMS (crop) | 性质 |
|
||||
|------|------------------|------|
|
||||
| steady_cloak | 0.196 | 稳态,开环 |
|
||||
| karman_re100 | 0.397 | 周期,PPO 闭环 |
|
||||
| vortex_lamb | 0.164 | 瞬态,PPO 闭环(fade-in/out + swapped norm) |
|
||||
| vortex_taylor | 0.203 | 瞬态,PPO 闭环(fade-in/out + swapped norm) |
|
||||
|
||||
**结论**:不论上游条件如何(稳态/周期涡街/瞬态涡),控制策略的基本物理机制一致——"通过后两圆柱旋转补偿 pinball 阻塞引起的速度亏损,前端圆柱调节升力"。这与 SR 分析的结论完全吻合。
|
||||
|
||||
### 5.4 Steady Cloak 定量化
|
||||
|
||||
| 指标 | 值 |
|
||||
|------|-----|
|
||||
| 全局波动抑制 | ≈0% |
|
||||
| 残留/阻塞比 | 81% |
|
||||
| 传感器区残留 | 13% |
|
||||
|
||||
**结论**:开环恒速旋转几乎无法抑制波动。需要闭环 DRL 控制。
|
||||
|
||||
### 5.5 Action-CCD Mode 1
|
||||
|
||||
Action-CCD 找出了控制器直接调制的主要结构。对 Cloak 场景,action-CCD mode 1 ≈ dq_ctl mean field,确认了"控制调制的结构 = correction-field 的主成分"的直觉。
|
||||
|
||||
---
|
||||
|
||||
## 6. Correction-field 诊断图
|
||||
|
||||
### 6.1 图例说明
|
||||
|
||||
所有位于 `data/figures/` 下(无 colorbar,干净布局,裁剪到 x=300-1100):
|
||||
|
||||
| 图 | 内容 |
|
||||
|----|------|
|
||||
| `corr_comparison_all_scenes.png` | 7 场景全景(4 cloak + 3 illusion),4 行 × 7 列 |
|
||||
| `corr_cloak_comparison_dqctl.png` | Cloak 四场景 dq_ctl 对比 |
|
||||
| `corr_illusion_comparison_dqctl.png` | Illusion 三直径 dq_ctl 对比 |
|
||||
| `corr_{scene}_ctl_vs_tar.png` | 单场景 dq_ctl vs dq_tar 对比(2×2) |
|
||||
| `steady_cloak_cancel_test.png` | Steady cloak 抵消检验 |
|
||||
|
||||
### 6.2 全景对比图的读法
|
||||
|
||||
每行 = 一个物理量(ux_mean / uy_mean / RMS / vorticity)
|
||||
每列 = 一个场景
|
||||
颜色统一(同一行在所有列间共享 vmax)
|
||||
|
||||
**核心观察**:
|
||||
- cloak 四列间 dq_ctl 高度一致 → 控制策略不依赖上游条件
|
||||
- illusion_1.0L 与 cloak 定性一致 → 1.0L illusion=cloak 机制
|
||||
- illusion_0.75L 偏弱但定性相似 → 控制效率降低
|
||||
- illusion_1.5L 结构突变 → 完全不同的策略
|
||||
|
||||
---
|
||||
|
||||
## 7. 操作流程
|
||||
|
||||
### 7.1 环境
|
||||
|
||||
```bash
|
||||
conda run -n pycuda_3_10
|
||||
# Python 3.10+, numpy, matplotlib, LegacyCelerisLab, stable-baselines3
|
||||
# CUDA device 2 (采集用)
|
||||
```
|
||||
|
||||
### 7.2 快速重新生成所有图
|
||||
|
||||
```bash
|
||||
# 1. Correction-field pipeline(CPU)
|
||||
conda run -n pycuda_3_10 python3 correction_analysis/diagnose_corrections.py
|
||||
|
||||
# 2. 对比图(CPU)
|
||||
conda run -n pycuda_3_10 python3 correction_analysis/compare_dqctl_scenes.py
|
||||
```
|
||||
|
||||
### 7.3 从零开始完整流程
|
||||
|
||||
```bash
|
||||
# Step 1: GPU 数据采集(每次 > ~4min)
|
||||
# (已有数据则可跳过)
|
||||
|
||||
# Step 2: Phase alignment(CPU)
|
||||
python3 scripts/detect_period.py --scene {scene_name}
|
||||
|
||||
# Step 3: Field replay(GPU)
|
||||
conda run -n pycuda_3_10 python3 scripts/replay_fields.py --scene {scene_name} --device 2
|
||||
|
||||
# Step 4: Correction-field 分析(CPU)
|
||||
conda run -n pycuda_3_10 python3 correction_analysis/compute_correction_fields.py
|
||||
conda run -n pycuda_3_10 python3 correction_analysis/diagnose_corrections.py
|
||||
|
||||
# Step 5: 对比图(CPU)
|
||||
conda run -n pycuda_3_10 python3 correction_analysis/compare_dqctl_scenes.py
|
||||
```
|
||||
|
||||
### 7.4 添加新场景
|
||||
|
||||
1. 在 `configs.py` 中添加场景注册
|
||||
2. 在 `compute_correction_fields.py` 的 `_SCENE_MAP` 中添加映射
|
||||
3. 在 `_resolve_source()` 中添加加载器分发
|
||||
4. 编写 GPU 采集脚本(参考 `collect_vortex.py`)
|
||||
5. 在 `diagnose_corrections.py` 的 `SCENE_TYPES` 中添加场景
|
||||
6. 运行采集 → detect_period → replay_fields → diagnose_corrections
|
||||
|
||||
---
|
||||
|
||||
## 8. 代码结构
|
||||
|
||||
```
|
||||
src/CCD_analysis/
|
||||
ccd_knowledge.md ← 本文档(唯一知识库)
|
||||
configs.py ← 场景元数据(统一几何)
|
||||
README.md ← 快速入口
|
||||
Lyu23.md ← CCD 方法文献
|
||||
ccd/ ← Round 5 冻结基线(勿改)
|
||||
utils/
|
||||
resampling.py ← POD, CCD, 场加载
|
||||
field_translate.py ← 场平移(备用,不参与默认 pipeline)
|
||||
load_vortex_fields.py ← 瞬态 vortex 场加载
|
||||
cfd_interface.py ← LegacyCelerisLab 封装 (GPU)
|
||||
scripts/
|
||||
detect_period.py ← 周期检测 → phase_plan.json
|
||||
replay_fields.py ← 场回放 → fields_aligned.npz
|
||||
collect_*.py ← GPU 数据采集
|
||||
correction_analysis/
|
||||
compute_correction_fields.py ← correction-field 计算
|
||||
diagnose_corrections.py ← 诊断图生成
|
||||
compare_dqctl_scenes.py ← 多场景对比图
|
||||
decompose_corrections.py ← CCD 定量分解(POD + force/action CCD)
|
||||
run_signature_line.py ← signature CCD
|
||||
run_15L_correction.py ← 1.5L 专项分析
|
||||
run_zone_ccd.py ← zone-restricted CCD
|
||||
run_steady_metrics.py ← steady cloak 定量度量
|
||||
visualize_action_ccd.py ← action-CCD mode1 可视化
|
||||
process_legacy_steady.py ← 旧格式加载
|
||||
data/
|
||||
{scene_id}/{scene_name}/ ← 各场景数据
|
||||
resampled/ ← phase_plan.json
|
||||
ccd/ ← JSON 结果文件
|
||||
figures/ ← PNG 诊断图
|
||||
old_data/ ← 归档(旧报告、旧脚本、旧 resampled 数据)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 硬规则
|
||||
|
||||
1. **Illusion 只用 o14 模型**(S_DIM=14,`_2U` 系列)。不使用 o12 模型。
|
||||
2. **`_2U` 表示 S_DIM=14**(2 个额外的目标力通道),不是 2× 速度。U0 始终是 0.01。
|
||||
3. **action_bias ≠ preset_action**。bias 是 DRL action scaling(illusions=[0,-2,2]),preset 是 FIFO warmup 用的动作数组。
|
||||
4. **默认物理参数**:u0=0.01, nu=0.004(所有模型一致)。
|
||||
5. **主分析对象 = dq_ctl**(不是 q_ctl)。
|
||||
6. **Steady cloak 不要使用 phase-based CCD**——它是稳态问题。
|
||||
7. **Karman 的物理问题不同**(incoming-street preservation vs target generation),不要硬套 illusion 模板。
|
||||
8. **所有场景已在采集时统一几何**(pinball 中心 613 px,sensor x=800 px);`field_translate.py` 仅作备用工具,不参与默认 pipeline。
|
||||
9. **旧数据格式**(`fields.npz`):(N, NX, NY);**新对齐格式**(`fields_aligned.npz`):(N, NX, NY);**加载后统一转 (N, NY, NX)**。
|
||||
|
||||
---
|
||||
|
||||
## 10. 不能写进论文的结论(内部参考,勿引用)
|
||||
|
||||
以下陈述看似合理但没有被充分证据支持,不得写进正式论文:
|
||||
- "Illusion 已证明使用完全不同于 target 的物理机制。"
|
||||
- "低 overlap 已足以证明 force 通道与 target 正交。"
|
||||
- "CCD 已经直接识别了壁面涡量生成机制。"
|
||||
- "far wake 模态就是瞬时力的主载体。"
|
||||
|
||||
---
|
||||
|
||||
## 11. 未完成工作(2026-06-28 更新)
|
||||
|
||||
| 方向 | 状态 | 说明 |
|
||||
|------|------|------|
|
||||
| Karman cloak CCD 分析 | 数据已齐,分析延后 | 问题定义不同(distortion compensation) |
|
||||
| Vortex 数据采集 Bug 修复 | 已完成 | 修复 cylinder order swap(BOTTOM=id4, TOP=id5)+ FIFO warmup 导致涡量消失;重采集 Taylor/Lamb |
|
||||
| SR-CCD-OID 映射 | 草稿 | 归档 `data/old_data/sr_ccd_oid_mapping.md`,需最终校正 |
|
||||
| 统一几何后 CCD 重跑 | 已完成 | `correction_ccd_results.json` 已更新(含 1.5L) |
|
||||
| Vortex 对比图更新 | 已完成 | `compare_dqctl_scenes.py` 已用修正数据重跑 |
|
||||
| 项目目录清理 + 文档更新 | 已完成 | 移除废弃目录、SI=200 错误数据、旧诊断图 |
|
||||
|
||||
---
|
||||
|
||||
## 12. Vortex 采集 Bug 排查经验(2026-06-29)
|
||||
|
||||
`collect_vortex.py` 中发现了四个独立 bug,按发现顺序:
|
||||
|
||||
**Bug 1 — 圆柱顺序对调**
|
||||
- 训练 env `legacy_env_vortex.py` 添加顺序:front(id3) → TOP(+y, id4) → BOTTOM(-y, id5)
|
||||
- action 映射:`temp[3]=front, temp[4]=TOP(bias=-4), temp[5]=BOTTOM(bias=+4)`
|
||||
- 脚本先后把 TOP/BOTTOM 添加顺序写反 → 后两圆柱旋转方向全错
|
||||
- 症状:Lamb front 剧烈振荡 (std=0.28),dipole 对称性完全破坏
|
||||
|
||||
**Bug 2 — 涡量消失**
|
||||
- FIFO warmup 在涡加入后跑 150×800=120000 步 → 涡从 x=15 漂出域外 (1280 lu)
|
||||
- 症状:涡量场只有 pinball 尾流,完全看不到 vortex 结构
|
||||
|
||||
**Bug 3(核心)— 观测值归一化顺序错误**
|
||||
- 训练 env 产出:`obs = [forces/force_norm, sensors/sens_norm]`(force 先)
|
||||
- 脚本将 channel 和 norm 互换:`[sensors/force_norm, forces/sens_norm]`
|
||||
- 模型收到不匹配分布的反馈
|
||||
- 症状:Lamb cross-corr 仅 0.73, Taylor 仅 0.50;后圆柱同向转而非反向
|
||||
|
||||
**Bug 4 — fade-in/out 缺失**
|
||||
- `uni_test.ipynb` 有 25 步渐入 + 25 步渐出到 steady-cloak bias
|
||||
- 脚本直接给全量 PPO action,无过渡
|
||||
|
||||
**最终修正方案**:
|
||||
1. Cylinder order 匹配训练 env
|
||||
2. Bias 使用 uni_test 值:[-5, +5](FIFO),[-5.1, +5.1](fade target)
|
||||
3. Obs 归一化:`forces_norm = obs[6:12]/force_norm`,`sens_norm = (obs[0:6]-sens_dev)/sens_norm`,`hstack([forces_norm, sens_norm])`
|
||||
4. 25-step fade-in / 25-step fade-out
|
||||
|
||||
**修正后验证**:
|
||||
|
||||
| 场景 | sim | cross-corr | active front mean | dq_ctl RMS |
|
||||
|------|:---:|:----------:|:-----------------:|:----------:|
|
||||
| vortex_lamb | 0.946 | 0.974 | 0.007 | 0.146 |
|
||||
| vortex_taylor | 0.923 | 0.953 | -0.030 | 0.188 |
|
||||
|
||||
**其他脚本审计**:`collect_karman.py` 和 `collect_illusion.py` 使用 `build_observation()` 函数,该函数正确实现了 force-first 归一化,无需修改。`collect_vortex.py` 是唯一手动构建 obs 的脚本。
|
||||
|
||||
**代码注释**:`collect_vortex.py` 的文件头 docstring 和关键行号均有 BUG HISTORY 和 BUG-FIX 标记。
|
||||
@@ -1,291 +0,0 @@
|
||||
"""Unified scene configuration for CCD_analysis.
|
||||
|
||||
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.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
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")
|
||||
LEGACY_CFG_DIR = os.path.join(os.path.dirname(__file__), "configs")
|
||||
DATA_DIR = os.path.join(os.path.dirname(__file__), "data")
|
||||
|
||||
# -- Physics constants -------------------------------------------------------
|
||||
U0 = 0.01 # standard inlet center velocity (all models use this)
|
||||
# NOTE: "2U" in model name means S_DIM=14 (2 extra target force obs), NOT u0 scaling
|
||||
D_CYL = 20.0
|
||||
D_REF = 40.0
|
||||
L0 = 20.0
|
||||
NX = 1280
|
||||
NY = 512
|
||||
CENTER_Y = (NY - 1) / 2.0
|
||||
FIFO_LEN = 150
|
||||
# CONV_LEN is per-scene. Illusion=36, Karman/Steady=30.
|
||||
# Set locally in collection scripts, not as a global here.
|
||||
|
||||
|
||||
def nu_from_re(re_code: float, u0: float = U0) -> float:
|
||||
return u0 * D_REF / re_code
|
||||
|
||||
|
||||
# -- Scene definitions -------------------------------------------------------
|
||||
SCENES: Dict[str, Any] = {}
|
||||
|
||||
# -- Pure Pinball (uncontrolled baseline) ------------------------------------
|
||||
SCENES["pinball"] = {
|
||||
"scene_id": "pinball",
|
||||
"re_code": 100,
|
||||
"has_disturbance": False,
|
||||
"sample_interval": 800,
|
||||
"source": "open_loop",
|
||||
"model_name": None,
|
||||
"n_objects_env": 6,
|
||||
"obs_slice": (0, 12),
|
||||
"sensor_x": 40.0,
|
||||
"pinball_front_x": 30.0,
|
||||
"pinball_rear_x": 31.3,
|
||||
"target_type": "periodic",
|
||||
"s_dim": 12,
|
||||
"u0": U0,
|
||||
"nu": nu_from_re(100),
|
||||
}
|
||||
|
||||
# -- Steady Cloak (open-loop constant rotation) ------------------------------
|
||||
SCENES["steady_cloak"] = {
|
||||
"scene_id": "steady_cloak",
|
||||
"re_code": 100,
|
||||
"has_disturbance": False,
|
||||
"sample_interval": 800,
|
||||
"source": "open_loop",
|
||||
"model_name": None,
|
||||
"n_objects_env": 6,
|
||||
"obs_slice": (0, 12),
|
||||
"sensor_x": 40.0,
|
||||
"pinball_front_x": 30.0,
|
||||
"pinball_rear_x": 31.3,
|
||||
"target_type": "steady",
|
||||
"s_dim": 12,
|
||||
"u0": U0,
|
||||
"nu": nu_from_re(100),
|
||||
"omega_front": 0.0,
|
||||
"omega_rear_scale": 5.1,
|
||||
}
|
||||
|
||||
# -- Karman Cloak re100 (PPO, cloak validation only) -------------------------
|
||||
SCENES["karman_re100"] = {
|
||||
"scene_id": "karman",
|
||||
"re_code": 100,
|
||||
"has_disturbance": True,
|
||||
"sample_interval": 800,
|
||||
"action_scale": 8.0,
|
||||
"action_bias": (0.0, -4.0, 4.0),
|
||||
"source": "PPO_inference",
|
||||
"model_name": "d1a3o12_re100",
|
||||
"model_subdir": "old",
|
||||
"n_objects_env": 7,
|
||||
"obs_slice": (2, 14),
|
||||
"sensor_x": 40.0,
|
||||
"pinball_front_x": 30.0,
|
||||
"pinball_rear_x": 31.3,
|
||||
"target_type": "periodic",
|
||||
"s_dim": 12,
|
||||
"u0": U0,
|
||||
"nu": nu_from_re(100),
|
||||
}
|
||||
|
||||
# -- Karman q_in (target/incoming vortex street, no pinball) ------------------
|
||||
SCENES["karman_q_in"] = {
|
||||
"scene_id": "karman_target",
|
||||
"re_code": 100,
|
||||
"has_disturbance": True,
|
||||
"sample_interval": 800,
|
||||
"source": "open_loop",
|
||||
"n_objects_env": 4,
|
||||
"obs_slice": (0, 8),
|
||||
"sensor_x": 40.0,
|
||||
"pinball_front_x": None,
|
||||
"pinball_rear_x": None,
|
||||
"target_type": "periodic",
|
||||
"s_dim": None,
|
||||
"u0": U0,
|
||||
"nu": 0.004,
|
||||
}
|
||||
|
||||
# -- Karman q_blk (pinball in vortex street, zero control) -------------------
|
||||
SCENES["karman_q_blk"] = {
|
||||
"scene_id": "karman_blocked",
|
||||
"re_code": 100,
|
||||
"has_disturbance": True,
|
||||
"sample_interval": 800,
|
||||
"source": "open_loop",
|
||||
"n_objects_env": 7,
|
||||
"obs_slice": (2, 14),
|
||||
"sensor_x": 40.0,
|
||||
"pinball_front_x": 30.0,
|
||||
"pinball_rear_x": 31.3,
|
||||
"target_type": "periodic",
|
||||
"s_dim": None,
|
||||
"u0": U0,
|
||||
"nu": 0.004,
|
||||
}
|
||||
|
||||
# -- Illusion scenes (S_DIM=14) -----------------------------------------------
|
||||
# All use u0=0.01, SAMPLE_INTERVAL per diameter, nu=0.004 confirmed
|
||||
# Sweep results: 0.004=0.962, 0.008=0.957, 0.002=0.882
|
||||
# "2U" in model name = S_DIM=14 (2 extra target force dimensions), NOT 2x velocity
|
||||
_ILLUSION_2U = [
|
||||
("illusion_0.75L", "d1a3o14_250525_imit_075L_2U_400S", 0.75, 400),
|
||||
("illusion_1.0L", "d1a3o14_250525_imit_1L_2U_600S", 1.0, 600),
|
||||
("illusion_1.5L", "d1a3o14_250525_imit_15L_2U", 1.5, 800),
|
||||
]
|
||||
for key, mn, diam, si in _ILLUSION_2U:
|
||||
SCENES[key] = {
|
||||
"scene_id": "illusion",
|
||||
"target_diameter": diam,
|
||||
"re_code": 100, # u0=0.01, nu=0.004
|
||||
"has_disturbance": False,
|
||||
"sample_interval": si,
|
||||
"conv_len": 36, # Illusion uses 36 (see legacy_env_imit.py)
|
||||
"action_scale": 8.0,
|
||||
"action_bias": (0.0, -2.0, 2.0),
|
||||
"source": "PPO_inference",
|
||||
"model_name": mn,
|
||||
"model_subdir": "250525",
|
||||
"n_objects_env": 6,
|
||||
"obs_slice": (0, 12),
|
||||
"sensor_x": 40.0, # UNIFIED: was 30.0
|
||||
"pinball_front_x": 30.0, # UNIFIED: was 19.0
|
||||
"pinball_rear_x": 31.3, # UNIFIED: was 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": 40.0, # UNIFIED: was 30.0
|
||||
"cylinder_x": 30.65, # UNIFIED: was 20.0 (pinball center)
|
||||
"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),
|
||||
}
|
||||
|
||||
# -- Vortex cloak scenes (transient, Taylor monopole & Lamb dipole) ---------
|
||||
# Taylor: monopole vortex, strength=0.03*U0, r=2*L0=40
|
||||
# Lamb: dipole vortex, strength=0.5*U0, r=2*L0=40
|
||||
# Both: MAX_STEPS=150, action_scale=4, action_bias=(0,-4,4), s_dim=12
|
||||
# Geometry: pinball at (30, 31.3)xL0, sensors at 40*xL0
|
||||
# Vortex at 10*xL0 (target) or 15*xL0 (pinball phase)
|
||||
_VORTEX_SCENES = [
|
||||
("vortex_lamb", "vortex_lamb", "lamb", 0.50),
|
||||
("vortex_taylor", "vortex_taylor", "taylor", 0.03),
|
||||
("vortex_uncontrolled_lamb", None, "lamb", 0.50),
|
||||
("vortex_uncontrolled_taylor", None, "taylor", 0.03),
|
||||
("vortex_target_lamb", None, "lamb", 0.50),
|
||||
("vortex_target_taylor", None, "taylor", 0.03),
|
||||
]
|
||||
for key, mn, vtype, vstrength in _VORTEX_SCENES:
|
||||
is_controlled = mn is not None
|
||||
SCENES[key] = {
|
||||
"scene_id": key,
|
||||
"re_code": 100,
|
||||
"has_disturbance": False,
|
||||
"sample_interval": 800,
|
||||
"vortex_type": vtype,
|
||||
"vortex_strength": vstrength,
|
||||
"conv_len": 30,
|
||||
"action_scale": 4.0 if is_controlled else None,
|
||||
"action_bias": (0.0, -4.0, 4.0) if is_controlled else None,
|
||||
"source": "PPO_inference" if is_controlled else "open_loop",
|
||||
"model_name": mn,
|
||||
"model_subdir": "old",
|
||||
"n_objects_env": 6,
|
||||
"obs_slice": (0, 12),
|
||||
"sensor_x": 40.0,
|
||||
"pinball_front_x": 30.0,
|
||||
"pinball_rear_x": 31.3,
|
||||
"target_type": "transient",
|
||||
"max_steps": 150,
|
||||
"s_dim": 12 if is_controlled else None,
|
||||
"u0": U0,
|
||||
"nu": nu_from_re(100),
|
||||
}
|
||||
|
||||
|
||||
# -- Utility helpers ---------------------------------------------------------
|
||||
|
||||
def get_scene(name: str) -> dict:
|
||||
if name not in SCENES:
|
||||
raise KeyError(f"Unknown scene: {name}. Available: {list(SCENES.keys())}")
|
||||
return dict(SCENES[name])
|
||||
|
||||
|
||||
def get_scene_list(scene_id: Optional[str] = None) -> List[str]:
|
||||
if scene_id is None:
|
||||
return list(SCENES.keys())
|
||||
return [k for k, v in SCENES.items() if v["scene_id"] == scene_id]
|
||||
|
||||
|
||||
def model_path_for_scene(scene_name: str) -> Optional[str]:
|
||||
cfg = get_scene(scene_name)
|
||||
mn = cfg.get("model_name")
|
||||
if mn is None:
|
||||
return None
|
||||
subdir = cfg.get("model_subdir", "old")
|
||||
p = os.path.join(MODEL_DIR, subdir, f"{mn}.zip")
|
||||
return p if os.path.isfile(p) else None
|
||||
|
||||
|
||||
def data_dir_for_scene(scene_name: str) -> str:
|
||||
cfg = get_scene(scene_name)
|
||||
scene_id = cfg["scene_id"]
|
||||
d = os.path.join(DATA_DIR, scene_id, scene_name)
|
||||
os.makedirs(d, exist_ok=True)
|
||||
return d
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"multi_gpu": false,
|
||||
"gpu_connection": "NVLink",
|
||||
"required_cuda_capability": "7.0",
|
||||
"threads_per_block": 128,
|
||||
"X_1U": 128,
|
||||
"Y_1U": 32,
|
||||
"Z_1U": 1
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"data_type": "FP32",
|
||||
"dimensionality": 2,
|
||||
"lattice": 9,
|
||||
"field_dim_in_U": [10, 16, 1],
|
||||
"viscosity": 0.004,
|
||||
"velocity": 0.01,
|
||||
"boundary_conditions": {
|
||||
"x": ["parabolic", "outflow"],
|
||||
"y": ["noslip", "noslip"],
|
||||
"z": ["none", "none"]
|
||||
}
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
"""Generate comparison figures across all cloak & illusion scenarios.
|
||||
|
||||
All dq_ctl fields use unified geometry (pinball center at ~613px, sensors at ~800px),
|
||||
set during GPU collection (configs.py UNIFIED coordinates).
|
||||
Figures zoom into the region around the pinball/cylinder (x=300-1100) to exclude
|
||||
boundary artifacts.
|
||||
|
||||
1. All-scenes panorama: steady_cloak, karman_re100, vortex_lamb, vortex_taylor,
|
||||
illusion_0.75L, illusion_1.0L, illusion_1.5L
|
||||
2. Illusion-only comparison: 0.75L, 1.0L, 1.5L
|
||||
|
||||
Usage:
|
||||
conda run -n pycuda_3_10 python correction_analysis/compare_dqctl_scenes.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
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 DATA_DIR, NX, NY
|
||||
from CCD_analysis.correction_analysis.compute_correction_fields import (
|
||||
compute_correction,
|
||||
)
|
||||
|
||||
FIG_DIR = os.path.join(DATA_DIR, "figures")
|
||||
os.makedirs(FIG_DIR, exist_ok=True)
|
||||
|
||||
# Display crop region (pixels) — around pinball at x~613
|
||||
CROP_X0, CROP_X1 = 300, 1100
|
||||
|
||||
# Scene groups
|
||||
CLOAK_SCENES = ["steady_cloak", "karman_re100", "vortex_lamb", "vortex_taylor"]
|
||||
ILLUSION_SCENES = ["illusion_0.75L", "illusion_1.0L", "illusion_1.5L"]
|
||||
ALL_SCENES = CLOAK_SCENES + ILLUSION_SCENES
|
||||
|
||||
SCENE_LABELS = {
|
||||
"steady_cloak": "Steady Cloak",
|
||||
"karman_re100": "Karman Cloak",
|
||||
"vortex_lamb": "Vortex Lamb",
|
||||
"vortex_taylor": "Vortex Taylor",
|
||||
"illusion_0.75L": "Illusion 0.75L",
|
||||
"illusion_1.0L": "Illusion 1.0L",
|
||||
"illusion_1.5L": "Illusion 1.5L",
|
||||
}
|
||||
|
||||
FIELD_METRICS = [
|
||||
("ux_mean", r"mean $u_x$", "RdBu_r", True),
|
||||
("uy_mean", r"mean $u_y$", "RdBu_r", True),
|
||||
("rms", "RMS", "viridis", False),
|
||||
("vorticity", r"$\omega_z$", "RdBu_r", True),
|
||||
]
|
||||
|
||||
|
||||
def compute_metrics(st: str) -> dict | None:
|
||||
"""Load dq_ctl for a scene and compute metrics."""
|
||||
try:
|
||||
corr = compute_correction(st)
|
||||
dq = corr.get("dq_ctl")
|
||||
if dq is None:
|
||||
return None
|
||||
ux, uy = dq["ux"], dq["uy"]
|
||||
return {
|
||||
"ux_mean": np.mean(ux, axis=0),
|
||||
"uy_mean": np.mean(uy, axis=0),
|
||||
"rms": np.sqrt(np.std(ux, axis=0)**2 + np.std(uy, axis=0)**2),
|
||||
"vorticity": np.gradient(np.mean(uy, axis=0), axis=1)
|
||||
- np.gradient(np.mean(ux, axis=0), axis=0),
|
||||
}
|
||||
except Exception as e:
|
||||
print(f" SKIP {st}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def crop_field(f: np.ndarray) -> np.ndarray:
|
||||
"""Crop to display region (NY, NX_cropped)."""
|
||||
return f[:, CROP_X0:CROP_X1]
|
||||
|
||||
|
||||
def plot_comparison(scene_list: str | list, name: str):
|
||||
"""Generate a grid of dq_ctl metrics for selected scenes."""
|
||||
if isinstance(scene_list, str):
|
||||
scene_list = [scene_list]
|
||||
|
||||
# Load all fields
|
||||
fields = {}
|
||||
for st in scene_list:
|
||||
print(f" Loading {st}...", flush=True)
|
||||
m = compute_metrics(st)
|
||||
if m is not None:
|
||||
fields[st] = m
|
||||
|
||||
n_scenes = len(fields)
|
||||
if n_scenes == 0:
|
||||
print(" No valid fields, skipping")
|
||||
return
|
||||
|
||||
scene_names = list(fields.keys())
|
||||
n_rows = len(FIELD_METRICS)
|
||||
|
||||
# Compute global vmax per metric from CROPPED fields
|
||||
metric_vmax = {}
|
||||
for mkey, _, _, _ in FIELD_METRICS:
|
||||
all_vals = np.concatenate(
|
||||
[abs(crop_field(fields[s][mkey])).ravel() for s in fields])
|
||||
vmax = float(np.percentile(all_vals[np.isfinite(all_vals)], 99.5))
|
||||
metric_vmax[mkey] = max(vmax, 1e-12)
|
||||
|
||||
fig, axes = plt.subplots(n_rows, n_scenes,
|
||||
figsize=(3.5 * n_scenes, 3.0 * n_rows))
|
||||
if n_rows == 1:
|
||||
axes = [axes]
|
||||
if n_scenes == 1:
|
||||
axes = [[a] for a in axes]
|
||||
|
||||
nx_crop = CROP_X1 - CROP_X0
|
||||
extent = (CROP_X0, CROP_X1, 0, NY - 1)
|
||||
|
||||
for row, (mkey, mlabel, cmap, symmetric) in enumerate(FIELD_METRICS):
|
||||
for col, sn in enumerate(scene_names):
|
||||
ax = axes[row][col]
|
||||
f = crop_field(fields[sn][mkey])
|
||||
vmax = metric_vmax[mkey]
|
||||
|
||||
kwargs = {"cmap": cmap, "origin": "lower",
|
||||
"aspect": "equal", "extent": extent}
|
||||
if symmetric:
|
||||
kwargs["vmin"] = -vmax
|
||||
kwargs["vmax"] = vmax
|
||||
else:
|
||||
kwargs["vmin"] = 0
|
||||
kwargs["vmax"] = vmax
|
||||
|
||||
ax.imshow(f, **kwargs)
|
||||
ax.tick_params(left=False, right=False, labelleft=False,
|
||||
bottom=False, top=False, labelbottom=False)
|
||||
|
||||
if row == 0:
|
||||
ax.set_title(SCENE_LABELS.get(sn, sn), fontsize=10)
|
||||
if col == 0:
|
||||
ax.set_ylabel(mlabel, fontsize=10)
|
||||
|
||||
plt.suptitle(f"dq_ctl: [{', '.join(SCENE_LABELS.get(s,s) for s in scene_names)}]",
|
||||
fontsize=12, y=1.01)
|
||||
plt.tight_layout()
|
||||
path = os.path.join(FIG_DIR, name)
|
||||
fig.savefig(path, dpi=150, bbox_inches="tight")
|
||||
plt.close(fig)
|
||||
print(f" Saved: {path}", flush=True)
|
||||
|
||||
# Stats
|
||||
print(f"\n --- RMS (cropped region) ---")
|
||||
for sn in scene_names:
|
||||
rms_crop = crop_field(fields[sn]["rms"])
|
||||
rms_val = float(np.sqrt(np.mean(rms_crop**2)))
|
||||
print(f" {sn:22s}: RMS={rms_val:.6f}")
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 60)
|
||||
print("Comparison: dq_ctl across all cloak & illusion scenes")
|
||||
print("=" * 60)
|
||||
|
||||
# 1. All 7 scenes panorama
|
||||
print("\n--- All 7 scenes panorama ---")
|
||||
plot_comparison(ALL_SCENES, "corr_comparison_all_scenes.png")
|
||||
|
||||
# 2. Illusion-only (3 diameters)
|
||||
print("\n--- Illusion-only comparison ---")
|
||||
plot_comparison(ILLUSION_SCENES, "corr_illusion_comparison_dqctl.png")
|
||||
|
||||
# 3. Cloak-only (4 scenes) for reference
|
||||
print("\n--- Cloak-only comparison ---")
|
||||
plot_comparison(CLOAK_SCENES, "corr_cloak_comparison_dqctl.png")
|
||||
|
||||
print("\nDone!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,434 +0,0 @@
|
||||
"""Build q_in, q_blk, q_ctl, q_tar field references and compute Delta corrections.
|
||||
|
||||
Each scene type maps to different data sources:
|
||||
|
||||
| Scene type | q_in | q_blk | q_ctl | q_tar |
|
||||
|-----------------|-------------------|---------|---------------------|--------------------------|
|
||||
| illusion_0.75L | target_channel* | pinball | illusion_0.75L | target_cylinder_0.75L |
|
||||
| illusion_1.0L | target_channel* | pinball | illusion_1.0L | target_cylinder_1.0L |
|
||||
| illusion_1.5L | target_channel* | pinball | illusion_1.5L | target_cylinder_1.5L |
|
||||
| steady_cloak | target_channel* | pinball | steady_cloak* | None (target=q_in) |
|
||||
| karman_re100 | karman_q_in | karman_q_blk | karman_re100 | karman_q_in |
|
||||
|
||||
(*) loaded via load_legacy_steady(). Vortex scenes use load_vortex_fields().
|
||||
All scenes now use UNIFIED geometry (pinball center at 613px, sensors at 800px).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import warnings
|
||||
from typing import Any, Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
from CCD_analysis.configs import DATA_DIR, 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
|
||||
from CCD_analysis.utils.load_vortex_fields import load_vortex_fields
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Source mapping
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _resolve_source(name: str) -> Optional[dict]:
|
||||
"""Load a named data source, dispatching to the correct loader.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
name : str
|
||||
Scene or special name:
|
||||
- 'target_channel', 'steady_cloak' -> load_legacy_steady
|
||||
- 'vortex_*' -> load_vortex_fields (transient)
|
||||
- 'karman_re100' -> _load_karman_re100 (handles 72 vs 96 mismatch)
|
||||
- all others -> load_aligned_fields
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict or None
|
||||
"""
|
||||
_LEGACY_SCENES = {"target_channel", "steady_cloak"}
|
||||
_VORTEX_SCENES = {
|
||||
"vortex_lamb", "vortex_taylor",
|
||||
"vortex_uncontrolled_lamb", "vortex_uncontrolled_taylor",
|
||||
"vortex_target_lamb", "vortex_target_taylor",
|
||||
}
|
||||
|
||||
if name in _LEGACY_SCENES:
|
||||
return load_legacy_steady(name)
|
||||
elif name in _VORTEX_SCENES:
|
||||
return load_vortex_fields(name)
|
||||
elif name == "karman_re100":
|
||||
return _load_karman_re100()
|
||||
else:
|
||||
return load_aligned_fields(name)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Karman re100 special loader (handles 72 vs 96 frame mismatch)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _load_karman_re100() -> dict:
|
||||
"""Load karman_re100 aligned fields, handling the 72 vs 96 frame mismatch.
|
||||
|
||||
karman_re100's fields_aligned.npz has 72 snapshots (3 cycles x 24 pts)
|
||||
but the phase_plan.json lists 96 step_indices (4 cycles x 24 pts).
|
||||
This loader truncates step_indices to match.
|
||||
"""
|
||||
import json as _json
|
||||
|
||||
scene_name = "karman_re100"
|
||||
from CCD_analysis.configs import SCENES as _SCENES
|
||||
|
||||
cfg = _SCENES[scene_name]
|
||||
scene_id = cfg["scene_id"]
|
||||
data_dir = os.path.join(DATA_DIR, scene_id, scene_name)
|
||||
|
||||
# Load fields_aligned.npz
|
||||
fa_path = os.path.join(data_dir, "fields_aligned.npz")
|
||||
fd = np.load(fa_path)
|
||||
ux_raw = fd["ux"]
|
||||
uy_raw = fd["uy"]
|
||||
N = ux_raw.shape[0] # 72
|
||||
fd.close()
|
||||
|
||||
# Transpose (N, NX, NY) -> (N, NY, NX)
|
||||
ux = np.ascontiguousarray(ux_raw.transpose(0, 2, 1))
|
||||
uy = np.ascontiguousarray(uy_raw.transpose(0, 2, 1))
|
||||
|
||||
# Load phase_plan, truncate to N
|
||||
plan_path = os.path.join(DATA_DIR, "resampled", scene_name, "phase_plan.json")
|
||||
with open(plan_path) as f:
|
||||
plan = _json.load(f)
|
||||
step_indices = list(plan["step_indices"][:N])
|
||||
|
||||
# Load telemetry
|
||||
tele_path = os.path.join(data_dir, "controlled.npz")
|
||||
td = np.load(tele_path)
|
||||
|
||||
result = {
|
||||
"ux": ux,
|
||||
"uy": uy,
|
||||
"forces": td["forces"][step_indices] if "forces" in td else None,
|
||||
"actions": td["actions"][step_indices] if "actions" in td else None,
|
||||
"sensors": td["sensors"][step_indices] if "sensors" in td else 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"),
|
||||
"note": "step_indices truncated from 96 to 72 (3 cycles, not 4)",
|
||||
},
|
||||
"step_indices": step_indices,
|
||||
}
|
||||
td.close()
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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"),
|
||||
"vortex_lamb": ("target_channel", "vortex_uncontrolled_lamb", "vortex_lamb", "vortex_target_lamb"),
|
||||
"vortex_taylor": ("target_channel", "vortex_uncontrolled_taylor", "vortex_taylor", "vortex_target_taylor"),
|
||||
}
|
||||
|
||||
|
||||
def get_diameter(scene_type: str) -> Optional[float]:
|
||||
"""Extract target diameter from scene type string (e.g. 'illusion_1.0L' -> 1.0)."""
|
||||
if "illusion" in scene_type or "target_cylinder" in scene_type:
|
||||
try:
|
||||
return float(scene_type.split("_")[-1].replace("L", ""))
|
||||
except (ValueError, IndexError):
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def subtract_fields(q_a: dict, q_b: dict) -> Optional[dict]:
|
||||
"""Compute q_a - q_b field difference.
|
||||
|
||||
Both must have the same N. Returns dict with:
|
||||
ux, uy : (N, NY, NX) -- field difference
|
||||
forces : from q_a (reference)
|
||||
sensors : from q_a (reference)
|
||||
actions : from q_a (reference)
|
||||
meta : combined
|
||||
step_indices : from q_a
|
||||
|
||||
Parameters
|
||||
----------
|
||||
q_a : dict -- reference (minuend)
|
||||
q_b : dict -- subtrahend
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict or None if either input is None
|
||||
"""
|
||||
if q_a is None or q_b is None:
|
||||
return None
|
||||
|
||||
N_a = q_a["ux"].shape[0]
|
||||
N_b = q_b["ux"].shape[0]
|
||||
if N_a != N_b:
|
||||
raise ValueError(
|
||||
f"Frame count mismatch: q_a has {N_a} frames, q_b has {N_b}. "
|
||||
"Use common_length() to align."
|
||||
)
|
||||
|
||||
return {
|
||||
"ux": q_a["ux"] - q_b["ux"],
|
||||
"uy": q_a["uy"] - q_b["uy"],
|
||||
"forces": q_a.get("forces"),
|
||||
"sensors": q_a.get("sensors"),
|
||||
"actions": q_a.get("actions"),
|
||||
"meta": {**q_a.get("meta", {}), "delta_from": q_b.get("meta", {}).get("scene", "unknown")},
|
||||
"step_indices": q_a.get("step_indices"),
|
||||
}
|
||||
|
||||
|
||||
def dict_to_field_matrix(q: dict) -> np.ndarray:
|
||||
"""Wrapper: build_field_matrix(q['ux'], q['uy']) with error checking.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
q : dict -- must contain 'ux' and 'uy' with shape (N, NY, NX).
|
||||
|
||||
Returns
|
||||
-------
|
||||
Q : (2 * NX * NY, N) ndarray -- snapshot matrix for POD.
|
||||
"""
|
||||
if q is None:
|
||||
raise ValueError("Cannot build field matrix from None")
|
||||
ux = q["ux"]
|
||||
uy = q["uy"]
|
||||
if ux.ndim != 3 or ux.shape[-2:] != (NY, NX):
|
||||
raise ValueError(
|
||||
f"Expected field shape (N, {NY}, {NX}), got {ux.shape}"
|
||||
)
|
||||
return _build_field_matrix(ux, uy)
|
||||
|
||||
|
||||
def compute_correction(scene_type: str) -> dict:
|
||||
"""Load q_in, q_blk, q_ctl, q_tar and compute Delta fields.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
scene_type : str -- one of the keys in _SCENE_MAP.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict with:
|
||||
scene_type : str
|
||||
diam : float or None
|
||||
q_in, q_blk, q_ctl, q_tar : dict or None -- raw loaded data
|
||||
dq_blk, dq_ctl, dq_tar : dict or None -- field differences
|
||||
dq_tar_minus_blk : dict or None
|
||||
N : int -- aligned frame count (min across all loaded sources)
|
||||
meta : combined metadata dict
|
||||
"""
|
||||
if scene_type not in _SCENE_MAP:
|
||||
raise KeyError(
|
||||
f"Unknown scene_type: {scene_type}. "
|
||||
f"Available: {list(_SCENE_MAP.keys())}"
|
||||
)
|
||||
|
||||
q_in_name, q_blk_name, q_ctl_name, q_tar_name = _SCENE_MAP[scene_type]
|
||||
diam = get_diameter(scene_type)
|
||||
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f"Computing correction fields for: {scene_type}")
|
||||
print(f" q_in = {q_in_name}, q_blk = {q_blk_name}, "
|
||||
f"q_ctl = {q_ctl_name}, q_tar = {q_tar_name}")
|
||||
print(f"{'=' * 60}")
|
||||
|
||||
# -- Load all sources --
|
||||
q_in = _resolve_source(q_in_name) if q_in_name else None
|
||||
q_blk = _resolve_source(q_blk_name) if q_blk_name else None
|
||||
q_ctl = _resolve_source(q_ctl_name) if q_ctl_name else None
|
||||
q_tar = _resolve_source(q_tar_name) if q_tar_name else None
|
||||
|
||||
# -- Determine aligned N --
|
||||
all_N = []
|
||||
for label, q in [("q_in", q_in), ("q_blk", q_blk), ("q_ctl", q_ctl), ("q_tar", q_tar)]:
|
||||
if q is not None:
|
||||
n = q["ux"].shape[0]
|
||||
all_N.append(n)
|
||||
print(f" {label}: {n} frames, shape={q['ux'].shape}")
|
||||
else:
|
||||
print(f" {label}: None")
|
||||
|
||||
N = min(all_N) if all_N else 0
|
||||
|
||||
# -- Compute Delta fields --
|
||||
# dq_blk = q_ctl - q_blk (controller adds beyond pinball)
|
||||
# dq_tar = q_tar - q_blk (target cylinder wake beyond pinball)
|
||||
# dq_ctl = q_ctl - q_in (ctl perturbation from inflow)
|
||||
# dq_tar_in = q_tar - q_in (target perturbation from inflow)
|
||||
|
||||
dq_blk = _safe_subtract(q_blk, q_in, "dq_blk = q_blk - q_in (pinball blockage)")
|
||||
dq_ctl = _safe_subtract(q_ctl, q_blk, "dq_ctl = q_ctl - q_blk (control correction)")
|
||||
dq_tar = _safe_subtract(q_tar, q_blk, "dq_tar = q_tar - q_blk (target correction)")
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"scene_type": scene_type,
|
||||
"diam": diam,
|
||||
"q_in": q_in,
|
||||
"q_blk": q_blk,
|
||||
"q_ctl": q_ctl,
|
||||
"q_tar": q_tar,
|
||||
"dq_blk": dq_blk, # q_blk - q_in
|
||||
"dq_ctl": dq_ctl, # q_ctl - q_blk
|
||||
"dq_tar": dq_tar, # q_tar - q_blk
|
||||
"N": N,
|
||||
"meta": {
|
||||
"scene_type": scene_type,
|
||||
"q_in": q_in_name,
|
||||
"q_blk": q_blk_name,
|
||||
"q_ctl": q_ctl_name,
|
||||
"q_tar": q_tar_name,
|
||||
"N_aligned": N,
|
||||
},
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _safe_subtract(q_a: Optional[dict], q_b: Optional[dict],
|
||||
label: str) -> Optional[dict]:
|
||||
"""Subtract fields with optional trimming and None safety."""
|
||||
if q_a is None or q_b is None:
|
||||
print(f" {label}: skipped (None input)")
|
||||
return None
|
||||
|
||||
N_a = q_a["ux"].shape[0]
|
||||
N_b = q_b["ux"].shape[0]
|
||||
|
||||
if N_a != N_b:
|
||||
N_min = min(N_a, N_b)
|
||||
print(f" {label}: N mismatch ({N_a} vs {N_b}), "
|
||||
f"trimming to min N={N_min}")
|
||||
q_a_trim = _trim_to(q_a, N_min)
|
||||
q_b_trim = _trim_to(q_b, N_min)
|
||||
else:
|
||||
q_a_trim = q_a
|
||||
q_b_trim = q_b
|
||||
|
||||
dq = subtract_fields(q_a_trim, q_b_trim)
|
||||
if dq is not None:
|
||||
_print_field_summary(f" {label}", dq["ux"], dq["uy"])
|
||||
return dq
|
||||
|
||||
|
||||
def _trim_to(q: dict, N: int) -> dict:
|
||||
"""Trim first N frames from field dict."""
|
||||
return {
|
||||
"ux": q["ux"][:N],
|
||||
"uy": q["uy"][:N],
|
||||
"forces": q.get("forces")[:N] if q.get("forces") is not None else None,
|
||||
"sensors": q.get("sensors")[:N] if q.get("sensors") is not None else None,
|
||||
"actions": q.get("actions")[:N] if q.get("actions") is not None else None,
|
||||
"step_indices": q.get("step_indices")[:N] if q.get("step_indices") is not None else None,
|
||||
"meta": q.get("meta", {}),
|
||||
}
|
||||
|
||||
|
||||
def _print_field_summary(label: str, ux: np.ndarray, uy: np.ndarray) -> None:
|
||||
"""Print one-line field statistics."""
|
||||
ux_mean = ux.mean()
|
||||
uy_mean = uy.mean()
|
||||
ux_rms = ux.std()
|
||||
uy_rms = uy.std()
|
||||
mag_mean = np.sqrt(ux_mean**2 + uy_mean**2)
|
||||
print(f" {label}:")
|
||||
print(f" shape = {ux.shape}")
|
||||
print(f" ux_mean = {ux_mean:.6f} uy_mean = {uy_mean:.6f}")
|
||||
print(f" ux_rms = {ux_rms:.6f} uy_rms = {uy_rms:.6f}")
|
||||
print(f" |q|_mean = {mag_mean:.6f}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main (test / verification)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("=" * 60)
|
||||
print("Testing compute_correction_fields.py")
|
||||
print("=" * 60)
|
||||
|
||||
# -- 1. Load target_channel + pinball, compute dq_blk --
|
||||
print("\n--- 1. Loading target_channel (legacy) + pinball (aligned) ---")
|
||||
target_channel = load_legacy_steady("target_channel")
|
||||
pinball = load_aligned_fields("pinball")
|
||||
|
||||
_print_field_summary("target_channel (mean)", target_channel["ux"], target_channel["uy"])
|
||||
_print_field_summary("pinball (mean)", pinball["ux"], pinball["uy"])
|
||||
|
||||
# Verify shapes
|
||||
print(f"\n target_channel: N={target_channel['ux'].shape[0]}, "
|
||||
f"shape={target_channel['ux'].shape}")
|
||||
print(f" pinball: N={pinball['ux'].shape[0]}, "
|
||||
f"shape={pinball['ux'].shape}")
|
||||
print(f" sensors (target): {target_channel['sensors'].shape}")
|
||||
print(f" sensors (pinball): {pinball['sensors'].shape}")
|
||||
print(f" forces (pinball): {pinball['forces'].shape}")
|
||||
|
||||
# -- 2. Compute dq_blk = pinball - target_channel --
|
||||
# Trim to smaller N
|
||||
dq_blk_test = _safe_subtract(pinball, target_channel, "pinball - target_channel")
|
||||
if dq_blk_test is not None:
|
||||
print(f"\n dq_blk shape: {dq_blk_test['ux'].shape}")
|
||||
print(f" dq_blk ux_mean (mean of difference): {dq_blk_test['ux'].mean():.6f}")
|
||||
|
||||
# -- 3. Test full compute_correction for illusion_1.0L --
|
||||
print("\n--- 3. Full correction field pipeline: illusion_1.0L ---")
|
||||
result = compute_correction("illusion_1.0L")
|
||||
|
||||
print(f"\n--- Result summary for {result['scene_type']} ---")
|
||||
print(f" diam = {result['diam']}")
|
||||
print(f" N = {result['N']}")
|
||||
print(f" q_in = {result['q_in']['ux'].shape if result['q_in'] else None}")
|
||||
print(f" q_blk = {result['q_blk']['ux'].shape if result['q_blk'] else None}")
|
||||
print(f" q_ctl = {result['q_ctl']['ux'].shape if result['q_ctl'] else None}")
|
||||
print(f" q_tar = {result['q_tar']['ux'].shape if result['q_tar'] else None}")
|
||||
|
||||
for key in ["dq_blk", "dq_ctl", "dq_tar", "dq_tar_minus_blk"]:
|
||||
dq = result.get(key)
|
||||
if dq is not None:
|
||||
print(f" {key}: mean(ux)={dq['ux'].mean():.6f}, "
|
||||
f"mean(uy)={dq['uy'].mean():.6f}")
|
||||
else:
|
||||
print(f" {key}: None")
|
||||
|
||||
# -- 4. Verify dict_to_field_matrix --
|
||||
print("\n--- 4. Testing dict_to_field_matrix ---")
|
||||
Q = dict_to_field_matrix(pinball)
|
||||
print(f" pinball snapshot matrix: {Q.shape} "
|
||||
f"(expect ({2 * NX * NY}, 96))")
|
||||
assert Q.shape == (2 * NX * NY, 96), f"Unexpected shape: {Q.shape}"
|
||||
print(f" Q range: [{Q.min():.6f}, {Q.max():.6f}]")
|
||||
|
||||
# -- 5. Test steady_cloak --
|
||||
print("\n--- 5. Testing steady_cloak correction ---")
|
||||
result_sc = compute_correction("steady_cloak")
|
||||
print(f" steady_cloak N = {result_sc['N']}")
|
||||
if result_sc.get("dq_blk") is not None:
|
||||
print(f" dq_blk (cloak-pinball) ux_mean: "
|
||||
f"{result_sc['dq_blk']['ux'].mean():.6f}")
|
||||
|
||||
print("\nAll tests passed.")
|
||||
@@ -1,223 +0,0 @@
|
||||
"""Minimal correction-field CCD: POD + force/action CCD on dq_ctl.
|
||||
|
||||
Simplified version — processes only illusion_0.75L and illusion_1.0L.
|
||||
No LOCO validation (separate step). Outputs CCD results and overlaps.
|
||||
|
||||
Usage:
|
||||
conda run -n pycuda_3_10 python correction_analysis/decompose_corrections.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
|
||||
_SRC = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
if _SRC not in sys.path:
|
||||
sys.path.insert(0, _SRC)
|
||||
|
||||
from CCD_analysis.configs import DATA_DIR
|
||||
from CCD_analysis.utils.resampling import (
|
||||
compute_pod, cumulative_energy, e95_index,
|
||||
compute_reduced_ccd, make_force_obs,
|
||||
)
|
||||
from CCD_analysis.correction_analysis.compute_correction_fields import (
|
||||
compute_correction, dict_to_field_matrix,
|
||||
)
|
||||
|
||||
R_CANDIDATES = [6, 8, 10]
|
||||
CCD_Q = 6
|
||||
SCENE_TYPES = ["illusion_0.75L", "illusion_1.0L", "illusion_1.5L", "steady_cloak"]
|
||||
DIAMETERS_MAIN = [0.75, 1.0]
|
||||
DIAMETER_SPECIAL = 1.5 # flagged as special_mechanism (high-freq modulation)
|
||||
|
||||
|
||||
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")
|
||||
is_special = (diam is not None and diam >= DIAMETER_SPECIAL)
|
||||
flag = " [SPECIAL MECHANISM — high-freq modulation]" if is_special else ""
|
||||
print(f"\n--- {st} (diam={diam}){flag} ---", 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)))],
|
||||
"special_mechanism": is_special,
|
||||
}
|
||||
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)))],
|
||||
"special_mechanism": is_special,
|
||||
}
|
||||
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)))],
|
||||
"special_mechanism": is_special,
|
||||
}
|
||||
# Overlap: dq_ctl vs dq_tar
|
||||
ck = f"{st}_dqctl_force_fy_r{r}"
|
||||
if ck in W_dict:
|
||||
Wc = W_dict[ck]
|
||||
n = min(Wc.shape[1], Wt.shape[1], 5)
|
||||
for k in range(n):
|
||||
ov = float(abs(
|
||||
Wc[:, k] / (np.linalg.norm(Wc[:, k])+1e-12) @
|
||||
Wt[:, k] / (np.linalg.norm(Wt[:, k])+1e-12)
|
||||
))
|
||||
all_results[f"{st}_O_dqctl_vs_dqtar_r{r}_mode{k+1}"] = {
|
||||
"overlap": ov, "mode": k+1, "r": r
|
||||
}
|
||||
if k == 0:
|
||||
print(f" O(dqctl, dqtar) mode1={ov:.4f}", flush=True)
|
||||
|
||||
# Overlap dqctl_target vs dqctl_illusion at r=6
|
||||
print(f" Modal overlaps r=6:", flush=True)
|
||||
ovs = compute_modal_overlap(W_dict, st, 6, "force_fy")
|
||||
for ov in ovs:
|
||||
print(f" O({ov['case_a']}, {ov['case_b']}) mode{ov['mode']} = {ov['O']:.4f}", flush=True)
|
||||
|
||||
# Save
|
||||
ccd_path = os.path.join(out_dir, "correction_ccd_results.json")
|
||||
with open(ccd_path, "w") as f:
|
||||
json.dump(all_results, f, indent=2)
|
||||
print(f"\nSaved {len(all_results)} entries to {ccd_path}", flush=True)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,377 +0,0 @@
|
||||
"""Phase 2: Baseline diagnostics — mean/RMS/vorticity + zone metrics for correction fields.
|
||||
|
||||
For each available scene type:
|
||||
1. Mean/RMS/vorticity of dq_blk, dq_ctl, dq_tar
|
||||
2. Three-zone spatial metrics
|
||||
3. Figures saved to data/figures/
|
||||
|
||||
Usage:
|
||||
conda run -n pycuda_3_10 python correction_analysis/diagnose_corrections.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
import matplotlib
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
_SRC = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
if _SRC not in sys.path:
|
||||
sys.path.insert(0, _SRC)
|
||||
|
||||
from CCD_analysis.configs import DATA_DIR, NX, NY, L0, CENTER_Y
|
||||
from CCD_analysis.utils.resampling import load_aligned_fields
|
||||
from CCD_analysis.correction_analysis.compute_correction_fields import (
|
||||
compute_correction, dict_to_field_matrix,
|
||||
)
|
||||
|
||||
FIG_DIR = os.path.join(DATA_DIR, "figures")
|
||||
os.makedirs(FIG_DIR, exist_ok=True)
|
||||
|
||||
# Scene types to process
|
||||
SCENE_TYPES = [
|
||||
"illusion_0.75L",
|
||||
"illusion_1.0L",
|
||||
"illusion_1.5L",
|
||||
"steady_cloak",
|
||||
"karman_re100",
|
||||
"vortex_lamb",
|
||||
"vortex_taylor",
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Three-zone masks (unified geometry: pinball center at 613 px, sensors at 800 px)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def define_zones() -> dict:
|
||||
"""Define three-zone masks for all scenes (unified geometry, 2026-06-28).
|
||||
|
||||
All scenes now use the same pinball/sensor positions after unified collection.
|
||||
Zone ranges:
|
||||
near_body: 580-720 px (around pinball at x≈613)
|
||||
body_wake: 720-850 px (near wake downstream)
|
||||
sensor_zone: 780-850 px (around sensors at x=800)
|
||||
"""
|
||||
zones = {}
|
||||
mask = np.zeros((NY, NX), dtype=bool)
|
||||
mask[:, 580:720] = True
|
||||
zones["near_body"] = mask
|
||||
|
||||
mask = np.zeros((NY, NX), dtype=bool)
|
||||
mask[:, 720:850] = True
|
||||
zones["body_wake"] = mask
|
||||
|
||||
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)
|
||||
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")
|
||||
|
||||
# Mean uy
|
||||
vmax = max(abs(uy_m).max(), 1e-12)
|
||||
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")
|
||||
|
||||
# RMS magnitude
|
||||
axes[2].imshow(rms, cmap="viridis", origin="lower",
|
||||
aspect="equal", extent=extent)
|
||||
axes[2].set_title(f"{label}: RMS magnitude")
|
||||
|
||||
# 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)
|
||||
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.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 = define_zones()
|
||||
|
||||
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, AssertionError, ValueError) as e:
|
||||
print(f" SKIP: {e}", flush=True)
|
||||
continue
|
||||
|
||||
if corr["N"] == 0:
|
||||
print(f" SKIP: no valid data (N=0)", flush=True)
|
||||
continue
|
||||
|
||||
# Unified geometry — same zones for all scenes
|
||||
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 scenes with a target, also plot dq_tar if available
|
||||
if dq_key == "dq_ctl" and corr.get("dq_tar") is not None:
|
||||
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)
|
||||
|
||||
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")
|
||||
|
||||
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")
|
||||
|
||||
# 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)
|
||||
|
||||
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")
|
||||
|
||||
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.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)
|
||||
|
||||
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)")
|
||||
|
||||
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)")
|
||||
|
||||
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.suptitle(f"Steady cloak: cancellation test")
|
||||
plt.tight_layout()
|
||||
path = os.path.join(FIG_DIR, "steady_cloak_cancel_test.png")
|
||||
fig.savefig(path, dpi=150)
|
||||
plt.close(fig)
|
||||
print(f" Saved: {path}", flush=True)
|
||||
|
||||
# Save zone metrics
|
||||
metrics_path = os.path.join(DATA_DIR, "ccd", "zone_metrics.json")
|
||||
with open(metrics_path, "w") as f:
|
||||
json.dump(all_metrics, f, indent=2)
|
||||
print(f"\nZone metrics saved to {metrics_path}", flush=True)
|
||||
print("\nDone.", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
@@ -1,153 +0,0 @@
|
||||
"""Load legacy fields.npz format for steady scenes (steady_cloak, target_channel).
|
||||
|
||||
Converts to the same convention as load_aligned_fields():
|
||||
- Transposes fields from (N, NX, NY) -> (N, NY, NX)
|
||||
- Loads telemetry from sensors.npz
|
||||
- Returns dict with identical key structure
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from CCD_analysis.configs import DATA_DIR, NX, NY
|
||||
|
||||
|
||||
def load_legacy_steady(scene_name: str) -> dict:
|
||||
"""Load steady scene from legacy fields.npz format.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
scene_name : str — one of 'steady_cloak' or 'target_channel'
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict with same keys as load_aligned_fields():
|
||||
ux, uy : (N, NY, NX) ndarray
|
||||
forces : None (no force telemetry in legacy sensors)
|
||||
sensors : (N, 6) ndarray or None
|
||||
actions : None (open-loop)
|
||||
meta : dict with scene info
|
||||
step_indices : list of int
|
||||
"""
|
||||
scene_dir = os.path.join(DATA_DIR, scene_name, scene_name)
|
||||
if not os.path.isdir(scene_dir):
|
||||
raise FileNotFoundError(f"Scene directory not found: {scene_dir}")
|
||||
|
||||
# -- fields.npz (native simulation order: NX first) --
|
||||
fields_path = os.path.join(scene_dir, "fields.npz")
|
||||
if not os.path.isfile(fields_path):
|
||||
raise FileNotFoundError(f"{fields_path} not found")
|
||||
|
||||
fd = np.load(fields_path)
|
||||
ux_raw = fd["ux"] # (N, NX, NY)
|
||||
uy_raw = fd["uy"]
|
||||
N = ux_raw.shape[0]
|
||||
fd.close()
|
||||
|
||||
# Transpose (N, NX, NY) -> (N, NY, NX) to match load_aligned_fields convention
|
||||
ux = np.ascontiguousarray(ux_raw.transpose(0, 2, 1))
|
||||
uy = np.ascontiguousarray(uy_raw.transpose(0, 2, 1))
|
||||
|
||||
# -- sensors.npz (telemetry) --
|
||||
sensors_path = os.path.join(scene_dir, "sensors.npz")
|
||||
sensors = None
|
||||
if os.path.isfile(sensors_path):
|
||||
sd = np.load(sensors_path)
|
||||
if "sensors" in sd:
|
||||
sensors = sd["sensors"] # (N, 6)
|
||||
assert sensors.shape[0] == N, (
|
||||
f"sensors ({sensors.shape[0]}) != fields ({N})"
|
||||
)
|
||||
sd.close()
|
||||
|
||||
# -- meta.json --
|
||||
meta = {"scene": scene_name, "scene_id": scene_name, "source": "legacy_steady"}
|
||||
meta_path = os.path.join(scene_dir, "meta.json")
|
||||
if os.path.isfile(meta_path):
|
||||
with open(meta_path) as f:
|
||||
meta.update(json.load(f))
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"ux": ux,
|
||||
"uy": uy,
|
||||
"forces": None, # no forces in legacy steady telemetry
|
||||
"actions": None, # open-loop
|
||||
"sensors": sensors,
|
||||
"meta": meta,
|
||||
"step_indices": list(range(N)),
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Diagnostic helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _print_field_stats(label: str, ux: np.ndarray, uy: np.ndarray) -> None:
|
||||
"""Print mean velocity statistics for a set of fields."""
|
||||
ux_mean = ux.mean()
|
||||
uy_mean = uy.mean()
|
||||
ux_std = ux.std()
|
||||
uy_std = uy.std()
|
||||
print(f" {label}:")
|
||||
print(f" shape = {ux.shape}")
|
||||
print(f" ux_mean = {ux_mean:.6f} (expect ~U0={0.01} for channel)")
|
||||
print(f" uy_mean = {uy_mean:.6f} (expect near 0)")
|
||||
print(f" ux_rms = {ux_std:.6f}")
|
||||
print(f" uy_rms = {uy_std:.6f}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main (test)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("=" * 60)
|
||||
print("Testing load_legacy_steady()")
|
||||
print("=" * 60)
|
||||
|
||||
for scene in ["steady_cloak", "target_channel"]:
|
||||
print(f"\n--- {scene} ---")
|
||||
data = load_legacy_steady(scene)
|
||||
ux = data["ux"]
|
||||
uy = data["uy"]
|
||||
sensors = data["sensors"]
|
||||
N = ux.shape[0]
|
||||
|
||||
_print_field_stats(scene, ux, uy)
|
||||
|
||||
print(f" N_frames = {N}")
|
||||
print(f" NY x NX = {ux.shape[1]} x {ux.shape[2]}")
|
||||
print(f" sensors = {sensors.shape if sensors is not None else None}")
|
||||
print(f" forces = {data['forces']}")
|
||||
print(f" actions = {data['actions']}")
|
||||
print(f" step_range = [{data['step_indices'][0]}, {data['step_indices'][-1]}]")
|
||||
|
||||
# Physical reasonableness checks
|
||||
ux_max = ux.max()
|
||||
uy_max = abs(uy).max()
|
||||
print(f" ux_max = {ux_max:.4f} (expect order 0.01)")
|
||||
print(f" |uy|_max = {uy_max:.4f} (expect < ux_max)")
|
||||
print(f" metadata = {list(data['meta'].keys())}")
|
||||
|
||||
# Quick: verify convention matches load_aligned_fields
|
||||
print("\n--- Convention check: transpose correctness ---")
|
||||
# Load raw from steady_cloak to verify ravel order
|
||||
raw = np.load(
|
||||
os.path.join(DATA_DIR, "steady_cloak", "steady_cloak", "fields.npz")
|
||||
)
|
||||
raw_ux = raw["ux"][0] # (NX, NY)
|
||||
loaded = load_legacy_steady("steady_cloak")
|
||||
loaded_ux = loaded["ux"][0] # (NY, NX)
|
||||
|
||||
# raw_ux[NX, NY] should == loaded_ux[NY, NX] after transpose
|
||||
match = np.allclose(raw_ux.T, loaded_ux)
|
||||
print(f" Transpose (raw.T == loaded): {match}")
|
||||
raw.close()
|
||||
|
||||
print("\nDone.")
|
||||
@@ -1,366 +0,0 @@
|
||||
"""1.5L correction-field CCD: force-CCD, action-CCD, signature-CCD on dq_ctl.
|
||||
|
||||
Extends the Phase 2 pipeline to the 1.5L "special mechanism" case.
|
||||
Target-only POD basis, Q_delay=6, r=[6, 8, 10].
|
||||
|
||||
Usage:
|
||||
conda run -n pycuda_3_10 python correction_analysis/run_15L_correction.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
|
||||
_SRC = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
if _SRC not in sys.path:
|
||||
sys.path.insert(0, _SRC)
|
||||
|
||||
from CCD_analysis.configs import DATA_DIR, NX, NY, CENTER_Y
|
||||
from CCD_analysis.utils.resampling import (
|
||||
compute_pod, cumulative_energy, e95_index,
|
||||
compute_reduced_ccd, make_force_obs,
|
||||
)
|
||||
from CCD_analysis.correction_analysis.compute_correction_fields import (
|
||||
compute_correction, dict_to_field_matrix,
|
||||
)
|
||||
|
||||
R_CANDIDATES = [6, 8, 10]
|
||||
CCD_Q = 6
|
||||
SCENE_TYPE = "illusion_1.5L"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Zone masks for illusion layout (sensors at x=30*L0=600)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _define_zones() -> dict:
|
||||
"""Define body_wake and sensor_zone masks for 1.5L illusion."""
|
||||
zones = {}
|
||||
# body_wake: immediate downstream of pinball, x=[500, 700)
|
||||
mask_bw = np.zeros((NY, NX), dtype=bool)
|
||||
mask_bw[:, 500:700] = True
|
||||
zones["body_wake"] = mask_bw
|
||||
# sensor_zone: around sensors at x=600, x=[580, 650)
|
||||
mask_sz = np.zeros((NY, NX), dtype=bool)
|
||||
mask_sz[:, 580:650] = True
|
||||
zones["sensor_zone"] = mask_sz
|
||||
return zones
|
||||
|
||||
|
||||
def _zone_ke_ratio(dq: dict, zones: dict) -> dict:
|
||||
"""Compute correction energy ratio body_wake / sensor_zone."""
|
||||
ux, uy = dq["ux"], dq["uy"]
|
||||
ke_field = 0.5 * np.mean(ux**2 + uy ** 2, axis=0) # (NY, NX)
|
||||
body_ke = ke_field[zones["body_wake"]].sum()
|
||||
sensor_ke = ke_field[zones["sensor_zone"]].sum()
|
||||
ratio = body_ke / sensor_ke if sensor_ke > 0 else float("inf")
|
||||
return {
|
||||
"body_wake_KE": float(body_ke),
|
||||
"sensor_zone_KE": float(sensor_ke),
|
||||
"ratio_bw_over_sz": float(ratio),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Signature-CCD helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def compute_tau_corr(a_ctl: np.ndarray, e_s: np.ndarray,
|
||||
max_lag: int = 12) -> int:
|
||||
"""Find tau that maximises |cross-correlation| between a1 and sensor error.
|
||||
|
||||
Computes average absolute cross-correlation across sensor channels,
|
||||
returns the lag (in snapshot steps) with the strongest correlation.
|
||||
"""
|
||||
a1 = a_ctl[0, :] # leading POD coefficient
|
||||
n = len(a1)
|
||||
# Normalise
|
||||
a1_z = (a1 - a1.mean()) / (a1.std() + 1e-12)
|
||||
# Average absolute correlation across sensor channels
|
||||
corr_avg = np.zeros(2 * max_lag + 1)
|
||||
for ch in range(e_s.shape[0]):
|
||||
ech = e_s[ch, :n]
|
||||
ech_z = (ech - ech.mean()) / (ech.std() + 1e-12)
|
||||
c = np.correlate(a1_z, ech_z, mode="full")
|
||||
c_mid = len(c) // 2
|
||||
seg = c[c_mid - max_lag:c_mid + max_lag + 1]
|
||||
corr_avg += np.abs(seg)
|
||||
corr_avg /= e_s.shape[0]
|
||||
best_lag = np.argmax(corr_avg) - max_lag
|
||||
return int(best_lag)
|
||||
|
||||
|
||||
def _scene_to_target_name(scene_type: str) -> str | None:
|
||||
if "illusion" in scene_type:
|
||||
parts = scene_type.split("_")
|
||||
if len(parts) >= 2:
|
||||
return f"target_cylinder_{parts[1]}"
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
print("=" * 60, flush=True)
|
||||
print("1.5L Correction-field CCD — dq_ctl", flush=True)
|
||||
print("=" * 60, flush=True)
|
||||
|
||||
out_dir = os.path.join(DATA_DIR, "ccd")
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
all_results = {}
|
||||
W_dict = {}
|
||||
|
||||
# ---- 1. Load correction fields for 1.5L ----
|
||||
print(f"\n--- Loading correction: {SCENE_TYPE} ---", flush=True)
|
||||
t0 = time.time()
|
||||
corr = compute_correction(SCENE_TYPE)
|
||||
dq_ctl = corr["dq_ctl"]
|
||||
dq_tar = corr["dq_tar"]
|
||||
diam = corr.get("diam")
|
||||
t_elapsed = time.time() - t0
|
||||
|
||||
if dq_ctl is None:
|
||||
print(" dq_ctl is None — cannot proceed.", flush=True)
|
||||
return 1
|
||||
|
||||
print(f" dq_ctl: {dq_ctl['ux'].shape[0]} frames, "
|
||||
f"forces={'✓' if dq_ctl['forces'] is not None else '✗'}, "
|
||||
f"actions={'✓' if dq_ctl['actions'] is not None else '✗'}, "
|
||||
f"sensors={'✓' if dq_ctl['sensors'] is not None else '✗'}, "
|
||||
f"{t_elapsed:.1f}s", flush=True)
|
||||
|
||||
# ---- 2. Phase drift: zone energy ratio ----
|
||||
print(f"\n--- Phase drift: zone energy ratio (body_wake / sensor_zone) ---",
|
||||
flush=True)
|
||||
zones = _define_zones()
|
||||
ze = _zone_ke_ratio(dq_ctl, zones)
|
||||
print(f" body_wake KE = {ze['body_wake_KE']:.4e}", flush=True)
|
||||
print(f" sensor_zone KE = {ze['sensor_zone_KE']:.4e}", flush=True)
|
||||
print(f" ratio (bw/sz) = {ze['ratio_bw_over_sz']:.4f}", flush=True)
|
||||
all_results["zone_energy_ratio"] = ze
|
||||
|
||||
# ---- 3. POD: target-only basis ----
|
||||
print(f"\n--- POD: target-only basis ---", flush=True)
|
||||
Q_ctl = dict_to_field_matrix(dq_ctl)
|
||||
N = Q_ctl.shape[1]
|
||||
print(f" dq_ctl: shape={Q_ctl.shape}", flush=True)
|
||||
|
||||
if dq_tar is not None:
|
||||
Q_tar = dict_to_field_matrix(dq_tar)
|
||||
print(f" dq_tar: shape={Q_tar.shape}", flush=True)
|
||||
mf_tar, modes_tar, sv_tar, coeffs_tar = compute_pod(Q_tar)
|
||||
# Project dq_ctl into target basis
|
||||
dc = dq_ctl
|
||||
q_proj = np.column_stack([
|
||||
np.concatenate([dc["ux"][s].ravel(), dc["uy"][s].ravel()])
|
||||
for s in range(N)
|
||||
])
|
||||
a_ctl = modes_tar.T @ (q_proj - mf_tar[:, None]).astype(np.float64)
|
||||
a_tar = coeffs_tar
|
||||
print(f" POD: target-only basis "
|
||||
f"(E95={e95_index(cumulative_energy(sv_tar))})", flush=True)
|
||||
else:
|
||||
print(" dq_tar is None — cannot proceed.", flush=True)
|
||||
return 1
|
||||
|
||||
# ---- 4. Sensor error for signature line ----
|
||||
sensors_ctl = dq_ctl.get("sensors") # (N, 6) — illusion sensors
|
||||
sensors_tar = dq_tar.get("sensors") # (N, 6) — target sensors
|
||||
if sensors_ctl is not None and sensors_tar is not None:
|
||||
# Both have 6 sensor channels: use all 6 dimensions
|
||||
n_min = min(sensors_ctl.shape[0], sensors_tar.shape[0], N)
|
||||
e_s_full = (sensors_ctl[:n_min] - sensors_tar[:n_min]).T # (6, N)
|
||||
print(f" Sensor error e_s: shape={e_s_full.shape}", flush=True)
|
||||
else:
|
||||
print(" Sensor data incomplete — signature-CCD skipped.", flush=True)
|
||||
e_s_full = None
|
||||
|
||||
# ---- 5. Force-CCD, action-CCD, target-CCD ----
|
||||
for r in R_CANDIDATES:
|
||||
a_r = a_ctl[:r, :]
|
||||
Nv = a_r.shape[1]
|
||||
print(f"\n r={r}: N={Nv}", flush=True)
|
||||
|
||||
# Force-CCD
|
||||
frc = dq_ctl.get("forces")
|
||||
if frc is not None:
|
||||
for fmode, flabel in [("fy", "force_fy"),
|
||||
("fx", "force_fx"),
|
||||
("joint", "force_joint")]:
|
||||
y = make_force_obs(frc, SCENE_TYPE, mode=fmode)[:, :Nv]
|
||||
W, sig, _, _, _, _ = compute_reduced_ccd(
|
||||
a_r, y, Q_delay=CCD_Q)
|
||||
en = cumulative_energy(sig)
|
||||
m80 = int(np.searchsorted(en, 0.80) + 1) if len(en) > 0 else 0
|
||||
key = f"illusion_1.5L_dqctl_{flabel}_r{r}"
|
||||
W_dict[key] = W
|
||||
all_results[key] = {
|
||||
"scene": SCENE_TYPE, "diam": diam, "obs": flabel, "r": r,
|
||||
"m80": m80, "N": sig.size,
|
||||
"sigma_top3": [
|
||||
float(sig[i]) for i in range(min(3, len(sig)))
|
||||
],
|
||||
}
|
||||
if fmode == "fy":
|
||||
print(f" {key}: m80={m80} "
|
||||
f"s1={float(sig[0]):.4f}", flush=True)
|
||||
|
||||
# Action-CCD
|
||||
act = dq_ctl.get("actions")
|
||||
if act is not None:
|
||||
y_a = act.T[:, :Nv]
|
||||
W, sig, _, _, _, _ = compute_reduced_ccd(
|
||||
a_r, y_a, Q_delay=CCD_Q)
|
||||
en = cumulative_energy(sig)
|
||||
m80 = int(np.searchsorted(en, 0.80) + 1) if len(en) > 0 else 0
|
||||
key = f"illusion_1.5L_dqctl_action_r{r}"
|
||||
W_dict[key] = W
|
||||
all_results[key] = {
|
||||
"scene": SCENE_TYPE, "diam": diam, "obs": "action", "r": r,
|
||||
"m80": m80, "N": sig.size,
|
||||
"sigma_top3": [
|
||||
float(sig[i]) for i in range(min(3, len(sig)))
|
||||
],
|
||||
}
|
||||
print(f" {key}: m80={m80} s1={float(sig[0]):.4f}", flush=True)
|
||||
|
||||
# Target force-CCD reference
|
||||
if dq_tar is not None:
|
||||
a_tr = a_tar[:r, :Nv]
|
||||
frc_t = dq_tar.get("forces")
|
||||
if frc_t is not None:
|
||||
tname = _scene_to_target_name(SCENE_TYPE) or f"{SCENE_TYPE}_tar"
|
||||
y_t = make_force_obs(frc_t[:Nv], tname, mode="fy")
|
||||
Wt, sig_t, _, _, _, _ = compute_reduced_ccd(
|
||||
a_tr, y_t, Q_delay=CCD_Q)
|
||||
kt = f"illusion_1.5L_dqtar_force_fy_r{r}"
|
||||
W_dict[kt] = Wt
|
||||
all_results[kt] = {
|
||||
"scene": SCENE_TYPE, "diam": diam,
|
||||
"obs": "force_fy_tar", "r": r,
|
||||
"m80": int(np.searchsorted(
|
||||
cumulative_energy(sig_t), 0.80) + 1
|
||||
) if len(sig_t) > 0 else 0,
|
||||
"N": sig_t.size,
|
||||
"sigma_top3": [
|
||||
float(sig_t[i]) for i in range(min(3, len(sig_t)))
|
||||
],
|
||||
}
|
||||
# Overlap: dq_ctl vs dq_tar
|
||||
ck = f"illusion_1.5L_dqctl_force_fy_r{r}"
|
||||
if ck in W_dict:
|
||||
Wc = W_dict[ck]
|
||||
n = min(Wc.shape[1], Wt.shape[1], 5)
|
||||
for k in range(n):
|
||||
ov = float(abs(
|
||||
Wc[:, k] / (np.linalg.norm(Wc[:, k]) + 1e-12) @
|
||||
Wt[:, k] / (np.linalg.norm(Wt[:, k]) + 1e-12)
|
||||
))
|
||||
all_results[
|
||||
f"illusion_1.5L_O_dqctl_vs_dqtar_r{r}_mode{k+1}"
|
||||
] = {"overlap": ov, "mode": k + 1, "r": r}
|
||||
if k == 0:
|
||||
print(
|
||||
f" O(dqctl, dqtar) mode1={ov:.4f}",
|
||||
flush=True
|
||||
)
|
||||
|
||||
# ---- 6. Overlap at r=6 (comparison anchor) ----
|
||||
# Print explicit comparison with 0.75L (0.564) and 1.0L (0.913)
|
||||
key_r6 = "illusion_1.5L_O_dqctl_vs_dqtar_r6_mode1"
|
||||
ov_r6 = all_results.get(key_r6, {}).get("overlap")
|
||||
if ov_r6 is not None:
|
||||
verdict = "lower=special" if ov_r6 < 0.7 else "higher=normal"
|
||||
print(
|
||||
f"\n 1.5L O(dqctl, dqtar) = {ov_r6:.4f} "
|
||||
f"(0.75L: 0.564, 1.0L: 0.913 → {verdict})",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
# ---- 7. Signature-CCD ----
|
||||
print(f"\n--- Signature-CCD (future sensor error e_s(t+tau)) ---",
|
||||
flush=True)
|
||||
if e_s_full is not None:
|
||||
# tau candidates
|
||||
tau_geom = 3 # geometric advection delay (snapshot steps)
|
||||
tau_corr = compute_tau_corr(a_ctl, e_s_full, max_lag=12)
|
||||
tau_candidates = [("tau_0", 0), ("tau_geom", tau_geom),
|
||||
("tau_corr", tau_corr)]
|
||||
print(f" tau_geom={tau_geom}, tau_corr={tau_corr}", flush=True)
|
||||
|
||||
for tau_label, tau in tau_candidates:
|
||||
print(f"\n --- tau={tau} ({tau_label}) ---", flush=True)
|
||||
for r in R_CANDIDATES:
|
||||
a_r = a_ctl[:r, :]
|
||||
Nv = a_r.shape[1]
|
||||
# Shift observable forward by tau
|
||||
if tau >= 0:
|
||||
y_sig = e_s_full[:, tau: tau + Nv]
|
||||
# Also shift POD coefficients to align: use a_r[:, :-tau]
|
||||
a_r_aligned = a_r[:, :Nv - tau] if tau > 0 else a_r
|
||||
y_sig_aligned = y_sig[:, :a_r_aligned.shape[1]]
|
||||
else:
|
||||
# Negative tau: shift backward
|
||||
y_sig = e_s_full[:, :Nv + tau]
|
||||
a_r_aligned = a_r[:, -tau:]
|
||||
y_sig_aligned = y_sig[:, :a_r_aligned.shape[1]]
|
||||
|
||||
if y_sig_aligned.shape[1] < CCD_Q:
|
||||
print(f" r={r}: too few samples ({y_sig_aligned.shape[1]}), skipping",
|
||||
flush=True)
|
||||
continue
|
||||
|
||||
W, sig, _, _, _, _ = compute_reduced_ccd(
|
||||
a_r_aligned, y_sig_aligned, Q_delay=CCD_Q)
|
||||
en = cumulative_energy(sig)
|
||||
m80 = int(np.searchsorted(en, 0.80) + 1) if len(en) > 0 else 0
|
||||
key = f"illusion_1.5L_dqctl_signature_{tau_label}_r{r}"
|
||||
W_dict[key] = W
|
||||
all_results[key] = {
|
||||
"scene": SCENE_TYPE, "diam": diam,
|
||||
"obs": f"signature_{tau_label}", "r": r,
|
||||
"tau": tau, "m80": m80, "N": sig.size,
|
||||
"sigma_top3": [
|
||||
float(sig[i]) for i in range(min(3, len(sig)))
|
||||
],
|
||||
}
|
||||
print(f" {key}: m80={m80} "
|
||||
f"s1={float(sig[0]):.4f}", flush=True)
|
||||
else:
|
||||
print(" Skipping: sensor error not available.", flush=True)
|
||||
|
||||
# ---- 8. Save ----
|
||||
ccd_path = os.path.join(out_dir, "15L_correction_results.json")
|
||||
with open(ccd_path, "w") as f:
|
||||
json.dump(all_results, f, indent=2)
|
||||
print(f"\nSaved {len(all_results)} entries to {ccd_path}", flush=True)
|
||||
|
||||
# ---- 9. Summary ----
|
||||
print("\n" + "=" * 60, flush=True)
|
||||
print("1.5L Correction-field CCD — Summary", flush=True)
|
||||
print("=" * 60, flush=True)
|
||||
print(f" Zone KE ratio (body_wake/sensor_zone): {ze['ratio_bw_over_sz']:.4f}",
|
||||
flush=True)
|
||||
if ov_r6 is not None:
|
||||
print(f" O(dqctl, dqtar) r=6 mode1: {ov_r6:.4f}", flush=True)
|
||||
|
||||
for r in R_CANDIDATES:
|
||||
print(f"\n r={r}:", flush=True)
|
||||
for obs in ["force_fy", "force_fx", "force_joint", "action"]:
|
||||
k = f"illusion_1.5L_dqctl_{obs}_r{r}"
|
||||
if k in all_results:
|
||||
d = all_results[k]
|
||||
print(f" {obs:12s}: m80={d['m80']}, "
|
||||
f"s1={d['sigma_top3'][0]:.4f}", flush=True)
|
||||
|
||||
print(f"\nDone. Results saved.", flush=True)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,563 +0,0 @@
|
||||
"""Signature-line CCD on dq_ctl: which correction structures determine future sensor mismatch.
|
||||
|
||||
Force/action line CCD on dq_ctl is complete (Phase 1-2). Now we need the
|
||||
SIGNATURE LINE — answering which correction structures most determine future
|
||||
sensor error (rather than instantaneous force).
|
||||
|
||||
Key idea: the observable for signature-CCD is the FUTURE sensor error
|
||||
e(t+tau) = sensors_ctl(t+tau) - sensors_tar(t+tau).
|
||||
|
||||
Usage:
|
||||
conda run -n pycuda_3_10 python correction_analysis/run_signature_line.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
|
||||
_SRC = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
if _SRC not in sys.path:
|
||||
sys.path.insert(0, _SRC)
|
||||
|
||||
from CCD_analysis.configs import DATA_DIR, SCENES
|
||||
from CCD_analysis.utils.resampling import (
|
||||
compute_pod, cumulative_energy, e95_index,
|
||||
compute_reduced_ccd, make_force_obs,
|
||||
)
|
||||
from CCD_analysis.correction_analysis.compute_correction_fields import (
|
||||
compute_correction, dict_to_field_matrix,
|
||||
)
|
||||
|
||||
R_LIST = [6, 8, 10]
|
||||
CCD_Q = 6
|
||||
SCENE_TYPES = ["illusion_0.75L", "illusion_1.0L"]
|
||||
TAU_GEOM = 3
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Signature observable
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def make_signature_obs(sensors_ctl: np.ndarray, sensors_tar: np.ndarray,
|
||||
step_indices: list, tau: int = 0) -> np.ndarray:
|
||||
"""Construct signature observable: future sensor error e(t+tau).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
sensors_ctl : (N_total_raw, 6) — full raw sensor telemetry from controlled data.
|
||||
sensors_tar : (N_total_raw, 6) — full raw sensor telemetry from target data.
|
||||
step_indices : list of int — absolute frame indices (from dq_ctl step_indices).
|
||||
tau : int — future shift in simulation steps.
|
||||
|
||||
Returns
|
||||
-------
|
||||
e : (6, N_valid) — sensor error at shifted indices (6 channels).
|
||||
"""
|
||||
si = np.asarray(step_indices, dtype=int)
|
||||
max_idx = min(len(sensors_ctl), len(sensors_tar)) - 1
|
||||
shifted = np.clip(si + tau, 0, max_idx)
|
||||
e = sensors_ctl[shifted] - sensors_tar[shifted]
|
||||
return e.T # (6, N_valid)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tau computation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def compute_tau_corr(sensors_ctl: np.ndarray, sensors_tar: np.ndarray,
|
||||
step_indices: list, max_lag: int = 50) -> int:
|
||||
"""Compute optimal tau via cross-correlation of target/illusion sensor[:,3].
|
||||
|
||||
Cross-correlates the target cylinder sensor[:,3] with the illusion
|
||||
sensor[:,3] at the snapshot-aligned times. Returns the absolute lag
|
||||
(in steps) that maximises cross-correlation.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
sensors_ctl : (N_total_raw, 6) — full raw sensors from controlled data.
|
||||
sensors_tar : (N_total_raw, 6) — full raw sensors from target data.
|
||||
step_indices : list of int — absolute frame indices.
|
||||
max_lag : int — maximum lag to consider (in steps).
|
||||
|
||||
Returns
|
||||
-------
|
||||
tau : int — optimal lag in steps (non-negative).
|
||||
"""
|
||||
si = np.asarray(step_indices, dtype=int)
|
||||
s_ctl = sensors_ctl[si, 3]
|
||||
s_tar = sensors_tar[si, 3]
|
||||
|
||||
n = len(s_ctl)
|
||||
ctl = s_ctl - np.mean(s_ctl)
|
||||
tar = s_tar - np.mean(s_tar)
|
||||
|
||||
xcorr = np.correlate(tar, ctl, mode='same')
|
||||
mid = n // 2
|
||||
lags = np.arange(-mid, mid + 1)
|
||||
if n % 2 == 0:
|
||||
lags = lags[:-1]
|
||||
|
||||
valid = np.abs(lags) <= max_lag
|
||||
if not np.any(valid):
|
||||
return 0
|
||||
|
||||
best_idx = np.argmax(xcorr[valid])
|
||||
tau = lags[valid][best_idx]
|
||||
return int(abs(tau))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Raw sensor loader (full telemetry, before step-index subsampling)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _load_raw_sensors(scene_name: str) -> np.ndarray:
|
||||
"""Load full raw sensor telemetry (before step-index subsampling).
|
||||
|
||||
Returns
|
||||
-------
|
||||
sensors : (N_total_raw, 6) ndarray — the full sensor time series.
|
||||
"""
|
||||
cfg = SCENES.get(scene_name)
|
||||
if cfg is None:
|
||||
raise KeyError(f"Unknown scene: {scene_name}")
|
||||
|
||||
scene_id = cfg["scene_id"]
|
||||
sd = os.path.join(DATA_DIR, scene_id, scene_name)
|
||||
|
||||
tele_path = None
|
||||
for p in [os.path.join(sd, "controlled.npz"), os.path.join(sd, "sensors.npz")]:
|
||||
if os.path.isfile(p):
|
||||
tele_path = p
|
||||
break
|
||||
if tele_path is None:
|
||||
raise FileNotFoundError(f"No telemetry (*.npz) found in {sd}")
|
||||
|
||||
td = np.load(tele_path)
|
||||
sensors = td["sensors"]
|
||||
td.close()
|
||||
return sensors
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Modal overlap
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def modal_overlap(W_a: np.ndarray, W_b: np.ndarray, n_modes: int = 5) -> list:
|
||||
"""Pairwise modal overlap between two CCD direction matrices.
|
||||
|
||||
Returns list of {mode, O} dicts.
|
||||
"""
|
||||
n = min(W_a.shape[1], W_b.shape[1], n_modes)
|
||||
results = []
|
||||
for k in range(n):
|
||||
u_a = W_a[:, k] / (np.linalg.norm(W_a[:, k]) + 1e-12)
|
||||
u_b = W_b[:, k] / (np.linalg.norm(W_b[:, k]) + 1e-12)
|
||||
ov = float(abs(u_a @ u_b))
|
||||
results.append({"mode": k + 1, "O": ov})
|
||||
return results
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# LOCO validation helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def r2_score(y_true: np.ndarray, y_pred: np.ndarray) -> float:
|
||||
"""Coefficient of determination."""
|
||||
ss_r = np.sum((y_true - y_pred) ** 2)
|
||||
ss_t = np.sum((y_true - np.mean(y_true)) ** 2)
|
||||
return float(1.0 - ss_r / (ss_t + 1e-12))
|
||||
|
||||
|
||||
def reconstruct_from_ccd(W, sigma, R, a_test, y_train, CCD_Q, m_obs):
|
||||
"""Reconstruct observable from CCD modes. Returns dict with 'mode1' and 'm80'."""
|
||||
am = np.mean(a_test, axis=1, keepdims=True)
|
||||
as_ = np.std(a_test, axis=1, keepdims=True) + 1e-12
|
||||
a_test_z = (a_test - am) / as_
|
||||
z_test = W.T @ a_test_z
|
||||
|
||||
ym = np.mean(y_train, axis=1, keepdims=True)
|
||||
ys = np.std(y_train, axis=1, keepdims=True) + 1e-12
|
||||
half = CCD_Q // 2
|
||||
|
||||
en = cumulative_energy(sigma)
|
||||
m80 = int(np.searchsorted(en, 0.80) + 1) if len(en) > 0 else 1
|
||||
|
||||
results = {}
|
||||
|
||||
# Mode-1
|
||||
if R.shape[1] >= 1:
|
||||
pz_1 = R[:, :1] * sigma[:1] @ z_test[:1, :]
|
||||
yp_1 = pz_1[half * m_obs:(half + 1) * m_obs, :] * ys + ym
|
||||
results["mode1"] = yp_1
|
||||
else:
|
||||
results["mode1"] = np.zeros_like(y_train[:, :a_test.shape[1]])
|
||||
|
||||
# M80
|
||||
n_rm = min(m80, R.shape[1])
|
||||
if n_rm >= 1:
|
||||
pz_m = R[:, :n_rm] * sigma[:n_rm] @ z_test[:n_rm, :]
|
||||
yp_m = pz_m[half * m_obs:(half + 1) * m_obs, :] * ys + ym
|
||||
results["m80"] = yp_m
|
||||
else:
|
||||
results["m80"] = np.zeros_like(y_train[:, :a_test.shape[1]])
|
||||
|
||||
return results
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
print("=" * 60, flush=True)
|
||||
print("Signature-line CCD on dq_ctl", flush=True)
|
||||
print("=" * 60, flush=True)
|
||||
|
||||
out_dir = os.path.join(DATA_DIR, "ccd")
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
all_results = {}
|
||||
W_dict = {} # CCD direction matrices keyed by label
|
||||
pod_basis_cache = {} # (scene_type, r) -> (mean_field, modes_r)
|
||||
raw_sensors_cache = {} # scene_name -> raw sensors
|
||||
|
||||
# ---- 1. Load correction fields ----
|
||||
print("\n--- Step 1: Loading correction fields ---", flush=True)
|
||||
cache = {}
|
||||
for st in SCENE_TYPES:
|
||||
t0 = time.time()
|
||||
try:
|
||||
corr = compute_correction(st)
|
||||
cache[st] = corr
|
||||
dq = corr["dq_ctl"]
|
||||
if dq is not None:
|
||||
print(f" {st}: dq_ctl {dq['ux'].shape[0]} frames, "
|
||||
f"sensors={'✓' if corr['q_ctl'] is not None else '✗'}, "
|
||||
f"forces={'✓' if dq['forces'] is not None else '✗'}, "
|
||||
f"{time.time()-t0:.1f}s", flush=True)
|
||||
except Exception as e:
|
||||
print(f" {st}: FAILED — {e}", flush=True)
|
||||
|
||||
# ---- 2. Load raw sensor data for all needed scenes ----
|
||||
print("\n--- Step 2: Loading raw sensor telemetry ---", flush=True)
|
||||
for st in SCENE_TYPES:
|
||||
corr = cache.get(st)
|
||||
if corr is None:
|
||||
continue
|
||||
diam = corr.get("diam")
|
||||
tar_name = f"target_cylinder_{diam}L"
|
||||
for name in [st, tar_name]:
|
||||
if name not in raw_sensors_cache:
|
||||
try:
|
||||
raw_sensors_cache[name] = _load_raw_sensors(name)
|
||||
print(f" {name}: raw sensors {raw_sensors_cache[name].shape}", flush=True)
|
||||
except Exception as e:
|
||||
print(f" {name}: FAILED — {e}", flush=True)
|
||||
|
||||
# ---- 3. For each scene, pre-compute POD basis ----
|
||||
print("\n--- Step 3: Building target-only POD basis ---", flush=True)
|
||||
for st in SCENE_TYPES:
|
||||
corr = cache.get(st)
|
||||
if corr is None:
|
||||
continue
|
||||
dq_tar = corr["dq_tar"]
|
||||
if dq_tar is None:
|
||||
print(f" {st}: dq_tar is None, skipping", flush=True)
|
||||
continue
|
||||
|
||||
Q_tar = dict_to_field_matrix(dq_tar)
|
||||
mf_tar, modes_tar, sv_tar, _ = compute_pod(Q_tar)
|
||||
e95 = e95_index(cumulative_energy(sv_tar))
|
||||
print(f" {st}: target-only POD E95={e95}", flush=True)
|
||||
|
||||
for r in R_LIST:
|
||||
pod_basis_cache[(st, r)] = (mf_tar, modes_tar[:, :r])
|
||||
|
||||
# ---- 4. Compute tau_corr for each scene ----
|
||||
print("\n--- Step 4: Computing tau values ---", flush=True)
|
||||
tau_config = {} # scene_type -> list of tau
|
||||
for st in SCENE_TYPES:
|
||||
corr = cache.get(st)
|
||||
if corr is None:
|
||||
continue
|
||||
raw_ctl = raw_sensors_cache.get(st)
|
||||
diam = corr.get("diam")
|
||||
tar_name = f"target_cylinder_{diam}L"
|
||||
raw_tar = raw_sensors_cache.get(tar_name)
|
||||
step_idx = corr["q_ctl"].get("step_indices", [])
|
||||
if step_idx is None or len(step_idx) == 0:
|
||||
step_idx = list(range(corr["q_ctl"]["ux"].shape[0]))
|
||||
|
||||
if raw_ctl is not None and raw_tar is not None:
|
||||
tau_corr = compute_tau_corr(raw_ctl, raw_tar, step_idx)
|
||||
else:
|
||||
tau_corr = TAU_GEOM
|
||||
|
||||
taus = sorted(set([0, TAU_GEOM, tau_corr]))
|
||||
tau_config[st] = taus
|
||||
print(f" {st}: tau_corr={tau_corr}, taus={taus}", flush=True)
|
||||
|
||||
# ---- 5. Signature-CCD and Force-CCD for each (scene, r, tau) ----
|
||||
print("\n--- Step 5: Running CCD ---", flush=True)
|
||||
for st in SCENE_TYPES:
|
||||
corr = cache.get(st)
|
||||
if corr is None:
|
||||
continue
|
||||
dq_ctl = corr["dq_ctl"]
|
||||
dq_tar = corr["dq_tar"]
|
||||
if dq_ctl is None or dq_tar is None:
|
||||
continue
|
||||
|
||||
diam = corr.get("diam")
|
||||
taus = tau_config.get(st, [0, TAU_GEOM])
|
||||
step_idx = corr["q_ctl"].get("step_indices", [])
|
||||
if step_idx is None or len(step_idx) == 0:
|
||||
step_idx = list(range(dq_ctl["ux"].shape[0]))
|
||||
|
||||
raw_ctl = raw_sensors_cache.get(st)
|
||||
diam = corr.get("diam")
|
||||
tar_name = f"target_cylinder_{diam}L"
|
||||
raw_tar = raw_sensors_cache.get(tar_name)
|
||||
|
||||
Q_ctl = dict_to_field_matrix(dq_ctl)
|
||||
N = Q_ctl.shape[1]
|
||||
print(f"\n --- {st} (diam={diam}) ---", flush=True)
|
||||
|
||||
for r in R_LIST:
|
||||
mf_r, modes_r = pod_basis_cache[(st, r)]
|
||||
a_r = modes_r.T @ (Q_ctl - mf_r[:, None]).astype(np.float64)
|
||||
Nv = a_r.shape[1]
|
||||
print(f" r={r}: N={Nv}", flush=True)
|
||||
|
||||
# -- Signature-CCD --
|
||||
if raw_ctl is not None and raw_tar is not None and step_idx is not None:
|
||||
for tau in taus:
|
||||
e_sig = make_signature_obs(raw_ctl, raw_tar, step_idx, tau=tau)
|
||||
# Trim to match a_r length
|
||||
Ne = e_sig.shape[1]
|
||||
a_r_use = a_r[:, :Ne] if Ne < Nv else a_r
|
||||
e_use = e_sig[:, :Nv] if Nv < Ne else e_sig
|
||||
N_use = min(Nv, Ne)
|
||||
|
||||
W, sig, Rmat, z, N_orig, N_valid = compute_reduced_ccd(
|
||||
a_r_use[:, :N_use], e_use[:, :N_use], Q_delay=CCD_Q
|
||||
)
|
||||
en = cumulative_energy(sig)
|
||||
m80 = int(np.searchsorted(en, 0.80) + 1) if len(en) > 0 else 0
|
||||
|
||||
key = f"{st}_sig_tau{tau}_r{r}"
|
||||
W_dict[key] = W
|
||||
all_results[key] = {
|
||||
"scene": st, "diam": diam, "obs": f"sig_tau{tau}", "r": r,
|
||||
"tau": tau, "m80": m80, "N": sig.size, "N_valid": N_valid,
|
||||
"N_original": N_orig,
|
||||
"sigma_top3": [float(sig[i]) for i in range(min(3, len(sig)))],
|
||||
}
|
||||
print(f" {key}: m80={m80} "
|
||||
f"s1={float(sig[0]):.4f} N_valid={N_valid}", flush=True)
|
||||
|
||||
# -- Force-CCD reference (SigmaFy, tau=0) --
|
||||
frc = dq_ctl.get("forces")
|
||||
if frc is not None:
|
||||
y_f = make_force_obs(frc, st, mode="fy")[:, :Nv]
|
||||
W_f, sig_f, _, _, N_orig_f, N_valid_f = compute_reduced_ccd(
|
||||
a_r, y_f, Q_delay=CCD_Q
|
||||
)
|
||||
en_f = cumulative_energy(sig_f)
|
||||
m80_f = int(np.searchsorted(en_f, 0.80) + 1) if len(en_f) > 0 else 0
|
||||
key_f = f"{st}_force_fy_r{r}"
|
||||
W_dict[key_f] = W_f
|
||||
all_results[key_f] = {
|
||||
"scene": st, "diam": diam, "obs": "force_fy", "r": r,
|
||||
"tau": 0, "m80": m80_f, "N": sig_f.size, "N_valid": N_valid_f,
|
||||
"N_original": N_orig_f,
|
||||
"sigma_top3": [float(sig_f[i]) for i in range(min(3, len(sig_f)))],
|
||||
}
|
||||
print(f" {key_f}: m80={m80_f} "
|
||||
f"s1={float(sig_f[0]):.4f} N_valid={N_valid_f}", flush=True)
|
||||
|
||||
# ---- 6. Force vs Signature modal overlap comparison (r=6) ----
|
||||
print("\n\n--- Step 6: Force vs Signature modal overlap (r=6) ---", flush=True)
|
||||
for st in SCENE_TYPES:
|
||||
corr = cache.get(st)
|
||||
if corr is None:
|
||||
continue
|
||||
diam = corr.get("diam")
|
||||
taus = tau_config.get(st, [0, TAU_GEOM])
|
||||
|
||||
force_key = f"{st}_force_fy_r{6}"
|
||||
if force_key not in W_dict:
|
||||
print(f" {st}: no force key, skipping overlap", flush=True)
|
||||
continue
|
||||
|
||||
W_force = W_dict[force_key]
|
||||
print(f"\n {st} (diam={diam}):", flush=True)
|
||||
for tau in taus:
|
||||
sig_key = f"{st}_sig_tau{tau}_r{6}"
|
||||
if sig_key not in W_dict:
|
||||
continue
|
||||
W_sig = W_dict[sig_key]
|
||||
ovs = modal_overlap(W_force, W_sig, n_modes=5)
|
||||
for ov in ovs:
|
||||
key = f"{st}_O_force_vs_sig_tau{tau}_r6_mode{ov['mode']}"
|
||||
all_results[key] = {
|
||||
"scene": st, "diam": diam, "r": 6,
|
||||
"tau_sig": tau, "mode": ov["mode"],
|
||||
"overlap": ov["O"],
|
||||
}
|
||||
ov_str = ", ".join([f"mode{ov['mode']}={ov['O']:.4f}" for ov in ovs])
|
||||
print(f" O(force, sig_tau{tau}) r=6: {ov_str}", flush=True)
|
||||
|
||||
# ---- 7. LOCO validation (signature observable) ----
|
||||
N_PTS = 24
|
||||
N_CYCLES = 4
|
||||
|
||||
print("\n\n--- Step 7: LOCO validation (signature observable, r=6) ---", flush=True)
|
||||
loco_results = {}
|
||||
for st in SCENE_TYPES:
|
||||
corr = cache.get(st)
|
||||
if corr is None:
|
||||
continue
|
||||
dq_ctl = corr["dq_ctl"]
|
||||
dq_tar = corr["dq_tar"]
|
||||
if dq_ctl is None or dq_tar is None:
|
||||
continue
|
||||
|
||||
diam = corr.get("diam")
|
||||
taus = tau_config.get(st, [0, TAU_GEOM])
|
||||
step_idx = corr["q_ctl"].get("step_indices", [])
|
||||
if step_idx is None or len(step_idx) == 0:
|
||||
step_idx = list(range(dq_ctl["ux"].shape[0]))
|
||||
|
||||
tar_name = f"target_cylinder_{diam}L"
|
||||
raw_ctl = raw_sensors_cache.get(st)
|
||||
raw_tar = raw_sensors_cache.get(tar_name)
|
||||
if raw_ctl is None or raw_tar is None:
|
||||
print(f" {st}: raw sensors missing, skipping LOCO", flush=True)
|
||||
continue
|
||||
|
||||
Q_ctl = dict_to_field_matrix(dq_ctl)
|
||||
N_total = Q_ctl.shape[1]
|
||||
|
||||
for tau in taus:
|
||||
# Build the full signature observable
|
||||
e_full = make_signature_obs(raw_ctl, raw_tar, step_idx, tau=tau)
|
||||
|
||||
r = 6
|
||||
fold_r2_m1, fold_r2_m80 = [], []
|
||||
|
||||
for fold in range(N_CYCLES):
|
||||
test_cyc = fold
|
||||
train_cyc = [c for c in range(N_CYCLES) if c != test_cyc]
|
||||
train_idx = sorted([c * N_PTS + p for c in train_cyc for p in range(N_PTS)])
|
||||
test_idx = sorted([c * N_PTS + p for c in [test_cyc] for p in range(N_PTS)])
|
||||
|
||||
# Trim to valid range
|
||||
train_idx = [i for i in train_idx if i < N_total]
|
||||
test_idx = [i for i in test_idx if i < N_total]
|
||||
if len(train_idx) < N_PTS or len(test_idx) < N_PTS // 2:
|
||||
continue
|
||||
|
||||
# Build LOCO POD basis from target-only data
|
||||
Q_tar = dict_to_field_matrix(dq_tar)
|
||||
Q_ref = Q_tar[:, train_idx]
|
||||
mf = np.mean(Q_ref, axis=1)
|
||||
U, _, _ = np.linalg.svd(Q_ref - mf[:, None], full_matrices=False)
|
||||
modes_r = U[:, :r]
|
||||
|
||||
a_train = modes_r.T @ (Q_ctl[:, train_idx] - mf[:, None])
|
||||
a_test = modes_r.T @ (Q_ctl[:, test_idx] - mf[:, None])
|
||||
|
||||
y_train = e_full[:, train_idx]
|
||||
y_test = e_full[:, test_idx]
|
||||
|
||||
# Handle length mismatches
|
||||
na = a_train.shape[1]
|
||||
ny = y_train.shape[1]
|
||||
n_min = min(na, ny)
|
||||
a_train = a_train[:, :n_min]
|
||||
y_train = y_train[:, :n_min]
|
||||
|
||||
try:
|
||||
W, sigma, Rmat, _, _, _ = compute_reduced_ccd(a_train, y_train, Q_delay=CCD_Q)
|
||||
except Exception as exc:
|
||||
print(f" LOCO fold {fold}: CCD failed — {exc}", flush=True)
|
||||
continue
|
||||
|
||||
recon = reconstruct_from_ccd(W, sigma, Rmat, a_test, y_train, CCD_Q, m_obs=6)
|
||||
|
||||
na_test = a_test.shape[1]
|
||||
ny_test = y_test.shape[1]
|
||||
n_test = min(na_test, ny_test)
|
||||
ch_m1 = [r2_score(y_test[c, :n_test], recon["mode1"][c, :n_test])
|
||||
for c in range(min(y_test.shape[0], recon["mode1"].shape[0]))]
|
||||
ch_m80 = [r2_score(y_test[c, :n_test], recon["m80"][c, :n_test])
|
||||
for c in range(min(y_test.shape[0], recon["m80"].shape[0]))]
|
||||
fold_r2_m1.append(float(np.mean(ch_m1)))
|
||||
fold_r2_m80.append(float(np.mean(ch_m80)))
|
||||
|
||||
if fold_r2_m1:
|
||||
key = f"{st}_LOCO_sig_tau{tau}_r{r}"
|
||||
loco_results[key] = {
|
||||
"scene": st, "diam": diam, "tau": tau, "r": r,
|
||||
"mode1": {
|
||||
"mean": float(np.mean(fold_r2_m1)),
|
||||
"std": float(np.std(fold_r2_m1)),
|
||||
},
|
||||
"m80": {
|
||||
"mean": float(np.mean(fold_r2_m80)),
|
||||
"std": float(np.std(fold_r2_m80)),
|
||||
},
|
||||
}
|
||||
print(f" {key}: R2_m1={loco_results[key]['mode1']['mean']:.4f}+-"
|
||||
f"{loco_results[key]['mode1']['std']:.4f} "
|
||||
f"R2_m80={loco_results[key]['m80']['mean']:.4f}+-"
|
||||
f"{loco_results[key]['m80']['std']:.4f}", flush=True)
|
||||
else:
|
||||
print(f" {st} tau={tau}: LOCO skipped (no valid folds)", flush=True)
|
||||
|
||||
all_results["_loco"] = loco_results
|
||||
|
||||
# ---- 8. Save ----
|
||||
out_path = os.path.join(out_dir, "signature_ccd_results.json")
|
||||
with open(out_path, "w") as f:
|
||||
json.dump(all_results, f, indent=2)
|
||||
print(f"\nSaved {len(all_results)} entries to {out_path}", flush=True)
|
||||
|
||||
# ---- 9. Summary ----
|
||||
print("\n" + "=" * 60, flush=True)
|
||||
print("SUMMARY", flush=True)
|
||||
print("=" * 60, flush=True)
|
||||
|
||||
for st in SCENE_TYPES:
|
||||
corr = cache.get(st)
|
||||
if corr is None:
|
||||
continue
|
||||
diam = corr.get("diam")
|
||||
taus = tau_config.get(st, [0, TAU_GEOM])
|
||||
print(f"\n {st} (diam={diam}):", flush=True)
|
||||
|
||||
# Signature R2_m80 from LOCO (r=6)
|
||||
for tau in taus:
|
||||
lk = f"{st}_LOCO_sig_tau{tau}_r{6}"
|
||||
if lk in loco_results:
|
||||
r2_m80 = loco_results[lk]["m80"]["mean"]
|
||||
flag = "✓" if r2_m80 >= 0.4 else "✗"
|
||||
print(f" LOCO sig_tau{tau} R2_m80={r2_m80:.4f} {flag}", flush=True)
|
||||
|
||||
# Overlaps
|
||||
for tau in taus:
|
||||
ok = f"{st}_O_force_vs_sig_tau{tau}_r6_mode1"
|
||||
if ok in all_results:
|
||||
ov = all_results[ok]["overlap"]
|
||||
cat = "shared" if ov > 0.8 else ("partial" if ov > 0.5 else "separated")
|
||||
print(f" O(force, sig_tau{tau}) mode1={ov:.4f} ({cat})", flush=True)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,344 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compute quantitative metrics for steady cloak.
|
||||
|
||||
Measures:
|
||||
- Mean wake restoration (downstream ux profile)
|
||||
- Fluctuation (RMS) suppression ratio
|
||||
- Recirculation zone length (centreline ux < 0)
|
||||
- dq_ctl + dq_blk cancellation quality
|
||||
- Force / power bookkeeping (if forces available)
|
||||
|
||||
Usage:
|
||||
conda run -n pycuda_3_10 python correction_analysis/run_steady_metrics.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
_SRC = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
if _SRC not in sys.path:
|
||||
sys.path.insert(0, _SRC)
|
||||
|
||||
from CCD_analysis.configs import DATA_DIR, NX, NY, L0, CENTER_Y, U0, SCENES
|
||||
from CCD_analysis.correction_analysis.compute_correction_fields import (
|
||||
compute_correction,
|
||||
)
|
||||
from CCD_analysis.correction_analysis.process_legacy_steady import (
|
||||
load_legacy_steady,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sensor / geometry constants (steady_cloak layout)
|
||||
# ---------------------------------------------------------------------------
|
||||
# pinball_front_x = 30.0 * L0 = 600
|
||||
# pinball_rear_x = 31.3 * L0 = 626
|
||||
# sensor_x = 40.0 * L0 = 800
|
||||
SENSOR_X_PX = int(SCENES["steady_cloak"]["sensor_x"] * L0) # ~800
|
||||
FRONT_X_PX = int(SCENES["steady_cloak"]["pinball_front_x"] * L0) # ~600
|
||||
REAR_X_PX = int(SCENES["steady_cloak"]["pinball_rear_x"] * L0) # ~626
|
||||
CY = int(round(CENTER_Y)) # centreline row index
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _crop_sensor_zone(ux: np.ndarray) -> np.ndarray:
|
||||
"""Crop ux to sensor-zone column range around SENSOR_X_PX.
|
||||
|
||||
Handles both 2D (NY, NX) and 3D (N, NY, NX) arrays by always
|
||||
cropping the last (x) axis.
|
||||
"""
|
||||
half = int(NX * 0.1) # ~10 % of total width on each side
|
||||
x0 = max(0, SENSOR_X_PX - half)
|
||||
x1 = min(NX, SENSOR_X_PX + half)
|
||||
# Ellipsis crops the last axis regardless of dimensionality
|
||||
return ux[..., x0:x1]
|
||||
|
||||
|
||||
def _crop_streamwise_centreline(ux_mean: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""Extract centreline ux profile from mean field, trimmed to downstream region.
|
||||
|
||||
Returns
|
||||
-------
|
||||
x_vals : (NX_trim,) pixel indices
|
||||
ux_cl : (NX_trim,) centreline ux values
|
||||
"""
|
||||
# Downstream region: from body trailing edge to domain end
|
||||
x0 = REAR_X_PX - 20 # start a bit before body
|
||||
x_end = min(NX, int(NX * 0.95))
|
||||
ux_cl = ux_mean[CY, x0:x_end]
|
||||
x_vals = np.arange(x0, x_end)
|
||||
return x_vals, ux_cl
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Metrics
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def compute_mean_wake_restoration(
|
||||
dq_blk: dict, dq_ctl: dict, q_in: dict, q_ctl_raw: dict
|
||||
) -> dict[str, Any]:
|
||||
"""Compare downstream ux profiles for blocked vs controlled flow.
|
||||
|
||||
A perfect steady cloak restores the wake to the uniform-channel profile.
|
||||
"""
|
||||
# Mean fields from correction differences
|
||||
ux_blk_mean = np.mean(dq_blk["ux"], axis=0) # (NY, NX) — blockage perturbation
|
||||
ux_ctl_mean = np.mean(dq_ctl["ux"], axis=0) # (NY, NX) — control perturbation
|
||||
|
||||
# Actual mean fields
|
||||
# q_in (target_channel) is the ideal undisturbed profile
|
||||
ux_in_mean = np.mean(q_in["ux"], axis=0) # (NY, NX)
|
||||
|
||||
# q_ctl raw = steady_cloak mean
|
||||
ux_sc_mean = np.mean(q_ctl_raw["ux"], axis=0) # (NY, NX)
|
||||
|
||||
# Centreline profiles
|
||||
x_vals, ux_cl_in = _crop_streamwise_centreline(ux_in_mean)
|
||||
_, ux_cl_sc = _crop_streamwise_centreline(ux_sc_mean)
|
||||
_, ux_cl_blk = _crop_streamwise_centreline(ux_blk_mean)
|
||||
_, ux_cl_ctl = _crop_streamwise_centreline(ux_ctl_mean)
|
||||
|
||||
# Sensor-zone averaged ux (over the full field, not just centreline)
|
||||
sz_blk = np.mean(_crop_sensor_zone(dq_blk["ux"]))
|
||||
sz_ctl = np.mean(_crop_sensor_zone(dq_ctl["ux"]))
|
||||
|
||||
# Wake restoration metric: RMS deviation from target channel in sensor zone
|
||||
_, ux_sz_in = _crop_sensor_zone(ux_in_mean), None # not used for deviation
|
||||
ux_sz_sc = _crop_sensor_zone(ux_sc_mean)
|
||||
ux_sz_tc = _crop_sensor_zone(ux_in_mean)
|
||||
dev_sc = np.std(ux_sz_sc - ux_sz_tc)
|
||||
|
||||
return {
|
||||
"sensor_zone_mean_ux_blk": float(np.mean(sz_blk)),
|
||||
"sensor_zone_mean_ux_ctl": float(np.mean(sz_ctl)),
|
||||
"centreline_ux_blk_mean": float(np.mean(ux_cl_blk)),
|
||||
"centreline_ux_ctl_mean": float(np.mean(ux_cl_ctl)),
|
||||
"sensor_zone_deviation_from_channel": float(dev_sc),
|
||||
}
|
||||
|
||||
|
||||
def compute_rms_suppression(dq_blk: dict, dq_ctl: dict) -> dict[str, Any]:
|
||||
"""Compute RMS fluctuation suppression ratio.
|
||||
|
||||
suppression_ratio = 1 - RMS(rms_ctl) / RMS(rms_blk)
|
||||
where rms is computed per-pixel over the snapshot dimension.
|
||||
|
||||
A value of 1.0 = perfect suppression, 0.0 = no suppression.
|
||||
"""
|
||||
rms_blk = np.std(dq_blk["ux"], axis=0) # (NY, NX)
|
||||
rms_ctl = np.std(dq_ctl["ux"], axis=0) # (NY, NX)
|
||||
|
||||
global_rms_blk = np.sqrt(np.mean(rms_blk**2))
|
||||
global_rms_ctl = np.sqrt(np.mean(rms_ctl**2))
|
||||
|
||||
suppression_ratio = 1.0 - global_rms_ctl / max(global_rms_blk, 1e-15)
|
||||
|
||||
# Sensor-zone specific
|
||||
sz_blk = _crop_sensor_zone(rms_blk)
|
||||
sz_ctl = _crop_sensor_zone(rms_ctl)
|
||||
sz_suppression = 1.0 - np.mean(sz_ctl) / max(np.mean(sz_blk), 1e-15)
|
||||
|
||||
return {
|
||||
"global_RMS_blk": float(global_rms_blk),
|
||||
"global_RMS_ctl": float(global_rms_ctl),
|
||||
"suppression_ratio": float(suppression_ratio),
|
||||
"sensor_zone_RMS_blk": float(np.mean(sz_blk)),
|
||||
"sensor_zone_RMS_ctl": float(np.mean(sz_ctl)),
|
||||
"sensor_zone_suppression": float(sz_suppression),
|
||||
}
|
||||
|
||||
|
||||
def compute_recirculation_zone(q_ctl_raw: dict) -> dict[str, Any]:
|
||||
"""Find recirculation zone length from mean ux of steady_cloak field.
|
||||
|
||||
Recirculation length: streamwise distance from body trailing edge
|
||||
to the point where centreline ux recovers to >= 0.
|
||||
"""
|
||||
ux_mean = np.mean(q_ctl_raw["ux"], axis=0) # (NY, NX)
|
||||
x_vals, ux_cl = _crop_streamwise_centreline(ux_mean)
|
||||
|
||||
# Find first point (downstream of body) where ux returns to >= 0
|
||||
neg = ux_cl < 0
|
||||
if not np.any(neg):
|
||||
recirc_len = 0.0
|
||||
x_recovery = None
|
||||
else:
|
||||
# Find the last negative index in this trimmed region
|
||||
neg_indices = np.where(neg)[0]
|
||||
last_neg = neg_indices[-1]
|
||||
x_recovery = int(x_vals[last_neg])
|
||||
# Distance from rear cylinder in pixel units, convert to L0
|
||||
recirc_len = (x_recovery - REAR_X_PX) / L0
|
||||
|
||||
# Also report min centreline ux
|
||||
min_ux = float(np.min(ux_cl))
|
||||
|
||||
return {
|
||||
"recirculation_length_L0": float(recirc_len) if recirc_len is not None else 0.0,
|
||||
"recirculation_x_recovery_px": x_recovery,
|
||||
"centreline_min_ux": min_ux,
|
||||
}
|
||||
|
||||
|
||||
def compute_cancellation_quality(dq_blk: dict, dq_ctl: dict) -> dict[str, Any]:
|
||||
"""Compute residual cancellation quality.
|
||||
|
||||
For perfect steady cloak: dq_ctl ≈ -dq_blk (control cancels blockage).
|
||||
measured by: cancellation_ratio = RMS(dq_ctl + dq_blk) / RMS(dq_blk)
|
||||
(lower is better, 0.0 = perfect cancellation)
|
||||
"""
|
||||
residual_ux = dq_ctl["ux"] + dq_blk["ux"] # (N, NY, NX)
|
||||
rms_residual = np.std(residual_ux)
|
||||
rms_blk = np.std(dq_blk["ux"])
|
||||
|
||||
cancel_ratio = rms_residual / max(rms_blk, 1e-15)
|
||||
|
||||
# Sensor-zone specific
|
||||
sz_res = _crop_sensor_zone(residual_ux)
|
||||
sz_blk_rms = np.std(_crop_sensor_zone(dq_blk["ux"]))
|
||||
sz_cancel = np.std(sz_res) / max(sz_blk_rms, 1e-15)
|
||||
|
||||
return {
|
||||
"cancellation_ratio": float(cancel_ratio),
|
||||
"sensor_zone_cancellation_ratio": float(sz_cancel),
|
||||
"residual_RMS": float(rms_residual),
|
||||
"blockage_RMS": float(rms_blk),
|
||||
}
|
||||
|
||||
|
||||
def compute_force_bookkeeping(
|
||||
dq_blk: dict, dq_ctl: dict
|
||||
) -> dict[str, Any]:
|
||||
"""Estimate drag from field data.
|
||||
|
||||
Since steady_cloak sensors.npz does not contain force telemetry,
|
||||
we estimate drag proxy from the momentum deficit in the wake.
|
||||
|
||||
drag_proxy = integral of (U0 - ux) across a wake profile
|
||||
(qualitative comparison only, not calibrated to actual drag)
|
||||
"""
|
||||
# Use mean ux from blockage (pinball - channel) and control (steady_cloak - pinball)
|
||||
ux_blk_m = np.mean(dq_blk["ux"], axis=0) # blockage perturbation
|
||||
ux_ctl_m = np.mean(dq_ctl["ux"], axis=0) # control perturbation
|
||||
|
||||
# Mean flow = blockage + channel for pinball; control restores toward channel
|
||||
# Take a wake profile at sensor_x location
|
||||
# Drag proxy: momentum deficit across channel height
|
||||
# Positive deficit means flow slower than free-stream
|
||||
deficit_blk = float(np.trapz(-ux_blk_m[:, SENSOR_X_PX])) if SENSOR_X_PX < NX else 0.0
|
||||
deficit_ctl = float(np.trapz(-ux_ctl_m[:, SENSOR_X_PX])) if SENSOR_X_PX < NX else 0.0
|
||||
deficit_ratio = deficit_ctl / max(abs(deficit_blk), 1e-15)
|
||||
|
||||
return {
|
||||
"drag_proxy_blockage": deficit_blk,
|
||||
"drag_proxy_control": deficit_ctl,
|
||||
"drag_proxy_ratio": deficit_ratio,
|
||||
"note": "drag proxy from ux deficit at sensor plane; no actual force telemetry available",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def run() -> dict[str, Any]:
|
||||
print("=" * 60)
|
||||
print("Steady Cloak Quantitative Metrics")
|
||||
print("=" * 60)
|
||||
|
||||
# -- 1. Load correction fields --
|
||||
print("\n--- Loading correction fields ---")
|
||||
corr = compute_correction("steady_cloak")
|
||||
dq_blk = corr["dq_blk"] # q_blk - q_in (pinball blockage)
|
||||
dq_ctl = corr["dq_ctl"] # q_ctl - q_blk (control correction)
|
||||
q_in = corr["q_in"] # target_channel (undisturbed)
|
||||
N = corr["N"]
|
||||
print(f" Aligned frames: {N}")
|
||||
|
||||
# -- 2. Load steady_cloak raw data (for true mean field) --
|
||||
print("\n--- Loading steady_cloak raw fields ---")
|
||||
sc_raw = load_legacy_steady("steady_cloak")
|
||||
print(f" ux shape: {sc_raw['ux'].shape}")
|
||||
|
||||
# -- 3. Compute metrics --
|
||||
print("\n--- Computing wake restoration ---")
|
||||
wake = compute_mean_wake_restoration(dq_blk, dq_ctl, q_in, sc_raw)
|
||||
|
||||
print("\n--- Computing RMS suppression ---")
|
||||
rms = compute_rms_suppression(dq_blk, dq_ctl)
|
||||
|
||||
print("\n--- Computing recirculation zone ---")
|
||||
recirc = compute_recirculation_zone(sc_raw)
|
||||
|
||||
print("\n--- Computing cancellation quality ---")
|
||||
cancel = compute_cancellation_quality(dq_blk, dq_ctl)
|
||||
|
||||
print("\n--- Computing drag bookkeeping ---")
|
||||
drag = compute_force_bookkeeping(dq_blk, dq_ctl)
|
||||
|
||||
# -- 4. Assemble --
|
||||
metrics = {
|
||||
"scene": "steady_cloak",
|
||||
"N_frames": int(N),
|
||||
"N_raw": int(sc_raw["ux"].shape[0]),
|
||||
"recirculation_zone": recirc,
|
||||
"wake_restoration": wake,
|
||||
"rms_suppression": rms,
|
||||
"cancellation_quality": cancel,
|
||||
"force_bookkeeping": drag,
|
||||
}
|
||||
|
||||
# -- 5. Save --
|
||||
os.makedirs(os.path.join(DATA_DIR, "ccd"), exist_ok=True)
|
||||
out_path = os.path.join(DATA_DIR, "ccd", "steady_metrics.json")
|
||||
with open(out_path, "w") as f:
|
||||
json.dump(metrics, f, indent=2)
|
||||
print(f"\nMetrics saved to {out_path}")
|
||||
|
||||
# -- 6. Print summary --
|
||||
_print_summary(metrics)
|
||||
|
||||
return metrics
|
||||
|
||||
|
||||
def _print_summary(m: dict):
|
||||
r = m["recirculation_zone"]
|
||||
w = m["wake_restoration"]
|
||||
rms = m["rms_suppression"]
|
||||
c = m["cancellation_quality"]
|
||||
d = m["force_bookkeeping"]
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("=== Steady Cloak Metrics ===")
|
||||
print("=" * 60)
|
||||
|
||||
# Drag bookkeeping
|
||||
print(f"Drag proxy (deficit area): blockage={d['drag_proxy_blockage']:.4f}, "
|
||||
f"control={d['drag_proxy_control']:.4f}")
|
||||
|
||||
# Fluctuation suppression
|
||||
print(f"Fluctuation suppression (global): {rms['suppression_ratio']*100:.1f}%")
|
||||
print(f"Fluctuation suppression (sensor zone): {rms['sensor_zone_suppression']*100:.1f}%")
|
||||
|
||||
# Recirculation
|
||||
print(f"Recirculation length: {r['recirculation_length_L0']:.2f} (L0 units)")
|
||||
print(f"Centreline min ux: {r['centreline_min_ux']:.6f}")
|
||||
|
||||
# Cancellation
|
||||
print(f"dq_ctl + dq_blk cancellation ratio: {c['cancellation_ratio']:.4f}")
|
||||
print(f"Sensor-zone cancellation ratio: {c['sensor_zone_cancellation_ratio']:.4f}")
|
||||
|
||||
# Wake restoration
|
||||
print(f"Sensor-zone deviation from channel (RMS): {w['sensor_zone_deviation_from_channel']:.6f}")
|
||||
print(f"Note: {d['note']}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
@@ -1,365 +0,0 @@
|
||||
"""Zone-wise CCD: force-CCD and signature-CCD per spatial zone.
|
||||
|
||||
Processes each of three spatial zones separately for illusion 0.75L and 1.0L
|
||||
(unified geometry: pinball center ~613px, sensors at 800px):
|
||||
- near_body: x[580:720] (envelope around pinball)
|
||||
- body_wake: x[720:850] (body-connected near wake)
|
||||
- sensor_zone: x[780:850] (around sensor plane at x=40*L0=800)
|
||||
|
||||
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 unified geometry (pinball center ~613px, sensors at 800px).
|
||||
|
||||
Updated 2026-06-28 to match unified geometry after all scenes were re-collected
|
||||
with pinball at front x=30*L0=600, rear x=31.3*L0=626 (center ~613px), sensors at 40*L0=800.
|
||||
Same zones as diagnose_corrections.py:define_zones().
|
||||
"""
|
||||
zones = {}
|
||||
|
||||
# near_body: envelope around pinball (center ~613px)
|
||||
mask = np.zeros((NY, NX), dtype=bool)
|
||||
mask[:, 580:720] = True
|
||||
zones["near_body"] = mask
|
||||
|
||||
# body_wake: near wake downstream of pinball
|
||||
mask = np.zeros((NY, NX), dtype=bool)
|
||||
mask[:, 720:850] = True
|
||||
zones["body_wake"] = mask
|
||||
|
||||
# sensor_zone: around sensors at x=800 (40*L0)
|
||||
mask = np.zeros((NY, NX), dtype=bool)
|
||||
mask[:, 780:850] = True
|
||||
zones["sensor_zone"] = mask
|
||||
|
||||
return zones
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Masking helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def mask_field_matrix(Q_full: np.ndarray, ny: int, nx: int,
|
||||
mask: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""Zero out all grid points outside the mask.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
Q_full : (2*nx*ny, N) ndarray
|
||||
Snapshot matrix — first half = ux, second half = uy.
|
||||
mask : (ny, nx) ndarray
|
||||
Boolean mask, True = keep.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Q_masked : (2*n_sum, N) ndarray
|
||||
Masked snapshot matrix.
|
||||
ux_idx : (n_sum,) ndarray
|
||||
Indices into the original ux ravel for kept points.
|
||||
"""
|
||||
mask_flat = mask.ravel() # (ny*nx,)
|
||||
ux_idx = np.where(mask_flat)[0]
|
||||
uy_idx = ux_idx + nx * ny
|
||||
all_idx = np.concatenate([ux_idx, uy_idx])
|
||||
return Q_full[all_idx, :], ux_idx
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# tau_corr heuristic (from run_15L_correction.py)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def compute_tau_corr(a_ctl: np.ndarray, e_s: np.ndarray,
|
||||
max_lag: int = 12) -> int:
|
||||
"""Find tau that maximises |cross-correlation| between a1 and sensor error."""
|
||||
a1 = a_ctl[0, :]
|
||||
n = len(a1)
|
||||
a1_z = (a1 - a1.mean()) / (a1.std() + 1e-12)
|
||||
corr_avg = np.zeros(2 * max_lag + 1)
|
||||
for ch in range(e_s.shape[0]):
|
||||
ech = e_s[ch, :n]
|
||||
ech_z = (ech - ech.mean()) / (ech.std() + 1e-12)
|
||||
c = np.correlate(a1_z, ech_z, mode="full")
|
||||
c_mid = len(c) // 2
|
||||
seg = c[c_mid - max_lag:c_mid + max_lag + 1]
|
||||
corr_avg += np.abs(seg)
|
||||
corr_avg /= e_s.shape[0]
|
||||
best_lag = np.argmax(corr_avg) - max_lag
|
||||
return int(best_lag)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
print("=" * 60, flush=True)
|
||||
print("Zone-wise CCD: force + signature per spatial zone", flush=True)
|
||||
print("=" * 60, flush=True)
|
||||
|
||||
out_dir = os.path.join(DATA_DIR, "ccd")
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
all_results = {}
|
||||
|
||||
scene_types = ["illusion_0.75L", "illusion_1.0L"]
|
||||
zones = _define_zones()
|
||||
|
||||
for scene_type in scene_types:
|
||||
print(f"\n{'=' * 60}", flush=True)
|
||||
print(f"Processing: {scene_type}", flush=True)
|
||||
print(f"{'=' * 60}", flush=True)
|
||||
|
||||
# ---- 1. Load correction fields ----
|
||||
t0 = time.time()
|
||||
corr = compute_correction(scene_type)
|
||||
dq_ctl = corr["dq_ctl"]
|
||||
dq_tar = corr["dq_tar"]
|
||||
diam = corr.get("diam")
|
||||
t_elapsed = time.time() - t0
|
||||
|
||||
if dq_ctl is None:
|
||||
print(" dq_ctl is None — cannot proceed.", flush=True)
|
||||
continue
|
||||
if dq_tar is None:
|
||||
print(" dq_tar is None — cannot proceed.", flush=True)
|
||||
continue
|
||||
|
||||
N = dq_ctl["ux"].shape[0]
|
||||
print(f" N={N}, diam={diam}, load_time={t_elapsed:.1f}s", flush=True)
|
||||
|
||||
# ---- 2. Build full snapshot matrices ----
|
||||
Q_ctl = dict_to_field_matrix(dq_ctl) # (2*NX*NY, N)
|
||||
Q_tar = dict_to_field_matrix(dq_tar) # (2*NX*NY, N_tar)
|
||||
print(f" Q_ctl: {Q_ctl.shape}, Q_tar: {Q_tar.shape}", flush=True)
|
||||
|
||||
# ---- 3. Extract global observables (unmasked) ----
|
||||
# Force observable (SigmaFy)
|
||||
frc = dq_ctl.get("forces")
|
||||
if frc is None:
|
||||
print(" No force data — skipping.", flush=True)
|
||||
continue
|
||||
y_force = make_force_obs(frc, scene_type, mode="fy")[:, :N] # (1, N)
|
||||
|
||||
# Sensor error e_s = sensors_ctl - sensors_tar
|
||||
sensors_ctl = dq_ctl.get("sensors")
|
||||
sensors_tar = dq_tar.get("sensors")
|
||||
if sensors_ctl is None or sensors_tar is None:
|
||||
print(" Sensor data incomplete — skipping.", flush=True)
|
||||
continue
|
||||
n_min = min(sensors_ctl.shape[0], sensors_tar.shape[0], N)
|
||||
e_s = (sensors_ctl[:n_min] - sensors_tar[:n_min]).T # (6, N)
|
||||
|
||||
print(f" y_force: {y_force.shape}, e_s: {e_s.shape}", flush=True)
|
||||
|
||||
# ---- 4. Process each zone ----
|
||||
for zname, zmask in zones.items():
|
||||
n_masked = int(zmask.sum())
|
||||
print(f"\n --- Zone: {zname} (N_grid={n_masked}) ---", flush=True)
|
||||
|
||||
# Mask snapshot matrices
|
||||
Q_ctl_m, _ = mask_field_matrix(Q_ctl, NY, NX, zmask)
|
||||
Q_tar_m, _ = mask_field_matrix(Q_tar, NY, NX, zmask)
|
||||
n_field = Q_ctl_m.shape[0]
|
||||
print(f" Masked field dim: {n_field}", flush=True)
|
||||
|
||||
# Build target-only POD basis from masked dq_tar
|
||||
mf_tar, modes_tar, sv_tar, _ = compute_pod(Q_tar_m)
|
||||
en_tar = cumulative_energy(sv_tar)
|
||||
e95 = int(np.searchsorted(en_tar, 0.95) + 1) if len(en_tar) > 0 else 0
|
||||
print(f" Target POD: E95={e95}, "
|
||||
f"N_modes={len(sv_tar)}", flush=True)
|
||||
|
||||
# Project masked dq_ctl into masked target basis
|
||||
proj_mean = mf_tar[:, None]
|
||||
a_ctl_all = modes_tar.T @ (Q_ctl_m - proj_mean).astype(np.float64)
|
||||
a_r = a_ctl_all[:R, :] # (R, N)
|
||||
|
||||
# ---- Force-CCD (SigmaFy) ----
|
||||
W_f, sig_f, _, _, _, _ = compute_reduced_ccd(
|
||||
a_r, y_force, Q_delay=CCD_Q)
|
||||
en_f = cumulative_energy(sig_f)
|
||||
m80_f = int(np.searchsorted(en_f, 0.80) + 1) if len(en_f) > 0 else 0
|
||||
frc_key = f"{scene_type}_{zname}_force_fy_r{R}"
|
||||
all_results[frc_key] = {
|
||||
"scene": scene_type,
|
||||
"zone": zname,
|
||||
"r": R,
|
||||
"N_masked_grid": n_masked,
|
||||
"m80": m80_f,
|
||||
"N_modes": int(sig_f.size),
|
||||
"sigma_top3": [float(sig_f[i])
|
||||
for i in range(min(3, len(sig_f)))],
|
||||
}
|
||||
s1_f = sig_f[0]
|
||||
s2_f = sig_f[1] if len(sig_f) > 1 else float('nan')
|
||||
s3_f = sig_f[2] if len(sig_f) > 2 else float('nan')
|
||||
print(f" force_fy r={R}: m80={m80_f}, "
|
||||
f"s1={s1_f:.4f}, s2={s2_f:.4f}, s3={s3_f:.4f}",
|
||||
flush=True)
|
||||
|
||||
# ---- Compute tau_corr for this zone ----
|
||||
tau_corr = compute_tau_corr(a_ctl_all, e_s, max_lag=12)
|
||||
tau_candidates = [("tau0", 0), ("tau_corr", tau_corr)]
|
||||
print(f" tau_corr = {tau_corr}", flush=True)
|
||||
|
||||
# ---- Signature-CCD ----
|
||||
for tau_label, tau in tau_candidates:
|
||||
# Shift sensor error forward by tau
|
||||
if tau >= 0:
|
||||
y_sig = e_s[:, tau: tau + N]
|
||||
a_r_aligned = a_r[:, :N - tau] if tau > 0 else a_r
|
||||
else:
|
||||
y_sig = e_s[:, :N + tau]
|
||||
a_r_aligned = a_r[:, -tau:]
|
||||
|
||||
y_sig_aligned = y_sig[:, :a_r_aligned.shape[1]]
|
||||
|
||||
if y_sig_aligned.shape[1] < CCD_Q:
|
||||
print(f" tau={tau}: too few samples "
|
||||
f"({y_sig_aligned.shape[1]}), skipping", flush=True)
|
||||
continue
|
||||
|
||||
W_s, sig_s, _, _, _, _ = compute_reduced_ccd(
|
||||
a_r_aligned, y_sig_aligned, Q_delay=CCD_Q)
|
||||
|
||||
en_s = cumulative_energy(sig_s)
|
||||
m80_s = (int(np.searchsorted(en_s, 0.80) + 1)
|
||||
if len(en_s) > 0 else 0)
|
||||
sig_key = f"{scene_type}_{zname}_sig_{tau_label}_r{R}"
|
||||
all_results[sig_key] = {
|
||||
"scene": scene_type,
|
||||
"zone": zname,
|
||||
"r": R,
|
||||
"tau": tau,
|
||||
"N_masked_grid": n_masked,
|
||||
"m80": m80_s,
|
||||
"N_modes": int(sig_s.size),
|
||||
"sigma_top3": [float(sig_s[i])
|
||||
for i in range(min(3, len(sig_s)))],
|
||||
}
|
||||
s1_s = sig_s[0]
|
||||
s2_s = sig_s[1] if len(sig_s) > 1 else float('nan')
|
||||
s3_s = sig_s[2] if len(sig_s) > 2 else float('nan')
|
||||
print(f" sig_{tau_label}: m80={m80_s}, "
|
||||
f"s1={s1_s:.4f}, s2={s2_s:.4f}, s3={s3_s:.4f}",
|
||||
flush=True)
|
||||
|
||||
# ---- Overlap O(force, sig) ----
|
||||
w_f0 = W_f[:, 0] / (np.linalg.norm(W_f[:, 0]) + 1e-12)
|
||||
w_s0 = W_s[:, 0] / (np.linalg.norm(W_s[:, 0]) + 1e-12)
|
||||
overlap = float(abs(w_f0 @ w_s0))
|
||||
|
||||
ov_key = f"{scene_type}_{zname}_O_force_vs_sig_{tau_label}_r{R}"
|
||||
all_results[ov_key] = {
|
||||
"scene": scene_type,
|
||||
"zone": zname,
|
||||
"r": R,
|
||||
"tau": tau,
|
||||
"overlap": overlap,
|
||||
}
|
||||
print(f" O(force, sig)_{tau_label}: {overlap:.4f}",
|
||||
flush=True)
|
||||
|
||||
# ---- 5. Save results ----
|
||||
ccd_path = os.path.join(out_dir, "zone_ccd_results.json")
|
||||
with open(ccd_path, "w") as f:
|
||||
json.dump(all_results, f, indent=2)
|
||||
print(f"\nSaved {len(all_results)} entries to {ccd_path}", flush=True)
|
||||
|
||||
# ---- 6. Print summary table ----
|
||||
print("\n" + "=" * 80, flush=True)
|
||||
print("SUMMARY: Zone CCD Results", flush=True)
|
||||
print("=" * 80, flush=True)
|
||||
|
||||
for scene_type in scene_types:
|
||||
print(f"\n{'=' * 70}", flush=True)
|
||||
print(f" {scene_type}", flush=True)
|
||||
print(f"{'=' * 70}", flush=True)
|
||||
header = (
|
||||
f" {'Zone':<15s} | {'N_masked':>9s} | "
|
||||
f"{'force_fy':>20s} | {'sig_tau0':>20s} | {'sig_tau_corr':>22s} | "
|
||||
f"{'O_0':>6s} | {'O_corr':>6s}"
|
||||
)
|
||||
sep = " " + "-" * (15 + 9 + 20 + 20 + 22 + 6 + 6 + 12)
|
||||
print(header, flush=True)
|
||||
print(sep, flush=True)
|
||||
|
||||
for zname in zones:
|
||||
n_pts = all_results.get(
|
||||
f"{scene_type}_{zname}_force_fy_r{R}", {}
|
||||
).get("N_masked_grid", 0)
|
||||
|
||||
fd = all_results.get(f"{scene_type}_{zname}_force_fy_r{R}", {})
|
||||
sd0 = all_results.get(f"{scene_type}_{zname}_sig_tau0_r{R}", {})
|
||||
sdc = all_results.get(f"{scene_type}_{zname}_sig_tau_corr_r{R}", {})
|
||||
od0 = all_results.get(
|
||||
f"{scene_type}_{zname}_O_force_vs_sig_tau0_r{R}", {})
|
||||
odc = all_results.get(
|
||||
f"{scene_type}_{zname}_O_force_vs_sig_tau_corr_r{R}", {})
|
||||
|
||||
def fmt_ccd(d):
|
||||
m = d.get("m80", "-")
|
||||
s1 = d.get("sigma_top3", ["-"])[0]
|
||||
if isinstance(s1, float):
|
||||
return f"m80={m} s1={s1:.4f}"
|
||||
return f"m80={m} s1={s1}"
|
||||
|
||||
def fmt_ov(d):
|
||||
v = d.get("overlap", "-")
|
||||
if isinstance(v, float):
|
||||
return f"{v:.4f}"
|
||||
return f"{v}"
|
||||
|
||||
print(
|
||||
f" {zname:<15s} | {n_pts:>9d} | "
|
||||
f"{fmt_ccd(fd):>20s} | {fmt_ccd(sd0):>20s} | "
|
||||
f"{fmt_ccd(sdc):>22s} | {fmt_ov(od0):>6s} | "
|
||||
f"{fmt_ov(odc):>6s}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
print(f"\n{'=' * 80}", flush=True)
|
||||
print("Done. Zone CCD analysis complete.", flush=True)
|
||||
print(f"{'=' * 80}", flush=True)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,126 +0,0 @@
|
||||
"""Action-CCD mode 1 visualization for cloak scenes.
|
||||
|
||||
Action-CCD finds correction-field structures most correlated with cylinder
|
||||
rotation speeds. For cloak scenes (steady/karman/vortex), this should reveal
|
||||
the structures the controller directly modulates — clean velocity deficit
|
||||
compensation and cylinder dipoles, excluding upstream disturbance structures.
|
||||
|
||||
Usage:
|
||||
conda run -n pycuda_3_10 python correction_analysis/visualize_action_ccd.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
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 DATA_DIR, NX, NY, L0
|
||||
from CCD_analysis.utils.resampling import (
|
||||
compute_pod, cumulative_energy, e95_index, compute_reduced_ccd,
|
||||
unstack_velocity_modes,
|
||||
)
|
||||
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)
|
||||
|
||||
CLOAK_SCENES = ["steady_cloak", "vortex_lamb", "vortex_taylor"]
|
||||
# karman_re100 excluded due to 72 vs 96 frame mismatch
|
||||
|
||||
R = 10
|
||||
CCD_Q = 6
|
||||
CROP_X0, CROP_X1 = 300, 1100
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 60)
|
||||
print("Action-CCD Mode 1: Cloak scenes")
|
||||
print("=" * 60)
|
||||
|
||||
for st in CLOAK_SCENES:
|
||||
print(f"\n--- {st} ---", flush=True)
|
||||
|
||||
# Load correction fields
|
||||
corr = compute_correction(st)
|
||||
dq_ctl = corr.get("dq_ctl")
|
||||
if dq_ctl is None or dq_ctl.get("actions") is None:
|
||||
print(f" SKIP: no dq_ctl or no actions")
|
||||
continue
|
||||
|
||||
# Build snapshot matrix and compute POD
|
||||
Q = dict_to_field_matrix(dq_ctl)
|
||||
N = Q.shape[1]
|
||||
mf, modes, sv, coeffs = compute_pod(Q)
|
||||
e95 = e95_index(cumulative_energy(sv))
|
||||
print(f" POD: E95={e95}, N_modes={len(sv)}")
|
||||
|
||||
# Action-CCD: find structures correlated with actions
|
||||
a_r = coeffs[:R, :]
|
||||
actions = dq_ctl["actions"][:N].T # (3, N)
|
||||
W, sigma, _, _, _, _ = compute_reduced_ccd(a_r, actions, Q_delay=CCD_Q)
|
||||
|
||||
print(f" Action-CCD: sigma[0]={sigma[0]:.4f}, sigma_top3={sigma[:3]}")
|
||||
|
||||
# Reconstruct CCD mode 1 in physical space
|
||||
# z1 = W[:, 0] @ A_z → CCD temporal coefficient
|
||||
# CCD mode = sum over POD modes of (CCD direction weights * POD mode)
|
||||
w1 = W[:, 0] / (np.linalg.norm(W[:, 0]) + 1e-12)
|
||||
ccd_mode1 = modes[:, :R] @ w1 # (2*NX*NY,)
|
||||
|
||||
# Unstack into ux, uy
|
||||
half = NX * NY
|
||||
ux_mode = ccd_mode1[:half].reshape(NY, NX)
|
||||
uy_mode = ccd_mode1[half:].reshape(NY, NX)
|
||||
|
||||
# Plot mode 1: ux + uy + vorticity, cropped
|
||||
vor = np.gradient(uy_mode, axis=1) - np.gradient(ux_mode, axis=0)
|
||||
|
||||
fig, axes = plt.subplots(1, 3, figsize=(14, 4))
|
||||
extent = (CROP_X0, CROP_X1, 0, NY - 1)
|
||||
|
||||
# ux
|
||||
vmax_ux = max(abs(ux_mode).max(), 1e-12)
|
||||
axes[0].imshow(ux_mode[:, CROP_X0:CROP_X1], cmap="RdBu_r",
|
||||
vmin=-vmax_ux, vmax=vmax_ux,
|
||||
origin="lower", aspect="equal", extent=extent)
|
||||
axes[0].set_title(f"{st}: Action-CCD mode 1 ux")
|
||||
|
||||
# uy
|
||||
vmax_uy = max(abs(uy_mode).max(), 1e-12)
|
||||
axes[1].imshow(uy_mode[:, CROP_X0:CROP_X1], cmap="RdBu_r",
|
||||
vmin=-vmax_uy, vmax=vmax_uy,
|
||||
origin="lower", aspect="equal", extent=extent)
|
||||
axes[1].set_title(f"{st}: Action-CCD mode 1 uy")
|
||||
|
||||
# vorticity
|
||||
vmax_vor = max(np.percentile(abs(vor), 99), 1e-12)
|
||||
axes[2].imshow(vor[:, CROP_X0:CROP_X1], cmap="RdBu_r",
|
||||
vmin=-vmax_vor, vmax=vmax_vor,
|
||||
origin="lower", aspect="equal", extent=extent)
|
||||
axes[2].set_title(f"{st}: Action-CCD mode 1 vorticity")
|
||||
|
||||
for ax in axes:
|
||||
ax.tick_params(left=False, right=False, labelleft=False,
|
||||
bottom=False, top=False, labelbottom=False)
|
||||
|
||||
plt.tight_layout()
|
||||
path = os.path.join(FIG_DIR, f"action_ccd_mode1_{st}.png")
|
||||
fig.savefig(path, dpi=150, bbox_inches="tight")
|
||||
plt.close(fig)
|
||||
print(f" Saved: {path}")
|
||||
|
||||
print("\nDone!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,13 @@
|
||||
"""CPU-only, strict same-time direct correction-field analysis."""
|
||||
from .analysis import (
|
||||
CLOSURE_ATOL, MIN_ANALYSIS_FLUID_POINTS, analysis_mask, compute_estimands,
|
||||
parse_station_token, phase_conditioned_outputs, profile_metrics, segmented_trapezoid, select_relative_interval, station_index,
|
||||
vorticity, weighted_vector_rms,
|
||||
)
|
||||
from .io import load_acquisition_artifact, load_matched_inputs, load_result, load_result_metadata_unverified
|
||||
|
||||
__all__ = [
|
||||
"CLOSURE_ATOL", "MIN_ANALYSIS_FLUID_POINTS", "analysis_mask", "compute_estimands",
|
||||
"parse_station_token", "phase_conditioned_outputs", "profile_metrics", "segmented_trapezoid", "select_relative_interval", "station_index",
|
||||
"vorticity", "weighted_vector_rms", "load_acquisition_artifact", "load_matched_inputs", "load_result", "load_result_metadata_unverified",
|
||||
]
|
||||
@@ -0,0 +1,3 @@
|
||||
from .cli import main
|
||||
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,217 @@
|
||||
"""Pure NumPy direct correction-field arithmetic and physical summaries."""
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import Any, Mapping, Sequence
|
||||
import numpy as np
|
||||
|
||||
CLOSURE_ATOL = 1.0e-6
|
||||
MIN_ANALYSIS_FLUID_POINTS = 4
|
||||
|
||||
|
||||
def coordinate_weights(coordinate: np.ndarray) -> np.ndarray:
|
||||
value = np.asarray(coordinate)
|
||||
if value.ndim != 1 or value.size < 2 or not np.isfinite(value).all() or np.any(np.diff(value) <= 0):
|
||||
raise ValueError("coordinate must be finite, increasing, and contain at least two points")
|
||||
weights = np.empty(value.size, dtype=np.float64)
|
||||
weights[0] = (value[1] - value[0]) / 2
|
||||
weights[-1] = (value[-1] - value[-2]) / 2
|
||||
if value.size > 2:
|
||||
weights[1:-1] = (value[2:] - value[:-2]) / 2
|
||||
return weights
|
||||
|
||||
|
||||
def analysis_mask(masks: Sequence[np.ndarray]) -> np.ndarray:
|
||||
values = [np.asarray(mask) for mask in masks]
|
||||
if len(values) != 3 or any(value.dtype != np.bool_ or value.ndim != 2 for value in values) or len({value.shape for value in values}) != 1:
|
||||
raise ValueError("exactly three matching solver-derived boolean masks are required")
|
||||
common = values[0] & values[1] & values[2]
|
||||
occupied_x = np.flatnonzero(common.any(axis=1))
|
||||
occupied_y = np.flatnonzero(common.any(axis=0))
|
||||
if int(common.sum()) < MIN_ANALYSIS_FLUID_POINTS or occupied_x.size < 2 or occupied_y.size < 2:
|
||||
raise ValueError(f"solver-fluid intersection is too small; require >= {MIN_ANALYSIS_FLUID_POINTS} points spanning two x and y coordinates")
|
||||
return common
|
||||
|
||||
|
||||
def compute_estimands(q_target: np.ndarray, q_blk: np.ndarray, q_ctl: np.ndarray, *, closure_atol: float = CLOSURE_ATOL) -> dict[str, np.ndarray]:
|
||||
values = [np.asarray(item) for item in (q_target, q_blk, q_ctl)]
|
||||
if any(item.dtype != np.float32 or item.ndim != 4 or item.shape[1] != 2 or not np.isfinite(item).all() for item in values) or len({item.shape for item in values}) != 1:
|
||||
raise ValueError("q fields must be finite matching float32 (time,vector,x,y)")
|
||||
target, blocked, controlled = values
|
||||
e_target = controlled - target
|
||||
dq_ctl = controlled - blocked
|
||||
dq_tar = target - blocked
|
||||
residual = e_target - (dq_ctl - dq_tar)
|
||||
maximum = float(np.max(np.abs(residual)))
|
||||
if maximum > closure_atol:
|
||||
raise ValueError(f"pointwise direct-dq closure failed: {maximum} > {closure_atol}")
|
||||
return {"e_target": e_target, "dq_ctl": dq_ctl, "dq_tar": dq_tar, "closure_residual": residual}
|
||||
|
||||
|
||||
def _valid_runs(mask: np.ndarray) -> list[np.ndarray]:
|
||||
indices = np.flatnonzero(mask)
|
||||
if not indices.size:
|
||||
return []
|
||||
return [run for run in np.split(indices, np.flatnonzero(np.diff(indices) > 1) + 1) if run.size]
|
||||
|
||||
|
||||
def segmented_trapezoid(values: np.ndarray, coordinate: np.ndarray, mask: np.ndarray) -> float:
|
||||
value, position, valid = np.asarray(values), np.asarray(coordinate), np.asarray(mask)
|
||||
if value.shape != position.shape or valid.shape != position.shape or valid.dtype != np.bool_ or value.ndim != 1 or not np.isfinite(value).all():
|
||||
raise ValueError("segmented quadrature profile contract is invalid")
|
||||
coordinate_weights(position)
|
||||
result = 0.0
|
||||
integrator = np.trapezoid
|
||||
for run in _valid_runs(valid):
|
||||
if run.size >= 2:
|
||||
result += float(integrator(value[run], position[run]))
|
||||
return result
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StationRequest:
|
||||
token: str
|
||||
value: np.float32
|
||||
|
||||
|
||||
def parse_station_token(token: str) -> StationRequest:
|
||||
if not isinstance(token, str) or not token or token != token.strip():
|
||||
raise ValueError("station token must be a nonempty canonical decimal string")
|
||||
try:
|
||||
decimal = Decimal(token)
|
||||
except InvalidOperation as exc:
|
||||
raise ValueError(f"invalid station decimal token: {token!r}") from exc
|
||||
if not decimal.is_finite():
|
||||
raise ValueError("station token must be finite")
|
||||
value = np.float32(str(decimal))
|
||||
if not np.isfinite(value):
|
||||
raise ValueError("station token is outside finite float32 range")
|
||||
return StationRequest(token, value)
|
||||
|
||||
|
||||
def station_index(x_D: np.ndarray, requested: StationRequest | str) -> int:
|
||||
coordinate = np.asarray(x_D)
|
||||
request = parse_station_token(requested) if isinstance(requested, str) else requested
|
||||
if coordinate.dtype != np.float32 or coordinate.ndim != 1 or not isinstance(request, StationRequest):
|
||||
raise ValueError("station request and x_D coordinate are invalid")
|
||||
matches = np.flatnonzero(coordinate == request.value)
|
||||
if matches.size != 1:
|
||||
raise ValueError(f"requested x/D station token {request.token!r} is absent or ambiguous after canonical float32 conversion; nearest/tolerance substitution is forbidden")
|
||||
return int(matches[0])
|
||||
|
||||
|
||||
def weighted_vector_rms(vector: np.ndarray, x_D: np.ndarray, y_D: np.ndarray, mask: np.ndarray) -> float:
|
||||
field, valid = np.asarray(vector), np.asarray(mask)
|
||||
if field.shape != (2, valid.shape[0], valid.shape[1]) or not np.isfinite(field).all():
|
||||
raise ValueError("weighted vector RMS field contract is invalid")
|
||||
weights = coordinate_weights(x_D)[:, None] * coordinate_weights(y_D)[None, :]
|
||||
denominator = float(weights[valid].sum())
|
||||
return float(np.sqrt(np.sum(weights[valid] * np.sum(field[:, valid] ** 2, axis=0)) / denominator))
|
||||
|
||||
|
||||
def profile_metrics(target_ux: np.ndarray, controlled_ux: np.ndarray, y_D: np.ndarray, mask: np.ndarray) -> dict[str, float | None]:
|
||||
target, controlled, y, valid = map(np.asarray, (target_ux, controlled_ux, y_D, mask))
|
||||
if target.shape != y.shape or controlled.shape != y.shape or valid.shape != y.shape or valid.dtype != np.bool_ or not np.isfinite(target).all() or not np.isfinite(controlled).all():
|
||||
raise ValueError("profile contract is invalid")
|
||||
signed = target - controlled
|
||||
positive = np.maximum(signed, 0.0)
|
||||
signed_integral = segmented_trapezoid(signed, y, valid)
|
||||
momentum = segmented_trapezoid(target * signed, y, valid)
|
||||
area = segmented_trapezoid(positive, y, valid)
|
||||
centroid = None
|
||||
width = None
|
||||
if area > 0:
|
||||
centroid = segmented_trapezoid(positive * y, y, valid) / area
|
||||
variance = segmented_trapezoid(positive * (y - centroid) ** 2, y, valid) / area
|
||||
width = float(np.sqrt(max(variance, 0.0)))
|
||||
return {"signed_target_relative_ux_deficit_integral": signed_integral, "momentum_flux_proxy_incomplete": momentum, "positive_deficit_area": area, "positive_deficit_centroid_y_D": centroid, "positive_deficit_width_D": width}
|
||||
|
||||
|
||||
def _masked_axis_derivative(field: np.ndarray, coordinate: np.ndarray, mask: np.ndarray, axis: int) -> tuple[np.ndarray, np.ndarray]:
|
||||
values = np.asarray(field, dtype=np.float64)
|
||||
positions = np.asarray(coordinate, dtype=np.float64)
|
||||
valid = np.asarray(mask)
|
||||
derivative = np.zeros_like(values, dtype=np.float64)
|
||||
derivative_valid = np.zeros_like(valid)
|
||||
outer = values.shape[1 - axis]
|
||||
for index in range(outer):
|
||||
line = values[:, index] if axis == 0 else values[index, :]
|
||||
line_mask = valid[:, index] if axis == 0 else valid[index, :]
|
||||
for run in _valid_runs(line_mask):
|
||||
if run.size < 2:
|
||||
continue
|
||||
edge_order = 2 if run.size >= 3 else 1
|
||||
gradient = np.gradient(line[run], positions[run], edge_order=edge_order)
|
||||
if axis == 0:
|
||||
derivative[run, index] = gradient
|
||||
derivative_valid[run, index] = True
|
||||
else:
|
||||
derivative[index, run] = gradient
|
||||
derivative_valid[index, run] = True
|
||||
return derivative, derivative_valid
|
||||
|
||||
|
||||
def vorticity(ux: np.ndarray, uy: np.ndarray, x_D: np.ndarray, y_D: np.ndarray, mask: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
||||
u, v, valid = np.asarray(ux), np.asarray(uy), np.asarray(mask)
|
||||
if u.shape != valid.shape or v.shape != valid.shape or valid.dtype != np.bool_ or not np.isfinite(u).all() or not np.isfinite(v).all():
|
||||
raise ValueError("vorticity field/mask contract is invalid")
|
||||
coordinate_weights(x_D)
|
||||
coordinate_weights(y_D)
|
||||
dv_dx, valid_x = _masked_axis_derivative(v, x_D, valid, 0)
|
||||
du_dy, valid_y = _masked_axis_derivative(u, y_D, valid, 1)
|
||||
omega_valid = valid_x & valid_y
|
||||
omega = np.zeros_like(u, dtype=np.float32)
|
||||
omega[omega_valid] = (dv_dx - du_dy)[omega_valid].astype(np.float32)
|
||||
return omega, omega_valid
|
||||
|
||||
|
||||
def phase_conditioned_outputs(*args: Any, **kwargs: Any) -> None:
|
||||
raise NotImplementedError("phase-conditioned direct-dq is fail-closed until independent cross-role physical-phase equality is proven")
|
||||
|
||||
|
||||
def select_relative_interval(timeline: np.ndarray, *, start_after_relative_step: int, end_at_relative_step: int | None = None) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""Select exact samples satisfying start < physical step <= optional end."""
|
||||
steps = np.asarray(timeline)
|
||||
if steps.dtype != np.int64 or steps.ndim != 1 or not steps.size or np.any(np.diff(steps) <= 0):
|
||||
raise ValueError("selection requires a nonempty strictly increasing int64 relative timeline")
|
||||
if type(start_after_relative_step) is not int or (end_at_relative_step is not None and type(end_at_relative_step) is not int):
|
||||
raise ValueError("relative-step selection bounds must be explicit integers")
|
||||
if end_at_relative_step is not None and end_at_relative_step <= start_after_relative_step:
|
||||
raise ValueError("end-at-relative-step must be greater than start-after-relative-step")
|
||||
selected = steps > start_after_relative_step
|
||||
if end_at_relative_step is not None:
|
||||
selected &= steps <= end_at_relative_step
|
||||
indices = np.flatnonzero(selected).astype(np.int64)
|
||||
if not indices.size:
|
||||
raise ValueError("relative-step selection is empty")
|
||||
if end_at_relative_step is not None and int(steps[indices[-1]]) != end_at_relative_step:
|
||||
raise ValueError("end-at-relative-step must be the terminal selected acquisition step")
|
||||
return indices, steps[indices].copy()
|
||||
|
||||
|
||||
def analyze_loaded(artifacts: Mapping[str, Any], *, station_tokens: Sequence[str], window_sizes: Sequence[int], start_after_relative_step: int, end_at_relative_step: int | None = None) -> tuple[dict[str, np.ndarray], dict[str, Any]]:
|
||||
fields = {role: artifacts[role].fields for role in ("q_target", "q_blk", "q_ctl")}
|
||||
original_timeline = fields["q_target"]["acquisition_relative_lattice_steps"]
|
||||
selected_indices, selected_timeline = select_relative_interval(original_timeline, start_after_relative_step=start_after_relative_step, end_at_relative_step=end_at_relative_step)
|
||||
q = {role: np.stack((value["ux"][selected_indices], value["uy"][selected_indices]), axis=1) for role, value in fields.items()}
|
||||
estimands = compute_estimands(q["q_target"], q["q_blk"], q["q_ctl"])
|
||||
mask = analysis_mask([fields[role]["fluid_mask"] for role in ("q_target", "q_blk", "q_ctl")])
|
||||
means = {**{role: value.mean(axis=0, dtype=np.float64).astype(np.float32) for role, value in q.items()}, **{key: value.mean(axis=0, dtype=np.float64).astype(np.float32) for key, value in estimands.items() if key != "closure_residual"}}
|
||||
x, y = fields["q_target"]["x_D"], fields["q_target"]["y_D"]
|
||||
station_requests = [parse_station_token(token) for token in station_tokens]
|
||||
station_indices = np.asarray([station_index(x, request) for request in station_requests], np.int64)
|
||||
profile_mask = np.stack([mask[index] for index in station_indices]) if station_indices.size else np.empty((0, y.size), bool)
|
||||
arrays = {"x_D": x, "y_D": y, "q_target_solver_fluid_mask": fields["q_target"]["fluid_mask"], "q_blk_solver_fluid_mask": fields["q_blk"]["fluid_mask"], "q_ctl_solver_fluid_mask": fields["q_ctl"]["fluid_mask"], "analysis_fluid_mask": mask, "original_acquisition_relative_lattice_steps": original_timeline, "selected_acquisition_relative_lattice_steps": selected_timeline, "selected_timeline_indices": selected_indices, "station_indices": station_indices, "station_x_D": x[station_indices], "profile_analysis_mask": profile_mask, "convergence_window_sizes": np.asarray(window_sizes, np.int64), "q_target_instantaneous": q["q_target"], "q_blk_instantaneous": q["q_blk"], "q_ctl_instantaneous": q["q_ctl"], "e_target_instantaneous": estimands["e_target"], "dq_ctl_instantaneous": estimands["dq_ctl"], "dq_tar_instantaneous": estimands["dq_tar"]}
|
||||
for name, value in means.items():
|
||||
arrays[f"{name}_mean"] = value
|
||||
omega, omega_valid = vorticity(value[0], value[1], x, y, mask)
|
||||
arrays[f"{name}_mean_vorticity"] = omega
|
||||
arrays[f"{name}_mean_vorticity_valid_mask"] = omega_valid
|
||||
arrays[f"{name}_mean_ux_profiles"] = value[0, station_indices, :] if station_indices.size else np.empty((0, y.size), np.float32)
|
||||
profile_summaries = []
|
||||
for request, index in zip(station_requests, station_indices):
|
||||
profile_summaries.append({"requested_x_D_token": request.token, "canonical_float32_x_D": float(request.value), "exact_x_D": float(x[index]), **profile_metrics(means["q_target"][0, index], means["q_ctl"][0, index], y, mask[index])})
|
||||
from .convergence import convergence_report
|
||||
convergence = convergence_report(q, estimands, x_D=x, y_D=y, mask=mask, window_sizes=window_sizes)
|
||||
summary = {"schema_id": "ccd-direct-dq-summary/v2", "sample_count": int(q["q_target"].shape[0]), "selection": {"start_after_relative_step": start_after_relative_step, "end_at_relative_step": end_at_relative_step, "selected_count": int(selected_indices.size), "first_selected_relative_step": int(selected_timeline[0]), "last_selected_relative_step": int(selected_timeline[-1])}, "analysis_fluid_mask_definition": "exact intersection of q_target, q_blk, and q_ctl solver-derived fluid masks; no coordinate-generated mask, crop, or translation", "analysis_fluid_point_count": int(mask.sum()), "time_aggregation": "time mean only; acquisition-relative same-time matching is exact; physical-phase equality is not claimed", "closure": {"identity": "e_target = dq_ctl - dq_tar", "absolute_tolerance": CLOSURE_ATOL, "maximum_absolute_residual": float(np.max(np.abs(estimands["closure_residual"])))}, "shared_baseline_caveat": "dq_ctl and dq_tar share the same -q_blk term; agreement is not mechanism or causation evidence", "weighted_vector_rms_target_error": weighted_vector_rms(means["e_target"], x, y, mask), "profiles": profile_summaries, "momentum_flux_proxy_warning": "incomplete proxy integral u_target*(u_target-u_ctl) dy; excludes pressure, viscous, transverse-flux, and control-surface terms", "convergence": convergence, "uncertainty_claim": "none; prefix/suffix windows from one record are convergence diagnostics, not independent realizations", "phase_conditioned_outputs": "unsupported until independent cross-role physical-phase equality is proven"}
|
||||
return arrays, summary
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Command line entry point for strict CPU-only direct-dq analysis."""
|
||||
from __future__ import annotations
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Sequence
|
||||
from .analysis import analyze_loaded
|
||||
from .io import ResultTransaction, load_matched_inputs
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="Strict same-time direct-dq analysis (CPU only)")
|
||||
parser.add_argument("--case", required=True, choices=("karman_re100", "illusion_1.0L"))
|
||||
parser.add_argument("--q-target", required=True, type=Path)
|
||||
parser.add_argument("--q-blk", required=True, type=Path)
|
||||
parser.add_argument("--q-ctl", required=True, type=Path)
|
||||
parser.add_argument("--output", required=True, type=Path)
|
||||
parser.add_argument("--station-x-D", action="append", required=True, type=str, dest="station_tokens")
|
||||
parser.add_argument("--window-size", action="append", required=True, type=int, dest="windows")
|
||||
parser.add_argument("--start-after-relative-step", required=True, type=int)
|
||||
parser.add_argument("--end-at-relative-step", type=int)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
artifacts = load_matched_inputs(q_target=args.q_target, q_blk=args.q_blk, q_ctl=args.q_ctl, case_id=args.case)
|
||||
arrays, summary = analyze_loaded(artifacts, station_tokens=args.station_tokens, window_sizes=args.windows, start_after_relative_step=args.start_after_relative_step, end_at_relative_step=args.end_at_relative_step)
|
||||
config = {"schema_id": "ccd-direct-dq-config/v2", "case_id": args.case, "inputs": {role: str(artifacts[role].path.resolve()) for role in artifacts}, "station_x_D_tokens": list(args.station_tokens), "window_sizes": list(args.windows), "selection": {"start_after_relative_step": args.start_after_relative_step, "end_at_relative_step": args.end_at_relative_step}, "alignment": "exact full acquisition-relative lattice timeline matching followed by exact physical-step inequality selection and exact float32 coordinates; no index trim, nearest-time, phase guess, crop, or translation"}
|
||||
input_hashes = {role: artifacts[role].input_identity for role in artifacts}
|
||||
with ResultTransaction(args.output) as transaction:
|
||||
transaction.write(arrays=arrays, summary=summary, config=config, input_hashes=input_hashes)
|
||||
result = transaction.publish()
|
||||
print(json.dumps({"result": str(result), "summary": summary}, sort_keys=True))
|
||||
return 0
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Declared nested-window convergence diagnostics for one acquisition record."""
|
||||
from __future__ import annotations
|
||||
from typing import Mapping, Sequence
|
||||
import numpy as np
|
||||
from .analysis import weighted_vector_rms
|
||||
|
||||
WINDOW_SEMANTICS = "declared nested prefix and suffix sample-count windows from one time record"
|
||||
INDEPENDENT_REALIZATION_UNCERTAINTY = False
|
||||
|
||||
|
||||
def validate_window_sizes(window_sizes: Sequence[int], sample_count: int) -> tuple[int, ...]:
|
||||
values = tuple(window_sizes)
|
||||
if not values or any(type(value) is not int or value < 1 or value > sample_count for value in values):
|
||||
raise ValueError("window sizes must be explicit positive integers no larger than sample count")
|
||||
if tuple(sorted(set(values))) != values or values[-1] != sample_count:
|
||||
raise ValueError("window sizes must be unique increasing nested counts ending at the full record")
|
||||
return values
|
||||
|
||||
|
||||
def convergence_report(q: Mapping[str, np.ndarray], estimands: Mapping[str, np.ndarray], *, x_D: np.ndarray, y_D: np.ndarray, mask: np.ndarray, window_sizes: Sequence[int]) -> dict:
|
||||
sample_count = int(q["q_target"].shape[0])
|
||||
windows = validate_window_sizes(window_sizes, sample_count)
|
||||
series = {key: value for key, value in estimands.items() if key in ("e_target", "dq_ctl", "dq_tar")}
|
||||
full_means = {key: value.mean(axis=0, dtype=np.float64) for key, value in series.items()}
|
||||
records = []
|
||||
for size in windows:
|
||||
entry = {"sample_count": size, "prefix": {}, "suffix": {}}
|
||||
for label, selection in (("prefix", slice(0, size)), ("suffix", slice(sample_count - size, sample_count))):
|
||||
for key, value in series.items():
|
||||
mean = value[selection].mean(axis=0, dtype=np.float64)
|
||||
entry[label][f"{key}_mean_deviation_weighted_vector_rms"] = weighted_vector_rms((mean - full_means[key]).astype(np.float32), x_D, y_D, mask)
|
||||
mean_error = series["e_target"][selection].mean(axis=0, dtype=np.float64).astype(np.float32)
|
||||
entry[label]["mean_target_error_weighted_vector_rms"] = weighted_vector_rms(mean_error, x_D, y_D, mask)
|
||||
records.append(entry)
|
||||
return {"window_semantics": WINDOW_SEMANTICS, "window_sizes": list(windows), "records": records, "independent_realization_uncertainty": INDEPENDENT_REALIZATION_UNCERTAINTY}
|
||||
@@ -0,0 +1,164 @@
|
||||
"""Strict readers and immutable result transactions for direct-dq."""
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from hashlib import sha256
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import uuid
|
||||
from typing import Any, Mapping
|
||||
import numpy as np
|
||||
from CCD_analysis.acquisition.artifacts import file_sha256, rename_noreplace
|
||||
from CCD_analysis.acquisition.contracts import ARTIFACT_SCHEMA_ID, ROLES, canonical_json
|
||||
from CCD_analysis.acquisition.validation import MANIFEST_KEYS, validate_acquisition_semantics
|
||||
from .schema import RESULT_SCHEMA_ID, canonical_array_sha256, validate_result_science
|
||||
|
||||
RESULT_FILES = {"arrays.npz", "summary.json", "config.json", "input_hashes.json"}
|
||||
|
||||
|
||||
def _fsync_file(path: Path) -> None:
|
||||
with path.open("rb") as stream: os.fsync(stream.fileno())
|
||||
|
||||
|
||||
def _read_canonical_json(path: Path) -> dict[str, Any]:
|
||||
try:
|
||||
raw = path.read_bytes(); value = json.loads(raw)
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise ValueError(f"invalid JSON: {path}") from exc
|
||||
if not isinstance(value, dict) or raw != canonical_json(value):
|
||||
raise ValueError(f"canonical JSON object required: {path}")
|
||||
return value
|
||||
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AcquisitionArtifact:
|
||||
path: Path
|
||||
role: str
|
||||
case_id: str
|
||||
config: dict[str, Any]
|
||||
manifest: dict[str, Any]
|
||||
fields: Mapping[str, np.ndarray]
|
||||
input_identity: dict[str, Any]
|
||||
|
||||
|
||||
def load_acquisition_artifact(path: str | Path, *, expected_case: str, expected_role: str) -> AcquisitionArtifact:
|
||||
root = Path(path)
|
||||
if expected_role not in ROLES or not root.is_dir(): raise ValueError("invalid expected role or acquisition artifact directory")
|
||||
expected_files = {"manifest.json", "config.json", "fields.npz", "controller_state.npz"}
|
||||
if {item.name for item in root.iterdir() if item.is_file()} != expected_files: raise ValueError("artifact directory file inventory is not exact")
|
||||
manifest = _read_canonical_json(root / "manifest.json")
|
||||
if set(manifest) != MANIFEST_KEYS or manifest.get("schema_id") != ARTIFACT_SCHEMA_ID or manifest.get("complete") is not True or set(manifest.get("files", {})) != {"config.json", "fields.npz", "controller_state.npz"}: raise ValueError("artifact manifest schema/inventory is not exact")
|
||||
for name, digest in manifest["files"].items():
|
||||
if file_sha256(root / name) != digest: raise ValueError(f"artifact file hash mismatch: {name}")
|
||||
config = _read_canonical_json(root / "config.json")
|
||||
if sha256((root / "config.json").read_bytes()).hexdigest() != manifest["config_sha256"]: raise ValueError("artifact config hash mismatch")
|
||||
with np.load(root / "fields.npz", allow_pickle=False) as archive:
|
||||
fields = {key: archive[key].copy() for key in archive.files}
|
||||
with np.load(root / "controller_state.npz", allow_pickle=False) as archive:
|
||||
state = {key: archive[key].copy() for key in archive.files}
|
||||
fields, _ = validate_acquisition_semantics(arrays=fields, config=config, state=state, manifest=manifest, expected_case=expected_case, expected_role=expected_role)
|
||||
role_instantaneous = np.stack((fields["ux"], fields["uy"]), axis=1)
|
||||
identity = {"path": str(root.resolve()), "manifest_sha256": file_sha256(root / "manifest.json"), "config_sha256": manifest["config_sha256"], "files": dict(sorted(manifest["files"].items())), "role_instantaneous_sha256": canonical_array_sha256(role_instantaneous)}
|
||||
return AcquisitionArtifact(root, expected_role, expected_case, config, manifest, fields, identity)
|
||||
|
||||
|
||||
def load_matched_inputs(*, q_target: str | Path, q_blk: str | Path, q_ctl: str | Path, case_id: str) -> dict[str, AcquisitionArtifact]:
|
||||
artifacts = {role: load_acquisition_artifact(path, expected_case=case_id, expected_role=role) for role, path in (("q_target", q_target), ("q_blk", q_blk), ("q_ctl", q_ctl))}
|
||||
reference = artifacts["q_target"].fields
|
||||
for role in ("q_blk", "q_ctl"):
|
||||
fields = artifacts[role].fields
|
||||
if not np.array_equal(fields["x_D"], reference["x_D"]) or not np.array_equal(fields["y_D"], reference["y_D"]): raise ValueError(f"common grid exact-equality failure for {role}")
|
||||
if fields["ux"].shape != reference["ux"].shape: raise ValueError(f"field shape mismatch for {role}; silent trimming is forbidden")
|
||||
if not np.array_equal(fields["acquisition_relative_lattice_steps"], reference["acquisition_relative_lattice_steps"]): raise ValueError(f"acquisition-relative timeline exact-equality failure for {role}")
|
||||
return artifacts
|
||||
|
||||
|
||||
class ResultTransaction:
|
||||
def __init__(self, destination: str | Path):
|
||||
self.destination = Path(destination)
|
||||
self.partial = self.destination.with_name(f".{self.destination.name}.partial.{os.getpid()}.{uuid.uuid4().hex}")
|
||||
self.active = False
|
||||
|
||||
def __enter__(self) -> "ResultTransaction":
|
||||
if self.destination.exists(): raise FileExistsError(self.destination)
|
||||
self.destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.partial.mkdir(); self.active = True
|
||||
return self
|
||||
|
||||
def write(self, *, arrays: Mapping[str, Any], summary: dict[str, Any], config: dict[str, Any], input_hashes: dict[str, Any]) -> None:
|
||||
if not self.active: raise RuntimeError("transaction inactive")
|
||||
data = validate_result_science(arrays=arrays, summary=summary, config=config, input_hashes=input_hashes)
|
||||
np.savez_compressed(self.partial / "arrays.npz", **data)
|
||||
for name, value in (("summary.json", summary), ("config.json", config), ("input_hashes.json", input_hashes)):
|
||||
(self.partial / name).write_bytes(canonical_json(value))
|
||||
for path in self.partial.iterdir(): _fsync_file(path)
|
||||
files = {path.name: file_sha256(path) for path in sorted(self.partial.iterdir())}
|
||||
(self.partial / "manifest.json").write_bytes(canonical_json({"schema_id": RESULT_SCHEMA_ID, "complete": True, "files": files}))
|
||||
_fsync_file(self.partial / "manifest.json")
|
||||
|
||||
def publish(self) -> Path:
|
||||
load_result(self.partial)
|
||||
rename_noreplace(self.partial, self.destination)
|
||||
directory_fd = os.open(self.destination.parent, os.O_RDONLY)
|
||||
try: os.fsync(directory_fd)
|
||||
finally: os.close(directory_fd)
|
||||
self.active = False
|
||||
load_result(self.destination)
|
||||
return self.destination
|
||||
|
||||
def __exit__(self, exc_type: Any, exc: Any, traceback: Any) -> None:
|
||||
if self.active: shutil.rmtree(self.partial, ignore_errors=True); self.active = False
|
||||
|
||||
|
||||
def load_result_metadata_unverified(path: str | Path) -> dict[str, Any]:
|
||||
"""Load and internally validate a result without provenance validation.
|
||||
|
||||
This explicitly unverified API does not reread acquisition inputs and must not
|
||||
be used for publication, CLI success, or scientific provenance claims.
|
||||
"""
|
||||
root = Path(path)
|
||||
manifest = _read_canonical_json(root / "manifest.json")
|
||||
if set(manifest) != {"schema_id", "complete", "files"} or manifest.get("schema_id") != RESULT_SCHEMA_ID or manifest.get("complete") is not True or set(manifest.get("files", {})) != RESULT_FILES: raise ValueError("result manifest schema/inventory is not exact")
|
||||
if {item.name for item in root.iterdir() if item.is_file()} != RESULT_FILES | {"manifest.json"}: raise ValueError("result directory file inventory is not exact")
|
||||
for name, digest in manifest["files"].items():
|
||||
if file_sha256(root / name) != digest: raise ValueError(f"result file hash mismatch: {name}")
|
||||
with np.load(root / "arrays.npz", allow_pickle=False) as archive: arrays = {key: archive[key].copy() for key in archive.files}
|
||||
summary, config, input_hashes = (_read_canonical_json(root / name) for name in ("summary.json", "config.json", "input_hashes.json"))
|
||||
arrays = validate_result_science(arrays=arrays, summary=summary, config=config, input_hashes=input_hashes)
|
||||
return {"arrays": arrays, "summary": summary, "config": config, "input_hashes": input_hashes, "manifest": manifest, "provenance_validation": "UNVERIFIED: acquisition inputs were not reread"}
|
||||
|
||||
|
||||
def _validate_live_result_inputs(result: Mapping[str, Any]) -> None:
|
||||
arrays, config, recorded = result["arrays"], result["config"], result["input_hashes"]
|
||||
case_id = config["case_id"]
|
||||
live: dict[str, AcquisitionArtifact] = {}
|
||||
for role in ROLES:
|
||||
path = recorded[role]["path"]
|
||||
if config["inputs"][role] != path:
|
||||
raise ValueError(f"{role} configured path contradicts recorded input identity")
|
||||
try:
|
||||
artifact = load_acquisition_artifact(path, expected_case=case_id, expected_role=role)
|
||||
except (OSError, ValueError) as exc:
|
||||
raise ValueError(f"live acquisition input validation failed for {role}: {path}") from exc
|
||||
if artifact.input_identity != recorded[role]:
|
||||
raise ValueError(f"live acquisition manifest/config/file identities changed for {role}")
|
||||
role_field = np.stack((artifact.fields["ux"], artifact.fields["uy"]), axis=1)
|
||||
selected_indices = arrays["selected_timeline_indices"]
|
||||
if not np.array_equal(role_field[selected_indices], arrays[f"{role}_instantaneous"]):
|
||||
raise ValueError(f"persisted {role} instantaneous field differs from live validated acquisition")
|
||||
if not np.array_equal(artifact.fields["fluid_mask"], arrays[f"{role}_solver_fluid_mask"]):
|
||||
raise ValueError(f"persisted {role} mask differs from live validated acquisition")
|
||||
live[role] = artifact
|
||||
target = live["q_target"].fields
|
||||
if not np.array_equal(target["x_D"], arrays["x_D"]) or not np.array_equal(target["y_D"], arrays["y_D"]) or not np.array_equal(target["acquisition_relative_lattice_steps"], arrays["original_acquisition_relative_lattice_steps"]):
|
||||
raise ValueError("persisted result grid/timeline differs from live validated acquisitions")
|
||||
|
||||
|
||||
def load_result(path: str | Path) -> dict[str, Any]:
|
||||
"""Load a result with mandatory live acquisition provenance validation."""
|
||||
result = load_result_metadata_unverified(path)
|
||||
_validate_live_result_inputs(result)
|
||||
result["provenance_validation"] = "VERIFIED: all live acquisition artifacts reread and matched"
|
||||
return result
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Exact cross-file scientific schema for direct-dq results."""
|
||||
from __future__ import annotations
|
||||
from hashlib import sha256
|
||||
from typing import Any, Mapping
|
||||
import numpy as np
|
||||
from CCD_analysis.acquisition.contracts import CASES, ROLES, canonical_json
|
||||
from CCD_analysis.acquisition.validation import require_sha256
|
||||
from .analysis import CLOSURE_ATOL, parse_station_token, profile_metrics, station_index, vorticity, weighted_vector_rms
|
||||
from .convergence import convergence_report, validate_window_sizes
|
||||
|
||||
RESULT_SCHEMA_ID = "ccd-direct-dq-result/v2"
|
||||
SUMMARY_SCHEMA_ID = "ccd-direct-dq-summary/v2"
|
||||
CONFIG_SCHEMA_ID = "ccd-direct-dq-config/v2"
|
||||
BASE_ARRAY_KEYS = {"x_D", "y_D", "q_target_solver_fluid_mask", "q_blk_solver_fluid_mask", "q_ctl_solver_fluid_mask", "analysis_fluid_mask", "original_acquisition_relative_lattice_steps", "selected_acquisition_relative_lattice_steps", "selected_timeline_indices", "station_indices", "station_x_D", "profile_analysis_mask", "convergence_window_sizes", "q_target_instantaneous", "q_blk_instantaneous", "q_ctl_instantaneous", "e_target_instantaneous", "dq_ctl_instantaneous", "dq_tar_instantaneous"}
|
||||
FIELD_NAMES = ("q_target", "q_blk", "q_ctl", "e_target", "dq_ctl", "dq_tar")
|
||||
DERIVED_ARRAY_KEYS = {f"{name}_{suffix}" for name in FIELD_NAMES for suffix in ("mean", "mean_vorticity", "mean_vorticity_valid_mask", "mean_ux_profiles")}
|
||||
ARRAY_KEYS = BASE_ARRAY_KEYS | DERIVED_ARRAY_KEYS
|
||||
SUMMARY_KEYS = {"schema_id", "sample_count", "selection", "analysis_fluid_mask_definition", "analysis_fluid_point_count", "time_aggregation", "closure", "shared_baseline_caveat", "weighted_vector_rms_target_error", "profiles", "momentum_flux_proxy_warning", "convergence", "uncertainty_claim", "phase_conditioned_outputs"}
|
||||
CONFIG_KEYS = {"schema_id", "case_id", "inputs", "station_x_D_tokens", "window_sizes", "selection", "alignment"}
|
||||
IDENTITY_KEYS = {"path", "manifest_sha256", "config_sha256", "files", "role_instantaneous_sha256"}
|
||||
INPUT_FILES = {"config.json", "fields.npz", "controller_state.npz"}
|
||||
|
||||
|
||||
def _finite_number(value: Any, label: str) -> float:
|
||||
if type(value) not in (int, float) or not np.isfinite(value): raise ValueError(f"{label} must be finite number")
|
||||
return float(value)
|
||||
|
||||
|
||||
def canonical_array_sha256(value: np.ndarray) -> str:
|
||||
"""Hash exact dtype, shape, and contiguous bytes for scientific identity."""
|
||||
array = np.ascontiguousarray(value)
|
||||
header = canonical_json({"dtype": array.dtype.str, "shape": list(array.shape)})
|
||||
digest = sha256(); digest.update(header); digest.update(array.tobytes())
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def validate_result_science(*, arrays: Mapping[str, Any], summary: Mapping[str, Any], config: Mapping[str, Any], input_hashes: Mapping[str, Any]) -> dict[str, np.ndarray]:
|
||||
if not isinstance(summary, Mapping) or set(summary) != SUMMARY_KEYS or summary["schema_id"] != SUMMARY_SCHEMA_ID:
|
||||
raise ValueError("direct-dq summary schema is not exact")
|
||||
if not isinstance(config, Mapping) or set(config) != CONFIG_KEYS or config["schema_id"] != CONFIG_SCHEMA_ID or config["case_id"] not in CASES:
|
||||
raise ValueError("direct-dq config schema is not exact")
|
||||
if not isinstance(config["inputs"], Mapping) or set(config["inputs"]) != set(ROLES) or any(not isinstance(value, str) or not value for value in config["inputs"].values()): raise ValueError("direct-dq input path schema is not exact")
|
||||
if not isinstance(config["alignment"], str) or not isinstance(config["station_x_D_tokens"], list) or not all(isinstance(token, str) for token in config["station_x_D_tokens"]): raise ValueError("direct-dq station/alignment config is invalid")
|
||||
if not isinstance(config["window_sizes"], list): raise ValueError("window sizes must be a list")
|
||||
selection_config = config["selection"]
|
||||
if not isinstance(selection_config, Mapping) or set(selection_config) != {"start_after_relative_step", "end_at_relative_step"}:
|
||||
raise ValueError("selection config schema is not exact")
|
||||
start = selection_config["start_after_relative_step"]; end = selection_config["end_at_relative_step"]
|
||||
if type(start) is not int or (end is not None and type(end) is not int) or (end is not None and end <= start):
|
||||
raise ValueError("selection bounds are invalid")
|
||||
if not isinstance(input_hashes, Mapping) or set(input_hashes) != set(ROLES): raise ValueError("input hash roles are not exact")
|
||||
for role, identity in input_hashes.items():
|
||||
if not isinstance(identity, Mapping) or set(identity) != IDENTITY_KEYS or identity["path"] != config["inputs"][role] or set(identity["files"]) != INPUT_FILES: raise ValueError(f"input identity structure is invalid for {role}")
|
||||
require_sha256(identity["manifest_sha256"], f"{role}.manifest_sha256"); require_sha256(identity["config_sha256"], f"{role}.config_sha256"); require_sha256(identity["role_instantaneous_sha256"], f"{role}.role_instantaneous_sha256")
|
||||
for name, digest in identity["files"].items(): require_sha256(digest, f"{role}.files[{name}]")
|
||||
|
||||
if set(arrays) != ARRAY_KEYS: raise ValueError("direct-dq arrays schema is not exact")
|
||||
data = {key: np.asarray(value) for key, value in arrays.items()}
|
||||
x, y = data["x_D"], data["y_D"]
|
||||
original_timeline = data["original_acquisition_relative_lattice_steps"]
|
||||
timeline = data["selected_acquisition_relative_lattice_steps"]
|
||||
selected_indices = data["selected_timeline_indices"]
|
||||
if x.dtype != np.float32 or y.dtype != np.float32 or x.ndim != 1 or y.ndim != 1 or x.size < 2 or y.size < 2 or not np.isfinite(x).all() or not np.isfinite(y).all() or np.any(np.diff(x) <= 0) or np.any(np.diff(y) <= 0): raise ValueError("result coordinates are invalid")
|
||||
if original_timeline.dtype != np.int64 or original_timeline.ndim != 1 or original_timeline.size < 1 or np.any(np.diff(original_timeline) <= 0): raise ValueError("original result timeline is invalid")
|
||||
if timeline.dtype != np.int64 or timeline.ndim != 1 or timeline.size < 1 or np.any(np.diff(timeline) <= 0): raise ValueError("selected result timeline is invalid")
|
||||
if selected_indices.dtype != np.int64 or selected_indices.ndim != 1 or selected_indices.size != timeline.size or np.any(np.diff(selected_indices) <= 0) or np.any(selected_indices < 0) or np.any(selected_indices >= original_timeline.size): raise ValueError("selected timeline indices are invalid")
|
||||
expected_indices = np.flatnonzero((original_timeline > start) & ((original_timeline <= end) if end is not None else True)).astype(np.int64)
|
||||
if not np.array_equal(selected_indices, expected_indices) or not np.array_equal(timeline, original_timeline[selected_indices]): raise ValueError("persisted selection contradicts exact inequality selection")
|
||||
if end is not None and int(timeline[-1]) != end: raise ValueError("selection terminal step contradicts requested end")
|
||||
nt, nx, ny, ns = timeline.size, x.size, y.size, len(config["station_x_D_tokens"])
|
||||
persisted_windows = data["convergence_window_sizes"]
|
||||
if persisted_windows.dtype != np.int64 or persisted_windows.ndim != 1 or persisted_windows.tolist() != config["window_sizes"]: raise ValueError("convergence window sizes contradict persisted arrays")
|
||||
if summary["sample_count"] != nt: raise ValueError("summary sample count contradicts arrays")
|
||||
expected_selection_summary = {"start_after_relative_step": start, "end_at_relative_step": end, "selected_count": nt, "first_selected_relative_step": int(timeline[0]), "last_selected_relative_step": int(timeline[-1])}
|
||||
if summary["selection"] != expected_selection_summary: raise ValueError("summary selection contradicts arrays/config")
|
||||
masks = []
|
||||
for role in ROLES:
|
||||
mask = data[f"{role}_solver_fluid_mask"]
|
||||
if mask.dtype != np.bool_ or mask.shape != (nx, ny) or not mask.any(): raise ValueError(f"{role} solver mask invalid")
|
||||
masks.append(mask)
|
||||
analysis = data["analysis_fluid_mask"]
|
||||
if analysis.dtype != np.bool_ or analysis.shape != (nx, ny) or not np.array_equal(analysis, masks[0] & masks[1] & masks[2]) or summary["analysis_fluid_point_count"] != int(analysis.sum()): raise ValueError("analysis mask/summary is inconsistent")
|
||||
indices, station_values, profile_mask = data["station_indices"], data["station_x_D"], data["profile_analysis_mask"]
|
||||
if indices.dtype != np.int64 or indices.shape != (ns,) or station_values.dtype != np.float32 or station_values.shape != (ns,) or profile_mask.dtype != np.bool_ or profile_mask.shape != (ns, ny): raise ValueError("station arrays are invalid")
|
||||
expected_station_indices = np.asarray([station_index(x, parse_station_token(token)) for token in config["station_x_D_tokens"]], np.int64)
|
||||
if not np.array_equal(indices, expected_station_indices) or not np.array_equal(station_values, x[indices]) or not np.array_equal(profile_mask, analysis[indices]): raise ValueError("station arrays contradict exact token/grid mapping")
|
||||
if not isinstance(summary["profiles"], list) or len(summary["profiles"]) != ns: raise ValueError("summary profiles count invalid")
|
||||
|
||||
instantaneous = {}
|
||||
for name in FIELD_NAMES:
|
||||
value = data[f"{name}_instantaneous"]
|
||||
if value.dtype != np.float32 or value.shape != (nt, 2, nx, ny) or not np.isfinite(value).all(): raise ValueError(f"{name} instantaneous array invalid")
|
||||
instantaneous[name] = value
|
||||
expected_estimands = {
|
||||
"e_target": instantaneous["q_ctl"] - instantaneous["q_target"],
|
||||
"dq_ctl": instantaneous["q_ctl"] - instantaneous["q_blk"],
|
||||
"dq_tar": instantaneous["q_target"] - instantaneous["q_blk"],
|
||||
}
|
||||
for name, expected in expected_estimands.items():
|
||||
if not np.array_equal(instantaneous[name], expected): raise ValueError(f"persisted {name} instantaneous differs from absolute-role recomputation")
|
||||
residual = expected_estimands["e_target"] - (expected_estimands["dq_ctl"] - expected_estimands["dq_tar"])
|
||||
maximum = float(np.max(np.abs(residual)))
|
||||
closure = summary["closure"]
|
||||
if not isinstance(closure, Mapping) or set(closure) != {"identity", "absolute_tolerance", "maximum_absolute_residual"} or closure["identity"] != "e_target = dq_ctl - dq_tar" or closure["absolute_tolerance"] != CLOSURE_ATOL or maximum > CLOSURE_ATOL or closure["maximum_absolute_residual"] != maximum: raise ValueError("reloaded pointwise closure validation failed")
|
||||
for name in FIELD_NAMES:
|
||||
mean, omega, omega_mask, profiles = (data[f"{name}_{suffix}"] for suffix in ("mean", "mean_vorticity", "mean_vorticity_valid_mask", "mean_ux_profiles"))
|
||||
if mean.dtype != np.float32 or mean.shape != (2, nx, ny) or not np.isfinite(mean).all() or omega.dtype != np.float32 or omega.shape != (nx, ny) or not np.isfinite(omega).all() or omega_mask.dtype != np.bool_ or omega_mask.shape != (nx, ny) or profiles.dtype != np.float32 or profiles.shape != (ns, ny) or not np.isfinite(profiles).all() or not np.array_equal(profiles, mean[0, indices, :]): raise ValueError(f"{name} mean/vorticity/profile schema invalid")
|
||||
expected_omega, expected_omega_mask = vorticity(mean[0], mean[1], x, y, analysis)
|
||||
if not np.array_equal(omega, expected_omega) or not np.array_equal(omega_mask, expected_omega_mask): raise ValueError(f"{name} vorticity contradicts mean field/mask")
|
||||
for name in FIELD_NAMES:
|
||||
expected = instantaneous[name].mean(axis=0, dtype=np.float64).astype(np.float32)
|
||||
if not np.array_equal(data[f"{name}_mean"], expected): raise ValueError(f"{name} mean contradicts persisted instantaneous fields")
|
||||
expected_rms = weighted_vector_rms(data["e_target_mean"], x, y, analysis)
|
||||
if summary["weighted_vector_rms_target_error"] != expected_rms: raise ValueError("summary weighted target error contradicts arrays")
|
||||
for item, token, index in zip(summary["profiles"], config["station_x_D_tokens"], indices):
|
||||
expected_keys = {"requested_x_D_token", "canonical_float32_x_D", "exact_x_D", "signed_target_relative_ux_deficit_integral", "momentum_flux_proxy_incomplete", "positive_deficit_area", "positive_deficit_centroid_y_D", "positive_deficit_width_D"}
|
||||
if not isinstance(item, Mapping) or set(item) != expected_keys or item["requested_x_D_token"] != token or item["canonical_float32_x_D"] != float(parse_station_token(token).value) or item["exact_x_D"] != float(x[index]): raise ValueError("summary station token mapping is inconsistent")
|
||||
metrics = profile_metrics(data["q_target_mean"][0, index], data["q_ctl_mean"][0, index], y, analysis[index])
|
||||
if any(item[key] != value for key, value in metrics.items()): raise ValueError("summary profile metrics contradict arrays")
|
||||
convergence = summary["convergence"]
|
||||
recomputed_convergence = convergence_report({"q_target": instantaneous["q_target"]}, expected_estimands, x_D=x, y_D=y, mask=analysis, window_sizes=config["window_sizes"])
|
||||
if convergence != recomputed_convergence:
|
||||
raise ValueError("convergence report differs from deterministic recomputation")
|
||||
fixed_summary = {
|
||||
"analysis_fluid_mask_definition": "exact intersection of q_target, q_blk, and q_ctl solver-derived fluid masks; no coordinate-generated mask, crop, or translation",
|
||||
"time_aggregation": "time mean only; acquisition-relative same-time matching is exact; physical-phase equality is not claimed",
|
||||
"shared_baseline_caveat": "dq_ctl and dq_tar share the same -q_blk term; agreement is not mechanism or causation evidence",
|
||||
"momentum_flux_proxy_warning": "incomplete proxy integral u_target*(u_target-u_ctl) dy; excludes pressure, viscous, transverse-flux, and control-surface terms",
|
||||
"uncertainty_claim": "none; prefix/suffix windows from one record are convergence diagnostics, not independent realizations",
|
||||
"phase_conditioned_outputs": "unsupported until independent cross-role physical-phase equality is proven",
|
||||
}
|
||||
if any(summary[key] != value for key, value in fixed_summary.items()): raise ValueError("fixed direct-dq semantic strings differ from authority")
|
||||
expected_alignment = "exact full acquisition-relative lattice timeline matching followed by exact physical-step inequality selection and exact float32 coordinates; no index trim, nearest-time, phase guess, crop, or translation"
|
||||
if config["alignment"] != expected_alignment: raise ValueError("fixed alignment semantics differ from authority")
|
||||
canonical_json(summary); canonical_json(config); canonical_json(input_hashes)
|
||||
return data
|
||||
@@ -0,0 +1,291 @@
|
||||
{
|
||||
"schema_id": "ccd-acquisition-independent-gate-remediation/v1",
|
||||
"reviewed_at_utc": "2026-08-03T18:14:30Z",
|
||||
"prior_gate": {
|
||||
"status": "FAILED",
|
||||
"high_findings": [
|
||||
"role CLI built runtime and exited without acquisition",
|
||||
"Legacy end_control_interval did not persist final EMA into self.action"
|
||||
],
|
||||
"medium_findings": "digest, object identity, controller/telemetry/artifact validation and end-to-end CPU coverage incomplete"
|
||||
},
|
||||
"remediation": [
|
||||
{
|
||||
"name": "solver_lifecycle",
|
||||
"status": "fixed_cpu_regression",
|
||||
"evidence": "final EMA/raw step persisted at boundary; next interval starts from prior final EMA"
|
||||
},
|
||||
{
|
||||
"name": "complete_runner",
|
||||
"status": "fixed_cpu_end_to_end",
|
||||
"evidence": "runner initializes, collects exact dual clocks, updates FIFO/clocks, captures mask/grid/state and atomically publishes; CLI invokes runner"
|
||||
},
|
||||
{
|
||||
"name": "identity_binding",
|
||||
"status": "fixed_static",
|
||||
"evidence": "expected SHA256 bound for two models/two configs/action formula; normalization/harmonics content persisted and hashed; policy archive spaces checked"
|
||||
},
|
||||
{
|
||||
"name": "object_identity",
|
||||
"status": "fixed_cpu",
|
||||
"evidence": "exact IDs/order/types/centers/radii/action width verified and persisted"
|
||||
},
|
||||
{
|
||||
"name": "validation",
|
||||
"status": "fixed_cpu",
|
||||
"evidence": "strict telemetry, controller, harmonic, normalization, action, timeline, DDF, clocks, hash and fsync/revalidation contracts"
|
||||
},
|
||||
{
|
||||
"name": "verification",
|
||||
"status": "passed_cpu_only",
|
||||
"evidence": "19 full CCD acquisition tests passed; strengthened preflight PASS; no CFD run"
|
||||
}
|
||||
],
|
||||
"re_review": {
|
||||
"status": "ready_focused_static_review",
|
||||
"runtime_success_claimed": false
|
||||
},
|
||||
"second_re_review": {
|
||||
"status": "FAILED",
|
||||
"findings": [
|
||||
"normalization trajectory was not isolated by exact post-stabilization restore",
|
||||
"Illusion PPO incorrectly consumed newly generated +11D target harmonics/normalization",
|
||||
"case-specific first policy observations were not exact",
|
||||
"field schedule was not prevalidated"
|
||||
]
|
||||
},
|
||||
"second_remediation": [
|
||||
{
|
||||
"name": "checkpoint_sequence",
|
||||
"status": "fixed_cpu",
|
||||
"evidence": "stabilize -> full solver checkpoint -> zero norm trajectory -> exact restore -> FIFO warmup; restored EMA/action exactly zero"
|
||||
},
|
||||
{
|
||||
"name": "illusion_training_reference",
|
||||
"status": "fixed_exact_hash",
|
||||
"evidence": "q_ctl binds SR_analysis/data/illusion/illusion_1L norm 9ec5... and two target-force harmonics f135...; +11D target eight-channel harmonics are measured phase evidence only"
|
||||
},
|
||||
{
|
||||
"name": "policy_initial_state",
|
||||
"status": "fixed_cpu",
|
||||
"evidence": "Karman first 12D input bitwise zero; Illusion first input is warmup boundary normalization plus frozen training harmonic phase zero; next-input update tested"
|
||||
},
|
||||
{
|
||||
"name": "schedule",
|
||||
"status": "fixed_prebuild",
|
||||
"evidence": "nonempty terminal-inclusive schedule and horizon divisibility rejected before runtime construction"
|
||||
},
|
||||
{
|
||||
"name": "verification",
|
||||
"status": "passed_cpu_only",
|
||||
"evidence": "24 pinball_math tests and strengthened preflight PASS; no CFD"
|
||||
}
|
||||
],
|
||||
"third_review": {
|
||||
"status": "FAILED",
|
||||
"finding": "solver absolute, acquisition-relative, and policy harmonic phase clocks were conflated"
|
||||
},
|
||||
"third_remediation": [
|
||||
{
|
||||
"name": "public_solver_clocks",
|
||||
"status": "fixed_cpu",
|
||||
"evidence": "solver_clock_state exposes absolute lattice/control counters; full checkpoint/restore preserves both"
|
||||
},
|
||||
{
|
||||
"name": "domain_separation",
|
||||
"status": "fixed_cpu",
|
||||
"evidence": "runtime/state/config distinguish solver_absolute_lattice_clock, solver_absolute_control_clock, acquisition_relative_lattice/control, policy_harmonic_phase_index"
|
||||
},
|
||||
{
|
||||
"name": "artifact_lineage",
|
||||
"status": "fixed_cpu",
|
||||
"evidence": "lattice_steps/sample_ids are solver absolute; acquisition_relative_lattice_steps added; control_indices rollout-relative; solver_absolute_control_indices added"
|
||||
},
|
||||
{
|
||||
"name": "verification",
|
||||
"status": "passed_cpu_only",
|
||||
"evidence": "25 tests including nonzero solver origin and per-interval assertions; no CFD"
|
||||
}
|
||||
],
|
||||
"final_static_review": {
|
||||
"status": "FAILED",
|
||||
"finding": "DualClockCollector called boundary-only solver_clock_state during active split snapshots"
|
||||
},
|
||||
"final_static_remediation": [
|
||||
{
|
||||
"name": "active_accessor",
|
||||
"status": "fixed",
|
||||
"evidence": "Legacy active_step_clock_state valid only with active split and >=1 completed step; reports absolute lattice and last fully completed control count/active interval index"
|
||||
},
|
||||
{
|
||||
"name": "collector_lifecycle",
|
||||
"status": "fixed_cpu",
|
||||
"evidence": "snapshots call active_step_clock_state; initialization and post-end checks call boundary-only solver_clock_state"
|
||||
},
|
||||
{
|
||||
"name": "verification",
|
||||
"status": "passed_cpu_only",
|
||||
"evidence": "strict fake flow rejects wrong accessor lifecycle; 26 tests and preflight PASS; no CFD"
|
||||
}
|
||||
],
|
||||
"final_independent_pre_cfd_static_gate": {
|
||||
"status": "PASS",
|
||||
"decision": "PASS after four fail-back reviews",
|
||||
"fail_back_reviews": 4,
|
||||
"verification": {
|
||||
"environment": "pinball_math",
|
||||
"cpu_tests": 26,
|
||||
"result": "passed"
|
||||
},
|
||||
"unresolved_static_findings": {
|
||||
"high": 0,
|
||||
"medium": 0
|
||||
},
|
||||
"runtime_authorization": {
|
||||
"authorized": true,
|
||||
"scope": "minimal sequential fresh-process pycuda smoke only",
|
||||
"production_authorized": false
|
||||
},
|
||||
"remaining_requirement": "Strict +11D Illusion replay/history compatibility remains required before production.",
|
||||
"runtime_success_claimed": false
|
||||
},
|
||||
"runtime_smoke_attempt_2026_08_03": {
|
||||
"status": "FAILED_NO_ARTIFACT",
|
||||
"scope": "authorized minimal sequential fresh-process pycuda smoke; karman_re100 q_target",
|
||||
"finding": "GPU execution reached the scheduled field snapshot, then velocity extraction rejected zero density at solver-flagged solid cells because the decoder incorrectly required valid density over the full lattice.",
|
||||
"artifact_status": "none published; transactional output remained absent",
|
||||
"scientific_diagnosis": "Legacy FLUID is bit 0 (0b00000001) in driver.py and kernels/macros.h; collision executes on FLUID cells while solid-cell populations are not a valid macroscopic-field domain.",
|
||||
"remediation": [
|
||||
"public Legacy current_step_velocity_field now copies completed solver flags and passes them to a pure mask-aware D2Q9 decoder",
|
||||
"decoder accepts only an exact boolean (x,y) mask or exact uint8 Legacy flags, validates finite nonzero density only on FLUID cells, and emits exact float32 zero on all nonfluid cells",
|
||||
"unused solid populations, including zero and nonfinite garbage, are ignored for field extraction; invalid fluid populations/density fail closed",
|
||||
"artifact validation requires finite full velocity fields, a saved nonempty solver fluid mask, and exact-zero solid velocities"
|
||||
],
|
||||
"verification": {
|
||||
"environment": "pinball_math",
|
||||
"focused_acquisition_tests": 30,
|
||||
"full_active_tests": 109,
|
||||
"result": "passed",
|
||||
"lints": "none"
|
||||
},
|
||||
"runtime_success_claimed": false,
|
||||
"readiness": "READY_TO_RETRY_SAME_MINIMAL_KARMAN_Q_TARGET_SMOKE",
|
||||
"production_authorized": false
|
||||
},
|
||||
"runtime_smoke_attempt_2026_08_03_coordinate_contract": {
|
||||
"status": "FAILED_NO_ARTIFACT",
|
||||
"scope": "authorized minimal sequential fresh-process pycuda smoke; karman_re100 q_target",
|
||||
"finding": "Second smoke reached artifact semantic validation; np.diff-based uniform float32 spacing rejected canonical x_D=np.arange(1280,dtype=float32)/20 because representable differences vary by ULP.",
|
||||
"artifact_status": "none published; transactional output remained absent",
|
||||
"remediation": [
|
||||
"runtime config persists exact coordinate schema, axis order, float32 dtype, per-axis count/origin/spacing, and lattice reference length",
|
||||
"one shared helper analytically generates float32 coordinates and validation requires strict finite float32 1D bitwise array equality; no tolerance, nearest matching, or np.diff uniformity inference",
|
||||
"regressions accept nx=1280,D=20 despite ULP-varying differences and reject shifted, alternate-cast/noncanonical, skipped, nonmonotonic, wrong-dtype, non-1D, and nonfinite arrays",
|
||||
"direct-dq retains exact cross-role x_D/y_D array equality"
|
||||
],
|
||||
"verification": {
|
||||
"environment": "pinball_math",
|
||||
"focused_acquisition_and_direct_dq_tests": 69,
|
||||
"full_active_tests": 111,
|
||||
"result": "passed",
|
||||
"lints": "none"
|
||||
},
|
||||
"runtime_success_claimed": false,
|
||||
"readiness": "READY_TO_RETRY_SAME_MINIMAL_KARMAN_Q_TARGET_SMOKE",
|
||||
"production_authorized": false
|
||||
},
|
||||
"runtime_smoke_attempt_2026_08_04_q_ctl_context": {
|
||||
"status": "REMEDIATED_READY_TO_RETRY",
|
||||
"scope": "authorized minimal sequential fresh-process pycuda smoke; karman_re100 q_target, q_blk, q_ctl",
|
||||
"passed_roles": {
|
||||
"q_target": "artifact published and manifest complete",
|
||||
"q_blk": "artifact published and manifest complete"
|
||||
},
|
||||
"failed_role": {
|
||||
"role": "q_ctl",
|
||||
"finding": "Stable Baselines PPO loaded on cuda:0; the subsequent Legacy PyCUDA kernel failed with invalid resource handle.",
|
||||
"artifact_status": "none published; transactional q_ctl output remained absent"
|
||||
},
|
||||
"remediation": [
|
||||
"_load_policy accepts only policy_device='cpu' and PPO.load receives device='cpu'",
|
||||
"build/runtime provenance persists policy_device=cpu separately from cfd_device logical 0",
|
||||
"policy verification rejects a non-CPU loaded policy device",
|
||||
"preflight continues to inspect the bound policy archive without importing PPO, Torch, PyCUDA, or initializing CUDA",
|
||||
"authoritative role, object/action, model, formula, and source identities remain unchanged"
|
||||
],
|
||||
"numerical_note": "Deterministic policy inference may have tiny backend-dependent floating-point differences; the retry smoke validates actual chosen production CPU inference rather than asserting CUDA/CPU bit identity.",
|
||||
"verification": {
|
||||
"environment": "pinball_math",
|
||||
"focused_acquisition_tests": 35,
|
||||
"full_active_tests": 114,
|
||||
"result": "passed",
|
||||
"lints": "none"
|
||||
},
|
||||
"runtime_success_claimed_for_q_ctl": false,
|
||||
"readiness": "READY_TO_RETRY_MINIMAL_KARMAN_Q_CTL_SMOKE",
|
||||
"production_authorized": false
|
||||
},
|
||||
"decoder_incident_2026_08_04": {
|
||||
"severity": "HIGH",
|
||||
"status": "REMEDIATED_CPU_VERIFIED_PRODUCTION_BLOCKED",
|
||||
"finding": "Active decoder divided D2Q9 momentum by density, while archived verified Legacy cfd_interface divides both momentum components by u0.",
|
||||
"impact": "All six smoke-20260804 artifacts are withdrawn and invalid under current schema; apparent 1e3-1e5 speeds were decoder artifacts, not current scientific evidence.",
|
||||
"withdrawn_artifacts": [
|
||||
"src/CCD_analysis/evidence/smoke-20260804/karman_re100/q_target",
|
||||
"src/CCD_analysis/evidence/smoke-20260804/karman_re100/q_blk",
|
||||
"src/CCD_analysis/evidence/smoke-20260804/karman_re100/q_ctl",
|
||||
"src/CCD_analysis/evidence/smoke-20260804/illusion_1.0L/q_target",
|
||||
"src/CCD_analysis/evidence/smoke-20260804/illusion_1.0L/q_blk",
|
||||
"src/CCD_analysis/evidence/smoke-20260804/illusion_1.0L/q_ctl"
|
||||
],
|
||||
"immutability": "do not overwrite; retain only as invalid-decoder negative evidence",
|
||||
"remediation": [
|
||||
"exact archived q/U0 formula on solver FLUID cells",
|
||||
"positive finite u0 and finite fluid-population checks",
|
||||
"nonfluid exact zero while ignoring nonfluid garbage",
|
||||
"field and point probe share the same ux/uy q/U0 decoder",
|
||||
"contract/artifact schema v2 plus decoder schema/formula SHA validation rejects old artifacts",
|
||||
"no arbitrary scientific magnitude bound in artifact validator"
|
||||
],
|
||||
"production_authorized": false,
|
||||
"next_path": "src/CCD_analysis/evidence/smoke-20260804-q-over-u0-v1",
|
||||
"verification": {
|
||||
"environment": "pinball_math",
|
||||
"full_active_tests": 114,
|
||||
"result": "passed",
|
||||
"withdrawn_artifact_loader_check": "all six rejected",
|
||||
"lints": "none"
|
||||
}
|
||||
},
|
||||
"control_boundary_lineage_remediation_2026_08_04": {
|
||||
"status": "CPU_VERIFIED_READY_FOR_FRESH_PILOT_RETRY",
|
||||
"finding": "artifact v2 retained only terminal FIFO and field-time telemetry, so sustained q_ctl policy/action lineage was not independently reconstructable when field cadence differed from control cadence",
|
||||
"schema": "ccd-acquisition-artifact/v3",
|
||||
"persisted": [
|
||||
"initial_fifo_history exact pre-first-action (150,12)",
|
||||
"boundary_observation_history interval averages (control_count,12)",
|
||||
"policy_source_observation_history and per-row SHA256",
|
||||
"policy_input_observation_history (control_count,s_dim)",
|
||||
"policy_harmonic_phase_indices zero-origin",
|
||||
"complete requested normalized/physical control histories"
|
||||
],
|
||||
"validator": [
|
||||
"terminal FIFO exact rolling append(initial,boundaries)",
|
||||
"policy sources exact initial-last then preceding boundaries for q_ctl; explicit zero/not-applicable for other roles",
|
||||
"policy inputs exact frozen normalization/harmonic reconstruction including Karman initial zero",
|
||||
"field-time requested telemetry indexes complete control histories",
|
||||
"solver/acquisition/policy clocks exact"
|
||||
],
|
||||
"verification": {
|
||||
"environment": "pinball_math",
|
||||
"focused_acquisition_tests": 36,
|
||||
"full_active_tests": 116,
|
||||
"result": "passed",
|
||||
"cfd_executed": false
|
||||
},
|
||||
"immutability": "all schema-v2 pilot artifacts remain immutable and are not current production evidence",
|
||||
"production_authorized": false,
|
||||
"readiness": "READY_FOR_FRESH_NO_CLOBBER_PILOT_RETRY",
|
||||
"next_path": "src/CCD_analysis/evidence/smoke-20260804-q-over-u0-lineage-v3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"active_archive_import_scan_matches": 0,
|
||||
"active_imports": {
|
||||
"src.CCD_analysis": "src/CCD_analysis/__init__.py",
|
||||
"src.CCD_analysis.acquisition": "src/CCD_analysis/acquisition/__init__.py",
|
||||
"src.CCD_analysis.direct_dq": "src/CCD_analysis/direct_dq/__init__.py",
|
||||
"src.CCD_analysis.evidence": "src/CCD_analysis/evidence/__init__.py",
|
||||
"src.CCD_analysis.original_ccd": "src/CCD_analysis/original_ccd/__init__.py",
|
||||
"src.CCD_analysis.tests": "src/CCD_analysis/tests/__init__.py"
|
||||
},
|
||||
"active_legacy_surface_scan_matches": 0,
|
||||
"archive_import_blocked": true,
|
||||
"archive_import_error": "src.CCD_analysis.archive is historical and non-importable",
|
||||
"environment": "pinball_math",
|
||||
"errors": [],
|
||||
"manifest_sha256": "8e36f79112d8dfe017259c28ec69add89412880d8622b254392c87c632a19bb3",
|
||||
"payload_counts": {
|
||||
"directories": 718,
|
||||
"file_bytes": 92230101305,
|
||||
"files": 1331,
|
||||
"symlinks": 16
|
||||
},
|
||||
"schema_version": 1,
|
||||
"status": "passed",
|
||||
"independent_post_archive_gate": {
|
||||
"reviewer": "independent",
|
||||
"status": "passed",
|
||||
"verified": {
|
||||
"all_files_hash_verified": true,
|
||||
"directories": 718,
|
||||
"files": 1331,
|
||||
"symlinks": 16,
|
||||
"omissions": 0,
|
||||
"archive_nesting_leaks": 0,
|
||||
"active_import_leaks": 0
|
||||
},
|
||||
"findings": {
|
||||
"high": [],
|
||||
"medium": [],
|
||||
"low_operational_concerns": [
|
||||
"The 92.23 GB archived payload remains inside the working tree; never force-add the ignored payload."
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
{"authoritative_evidence":{"illusion_acquisition":"src/CCD_analysis/evidence/production-20260804-q-over-u0-v3-illusion-authorized-450-fi1250","illusion_compatibility_certificate":"src/CCD_analysis/evidence/illusion-compatibility-certificate-v1.json","illusion_result":"src/CCD_analysis/evidence/direct-dq-illusion-authorized-burn90000","karman_acquisition":"src/CCD_analysis/evidence/production-20260804-q-over-u0-v3-karman-450-fi2000","karman_result":"src/CCD_analysis/evidence/direct-dq-karman-burn120000"},"claim_boundary":["same-time direct differences only","no physical-phase equality","no independent-realization uncertainty","no causal or mechanism claim","no CCD-versus-POD superiority claim","no real-case CCD analysis"],"high_findings":[],"low_findings":["working tree contains unrelated changes; authoritative artifacts remain hash-bound"],"medium_findings":[],"schema_id":"ccd-final-results-review/v1","status":"PASS","verification":{"diff_check":"passed","illusion_live_provenance_reload":"passed","karman_live_provenance_reload":"passed","lints":"no diagnostics","pinball_math_tests":"128 passed"}}
|
||||
@@ -0,0 +1 @@
|
||||
{"checks":{"boundary_history_shape_exact":true,"control_count_exact":true,"field_bound":true,"field_interval_exact":true,"initial_fifo_shape_exact":true,"normalized_action_bound":true,"physical_action_bound":true,"policy_input_bound":true,"policy_input_shape_exact":true,"policy_source_hash_count_exact":true,"raw_observation_bound":true},"claim_boundary":"runtime compatibility only; not accuracy, stability, physical-phase, or mechanism validation","contract":{"acceptance":"all active acquisition semantics pass; exact 150-control FIFO/policy lineage; all listed finite declared bounds pass","case_id":"illusion_1.0L","field_q_over_u0_absolute_limit":5.0,"normalized_action_absolute_limit":1.0,"physical_action_absolute_limit":0.1,"policy_input_absolute_limit":1.0,"raw_observation_absolute_limit":5.0,"required_control_count":150,"required_field_interval":600,"required_policy_input_width":14,"role":"q_ctl","schema_id":"ccd-illusion-policy-compatibility/v1"},"contract_sha256":"e3ee5ec16a4da986c4da56a9cb769547be316ad49fdba752be995cd0d495d633","metrics":{"field_q_over_u0_max_abs":2.4387996196746826,"normalized_action_max_abs":0.2324981540441513,"physical_action_max_abs":0.013582179322838783,"policy_input_max_abs":0.4606127142906189,"raw_observation_max_abs":1.4082750082015991},"pilot_manifest_sha256":"fdd3e9826dea167f78727bd00ed2da968cf407c2f6302c33969fc2bf815457a2","pilot_path":"/home/frank14f/DynamisLab/src/CCD_analysis/evidence/smoke-20260804-q-over-u0-lineage-v3/illusion_1.0L/q_ctl-pilot150","production_authorized":true,"schema_id":"ccd-illusion-policy-compatibility/v1","status":"PASS"}
|
||||
@@ -0,0 +1 @@
|
||||
{"authoritative_publication":"src/CCD_analysis/data/karman-dynamic/karman-dynamic-v1-production/publication-v2","claim_review":"PASS","code_review":"PASS","dense_deletion":"RETAIN_ALL: compact products do not suffice for every source-level downstream recomputation; zero has no compact fields","initial_finding":{"finding":"publication-v1 labeled the deliverable four-role but omitted the target reference bar","resolution":"published immutable publication-v2 with target, zero, constant_mean, and DRL bars","severity":"medium"},"remediation_cycles":1,"rounds":2,"schema_id":"ccd-karman-dynamic-final-review/v1","science_review":"PASS","scope":"all karman_dynamic code, tests, immutable result artifacts, final publication, claims, provenance, deletion decision","unresolved_high":[],"unresolved_medium":[],"verified":["fresh live parent reloads","manifest and canonical array hashes","four-role mean recomputation and decomposition closure","temporal and phase-domain essential recomputation","old real_ccd/direct_dq loader isolation","zero phase output prohibition","focused and full tests","changed-file lints","git diff whitespace"]}
|
||||
@@ -0,0 +1,98 @@
|
||||
{
|
||||
"schema_id": "ccd-original-ccd-derivation-post-todo-reviews/v1",
|
||||
"todo": "derive-original-ccd",
|
||||
"todo_status": "completed",
|
||||
"reviews": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "scope_review",
|
||||
"status": "PASS",
|
||||
"evidence": "Only active contract docs, private _reference.py, tests, evidence, README, and checkpoint changed; archive remained read-only; no CFD, production API, CLI, or artifact implementation."
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "equation_review",
|
||||
"status": "PASS",
|
||||
"evidence": "Contract states U in C^(M x N), P in C^(LQ x N), A=P U^dagger/(N sqrt(LQ)), A=R Sigma V^dagger, and reproduces Lyu equations (3.1)-(3.2), Q=128, Delta tau=2pi/128. A literal materialized P U^dagger test at noninteger-cycle length equals the factorized accumulator across five chunk sizes to tight tolerance."
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"name": "weighted_variational_review",
|
||||
"status": "PASS",
|
||||
"evidence": "Computation dtype includes U, P, and W before safe casts; exact W shape, finite, Hermitian, and PD checks precede square roots. Real U/P with genuinely complex non-diagonal HPD W is checked against direct A and Phi^H W Phi."
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"name": "timing_preprocessing_review",
|
||||
"status": "PASS",
|
||||
"evidence": "Pair uniqueness is by (block,timestamp); repeated local clocks and interleaved blocks are valid; per-block local clocks must be strictly ordered; integer offsets use independent per-block sequences; duplicate delays are allowed in declared order; no crossing/wrap/nearest."
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"name": "pod_equivalence_review",
|
||||
"status": "PASS",
|
||||
"evidence": "Complex full-rank weighted equivalence verifies singular values, phase-aligned simple modes, degenerate projectors, coefficients, and reconstruction. Truncation tests cover simple containment, partial degenerate intersection with equal value but incomplete projector, and strict loss without intersection. Standardization correction states unequal-scale non-equivalence and exact-degenerate isotropic invariance."
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"name": "verification_and_gate_review",
|
||||
"status": "PASS",
|
||||
"evidence": "Final independent math review passed after remediation. The focused suite has 20 passing tests and the full active suite has 83 passing tests; the published noisy case checks the traveling-pair projector and sign/phase-invariant individual mode ordering, and literal materialized P U^dagger equals the factorized accumulator across five chunk sizes."
|
||||
}
|
||||
],
|
||||
"production_api_available": false,
|
||||
"cfd_run": false,
|
||||
"independent_math_gate": {
|
||||
"required": true,
|
||||
"status": "FINAL_INDEPENDENT_PASS",
|
||||
"downstream_blocked": false,
|
||||
"next_todo_after_pass": "implement-original-ccd"
|
||||
},
|
||||
"verification": {
|
||||
"environment": "pinball_math",
|
||||
"focused": "20 passed",
|
||||
"full_active_suite": "83 passed",
|
||||
"lints": "clean",
|
||||
"cfd": "not run",
|
||||
"commit": "not created",
|
||||
"scoped_diff_check": "clean"
|
||||
},
|
||||
"independent_math_gate_history": [
|
||||
{
|
||||
"status": "FAIL",
|
||||
"severity": "HIGH_MEDIUM_WITH_LISTED_LOW_GAPS",
|
||||
"finding_count": 7,
|
||||
"findings": [
|
||||
"W dtype excluded before casting and complex W could be lost",
|
||||
"lag uniqueness/order semantics incorrect for repeated local clocks and interleaving",
|
||||
"false exact-degenerate standardization rotation-sensitivity claim",
|
||||
"truncated equality conditions did not distinguish value/vector/projector",
|
||||
"full-rank complex weighted equivalence coverage incomplete",
|
||||
"published N=10000 noise-100 example not executed",
|
||||
"Q1/odd/duplicate delays, complex multiobservable, left relation, centering and scalar gaps"
|
||||
]
|
||||
},
|
||||
{
|
||||
"status": "REMEDIATED_PENDING_INDEPENDENT_RE_REVIEW",
|
||||
"evidence": "Reference, contract, and 18 focused tests address every listed high/medium/low finding; production remains blocked."
|
||||
},
|
||||
{
|
||||
"status": "FOLLOWUP_MEDIUM_LOW_REMEDIATED_PENDING_RE_REVIEW",
|
||||
"findings": [
|
||||
"published noisy test lacked individual sign/phase-invariant mode-order checks",
|
||||
"factorized/chunked accumulator lacked literal materialized P U^dagger equivalence on noninteger cycles",
|
||||
"optional low fail-closed and chunk-invariance gaps"
|
||||
],
|
||||
"evidence": "Published frozen-seed overlaps are now predeclared with observed values and bounded tolerances; literal P/U equality holds across chunks 1,17,128,257,4096 at noninteger-cycle n=421 with identical RNG; duplicate field pairs, invalid delay kind/noninteger index, nonfinite U/P/W, and exact-only interpolation are tested."
|
||||
},
|
||||
{
|
||||
"status": "FINAL_INDEPENDENT_PASS",
|
||||
"evidence": "Independent math re-review passed after all recorded FAIL and remediation rounds; 20 focused tests and 83 full active tests pass. Production implementation is authorized, while CFD execution and empirical/CFD claims remain unauthorized."
|
||||
}
|
||||
],
|
||||
"production_implementation_authorized": true,
|
||||
"cfd_claims_authorized": false,
|
||||
"empirical_claims_authorized": false,
|
||||
"unique_next_entry": "implement-original-ccd pre-todo",
|
||||
"production_cfd_authorized": false
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
{
|
||||
"schema_id": "ccd-original-ccd-implementation-post-todo-reviews/v1",
|
||||
"todo": "implement-original-ccd",
|
||||
"todo_status": "COMPLETED_FINAL_INDEPENDENT_PASS_AFTER_REMEDIATION",
|
||||
"reviews": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "scope_review",
|
||||
"status": "PASS",
|
||||
"evidence": "Only active production CCD code/tests/docs/evidence/checkpoint changed; no plan/archive edit, CFD, real-case CCD, artifact writer, POD comparison, empirical claim, or commit."
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "scientific_review",
|
||||
"status": "PASS_AFTER_REMEDIATION",
|
||||
"evidence": "Initial independent review FAIL findings were remediated; independent re-review passed with no remaining severity-rated findings or new contradictions."
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"name": "code_review",
|
||||
"status": "PASS_AFTER_REMEDIATION",
|
||||
"evidence": "Initial independent review FAIL findings were remediated; independent re-review passed with no new high- or medium-severity regression."
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"name": "verification",
|
||||
"status": "PASS",
|
||||
"evidence": "Final pinball_math suite passed 105 active tests, including 42 focused production+derivation tests; edited-file IDE lints and scoped diff checks are clean; CFD and real-case CCD were not run."
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"name": "artifact_review",
|
||||
"status": "NOT_APPLICABLE_PASS",
|
||||
"evidence": "This todo implements an in-memory algorithm/reconstruction API only; the plan does not request an immutable result writer or persisted result schema."
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"name": "memory_checkpoint",
|
||||
"status": "PASS",
|
||||
"evidence": "One durable original-CCD implementation completion memory was saved after both independent reviews reached final PASS."
|
||||
}
|
||||
],
|
||||
"production_api_available": true,
|
||||
"independent_code_review": {
|
||||
"status": "FINAL_PASS",
|
||||
"prior_findings": [
|
||||
"Public LaggedObservables metadata was not cross-validated against its matrix shape and delays.",
|
||||
"Diagonal and dense encodings of the same HPD metric used inconsistent numerical conditioning thresholds."
|
||||
],
|
||||
"remediation": "fit validates finite nonempty LQxN matrix, positive integer L/Q, exact row/delay counts and unique valid field mapping before reshaping to validated (L,Q,N); diagonal and dense W share one relative conditioning threshold.",
|
||||
"re_review": "PASS; no new high- or medium-severity regression."
|
||||
},
|
||||
"independent_science_review": {
|
||||
"status": "FINAL_PASS",
|
||||
"prior_findings": [
|
||||
"Authoritative math contract still declared the production API unavailable after implementation.",
|
||||
"Authoritative centering contract described one joint switch while production explicitly supports independent U and P centering."
|
||||
],
|
||||
"remediation": "Updated ORIGINAL_CCD_MATH.md to record the passed implementation gate and available public API, and to define all four independent snapshot/observable centering regimes.",
|
||||
"re_review": "PASS; no remaining severity-rated findings or new contradictions."
|
||||
},
|
||||
"verification": {
|
||||
"environment": "pinball_math",
|
||||
"focused": "42 production+derivation tests passed",
|
||||
"full_active_suite": "105 passed",
|
||||
"lints": "clean",
|
||||
"cfd": "not run",
|
||||
"real_case_ccd": "not run",
|
||||
"commit": "not created"
|
||||
},
|
||||
"latest_production_reviews": {
|
||||
"initial_status": "FAIL",
|
||||
"finding_groups": {
|
||||
"A": "HPD conditioning was not scale invariant or dtype-epsilon documented.",
|
||||
"B": "LaggedObservables invariant was deferred to fit and row ordering was not explicit.",
|
||||
"C": "Left-function empirical identity, conjugation convention, direct-sum normalization and LQ view were incomplete.",
|
||||
"D": "No analytic N_valid endpoint-drop denominator test.",
|
||||
"E": "No selected-only centering regression with dropped outlier.",
|
||||
"F": "No fail-closed nonzero degenerate-block/null-vector reconstruction policy."
|
||||
},
|
||||
"remediation_status": "FINAL_INDEPENDENT_CODE_AND_SCIENCE_PASS",
|
||||
"remediation_evidence": [
|
||||
"One min/max HPD helper uses eps(real dtype)*M for diagonal and dense weights; 1e-20/1e20 scale tests pass.",
|
||||
"Frozen LaggedObservables validates finite LQxN, L/Q, delay count, explicit channel-major/delay-minor metadata and unique indices in __post_init__.",
|
||||
"left_functions_lq and direct complex P@a.conj()/N_valid identity are documented and tested.",
|
||||
"Analytic endpoint drop distinguishes N_valid=3 from wrong original N=4 and reports selected indices/count.",
|
||||
"Centering after selection excludes a dropped 1e12 field outlier and validates both selected means.",
|
||||
"Declared tolerances classify nonzero blocks/null modes; default reconstruction excludes nulls and refuses split blocks; config rank cannot clip a block; physical weighted projector invariance is tested."
|
||||
],
|
||||
"verification": "105 full active tests and 42 focused production+derivation tests passed; edited-file lints clean; scoped diff check clean"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"claim_boundary": {
|
||||
"forbidden_claims": [
|
||||
"CCD>POD",
|
||||
"causal",
|
||||
"mechanism",
|
||||
"response-time",
|
||||
"same-phase",
|
||||
"independent-realization",
|
||||
"observable prediction"
|
||||
],
|
||||
"mean_context_outside_ccd": [
|
||||
"mean effective actions",
|
||||
"authoritative mean dq_ctl"
|
||||
],
|
||||
"sigma_label": "cross-correlation strength"
|
||||
},
|
||||
"contract": {
|
||||
"centering": {
|
||||
"action_channels": true,
|
||||
"field_rows": true,
|
||||
"implicit": false
|
||||
},
|
||||
"field_estimand": "centered full-resolution dq_ctl=q_ctl-q_blk on persisted analysis_fluid_mask",
|
||||
"flatten_order": "component-major ux then uy; each component uses C-order (x-major,y-minor) analysis-mask order",
|
||||
"observable_source": "q_ctl effective_applied_action[-3:] at each exact admitted field time, native physical units",
|
||||
"observables": [
|
||||
"front_ccw_positive",
|
||||
"upper_ccw_positive",
|
||||
"lower_ccw_positive"
|
||||
],
|
||||
"path": "src/CCD_analysis/original_ccd/REAL_CASE_CCD_CONTRACT.md",
|
||||
"prohibited": [
|
||||
"POD pre-reduction",
|
||||
"whitening",
|
||||
"standardization",
|
||||
"nearest-time matching",
|
||||
"interpolation",
|
||||
"phase guessing",
|
||||
"silent trimming"
|
||||
],
|
||||
"publication": "future per-case fsync-backed atomic no-replace immutable schema with mandatory live-provenance reload",
|
||||
"streaming": "future provenance-validated mask-compressed two/three-pass implementation with explicit RAM/scratch budgets",
|
||||
"weighting": "coordinate trapezoid cell-area weights repeated by component; W^(1/2) coordinates; no area normalization"
|
||||
},
|
||||
"review": {
|
||||
"evidence": "Contract cross-checked against active original_ccd math/API, acquisition v3 effective-action semantics and lineage, and direct-dq v2 selection/mask/provenance contracts.",
|
||||
"status": "PASS",
|
||||
"verification": [
|
||||
"canonical JSON parse and checkpoint invariant assertions passed",
|
||||
"documentation whitespace checks passed",
|
||||
"git diff --check passed",
|
||||
"no code tests run because this todo changes contract/checkpoint documentation only"
|
||||
]
|
||||
},
|
||||
"schema_id": "ccd-real-ccd-contract-review/v1",
|
||||
"scope": {
|
||||
"cases": [
|
||||
"karman_re100",
|
||||
"illusion_1.0L"
|
||||
],
|
||||
"cfd_run": false,
|
||||
"data_or_artifacts_modified": false,
|
||||
"q": 1,
|
||||
"real_case_ccd_run": false,
|
||||
"streaming_implemented": false,
|
||||
"tau": 0
|
||||
},
|
||||
"todo": "real-ccd-contract",
|
||||
"todo_status": "COMPLETED_CONTRACT_ONLY",
|
||||
"unique_next_entry": "real-ccd-streaming"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"checks":["public load_result is sole data entry","verified transient centered snapshots only","no full MxN persistence","full 1280x512 grid","correct sigma/sigma^2 labels","Q=1 bars not lag curves","exact acquisition-relative steps","fixed-parity mismatch diagnostics are descriptive only","no causal/mechanism/response-time/CCD>POD claims"],"code_review":"PASS","initial_finding":{"finding":"single oversized reconstruction plate was impractical to render and did not initially include uy rank views","resolution":"split by selected timestamp and included ux/uy truth, reconstruction, and residual for ranks 1/2/3","severity":"medium"},"remaining_scope":"Illusion awaits user authorization; governing todo remains in_progress","remediation_cycles":1,"rounds":1,"schema_id":"ccd-real-ccd-figures-review/v1","science_review":"PASS","scope":"Karman-only subset of real-ccd-figures-review"}
|
||||
@@ -0,0 +1 @@
|
||||
{"Q":1,"action_means":{"front":-0.004750394590640402,"lower":0.03969304291531443,"upper":-0.0417400509895136},"artifact_manifest_sha256":"97d5cb300d642295bd3dffedd85d940fde06c8cc1c236fade390db78c3e3810e","artifact_path":"/home/frank14f/DynamisLab/src/CCD_analysis/evidence/real-ccd-karman-q1-tau0-burn120000-v1","canonical_array_hashes":{"action_mean":"67b3145d45ae4d618e4e4c39137d9fe0257ccd43557f3ce30d63bdf424cb985a","analysis_fluid_mask":"d8f661bea050bd3b60146b2051c8ec12a0491760599f885964306dd3089a842f","authoritative_mean_dq_ctl":"a45034707ee2db79a1c3af0c128b0bb8440d971e70567900b71ec79dd494778c","coefficients":"80e9f3b8b8475fc3ebbc952ab5a33f764f1af27eb0678a951b38a1087f6824a3","coordinate_weights":"56519c10c3b8a8d95b45244f6ca2cf48ca2e50bd6e8c594c7ad6db56d02ff2bc","cross_correlation":"7b8914e18344c044f4a5f4a58445eea502a38f4bc35a1fb327c35e9540b3a6cb","effective_actions":"64767cef6691c3cfedd06e400922e03b5403077bad30275b883ec801b64752a5","field_mean":"df4c0c131f787c3783ee1e1c6a900e5da08eb41fc984d66d181c68738a7963ba","identifiable_mode_mask":"75dd88e3c492f98b627e0d14e5d13716012b068ad46a1f0f7e26c4c24e8c6c0f","left_functions":"b896e02f82679cd1accca07debeea6b4c619f0d52a32fbc07ef0098f50067d97","physical_modes":"d2b06939cbf232fe08e571b2e03d3f12ac89cfdd588a73b84a8578ef98505f9f","q_blk_solver_fluid_mask":"d8f661bea050bd3b60146b2051c8ec12a0491760599f885964306dd3089a842f","q_ctl_solver_fluid_mask":"d8f661bea050bd3b60146b2051c8ec12a0491760599f885964306dd3089a842f","q_target_solver_fluid_mask":"0af572839be6a3ea6cc7c8d88eb2799295c2dc69aa4a4d12899ff7fd76252915","residual_block_boundaries":"b20bfed6cc98fd7eda53c0783d8c9ce4c66153b87c29a88d4fcdeab0155613b7","selected_acquisition_relative_lattice_steps":"644479c9c190d9513bd165e2e312fcccda45b2be211b4e24b8b16bf5a3d82597","selected_q_ctl_absolute_lattice_steps":"271e000641fa32c9eeda1f2518c2dfbd156548da542eaccbcb101badd6f1cdde","selected_timeline_indices":"696ea7f9b8802dae14563504cd0a6156c62ba77b68554b032c07ee0ea0e56d56","singular_values":"04c2776f76cd78ce086e799d3a9e92013cbaa590655728d5c3cd95c5000ffe8a","weighted_relative_residuals":"23ea5f9d0392775f9a69094c77e2f29c23dc44ee2fa43aa7670867352450545d","x_D":"96baa892f5c98be62214f1b500ba9a8b1dbc7c29d395544563420060bc29ec72","y_D":"1437d8d261b01d8da05550aad70a7de0ab40aed2d6ea9ed4f37eb82c070609f2"},"case_id":"karman_re100","cfd_run":false,"chunk_size":8,"decision":"PASS","degenerate_singular_blocks":[],"dimensions":{"coefficients":[3,120],"cross_correlation":[3,1299184],"modes":[1299184,3],"samples":120,"spatial_dof":1299184},"direct_dq_manifest_sha256":"f4c835d3da947e3a675815ec9884fcaeb737552ce19406d9b9e08c36eeb77814","direct_dq_path":"/home/frank14f/DynamisLab/src/CCD_analysis/evidence/direct-dq-karman-burn120000","essential_identity_recompute":"PASS: means, weights, cross-correlation, weighted orthonormality, SVD factorization, and coefficients recomputed by fresh loader","illusion_run":false,"immutable_artifact_modified":false,"interpretation":"Singular values are cross-correlation strengths, not field energy, explained variance, or canonical coefficients. Means are outside centered CCD.","memory":{"admission_basis":"all-fluid M estimate before authoritative loader; tight mask estimate also passed","conservative_all_fluid_estimated_peak_ram_bytes":16986938700,"decision":"PASS","estimated_peak_ram_bytes":16982785740,"estimated_scratch_bytes":0,"formula":"ceil(safety_margin * sum(terms)); loader residency explicitly included; no MxM or full float64 MxN term","ram_budget_bytes":47033803776,"raw_peak_ram_bytes":13586228592,"raw_scratch_bytes":0,"safety_margin":1.25,"scratch_budget_bytes":0,"terms_bytes":{"actions_coefficients_and_small_svd":6000,"chunk_float64_working_set":249443328,"cross_and_modes_float64":93541248,"field_mean_float64":10393472,"loader_decompression_and_copy_allowance":6606028800,"validated_direct_result_float32_fields":3774873600,"validated_live_acquisition_float32_fields":2831155200,"weights_and_roots_float64":20786944}},"null_tolerance":1.4676751183519247e-11,"numerical_rank":3,"provenance_reload":"VERIFIED: direct-dq and live acquisition inputs reread and essential identities recomputed","residual_block_boundaries":[1,2,3],"schema_id":"ccd-real-ccd-karman-review/v1","scientific_boundaries":"no CCD>POD, causal, mechanism, response-time, same-phase, independent-realization, uncertainty, or observable-prediction claim","singular_values":[0.14676751183519246,0.05920814023903697,0.0063884442355860915],"squared_cross_correlation_strengths":[0.02154070253029336,0.003505603870565469,4.081221975119316e-05],"tau":0,"unresolved_high_findings":[],"weighted_relative_residuals":[0.7823805118116768,0.4698397615223296,0.45542496362972573]}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
{"blockers":["Real-data preflight/equivalence gate intentionally deferred to real-ccd-preflight-tests; no real artifact was opened or published."],"review":{"cycle_count":1,"evidence":["three-pass mask-compressed Q=1/tau=0 implementation","mandatory direct-dq and live q_ctl provenance on load","atomic fsync no-replace transaction","canonical schema/hash/inventory validation","focused synthetic tests"],"findings":[],"remediation":"Initial focused-test defect corrected before review: RealCCDInput action/mean positional construction now preserves declared field ordering. No additional review/remediation cycle used.","status":"PASS"},"schema_id":"ccd-real-ccd-streaming-review/v1","scope":{"archive_or_data_modified":false,"cfd_run":false,"package":"src/CCD_analysis/real_ccd","real_artifact_preflight":false,"real_case_ccd_run":false,"real_results_published":false},"todo":"real-ccd-streaming","todo_status":"IMPLEMENTED_SYNTHETIC_TESTED_NO_REAL_PREFLIGHT_OR_RUN","unique_next_entry":"real-ccd-preflight-tests","verification":{"focused_real_ccd_tests":"5 passed","full_active_pinball_math_tests":"133 passed","git_diff_check":"passed","lints":"no errors"}}
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Karman DRL-vs-constant-mean campaign; CPU-safe imports."""
|
||||
from .artifacts import load_role_artifact
|
||||
from .contracts import CONTRACT,ROLES,constant_mean_provenance,contract_snapshot,effective_action_mean,verify_decomposition
|
||||
from .phase import evaluate_gate,load_phase_compact,publish_phase_compact,recover_phase
|
||||
from .dynamic_increment import load_dynamic_increment,publish_dynamic_increment
|
||||
from .temporal_ccd import TemporalConfig,TemporalTransaction,decompose_temporal,load_temporal_input,load_temporal_result
|
||||
from .phase_domain_ccd import PhaseDomainTransaction,decompose_phase_domain,load_phase_domain_result
|
||||
from .publication import load_dynamic_publication,publish_dynamic_figures
|
||||
from .orchestration import CampaignSchedule,orchestrate,role_command
|
||||
__all__=["CONTRACT","ROLES","CampaignSchedule","constant_mean_provenance","contract_snapshot","effective_action_mean","load_role_artifact","recover_phase","evaluate_gate","publish_phase_compact","load_phase_compact","publish_dynamic_increment","load_dynamic_increment","TemporalConfig","TemporalTransaction","load_temporal_input","decompose_temporal","load_temporal_result","PhaseDomainTransaction","decompose_phase_domain","load_phase_domain_result","publish_dynamic_figures","load_dynamic_publication","orchestrate","role_command","verify_decomposition"]
|
||||
@@ -0,0 +1,2 @@
|
||||
from .cli import main
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Immutable campaign wrappers around unchanged schema-v3 payloads."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import numpy as np
|
||||
from CCD_analysis.acquisition.artifacts import file_sha256,rename_noreplace
|
||||
from CCD_analysis.acquisition.validation import validate_acquisition_semantics
|
||||
from .contracts import *
|
||||
def _legacy(path,role):
|
||||
manifest=json.loads((path/"manifest.json").read_text()); config=json.loads((path/"config.json").read_text())
|
||||
for n,d in manifest["files"].items():
|
||||
if file_sha256(path/n)!=d: raise ValueError("legacy payload hash mismatch")
|
||||
with np.load(path/"fields.npz",allow_pickle=False) as f,np.load(path/"controller_state.npz",allow_pickle=False) as s: arrays={k:f[k] for k in f.files}; state={k:s[k] for k in s.files}
|
||||
validate_acquisition_semantics(arrays=arrays,config=config,state=state,manifest=manifest,expected_case=CASE_ID,expected_role=LEGACY_ROLE[role]); return arrays
|
||||
def publish_wrapper(staging,destination,*,role,campaign_id,warmup_intervals,collect_boundaries,constant_mean_provenance=None,smoke=False):
|
||||
staging,destination=Path(staging),Path(destination); arrays=_legacy(staging/"payload",role); count=len(arrays["sensors"]); stop=warmup_intervals+collect_boundaries
|
||||
if count<stop: raise ValueError("payload has fewer than warmup plus retained boundaries")
|
||||
np.savez_compressed(staging/"campaign_telemetry.npz",center_sensor_uy=arrays["sensors"][:,CENTER_SENSOR_UY_COLUMN],requested_physical_action=arrays["requested_physical_action"],effective_applied_action=arrays["effective_applied_action"],acquisition_relative_lattice_steps=arrays["acquisition_relative_lattice_steps"])
|
||||
meta={"schema_id":ARTIFACT_SCHEMA_ID,"complete":True,"campaign_id":campaign_id,"case_id":CASE_ID,"role":role,"legacy_role":LEGACY_ROLE[role],"retained_slice":[warmup_intervals,stop],"collect_boundaries":collect_boundaries,"phase_signal":"center sensor uy=sensors[:,3]","acquisition_mode":"smoke" if smoke else "production","contract":contract_snapshot(),"constant_mean_provenance":constant_mean_provenance,"legacy_manifest_sha256":file_sha256(staging/"payload/manifest.json")}
|
||||
(staging/"campaign.json").write_bytes(canonical_json(meta)); files={n:file_sha256(staging/n) for n in ("campaign.json","campaign_telemetry.npz")}; (staging/"campaign_manifest.json").write_bytes(canonical_json({"schema_id":ARTIFACT_SCHEMA_ID,"complete":True,"files":files,"payload_manifest_sha256":meta["legacy_manifest_sha256"]})); rename_noreplace(staging,destination); return load_role_artifact(destination,expected_role=role)
|
||||
def load_role_artifact(path,*,expected_role=None):
|
||||
path=Path(path); wrapper=json.loads((path/"campaign_manifest.json").read_text()); meta=json.loads((path/"campaign.json").read_text())
|
||||
if wrapper.get("schema_id")!=ARTIFACT_SCHEMA_ID or not wrapper.get("complete") or meta.get("schema_id")!=ARTIFACT_SCHEMA_ID or not meta.get("complete"): raise ValueError("campaign artifact incomplete/schema mismatch")
|
||||
if expected_role and meta["role"]!=expected_role: raise ValueError("campaign role mismatch")
|
||||
if meta.get("acquisition_mode") not in {"smoke","production"}: raise ValueError("campaign acquisition mode missing/invalid")
|
||||
for n,d in wrapper["files"].items():
|
||||
if file_sha256(path/n)!=d: raise ValueError("campaign wrapper hash mismatch")
|
||||
arrays=_legacy(path/"payload",meta["role"]); assert file_sha256(path/"payload/manifest.json")==wrapper["payload_manifest_sha256"]
|
||||
with np.load(path/"campaign_telemetry.npz",allow_pickle=False) as t: telemetry={k:t[k] for k in t.files}
|
||||
if not np.array_equal(telemetry["center_sensor_uy"],arrays["sensors"][:,3]): raise ValueError("center uy telemetry mismatch")
|
||||
start,stop=meta["retained_slice"]
|
||||
if stop-start!=meta["collect_boundaries"] or stop>len(telemetry["center_sensor_uy"]): raise ValueError("invalid retained slice")
|
||||
if meta["role"]=="constant_mean" and not isinstance(meta["constant_mean_provenance"],dict): raise ValueError("constant_mean provenance missing")
|
||||
return {"path":path,"metadata":meta,"telemetry":telemetry,"legacy_arrays":arrays}
|
||||
@@ -0,0 +1,38 @@
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
from .contracts import ROLES
|
||||
from .orchestration import CampaignSchedule,orchestrate
|
||||
from .runtime import execute_role
|
||||
from .phase import publish_phase_compact
|
||||
from .temporal_ccd import TemporalConfig, TemporalTransaction, decompose_temporal, load_temporal_input
|
||||
from .phase_domain_ccd import PhaseDomainTransaction,decompose_phase_domain
|
||||
from .publication import publish_dynamic_figures
|
||||
def main(argv=None):
|
||||
p=argparse.ArgumentParser(); s=p.add_subparsers(dest="command",required=True); common=argparse.ArgumentParser(add_help=False); common.add_argument("--campaign-id",required=True); common.add_argument("--warmup-intervals",type=int,default=480); common.add_argument("--collect-boundaries",type=int,default=360); common.add_argument("--launch-delay-seconds",type=float,default=120)
|
||||
role=s.add_parser("role",parents=[common]); role.add_argument("--role",choices=ROLES,required=True); role.add_argument("--output",type=Path,required=True); role.add_argument("--drl-artifact",type=Path); role.add_argument("--phase-artifact",type=Path); role.add_argument("--smoke",action="store_true")
|
||||
orch=s.add_parser("orchestrate",parents=[common]); orch.add_argument("--root",type=Path,required=True); orch.add_argument("--execute",action="store_true"); orch.add_argument("--smoke",action="store_true")
|
||||
phase=s.add_parser("phase-gate"); phase.add_argument("--role",choices=ROLES,default="drl"); phase.add_argument("--role-artifact",type=Path); phase.add_argument("--drl-artifact",type=Path); phase.add_argument("--output",type=Path,required=True)
|
||||
temporal=s.add_parser("temporal-ccd"); temporal.add_argument("--drl-artifact",type=Path,required=True); temporal.add_argument("--phase-artifact",type=Path,required=True); temporal.add_argument("--output",type=Path,required=True); temporal.add_argument("--chunk-size",type=int,default=8); temporal.add_argument("--ram-budget-bytes",type=int,required=True)
|
||||
phase_ccd=s.add_parser("phase-domain-ccd"); phase_ccd.add_argument("--drl-phase",type=Path,required=True); phase_ccd.add_argument("--constant-mean-phase",type=Path,required=True); phase_ccd.add_argument("--dynamic-increment",type=Path,required=True); phase_ccd.add_argument("--output",type=Path,required=True)
|
||||
publication=s.add_parser("publication"); publication.add_argument("--dynamic-increment",type=Path,required=True); publication.add_argument("--temporal-ccd",type=Path,required=True); publication.add_argument("--phase-domain-ccd",type=Path,required=True); publication.add_argument("--output",type=Path,required=True)
|
||||
a=p.parse_args(argv)
|
||||
if a.command=="publication":
|
||||
published=publish_dynamic_figures(a.dynamic_increment,a.temporal_ccd,a.phase_domain_ccd,a.output); print(json.dumps({"result":str(published.resolve())},sort_keys=True)); return 0
|
||||
if a.command=="phase-domain-ccd":
|
||||
result=decompose_phase_domain(a.drl_phase,a.constant_mean_phase,a.dynamic_increment)
|
||||
with PhaseDomainTransaction(a.output) as tx: tx.write(result); published=tx.publish()
|
||||
print(json.dumps({"result":str(published.resolve()),"summary":result.summary},sort_keys=True)); return 0
|
||||
if a.command=="temporal-ccd":
|
||||
inp=load_temporal_input(a.drl_artifact,a.phase_artifact); result=decompose_temporal(inp,streaming_config=TemporalConfig(a.chunk_size,a.ram_budget_bytes));
|
||||
with TemporalTransaction(a.output) as tx: tx.write(result); published=tx.publish()
|
||||
print(json.dumps({"result":str(published.resolve()),"summary":result.summary},sort_keys=True)); return 0
|
||||
if a.command=="phase-gate":
|
||||
source=a.role_artifact or a.drl_artifact
|
||||
if source is None: p.error("phase-gate requires --role-artifact (or legacy --drl-artifact)")
|
||||
result=publish_phase_compact(source,a.output,role=a.role); print(result["summary"]); return 0
|
||||
schedule=CampaignSchedule(a.campaign_id,a.warmup_intervals,a.collect_boundaries,a.launch_delay_seconds)
|
||||
if a.command=="role":
|
||||
execute_role(role=a.role,output=a.output,campaign_id=a.campaign_id,warmup_intervals=a.warmup_intervals,collect_boundaries=a.collect_boundaries,drl_artifact=a.drl_artifact,phase_artifact=a.phase_artifact,launch_delay_seconds=a.launch_delay_seconds,smoke=a.smoke); return 0
|
||||
for c in orchestrate(root=a.root,schedule=schedule,execute=a.execute,smoke=a.smoke): print(" ".join(c))
|
||||
return 0
|
||||
@@ -0,0 +1,31 @@
|
||||
"""CPU-only Karman dynamic-increment campaign contract."""
|
||||
from dataclasses import asdict,dataclass
|
||||
from hashlib import sha256
|
||||
import json
|
||||
from pathlib import Path
|
||||
import numpy as np
|
||||
SCHEMA_ID="ccd-karman-dynamic-campaign/v1"; ARTIFACT_SCHEMA_ID="ccd-karman-dynamic-role/v1"; CASE_ID="karman_re100"
|
||||
ROLES=("target","zero","drl","constant_mean"); EXECUTION_ORDER=("drl","constant_mean","target","zero"); LEGACY_ROLE={"target":"q_target","zero":"q_blk","drl":"q_ctl","constant_mean":"q_ctl"}
|
||||
CONTROL_INTERVAL=800; DEFAULT_WARMUP_INTERVALS=480; DEFAULT_COLLECT_BOUNDARIES=360; MIN_LAUNCH_COOLDOWN_SECONDS=30.; DEFAULT_LAUNCH_DELAY_SECONDS=120.
|
||||
CENTER_SENSOR_UY_COLUMN=3; EXPECTED_OPTANE_MOUNT=Path("/home/frank14f/optane"); DEFAULT_OPTANE_ROOT=EXPECTED_OPTANE_MOUNT/"DynamisLab/ccd/karman-dynamic"; DEFAULT_REPO_MAPPING=Path(__file__).resolve().parents[1]/"data/karman-dynamic"
|
||||
LEASE_PATH=Path(__file__).resolve().parents[3]/".runtime/ccd-karman-dynamic.lock"; COOLDOWN_PATH=LEASE_PATH.with_name("ccd-karman-dynamic-cooldown.json")
|
||||
@dataclass(frozen=True)
|
||||
class CampaignContract:
|
||||
case_id:str=CASE_ID; code_reynolds:int=100; physical_re_D:float=50.; roles:tuple=ROLES; control_interval:int=CONTROL_INTERVAL; phase_signal:str="center sensor uy=sensors[:,3]"; comparison:str="drl minus constant_mean"
|
||||
CONTRACT=CampaignContract()
|
||||
def canonical_json(v): return (json.dumps(v,sort_keys=True,separators=(",",":"),allow_nan=False)+"\n").encode()
|
||||
def contract_snapshot():
|
||||
v={"schema_id":SCHEMA_ID,"contract":asdict(CONTRACT),"legacy_role_mapping":LEGACY_ROLE,"claims":{"zero":"passive baseline only","target":"cloaking-error reference only","dynamic_comparison":"DRL minus fresh effective-action-mean constant control","phase_difference":"independent phase-conditioned means; not pointwise counterfactual or causal"}}; v["contract_sha256"]=sha256(canonical_json(v)).hexdigest(); return v
|
||||
def effective_action_mean(effective,retained_start):
|
||||
v=np.asarray(effective)
|
||||
if v.dtype!=np.float32 or v.ndim!=2 or v.shape[1]<3 or not np.isfinite(v).all(): raise ValueError("effective actions must be finite float32 (boundary, >=3)")
|
||||
if type(retained_start)is not int or retained_start<0 or retained_start>=len(v): raise ValueError("retained_start must select a nonempty fresh DRL interval")
|
||||
return np.mean(v[retained_start:,-3:],axis=0,dtype=np.float64).astype(np.float32)
|
||||
def constant_mean_provenance(*,drl_manifest_sha256,effective,retained_start):
|
||||
if not isinstance(drl_manifest_sha256,str) or len(drl_manifest_sha256)!=64: raise ValueError("DRL manifest SHA256 required")
|
||||
int(drl_manifest_sha256,16); mean=effective_action_mean(effective,retained_start); source=np.ascontiguousarray(np.asarray(effective)[retained_start:,-3:])
|
||||
return {"schema_id":"ccd-karman-constant-mean-provenance/v1","drl_manifest_sha256":drl_manifest_sha256,"retained_start":retained_start,"source_effective_action_sha256":sha256(source.tobytes()).hexdigest(),"constant_mean_physical_action":mean.tolist(),"symmetrized":False,"source":"fresh DRL retained effective_applied_action mean"}
|
||||
def verify_decomposition(q_d,q_c):
|
||||
d,c=np.asarray(q_d,dtype=np.float64),np.asarray(q_c,dtype=np.float64)
|
||||
if d.shape!=c.shape or d.ndim<1 or not np.isfinite(d).all() or not np.isfinite(c).all(): raise ValueError("matching finite arrays required")
|
||||
return bool(np.allclose(d-c,(d.mean(0)-c.mean(0))+((d-d.mean(0))-(c-c.mean(0))),rtol=1e-12,atol=1e-12))
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Immutable four-role Karman dynamic-increment statistics and phase differences."""
|
||||
from __future__ import annotations
|
||||
import json, shutil, tempfile
|
||||
from pathlib import Path
|
||||
import numpy as np
|
||||
from CCD_analysis.acquisition.artifacts import file_sha256, rename_noreplace
|
||||
from CCD_analysis.direct_dq.analysis import coordinate_weights
|
||||
from .artifacts import load_role_artifact
|
||||
from .contracts import canonical_json, verify_decomposition
|
||||
from .phase import load_phase_compact
|
||||
|
||||
SCHEMA_ID="ccd-karman-dynamic-increment/v1"
|
||||
ROLES=("drl","constant_mean","target","zero")
|
||||
|
||||
def _wrms(field, weights):
|
||||
return float(np.sqrt(np.sum(weights*np.sum(np.asarray(field,dtype=np.float64)**2,axis=0))/np.sum(weights)))
|
||||
|
||||
def _dense_statistics(role_path, common, weights):
|
||||
d=load_role_artifact(role_path); start,stop=d["metadata"]["retained_slice"]; a=d["legacy_arrays"]
|
||||
q=np.stack((a["ux"][start:stop][:,common],a["uy"][start:stop][:,common]),axis=1)
|
||||
mean=np.mean(q,axis=0,dtype=np.float64).astype(np.float32)
|
||||
fluct=_wrms(np.sqrt(np.mean((q-mean[None])**2,axis=0,dtype=np.float64)),weights)
|
||||
action=d["telemetry"]["effective_applied_action"][start:stop,-3:]
|
||||
stats={"retained_boundary_count":int(stop-start),"fluctuation_weighted_vector_rms":fluct,"effective_action_mean":np.mean(action,axis=0,dtype=np.float64).tolist(),"effective_action_rms":np.sqrt(np.mean(action.astype(np.float64)**2,axis=0)).tolist(),"effective_action_total_rms":float(np.sqrt(np.mean(action.astype(np.float64)**2)))}
|
||||
return mean,stats
|
||||
|
||||
def _phase_mean(path, common):
|
||||
info=load_phase_compact(path); s=info["summary"]
|
||||
if not s["gate_passed"]: return None
|
||||
with np.load(Path(path)/"compact.npz",allow_pickle=False) as z:
|
||||
role_mask=z["fluid_mask"]; fields=np.mean(z["cycle_bin_fields"],axis=0,dtype=np.float64).astype(np.float32)
|
||||
full=np.zeros((10,2,*role_mask.shape),np.float32); full[:,:,role_mask]=fields
|
||||
return full[:,:,common]
|
||||
|
||||
def publish_dynamic_increment(role_paths,phase_paths,output):
|
||||
output=Path(output)
|
||||
if output.exists(): raise FileExistsError(output)
|
||||
if set(role_paths)!=set(ROLES) or set(phase_paths)!=set(ROLES): raise ValueError("exactly four role and phase paths required")
|
||||
loaded={r:load_role_artifact(role_paths[r],expected_role=r) for r in ROLES}
|
||||
x=loaded["drl"]["legacy_arrays"]["x_D"]; y=loaded["drl"]["legacy_arrays"]["y_D"]
|
||||
for r,d in loaded.items():
|
||||
a=d["legacy_arrays"]
|
||||
if not np.array_equal(a["x_D"],x) or not np.array_equal(a["y_D"],y): raise ValueError(f"{r} grid mismatch")
|
||||
common=np.logical_and.reduce([loaded[r]["legacy_arrays"]["fluid_mask"] for r in ROLES])
|
||||
if common.sum()<4: raise ValueError("four-mask intersection too small")
|
||||
weights=(coordinate_weights(x)[:,None]*coordinate_weights(y)[None,:])[common]
|
||||
means={}; stats={}
|
||||
for r in ROLES: means[r],stats[r]=_dense_statistics(Path(role_paths[r]),common,weights)
|
||||
for r in ("zero","constant_mean","drl"):
|
||||
stats[r]["mean_target_error_weighted_vector_rms"]=_wrms(means[r]-means["target"],weights)
|
||||
stats["target"]["mean_target_error_weighted_vector_rms"]=0.0
|
||||
zero_constant=means["constant_mean"]-means["zero"]; constant_drl=means["drl"]-means["constant_mean"]
|
||||
phase={r:_phase_mean(phase_paths[r],common) for r in ROLES}; phase_available=phase["drl"] is not None and phase["constant_mean"] is not None
|
||||
arrays={"x_D":x,"y_D":y,"four_role_fluid_mask":common,"quadrature_weights":weights,"mean_drl":means["drl"],"mean_constant_mean":means["constant_mean"],"mean_target":means["target"],"mean_zero":means["zero"],"mean_increment_zero_to_constant":zero_constant,"mean_increment_constant_to_drl":constant_drl}
|
||||
phase_metrics={}
|
||||
if phase_available:
|
||||
total=phase["drl"]-phase["constant_mean"]; centered=(phase["drl"]-means["drl"])-(phase["constant_mean"]-means["constant_mean"])
|
||||
arrays.update({"phase_mean_drl":phase["drl"],"phase_mean_constant_mean":phase["constant_mean"],"phase_difference_total_drl_minus_constant":total,"phase_difference_centered_drl_minus_constant":centered})
|
||||
for r in ("target","zero"):
|
||||
if phase[r] is not None: arrays[f"phase_mean_{r}"]=phase[r]
|
||||
if phase["target"] is not None:
|
||||
for r in ("zero","constant_mean","drl"):
|
||||
if phase[r] is not None:
|
||||
vals=[_wrms(phase[r][b]-phase["target"][b],weights) for b in range(10)]; phase_metrics[r]={"target_error_by_bin_weighted_vector_rms":vals,"target_error_cycle_mean_weighted_vector_rms":float(np.mean(vals))}
|
||||
closure=total-((means["drl"]-means["constant_mean"])[None]+centered); closure_max=float(np.max(np.abs(closure)))
|
||||
if closure_max>2e-6 or not verify_decomposition(phase["drl"],phase["constant_mean"]): raise ValueError("phase decomposition closure failed")
|
||||
else: closure_max=None
|
||||
benefits={"zero_to_constant_overall_mean_control_benefit_target_error_reduction":stats["zero"]["mean_target_error_weighted_vector_rms"]-stats["constant_mean"]["mean_target_error_weighted_vector_rms"],"constant_to_drl_dynamic_increment_target_error_reduction":stats["constant_mean"]["mean_target_error_weighted_vector_rms"]-stats["drl"]["mean_target_error_weighted_vector_rms"],"zero_to_drl_total_target_error_reduction":stats["zero"]["mean_target_error_weighted_vector_rms"]-stats["drl"]["mean_target_error_weighted_vector_rms"]}
|
||||
benefit_closure=benefits["zero_to_constant_overall_mean_control_benefit_target_error_reduction"]+benefits["constant_to_drl_dynamic_increment_target_error_reduction"]-benefits["zero_to_drl_total_target_error_reduction"]
|
||||
parents={r:{"role_path":str(Path(role_paths[r]).resolve()),"role_campaign_manifest_sha256":file_sha256(Path(role_paths[r])/"campaign_manifest.json"),"phase_path":str(Path(phase_paths[r]).resolve()),"phase_manifest_sha256":file_sha256(Path(phase_paths[r])/"manifest.json"),"phase_gate_passed":phase[r] is not None} for r in ROLES}
|
||||
summary={"schema_id":SCHEMA_ID,"complete":True,"drl_constant_phase_differences_available":phase_available,"phase_gate_passed_by_role":{r:phase[r] is not None for r in ROLES},"phase_blockers":[r for r in ROLES if phase[r] is None],"mask_definition":"exact intersection of all four solver-derived fluid masks","analysis_fluid_point_count":int(common.sum()),"quadrature_rule":"coordinate_weights(x_D)*coordinate_weights(y_D) on common mask; not area-normalized","statistics":stats,"phase_target_error_metrics":phase_metrics,"benefits":benefits,"closure":{"benefit_additivity_absolute_residual":float(abs(benefit_closure)),"mean_increment_max_absolute_residual":float(np.max(np.abs((means['drl']-means['zero'])-(zero_constant+constant_drl)))),"phase_decomposition_max_absolute_residual":closure_max},"claims":"independent phase-conditioned trajectory means; not pointwise counterfactual, response, or causal effect","dense_fields_deleted":False,"parents":parents}
|
||||
output.parent.mkdir(parents=True,exist_ok=True); stage=Path(tempfile.mkdtemp(prefix=f".{output.name}.partial-",dir=output.parent))
|
||||
try:
|
||||
np.savez_compressed(stage/"arrays.npz",**arrays); (stage/"summary.json").write_bytes(canonical_json(summary)); files={p.name:file_sha256(p) for p in stage.iterdir() if p.is_file()}; (stage/"manifest.json").write_bytes(canonical_json({"schema_id":SCHEMA_ID,"complete":True,"files":files})); rename_noreplace(stage,output); return load_dynamic_increment(output)
|
||||
except Exception: shutil.rmtree(stage,ignore_errors=True); raise
|
||||
|
||||
def load_dynamic_increment(path):
|
||||
path=Path(path); manifest=json.loads((path/"manifest.json").read_text()); summary=json.loads((path/"summary.json").read_text())
|
||||
if manifest.get("schema_id")!=SCHEMA_ID or not manifest.get("complete") or summary.get("schema_id")!=SCHEMA_ID: raise ValueError("dynamic increment schema/incomplete")
|
||||
for n,h in manifest["files"].items():
|
||||
if file_sha256(path/n)!=h: raise ValueError("dynamic increment file hash mismatch")
|
||||
for r,p in summary["parents"].items():
|
||||
load_role_artifact(p["role_path"],expected_role=r); load_phase_compact(p["phase_path"])
|
||||
if file_sha256(Path(p["role_path"])/"campaign_manifest.json")!=p["role_campaign_manifest_sha256"] or file_sha256(Path(p["phase_path"])/"manifest.json")!=p["phase_manifest_sha256"]: raise ValueError("dynamic increment live provenance mismatch")
|
||||
with np.load(path/"arrays.npz",allow_pickle=False) as z:
|
||||
required={"x_D","y_D","four_role_fluid_mask","quadrature_weights","mean_drl","mean_constant_mean","mean_target","mean_zero","mean_increment_zero_to_constant","mean_increment_constant_to_drl"}
|
||||
if not required.issubset(z.files): raise ValueError("dynamic increment arrays incomplete")
|
||||
if np.max(np.abs((z["mean_drl"]-z["mean_zero"])-(z["mean_increment_zero_to_constant"]+z["mean_increment_constant_to_drl"])))>2e-6: raise ValueError("mean increment closure mismatch")
|
||||
return {"path":path,"summary":summary}
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Fresh-child campaign planning and fail-closed state."""
|
||||
from dataclasses import dataclass
|
||||
import json,os,subprocess,sys
|
||||
from pathlib import Path
|
||||
from .artifacts import load_role_artifact
|
||||
from .contracts import *
|
||||
from .safety import enforce_cooldown,exclusive_lease,require_execution_environment,validate_optane_storage
|
||||
@dataclass(frozen=True)
|
||||
class CampaignSchedule:
|
||||
campaign_id:str; warmup_intervals:int=DEFAULT_WARMUP_INTERVALS; collect_boundaries:int=DEFAULT_COLLECT_BOUNDARIES; launch_delay_seconds:float=DEFAULT_LAUNCH_DELAY_SECONDS
|
||||
def __post_init__(self):
|
||||
if not self.campaign_id or self.warmup_intervals<1 or self.collect_boundaries<1 or self.launch_delay_seconds<30: raise ValueError("campaign id, positive counts, and delay >=30 required")
|
||||
def role_command(*,role,output,schedule,drl_artifact=None,smoke=False):
|
||||
if role not in ROLES: raise ValueError("invalid role")
|
||||
c=[sys.executable,"-m","CCD_analysis.karman_dynamic","role","--campaign-id",schedule.campaign_id,"--role",role,"--output",str(output),"--warmup-intervals",str(schedule.warmup_intervals),"--collect-boundaries",str(schedule.collect_boundaries)]
|
||||
if drl_artifact is not None:c += ["--drl-artifact",str(drl_artifact)]
|
||||
if smoke:c.append("--smoke")
|
||||
return c
|
||||
def mark_campaign(root,*,campaign_id,status,failed_role=None,error=None):
|
||||
if status not in {"READY","RUNNING","QUARANTINED","FAILED","COMPLETE"}: raise ValueError("invalid status")
|
||||
root=Path(root); root.mkdir(parents=True,exist_ok=True); state={"schema_id":"ccd-karman-dynamic-state/v1","campaign_id":campaign_id,"status":status,"failed_role":failed_role,"error":error}; tmp=root/f".campaign_state.{os.getpid()}.tmp"; tmp.write_text(json.dumps(state,sort_keys=True)+"\n"); os.replace(tmp,root/"campaign_state.json"); return state
|
||||
def orchestrate(*,root,schedule,execute=False,smoke=False):
|
||||
root=Path(root); commands=[role_command(role=r,output=root/r,schedule=schedule,drl_artifact=(root/"drl" if r=="constant_mean" else None),smoke=smoke) for r in EXECUTION_ORDER]
|
||||
if not execute:return commands
|
||||
require_execution_environment(); backing=validate_optane_storage(repo_mapping=DEFAULT_REPO_MAPPING,optane_root=DEFAULT_OPTANE_ROOT,mount=EXPECTED_OPTANE_MOUNT)
|
||||
if backing!=root and backing not in root.resolve(strict=False).parents: raise ValueError("campaign root must be below validated Optane mapping")
|
||||
mark_campaign(root,campaign_id=schedule.campaign_id,status="RUNNING")
|
||||
for role,command in zip(EXECUTION_ORDER,commands):
|
||||
try: subprocess.run(command,check=True,env=dict(os.environ)); load_role_artifact(root/role,expected_role=role)
|
||||
except Exception as exc: mark_campaign(root,campaign_id=schedule.campaign_id,status="QUARANTINED",failed_role=role,error=repr(exc)); raise
|
||||
mark_campaign(root,campaign_id=schedule.campaign_id,status="COMPLETE"); return commands
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Fail-closed phase/stationarity gate and compact cycle-balanced fields."""
|
||||
from __future__ import annotations
|
||||
import json, shutil, tempfile
|
||||
from pathlib import Path
|
||||
import numpy as np
|
||||
from CCD_analysis.acquisition.artifacts import file_sha256, rename_noreplace
|
||||
from .artifacts import load_role_artifact
|
||||
from .contracts import canonical_json, constant_mean_provenance
|
||||
PHASE_SCHEMA_ID="ccd-karman-dynamic-phase-compact/v1"
|
||||
LIMITS={"minimum_complete_cycles":10,"maximum_period_cv":0.05,"maximum_amplitude_cv":0.10,"minimum_cycle_amplitude_fraction":0.25,"maximum_prefix_suffix_period_shift":0.05,"maximum_prefix_suffix_amplitude_shift":0.10,"maximum_double_crossing_fraction":0.0,"maximum_field_sensitivity_relative_rms":0.15}
|
||||
def _crossings(s,rising):
|
||||
s=np.asarray(s,np.float64); q=(s[:-1]<0)&(s[1:]>=0) if rising else (s[:-1]>0)&(s[1:]<=0); i=np.flatnonzero(q); return i-s[i]/(s[i+1]-s[i])
|
||||
def recover_phase(signal):
|
||||
y=np.asarray(signal)
|
||||
if y.dtype!=np.float32 or y.ndim!=1 or len(y)<3 or not np.isfinite(y).all(): raise ValueError("phase signal must be finite float32 vector")
|
||||
rising,falling=_crossings(y,True),_crossings(y,False); ids=np.full(len(y),-1,np.int64); phase=np.full(len(y),np.nan); amps=[]; double=[]
|
||||
for c,(left,right) in enumerate(zip(rising[:-1],rising[1:])):
|
||||
idx=np.flatnonzero((np.arange(len(y))>=left)&(np.arange(len(y))<right)); ids[idx]=c; phase[idx]=2*np.pi*(np.arange(len(y))[idx]-left)/(right-left); amps.append(float(np.ptp(y[idx]))); double.append(int(np.sum((falling>left)&(falling<right))!=1))
|
||||
return {"rising_crossings":rising,"falling_crossings":falling,"periods":np.diff(rising),"cycle_amplitudes":np.asarray(amps),"double_crossing":np.asarray(double,np.int8),"cycle_id":ids,"phase":phase}
|
||||
def _shift(a,b): return abs(float(a)-float(b))/max(abs(float(a)),abs(float(b)),np.finfo(float).eps)
|
||||
def evaluate_gate(signal):
|
||||
r=recover_phase(signal); p,a=r["periods"],r["cycle_amplitudes"]; n=len(p); h=n//2; pc=float(np.std(p,ddof=1)/np.mean(p)) if n>1 else float("inf"); ac=float(np.std(a,ddof=1)/np.mean(a)) if n>1 else float("inf"); low=a<LIMITS["minimum_cycle_amplitude_fraction"]*np.median(a) if n else np.ones(1,bool); ps=_shift(np.mean(p[:h]),np.mean(p[-h:])) if h else float("inf"); ass=_shift(np.mean(a[:h]),np.mean(a[-h:])) if h else float("inf"); df=float(np.mean(r["double_crossing"])) if n else 1.
|
||||
m={"retained_boundary_count":int(len(signal)),"rising_crossing_count":int(len(r["rising_crossings"])),"complete_cycle_count":int(n),"period_mean_boundaries":float(np.mean(p)) if n else None,"period_std_boundaries":float(np.std(p,ddof=1)) if n>1 else None,"period_cv":pc,"period_min_boundaries":float(np.min(p)) if n else None,"period_max_boundaries":float(np.max(p)) if n else None,"amplitude_mean":float(np.mean(a)) if n else None,"amplitude_cv":ac,"minimum_cycle_amplitude":float(np.min(a)) if n else None,"low_amplitude_cycle_count":int(np.sum(low)),"double_crossing_cycle_count":int(np.sum(r["double_crossing"])),"double_crossing_fraction":df,"prefix_suffix_period_shift":ps,"prefix_suffix_amplitude_shift":ass,"signal_mean":float(np.mean(signal)),"signal_std":float(np.std(signal)),"signal_peak_to_peak":float(np.ptp(signal))}
|
||||
tests=((n>=10,f"complete cycle count {n} < 10"),(pc<=.05,f"period CV {pc:.6g} > 0.05"),(ac<=.10,f"amplitude CV {ac:.6g} > 0.10"),(not np.any(low),f"low-amplitude cycles {int(np.sum(low))} > 0"),(df<=0,f"double-crossing fraction {df:.6g} > 0"),(ps<=.05,f"prefix/suffix period shift {ps:.6g} > 0.05"),(ass<=.10,f"prefix/suffix amplitude shift {ass:.6g} > 0.10")); return r,m,[msg for ok,msg in tests if not ok]
|
||||
def _bins(ux,uy,actions,mask,phase,ids,bins,origin):
|
||||
cycles=np.unique(ids[ids>=0]); fields=np.empty((len(cycles),bins,2,int(mask.sum())),np.float32); acts=np.empty((len(cycles),bins,3),np.float32); counts=np.zeros((len(cycles),bins),np.int64); bid=np.full(len(phase),-1,np.int64); valid=np.isfinite(phase); bid[valid]=np.floor(np.mod(phase[valid]-origin,2*np.pi)*bins/(2*np.pi)).astype(np.int64)
|
||||
for ci,c in enumerate(cycles):
|
||||
for b in range(bins):
|
||||
s=(ids==c)&(bid==b); counts[ci,b]=s.sum()
|
||||
if not s.any(): raise ValueError(f"empty cycle/bin {c}/{b} for {bins} bins")
|
||||
fields[ci,b,0]=np.mean(ux[s][:,mask],axis=0,dtype=np.float64); fields[ci,b,1]=np.mean(uy[s][:,mask],axis=0,dtype=np.float64); acts[ci,b]=np.mean(actions[s,-3:],axis=0,dtype=np.float64)
|
||||
return fields,acts,counts
|
||||
def _curve_distance(a,b,b_origin_fraction=0.):
|
||||
def weights(n,origin):
|
||||
u=((np.arange(120)+.5)/120-origin)*n-.5; lo=np.floor(u).astype(int); return lo%n,(lo+1)%n,(u-lo)[:,None]
|
||||
ai0,ai1,aw=weights(len(a),0.); bi0,bi1,bw=weights(len(b),b_origin_fraction); af=a.reshape(len(a),-1); bf=b.reshape(len(b),-1); err=energy=0.; count=0
|
||||
for left in range(0,af.shape[1],4096):
|
||||
aa=(1-aw)*af[ai0,left:left+4096]+aw*af[ai1,left:left+4096]; bb=(1-bw)*bf[bi0,left:left+4096]+bw*bf[bi1,left:left+4096]; err+=float(np.sum((aa-bb)**2)); energy+=float(np.sum((aa-aa.mean(0))**2)); count+=aa.size
|
||||
return float(np.sqrt(err/max(count,1))/max(np.sqrt(energy/max(count,1)),np.finfo(float).eps))
|
||||
def publish_phase_compact(role_path,output,*,role="drl"):
|
||||
if role not in {"drl","constant_mean","target","zero"}: raise ValueError("invalid phase role")
|
||||
role_path,output=Path(role_path),Path(output); d=load_role_artifact(role_path,expected_role=role); start,stop=d["metadata"]["retained_slice"]; a=d["legacy_arrays"]; signal=d["telemetry"]["center_sensor_uy"][start:stop]; r,m,blockers=evaluate_gate(signal); parent=file_sha256(role_path/"campaign_manifest.json")
|
||||
if output.exists(): raise FileExistsError(output)
|
||||
output.parent.mkdir(parents=True,exist_ok=True); stage=Path(tempfile.mkdtemp(prefix=f".{output.name}.partial-",dir=output.parent)); provenance=None
|
||||
try:
|
||||
if not blockers:
|
||||
ux,uy=a["ux"][start:stop],a["uy"][start:stop]; actions=d["telemetry"]["effective_applied_action"][start:stop]; mask=a["fluid_mask"]; primary,pacts,counts=_bins(ux,uy,actions,mask,r["phase"],r["cycle_id"],10,0.); ensembles={}; all_counts={}
|
||||
for bins,origin,name in ((8,0.,"bins8"),(12,0.,"bins12"),(10,np.pi/10,"bins10_half_shift")):
|
||||
q,_,c=_bins(ux,uy,actions,mask,r["phase"],r["cycle_id"],bins,origin); ensembles[name]=np.mean(q,axis=0,dtype=np.float64).astype(np.float32); all_counts[name]=c
|
||||
n=len(primary); h=n//2; base=np.mean(primary,axis=0,dtype=np.float64); splits={"odd_cycles":np.mean(primary[::2],axis=0,dtype=np.float64),"even_cycles":np.mean(primary[1::2],axis=0,dtype=np.float64),"prefix_cycles":np.mean(primary[:h],axis=0,dtype=np.float64),"suffix_cycles":np.mean(primary[-h:],axis=0,dtype=np.float64)}; sensitivity={k:_curve_distance(base,v,.05 if k=="bins10_half_shift" else 0.) for k,v in {**ensembles,**splits}.items()}; m["field_sensitivity_relative_rms"]=sensitivity
|
||||
unstable={k:v for k,v in sensitivity.items() if v>LIMITS["maximum_field_sensitivity_relative_rms"]}
|
||||
if unstable: blockers.append(f"field sensitivity relative RMS exceeds 0.15: {unstable}")
|
||||
if not blockers:
|
||||
np.savez_compressed(stage/"compact.npz",fluid_mask=mask,x_D=a["x_D"],y_D=a["y_D"],cycle_bin_fields=primary,cycle_bin_effective_actions=pacts,cycle_bin_counts=counts,cycle_ids=np.unique(r["cycle_id"][r["cycle_id"]>=0]),rising_crossings=r["rising_crossings"],periods=r["periods"],cycle_amplitudes=r["cycle_amplitudes"],**{f"ensemble_{k}":v for k,v in ensembles.items()},**{f"counts_{k}":v for k,v in all_counts.items()})
|
||||
if role=="drl":
|
||||
provenance=constant_mean_provenance(drl_manifest_sha256=parent,effective=d["telemetry"]["effective_applied_action"],retained_start=start); (stage/"constant_mean_provenance.json").write_bytes(canonical_json(provenance))
|
||||
summary={"schema_id":PHASE_SCHEMA_ID,"complete":True,"gate_passed":not blockers,"blockers":blockers,"quality_limits":LIMITS,"metrics":m,"phase_definition":"linear phase between independent rising zero crossings of retained center sensor uy","primary_bins":10,"sensitivity":{"bin_counts":[8,10,12],"origin":"zero and half-bin for 10","cycle_splits":["odd/even","prefix/suffix"]},"role":role,"source":{"absolute_path":str(role_path.resolve()),"campaign_manifest_sha256":parent,"payload_manifest_sha256":file_sha256(role_path/"payload/manifest.json"),"retained_slice":[start,stop]},"dense_fields_deleted":False,"constant_mean_provenance":provenance}; (stage/"summary.json").write_bytes(canonical_json(summary)); files={p.name:file_sha256(p) for p in stage.iterdir() if p.is_file()}; (stage/"manifest.json").write_bytes(canonical_json({"schema_id":PHASE_SCHEMA_ID,"complete":True,"files":files})); rename_noreplace(stage,output); return load_phase_compact(output)
|
||||
except Exception: shutil.rmtree(stage,ignore_errors=True); raise
|
||||
def load_phase_compact(path):
|
||||
path=Path(path); manifest=json.loads((path/"manifest.json").read_text()); summary=json.loads((path/"summary.json").read_text())
|
||||
if manifest.get("schema_id")!=PHASE_SCHEMA_ID or not manifest.get("complete") or summary.get("schema_id")!=PHASE_SCHEMA_ID: raise ValueError("phase compact schema/incomplete")
|
||||
for n,h in manifest["files"].items():
|
||||
if file_sha256(path/n)!=h: raise ValueError("phase compact file hash mismatch")
|
||||
role=summary.get("role","drl"); source=Path(summary["source"]["absolute_path"]); load_role_artifact(source,expected_role=role)
|
||||
if file_sha256(source/"campaign_manifest.json")!=summary["source"]["campaign_manifest_sha256"] or file_sha256(source/"payload/manifest.json")!=summary["source"]["payload_manifest_sha256"]: raise ValueError("phase compact live source provenance mismatch")
|
||||
if summary["gate_passed"]:
|
||||
with np.load(path/"compact.npz",allow_pickle=False) as z:
|
||||
required={"fluid_mask","x_D","y_D","cycle_bin_fields","cycle_bin_effective_actions","cycle_bin_counts","cycle_ids","rising_crossings","periods","cycle_amplitudes","ensemble_bins8","ensemble_bins12","ensemble_bins10_half_shift","counts_bins8","counts_bins12","counts_bins10_half_shift"}
|
||||
if set(z.files)!=required or z["cycle_bin_fields"].dtype!=np.float32 or z["cycle_bin_fields"].shape[1:3]!=(10,2) or np.any(z["cycle_bin_counts"]<=0): raise ValueError("phase compact arrays invalid")
|
||||
elif "constant_mean_provenance.json" in manifest["files"] or (path/"compact.npz").exists(): raise ValueError("failed gate published forbidden products")
|
||||
if role!="drl" and (summary.get("constant_mean_provenance") is not None or "constant_mean_provenance.json" in manifest["files"]): raise ValueError("non-DRL phase artifact contains DRL mean provenance")
|
||||
return {"path":path,"summary":summary}
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Exploratory circular phase-domain CCD for DRL minus constant-mean dynamics."""
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
import json, shutil, tempfile
|
||||
import numpy as np
|
||||
from CCD_analysis.acquisition.artifacts import file_sha256, rename_noreplace
|
||||
from CCD_analysis.direct_dq.schema import canonical_array_sha256
|
||||
from .contracts import canonical_json
|
||||
from .dynamic_increment import load_dynamic_increment
|
||||
from .phase import load_phase_compact
|
||||
|
||||
SCHEMA_ID="ccd-karman-phase-domain-exploratory/v1"
|
||||
CHANNELS=("front","upper","lower")
|
||||
VARIANTS=(("bins8",8,0.0),("primary",10,0.0),("bins12",12,0.0),("bins10_half_shift",10,0.5))
|
||||
HARMONIC_ORDERS=(1,2,3)
|
||||
CLAIM_BOUNDARY="exploratory circular phase co-variation only; phase offsets are not time-response lags, causality, mechanism, uncertainty, or observable prediction"
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PhaseDomainResult:
|
||||
arrays:dict[str,np.ndarray]; config:dict[str,Any]; summary:dict[str,Any]; input_hashes:dict[str,Any]
|
||||
|
||||
def _periodic_resample(curve,n,origin_fraction=0.0,order=None):
|
||||
x=np.asarray(curve,np.float64); old_n=len(x); old=(np.arange(old_n)+.5)/old_n; new=(np.arange(n)+.5+origin_fraction)/n
|
||||
if order is None: order=min((old_n-1)//2,4)
|
||||
out=np.broadcast_to(x.mean(0),(n,)+x.shape[1:]).copy(); flat=x.reshape(old_n,-1); outf=out.reshape(n,-1)
|
||||
for k in range(1,order+1):
|
||||
c=(2/old_n)*np.sum(flat*np.cos(2*np.pi*k*old)[:,None],axis=0); s=(2/old_n)*np.sum(flat*np.sin(2*np.pi*k*old)[:,None],axis=0)
|
||||
outf+=np.cos(2*np.pi*k*new)[:,None]*c+np.sin(2*np.pi*k*new)[:,None]*s
|
||||
return out
|
||||
|
||||
def _decompose(u,p,w):
|
||||
u=np.asarray(u,np.float64); p=np.asarray(p,np.float64); w=np.asarray(w,np.float64); n=u.shape[1]
|
||||
uc=u-u.mean(1,keepdims=True); pc=p-p.mean(1,keepdims=True); roots=np.sqrt(w)
|
||||
a=pc@(uc*roots[:,None]).T/(n*np.sqrt(3.0)); left,s,vh=np.linalg.svd(a,full_matrices=False); weighted=vh.T
|
||||
for k in range(weighted.shape[1]):
|
||||
pivot=int(np.argmax(np.abs(weighted[:,k])))
|
||||
if weighted[pivot,k]<0: weighted[:,k]*=-1; left[:,k]*=-1
|
||||
rank=int(np.sum(s>1e-10*s[0])) if len(s) and s[0]>0 else 0
|
||||
return {"cross":a,"left":left,"singular":s,"modes":weighted/roots[:,None],"rank":rank}
|
||||
|
||||
def _comparison(primary,other,w):
|
||||
r=min(primary["rank"],other["rank"],3); out={"rank":other["rank"],"leading_spectrum":other["singular"][:3].tolist()}
|
||||
if r:
|
||||
root=np.sqrt(w)[:,None]; vp=primary["modes"][:,:r]*root; vo=other["modes"][:,:r]*root
|
||||
out["projector_principal_cosines"]=np.linalg.svd(vp.T@vo,compute_uv=False).tolist(); out["minimum_projector_cosine"]=float(min(out["projector_principal_cosines"])); out["left_function_absolute_cosines"]=[float(abs(primary["left"][:,k]@other["left"][:,k])) for k in range(r)]
|
||||
else: out.update(projector_principal_cosines=[],minimum_projector_cosine=None,left_function_absolute_cosines=[])
|
||||
return out
|
||||
|
||||
def _load_inputs(drl_phase,constant_phase,dynamic):
|
||||
dp,cp=load_phase_compact(drl_phase),load_phase_compact(constant_phase); di=load_dynamic_increment(dynamic)
|
||||
if not dp["summary"]["gate_passed"] or dp["summary"].get("role","drl")!="drl" or not cp["summary"]["gate_passed"] or cp["summary"].get("role","drl")!="constant_mean": raise ValueError("passing DRL and constant_mean phase artifacts required")
|
||||
with np.load(Path(drl_phase)/"compact.npz",allow_pickle=False) as dz, np.load(Path(constant_phase)/"compact.npz",allow_pickle=False) as cz, np.load(Path(dynamic)/"arrays.npz",allow_pickle=False) as iz:
|
||||
if not np.array_equal(dz["fluid_mask"],cz["fluid_mask"]): raise ValueError("phase masks differ")
|
||||
common=iz["four_role_fluid_mask"].copy(); selector=common[dz["fluid_mask"]]
|
||||
if int(selector.sum())!=len(iz["quadrature_weights"]): raise ValueError("dynamic weights/common mask mismatch")
|
||||
means={r:iz[f"mean_{r}"].copy() for r in ("drl","constant_mean")}; weights=np.concatenate((iz["quadrature_weights"],iz["quadrature_weights"])).astype(np.float64); fields={}
|
||||
for name,_,_ in VARIANTS:
|
||||
kd="cycle_bin_fields" if name=="primary" else f"ensemble_{name}"; dd=np.mean(dz[kd],axis=0,dtype=np.float64) if name=="primary" else dz[kd].astype(np.float64); cc=np.mean(cz[kd],axis=0,dtype=np.float64) if name=="primary" else cz[kd].astype(np.float64)
|
||||
fields[name]=(dd[:,:,selector]-means["drl"][None])-(cc[:,:,selector]-means["constant_mean"][None])
|
||||
actions=np.mean(dz["cycle_bin_effective_actions"],axis=0,dtype=np.float64)
|
||||
return dp,cp,di,fields,actions,weights
|
||||
|
||||
def decompose_phase_domain(drl_phase,constant_phase,dynamic_increment)->PhaseDomainResult:
|
||||
dp,cp,di,fields,actions,w=_load_inputs(drl_phase,constant_phase,dynamic_increment); results={}; arrays={"coordinate_weights":w,"primary_phase_action_curve":actions}
|
||||
for name,n,origin in VARIANTS:
|
||||
u=fields[name].transpose(1,2,0).reshape(len(w),n); p=_periodic_resample(actions,n,origin_fraction=origin).T; r=_decompose(u,p,w); results[name]=r
|
||||
for key in ("cross","left","singular","modes"): arrays[f"{name}_{key}"]=r[key]
|
||||
primary=results["primary"]; comparisons={name:_comparison(primary,r,w) for name,r in results.items() if name!="primary"}; harmonic={}
|
||||
for order in HARMONIC_ORDERS:
|
||||
u0=fields["primary"].transpose(1,2,0).reshape(len(w),10).T; r=_decompose(_periodic_resample(u0,10,order=order).T,_periodic_resample(actions,10,order=order).T,w); harmonic[str(order)]=_comparison(primary,r,w)
|
||||
for key in ("cross","left","singular","modes"): arrays[f"harmonic_{order}_{key}"]=r[key]
|
||||
offsets=[]
|
||||
for off in range(10):
|
||||
r=_decompose(fields["primary"].transpose(1,2,0).reshape(len(w),10),np.roll((actions-actions.mean(0)).T,off,axis=1),w); signed=off if off<=5 else off-10
|
||||
offsets.append({"phase_offset_bins":signed,"phase_offset_radians":float(2*np.pi*signed/10),"leading_spectrum":r["singular"][:3].tolist(),"rank":r["rank"]})
|
||||
checks=list(comparisons.values())+list(harmonic.values()); stable=[x["minimum_projector_cosine"] for x in checks if x.get("minimum_projector_cosine") is not None]; left=[min(x["left_function_absolute_cosines"]) for x in checks if x["left_function_absolute_cosines"]]; rank_stable=all(x["rank"]==primary["rank"] for x in checks)
|
||||
decision="PASS_EXPLORATORY" if primary["rank"]>0 and rank_stable and min(stable,default=0)>=0.9 and min(left,default=0)>=0.8 else "DOWNGRADE"
|
||||
config={"schema_id":SCHEMA_ID,"field_estimand":"separately_centered_phase_coherent_difference_delta_q_prime_phase(phi)=(q_DRL(phi)-mean_q_DRL)-(q_constant_mean(phi)-mean_q_constant_mean)","observable_estimand":"DRL phase-conditioned three-channel effective_applied_action fluctuation","operator":"A=P(W^(1/2)U)^T/(N*sqrt(3Q)); Q=1","primary_bins":10,"sensitivity_bins":[8,12],"half_bin_origin":True,"harmonic_orders":list(HARMONIC_ORDERS),"action_grid_sensitivity":"periodic Fourier interpolation of immutable primary 10-bin DRL action curve, maximum order 4; harmonic tests truncate both U and P","phase_offset_semantics":"circular phase offsets only; not time-response lags","center_snapshots":True,"center_observables":True,"standardization":False,"whitening":False,"pod":False,"claim_boundary":CLAIM_BOUNDARY}
|
||||
summary={"schema_id":SCHEMA_ID,"decision":decision,"N":10,"Q":1,"M":len(w),"numerical_rank":primary["rank"],"primary_singular_values":primary["singular"].tolist(),"primary_squared_singular_values":(primary["singular"]**2).tolist(),"primary_left_functions":primary["left"].tolist(),"sensitivity":{"bin_and_origin":comparisons,"harmonic_order":harmonic},"circular_phase_offsets":offsets,"stability_gate":{"rank_stable":rank_stable,"minimum_projector_principal_cosine":min(stable,default=None),"minimum_matched_left_function_absolute_cosine":min(left,default=None),"thresholds":{"projector_cosine":0.9,"left_function_absolute_cosine":0.8}},"spectrum_label":"cross-correlation strength; not field energy or explained variance","claim_boundary":CLAIM_BOUNDARY,"scientific_contract_explicit":True}
|
||||
parents={"drl_phase":{"path":str(Path(drl_phase).resolve()),"manifest_sha256":file_sha256(Path(drl_phase)/"manifest.json")},"constant_mean_phase":{"path":str(Path(constant_phase).resolve()),"manifest_sha256":file_sha256(Path(constant_phase)/"manifest.json")},"dynamic_increment":{"path":str(Path(dynamic_increment).resolve()),"manifest_sha256":file_sha256(Path(dynamic_increment)/"manifest.json")}}; hashes={"parents":parents,"canonical_arrays":{k:canonical_array_sha256(v) for k,v in arrays.items()}}
|
||||
validate_phase_domain(arrays,config,summary,hashes); return PhaseDomainResult(arrays,config,summary,hashes)
|
||||
|
||||
def validate_phase_domain(arrays,config,summary,hashes):
|
||||
if config.get("schema_id")!=SCHEMA_ID or summary.get("schema_id")!=SCHEMA_ID or config.get("field_estimand")!="separately_centered_phase_coherent_difference_delta_q_prime_phase(phi)=(q_DRL(phi)-mean_q_DRL)-(q_constant_mean(phi)-mean_q_constant_mean)" or config.get("operator")!="A=P(W^(1/2)U)^T/(N*sqrt(3Q)); Q=1": raise ValueError("phase-domain scientific contract contradicted")
|
||||
if config.get("phase_offset_semantics")!="circular phase offsets only; not time-response lags" or any(config.get(k) is not False for k in ("standardization","whitening","pod")): raise ValueError("phase-domain preprocessing/offset contract contradicted")
|
||||
d={k:np.asarray(v) for k,v in arrays.items()}; m=len(d["coordinate_weights"])
|
||||
if summary.get("N")!=10 or summary.get("Q")!=1 or summary.get("M")!=m or d["primary_cross"].shape!=(3,m) or d["primary_left"].shape!=(3,3): raise ValueError("phase-domain dimensions invalid")
|
||||
if set(hashes)!={"parents","canonical_arrays"} or set(hashes["canonical_arrays"])!=set(d) or any(hashes["canonical_arrays"][k]!=canonical_array_sha256(v) for k,v in d.items()): raise ValueError("phase-domain hashes invalid")
|
||||
canonical_json(config); canonical_json(summary); canonical_json(hashes)
|
||||
|
||||
class PhaseDomainTransaction:
|
||||
def __init__(self,destination): self.destination=Path(destination); self.stage=None
|
||||
def __enter__(self):
|
||||
if self.destination.exists(): raise FileExistsError(self.destination)
|
||||
self.destination.parent.mkdir(parents=True,exist_ok=True); self.stage=Path(tempfile.mkdtemp(prefix=f".{self.destination.name}.partial-",dir=self.destination.parent)); return self
|
||||
def write(self,result):
|
||||
validate_phase_domain(result.arrays,result.config,result.summary,result.input_hashes); np.savez_compressed(self.stage/"arrays.npz",**result.arrays)
|
||||
for n,v in (("config.json",result.config),("summary.json",result.summary),("input_hashes.json",result.input_hashes)): (self.stage/n).write_bytes(canonical_json(v))
|
||||
files={p.name:file_sha256(p) for p in self.stage.iterdir()}; (self.stage/"manifest.json").write_bytes(canonical_json({"schema_id":SCHEMA_ID,"complete":True,"files":files}))
|
||||
def publish(self):
|
||||
load_phase_domain_result(self.stage,recompute=False); rename_noreplace(self.stage,self.destination); self.stage=None; load_phase_domain_result(self.destination,recompute=True); return self.destination
|
||||
def __exit__(self,*args):
|
||||
if self.stage is not None: shutil.rmtree(self.stage,ignore_errors=True)
|
||||
|
||||
def load_phase_domain_result(path,recompute=True):
|
||||
root=Path(path); manifest=json.loads((root/"manifest.json").read_text())
|
||||
if manifest.get("schema_id")!=SCHEMA_ID or not manifest.get("complete") or set(manifest.get("files",{}))!={"arrays.npz","config.json","summary.json","input_hashes.json"}: raise ValueError("phase-domain manifest invalid")
|
||||
for n,h in manifest["files"].items():
|
||||
if file_sha256(root/n)!=h: raise ValueError("phase-domain file hash mismatch")
|
||||
with np.load(root/"arrays.npz",allow_pickle=False) as z: arrays={k:z[k].copy() for k in z.files}
|
||||
config=json.loads((root/"config.json").read_text()); summary=json.loads((root/"summary.json").read_text()); hashes=json.loads((root/"input_hashes.json").read_text()); validate_phase_domain(arrays,config,summary,hashes)
|
||||
for parent in hashes["parents"].values():
|
||||
if file_sha256(Path(parent["path"])/"manifest.json")!=parent["manifest_sha256"]: raise ValueError("phase-domain live parent identity changed")
|
||||
if recompute:
|
||||
p=hashes["parents"]; fresh=decompose_phase_domain(p["drl_phase"]["path"],p["constant_mean_phase"]["path"],p["dynamic_increment"]["path"])
|
||||
for k in arrays: np.testing.assert_allclose(arrays[k],fresh.arrays[k],rtol=2e-11,atol=2e-12)
|
||||
return {"arrays":arrays,"config":config,"summary":summary,"input_hashes":hashes,"manifest":manifest,"provenance_validation":"VERIFIED: live compact and dynamic-increment parents reread; essential decomposition recomputed" if recompute else "VERIFIED_HASHES_AND_LIVE_PARENTS"}
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Deterministic artifact-only publication for the Karman dynamic campaign."""
|
||||
from __future__ import annotations
|
||||
import json, os, shutil, uuid
|
||||
from pathlib import Path
|
||||
import matplotlib
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
import numpy as np
|
||||
from CCD_analysis.acquisition.artifacts import file_sha256, rename_noreplace
|
||||
from .contracts import canonical_json
|
||||
from .dynamic_increment import load_dynamic_increment
|
||||
from .temporal_ccd import load_temporal_result, CHANNELS
|
||||
from .phase_domain_ccd import load_phase_domain_result
|
||||
SCHEMA_ID="ccd-karman-dynamic-publication/v1"
|
||||
STEMS=("01_four_role_mean_performance","02_drl_constant_phase_difference","03_temporal_ccd_spectrum_sensitivity","04_temporal_ccd_leading_modes","05_temporal_ccd_left_lag_functions","06_phase_domain_ccd_downgrade")
|
||||
def _save(fig,root,stem):
|
||||
names=[]
|
||||
for ext in ("png","pdf"):
|
||||
q=root/f"{stem}.{ext}"; fig.savefig(q,dpi=300 if ext=="png" else None,bbox_inches="tight",metadata={"Creator":"CCD_analysis.karman_dynamic.publication"}); names.append(q.name)
|
||||
plt.close(fig); return names
|
||||
def _wrms(field,w):
|
||||
a=np.asarray(field,float); return float(np.sqrt(np.sum(w*np.sum(a*a,axis=0))/np.sum(w)))
|
||||
def _components(v,mask):
|
||||
n=int(mask.sum()); out=[]
|
||||
for a in (v[:n],v[n:]):
|
||||
f=np.full(mask.shape,np.nan); f[mask]=a; out.append(f)
|
||||
return out
|
||||
def _panel(ax,f,x,y,mask,lim,title):
|
||||
ax.pcolormesh(x,y,f.T,shading="nearest",cmap="RdBu_r",vmin=-lim,vmax=lim,rasterized=True); solid=np.ma.masked_where(mask,np.ones(mask.shape)); ax.pcolormesh(x,y,solid.T,shading="nearest",cmap="Greys",vmin=0,vmax=1); ax.set(title=title,xlabel="x/D",ylabel="y/D"); ax.set_aspect("equal")
|
||||
def _source(root): return {"path":str(root.resolve()),"manifest_sha256":file_sha256(root/"manifest.json")}
|
||||
def publish_dynamic_figures(dynamic_root,temporal_root,phase_domain_root,output):
|
||||
dynamic_root,temporal_root,phase_domain_root=map(Path,(dynamic_root,temporal_root,phase_domain_root)); destination=Path(output)
|
||||
if destination.exists(): raise FileExistsError(destination)
|
||||
dynamic=load_dynamic_increment(dynamic_root); temporal=load_temporal_result(temporal_root,recompute=True); phase=load_phase_domain_result(phase_domain_root,recompute=True)
|
||||
if phase["summary"]["decision"]!="DOWNGRADE": raise ValueError("frozen phase-domain decision must be DOWNGRADE")
|
||||
partial=destination.with_name(f".{destination.name}.partial.{os.getpid()}.{uuid.uuid4().hex}"); partial.mkdir(parents=True)
|
||||
try:
|
||||
with np.load(dynamic_root/"arrays.npz",allow_pickle=False) as z: inc={k:z[k].copy() for k in z.files}
|
||||
ds=dynamic["summary"]; ta,ts=temporal["arrays"],temporal["summary"]; ps=phase["summary"]; files=[]
|
||||
roles=("target","zero","constant_mean","drl"); errors=[ds["statistics"][r]["mean_target_error_weighted_vector_rms"] for r in roles]; b=ds["benefits"]
|
||||
fig,ax=plt.subplots(figsize=(6.4,3.8),layout="constrained"); bars=ax.bar(("Target","Zero","Constant mean","DRL"),errors,color=("#59A14F","#777777","#4C78A8","#E45756")); ax.bar_label(bars,fmt="%.4f",padding=3); ax.annotate(f"-{b['zero_to_constant_overall_mean_control_benefit_target_error_reduction']:.4f}",xy=(2,errors[2]),xytext=(1,errors[1]+.012),arrowprops={"arrowstyle":"->"},ha="center"); ax.annotate(f"-{b['constant_to_drl_dynamic_increment_target_error_reduction']:.4f}",xy=(3,errors[3]),xytext=(2,errors[2]+.012),arrowprops={"arrowstyle":"->"},ha="center"); ax.set(ylabel="Mean-field target error (weighted vector RMS)",ylim=(0,max(errors)*1.25),title="Mean performance decomposition"); ax.grid(axis="y",alpha=.25); files+=_save(fig,partial,STEMS[0])
|
||||
bins=np.arange(1,11); cp=ds["phase_target_error_metrics"]["constant_mean"]["target_error_by_bin_weighted_vector_rms"]; dp=ds["phase_target_error_metrics"]["drl"]["target_error_by_bin_weighted_vector_rms"]; centered=inc["phase_difference_centered_drl_minus_constant"]; w=inc["quadrature_weights"]; cr=[_wrms(centered[i],w) for i in range(10)]
|
||||
fig,axs=plt.subplots(1,2,figsize=(10,3.7),layout="constrained"); axs[0].plot(bins,cp,"o-",label="Constant mean"); axs[0].plot(bins,dp,"o-",label="DRL"); axs[0].set(ylabel="Phase target error (weighted vector RMS)",title="Independent 10-bin phase means"); axs[0].legend(); axs[1].plot(bins,cr,"o-",color="#7A5195"); axs[1].set(ylabel="Centered DRL-constant difference (weighted vector RMS)",title="Phase-coherent increment"); [a.set(xlabel="Phase bin",xticks=bins) for a in axs]; [a.grid(alpha=.25) for a in axs]; fig.suptitle("DRL versus constant mean; zero omitted because its phase gate failed"); files+=_save(fig,partial,STEMS[1])
|
||||
sigma=ta["primary_singular_values"]; sens=ts["sensitivity"]; fig,axs=plt.subplots(1,2,figsize=(10,3.7),layout="constrained"); axs[0].semilogy(np.arange(1,len(sigma)+1),sigma,"o-",ms=3); axs[0].set(xlabel="Mode",ylabel="Cross-correlation strength",title="Negative-lag CCD spectrum"); spectra=[sigma[:3]]+[np.asarray(v["common_support_vs_primary"]["leading_spectrum"]) for v in sens]; labels=["-17...0"]+[f"{v['lags'][0]}...0" for v in sens]
|
||||
for vals,label in zip(spectra,labels): axs[1].plot((1,2,3),vals/sigma[:3],"o-",label=label)
|
||||
axs[1].axhline(1,color="black",lw=.7); axs[1].set(xlabel="Leading mode",ylabel="Strength / primary strength",xticks=(1,2,3),title="Common-support sensitivity (N=21)"); axs[1].legend(title="Lag window"); [a.grid(alpha=.25) for a in axs]; fig.suptitle("Closed-loop temporal co-variation; not causality or response time"); files+=_save(fig,partial,STEMS[2])
|
||||
mask,x,y=ta["fluid_mask"],ta["x_D"],ta["y_D"]; comps=[_components(ta["primary_physical_modes"][:,j],mask) for j in range(3)]; fig,axs=plt.subplots(3,2,figsize=(12,7),sharex=True,sharey=True,layout="constrained")
|
||||
for j,c in enumerate(comps):
|
||||
lim=float(np.percentile(np.abs(np.concatenate([q[np.isfinite(q)] for q in c])),99)) or 1
|
||||
for ax,q,label in zip(axs[j],c,("ux","uy")): _panel(ax,q,x,y,mask,lim,f"Mode {j+1} {label}")
|
||||
fig.suptitle("Leading temporal CCD physical modes (full grid; per-mode symmetric scale)"); files+=_save(fig,partial,STEMS[3])
|
||||
left=ta["primary_left_functions"].reshape(3,len(ta["primary_lags"]),-1); fig,axs=plt.subplots(1,3,figsize=(11,3.4),sharey=True,layout="constrained")
|
||||
for j,ax in enumerate(axs):
|
||||
for ch,name in enumerate(CHANNELS): ax.plot(ta["primary_lags"],left[ch,:,j],"o-",ms=3,label=name)
|
||||
peak=ts["primary_left_lag_metrics"]["modes"][j]["peak_lag_boundaries"]; ax.axvline(peak,color="black",ls="--",lw=.8); ax.set(title=f"Mode {j+1}; energy peak {peak}",xlabel="tau / 800 lattice steps"); ax.grid(alpha=.25)
|
||||
axs[0].set_ylabel("Left lag-function component"); axs[-1].legend(); fig.suptitle("tau < 0 means action precedes field; lag structure is descriptive"); files+=_save(fig,partial,STEMS[4])
|
||||
primary=np.asarray(ps["primary_singular_values"][:3]); variants={"10 bins":primary,"8 bins":ps["sensitivity"]["bin_and_origin"]["bins8"]["leading_spectrum"],"12 bins":ps["sensitivity"]["bin_and_origin"]["bins12"]["leading_spectrum"],"half-bin":ps["sensitivity"]["bin_and_origin"]["bins10_half_shift"]["leading_spectrum"],"harmonic 1":ps["sensitivity"]["harmonic_order"]["1"]["leading_spectrum"]}; fig,axs=plt.subplots(1,2,figsize=(10,3.7),layout="constrained"); axs[0].bar((1,2,3),primary,color="#F58518"); axs[0].set(xlabel="Mode",ylabel="Cross-correlation strength",title="Primary 10-bin spectrum")
|
||||
for name,vals in variants.items(): axs[1].plot((1,2,3),np.asarray(vals)/primary,"o-",label=name)
|
||||
axs[1].set(xlabel="Mode",ylabel="Strength / primary strength",xticks=(1,2,3),title="Resolution and harmonic sensitivity"); axs[1].legend(fontsize=8); [a.grid(alpha=.25) for a in axs]; fig.suptitle("PHASE-DOMAIN CCD - DOWNGRADE: first-harmonic rank is 2, not 3"); files+=_save(fig,partial,STEMS[5])
|
||||
report={"schema_id":SCHEMA_ID,"artifact_only":True,"sources":{"dynamic_increment":_source(dynamic_root),"temporal_ccd":_source(temporal_root),"phase_domain_ccd":_source(phase_domain_root)},"source_reload":{"dynamic_increment":"VERIFIED live four-role and phase parents","temporal_ccd":temporal["provenance_validation"],"phase_domain_ccd":phase["provenance_validation"]},"figure_files":files,"mean_target_errors":dict(zip(roles,errors)),"mean_target_error_reductions":b,"phase_target_error_cycle_means":{r:ds["phase_target_error_metrics"][r]["target_error_cycle_mean_weighted_vector_rms"] for r in ("constant_mean","drl")},"centered_phase_difference_weighted_vector_rms_by_bin":cr,"zero_phase_output":"PROHIBITED: zero failed the phase gate; no zero phase result is plotted or claimed","temporal_leading_singular_values":sigma[:3].tolist(),"temporal_common_support_sensitivity":sens,"phase_domain_decision":ps["decision"],"phase_domain_leading_singular_values":primary.tolist(),"phase_domain_stability_gate":ps["stability_gate"],"claim_boundary":"Independent trajectory statistics and closed-loop co-variation only; no pointwise counterfactual, causal, mechanism, response-time, uncertainty, CCD>POD, or explained-variance claim."}; (partial/"RESULTS.json").write_bytes(canonical_json(report)); fraction=100*b["constant_to_drl_dynamic_increment_target_error_reduction"]/b["zero_to_drl_total_target_error_reduction"]
|
||||
lines=["# Karman dynamic-increment publication results","","All outputs were generated from fresh verified reloads of immutable artifacts; no CFD or CUDA was used.","","## Mean performance","",f"Mean target error decreases from zero `{errors[1]:.7f}` to constant mean `{errors[2]:.7f}` (reduction `{b['zero_to_constant_overall_mean_control_benefit_target_error_reduction']:.7f}`), then to DRL `{errors[3]:.7f}` (additional reduction `{b['constant_to_drl_dynamic_increment_target_error_reduction']:.7f}`). The latter is about {fraction:.1f}% of the total zero-to-DRL reduction.","","## Phase and CCD boundary","",f"The cycle-mean 10-bin target error is `{report['phase_target_error_cycle_means']['constant_mean']:.7f}` for constant mean and `{report['phase_target_error_cycle_means']['drl']:.7f}` for DRL. Zero is absent because it failed the phase gate.","",f"Temporal leading strengths are `{report['temporal_leading_singular_values']}`. Common-support comparisons preserve the leading-three subspace; native-support changes are not timing evidence.","",f"Phase-domain CCD is **{ps['decision']}**. Primary rank is 3, but first-harmonic rank is `{ps['sensitivity']['harmonic_order']['1']['rank']}`; only exploratory circular co-variation is supported.","",f"Claim boundary: {report['claim_boundary']}",""]; (partial/"RESULTS.md").write_text("\n".join(lines)); hashes={q.name:file_sha256(q) for q in partial.iterdir() if q.is_file()}; (partial/"manifest.json").write_bytes(canonical_json({"schema_id":SCHEMA_ID,"complete":True,"files":hashes})); rename_noreplace(partial,destination); return destination
|
||||
except Exception: shutil.rmtree(partial,ignore_errors=True); raise
|
||||
def load_dynamic_publication(path):
|
||||
root=Path(path); manifest=json.loads((root/"manifest.json").read_text())
|
||||
if manifest.get("schema_id")!=SCHEMA_ID or not manifest.get("complete"): raise ValueError("publication manifest invalid")
|
||||
for name,h in manifest.get("files",{}).items():
|
||||
if file_sha256(root/name)!=h: raise ValueError("publication file hash mismatch")
|
||||
report=json.loads((root/"RESULTS.json").read_text())
|
||||
if report.get("schema_id")!=SCHEMA_ID or report.get("phase_domain_decision")!="DOWNGRADE" or not report.get("zero_phase_output","").startswith("PROHIBITED"): raise ValueError("publication claim contract invalid")
|
||||
for source in report["sources"].values():
|
||||
if file_sha256(Path(source["path"])/"manifest.json")!=source["manifest_sha256"]: raise ValueError("publication source identity changed")
|
||||
return {"manifest":manifest,"report":report,"provenance_validation":"VERIFIED publication hashes and immutable source manifest identities"}
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Campaign role adapter; solver/PPO imports remain lazy until guarded execution."""
|
||||
from pathlib import Path
|
||||
import tempfile,shutil
|
||||
import numpy as np
|
||||
from CCD_analysis.acquisition.artifacts import file_sha256
|
||||
from CCD_analysis.acquisition.runtime import build_role_runtime,initialize_role,run_role_acquisition
|
||||
from .artifacts import load_role_artifact,publish_wrapper
|
||||
from .phase import load_phase_compact
|
||||
from .contracts import CASE_ID,LEGACY_ROLE,constant_mean_provenance,DEFAULT_REPO_MAPPING,DEFAULT_OPTANE_ROOT,EXPECTED_OPTANE_MOUNT,LEASE_PATH,COOLDOWN_PATH
|
||||
from .safety import require_execution_environment,validate_optane_storage,exclusive_lease,enforce_cooldown
|
||||
class FixedPolicy:
|
||||
def __init__(self,normalized): self.normalized=np.asarray(normalized,np.float32); self.device="cpu"
|
||||
def predict(self,obs,deterministic=True): return self.normalized.copy(),None
|
||||
def execute_role(*,role,output,campaign_id,warmup_intervals,collect_boundaries,drl_artifact=None,phase_artifact=None,launch_delay_seconds=120,smoke=False):
|
||||
require_execution_environment(); output=Path(output)
|
||||
backing=validate_optane_storage(repo_mapping=DEFAULT_REPO_MAPPING,optane_root=DEFAULT_OPTANE_ROOT,mount=EXPECTED_OPTANE_MOUNT)
|
||||
if backing!=output.parent.resolve(strict=False) and backing not in output.parent.resolve(strict=False).parents: raise ValueError("role output must be below validated Optane campaign root")
|
||||
if output.exists(): raise FileExistsError(output)
|
||||
if smoke and (warmup_intervals,collect_boundaries)!=(1,2): raise ValueError("smoke requires exactly --warmup-intervals 1 --collect-boundaries 2")
|
||||
relative_parent=output.parent.resolve(strict=False).relative_to(backing)
|
||||
current=backing
|
||||
for component in relative_parent.parts:
|
||||
current=current/component
|
||||
if current.is_symlink(): raise ValueError("campaign output parents must not be symlinks")
|
||||
current.mkdir(exist_ok=True)
|
||||
if output.parent.resolve(strict=True).relative_to(backing)!=relative_parent: raise ValueError("campaign output parent escaped validated Optane root")
|
||||
if output.exists(): raise FileExistsError(output)
|
||||
total=warmup_intervals+collect_boundaries; staging=Path(tempfile.mkdtemp(prefix=f".{role}.campaign-",dir=output.parent)); payload=staging/"payload"; provenance=None
|
||||
try:
|
||||
with exclusive_lease(LEASE_PATH,campaign_id=campaign_id,role=role):
|
||||
enforce_cooldown(COOLDOWN_PATH,delay_seconds=launch_delay_seconds)
|
||||
legacy=LEGACY_ROLE[role]; runtime=None; initializer=initialize_role
|
||||
if role=="constant_mean":
|
||||
if drl_artifact is None or phase_artifact is None: raise ValueError("constant_mean requires --drl-artifact and --phase-artifact")
|
||||
gate=load_phase_compact(phase_artifact)
|
||||
if not gate["summary"]["gate_passed"]: raise ValueError("constant_mean blocked by failed phase/stationarity gate")
|
||||
drl=load_role_artifact(drl_artifact,expected_role="drl"); start,_=drl["metadata"]["retained_slice"]; provenance=constant_mean_provenance(drl_manifest_sha256=file_sha256(Path(drl_artifact)/"campaign_manifest.json"),effective=drl["telemetry"]["effective_applied_action"],retained_start=start)
|
||||
if provenance != gate["summary"]["constant_mean_provenance"]: raise ValueError("phase gate constant_mean provenance mismatch")
|
||||
mean=np.asarray(provenance["constant_mean_physical_action"],np.float32); normalized=((mean/.01)-np.asarray([0.,-4.,4.],np.float32))/8
|
||||
if np.any(normalized < -1) or np.any(normalized > 1): raise ValueError("fresh DRL mean cannot be represented by frozen policy action bounds")
|
||||
runtime=build_role_runtime(case=CASE_ID,role="q_ctl")
|
||||
def initializer(current,**kwargs): initialize_role(current,**kwargs); current.policy=FixedPolicy(normalized)
|
||||
run_role_acquisition(case=CASE_ID,role=legacy,output=payload,control_count=total,field_interval=800,runtime=runtime,initializer=initializer)
|
||||
return publish_wrapper(staging,output,role=role,campaign_id=campaign_id,warmup_intervals=warmup_intervals,collect_boundaries=collect_boundaries,constant_mean_provenance=provenance,smoke=smoke)
|
||||
except Exception:
|
||||
shutil.rmtree(staging,ignore_errors=True); raise
|
||||
@@ -0,0 +1,34 @@
|
||||
"""GPU, Optane, lease, and cooldown guards."""
|
||||
from contextlib import contextmanager
|
||||
import json,os,socket,time
|
||||
from pathlib import Path
|
||||
from .contracts import MIN_LAUNCH_COOLDOWN_SECONDS
|
||||
def require_execution_environment(env=None):
|
||||
env=os.environ if env is None else env
|
||||
if env.get("CONDA_DEFAULT_ENV")!="pycuda_3_10": raise RuntimeError("requires CONDA_DEFAULT_ENV=pycuda_3_10")
|
||||
visible=env.get("CUDA_VISIBLE_DEVICES")
|
||||
if visible is None or not visible.strip() or "," in visible: raise RuntimeError("CUDA_VISIBLE_DEVICES must expose exactly one GPU")
|
||||
return visible.strip()
|
||||
def validate_optane_storage(*,repo_mapping,optane_root,mount,is_mount=os.path.ismount,stat=os.stat):
|
||||
mapping,root,mount=Path(repo_mapping),Path(optane_root).resolve(),Path(mount).resolve()
|
||||
if not is_mount(mount): raise ValueError("Optane backing path is not a mount")
|
||||
if not mapping.is_symlink() or mapping.resolve(strict=True)!=root: raise ValueError("repository mapping must be a stable symlink to exact Optane root")
|
||||
if mount!=root and mount not in root.parents: raise ValueError("campaign root must be under Optane mount")
|
||||
if stat(root).st_dev!=stat(mount).st_dev: raise ValueError("campaign root is not Optane-backed")
|
||||
return root
|
||||
@contextmanager
|
||||
def exclusive_lease(path,*,campaign_id,role):
|
||||
path=Path(path); path.parent.mkdir(parents=True,exist_ok=True); payload={"schema_id":"ccd-karman-dynamic-lease/v1","pid":os.getpid(),"host":socket.gethostname(),"campaign_id":campaign_id,"role":role}
|
||||
try: fd=os.open(path,os.O_WRONLY|os.O_CREAT|os.O_EXCL,0o600)
|
||||
except FileExistsError: raise RuntimeError(f"campaign CFD lease already exists; never steal it: {path}") from None
|
||||
with os.fdopen(fd,"w") as s: json.dump(payload,s,sort_keys=True); s.write("\n"); s.flush(); os.fsync(s.fileno())
|
||||
try: yield payload
|
||||
finally:
|
||||
if not path.exists() or json.loads(path.read_text())!=payload: raise RuntimeError("campaign lease ownership changed or disappeared")
|
||||
path.unlink()
|
||||
def enforce_cooldown(path,*,delay_seconds,monotonic=time.monotonic,sleep=time.sleep):
|
||||
if delay_seconds<MIN_LAUNCH_COOLDOWN_SECONDS: raise ValueError("launch delay must be at least 30 seconds")
|
||||
path=Path(path); now=float(monotonic()); waited=0.
|
||||
if path.exists():
|
||||
previous=float(json.loads(path.read_text())["started_monotonic"]); waited=max(0.,delay_seconds-(now-previous)); sleep(waited); now=float(monotonic())
|
||||
path.parent.mkdir(parents=True,exist_ok=True); path.write_text(json.dumps({"schema_id":"ccd-karman-dynamic-cooldown/v1","started_monotonic":now},sort_keys=True)+"\n"); return waited
|
||||
@@ -0,0 +1,161 @@
|
||||
"""Immutable block-local temporal negative-lag CCD for the Karman DRL role."""
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterator
|
||||
import json, os, shutil, tempfile
|
||||
import numpy as np
|
||||
from CCD_analysis.acquisition.artifacts import file_sha256, rename_noreplace
|
||||
from CCD_analysis.acquisition.contracts import ACTION_IDENTITIES, canonical_json
|
||||
from CCD_analysis.direct_dq.analysis import coordinate_weights
|
||||
from CCD_analysis.direct_dq.schema import canonical_array_sha256
|
||||
from .artifacts import load_role_artifact
|
||||
from .phase import load_phase_compact, recover_phase
|
||||
|
||||
SCHEMA_ID="ccd-karman-temporal-lagged/v1"
|
||||
ROW_ORDER="channel-major_delay-minor"
|
||||
CHANNELS=("front","upper","lower")
|
||||
PRIMARY_LAGS=tuple(range(-17,1))
|
||||
NEIGHBOR_LAG_WINDOWS=(tuple(range(-16,1)),tuple(range(-15,1)))
|
||||
CLAIM_BOUNDARY="closed-loop temporal co-variation only; no causal, response-time, mechanism, uncertainty, CCD>POD, or observable-prediction claim"
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TemporalInput:
|
||||
role_root:Path; phase_root:Path; role_manifest_sha256:str; phase_manifest_sha256:str
|
||||
x_D:np.ndarray; y_D:np.ndarray; mask:np.ndarray; fields:np.ndarray; actions:np.ndarray
|
||||
relative_steps:np.ndarray; absolute_steps:np.ndarray; cycle_ids:np.ndarray
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TemporalConfig:
|
||||
chunk_size:int; ram_budget_bytes:int; safety_margin:float=1.25
|
||||
def __post_init__(self):
|
||||
if type(self.chunk_size) is not int or self.chunk_size<=0 or type(self.ram_budget_bytes) is not int or self.ram_budget_bytes<=0 or not np.isfinite(self.safety_margin) or self.safety_margin<1: raise ValueError("positive chunk/RAM and safety_margin>=1 required")
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TemporalResult:
|
||||
arrays:dict[str,np.ndarray]; config:dict[str,Any]; summary:dict[str,Any]; input_hashes:dict[str,Any]
|
||||
|
||||
def _memory(inp:TemporalInput,cfg:TemporalConfig)->dict[str,Any]:
|
||||
n=len(inp.actions); m=2*int(inp.mask.sum()); q=max(len(PRIMARY_LAGS),*(len(x) for x in NEIGHBOR_LAG_WINDOWS)); c=min(n,cfg.chunk_size)
|
||||
terms={"loaded_role_fields_float32":int(inp.fields.nbytes),"loaded_actions_clocks_masks":int(inp.actions.nbytes+inp.relative_steps.nbytes+inp.absolute_steps.nbytes+inp.cycle_ids.nbytes+inp.mask.nbytes),"field_mean_weights_float64":3*m*8,"largest_cross_modes_float64":3*(3*q)*m*8,"chunk_float64":m*c*8,"small_factors":(3*q*n+3*q*3*q)*8}
|
||||
raw=sum(terms.values()); peak=int(np.ceil(raw*cfg.safety_margin)); out={"terms_bytes":terms,"raw_peak_ram_bytes":raw,"estimated_peak_ram_bytes":peak,"ram_budget_bytes":cfg.ram_budget_bytes,"safety_margin":cfg.safety_margin,"scratch_bytes":0,"decision":"PASS" if peak<=cfg.ram_budget_bytes else "FAIL","formula":"ceil(safety_margin*declared terms); no MxM or full float64 MxN"}
|
||||
if peak>cfg.ram_budget_bytes: raise MemoryError(f"temporal CCD memory admission failed: {out}")
|
||||
return out
|
||||
|
||||
def load_temporal_input(role_root:str|Path,phase_root:str|Path)->TemporalInput:
|
||||
role_root,phase_root=Path(role_root).resolve(),Path(phase_root).resolve(); d=load_role_artifact(role_root,expected_role="drl"); phase=load_phase_compact(phase_root)
|
||||
if not phase["summary"]["gate_passed"]: raise ValueError("passing DRL phase artifact required")
|
||||
if phase["summary"]["source"]["campaign_manifest_sha256"]!=file_sha256(role_root/"campaign_manifest.json"): raise ValueError("phase/role parent mismatch")
|
||||
start,stop=d["metadata"]["retained_slice"]; a=d["legacy_arrays"]; t=d["telemetry"]
|
||||
if d["metadata"]["contract"]["contract"]["control_interval"]!=800: raise ValueError("exact 800-lattice-step cadence required")
|
||||
rel=t["acquisition_relative_lattice_steps"][start:stop].copy(); absolute=a["lattice_steps"][start:stop].copy()
|
||||
if rel.dtype!=np.int64 or absolute.dtype!=np.int64 or not np.all(np.diff(rel)==800) or not np.all(np.diff(absolute)==800): raise ValueError("exact 800-step clocks required")
|
||||
r=recover_phase(t["center_sensor_uy"][start:stop]); ids=r["cycle_id"].copy()
|
||||
with np.load(phase_root/"compact.npz",allow_pickle=False) as z:
|
||||
if not np.array_equal(z["cycle_ids"],np.unique(ids[ids>=0])) or not np.array_equal(z["fluid_mask"],a["fluid_mask"]): raise ValueError("phase blocks/mask differ from live role")
|
||||
fields=np.stack((a["ux"][start:stop],a["uy"][start:stop]),axis=1)
|
||||
actions=t["effective_applied_action"][start:stop,-3:].copy()
|
||||
if fields.dtype!=np.float32 or actions.dtype!=np.float32 or fields.shape[0]!=len(ids) or actions.shape!=(len(ids),3): raise ValueError("full-resolution DRL fields/actions invalid")
|
||||
return TemporalInput(role_root,phase_root,file_sha256(role_root/"campaign_manifest.json"),file_sha256(phase_root/"manifest.json"),a["x_D"].copy(),a["y_D"].copy(),a["fluid_mask"].copy(),fields,actions,rel,absolute,ids)
|
||||
|
||||
def _admit(ids:np.ndarray,lags:tuple[int,...],support:np.ndarray|None=None)->tuple[np.ndarray,np.ndarray]:
|
||||
if not lags or any(type(x) is not int or x>0 for x in lags) or tuple(sorted(set(lags)))!=lags: raise ValueError("lags must be unique increasing nonpositive integers")
|
||||
local={}; positions={}
|
||||
for i,b in enumerate(ids):
|
||||
if b>=0: positions[i]=len(local.setdefault(int(b),[])); local[int(b)].append(i)
|
||||
field=[]; obs=[]
|
||||
for i in range(len(ids)):
|
||||
if i not in positions: continue
|
||||
seq=local[int(ids[i])]; pos=positions[i]; targets=[pos+lag for lag in lags]
|
||||
if min(targets)<0: continue
|
||||
if support is not None and i not in support: continue
|
||||
field.append(i); obs.append([seq[j] for j in targets])
|
||||
if not field: raise ValueError("no complete block-local lag columns")
|
||||
return np.asarray(field,np.int64),np.asarray(obs,np.int64)
|
||||
|
||||
def _chunks(inp:TemporalInput,indices:np.ndarray,chunk:int)->Iterator[tuple[slice,np.ndarray]]:
|
||||
mask=inp.mask
|
||||
for s in range(0,len(indices),chunk):
|
||||
e=min(s+chunk,len(indices)); raw=inp.fields[indices[s:e]]; u=np.concatenate((raw[:,0][:,mask],raw[:,1][:,mask]),axis=1).T.astype(np.float64); yield slice(s,e),u
|
||||
|
||||
def _decompose(inp:TemporalInput,lags:tuple[int,...],cfg:TemporalConfig,field_mean:np.ndarray,weights:np.ndarray,support:np.ndarray|None=None)->dict[str,np.ndarray]:
|
||||
fi,oi=_admit(inp.cycle_ids,lags,support); p=inp.actions[oi].transpose(2,1,0).reshape(3*len(lags),len(fi)).astype(np.float64); pm=p.mean(1); roots=np.sqrt(weights); cross=np.zeros((len(pm),len(weights)))
|
||||
for sl,u in _chunks(inp,fi,cfg.chunk_size): cross+=(p[:,sl]-pm[:,None])@((u-field_mean[:,None])*roots[:,None]).T
|
||||
cross/=len(fi)*np.sqrt(3*len(lags)); left,s,vh=np.linalg.svd(cross,full_matrices=False); weighted=vh.T
|
||||
for k in range(weighted.shape[1]):
|
||||
pivot=int(np.argmax(np.abs(weighted[:,k])))
|
||||
if weighted[pivot,k]<0: weighted[:,k]*=-1; left[:,k]*=-1
|
||||
modes=weighted/roots[:,None]; coeff=np.empty((len(s),len(fi))); total=0.
|
||||
for sl,u in _chunks(inp,fi,cfg.chunk_size): x=(u-field_mean[:,None])*roots[:,None]; coeff[:,sl]=weighted.T@x; total+=float(np.sum(x*x))
|
||||
residual=np.sqrt(np.maximum(total-np.cumsum(np.sum(coeff*coeff,axis=1)),0)/max(total,np.finfo(float).tiny))
|
||||
return {"lags":np.asarray(lags,np.int64),"field_indices":fi,"observable_indices":oi,"observable_mean":pm,"cross_correlation":cross,"left_functions":left,"singular_values":s,"physical_modes":modes,"coefficients":coeff,"weighted_relative_residuals":residual}
|
||||
|
||||
def _left_metrics(result:dict[str,np.ndarray])->dict[str,Any]:
|
||||
lags=result["lags"]; left=result["left_functions"].reshape(3,len(lags),-1); out=[]
|
||||
for k in range(left.shape[2]):
|
||||
lag_energy=np.sum(left[:,:,k]**2,axis=0); lag_energy/=lag_energy.sum(); channel=np.sum(left[:,:,k]**2,axis=1)
|
||||
out.append({"mode":k+1,"peak_lag_boundaries":int(lags[int(np.argmax(lag_energy))]),"peak_lag_lattice_steps":800*int(lags[int(np.argmax(lag_energy))]),"lag_energy_centroid_boundaries":float(np.sum(lags*lag_energy)),"channel_squared_norms":{CHANNELS[j]:float(channel[j]) for j in range(3)},"coefficient_rms":float(np.sqrt(np.mean(result["coefficients"][k]**2)))})
|
||||
return {"modes":out,"lag_energy_by_mode":np.sum(left*left,axis=0).T.tolist()}
|
||||
|
||||
def _compare(a:dict[str,np.ndarray],b:dict[str,np.ndarray],weights:np.ndarray)->dict[str,Any]:
|
||||
r=min(3,len(a["singular_values"]),len(b["singular_values"])); roots=np.sqrt(weights)[:,None]; va=a["physical_modes"][:,:r]*roots; vb=b["physical_modes"][:,:r]*roots
|
||||
overlap=np.linalg.svd(va.T@vb,compute_uv=False)
|
||||
return {"leading_spectrum":b["singular_values"][:r].tolist(),"leading_spectrum_relative_change":((b["singular_values"][:r]-a["singular_values"][:r])/np.maximum(a["singular_values"][:r],np.finfo(float).tiny)).tolist(),"leading_weighted_subspace_principal_cosines":overlap.tolist()}
|
||||
|
||||
def decompose_temporal(inp:TemporalInput,*,streaming_config:TemporalConfig)->TemporalResult:
|
||||
mem=_memory(inp,streaming_config); mask=inp.mask; point=(coordinate_weights(inp.x_D)[:,None]*coordinate_weights(inp.y_D)[None,:])[mask]; weights=np.concatenate((point,point)); all_idx=np.arange(len(inp.actions),dtype=np.int64)
|
||||
fsum=np.zeros(len(weights)); count=0
|
||||
for _,u in _chunks(inp,all_idx,streaming_config.chunk_size): fsum+=u.sum(1); count+=u.shape[1]
|
||||
field_mean=fsum/count
|
||||
primary=_decompose(inp,PRIMARY_LAGS,streaming_config,field_mean,weights); common=primary["field_indices"]
|
||||
neighbors=[]; common_neighbors=[]
|
||||
for lags in NEIGHBOR_LAG_WINDOWS:
|
||||
neighbors.append(_decompose(inp,lags,streaming_config,field_mean,weights)); common_neighbors.append(_decompose(inp,lags,streaming_config,field_mean,weights,support=common))
|
||||
arrays={"x_D":inp.x_D,"y_D":inp.y_D,"fluid_mask":mask,"coordinate_weights":weights,"full_run_field_mean":field_mean,"full_run_action_mean":inp.actions.astype(np.float64).mean(0),"effective_actions":inp.actions,"relative_steps":inp.relative_steps,"absolute_steps":inp.absolute_steps,"cycle_ids":inp.cycle_ids}
|
||||
for prefix,r in [("primary",primary)]+[(f"neighbor_{i}",v) for i,v in enumerate(neighbors)]+[(f"common_neighbor_{i}",v) for i,v in enumerate(common_neighbors)]:
|
||||
for k,v in r.items(): arrays[f"{prefix}_{k}"]=v
|
||||
sensitivity=[]
|
||||
for i,(n,c) in enumerate(zip(neighbors,common_neighbors)):
|
||||
sensitivity.append({"lags":n["lags"].tolist(),"native_N":int(len(n["field_indices"])),"common_support_N":int(len(c["field_indices"])),"native_vs_primary":_compare(primary,n,weights),"common_support_vs_primary":_compare(primary,c,weights)})
|
||||
config={"schema_id":"ccd-karman-temporal-lagged-config/v1","case_id":"karman_re100","role":"drl","primary_lags_boundaries":list(PRIMARY_LAGS),"neighbor_lag_windows_boundaries":[list(x) for x in NEIGHBOR_LAG_WINDOWS],"lag_sign":"tau<0 means action precedes field","cadence_lattice_steps":800,"row_order":ROW_ORDER,"observable_channels":list(CHANNELS),"action_identities":list(ACTION_IDENTITIES),"field_estimand":"full-resolution mask-compressed q_DRL(t)-mean_over_all_360_retained_q_DRL","observable_estimand":"exact same-boundary three-channel effective_applied_action fluctuation; per-lag-row admitted-support mean","operator":"A=P(W^(1/2)U)^T/(N*sqrt(3Q))","center_snapshots":True,"center_observables":True,"standardization":False,"whitening":False,"interpolation":False,"nearest":False,"wrap":False,"block_definition":"independent complete rising-zero-crossing phase cycles","chunk_size":streaming_config.chunk_size,"memory":mem,"claim_boundary":CLAIM_BOUNDARY}
|
||||
summary={"schema_id":"ccd-karman-temporal-lagged-summary/v1","N":int(len(primary["field_indices"])),"Q":len(PRIMARY_LAGS),"M":len(weights),"cycle_count":int(len(np.unique(inp.cycle_ids[inp.cycle_ids>=0]))),"numerical_rank":int(np.sum(primary["singular_values"]>1e-10*primary["singular_values"][0])),"spectrum_label":"cross-correlation strength; not field energy or explained variance","primary_singular_values":primary["singular_values"].tolist(),"primary_squared_singular_values":(primary["singular_values"]**2).tolist(),"primary_left_lag_metrics":_left_metrics(primary),"sensitivity":sensitivity,"claim_boundary":CLAIM_BOUNDARY,"provenance_status":"VERIFIED_LIVE_ROLE_AND_PHASE_REQUIRED_ON_LOAD"}
|
||||
hashes={"parents":{"role":{"path":str(inp.role_root),"campaign_manifest_sha256":inp.role_manifest_sha256},"phase":{"path":str(inp.phase_root),"manifest_sha256":inp.phase_manifest_sha256}},"canonical_arrays":{k:canonical_array_sha256(v) for k,v in arrays.items()}}
|
||||
validate_temporal_result(arrays,config,summary,hashes); return TemporalResult(arrays,config,summary,hashes)
|
||||
|
||||
def validate_temporal_result(arrays,config,summary,hashes):
|
||||
if config.get("schema_id")!="ccd-karman-temporal-lagged-config/v1" or summary.get("schema_id")!="ccd-karman-temporal-lagged-summary/v1" or config.get("primary_lags_boundaries")!=list(PRIMARY_LAGS) or config.get("row_order")!=ROW_ORDER or config.get("claim_boundary")!=CLAIM_BOUNDARY or summary.get("claim_boundary")!=CLAIM_BOUNDARY: raise ValueError("temporal CCD frozen schema contradicted")
|
||||
if config.get("field_estimand")!="full-resolution mask-compressed q_DRL(t)-mean_over_all_360_retained_q_DRL" or config.get("observable_channels")!=list(CHANNELS) or config.get("cadence_lattice_steps")!=800 or any(config.get(k) is not False for k in ("standardization","whitening","interpolation","nearest","wrap")): raise ValueError("temporal CCD estimand contradicted")
|
||||
d={k:np.asarray(v) for k,v in arrays.items()}; n=len(d["effective_actions"]); m=2*int(d["fluid_mask"].sum())
|
||||
if summary.get("N")!=len(d["primary_field_indices"]) or summary.get("Q")!=18 or summary.get("M")!=m or d["primary_cross_correlation"].shape!=(54,m) or d["primary_left_functions"].shape[0]!=54 or d["full_run_field_mean"].shape!=(m,): raise ValueError("temporal CCD dimensions invalid")
|
||||
if d["effective_actions"].shape!=(n,3) or d["cycle_ids"].shape!=(n,) or not np.all(np.diff(d["relative_steps"])==800): raise ValueError("temporal CCD clocks/actions invalid")
|
||||
if set(hashes)!={"parents","canonical_arrays"} or set(hashes["canonical_arrays"])!=set(d) or any(hashes["canonical_arrays"][k]!=canonical_array_sha256(v) for k,v in d.items()): raise ValueError("temporal CCD hashes invalid")
|
||||
canonical_json(config); canonical_json(summary); canonical_json(hashes); return d
|
||||
|
||||
class TemporalTransaction:
|
||||
def __init__(self,destination): self.destination=Path(destination); self.stage=None
|
||||
def __enter__(self):
|
||||
if self.destination.exists(): raise FileExistsError(self.destination)
|
||||
self.destination.parent.mkdir(parents=True,exist_ok=True); self.stage=Path(tempfile.mkdtemp(prefix=f".{self.destination.name}.partial-",dir=self.destination.parent)); return self
|
||||
def write(self,result):
|
||||
validate_temporal_result(result.arrays,result.config,result.summary,result.input_hashes); np.savez_compressed(self.stage/"arrays.npz",**result.arrays)
|
||||
for n,v in (("config.json",result.config),("summary.json",result.summary),("input_hashes.json",result.input_hashes)): (self.stage/n).write_bytes(canonical_json(v))
|
||||
files={p.name:file_sha256(p) for p in self.stage.iterdir()}; (self.stage/"manifest.json").write_bytes(canonical_json({"schema_id":SCHEMA_ID,"complete":True,"files":files}))
|
||||
def publish(self):
|
||||
load_temporal_result(self.stage,recompute=False); rename_noreplace(self.stage,self.destination); self.stage=None; load_temporal_result(self.destination,recompute=True); return self.destination
|
||||
def __exit__(self,*args):
|
||||
if self.stage is not None: shutil.rmtree(self.stage,ignore_errors=True)
|
||||
|
||||
def load_temporal_result(path,recompute=True):
|
||||
root=Path(path); manifest=json.loads((root/"manifest.json").read_text())
|
||||
if manifest.get("schema_id")!=SCHEMA_ID or not manifest.get("complete") or set(manifest.get("files",{}))!={"arrays.npz","config.json","summary.json","input_hashes.json"}: raise ValueError("temporal CCD manifest invalid")
|
||||
for n,h in manifest["files"].items():
|
||||
if file_sha256(root/n)!=h: raise ValueError("temporal CCD file hash mismatch")
|
||||
with np.load(root/"arrays.npz",allow_pickle=False) as z: arrays={k:z[k].copy() for k in z.files}
|
||||
config=json.loads((root/"config.json").read_text()); summary=json.loads((root/"summary.json").read_text()); hashes=json.loads((root/"input_hashes.json").read_text()); validate_temporal_result(arrays,config,summary,hashes)
|
||||
inp=load_temporal_input(hashes["parents"]["role"]["path"],hashes["parents"]["phase"]["path"])
|
||||
if inp.role_manifest_sha256!=hashes["parents"]["role"]["campaign_manifest_sha256"] or inp.phase_manifest_sha256!=hashes["parents"]["phase"]["manifest_sha256"]: raise ValueError("temporal CCD live parent identity changed")
|
||||
if not np.array_equal(inp.actions,arrays["effective_actions"]) or not np.array_equal(inp.relative_steps,arrays["relative_steps"]) or not np.array_equal(inp.cycle_ids,arrays["cycle_ids"]): raise ValueError("temporal CCD live telemetry/blocks changed")
|
||||
if recompute:
|
||||
cfg=TemporalConfig(config["chunk_size"],config["memory"]["ram_budget_bytes"],config["memory"]["safety_margin"]); fresh=decompose_temporal(inp,streaming_config=cfg)
|
||||
for k in arrays: np.testing.assert_allclose(arrays[k],fresh.arrays[k],rtol=2e-11,atol=2e-12)
|
||||
return {"arrays":arrays,"config":config,"summary":summary,"input_hashes":hashes,"manifest":manifest,"provenance_validation":"VERIFIED: live DRL role and passing phase parents reread; essential decomposition recomputed" if recompute else "VERIFIED_HASHES_AND_LIVE_PARENTS"}
|
||||
@@ -0,0 +1,154 @@
|
||||
# Original full-field CCD mathematical contract
|
||||
|
||||
This active document defines the original canonical correlation decomposition (CCD) from Lyu's formulation. The archived `Lyu23.md` is a read-only historical source, not active code or authority. The private CPU reference freezes the literal equations for tests, and the separate public production API implements this contract after the independent mathematical gate passed.
|
||||
|
||||
## Literal construction
|
||||
|
||||
Let the field snapshots be columns
|
||||
|
||||
\[
|
||||
U=[u_1,\ldots,u_N]\in\mathbb C^{M\times N}.
|
||||
\]
|
||||
|
||||
For one observable sampled at the declared delays \(\tau_1,\ldots,\tau_Q\), let \(p_i=[p(t_i+\tau_1),\ldots,p(t_i+\tau_Q)]^T\) and
|
||||
|
||||
\[
|
||||
P=[p_1,\ldots,p_N]\in\mathbb C^{Q\times N}.
|
||||
\]
|
||||
|
||||
For \(L\) observables, stack their \(Q\)-row blocks in declared order, so \(P\in\mathbb C^{LQ\times N}\). The literal multiobservable normalization used here is
|
||||
|
||||
\[
|
||||
A={P U^\dagger\over N\sqrt{LQ}},\qquad
|
||||
A=R\Sigma V^\dagger.
|
||||
\]
|
||||
|
||||
For \(L=1\), this is exactly Lyu (2.4), \(A=PU^\dagger/(N\sqrt Q)\). Rows of multiobservable \(P\) are channel-major/delay-minor: row \(lQ+q\) is channel \(l\), declared delay \(q\). The columns of \(V\) are field modes and columns of \(R\) are unit-normalized lag/observable correlation functions in the direct-sum \(\mathbb C^{LQ}\) metric. With coefficients \(a_k=v_k^\dagger X\), the exact empirical identity is
|
||||
|
||||
\[
|
||||
{P a_k^\dagger\over N}=\sqrt{LQ}\,\sigma_k r_k.
|
||||
\]
|
||||
|
||||
Here \(P a_k^\dagger\) uses the complex conjugate of the coefficient row. This is consistent with \(A=PX^\dagger/(N\sqrt{LQ})\); statements of Lyu's correlation as \(R=\langle p^*u
|
||||
angle\) use the conjugated-observable convention and correspond by complex conjugation, while agreeing for real data. `left_functions_lq()` exposes the same rows as `(L,Q,rank)` without reordering. The values \(\sigma_k^2\) are the discrete variational eigenvalues because
|
||||
|
||||
\[
|
||||
A^\dagger A v_k=\sigma_k^2v_k,
|
||||
\quad
|
||||
\max_{\|v\|_2=1}\|Av\|_2^2=\sigma_1^2.
|
||||
\]
|
||||
|
||||
Each observable channel remains in its physical units. Multiplication of all rows by one common complex scalar scales every singular value by its modulus and changes no right-mode projector. Relative channel scaling changes \(P^\dagger P\), hence changes the observable metric and can change modes and ranking; it is never treated as innocuous normalization.
|
||||
|
||||
## Physical weighted inner product
|
||||
|
||||
Let the declared physical inner product be \(\langle f,g\rangle_W=f^\dagger Wg\), where \(W=W^\dagger\succ0\) contains quadrature/component weights. Work in Euclidean coordinates
|
||||
|
||||
\[
|
||||
X=W^{1/2}U,
|
||||
\qquad
|
||||
A_W={P X^\dagger\over N\sqrt{LQ}}
|
||||
={P U^\dagger W^{1/2}\over N\sqrt{LQ}},
|
||||
\qquad A_W=R\Sigma V^\dagger.
|
||||
\]
|
||||
|
||||
Back-transform \(\Phi=W^{-1/2}V\). Then \(\Phi^\dagger W\Phi=I\), and the physical-amplitude coefficients are
|
||||
|
||||
\[
|
||||
a=\Phi^\dagger WU=V^\dagger W^{1/2}U=V^\dagger X.
|
||||
\]
|
||||
|
||||
Thus \(U=\Phi a\) when the complete basis is retained. This square-root coordinate convention is essential: inserting \(W\) directly into the Euclidean SVD would solve a different problem. Diagonal and dense encodings use one scale-invariant HPD conditioning rule: all eigenvalues must be positive and \(\lambda_{\min}/\lambda_{\max}>\epsilon_{
|
||||
m real}M\), where \(\epsilon_{
|
||||
m real}\) is machine epsilon of the real computation dtype. Consequently a uniform positive rescaling of \(W\) does not change acceptance; singular values scale by the square root of that factor, physical modes by its inverse square root, and the field projector is unchanged.
|
||||
|
||||
## Preprocessing contract
|
||||
|
||||
The original paper's equations apply directly to full field snapshots: there is **no preliminary POD, no row standardization, and no whitening**. Centering is optional declared preprocessing. Production declares snapshot and observable centering independently: `center_snapshots=True` subtracts each row mean from \(U\), and `center_observables=True` subtracts each row mean from \(P\), before constructing \(A\). Either, both, or neither may be selected, and each selection defines the corresponding explicit correlation objective. If a switch is disabled, that input's offsets remain part of the correlation. Centering must never be inferred or silently applied. For exact-lag input, incomplete endpoint columns are dropped first and both declared means are computed only over the resulting \(N_{valid}\) aligned columns. The operator denominator is likewise \(N_{valid}\), and the retained original field indices are returned.
|
||||
|
||||
## Exact clocks, delays, and blocks
|
||||
|
||||
Every CCD column is a physical pairing, not an array roll. The inputs declare either:
|
||||
|
||||
1. explicit physical timestamps and physical delays, requiring exact equality \(t_i+\tau_j=t_k^p\); or
|
||||
2. an observable index clock and declared integer offsets from an exactly timestamp-matched base sample.
|
||||
|
||||
There is no circular wrap, nearest-time substitution, or crossing of realization/block boundaries. Pair identity is `(block, timestamp)`, so repeated local timestamps in different blocks are valid but duplicate pairs are rejected. Storage may interleave blocks. Integer offsets operate on each block's independent storage subsequence, whose local timestamps must be strictly increasing; unsorted local clocks are rejected rather than silently reordered. A field column is admitted only when all requested lag samples exist in the same block. \(Q\) is any positive integer, including one: odd/even and symmetric/asymmetric delay sets are equally valid. Delay order is preserved. Duplicate delays are allowed deliberately and produce duplicate rows in that declared order.
|
||||
|
||||
Field and observable cadences may differ only under an explicit timestamp pairing/interpolation contract. The derivation reference and its tests authorize exact samples only; nearest matching is forbidden and interpolation remains a future production design decision.
|
||||
|
||||
## Full-rank weighted POD-coordinate equivalence
|
||||
|
||||
Apply the identical declared preprocessing and form \(X=W^{1/2}U\). Let a full-rank POD factorization of the snapshot range be
|
||||
|
||||
\[
|
||||
X=\Psi C,
|
||||
\qquad \Psi^\dagger\Psi=I,
|
||||
\qquad C=\Psi^\dagger X,
|
||||
\]
|
||||
|
||||
where `full-rank` means \(\Psi\) spans `range(X)` and \(C\) contains physical-amplitude coefficients—no per-row standardization. Define
|
||||
|
||||
\[
|
||||
A_X={PX^\dagger\over N\sqrt{LQ}},
|
||||
\qquad
|
||||
A_C={PC^\dagger\over N\sqrt{LQ}}.
|
||||
\]
|
||||
|
||||
Since \(X^\dagger=C^\dagger\Psi^\dagger\), \(A_X=A_C\Psi^\dagger\), and therefore
|
||||
|
||||
\[
|
||||
A_X^\dagger A_X=\Psi(A_C^\dagger A_C)\Psi^\dagger.
|
||||
\]
|
||||
|
||||
The nonzero singular values agree and coefficient-space right modes \(z_k\) lift to weighted field modes \(v_k=\Psi z_k\), then physical modes \(\phi_k=W^{-1/2}v_k\). A simple mode agrees up to one complex unit-modulus phase. For an exactly degenerate singular value, individual vectors are not identifiable; equality means equality of the complete degenerate spectral projector.
|
||||
|
||||
For truncated \(\Psi_r\), coordinate CCD solves only
|
||||
|
||||
\[
|
||||
\max_{v\in\operatorname{range}(\Psi_r),\ \|v\|_2=1}\|A_Xv\|_2^2,
|
||||
\]
|
||||
|
||||
namely the constrained optimum in the retained POD subspace. Let \(E_1\) be the top eigenspace of \(A_X^\dagger A_X\) and \(S=\operatorname{range}(\Psi_r)\). The constrained leading value equals the full leading value if and only if \(E_1\cap S\ne\{0\}\). If the top eigenvalue is simple, this says its unique vector (up to complex phase) lies in \(S\). For a degenerate top eigenvalue, a partial intersection preserves the leading value and a shared maximizing vector but not the full top projector. Recovering the complete top projector requires \(E_1\subseteq S\). With no intersection the constrained value is strictly smaller. The same containment statement applies mode cluster by mode cluster. A low-energy but observable-correlated structure can therefore be removed before CCD. This is a subspace statement, not a POD-superiority or CCD-superiority claim.
|
||||
|
||||
If coefficient rows have unequal/non-isotropic scales and are standardized, the transformed matrix is \(D^{-1}C\), not physical-amplitude \(C\), and generally changes the metric and CCD objective. In an exactly degenerate POD subspace the covariance is isotropic, so all admissible unitary rotations have equal row variance and standardization is rotation invariant there; the prior contrary statement was false. For a merely near-degenerate cluster, unequal variances make the operation basis dependent, although the admissibility and numerical meaning of arbitrary rotations then depend on a separately declared tolerance rather than exact spectral degeneracy.
|
||||
|
||||
|
||||
## Singular blocks and identifiable reconstruction
|
||||
|
||||
The numerical SVD vectors are retained for transparent algebra, but individual vectors are not identifiable inside a nonzero degenerate singular block, and null/near-zero vectors are arbitrary. `singular_block_rtol` and `singular_block_atol` declare both the nonzero threshold and block clustering tolerance. The result exposes `singular_blocks`, `identifiable_mode_mask`, and `identifiable_rank`; default reconstruction uses only identifiable non-null modes.
|
||||
|
||||
A configured rank or default reconstruction selection may not split a declared nonzero degenerate block. Such a request fails closed. A caller may explicitly set `allow_basis_dependent=True` on reconstruction to select arbitrary returned SVD representatives, but that output is marked basis-dependent and carries no individual-mode identification claim. Rank clipping in `CCDConfig` has no override and is rejected if it cuts a block. In physical coordinates the invariant object for a complete block \(B\) is its weighted projector
|
||||
|
||||
\[
|
||||
\Pi_B=\Phi_B\Phi_B^\dagger W,
|
||||
\]
|
||||
|
||||
not any individual column. Phase convention fixes only a representative phase and does not change this identifiability policy.
|
||||
|
||||
## Lyu published synthetic example (3.1–3.2)
|
||||
|
||||
The archived paper defines
|
||||
|
||||
\[
|
||||
\begin{aligned}
|
||||
u(x,t)={}&2\cos(t-x)+1.5\cos(2t)\cos(2x)+\cos(3t)\cos(3x)\\
|
||||
&+0.5\cos(4t)\cos(4x)+\cos(6t)\cos(6x)e^{-0.1(x-\pi)^2}+100r(t,x),
|
||||
\end{aligned}\tag{3.1}
|
||||
\]
|
||||
|
||||
where \(r\) is uniform on \([-0.5,0.5]\), and
|
||||
|
||||
\[
|
||||
p(t)=\cos(t-\pi/4)+\sin(2t-\pi/3)+\cos(4t)+\cos(6t-\pi/12).\tag{3.2}
|
||||
\]
|
||||
|
||||
The correlated field structures are the traveling-wave pair at frequency 1 and the frequency 2, 4, and 6 structures. The energetic \(\cos(3t)\cos(3x)\) term and random noise are uncorrelated. The published discretization uses 128 spatial points, \(Q=128\), \(\Delta\tau=2\pi/128\), \(N=10^4\) cycles (1,280,000 field snapshots), and noise amplitude 100, giving an observable Nyquist angular harmonic 64 and harmonic resolution 1 over the \(2\pi\) lag window. The reference test executes those exact published parameters with chunked direct accumulation of \(P U^\dagger\), algebraically identical to materializing the full matrices while avoiding their multi-gigabyte storage. It predeclares and checks the traveling-wave pair followed by frequency-2, localized frequency-6, and frequency-4 structures; the leading \(\sigma^2\) ratio \(4:4:2.25\); frequency-3 suppression; and separation from the noise floor. This is an exact-parameter stochastic reproduction of the equations, not a pixel reproduction of the published figure. A separate noiseless test isolates algebra, and multiple smaller-N seeds test convergence statistically.
|
||||
|
||||
## Frozen real-case specialization
|
||||
|
||||
The first real-data use is now frozen by [`REAL_CASE_CCD_CONTRACT.md`](REAL_CASE_CCD_CONTRACT.md). For `karman_re100` and `illusion_1.0L` separately, it mandates centered full-resolution mask-compressed `dq_ctl=q_ctl-q_blk`, the three centered native-unit exact-field-time q_ctl effective action channels, `Q=1`, `tau=0`, the literal weighted operator above, and no POD/whitening/standardization. It also freezes flattening, quadrature, provenance, OOM-safe streaming, immutable schema, and claim limits. This is a documentation contract only: the streaming adapter and real-case CCD results do not yet exist.
|
||||
|
||||
## Gate and scope
|
||||
|
||||
`_reference.py` remains private, derivation-only, CPU test support and is not imported by `original_ccd/__init__.py`. The independent mathematical gate passed before implementation. The public CPU API now provides the production full-field decomposition, exact lag construction, coefficients, and weighted field-projection reconstruction defined here. It introduces no immutable artifact format, CLI, CFD execution, real-case result, observable prediction, empirical mechanism claim, or CCD-versus-POD superiority claim.
|
||||
@@ -0,0 +1,61 @@
|
||||
# Original full-field CCD production API
|
||||
|
||||
This package implements Lyu's original full-field operator
|
||||
`A = P (W^1/2 U)† / (N sqrt(LQ))` and its direct rectangular SVD. There is no
|
||||
pre-reduction, whitening, standardization, or inferred centering.
|
||||
|
||||
`U` is `(M,N)`. Supplied `P` may be `(LQ,N)` (declared as `L=1`) or explicit
|
||||
`(L,Q,N)`. `LaggedObservables` is a validated immutable public value: its finite
|
||||
2D matrix, positive `L/Q`, `LQ` row count, delay count, channel-major/delay-minor
|
||||
row order, and unique nonnegative field indices must agree at construction.
|
||||
`fit()` additionally checks indices against `U`, selects admitted columns first,
|
||||
and then computes declared `U` and `P` means and the denominator using
|
||||
`N_valid`. The result reports `selected_field_indices` and `valid_sample_count`.
|
||||
|
||||
```python
|
||||
from CCD_analysis.original_ccd import CCDConfig, build_lagged_observables, fit
|
||||
|
||||
lagged = build_lagged_observables(
|
||||
field_times, observable_times, observable_values, delays,
|
||||
field_blocks=field_blocks, observable_blocks=observable_blocks,
|
||||
delay_kind="time",
|
||||
)
|
||||
result = fit(
|
||||
U, lagged, weight=quadrature_weights,
|
||||
config=CCDConfig(center_snapshots=True, center_observables=True,
|
||||
chunk_size=4096, singular_block_rtol=1e-10),
|
||||
)
|
||||
projection = result.reconstruct() # all identifiable non-null blocks
|
||||
residual = result.residual()
|
||||
R_lq = result.left_functions_lq() # (L,Q,rank), no row reordering
|
||||
```
|
||||
|
||||
For coefficient row `a_k`, the complex empirical identity is
|
||||
`P @ a_k.conj() / N_valid = sqrt(LQ) * sigma_k * r_k`. Thus left functions are
|
||||
unit vectors in the channel/direct-delay sum. This `P` convention is conjugate
|
||||
to writing Lyu's correlation as `<p* u>` and agrees directly for real data.
|
||||
|
||||
One-dimensional weights are efficient positive diagonal `W`; two-dimensional
|
||||
weights are dense complex Hermitian HPD. Both use the same scale-invariant
|
||||
condition `lambda_min/lambda_max > eps(real computation dtype) * M`. Uniformly
|
||||
rescaling `W` is therefore accepted identically: singular values scale with its
|
||||
square root while the weighted field projector remains unchanged.
|
||||
|
||||
Returned SVD vectors include transparent null representatives, but
|
||||
`identifiable_mode_mask` excludes near-zero vectors and `singular_blocks`
|
||||
records nonzero degenerate clusters under declared tolerances. Default
|
||||
reconstruction uses `identifiable_rank`. Rank or index selections that split a
|
||||
nonzero block, or select null vectors, fail closed. Only an explicit
|
||||
`allow_basis_dependent=True` reconstruction can request arbitrary SVD vectors;
|
||||
it does not make them identifiable. `CCDConfig.rank` cannot clip through a
|
||||
block. The invariant physical block object is `Phi_B Phi_B† W`; phase fixing
|
||||
only chooses representatives.
|
||||
|
||||
Reconstruction is a weighted projection of field snapshots, not observable
|
||||
prediction. Exact lags never wrap, sort, interpolate, cross blocks, or use a
|
||||
nearest timestamp. No CFD, real-case result, causal interpretation, or
|
||||
method-superiority claim is included.
|
||||
|
||||
## Frozen real-case adapter boundary
|
||||
|
||||
`REAL_CASE_CCD_CONTRACT.md` specializes this API for the two authoritative real cases: centered full-resolution `dq_ctl`, exact-time centered q_ctl effective actions in front/upper/lower native units, `Q=1`, `tau=0`, physical coordinate quadrature, provenance-validated mask compression, bounded streaming, and immutable no-clobber results. It is a contract only. The present in-memory API is not itself the real-artifact streaming adapter, and no real-case CCD result has been run.
|
||||
@@ -0,0 +1,108 @@
|
||||
# Real-case Q=1 original CCD contract
|
||||
|
||||
This document is the active mathematical and adapter contract for the first real-case CCD analyses of `karman_re100` and `illusion_1.0L`. It specializes the literal weighted Lyu operator in `ORIGINAL_CCD_MATH.md`; it does not change that operator. The estimand remains frozen here. The active `real_ccd/` package now implements the streaming adapter and immutable schema; no real-artifact preflight, real-case decomposition run, published result artifact, or empirical result has been performed.
|
||||
|
||||
## Frozen inputs and admitted columns
|
||||
|
||||
Each case is processed independently. The only admissible top-level input is its authoritative direct-dq result together with all three immutable live acquisition roots recorded by that result:
|
||||
|
||||
- `evidence/direct-dq-karman-burn120000/` and its recorded schema-v3 Kármán acquisition roots;
|
||||
- `evidence/direct-dq-illusion-authorized-burn90000/` and its recorded certificate-authorized schema-v3 Illusion acquisition roots.
|
||||
|
||||
Every preflight, run, and reload must reread both levels. It must validate exact inventories and hashes; case/role/schema and velocity-decoder identity; grid and coordinates; all three solver-derived masks and their persisted intersection; original and selected integer indices/timestamps; selected role fields; and q_ctl action identity, order, units, field-time values, and source/control history lineage. Missing live roots, duplicate or non-increasing selected times, non-finite data, any mismatch, or an incomplete exact join fails closed. A result directory alone is not portable evidence. There is no silent trim, nearest-time match, interpolation, timestamp tolerance, phase guess, crop, translated/rebuilt mask, or block wrap.
|
||||
|
||||
The admitted columns are exactly the direct-dq selected columns, in persisted order: 120 Kármán columns selected by relative lattice step `>120000`, and 144 Illusion columns selected by relative lattice step `>90000`. These counts are input-contract facts, not CCD results.
|
||||
|
||||
## Frozen field estimand, mask, flattening, and weights
|
||||
|
||||
At every admitted exact field time,
|
||||
|
||||
\[
|
||||
dq_{ctl}(t_n)=q_{ctl}(t_n)-q_{blk}(t_n).
|
||||
\]
|
||||
|
||||
Only full-resolution `u_x,u_y` degrees of freedom at `analysis_fluid_mask` points are admitted. That authoritative mask is the exact persisted intersection of the three solver-derived fluid masks. The adapter must neither crop the 1280-by-512 grid nor infer a geometry mask.
|
||||
|
||||
The frozen flattening is component-major, then NumPy C order on `(x,y)`: first `dq_ctl[:,0,:,:][:,analysis_fluid_mask]` in the mask order produced by C-order flattening (`x` major, `y` minor), then the identically ordered `u_y` values. Equivalently, each snapshot column has `M=2*count_nonzero(analysis_fluid_mask)` rows, with all `u_x` rows before all `u_y` rows. The result must persist this declaration, dimensions, coordinates, and a hash of the exact boolean mask.
|
||||
|
||||
Let `w_x=coordinate_weights(x_D)` and `w_y=coordinate_weights(y_D)` use the active endpoint-half/interior-centered coordinate quadrature. For every admitted fluid point `(i,j)`, the diagonal physical weight is `w_x[i]*w_y[j]`, repeated once for `u_x` and once for `u_y` in the frozen flatten order. Weights are positive native `D^2` area weights and are not normalized by mask area. The result must persist the coordinate/weight rule, exact weight vector (or losslessly reproducible coordinates plus its canonical hash), dtype, and hash. Computation uses the literal square-root coordinates `X=W^(1/2)U`; inserting `W` directly into the Euclidean SVD is forbidden.
|
||||
|
||||
For the admitted columns, compute each spatial row mean in stable floating-point accumulation and explicitly form the conceptual centered matrix
|
||||
|
||||
\[
|
||||
U_c(:,n)=dq_{ctl}(:,n)-\overline{dq}_{ctl}.
|
||||
\]
|
||||
|
||||
`center_snapshots=true` is mandatory. The field mean, accumulation dtype/method, count, and centering flag must be persisted. The authoritative direct-dq mean `dq_ctl` remains a separate physical context result; it is reported beside mean actions and must not be called a CCD mode.
|
||||
|
||||
## Frozen observable and mean-action algebra
|
||||
|
||||
The observable has exactly `L=3` channels and `Q=1`, in this immutable order and identity:
|
||||
|
||||
1. `front` / `front_ccw_positive`;
|
||||
2. `upper` / `upper_ccw_positive`;
|
||||
3. `lower` / `lower_ccw_positive`.
|
||||
|
||||
For every admitted field column, take the last three entries of q_ctl `effective_applied_action` at that exact field timestamp. These are the solver EMA commands at the completed lattice step, in the artifact's native physical action units. Requested normalized/physical actions, another role's actions, a control-boundary neighbor, interpolated values, reordering, sign changes, or unit conversion are forbidden. The adapter must revalidate the q_ctl field-time-to-control-history lineage before admitting a column.
|
||||
|
||||
Let `P` be the resulting real `3 x N` matrix. Compute and persist each channel mean and explicitly center each row, `P_c=P-mu 1_N^T`; `center_observables=true` is mandatory. There is no channel standardization, whitening, RMS scaling, nondimensionalization, or metric change. Native-unit relative channel scaling is part of the estimand.
|
||||
|
||||
Centering does not discard a constant action contribution to the centered operator. Since `U_c 1_N=0`,
|
||||
|
||||
\[
|
||||
P U_c^\dagger=(P_c+\mu 1_N^T)U_c^\dagger
|
||||
=P_cU_c^\dagger+\mu(U_c1_N)^\dagger=P_cU_c^\dagger.
|
||||
\]
|
||||
|
||||
Mean effective actions and the authoritative mean `dq_ctl` are therefore reported outside CCD as physical context, never as modes.
|
||||
|
||||
## Frozen Q=1, tau=0 operator
|
||||
|
||||
The first analyses are separate case-wise runs with exactly `Q=1` and `tau=0`. CCD does not require `Q>1`. The only join is the exact common field/action sample at the same admitted timestamp. No response lag is guessed, and no `Q=2`, `Q=12`, period/phase window, or lagged extension is implicitly authorized.
|
||||
|
||||
With `LQ=3`, the literal weighted operator is
|
||||
|
||||
\[
|
||||
A_W={P_c(W^{1/2}U_c)^\dagger\over N\sqrt{3}}\in\mathbb R^{3\times M},
|
||||
\qquad A_W=R\Sigma V^\dagger,
|
||||
\qquad \Phi=W^{-1/2}V.
|
||||
\]
|
||||
|
||||
The implementation must accumulate the equivalent `3 x M` cross-correlation and perform a direct rectangular/thin SVD. It must not perform POD pre-reduction, whitening, action/row standardization, covariance normalization, implicit centering, or materialize an `M x M` operator. `sigma` and `sigma^2` are labeled only as cross-correlation strength in this declared native-action/weighted-field metric; they are not field energy, explained variance, or canonical correlation coefficients.
|
||||
|
||||
Coefficients are `a=V^dagger W^(1/2)U_c`. Full-field modes are persisted in the frozen mask-compressed order with an exact unflattening declaration; optional full-grid `u_x/u_y` views place values only on the authoritative mask. Optional vorticity is derived from modes and labeled as derived, not independently decomposed.
|
||||
|
||||
## Implemented streaming and memory contract
|
||||
|
||||
The active adapter is mask-compressed and OOM-safe:
|
||||
|
||||
1. Pass 1 rereads and validates provenance and exact columns, then computes stable field and three-channel action means, counts, and finite-value checks.
|
||||
2. Pass 2 rereads chunks, explicitly centers and applies `sqrt(W)`, and accumulates only the `3 x M` cross-correlation before thin SVD.
|
||||
3. Pass 3 rereads exact columns to stream coefficients, weighted residual scalars at complete singular-block boundaries, and declared spot checks.
|
||||
|
||||
Passes may be safely fused only when numerical equivalence and the same memory bound are demonstrated. The adapter must never hold a full float64 `M x N` copy, multiple full-field duplicates, an `M x M` operator, or default full `M x N` reconstructions. Selected snapshot reconstructions are generated from modes and coefficients only on explicit request and are saved no-clobber.
|
||||
|
||||
Before work begins, preflight must compute conservative peak RAM and scratch estimates from actual `M,N,L,Q`, input and accumulation dtypes, chunk size, resident mode/cross-correlation/coefficient arrays, decompression or loader buffers, temporary arrays, and an explicit safety margin. The formulas, terms, available/allowed budgets, safety margin, and decision are persisted. Missing budgets or estimates exceeding either budget fail closed. This contract does not choose a machine-specific budget; it requires the future run configuration to declare one.
|
||||
|
||||
## Immutable result schema contract
|
||||
|
||||
Each case publishes separately through fsync-backed atomic no-replace semantics. A partial or failed directory must never satisfy the loader. Schema v1 is implemented as canonical `arrays.npz`, `config.json`, `summary.json`, and `input_hashes.json`, covered by a complete hash manifest and atomic no-replace transaction. Schema evolution may not omit these required identities and products:
|
||||
|
||||
- schema ID/version, completeness marker, exact file inventory and SHA256 hashes;
|
||||
- case, `Q=1`, `tau=0`, `L=3`, row/channel order, all config and numerical tolerances;
|
||||
- direct-dq result identity, all three absolute live acquisition roots and their manifest/config/file identities, and compatibility-certificate identity where applicable;
|
||||
- exact original/selected indices and timestamps, selected count, grid/coordinates, three solver masks, analysis mask, flatten order, component order, weights, and hashes;
|
||||
- exact action identities/order/native units/semantics and q_ctl action/control-history lineage identities;
|
||||
- field/action means, centering flags, accumulation methods/dtypes, and finite/count checks;
|
||||
- conservative RAM/scratch estimate, formulas, budget, margin, and pass/chunk strategy;
|
||||
- weighted full-field `u_x/u_y` modes, singular values, three zero-lag left-function channels, coefficients at exact timestamps, numerical rank, null threshold, and degenerate singular blocks;
|
||||
- weighted reconstruction residual metrics only at complete nondegenerate/block boundaries, plus any explicitly requested selected reconstructions and their validation;
|
||||
- claim-boundary strings and loader provenance status.
|
||||
|
||||
Every preflight/run/reload revalidates live provenance. Fresh-process reload must recompute enough identities, dimensions, orthogonality/singular relations, and persisted scalar checks to fail closed on corruption or contradiction. Full reconstructed `M x N` fields are not a default schema member.
|
||||
|
||||
## Deliverables and claim boundary
|
||||
|
||||
Per case, eventual artifact-derived reporting includes mean effective actions and authoritative mean `dq_ctl` outside CCD; `sigma` and `sigma^2` cross-correlation-strength spectra; leading full-field velocity modes; three zero-lag left vectors; exact-time coefficients; and complete-block weighted residual curves. Degenerate blocks are interpreted as subspaces/projectors, not unique individual modes.
|
||||
|
||||
The analyses do not support a CCD-over-POD claim, causality, mechanism, response time, same phase, independent realizations, uncertainty from one record, or an observable-prediction claim. Ordinary exact-sample lag correlations may be computed only after both Q=1 results validate, are descriptive diagnostics rather than CCD or causal evidence, and require stopping for user approval before any lagged-CCD contract or run.
|
||||
@@ -0,0 +1,7 @@
|
||||
"""Production original full-field canonical correlation decomposition."""
|
||||
from .api import CCDConfig, CCDResult, LaggedObservables, decompose, fit, flatten_fields, unflatten_fields
|
||||
from .delays import build_lagged_observables
|
||||
|
||||
IMPLEMENTATION_AVAILABLE = True
|
||||
|
||||
__all__ = ["IMPLEMENTATION_AVAILABLE", "CCDConfig", "CCDResult", "LaggedObservables", "build_lagged_observables", "decompose", "fit", "flatten_fields", "unflatten_fields"]
|
||||
@@ -0,0 +1,169 @@
|
||||
"""Private, derivation-only CPU reference for the original Lyu CCD contract.
|
||||
|
||||
This module is intentionally not imported by :mod:`CCD_analysis.original_ccd`.
|
||||
It favors literal mathematics and fail-closed timing checks over performance.
|
||||
"""
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, Sequence
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _ReferenceResult:
|
||||
cross_correlation: np.ndarray
|
||||
left_functions: np.ndarray
|
||||
singular_values: np.ndarray
|
||||
weighted_modes: np.ndarray
|
||||
physical_modes: np.ndarray
|
||||
physical_amplitude_coefficients: np.ndarray
|
||||
preprocessed_snapshots: np.ndarray
|
||||
|
||||
|
||||
def _hermitian_square_roots(weight: np.ndarray):
|
||||
weight = np.asarray(weight)
|
||||
if weight.ndim != 2 or weight.shape[0] != weight.shape[1]:
|
||||
raise ValueError("W must be square")
|
||||
if not np.allclose(weight, weight.conj().T, rtol=1e-12, atol=1e-12):
|
||||
raise ValueError("W must be Hermitian")
|
||||
values, vectors = np.linalg.eigh(weight)
|
||||
scale = max(1.0, float(np.max(np.abs(values))))
|
||||
if np.min(values) <= 1e-12 * scale:
|
||||
raise ValueError("W must be positive definite")
|
||||
root = (vectors * np.sqrt(values)) @ vectors.conj().T
|
||||
inverse = (vectors * (1.0 / np.sqrt(values))) @ vectors.conj().T
|
||||
return root, inverse
|
||||
|
||||
|
||||
def _as_observable_rows(observables: np.ndarray, sample_count: int):
|
||||
values = np.asarray(observables)
|
||||
if values.ndim == 2:
|
||||
if values.shape[1] != sample_count:
|
||||
raise ValueError("P must have N columns")
|
||||
return values, 1, values.shape[0]
|
||||
if values.ndim == 3:
|
||||
if values.shape[2] != sample_count:
|
||||
raise ValueError("P must have shape (L,Q,N)")
|
||||
return values.reshape(values.shape[0] * values.shape[1], sample_count), values.shape[0], values.shape[1]
|
||||
raise ValueError("P must have shape (Q,N) or (L,Q,N)")
|
||||
|
||||
|
||||
def _reference_ccd(snapshots: np.ndarray, observables: np.ndarray, *, weight: Optional[np.ndarray] = None, center: bool = False) -> _ReferenceResult:
|
||||
"""Evaluate the literal weighted-coordinate CCD equations without whitening."""
|
||||
u = np.asarray(snapshots)
|
||||
if u.ndim != 2 or u.shape[1] == 0:
|
||||
raise ValueError("U must have nonempty shape (M,N)")
|
||||
p, observable_count, delay_count = _as_observable_rows(observables, u.shape[1])
|
||||
if delay_count <= 0 or observable_count <= 0:
|
||||
raise ValueError("L and Q must be positive")
|
||||
raw_weight = np.eye(u.shape[0]) if weight is None else np.asarray(weight)
|
||||
dtype = np.result_type(u.dtype, p.dtype, raw_weight.dtype, np.float64)
|
||||
if not (np.can_cast(u.dtype, dtype, casting="safe") and np.can_cast(p.dtype, dtype, casting="safe") and np.can_cast(raw_weight.dtype, dtype, casting="safe")):
|
||||
raise TypeError("U, P, and W must cast losslessly to the computation dtype")
|
||||
u = u.astype(dtype, copy=True)
|
||||
p = p.astype(dtype, copy=True)
|
||||
if not np.all(np.isfinite(u)) or not np.all(np.isfinite(p)):
|
||||
raise ValueError("U and P must be finite")
|
||||
if center:
|
||||
u -= u.mean(axis=1, keepdims=True)
|
||||
p -= p.mean(axis=1, keepdims=True)
|
||||
m, n = u.shape
|
||||
if raw_weight.shape != (m, m):
|
||||
raise ValueError(f"W must have exact shape ({m}, {m})")
|
||||
w = raw_weight.astype(dtype, copy=True)
|
||||
if not np.all(np.isfinite(w)):
|
||||
raise ValueError("W must be finite")
|
||||
root, inverse = _hermitian_square_roots(w)
|
||||
x = root @ u
|
||||
a = p @ x.conj().T / (n * np.sqrt(observable_count * delay_count))
|
||||
r, sigma, vh = np.linalg.svd(a, full_matrices=True)
|
||||
v = vh.conj().T
|
||||
physical_modes = inverse @ v
|
||||
coefficients = physical_modes.conj().T @ w @ u
|
||||
return _ReferenceResult(a, r, sigma, v, physical_modes, coefficients, u)
|
||||
|
||||
|
||||
def _literal_cross_correlation(snapshots: np.ndarray, observables: np.ndarray):
|
||||
u = np.asarray(snapshots)
|
||||
p, l, q = _as_observable_rows(observables, u.shape[1])
|
||||
return p @ u.conj().T / (u.shape[1] * np.sqrt(l * q))
|
||||
|
||||
|
||||
def _build_lag_matrix(field_times: Sequence, observable_times: Sequence, observables: np.ndarray, delays: Sequence, *, field_blocks: Optional[Sequence] = None, observable_blocks: Optional[Sequence] = None, delay_kind: str = "time", interpolation: str = "exact"):
|
||||
"""Build exact lag columns using canonical per-block timestamp order.
|
||||
|
||||
Storage may interleave blocks. Within each block timestamps must be strictly
|
||||
increasing after selecting their storage subsequence; unsorted local clocks
|
||||
are rejected rather than silently canonicalized. ``(block, timestamp)``
|
||||
pairs must be unique. Duplicate delays are allowed and preserve declaration
|
||||
order, because they deliberately duplicate rows of P.
|
||||
"""
|
||||
ft = np.asarray(field_times)
|
||||
ot = np.asarray(observable_times)
|
||||
y = np.asarray(observables)
|
||||
if y.ndim == 1:
|
||||
y = y[None, :]
|
||||
if ft.ndim != 1 or ot.ndim != 1 or y.ndim != 2 or y.shape[1] != ot.size:
|
||||
raise ValueError("invalid clock or observable shape")
|
||||
if interpolation != "exact":
|
||||
raise NotImplementedError("only exact timestamp pairing is authorized")
|
||||
fb = np.zeros(ft.size, dtype=np.int64) if field_blocks is None else np.asarray(field_blocks)
|
||||
ob = np.zeros(ot.size, dtype=np.int64) if observable_blocks is None else np.asarray(observable_blocks)
|
||||
if fb.shape != ft.shape or ob.shape != ot.shape:
|
||||
raise ValueError("block labels must match clock shape")
|
||||
delays = list(delays)
|
||||
if not delays:
|
||||
raise ValueError("Q must be positive")
|
||||
|
||||
def pair(value):
|
||||
return value.item() if hasattr(value, "item") else value
|
||||
|
||||
field_pairs = [(pair(fb[i]), pair(ft[i])) for i in range(ft.size)]
|
||||
observable_pairs = [(pair(ob[i]), pair(ot[i])) for i in range(ot.size)]
|
||||
if len(set(field_pairs)) != len(field_pairs):
|
||||
raise ValueError("field (block, timestamp) pairs must be unique")
|
||||
if len(set(observable_pairs)) != len(observable_pairs):
|
||||
raise ValueError("observable (block, timestamp) pairs must be unique")
|
||||
|
||||
block_sequences = {}
|
||||
for storage_index, (block, time) in enumerate(observable_pairs):
|
||||
block_sequences.setdefault(block, []).append((time, storage_index))
|
||||
for block, sequence in block_sequences.items():
|
||||
local_times = [item[0] for item in sequence]
|
||||
if any(not local_times[i] < local_times[i + 1] for i in range(len(local_times) - 1)):
|
||||
raise ValueError(f"observable timestamps must be strictly increasing within block {block!r}")
|
||||
|
||||
lookup = {key: index for index, key in enumerate(observable_pairs)}
|
||||
local_positions = {
|
||||
(block, time): (position, sequence)
|
||||
for block, sequence in block_sequences.items()
|
||||
for position, (time, _) in enumerate(sequence)
|
||||
}
|
||||
columns, field_indices = [], []
|
||||
for i, (block, time) in enumerate(field_pairs):
|
||||
indices = []
|
||||
for delay in delays:
|
||||
if delay_kind == "time":
|
||||
index = lookup.get((block, pair(time + delay)))
|
||||
elif delay_kind == "index":
|
||||
if not isinstance(delay, (int, np.integer)):
|
||||
raise ValueError("index delays must be integers")
|
||||
local = local_positions.get((block, time))
|
||||
if local is None:
|
||||
index = None
|
||||
else:
|
||||
position, sequence = local
|
||||
target = position + int(delay)
|
||||
index = sequence[target][1] if 0 <= target < len(sequence) else None
|
||||
else:
|
||||
raise ValueError("delay_kind must be 'time' or 'index'")
|
||||
if index is None:
|
||||
indices = []
|
||||
break
|
||||
indices.append(index)
|
||||
if indices:
|
||||
columns.append(y[:, indices].reshape(-1))
|
||||
field_indices.append(i)
|
||||
if not columns:
|
||||
raise ValueError("no complete exact lag columns")
|
||||
return np.stack(columns, axis=1), np.asarray(field_indices, dtype=np.int64)
|
||||
@@ -0,0 +1,362 @@
|
||||
"""Production CPU implementation of original full-field Lyu CCD.
|
||||
|
||||
The implementation SVDs the rectangular ``(L*Q) x M`` cross-correlation and
|
||||
never forms an ``M x M`` operator. Rows are always channel-major/delay-minor.
|
||||
"""
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, Sequence, Tuple, Union
|
||||
|
||||
import numpy as np
|
||||
|
||||
Array = np.ndarray
|
||||
ROW_ORDER = "channel-major_delay-minor"
|
||||
|
||||
|
||||
def _positive_int(name: str, value: object) -> int:
|
||||
if not isinstance(value, (int, np.integer)) or isinstance(value, (bool, np.bool_)) or value <= 0:
|
||||
raise ValueError(f"{name} must be a positive integer")
|
||||
return int(value)
|
||||
|
||||
|
||||
def _numeric_finite_array(name: str, value: object) -> Array:
|
||||
array = np.asarray(value)
|
||||
if array.dtype.kind not in "iufc" or array.dtype.kind == "b":
|
||||
raise TypeError(f"{name} must have a real or complex numeric dtype")
|
||||
if not np.all(np.isfinite(array)):
|
||||
raise ValueError(f"{name} must be finite")
|
||||
return array
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CCDConfig:
|
||||
"""Declared preprocessing, rank-identification, and numerical configuration."""
|
||||
|
||||
center_snapshots: bool = False
|
||||
center_observables: bool = False
|
||||
rank: Optional[int] = None
|
||||
chunk_size: Optional[int] = None
|
||||
phase_convention: bool = True
|
||||
singular_block_rtol: float = 1e-10
|
||||
singular_block_atol: float = 0.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LaggedObservables:
|
||||
"""Validated exact lag matrix and field-column mapping.
|
||||
|
||||
``matrix`` rows are channel-major/delay-minor: row ``l*Q + q`` stores
|
||||
channel ``l`` at declared delay ``q``.
|
||||
"""
|
||||
|
||||
matrix: Array
|
||||
field_indices: Array
|
||||
delays: Tuple[object, ...]
|
||||
observable_count: int
|
||||
delay_count: int
|
||||
row_order: str = ROW_ORDER
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
matrix = _numeric_finite_array("lagged matrix", self.matrix)
|
||||
if matrix.ndim != 2 or 0 in matrix.shape:
|
||||
raise ValueError("lagged matrix must have nonempty shape (LQ,N)")
|
||||
observable_count = _positive_int("observable_count", self.observable_count)
|
||||
delay_count = _positive_int("delay_count", self.delay_count)
|
||||
delays = tuple(self.delays)
|
||||
if len(delays) != delay_count:
|
||||
raise ValueError("lagged delays length must equal delay_count")
|
||||
if matrix.shape[0] != observable_count * delay_count:
|
||||
raise ValueError("lagged metadata L*Q must equal the matrix row count")
|
||||
if self.row_order != ROW_ORDER:
|
||||
raise ValueError(f"row_order must be {ROW_ORDER!r}")
|
||||
indices = np.asarray(self.field_indices)
|
||||
if indices.ndim != 1 or not np.issubdtype(indices.dtype, np.integer):
|
||||
raise ValueError("lagged field_indices must be one-dimensional integers")
|
||||
indices = indices.astype(np.int64, copy=True)
|
||||
if indices.size != matrix.shape[1] or np.any(indices < 0):
|
||||
raise ValueError("lagged field_indices must be nonnegative and match matrix columns")
|
||||
if np.unique(indices).size != indices.size:
|
||||
raise ValueError("lagged field_indices must not contain duplicates")
|
||||
matrix = matrix.copy()
|
||||
matrix.setflags(write=False)
|
||||
indices.setflags(write=False)
|
||||
object.__setattr__(self, "matrix", matrix)
|
||||
object.__setattr__(self, "field_indices", indices)
|
||||
object.__setattr__(self, "delays", delays)
|
||||
object.__setattr__(self, "observable_count", observable_count)
|
||||
object.__setattr__(self, "delay_count", delay_count)
|
||||
|
||||
def as_lqn(self) -> Array:
|
||||
"""Return the explicit channel-major ``(L,Q,N)`` view."""
|
||||
return self.matrix.reshape(self.observable_count, self.delay_count, self.matrix.shape[1])
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CCDResult:
|
||||
"""Original-CCD factors and weighted field-projection data."""
|
||||
|
||||
config: CCDConfig
|
||||
cross_correlation: Array
|
||||
left_functions: Array
|
||||
singular_values: Array
|
||||
weighted_modes: Array
|
||||
physical_modes: Array
|
||||
coefficients: Array
|
||||
snapshot_mean: Array
|
||||
observable_mean: Array
|
||||
preprocessed_snapshots: Array
|
||||
selected_field_indices: Array
|
||||
observable_count: int
|
||||
delay_count: int
|
||||
singular_blocks: Tuple[Tuple[int, int], ...]
|
||||
identifiable_mode_mask: Array
|
||||
|
||||
@property
|
||||
def rank(self) -> int:
|
||||
"""Number of retained numerical SVD vectors, including any null vectors."""
|
||||
return int(self.singular_values.size)
|
||||
|
||||
@property
|
||||
def identifiable_rank(self) -> int:
|
||||
"""Number of retained non-null vectors; complete degenerate blocks count fully."""
|
||||
return int(np.count_nonzero(self.identifiable_mode_mask))
|
||||
|
||||
@property
|
||||
def valid_sample_count(self) -> int:
|
||||
"""Number of admitted aligned columns used in the empirical average."""
|
||||
return int(self.preprocessed_snapshots.shape[1])
|
||||
|
||||
def left_functions_lq(self) -> Array:
|
||||
"""Return unit-normalized left functions as ``(L,Q,rank)``."""
|
||||
return self.left_functions.reshape(self.observable_count, self.delay_count, self.rank)
|
||||
|
||||
def _mode_indices(self, rank: Optional[int], mode_indices: Optional[Sequence[int]], allow_basis_dependent: bool) -> Array:
|
||||
if rank is not None and mode_indices is not None:
|
||||
raise ValueError("specify rank or mode_indices, not both")
|
||||
if mode_indices is None:
|
||||
use_rank = self.identifiable_rank if rank is None else _validate_rank(rank, self.rank)
|
||||
indices = np.arange(use_rank, dtype=np.int64)
|
||||
else:
|
||||
raw = np.asarray(mode_indices)
|
||||
if raw.ndim != 1 or raw.size == 0 or not np.issubdtype(raw.dtype, np.integer):
|
||||
raise ValueError("mode_indices must be a nonempty one-dimensional integer sequence")
|
||||
indices = raw.astype(np.int64, copy=False)
|
||||
if np.any(indices < 0) or np.any(indices >= self.rank):
|
||||
raise ValueError("mode index is out of range")
|
||||
if np.unique(indices).size != indices.size:
|
||||
raise ValueError("mode_indices must not contain duplicates")
|
||||
if allow_basis_dependent:
|
||||
return indices
|
||||
chosen = set(indices.tolist())
|
||||
if any(not self.identifiable_mode_mask[index] for index in indices):
|
||||
raise ValueError("null/near-zero modes are basis-dependent; pass allow_basis_dependent=True explicitly")
|
||||
for start, stop in self.singular_blocks:
|
||||
overlap = chosen.intersection(range(start, stop))
|
||||
if overlap and len(overlap) != stop - start:
|
||||
raise ValueError("selection splits a nonzero degenerate singular block; pass allow_basis_dependent=True explicitly")
|
||||
return indices
|
||||
|
||||
def reconstruct(self, *, rank: Optional[int] = None, mode_indices: Optional[Sequence[int]] = None, restore_snapshot_mean: bool = False, allow_basis_dependent: bool = False) -> Array:
|
||||
"""Reconstruct a weighted field projection.
|
||||
|
||||
By default this uses all identifiable non-null modes and refuses a rank
|
||||
or index selection that splits a nonzero degenerate block. Setting
|
||||
``allow_basis_dependent=True`` explicitly requests arbitrary SVD vectors.
|
||||
"""
|
||||
indices = self._mode_indices(rank, mode_indices, allow_basis_dependent)
|
||||
reconstructed = self.physical_modes[:, indices] @ self.coefficients[indices]
|
||||
if restore_snapshot_mean:
|
||||
reconstructed = reconstructed + self.snapshot_mean
|
||||
return reconstructed
|
||||
|
||||
def residual(self, *, rank: Optional[int] = None, mode_indices: Optional[Sequence[int]] = None, allow_basis_dependent: bool = False) -> Array:
|
||||
"""Return preprocessed snapshots minus the selected weighted projection."""
|
||||
return self.preprocessed_snapshots - self.reconstruct(rank=rank, mode_indices=mode_indices, allow_basis_dependent=allow_basis_dependent)
|
||||
|
||||
|
||||
def flatten_fields(fields: Array) -> Tuple[Array, Tuple[int, ...]]:
|
||||
"""Flatten ``(..., N)`` field samples to ``(M, N)`` and return field shape."""
|
||||
values = _numeric_finite_array("fields", fields)
|
||||
if values.ndim < 2 or values.shape[-1] == 0:
|
||||
raise ValueError("fields must have nonempty shape (..., N)")
|
||||
field_shape = tuple(values.shape[:-1])
|
||||
return values.reshape(int(np.prod(field_shape)), values.shape[-1]), field_shape
|
||||
|
||||
|
||||
def unflatten_fields(snapshots: Array, field_shape: Sequence[int]) -> Array:
|
||||
"""Restore a flattened ``(M,N)`` matrix to ``(...,N)``."""
|
||||
values = _numeric_finite_array("snapshots", snapshots)
|
||||
shape = tuple(int(value) for value in field_shape)
|
||||
if values.ndim != 2 or not shape or any(value <= 0 for value in shape):
|
||||
raise ValueError("snapshots and field_shape are invalid")
|
||||
if int(np.prod(shape)) != values.shape[0]:
|
||||
raise ValueError("field_shape does not match the flattened field dimension")
|
||||
return values.reshape(shape + (values.shape[1],))
|
||||
|
||||
|
||||
def _observable_rows(observables: Array, sample_count: int) -> Tuple[Array, int, int]:
|
||||
values = _numeric_finite_array("P", observables)
|
||||
if values.ndim == 2:
|
||||
if values.shape[0] == 0 or values.shape[1] != sample_count:
|
||||
raise ValueError("P must have nonempty shape (LQ,N)")
|
||||
return values, 1, values.shape[0]
|
||||
if values.ndim == 3:
|
||||
if 0 in values.shape or values.shape[2] != sample_count:
|
||||
raise ValueError("P must have nonempty shape (L,Q,N)")
|
||||
return values.reshape(values.shape[0] * values.shape[1], sample_count), values.shape[0], values.shape[1]
|
||||
raise ValueError("P must have shape (LQ,N) or (L,Q,N)")
|
||||
|
||||
|
||||
def _validate_config(config: CCDConfig) -> None:
|
||||
if not isinstance(config, CCDConfig):
|
||||
raise TypeError("config must be CCDConfig")
|
||||
if type(config.center_snapshots) is not bool or type(config.center_observables) is not bool or type(config.phase_convention) is not bool:
|
||||
raise TypeError("centering and phase declarations must be bool")
|
||||
if config.chunk_size is not None:
|
||||
_positive_int("chunk_size", config.chunk_size)
|
||||
for name, value in (("singular_block_rtol", config.singular_block_rtol), ("singular_block_atol", config.singular_block_atol)):
|
||||
if not isinstance(value, (int, float, np.integer, np.floating)) or isinstance(value, (bool, np.bool_)) or not np.isfinite(value) or value < 0:
|
||||
raise ValueError(f"{name} must be finite and nonnegative")
|
||||
|
||||
|
||||
def _validate_rank(rank: object, maximum: int) -> int:
|
||||
value = _positive_int("rank", rank)
|
||||
if value > maximum:
|
||||
raise ValueError(f"rank must be between 1 and {maximum}")
|
||||
return value
|
||||
|
||||
|
||||
def _conditioning_threshold(dtype: np.dtype, size: int) -> float:
|
||||
"""Relative HPD eigenvalue floor: ``eps(real computation dtype) * size``."""
|
||||
real_dtype = np.empty((), dtype=dtype).real.dtype
|
||||
return float(np.finfo(real_dtype).eps * max(1, size))
|
||||
|
||||
|
||||
def _validate_positive_spectrum(values: Array, dtype: np.dtype, size: int) -> None:
|
||||
maximum = float(np.max(values))
|
||||
minimum = float(np.min(values))
|
||||
if maximum <= 0 or minimum <= 0 or minimum / maximum <= _conditioning_threshold(dtype, size):
|
||||
raise ValueError("W must be positive definite and numerically well-conditioned by min/max ratio")
|
||||
|
||||
|
||||
def _weight_transforms(weight: Optional[Array], size: int, dtype: np.dtype):
|
||||
if weight is None:
|
||||
diagonal = np.ones(size, dtype=np.float64)
|
||||
return "diagonal", diagonal, diagonal
|
||||
raw = _numeric_finite_array("W", weight)
|
||||
if raw.ndim == 1:
|
||||
if raw.shape != (size,):
|
||||
raise ValueError(f"diagonal W must have exact shape ({size},)")
|
||||
diagonal = raw.astype(dtype, copy=True)
|
||||
if np.iscomplexobj(diagonal) and not np.all(diagonal.imag == 0):
|
||||
raise ValueError("diagonal W must be real positive")
|
||||
diagonal = diagonal.real
|
||||
_validate_positive_spectrum(diagonal, dtype, size)
|
||||
return "diagonal", np.sqrt(diagonal), 1.0 / np.sqrt(diagonal)
|
||||
if raw.ndim != 2 or raw.shape != (size, size):
|
||||
raise ValueError(f"dense W must have exact shape ({size}, {size})")
|
||||
dense = raw.astype(dtype, copy=True)
|
||||
hermitian_scale = float(np.max(np.abs(dense)))
|
||||
tolerance = _conditioning_threshold(dtype, size) * hermitian_scale
|
||||
if hermitian_scale == 0 or float(np.max(np.abs(dense - dense.conj().T))) > tolerance:
|
||||
raise ValueError("W must be Hermitian")
|
||||
values, vectors = np.linalg.eigh(dense)
|
||||
_validate_positive_spectrum(values, dtype, size)
|
||||
root = (vectors * np.sqrt(values)) @ vectors.conj().T
|
||||
inverse = (vectors * (1.0 / np.sqrt(values))) @ vectors.conj().T
|
||||
return "dense", root, inverse
|
||||
|
||||
|
||||
def _apply_transform(kind: str, transform: Array, values: Array) -> Array:
|
||||
return transform[:, None] * values if kind == "diagonal" else transform @ values
|
||||
|
||||
|
||||
def _phase_fix(left: Array, right: Array) -> Tuple[Array, Array]:
|
||||
left = left.copy()
|
||||
right = right.copy()
|
||||
for column in range(right.shape[1]):
|
||||
pivot = int(np.argmax(np.abs(right[:, column])))
|
||||
value = right[pivot, column]
|
||||
if value != 0:
|
||||
phase = np.conj(value) / abs(value)
|
||||
right[:, column] *= phase
|
||||
left[:, column] *= phase
|
||||
return left, right
|
||||
|
||||
|
||||
def _classify_singular_values(values: Array, config: CCDConfig) -> Tuple[Array, Tuple[Tuple[int, int], ...]]:
|
||||
if values.size == 0:
|
||||
return np.zeros(0, dtype=bool), ()
|
||||
scale = float(values[0])
|
||||
null_tolerance = config.singular_block_atol + config.singular_block_rtol * scale
|
||||
identifiable = values > null_tolerance
|
||||
blocks = []
|
||||
stop_nonzero = int(np.count_nonzero(identifiable))
|
||||
start = 0
|
||||
while start < stop_nonzero:
|
||||
stop = start + 1
|
||||
while stop < stop_nonzero and abs(float(values[stop] - values[start])) <= config.singular_block_atol + config.singular_block_rtol * max(float(values[start]), float(values[stop])):
|
||||
stop += 1
|
||||
if stop - start > 1:
|
||||
blocks.append((start, stop))
|
||||
start = stop
|
||||
return identifiable, tuple(blocks)
|
||||
|
||||
|
||||
def decompose(snapshots: Array, observables: Array, *, weight: Optional[Array] = None, config: Optional[CCDConfig] = None) -> CCDResult:
|
||||
"""Decompose aligned full-field snapshots and supplied lag observables."""
|
||||
cfg = CCDConfig() if config is None else config
|
||||
_validate_config(cfg)
|
||||
u_raw = _numeric_finite_array("U", snapshots)
|
||||
if u_raw.ndim != 2 or 0 in u_raw.shape:
|
||||
raise ValueError("U must have nonempty shape (M,N)")
|
||||
p_raw, observable_count, delay_count = _observable_rows(observables, u_raw.shape[1])
|
||||
weight_raw = np.ones(u_raw.shape[0]) if weight is None else _numeric_finite_array("W", weight)
|
||||
dtype = np.result_type(u_raw.dtype, p_raw.dtype, weight_raw.dtype, np.float64)
|
||||
u = u_raw.astype(dtype, copy=True)
|
||||
p = p_raw.astype(dtype, copy=True)
|
||||
snapshot_mean = u.mean(axis=1, keepdims=True) if cfg.center_snapshots else np.zeros((u.shape[0], 1), dtype=dtype)
|
||||
observable_mean = p.mean(axis=1, keepdims=True) if cfg.center_observables else np.zeros((p.shape[0], 1), dtype=dtype)
|
||||
u -= snapshot_mean
|
||||
p -= observable_mean
|
||||
kind, root, inverse = _weight_transforms(weight, u.shape[0], dtype)
|
||||
chunk = u.shape[1] if cfg.chunk_size is None else min(int(cfg.chunk_size), u.shape[1])
|
||||
cross = np.zeros((p.shape[0], u.shape[0]), dtype=dtype)
|
||||
for start in range(0, u.shape[1], chunk):
|
||||
stop = min(start + chunk, u.shape[1])
|
||||
cross += p[:, start:stop] @ _apply_transform(kind, root, u[:, start:stop]).conj().T
|
||||
cross /= u.shape[1] * np.sqrt(observable_count * delay_count)
|
||||
left, singular_values, vh = np.linalg.svd(cross, full_matrices=False)
|
||||
weighted_modes = vh.conj().T
|
||||
if cfg.phase_convention:
|
||||
left, weighted_modes = _phase_fix(left, weighted_modes)
|
||||
identifiable, blocks = _classify_singular_values(singular_values, cfg)
|
||||
maximum_rank = singular_values.size
|
||||
retained_rank = maximum_rank if cfg.rank is None else _validate_rank(cfg.rank, maximum_rank)
|
||||
if cfg.rank is not None:
|
||||
if any(start < retained_rank < stop for start, stop in blocks):
|
||||
raise ValueError("configured rank splits a nonzero degenerate singular block")
|
||||
left = left[:, :retained_rank]
|
||||
singular_values = singular_values[:retained_rank]
|
||||
weighted_modes = weighted_modes[:, :retained_rank]
|
||||
identifiable = identifiable[:retained_rank]
|
||||
blocks = tuple((start, stop) for start, stop in blocks if stop <= retained_rank)
|
||||
physical_modes = _apply_transform(kind, inverse, weighted_modes)
|
||||
coefficients = np.empty((retained_rank, u.shape[1]), dtype=dtype)
|
||||
for start in range(0, u.shape[1], chunk):
|
||||
stop = min(start + chunk, u.shape[1])
|
||||
coefficients[:, start:stop] = weighted_modes.conj().T @ _apply_transform(kind, root, u[:, start:stop])
|
||||
return CCDResult(cfg, cross, left, singular_values, weighted_modes, physical_modes, coefficients, snapshot_mean, observable_mean, u, np.arange(u.shape[1], dtype=np.int64), observable_count, delay_count, blocks, identifiable)
|
||||
|
||||
|
||||
def fit(snapshots: Array, observables: Union[Array, LaggedObservables], *, weight: Optional[Array] = None, config: Optional[CCDConfig] = None) -> CCDResult:
|
||||
"""Fit CCD, applying a validated exact-lag field-column mapping first."""
|
||||
if not isinstance(observables, LaggedObservables):
|
||||
return decompose(snapshots, observables, weight=weight, config=config)
|
||||
u = _numeric_finite_array("U", snapshots)
|
||||
if u.ndim != 2 or 0 in u.shape:
|
||||
raise ValueError("U must have nonempty shape (M,N)")
|
||||
indices = observables.field_indices
|
||||
if np.any(indices >= u.shape[1]):
|
||||
raise ValueError("lagged field_indices do not map valid U columns")
|
||||
result = decompose(u[:, indices], observables.as_lqn(), weight=weight, config=config)
|
||||
return CCDResult(result.config, result.cross_correlation, result.left_functions, result.singular_values, result.weighted_modes, result.physical_modes, result.coefficients, result.snapshot_mean, result.observable_mean, result.preprocessed_snapshots, indices.copy(), observables.observable_count, observables.delay_count, result.singular_blocks, result.identifiable_mode_mask)
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Exact, block-local lag construction for original CCD."""
|
||||
from typing import Optional, Sequence
|
||||
|
||||
import numpy as np
|
||||
|
||||
from .api import LaggedObservables, _numeric_finite_array
|
||||
|
||||
|
||||
def _scalar(value):
|
||||
return value.item() if hasattr(value, "item") else value
|
||||
|
||||
|
||||
def _pairs(times, blocks, label):
|
||||
pairs = []
|
||||
for block, time in zip(blocks, times):
|
||||
pair = (_scalar(block), _scalar(time))
|
||||
try:
|
||||
hash(pair)
|
||||
except TypeError as exc:
|
||||
raise TypeError(f"{label} block/timestamp values must be hashable") from exc
|
||||
pairs.append(pair)
|
||||
if len(set(pairs)) != len(pairs):
|
||||
raise ValueError(f"{label} (block, timestamp) pairs must be unique")
|
||||
return pairs
|
||||
|
||||
|
||||
def build_lagged_observables(field_times: Sequence, observable_times: Sequence, observables: np.ndarray, delays: Sequence, *, field_blocks: Optional[Sequence] = None, observable_blocks: Optional[Sequence] = None, delay_kind: str = "time", interpolation: str = "exact") -> LaggedObservables:
|
||||
"""Construct exact lag columns, preserving delay and field-column order."""
|
||||
field_clock = np.asarray(field_times)
|
||||
observable_clock = np.asarray(observable_times)
|
||||
values = _numeric_finite_array("observables", observables)
|
||||
if field_clock.ndim != 1 or observable_clock.ndim != 1:
|
||||
raise ValueError("field_times and observable_times must be one-dimensional")
|
||||
if field_clock.dtype.kind not in "iuf" or observable_clock.dtype.kind not in "iuf":
|
||||
raise TypeError("timestamps must have a real numeric dtype")
|
||||
if not np.all(np.isfinite(field_clock)) or not np.all(np.isfinite(observable_clock)):
|
||||
raise ValueError("timestamps must be finite")
|
||||
if values.ndim == 1:
|
||||
values = values[None, :]
|
||||
if values.ndim != 2 or values.shape[0] == 0 or values.shape[1] != observable_clock.size:
|
||||
raise ValueError("observables must have shape (L, observable_sample_count)")
|
||||
if interpolation != "exact":
|
||||
raise NotImplementedError("only exact timestamp pairing is supported")
|
||||
delay_values = tuple(delays)
|
||||
if not delay_values:
|
||||
raise ValueError("Q must be positive")
|
||||
if delay_kind not in ("time", "index"):
|
||||
raise ValueError("delay_kind must be 'time' or 'index'")
|
||||
if delay_kind == "index" and any(not isinstance(value, (int, np.integer)) or isinstance(value, (bool, np.bool_)) for value in delay_values):
|
||||
raise ValueError("index delays must be integers")
|
||||
field_block_values = np.zeros(field_clock.size, dtype=np.int64) if field_blocks is None else np.asarray(field_blocks)
|
||||
observable_block_values = np.zeros(observable_clock.size, dtype=np.int64) if observable_blocks is None else np.asarray(observable_blocks)
|
||||
if field_block_values.shape != field_clock.shape or observable_block_values.shape != observable_clock.shape:
|
||||
raise ValueError("block labels must exactly match their clock shape")
|
||||
field_pairs = _pairs(field_clock, field_block_values, "field")
|
||||
observable_pairs = _pairs(observable_clock, observable_block_values, "observable")
|
||||
sequences = {}
|
||||
for storage_index, (block, time) in enumerate(observable_pairs):
|
||||
sequences.setdefault(block, []).append((time, storage_index))
|
||||
for block, sequence in sequences.items():
|
||||
local_times = [entry[0] for entry in sequence]
|
||||
if any(not local_times[index] < local_times[index + 1] for index in range(len(local_times) - 1)):
|
||||
raise ValueError(f"observable timestamps must be strictly increasing within block {block!r}")
|
||||
lookup = {pair: index for index, pair in enumerate(observable_pairs)}
|
||||
local_positions = {(block, time): (position, sequence) for block, sequence in sequences.items() for position, (time, _) in enumerate(sequence)}
|
||||
columns = []
|
||||
field_indices = []
|
||||
for field_index, (block, time) in enumerate(field_pairs):
|
||||
sample_indices = []
|
||||
for delay in delay_values:
|
||||
if delay_kind == "time":
|
||||
try:
|
||||
target_time = _scalar(time + delay)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise TypeError("time delays must be compatible with timestamp dtype") from exc
|
||||
observable_index = lookup.get((block, target_time))
|
||||
else:
|
||||
local = local_positions.get((block, time))
|
||||
if local is None:
|
||||
observable_index = None
|
||||
else:
|
||||
position, sequence = local
|
||||
target = position + int(delay)
|
||||
observable_index = sequence[target][1] if 0 <= target < len(sequence) else None
|
||||
if observable_index is None:
|
||||
sample_indices = []
|
||||
break
|
||||
sample_indices.append(observable_index)
|
||||
if sample_indices:
|
||||
columns.append(values[:, sample_indices].reshape(-1))
|
||||
field_indices.append(field_index)
|
||||
if not columns:
|
||||
raise ValueError("no complete exact lag columns")
|
||||
return LaggedObservables(np.stack(columns, axis=1), np.asarray(field_indices, dtype=np.int64), delay_values, values.shape[0], len(delay_values))
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Active real-case provenance-validated streaming CCD package."""
|
||||
from .core import MemoryBudget,RealCCDInput,RealCCDResult,StreamingConfig,decompose_streaming,estimate_memory,inspect_direct_dq_dimensions,load_validated_input
|
||||
from .io import ResultTransaction,load_result
|
||||
from .preflight import available_host_memory_bytes,preflight_real_artifact,safe_host_budget
|
||||
__all__=["MemoryBudget","StreamingConfig","RealCCDInput","RealCCDResult","estimate_memory","inspect_direct_dq_dimensions","load_validated_input","decompose_streaming","ResultTransaction","load_result","available_host_memory_bytes","safe_host_budget","preflight_real_artifact"]
|
||||
@@ -0,0 +1,3 @@
|
||||
from .cli import main
|
||||
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,46 @@
|
||||
"""CPU-only CLI for provenance-bound real-case CCD."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Sequence
|
||||
|
||||
from .core import MemoryBudget, StreamingConfig, decompose_streaming, load_validated_input
|
||||
from .io import ResultTransaction
|
||||
from .preflight import preflight_real_artifact, safe_host_budget
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="Provenance-bound streaming real-case CCD (CPU only)")
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
for command in ("preflight", "run"):
|
||||
subparser = subparsers.add_parser(command)
|
||||
subparser.add_argument("--direct-dq-root", required=True, type=Path)
|
||||
subparser.add_argument("--chunk-size", required=True, type=int)
|
||||
subparser.add_argument("--available-host-bytes", type=int, help="Override detected MemAvailable for deterministic admission tests")
|
||||
if command == "run":
|
||||
subparser.add_argument("--output", required=True, type=Path)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
root = args.direct_dq_root.resolve()
|
||||
if args.command == "preflight":
|
||||
report = preflight_real_artifact(root, chunk_size=args.chunk_size, available_bytes=args.available_host_bytes)
|
||||
print(json.dumps(report, sort_keys=True))
|
||||
return 0
|
||||
|
||||
output = args.output.resolve()
|
||||
if output.exists():
|
||||
raise FileExistsError(output)
|
||||
host = safe_host_budget(available_bytes=args.available_host_bytes)
|
||||
config = StreamingConfig(args.chunk_size, MemoryBudget(host["ram_budget_bytes"], 0, 1.25))
|
||||
inp, memory = load_validated_input(root, streaming_config=config)
|
||||
result = decompose_streaming(inp, streaming_config=config, memory_estimate=memory)
|
||||
with ResultTransaction(output) as transaction:
|
||||
transaction.write(result)
|
||||
published = transaction.publish()
|
||||
print(json.dumps({"result": str(published), "case_id": result.config["case_id"], "chunk_size": args.chunk_size, "host_memory": host, "memory": memory, "summary": result.summary}, sort_keys=True))
|
||||
return 0
|
||||
@@ -0,0 +1,147 @@
|
||||
"""Provenance-bound, mask-compressed streaming real-case Q=1 CCD."""
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterator, Mapping
|
||||
import json
|
||||
import numpy as np
|
||||
from CCD_analysis.acquisition.contracts import ACTION_IDENTITIES
|
||||
from CCD_analysis.direct_dq.analysis import coordinate_weights
|
||||
from CCD_analysis.direct_dq.io import load_acquisition_artifact, load_result as load_direct_dq_result
|
||||
from CCD_analysis.acquisition.artifacts import file_sha256
|
||||
from CCD_analysis.direct_dq.schema import canonical_array_sha256
|
||||
|
||||
ACTION_CHANNEL_NAMES=("front","upper","lower")
|
||||
ACTION_UNITS="native solver angular-velocity command units"
|
||||
FLATTEN_ORDER="component-major ux then uy; C-order analysis-mask point order"
|
||||
WEIGHT_RULE="direct_dq.coordinate_weights(x_D)*coordinate_weights(y_D), repeated ux then uy; not area-normalized"
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MemoryBudget:
|
||||
ram_bytes:int
|
||||
scratch_bytes:int
|
||||
safety_margin:float=1.25
|
||||
def __post_init__(self):
|
||||
if type(self.ram_bytes) is not int or type(self.scratch_bytes) is not int or self.ram_bytes<=0 or self.scratch_bytes<0 or not np.isfinite(self.safety_margin) or self.safety_margin<1:
|
||||
raise ValueError("explicit positive RAM, nonnegative scratch, and safety_margin>=1 required")
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StreamingConfig:
|
||||
chunk_size:int
|
||||
budget:MemoryBudget
|
||||
singular_block_rtol:float=1e-10
|
||||
singular_block_atol:float=0.0
|
||||
def __post_init__(self):
|
||||
if type(self.chunk_size) is not int or self.chunk_size<=0: raise ValueError("chunk_size must be positive")
|
||||
if min(self.singular_block_rtol,self.singular_block_atol)<0: raise ValueError("singular tolerances must be nonnegative")
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RealCCDInput:
|
||||
case_id:str; direct_dq_root:Path; direct_manifest_sha256:str
|
||||
acquisition_identities:Mapping[str,Mapping[str,Any]]
|
||||
x_D:np.ndarray; y_D:np.ndarray; role_masks:Mapping[str,np.ndarray]; analysis_mask:np.ndarray
|
||||
selected_indices:np.ndarray; selected_relative_steps:np.ndarray
|
||||
q_ctl_absolute_steps:np.ndarray; dq_ctl:np.ndarray; actions:np.ndarray
|
||||
authoritative_mean_dq_ctl:np.ndarray
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RealCCDResult:
|
||||
arrays:dict[str,np.ndarray]; config:dict[str,Any]; summary:dict[str,Any]; input_hashes:dict[str,Any]
|
||||
|
||||
def _read_json(path:Path)->dict[str,Any]:
|
||||
value=json.loads(path.read_text())
|
||||
if not isinstance(value,dict): raise ValueError(f"JSON object required: {path}")
|
||||
return value
|
||||
|
||||
def estimate_memory(*,m:int,n:int,nx:int,ny:int,full_count:int,chunk_size:int,budget:MemoryBudget)->dict[str,Any]:
|
||||
if min(m,n,nx,ny,full_count,chunk_size)<=0: raise ValueError("memory dimensions must be positive")
|
||||
c=min(chunk_size,n); grid=nx*ny
|
||||
terms={
|
||||
"validated_direct_result_float32_fields":6*n*2*grid*4,
|
||||
"validated_live_acquisition_float32_fields":3*full_count*2*grid*4,
|
||||
"loader_decompression_and_copy_allowance":(6*n+3*full_count)*2*grid*4,
|
||||
"field_mean_float64":m*8,"weights_and_roots_float64":2*m*8,
|
||||
"cross_and_modes_float64":(3*m+2*3*m)*8,
|
||||
"chunk_float64_working_set":3*m*c*8,
|
||||
"actions_coefficients_and_small_svd":(3*n+3*n+30)*8,
|
||||
}
|
||||
raw=sum(terms.values()); peak=int(np.ceil(raw*budget.safety_margin)); scratch_raw=0; scratch=int(np.ceil(scratch_raw*budget.safety_margin))
|
||||
decision=peak<=budget.ram_bytes and scratch<=budget.scratch_bytes
|
||||
out={"formula":"ceil(safety_margin * sum(terms)); loader residency explicitly included; no MxM or full float64 MxN term","terms_bytes":terms,"raw_peak_ram_bytes":raw,"estimated_peak_ram_bytes":peak,"raw_scratch_bytes":scratch_raw,"estimated_scratch_bytes":scratch,"ram_budget_bytes":budget.ram_bytes,"scratch_budget_bytes":budget.scratch_bytes,"safety_margin":budget.safety_margin,"decision":"PASS" if decision else "FAIL"}
|
||||
if not decision: raise MemoryError(f"real-CCD memory/scratch estimate exceeds explicit budget: {out}")
|
||||
return out
|
||||
|
||||
def inspect_direct_dq_dimensions(path:str|Path)->dict[str,int]:
|
||||
root=Path(path); summary=_read_json(root/'summary.json'); config=_read_json(root/'config.json')
|
||||
if config.get('schema_id')!='ccd-direct-dq-config/v2': raise ValueError('authoritative direct-dq config required')
|
||||
identities=_read_json(root/'input_hashes.json'); qctl=Path(identities['q_ctl']['path']); acq_manifest=_read_json(qctl/'manifest.json'); acq_config=_read_json(qctl/'config.json')
|
||||
frame=acq_config['runtime']['coordinate_frame']
|
||||
return {"n":int(summary['sample_count']),"nx":int(frame['x']['count']),"ny":int(frame['y']['count']),"full_count":int(acq_manifest['field_count'])}
|
||||
|
||||
def load_validated_input(path:str|Path,*,streaming_config:StreamingConfig)->tuple[RealCCDInput,dict[str,Any]]:
|
||||
root=Path(path).resolve(); dims=inspect_direct_dq_dimensions(root)
|
||||
estimate=estimate_memory(m=2*dims['nx']*dims['ny'],chunk_size=streaming_config.chunk_size,budget=streaming_config.budget,**dims)
|
||||
direct=load_direct_dq_result(root); a=direct['arrays']; cfg=direct['config']; recorded=direct['input_hashes']; case=cfg['case_id']
|
||||
mask=a['analysis_fluid_mask']; m=2*int(mask.sum())
|
||||
tight=estimate_memory(m=m,chunk_size=streaming_config.chunk_size,budget=streaming_config.budget,**dims)
|
||||
qctl=load_acquisition_artifact(recorded['q_ctl']['path'],expected_case=case,expected_role='q_ctl')
|
||||
idx=a['selected_timeline_indices']; rel=a['selected_acquisition_relative_lattice_steps']
|
||||
if not np.array_equal(qctl.fields['acquisition_relative_lattice_steps'][idx],rel): raise ValueError('q_ctl exact selected indices/timestamps mismatch')
|
||||
actions=qctl.fields['effective_applied_action'][idx,-3:].copy()
|
||||
if actions.dtype!=np.float32 or actions.shape!=(idx.size,3) or not np.isfinite(actions).all(): raise ValueError('q_ctl effective field-time actions invalid')
|
||||
dq=(a['q_ctl_instantaneous']-a['q_blk_instantaneous'])
|
||||
if not np.array_equal(dq,a['dq_ctl_instantaneous']): raise ValueError('authoritative direct_dq dq_ctl identity failed')
|
||||
inp=RealCCDInput(case,root,file_sha256(root/'manifest.json'),recorded,a['x_D'].copy(),a['y_D'].copy(),{r:a[f'{r}_solver_fluid_mask'].copy() for r in ('q_target','q_blk','q_ctl')},mask.copy(),idx.copy(),rel.copy(),qctl.fields['lattice_steps'][idx].copy(),dq,actions,a['dq_ctl_mean'].copy())
|
||||
estimate={**tight,"conservative_all_fluid_estimated_peak_ram_bytes":estimate['estimated_peak_ram_bytes'],"admission_basis":"all-fluid M estimate before authoritative loader; tight mask estimate also passed"}
|
||||
return inp,estimate
|
||||
|
||||
def _chunks(inp:RealCCDInput,chunk:int)->Iterator[tuple[slice,np.ndarray,np.ndarray]]:
|
||||
mask=inp.analysis_mask
|
||||
for start in range(0,inp.selected_indices.size,chunk):
|
||||
stop=min(start+chunk,inp.selected_indices.size); raw=inp.dq_ctl[start:stop]
|
||||
field=np.concatenate((raw[:,0][:,mask],raw[:,1][:,mask]),axis=1).T.astype(np.float64)
|
||||
actions=inp.actions[start:stop].T.astype(np.float64)
|
||||
if not np.isfinite(field).all() or not np.isfinite(actions).all(): raise ValueError('nonfinite streamed column')
|
||||
yield slice(start,stop),field,actions
|
||||
|
||||
def _classify(s:np.ndarray,rtol:float,atol:float)->tuple[np.ndarray,list[list[int]],list[int],float]:
|
||||
tol=atol+(rtol*float(s[0]) if s.size else 0.0); identifiable=s>tol; blocks=[]; boundaries=[]; stop=int(identifiable.sum()); start=0
|
||||
while start<stop:
|
||||
end=start+1
|
||||
while end<stop and abs(float(s[end]-s[start]))<=atol+rtol*max(float(s[start]),float(s[end])): end+=1
|
||||
if end-start>1: blocks.append([start,end])
|
||||
boundaries.append(end); start=end
|
||||
return identifiable,blocks,boundaries,tol
|
||||
|
||||
def decompose_streaming(inp:RealCCDInput,*,streaming_config:StreamingConfig,memory_estimate:dict[str,Any]|None=None)->RealCCDResult:
|
||||
if inp.actions.shape!=(inp.selected_indices.size,3): raise ValueError('exactly three action channels required')
|
||||
n=inp.selected_indices.size
|
||||
for name, values in (("selected indices", inp.selected_indices), ("selected relative timestamps", inp.selected_relative_steps), ("q_ctl absolute timestamps", inp.q_ctl_absolute_steps)):
|
||||
if values.dtype != np.int64 or values.shape != (n,) or np.any(np.diff(values) <= 0):
|
||||
raise ValueError(f'{name} must be exact strictly increasing int64 values')
|
||||
mask=inp.analysis_mask; points=int(mask.sum()); m=2*points
|
||||
estimate=memory_estimate or estimate_memory(m=m,n=n,nx=inp.x_D.size,ny=inp.y_D.size,full_count=inp.dq_ctl.shape[0],chunk_size=streaming_config.chunk_size,budget=streaming_config.budget)
|
||||
wx=coordinate_weights(inp.x_D); wy=coordinate_weights(inp.y_D); point_w=(wx[:,None]*wy[None,:])[mask]; weights=np.concatenate((point_w,point_w)); roots=np.sqrt(weights)
|
||||
fsum=np.zeros(m,np.float64); psum=np.zeros(3,np.float64); count=0
|
||||
for _,u,p in _chunks(inp,streaming_config.chunk_size): fsum+=u.sum(axis=1); psum+=p.sum(axis=1); count+=u.shape[1]
|
||||
if count!=n: raise ValueError('stream pass count mismatch')
|
||||
fmean=fsum/n; pmean=psum/n; cross=np.zeros((3,m),np.float64)
|
||||
for _,u,p in _chunks(inp,streaming_config.chunk_size): cross+=(p-pmean[:,None])@((u-fmean[:,None])*roots[:,None]).T
|
||||
cross/=n*np.sqrt(3.0)
|
||||
left,s,vh=np.linalg.svd(cross,full_matrices=False); weighted=vh.T
|
||||
for k in range(weighted.shape[1]):
|
||||
pivot=int(np.argmax(np.abs(weighted[:,k])))
|
||||
if weighted[pivot,k]<0: weighted[:,k]*=-1; left[:,k]*=-1
|
||||
modes=weighted/roots[:,None]; identifiable,blocks,boundaries,null_tol=_classify(s,streaming_config.singular_block_rtol,streaming_config.singular_block_atol)
|
||||
coeff=np.empty((3,n),np.float64); total_sq=0.0
|
||||
for sl,u,_ in _chunks(inp,streaming_config.chunk_size):
|
||||
x=(u-fmean[:,None])*roots[:,None]; coeff[:,sl]=weighted.T@x; total_sq+=float(np.sum(x*x))
|
||||
residual=np.asarray([max(total_sq-float(np.sum(coeff[:r]**2)),0.0) for r in boundaries],np.float64)
|
||||
residual=np.sqrt(residual/max(total_sq,np.finfo(float).tiny))
|
||||
arrays={"x_D":inp.x_D,"y_D":inp.y_D,"q_target_solver_fluid_mask":inp.role_masks['q_target'],"q_blk_solver_fluid_mask":inp.role_masks['q_blk'],"q_ctl_solver_fluid_mask":inp.role_masks['q_ctl'],"analysis_fluid_mask":mask,"selected_timeline_indices":inp.selected_indices,"selected_acquisition_relative_lattice_steps":inp.selected_relative_steps,"selected_q_ctl_absolute_lattice_steps":inp.q_ctl_absolute_steps,"coordinate_weights":weights,"field_mean":fmean,"action_mean":pmean,"effective_actions":inp.actions,"cross_correlation":cross,"left_functions":left,"singular_values":s,"physical_modes":modes,"coefficients":coeff,"identifiable_mode_mask":identifiable,"residual_block_boundaries":np.asarray(boundaries,np.int64),"weighted_relative_residuals":residual,"authoritative_mean_dq_ctl":inp.authoritative_mean_dq_ctl}
|
||||
input_hashes={"direct_dq":{"path":str(inp.direct_dq_root),"manifest_sha256":inp.direct_manifest_sha256},"acquisitions":{k:dict(v) for k,v in inp.acquisition_identities.items()},"canonical_arrays":{k:canonical_array_sha256(v) for k,v in arrays.items()}}
|
||||
config={"schema_id":"ccd-real-ccd-config/v1","case_id":inp.case_id,"Q":1,"tau":0,"observable_count":3,"channel_names":list(ACTION_CHANNEL_NAMES),"action_identities":list(ACTION_IDENTITIES),"action_units":ACTION_UNITS,"flatten_order":FLATTEN_ORDER,"weight_rule":WEIGHT_RULE,"center_snapshots":True,"center_observables":True,"standardization":False,"whitening":False,"chunk_size":streaming_config.chunk_size,"accumulation_dtype":"float64","input_field_dtype":"float32","singular_block_rtol":streaming_config.singular_block_rtol,"singular_block_atol":streaming_config.singular_block_atol,"memory":estimate,"full_reconstructions_persisted":False}
|
||||
summary={"schema_id":"ccd-real-ccd-summary/v1","sample_count":n,"spatial_dof_count":m,"numerical_rank":int(identifiable.sum()),"null_tolerance":null_tol,"degenerate_singular_blocks":blocks,"complete_block_boundaries":boundaries,"spectrum_label":"cross-correlation strength; not field energy, explained variance, or canonical coefficient","mean_context":"mean effective actions and authoritative mean dq_ctl are outside CCD","claim_boundary":"no CCD>POD, causal, mechanism, response-time, same-phase, independent-realization, uncertainty, or observable-prediction claim","passes":3,"provenance_status":"VERIFIED_LIVE_INPUTS_REQUIRED_ON_LOAD"}
|
||||
from .schema import validate_result
|
||||
arrays=validate_result(arrays=arrays,config=config,summary=summary,input_hashes=input_hashes)
|
||||
return RealCCDResult(arrays,config,summary,input_hashes)
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Deterministic artifact-only Karman real-CCD figures."""
|
||||
from __future__ import annotations
|
||||
import argparse,json,os,shutil,uuid
|
||||
from pathlib import Path
|
||||
from typing import Sequence
|
||||
import matplotlib
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
import numpy as np
|
||||
from CCD_analysis.acquisition.contracts import canonical_json
|
||||
from .io import load_result
|
||||
STEMS=("01_cross_correlation_spectrum","02_mean_and_physical_modes","03_action_channel_left_vectors","04_coefficient_time_traces","05_weighted_reconstruction_residual","06_snapshot_reconstructions")
|
||||
def _components(v,mask):
|
||||
n=int(mask.sum()); result=[]
|
||||
for part in (v[:n],v[n:]):
|
||||
field=np.full(mask.shape,np.nan); field[mask]=part; result.append(field)
|
||||
return result
|
||||
def _limit(fields):
|
||||
v=np.concatenate([np.abs(f[np.isfinite(f)]) for f in fields]); return float(np.percentile(v,99)) or 1.
|
||||
def _panel(ax,field,x,y,mask,limit,title):
|
||||
image=ax.pcolormesh(x,y,field.T,shading="nearest",cmap="RdBu_r",vmin=-limit,vmax=limit,rasterized=True)
|
||||
solid=np.ma.masked_where(mask,np.ones(mask.shape)); ax.pcolormesh(x,y,solid.T,shading="nearest",cmap="Greys",vmin=0,vmax=1)
|
||||
ax.set(title=title,xlabel="x/D",ylabel="y/D"); ax.set_aspect("equal"); return image
|
||||
def _save(fig,root,stem):
|
||||
names=[]
|
||||
for ext in ("png","pdf"):
|
||||
p=root/f"{stem}.{ext}"; fig.savefig(p,dpi=300 if ext=="png" else None,bbox_inches="tight",metadata={"Creator":"CCD_analysis.real_ccd.figures"}); names.append(p.name)
|
||||
plt.close(fig); return names
|
||||
def _metrics(mode,mask,x,y,w):
|
||||
n=int(mask.sum()); pw=w[:n]; e=(mode[:n]**2+mode[n:]**2)*pw; xx,yy=np.meshgrid(x,y,indexing="ij"); ux,uy=_components(mode,mask)
|
||||
def mm(a,sign):
|
||||
b=sign*np.flip(a,axis=1); valid=np.isfinite(a)&np.isfinite(b); return float(np.linalg.norm((a-b)[valid])/np.linalg.norm(a[valid]))
|
||||
return {"weighted_ux_norm":float(np.sqrt(np.sum(mode[:n]**2*pw))),"weighted_uy_norm":float(np.sqrt(np.sum(mode[n:]**2*pw))),"energy_centroid_x_D":float(np.sum(xx[mask]*e)/e.sum()),"energy_centroid_y_D":float(np.sum(yy[mask]*e)/e.sum()),"ux_reflection_even_mismatch":mm(ux,1),"uy_reflection_odd_mismatch":mm(uy,-1)}
|
||||
def publish_karman_figures(result_root:str|Path,output:str|Path,*,snapshot_indices:Sequence[int]|None=None)->Path:
|
||||
destination=Path(output)
|
||||
if destination.exists(): raise FileExistsError(destination)
|
||||
partial=destination.with_name(f".{destination.name}.partial.{os.getpid()}.{uuid.uuid4().hex}"); partial.mkdir(parents=True)
|
||||
try:
|
||||
loaded=load_result(result_root,include_centered_snapshots=True); a=loaded["arrays"]; cfg=loaded["config"]
|
||||
if cfg["case_id"]!="karman_re100": raise ValueError("Karman-only publication requires karman_re100")
|
||||
x,y,mask=a["x_D"],a["y_D"],a["analysis_fluid_mask"]; modes,coef,steps=a["physical_modes"],a["coefficients"],a["selected_acquisition_relative_lattice_steps"]; snapshots=loaded["centered_snapshots"]
|
||||
selected=tuple(snapshot_indices or (0,len(steps)//2,len(steps)-1))
|
||||
if not selected or len(set(selected))!=len(selected) or min(selected)<0 or max(selected)>=len(steps): raise ValueError("invalid snapshot indices")
|
||||
files=[]; sigma=a["singular_values"]; ids=np.arange(1,4)
|
||||
fig,axs=plt.subplots(1,2,figsize=(8,3.2),layout="constrained"); axs[0].bar(ids,sigma); axs[1].bar(ids,sigma**2,color="#D95F02"); axs[0].set_ylabel(r"$\sigma_j$ (cross-correlation strength)"); axs[1].set_ylabel(r"$\sigma_j^2$ (squared cross-correlation strength)")
|
||||
for ax in axs: ax.set(xlabel="CCD mode j",xticks=ids); ax.grid(axis="y",alpha=.25)
|
||||
fig.suptitle("Karman Q=1, tau=0 spectrum (not field energy or explained variance)"); files+=_save(fig,partial,STEMS[0])
|
||||
mean=[a["authoritative_mean_dq_ctl"][i].astype(float) for i in range(2)]; fields=[_components(modes[:,j],mask) for j in range(3)]; limits=(_limit(mean),_limit([f[0] for f in fields]),_limit([f[1] for f in fields]))
|
||||
fig,axs=plt.subplots(4,2,figsize=(12,8.5),sharex=True,sharey=True,layout="constrained")
|
||||
for c in range(2): _panel(axs[0,c],mean[c],x,y,mask,limits[0],f"authoritative mean dq_ctl {'ux' if c==0 else 'uy'}")
|
||||
for j in range(3):
|
||||
for c in range(2): _panel(axs[j+1,c],fields[j][c],x,y,mask,limits[c+1],f"mode {j+1} {'ux' if c==0 else 'uy'}")
|
||||
fig.suptitle("Full-resolution physical fields; black is solver non-fluid mask/geometry"); files+=_save(fig,partial,STEMS[1])
|
||||
fig,axs=plt.subplots(1,3,figsize=(9,3.2),sharey=True,layout="constrained")
|
||||
for j,ax in enumerate(axs): ax.bar(cfg["channel_names"],a["left_functions"][:,j]); ax.axhline(0,color="black",lw=.7); ax.set_title(f"mode {j+1}, zero lag"); ax.tick_params(axis="x",rotation=25)
|
||||
axs[0].set_ylabel("left-vector component"); fig.suptitle("Q=1 action-channel left vectors (not lag curves)"); files+=_save(fig,partial,STEMS[2])
|
||||
fig,ax=plt.subplots(figsize=(8,4),layout="constrained")
|
||||
for j in range(3): ax.plot(steps,coef[j],label=f"mode {j+1}")
|
||||
ax.set(xlabel="acquisition-relative lattice step",ylabel="physical-amplitude coefficient"); ax.legend(); ax.grid(alpha=.25); files+=_save(fig,partial,STEMS[3])
|
||||
fig,ax=plt.subplots(figsize=(5.5,3.6),layout="constrained"); boundaries=a["residual_block_boundaries"]; residuals=a["weighted_relative_residuals"]; ax.plot(boundaries,residuals,"o-"); ax.set(xlabel="complete retained mode block boundary",ylabel="weighted relative reconstruction residual",xticks=boundaries,ylim=(0,1)); ax.grid(alpha=.25); files+=_save(fig,partial,STEMS[4])
|
||||
snapshot_limits=[]
|
||||
for index in selected:
|
||||
truth=snapshots[:,index]; vectors=[truth]
|
||||
for rank in (1,2,3):
|
||||
recon=modes[:,:rank]@coef[:rank,index]; vectors.extend((recon,truth-recon))
|
||||
ff=[_components(v,mask) for v in vectors]; limit=_limit([z for pair in ff for z in pair]); snapshot_limits.append(limit)
|
||||
fig,axs=plt.subplots(2,7,figsize=(18,5.5),sharex=True,sharey=True,layout="constrained")
|
||||
labels=["centered snapshot"]
|
||||
for rank in (1,2,3): labels.extend((f"rank {rank} reconstruction",f"rank {rank} residual"))
|
||||
for col,(pair,label) in enumerate(zip(ff,labels)):
|
||||
for component in range(2): _panel(axs[component,col],pair[component],x,y,mask,limit,f"{label} {'ux' if component==0 else 'uy'}")
|
||||
stem=f"{STEMS[5]}_step{int(steps[index])}"; fig.suptitle(f"On-demand centered dq_ctl reconstruction at acquisition-relative step {int(steps[index])}"); files+=_save(fig,partial,stem)
|
||||
metrics=[_metrics(modes[:,j],mask,x,y,a["coordinate_weights"]) for j in range(3)]; centered=a["effective_actions"].astype(float)-a["action_mean"]; correlations=[{cfg["channel_names"][k]:float(np.corrcoef(coef[j],centered[:,k])[0,1]) for k in range(3)} for j in range(3)]
|
||||
report={"schema_id":"ccd-real-ccd-karman-figures/v1","case_id":"karman_re100","source_result":str(Path(result_root).resolve()),"source_provenance":loaded["provenance_validation"],"grid_dimensions":{"nx":int(x.size),"ny":int(y.size),"mask_points":int(mask.sum()),"spatial_dof":int(modes.shape[0]),"downsampling":"none"},"figure_files":files,"snapshot_indices":list(selected),"snapshot_acquisition_relative_lattice_steps":[int(steps[i]) for i in selected],"snapshot_common_robust_limits":snapshot_limits,"robust_scale":"symmetric 99th percentile absolute value over each comparable set","singular_values":sigma.tolist(),"squared_cross_correlation_strengths":(sigma**2).tolist(),"action_means":a["action_mean"].tolist(),"mode_metrics":metrics,"coefficient_action_pearson_correlations":correlations,"residual_block_boundaries":boundaries.tolist(),"weighted_relative_reconstruction_residuals":residuals.tolist(),"claim_boundary":loaded["summary"]["claim_boundary"],"full_reconstructions_persisted":False}
|
||||
(partial/"KARMAN_INTERPRETATION.json").write_bytes(canonical_json(report)); lines=["# Karman real-CCD figure interpretation","",f"Verified source: `{report['source_result']}`.","",f"Mean actions (front/upper/lower, native units): `{report['action_means']}`. The authoritative mean dq_ctl is context outside centered CCD.",""]
|
||||
for j,m in enumerate(metrics): lines.append(f"- Mode {j+1}: weighted ux/uy norms {m['weighted_ux_norm']:.6g}/{m['weighted_uy_norm']:.6g}; squared-amplitude centroid (x/D,y/D)=({m['energy_centroid_x_D']:.4f},{m['energy_centroid_y_D']:.4f}); ux-even/uy-odd reflection mismatches {m['ux_reflection_even_mismatch']:.4f}/{m['uy_reflection_odd_mismatch']:.4f}; coefficient/action Pearson correlations `{correlations[j]}`.")
|
||||
lines += ["",f"Selected acquisition-relative lattice steps: `{report['snapshot_acquisition_relative_lattice_steps']}`. Views are generated on demand; no full MxN reconstruction is persisted.","",f"Complete-block weighted relative residuals: `{report['weighted_relative_reconstruction_residuals']}`. This is the weighted norm of centered field content outside the retained modal projection divided by the weighted centered-field norm; it is not unexplained variance or q_target error.","",f"Claim boundary: {report['claim_boundary']}.",""]; (partial/"KARMAN_INTERPRETATION.md").write_text("\n".join(lines),encoding="ascii")
|
||||
os.rename(partial,destination); return destination
|
||||
except Exception:
|
||||
shutil.rmtree(partial,ignore_errors=True); raise
|
||||
def main(argv:Sequence[str]|None=None)->int:
|
||||
parser=argparse.ArgumentParser(); parser.add_argument("--result-root",required=True,type=Path); parser.add_argument("--output",required=True,type=Path); parser.add_argument("--snapshot-index",action="append",type=int); args=parser.parse_args(argv); out=publish_karman_figures(args.result_root,args.output,snapshot_indices=args.snapshot_index); print(json.dumps({"published":str(out.resolve())})); return 0
|
||||
if __name__=="__main__": raise SystemExit(main())
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Immutable real-CCD transaction and mandatory verified loader."""
|
||||
from __future__ import annotations
|
||||
import json,os,shutil,uuid
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
import numpy as np
|
||||
from CCD_analysis.acquisition.artifacts import file_sha256,rename_noreplace
|
||||
from CCD_analysis.acquisition.contracts import canonical_json
|
||||
from CCD_analysis.direct_dq.io import load_result as load_direct_dq_result
|
||||
from .core import RealCCDResult
|
||||
from .schema import RESULT_SCHEMA_ID,validate_result
|
||||
RESULT_FILES={"arrays.npz","config.json","summary.json","input_hashes.json"}
|
||||
def _read(path:Path):
|
||||
raw=path.read_bytes(); value=json.loads(raw)
|
||||
if not isinstance(value,dict) or raw!=canonical_json(value): raise ValueError(f"canonical JSON object required: {path}")
|
||||
return value
|
||||
def _fsync(path:Path):
|
||||
with path.open('rb') as f: os.fsync(f.fileno())
|
||||
class ResultTransaction:
|
||||
def __init__(self,destination:str|Path):
|
||||
self.destination=Path(destination); self.partial=self.destination.with_name(f'.{self.destination.name}.partial.{os.getpid()}.{uuid.uuid4().hex}'); self.active=False
|
||||
def __enter__(self):
|
||||
if self.destination.exists(): raise FileExistsError(self.destination)
|
||||
self.destination.parent.mkdir(parents=True,exist_ok=True); self.partial.mkdir(); self.active=True; return self
|
||||
def write(self,result:RealCCDResult):
|
||||
if not self.active: raise RuntimeError('transaction inactive')
|
||||
arrays=validate_result(arrays=result.arrays,config=result.config,summary=result.summary,input_hashes=result.input_hashes)
|
||||
np.savez_compressed(self.partial/'arrays.npz',**arrays)
|
||||
for name,value in (("config.json",result.config),("summary.json",result.summary),("input_hashes.json",result.input_hashes)): (self.partial/name).write_bytes(canonical_json(value))
|
||||
for p in self.partial.iterdir(): _fsync(p)
|
||||
files={p.name:file_sha256(p) for p in sorted(self.partial.iterdir())}; (self.partial/'manifest.json').write_bytes(canonical_json({"schema_id":RESULT_SCHEMA_ID,"complete":True,"files":files})); _fsync(self.partial/'manifest.json')
|
||||
def publish(self):
|
||||
load_result(self.partial); rename_noreplace(self.partial,self.destination); fd=os.open(self.destination.parent,os.O_RDONLY)
|
||||
try: os.fsync(fd)
|
||||
finally: os.close(fd)
|
||||
self.active=False; load_result(self.destination); return self.destination
|
||||
def __exit__(self,*args):
|
||||
if self.active: shutil.rmtree(self.partial,ignore_errors=True); self.active=False
|
||||
def load_result(path:str|Path, *, include_centered_snapshots:bool=False)->dict[str,Any]:
|
||||
root=Path(path); manifest=_read(root/'manifest.json')
|
||||
if set(manifest)!={"schema_id","complete","files"} or manifest.get('schema_id')!=RESULT_SCHEMA_ID or manifest.get('complete') is not True or set(manifest.get('files',{}))!=RESULT_FILES: raise ValueError('real-CCD manifest schema/inventory is not exact')
|
||||
if {p.name for p in root.iterdir() if p.is_file()}!=RESULT_FILES|{"manifest.json"}: raise ValueError('real-CCD file inventory is not exact')
|
||||
for name,digest in manifest['files'].items():
|
||||
if file_sha256(root/name)!=digest: raise ValueError(f'real-CCD file hash mismatch: {name}')
|
||||
with np.load(root/'arrays.npz',allow_pickle=False) as z: arrays={k:z[k].copy() for k in z.files}
|
||||
config,summary,input_hashes=(_read(root/n) for n in ('config.json','summary.json','input_hashes.json'))
|
||||
arrays=validate_result(arrays=arrays,config=config,summary=summary,input_hashes=input_hashes)
|
||||
direct=load_direct_dq_result(input_hashes['direct_dq']['path'])
|
||||
if file_sha256(Path(input_hashes['direct_dq']['path'])/'manifest.json')!=input_hashes['direct_dq']['manifest_sha256']: raise ValueError('live direct-dq identity changed')
|
||||
da=direct['arrays']; idx=arrays['selected_timeline_indices']
|
||||
if not np.array_equal(da['selected_timeline_indices'],idx) or not np.array_equal(da['selected_acquisition_relative_lattice_steps'],arrays['selected_acquisition_relative_lattice_steps']) or not np.array_equal(da['analysis_fluid_mask'],arrays['analysis_fluid_mask']): raise ValueError('live direct-dq selection/mask differs')
|
||||
qctl_path=input_hashes['acquisitions']['q_ctl']['path']; from CCD_analysis.direct_dq.io import load_acquisition_artifact
|
||||
qctl=load_acquisition_artifact(qctl_path,expected_case=config['case_id'],expected_role='q_ctl')
|
||||
if qctl.input_identity!=input_hashes['acquisitions']['q_ctl'] or not np.array_equal(qctl.fields['effective_applied_action'][idx,-3:],arrays['effective_actions']): raise ValueError('live q_ctl effective-action provenance differs')
|
||||
if not np.array_equal(qctl.fields['lattice_steps'][idx], arrays['selected_q_ctl_absolute_lattice_steps']): raise ValueError('live q_ctl absolute timestamp provenance differs')
|
||||
from CCD_analysis.direct_dq.analysis import coordinate_weights
|
||||
mask=arrays['analysis_fluid_mask']; point_w=(coordinate_weights(arrays['x_D'])[:,None]*coordinate_weights(arrays['y_D'])[None,:])[mask]; w=np.concatenate((point_w,point_w))
|
||||
np.testing.assert_array_equal(w,arrays['coordinate_weights'])
|
||||
dq=da['q_ctl_instantaneous']-da['q_blk_instantaneous']; u=np.concatenate((dq[:,0][:,mask],dq[:,1][:,mask]),axis=1).T.astype(np.float64)
|
||||
p=arrays['effective_actions'].T.astype(np.float64); umean=u.mean(axis=1); pmean=p.mean(axis=1)
|
||||
np.testing.assert_allclose(arrays['field_mean'],umean,rtol=0,atol=0); np.testing.assert_allclose(arrays['action_mean'],pmean,rtol=0,atol=0)
|
||||
x=(u-umean[:,None])*np.sqrt(w)[:,None]; cross=(p-pmean[:,None])@x.T/(u.shape[1]*np.sqrt(3.0))
|
||||
np.testing.assert_allclose(arrays['cross_correlation'],cross,rtol=1e-12,atol=1e-12)
|
||||
modes=arrays['physical_modes']; weighted=modes*np.sqrt(w)[:,None]
|
||||
np.testing.assert_allclose(weighted.T@weighted,np.eye(3),rtol=1e-10,atol=1e-10)
|
||||
np.testing.assert_allclose(arrays['cross_correlation'],arrays['left_functions']@np.diag(arrays['singular_values'])@weighted.T,rtol=1e-10,atol=1e-10)
|
||||
np.testing.assert_allclose(arrays['coefficients'],weighted.T@x,rtol=1e-10,atol=1e-10)
|
||||
loaded={"arrays":arrays,"config":config,"summary":summary,"input_hashes":input_hashes,"manifest":manifest,"provenance_validation":"VERIFIED: direct-dq and live acquisition inputs reread and essential identities recomputed"}
|
||||
if include_centered_snapshots:
|
||||
loaded["centered_snapshots"]=(u-umean[:,None])
|
||||
return loaded
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Read-only real-artifact admission report for real-case CCD."""
|
||||
from __future__ import annotations
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
import numpy as np
|
||||
from .core import MemoryBudget, StreamingConfig, load_validated_input
|
||||
|
||||
GIB = 1024 ** 3
|
||||
|
||||
def available_host_memory_bytes() -> int:
|
||||
values = {}
|
||||
for line in Path("/proc/meminfo").read_text().splitlines():
|
||||
key, value = line.split(":", 1)
|
||||
values[key] = int(value.strip().split()[0]) * 1024
|
||||
available = values.get("MemAvailable", 0)
|
||||
if available <= 0:
|
||||
raise RuntimeError("positive /proc/meminfo MemAvailable is required")
|
||||
return available
|
||||
|
||||
def safe_host_budget(*, available_bytes: int | None = None) -> dict[str, int]:
|
||||
available = available_host_memory_bytes() if available_bytes is None else available_bytes
|
||||
if type(available) is not int or available <= 0:
|
||||
raise ValueError("available_bytes must be a positive integer")
|
||||
reserve = max(8 * GIB, int(np.ceil(available * 0.25)))
|
||||
budget = available - reserve
|
||||
if budget <= 0:
|
||||
raise MemoryError("available host RAM does not exceed the mandatory safety reserve")
|
||||
return {"available_host_ram_bytes": available, "safety_reserve_bytes": reserve, "ram_budget_bytes": budget}
|
||||
|
||||
def preflight_real_artifact(path: str | Path, *, chunk_size: int = 8, available_bytes: int | None = None) -> dict[str, Any]:
|
||||
"""Validate all live provenance and return an in-memory report; write nothing."""
|
||||
host = safe_host_budget(available_bytes=available_bytes)
|
||||
cfg = StreamingConfig(chunk_size, MemoryBudget(host["ram_budget_bytes"], 0, 1.25))
|
||||
inp, memory = load_validated_input(path, streaming_config=cfg)
|
||||
action_ranges = {name: [float(inp.actions[:, i].min()), float(inp.actions[:, i].max())] for i, name in enumerate(("front", "upper", "lower"))}
|
||||
return {"schema_id": "ccd-real-ccd-preflight/v1", "decision": "PASS", "read_only": True, "case_id": inp.case_id, "direct_dq_root": str(inp.direct_dq_root), "direct_manifest_sha256": inp.direct_manifest_sha256, "live_acquisition_roots": {role: identity["path"] for role, identity in inp.acquisition_identities.items()}, "dimensions": {"N": int(inp.selected_indices.size), "Nx": int(inp.x_D.size), "Ny": int(inp.y_D.size), "analysis_fluid_points": int(inp.analysis_mask.sum()), "M": 2 * int(inp.analysis_mask.sum()), "Q": 1, "L": 3}, "selection": {"first_index": int(inp.selected_indices[0]), "last_index": int(inp.selected_indices[-1]), "first_relative_step": int(inp.selected_relative_steps[0]), "last_relative_step": int(inp.selected_relative_steps[-1]), "first_q_ctl_absolute_step": int(inp.q_ctl_absolute_steps[0]), "last_q_ctl_absolute_step": int(inp.q_ctl_absolute_steps[-1])}, "actions": {"identities": ["front_ccw_positive", "upper_ccw_positive", "lower_ccw_positive"], "channel_order": ["front", "upper", "lower"], "units": "native solver angular-velocity command units", "ranges": action_ranges}, "host_memory": host, "memory": memory, "scratch_note": "No disk scratch or result directory is used by preflight."}
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Strict immutable schema for real-case streaming CCD."""
|
||||
from __future__ import annotations
|
||||
from typing import Any,Mapping
|
||||
import numpy as np
|
||||
from CCD_analysis.acquisition.contracts import ACTION_IDENTITIES,CASES,canonical_json
|
||||
from CCD_analysis.direct_dq.schema import canonical_array_sha256
|
||||
RESULT_SCHEMA_ID='ccd-real-ccd-result/v1'
|
||||
ARRAY_KEYS={"x_D","y_D","q_target_solver_fluid_mask","q_blk_solver_fluid_mask","q_ctl_solver_fluid_mask","analysis_fluid_mask","selected_timeline_indices","selected_acquisition_relative_lattice_steps","selected_q_ctl_absolute_lattice_steps","coordinate_weights","field_mean","action_mean","effective_actions","cross_correlation","left_functions","singular_values","physical_modes","coefficients","identifiable_mode_mask","residual_block_boundaries","weighted_relative_residuals","authoritative_mean_dq_ctl"}
|
||||
CONFIG_KEYS={"schema_id","case_id","Q","tau","observable_count","channel_names","action_identities","action_units","flatten_order","weight_rule","center_snapshots","center_observables","standardization","whitening","chunk_size","accumulation_dtype","input_field_dtype","singular_block_rtol","singular_block_atol","memory","full_reconstructions_persisted"}
|
||||
SUMMARY_KEYS={"schema_id","sample_count","spatial_dof_count","numerical_rank","null_tolerance","degenerate_singular_blocks","complete_block_boundaries","spectrum_label","mean_context","claim_boundary","passes","provenance_status"}
|
||||
def validate_result(*,arrays:Mapping[str,Any],config:Mapping[str,Any],summary:Mapping[str,Any],input_hashes:Mapping[str,Any])->dict[str,np.ndarray]:
|
||||
if set(config)!=CONFIG_KEYS or config.get('schema_id')!='ccd-real-ccd-config/v1' or config.get('case_id') not in CASES: raise ValueError('real-CCD config schema is not exact')
|
||||
fixed=(config['Q']==1 and config['tau']==0 and config['observable_count']==3 and config['channel_names']==['front','upper','lower'] and config['action_identities']==list(ACTION_IDENTITIES) and config['action_units']=='native solver angular-velocity command units' and config['flatten_order']=='component-major ux then uy; C-order analysis-mask point order' and config['weight_rule']=='direct_dq.coordinate_weights(x_D)*coordinate_weights(y_D), repeated ux then uy; not area-normalized' and config['center_snapshots'] is True and config['center_observables'] is True and config['standardization'] is False and config['whitening'] is False and config['full_reconstructions_persisted'] is False and config['accumulation_dtype']=='float64')
|
||||
if not fixed: raise ValueError('frozen real-CCD estimand/config contradicted')
|
||||
mem=config['memory']
|
||||
if not isinstance(mem,Mapping) or mem.get('decision')!='PASS' or mem.get('estimated_peak_ram_bytes',1)>mem.get('ram_budget_bytes',0) or mem.get('estimated_scratch_bytes',1)>mem.get('scratch_budget_bytes',0): raise ValueError('memory decision must pass explicit budgets')
|
||||
frozen_summary = {
|
||||
'spectrum_label': 'cross-correlation strength; not field energy, explained variance, or canonical coefficient',
|
||||
'mean_context': 'mean effective actions and authoritative mean dq_ctl are outside CCD',
|
||||
'claim_boundary': 'no CCD>POD, causal, mechanism, response-time, same-phase, independent-realization, uncertainty, or observable-prediction claim',
|
||||
'provenance_status': 'VERIFIED_LIVE_INPUTS_REQUIRED_ON_LOAD',
|
||||
}
|
||||
if set(summary)!=SUMMARY_KEYS or summary.get('schema_id')!='ccd-real-ccd-summary/v1' or summary.get('passes')!=3 or any(summary.get(k) != v for k, v in frozen_summary.items()): raise ValueError('real-CCD summary schema/claims are not exact')
|
||||
if set(arrays)!=ARRAY_KEYS: raise ValueError('real-CCD array inventory is not exact')
|
||||
d={k:np.asarray(v) for k,v in arrays.items()}; n=summary['sample_count']; m=summary['spatial_dof_count']
|
||||
if type(n) is not int or type(m) is not int or n<1 or m<1: raise ValueError('invalid result dimensions')
|
||||
if d['selected_timeline_indices'].dtype!=np.int64 or d['selected_timeline_indices'].shape!=(n,) or np.any(np.diff(d['selected_timeline_indices'])<=0): raise ValueError('selected indices invalid')
|
||||
for key in ('selected_acquisition_relative_lattice_steps','selected_q_ctl_absolute_lattice_steps'):
|
||||
if d[key].dtype!=np.int64 or d[key].shape!=(n,) or np.any(np.diff(d[key])<=0): raise ValueError(f'{key} invalid')
|
||||
mask=d['analysis_fluid_mask']; nx=d['x_D'].size; ny=d['y_D'].size
|
||||
masks=[]
|
||||
for role in ('q_target','q_blk','q_ctl'):
|
||||
v=d[f'{role}_solver_fluid_mask']; masks.append(v)
|
||||
if v.dtype!=np.bool_ or v.shape!=(nx,ny): raise ValueError('role mask invalid')
|
||||
if mask.dtype!=np.bool_ or mask.shape!=(nx,ny) or not np.array_equal(mask,masks[0]&masks[1]&masks[2]) or m!=2*int(mask.sum()): raise ValueError('analysis mask/flatten dimension invalid')
|
||||
shapes={"coordinate_weights":(m,),"field_mean":(m,),"action_mean":(3,),"effective_actions":(n,3),"cross_correlation":(3,m),"left_functions":(3,3),"singular_values":(3,),"physical_modes":(m,3),"coefficients":(3,n),"identifiable_mode_mask":(3,)}
|
||||
for k,s in shapes.items():
|
||||
if d[k].shape!=s or (d[k].dtype.kind in 'fc' and not np.isfinite(d[k]).all()): raise ValueError(f'{k} invalid')
|
||||
if d['coordinate_weights'].dtype!=np.float64 or np.any(d['coordinate_weights']<=0) or d['field_mean'].dtype!=np.float64 or d['action_mean'].dtype!=np.float64 or d['effective_actions'].dtype!=np.float32: raise ValueError('canonical dtypes invalid')
|
||||
if d['authoritative_mean_dq_ctl'].dtype!=np.float32 or d['authoritative_mean_dq_ctl'].shape!=(2,nx,ny) or not np.isfinite(d['authoritative_mean_dq_ctl']).all(): raise ValueError('authoritative mean dq_ctl invalid')
|
||||
if d['residual_block_boundaries'].dtype!=np.int64 or d['weighted_relative_residuals'].dtype!=np.float64 or d['residual_block_boundaries'].shape!=d['weighted_relative_residuals'].shape or np.any(np.diff(d['residual_block_boundaries'])<=0): raise ValueError('block residual schema invalid')
|
||||
if summary['complete_block_boundaries']!=d['residual_block_boundaries'].tolist() or summary['numerical_rank']!=int(d['identifiable_mode_mask'].sum()): raise ValueError('summary rank/block contradiction')
|
||||
if set(input_hashes)!={'direct_dq','acquisitions','canonical_arrays'} or set(input_hashes['acquisitions'])!={'q_target','q_blk','q_ctl'} or set(input_hashes['canonical_arrays'])!=ARRAY_KEYS: raise ValueError('input/hash schema invalid')
|
||||
if any(input_hashes['canonical_arrays'][k]!=canonical_array_sha256(v) for k,v in d.items()): raise ValueError('canonical array hash mismatch')
|
||||
canonical_json(config); canonical_json(summary); canonical_json(input_hashes)
|
||||
return d
|
||||
@@ -1,731 +0,0 @@
|
||||
{
|
||||
"illusion_0.75L_dqctl_force_fy_r6": {
|
||||
"scene": "illusion_0.75L",
|
||||
"diam": 0.75,
|
||||
"obs": "force_fy",
|
||||
"r": 6,
|
||||
"m80": 2,
|
||||
"N": 6,
|
||||
"sigma_top3": [
|
||||
0.9363693259668311,
|
||||
0.5427124016524685,
|
||||
0.02867293614015484
|
||||
],
|
||||
"special_mechanism": false
|
||||
},
|
||||
"illusion_0.75L_dqctl_force_fx_r6": {
|
||||
"scene": "illusion_0.75L",
|
||||
"diam": 0.75,
|
||||
"obs": "force_fx",
|
||||
"r": 6,
|
||||
"m80": 2,
|
||||
"N": 6,
|
||||
"sigma_top3": [
|
||||
0.8887476512241682,
|
||||
0.7024685939692135,
|
||||
0.17373174245109088
|
||||
],
|
||||
"special_mechanism": false
|
||||
},
|
||||
"illusion_0.75L_dqctl_action_r6": {
|
||||
"scene": "illusion_0.75L",
|
||||
"diam": 0.75,
|
||||
"obs": "action",
|
||||
"r": 6,
|
||||
"m80": 2,
|
||||
"N": 6,
|
||||
"sigma_top3": [
|
||||
1.4929827205494475,
|
||||
1.0564978623336443,
|
||||
0.3536240923864859
|
||||
],
|
||||
"special_mechanism": false
|
||||
},
|
||||
"illusion_0.75L_dqtar_force_fy_r6": {
|
||||
"scene": "illusion_0.75L",
|
||||
"diam": 0.75,
|
||||
"obs": "force_fy_tar",
|
||||
"r": 6,
|
||||
"m80": 2,
|
||||
"N": 6,
|
||||
"sigma_top3": [
|
||||
0.9557871261173136,
|
||||
0.5104093433240032,
|
||||
0.00272381506789553
|
||||
],
|
||||
"special_mechanism": false
|
||||
},
|
||||
"illusion_0.75L_O_dqctl_vs_dqtar_r6_mode1": {
|
||||
"overlap": 0.38274004938666406,
|
||||
"mode": 1,
|
||||
"r": 6
|
||||
},
|
||||
"illusion_0.75L_O_dqctl_vs_dqtar_r6_mode2": {
|
||||
"overlap": 0.3742735673782987,
|
||||
"mode": 2,
|
||||
"r": 6
|
||||
},
|
||||
"illusion_0.75L_O_dqctl_vs_dqtar_r6_mode3": {
|
||||
"overlap": 0.4160221885366777,
|
||||
"mode": 3,
|
||||
"r": 6
|
||||
},
|
||||
"illusion_0.75L_O_dqctl_vs_dqtar_r6_mode4": {
|
||||
"overlap": 0.7933885479724803,
|
||||
"mode": 4,
|
||||
"r": 6
|
||||
},
|
||||
"illusion_0.75L_O_dqctl_vs_dqtar_r6_mode5": {
|
||||
"overlap": 0.6200548221579631,
|
||||
"mode": 5,
|
||||
"r": 6
|
||||
},
|
||||
"illusion_0.75L_dqctl_force_fy_r8": {
|
||||
"scene": "illusion_0.75L",
|
||||
"diam": 0.75,
|
||||
"obs": "force_fy",
|
||||
"r": 8,
|
||||
"m80": 2,
|
||||
"N": 7,
|
||||
"sigma_top3": [
|
||||
1.1478269894791648,
|
||||
0.652863182963739,
|
||||
0.028786077604042823
|
||||
],
|
||||
"special_mechanism": false
|
||||
},
|
||||
"illusion_0.75L_dqctl_force_fx_r8": {
|
||||
"scene": "illusion_0.75L",
|
||||
"diam": 0.75,
|
||||
"obs": "force_fx",
|
||||
"r": 8,
|
||||
"m80": 2,
|
||||
"N": 7,
|
||||
"sigma_top3": [
|
||||
0.9786149641068052,
|
||||
0.7691113807859654,
|
||||
0.188609876636516
|
||||
],
|
||||
"special_mechanism": false
|
||||
},
|
||||
"illusion_0.75L_dqctl_action_r8": {
|
||||
"scene": "illusion_0.75L",
|
||||
"diam": 0.75,
|
||||
"obs": "action",
|
||||
"r": 8,
|
||||
"m80": 2,
|
||||
"N": 8,
|
||||
"sigma_top3": [
|
||||
1.8212349775439216,
|
||||
1.2876771686498985,
|
||||
0.36519455351462626
|
||||
],
|
||||
"special_mechanism": false
|
||||
},
|
||||
"illusion_0.75L_dqtar_force_fy_r8": {
|
||||
"scene": "illusion_0.75L",
|
||||
"diam": 0.75,
|
||||
"obs": "force_fy_tar",
|
||||
"r": 8,
|
||||
"m80": 2,
|
||||
"N": 7,
|
||||
"sigma_top3": [
|
||||
0.9559584636516365,
|
||||
0.5114433620666231,
|
||||
0.020016175925514433
|
||||
],
|
||||
"special_mechanism": false
|
||||
},
|
||||
"illusion_0.75L_O_dqctl_vs_dqtar_r8_mode1": {
|
||||
"overlap": 0.32804391166994556,
|
||||
"mode": 1,
|
||||
"r": 8
|
||||
},
|
||||
"illusion_0.75L_O_dqctl_vs_dqtar_r8_mode2": {
|
||||
"overlap": 0.3279264837693497,
|
||||
"mode": 2,
|
||||
"r": 8
|
||||
},
|
||||
"illusion_0.75L_O_dqctl_vs_dqtar_r8_mode3": {
|
||||
"overlap": 0.04479564315361202,
|
||||
"mode": 3,
|
||||
"r": 8
|
||||
},
|
||||
"illusion_0.75L_O_dqctl_vs_dqtar_r8_mode4": {
|
||||
"overlap": 0.345183841487867,
|
||||
"mode": 4,
|
||||
"r": 8
|
||||
},
|
||||
"illusion_0.75L_O_dqctl_vs_dqtar_r8_mode5": {
|
||||
"overlap": 0.4363341302545839,
|
||||
"mode": 5,
|
||||
"r": 8
|
||||
},
|
||||
"illusion_0.75L_dqctl_force_fy_r10": {
|
||||
"scene": "illusion_0.75L",
|
||||
"diam": 0.75,
|
||||
"obs": "force_fy",
|
||||
"r": 10,
|
||||
"m80": 2,
|
||||
"N": 7,
|
||||
"sigma_top3": [
|
||||
1.2041535893498374,
|
||||
0.6687434874320507,
|
||||
0.033382686367867884
|
||||
],
|
||||
"special_mechanism": false
|
||||
},
|
||||
"illusion_0.75L_dqctl_force_fx_r10": {
|
||||
"scene": "illusion_0.75L",
|
||||
"diam": 0.75,
|
||||
"obs": "force_fx",
|
||||
"r": 10,
|
||||
"m80": 2,
|
||||
"N": 7,
|
||||
"sigma_top3": [
|
||||
1.021082402461413,
|
||||
0.7906946910917539,
|
||||
0.19569390445830676
|
||||
],
|
||||
"special_mechanism": false
|
||||
},
|
||||
"illusion_0.75L_dqctl_action_r10": {
|
||||
"scene": "illusion_0.75L",
|
||||
"diam": 0.75,
|
||||
"obs": "action",
|
||||
"r": 10,
|
||||
"m80": 2,
|
||||
"N": 10,
|
||||
"sigma_top3": [
|
||||
1.873772820479455,
|
||||
1.3476153959060446,
|
||||
0.3912418253445644
|
||||
],
|
||||
"special_mechanism": false
|
||||
},
|
||||
"illusion_0.75L_dqtar_force_fy_r10": {
|
||||
"scene": "illusion_0.75L",
|
||||
"diam": 0.75,
|
||||
"obs": "force_fy_tar",
|
||||
"r": 10,
|
||||
"m80": 2,
|
||||
"N": 7,
|
||||
"sigma_top3": [
|
||||
0.95651717599455,
|
||||
0.5116328905682535,
|
||||
0.02153808322188994
|
||||
],
|
||||
"special_mechanism": false
|
||||
},
|
||||
"illusion_0.75L_O_dqctl_vs_dqtar_r10_mode1": {
|
||||
"overlap": 0.3200853391492714,
|
||||
"mode": 1,
|
||||
"r": 10
|
||||
},
|
||||
"illusion_0.75L_O_dqctl_vs_dqtar_r10_mode2": {
|
||||
"overlap": 0.3169378757210263,
|
||||
"mode": 2,
|
||||
"r": 10
|
||||
},
|
||||
"illusion_0.75L_O_dqctl_vs_dqtar_r10_mode3": {
|
||||
"overlap": 0.07447905724690952,
|
||||
"mode": 3,
|
||||
"r": 10
|
||||
},
|
||||
"illusion_0.75L_O_dqctl_vs_dqtar_r10_mode4": {
|
||||
"overlap": 0.05289143799442159,
|
||||
"mode": 4,
|
||||
"r": 10
|
||||
},
|
||||
"illusion_0.75L_O_dqctl_vs_dqtar_r10_mode5": {
|
||||
"overlap": 0.3699778630857817,
|
||||
"mode": 5,
|
||||
"r": 10
|
||||
},
|
||||
"illusion_1.0L_dqctl_force_fy_r6": {
|
||||
"scene": "illusion_1.0L",
|
||||
"diam": 1.0,
|
||||
"obs": "force_fy",
|
||||
"r": 6,
|
||||
"m80": 2,
|
||||
"N": 6,
|
||||
"sigma_top3": [
|
||||
0.9133527075400956,
|
||||
0.5492417355984791,
|
||||
0.03698984539919565
|
||||
],
|
||||
"special_mechanism": false
|
||||
},
|
||||
"illusion_1.0L_dqctl_force_fx_r6": {
|
||||
"scene": "illusion_1.0L",
|
||||
"diam": 1.0,
|
||||
"obs": "force_fx",
|
||||
"r": 6,
|
||||
"m80": 2,
|
||||
"N": 6,
|
||||
"sigma_top3": [
|
||||
0.9627700852255946,
|
||||
0.575350111829231,
|
||||
0.12210932634955265
|
||||
],
|
||||
"special_mechanism": false
|
||||
},
|
||||
"illusion_1.0L_dqctl_action_r6": {
|
||||
"scene": "illusion_1.0L",
|
||||
"diam": 1.0,
|
||||
"obs": "action",
|
||||
"r": 6,
|
||||
"m80": 3,
|
||||
"N": 6,
|
||||
"sigma_top3": [
|
||||
1.1703371479902003,
|
||||
0.928425799748123,
|
||||
0.6993315776263908
|
||||
],
|
||||
"special_mechanism": false
|
||||
},
|
||||
"illusion_1.0L_dqtar_force_fy_r6": {
|
||||
"scene": "illusion_1.0L",
|
||||
"diam": 1.0,
|
||||
"obs": "force_fy_tar",
|
||||
"r": 6,
|
||||
"m80": 2,
|
||||
"N": 6,
|
||||
"sigma_top3": [
|
||||
0.9230800944823152,
|
||||
0.5409412328754315,
|
||||
0.013881195337352553
|
||||
],
|
||||
"special_mechanism": false
|
||||
},
|
||||
"illusion_1.0L_O_dqctl_vs_dqtar_r6_mode1": {
|
||||
"overlap": 0.9255017481198297,
|
||||
"mode": 1,
|
||||
"r": 6
|
||||
},
|
||||
"illusion_1.0L_O_dqctl_vs_dqtar_r6_mode2": {
|
||||
"overlap": 0.9092817930388517,
|
||||
"mode": 2,
|
||||
"r": 6
|
||||
},
|
||||
"illusion_1.0L_O_dqctl_vs_dqtar_r6_mode3": {
|
||||
"overlap": 0.5921722575222331,
|
||||
"mode": 3,
|
||||
"r": 6
|
||||
},
|
||||
"illusion_1.0L_O_dqctl_vs_dqtar_r6_mode4": {
|
||||
"overlap": 0.3695873593868685,
|
||||
"mode": 4,
|
||||
"r": 6
|
||||
},
|
||||
"illusion_1.0L_O_dqctl_vs_dqtar_r6_mode5": {
|
||||
"overlap": 0.49273985058737546,
|
||||
"mode": 5,
|
||||
"r": 6
|
||||
},
|
||||
"illusion_1.0L_dqctl_force_fy_r8": {
|
||||
"scene": "illusion_1.0L",
|
||||
"diam": 1.0,
|
||||
"obs": "force_fy",
|
||||
"r": 8,
|
||||
"m80": 2,
|
||||
"N": 7,
|
||||
"sigma_top3": [
|
||||
1.0512290431026903,
|
||||
0.6038168034405464,
|
||||
0.038604687614151825
|
||||
],
|
||||
"special_mechanism": false
|
||||
},
|
||||
"illusion_1.0L_dqctl_force_fx_r8": {
|
||||
"scene": "illusion_1.0L",
|
||||
"diam": 1.0,
|
||||
"obs": "force_fx",
|
||||
"r": 8,
|
||||
"m80": 2,
|
||||
"N": 7,
|
||||
"sigma_top3": [
|
||||
1.024212111702677,
|
||||
0.6235362523473601,
|
||||
0.1398413327047235
|
||||
],
|
||||
"special_mechanism": false
|
||||
},
|
||||
"illusion_1.0L_dqctl_action_r8": {
|
||||
"scene": "illusion_1.0L",
|
||||
"diam": 1.0,
|
||||
"obs": "action",
|
||||
"r": 8,
|
||||
"m80": 3,
|
||||
"N": 8,
|
||||
"sigma_top3": [
|
||||
1.289052813250248,
|
||||
0.9661024499957929,
|
||||
0.7306012213889583
|
||||
],
|
||||
"special_mechanism": false
|
||||
},
|
||||
"illusion_1.0L_dqtar_force_fy_r8": {
|
||||
"scene": "illusion_1.0L",
|
||||
"diam": 1.0,
|
||||
"obs": "force_fy_tar",
|
||||
"r": 8,
|
||||
"m80": 2,
|
||||
"N": 7,
|
||||
"sigma_top3": [
|
||||
0.9296477714715806,
|
||||
0.5431506822355127,
|
||||
0.013939617415588563
|
||||
],
|
||||
"special_mechanism": false
|
||||
},
|
||||
"illusion_1.0L_O_dqctl_vs_dqtar_r8_mode1": {
|
||||
"overlap": 0.7335300559150799,
|
||||
"mode": 1,
|
||||
"r": 8
|
||||
},
|
||||
"illusion_1.0L_O_dqctl_vs_dqtar_r8_mode2": {
|
||||
"overlap": 0.7735101257517341,
|
||||
"mode": 2,
|
||||
"r": 8
|
||||
},
|
||||
"illusion_1.0L_O_dqctl_vs_dqtar_r8_mode3": {
|
||||
"overlap": 0.5847457198896681,
|
||||
"mode": 3,
|
||||
"r": 8
|
||||
},
|
||||
"illusion_1.0L_O_dqctl_vs_dqtar_r8_mode4": {
|
||||
"overlap": 0.361282468563919,
|
||||
"mode": 4,
|
||||
"r": 8
|
||||
},
|
||||
"illusion_1.0L_O_dqctl_vs_dqtar_r8_mode5": {
|
||||
"overlap": 0.024744386112383842,
|
||||
"mode": 5,
|
||||
"r": 8
|
||||
},
|
||||
"illusion_1.0L_dqctl_force_fy_r10": {
|
||||
"scene": "illusion_1.0L",
|
||||
"diam": 1.0,
|
||||
"obs": "force_fy",
|
||||
"r": 10,
|
||||
"m80": 2,
|
||||
"N": 7,
|
||||
"sigma_top3": [
|
||||
1.1068219379848616,
|
||||
0.6279999575810425,
|
||||
0.03877250537017653
|
||||
],
|
||||
"special_mechanism": false
|
||||
},
|
||||
"illusion_1.0L_dqctl_force_fx_r10": {
|
||||
"scene": "illusion_1.0L",
|
||||
"diam": 1.0,
|
||||
"obs": "force_fx",
|
||||
"r": 10,
|
||||
"m80": 2,
|
||||
"N": 7,
|
||||
"sigma_top3": [
|
||||
1.0798028524010137,
|
||||
0.6538475361620503,
|
||||
0.14841911584738715
|
||||
],
|
||||
"special_mechanism": false
|
||||
},
|
||||
"illusion_1.0L_dqctl_action_r10": {
|
||||
"scene": "illusion_1.0L",
|
||||
"diam": 1.0,
|
||||
"obs": "action",
|
||||
"r": 10,
|
||||
"m80": 3,
|
||||
"N": 10,
|
||||
"sigma_top3": [
|
||||
1.3276794140080077,
|
||||
1.0190742997211644,
|
||||
0.7375547554838907
|
||||
],
|
||||
"special_mechanism": false
|
||||
},
|
||||
"illusion_1.0L_dqtar_force_fy_r10": {
|
||||
"scene": "illusion_1.0L",
|
||||
"diam": 1.0,
|
||||
"obs": "force_fy_tar",
|
||||
"r": 10,
|
||||
"m80": 2,
|
||||
"N": 7,
|
||||
"sigma_top3": [
|
||||
0.9303683839417445,
|
||||
0.5438911279398368,
|
||||
0.015264317923416145
|
||||
],
|
||||
"special_mechanism": false
|
||||
},
|
||||
"illusion_1.0L_O_dqctl_vs_dqtar_r10_mode1": {
|
||||
"overlap": 0.6843033711672328,
|
||||
"mode": 1,
|
||||
"r": 10
|
||||
},
|
||||
"illusion_1.0L_O_dqctl_vs_dqtar_r10_mode2": {
|
||||
"overlap": 0.7092374384087569,
|
||||
"mode": 2,
|
||||
"r": 10
|
||||
},
|
||||
"illusion_1.0L_O_dqctl_vs_dqtar_r10_mode3": {
|
||||
"overlap": 0.5408170275775555,
|
||||
"mode": 3,
|
||||
"r": 10
|
||||
},
|
||||
"illusion_1.0L_O_dqctl_vs_dqtar_r10_mode4": {
|
||||
"overlap": 0.2185189595791332,
|
||||
"mode": 4,
|
||||
"r": 10
|
||||
},
|
||||
"illusion_1.0L_O_dqctl_vs_dqtar_r10_mode5": {
|
||||
"overlap": 0.19476801271238336,
|
||||
"mode": 5,
|
||||
"r": 10
|
||||
},
|
||||
"illusion_1.5L_dqctl_force_fy_r6": {
|
||||
"scene": "illusion_1.5L",
|
||||
"diam": 1.5,
|
||||
"obs": "force_fy",
|
||||
"r": 6,
|
||||
"m80": 1,
|
||||
"N": 6,
|
||||
"sigma_top3": [
|
||||
0.9147833091150019,
|
||||
0.45038325163212484,
|
||||
0.03489015873428606
|
||||
],
|
||||
"special_mechanism": true
|
||||
},
|
||||
"illusion_1.5L_dqctl_force_fx_r6": {
|
||||
"scene": "illusion_1.5L",
|
||||
"diam": 1.5,
|
||||
"obs": "force_fx",
|
||||
"r": 6,
|
||||
"m80": 2,
|
||||
"N": 6,
|
||||
"sigma_top3": [
|
||||
0.24320738039972734,
|
||||
0.21287788693838852,
|
||||
0.042222381595628855
|
||||
],
|
||||
"special_mechanism": true
|
||||
},
|
||||
"illusion_1.5L_dqctl_action_r6": {
|
||||
"scene": "illusion_1.5L",
|
||||
"diam": 1.5,
|
||||
"obs": "action",
|
||||
"r": 6,
|
||||
"m80": 2,
|
||||
"N": 6,
|
||||
"sigma_top3": [
|
||||
0.20100087148018667,
|
||||
0.16474318694556772,
|
||||
0.06287962977503929
|
||||
],
|
||||
"special_mechanism": true
|
||||
},
|
||||
"illusion_1.5L_dqtar_force_fy_r6": {
|
||||
"scene": "illusion_1.5L",
|
||||
"diam": 1.5,
|
||||
"obs": "force_fy_tar",
|
||||
"r": 6,
|
||||
"m80": 2,
|
||||
"N": 6,
|
||||
"sigma_top3": [
|
||||
0.9652520561509902,
|
||||
0.5009040282539201,
|
||||
0.03619846551092434
|
||||
],
|
||||
"special_mechanism": true
|
||||
},
|
||||
"illusion_1.5L_O_dqctl_vs_dqtar_r6_mode1": {
|
||||
"overlap": 0.9219537578400411,
|
||||
"mode": 1,
|
||||
"r": 6
|
||||
},
|
||||
"illusion_1.5L_O_dqctl_vs_dqtar_r6_mode2": {
|
||||
"overlap": 0.9523902874156159,
|
||||
"mode": 2,
|
||||
"r": 6
|
||||
},
|
||||
"illusion_1.5L_O_dqctl_vs_dqtar_r6_mode3": {
|
||||
"overlap": 0.6142970258676237,
|
||||
"mode": 3,
|
||||
"r": 6
|
||||
},
|
||||
"illusion_1.5L_O_dqctl_vs_dqtar_r6_mode4": {
|
||||
"overlap": 0.6094429047936349,
|
||||
"mode": 4,
|
||||
"r": 6
|
||||
},
|
||||
"illusion_1.5L_O_dqctl_vs_dqtar_r6_mode5": {
|
||||
"overlap": 0.7802610781754543,
|
||||
"mode": 5,
|
||||
"r": 6
|
||||
},
|
||||
"illusion_1.5L_dqctl_force_fy_r8": {
|
||||
"scene": "illusion_1.5L",
|
||||
"diam": 1.5,
|
||||
"obs": "force_fy",
|
||||
"r": 8,
|
||||
"m80": 1,
|
||||
"N": 7,
|
||||
"sigma_top3": [
|
||||
1.0829392538529714,
|
||||
0.5377241276357321,
|
||||
0.035957125479337396
|
||||
],
|
||||
"special_mechanism": true
|
||||
},
|
||||
"illusion_1.5L_dqctl_force_fx_r8": {
|
||||
"scene": "illusion_1.5L",
|
||||
"diam": 1.5,
|
||||
"obs": "force_fx",
|
||||
"r": 8,
|
||||
"m80": 2,
|
||||
"N": 7,
|
||||
"sigma_top3": [
|
||||
0.24532002823760304,
|
||||
0.2176027887532337,
|
||||
0.06997228412312347
|
||||
],
|
||||
"special_mechanism": true
|
||||
},
|
||||
"illusion_1.5L_dqctl_action_r8": {
|
||||
"scene": "illusion_1.5L",
|
||||
"diam": 1.5,
|
||||
"obs": "action",
|
||||
"r": 8,
|
||||
"m80": 2,
|
||||
"N": 8,
|
||||
"sigma_top3": [
|
||||
0.24569133479372693,
|
||||
0.19734109779802844,
|
||||
0.07358222383616513
|
||||
],
|
||||
"special_mechanism": true
|
||||
},
|
||||
"illusion_1.5L_dqtar_force_fy_r8": {
|
||||
"scene": "illusion_1.5L",
|
||||
"diam": 1.5,
|
||||
"obs": "force_fy_tar",
|
||||
"r": 8,
|
||||
"m80": 2,
|
||||
"N": 7,
|
||||
"sigma_top3": [
|
||||
0.965559410900674,
|
||||
0.5011783760115653,
|
||||
0.036374759425362196
|
||||
],
|
||||
"special_mechanism": true
|
||||
},
|
||||
"illusion_1.5L_O_dqctl_vs_dqtar_r8_mode1": {
|
||||
"overlap": 0.7669082530299124,
|
||||
"mode": 1,
|
||||
"r": 8
|
||||
},
|
||||
"illusion_1.5L_O_dqctl_vs_dqtar_r8_mode2": {
|
||||
"overlap": 0.7846172591640821,
|
||||
"mode": 2,
|
||||
"r": 8
|
||||
},
|
||||
"illusion_1.5L_O_dqctl_vs_dqtar_r8_mode3": {
|
||||
"overlap": 0.5966131594534103,
|
||||
"mode": 3,
|
||||
"r": 8
|
||||
},
|
||||
"illusion_1.5L_O_dqctl_vs_dqtar_r8_mode4": {
|
||||
"overlap": 0.4961899748365384,
|
||||
"mode": 4,
|
||||
"r": 8
|
||||
},
|
||||
"illusion_1.5L_O_dqctl_vs_dqtar_r8_mode5": {
|
||||
"overlap": 0.554083931172919,
|
||||
"mode": 5,
|
||||
"r": 8
|
||||
},
|
||||
"illusion_1.5L_dqctl_force_fy_r10": {
|
||||
"scene": "illusion_1.5L",
|
||||
"diam": 1.5,
|
||||
"obs": "force_fy",
|
||||
"r": 10,
|
||||
"m80": 1,
|
||||
"N": 7,
|
||||
"sigma_top3": [
|
||||
1.2267278703286486,
|
||||
0.583312734476056,
|
||||
0.03658838363884156
|
||||
],
|
||||
"special_mechanism": true
|
||||
},
|
||||
"illusion_1.5L_dqctl_force_fx_r10": {
|
||||
"scene": "illusion_1.5L",
|
||||
"diam": 1.5,
|
||||
"obs": "force_fx",
|
||||
"r": 10,
|
||||
"m80": 2,
|
||||
"N": 7,
|
||||
"sigma_top3": [
|
||||
0.2567417793308261,
|
||||
0.23357924739128508,
|
||||
0.10637915056271505
|
||||
],
|
||||
"special_mechanism": true
|
||||
},
|
||||
"illusion_1.5L_dqctl_action_r10": {
|
||||
"scene": "illusion_1.5L",
|
||||
"diam": 1.5,
|
||||
"obs": "action",
|
||||
"r": 10,
|
||||
"m80": 2,
|
||||
"N": 10,
|
||||
"sigma_top3": [
|
||||
0.2696699328581466,
|
||||
0.23742010933522745,
|
||||
0.08104308573268743
|
||||
],
|
||||
"special_mechanism": true
|
||||
},
|
||||
"illusion_1.5L_dqtar_force_fy_r10": {
|
||||
"scene": "illusion_1.5L",
|
||||
"diam": 1.5,
|
||||
"obs": "force_fy_tar",
|
||||
"r": 10,
|
||||
"m80": 2,
|
||||
"N": 7,
|
||||
"sigma_top3": [
|
||||
0.9662457501034244,
|
||||
0.5015382439664361,
|
||||
0.03666891184815278
|
||||
],
|
||||
"special_mechanism": true
|
||||
},
|
||||
"illusion_1.5L_O_dqctl_vs_dqtar_r10_mode1": {
|
||||
"overlap": 0.6614436164339873,
|
||||
"mode": 1,
|
||||
"r": 10
|
||||
},
|
||||
"illusion_1.5L_O_dqctl_vs_dqtar_r10_mode2": {
|
||||
"overlap": 0.7185566980291951,
|
||||
"mode": 2,
|
||||
"r": 10
|
||||
},
|
||||
"illusion_1.5L_O_dqctl_vs_dqtar_r10_mode3": {
|
||||
"overlap": 0.5549038455928854,
|
||||
"mode": 3,
|
||||
"r": 10
|
||||
},
|
||||
"illusion_1.5L_O_dqctl_vs_dqtar_r10_mode4": {
|
||||
"overlap": 0.42330974813153344,
|
||||
"mode": 4,
|
||||
"r": 10
|
||||
},
|
||||
"illusion_1.5L_O_dqctl_vs_dqtar_r10_mode5": {
|
||||
"overlap": 0.4199314352386385,
|
||||
"mode": 5,
|
||||
"r": 10
|
||||
}
|
||||
}
|
||||
@@ -1,282 +0,0 @@
|
||||
{
|
||||
"illusion_0.75L_dq_blk": {
|
||||
"near_body": {
|
||||
"n_points": 76800,
|
||||
"mean_KE": 0.0007648248574696481,
|
||||
"mean_enstrophy": 2.9027347636656486e-07,
|
||||
"KE_fraction": 0.0023668697103857994
|
||||
},
|
||||
"body_wake": {
|
||||
"n_points": 102400,
|
||||
"mean_KE": 0.07021629065275192,
|
||||
"mean_enstrophy": 0.0008679562015458941,
|
||||
"KE_fraction": 0.28972700238227844
|
||||
},
|
||||
"sensor_zone": {
|
||||
"n_points": 35840,
|
||||
"mean_KE": 0.09573789685964584,
|
||||
"mean_enstrophy": 0.001991209341213107,
|
||||
"KE_fraction": 0.1382620632648468
|
||||
}
|
||||
},
|
||||
"illusion_0.75L_dq_ctl": {
|
||||
"near_body": {
|
||||
"n_points": 76800,
|
||||
"mean_KE": 3.614435627241619e-05,
|
||||
"mean_enstrophy": 1.743321575986556e-08,
|
||||
"KE_fraction": 0.000138708230224438
|
||||
},
|
||||
"body_wake": {
|
||||
"n_points": 102400,
|
||||
"mean_KE": 0.031560733914375305,
|
||||
"mean_enstrophy": 0.001438321196474135,
|
||||
"KE_fraction": 0.16149072349071503
|
||||
},
|
||||
"sensor_zone": {
|
||||
"n_points": 35840,
|
||||
"mean_KE": 0.04915999248623848,
|
||||
"mean_enstrophy": 0.003602436976507306,
|
||||
"KE_fraction": 0.0880400612950325
|
||||
}
|
||||
},
|
||||
"illusion_1.0L_dq_blk": {
|
||||
"near_body": {
|
||||
"n_points": 76800,
|
||||
"mean_KE": 0.0007648248574696481,
|
||||
"mean_enstrophy": 2.9027347636656486e-07,
|
||||
"KE_fraction": 0.0023668697103857994
|
||||
},
|
||||
"body_wake": {
|
||||
"n_points": 102400,
|
||||
"mean_KE": 0.07021629065275192,
|
||||
"mean_enstrophy": 0.0008679562015458941,
|
||||
"KE_fraction": 0.28972700238227844
|
||||
},
|
||||
"sensor_zone": {
|
||||
"n_points": 35840,
|
||||
"mean_KE": 0.09573789685964584,
|
||||
"mean_enstrophy": 0.001991209341213107,
|
||||
"KE_fraction": 0.1382620632648468
|
||||
}
|
||||
},
|
||||
"illusion_1.0L_dq_ctl": {
|
||||
"near_body": {
|
||||
"n_points": 76800,
|
||||
"mean_KE": 2.7065932954428717e-05,
|
||||
"mean_enstrophy": 1.3172467561162193e-08,
|
||||
"KE_fraction": 0.000290345138637349
|
||||
},
|
||||
"body_wake": {
|
||||
"n_points": 102400,
|
||||
"mean_KE": 0.0158767718821764,
|
||||
"mean_enstrophy": 0.0004291802179068327,
|
||||
"KE_fraction": 0.2270871102809906
|
||||
},
|
||||
"sensor_zone": {
|
||||
"n_points": 35840,
|
||||
"mean_KE": 0.015243785455822945,
|
||||
"mean_enstrophy": 0.0009271366288885474,
|
||||
"KE_fraction": 0.07631170749664307
|
||||
}
|
||||
},
|
||||
"illusion_1.5L_dq_blk": {
|
||||
"near_body": {
|
||||
"n_points": 76800,
|
||||
"mean_KE": 0.0007648248574696481,
|
||||
"mean_enstrophy": 2.9027347636656486e-07,
|
||||
"KE_fraction": 0.0023668697103857994
|
||||
},
|
||||
"body_wake": {
|
||||
"n_points": 102400,
|
||||
"mean_KE": 0.07021629065275192,
|
||||
"mean_enstrophy": 0.0008679562015458941,
|
||||
"KE_fraction": 0.28972700238227844
|
||||
},
|
||||
"sensor_zone": {
|
||||
"n_points": 35840,
|
||||
"mean_KE": 0.09573789685964584,
|
||||
"mean_enstrophy": 0.001991209341213107,
|
||||
"KE_fraction": 0.1382620632648468
|
||||
}
|
||||
},
|
||||
"illusion_1.5L_dq_ctl": {
|
||||
"near_body": {
|
||||
"n_points": 76800,
|
||||
"mean_KE": 2.4420158297289163e-05,
|
||||
"mean_enstrophy": 1.2525031012344812e-09,
|
||||
"KE_fraction": 8.383671229239553e-05
|
||||
},
|
||||
"body_wake": {
|
||||
"n_points": 102400,
|
||||
"mean_KE": 0.04845782741904259,
|
||||
"mean_enstrophy": 0.00021500905859284103,
|
||||
"KE_fraction": 0.22181373834609985
|
||||
},
|
||||
"sensor_zone": {
|
||||
"n_points": 35840,
|
||||
"mean_KE": 0.11757005751132965,
|
||||
"mean_enstrophy": 0.0005185439949855208,
|
||||
"KE_fraction": 0.18836025893688202
|
||||
}
|
||||
},
|
||||
"steady_cloak_dq_blk": {
|
||||
"near_body": {
|
||||
"n_points": 76800,
|
||||
"mean_KE": 0.0007648248574696481,
|
||||
"mean_enstrophy": 2.9027347636656486e-07,
|
||||
"KE_fraction": 0.0023668697103857994
|
||||
},
|
||||
"body_wake": {
|
||||
"n_points": 102400,
|
||||
"mean_KE": 0.07021629065275192,
|
||||
"mean_enstrophy": 0.0008679562015458941,
|
||||
"KE_fraction": 0.28972700238227844
|
||||
},
|
||||
"sensor_zone": {
|
||||
"n_points": 35840,
|
||||
"mean_KE": 0.09573789685964584,
|
||||
"mean_enstrophy": 0.001991209341213107,
|
||||
"KE_fraction": 0.1382620632648468
|
||||
}
|
||||
},
|
||||
"steady_cloak_dq_ctl": {
|
||||
"near_body": {
|
||||
"n_points": 76800,
|
||||
"mean_KE": 6.172616849653423e-05,
|
||||
"mean_enstrophy": 3.953555349767157e-08,
|
||||
"KE_fraction": 0.00015435306704603136
|
||||
},
|
||||
"body_wake": {
|
||||
"n_points": 102400,
|
||||
"mean_KE": 0.13577842712402344,
|
||||
"mean_enstrophy": 0.008918941020965576,
|
||||
"KE_fraction": 0.45270517468452454
|
||||
},
|
||||
"sensor_zone": {
|
||||
"n_points": 35840,
|
||||
"mean_KE": 0.3214779794216156,
|
||||
"mean_enstrophy": 0.02501676417887211,
|
||||
"KE_fraction": 0.375149130821228
|
||||
}
|
||||
},
|
||||
"karman_re100_dq_blk": {
|
||||
"near_body": {
|
||||
"n_points": 71680,
|
||||
"mean_KE": 0.14623497426509857,
|
||||
"mean_enstrophy": 0.0006234294269233942,
|
||||
"KE_fraction": 0.20486503839492798
|
||||
},
|
||||
"body_wake": {
|
||||
"n_points": 66560,
|
||||
"mean_KE": 0.10329263657331467,
|
||||
"mean_enstrophy": 0.0001615467481315136,
|
||||
"KE_fraction": 0.13436967134475708
|
||||
},
|
||||
"sensor_zone": {
|
||||
"n_points": 35840,
|
||||
"mean_KE": 0.0839567631483078,
|
||||
"mean_enstrophy": 0.00010710477363318205,
|
||||
"KE_fraction": 0.05880879983305931
|
||||
}
|
||||
},
|
||||
"karman_re100_dq_ctl": {
|
||||
"near_body": {
|
||||
"n_points": 71680,
|
||||
"mean_KE": 0.20531150698661804,
|
||||
"mean_enstrophy": 0.008244173601269722,
|
||||
"KE_fraction": 0.2548401653766632
|
||||
},
|
||||
"body_wake": {
|
||||
"n_points": 66560,
|
||||
"mean_KE": 0.12807133793830872,
|
||||
"mean_enstrophy": 0.00012567009252961725,
|
||||
"KE_fraction": 0.147612065076828
|
||||
},
|
||||
"sensor_zone": {
|
||||
"n_points": 35840,
|
||||
"mean_KE": 0.12139707058668137,
|
||||
"mean_enstrophy": 8.339789928868413e-05,
|
||||
"KE_fraction": 0.07534124702215195
|
||||
}
|
||||
},
|
||||
"vortex_lamb_dq_blk": {
|
||||
"near_body": {
|
||||
"n_points": 71680,
|
||||
"mean_KE": 0.13352914154529572,
|
||||
"mean_enstrophy": 0.0010146809509024024,
|
||||
"KE_fraction": 0.46505114436149597
|
||||
},
|
||||
"body_wake": {
|
||||
"n_points": 66560,
|
||||
"mean_KE": 0.11699999123811722,
|
||||
"mean_enstrophy": 0.0002210606326116249,
|
||||
"KE_fraction": 0.37837791442871094
|
||||
},
|
||||
"sensor_zone": {
|
||||
"n_points": 35840,
|
||||
"mean_KE": 0.09222870320081711,
|
||||
"mean_enstrophy": 0.00014765505329705775,
|
||||
"KE_fraction": 0.16060562431812286
|
||||
}
|
||||
},
|
||||
"vortex_lamb_dq_ctl": {
|
||||
"near_body": {
|
||||
"n_points": 71680,
|
||||
"mean_KE": 0.18387039005756378,
|
||||
"mean_enstrophy": 0.008811434730887413,
|
||||
"KE_fraction": 0.48393887281417847
|
||||
},
|
||||
"body_wake": {
|
||||
"n_points": 66560,
|
||||
"mean_KE": 0.13139627873897552,
|
||||
"mean_enstrophy": 0.0002448623126838356,
|
||||
"KE_fraction": 0.32112717628479004
|
||||
},
|
||||
"sensor_zone": {
|
||||
"n_points": 35840,
|
||||
"mean_KE": 0.12105172127485275,
|
||||
"mean_enstrophy": 0.00019949178386013955,
|
||||
"KE_fraction": 0.15930141508579254
|
||||
}
|
||||
},
|
||||
"vortex_taylor_dq_blk": {
|
||||
"near_body": {
|
||||
"n_points": 71680,
|
||||
"mean_KE": 0.1294081211090088,
|
||||
"mean_enstrophy": 0.0011693575652316213,
|
||||
"KE_fraction": 0.44358906149864197
|
||||
},
|
||||
"body_wake": {
|
||||
"n_points": 66560,
|
||||
"mean_KE": 0.10131462663412094,
|
||||
"mean_enstrophy": 0.0001417396852048114,
|
||||
"KE_fraction": 0.3224829137325287
|
||||
},
|
||||
"sensor_zone": {
|
||||
"n_points": 35840,
|
||||
"mean_KE": 0.07597015053033829,
|
||||
"mean_enstrophy": 4.9815782404039055e-05,
|
||||
"KE_fraction": 0.1302063763141632
|
||||
}
|
||||
},
|
||||
"vortex_taylor_dq_ctl": {
|
||||
"near_body": {
|
||||
"n_points": 71680,
|
||||
"mean_KE": 0.16766612231731415,
|
||||
"mean_enstrophy": 0.008833244442939758,
|
||||
"KE_fraction": 0.48838308453559875
|
||||
},
|
||||
"body_wake": {
|
||||
"n_points": 66560,
|
||||
"mean_KE": 0.08698907494544983,
|
||||
"mean_enstrophy": 0.000124395388411358,
|
||||
"KE_fraction": 0.23528558015823364
|
||||
},
|
||||
"sensor_zone": {
|
||||
"n_points": 35840,
|
||||
"mean_KE": 0.06907761096954346,
|
||||
"mean_enstrophy": 3.919627488357946e-05,
|
||||
"KE_fraction": 0.10060571134090424
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
../../data/figures/corr_comparison_all_scenes.png
|
||||
@@ -1 +0,0 @@
|
||||
../../data/figures/corr_cloak_comparison_dqctl.png
|
||||
@@ -1 +0,0 @@
|
||||
../../data/figures/corr_illusion_comparison_dqctl.png
|
||||
@@ -1 +0,0 @@
|
||||
../../data/figures/steady_cloak_cancel_test.png
|
||||
@@ -1 +0,0 @@
|
||||
../../data/figures/corr_illusion_0.75L_ctl_vs_tar.png
|
||||
@@ -1 +0,0 @@
|
||||
../../data/figures/corr_illusion_1.0L_ctl_vs_tar.png
|
||||
@@ -1 +0,0 @@
|
||||
../../data/figures/corr_illusion_1.5L_ctl_vs_tar.png
|
||||
@@ -1 +0,0 @@
|
||||
../../data/figures/corr_karman_re100_ctl_vs_tar.png
|
||||
@@ -1 +0,0 @@
|
||||
../../data/figures/corr_vortex_lamb_ctl_vs_tar.png
|
||||
@@ -1 +0,0 @@
|
||||
../../data/figures/corr_vortex_taylor_ctl_vs_tar.png
|
||||
@@ -1 +0,0 @@
|
||||
../../data/figures/vortex_lamb_diagnosis.png
|
||||
@@ -1 +0,0 @@
|
||||
../../data/figures/vortex_taylor_diagnosis.png
|
||||
@@ -1 +0,0 @@
|
||||
../../data/figures/vortex_lamb_vorticity.png
|
||||
@@ -1 +0,0 @@
|
||||
../../data/figures/vortex_taylor_vorticity.png
|
||||
@@ -1 +0,0 @@
|
||||
../../data/figures/vortex_lamb_target_vorticity.png
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user