From 6be553822f4376c5475980bfc481af932f1effdb Mon Sep 17 00:00:00 2001
From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com>
Date: Mon, 3 Aug 2026 19:31:52 +0800
Subject: [PATCH 1/9] feat: add frequency response run reports
---
.github/workflows/ci.yml | 2 +-
README.md | 26 +-
doc/README_EN.md | 19 +-
...77\347\224\250\346\214\207\345\215\227.md" | 85 +++
...23\345\207\272\346\240\274\345\274\217.md" | 27 +
pyproject.toml | 2 +
src/wavebench/cli.py | 25 +-
src/wavebench/cli_parser.py | 9 +
src/wavebench/config.py | 9 +-
src/wavebench/data/packages.py | 28 +-
src/wavebench/report/html.py | 500 +++++++++++++++--
src/wavebench/services/frequency_response.py | 513 ++++++++++++++++++
src/wavebench/services/run_analysis.py | 5 +
src/wavebench/services/run_plan.py | 120 ++++
src/wavebench/services/run_safety.py | 6 +
src/wavebench/services/run_service.py | 280 +++++++++-
src/wavebench/services/run_templates.py | 88 +++
tests/test_cli.py | 108 ++++
tests/test_frequency_response.py | 270 +++++++++
tests/test_packages.py | 18 +
tests/test_report.py | 146 ++++-
tests/test_run_plan.py | 109 ++++
tests/test_run_service.py | 218 ++++++++
tests/test_run_templates.py | 54 +-
24 files changed, 2590 insertions(+), 77 deletions(-)
create mode 100644 src/wavebench/services/frequency_response.py
create mode 100644 tests/test_frequency_response.py
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index caa5ea5..4af28da 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -27,7 +27,7 @@ jobs:
- name: Install package and test dependencies
run: |
python -m pip install --upgrade pip
- python -m pip install -e ".[dev]"
+ python -m pip install -e ".[dev,analysis,pdf]"
- name: Run Ruff
run: python -m ruff check .
diff --git a/README.md b/README.md
index b3e3a8e..399cd4d 100644
--- a/README.md
+++ b/README.md
@@ -107,11 +107,11 @@ WaveBench 主包长期预装 RTM2000/RTM2032、DS1104Z/DS1000Z、DG4000/DG4202
- `run check --plan `:只解析并汇总 plan,不连接仪器
- `run verify --plan `:只读查询 plan 涉及仪器的高阻保护状态与 `*IDN?`,用于执行前预检可达性
-- `run template --list` / `run template --output `:列出或生成保守 run plan 模板;可用 `--frequency`、`--vpp`、`--source-channel` 等少量参数定制;不连接仪器,不覆盖已有文件,除非显式 `--force`
-- `run plan --plan `:执行显式 source、power、scope、dmm、sleep 步骤;一次 run 内统一打开并复用所需仪器 session,成功或失败后统一关闭,不静默断线重连
-- `run report `:根据 `run.json` / `summary.csv` 生成静态离线 HTML 报告,包含信号分析指标、DMM 读数卡片、实验证据摘要、产物链接、证据时间线和截图
+- `run template --list` / `run template --output `:列出或生成保守 run plan 模板;可用 `--frequency`、`--frequencies`、`--reference-channel`、`--response-channel`、`--fit` 等少量参数定制;不连接仪器,不覆盖已有文件,除非显式 `--force`
+- `run plan --plan `:执行显式 source、power、scope、dmm、sleep 和双通道 `sweep.frequency_response` 步骤;一次 run 内统一打开并复用所需仪器 session,成功或失败后统一关闭,不静默断线重连
+- `run report `:根据 `run.json` / `summary.csv` 生成静态离线 HTML 报告;频响 run 额外包含幅频、相频、拟合对比、逐点 CSV 与采集证据链接。加 `--pdf` 可同时导出嵌入截图、SVG 和表格的便携 PDF
- `capture inspect `:打印离线采集包摘要
-- 默认示波器高阻保护:`scope.capture` / `scope.fetch` / `sweep discrete` / run-plan `scope.capture` 在采集前查询通道耦合。RTM2032 的 `DCL`/`ACL` 视为高阻,`DC`/`AC` 默认按可能的 50 Ω 拒绝;DS1000Z 输入固定为 1 MΩ,`AC`/`DC`/`GND` 只表示耦合方式,均按该机型语义检查。WaveBench 不会自动修改耦合或输入设置
+- 默认示波器高阻保护:`scope.capture` / `scope.fetch` / `sweep discrete` / run-plan `scope.capture` / `sweep.frequency_response` 在采集前查询通道耦合。频响会同时保护 reference 与 response 两路;RTM2032 的 `DCL`/`ACL` 视为高阻,`DC`/`AC` 默认按可能的 50 Ω 拒绝;DS1000Z 输入固定为 1 MΩ,`AC`/`DC`/`GND` 只表示耦合方式,均按该机型语义检查。WaveBench 不会自动修改耦合或输入设置
- 可选 `[restore] source_state = true`:在 `finally` 路径快照并恢复 basic 信号源通道状态(输出、函数、频率、Vpp、方波占空比)。该选项不恢复 offset、phase、frequency mode、sweep、负载、极性、噪声、同步、burst、调制、marker、pulse hold 或易失任意波内存;run artifact 以 `source_state_scope = "basic"` 明示范围
- run 输出位于 `data/runs/_/`,包含 `run.json`、`summary.csv`、步骤记录、质量状态和普通采集包引用
- `scope.capture` 可启用 `quality_gate = true`;配合 `auto_recover = true` 时,质量告警会触发最多 `[quality].auto_recover_attempts` 次 autoscale + 重采
@@ -206,13 +206,17 @@ python3 -m venv .venv
cp wavebench.example.toml wavebench.toml
```
-需要运行测试和代码检查时安装开发依赖;需要终端 TUI 时安装 `tui` extra:
+需要运行测试和代码检查时安装开发依赖;频响 PCHIP 拟合需要 `analysis` extra;离线 PDF 报告需要 `pdf` extra;终端 TUI 需要 `tui` extra:
```bash
.venv/bin/python -m pip install -e ".[dev]"
+.venv/bin/python -m pip install -e ".[analysis]"
+.venv/bin/python -m pip install -e ".[pdf]"
.venv/bin/python -m pip install -e ".[tui]"
```
+`pdf` 使用 WeasyPrint。Linux / WSL 上若导入或渲染失败,请按发行版安装 Cairo、Pango、GDK-PixBuf 及可显示中文的字体;这些是操作系统渲染依赖,不会由 WaveBench 修改或安装。
+
编辑 `wavebench.toml`,填写实际使用的 VISA/串口 resource,并删除或禁用不属于当前实验台的仪器段。示例配置使用内建短名:
| 仪器族 | 内建 `driver` 短名 |
@@ -489,6 +493,18 @@ python -m wavebench run plan --config wavebench.toml --plan plans/example_scope_
python -m wavebench run report data/runs/
```
+生成双通道频响模板(CH1 接 DUT 输入,CH2 接 DUT 输出),通过 `run check` / `run verify` 后才允许执行。`--fit` 会生成线性对数插值、多项式和 PCHIP 拟合配置,因此需要先安装 `.[analysis]`:
+
+```powershell
+python -m wavebench run template source-scope-frequency-response --frequencies 100,1000,10000 --reference-channel 1 --response-channel 2 --fit --output plans/frequency_response.toml
+python -m wavebench run check --config wavebench.toml --plan plans/frequency_response.toml
+python -m wavebench run verify --config wavebench.toml --plan plans/frequency_response.toml
+python -m wavebench run plan --config wavebench.toml --plan plans/frequency_response.toml
+python -m wavebench run report data/runs/ --pdf
+```
+
+PDF 是“可见报告”的单文件封装:截图、静态 SVG 曲线和表格会嵌入 PDF;`frequency_response.csv`、拟合 JSON、NPY 波形和完整采集包仍保留在 run 目录,适合复算和审计。
+
DMM ACV smoke 示例:
```powershell
diff --git a/doc/README_EN.md b/doc/README_EN.md
index 6bf4475..4ce186a 100644
--- a/doc/README_EN.md
+++ b/doc/README_EN.md
@@ -2,7 +2,7 @@
[中文文档](README.md) | English
-WaveBench is a lightweight Python measurement bench for explicit, reproducible control of laboratory instruments. It currently covers oscilloscope capture, signal-generator and power-supply control, digital-multimeter reads, multi-instrument run plans, offline reports, and trusted executable instrument plugins.
+WaveBench is a lightweight Python measurement bench for explicit, reproducible control of laboratory instruments. It currently covers oscilloscope capture, signal-generator and power-supply control, digital-multimeter reads, multi-instrument run plans, offline reports, two-channel source/scope frequency-response measurements, and trusted executable instrument plugins.
The WaveBench distribution includes built-in drivers for the RTM2000/RTM2032, DS1104Z/DS1000Z, DG4000/DG4202, DP800, and DM3000/DM3058 families. These five families are the permanent bundled baseline: first use does not require an external plugin, and they are not scheduled for removal from the main package. External packages are optional, independently released upgrades or extensions. A narrowly allowlisted package may take over a canonical ID, while built-in short names remain pinned to the bundled implementation and uninstalling the package restores the bundled canonical implementation where the IDs are shared.
@@ -56,6 +56,23 @@ The Textual interface is an optional extra. In a source checkout, install it wit
Its supported product scope is intentionally frozen to the power-supply, DMM, and signal-source panels. CLI commands, run plans, and services remain the primary interfaces; the TUI is not a run-plan editor, plugin manager, full oscilloscope viewer, or reporting system.
+## Optional frequency-response analysis and PDF reports
+
+`sweep.frequency_response` sets the source through explicit frequency points and captures a reference (DUT input) and response (DUT output) scope channel in one acquisition per point. It writes a point-by-point `frequency_response.csv` with linear/dB gain and wrapped/unwrapped output-relative phase. The offline HTML report renders magnitude, phase, fit-comparison SVGs, the point table, and any saved per-point screenshots.
+
+Use the conservative template before editing a plan manually:
+
+```bash
+python -m pip install -e ".[analysis,pdf]"
+python -m wavebench run template source-scope-frequency-response \
+ --frequencies 100,1000,10000 --reference-channel 1 --response-channel 2 \
+ --fit --output plans/frequency_response.toml
+python -m wavebench run check --plan plans/frequency_response.toml
+python -m wavebench run report data/runs/ --pdf
+```
+
+`linear_log` and `polynomial` fit linear gain against `log10(frequency_hz / Hz)`; PCHIP additionally needs the `analysis` extra. The PDF is a portable visual report: its visible screenshots, SVG charts, and tables are embedded, while CSV/JSON/NPY evidence stays as separate artifacts for reproducible analysis. WeasyPrint also relies on platform rendering libraries (Cairo, Pango, GDK-PixBuf) and suitable CJK fonts where needed.
+
Executable plugins use canonical IDs and cannot define aliases. Built-in IDs are protected except for narrowly allowlisted optional-override slots that bind one canonical ID to one distribution. The current shared-ID slots cover DG4000, DM3000, DP800, and RTM2000. Their built-in short aliases always select the bundled baseline, and uninstalling the external distribution restores the bundled canonical implementation. DS1000Z uses the separate external canonical ID `rigol.ds1000z`; its built-in `ds1104` and `ds1000z` aliases remain available without the package. DG4000 source plugins may import the stable `DG4000DacBlock` and `DG4000ByteOrder` types from `wavebench.instruments`; waveform loading, normalization, DAC14 encoding, services, and safety policy remain core responsibilities. The source code retains the historical term `migration slot` for this allowlist, but it does not imply deprecating the bundled drivers.
## Safety defaults
diff --git "a/doc/project/WaveBench_run_plan_\344\275\277\347\224\250\346\214\207\345\215\227.md" "b/doc/project/WaveBench_run_plan_\344\275\277\347\224\250\346\214\207\345\215\227.md"
index 9d9e76b..ecb2bb1 100644
--- "a/doc/project/WaveBench_run_plan_\344\275\277\347\224\250\346\214\207\345\215\227.md"
+++ "b/doc/project/WaveBench_run_plan_\344\275\277\347\224\250\346\214\207\345\215\227.md"
@@ -70,6 +70,14 @@ python -m wavebench run template source-scope-sweep ^
--scope-channel 1 ^
--output plans/source_scope_sweep.toml
+python -m wavebench run template source-scope-frequency-response ^
+ --frequencies 100,1000,10000 ^
+ --source-channel 1 ^
+ --reference-channel 1 ^
+ --response-channel 2 ^
+ --fit ^
+ --output plans/source_scope_frequency_response.toml
+
python -m wavebench run template dmm-acv-source ^
--frequency 1000 ^
--vpp 1.0 ^
@@ -87,6 +95,7 @@ python -m wavebench run template power-dmm-dcv ^
- `source-scope-sine`:生成单频点 DG4202 -> RTM2032 闭环 plan,带 source restore、scope safety、质量检查、`[steps.expect]` 和 `[steps.expect_fft]`。
- `source-scope-sweep`:把 `--frequencies` 里的频点展开成多组 `source.set_freq` + `scope.capture`,每个频点都有独立 label、expect 和 FFT expect。它不是新的执行器,只是 run plan 展开器。
+- `source-scope-frequency-response`:生成一次同步双通道采集的频响 plan。reference 通道接 DUT 输入,response 通道接 DUT 输出;`--fit` 额外写入线性增益拟合配置。
- `dmm-acv-source`:生成 DG4202 -> DMM ACV smoke plan,ACV 期望值按 `Vpp / (2 * sqrt(2))` 自动缩放。
- `power-dmm-dcv`:生成 DP800 电压设置 + DMM DCV 读回 plan,只设置电压/限流,不自动打开或关闭电源输出。
@@ -97,6 +106,63 @@ python -m wavebench run check --config wavebench.toml --plan plans/source_scope
python -m wavebench run verify --config wavebench.toml --plan plans/source_scope_sweep.toml
```
+## 双通道频率响应 / Frequency response
+
+`sweep.frequency_response` 让信号源按离散频点设定频率,并让示波器在每个频点只触发一次、同步读取两路波形:
+
+- `reference_channel` 是 DUT 输入参考;`response_channel` 是 DUT 输出,二者必须不同。
+- source 输出必须已经由前面的显式 `source.output state = "on"` 打开。频响 step 不会偷偷打开输出;若输出关闭或设频写入失败,会立即停止后续频点并走已有 restore 路径。
+- 两路都会经过高阻保护。执行前仍需人工确认探头、线缆、量程和接地;WaveBench 不自动 deskew,也不会把测得相位冒充为已校准 DUT 相位。
+- 每个成功采集强制保存双路 NPY 与 `metadata.json` 作为原始证据,即使全局输出配置关闭了 NPY/JSON。采集或分析失败会写入该频点 CSV 行后继续;信号源状态/写入异常会停止。
+- 一个 plan 最多包含一个该 step,避免固定的 `frequency_response.csv` / `frequency_response_fit.json` 产物名冲突。
+
+显式频点示例:
+
+```toml
+[[steps]]
+kind = "source.output"
+channel = 1
+state = "on"
+
+[[steps]]
+kind = "sweep.frequency_response"
+label = "lowpass_bode"
+source_channel = 1
+reference_channel = 1
+response_channel = 2
+frequencies_hz = [100, 316.228, 1000, 3162.28, 10000]
+target_cycles = 10
+settle_s = 0.3
+points = "def"
+save_csv = false
+screenshot = true
+
+[steps.fit]
+methods = ["linear_log", "polynomial", "pchip"]
+polynomial_degree = 2
+```
+
+也可让 parser 生成等比或等差频点:
+
+```toml
+[[steps]]
+kind = "sweep.frequency_response"
+reference_channel = 1
+response_channel = 2
+start_frequency_hz = 100
+stop_frequency_hz = 100000
+frequency_count = 31
+spacing = "log" # "log" 或 "linear"
+```
+
+拟合的因变量始终是线性增益 `gain_linear`,自变量是 `x = log10(f / Hz)`;不会对 dB 增益拟合,也不会在测量频段外外推:
+
+- `linear_log`:分段线性插值,导出每段 `m`、`b`,即 `G = m*x + b`。
+- `polynomial`:1–5 阶多项式,导出降幂系数。阶数必须小于有效频点数。
+- `pchip`:保形三次插值,导出每段 `x_start`、`x_stop` 和 `[c3, c2, c1, c0]`,即 `G = c3*dx^3 + c2*dx^2 + c1*dx + c0`。它需要先安装 `python -m pip install -e ".[analysis]"`。
+
+相位使用输出相对输入的相量差,CSV 同时提供 `phase_wrapped_deg` 和不跨失败点连接的 `phase_unwrapped_deg`。探头、电缆和通道延迟均会包含在相位里;先做直通基线或 deskew,才能把相位解释为 DUT 本体特性。
+
## 一个 step 只做一件事
例如设置电源电压不会顺手打开输出:
@@ -249,6 +315,15 @@ data/runs/YYYYMMDD_HHMMSS_/
| `quality_recovery` | 自动恢复尝试记录;未启用或未触发时可能不存在。 |
| `expect` | `[steps.expect]` 的检查结果;未配置时可能不存在。 |
+`sweep.frequency_response` 的 `artifact.frequency_response` 常见字段:
+
+| 字段 | 含义 |
+|---|---|
+| `csv` | run 根目录的逐点 `frequency_response.csv`。 |
+| `fit_json` | 启用拟合时的 `frequency_response_fit.json`;未启用则为空。 |
+| `captures` | 每个已有双通道采集包与 metadata 的引用,供报告和审计使用。 |
+| `failed_point_count` / `warning_point_count` | 频点失败与质量 warning 数量。 |
+
### `summary.csv`
`summary.csv` 面向脚本和表格查看。常见列:
@@ -283,9 +358,19 @@ HTML 报告当前会汇总:
- `实验证据摘要 / Run evidence summary`:source 步骤、scope capture、DMM 读数、run.json、summary.csv、截图和波形预览数量。
- `证据时间线 / Evidence timeline`:按 step 展示 source/scope/DMM/sleep 的证据摘要。
- `扫频摘要 / Sweep summary`:当 run 里有多点 `scope.capture` 或 sweep label 时显示,列出每个频点的 label、status、quality、expect、FFT、frequency、Vpp、FFT peak、peak amplitude 和 THD。
+- `频率响应 / Frequency response`:当 run 根目录存在 `frequency_response.csv` 时显示幅频、相频、线性增益拟合对比、逐点表格、拟合公式/参数,并发现每点的截图和原始采集包链接。
- `验收摘要 / Acceptance summary` 与 `预期 vs 实测 / Expected vs measured`:汇总 `[steps.expect]` 和 `[steps.expect_fft]` 的验收结果。
- `DMM 读数 / DMM readings`、`信号分析 / Signal analysis`、`波形预览 / Waveform previews`、`截图 / Screenshots`。
+安装 `.[pdf]` 后可在同一离线命令中导出 PDF:
+
+```powershell
+python -m wavebench run report data/runs/ --pdf
+python -m wavebench run report data/runs/ --output reports/lowpass.html --pdf --pdf-output reports/lowpass.pdf
+```
+
+PDF 会嵌入 HTML 中可见的截图、静态 SVG 曲线和表格,适合把报告发给他人或归档。CSV、拟合 JSON、NPY 和完整采集包仍是独立证据文件;PDF 中保留它们的链接,但不把大型原始波形伪装成可见图表。WeasyPrint 还需要操作系统提供 Cairo、Pango、GDK-PixBuf 和合适的中文字体。
+
典型 sweep 流程:
```powershell
diff --git "a/doc/project/WaveBench_\346\225\260\346\215\256\350\276\223\345\207\272\346\240\274\345\274\217.md" "b/doc/project/WaveBench_\346\225\260\346\215\256\350\276\223\345\207\272\346\240\274\345\274\217.md"
index dad06c1..55c92bb 100644
--- "a/doc/project/WaveBench_\346\225\260\346\215\256\350\276\223\345\207\272\346\240\274\345\274\217.md"
+++ "b/doc/project/WaveBench_\346\225\260\346\215\256\350\276\223\345\207\272\346\240\274\345\274\217.md"
@@ -630,3 +630,30 @@ step_index,kind,status,package,metadata,quality_status,quality_warnings,expect_s
```
断言失败时,run 会标记为 `failed`,但采集包仍会保留。这样失败结果也能被复盘,而不是只得到一条错误消息。
+
+## 双通道频率响应产物
+
+`sweep.frequency_response` 在本次 run 根目录额外写入下列产物,不覆盖普通 `scope.capture` 包:
+
+```text
+data/runs/YYYYMMDD_HHMMSS_/
+├─ frequency_response.csv
+└─ frequency_response_fit.json # 仅配置 [steps.fit] 时存在
+```
+
+`frequency_response.csv` 每请求一个频点就原子刷新一次,因此 source 设频失败、scope 采集失败或分析失败时,前序记录和当前失败行仍会保留。稳定基础列为:
+
+```text
+index,requested_frequency_hz,reference_frequency_hz,response_frequency_hz,
+reference_amplitude_peak_v,response_amplitude_peak_v,reference_vpp_v,response_vpp_v,
+gain_linear,gain_db,phase_wrapped_deg,phase_unwrapped_deg,status,warnings,error,
+capture_package,metadata_path
+```
+
+- `gain_linear` 是输出基波峰值 / 输入基波峰值;`gain_db = 20 * log10(gain_linear)`。
+- `phase_wrapped_deg` 在 `[-180, 180)`;`phase_unwrapped_deg` 对连续成功点展开,绝不跨失败点连接。
+- `status` 为 `ok`、`warning` 或 `failed`。失败行的数值字段为空,`error` 保存可读错误,不能被误当作零增益或零相位。
+- `capture_package` / `metadata_path` 指向每个成功的同步双通道原始证据。频响采集强制写入两路 NPY 与 metadata,普通可选 CSV 和截图仍遵循该 step 的 `save_csv` / `screenshot` 设置。
+- 开启拟合后,CSV 还会增加 `fit__gain_linear` 与 `fit__residual` 列;这些值只对应实际有效频点。
+
+`frequency_response_fit.json` 是供报告、调试脚本和复算使用的 JSON 文档。它声明 `x_transform = "log10(frequency_hz / Hz)"`、有效范围、被排除的点、拟合公式、参数、误差指标和用于图表的频率/线性增益曲线。它不在定义域外外推:调试脚本应先检查 `valid_domain_hz`。
diff --git a/pyproject.toml b/pyproject.toml
index 4a2905a..4ae61a5 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -13,7 +13,9 @@ authors = [{ name = "Scaxlibur" }]
dependencies = ["RsInstrument>=1.90.0", "numpy>=2.0.0", "packaging>=24.0", "pyserial>=3.5", "pyvisa-py>=0.8.1"]
[project.optional-dependencies]
+analysis = ["scipy>=1.14.0"]
dev = ["hatchling==1.30.1", "pytest>=8.0.0", "ruff==0.15.20"]
+pdf = ["weasyprint>=62.0"]
tui = ["textual>=0.86.0"]
[project.scripts]
diff --git a/src/wavebench/cli.py b/src/wavebench/cli.py
index 6e192dc..b6468f6 100644
--- a/src/wavebench/cli.py
+++ b/src/wavebench/cli.py
@@ -11,7 +11,7 @@
from .data.packages import load_capture_package, load_run_package
from .discovery import discover_instruments
from .doctor import doctor_records, has_doctor_errors as has_config_doctor_errors
-from .report.html import write_run_report_html
+from .report.html import write_run_report_html, write_run_report_pdf
from .report.index import write_report_index
from .errors import ConfigError, WaveBenchError
from .cli_parser import build_parser
@@ -385,8 +385,26 @@ def main(argv: list[str] | None = None) -> int:
return 0
if args.domain == "run":
if args.command == "report":
- output = write_run_report_html(load_run_package(args.path), output_path=args.output)
+ if args.pdf_output and not args.pdf:
+ raise ConfigError("run report --pdf-output requires --pdf")
+ package = load_run_package(args.path)
+ output = Path(args.output) if args.output else package.path / "report.html"
+ pdf_output = (
+ Path(args.pdf_output) if args.pdf_output else output.with_suffix(".pdf")
+ )
+ if args.pdf and output.resolve() == pdf_output.resolve():
+ raise ConfigError("run report HTML and PDF outputs must use different paths")
+ if args.pdf and output.suffix.lower() == ".pdf":
+ raise ConfigError("run report --output is an HTML path and must not use a .pdf suffix")
+ if args.pdf and pdf_output.suffix.lower() in {".htm", ".html"}:
+ raise ConfigError(
+ "run report --pdf-output is a PDF path and must not use an HTML suffix"
+ )
+ output = write_run_report_html(package, output_path=output)
print(f"report={output}")
+ if args.pdf:
+ pdf = write_run_report_pdf(package, output_path=pdf_output)
+ print(f"pdf={pdf}")
return 0
if args.command == "report-index":
result = write_report_index(args.paths, args.output)
@@ -424,6 +442,9 @@ def main(argv: list[str] | None = None) -> int:
vpp=args.vpp,
source_channel=args.source_channel,
scope_channel=args.scope_channel,
+ reference_channel=args.reference_channel,
+ response_channel=args.response_channel,
+ frequency_response_fit=args.frequency_response_fit,
power_channel=args.power_channel,
voltage_v=args.voltage,
current_limit_a=args.current_limit,
diff --git a/src/wavebench/cli_parser.py b/src/wavebench/cli_parser.py
index ce16d68..7da4888 100644
--- a/src/wavebench/cli_parser.py
+++ b/src/wavebench/cli_parser.py
@@ -328,6 +328,9 @@ def build_parser() -> argparse.ArgumentParser:
run_template.add_argument("--vpp", type=float, default=1.0, help="Template source amplitude in Vpp")
run_template.add_argument("--source-channel", type=int, default=None, help="Template source channel")
run_template.add_argument("--scope-channel", type=int, default=None, help="Template scope channel")
+ run_template.add_argument("--reference-channel", type=int, default=None, help="Template frequency-response reference scope channel")
+ run_template.add_argument("--response-channel", type=int, default=None, help="Template frequency-response DUT-output scope channel")
+ run_template.add_argument("--fit", action="store_true", dest="frequency_response_fit", help="Enable all frequency-response fit candidates in the template")
run_template.add_argument("--power-channel", type=int, default=None, help="Template power channel")
run_template.add_argument("--voltage", type=float, default=3.3, help="Template power voltage in V")
run_template.add_argument("--current-limit", type=float, default=0.1, help="Template power current limit in A")
@@ -337,6 +340,12 @@ def build_parser() -> argparse.ArgumentParser:
run_report = run_sub.add_parser("report", help="Generate an offline HTML report for a run package")
run_report.add_argument("path", help="Path to data/runs/")
run_report.add_argument("--output", default=None, help="Output HTML path; defaults to /report.html")
+ run_report.add_argument(
+ "--pdf",
+ action="store_true",
+ help="Also export a portable PDF with visible images embedded / 同时导出嵌入可见图片的便携 PDF 报告",
+ )
+ run_report.add_argument("--pdf-output", default=None, help="Output PDF path; defaults to the HTML path with a .pdf suffix")
run_report_index = run_sub.add_parser("report-index", help="Generate manifest JSON/CSV for multiple run directories")
run_report_index.add_argument("paths", nargs="+", help="Paths to data/runs/")
run_report_index.add_argument("--output", required=True, help="Output directory for manifest.json and manifest.csv")
diff --git a/src/wavebench/config.py b/src/wavebench/config.py
index 3d0d64b..a17d726 100644
--- a/src/wavebench/config.py
+++ b/src/wavebench/config.py
@@ -233,7 +233,12 @@ def with_resource(self, resource: str) -> "WaveBenchConfig":
)
def with_output_overrides(
- self, *, save_csv: bool | None = None, save_npy: bool | None = None, save_screenshot: bool | None = None
+ self,
+ *,
+ save_csv: bool | None = None,
+ save_npy: bool | None = None,
+ save_json: bool | None = None,
+ save_screenshot: bool | None = None,
) -> "WaveBenchConfig":
return WaveBenchConfig(
connection=self.connection,
@@ -245,7 +250,7 @@ def with_output_overrides(
package_naming=self.output.package_naming,
save_csv=self.output.save_csv if save_csv is None else save_csv,
save_npy=self.output.save_npy if save_npy is None else save_npy,
- save_json=self.output.save_json,
+ save_json=self.output.save_json if save_json is None else save_json,
save_commands_log=self.output.save_commands_log,
save_screenshot=self.output.save_screenshot if save_screenshot is None else save_screenshot,
),
diff --git a/src/wavebench/data/packages.py b/src/wavebench/data/packages.py
index 67bf6f4..adecebe 100644
--- a/src/wavebench/data/packages.py
+++ b/src/wavebench/data/packages.py
@@ -2,7 +2,7 @@
import csv
import json
-from dataclasses import dataclass
+from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
@@ -42,6 +42,11 @@ class RunPackage:
run: dict[str, Any]
summary_csv_path: Path | None
summary_rows: list[dict[str, str]]
+ frequency_response_csv_path: Path | None = None
+ frequency_response_rows: list[dict[str, str]] = field(default_factory=list)
+ frequency_response_fit_path: Path | None = None
+ frequency_response_fit: dict[str, Any] | None = None
+ frequency_response_fit_error: str | None = None
@property
def status(self) -> str:
@@ -137,12 +142,33 @@ def load_run_package(path: str | Path) -> RunPackage:
present_summary_path = summary_path
with summary_path.open(newline="", encoding="utf-8") as file:
rows = [dict(row) for row in csv.DictReader(file)]
+ response_path = run_dir / "frequency_response.csv"
+ response_rows: list[dict[str, str]] = []
+ present_response_path: Path | None = None
+ if response_path.exists():
+ present_response_path = response_path
+ with response_path.open(newline="", encoding="utf-8") as file:
+ response_rows = [dict(row) for row in csv.DictReader(file)]
+ fit_path = run_dir / "frequency_response_fit.json"
+ present_fit_path: Path | None = fit_path if fit_path.exists() else None
+ fit: dict[str, Any] | None = None
+ fit_error: str | None = None
+ if present_fit_path is not None:
+ try:
+ fit = _read_json_object(present_fit_path, label="frequency response fit JSON")
+ except ConfigError as exc:
+ fit_error = str(exc)
return RunPackage(
path=run_dir,
run_json_path=run_json_path,
run=run_data,
summary_csv_path=present_summary_path,
summary_rows=rows,
+ frequency_response_csv_path=present_response_path,
+ frequency_response_rows=response_rows,
+ frequency_response_fit_path=present_fit_path,
+ frequency_response_fit=fit,
+ frequency_response_fit_error=fit_error,
)
diff --git a/src/wavebench/report/html.py b/src/wavebench/report/html.py
index 92997ae..5fab21d 100644
--- a/src/wavebench/report/html.py
+++ b/src/wavebench/report/html.py
@@ -10,6 +10,7 @@
import numpy as np
from wavebench.data.packages import RunPackage
+from wavebench.errors import ConfigError
def write_run_report_html(run: RunPackage, output_path: str | Path | None = None) -> Path:
@@ -19,6 +20,27 @@ def write_run_report_html(run: RunPackage, output_path: str | Path | None = None
return path
+def write_run_report_pdf(run: RunPackage, output_path: str | Path | None = None) -> Path:
+ """Render the report as a portable PDF with its visible images and SVGs embedded."""
+ path = Path(output_path) if output_path is not None else run.path / "report.pdf"
+ try:
+ from weasyprint import HTML
+ except (ImportError, OSError) as exc:
+ raise ConfigError(
+ "PDF report export requires the optional PDF dependency; "
+ "install WaveBench with `.[pdf]` and the platform rendering libraries"
+ ) from exc
+ # A full HTML report may contain hundreds of independent capture artifacts.
+ # Keep the portable PDF focused on its reviewable result: summary, Bode curves,
+ # fit, and point table. The run directory remains the authoritative raw evidence.
+ html = render_run_report_html(run, output_dir=path.parent, compact=True)
+ try:
+ HTML(string=html, base_url=path.parent.resolve().as_uri() + "/").write_pdf(str(path))
+ except Exception as exc: # noqa: BLE001 - surface renderer failures as a user-facing config error
+ raise ConfigError(f"PDF report export failed: {type(exc).__name__}: {exc}") from exc
+ return path
+
+
def write_run_report_manifest(
run: RunPackage, output_dir: str | Path | None = None, report_path: str | Path | None = None
) -> Path:
@@ -130,6 +152,13 @@ class ReportArtifactLink:
status: str
+@dataclass(frozen=True)
+class ReportCaptureReference:
+ step_index: str
+ package: str
+ metadata: str
+
+
@dataclass(frozen=True)
class ReportEvidenceSummary:
source_step_count: int
@@ -143,34 +172,45 @@ class ReportEvidenceSummary:
waveform_preview_count: int
-def render_run_report_html(run: RunPackage, output_dir: str | Path | None = None) -> str:
+def render_run_report_html(
+ run: RunPackage,
+ output_dir: str | Path | None = None,
+ *,
+ compact: bool = False,
+) -> str:
experiment = run.run.get("experiment", {}) if isinstance(run.run.get("experiment"), dict) else {}
restore = run.run.get("restore", {}) if isinstance(run.run.get("restore"), dict) else {}
error = run.run.get("error", {}) if isinstance(run.run.get("error"), dict) else {}
report_output_dir = Path(output_dir) if output_dir is not None else run.path
- screenshots = _collect_screenshots(run, report_output_dir)
- signals = _collect_signal_summaries(run)
- expectations = _collect_expectation_rows(run)
- dmm_readings = _collect_dmm_readings(run)
- sweep_rows = _collect_sweep_rows(run)
- waveform_previews = _collect_waveform_previews(run)
+ screenshots = [] if compact else _collect_screenshots(run, report_output_dir)
+ signals = [] if compact else _collect_signal_summaries(run)
+ expectations = [] if compact else _collect_expectation_rows(run)
+ dmm_readings = [] if compact else _collect_dmm_readings(run)
+ sweep_rows = [] if compact else _collect_sweep_rows(run)
+ waveform_previews = [] if compact else _collect_waveform_previews(run)
evidence = _build_evidence_summary(run, expectations, dmm_readings, screenshots, waveform_previews)
- artifact_links = _collect_artifact_links(run, report_output_dir, screenshots)
+ artifact_links = [] if compact else _collect_artifact_links(run, report_output_dir, screenshots)
summary = _build_report_summary(run, screenshots, signals)
screenshots_by_step = {item.step_index: item for item in screenshots}
- evidence_timeline_block = _evidence_timeline_block(run, screenshots_by_step)
+ evidence_timeline_block = "" if compact else _evidence_timeline_block(run, screenshots_by_step)
rows = "\n".join(_step_row(step, screenshots_by_step.get(str(step.get("index", "")))) for step in run.steps)
if not rows:
rows = '没有记录步骤 / No steps recorded. '
- screenshots_block = _screenshots_block(screenshots)
- acceptance_block = _acceptance_block(expectations)
- expectations_block = _expectations_block(expectations)
- dmm_block = _dmm_readings_block(dmm_readings)
- sweep_block = _sweep_summary_block(sweep_rows)
- artifact_links_block = _artifact_links_block(artifact_links)
- signals_block = _signals_block(signals)
- waveform_previews_block = _waveform_previews_block(waveform_previews)
+ screenshots_block = "" if compact else _screenshots_block(screenshots)
+ acceptance_block = "" if compact else _acceptance_block(expectations)
+ expectations_block = "" if compact else _expectations_block(expectations)
+ dmm_block = "" if compact else _dmm_readings_block(dmm_readings)
+ sweep_block = "" if compact else _sweep_summary_block(sweep_rows)
+ frequency_response_block = _frequency_response_block(run, include_table=not compact)
+ artifact_links_block = "" if compact else _artifact_links_block(artifact_links)
+ signals_block = "" if compact else _signals_block(signals)
+ waveform_previews_block = "" if compact else _waveform_previews_block(waveform_previews)
summary_note = "present" if run.summary_csv_path is not None else "missing"
+ compact_note = (
+ 'PDF 精简为结果摘要、Bode 曲线、拟合与逐点表;完整原始证据保留在 run 目录。
'
+ if compact
+ else ""
+ )
error_block = ""
if error:
error_block = f"运行错误 / Run error {escape(str(error))} "
@@ -246,14 +286,30 @@ def render_run_report_html(run: RunPackage, output_dir: str | Path | None = None
.dmm-card dl {{ margin: 0; display: grid; gap: 0.25rem; }}
.dmm-card div {{ display: grid; grid-template-columns: 5.5rem 1fr; gap: 0.5rem; }}
.dmm-card dt {{ color: var(--muted); }}
-.dmm-card dd {{ margin: 0; font-weight: 650; overflow-wrap: anywhere; }}
-@media print {{ body {{ background: #fff; }} main {{ max-width: none; padding: 0; }} section, article.card, figure.card, .table {{ box-shadow: none; }} }}
+ .dmm-card dd {{ margin: 0; font-weight: 650; overflow-wrap: anywhere; }}
+ .frequency-response-grid {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(22rem, 1fr)); gap: 1rem; margin: 0.5rem 0 1.25rem; }}
+ .frequency-response-card {{ padding: 0.85rem; }}
+ .frequency-response-card h3 {{ margin: 0 0 0.35rem; font-size: 1rem; }}
+ .frequency-response-card svg {{ display: block; width: 100%; height: auto; margin-top: 0.5rem; border-radius: 8px; }}
+ .fit-formula {{ white-space: pre-wrap; overflow-wrap: anywhere; }}
+@page {{ size: A4 landscape; margin: 10mm; }}
+@media print {{
+ body {{ background: #fff; font-size: 9pt; }}
+ main {{ max-width: none; padding: 0; }}
+ section, article.card, figure.card, .table {{ box-shadow: none; }}
+ article.card, figure.card {{ break-inside: avoid; }}
+ .table {{ overflow: visible; }}
+ .compact-table table {{ font-size: 7pt; }}
+ .compact-table th, .compact-table td {{ padding: 0.25rem 0.3rem; }}
+ .frequency-response-grid {{ grid-template-columns: 1fr; }}
+}}
WaveBench 运行报告 Run report
-A static, self-contained hardware validation report.
+A static offline hardware validation report.
+{compact_note}
{_summary_block(summary)}
{_evidence_summary_block(evidence)}
{evidence_timeline_block}
@@ -275,9 +331,10 @@ def render_run_report_html(run: RunPackage, output_dir: str | Path | None = None
-{dmm_block}
-{sweep_block}
-{acceptance_block}
+ {dmm_block}
+ {sweep_block}
+ {frequency_response_block}
+ {acceptance_block}
{expectations_block}
{signals_block}
{waveform_previews_block}
@@ -332,11 +389,34 @@ def _build_report_manifest(run: RunPackage, *, output_dir: Path, report_path: Pa
"generated": "inline-svg",
}
)
+ known_packages = {str(record["package"]) for record in capture_packages}
+ for reference in _capture_references(run):
+ if reference.package in known_packages:
+ continue
+ package_dir = _resolve_artifact_path(run.path, reference.package)
+ capture_packages.append(
+ {
+ "step_index": reference.step_index,
+ "package": reference.package,
+ "path": _relative_url(package_dir, output_dir),
+ "exists": package_dir.exists(),
+ }
+ )
+ if not package_dir.exists():
+ warnings.append(
+ f"step {reference.step_index}: capture package missing: {reference.package}"
+ )
return {
"schema": "wavebench.report_manifest.v1",
"report": _relative_url(report_path, output_dir),
"run_json": _relative_url(run.run_json_path, output_dir),
"summary_csv": _relative_url(run.summary_csv_path, output_dir) if run.summary_csv_path is not None else None,
+ "frequency_response_csv": _relative_url(run.frequency_response_csv_path, output_dir)
+ if run.frequency_response_csv_path is not None
+ else None,
+ "frequency_response_fit_json": _relative_url(run.frequency_response_fit_path, output_dir)
+ if run.frequency_response_fit_path is not None
+ else None,
"capture_packages": capture_packages,
"screenshots": [
{
@@ -663,6 +743,268 @@ def _sweep_summary_row(row: ReportSweepRow) -> str:
)
+def _frequency_response_block(run: RunPackage, *, include_table: bool = True) -> str:
+ if run.frequency_response_csv_path is None:
+ return ""
+ rows = run.frequency_response_rows
+ gain_blocks = _response_blocks(rows, "gain_db")
+ phase_blocks = _response_blocks(rows, "phase_unwrapped_deg")
+ fit_series = _fit_curve_series(run.frequency_response_fit)
+ gain_svg = _response_svg(
+ gain_blocks,
+ title="幅频 / Magnitude response",
+ y_label="Gain (dB)",
+ series=(),
+ )
+ phase_svg = _response_svg(
+ phase_blocks,
+ title="相频 / Phase response",
+ y_label="Phase (deg, unwrapped)",
+ series=(),
+ )
+ linear_blocks = [[point] for block in _response_blocks(rows, "gain_linear") for point in block]
+ fit_svg = _response_svg(
+ linear_blocks,
+ title="线性增益拟合 / Linear gain fit comparison",
+ y_label="Linear gain (V/V)",
+ series=fit_series,
+ actual_label="Measured",
+ )
+ table_block = ""
+ if include_table:
+ table_rows = "\n".join(_frequency_response_row(row) for row in rows)
+ if not table_rows:
+ table_rows = '频响 CSV 没有可读取的记录 / No readable response rows. '
+ table_block = f"""
+# 请求频率 / Requested 输入峰值 / Input peak 输出峰值 / Output peak 线性增益 增益 / Gain 相位 / Phase 展开相位 / Unwrapped 状态 / Status 警告或错误 / Warning or error
+
+{table_rows}
+
+
"""
+ else:
+ table_block = '逐频点结果请见同目录 frequency_response.csv。
'
+ fit_summary = (
+ _fit_summary_block(run.frequency_response_fit, run.frequency_response_fit_error)
+ if include_table
+ else _compact_fit_summary_block(run.frequency_response_fit, run.frequency_response_fit_error)
+ )
+ return f"""频率响应 / Frequency response
+幅频与相频由同一次双通道采集计算。相位为输出相对输入,包含探头、电缆和通道偏斜;未自动校准或 deskew。
+
+幅频 / Magnitude {gain_svg}
+相频 / Phase {phase_svg}
+
+
+拟合对比 / Fit comparison {fit_svg}
+
+{fit_summary}
+{table_block}
+"""
+
+
+def _frequency_response_row(row: dict[str, str]) -> str:
+ status = str(row.get("status", ""))
+ details = " | ".join(
+ value for value in (str(row.get("warnings", "")), str(row.get("error", ""))) if value
+ )
+ return (
+ f''
+ f"{escape(str(row.get('index', '')))} "
+ f"{escape(_format_metric(row.get('requested_frequency_hz'), 'Hz'))} "
+ f"{escape(_format_metric(row.get('reference_amplitude_peak_v'), 'V'))} "
+ f"{escape(_format_metric(row.get('response_amplitude_peak_v'), 'V'))} "
+ f"{escape(_format_plain(row.get('gain_linear')))} "
+ f"{escape(_format_metric(row.get('gain_db'), 'dB'))} "
+ f"{escape(_format_metric(row.get('phase_wrapped_deg'), 'deg'))} "
+ f"{escape(_format_metric(row.get('phase_unwrapped_deg'), 'deg'))} "
+ f'{escape(status)} '
+ f"{escape(details)} "
+ " "
+ )
+
+
+def _fit_summary_block(document: dict[str, Any] | None, error: str | None) -> str:
+ if error:
+ return f'拟合 JSON 无法读取 / Fit JSON unavailable: {escape(error)}
'
+ if not document:
+ return '未启用拟合 / Fit was not enabled for this run.
'
+ methods = document.get("methods", {})
+ if not isinstance(methods, dict) or not methods:
+ return '没有可用的拟合结果 / No fit results available.
'
+ rows = []
+ for name, raw in methods.items():
+ payload = raw if isinstance(raw, dict) else {}
+ metrics = payload.get("metrics", {}) if isinstance(payload.get("metrics"), dict) else {}
+ parameters = payload.get("parameters", {})
+ parameter_text = json.dumps(parameters, ensure_ascii=False) if parameters else ""
+ rows.append(
+ ""
+ f"{escape(str(payload.get('display') or name))} "
+ f"{escape(str(payload.get('status', '')))} "
+ f"{escape(str(payload.get('formula', payload.get('reason', ''))))} "
+ f"{escape(_format_plain(metrics.get('rmse')))} "
+ f"{escape(_format_plain(metrics.get('r_squared')))} "
+ f"{escape(parameter_text)} "
+ " "
+ )
+ return f"""拟合公式与指标 / Fit formulas and metrics
+
+方法 / Method 状态 / Status 公式 / Formula RMSE R² 参数 / Parameters
+{''.join(rows)}
+
+"""
+
+
+def _compact_fit_summary_block(document: dict[str, Any] | None, error: str | None) -> str:
+ """Keep portable PDFs readable when interpolation metadata has hundreds of segments."""
+ if error:
+ return f'拟合 JSON 无法读取 / Fit JSON unavailable: {escape(error)}
'
+ if not document:
+ return '未启用拟合 / Fit was not enabled for this run.
'
+ methods = document.get("methods", {})
+ if not isinstance(methods, dict) or not methods:
+ return '没有可用的拟合结果 / No fit results available.
'
+ rows = []
+ for name, raw in methods.items():
+ payload = raw if isinstance(raw, dict) else {}
+ metrics = payload.get("metrics", {}) if isinstance(payload.get("metrics"), dict) else {}
+ formula = str(payload.get("formula", "")) if name == "polynomial" else "完整公式见拟合 JSON"
+ rows.append(
+ ""
+ f"{escape(str(payload.get('display') or name))} "
+ f"{escape(str(payload.get('status', '')))} "
+ f"{escape(_format_plain(metrics.get('rmse')))} "
+ f"{escape(_format_plain(metrics.get('r_squared')))} "
+ f"{escape(formula)} "
+ " "
+ )
+ return f"""线性增益拟合摘要 / Linear-gain fit summary
+
+方法 / Method 状态 / Status RMSE R² 公式 / Formula
+{''.join(rows)}
+
"""
+
+
+def _response_blocks(rows: list[dict[str, str]], key: str) -> list[list[tuple[float, float]]]:
+ blocks: list[list[tuple[float, float]]] = []
+ block: list[tuple[float, float]] = []
+ for row in rows:
+ frequency = _finite_float(row.get("requested_frequency_hz"))
+ value = _finite_float(row.get(key))
+ if row.get("status") == "failed" or frequency is None or frequency <= 0 or value is None:
+ if block:
+ blocks.append(block)
+ block = []
+ continue
+ block.append((frequency, value))
+ if block:
+ blocks.append(block)
+ return blocks
+
+
+def _fit_curve_series(document: dict[str, Any] | None) -> list[tuple[str, str, list[tuple[float, float]]]]:
+ if not document:
+ return []
+ methods = document.get("methods", {})
+ if not isinstance(methods, dict):
+ return []
+ colors = ("#7c3aed", "#dc2626", "#0891b2", "#ea580c")
+ series = []
+ for index, (name, raw) in enumerate(methods.items()):
+ payload = raw if isinstance(raw, dict) else {}
+ curve = payload.get("curve", [])
+ values: list[tuple[float, float]] = []
+ if isinstance(curve, list):
+ for item in curve:
+ if not isinstance(item, dict):
+ continue
+ frequency = _finite_float(item.get("frequency_hz"))
+ gain = _finite_float(item.get("gain_linear"))
+ if frequency is not None and frequency > 0 and gain is not None:
+ values.append((frequency, gain))
+ if values:
+ series.append((str(payload.get("display") or name), colors[index % len(colors)], values))
+ return series
+
+
+def _response_svg(
+ blocks: list[list[tuple[float, float]]],
+ *,
+ title: str,
+ y_label: str,
+ series: list[tuple[str, str, list[tuple[float, float]]]],
+ actual_label: str = "Measured",
+) -> str:
+ actual = [point for block in blocks for point in block]
+ all_points = actual + [point for _name, _color, values in series for point in values]
+ if not all_points:
+ return '没有可绘制的有效频点 / No valid points to plot.
'
+ x_values = np.asarray([np.log10(point[0]) for point in all_points], dtype=float)
+ y_values = np.asarray([point[1] for point in all_points], dtype=float)
+ x_min = float(np.min(x_values))
+ x_max = float(np.max(x_values))
+ y_min = float(np.min(y_values))
+ y_max = float(np.max(y_values))
+ if x_min == x_max:
+ x_min -= 0.5
+ x_max += 0.5
+ if y_min == y_max:
+ y_min -= max(abs(y_min) * 0.1, 0.5)
+ y_max += max(abs(y_max) * 0.1, 0.5)
+ else:
+ pad_y = (y_max - y_min) * 0.08
+ y_min -= pad_y
+ y_max += pad_y
+ width, height, pad_left, pad_right, pad_top, pad_bottom = 680, 270, 58, 20, 28, 42
+
+ def position(point: tuple[float, float]) -> tuple[float, float]:
+ x_value = np.log10(point[0])
+ px = pad_left + (x_value - x_min) / (x_max - x_min) * (width - pad_left - pad_right)
+ py = height - pad_bottom - (point[1] - y_min) / (y_max - y_min) * (height - pad_top - pad_bottom)
+ return float(px), float(py)
+
+ polylines = []
+ for block in blocks:
+ if len(block) < 2:
+ continue
+ points = " ".join(f"{x:.2f},{y:.2f}" for x, y in (position(point) for point in block))
+ polylines.append(f' ')
+ fit_lines = []
+ legends = []
+ for legend_index, (name, color, values) in enumerate(series):
+ points = " ".join(f"{x:.2f},{y:.2f}" for x, y in (position(point) for point in values))
+ fit_lines.append(f' ')
+ legends.append(
+ f'{escape(name)} '
+ )
+ circles = "".join(
+ f' '
+ for x, y in (position(point) for point in actual)
+ )
+ actual_legend = (
+ f'{escape(actual_label)} '
+ )
+ return (
+ f''
+ f' '
+ f' '
+ f' '
+ f'{escape(title)} '
+ f'log10(f / Hz): {x_min:.3g} .. {x_max:.3g} '
+ f'{escape(y_label)}: {y_min:.4g} .. {y_max:.4g} '
+ f'{"".join(polylines)}{circles}{"".join(fit_lines)}{actual_legend}{"".join(legends)}'
+ ' '
+ )
+
+
+def _finite_float(value: Any) -> float | None:
+ try:
+ numeric = float(value)
+ except (TypeError, ValueError):
+ return None
+ return numeric if np.isfinite(numeric) else None
+
+
def _metric_label(metric: str) -> str:
labels = {
"frequency_estimate_hz": "频率 / Frequency",
@@ -910,16 +1252,13 @@ def _build_report_summary(
experiment = run.run.get("experiment", {}) if isinstance(run.run.get("experiment"), dict) else {}
restore = run.run.get("restore", {}) if isinstance(run.run.get("restore"), dict) else {}
failed_steps = 0
- packages: set[str] = set()
+ packages = {reference.package for reference in _capture_references(run)}
warning_messages: set[str] = set()
failed_expect_count = 0
for step in run.steps:
if step.get("status") == "failed":
failed_steps += 1
artifact = step.get("artifact", {}) if isinstance(step.get("artifact"), dict) else {}
- package = artifact.get("package")
- if package:
- packages.add(str(package))
quality = artifact.get("quality", {}) if isinstance(artifact.get("quality"), dict) else {}
warnings = quality.get("warnings", [])
if isinstance(warnings, list):
@@ -958,12 +1297,7 @@ def _build_evidence_summary(
screenshots: list[ReportScreenshot],
waveform_previews: list[ReportWaveformPreview],
) -> ReportEvidenceSummary:
- packages = {
- str(artifact.get("package"))
- for step in run.steps
- for artifact in [step.get("artifact", {}) if isinstance(step.get("artifact"), dict) else {}]
- if artifact.get("package")
- }
+ packages = {reference.package for reference in _capture_references(run)}
return ReportEvidenceSummary(
source_step_count=sum(1 for step in run.steps if str(step.get("kind", "")).startswith("source.")),
scope_capture_count=sum(1 for step in run.steps if step.get("kind") == "scope.capture"),
@@ -999,36 +1333,50 @@ def _collect_artifact_links(
status=_availability_text(run.summary_csv_path.exists()),
)
)
- screenshots_by_step = {item.step_index: item for item in screenshots}
- for step in run.steps:
- artifact = step.get("artifact", {}) if isinstance(step.get("artifact"), dict) else {}
- package_text = artifact.get("package")
- if not package_text:
- continue
- step_index = str(step.get("index", ""))
- package = str(package_text)
- package_dir = _resolve_artifact_path(run.path, package)
+ if run.frequency_response_csv_path is not None:
links.append(
ReportArtifactLink(
- step_index=step_index,
+ step_index="-",
+ kind="频率响应 CSV / Frequency response CSV",
+ label="frequency_response.csv",
+ href=_relative_url(run.frequency_response_csv_path, output_dir),
+ status=_availability_text(run.frequency_response_csv_path.exists()),
+ )
+ )
+ if run.frequency_response_fit_path is not None:
+ links.append(
+ ReportArtifactLink(
+ step_index="-",
+ kind="频响拟合 JSON / Frequency response fit JSON",
+ label="frequency_response_fit.json",
+ href=_relative_url(run.frequency_response_fit_path, output_dir),
+ status=_availability_text(run.frequency_response_fit_path.exists()),
+ )
+ )
+ screenshots_by_package = {item.package: item for item in screenshots}
+ for reference in _capture_references(run):
+ package_dir = _resolve_artifact_path(run.path, reference.package)
+ links.append(
+ ReportArtifactLink(
+ step_index=reference.step_index,
kind="采集包 / Capture package",
- label=package,
+ label=reference.package,
href=_relative_url(package_dir, output_dir),
status=_availability_text(package_dir.exists()),
)
)
- screenshot = screenshots_by_step.get(step_index)
+ screenshot = screenshots_by_package.get(reference.package)
if screenshot is not None:
links.append(
ReportArtifactLink(
- step_index=step_index,
+ step_index=reference.step_index,
kind="截图 / Screenshot",
label=Path(screenshot.src).name,
href=screenshot.src,
status=_availability_text(screenshot.path.exists()),
)
)
- metadata = _read_capture_metadata(package_dir, artifact.get("metadata"))
+ metadata = _read_capture_metadata(package_dir, reference.metadata)
for channel, npy_text, _summary in _metadata_waveform_npy_files(metadata):
if not npy_text:
continue
@@ -1038,7 +1386,7 @@ def _collect_artifact_links(
npy_name = Path(str(npy_text).replace("\\", "/")).name
links.append(
ReportArtifactLink(
- step_index=step_index,
+ step_index=reference.step_index,
kind="波形原始数据 / Waveform raw artifact",
label=f"ch{channel} {npy_name}",
href=_relative_url(npy_path, output_dir),
@@ -1317,13 +1665,9 @@ def _metadata_signal_summaries(metadata: dict[str, Any]) -> list[dict[str, Any]]
def _collect_screenshots(run: RunPackage, output_dir: Path) -> list[ReportScreenshot]:
screenshots: list[ReportScreenshot] = []
- for step in run.steps:
- artifact = step.get("artifact", {}) if isinstance(step.get("artifact"), dict) else {}
- package_text = artifact.get("package")
- if not package_text:
- continue
- package_dir = _resolve_artifact_path(run.path, str(package_text))
- metadata = _read_capture_metadata(package_dir, artifact.get("metadata"))
+ for reference in _capture_references(run):
+ package_dir = _resolve_artifact_path(run.path, reference.package)
+ metadata = _read_capture_metadata(package_dir, reference.metadata)
screenshot_text = _metadata_screenshot_path(metadata)
if not screenshot_text:
continue
@@ -1334,8 +1678,8 @@ def _collect_screenshots(run: RunPackage, output_dir: Path) -> list[ReportScreen
continue
screenshots.append(
ReportScreenshot(
- step_index=str(step.get("index", "")),
- package=str(package_text),
+ step_index=reference.step_index,
+ package=reference.package,
path=screenshot_path,
src=_relative_url(screenshot_path, output_dir),
)
@@ -1343,6 +1687,48 @@ def _collect_screenshots(run: RunPackage, output_dir: Path) -> list[ReportScreen
return screenshots
+def _capture_references(run: RunPackage) -> list[ReportCaptureReference]:
+ references: list[ReportCaptureReference] = []
+ seen: set[str] = set()
+
+ def add(step_index: str, package: Any, metadata: Any) -> None:
+ package_text = str(package or "").strip()
+ if not package_text or package_text in seen:
+ return
+ seen.add(package_text)
+ references.append(
+ ReportCaptureReference(
+ step_index=step_index,
+ package=package_text,
+ metadata=str(metadata or ""),
+ )
+ )
+
+ for step in run.steps:
+ artifact = step.get("artifact", {}) if isinstance(step.get("artifact"), dict) else {}
+ add(str(step.get("index", "")), artifact.get("package"), artifact.get("metadata"))
+ response = artifact.get("frequency_response", {})
+ captures = response.get("captures", []) if isinstance(response, dict) else []
+ if isinstance(captures, list):
+ for capture in captures:
+ if isinstance(capture, dict):
+ point_index = str(capture.get("index", ""))
+ add(
+ f"frequency response {point_index}",
+ capture.get("package"),
+ capture.get("metadata"),
+ )
+
+ for row in run.frequency_response_rows:
+ point_index = str(row.get("index", ""))
+ add(
+ f"frequency response {point_index}",
+ row.get("capture_package"),
+ row.get("metadata_path"),
+ )
+ return references
+
+
def _read_capture_metadata(package_dir: Path, metadata_text: Any) -> dict[str, Any]:
metadata_path = _resolve_artifact_path(package_dir, str(metadata_text)) if metadata_text else package_dir / "metadata.json"
if not metadata_path.exists():
diff --git a/src/wavebench/services/frequency_response.py b/src/wavebench/services/frequency_response.py
new file mode 100644
index 0000000..088076d
--- /dev/null
+++ b/src/wavebench/services/frequency_response.py
@@ -0,0 +1,513 @@
+from __future__ import annotations
+
+import csv
+from dataclasses import dataclass, replace
+import json
+from math import isfinite
+from pathlib import Path
+from typing import Any
+
+import numpy as np
+
+from wavebench.errors import ConfigError
+
+
+FIT_METHODS = ("linear_log", "polynomial", "pchip")
+BASE_CSV_FIELDS = (
+ "index",
+ "requested_frequency_hz",
+ "reference_frequency_hz",
+ "response_frequency_hz",
+ "reference_amplitude_peak_v",
+ "response_amplitude_peak_v",
+ "reference_vpp_v",
+ "response_vpp_v",
+ "gain_linear",
+ "gain_db",
+ "phase_wrapped_deg",
+ "phase_unwrapped_deg",
+ "status",
+ "warnings",
+ "error",
+ "capture_package",
+ "metadata_path",
+)
+
+
+@dataclass(frozen=True)
+class FrequencyResponsePoint:
+ index: int
+ requested_frequency_hz: float
+ reference_frequency_hz: float | None
+ response_frequency_hz: float | None
+ reference_amplitude_peak_v: float | None
+ response_amplitude_peak_v: float | None
+ reference_vpp_v: float | None
+ response_vpp_v: float | None
+ gain_linear: float | None
+ gain_db: float | None
+ phase_wrapped_deg: float | None
+ phase_unwrapped_deg: float | None
+ status: str
+ warnings: tuple[str, ...] = ()
+ error: str = ""
+ capture_package: str = ""
+ metadata_path: str = ""
+
+ @property
+ def usable_for_fit(self) -> bool:
+ return (
+ self.status != "failed"
+ and self.gain_linear is not None
+ and isfinite(self.gain_linear)
+ and self.gain_linear > 0
+ )
+
+ def as_csv_row(self, fit_values: dict[str, tuple[float | None, float | None]] | None = None) -> dict[str, object]:
+ row: dict[str, object] = {
+ "index": self.index,
+ "requested_frequency_hz": self.requested_frequency_hz,
+ "reference_frequency_hz": self.reference_frequency_hz,
+ "response_frequency_hz": self.response_frequency_hz,
+ "reference_amplitude_peak_v": self.reference_amplitude_peak_v,
+ "response_amplitude_peak_v": self.response_amplitude_peak_v,
+ "reference_vpp_v": self.reference_vpp_v,
+ "response_vpp_v": self.response_vpp_v,
+ "gain_linear": self.gain_linear,
+ "gain_db": self.gain_db,
+ "phase_wrapped_deg": self.phase_wrapped_deg,
+ "phase_unwrapped_deg": self.phase_unwrapped_deg,
+ "status": self.status,
+ "warnings": " | ".join(self.warnings),
+ "error": self.error,
+ "capture_package": self.capture_package,
+ "metadata_path": self.metadata_path,
+ }
+ for method, values in (fit_values or {}).items():
+ row[f"fit_{method}_gain_linear"] = values[0]
+ row[f"fit_{method}_residual"] = values[1]
+ return row
+
+
+def analyze_frequency_response_point(
+ *,
+ index: int,
+ requested_frequency_hz: float,
+ reference_waveform: Any,
+ response_waveform: Any,
+ frequency_tolerance_ratio: float,
+ capture_package: str,
+ metadata_path: str,
+) -> FrequencyResponsePoint:
+ """Compute one transfer-function point from a simultaneous two-channel capture."""
+ try:
+ reference_phasor, reference_amplitude = _fit_sine_phasor(
+ reference_waveform, requested_frequency_hz
+ )
+ response_phasor, response_amplitude = _fit_sine_phasor(
+ response_waveform, requested_frequency_hz
+ )
+ if reference_amplitude <= _amplitude_floor(reference_waveform):
+ raise ValueError("reference fundamental amplitude is zero or too small")
+ if response_amplitude <= _amplitude_floor(response_waveform):
+ raise ValueError("response fundamental amplitude is zero or too small")
+ transfer = response_phasor / reference_phasor
+ gain_linear = float(abs(transfer))
+ if not isfinite(gain_linear) or gain_linear <= 0:
+ raise ValueError("gain is not finite and positive")
+ gain_db = float(20.0 * np.log10(gain_linear))
+ phase_wrapped_deg = _wrap_phase_deg(float(np.degrees(np.angle(transfer))))
+ reference_summary = _summary(reference_waveform, requested_frequency_hz, frequency_tolerance_ratio)
+ response_summary = _summary(response_waveform, requested_frequency_hz, frequency_tolerance_ratio)
+ warnings = _quality_warnings(reference_summary, "reference") + _quality_warnings(
+ response_summary, "response"
+ )
+ return FrequencyResponsePoint(
+ index=index,
+ requested_frequency_hz=requested_frequency_hz,
+ reference_frequency_hz=_finite_or_none(reference_summary.get("frequency_estimate_hz")),
+ response_frequency_hz=_finite_or_none(response_summary.get("frequency_estimate_hz")),
+ reference_amplitude_peak_v=reference_amplitude,
+ response_amplitude_peak_v=response_amplitude,
+ reference_vpp_v=_finite_or_none(reference_summary.get("voltage_vpp_v")),
+ response_vpp_v=_finite_or_none(response_summary.get("voltage_vpp_v")),
+ gain_linear=gain_linear,
+ gain_db=gain_db,
+ phase_wrapped_deg=phase_wrapped_deg,
+ phase_unwrapped_deg=None,
+ status="warning" if warnings else "ok",
+ warnings=tuple(warnings),
+ capture_package=capture_package,
+ metadata_path=metadata_path,
+ )
+ except Exception as exc: # noqa: BLE001 - every point must remain auditable
+ return FrequencyResponsePoint(
+ index=index,
+ requested_frequency_hz=requested_frequency_hz,
+ reference_frequency_hz=None,
+ response_frequency_hz=None,
+ reference_amplitude_peak_v=None,
+ response_amplitude_peak_v=None,
+ reference_vpp_v=None,
+ response_vpp_v=None,
+ gain_linear=None,
+ gain_db=None,
+ phase_wrapped_deg=None,
+ phase_unwrapped_deg=None,
+ status="failed",
+ error=f"{type(exc).__name__}: {exc}",
+ capture_package=capture_package,
+ metadata_path=metadata_path,
+ )
+
+
+def failed_frequency_response_point(
+ *,
+ index: int,
+ requested_frequency_hz: float,
+ error: Exception | str,
+) -> FrequencyResponsePoint:
+ text = str(error)
+ if isinstance(error, Exception):
+ text = f"{type(error).__name__}: {error}"
+ return FrequencyResponsePoint(
+ index=index,
+ requested_frequency_hz=requested_frequency_hz,
+ reference_frequency_hz=None,
+ response_frequency_hz=None,
+ reference_amplitude_peak_v=None,
+ response_amplitude_peak_v=None,
+ reference_vpp_v=None,
+ response_vpp_v=None,
+ gain_linear=None,
+ gain_db=None,
+ phase_wrapped_deg=None,
+ phase_unwrapped_deg=None,
+ status="failed",
+ error=text,
+ )
+
+
+def unwrap_frequency_response_phase(points: list[FrequencyResponsePoint]) -> list[FrequencyResponsePoint]:
+ result: list[FrequencyResponsePoint] = []
+ block: list[FrequencyResponsePoint] = []
+
+ def flush() -> None:
+ if not block:
+ return
+ unwrapped = np.degrees(np.unwrap(np.radians([point.phase_wrapped_deg for point in block])))
+ result.extend(
+ replace(point, phase_unwrapped_deg=float(value))
+ for point, value in zip(block, unwrapped)
+ )
+ block.clear()
+
+ for point in points:
+ if point.status == "failed" or point.phase_wrapped_deg is None:
+ flush()
+ result.append(point)
+ else:
+ block.append(point)
+ flush()
+ return result
+
+
+def ensure_fit_dependencies(fit: dict[str, Any] | None) -> None:
+ methods = _fit_methods(fit)
+ if "pchip" not in methods:
+ return
+ try:
+ from scipy.interpolate import PchipInterpolator # noqa: F401
+ except ImportError as exc:
+ raise ConfigError(
+ "fit method 'pchip' requires the optional analysis dependency; "
+ "install WaveBench with `.[analysis]`"
+ ) from exc
+
+
+def build_fit_document(
+ points: list[FrequencyResponsePoint], fit: dict[str, Any] | None
+) -> tuple[dict[str, Any] | None, dict[str, dict[int, tuple[float | None, float | None]]]]:
+ if fit is None:
+ return None, {}
+ methods = _fit_methods(fit)
+ degree = int(fit.get("polynomial_degree", 3))
+ sample_count = int(fit.get("sample_count", 240))
+ usable = sorted((point for point in points if point.usable_for_fit), key=lambda point: point.requested_frequency_hz)
+ excluded = [
+ {"index": point.index, "frequency_hz": point.requested_frequency_hz, "reason": point.error or point.status}
+ for point in points
+ if not point.usable_for_fit
+ ]
+ document: dict[str, Any] = {
+ "schema_version": 1,
+ "x_transform": "log10(frequency_hz / Hz)",
+ "valid_points": [point.index for point in usable],
+ "excluded_points": excluded,
+ "methods": {},
+ }
+ fit_values: dict[str, dict[int, tuple[float | None, float | None]]] = {}
+ if usable:
+ document["valid_domain_hz"] = [
+ usable[0].requested_frequency_hz,
+ usable[-1].requested_frequency_hz,
+ ]
+ else:
+ document["valid_domain_hz"] = None
+
+ x = np.log10(np.asarray([point.requested_frequency_hz for point in usable], dtype=float))
+ y = np.asarray([point.gain_linear for point in usable], dtype=float)
+ for method in methods:
+ result, values = _fit_method(
+ method=method,
+ x=x,
+ y=y,
+ points=usable,
+ polynomial_degree=degree,
+ sample_count=sample_count,
+ )
+ document["methods"][method] = result
+ fit_values[method] = values
+ return document, fit_values
+
+
+def write_frequency_response_csv(
+ path: str | Path,
+ points: list[FrequencyResponsePoint],
+ fit_values: dict[str, dict[int, tuple[float | None, float | None]]] | None = None,
+) -> Path:
+ output = Path(path)
+ methods = tuple((fit_values or {}).keys())
+ fieldnames = list(BASE_CSV_FIELDS)
+ for method in methods:
+ fieldnames.extend((f"fit_{method}_gain_linear", f"fit_{method}_residual"))
+ temporary = output.with_suffix(output.suffix + ".tmp")
+ with temporary.open("w", newline="", encoding="utf-8") as file:
+ writer = csv.DictWriter(file, fieldnames=fieldnames)
+ writer.writeheader()
+ for point in points:
+ method_values = {
+ method: (fit_values or {}).get(method, {}).get(point.index, (None, None))
+ for method in methods
+ }
+ writer.writerow(point.as_csv_row(method_values))
+ temporary.replace(output)
+ return output
+
+
+def write_fit_document(path: str | Path, document: dict[str, Any] | None) -> Path | None:
+ if document is None:
+ return None
+ output = Path(path)
+ temporary = output.with_suffix(output.suffix + ".tmp")
+ temporary.write_text(json.dumps(document, indent=2, ensure_ascii=False), encoding="utf-8")
+ temporary.replace(output)
+ return output
+
+
+def _fit_sine_phasor(waveform: Any, frequency_hz: float) -> tuple[complex, float]:
+ times = np.asarray(getattr(waveform, "times_s"), dtype=float)
+ voltages = np.asarray(getattr(waveform, "voltages_v"), dtype=float)
+ if not isfinite(frequency_hz) or frequency_hz <= 0:
+ raise ValueError("frequency must be finite and positive")
+ if times.ndim != 1 or voltages.ndim != 1:
+ raise ValueError("waveform time and voltage samples must be one-dimensional")
+ if times.size != voltages.size:
+ raise ValueError("waveform time and voltage sample counts must match")
+ if times.size < 4:
+ raise ValueError("need at least four waveform samples")
+ if not np.all(np.isfinite(times)) or not np.all(np.isfinite(voltages)):
+ raise ValueError("waveform samples must be finite")
+ if np.any(np.diff(times) <= 0):
+ raise ValueError("waveform time samples must be strictly increasing")
+
+ time_origin = float(times[0])
+ omega_times = 2.0 * np.pi * frequency_hz * (times - time_origin)
+ design = np.column_stack((np.ones(times.size), np.sin(omega_times), np.cos(omega_times)))
+ coefficients, _, rank, _ = np.linalg.lstsq(design, voltages, rcond=None)
+ if rank < 3:
+ raise ValueError("sine fit is rank deficient")
+ local_phasor = complex(float(coefficients[1]), float(coefficients[2]))
+ phasor = local_phasor * np.exp(-1j * 2.0 * np.pi * frequency_hz * time_origin)
+ amplitude = float(abs(phasor))
+ if not isfinite(amplitude):
+ raise ValueError("sine fit amplitude is not finite")
+ return phasor, amplitude
+
+
+def _amplitude_floor(waveform: Any) -> float:
+ values = np.asarray(getattr(waveform, "voltages_v"), dtype=float)
+ finite = values[np.isfinite(values)]
+ scale = float(np.max(np.abs(finite))) if finite.size else 0.0
+ return max(1e-12, scale * 1e-12)
+
+
+def _summary(waveform: Any, frequency_hz: float, tolerance_ratio: float) -> dict[str, Any]:
+ summary = waveform.summary(
+ expected_frequency_hz=frequency_hz,
+ frequency_tolerance_ratio=tolerance_ratio,
+ )
+ return summary if isinstance(summary, dict) else {}
+
+
+def _quality_warnings(summary: dict[str, Any], channel: str) -> list[str]:
+ raw = summary.get("quality_warnings", [])
+ if isinstance(raw, list):
+ return [f"{channel}: {item}" for item in raw if item]
+ return [f"{channel}: {raw}"] if raw else []
+
+
+def _finite_or_none(value: Any) -> float | None:
+ try:
+ numeric = float(value)
+ except (TypeError, ValueError):
+ return None
+ return numeric if isfinite(numeric) else None
+
+
+def _wrap_phase_deg(value: float) -> float:
+ return float((value + 180.0) % 360.0 - 180.0)
+
+
+def _fit_methods(fit: dict[str, Any] | None) -> tuple[str, ...]:
+ if fit is None:
+ return ()
+ methods = fit.get("methods", FIT_METHODS)
+ if not isinstance(methods, list):
+ raise ConfigError("frequency response fit methods must be a list")
+ normalized = tuple(str(method).strip().lower() for method in methods)
+ if not normalized or any(method not in FIT_METHODS for method in normalized):
+ choices = ", ".join(FIT_METHODS)
+ raise ConfigError(f"frequency response fit methods must use: {choices}")
+ if len(set(normalized)) != len(normalized):
+ raise ConfigError("frequency response fit methods must not contain duplicates")
+ return normalized
+
+
+def _fit_method(
+ *,
+ method: str,
+ x: np.ndarray,
+ y: np.ndarray,
+ points: list[FrequencyResponsePoint],
+ polynomial_degree: int,
+ sample_count: int,
+) -> tuple[dict[str, Any], dict[int, tuple[float | None, float | None]]]:
+ if x.size < 2:
+ return _unavailable_fit(method, "at least two valid points are required")
+ curve_x = np.linspace(float(x[0]), float(x[-1]), max(2, sample_count))
+ try:
+ if method == "linear_log":
+ predicted = np.interp(x, x, y)
+ curve_y = np.interp(curve_x, x, y)
+ segments = _linear_segments(x, y)
+ result = {
+ "status": "ok",
+ "display": "Log-frequency piecewise linear interpolation",
+ "formula": "x = log10(f / Hz); G = m_i * x + b_i within segment i",
+ "parameters": {"segments": segments},
+ }
+ elif method == "polynomial":
+ if polynomial_degree < 1 or polynomial_degree > 5:
+ return _unavailable_fit(method, "polynomial degree must be from 1 through 5")
+ if x.size < polynomial_degree + 1:
+ return _unavailable_fit(
+ method, f"degree {polynomial_degree} requires at least {polynomial_degree + 1} valid points"
+ )
+ coefficients = np.polyfit(x, y, polynomial_degree)
+ predicted = np.polyval(coefficients, x)
+ curve_y = np.polyval(coefficients, curve_x)
+ terms = [
+ {"power": polynomial_degree - offset, "coefficient": float(value)}
+ for offset, value in enumerate(coefficients)
+ ]
+ result = {
+ "status": "ok",
+ "display": f"Degree-{polynomial_degree} polynomial in log frequency",
+ "formula": "x = log10(f / Hz); G = sum(c_k * x^k)",
+ "parameters": {"degree": polynomial_degree, "terms": terms},
+ }
+ elif method == "pchip":
+ try:
+ from scipy.interpolate import PchipInterpolator
+ except ImportError as exc: # pragma: no cover - guarded in run check
+ return _unavailable_fit(method, f"optional dependency unavailable: {exc}")
+ interpolator = PchipInterpolator(x, y, extrapolate=False)
+ predicted = np.asarray(interpolator(x), dtype=float)
+ curve_y = np.asarray(interpolator(curve_x), dtype=float)
+ result = {
+ "status": "ok",
+ "display": "PCHIP shape-preserving cubic interpolation",
+ "formula": "x = log10(f / Hz); G_i(x) = c3*(x-x_i)^3 + c2*(x-x_i)^2 + c1*(x-x_i) + c0",
+ "parameters": {
+ "segments": _pchip_segments(interpolator),
+ },
+ }
+ else: # pragma: no cover - normalized before dispatch
+ return _unavailable_fit(method, "unsupported method")
+ except Exception as exc: # noqa: BLE001 - optional fit must not discard measurements
+ return _unavailable_fit(method, f"{type(exc).__name__}: {exc}")
+
+ predicted = np.asarray(predicted, dtype=float)
+ curve_y = np.asarray(curve_y, dtype=float)
+ if not np.all(np.isfinite(predicted)) or not np.all(np.isfinite(curve_y)):
+ return _unavailable_fit(method, "fit produced non-finite values")
+ residuals = y - predicted
+ result["metrics"] = _fit_metrics(y, predicted)
+ result["curve"] = [
+ {"frequency_hz": float(10.0**x_value), "gain_linear": float(y_value)}
+ for x_value, y_value in zip(curve_x, curve_y)
+ ]
+ values = {
+ point.index: (float(value), float(residual))
+ for point, value, residual in zip(points, predicted, residuals)
+ }
+ return result, values
+
+
+def _unavailable_fit(method: str, reason: str) -> tuple[dict[str, Any], dict[int, tuple[float | None, float | None]]]:
+ return {
+ "status": "unavailable",
+ "display": method,
+ "reason": reason,
+ "curve": [],
+ }, {}
+
+
+def _linear_segments(x: np.ndarray, y: np.ndarray) -> list[dict[str, float]]:
+ segments: list[dict[str, float]] = []
+ for index in range(x.size - 1):
+ slope = float((y[index + 1] - y[index]) / (x[index + 1] - x[index]))
+ segments.append(
+ {
+ "x_start": float(x[index]),
+ "x_stop": float(x[index + 1]),
+ "gain_start": float(y[index]),
+ "slope": slope,
+ "intercept": float(y[index] - slope * x[index]),
+ }
+ )
+ return segments
+
+
+def _pchip_segments(interpolator: Any) -> list[dict[str, Any]]:
+ return [
+ {
+ "x_start": float(interpolator.x[index]),
+ "x_stop": float(interpolator.x[index + 1]),
+ "coefficients": [float(value) for value in interpolator.c[:, index]],
+ }
+ for index in range(len(interpolator.x) - 1)
+ ]
+
+
+def _fit_metrics(actual: np.ndarray, predicted: np.ndarray) -> dict[str, float | None]:
+ residual = actual - predicted
+ rmse = float(np.sqrt(np.mean(np.square(residual))))
+ mae = float(np.mean(np.abs(residual)))
+ total = float(np.sum(np.square(actual - np.mean(actual))))
+ if total <= 0:
+ r_squared = 1.0 if np.allclose(residual, 0.0, rtol=0.0, atol=1e-12) else None
+ else:
+ r_squared = float(1.0 - np.sum(np.square(residual)) / total)
+ return {"rmse": rmse, "mae": mae, "r_squared": r_squared}
diff --git a/src/wavebench/services/run_analysis.py b/src/wavebench/services/run_analysis.py
index fa28cdb..bd5e337 100644
--- a/src/wavebench/services/run_analysis.py
+++ b/src/wavebench/services/run_analysis.py
@@ -19,10 +19,15 @@ def capture_fft_summary(capture: Any) -> dict[str, Any]:
def step_status(artifact: dict[str, Any]) -> str:
+ response = artifact.get("frequency_response", {})
+ if isinstance(response, dict) and response.get("status") == "failed":
+ return "failed"
if artifact.get("expect", {}).get("status") == "failed":
return "failed"
if artifact.get("expect_fft", {}).get("status") == "failed":
return "failed"
+ if isinstance(response, dict) and response.get("status") == "warning":
+ return "warning"
return "ok"
diff --git a/src/wavebench/services/run_plan.py b/src/wavebench/services/run_plan.py
index 5f8dde1..e8ade0d 100644
--- a/src/wavebench/services/run_plan.py
+++ b/src/wavebench/services/run_plan.py
@@ -3,16 +3,19 @@
from dataclasses import dataclass
from pathlib import Path
import difflib
+from math import log10
from typing import Any
import tomllib
from wavebench.config import normalize_waveform_points
from wavebench.errors import ConfigError
+from wavebench.services.frequency_response import FIT_METHODS
ALLOWED_STEP_KINDS = {
"scope.auto",
"scope.capture",
+ "sweep.frequency_response",
"source.status",
"source.set_freq",
"source.arb_load",
@@ -36,6 +39,7 @@
"source.set_vpp": ("value_vpp",),
"source.set_duty": ("duty_percent",),
"source.output": ("state",),
+ "sweep.frequency_response": ("reference_channel", "response_channel"),
"sleep": ("duration_s",),
}
@@ -62,6 +66,22 @@
"expect",
"expect_fft",
},
+ "sweep.frequency_response": {
+ "label",
+ "source_channel",
+ "frequencies_hz",
+ "start_frequency_hz",
+ "stop_frequency_hz",
+ "frequency_count",
+ "spacing",
+ "target_cycles",
+ "settle_s",
+ "frequency_tolerance",
+ "points",
+ "save_csv",
+ "screenshot",
+ "fit",
+ },
"source.status": {"channel"},
"source.set_freq": {"channel"},
"source.arb_load": {"channel", "offset_v", "sample_rate_hz", "max_points", "byte_order", "output_on"},
@@ -80,6 +100,7 @@
_STEP_NOTES = {
"scope.auto": "Explicit RTM2032 AUToscale. It changes front-panel settings and is never inserted implicitly.",
"scope.capture": "Trigger one acquisition, write a capture package, and optionally evaluate quality/expect checks. Use target_vpp or vertical_scale_v_per_div to fit the waveform vertically before capture.",
+ "sweep.frequency_response": "Sweep a source through discrete frequencies, capture reference and response channels in one acquisition per point, and write a Bode response CSV.",
"source.status": "Read signal-generator channel state without changing output.",
"source.arb_load": "Upload a DG4202 arbitrary waveform from CSV/NPY using DATA:DAC VOLATILE; output remains unchanged unless output_on = true.",
"source.set_freq": "Set fixed source frequency in Hz; config may force FIX mode first.",
@@ -220,6 +241,7 @@ def load_run_plan(path: str | Path) -> RunPlan:
if not isinstance(steps_raw, list) or not steps_raw:
raise ConfigError("run plan requires at least one [[steps]] entry")
steps = [_parse_step(index, item) for index, item in enumerate(steps_raw)]
+ _validate_frequency_response_steps(steps)
return RunPlan(path=plan_path, name=name, label=label, safety=safety, restore=restore, steps=steps)
@@ -369,6 +391,8 @@ def _normalize_step_fields(index: int, kind: str, fields: dict[str, Any]) -> Non
fields["expect"] = _parse_expect(fields["expect"], f"{prefix}.expect")
if "expect_fft" in fields:
fields["expect_fft"] = _parse_expect(fields["expect_fft"], f"{prefix}.expect_fft")
+ elif kind == "sweep.frequency_response":
+ _normalize_frequency_response_fields(prefix, fields)
elif kind == "power.set":
fields["voltage_v"] = _positive_float(fields["voltage_v"], f"{prefix}.voltage_v")
fields["current_limit_a"] = _positive_float(
@@ -412,6 +436,102 @@ def _normalize_step_fields(index: int, kind: str, fields: dict[str, Any]) -> Non
fields["duration_s"] = _positive_float(fields["duration_s"], f"{prefix}.duration_s")
+def _validate_frequency_response_steps(steps: list[RunStep]) -> None:
+ response_steps = [step for step in steps if step.kind == "sweep.frequency_response"]
+ if len(response_steps) > 1:
+ raise ConfigError("a run plan may contain at most one sweep.frequency_response step")
+
+
+def _normalize_frequency_response_fields(prefix: str, fields: dict[str, Any]) -> None:
+ for name in ("source_channel", "reference_channel", "response_channel"):
+ if name in fields:
+ fields[name] = _positive_int(fields[name], f"{prefix}.{name}")
+ if fields["reference_channel"] == fields["response_channel"]:
+ raise ConfigError(f"{prefix}.reference_channel and response_channel must differ")
+ if "label" in fields:
+ fields["label"] = _non_empty_str(fields["label"], f"{prefix}.label")
+
+ explicit = fields.get("frequencies_hz")
+ generated_names = {"start_frequency_hz", "stop_frequency_hz", "frequency_count", "spacing"}
+ has_generated = any(name in fields for name in generated_names)
+ if explicit is not None and has_generated:
+ raise ConfigError(
+ f"{prefix} must use either frequencies_hz or start/stop/frequency_count, not both"
+ )
+ if explicit is not None:
+ if not isinstance(explicit, list) or len(explicit) < 2:
+ raise ConfigError(f"{prefix}.frequencies_hz must be an array with at least two frequencies")
+ frequencies = [_positive_float(value, f"{prefix}.frequencies_hz") for value in explicit]
+ else:
+ required = ("start_frequency_hz", "stop_frequency_hz", "frequency_count")
+ missing = [name for name in required if name not in fields]
+ if missing:
+ raise ConfigError(
+ f"{prefix} requires frequencies_hz or start_frequency_hz, stop_frequency_hz, and frequency_count"
+ )
+ start = _positive_float(fields["start_frequency_hz"], f"{prefix}.start_frequency_hz")
+ stop = _positive_float(fields["stop_frequency_hz"], f"{prefix}.stop_frequency_hz")
+ count = _positive_int(fields["frequency_count"], f"{prefix}.frequency_count")
+ if stop <= start:
+ raise ConfigError(f"{prefix}.stop_frequency_hz must be greater than start_frequency_hz")
+ if count < 2:
+ raise ConfigError(f"{prefix}.frequency_count must be >= 2")
+ spacing = _non_empty_str(fields.get("spacing", "log"), f"{prefix}.spacing").lower()
+ if spacing not in {"log", "linear"}:
+ raise ConfigError(f"{prefix}.spacing must be 'log' or 'linear'")
+ fields["start_frequency_hz"] = start
+ fields["stop_frequency_hz"] = stop
+ fields["frequency_count"] = count
+ fields["spacing"] = spacing
+ if spacing == "log":
+ step = (log10(stop) - log10(start)) / (count - 1)
+ frequencies = [10.0 ** (log10(start) + index * step) for index in range(count)]
+ else:
+ step = (stop - start) / (count - 1)
+ frequencies = [start + index * step for index in range(count)]
+ if any(second <= first for first, second in zip(frequencies, frequencies[1:])):
+ raise ConfigError(f"{prefix}.frequencies_hz must be strictly increasing and unique")
+ fields["frequencies_hz"] = frequencies
+
+ fields["target_cycles"] = _positive_float(
+ fields.get("target_cycles", 10.0), f"{prefix}.target_cycles"
+ )
+ settle_s = _finite_float(fields.get("settle_s", 0.3), f"{prefix}.settle_s")
+ if settle_s < 0:
+ raise ConfigError(f"{prefix}.settle_s must be >= 0")
+ fields["settle_s"] = settle_s
+ if "frequency_tolerance" in fields:
+ fields["frequency_tolerance"] = _positive_float(
+ fields["frequency_tolerance"], f"{prefix}.frequency_tolerance"
+ )
+ if "points" in fields:
+ fields["points"] = normalize_waveform_points(
+ _non_empty_str(fields["points"], f"{prefix}.points")
+ )
+ for name in ("save_csv", "screenshot"):
+ if name in fields and not isinstance(fields[name], bool):
+ raise ConfigError(f"{prefix}.{name} must be true or false")
+ if "fit" in fields:
+ fields["fit"] = _parse_frequency_response_fit(fields["fit"], f"{prefix}.fit")
+
+
+def _parse_frequency_response_fit(raw: Any, name: str) -> dict[str, Any]:
+ table = _table(raw, name)
+ _reject_unknown_keys(table, {"methods", "polynomial_degree"}, name)
+ methods_raw = table.get("methods", list(FIT_METHODS))
+ if not isinstance(methods_raw, list) or not methods_raw:
+ raise ConfigError(f"{name}.methods must be a non-empty array")
+ methods = [_non_empty_str(value, f"{name}.methods").lower() for value in methods_raw]
+ if any(method not in FIT_METHODS for method in methods):
+ raise ConfigError(f"{name}.methods must use: {', '.join(FIT_METHODS)}")
+ if len(set(methods)) != len(methods):
+ raise ConfigError(f"{name}.methods must not contain duplicates")
+ degree = _positive_int(table.get("polynomial_degree", 3), f"{name}.polynomial_degree")
+ if degree > 5:
+ raise ConfigError(f"{name}.polynomial_degree must be <= 5")
+ return {"methods": methods, "polynomial_degree": degree}
+
+
def _parse_expect(raw: Any, name: str) -> dict[str, dict[str, float]]:
table = _table(raw, name)
if not table:
diff --git a/src/wavebench/services/run_safety.py b/src/wavebench/services/run_safety.py
index 6e6151a..3ee57bc 100644
--- a/src/wavebench/services/run_safety.py
+++ b/src/wavebench/services/run_safety.py
@@ -19,6 +19,7 @@ def require_high_impedance(self, channel: int, *, allow_50ohm: bool = False) ->
"power.output",
"scope.auto",
"scope.capture",
+ "sweep.frequency_response",
"source.status",
"source.set_freq",
"source.arb_load",
@@ -73,6 +74,11 @@ def plan_scope_guard_channels(plan: RunPlan, default_channel: int) -> list[int]:
channel = step.fields.get("channel") or default_channel
if channel not in channels:
channels.append(channel)
+ elif step.kind == "sweep.frequency_response":
+ for field in ("reference_channel", "response_channel"):
+ channel = step.fields[field]
+ if channel not in channels:
+ channels.append(channel)
return channels
diff --git a/src/wavebench/services/run_service.py b/src/wavebench/services/run_service.py
index 5b208b7..957317c 100644
--- a/src/wavebench/services/run_service.py
+++ b/src/wavebench/services/run_service.py
@@ -9,12 +9,21 @@
from wavebench.config import WaveBenchConfig
from wavebench.data.package import new_package_dir
-from wavebench.errors import ConfigError, WaveBenchError
+from wavebench.errors import ConfigError
from wavebench.instruments.capabilities import require_capabilities
from wavebench.instruments.registry import resolve_instrument_descriptor
from wavebench.logging import CommandLogger
from wavebench.services.power_service import PowerService
from wavebench.services.dmm_service import DmmService
+from wavebench.services.frequency_response import (
+ analyze_frequency_response_point,
+ build_fit_document,
+ ensure_fit_dependencies,
+ failed_frequency_response_point,
+ unwrap_frequency_response_phase,
+ write_fit_document,
+ write_frequency_response_csv,
+)
from wavebench.services.run_artifacts import RunStepRecord, write_run_files, write_step_record
from wavebench.services.run_analysis import (
capture_consistency,
@@ -58,6 +67,15 @@ class RunInstrumentServices:
dmm: DmmService | None = None
+class _FrequencyResponseExecutionError(Exception):
+ """Carry the partial sweep artifact so a fatal source failure remains auditable."""
+
+ def __init__(self, record: RunStepRecord, cause: Exception) -> None:
+ self.record = record
+ self.cause = cause
+ super().__init__(str(cause))
+
+
def run_output_base(config: WaveBenchConfig) -> Path:
return config.output.directory.parent / "runs"
@@ -139,6 +157,17 @@ def add(kind: str, *capabilities: str) -> None:
add("scope", "scope.screenshot")
if step.fields.get("autoscale_before_capture") or step.fields.get("auto_recover"):
add("scope", "scope.autoscale")
+ elif step.kind == "sweep.frequency_response":
+ add("scope", "scope.idn", "scope.capture_waveforms")
+ add("source", "source.status", "source.set_frequency")
+ source = self.config.source
+ if self.config.scope.check_errors:
+ add("scope", "scope.errors")
+ if source is not None and source.check_errors:
+ add("source", "source.errors")
+ if step.fields.get("screenshot", self.config.output.save_screenshot):
+ add("scope", "scope.screenshot")
+ ensure_fit_dependencies(step.fields.get("fit"))
elif step.kind == "source.status":
add("source", "source.status")
elif step.kind == "source.set_freq":
@@ -207,6 +236,9 @@ def _driver_reference(self, kind: str) -> str:
def _plan_instruments(self, plan: RunPlan) -> set[str]:
instruments = {step.kind.split(".", 1)[0] for step in plan.steps if "." in step.kind}
instruments.discard("sleep")
+ if "sweep" in instruments:
+ instruments.discard("sweep")
+ instruments.update({"source", "scope"})
if plan.restore.source_state:
instruments.add("source")
if plan.safety.require_scope_coupling_not:
@@ -236,10 +268,15 @@ def run(self, plan: RunPlan) -> RunResult:
source_service_factory=lambda: self._source_service(services=services),
)
for step in plan.steps:
- record = self._run_step(plan, step, services=services)
+ record = self._run_step(plan, step, run_dir=run_dir, services=services)
records.append(record)
write_step_record(steps_dir, record)
except Exception as exc:
+ failure = exc
+ if isinstance(exc, _FrequencyResponseExecutionError):
+ records.append(exc.record)
+ write_step_record(steps_dir, exc.record)
+ failure = exc.cause
restore_error = restore_source_state(
restore_state,
source_service_factory=lambda: self._source_service(services=services),
@@ -250,12 +287,12 @@ def run(self, plan: RunPlan) -> RunResult:
summary_csv_path=summary_csv_path,
status="failed",
records=records,
- error={"type": type(exc).__name__, "message": str(exc)},
+ error={"type": type(failure).__name__, "message": str(failure)},
restore_state=restore_state,
restore_error=restore_error,
)
- if isinstance(exc, WaveBenchError):
- raise
+ if isinstance(exc, _FrequencyResponseExecutionError):
+ raise failure from None
raise
restore_error = restore_source_state(
@@ -310,6 +347,7 @@ def _run_step(
plan: RunPlan,
step: RunStep,
*,
+ run_dir: Path,
services: RunInstrumentServices | None = None,
) -> RunStepRecord:
if step.kind == "power.status":
@@ -379,6 +417,12 @@ def _run_step(
artifact = {"autoscale": "completed"}
elif step.kind == "scope.capture":
artifact = self._run_scope_capture_step(plan, step, services=services)
+ elif step.kind == "sweep.frequency_response":
+ artifact = self._run_frequency_response_step(
+ step,
+ run_dir=run_dir,
+ services=services,
+ )
elif step.kind == "dmm.read":
reading = self._dmm_service(services=services).read(function=step.fields.get("function", "dcv"))
reading_payload = _status_payload(reading)
@@ -398,6 +442,204 @@ def _run_step(
artifact=artifact,
)
+ def _run_frequency_response_step(
+ self,
+ step: RunStep,
+ *,
+ run_dir: Path,
+ services: RunInstrumentServices | None = None,
+ ) -> dict[str, Any]:
+ source_channel = step.fields.get("source_channel")
+ source = self._source_service(services=services)
+ csv_path = run_dir / "frequency_response.csv"
+ fit_path = run_dir / "frequency_response_fit.json"
+ points = []
+ reference_channel = step.fields["reference_channel"]
+ response_channel = step.fields["response_channel"]
+ tolerance = step.fields.get(
+ "frequency_tolerance", self.config.waveform.frequency_tolerance_ratio
+ )
+ label = step.fields.get("label", f"frequency_response_{step.index:02d}")
+
+ try:
+ source_status = source.status(channel=source_channel)
+ if str(source_status.output).strip().upper() != "ON":
+ raise ConfigError(
+ "frequency response requires the source output to be ON; "
+ "enable it explicitly with a source.output step before the sweep"
+ )
+ except Exception as exc:
+ write_frequency_response_csv(csv_path, points)
+ raise self._frequency_response_execution_error(
+ step,
+ exc,
+ points=points,
+ csv_path=csv_path,
+ fit_path=None,
+ source_channel=source_channel,
+ reference_channel=reference_channel,
+ response_channel=response_channel,
+ ) from exc
+
+ for index, frequency_hz in enumerate(step.fields["frequencies_hz"]):
+ try:
+ source_status = source.set_frequency(channel=source_channel, value_hz=frequency_hz)
+ except Exception as exc:
+ points.append(
+ failed_frequency_response_point(
+ index=index,
+ requested_frequency_hz=frequency_hz,
+ error=exc,
+ )
+ )
+ write_frequency_response_csv(csv_path, points)
+ raise self._frequency_response_execution_error(
+ step,
+ exc,
+ points=points,
+ csv_path=csv_path,
+ fit_path=None,
+ source_channel=source_channel,
+ reference_channel=reference_channel,
+ response_channel=response_channel,
+ ) from exc
+ if str(source_status.output).strip().upper() != "ON":
+ error = ConfigError(
+ f"source output is {source_status.output} after setting {frequency_hz:.12g} Hz"
+ )
+ points.append(
+ failed_frequency_response_point(
+ index=index,
+ requested_frequency_hz=frequency_hz,
+ error=error,
+ )
+ )
+ write_frequency_response_csv(csv_path, points)
+ raise self._frequency_response_execution_error(
+ step,
+ error,
+ points=points,
+ csv_path=csv_path,
+ fit_path=None,
+ source_channel=source_channel,
+ reference_channel=reference_channel,
+ response_channel=response_channel,
+ ) from error
+ if step.fields["settle_s"]:
+ time.sleep(step.fields["settle_s"])
+
+ scope = self._scope_service_for_frequency_response(
+ step,
+ frequency_hz=frequency_hz,
+ services=services,
+ )
+ try:
+ capture = scope.capture_waveforms(
+ channels=[reference_channel, response_channel],
+ label=f"{label}_{index:03d}_{frequency_hz:.12g}hz",
+ )
+ points.append(
+ analyze_frequency_response_point(
+ index=index,
+ requested_frequency_hz=frequency_hz,
+ reference_waveform=capture.waveforms[reference_channel],
+ response_waveform=capture.waveforms[response_channel],
+ frequency_tolerance_ratio=tolerance,
+ capture_package=str(capture.package_dir),
+ metadata_path=str(capture.metadata_path),
+ )
+ )
+ except Exception as exc: # noqa: BLE001 - retain failed points and continue the sweep
+ points.append(
+ failed_frequency_response_point(
+ index=index,
+ requested_frequency_hz=frequency_hz,
+ error=exc,
+ )
+ )
+ write_frequency_response_csv(csv_path, points)
+
+ points = unwrap_frequency_response_phase(points)
+ fit_document, fit_values = build_fit_document(points, step.fields.get("fit"))
+ write_frequency_response_csv(csv_path, points, fit_values)
+ written_fit_path = write_fit_document(fit_path, fit_document)
+ return self._frequency_response_artifact(
+ points=points,
+ csv_path=csv_path,
+ fit_path=written_fit_path,
+ source_channel=source_channel,
+ reference_channel=reference_channel,
+ response_channel=response_channel,
+ )
+
+ def _frequency_response_execution_error(
+ self,
+ step: RunStep,
+ cause: Exception,
+ *,
+ points: list[Any],
+ csv_path: Path,
+ fit_path: Path | None,
+ source_channel: int | None,
+ reference_channel: int,
+ response_channel: int,
+ ) -> _FrequencyResponseExecutionError:
+ artifact = self._frequency_response_artifact(
+ points=points,
+ csv_path=csv_path,
+ fit_path=fit_path,
+ source_channel=source_channel,
+ reference_channel=reference_channel,
+ response_channel=response_channel,
+ error=cause,
+ )
+ record = RunStepRecord(
+ index=step.index,
+ kind=step.kind,
+ status="failed",
+ fields=step.fields,
+ artifact=artifact,
+ )
+ return _FrequencyResponseExecutionError(record, cause)
+
+ def _frequency_response_artifact(
+ self,
+ *,
+ points: list[Any],
+ csv_path: Path,
+ fit_path: Path | None,
+ source_channel: int | None,
+ reference_channel: int,
+ response_channel: int,
+ error: Exception | None = None,
+ ) -> dict[str, Any]:
+ failed_points = sum(point.status == "failed" for point in points)
+ warning_points = sum(point.status == "warning" for point in points)
+ captures = [
+ {
+ "index": point.index,
+ "package": point.capture_package,
+ "metadata": point.metadata_path,
+ }
+ for point in points
+ if point.capture_package
+ ]
+ response: dict[str, Any] = {
+ "status": "failed" if error is not None or failed_points else ("warning" if warning_points else "ok"),
+ "csv": str(csv_path),
+ "fit_json": str(fit_path) if fit_path is not None else "",
+ "point_count": len(points),
+ "failed_point_count": failed_points,
+ "warning_point_count": warning_points,
+ "source_channel": source_channel,
+ "reference_channel": reference_channel,
+ "response_channel": response_channel,
+ "captures": captures,
+ }
+ if error is not None:
+ response["error"] = f"{type(error).__name__}: {error}"
+ return {"frequency_response": response}
+
def _run_scope_capture_step(
self,
plan: RunPlan,
@@ -584,6 +826,34 @@ def _scope_service_for_capture(
)
return ScopeService(config=config, logger=CommandLogger())
+ def _scope_service_for_frequency_response(
+ self,
+ step: RunStep,
+ *,
+ frequency_hz: float,
+ services: RunInstrumentServices | None = None,
+ ) -> ScopeService:
+ config = self.config.with_waveform_overrides(
+ points=step.fields.get("points"),
+ time_range_s=step.fields["target_cycles"] / frequency_hz,
+ expected_frequency_hz=frequency_hz,
+ frequency_tolerance_ratio=step.fields.get("frequency_tolerance"),
+ target_cycles=step.fields["target_cycles"],
+ window_frequency_hz=frequency_hz,
+ ).with_output_overrides(
+ save_csv=step.fields.get("save_csv"),
+ save_npy=True,
+ save_json=True,
+ save_screenshot=step.fields.get("screenshot"),
+ )
+ if services is not None and services.scope is not None:
+ return ScopeService(
+ config=config,
+ logger=services.scope.logger,
+ session=services.scope.session,
+ )
+ return ScopeService(config=config, logger=CommandLogger())
+
def _has_waveform_overrides(step: RunStep) -> bool:
return any(
diff --git a/src/wavebench/services/run_templates.py b/src/wavebench/services/run_templates.py
index f9d523c..21ecaa6 100644
--- a/src/wavebench/services/run_templates.py
+++ b/src/wavebench/services/run_templates.py
@@ -22,6 +22,9 @@ class RunTemplateOptions:
vpp: float = 1.0
source_channel: int | None = None
scope_channel: int | None = None
+ reference_channel: int | None = None
+ response_channel: int | None = None
+ frequency_response_fit: bool = False
power_channel: int | None = None
voltage_v: float = 3.3
current_limit_a: float = 0.1
@@ -46,6 +49,8 @@ def render_run_template(name: str, options: RunTemplateOptions | None = None) ->
return _render_source_scope_sine(opts)
if name == "source-scope-sweep":
return _render_source_scope_sweep(opts)
+ if name == "source-scope-frequency-response":
+ return _render_source_scope_frequency_response(opts)
if name == "dmm-acv-source":
return _render_dmm_acv_source(opts)
if name == "power-dmm-dcv":
@@ -77,6 +82,10 @@ def write_run_template(
name="source-scope-sweep",
description="DG4202 source -> RTM2032 scope multi-frequency sine sweep as an expanded run plan",
),
+ "source-scope-frequency-response": RunTemplate(
+ name="source-scope-frequency-response",
+ description="DG4202 source -> two scope channels frequency response with optional gain fitting",
+ ),
"dmm-acv-source": RunTemplate(
name="dmm-acv-source",
description="DG4202 source -> DMM ACV smoke with source-state restore",
@@ -292,6 +301,83 @@ def _render_source_scope_sweep_point(frequency_hz: float, vpp: float, source_cha
).strip()
+def _render_source_scope_frequency_response(options: RunTemplateOptions) -> str:
+ source_channel = options.source_channel or 1
+ reference_channel = options.reference_channel or 1
+ response_channel = options.response_channel or 2
+ if reference_channel == response_channel:
+ raise ConfigError("--reference-channel and --response-channel must differ")
+ frequencies = options.frequencies_hz or (100.0, 1000.0, 10000.0)
+ if len(frequencies) < 2:
+ raise ConfigError("frequency-response template requires at least two --frequencies values")
+ if any(second <= first for first, second in zip(frequencies, frequencies[1:])):
+ raise ConfigError(
+ "frequency-response template --frequencies values must be strictly increasing and unique"
+ )
+ label = (
+ f"source_scope_frequency_response_{_frequency_label(frequencies[0])}_to_"
+ f"{_frequency_label(frequencies[-1])}"
+ )
+ fit = ""
+ if options.frequency_response_fit:
+ polynomial_degree = min(3, len(frequencies) - 1)
+ fit = """
+
+ [steps.fit]
+ methods = ["linear_log", "polynomial", "pchip"]
+ polynomial_degree = {polynomial_degree}
+ """.format(polynomial_degree=polynomial_degree)
+ frequency_text = ", ".join(_fmt(value) for value in frequencies)
+ return _clean(
+ f"""
+ # WaveBench template: DG4202 CH{source_channel} -> DUT -> scope CH{response_channel}.
+ # Scope CH{reference_channel} measures the DUT input reference. Confirm both probe paths,
+ # high-impedance inputs, vertical scales, and source output before execution.
+
+ [experiment]
+ name = "{label}"
+ label = "{label}"
+
+ [restore]
+ source_state = true
+ source_channel = {source_channel}
+
+ [[steps]]
+ kind = "source.status"
+ channel = {source_channel}
+
+ [[steps]]
+ kind = "source.set_func"
+ channel = {source_channel}
+ function = "sin"
+
+ [[steps]]
+ kind = "source.set_vpp"
+ channel = {source_channel}
+ value_vpp = {_fmt(options.vpp)}
+
+ [[steps]]
+ kind = "source.output"
+ channel = {source_channel}
+ state = "on"
+
+ [[steps]]
+ kind = "sweep.frequency_response"
+ label = "{label}"
+ source_channel = {source_channel}
+ reference_channel = {reference_channel}
+ response_channel = {response_channel}
+ frequencies_hz = [{frequency_text}]
+ target_cycles = 10
+ settle_s = 0.3
+ points = "def"
+ save_csv = false
+ screenshot = false
+ {fit}
+ """
+ )
+
+
def _render_dmm_acv_source(options: RunTemplateOptions) -> str:
source_channel = options.source_channel or 2
label = f"dmm_acv_source_{_frequency_label(options.frequency_hz)}"
@@ -400,6 +486,8 @@ def _validate_options(options: RunTemplateOptions) -> RunTemplateOptions:
for label, value in (
("--source-channel", options.source_channel),
("--scope-channel", options.scope_channel),
+ ("--reference-channel", options.reference_channel),
+ ("--response-channel", options.response_channel),
("--power-channel", options.power_channel),
):
if value is not None and value <= 0:
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 92f2fc5..7383d5e 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -1315,6 +1315,114 @@ def test_run_report_accepts_path_and_output(self):
self.assertEqual(args.path, "data/runs/example")
self.assertEqual(args.output, "report.html")
+ def test_run_report_accepts_pdf_options(self):
+ args = build_parser().parse_args(
+ ["run", "report", "data/runs/example", "--pdf", "--pdf-output", "report.pdf"]
+ )
+
+ self.assertTrue(args.pdf)
+ self.assertEqual(args.pdf_output, "report.pdf")
+
+ def test_run_report_writes_html_and_pdf_to_distinct_paths(self):
+ with TemporaryDirectory() as tmp:
+ run_dir = Path(tmp) / "run"
+ run_dir.mkdir()
+ (run_dir / "run.json").write_text(json.dumps({"status": "ok", "steps": []}), encoding="utf-8")
+ html_path = Path(tmp) / "report.html"
+ pdf_path = Path(tmp) / "report.pdf"
+ stdout = io.StringIO()
+ with patch("wavebench.cli.write_run_report_html", return_value=html_path) as write_html, patch(
+ "wavebench.cli.write_run_report_pdf", return_value=pdf_path
+ ) as write_pdf, redirect_stdout(stdout):
+ code = main(
+ [
+ "run",
+ "report",
+ str(run_dir),
+ "--output",
+ str(html_path),
+ "--pdf",
+ "--pdf-output",
+ str(pdf_path),
+ ]
+ )
+
+ self.assertEqual(code, 0)
+ write_html.assert_called_once()
+ write_pdf.assert_called_once()
+ self.assertIn(f"report={html_path}", stdout.getvalue())
+ self.assertIn(f"pdf={pdf_path}", stdout.getvalue())
+
+ def test_run_report_rejects_pdf_output_without_pdf_flag_and_path_collisions(self):
+ with TemporaryDirectory() as tmp:
+ run_dir = Path(tmp) / "run"
+ run_dir.mkdir()
+ (run_dir / "run.json").write_text(json.dumps({"status": "ok", "steps": []}), encoding="utf-8")
+ stderr = io.StringIO()
+
+ with redirect_stderr(stderr):
+ missing_flag = main(["run", "report", str(run_dir), "--pdf-output", "report.pdf"])
+ self.assertEqual(missing_flag, 2)
+ self.assertIn("requires --pdf", stderr.getvalue())
+
+ stderr = io.StringIO()
+ same_path = Path(tmp) / "report.html"
+ with redirect_stderr(stderr):
+ collision = main(
+ [
+ "run",
+ "report",
+ str(run_dir),
+ "--output",
+ str(same_path),
+ "--pdf",
+ "--pdf-output",
+ str(same_path),
+ ]
+ )
+ self.assertEqual(collision, 2)
+ self.assertIn("different paths", stderr.getvalue())
+
+ def test_run_report_rejects_misleading_html_and_pdf_suffixes(self):
+ with TemporaryDirectory() as tmp:
+ run_dir = Path(tmp) / "run"
+ run_dir.mkdir()
+ (run_dir / "run.json").write_text(json.dumps({"status": "ok", "steps": []}), encoding="utf-8")
+ cases = [
+ (
+ [
+ "--output",
+ str(Path(tmp) / "html-named.pdf"),
+ "--pdf",
+ "--pdf-output",
+ str(Path(tmp) / "report.pdf"),
+ ],
+ "--output is an HTML path",
+ ),
+ (
+ [
+ "--output",
+ str(Path(tmp) / "report.html"),
+ "--pdf",
+ "--pdf-output",
+ str(Path(tmp) / "pdf-named.html"),
+ ],
+ "--pdf-output is a PDF path",
+ ),
+ ]
+
+ for options, message in cases:
+ stderr = io.StringIO()
+ with patch("wavebench.cli.write_run_report_html") as write_html, patch(
+ "wavebench.cli.write_run_report_pdf"
+ ) as write_pdf, redirect_stderr(stderr):
+ code = main(["run", "report", str(run_dir), *options])
+
+ self.assertEqual(code, 2)
+ self.assertIn(message, stderr.getvalue())
+ write_html.assert_not_called()
+ write_pdf.assert_not_called()
+
if __name__ == "__main__":
unittest.main()
diff --git a/tests/test_frequency_response.py b/tests/test_frequency_response.py
new file mode 100644
index 0000000..e489a33
--- /dev/null
+++ b/tests/test_frequency_response.py
@@ -0,0 +1,270 @@
+from __future__ import annotations
+
+import importlib.util
+from pathlib import Path
+from tempfile import TemporaryDirectory
+from types import SimpleNamespace
+import unittest
+
+import numpy as np
+
+from wavebench.instruments.models import WaveformData, WaveformHeader
+from wavebench.services.frequency_response import (
+ FrequencyResponsePoint,
+ analyze_frequency_response_point,
+ build_fit_document,
+ unwrap_frequency_response_phase,
+ write_fit_document,
+ write_frequency_response_csv,
+)
+
+
+HAS_SCIPY = importlib.util.find_spec("scipy") is not None
+
+
+def _waveform(
+ *,
+ channel: int,
+ start_s: float,
+ samples: int,
+ sample_rate_hz: float,
+ frequency_hz: float,
+ amplitude_peak_v: float,
+ phase_deg: float,
+ offset_v: float,
+) -> WaveformData:
+ times = start_s + np.arange(samples, dtype=float) / sample_rate_hz
+ voltages = offset_v + amplitude_peak_v * np.sin(
+ 2.0 * np.pi * frequency_hz * times + np.radians(phase_deg)
+ )
+ return WaveformData(
+ channel=channel,
+ header=WaveformHeader(
+ x_start=float(times[0]),
+ x_stop=float(times[-1]),
+ points=samples,
+ segment=1,
+ ),
+ voltages_v=voltages,
+ )
+
+
+def _point(index: int, frequency_hz: float, gain: float, phase: float = 0.0) -> FrequencyResponsePoint:
+ return FrequencyResponsePoint(
+ index=index,
+ requested_frequency_hz=frequency_hz,
+ reference_frequency_hz=frequency_hz,
+ response_frequency_hz=frequency_hz,
+ reference_amplitude_peak_v=1.0,
+ response_amplitude_peak_v=gain,
+ reference_vpp_v=2.0,
+ response_vpp_v=2.0 * gain,
+ gain_linear=gain,
+ gain_db=20.0 * np.log10(gain),
+ phase_wrapped_deg=phase,
+ phase_unwrapped_deg=None,
+ status="ok",
+ )
+
+
+class FrequencyResponseTests(unittest.TestCase):
+ def test_measures_gain_and_phase_from_different_time_origins(self):
+ frequency_hz = 1_000.0
+ reference = _waveform(
+ channel=1,
+ start_s=0.017,
+ samples=4096,
+ sample_rate_hz=100_000.0,
+ frequency_hz=frequency_hz,
+ amplitude_peak_v=1.5,
+ phase_deg=25.0,
+ offset_v=0.2,
+ )
+ response = _waveform(
+ channel=2,
+ start_s=0.0173,
+ samples=3500,
+ sample_rate_hz=100_000.0,
+ frequency_hz=frequency_hz,
+ amplitude_peak_v=3.0,
+ phase_deg=-65.0,
+ offset_v=-0.4,
+ )
+
+ point = analyze_frequency_response_point(
+ index=0,
+ requested_frequency_hz=frequency_hz,
+ reference_waveform=reference,
+ response_waveform=response,
+ frequency_tolerance_ratio=0.05,
+ capture_package="capture",
+ metadata_path="metadata.json",
+ )
+
+ self.assertNotEqual(point.status, "failed")
+ self.assertAlmostEqual(point.reference_amplitude_peak_v or 0.0, 1.5, places=8)
+ self.assertAlmostEqual(point.response_amplitude_peak_v or 0.0, 3.0, places=8)
+ self.assertAlmostEqual(point.gain_linear or 0.0, 2.0, places=8)
+ self.assertAlmostEqual(point.gain_db or 0.0, 6.020599913, places=7)
+ self.assertAlmostEqual(point.phase_wrapped_deg or 0.0, -90.0, places=7)
+
+ def test_zero_response_is_a_failed_point(self):
+ frequency_hz = 1_000.0
+ reference = _waveform(
+ channel=1,
+ start_s=0.0,
+ samples=200,
+ sample_rate_hz=50_000.0,
+ frequency_hz=frequency_hz,
+ amplitude_peak_v=1.0,
+ phase_deg=0.0,
+ offset_v=0.0,
+ )
+ response = _waveform(
+ channel=2,
+ start_s=0.0,
+ samples=200,
+ sample_rate_hz=50_000.0,
+ frequency_hz=frequency_hz,
+ amplitude_peak_v=0.0,
+ phase_deg=0.0,
+ offset_v=0.0,
+ )
+
+ point = analyze_frequency_response_point(
+ index=0,
+ requested_frequency_hz=frequency_hz,
+ reference_waveform=reference,
+ response_waveform=response,
+ frequency_tolerance_ratio=0.05,
+ capture_package="capture",
+ metadata_path="metadata.json",
+ )
+
+ self.assertEqual(point.status, "failed")
+ self.assertIn("too small", point.error)
+
+ def test_phase_unwrap_does_not_bridge_failed_points(self):
+ points = [
+ _point(0, 10.0, 1.0, 170.0),
+ _point(1, 100.0, 1.0, -170.0),
+ FrequencyResponsePoint(
+ index=2,
+ requested_frequency_hz=1_000.0,
+ reference_frequency_hz=None,
+ response_frequency_hz=None,
+ reference_amplitude_peak_v=None,
+ response_amplitude_peak_v=None,
+ reference_vpp_v=None,
+ response_vpp_v=None,
+ gain_linear=None,
+ gain_db=None,
+ phase_wrapped_deg=None,
+ phase_unwrapped_deg=None,
+ status="failed",
+ error="capture failed",
+ ),
+ _point(3, 10_000.0, 1.0, -170.0),
+ _point(4, 100_000.0, 1.0, 170.0),
+ ]
+
+ result = unwrap_frequency_response_phase(points)
+
+ self.assertAlmostEqual(result[0].phase_unwrapped_deg or 0.0, 170.0)
+ self.assertAlmostEqual(result[1].phase_unwrapped_deg or 0.0, 190.0)
+ self.assertIsNone(result[2].phase_unwrapped_deg)
+ self.assertAlmostEqual(result[3].phase_unwrapped_deg or 0.0, -170.0)
+ self.assertAlmostEqual(result[4].phase_unwrapped_deg or 0.0, -190.0)
+
+ def test_fit_document_writes_csv_and_json_for_all_candidates(self):
+ frequencies = (10.0, 100.0, 1_000.0, 10_000.0)
+ points = [_point(index, frequency, 1.0 + 0.5 * np.log10(frequency)) for index, frequency in enumerate(frequencies)]
+ methods = ["linear_log", "polynomial"]
+ if HAS_SCIPY:
+ methods.append("pchip")
+
+ document, values = build_fit_document(
+ points,
+ {"methods": methods, "polynomial_degree": 2},
+ )
+
+ assert document is not None
+ self.assertEqual(document["methods"]["linear_log"]["status"], "ok")
+ self.assertEqual(document["methods"]["polynomial"]["status"], "ok")
+ if HAS_SCIPY:
+ self.assertEqual(document["methods"]["pchip"]["status"], "ok")
+ self.assertAlmostEqual(values["linear_log"][2][0] or 0.0, 2.5)
+ with TemporaryDirectory() as tmp:
+ csv_path = write_frequency_response_csv(Path(tmp) / "frequency_response.csv", points, values)
+ json_path = write_fit_document(Path(tmp) / "frequency_response_fit.json", document)
+ self.assertIn("fit_polynomial_gain_linear", csv_path.read_text(encoding="utf-8"))
+ self.assertIsNotNone(json_path)
+ if HAS_SCIPY:
+ self.assertIn("PCHIP", json_path.read_text(encoding="utf-8"))
+
+ def test_fit_formulas_export_directly_usable_piecewise_parameters(self):
+ points = [
+ _point(0, 10.0, 1.0),
+ _point(1, 100.0, 1.5),
+ _point(2, 1000.0, 2.0),
+ ]
+
+ document, _values = build_fit_document(
+ points,
+ {"methods": ["linear_log", "polynomial"], "polynomial_degree": 2},
+ )
+
+ assert document is not None
+ segment = document["methods"]["linear_log"]["parameters"]["segments"][0]
+ self.assertEqual(segment["x_start"], 1.0)
+ self.assertEqual(segment["x_stop"], 2.0)
+ self.assertAlmostEqual(segment["slope"], 0.5)
+ self.assertAlmostEqual(segment["intercept"], 0.5)
+ polynomial = document["methods"]["polynomial"]
+ self.assertEqual(polynomial["metrics"]["r_squared"], 1.0)
+ if HAS_SCIPY:
+ pchip_document, _values = build_fit_document(
+ points,
+ {"methods": ["pchip"], "polynomial_degree": 2},
+ )
+ assert pchip_document is not None
+ pchip_segment = pchip_document["methods"]["pchip"]["parameters"]["segments"][0]
+ self.assertEqual(len(pchip_segment["coefficients"]), 4)
+ self.assertEqual(pchip_segment["x_start"], 1.0)
+
+ def test_invalid_waveform_time_axis_becomes_an_auditable_failed_point(self):
+ frequency_hz = 1_000.0
+ reference = _waveform(
+ channel=1,
+ start_s=0.0,
+ samples=100,
+ sample_rate_hz=50_000.0,
+ frequency_hz=frequency_hz,
+ amplitude_peak_v=1.0,
+ phase_deg=0.0,
+ offset_v=0.0,
+ )
+ response_times = reference.times_s.copy()
+ response_times[20] = response_times[19]
+ response = SimpleNamespace(
+ times_s=response_times,
+ voltages_v=reference.voltages_v.copy(),
+ summary=lambda **kwargs: {},
+ )
+
+ point = analyze_frequency_response_point(
+ index=0,
+ requested_frequency_hz=frequency_hz,
+ reference_waveform=reference,
+ response_waveform=response,
+ frequency_tolerance_ratio=0.05,
+ capture_package="capture",
+ metadata_path="metadata.json",
+ )
+
+ self.assertEqual(point.status, "failed")
+ self.assertIn("strictly increasing", point.error)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_packages.py b/tests/test_packages.py
index d776a1c..d63d0dc 100644
--- a/tests/test_packages.py
+++ b/tests/test_packages.py
@@ -89,6 +89,24 @@ def test_load_run_package_reads_run_json_and_summary(self):
self.assertEqual(len(loaded.steps), 1)
self.assertEqual(loaded.summary_rows[0]["kind"], "scope.capture")
+ def test_load_run_package_reads_frequency_response_and_tolerates_bad_fit_json(self):
+ with TemporaryDirectory() as tmp:
+ run = Path(tmp)
+ (run / "run.json").write_text(json.dumps({"status": "failed", "steps": []}), encoding="utf-8")
+ (run / "frequency_response.csv").write_text(
+ "index,requested_frequency_hz,gain_linear,status\n0,100,2,ok\n",
+ encoding="utf-8",
+ )
+ (run / "frequency_response_fit.json").write_text("not json", encoding="utf-8")
+
+ loaded = load_run_package(run)
+
+ self.assertEqual(loaded.frequency_response_rows[0]["gain_linear"], "2")
+ self.assertIsNotNone(loaded.frequency_response_csv_path)
+ self.assertIsNotNone(loaded.frequency_response_fit_path)
+ self.assertIsNone(loaded.frequency_response_fit)
+ self.assertIn("not valid JSON", loaded.frequency_response_fit_error or "")
+
if __name__ == "__main__":
unittest.main()
diff --git a/tests/test_report.py b/tests/test_report.py
index b993be5..bdef417 100644
--- a/tests/test_report.py
+++ b/tests/test_report.py
@@ -1,12 +1,18 @@
+import base64
+import importlib.util
import json
+import sys
+import types
import unittest
from pathlib import Path
from tempfile import TemporaryDirectory
+from unittest.mock import Mock, patch
import numpy as np
from wavebench.data.packages import load_run_package
-from wavebench.report.html import render_run_report_html, write_run_report_html
+from wavebench.errors import ConfigError
+from wavebench.report.html import render_run_report_html, write_run_report_html, write_run_report_pdf
class RunReportTests(unittest.TestCase):
@@ -763,6 +769,144 @@ def test_run_report_omits_expected_vs_measured_without_expect_checks(self):
self.assertNotIn("验收摘要 / Acceptance summary ", html)
self.assertNotIn("预期 vs 实测 / Expected vs measured ", html)
+ def test_run_report_discovers_frequency_response_capture_screenshots_from_csv(self):
+ with TemporaryDirectory() as tmp:
+ root = Path(tmp)
+ run_dir = root / "data" / "runs" / "response"
+ run_dir.mkdir(parents=True)
+ captures = []
+ for index in range(2):
+ capture = root / "data" / "raw" / f"response_{index}"
+ capture.mkdir(parents=True)
+ (capture / "screenshot.png").write_bytes(b"\x89PNG\r\n\x1a\n")
+ np.save(capture / "ch1.npy", np.array([[0.0, 0.0], [1e-3, 1.0]]))
+ np.save(capture / "ch2.npy", np.array([[0.0, 0.0], [1e-3, 2.0]]))
+ metadata = capture / "metadata.json"
+ metadata.write_text(
+ json.dumps(
+ {
+ "channels": {"1": {"summary": {}}, "2": {"summary": {}}},
+ "files": {
+ "1": {"npy": str(capture / "ch1.npy")},
+ "2": {"npy": str(capture / "ch2.npy")},
+ "screenshot": str(capture / "screenshot.png"),
+ },
+ }
+ ),
+ encoding="utf-8",
+ )
+ captures.append((capture, metadata))
+ (run_dir / "run.json").write_text(
+ json.dumps(
+ {
+ "status": "ok",
+ "steps": [{"index": 0, "kind": "sweep.frequency_response", "status": "ok"}],
+ }
+ ),
+ encoding="utf-8",
+ )
+ (run_dir / "frequency_response.csv").write_text(
+ "index,requested_frequency_hz,gain_linear,gain_db,phase_unwrapped_deg,status,capture_package,metadata_path\n"
+ f"0,100,2,6.0206,-45,ok,{captures[0][0]},{captures[0][1]}\n"
+ f"1,1000,2,6.0206,-45,ok,{captures[1][0]},{captures[1][1]}\n",
+ encoding="utf-8",
+ )
+
+ output = write_run_report_html(load_run_package(run_dir))
+ html = output.read_text(encoding="utf-8")
+ manifest = json.loads((run_dir / "report-assets" / "manifest.json").read_text(encoding="utf-8"))
+
+ self.assertIn("频率响应 / Frequency response ", html)
+ self.assertEqual(html.count(''), 2)
+ self.assertIn("frequency response 0", html)
+ self.assertEqual(len(manifest["capture_packages"]), 2)
+ self.assertEqual(len(manifest["screenshots"]), 2)
+
+ def test_pdf_report_uses_output_directory_as_resource_base(self):
+ with TemporaryDirectory() as tmp:
+ run_dir = Path(tmp) / "run"
+ run_dir.mkdir()
+ (run_dir / "run.json").write_text(json.dumps({"status": "ok", "steps": []}), encoding="utf-8")
+ html_factory = Mock()
+ renderer = html_factory.return_value
+ module = types.ModuleType("weasyprint")
+ module.HTML = html_factory
+ pdf_path = Path(tmp) / "export" / "report.pdf"
+
+ with patch.dict(sys.modules, {"weasyprint": module}):
+ result = write_run_report_pdf(load_run_package(run_dir), output_path=pdf_path)
+
+ self.assertEqual(result, pdf_path)
+ self.assertEqual(html_factory.call_args.kwargs["base_url"], pdf_path.parent.resolve().as_uri() + "/")
+ renderer.write_pdf.assert_called_once_with(str(pdf_path))
+
+ def test_pdf_report_turns_renderer_failures_into_config_errors(self):
+ with TemporaryDirectory() as tmp:
+ run_dir = Path(tmp) / "run"
+ run_dir.mkdir()
+ (run_dir / "run.json").write_text(json.dumps({"status": "ok", "steps": []}), encoding="utf-8")
+ html_factory = Mock()
+ html_factory.return_value.write_pdf.side_effect = RuntimeError("renderer unavailable")
+ module = types.ModuleType("weasyprint")
+ module.HTML = html_factory
+
+ with patch.dict(sys.modules, {"weasyprint": module}):
+ with self.assertRaisesRegex(ConfigError, "PDF report export failed"):
+ write_run_report_pdf(load_run_package(run_dir))
+
+ def test_pdf_report_explains_missing_optional_dependency(self):
+ with TemporaryDirectory() as tmp:
+ run_dir = Path(tmp) / "run"
+ run_dir.mkdir()
+ (run_dir / "run.json").write_text(json.dumps({"status": "ok", "steps": []}), encoding="utf-8")
+
+ with patch.dict(sys.modules, {"weasyprint": None}):
+ with self.assertRaisesRegex(ConfigError, "optional PDF dependency"):
+ write_run_report_pdf(load_run_package(run_dir))
+
+ @unittest.skipUnless(importlib.util.find_spec("weasyprint"), "PDF extra is not installed")
+ def test_pdf_report_smoke_embeds_a_local_screenshot(self):
+ with TemporaryDirectory() as tmp:
+ root = Path(tmp)
+ run_dir = root / "data" / "runs" / "run"
+ capture = root / "data" / "raw" / "capture"
+ run_dir.mkdir(parents=True)
+ capture.mkdir(parents=True)
+ (capture / "screenshot.png").write_bytes(
+ base64.b64decode(
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4z8DwHwAFgAI/"
+ "y4p3VQAAAABJRU5ErkJggg=="
+ )
+ )
+ (capture / "metadata.json").write_text(
+ json.dumps({"files": {"screenshot": str(capture / "screenshot.png")}}),
+ encoding="utf-8",
+ )
+ (run_dir / "run.json").write_text(
+ json.dumps(
+ {
+ "status": "ok",
+ "steps": [
+ {
+ "index": 0,
+ "kind": "scope.capture",
+ "status": "ok",
+ "artifact": {
+ "package": str(capture),
+ "metadata": str(capture / "metadata.json"),
+ },
+ }
+ ],
+ }
+ ),
+ encoding="utf-8",
+ )
+
+ pdf = write_run_report_pdf(load_run_package(run_dir))
+
+ self.assertTrue(pdf.read_bytes().startswith(b"%PDF"))
+ self.assertGreater(pdf.stat().st_size, 1_000)
+
if __name__ == "__main__":
unittest.main()
diff --git a/tests/test_run_plan.py b/tests/test_run_plan.py
index 485cc88..05c250f 100644
--- a/tests/test_run_plan.py
+++ b/tests/test_run_plan.py
@@ -295,10 +295,119 @@ def test_format_run_plan_schema_lists_expect_and_power_output(self):
text = format_run_plan_schema()
self.assertIn("power.output", text)
self.assertIn("source.arb_load", text)
+ self.assertIn("sweep.frequency_response", text)
self.assertIn("[steps.expect]", text)
self.assertIn("[steps.expect_fft]", text)
self.assertIn("frequency_estimate_hz", text)
+ def test_frequency_response_plan_normalizes_log_frequency_points_and_fit(self):
+ plan = load_run_plan(self._write_plan("""
+[[steps]]
+kind = "sweep.frequency_response"
+source_channel = 2
+reference_channel = 1
+response_channel = 2
+start_frequency_hz = 100
+stop_frequency_hz = 10000
+frequency_count = 3
+spacing = "log"
+target_cycles = 8
+settle_s = 0
+
+[steps.fit]
+methods = ["linear_log", "polynomial"]
+polynomial_degree = 2
+"""))
+
+ fields = plan.steps[0].fields
+ self.assertEqual(fields["frequencies_hz"][0], 100.0)
+ self.assertAlmostEqual(fields["frequencies_hz"][1], 1000.0)
+ self.assertEqual(fields["frequencies_hz"][2], 10000.0)
+ self.assertEqual(fields["target_cycles"], 8.0)
+ self.assertEqual(fields["settle_s"], 0.0)
+ self.assertEqual(
+ fields["fit"],
+ {"methods": ["linear_log", "polynomial"], "polynomial_degree": 2},
+ )
+
+ def test_frequency_response_plan_rejects_conflicting_channels_and_frequencies(self):
+ same_channel = self._write_plan("""
+[[steps]]
+kind = "sweep.frequency_response"
+reference_channel = 1
+response_channel = 1
+frequencies_hz = [100, 1000]
+""")
+ with self.assertRaisesRegex(ConfigError, "must differ"):
+ load_run_plan(same_channel)
+
+ mixed_frequency_forms = self._write_plan("""
+[[steps]]
+kind = "sweep.frequency_response"
+reference_channel = 1
+response_channel = 2
+frequencies_hz = [100, 1000]
+start_frequency_hz = 100
+stop_frequency_hz = 1000
+frequency_count = 2
+""")
+ with self.assertRaisesRegex(ConfigError, "either frequencies_hz"):
+ load_run_plan(mixed_frequency_forms)
+
+ def test_frequency_response_plan_rejects_bad_frequency_lists_and_multiple_steps(self):
+ duplicate_frequencies = self._write_plan("""
+[[steps]]
+kind = "sweep.frequency_response"
+reference_channel = 1
+response_channel = 2
+frequencies_hz = [100, 100]
+""")
+ with self.assertRaisesRegex(ConfigError, "strictly increasing"):
+ load_run_plan(duplicate_frequencies)
+
+ multiple_steps = self._write_plan("""
+[[steps]]
+kind = "sweep.frequency_response"
+reference_channel = 1
+response_channel = 2
+frequencies_hz = [100, 1000]
+
+[[steps]]
+kind = "sweep.frequency_response"
+reference_channel = 3
+response_channel = 4
+frequencies_hz = [100, 1000]
+""")
+ with self.assertRaisesRegex(ConfigError, "at most one"):
+ load_run_plan(multiple_steps)
+
+ def test_frequency_response_fit_rejects_unknown_method_and_high_degree(self):
+ unknown_method = self._write_plan("""
+[[steps]]
+kind = "sweep.frequency_response"
+reference_channel = 1
+response_channel = 2
+frequencies_hz = [100, 1000]
+
+[steps.fit]
+methods = ["spline"]
+""")
+ with self.assertRaisesRegex(ConfigError, "linear_log"):
+ load_run_plan(unknown_method)
+
+ high_degree = self._write_plan("""
+[[steps]]
+kind = "sweep.frequency_response"
+reference_channel = 1
+response_channel = 2
+frequencies_hz = [100, 1000]
+
+[steps.fit]
+polynomial_degree = 6
+""")
+ with self.assertRaisesRegex(ConfigError, "must be <= 5"):
+ load_run_plan(high_degree)
+
def test_scope_auto_step_is_explicit_and_has_no_fields(self):
path = self._write_plan("""
[[steps]]
diff --git a/tests/test_run_service.py b/tests/test_run_service.py
index 2a460d8..80314c9 100644
--- a/tests/test_run_service.py
+++ b/tests/test_run_service.py
@@ -1,3 +1,5 @@
+import csv
+from dataclasses import replace
from pathlib import Path
from tempfile import TemporaryDirectory
from types import SimpleNamespace
@@ -128,6 +130,41 @@ def fake_capture(
waveform = SimpleNamespace(summary=lambda **kwargs: summary)
return SimpleNamespace(package_dir=package, metadata_path=metadata, waveform=waveform, npy_path=waveform_path)
+
+def fake_frequency_response_capture(
+ tmp: str,
+ name: str,
+ *,
+ frequency_hz: float,
+ gain: float = 2.0,
+ phase_deg: float = -45.0,
+):
+ package = Path(tmp) / name
+ package.mkdir()
+ metadata = package / "metadata.json"
+ metadata.write_text("{}", encoding="utf-8")
+ times = 0.013 + np.arange(4096, dtype=float) / 100_000.0
+
+ def waveform(amplitude: float, phase: float):
+ values = amplitude * np.sin(2.0 * np.pi * frequency_hz * times + np.radians(phase))
+ summary = {
+ "quality_warnings": [],
+ "frequency_estimate_hz": frequency_hz,
+ "voltage_vpp_v": amplitude * 2.0,
+ "frequency_error_ratio": 0.0,
+ }
+ return SimpleNamespace(
+ times_s=times,
+ voltages_v=values,
+ summary=lambda **kwargs: summary,
+ )
+
+ return SimpleNamespace(
+ package_dir=package,
+ metadata_path=metadata,
+ waveforms={1: waveform(1.0, 0.0), 2: waveform(gain, phase_deg)},
+ )
+
def ok_power_status() -> PowerStatus:
return PowerStatus(
channel=1,
@@ -1142,6 +1179,187 @@ def test_restores_multiple_source_channels_after_step_failure_when_enabled(self)
self.assertEqual(source.restore_restorable_state.call_args_list, [call(fake_state_1), call(fake_state_2)])
+ def test_frequency_response_captures_two_channels_and_persists_evidence(self):
+ with TemporaryDirectory() as tmp:
+ plan = load_run_plan(write_plan(tmp, """
+[[steps]]
+kind = "sweep.frequency_response"
+source_channel = 1
+reference_channel = 1
+response_channel = 2
+frequencies_hz = [100, 1000]
+target_cycles = 6
+settle_s = 0
+"""))
+ config = make_config(tmp)
+ config = replace(config, output=replace(config.output, save_json=False))
+ first = fake_frequency_response_capture(tmp, "response_100", frequency_hz=100.0)
+ second = fake_frequency_response_capture(tmp, "response_1000", frequency_hz=1000.0)
+ status = SimpleNamespace(output="ON")
+ with patch("wavebench.services.run_service.ScopeService") as scope_cls, patch(
+ "wavebench.services.run_service.SourceService"
+ ) as source_cls:
+ scope = scope_cls.return_value
+ source = source_cls.return_value
+ source.status.return_value = status
+ source.set_frequency.return_value = status
+ scope.capture_waveforms.side_effect = [first, second]
+
+ result = RunService(config=config, logger=CommandLogger()).run(plan)
+
+ self.assertEqual(
+ source.set_frequency.call_args_list,
+ [call(channel=1, value_hz=100.0), call(channel=1, value_hz=1000.0)],
+ )
+ self.assertEqual(
+ scope.capture_waveforms.call_args_list,
+ [
+ call(channels=[1, 2], label="frequency_response_00_000_100hz"),
+ call(channels=[1, 2], label="frequency_response_00_001_1000hz"),
+ ],
+ )
+ self.assertEqual(scope.require_high_impedance.call_args_list, [call(1, allow_50ohm=False), call(2, allow_50ohm=False)])
+ response = result.steps[0].artifact["frequency_response"]
+ self.assertEqual(response["status"], "ok")
+ self.assertEqual(len(response["captures"]), 2)
+ self.assertTrue((result.run_dir / "frequency_response.csv").exists())
+ self.assertTrue(
+ all(
+ call.kwargs["config"].output.save_npy and call.kwargs["config"].output.save_json
+ for call in scope_cls.call_args_list
+ if call.kwargs["config"].waveform.expected_frequency_hz is not None
+ )
+ )
+
+ def test_frequency_response_refuses_to_set_frequency_when_source_is_off(self):
+ with TemporaryDirectory() as tmp:
+ plan = load_run_plan(write_plan(tmp, """
+[[steps]]
+kind = "sweep.frequency_response"
+reference_channel = 1
+response_channel = 2
+frequencies_hz = [100, 1000]
+settle_s = 0
+"""))
+ with patch("wavebench.services.run_service.ScopeService") as scope_cls, patch(
+ "wavebench.services.run_service.SourceService"
+ ) as source_cls:
+ source = source_cls.return_value
+ source.status.return_value = SimpleNamespace(output="OFF")
+
+ with self.assertRaisesRegex(ConfigError, "output to be ON"):
+ RunService(config=make_config(tmp), logger=CommandLogger()).run(plan)
+
+ source.set_frequency.assert_not_called()
+ run_dirs = list((Path(tmp) / "data" / "runs").iterdir())
+ run_data = json.loads((run_dirs[0] / "run.json").read_text(encoding="utf-8"))
+ self.assertEqual(run_data["steps"][0]["kind"], "sweep.frequency_response")
+ self.assertEqual(run_data["steps"][0]["status"], "failed")
+ self.assertTrue((run_dirs[0] / "frequency_response.csv").exists())
+ scope_cls.return_value.capture_waveforms.assert_not_called()
+
+ def test_frequency_response_continues_after_an_auditable_capture_failure(self):
+ with TemporaryDirectory() as tmp:
+ plan = load_run_plan(write_plan(tmp, """
+[[steps]]
+kind = "sweep.frequency_response"
+reference_channel = 1
+response_channel = 2
+frequencies_hz = [100, 1000, 10000]
+settle_s = 0
+"""))
+ status = SimpleNamespace(output="ON")
+ first = fake_frequency_response_capture(tmp, "response_100", frequency_hz=100.0)
+ third = fake_frequency_response_capture(tmp, "response_10000", frequency_hz=10000.0)
+ with patch("wavebench.services.run_service.ScopeService") as scope_cls, patch(
+ "wavebench.services.run_service.SourceService"
+ ) as source_cls:
+ source = source_cls.return_value
+ source.status.return_value = status
+ source.set_frequency.return_value = status
+ scope_cls.return_value.capture_waveforms.side_effect = [
+ first,
+ ConfigError("scope capture failed"),
+ third,
+ ]
+
+ result = RunService(config=make_config(tmp), logger=CommandLogger()).run(plan)
+
+ self.assertEqual(source.set_frequency.call_count, 3)
+ self.assertEqual(result.steps[0].status, "failed")
+ rows = list(csv.DictReader((result.run_dir / "frequency_response.csv").open(encoding="utf-8")))
+ self.assertEqual([row["status"] for row in rows], ["ok", "failed", "ok"])
+ self.assertIn("scope capture failed", rows[1]["error"])
+
+ def test_frequency_response_source_failure_keeps_csv_step_record_and_restore(self):
+ with TemporaryDirectory() as tmp:
+ plan = load_run_plan(write_plan(tmp, """
+[restore]
+source_state = true
+source_channel = 1
+
+[[steps]]
+kind = "sweep.frequency_response"
+source_channel = 1
+reference_channel = 1
+response_channel = 2
+frequencies_hz = [100, 1000, 10000]
+settle_s = 0
+"""))
+ first = fake_frequency_response_capture(tmp, "response_100", frequency_hz=100.0)
+ status = SimpleNamespace(output="ON")
+ state = SimpleNamespace(channel=1, as_dict=lambda: {"channel": 1, "function": "SIN"})
+ with patch("wavebench.services.run_service.ScopeService") as scope_cls, patch(
+ "wavebench.services.run_service.SourceService"
+ ) as source_cls:
+ scope = scope_cls.return_value
+ source = source_cls.return_value
+ source.status.return_value = status
+ source.set_frequency.side_effect = [status, ConfigError("set failed")]
+ source.snapshot_restorable_state.return_value = state
+ scope.capture_waveforms.return_value = first
+
+ with self.assertRaisesRegex(ConfigError, "set failed") as raised:
+ RunService(config=make_config(tmp), logger=CommandLogger()).run(plan)
+
+ self.assertIsNone(raised.exception.__cause__)
+ self.assertEqual(source.set_frequency.call_count, 2)
+ source.restore_restorable_state.assert_called_once_with(state)
+ run_dir = next((Path(tmp) / "data" / "runs").iterdir())
+ rows = list(csv.DictReader((run_dir / "frequency_response.csv").open(encoding="utf-8")))
+ self.assertEqual([row["status"] for row in rows], ["ok", "failed"])
+ run_data = json.loads((run_dir / "run.json").read_text(encoding="utf-8"))
+ self.assertEqual(run_data["status"], "failed")
+ self.assertEqual(run_data["steps"][0]["kind"], "sweep.frequency_response")
+ self.assertEqual(run_data["steps"][0]["status"], "failed")
+ self.assertEqual(run_data["error"], {"type": "ConfigError", "message": "set failed"})
+ self.assertEqual(run_data["restore"]["status"], "ok")
+ step_record = json.loads(
+ (run_dir / "steps" / "00_sweep_frequency_response.json").read_text(encoding="utf-8")
+ )
+ self.assertEqual(step_record["status"], "failed")
+ self.assertEqual(step_record["artifact"]["frequency_response"]["failed_point_count"], 1)
+ summary_rows = list(csv.DictReader((run_dir / "summary.csv").open(encoding="utf-8")))
+ self.assertEqual(
+ summary_rows,
+ [
+ {
+ "index": "0",
+ "kind": "sweep.frequency_response",
+ "status": "failed",
+ "package": "",
+ "metadata": "",
+ "quality_status": "",
+ "quality_warnings": "",
+ "recovered": "",
+ "expect_status": "",
+ "expect_failures": "",
+ "expect_fft_status": "",
+ "expect_fft_failures": "",
+ }
+ ],
+ )
+
if __name__ == "__main__":
unittest.main()
diff --git a/tests/test_run_templates.py b/tests/test_run_templates.py
index 462ce87..e89adcd 100644
--- a/tests/test_run_templates.py
+++ b/tests/test_run_templates.py
@@ -16,10 +16,25 @@
def test_list_run_templates_includes_public_names():
names = [item.name for item in list_run_templates()]
- assert names == ["dmm-acv-source", "power-dmm-dcv", "source-scope-sine", "source-scope-sweep"]
+ assert names == [
+ "dmm-acv-source",
+ "power-dmm-dcv",
+ "source-scope-frequency-response",
+ "source-scope-sine",
+ "source-scope-sweep",
+ ]
-@pytest.mark.parametrize("name", ["source-scope-sine", "source-scope-sweep", "dmm-acv-source", "power-dmm-dcv"])
+@pytest.mark.parametrize(
+ "name",
+ [
+ "source-scope-sine",
+ "source-scope-sweep",
+ "source-scope-frequency-response",
+ "dmm-acv-source",
+ "power-dmm-dcv",
+ ],
+)
def test_run_templates_render_valid_plans(tmp_path: Path, name: str):
output = write_run_template(name, tmp_path / f"{name}.toml")
@@ -87,6 +102,41 @@ def test_source_scope_sweep_template_expands_frequency_points(tmp_path: Path):
assert text.count("[steps.expect_fft]") == 3
+def test_frequency_response_template_uses_two_scope_channels_and_compatible_fit_degree(tmp_path: Path):
+ output = write_run_template(
+ "source-scope-frequency-response",
+ tmp_path / "response.toml",
+ options=RunTemplateOptions(
+ frequencies_hz=(100.0, 1000.0, 10000.0),
+ source_channel=2,
+ reference_channel=1,
+ response_channel=3,
+ frequency_response_fit=True,
+ ),
+ )
+
+ plan = load_run_plan(output)
+ response = next(step for step in plan.steps if step.kind == "sweep.frequency_response")
+
+ assert response.fields["source_channel"] == 2
+ assert response.fields["reference_channel"] == 1
+ assert response.fields["response_channel"] == 3
+ assert response.fields["frequencies_hz"] == [100.0, 1000.0, 10000.0]
+ assert response.fields["fit"] == {
+ "methods": ["linear_log", "polynomial", "pchip"],
+ "polynomial_degree": 2,
+ }
+
+
+@pytest.mark.parametrize("frequencies", [(1000.0,), (1000.0, 1000.0), (1000.0, 100.0)])
+def test_frequency_response_template_rejects_non_sweep_frequency_lists(frequencies: tuple[float, ...]):
+ with pytest.raises(ConfigError, match="frequency-response template"):
+ render_run_template(
+ "source-scope-frequency-response",
+ options=RunTemplateOptions(frequencies_hz=frequencies),
+ )
+
+
def test_parse_frequencies_accepts_comma_separated_values():
assert parse_frequencies("100, 1000,10000") == (100.0, 1000.0, 10000.0)
From 4d3a91578bb42a59803c8c85acd844334b3c4050 Mon Sep 17 00:00:00 2001
From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com>
Date: Mon, 3 Aug 2026 19:36:57 +0800
Subject: [PATCH 2/9] fix: keep PDF summary on first page
---
src/wavebench/report/html.py | 22 ++++++++++++++++------
tests/test_report.py | 25 +++++++++++++++++++++++++
2 files changed, 41 insertions(+), 6 deletions(-)
diff --git a/src/wavebench/report/html.py b/src/wavebench/report/html.py
index 5fab21d..18ad8ee 100644
--- a/src/wavebench/report/html.py
+++ b/src/wavebench/report/html.py
@@ -205,12 +205,14 @@ def render_run_report_html(
artifact_links_block = "" if compact else _artifact_links_block(artifact_links)
signals_block = "" if compact else _signals_block(signals)
waveform_previews_block = "" if compact else _waveform_previews_block(waveform_previews)
+ evidence_summary_block = "" if compact else _evidence_summary_block(evidence)
summary_note = "present" if run.summary_csv_path is not None else "missing"
compact_note = (
- 'PDF 精简为结果摘要、Bode 曲线、拟合与逐点表;完整原始证据保留在 run 目录。
'
+ 'PDF 精简为结果摘要、Bode 曲线与拟合;逐点 CSV 和完整原始证据保留在 run 目录。
'
if compact
else ""
)
+ body_class = "pdf-compact" if compact else ""
error_block = ""
if error:
error_block = f"运行错误 / Run error {escape(str(error))} "
@@ -302,16 +304,23 @@ def render_run_report_html(
.compact-table table {{ font-size: 7pt; }}
.compact-table th, .compact-table td {{ padding: 0.25rem 0.3rem; }}
.frequency-response-grid {{ grid-template-columns: 1fr; }}
+ body.pdf-compact h1 {{ font-size: 17pt; margin: 0 0 0.15rem; }}
+ body.pdf-compact h2 {{ margin: 0.65rem 0 0.3rem; font-size: 13pt; }}
+ body.pdf-compact .summary-grid {{ display: block; margin: 0.35rem 0 0.55rem; padding: 0; break-inside: avoid; page-break-inside: avoid; }}
+ body.pdf-compact .summary-card {{ box-sizing: border-box; display: inline-block; vertical-align: top; width: 24%; min-height: 0; margin: 0.2% 0.35%; padding: 0.38rem 0.45rem; border-radius: 7px; break-inside: avoid; page-break-inside: avoid; }}
+ body.pdf-compact .summary-card .label {{ font-size: 7.5pt; line-height: 1.25; }}
+ body.pdf-compact .summary-card .value {{ font-size: 10.5pt; line-height: 1.22; margin-top: 0.08rem; }}
+ body.pdf-compact .summary-card .value.ok, body.pdf-compact .summary-card .value.failed, body.pdf-compact .summary-card .value.warning {{ font-size: 8.5pt; padding: 0.04rem 0.3rem; }}
}}
-
+
WaveBench 运行报告 Run report
A static offline hardware validation report.
{compact_note}
-{_summary_block(summary)}
-{_evidence_summary_block(evidence)}
+{_summary_block(summary, compact=compact)}
+{evidence_summary_block}
{evidence_timeline_block}
{artifact_links_block}
@@ -431,9 +440,10 @@ def _build_report_manifest(run: RunPackage, *, output_dir: Path, report_path: Pa
}
-def _summary_block(summary: ReportSummary) -> str:
+def _summary_block(summary: ReportSummary, *, compact: bool = False) -> str:
+ css_class = "summary-grid pdf-summary-grid" if compact else "summary-grid"
return f"""摘要 / Summary
-
+
{_summary_card("状态 / Status", summary.status, css_class=summary.status)}
{_summary_card("实验 / Experiment", summary.experiment_label)}
{_summary_card("步骤 / Steps", str(summary.total_steps))}
diff --git a/tests/test_report.py b/tests/test_report.py
index bdef417..0a42601 100644
--- a/tests/test_report.py
+++ b/tests/test_report.py
@@ -838,8 +838,33 @@ def test_pdf_report_uses_output_directory_as_resource_base(self):
self.assertEqual(result, pdf_path)
self.assertEqual(html_factory.call_args.kwargs["base_url"], pdf_path.parent.resolve().as_uri() + "/")
+ compact_html = html_factory.call_args.kwargs["string"]
+ self.assertIn('', compact_html)
+ self.assertIn('', compact_html)
+ self.assertIn("width: 24%;", compact_html)
+ self.assertIn("page-break-inside: avoid;", compact_html)
+ self.assertNotIn("实验证据摘要 / Run evidence summary ", compact_html)
renderer.write_pdf.assert_called_once_with(str(pdf_path))
+ def test_compact_pdf_html_keeps_summary_and_omits_nonessential_evidence(self):
+ with TemporaryDirectory() as tmp:
+ run_dir = Path(tmp) / "run"
+ run_dir.mkdir()
+ (run_dir / "run.json").write_text(
+ json.dumps({"status": "ok", "steps": [{"index": 0, "kind": "sleep", "status": "ok"}]}),
+ encoding="utf-8",
+ )
+
+ html = render_run_report_html(load_run_package(run_dir), compact=True)
+
+ self.assertIn('', html)
+ self.assertIn('', html)
+ self.assertIn("PDF 精简为结果摘要、Bode 曲线与拟合", html)
+ self.assertIn("width: 24%;", html)
+ self.assertIn("page-break-inside: avoid;", html)
+ self.assertNotIn("实验证据摘要 / Run evidence summary ", html)
+ self.assertNotIn("证据时间线 / Evidence timeline ", html)
+
def test_pdf_report_turns_renderer_failures_into_config_errors(self):
with TemporaryDirectory() as tmp:
run_dir = Path(tmp) / "run"
From b917d492a2fe879985c8ad5b0c0c8c1b7f709181 Mon Sep 17 00:00:00 2001
From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com>
Date: Tue, 4 Aug 2026 01:45:45 +0800
Subject: [PATCH 3/9] fix: lay out response chart legends
---
src/wavebench/report/html.py | 49 +++++++++++++++++++++++++-----------
tests/test_report.py | 28 ++++++++++++++++++++-
2 files changed, 62 insertions(+), 15 deletions(-)
diff --git a/src/wavebench/report/html.py b/src/wavebench/report/html.py
index 18ad8ee..d722617 100644
--- a/src/wavebench/report/html.py
+++ b/src/wavebench/report/html.py
@@ -965,12 +965,20 @@ def _response_svg(
pad_y = (y_max - y_min) * 0.08
y_min -= pad_y
y_max += pad_y
- width, height, pad_left, pad_right, pad_top, pad_bottom = 680, 270, 58, 20, 28, 42
+ width, pad_left, pad_right, pad_top = 680, 58, 20, 28
+ legend_items = [(actual_label, "#2563eb"), *[(name, color) for name, color, _values in series]]
+ legend_columns = 2
+ legend_row_height = 15
+ legend_rows = max(1, (len(legend_items) + legend_columns - 1) // legend_columns)
+ legend_footer = 34
+ pad_bottom = legend_footer + legend_rows * legend_row_height
+ height = 270 + max(0, legend_rows - 2) * legend_row_height
+ axis_y = height - pad_bottom
def position(point: tuple[float, float]) -> tuple[float, float]:
x_value = np.log10(point[0])
px = pad_left + (x_value - x_min) / (x_max - x_min) * (width - pad_left - pad_right)
- py = height - pad_bottom - (point[1] - y_min) / (y_max - y_min) * (height - pad_top - pad_bottom)
+ py = axis_y - (point[1] - y_min) / (y_max - y_min) * (axis_y - pad_top)
return float(px), float(py)
polylines = []
@@ -980,33 +988,46 @@ def position(point: tuple[float, float]) -> tuple[float, float]:
points = " ".join(f"{x:.2f},{y:.2f}" for x, y in (position(point) for point in block))
polylines.append(f' ')
fit_lines = []
- legends = []
- for legend_index, (name, color, values) in enumerate(series):
+ for name, color, values in series:
points = " ".join(f"{x:.2f},{y:.2f}" for x, y in (position(point) for point in values))
fit_lines.append(f' ')
- legends.append(
- f'{escape(name)} '
- )
circles = "".join(
f' '
for x, y in (position(point) for point in actual)
)
- actual_legend = (
- f'{escape(actual_label)} '
- )
+ legend_column_width = (width - pad_left - pad_right) / legend_columns
+ legend_base_y = axis_y + 38
+ legends = []
+ for index, (name, color) in enumerate(legend_items):
+ row, column = divmod(index, legend_columns)
+ marker_x = pad_left + column * legend_column_width
+ legend_y = legend_base_y + row * legend_row_height
+ legends.append(
+ f''
+ f' '
+ f''
+ f'{escape(_short_svg_legend_label(name))} '
+ )
return (
f''
f' '
- f' '
- f' '
+ f' '
+ f' '
f'{escape(title)} '
- f'log10(f / Hz): {x_min:.3g} .. {x_max:.3g} '
+ f'log10(f / Hz): {x_min:.3g} .. {x_max:.3g} '
f'{escape(y_label)}: {y_min:.4g} .. {y_max:.4g} '
- f'{"".join(polylines)}{circles}{"".join(fit_lines)}{actual_legend}{"".join(legends)}'
+ f'{"".join(polylines)}{circles}{"".join(fit_lines)}{"".join(legends)}'
' '
)
+def _short_svg_legend_label(label: str, *, limit: int = 30) -> str:
+ normalized = " ".join(label.split())
+ return normalized if len(normalized) <= limit else normalized[: limit - 1] + "…"
+
+
def _finite_float(value: Any) -> float | None:
try:
numeric = float(value)
diff --git a/tests/test_report.py b/tests/test_report.py
index 0a42601..6fa108c 100644
--- a/tests/test_report.py
+++ b/tests/test_report.py
@@ -1,6 +1,7 @@
import base64
import importlib.util
import json
+import re
import sys
import types
import unittest
@@ -12,10 +13,35 @@
from wavebench.data.packages import load_run_package
from wavebench.errors import ConfigError
-from wavebench.report.html import render_run_report_html, write_run_report_html, write_run_report_pdf
+from wavebench.report.html import _response_svg, render_run_report_html, write_run_report_html, write_run_report_pdf
class RunReportTests(unittest.TestCase):
+ def test_response_svg_uses_a_separate_two_column_legend_area(self):
+ svg = _response_svg(
+ [[(100.0, 1.0), (1000.0, 2.0)]],
+ title="Fit comparison",
+ y_label="Linear gain",
+ actual_label="Measured",
+ series=[
+ ("Frequency piecewise linear interpolation", "#7c3aed", [(100.0, 1.0), (1000.0, 2.0)]),
+ ("Degree-3 polynomial in log frequency", "#dc2626", [(100.0, 1.2), (1000.0, 1.8)]),
+ ("PCHIP shape-preserving cubic interpolation", "#0891b2", [(100.0, 1.1), (1000.0, 1.9)]),
+ ],
+ )
+
+ positions = re.findall(
+ r'Measured", svg)
+ self.assertIn("…", svg)
+ self.assertNotIn("Frequency piecewise linear interpolation", svg)
+
def test_run_report_embeds_capture_screenshot_relative_to_report(self):
with TemporaryDirectory() as tmp:
root = Path(tmp)
From 40dde7180b52f92c6f51d878dbf3c7b58d337c8f Mon Sep 17 00:00:00 2001
From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com>
Date: Tue, 4 Aug 2026 21:12:49 +0800
Subject: [PATCH 4/9] feat: add 2D frequency response calibration
---
README.md | 61 ++-
doc/README.md | 13 +-
doc/README_EN.md | 21 +-
...77\347\224\250\346\214\207\345\215\227.md" | 78 ++-
...23\345\207\272\346\240\274\345\274\217.md" | 32 +-
...07\344\273\266\346\240\274\345\274\217.md" | 4 +-
scripts/pytest_progress.py | 68 +++
src/wavebench/cli.py | 27 +
src/wavebench/cli_parser.py | 7 +
src/wavebench/data/packages.py | 28 +
src/wavebench/report/html.py | 199 +++++++-
src/wavebench/services/frequency_response.py | 122 ++++-
.../frequency_response_calibration.py | 481 ++++++++++++++++++
src/wavebench/services/run_plan.py | 51 ++
src/wavebench/services/run_safety.py | 12 +
src/wavebench/services/run_service.py | 244 ++++++---
src/wavebench/services/run_templates.py | 2 +-
tests/test_cli.py | 24 +
tests/test_frequency_response.py | 18 +
tests/test_frequency_response_calibration.py | 71 +++
tests/test_packages.py | 17 +
tests/test_report.py | 35 ++
tests/test_run_plan.py | 37 ++
tests/test_run_service.py | 62 +++
tests/test_run_templates.py | 8 +-
25 files changed, 1624 insertions(+), 98 deletions(-)
create mode 100644 scripts/pytest_progress.py
create mode 100644 src/wavebench/services/frequency_response_calibration.py
create mode 100644 tests/test_frequency_response_calibration.py
diff --git a/README.md b/README.md
index 399cd4d..1c4e6b4 100644
--- a/README.md
+++ b/README.md
@@ -108,8 +108,9 @@ WaveBench 主包长期预装 RTM2000/RTM2032、DS1104Z/DS1000Z、DG4000/DG4202
- `run check --plan `:只解析并汇总 plan,不连接仪器
- `run verify --plan `:只读查询 plan 涉及仪器的高阻保护状态与 `*IDN?`,用于执行前预检可达性
- `run template --list` / `run template --output `:列出或生成保守 run plan 模板;可用 `--frequency`、`--frequencies`、`--reference-channel`、`--response-channel`、`--fit` 等少量参数定制;不连接仪器,不覆盖已有文件,除非显式 `--force`
-- `run plan --plan `:执行显式 source、power、scope、dmm、sleep 和双通道 `sweep.frequency_response` 步骤;一次 run 内统一打开并复用所需仪器 session,成功或失败后统一关闭,不静默断线重连
-- `run report `:根据 `run.json` / `summary.csv` 生成静态离线 HTML 报告;频响 run 额外包含幅频、相频、拟合对比、逐点 CSV 与采集证据链接。加 `--pdf` 可同时导出嵌入截图、SVG 和表格的便携 PDF
+- `run plan --plan `:执行显式 source、power、scope、dmm、sleep 和双通道 `sweep.frequency_response` 步骤;频响可扫描一个或多个请求 Vpp 切片,并可自动导出二维校准 LUT;一次 run 内统一打开并复用所需仪器 session,成功或失败后统一关闭,不静默断线重连
+- `run calibrate --config `:完全离线地从既有二维频响 CSV 重建校准 LUT,不连接仪器、不改写原始测量 CSV
+- `run report `:根据 `run.json` / `summary.csv` 生成静态离线 HTML 报告;频响 run 额外包含幅频、相频、拟合对比、逐点 CSV 与采集证据链接;二维校准会增加补偿热图与代表性切片。加 `--pdf` 可同时导出嵌入截图、SVG 和表格的便携 PDF
- `capture inspect `:打印离线采集包摘要
- 默认示波器高阻保护:`scope.capture` / `scope.fetch` / `sweep discrete` / run-plan `scope.capture` / `sweep.frequency_response` 在采集前查询通道耦合。频响会同时保护 reference 与 response 两路;RTM2032 的 `DCL`/`ACL` 视为高阻,`DC`/`AC` 默认按可能的 50 Ω 拒绝;DS1000Z 输入固定为 1 MΩ,`AC`/`DC`/`GND` 只表示耦合方式,均按该机型语义检查。WaveBench 不会自动修改耦合或输入设置
- 可选 `[restore] source_state = true`:在 `finally` 路径快照并恢复 basic 信号源通道状态(输出、函数、频率、Vpp、方波占空比)。该选项不恢复 offset、phase、frequency mode、sweep、负载、极性、噪声、同步、burst、调制、marker、pulse hold 或易失任意波内存;run artifact 以 `source_state_scope = "basic"` 明示范围
@@ -206,7 +207,7 @@ python3 -m venv .venv
cp wavebench.example.toml wavebench.toml
```
-需要运行测试和代码检查时安装开发依赖;频响 PCHIP 拟合需要 `analysis` extra;离线 PDF 报告需要 `pdf` extra;终端 TUI 需要 `tui` extra:
+需要运行测试和代码检查时安装开发依赖;频响 PCHIP、dB 平滑样条、二维校准需要 `analysis` extra;离线 PDF 报告需要 `pdf` extra;终端 TUI 需要 `tui` extra:
```bash
.venv/bin/python -m pip install -e ".[dev]"
@@ -493,7 +494,7 @@ python -m wavebench run plan --config wavebench.toml --plan plans/example_scope_
python -m wavebench run report data/runs/
```
-生成双通道频响模板(CH1 接 DUT 输入,CH2 接 DUT 输出),通过 `run check` / `run verify` 后才允许执行。`--fit` 会生成线性对数插值、多项式和 PCHIP 拟合配置,因此需要先安装 `.[analysis]`:
+生成双通道频响模板(CH1 接 DUT 输入,CH2 接 DUT 输出),通过 `run check` / `run verify` 后才允许执行。`--fit` 会生成线性对数插值、多项式、PCHIP、dB 平滑样条和分段 Chebyshev 拟合配置,因此需要先安装 `.[analysis]`:
```powershell
python -m wavebench run template source-scope-frequency-response --frequencies 100,1000,10000 --reference-channel 1 --response-channel 2 --fit --output plans/frequency_response.toml
@@ -503,7 +504,53 @@ python -m wavebench run plan --config wavebench.toml --plan plans/frequency_resp
python -m wavebench run report data/runs/ --pdf
```
-PDF 是“可见报告”的单文件封装:截图、静态 SVG 曲线和表格会嵌入 PDF;`frequency_response.csv`、拟合 JSON、NPY 波形和完整采集包仍保留在 run 目录,适合复算和审计。
+二维幅值校准可在同一个频响 step 中给出请求 Vpp 轴。它会对每个幅值切片设定信号源、首个频点 autoscale,并在 run 完成后生成浮点 LUT、补偿系数和分段 Chebyshev 公式:
+
+```toml
+[[steps]]
+kind = "sweep.frequency_response"
+source_channel = 1
+reference_channel = 1
+response_channel = 2
+start_frequency_hz = 10000
+stop_frequency_hz = 500000
+frequency_count = 246
+spacing = "linear"
+start_vpp = 0.005
+stop_vpp = 0.25
+vpp_step = 0.005
+target_cycles = 10
+settle_s = 1.0
+
+[steps.calibration]
+target_mode = "passband_median" # 或 explicit_gain_db / unity_gain
+# target_gain_db = 0.0 # explicit_gain_db 时必填
+correction_min_db = -12
+correction_max_db = 12
+max_slope_db_per_octave = 6
+```
+
+也可完全离线地对已有二维 run 重调目标和限幅参数。校准 TOML 只需要 `[calibration]` 表,不读取 `wavebench.toml`,也不会连接仪器:
+
+```powershell
+python -m wavebench run calibrate data/runs/ --config plans/calibration.toml
+```
+
+`plans/calibration.toml` 的完整最小配置如下。`passband_median` 会在指定通带(未指定则全有效频段)取所有拟合增益的稳健中位数;`explicit_gain_db` 必须提供 `target_gain_db`;`unity_gain` 的目标固定为 0 dB。
+
+```toml
+[calibration]
+target_mode = "passband_median"
+target_frequency_min_hz = 10000
+target_frequency_max_hz = 400000
+correction_min_db = -12
+correction_max_db = 12
+max_slope_db_per_octave = 6
+chebyshev_degree = 3
+chebyshev_segment_count = 8
+```
+
+校准输出为 `frequency_response_calibration.csv` 和 `frequency_response_calibration.json`;原始 `frequency_response.csv` 不会被改写。HTML/PDF 会展示校正热图和代表性幅值切片,完整 LUT 与公式保留在 run 目录。PDF 是“可见报告”的单文件封装:截图、静态 SVG 曲线和表格会嵌入 PDF;CSV、JSON、NPY 波形和完整采集包仍保留在 run 目录,适合复算和审计。
DMM ACV smoke 示例:
@@ -574,7 +621,7 @@ frequency_estimate_hz = { min = 950, max = 1050 }
```powershell
python -m pip install -e ".[dev]"
-python -m pytest -q
+python scripts/pytest_progress.py -- -q
```
-GitHub Actions 会在 push 和 pull request 时自动运行 Python 3.11 / 3.12 单元测试。
+`scripts/pytest_progress.py` 不增加依赖,原样转发 pytest 输出,并在每 5 秒打印一行心跳;适合会因长时间无输出而断开的终端。可用 `--heartbeat-s 10` 调整间隔。GitHub Actions 会在 push 和 pull request 时自动运行 Python 3.11 / 3.12 单元测试。
diff --git a/doc/README.md b/doc/README.md
index a9dc8c4..a2c2cf8 100644
--- a/doc/README.md
+++ b/doc/README.md
@@ -19,7 +19,18 @@ WaveBench 是面向电赛调试场景的轻量 Python 自动测量台。当前
可靠地远程读取示波器波形、显式控制信号源和电源,并保存 CSV / NPY / metadata / commands.log。
```
-当前已经支持单次/多通道采集、失败采集包、数据质量摘要、采集窗口控制、DG4202 离散扫频与占空比控制、DP800 电源显式控制、DMM 读数,以及多仪器 run plan 执行。`wavebench doctor` 可只读检查配置中的仪器资源、IDN 和型号匹配,也可用 `--discover-subnet` 在配置资源失效时按 IDN 匹配候选替代 resource,便于现场排查网络和配置问题。`run template` 可生成保守的 run plan 模板,并支持少量频率、频点列表、幅度、通道、电压参数,减少手写 TOML 的低级错误。run plan 的 `scope.capture` 可选择质量检查,并在质量警告时按 `[quality].auto_recover_attempts` 触发多次显式 autoscale 重采;若多次采集指标稳定,可标记为 `ok_by_consistency`。`[steps.expect]` 可对采集指标设置 min/max 断言,`[steps.expect_fft]` 可直接对 FFT 主频、主峰幅度、THD、谐波幅度做断言。断言失败会把实验标记为 failed。可选的实验性 TUI 已覆盖 DP800 电源、DM3000/DM3058 万用表和 DG4202 信号源的常用查看/控制操作,并冻结在这三个面板;CLI、run plan 和 Service 仍是核心能力。HTTP MCP 只读 MVP 已提供 `/health`、`/mcp`、`/tools`、`/call`,其中 `/mcp` 支持 MCP JSON-RPC 的 `initialize`、`tools/list`、`tools/call`,工具为 `run.schema`、`run.check`、`capture.inspect` 三个只读工具。v0.2 已开始加入离线包读取和静态 `run report`;报告现在会输出 `验收摘要 / Acceptance summary`、`预期 vs 实测 / Expected vs measured`,并汇总频率、Vpp、均值、duty、截图与 FFT 验收信息;对多点 sweep run 还会生成扫频摘要表,便于快速查看各频点质量、主峰与 THD。这些报告命令只读已有文件,不连接仪器。
+当前已经支持单次/多通道采集、失败采集包、数据质量摘要、采集窗口控制、DG4202 离散扫频与占空比控制、DP800 电源显式控制、DMM 读数,以及多仪器 run plan 执行。双通道 `sweep.frequency_response` 可测量幅频、相频和传统一维拟合,也可按请求 Vpp 形成二维扫频;完成后可自动生成供数字后端使用的浮点校准 LUT、补偿限制审计和分段 Chebyshev 公式,或用 `run calibrate` 完全离线地从既有 run 重算。`wavebench doctor` 可只读检查配置中的仪器资源、IDN 和型号匹配,也可用 `--discover-subnet` 在配置资源失效时按 IDN 匹配候选替代 resource,便于现场排查网络和配置问题。`run template` 可生成保守的 run plan 模板,并支持少量频率、频点列表、幅度、通道、电压参数,减少手写 TOML 的低级错误。run plan 的 `scope.capture` 可选择质量检查,并在质量警告时按 `[quality].auto_recover_attempts` 触发多次显式 autoscale 重采;若多次采集指标稳定,可标记为 `ok_by_consistency`。`[steps.expect]` 可对采集指标设置 min/max 断言,`[steps.expect_fft]` 可直接对 FFT 主频、主峰幅度、THD、谐波幅度做断言。断言失败会把实验标记为 failed。可选的实验性 TUI 已覆盖 DP800 电源、DM3000/DM3058 万用表和 DG4202 信号源的常用查看/控制操作,并冻结在这三个面板;CLI、run plan 和 Service 仍是核心能力。HTTP MCP 只读 MVP 已提供 `/health`、`/mcp`、`/tools`、`/call`,其中 `/mcp` 支持 MCP JSON-RPC 的 `initialize`、`tools/list`、`tools/call`,工具为 `run.schema`、`run.check`、`capture.inspect` 三个只读工具。v0.2 已开始加入离线包读取和静态 `run report`;报告现在会输出 `验收摘要 / Acceptance summary`、`预期 vs 实测 / Expected vs measured`,并汇总频率、Vpp、均值、duty、截图与 FFT 验收信息;对多点 sweep run 还会生成扫频摘要表,便于快速查看各频点质量、主峰与 THD。这些报告命令只读已有文件,不连接仪器。
+
+## 长时间测试
+
+对会因静默而断开的终端,使用无额外依赖的心跳运行器:
+
+```bash
+python scripts/pytest_progress.py -- -q
+python scripts/pytest_progress.py --heartbeat-s 10 -- -q tests/test_run_service.py
+```
+
+它原样转发 pytest 输出,并每隔指定秒数打印 keepalive。它只能避免“无输出”超时,不能绕过终端或编排平台本身设置的总运行时限。
## 文档索引
diff --git a/doc/README_EN.md b/doc/README_EN.md
index 4ce186a..d50043f 100644
--- a/doc/README_EN.md
+++ b/doc/README_EN.md
@@ -58,7 +58,7 @@ Its supported product scope is intentionally frozen to the power-supply, DMM, an
## Optional frequency-response analysis and PDF reports
-`sweep.frequency_response` sets the source through explicit frequency points and captures a reference (DUT input) and response (DUT output) scope channel in one acquisition per point. It writes a point-by-point `frequency_response.csv` with linear/dB gain and wrapped/unwrapped output-relative phase. The offline HTML report renders magnitude, phase, fit-comparison SVGs, the point table, and any saved per-point screenshots.
+`sweep.frequency_response` sets the source through explicit frequency points and captures a reference (DUT input) and response (DUT output) scope channel in one acquisition per point. It writes a point-by-point `frequency_response.csv` with linear/dB gain and wrapped/unwrapped output-relative phase. A plan may also specify multiple requested Vpp slices, producing a two-dimensional Vpp × frequency measurement. The offline HTML report renders magnitude, phase, fit-comparison SVGs, the point table, and any saved per-point screenshots.
Use the conservative template before editing a plan manually:
@@ -71,7 +71,24 @@ python -m wavebench run check --plan plans/frequency_response.toml
python -m wavebench run report data/runs/ --pdf
```
-`linear_log` and `polynomial` fit linear gain against `log10(frequency_hz / Hz)`; PCHIP additionally needs the `analysis` extra. The PDF is a portable visual report: its visible screenshots, SVG charts, and tables are embedded, while CSV/JSON/NPY evidence stays as separate artifacts for reproducible analysis. WeasyPrint also relies on platform rendering libraries (Cairo, Pango, GDK-PixBuf) and suitable CJK fonts where needed.
+`linear_log` and `polynomial` fit linear gain against `log10(frequency_hz / Hz)`; PCHIP, dB smoothing splines, and 2D calibration require the `analysis` extra. For multi-Vpp data, `[steps.calibration]` selects a dB target (`passband_median`, `explicit_gain_db`, or `unity_gain`), emits a bounded floating-point LUT in `frequency_response_calibration.csv`, and records validation, limiter flags, and piecewise Chebyshev coefficients in `frequency_response_calibration.json`. Calibration never extrapolates beyond the measured frequency/Vpp domain. The same products can be regenerated without instruments using:
+
+```bash
+python -m wavebench run calibrate data/runs/ --config plans/calibration.toml
+```
+
+The PDF is a portable visual report: its visible screenshots, SVG charts, and tables are embedded, while CSV/JSON/NPY evidence stays as separate artifacts for reproducible analysis. WeasyPrint also relies on platform rendering libraries (Cairo, Pango, GDK-PixBuf) and suitable CJK fonts where needed.
+
+## Running tests in idle-limited terminals
+
+For terminals that disconnect a silent process, use the dependency-free runner below. It forwards pytest output and emits a keepalive line every five seconds; `--heartbeat-s` changes the interval.
+
+```bash
+python scripts/pytest_progress.py -- -q
+python scripts/pytest_progress.py --heartbeat-s 10 -- -q tests/test_run_service.py
+```
+
+This prevents idle-output timeouts; it does not override a terminal or orchestration system's separate total-runtime limit.
Executable plugins use canonical IDs and cannot define aliases. Built-in IDs are protected except for narrowly allowlisted optional-override slots that bind one canonical ID to one distribution. The current shared-ID slots cover DG4000, DM3000, DP800, and RTM2000. Their built-in short aliases always select the bundled baseline, and uninstalling the external distribution restores the bundled canonical implementation. DS1000Z uses the separate external canonical ID `rigol.ds1000z`; its built-in `ds1104` and `ds1000z` aliases remain available without the package. DG4000 source plugins may import the stable `DG4000DacBlock` and `DG4000ByteOrder` types from `wavebench.instruments`; waveform loading, normalization, DAC14 encoding, services, and safety policy remain core responsibilities. The source code retains the historical term `migration slot` for this allowlist, but it does not imply deprecating the bundled drivers.
diff --git "a/doc/project/WaveBench_run_plan_\344\275\277\347\224\250\346\214\207\345\215\227.md" "b/doc/project/WaveBench_run_plan_\344\275\277\347\224\250\346\214\207\345\215\227.md"
index ecb2bb1..a974e11 100644
--- "a/doc/project/WaveBench_run_plan_\344\275\277\347\224\250\346\214\207\345\215\227.md"
+++ "b/doc/project/WaveBench_run_plan_\344\275\277\347\224\250\346\214\207\345\215\227.md"
@@ -114,7 +114,7 @@ python -m wavebench run verify --config wavebench.toml --plan plans/source_scope
- source 输出必须已经由前面的显式 `source.output state = "on"` 打开。频响 step 不会偷偷打开输出;若输出关闭或设频写入失败,会立即停止后续频点并走已有 restore 路径。
- 两路都会经过高阻保护。执行前仍需人工确认探头、线缆、量程和接地;WaveBench 不自动 deskew,也不会把测得相位冒充为已校准 DUT 相位。
- 每个成功采集强制保存双路 NPY 与 `metadata.json` 作为原始证据,即使全局输出配置关闭了 NPY/JSON。采集或分析失败会写入该频点 CSV 行后继续;信号源状态/写入异常会停止。
-- 一个 plan 最多包含一个该 step,避免固定的 `frequency_response.csv` / `frequency_response_fit.json` 产物名冲突。
+- 一个 plan 最多包含一个该 step,避免固定的频响与校准产物名冲突。
显式频点示例:
@@ -138,7 +138,7 @@ save_csv = false
screenshot = true
[steps.fit]
-methods = ["linear_log", "polynomial", "pchip"]
+methods = ["linear_log", "polynomial", "pchip", "smoothing_spline_db", "piecewise_chebyshev_db"]
polynomial_degree = 2
```
@@ -155,11 +155,79 @@ frequency_count = 31
spacing = "log" # "log" 或 "linear"
```
-拟合的因变量始终是线性增益 `gain_linear`,自变量是 `x = log10(f / Hz)`;不会对 dB 增益拟合,也不会在测量频段外外推:
+### 二维 Vpp × 频率扫频与自动校准
+
+默认频响 plan 是一个固定源幅值切片。需要观察增益随输入幅值变化、或为数字后端生成二维 LUT 时,在同一个 step 中额外给出请求 Vpp 轴。可以显式列举,或用首末值和步进生成;两种写法不能混用:
+
+```toml
+[[steps]]
+kind = "sweep.frequency_response"
+source_channel = 1
+reference_channel = 1
+response_channel = 2
+start_frequency_hz = 10000
+stop_frequency_hz = 500000
+frequency_count = 246
+spacing = "linear"
+start_vpp = 0.005
+stop_vpp = 0.250
+vpp_step = 0.005
+target_cycles = 10
+settle_s = 1.0
+
+[steps.calibration]
+target_mode = "passband_median"
+correction_min_db = -12
+correction_max_db = 12
+max_slope_db_per_octave = 6
+```
+
+等价的显式幅值写法为 `amplitudes_vpp = [0.005, 0.010, 0.020]`。每个值都是信号源**请求/设定** Vpp;`reference_vpp_v` 始终保留为示波器 CH1 的实测审计量,二者不可互换。
+
+| 字段 | 含义与限制 |
+|---|---|
+| `amplitudes_vpp` | 严格递增、正数的显式 Vpp 数组;不能与生成式字段共用。 |
+| `start_vpp` / `stop_vpp` / `vpp_step` | 三者必须同时出现;`stop_vpp > start_vpp`,步进必须整除区间。 |
+| `autoscale_each_amplitude` | 多幅值时默认 `true`;仅在已人工确认量程时才建议设为 `false`。 |
+| `[steps.calibration]` | 仅多幅值数据有实际意义;`enabled = false` 可关闭自动派生。 |
+
+执行顺序固定为“设定 Vpp → 对每个频点设频并等待 `settle_s` → 每个幅值切片的首个频点 autoscale → 再等待 `settle_s` → 同步采集 CH1/CH2”。`autoscale_each_amplitude = false` 可显式关闭该切片首点 autoscale,但仅适用于已由人工确认两路量程足够的情况。每个 Vpp 值都在连接仪器前受 `[safety_limits].max_source_vpp` 检查。
+
+完整矩阵的点数为 `幅值数 × 频点数`;每个点至少消耗 `settle_s`,每个幅值切片还会增加一次 autoscale 与稳定等待。先用少量幅值和稀疏频点执行 `run check`、只读 `run verify`、再做实机小矩阵确认量程和耗时,别拿 5 mV 步进和 500 Hz 步进直接开 13 小时以上的盲扫,没必要给仪器和人都上强度。
+
+`[steps.calibration]` 需要至少两个请求 Vpp 切片且每个切片至少四个共同有效频点。它会在测量结束后使用每切片的 dB 平滑样条、请求 Vpp 方向线性插值生成校准;频率节点沿用实测点,不在频率或 Vpp 定义域外外推。自动校准失败不会删除或重写原始频响 CSV:该 step 变为 `warning`,`artifact.frequency_response.calibration_error` 记录原因,之后可修正配置或数据再离线重算。
+
+离线重算使用只含 `[calibration]` 的独立 TOML,既不读取 `wavebench.toml`,也不连接仪器:
+
+```powershell
+python -m wavebench run calibrate data/runs/ --config plans/calibration.toml
+```
+
+`run calibrate` 使用的 `[calibration]` 与 plan 内 `[steps.calibration]` 使用同一 schema。除 `enabled`(仅 plan 使用)外,字段如下:
+
+| 字段 | 默认值 | 说明 |
+|---|---:|---|
+| `model` | `"smoothing_spline_db"` | 当前唯一受支持的二维频率模型。 |
+| `target_mode` | `"passband_median"` | `passband_median`、`explicit_gain_db` 或 `unity_gain`。 |
+| `target_gain_db` | 无 | `explicit_gain_db` 时必填。 |
+| `target_frequency_min_hz` / `target_frequency_max_hz` | 全有效频段 | 仅确定通带中位数目标的频段。 |
+| `correction_min_db` / `correction_max_db` | `-12` / `12` | 导出的补偿 dB 边界。 |
+| `max_slope_db_per_octave` | `6` | 相邻频率校正的最大斜率,必须大于零。 |
+| `chebyshev_degree` / `chebyshev_segment_count` | `3` / `8` | 为需要公式求值的后端导出的近似参数。 |
+
+该命令只覆盖同 run 目录下的 `frequency_response_calibration.csv/json` 派生产物,绝不改写 `frequency_response.csv` 或原始采集包。
+
+### 拟合方法
+
+除专供二维校准的模型外,传统线性增益拟合仍使用因变量 `gain_linear` 与 `x = log10(f / Hz)`:
- `linear_log`:分段线性插值,导出每段 `m`、`b`,即 `G = m*x + b`。
- `polynomial`:1–5 阶多项式,导出降幂系数。阶数必须小于有效频点数。
- `pchip`:保形三次插值,导出每段 `x_start`、`x_stop` 和 `[c3, c2, c1, c0]`,即 `G = c3*dx^3 + c2*dx^2 + c1*dx + c0`。它需要先安装 `python -m pip install -e ".[analysis]"`。
+- `smoothing_spline_db`:在 dB 增益域对 `log10(f / Hz)` 做平滑样条,输出曲线用于可读性更好的报告和调试;少于四个有效点时标为 unavailable。
+- `piecewise_chebyshev_db`:在 dB 域给出低阶分段 Chebyshev 近似,适合需要直接计算公式的调试后端。
+
+二维校准固定使用 `smoothing_spline_db`;它会用规律留点交叉验证在若干平滑度候选中选择一个全切片共享的惩罚,再导出每幅值的三阶、默认 8 个对数频段的 Chebyshev 系数。校正定义为 `correction_db = target_gain_db - fitted_gain_db`,线性乘数为 `10^(correction_db / 20)`;补偿幅度和相邻频点的 dB/oct 斜率都会被限制并逐点标记。
相位使用输出相对输入的相量差,CSV 同时提供 `phase_wrapped_deg` 和不跨失败点连接的 `phase_unwrapped_deg`。探头、电缆和通道延迟均会包含在相位里;先做直通基线或 deskew,才能把相位解释为 DUT 本体特性。
@@ -321,6 +389,8 @@ data/runs/YYYYMMDD_HHMMSS_/
|---|---|
| `csv` | run 根目录的逐点 `frequency_response.csv`。 |
| `fit_json` | 启用拟合时的 `frequency_response_fit.json`;未启用则为空。 |
+| `calibration_csv` / `calibration_json` | 自动二维校准成功时的派生 LUT 路径;未启用或未生成则为空。 |
+| `calibration_error` | 自动校准未生成时的原因;原始频响仍然保留。 |
| `captures` | 每个已有双通道采集包与 metadata 的引用,供报告和审计使用。 |
| `failed_point_count` / `warning_point_count` | 频点失败与质量 warning 数量。 |
@@ -358,7 +428,7 @@ HTML 报告当前会汇总:
- `实验证据摘要 / Run evidence summary`:source 步骤、scope capture、DMM 读数、run.json、summary.csv、截图和波形预览数量。
- `证据时间线 / Evidence timeline`:按 step 展示 source/scope/DMM/sleep 的证据摘要。
- `扫频摘要 / Sweep summary`:当 run 里有多点 `scope.capture` 或 sweep label 时显示,列出每个频点的 label、status、quality、expect、FFT、frequency、Vpp、FFT peak、peak amplitude 和 THD。
-- `频率响应 / Frequency response`:当 run 根目录存在 `frequency_response.csv` 时显示幅频、相频、线性增益拟合对比、逐点表格、拟合公式/参数,并发现每点的截图和原始采集包链接。
+- `频率响应 / Frequency response`:当 run 根目录存在 `frequency_response.csv` 时显示幅频、相频、线性增益拟合对比、逐点表格、拟合公式/参数,并发现每点的截图和原始采集包链接;存在校准产物时还会显示目标、留点验证误差、补偿热图与代表性幅值切片。
- `验收摘要 / Acceptance summary` 与 `预期 vs 实测 / Expected vs measured`:汇总 `[steps.expect]` 和 `[steps.expect_fft]` 的验收结果。
- `DMM 读数 / DMM readings`、`信号分析 / Signal analysis`、`波形预览 / Waveform previews`、`截图 / Screenshots`。
diff --git "a/doc/project/WaveBench_\346\225\260\346\215\256\350\276\223\345\207\272\346\240\274\345\274\217.md" "b/doc/project/WaveBench_\346\225\260\346\215\256\350\276\223\345\207\272\346\240\274\345\274\217.md"
index 55c92bb..46f7300 100644
--- "a/doc/project/WaveBench_\346\225\260\346\215\256\350\276\223\345\207\272\346\240\274\345\274\217.md"
+++ "b/doc/project/WaveBench_\346\225\260\346\215\256\350\276\223\345\207\272\346\240\274\345\274\217.md"
@@ -638,22 +638,50 @@ step_index,kind,status,package,metadata,quality_status,quality_warnings,expect_s
```text
data/runs/YYYYMMDD_HHMMSS_/
├─ frequency_response.csv
-└─ frequency_response_fit.json # 仅配置 [steps.fit] 时存在
+├─ frequency_response_fit.json # 仅配置 [steps.fit] 时存在
+├─ frequency_response_calibration.csv # 仅自动或离线二维校准成功时存在
+└─ frequency_response_calibration.json
```
`frequency_response.csv` 每请求一个频点就原子刷新一次,因此 source 设频失败、scope 采集失败或分析失败时,前序记录和当前失败行仍会保留。稳定基础列为:
```text
-index,requested_frequency_hz,reference_frequency_hz,response_frequency_hz,
+index,amplitude_index,requested_vpp,requested_frequency_hz,reference_frequency_hz,response_frequency_hz,
reference_amplitude_peak_v,response_amplitude_peak_v,reference_vpp_v,response_vpp_v,
gain_linear,gain_db,phase_wrapped_deg,phase_unwrapped_deg,status,warnings,error,
capture_package,metadata_path
```
- `gain_linear` 是输出基波峰值 / 输入基波峰值;`gain_db = 20 * log10(gain_linear)`。
+- `amplitude_index` 从零开始标识请求 Vpp 切片;固定幅值的旧 run 也写为 `0`。`requested_vpp` 是信号源设定值;`reference_vpp_v` 是 CH1 实测输入值,只用于审计和诊断,不能替代 LUT 的幅值轴。
- `phase_wrapped_deg` 在 `[-180, 180)`;`phase_unwrapped_deg` 对连续成功点展开,绝不跨失败点连接。
- `status` 为 `ok`、`warning` 或 `failed`。失败行的数值字段为空,`error` 保存可读错误,不能被误当作零增益或零相位。
- `capture_package` / `metadata_path` 指向每个成功的同步双通道原始证据。频响采集强制写入两路 NPY 与 metadata,普通可选 CSV 和截图仍遵循该 step 的 `save_csv` / `screenshot` 设置。
- 开启拟合后,CSV 还会增加 `fit__gain_linear` 与 `fit__residual` 列;这些值只对应实际有效频点。
`frequency_response_fit.json` 是供报告、调试脚本和复算使用的 JSON 文档。它声明 `x_transform = "log10(frequency_hz / Hz)"`、有效范围、被排除的点、拟合公式、参数、误差指标和用于图表的频率/线性增益曲线。它不在定义域外外推:调试脚本应先检查 `valid_domain_hz`。
+
+启用多幅值采集时,传统一维 `[steps.fit]` 文档会选择最低 `requested_vpp` 切片,并通过 `fit_amplitude_vpp` 与 `fit_note` 声明这一点;二维部署/校准必须读取下述校准文件,而不是拼接一维拟合结果。
+
+### 二维校准 LUT
+
+`frequency_response_calibration.csv` 是后端最直接的浮点 LUT 输入;行的稳定列为:
+
+```text
+frequency_hz,requested_vpp,fitted_gain_db,correction_db,correction_linear,
+correction_limited,slope_limited
+```
+
+- 每个 `(requested_vpp, frequency_hz)` 组合一行;频率与幅值节点来自有效测量的共同网格。
+- `fitted_gain_db` 是 dB 域平滑样条预测值,不是原始单点噪声的机械复制。
+- `correction_db = target_gain_db - fitted_gain_db`,`correction_linear = 10^(correction_db / 20)`。
+- `correction_limited` 表示命中 `correction_min_db` / `correction_max_db`;`slope_limited` 表示为满足 `max_slope_db_per_octave` 而受限。后端不应把受限标记忽略后再次放大。
+
+`frequency_response_calibration.json`(`schema_version = 1`)是完整审计和公式载体,包含:
+
+- 源 `frequency_response.csv` 路径、`configuration`、目标模式与最终 `target_gain_db`;
+- 频率/Vpp 有效域、采用的实测网格、样条平滑度候选的留点误差、频率及幅值留点验证 RMSE;
+- 补偿和斜率限制命中数、完整的 `lut` 行;
+- 每个请求 Vpp 的 `chebyshev` 分段。每段以 `x = log10(frequency_hz / Hz)` 为自变量,`x_start` / `x_stop` 映射到 `t ∈ [-1, 1]`,并按 `G_dB = Σ c_k T_k(t)` 计算。
+
+插值约定是“频率方向平滑样条、相邻请求 Vpp 方向线性插值”;频率或 Vpp 超出 `valid_domain` 时不得外推。输出为 IEEE 浮点 CSV/JSON,不提供 Q 格式、COE 或 MEM 文件。
diff --git "a/doc/project/WaveBench_\351\205\215\347\275\256\346\226\207\344\273\266\346\240\274\345\274\217.md" "b/doc/project/WaveBench_\351\205\215\347\275\256\346\226\207\344\273\266\346\240\274\345\274\217.md"
index 9138b69..3382b55 100644
--- "a/doc/project/WaveBench_\351\205\215\347\275\256\346\226\207\344\273\266\346\240\274\345\274\217.md"
+++ "b/doc/project/WaveBench_\351\205\215\347\275\256\346\226\207\344\273\266\346\240\274\345\274\217.md"
@@ -333,7 +333,7 @@ max_power_current_limit_a = 0.2
这些参数是第一层执行安全上限。全部都是可选项;省略某一项表示这一轴不设软件上限。
-- `max_source_vpp`:限制 `source set-vpp`、`source arb-load`、`run plan` 中 `source.set_vpp` / `source.arb_load` 的 Vpp。
+- `max_source_vpp`:限制 `source set-vpp`、`source arb-load`、`run plan` 中 `source.set_vpp` / `source.arb_load`,以及 `sweep.frequency_response` 的每个 `amplitudes_vpp` / 生成 Vpp 切片。
- `max_power_voltage_v`:限制 `power set` 与 `run plan` 中 `power.set` 的设定电压。
- `max_power_current_limit_a`:限制 `power set` 与 `run plan` 中 `power.set` 的限流值。
@@ -434,7 +434,7 @@ max_power_current_limit_a = 0.2
这些参数是第一层执行安全上限。全部都是可选项;省略某一项表示这一轴不设软件上限。
-- `max_source_vpp`:限制 `source set-vpp`、`source arb-load`、`run plan` 中 `source.set_vpp` / `source.arb_load` 的 Vpp。
+- `max_source_vpp`:限制 `source set-vpp`、`source arb-load`、`run plan` 中 `source.set_vpp` / `source.arb_load`,以及 `sweep.frequency_response` 的每个 `amplitudes_vpp` / 生成 Vpp 切片。
- `max_power_voltage_v`:限制 `power set` 与 `run plan` 中 `power.set` 的设定电压。
- `max_power_current_limit_a`:限制 `power set` 与 `run plan` 中 `power.set` 的限流值。
diff --git a/scripts/pytest_progress.py b/scripts/pytest_progress.py
new file mode 100644
index 0000000..24b2098
--- /dev/null
+++ b/scripts/pytest_progress.py
@@ -0,0 +1,68 @@
+"""Run pytest with forwarded output and periodic keepalive lines for idle-limited terminals."""
+
+from __future__ import annotations
+
+import argparse
+import subprocess
+import sys
+import threading
+import time
+from typing import Sequence
+
+
+def build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(
+ description="Run pytest while printing a periodic keepalive for terminals with idle timeouts."
+ )
+ parser.add_argument(
+ "--heartbeat-s",
+ type=float,
+ default=5.0,
+ help="Seconds between keepalive lines while pytest is still running (default: 5).",
+ )
+ parser.add_argument(
+ "pytest_args",
+ nargs=argparse.REMAINDER,
+ help="Arguments forwarded to pytest; place them after --, for example: -- -q",
+ )
+ return parser
+
+
+def main(argv: Sequence[str] | None = None) -> int:
+ args = build_parser().parse_args(argv)
+ if args.heartbeat_s <= 0:
+ raise SystemExit("--heartbeat-s must be > 0")
+ pytest_args = list(args.pytest_args)
+ if pytest_args[:1] == ["--"]:
+ pytest_args.pop(0)
+ command = [sys.executable, "-m", "pytest", *pytest_args]
+ process = subprocess.Popen(
+ command,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT,
+ text=True,
+ bufsize=1,
+ )
+
+ def forward_output() -> None:
+ assert process.stdout is not None
+ for character in iter(lambda: process.stdout.read(1), ""):
+ sys.stdout.write(character)
+ sys.stdout.flush()
+
+ forwarder = threading.Thread(target=forward_output, daemon=True)
+ forwarder.start()
+ started = time.monotonic()
+ while True:
+ try:
+ returncode = process.wait(timeout=args.heartbeat_s)
+ except subprocess.TimeoutExpired:
+ elapsed = time.monotonic() - started
+ print(f"\n[pytest-progress] elapsed={elapsed:.1f}s status=running", flush=True)
+ continue
+ forwarder.join()
+ return returncode
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/src/wavebench/cli.py b/src/wavebench/cli.py
index b6468f6..5d00e79 100644
--- a/src/wavebench/cli.py
+++ b/src/wavebench/cli.py
@@ -92,6 +92,12 @@
write_run_template,
)
from .services.run_service import RunService
+from .services.frequency_response_calibration import (
+ build_frequency_response_calibration,
+ load_frequency_response_calibration_config,
+ write_frequency_response_calibration_csv,
+ write_frequency_response_calibration_json,
+)
from .services.sweep_service import SweepService, parse_frequency_list
@@ -384,6 +390,27 @@ def main(argv: list[str] | None = None) -> int:
)
return 0
if args.domain == "run":
+ if args.command == "calibrate":
+ package = load_run_package(args.path)
+ if package.frequency_response_csv_path is None:
+ raise ConfigError("run calibrate requires frequency_response.csv in the run directory")
+ calibration = load_frequency_response_calibration_config(args.config)
+ if not calibration.enabled:
+ raise ConfigError("calibration.enabled must be true for run calibrate")
+ document, rows = build_frequency_response_calibration(
+ package.frequency_response_rows,
+ calibration,
+ source_csv=package.frequency_response_csv_path,
+ )
+ csv_path = write_frequency_response_calibration_csv(
+ package.path / "frequency_response_calibration.csv", rows
+ )
+ json_path = write_frequency_response_calibration_json(
+ package.path / "frequency_response_calibration.json", document
+ )
+ print(f"calibration_csv={csv_path}")
+ print(f"calibration_json={json_path}")
+ return 0
if args.command == "report":
if args.pdf_output and not args.pdf:
raise ConfigError("run report --pdf-output requires --pdf")
diff --git a/src/wavebench/cli_parser.py b/src/wavebench/cli_parser.py
index 7da4888..187a887 100644
--- a/src/wavebench/cli_parser.py
+++ b/src/wavebench/cli_parser.py
@@ -337,6 +337,13 @@ def build_parser() -> argparse.ArgumentParser:
run_plan = run_sub.add_parser("plan", help="Execute a WaveBench run plan")
run_plan.add_argument("--plan", required=True, help="Path to a WaveBench run plan TOML file")
add_runtime_options(run_plan)
+ run_calibrate = run_sub.add_parser(
+ "calibrate", help="Build an offline 2D frequency-response calibration LUT from an existing run"
+ )
+ run_calibrate.add_argument("path", help="Path to data/runs/")
+ run_calibrate.add_argument(
+ "--config", required=True, help="Path to TOML containing a [calibration] table"
+ )
run_report = run_sub.add_parser("report", help="Generate an offline HTML report for a run package")
run_report.add_argument("path", help="Path to data/runs/")
run_report.add_argument("--output", default=None, help="Output HTML path; defaults to /report.html")
diff --git a/src/wavebench/data/packages.py b/src/wavebench/data/packages.py
index adecebe..7922937 100644
--- a/src/wavebench/data/packages.py
+++ b/src/wavebench/data/packages.py
@@ -47,6 +47,11 @@ class RunPackage:
frequency_response_fit_path: Path | None = None
frequency_response_fit: dict[str, Any] | None = None
frequency_response_fit_error: str | None = None
+ frequency_response_calibration_csv_path: Path | None = None
+ frequency_response_calibration_rows: list[dict[str, str]] = field(default_factory=list)
+ frequency_response_calibration_path: Path | None = None
+ frequency_response_calibration: dict[str, Any] | None = None
+ frequency_response_calibration_error: str | None = None
@property
def status(self) -> str:
@@ -158,6 +163,24 @@ def load_run_package(path: str | Path) -> RunPackage:
fit = _read_json_object(present_fit_path, label="frequency response fit JSON")
except ConfigError as exc:
fit_error = str(exc)
+ calibration_csv_path = run_dir / "frequency_response_calibration.csv"
+ calibration_rows: list[dict[str, str]] = []
+ present_calibration_csv_path: Path | None = None
+ if calibration_csv_path.exists():
+ present_calibration_csv_path = calibration_csv_path
+ with calibration_csv_path.open(newline="", encoding="utf-8") as file:
+ calibration_rows = [dict(row) for row in csv.DictReader(file)]
+ calibration_path = run_dir / "frequency_response_calibration.json"
+ present_calibration_path: Path | None = calibration_path if calibration_path.exists() else None
+ calibration: dict[str, Any] | None = None
+ calibration_error: str | None = None
+ if present_calibration_path is not None:
+ try:
+ calibration = _read_json_object(
+ present_calibration_path, label="frequency response calibration JSON"
+ )
+ except ConfigError as exc:
+ calibration_error = str(exc)
return RunPackage(
path=run_dir,
run_json_path=run_json_path,
@@ -169,6 +192,11 @@ def load_run_package(path: str | Path) -> RunPackage:
frequency_response_fit_path=present_fit_path,
frequency_response_fit=fit,
frequency_response_fit_error=fit_error,
+ frequency_response_calibration_csv_path=present_calibration_csv_path,
+ frequency_response_calibration_rows=calibration_rows,
+ frequency_response_calibration_path=present_calibration_path,
+ frequency_response_calibration=calibration,
+ frequency_response_calibration_error=calibration_error,
)
diff --git a/src/wavebench/report/html.py b/src/wavebench/report/html.py
index d722617..d311118 100644
--- a/src/wavebench/report/html.py
+++ b/src/wavebench/report/html.py
@@ -426,6 +426,16 @@ def _build_report_manifest(run: RunPackage, *, output_dir: Path, report_path: Pa
"frequency_response_fit_json": _relative_url(run.frequency_response_fit_path, output_dir)
if run.frequency_response_fit_path is not None
else None,
+ "frequency_response_calibration_csv": _relative_url(
+ run.frequency_response_calibration_csv_path, output_dir
+ )
+ if run.frequency_response_calibration_csv_path is not None
+ else None,
+ "frequency_response_calibration_json": _relative_url(
+ run.frequency_response_calibration_path, output_dir
+ )
+ if run.frequency_response_calibration_path is not None
+ else None,
"capture_packages": capture_packages,
"screenshots": [
{
@@ -786,7 +796,7 @@ def _frequency_response_block(run: RunPackage, *, include_table: bool = True) ->
if not table_rows:
table_rows = '频响 CSV 没有可读取的记录 / No readable response rows. '
table_block = f"""
-# 请求频率 / Requested 输入峰值 / Input peak 输出峰值 / Output peak 线性增益 增益 / Gain 相位 / Phase 展开相位 / Unwrapped 状态 / Status 警告或错误 / Warning or error
+# 请求幅值 / Requested Vpp 请求频率 / Requested 输入峰值 / Input peak 输出峰值 / Output peak 线性增益 增益 / Gain 相位 / Phase 展开相位 / Unwrapped 状态 / Status 警告或错误 / Warning or error
{table_rows}
@@ -798,6 +808,7 @@ def _frequency_response_block(run: RunPackage, *, include_table: bool = True) ->
if include_table
else _compact_fit_summary_block(run.frequency_response_fit, run.frequency_response_fit_error)
)
+ calibration_block = _frequency_response_calibration_block(run)
return f"""频率响应 / Frequency response
幅频与相频由同一次双通道采集计算。相位为输出相对输入,包含探头、电缆和通道偏斜;未自动校准或 deskew。
@@ -808,6 +819,7 @@ def _frequency_response_block(run: RunPackage, *, include_table: bool = True) ->
拟合对比 / Fit comparison {fit_svg}
{fit_summary}
+{calibration_block}
{table_block}
"""
@@ -820,6 +832,7 @@ def _frequency_response_row(row: dict[str, str]) -> str:
return (
f''
f"{escape(str(row.get('index', '')))} "
+ f"{escape(_format_metric(row.get('requested_vpp'), 'Vpp'))} "
f"{escape(_format_metric(row.get('requested_frequency_hz'), 'Hz'))} "
f"{escape(_format_metric(row.get('reference_amplitude_peak_v'), 'V'))} "
f"{escape(_format_metric(row.get('response_amplitude_peak_v'), 'V'))} "
@@ -898,7 +911,13 @@ def _compact_fit_summary_block(document: dict[str, Any] | None, error: str | Non
def _response_blocks(rows: list[dict[str, str]], key: str) -> list[list[tuple[float, float]]]:
blocks: list[list[tuple[float, float]]] = []
block: list[tuple[float, float]] = []
+ amplitude: str | None = None
for row in rows:
+ current_amplitude = str(row.get("amplitude_index", row.get("requested_vpp", "")))
+ if amplitude is not None and current_amplitude != amplitude and block:
+ blocks.append(block)
+ block = []
+ amplitude = current_amplitude
frequency = _finite_float(row.get("requested_frequency_hz"))
value = _finite_float(row.get(key))
if row.get("status") == "failed" or frequency is None or frequency <= 0 or value is None:
@@ -912,6 +931,159 @@ def _response_blocks(rows: list[dict[str, str]], key: str) -> list[list[tuple[fl
return blocks
+def _frequency_response_calibration_block(run: RunPackage) -> str:
+ if run.frequency_response_calibration_error:
+ return (
+ '二维校准 / 2D calibration '
+ f'校准 JSON 无法读取 / Calibration unavailable: '
+ f'{escape(run.frequency_response_calibration_error)}
'
+ )
+ document = run.frequency_response_calibration
+ rows = run.frequency_response_calibration_rows
+ if not document and not rows:
+ errors = [
+ str(response.get("calibration_error"))
+ for step in run.steps
+ if isinstance(step.get("artifact"), dict)
+ for response in [step["artifact"].get("frequency_response", {})]
+ if isinstance(response, dict) and response.get("calibration_error")
+ ]
+ if errors:
+ return (
+ '二维校准 / 2D calibration '
+ f'自动校准未生成;原始频响仍已保留:{escape(errors[0])}
'
+ )
+ return ""
+ configuration = document.get("configuration", {}) if isinstance(document, dict) else {}
+ validation = document.get("validation", {}) if isinstance(document, dict) else {}
+ domain = document.get("valid_domain", {}) if isinstance(document, dict) else {}
+ target = document.get("target_gain_db") if isinstance(document, dict) else None
+ summary = f"""
+目标 / Target 有效频段 / Frequency range 请求幅值 / Requested Vpp 频率留点 RMSE 幅值留点 RMSE
+{escape(_format_metric(target, 'dB'))} ({escape(str(configuration.get('target_mode', '')) )})
+{escape(_format_range(domain.get('frequency_hz'), 'Hz'))}
+{escape(_format_range(domain.get('requested_vpp'), 'Vpp'))}
+{escape(_format_metric(validation.get('frequency_holdout_rmse_db'), 'dB'))}
+{escape(_format_metric(validation.get('amplitude_holdout_rmse_db'), 'dB'))}
+
"""
+ heatmap = _calibration_heatmap_svg(rows)
+ curves = _calibration_curve_svg(rows)
+ note = (
+ '二维 LUT 使用频率 dB 平滑样条与相邻请求 Vpp 线性插值;超出有效域不外推。'
+ '完整浮点 LUT 与 Chebyshev 系数见 frequency_response_calibration.csv 和 '
+ 'frequency_response_calibration.json。
'
+ )
+ return f"""二维校准 / 2D calibration
+{summary}
+
+补偿热图 / Correction heatmap {heatmap}
+代表性切片 / Representative slices {curves}
+
+{note}"""
+
+
+def _calibration_heatmap_svg(rows: list[dict[str, str]]) -> str:
+ points = _calibration_points(rows)
+ if not points:
+ return '没有可绘制的校准 LUT 点 / No readable calibration points.
'
+ amplitudes = sorted({point[0] for point in points})
+ frequencies = sorted({point[1] for point in points})
+ amplitude_indexes = _sample_indexes(len(amplitudes), 16)
+ frequency_indexes = _sample_indexes(len(frequencies), 70)
+ selected_amplitudes = [amplitudes[index] for index in amplitude_indexes]
+ selected_frequencies = [frequencies[index] for index in frequency_indexes]
+ lookup = {(amplitude, frequency): correction for amplitude, frequency, correction in points}
+ values = [lookup[(amplitude, frequency)] for amplitude in selected_amplitudes for frequency in selected_frequencies if (amplitude, frequency) in lookup]
+ if not values:
+ return '校准 LUT 不是完整矩阵,无法绘制热图。
'
+ span = max(max(abs(value) for value in values), 0.1)
+ width, height, left, top, right, bottom = 680, 285, 58, 28, 20, 48
+ plot_width, plot_height = width - left - right, height - top - bottom
+ cell_width = plot_width / len(selected_frequencies)
+ cell_height = plot_height / len(selected_amplitudes)
+ cells = []
+ for row_index, amplitude in enumerate(selected_amplitudes):
+ for column_index, frequency in enumerate(selected_frequencies):
+ correction = lookup.get((amplitude, frequency))
+ if correction is None:
+ color = "#e5e7eb"
+ else:
+ color = _calibration_color(correction, span)
+ cells.append(
+ f' '
+ )
+ return (
+ f''
+ f' {"".join(cells)}'
+ f' '
+ f'Correction (dB), frequency increases left to right '
+ f'{selected_frequencies[0]:.4g} Hz '
+ f'{selected_frequencies[-1]:.4g} Hz '
+ f'Vpp: {selected_amplitudes[0]:.4g} .. {selected_amplitudes[-1]:.4g}; color: -{span:.3g} .. +{span:.3g} dB '
+ ' '
+ )
+
+
+def _calibration_curve_svg(rows: list[dict[str, str]]) -> str:
+ points = _calibration_points(rows)
+ grouped: dict[float, list[tuple[float, float]]] = {}
+ for amplitude, frequency, correction in points:
+ grouped.setdefault(amplitude, []).append((frequency, correction))
+ amplitudes = sorted(grouped)
+ if not amplitudes:
+ return '没有可绘制的校准 LUT 点 / No readable calibration points.
'
+ indexes = sorted({0, len(amplitudes) // 2, len(amplitudes) - 1})
+ colors = ("#2563eb", "#7c3aed", "#dc2626")
+ series = [
+ (f"{amplitudes[index]:.6g} Vpp", colors[position], sorted(grouped[amplitudes[index]]))
+ for position, index in enumerate(indexes)
+ ]
+ return _response_svg(
+ [],
+ title="Correction by requested amplitude",
+ y_label="Correction (dB)",
+ series=series,
+ actual_label="",
+ )
+
+
+def _calibration_points(rows: list[dict[str, str]]) -> list[tuple[float, float, float]]:
+ points: list[tuple[float, float, float]] = []
+ for row in rows:
+ amplitude = _finite_float(row.get("requested_vpp"))
+ frequency = _finite_float(row.get("frequency_hz"))
+ correction = _finite_float(row.get("correction_db"))
+ if amplitude is not None and amplitude > 0 and frequency is not None and frequency > 0 and correction is not None:
+ points.append((amplitude, frequency, correction))
+ return points
+
+
+def _sample_indexes(count: int, maximum: int) -> list[int]:
+ if count <= maximum:
+ return list(range(count))
+ return sorted({round(value) for value in np.linspace(0, count - 1, maximum)})
+
+
+def _calibration_color(value: float, span: float) -> str:
+ normalized = min(1.0, abs(value) / span)
+ if value < 0:
+ red = round(232 - 120 * normalized)
+ green = round(241 - 75 * normalized)
+ blue = round(248 - 5 * normalized)
+ else:
+ red = round(255 - 15 * normalized)
+ green = round(247 - 105 * normalized)
+ blue = round(237 - 135 * normalized)
+ return f"#{red:02x}{green:02x}{blue:02x}"
+
+
+def _format_range(value: Any, unit: str) -> str:
+ if not isinstance(value, list) or len(value) != 2:
+ return ""
+ return f"{_format_metric(value[0], unit)} .. {_format_metric(value[1], unit)}"
+
+
def _fit_curve_series(document: dict[str, Any] | None) -> list[tuple[str, str, list[tuple[float, float]]]]:
if not document:
return []
@@ -966,7 +1138,10 @@ def _response_svg(
y_min -= pad_y
y_max += pad_y
width, pad_left, pad_right, pad_top = 680, 58, 20, 28
- legend_items = [(actual_label, "#2563eb"), *[(name, color) for name, color, _values in series]]
+ legend_items = (
+ ([(actual_label, "#2563eb")] if actual else [])
+ + [(name, color) for name, color, _values in series]
+ )
legend_columns = 2
legend_row_height = 15
legend_rows = max(1, (len(legend_items) + legend_columns - 1) // legend_columns)
@@ -1384,6 +1559,26 @@ def _collect_artifact_links(
status=_availability_text(run.frequency_response_fit_path.exists()),
)
)
+ if run.frequency_response_calibration_csv_path is not None:
+ links.append(
+ ReportArtifactLink(
+ step_index="-",
+ kind="二维校准 CSV / 2D calibration CSV",
+ label="frequency_response_calibration.csv",
+ href=_relative_url(run.frequency_response_calibration_csv_path, output_dir),
+ status=_availability_text(run.frequency_response_calibration_csv_path.exists()),
+ )
+ )
+ if run.frequency_response_calibration_path is not None:
+ links.append(
+ ReportArtifactLink(
+ step_index="-",
+ kind="二维校准 JSON / 2D calibration JSON",
+ label="frequency_response_calibration.json",
+ href=_relative_url(run.frequency_response_calibration_path, output_dir),
+ status=_availability_text(run.frequency_response_calibration_path.exists()),
+ )
+ )
screenshots_by_package = {item.package: item for item in screenshots}
for reference in _capture_references(run):
package_dir = _resolve_artifact_path(run.path, reference.package)
diff --git a/src/wavebench/services/frequency_response.py b/src/wavebench/services/frequency_response.py
index 088076d..256f54f 100644
--- a/src/wavebench/services/frequency_response.py
+++ b/src/wavebench/services/frequency_response.py
@@ -12,9 +12,17 @@
from wavebench.errors import ConfigError
-FIT_METHODS = ("linear_log", "polynomial", "pchip")
+FIT_METHODS = (
+ "linear_log",
+ "polynomial",
+ "pchip",
+ "smoothing_spline_db",
+ "piecewise_chebyshev_db",
+)
BASE_CSV_FIELDS = (
"index",
+ "amplitude_index",
+ "requested_vpp",
"requested_frequency_hz",
"reference_frequency_hz",
"response_frequency_hz",
@@ -49,6 +57,8 @@ class FrequencyResponsePoint:
phase_wrapped_deg: float | None
phase_unwrapped_deg: float | None
status: str
+ amplitude_index: int = 0
+ requested_vpp: float | None = None
warnings: tuple[str, ...] = ()
error: str = ""
capture_package: str = ""
@@ -66,6 +76,8 @@ def usable_for_fit(self) -> bool:
def as_csv_row(self, fit_values: dict[str, tuple[float | None, float | None]] | None = None) -> dict[str, object]:
row: dict[str, object] = {
"index": self.index,
+ "amplitude_index": self.amplitude_index,
+ "requested_vpp": self.requested_vpp,
"requested_frequency_hz": self.requested_frequency_hz,
"reference_frequency_hz": self.reference_frequency_hz,
"response_frequency_hz": self.response_frequency_hz,
@@ -92,6 +104,8 @@ def as_csv_row(self, fit_values: dict[str, tuple[float | None, float | None]] |
def analyze_frequency_response_point(
*,
index: int,
+ amplitude_index: int = 0,
+ requested_vpp: float | None = None,
requested_frequency_hz: float,
reference_waveform: Any,
response_waveform: Any,
@@ -136,6 +150,8 @@ def analyze_frequency_response_point(
phase_wrapped_deg=phase_wrapped_deg,
phase_unwrapped_deg=None,
status="warning" if warnings else "ok",
+ amplitude_index=amplitude_index,
+ requested_vpp=requested_vpp,
warnings=tuple(warnings),
capture_package=capture_package,
metadata_path=metadata_path,
@@ -155,6 +171,8 @@ def analyze_frequency_response_point(
phase_wrapped_deg=None,
phase_unwrapped_deg=None,
status="failed",
+ amplitude_index=amplitude_index,
+ requested_vpp=requested_vpp,
error=f"{type(exc).__name__}: {exc}",
capture_package=capture_package,
metadata_path=metadata_path,
@@ -164,6 +182,8 @@ def analyze_frequency_response_point(
def failed_frequency_response_point(
*,
index: int,
+ amplitude_index: int = 0,
+ requested_vpp: float | None = None,
requested_frequency_hz: float,
error: Exception | str,
) -> FrequencyResponsePoint:
@@ -184,6 +204,8 @@ def failed_frequency_response_point(
phase_wrapped_deg=None,
phase_unwrapped_deg=None,
status="failed",
+ amplitude_index=amplitude_index,
+ requested_vpp=requested_vpp,
error=text,
)
@@ -202,7 +224,11 @@ def flush() -> None:
)
block.clear()
+ last_amplitude_index: int | None = None
for point in points:
+ if last_amplitude_index is not None and point.amplitude_index != last_amplitude_index:
+ flush()
+ last_amplitude_index = point.amplitude_index
if point.status == "failed" or point.phase_wrapped_deg is None:
flush()
result.append(point)
@@ -214,13 +240,13 @@ def flush() -> None:
def ensure_fit_dependencies(fit: dict[str, Any] | None) -> None:
methods = _fit_methods(fit)
- if "pchip" not in methods:
+ if not {"pchip", "smoothing_spline_db"}.intersection(methods):
return
try:
from scipy.interpolate import PchipInterpolator # noqa: F401
except ImportError as exc:
raise ConfigError(
- "fit method 'pchip' requires the optional analysis dependency; "
+ "frequency response PCHIP and smoothing-spline fits require the optional analysis dependency; "
"install WaveBench with `.[analysis]`"
) from exc
@@ -242,10 +268,22 @@ def build_fit_document(
document: dict[str, Any] = {
"schema_version": 1,
"x_transform": "log10(frequency_hz / Hz)",
- "valid_points": [point.index for point in usable],
+ "valid_points": [],
"excluded_points": excluded,
"methods": {},
}
+ requested_amplitudes = sorted(
+ {point.requested_vpp for point in usable if point.requested_vpp is not None}
+ )
+ if len(requested_amplitudes) > 1:
+ selected_amplitude = requested_amplitudes[0]
+ usable = [point for point in usable if point.requested_vpp == selected_amplitude]
+ document["fit_amplitude_vpp"] = selected_amplitude
+ document["fit_note"] = (
+ "Multiple requested Vpp slices were captured; conventional one-dimensional fits use "
+ "the lowest requested Vpp slice. Use frequency_response_calibration.json for the 2D model."
+ )
+ document["valid_points"] = [point.index for point in usable]
fit_values: dict[str, dict[int, tuple[float | None, float | None]]] = {}
if usable:
document["valid_domain_hz"] = [
@@ -443,6 +481,26 @@ def _fit_method(
"segments": _pchip_segments(interpolator),
},
}
+ elif method == "smoothing_spline_db":
+ predicted_db, curve_db, alpha = _smoothing_spline_db(x, y, curve_x)
+ predicted = np.power(10.0, predicted_db / 20.0)
+ curve_y = np.power(10.0, curve_db / 20.0)
+ result = {
+ "status": "ok",
+ "display": "Smoothing spline in dB over log frequency",
+ "formula": "x = log10(f / Hz); G_dB = S(x); G = 10^(G_dB / 20)",
+ "parameters": {"smoothing_alpha": alpha, "domain": "dB"},
+ }
+ elif method == "piecewise_chebyshev_db":
+ predicted_db, curve_db, segments = _piecewise_chebyshev_db(x, y, curve_x)
+ predicted = np.power(10.0, predicted_db / 20.0)
+ curve_y = np.power(10.0, curve_db / 20.0)
+ result = {
+ "status": "ok",
+ "display": "Piecewise Chebyshev approximation in dB",
+ "formula": "x = log10(f / Hz); G_dB = sum(c_k * T_k(t)) within segment; G = 10^(G_dB / 20)",
+ "parameters": {"domain": "dB", "segments": segments},
+ }
else: # pragma: no cover - normalized before dispatch
return _unavailable_fit(method, "unsupported method")
except Exception as exc: # noqa: BLE001 - optional fit must not discard measurements
@@ -465,6 +523,62 @@ def _fit_method(
return result, values
+def _smoothing_spline_db(
+ x: np.ndarray, y_linear: np.ndarray, curve_x: np.ndarray, *, alpha: float = 0.1
+) -> tuple[np.ndarray, np.ndarray, float]:
+ """Fit a conservative spline in dB, scaling the penalty to the observed variance."""
+ try:
+ from scipy.interpolate import UnivariateSpline
+ except ImportError as exc: # pragma: no cover - guarded in run check
+ raise ConfigError("smoothing_spline_db requires WaveBench with `.[analysis]`") from exc
+ y_db = 20.0 * np.log10(y_linear)
+ if x.size < 4:
+ raise ValueError("smoothing spline requires at least four valid points")
+ variance = float(np.var(y_db))
+ smoothing = max(0.0, alpha * x.size * variance)
+ spline = UnivariateSpline(x, y_db, k=min(3, x.size - 1), s=smoothing)
+ return (
+ np.asarray(spline(x), dtype=float),
+ np.asarray(spline(curve_x), dtype=float),
+ alpha,
+ )
+
+
+def _piecewise_chebyshev_db(
+ x: np.ndarray, y_linear: np.ndarray, curve_x: np.ndarray, *, segment_count: int = 8,
+ degree: int = 3,
+) -> tuple[np.ndarray, np.ndarray, list[dict[str, Any]]]:
+ """Approximate dB gain by deployable low-order Chebyshev pieces in log-frequency."""
+ if x.size < 2:
+ raise ValueError("Chebyshev approximation requires at least two valid points")
+ y_db = 20.0 * np.log10(y_linear)
+ count = min(segment_count, x.size - 1)
+ edges = np.linspace(float(x[0]), float(x[-1]), count + 1)
+ predictions = np.empty_like(x)
+ curve_predictions = np.empty_like(curve_x)
+ segments: list[dict[str, Any]] = []
+ for index, (start, stop) in enumerate(zip(edges[:-1], edges[1:])):
+ in_measurement = (x >= start) & ((x < stop) if index < count - 1 else (x <= stop))
+ sample_x = x[in_measurement]
+ sample_count = max(degree + 1, sample_x.size)
+ fit_x = np.linspace(start, stop, sample_count)
+ fit_y = np.interp(fit_x, x, y_db)
+ polynomial = np.polynomial.Chebyshev.fit(
+ fit_x, fit_y, deg=min(degree, sample_count - 1), domain=[start, stop]
+ )
+ predictions[in_measurement] = polynomial(sample_x)
+ in_curve = (curve_x >= start) & ((curve_x < stop) if index < count - 1 else (curve_x <= stop))
+ curve_predictions[in_curve] = polynomial(curve_x[in_curve])
+ segments.append(
+ {
+ "x_start": float(start),
+ "x_stop": float(stop),
+ "coefficients": [float(value) for value in polynomial.coef],
+ }
+ )
+ return predictions, curve_predictions, segments
+
+
def _unavailable_fit(method: str, reason: str) -> tuple[dict[str, Any], dict[int, tuple[float | None, float | None]]]:
return {
"status": "unavailable",
diff --git a/src/wavebench/services/frequency_response_calibration.py b/src/wavebench/services/frequency_response_calibration.py
new file mode 100644
index 0000000..8372293
--- /dev/null
+++ b/src/wavebench/services/frequency_response_calibration.py
@@ -0,0 +1,481 @@
+from __future__ import annotations
+
+import csv
+import json
+from dataclasses import asdict, dataclass
+from math import isfinite, log2
+from pathlib import Path
+import tomllib
+from typing import Any, Iterable
+
+import numpy as np
+
+from wavebench.errors import ConfigError
+
+
+CALIBRATION_TARGET_MODES = ("passband_median", "explicit_gain_db", "unity_gain")
+_SMOOTHING_ALPHAS = (0.0, 0.01, 0.1, 1.0, 10.0)
+_CSV_FIELDS = (
+ "frequency_hz",
+ "requested_vpp",
+ "fitted_gain_db",
+ "correction_db",
+ "correction_linear",
+ "correction_limited",
+ "slope_limited",
+)
+
+
+@dataclass(frozen=True)
+class FrequencyResponseCalibrationConfig:
+ enabled: bool = True
+ model: str = "smoothing_spline_db"
+ target_mode: str = "passband_median"
+ target_gain_db: float | None = None
+ target_frequency_min_hz: float | None = None
+ target_frequency_max_hz: float | None = None
+ correction_min_db: float = -12.0
+ correction_max_db: float = 12.0
+ max_slope_db_per_octave: float = 6.0
+ chebyshev_degree: int = 3
+ chebyshev_segment_count: int = 8
+
+ def as_dict(self) -> dict[str, Any]:
+ return asdict(self)
+
+
+def normalize_frequency_response_calibration(
+ raw: Any, name: str = "calibration"
+) -> FrequencyResponseCalibrationConfig:
+ if not isinstance(raw, dict):
+ raise ConfigError(f"{name} must be a TOML table")
+ allowed = {
+ "enabled",
+ "model",
+ "target_mode",
+ "target_gain_db",
+ "target_frequency_min_hz",
+ "target_frequency_max_hz",
+ "correction_min_db",
+ "correction_max_db",
+ "max_slope_db_per_octave",
+ "chebyshev_degree",
+ "chebyshev_segment_count",
+ }
+ unknown = sorted(set(raw) - allowed)
+ if unknown:
+ raise ConfigError(f"{name} has unsupported keys: {', '.join(unknown)}")
+ enabled = _bool(raw.get("enabled", True), f"{name}.enabled")
+ model = _text(raw.get("model", "smoothing_spline_db"), f"{name}.model").lower()
+ if model != "smoothing_spline_db":
+ raise ConfigError(f"{name}.model must be 'smoothing_spline_db'")
+ target_mode = _text(raw.get("target_mode", "passband_median"), f"{name}.target_mode").lower()
+ if target_mode not in CALIBRATION_TARGET_MODES:
+ choices = ", ".join(CALIBRATION_TARGET_MODES)
+ raise ConfigError(f"{name}.target_mode must be one of: {choices}")
+ target_gain_db = _optional_float(raw.get("target_gain_db"), f"{name}.target_gain_db")
+ if target_mode == "explicit_gain_db" and target_gain_db is None:
+ raise ConfigError(f"{name}.target_gain_db is required when target_mode = 'explicit_gain_db'")
+ minimum = _optional_positive_float(
+ raw.get("target_frequency_min_hz"), f"{name}.target_frequency_min_hz"
+ )
+ maximum = _optional_positive_float(
+ raw.get("target_frequency_max_hz"), f"{name}.target_frequency_max_hz"
+ )
+ if minimum is not None and maximum is not None and minimum >= maximum:
+ raise ConfigError(f"{name}.target_frequency_min_hz must be less than target_frequency_max_hz")
+ correction_min = _finite_float(raw.get("correction_min_db", -12.0), f"{name}.correction_min_db")
+ correction_max = _finite_float(raw.get("correction_max_db", 12.0), f"{name}.correction_max_db")
+ if correction_min > correction_max:
+ raise ConfigError(f"{name}.correction_min_db must be <= correction_max_db")
+ slope = _positive_float(
+ raw.get("max_slope_db_per_octave", 6.0), f"{name}.max_slope_db_per_octave"
+ )
+ degree = _positive_int(raw.get("chebyshev_degree", 3), f"{name}.chebyshev_degree")
+ if degree > 8:
+ raise ConfigError(f"{name}.chebyshev_degree must be <= 8")
+ segments = _positive_int(
+ raw.get("chebyshev_segment_count", 8), f"{name}.chebyshev_segment_count"
+ )
+ return FrequencyResponseCalibrationConfig(
+ enabled=enabled,
+ model=model,
+ target_mode=target_mode,
+ target_gain_db=target_gain_db,
+ target_frequency_min_hz=minimum,
+ target_frequency_max_hz=maximum,
+ correction_min_db=correction_min,
+ correction_max_db=correction_max,
+ max_slope_db_per_octave=slope,
+ chebyshev_degree=degree,
+ chebyshev_segment_count=segments,
+ )
+
+
+def load_frequency_response_calibration_config(path: str | Path) -> FrequencyResponseCalibrationConfig:
+ config_path = Path(path)
+ try:
+ with config_path.open("rb") as file:
+ raw = tomllib.load(file)
+ except OSError as exc:
+ raise ConfigError(f"cannot read calibration config: {config_path}: {exc}") from exc
+ table = raw.get("calibration") if isinstance(raw, dict) else None
+ if table is None:
+ raise ConfigError(f"calibration config requires a [calibration] table: {config_path}")
+ return normalize_frequency_response_calibration(table, "calibration")
+
+
+def ensure_calibration_dependencies() -> None:
+ try:
+ from scipy.interpolate import UnivariateSpline # noqa: F401
+ except ImportError as exc:
+ raise ConfigError(
+ "frequency response calibration requires the optional analysis dependency; "
+ "install WaveBench with `.[analysis]`"
+ ) from exc
+
+
+def build_frequency_response_calibration(
+ rows: Iterable[dict[str, Any]],
+ config: FrequencyResponseCalibrationConfig | dict[str, Any],
+ *,
+ source_csv: str | Path | None = None,
+) -> tuple[dict[str, Any], list[dict[str, Any]]]:
+ """Build a deployable two-dimensional gain-correction LUT from an audited CSV."""
+ if isinstance(config, dict):
+ config = normalize_frequency_response_calibration(config)
+ ensure_calibration_dependencies()
+ groups = _measurement_groups(rows)
+ if len(groups) < 2:
+ raise ConfigError("two-dimensional calibration requires at least two valid requested_vpp slices")
+ for amplitude, samples in groups.items():
+ if len(samples) < 4:
+ raise ConfigError(f"requested_vpp {amplitude:.12g} requires at least four valid frequency points")
+ amplitudes = np.asarray(sorted(groups), dtype=float)
+ common_frequencies = _common_frequency_grid(groups)
+ if common_frequencies.size < 4:
+ raise ConfigError("two-dimensional calibration requires at least four common valid frequency points")
+
+ alpha, frequency_cv = _select_smoothing_alpha(groups)
+ models = {
+ amplitude: _fit_spline(*_samples_to_arrays(samples), alpha=alpha)
+ for amplitude, samples in groups.items()
+ }
+ x_grid = np.log10(common_frequencies)
+ fitted = np.asarray(
+ [np.asarray(models[amplitude](x_grid), dtype=float) for amplitude in amplitudes], dtype=float
+ )
+ target_gain_db = _target_gain_db(fitted, common_frequencies, config)
+ calibration_rows: list[dict[str, Any]] = []
+ limit_counts = {"correction_limited": 0, "slope_limited": 0}
+ for amplitude, gains in zip(amplitudes, fitted):
+ raw_correction = target_gain_db - gains
+ correction, correction_limited, slope_limited = _limit_correction(
+ raw_correction,
+ common_frequencies,
+ config,
+ )
+ limit_counts["correction_limited"] += int(np.count_nonzero(correction_limited))
+ limit_counts["slope_limited"] += int(np.count_nonzero(slope_limited))
+ for frequency, gain_db, correction_db, limited, slope_flag in zip(
+ common_frequencies, gains, correction, correction_limited, slope_limited
+ ):
+ calibration_rows.append(
+ {
+ "frequency_hz": float(frequency),
+ "requested_vpp": float(amplitude),
+ "fitted_gain_db": float(gain_db),
+ "correction_db": float(correction_db),
+ "correction_linear": float(10.0 ** (correction_db / 20.0)),
+ "correction_limited": bool(limited),
+ "slope_limited": bool(slope_flag),
+ }
+ )
+ chebyshev = [
+ _chebyshev_document(
+ amplitude,
+ x_grid,
+ models[amplitude],
+ degree=config.chebyshev_degree,
+ segment_count=config.chebyshev_segment_count,
+ )
+ for amplitude in amplitudes
+ ]
+ document = {
+ "schema_version": 1,
+ "source_frequency_response_csv": str(source_csv) if source_csv is not None else None,
+ "model": config.model,
+ "configuration": config.as_dict(),
+ "x_transform": "log10(frequency_hz / Hz)",
+ "interpolation": "smoothing spline in frequency; linear in requested_vpp; no extrapolation",
+ "valid_domain": {
+ "frequency_hz": [float(common_frequencies[0]), float(common_frequencies[-1])],
+ "requested_vpp": [float(amplitudes[0]), float(amplitudes[-1])],
+ },
+ "grid": {
+ "frequency_hz": [float(value) for value in common_frequencies],
+ "requested_vpp": [float(value) for value in amplitudes],
+ },
+ "target_gain_db": float(target_gain_db),
+ "smoothing": {"selected_alpha": alpha, "candidate_holdout_rmse_db": frequency_cv},
+ "validation": {
+ "frequency_holdout_rmse_db": _minimum_metric(frequency_cv),
+ "amplitude_holdout_rmse_db": _amplitude_holdout_rmse(groups),
+ },
+ "limit_counts": limit_counts,
+ "chebyshev": chebyshev,
+ "lut": calibration_rows,
+ }
+ return document, calibration_rows
+
+
+def write_frequency_response_calibration_csv(
+ path: str | Path, rows: Iterable[dict[str, Any]]
+) -> Path:
+ output = Path(path)
+ temporary = output.with_suffix(output.suffix + ".tmp")
+ with temporary.open("w", newline="", encoding="utf-8") as file:
+ writer = csv.DictWriter(file, fieldnames=_CSV_FIELDS)
+ writer.writeheader()
+ writer.writerows(rows)
+ temporary.replace(output)
+ return output
+
+
+def write_frequency_response_calibration_json(path: str | Path, document: dict[str, Any]) -> Path:
+ output = Path(path)
+ temporary = output.with_suffix(output.suffix + ".tmp")
+ temporary.write_text(json.dumps(document, indent=2, ensure_ascii=False), encoding="utf-8")
+ temporary.replace(output)
+ return output
+
+
+def _measurement_groups(rows: Iterable[dict[str, Any]]) -> dict[float, list[tuple[float, float]]]:
+ groups: dict[float, list[tuple[float, float]]] = {}
+ for row in rows:
+ if str(row.get("status", "")).strip().lower() == "failed":
+ continue
+ frequency = _row_float(row, "requested_frequency_hz")
+ amplitude = _row_float(row, "requested_vpp")
+ gain_db = _row_float(row, "gain_db")
+ if gain_db is None:
+ gain = _row_float(row, "gain_linear")
+ gain_db = 20.0 * np.log10(gain) if gain is not None and gain > 0 else None
+ if frequency is None or frequency <= 0 or amplitude is None or amplitude <= 0 or gain_db is None:
+ continue
+ groups.setdefault(amplitude, []).append((frequency, gain_db))
+ normalized: dict[float, list[tuple[float, float]]] = {}
+ for amplitude, samples in groups.items():
+ by_frequency = {frequency: gain_db for frequency, gain_db in samples}
+ normalized[amplitude] = sorted(by_frequency.items())
+ return normalized
+
+
+def _common_frequency_grid(groups: dict[float, list[tuple[float, float]]]) -> np.ndarray:
+ frequency_sets = [{frequency for frequency, _gain in samples} for samples in groups.values()]
+ common = set.intersection(*frequency_sets) if frequency_sets else set()
+ return np.asarray(sorted(common), dtype=float)
+
+
+def _samples_to_arrays(samples: list[tuple[float, float]]) -> tuple[np.ndarray, np.ndarray]:
+ frequencies = np.asarray([frequency for frequency, _gain in samples], dtype=float)
+ gains = np.asarray([gain for _frequency, gain in samples], dtype=float)
+ return np.log10(frequencies), gains
+
+
+def _fit_spline(x: np.ndarray, y_db: np.ndarray, *, alpha: float):
+ from scipy.interpolate import UnivariateSpline
+
+ variance = float(np.var(y_db))
+ smoothing = max(0.0, alpha * x.size * variance)
+ return UnivariateSpline(x, y_db, k=min(3, x.size - 1), s=smoothing)
+
+
+def _select_smoothing_alpha(
+ groups: dict[float, list[tuple[float, float]]]
+) -> tuple[float, dict[str, float | None]]:
+ scores = {str(alpha): _frequency_holdout_rmse(groups, alpha) for alpha in _SMOOTHING_ALPHAS}
+ usable = [(alpha, score) for alpha, score in zip(_SMOOTHING_ALPHAS, scores.values()) if score is not None]
+ if not usable:
+ return 0.1, scores
+ return min(usable, key=lambda item: (item[1], item[0]))[0], scores
+
+
+def _frequency_holdout_rmse(
+ groups: dict[float, list[tuple[float, float]]], alpha: float
+) -> float | None:
+ errors: list[float] = []
+ for samples in groups.values():
+ x, y = _samples_to_arrays(samples)
+ if x.size < 5:
+ continue
+ for fold in range(5):
+ held = np.asarray(
+ [index for index in range(1, x.size - 1) if index % 5 == fold], dtype=int
+ )
+ if not held.size:
+ continue
+ train = np.ones(x.size, dtype=bool)
+ train[held] = False
+ if int(np.count_nonzero(train)) < 4:
+ continue
+ prediction = _fit_spline(x[train], y[train], alpha=alpha)(x[held])
+ errors.extend(float(value) for value in (y[held] - prediction))
+ return float(np.sqrt(np.mean(np.square(errors)))) if errors else None
+
+
+def _target_gain_db(
+ fitted: np.ndarray, frequencies: np.ndarray, config: FrequencyResponseCalibrationConfig
+) -> float:
+ if config.target_mode == "explicit_gain_db":
+ assert config.target_gain_db is not None
+ return config.target_gain_db
+ if config.target_mode == "unity_gain":
+ return 0.0
+ mask = np.ones(frequencies.size, dtype=bool)
+ if config.target_frequency_min_hz is not None:
+ mask &= frequencies >= config.target_frequency_min_hz
+ if config.target_frequency_max_hz is not None:
+ mask &= frequencies <= config.target_frequency_max_hz
+ selected = fitted[:, mask]
+ if not selected.size:
+ raise ConfigError("calibration target frequency range does not contain any valid LUT point")
+ return float(np.median(selected))
+
+
+def _limit_correction(
+ correction: np.ndarray,
+ frequencies: np.ndarray,
+ config: FrequencyResponseCalibrationConfig,
+) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
+ limited = np.clip(correction, config.correction_min_db, config.correction_max_db)
+ correction_limited = ~np.isclose(limited, correction, rtol=0.0, atol=1e-12)
+ slope_limited = np.zeros(limited.size, dtype=bool)
+ for index in range(1, limited.size):
+ delta_octaves = log2(float(frequencies[index] / frequencies[index - 1]))
+ allowed = config.max_slope_db_per_octave * delta_octaves
+ lower = limited[index - 1] - allowed
+ upper = limited[index - 1] + allowed
+ constrained = float(np.clip(limited[index], lower, upper))
+ if not np.isclose(constrained, limited[index], rtol=0.0, atol=1e-12):
+ slope_limited[index] = True
+ limited[index] = constrained
+ return limited, correction_limited, slope_limited
+
+
+def _chebyshev_document(
+ amplitude: float,
+ x: np.ndarray,
+ spline: Any,
+ *,
+ degree: int,
+ segment_count: int,
+) -> dict[str, Any]:
+ count = min(segment_count, x.size - 1)
+ edges = np.linspace(float(x[0]), float(x[-1]), count + 1)
+ segments: list[dict[str, Any]] = []
+ for index, (start, stop) in enumerate(zip(edges[:-1], edges[1:])):
+ sample_count = max(degree + 1, 9)
+ sample_x = np.linspace(start, stop, sample_count)
+ sample_y = np.asarray(spline(sample_x), dtype=float)
+ polynomial = np.polynomial.Chebyshev.fit(
+ sample_x, sample_y, deg=min(degree, sample_count - 1), domain=[start, stop]
+ )
+ segments.append(
+ {
+ "index": index,
+ "x_start": float(start),
+ "x_stop": float(stop),
+ "coefficients": [float(value) for value in polynomial.coef],
+ }
+ )
+ return {
+ "requested_vpp": float(amplitude),
+ "formula": "G_dB = sum(c_k * T_k(t)); t maps x linearly from x_start..x_stop to -1..1",
+ "segments": segments,
+ }
+
+
+def _amplitude_holdout_rmse(groups: dict[float, list[tuple[float, float]]]) -> float | None:
+ amplitudes = sorted(groups)
+ if len(amplitudes) < 3:
+ return None
+ errors: list[float] = []
+ for index in range(1, len(amplitudes) - 1):
+ lower, current, upper = amplitudes[index - 1], amplitudes[index], amplitudes[index + 1]
+ lower_x, lower_y = _samples_to_arrays(groups[lower])
+ current_x, current_y = _samples_to_arrays(groups[current])
+ upper_x, upper_y = _samples_to_arrays(groups[upper])
+ shared = sorted(set(lower_x).intersection(current_x, upper_x))
+ if not shared:
+ continue
+ lower_values = np.interp(shared, lower_x, lower_y)
+ upper_values = np.interp(shared, upper_x, upper_y)
+ current_values = np.interp(shared, current_x, current_y)
+ weight = (current - lower) / (upper - lower)
+ errors.extend(current_values - (lower_values + weight * (upper_values - lower_values)))
+ return float(np.sqrt(np.mean(np.square(errors)))) if errors else None
+
+
+def _minimum_metric(metrics: dict[str, float | None]) -> float | None:
+ values = [value for value in metrics.values() if value is not None]
+ return min(values) if values else None
+
+
+def _row_float(row: dict[str, Any], name: str) -> float | None:
+ try:
+ value = float(row.get(name))
+ except (TypeError, ValueError):
+ return None
+ return value if isfinite(value) else None
+
+
+def _bool(value: Any, name: str) -> bool:
+ if not isinstance(value, bool):
+ raise ConfigError(f"{name} must be true or false")
+ return value
+
+
+def _text(value: Any, name: str) -> str:
+ if not isinstance(value, str) or not value.strip():
+ raise ConfigError(f"{name} must be a non-empty string")
+ return value.strip()
+
+
+def _finite_float(value: Any, name: str) -> float:
+ if isinstance(value, bool):
+ raise ConfigError(f"{name} must be a number")
+ try:
+ result = float(value)
+ except (TypeError, ValueError) as exc:
+ raise ConfigError(f"{name} must be a number") from exc
+ if not isfinite(result):
+ raise ConfigError(f"{name} must be finite")
+ return result
+
+
+def _optional_float(value: Any, name: str) -> float | None:
+ return None if value is None else _finite_float(value, name)
+
+
+def _positive_float(value: Any, name: str) -> float:
+ result = _finite_float(value, name)
+ if result <= 0:
+ raise ConfigError(f"{name} must be > 0")
+ return result
+
+
+def _optional_positive_float(value: Any, name: str) -> float | None:
+ return None if value is None else _positive_float(value, name)
+
+
+def _positive_int(value: Any, name: str) -> int:
+ if isinstance(value, bool):
+ raise ConfigError(f"{name} must be an integer")
+ try:
+ result = int(value)
+ except (TypeError, ValueError) as exc:
+ raise ConfigError(f"{name} must be an integer") from exc
+ if result != value or result <= 0:
+ raise ConfigError(f"{name} must be a positive integer")
+ return result
diff --git a/src/wavebench/services/run_plan.py b/src/wavebench/services/run_plan.py
index e8ade0d..cd9f584 100644
--- a/src/wavebench/services/run_plan.py
+++ b/src/wavebench/services/run_plan.py
@@ -10,6 +10,7 @@
from wavebench.config import normalize_waveform_points
from wavebench.errors import ConfigError
from wavebench.services.frequency_response import FIT_METHODS
+from wavebench.services.frequency_response_calibration import normalize_frequency_response_calibration
ALLOWED_STEP_KINDS = {
@@ -81,6 +82,12 @@
"save_csv",
"screenshot",
"fit",
+ "amplitudes_vpp",
+ "start_vpp",
+ "stop_vpp",
+ "vpp_step",
+ "autoscale_each_amplitude",
+ "calibration",
},
"source.status": {"channel"},
"source.set_freq": {"channel"},
@@ -493,6 +500,8 @@ def _normalize_frequency_response_fields(prefix: str, fields: dict[str, Any]) ->
raise ConfigError(f"{prefix}.frequencies_hz must be strictly increasing and unique")
fields["frequencies_hz"] = frequencies
+ _normalize_frequency_response_amplitudes(prefix, fields)
+
fields["target_cycles"] = _positive_float(
fields.get("target_cycles", 10.0), f"{prefix}.target_cycles"
)
@@ -513,6 +522,48 @@ def _normalize_frequency_response_fields(prefix: str, fields: dict[str, Any]) ->
raise ConfigError(f"{prefix}.{name} must be true or false")
if "fit" in fields:
fields["fit"] = _parse_frequency_response_fit(fields["fit"], f"{prefix}.fit")
+ if "calibration" in fields:
+ fields["calibration"] = normalize_frequency_response_calibration(
+ fields["calibration"], f"{prefix}.calibration"
+ ).as_dict()
+
+
+def _normalize_frequency_response_amplitudes(prefix: str, fields: dict[str, Any]) -> None:
+ explicit = fields.get("amplitudes_vpp")
+ generated_names = {"start_vpp", "stop_vpp", "vpp_step"}
+ has_generated = any(name in fields for name in generated_names)
+ if explicit is not None and has_generated:
+ raise ConfigError(
+ f"{prefix} must use either amplitudes_vpp or start_vpp, stop_vpp, and vpp_step, not both"
+ )
+ amplitudes: list[float] | None = None
+ if explicit is not None:
+ if not isinstance(explicit, list) or not explicit:
+ raise ConfigError(f"{prefix}.amplitudes_vpp must be a non-empty array")
+ amplitudes = [_positive_float(value, f"{prefix}.amplitudes_vpp") for value in explicit]
+ elif has_generated:
+ required = ("start_vpp", "stop_vpp", "vpp_step")
+ missing = [name for name in required if name not in fields]
+ if missing:
+ raise ConfigError(f"{prefix} requires start_vpp, stop_vpp, and vpp_step together")
+ start = _positive_float(fields["start_vpp"], f"{prefix}.start_vpp")
+ stop = _positive_float(fields["stop_vpp"], f"{prefix}.stop_vpp")
+ step = _positive_float(fields["vpp_step"], f"{prefix}.vpp_step")
+ if stop <= start:
+ raise ConfigError(f"{prefix}.stop_vpp must be greater than start_vpp")
+ count = round((stop - start) / step)
+ if count < 1 or abs(start + count * step - stop) > max(1e-12, step * 1e-9):
+ raise ConfigError(f"{prefix}.vpp_step must divide the requested Vpp range exactly")
+ amplitudes = [round(start + index * step, 15) for index in range(count + 1)]
+ if amplitudes is not None:
+ if any(second <= first for first, second in zip(amplitudes, amplitudes[1:])):
+ raise ConfigError(f"{prefix}.amplitudes_vpp must be strictly increasing and unique")
+ fields["amplitudes_vpp"] = amplitudes
+ if amplitudes is not None or "autoscale_each_amplitude" in fields:
+ autoscale = fields.get("autoscale_each_amplitude", True)
+ if not isinstance(autoscale, bool):
+ raise ConfigError(f"{prefix}.autoscale_each_amplitude must be true or false")
+ fields["autoscale_each_amplitude"] = autoscale
def _parse_frequency_response_fit(raw: Any, name: str) -> dict[str, Any]:
diff --git a/src/wavebench/services/run_safety.py b/src/wavebench/services/run_safety.py
index 3ee57bc..c236631 100644
--- a/src/wavebench/services/run_safety.py
+++ b/src/wavebench/services/run_safety.py
@@ -42,6 +42,18 @@ def check_run_plan_safety_limits(plan: RunPlan, limits: SafetyLimitsConfig) -> N
config_key="max_source_vpp",
unit="Vpp",
)
+ elif step.kind == "sweep.frequency_response":
+ for amplitude in step.fields.get("amplitudes_vpp", []):
+ _check_limit(
+ amplitude,
+ limits.max_source_vpp,
+ field=(
+ f"run step {step.index} frequency-response amplitude / "
+ f"运行步骤 {step.index} 频响信号源幅度"
+ ),
+ config_key="max_source_vpp",
+ unit="Vpp",
+ )
elif step.kind == "source.arb_load":
_check_limit(
step.fields["amplitude_vpp"],
diff --git a/src/wavebench/services/run_service.py b/src/wavebench/services/run_service.py
index 957317c..f4976e3 100644
--- a/src/wavebench/services/run_service.py
+++ b/src/wavebench/services/run_service.py
@@ -24,6 +24,12 @@
write_fit_document,
write_frequency_response_csv,
)
+from wavebench.services.frequency_response_calibration import (
+ build_frequency_response_calibration,
+ ensure_calibration_dependencies,
+ write_frequency_response_calibration_csv,
+ write_frequency_response_calibration_json,
+)
from wavebench.services.run_artifacts import RunStepRecord, write_run_files, write_step_record
from wavebench.services.run_analysis import (
capture_consistency,
@@ -160,6 +166,10 @@ def add(kind: str, *capabilities: str) -> None:
elif step.kind == "sweep.frequency_response":
add("scope", "scope.idn", "scope.capture_waveforms")
add("source", "source.status", "source.set_frequency")
+ if step.fields.get("amplitudes_vpp"):
+ add("source", "source.set_amplitude_vpp")
+ if step.fields.get("autoscale_each_amplitude"):
+ add("scope", "scope.autoscale")
source = self.config.source
if self.config.scope.check_errors:
add("scope", "scope.errors")
@@ -168,6 +178,9 @@ def add(kind: str, *capabilities: str) -> None:
if step.fields.get("screenshot", self.config.output.save_screenshot):
add("scope", "scope.screenshot")
ensure_fit_dependencies(step.fields.get("fit"))
+ calibration = step.fields.get("calibration")
+ if calibration and calibration.get("enabled", True):
+ ensure_calibration_dependencies()
elif step.kind == "source.status":
add("source", "source.status")
elif step.kind == "source.set_freq":
@@ -453,6 +466,8 @@ def _run_frequency_response_step(
source = self._source_service(services=services)
csv_path = run_dir / "frequency_response.csv"
fit_path = run_dir / "frequency_response_fit.json"
+ calibration_csv_path = run_dir / "frequency_response_calibration.csv"
+ calibration_json_path = run_dir / "frequency_response_calibration.json"
points = []
reference_channel = step.fields["reference_channel"]
response_channel = step.fields["response_channel"]
@@ -481,88 +496,157 @@ def _run_frequency_response_step(
response_channel=response_channel,
) from exc
- for index, frequency_hz in enumerate(step.fields["frequencies_hz"]):
- try:
- source_status = source.set_frequency(channel=source_channel, value_hz=frequency_hz)
- except Exception as exc:
- points.append(
- failed_frequency_response_point(
- index=index,
- requested_frequency_hz=frequency_hz,
- error=exc,
+ requested_amplitudes = step.fields.get("amplitudes_vpp") or [None]
+ point_index = 0
+ for amplitude_index, requested_vpp in enumerate(requested_amplitudes):
+ if requested_vpp is not None:
+ try:
+ source_status = source.set_amplitude_vpp(
+ channel=source_channel, value_vpp=requested_vpp
)
- )
- write_frequency_response_csv(csv_path, points)
- raise self._frequency_response_execution_error(
- step,
- exc,
- points=points,
- csv_path=csv_path,
- fit_path=None,
- source_channel=source_channel,
- reference_channel=reference_channel,
- response_channel=response_channel,
- ) from exc
- if str(source_status.output).strip().upper() != "ON":
- error = ConfigError(
- f"source output is {source_status.output} after setting {frequency_hz:.12g} Hz"
- )
- points.append(
- failed_frequency_response_point(
- index=index,
- requested_frequency_hz=frequency_hz,
- error=error,
+ except Exception as exc:
+ write_frequency_response_csv(csv_path, points)
+ raise self._frequency_response_execution_error(
+ step,
+ exc,
+ points=points,
+ csv_path=csv_path,
+ fit_path=None,
+ source_channel=source_channel,
+ reference_channel=reference_channel,
+ response_channel=response_channel,
+ ) from exc
+ if str(source_status.output).strip().upper() != "ON":
+ error = ConfigError(
+ f"source output is {source_status.output} after setting {requested_vpp:.12g} Vpp"
)
- )
- write_frequency_response_csv(csv_path, points)
- raise self._frequency_response_execution_error(
+ write_frequency_response_csv(csv_path, points)
+ raise self._frequency_response_execution_error(
+ step,
+ error,
+ points=points,
+ csv_path=csv_path,
+ fit_path=None,
+ source_channel=source_channel,
+ reference_channel=reference_channel,
+ response_channel=response_channel,
+ ) from error
+ for frequency_index, frequency_hz in enumerate(step.fields["frequencies_hz"]):
+ try:
+ source_status = source.set_frequency(channel=source_channel, value_hz=frequency_hz)
+ except Exception as exc:
+ points.append(
+ failed_frequency_response_point(
+ index=point_index,
+ amplitude_index=amplitude_index,
+ requested_vpp=requested_vpp,
+ requested_frequency_hz=frequency_hz,
+ error=exc,
+ )
+ )
+ write_frequency_response_csv(csv_path, points)
+ raise self._frequency_response_execution_error(
+ step,
+ exc,
+ points=points,
+ csv_path=csv_path,
+ fit_path=None,
+ source_channel=source_channel,
+ reference_channel=reference_channel,
+ response_channel=response_channel,
+ ) from exc
+ if str(source_status.output).strip().upper() != "ON":
+ error = ConfigError(
+ f"source output is {source_status.output} after setting {frequency_hz:.12g} Hz"
+ )
+ points.append(
+ failed_frequency_response_point(
+ index=point_index,
+ amplitude_index=amplitude_index,
+ requested_vpp=requested_vpp,
+ requested_frequency_hz=frequency_hz,
+ error=error,
+ )
+ )
+ write_frequency_response_csv(csv_path, points)
+ raise self._frequency_response_execution_error(
+ step,
+ error,
+ points=points,
+ csv_path=csv_path,
+ fit_path=None,
+ source_channel=source_channel,
+ reference_channel=reference_channel,
+ response_channel=response_channel,
+ ) from error
+ if step.fields["settle_s"]:
+ time.sleep(step.fields["settle_s"])
+
+ scope = self._scope_service_for_frequency_response(
step,
- error,
- points=points,
- csv_path=csv_path,
- fit_path=None,
- source_channel=source_channel,
- reference_channel=reference_channel,
- response_channel=response_channel,
- ) from error
- if step.fields["settle_s"]:
- time.sleep(step.fields["settle_s"])
-
- scope = self._scope_service_for_frequency_response(
- step,
- frequency_hz=frequency_hz,
- services=services,
- )
- try:
- capture = scope.capture_waveforms(
- channels=[reference_channel, response_channel],
- label=f"{label}_{index:03d}_{frequency_hz:.12g}hz",
+ frequency_hz=frequency_hz,
+ services=services,
)
- points.append(
- analyze_frequency_response_point(
- index=index,
- requested_frequency_hz=frequency_hz,
- reference_waveform=capture.waveforms[reference_channel],
- response_waveform=capture.waveforms[response_channel],
- frequency_tolerance_ratio=tolerance,
- capture_package=str(capture.package_dir),
- metadata_path=str(capture.metadata_path),
+ if frequency_index == 0 and step.fields.get("autoscale_each_amplitude"):
+ scope.autoscale()
+ if step.fields["settle_s"]:
+ time.sleep(step.fields["settle_s"])
+ try:
+ amplitude_label = (
+ f"{label}_{point_index:03d}_{frequency_hz:.12g}hz"
+ if requested_vpp is None
+ else f"{label}_a{amplitude_index:02d}_{requested_vpp:.12g}vpp_"
+ f"{frequency_index:03d}_{frequency_hz:.12g}hz"
)
- )
- except Exception as exc: # noqa: BLE001 - retain failed points and continue the sweep
- points.append(
- failed_frequency_response_point(
- index=index,
- requested_frequency_hz=frequency_hz,
- error=exc,
+ capture = scope.capture_waveforms(
+ channels=[reference_channel, response_channel],
+ label=amplitude_label,
)
- )
- write_frequency_response_csv(csv_path, points)
+ points.append(
+ analyze_frequency_response_point(
+ index=point_index,
+ amplitude_index=amplitude_index,
+ requested_vpp=requested_vpp,
+ requested_frequency_hz=frequency_hz,
+ reference_waveform=capture.waveforms[reference_channel],
+ response_waveform=capture.waveforms[response_channel],
+ frequency_tolerance_ratio=tolerance,
+ capture_package=str(capture.package_dir),
+ metadata_path=str(capture.metadata_path),
+ )
+ )
+ except Exception as exc: # noqa: BLE001 - retain failed points and continue the sweep
+ points.append(
+ failed_frequency_response_point(
+ index=point_index,
+ amplitude_index=amplitude_index,
+ requested_vpp=requested_vpp,
+ requested_frequency_hz=frequency_hz,
+ error=exc,
+ )
+ )
+ point_index += 1
+ write_frequency_response_csv(csv_path, points)
points = unwrap_frequency_response_phase(points)
fit_document, fit_values = build_fit_document(points, step.fields.get("fit"))
write_frequency_response_csv(csv_path, points, fit_values)
written_fit_path = write_fit_document(fit_path, fit_document)
+ calibration = step.fields.get("calibration")
+ written_calibration_csv: Path | None = None
+ written_calibration_json: Path | None = None
+ calibration_error: str | None = None
+ if calibration and calibration.get("enabled", True):
+ try:
+ document, rows = build_frequency_response_calibration(
+ [point.as_csv_row() for point in points], calibration, source_csv=csv_path
+ )
+ written_calibration_csv = write_frequency_response_calibration_csv(calibration_csv_path, rows)
+ written_calibration_json = write_frequency_response_calibration_json(
+ calibration_json_path, document
+ )
+ except Exception as exc: # noqa: BLE001 - retain a valid measurement when derived calibration fails
+ calibration_error = f"{type(exc).__name__}: {exc}"
return self._frequency_response_artifact(
points=points,
csv_path=csv_path,
@@ -570,6 +654,9 @@ def _run_frequency_response_step(
source_channel=source_channel,
reference_channel=reference_channel,
response_channel=response_channel,
+ calibration_csv_path=written_calibration_csv,
+ calibration_json_path=written_calibration_json,
+ calibration_error=calibration_error,
)
def _frequency_response_execution_error(
@@ -583,6 +670,8 @@ def _frequency_response_execution_error(
source_channel: int | None,
reference_channel: int,
response_channel: int,
+ calibration_csv_path: Path | None = None,
+ calibration_json_path: Path | None = None,
) -> _FrequencyResponseExecutionError:
artifact = self._frequency_response_artifact(
points=points,
@@ -591,6 +680,8 @@ def _frequency_response_execution_error(
source_channel=source_channel,
reference_channel=reference_channel,
response_channel=response_channel,
+ calibration_csv_path=calibration_csv_path,
+ calibration_json_path=calibration_json_path,
error=cause,
)
record = RunStepRecord(
@@ -611,6 +702,9 @@ def _frequency_response_artifact(
source_channel: int | None,
reference_channel: int,
response_channel: int,
+ calibration_csv_path: Path | None = None,
+ calibration_json_path: Path | None = None,
+ calibration_error: str | None = None,
error: Exception | None = None,
) -> dict[str, Any]:
failed_points = sum(point.status == "failed" for point in points)
@@ -625,9 +719,13 @@ def _frequency_response_artifact(
if point.capture_package
]
response: dict[str, Any] = {
- "status": "failed" if error is not None or failed_points else ("warning" if warning_points else "ok"),
+ "status": "failed"
+ if error is not None or failed_points
+ else ("warning" if warning_points or calibration_error else "ok"),
"csv": str(csv_path),
"fit_json": str(fit_path) if fit_path is not None else "",
+ "calibration_csv": str(calibration_csv_path) if calibration_csv_path is not None else "",
+ "calibration_json": str(calibration_json_path) if calibration_json_path is not None else "",
"point_count": len(points),
"failed_point_count": failed_points,
"warning_point_count": warning_points,
@@ -638,6 +736,8 @@ def _frequency_response_artifact(
}
if error is not None:
response["error"] = f"{type(error).__name__}: {error}"
+ if calibration_error is not None:
+ response["calibration_error"] = calibration_error
return {"frequency_response": response}
def _run_scope_capture_step(
diff --git a/src/wavebench/services/run_templates.py b/src/wavebench/services/run_templates.py
index 21ecaa6..f8bd07b 100644
--- a/src/wavebench/services/run_templates.py
+++ b/src/wavebench/services/run_templates.py
@@ -324,7 +324,7 @@ def _render_source_scope_frequency_response(options: RunTemplateOptions) -> str:
fit = """
[steps.fit]
- methods = ["linear_log", "polynomial", "pchip"]
+ methods = ["linear_log", "polynomial", "pchip", "smoothing_spline_db", "piecewise_chebyshev_db"]
polynomial_degree = {polynomial_degree}
""".format(polynomial_degree=polynomial_degree)
frequency_text = ", ".join(_fmt(value) for value in frequencies)
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 7383d5e..a781969 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -44,6 +44,30 @@ def make_cli_plugin(driver_id="example.scope"):
class CliTests(unittest.TestCase):
+ def test_run_calibrate_builds_offline_artifacts_without_loading_instruments(self):
+ with TemporaryDirectory() as tmp:
+ run_dir = Path(tmp) / "run"
+ run_dir.mkdir()
+ (run_dir / "run.json").write_text(json.dumps({"status": "ok", "steps": []}), encoding="utf-8")
+ response_rows = ["index,requested_frequency_hz,requested_vpp,gain_db,status"]
+ index = 0
+ for amplitude in (0.05, 0.1):
+ for frequency in (100, 1000, 10000, 100000):
+ response_rows.append(f"{index},{frequency},{amplitude},{6 - index * 0.05},ok")
+ index += 1
+ (run_dir / "frequency_response.csv").write_text("\n".join(response_rows), encoding="utf-8")
+ calibration = Path(tmp) / "calibration.toml"
+ calibration.write_text("[calibration]\ntarget_mode = 'unity_gain'\n", encoding="utf-8")
+ stdout = io.StringIO()
+ with patch("wavebench.cli._load_run_service") as load_service, redirect_stdout(stdout):
+ code = main(["run", "calibrate", str(run_dir), "--config", str(calibration)])
+
+ self.assertEqual(code, 0)
+ load_service.assert_not_called()
+ self.assertTrue((run_dir / "frequency_response_calibration.csv").exists())
+ self.assertTrue((run_dir / "frequency_response_calibration.json").exists())
+ self.assertIn("calibration_csv=", stdout.getvalue())
+
def test_capture_accepts_points_and_output_flags(self):
args = build_parser().parse_args([
"scope", "capture", "--points", "def", "--time-range", "0.01", "--window-frequency", "500", "--target-cycles", "10", "--expect-frequency", "500", "--frequency-tolerance", "0.1", "--no-csv", "--label", "x"
diff --git a/tests/test_frequency_response.py b/tests/test_frequency_response.py
index e489a33..7e23c76 100644
--- a/tests/test_frequency_response.py
+++ b/tests/test_frequency_response.py
@@ -232,6 +232,24 @@ def test_fit_formulas_export_directly_usable_piecewise_parameters(self):
self.assertEqual(len(pchip_segment["coefficients"]), 4)
self.assertEqual(pchip_segment["x_start"], 1.0)
+ @unittest.skipUnless(HAS_SCIPY, "SciPy analysis dependency is unavailable")
+ def test_db_smoothing_and_chebyshev_fit_export_deployable_metadata(self):
+ points = [
+ _point(index, 10.0 ** (index + 1), 1.0 + 0.2 * index)
+ for index in range(5)
+ ]
+
+ document, values = build_fit_document(
+ points,
+ {"methods": ["smoothing_spline_db", "piecewise_chebyshev_db"]},
+ )
+
+ assert document is not None
+ self.assertEqual(document["methods"]["smoothing_spline_db"]["status"], "ok")
+ self.assertEqual(document["methods"]["piecewise_chebyshev_db"]["status"], "ok")
+ self.assertTrue(document["methods"]["piecewise_chebyshev_db"]["parameters"]["segments"])
+ self.assertIsNotNone(values["smoothing_spline_db"][0][0])
+
def test_invalid_waveform_time_axis_becomes_an_auditable_failed_point(self):
frequency_hz = 1_000.0
reference = _waveform(
diff --git a/tests/test_frequency_response_calibration.py b/tests/test_frequency_response_calibration.py
new file mode 100644
index 0000000..0ff81a3
--- /dev/null
+++ b/tests/test_frequency_response_calibration.py
@@ -0,0 +1,71 @@
+from __future__ import annotations
+
+import unittest
+
+import numpy as np
+
+from wavebench.errors import ConfigError
+from wavebench.services.frequency_response_calibration import (
+ FrequencyResponseCalibrationConfig,
+ build_frequency_response_calibration,
+ normalize_frequency_response_calibration,
+)
+
+
+def _rows(*, amplitudes=(0.05, 0.1, 0.2), frequencies=(1e3, 2e3, 4e3, 8e3, 16e3)):
+ rows = []
+ for amplitude in amplitudes:
+ for frequency in frequencies:
+ gain_db = 6.0 - 2.0 * np.log10(frequency / 1e3) + 5.0 * amplitude
+ rows.append(
+ {
+ "status": "ok",
+ "requested_frequency_hz": frequency,
+ "requested_vpp": amplitude,
+ "gain_db": gain_db,
+ }
+ )
+ return rows
+
+
+class FrequencyResponseCalibrationTests(unittest.TestCase):
+ def test_builds_two_dimensional_lut_and_chebyshev_formulas(self):
+ document, lut = build_frequency_response_calibration(
+ _rows(), FrequencyResponseCalibrationConfig(max_slope_db_per_octave=20.0)
+ )
+
+ self.assertEqual(document["schema_version"], 1)
+ self.assertEqual(document["grid"]["requested_vpp"], [0.05, 0.1, 0.2])
+ self.assertEqual(len(lut), 15)
+ self.assertEqual(len(document["chebyshev"]), 3)
+ self.assertAlmostEqual(document["validation"]["amplitude_holdout_rmse_db"] or 0.0, 0.0)
+ self.assertTrue(all(row["correction_linear"] > 0 for row in lut))
+
+ def test_unity_target_and_limit_flags_are_auditable(self):
+ document, lut = build_frequency_response_calibration(
+ _rows(),
+ FrequencyResponseCalibrationConfig(
+ target_mode="unity_gain",
+ correction_min_db=-12.0,
+ correction_max_db=12.0,
+ max_slope_db_per_octave=0.01,
+ ),
+ )
+
+ self.assertEqual(document["target_gain_db"], 0.0)
+ self.assertGreater(document["limit_counts"]["slope_limited"], 0)
+ self.assertTrue(any(row["slope_limited"] for row in lut))
+
+ def test_requires_two_requested_vpp_slices(self):
+ with self.assertRaisesRegex(ConfigError, "at least two"):
+ build_frequency_response_calibration(
+ _rows(amplitudes=(0.1,)), FrequencyResponseCalibrationConfig()
+ )
+
+ def test_normalizes_explicit_target_and_rejects_incomplete_target(self):
+ config = normalize_frequency_response_calibration(
+ {"target_mode": "explicit_gain_db", "target_gain_db": -1.5}
+ )
+ self.assertEqual(config.target_gain_db, -1.5)
+ with self.assertRaisesRegex(ConfigError, "target_gain_db is required"):
+ normalize_frequency_response_calibration({"target_mode": "explicit_gain_db"})
diff --git a/tests/test_packages.py b/tests/test_packages.py
index d63d0dc..1d3f422 100644
--- a/tests/test_packages.py
+++ b/tests/test_packages.py
@@ -107,6 +107,23 @@ def test_load_run_package_reads_frequency_response_and_tolerates_bad_fit_json(se
self.assertIsNone(loaded.frequency_response_fit)
self.assertIn("not valid JSON", loaded.frequency_response_fit_error or "")
+ def test_load_run_package_reads_frequency_response_calibration_artifacts(self):
+ with TemporaryDirectory() as tmp:
+ run = Path(tmp)
+ (run / "run.json").write_text(json.dumps({"status": "ok", "steps": []}), encoding="utf-8")
+ (run / "frequency_response_calibration.csv").write_text(
+ "frequency_hz,requested_vpp,correction_db\n100,0.1,-1\n",
+ encoding="utf-8",
+ )
+ (run / "frequency_response_calibration.json").write_text(
+ json.dumps({"schema_version": 1, "target_gain_db": 0}), encoding="utf-8"
+ )
+
+ loaded = load_run_package(run)
+
+ self.assertEqual(loaded.frequency_response_calibration_rows[0]["requested_vpp"], "0.1")
+ self.assertEqual(loaded.frequency_response_calibration["target_gain_db"], 0)
+
if __name__ == "__main__":
unittest.main()
diff --git a/tests/test_report.py b/tests/test_report.py
index 6fa108c..a88e202 100644
--- a/tests/test_report.py
+++ b/tests/test_report.py
@@ -848,6 +848,41 @@ def test_run_report_discovers_frequency_response_capture_screenshots_from_csv(se
self.assertEqual(len(manifest["capture_packages"]), 2)
self.assertEqual(len(manifest["screenshots"]), 2)
+ def test_run_report_renders_two_dimensional_calibration_summary_and_charts(self):
+ with TemporaryDirectory() as tmp:
+ run_dir = Path(tmp) / "run"
+ run_dir.mkdir()
+ (run_dir / "run.json").write_text(json.dumps({"status": "ok", "steps": []}), encoding="utf-8")
+ (run_dir / "frequency_response.csv").write_text(
+ "index,requested_frequency_hz,gain_db,status\n0,100,1,ok\n",
+ encoding="utf-8",
+ )
+ calibration_rows = ["frequency_hz,requested_vpp,fitted_gain_db,correction_db,correction_linear,correction_limited,slope_limited"]
+ for amplitude in (0.05, 0.1):
+ for frequency in (100, 1000, 10000, 100000):
+ calibration_rows.append(f"{frequency},{amplitude},1,-1,0.891,false,false")
+ (run_dir / "frequency_response_calibration.csv").write_text(
+ "\n".join(calibration_rows), encoding="utf-8"
+ )
+ (run_dir / "frequency_response_calibration.json").write_text(
+ json.dumps(
+ {
+ "configuration": {"target_mode": "unity_gain"},
+ "valid_domain": {"frequency_hz": [100, 100000], "requested_vpp": [0.05, 0.1]},
+ "target_gain_db": 0,
+ "validation": {"frequency_holdout_rmse_db": 0.1, "amplitude_holdout_rmse_db": None},
+ }
+ ),
+ encoding="utf-8",
+ )
+
+ html = render_run_report_html(load_run_package(run_dir), output_dir=run_dir)
+
+ self.assertIn("二维校准 / 2D calibration", html)
+ self.assertIn("Correction heatmap", html)
+ self.assertIn("Representative slices", html)
+ self.assertIn("frequency_response_calibration.csv", html)
+
def test_pdf_report_uses_output_directory_as_resource_base(self):
with TemporaryDirectory() as tmp:
run_dir = Path(tmp) / "run"
diff --git a/tests/test_run_plan.py b/tests/test_run_plan.py
index 05c250f..a301a26 100644
--- a/tests/test_run_plan.py
+++ b/tests/test_run_plan.py
@@ -330,6 +330,43 @@ def test_frequency_response_plan_normalizes_log_frequency_points_and_fit(self):
{"methods": ["linear_log", "polynomial"], "polynomial_degree": 2},
)
+ def test_frequency_response_plan_generates_vpp_slices_and_calibration(self):
+ plan = load_run_plan(self._write_plan("""
+[[steps]]
+kind = "sweep.frequency_response"
+source_channel = 1
+reference_channel = 1
+response_channel = 2
+frequencies_hz = [100, 1000, 10000, 100000]
+start_vpp = 0.05
+stop_vpp = 0.15
+vpp_step = 0.05
+
+[steps.calibration]
+target_mode = "explicit_gain_db"
+target_gain_db = -1.0
+"""))
+
+ fields = plan.steps[0].fields
+ self.assertEqual(fields["amplitudes_vpp"], [0.05, 0.1, 0.15])
+ self.assertTrue(fields["autoscale_each_amplitude"])
+ self.assertEqual(fields["calibration"]["target_gain_db"], -1.0)
+
+ def test_frequency_response_plan_rejects_mixed_vpp_forms(self):
+ path = self._write_plan("""
+[[steps]]
+kind = "sweep.frequency_response"
+reference_channel = 1
+response_channel = 2
+frequencies_hz = [100, 1000]
+amplitudes_vpp = [0.05, 0.1]
+start_vpp = 0.05
+stop_vpp = 0.1
+vpp_step = 0.05
+""")
+ with self.assertRaisesRegex(ConfigError, "either amplitudes_vpp"):
+ load_run_plan(path)
+
def test_frequency_response_plan_rejects_conflicting_channels_and_frequencies(self):
same_channel = self._write_plan("""
[[steps]]
diff --git a/tests/test_run_service.py b/tests/test_run_service.py
index 80314c9..10b3b1a 100644
--- a/tests/test_run_service.py
+++ b/tests/test_run_service.py
@@ -1231,6 +1231,68 @@ def test_frequency_response_captures_two_channels_and_persists_evidence(self):
)
)
+ def test_frequency_response_multiple_vpp_slices_autoscale_and_write_calibration(self):
+ with TemporaryDirectory() as tmp:
+ plan = load_run_plan(write_plan(tmp, """
+[[steps]]
+kind = "sweep.frequency_response"
+source_channel = 1
+reference_channel = 1
+response_channel = 2
+frequencies_hz = [100, 1000, 5000, 10000]
+amplitudes_vpp = [0.05, 0.1]
+settle_s = 0
+
+[steps.calibration]
+target_mode = "unity_gain"
+max_slope_db_per_octave = 20
+"""))
+ config = make_config(tmp)
+ status = SimpleNamespace(output="ON")
+ captures = [
+ fake_frequency_response_capture(tmp, f"response_{index}", frequency_hz=frequency)
+ for index, frequency in enumerate([100, 1000, 5000, 10000] * 2)
+ ]
+ with patch("wavebench.services.run_service.ScopeService") as scope_cls, patch(
+ "wavebench.services.run_service.SourceService"
+ ) as source_cls:
+ source = source_cls.return_value
+ source.status.return_value = status
+ source.set_amplitude_vpp.return_value = status
+ source.set_frequency.return_value = status
+ scope = scope_cls.return_value
+ scope.capture_waveforms.side_effect = captures
+
+ result = RunService(config=config, logger=CommandLogger()).run(plan)
+
+ self.assertEqual(
+ source.set_amplitude_vpp.call_args_list,
+ [call(channel=1, value_vpp=0.05), call(channel=1, value_vpp=0.1)],
+ )
+ self.assertEqual(scope.autoscale.call_count, 2)
+ rows = list(csv.DictReader((result.run_dir / "frequency_response.csv").open(encoding="utf-8")))
+ self.assertEqual(len(rows), 8)
+ self.assertEqual([row["requested_vpp"] for row in rows[:4]], ["0.05"] * 4)
+ self.assertEqual([row["amplitude_index"] for row in rows[4:]], ["1"] * 4)
+ self.assertTrue((result.run_dir / "frequency_response_calibration.csv").exists())
+ self.assertTrue((result.run_dir / "frequency_response_calibration.json").exists())
+ response = result.steps[0].artifact["frequency_response"]
+ self.assertTrue(response["calibration_csv"])
+
+ def test_frequency_response_vpp_slices_respect_source_safety_limit(self):
+ with TemporaryDirectory() as tmp:
+ plan = load_run_plan(write_plan(tmp, """
+[[steps]]
+kind = "sweep.frequency_response"
+reference_channel = 1
+response_channel = 2
+frequencies_hz = [100, 1000]
+amplitudes_vpp = [0.05, 0.1]
+"""))
+ limits = SafetyLimitsConfig(max_source_vpp=0.075)
+ with self.assertRaisesRegex(ConfigError, "max_source_vpp"):
+ RunService(config=make_config(tmp, safety_limits=limits), logger=CommandLogger()).check(plan)
+
def test_frequency_response_refuses_to_set_frequency_when_source_is_off(self):
with TemporaryDirectory() as tmp:
plan = load_run_plan(write_plan(tmp, """
diff --git a/tests/test_run_templates.py b/tests/test_run_templates.py
index e89adcd..37b0186 100644
--- a/tests/test_run_templates.py
+++ b/tests/test_run_templates.py
@@ -123,7 +123,13 @@ def test_frequency_response_template_uses_two_scope_channels_and_compatible_fit_
assert response.fields["response_channel"] == 3
assert response.fields["frequencies_hz"] == [100.0, 1000.0, 10000.0]
assert response.fields["fit"] == {
- "methods": ["linear_log", "polynomial", "pchip"],
+ "methods": [
+ "linear_log",
+ "polynomial",
+ "pchip",
+ "smoothing_spline_db",
+ "piecewise_chebyshev_db",
+ ],
"polynomial_degree": 2,
}
From 6fc416d1cc86cf8c66a42443717068224f8374ec Mon Sep 17 00:00:00 2001
From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com>
Date: Tue, 4 Aug 2026 23:24:39 +0800
Subject: [PATCH 5/9] feat: extend advanced frequency response sweeps
---
README.md | 37 +-
doc/README.md | 2 +-
doc/README_EN.md | 6 +-
...77\347\224\250\346\214\207\345\215\227.md" | 62 ++-
...23\345\207\272\346\240\274\345\274\217.md" | 30 +-
plans/passive_filter_2d_calibrated.toml | 71 +++
plans/passive_filter_adaptive_5mhz.toml | 51 ++
plans/passive_filter_dense_2d_calibrated.toml | 67 +++
plans/through_baseline_2d_10k_500k.toml | 45 ++
plans/through_baseline_2d_stable.toml | 45 ++
plans/through_diagnostic_100mvpp.toml | 44 ++
src/wavebench/cli.py | 17 +-
src/wavebench/cli_parser.py | 5 +
src/wavebench/data/packages.py | 252 ++++++++--
src/wavebench/report/html.py | 191 +++++--
src/wavebench/services/frequency_response.py | 116 ++++-
.../services/frequency_response_adaptive.py | 156 ++++++
.../services/frequency_response_baseline.py | 204 ++++++++
.../frequency_response_calibration.py | 216 +++++++-
src/wavebench/services/run_plan.py | 24 +-
src/wavebench/services/run_service.py | 474 +++++++++++++-----
tests/test_cli.py | 30 ++
tests/test_frequency_response_adaptive.py | 63 +++
tests/test_frequency_response_baseline.py | 98 ++++
tests/test_frequency_response_calibration.py | 48 ++
tests/test_packages.py | 30 ++
tests/test_report.py | 37 ++
tests/test_run_plan.py | 44 +-
tests/test_run_service.py | 125 +++++
29 files changed, 2329 insertions(+), 261 deletions(-)
create mode 100644 plans/passive_filter_2d_calibrated.toml
create mode 100644 plans/passive_filter_adaptive_5mhz.toml
create mode 100644 plans/passive_filter_dense_2d_calibrated.toml
create mode 100644 plans/through_baseline_2d_10k_500k.toml
create mode 100644 plans/through_baseline_2d_stable.toml
create mode 100644 plans/through_diagnostic_100mvpp.toml
create mode 100644 src/wavebench/services/frequency_response_adaptive.py
create mode 100644 src/wavebench/services/frequency_response_baseline.py
create mode 100644 tests/test_frequency_response_adaptive.py
create mode 100644 tests/test_frequency_response_baseline.py
diff --git a/README.md b/README.md
index 1c4e6b4..a07e980 100644
--- a/README.md
+++ b/README.md
@@ -108,9 +108,9 @@ WaveBench 主包长期预装 RTM2000/RTM2032、DS1104Z/DS1000Z、DG4000/DG4202
- `run check --plan `:只解析并汇总 plan,不连接仪器
- `run verify --plan `:只读查询 plan 涉及仪器的高阻保护状态与 `*IDN?`,用于执行前预检可达性
- `run template --list` / `run template --output `:列出或生成保守 run plan 模板;可用 `--frequency`、`--frequencies`、`--reference-channel`、`--response-channel`、`--fit` 等少量参数定制;不连接仪器,不覆盖已有文件,除非显式 `--force`
-- `run plan --plan `:执行显式 source、power、scope、dmm、sleep 和双通道 `sweep.frequency_response` 步骤;频响可扫描一个或多个请求 Vpp 切片,并可自动导出二维校准 LUT;一次 run 内统一打开并复用所需仪器 session,成功或失败后统一关闭,不静默断线重连
-- `run calibrate --config `:完全离线地从既有二维频响 CSV 重建校准 LUT,不连接仪器、不改写原始测量 CSV
-- `run report `:根据 `run.json` / `summary.csv` 生成静态离线 HTML 报告;频响 run 额外包含幅频、相频、拟合对比、逐点 CSV 与采集证据链接;二维校准会增加补偿热图与代表性切片。加 `--pdf` 可同时导出嵌入截图、SVG 和表格的便携 PDF
+- `run plan --plan `:执行显式 source、power、scope、dmm、sleep 和双通道 `sweep.frequency_response` 步骤;一个 run 可有多个具唯一 label 的频响,每个响应可扫描二维 Vpp × 频率、软件直通基线、可选自适应频率加密,并导出浮点/定点校准 LUT;一次 run 内统一打开并复用所需仪器 session,成功或失败后统一关闭,不静默断线重连
+- `run calibrate --config [--response ]`:完全离线地从既有二维频响 CSV 重建校准 LUT,不连接仪器、不改写原始测量 CSV;多响应 run 必须指定 `--response`
+- `run report `:根据 `run.json` / `summary.csv` 生成静态离线 HTML 报告;频响 run 额外包含原始/软件校正的幅频与相频、拟合对比、逐点 CSV 与采集证据链接;二维校准会增加补偿热图与代表性切片。加 `--pdf` 可同时导出嵌入截图、SVG 和表格的便携 PDF
- `capture inspect `:打印离线采集包摘要
- 默认示波器高阻保护:`scope.capture` / `scope.fetch` / `sweep discrete` / run-plan `scope.capture` / `sweep.frequency_response` 在采集前查询通道耦合。频响会同时保护 reference 与 response 两路;RTM2032 的 `DCL`/`ACL` 视为高阻,`DC`/`AC` 默认按可能的 50 Ω 拒绝;DS1000Z 输入固定为 1 MΩ,`AC`/`DC`/`GND` 只表示耦合方式,均按该机型语义检查。WaveBench 不会自动修改耦合或输入设置
- 可选 `[restore] source_state = true`:在 `finally` 路径快照并恢复 basic 信号源通道状态(输出、函数、频率、Vpp、方波占空比)。该选项不恢复 offset、phase、frequency mode、sweep、负载、极性、噪声、同步、burst、调制、marker、pulse hold 或易失任意波内存;run artifact 以 `source_state_scope = "basic"` 明示范围
@@ -550,7 +550,36 @@ chebyshev_degree = 3
chebyshev_segment_count = 8
```
-校准输出为 `frequency_response_calibration.csv` 和 `frequency_response_calibration.json`;原始 `frequency_response.csv` 不会被改写。HTML/PDF 会展示校正热图和代表性幅值切片,完整 LUT 与公式保留在 run 目录。PDF 是“可见报告”的单文件封装:截图、静态 SVG 曲线和表格会嵌入 PDF;CSV、JSON、NPY 波形和完整采集包仍保留在 run 目录,适合复算和审计。
+校准输出为 `frequency_response_calibration.csv` 和 `frequency_response_calibration.json`;原始 `frequency_response.csv` 不会被改写。默认还会生成有符号二补码 `Q4.12` 的审计 CSV、Xilinx `.coe` 和逐字 `.mem`(幅值主序:`amplitude_index * frequency_count + frequency_index`);可在 `[steps.calibration.fixed_point]` 或离线 `[calibration.fixed_point]` 调整字宽、小数位、布局、格式和溢出策略。HTML/PDF 会展示校正热图和代表性幅值切片,完整 LUT 与公式保留在 run 目录。PDF 是“可见报告”的单文件封装:截图、静态 SVG 曲线和表格会嵌入 PDF;CSV、JSON、NPY 波形和完整采集包仍保留在 run 目录,适合复算和审计。
+
+### 直通基线、自适应加密与多响应
+
+直通基线必须是一个先前完成的独立频响 run:操作者先把示波器 CH1/CH2 手动直通接线、确认高阻与安全范围,再按普通双通道频响采集。DUT step 用 `[steps.baseline]` 引用该 run;默认 `complex_transfer` 在 `log10(frequency)` 域插值并同时扣除基线增益和展开相位,另有 `phase_only` 与 `delay_only`。定义域外绝不外推,软件校正不写入示波器 deskew 或前面板设置。
+
+```toml
+[[steps]]
+kind = "sweep.frequency_response"
+label = "dut_path"
+reference_channel = 1
+response_channel = 2
+start_frequency_hz = 10000
+stop_frequency_hz = 500000
+frequency_count = 41
+spacing = "log"
+
+[steps.baseline]
+run_dir = "../runs/through_baseline"
+mode = "complex_transfer" # complex_transfer / phase_only / delay_only
+
+[steps.adaptive]
+enabled = true
+gain_threshold_db = 0.5
+phase_threshold_deg = 10
+max_levels = 2
+max_frequency_points = 1000
+```
+
+自适应默认关闭以保持旧 plan 行为;开启后先采集初始网格,任一 Vpp 切片相邻点的增益变化达到 0.5 dB 或展开相位变化达到 10° 时,在该区间加入中点(log 为几何中点、linear 为算术中点),并对所有 Vpp 切片采集,保持二维矩形网格。它不能发现“两个端点恰好相同、但中间存在未采样窄带异常”的特征,初始网格仍须覆盖关注频段。多 response 会写入 `frequency_responses.json`;各响应在自己的子目录中保存产物,HTML/PDF 逐段展示。
DMM ACV smoke 示例:
diff --git a/doc/README.md b/doc/README.md
index a2c2cf8..b83b137 100644
--- a/doc/README.md
+++ b/doc/README.md
@@ -19,7 +19,7 @@ WaveBench 是面向电赛调试场景的轻量 Python 自动测量台。当前
可靠地远程读取示波器波形、显式控制信号源和电源,并保存 CSV / NPY / metadata / commands.log。
```
-当前已经支持单次/多通道采集、失败采集包、数据质量摘要、采集窗口控制、DG4202 离散扫频与占空比控制、DP800 电源显式控制、DMM 读数,以及多仪器 run plan 执行。双通道 `sweep.frequency_response` 可测量幅频、相频和传统一维拟合,也可按请求 Vpp 形成二维扫频;完成后可自动生成供数字后端使用的浮点校准 LUT、补偿限制审计和分段 Chebyshev 公式,或用 `run calibrate` 完全离线地从既有 run 重算。`wavebench doctor` 可只读检查配置中的仪器资源、IDN 和型号匹配,也可用 `--discover-subnet` 在配置资源失效时按 IDN 匹配候选替代 resource,便于现场排查网络和配置问题。`run template` 可生成保守的 run plan 模板,并支持少量频率、频点列表、幅度、通道、电压参数,减少手写 TOML 的低级错误。run plan 的 `scope.capture` 可选择质量检查,并在质量警告时按 `[quality].auto_recover_attempts` 触发多次显式 autoscale 重采;若多次采集指标稳定,可标记为 `ok_by_consistency`。`[steps.expect]` 可对采集指标设置 min/max 断言,`[steps.expect_fft]` 可直接对 FFT 主频、主峰幅度、THD、谐波幅度做断言。断言失败会把实验标记为 failed。可选的实验性 TUI 已覆盖 DP800 电源、DM3000/DM3058 万用表和 DG4202 信号源的常用查看/控制操作,并冻结在这三个面板;CLI、run plan 和 Service 仍是核心能力。HTTP MCP 只读 MVP 已提供 `/health`、`/mcp`、`/tools`、`/call`,其中 `/mcp` 支持 MCP JSON-RPC 的 `initialize`、`tools/list`、`tools/call`,工具为 `run.schema`、`run.check`、`capture.inspect` 三个只读工具。v0.2 已开始加入离线包读取和静态 `run report`;报告现在会输出 `验收摘要 / Acceptance summary`、`预期 vs 实测 / Expected vs measured`,并汇总频率、Vpp、均值、duty、截图与 FFT 验收信息;对多点 sweep run 还会生成扫频摘要表,便于快速查看各频点质量、主峰与 THD。这些报告命令只读已有文件,不连接仪器。
+当前已经支持单次/多通道采集、失败采集包、数据质量摘要、采集窗口控制、DG4202 离散扫频与占空比控制、DP800 电源显式控制、DMM 读数,以及多仪器 run plan 执行。双通道 `sweep.frequency_response` 可测量幅频、相频和传统一维拟合,也可按请求 Vpp 形成二维扫频;一个 run 可记录多个独立 response。它支持引用操作者手动直通采集的独立基线 run,在软件中做完整复传递函数、仅相位或仅延迟校正(不改示波器 deskew);可选自适应频率加密仍保持每个 Vpp 的矩形网格。二维校准会自动生成浮点 LUT、补偿限制审计、分段 Chebyshev 公式,以及默认 signed `Q4.12` 的 audit CSV/Xilinx COE/MEM;`run calibrate --response ` 可完全离线重算。`wavebench doctor` 可只读检查配置中的仪器资源、IDN 和型号匹配,也可用 `--discover-subnet` 在配置资源失效时按 IDN 匹配候选替代 resource,便于现场排查网络和配置问题。`run template` 可生成保守的 run plan 模板,并支持少量频率、频点列表、幅度、通道、电压参数,减少手写 TOML 的低级错误。run plan 的 `scope.capture` 可选择质量检查,并在质量警告时按 `[quality].auto_recover_attempts` 触发多次显式 autoscale 重采;若多次采集指标稳定,可标记为 `ok_by_consistency`。`[steps.expect]` 可对采集指标设置 min/max 断言,`[steps.expect_fft]` 可直接对 FFT 主频、主峰幅度、THD、谐波幅度做断言。断言失败会把实验标记为 failed。可选的实验性 TUI 已覆盖 DP800 电源、DM3000/DM3058 万用表和 DG4202 信号源的常用查看/控制操作,并冻结在这三个面板;CLI、run plan 和 Service 仍是核心能力。HTTP MCP 只读 MVP 已提供 `/health`、`/mcp`、`/tools`、`/call`,其中 `/mcp` 支持 MCP JSON-RPC 的 `initialize`、`tools/list`、`tools/call`,工具为 `run.schema`、`run.check`、`capture.inspect` 三个只读工具。v0.2 已开始加入离线包读取和静态 `run report`;报告现在会输出 `验收摘要 / Acceptance summary`、`预期 vs 实测 / Expected vs measured`,并汇总频率、Vpp、均值、duty、截图与 FFT 验收信息;对多点 sweep run 还会生成扫频摘要表,便于快速查看各频点质量、主峰与 THD。这些报告命令只读已有文件,不连接仪器。
## 长时间测试
diff --git a/doc/README_EN.md b/doc/README_EN.md
index d50043f..6065776 100644
--- a/doc/README_EN.md
+++ b/doc/README_EN.md
@@ -58,7 +58,7 @@ Its supported product scope is intentionally frozen to the power-supply, DMM, an
## Optional frequency-response analysis and PDF reports
-`sweep.frequency_response` sets the source through explicit frequency points and captures a reference (DUT input) and response (DUT output) scope channel in one acquisition per point. It writes a point-by-point `frequency_response.csv` with linear/dB gain and wrapped/unwrapped output-relative phase. A plan may also specify multiple requested Vpp slices, producing a two-dimensional Vpp × frequency measurement. The offline HTML report renders magnitude, phase, fit-comparison SVGs, the point table, and any saved per-point screenshots.
+`sweep.frequency_response` sets the source through explicit frequency points and captures a reference (DUT input) and response (DUT output) scope channel in one acquisition per point. It writes a point-by-point `frequency_response.csv` with raw and, when configured, software-baseline-corrected linear/dB gain and wrapped/unwrapped output-relative phase. A run may contain multiple independently labelled responses and multiple requested Vpp slices, producing a two-dimensional Vpp × frequency measurement. A baseline is an explicitly referenced, separately captured manual CH1/CH2 through connection; it never changes scope deskew or front-panel settings. Optional adaptive refinement inserts linear or logarithmic midpoints where either gain or unwrapped phase changes too quickly, then samples every Vpp slice at the new frequency. The offline HTML/PDF report renders each response's raw/corrected magnitude and phase, fit comparison, audit summary, point table, and any saved per-point screenshots.
Use the conservative template before editing a plan manually:
@@ -71,10 +71,10 @@ python -m wavebench run check --plan plans/frequency_response.toml
python -m wavebench run report data/runs/ --pdf
```
-`linear_log` and `polynomial` fit linear gain against `log10(frequency_hz / Hz)`; PCHIP, dB smoothing splines, and 2D calibration require the `analysis` extra. For multi-Vpp data, `[steps.calibration]` selects a dB target (`passband_median`, `explicit_gain_db`, or `unity_gain`), emits a bounded floating-point LUT in `frequency_response_calibration.csv`, and records validation, limiter flags, and piecewise Chebyshev coefficients in `frequency_response_calibration.json`. Calibration never extrapolates beyond the measured frequency/Vpp domain. The same products can be regenerated without instruments using:
+`linear_log` and `polynomial` fit linear gain against `log10(frequency_hz / Hz)`; PCHIP, dB smoothing splines, and 2D calibration require the `analysis` extra. For multi-Vpp data, `[steps.calibration]` selects a dB target (`passband_median`, `explicit_gain_db`, or `unity_gain`), emits a bounded floating-point LUT in `frequency_response_calibration.csv`, and records validation, limiter flags, and piecewise Chebyshev coefficients in `frequency_response_calibration.json`. By default it additionally emits an auditable signed-two's-complement Q4.12 CSV plus Xilinx COE and MEM files in amplitude-major address order; `[calibration.fixed_point]` can override the width, fractional bits, formats, layout, and overflow policy. Calibration never extrapolates beyond the measured frequency/Vpp domain. The same products can be regenerated without instruments using:
```bash
-python -m wavebench run calibrate data/runs/ --config plans/calibration.toml
+python -m wavebench run calibrate data/runs/ --config plans/calibration.toml --response
```
The PDF is a portable visual report: its visible screenshots, SVG charts, and tables are embedded, while CSV/JSON/NPY evidence stays as separate artifacts for reproducible analysis. WeasyPrint also relies on platform rendering libraries (Cairo, Pango, GDK-PixBuf) and suitable CJK fonts where needed.
diff --git "a/doc/project/WaveBench_run_plan_\344\275\277\347\224\250\346\214\207\345\215\227.md" "b/doc/project/WaveBench_run_plan_\344\275\277\347\224\250\346\214\207\345\215\227.md"
index a974e11..809a650 100644
--- "a/doc/project/WaveBench_run_plan_\344\275\277\347\224\250\346\214\207\345\215\227.md"
+++ "b/doc/project/WaveBench_run_plan_\344\275\277\347\224\250\346\214\207\345\215\227.md"
@@ -114,7 +114,7 @@ python -m wavebench run verify --config wavebench.toml --plan plans/source_scope
- source 输出必须已经由前面的显式 `source.output state = "on"` 打开。频响 step 不会偷偷打开输出;若输出关闭或设频写入失败,会立即停止后续频点并走已有 restore 路径。
- 两路都会经过高阻保护。执行前仍需人工确认探头、线缆、量程和接地;WaveBench 不自动 deskew,也不会把测得相位冒充为已校准 DUT 相位。
- 每个成功采集强制保存双路 NPY 与 `metadata.json` 作为原始证据,即使全局输出配置关闭了 NPY/JSON。采集或分析失败会写入该频点 CSV 行后继续;信号源状态/写入异常会停止。
-- 一个 plan 最多包含一个该 step,避免固定的频响与校准产物名冲突。
+- 一个 plan 可以包含多个该 step,但每个 `label` 必须唯一;多 response 会写根目录 `frequency_responses.json`,每个响应使用独立子目录,因此不会覆盖产物。
显式频点示例:
@@ -215,7 +215,58 @@ python -m wavebench run calibrate data/runs/ --config plans/calibration
| `max_slope_db_per_octave` | `6` | 相邻频率校正的最大斜率,必须大于零。 |
| `chebyshev_degree` / `chebyshev_segment_count` | `3` / `8` | 为需要公式求值的后端导出的近似参数。 |
-该命令只覆盖同 run 目录下的 `frequency_response_calibration.csv/json` 派生产物,绝不改写 `frequency_response.csv` 或原始采集包。
+该命令只覆盖选中 response 目录的 `frequency_response_calibration*` 派生产物,绝不改写 `frequency_response.csv` 或原始采集包。若 manifest 有多个 response,必须显式选择:
+
+```powershell
+python -m wavebench run calibrate data/runs/ --config plans/calibration.toml --response dut_path
+```
+
+### 直通基线、软件 deskew 与自适应频率
+
+直通基线必须是**独立 run**:操作者先把 CH1/CH2 手动直通、确认高阻与安全幅度,再按普通 `sweep.frequency_response` 采集。DUT step 只读引用该证据;它不改示波器 deskew 或前面板设置,且 `run check` 会在连接仪器前离线检查基线 response、Vpp 切片和初始频率有效域。
+
+```toml
+[[steps]]
+kind = "sweep.frequency_response"
+label = "dut_path"
+source_channel = 1
+reference_channel = 1
+response_channel = 2
+start_frequency_hz = 10000
+stop_frequency_hz = 500000
+frequency_count = 41
+spacing = "log"
+
+[steps.baseline]
+run_dir = "../runs/through_baseline"
+# response = "through" # 基线 run 有多个 response 时必填
+mode = "complex_transfer" # 默认;或 phase_only / delay_only
+
+[steps.adaptive]
+enabled = true # 默认关闭,保持旧 plan 行为
+gain_threshold_db = 0.5
+phase_threshold_deg = 10
+max_levels = 2
+max_frequency_points = 1000
+```
+
+默认 `complex_transfer` 同时扣除基线 dB 增益和展开相位;`phase_only` 只校正相位;`delay_only` 从基线相位—频率线性拟合估算延迟后校正相位。原始列永远保留,报告并列原始和校正曲线,二维校准默认用校正增益。
+
+自适应先采集初始网格;任一 Vpp 切片相邻点的增益或展开相位达到阈值时加入中点(log 为几何、linear 为算术),每个新频点都采集**所有** Vpp 切片以保持二维矩形网格。它不能发现端点相同而中间存在未采样窄带异常的特征,初始网格仍须覆盖已知关注区域。
+
+### 定点 LUT / COE / MEM
+
+校准默认同时导出 signed-two's-complement `Q4.12`:审计 CSV、Xilinx `.coe` 与每行一个十六进制字的 `.mem`。默认地址为幅值主序 `amplitude_index * frequency_count + frequency_index`,默认越界报错,不静默截断。
+
+```toml
+[steps.calibration.fixed_point]
+word_width = 16
+fractional_bits = 12
+formats = ["csv", "coe", "mem"]
+layout = "amplitude_major" # 或 frequency_major
+rounding = "nearest"
+overflow = "error" # 需要硬件饱和时才显式使用 saturate
+```
### 拟合方法
@@ -387,9 +438,12 @@ data/runs/YYYYMMDD_HHMMSS_/
| 字段 | 含义 |
|---|---|
-| `csv` | run 根目录的逐点 `frequency_response.csv`。 |
+| `csv` | 此 response 的逐点 `frequency_response.csv`;多 response 时在独立子目录。 |
| `fit_json` | 启用拟合时的 `frequency_response_fit.json`;未启用则为空。 |
| `calibration_csv` / `calibration_json` | 自动二维校准成功时的派生 LUT 路径;未启用或未生成则为空。 |
+| `baseline_json` | 软件基线引用、模式、有效域和估算延迟;未配置则为空。 |
+| `adaptive` | 初始/最终频点数、层数、预算限制和配置;未配置则不存在。 |
+| `fixed_point` | 定点 audit CSV/COE/MEM 路径;校准未生成时为空对象。 |
| `calibration_error` | 自动校准未生成时的原因;原始频响仍然保留。 |
| `captures` | 每个已有双通道采集包与 metadata 的引用,供报告和审计使用。 |
| `failed_point_count` / `warning_point_count` | 频点失败与质量 warning 数量。 |
@@ -428,7 +482,7 @@ HTML 报告当前会汇总:
- `实验证据摘要 / Run evidence summary`:source 步骤、scope capture、DMM 读数、run.json、summary.csv、截图和波形预览数量。
- `证据时间线 / Evidence timeline`:按 step 展示 source/scope/DMM/sleep 的证据摘要。
- `扫频摘要 / Sweep summary`:当 run 里有多点 `scope.capture` 或 sweep label 时显示,列出每个频点的 label、status、quality、expect、FFT、frequency、Vpp、FFT peak、peak amplitude 和 THD。
-- `频率响应 / Frequency response`:当 run 根目录存在 `frequency_response.csv` 时显示幅频、相频、线性增益拟合对比、逐点表格、拟合公式/参数,并发现每点的截图和原始采集包链接;存在校准产物时还会显示目标、留点验证误差、补偿热图与代表性幅值切片。
+- `频率响应 / Frequency response`:按每个 manifest response 显示原始/软件校正幅频和相频、基线与自适应摘要、拟合对比、逐点表格、拟合公式/参数和原始采集包链接;校准产物还会显示目标、留点验证误差、补偿热图、代表性幅值切片和定点摘要。
- `验收摘要 / Acceptance summary` 与 `预期 vs 实测 / Expected vs measured`:汇总 `[steps.expect]` 和 `[steps.expect_fft]` 的验收结果。
- `DMM 读数 / DMM readings`、`信号分析 / Signal analysis`、`波形预览 / Waveform previews`、`截图 / Screenshots`。
diff --git "a/doc/project/WaveBench_\346\225\260\346\215\256\350\276\223\345\207\272\346\240\274\345\274\217.md" "b/doc/project/WaveBench_\346\225\260\346\215\256\350\276\223\345\207\272\346\240\274\345\274\217.md"
index 46f7300..4cbbaa5 100644
--- "a/doc/project/WaveBench_\346\225\260\346\215\256\350\276\223\345\207\272\346\240\274\345\274\217.md"
+++ "b/doc/project/WaveBench_\346\225\260\346\215\256\350\276\223\345\207\272\346\240\274\345\274\217.md"
@@ -633,22 +633,30 @@ step_index,kind,status,package,metadata,quality_status,quality_warnings,expect_s
## 双通道频率响应产物
-`sweep.frequency_response` 在本次 run 根目录额外写入下列产物,不覆盖普通 `scope.capture` 包:
+单一 `sweep.frequency_response` 保持在本次 run 根目录额外写入下列产物,不覆盖普通 `scope.capture` 包:
```text
data/runs/YYYYMMDD_HHMMSS_/
├─ frequency_response.csv
├─ frequency_response_fit.json # 仅配置 [steps.fit] 时存在
-├─ frequency_response_calibration.csv # 仅自动或离线二维校准成功时存在
-└─ frequency_response_calibration.json
+├─ frequency_response_calibration.csv # 自动或离线二维校准成功时存在
+├─ frequency_response_calibration.json
+├─ frequency_response_calibration_fixed.csv # 默认 Q4.12 定点审计表
+├─ frequency_response_calibration_q.coe
+└─ frequency_response_calibration_q.mem
```
+同一 run 含多个频响 step 时,根目录新增 `frequency_responses.json`(`schema_version = 1`)。其 `responses[]` 以唯一 `label`、`step_index`、相对 `directory` 和各派生产物引用描述每个响应;每个响应保存在 `frequency_response/_/`。旧 run 没有 manifest 时仍按根目录单响应产物读取。
+
`frequency_response.csv` 每请求一个频点就原子刷新一次,因此 source 设频失败、scope 采集失败或分析失败时,前序记录和当前失败行仍会保留。稳定基础列为:
```text
index,amplitude_index,requested_vpp,requested_frequency_hz,reference_frequency_hz,response_frequency_hz,
reference_amplitude_peak_v,response_amplitude_peak_v,reference_vpp_v,response_vpp_v,
-gain_linear,gain_db,phase_wrapped_deg,phase_unwrapped_deg,status,warnings,error,
+gain_linear,gain_db,phase_wrapped_deg,phase_unwrapped_deg,
+baseline_gain_db,baseline_phase_unwrapped_deg,gain_linear_corrected,gain_db_corrected,
+phase_wrapped_corrected_deg,phase_unwrapped_corrected_deg,
+adaptive_level,adaptive_parent_start_hz,adaptive_parent_stop_hz,status,warnings,error,
capture_package,metadata_path
```
@@ -658,6 +666,10 @@ capture_package,metadata_path
- `status` 为 `ok`、`warning` 或 `failed`。失败行的数值字段为空,`error` 保存可读错误,不能被误当作零增益或零相位。
- `capture_package` / `metadata_path` 指向每个成功的同步双通道原始证据。频响采集强制写入两路 NPY 与 metadata,普通可选 CSV 和截图仍遵循该 step 的 `save_csv` / `screenshot` 设置。
- 开启拟合后,CSV 还会增加 `fit__gain_linear` 与 `fit__residual` 列;这些值只对应实际有效频点。
+- 原始 `gain_*` 和 `phase_*` 永远不被软件校正覆盖。直通基线开启时,`baseline_*` 是 `log10(frequency)` 域的插值基线,`*_corrected` 是派生结果;二维校准优先使用校正增益。
+- `adaptive_level = 0` 是初始网格;正数表示加密层级,父区间列记录中点来源。失败点仍保留为失败证据。
+
+`frequency_response_baseline.json` 仅在配置 `[steps.baseline]` 时存在,记录独立基线 response、校正模式、每个 Vpp 的有效域、已用切片和由展开相位拟合的估算延迟。它只证明软件后处理,不改变仪器硬件 deskew。
`frequency_response_fit.json` 是供报告、调试脚本和复算使用的 JSON 文档。它声明 `x_transform = "log10(frequency_hz / Hz)"`、有效范围、被排除的点、拟合公式、参数、误差指标和用于图表的频率/线性增益曲线。它不在定义域外外推:调试脚本应先检查 `valid_domain_hz`。
@@ -684,4 +696,12 @@ correction_limited,slope_limited
- 补偿和斜率限制命中数、完整的 `lut` 行;
- 每个请求 Vpp 的 `chebyshev` 分段。每段以 `x = log10(frequency_hz / Hz)` 为自变量,`x_start` / `x_stop` 映射到 `t ∈ [-1, 1]`,并按 `G_dB = Σ c_k T_k(t)` 计算。
-插值约定是“频率方向平滑样条、相邻请求 Vpp 方向线性插值”;频率或 Vpp 超出 `valid_domain` 时不得外推。输出为 IEEE 浮点 CSV/JSON,不提供 Q 格式、COE 或 MEM 文件。
+插值约定是“频率方向平滑样条、相邻请求 Vpp 方向线性插值”;频率或 Vpp 超出 `valid_domain` 时不得外推。
+
+### 定点部署文件
+
+浮点 LUT 始终是审计事实源;自动或离线校准默认还导出其 `correction_linear` 的部署副本:`frequency_response_calibration_fixed.csv`、Xilinx `frequency_response_calibration_q.coe` 和每行一个固定字宽十六进制字的 `frequency_response_calibration_q.mem`。
+
+- 默认是 16 位有符号二补码 `Q4.12`、半值远离零的最近整数、幅值主序 `linear_index = amplitude_index * frequency_count + frequency_index`、`overflow = error`,绝不静默饱和。
+- fixed CSV 逐地址记录 Vpp/频率索引、原值、量化整数/十六进制、量化值和逐点误差;calibration JSON 的 `fixed_point` 记录格式、配置、编码、地址映射、文件路径和最大绝对量化误差。
+- `[calibration.fixed_point]` 可覆盖 `formats`(`csv` / `coe` / `mem`)、`word_width`、`fractional_bits`、`layout`、`rounding` 与 `overflow`;`saturate` 必须显式选择。
diff --git a/plans/passive_filter_2d_calibrated.toml b/plans/passive_filter_2d_calibrated.toml
new file mode 100644
index 0000000..1da050f
--- /dev/null
+++ b/plans/passive_filter_2d_calibrated.toml
@@ -0,0 +1,71 @@
+# 实机 DUT:DG4202 CH1 / RTM CH1 接无源滤波器输入,RTM CH2 接输出。
+# 使用已验证的直通基线,原始列、校正列与定点校准产物会同时保留。
+
+[experiment]
+name = "passive_filter_2d_calibrated"
+label = "passive_filter_2d_calibrated"
+
+[restore]
+source_state = true
+source_channel = 1
+
+[[steps]]
+kind = "source.set_func"
+channel = 1
+function = "sin"
+
+[[steps]]
+kind = "source.set_vpp"
+channel = 1
+value_vpp = 0.05
+
+[[steps]]
+kind = "source.set_freq"
+channel = 1
+frequency_hz = 10000
+
+[[steps]]
+kind = "source.output"
+channel = 1
+state = "on"
+
+[[steps]]
+kind = "sweep.frequency_response"
+label = "passive_filter"
+source_channel = 1
+reference_channel = 1
+response_channel = 2
+frequencies_hz = [10000, 50000, 200000, 500000]
+amplitudes_vpp = [0.05, 0.1]
+autoscale_each_amplitude = true
+target_cycles = 10
+settle_s = 3.0
+points = "def"
+save_csv = false
+screenshot = false
+
+[steps.baseline]
+run_dir = "../data/runs/20260804_221640_through_baseline_2d_stable"
+response = "through"
+mode = "complex_transfer"
+
+[steps.adaptive]
+enabled = true
+gain_threshold_db = 0.5
+phase_threshold_deg = 10
+max_levels = 1
+max_frequency_points = 7
+
+[steps.calibration]
+target_mode = "unity_gain"
+correction_min_db = -12
+correction_max_db = 12
+max_slope_db_per_octave = 6
+
+[steps.calibration.fixed_point]
+word_width = 16
+fractional_bits = 12
+formats = ["csv", "coe", "mem"]
+layout = "amplitude_major"
+rounding = "nearest"
+overflow = "error"
diff --git a/plans/passive_filter_adaptive_5mhz.toml b/plans/passive_filter_adaptive_5mhz.toml
new file mode 100644
index 0000000..22bf870
--- /dev/null
+++ b/plans/passive_filter_adaptive_5mhz.toml
@@ -0,0 +1,51 @@
+# 实机自适应扫频测试:不引用 500 kHz 截止的基线,也不生成校准 LUT。
+
+[experiment]
+name = "passive_filter_adaptive_5mhz"
+label = "passive_filter_adaptive_5mhz"
+
+[restore]
+source_state = true
+source_channel = 1
+
+[[steps]]
+kind = "source.set_func"
+channel = 1
+function = "sin"
+
+[[steps]]
+kind = "source.set_vpp"
+channel = 1
+value_vpp = 0.1
+
+[[steps]]
+kind = "source.set_freq"
+channel = 1
+frequency_hz = 10000
+
+[[steps]]
+kind = "source.output"
+channel = 1
+state = "on"
+
+[[steps]]
+kind = "sweep.frequency_response"
+label = "passive_filter_adaptive"
+source_channel = 1
+reference_channel = 1
+response_channel = 2
+frequencies_hz = [10000, 100000, 500000, 1000000, 5000000]
+amplitudes_vpp = [0.1]
+autoscale_each_amplitude = true
+target_cycles = 10
+settle_s = 3.0
+points = "def"
+save_csv = false
+screenshot = false
+
+[steps.adaptive]
+enabled = true
+gain_threshold_db = 0.5
+phase_threshold_deg = 10
+max_levels = 2
+max_frequency_points = 10
diff --git a/plans/passive_filter_dense_2d_calibrated.toml b/plans/passive_filter_dense_2d_calibrated.toml
new file mode 100644
index 0000000..5a2c7b0
--- /dev/null
+++ b/plans/passive_filter_dense_2d_calibrated.toml
@@ -0,0 +1,67 @@
+# 密集二维校准:使用 10..500 kHz 直通基线的完整有效域。
+# 101 频点 × 2 个请求 Vpp = 202 个测量点;不启用自适应,以固定密集网格形成 LUT。
+
+[experiment]
+name = "passive_filter_dense_2d_calibrated"
+label = "passive_filter_dense_2d_calibrated"
+
+[restore]
+source_state = true
+source_channel = 1
+
+[[steps]]
+kind = "source.set_func"
+channel = 1
+function = "sin"
+
+[[steps]]
+kind = "source.set_vpp"
+channel = 1
+value_vpp = 0.05
+
+[[steps]]
+kind = "source.set_freq"
+channel = 1
+frequency_hz = 10000
+
+[[steps]]
+kind = "source.output"
+channel = 1
+state = "on"
+
+[[steps]]
+kind = "sweep.frequency_response"
+label = "passive_filter_dense"
+source_channel = 1
+reference_channel = 1
+response_channel = 2
+start_frequency_hz = 10000
+stop_frequency_hz = 500000
+frequency_count = 101
+spacing = "linear"
+amplitudes_vpp = [0.05, 0.1]
+autoscale_each_amplitude = true
+target_cycles = 10
+settle_s = 3.0
+points = "dmax"
+save_csv = false
+screenshot = false
+
+[steps.baseline]
+run_dir = "../data/runs/20260804_221640_through_baseline_2d_stable"
+response = "through"
+mode = "complex_transfer"
+
+[steps.calibration]
+target_mode = "unity_gain"
+correction_min_db = -12
+correction_max_db = 12
+max_slope_db_per_octave = 6
+
+[steps.calibration.fixed_point]
+word_width = 16
+fractional_bits = 12
+formats = ["csv", "coe", "mem"]
+layout = "amplitude_major"
+rounding = "nearest"
+overflow = "error"
diff --git a/plans/through_baseline_2d_10k_500k.toml b/plans/through_baseline_2d_10k_500k.toml
new file mode 100644
index 0000000..561ac06
--- /dev/null
+++ b/plans/through_baseline_2d_10k_500k.toml
@@ -0,0 +1,45 @@
+# 实机直通基线:DG4202 CH1 同时接 RTM CH1/CH2。
+# 操作者已确认两路为高阻 DC;本计划不包含 DUT 校准。
+
+[experiment]
+name = "through_baseline_2d_10k_500k"
+label = "through_baseline_2d_10k_500k"
+
+[restore]
+source_state = true
+source_channel = 1
+
+[[steps]]
+kind = "source.set_func"
+channel = 1
+function = "sin"
+
+[[steps]]
+kind = "source.set_vpp"
+channel = 1
+value_vpp = 0.05
+
+[[steps]]
+kind = "source.set_freq"
+channel = 1
+frequency_hz = 10000
+
+[[steps]]
+kind = "source.output"
+channel = 1
+state = "on"
+
+[[steps]]
+kind = "sweep.frequency_response"
+label = "through"
+source_channel = 1
+reference_channel = 1
+response_channel = 2
+frequencies_hz = [10000, 30000, 100000, 300000, 500000]
+amplitudes_vpp = [0.05, 0.1, 0.2]
+autoscale_each_amplitude = true
+target_cycles = 10
+settle_s = 1.0
+points = "def"
+save_csv = false
+screenshot = false
diff --git a/plans/through_baseline_2d_stable.toml b/plans/through_baseline_2d_stable.toml
new file mode 100644
index 0000000..c42a5f1
--- /dev/null
+++ b/plans/through_baseline_2d_stable.toml
@@ -0,0 +1,45 @@
+# 实机二维直通基线:DG4202 CH1 同时接 RTM CH1/CH2。
+# 已由 100 mVpp 诊断验证;幅值切换后保留 3 秒稳定时间。
+
+[experiment]
+name = "through_baseline_2d_stable"
+label = "through_baseline_2d_stable"
+
+[restore]
+source_state = true
+source_channel = 1
+
+[[steps]]
+kind = "source.set_func"
+channel = 1
+function = "sin"
+
+[[steps]]
+kind = "source.set_vpp"
+channel = 1
+value_vpp = 0.05
+
+[[steps]]
+kind = "source.set_freq"
+channel = 1
+frequency_hz = 10000
+
+[[steps]]
+kind = "source.output"
+channel = 1
+state = "on"
+
+[[steps]]
+kind = "sweep.frequency_response"
+label = "through"
+source_channel = 1
+reference_channel = 1
+response_channel = 2
+frequencies_hz = [10000, 50000, 200000, 500000]
+amplitudes_vpp = [0.05, 0.1]
+autoscale_each_amplitude = true
+target_cycles = 10
+settle_s = 3.0
+points = "def"
+save_csv = false
+screenshot = false
diff --git a/plans/through_diagnostic_100mvpp.toml b/plans/through_diagnostic_100mvpp.toml
new file mode 100644
index 0000000..7f9366b
--- /dev/null
+++ b/plans/through_diagnostic_100mvpp.toml
@@ -0,0 +1,44 @@
+# 短时实机诊断:验证直通接线和 autoscale 后的两通道稳定性。
+
+[experiment]
+name = "through_diagnostic_100mvpp"
+label = "through_diagnostic_100mvpp"
+
+[restore]
+source_state = true
+source_channel = 1
+
+[[steps]]
+kind = "source.set_func"
+channel = 1
+function = "sin"
+
+[[steps]]
+kind = "source.set_vpp"
+channel = 1
+value_vpp = 0.1
+
+[[steps]]
+kind = "source.set_freq"
+channel = 1
+frequency_hz = 10000
+
+[[steps]]
+kind = "source.output"
+channel = 1
+state = "on"
+
+[[steps]]
+kind = "sweep.frequency_response"
+label = "through_diagnostic"
+source_channel = 1
+reference_channel = 1
+response_channel = 2
+frequencies_hz = [10000, 100000, 500000]
+amplitudes_vpp = [0.1]
+autoscale_each_amplitude = true
+target_cycles = 10
+settle_s = 3.0
+points = "def"
+save_csv = false
+screenshot = false
diff --git a/src/wavebench/cli.py b/src/wavebench/cli.py
index 5d00e79..7ee9a94 100644
--- a/src/wavebench/cli.py
+++ b/src/wavebench/cli.py
@@ -97,6 +97,7 @@
load_frequency_response_calibration_config,
write_frequency_response_calibration_csv,
write_frequency_response_calibration_json,
+ write_fixed_point_calibration,
)
from .services.sweep_service import SweepService, parse_frequency_list
@@ -392,24 +393,30 @@ def main(argv: list[str] | None = None) -> int:
if args.domain == "run":
if args.command == "calibrate":
package = load_run_package(args.path)
- if package.frequency_response_csv_path is None:
+ response = package.select_frequency_response(args.response)
+ if response.csv_path is None:
raise ConfigError("run calibrate requires frequency_response.csv in the run directory")
calibration = load_frequency_response_calibration_config(args.config)
if not calibration.enabled:
raise ConfigError("calibration.enabled must be true for run calibrate")
document, rows = build_frequency_response_calibration(
- package.frequency_response_rows,
+ response.rows,
calibration,
- source_csv=package.frequency_response_csv_path,
+ source_csv=response.csv_path,
)
csv_path = write_frequency_response_calibration_csv(
- package.path / "frequency_response_calibration.csv", rows
+ response.directory / "frequency_response_calibration.csv", rows
+ )
+ fixed_paths = write_fixed_point_calibration(
+ response.directory, document, rows, calibration.fixed_point
)
json_path = write_frequency_response_calibration_json(
- package.path / "frequency_response_calibration.json", document
+ response.directory / "frequency_response_calibration.json", document
)
print(f"calibration_csv={csv_path}")
print(f"calibration_json={json_path}")
+ for name, path in fixed_paths.items():
+ print(f"calibration_fixed_{name}={path}")
return 0
if args.command == "report":
if args.pdf_output and not args.pdf:
diff --git a/src/wavebench/cli_parser.py b/src/wavebench/cli_parser.py
index 187a887..2b66698 100644
--- a/src/wavebench/cli_parser.py
+++ b/src/wavebench/cli_parser.py
@@ -344,6 +344,11 @@ def build_parser() -> argparse.ArgumentParser:
run_calibrate.add_argument(
"--config", required=True, help="Path to TOML containing a [calibration] table"
)
+ run_calibrate.add_argument(
+ "--response",
+ default=None,
+ help="Frequency-response label for a multi-response run / 多频响 run 的响应标签",
+ )
run_report = run_sub.add_parser("report", help="Generate an offline HTML report for a run package")
run_report.add_argument("path", help="Path to data/runs/")
run_report.add_argument("--output", default=None, help="Output HTML path; defaults to /report.html")
diff --git a/src/wavebench/data/packages.py b/src/wavebench/data/packages.py
index 7922937..f68c1de 100644
--- a/src/wavebench/data/packages.py
+++ b/src/wavebench/data/packages.py
@@ -35,6 +35,29 @@ def instrument(self) -> dict[str, Any]:
return value if isinstance(value, dict) else {}
+@dataclass(frozen=True)
+class FrequencyResponsePackage:
+ """One independently auditable frequency-response result within a run."""
+
+ label: str
+ step_index: int | None
+ directory: Path
+ csv_path: Path | None
+ rows: list[dict[str, str]]
+ fit_path: Path | None
+ fit: dict[str, Any] | None
+ fit_error: str | None
+ baseline_path: Path | None
+ baseline: dict[str, Any] | None
+ baseline_error: str | None
+ calibration_csv_path: Path | None
+ calibration_rows: list[dict[str, str]]
+ calibration_path: Path | None
+ calibration: dict[str, Any] | None
+ calibration_error: str | None
+ manifest_entry: dict[str, Any] = field(default_factory=dict)
+
+
@dataclass(frozen=True)
class RunPackage:
path: Path
@@ -52,6 +75,8 @@ class RunPackage:
frequency_response_calibration_path: Path | None = None
frequency_response_calibration: dict[str, Any] | None = None
frequency_response_calibration_error: str | None = None
+ frequency_responses_manifest_path: Path | None = None
+ frequency_responses: list[FrequencyResponsePackage] = field(default_factory=list)
@property
def status(self) -> str:
@@ -62,6 +87,21 @@ def steps(self) -> list[dict[str, Any]]:
value = self.run.get("steps", [])
return value if isinstance(value, list) else []
+ def select_frequency_response(self, label: str | None = None) -> FrequencyResponsePackage:
+ """Select one response, requiring an explicit label for a multi-response run."""
+ if label is not None:
+ for response in self.frequency_responses:
+ if response.label == label:
+ return response
+ choices = ", ".join(response.label for response in self.frequency_responses) or "(none)"
+ raise ConfigError(f"frequency response {label!r} was not found; available: {choices}")
+ if len(self.frequency_responses) == 1:
+ return self.frequency_responses[0]
+ if not self.frequency_responses:
+ raise ConfigError("run package has no frequency response CSV")
+ choices = ", ".join(response.label for response in self.frequency_responses)
+ raise ConfigError(f"run has multiple frequency responses; specify --response. Available: {choices}")
+
def _read_json_object(path: Path, *, label: str) -> dict[str, Any]:
if not path.exists():
@@ -147,57 +187,181 @@ def load_run_package(path: str | Path) -> RunPackage:
present_summary_path = summary_path
with summary_path.open(newline="", encoding="utf-8") as file:
rows = [dict(row) for row in csv.DictReader(file)]
- response_path = run_dir / "frequency_response.csv"
- response_rows: list[dict[str, str]] = []
- present_response_path: Path | None = None
- if response_path.exists():
- present_response_path = response_path
- with response_path.open(newline="", encoding="utf-8") as file:
- response_rows = [dict(row) for row in csv.DictReader(file)]
- fit_path = run_dir / "frequency_response_fit.json"
- present_fit_path: Path | None = fit_path if fit_path.exists() else None
- fit: dict[str, Any] | None = None
- fit_error: str | None = None
- if present_fit_path is not None:
- try:
- fit = _read_json_object(present_fit_path, label="frequency response fit JSON")
- except ConfigError as exc:
- fit_error = str(exc)
- calibration_csv_path = run_dir / "frequency_response_calibration.csv"
- calibration_rows: list[dict[str, str]] = []
- present_calibration_csv_path: Path | None = None
- if calibration_csv_path.exists():
- present_calibration_csv_path = calibration_csv_path
- with calibration_csv_path.open(newline="", encoding="utf-8") as file:
- calibration_rows = [dict(row) for row in csv.DictReader(file)]
- calibration_path = run_dir / "frequency_response_calibration.json"
- present_calibration_path: Path | None = calibration_path if calibration_path.exists() else None
- calibration: dict[str, Any] | None = None
- calibration_error: str | None = None
- if present_calibration_path is not None:
- try:
- calibration = _read_json_object(
- present_calibration_path, label="frequency response calibration JSON"
- )
- except ConfigError as exc:
- calibration_error = str(exc)
+ manifest_path = run_dir / "frequency_responses.json"
+ responses = _load_frequency_responses(run_dir, run_data, manifest_path if manifest_path.exists() else None)
+ primary = responses[0] if responses else None
+ fallback_calibration_csv = run_dir / "frequency_response_calibration.csv"
+ fallback_calibration_rows = _read_csv_rows(fallback_calibration_csv)
+ fallback_calibration_path, fallback_calibration, fallback_calibration_error = _read_optional_json(
+ run_dir / "frequency_response_calibration.json", "frequency response calibration JSON"
+ )
return RunPackage(
path=run_dir,
run_json_path=run_json_path,
run=run_data,
summary_csv_path=present_summary_path,
summary_rows=rows,
- frequency_response_csv_path=present_response_path,
- frequency_response_rows=response_rows,
- frequency_response_fit_path=present_fit_path,
- frequency_response_fit=fit,
- frequency_response_fit_error=fit_error,
- frequency_response_calibration_csv_path=present_calibration_csv_path,
- frequency_response_calibration_rows=calibration_rows,
- frequency_response_calibration_path=present_calibration_path,
- frequency_response_calibration=calibration,
- frequency_response_calibration_error=calibration_error,
+ frequency_response_csv_path=primary.csv_path if primary else None,
+ frequency_response_rows=primary.rows if primary else [],
+ frequency_response_fit_path=primary.fit_path if primary else None,
+ frequency_response_fit=primary.fit if primary else None,
+ frequency_response_fit_error=primary.fit_error if primary else None,
+ frequency_response_calibration_csv_path=(
+ primary.calibration_csv_path if primary else fallback_calibration_csv if fallback_calibration_csv.exists() else None
+ ),
+ frequency_response_calibration_rows=(
+ primary.calibration_rows if primary else fallback_calibration_rows
+ ),
+ frequency_response_calibration_path=primary.calibration_path if primary else fallback_calibration_path,
+ frequency_response_calibration=primary.calibration if primary else fallback_calibration,
+ frequency_response_calibration_error=primary.calibration_error if primary else fallback_calibration_error,
+ frequency_responses_manifest_path=manifest_path if manifest_path.exists() else None,
+ frequency_responses=responses,
+ )
+
+
+def _load_frequency_responses(
+ run_dir: Path, run_data: dict[str, Any], manifest_path: Path | None
+) -> list[FrequencyResponsePackage]:
+ if manifest_path is not None:
+ manifest = _read_json_object(manifest_path, label="frequency responses manifest")
+ entries = manifest.get("responses")
+ if not isinstance(entries, list):
+ raise ConfigError(f"frequency responses manifest has no responses array: {manifest_path}")
+ responses: list[FrequencyResponsePackage] = []
+ labels: set[str] = set()
+ for entry in entries:
+ if not isinstance(entry, dict):
+ raise ConfigError(f"frequency responses manifest contains a non-object entry: {manifest_path}")
+ label = entry.get("label")
+ directory = entry.get("directory")
+ if not isinstance(label, str) or not label.strip() or not isinstance(directory, str):
+ raise ConfigError(f"frequency responses manifest entry requires label and directory: {manifest_path}")
+ if label in labels:
+ raise ConfigError(f"frequency responses manifest has duplicate label {label!r}: {manifest_path}")
+ labels.add(label)
+ step_index = entry.get("step_index")
+ responses.append(
+ _load_frequency_response_package(
+ run_dir=run_dir,
+ label=label,
+ step_index=step_index if isinstance(step_index, int) else None,
+ directory=_manifest_directory(run_dir, directory, manifest_path),
+ entry=entry,
+ )
+ )
+ return responses
+
+ legacy_csv = run_dir / "frequency_response.csv"
+ if not legacy_csv.exists():
+ return []
+ label, step_index = _legacy_response_identity(run_data)
+ return [
+ _load_frequency_response_package(
+ run_dir=run_dir,
+ label=label,
+ step_index=step_index,
+ directory=run_dir,
+ entry={},
+ )
+ ]
+
+
+def _load_frequency_response_package(
+ *,
+ run_dir: Path,
+ label: str,
+ step_index: int | None,
+ directory: Path,
+ entry: dict[str, Any],
+) -> FrequencyResponsePackage:
+ csv_path = _artifact_file(directory, entry.get("csv"), "frequency_response.csv")
+ rows = _read_csv_rows(csv_path)
+ fit_path, fit, fit_error = _read_optional_json(
+ _artifact_file(directory, entry.get("fit_json"), "frequency_response_fit.json"),
+ "frequency response fit JSON",
+ )
+ baseline_path, baseline, baseline_error = _read_optional_json(
+ _artifact_file(directory, entry.get("baseline_json"), "frequency_response_baseline.json"),
+ "frequency response baseline JSON",
+ )
+ calibration_csv_path = _artifact_file(
+ directory, entry.get("calibration_csv"), "frequency_response_calibration.csv"
)
+ calibration_rows = _read_csv_rows(calibration_csv_path)
+ calibration_path, calibration, calibration_error = _read_optional_json(
+ _artifact_file(directory, entry.get("calibration_json"), "frequency_response_calibration.json"),
+ "frequency response calibration JSON",
+ )
+ return FrequencyResponsePackage(
+ label=label,
+ step_index=step_index,
+ directory=directory,
+ csv_path=csv_path if csv_path.exists() else None,
+ rows=rows,
+ fit_path=fit_path,
+ fit=fit,
+ fit_error=fit_error,
+ baseline_path=baseline_path,
+ baseline=baseline,
+ baseline_error=baseline_error,
+ calibration_csv_path=calibration_csv_path if calibration_csv_path.exists() else None,
+ calibration_rows=calibration_rows,
+ calibration_path=calibration_path,
+ calibration=calibration,
+ calibration_error=calibration_error,
+ manifest_entry=dict(entry),
+ )
+
+
+def _artifact_file(directory: Path, raw: Any, default_name: str) -> Path:
+ if isinstance(raw, str) and raw.strip():
+ candidate = Path(raw)
+ return candidate if candidate.is_absolute() else directory / candidate
+ return directory / default_name
+
+
+def _read_csv_rows(path: Path) -> list[dict[str, str]]:
+ if not path.exists():
+ return []
+ with path.open(newline="", encoding="utf-8") as file:
+ return [dict(row) for row in csv.DictReader(file)]
+
+
+def _read_optional_json(path: Path, label: str) -> tuple[Path | None, dict[str, Any] | None, str | None]:
+ if not path.exists():
+ return None, None, None
+ try:
+ return path, _read_json_object(path, label=label), None
+ except ConfigError as exc:
+ return path, None, str(exc)
+
+
+def _manifest_directory(run_dir: Path, raw: str, manifest_path: Path) -> Path:
+ candidate = Path(raw)
+ path = candidate if candidate.is_absolute() else run_dir / candidate
+ try:
+ path.resolve().relative_to(run_dir.resolve())
+ except ValueError as exc:
+ raise ConfigError(f"frequency responses manifest directory escapes run directory: {manifest_path}") from exc
+ return path
+
+
+def _legacy_response_identity(run_data: dict[str, Any]) -> tuple[str, int | None]:
+ steps = run_data.get("steps")
+ if isinstance(steps, list):
+ for step in steps:
+ if not isinstance(step, dict) or step.get("kind") != "sweep.frequency_response":
+ continue
+ fields = step.get("fields")
+ if isinstance(fields, dict):
+ label = fields.get("label")
+ if isinstance(label, str) and label.strip():
+ return label, step.get("index") if isinstance(step.get("index"), int) else None
+ index = step.get("index")
+ if isinstance(index, int):
+ return f"frequency_response_{index:02d}", index
+ return "frequency_response", None
def _dict_or_empty(value: Any) -> dict[str, Any]:
diff --git a/src/wavebench/report/html.py b/src/wavebench/report/html.py
index d311118..2099aac 100644
--- a/src/wavebench/report/html.py
+++ b/src/wavebench/report/html.py
@@ -9,7 +9,7 @@
import numpy as np
-from wavebench.data.packages import RunPackage
+from wavebench.data.packages import FrequencyResponsePackage, RunPackage
from wavebench.errors import ConfigError
@@ -436,6 +436,24 @@ def _build_report_manifest(run: RunPackage, *, output_dir: Path, report_path: Pa
)
if run.frequency_response_calibration_path is not None
else None,
+ "frequency_responses": [
+ {
+ "label": response.label,
+ "directory": _relative_url(response.directory, output_dir),
+ "csv": _relative_url(response.csv_path, output_dir)
+ if response.csv_path is not None
+ else None,
+ "baseline_json": _relative_url(response.baseline_path, output_dir)
+ if response.baseline_path is not None
+ else None,
+ "calibration_json": _relative_url(response.calibration_path, output_dir)
+ if response.calibration_path is not None
+ else None,
+ "status": response.manifest_entry.get("status"),
+ "adaptive": response.manifest_entry.get("adaptive"),
+ }
+ for response in run.frequency_responses
+ ],
"capture_packages": capture_packages,
"screenshots": [
{
@@ -764,25 +782,53 @@ def _sweep_summary_row(row: ReportSweepRow) -> str:
def _frequency_response_block(run: RunPackage, *, include_table: bool = True) -> str:
- if run.frequency_response_csv_path is None:
+ if not run.frequency_responses:
return ""
- rows = run.frequency_response_rows
- gain_blocks = _response_blocks(rows, "gain_db")
- phase_blocks = _response_blocks(rows, "phase_unwrapped_deg")
- fit_series = _fit_curve_series(run.frequency_response_fit)
+ multiple = len(run.frequency_responses) > 1
+ return "".join(
+ _frequency_response_section(response, include_table=include_table, multiple=multiple)
+ for response in run.frequency_responses
+ )
+
+
+def _frequency_response_section(
+ response: FrequencyResponsePackage, *, include_table: bool, multiple: bool
+) -> str:
+ rows = response.rows
+ raw_gain_blocks = _response_blocks(rows, "gain_db")
+ raw_phase_blocks = _response_blocks(rows, "phase_unwrapped_deg")
+ corrected_gain_blocks = _response_blocks(rows, "gain_db_corrected")
+ corrected_phase_blocks = _response_blocks(rows, "phase_unwrapped_corrected_deg")
+ fit_series = _fit_curve_series(response.fit)
gain_svg = _response_svg(
- gain_blocks,
- title="幅频 / Magnitude response",
+ raw_gain_blocks,
+ title="原始幅频 / Raw magnitude response",
y_label="Gain (dB)",
series=(),
)
phase_svg = _response_svg(
- phase_blocks,
- title="相频 / Phase response",
+ raw_phase_blocks,
+ title="原始相频 / Raw phase response",
y_label="Phase (deg, unwrapped)",
series=(),
)
- linear_blocks = [[point] for block in _response_blocks(rows, "gain_linear") for point in block]
+ corrected_gain_svg = _response_svg(
+ corrected_gain_blocks,
+ title="校正幅频 / Corrected magnitude response",
+ y_label="Gain (dB)",
+ series=(),
+ ) if corrected_gain_blocks else '未配置软件基线校正 / No software baseline correction.
'
+ corrected_phase_svg = _response_svg(
+ corrected_phase_blocks,
+ title="校正相频 / Corrected phase response",
+ y_label="Phase (deg, unwrapped)",
+ series=(),
+ ) if corrected_phase_blocks else '未配置软件基线校正 / No software baseline correction.
'
+ linear_blocks = [
+ [point]
+ for block in _response_blocks(rows, "gain_linear_corrected") or _response_blocks(rows, "gain_linear")
+ for point in block
+ ]
fit_svg = _response_svg(
linear_blocks,
title="线性增益拟合 / Linear gain fit comparison",
@@ -802,18 +848,29 @@ def _frequency_response_block(run: RunPackage, *, include_table: bool = True) ->
"""
else:
- table_block = '逐频点结果请见同目录 frequency_response.csv。
'
+ table_block = '逐频点结果请见本响应目录的 frequency_response.csv。
'
fit_summary = (
- _fit_summary_block(run.frequency_response_fit, run.frequency_response_fit_error)
+ _fit_summary_block(response.fit, response.fit_error)
if include_table
- else _compact_fit_summary_block(run.frequency_response_fit, run.frequency_response_fit_error)
+ else _compact_fit_summary_block(response.fit, response.fit_error)
)
- calibration_block = _frequency_response_calibration_block(run)
- return f"""频率响应 / Frequency response
-幅频与相频由同一次双通道采集计算。相位为输出相对输入,包含探头、电缆和通道偏斜;未自动校准或 deskew。
+ calibration_block = _frequency_response_calibration_block(response)
+ baseline_block = _frequency_response_baseline_block(response)
+ adaptive_block = _frequency_response_adaptive_block(response)
+ title = "频率响应 / Frequency response"
+ if multiple:
+ title += f" — {escape(response.label)}"
+ return f"""{title}
+响应标签 / Response label: {escape(response.label)}。原始幅相保留为证据;软件基线校正不会改写仪器 deskew 或前面板设置。
+{baseline_block}
+{adaptive_block}
+
+原始幅频 / Raw magnitude {gain_svg}
+原始相频 / Raw phase {phase_svg}
+
-幅频 / Magnitude {gain_svg}
-相频 / Phase {phase_svg}
+校正幅频 / Corrected magnitude {corrected_gain_svg}
+校正相频 / Corrected phase {corrected_phase_svg}
拟合对比 / Fit comparison {fit_svg}
@@ -824,6 +881,40 @@ def _frequency_response_block(run: RunPackage, *, include_table: bool = True) ->
"""
+def _frequency_response_baseline_block(response: FrequencyResponsePackage) -> str:
+ if response.baseline_error:
+ return f'基线审计 JSON 无法读取:{escape(response.baseline_error)}
'
+ if not response.baseline:
+ return ""
+ mode = response.baseline.get("mode", "")
+ source = response.baseline.get("baseline_response", "")
+ domain = response.baseline.get("valid_domain_hz_by_requested_vpp", {})
+ delay = response.baseline.get("estimated_delay_s_by_requested_vpp", {})
+ return (
+ '软件基线 / Software baseline '
+ '模式 / Mode 有效域 / Valid domain 估算延迟 / Estimated delay '
+ f'{escape(str(source))} {escape(str(mode))} '
+ f'{escape(json.dumps(domain, ensure_ascii=False))} '
+ f'{escape(json.dumps(delay, ensure_ascii=False))}
'
+ )
+
+
+def _frequency_response_adaptive_block(response: FrequencyResponsePackage) -> str:
+ adaptive = response.manifest_entry.get("adaptive")
+ if not isinstance(adaptive, dict):
+ return ""
+ return (
+ '自适应加密 / Adaptive refinement '
+ '初始点 加密层数 最终点数 预算限制 '
+ f'{escape(str(adaptive.get("configuration", {})))} '
+ f'{escape(str(adaptive.get("initial_frequency_count", "")))} '
+ f'{escape(str(adaptive.get("refinement_levels_completed", "")))} '
+ f'{escape(str(adaptive.get("final_frequency_count", "")))} '
+ f'{escape(str(adaptive.get("budget_limited", False)))} '
+ '
'
+ )
+
+
def _frequency_response_row(row: dict[str, str]) -> str:
status = str(row.get("status", ""))
details = " | ".join(
@@ -931,28 +1022,16 @@ def _response_blocks(rows: list[dict[str, str]], key: str) -> list[list[tuple[fl
return blocks
-def _frequency_response_calibration_block(run: RunPackage) -> str:
- if run.frequency_response_calibration_error:
+def _frequency_response_calibration_block(response: FrequencyResponsePackage) -> str:
+ if response.calibration_error:
return (
'二维校准 / 2D calibration '
f'校准 JSON 无法读取 / Calibration unavailable: '
- f'{escape(run.frequency_response_calibration_error)}
'
+ f'{escape(response.calibration_error)}
'
)
- document = run.frequency_response_calibration
- rows = run.frequency_response_calibration_rows
+ document = response.calibration
+ rows = response.calibration_rows
if not document and not rows:
- errors = [
- str(response.get("calibration_error"))
- for step in run.steps
- if isinstance(step.get("artifact"), dict)
- for response in [step["artifact"].get("frequency_response", {})]
- if isinstance(response, dict) and response.get("calibration_error")
- ]
- if errors:
- return (
- '二维校准 / 2D calibration '
- f'自动校准未生成;原始频响仍已保留:{escape(errors[0])}
'
- )
return ""
configuration = document.get("configuration", {}) if isinstance(document, dict) else {}
validation = document.get("validation", {}) if isinstance(document, dict) else {}
@@ -973,13 +1052,23 @@ def _frequency_response_calibration_block(run: RunPackage) -> str:
'完整浮点 LUT 与 Chebyshev 系数见 frequency_response_calibration.csv 和 '
'frequency_response_calibration.json。'
)
+ fixed_point = document.get("fixed_point") if isinstance(document, dict) else None
+ fixed_note = ""
+ if isinstance(fixed_point, dict):
+ fixed_note = (
+ '定点部署 / Fixed point: '
+ f'{escape(str(fixed_point.get("q_format", "")))}, '
+ f'{escape(str(fixed_point.get("encoding", "")))}, 最大量化误差 / max error '
+ f'{escape(_format_plain(fixed_point.get("max_abs_quantization_error")))}
'
+ )
return f"""二维校准 / 2D calibration
{summary}
补偿热图 / Correction heatmap {heatmap}
代表性切片 / Representative slices {curves}
-{note}"""
+{note}
+{fixed_note}"""
def _calibration_heatmap_svg(rows: list[dict[str, str]]) -> str:
@@ -1579,6 +1668,34 @@ def _collect_artifact_links(
status=_availability_text(run.frequency_response_calibration_path.exists()),
)
)
+ for response in run.frequency_responses:
+ prefix = f"[{response.label}] " if len(run.frequency_responses) > 1 else ""
+ if response.baseline_path is not None:
+ links.append(
+ ReportArtifactLink(
+ step_index=str(response.step_index) if response.step_index is not None else "-",
+ kind=f"{prefix}软件基线 JSON / Software baseline JSON",
+ label="frequency_response_baseline.json",
+ href=_relative_url(response.baseline_path, output_dir),
+ status=_availability_text(response.baseline_path.exists()),
+ )
+ )
+ fixed_point = response.calibration.get("fixed_point", {}) if response.calibration else {}
+ outputs = fixed_point.get("outputs", {}) if isinstance(fixed_point, dict) else {}
+ if isinstance(outputs, dict):
+ for name, raw_path in outputs.items():
+ if not isinstance(raw_path, str):
+ continue
+ path = Path(raw_path)
+ links.append(
+ ReportArtifactLink(
+ step_index=str(response.step_index) if response.step_index is not None else "-",
+ kind=f"{prefix}定点 {name.upper()} / Fixed-point {name.upper()}",
+ label=path.name,
+ href=_relative_url(path, output_dir),
+ status=_availability_text(path.exists()),
+ )
+ )
screenshots_by_package = {item.package: item for item in screenshots}
for reference in _capture_references(run):
package_dir = _resolve_artifact_path(run.path, reference.package)
diff --git a/src/wavebench/services/frequency_response.py b/src/wavebench/services/frequency_response.py
index 256f54f..6439748 100644
--- a/src/wavebench/services/frequency_response.py
+++ b/src/wavebench/services/frequency_response.py
@@ -34,6 +34,15 @@
"gain_db",
"phase_wrapped_deg",
"phase_unwrapped_deg",
+ "baseline_gain_db",
+ "baseline_phase_unwrapped_deg",
+ "gain_linear_corrected",
+ "gain_db_corrected",
+ "phase_wrapped_corrected_deg",
+ "phase_unwrapped_corrected_deg",
+ "adaptive_level",
+ "adaptive_parent_start_hz",
+ "adaptive_parent_stop_hz",
"status",
"warnings",
"error",
@@ -59,6 +68,15 @@ class FrequencyResponsePoint:
status: str
amplitude_index: int = 0
requested_vpp: float | None = None
+ baseline_gain_db: float | None = None
+ baseline_phase_unwrapped_deg: float | None = None
+ gain_linear_corrected: float | None = None
+ gain_db_corrected: float | None = None
+ phase_wrapped_corrected_deg: float | None = None
+ phase_unwrapped_corrected_deg: float | None = None
+ adaptive_level: int = 0
+ adaptive_parent_start_hz: float | None = None
+ adaptive_parent_stop_hz: float | None = None
warnings: tuple[str, ...] = ()
error: str = ""
capture_package: str = ""
@@ -89,6 +107,15 @@ def as_csv_row(self, fit_values: dict[str, tuple[float | None, float | None]] |
"gain_db": self.gain_db,
"phase_wrapped_deg": self.phase_wrapped_deg,
"phase_unwrapped_deg": self.phase_unwrapped_deg,
+ "baseline_gain_db": self.baseline_gain_db,
+ "baseline_phase_unwrapped_deg": self.baseline_phase_unwrapped_deg,
+ "gain_linear_corrected": self.gain_linear_corrected,
+ "gain_db_corrected": self.gain_db_corrected,
+ "phase_wrapped_corrected_deg": self.phase_wrapped_corrected_deg,
+ "phase_unwrapped_corrected_deg": self.phase_unwrapped_corrected_deg,
+ "adaptive_level": self.adaptive_level,
+ "adaptive_parent_start_hz": self.adaptive_parent_start_hz,
+ "adaptive_parent_stop_hz": self.adaptive_parent_stop_hz,
"status": self.status,
"warnings": " | ".join(self.warnings),
"error": self.error,
@@ -112,6 +139,9 @@ def analyze_frequency_response_point(
frequency_tolerance_ratio: float,
capture_package: str,
metadata_path: str,
+ adaptive_level: int = 0,
+ adaptive_parent_start_hz: float | None = None,
+ adaptive_parent_stop_hz: float | None = None,
) -> FrequencyResponsePoint:
"""Compute one transfer-function point from a simultaneous two-channel capture."""
try:
@@ -152,6 +182,9 @@ def analyze_frequency_response_point(
status="warning" if warnings else "ok",
amplitude_index=amplitude_index,
requested_vpp=requested_vpp,
+ adaptive_level=adaptive_level,
+ adaptive_parent_start_hz=adaptive_parent_start_hz,
+ adaptive_parent_stop_hz=adaptive_parent_stop_hz,
warnings=tuple(warnings),
capture_package=capture_package,
metadata_path=metadata_path,
@@ -173,6 +206,9 @@ def analyze_frequency_response_point(
status="failed",
amplitude_index=amplitude_index,
requested_vpp=requested_vpp,
+ adaptive_level=adaptive_level,
+ adaptive_parent_start_hz=adaptive_parent_start_hz,
+ adaptive_parent_stop_hz=adaptive_parent_stop_hz,
error=f"{type(exc).__name__}: {exc}",
capture_package=capture_package,
metadata_path=metadata_path,
@@ -186,6 +222,9 @@ def failed_frequency_response_point(
requested_vpp: float | None = None,
requested_frequency_hz: float,
error: Exception | str,
+ adaptive_level: int = 0,
+ adaptive_parent_start_hz: float | None = None,
+ adaptive_parent_stop_hz: float | None = None,
) -> FrequencyResponsePoint:
text = str(error)
if isinstance(error, Exception):
@@ -206,38 +245,61 @@ def failed_frequency_response_point(
status="failed",
amplitude_index=amplitude_index,
requested_vpp=requested_vpp,
+ adaptive_level=adaptive_level,
+ adaptive_parent_start_hz=adaptive_parent_start_hz,
+ adaptive_parent_stop_hz=adaptive_parent_stop_hz,
error=text,
)
def unwrap_frequency_response_phase(points: list[FrequencyResponsePoint]) -> list[FrequencyResponsePoint]:
- result: list[FrequencyResponsePoint] = []
- block: list[FrequencyResponsePoint] = []
-
- def flush() -> None:
- if not block:
- return
- unwrapped = np.degrees(np.unwrap(np.radians([point.phase_wrapped_deg for point in block])))
- result.extend(
- replace(point, phase_unwrapped_deg=float(value))
- for point, value in zip(block, unwrapped)
- )
- block.clear()
+ """Unwrap each amplitude slice in frequency order.
- last_amplitude_index: int | None = None
+ Adaptive points are acquired after their parent grid, so acquisition order is no
+ longer frequency order. Sorting here prevents those late points from creating
+ fictitious 360-degree jumps while retaining failed evidence rows.
+ """
+ grouped: dict[int, list[FrequencyResponsePoint]] = {}
for point in points:
- if last_amplitude_index is not None and point.amplitude_index != last_amplitude_index:
- flush()
- last_amplitude_index = point.amplitude_index
- if point.status == "failed" or point.phase_wrapped_deg is None:
- flush()
- result.append(point)
- else:
- block.append(point)
- flush()
+ grouped.setdefault(point.amplitude_index, []).append(point)
+ result: list[FrequencyResponsePoint] = []
+ for amplitude_index in sorted(grouped):
+ block: list[FrequencyResponsePoint] = []
+
+ def flush() -> None:
+ if not block:
+ return
+ unwrapped = np.degrees(np.unwrap(np.radians([point.phase_wrapped_deg for point in block])))
+ result.extend(
+ replace(point, phase_unwrapped_deg=float(value))
+ for point, value in zip(block, unwrapped)
+ )
+ block.clear()
+
+ for point in sorted(grouped[amplitude_index], key=lambda item: item.requested_frequency_hz):
+ if point.status == "failed" or point.phase_wrapped_deg is None:
+ flush()
+ result.append(point)
+ else:
+ block.append(point)
+ flush()
return result
+def response_gain_db(point: FrequencyResponsePoint) -> float | None:
+ """Return the corrected gain when available, otherwise the raw measured gain."""
+ return point.gain_db_corrected if point.gain_db_corrected is not None else point.gain_db
+
+
+def response_phase_unwrapped_deg(point: FrequencyResponsePoint) -> float | None:
+ """Return the corrected phase when available, otherwise the raw measured phase."""
+ return (
+ point.phase_unwrapped_corrected_deg
+ if point.phase_unwrapped_corrected_deg is not None
+ else point.phase_unwrapped_deg
+ )
+
+
def ensure_fit_dependencies(fit: dict[str, Any] | None) -> None:
methods = _fit_methods(fit)
if not {"pchip", "smoothing_spline_db"}.intersection(methods):
@@ -294,7 +356,15 @@ def build_fit_document(
document["valid_domain_hz"] = None
x = np.log10(np.asarray([point.requested_frequency_hz for point in usable], dtype=float))
- y = np.asarray([point.gain_linear for point in usable], dtype=float)
+ y = np.asarray(
+ [
+ point.gain_linear_corrected
+ if point.gain_linear_corrected is not None
+ else point.gain_linear
+ for point in usable
+ ],
+ dtype=float,
+ )
for method in methods:
result, values = _fit_method(
method=method,
diff --git a/src/wavebench/services/frequency_response_adaptive.py b/src/wavebench/services/frequency_response_adaptive.py
new file mode 100644
index 0000000..06a4dc9
--- /dev/null
+++ b/src/wavebench/services/frequency_response_adaptive.py
@@ -0,0 +1,156 @@
+from __future__ import annotations
+
+from dataclasses import asdict, dataclass
+from math import isfinite, log10
+from typing import Any
+
+from wavebench.errors import ConfigError
+from wavebench.services.frequency_response import (
+ FrequencyResponsePoint,
+ response_gain_db,
+ response_phase_unwrapped_deg,
+)
+
+
+@dataclass(frozen=True)
+class FrequencyResponseAdaptiveConfig:
+ enabled: bool = True
+ gain_threshold_db: float = 0.5
+ phase_threshold_deg: float = 10.0
+ max_levels: int = 2
+ max_frequency_points: int = 1000
+
+ def as_dict(self) -> dict[str, Any]:
+ return asdict(self)
+
+
+@dataclass(frozen=True)
+class AdaptiveFrequency:
+ frequency_hz: float
+ level: int
+ parent_start_hz: float
+ parent_stop_hz: float
+
+
+@dataclass(frozen=True)
+class AdaptiveRefinement:
+ frequencies: tuple[AdaptiveFrequency, ...]
+ budget_limited: bool
+
+
+def normalize_frequency_response_adaptive(
+ raw: Any, name: str = "adaptive"
+) -> FrequencyResponseAdaptiveConfig:
+ if not isinstance(raw, dict):
+ raise ConfigError(f"{name} must be a TOML table")
+ allowed = {"enabled", "gain_threshold_db", "phase_threshold_deg", "max_levels", "max_frequency_points"}
+ unknown = sorted(set(raw) - allowed)
+ if unknown:
+ raise ConfigError(f"{name} has unsupported keys: {', '.join(unknown)}")
+ enabled = _bool(raw.get("enabled", True), f"{name}.enabled")
+ gain = _positive_float(raw.get("gain_threshold_db", 0.5), f"{name}.gain_threshold_db")
+ phase = _positive_float(raw.get("phase_threshold_deg", 10.0), f"{name}.phase_threshold_deg")
+ levels = _positive_int(raw.get("max_levels", 2), f"{name}.max_levels")
+ points = _positive_int(raw.get("max_frequency_points", 1000), f"{name}.max_frequency_points")
+ return FrequencyResponseAdaptiveConfig(
+ enabled=enabled,
+ gain_threshold_db=gain,
+ phase_threshold_deg=phase,
+ max_levels=levels,
+ max_frequency_points=points,
+ )
+
+
+def select_adaptive_frequency_refinement(
+ points: list[FrequencyResponsePoint],
+ *,
+ spacing: str,
+ level: int,
+ config: FrequencyResponseAdaptiveConfig | dict[str, Any],
+ existing_frequencies_hz: set[float],
+) -> AdaptiveRefinement:
+ """Select a rectangular-grid refinement from gain or phase changes in any Vpp slice."""
+ if isinstance(config, dict):
+ config = normalize_frequency_response_adaptive(config)
+ if not config.enabled or level > config.max_levels:
+ return AdaptiveRefinement((), False)
+ if spacing not in {"linear", "log"}:
+ raise ConfigError("adaptive refinement spacing must be 'linear' or 'log'")
+ by_amplitude: dict[int, list[FrequencyResponsePoint]] = {}
+ for point in points:
+ if point.adaptive_level > level - 1:
+ continue
+ by_amplitude.setdefault(point.amplitude_index, []).append(point)
+ selected: dict[float, AdaptiveFrequency] = {}
+ for samples in by_amplitude.values():
+ ordered = sorted(samples, key=lambda point: point.requested_frequency_hz)
+ for left, right in zip(ordered, ordered[1:]):
+ if not _requires_refinement(left, right, config):
+ continue
+ midpoint = _midpoint(left.requested_frequency_hz, right.requested_frequency_hz, spacing)
+ if midpoint in existing_frequencies_hz or midpoint in selected:
+ continue
+ selected[midpoint] = AdaptiveFrequency(
+ frequency_hz=midpoint,
+ level=level,
+ parent_start_hz=left.requested_frequency_hz,
+ parent_stop_hz=right.requested_frequency_hz,
+ )
+ budget = max(0, config.max_frequency_points - len(existing_frequencies_hz))
+ candidates = sorted(selected.values(), key=lambda item: item.frequency_hz)
+ return AdaptiveRefinement(tuple(candidates[:budget]), len(candidates) > budget)
+
+
+def _requires_refinement(
+ left: FrequencyResponsePoint,
+ right: FrequencyResponsePoint,
+ config: FrequencyResponseAdaptiveConfig,
+) -> bool:
+ left_gain, right_gain = response_gain_db(left), response_gain_db(right)
+ left_phase, right_phase = response_phase_unwrapped_deg(left), response_phase_unwrapped_deg(right)
+ gain_change = (
+ left_gain is not None and right_gain is not None and abs(right_gain - left_gain) >= config.gain_threshold_db
+ )
+ phase_change = (
+ left_phase is not None
+ and right_phase is not None
+ and abs(right_phase - left_phase) >= config.phase_threshold_deg
+ )
+ return gain_change or phase_change
+
+
+def _midpoint(start: float, stop: float, spacing: str) -> float:
+ midpoint = (start + stop) / 2.0 if spacing == "linear" else 10.0 ** ((log10(start) + log10(stop)) / 2.0)
+ if not isfinite(midpoint) or not start < midpoint < stop:
+ raise ConfigError(f"cannot refine frequency interval {start:.12g} Hz .. {stop:.12g} Hz")
+ return midpoint
+
+
+def _bool(value: Any, name: str) -> bool:
+ if not isinstance(value, bool):
+ raise ConfigError(f"{name} must be true or false")
+ return value
+
+
+def _positive_float(value: Any, name: str) -> float:
+ if isinstance(value, bool):
+ raise ConfigError(f"{name} must be a number")
+ try:
+ result = float(value)
+ except (TypeError, ValueError) as exc:
+ raise ConfigError(f"{name} must be a number") from exc
+ if not isfinite(result) or result <= 0:
+ raise ConfigError(f"{name} must be > 0")
+ return result
+
+
+def _positive_int(value: Any, name: str) -> int:
+ if isinstance(value, bool):
+ raise ConfigError(f"{name} must be a positive integer")
+ try:
+ result = int(value)
+ except (TypeError, ValueError) as exc:
+ raise ConfigError(f"{name} must be a positive integer") from exc
+ if result != value or result <= 0:
+ raise ConfigError(f"{name} must be a positive integer")
+ return result
diff --git a/src/wavebench/services/frequency_response_baseline.py b/src/wavebench/services/frequency_response_baseline.py
new file mode 100644
index 0000000..0778ace
--- /dev/null
+++ b/src/wavebench/services/frequency_response_baseline.py
@@ -0,0 +1,204 @@
+from __future__ import annotations
+
+from dataclasses import asdict, dataclass, replace
+import json
+from math import isfinite
+from pathlib import Path
+from typing import Any, Iterable
+
+import numpy as np
+
+from wavebench.errors import ConfigError
+from wavebench.services.frequency_response import FrequencyResponsePoint
+
+
+BASELINE_MODES = ("complex_transfer", "phase_only", "delay_only")
+
+
+@dataclass(frozen=True)
+class FrequencyResponseBaselineConfig:
+ run_dir: str
+ response: str | None = None
+ mode: str = "complex_transfer"
+
+ def as_dict(self) -> dict[str, Any]:
+ return asdict(self)
+
+
+def normalize_frequency_response_baseline(
+ raw: Any, name: str = "baseline"
+) -> FrequencyResponseBaselineConfig:
+ if not isinstance(raw, dict):
+ raise ConfigError(f"{name} must be a TOML table")
+ unknown = sorted(set(raw) - {"run_dir", "response", "mode"})
+ if unknown:
+ raise ConfigError(f"{name} has unsupported keys: {', '.join(unknown)}")
+ run_dir = _text(raw.get("run_dir"), f"{name}.run_dir")
+ response = raw.get("response")
+ if response is not None:
+ response = _text(response, f"{name}.response")
+ mode = _text(raw.get("mode", "complex_transfer"), f"{name}.mode").lower()
+ if mode not in BASELINE_MODES:
+ raise ConfigError(f"{name}.mode must be one of: {', '.join(BASELINE_MODES)}")
+ return FrequencyResponseBaselineConfig(run_dir=run_dir, response=response, mode=mode)
+
+
+def apply_frequency_response_baseline(
+ points: list[FrequencyResponsePoint],
+ baseline_rows: Iterable[dict[str, Any]],
+ config: FrequencyResponseBaselineConfig | dict[str, Any],
+) -> tuple[list[FrequencyResponsePoint], dict[str, Any]]:
+ """Apply an auditable software baseline without replacing raw response fields."""
+ if isinstance(config, dict):
+ config = normalize_frequency_response_baseline(config)
+ baseline = _baseline_by_amplitude(baseline_rows)
+ corrected: list[FrequencyResponsePoint] = []
+ used_amplitudes: set[float | None] = set()
+ for point in points:
+ if point.status == "failed" or point.gain_db is None or point.phase_unwrapped_deg is None:
+ corrected.append(point)
+ continue
+ key, frequencies, gains, phases = _select_baseline_slice(baseline, point.requested_vpp)
+ frequency = point.requested_frequency_hz
+ if frequency < frequencies[0] or frequency > frequencies[-1]:
+ raise ConfigError(
+ "baseline frequency domain does not cover response point "
+ f"{frequency:.12g} Hz for requested_vpp {point.requested_vpp!r}"
+ )
+ x = np.log10(frequencies)
+ point_x = float(np.log10(frequency))
+ baseline_gain = float(np.interp(point_x, x, gains))
+ baseline_phase = float(np.interp(point_x, x, phases))
+ delay_s = _estimate_delay_s(frequencies, phases)
+ gain_db = point.gain_db
+ phase = point.phase_unwrapped_deg
+ if config.mode == "complex_transfer":
+ gain_db -= baseline_gain
+ phase -= baseline_phase
+ elif config.mode == "phase_only":
+ phase -= baseline_phase
+ else:
+ # phase slope = -360 * delay, so remove a positive measured delay
+ # by adding 360 * f * delay to the DUT's raw phase.
+ phase += 360.0 * frequency * delay_s
+ corrected.append(
+ replace(
+ point,
+ baseline_gain_db=baseline_gain,
+ baseline_phase_unwrapped_deg=baseline_phase,
+ gain_db_corrected=gain_db,
+ gain_linear_corrected=float(10.0 ** (gain_db / 20.0)),
+ phase_unwrapped_corrected_deg=phase,
+ phase_wrapped_corrected_deg=_wrap_phase_deg(phase),
+ )
+ )
+ used_amplitudes.add(key)
+ domains = {
+ str(key) if key is not None else "unspecified": [float(values[0][0]), float(values[0][-1])]
+ for key, values in baseline.items()
+ }
+ delays = {
+ str(key) if key is not None else "unspecified": _estimate_delay_s(values[0], values[2])
+ for key, values in baseline.items()
+ }
+ return corrected, {
+ "schema_version": 1,
+ "baseline": config.as_dict(),
+ "mode": config.mode,
+ "valid_domain_hz_by_requested_vpp": domains,
+ "estimated_delay_s_by_requested_vpp": delays,
+ "used_baseline_requested_vpp": sorted(value for value in used_amplitudes if value is not None),
+ }
+
+
+def write_frequency_response_baseline_json(path: str | Path, document: dict[str, Any]) -> Path:
+ """Write the software-correction provenance without touching instrument state."""
+ output = Path(path)
+ temporary = output.with_suffix(output.suffix + ".tmp")
+ temporary.write_text(json.dumps(document, indent=2, ensure_ascii=False), encoding="utf-8")
+ temporary.replace(output)
+ return output
+
+
+def _baseline_by_amplitude(
+ rows: Iterable[dict[str, Any]],
+) -> dict[float | None, tuple[np.ndarray, np.ndarray, np.ndarray]]:
+ grouped: dict[float | None, dict[float, tuple[float, float]]] = {}
+ for row in rows:
+ if str(row.get("status", "ok")).lower() == "failed":
+ continue
+ frequency = _float(row.get("requested_frequency_hz"))
+ gain = _first_float(row, "gain_db_corrected", "gain_db")
+ phase = _first_float(
+ row, "phase_unwrapped_corrected_deg", "phase_unwrapped_deg"
+ )
+ amplitude = _float(row.get("requested_vpp"))
+ if frequency is None or frequency <= 0 or gain is None or phase is None:
+ continue
+ grouped.setdefault(amplitude, {})[frequency] = (gain, phase)
+ result: dict[float | None, tuple[np.ndarray, np.ndarray, np.ndarray]] = {}
+ for amplitude, samples in grouped.items():
+ frequencies = np.asarray(sorted(samples), dtype=float)
+ if frequencies.size < 2:
+ continue
+ result[amplitude] = (
+ frequencies,
+ np.asarray([samples[value][0] for value in frequencies], dtype=float),
+ np.asarray([samples[value][1] for value in frequencies], dtype=float),
+ )
+ if not result:
+ raise ConfigError("baseline response has no usable gain and unwrapped-phase points")
+ return result
+
+
+def _select_baseline_slice(
+ baseline: dict[float | None, tuple[np.ndarray, np.ndarray, np.ndarray]],
+ requested_vpp: float | None,
+) -> tuple[float | None, np.ndarray, np.ndarray, np.ndarray]:
+ if requested_vpp in baseline:
+ frequencies, gains, phases = baseline[requested_vpp]
+ return requested_vpp, frequencies, gains, phases
+ if len(baseline) == 1:
+ key, values = next(iter(baseline.items()))
+ return key, *values
+ if requested_vpp is None and None in baseline:
+ frequencies, gains, phases = baseline[None]
+ return None, frequencies, gains, phases
+ choices = ", ".join(str(value) for value in sorted(value for value in baseline if value is not None))
+ raise ConfigError(
+ "baseline has multiple requested_vpp slices but no matching slice for "
+ f"{requested_vpp!r}; available: {choices}"
+ )
+
+
+def _estimate_delay_s(frequencies: np.ndarray, phases_deg: np.ndarray) -> float:
+ if frequencies.size < 2:
+ return 0.0
+ slope, _intercept = np.polyfit(frequencies, phases_deg, 1)
+ return float(-slope / 360.0)
+
+
+def _wrap_phase_deg(value: float) -> float:
+ return float((value + 180.0) % 360.0 - 180.0)
+
+
+def _float(value: Any) -> float | None:
+ try:
+ result = float(value)
+ except (TypeError, ValueError):
+ return None
+ return result if isfinite(result) else None
+
+
+def _first_float(row: dict[str, Any], *names: str) -> float | None:
+ for name in names:
+ value = _float(row.get(name))
+ if value is not None:
+ return value
+ return None
+
+
+def _text(value: Any, name: str) -> str:
+ if not isinstance(value, str) or not value.strip():
+ raise ConfigError(f"{name} must be a non-empty string")
+ return value.strip()
diff --git a/src/wavebench/services/frequency_response_calibration.py b/src/wavebench/services/frequency_response_calibration.py
index 8372293..493c9cb 100644
--- a/src/wavebench/services/frequency_response_calibration.py
+++ b/src/wavebench/services/frequency_response_calibration.py
@@ -2,8 +2,8 @@
import csv
import json
-from dataclasses import asdict, dataclass
-from math import isfinite, log2
+from dataclasses import asdict, dataclass, field
+from math import floor, isfinite, log2
from pathlib import Path
import tomllib
from typing import Any, Iterable
@@ -24,6 +24,30 @@
"correction_limited",
"slope_limited",
)
+FIXED_POINT_FORMATS = ("csv", "coe", "mem")
+FIXED_POINT_LAYOUTS = ("amplitude_major", "frequency_major")
+
+
+@dataclass(frozen=True)
+class FixedPointExportConfig:
+ enabled: bool = True
+ formats: tuple[str, ...] = FIXED_POINT_FORMATS
+ word_width: int = 16
+ fractional_bits: int = 12
+ layout: str = "amplitude_major"
+ rounding: str = "nearest"
+ overflow: str = "error"
+
+ def as_dict(self) -> dict[str, Any]:
+ return {
+ "enabled": self.enabled,
+ "formats": list(self.formats),
+ "word_width": self.word_width,
+ "fractional_bits": self.fractional_bits,
+ "layout": self.layout,
+ "rounding": self.rounding,
+ "overflow": self.overflow,
+ }
@dataclass(frozen=True)
@@ -39,9 +63,13 @@ class FrequencyResponseCalibrationConfig:
max_slope_db_per_octave: float = 6.0
chebyshev_degree: int = 3
chebyshev_segment_count: int = 8
+ fixed_point: FixedPointExportConfig | None = field(default_factory=FixedPointExportConfig)
def as_dict(self) -> dict[str, Any]:
- return asdict(self)
+ result = asdict(self)
+ if self.fixed_point is not None:
+ result["fixed_point"] = self.fixed_point.as_dict()
+ return result
def normalize_frequency_response_calibration(
@@ -61,6 +89,7 @@ def normalize_frequency_response_calibration(
"max_slope_db_per_octave",
"chebyshev_degree",
"chebyshev_segment_count",
+ "fixed_point",
}
unknown = sorted(set(raw) - allowed)
if unknown:
@@ -97,6 +126,11 @@ def normalize_frequency_response_calibration(
segments = _positive_int(
raw.get("chebyshev_segment_count", 8), f"{name}.chebyshev_segment_count"
)
+ fixed_point = (
+ normalize_fixed_point_export(raw["fixed_point"], f"{name}.fixed_point")
+ if "fixed_point" in raw
+ else FixedPointExportConfig()
+ )
return FrequencyResponseCalibrationConfig(
enabled=enabled,
model=model,
@@ -109,6 +143,47 @@ def normalize_frequency_response_calibration(
max_slope_db_per_octave=slope,
chebyshev_degree=degree,
chebyshev_segment_count=segments,
+ fixed_point=fixed_point,
+ )
+
+
+def normalize_fixed_point_export(raw: Any, name: str = "fixed_point") -> FixedPointExportConfig:
+ if not isinstance(raw, dict):
+ raise ConfigError(f"{name} must be a TOML table")
+ allowed = {"enabled", "formats", "word_width", "fractional_bits", "layout", "rounding", "overflow"}
+ unknown = sorted(set(raw) - allowed)
+ if unknown:
+ raise ConfigError(f"{name} has unsupported keys: {', '.join(unknown)}")
+ enabled = _bool(raw.get("enabled", True), f"{name}.enabled")
+ formats_raw = raw.get("formats", list(FIXED_POINT_FORMATS))
+ if not isinstance(formats_raw, (list, tuple)) or not formats_raw:
+ raise ConfigError(f"{name}.formats must be a non-empty array")
+ formats = tuple(_text(value, f"{name}.formats").lower() for value in formats_raw)
+ if any(value not in FIXED_POINT_FORMATS for value in formats) or len(set(formats)) != len(formats):
+ raise ConfigError(f"{name}.formats must use unique values from: {', '.join(FIXED_POINT_FORMATS)}")
+ width = _positive_int(raw.get("word_width", 16), f"{name}.word_width")
+ if width < 2 or width > 32:
+ raise ConfigError(f"{name}.word_width must be from 2 through 32")
+ fractional = _nonnegative_int(raw.get("fractional_bits", 12), f"{name}.fractional_bits")
+ if fractional >= width - 1:
+ raise ConfigError(f"{name}.fractional_bits must leave at least one signed integer bit")
+ layout = _text(raw.get("layout", "amplitude_major"), f"{name}.layout").lower()
+ if layout not in FIXED_POINT_LAYOUTS:
+ raise ConfigError(f"{name}.layout must be one of: {', '.join(FIXED_POINT_LAYOUTS)}")
+ rounding = _text(raw.get("rounding", "nearest"), f"{name}.rounding").lower()
+ if rounding != "nearest":
+ raise ConfigError(f"{name}.rounding must be 'nearest'")
+ overflow = _text(raw.get("overflow", "error"), f"{name}.overflow").lower()
+ if overflow not in {"error", "saturate"}:
+ raise ConfigError(f"{name}.overflow must be 'error' or 'saturate'")
+ return FixedPointExportConfig(
+ enabled=enabled,
+ formats=formats,
+ word_width=width,
+ fractional_bits=fractional,
+ layout=layout,
+ rounding=rounding,
+ overflow=overflow,
)
@@ -250,6 +325,70 @@ def write_frequency_response_calibration_json(path: str | Path, document: dict[s
return output
+def write_fixed_point_calibration(
+ output_dir: str | Path,
+ document: dict[str, Any],
+ rows: Iterable[dict[str, Any]],
+ config: FixedPointExportConfig | None,
+) -> dict[str, Path]:
+ """Write auditable signed fixed-point correction values and Xilinx memory files."""
+ if config is None or not config.enabled:
+ return {}
+ output = Path(output_dir)
+ ordered = _ordered_lut_rows(rows, layout=config.layout)
+ encoded = [_encode_fixed_point(float(row["correction_linear"]), config) for row in ordered]
+ width_hex = (config.word_width + 3) // 4
+ audit_rows = []
+ for linear_index, (row, encoded_value) in enumerate(zip(ordered, encoded)):
+ quantized, raw = encoded_value
+ audit_rows.append(
+ {
+ "linear_index": linear_index,
+ "amplitude_index": row["amplitude_index"],
+ "frequency_index": row["frequency_index"],
+ "requested_vpp": row["requested_vpp"],
+ "frequency_hz": row["frequency_hz"],
+ "correction_linear": row["correction_linear"],
+ "quantized_integer": quantized,
+ "hex": f"{raw:0{width_hex}X}",
+ "quantized_correction_linear": quantized / (1 << config.fractional_bits),
+ "quantization_error": quantized / (1 << config.fractional_bits) - float(row["correction_linear"]),
+ }
+ )
+ paths: dict[str, Path] = {}
+ if "csv" in config.formats:
+ path = output / "frequency_response_calibration_fixed.csv"
+ _write_csv(path, audit_rows)
+ paths["csv"] = path
+ words = [row["hex"] for row in audit_rows]
+ if "mem" in config.formats:
+ path = output / "frequency_response_calibration_q.mem"
+ _atomic_text(path, "\n".join(words) + "\n")
+ paths["mem"] = path
+ if "coe" in config.formats:
+ path = output / "frequency_response_calibration_q.coe"
+ _atomic_text(
+ path,
+ "memory_initialization_radix=16;\n"
+ "memory_initialization_vector=\n"
+ + ",\n".join(words)
+ + ";\n",
+ )
+ paths["coe"] = path
+ errors = [abs(float(row["quantization_error"])) for row in audit_rows]
+ document["fixed_point"] = {
+ "configuration": config.as_dict(),
+ "q_format": f"Q{config.word_width - config.fractional_bits}.{config.fractional_bits}",
+ "encoding": "signed two's complement",
+ "linear_index": "amplitude_index * frequency_count + frequency_index"
+ if config.layout == "amplitude_major"
+ else "frequency_index * amplitude_count + amplitude_index",
+ "outputs": {key: str(value) for key, value in paths.items()},
+ "max_abs_quantization_error": max(errors, default=0.0),
+ }
+ return paths
+
+
def _measurement_groups(rows: Iterable[dict[str, Any]]) -> dict[float, list[tuple[float, float]]]:
groups: dict[float, list[tuple[float, float]]] = {}
for row in rows:
@@ -257,7 +396,7 @@ def _measurement_groups(rows: Iterable[dict[str, Any]]) -> dict[float, list[tupl
continue
frequency = _row_float(row, "requested_frequency_hz")
amplitude = _row_float(row, "requested_vpp")
- gain_db = _row_float(row, "gain_db")
+ gain_db = _first_row_float(row, "gain_db_corrected", "gain_db")
if gain_db is None:
gain = _row_float(row, "gain_linear")
gain_db = 20.0 * np.log10(gain) if gain is not None and gain > 0 else None
@@ -430,6 +569,63 @@ def _row_float(row: dict[str, Any], name: str) -> float | None:
return value if isfinite(value) else None
+def _first_row_float(row: dict[str, Any], *names: str) -> float | None:
+ for name in names:
+ value = _row_float(row, name)
+ if value is not None:
+ return value
+ return None
+
+
+def _ordered_lut_rows(rows: Iterable[dict[str, Any]], *, layout: str) -> list[dict[str, Any]]:
+ materialized = [dict(row) for row in rows]
+ amplitudes = sorted({float(row["requested_vpp"]) for row in materialized})
+ frequencies = sorted({float(row["frequency_hz"]) for row in materialized})
+ amplitude_index = {value: index for index, value in enumerate(amplitudes)}
+ frequency_index = {value: index for index, value in enumerate(frequencies)}
+ for row in materialized:
+ row["amplitude_index"] = amplitude_index[float(row["requested_vpp"])]
+ row["frequency_index"] = frequency_index[float(row["frequency_hz"])]
+ key = (
+ (lambda row: (row["amplitude_index"], row["frequency_index"]))
+ if layout == "amplitude_major"
+ else (lambda row: (row["frequency_index"], row["amplitude_index"]))
+ )
+ return sorted(materialized, key=key)
+
+
+def _encode_fixed_point(value: float, config: FixedPointExportConfig) -> tuple[int, int]:
+ scale = 1 << config.fractional_bits
+ # "nearest" deliberately means ordinary half-away-from-zero rounding here,
+ # not NumPy's banker's rounding: the emitted files are hardware contracts.
+ scaled = (1 if value >= 0 else -1) * int(floor(abs(value) * scale + 0.5))
+ minimum = -(1 << (config.word_width - 1))
+ maximum = (1 << (config.word_width - 1)) - 1
+ if scaled < minimum or scaled > maximum:
+ if config.overflow == "error":
+ raise ConfigError(
+ f"fixed-point overflow for correction_linear {value:.12g} in "
+ f"Q{config.word_width - config.fractional_bits}.{config.fractional_bits}"
+ )
+ scaled = min(max(scaled, minimum), maximum)
+ return scaled, scaled & ((1 << config.word_width) - 1)
+
+
+def _write_csv(path: Path, rows: list[dict[str, Any]]) -> None:
+ temporary = path.with_suffix(path.suffix + ".tmp")
+ with temporary.open("w", newline="", encoding="utf-8") as file:
+ writer = csv.DictWriter(file, fieldnames=list(rows[0]) if rows else ["linear_index"])
+ writer.writeheader()
+ writer.writerows(rows)
+ temporary.replace(path)
+
+
+def _atomic_text(path: Path, text: str) -> None:
+ temporary = path.with_suffix(path.suffix + ".tmp")
+ temporary.write_text(text, encoding="utf-8")
+ temporary.replace(path)
+
+
def _bool(value: Any, name: str) -> bool:
if not isinstance(value, bool):
raise ConfigError(f"{name} must be true or false")
@@ -479,3 +675,15 @@ def _positive_int(value: Any, name: str) -> int:
if result != value or result <= 0:
raise ConfigError(f"{name} must be a positive integer")
return result
+
+
+def _nonnegative_int(value: Any, name: str) -> int:
+ if isinstance(value, bool):
+ raise ConfigError(f"{name} must be a non-negative integer")
+ try:
+ result = int(value)
+ except (TypeError, ValueError) as exc:
+ raise ConfigError(f"{name} must be a non-negative integer") from exc
+ if result != value or result < 0:
+ raise ConfigError(f"{name} must be a non-negative integer")
+ return result
diff --git a/src/wavebench/services/run_plan.py b/src/wavebench/services/run_plan.py
index cd9f584..825437d 100644
--- a/src/wavebench/services/run_plan.py
+++ b/src/wavebench/services/run_plan.py
@@ -10,6 +10,8 @@
from wavebench.config import normalize_waveform_points
from wavebench.errors import ConfigError
from wavebench.services.frequency_response import FIT_METHODS
+from wavebench.services.frequency_response_adaptive import normalize_frequency_response_adaptive
+from wavebench.services.frequency_response_baseline import normalize_frequency_response_baseline
from wavebench.services.frequency_response_calibration import normalize_frequency_response_calibration
@@ -88,6 +90,8 @@
"vpp_step",
"autoscale_each_amplitude",
"calibration",
+ "baseline",
+ "adaptive",
},
"source.status": {"channel"},
"source.set_freq": {"channel"},
@@ -445,8 +449,12 @@ def _normalize_step_fields(index: int, kind: str, fields: dict[str, Any]) -> Non
def _validate_frequency_response_steps(steps: list[RunStep]) -> None:
response_steps = [step for step in steps if step.kind == "sweep.frequency_response"]
- if len(response_steps) > 1:
- raise ConfigError("a run plan may contain at most one sweep.frequency_response step")
+ labels: set[str] = set()
+ for step in response_steps:
+ label = str(step.fields.get("label", f"frequency_response_{step.index:02d}"))
+ if label in labels:
+ raise ConfigError(f"sweep.frequency_response labels must be unique: {label!r}")
+ labels.add(label)
def _normalize_frequency_response_fields(prefix: str, fields: dict[str, Any]) -> None:
@@ -526,6 +534,18 @@ def _normalize_frequency_response_fields(prefix: str, fields: dict[str, Any]) ->
fields["calibration"] = normalize_frequency_response_calibration(
fields["calibration"], f"{prefix}.calibration"
).as_dict()
+ if "baseline" in fields:
+ fields["baseline"] = normalize_frequency_response_baseline(
+ fields["baseline"], f"{prefix}.baseline"
+ ).as_dict()
+ if "adaptive" in fields:
+ fields["adaptive"] = normalize_frequency_response_adaptive(
+ fields["adaptive"], f"{prefix}.adaptive"
+ ).as_dict()
+ if fields["adaptive"]["max_frequency_points"] < len(fields["frequencies_hz"]):
+ raise ConfigError(
+ f"{prefix}.adaptive.max_frequency_points must be at least the initial frequency count"
+ )
def _normalize_frequency_response_amplitudes(prefix: str, fields: dict[str, Any]) -> None:
diff --git a/src/wavebench/services/run_service.py b/src/wavebench/services/run_service.py
index f4976e3..ab4996a 100644
--- a/src/wavebench/services/run_service.py
+++ b/src/wavebench/services/run_service.py
@@ -2,6 +2,8 @@
import shutil
import time
+import json
+from math import isfinite
from contextlib import ExitStack, contextmanager
from dataclasses import dataclass
from pathlib import Path
@@ -9,6 +11,7 @@
from wavebench.config import WaveBenchConfig
from wavebench.data.package import new_package_dir
+from wavebench.data.packages import load_run_package
from wavebench.errors import ConfigError
from wavebench.instruments.capabilities import require_capabilities
from wavebench.instruments.registry import resolve_instrument_descriptor
@@ -24,9 +27,16 @@
write_fit_document,
write_frequency_response_csv,
)
+from wavebench.services.frequency_response_adaptive import select_adaptive_frequency_refinement
+from wavebench.services.frequency_response_baseline import (
+ apply_frequency_response_baseline,
+ write_frequency_response_baseline_json,
+)
from wavebench.services.frequency_response_calibration import (
build_frequency_response_calibration,
ensure_calibration_dependencies,
+ normalize_frequency_response_calibration,
+ write_fixed_point_calibration,
write_frequency_response_calibration_csv,
write_frequency_response_calibration_json,
)
@@ -142,8 +152,70 @@ def verify(self, plan: RunPlan) -> list[RunPreflightRecord]:
def check(self, plan: RunPlan) -> None:
check_run_plan_safety_limits(plan, self.config.safety_limits)
reject_unsupported_steps(plan)
+ self._check_frequency_response_baselines(plan)
self._check_plan_capabilities(plan)
+ def _check_frequency_response_baselines(self, plan: RunPlan) -> None:
+ """Validate referenced baseline evidence offline, before an instrument is opened."""
+ for step in plan.steps:
+ baseline = step.fields.get("baseline") if step.kind == "sweep.frequency_response" else None
+ if not baseline:
+ continue
+ response = self._load_baseline_response(plan, baseline)
+ if response.csv_path is None:
+ raise ConfigError(f"baseline response {response.label!r} has no frequency_response.csv")
+ groups: dict[float | None, list[float]] = {}
+ for row in response.rows:
+ if str(row.get("status", "ok")).lower() == "failed":
+ continue
+ try:
+ frequency = float(row.get("requested_frequency_hz"))
+ except (TypeError, ValueError):
+ continue
+ gain = _first_finite_row_value(row, "gain_db_corrected", "gain_db")
+ phase = _first_finite_row_value(
+ row, "phase_unwrapped_corrected_deg", "phase_unwrapped_deg"
+ )
+ mode = baseline.get("mode", "complex_transfer")
+ if (mode == "complex_transfer" and (gain is None or phase is None)) or (
+ mode in {"phase_only", "delay_only"} and phase is None
+ ):
+ continue
+ try:
+ amplitude = float(row["requested_vpp"])
+ except (KeyError, TypeError, ValueError):
+ amplitude = None
+ if frequency > 0:
+ groups.setdefault(amplitude, []).append(frequency)
+ if not groups:
+ raise ConfigError(f"baseline response {response.label!r} has no valid frequency rows")
+ requested_amplitudes = step.fields.get("amplitudes_vpp") or [None]
+ for amplitude in requested_amplitudes:
+ values = groups.get(amplitude)
+ if values is None and len(groups) == 1:
+ values = next(iter(groups.values()))
+ if values is None:
+ choices = ", ".join(str(value) for value in groups)
+ raise ConfigError(
+ f"baseline response {response.label!r} has no requested_vpp slice for "
+ f"{amplitude!r}; available: {choices}"
+ )
+ lower, upper = min(values), max(values)
+ for frequency in step.fields["frequencies_hz"]:
+ if frequency < lower or frequency > upper:
+ raise ConfigError(
+ f"baseline response {response.label!r} does not cover {frequency:.12g} Hz "
+ f"for requested_vpp {amplitude!r}; valid domain is {lower:.12g}..{upper:.12g} Hz"
+ )
+
+ @staticmethod
+ def _load_baseline_response(plan: RunPlan, baseline: dict[str, Any]):
+ run_dir = Path(baseline["run_dir"])
+ if not run_dir.is_absolute():
+ run_dir = plan.path.parent / run_dir
+ package = load_run_package(run_dir)
+ return package.select_frequency_response(baseline.get("response"))
+
def _check_plan_capabilities(self, plan: RunPlan) -> None:
required: dict[str, set[str]] = {}
@@ -284,11 +356,13 @@ def run(self, plan: RunPlan) -> RunResult:
record = self._run_step(plan, step, run_dir=run_dir, services=services)
records.append(record)
write_step_record(steps_dir, record)
+ self._update_frequency_responses_manifest(run_dir, record)
except Exception as exc:
failure = exc
if isinstance(exc, _FrequencyResponseExecutionError):
records.append(exc.record)
write_step_record(steps_dir, exc.record)
+ self._update_frequency_responses_manifest(run_dir, exc.record)
failure = exc.cause
restore_error = restore_source_state(
restore_state,
@@ -432,8 +506,12 @@ def _run_step(
artifact = self._run_scope_capture_step(plan, step, services=services)
elif step.kind == "sweep.frequency_response":
artifact = self._run_frequency_response_step(
+ plan,
step,
run_dir=run_dir,
+ multiple_responses=sum(
+ item.kind == "sweep.frequency_response" for item in plan.steps
+ ) > 1,
services=services,
)
elif step.kind == "dmm.read":
@@ -457,24 +535,39 @@ def _run_step(
def _run_frequency_response_step(
self,
+ plan: RunPlan,
step: RunStep,
*,
run_dir: Path,
+ multiple_responses: bool,
services: RunInstrumentServices | None = None,
) -> dict[str, Any]:
source_channel = step.fields.get("source_channel")
source = self._source_service(services=services)
- csv_path = run_dir / "frequency_response.csv"
- fit_path = run_dir / "frequency_response_fit.json"
- calibration_csv_path = run_dir / "frequency_response_calibration.csv"
- calibration_json_path = run_dir / "frequency_response_calibration.json"
+ label = step.fields.get("label", f"frequency_response_{step.index:02d}")
+ response_dir = self._frequency_response_directory(
+ run_dir, step.index, label, multiple_responses=multiple_responses
+ )
+ response_dir.mkdir(parents=True, exist_ok=True)
+ csv_path = response_dir / "frequency_response.csv"
+ fit_path = response_dir / "frequency_response_fit.json"
+ baseline_path = response_dir / "frequency_response_baseline.json"
+ calibration_csv_path = response_dir / "frequency_response_calibration.csv"
+ calibration_json_path = response_dir / "frequency_response_calibration.json"
points = []
reference_channel = step.fields["reference_channel"]
response_channel = step.fields["response_channel"]
tolerance = step.fields.get(
"frequency_tolerance", self.config.waveform.frequency_tolerance_ratio
)
- label = step.fields.get("label", f"frequency_response_{step.index:02d}")
+ adaptive_config = step.fields.get("adaptive")
+ adaptive_summary: dict[str, Any] | None = None
+ baseline_document: dict[str, Any] | None = None
+ baseline_response = (
+ self._load_baseline_response(plan, step.fields["baseline"])
+ if step.fields.get("baseline")
+ else None
+ )
try:
source_status = source.status(channel=source_channel)
@@ -491,6 +584,8 @@ def _run_frequency_response_step(
points=points,
csv_path=csv_path,
fit_path=None,
+ label=label,
+ response_dir=response_dir,
source_channel=source_channel,
reference_channel=reference_channel,
response_channel=response_channel,
@@ -498,150 +593,177 @@ def _run_frequency_response_step(
requested_amplitudes = step.fields.get("amplitudes_vpp") or [None]
point_index = 0
- for amplitude_index, requested_vpp in enumerate(requested_amplitudes):
- if requested_vpp is not None:
- try:
- source_status = source.set_amplitude_vpp(
- channel=source_channel, value_vpp=requested_vpp
- )
- except Exception as exc:
- write_frequency_response_csv(csv_path, points)
- raise self._frequency_response_execution_error(
- step,
- exc,
- points=points,
- csv_path=csv_path,
- fit_path=None,
- source_channel=source_channel,
- reference_channel=reference_channel,
- response_channel=response_channel,
- ) from exc
- if str(source_status.output).strip().upper() != "ON":
- error = ConfigError(
- f"source output is {source_status.output} after setting {requested_vpp:.12g} Vpp"
- )
- write_frequency_response_csv(csv_path, points)
- raise self._frequency_response_execution_error(
- step,
- error,
- points=points,
- csv_path=csv_path,
- fit_path=None,
- source_channel=source_channel,
- reference_channel=reference_channel,
- response_channel=response_channel,
- ) from error
- for frequency_index, frequency_hz in enumerate(step.fields["frequencies_hz"]):
- try:
- source_status = source.set_frequency(channel=source_channel, value_hz=frequency_hz)
- except Exception as exc:
- points.append(
- failed_frequency_response_point(
- index=point_index,
- amplitude_index=amplitude_index,
- requested_vpp=requested_vpp,
- requested_frequency_hz=frequency_hz,
- error=exc,
+ pending = [
+ (frequency_hz, 0, None, None)
+ for frequency_hz in step.fields["frequencies_hz"]
+ ]
+ refinement_levels = 0
+ budget_limited = False
+ while pending:
+ current_level = pending[0][1]
+ for amplitude_index, requested_vpp in enumerate(requested_amplitudes):
+ if requested_vpp is not None:
+ try:
+ source_status = source.set_amplitude_vpp(
+ channel=source_channel, value_vpp=requested_vpp
)
- )
- write_frequency_response_csv(csv_path, points)
- raise self._frequency_response_execution_error(
- step,
- exc,
- points=points,
- csv_path=csv_path,
- fit_path=None,
- source_channel=source_channel,
- reference_channel=reference_channel,
- response_channel=response_channel,
- ) from exc
- if str(source_status.output).strip().upper() != "ON":
- error = ConfigError(
- f"source output is {source_status.output} after setting {frequency_hz:.12g} Hz"
- )
- points.append(
- failed_frequency_response_point(
- index=point_index,
- amplitude_index=amplitude_index,
- requested_vpp=requested_vpp,
- requested_frequency_hz=frequency_hz,
- error=error,
+ except Exception as exc:
+ write_frequency_response_csv(csv_path, points)
+ raise self._frequency_response_execution_error(
+ step, exc, points=points, csv_path=csv_path, fit_path=None,
+ label=label, response_dir=response_dir, source_channel=source_channel,
+ reference_channel=reference_channel, response_channel=response_channel,
+ ) from exc
+ if str(source_status.output).strip().upper() != "ON":
+ error = ConfigError(
+ f"source output is {source_status.output} after setting {requested_vpp:.12g} Vpp"
)
- )
- write_frequency_response_csv(csv_path, points)
- raise self._frequency_response_execution_error(
- step,
- error,
- points=points,
- csv_path=csv_path,
- fit_path=None,
- source_channel=source_channel,
- reference_channel=reference_channel,
- response_channel=response_channel,
- ) from error
- if step.fields["settle_s"]:
- time.sleep(step.fields["settle_s"])
-
- scope = self._scope_service_for_frequency_response(
- step,
- frequency_hz=frequency_hz,
- services=services,
- )
- if frequency_index == 0 and step.fields.get("autoscale_each_amplitude"):
- scope.autoscale()
+ write_frequency_response_csv(csv_path, points)
+ raise self._frequency_response_execution_error(
+ step, error, points=points, csv_path=csv_path, fit_path=None,
+ label=label, response_dir=response_dir, source_channel=source_channel,
+ reference_channel=reference_channel, response_channel=response_channel,
+ ) from error
+ for frequency_index, (frequency_hz, adaptive_level, parent_start, parent_stop) in enumerate(pending):
+ try:
+ source_status = source.set_frequency(channel=source_channel, value_hz=frequency_hz)
+ except Exception as exc:
+ points.append(failed_frequency_response_point(
+ index=point_index, amplitude_index=amplitude_index, requested_vpp=requested_vpp,
+ requested_frequency_hz=frequency_hz, error=exc, adaptive_level=adaptive_level,
+ adaptive_parent_start_hz=parent_start, adaptive_parent_stop_hz=parent_stop,
+ ))
+ write_frequency_response_csv(csv_path, points)
+ raise self._frequency_response_execution_error(
+ step, exc, points=points, csv_path=csv_path, fit_path=None,
+ label=label, response_dir=response_dir, source_channel=source_channel,
+ reference_channel=reference_channel, response_channel=response_channel,
+ ) from exc
+ if str(source_status.output).strip().upper() != "ON":
+ error = ConfigError(
+ f"source output is {source_status.output} after setting {frequency_hz:.12g} Hz"
+ )
+ points.append(failed_frequency_response_point(
+ index=point_index, amplitude_index=amplitude_index, requested_vpp=requested_vpp,
+ requested_frequency_hz=frequency_hz, error=error, adaptive_level=adaptive_level,
+ adaptive_parent_start_hz=parent_start, adaptive_parent_stop_hz=parent_stop,
+ ))
+ write_frequency_response_csv(csv_path, points)
+ raise self._frequency_response_execution_error(
+ step, error, points=points, csv_path=csv_path, fit_path=None,
+ label=label, response_dir=response_dir, source_channel=source_channel,
+ reference_channel=reference_channel, response_channel=response_channel,
+ ) from error
if step.fields["settle_s"]:
time.sleep(step.fields["settle_s"])
- try:
- amplitude_label = (
- f"{label}_{point_index:03d}_{frequency_hz:.12g}hz"
- if requested_vpp is None
- else f"{label}_a{amplitude_index:02d}_{requested_vpp:.12g}vpp_"
- f"{frequency_index:03d}_{frequency_hz:.12g}hz"
+ scope = self._scope_service_for_frequency_response(
+ step, frequency_hz=frequency_hz, services=services
)
- capture = scope.capture_waveforms(
- channels=[reference_channel, response_channel],
- label=amplitude_label,
- )
- points.append(
- analyze_frequency_response_point(
- index=point_index,
- amplitude_index=amplitude_index,
- requested_vpp=requested_vpp,
+ if current_level == 0 and frequency_index == 0 and step.fields.get("autoscale_each_amplitude"):
+ scope.autoscale()
+ if step.fields["settle_s"]:
+ time.sleep(step.fields["settle_s"])
+ try:
+ amplitude_label = (
+ f"{label}_{point_index:03d}_{frequency_hz:.12g}hz"
+ if requested_vpp is None
+ else f"{label}_a{amplitude_index:02d}_{requested_vpp:.12g}vpp_"
+ f"l{adaptive_level}_{frequency_index:03d}_{frequency_hz:.12g}hz"
+ )
+ capture = scope.capture_waveforms(
+ channels=[reference_channel, response_channel], label=amplitude_label
+ )
+ points.append(analyze_frequency_response_point(
+ index=point_index, amplitude_index=amplitude_index, requested_vpp=requested_vpp,
requested_frequency_hz=frequency_hz,
reference_waveform=capture.waveforms[reference_channel],
response_waveform=capture.waveforms[response_channel],
- frequency_tolerance_ratio=tolerance,
- capture_package=str(capture.package_dir),
- metadata_path=str(capture.metadata_path),
- )
- )
- except Exception as exc: # noqa: BLE001 - retain failed points and continue the sweep
- points.append(
- failed_frequency_response_point(
- index=point_index,
- amplitude_index=amplitude_index,
- requested_vpp=requested_vpp,
- requested_frequency_hz=frequency_hz,
- error=exc,
- )
+ frequency_tolerance_ratio=tolerance, capture_package=str(capture.package_dir),
+ metadata_path=str(capture.metadata_path), adaptive_level=adaptive_level,
+ adaptive_parent_start_hz=parent_start, adaptive_parent_stop_hz=parent_stop,
+ ))
+ except Exception as exc: # noqa: BLE001 - retain failed points and continue the sweep
+ points.append(failed_frequency_response_point(
+ index=point_index, amplitude_index=amplitude_index, requested_vpp=requested_vpp,
+ requested_frequency_hz=frequency_hz, error=exc, adaptive_level=adaptive_level,
+ adaptive_parent_start_hz=parent_start, adaptive_parent_stop_hz=parent_stop,
+ ))
+ point_index += 1
+ write_frequency_response_csv(csv_path, points)
+
+ points = unwrap_frequency_response_phase(points)
+ if baseline_response is not None:
+ try:
+ points, baseline_document = apply_frequency_response_baseline(
+ points, baseline_response.rows, step.fields["baseline"]
)
- point_index += 1
- write_frequency_response_csv(csv_path, points)
+ baseline_document["baseline_response_directory"] = str(baseline_response.directory)
+ baseline_document["baseline_response"] = baseline_response.label
+ baseline_document["baseline_csv"] = str(baseline_response.csv_path)
+ except Exception as exc:
+ write_frequency_response_csv(csv_path, points)
+ raise self._frequency_response_execution_error(
+ step, exc, points=points, csv_path=csv_path, fit_path=None,
+ label=label, response_dir=response_dir, source_channel=source_channel,
+ reference_channel=reference_channel, response_channel=response_channel,
+ ) from exc
+ if not adaptive_config or not adaptive_config.get("enabled", True):
+ break
+ refinement = select_adaptive_frequency_refinement(
+ points,
+ spacing=step.fields.get("spacing", "log"),
+ level=current_level + 1,
+ config=adaptive_config,
+ existing_frequencies_hz={point.requested_frequency_hz for point in points},
+ )
+ budget_limited = budget_limited or refinement.budget_limited
+ if not refinement.frequencies:
+ break
+ refinement_levels += 1
+ pending = [
+ (item.frequency_hz, item.level, item.parent_start_hz, item.parent_stop_hz)
+ for item in refinement.frequencies
+ ]
points = unwrap_frequency_response_phase(points)
+ if baseline_response is not None:
+ points, baseline_document = apply_frequency_response_baseline(
+ points, baseline_response.rows, step.fields["baseline"]
+ )
+ baseline_document["baseline_response_directory"] = str(baseline_response.directory)
+ baseline_document["baseline_response"] = baseline_response.label
+ baseline_document["baseline_csv"] = str(baseline_response.csv_path)
+ written_baseline_path = (
+ write_frequency_response_baseline_json(baseline_path, baseline_document)
+ if baseline_document is not None
+ else None
+ )
+ if adaptive_config:
+ adaptive_summary = {
+ "configuration": adaptive_config,
+ "initial_frequency_count": len(step.fields["frequencies_hz"]),
+ "refinement_levels_completed": refinement_levels,
+ "final_frequency_count": len({point.requested_frequency_hz for point in points}),
+ "budget_limited": budget_limited,
+ }
fit_document, fit_values = build_fit_document(points, step.fields.get("fit"))
write_frequency_response_csv(csv_path, points, fit_values)
written_fit_path = write_fit_document(fit_path, fit_document)
calibration = step.fields.get("calibration")
written_calibration_csv: Path | None = None
written_calibration_json: Path | None = None
+ fixed_point_paths: dict[str, Path] = {}
calibration_error: str | None = None
if calibration and calibration.get("enabled", True):
try:
+ calibration_config = normalize_frequency_response_calibration(calibration)
document, rows = build_frequency_response_calibration(
- [point.as_csv_row() for point in points], calibration, source_csv=csv_path
+ [point.as_csv_row() for point in points], calibration_config, source_csv=csv_path
)
written_calibration_csv = write_frequency_response_calibration_csv(calibration_csv_path, rows)
+ fixed_point_paths = write_fixed_point_calibration(
+ response_dir, document, rows, calibration_config.fixed_point
+ )
written_calibration_json = write_frequency_response_calibration_json(
calibration_json_path, document
)
@@ -651,11 +773,16 @@ def _run_frequency_response_step(
points=points,
csv_path=csv_path,
fit_path=written_fit_path,
+ label=label,
+ response_dir=response_dir,
source_channel=source_channel,
reference_channel=reference_channel,
response_channel=response_channel,
+ baseline_json_path=written_baseline_path,
+ adaptive=adaptive_summary,
calibration_csv_path=written_calibration_csv,
calibration_json_path=written_calibration_json,
+ fixed_point_paths=fixed_point_paths,
calibration_error=calibration_error,
)
@@ -667,21 +794,31 @@ def _frequency_response_execution_error(
points: list[Any],
csv_path: Path,
fit_path: Path | None,
+ label: str,
+ response_dir: Path,
source_channel: int | None,
reference_channel: int,
response_channel: int,
+ baseline_json_path: Path | None = None,
+ adaptive: dict[str, Any] | None = None,
calibration_csv_path: Path | None = None,
calibration_json_path: Path | None = None,
+ fixed_point_paths: dict[str, Path] | None = None,
) -> _FrequencyResponseExecutionError:
artifact = self._frequency_response_artifact(
points=points,
csv_path=csv_path,
fit_path=fit_path,
+ label=label,
+ response_dir=response_dir,
source_channel=source_channel,
reference_channel=reference_channel,
response_channel=response_channel,
+ baseline_json_path=baseline_json_path,
+ adaptive=adaptive,
calibration_csv_path=calibration_csv_path,
calibration_json_path=calibration_json_path,
+ fixed_point_paths=fixed_point_paths,
error=cause,
)
record = RunStepRecord(
@@ -699,11 +836,16 @@ def _frequency_response_artifact(
points: list[Any],
csv_path: Path,
fit_path: Path | None,
+ label: str,
+ response_dir: Path,
source_channel: int | None,
reference_channel: int,
response_channel: int,
+ baseline_json_path: Path | None = None,
+ adaptive: dict[str, Any] | None = None,
calibration_csv_path: Path | None = None,
calibration_json_path: Path | None = None,
+ fixed_point_paths: dict[str, Path] | None = None,
calibration_error: str | None = None,
error: Exception | None = None,
) -> dict[str, Any]:
@@ -724,8 +866,14 @@ def _frequency_response_artifact(
else ("warning" if warning_points or calibration_error else "ok"),
"csv": str(csv_path),
"fit_json": str(fit_path) if fit_path is not None else "",
+ "label": label,
+ "directory": str(response_dir),
+ "baseline_json": str(baseline_json_path) if baseline_json_path is not None else "",
"calibration_csv": str(calibration_csv_path) if calibration_csv_path is not None else "",
"calibration_json": str(calibration_json_path) if calibration_json_path is not None else "",
+ "fixed_point": {
+ key: str(value) for key, value in (fixed_point_paths or {}).items()
+ },
"point_count": len(points),
"failed_point_count": failed_points,
"warning_point_count": warning_points,
@@ -738,8 +886,71 @@ def _frequency_response_artifact(
response["error"] = f"{type(error).__name__}: {error}"
if calibration_error is not None:
response["calibration_error"] = calibration_error
+ if adaptive is not None:
+ response["adaptive"] = adaptive
return {"frequency_response": response}
+ @staticmethod
+ def _frequency_response_directory(
+ run_dir: Path, step_index: int, label: str, *, multiple_responses: bool
+ ) -> Path:
+ if not multiple_responses:
+ return run_dir
+ safe_label = "".join(
+ character if character.isalnum() or character in {"-", "_"} else "_"
+ for character in label
+ ).strip("._")
+ if not safe_label:
+ safe_label = f"response_{step_index:02d}"
+ return run_dir / "frequency_response" / f"{step_index:02d}_{safe_label}"
+
+ def _update_frequency_responses_manifest(self, run_dir: Path, record: RunStepRecord) -> None:
+ if record.kind != "sweep.frequency_response":
+ return
+ response = record.artifact.get("frequency_response")
+ if not isinstance(response, dict):
+ return
+ directory_text = response.get("directory")
+ if not isinstance(directory_text, str) or not directory_text:
+ return
+ directory = Path(directory_text)
+ try:
+ relative_directory = directory.resolve().relative_to(run_dir.resolve())
+ except ValueError:
+ return
+ manifest_path = run_dir / "frequency_responses.json"
+ existing: dict[str, Any] = {"schema_version": 1, "responses": []}
+ if manifest_path.exists():
+ try:
+ candidate = json.loads(manifest_path.read_text(encoding="utf-8"))
+ if isinstance(candidate, dict) and isinstance(candidate.get("responses"), list):
+ existing = candidate
+ except json.JSONDecodeError:
+ pass
+ entry = {
+ "step_index": record.index,
+ "label": str(response.get("label", f"frequency_response_{record.index:02d}")),
+ "directory": str(relative_directory) if str(relative_directory) else ".",
+ "csv": "frequency_response.csv",
+ "fit_json": "frequency_response_fit.json" if response.get("fit_json") else "",
+ "baseline_json": "frequency_response_baseline.json" if response.get("baseline_json") else "",
+ "calibration_csv": "frequency_response_calibration.csv" if response.get("calibration_csv") else "",
+ "calibration_json": "frequency_response_calibration.json" if response.get("calibration_json") else "",
+ "status": response.get("status", record.status),
+ "adaptive": response.get("adaptive"),
+ "fixed_point": response.get("fixed_point", {}),
+ }
+ entries = [item for item in existing["responses"] if not (
+ isinstance(item, dict) and item.get("label") == entry["label"]
+ )]
+ entries.append(entry)
+ entries.sort(key=lambda item: int(item.get("step_index", 0)))
+ existing["schema_version"] = 1
+ existing["responses"] = entries
+ temporary = manifest_path.with_suffix(manifest_path.suffix + ".tmp")
+ temporary.write_text(json.dumps(existing, indent=2, ensure_ascii=False), encoding="utf-8")
+ temporary.replace(manifest_path)
+
def _run_scope_capture_step(
self,
plan: RunPlan,
@@ -977,3 +1188,14 @@ def _status_payload(status: Any) -> dict[str, Any]:
if hasattr(status, "__dict__"):
return dict(status.__dict__)
return {"repr": repr(status)}
+
+
+def _first_finite_row_value(row: dict[str, Any], *names: str) -> float | None:
+ for name in names:
+ try:
+ value = float(row.get(name))
+ except (TypeError, ValueError):
+ continue
+ if isfinite(value):
+ return value
+ return None
diff --git a/tests/test_cli.py b/tests/test_cli.py
index a781969..c6f01d0 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -68,6 +68,36 @@ def test_run_calibrate_builds_offline_artifacts_without_loading_instruments(self
self.assertTrue((run_dir / "frequency_response_calibration.json").exists())
self.assertIn("calibration_csv=", stdout.getvalue())
+ def test_run_calibrate_requires_response_selector_for_manifest_with_multiple_responses(self):
+ with TemporaryDirectory() as tmp:
+ run_dir = Path(tmp) / "run"
+ run_dir.mkdir()
+ (run_dir / "run.json").write_text(json.dumps({"status": "ok", "steps": []}), encoding="utf-8")
+ entries = []
+ for label in ("a", "b"):
+ directory = run_dir / "frequency_response" / label
+ directory.mkdir(parents=True)
+ rows = ["index,requested_frequency_hz,requested_vpp,gain_db,status"]
+ index = 0
+ for amplitude in (0.05, 0.1):
+ for frequency in (100, 1000, 10000, 100000):
+ rows.append(f"{index},{frequency},{amplitude},1,ok")
+ index += 1
+ (directory / "frequency_response.csv").write_text("\n".join(rows), encoding="utf-8")
+ entries.append({"step_index": len(entries), "label": label, "directory": f"frequency_response/{label}"})
+ (run_dir / "frequency_responses.json").write_text(
+ json.dumps({"schema_version": 1, "responses": entries}), encoding="utf-8"
+ )
+ calibration = Path(tmp) / "calibration.toml"
+ calibration.write_text("[calibration]\ntarget_mode = 'unity_gain'\n", encoding="utf-8")
+
+ self.assertEqual(main(["run", "calibrate", str(run_dir), "--config", str(calibration)]), 2)
+ self.assertEqual(
+ main(["run", "calibrate", str(run_dir), "--config", str(calibration), "--response", "b"]),
+ 0,
+ )
+ self.assertTrue((run_dir / "frequency_response" / "b" / "frequency_response_calibration.json").exists())
+
def test_capture_accepts_points_and_output_flags(self):
args = build_parser().parse_args([
"scope", "capture", "--points", "def", "--time-range", "0.01", "--window-frequency", "500", "--target-cycles", "10", "--expect-frequency", "500", "--frequency-tolerance", "0.1", "--no-csv", "--label", "x"
diff --git a/tests/test_frequency_response_adaptive.py b/tests/test_frequency_response_adaptive.py
new file mode 100644
index 0000000..df29f50
--- /dev/null
+++ b/tests/test_frequency_response_adaptive.py
@@ -0,0 +1,63 @@
+from __future__ import annotations
+
+import unittest
+
+from wavebench.services.frequency_response import FrequencyResponsePoint
+from wavebench.services.frequency_response_adaptive import select_adaptive_frequency_refinement
+
+
+def _point(index: int, frequency_hz: float, gain_db: float, phase_deg: float, *, amplitude_index: int = 0):
+ return FrequencyResponsePoint(
+ index=index,
+ requested_frequency_hz=frequency_hz,
+ reference_frequency_hz=frequency_hz,
+ response_frequency_hz=frequency_hz,
+ reference_amplitude_peak_v=1.0,
+ response_amplitude_peak_v=1.0,
+ reference_vpp_v=2.0,
+ response_vpp_v=2.0,
+ gain_linear=1.0,
+ gain_db=gain_db,
+ phase_wrapped_deg=phase_deg,
+ phase_unwrapped_deg=phase_deg,
+ status="ok",
+ amplitude_index=amplitude_index,
+ )
+
+
+class FrequencyResponseAdaptiveTests(unittest.TestCase):
+ def test_gain_or_phase_change_refines_every_amplitude_slice_as_a_rectangle(self):
+ points = [
+ _point(0, 100.0, 0.0, 0.0, amplitude_index=0),
+ _point(1, 1000.0, 1.0, 0.0, amplitude_index=0),
+ _point(2, 100.0, 0.0, 0.0, amplitude_index=1),
+ _point(3, 1000.0, 0.0, 20.0, amplitude_index=1),
+ ]
+ result = select_adaptive_frequency_refinement(
+ points,
+ spacing="log",
+ level=1,
+ config={"enabled": True, "max_levels": 2, "max_frequency_points": 10},
+ existing_frequencies_hz={100.0, 1000.0},
+ )
+
+ self.assertEqual(len(result.frequencies), 1)
+ self.assertAlmostEqual(result.frequencies[0].frequency_hz, 10**2.5)
+ self.assertEqual(result.frequencies[0].level, 1)
+
+ def test_linear_midpoint_and_budget_are_auditable(self):
+ points = [
+ _point(0, 100.0, 0.0, 0.0),
+ _point(1, 200.0, 1.0, 0.0),
+ _point(2, 300.0, 2.0, 0.0),
+ ]
+ result = select_adaptive_frequency_refinement(
+ points,
+ spacing="linear",
+ level=1,
+ config={"enabled": True, "max_frequency_points": 4},
+ existing_frequencies_hz={100.0, 200.0, 300.0},
+ )
+
+ self.assertEqual([item.frequency_hz for item in result.frequencies], [150.0])
+ self.assertTrue(result.budget_limited)
diff --git a/tests/test_frequency_response_baseline.py b/tests/test_frequency_response_baseline.py
new file mode 100644
index 0000000..3d48dc2
--- /dev/null
+++ b/tests/test_frequency_response_baseline.py
@@ -0,0 +1,98 @@
+from __future__ import annotations
+
+import unittest
+
+import numpy as np
+
+from wavebench.errors import ConfigError
+from wavebench.services.frequency_response import FrequencyResponsePoint
+from wavebench.services.frequency_response_baseline import apply_frequency_response_baseline
+
+
+def _point(index: int, frequency_hz: float, gain_db: float, phase_deg: float, *, vpp: float | None = 0.1):
+ return FrequencyResponsePoint(
+ index=index,
+ requested_frequency_hz=frequency_hz,
+ reference_frequency_hz=frequency_hz,
+ response_frequency_hz=frequency_hz,
+ reference_amplitude_peak_v=1.0,
+ response_amplitude_peak_v=10.0 ** (gain_db / 20.0),
+ reference_vpp_v=2.0,
+ response_vpp_v=2.0,
+ gain_linear=10.0 ** (gain_db / 20.0),
+ gain_db=gain_db,
+ phase_wrapped_deg=(phase_deg + 180.0) % 360.0 - 180.0,
+ phase_unwrapped_deg=phase_deg,
+ status="ok",
+ requested_vpp=vpp,
+ )
+
+
+class FrequencyResponseBaselineTests(unittest.TestCase):
+ def test_complex_transfer_preserves_raw_evidence_and_subtracts_log_interpolated_baseline(self):
+ points = [_point(0, 1_000.0, 8.0, 35.0), _point(1, 10_000.0, 10.0, 55.0)]
+ baseline = [
+ {"status": "ok", "requested_vpp": 0.1, "requested_frequency_hz": 1_000, "gain_db": 2, "phase_unwrapped_deg": 5},
+ {"status": "ok", "requested_vpp": 0.1, "requested_frequency_hz": 10_000, "gain_db": 4, "phase_unwrapped_deg": 15},
+ ]
+
+ corrected, document = apply_frequency_response_baseline(
+ points, baseline, {"run_dir": "baseline", "mode": "complex_transfer"}
+ )
+
+ self.assertEqual([point.gain_db for point in corrected], [8.0, 10.0])
+ self.assertEqual([point.phase_unwrapped_deg for point in corrected], [35.0, 55.0])
+ self.assertEqual([point.gain_db_corrected for point in corrected], [6.0, 6.0])
+ self.assertEqual([point.phase_unwrapped_corrected_deg for point in corrected], [30.0, 40.0])
+ self.assertAlmostEqual(corrected[0].gain_linear_corrected or 0.0, 10.0 ** (6.0 / 20.0))
+ self.assertEqual(document["mode"], "complex_transfer")
+ self.assertEqual(document["valid_domain_hz_by_requested_vpp"]["0.1"], [1000.0, 10000.0])
+
+ def test_delay_only_estimates_and_removes_linear_phase_delay(self):
+ delay_s = 2e-6
+ frequencies = (1_000.0, 10_000.0)
+ baseline = [
+ {
+ "status": "ok",
+ "requested_frequency_hz": frequency,
+ "gain_db": 0,
+ "phase_unwrapped_deg": -360.0 * frequency * delay_s,
+ }
+ for frequency in frequencies
+ ]
+ points = [_point(index, frequency, 3.0, -360.0 * frequency * delay_s - 20.0, vpp=None)
+ for index, frequency in enumerate(frequencies)]
+
+ corrected, document = apply_frequency_response_baseline(
+ points, baseline, {"run_dir": "baseline", "mode": "delay_only"}
+ )
+
+ self.assertTrue(np.allclose(
+ [point.phase_unwrapped_corrected_deg for point in corrected], [-20.0, -20.0]
+ ))
+ self.assertAlmostEqual(document["estimated_delay_s_by_requested_vpp"]["unspecified"], delay_s)
+
+ def test_phase_only_keeps_gain_and_corrects_only_phase(self):
+ corrected, _document = apply_frequency_response_baseline(
+ [_point(0, 1_000.0, 6.0, 30.0)],
+ [
+ {"status": "ok", "requested_vpp": 0.1, "requested_frequency_hz": 100, "gain_db": 1, "phase_unwrapped_deg": 10},
+ {"status": "ok", "requested_vpp": 0.1, "requested_frequency_hz": 10_000, "gain_db": 1, "phase_unwrapped_deg": 10},
+ ],
+ {"run_dir": "baseline", "mode": "phase_only"},
+ )
+
+ self.assertEqual(corrected[0].gain_db, 6.0)
+ self.assertEqual(corrected[0].gain_db_corrected, 6.0)
+ self.assertEqual(corrected[0].phase_unwrapped_corrected_deg, 20.0)
+
+ def test_refuses_to_extrapolate_outside_baseline_domain(self):
+ with self.assertRaisesRegex(ConfigError, "does not cover"):
+ apply_frequency_response_baseline(
+ [_point(0, 20_000.0, 1.0, 0.0)],
+ [
+ {"status": "ok", "requested_vpp": 0.1, "requested_frequency_hz": 1_000, "gain_db": 0, "phase_unwrapped_deg": 0},
+ {"status": "ok", "requested_vpp": 0.1, "requested_frequency_hz": 10_000, "gain_db": 0, "phase_unwrapped_deg": 0},
+ ],
+ {"run_dir": "baseline"},
+ )
diff --git a/tests/test_frequency_response_calibration.py b/tests/test_frequency_response_calibration.py
index 0ff81a3..2c36ee9 100644
--- a/tests/test_frequency_response_calibration.py
+++ b/tests/test_frequency_response_calibration.py
@@ -1,14 +1,18 @@
from __future__ import annotations
import unittest
+from pathlib import Path
+from tempfile import TemporaryDirectory
import numpy as np
from wavebench.errors import ConfigError
from wavebench.services.frequency_response_calibration import (
+ FixedPointExportConfig,
FrequencyResponseCalibrationConfig,
build_frequency_response_calibration,
normalize_frequency_response_calibration,
+ write_fixed_point_calibration,
)
@@ -69,3 +73,47 @@ def test_normalizes_explicit_target_and_rejects_incomplete_target(self):
self.assertEqual(config.target_gain_db, -1.5)
with self.assertRaisesRegex(ConfigError, "target_gain_db is required"):
normalize_frequency_response_calibration({"target_mode": "explicit_gain_db"})
+
+ def test_writes_q412_two_complement_coe_mem_and_amplitude_major_audit(self):
+ rows = [
+ {"requested_vpp": 0.2, "frequency_hz": 2_000.0, "correction_linear": -1.5},
+ {"requested_vpp": 0.1, "frequency_hz": 2_000.0, "correction_linear": 1.0},
+ {"requested_vpp": 0.1, "frequency_hz": 1_000.0, "correction_linear": 2.0},
+ {"requested_vpp": 0.2, "frequency_hz": 1_000.0, "correction_linear": 0.5},
+ ]
+ document: dict[str, object] = {}
+ with TemporaryDirectory() as tmp:
+ paths = write_fixed_point_calibration(tmp, document, rows, FixedPointExportConfig())
+ words = (Path(paths["mem"]).read_text(encoding="utf-8").splitlines())
+ audit = (Path(paths["csv"]).read_text(encoding="utf-8").splitlines())
+ coe = Path(paths["coe"]).read_text(encoding="utf-8")
+
+ self.assertEqual(words, ["2000", "1000", "0800", "E800"])
+ self.assertIn("linear_index,amplitude_index,frequency_index", audit[0])
+ self.assertIn("memory_initialization_radix=16", coe)
+ self.assertEqual(document["fixed_point"]["q_format"], "Q4.12")
+ self.assertEqual(
+ document["fixed_point"]["linear_index"],
+ "amplitude_index * frequency_count + frequency_index",
+ )
+
+ def test_fixed_point_overflow_errors_unless_saturation_is_explicit(self):
+ rows = [{"requested_vpp": 0.1, "frequency_hz": 1_000.0, "correction_linear": 8.0}]
+ with TemporaryDirectory() as tmp:
+ with self.assertRaisesRegex(ConfigError, "fixed-point overflow"):
+ write_fixed_point_calibration(tmp, {}, rows, FixedPointExportConfig())
+ paths = write_fixed_point_calibration(
+ tmp, {}, rows, FixedPointExportConfig(formats=("mem",), overflow="saturate")
+ )
+ self.assertEqual(Path(paths["mem"]).read_text(encoding="utf-8"), "7FFF\n")
+
+ def test_fixed_point_nearest_uses_half_away_from_zero(self):
+ rows = [
+ {"requested_vpp": 0.1, "frequency_hz": 1_000.0, "correction_linear": 0.5 / 4096},
+ {"requested_vpp": 0.2, "frequency_hz": 1_000.0, "correction_linear": -0.5 / 4096},
+ ]
+ with TemporaryDirectory() as tmp:
+ paths = write_fixed_point_calibration(
+ tmp, {}, rows, FixedPointExportConfig(formats=("mem",))
+ )
+ self.assertEqual(Path(paths["mem"]).read_text(encoding="utf-8"), "0001\nFFFF\n")
diff --git a/tests/test_packages.py b/tests/test_packages.py
index 1d3f422..0afbbd6 100644
--- a/tests/test_packages.py
+++ b/tests/test_packages.py
@@ -124,6 +124,36 @@ def test_load_run_package_reads_frequency_response_calibration_artifacts(self):
self.assertEqual(loaded.frequency_response_calibration_rows[0]["requested_vpp"], "0.1")
self.assertEqual(loaded.frequency_response_calibration["target_gain_db"], 0)
+ def test_load_run_package_reads_multi_response_manifest_and_requires_a_selector(self):
+ with TemporaryDirectory() as tmp:
+ run = Path(tmp)
+ (run / "run.json").write_text(json.dumps({"status": "ok", "steps": []}), encoding="utf-8")
+ for label in ("low", "high"):
+ directory = run / "frequency_response" / label
+ directory.mkdir(parents=True)
+ (directory / "frequency_response.csv").write_text(
+ "index,requested_frequency_hz,gain_db,status\n0,100,1,ok\n", encoding="utf-8"
+ )
+ (run / "frequency_responses.json").write_text(
+ json.dumps(
+ {
+ "schema_version": 1,
+ "responses": [
+ {"step_index": 0, "label": "low", "directory": "frequency_response/low"},
+ {"step_index": 1, "label": "high", "directory": "frequency_response/high"},
+ ],
+ }
+ ),
+ encoding="utf-8",
+ )
+
+ loaded = load_run_package(run)
+
+ self.assertEqual([item.label for item in loaded.frequency_responses], ["low", "high"])
+ self.assertEqual(loaded.select_frequency_response("high").rows[0]["gain_db"], "1")
+ with self.assertRaisesRegex(ConfigError, "specify --response"):
+ loaded.select_frequency_response()
+
if __name__ == "__main__":
unittest.main()
diff --git a/tests/test_report.py b/tests/test_report.py
index a88e202..1778dc3 100644
--- a/tests/test_report.py
+++ b/tests/test_report.py
@@ -883,6 +883,43 @@ def test_run_report_renders_two_dimensional_calibration_summary_and_charts(self)
self.assertIn("Representative slices", html)
self.assertIn("frequency_response_calibration.csv", html)
+ def test_run_report_renders_each_manifest_frequency_response_with_raw_and_corrected_curves(self):
+ with TemporaryDirectory() as tmp:
+ run_dir = Path(tmp) / "run"
+ run_dir.mkdir()
+ (run_dir / "run.json").write_text(json.dumps({"status": "ok", "steps": []}), encoding="utf-8")
+ entries = []
+ for index, label in enumerate(("input", "output")):
+ directory = run_dir / "frequency_response" / label
+ directory.mkdir(parents=True)
+ (directory / "frequency_response.csv").write_text(
+ "index,requested_frequency_hz,gain_db,phase_unwrapped_deg,gain_db_corrected,phase_unwrapped_corrected_deg,status\n"
+ "0,100,1,-10,0,-5,ok\n1,1000,2,-20,1,-15,ok\n",
+ encoding="utf-8",
+ )
+ (directory / "frequency_response_baseline.json").write_text(
+ json.dumps({"mode": "complex_transfer", "baseline_response": "through"}), encoding="utf-8"
+ )
+ entries.append(
+ {
+ "step_index": index,
+ "label": label,
+ "directory": f"frequency_response/{label}",
+ "baseline_json": "frequency_response_baseline.json",
+ }
+ )
+ (run_dir / "frequency_responses.json").write_text(
+ json.dumps({"schema_version": 1, "responses": entries}), encoding="utf-8"
+ )
+
+ html = render_run_report_html(load_run_package(run_dir), output_dir=run_dir)
+
+ self.assertIn("Frequency response — input", html)
+ self.assertIn("Frequency response — output", html)
+ self.assertIn("Raw magnitude", html)
+ self.assertIn("Corrected magnitude", html)
+ self.assertIn("complex_transfer", html)
+
def test_pdf_report_uses_output_directory_as_resource_base(self):
with TemporaryDirectory() as tmp:
run_dir = Path(tmp) / "run"
diff --git a/tests/test_run_plan.py b/tests/test_run_plan.py
index a301a26..b0704f1 100644
--- a/tests/test_run_plan.py
+++ b/tests/test_run_plan.py
@@ -391,7 +391,7 @@ def test_frequency_response_plan_rejects_conflicting_channels_and_frequencies(se
with self.assertRaisesRegex(ConfigError, "either frequencies_hz"):
load_run_plan(mixed_frequency_forms)
- def test_frequency_response_plan_rejects_bad_frequency_lists_and_multiple_steps(self):
+ def test_frequency_response_plan_rejects_bad_frequency_lists_and_allows_unique_multiple_steps(self):
duplicate_frequencies = self._write_plan("""
[[steps]]
kind = "sweep.frequency_response"
@@ -415,8 +415,46 @@ def test_frequency_response_plan_rejects_bad_frequency_lists_and_multiple_steps(
response_channel = 4
frequencies_hz = [100, 1000]
""")
- with self.assertRaisesRegex(ConfigError, "at most one"):
- load_run_plan(multiple_steps)
+ self.assertEqual(len(load_run_plan(multiple_steps).steps), 2)
+
+ def test_frequency_response_plan_rejects_duplicate_response_labels_and_accepts_adaptive_baseline(self):
+ duplicate_labels = self._write_plan("""
+[[steps]]
+kind = "sweep.frequency_response"
+label = "same"
+reference_channel = 1
+response_channel = 2
+frequencies_hz = [100, 1000]
+
+[[steps]]
+kind = "sweep.frequency_response"
+label = "same"
+reference_channel = 3
+response_channel = 4
+frequencies_hz = [100, 1000]
+""")
+ with self.assertRaisesRegex(ConfigError, "labels must be unique"):
+ load_run_plan(duplicate_labels)
+
+ plan = load_run_plan(self._write_plan("""
+[[steps]]
+kind = "sweep.frequency_response"
+reference_channel = 1
+response_channel = 2
+frequencies_hz = [100, 1000]
+
+[steps.baseline]
+run_dir = "baseline-run"
+
+[steps.adaptive]
+enabled = true
+gain_threshold_db = 0.25
+phase_threshold_deg = 5
+max_levels = 2
+max_frequency_points = 20
+"""))
+ self.assertEqual(plan.steps[0].fields["baseline"]["mode"], "complex_transfer")
+ self.assertEqual(plan.steps[0].fields["adaptive"]["max_frequency_points"], 20)
def test_frequency_response_fit_rejects_unknown_method_and_high_degree(self):
unknown_method = self._write_plan("""
diff --git a/tests/test_run_service.py b/tests/test_run_service.py
index 10b3b1a..79d12b7 100644
--- a/tests/test_run_service.py
+++ b/tests/test_run_service.py
@@ -1276,6 +1276,9 @@ def test_frequency_response_multiple_vpp_slices_autoscale_and_write_calibration(
self.assertEqual([row["amplitude_index"] for row in rows[4:]], ["1"] * 4)
self.assertTrue((result.run_dir / "frequency_response_calibration.csv").exists())
self.assertTrue((result.run_dir / "frequency_response_calibration.json").exists())
+ self.assertTrue((result.run_dir / "frequency_response_calibration_fixed.csv").exists())
+ self.assertTrue((result.run_dir / "frequency_response_calibration_q.coe").exists())
+ self.assertTrue((result.run_dir / "frequency_response_calibration_q.mem").exists())
response = result.steps[0].artifact["frequency_response"]
self.assertTrue(response["calibration_csv"])
@@ -1422,6 +1425,128 @@ def test_frequency_response_source_failure_keeps_csv_step_record_and_restore(sel
],
)
+ def test_multiple_frequency_responses_write_independent_directories_and_manifest(self):
+ with TemporaryDirectory() as tmp:
+ plan = load_run_plan(write_plan(tmp, """
+[[steps]]
+kind = "sweep.frequency_response"
+label = "input_path"
+reference_channel = 1
+response_channel = 2
+frequencies_hz = [100, 1000]
+settle_s = 0
+
+[[steps]]
+kind = "sweep.frequency_response"
+label = "output_path"
+reference_channel = 1
+response_channel = 2
+frequencies_hz = [100, 1000]
+settle_s = 0
+"""))
+ captures = [
+ fake_frequency_response_capture(tmp, f"multi_{index}", frequency_hz=frequency)
+ for index, frequency in enumerate([100, 1000, 100, 1000])
+ ]
+ status = SimpleNamespace(output="ON")
+ with patch("wavebench.services.run_service.ScopeService") as scope_cls, patch(
+ "wavebench.services.run_service.SourceService"
+ ) as source_cls:
+ source_cls.return_value.status.return_value = status
+ source_cls.return_value.set_frequency.return_value = status
+ scope_cls.return_value.capture_waveforms.side_effect = captures
+
+ result = RunService(config=make_config(tmp), logger=CommandLogger()).run(plan)
+
+ manifest = json.loads((result.run_dir / "frequency_responses.json").read_text(encoding="utf-8"))
+ self.assertEqual([item["label"] for item in manifest["responses"]], ["input_path", "output_path"])
+ self.assertTrue((result.run_dir / "frequency_response" / "00_input_path" / "frequency_response.csv").exists())
+ self.assertTrue((result.run_dir / "frequency_response" / "01_output_path" / "frequency_response.csv").exists())
+
+ def test_adaptive_frequency_response_adds_midpoint_for_all_vpp_slices(self):
+ with TemporaryDirectory() as tmp:
+ plan = load_run_plan(write_plan(tmp, """
+[[steps]]
+kind = "sweep.frequency_response"
+reference_channel = 1
+response_channel = 2
+frequencies_hz = [100, 1000]
+amplitudes_vpp = [0.05, 0.1]
+settle_s = 0
+
+[steps.adaptive]
+gain_threshold_db = 0.5
+phase_threshold_deg = 10
+max_levels = 1
+max_frequency_points = 3
+"""))
+ captures = [
+ fake_frequency_response_capture(tmp, f"adaptive_{index}", frequency_hz=frequency, gain=gain)
+ for index, (frequency, gain) in enumerate(
+ [(100, 1), (1000, 2), (100, 1), (1000, 2), (10**2.5, 1.5), (10**2.5, 1.5)]
+ )
+ ]
+ status = SimpleNamespace(output="ON")
+ with patch("wavebench.services.run_service.ScopeService") as scope_cls, patch(
+ "wavebench.services.run_service.SourceService"
+ ) as source_cls:
+ source = source_cls.return_value
+ source.status.return_value = status
+ source.set_amplitude_vpp.return_value = status
+ source.set_frequency.return_value = status
+ scope_cls.return_value.capture_waveforms.side_effect = captures
+
+ result = RunService(config=make_config(tmp), logger=CommandLogger()).run(plan)
+
+ rows = list(csv.DictReader((result.run_dir / "frequency_response.csv").open(encoding="utf-8")))
+ self.assertEqual(len(rows), 6)
+ self.assertEqual(sum(row["adaptive_level"] == "1" for row in rows), 2)
+ self.assertEqual({row["requested_vpp"] for row in rows if row["adaptive_level"] == "1"}, {"0.05", "0.1"})
+ response = result.steps[0].artifact["frequency_response"]
+ self.assertEqual(response["adaptive"]["final_frequency_count"], 3)
+
+ def test_frequency_response_applies_referenced_software_baseline_without_rewriting_raw_columns(self):
+ with TemporaryDirectory() as tmp:
+ baseline_dir = Path(tmp) / "baseline"
+ baseline_dir.mkdir()
+ (baseline_dir / "run.json").write_text(json.dumps({"status": "ok", "steps": []}), encoding="utf-8")
+ (baseline_dir / "frequency_response.csv").write_text(
+ "requested_vpp,requested_frequency_hz,gain_db,phase_unwrapped_deg,status\n"
+ "0.1,100,1,10,ok\n0.1,1000,1,10,ok\n",
+ encoding="utf-8",
+ )
+ plan = load_run_plan(write_plan(tmp, """
+[[steps]]
+kind = "sweep.frequency_response"
+reference_channel = 1
+response_channel = 2
+amplitudes_vpp = [0.1]
+frequencies_hz = [100, 1000]
+settle_s = 0
+
+[steps.baseline]
+run_dir = "baseline"
+mode = "complex_transfer"
+"""))
+ status = SimpleNamespace(output="ON")
+ with patch("wavebench.services.run_service.ScopeService") as scope_cls, patch(
+ "wavebench.services.run_service.SourceService"
+ ) as source_cls:
+ source = source_cls.return_value
+ source.status.return_value = status
+ source.set_amplitude_vpp.return_value = status
+ source.set_frequency.return_value = status
+ scope_cls.return_value.capture_waveforms.side_effect = [
+ fake_frequency_response_capture(tmp, "baseline_dut_100", frequency_hz=100, gain=2, phase_deg=0),
+ fake_frequency_response_capture(tmp, "baseline_dut_1000", frequency_hz=1000, gain=2, phase_deg=0),
+ ]
+ result = RunService(config=make_config(tmp), logger=CommandLogger()).run(plan)
+
+ rows = list(csv.DictReader((result.run_dir / "frequency_response.csv").open(encoding="utf-8")))
+ self.assertTrue(all(row["gain_db"] for row in rows))
+ self.assertTrue(all(row["gain_db_corrected"] for row in rows))
+ self.assertTrue((result.run_dir / "frequency_response_baseline.json").exists())
+
if __name__ == "__main__":
unittest.main()
From 29866bf79b2b709e18b706471226e7b18c223146 Mon Sep 17 00:00:00 2001
From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com>
Date: Wed, 5 Aug 2026 10:55:46 +0800
Subject: [PATCH 6/9] feat: improve frequency response report traces
---
plans/passive_filter_raw_2d_10hz_1mhz.toml | 48 ++++
...e_filter_raw_2d_10hz_1mhz_500mv_1v_2v.toml | 48 ++++
src/wavebench/report/html.py | 216 ++++++++++++++++--
tests/test_report.py | 42 +++-
4 files changed, 337 insertions(+), 17 deletions(-)
create mode 100644 plans/passive_filter_raw_2d_10hz_1mhz.toml
create mode 100644 plans/passive_filter_raw_2d_10hz_1mhz_500mv_1v_2v.toml
diff --git a/plans/passive_filter_raw_2d_10hz_1mhz.toml b/plans/passive_filter_raw_2d_10hz_1mhz.toml
new file mode 100644
index 0000000..06d7f9f
--- /dev/null
+++ b/plans/passive_filter_raw_2d_10hz_1mhz.toml
@@ -0,0 +1,48 @@
+# 原始二维扫频:不加载任何直通基线、LUT 或软件校正。
+# 3 个 Vpp 切片 × 101 个对数频点 = 303 个独立测量组合。
+
+[experiment]
+name = "passive_filter_raw_2d_10hz_1mhz"
+label = "passive_filter_raw_2d_10hz_1mhz"
+
+[restore]
+source_state = true
+source_channel = 1
+
+[[steps]]
+kind = "source.set_func"
+channel = 1
+function = "sin"
+
+[[steps]]
+kind = "source.set_vpp"
+channel = 1
+value_vpp = 0.001
+
+[[steps]]
+kind = "source.set_freq"
+channel = 1
+frequency_hz = 10
+
+[[steps]]
+kind = "source.output"
+channel = 1
+state = "on"
+
+[[steps]]
+kind = "sweep.frequency_response"
+label = "passive_filter_raw_2d"
+source_channel = 1
+reference_channel = 1
+response_channel = 2
+start_frequency_hz = 10
+stop_frequency_hz = 1000000
+frequency_count = 101
+spacing = "log"
+amplitudes_vpp = [0.001, 0.002, 0.5]
+autoscale_each_amplitude = true
+target_cycles = 10
+settle_s = 1.0
+points = "def"
+save_csv = false
+screenshot = false
diff --git a/plans/passive_filter_raw_2d_10hz_1mhz_500mv_1v_2v.toml b/plans/passive_filter_raw_2d_10hz_1mhz_500mv_1v_2v.toml
new file mode 100644
index 0000000..80022e3
--- /dev/null
+++ b/plans/passive_filter_raw_2d_10hz_1mhz_500mv_1v_2v.toml
@@ -0,0 +1,48 @@
+# 原始二维扫频:不加载任何直通基线、LUT 或软件校正。
+# 3 个 Vpp 切片 × 101 个对数频点 = 303 个独立测量组合。
+
+[experiment]
+name = "passive_filter_raw_2d_10hz_1mhz_500mv_1v_2v"
+label = "passive_filter_raw_2d_10hz_1mhz_500mv_1v_2v"
+
+[restore]
+source_state = true
+source_channel = 1
+
+[[steps]]
+kind = "source.set_func"
+channel = 1
+function = "sin"
+
+[[steps]]
+kind = "source.set_vpp"
+channel = 1
+value_vpp = 0.5
+
+[[steps]]
+kind = "source.set_freq"
+channel = 1
+frequency_hz = 10
+
+[[steps]]
+kind = "source.output"
+channel = 1
+state = "on"
+
+[[steps]]
+kind = "sweep.frequency_response"
+label = "passive_filter_raw_2d"
+source_channel = 1
+reference_channel = 1
+response_channel = 2
+start_frequency_hz = 10
+stop_frequency_hz = 1000000
+frequency_count = 101
+spacing = "log"
+amplitudes_vpp = [0.5, 1.0, 2.0]
+autoscale_each_amplitude = true
+target_cycles = 10
+settle_s = 1.0
+points = "def"
+save_csv = false
+screenshot = false
diff --git a/src/wavebench/report/html.py b/src/wavebench/report/html.py
index 2099aac..9331ba7 100644
--- a/src/wavebench/report/html.py
+++ b/src/wavebench/report/html.py
@@ -3,6 +3,7 @@
from dataclasses import dataclass
from html import escape
import json
+import math
import os
from pathlib import Path
from typing import Any
@@ -795,36 +796,51 @@ def _frequency_response_section(
response: FrequencyResponsePackage, *, include_table: bool, multiple: bool
) -> str:
rows = response.rows
- raw_gain_blocks = _response_blocks(rows, "gain_db")
- raw_phase_blocks = _response_blocks(rows, "phase_unwrapped_deg")
- corrected_gain_blocks = _response_blocks(rows, "gain_db_corrected")
- corrected_phase_blocks = _response_blocks(rows, "phase_unwrapped_corrected_deg")
+ raw_gain_measurements = _response_amplitude_series(rows, "gain_db")
+ raw_phase_measurements = _response_amplitude_series(rows, "phase_unwrapped_deg")
+ corrected_gain_measurements = _response_amplitude_series(rows, "gain_db_corrected")
+ corrected_phase_measurements = _response_amplitude_series(rows, "phase_unwrapped_corrected_deg")
+ raw_gain_blocks = [] if raw_gain_measurements else _response_blocks(rows, "gain_db")
+ raw_phase_blocks = [] if raw_phase_measurements else _response_blocks(rows, "phase_unwrapped_deg")
+ corrected_gain_blocks = [] if corrected_gain_measurements else _response_blocks(rows, "gain_db_corrected")
+ corrected_phase_blocks = [] if corrected_phase_measurements else _response_blocks(rows, "phase_unwrapped_corrected_deg")
fit_series = _fit_curve_series(response.fit)
gain_svg = _response_svg(
raw_gain_blocks,
title="原始幅频 / Raw magnitude response",
y_label="Gain (dB)",
series=(),
+ measured_series=raw_gain_measurements,
+ actual_label="",
)
phase_svg = _response_svg(
raw_phase_blocks,
title="原始相频 / Raw phase response",
y_label="Phase (deg, unwrapped)",
series=(),
+ measured_series=raw_phase_measurements,
+ actual_label="",
)
corrected_gain_svg = _response_svg(
corrected_gain_blocks,
title="校正幅频 / Corrected magnitude response",
y_label="Gain (dB)",
series=(),
- ) if corrected_gain_blocks else '未配置软件基线校正 / No software baseline correction.
'
+ measured_series=corrected_gain_measurements,
+ actual_label="",
+ ) if corrected_gain_blocks or corrected_gain_measurements else '未配置软件基线校正 / No software baseline correction.
'
corrected_phase_svg = _response_svg(
corrected_phase_blocks,
title="校正相频 / Corrected phase response",
y_label="Phase (deg, unwrapped)",
series=(),
- ) if corrected_phase_blocks else '未配置软件基线校正 / No software baseline correction.
'
- linear_blocks = [
+ measured_series=corrected_phase_measurements,
+ actual_label="",
+ ) if corrected_phase_blocks or corrected_phase_measurements else '未配置软件基线校正 / No software baseline correction.
'
+ linear_measurements = _response_amplitude_series(rows, "gain_linear_corrected") or _response_amplitude_series(
+ rows, "gain_linear"
+ )
+ linear_blocks = [] if linear_measurements else [
[point]
for block in _response_blocks(rows, "gain_linear_corrected") or _response_blocks(rows, "gain_linear")
for point in block
@@ -834,6 +850,7 @@ def _frequency_response_section(
title="线性增益拟合 / Linear gain fit comparison",
y_label="Linear gain (V/V)",
series=fit_series,
+ measured_series=linear_measurements,
actual_label="Measured",
)
table_block = ""
@@ -857,11 +874,13 @@ def _frequency_response_section(
calibration_block = _frequency_response_calibration_block(response)
baseline_block = _frequency_response_baseline_block(response)
adaptive_block = _frequency_response_adaptive_block(response)
+ matrix_summary = _frequency_response_matrix_summary(rows)
title = "频率响应 / Frequency response"
if multiple:
title += f" — {escape(response.label)}"
return f"""{title}
响应标签 / Response label: {escape(response.label)}。原始幅相保留为证据;软件基线校正不会改写仪器 deskew 或前面板设置。
+{matrix_summary}
{baseline_block}
{adaptive_block}
@@ -1022,6 +1041,72 @@ def _response_blocks(rows: list[dict[str, str]], key: str) -> list[list[tuple[fl
return blocks
+def _response_amplitude_series(
+ rows: list[dict[str, str]], key: str
+) -> list[tuple[str, str, list[tuple[float, float]]]]:
+ """Split a 2D sweep into one labeled, colored Bode series per requested Vpp."""
+ groups: dict[str, tuple[float, list[tuple[float, float]]]] = {}
+ for row in rows:
+ requested_vpp = _finite_float(row.get("requested_vpp"))
+ frequency = _finite_float(row.get("requested_frequency_hz"))
+ value = _finite_float(row.get(key))
+ if (
+ requested_vpp is None
+ or requested_vpp <= 0
+ or frequency is None
+ or frequency <= 0
+ or value is None
+ or row.get("status") == "failed"
+ ):
+ continue
+ group_key = str(row.get("amplitude_index", "")) or f"{requested_vpp:.12g}"
+ if group_key not in groups:
+ groups[group_key] = (requested_vpp, [])
+ groups[group_key][1].append((frequency, value))
+ colors = ("#2563eb", "#dc2626", "#0891b2", "#7c3aed", "#ea580c", "#16a34a")
+ return [
+ (f"Measured · {_format_metric(amplitude, 'Vpp')}", colors[index % len(colors)], sorted(values))
+ for index, (_group_key, (amplitude, values)) in enumerate(
+ sorted(groups.items(), key=lambda item: item[1][0])
+ )
+ ]
+
+
+def _frequency_response_matrix_summary(rows: list[dict[str, str]]) -> str:
+ amplitudes = sorted(
+ {
+ value
+ for row in rows
+ if (value := _finite_float(row.get("requested_vpp"))) is not None and value > 0
+ }
+ )
+ frequencies = sorted(
+ {
+ value
+ for row in rows
+ if (value := _finite_float(row.get("requested_frequency_hz"))) is not None and value > 0
+ }
+ )
+ if not amplitudes or not frequencies:
+ return ""
+ combinations = len(amplitudes) * len(frequencies)
+ observed = {
+ (amplitude, frequency)
+ for row in rows
+ if (amplitude := _finite_float(row.get("requested_vpp"))) is not None
+ and amplitude > 0
+ and (frequency := _finite_float(row.get("requested_frequency_hz"))) is not None
+ and frequency > 0
+ }
+ kind = "二维 / 2D" if len(amplitudes) > 1 else "单幅值 / single-amplitude"
+ return (
+ '扫频矩阵 / Sweep matrix: '
+ f'{escape(kind)},{len(amplitudes)} 个 Vpp 切片 × {len(frequencies)} 个频率节点 '
+ f'= {combinations} 个请求组合;CSV 已记录 {len(observed)}/{combinations} 个组合。'
+ '
'
+ )
+
+
def _frequency_response_calibration_block(response: FrequencyResponsePackage) -> str:
if response.calibration_error:
return (
@@ -1204,10 +1289,12 @@ def _response_svg(
title: str,
y_label: str,
series: list[tuple[str, str, list[tuple[float, float]]]],
+ measured_series: list[tuple[str, str, list[tuple[float, float]]]] = (),
actual_label: str = "Measured",
) -> str:
actual = [point for block in blocks for point in block]
- all_points = actual + [point for _name, _color, values in series for point in values]
+ measured = [point for _name, _color, values in measured_series for point in values]
+ all_points = actual + measured + [point for _name, _color, values in series for point in values]
if not all_points:
return '没有可绘制的有效频点 / No valid points to plot.
'
x_values = np.asarray([np.log10(point[0]) for point in all_points], dtype=float)
@@ -1226,17 +1313,23 @@ def _response_svg(
pad_y = (y_max - y_min) * 0.08
y_min -= pad_y
y_max += pad_y
- width, pad_left, pad_right, pad_top = 680, 58, 20, 28
+ y_min, y_max, y_ticks, y_tick_step = _nice_linear_axis_ticks(y_min, y_max)
+ x_ticks = _log_frequency_ticks(x_min, x_max)
+ width, pad_left, pad_right, pad_top = 680, 72, 20, 28
legend_items = (
- ([(actual_label, "#2563eb")] if actual else [])
+ ([(actual_label, "#2563eb")] if actual and actual_label else [])
+ + [(name, color) for name, color, _values in measured_series]
+ [(name, color) for name, color, _values in series]
)
legend_columns = 2
legend_row_height = 15
legend_rows = max(1, (len(legend_items) + legend_columns - 1) // legend_columns)
- legend_footer = 34
+ # Tick labels and the common x-axis label sit above the legend. They are
+ # emitted into the SVG itself so the browser and WeasyPrint PDF receive the
+ # exact same, self-contained coordinate system.
+ legend_footer = 52
pad_bottom = legend_footer + legend_rows * legend_row_height
- height = 270 + max(0, legend_rows - 2) * legend_row_height
+ height = 288 + max(0, legend_rows - 2) * legend_row_height
axis_y = height - pad_bottom
def position(point: tuple[float, float]) -> tuple[float, float]:
@@ -1259,8 +1352,51 @@ def position(point: tuple[float, float]) -> tuple[float, float]:
f' '
for x, y in (position(point) for point in actual)
)
+ measured_lines = []
+ measured_circles = []
+ for _name, color, values in measured_series:
+ points = " ".join(f"{x:.2f},{y:.2f}" for x, y in (position(point) for point in values))
+ measured_lines.append(f' ')
+ measured_circles.extend(
+ f' '
+ for x, y in (position(point) for point in values)
+ )
+ x_grid = []
+ x_tick_labels = []
+ for frequency_hz in x_ticks:
+ x_value = math.log10(frequency_hz)
+ x = pad_left + (x_value - x_min) / (x_max - x_min) * (width - pad_left - pad_right)
+ x_grid.append(
+ f' '
+ )
+ x_grid.append(
+ f' '
+ )
+ x_tick_labels.append(
+ f''
+ f'{escape(_format_frequency_axis_tick(frequency_hz))} '
+ )
+ y_grid = []
+ for value in y_ticks:
+ y = axis_y - (value - y_min) / (y_max - y_min) * (axis_y - pad_top)
+ y_grid.append(
+ f' '
+ )
+ y_grid.append(
+ f' '
+ )
+ y_grid.append(
+ f''
+ f'{escape(_format_linear_axis_tick(value, y_tick_step))} '
+ )
legend_column_width = (width - pad_left - pad_right) / legend_columns
- legend_base_y = axis_y + 38
+ legend_base_y = axis_y + 54
legends = []
for index, (name, color) in enumerate(legend_items):
row, column = divmod(index, legend_columns)
@@ -1277,16 +1413,64 @@ def position(point: tuple[float, float]) -> tuple[float, float]:
return (
f''
f' '
+ f'{"".join(x_grid)}{"".join(y_grid)}{"".join(x_tick_labels)}'
f' '
f' '
f'{escape(title)} '
- f'log10(f / Hz): {x_min:.3g} .. {x_max:.3g} '
- f'{escape(y_label)}: {y_min:.4g} .. {y_max:.4g} '
- f'{"".join(polylines)}{circles}{"".join(fit_lines)}{"".join(legends)}'
+ f'Frequency / Hz (log) '
+ f'{escape(y_label)} '
+ f'{"".join(polylines)}{circles}{"".join(measured_lines)}{"".join(measured_circles)}'
+ f'{"".join(fit_lines)}{"".join(legends)}'
' '
)
+def _log_frequency_ticks(x_min: float, x_max: float) -> list[float]:
+ """Return conventional 1-2-5 log-frequency ticks within the visible domain."""
+ ticks = [
+ factor * 10.0**exponent
+ for exponent in range(math.floor(x_min), math.ceil(x_max) + 1)
+ for factor in (1.0, 2.0, 5.0)
+ if x_min - 1e-12 <= math.log10(factor * 10.0**exponent) <= x_max + 1e-12
+ ]
+ # Very broad plots would otherwise turn into a forest of labels. Retain
+ # decade ticks first; the common measurement ranges keep all 1-2-5 ticks.
+ if len(ticks) > 10:
+ ticks = [tick for tick in ticks if math.isclose(math.log10(tick) % 1.0, 0.0, abs_tol=1e-10)]
+ return ticks
+
+
+def _nice_linear_axis_ticks(y_min: float, y_max: float) -> tuple[float, float, list[float], float]:
+ """Expand a numeric domain to readable, evenly spaced linear ticks."""
+ span = y_max - y_min
+ raw_step = span / 5.0
+ exponent = 10.0 ** math.floor(math.log10(raw_step))
+ normalized = raw_step / exponent
+ multiplier = next(value for value in (1.0, 2.0, 2.5, 5.0, 10.0) if normalized <= value)
+ step = multiplier * exponent
+ tick_min = math.floor(y_min / step) * step
+ tick_max = math.ceil(y_max / step) * step
+ count = int(round((tick_max - tick_min) / step)) + 1
+ ticks = [tick_min + index * step for index in range(count)]
+ return tick_min, tick_max, ticks, step
+
+
+def _format_frequency_axis_tick(frequency_hz: float) -> str:
+ if frequency_hz >= 1e6:
+ return f"{frequency_hz / 1e6:g} M"
+ if frequency_hz >= 1e3:
+ return f"{frequency_hz / 1e3:g} k"
+ return f"{frequency_hz:g}"
+
+
+def _format_linear_axis_tick(value: float, step: float) -> str:
+ if math.isclose(value, 0.0, abs_tol=abs(step) * 1e-9):
+ value = 0.0
+ precision = max(0, min(6, int(math.ceil(-math.log10(abs(step)))) + 1))
+ return f"{value:.{precision}f}".rstrip("0").rstrip(".")
+
+
def _short_svg_legend_label(label: str, *, limit: int = 30) -> str:
normalized = " ".join(label.split())
return normalized if len(normalized) <= limit else normalized[: limit - 1] + "…"
diff --git a/tests/test_report.py b/tests/test_report.py
index 1778dc3..636fd3e 100644
--- a/tests/test_report.py
+++ b/tests/test_report.py
@@ -36,12 +36,52 @@ def test_response_svg_uses_a_separate_two_column_legend_area(self):
)
self.assertEqual(len(positions), 4)
self.assertEqual(len(set(positions)), 4)
- self.assertEqual({position[0] for position in positions}, {"58.00", "359.00"})
+ self.assertEqual({position[0] for position in positions}, {"72.00", "366.00"})
self.assertEqual(len({position[1] for position in positions}), 2)
self.assertIn(">Measured", svg)
self.assertIn("…", svg)
self.assertNotIn("Frequency piecewise linear interpolation", svg)
+ def test_response_svg_includes_readable_log_frequency_and_linear_value_ticks(self):
+ svg = _response_svg(
+ [[(10_000.0, -0.2), (100_000.0, -1.0), (500_000.0, -3.1)]],
+ title="Magnitude response",
+ y_label="Gain (dB)",
+ series=(),
+ )
+
+ self.assertIn('class="plot-grid x-grid"', svg)
+ self.assertIn('class="plot-grid y-grid"', svg)
+ self.assertIn('class="x-axis-tick"', svg)
+ self.assertIn('class="y-axis-tick"', svg)
+ self.assertIn('class="x-axis-title"', svg)
+ self.assertIn('class="y-axis-title"', svg)
+ self.assertIn(">10 k", svg)
+ self.assertIn(">100 k", svg)
+ self.assertIn(">Gain (dB)", svg)
+
+ def test_run_report_labels_each_amplitude_slice_and_summarizes_a_two_dimensional_sweep(self):
+ with TemporaryDirectory() as tmp:
+ run_dir = Path(tmp) / "run"
+ run_dir.mkdir()
+ (run_dir / "run.json").write_text(json.dumps({"status": "ok", "steps": []}), encoding="utf-8")
+ rows = [
+ "index,amplitude_index,requested_vpp,requested_frequency_hz,gain_linear,gain_db,phase_unwrapped_deg,status"
+ ]
+ for amplitude_index, amplitude in enumerate((0.05, 0.1)):
+ for frequency in (10_000, 100_000, 500_000):
+ rows.append(
+ f"{len(rows) - 1},{amplitude_index},{amplitude},{frequency},0.9,-0.9,-45,ok"
+ )
+ (run_dir / "frequency_response.csv").write_text("\n".join(rows), encoding="utf-8")
+
+ html = render_run_report_html(load_run_package(run_dir), output_dir=run_dir)
+
+ self.assertIn("扫频矩阵 / Sweep matrix", html)
+ self.assertIn("二维 / 2D,2 个 Vpp 切片 × 3 个频率节点 = 6 个请求组合", html)
+ self.assertIn('data-label="Measured · 0.05 Vpp"', html)
+ self.assertIn('data-label="Measured · 0.1 Vpp"', html)
+
def test_run_report_embeds_capture_screenshot_relative_to_report(self):
with TemporaryDirectory() as tmp:
root = Path(tmp)
From 9c34554445efdbde73f350220a3f543277d39b90 Mon Sep 17 00:00:00 2001
From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com>
Date: Wed, 5 Aug 2026 12:55:58 +0800
Subject: [PATCH 7/9] feat: retry warning frequency response points
---
...77\347\224\250\346\214\207\345\215\227.md" | 3 +
...ve_filter_raw_2d_10hz_1mhz_retry_test.toml | 49 +++++++++++++
src/wavebench/services/frequency_response.py | 12 ++++
src/wavebench/services/run_plan.py | 5 ++
src/wavebench/services/run_service.py | 49 ++++++++++++-
tests/test_run_plan.py | 13 ++++
tests/test_run_service.py | 70 ++++++++++++++++++-
7 files changed, 197 insertions(+), 4 deletions(-)
create mode 100644 plans/passive_filter_raw_2d_10hz_1mhz_retry_test.toml
diff --git "a/doc/project/WaveBench_run_plan_\344\275\277\347\224\250\346\214\207\345\215\227.md" "b/doc/project/WaveBench_run_plan_\344\275\277\347\224\250\346\214\207\345\215\227.md"
index 809a650..593bf80 100644
--- "a/doc/project/WaveBench_run_plan_\344\275\277\347\224\250\346\214\207\345\215\227.md"
+++ "b/doc/project/WaveBench_run_plan_\344\275\277\347\224\250\346\214\207\345\215\227.md"
@@ -189,10 +189,13 @@ max_slope_db_per_octave = 6
| `amplitudes_vpp` | 严格递增、正数的显式 Vpp 数组;不能与生成式字段共用。 |
| `start_vpp` / `stop_vpp` / `vpp_step` | 三者必须同时出现;`stop_vpp > start_vpp`,步进必须整除区间。 |
| `autoscale_each_amplitude` | 多幅值时默认 `true`;仅在已人工确认量程时才建议设为 `false`。 |
+| `retry_warning_with_autoscale` | 默认 `true`;单点出现质量 warning 时 autoscale、稳定后重测一次。重测仍 warning 则该点标为 failed,并保留两次采集证据。 |
| `[steps.calibration]` | 仅多幅值数据有实际意义;`enabled = false` 可关闭自动派生。 |
执行顺序固定为“设定 Vpp → 对每个频点设频并等待 `settle_s` → 每个幅值切片的首个频点 autoscale → 再等待 `settle_s` → 同步采集 CH1/CH2”。`autoscale_each_amplitude = false` 可显式关闭该切片首点 autoscale,但仅适用于已由人工确认两路量程足够的情况。每个 Vpp 值都在连接仪器前受 `[safety_limits].max_source_vpp` 检查。
+频响点的首次采集若出现质量 warning,默认会执行一次“autoscale → 等待 `settle_s` → 同频同幅值重测”。重测成功时 CSV 的最终值来自第二次采集,并用 `quality_retry_count`、`initial_warnings`、`initial_capture_package` 和 `initial_metadata_path` 保存首次证据;重测仍 warning 时该点标为 `failed`、带 `quality_retry_exhausted` 原因,不参与拟合、软件校正或二维 LUT。可在 step 中设 `retry_warning_with_autoscale = false` 关闭此策略。
+
完整矩阵的点数为 `幅值数 × 频点数`;每个点至少消耗 `settle_s`,每个幅值切片还会增加一次 autoscale 与稳定等待。先用少量幅值和稀疏频点执行 `run check`、只读 `run verify`、再做实机小矩阵确认量程和耗时,别拿 5 mV 步进和 500 Hz 步进直接开 13 小时以上的盲扫,没必要给仪器和人都上强度。
`[steps.calibration]` 需要至少两个请求 Vpp 切片且每个切片至少四个共同有效频点。它会在测量结束后使用每切片的 dB 平滑样条、请求 Vpp 方向线性插值生成校准;频率节点沿用实测点,不在频率或 Vpp 定义域外外推。自动校准失败不会删除或重写原始频响 CSV:该 step 变为 `warning`,`artifact.frequency_response.calibration_error` 记录原因,之后可修正配置或数据再离线重算。
diff --git a/plans/passive_filter_raw_2d_10hz_1mhz_retry_test.toml b/plans/passive_filter_raw_2d_10hz_1mhz_retry_test.toml
new file mode 100644
index 0000000..5c3e941
--- /dev/null
+++ b/plans/passive_filter_raw_2d_10hz_1mhz_retry_test.toml
@@ -0,0 +1,49 @@
+# 原始二维扫频的 warning 自动恢复实测:不加载基线、LUT 或软件校正。
+# 3 个 Vpp 切片 × 101 个对数频点 = 303 个组合;warning 时最多同点重测一次。
+
+[experiment]
+name = "passive_filter_raw_2d_10hz_1mhz_retry_test"
+label = "passive_filter_raw_2d_10hz_1mhz_retry_test"
+
+[restore]
+source_state = true
+source_channel = 1
+
+[[steps]]
+kind = "source.set_func"
+channel = 1
+function = "sin"
+
+[[steps]]
+kind = "source.set_vpp"
+channel = 1
+value_vpp = 0.5
+
+[[steps]]
+kind = "source.set_freq"
+channel = 1
+frequency_hz = 10
+
+[[steps]]
+kind = "source.output"
+channel = 1
+state = "on"
+
+[[steps]]
+kind = "sweep.frequency_response"
+label = "passive_filter_raw_2d_retry"
+source_channel = 1
+reference_channel = 1
+response_channel = 2
+start_frequency_hz = 10
+stop_frequency_hz = 1000000
+frequency_count = 101
+spacing = "log"
+amplitudes_vpp = [0.5, 1.0, 2.0]
+autoscale_each_amplitude = true
+retry_warning_with_autoscale = true
+target_cycles = 10
+settle_s = 1.0
+points = "def"
+save_csv = false
+screenshot = false
diff --git a/src/wavebench/services/frequency_response.py b/src/wavebench/services/frequency_response.py
index 6439748..d956e2f 100644
--- a/src/wavebench/services/frequency_response.py
+++ b/src/wavebench/services/frequency_response.py
@@ -43,6 +43,10 @@
"adaptive_level",
"adaptive_parent_start_hz",
"adaptive_parent_stop_hz",
+ "quality_retry_count",
+ "initial_warnings",
+ "initial_capture_package",
+ "initial_metadata_path",
"status",
"warnings",
"error",
@@ -77,6 +81,10 @@ class FrequencyResponsePoint:
adaptive_level: int = 0
adaptive_parent_start_hz: float | None = None
adaptive_parent_stop_hz: float | None = None
+ quality_retry_count: int = 0
+ initial_warnings: tuple[str, ...] = ()
+ initial_capture_package: str = ""
+ initial_metadata_path: str = ""
warnings: tuple[str, ...] = ()
error: str = ""
capture_package: str = ""
@@ -116,6 +124,10 @@ def as_csv_row(self, fit_values: dict[str, tuple[float | None, float | None]] |
"adaptive_level": self.adaptive_level,
"adaptive_parent_start_hz": self.adaptive_parent_start_hz,
"adaptive_parent_stop_hz": self.adaptive_parent_stop_hz,
+ "quality_retry_count": self.quality_retry_count,
+ "initial_warnings": " | ".join(self.initial_warnings),
+ "initial_capture_package": self.initial_capture_package,
+ "initial_metadata_path": self.initial_metadata_path,
"status": self.status,
"warnings": " | ".join(self.warnings),
"error": self.error,
diff --git a/src/wavebench/services/run_plan.py b/src/wavebench/services/run_plan.py
index 825437d..14d0dc5 100644
--- a/src/wavebench/services/run_plan.py
+++ b/src/wavebench/services/run_plan.py
@@ -89,6 +89,7 @@
"stop_vpp",
"vpp_step",
"autoscale_each_amplitude",
+ "retry_warning_with_autoscale",
"calibration",
"baseline",
"adaptive",
@@ -525,6 +526,10 @@ def _normalize_frequency_response_fields(prefix: str, fields: dict[str, Any]) ->
fields["points"] = normalize_waveform_points(
_non_empty_str(fields["points"], f"{prefix}.points")
)
+ retry_warning = fields.get("retry_warning_with_autoscale", True)
+ if not isinstance(retry_warning, bool):
+ raise ConfigError(f"{prefix}.retry_warning_with_autoscale must be true or false")
+ fields["retry_warning_with_autoscale"] = retry_warning
for name in ("save_csv", "screenshot"):
if name in fields and not isinstance(fields[name], bool):
raise ConfigError(f"{prefix}.{name} must be true or false")
diff --git a/src/wavebench/services/run_service.py b/src/wavebench/services/run_service.py
index ab4996a..c38ab00 100644
--- a/src/wavebench/services/run_service.py
+++ b/src/wavebench/services/run_service.py
@@ -5,7 +5,7 @@
import json
from math import isfinite
from contextlib import ExitStack, contextmanager
-from dataclasses import dataclass
+from dataclasses import dataclass, replace
from pathlib import Path
from typing import Any, Iterator
@@ -673,7 +673,7 @@ def _run_frequency_response_step(
capture = scope.capture_waveforms(
channels=[reference_channel, response_channel], label=amplitude_label
)
- points.append(analyze_frequency_response_point(
+ point = analyze_frequency_response_point(
index=point_index, amplitude_index=amplitude_index, requested_vpp=requested_vpp,
requested_frequency_hz=frequency_hz,
reference_waveform=capture.waveforms[reference_channel],
@@ -681,7 +681,50 @@ def _run_frequency_response_step(
frequency_tolerance_ratio=tolerance, capture_package=str(capture.package_dir),
metadata_path=str(capture.metadata_path), adaptive_level=adaptive_level,
adaptive_parent_start_hz=parent_start, adaptive_parent_stop_hz=parent_stop,
- ))
+ )
+ if point.status == "warning" and step.fields["retry_warning_with_autoscale"]:
+ initial_point = point
+ try:
+ scope.autoscale()
+ if step.fields["settle_s"]:
+ time.sleep(step.fields["settle_s"])
+ retry_capture = scope.capture_waveforms(
+ channels=[reference_channel, response_channel], label=f"{amplitude_label}_retry1"
+ )
+ retry_point = analyze_frequency_response_point(
+ index=point_index, amplitude_index=amplitude_index, requested_vpp=requested_vpp,
+ requested_frequency_hz=frequency_hz,
+ reference_waveform=retry_capture.waveforms[reference_channel],
+ response_waveform=retry_capture.waveforms[response_channel],
+ frequency_tolerance_ratio=tolerance,
+ capture_package=str(retry_capture.package_dir), metadata_path=str(retry_capture.metadata_path),
+ adaptive_level=adaptive_level, adaptive_parent_start_hz=parent_start,
+ adaptive_parent_stop_hz=parent_stop,
+ )
+ point = replace(
+ retry_point,
+ status="failed" if retry_point.status == "warning" else retry_point.status,
+ quality_retry_count=1,
+ initial_warnings=initial_point.warnings,
+ initial_capture_package=initial_point.capture_package,
+ initial_metadata_path=initial_point.metadata_path,
+ error=(
+ "quality_retry_exhausted: initial warnings="
+ + " | ".join(initial_point.warnings)
+ + "; retry warnings=" + " | ".join(retry_point.warnings)
+ ) if retry_point.status == "warning" else retry_point.error,
+ )
+ except Exception as retry_exc: # noqa: BLE001 - retain the first capture evidence
+ point = replace(
+ initial_point,
+ status="failed",
+ quality_retry_count=1,
+ initial_warnings=initial_point.warnings,
+ initial_capture_package=initial_point.capture_package,
+ initial_metadata_path=initial_point.metadata_path,
+ error=f"quality_retry_failed: {type(retry_exc).__name__}: {retry_exc}",
+ )
+ points.append(point)
except Exception as exc: # noqa: BLE001 - retain failed points and continue the sweep
points.append(failed_frequency_response_point(
index=point_index, amplitude_index=amplitude_index, requested_vpp=requested_vpp,
diff --git a/tests/test_run_plan.py b/tests/test_run_plan.py
index b0704f1..5dcb955 100644
--- a/tests/test_run_plan.py
+++ b/tests/test_run_plan.py
@@ -325,6 +325,7 @@ def test_frequency_response_plan_normalizes_log_frequency_points_and_fit(self):
self.assertEqual(fields["frequencies_hz"][2], 10000.0)
self.assertEqual(fields["target_cycles"], 8.0)
self.assertEqual(fields["settle_s"], 0.0)
+ self.assertTrue(fields["retry_warning_with_autoscale"])
self.assertEqual(
fields["fit"],
{"methods": ["linear_log", "polynomial"], "polynomial_degree": 2},
@@ -352,6 +353,18 @@ def test_frequency_response_plan_generates_vpp_slices_and_calibration(self):
self.assertTrue(fields["autoscale_each_amplitude"])
self.assertEqual(fields["calibration"]["target_gain_db"], -1.0)
+ def test_frequency_response_plan_accepts_warning_retry_configuration(self):
+ plan = load_run_plan(self._write_plan("""
+[[steps]]
+kind = "sweep.frequency_response"
+reference_channel = 1
+response_channel = 2
+frequencies_hz = [100, 1000]
+retry_warning_with_autoscale = false
+"""))
+
+ self.assertFalse(plan.steps[0].fields["retry_warning_with_autoscale"])
+
def test_frequency_response_plan_rejects_mixed_vpp_forms(self):
path = self._write_plan("""
[[steps]]
diff --git a/tests/test_run_service.py b/tests/test_run_service.py
index 79d12b7..f35bd66 100644
--- a/tests/test_run_service.py
+++ b/tests/test_run_service.py
@@ -138,6 +138,7 @@ def fake_frequency_response_capture(
frequency_hz: float,
gain: float = 2.0,
phase_deg: float = -45.0,
+ warnings: list[str] | None = None,
):
package = Path(tmp) / name
package.mkdir()
@@ -148,7 +149,7 @@ def fake_frequency_response_capture(
def waveform(amplitude: float, phase: float):
values = amplitude * np.sin(2.0 * np.pi * frequency_hz * times + np.radians(phase))
summary = {
- "quality_warnings": [],
+ "quality_warnings": warnings or [],
"frequency_estimate_hz": frequency_hz,
"voltage_vpp_v": amplitude * 2.0,
"frequency_error_ratio": 0.0,
@@ -1231,6 +1232,73 @@ def test_frequency_response_captures_two_channels_and_persists_evidence(self):
)
)
+ def test_frequency_response_retries_warning_after_autoscale_and_uses_clean_retry(self):
+ with TemporaryDirectory() as tmp:
+ plan = load_run_plan(write_plan(tmp, """
+[[steps]]
+kind = "sweep.frequency_response"
+source_channel = 1
+reference_channel = 1
+response_channel = 2
+frequencies_hz = [1000, 2000]
+settle_s = 0
+"""))
+ status = SimpleNamespace(output="ON")
+ initial = fake_frequency_response_capture(tmp, "first", frequency_hz=1000, warnings=["frequency_mismatch"])
+ retry = fake_frequency_response_capture(tmp, "retry", frequency_hz=1000)
+ second = fake_frequency_response_capture(tmp, "second", frequency_hz=2000)
+ with patch("wavebench.services.run_service.ScopeService") as scope_cls, patch(
+ "wavebench.services.run_service.SourceService"
+ ) as source_cls:
+ source = source_cls.return_value
+ source.status.return_value = status
+ source.set_frequency.return_value = status
+ scope = scope_cls.return_value
+ scope.capture_waveforms.side_effect = [initial, retry, second]
+
+ result = RunService(config=make_config(tmp), logger=CommandLogger()).run(plan)
+
+ rows = list(csv.DictReader((result.run_dir / "frequency_response.csv").open(encoding="utf-8")))
+ self.assertEqual(scope.autoscale.call_count, 1)
+ self.assertEqual(len(rows), 2)
+ self.assertEqual(rows[0]["status"], "ok")
+ self.assertEqual(rows[0]["quality_retry_count"], "1")
+ self.assertEqual(rows[0]["initial_warnings"], "reference: frequency_mismatch | response: frequency_mismatch")
+ self.assertEqual(rows[0]["initial_capture_package"], str(initial.package_dir))
+
+ def test_frequency_response_marks_point_failed_when_warning_retry_is_exhausted(self):
+ with TemporaryDirectory() as tmp:
+ plan = load_run_plan(write_plan(tmp, """
+[[steps]]
+kind = "sweep.frequency_response"
+source_channel = 1
+reference_channel = 1
+response_channel = 2
+frequencies_hz = [1000, 2000]
+settle_s = 0
+"""))
+ status = SimpleNamespace(output="ON")
+ first = fake_frequency_response_capture(tmp, "first", frequency_hz=1000, warnings=["frequency_mismatch"])
+ retry = fake_frequency_response_capture(tmp, "retry", frequency_hz=1000, warnings=["frequency_mismatch"])
+ second = fake_frequency_response_capture(tmp, "second", frequency_hz=2000)
+ with patch("wavebench.services.run_service.ScopeService") as scope_cls, patch(
+ "wavebench.services.run_service.SourceService"
+ ) as source_cls:
+ source = source_cls.return_value
+ source.status.return_value = status
+ source.set_frequency.return_value = status
+ scope = scope_cls.return_value
+ scope.capture_waveforms.side_effect = [first, retry, second]
+
+ result = RunService(config=make_config(tmp), logger=CommandLogger()).run(plan)
+
+ rows = list(csv.DictReader((result.run_dir / "frequency_response.csv").open(encoding="utf-8")))
+ self.assertEqual(scope.autoscale.call_count, 1)
+ self.assertEqual(rows[0]["status"], "failed")
+ self.assertEqual(rows[0]["quality_retry_count"], "1")
+ self.assertIn("quality_retry_exhausted", rows[0]["error"])
+ self.assertEqual(rows[1]["status"], "ok")
+
def test_frequency_response_multiple_vpp_slices_autoscale_and_write_calibration(self):
with TemporaryDirectory() as tmp:
plan = load_run_plan(write_plan(tmp, """
From 42380b74ea8fea43b7235f343ff18feef9ecda6a Mon Sep 17 00:00:00 2001
From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com>
Date: Wed, 5 Aug 2026 13:31:33 +0800
Subject: [PATCH 8/9] feat: add interactive 3D frequency response plots
---
README.md | 7 +-
doc/README.md | 2 +-
doc/README_EN.md | 10 +-
...77\347\224\250\346\214\207\345\215\227.md" | 4 +-
...23\345\207\272\346\240\274\345\274\217.md" | 18 +
pyproject.toml | 3 +-
src/wavebench/report/html.py | 101 +++++-
src/wavebench/report/plot3d.py | 328 ++++++++++++++++++
tests/test_release_artifacts.py | 4 +-
tests/test_report_plot3d.py | 263 ++++++++++++++
10 files changed, 719 insertions(+), 21 deletions(-)
create mode 100644 src/wavebench/report/plot3d.py
create mode 100644 tests/test_report_plot3d.py
diff --git a/README.md b/README.md
index a07e980..3efe13d 100644
--- a/README.md
+++ b/README.md
@@ -110,7 +110,7 @@ WaveBench 主包长期预装 RTM2000/RTM2032、DS1104Z/DS1000Z、DG4000/DG4202
- `run template --list` / `run template --output `:列出或生成保守 run plan 模板;可用 `--frequency`、`--frequencies`、`--reference-channel`、`--response-channel`、`--fit` 等少量参数定制;不连接仪器,不覆盖已有文件,除非显式 `--force`
- `run plan --plan `:执行显式 source、power、scope、dmm、sleep 和双通道 `sweep.frequency_response` 步骤;一个 run 可有多个具唯一 label 的频响,每个响应可扫描二维 Vpp × 频率、软件直通基线、可选自适应频率加密,并导出浮点/定点校准 LUT;一次 run 内统一打开并复用所需仪器 session,成功或失败后统一关闭,不静默断线重连
- `run calibrate --config [--response ]`:完全离线地从既有二维频响 CSV 重建校准 LUT,不连接仪器、不改写原始测量 CSV;多响应 run 必须指定 `--response`
-- `run report `:根据 `run.json` / `summary.csv` 生成静态离线 HTML 报告;频响 run 额外包含原始/软件校正的幅频与相频、拟合对比、逐点 CSV 与采集证据链接;二维校准会增加补偿热图与代表性切片。加 `--pdf` 可同时导出嵌入截图、SVG 和表格的便携 PDF
+- `run report `:根据 `run.json` / `summary.csv` 生成离线 HTML 报告;频响 run 额外包含原始/软件校正的幅频与相频、拟合对比、逐点 CSV 与采集证据链接;二维校准会增加补偿热图与代表性切片。安装 `report3d` extra 后,二维频响 HTML 还会加入可旋转、缩放和切换 Raw/Corrected、dB/V/V 的三维实测增益曲面。加 `--pdf` 可同时导出嵌入截图、静态 SVG 和表格的便携 PDF
- `capture inspect `:打印离线采集包摘要
- 默认示波器高阻保护:`scope.capture` / `scope.fetch` / `sweep discrete` / run-plan `scope.capture` / `sweep.frequency_response` 在采集前查询通道耦合。频响会同时保护 reference 与 response 两路;RTM2032 的 `DCL`/`ACL` 视为高阻,`DC`/`AC` 默认按可能的 50 Ω 拒绝;DS1000Z 输入固定为 1 MΩ,`AC`/`DC`/`GND` 只表示耦合方式,均按该机型语义检查。WaveBench 不会自动修改耦合或输入设置
- 可选 `[restore] source_state = true`:在 `finally` 路径快照并恢复 basic 信号源通道状态(输出、函数、频率、Vpp、方波占空比)。该选项不恢复 offset、phase、frequency mode、sweep、负载、极性、噪声、同步、burst、调制、marker、pulse hold 或易失任意波内存;run artifact 以 `source_state_scope = "basic"` 明示范围
@@ -207,12 +207,13 @@ python3 -m venv .venv
cp wavebench.example.toml wavebench.toml
```
-需要运行测试和代码检查时安装开发依赖;频响 PCHIP、dB 平滑样条、二维校准需要 `analysis` extra;离线 PDF 报告需要 `pdf` extra;终端 TUI 需要 `tui` extra:
+需要运行测试和代码检查时安装开发依赖;频响 PCHIP、dB 平滑样条、二维校准需要 `analysis` extra;离线 PDF 报告需要 `pdf` extra;交互式三维频响 HTML 需要 `report3d` extra;终端 TUI 需要 `tui` extra:
```bash
.venv/bin/python -m pip install -e ".[dev]"
.venv/bin/python -m pip install -e ".[analysis]"
.venv/bin/python -m pip install -e ".[pdf]"
+.venv/bin/python -m pip install -e ".[report3d]"
.venv/bin/python -m pip install -e ".[tui]"
```
@@ -550,7 +551,7 @@ chebyshev_degree = 3
chebyshev_segment_count = 8
```
-校准输出为 `frequency_response_calibration.csv` 和 `frequency_response_calibration.json`;原始 `frequency_response.csv` 不会被改写。默认还会生成有符号二补码 `Q4.12` 的审计 CSV、Xilinx `.coe` 和逐字 `.mem`(幅值主序:`amplitude_index * frequency_count + frequency_index`);可在 `[steps.calibration.fixed_point]` 或离线 `[calibration.fixed_point]` 调整字宽、小数位、布局、格式和溢出策略。HTML/PDF 会展示校正热图和代表性幅值切片,完整 LUT 与公式保留在 run 目录。PDF 是“可见报告”的单文件封装:截图、静态 SVG 曲线和表格会嵌入 PDF;CSV、JSON、NPY 波形和完整采集包仍保留在 run 目录,适合复算和审计。
+校准输出为 `frequency_response_calibration.csv` 和 `frequency_response_calibration.json`;原始 `frequency_response.csv` 不会被改写。默认还会生成有符号二补码 `Q4.12` 的审计 CSV、Xilinx `.coe` 和逐字 `.mem`(幅值主序:`amplitude_index * frequency_count + frequency_index`);可在 `[steps.calibration.fixed_point]` 或离线 `[calibration.fixed_point]` 调整字宽、小数位、布局、格式和溢出策略。HTML/PDF 会展示校正热图和代表性幅值切片,完整 LUT 与公式保留在 run 目录。安装 `.[report3d]` 后,二维 HTML 会优先显示校正增益的交互曲面,并允许切回原始增益及 dB/V/V;曲面只连接已有矩形网格节点,failed 点留洞,warning 与自动恢复点分别标记。Plotly 运行时保存在报告旁的 `report-assets/plotly.min.js`,移动 HTML 时必须一并携带该目录。PDF 是“可见报告”的单文件静态封装,不加载 Plotly:截图、静态 SVG 曲线和表格会嵌入 PDF;CSV、JSON、NPY 波形和完整采集包仍保留在 run 目录,适合复算和审计。
### 直通基线、自适应加密与多响应
diff --git a/doc/README.md b/doc/README.md
index b83b137..09c3b08 100644
--- a/doc/README.md
+++ b/doc/README.md
@@ -19,7 +19,7 @@ WaveBench 是面向电赛调试场景的轻量 Python 自动测量台。当前
可靠地远程读取示波器波形、显式控制信号源和电源,并保存 CSV / NPY / metadata / commands.log。
```
-当前已经支持单次/多通道采集、失败采集包、数据质量摘要、采集窗口控制、DG4202 离散扫频与占空比控制、DP800 电源显式控制、DMM 读数,以及多仪器 run plan 执行。双通道 `sweep.frequency_response` 可测量幅频、相频和传统一维拟合,也可按请求 Vpp 形成二维扫频;一个 run 可记录多个独立 response。它支持引用操作者手动直通采集的独立基线 run,在软件中做完整复传递函数、仅相位或仅延迟校正(不改示波器 deskew);可选自适应频率加密仍保持每个 Vpp 的矩形网格。二维校准会自动生成浮点 LUT、补偿限制审计、分段 Chebyshev 公式,以及默认 signed `Q4.12` 的 audit CSV/Xilinx COE/MEM;`run calibrate --response ` 可完全离线重算。`wavebench doctor` 可只读检查配置中的仪器资源、IDN 和型号匹配,也可用 `--discover-subnet` 在配置资源失效时按 IDN 匹配候选替代 resource,便于现场排查网络和配置问题。`run template` 可生成保守的 run plan 模板,并支持少量频率、频点列表、幅度、通道、电压参数,减少手写 TOML 的低级错误。run plan 的 `scope.capture` 可选择质量检查,并在质量警告时按 `[quality].auto_recover_attempts` 触发多次显式 autoscale 重采;若多次采集指标稳定,可标记为 `ok_by_consistency`。`[steps.expect]` 可对采集指标设置 min/max 断言,`[steps.expect_fft]` 可直接对 FFT 主频、主峰幅度、THD、谐波幅度做断言。断言失败会把实验标记为 failed。可选的实验性 TUI 已覆盖 DP800 电源、DM3000/DM3058 万用表和 DG4202 信号源的常用查看/控制操作,并冻结在这三个面板;CLI、run plan 和 Service 仍是核心能力。HTTP MCP 只读 MVP 已提供 `/health`、`/mcp`、`/tools`、`/call`,其中 `/mcp` 支持 MCP JSON-RPC 的 `initialize`、`tools/list`、`tools/call`,工具为 `run.schema`、`run.check`、`capture.inspect` 三个只读工具。v0.2 已开始加入离线包读取和静态 `run report`;报告现在会输出 `验收摘要 / Acceptance summary`、`预期 vs 实测 / Expected vs measured`,并汇总频率、Vpp、均值、duty、截图与 FFT 验收信息;对多点 sweep run 还会生成扫频摘要表,便于快速查看各频点质量、主峰与 THD。这些报告命令只读已有文件,不连接仪器。
+当前已经支持单次/多通道采集、失败采集包、数据质量摘要、采集窗口控制、DG4202 离散扫频与占空比控制、DP800 电源显式控制、DMM 读数,以及多仪器 run plan 执行。双通道 `sweep.frequency_response` 可测量幅频、相频和传统一维拟合,也可按请求 Vpp 形成二维扫频;一个 run 可记录多个独立 response。它支持引用操作者手动直通采集的独立基线 run,在软件中做完整复传递函数、仅相位或仅延迟校正(不改示波器 deskew);可选自适应频率加密仍保持每个 Vpp 的矩形网格。二维校准会自动生成浮点 LUT、补偿限制审计、分段 Chebyshev 公式,以及默认 signed `Q4.12` 的 audit CSV/Xilinx COE/MEM;`run calibrate --response ` 可完全离线重算。安装 `report3d` extra 后,二维频响 HTML 还会离线显示可旋转的实测增益曲面,支持 Raw/Corrected 与 dB/V/V 切换;PDF 保持单文件静态归档。`wavebench doctor` 可只读检查配置中的仪器资源、IDN 和型号匹配,也可用 `--discover-subnet` 在配置资源失效时按 IDN 匹配候选替代 resource,便于现场排查网络和配置问题。`run template` 可生成保守的 run plan 模板,并支持少量频率、频点列表、幅度、通道、电压参数,减少手写 TOML 的低级错误。run plan 的 `scope.capture` 可选择质量检查,并在质量警告时按 `[quality].auto_recover_attempts` 触发多次显式 autoscale 重采;若多次采集指标稳定,可标记为 `ok_by_consistency`。`[steps.expect]` 可对采集指标设置 min/max 断言,`[steps.expect_fft]` 可直接对 FFT 主频、主峰幅度、THD、谐波幅度做断言。断言失败会把实验标记为 failed。可选的实验性 TUI 已覆盖 DP800 电源、DM3000/DM3058 万用表和 DG4202 信号源的常用查看/控制操作,并冻结在这三个面板;CLI、run plan 和 Service 仍是核心能力。HTTP MCP 只读 MVP 已提供 `/health`、`/mcp`、`/tools`、`/call`,其中 `/mcp` 支持 MCP JSON-RPC 的 `initialize`、`tools/list`、`tools/call`,工具为 `run.schema`、`run.check`、`capture.inspect` 三个只读工具。v0.2 已开始加入离线包读取和静态 `run report`;报告现在会输出 `验收摘要 / Acceptance summary`、`预期 vs 实测 / Expected vs measured`,并汇总频率、Vpp、均值、duty、截图与 FFT 验收信息;对多点 sweep run 还会生成扫频摘要表,便于快速查看各频点质量、主峰与 THD。这些报告命令只读已有文件,不连接仪器。
## 长时间测试
diff --git a/doc/README_EN.md b/doc/README_EN.md
index 6065776..ae331ec 100644
--- a/doc/README_EN.md
+++ b/doc/README_EN.md
@@ -56,14 +56,14 @@ The Textual interface is an optional extra. In a source checkout, install it wit
Its supported product scope is intentionally frozen to the power-supply, DMM, and signal-source panels. CLI commands, run plans, and services remain the primary interfaces; the TUI is not a run-plan editor, plugin manager, full oscilloscope viewer, or reporting system.
-## Optional frequency-response analysis and PDF reports
+## Optional frequency-response analysis, interactive HTML, and PDF reports
-`sweep.frequency_response` sets the source through explicit frequency points and captures a reference (DUT input) and response (DUT output) scope channel in one acquisition per point. It writes a point-by-point `frequency_response.csv` with raw and, when configured, software-baseline-corrected linear/dB gain and wrapped/unwrapped output-relative phase. A run may contain multiple independently labelled responses and multiple requested Vpp slices, producing a two-dimensional Vpp × frequency measurement. A baseline is an explicitly referenced, separately captured manual CH1/CH2 through connection; it never changes scope deskew or front-panel settings. Optional adaptive refinement inserts linear or logarithmic midpoints where either gain or unwrapped phase changes too quickly, then samples every Vpp slice at the new frequency. The offline HTML/PDF report renders each response's raw/corrected magnitude and phase, fit comparison, audit summary, point table, and any saved per-point screenshots.
+`sweep.frequency_response` sets the source through explicit frequency points and captures a reference (DUT input) and response (DUT output) scope channel in one acquisition per point. It writes a point-by-point `frequency_response.csv` with raw and, when configured, software-baseline-corrected linear/dB gain and wrapped/unwrapped output-relative phase. A run may contain multiple independently labelled responses and multiple requested Vpp slices, producing a two-dimensional Vpp × frequency measurement. A baseline is an explicitly referenced, separately captured manual CH1/CH2 through connection; it never changes scope deskew or front-panel settings. Optional adaptive refinement inserts linear or logarithmic midpoints where either gain or unwrapped phase changes too quickly, then samples every Vpp slice at the new frequency. The offline HTML/PDF report renders each response's raw/corrected magnitude and phase, fit comparison, audit summary, point table, and any saved per-point screenshots. With the `report3d` extra installed, two-dimensional HTML reports also include a rotatable measured-gain surface with Raw/Corrected and dB/V/V selectors, warning/recovery markers, hover evidence, zoom, and camera reset.
Use the conservative template before editing a plan manually:
```bash
-python -m pip install -e ".[analysis,pdf]"
+python -m pip install -e ".[analysis,pdf,report3d]"
python -m wavebench run template source-scope-frequency-response \
--frequencies 100,1000,10000 --reference-channel 1 --response-channel 2 \
--fit --output plans/frequency_response.toml
@@ -77,7 +77,9 @@ python -m wavebench run report data/runs/ --pdf
python -m wavebench run calibrate data/runs/ --config plans/calibration.toml --response
```
-The PDF is a portable visual report: its visible screenshots, SVG charts, and tables are embedded, while CSV/JSON/NPY evidence stays as separate artifacts for reproducible analysis. WeasyPrint also relies on platform rendering libraries (Cairo, Pango, GDK-PixBuf) and suitable CJK fonts where needed.
+The interactive HTML bundles no network resources: Plotly is copied to `report-assets/plotly.min.js` and recorded in the report manifest. Keep that directory beside the HTML when moving the report. Surfaces only connect neighbouring measured grid nodes; failed points remain holes, and no fit or extrapolation is invented. Single-amplitude or smaller-than-2×2 data stays on the static Bode presentation.
+
+The PDF is a portable static visual report: it deliberately does not load Plotly, while its visible screenshots, SVG charts, and tables are embedded. CSV/JSON/NPY evidence stays as separate artifacts for reproducible analysis. WeasyPrint also relies on platform rendering libraries (Cairo, Pango, GDK-PixBuf) and suitable CJK fonts where needed.
## Running tests in idle-limited terminals
diff --git "a/doc/project/WaveBench_run_plan_\344\275\277\347\224\250\346\214\207\345\215\227.md" "b/doc/project/WaveBench_run_plan_\344\275\277\347\224\250\346\214\207\345\215\227.md"
index 593bf80..d70202b 100644
--- "a/doc/project/WaveBench_run_plan_\344\275\277\347\224\250\346\214\207\345\215\227.md"
+++ "b/doc/project/WaveBench_run_plan_\344\275\277\347\224\250\346\214\207\345\215\227.md"
@@ -485,7 +485,7 @@ HTML 报告当前会汇总:
- `实验证据摘要 / Run evidence summary`:source 步骤、scope capture、DMM 读数、run.json、summary.csv、截图和波形预览数量。
- `证据时间线 / Evidence timeline`:按 step 展示 source/scope/DMM/sleep 的证据摘要。
- `扫频摘要 / Sweep summary`:当 run 里有多点 `scope.capture` 或 sweep label 时显示,列出每个频点的 label、status、quality、expect、FFT、frequency、Vpp、FFT peak、peak amplitude 和 THD。
-- `频率响应 / Frequency response`:按每个 manifest response 显示原始/软件校正幅频和相频、基线与自适应摘要、拟合对比、逐点表格、拟合公式/参数和原始采集包链接;校准产物还会显示目标、留点验证误差、补偿热图、代表性幅值切片和定点摘要。
+- `频率响应 / Frequency response`:按每个 manifest response 显示原始/软件校正幅频和相频、基线与自适应摘要、拟合对比、逐点表格、拟合公式/参数和原始采集包链接;校准产物还会显示目标、留点验证误差、补偿热图、代表性幅值切片和定点摘要。安装 `.[report3d]` 后,至少 2 个 Vpp × 2 个频率节点的 response 会在 Bode 图前增加交互式三维增益曲面,可切换 Raw/Corrected、dB/V/V,并查看 warning、自动恢复次数和采集证据。
- `验收摘要 / Acceptance summary` 与 `预期 vs 实测 / Expected vs measured`:汇总 `[steps.expect]` 和 `[steps.expect_fft]` 的验收结果。
- `DMM 读数 / DMM readings`、`信号分析 / Signal analysis`、`波形预览 / Waveform previews`、`截图 / Screenshots`。
@@ -498,6 +498,8 @@ python -m wavebench run report data/runs/ --output reports/lowpass.html
PDF 会嵌入 HTML 中可见的截图、静态 SVG 曲线和表格,适合把报告发给他人或归档。CSV、拟合 JSON、NPY 和完整采集包仍是独立证据文件;PDF 中保留它们的链接,但不把大型原始波形伪装成可见图表。WeasyPrint 还需要操作系统提供 Cairo、Pango、GDK-PixBuf 和合适的中文字体。
+三维 HTML 使用报告目录内的 `report-assets/plotly.min.js`,不访问 CDN;移动或打包 HTML 时必须同时保留整个 `report-assets/`。failed 点在曲面中保持空洞,不插值补齐;单幅值或不足 2 × 2 的数据自动退回静态 Bode。PDF 的 compact 渲染路径不加载 Plotly,也不预留空白交互区域,因此仍是单文件静态归档。
+
典型 sweep 流程:
```powershell
diff --git "a/doc/project/WaveBench_\346\225\260\346\215\256\350\276\223\345\207\272\346\240\274\345\274\217.md" "b/doc/project/WaveBench_\346\225\260\346\215\256\350\276\223\345\207\272\346\240\274\345\274\217.md"
index 4cbbaa5..9f95ad7 100644
--- "a/doc/project/WaveBench_\346\225\260\346\215\256\350\276\223\345\207\272\346\240\274\345\274\217.md"
+++ "b/doc/project/WaveBench_\346\225\260\346\215\256\350\276\223\345\207\272\346\240\274\345\274\217.md"
@@ -705,3 +705,21 @@ correction_limited,slope_limited
- 默认是 16 位有符号二补码 `Q4.12`、半值远离零的最近整数、幅值主序 `linear_index = amplitude_index * frequency_count + frequency_index`、`overflow = error`,绝不静默饱和。
- fixed CSV 逐地址记录 Vpp/频率索引、原值、量化整数/十六进制、量化值和逐点误差;calibration JSON 的 `fixed_point` 记录格式、配置、编码、地址映射、文件路径和最大绝对量化误差。
- `[calibration.fixed_point]` 可覆盖 `formats`(`csv` / `coe` / `mem`)、`word_width`、`fractional_bits`、`layout`、`rounding` 与 `overflow`;`saturate` 必须显式选择。
+
+### 交互式三维 HTML 资源
+
+安装 `WaveBench[report3d]` 后,对至少 2 个请求 Vpp × 2 个频率节点的 response 运行 `run report` 会额外写入:
+
+```text
+/
+├─ report.html
+└─ report-assets/
+ ├─ plotly.min.js
+ └─ manifest.json
+```
+
+- 所有 response 共用同一份本地 `plotly.min.js`,HTML 只写相对路径,不访问 CDN。`manifest.json` 的 `interactive_assets[]` 记录 `kind = "plotly.js"`、相对 `path` 和生成时的 `exists` 状态。
+- X 坐标使用 `log10(frequency_hz)` 几何位置,但刻度和 hover 显示实际 Hz;Y 为 `requested_vpp`;Z 优先使用 `gain_db_corrected` / `gain_linear_corrected`,也可切换到原始增益和 dB/V/V。
+- 曲面矩阵只来自 CSV 已有节点。`failed` 点写为缺失值并留洞,绝不补点或域外外推;圆点才表示真实采样。`warning` 点和成功自动恢复点使用独立标记,hover 保留首次 warning、重试次数、首次与最终采集路径。
+- 单幅值或不足 2 × 2 的 response 不生成伪曲面,只保留静态 Bode 图。缺少 `report3d` extra 时报告仍能生成,并显示安装提示。
+- 移动 `report.html` 时必须同时携带同级 `report-assets/`。PDF compact 路径完全不加载 Plotly,继续作为单文件静态视觉归档。
diff --git a/pyproject.toml b/pyproject.toml
index 4ae61a5..c6bbf9c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -16,6 +16,7 @@ dependencies = ["RsInstrument>=1.90.0", "numpy>=2.0.0", "packaging>=24.0", "pyse
analysis = ["scipy>=1.14.0"]
dev = ["hatchling==1.30.1", "pytest>=8.0.0", "ruff==0.15.20"]
pdf = ["weasyprint>=62.0"]
+report3d = ["plotly>=6.0"]
tui = ["textual>=0.86.0"]
[project.scripts]
@@ -25,7 +26,7 @@ wavebench = "wavebench.cli:main"
packages = ["src/wavebench"]
[tool.hatch.build.targets.sdist]
-exclude = ["/doc/instruments"]
+exclude = ["/data", "/doc/instruments"]
[tool.pytest.ini_options]
pythonpath = ["src"]
diff --git a/src/wavebench/report/html.py b/src/wavebench/report/html.py
index 9331ba7..9d237fb 100644
--- a/src/wavebench/report/html.py
+++ b/src/wavebench/report/html.py
@@ -12,12 +12,30 @@
from wavebench.data.packages import FrequencyResponsePackage, RunPackage
from wavebench.errors import ConfigError
+from wavebench.report.plot3d import (
+ build_surface_payload,
+ plotly_head_tag,
+ plotly_initializer,
+ render_surface_card,
+ write_plotly_asset,
+)
def write_run_report_html(run: RunPackage, output_path: str | Path | None = None) -> Path:
path = Path(output_path) if output_path is not None else run.path / "report.html"
- path.write_text(render_run_report_html(run, output_dir=path.parent), encoding="utf-8")
- write_run_report_manifest(run, output_dir=path.parent, report_path=path)
+ path.parent.mkdir(parents=True, exist_ok=True)
+ has_surface = any(
+ build_surface_payload(response.rows, plot_id="probe", response_label=response.label)
+ for response in run.frequency_responses
+ )
+ plotly_asset = write_plotly_asset(path.parent) if has_surface else None
+ plotly_url = _relative_url(plotly_asset, path.parent) if plotly_asset is not None else None
+ path.write_text(
+ render_run_report_html(run, output_dir=path.parent, plotly_url=plotly_url), encoding="utf-8"
+ )
+ write_run_report_manifest(
+ run, output_dir=path.parent, report_path=path, interactive_asset_path=plotly_asset
+ )
return path
@@ -43,7 +61,10 @@ def write_run_report_pdf(run: RunPackage, output_path: str | Path | None = None)
def write_run_report_manifest(
- run: RunPackage, output_dir: str | Path | None = None, report_path: str | Path | None = None
+ run: RunPackage,
+ output_dir: str | Path | None = None,
+ report_path: str | Path | None = None,
+ interactive_asset_path: str | Path | None = None,
) -> Path:
report_output_dir = Path(output_dir) if output_dir is not None else run.path
manifest_path = report_output_dir / "report-assets" / "manifest.json"
@@ -52,6 +73,7 @@ def write_run_report_manifest(
run,
output_dir=report_output_dir,
report_path=Path(report_path) if report_path is not None else report_output_dir / "report.html",
+ interactive_asset_path=(Path(interactive_asset_path) if interactive_asset_path is not None else None),
)
manifest_path.write_text(json.dumps(manifest, indent=2, ensure_ascii=False), encoding="utf-8")
return manifest_path
@@ -178,6 +200,7 @@ def render_run_report_html(
output_dir: str | Path | None = None,
*,
compact: bool = False,
+ plotly_url: str | None = None,
) -> str:
experiment = run.run.get("experiment", {}) if isinstance(run.run.get("experiment"), dict) else {}
restore = run.run.get("restore", {}) if isinstance(run.run.get("restore"), dict) else {}
@@ -202,7 +225,12 @@ def render_run_report_html(
expectations_block = "" if compact else _expectations_block(expectations)
dmm_block = "" if compact else _dmm_readings_block(dmm_readings)
sweep_block = "" if compact else _sweep_summary_block(sweep_rows)
- frequency_response_block = _frequency_response_block(run, include_table=not compact)
+ frequency_response_block = _frequency_response_block(
+ run,
+ include_table=not compact,
+ include_interactive_3d=not compact,
+ plotly_url=plotly_url if not compact else None,
+ )
artifact_links_block = "" if compact else _artifact_links_block(artifact_links)
signals_block = "" if compact else _signals_block(signals)
waveform_previews_block = "" if compact else _waveform_previews_block(waveform_previews)
@@ -294,6 +322,13 @@ def render_run_report_html(
.frequency-response-card {{ padding: 0.85rem; }}
.frequency-response-card h3 {{ margin: 0 0 0.35rem; font-size: 1rem; }}
.frequency-response-card svg {{ display: block; width: 100%; height: auto; margin-top: 0.5rem; border-radius: 8px; }}
+ .response-3d-card {{ margin: 0.5rem 0 1.25rem; }}
+ .response-3d-controls {{ display: flex; flex-wrap: wrap; gap: 0.75rem; margin: 0.4rem 0; }}
+ .response-3d-controls label {{ color: var(--muted); font-size: 0.9rem; }}
+ .response-3d-controls select {{ margin-left: 0.3rem; padding: 0.25rem 0.4rem; border: 1px solid var(--line); border-radius: 6px; background: var(--surface); color: var(--text); }}
+ .response-3d-controls button {{ padding: 0.25rem 0.6rem; border: 1px solid var(--line); border-radius: 6px; background: var(--surface); color: var(--text); cursor: pointer; }}
+ .response-3d-controls button:hover {{ border-color: var(--accent); }}
+ .response-3d-plot {{ width: 100%; height: 34rem; min-height: 28rem; }}
.fit-formula {{ white-space: pre-wrap; overflow-wrap: anywhere; }}
@page {{ size: A4 landscape; margin: 10mm; }}
@media print {{
@@ -314,11 +349,12 @@ def render_run_report_html(
body.pdf-compact .summary-card .value.ok, body.pdf-compact .summary-card .value.failed, body.pdf-compact .summary-card .value.warning {{ font-size: 8.5pt; padding: 0.04rem 0.3rem; }}
}}
+{plotly_head_tag(plotly_url if not compact else None)}
WaveBench 运行报告 Run report
-A static offline hardware validation report.
+An offline hardware validation report.
{compact_note}
{_summary_block(summary, compact=compact)}
{evidence_summary_block}
@@ -350,12 +386,19 @@ def render_run_report_html(
{waveform_previews_block}
{screenshots_block}
+{plotly_initializer(plotly_url if not compact else None)}
"""
-def _build_report_manifest(run: RunPackage, *, output_dir: Path, report_path: Path) -> dict[str, Any]:
+def _build_report_manifest(
+ run: RunPackage,
+ *,
+ output_dir: Path,
+ report_path: Path,
+ interactive_asset_path: Path | None = None,
+) -> dict[str, Any]:
screenshots = _collect_screenshots(run, output_dir)
capture_packages: list[dict[str, Any]] = []
waveform_previews: list[dict[str, Any]] = []
@@ -465,6 +508,17 @@ def _build_report_manifest(run: RunPackage, *, output_dir: Path, report_path: Pa
for item in screenshots
],
"waveform_previews": waveform_previews,
+ "interactive_assets": (
+ [
+ {
+ "kind": "plotly.js",
+ "path": _relative_url(interactive_asset_path, output_dir),
+ "exists": interactive_asset_path.exists(),
+ }
+ ]
+ if interactive_asset_path is not None
+ else []
+ ),
"warnings": warnings,
}
@@ -782,18 +836,44 @@ def _sweep_summary_row(row: ReportSweepRow) -> str:
)
-def _frequency_response_block(run: RunPackage, *, include_table: bool = True) -> str:
+def _frequency_response_block(
+ run: RunPackage,
+ *,
+ include_table: bool = True,
+ include_interactive_3d: bool = True,
+ plotly_url: str | None = None,
+) -> str:
if not run.frequency_responses:
return ""
multiple = len(run.frequency_responses) > 1
return "".join(
- _frequency_response_section(response, include_table=include_table, multiple=multiple)
- for response in run.frequency_responses
+ _frequency_response_section(
+ response,
+ include_table=include_table,
+ multiple=multiple,
+ interactive_3d=(
+ render_surface_card(
+ build_surface_payload(
+ response.rows,
+ plot_id=f"wavebench-frequency-response-3d-{index}",
+ response_label=response.label,
+ ),
+ plotly_url=plotly_url,
+ )
+ if include_interactive_3d
+ else ""
+ ),
+ )
+ for index, response in enumerate(run.frequency_responses)
)
def _frequency_response_section(
- response: FrequencyResponsePackage, *, include_table: bool, multiple: bool
+ response: FrequencyResponsePackage,
+ *,
+ include_table: bool,
+ multiple: bool,
+ interactive_3d: str = "",
) -> str:
rows = response.rows
raw_gain_measurements = _response_amplitude_series(rows, "gain_db")
@@ -883,6 +963,7 @@ def _frequency_response_section(
{matrix_summary}
{baseline_block}
{adaptive_block}
+{interactive_3d}
原始幅频 / Raw magnitude {gain_svg}
原始相频 / Raw phase {phase_svg}
diff --git a/src/wavebench/report/plot3d.py b/src/wavebench/report/plot3d.py
new file mode 100644
index 0000000..6969a5f
--- /dev/null
+++ b/src/wavebench/report/plot3d.py
@@ -0,0 +1,328 @@
+from __future__ import annotations
+
+from html import escape
+import json
+from math import isfinite, log10
+from pathlib import Path
+from typing import Any
+
+
+PLOTLY_ASSET_RELATIVE = Path("report-assets") / "plotly.min.js"
+
+
+def write_plotly_asset(output_dir: Path) -> Path | None:
+ """Write Plotly.js beside the report when the optional dependency is installed."""
+ try:
+ from plotly.offline import get_plotlyjs
+ except (ImportError, ModuleNotFoundError):
+ return None
+ path = output_dir / PLOTLY_ASSET_RELATIVE
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(get_plotlyjs(), encoding="utf-8")
+ return path
+
+
+def build_surface_payload(
+ rows: list[dict[str, str]], *, plot_id: str, response_label: str
+) -> dict[str, Any] | None:
+ frequencies = sorted(
+ {
+ value
+ for row in rows
+ if (value := _finite_float(row.get("requested_frequency_hz"))) is not None and value > 0
+ }
+ )
+ amplitudes = sorted(
+ {
+ value
+ for row in rows
+ if (value := _finite_float(row.get("requested_vpp"))) is not None and value > 0
+ }
+ )
+ if len(frequencies) < 2 or len(amplitudes) < 2:
+ return None
+
+ lookup = {
+ (amplitude, frequency): row
+ for row in rows
+ if (amplitude := _finite_float(row.get("requested_vpp"))) is not None
+ and amplitude > 0
+ and (frequency := _finite_float(row.get("requested_frequency_hz"))) is not None
+ and frequency > 0
+ }
+ modes_to_basis_unit = {
+ "raw_db": ("raw", "db"),
+ "raw_linear": ("raw", "linear"),
+ "corrected_db": ("corrected", "db"),
+ "corrected_linear": ("corrected", "linear"),
+ }
+ modes: dict[str, list[list[float | None]]] = {}
+ for mode, (basis, unit) in modes_to_basis_unit.items():
+ matrix = [
+ [
+ _gain_value(lookup.get((amplitude, frequency)), basis=basis, unit=unit)
+ for frequency in frequencies
+ ]
+ for amplitude in amplitudes
+ ]
+ if any(value is not None for row in matrix for value in row):
+ modes[mode] = matrix
+ if "raw_db" not in modes and "raw_linear" not in modes:
+ return None
+
+ default_mode = next(
+ mode
+ for mode in ("corrected_db", "corrected_linear", "raw_db", "raw_linear")
+ if mode in modes
+ )
+ points = []
+ for amplitude in amplitudes:
+ for frequency in frequencies:
+ row = lookup.get((amplitude, frequency))
+ if row is None:
+ continue
+ values = {
+ mode: _gain_value(row, basis=basis, unit=unit)
+ for mode, (basis, unit) in modes_to_basis_unit.items()
+ }
+ points.append(
+ {
+ "frequency_hz": frequency,
+ "x_log10": log10(frequency),
+ "requested_vpp": amplitude,
+ "status": str(row.get("status", "")).lower(),
+ "warnings": str(row.get("warnings", "")),
+ "error": str(row.get("error", "")),
+ "quality_retry_count": _nonnegative_int(row.get("quality_retry_count")),
+ "initial_warnings": str(row.get("initial_warnings", "")),
+ "initial_capture_package": str(row.get("initial_capture_package", "")),
+ "capture_package": str(row.get("capture_package", "")),
+ **values,
+ }
+ )
+ tick_indexes = _sample_indexes(len(frequencies), 8)
+ return {
+ "plot_id": plot_id,
+ "response_label": response_label,
+ "frequencies_hz": frequencies,
+ "x_log10": [log10(value) for value in frequencies],
+ "amplitudes_vpp": amplitudes,
+ "modes": modes,
+ "default_mode": default_mode,
+ "points": points,
+ "x_ticks": [log10(frequencies[index]) for index in tick_indexes],
+ "x_tick_labels": [_format_frequency(frequencies[index]) for index in tick_indexes],
+ }
+
+
+def render_surface_card(payload: dict[str, Any] | None, *, plotly_url: str | None) -> str:
+ if payload is None:
+ return ""
+ if plotly_url is None:
+ return (
+ ''
+ '三维增益曲面 / Interactive 3D gain surface '
+ '安装 WaveBench[report3d] 后重新生成 HTML,'
+ '即可离线旋转二维扫频增益曲面。
'
+ )
+ plot_id = escape(str(payload["plot_id"]), quote=True)
+ corrected_available = any(mode.startswith("corrected_") for mode in payload["modes"])
+ corrected_disabled = "" if corrected_available else " disabled"
+ corrected_selected = " selected" if payload["default_mode"].startswith("corrected") else ""
+ raw_selected = "" if corrected_selected else " selected"
+ linear_selected = " selected" if payload["default_mode"].endswith("linear") else ""
+ db_selected = "" if linear_selected else " selected"
+ return (
+ ''
+ '三维增益曲面 / Interactive 3D gain surface '
+ ''
+ f'数据 / Data '
+ f'Raw '
+ f'Corrected '
+ ' '
+ f'增益 / Gain '
+ f'dB '
+ f'V/V '
+ ' '
+ f''
+ '重置视角 / Reset view
'
+ f'
'
+ f''
+ '曲面只连接相邻实测节点;圆点代表真实采样,failed 节点留洞且不外推。
'
+ ' '
+ )
+
+
+def plotly_head_tag(plotly_url: str | None) -> str:
+ if plotly_url is None:
+ return ""
+ return f''
+
+
+def plotly_initializer(plotly_url: str | None) -> str:
+ if plotly_url is None:
+ return ""
+ return """"""
+
+
+def _gain_value(row: dict[str, str] | None, *, basis: str, unit: str) -> float | None:
+ if row is None or str(row.get("status", "")).lower() == "failed":
+ return None
+ suffix = "_corrected" if basis == "corrected" else ""
+ value = _finite_float(row.get(f"gain_{unit}{suffix}"))
+ if value is not None:
+ return value
+ if unit == "db":
+ linear = _finite_float(row.get(f"gain_linear{suffix}"))
+ return 20.0 * log10(linear) if linear is not None and linear > 0 else None
+ gain_db = _finite_float(row.get(f"gain_db{suffix}"))
+ return 10.0 ** (gain_db / 20.0) if gain_db is not None else None
+
+
+def _finite_float(value: Any) -> float | None:
+ try:
+ result = float(value)
+ except (TypeError, ValueError):
+ return None
+ return result if isfinite(result) else None
+
+
+def _nonnegative_int(value: Any) -> int:
+ try:
+ result = int(value)
+ except (TypeError, ValueError):
+ return 0
+ return max(0, result)
+
+
+def _sample_indexes(count: int, maximum: int) -> list[int]:
+ if count <= maximum:
+ return list(range(count))
+ return sorted({round(index * (count - 1) / (maximum - 1)) for index in range(maximum)})
+
+
+def _format_frequency(value: float) -> str:
+ if value >= 1e6:
+ return f"{value / 1e6:g} MHz"
+ if value >= 1e3:
+ return f"{value / 1e3:g} kHz"
+ return f"{value:g} Hz"
+
+
+def _json_for_html(value: Any) -> str:
+ return (
+ json.dumps(value, ensure_ascii=False, separators=(",", ":"), allow_nan=False)
+ .replace("&", "\\u0026")
+ .replace("<", "\\u003c")
+ .replace(">", "\\u003e")
+ )
diff --git a/tests/test_release_artifacts.py b/tests/test_release_artifacts.py
index 58d735b..0f55abf 100644
--- a/tests/test_release_artifacts.py
+++ b/tests/test_release_artifacts.py
@@ -7,7 +7,7 @@
PROJECT_ROOT = Path(__file__).resolve().parents[1]
-def test_sdist_excludes_instrument_reference_material(tmp_path: Path) -> None:
+def test_sdist_excludes_runtime_data_and_instrument_reference_material(tmp_path: Path) -> None:
subprocess.run(
[
sys.executable,
@@ -29,6 +29,8 @@ def test_sdist_excludes_instrument_reference_material(tmp_path: Path) -> None:
assert len(artifacts) == 1
with tarfile.open(artifacts[0]) as archive:
members = archive.getnames()
+ relative_members = [member.partition("/")[2] for member in members]
assert any("/doc/project/" in member for member in members)
+ assert not any(member.startswith("data/") for member in relative_members)
assert not any("/doc/instruments/" in member for member in members)
diff --git a/tests/test_report_plot3d.py b/tests/test_report_plot3d.py
new file mode 100644
index 0000000..46903c3
--- /dev/null
+++ b/tests/test_report_plot3d.py
@@ -0,0 +1,263 @@
+import json
+from math import log10
+import shutil
+import subprocess
+import sys
+import types
+import unittest
+from pathlib import Path
+from tempfile import TemporaryDirectory
+from unittest.mock import patch
+
+from wavebench.data.packages import load_run_package
+from wavebench.report.html import render_run_report_html, write_run_report_html
+from wavebench.report.plot3d import (
+ PLOTLY_ASSET_RELATIVE,
+ build_surface_payload,
+ plotly_initializer,
+ render_surface_card,
+ write_plotly_asset,
+)
+
+
+def _surface_rows(*, corrected: bool = True) -> list[dict[str, str]]:
+ rows: list[dict[str, str]] = []
+ for amplitude in (0.5, 1.0):
+ for frequency in (10.0, 100.0):
+ row = {
+ "requested_vpp": str(amplitude),
+ "requested_frequency_hz": str(frequency),
+ "gain_db": str(-frequency / 100.0 - amplitude),
+ "status": "ok",
+ "warnings": "",
+ "error": "",
+ "quality_retry_count": "0",
+ "initial_warnings": "",
+ "initial_capture_package": "",
+ "capture_package": f"data/raw/{amplitude}_{frequency}",
+ }
+ if corrected:
+ # Supplying only linear corrected gain exercises dB/V/V conversion.
+ row["gain_linear_corrected"] = str(0.9 - frequency / 1000.0)
+ rows.append(row)
+ rows[0].update(
+ {
+ "quality_retry_count": "1",
+ "initial_warnings": "reference frequency_mismatch",
+ "initial_capture_package": "data/raw/first_warning",
+ }
+ )
+ rows[1].update({"status": "warning", "warnings": "response low_signal_amplitude"})
+ rows[-1].update({"status": "failed", "error": "retry still warning"})
+ return rows
+
+
+def _write_legacy_2d_run(run_dir: Path) -> None:
+ run_dir.mkdir(parents=True)
+ (run_dir / "run.json").write_text(
+ json.dumps({"status": "ok", "steps": []}), encoding="utf-8"
+ )
+ header = (
+ "requested_vpp,requested_frequency_hz,gain_linear,gain_db,"
+ "gain_linear_corrected,gain_db_corrected,status,warnings,error,"
+ "quality_retry_count,initial_warnings,initial_capture_package,capture_package"
+ )
+ rows = [header]
+ for amplitude in (0.5, 1.0):
+ for frequency in (10, 100):
+ rows.append(
+ f"{amplitude},{frequency},0.8,-1.938,0.9,-0.915,ok,,,0,,,"
+ f"data/raw/{amplitude}_{frequency}"
+ )
+ (run_dir / "frequency_response.csv").write_text("\n".join(rows), encoding="utf-8")
+
+
+class FrequencyResponsePlot3DTests(unittest.TestCase):
+ def test_surface_payload_supports_four_modes_and_preserves_quality_audit(self):
+ payload = build_surface_payload(
+ _surface_rows(), plot_id="plot-a", response_label="output"
+ )
+
+ self.assertIsNotNone(payload)
+ assert payload is not None
+ self.assertEqual(
+ set(payload["modes"]),
+ {"raw_db", "raw_linear", "corrected_db", "corrected_linear"},
+ )
+ self.assertEqual(payload["default_mode"], "corrected_db")
+ self.assertEqual(payload["x_log10"], [1.0, 2.0])
+ self.assertEqual(payload["x_tick_labels"], ["10 Hz", "100 Hz"])
+ self.assertAlmostEqual(payload["modes"]["raw_linear"][0][0], 10 ** (-0.6 / 20))
+ self.assertAlmostEqual(
+ payload["modes"]["corrected_db"][0][0], 20 * log10(0.89)
+ )
+ self.assertIsNone(payload["modes"]["raw_db"][1][1])
+ self.assertIsNone(payload["modes"]["corrected_linear"][1][1])
+ recovered = next(
+ point for point in payload["points"] if point["quality_retry_count"] == 1
+ )
+ self.assertEqual(recovered["initial_warnings"], "reference frequency_mismatch")
+ self.assertEqual(recovered["initial_capture_package"], "data/raw/first_warning")
+ warning = next(point for point in payload["points"] if point["status"] == "warning")
+ self.assertEqual(warning["warnings"], "response low_signal_amplitude")
+
+ def test_surface_payload_falls_back_to_raw_and_does_not_invent_a_surface(self):
+ payload = build_surface_payload(
+ _surface_rows(corrected=False), plot_id="plot-a", response_label="output"
+ )
+
+ self.assertIsNotNone(payload)
+ assert payload is not None
+ self.assertEqual(set(payload["modes"]), {"raw_db", "raw_linear"})
+ self.assertEqual(payload["default_mode"], "raw_db")
+ for rows in (
+ _surface_rows()[:2],
+ [row for row in _surface_rows() if row["requested_frequency_hz"] == "10.0"],
+ ):
+ with self.subTest(rows=len(rows)):
+ self.assertIsNone(
+ build_surface_payload(rows, plot_id="too-small", response_label="small")
+ )
+
+ def test_surface_card_has_switches_reset_and_missing_dependency_fallback(self):
+ payload = build_surface_payload(
+ _surface_rows(), plot_id="plot-a", response_label="output"
+ )
+
+ interactive = render_surface_card(payload, plotly_url="report-assets/plotly.min.js")
+ fallback = render_surface_card(payload, plotly_url=None)
+
+ self.assertIn('data-role="basis"', interactive)
+ self.assertIn('data-role="unit"', interactive)
+ self.assertIn('data-role="reset"', interactive)
+ self.assertIn('class="response-3d-data"', interactive)
+ self.assertIn("first_warning", interactive)
+ self.assertIn("WaveBench[report3d]", fallback)
+ initializer = plotly_initializer("report-assets/plotly.min.js")
+ self.assertIn('connectgaps: false', initializer)
+ self.assertIn('name: "Recovered"', initializer)
+ self.assertIn('name: "Warning"', initializer)
+ self.assertIn("Plotly.relayout", initializer)
+
+ @unittest.skipUnless(shutil.which("node"), "Node.js is not installed")
+ def test_plotly_initializer_is_valid_javascript(self):
+ initializer = plotly_initializer("report-assets/plotly.min.js")
+ javascript = initializer.removeprefix("")
+
+ result = subprocess.run(
+ ["node", "--check"],
+ input=javascript,
+ text=True,
+ capture_output=True,
+ check=False,
+ )
+
+ self.assertEqual(result.returncode, 0, result.stderr)
+
+ def test_plotly_asset_writer_is_lazy_and_writes_the_bundled_runtime(self):
+ with TemporaryDirectory() as tmp:
+ output_dir = Path(tmp)
+ with patch.dict(sys.modules, {"plotly": None, "plotly.offline": None}):
+ self.assertIsNone(write_plotly_asset(output_dir))
+
+ package = types.ModuleType("plotly")
+ package.__path__ = []
+ offline = types.ModuleType("plotly.offline")
+ offline.get_plotlyjs = lambda: "window.Plotly = {version: 'test'};"
+ with patch.dict(sys.modules, {"plotly": package, "plotly.offline": offline}):
+ asset = write_plotly_asset(output_dir)
+
+ self.assertEqual(asset, output_dir / PLOTLY_ASSET_RELATIVE)
+ assert asset is not None
+ self.assertEqual(asset.read_text(encoding="utf-8"), "window.Plotly = {version: 'test'};")
+
+ def test_html_export_writes_shared_asset_manifest_and_relative_link(self):
+ with TemporaryDirectory() as tmp:
+ root = Path(tmp)
+ run_dir = root / "data" / "runs" / "run"
+ _write_legacy_2d_run(run_dir)
+ output = root / "exports" / "nested" / "report.html"
+
+ def fake_asset_writer(output_dir: Path) -> Path:
+ asset = output_dir / PLOTLY_ASSET_RELATIVE
+ asset.parent.mkdir(parents=True, exist_ok=True)
+ asset.write_text("/* local Plotly */", encoding="utf-8")
+ return asset
+
+ with patch(
+ "wavebench.report.html.write_plotly_asset", side_effect=fake_asset_writer
+ ):
+ result = write_run_report_html(load_run_package(run_dir), output_path=output)
+
+ self.assertEqual(result, output)
+ html = output.read_text(encoding="utf-8")
+ self.assertIn('', html)
+ self.assertIn('id="wavebench-frequency-response-3d-0"', html)
+ manifest = json.loads(
+ (output.parent / "report-assets" / "manifest.json").read_text(encoding="utf-8")
+ )
+ self.assertEqual(
+ manifest["interactive_assets"],
+ [{"kind": "plotly.js", "path": "report-assets/plotly.min.js", "exists": True}],
+ )
+
+ def test_html_export_without_plotly_keeps_static_report_and_enable_hint(self):
+ with TemporaryDirectory() as tmp:
+ run_dir = Path(tmp) / "run"
+ _write_legacy_2d_run(run_dir)
+
+ with patch("wavebench.report.html.write_plotly_asset", return_value=None):
+ output = write_run_report_html(load_run_package(run_dir))
+
+ html = output.read_text(encoding="utf-8")
+ self.assertIn("WaveBench[report3d]", html)
+ self.assertIn("原始幅频 / Raw magnitude", html)
+ self.assertNotIn("plotly.min.js", html)
+ manifest = json.loads(
+ (run_dir / "report-assets" / "manifest.json").read_text(encoding="utf-8")
+ )
+ self.assertEqual(manifest["interactive_assets"], [])
+
+ def test_multi_response_uses_unique_dom_ids_and_pdf_compact_omits_plotly(self):
+ with TemporaryDirectory() as tmp:
+ run_dir = Path(tmp) / "run"
+ run_dir.mkdir()
+ (run_dir / "run.json").write_text(
+ json.dumps({"status": "ok", "steps": []}), encoding="utf-8"
+ )
+ entries = []
+ for index, label in enumerate(("input", "output")):
+ directory = run_dir / "frequency_response" / label
+ _write_legacy_2d_run(directory)
+ entries.append(
+ {
+ "step_index": index,
+ "label": label,
+ "directory": f"frequency_response/{label}",
+ }
+ )
+ (run_dir / "frequency_responses.json").write_text(
+ json.dumps({"schema_version": 1, "responses": entries}), encoding="utf-8"
+ )
+ run = load_run_package(run_dir)
+
+ html = render_run_report_html(
+ run, output_dir=run_dir, plotly_url="report-assets/plotly.min.js"
+ )
+ compact = render_run_report_html(
+ run,
+ output_dir=run_dir,
+ compact=True,
+ plotly_url="report-assets/plotly.min.js",
+ )
+
+ self.assertEqual(html.count('class="response-3d-data"'), 2)
+ self.assertIn('id="wavebench-frequency-response-3d-0"', html)
+ self.assertIn('id="wavebench-frequency-response-3d-1"', html)
+ self.assertNotIn("response-3d-data", compact)
+ self.assertNotIn("plotly.min.js", compact)
+ self.assertNotIn("window.addEventListener", compact)
+
+
+if __name__ == "__main__":
+ unittest.main()
From f49024478665caeb28141ffab32d7f3c1e49d5cd Mon Sep 17 00:00:00 2001
From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com>
Date: Thu, 6 Aug 2026 11:31:05 +0800
Subject: [PATCH 9/9] feat: add sweep threshold and collapsible reports
---
...77\347\224\250\346\214\207\345\215\227.md" | 3 +
...23\345\207\272\346\240\274\345\274\217.md" | 2 +-
...ctive_filter_raw_2d_10mv_2v_10hz_5mhz.toml | 63 +++++++++++++++++++
src/wavebench/config.py | 6 ++
src/wavebench/data/quality.py | 10 ++-
src/wavebench/instruments/models.py | 2 +
src/wavebench/report/html.py | 57 +++++++++++++++--
src/wavebench/services/frequency_response.py | 17 ++++-
src/wavebench/services/run_plan.py | 4 ++
src/wavebench/services/run_service.py | 4 ++
src/wavebench/services/scope_service.py | 3 +
tests/test_config_overrides.py | 13 ++++
tests/test_frequency_response.py | 19 ++++++
tests/test_quality.py | 15 +++++
tests/test_report.py | 47 +++++++++++++-
tests/test_run_plan.py | 14 +++++
tests/test_run_service.py | 8 +++
17 files changed, 274 insertions(+), 13 deletions(-)
create mode 100644 plans/active_filter_raw_2d_10mv_2v_10hz_5mhz.toml
diff --git "a/doc/project/WaveBench_run_plan_\344\275\277\347\224\250\346\214\207\345\215\227.md" "b/doc/project/WaveBench_run_plan_\344\275\277\347\224\250\346\214\207\345\215\227.md"
index d70202b..e6de222 100644
--- "a/doc/project/WaveBench_run_plan_\344\275\277\347\224\250\346\214\207\345\215\227.md"
+++ "b/doc/project/WaveBench_run_plan_\344\275\277\347\224\250\346\214\207\345\215\227.md"
@@ -190,12 +190,15 @@ max_slope_db_per_octave = 6
| `start_vpp` / `stop_vpp` / `vpp_step` | 三者必须同时出现;`stop_vpp > start_vpp`,步进必须整除区间。 |
| `autoscale_each_amplitude` | 多幅值时默认 `true`;仅在已人工确认量程时才建议设为 `false`。 |
| `retry_warning_with_autoscale` | 默认 `true`;单点出现质量 warning 时 autoscale、稳定后重测一次。重测仍 warning 则该点标为 failed,并保留两次采集证据。 |
+| `min_signal_vpp` | 仅本频响 step 的低信号 warning 门限,默认 `0.020` Vpp。若已用实测波形确认低幅值切片仍有足够 SNR,可降低(例如 `0.005`);它会写入每点原始 metadata 的 operation,不能用来掩盖削顶、频率失配等其他 warning。 |
| `[steps.calibration]` | 仅多幅值数据有实际意义;`enabled = false` 可关闭自动派生。 |
执行顺序固定为“设定 Vpp → 对每个频点设频并等待 `settle_s` → 每个幅值切片的首个频点 autoscale → 再等待 `settle_s` → 同步采集 CH1/CH2”。`autoscale_each_amplitude = false` 可显式关闭该切片首点 autoscale,但仅适用于已由人工确认两路量程足够的情况。每个 Vpp 值都在连接仪器前受 `[safety_limits].max_source_vpp` 检查。
频响点的首次采集若出现质量 warning,默认会执行一次“autoscale → 等待 `settle_s` → 同频同幅值重测”。重测成功时 CSV 的最终值来自第二次采集,并用 `quality_retry_count`、`initial_warnings`、`initial_capture_package` 和 `initial_metadata_path` 保存首次证据;重测仍 warning 时该点标为 `failed`、带 `quality_retry_exhausted` 原因,不参与拟合、软件校正或二维 LUT。可在 step 中设 `retry_warning_with_autoscale = false` 关闭此策略。
+默认 20 mVpp 的门限是保守质量保护,不是仪器限制。对已确认稳定的低幅值扫频,可只在该 `sweep.frequency_response` 中设置 `min_signal_vpp = 0.005`;这会在采集分析阶段生效,因此 `metadata.json`、CSV 状态和报告审计使用同一个判断依据。全局 `[waveform]` 也可设同名默认值,但不建议为了一个低幅值实验而永久降低所有普通采集的门限。
+
完整矩阵的点数为 `幅值数 × 频点数`;每个点至少消耗 `settle_s`,每个幅值切片还会增加一次 autoscale 与稳定等待。先用少量幅值和稀疏频点执行 `run check`、只读 `run verify`、再做实机小矩阵确认量程和耗时,别拿 5 mV 步进和 500 Hz 步进直接开 13 小时以上的盲扫,没必要给仪器和人都上强度。
`[steps.calibration]` 需要至少两个请求 Vpp 切片且每个切片至少四个共同有效频点。它会在测量结束后使用每切片的 dB 平滑样条、请求 Vpp 方向线性插值生成校准;频率节点沿用实测点,不在频率或 Vpp 定义域外外推。自动校准失败不会删除或重写原始频响 CSV:该 step 变为 `warning`,`artifact.frequency_response.calibration_error` 记录原因,之后可修正配置或数据再离线重算。
diff --git "a/doc/project/WaveBench_\346\225\260\346\215\256\350\276\223\345\207\272\346\240\274\345\274\217.md" "b/doc/project/WaveBench_\346\225\260\346\215\256\350\276\223\345\207\272\346\240\274\345\274\217.md"
index 9f95ad7..30b574f 100644
--- "a/doc/project/WaveBench_\346\225\260\346\215\256\350\276\223\345\207\272\346\240\274\345\274\217.md"
+++ "b/doc/project/WaveBench_\346\225\260\346\215\256\350\276\223\345\207\272\346\240\274\345\274\217.md"
@@ -648,7 +648,7 @@ data/runs/YYYYMMDD_HHMMSS_/
同一 run 含多个频响 step 时,根目录新增 `frequency_responses.json`(`schema_version = 1`)。其 `responses[]` 以唯一 `label`、`step_index`、相对 `directory` 和各派生产物引用描述每个响应;每个响应保存在 `frequency_response/_/`。旧 run 没有 manifest 时仍按根目录单响应产物读取。
-`frequency_response.csv` 每请求一个频点就原子刷新一次,因此 source 设频失败、scope 采集失败或分析失败时,前序记录和当前失败行仍会保留。稳定基础列为:
+`frequency_response.csv` 每请求一个频点就原子刷新一次,因此 source 设频失败、scope 采集失败或分析失败时,前序记录和当前失败行仍会保留。每个原始 `metadata.json.operation.min_signal_vpp` 记录当点采用的低信号门限;普通默认是 20 mVpp,频响 step 可显式降低,但不会抑制频率、削顶或其他质量 warning。稳定基础列为:
```text
index,amplitude_index,requested_vpp,requested_frequency_hz,reference_frequency_hz,response_frequency_hz,
diff --git a/plans/active_filter_raw_2d_10mv_2v_10hz_5mhz.toml b/plans/active_filter_raw_2d_10mv_2v_10hz_5mhz.toml
new file mode 100644
index 0000000..e67a327
--- /dev/null
+++ b/plans/active_filter_raw_2d_10mv_2v_10hz_5mhz.toml
@@ -0,0 +1,63 @@
+# 有源滤波器原始二维频响地图。
+# 已人工确认:DUT 独立稳定供电、2 Vpp 输入在线性范围内、CH2 输出不超过 10 Vpp。
+# 不引用旧直通基线,不生成校准 LUT;高频低幅值失败是有效域证据,不做外推。
+
+[experiment]
+name = "active_filter_raw_2d_10mv_2v_10hz_5mhz"
+label = "active_filter_raw_2d_10mv_2v_10hz_5mhz"
+
+[restore]
+source_state = true
+source_channel = 1
+
+[[steps]]
+kind = "source.set_func"
+channel = 1
+function = "sin"
+
+[[steps]]
+kind = "source.set_vpp"
+channel = 1
+value_vpp = 0.010
+
+[[steps]]
+kind = "source.set_freq"
+channel = 1
+frequency_hz = 10
+
+[[steps]]
+kind = "source.output"
+channel = 1
+state = "on"
+
+[[steps]]
+kind = "sweep.frequency_response"
+label = "active_filter_raw_2d"
+source_channel = 1
+reference_channel = 1
+response_channel = 2
+start_frequency_hz = 10
+stop_frequency_hz = 5000000
+frequency_count = 144
+spacing = "log"
+amplitudes_vpp = [
+ 0.010, 0.015, 0.022, 0.032, 0.047,
+ 0.068, 0.100, 0.150, 0.220, 0.320,
+ 0.470, 0.680, 1.000, 1.500, 2.000,
+]
+autoscale_each_amplitude = true
+retry_warning_with_autoscale = true
+target_cycles = 10
+# 10/15 mVpp 切片已实测具有稳定的参考/响应波形;仅降低本扫频的低信号告警门限。
+min_signal_vpp = 0.005
+settle_s = 2.0
+points = "def"
+save_csv = false
+screenshot = false
+
+[steps.adaptive]
+enabled = true
+gain_threshold_db = 0.5
+phase_threshold_deg = 10
+max_levels = 1
+max_frequency_points = 200
diff --git a/src/wavebench/config.py b/src/wavebench/config.py
index a17d726..edfb0e6 100644
--- a/src/wavebench/config.py
+++ b/src/wavebench/config.py
@@ -83,6 +83,7 @@ class WaveformConfig:
window_frequency_hz: float | None = None
vertical_scale_v_per_div: float | None = None
target_vpp: float | None = None
+ min_signal_vpp: float = 0.02
@dataclass(frozen=True)
class SourceConfig:
@@ -274,6 +275,7 @@ def with_waveform_overrides(
window_frequency_hz: float | None = None,
vertical_scale_v_per_div: float | None = None,
target_vpp: float | None = None,
+ min_signal_vpp: float | None = None,
) -> "WaveBenchConfig":
return WaveBenchConfig(
connection=self.connection,
@@ -300,6 +302,7 @@ def with_waveform_overrides(
else vertical_scale_v_per_div
),
target_vpp=self.waveform.target_vpp if target_vpp is None else target_vpp,
+ min_signal_vpp=self.waveform.min_signal_vpp if min_signal_vpp is None else min_signal_vpp,
),
output=self.output,
source_path=self.source_path,
@@ -524,6 +527,7 @@ def load_config(path: str | Path = "wavebench.toml") -> WaveBenchConfig:
else None
),
target_vpp=float(w["target_vpp"]) if "target_vpp" in w else None,
+ min_signal_vpp=float(w.get("min_signal_vpp", 0.02)),
),
output=OutputConfig(
directory=Path(str(o.get("directory", "data/raw"))),
@@ -587,6 +591,8 @@ def load_config(path: str | Path = "wavebench.toml") -> WaveBenchConfig:
raise ConfigError("waveform.vertical_scale_v_per_div must be > 0")
if config.waveform.target_vpp is not None and config.waveform.target_vpp <= 0:
raise ConfigError("waveform.target_vpp must be > 0")
+ if config.waveform.min_signal_vpp <= 0:
+ raise ConfigError("waveform.min_signal_vpp must be > 0")
if config.quality.auto_recover_attempts < 0:
raise ConfigError("quality.auto_recover_attempts must be >= 0")
if config.quality.consistency_required_captures < 2:
diff --git a/src/wavebench/data/quality.py b/src/wavebench/data/quality.py
index 44fc1d2..7bd9f5e 100644
--- a/src/wavebench/data/quality.py
+++ b/src/wavebench/data/quality.py
@@ -225,6 +225,7 @@ def quality_warnings(
tolerance_ratio: float,
sample_count: int,
voltage_vpp_v: float,
+ min_signal_vpp: float = 0.02,
) -> list[str]:
warnings: list[str] = []
frequency_required = expected_frequency_hz is not None
@@ -237,8 +238,11 @@ def quality_warnings(
points_per_cycle = sample_count / estimated_cycles
if points_per_cycle < 20.0:
warnings.append("low_points_per_cycle: waveform has fewer than 20 samples per estimated cycle; duty and edge metrics may be unreliable")
- if dynamic_signal and voltage_vpp_v < 0.02:
- warnings.append("low_signal_amplitude: waveform Vpp is below 20 mV; check channel scale, probe, or signal connection")
+ if dynamic_signal and voltage_vpp_v < min_signal_vpp:
+ warnings.append(
+ "low_signal_amplitude: waveform Vpp is below "
+ f"{min_signal_vpp * 1e3:g} mV; check channel scale, probe, or signal connection"
+ )
if frequency_error is not None and frequency_error > tolerance_ratio:
warnings.append("frequency_mismatch: estimated frequency differs from expected frequency")
return warnings
@@ -250,6 +254,7 @@ def summarize_waveform(
*,
expected_frequency_hz: float | None = None,
frequency_tolerance_ratio: float = 0.05,
+ min_signal_vpp: float = 0.02,
) -> WaveformQuality:
frequency = estimate_frequency_hysteresis(times_s, voltages_v)
method = "hysteresis_rising_crossing"
@@ -292,5 +297,6 @@ def summarize_waveform(
tolerance_ratio=frequency_tolerance_ratio,
sample_count=int(voltages_v.size),
voltage_vpp_v=voltage_vpp_v,
+ min_signal_vpp=min_signal_vpp,
),
)
diff --git a/src/wavebench/instruments/models.py b/src/wavebench/instruments/models.py
index e24ac40..3f00e68 100644
--- a/src/wavebench/instruments/models.py
+++ b/src/wavebench/instruments/models.py
@@ -728,12 +728,14 @@ def summary(
*,
expected_frequency_hz: float | None = None,
frequency_tolerance_ratio: float = 0.05,
+ min_signal_vpp: float = 0.02,
) -> dict[str, object]:
quality = summarize_waveform(
self.times_s,
self.voltages_v,
expected_frequency_hz=expected_frequency_hz,
frequency_tolerance_ratio=frequency_tolerance_ratio,
+ min_signal_vpp=min_signal_vpp,
)
return {
"channel": self.channel,
diff --git a/src/wavebench/report/html.py b/src/wavebench/report/html.py
index 9d237fb..38036bd 100644
--- a/src/wavebench/report/html.py
+++ b/src/wavebench/report/html.py
@@ -327,9 +327,22 @@ def render_run_report_html(
.response-3d-controls label {{ color: var(--muted); font-size: 0.9rem; }}
.response-3d-controls select {{ margin-left: 0.3rem; padding: 0.25rem 0.4rem; border: 1px solid var(--line); border-radius: 6px; background: var(--surface); color: var(--text); }}
.response-3d-controls button {{ padding: 0.25rem 0.6rem; border: 1px solid var(--line); border-radius: 6px; background: var(--surface); color: var(--text); cursor: pointer; }}
- .response-3d-controls button:hover {{ border-color: var(--accent); }}
- .response-3d-plot {{ width: 100%; height: 34rem; min-height: 28rem; }}
- .fit-formula {{ white-space: pre-wrap; overflow-wrap: anywhere; }}
+.response-3d-controls button:hover {{ border-color: var(--accent); }}
+.response-3d-plot {{ width: 100%; height: 34rem; min-height: 28rem; }}
+.frequency-response-point-log {{ margin: 1rem 0 1.25rem; border: 1px solid var(--line); border-radius: 12px; background: var(--surface); }}
+.frequency-response-point-log > summary {{ cursor: pointer; padding: 0.75rem 0.9rem; color: #102a43; font-weight: 700; }}
+.frequency-response-point-log > summary::marker {{ color: var(--brand); }}
+.frequency-response-point-log[open] > summary {{ border-bottom: 1px solid var(--line); }}
+.frequency-response-point-log-summary {{ margin-left: 0.4rem; color: var(--muted); font-size: 0.9rem; font-weight: 500; }}
+.frequency-response-point-log-note {{ margin: 0.7rem 0.9rem 0; }}
+.frequency-response-point-log .table {{ margin: 0.7rem 0.9rem 0.9rem; }}
+.artifact-links-log {{ margin: 0.5rem 0 1.25rem; border: 1px solid var(--line); border-radius: 12px; background: var(--surface); }}
+.artifact-links-log > summary {{ cursor: pointer; padding: 0.75rem 0.9rem; color: #102a43; font-weight: 700; }}
+.artifact-links-log > summary::marker {{ color: var(--brand); }}
+.artifact-links-log[open] > summary {{ border-bottom: 1px solid var(--line); }}
+.artifact-links-log-summary {{ margin-left: 0.4rem; color: var(--muted); font-size: 0.9rem; font-weight: 500; }}
+.artifact-links-log .table {{ margin: 0.7rem 0.9rem 0.9rem; }}
+.fit-formula {{ white-space: pre-wrap; overflow-wrap: anywhere; }}
@page {{ size: A4 landscape; margin: 10mm; }}
@media print {{
body {{ background: #fff; font-size: 9pt; }}
@@ -715,17 +728,32 @@ def _artifact_links_block(links: list[ReportArtifactLink]) -> str:
if not links:
return ""
rows = "\n".join(_artifact_link_row(link) for link in links)
+ open_attribute = " open" if len(links) <= 100 else ""
return f"""产物链接 / Artifact links
+
+产物链接 / Artifact links{_artifact_links_summary(links)}
步骤 / Step 类型 / Type 产物 / Artifact 链接 / Link 状态 / Status
{rows}
-
+
"""
+def _artifact_links_summary(links: list[ReportArtifactLink]) -> str:
+ kind_counts: dict[str, int] = {}
+ status_counts: dict[str, int] = {}
+ for link in links:
+ kind_counts[link.kind] = kind_counts.get(link.kind, 0) + 1
+ status = link.status or "unknown"
+ status_counts[status] = status_counts.get(status, 0) + 1
+ kinds = ", ".join(f"{kind}: {count:,}" for kind, count in sorted(kind_counts.items()))
+ statuses = ", ".join(f"{status}: {count:,}" for status, count in sorted(status_counts.items()))
+ return escape(f"{len(links):,} 条 / links · 类型 {kinds} · 状态 {statuses}")
+
+
def _artifact_link_row(link: ReportArtifactLink) -> str:
href = escape(link.href, quote=True)
return (
@@ -938,12 +966,16 @@ def _frequency_response_section(
table_rows = "\n".join(_frequency_response_row(row) for row in rows)
if not table_rows:
table_rows = '频响 CSV 没有可读取的记录 / No readable response rows. '
- table_block = f"""
+ open_attribute = " open" if len(rows) <= 100 else ""
+ table_block = f"""
+逐点日志 / Point log{_frequency_response_point_log_summary(rows)}
+完整逐点记录默认收起;CSV 仍是可复算的原始事实源。
+
# 请求幅值 / Requested Vpp 请求频率 / Requested 输入峰值 / Input peak 输出峰值 / Output peak 线性增益 增益 / Gain 相位 / Phase 展开相位 / Unwrapped 状态 / Status 警告或错误 / Warning or error
{table_rows}
-
"""
+
"""
else:
table_block = '逐频点结果请见本响应目录的 frequency_response.csv。
'
fit_summary = (
@@ -981,6 +1013,19 @@ def _frequency_response_section(
"""
+def _frequency_response_point_log_summary(rows: list[dict[str, str]]) -> str:
+ counts: dict[str, int] = {}
+ for row in rows:
+ status = str(row.get("status", "unknown") or "unknown")
+ counts[status] = counts.get(status, 0) + 1
+ parts = [f"{len(rows):,} 点 / points"]
+ for status in ("ok", "warning", "failed"):
+ if status in counts:
+ parts.append(f"{status}: {counts.pop(status):,}")
+ parts.extend(f"{status}: {count:,}" for status, count in sorted(counts.items()))
+ return escape(" · ".join(parts))
+
+
def _frequency_response_baseline_block(response: FrequencyResponsePackage) -> str:
if response.baseline_error:
return f'基线审计 JSON 无法读取:{escape(response.baseline_error)}
'
diff --git a/src/wavebench/services/frequency_response.py b/src/wavebench/services/frequency_response.py
index d956e2f..4991590 100644
--- a/src/wavebench/services/frequency_response.py
+++ b/src/wavebench/services/frequency_response.py
@@ -149,6 +149,7 @@ def analyze_frequency_response_point(
reference_waveform: Any,
response_waveform: Any,
frequency_tolerance_ratio: float,
+ min_signal_vpp: float = 0.02,
capture_package: str,
metadata_path: str,
adaptive_level: int = 0,
@@ -173,8 +174,12 @@ def analyze_frequency_response_point(
raise ValueError("gain is not finite and positive")
gain_db = float(20.0 * np.log10(gain_linear))
phase_wrapped_deg = _wrap_phase_deg(float(np.degrees(np.angle(transfer))))
- reference_summary = _summary(reference_waveform, requested_frequency_hz, frequency_tolerance_ratio)
- response_summary = _summary(response_waveform, requested_frequency_hz, frequency_tolerance_ratio)
+ reference_summary = _summary(
+ reference_waveform, requested_frequency_hz, frequency_tolerance_ratio, min_signal_vpp
+ )
+ response_summary = _summary(
+ response_waveform, requested_frequency_hz, frequency_tolerance_ratio, min_signal_vpp
+ )
warnings = _quality_warnings(reference_summary, "reference") + _quality_warnings(
response_summary, "response"
)
@@ -462,10 +467,16 @@ def _amplitude_floor(waveform: Any) -> float:
return max(1e-12, scale * 1e-12)
-def _summary(waveform: Any, frequency_hz: float, tolerance_ratio: float) -> dict[str, Any]:
+def _summary(
+ waveform: Any,
+ frequency_hz: float,
+ tolerance_ratio: float,
+ min_signal_vpp: float,
+) -> dict[str, Any]:
summary = waveform.summary(
expected_frequency_hz=frequency_hz,
frequency_tolerance_ratio=tolerance_ratio,
+ min_signal_vpp=min_signal_vpp,
)
return summary if isinstance(summary, dict) else {}
diff --git a/src/wavebench/services/run_plan.py b/src/wavebench/services/run_plan.py
index 14d0dc5..07e4ea7 100644
--- a/src/wavebench/services/run_plan.py
+++ b/src/wavebench/services/run_plan.py
@@ -80,6 +80,7 @@
"target_cycles",
"settle_s",
"frequency_tolerance",
+ "min_signal_vpp",
"points",
"save_csv",
"screenshot",
@@ -514,6 +515,9 @@ def _normalize_frequency_response_fields(prefix: str, fields: dict[str, Any]) ->
fields["target_cycles"] = _positive_float(
fields.get("target_cycles", 10.0), f"{prefix}.target_cycles"
)
+ fields["min_signal_vpp"] = _positive_float(
+ fields.get("min_signal_vpp", 0.02), f"{prefix}.min_signal_vpp"
+ )
settle_s = _finite_float(fields.get("settle_s", 0.3), f"{prefix}.settle_s")
if settle_s < 0:
raise ConfigError(f"{prefix}.settle_s must be >= 0")
diff --git a/src/wavebench/services/run_service.py b/src/wavebench/services/run_service.py
index c38ab00..18c7ba1 100644
--- a/src/wavebench/services/run_service.py
+++ b/src/wavebench/services/run_service.py
@@ -680,6 +680,7 @@ def _run_frequency_response_step(
response_waveform=capture.waveforms[response_channel],
frequency_tolerance_ratio=tolerance, capture_package=str(capture.package_dir),
metadata_path=str(capture.metadata_path), adaptive_level=adaptive_level,
+ min_signal_vpp=step.fields["min_signal_vpp"],
adaptive_parent_start_hz=parent_start, adaptive_parent_stop_hz=parent_stop,
)
if point.status == "warning" and step.fields["retry_warning_with_autoscale"]:
@@ -697,6 +698,7 @@ def _run_frequency_response_step(
reference_waveform=retry_capture.waveforms[reference_channel],
response_waveform=retry_capture.waveforms[response_channel],
frequency_tolerance_ratio=tolerance,
+ min_signal_vpp=step.fields["min_signal_vpp"],
capture_package=str(retry_capture.package_dir), metadata_path=str(retry_capture.metadata_path),
adaptive_level=adaptive_level, adaptive_parent_start_hz=parent_start,
adaptive_parent_stop_hz=parent_stop,
@@ -1078,6 +1080,7 @@ def _capture_artifact(self, capture: Any, service: ScopeService) -> dict[str, An
summary = capture.waveform.summary(
expected_frequency_hz=service.config.waveform.expected_frequency_hz,
frequency_tolerance_ratio=service.config.waveform.frequency_tolerance_ratio,
+ min_signal_vpp=service.config.waveform.min_signal_vpp,
)
return {
"package": str(capture.package_dir),
@@ -1194,6 +1197,7 @@ def _scope_service_for_frequency_response(
frequency_tolerance_ratio=step.fields.get("frequency_tolerance"),
target_cycles=step.fields["target_cycles"],
window_frequency_hz=frequency_hz,
+ min_signal_vpp=step.fields["min_signal_vpp"],
).with_output_overrides(
save_csv=step.fields.get("save_csv"),
save_npy=True,
diff --git a/src/wavebench/services/scope_service.py b/src/wavebench/services/scope_service.py
index 9a2f822..9bf8547 100644
--- a/src/wavebench/services/scope_service.py
+++ b/src/wavebench/services/scope_service.py
@@ -405,6 +405,7 @@ def _waveform_metadata(self, waveform: WaveformData) -> dict[str, Any]:
"summary": waveform.summary(
expected_frequency_hz=self.config.waveform.expected_frequency_hz,
frequency_tolerance_ratio=self.config.waveform.frequency_tolerance_ratio,
+ min_signal_vpp=self.config.waveform.min_signal_vpp,
),
}
@@ -470,6 +471,7 @@ def capture_waveform(self, channel: int, label: str) -> CaptureResult:
"frequency_tolerance_ratio": self.config.waveform.frequency_tolerance_ratio,
"vertical_scale_v_per_div": self.config.waveform.vertical_scale_v_per_div,
"target_vpp": self.config.waveform.target_vpp,
+ "min_signal_vpp": self.config.waveform.min_signal_vpp,
}
screenshot_path: Path | None = None
screenshot_error: dict[str, str] | None = None
@@ -548,6 +550,7 @@ def capture_waveforms(self, channels: list[int], label: str) -> MultiCaptureResu
"frequency_tolerance_ratio": self.config.waveform.frequency_tolerance_ratio,
"vertical_scale_v_per_div": self.config.waveform.vertical_scale_v_per_div,
"target_vpp": self.config.waveform.target_vpp,
+ "min_signal_vpp": self.config.waveform.min_signal_vpp,
}
waveforms: dict[int, WaveformData] = {}
files: dict[str, dict[str, str]] = {}
diff --git a/tests/test_config_overrides.py b/tests/test_config_overrides.py
index 6713abe..e93c315 100644
--- a/tests/test_config_overrides.py
+++ b/tests/test_config_overrides.py
@@ -149,6 +149,19 @@ def test_waveform_overrides_target_cycles(self):
self.assertEqual(updated.waveform.window_frequency_hz, 1000.0)
self.assertEqual(updated.waveform.target_cycles, 10.0)
+ def test_waveform_overrides_min_signal_vpp(self):
+ config = WaveBenchConfig(
+ connection=ConnectionConfig("lan", "TCPIP::127.0.0.1::INSTR", 100, 100),
+ scope=ScopeConfig("rtm2032", None, 1, False, True),
+ autoscale=AutoscaleConfig(True, True),
+ waveform=WaveformConfig("real", "lsbf", "dmax"),
+ output=OutputConfig(Path("data/raw"), "timestamp_label", True, True, True, True, False),
+ source_path=Path("test.toml"),
+ )
+ self.assertEqual(config.waveform.min_signal_vpp, 0.02)
+ updated = config.with_waveform_overrides(min_signal_vpp=0.005)
+ self.assertEqual(updated.waveform.min_signal_vpp, 0.005)
+
def test_waveform_overrides_vertical_scale_and_target_vpp(self):
config = WaveBenchConfig(
connection=ConnectionConfig("lan", "TCPIP::127.0.0.1::INSTR", 100, 100),
diff --git a/tests/test_frequency_response.py b/tests/test_frequency_response.py
index 7e23c76..8321f49 100644
--- a/tests/test_frequency_response.py
+++ b/tests/test_frequency_response.py
@@ -144,6 +144,25 @@ def test_zero_response_is_a_failed_point(self):
self.assertEqual(point.status, "failed")
self.assertIn("too small", point.error)
+ def test_low_signal_threshold_is_used_for_frequency_response_quality(self):
+ frequency_hz = 1_000.0
+ reference = _waveform(
+ channel=1, start_s=0.0, samples=1000, sample_rate_hz=100_000.0,
+ frequency_hz=frequency_hz, amplitude_peak_v=0.005, phase_deg=0.0, offset_v=0.0,
+ )
+ response = _waveform(
+ channel=2, start_s=0.0, samples=1000, sample_rate_hz=100_000.0,
+ frequency_hz=frequency_hz, amplitude_peak_v=0.02, phase_deg=0.0, offset_v=0.0,
+ )
+
+ point = analyze_frequency_response_point(
+ index=0, requested_frequency_hz=frequency_hz, reference_waveform=reference,
+ response_waveform=response, frequency_tolerance_ratio=0.05, min_signal_vpp=0.005,
+ capture_package="capture", metadata_path="metadata.json",
+ )
+
+ self.assertEqual(point.status, "ok")
+
def test_phase_unwrap_does_not_bridge_failed_points(self):
points = [
_point(0, 10.0, 1.0, 170.0),
diff --git a/tests/test_quality.py b/tests/test_quality.py
index 2eedea1..e4a41cc 100644
--- a/tests/test_quality.py
+++ b/tests/test_quality.py
@@ -95,6 +95,21 @@ def test_warns_when_signal_amplitude_is_low(self):
quality = summarize_waveform(times, voltages)
self.assertTrue(any("low_signal_amplitude" in warning for warning in quality.quality_warnings))
+ def test_frequency_response_can_use_a_lower_signal_warning_threshold(self):
+ times = np.linspace(0.0, 0.01, 10000, endpoint=False)
+ voltages = 0.005 * np.sin(2 * np.pi * 1000.0 * times)
+
+ default_quality = summarize_waveform(times, voltages, expected_frequency_hz=1000.0)
+ relaxed_quality = summarize_waveform(
+ times,
+ voltages,
+ expected_frequency_hz=1000.0,
+ min_signal_vpp=0.005,
+ )
+
+ self.assertTrue(any("low_signal_amplitude" in warning for warning in default_quality.quality_warnings))
+ self.assertFalse(any("low_signal_amplitude" in warning for warning in relaxed_quality.quality_warnings))
+
def test_flat_dc_without_expected_frequency_has_no_frequency_warning(self):
times = np.linspace(0.0, 0.01, 10000, endpoint=False)
voltages = np.full_like(times, 5.0)
diff --git a/tests/test_report.py b/tests/test_report.py
index 636fd3e..4884eec 100644
--- a/tests/test_report.py
+++ b/tests/test_report.py
@@ -13,7 +13,14 @@
from wavebench.data.packages import load_run_package
from wavebench.errors import ConfigError
-from wavebench.report.html import _response_svg, render_run_report_html, write_run_report_html, write_run_report_pdf
+from wavebench.report.html import (
+ ReportArtifactLink,
+ _artifact_links_block,
+ _response_svg,
+ render_run_report_html,
+ write_run_report_html,
+ write_run_report_pdf,
+)
class RunReportTests(unittest.TestCase):
@@ -42,6 +49,25 @@ def test_response_svg_uses_a_separate_two_column_legend_area(self):
self.assertIn("…", svg)
self.assertNotIn("Frequency piecewise linear interpolation", svg)
+ def test_large_artifact_link_log_is_collapsed_by_default(self):
+ links = [
+ ReportArtifactLink(
+ step_index=str(index),
+ kind="Capture package",
+ label=f"capture-{index}",
+ href=f"capture-{index}",
+ status="ok",
+ )
+ for index in range(101)
+ ]
+
+ html = _artifact_links_block(links)
+
+ self.assertIn('', html)
+ self.assertIn("101 条 / links", html)
+ self.assertIn("类型 Capture package: 101", html)
+ self.assertNotIn('data-artifact-links-log="true" open', html)
+
def test_response_svg_includes_readable_log_frequency_and_linear_value_ticks(self):
svg = _response_svg(
[[(10_000.0, -0.2), (100_000.0, -1.0), (500_000.0, -3.1)]],
@@ -81,6 +107,25 @@ def test_run_report_labels_each_amplitude_slice_and_summarizes_a_two_dimensional
self.assertIn("二维 / 2D,2 个 Vpp 切片 × 3 个频率节点 = 6 个请求组合", html)
self.assertIn('data-label="Measured · 0.05 Vpp"', html)
self.assertIn('data-label="Measured · 0.1 Vpp"', html)
+ self.assertIn('data-frequency-response-point-log="true" open', html)
+ self.assertIn('6 点 / points · ok: 6', html)
+
+ def test_large_frequency_response_point_log_is_collapsed_by_default(self):
+ with TemporaryDirectory() as tmp:
+ run_dir = Path(tmp) / "run"
+ run_dir.mkdir()
+ (run_dir / "run.json").write_text(json.dumps({"status": "ok", "steps": []}), encoding="utf-8")
+ rows = ["index,requested_frequency_hz,gain_linear,gain_db,phase_unwrapped_deg,status"]
+ for index in range(101):
+ status = "warning" if index == 100 else "ok"
+ rows.append(f"{index},{100 + index},1,0,0,{status}")
+ (run_dir / "frequency_response.csv").write_text("\n".join(rows), encoding="utf-8")
+
+ html = render_run_report_html(load_run_package(run_dir), output_dir=run_dir)
+
+ self.assertIn('', html)
+ self.assertIn('101 点 / points · ok: 100 · warning: 1', html)
+ self.assertIn('完整逐点记录默认收起', html)
def test_run_report_embeds_capture_screenshot_relative_to_report(self):
with TemporaryDirectory() as tmp:
diff --git a/tests/test_run_plan.py b/tests/test_run_plan.py
index 5dcb955..9052bbf 100644
--- a/tests/test_run_plan.py
+++ b/tests/test_run_plan.py
@@ -312,6 +312,7 @@ def test_frequency_response_plan_normalizes_log_frequency_points_and_fit(self):
frequency_count = 3
spacing = "log"
target_cycles = 8
+min_signal_vpp = 0.005
settle_s = 0
[steps.fit]
@@ -324,6 +325,7 @@ def test_frequency_response_plan_normalizes_log_frequency_points_and_fit(self):
self.assertAlmostEqual(fields["frequencies_hz"][1], 1000.0)
self.assertEqual(fields["frequencies_hz"][2], 10000.0)
self.assertEqual(fields["target_cycles"], 8.0)
+ self.assertEqual(fields["min_signal_vpp"], 0.005)
self.assertEqual(fields["settle_s"], 0.0)
self.assertTrue(fields["retry_warning_with_autoscale"])
self.assertEqual(
@@ -365,6 +367,18 @@ def test_frequency_response_plan_accepts_warning_retry_configuration(self):
self.assertFalse(plan.steps[0].fields["retry_warning_with_autoscale"])
+ def test_frequency_response_plan_rejects_non_positive_min_signal_vpp(self):
+ path = self._write_plan("""
+[[steps]]
+kind = "sweep.frequency_response"
+reference_channel = 1
+response_channel = 2
+frequencies_hz = [100, 1000]
+min_signal_vpp = 0
+""")
+ with self.assertRaisesRegex(ConfigError, "min_signal_vpp must be > 0"):
+ load_run_plan(path)
+
def test_frequency_response_plan_rejects_mixed_vpp_forms(self):
path = self._write_plan("""
[[steps]]
diff --git a/tests/test_run_service.py b/tests/test_run_service.py
index f35bd66..4b1c0b1 100644
--- a/tests/test_run_service.py
+++ b/tests/test_run_service.py
@@ -1190,6 +1190,7 @@ def test_frequency_response_captures_two_channels_and_persists_evidence(self):
response_channel = 2
frequencies_hz = [100, 1000]
target_cycles = 6
+min_signal_vpp = 0.005
settle_s = 0
"""))
config = make_config(tmp)
@@ -1231,6 +1232,13 @@ def test_frequency_response_captures_two_channels_and_persists_evidence(self):
if call.kwargs["config"].waveform.expected_frequency_hz is not None
)
)
+ self.assertTrue(
+ all(
+ call.kwargs["config"].waveform.min_signal_vpp == 0.005
+ for call in scope_cls.call_args_list
+ if call.kwargs["config"].waveform.expected_frequency_hz is not None
+ )
+ )
def test_frequency_response_retries_warning_after_autoscale_and_uses_clean_retry(self):
with TemporaryDirectory() as tmp: