Compare commits

...
2 Commits
Author SHA1 Message Date
Frank14fandCursor 213956d964 refactor(oid): reset analysis to claim-free core
Archive superseded studies and payload metadata while preserving a tested Schlegel LR/LE implementation with explicit scientific and physics contracts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 21:38:24 +08:00
Frank14fandCursor 8d760145de feat(ccd): add dual-clock field sampling
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 14:07:05 +08:00
414 changed files with 25379 additions and 617 deletions
+16
View File
@@ -79,6 +79,19 @@ tensorboard/
# Analysis outputs (generated) # Analysis outputs (generated)
src/OID_analysis/data/derived/ src/OID_analysis/data/derived/
# Archived OID generated payloads live on Optane; keep only relocation stubs/manifests locally.
src/OID_analysis/archive/legacy_oid_v1/data_derived/*
!src/OID_analysis/archive/legacy_oid_v1/data_derived/RELOCATED.txt
src/OID_analysis/archive/v2_development_runs/*
!src/OID_analysis/archive/v2_development_runs/RELOCATED.txt
src/OID_analysis/data_li22b/derived/*
!src/OID_analysis/data_li22b/derived/RELOCATED.txt
# OID scientific-reset archives: preserve source/docs/compact evidence; large arrays remain ignored by extension.
# Historical study paths are intentionally regular directories (never symlinks).
src/OID_analysis/archive/studies/*/evidence/**/*.npz
src/OID_analysis/archive/materials/pre-reset-active/data/**/*.npz
src/OID_analysis/archive/materials/pre-reset-active/data/**/*.npy
src/OID_analysis/data/*/vorticity_*.png src/OID_analysis/data/*/vorticity_*.png
src/OID_analysis/data/*/*/vorticity_*.png src/OID_analysis/data/*/*/vorticity_*.png
src/OID_analysis/data/*/*/ddf_checkpoint.npy src/OID_analysis/data/*/*/ddf_checkpoint.npy
@@ -105,6 +118,9 @@ outputs/
ref/ ref/
docs/ docs/
# Historical OID authority documents are compact preservation evidence.
!src/OID_analysis/archive/studies/*/presentation/docs/
!src/OID_analysis/archive/studies/*/presentation/docs/**
ParaView/ ParaView/
# Runtime outputs (generated, not committed) # Runtime outputs (generated, not committed)
src/drl_pinball/legacy_test/output/ src/drl_pinball/legacy_test/output/
+60 -17
View File
@@ -111,6 +111,7 @@ class FlowField:
self.objects = {} self.objects = {}
self.action = np.zeros(0, dtype=self.DATA_TYPE) self.action = np.zeros(0, dtype=self.DATA_TYPE)
self.obs = np.zeros(0, dtype=self.DATA_TYPE) self.obs = np.zeros(0, dtype=self.DATA_TYPE)
self._control_interval = None
initflow( initflow(
self.flag_gpu, self.flag_gpu,
@@ -337,26 +338,55 @@ class FlowField:
# if type == "taylor": # if type == "taylor":
# self.vortex_config[6] = # self.vortex_config[6] =
def run(self, num_steps: int, action_target: np.ndarray): def _validate_run(self, num_steps: int, action_target: np.ndarray):
if ( if type(num_steps) is not int or num_steps < 1:
action_target.size != len(self.objects) raise ValueError("num_steps must be a positive integer")
or action_target.dtype != self.DATA_TYPE if action_target.size != len(self.objects) or action_target.dtype != self.DATA_TYPE:
):
raise ValueError("action data type or size does not match the objects.") raise ValueError("action data type or size does not match the objects.")
elif len(self.objects) == 0: if len(self.objects) == 0:
raise ValueError("No objects have been added to the flow field.") raise ValueError("No objects have been added to the flow field.")
weight = 0.1 def run(self, num_steps: int, action_target: np.ndarray):
stream = cuda.Stream() """Advance one complete legacy interval (original public behavior)."""
action_pinned = cuda.pagelocked_empty_like(self.action) if self._control_interval is not None:
action_pinned[:] = self.action raise RuntimeError("run is unavailable while a control interval is active")
obs_pinned = cuda.pagelocked_empty_like(self.obs) self.begin_control_interval(num_steps, action_target)
self.run_control_segment(num_steps)
self.end_control_interval()
def begin_control_interval(self, total_steps: int, action_target: np.ndarray):
"""Start one policy interval that may be split only to read/save fields."""
if self._control_interval is not None:
raise RuntimeError("a control interval is already active")
self._validate_run(total_steps, action_target)
self.error_flag[0] = 0 self.error_flag[0] = 0
cuda.memcpy_htod(self.error_flag_gpu, self.error_flag) cuda.memcpy_htod(self.error_flag_gpu, self.error_flag)
self.obs[:] = 0 self.obs[:] = 0
for i in range(num_steps): action = cuda.pagelocked_empty_like(self.action)
action_pinned = (1 - weight) * action_pinned + weight * action_target action[:] = self.action
cuda.memcpy_htod_async(self.action_gpu, action_pinned, stream) self._control_interval = {
"total_steps": total_steps,
"completed_steps": 0,
"target": action_target.copy(),
"action": action,
"obs_steps": cuda.pagelocked_empty((total_steps, self.obs.size), dtype=self.DATA_TYPE),
"stream": cuda.Stream(),
}
def run_control_segment(self, num_steps: int):
"""Advance part of the active interval without resetting smoothing or obs."""
state = self._control_interval
if state is None:
raise RuntimeError("no control interval is active")
if type(num_steps) is not int or num_steps < 1:
raise ValueError("num_steps must be a positive integer")
if state["completed_steps"] + num_steps > state["total_steps"]:
raise ValueError("segment exceeds the active control interval")
stream = state["stream"]
start = state["completed_steps"]
for local_step in range(num_steps):
state["action"] = 0.9 * state["action"] + 0.1 * state["target"]
cuda.memcpy_htod_async(self.action_gpu, state["action"], stream)
self.step( self.step(
self.flag_gpu, self.flag_gpu,
self.ddf_gpu, self.ddf_gpu,
@@ -375,13 +405,26 @@ class FlowField:
stream=stream, stream=stream,
) )
self.ddf_gpu, self.temp_gpu = self.temp_gpu, self.ddf_gpu self.ddf_gpu, self.temp_gpu = self.temp_gpu, self.ddf_gpu
cuda.memcpy_dtoh_async(obs_pinned, self.obs_gpu, stream) cuda.memcpy_dtoh_async(state["obs_steps"][start + local_step], self.obs_gpu, stream)
cuda.memset_d32_async(self.obs_gpu, 0, self.obs.size, stream) cuda.memset_d32_async(self.obs_gpu, 0, self.obs.size, stream)
self.obs += obs_pinned
stream.synchronize() stream.synchronize()
self.obs = (self.obs / num_steps).astype(self.DATA_TYPE) state["completed_steps"] += num_steps
def end_control_interval(self):
"""Publish obs once, only at the original policy-control boundary."""
state = self._control_interval
if state is None:
raise RuntimeError("no control interval is active")
if state["completed_steps"] != state["total_steps"]:
raise RuntimeError("cannot end a control interval before its boundary")
self.obs[:] = 0
for step_obs in state["obs_steps"]:
self.obs += step_obs
self.obs = (self.obs / state["total_steps"]).astype(self.DATA_TYPE)
cuda.memcpy_dtoh(self.error_flag, self.error_flag_gpu) cuda.memcpy_dtoh(self.error_flag, self.error_flag_gpu)
self.last_error_flag = int(self.error_flag[0]) self.last_error_flag = int(self.error_flag[0])
self._control_interval = None
return self.obs
def has_numeric_error(self) -> bool: def has_numeric_error(self) -> bool:
return bool(self.last_error_flag != 0) return bool(self.last_error_flag != 0)
+173
View File
@@ -0,0 +1,173 @@
# 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 similarity0.973265
- DDF 读取组:0.976044
- 差值:+0.002779
- 平均 reward 差值:-0.001633。
### Kármán Re=100
- 控制周期:800;流场间隔:317;
- baseline DTW similarity0.954562
- DDF 读取组:0.953442
- 差值:-0.001119(约 -0.12%);
- 两组末 100 周期传感器序列经相位补偿后的相互 DTW similarity0.965680。
最终受控流场保持相同类型的尾流结构,没有出现控制失效。因此对同一条连续在线闭环轨迹,可以认为控制期间独立读取 DDF 不会造成有意义的控制效果下降。
## 边界和注意事项
- 该方法解决的是同一条连续闭环轨迹中的独立流场采样。
- 它不保证不同 checkpoint restore 分支 bitwise 相同;Legacy CUDA kernel 本身存在数值非确定性。
- 不要把两个独立 restore 分支当成逐点 bitwise 配对数据。
- 不要在 active control interval 中调用普通 `ff.run()`
- 不要提前调用 `end_control_interval()`
- 不要在同一 control interval 内切换 action target。
- DDF 读取后必须继续同一个 session,直至原固定控制边界。
+4
View File
@@ -28,6 +28,10 @@ scripts/collect_*.py → detect_period.py → compute_correction_
(CPU) (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 ## Key Conventions
### Geometry (unified 2026-06-25) ### Geometry (unified 2026-06-25)
+2
View File
@@ -6,6 +6,8 @@ Analyzes DRL-controlled fluidic pinball using **correction-field decomposition**
**→ [`PIPELINE.md`](PIPELINE.md)** — pipeline overview, results index, conventions, new training integration guide. **→ [`PIPELINE.md`](PIPELINE.md)** — pipeline overview, results index, conventions, new training integration guide.
**→ [`DUAL_CLOCK_SAMPLING.md`](DUAL_CLOCK_SAMPLING.md)** — 在固定 DRL 控制周期内按独立时钟读取/保存 DDF;CCD 与 OID 共用。
## Quick Commands ## Quick Commands
```bash ```bash
+41 -4
View File
@@ -17,6 +17,7 @@ import os
import sys import sys
import time import time
from collections import deque from collections import deque
from pathlib import Path
import numpy as np import numpy as np
@@ -36,6 +37,7 @@ from CCD_analysis.utils.cfd_interface import (
calc_lag, calc_dtw_sim, calc_lag, calc_dtw_sim,
) )
from CCD_analysis.utils.resampling import analyze_harmonics, gen_target_states_at from CCD_analysis.utils.resampling import analyze_harmonics, gen_target_states_at
from CCD_analysis.utils.dual_clock import DualClockCollector, field_steps_from_interval
DATA_TYPE = np.float32 DATA_TYPE = np.float32
L0 = 20.0 L0 = 20.0
@@ -44,9 +46,17 @@ FIFO_LEN = 150
CONV_LEN = 36 CONV_LEN = 36
def run_single(scene_name: str, device_id: int, n_steps: int) -> dict: def run_single(
scene_name: str,
device_id: int,
n_steps: int,
*,
field_interval: int | None = None,
output_dir: str | None = None,
) -> dict:
cfg = get_scene(scene_name) cfg = get_scene(scene_name)
out_dir = data_dir_for_scene(scene_name) out_dir = output_dir or data_dir_for_scene(scene_name)
Path(out_dir).mkdir(parents=True, exist_ok=True)
u0 = cfg["u0"] u0 = cfg["u0"]
si = cfg["sample_interval"] si = cfg["sample_interval"]
ac_scale = cfg["action_scale"] ac_scale = cfg["action_scale"]
@@ -152,6 +162,15 @@ def run_single(scene_name: str, device_id: int, n_steps: int) -> dict:
obs = np.zeros(s_dim, dtype=np.float32) obs = np.zeros(s_dim, dtype=np.float32)
sens_c, forc_c, act_c, rew_c, sim_c = [], [], [], [], [] sens_c, forc_c, act_c, rew_c, sim_c = [], [], [], [], []
collector = None
if field_interval is not None:
collector = DualClockCollector(
ff,
control_interval=si,
control_count=n_steps,
u0=u0,
field_steps=field_steps_from_interval(si * n_steps, field_interval),
)
for step in range(n_steps): for step in range(n_steps):
action, _ = model.predict(obs, deterministic=True) action, _ = model.predict(obs, deterministic=True)
@@ -163,7 +182,10 @@ def run_single(scene_name: str, device_id: int, n_steps: int) -> dict:
temp_a[3:6] = omega temp_a[3:6] = omega
ff.context.push() ff.context.push()
if collector is None:
ff.run(si, temp_a) ff.run(si, temp_a)
else:
collector.run_interval(step, temp_a)
ff.context.pop() ff.context.pop()
obs_slice = ff.obs.copy()[0:12] obs_slice = ff.obs.copy()[0:12]
@@ -219,6 +241,8 @@ def run_single(scene_name: str, device_id: int, n_steps: int) -> dict:
np.savez(os.path.join(out_dir, "controlled.npz"), np.savez(os.path.join(out_dir, "controlled.npz"),
sensors=sens_arr, forces=forc_arr, actions=act_arr, sensors=sens_arr, forces=forc_arr, actions=act_arr,
rewards=np.array(rew_c, dtype=np.float32)) rewards=np.array(rew_c, dtype=np.float32))
if collector is not None:
collector.save(Path(out_dir) / "fields.npz")
save_vorticity_png(os.path.join(out_dir, "vorticity_controlled.png"), save_vorticity_png(os.path.join(out_dir, "vorticity_controlled.png"),
vorticity_from_ddf(ff, u0=u0), vorticity_from_ddf(ff, u0=u0),
@@ -229,7 +253,13 @@ def run_single(scene_name: str, device_id: int, n_steps: int) -> dict:
avg_sim = float(np.mean(sim_c[-tail:])) if sim_c else 0.0 avg_sim = float(np.mean(sim_c[-tail:])) if sim_c else 0.0
print(f" reward={avg_reward:.4f} similarity={avg_sim:.4f}") print(f" reward={avg_reward:.4f} similarity={avg_sim:.4f}")
result = {"scene": scene_name, "similarity": avg_sim, "avg_reward": avg_reward} result = {
"scene": scene_name,
"similarity": avg_sim,
"avg_reward": avg_reward,
"control_interval": si,
"field_interval": field_interval,
}
with open(os.path.join(out_dir, "result.json"), "w") as f: with open(os.path.join(out_dir, "result.json"), "w") as f:
json.dump(result, f, indent=2) json.dump(result, f, indent=2)
@@ -245,6 +275,10 @@ def main():
help="Diameter shortcut (0.75, 1.0, 1.5)") help="Diameter shortcut (0.75, 1.0, 1.5)")
ap.add_argument("--device", type=int, default=2) ap.add_argument("--device", type=int, default=2)
ap.add_argument("--steps", type=int, default=200) ap.add_argument("--steps", type=int, default=200)
ap.add_argument("--field-interval", type=int, default=None,
help="Save fields every N absolute lattice steps")
ap.add_argument("--output-dir", type=str, default=None,
help="Optional isolated output directory")
args = ap.parse_args() args = ap.parse_args()
if args.diameter is not None: if args.diameter is not None:
@@ -257,7 +291,10 @@ def main():
return 1 return 1
t0 = time.time() t0 = time.time()
r = run_single(scene_name, args.device, args.steps) r = run_single(
scene_name, args.device, args.steps,
field_interval=args.field_interval, output_dir=args.output_dir,
)
print(f"Done in {time.time()-t0:.1f}s: sim={r['similarity']:.4f}") print(f"Done in {time.time()-t0:.1f}s: sim={r['similarity']:.4f}")
+41 -4
View File
@@ -15,6 +15,7 @@ import os
import sys import sys
import time import time
from collections import deque from collections import deque
from pathlib import Path
import numpy as np import numpy as np
@@ -28,6 +29,7 @@ if _SRC not in sys.path:
from LegacyCelerisLab import FlowField from LegacyCelerisLab import FlowField
from CCD_analysis.configs import get_scene, get_scene_list, data_dir_for_scene, model_path_for_scene, LEGACY_CFG_DIR from CCD_analysis.configs import get_scene, get_scene_list, data_dir_for_scene, model_path_for_scene, LEGACY_CFG_DIR
from CCD_analysis.utils.dual_clock import DualClockCollector, field_steps_from_interval
from CCD_analysis.utils.cfd_interface import ( from CCD_analysis.utils.cfd_interface import (
load_legacy_configs, load_legacy_configs,
build_karman_cloak_env, add_pinball, build_observation, build_karman_cloak_env, add_pinball, build_observation,
@@ -39,9 +41,17 @@ DATA_TYPE = np.float32
L0 = 20.0 L0 = 20.0
def run_single(scene_name: str, device_id: int, n_steps: int) -> dict: def run_single(
scene_name: str,
device_id: int,
n_steps: int,
*,
field_interval: int | None = None,
output_dir: str | None = None,
) -> dict:
cfg = get_scene(scene_name) cfg = get_scene(scene_name)
out_dir = data_dir_for_scene(scene_name) out_dir = output_dir or data_dir_for_scene(scene_name)
Path(out_dir).mkdir(parents=True, exist_ok=True)
u0 = cfg["u0"] u0 = cfg["u0"]
si = cfg["sample_interval"] si = cfg["sample_interval"]
ac_scale = cfg["action_scale"] ac_scale = cfg["action_scale"]
@@ -120,6 +130,15 @@ def run_single(scene_name: str, device_id: int, n_steps: int) -> dict:
sens_c, forc_c, act_c, rew_c = [], [], [], [] sens_c, forc_c, act_c, rew_c = [], [], [], []
obs = np.zeros(s_dim, dtype=np.float32) obs = np.zeros(s_dim, dtype=np.float32)
collector = None
if field_interval is not None:
collector = DualClockCollector(
ff,
control_interval=si,
control_count=n_steps,
u0=u0,
field_steps=field_steps_from_interval(si * n_steps, field_interval),
)
for step in range(n_steps): for step in range(n_steps):
action, _ = model.predict(obs, deterministic=True) action, _ = model.predict(obs, deterministic=True)
@@ -129,7 +148,10 @@ def run_single(scene_name: str, device_id: int, n_steps: int) -> dict:
action_arr = scale_action(action, scale=ac_scale, bias=ac_bias, action_arr = scale_action(action, scale=ac_scale, bias=ac_bias,
u0=u0, n_total_bodies=n_obj) u0=u0, n_total_bodies=n_obj)
ff.context.push() ff.context.push()
if collector is None:
ff.run(si, action_arr) ff.run(si, action_arr)
else:
collector.run_interval(step, action_arr)
ff.context.pop() ff.context.pop()
obs_slice = ff.obs.copy()[2:14] obs_slice = ff.obs.copy()[2:14]
@@ -156,6 +178,8 @@ def run_single(scene_name: str, device_id: int, n_steps: int) -> dict:
np.savez(os.path.join(out_dir, "controlled.npz"), np.savez(os.path.join(out_dir, "controlled.npz"),
sensors=sens_arr, forces=forc_arr, actions=act_arr, rewards=rew_arr) sensors=sens_arr, forces=forc_arr, actions=act_arr, rewards=rew_arr)
if collector is not None:
collector.save(Path(out_dir) / "fields.npz")
save_vorticity_png(os.path.join(out_dir, "vorticity_controlled.png"), save_vorticity_png(os.path.join(out_dir, "vorticity_controlled.png"),
vorticity_from_ddf(ff, u0=u0), vorticity_from_ddf(ff, u0=u0),
@@ -165,7 +189,13 @@ def run_single(scene_name: str, device_id: int, n_steps: int) -> dict:
sim_score = compute_similarity(target_states, sens_arr, 30) sim_score = compute_similarity(target_states, sens_arr, 30)
print(f" reward={avg_reward:.4f} similarity={sim_score:.4f}") print(f" reward={avg_reward:.4f} similarity={sim_score:.4f}")
result = {"scene": scene_name, "similarity": sim_score, "avg_reward": avg_reward} result = {
"scene": scene_name,
"similarity": sim_score,
"avg_reward": avg_reward,
"control_interval": si,
"field_interval": field_interval,
}
with open(os.path.join(out_dir, "result.json"), "w") as f: with open(os.path.join(out_dir, "result.json"), "w") as f:
json.dump(result, f, indent=2) json.dump(result, f, indent=2)
@@ -181,6 +211,10 @@ def main():
help="Re number shortcut (50, 100, 200, 400)") help="Re number shortcut (50, 100, 200, 400)")
ap.add_argument("--device", type=int, default=2) ap.add_argument("--device", type=int, default=2)
ap.add_argument("--steps", type=int, default=200) ap.add_argument("--steps", type=int, default=200)
ap.add_argument("--field-interval", type=int, default=None,
help="Save fields every N absolute lattice steps")
ap.add_argument("--output-dir", type=str, default=None,
help="Optional isolated output directory")
args = ap.parse_args() args = ap.parse_args()
if args.re is not None: if args.re is not None:
@@ -193,7 +227,10 @@ def main():
return 1 return 1
t0 = time.time() t0 = time.time()
r = run_single(scene_name, args.device, args.steps) r = run_single(
scene_name, args.device, args.steps,
field_interval=args.field_interval, output_dir=args.output_dir,
)
print(f"Done in {time.time()-t0:.1f}s: sim={r['similarity']:.4f}") print(f"Done in {time.time()-t0:.1f}s: sim={r['similarity']:.4f}")
+65
View File
@@ -0,0 +1,65 @@
import numpy as np
import pytest
from CCD_analysis.utils.dual_clock import DualClockCollector, field_steps_from_interval
class FakeFlowField:
def __init__(self):
self.absolute_step = 0
self.ddf = np.zeros(9, dtype=np.float32)
self.begin_calls = 0
self.end_calls = 0
self._completed = 0
self._total = 0
def begin_control_interval(self, total_steps, action_target):
self.begin_calls += 1
self._total = total_steps
self._completed = 0
def run_control_segment(self, steps):
self.absolute_step += steps
self._completed += steps
def end_control_interval(self):
assert self._completed == self._total
self.end_calls += 1
return np.array([self.absolute_step], dtype=np.float32)
def get_ddf(self):
self.ddf[:] = self.absolute_step
def test_field_steps_from_nondivisible_interval():
assert field_steps_from_interval(16, 3) == (3, 6, 9, 12, 15)
def test_irregular_field_reads_do_not_add_control_boundaries(monkeypatch):
def fake_velocity(ff, u0):
ff.get_ddf()
return ff.ddf[:3].copy(), ff.ddf[3:6].copy()
monkeypatch.setattr("CCD_analysis.utils.dual_clock.get_velocity_field", fake_velocity)
ff = FakeFlowField()
collector = DualClockCollector(
ff,
control_interval=8,
control_count=2,
u0=0.01,
field_steps=[3, 6, 9, 12, 15, 16],
)
collector.run_interval(0, np.zeros(1, dtype=np.float32))
collector.run_interval(1, np.zeros(1, dtype=np.float32))
assert collector.saved_steps == [3, 6, 9, 12, 15, 16]
assert ff.begin_calls == 2
assert ff.end_calls == 2
assert ff.absolute_step == 16
def test_collector_rejects_invalid_field_timeline():
with pytest.raises(ValueError):
DualClockCollector(
FakeFlowField(), control_interval=8, control_count=2,
u0=0.01, field_steps=[3, 3],
)
+101
View File
@@ -0,0 +1,101 @@
"""Minimal dual-clock field sampling for legacy CelerisLab rollouts."""
from __future__ import annotations
from collections.abc import Iterable
from pathlib import Path
import numpy as np
from CCD_analysis.utils.cfd_interface import get_velocity_field
def field_steps_from_interval(total_steps: int, field_interval: int) -> tuple[int, ...]:
"""Return absolute field-save steps within a finite rollout."""
if type(total_steps) is not int or total_steps < 1:
raise ValueError("total_steps 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")
return tuple(range(field_interval, total_steps + 1, field_interval))
class DualClockCollector:
"""Keep policy decisions fixed while reading fields at independent steps."""
def __init__(
self,
flow_field,
*,
control_interval: int,
control_count: int,
u0: float,
field_steps: Iterable[int],
):
if type(control_interval) is not int or control_interval < 1:
raise ValueError("control_interval must be a positive integer")
if type(control_count) is not int or control_count < 1:
raise ValueError("control_count must be a positive integer")
self.flow_field = flow_field
self.control_interval = control_interval
self.control_count = control_count
self.u0 = u0
self.field_steps = tuple(field_steps)
horizon = control_interval * control_count
if any(type(step) is not int for step in self.field_steps):
raise ValueError("field steps must be integers")
if tuple(sorted(set(self.field_steps))) != self.field_steps:
raise ValueError("field steps must be strictly increasing and unique")
if any(step < 1 or step > horizon for step in self.field_steps):
raise ValueError("field steps must lie within the rollout")
self._next_field = 0
self._next_control = 0
self.ux: list[np.ndarray] = []
self.uy: list[np.ndarray] = []
self.saved_steps: list[int] = []
def run_interval(self, control_index: int, action_target: np.ndarray) -> np.ndarray:
"""Advance exactly one policy interval, pausing only at due field steps."""
if control_index != self._next_control:
raise ValueError("control intervals must run sequentially")
start = control_index * self.control_interval
stop = start + self.control_interval
due = []
while self._next_field < len(self.field_steps):
step = self.field_steps[self._next_field]
if step > stop:
break
if step > start:
due.append(step)
self._next_field += 1
ff = self.flow_field
ff.begin_control_interval(self.control_interval, action_target)
current = start
boundaries = due if due and due[-1] == stop else [*due, stop]
for step in boundaries:
if step > current:
ff.run_control_segment(step - current)
current = step
if step in due:
ux, uy = get_velocity_field(ff, u0=self.u0)
self.ux.append(ux)
self.uy.append(uy)
self.saved_steps.append(step)
obs = ff.end_control_interval().copy()
self._next_control += 1
return obs
def save(self, path: str | Path) -> None:
"""Save fields and their absolute lattice-step timeline."""
if self._next_control != self.control_count:
raise RuntimeError("cannot save an incomplete rollout")
destination = Path(path)
destination.parent.mkdir(parents=True, exist_ok=True)
np.savez_compressed(
destination,
ux=np.asarray(self.ux, dtype=np.float32),
uy=np.asarray(self.uy, dtype=np.float32),
lattice_steps=np.asarray(self.saved_steps, dtype=np.int64),
control_indices=(np.asarray(self.saved_steps, dtype=np.int64) - 1) // self.control_interval,
control_offsets=(np.asarray(self.saved_steps, dtype=np.int64) - 1) % self.control_interval + 1,
control_interval=np.asarray(self.control_interval, dtype=np.int64),
)
+14
View File
@@ -0,0 +1,14 @@
# OID handoff
There is no active redo entrypoint. Do not revive an archived runner in place.
Read `SCIENTIFIC_RESET.md`, `METHOD.md`, and `PHYSICS_CONTRACT.md`; then inspect the July status and migration manifest. Before writing collection or analysis code, choose exactly one question:
1. observable-resolving modes;
2. sensor state estimation;
3. policy sensitivity; or
4. physical correction mechanism.
These require different states, observables, interventions, metrics, and gates and must not be recombined as “importance.” Create a new study ID and new run IDs. Bind complete provenance: source tree/hash, action convention and units, body order, requested and effective action at field time, policy/checkpoint hash, parent checkpoint, DDF/FIFO state, observations, dual clocks/sample IDs, phase definition, geometry, metrics, and all generated artifacts. Never reuse `formal-three`, infer `COMPLETE`, or mutate the archived freeze.
Candidate checks are questions, not a prescribed redo: sensor-excluded or upstream-only states; explicit measurement-operator/POD baselines; rank/domain/metric sensitivity; task-error observables; force symmetry coordinates; distributed delays; independent parents; matched action interventions; closed-loop ablation; and momentum/vorticity/energy budgets.
+49
View File
@@ -0,0 +1,49 @@
# Claim-free OID method
## Scope and claim levels
- **Mathematical theorem/finite-dimensional consequence:** weighted POD orthogonality; least-squares normal equations; right-inverse identities on the generated range; LR minimum training mean-square state residual; LE minimum `G`-norm field among exact generated-range inverses.
- **Standard extension:** diagonal spatial quadrature/masks, sign-invariant MAC, principal angles, projector similarity, and delayed sample pairing.
- **Project choice:** state domain, metric `G`, observable metric `H`, POD rank, delay, truncation tolerance, and observable definition. Each changes the question.
- **Unsupported claim:** importance, causal effect, physical mechanism, controller necessity, controllability, or actuator/control energy inferred from reconstruction scores or modes.
For centered retained state coefficients `a` and observable POD coordinates `b`, the forward map is the minimum-MSE linear regression `C = Sigma_ba Sigma_aa^+`, so `b_hat=C a`. If `C` is rank deficient, active code either fails or explicitly restricts all identities to `range(C)`.
The LR inverse is `L_R = Sigma_aa C^T (C Sigma_aa C^T)^+`. On the generated range it is an exact right inverse and minimizes empirical state reconstruction MSE among exact linear inverses. “Minimum MSE linear inverse” does not mean most-probable nonlinear state.
For a declared positive-definite state-field metric `G`, the LE inverse is `L_E = G^-1 C^T (C G^-1 C^T)^+`. It minimizes the `G`-norm of the reconstructed field under the empirical mapping constraint. This is not control energy, actuator cost, controllability, or a NavierStokes energy principle.
`q_ctl-q_blk` is a same-parent, time-paired difference between closed-loop branches. Feedback changes future state and future action together; this difference is not an isolated action effect, autonomous perturbation, tangent state, or causal intervention estimate.
## Schlegel section and equation map
The active finite-dimensional implementation uses Schlegel et al.'s notation only where the mathematical objects match:
- Section 2.3, equations (2.8)-(2.10): the linear stochastic estimate `b = C a`, observable quadratic form, and EPOD context. Active `C = Sigma_ba Sigma_aa^+` is the fitted regression map, not raw cross-covariance.
- Section 2.4, equations (2.11)-(2.15): the delayed linear OID assumption, its finite-dimensional map, and modes obtained by applying an inverse map to observable POD directions. Active delayed pairing implements `b(t+tau) = C(tau) a(t)` without padding or crossing declared segments.
- Sections 2.5-2.6: pseudoinverse choice, LR/LE variants, snapshot algorithm, and delay selection context. This project fixes delay and rank by declared analysis choices; it does not infer causality from a fitted delay.
- Appendix, especially (A 1)-(A 4): generated-range projection/right-inverse constraint and least-residual versus least-energetic variational definitions. Active code restricts identities to `range(C)` when truncation is explicitly enabled.
Schlegel's application language about observer/control design is not inherited as a result here. In particular, this project's `G`-minimum is a declared state metric, not actuator energy or a Lyapunov-control certificate.
## Literature support matrix
| Source | Direct support | Boundary |
|---|---|---|
| Schlegel et al. (2012) | LR/LE forward/inverse construction and observable-linked modes | Does not license this project's importance, causal, or controller claims |
| Sirovich (1987) | Method of snapshots for POD | Weighting, masks, ranks, and domains here are project choices/standard extensions |
| Adrian (1994), LSE | Conditional linear minimum-mean-square estimation | A regression estimate is not causal or a most-probable nonlinear state |
| Borée (2003), EPOD | Correlated-field extension of POD and its connection to LSE; Schlegel identifies prefiltered LR-OID with EPOD | Equivalence requires the stated POD prefiltering and metric assumptions |
| Everson & Sirovich (1995), gappy POD | Least-squares recovery from incomplete snapshots in a fixed empirical basis | Active OID is not gappy POD; local sensors overlapping the state domain require explicit overlap analysis |
| Theiler et al. (1992) | Broad rationale for surrogate/null testing of time series | The project's circular-shift null, exclusions, and selection loop are not directly derived from Theiler |
Bibliographic leads do not upgrade project choices to theorems. Any new study must inspect the primary source and state its exact assumptions.
## Bibliography
- Schlegel, M., Noack, B. R., Jordan, P., Dillmann, A., Gröschel, E., Schröder, W., Wei, M., Freund, J. B., Lehmann, O., & Tadmor, G. (2012). “On least-order flow representations for aerodynamics and aeroacoustics.” *Journal of Fluid Mechanics*, 697, 367-398. DOI: 10.1017/jfm.2012.70.
- Sirovich, L. (1987). “Turbulence and the dynamics of coherent structures. I. Coherent structures.” *Quarterly of Applied Mathematics*, 45(3), 561-571. DOI: 10.1090/qam/910462.
- Adrian, R. J. (1994). “Stochastic estimation of conditional structure: a review.” *Applied Scientific Research*, 53, 291-303. DOI: 10.1007/BF00849106.
- Borée, J. (2003). “Extended proper orthogonal decomposition: a tool to analyse correlated events in turbulent flows.” *Experiments in Fluids*, 35, 188-192. DOI: 10.1007/s00348-003-0656-3.
- Everson, R., & Sirovich, L. (1995). “Karhunen-Loève procedure for gappy data.” *Journal of the Optical Society of America A*, 12(8), 1657-1664. DOI: 10.1364/JOSAA.12.001657.
- Theiler, J., Eubank, S., Longtin, A., Galdrikian, B., & Farmer, J. D. (1992). “Testing for nonlinearity in time series: the method of surrogate data.” *Physica D: Nonlinear Phenomena*, 58(1-4), 77-94. DOI: 10.1016/0167-2789(92)90102-S.
+22
View File
@@ -0,0 +1,22 @@
# Physics and rotation contract
This contract is source-derived. Historical labels do not override it.
## Coordinates and storage
- `CelerisLab/src/CelerisLab/body/geometry/circle.py` stores native linear index `k = x + y*nx`.
- `CelerisLab/src/CelerisLab/common/render.py` uses maintained `imshow(..., origin="lower")`; displayed `+y` is upward. Array storage and plotting orientation must not be silently conflated.
- Pinball body order is `[front, upper, lower]`, consistent with creation/order use in `src/drl_pinball/train/env_karman.py` and archived OID metadata.
## Rotation oracle
`LegacyCelerisLab/driver.py` stores rim direction components `(-ry/R, rx/R)` as `(y_c-y)/R, (x-x_c)/R`; `LegacyCelerisLab/kernels/kernel.cu` multiplies both by scalar surface action `u_s`. Thus `(Uw,Vw)=u_s(-ry/R,rx/R)`.
Modern `CelerisLab/src/CelerisLab/lbm/kernels/step/aux_kernels.cu` uses `(Uw,Vw)=(-Omega*ry, Omega*rx)`. Therefore `Omega=u_s/R` is the nominal continuum sign-and-unit conversion. It is not an exact discrete wall-velocity parity statement. Legacy normalizes the interface solid-cell center direction by nominal `R`; modern applies `Omega` to the boundary-hit lever arm. Those discrete vectors generally have different lengths and directions, so local wall speeds differ by the corresponding geometry factor even under the same nominal conversion.
The V5 policy adapter `src/drl_pinball/train/env_karman.py::_action_to_omega` uses
`Omega = -(a*scale + bias) U0/R`. Requested and EMA-effective action must be recorded separately in any future study.
The archived OID steady tuple `[0,-5.1,+5.1] U0` and the stated CCD tuple `[0,+5.1,-5.1] U0` are physically opposite under the same `[front,upper,lower]` ordering. Current CCD source `src/CCD_analysis/scripts/collect_vortex.py` contains `[0,-5.1,+5.1] U0`, contradicting that stated CCD label; treat this as unresolved documentation/source disagreement, not corroboration. Existing `src/steady_pinball_theory` documents also contain historical sign interpretations that conflict with this source-derived `Omega=u_s/R` oracle. They are outside this reset and were not edited.
The CPU oracle in `tests/test_physics_contract.py` binds the sign and nominal `Omega=u_s/R` conversion, and explicitly demonstrates the expected discrete geometry mismatch, without CUDA import or CFD initialization. Any future physics result must additionally record body IDs/order, requested and effective actions, action units, source hash, checkpoint hash, and plotting convention.
+6 -79
View File
@@ -1,85 +1,12 @@
# OID_analysis — Observable-Inferred Decomposition for Fluidic Pinball # OID analysis: reset state
Identifies which correction-field structures the DRL controller modulates, ranked by cross-correlation with force and signature observables (not by POD energy). The active package is a claim-free, CPU-only method core. It contains weighted snapshot POD, the Schlegel LR/LE linear maps and generated-range handling, sign-invariant mode/subspace metrics, exact/delayed pairing, and atomic artifact validation. It contains no current scientific result, scene runner, GPU path, plotting path, audit, freeze builder, or redo command.
## Quick Start The July 2026 two-scene work is preserved at `archive/studies/2026-07-two-scene-conditional/`. Its status is conditional: sensor high R² is partly self-observation because those sensors are local velocity functionals inside the state domain; force failed gates; no active LR/LE action result exists. Prediction or reconstruction does not imply importance, mechanism, causality, controller necessity, or control authority.
```bash Read `METHOD.md`, `SCIENTIFIC_RESET.md`, `PHYSICS_CONTRACT.md`, then `HANDOFF.md`. Active tests exercise only mathematics, alignment, atomic artifacts, and a CPU source oracle. Historical `OID_METHOD.md`, `PIPELINE.md`, and `RESULTS.md` moved with the study and are not active instructions.
# Read first: PIPELINE.md pipeline overview, scene table, conventions
# Deep dive: OID_knowledge.md rules, results table, bug history
# Tasks: OID_notes.md open items, handover
# Conclusions: Final_Conclusions.md six key questions answered
# Run (from repo root):
PYTHONPATH="src:$PYTHONPATH" conda run -n sr_env python3 \
src/OID_analysis/analysis/run_full_analysis.py --scene karman_re100 --force
```
## File Map ## Preservation status
``` These worktree files are not Git-preserved merely because they exist locally. Git preservation requires deliberate review, staging, and commit. Compact authority documents, manifests, source, and tests are intended to remain trackable; bulk NPZ/NPY payloads may remain ignored because recovery depends on the SHA-256-bound Optane archive inventories documented in `archive/RELOCATION.md`. Verify those manifests and restore into staging before using archived payloads.
src/OID_analysis/
├── PIPELINE.md ← START HERE — overview, scene table, conventions
├── OID_knowledge.md hard rules, full results, bugs
├── OID_notes.md task tracking, open items
├── Final_Conclusions.md six key questions answered
├── scene_registry.json machine-readable scene index + canonical values
├── configs.py single source of truth: 13 scene definitions
├── utils/ core library (CPU, no GPU dependency)
│ ├── analysis.py POD, force-OID, sig-OID, PCD, zone stats
│ └── cfd_interface.py re-exports from CCD_analysis
├── scripts/ GPU data collection (pycuda_3_10 env)
│ ├── collect_empty_channel.py / collect_pinball_baseline.py
│ ├── collect_karman_blk.py / collect_disturbance_only.py
│ ├── collect_controlled.py / collect_steady_cloak.py
│ ├── collect_illusion_qblk.py / collect_target_cylinder.py
│ ├── collect_all_data.py batch orchestrator
│ └── replay_full_fields.py full-field PPO replay
├── analysis/ CPU analysis pipeline (sr_env)
│ ├── phase1_correction_pod.py → phase7_whitebox.py (7 phases)
│ ├── robustness_analysis.py / steady_reanalysis.py
│ ├── compile_master_table.py / make_figures.py
│ └── run_full_analysis.py batch runner
├── data/ raw collected data (NOT committed)
│ ├── steady_cloak/ q_in, q_blk, q_ctl
│ ├── karman_cloak/ q_in, q_blk, q_ctl
│ ├── illusion/ q_ctl (3 diameters)
│ ├── target_cylinder/ reference targets
│ └── derived/ all computed results + 7 figures
├── papers/ reference papers
│ ├── Sch12.md OID original paper
│ └── Li22b.md pinball state estimation paper
├── docs/
│ └── sch12_code_mapping.md Sch12 formula → code traceability
├── tests/ unit tests (7/7 pass)
└── archive/ deprecated files
```
## Core Results (one table)
| Finding | Key Value | Confidence |
|---------|-----------|------------|
| Force-sig monotonic separation | +0.763 → -0.034 → -0.082 → -0.495 → -0.932 | High |
| OID beats POD (force prediction) | R²=0.44-0.75 (OID) vs -2.4~0.42 (POD) | High |
| OID beats POD (sig prediction) | R²=0.32-0.66 (OID) vs -0.16~0.06 (POD) | High |
| Action orthogonal to force+sig | |overlap|<0.33 across all scenes | Confirmed |
| Steady cloak RMS reduction | 99.4% | High |
| Li22b cross-map | modes 0-5 diagonal 0.81-0.98 | Verified |
| SR validation | OID z1 ↔ Cl_tot r=-0.82 | Verified |
Full numbers: `data/derived/master/master_table.json` · `scene_registry.json`
## Pitfalls
1. OID operates on **Δq_ctl = q_ctl q_blk**, not raw q_ctl.
2. **force-OID and sig-OID reported separately** — divergence is a mechanism result.
3. **Illusion q_blk uses separate geometry** (pinball_x=19/20.3, sensor_x=30).
4. Fields stored at **full 1280×512**; ROI mask applied only at analysis stage.
5. GPU: Karman on device 1, steady/illusion on device 3.
6. Conda: `pycuda_3_10` for GPU, `sr_env` for CPU analysis.
7. "2U" in model name = S_DIM=14, NOT 2× velocity.
+9
View File
@@ -0,0 +1,9 @@
# Scientific reset
The July 2026 study is historical and conditional, not an active result. The reset withdraws any reading that ranks sensor, force, or action “importance” from predictive or inverse-reconstruction performance.
Sensor observables are local velocity functionals sampled inside the state-field domain. Reconstructing them from a state that contains the same local flow structure creates partial self-observation; high sensor R² therefore cannot establish privileged mechanism or control importance. Force is an integrated surface projection with different dimension, noise, symmetry, and conditioning, and the force jobs failed required gates. Action is generated inside a feedback loop; delayed association does not isolate a commanded action from state-dependent policy response. These three observables are not exchangeable scores on one importance scale.
There is no active LR/LE action result. No archived score establishes mechanism, causality, controller necessity, actuator authority, controllability, or a paper conclusion. `q_ctl-q_blk` remains a paired closed-loop branch difference only.
Preservation is non-destructive. The study payload, failed gates, contradiction evidence, old entrypoints, plots, and frozen provenance remain under `archive/studies/2026-07-two-scene-conditional/`. Older material has a machine-readable disposition index at `archive/MATERIAL_DISPOSITION_INDEX.json`; uncertain data was retained.
@@ -0,0 +1,32 @@
{
"created_or_extracted_files": [
".gitignore",
"src/OID_analysis/README.md",
"src/OID_analysis/METHOD.md",
"src/OID_analysis/SCIENTIFIC_RESET.md",
"src/OID_analysis/PHYSICS_CONTRACT.md",
"src/OID_analysis/HANDOFF.md",
"src/OID_analysis/v2/__init__.py",
"src/OID_analysis/v2/analysis.py",
"src/OID_analysis/v2/alignment.py",
"src/OID_analysis/v2/artifacts.py",
"src/OID_analysis/tests/__init__.py",
"src/OID_analysis/tests/test_schlegel_oid.py",
"src/OID_analysis/tests/test_alignment_artifacts.py",
"src/OID_analysis/tests/test_physics_contract.py",
"src/OID_analysis/archive/MATERIAL_DISPOSITION_INDEX.json",
"src/OID_analysis/archive/studies/2026-07-two-scene-conditional/STATUS.md",
"src/OID_analysis/archive/studies/2026-07-two-scene-conditional/lineage/CURRENT_STATE_MIGRATION_MANIFEST.json"
],
"deleted_files": [],
"moved_file_count": 516,
"moved_files_manifest": "src/OID_analysis/archive/studies/2026-07-two-scene-conditional/lineage/CURRENT_STATE_MIGRATION_MANIFEST.json",
"notes": [
"No commit created",
"Plan file not edited",
"Nowledge memories were reviewed and updated after the filesystem reset",
"No CFD/GPU initialization",
"Final-review hardening edited only active files already enumerated above; the original 516-file migration count and manifest remain unchanged"
],
"schema_id": "oid-scientific-reset-changeset/v1"
}
@@ -0,0 +1,71 @@
{
"entries": [
{
"disposition": "conditional-historical-study",
"path": "archive/studies/2026-07-two-scene-conditional",
"reason": "July 2026 source, evidence, lineage, and presentation preserved with conditional status"
},
{
"disposition": "superseded-method",
"path": "archive/legacy_oid_v1",
"reason": "preserves v1 methods, PLS mislabeling, and error history"
},
{
"disposition": "preserve-evidence",
"path": "archive/v2_development_code",
"reason": "development source and compact evidence retained"
},
{
"disposition": "unknown-provenance",
"path": "archive/v2_development_runs",
"reason": "relocation stub and external hash-bound payload retained"
},
{
"disposition": "unknown-provenance",
"path": "archive/materials/pre-reset-active/data",
"reason": "18 GB local older data retained conservatively"
},
{
"disposition": "superseded-method",
"path": "archive/materials/pre-reset-active/data_li22b",
"reason": "sensor reconstruction context retained"
},
{
"disposition": "preserve-contradiction",
"path": "provenance/contradictions/steady_reanalysis.json",
"reason": "steady physics contradiction evidence; now within July study lineage"
},
{
"disposition": "preserve-evidence",
"path": "archive/RELOCATION_MANIFEST.json",
"reason": "hash-bound external relocation authority"
},
{
"disposition": "preserve-evidence",
"path": "archive/RUNS_RELOCATION_MANIFEST.json",
"reason": "hash-bound run relocation authority"
},
{
"disposition": "preserve-evidence",
"path": "archive/materials/pre-reset-active/papers",
"reason": "preserved local literature notes used by older work"
},
{
"disposition": "superseded-method",
"path": "archive/legacy_oid_v1/utils",
"reason": "preserved executable history; inactive legacy utilities"
},
{
"disposition": "preserve-evidence",
"path": "archive/materials/pre-reset-active/legacy_inventory.json",
"reason": "inventory of pre-reset material and original path identities"
},
{
"disposition": "preserve-evidence",
"path": "archive/materials/pre-reset-active/data/configs/legacy",
"reason": "preserved legacy solver configuration files at their current archive path"
}
],
"policy": "conservative preservation; no payload deleted during scientific reset",
"schema_id": "oid-material-disposition/v1"
}
+78
View File
@@ -0,0 +1,78 @@
# OID archive relocation
Large historical OID payloads were relocated on 2026-07-22 using the selected **move + local stub, no symlink** policy.
- Archive root: `/home/frank14f/optane/DynamisLab-backups/OID_analysis-archive-20260722/`
- Payload root: `/home/frank14f/optane/DynamisLab-backups/OID_analysis-archive-20260722/payload/`
- Local manifest: `RELOCATION_MANIFEST.json`
- Manifest SHA-256: `0f4a7c25b0e76ec60a9c2229260917444d2420fccf101b091a5aa0b2f34a32aa`
- Verified payload: 250 files, 6,240,991,692 bytes
The relocation manifest contains each source-relative path, byte size, mtime, and SHA-256. The destination was independently rehashed before publication. The local manifest is sufficient to inspect provenance without mounting Optane.
## Restore
From the repository root, first verify the manifest and perform a checksum-only dry run:
```bash
sha256sum -c /home/frank14f/optane/DynamisLab-backups/OID_analysis-archive-20260722/source_manifest.sha256
rsync -a --checksum --dry-run /home/frank14f/optane/DynamisLab-backups/OID_analysis-archive-20260722/payload/ ./
```
After reviewing the dry-run output, restore with the same command without `--dry-run`. Never restore over newly generated files; use a clean checkout or review every conflict.
## Local evidence retained
- `../provenance/contradictions/steady_reanalysis.json`: small contradiction evidence consumed by the active audit.
- `../provenance/parents/illusion_1.0L/`: Illusion parent checkpoint, trajectory, and manifest needed to close formal lineage.
The formal Kármán branch metadata records checkpoint bundle SHA-256 `2c98a02d6309b597f1aeb927324c4ddd7d318a47ac345fcd70a4b2cd897d83b7`, but that bundle was not present in the development archive at relocation time. This is a fail-closed lineage limitation and must be resolved by creating a new independently initialized parent for subsequent realizations; the old formal run remains regression evidence only.
## Phase 0 active-runs relocation (2026-07-23)
The active OID runtime payloads were compacted under the same **copy, independently SHA-256 verify, then remove local payload; local stub; no symlink** convention.
- Archive root: `/home/frank14f/optane/DynamisLab-backups/OID_analysis-runs-phase0-20260723/`
- Payload root: `/home/frank14f/optane/DynamisLab-backups/OID_analysis-runs-phase0-20260723/payload/`
- Archive and local inventory: `RUNS_RELOCATION_MANIFEST.json`
- Inventory SHA-256: `97a7611a7788d473081fb0c5498fd868d927f055d70910e818a9007eb5e69e67`
- Verified archive: 202 files, 71,144,300,428 bytes
- Relocated local NPZ payloads: 94 files, 71,144,174,465 bytes
- Compact local `runs/` size after relocation: 151,691 logical file bytes (about 792 KiB allocated)
- Active evidence entry: `../runs/ACTIVE_EVIDENCE.json`
- Active evidence SHA-256 at publication: `1f381aef568e850a2d416a398e0301ad11b244ddc5a9199588a3b25f42a14d87`
The archive contains the complete pre-relocation `src/OID_analysis/runs` inventory, including the six corrected formal-three paired realizations, six parent bundles, invalid-telemetry quarantine, legacy formal runtime payloads, and rebuildable canonical/analysis payloads. The worktree retains compact branch, paired, parent, twin-gate, canonical, audit, quarantine, and analysis reports. `ACTIVE_EVIDENCE.json` is the single manifest-driven entry for the six trusted paired realizations and their archive-bound payload hashes.
### Formal COMPLETE status
No `COMPLETE.json` was created. This is intentional fail-closed behavior. The six `formal-three` realization directories have neither the original `RUNNING.json` state nor a canonical run-level `oid-formal-run/v2` manifest that binds all artifacts and the three-realization contract. Their branch, parent, paired-collection, twin-gate, and canonical-product hashes were verified before relocation, but retroactively inventing an immutable COMPLETE transition would not be faithful. They remain **verified existing lineage, not strict-loader COMPLETE runs**.
### Verify without restoring
From the repository root:
```bash
sha256sum src/OID_analysis/archive/RUNS_RELOCATION_MANIFEST.json
sha256sum /home/frank14f/optane/DynamisLab-backups/OID_analysis-runs-phase0-20260723/RUNS_RELOCATION_MANIFEST.json
cmp src/OID_analysis/archive/RUNS_RELOCATION_MANIFEST.json \
/home/frank14f/optane/DynamisLab-backups/OID_analysis-runs-phase0-20260723/RUNS_RELOCATION_MANIFEST.json
```
Both SHA-256 commands must print `97a7611a7788d473081fb0c5498fd868d927f055d70910e818a9007eb5e69e67`, and `cmp` must produce no output.
### Restore selected or all runtime payloads
Always restore into an empty staging directory first; never overwrite the compact evidence tree or newly generated files.
```bash
mkdir -p /tmp/oid-runs-restore
rsync -a --checksum --dry-run \
/home/frank14f/optane/DynamisLab-backups/OID_analysis-runs-phase0-20260723/payload/src/OID_analysis/runs/ \
/tmp/oid-runs-restore/
rsync -a --checksum \
/home/frank14f/optane/DynamisLab-backups/OID_analysis-runs-phase0-20260723/payload/src/OID_analysis/runs/ \
/tmp/oid-runs-restore/
```
Verify staged files against `RUNS_RELOCATION_MANIFEST.json` before copying selected payloads back. Paths in the inventory are repository-relative. Review every destination conflict manually; do not use `--delete` and do not restore the archived compact JSON over current compact evidence.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,19 @@
# Legacy OID v1 archive
Archived on 2026-07-22 to prevent unverified results from being confused with the two-scene trusted baseline.
## Status
This directory is historical, read-only, and non-executable. Do not run its scripts, import its modules, or use its numerical claims in a paper. The old pipeline used index-based field subtraction, leaky decomposition fitting, ambiguous signed mode overlap, and a cross-covariance SVD labelled as Schlegel OID without implementing Schlegel et al.'s LR-OID or LE-OID pseudoinverse construction.
## Contents
- `analysis/`: old seven-phase CPU analysis and figure generation.
- `scripts/`: old GPU collection/replay scripts. They are retained only for provenance and must not be run as the trusted pipeline.
- `data_derived/`: generated v1 products and figures. Source raw data remain in the original immutable `data/` tree.
- `documents/`: old conclusions, pipeline descriptions, and result tables.
- `li22b/`: historical Li22b reproduction scripts. The associated `data_li22b/` tree remains separately marked archive/read-only because the large flow fields have already been removed.
## Archive-only warning
Nothing in this archive is an active workflow or supported entrypoint. Do not run archived commands or treat archived documents, scripts, or results as current guidance. For the current active scope and recovery state, read `../../README.md`, `../../SCIENTIFIC_RESET.md`, and `../../HANDOFF.md`. For the July historical study record only, see `../studies/2026-07-two-scene-conditional/STATUS.md`; that status is provenance, not an active pipeline.
@@ -0,0 +1 @@
Relocated. See ../../RELOCATION.md and ../../RELOCATION_MANIFEST.json.
@@ -0,0 +1,85 @@
# OID_analysis — Observable-Inferred Decomposition for Fluidic Pinball
Identifies which correction-field structures the DRL controller modulates, ranked by cross-correlation with force and signature observables (not by POD energy).
## Quick Start
```bash
# Read first: PIPELINE.md pipeline overview, scene table, conventions
# Deep dive: OID_knowledge.md rules, results table, bug history
# Tasks: OID_notes.md open items, handover
# Conclusions: Final_Conclusions.md six key questions answered
# Run (from repo root):
PYTHONPATH="src:$PYTHONPATH" conda run -n sr_env python3 \
src/OID_analysis/analysis/run_full_analysis.py --scene karman_re100 --force
```
## File Map
```
src/OID_analysis/
├── PIPELINE.md ← START HERE — overview, scene table, conventions
├── OID_knowledge.md hard rules, full results, bugs
├── OID_notes.md task tracking, open items
├── Final_Conclusions.md six key questions answered
├── scene_registry.json machine-readable scene index + canonical values
├── configs.py single source of truth: 13 scene definitions
├── utils/ core library (CPU, no GPU dependency)
│ ├── analysis.py POD, force-OID, sig-OID, PCD, zone stats
│ └── cfd_interface.py re-exports from CCD_analysis
├── scripts/ GPU data collection (pycuda_3_10 env)
│ ├── collect_empty_channel.py / collect_pinball_baseline.py
│ ├── collect_karman_blk.py / collect_disturbance_only.py
│ ├── collect_controlled.py / collect_steady_cloak.py
│ ├── collect_illusion_qblk.py / collect_target_cylinder.py
│ ├── collect_all_data.py batch orchestrator
│ └── replay_full_fields.py full-field PPO replay
├── analysis/ CPU analysis pipeline (sr_env)
│ ├── phase1_correction_pod.py → phase7_whitebox.py (7 phases)
│ ├── robustness_analysis.py / steady_reanalysis.py
│ ├── compile_master_table.py / make_figures.py
│ └── run_full_analysis.py batch runner
├── data/ raw collected data (NOT committed)
│ ├── steady_cloak/ q_in, q_blk, q_ctl
│ ├── karman_cloak/ q_in, q_blk, q_ctl
│ ├── illusion/ q_ctl (3 diameters)
│ ├── target_cylinder/ reference targets
│ └── derived/ all computed results + 7 figures
├── papers/ reference papers
│ ├── Sch12.md OID original paper
│ └── Li22b.md pinball state estimation paper
├── docs/
│ └── sch12_code_mapping.md Sch12 formula → code traceability
├── tests/ unit tests (7/7 pass)
└── archive/ deprecated files
```
## Core Results (one table)
| Finding | Key Value | Confidence |
|---------|-----------|------------|
| Force-sig monotonic separation | +0.763 → -0.034 → -0.082 → -0.495 → -0.932 | High |
| OID beats POD (force prediction) | R²=0.44-0.75 (OID) vs -2.4~0.42 (POD) | High |
| OID beats POD (sig prediction) | R²=0.32-0.66 (OID) vs -0.16~0.06 (POD) | High |
| Action orthogonal to force+sig | |overlap|<0.33 across all scenes | Confirmed |
| Steady cloak RMS reduction | 99.4% | High |
| Li22b cross-map | modes 0-5 diagonal 0.81-0.98 | Verified |
| SR validation | OID z1 ↔ Cl_tot r=-0.82 | Verified |
Full numbers: `data/derived/master/master_table.json` · `scene_registry.json`
## Pitfalls
1. OID operates on **Δq_ctl = q_ctl q_blk**, not raw q_ctl.
2. **force-OID and sig-OID reported separately** — divergence is a mechanism result.
3. **Illusion q_blk uses separate geometry** (pinball_x=19/20.3, sensor_x=30).
4. Fields stored at **full 1280×512**; ROI mask applied only at analysis stage.
5. GPU: Karman on device 1, steady/illusion on device 3.
6. Conda: `pycuda_3_10` for GPU, `sr_env` for CPU analysis.
7. "2U" in model name = S_DIM=14, NOT 2× velocity.
@@ -0,0 +1,3 @@
# Superseded Sch12 mapping warning
`sch12_code_mapping.md` is retained only to document an earlier, incorrect interpretation. Its assertion that standardized cross-covariance SVD is Schlegel LR-OID is false: cross-covariance omits the state-covariance inverse and constructs neither the LR nor LE generalized inverse. Use `../../../OID_METHOD.md` and active `../../../v2/analysis.py` instead.
@@ -0,0 +1,84 @@
# Sch12 → OID Code Mapping
> Map from Schlegel et al. (2012) "On least-order flow representations" to `src/OID_analysis/utils/analysis.py`.
> Date: 2026-06-28
---
## OID Variant Identification
**Our implementation**: Cross-covariance SVD in POD coefficient subspace. Finds directions in r-dimensional POD space that maximize correlation with the observable. Closest to Sch12's **LR-OID (least-residual) / EPOD** approach solved in coefficient space, implemented via Canonical Correlation Analysis restricted to POD subspace.
**NOT implemented**: Sch12's LE-OID (least-energetic) variant that uses Moore-Penrose pseudoinverse and projects onto row vectors of C (Eq 2.25).
**Rationale**: LR-OID identifies the most correlated structures for observer design — appropriate for our goal of finding which correction structures most strongly relate to force/signature observables.
---
## Formula → Code Mapping
| Sch12 Eq | Formula | analysis.py Function | Lines | Implementation Correct? |
|----------|---------|---------------------|-------|------------------------|
| (2.3) | `Q^Ω = ⟨∫ u'·u' dx⟩` | `compute_pod` (energy from S²) | 66-68 | YES — `energy = S²/ΣS²` |
| (2.6a) | `a(t) = [a₁,...,a_N]^T` | `compute_pod` returns `coefs` (N,r) | 73 | YES — POD coefs as (N_samples × r_rank) |
| (2.7) | `Q^E(a) = ⟨a·a⟩` | `compute_pod``standardize` | 303-309 | YES — standardized to mean=0, std≈1 |
| (2.8) | `b = C a` (LSE) | `compute_force_oid``C_AY = (1/N)A^T@Y` | 103 | YES — `C_AY` is the LSE estimate of C |
| (2.17) | `⟨u⟩ = (1/K)Σ u(t^i)` | `compute_pod``mean = np.mean(snapshots, axis=0)` | 35 | YES — arithmetic mean of snapshots |
| (2.18) | POD expansion | `compute_pod` → coefs @ modes.T + mean | 35,73 | YES — snapshot-to-coef representation |
| (2.20) | `(u',v')_A = (C_A u', C_A v')_Γ` | `compute_force_oid` cross-covariance | 103-105 | YES — approximation via C_AY SVD |
| (2.22) | `R_u^OID = (1/K)[C a^j · C a^k]` | OID coords `z = A @ U` | 105 | Equivalent in coefficient space |
| (2.23) | Eigenvalue eq `R_u^OID c^[i] = λ_i^p c^[i]` | `SVD(C_AY)` → S, U | 104-105 | YES — SVD solves it directly |
| (2.24) | `u_i^A = Σ d_j^[i] u^j` | `reconstruct_oid_modes(pod_modes, U)` | 327 | YES — `psi_OID = Phi @ U` |
| §2.6 | Time delay τ | `phase2_build_observables.py``sensor_error_delayed` | — | YES — tau_c shifts observable |
---
## Cross-Covariance vs Expectation
Sch12 uses expectation `⟨b a^T⟩` which estimates C in (2.8). Our `(1/N)*A^T@Y` is the sample estimate of this expectation from N snapshots. **Mathematically equivalent** for empirical data.
## Standardization
Sch12 POD coefs are zero-mean by construction (POD removes mean). We additionally standardize to unit variance:
```python
std = np.where(std < 1e-12, 1.0, std) # prevent div-by-zero
X_std = (X - mean) / std
```
This is a standard preprocessing step that ensures each POD coefficient (and observable channel) contributes equally to the cross-covariance, regardless of physical units. **Not specified in Sch12 but mathematically valid** — it's equivalent to using a weighted inner product.
## PCD Whitening
```python
C_AA_inv_half = sqrtm(inv(C_AA_reg)).real
C_PP_inv_half = sqrtm(inv(C_PP_reg)).real
K = C_AA_inv_half @ C_AP @ C_PP_inv_half
```
This is standard **Canonical Correlation Analysis** (CCA) restricted to POD subspace. The Tikhonov regularization (`+eps*eye(r)`) ensures numerical stability for near-singular covariance matrices. Taking `.real` after `sqrtm` handles any negligible imaginary components from numerical rounding.
## POD: Method of Snapshots
When N_samples < N_DOF:
```python
C = Q @ Q.T # (N, N) — much smaller than (DOF, DOF)
eigvals, eigvecs = eigh(C)
modes = (Q.T @ eigvecs) / S
```
Standard method-of-snapshots. The division by S+1e-30 is the correct recovery formula: `phi_i = (1/σ_i) * Q^T * v_i`.
---
## Known Deviations from Sch12
1. **No Moore-Penrose pseudoinverse**: We don't implement `C^- q_i` (Eq 2.14). Instead we compute directions in POD space via cross-covariance SVD. This is the LR-OID variant solved in coefficient space, not the LE-OID variant.
2. **No OID snapshot matrix**: We don't compute `R_u^OID` (Eq 2.22) in physical snapshot space. We operate entirely in POD coefficient space, which is equivalent due to the orthogonality of POD modes.
3. **Standardization not in Sch12**: We z-score both A and Y. Sch12 assumes POD coefs have intrinsic energy weighting. Our standardization removes this weighting, making it a pure correlation-based analysis.
4. **PCD has no direct Sch12 equivalent**: The whitened CCA (`compute_pcd`) extends OID by equalizing variance across all POD coefs and observable channels before computing cross-correlation. This is a natural extension but not described in Sch12.
---
## Verdict
The implementation is **mathematically sound and correctly applies the Sch12 framework**. The key insight is that we implement the LR-OID (least-residual / EPOD) variant via cross-covariance SVD in POD coefficient space, which is the most appropriate variant for our goal of identifying observable-relevant correction structures. The deviation from Sch12's LE-OID is intentional — LE-OID minimizes energy (suited for control design), while LR-OID maximizes correlation (suited for structure identification, which is our goal).
@@ -108,9 +108,11 @@ def test_oid_field_reconstruction():
# Reconstruct in standardized space: z @ psi_OID.T = A_std @ U @ U^T @ Phi.T # Reconstruct in standardized space: z @ psi_OID.T = A_std @ U @ U^T @ Phi.T
# Since U is square orthogonal, this = A_std @ Phi.T # Since U is square orthogonal, this = A_std @ Phi.T
psi_oid = reconstruct_oid_modes(Phi, U) # (DOF, r) # Standardized POD coordinates map to physical directions through Phi D U.
q_std_oid = z @ psi_oid.T # standardized reconstruction _, _, a_scale = standardize(A)
q_std_direct = A_std @ Phi.T # direct standardized reconstruction psi_oid = reconstruct_oid_modes(Phi, U, a_scale) # (DOF, r)
q_std_oid = z @ psi_oid.T
q_std_direct = A_std @ np.diag(a_scale.reshape(-1)) @ Phi.T
rel_err = np.mean((q_std_oid - q_std_direct)**2) / \ rel_err = np.mean((q_std_oid - q_std_direct)**2) / \
(np.mean(q_std_direct**2) + 1e-30) (np.mean(q_std_direct**2) + 1e-30)
assert rel_err < 1e-6, f"Reconstruction error: {rel_err:.10f}" assert rel_err < 1e-6, f"Reconstruction error: {rel_err:.10f}"
@@ -57,13 +57,20 @@ def compute_pod(
modes = Vt.T # (DOF, N) modes = Vt.T # (DOF, N)
coefs = U * S # (N, N) coefs = U * S # (N, N)
# Truncate # Energy is always normalized by the complete snapshot spectrum. The old
if rank is not None and rank < N: # implementation normalized after truncation, making every retained rank
# incorrectly appear to capture 100% of fluctuation energy.
total_energy = np.sum(S ** 2)
# Truncate only after preserving the full-spectrum denominator.
max_rank = min(N, DOF)
if rank is not None:
if type(rank) is not int or rank < 1 or rank > max_rank:
raise ValueError(f"rank must be in [1, {max_rank}]")
modes = modes[:, :rank] modes = modes[:, :rank]
S = S[:rank] S = S[:rank]
coefs = coefs[:, :rank] coefs = coefs[:, :rank]
total_energy = np.sum(S ** 2)
energy = (S ** 2) / total_energy if total_energy > 0 else np.zeros_like(S) energy = (S ** 2) / total_energy if total_energy > 0 else np.zeros_like(S)
cum_energy = np.cumsum(energy) cum_energy = np.cumsum(energy)
@@ -312,19 +319,23 @@ def standardize(X: np.ndarray) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
def reconstruct_oid_modes( def reconstruct_oid_modes(
pod_modes: np.ndarray, pod_modes: np.ndarray,
U_oid: np.ndarray, U_oid: np.ndarray,
coefficient_scale: Optional[np.ndarray] = None,
) -> np.ndarray: ) -> np.ndarray:
"""Reconstruct OID spatial modes from POD modes and OID rotation. """Reconstruct physical PLS-type directions from standardized POD space.
psi_k_OID = sum_j U_{jk} * phi_j If decomposition directions ``U_oid`` were fitted to standardized POD
coefficients ``(A - mean) / scale``, the physical field direction is
Args: ``Phi @ diag(scale) @ U``. ``coefficient_scale=None`` is retained only for
pod_modes: (DOF, r) POD spatial modes legacy callers whose coefficients were not standardized.
U_oid: (r, r) OID rotation matrix
Returns:
oid_modes: (DOF, r) OID spatial modes
""" """
return pod_modes @ U_oid modes = np.asarray(pod_modes)
directions = np.asarray(U_oid)
if coefficient_scale is None:
return modes @ directions
scale = np.asarray(coefficient_scale).reshape(-1)
if modes.shape[1] != len(scale) or directions.shape[0] != len(scale):
raise ValueError("pod_modes, coefficient_scale, and U_oid dimensions differ")
return modes @ (scale[:, None] * directions)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -0,0 +1,9 @@
{
"multi_gpu": false,
"gpu_connection": "NVLink",
"required_cuda_capability": "7.0",
"threads_per_block": 128,
"X_1U": 128,
"Y_1U": 32,
"Z_1U": 1
}
@@ -0,0 +1,13 @@
{
"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"]
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 375 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

Some files were not shown because too many files have changed in this diff Show More