diff --git a/README.md b/README.md index c4a9388..fa817cc 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ WaveBench 是一个用 Python 编写的实验室自动测量台,面向电子设计竞赛调试和日常实验。它把仪器控制、实验步骤和采集证据放在同一条命令链中,支持先离线检查 plan,再决定是否连接硬件。 -当前仓库开发线为 `0.8.24`,最新稳定 tag 为 `v0.8.0`。不同版本的命令和能力可能不同,以对应 tag 中的文档为准。 +当前仓库开发线为 `0.8.25`,最新稳定 tag 为 `v0.8.0`。不同版本的命令和能力可能不同,以对应 tag 中的文档为准。 ## 🌟 特别鸣谢 @@ -114,12 +114,22 @@ wavebench tui --fake | 信号源 | RIGOL DG4000/DG4202 | 基本波形、频率控制、扫频和任意波上传 | 主包能力 | | 电源 | RIGOL DP800 | 状态、保护、设定值和输出控制 | 主包能力 | | 万用表 | RIGOL DM3000/DM3058 | 常用读数、功能和部分量程/触发状态 | 主包能力 | -| run plan | source、power、scope、dmm、sleep、频响步骤 | 多仪器编排、质量检查和恢复 | 主入口 | +| run plan | source、rf_source、power、scope、dmm、sleep、频响步骤 | 多仪器编排、质量检查和恢复 | 主入口 | | TUI | 电源、万用表、信号源面板 | 人工查看和少量控制 | 实验性 | | 插件 | `wavebench.instruments` 外部 driver | 添加或替换特定仪器实现 | 可选 | 详细的能力边界和参数见 [文档总览](docs/README.md)、[项目文档分类](docs/project/README.md) 及 `docs/project/reference/` 下的参考页。 +## RF 信号源 + +`rf_source` 是独立于普通 `source` 的仪器领域。DSG830 当前已开放只读状态、RF OFF 时的单字段 CW 配置、RF-OFF 内部正弦 AM/FM/PM 配置及按模式关闭、具有完整 safety 配置的 `rf_out` ON/OFF、RF-OFF internal/single Pulse 配置,以及 RF-OFF 的 frequency-only Step Sweep 配置。PM 的 production profile 限于 `1.25 rad`。A4-MO 已将受限 `rf_source.modulated_output_enable` 提升到 production:仅接受已激活且精确匹配的 AM `50 %`/`1 kHz`、FM `20 kHz`/`1 kHz`、PM `1.25 rad`/`1 kHz` profile,最大功率均为 `-50 dBm`;普通 `rf_source.output` 仍要求调制关闭。FM/PM 的 WaveBench CH2 分析只记录波形质量,不计量频偏或相偏。Step Sweep 固定为 `STEP`/`FWD`/`RAMP`/`LIN`,配置后保持 Sweep disabled。 + +已完成的 A5 只覆盖一条后面板物理路径:DSG830 的「PULSE IN/OUT」按 output 方向、固定 internal/single/normal/`1 ms`/`100 μs` profile,提供 `rf_source.pulse_output` 与 `wavebench rf-source pulse-output`。它不启用 RF 输出,也不定义 Pulse input、`TRIGGER IN`、Sweep fire、sync/reference、Level Sweep 或 list。A5-0 仍仅是逻辑 Pulse/Sweep trigger configuration 的零写读取合同,DSG830 production descriptor 不声明 `rf_source.trigger_snapshot`。 + +- 日常配置与操作:[RF 信号源使用指南](docs/project/guides/WaveBench_RF信号源使用指南.md) +- 模型、安全语义和 capability 边界:[RF 信号源领域设计](docs/project/design/WaveBench_RF信号源设计.md) +- Core/插件的同步计划和证据状态:[RF 信号源开发里程碑](docs/project/design/WaveBench_RF信号源开发里程碑.md) + ## 三条常用路径 ### source → scope 完整流程 diff --git a/docs/README.md b/docs/README.md index 11d2f06..4437383 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,7 +2,7 @@ [English](README_EN.md) · 中文 -WaveBench 是一个用 Python 编写的实验室自动测量台,提供 CLI、实验性 TUI、显式 run plan、采集包和离线报告。当前开发线为 `0.8.24`,最新稳定 tag 为 `v0.8.0`。版本变化见 [更新日志](../CHANGELOG.md);旧版本原始文档可切换到对应 Git tag 查看。 +WaveBench 是一个用 Python 编写的实验室自动测量台,提供 CLI、实验性 TUI、显式 run plan、采集包和离线报告。当前开发线为 `0.8.25`,最新稳定 tag 为 `v0.8.0`。版本变化见 [更新日志](../CHANGELOG.md);旧版本原始文档可切换到对应 Git tag 查看。 > [!WARNING] > 部分命令会连接并控制真实仪器。示例会区分离线检查、连接读取和硬件写入;执行写入前,应确认接线和限制值。 @@ -26,6 +26,10 @@ wavebench run check --plan /tmp/wavebench-demo.toml - [配置文件格式](project/reference/WaveBench_配置文件格式.md):TOML 查找顺序、字段和安全限制。 - 仪器型号命令和编程手册由 [仪器插件仓库](https://github.com/Scaxlibur/wavebench-instrument-plugins) 维护;本仓库只记录 WaveBench 的接入边界。 +### 使用 RF 信号源 + +`rf_source` 不复用普通 `source` 的 Vpp、offset 或数字 channel 模型。先从 [RF 信号源使用指南](project/guides/WaveBench_RF信号源使用指南.md) 确认当前 production capability 和端接声明;DSG830 已开放固定 profile 的调制输出,以及唯一受验证的后面板 `pulse_in_out` output 路径。后者不代表 Pulse input、`TRIGGER IN` 或同步能力。需要实现新型号或查看证据门时,再阅读 [领域设计](project/design/WaveBench_RF信号源设计.md) 与 [开发里程碑](project/design/WaveBench_RF信号源开发里程碑.md)。 + ### 执行实验 - [run plan 使用指南](project/guides/WaveBench_run_plan_使用指南.md):模板、`run check`、`run verify`、执行、恢复和报告。 diff --git a/docs/README_EN.md b/docs/README_EN.md index d379500..d607ec0 100644 --- a/docs/README_EN.md +++ b/docs/README_EN.md @@ -2,7 +2,7 @@ [中文文档](README.md) · English -WaveBench is a Python measurement bench for laboratory debugging. It combines explicit instrument commands, run plans, capture packages, and offline reports. It requires Python 3.11 or newer. The current development line is `0.8.24`; the latest stable tag is `v0.8.0`. +WaveBench is a Python measurement bench for laboratory debugging. It combines explicit instrument commands, run plans, capture packages, and offline reports. It requires Python 3.11 or newer. The current development line is `0.8.25`; the latest stable tag is `v0.8.0`. > [!WARNING] > Some commands connect to and change real instruments. Check wiring, input impedance, output state, and voltage/current limits before running a hardware action. @@ -59,6 +59,7 @@ For the terminal UI, install `.[tui]` and run `wavebench tui --fake`. The fake m - Install or develop plugins: [plugin user guide](project/guides/WaveBench_可安装仪器插件.md) and [plugin development guide](project/contributing/WaveBench_插件开发指南.md) - TUI and read-only HTTP MCP: [TUI](project/guides/WaveBench_TUI终端控制面板.md) and [HTTP MCP](project/guides/WaveBench_HTTP_MCP_只读接口.md) - Example plans and their hardware boundaries: [plans README](../plans/README.md) +- RF-source domain and milestones: [guide](project/guides/WaveBench_RF信号源使用指南.md), [design](project/design/WaveBench_RF信号源设计.md), and [milestones](project/design/WaveBench_RF信号源开发里程碑.md). Core provides M0–M4 and one bounded A5 Pulse Output contract; DSG830 A1/A2/A3/A4/A4-MO/A5 evidence permits production identity, snapshot, OFF-only `rf_source.cw_configure`, RF-OFF internal-sine `rf_source.modulation_configure`, `rf_source.modulation_disable`, safety-gated `rf_source.output` ON/OFF, fixed-profile modulated output, internal/single Pulse configuration, `rf_source.pulse_output`, and frequency-only Step Sweep configuration that remains disabled. A5 covers only the declared `pulse_in_out` output route, not Pulse input, `TRIGGER IN`, trigger, sync, or Sweep execution. PM is limited to the verified `1.25 rad` production profile. Most detailed pages are currently maintained in Chinese. Commands, identifiers, and schemas should match across languages. diff --git a/docs/project/README.md b/docs/project/README.md index 94fbfc5..c1063d6 100644 --- a/docs/project/README.md +++ b/docs/project/README.md @@ -2,6 +2,18 @@ `docs/project/` 按文档用途分组。当前重点是让入口、参考资料和设计说明各自承担单一职责;文件名暂时保留原样,ASCII 文件名迁移另行处理。 +## RF 信号源 + +`rf_source` 是与普通 `source` 平行的仪器领域:它使用 `port_id`、dBm、RF 输出、端接与 protection 状态,不能套用普通信号源的 Vpp、offset 或 channel 语义。 + +| 阅读目的 | 入口 | +| --- | --- | +| 配置仪器、声明端接和执行已开放操作 | [使用指南](guides/WaveBench_RF信号源使用指南.md) | +| 理解模型、事务与安全边界 | [领域设计](design/WaveBench_RF信号源设计.md) | +| 开发 Core/DSG830 或复核 capability 提升依据 | [开发里程碑](design/WaveBench_RF信号源开发里程碑.md) | + +DSG830 当前 production 范围包括只读状态、RF-OFF CW、RF-OFF 内部正弦调制、受 safety 限制的 RF ON/OFF、保持 disabled 的 internal/single Pulse 和 fixed Step Sweep 配置。调制配置不授权调制开启时的 RF 输出;`rf_source.modulation_disable`、trigger、Sweep execute/fire、Level Sweep 与 list 仍不在 production 范围内。PM 的 production profile 仅为已验证的 `1.25 rad`。 + ## guides:使用指南 - [CLI 形态](guides/WaveBench_CLI形态.md) diff --git "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" new file mode 100644 index 0000000..95cc87c --- /dev/null +++ "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\345\274\200\345\217\221\351\207\214\347\250\213\347\242\221.md" @@ -0,0 +1,220 @@ +# WaveBench RF 信号源开发里程碑 + +[领域设计](WaveBench_RF信号源设计.md) · [项目文档分类](../README.md) + +本文把 RF 信号源领域设计拆成可交付、可验证的双仓库工作项。它不替代当前程序事实源,也不将离线合同写成真实仪器能力。 + +## 当前基线 + +| 范围 | 当前状态 | 说明 | +| --- | --- | --- | +| Core `0.8.25` 开发线 | M0–M4、M3-MO、A5-0 与 A5 Pulse Output 合同完成;已提升的范围由插件 descriptor 决定 | 已有 `rf_source` kind、配置、只读路径、OFF-only CW、端口输出、内部正弦 AM/FM/PM、按模式调制关闭、profile-bound 调制输出、internal/single Pulse、frequency-only Step Sweep、逻辑 trigger configuration 的只读类型,以及物理 Pulse Output 的 Service、CLI、run 和 artifact。 | +| DSG830 包 `0.2.0` | M0–M3、M4 Pulse 与 Step Sweep、M3-MO 的 A4/A4-MO 均已通过并提升;A5-0 映射已完成,A5 Pulse Output 已通过并提升 | 已迁移为 `kind="rf_source"`,含 `rf_out` topology、严格 snapshot parser、`:FREQ`/`:LEV`/`:OUTP`、内部正弦 AM/FM/PM、internal/single Pulse、frequency-only Step Sweep、六条固定 trigger configuration query,以及 `:PULM:OUT:STAT` 映射;production descriptor 声明 `rf_source.idn`、`rf_source.snapshot`、`rf_source.cw_configure`、`rf_source.output`、`rf_source.modulation_configure`、`rf_source.modulation_disable`、AM `50 %`/`1 kHz`、FM `20 kHz`/`1 kHz`、PM `1.25 rad`/`1 kHz`、最大 `-50 dBm` 的 `rf_source.modulated_output_enable`、`rf_source.pulse_configure`、`rf_source.pulse_output` 和 `rf_source.sweep_configure`,不声明 `rf_source.trigger_snapshot`。 | +| 真实仪器证据 | A1、A2、A3、A4 调制/Pulse/Step Sweep、A4-MO 与一条 A5 Pulse Output 路径已完成 | 真实设备能力不能由 fake transport 替代;A1 提升 snapshot,A2 提升端口级 output,A3 提升 OFF-only CW,A4 分别提升 RF-OFF 调制、调制关闭、OFF-only Pulse 与保持 Sweep disabled 的 Step Sweep 配置。A4-MO 提升三个固定调制输出 profile。A5-0 不产生 production capability,A5 Pulse Output 只提升被验证的 output 路径。 | + +## 双仓库交付规则 + +| 规则 | 要求 | +| --- | --- | +| Core 优先 | 新 kind、descriptor extension、capability registry、配置、Service、CLI、run 和 artifact 先在 Core 开发线完成;正式 wheel 验收还需要已发布的 Core。 | +| 插件跟随 | DSG830 已迁移为 `kind="rf_source"`,并把 `Requires-Dist: wavebench` 与 descriptor 版本门同步为 `>=0.8.25,<0.9`;当前双仓库开发依赖匹配的 Core checkout/版本范围。 | +| 测试隔离 | 后续 capability 可以只出现在 fake descriptor 中,用于离线测试;production descriptor 不得提前声明。 | +| 证据提升 | capability 进入 production descriptor 前,必须有对应 A 级实机证据,记录型号、固件、选件、端口、端接和最终 RF OFF 状态。 | +| 失败语义 | 不确定写入不重试;只有 session health 允许时,M2 才可最多执行一次目标端口 RF OFF recovery。M3 配置失败后只允许在新的、独立预检 session 中使用按模式调制关闭事务恢复已知状态。 | + +## 里程碑总览 + +| 阶段 | 状态 | Core 交付 | DSG830 交付 | 完成条件 | +| --- | --- | --- | --- | --- | +| Seed | 历史完成 | 无 RF Core 改动 | `0.1.0` 的旧 `source.idn` 种子、无 I/O descriptor、包装与 fake 测试 | 已由 M0 迁移取代,不代表 RF 支持。 | +| M0 | 离线完成;A1 已完成 | `rf_source` 只读领域 | `rf_out` topology 与严格 snapshot parser | A1 复核后,生产包声明 `rf_source.idn` 和 `rf_source.snapshot`。 | +| M1 | 离线完成;A3 已完成 | OFF-only CW 配置 | `:FREQ`/`:LEV` 映射与独立回读 | typed request/result、Service、CLI、run step、artifact、fake 测试与受控 A3 证据已完成;DSG830 production 已开放 `rf_source.cw_configure`。 | +| M2 | 离线完成;A2 已完成 | RF 输出安全事务 | `:OUTP` ON/OFF 的单次映射;Core 独立 readback | 安全配置/端接/protection 不满足时 ON 零写拒绝;失败最多一次受 guard 的 OFF recovery;DSG830 production 已开放 `rf_source.output`。 | +| M3 | A4 已通过并提升 | 声明式内部正弦 AM/FM/PM profile、配置与按模式关闭事务、CLI、run 与 artifact | 手册范围内的内部 Sine AM/FM/PM 映射、严格 readback、单模式 RF-OFF evidence harness 与私有恢复路径 | 配置只在 RF OFF、所有调制模式 disabled、profile 匹配且 postcondition 成立时写入;关闭只在 RF OFF、唯一目标模式活动时写入。DSG830 production 已开放 `rf_source.modulation_configure` 和 `rf_source.modulation_disable`。PM 的 production profile 固定为 `1.25 rad`。 | +| M3-MO | A4-MO 已通过并提升 | `RfModulatedOutputProfile`、严格 pre/post RF 与调制 snapshot、一次 RF ON、受 guard OFF recovery、CLI、run 与 artifact | 复用既有 `:OUTP`/调制 snapshot 映射;历史 AM 和独立 FM/PM descriptor、CH2 observation、WaveBench 波形摘要/FFT 与 fake 回归 | 只接受已激活且精确匹配的内部 Sine profile;不配置调制,不重试 ON。普通 `rf_source.output` 仍要求调制关闭。DSG830 production 开放 AM `50 %`/`1 kHz`、FM `20 kHz`/`1 kHz`、PM `1.25 rad`/`1 kHz`,最大 `-50 dBm`。scope 分析只记录波形质量,不能计量频偏或相偏。 | +| M4(Pulse) | 离线完成;A4 Pulse 已通过 | internal/single Pulse profile、OFF-only 配置事务、CLI、run 与 artifact | `:PULM:SOUR INT`、`:PULM:MODE SING`、period/width/polarity,固定以 `:PULM:STAT OFF` 收尾 | 初始或写后 RF 输出、调制、Pulse、Sweep、protection 不满足时拒绝;不触发、不使用后面板 Pulse I/O;DSG830 production 已开放 `rf_source.pulse_configure`。 | +| M4(Step Sweep) | A4 已完成并提升 | frequency-only Step Sweep 合同、CLI、run、artifact 与本地 evidence harness | `:SWE:TYPE STEP`、`:SWE:DIR FWD`、`RAMP`/`LIN`、起止频率、点数、驻留时间,固定以 `:SWE:STAT OFF` 收尾 | 初始或写后 RF 输出、调制、Pulse、Sweep、protection 不满足时拒绝;不写 `:SWE:EXEC`、trigger、Level Sweep 或 RF 输出;DSG830 production 已开放 `rf_source.sweep_configure`。 | +| A5-0 | 离线完成;不属于物理 A5 证据 | `RfTriggerProfile`、`RfTriggerSnapshot`、`rf_source.trigger_snapshot`、只读 Service/CLI/run/artifact | `:PULM:TRIG:MODE?`、external edge/gate query、Sweep mode/period/point trigger query 与严格 enum parser | 只使用 `TRIGGER / READ` profile 和非 production descriptor;固定 query 顺序、零 write、未知值失败关闭。它不定义物理 connector,不发送 trigger,也不提升 production capability。 | +| A5(Pulse Output) | 已通过并提升 | `RfPulseOutputProfile`、固定物理接口的 Service/CLI/run/artifact | 仅 `pulse_in_out` output 的 `:PULM:OUT:STAT?`/`:PULM:OUT:STAT ON|OFF` | 固定 `0 V`/`3.3 V`、约 `600 Ω`、internal/single/normal/`1 ms`/`100 μs` profile;最终 RF 输出和 Pulse Output 必须为 OFF。只提升 `rf_source.pulse_output`。 | + +## Seed:历史种子包 + +DSG830 `0.1.0` 种子包只包含 `*IDN?`、`close()`、无 I/O descriptor、包元数据与离线测试。它使用普通 `source` 名称空间,是 Core M0 前维持打包与开发环境接入的过渡实现;当前包已迁移,不应作为现状描述。 + +不允许把下列内容从 Seed 推导出来: + +- 历史 `source.idn` 种子本身可证明当前 `rf_source` 的能力; +- 频率、dBm 功率、RF 输出、调制、Pulse 或 Sweep 已可用; +- `source.idn` 种子可进入普通 source 的 Vpp、channel 或 run plan 工作流。 + +## M0:只读领域与迁移(离线完成) + +### Core + +- 已添加 `rf_source` plugin kind 和 append-only `InstrumentDescriptor.rf_source_extensions`。 +- 已冻结 topology、`port_id`、`RfObserved`、snapshot、protection policy、Protocol 与 descriptor validator 的最小类型合同。 +- 已添加 `rf_source.idn`、`rf_source.snapshot` capability 与 OperationSpec;复用 access policy、resource lease、guarded transport 和 session health。 +- 已添加独立 `[rf_source]` 与按 `port_id` 配置的安全字段;不复用 `SourceConfig`、`source.terminations` 或 Vpp 限制。 +- 已添加只读 `rf-source idn`/`rf-source status`、doctor IDN target,以及 `rf_source.status` 的 run schema、check、verify、intent、lifecycle、dispatch 和独立 artifact namespace。 + +### DSG830 + +- 已将种子 package 迁移到 `kind="rf_source"`、`rf_source.*` 和 `[rf_source]`。 +- 已声明一个稳定端口 `rf_out`、手册范围和设备 dBm 参考阻抗。 +- 已实现严格 snapshot parser,分别覆盖正常响应、未知响应、坏响应与 protection condition;A1 后可由 production 的只读状态入口消费。 +- A1 已提升 `rf_source.snapshot`;A2 已将 M2 的 `rf_source.output` 提升到 DSG830 production descriptor;A3 已将 M1 的 `rf_source.cw_configure` 提升到同一 descriptor;A4 调制、Pulse 与 Step Sweep 已分别将 `rf_source.modulation_configure`、`rf_source.modulation_disable`、`rf_source.pulse_configure`、`rf_source.sweep_configure` 提升到同一 descriptor;A4-MO 已将 AM `50 %`/`1 kHz`、FM `20 kHz`/`1 kHz`、PM `1.25 rad`/`1 kHz`、最大 `-50 dBm` 的 `rf_source.modulated_output_enable` 提升到同一 descriptor。 + +### 离线完成条件 + +- descriptor 导入和静态校验没有 I/O;每个状态 query、解析分支和坏响应都有测试。 +- Core 的 registry、配置、CLI、doctor、run check/verify/intent 与 artifact 测试通过。 +- 插件开发依赖区间与 descriptor 版本门均为 `>=0.8.25,<0.9`;正式 wheel 验收等待已发布的 Core 版本。 + +## M1:OFF-only CW 配置 + +Core 已在离线开发中加入单字段 `RfCwRequest`/result、`rf_source.set_frequency`/`rf_source.set_power_dbm` OperationSpec、端口范围检查、OFF-only Service 事务、CLI、run step 与带 preflight/postcondition snapshot 的 artifact。所有 CW 写入前必须确认目标 RF 输出为 OFF,且调制、Pulse、Sweep 与 protection 状态没有冲突。离线 fake/guarded transport 验收和 A3 受控实机证据均已完成;DSG830 production 已开放 CW capability。 + +DSG830 driver 已在离线测试中实现已冻结的 `:FREQ` 与 `:LEV` 单次写映射;Core 负责独立 snapshot 回读。输出 ON、越界、缺失安全关键状态或 readback 不确定时,必须零写拒绝或停止后续写入。A3 的实机证据已通过并复核,production descriptor 现在声明 CW write capability;Sweep execute/fire、trigger 与 Level Sweep 仍继续关闭。 + +## M2:RF 输出安全事务 + +Core 已在离线环境中完成 `RfOutputRequest`/result、`rf_source.output_enable`/`rf_source.output_disable` OperationSpec、descriptor 输出 profile 校验、每端口 safety 预检、CLI、run schema/intent/dispatch 与带 preflight/postcondition snapshot 的 artifact。RF ON 必须确认完整 safety 配置、端接匹配、频率与 dBm 功率范围、已关闭的调制/Pulse/Sweep,以及只含已知非阻断项的 protection。RF OFF 不依赖频率、功率、端接或 protection readback。 + +ON 结果不明、写后 readback 失败或 protection 变化时,Core 不重试 ON,而是将 session 降为不确定状态;仅在受 guard 的 recovery 预算内最多发送一次同端口 OFF,并独立回读 OFF。OFF 写入或其 readback 结果不明时不重试,session 降为 poisoned。DSG830 driver 使用单次 `:OUTP ON|OFF` 映射,Core 负责所有 snapshot readback 与 recovery;A2 已将 `rf_source.output` 加入 production descriptor,后续 A3 单独提升 CW,不提升 M3/M4 或其它 capability。 + +## M3:内部正弦调制(A4 已通过并提升) + +Core 已冻结 `RfModulationModeProfile`、typed request/result、调制 snapshot、 +`rf_source.modulation_configure` OperationSpec、Service、CLI、run step 与 artifact。M3 只描述内部 +Sine AM/FM/PM:AM 使用 percent 深度,FM 使用 Hz 频偏,PM 使用 rad 相偏;每种模式都有独立的内部频率和静态范围。 +run plan 使用 `modulation_kind` 表示 AM/FM/PM,避免与步骤自身的 `kind` 键冲突,并且只能提供与该模式匹配的一个数值字段。 + +DSG830 driver 已实现固定且无重试的内部正弦写入序列与严格 readback:先读取全局调制状态、三种模式的 enable 状态,只有写后才读取目标 profile 的 source/waveform/数值/内部频率;这样不会因未启用的外部 profile 阻塞安全 preflight。 +FM/PM 的共享 mode type 会与被查询 profile 分开记录。当前类型与目标 FM/PM 不同但三种模式均 disabled 时,preflight 可继续, +固定写入显式选择目标类型;postcondition 必须核对目标类型。M3 preflight 要求目标 RF 输出 OFF、AM/FM/PM +均 disabled、Pulse/Sweep disabled 且无活动 protection condition;postcondition 要求 RF 仍 OFF、仅目标模式 enabled、全局调制 +开启且所有目标字段精确匹配。写入或 postcondition 结果不明时不重试,session 降为不确定状态。 + +`rf_source.modulation_disable` 单独关闭一个已明确识别的 AM/FM/PM 模式和全局调制开关。它要求 RF OFF、Pulse/Sweep disabled、无活动 protection,且调制状态只包含请求模式;写后必须重新确认所有模式和全局调制均关闭。已一致关闭的状态不写入;混合、未知或矛盾状态在写入前拒绝。A4 调制与 A4-MO 清理均已验证此事务,因此 DSG830 production descriptor 已开放它,并由 `wavebench rf-source modulation disable --port PORT_ID --modulation-kind am|fm|pm` 和 `rf_source.modulation_disable` 提供日常入口。 + +DSG830 的 A4 调制证据已将 `rf_source.modulation_configure` 加入 production descriptor。production profile 为 AM `0–100 %`、FM `0.1 Hz–1 MHz`,以及 PM 精确 `1.25 rad`;三种模式的内部频率均为 `10 Hz–100 kHz`。driver 的离线 PM 映射范围不自动扩大 production profile。 +当前 M2 的 RF ON 合同仍要求调制 disabled,因此 M3 capability 不授权在调制开启时输出 RF;M3-MO 为固定 profile 提供专门的输出安全合同,且已由独立 A4-MO 实机证据提升。 + +DSG830 源码 checkout 提供 A4 的独立本地 harness 和资源无关 setup 模板。一次运行只配置一个内部 Sine AM/FM/PM profile;成功路径在配置读回后执行同一模式的受限关闭事务,最终 snapshot 必须同时确认 RF OFF 和调制关闭。三种模式的 RF-OFF 配置、严格读回与关闭恢复均已通过。为使生产 profile 与 PM 的严格读回证据完全一致,PM 仅开放 `1.25 rad`,不将离线映射的其它值外推到 production。`--recover` 只用于把已知的单一活动模式恢复为关闭状态,输出为私有恢复记录,不是新的 capability 提升证据。两条路径都不读取 scope、不调用 RF output,也不做 output recovery。该证据不能外推为调制输出、CH2 信号、Pulse、Sweep 或 trigger 证据。 + +### M3-MO:受限调制输出(A4-MO 已通过并提升) + +Core 将调制开启时的 RF 输出建模为独立的 `rf_source.modulated_output_enable`,而不是放宽通用 `rf_source.output`。request 包含一个内部 Sine AM/FM/PM profile;preflight 要求 RF OFF、全局调制开启、唯一目标模式开启且完整 profile 与 request 精确相同,Pulse/Sweep disabled、protection 清晰、频率/功率/实际端接满足端口 safety 配置,并且 request 被 `RfModulatedOutputProfile` 的窄范围接受。它读取 RF snapshot 和完整调制 snapshot,单次 ON 后再次读取二者;不会配置调制,也不会在成功后自动 RF OFF 或关闭调制。 + +任何 ON 结果不明、RF readback 或调制 readback 不符时,都不重试 ON。只有 session health 允许时,Core 才复用 M2 的一次受 guard RF OFF recovery;恢复后不推断调制状态。普通 `rf_source.output` 的 ON preflight 不变,仍要求调制关闭。 + +DSG830 源码 checkout 保留 `tools/a4_modulated_output_evidence.py` 与无资源 setup 模板,作为已完成的历史 AM 验收:它使用仅在内存中创建的 descriptor,固定为 AM `50 %`/内部 `1 kHz`、RF `1 MHz`/`-50 dBm`,并只读取 CH2 的当前 `DEF` 缓冲区。CH2 必须由 setup 显式确认 50 Ω;scope 只判定是否有可见信号,不计算 dBm、频率或调制深度。工具不读取或控制 CH1,不把 LF OUTPUT 解释为调制测量,不使用 trigger/sync/后面板 Pulse I/O。该受控序列已通过:154 次 RF query、12 次完成 write、CH2 信号存在、最终 RF OFF/调制关闭、两个 session 健康关闭,脱敏记录为 `0600`。提升后 historical harness 拒绝重跑。 + +独立的 `tools/a4_fm_pm_modulated_output_evidence.py` 使用同一安全合同验证 FM `20 kHz`/内部 `1 kHz` 和 PM `1.25 rad`/内部 `1 kHz`,两者同为 RF `1 MHz`/`-50 dBm`。它们在严格源端 profile readback 与最终关闭复核之外,使用 WaveBench 波形摘要和 FFT 记录 CH2 信号存在及质量告警;这些记录不测量频偏、相偏、调制准确度或频谱合规性。三个精确 profile 因此已进入 production descriptor。 + +## M4:Pulse 与 Step Sweep + +M4 当前先完成 Pulse,再处理 frequency-only Step Sweep。`RfPortSnapshot` 中的 Pulse、Sweep 状态必须可区分,不能将外部 trigger、后面板辅助输出或设备私有模式默认为安全。 + +Pulse 只覆盖 `rf_out` 的 internal/single 子集。request 只包含 period、width 和 polarity;Core 在写入前要求 RF 输出、调制、Pulse、Sweep 均关闭且无活动 protection,写后独立读回 internal/single、全部请求字段和 Pulse 关闭状态。driver 的固定 sequence 以 `:PULM:STAT OFF` 收尾。它不控制 `:PULM:OUT`、RF 输出或任何 trigger,失败时不重试、不追加恢复 setter。 + +源码 checkout 的 `tools/a4_pulse_evidence.py` 与资源无关的 setup 模板完成了 A4 Pulse 受控验收。静态预检要求独立 `read_only` RF 配置、关闭读重试、精确的 production descriptor 和 50 Ω 端接声明;显式 `--execute` 才在内存中建立临时 `read_write` descriptor。两种已声明 polarity 都通过初始 snapshot、一次 Pulse 配置、独立配置读回和最终 snapshot,均为 38 次 query、6 次配置 write,并确认 RF 与 Pulse 仍关闭。`--diagnose` 保持 `read_only`,固定 22 次 query、零 write。两种模式都不读取 scope、不使用 CH1/CH2、不发送 trigger,证据以 `0600` 保存且不包含资源或原始响应。证据复核后,DSG830 production descriptor 已声明 `rf_source.pulse_configure`;historical harness 现在会拒绝重跑。 + +frequency-only Step Sweep 的生产子集只接受起止频率、点数和驻留时间,静态 profile 固定为 `STEP`/`FWD`/`RAMP`/`LIN`;Service 在写前和写后要求 RF 输出、调制、Pulse、Sweep 均关闭且无活动 protection,写后独立读取完整 Sweep profile 并要求状态仍为 disabled。DSG830 driver 只查询/写入 Step Sweep profile,并固定以 `:SWE:STAT OFF` 收尾;不发送 `:SWE:EXEC`、`*TRG`、任意 `:TRIG:*`、`:SWE:STAT FREQ`、Level Sweep、list、RF 输出或后面板接口命令。A4 Step Sweep 证据通过后,DSG830 production descriptor 声明 `rf_source.sweep_configure`;普通 CLI 和 run step 仍要求 `read_write`、profile 匹配和 fresh OFF-only preflight。 + +源码 checkout 的 `tools/a4_step_sweep_evidence.py` 与资源无关 setup 模板已完成离线回归和实机验收。静态预检要求独立 `read_only` RF 配置、关闭读重试、精确 production descriptor 和人工确认的 50 Ω 端接。`--diagnose` 保持 `read_only`,读取初始/最终 RF snapshot 与完整 Step Sweep profile,成功路径固定为 25 次 query、零 write;显式 `--execute` 才在内存中建立受限 `read_write` descriptor,成功路径固定为初始 snapshot、一次配置、独立 profile readback、最终 snapshot,共 41 次 query、9 条配置 write。两条路径都不读取 scope、不调用 RF output、不执行 arm/fire/trigger,且证据以 `0600` 保存。诊断与受控配置序列均通过,最终独立复核 RF 输出、调制、Pulse、Sweep 关闭且无活动 protection;historical harness 在 capability 提升后拒绝重跑。 + +除已验证的「PULSE IN/OUT」output 外,Pulse trigger、Sweep arm/fire/stop、外部 trigger、其它后面板辅助接口、参考时钟和同步仍未实现。fake descriptor 可以覆盖后续 trigger/fire 事务;production descriptor 只有在对应 A5 证据具备后才能声明相关 capability。 + +## A1–A5:实机证据门 + +| 证据 | 范围 | 可以提升的 production capability | +| --- | --- | --- | +| A1 | 只读 snapshot | `rf_source.snapshot` | +| A2 | RF OFF/ON、readback 与最终 OFF | `rf_source.output` | +| A3 | CW 环回、频率与 dBm 功率 | `rf_source.cw_configure` | +| A4 | RF-OFF 调制、Pulse、Step Sweep | 对应 M3/M4 capability | +| A4-MO | 固定调制 profile 的 RF ON、CH2 存在性观察与最终清理 | `rf_source.modulated_output_enable` | +| A5 | 外部 trigger 或同步接线 | trigger/fire/同步相关 capability | + +每次证据记录必须独立于代码提交,且不能包含真实资源地址、序列号、原始响应或实验室专用配置。未恢复或无法确认最终 RF OFF 的验收不能用于提升 capability。 + +### A5-0:逻辑 trigger configuration 读取(离线完成) + +A5-0 是物理 A5 之前的只读基础,不验证外部 trigger/同步接线。Core 已增加 `RfTriggerProfile` 和 `RfTriggerSnapshot`,以封闭 enum 表示 Pulse trigger mode、external trigger edge、external gate polarity、Sweep mode、Sweep period trigger 与 Sweep point trigger。`rf_source.trigger_snapshot`、`wavebench rf-source trigger status --port PORT_ID` 与 `rf_source.trigger_status` 都要求目标端口的 `TRIGGER / READ` profile;操作为 `stateful_read`,不读取普通 RF snapshot、不写入、不触发且不做 recovery。 + +DSG830 driver 已用六条固定 query 读取该逻辑 configuration,并对别名和未知响应执行严格解析。production descriptor 仍不声明 `rf_source.trigger_snapshot` 或 `TRIGGER` feature;因此普通 DSG830 配置会在 session 建立前拒绝该入口。`port_id` 只表示这些设置影响的 RF 输出,不表示物理 trigger/sync connector,也不从 CH2 的 50 Ω 或 `rf_out` 的 dBm 参考推导电气条件。 + +### A5:物理接口按路径提升 + +A5 从已核对的物理接口开始,不从某条 SCPI 命令或已有 `rf_out` 证据开始。一次验收只能覆盖一个明确目标;通过一条后面板路径不能外推为其它 trigger、fire、同步或辅助输出能力。 + +| 项目 | 已完成的 Pulse Output 路径 | 其它 A5 路径的进入条件 | +| --- | --- | --- | +| 目标行为 | DSG830「PULSE IN/OUT」output 的 ON/OFF,触发 RTM2032「EXT TRIGGER INPUT」上的一次 single acquisition。 | 只验证一个明确 trigger/sync 模式及其成功判据。 | +| 物理接线 | DSG830 `PULSE IN/OUT` output → RTM2032 `EXT TRIGGER INPUT`。 | 明确每根线的源端与目标端、线缆和转接件,区分 input/output/sync/reference/`rf_out`。 | +| 电气兼容 | DSG830 固定 `0 V`/`3.3 V`、约 `600 Ω`;RTM 输入为 `1 MΩ`/`12 pF`/`≤ 150 Vp`。 | 核对方向、逻辑或模拟属性、幅度/阈值、极性、脉宽、频率/时序、阻抗和终端。 | +| 初始与恢复状态 | RF 输出、调制、Pulse、Sweep 均 OFF,protection 为空;最终 RF 输出和 Pulse Output 独立确认 OFF。 | 预先定义初始状态、失败恢复和最终 RF OFF 独立确认方式。 | +| 观察方式 | scope 仅在隔离 harness 中临时采用 external/normal/single/auto 触发序列;不形成 RTM API 或 capability。 | 如使用示波器,只作补充观察,不能代替仪器端 readback。 | + +已完成的 A5 harness 固定 DSG830 为 internal/single/normal、period `1 ms`、width `100 μs`,在 RF 始终关闭时执行一次 Pulse Output ON、scope single、Pulse Output OFF。审计结果为 RF 主 session `97` 次 query/`8` 次完成 write、独立最终 RF 复核 `15` 次 query/零 write、scope `5` 次 query/`3` 次完成 write;最终两项输出均确认关闭。source 的既有 Pulse profile 与 scope acquisition state 不恢复,scope 可能停在 `Single`。历史 harness 在 production 提升后拒绝重跑,避免临时 descriptor 绕过 capability。 + +A5-0 的逻辑 configuration readback 仍保持独立:它固定读取六项逻辑 trigger 状态,私有零写诊断成功预算为 22 次 query、零 write;production descriptor 不声明 `rf_source.trigger_snapshot`。后续的 Pulse input、`TRIGGER IN`、Pulse trigger、Sweep fire、sync/reference、Level Sweep 与 list 必须分别定义接线、电气 profile 和恢复语义,并取得新的 A5 证据。没有这些事实时,不写入后面板配置,不发送 `*TRG`、`:TRIG:*` 或 `:SWE:EXEC`,也不把外部接口视为安全。 + +### A1:已完成的只读 snapshot 验收 + +A1 已使用一次性、非 production 的本地 evidence harness 完成并经复核。当时没有临时将 +`rf_source.snapshot` 加入 production descriptor,也没有通过 `rf-source status` 绕过 capability 门禁。验收期间,production descriptor 始终保持仅 `rf_source.idn`。 + +1. 使用独立的本地 TOML 副本,不修改现有 `wavebench.toml`。副本中的 `[rf_source]` 必须指定已核对的 + canonical driver、资源和 `access = "read_only"`;不得使用 `read_write` 或资源猜测。若需网络发现,必须在 harness 之外以有界、单独授权的流程完成,人工复核后才写入副本,且发现结果不进入证据。 +2. harness 通过受 guard 的 transport 和独占 resource lease 创建单个 session,只调用手册已审计的 snapshot + query,且不添加 query 重试:`*IDN?`、`:FREQ?`、`:LEV?`、`:OUTP?`、`:MOD:STAT?`、`:PULM:STAT?`、 + `:SWE:STAT?`、`:STAT:QUES:POW:COND?`。 +3. 禁止 `*RST`、错误队列、RF OFF/ON、频率/功率/调制/Pulse/Sweep setter、trigger、capture 和任何 + `write`/`write_bytes`。A1 失败时不在该只读流程中尝试 recovery OFF。 +4. 本地证据只保存 A1 标签、时间、Core/插件版本、canonical driver、脱敏的型号/固件/选件信息、`rf_out` + 的类型化 snapshot、已人工确认的实际端接、session 结果和 guard audit 摘要。隔离 TOML 的 + `[a1_evidence]` 必须显式记录端口、有限正数端接和已确认的选件列表;firmware 从同一次 `*IDN?` 的 + 受限字段提取,不新增查询。不得从连接器标签、scope coupling 或型号名称推导端接,也不得保存资源、 + 序列号、完整 IDN、原始响应或命令日志。 +5. 成功条件为:parser 完整成功、目标 RF 输出明确为 OFF、session 健康且关闭成功、audit 显示 + `access=read_only`、query 数量与预期一致、所有写计数和 `instrument_mutation_writes` 均为零。输出为 ON、 + 状态未知、保护/解析异常、session 异常或关闭失败均为未通过,不能提升 capability。 + +本次证据已由人工复核,并在对应插件补丁中把 `rf_source.snapshot` 加入 DSG830 production descriptor。该提升不包含任何 RF 写 capability。 + +### A2:已完成的受控 RF 输出验收 + +A2 使用一次性、非 production 的本地 evidence harness 完成并经复核。验收前使用有界网络发现确定候选资源,随后只在隔离 TOML 中使用已复核的 RF 和 scope 资源;发现结果、资源地址、序列号、原始响应、命令与波形均不进入证据。RF 配置与 scope 配置在静态预检阶段均保持 `read_only`,读重试关闭;只有显式执行阶段才在内存中创建受限的 write session。 + +主序列确认初始 RF OFF、一次 RF ON、独立 readback、一次 RF OFF 和独立 readback。启用前的 fresh snapshot 同时验证端口安全配置、实际端接、频率、功率、调制、Pulse、Sweep 和 protection。若 ON 的结果不明,Core 最多执行一次受授权的 OFF recovery;若 OFF transaction 已开始但结果不明,不重试。验收记录确认最终 RF OFF、关闭成功和无结果不明的 guard audit。scope 对 CH1/CH2 的当前缓冲区观察作为补充,CH2 的 50 Ω 输入由独立配置明确确认,不替代 RF readback。 + +该证据已将 DSG830 的 `rf_source.output` 加入 production descriptor。普通 CLI 和 run step 仍必须使用 `read_write`、完整端口 safety 配置和 fresh preflight;A2 本身不提升 CW,后者由后续 A3 单独验证。 + +### A3:已完成的 CW 环回验收 + +DSG830 插件源码 checkout 保留本地 `tools/a3_cw_evidence.py`、回归测试和不含资源地址的 setup 模板。它不进入 +wheel 或 sdist。受控序列已通过并经复核,production descriptor 现在声明 `rf_source.cw_configure`;historical harness 会以 +`production_cw_gate_changed` 拒绝重跑。 + +静态预检要求 RF 与 scope 配置保持 `read_only`、读重试关闭且资源不同。显式 `--execute` 后,harness 才在内存中建立 +只包含一个频点、一个低功率上限和实际端接声明的 write 配置。主序列为:初始 RF OFF snapshot、一次频率写入及独立 +readback、一次功率写入及独立 readback、一次由已验证 M2 能力执行的 RF ON/OFF、CH2 当前 `DEF` 缓冲区读取,以及最终 +RF OFF 的独立 readback。未确认最终 OFF、任一 CW readback 不符或 CH2 未观察到可见信号都会使 A3 失败。 + +CH2 的 50 Ω 端接是在 setup 中明确声明的电气安全前提。scope 只提供「可见信号」补充证据,不进行 dBm 与 Vpp 换算, +也不代替源端频率/功率回读。CH1 接入的低频辅助输出是独立端口,A3 未读取、未控制,也未从其观测推断 RF 输出状态或 +通过条件。本次证据确认两项 CW 源端回读、CH2 可见信号、4 次完成写入、72 次查询、健康关闭和最终 RF OFF;频率与功率保留为 setup 指定的测试值。 + +## 推荐实施顺序 + +1. 已完成 Core M0 的 kind、descriptor、配置、只读 Service/CLI 与 run status 全链路。 +2. 已在匹配的 Core `0.8.25` 开发线上迁移 DSG830 的 descriptor、依赖区间、topology 与 snapshot parser;正式 wheel 验收等待 Core 发布版本。 +3. 已取得并复核 A1 的只读 snapshot 证据;DSG830 parser 已仅作为 `rf_source.snapshot` 暴露为 production capability。 +4. 已完成 M1/M2 的 fake descriptor 零写拒绝、postcondition 测试、guarded OFF recovery、Core CLI/run 路由和 DSG830 离线 SCPI 映射;A2 已通过并仅提升 `rf_source.output`。 +5. A3 已完成并将 `rf_source.cw_configure` 加入 production descriptor。M3 的 Core 合同、DSG830 映射、CLI、run、artifact 与 A4 证据均已完成,`rf_source.modulation_configure` 已提升;M4 继续保持独立工作。 +6. A4 的 AM/FM/PM RF-OFF 单模式配置、严格读回与关闭恢复证据均已通过。PM production profile 固定为 `1.25 rad`,以避免将更宽的离线映射当作实机覆盖范围。源码 checkout 的 `--diagnose` 模式保留 `read_only` 配置,只读取初始/最终 RF snapshot 与指定模式 profile,并以零写审计保存私有诊断记录。该记录不构成新的 capability 提升证据;任何允许调制开启时 RF 输出的安全合同仍须单独设计和验证,CH2 可见信号也不能替代该证据。 +7. 已完成 M4 frequency-only Step Sweep 的 Core/DSG830 合同、固定 SCPI 映射、CLI、run、artifact、fake 回归和独立 A4 证据。零写诊断与一次受控配置均已通过,production descriptor 已提升 `rf_source.sweep_configure`;后续只讨论未开放的 execute/fire、trigger、Level Sweep、list 或调制输出等独立范围。 +8. 已完成 M3-MO 的 Core special capability、严格 transaction、CLI/run/artifact、公开按模式关闭入口、DSG830 固定 profile descriptor、CH2 observation 与 fake 回归。已使用 WaveBench 有界网络发现确认候选设备,并完成只读诊断与 AM `50 %`/`1 kHz`、FM `20 kHz`/`1 kHz`、PM `1.25 rad`/`1 kHz`、RF `1 MHz`/`-50 dBm` 的受控 A4-MO;CH2 信号存在、最终 RF OFF、调制关闭和健康关闭均已通过。FM/PM 记录通过 WaveBench 波形摘要与 FFT 保存质量告警,但不测量频偏或相偏;production descriptor 已提升三个精确 profile。CH1 的低频输出不属于该证据路径。 +9. 已完成受限 A5 Pulse Output 的 Core 合同、DSG830 固定 `:PULM:OUT:STAT` 映射、CLI/run/artifact、fake 回归和隔离实机验收。唯一提升路径为「PULSE IN/OUT」output →「EXT TRIGGER INPUT」,固定 `0 V`/`3.3 V`、约 `600 Ω`、internal/single/normal/`1 ms`/`100 μs`;最终 RF 输出和 Pulse Output 均为 OFF。production descriptor 已提升 `rf_source.pulse_output`。后续 A5 工作必须从另一条物理接口、接线和电气边界重新开始,不能复用本条证据。 diff --git "a/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" new file mode 100644 index 0000000..caa28ea --- /dev/null +++ "b/docs/project/design/WaveBench_RF\344\277\241\345\217\267\346\272\220\350\256\276\350\256\241.md" @@ -0,0 +1,469 @@ +# WaveBench RF 信号源领域设计 + +## 文档定位 + +本文定义独立 `rf_source` 领域合同,说明它为什么不能复用普通函数发生器的 `source` 合同,以及 Core 与仪器插件应如何分阶段实现。Core `0.8.25` 开发线已具备 M0 只读、M1 OFF-only CW、M2 端口输出、M3 内部正弦调制及按模式关闭、M3-MO 受限调制输出、M4 Pulse 和 frequency-only Step Sweep 配置,以及受限的 A5 Pulse Output 合同与控制入口;DSG830 已凭 A1/A2/A3/A4/A4-MO/A5 Pulse Output 证据开放 snapshot、OFF-only CW、受 safety 限制的 output、RF-OFF 内部正弦调制及按模式关闭、固定 profile 调制输出、RF-OFF Pulse 配置、保持 Sweep disabled 的 Step Sweep 配置,以及一条后面板「PULSE IN/OUT」输出路径。M3 的 PM production profile 仅为 `1.25 rad`;M3-MO 的 production profile 为 AM `50 %`/`1 kHz`、FM `20 kHz`/`1 kHz`、PM `1.25 rad`/`1 kHz`,最大 `-50 dBm`。离线代码与宽于 production profile 的映射不能替代相应 capability 的实机证据。 + +阅读顺序如下: + +1. 本文界定领域模型、安全规则和 production capability 的证据门槛。 +2. [RF 信号源开发里程碑](WaveBench_RF信号源开发里程碑.md) 说明 Core 与 DSG830 插件的交付顺序。 +3. [设备抽象层](WaveBench_设备抽象层.md) 和 [多仪器流程设计](WaveBench_多仪器协同流程设计.md)说明当前通用分层与 run plan 边界。 +4. 面向使用者的配置与操作顺序见 [RF 信号源使用指南](../guides/WaveBench_RF信号源使用指南.md);当前可执行命令、配置字段和 step kind 仍以 `wavebench --help`、`wavebench run schema`、`wavebench.example.toml` 与参考文档为准。 + +## 当前状态 + +| 范围 | 当前状态 | 边界 | +| --- | --- | --- | +| Core `0.8.25` 开发线 | 已实现 `rf_source` kind、append-only descriptor extension、`[rf_source]`、M0 只读路径、M1 OFF-only CW、M2 端口输出、M3 内部正弦 AM/FM/PM 及按模式关闭、M3-MO profile-bound 调制输出、M4 Pulse/frequency-only Step Sweep 配置,以及 A5 Pulse Output 的 Service/CLI/run/artifact。 | production capability 仍由各插件的实机证据逐项决定。 | +| DSG830 包 `0.2.0` | 已迁移为 `kind="rf_source"`,提供 `rf_out` 静态 topology、严格 snapshot parser、`:FREQ`/`:LEV`/`:OUTP`、内部正弦 AM/FM/PM、internal/single Pulse、frequency-only Step Sweep 和 `:PULM:OUT:STAT` 输出映射;A1/A2/A3/A4/A4-MO/A5 Pulse Output 证据已经完成。 | production descriptor 声明 `rf_source.idn`、`rf_source.snapshot`、OFF-only `rf_source.cw_configure`、受 safety 限制的 `rf_source.output`、`rf_source.modulation_configure`、`rf_source.modulation_disable`、AM `50 %`/`1 kHz`、FM `20 kHz`/`1 kHz`、PM `1.25 rad`/`1 kHz`、最大 `-50 dBm` 的 `rf_source.modulated_output_enable`、`rf_source.pulse_configure`、受限 `rf_source.pulse_output` 和保持 Sweep disabled 的 `rf_source.sweep_configure`。PM 的 RF-OFF 配置范围仍限于 `1.25 rad`。 | +| 实机证据 | A1、A2、A3、A4 调制/Pulse/Step Sweep、A4-MO 与一条 A5 Pulse Output 路径已完成。 | A5 只提升已验证的「PULSE IN/OUT」output 方向;不提升 Pulse input、`TRIGGER IN`、trigger、fire、sync/reference、Level Sweep 或 list。 | + +普通 `source` 仍是面向函数/任意波形发生器的 Vpp、offset、数字 channel 与波形模型。它不是 RF 领域的兼容别名。 + +除明确标为「生产只读」「A2 已提升」「A3 已提升」「A4 已提升」或「离线已完成」的内容外,本文中的其它 M4 子项、production 写 capability 与 A5 均为目标合同或证据门。M1 仅在已取得 A3 证据的插件上开放 OFF-only CW;M2 仅在已取得 A2 证据的插件上开放端口级 output;DSG830 的 M3、M4 Pulse 和 M4 Step Sweep 已由 A4 分别提升,但各项均保持其声明的 profile 和输出边界。 + +## 术语与证据级别 + +| 术语 | 含义 | +| --- | --- | +| 当前能力 | 已在当前 Core 或当前 production descriptor 中声明,并由程序入口实际消费的能力。 | +| 离线合同 | model、parser、fake transport、SCPI 映射和包装测试的结果;不授权真实仪器写入。 | +| 测试 descriptor | 只在 fake transport 测试中声明后续 capability 的 descriptor,不得随生产包对真实设备开放。 | +| production descriptor | 面向已联网设备的公开 descriptor;只能声明已有对应实机证据的 capability。 | + +## 目标 + +WaveBench 的独立 `rf_source` 仪器域面向以频率、功率等级、RF 输出、调制、脉冲和扫频为主要控制对象的射频信号源,不复用普通函数发生器的 `source`、Vpp、offset、channel、ARB 或波形模型。 + +RIGOL DSG830 是第一个适配目标和手册验证样本,不是该领域的边界。核心合同不得出现 DSG830 专用 SCPI、固定频率范围、固定功率范围、固定端口名或厂商状态位。设备差异由插件 descriptor、driver 和证据记录承载。 + +本文覆盖 M0 的当前只读实现、已完成 A1/A2/A3/A4 调制/Pulse/Step Sweep/A4-MO 和 A5 Pulse Output 的提升边界,以及 M1、M3、M3-MO 与 M4 的离线开发和 fake transport 验证边界。DSG830 的 M3、M3-MO 和受限 A5 Pulse Output 已完成 Core 与实机验收并进入 production;其它 A5 接口仍另行处理,离线代码不能替代这些证据。 + +## 范围与非目标 + +### 目标交付 + +- M0 已提供 `rf_source` plugin kind、配置、capability、model、driver Protocol、只读 Service/CLI/doctor、run status 和 artifact namespace。 +- M1 已提供 OFF-only CW 的 typed request/result、单次写入、独立 snapshot 回读、CLI、run step 和 artifact;DSG830 已由 A3 将其提升到 production。 +- M2 已提供端口级 RF ON/OFF 事务、ON safety preflight、一次性 OFF recovery、CLI、run step 和 artifact;DSG830 的 A2 已将这一 capability 提升到 production。 +- 定义多 RF 输出端口的通用模型;首个 DSG830 适配器只声明一个端口。 +- 定义 CW 频率/dBm 功率配置、RF 输出控制、AM/FM/PM、profile-bound 调制输出、Pulse、Step Sweep、arm/fire/stop 的标准 operation 合同;M4 当前完成 internal/single Pulse 与保持 Sweep disabled 的 frequency-only Step Sweep 配置子集,A5 当前只完成一个固定后面板 Pulse 输出子集。 +- 为每条写路径定义输入校验、RF OFF 配置前置条件、独立回读、状态异常失败关闭、fake transport 故障注入和包装测试要求。 + +### 明确不做 + +- 默认测试不访问、查询或写入已联网的真实仪器;实机 I/O 只能在单项 A 级证据流程中执行。 +- 不发送 `*RST`、preset、memory、IQ、correction、任意波、list 上传或仪器文件系统命令。 +- 不将 `dBm` 换算为 Vpp,也不从连接器铭文、仪器显示或型号名推断实际端接。 +- 不将设备专用 ALC、衰减器、参考时钟、同步、外部触发或保护复位抽象为未定义的通用字段。 +- 不将未完成实机验收的 snapshot 或写 driver 方法暴露为 production descriptor capability;A2 只授权已验收插件的 `rf_source.output`,A3 只授权已验收插件的 `rf_source.cw_configure`,A4 只授权已验收范围内的 RF-OFF 调制、调制关闭、Pulse 或 Step Sweep 配置 capability,A4-MO 只授权其精确声明的调制输出 profile,A5 只授权逐条确认的物理接口、方向和电气 profile。 + +## 分层与职责 + +| 层 | 职责 | 不承担的职责 | +| --- | --- | --- | +| 核心 `rf_source` 域 | 公共 model、capability、访问控制、资源租约、session health、安全预检、Service、CLI、run plan 与 artifact | 厂商 SCPI、厂商响应解析、设备功能猜测 | +| 插件 descriptor | 稳定 driver ID、输出端口拓扑、支持功能、有效范围、功率参考与证据引用 | 建立连接、扫描资源、隐式授权 | +| 插件 driver | SCPI 命令、响应解析、写后设备 readback、私有状态映射、`close()` | 读取完整配置、另建 transport、直接写 run artifact、重试能量操作 | +| 实验室配置 | 当前 resource、访问模式、端口安全限制、实际端接声明 | 替代设备能力或实机证据 | + +核心复用既有 registry、factory、`DriverContext`、`GuardedAuditedTransport`、资源租约、access policy 与 session health。它不复用 `SourceDriver`、`SourceStatus`、`SourceService`、Source V2 extension、Vpp safety limit 或普通 source restore。 + +## 通用对象模型 + +### 输出端口与拓扑 + +RF 输出端口使用 descriptor 声明的稳定 `port_id`,而不是数字 channel。例如,一个单端口设备可声明 `"rf_out"`;多端口设备可声明多个不同端口。`port_id` 只在同一 driver ID 内稳定,不承担物理接头型号、实际端接或资源地址语义。 + +```python +@dataclass(frozen=True, slots=True) +class RfOutputPortProfile: + port_id: str + frequency_min_hz: float + frequency_max_hz: float + power_min_dbm: float + power_max_dbm: float + power_reference_impedance_ohm: float + + +@dataclass(frozen=True, slots=True) +class RfSourceTopology: + ports: tuple[RfOutputPortProfile, ...] +``` + +所有范围端点必须有限,最小值不得大于最大值;端口 ID 必须非空、排序稳定且不重复。`power_reference_impedance_ohm` 表示设备用来定义 dBm 的固定参考阻抗,不等于 DUT 的实际端接。 + +### 可观测状态 + +不同设备对状态的读取能力不同。公共 model 使用独立的 `RfObserved[T]` 表示值及其可用性: + +```python +from typing import Generic, TypeVar + + +T = TypeVar("T") + + +class RfAvailability(StrEnum): + VALUE = "value" + UNSUPPORTED = "unsupported" + NOT_APPLICABLE = "not_applicable" + UNAVAILABLE = "unavailable" + UNKNOWN = "unknown" + + +@dataclass(frozen=True, slots=True) +class RfObserved(Generic[T]): + availability: RfAvailability + value: T | None = None + reason_code: RfReasonCode | None = None +``` + +`VALUE` 必须携带通过类型与有限值校验的值;其它可用性必须使用 `value=None`。安全相关字段不是 `VALUE` 时,所有依赖该字段的能量增加 operation 必须在写入前拒绝。 + +`reason_code` 只用于稳定、脱敏的原因标识,不能保存原始 SCPI 响应或厂商私有错误文本。M0 必须通过边界测试冻结 `VALUE` 的有限数/布尔值校验,以及非 `VALUE` 情况下 `value` 与 `reason_code` 的组合规则。 + +```python +@dataclass(frozen=True, slots=True) +class RfPortSnapshot: + port_id: str + frequency_hz: RfObserved[float] + power_dbm: RfObserved[float] + output_enabled: RfObserved[bool] + modulation: RfObserved["RfModulationState"] + pulse: RfObserved["RfPulseState"] + sweep: RfObserved["RfSweepState"] + + +@dataclass(frozen=True, slots=True) +class RfSourceSnapshot: + ports: tuple[RfPortSnapshot, ...] + protection: RfObserved["RfProtectionStatus"] +``` + +`RfModulationState`、`RfPulseState` 与 `RfSweepState` 是封闭的类型化状态,不是自由 mapping。某个 feature 的状态模型尚未完成时,driver 必须返回非 `VALUE`,不能用猜测字段填充。M3/M4 在相应 capability 进入 descriptor 前冻结这些状态的枚举、模式和 readback 语义。 + +`RfProtectionStatus` 只保留规范化的活动 condition。descriptor 对每个已知 condition 声明明确 policy;出现未声明、未知或无法解释的 active code 时,一律阻止 RF ON。artifact 不保存原始状态寄存器、SCPI 响应或厂商私有文本。 + +```python +@dataclass(frozen=True, slots=True) +class RfProtectionStatus: + active_codes: tuple[str, ...] + + +@dataclass(frozen=True, slots=True) +class RfProtectionConditionPolicy: + code: str + blocks_output_enable: bool +``` + +### 功能 profile 与 capability + +descriptor 在 `rf_source_extensions` 中声明 topology、功能和方向。它必须位于 `InstrumentDescriptor` 的末尾,不能复用或改变既有 `source_extensions` 的布局。核心只接受 `kind="rf_source"` 的 descriptor 提供该字段;目标 operation 缺少所需 capability、profile、driver method 或版本门时,必须在仪器 operation 前拒绝。 + +```python +class RfFeature(StrEnum): + CW = "cw" + OUTPUT = "output" + MODULATION = "modulation" + MODULATED_OUTPUT = "modulated_output" + PULSE = "pulse" + PULSE_OUTPUT = "pulse_output" + SWEEP = "sweep" + TRIGGER = "trigger" + + +class RfFeatureDirection(StrEnum): + READ = "read" + CONFIGURE = "configure" + ENABLE = "enable" + DISABLE = "disable" + ARM = "arm" + TRIGGER = "trigger" + FIRE = "fire" + STOP = "stop" +``` + +```python +@dataclass(frozen=True, slots=True) +class RfFeatureCapability: + feature: RfFeature + directions: tuple[RfFeatureDirection, ...] + port_ids: tuple[str, ...] + profile: "RfFeatureProfile" + + +@dataclass(frozen=True, slots=True) +class RfSourceDescriptorExtensions: + contract_version: Literal["wavebench.rf_source.v1"] + topology: RfSourceTopology + features: tuple[RfFeatureCapability, ...] + protection_conditions: tuple[RfProtectionConditionPolicy, ...] +``` + +每个 protection policy 的 `code` 必须非空且唯一。Core 以 policy 集合识别已知 condition;只有 `blocks_output_enable=False` 的已知 active code 可以不阻断 RF ON。不存在 policy 的 active code 必须拒绝 RF ON。 + +`RfFeatureProfile` 是 `RfCwProfile`、`RfOutputProfile`、`RfModulationProfile`、`RfModulatedOutputProfile`、`RfPulseProfile`、`RfPulseOutputProfile`、`RfSweepProfile` 或 `RfTriggerProfile` 的封闭联合。`RfModulatedOutputProfile` 只列出已逐项证实允许在调制开启时启用 RF 的内部 Sine profile,并且必须是基础调制 profile 的子集,功率上限不得超过端口范围。`RfPulseOutputProfile` 明确一个物理接口 ID、唯一 output 方向、可读回状态、电平、源阻抗和固定的内部 Pulse profile;它不隐含同名接口的 input 方向。`RfTriggerProfile` 只描述可读取的逻辑 Pulse/Sweep trigger configuration 值;它不表示物理 trigger/sync 接口、方向、电平或端接。每种 profile 只描述所属 feature 的模式、方向、可读回字段和数值范围;不能用自由 mapping、SCPI 字符串或厂商回调扩展安全语义。 + +每个 `RfFeatureCapability` 必须指定 feature、direction、适用端口、静态限制和可读回字段。静态 profile 只能收紧设备支持范围,不能授权未声明的 operation。`rf_source.pulse_trigger` 对应 `PULSE / TRIGGER`;`rf_source.sweep_fire` 对应 `SWEEP / FIRE`;其他 operation 也必须在 M0–M4 的 descriptor validator 中有唯一映射。 + +Core 在调用目标 driver operation 前校验 request、access、descriptor 静态 schema、capability 名称、profile、版本和配置。factory 返回 driver 后再校验 capability 所需方法;现有 factory 可以在构造 driver 时打开已配置 transport,因此不承诺「方法校验发生在建立连接之前」。descriptor 导入和静态校验不得进行 I/O,且任何 SCPI operation 都不得绕过上述校验。 + +标准 capability 与 driver 方法如下: + +| capability | driver 方法 | 作用 | +| --- | --- | --- | +| `rf_source.idn` | `idn()` | 身份查询 | +| `rf_source.snapshot` | `get_rf_snapshot()` | 只读完整快照 | +| `rf_source.trigger_snapshot` | `get_rf_trigger_snapshot(port_id)` | 只读逻辑 Pulse/Sweep trigger configuration;不表示物理 trigger connector。 | +| `rf_source.cw_configure` | `configure_cw(request)` | 端口频率与 dBm 功率配置 | +| `rf_source.output` | `set_rf_output(request)` | 单端口 RF ON/OFF | +| `rf_source.modulation_configure` | `configure_rf_modulation(request)` | 已声明的 AM/FM/PM 配置 | +| `rf_source.modulation_disable` | `disable_rf_modulation(request)` | 关闭一个已明确识别的调制模式与全局调制开关 | +| `rf_source.modulated_output_enable` | `get_rf_modulation_snapshot(port_id, kind)`、`set_rf_output(request)` | 只在已激活 profile 精确匹配时,单次启用 RF;不配置或关闭调制。 | +| `rf_source.pulse_configure` | `configure_rf_pulse(request)` | 已声明的 Pulse 配置 | +| `rf_source.pulse_output` | `get_rf_pulse_output_snapshot(port_id, interface_id)`、`set_rf_pulse_output(request)` | 已声明的物理 Pulse 输出接口;不控制 RF 输出或接收设备。 | +| `rf_source.pulse_trigger` | `trigger_rf_pulse(request)` | 已声明的 Pulse 触发 | +| `rf_source.sweep_configure` | `configure_rf_sweep(request)` | 已声明的 Sweep 配置 | +| `rf_source.sweep_arm` | `arm_rf_sweep(request)` | 准备 Sweep | +| `rf_source.sweep_fire` | `fire_rf_sweep(request)` | 发起已准备 Sweep | +| `rf_source.sweep_stop` | `stop_rf_sweep(request)` | 停止 Sweep | + +所有 request 均显式携带 `port_id`。同一 operation 不可借默认端口、当前前面板选择或 driver 私有缓存猜测目标端口。 + +`rf-source set-frequency` 与 `rf-source set-power` 是独立的 CLI/run 操作名,但共同使用 `rf_source.cw_configure` capability 和同一类 CW request/postcondition 合同;一个 operation 不能借另一个 operation 的成功隐式取得写入授权。 + +## 通用写入与安全合同 + +### 配置模型 + +`[rf_source]` 是独立配置段。端口安全限制按 descriptor 的 `port_id` 声明: + +```toml +[rf_source] +driver = "vendor.model" +resource = "" +access = "read_only" + +[[rf_source.safety.ports]] +port_id = "rf_out" +minimum_frequency_hz = 9000 +maximum_frequency_hz = 3000000000 +maximum_power_dbm = -20 +actual_termination_ohm = 50 +``` + +每个会执行 RF ON、fire 或其它可能增加 RF 端口能量的 operation 都要求目标端口拥有完整安全配置。配置范围只能收紧 descriptor 的设备范围。`actual_termination_ohm` 必须是有限正数,并绑定当前端口;M0–M4 与 M3-MO 仅在它与 descriptor 的 `power_reference_impedance_ohm` 精确相等时允许使用 dBm 输出安全判断,不进行阻抗或电压换算。 + +### Operation 顺序 + +CW、调制、Pulse 与 Sweep 配置必须按以下顺序执行: + +1. 在目标 operation 的 SCPI I/O 前校验 request、access、capability 与 descriptor profile;只有可能增加端口能量的 operation 才同时校验完整安全配置; +2. 获得独占资源租约,创建一次受管 driver session; +3. 读取 fresh snapshot,确认目标 RF 输出为 OFF,且无与 operation 冲突的活动 feature; +4. 调用一次对应 driver 配置方法; +5. 读取独立 postcondition,逐字段比较请求值、端口状态和隐式变化; +6. 成功后返回类型化结果与脱敏 artifact。 + +主写开始后遇到结果不明、写后 readback 失败或保护状态变化时,不重试同一写入。M1 CW 与 M3 调制配置不执行 RF OFF recovery,而是将 session 保持在更保守状态;M2 与 M3-MO 的 RF ON 事务只在 session health 允许时最多执行一次目标端口 RF OFF 并独立回读。 + +### 调制关闭与恢复 + +`rf_source.modulation_disable` 是一个独立的、按模式寻址的写事务,不是 reset,也不等同于 RF 输出关闭。它只在目标 RF 输出为 OFF、Pulse/Sweep 已关闭、protection 清晰,并且状态明确表明仅请求的 AM、FM 或 PM 模式已启用时才发送关闭写入;随后必须用 RF snapshot 和调制状态回读确认全局调制及所有模式均已关闭。 + +已一致关闭的状态以零写方式返回。混合模式、状态矛盾、未知状态或写后结果不明都会拒绝或使 session 降为不确定状态,不能改用宽泛关闭命令重试。DSG830 的 A4 调制与 A4-MO 清理证据已经覆盖该事务,因此 production descriptor 声明 `rf_source.modulation_disable`,并提供 `wavebench rf-source modulation disable --port PORT_ID --modulation-kind am|fm|pm` 与 `rf_source.modulation_disable` run step。 + +RF ON 是独立 operation。其 preflight 必须确认: + +- `access = "read_write"`; +- target port 的频率、dBm 功率、输出状态、调制、Pulse、Sweep 和 protection 全部为 `VALUE`; +- frequency 与 power 同时在端口安全配置及 descriptor profile 范围内; +- 实际端接与设备 dBm 参考阻抗相等; +- 没有活动的 blocking protection condition; +- 没有尚未获得专项输出安全规则的调制、Pulse 或 Sweep 状态。 + +RF OFF 不依赖频率、功率、端接或 protection readback;它仍受 access、session health 和单次写入规则限制。 + +### M3-MO:受限调制输出 + +`rf_source.modulated_output_enable` 是与 `rf_source.output` 分开的特殊 capability。它只接受一个精确的、已经激活的内部 Sine AM/FM/PM request:不会配置调制,不会在成功后自动 RF OFF,也不会关闭调制。普通 `rf_source.output` 的 ON preflight 仍要求所有调制模式关闭,不能用这个 capability 放宽其边界。 + +特殊 operation 在一个独占 session 中依次读取 RF snapshot 和目标完整调制 snapshot,确认目标 RF 为 OFF、调制全局开关与唯一目标模式已开启、source/waveform/数值/内部频率精确匹配、Pulse/Sweep disabled、protection 清晰,以及频率、功率、50 Ω 实际端接和 descriptor 中更窄的调制输出 profile 均满足。随后只发送一次 RF ON,并再次读取两类 snapshot,确认 RF 为 ON 且调制 profile 未改变。 + +ON 写入、RF readback 或调制 readback 任何一项不确定时,绝不重试 ON;只允许沿用 M2 的一次受 guard RF OFF recovery。恢复后 session 保持不确定,调用方不得假设调制已关闭。`RfModulatedOutputProfile` 必须显式声明可用端口、精确 mode profile 与最大功率,不能从基础 `RfModulationProfile` 或普通 output capability 自动推导。 + +DSG830 的 A4-MO 受控证据提升三个精确 profile:AM `50 %`/内部 `1 kHz`、FM `20 kHz` 频偏/内部 `1 kHz`、PM `1.25 rad` 相偏/内部 `1 kHz`,最大功率均为 `-50 dBm`。它们必须在普通 M3 配置读回后通过相应 `enable-output-am|fm|pm` 启用;结束时先用普通 `output off`,再用按模式 `modulation disable` 清理。FM/PM 的 WaveBench CH2 分析只记录信号存在和波形质量,不能独立测量频偏或相偏。该提升不修改普通 `rf_source.output` 的调制关闭前置条件。 + +Sweep arm 是 OFF-only 准备 operation,必须保持目标端口 RF OFF。Sweep fire 与 Pulse trigger 是潜在能量操作。它们必须使用独立、一次性安全决定,不能因先前的 configure、arm 或 output ON 成功而自动获得许可。core 不会隐式打开输出、触发外部端口或开启后面板辅助输出。 + +## Service、CLI、doctor 与 run plan + +### 当前已实现的入口 + +`RfSourceService` 统一负责 capability、access、资源租约、session health 和类型化 snapshot;driver 只执行已冻结的设备动作,CLI 不直接生成 SCPI。当前入口按能力层次分为三组: + +```text +# 生产只读:DSG830 已由 A1 提升 +wavebench rf-source idn +wavebench rf-source status +rf_source.status + +# A5-0 离线只读:需要声明 rf_source.trigger_snapshot 的非 production descriptor +wavebench rf-source trigger status --port PORT_ID +rf_source.trigger_status + +# 生产 M1:仅在已完成 A3 的插件上,且必须同时具备 read_write、CW capability 和 OFF-only preflight +wavebench rf-source set-frequency --port PORT_ID HZ +wavebench rf-source set-power --port PORT_ID DBM +rf_source.set_frequency +rf_source.set_power_dbm + +# 生产 M2:仅在已完成 A2 的插件上,且必须同时具备 read_write、output capability 和端口级 safety preflight +wavebench rf-source output --port PORT_ID on|off +rf_source.output_enable +rf_source.output_disable + +# M3-MO:只限已完成对应实机证据的 production descriptor +wavebench rf-source modulation enable-output-am ... +wavebench rf-source modulation enable-output-fm ... +wavebench rf-source modulation enable-output-pm ... +rf_source.modulated_output_enable + +# A5 Pulse Output:只限声明的物理 output 接口与固定 profile +wavebench rf-source pulse-output --port PORT_ID --interface INTERFACE_ID on|off +rf_source.pulse_output_enable +rf_source.pulse_output_disable +``` + +`rf-source status` 和 `rf_source.status` 均要求 descriptor 声明 `rf_source.snapshot`;缺少该 capability 时,Core 会在打开 transport 前拒绝请求。`rf-source trigger status` 和 `rf_source.trigger_status` 要求独立的 `rf_source.trigger_snapshot` capability,以及目标 `port_id` 的 `TRIGGER / READ` profile;它们是 `stateful_read`,不读取普通 RF snapshot、不执行 recovery、不写入或触发。DSG830 当前 production descriptor 未声明该 capability,因此该命令会在打开 session 前拒绝。`doctor` 仅新增 `rf_source` 的 `*IDN?` target;它不读取运行状态、不改变访问模式,也不打开 RF 输出。 + +M1 的每个 run step 都要求 `port_id` 与一个有限数值;M2 与 A5 Pulse Output 的每个 run step 都要求 `port_id`,其中 A5 还要求显式 `interface_id`,并产生脱敏的 preflight/postcondition snapshot artifact。所有步骤使用独立的 `wavebench.rf_source.operation.v1` artifact namespace。DSG830 已由 A3 声明 `rf_source.cw_configure`、由 A2 声明 `rf_source.output`、由受限 A5 证据声明 `rf_source.pulse_output`;M1 仅在 `read_write`、目标端口明确 OFF 与完整 OFF-only preflight 同时成立时可执行,M2 还要求完整端口 safety 配置和 fresh preflight。 + +### M1 的生产 CW 与 M2 的生产输出合同 + +M1 是 OFF-only CW 配置:目标端口必须明确为 OFF,调制、Pulse、Sweep 与 protection 不得冲突;每次调用只写一个频率或 dBm 字段,并用独立 snapshot 回读确认。写后结果不明时不重试。 + +M2 是端口级输出事务。RF ON 必须确认完整 safety 配置、实际端接与 dBm 参考阻抗一致、频率与 dBm 功率处于设备和实验室配置范围内、调制/Pulse/Sweep 都关闭,并且 protection 仅含已知的非阻断状态。RF OFF 只依赖目标输出状态,不要求频率、功率、端接或 protection 可读。ON 写入或其 readback 结果不明时,session 降为不确定状态,并且只在受 guard 的 recovery 预算内最多执行一次同端口 OFF 和 OFF 回读;OFF 写入结果不明时不重试,session 降为 poisoned。 + +M1 已由 A3 在真实设备上完成受控频率/功率写入、独立 readback、低功率 RF ON/OFF 环回与最终 OFF 验收,因而将 `rf_source.cw_configure` 纳入 DSG830 production descriptor。M2 已由 A2 将 `rf_source.output` 纳入同一 descriptor;人工确认的实验室端接本身仍不构成调制、Pulse、Sweep、trigger 或其它额外写入授权。 + +### M3、M3-MO 与 M4 的 production 入口 + +M3 的写入 CLI 和 run step 已进入当前 Core schema。DSG830 已由 A4 声明 `rf_source.modulation_configure`,但真实仪器使用仍由 production descriptor、`read_write`、匹配 profile 与 fresh RF-OFF preflight 共同门禁: + +```text +wavebench rf-source modulation configure-am ... +wavebench rf-source modulation configure-fm ... +wavebench rf-source modulation configure-pm ... +rf_source.modulation_configure + +# M3-MO:仅接受 descriptor 明确声明的 active profile +wavebench rf-source modulation enable-output-am ... +wavebench rf-source modulation enable-output-fm ... +wavebench rf-source modulation enable-output-pm ... +rf_source.modulated_output_enable + +# M4 Pulse:只允许 internal/single 配置,配置后 Pulse 仍保持关闭 +wavebench rf-source pulse configure --port PORT_ID --period-s SECONDS --width-s SECONDS --polarity normal|inverted +rf_source.pulse_configure + +# A5 Pulse Output:只切换已声明的物理 output 状态,不启用 RF 输出 +wavebench rf-source pulse-output --port PORT_ID --interface INTERFACE_ID on|off +rf_source.pulse_output_enable +rf_source.pulse_output_disable + +# Pulse trigger、Sweep arm/fire/stop 与 Level Sweep 仍是目标合同,尚未进入当前 Core schema +wavebench rf-source pulse trigger ... +wavebench rf-source sweep arm ... +wavebench rf-source sweep fire ... +wavebench rf-source sweep stop ... + +# M4 Step Sweep:仅配置 frequency-only、forward、linear、ramp profile,配置后 Sweep 仍保持关闭 +wavebench rf-source sweep configure --port PORT_ID --start-frequency-hz START --stop-frequency-hz STOP --points COUNT --dwell-s SECONDS +rf_source.sweep_configure +``` + +M3 使用 `modulation_kind = "am" | "fm" | "pm"`,并且只接受与该模式匹配的 `depth_percent`、`frequency_deviation_hz` 或 `phase_deviation_rad` 之一。它要求 RF OFF、所有调制模式 disabled、Pulse/Sweep disabled 和无活动 protection condition。DSG830 production profile 为 AM `0–100 %`、FM `0.1 Hz–1 MHz`、PM 精确 `1.25 rad`,内部频率均为 `10 Hz–100 kHz`。FM/PM 的共享选择位作为调制 snapshot 的独立字段记录:preflight 可接受另一种已关闭的 FM/PM 选择,固定 driver 写入会明确选择目标类型,postcondition 则必须确认目标类型。随后以调制 snapshot 独立验证目标模式、内部 source、Sine waveform、数值、内部频率和全局状态。结果不明时不重试,且不会隐式执行 RF OFF recovery。M2 的 RF ON 仍要求调制关闭,因此这不是调制输出入口。 + +M3-MO 复用同一 `modulation_kind` 和数值字段,但语义相反:它要求调制已经激活且完整 profile 精确匹配,不会配置该 profile。当前 Core schema 已有三个 `enable-output-*` CLI、`rf_source.modulated_output_enable` run step,以及显式 `rf_source.modulation_disable` 清理入口。DSG830 production descriptor 声明 AM `50 %`/`1 kHz`、FM `20 kHz`/`1 kHz`、PM `1.25 rad`/`1 kHz`、最大 `-50 dBm` 的 M3-MO profile;其它 special output 请求会在仪器 I/O 前拒绝。A4-MO 的三条固定 profile 均使用 RF `1 MHz`/`-50 dBm` 和 CH2 的显式 50 Ω 观察完成受控验收。FM/PM 额外保存 WaveBench 波形摘要和 FFT 质量记录;它们不读取或控制 CH1,不把 LF OUTPUT 当作调制测量,也不从 scope 推断 dBm、频偏、相偏、调制准确度或频谱合规性。 + +`rf_source.pulse_configure` 已进入当前 Core schema;DSG830 已在 A4 Pulse 证据复核后声明该 capability。它只接受 period、width 和 polarity,要求 RF 输出、调制、Pulse、Sweep 均关闭且无活动 protection;写入后必须读回 internal/single、请求的 timing/polarity 和 Pulse 关闭状态。它不提供 trigger、后面板 Pulse I/O 或 RF 输出控制。 + +`rf_source.pulse_output` 是与 `rf_source.pulse_configure` 分开的受限物理接口 operation。DSG830 仅声明 `rf_out` 上的 `pulse_in_out`,方向固定为 output,电气 profile 固定为 `0 V`/`3.3 V`、约 `600 Ω`,Pulse profile 固定为 internal/single/normal/`1 ms`/`100 μs` 且 Pulse 调制保持关闭。启用前,Core 要求 RF 输出、调制、Pulse、Sweep 均关闭、protection 为空、接口与完整固定 profile 精确匹配;随后只执行一次 Pulse Output 状态写入,并分别回读 RF 与物理接口状态。关闭操作故意允许已知 profile 漂移,以保留关闭已启用输出的安全路径;它仍只操作该接口。任何写入或 readback 结果不明都会使 session 降为不确定状态,Core 不自动重试,也不配置接收设备、RF 输出、trigger 或示波器。 + +`rf_source.sweep_configure` 已进入当前 Core schema;DSG830 已在 A4 Step Sweep 证据复核后声明该 capability。请求只接受起止频率、点数和驻留时间,静态 profile 固定为 `STEP`/`FWD`/`RAMP`/`LIN`。Core 在写前和写后都要求 RF 输出、调制、Pulse、Sweep 关闭且无活动 protection;driver 配置后必须保持 Sweep disabled,并以独立 profile readback 逐字段确认。该 operation 没有 Level Sweep、arm、fire、`SWE:EXEC`、trigger、后面板接口或 RF 输出字段。Pulse trigger、Sweep arm/fire/stop 仍是目标合同。所有这些入口都必须显式指定 `port_id`,拥有独立 `OperationSpec` 与 `wavebench.rf_source.operation.v1` artifact,不得访问普通 source channel 或未声明端口。 + +### A5:物理接口按路径提升 + +外部 trigger/同步不能只在 `RfFeatureDirection.TRIGGER`、`FIRE` 或 `ARM` 已存在的前提下补充 driver 方法。每条路径都必须先定义目标物理接口、方向、电气 profile、允许的模式、可读回状态、RF 能量前置条件和失败恢复语义;这些字段不能用自由 mapping 或普通 `source` 的 channel/Vpp 模型表示。 + +#### A5-0:逻辑 trigger configuration 读取 + +A5-0 已在离线范围内增加 `RfTriggerProfile`、`RfTriggerSnapshot`、`rf_source.trigger_snapshot`、`rf-source trigger status` 与 `rf_source.trigger_status`;它固定读取逻辑 Pulse/Sweep trigger configuration,不把 `port_id` 解释为物理 trigger/sync connector。DSG830 production descriptor 仍不声明该 capability。源码 checkout 的私有 A5-0 harness 保持原始 `read_only` 配置,并已完成隔离零写诊断:22 次 query、零 write、最终 RF OFF 和健康关闭均已复核。静态预检绑定当前 production descriptor 的 capability 列表;后续 capability 变更必须通过代码审查、fake 回归和新的零写诊断更新该基线,否则工具在建立 session 前拒绝。该诊断不构成物理 A5 实机证据,也不创建外部接口默认值、不隐式触发设备,或把 `rf_out` 的端接和 CH2 输入设置用于推断 trigger/sync 端口。 + +#### A5 Pulse Output:已完成的单一路径 + +已验证的唯一路径是 DSG830「PULSE IN/OUT」的 output 方向接入 RTM2032「EXT TRIGGER INPUT」。DSG830 侧 profile 固定为 `0 V`/`3.3 V`、约 `600 Ω`,internal/single/normal、period `1 ms`、width `100 μs`;接收端仅以其 `1 MΩ`/`12 pF`/`≤ 150 Vp` 输入额定值作为本次接线的电气边界。验收 harness 在隔离 session 中先核对 scope trigger source 为 external、mode 为 auto,再短暂设为 normal、执行 single、复位为 auto;scope 只作为隔离的物理观察与单次采集执行者,不构成 RTM driver 或 production capability。 + +该路径在 RF 输出始终关闭的前提下完成一次「Pulse Output ON → scope single → Pulse Output OFF」序列。成功审计为 RF 主 session `97` 次 query/`8` 次完成 write、独立最终 RF 复核 session `15` 次 query/零 write、scope session `5` 次 query/`3` 次完成 write;最终 RF 输出和 Pulse Output 均独立确认关闭。source 的既有 Pulse profile 与 scope acquisition state 不属于恢复范围,scope 在完成后可能停在 `Single`。历史 harness 在 capability 提升后拒绝重跑,避免以临时 descriptor 绕过 production 边界。 + +因此 DSG830 production descriptor 只增加 `rf_source.pulse_output`。它不提升同一连接器的 input 方向、`TRIGGER IN`、Pulse trigger、Sweep arm/fire、sync/reference、Level Sweep 或 list;任何会使设备开始 Pulse 或 Sweep 的 trigger/fire operation 仍需独立、一次性的安全决定和另一条 A5 实机证据。 + +## M0–M4 与 M3-MO 里程碑 + +下表同时标出当前进度和交付边界。Core 与 DSG830 插件的依赖、完成条件和状态见[RF 信号源开发里程碑](WaveBench_RF信号源开发里程碑.md)。 + +| 里程碑 | 通用核心交付 | 首个适配器离线交付 | 离线验证标准 | +| --- | --- | --- | --- | +| M0(生产只读) | `rf_source` kind、config、拓扑/profile、可观测 snapshot、Protocol、registry、doctor、只读 CLI 与 run status | 严格 snapshot parser;A1 后 production descriptor 声明只读 snapshot | descriptor 无 I/O;每个状态 query、解析和坏响应测试通过;A1 证据复核后仅提升 snapshot。 | +| M1(离线完成;DSG830 A3 已提升) | CW request/result、OFF-only transaction、CLI、run step、artifact 与端口范围检查 | 频率/dBm 功率单次写入与独立回读 | output ON、活动调制/Pulse/Sweep 或越界请求时零写拒绝;结果不明无重试;DSG830 仅在 A3 复核后声明 CW。 | +| M2(离线完成;DSG830 A2 已提升) | per-port 输出事务、安全预检、受 guard 的一次性 RF OFF recovery、CLI、run step 与 artifact | RF ON/OFF 单次写入;Core 负责独立 readback | 安全配置缺失、端接不匹配、保护异常或状态缺失时 ON 零写拒绝;ON readback 失败最多一次 OFF;DSG830 仅在 A2 复核后声明 output。 | +| M3(A4 已通过并提升) | 内部正弦 AM/FM/PM profile、typed request/result、调制 snapshot、配置 Service/CLI/run step/artifact;按模式关闭仅用于本地证据与私有恢复 | 内部 Sine 调制序列、严格 readback、单模式 RF-OFF evidence harness 与受限恢复路径 | 输出未 OFF、任一模式已开启、profile 不支持、Pulse/Sweep/protection 冲突或 postcondition 不符时零写拒绝;DSG830 已声明 `rf_source.modulation_configure`,其中 PM 固定为 `1.25 rad`。 | +| M3-MO(A4-MO 已通过并提升) | `RfModulatedOutputProfile`、special capability、严格 pre/post RF 与调制 snapshot、一次 ON、受 guard OFF recovery、CLI、run step 与 artifact | 复用已有调制 snapshot 与 `:OUTP` 映射;历史 AM 与独立 FM/PM evidence descriptor、CH2 observation、WaveBench 波形摘要/FFT 和 fake 回归 | 只在完整 active profile 精确匹配、RF OFF、Pulse/Sweep OFF、protection 清晰和端口 safety 完整时写一次 ON;绝不重试 ON。DSG830 production 声明 AM `50 %`/`1 kHz`、FM `20 kHz`/`1 kHz`、PM `1.25 rad`/`1 kHz`、最大 `-50 dBm`。scope 分析不计量频偏或相偏。 | +| M4(Pulse;DSG830 A4 已提升) | internal/single Pulse profile、typed request/result、OFF-only Service、CLI、run step 与 artifact | period/width/polarity 的固定映射;配置后强制 Pulse OFF | 输出、调制、Pulse、Sweep 或 protection 不满足时零写拒绝;写后逐字段 readback;DSG830 已声明 `rf_source.pulse_configure`。 | +| M4(Step Sweep;DSG830 A4 已提升) | frequency-only Step Sweep profile、configure、CLI、run step 与 artifact;不含 arm/fire/stop | `STEP`/`FWD`/`RAMP`/`LIN` 的严格 readback 与固定配置写入,最后保持 Sweep disabled | 输出、调制、Pulse、Sweep 或 protection 不满足时零写拒绝;不写 `SWE:EXEC`、trigger、Level Sweep 或 RF 输出。DSG830 已声明 `rf_source.sweep_configure`。 | +| A5(Pulse Output;DSG830 已提升) | `RfPulseOutputProfile`、固定物理 output request/result/snapshot、Service、CLI、run step 与 artifact | `pulse_in_out` output 的 `:PULM:OUT:STAT?`/`:PULM:OUT:STAT ON|OFF` 映射 | 仅在 RF 输出、调制、Pulse、Sweep 都关闭、protection 为空、接口和固定 profile 精确匹配时启用;关闭允许 profile 漂移以保留安全关闭路径。仅提升 `rf_source.pulse_output`。 | + +M0–M4、M3-MO 与 A5 Pulse Output 的每项提升都绑定代码合同、SCPI 映射和对应实机证据。证据顺序包含:A1 只读 snapshot,A2 RF OFF/ON,A3 CW 环回,A4 调制/Pulse/Sweep,A4-MO 调制输出,以及按物理路径拆分的 A5。每项 evidence 绑定 capability、型号、固件、选件、端口、端接和最终 RF OFF 状态。 + +DSG830 的 A1 已使 production descriptor 声明 `rf_source.snapshot`,A2 已使其声明 `rf_source.output`,A3 已使其声明 `rf_source.cw_configure`,A4 已分别使其声明 `rf_source.modulation_configure`、`rf_source.modulation_disable`、`rf_source.pulse_configure` 和受限的 `rf_source.sweep_configure`。调制证据覆盖 AM/FM/PM 的 RF-OFF 单模式配置、严格读回与关闭恢复;PM 的 production profile 固定为 `1.25 rad`。A4-MO 已在 RF `1 MHz`/`-50 dBm`、50 Ω CH2 路径上完成 AM `50 %`/`1 kHz`、FM `20 kHz`/`1 kHz`、PM `1.25 rad`/`1 kHz` 的固定 profile 验收,因此将这三个精确 profile 的 `rf_source.modulated_output_enable` 提升到 production。FM/PM 的 WaveBench 波形摘要与 FFT 只记录质量告警,不外推为偏差计量。受限 A5 已将「PULSE IN/OUT」output 方向的 `rf_source.pulse_output` 提升到 production;剩余 A5 trigger/fire/sync 路径仍需独立实机证据。未取得对应 evidence 时不得声明或提升其它 production descriptor capability。 + +## 首个适配器:RIGOL DSG830 + +DSG830 只为通用合同提供第一组设备映射,不改变核心类型或安全规则。DSG800 Programming Guide 中可用于首轮离线实现的事实如下: + +| 通用功能 | DSG830 SCPI | DSG830 静态范围或状态 | +| --- | --- | --- | +| 身份 | `*IDN?` | IDN 包含 `DSG830`。 | +| CW 频率 | `:FREQ ` / `:FREQ?` | `9 kHz–3 GHz`。 | +| CW dBm 功率 | `:LEV ` / `:LEV?` | `-110 dBm–20 dBm`;query 默认 dBm。 | +| RF 输出 | `:OUTP ON|OFF` / `:OUTP?` | 单个 `rf_out` 端口。 | +| 调制状态 | `:MOD:STAT?` | `0`/`1`;M3 还读取 AM/FM/PM enable 状态与目标内部 Sine 参数。 | +| Pulse 状态 | `:PULM:STAT?` | `0`/`1`;配置和触发进入 M4。 | +| 后面板 Pulse Output | `:PULM:OUT:STAT?` / `:PULM:OUT:STAT ON|OFF` | 仅 `pulse_in_out` 的 output 方向;`0 V`/`3.3 V`、约 `600 Ω`,internal/single/normal/`1 ms`/`100 μs`。 | +| Sweep 状态 | `:SWE:STAT?` | `OFF`、`FREQ`、`LEV` 或组合;frequency-only Step Sweep 子集进入 M4。 | +| 保护状态 | `:STAT:QUES:POW:COND?` | 位 0 ALC unlocked、位 1 output power protection、位 2 heater detector;未知高位按阻断处理。 | + +手册未给出可安全采用的 error queue 查询命令。因此 DSG830 不声明 `rf_source.errors`,所有写后判断依赖独立状态回读和 condition register。 + +DSG830 的 production `descriptor()` 在 A1/A2/A3/A4/A4-MO/A5 Pulse Output 完成后声明 `rf_source.idn`、`rf_source.snapshot`、`rf_source.cw_configure`、`rf_source.output`、`rf_source.modulation_configure`、`rf_source.modulation_disable`、`rf_source.modulated_output_enable`、`rf_source.pulse_configure`、`rf_source.pulse_output` 与 `rf_source.sweep_configure`。`get_rf_snapshot()` 可通过只读入口观察状态,`get_rf_trigger_snapshot()` 仅以固定 query 读取逻辑 trigger configuration,且不进入 production descriptor;`configure_cw()` 只可在目标输出 OFF 的完整 preflight 后写入一个频率或功率字段,`set_rf_output()` 只可在完整 safety preflight 后切换 `rf_out`。M3 覆盖 RF-OFF 的内部正弦 AM/FM/PM 与按模式关闭;PM production profile 固定为 `1.25 rad`。M3-MO 复用 `get_rf_modulation_snapshot()` 和 `set_rf_output()`,但 production 只接受 AM `50 %`/`1 kHz`、FM `20 kHz`/`1 kHz`、PM `1.25 rad`/`1 kHz`、最大 `-50 dBm`;它未改变普通 `rf_source.output` 的调制关闭前置条件。FM/PM 的 scope 分析只记录波形质量,不能独立计量频偏或相偏。M4 Pulse 固定 internal/single、period/width/polarity,并以 `:PULM:STAT OFF` 收尾;两种极性已通过受控实机配置、读回与最终 RF-OFF 验证。A5 Pulse Output 只通过 `get_rf_pulse_output_snapshot()`/`set_rf_pulse_output()` 查询和切换 `pulse_in_out` 的 output 状态,不配置 Pulse profile、RF 输出、trigger 或接收设备。M4 Step Sweep 固定为仅配置的 `STEP`/`FWD`/`RAMP`/`LIN` 映射与严格 readback,并以 `:SWE:STAT OFF` 收尾;它不写 `:SWE:EXEC`、trigger、Level Sweep 或 RF 输出。历史 A4/A4-MO/A5 Pulse Output harness 在对应 descriptor 提升后拒绝重跑;普通 M3/M3-MO/Pulse/Pulse Output/Step Sweep 使用必须经 production descriptor、`read_write` access 与完整 preflight。既有证据不开放 Sweep fire、Pulse input、`TRIGGER IN` 或同步控制。历史 `0.1.0` 的 `source.idn` 种子已迁移为当前 `0.2.0` 的 RF 包。 + +## 测试与发布边界 + +- 所有公共 model、profile 与 request 在实现前先有边界、非有限数、布尔值、未知枚举和不匹配端口的失败测试。 +- 所有 Service 写路径先有零写拒绝与写后回读失败测试,再实现最小 transaction。 +- `StatefulRfTransport` 与 guarded fake transport 模拟严格 query、单次写入、忽略写入、写前异常、写后 query 异常、protection 变化、recovery 预算与 session health。 +- driver 测试精确断言 SCPI 命令、值格式、query 顺序、写入次数与 postcondition;不以 fake 的调用次数代替外部可见状态断言。 +- 默认测试不扫描端口、不读取本地实验室配置、不连接仪器、不执行真实 SCPI。 +- 核心验证包括聚焦 pytest、完整 pytest、ruff 和 `git diff --check`;插件额外包括包级 pytest、ruff、wheel/package check 与安装 dry-run。 +- production descriptor 的 capability 提升只接受对应的 A1–A5 证据,不接受「代码已实现」或 fake 测试替代。 + +## 实施记录与边界 + +- 核心开发分支:`Scaxlibur/feat/rf-source-core`。 +- DSG830 插件开发分支:`Scaxlibur/feat/rf-source-dsg830`。 +- Core M0 提交:`8a746fb`、`6fa9c48`、`f3ae6d7`、`55474be`、`e8ff1be`、`cf53e14`;DSG830 M0 提交:`0c5c2bf`。 +- M0–M3 离线验证已完成;DSG830 A1 snapshot、A2 受控输出、A3 CW 环回与 A4 调制证据均已通过,production 已提升 snapshot、`rf_source.output`、`rf_source.cw_configure`、`rf_source.modulation_configure` 和 `rf_source.modulation_disable`。Core `5fc0e19` 保留调制 postcondition 的类型化证据,插件 `a7b3b93` 以独立 session 完成失败后的受限恢复,插件 `ebea610` 将已验证的 M3 profile 提升到 production;PM 仅为 `1.25 rad`。Core `ee790dc`/`8210299` 与插件 `e22911f`/`b3fa6c0` 增加 M4 Pulse 离线合同、控制入口和本地证据工具;两种极性通过受控实机验证后,插件 `40564a9` 将 `rf_source.pulse_configure` 加入 production descriptor。Core `d3481d8`/`8ec1733`/`e04ed60` 与插件 `851bdf5` 增加 frequency-only Step Sweep 的离线合同、固定映射、CLI、run 与 artifact;插件 `15c61e1` 增加隔离的零写诊断/受控配置 evidence harness。A4 Step Sweep 的诊断与受控配置实机序列均已通过,后者在独立 profile readback 与最终 OFF 复核后,插件 `9a6e30a` 将 `rf_source.sweep_configure` 加入 production descriptor。Core `f9c46e2`/`6d8d0af`/`3ee2697`/`188292d` 与插件 `5394f15`/`3fb3778`/`65eb611` 新增 M3-MO special capability、严格 transaction、公开调制关闭入口、历史 AM 证据 harness 与 fake 回归。A4-MO 先在 AM `50 %`/`1 kHz`、RF `1 MHz`/`-50 dBm`、CH2 50 Ω 路径上通过;随后独立 FM `20 kHz`/`1 kHz` 与 PM `1.25 rad`/`1 kHz` 受控循环也完成同样的严格源端 readback、CH2 信号存在、最终 RF OFF、调制关闭和健康关闭复核。FM/PM 使用 WaveBench 波形摘要和 FFT 记录质量告警,但不把它们外推为偏差计量;production 已提升三个精确 profile,最大 `-50 dBm`。Core `877645d` 与插件 `1b08593`/`e32e335`/`e9c3502` 增加并完成 A5 Pulse Output 合同、固定 `:PULM:OUT:STAT` 映射、隔离实机验收和 production 提升。该提升不开放 Pulse input、`TRIGGER IN`、Sweep execute/fire、sync 或 trigger capability。 +- `tool-of-rei/` 是本地恢复上下文,已忽略;面向项目的设计文档保存在 `docs/project/design/`。 diff --git "a/docs/project/design/WaveBench_\345\244\232\344\273\252\345\231\250\345\215\217\345\220\214\346\265\201\347\250\213\350\256\276\350\256\241.md" "b/docs/project/design/WaveBench_\345\244\232\344\273\252\345\231\250\345\215\217\345\220\214\346\265\201\347\250\213\350\256\276\350\256\241.md" index d30a01c..bdc0349 100644 --- "a/docs/project/design/WaveBench_\345\244\232\344\273\252\345\231\250\345\215\217\345\220\214\346\265\201\347\250\213\350\256\276\350\256\241.md" +++ "b/docs/project/design/WaveBench_\345\244\232\344\273\252\345\231\250\345\215\217\345\220\214\346\265\201\347\250\213\350\256\276\350\256\241.md" @@ -63,7 +63,7 @@ wavebench run verify --config wavebench.toml --plan plans/dp800_scope_probe_volt ## 资源租约 -`run plan` 在打开任何仪器 session 前,会为计划涉及的 `scope`、`source`、`power` 和 +`run plan` 在打开任何仪器 session 前,会为计划涉及的 `scope`、`source`、`rf_source`、`power` 和 `dmm` 资源按规范化资源键排序,并一次性取得本机独占租约。任一资源已被 其他进程占用时,后续 transport 不会打开,已取得的前置租约会全部释放,并返回稳定错误码 `resource_busy`。 @@ -228,13 +228,16 @@ source.set_func source.set_vpp source.output source.set_duty +rf_source.status +rf_source.output_enable +rf_source.output_disable power.status power.set power.output sleep ``` -当前执行器已经实装并实机验证的动作是: +当前执行器已实装的动作如下;对具体仪器执行仍取决于 descriptor capability、access 和各自的安全条件: ```text power.status @@ -247,11 +250,20 @@ source.set_func source.set_vpp source.set_duty source.output +rf_source.status +rf_source.set_frequency +rf_source.set_power_dbm +rf_source.output_enable +rf_source.output_disable +rf_source.pulse_configure +rf_source.sweep_configure sleep ``` `source.set_duty` 对 DG4202 使用 `:SOUR:FUNC:SQU:DCYC `,参数单位是百分比,范围限制为 `0 < duty_percent < 100`。 +RF 信号源不属于本节的 `source.*` step,也不能借用其中的 channel、Vpp、restore 或 safety gate 语义。当前 Core 已在 `run schema` 中提供 `rf_source.status`、CW 频率/功率步骤、`rf_source.output_enable`/`rf_source.output_disable`、M3 的 `rf_source.modulation_configure`、`rf_source.pulse_configure` 和 `rf_source.sweep_configure`;它们使用独立的类型化 RF artifact。DSG830 已完成 A1/A2/A3、A4 调制/Pulse/Step Sweep,production descriptor 声明 `rf_source.snapshot`、`rf_source.cw_configure`、受 safety 限制的 `rf_source.output`、RF-OFF `rf_source.modulation_configure`、`rf_source.pulse_configure` 和 `rf_source.sweep_configure`,因此 status 可在 `read_only` session 中读取快照,生产写步骤仅在 `read_write`、相应 capability 和 fresh OFF-only preflight 同时成立时执行。PM production profile 仅为 `1.25 rad`;Pulse 与 Step Sweep 只配置并保持 disabled;调制开启时的 RF 输出、trigger、Sweep execute/fire 与 Level Sweep 仍未开放。详见[RF 信号源使用指南](../guides/WaveBench_RF信号源使用指南.md)、[RF 信号源领域设计](WaveBench_RF信号源设计.md)和[RF 信号源开发里程碑](WaveBench_RF信号源开发里程碑.md)。 + `scope.capture` 可以额外声明: ```toml @@ -309,6 +321,9 @@ Supported `[[steps]]` kinds: | `source.set_freq` | `frequency_hz` | `channel` | | `source.set_duty` | `duty_percent` | `channel` | | `source.output` | `state` | `channel` | +| `rf_source.status` | - | `on_failure` | +| `rf_source.output_enable` | `port_id` | `on_failure` | +| `rf_source.output_disable` | `port_id` | `on_failure` | | `scope.auto` | - | `on_failure`, `safety_gate` | | `scope.capture` | - | `channel`, `label`, `points`, `time_range_s`, `window_frequency_hz`, `target_cycles`, `expect_frequency_hz`, `frequency_tolerance`, `save_csv`, `save_npy`, `quality_gate`, `auto_recover`, `on_failure`, `safety_gate`, `[steps.expect]` | | `sleep` | `duration_s` | `on_failure`, `safety_gate` | diff --git "a/docs/project/design/WaveBench_\350\256\276\345\244\207\346\212\275\350\261\241\345\261\202.md" "b/docs/project/design/WaveBench_\350\256\276\345\244\207\346\212\275\350\261\241\345\261\202.md" index c403f51..53f0a8a 100644 --- "a/docs/project/design/WaveBench_\350\256\276\345\244\207\346\212\275\350\261\241\345\261\202.md" +++ "b/docs/project/design/WaveBench_\350\256\276\345\244\207\346\212\275\350\261\241\345\261\202.md" @@ -27,7 +27,7 @@ class Instrument: ### Metadata 与可执行插件分层 -`wavebench.drivers` V1 继续提供只读 metadata;`wavebench.instruments` V2 提供可信的可执行 driver factory。Service 只依赖 `ScopeDriver` / `SourceDriver` / `PowerDriver` / `DmmDriver` contracts,并通过统一 registry/factory 创建内置或外部 driver。 +`wavebench.drivers` V1 继续提供只读 metadata;`wavebench.instruments` V2 提供可信的可执行 driver factory。当前 Service 依赖 `ScopeDriver` / `SourceDriver` / `RfSourceDriver` / `PowerDriver` / `DmmDriver` contracts,并通过统一 registry/factory 创建内置或外部 driver。 插件只负责设备差异。核心继续掌握 resource、transport factory、安全限制、Service、run plan 和 artifact。未选中的第三方插件默认不导入;`plugin ... --load` 才会显式加载并诊断全部可执行 descriptor。 @@ -248,6 +248,14 @@ Service 层可以按以下顺序组合这些动作: 设置信号 → 等待稳定 → 采集波形 → 保存数据 → 计算指标 ``` +## RF 信号源:当前 M0–M4 与后续阶段 + +上述 `SignalGenerator` 示例只描述普通函数/任意波形发生器。RF 信号源以频率、dBm 功率、RF 输出和稳定 `port_id` 为主,不能把它映射为普通 `SourceDriver` 的 Vpp、offset、数字 channel 或波形接口。 + +当前 Core 已实现独立 model、`RfSourceDriver` Protocol、只读 Service、OFF-only CW transaction、端口级输出 transaction、内部正弦 AM/FM/PM transaction、internal/single Pulse transaction、保持 Sweep disabled 的 Step Sweep transaction、`rf-source idn`/`rf-source status`/`rf-source set-frequency`/`rf-source set-power`/`rf-source output`/`rf-source modulation configure-*`/`rf-source pulse configure`/`rf-source sweep configure` 和对应的 run step。所有入口仍受 capability、access、资源租约与 session health 约束;descriptor 未声明所需 capability 时,会在 transport I/O 前被拒绝。 + +DSG830 已由 A1/A2/A3/A4 调制/Pulse/Step Sweep 将 snapshot、OFF-only `rf_source.cw_configure`、受 safety 限制的 `rf_source.output`、RF-OFF `rf_source.modulation_configure`、`rf_source.pulse_configure` 和 `rf_source.sweep_configure` 提升到 production。PM production profile 固定为 `1.25 rad`;Pulse 与 Step Sweep 只允许已声明的配置 profile,且配置后保持 disabled。调制开启时的 RF 输出、trigger、Sweep execute/fire 与 Level Sweep 仍待独立证据。设计与里程碑分别见[RF 信号源领域设计](WaveBench_RF信号源设计.md)和[RF 信号源开发里程碑](WaveBench_RF信号源开发里程碑.md)。 + ## 早期目录示意 以下目录反映早期设计,不作为当前源码树的路径清单。当前入口以代码和插件开发指南为准。 diff --git "a/docs/project/design/WaveBench_\351\241\271\347\233\256\350\276\271\347\225\214.md" "b/docs/project/design/WaveBench_\351\241\271\347\233\256\350\276\271\347\225\214.md" index 6424574..89a5a60 100644 --- "a/docs/project/design/WaveBench_\351\241\271\347\233\256\350\276\271\347\225\214.md" +++ "b/docs/project/design/WaveBench_\351\241\271\347\233\256\350\276\271\347\225\214.md" @@ -19,11 +19,18 @@ WaveBench 优先解决以下问题: | 信号源 | DG4000 / DG4202 的状态、基本波形、频率、幅度、输出和任意波上传 | 不提供通用波形编辑器或跨厂商抽象 | | 电源 | DP800 的状态、保护、设定值和显式输出控制 | `power set` 与 `power output` 是独立动作 | | 万用表 | DM3000 / DM3058 的常用读数和部分连接方式 | 型号、接口和测量函数以当前 driver 为准 | -| run plan | source、power、scope、dmm、sleep 和频响步骤;包含检查、预检、恢复和质量判断 | 不保证多仪器同步采样 | +| RF 信号源 | M0–M4 与受限 A5 插件领域:身份查询、类型化 snapshot、OFF-only CW、端口级输出、内部正弦调制、Pulse 与 Step Sweep 配置,以及一条后面板 Pulse 输出路径 | 不复用普通 source;DSG830 已完成 A1/A2/A3/A4 与 A5 Pulse Output,声明 `rf_source.idn`、`rf_source.snapshot`、`rf_source.cw_configure`、受 safety 限制的 `rf_source.output`、RF-OFF `rf_source.modulation_configure`、`rf_source.pulse_configure`、`rf_source.sweep_configure` 与受限的 `rf_source.pulse_output`;PM production profile 仅为 `1.25 rad`,普通调制输出只限固定 profile | +| run plan | source、rf_source、power、scope、dmm、sleep 和频响步骤;包含检查、预检、恢复和质量判断 | 不保证多仪器同步采样;RF 输出仍受 capability、access 和端口 safety 限制 | | 报告与产物 | CSV、NPY、JSON metadata、命令记录、静态 HTML 报告和 report index | 报告读取已有产物,不代替实时采集 | | TUI | 电源、万用表和信号源的实验性终端面板 | 不负责 run plan 编辑、完整波形查看或插件管理 | | 插件 | V2 Python 插件、V1 metadata 和声明式 SCPI 检查 | Python 插件是可信代码,不是安全沙箱 | +## RF 信号源的分阶段边界 + +RF 信号源不是当前 `source` 的别名。`rf_source` 使用 `port_id`、dBm、RF 输出、端接和 protection 状态,不复用 Vpp、offset、数字 channel 或波形模型。 + +当前 Core 已提供 M0–M4 合同,并增加受限的 A5 Pulse Output 合同。DSG830 已凭 A1 证据开放 production snapshot、凭 A2 证据开放具有完整端口 safety 配置的 `rf_source.output`、凭 A3 证据开放 OFF-only `rf_source.cw_configure`,并凭 A4 调制/Pulse/Step Sweep 证据开放 RF-OFF `rf_source.modulation_configure`、保持 disabled 的 `rf_source.pulse_configure` 与 `rf_source.sweep_configure`。A5 仅将「PULSE IN/OUT」的 output 方向、固定 internal/single/normal/`1 ms`/`100 μs` profile 提升为 `rf_source.pulse_output`;该操作不启用 RF 输出。M3 的 PM production profile 固定为 `1.25 rad`,而 M2 的 RF ON 合同仍要求调制 disabled;Pulse input、`TRIGGER IN`、Sweep execute/fire、同步与 Level Sweep 继续等待各自的独立证据。具体合同见[RF 信号源领域设计](WaveBench_RF信号源设计.md),日常操作见[RF 信号源使用指南](../guides/WaveBench_RF信号源使用指南.md),实现顺序见[RF 信号源开发里程碑](WaveBench_RF信号源开发里程碑.md)。 + ## 推荐工作顺序 1. 使用 `run template` 生成 plan。 diff --git "a/docs/project/guides/WaveBench_CLI\345\275\242\346\200\201.md" "b/docs/project/guides/WaveBench_CLI\345\275\242\346\200\201.md" index 3a0d2c5..38bfac6 100644 --- "a/docs/project/guides/WaveBench_CLI\345\275\242\346\200\201.md" +++ "b/docs/project/guides/WaveBench_CLI\345\275\242\346\200\201.md" @@ -13,7 +13,7 @@ wavebench 当前一级命令按设备或功能划分: ```text -scope source power dmm sweep run +scope source rf-source power dmm sweep run capture mcp tui net doctor plugin capability lock ``` @@ -31,8 +31,8 @@ wavebench run --help | 类别 | 示例 | 行为 | |---|---|---| | 离线 | `run schema`、`run template`、`run check`、`run intent`、`run report`、`run compare`、`run resume`、`capability explain`、`lock status`、`capture inspect`、`tui --fake` | 不连接仪器;报告、比较、检查、能力解释、锁查询和意图生成只读取本地文件 | -| 连接读取 | `doctor`、`net`、`scope idn`、`scope status`、`source snapshot-v2`、`run verify` | 查询资源、身份或状态,不应修改实验设置 | -| 显式写入或触发 | `scope auto`、`scope fetch/capture`、source / power setter、`run plan` | 可能改变设置、触发采集或切换输出 | +| 连接读取 | `doctor`、`net`、`scope idn`、`scope status`、`source snapshot-v2`、`rf-source idn`/`status`、`run verify` | 查询资源、身份或状态,不应修改实验设置 | +| 显式写入或触发 | `scope auto`、`scope fetch/capture`、source / power setter、已获 capability 的 `rf-source` setter、`run plan` | 可能改变设置、触发采集或切换输出 | 执行硬件写入前,应先确认接线、输入阻抗、输出状态和安全限制。CLI 不会自动发送 `*RST`,也不会因为设置电压或幅度而自动打开输出。 @@ -79,6 +79,32 @@ wavebench --json source snapshot-v2 --config wavebench.toml 关系,不接受单通道或 raw query 参数。普通模式输出缩进 JSON;`--json` 使用 `wavebench.cli.result.v1` envelope。命令不授权任何 Source V2 写入。 +安装匹配的 RF 插件后,RF 信号源使用独立命令域: + +```bash +wavebench rf-source idn --config wavebench.toml +wavebench rf-source status --config wavebench.toml +wavebench rf-source set-frequency --port PORT_ID HZ --config wavebench.toml +wavebench rf-source set-power --port PORT_ID DBM --config wavebench.toml +wavebench rf-source output --port PORT_ID on|off --config wavebench.toml +wavebench rf-source modulation configure-am --port PORT_ID --depth-percent PERCENT --internal-frequency-hz HZ --config wavebench.toml +wavebench rf-source modulation configure-fm --port PORT_ID --frequency-deviation-hz HZ --internal-frequency-hz HZ --config wavebench.toml +wavebench rf-source modulation configure-pm --port PORT_ID --phase-deviation-rad RAD --internal-frequency-hz HZ --config wavebench.toml +wavebench rf-source pulse configure --port PORT_ID --period-s SECONDS --width-s SECONDS --polarity normal|inverted --config wavebench.toml +wavebench rf-source sweep configure --port PORT_ID --start-frequency-hz START --stop-frequency-hz STOP --points COUNT --dwell-s SECONDS --config wavebench.toml +wavebench capability explain rf_source.snapshot --driver rigol.dsg830 --kind rf_source --access read_only +wavebench capability explain rf_source.output_enable --driver rigol.dsg830 --kind rf_source --access read_write +wavebench capability explain rf_source.modulation_configure --driver rigol.dsg830 --kind rf_source --access read_write +``` + +`rf-source idn` 要求 descriptor 声明 `rf_source.idn`。`rf-source status` 要求 +`rf_source.snapshot`,并在缺少 capability 时于 transport I/O 前拒绝。DSG830 已完成 A1/A2/A3 与 A4 调制/Pulse/Step Sweep,并在 +production descriptor 中声明只读 capability、`rf_source.cw_configure`、`rf_source.output`、`rf_source.modulation_configure`、`rf_source.pulse_configure` 与 `rf_source.sweep_configure`;其他插件仍可能被该门禁拒绝。它不表示可以改用 raw +SCPI。M1 的 `set-frequency` 与 `set-power` 还要求 `rf_source.cw_configure`、`read_write` 访问、已声明的 +CW profile 和完整的 OFF-only preflight。M2 的 `output` 要求 `rf_source.output`、 +`read_write` 访问、可读 output profile 和端口级 safety preflight;ON 还要求确认端接、频率、功率、调制、Pulse、Sweep 和 protection。 +DSG830 对 OFF-only CW、RF-OFF 内部正弦调制、端口级 ON/OFF、internal/single Pulse 与固定 profile 的 Step Sweep 配置开放 production 写入;缺少 `read_write`、对应 profile 或 fresh preflight 时仍在写入前拒绝。调制 profile 的 PM 固定为 `1.25 rad`,且当前 RF ON 合同要求调制 disabled。Pulse 与 Step Sweep 配置完成后分别保持 Pulse/Sweep disabled;它们不提供 `modulation_disable`、trigger、execute、arm、fire、Level Sweep 或 list。 + 已声明对应写 capability 的插件还可以配置跨通道关系: ```bash diff --git "a/docs/project/guides/WaveBench_RF\344\277\241\345\217\267\346\272\220\344\275\277\347\224\250\346\214\207\345\215\227.md" "b/docs/project/guides/WaveBench_RF\344\277\241\345\217\267\346\272\220\344\275\277\347\224\250\346\214\207\345\215\227.md" new file mode 100644 index 0000000..90216f1 --- /dev/null +++ "b/docs/project/guides/WaveBench_RF\344\277\241\345\217\267\346\272\220\344\275\277\347\224\250\346\214\207\345\215\227.md" @@ -0,0 +1,306 @@ +# WaveBench RF 信号源使用指南 + +[RF 信号源领域设计](../design/WaveBench_RF信号源设计.md) · [RF 信号源开发里程碑](../design/WaveBench_RF信号源开发里程碑.md) · [run plan 使用指南](WaveBench_run_plan_使用指南.md) + +本页说明 RF 信号源的当前使用入口、配置边界和已知限制。它面向实际使用与 plan 编写;领域模型、SCPI 映射和实机验收记录见设计文档与型号插件文档。 + +## 先区分两类信号源 + +`source` 面向函数/任意波形发生器,使用数字 channel、Vpp、offset 和波形模型。`rf_source` 面向以频率、dBm 功率等级、RF 输出和调制状态为主要对象的射频信号源。 + +二者不能互换: + +- 不把 RF 的 dBm 换算为 `source` 的 Vpp。 +- 不把 `rf_out` 当成普通 source channel。 +- 不把 scope 的输入阻抗、连接器标签或型号名称当成 RF 端口的实际端接声明。 +- 不通过普通 `source.*` 操作、原始 SCPI 或临时 descriptor 绕过 `rf_source` 的 capability 与 safety 门。 + +## 当前可用范围 + +| 操作 | Core 状态 | DSG830 production descriptor | 关键边界 | +| --- | --- | --- | --- | +| 身份与状态 | 已开放 | 已开放 | `read_only` 可执行。 | +| CW 频率/dBm 功率 | 已开放 | A3 后已开放 | 仅目标 RF 输出明确 OFF 时的单字段写入。 | +| RF ON/OFF | 已开放 | A2 后已开放 | ON 需要完整端口 safety 配置与 fresh preflight。 | +| 内部正弦 AM/FM/PM | 已开放 | A4 后已开放 | 只在 RF OFF 下配置。AM 为 `0–100 %`,FM 为 `0.1 Hz–1 MHz`,PM 的 production profile 精确为 `1.25 rad`;三种模式的内部频率均为 `10 Hz–100 kHz`。 | +| 按模式关闭调制 | 已开放 | A4 后已开放 | RF OFF、Pulse/Sweep disabled 且唯一目标模式活动时才写入;已一致关闭时零写返回。 | +| 受限调制输出 | A4-MO 后已开放 | A4-MO 后已开放 | AM `50 %`/内部 `1 kHz`、FM `20 kHz`/内部 `1 kHz`、PM `1.25 rad`/内部 `1 kHz`,最大 `-50 dBm`。它要求 profile 已激活且精确匹配,不配置或关闭调制;普通 `rf_source.output on` 仍要求调制关闭。 | +| Pulse | M4 离线合同与受控 evidence 已完成 | A4 Pulse 后已开放 | 当前只限 internal/single 配置并强制保持 Pulse OFF;需要 `read_write` 与 fresh OFF-only preflight。 | +| frequency-only Step Sweep | M4 合同、CLI、run step、artifact 与 A4 证据已完成 | A4 Step Sweep 后已开放 | 仅固定 `STEP`/`FWD`/`RAMP`/`LIN`,配置后 Sweep 仍保持关闭;需要 `read_write`、匹配 profile 与 fresh OFF-only preflight。 | +| 后面板 Pulse Output | A5 合同、CLI、run step、artifact 与受控 evidence 已完成 | A5 Pulse Output 后已开放 | 仅 `rf_out` 的 `pulse_in_out` output 方向;固定 `0 V`/`3.3 V`、约 `600 Ω`、internal/single/normal/`1 ms`/`100 μs`。它不启用 RF 输出,也不配置接收设备。 | +| 逻辑 trigger configuration 读取 | A5-0 离线合同完成 | 未开放 | `rf-source trigger status`/`rf_source.trigger_status` 需要独立 capability 和 `TRIGGER / READ` profile;当前 DSG830 production descriptor 会拒绝该请求。它不读取或配置物理 trigger/sync 接口。 | +| trigger、arm/fire、Level Sweep | 未完成 | 未开放 | 不应尝试调用或绕过。 | + +生产 descriptor 是否声明 capability 是实际边界。Core 中存在 CLI、run step 或 driver 方法,不等于当前仪器已经获准执行该操作。 + +## 配置与端接声明 + +RF 使用独立的 `[rf_source]` 段。日常身份查询和状态读取保持 `read_only`: + +```toml +[rf_source] +driver = "rigol.dsg830" +resource = "" +access = "read_only" +``` + +CW 写入或 RF 输出控制需要显式改为 `read_write`,并为每个使用的端口提供 safety 配置: + +```toml +[rf_source] +driver = "rigol.dsg830" +resource = "" +access = "read_write" + +[[rf_source.safety.ports]] +port_id = "rf_out" +minimum_frequency_hz = 9000 +maximum_frequency_hz = 3000000000 +maximum_power_dbm = -40 +actual_termination_ohm = 50 +``` + +示例中的 `50` 只适用于已人工确认整个 RF 路径确实以 50 Ω 端接的场景。若 RF 直接接入示波器,示波器 CH2 已设为 50 Ω 只是必要信息之一;线缆、转接件、分配器和实际连接路径也必须一起核对。无法确认时,不应填写猜测值。 + +网络发现只能帮助定位候选设备。先使用有界扫描,例如: + +```bash +wavebench net discover \ + --subnet 192.0.2.0/24 \ + --ports 5025,5555,111 \ + --timeout-ms 500 \ + --workers 16 \ + --max-hosts 256 \ + --no-idn \ + --no-visa +``` + +候选资源仍须通过只读身份查询、型号核对和隔离配置复核;发现结果不自动写回配置,也不构成写入授权。VXI-11 设备可能只表现为候选端口,未响应 socket `*IDN?` 不等于设备不可用。 + +## 当前生产操作 + +先使用只读入口确认状态: + +```bash +wavebench rf-source idn --config wavebench.toml +wavebench rf-source status --config wavebench.toml +``` + +在 production descriptor 已声明对应 capability、配置为 `read_write` 且所有 preflight 条件满足时,DSG830 可以执行受限的 CW、内部正弦调制、RF 输出、Pulse、Pulse Output 和 Step Sweep 操作: + +```bash +wavebench rf-source set-frequency --config wavebench.toml --port rf_out 1000000 +wavebench rf-source set-power --config wavebench.toml --port rf_out -40 +wavebench rf-source modulation configure-am --config wavebench.toml --port rf_out --depth-percent 25 --internal-frequency-hz 1000 +wavebench rf-source modulation configure-fm --config wavebench.toml --port rf_out --frequency-deviation-hz 10000 --internal-frequency-hz 1000 +wavebench rf-source modulation configure-pm --config wavebench.toml --port rf_out --phase-deviation-rad 1.25 --internal-frequency-hz 1000 +wavebench rf-source modulation disable --config wavebench.toml --port rf_out --modulation-kind am +wavebench rf-source output --config wavebench.toml --port rf_out on +wavebench rf-source output --config wavebench.toml --port rf_out off +wavebench rf-source pulse configure --config wavebench.toml --port rf_out --period-s 0.001 --width-s 0.0001 --polarity normal +wavebench rf-source pulse-output --config wavebench.toml --port rf_out --interface pulse_in_out on +wavebench rf-source pulse-output --config wavebench.toml --port rf_out --interface pulse_in_out off +wavebench rf-source sweep configure --config wavebench.toml --port rf_out --start-frequency-hz 1000000 --stop-frequency-hz 2000000 --points 11 --dwell-s 0.02 +``` + +`output on` 不是普通 setter。它会在写入前重新读取 RF 状态,确认频率、功率、实际端接、调制、Pulse、Sweep 和 protection 均满足安全合同。任何关键状态缺失或不一致都会在 ON 前拒绝;不应依赖先前一次成功查询。 + +调制输出必须使用单独的受限序列,不能将已激活调制交给普通 `output on`: + +```bash +wavebench rf-source modulation configure-am --config wavebench.toml --port rf_out --depth-percent 50 --internal-frequency-hz 1000 +wavebench rf-source modulation enable-output-am --config wavebench.toml --port rf_out --depth-percent 50 --internal-frequency-hz 1000 +wavebench rf-source output --config wavebench.toml --port rf_out off +wavebench rf-source modulation disable --config wavebench.toml --port rf_out --modulation-kind am +``` + +DSG830 目前只声明三个精确 profile:AM `50 %`/`1 kHz`、FM `20 kHz`/`1 kHz`、PM `1.25 rad`/`1 kHz`,最大功率均为 `-50 dBm`。例如 FM/PM 必须先在 RF OFF 下配置,再使用对应特殊入口: + +```bash +wavebench rf-source modulation configure-fm --config wavebench.toml --port rf_out --frequency-deviation-hz 20000 --internal-frequency-hz 1000 +wavebench rf-source modulation enable-output-fm --config wavebench.toml --port rf_out --frequency-deviation-hz 20000 --internal-frequency-hz 1000 +wavebench rf-source output --config wavebench.toml --port rf_out off +wavebench rf-source modulation disable --config wavebench.toml --port rf_out --modulation-kind fm +``` + +PM 使用 `configure-pm`/`enable-output-pm` 与 `--phase-deviation-rad 1.25`,其余字段和清理顺序相同。成功的特殊 ON 不会自动 RF OFF 或关闭调制;结束时必须显式执行普通 `output off` 和按模式 `disable`。超出三个精确 profile 的请求会在仪器 I/O 前拒绝;不得用原始 SCPI、临时替换 production descriptor 或普通 `output on` 绕过该限制。 + +## A5:后面板 Pulse Output(受限生产操作) + +`rf_source.pulse_output` 只覆盖 DSG830 的 `pulse_in_out` output 方向。它不是 `rf_source.pulse_configure` 的别名,也不表示接口的 input 方向、`TRIGGER IN`、Pulse trigger、Sweep fire、sync/reference 或 RTM 控制能力。 + +固定 profile 为 `0 V`/`3.3 V`、约 `600 Ω`、internal/single/normal、period `1 ms`、width `100 μs`。`on` 只在 RF 输出、调制、Pulse、Sweep 都关闭、protection 为空且 readback 与该 profile 精确匹配时执行一次状态写入;成功不会启用 RF 输出或改变示波器。`off` 仍只操作该接口,但故意允许已知 profile 漂移,以保留关闭已启用输出的路径。写入或读回结果不明时,不重试,session 会变为不确定状态。 + +已验证的接线仅为 DSG830「PULSE IN/OUT」output → RTM2032「EXT TRIGGER INPUT」。日常 CLI 或 run plan 不会检测接收设备、配置 scope trigger、发起 scope single 或恢复接收设备状态;使用前必须人工核对实际接线与接收端电气额定值。该操作不适用于从相似连接器名称推导出的其它路径。 + +## A5-0:逻辑 trigger configuration 读取 + +Core 已提供下列只读入口: + +```text +wavebench rf-source trigger status --port PORT_ID +rf_source.trigger_status +``` + +它读取 Pulse trigger mode、external trigger edge、external gate polarity、Sweep mode、Sweep period trigger 与 Sweep point trigger 的封闭类型化状态。该操作是 `stateful_read`,不读取普通 RF snapshot,不发送 setter、RF 输出、`*TRG`、`:SWE:EXEC` 或后面板配置命令。 + +入口仍由 `rf_source.trigger_snapshot` capability 和目标端口的 `TRIGGER / READ` profile 门控。DSG830 当前 production descriptor 不声明该 capability,因此日常配置会在建立 session 前被拒绝。它只适用于非 production 的离线测试 descriptor 或源码 checkout 中的私有零写诊断;后者保持 `read_only`、禁用读重试,成功预算为 22 次 query 与零 write。`rf_out` 表示受这些配置影响的 RF 输出,不是物理 trigger/sync connector。外部 trigger、arm/fire、后面板接口和同步仍需明确物理接线、电气边界与 A5 证据。 + +## run plan 中的 RF 步骤 + +CW 和输出步骤使用独立的 `rf_source.*` kind,并把 evidence 写入 `run.json.rf_source_operations`: + +```toml +[[steps]] +kind = "rf_source.set_frequency" +port_id = "rf_out" +frequency_hz = 1000000 + +[[steps]] +kind = "rf_source.set_power_dbm" +port_id = "rf_out" +power_dbm = -40 + +[[steps]] +kind = "rf_source.output_enable" +port_id = "rf_out" + +[[steps]] +kind = "rf_source.output_disable" +port_id = "rf_out" + +[[steps]] +kind = "rf_source.pulse_configure" +port_id = "rf_out" +period_s = 0.001 +width_s = 0.0001 +polarity = "normal" + +[[steps]] +kind = "rf_source.pulse_output_enable" +port_id = "rf_out" +interface_id = "pulse_in_out" + +[[steps]] +kind = "rf_source.pulse_output_disable" +port_id = "rf_out" +interface_id = "pulse_in_out" +``` + +`rf_source.sweep_configure` 已进入 schema,并在 DSG830 的 A4 Step Sweep 证据复核后成为受限生产操作: + +```toml +[[steps]] +kind = "rf_source.sweep_configure" +port_id = "rf_out" +start_frequency_hz = 1000000 +stop_frequency_hz = 2000000 +points = 11 +dwell_s = 0.02 +``` + +它只配置 frequency-only Step Sweep,不会 arm、fire、触发、执行 `SWE:EXEC`、切换 RF 输出或配置 Level Sweep。DSG830 使用这段 plan 时仍须为 `read_write`,并通过 capability、profile 和 fresh OFF-only preflight;配置完成后必须读回 Sweep disabled。 + +`rf_source.modulation_disable` 也已进入 run schema;它只需要 `port_id` 与 `modulation_kind`: + +```toml +[[steps]] +kind = "rf_source.modulation_disable" +port_id = "rf_out" +modulation_kind = "am" +``` + +`rf_source.modulated_output_enable` 同样已进入 production schema。它使用与 `rf_source.modulation_configure` 相同的 `port_id`、`modulation_kind`、内部频率和对应数值字段;不能把配置步骤和输出步骤合并,也不能假定 run plan 会在成功后自动关闭 RF 或调制。DSG830 的 production descriptor 只接受 AM `50 %`/`1 kHz`、FM `20 kHz`/`1 kHz`、PM `1.25 rad`/`1 kHz` 三个精确 profile,最大功率均为 `-50 dBm`。 + +先运行 `wavebench run check`,再运行只读的 `wavebench run verify`。只有在接线、端接、输出状态和设备身份均已复核时,才执行 `wavebench run plan`。运行计划不会把普通 source 的 restore 或 Vpp safety 规则套用到 RF 端口。 + +## M3:内部正弦调制合同 + +Core 已提供三条 M3 配置命令、一个按模式关闭命令和对应 run step: + +```text +wavebench rf-source modulation configure-am ... +wavebench rf-source modulation configure-fm ... +wavebench rf-source modulation configure-pm ... +wavebench rf-source modulation disable --port PORT_ID --modulation-kind am|fm|pm +rf_source.modulation_configure +rf_source.modulation_disable +``` + +该合同只覆盖内部 Sine: + +| 模式 | CLI 值字段 | run plan 值字段 | 单位 | +| --- | --- | --- | --- | +| AM | `--depth-percent` | `depth_percent` | percent | +| FM | `--frequency-deviation-hz` | `frequency_deviation_hz` | Hz | +| PM | `--phase-deviation-rad` | `phase_deviation_rad` | rad | + +三种模式都必须提供 `--internal-frequency-hz` 或 `internal_frequency_hz`。run plan 使用 `modulation_kind = "am" | "fm" | "pm"`,而不是复用步骤自身的 `kind` 键;每个步骤只能出现与该模式匹配的一个值字段。 + +```toml +[[steps]] +kind = "rf_source.modulation_configure" +port_id = "rf_out" +modulation_kind = "am" +depth_percent = 25 +internal_frequency_hz = 1000 +``` + +M3 事务要求目标 RF 输出 OFF、AM/FM/PM 三种模式均处于 disabled、Pulse/Sweep disabled,且没有活动 protection condition。FM 与 PM 共享设备的当前选择位:在三种模式均关闭时,preflight 可以观察到另一种 FM/PM 选择,固定写入会明确选择目标类型;postcondition 必须确认已切换到目标类型。Core 用独立调制 snapshot 验证目标模式、源、波形、数值、内部频率、全局调制开关和 RF 输出仍然 OFF。写入结果不明或 postcondition 不匹配时不重试,session 会降为不确定状态。 + +DSG830 production descriptor 已声明 `rf_source.modulation_configure` 与 `rf_source.modulation_disable`。它只接受 descriptor 中的内部 Sine profile:AM `0–100 %`、FM `0.1 Hz–1 MHz`、PM 精确 `1.25 rad`,内部频率均为 `10 Hz–100 kHz`。关闭操作只在 RF OFF、Pulse/Sweep disabled、protection 清晰且请求模式是唯一活动模式时写入;已一致关闭时零写。超出 production profile 或状态不清晰的请求会在仪器 I/O 前拒绝;不能把 driver 的离线映射范围当作当前设备的写入授权。 + +DSG830 源码 checkout 的 A4 harness 是已完成的开发验收工具,不是日常命令。它一次配置一个内部 Sine 模式,完成读回后立即执行同一模式的受限关闭事务,并在最终 snapshot 中确认 RF 输出与调制均已关闭。显式 `--recover` 只用于恢复「已明确识别的单一活动模式」,输出为私有恢复记录;两条路径都不读取 CH2、不调用 RF output,也不能改变 production capability。显式 `--diagnose` 保留原始 `read_only` 配置,只读取初始/最终 RF snapshot 与指定模式 profile,并要求 transport audit 为零写;它只生成私有诊断记录。AM/FM/PM 的 RF-OFF 序列均已通过;PM 的 production profile 因严格读回证据而固定为 `1.25 rad`。 + +M2 的 RF ON 合同目前要求调制 disabled。M3 已提升的配置 capability 不能据此推导「已可在调制开启时输出 RF」。 + +### M3-MO:受限调制输出(A4-MO 已通过并提升) + +Core 已提供下列专用入口: + +```text +wavebench rf-source modulation enable-output-am ... +wavebench rf-source modulation enable-output-fm ... +wavebench rf-source modulation enable-output-pm ... +rf_source.modulated_output_enable +``` + +它们复用 M3 的 `modulation_kind`、数值字段和内部频率字段,但不会配置调制:调用前目标 profile 必须已经完整激活并与 request 精确一致。Core 还要求 RF 当前为 OFF、Pulse/Sweep disabled、protection 清晰、端口 safety 配置完整、实际端接与 dBm 参考阻抗一致,以及特殊 `RfModulatedOutputProfile` 明确允许这一 profile 与功率。成功路径只启用一次 RF;不会自动 RF OFF 或关闭调制。任何写入或 readback 不确定时不重试 ON,只可能执行一次受 guard 的 RF OFF recovery。 + +DSG830 production descriptor 声明 AM `50 %`/`1 kHz`、FM `20 kHz`/`1 kHz`、PM `1.25 rad`/`1 kHz`、最大 `-50 dBm` 的 `rf_source.modulated_output_enable`。A4-MO 的三条固定 profile 均使用 RF `1 MHz`/`-50 dBm` 完成受控循环:CH2 显式为 50 Ω,scope 观察当前 `DEF` 缓冲区的信号存在;FM/PM 额外保存 WaveBench 波形摘要和 FFT 质量记录。CH1 的低频输出独立于 RF 调制路径,不被读取或当作证据;scope 不用于推断 dBm、频偏、相偏、调制准确度或频谱合规性。历史 harness 在 capability 提升后拒绝重跑,日常操作只能使用 production descriptor、`read_write`、完整 safety 配置和显式清理步骤。 + +## M4:受控 Pulse 与 Step Sweep 配置合同 + +Core 已提供下列生产入口;DSG830 已声明相应 capability: + +```text +wavebench rf-source pulse configure --port PORT_ID --period-s SECONDS --width-s SECONDS --polarity normal|inverted +rf_source.pulse_configure +``` + +DSG830 production descriptor 已声明 `rf_source.pulse_configure`。普通 CLI 或 run plan 仍要求 `read_write`、目标 RF 输出/调制/Pulse/Sweep 关闭、无活动 protection,以及 descriptor 声明的 internal/single profile;任何条件不满足都会在写入前拒绝。 + +源码 checkout 的 `tools/a4_pulse_evidence.py` 是已完成的受控验收工具。它只接受 internal/single、period、width 和 polarity,并在每次配置后保持 Pulse OFF;初始、写后和最终状态都要求 RF 输出、调制、Pulse、Sweep 关闭且无活动 protection。它不调用 RF output、不使用后面板 Pulse I/O、不发送 trigger、不读取 CH1/CH2。`--diagnose` 保持 `read_only` 且零写,`--execute` 才允许一次受审计的配置写入。两种 polarity 的证据均通过并经复核,DSG830 已开放该 capability;historical harness 在提升后会拒绝重跑。 + +### frequency-only Step Sweep + +Core 已提供下列离线入口: + +```text +wavebench rf-source sweep configure --port PORT_ID --start-frequency-hz START --stop-frequency-hz STOP --points COUNT --dwell-s SECONDS +rf_source.sweep_configure +``` + +该子集固定为 `STEP`/`FWD`/`RAMP`/`LIN`,请求只包含起止频率、点数和驻留时间。写前与写后均要求 RF 输出、调制、Pulse、Sweep 关闭且无活动 protection;写后独立读回所有 profile 字段,并要求 Sweep 仍为 disabled。DSG830 driver 固定以 `:SWE:STAT OFF` 收尾,不会发送 `:SWE:EXEC`、任意 `:TRIG:*`、Level Sweep、RF 输出或后面板接口命令。 + +DSG830 源码 checkout 的 `tools/a4_step_sweep_evidence.py` 与无资源 setup 模板完成了专项实机验收:`--diagnose` 保持 `read_only`,固定 25 次查询、零写入;显式 `--execute` 才允许一次受审计的配置,成功路径固定为 41 次查询、9 条 Step Sweep 配置写入。两条路径都不读取 Scope、不操作 RF output、arm、fire 或 trigger。诊断与受控配置均通过,最终独立复核 RF 输出、调制、Pulse、Sweep 关闭且无活动 protection,因此 DSG830 production descriptor 声明 `rf_source.sweep_configure`。该生产范围仍只限配置且保持 Sweep disabled;CH2 的 50 Ω 端接不改变 execute、trigger 或 RF 输出的边界。 + +## 上机前检查清单 + +1. 使用网络发现和只读身份查询确认候选设备,再在隔离配置中复核资源与型号。 +2. 从 `read_only` 开始;只有本次确实需要、且 production descriptor 已声明的操作才使用 `read_write`。 +3. 核对 `rf_out` 的实际端接、频率范围和功率上限。示波器的 CH2 50 Ω 输入不能替代整条路径核对。 +4. 在任何写入前读取 RF snapshot,确认 RF 输出 OFF;完成后独立确认最终 RF OFF。 +5. 日常 M3/M4/A5 操作不使用 raw SCPI,不执行 reset、preset、错误队列、外部调制、未声明的后面板接口、Step Sweep execute、trigger 或 scope 自动量程。Pulse 与 Step Sweep 只使用 descriptor 已声明的受限配置入口;A5 只使用已声明的 `pulse_in_out` output 路径;M3-MO 只能使用 DSG830 已声明的固定 AM/FM/PM profile,并在结束时显式 RF OFF 与按模式关闭调制。 + +需要实现新型号或提升 capability 时,继续阅读 [RF 信号源领域设计](../design/WaveBench_RF信号源设计.md)、[RF 信号源开发里程碑](../design/WaveBench_RF信号源开发里程碑.md) 和对应插件的型号级里程碑。 diff --git "a/docs/project/guides/WaveBench_run_plan_\344\275\277\347\224\250\346\214\207\345\215\227.md" "b/docs/project/guides/WaveBench_run_plan_\344\275\277\347\224\250\346\214\207\345\215\227.md" index 2875b05..f3824b2 100644 --- "a/docs/project/guides/WaveBench_run_plan_\344\275\277\347\224\250\346\214\207\345\215\227.md" +++ "b/docs/project/guides/WaveBench_run_plan_\344\275\277\347\224\250\346\214\207\345\215\227.md" @@ -64,6 +64,62 @@ step 的 `OperationSpec`。`run plan --intent` 会在取得资源租约、打开 - `run plan` 是整次实验持有 session,减少同一 plan 内反复连接/断开的开销,也让 safety、采集和 restore 使用同一批仪器连接。 - 当前第一版不做长 session 断线自动重建;如果 run 中途断线,应该让 run 失败并留下 `run.json` 证据,而不是偷偷重连后继续。 +## RF 信号源步骤 + +RF 使用独立的 `rf_source.*` step,不使用普通 `source` 的 channel、Vpp、restore 或 safety 语义。当前 DSG830 production descriptor 已开放只读状态、OFF-only CW、RF-OFF 内部正弦调制、受 safety 限制的 RF 输出、internal/single Pulse 和保持 Sweep disabled 的 frequency-only Step Sweep 配置: + +```toml +[[steps]] +kind = "rf_source.set_frequency" +port_id = "rf_out" +frequency_hz = 1000000 + +[[steps]] +kind = "rf_source.set_power_dbm" +port_id = "rf_out" +power_dbm = -40 + +[[steps]] +kind = "rf_source.output_enable" +port_id = "rf_out" + +[[steps]] +kind = "rf_source.output_disable" +port_id = "rf_out" + +[[steps]] +kind = "rf_source.pulse_configure" +port_id = "rf_out" +period_s = 0.001 +width_s = 0.0001 +polarity = "normal" + +[[steps]] +kind = "rf_source.sweep_configure" +port_id = "rf_out" +start_frequency_hz = 1000000 +stop_frequency_hz = 2000000 +points = 11 +dwell_s = 0.02 +``` + +CW、Pulse 和 Step Sweep 配置均要求 RF 输出明确 OFF,且调制、Pulse、Sweep 与 protection 没有冲突。Pulse 只支持 internal/single,配置后保持 disabled;Step Sweep 固定为 `STEP`/`FWD`/`RAMP`/`LIN`,不 arm、fire、trigger、execute、配置 Level Sweep 或切换 RF 输出,配置后保持 disabled。`rf_source.output_enable` 还会检查每端口安全配置、实际端接、频率、功率和 fresh snapshot;不满足时会在 ON 前拒绝。RF operation 的类型化 artifact 写入 `run.json.rf_source_operations`。 + +Core 已在 schema 中提供 M3 的 `rf_source.modulation_configure`: + +```toml +[[steps]] +kind = "rf_source.modulation_configure" +port_id = "rf_out" +modulation_kind = "fm" +frequency_deviation_hz = 10000 +internal_frequency_hz = 1000 +``` + +`modulation_kind` 为 `am`、`fm` 或 `pm`,分别只能使用 `depth_percent`、`frequency_deviation_hz` 或 `phase_deviation_rad`。它只覆盖内部 Sine,要求 RF OFF、所有调制模式 disabled、Pulse/Sweep disabled 和无活动 protection condition。DSG830 production descriptor 已声明调制 capability,profile 为 AM `0–100 %`、FM `0.1 Hz–1 MHz`、PM 精确 `1.25 rad`,且内部频率均为 `10 Hz–100 kHz`。不要把它与 `rf_source.output_enable` 拼成「调制输出」流程:当前 ON 合同要求调制 disabled。 + +RF 的配置、端接判断、CLI 与 A4 边界见 [RF 信号源使用指南](WaveBench_RF信号源使用指南.md)。 + ## 不想手写时先用模板 `run template` 只负责生成标准 TOML plan,不连接仪器、不改配置、不覆盖已有文件(除非传 `--force`)。生成后仍然走普通流程:`run check`、`run verify`、`run plan`、`run report`。 diff --git "a/docs/project/reference/WaveBench_\351\205\215\347\275\256\346\226\207\344\273\266\346\240\274\345\274\217.md" "b/docs/project/reference/WaveBench_\351\205\215\347\275\256\346\226\207\344\273\266\346\240\274\345\274\217.md" index ce9cabe..0734088 100644 --- "a/docs/project/reference/WaveBench_\351\205\215\347\275\256\346\226\207\344\273\266\346\240\274\345\274\217.md" +++ "b/docs/project/reference/WaveBench_\351\205\215\347\275\256\346\226\207\344\273\266\346\240\274\345\274\217.md" @@ -88,7 +88,7 @@ wavebench scope capture --channel 2 ## 仪器访问策略 -`[scope]`、`[source]`、`[power]` 和 `[dmm]` 都支持 `access` 字段。该字段只控制 +`[scope]`、`[source]`、`[rf_source]`、`[power]` 和 `[dmm]` 都支持 `access` 字段。该字段只控制 WaveBench 发起的仪器操作,不会修改配置文件,也不会替代操作系统或仪器自身的权限控制。 ```toml @@ -170,6 +170,20 @@ ensure_fix_mode_on_set_frequency = true settle_ms_after_set_frequency = 500 access = "read_write" +# DSG830 已完成 A1/A2;read_only 仍是身份与状态查询的默认安全选择。 +[rf_source] +driver = "rigol.dsg830" +resource = "TCPIP::192.0.2.13::INSTR" +access = "read_only" + +# `rf-source output` 的 ON preflight 会消费该静态证据;它不授权 CW 或其它 RF 写入能力。 +[[rf_source.safety.ports]] +port_id = "rf_out" +minimum_frequency_hz = 9000 +maximum_frequency_hz = 3000000000 +maximum_power_dbm = -20 +actual_termination_ohm = 50 + [power] driver = "dp800" resource = "TCPIP::192.0.2.12::INSTR" @@ -416,6 +430,46 @@ maximum_ohm = 50.5 实际端接与仪器显示的 `HiZ`、`50 Ω` 或其它 load setting 是不同事实。配置项只声明实验台已确认的 外部端接;未配置不会被核心根据显示负载自动推断。 +## `[rf_source]` + +```toml +[rf_source] +driver = "rigol.dsg830" +resource = "TCPIP::192.0.2.13::INSTR" +access = "read_only" + +[[rf_source.safety.ports]] +port_id = "rf_out" +minimum_frequency_hz = 9000 +maximum_frequency_hz = 3000000000 +maximum_power_dbm = -20 +actual_termination_ohm = 50 +``` + +`[rf_source]` 是独立于普通 `[source]` 的 RF 信号源配置。它使用 plugin descriptor 的稳定 +`port_id`、Hz 和 dBm,不存在 `default_channel`、Vpp 或波形字段。M0 提供 +`wavebench rf-source idn` 与 `wavebench rf-source status`;后者要求 production descriptor 声明 +`rf_source.snapshot`。M1 的频率/功率 CLI、M4 的 Pulse/Step Sweep 配置和 M2 的输出 CLI 都要求对应 capability、`read_write` 访问和 +fresh preflight;M2 的端口 ON/OFF 还要求完整安全配置。DSG830 已完成 A1/A2/A3/A4 Pulse/A4 Step Sweep,声明 `rf_source.idn`、`rf_source.snapshot`、`rf_source.cw_configure`、`rf_source.output`、`rf_source.pulse_configure` 与 `rf_source.sweep_configure`:status 可在已配置的只读 session 中执行,端口 ON/OFF 还要求切换为 `read_write` 并提供完整安全配置。Pulse 与 Step Sweep 配置分别保持 Pulse/Sweep disabled;调制、trigger、Sweep execute/fire 与 Level Sweep 仍由 capability 门禁拒绝。 + +字段说明: + +- `driver`:已安装 RF 插件的 canonical driver ID;默认值是 `rigol.dsg830`,但只有已安装插件才可解析。 +- `resource`:RF 信号源的 VISA 资源串;可由 `rf-source` 命令的 `--resource` 临时覆盖。 +- `access`:沿用通用访问策略。`read_only` 是 production 状态查询的默认选择;`read_write` 本身不授予写入, + 还必须同时具备对应 descriptor capability、profile、fresh safety preflight 和实机证据。 +- `options`:可选的插件私有配置表;公开配置不要放入真实资源之外的凭据或实验室专有数据。 + +`[[rf_source.safety.ports]]` 是按端口声明的本地静态安全证据。每项必须提供唯一 `port_id`、有限的 +`minimum_frequency_hz`、`maximum_frequency_hz`、`maximum_power_dbm` 和正数 +`actual_termination_ohm`;最大频率不得小于最小频率。它不改变仪器显示的负载设置,也不会把 dBm +换算为 Vpp。M2 的 RF ON 事务会使用它进行准入判断;若某个 descriptor 缺少 output capability,事务仍会在 +打开 transport 前拒绝。DSG830 已声明该 capability,但 `read_write`、完整安全配置和 fresh snapshot 缺一不可。 + +RF 的 capability、A1–A5 证据门和 DSG830 的当前 production 边界见 +[RF 信号源领域设计](../design/WaveBench_RF信号源设计.md)和 +[RF 信号源开发里程碑](../design/WaveBench_RF信号源开发里程碑.md)。 + ## `[power]` ```toml diff --git "a/docs/project/reference/plugins/WaveBench_\345\217\257\346\211\247\350\241\214\344\273\252\345\231\250\346\217\222\344\273\266API.md" "b/docs/project/reference/plugins/WaveBench_\345\217\257\346\211\247\350\241\214\344\273\252\345\231\250\346\217\222\344\273\266API.md" index 27e9051..8c0db84 100644 --- "a/docs/project/reference/plugins/WaveBench_\345\217\257\346\211\247\350\241\214\344\273\252\345\231\250\346\217\222\344\273\266API.md" +++ "b/docs/project/reference/plugins/WaveBench_\345\217\257\346\211\247\350\241\214\344\273\252\345\231\250\346\217\222\344\273\266API.md" @@ -120,7 +120,7 @@ def descriptor() -> InstrumentDescriptor: | 字段 | 核心强制 | 接口约定和用途 | | --- | --- | --- | | `driver_id` | 非空、无首尾空白;外置插件必须与 entry point 名一致 | 使用小写 ASCII canonical ID,推荐 `vendor.model` 形式;发布后不得复用给其他设备族 | -| `kind` | 配置解析时必须与目标槽位一致 | 只能是 `scope`、`source`、`power`、`dmm` 或 `sweep_analyzer` | +| `kind` | 配置解析时必须与目标槽位一致 | 只能是 `scope`、`source`、`rf_source`、`power`、`dmm` 或 `sweep_analyzer` | | `display_name` | 仅要求构造参数存在 | 面向用户的简短名称,不承担型号匹配 | | `manufacturer` | 仅要求构造参数存在 | 使用厂商正式名称 | | `models` | 至少包含一项 | 每项应为非空型号名称;不要把营销系列名当作已验证型号 | @@ -141,6 +141,7 @@ def descriptor() -> InstrumentDescriptor: | `config_fields` | 当前只展示;为空时由 `option_specs` 推导 `options.` | 只列出用户实际可配置的字段,不代表核心会按此字段授权 | | `scope_extensions` | 仅允许 scope descriptor 使用,类型必须为 `ScopeDescriptorExtensions` | 为 R1.3 capability 提供静态截图、采集控制、trace、标准 waveform bounded profile 和 average capture V2 profile;旧插件保持 `None` | | `source_extensions` | 仅允许 source descriptor 使用,类型必须为 `SourceDescriptorExtensions` | 为 `source.snapshot_v2` 及各已声明的 Source V2 写 capability 提供 topology、feature profile 和查询合同;旧插件保持 `None` | +| `rf_source_extensions` | `rf_source` descriptor 必须提供,且其它 kind 不得提供;类型必须为 `RfSourceDescriptorExtensions` | M0 的独立 RF topology、feature 和 protection policy;该字段 append-only,旧插件保持 `None` | ### `scope_coupling_policy` @@ -583,6 +584,42 @@ run plan 接受 `source.basic_configure_v2`、`source.output_enable_v2`、`sourc capability 的高级配置保持 V1。插件不得把 capability 注册视为 自行发起写操作的许可,也不得通过已有 V1 方法绕过核心路由。 +### RF 信号源 M0–M4(DSG830 production 已含 A2 output、A3 CW、A4 调制/Pulse/Step Sweep) + +`rf_source` 是独立于 `source` 的 kind,不能使用 `source_extensions`、Vpp、数字 channel 或普通 source +能力。descriptor 必须同时满足以下静态条件: + +- 只声明 `rf_source.*` capability,且至少包含 `rf_source.idn`;当前 Core 识别 + `rf_source.idn`、`rf_source.snapshot`、`rf_source.cw_configure`、`rf_source.modulation_configure`、`rf_source.modulation_disable`、`rf_source.pulse_configure`、`rf_source.sweep_configure` 和 `rf_source.output`。 +- 提供 `rf_source_extensions`,其 contract version、拓扑、端口 ID、feature 和 protection policy 必须通过 + Core 校验。 +- 声明 `rf_source.cw_configure` 时,CW feature 必须有 `CONFIGURE` direction 和至少一个可配置字段;声明 + `rf_source.output` 时,output feature 必须同时有 `ENABLE`/`DISABLE` direction 与可读 output state。 +- 声明 `rf_source.modulation_configure` 时,Modulation feature 必须同时有 `CONFIGURE`/`READ` direction、 + `configuration_readable = true`,并至少声明一个内部 Sine `RfModulationModeProfile`。profile 的模式、值单位、值范围和内部频率范围必须与 driver 的严格 readback 一致。 +- 声明 `rf_source.pulse_configure` 或 `rf_source.sweep_configure` 时,对应 feature 必须同时有 `CONFIGURE`/`READ` direction、可读 configuration state 和有界的 mode profile;Pulse 与 Sweep 的配置方法必须在独立 readback 后保持 disabled。 +- `wavebench_min_version` 不低于 `0.8.25`,并且小于 `wavebench_max_version`。 +- 打包检查时,wheel 必须有且仅有一条生效的 `wavebench` 依赖,并显式使用与 descriptor 相同的 + `>=wavebench_min_version, SourceService: return SourceService(config=config, logger=CommandLogger()) +def _load_rf_source_service(args: argparse.Namespace) -> RfSourceService: + config = load_config(args.config) + if args.resource: + config = config.with_rf_source_resource(args.resource) + return RfSourceService(config=config, logger=CommandLogger()) + + def _load_power_service(args: argparse.Namespace) -> PowerService: config = load_config(args.config) if args.resource: @@ -1520,6 +1540,138 @@ def _main(argv: list[str] | None = None) -> int: if args.command == "set-duty": _print_source_status(service.set_square_duty_cycle(channel=args.channel, duty_percent=args.duty_percent)) return 0 + if args.domain == "rf-source": + service = _load_rf_source_service(args) + if args.command == "idn": + print(service.idn()) + return 0 + if args.command == "status": + result = service.snapshot() + if args.json: + _emit_json_result(_json_payload(result)) + else: + print(json.dumps(_json_payload(result), indent=2, ensure_ascii=False)) + return 0 + if args.command == "trigger": + result = service.trigger_snapshot(args.port) + if args.json: + _emit_json_result(_json_payload(result)) + else: + print(json.dumps(_json_payload(result), indent=2, ensure_ascii=False)) + return 0 + if args.command == "set-frequency": + result = service.configure_cw( + RfCwRequest(port_id=args.port, frequency_hz=args.frequency_hz) + ) + if args.json: + _emit_json_result(_json_payload(result)) + else: + print(json.dumps(_json_payload(result), indent=2, ensure_ascii=False)) + return 0 + if args.command == "set-power": + result = service.configure_cw( + RfCwRequest(port_id=args.port, power_dbm=args.power_dbm) + ) + if args.json: + _emit_json_result(_json_payload(result)) + else: + print(json.dumps(_json_payload(result), indent=2, ensure_ascii=False)) + return 0 + if args.command == "modulation": + if args.modulation_command == "disable": + result = service.disable_modulation( + RfModulationDisableRequest( + port_id=args.port, + kind=RfModulationKind(args.modulation_kind), + ) + ) + if args.json: + _emit_json_result(_json_payload(result)) + else: + print(json.dumps(_json_payload(result), indent=2, ensure_ascii=False)) + return 0 + modulation_kind = { + "configure-am": RfModulationKind.AM, + "configure-fm": RfModulationKind.FM, + "configure-pm": RfModulationKind.PM, + "enable-output-am": RfModulationKind.AM, + "enable-output-fm": RfModulationKind.FM, + "enable-output-pm": RfModulationKind.PM, + }[args.modulation_command] + request_fields = { + "port_id": args.port, + "kind": modulation_kind, + "internal_frequency_hz": args.internal_frequency_hz, + } + if modulation_kind is RfModulationKind.AM: + request_fields["depth_percent"] = args.depth_percent + elif modulation_kind is RfModulationKind.FM: + request_fields["frequency_deviation_hz"] = args.frequency_deviation_hz + else: + request_fields["phase_deviation_rad"] = args.phase_deviation_rad + request = RfModulationRequest(**request_fields) + if args.modulation_command.startswith("enable-output-"): + result = service.enable_modulated_output( + RfModulatedOutputRequest(modulation=request) + ) + else: + result = service.configure_modulation(request) + if args.json: + _emit_json_result(_json_payload(result)) + else: + print(json.dumps(_json_payload(result), indent=2, ensure_ascii=False)) + return 0 + if args.command == "pulse": + result = service.configure_pulse( + RfPulseConfigureRequest( + port_id=args.port, + period_s=args.period_s, + width_s=args.width_s, + polarity=RfPulsePolarity(args.polarity), + ) + ) + if args.json: + _emit_json_result(_json_payload(result)) + else: + print(json.dumps(_json_payload(result), indent=2, ensure_ascii=False)) + return 0 + if args.command == "pulse-output": + result = service.set_pulse_output( + RfPulseOutputRequest( + port_id=args.port, + interface_id=args.interface_id, + enabled=args.state == "on", + ) + ) + if args.json: + _emit_json_result(_json_payload(result)) + else: + print(json.dumps(_json_payload(result), indent=2, ensure_ascii=False)) + return 0 + if args.command == "sweep": + result = service.configure_sweep( + RfSweepConfigureRequest( + port_id=args.port, + start_frequency_hz=args.start_frequency_hz, + stop_frequency_hz=args.stop_frequency_hz, + points=args.points, + dwell_s=args.dwell_s, + ) + ) + if args.json: + _emit_json_result(_json_payload(result)) + else: + print(json.dumps(_json_payload(result), indent=2, ensure_ascii=False)) + return 0 + if args.command == "output": + result = service.set_output( + RfOutputRequest(port_id=args.port, enabled=args.state == "on") + ) + if args.json: + _emit_json_result(_json_payload(result)) + else: + print(json.dumps(_json_payload(result), indent=2, ensure_ascii=False)) + return 0 if args.domain == "sweep": service = _load_sweep_service(args) if args.command == "discrete": diff --git a/src/wavebench/cli_parser.py b/src/wavebench/cli_parser.py index 6e8fbca..fcc59a8 100644 --- a/src/wavebench/cli_parser.py +++ b/src/wavebench/cli_parser.py @@ -37,6 +37,7 @@ def build_parser() -> argparse.ArgumentParser: scope_parser = subparsers.add_parser("scope", help="Oscilloscope commands") source_parser = subparsers.add_parser("source", help="Signal generator commands") + rf_source_parser = subparsers.add_parser("rf-source", help="RF signal source commands") power_parser = subparsers.add_parser("power", help="Power supply commands") dmm_parser = subparsers.add_parser("dmm", help="Digital multimeter commands") sweep_parser = subparsers.add_parser("sweep", help="Source/scope sweep commands") @@ -96,7 +97,7 @@ def build_parser() -> argparse.ArgumentParser: ) capability_explain.add_argument( "--kind", - choices=("scope", "source", "power", "dmm"), + choices=("scope", "source", "rf_source", "power", "dmm"), default=None, help="Instrument kind when selecting a configured driver / 仪器类型", ) @@ -153,7 +154,7 @@ def build_parser() -> argparse.ArgumentParser: ) plugin_list.add_argument( "--kind", - choices=("scope", "source", "power", "dmm"), + choices=("scope", "source", "rf_source", "power", "dmm"), default=None, help="Filter plugins by instrument kind / 按仪器类型过滤", ) @@ -644,6 +645,165 @@ def build_parser() -> argparse.ArgumentParser: protection_set.add_argument("--ocp", choices=["on", "off"], default=None) add_runtime_options(protection_set) + rf_source_sub = rf_source_parser.add_subparsers(dest="command", required=True) + rf_source_idn = rf_source_sub.add_parser("idn", help="Query RF source *IDN?") + add_runtime_options(rf_source_idn) + rf_source_status = rf_source_sub.add_parser( + "status", + help="Query a typed, read-only RF source snapshot", + ) + add_runtime_options(rf_source_status) + rf_source_trigger = rf_source_sub.add_parser( + "trigger", + help="Read declared Pulse and Sweep trigger configuration without triggering", + ) + rf_source_trigger_sub = rf_source_trigger.add_subparsers( + dest="trigger_command", + required=True, + ) + rf_source_trigger_status = rf_source_trigger_sub.add_parser( + "status", + help="Read logical trigger configuration without changing RF or trigger state", + ) + rf_source_trigger_status.add_argument("--port", required=True) + add_runtime_options(rf_source_trigger_status) + rf_source_set_frequency = rf_source_sub.add_parser( + "set-frequency", + help="Configure one RF port frequency while its RF output is OFF", + ) + rf_source_set_frequency.add_argument("--port", required=True) + rf_source_set_frequency.add_argument("frequency_hz", type=float) + add_runtime_options(rf_source_set_frequency) + rf_source_set_power = rf_source_sub.add_parser( + "set-power", + help="Configure one RF port dBm level while its RF output is OFF", + ) + rf_source_set_power.add_argument("--port", required=True) + rf_source_set_power.add_argument("power_dbm", type=float) + add_runtime_options(rf_source_set_power) + rf_source_output = rf_source_sub.add_parser( + "output", + help="Set one RF port output on or off after a safety-checked snapshot", + ) + rf_source_output.add_argument("--port", required=True) + rf_source_output.add_argument("state", choices=["on", "off"]) + add_runtime_options(rf_source_output) + + rf_source_modulation = rf_source_sub.add_parser( + "modulation", + help="Configure internal-sine modulation or enable RF for one exactly verified profile", + ) + rf_source_modulation_sub = rf_source_modulation.add_subparsers( + dest="modulation_command", + required=True, + ) + for command, value_option, help_text in ( + ("configure-am", "depth-percent", "Configure internal-sine AM while RF output is OFF"), + ( + "configure-fm", + "frequency-deviation-hz", + "Configure internal-sine FM while RF output is OFF", + ), + ( + "enable-output-am", + "depth-percent", + "Enable RF only when the active internal-sine AM profile exactly matches", + ), + ( + "enable-output-fm", + "frequency-deviation-hz", + "Enable RF only when the active internal-sine FM profile exactly matches", + ), + ( + "enable-output-pm", + "phase-deviation-rad", + "Enable RF only when the active internal-sine PM profile exactly matches", + ), + ( + "configure-pm", + "phase-deviation-rad", + "Configure internal-sine PM while RF output is OFF", + ), + ): + rf_source_modulation_configure = rf_source_modulation_sub.add_parser( + command, + help=help_text, + ) + rf_source_modulation_configure.add_argument("--port", required=True) + rf_source_modulation_configure.add_argument( + f"--{value_option}", + type=float, + required=True, + ) + rf_source_modulation_configure.add_argument( + "--internal-frequency-hz", + type=float, + required=True, + ) + add_runtime_options(rf_source_modulation_configure) + + rf_source_modulation_disable = rf_source_modulation_sub.add_parser( + "disable", + help="Disable one known active internal-sine modulation mode while RF output is OFF", + ) + rf_source_modulation_disable.add_argument("--port", required=True) + rf_source_modulation_disable.add_argument( + "--modulation-kind", + choices=("am", "fm", "pm"), + required=True, + ) + add_runtime_options(rf_source_modulation_disable) + + rf_source_pulse = rf_source_sub.add_parser( + "pulse", + help="Configure a bounded internal single-pulse profile while RF output is OFF", + ) + rf_source_pulse_sub = rf_source_pulse.add_subparsers( + dest="pulse_command", + required=True, + ) + rf_source_pulse_configure = rf_source_pulse_sub.add_parser( + "configure", + help="Configure a disabled internal single-pulse profile without triggering", + ) + rf_source_pulse_configure.add_argument("--port", required=True) + rf_source_pulse_configure.add_argument("--period-s", type=float, required=True) + rf_source_pulse_configure.add_argument("--width-s", type=float, required=True) + rf_source_pulse_configure.add_argument( + "--polarity", + choices=["normal", "inverted"], + required=True, + ) + add_runtime_options(rf_source_pulse_configure) + + rf_source_pulse_output = rf_source_sub.add_parser( + "pulse-output", + help="Set one declared physical Pulse-output interface without enabling RF output", + ) + rf_source_pulse_output.add_argument("--port", required=True) + rf_source_pulse_output.add_argument("--interface", dest="interface_id", required=True) + rf_source_pulse_output.add_argument("state", choices=["on", "off"]) + add_runtime_options(rf_source_pulse_output) + + rf_source_sweep = rf_source_sub.add_parser( + "sweep", + help="Configure a bounded frequency-only Step Sweep while RF output is OFF", + ) + rf_source_sweep_sub = rf_source_sweep.add_subparsers( + dest="sweep_command", + required=True, + ) + rf_source_sweep_configure = rf_source_sweep_sub.add_parser( + "configure", + help="Configure a disabled Step Sweep without arming, firing, or triggering", + ) + rf_source_sweep_configure.add_argument("--port", required=True) + rf_source_sweep_configure.add_argument("--start-frequency-hz", type=float, required=True) + rf_source_sweep_configure.add_argument("--stop-frequency-hz", type=float, required=True) + rf_source_sweep_configure.add_argument("--points", type=int, required=True) + rf_source_sweep_configure.add_argument("--dwell-s", type=float, required=True) + add_runtime_options(rf_source_sweep_configure) + source_sub = source_parser.add_subparsers(dest="command", required=True) source_idn = source_sub.add_parser("idn", help="Query source *IDN?") diff --git a/src/wavebench/config.py b/src/wavebench/config.py index 172818c..9dae508 100644 --- a/src/wavebench/config.py +++ b/src/wavebench/config.py @@ -3,6 +3,7 @@ from dataclasses import dataclass, field from math import isfinite from pathlib import Path +import re import tomllib from .errors import ConfigError @@ -100,6 +101,18 @@ class SourceConfig: access: AccessMode = "read_write" terminations: tuple["SourceTerminationConfig", ...] = () + +@dataclass(frozen=True) +class RfSourceConfig: + """Configuration isolated from the Vpp/channel-oriented source domain.""" + + driver: str + resource: str | None + options: dict[str, object] = field(default_factory=dict) + access: AccessMode = "read_write" + safety_ports: tuple["RfPortSafetyConfig", ...] = () + + @dataclass(frozen=True) class PowerConfig: driver: str @@ -168,6 +181,17 @@ class SourceTerminationConfig: minimum_ohm: float | None = None maximum_ohm: float | None = None + +@dataclass(frozen=True) +class RfPortSafetyConfig: + """Static local safety declaration for one descriptor-defined RF port.""" + + port_id: str + minimum_frequency_hz: float + maximum_frequency_hz: float + maximum_power_dbm: float + actual_termination_ohm: float + @dataclass(frozen=True) class TuiConfig: log_max_lines: int = 10_000 @@ -266,6 +290,74 @@ def _source_terminations(raw: dict[str, object]) -> tuple[SourceTerminationConfi return tuple(parsed) +_RF_PORT_ID = re.compile(r"^[A-Za-z0-9_.:-]{1,96}$") + + +def _rf_source_safety_ports(raw: dict[str, object]) -> tuple[RfPortSafetyConfig, ...]: + safety = raw.get("safety", {}) + if not isinstance(safety, dict): + raise ConfigError("rf_source.safety must be a TOML table") + values = safety.get("ports", []) + if not isinstance(values, list): + raise ConfigError("rf_source.safety.ports must be an array of TOML tables") + + parsed: list[RfPortSafetyConfig] = [] + for index, value in enumerate(values): + path = f"rf_source.safety.ports[{index}]" + if not isinstance(value, dict): + raise ConfigError(f"{path} must be a TOML table") + port_id = value.get("port_id") + if not isinstance(port_id, str) or _RF_PORT_ID.fullmatch(port_id) is None: + raise ConfigError(f"{path}.port_id must be a short safe port ID") + fields = ( + "minimum_frequency_hz", + "maximum_frequency_hz", + "maximum_power_dbm", + "actual_termination_ohm", + ) + missing = [name for name in fields if name not in value] + if missing: + raise ConfigError(f"{path} is missing required field(s): {', '.join(missing)}") + minimum_frequency_hz = _finite_number( + value["minimum_frequency_hz"], + path=f"{path}.minimum_frequency_hz", + ) + maximum_frequency_hz = _finite_number( + value["maximum_frequency_hz"], + path=f"{path}.maximum_frequency_hz", + ) + maximum_power_dbm = _finite_number( + value["maximum_power_dbm"], + path=f"{path}.maximum_power_dbm", + ) + actual_termination_ohm = _finite_number( + value["actual_termination_ohm"], + path=f"{path}.actual_termination_ohm", + ) + if minimum_frequency_hz <= 0: + raise ConfigError(f"{path}.minimum_frequency_hz must be > 0") + if maximum_frequency_hz < minimum_frequency_hz: + raise ConfigError( + f"{path}.maximum_frequency_hz must be >= {path}.minimum_frequency_hz" + ) + if actual_termination_ohm <= 0: + raise ConfigError(f"{path}.actual_termination_ohm must be > 0") + parsed.append( + RfPortSafetyConfig( + port_id=port_id, + minimum_frequency_hz=minimum_frequency_hz, + maximum_frequency_hz=maximum_frequency_hz, + maximum_power_dbm=maximum_power_dbm, + actual_termination_ohm=actual_termination_ohm, + ) + ) + parsed.sort(key=lambda item: item.port_id) + port_ids = tuple(item.port_id for item in parsed) + if len(set(port_ids)) != len(port_ids): + raise ConfigError("rf_source.safety.ports port_id values must be unique") + return tuple(parsed) + + def _instrument_options(raw: dict, section: str) -> dict[str, object]: options = raw.get("options", {}) if not isinstance(options, dict): @@ -286,6 +378,8 @@ class WaveBenchConfig: quality: QualityConfig = QualityConfig() safety_limits: SafetyLimitsConfig = SafetyLimitsConfig() tui: TuiConfig = TuiConfig() + # Append-only: preserve the public positional layout of existing config fields. + rf_source: RfSourceConfig | None = None def with_connection_timeout_ms(self, timeout_ms: int) -> "WaveBenchConfig": if timeout_ms <= 0: @@ -310,6 +404,7 @@ def with_connection_timeout_ms(self, timeout_ms: int) -> "WaveBenchConfig": quality=self.quality, safety_limits=self.safety_limits, tui=self.tui, + rf_source=self.rf_source, ) def with_resource(self, resource: str) -> "WaveBenchConfig": @@ -333,6 +428,7 @@ def with_resource(self, resource: str) -> "WaveBenchConfig": quality=self.quality, safety_limits=self.safety_limits, tui=self.tui, + rf_source=self.rf_source, ) def with_output_overrides( @@ -364,6 +460,7 @@ def with_output_overrides( quality=self.quality, safety_limits=self.safety_limits, tui=self.tui, + rf_source=self.rf_source, ) def with_waveform_overrides( @@ -414,6 +511,7 @@ def with_waveform_overrides( quality=self.quality, safety_limits=self.safety_limits, tui=self.tui, + rf_source=self.rf_source, ) def with_source_resource(self, resource: str) -> "WaveBenchConfig": @@ -448,6 +546,7 @@ def with_source_resource(self, resource: str) -> "WaveBenchConfig": quality=self.quality, safety_limits=self.safety_limits, tui=self.tui, + rf_source=self.rf_source, ) def with_power_resource(self, resource: str) -> "WaveBenchConfig": @@ -481,6 +580,7 @@ def with_power_resource(self, resource: str) -> "WaveBenchConfig": quality=self.quality, safety_limits=self.safety_limits, tui=self.tui, + rf_source=self.rf_source, ) def with_dmm_resource(self, resource: str) -> "WaveBenchConfig": @@ -528,6 +628,34 @@ def with_dmm_resource(self, resource: str) -> "WaveBenchConfig": quality=self.quality, safety_limits=self.safety_limits, tui=self.tui, + rf_source=self.rf_source, + ) + + def with_rf_source_resource(self, resource: str) -> "WaveBenchConfig": + rf_source = self.rf_source or RfSourceConfig( + driver="rigol.dsg830", + resource=None, + ) + return WaveBenchConfig( + connection=self.connection, + scope=self.scope, + autoscale=self.autoscale, + waveform=self.waveform, + output=self.output, + source_path=self.source_path, + source=self.source, + power=self.power, + dmm=self.dmm, + quality=self.quality, + safety_limits=self.safety_limits, + tui=self.tui, + rf_source=RfSourceConfig( + driver=rf_source.driver, + resource=resource, + options=rf_source.options, + access=rf_source.access, + safety_ports=rf_source.safety_ports, + ), ) def load_config(path: str | Path = "wavebench.toml") -> WaveBenchConfig: @@ -569,6 +697,21 @@ def load_config(path: str | Path = "wavebench.toml") -> WaveBenchConfig: access=normalize_access_mode(src.get("access", "read_write"), "source.access"), terminations=_source_terminations(src), ) + rf_raw = raw.get("rf_source") + rf_source = None + if rf_raw is not None: + if not isinstance(rf_raw, dict): + raise ConfigError("rf_source must be a TOML table") + rf_source = RfSourceConfig( + driver=str(rf_raw.get("driver", "rigol.dsg830")), + resource=str(rf_raw["resource"]) if "resource" in rf_raw else None, + options=_instrument_options(rf_raw, "rf_source"), + access=normalize_access_mode( + rf_raw.get("access", "read_write"), + "rf_source.access", + ), + safety_ports=_rf_source_safety_ports(rf_raw), + ) pwr = raw.get("power") power = None if pwr is not None: @@ -677,6 +820,7 @@ def load_config(path: str | Path = "wavebench.toml") -> WaveBenchConfig: log_max_lines=int(tui_raw.get("log_max_lines", 10_000)), log_keep_lines_after_trim=int(tui_raw.get("log_keep_lines_after_trim", 1_000)), ), + rf_source=rf_source, ) except KeyError as exc: raise ConfigError(f"missing required config key: {exc}") from exc @@ -735,6 +879,8 @@ def load_config(path: str | Path = "wavebench.toml") -> WaveBenchConfig: raise ConfigError("source.default_channel must be >= 1") if config.source.settle_ms_after_set_frequency < 0: raise ConfigError("source.settle_ms_after_set_frequency must be >= 0") + if config.rf_source is not None: + validate_instrument_reference(config.rf_source.driver, expected_kind="rf_source") if config.dmm is not None: validate_instrument_reference(config.dmm.driver, expected_kind="dmm") if config.dmm.backend.lower() not in {"serial", "lan", "visa", "pyvisa"}: diff --git a/src/wavebench/doctor.py b/src/wavebench/doctor.py index 69db913..a1ad5a0 100644 --- a/src/wavebench/doctor.py +++ b/src/wavebench/doctor.py @@ -195,6 +195,15 @@ def _doctor_targets(config: WaveBenchConfig) -> list[DoctorTarget]: expected_idn_tokens=_driver_expected_tokens(config.source.driver), ) ) + if config.rf_source is not None: + targets.append( + DoctorTarget( + name="rf_source", + driver=config.rf_source.driver, + resource=config.rf_source.resource, + expected_idn_tokens=_driver_expected_tokens(config.rf_source.driver), + ) + ) if config.power is not None: targets.append( DoctorTarget( diff --git a/src/wavebench/instruments/__init__.py b/src/wavebench/instruments/__init__.py index 279484c..8c4df10 100644 --- a/src/wavebench/instruments/__init__.py +++ b/src/wavebench/instruments/__init__.py @@ -150,6 +150,7 @@ ) from . import scope_extensions as _scope_extensions from . import source_extensions as _source_extensions +from . import rf_source_extensions as _rf_source_extensions for _scope_extension_name in _scope_extensions.__all__: globals()[_scope_extension_name] = getattr(_scope_extensions, _scope_extension_name) @@ -157,6 +158,9 @@ for _source_extension_name in _source_extensions.__all__: globals()[_source_extension_name] = getattr(_source_extensions, _source_extension_name) +for _rf_source_extension_name in _rf_source_extensions.__all__: + globals()[_rf_source_extension_name] = getattr(_rf_source_extensions, _rf_source_extension_name) + __all__ = [ "ArbitraryQueryProbeResult", "DmmCalculationStatistics", @@ -310,7 +314,9 @@ "open_instrument_driver", *_scope_extensions.__all__, *_source_extensions.__all__, + *_rf_source_extensions.__all__, ] del _scope_extension_name del _source_extension_name +del _rf_source_extension_name diff --git a/src/wavebench/instruments/api.py b/src/wavebench/instruments/api.py index 939005e..7d9a55b 100644 --- a/src/wavebench/instruments/api.py +++ b/src/wavebench/instruments/api.py @@ -11,6 +11,7 @@ from .scope_extensions import ScopeDescriptorExtensions from .source_extensions import SourceDescriptorExtensions +from .rf_source_extensions import RfSourceDescriptorExtensions EXECUTABLE_PLUGIN_API_VERSION = "wavebench.instrument.v2" ScopeCouplingPolicy = Literal["fixed-high-impedance", "switchable-termination", "unknown"] @@ -92,6 +93,7 @@ class InstrumentDescriptor: # Append-only to preserve the positional layout accepted by instrument API v2. scope_extensions: ScopeDescriptorExtensions | None = None source_extensions: SourceDescriptorExtensions | None = None + rf_source_extensions: RfSourceDescriptorExtensions | None = None def __post_init__(self) -> None: if not self.driver_id or self.driver_id.strip() != self.driver_id: @@ -129,6 +131,16 @@ def __post_init__(self) -> None: raise ValueError("source_extensions can only be declared by source descriptors") if not isinstance(self.source_extensions, SourceDescriptorExtensions): raise TypeError("source_extensions has an invalid type") + if self.rf_source_extensions is None: + if self.kind == "rf_source": + raise ValueError("rf_source descriptors require rf_source_extensions") + else: + if self.kind != "rf_source": + raise ValueError( + "rf_source_extensions can only be declared by rf_source descriptors" + ) + if not isinstance(self.rf_source_extensions, RfSourceDescriptorExtensions): + raise TypeError("rf_source_extensions has an invalid type") def with_distribution( self, diff --git a/src/wavebench/instruments/capabilities.py b/src/wavebench/instruments/capabilities.py index 8dfe903..653cc48 100644 --- a/src/wavebench/instruments/capabilities.py +++ b/src/wavebench/instruments/capabilities.py @@ -13,6 +13,10 @@ SOURCE_EXTENSION_CAPABILITY_METHODS, validate_source_descriptor, ) +from .rf_source_capabilities import ( + RF_SOURCE_CAPABILITY_METHODS, + validate_rf_source_descriptor, +) _PROFILE_AWARE_WAVEFORM_CAPABILITIES = frozenset( @@ -104,6 +108,7 @@ } CAPABILITY_METHODS.update(SCOPE_CAPABILITY_METHODS) CAPABILITY_METHODS.update(SOURCE_EXTENSION_CAPABILITY_METHODS) +CAPABILITY_METHODS.update(RF_SOURCE_CAPABILITY_METHODS) def require_capabilities( @@ -149,3 +154,4 @@ def validate_declared_capabilities( ) validate_scope_descriptor(descriptor, driver=driver) validate_source_descriptor(descriptor, driver=driver) + validate_rf_source_descriptor(descriptor, driver=driver) diff --git a/src/wavebench/instruments/registry.py b/src/wavebench/instruments/registry.py index 0e485e0..f58051c 100644 --- a/src/wavebench/instruments/registry.py +++ b/src/wavebench/instruments/registry.py @@ -22,6 +22,7 @@ from .scope_extension_capabilities import validate_scope_descriptor from .source_conformance import validate_source_conformance_distribution from .source_extension_capabilities import validate_source_descriptor +from .rf_source_capabilities import validate_rf_source_descriptor from .migrations import BUILTIN_MIGRATION_DISTRIBUTIONS ENTRY_POINT_GROUP = "wavebench.instruments" @@ -211,6 +212,7 @@ def _validate_descriptor( ) validate_scope_descriptor(descriptor) validate_source_descriptor(descriptor) + validate_rf_source_descriptor(descriptor) current = _version_tuple(__version__) if current < _version_tuple(descriptor.wavebench_min_version) or current >= _version_tuple( descriptor.wavebench_max_version diff --git a/src/wavebench/instruments/rf_source_capabilities.py b/src/wavebench/instruments/rf_source_capabilities.py new file mode 100644 index 0000000..42f939c --- /dev/null +++ b/src/wavebench/instruments/rf_source_capabilities.py @@ -0,0 +1,499 @@ +"""Capability registration and descriptor validation for RF signal sources.""" + +from __future__ import annotations + +from collections.abc import Iterable +from types import MappingProxyType +from typing import Mapping + +from packaging.requirements import InvalidRequirement, Requirement +from packaging.utils import canonicalize_name +from packaging.version import InvalidVersion, Version + +from wavebench.errors import ConfigError + +from .rf_source_extensions import ( + RF_SOURCE_CONTRACT_VERSION, + RF_SOURCE_SNAPSHOT_MIN_CORE_VERSION, + RfCwProfile, + RfFeature, + RfFeatureDirection, + RfModulatedOutputProfile, + RfModulationProfile, + RfOutputProfile, + RfPulseProfile, + RfPulseOutputProfile, + RfSourceDescriptorExtensions, + RfSweepProfile, + RfTriggerProfile, +) + + +RF_SOURCE_CAPABILITY_METHODS: Mapping[str, tuple[str, ...]] = MappingProxyType( + { + "rf_source.idn": ("idn",), + "rf_source.snapshot": ("get_rf_snapshot",), + "rf_source.trigger_snapshot": ("get_rf_trigger_snapshot",), + "rf_source.cw_configure": ("configure_cw",), + "rf_source.modulation_configure": ( + "get_rf_modulation_state", + "get_rf_modulation_snapshot", + "configure_rf_modulation", + ), + "rf_source.modulation_disable": ( + "get_rf_modulation_state", + "disable_rf_modulation", + ), + "rf_source.modulated_output_enable": ( + "get_rf_modulation_snapshot", + "set_rf_output", + ), + "rf_source.pulse_configure": ( + "get_rf_pulse_snapshot", + "configure_rf_pulse", + ), + "rf_source.pulse_output": ( + "get_rf_pulse_output_snapshot", + "set_rf_pulse_output", + ), + "rf_source.sweep_configure": ( + "get_rf_sweep_snapshot", + "configure_rf_sweep", + ), + "rf_source.output": ("set_rf_output",), + } +) + + +def validate_rf_source_descriptor(descriptor: object, driver: object | None = None) -> None: + """Validate the static RF-source descriptor contract.""" + + capabilities = tuple(getattr(descriptor, "capabilities", ())) + rf_capabilities = tuple( + capability for capability in capabilities if isinstance(capability, str) and capability.startswith("rf_source.") + ) + unknown = sorted(set(rf_capabilities) - set(RF_SOURCE_CAPABILITY_METHODS)) + if unknown: + raise ConfigError( + "RF source descriptor declares unknown capabilities: " + ", ".join(unknown) + ) + + kind = getattr(descriptor, "kind", None) + extensions = getattr(descriptor, "rf_source_extensions", None) + if kind != "rf_source": + if extensions is not None: + raise ConfigError("rf_source_extensions can only be declared by rf_source descriptors") + if rf_capabilities: + raise ConfigError("rf_source capabilities require kind='rf_source'") + return + if extensions is None: + raise ConfigError("rf_source descriptors require rf_source_extensions") + if not isinstance(extensions, RfSourceDescriptorExtensions): + raise ConfigError("rf_source_extensions has an invalid type") + if extensions.contract_version != RF_SOURCE_CONTRACT_VERSION: + raise ConfigError("rf_source_extensions uses an unsupported contract version") + if any(not isinstance(capability, str) or not capability.startswith("rf_source.") for capability in capabilities): + raise ConfigError("rf_source descriptors can only declare rf_source capabilities") + if "rf_source.idn" not in rf_capabilities: + raise ConfigError("rf_source descriptors require the rf_source.idn capability") + if "rf_source.cw_configure" in rf_capabilities: + _validate_cw_configure_feature(extensions) + if "rf_source.trigger_snapshot" in rf_capabilities: + _validate_trigger_snapshot_feature(extensions) + if "rf_source.modulation_configure" in rf_capabilities: + _validate_modulation_configure_feature(extensions) + if "rf_source.modulation_disable" in rf_capabilities: + _validate_modulation_disable_feature(extensions) + if "rf_source.modulated_output_enable" in rf_capabilities: + if "rf_source.output" not in rf_capabilities: + raise ConfigError( + "rf_source.modulated_output_enable requires the rf_source.output capability" + ) + if "rf_source.modulation_configure" not in rf_capabilities: + raise ConfigError( + "rf_source.modulated_output_enable requires the rf_source.modulation_configure capability" + ) + _validate_modulated_output_enable_feature(extensions) + if "rf_source.pulse_configure" in rf_capabilities: + _validate_pulse_configure_feature(extensions) + if "rf_source.pulse_output" in rf_capabilities: + if "rf_source.snapshot" not in rf_capabilities: + raise ConfigError("rf_source.pulse_output requires the rf_source.snapshot capability") + if "rf_source.pulse_configure" not in rf_capabilities: + raise ConfigError( + "rf_source.pulse_output requires the rf_source.pulse_configure capability" + ) + _validate_pulse_output_feature(extensions) + if "rf_source.sweep_configure" in rf_capabilities: + _validate_sweep_configure_feature(extensions) + if "rf_source.output" in rf_capabilities: + _validate_output_feature(extensions) + _validate_rf_source_version_range(descriptor) + if driver is not None: + for capability in rf_capabilities: + for method_name in RF_SOURCE_CAPABILITY_METHODS[capability]: + method = getattr(driver, method_name, None) + if not callable(method): + raise TypeError( + f"descriptor declares capability {capability!r}, but driver lacks " + f"callable method {method_name}" + ) + + +def _validate_cw_configure_feature(extensions: RfSourceDescriptorExtensions) -> None: + feature = next( + (item for item in extensions.features if item.feature is RfFeature.CW), + None, + ) + if feature is None or RfFeatureDirection.CONFIGURE not in feature.directions: + raise ConfigError( + "rf_source.cw_configure requires an RF CW feature with configure direction" + ) + if not isinstance(feature.profile, RfCwProfile): # defensive: extensions validates this. + raise ConfigError("rf_source.cw_configure requires an RF CW profile") + if not (feature.profile.frequency_configurable or feature.profile.power_configurable): + raise ConfigError( + "rf_source.cw_configure requires a configurable RF CW frequency or power field" + ) + + +def _validate_trigger_snapshot_feature(extensions: RfSourceDescriptorExtensions) -> None: + feature = next( + (item for item in extensions.features if item.feature is RfFeature.TRIGGER), + None, + ) + if feature is None or RfFeatureDirection.READ not in feature.directions: + raise ConfigError( + "rf_source.trigger_snapshot requires an RF trigger feature with read direction" + ) + if not isinstance(feature.profile, RfTriggerProfile): # defensive: extensions validates this. + raise ConfigError("rf_source.trigger_snapshot requires an RF trigger profile") + if not feature.profile.state_readable: + raise ConfigError("rf_source.trigger_snapshot requires readable RF trigger state") + + +def _validate_output_feature(extensions: RfSourceDescriptorExtensions) -> None: + feature = next( + (item for item in extensions.features if item.feature is RfFeature.OUTPUT), + None, + ) + if ( + feature is None + or RfFeatureDirection.ENABLE not in feature.directions + or RfFeatureDirection.DISABLE not in feature.directions + ): + raise ConfigError( + "rf_source.output requires matching RF output ENABLE and DISABLE directions" + ) + if not isinstance(feature.profile, RfOutputProfile): # defensive: extensions validates this. + raise ConfigError("rf_source.output requires an RF output profile") + if not feature.profile.output_readable: + raise ConfigError("rf_source.output requires readable RF output state") + + +def _validate_modulation_configure_feature(extensions: RfSourceDescriptorExtensions) -> None: + feature = next( + (item for item in extensions.features if item.feature is RfFeature.MODULATION), + None, + ) + if ( + feature is None + or RfFeatureDirection.CONFIGURE not in feature.directions + or RfFeatureDirection.READ not in feature.directions + ): + raise ConfigError( + "rf_source.modulation_configure requires an RF modulation feature with " + "configure and read directions" + ) + if not isinstance(feature.profile, RfModulationProfile): # defensive: extensions validates this. + raise ConfigError("rf_source.modulation_configure requires an RF modulation profile") + if not feature.profile.configuration_readable or not feature.profile.mode_profiles: + raise ConfigError( + "rf_source.modulation_configure requires readable bounded modulation mode profiles" + ) + + +def _validate_modulation_disable_feature(extensions: RfSourceDescriptorExtensions) -> None: + feature = next( + (item for item in extensions.features if item.feature is RfFeature.MODULATION), + None, + ) + if ( + feature is None + or RfFeatureDirection.DISABLE not in feature.directions + or RfFeatureDirection.READ not in feature.directions + ): + raise ConfigError( + "rf_source.modulation_disable requires an RF modulation feature with " + "disable and read directions" + ) + if not isinstance(feature.profile, RfModulationProfile): # defensive: extensions validates this. + raise ConfigError("rf_source.modulation_disable requires an RF modulation profile") + if not feature.profile.state_readable: + raise ConfigError("rf_source.modulation_disable requires readable RF modulation state") + + +def _validate_modulated_output_enable_feature(extensions: RfSourceDescriptorExtensions) -> None: + output_feature = next( + (item for item in extensions.features if item.feature is RfFeature.OUTPUT), + None, + ) + modulation_feature = next( + (item for item in extensions.features if item.feature is RfFeature.MODULATION), + None, + ) + feature = next( + (item for item in extensions.features if item.feature is RfFeature.MODULATED_OUTPUT), + None, + ) + if ( + output_feature is None + or RfFeatureDirection.ENABLE not in output_feature.directions + or RfFeatureDirection.DISABLE not in output_feature.directions + or not isinstance(output_feature.profile, RfOutputProfile) + or not output_feature.profile.output_readable + ): + raise ConfigError( + "rf_source.modulated_output_enable requires a readable base RF output profile" + ) + if ( + modulation_feature is None + or RfFeatureDirection.CONFIGURE not in modulation_feature.directions + or RfFeatureDirection.READ not in modulation_feature.directions + or not isinstance(modulation_feature.profile, RfModulationProfile) + or not modulation_feature.profile.configuration_readable + or not modulation_feature.profile.mode_profiles + ): + raise ConfigError( + "rf_source.modulated_output_enable requires a readable configurable modulation profile" + ) + if ( + feature is None + or RfFeatureDirection.ENABLE not in feature.directions + or not isinstance(feature.profile, RfModulatedOutputProfile) + ): + raise ConfigError( + "rf_source.modulated_output_enable requires a modulated-output feature with enable direction" + ) + if not set(feature.port_ids) <= set(output_feature.port_ids): + raise ConfigError( + "rf_source.modulated_output_enable ports must also declare the base output feature" + ) + if not set(feature.port_ids) <= set(modulation_feature.port_ids): + raise ConfigError( + "rf_source.modulated_output_enable ports must also declare the modulation feature" + ) + base_profiles = { + profile.kind: profile for profile in modulation_feature.profile.mode_profiles + } + for profile in feature.profile.mode_profiles: + base_profile = base_profiles.get(profile.kind) + if base_profile is None: + raise ConfigError( + "rf_source.modulated_output_enable profile must be declared by modulation" + ) + if ( + profile.source is not base_profile.source + or profile.waveform is not base_profile.waveform + or profile.value_unit is not base_profile.value_unit + or profile.value_min < base_profile.value_min + or profile.value_max > base_profile.value_max + or profile.internal_frequency_min_hz < base_profile.internal_frequency_min_hz + or profile.internal_frequency_max_hz > base_profile.internal_frequency_max_hz + ): + raise ConfigError( + "rf_source.modulated_output_enable profile must be a subset of modulation" + ) + topology = {port.port_id: port for port in extensions.topology.ports} + for port_id in feature.port_ids: + port = topology[port_id] + if not port.power_min_dbm <= feature.profile.maximum_power_dbm <= port.power_max_dbm: + raise ConfigError( + "rf_source.modulated_output_enable maximum_power_dbm must be within each port range" + ) + + +def _validate_pulse_configure_feature(extensions: RfSourceDescriptorExtensions) -> None: + feature = next( + (item for item in extensions.features if item.feature is RfFeature.PULSE), + None, + ) + if ( + feature is None + or RfFeatureDirection.CONFIGURE not in feature.directions + or RfFeatureDirection.READ not in feature.directions + ): + raise ConfigError( + "rf_source.pulse_configure requires an RF pulse feature with configure and read directions" + ) + if not isinstance(feature.profile, RfPulseProfile): # defensive: extensions validates this. + raise ConfigError("rf_source.pulse_configure requires an RF pulse profile") + if not feature.profile.configuration_readable or not feature.profile.mode_profiles: + raise ConfigError( + "rf_source.pulse_configure requires readable bounded RF pulse mode profiles" + ) + + +def _validate_pulse_output_feature(extensions: RfSourceDescriptorExtensions) -> None: + pulse_feature = next( + (item for item in extensions.features if item.feature is RfFeature.PULSE), + None, + ) + feature = next( + (item for item in extensions.features if item.feature is RfFeature.PULSE_OUTPUT), + None, + ) + if ( + pulse_feature is None + or RfFeatureDirection.CONFIGURE not in pulse_feature.directions + or RfFeatureDirection.READ not in pulse_feature.directions + or not isinstance(pulse_feature.profile, RfPulseProfile) + or not pulse_feature.profile.configuration_readable + ): + raise ConfigError( + "rf_source.pulse_output requires a readable configurable base pulse profile" + ) + if ( + feature is None + or RfFeatureDirection.READ not in feature.directions + or RfFeatureDirection.ENABLE not in feature.directions + or RfFeatureDirection.DISABLE not in feature.directions + or not isinstance(feature.profile, RfPulseOutputProfile) + or not feature.profile.output_readable + ): + raise ConfigError( + "rf_source.pulse_output requires a readable Pulse-output feature with " + "enable and disable directions" + ) + if not set(feature.port_ids) <= set(pulse_feature.port_ids): + raise ConfigError( + "rf_source.pulse_output ports must also declare the base pulse feature" + ) + profile = feature.profile + mode_profile = next( + ( + item + for item in pulse_feature.profile.mode_profiles + if item.source is profile.source and item.mode is profile.mode + ), + None, + ) + if mode_profile is None: + raise ConfigError( + "rf_source.pulse_output profile must reference a declared base pulse mode" + ) + if profile.polarity not in mode_profile.polarities: + raise ConfigError( + "rf_source.pulse_output profile polarity must be declared by the base pulse mode" + ) + if not mode_profile.period_min_s <= profile.period_s <= mode_profile.period_max_s: + raise ConfigError( + "rf_source.pulse_output profile period_s must be within the base pulse range" + ) + if not mode_profile.width_min_s <= profile.width_s <= mode_profile.width_max_s: + raise ConfigError( + "rf_source.pulse_output profile width_s must be within the base pulse range" + ) + if profile.width_s > profile.period_s - mode_profile.minimum_off_time_s: + raise ConfigError( + "rf_source.pulse_output profile violates the base pulse minimum off time" + ) + + +def _validate_sweep_configure_feature(extensions: RfSourceDescriptorExtensions) -> None: + feature = next( + (item for item in extensions.features if item.feature is RfFeature.SWEEP), + None, + ) + if ( + feature is None + or RfFeatureDirection.CONFIGURE not in feature.directions + or RfFeatureDirection.READ not in feature.directions + ): + raise ConfigError( + "rf_source.sweep_configure requires an RF Sweep feature with configure and read directions" + ) + if not isinstance(feature.profile, RfSweepProfile): # defensive: extensions validates this. + raise ConfigError("rf_source.sweep_configure requires an RF Sweep profile") + if not feature.profile.configuration_readable or not feature.profile.mode_profiles: + raise ConfigError( + "rf_source.sweep_configure requires readable bounded RF Sweep mode profiles" + ) + + +def validate_rf_source_plugin_dependencies( + descriptor: object, + dependencies: Iterable[str], +) -> None: + """Cross-check an RF-source descriptor against its wheel metadata.""" + + if getattr(descriptor, "kind", None) != "rf_source": + return + _validate_rf_source_version_range(descriptor) + + requirements: list[Requirement] = [] + for dependency in dependencies: + if not isinstance(dependency, str): + raise ConfigError("RF source wheel dependency metadata must contain strings") + try: + requirement = Requirement(dependency) + except InvalidRequirement as exc: + raise ConfigError("RF source wheel has an invalid Requires-Dist entry") from exc + if canonicalize_name(requirement.name) == "wavebench" and ( + requirement.marker is None or requirement.marker.evaluate() + ): + requirements.append(requirement) + if len(requirements) != 1: + raise ConfigError( + "RF source wheel must declare exactly one active WaveBench dependency for its descriptor" + ) + requirement = requirements[0] + + try: + minimum = Version(getattr(descriptor, "wavebench_min_version", "")) + maximum = Version(getattr(descriptor, "wavebench_max_version", "")) + except (InvalidVersion, TypeError) as exc: # pragma: no cover - checked above + raise ConfigError("RF source descriptor versions must use valid PEP 440 syntax") from exc + specifiers = tuple(requirement.specifier) + has_floor = any( + item.operator == ">=" and Version(item.version) == minimum + for item in specifiers + ) + has_ceiling = any( + item.operator == "<" and Version(item.version) == maximum + for item in specifiers + ) + if not has_floor or not has_ceiling: + raise ConfigError( + "RF source wheel WaveBench dependency must explicitly include " + f">={minimum},<{maximum} to match the descriptor" + ) + if minimum not in requirement.specifier or maximum in requirement.specifier: + raise ConfigError( + "RF source wheel WaveBench dependency expands or excludes its descriptor interval" + ) + + +def _validate_rf_source_version_range(descriptor: object) -> None: + minimum_text = getattr(descriptor, "wavebench_min_version", "") + maximum_text = getattr(descriptor, "wavebench_max_version", "") + try: + minimum = Version(minimum_text) + maximum = Version(maximum_text) + required = Version(RF_SOURCE_SNAPSHOT_MIN_CORE_VERSION) + except (InvalidVersion, TypeError) as exc: + raise ConfigError("RF source descriptor versions must use valid PEP 440 syntax") from exc + if minimum >= maximum: + raise ConfigError("RF source descriptor version range must satisfy min < max") + if minimum < required: + raise ConfigError( + "RF source descriptors require wavebench_min_version >= " + f"{RF_SOURCE_SNAPSHOT_MIN_CORE_VERSION}" + ) + + +__all__ = [ + "RF_SOURCE_CAPABILITY_METHODS", + "validate_rf_source_descriptor", + "validate_rf_source_plugin_dependencies", +] diff --git a/src/wavebench/instruments/rf_source_extensions.py b/src/wavebench/instruments/rf_source_extensions.py new file mode 100644 index 0000000..7ccb499 --- /dev/null +++ b/src/wavebench/instruments/rf_source_extensions.py @@ -0,0 +1,2189 @@ +"""Public contracts for RF signal-source plugins. + +This module deliberately models radio-frequency sources independently from +the ``source`` domain used by function and arbitrary waveform generators. +It contains static descriptors, typed requests/results, and snapshots; it +never opens a transport or sends SCPI commands. +""" + +from __future__ import annotations + +from dataclasses import dataclass, fields, is_dataclass +from enum import StrEnum +from hashlib import sha256 +import json +from math import isfinite +import re +from typing import Generic, Literal, Protocol, TypeAlias, TypeVar, runtime_checkable + +from .contracts import InstrumentDriver + + +RF_SOURCE_CONTRACT_VERSION = "wavebench.rf_source.v1" +RF_SOURCE_SNAPSHOT_SCHEMA = "wavebench.rf_source.snapshot.v1" +RF_SOURCE_MODULATION_STATE_SCHEMA = "wavebench.rf_source.modulation_state.v1" +RF_SOURCE_MODULATION_SNAPSHOT_SCHEMA = "wavebench.rf_source.modulation_snapshot.v1" +RF_SOURCE_PULSE_SNAPSHOT_SCHEMA = "wavebench.rf_source.pulse_snapshot.v1" +RF_SOURCE_PULSE_OUTPUT_SNAPSHOT_SCHEMA = "wavebench.rf_source.pulse_output_snapshot.v1" +RF_SOURCE_SWEEP_SNAPSHOT_SCHEMA = "wavebench.rf_source.sweep_snapshot.v1" +RF_SOURCE_TRIGGER_SNAPSHOT_SCHEMA = "wavebench.rf_source.trigger_snapshot.v1" +RF_SOURCE_OPERATION_ARTIFACT_SCHEMA = "wavebench.rf_source.operation.v1" +RF_SOURCE_SNAPSHOT_MIN_CORE_VERSION = "0.8.25" + +_SAFE_TOKEN = re.compile(r"^[A-Za-z0-9_.:-]{1,96}$") + + +def _require_bool(value: object, label: str) -> None: + if not isinstance(value, bool): + raise ValueError(f"{label} must be boolean") + + +def _require_token(value: object, label: str) -> None: + if not isinstance(value, str) or _SAFE_TOKEN.fullmatch(value) is None: + raise ValueError(f"{label} must be a short safe token") + + +def _require_finite( + value: object, + label: str, + *, + minimum: float | None = None, + maximum: float | None = None, +) -> None: + if isinstance(value, bool) or not isinstance(value, (int, float)) or not isfinite(value): + raise ValueError(f"{label} must be finite") + if minimum is not None and value < minimum: + raise ValueError(f"{label} must be >= {minimum}") + if maximum is not None and value > maximum: + raise ValueError(f"{label} must be <= {maximum}") + + +def _require_integer( + value: object, + label: str, + *, + minimum: int | None = None, + maximum: int | None = None, +) -> None: + if isinstance(value, bool) or not isinstance(value, int): + raise ValueError(f"{label} must be an integer") + if minimum is not None and value < minimum: + raise ValueError(f"{label} must be >= {minimum}") + if maximum is not None and value > maximum: + raise ValueError(f"{label} must be <= {maximum}") + + +def _require_enum_tuple( + values: object, + enum_type: type[StrEnum], + label: str, + *, + allow_empty: bool = False, +) -> None: + if not isinstance(values, tuple): + raise ValueError(f"{label} must be a tuple") + if not allow_empty and not values: + raise ValueError(f"{label} must not be empty") + if any(not isinstance(value, enum_type) for value in values): + raise ValueError(f"{label} entries have an invalid type") + names = tuple(value.value for value in values) + if len(set(names)) != len(names) or tuple(sorted(names)) != names: + raise ValueError(f"{label} must be sorted by value and unique") + + +def _require_token_tuple( + values: object, + label: str, + *, + allow_empty: bool = False, +) -> None: + if not isinstance(values, tuple): + raise ValueError(f"{label} must be a tuple") + if not allow_empty and not values: + raise ValueError(f"{label} must not be empty") + for value in values: + _require_token(value, label) + if len(set(values)) != len(values) or tuple(sorted(values)) != values: + raise ValueError(f"{label} must be sorted and unique") + + +def _contains_nonfinite(value: object) -> bool: + if isinstance(value, float): + return not isfinite(value) + if isinstance(value, tuple): + return any(_contains_nonfinite(item) for item in value) + if is_dataclass(value) and not isinstance(value, type): + return any(_contains_nonfinite(getattr(value, item.name)) for item in fields(value)) + return False + + +class RfAvailability(StrEnum): + VALUE = "value" + UNSUPPORTED = "unsupported" + NOT_APPLICABLE = "not_applicable" + UNAVAILABLE = "unavailable" + UNKNOWN = "unknown" + + +class RfReasonCode(StrEnum): + DESCRIPTOR_UNSUPPORTED = "descriptor_unsupported" + NOT_REQUESTED = "not_requested" + RESPONSE_MISSING_FIELD = "response_missing_field" + RESPONSE_INVALID_VALUE = "response_invalid_value" + DRIVER_SKIPPED_OPTIONAL = "driver_skipped_optional" + PROTOCOL_RECORD_INVALID = "protocol_record_invalid" + SESSION_NOT_HEALTHY = "session_not_healthy" + UNKNOWN_STATE = "unknown_state" + + +class RfModulationState(StrEnum): + DISABLED = "disabled" + ENABLED = "enabled" + + +class RfModulationKind(StrEnum): + AM = "am" + FM = "fm" + PM = "pm" + + +class RfModulationSource(StrEnum): + """Known current modulation-source values. + + M3 configuration profiles remain internal-only. ``EXTERNAL`` exists only + so a driver can report an inactive device state that M3 will explicitly + replace with the requested internal source. + """ + + INTERNAL = "internal" + EXTERNAL = "external" + + +class RfModulationWaveform(StrEnum): + """Known current internal modulation-waveform values. + + M3 configuration profiles remain sine-only. ``SQUARE`` exists only for + typed readback of a current inactive device state before M3 replaces it. + """ + + SINE = "sine" + SQUARE = "square" + + +class RfModulationValueUnit(StrEnum): + PERCENT = "percent" + HZ = "hz" + RAD = "rad" + + +class RfPulseState(StrEnum): + DISABLED = "disabled" + ENABLED = "enabled" + + +class RfPulseSource(StrEnum): + INTERNAL = "internal" + EXTERNAL = "external" + + +class RfPulseMode(StrEnum): + SINGLE = "single" + TRAIN = "train" + + +class RfPulsePolarity(StrEnum): + NORMAL = "normal" + INVERTED = "inverted" + + +class RfPulseOutputDirection(StrEnum): + """Physical direction declared by the bounded rear-panel Pulse contract.""" + + OUTPUT = "output" + + +class RfPulseTriggerMode(StrEnum): + """Logical Pulse trigger modes reported by a device configuration query.""" + + AUTOMATIC = "automatic" + BUS = "bus" + EXTERNAL = "external" + EXTERNAL_GATE = "external_gate" + KEY = "key" + + +class RfExternalTriggerEdge(StrEnum): + NEGATIVE = "negative" + POSITIVE = "positive" + + +class RfExternalGatePolarity(StrEnum): + INVERTED = "inverted" + NORMAL = "normal" + + +class RfSweepState(StrEnum): + DISABLED = "disabled" + ENABLED = "enabled" + + +class RfSweepType(StrEnum): + STEP = "step" + + +class RfSweepDirection(StrEnum): + FORWARD = "forward" + + +class RfSweepShape(StrEnum): + RAMP = "ramp" + + +class RfSweepSpacing(StrEnum): + LINEAR = "linear" + + +class RfSweepMode(StrEnum): + CONTINUOUS = "continuous" + SINGLE = "single" + + +class RfSweepTriggerMode(StrEnum): + AUTOMATIC = "automatic" + BUS = "bus" + EXTERNAL = "external" + KEY = "key" + + +class RfFeature(StrEnum): + CW = "cw" + MODULATION = "modulation" + MODULATED_OUTPUT = "modulated_output" + OUTPUT = "output" + PULSE = "pulse" + PULSE_OUTPUT = "pulse_output" + SWEEP = "sweep" + TRIGGER = "trigger" + + +class RfFeatureDirection(StrEnum): + ARM = "arm" + CONFIGURE = "configure" + DISABLE = "disable" + ENABLE = "enable" + FIRE = "fire" + READ = "read" + STOP = "stop" + TRIGGER = "trigger" + + +T = TypeVar("T") + + +@dataclass(frozen=True, slots=True) +class RfObserved(Generic[T]): + """A typed value or a stable, non-sensitive reason why it is unavailable.""" + + availability: RfAvailability + value: T | None = None + reason_code: RfReasonCode | None = None + + def __post_init__(self) -> None: + if not isinstance(self.availability, RfAvailability): + raise ValueError("RF observation availability has an invalid type") + if self.availability is RfAvailability.VALUE: + if self.value is None: + raise ValueError("VALUE RF observations must carry a value") + if self.reason_code is not None: + raise ValueError("VALUE RF observations cannot carry a reason_code") + if _contains_nonfinite(self.value): + raise ValueError("VALUE RF observations cannot contain non-finite floats") + else: + if self.value is not None: + raise ValueError("non-VALUE RF observations cannot carry a value") + if not isinstance(self.reason_code, RfReasonCode): + raise ValueError("non-VALUE RF observations require a registered reason_code") + + @classmethod + def value_of(cls, value: T) -> "RfObserved[T]": + return cls(availability=RfAvailability.VALUE, value=value) + + @classmethod + def missing( + cls, + availability: RfAvailability, + reason_code: RfReasonCode, + ) -> "RfObserved[T]": + if availability is RfAvailability.VALUE: + raise ValueError("missing RF observations cannot use VALUE") + return cls(availability=availability, reason_code=reason_code) + + +@dataclass(frozen=True, slots=True) +class RfOutputPortProfile: + port_id: str + frequency_min_hz: float + frequency_max_hz: float + power_min_dbm: float + power_max_dbm: float + power_reference_impedance_ohm: float + + def __post_init__(self) -> None: + _require_token(self.port_id, "RF output port_id") + _require_finite(self.frequency_min_hz, "RF frequency_min_hz", minimum=0.0) + _require_finite( + self.frequency_max_hz, + "RF frequency_max_hz", + minimum=self.frequency_min_hz, + ) + _require_finite(self.power_min_dbm, "RF power_min_dbm") + _require_finite( + self.power_max_dbm, + "RF power_max_dbm", + minimum=self.power_min_dbm, + ) + _require_finite( + self.power_reference_impedance_ohm, + "RF power_reference_impedance_ohm", + minimum=0.0, + ) + if self.power_reference_impedance_ohm <= 0.0: + raise ValueError("RF power_reference_impedance_ohm must be positive") + + +@dataclass(frozen=True, slots=True) +class RfSourceTopology: + ports: tuple[RfOutputPortProfile, ...] + + def __post_init__(self) -> None: + if not isinstance(self.ports, tuple) or not self.ports or any( + not isinstance(port, RfOutputPortProfile) for port in self.ports + ): + raise ValueError("RF source topology ports have an invalid type") + port_ids = tuple(port.port_id for port in self.ports) + if len(set(port_ids)) != len(port_ids) or tuple(sorted(port_ids)) != port_ids: + raise ValueError("RF source topology ports must be sorted and unique") + + +@dataclass(frozen=True, slots=True) +class RfProtectionStatus: + active_codes: tuple[str, ...] + + def __post_init__(self) -> None: + _require_token_tuple(self.active_codes, "RF protection active_codes", allow_empty=True) + + +@dataclass(frozen=True, slots=True) +class RfProtectionConditionPolicy: + code: str + blocks_output_enable: bool + + def __post_init__(self) -> None: + _require_token(self.code, "RF protection condition code") + _require_bool(self.blocks_output_enable, "RF protection blocks_output_enable") + + +@dataclass(frozen=True, slots=True) +class RfPortSnapshot: + port_id: str + frequency_hz: RfObserved[float] + power_dbm: RfObserved[float] + output_enabled: RfObserved[bool] + modulation: RfObserved[RfModulationState] + pulse: RfObserved[RfPulseState] + sweep: RfObserved[RfSweepState] + + def __post_init__(self) -> None: + _require_token(self.port_id, "RF port snapshot port_id") + _require_observed_number(self.frequency_hz, "RF port snapshot frequency_hz", minimum=0.0) + _require_observed_number(self.power_dbm, "RF port snapshot power_dbm") + _require_observed_value(self.output_enabled, bool, "RF port snapshot output_enabled") + _require_observed_value( + self.modulation, + RfModulationState, + "RF port snapshot modulation", + ) + _require_observed_value(self.pulse, RfPulseState, "RF port snapshot pulse") + _require_observed_value(self.sweep, RfSweepState, "RF port snapshot sweep") + + +@dataclass(frozen=True, slots=True) +class RfSourceSnapshot: + ports: tuple[RfPortSnapshot, ...] + protection: RfObserved[RfProtectionStatus] + + def __post_init__(self) -> None: + if not isinstance(self.ports, tuple) or not self.ports or any( + not isinstance(port, RfPortSnapshot) for port in self.ports + ): + raise ValueError("RF source snapshot ports have an invalid type") + port_ids = tuple(port.port_id for port in self.ports) + if len(set(port_ids)) != len(port_ids) or tuple(sorted(port_ids)) != port_ids: + raise ValueError("RF source snapshot ports must be sorted and unique") + _require_observed_value( + self.protection, + RfProtectionStatus, + "RF source snapshot protection", + ) + + def as_dict(self) -> dict[str, object]: + return rf_source_snapshot_document(self) + + +@dataclass(frozen=True, slots=True) +class RfModulationModeProfile: + """One bounded, readable internal-sine modulation mode declaration.""" + + kind: RfModulationKind + value_unit: RfModulationValueUnit + value_min: float + value_max: float + internal_frequency_min_hz: float + internal_frequency_max_hz: float + source: RfModulationSource = RfModulationSource.INTERNAL + waveform: RfModulationWaveform = RfModulationWaveform.SINE + + def __post_init__(self) -> None: + if not isinstance(self.kind, RfModulationKind): + raise ValueError("RF modulation mode kind has an invalid type") + if not isinstance(self.value_unit, RfModulationValueUnit): + raise ValueError("RF modulation mode value_unit has an invalid type") + if not isinstance(self.source, RfModulationSource): + raise ValueError("RF modulation mode source has an invalid type") + if not isinstance(self.waveform, RfModulationWaveform): + raise ValueError("RF modulation mode waveform has an invalid type") + if self.source is not RfModulationSource.INTERNAL: + raise ValueError("RF modulation mode profiles must use the internal source") + if self.waveform is not RfModulationWaveform.SINE: + raise ValueError("RF modulation mode profiles must use the sine waveform") + _require_finite(self.value_min, "RF modulation mode value_min") + _require_finite( + self.value_max, + "RF modulation mode value_max", + minimum=self.value_min, + ) + _require_finite( + self.internal_frequency_min_hz, + "RF modulation mode internal_frequency_min_hz", + minimum=0.0, + ) + _require_finite( + self.internal_frequency_max_hz, + "RF modulation mode internal_frequency_max_hz", + minimum=self.internal_frequency_min_hz, + ) + + +@dataclass(frozen=True, slots=True) +class RfCwProfile: + frequency_readable: bool + power_readable: bool + frequency_configurable: bool = False + power_configurable: bool = False + + def __post_init__(self) -> None: + _require_bool(self.frequency_readable, "RF CW frequency_readable") + _require_bool(self.power_readable, "RF CW power_readable") + _require_bool(self.frequency_configurable, "RF CW frequency_configurable") + _require_bool(self.power_configurable, "RF CW power_configurable") + if self.frequency_configurable and not self.frequency_readable: + raise ValueError("RF configurable frequency requires readable frequency") + if self.power_configurable and not self.power_readable: + raise ValueError("RF configurable power requires readable power") + + +@dataclass(frozen=True, slots=True) +class RfOutputProfile: + output_readable: bool + + def __post_init__(self) -> None: + _require_bool(self.output_readable, "RF output output_readable") + + +@dataclass(frozen=True, slots=True) +class RfModulationProfile: + state_readable: bool + configuration_readable: bool = False + mode_profiles: tuple[RfModulationModeProfile, ...] = () + + def __post_init__(self) -> None: + _require_bool(self.state_readable, "RF modulation state_readable") + _require_bool(self.configuration_readable, "RF modulation configuration_readable") + if not isinstance(self.mode_profiles, tuple) or any( + not isinstance(profile, RfModulationModeProfile) for profile in self.mode_profiles + ): + raise ValueError("RF modulation mode_profiles have an invalid type") + kinds = tuple(profile.kind for profile in self.mode_profiles) + if len(set(kinds)) != len(kinds) or tuple(sorted(kinds, key=lambda item: item.value)) != kinds: + raise ValueError("RF modulation mode_profiles must be sorted and unique") + if self.configuration_readable and not self.state_readable: + raise ValueError("RF modulation configuration readback requires readable state") + + +@dataclass(frozen=True, slots=True) +class RfModulatedOutputProfile: + """Bounded profiles that may enable an already modulated RF output. + + This is separate from :class:`RfOutputProfile`: ordinary RF output control + remains valid only while modulation is disabled. A descriptor must opt in + only after independent evidence covers the active modulation state, output + limits, and recovery behavior. + """ + + maximum_power_dbm: float + mode_profiles: tuple[RfModulationModeProfile, ...] + + def __post_init__(self) -> None: + _require_finite(self.maximum_power_dbm, "RF modulated-output maximum_power_dbm") + if not isinstance(self.mode_profiles, tuple) or not self.mode_profiles or any( + not isinstance(profile, RfModulationModeProfile) for profile in self.mode_profiles + ): + raise ValueError("RF modulated-output mode_profiles have an invalid type") + kinds = tuple(profile.kind for profile in self.mode_profiles) + if len(set(kinds)) != len(kinds) or tuple(sorted(kinds, key=lambda item: item.value)) != kinds: + raise ValueError("RF modulated-output mode_profiles must be sorted and unique") + + +@dataclass(frozen=True, slots=True) +class RfPulseProfile: + state_readable: bool + configuration_readable: bool = False + mode_profiles: tuple["RfPulseModeProfile", ...] = () + + def __post_init__(self) -> None: + _require_bool(self.state_readable, "RF pulse state_readable") + _require_bool(self.configuration_readable, "RF pulse configuration_readable") + if not isinstance(self.mode_profiles, tuple) or any( + not isinstance(profile, RfPulseModeProfile) for profile in self.mode_profiles + ): + raise ValueError("RF pulse mode_profiles have an invalid type") + identities = tuple((profile.source.value, profile.mode.value) for profile in self.mode_profiles) + if len(set(identities)) != len(identities) or tuple(sorted(identities)) != identities: + raise ValueError("RF pulse mode_profiles must be sorted and unique") + if self.configuration_readable and not self.state_readable: + raise ValueError("RF pulse configuration readback requires readable state") + + +@dataclass(frozen=True, slots=True) +class RfPulseModeProfile: + """One bounded pulse profile that can be configured while RF remains OFF.""" + + source: RfPulseSource + mode: RfPulseMode + polarities: tuple[RfPulsePolarity, ...] + period_min_s: float + period_max_s: float + width_min_s: float + width_max_s: float + minimum_off_time_s: float + + def __post_init__(self) -> None: + if not isinstance(self.source, RfPulseSource): + raise ValueError("RF pulse mode source has an invalid type") + if not isinstance(self.mode, RfPulseMode): + raise ValueError("RF pulse mode has an invalid type") + if self.source is not RfPulseSource.INTERNAL: + raise ValueError("RF pulse mode profiles must use the internal source") + if self.mode is not RfPulseMode.SINGLE: + raise ValueError("RF pulse mode profiles must use the single mode") + _require_enum_tuple(self.polarities, RfPulsePolarity, "RF pulse mode polarities") + _require_finite(self.period_min_s, "RF pulse mode period_min_s", minimum=0.0) + _require_finite( + self.period_max_s, + "RF pulse mode period_max_s", + minimum=self.period_min_s, + ) + _require_finite(self.width_min_s, "RF pulse mode width_min_s", minimum=0.0) + _require_finite( + self.width_max_s, + "RF pulse mode width_max_s", + minimum=self.width_min_s, + ) + _require_finite( + self.minimum_off_time_s, + "RF pulse mode minimum_off_time_s", + minimum=0.0, + ) + if self.minimum_off_time_s <= 0.0: + raise ValueError("RF pulse mode minimum_off_time_s must be positive") + if self.period_max_s < self.width_min_s + self.minimum_off_time_s: + raise ValueError("RF pulse mode ranges cannot satisfy the minimum off time") + + +@dataclass(frozen=True, slots=True) +class RfPulseOutputProfile: + """One bounded physical Pulse-output interface tied to an RF port. + + This contract intentionally models only a proven output direction. A + connector with an ``IN/OUT`` label does not imply that input behavior is + declared. The profile fixes both the documented electrical characteristics + and the exact internal Pulse profile that must be read back before the + physical output can be enabled. + """ + + interface_id: str + direction: RfPulseOutputDirection + output_readable: bool + low_level_v: float + high_level_v: float + output_impedance_ohm: float + source: RfPulseSource + mode: RfPulseMode + period_s: float + width_s: float + polarity: RfPulsePolarity + pulse_state: RfPulseState = RfPulseState.DISABLED + + def __post_init__(self) -> None: + _require_token(self.interface_id, "RF pulse-output interface_id") + if not isinstance(self.direction, RfPulseOutputDirection): + raise ValueError("RF pulse-output direction has an invalid type") + if self.direction is not RfPulseOutputDirection.OUTPUT: + raise ValueError("RF pulse-output profiles must declare output direction") + _require_bool(self.output_readable, "RF pulse-output output_readable") + _require_finite(self.low_level_v, "RF pulse-output low_level_v", minimum=0.0) + _require_finite( + self.high_level_v, + "RF pulse-output high_level_v", + minimum=self.low_level_v, + ) + if self.high_level_v <= self.low_level_v: + raise ValueError("RF pulse-output high_level_v must exceed low_level_v") + _require_finite( + self.output_impedance_ohm, + "RF pulse-output output_impedance_ohm", + minimum=0.0, + ) + if self.output_impedance_ohm <= 0.0: + raise ValueError("RF pulse-output output_impedance_ohm must be positive") + if self.source is not RfPulseSource.INTERNAL: + raise ValueError("RF pulse-output profiles must use the internal source") + if self.mode is not RfPulseMode.SINGLE: + raise ValueError("RF pulse-output profiles must use the single mode") + _require_finite(self.period_s, "RF pulse-output period_s", minimum=0.0) + _require_finite(self.width_s, "RF pulse-output width_s", minimum=0.0) + if self.period_s <= 0.0 or self.width_s <= 0.0: + raise ValueError("RF pulse-output period_s and width_s must be positive") + if self.width_s >= self.period_s: + raise ValueError("RF pulse-output width_s must be less than period_s") + if not isinstance(self.polarity, RfPulsePolarity): + raise ValueError("RF pulse-output polarity has an invalid type") + if self.pulse_state is not RfPulseState.DISABLED: + raise ValueError("RF pulse-output profiles must keep Pulse modulation disabled") + + +@dataclass(frozen=True, slots=True) +class RfSweepProfile: + state_readable: bool + configuration_readable: bool = False + mode_profiles: tuple["RfSweepModeProfile", ...] = () + + def __post_init__(self) -> None: + _require_bool(self.state_readable, "RF sweep state_readable") + _require_bool(self.configuration_readable, "RF sweep configuration_readable") + if not isinstance(self.mode_profiles, tuple) or any( + not isinstance(profile, RfSweepModeProfile) for profile in self.mode_profiles + ): + raise ValueError("RF sweep mode_profiles have an invalid type") + identities = tuple( + ( + profile.sweep_type.value, + profile.direction.value, + profile.shape.value, + profile.spacing.value, + ) + for profile in self.mode_profiles + ) + if len(set(identities)) != len(identities) or tuple(sorted(identities)) != identities: + raise ValueError("RF sweep mode_profiles must be sorted and unique") + if self.configuration_readable and not self.state_readable: + raise ValueError("RF sweep configuration readback requires readable state") + + +@dataclass(frozen=True, slots=True) +class RfSweepModeProfile: + """One bounded frequency-only Step Sweep profile that remains disabled.""" + + sweep_type: RfSweepType + direction: RfSweepDirection + shape: RfSweepShape + spacing: RfSweepSpacing + frequency_min_hz: float + frequency_max_hz: float + points_min: int + points_max: int + dwell_min_s: float + dwell_max_s: float + + def __post_init__(self) -> None: + if not isinstance(self.sweep_type, RfSweepType): + raise ValueError("RF sweep mode type has an invalid type") + if not isinstance(self.direction, RfSweepDirection): + raise ValueError("RF sweep mode direction has an invalid type") + if not isinstance(self.shape, RfSweepShape): + raise ValueError("RF sweep mode shape has an invalid type") + if not isinstance(self.spacing, RfSweepSpacing): + raise ValueError("RF sweep mode spacing has an invalid type") + if self.sweep_type is not RfSweepType.STEP: + raise ValueError("RF sweep mode profiles must use Step Sweep") + if self.direction is not RfSweepDirection.FORWARD: + raise ValueError("RF sweep mode profiles must use the forward direction") + if self.shape is not RfSweepShape.RAMP: + raise ValueError("RF sweep mode profiles must use ramp shape") + if self.spacing is not RfSweepSpacing.LINEAR: + raise ValueError("RF sweep mode profiles must use linear spacing") + _require_finite(self.frequency_min_hz, "RF sweep mode frequency_min_hz", minimum=0.0) + _require_finite( + self.frequency_max_hz, + "RF sweep mode frequency_max_hz", + minimum=self.frequency_min_hz, + ) + _require_integer(self.points_min, "RF sweep mode points_min", minimum=2) + _require_integer( + self.points_max, + "RF sweep mode points_max", + minimum=self.points_min, + ) + _require_finite(self.dwell_min_s, "RF sweep mode dwell_min_s", minimum=0.0) + _require_finite( + self.dwell_max_s, + "RF sweep mode dwell_max_s", + minimum=self.dwell_min_s, + ) + if self.dwell_min_s <= 0.0: + raise ValueError("RF sweep mode dwell_min_s must be positive") + + +@dataclass(frozen=True, slots=True) +class RfTriggerProfile: + """Complete read-only trigger-configuration profile for one RF output. + + The profile describes logical Pulse and Sweep trigger configuration that a + driver can read. It deliberately does not describe a physical trigger or + sync connector, its direction, or electrical characteristics; those need a + separate A5 physical-interface contract before any write or fire operation. + """ + + state_readable: bool + pulse_trigger_modes: tuple[RfPulseTriggerMode, ...] = () + pulse_external_trigger_edges: tuple[RfExternalTriggerEdge, ...] = () + pulse_external_gate_polarities: tuple[RfExternalGatePolarity, ...] = () + sweep_modes: tuple[RfSweepMode, ...] = () + sweep_period_trigger_modes: tuple[RfSweepTriggerMode, ...] = () + sweep_point_trigger_modes: tuple[RfSweepTriggerMode, ...] = () + + def __post_init__(self) -> None: + _require_bool(self.state_readable, "RF trigger state_readable") + fields = ( + (self.pulse_trigger_modes, RfPulseTriggerMode, "RF trigger pulse_trigger_modes"), + ( + self.pulse_external_trigger_edges, + RfExternalTriggerEdge, + "RF trigger pulse_external_trigger_edges", + ), + ( + self.pulse_external_gate_polarities, + RfExternalGatePolarity, + "RF trigger pulse_external_gate_polarities", + ), + (self.sweep_modes, RfSweepMode, "RF trigger sweep_modes"), + ( + self.sweep_period_trigger_modes, + RfSweepTriggerMode, + "RF trigger sweep_period_trigger_modes", + ), + ( + self.sweep_point_trigger_modes, + RfSweepTriggerMode, + "RF trigger sweep_point_trigger_modes", + ), + ) + for values, enum_type, label in fields: + _require_enum_tuple(values, enum_type, label, allow_empty=not self.state_readable) + if not self.state_readable and any(values for values, _, _ in fields): + raise ValueError("RF unreadable trigger profile cannot declare trigger states") + + +RfFeatureProfile: TypeAlias = ( + RfCwProfile + | RfOutputProfile + | RfModulationProfile + | RfModulatedOutputProfile + | RfPulseProfile + | RfPulseOutputProfile + | RfSweepProfile + | RfTriggerProfile +) + +_FEATURE_PROFILE_TYPES: dict[RfFeature, type[RfFeatureProfile]] = { + RfFeature.CW: RfCwProfile, + RfFeature.MODULATION: RfModulationProfile, + RfFeature.MODULATED_OUTPUT: RfModulatedOutputProfile, + RfFeature.OUTPUT: RfOutputProfile, + RfFeature.PULSE: RfPulseProfile, + RfFeature.PULSE_OUTPUT: RfPulseOutputProfile, + RfFeature.SWEEP: RfSweepProfile, + RfFeature.TRIGGER: RfTriggerProfile, +} + + +@dataclass(frozen=True, slots=True) +class RfFeatureCapability: + feature: RfFeature + directions: tuple[RfFeatureDirection, ...] + port_ids: tuple[str, ...] + profile: RfFeatureProfile + + def __post_init__(self) -> None: + if not isinstance(self.feature, RfFeature): + raise ValueError("RF feature has an invalid type") + _require_enum_tuple(self.directions, RfFeatureDirection, "RF feature directions") + _require_token_tuple(self.port_ids, "RF feature port_ids") + if not isinstance(self.profile, _FEATURE_PROFILE_TYPES[self.feature]): + raise ValueError("RF feature profile does not match feature") + + +@dataclass(frozen=True, slots=True) +class RfSourceDescriptorExtensions: + contract_version: Literal["wavebench.rf_source.v1"] + topology: RfSourceTopology + features: tuple[RfFeatureCapability, ...] = () + protection_conditions: tuple[RfProtectionConditionPolicy, ...] = () + + def __post_init__(self) -> None: + if self.contract_version != RF_SOURCE_CONTRACT_VERSION: + raise ValueError("RF source descriptor contract_version is unsupported") + if not isinstance(self.topology, RfSourceTopology): + raise ValueError("RF source descriptor topology has an invalid type") + if not isinstance(self.features, tuple) or any( + not isinstance(feature, RfFeatureCapability) for feature in self.features + ): + raise ValueError("RF source descriptor features have an invalid type") + feature_names = tuple(feature.feature.value for feature in self.features) + if len(set(feature_names)) != len(feature_names) or tuple(sorted(feature_names)) != feature_names: + raise ValueError("RF source descriptor features must be sorted and unique") + topology_port_ids = {port.port_id for port in self.topology.ports} + if any(not set(feature.port_ids) <= topology_port_ids for feature in self.features): + raise ValueError("RF source descriptor feature references an unknown port") + if not isinstance(self.protection_conditions, tuple) or any( + not isinstance(condition, RfProtectionConditionPolicy) + for condition in self.protection_conditions + ): + raise ValueError("RF source descriptor protection_conditions have an invalid type") + condition_codes = tuple(condition.code for condition in self.protection_conditions) + if len(set(condition_codes)) != len(condition_codes) or tuple(sorted(condition_codes)) != condition_codes: + raise ValueError("RF source descriptor protection_conditions must be sorted and unique") + + +@dataclass(frozen=True, slots=True) +class RfCwRequest: + """One OFF-only CW update for one explicitly addressed RF output port.""" + + port_id: str + frequency_hz: float | None = None + power_dbm: float | None = None + + def __post_init__(self) -> None: + _require_token(self.port_id, "RF CW request port_id") + frequency_requested = self.frequency_hz is not None + power_requested = self.power_dbm is not None + if frequency_requested == power_requested: + raise ValueError("RF CW request must set exactly one of frequency_hz or power_dbm") + if frequency_requested: + _require_finite( + self.frequency_hz, + "RF CW request frequency_hz", + minimum=0.0, + ) + if power_requested: + _require_finite(self.power_dbm, "RF CW request power_dbm") + + +@dataclass(frozen=True, slots=True) +class RfCwResult: + """A single CW field confirmed by an independent postcondition snapshot.""" + + port_id: str + frequency_hz: float | None = None + power_dbm: float | None = None + + def __post_init__(self) -> None: + _require_token(self.port_id, "RF CW result port_id") + frequency_confirmed = self.frequency_hz is not None + power_confirmed = self.power_dbm is not None + if frequency_confirmed == power_confirmed: + raise ValueError("RF CW result must confirm exactly one of frequency_hz or power_dbm") + if frequency_confirmed: + _require_finite( + self.frequency_hz, + "RF CW result frequency_hz", + minimum=0.0, + ) + if power_confirmed: + _require_finite(self.power_dbm, "RF CW result power_dbm") + + +def _validate_modulation_fields( + *, + kind: RfModulationKind, + depth_percent: float | None, + frequency_deviation_hz: float | None, + phase_deviation_rad: float | None, + label: str, +) -> None: + if not isinstance(kind, RfModulationKind): + raise ValueError(f"{label} kind has an invalid type") + fields = { + RfModulationKind.AM: depth_percent, + RfModulationKind.FM: frequency_deviation_hz, + RfModulationKind.PM: phase_deviation_rad, + } + if sum(value is not None for value in fields.values()) != 1 or fields[kind] is None: + raise ValueError(f"{label} must set exactly the parameter for its modulation kind") + for field, value in fields.items(): + if value is not None: + _require_finite(value, f"{label} {field.value} value", minimum=0.0) + + +@dataclass(frozen=True, slots=True) +class RfModulationRequest: + """One bounded internal-sine AM, FM, or PM configuration for one RF port.""" + + port_id: str + kind: RfModulationKind + internal_frequency_hz: float + depth_percent: float | None = None + frequency_deviation_hz: float | None = None + phase_deviation_rad: float | None = None + + def __post_init__(self) -> None: + _require_token(self.port_id, "RF modulation request port_id") + _require_finite( + self.internal_frequency_hz, + "RF modulation request internal_frequency_hz", + minimum=0.0, + ) + _validate_modulation_fields( + kind=self.kind, + depth_percent=self.depth_percent, + frequency_deviation_hz=self.frequency_deviation_hz, + phase_deviation_rad=self.phase_deviation_rad, + label="RF modulation request", + ) + + @property + def value(self) -> float: + value = { + RfModulationKind.AM: self.depth_percent, + RfModulationKind.FM: self.frequency_deviation_hz, + RfModulationKind.PM: self.phase_deviation_rad, + }[self.kind] + assert value is not None + return value + + @property + def value_unit(self) -> RfModulationValueUnit: + return { + RfModulationKind.AM: RfModulationValueUnit.PERCENT, + RfModulationKind.FM: RfModulationValueUnit.HZ, + RfModulationKind.PM: RfModulationValueUnit.RAD, + }[self.kind] + + +@dataclass(frozen=True, slots=True) +class RfModulationResult: + """An internal-sine modulation request confirmed by typed readback.""" + + port_id: str + kind: RfModulationKind + internal_frequency_hz: float + depth_percent: float | None = None + frequency_deviation_hz: float | None = None + phase_deviation_rad: float | None = None + + def __post_init__(self) -> None: + _require_token(self.port_id, "RF modulation result port_id") + _require_finite( + self.internal_frequency_hz, + "RF modulation result internal_frequency_hz", + minimum=0.0, + ) + _validate_modulation_fields( + kind=self.kind, + depth_percent=self.depth_percent, + frequency_deviation_hz=self.frequency_deviation_hz, + phase_deviation_rad=self.phase_deviation_rad, + label="RF modulation result", + ) + + @property + def value(self) -> float: + value = { + RfModulationKind.AM: self.depth_percent, + RfModulationKind.FM: self.frequency_deviation_hz, + RfModulationKind.PM: self.phase_deviation_rad, + }[self.kind] + assert value is not None + return value + + @property + def value_unit(self) -> RfModulationValueUnit: + return { + RfModulationKind.AM: RfModulationValueUnit.PERCENT, + RfModulationKind.FM: RfModulationValueUnit.HZ, + RfModulationKind.PM: RfModulationValueUnit.RAD, + }[self.kind] + + +@dataclass(frozen=True, slots=True) +class RfModulatedOutputRequest: + """Enable one RF output only for an exactly read-back modulation profile. + + The operation never configures modulation. The embedded request is the + caller's explicit assertion of the already active internal-sine profile + that must be read back before and after the one RF-ON write. + """ + + modulation: RfModulationRequest + + def __post_init__(self) -> None: + if not isinstance(self.modulation, RfModulationRequest): + raise ValueError("RF modulated-output request requires RfModulationRequest") + + @property + def port_id(self) -> str: + return self.modulation.port_id + + @property + def kind(self) -> RfModulationKind: + return self.modulation.kind + + +@dataclass(frozen=True, slots=True) +class RfModulatedOutputResult: + """A modulated RF-output enable confirmed by independent readback.""" + + modulation: RfModulationResult + write_completed: bool + + def __post_init__(self) -> None: + if not isinstance(self.modulation, RfModulationResult): + raise ValueError("RF modulated-output result requires RfModulationResult") + _require_bool(self.write_completed, "RF modulated-output result write_completed") + if self.write_completed is not True: + raise ValueError("RF modulated-output enable must complete one RF-ON write") + + @property + def port_id(self) -> str: + return self.modulation.port_id + + @property + def kind(self) -> RfModulationKind: + return self.modulation.kind + + +@dataclass(frozen=True, slots=True) +class RfModulationDisableRequest: + """Disable exactly one active modulation mode on one RF output port. + + This request deliberately identifies the mode that must be active before + the write. It is not a broad reset: Service preflight rejects an unknown, + mixed-mode, or otherwise unsafe modulation state before the driver sends + its mode-specific disable sequence. + """ + + port_id: str + kind: RfModulationKind + + def __post_init__(self) -> None: + _require_token(self.port_id, "RF modulation disable request port_id") + if not isinstance(self.kind, RfModulationKind): + raise ValueError("RF modulation disable request kind has an invalid type") + + +@dataclass(frozen=True, slots=True) +class RfModulationDisableResult: + """A mode-specific modulation-disable request confirmed by typed state readback.""" + + port_id: str + kind: RfModulationKind + write_completed: bool + + def __post_init__(self) -> None: + _require_token(self.port_id, "RF modulation disable result port_id") + if not isinstance(self.kind, RfModulationKind): + raise ValueError("RF modulation disable result kind has an invalid type") + _require_bool(self.write_completed, "RF modulation disable result write_completed") + + +@dataclass(frozen=True, slots=True) +class RfModulationStateSnapshot: + """State-only readback used before an M3 configuration write. + + An inactive device can legitimately retain an external source, a non-sine + waveform, or source-dependent values that are not queryable. M3 only + needs its mode/global/fault state before it replaces that configuration, + so this snapshot deliberately excludes profile fields. + """ + + port_id: str + enabled_modes: tuple[RfModulationKind, ...] = () + global_enabled: bool = False + fault_codes: tuple[str, ...] = () + + def __post_init__(self) -> None: + _require_token(self.port_id, "RF modulation state snapshot port_id") + _require_enum_tuple( + self.enabled_modes, + RfModulationKind, + "RF modulation state snapshot enabled_modes", + allow_empty=True, + ) + _require_bool(self.global_enabled, "RF modulation state snapshot global_enabled") + _require_token_tuple( + self.fault_codes, + "RF modulation state snapshot fault_codes", + allow_empty=True, + ) + + +@dataclass(frozen=True, slots=True) +class RfModulationSnapshot: + """Complete typed readback for one internal-sine modulation mode. + + ``kind`` identifies the stored profile queried by the driver. On devices + with one shared FM/PM front-panel selection, ``selected_fm_pm_kind`` records + that current selection separately. This lets an OFF-only transaction + safely prepare PM while FM is currently selected, then require the target + selection in its postcondition. + """ + + port_id: str + kind: RfModulationKind + source: RfModulationSource + waveform: RfModulationWaveform + internal_frequency_hz: float + selected_fm_pm_kind: RfModulationKind | None = None + depth_percent: float | None = None + frequency_deviation_hz: float | None = None + phase_deviation_rad: float | None = None + enabled_modes: tuple[RfModulationKind, ...] = () + global_enabled: bool = False + fault_codes: tuple[str, ...] = () + + def __post_init__(self) -> None: + _require_token(self.port_id, "RF modulation snapshot port_id") + if not isinstance(self.source, RfModulationSource): + raise ValueError("RF modulation snapshot source has an invalid type") + if not isinstance(self.waveform, RfModulationWaveform): + raise ValueError("RF modulation snapshot waveform has an invalid type") + _require_finite( + self.internal_frequency_hz, + "RF modulation snapshot internal_frequency_hz", + minimum=0.0, + ) + _validate_modulation_fields( + kind=self.kind, + depth_percent=self.depth_percent, + frequency_deviation_hz=self.frequency_deviation_hz, + phase_deviation_rad=self.phase_deviation_rad, + label="RF modulation snapshot", + ) + if self.kind is RfModulationKind.AM: + if self.selected_fm_pm_kind is not None: + raise ValueError("AM modulation snapshots cannot carry an FM/PM selection") + elif self.selected_fm_pm_kind not in { + RfModulationKind.FM, + RfModulationKind.PM, + }: + raise ValueError("FM/PM modulation snapshots require a selected FM/PM kind") + _require_enum_tuple( + self.enabled_modes, + RfModulationKind, + "RF modulation snapshot enabled_modes", + allow_empty=True, + ) + _require_bool(self.global_enabled, "RF modulation snapshot global_enabled") + _require_token_tuple(self.fault_codes, "RF modulation snapshot fault_codes", allow_empty=True) + + @property + def value(self) -> float: + value = { + RfModulationKind.AM: self.depth_percent, + RfModulationKind.FM: self.frequency_deviation_hz, + RfModulationKind.PM: self.phase_deviation_rad, + }[self.kind] + assert value is not None + return value + + @property + def value_unit(self) -> RfModulationValueUnit: + return { + RfModulationKind.AM: RfModulationValueUnit.PERCENT, + RfModulationKind.FM: RfModulationValueUnit.HZ, + RfModulationKind.PM: RfModulationValueUnit.RAD, + }[self.kind] + + +@dataclass(frozen=True, slots=True) +class RfPulseConfigureRequest: + """One RF-OFF internal single-pulse configuration for one RF port. + + The descriptor supplies the fixed source and mode; this request only + carries bounded timing and polarity fields. It deliberately has no trigger, + external-port, or pulse-output field. + """ + + port_id: str + period_s: float + width_s: float + polarity: RfPulsePolarity + + def __post_init__(self) -> None: + _require_token(self.port_id, "RF pulse configure request port_id") + _require_finite(self.period_s, "RF pulse configure request period_s", minimum=0.0) + _require_finite(self.width_s, "RF pulse configure request width_s", minimum=0.0) + if self.period_s <= 0.0: + raise ValueError("RF pulse configure request period_s must be positive") + if self.width_s <= 0.0: + raise ValueError("RF pulse configure request width_s must be positive") + if self.width_s >= self.period_s: + raise ValueError("RF pulse configure request width_s must be less than period_s") + if not isinstance(self.polarity, RfPulsePolarity): + raise ValueError("RF pulse configure request polarity has an invalid type") + + +@dataclass(frozen=True, slots=True) +class RfPulseConfigureResult: + """A bounded RF pulse configuration confirmed by independent readback.""" + + port_id: str + period_s: float + width_s: float + polarity: RfPulsePolarity + + def __post_init__(self) -> None: + _require_token(self.port_id, "RF pulse configure result port_id") + _require_finite(self.period_s, "RF pulse configure result period_s", minimum=0.0) + _require_finite(self.width_s, "RF pulse configure result width_s", minimum=0.0) + if self.period_s <= 0.0: + raise ValueError("RF pulse configure result period_s must be positive") + if self.width_s <= 0.0: + raise ValueError("RF pulse configure result width_s must be positive") + if self.width_s >= self.period_s: + raise ValueError("RF pulse configure result width_s must be less than period_s") + if not isinstance(self.polarity, RfPulsePolarity): + raise ValueError("RF pulse configure result polarity has an invalid type") + + +@dataclass(frozen=True, slots=True) +class RfPulseOutputRequest: + """Set one declared physical Pulse-output interface on or off. + + Timing, polarity, electrical levels, and physical direction are all fixed + by the descriptor. This request cannot select an input direction, a + trigger source, a receiving instrument, or arbitrary output levels. + """ + + port_id: str + interface_id: str + enabled: bool + + def __post_init__(self) -> None: + _require_token(self.port_id, "RF pulse-output request port_id") + _require_token(self.interface_id, "RF pulse-output request interface_id") + _require_bool(self.enabled, "RF pulse-output request enabled") + + +@dataclass(frozen=True, slots=True) +class RfPulseOutputResult: + """A physical Pulse-output target confirmed by independent readback.""" + + port_id: str + interface_id: str + enabled: bool + write_completed: bool + + def __post_init__(self) -> None: + _require_token(self.port_id, "RF pulse-output result port_id") + _require_token(self.interface_id, "RF pulse-output result interface_id") + _require_bool(self.enabled, "RF pulse-output result enabled") + _require_bool(self.write_completed, "RF pulse-output result write_completed") + + +@dataclass(frozen=True, slots=True) +class RfPulseSnapshot: + """Complete typed readback for one pulse profile on one RF port.""" + + port_id: str + source: RfPulseSource + mode: RfPulseMode + period_s: float + width_s: float + polarity: RfPulsePolarity + state: RfPulseState + + def __post_init__(self) -> None: + _require_token(self.port_id, "RF pulse snapshot port_id") + if not isinstance(self.source, RfPulseSource): + raise ValueError("RF pulse snapshot source has an invalid type") + if not isinstance(self.mode, RfPulseMode): + raise ValueError("RF pulse snapshot mode has an invalid type") + _require_finite(self.period_s, "RF pulse snapshot period_s", minimum=0.0) + _require_finite(self.width_s, "RF pulse snapshot width_s", minimum=0.0) + if self.period_s <= 0.0: + raise ValueError("RF pulse snapshot period_s must be positive") + if self.width_s <= 0.0: + raise ValueError("RF pulse snapshot width_s must be positive") + if self.width_s >= self.period_s: + raise ValueError("RF pulse snapshot width_s must be less than period_s") + if not isinstance(self.polarity, RfPulsePolarity): + raise ValueError("RF pulse snapshot polarity has an invalid type") + if not isinstance(self.state, RfPulseState): + raise ValueError("RF pulse snapshot state has an invalid type") + + +@dataclass(frozen=True, slots=True) +class RfPulseOutputSnapshot: + """Typed readback of one declared physical Pulse-output interface.""" + + port_id: str + interface_id: str + direction: RfPulseOutputDirection + enabled: bool + low_level_v: float + high_level_v: float + output_impedance_ohm: float + source: RfPulseSource + mode: RfPulseMode + period_s: float + width_s: float + polarity: RfPulsePolarity + pulse_state: RfPulseState + + def __post_init__(self) -> None: + _require_token(self.port_id, "RF pulse-output snapshot port_id") + _require_token(self.interface_id, "RF pulse-output snapshot interface_id") + if self.direction is not RfPulseOutputDirection.OUTPUT: + raise ValueError("RF pulse-output snapshots must report output direction") + _require_bool(self.enabled, "RF pulse-output snapshot enabled") + _require_finite(self.low_level_v, "RF pulse-output snapshot low_level_v", minimum=0.0) + _require_finite( + self.high_level_v, + "RF pulse-output snapshot high_level_v", + minimum=self.low_level_v, + ) + if self.high_level_v <= self.low_level_v: + raise ValueError("RF pulse-output snapshot high_level_v must exceed low_level_v") + _require_finite( + self.output_impedance_ohm, + "RF pulse-output snapshot output_impedance_ohm", + minimum=0.0, + ) + if self.output_impedance_ohm <= 0.0: + raise ValueError("RF pulse-output snapshot output_impedance_ohm must be positive") + if not isinstance(self.source, RfPulseSource): + raise ValueError("RF pulse-output snapshot source has an invalid type") + if not isinstance(self.mode, RfPulseMode): + raise ValueError("RF pulse-output snapshot mode has an invalid type") + _require_finite(self.period_s, "RF pulse-output snapshot period_s", minimum=0.0) + _require_finite(self.width_s, "RF pulse-output snapshot width_s", minimum=0.0) + if self.period_s <= 0.0 or self.width_s <= 0.0: + raise ValueError("RF pulse-output snapshot period_s and width_s must be positive") + if self.width_s >= self.period_s: + raise ValueError("RF pulse-output snapshot width_s must be less than period_s") + if not isinstance(self.polarity, RfPulsePolarity): + raise ValueError("RF pulse-output snapshot polarity has an invalid type") + if not isinstance(self.pulse_state, RfPulseState): + raise ValueError("RF pulse-output snapshot pulse_state has an invalid type") + + def as_dict(self) -> dict[str, object]: + return rf_pulse_output_snapshot_document(self) + + +@dataclass(frozen=True, slots=True) +class RfSweepConfigureRequest: + """One RF-OFF frequency-only Step Sweep configuration for one RF port. + + The descriptor supplies the fixed Step/forward/ramp/linear profile. This + request deliberately has no level, trigger, arm, fire, or output field. + """ + + port_id: str + start_frequency_hz: float + stop_frequency_hz: float + points: int + dwell_s: float + + def __post_init__(self) -> None: + _require_token(self.port_id, "RF sweep configure request port_id") + _require_finite( + self.start_frequency_hz, + "RF sweep configure request start_frequency_hz", + minimum=0.0, + ) + _require_finite( + self.stop_frequency_hz, + "RF sweep configure request stop_frequency_hz", + minimum=0.0, + ) + if self.start_frequency_hz >= self.stop_frequency_hz: + raise ValueError("RF sweep configure request start_frequency_hz must be less than stop_frequency_hz") + _require_integer(self.points, "RF sweep configure request points", minimum=2) + _require_finite(self.dwell_s, "RF sweep configure request dwell_s", minimum=0.0) + if self.dwell_s <= 0.0: + raise ValueError("RF sweep configure request dwell_s must be positive") + + +@dataclass(frozen=True, slots=True) +class RfSweepConfigureResult: + """A bounded Step Sweep configuration confirmed while Sweep remains disabled.""" + + port_id: str + start_frequency_hz: float + stop_frequency_hz: float + points: int + dwell_s: float + + def __post_init__(self) -> None: + RfSweepConfigureRequest( + port_id=self.port_id, + start_frequency_hz=self.start_frequency_hz, + stop_frequency_hz=self.stop_frequency_hz, + points=self.points, + dwell_s=self.dwell_s, + ) + + +@dataclass(frozen=True, slots=True) +class RfSweepSnapshot: + """Complete typed readback for one frequency-only Step Sweep profile.""" + + port_id: str + sweep_type: RfSweepType + direction: RfSweepDirection + shape: RfSweepShape + spacing: RfSweepSpacing + start_frequency_hz: float + stop_frequency_hz: float + points: int + dwell_s: float + state: RfSweepState + + def __post_init__(self) -> None: + _require_token(self.port_id, "RF sweep snapshot port_id") + if not isinstance(self.sweep_type, RfSweepType): + raise ValueError("RF sweep snapshot sweep_type has an invalid type") + if not isinstance(self.direction, RfSweepDirection): + raise ValueError("RF sweep snapshot direction has an invalid type") + if not isinstance(self.shape, RfSweepShape): + raise ValueError("RF sweep snapshot shape has an invalid type") + if not isinstance(self.spacing, RfSweepSpacing): + raise ValueError("RF sweep snapshot spacing has an invalid type") + _require_finite( + self.start_frequency_hz, + "RF sweep snapshot start_frequency_hz", + minimum=0.0, + ) + _require_finite( + self.stop_frequency_hz, + "RF sweep snapshot stop_frequency_hz", + minimum=0.0, + ) + if self.start_frequency_hz >= self.stop_frequency_hz: + raise ValueError("RF sweep snapshot start_frequency_hz must be less than stop_frequency_hz") + _require_integer(self.points, "RF sweep snapshot points", minimum=2) + _require_finite(self.dwell_s, "RF sweep snapshot dwell_s", minimum=0.0) + if self.dwell_s <= 0.0: + raise ValueError("RF sweep snapshot dwell_s must be positive") + if not isinstance(self.state, RfSweepState): + raise ValueError("RF sweep snapshot state has an invalid type") + + +@dataclass(frozen=True, slots=True) +class RfTriggerSnapshot: + """Complete readback of logical Pulse and Sweep trigger configuration. + + ``port_id`` identifies the RF output whose behavior the queried settings + govern. It is not a physical trigger/sync connector identifier. + """ + + port_id: str + pulse_trigger_mode: RfPulseTriggerMode + pulse_external_trigger_edge: RfExternalTriggerEdge + pulse_external_gate_polarity: RfExternalGatePolarity + sweep_mode: RfSweepMode + sweep_period_trigger_mode: RfSweepTriggerMode + sweep_point_trigger_mode: RfSweepTriggerMode + + def __post_init__(self) -> None: + _require_token(self.port_id, "RF trigger snapshot port_id") + if not isinstance(self.pulse_trigger_mode, RfPulseTriggerMode): + raise ValueError("RF trigger snapshot pulse_trigger_mode has an invalid type") + if not isinstance(self.pulse_external_trigger_edge, RfExternalTriggerEdge): + raise ValueError( + "RF trigger snapshot pulse_external_trigger_edge has an invalid type" + ) + if not isinstance(self.pulse_external_gate_polarity, RfExternalGatePolarity): + raise ValueError( + "RF trigger snapshot pulse_external_gate_polarity has an invalid type" + ) + if not isinstance(self.sweep_mode, RfSweepMode): + raise ValueError("RF trigger snapshot sweep_mode has an invalid type") + if not isinstance(self.sweep_period_trigger_mode, RfSweepTriggerMode): + raise ValueError("RF trigger snapshot sweep_period_trigger_mode has an invalid type") + if not isinstance(self.sweep_point_trigger_mode, RfSweepTriggerMode): + raise ValueError("RF trigger snapshot sweep_point_trigger_mode has an invalid type") + + def as_dict(self) -> dict[str, object]: + return rf_trigger_snapshot_document(self) + + +@dataclass(frozen=True, slots=True) +class RfOutputRequest: + """One explicit RF output state request for one descriptor-defined port.""" + + port_id: str + enabled: bool + + def __post_init__(self) -> None: + _require_token(self.port_id, "RF output request port_id") + _require_bool(self.enabled, "RF output request enabled") + + +@dataclass(frozen=True, slots=True) +class RfOutputResult: + """An RF output target confirmed by a fresh postcondition snapshot.""" + + port_id: str + enabled: bool + write_completed: bool + + def __post_init__(self) -> None: + _require_token(self.port_id, "RF output result port_id") + _require_bool(self.enabled, "RF output result enabled") + _require_bool(self.write_completed, "RF output result write_completed") + + +@runtime_checkable +class RfSourceDriver(InstrumentDriver, Protocol): + def get_rf_snapshot(self) -> RfSourceSnapshot: ... + + def get_rf_trigger_snapshot(self, port_id: str) -> RfTriggerSnapshot: ... + + def configure_cw(self, request: RfCwRequest) -> None: ... + + def get_rf_modulation_state(self, port_id: str) -> RfModulationStateSnapshot: ... + + def get_rf_modulation_snapshot( + self, + port_id: str, + kind: RfModulationKind, + ) -> RfModulationSnapshot: ... + + def configure_rf_modulation(self, request: RfModulationRequest) -> None: ... + + def disable_rf_modulation(self, request: RfModulationDisableRequest) -> None: ... + + def get_rf_pulse_snapshot(self, port_id: str) -> RfPulseSnapshot: ... + + def configure_rf_pulse(self, request: RfPulseConfigureRequest) -> None: ... + + def get_rf_pulse_output_snapshot( + self, + port_id: str, + interface_id: str, + ) -> RfPulseOutputSnapshot: ... + + def set_rf_pulse_output(self, request: RfPulseOutputRequest) -> None: ... + + def get_rf_sweep_snapshot(self, port_id: str) -> RfSweepSnapshot: ... + + def configure_rf_sweep(self, request: RfSweepConfigureRequest) -> None: ... + + def set_rf_output(self, request: RfOutputRequest) -> None: ... + + +def _require_observed_number( + observed: object, + label: str, + *, + minimum: float | None = None, +) -> None: + if not isinstance(observed, RfObserved): + raise ValueError(f"{label} has an invalid type") + if observed.availability is RfAvailability.VALUE: + _require_finite(observed.value, label, minimum=minimum) + + +def _require_observed_value( + observed: object, + expected_type: type[object], + label: str, +) -> None: + if not isinstance(observed, RfObserved): + raise ValueError(f"{label} has an invalid type") + if observed.availability is RfAvailability.VALUE and not isinstance( + observed.value, expected_type + ): + raise ValueError(f"{label} has an invalid VALUE type") + + +def rf_source_to_data(value: object) -> object: + """Convert public RF-source values into strict JSON-compatible data.""" + + if isinstance(value, StrEnum): + return value.value + if value is None or isinstance(value, (bool, int, str)): + return value + if isinstance(value, float): + if not isfinite(value): + raise ValueError("RF source JSON cannot contain non-finite floats") + return value + if isinstance(value, tuple): + return [rf_source_to_data(item) for item in value] + if is_dataclass(value) and not isinstance(value, type): + payload: dict[str, object] = {"type": type(value).__name__} + for item in fields(value): + payload[item.name] = rf_source_to_data(getattr(value, item.name)) + return payload + raise TypeError(f"unsupported RF source JSON value: {type(value).__name__}") + + +def rf_source_canonical_json(value: object) -> str: + return json.dumps( + rf_source_to_data(value), + ensure_ascii=False, + allow_nan=False, + sort_keys=True, + separators=(",", ":"), + ) + + +def rf_source_digest(value: object) -> str: + return "sha256:" + sha256(rf_source_canonical_json(value).encode("utf-8")).hexdigest() + + +def rf_source_snapshot_document(snapshot: RfSourceSnapshot) -> dict[str, object]: + if not isinstance(snapshot, RfSourceSnapshot): + raise TypeError("snapshot must be RfSourceSnapshot") + data = rf_source_to_data(snapshot) + assert isinstance(data, dict) + return {"schema": RF_SOURCE_SNAPSHOT_SCHEMA, **data} + + +def rf_modulation_snapshot_document(snapshot: RfModulationSnapshot) -> dict[str, object]: + """Build a redacted document for one typed RF modulation readback.""" + + if not isinstance(snapshot, RfModulationSnapshot): + raise TypeError("snapshot must be RfModulationSnapshot") + data = rf_source_to_data(snapshot) + assert isinstance(data, dict) + return {"schema": RF_SOURCE_MODULATION_SNAPSHOT_SCHEMA, **data} + + +def rf_modulation_state_snapshot_document( + snapshot: RfModulationStateSnapshot, +) -> dict[str, object]: + """Build a redacted document for one typed RF modulation-state readback.""" + + if not isinstance(snapshot, RfModulationStateSnapshot): + raise TypeError("snapshot must be RfModulationStateSnapshot") + data = rf_source_to_data(snapshot) + assert isinstance(data, dict) + return {"schema": RF_SOURCE_MODULATION_STATE_SCHEMA, **data} + + +def rf_pulse_snapshot_document(snapshot: RfPulseSnapshot) -> dict[str, object]: + """Build a redacted document for one typed RF pulse readback.""" + + if not isinstance(snapshot, RfPulseSnapshot): + raise TypeError("snapshot must be RfPulseSnapshot") + data = rf_source_to_data(snapshot) + assert isinstance(data, dict) + return {"schema": RF_SOURCE_PULSE_SNAPSHOT_SCHEMA, **data} + + +def rf_pulse_output_snapshot_document(snapshot: RfPulseOutputSnapshot) -> dict[str, object]: + """Build a redacted document for one physical RF Pulse-output readback.""" + + if not isinstance(snapshot, RfPulseOutputSnapshot): + raise TypeError("snapshot must be RfPulseOutputSnapshot") + data = rf_source_to_data(snapshot) + assert isinstance(data, dict) + return {"schema": RF_SOURCE_PULSE_OUTPUT_SNAPSHOT_SCHEMA, **data} + + +def rf_sweep_snapshot_document(snapshot: RfSweepSnapshot) -> dict[str, object]: + """Build a redacted document for one typed RF Step Sweep readback.""" + + if not isinstance(snapshot, RfSweepSnapshot): + raise TypeError("snapshot must be RfSweepSnapshot") + data = rf_source_to_data(snapshot) + assert isinstance(data, dict) + return {"schema": RF_SOURCE_SWEEP_SNAPSHOT_SCHEMA, **data} + + +def rf_trigger_snapshot_document(snapshot: RfTriggerSnapshot) -> dict[str, object]: + """Build a redacted document for one typed RF trigger configuration readback.""" + + if not isinstance(snapshot, RfTriggerSnapshot): + raise TypeError("snapshot must be RfTriggerSnapshot") + data = rf_source_to_data(snapshot) + assert isinstance(data, dict) + return {"schema": RF_SOURCE_TRIGGER_SNAPSHOT_SCHEMA, **data} + + +def rf_source_snapshot_operation_artifact(snapshot: RfSourceSnapshot) -> dict[str, object]: + """Build a read-only snapshot artifact without transport-private values.""" + + return { + "schema": RF_SOURCE_OPERATION_ARTIFACT_SCHEMA, + "operation": "rf_source.snapshot", + "snapshot": rf_source_snapshot_document(snapshot), + } + + +def rf_source_trigger_snapshot_operation_artifact( + snapshot: RfTriggerSnapshot, +) -> dict[str, object]: + """Build a read-only trigger-configuration artifact without transport values.""" + + return { + "schema": RF_SOURCE_OPERATION_ARTIFACT_SCHEMA, + "operation": "rf_source.trigger_snapshot", + "trigger_snapshot": rf_trigger_snapshot_document(snapshot), + } + + +def rf_source_cw_operation_artifact( + request: RfCwRequest, + result: RfCwResult, + *, + preflight_snapshot: RfSourceSnapshot, + postcondition_snapshot: RfSourceSnapshot, +) -> dict[str, object]: + """Build one redacted M1 CW operation artifact from typed evidence.""" + + if not isinstance(request, RfCwRequest): + raise TypeError("request must be RfCwRequest") + if not isinstance(result, RfCwResult): + raise TypeError("result must be RfCwResult") + if not isinstance(preflight_snapshot, RfSourceSnapshot): + raise TypeError("preflight_snapshot must be RfSourceSnapshot") + if not isinstance(postcondition_snapshot, RfSourceSnapshot): + raise TypeError("postcondition_snapshot must be RfSourceSnapshot") + operation = ( + "rf_source.set_frequency" + if request.frequency_hz is not None + else "rf_source.set_power_dbm" + ) + return { + "schema": RF_SOURCE_OPERATION_ARTIFACT_SCHEMA, + "operation": operation, + "request": rf_source_to_data(request), + "result": rf_source_to_data(result), + "preflight_snapshot": rf_source_snapshot_document(preflight_snapshot), + "postcondition_snapshot": rf_source_snapshot_document(postcondition_snapshot), + } + + +def rf_source_modulation_operation_artifact( + request: RfModulationRequest, + result: RfModulationResult, + *, + preflight_snapshot: RfSourceSnapshot, + preflight_modulation_state: RfModulationStateSnapshot, + postcondition_snapshot: RfSourceSnapshot, + postcondition_modulation_snapshot: RfModulationSnapshot, +) -> dict[str, object]: + """Build one redacted M3 modulation operation artifact from typed evidence.""" + + if not isinstance(request, RfModulationRequest): + raise TypeError("request must be RfModulationRequest") + if not isinstance(result, RfModulationResult): + raise TypeError("result must be RfModulationResult") + if ( + request.port_id != result.port_id + or request.kind is not result.kind + or request.internal_frequency_hz != result.internal_frequency_hz + or request.value != result.value + or request.value_unit is not result.value_unit + ): + raise ValueError("RF modulation request and result must describe the same target") + if not isinstance(preflight_snapshot, RfSourceSnapshot): + raise TypeError("preflight_snapshot must be RfSourceSnapshot") + if not isinstance(preflight_modulation_state, RfModulationStateSnapshot): + raise TypeError("preflight_modulation_state must be RfModulationStateSnapshot") + if not isinstance(postcondition_snapshot, RfSourceSnapshot): + raise TypeError("postcondition_snapshot must be RfSourceSnapshot") + if not isinstance(postcondition_modulation_snapshot, RfModulationSnapshot): + raise TypeError("postcondition_modulation_snapshot must be RfModulationSnapshot") + return { + "schema": RF_SOURCE_OPERATION_ARTIFACT_SCHEMA, + "operation": "rf_source.modulation_configure", + "request": rf_source_to_data(request), + "result": rf_source_to_data(result), + "preflight_snapshot": rf_source_snapshot_document(preflight_snapshot), + "preflight_modulation_state": rf_modulation_state_snapshot_document( + preflight_modulation_state + ), + "postcondition_snapshot": rf_source_snapshot_document(postcondition_snapshot), + "postcondition_modulation_snapshot": rf_modulation_snapshot_document( + postcondition_modulation_snapshot + ), + } + + +def rf_source_modulated_output_operation_artifact( + request: RfModulatedOutputRequest, + result: RfModulatedOutputResult, + *, + preflight_snapshot: RfSourceSnapshot, + preflight_modulation_snapshot: RfModulationSnapshot, + postcondition_snapshot: RfSourceSnapshot, + postcondition_modulation_snapshot: RfModulationSnapshot, +) -> dict[str, object]: + """Build typed evidence for one separate modulated RF-output enable. + + The artifact records the declared active profile both before and after the + one RF-ON write. It intentionally does not claim that RF OFF or modulation + disable was restored; those are separate operations and evidence. + """ + + if not isinstance(request, RfModulatedOutputRequest): + raise TypeError("request must be RfModulatedOutputRequest") + if not isinstance(result, RfModulatedOutputResult): + raise TypeError("result must be RfModulatedOutputResult") + modulation_request = request.modulation + modulation_result = result.modulation + if ( + modulation_request.port_id != modulation_result.port_id + or modulation_request.kind is not modulation_result.kind + or modulation_request.internal_frequency_hz != modulation_result.internal_frequency_hz + or modulation_request.value != modulation_result.value + or modulation_request.value_unit is not modulation_result.value_unit + ): + raise ValueError("RF modulated-output request and result must describe the same target") + if not isinstance(preflight_snapshot, RfSourceSnapshot): + raise TypeError("preflight_snapshot must be RfSourceSnapshot") + if not isinstance(preflight_modulation_snapshot, RfModulationSnapshot): + raise TypeError("preflight_modulation_snapshot must be RfModulationSnapshot") + if not isinstance(postcondition_snapshot, RfSourceSnapshot): + raise TypeError("postcondition_snapshot must be RfSourceSnapshot") + if not isinstance(postcondition_modulation_snapshot, RfModulationSnapshot): + raise TypeError("postcondition_modulation_snapshot must be RfModulationSnapshot") + return { + "schema": RF_SOURCE_OPERATION_ARTIFACT_SCHEMA, + "operation": "rf_source.modulated_output_enable", + "request": rf_source_to_data(request), + "result": rf_source_to_data(result), + "preflight_snapshot": rf_source_snapshot_document(preflight_snapshot), + "preflight_modulation_snapshot": rf_modulation_snapshot_document( + preflight_modulation_snapshot + ), + "postcondition_snapshot": rf_source_snapshot_document(postcondition_snapshot), + "postcondition_modulation_snapshot": rf_modulation_snapshot_document( + postcondition_modulation_snapshot + ), + } + + +def rf_source_modulation_disable_operation_artifact( + request: RfModulationDisableRequest, + result: RfModulationDisableResult, + *, + preflight_snapshot: RfSourceSnapshot, + preflight_modulation_state: RfModulationStateSnapshot, + postcondition_snapshot: RfSourceSnapshot, + postcondition_modulation_state: RfModulationStateSnapshot, +) -> dict[str, object]: + """Build redacted typed evidence for one RF modulation-disable operation.""" + + if not isinstance(request, RfModulationDisableRequest): + raise TypeError("request must be RfModulationDisableRequest") + if not isinstance(result, RfModulationDisableResult): + raise TypeError("result must be RfModulationDisableResult") + if request.port_id != result.port_id or request.kind is not result.kind: + raise ValueError("RF modulation disable request and result must describe the same target") + if not isinstance(preflight_snapshot, RfSourceSnapshot): + raise TypeError("preflight_snapshot must be RfSourceSnapshot") + if not isinstance(preflight_modulation_state, RfModulationStateSnapshot): + raise TypeError("preflight_modulation_state must be RfModulationStateSnapshot") + if not isinstance(postcondition_snapshot, RfSourceSnapshot): + raise TypeError("postcondition_snapshot must be RfSourceSnapshot") + if not isinstance(postcondition_modulation_state, RfModulationStateSnapshot): + raise TypeError("postcondition_modulation_state must be RfModulationStateSnapshot") + return { + "schema": RF_SOURCE_OPERATION_ARTIFACT_SCHEMA, + "operation": "rf_source.modulation_disable", + "request": rf_source_to_data(request), + "result": rf_source_to_data(result), + "preflight_snapshot": rf_source_snapshot_document(preflight_snapshot), + "preflight_modulation_state": rf_modulation_state_snapshot_document( + preflight_modulation_state + ), + "postcondition_snapshot": rf_source_snapshot_document(postcondition_snapshot), + "postcondition_modulation_state": rf_modulation_state_snapshot_document( + postcondition_modulation_state + ), + } + + +def rf_source_pulse_operation_artifact( + request: RfPulseConfigureRequest, + result: RfPulseConfigureResult, + *, + preflight_snapshot: RfSourceSnapshot, + postcondition_snapshot: RfSourceSnapshot, + postcondition_pulse_snapshot: RfPulseSnapshot, +) -> dict[str, object]: + """Build redacted typed evidence for one RF-OFF pulse configuration.""" + + if not isinstance(request, RfPulseConfigureRequest): + raise TypeError("request must be RfPulseConfigureRequest") + if not isinstance(result, RfPulseConfigureResult): + raise TypeError("result must be RfPulseConfigureResult") + if ( + request.port_id != result.port_id + or request.period_s != result.period_s + or request.width_s != result.width_s + or request.polarity is not result.polarity + ): + raise ValueError("RF pulse request and result must describe the same target") + if not isinstance(preflight_snapshot, RfSourceSnapshot): + raise TypeError("preflight_snapshot must be RfSourceSnapshot") + if not isinstance(postcondition_snapshot, RfSourceSnapshot): + raise TypeError("postcondition_snapshot must be RfSourceSnapshot") + if not isinstance(postcondition_pulse_snapshot, RfPulseSnapshot): + raise TypeError("postcondition_pulse_snapshot must be RfPulseSnapshot") + return { + "schema": RF_SOURCE_OPERATION_ARTIFACT_SCHEMA, + "operation": "rf_source.pulse_configure", + "request": rf_source_to_data(request), + "result": rf_source_to_data(result), + "preflight_snapshot": rf_source_snapshot_document(preflight_snapshot), + "postcondition_snapshot": rf_source_snapshot_document(postcondition_snapshot), + "postcondition_pulse_snapshot": rf_pulse_snapshot_document(postcondition_pulse_snapshot), + } + + +def rf_source_pulse_output_operation_artifact( + request: RfPulseOutputRequest, + result: RfPulseOutputResult, + *, + preflight_snapshot: RfSourceSnapshot, + preflight_pulse_output_snapshot: RfPulseOutputSnapshot, + postcondition_snapshot: RfSourceSnapshot, + postcondition_pulse_output_snapshot: RfPulseOutputSnapshot, +) -> dict[str, object]: + """Build redacted typed evidence for one physical Pulse-output state change.""" + + if not isinstance(request, RfPulseOutputRequest): + raise TypeError("request must be RfPulseOutputRequest") + if not isinstance(result, RfPulseOutputResult): + raise TypeError("result must be RfPulseOutputResult") + if ( + request.port_id != result.port_id + or request.interface_id != result.interface_id + or request.enabled is not result.enabled + ): + raise ValueError("RF pulse-output request and result must describe the same target") + if not isinstance(preflight_snapshot, RfSourceSnapshot): + raise TypeError("preflight_snapshot must be RfSourceSnapshot") + if not isinstance(preflight_pulse_output_snapshot, RfPulseOutputSnapshot): + raise TypeError("preflight_pulse_output_snapshot must be RfPulseOutputSnapshot") + if not isinstance(postcondition_snapshot, RfSourceSnapshot): + raise TypeError("postcondition_snapshot must be RfSourceSnapshot") + if not isinstance(postcondition_pulse_output_snapshot, RfPulseOutputSnapshot): + raise TypeError("postcondition_pulse_output_snapshot must be RfPulseOutputSnapshot") + return { + "schema": RF_SOURCE_OPERATION_ARTIFACT_SCHEMA, + "operation": ( + "rf_source.pulse_output_enable" + if request.enabled + else "rf_source.pulse_output_disable" + ), + "request": rf_source_to_data(request), + "result": rf_source_to_data(result), + "preflight_snapshot": rf_source_snapshot_document(preflight_snapshot), + "preflight_pulse_output_snapshot": rf_pulse_output_snapshot_document( + preflight_pulse_output_snapshot + ), + "postcondition_snapshot": rf_source_snapshot_document(postcondition_snapshot), + "postcondition_pulse_output_snapshot": rf_pulse_output_snapshot_document( + postcondition_pulse_output_snapshot + ), + } + + +def rf_source_sweep_operation_artifact( + request: RfSweepConfigureRequest, + result: RfSweepConfigureResult, + *, + preflight_snapshot: RfSourceSnapshot, + postcondition_snapshot: RfSourceSnapshot, + postcondition_sweep_snapshot: RfSweepSnapshot, +) -> dict[str, object]: + """Build redacted typed evidence for one disabled Step Sweep configuration.""" + + if not isinstance(request, RfSweepConfigureRequest): + raise TypeError("request must be RfSweepConfigureRequest") + if not isinstance(result, RfSweepConfigureResult): + raise TypeError("result must be RfSweepConfigureResult") + if ( + request.port_id != result.port_id + or request.start_frequency_hz != result.start_frequency_hz + or request.stop_frequency_hz != result.stop_frequency_hz + or request.points != result.points + or request.dwell_s != result.dwell_s + ): + raise ValueError("RF sweep request and result must describe the same target") + if not isinstance(preflight_snapshot, RfSourceSnapshot): + raise TypeError("preflight_snapshot must be RfSourceSnapshot") + if not isinstance(postcondition_snapshot, RfSourceSnapshot): + raise TypeError("postcondition_snapshot must be RfSourceSnapshot") + if not isinstance(postcondition_sweep_snapshot, RfSweepSnapshot): + raise TypeError("postcondition_sweep_snapshot must be RfSweepSnapshot") + return { + "schema": RF_SOURCE_OPERATION_ARTIFACT_SCHEMA, + "operation": "rf_source.sweep_configure", + "request": rf_source_to_data(request), + "result": rf_source_to_data(result), + "preflight_snapshot": rf_source_snapshot_document(preflight_snapshot), + "postcondition_snapshot": rf_source_snapshot_document(postcondition_snapshot), + "postcondition_sweep_snapshot": rf_sweep_snapshot_document(postcondition_sweep_snapshot), + } + + +def rf_source_output_operation_artifact( + request: RfOutputRequest, + result: RfOutputResult, + *, + preflight_snapshot: RfSourceSnapshot, + postcondition_snapshot: RfSourceSnapshot, +) -> dict[str, object]: + """Build one redacted M2 RF-output artifact from typed evidence.""" + + if not isinstance(request, RfOutputRequest): + raise TypeError("request must be RfOutputRequest") + if not isinstance(result, RfOutputResult): + raise TypeError("result must be RfOutputResult") + if request.port_id != result.port_id or request.enabled is not result.enabled: + raise ValueError("RF output request and result must describe the same target") + if not isinstance(preflight_snapshot, RfSourceSnapshot): + raise TypeError("preflight_snapshot must be RfSourceSnapshot") + if not isinstance(postcondition_snapshot, RfSourceSnapshot): + raise TypeError("postcondition_snapshot must be RfSourceSnapshot") + return { + "schema": RF_SOURCE_OPERATION_ARTIFACT_SCHEMA, + "operation": ( + "rf_source.output_enable" if request.enabled else "rf_source.output_disable" + ), + "request": rf_source_to_data(request), + "result": rf_source_to_data(result), + "preflight_snapshot": rf_source_snapshot_document(preflight_snapshot), + "postcondition_snapshot": rf_source_snapshot_document(postcondition_snapshot), + } + + +__all__ = [ + "RF_SOURCE_CONTRACT_VERSION", + "RF_SOURCE_MODULATION_STATE_SCHEMA", + "RF_SOURCE_MODULATION_SNAPSHOT_SCHEMA", + "RF_SOURCE_OPERATION_ARTIFACT_SCHEMA", + "RF_SOURCE_PULSE_SNAPSHOT_SCHEMA", + "RF_SOURCE_PULSE_OUTPUT_SNAPSHOT_SCHEMA", + "RF_SOURCE_SWEEP_SNAPSHOT_SCHEMA", + "RF_SOURCE_SNAPSHOT_MIN_CORE_VERSION", + "RF_SOURCE_SNAPSHOT_SCHEMA", + "RF_SOURCE_TRIGGER_SNAPSHOT_SCHEMA", + "RfAvailability", + "RfCwProfile", + "RfCwRequest", + "RfCwResult", + "RfExternalGatePolarity", + "RfExternalTriggerEdge", + "RfFeature", + "RfFeatureCapability", + "RfFeatureDirection", + "RfFeatureProfile", + "RfModulationKind", + "RfModulationDisableRequest", + "RfModulationDisableResult", + "RfModulationModeProfile", + "RfModulatedOutputProfile", + "RfModulatedOutputRequest", + "RfModulatedOutputResult", + "RfModulationProfile", + "RfModulationRequest", + "RfModulationResult", + "RfModulationStateSnapshot", + "RfModulationSnapshot", + "RfModulationSource", + "RfModulationState", + "RfModulationValueUnit", + "RfModulationWaveform", + "RfObserved", + "RfOutputPortProfile", + "RfOutputProfile", + "RfOutputRequest", + "RfOutputResult", + "RfPortSnapshot", + "RfProtectionConditionPolicy", + "RfProtectionStatus", + "RfPulseProfile", + "RfPulseConfigureRequest", + "RfPulseConfigureResult", + "RfPulseMode", + "RfPulseModeProfile", + "RfPulseOutputDirection", + "RfPulseOutputProfile", + "RfPulseOutputRequest", + "RfPulseOutputResult", + "RfPulseOutputSnapshot", + "RfPulsePolarity", + "RfPulseSnapshot", + "RfPulseSource", + "RfPulseState", + "RfPulseTriggerMode", + "RfReasonCode", + "RfSourceDescriptorExtensions", + "RfSourceDriver", + "RfSourceSnapshot", + "RfSourceTopology", + "RfSweepProfile", + "RfSweepConfigureRequest", + "RfSweepConfigureResult", + "RfSweepDirection", + "RfSweepModeProfile", + "RfSweepShape", + "RfSweepSnapshot", + "RfSweepSpacing", + "RfSweepState", + "RfSweepMode", + "RfSweepTriggerMode", + "RfSweepType", + "RfTriggerProfile", + "RfTriggerSnapshot", + "rf_source_canonical_json", + "rf_source_cw_operation_artifact", + "rf_source_digest", + "rf_modulation_snapshot_document", + "rf_modulation_state_snapshot_document", + "rf_pulse_snapshot_document", + "rf_pulse_output_snapshot_document", + "rf_sweep_snapshot_document", + "rf_trigger_snapshot_document", + "rf_source_modulation_disable_operation_artifact", + "rf_source_modulated_output_operation_artifact", + "rf_source_modulation_operation_artifact", + "rf_source_pulse_operation_artifact", + "rf_source_pulse_output_operation_artifact", + "rf_source_sweep_operation_artifact", + "rf_source_snapshot_document", + "rf_source_snapshot_operation_artifact", + "rf_source_trigger_snapshot_operation_artifact", + "rf_source_output_operation_artifact", + "rf_source_to_data", +] diff --git a/src/wavebench/plugins/api.py b/src/wavebench/plugins/api.py index b6d8d29..fb537b6 100644 --- a/src/wavebench/plugins/api.py +++ b/src/wavebench/plugins/api.py @@ -3,7 +3,7 @@ from dataclasses import dataclass, field from typing import Literal -PluginKind = Literal["scope", "source", "power", "dmm", "sweep_analyzer"] +PluginKind = Literal["scope", "source", "rf_source", "power", "dmm", "sweep_analyzer"] PluginOrigin = Literal["builtin", "entry_point", "local"] DiagnosticSeverity = Literal["ok", "warning", "error"] @@ -11,6 +11,7 @@ VALID_PLUGIN_KINDS: tuple[str, ...] = ( "scope", "source", + "rf_source", "power", "dmm", "sweep_analyzer", diff --git a/src/wavebench/plugins/lifecycle.py b/src/wavebench/plugins/lifecycle.py index dc1c079..ccd36b5 100644 --- a/src/wavebench/plugins/lifecycle.py +++ b/src/wavebench/plugins/lifecycle.py @@ -778,6 +778,7 @@ def _postflight(self, record: dict[str, str]) -> dict[str, object]: from wavebench.instruments.registry import _validate_descriptor from wavebench.instruments.source_conformance import validate_source_conformance_distribution from wavebench.instruments.source_extension_capabilities import validate_source_plugin_dependencies +from wavebench.instruments.rf_source_capabilities import validate_rf_source_plugin_dependencies ( expected_name, @@ -833,6 +834,10 @@ def installed_metadata_hash(suffix): descriptor, tuple(dist.metadata.get_all("Requires-Dist") or ()), ) +validate_rf_source_plugin_dependencies( + descriptor, + tuple(dist.metadata.get_all("Requires-Dist") or ()), +) with zipfile.ZipFile(wheel_path) as archive: record_names = [name for name in archive.namelist() if name.endswith(".dist-info/RECORD")] if len(record_names) != 1: diff --git a/src/wavebench/services/execution_intent.py b/src/wavebench/services/execution_intent.py index 8fc8e98..cad40b2 100644 --- a/src/wavebench/services/execution_intent.py +++ b/src/wavebench/services/execution_intent.py @@ -23,6 +23,17 @@ "scope.capture": "scope.capture", "sweep.frequency_response": "scope.capture_waveforms", "source.status": "source.status", + "rf_source.status": "rf_source.snapshot", + "rf_source.trigger_status": "rf_source.trigger_snapshot", + "rf_source.set_frequency": "rf_source.set_frequency", + "rf_source.set_power_dbm": "rf_source.set_power_dbm", + "rf_source.modulation_configure": "rf_source.modulation_configure", + "rf_source.modulation_disable": "rf_source.modulation_disable", + "rf_source.modulated_output_enable": "rf_source.modulated_output_enable", + "rf_source.pulse_configure": "rf_source.pulse_configure", + "rf_source.sweep_configure": "rf_source.sweep_configure", + "rf_source.output_enable": "rf_source.output_enable", + "rf_source.output_disable": "rf_source.output_disable", "source.arb_load": "source.arbitrary_upload", "source.set_freq": "source.set_frequency", "source.set_func": "source.set_function", diff --git a/src/wavebench/services/operation_specs.py b/src/wavebench/services/operation_specs.py index 5cacabf..915d4a8 100644 --- a/src/wavebench/services/operation_specs.py +++ b/src/wavebench/services/operation_specs.py @@ -55,7 +55,9 @@ _EFFECTS = frozenset({"offline", "observe", "stateful_read", "write", "acquire"}) _LEASE_MODES = frozenset({"none", "shared", "exclusive"}) -_INSTRUMENT_KINDS = frozenset({"scope", "source", "power", "dmm", "sweep_analyzer"}) +_INSTRUMENT_KINDS = frozenset( + {"scope", "source", "rf_source", "power", "dmm", "sweep_analyzer"} +) _SESSION_PURPOSES = frozenset({"normal", "recovery", "verification", "lifecycle"}) _ERROR_CHECK_MINIMUMS = frozenset({"required", "if_supported", "disabled"}) @@ -1019,6 +1021,197 @@ def _spec( _spec("source.set_square_duty_cycle", "source", required_capabilities=("source.set_square_duty_cycle",), effect="write", changed_fields=("square_duty_cycle",), restore_coverage="basic", risk_flags=("signal_output", "state_drift")), _spec("source.arbitrary_probe", "source", required_capabilities=("source.arbitrary_probe",), effect="stateful_read"), _spec("source.arbitrary_upload", "source", required_capabilities=("source.arbitrary_upload",), effect="write", changed_fields=("arbitrary_payload",), risk_flags=("signal_output", "volatile_payload")), + _spec( + "rf_source.idn", + "rf_source", + required_capabilities=("rf_source.idn",), + effect="observe", + ), + _spec( + "rf_source.snapshot", + "rf_source", + required_capabilities=("rf_source.snapshot",), + effect="stateful_read", + lease_mode="exclusive", + restore_coverage="none-read-only", + error_check_minimum="disabled", + risk_flags=("state_dependent_query",), + ), + _spec( + "rf_source.trigger_snapshot", + "rf_source", + required_capabilities=("rf_source.trigger_snapshot",), + effect="stateful_read", + lease_mode="exclusive", + restore_coverage="none-read-only", + error_check_minimum="disabled", + risk_flags=("state_dependent_query", "trigger_configuration"), + ), + _spec( + "rf_source.set_frequency", + "rf_source", + required_capabilities=("rf_source.snapshot", "rf_source.cw_configure"), + effect="write", + changed_fields=("rf_source.port.frequency_hz",), + restore_coverage="none", + risk_flags=("rf_output_must_be_off", "signal_level", "state_drift"), + safe_alternatives=("rf_source.snapshot",), + ), + _spec( + "rf_source.set_power_dbm", + "rf_source", + required_capabilities=("rf_source.snapshot", "rf_source.cw_configure"), + effect="write", + changed_fields=("rf_source.port.power_dbm",), + restore_coverage="none", + risk_flags=("rf_output_must_be_off", "signal_level", "state_drift"), + safe_alternatives=("rf_source.snapshot",), + ), + _spec( + "rf_source.modulation_configure", + "rf_source", + required_capabilities=("rf_source.snapshot", "rf_source.modulation_configure"), + effect="write", + changed_fields=( + "rf_source.modulation.kind", + "rf_source.modulation.source", + "rf_source.modulation.waveform", + "rf_source.modulation.value", + "rf_source.modulation.internal_frequency_hz", + "rf_source.modulation.enabled", + ), + restore_coverage="none", + risk_flags=("rf_output_must_be_off", "modulation_state", "state_drift"), + safe_alternatives=("rf_source.snapshot",), + ), + _spec( + "rf_source.modulation_disable", + "rf_source", + required_capabilities=("rf_source.snapshot", "rf_source.modulation_disable"), + effect="write", + changed_fields=( + "rf_source.modulation.enabled_modes", + "rf_source.modulation.global_enabled", + ), + restore_coverage="none", + risk_flags=( + "rf_output_must_be_off", + "modulation_state", + "safe_modulation_disable", + "state_drift", + ), + safe_alternatives=("rf_source.snapshot",), + ), + _spec( + "rf_source.modulated_output_enable", + "rf_source", + required_capabilities=( + "rf_source.snapshot", + "rf_source.output", + "rf_source.modulation_configure", + "rf_source.modulated_output_enable", + ), + effect="write", + changed_fields=("rf_source.port.output_enabled",), + restore_coverage="none", + risk_flags=( + "dangerous_output", + "rf_output_enable", + "active_modulation", + "state_drift", + ), + safe_alternatives=("rf_source.snapshot",), + ), + _spec( + "rf_source.pulse_configure", + "rf_source", + required_capabilities=("rf_source.snapshot", "rf_source.pulse_configure"), + effect="write", + changed_fields=( + "rf_source.pulse.source", + "rf_source.pulse.mode", + "rf_source.pulse.period_s", + "rf_source.pulse.width_s", + "rf_source.pulse.polarity", + "rf_source.pulse.state", + ), + restore_coverage="none", + risk_flags=("rf_output_must_be_off", "pulse_state", "state_drift"), + safe_alternatives=("rf_source.snapshot",), + ), + _spec( + "rf_source.pulse_output_enable", + "rf_source", + required_capabilities=( + "rf_source.snapshot", + "rf_source.pulse_configure", + "rf_source.pulse_output", + ), + effect="write", + changed_fields=("rf_source.physical_interface.pulse_output.enabled",), + restore_coverage="none", + risk_flags=( + "rf_output_must_be_off", + "physical_interface_output", + "pulse_output_state", + "state_drift", + ), + safe_alternatives=("rf_source.snapshot",), + ), + _spec( + "rf_source.pulse_output_disable", + "rf_source", + required_capabilities=( + "rf_source.snapshot", + "rf_source.pulse_configure", + "rf_source.pulse_output", + ), + effect="write", + changed_fields=("rf_source.physical_interface.pulse_output.enabled",), + restore_coverage="none", + risk_flags=("physical_interface_output", "pulse_output_state", "state_drift"), + safe_alternatives=("rf_source.snapshot",), + ), + _spec( + "rf_source.sweep_configure", + "rf_source", + required_capabilities=("rf_source.snapshot", "rf_source.sweep_configure"), + effect="write", + changed_fields=( + "rf_source.sweep.type", + "rf_source.sweep.direction", + "rf_source.sweep.shape", + "rf_source.sweep.spacing", + "rf_source.sweep.start_frequency_hz", + "rf_source.sweep.stop_frequency_hz", + "rf_source.sweep.points", + "rf_source.sweep.dwell_s", + "rf_source.sweep.state", + ), + restore_coverage="none", + risk_flags=("rf_output_must_be_off", "sweep_disabled", "state_drift"), + safe_alternatives=("rf_source.snapshot",), + ), + _spec( + "rf_source.output_enable", + "rf_source", + required_capabilities=("rf_source.snapshot", "rf_source.output"), + effect="write", + changed_fields=("rf_source.port.output_enabled",), + restore_coverage="none", + risk_flags=("dangerous_output", "rf_output_enable", "state_drift"), + safe_alternatives=("rf_source.snapshot",), + ), + _spec( + "rf_source.output_disable", + "rf_source", + required_capabilities=("rf_source.snapshot", "rf_source.output"), + effect="write", + changed_fields=("rf_source.port.output_enabled",), + restore_coverage="none", + risk_flags=("safe_output_disable", "state_drift"), + safe_alternatives=("rf_source.snapshot",), + ), _spec("power.idn", "power", required_capabilities=("power.idn",), effect="observe"), _spec("power.status", "power", required_capabilities=("power.status",), effect="stateful_read"), _spec("power.measurement", "power", required_capabilities=("power.measurement",), effect="stateful_read"), diff --git a/src/wavebench/services/rf_source_service.py b/src/wavebench/services/rf_source_service.py new file mode 100644 index 0000000..e950cca --- /dev/null +++ b/src/wavebench/services/rf_source_service.py @@ -0,0 +1,2389 @@ +"""Read-only, OFF-only CW/modulation, and guarded RF-output service for RF sources.""" + +from __future__ import annotations + +from collections.abc import Iterator +from contextlib import contextmanager +from dataclasses import dataclass +from typing import Any, cast + +from wavebench.config import RfPortSafetyConfig, RfSourceConfig, WaveBenchConfig +from wavebench.errors import ConfigError +from wavebench.instruments.api import InstrumentDescriptor +from wavebench.instruments.capabilities import require_capabilities +from wavebench.instruments.factory import open_instrument_driver +from wavebench.instruments.registry import resolve_instrument_descriptor +from wavebench.instruments.rf_source_extensions import ( + RfAvailability, + RfCwProfile, + RfCwRequest, + RfCwResult, + RfFeature, + RfFeatureDirection, + RfModulatedOutputProfile, + RfModulatedOutputRequest, + RfModulatedOutputResult, + RfModulationDisableRequest, + RfModulationDisableResult, + RfModulationKind, + RfModulationModeProfile, + RfModulationProfile, + RfModulationRequest, + RfModulationResult, + RfModulationStateSnapshot, + RfModulationSnapshot, + RfModulationSource, + RfModulationState, + RfModulationWaveform, + RfOutputPortProfile, + RfOutputProfile, + RfOutputRequest, + RfOutputResult, + RfPortSnapshot, + RfProtectionStatus, + RfPulseConfigureRequest, + RfPulseConfigureResult, + RfPulseMode, + RfPulseModeProfile, + RfPulseOutputProfile, + RfPulseOutputRequest, + RfPulseOutputResult, + RfPulseOutputSnapshot, + RfPulseProfile, + RfPulseSnapshot, + RfPulseSource, + RfPulseState, + RfSourceDescriptorExtensions, + RfSourceDriver, + RfSourceSnapshot, + RfSweepConfigureRequest, + RfSweepConfigureResult, + RfSweepDirection, + RfSweepModeProfile, + RfSweepProfile, + RfSweepShape, + RfSweepSnapshot, + RfSweepSpacing, + RfSweepState, + RfSweepType, + RfTriggerProfile, + RfTriggerSnapshot, + rf_source_cw_operation_artifact, + rf_source_modulation_disable_operation_artifact, + rf_source_modulated_output_operation_artifact, + rf_source_modulation_operation_artifact, + rf_source_output_operation_artifact, + rf_source_pulse_operation_artifact, + rf_source_pulse_output_operation_artifact, + rf_source_sweep_operation_artifact, + rf_source_trigger_snapshot_operation_artifact, +) +from wavebench.logging import CommandLogger +from wavebench.services.access_policy import access_policy +from wavebench.services.operation_specs import require_operation_spec +from wavebench.services.resource_lease import ResourceLease +from wavebench.services.session_alias import SessionStateAliasMixin +from wavebench.transport.base import InstrumentTransport +from wavebench.transport.session import ( + InstrumentSessionState, + SessionHealth, + SessionPurpose, + SessionTransactionCoordinator, +) + + +@dataclass(frozen=True) +class _RfCwTransaction: + result: RfCwResult + preflight_snapshot: RfSourceSnapshot + postcondition_snapshot: RfSourceSnapshot + + +@dataclass(frozen=True) +class _RfModulationTransaction: + result: RfModulationResult + preflight_snapshot: RfSourceSnapshot + preflight_modulation_state: RfModulationStateSnapshot + postcondition_snapshot: RfSourceSnapshot + postcondition_modulation_snapshot: RfModulationSnapshot + + +@dataclass(frozen=True) +class _RfModulationDisableTransaction: + result: RfModulationDisableResult + preflight_snapshot: RfSourceSnapshot + preflight_modulation_state: RfModulationStateSnapshot + postcondition_snapshot: RfSourceSnapshot + postcondition_modulation_state: RfModulationStateSnapshot + + +@dataclass(frozen=True) +class _RfModulatedOutputTransaction: + result: RfModulatedOutputResult + preflight_snapshot: RfSourceSnapshot + preflight_modulation_snapshot: RfModulationSnapshot + postcondition_snapshot: RfSourceSnapshot + postcondition_modulation_snapshot: RfModulationSnapshot + + +@dataclass(frozen=True) +class _RfPulseTransaction: + result: RfPulseConfigureResult + preflight_snapshot: RfSourceSnapshot + postcondition_snapshot: RfSourceSnapshot + postcondition_pulse_snapshot: RfPulseSnapshot + + +@dataclass(frozen=True) +class _RfPulseOutputTransaction: + result: RfPulseOutputResult + preflight_snapshot: RfSourceSnapshot + preflight_pulse_output_snapshot: RfPulseOutputSnapshot + postcondition_snapshot: RfSourceSnapshot + postcondition_pulse_output_snapshot: RfPulseOutputSnapshot + + +@dataclass(frozen=True) +class _RfSweepTransaction: + result: RfSweepConfigureResult + preflight_snapshot: RfSourceSnapshot + postcondition_snapshot: RfSourceSnapshot + postcondition_sweep_snapshot: RfSweepSnapshot + + +@dataclass(frozen=True) +class _RfOutputTransaction: + result: RfOutputResult + preflight_snapshot: RfSourceSnapshot + postcondition_snapshot: RfSourceSnapshot + + +class RfModulationPostconditionError(ConfigError): + """Strict M3 postcondition failure with already-read typed evidence. + + The exception never changes the fail-closed result. It only retains the + snapshots that were independently read before the strict comparison failed, + so a private evidence harness can record redacted field-level diagnostics + without issuing another query on an uncertain session. + """ + + def __init__( + self, + message: str, + *, + postcondition_snapshot: RfSourceSnapshot, + postcondition_modulation_snapshot: RfModulationSnapshot, + ) -> None: + super().__init__(message) + self.postcondition_snapshot = postcondition_snapshot + self.postcondition_modulation_snapshot = postcondition_modulation_snapshot + + +@dataclass +class RfSourceService(SessionStateAliasMixin): + """Open one configured RF source session for a bounded RF operation.""" + + config: WaveBenchConfig + logger: CommandLogger + session: RfSourceDriver | None = None + descriptor: InstrumentDescriptor | None = None + transport: InstrumentTransport | None = None + session_state: InstrumentSessionState | None = None + lease: ResourceLease | None = None + + def _require(self, operation: str, *capabilities: str) -> None: + rf_source = self._rf_source_config() + access_policy(getattr(rf_source, "access", "read_write"), "rf_source.access").require( + require_operation_spec(operation), + operation=operation, + ) + descriptor = self.descriptor or resolve_instrument_descriptor( + rf_source.driver, + expected_kind="rf_source", + ) + self.descriptor = descriptor + require_capabilities(descriptor, capabilities, operation=operation) + + def _rf_source_config(self) -> RfSourceConfig: + if self.config.rf_source is None or not self.config.rf_source.resource: + raise ConfigError( + "rf_source resource is not configured. Set [rf_source].resource or pass --resource." + ) + return self.config.rf_source + + def _open_rf_source(self) -> RfSourceDriver: + rf_source = self._rf_source_config() + self._prepare_session_open("rf_source") + if self.lease is None: + self.lease = ResourceLease( + resource=rf_source.resource or "", + operation="rf_source.session", + ) + opened = open_instrument_driver( + driver_reference=rf_source.driver, + expected_kind="rf_source", + resource=rf_source.resource or "", + configured_backend=self.config.connection.backend, + timeout_ms=self.config.connection.timeout_ms, + opc_timeout_ms=self.config.connection.opc_timeout_ms, + read_retry_attempts=self.config.connection.read_retry_attempts, + read_retry_delay_ms=self.config.connection.read_retry_delay_ms, + logger=self.logger, + options=getattr(rf_source, "options", {}), + access=getattr(rf_source, "access", "read_write"), + lease=self.lease, + ) + self.descriptor = opened.descriptor + self.transport = opened.transport + self.session_state = getattr(opened, "session_state", None) + return cast(RfSourceDriver, opened.driver) + + def audit_snapshot(self) -> dict[str, Any] | None: + snapshot = getattr(self.transport, "audit_snapshot", None) + return snapshot() if callable(snapshot) else None + + def open_session(self) -> RfSourceDriver: + return self._open_rf_source() + + @contextmanager + def _rf_source_session(self) -> Iterator[RfSourceDriver]: + if self.session is not None: + yield self.session + return + rf_source = self._open_rf_source() + try: + yield rf_source + finally: + rf_source.close() + + def idn(self) -> str: + self._require("rf_source.idn", "rf_source.idn") + with self._rf_source_session() as rf_source: + return rf_source.idn() + + def snapshot(self) -> RfSourceSnapshot: + self._require("rf_source.snapshot", "rf_source.snapshot") + with self._rf_source_session() as rf_source: + session_state = self.session_state + if session_state is None: + return rf_source.get_rf_snapshot() + with session_state.transaction_lock: + if session_state.health is not SessionHealth.HEALTHY: + raise ConfigError("rf_source.snapshot requires a healthy session") + return rf_source.get_rf_snapshot() + + def trigger_snapshot(self, port_id: str) -> RfTriggerSnapshot: + """Read a declared logical trigger configuration without changing state.""" + + operation = "rf_source.trigger_snapshot" + self._require(operation, "rf_source.trigger_snapshot") + profile = self._validate_trigger_snapshot_descriptor(port_id, operation) + with self._rf_source_session() as rf_source: + session_state = self.session_state + if session_state is None: + snapshot = rf_source.get_rf_trigger_snapshot(port_id) + else: + with session_state.transaction_lock: + if session_state.health is not SessionHealth.HEALTHY: + raise ConfigError(f"{operation} requires a healthy session") + snapshot = rf_source.get_rf_trigger_snapshot(port_id) + self._validate_trigger_snapshot_readback( + port_id, + snapshot, + profile, + operation=operation, + ) + return snapshot + + def trigger_snapshot_with_artifact( + self, + port_id: str, + ) -> tuple[RfTriggerSnapshot, dict[str, object]]: + """Read a declared trigger configuration and retain redacted evidence.""" + + snapshot = self.trigger_snapshot(port_id) + return snapshot, rf_source_trigger_snapshot_operation_artifact(snapshot) + + def configure_cw(self, request: RfCwRequest) -> RfCwResult: + return self._configure_cw_transaction(request).result + + def configure_cw_with_artifact( + self, + request: RfCwRequest, + ) -> tuple[RfCwResult, dict[str, object]]: + """Apply M1 CW once and retain typed pre/postcondition evidence.""" + + transaction = self._configure_cw_transaction(request) + return ( + transaction.result, + rf_source_cw_operation_artifact( + request, + transaction.result, + preflight_snapshot=transaction.preflight_snapshot, + postcondition_snapshot=transaction.postcondition_snapshot, + ), + ) + + def _configure_cw_transaction(self, request: RfCwRequest) -> _RfCwTransaction: + """Apply one OFF-only CW field and independently confirm the result. + + M1 deliberately permits exactly one write per call. It does not retry a + failed or mismatched write and leaves RF OFF recovery to the later M2 + output transaction. + """ + + if not isinstance(request, RfCwRequest): + raise ConfigError("rf_source CW configuration requires RfCwRequest") + operation = ( + "rf_source.set_frequency" + if request.frequency_hz is not None + else "rf_source.set_power_dbm" + ) + self._require(operation, "rf_source.snapshot", "rf_source.cw_configure") + port_profile, cw_profile = self._validate_cw_descriptor(request, operation) + with self._rf_source_session() as rf_source: + session_state = self.session_state + if session_state is None: + raise ConfigError(f"{operation} requires a connection-bound session state") + with session_state.transaction_lock: + if session_state.health is not SessionHealth.HEALTHY: + raise ConfigError(f"{operation} requires a healthy session") + preflight_snapshot = rf_source.get_rf_snapshot() + self._validate_cw_preflight( + request, + preflight_snapshot, + port_profile, + cw_profile, + operation=operation, + ) + main_entered = False + try: + main_entered = True + rf_source.configure_cw(request) + postcondition_snapshot = rf_source.get_rf_snapshot() + result = self._validate_cw_postcondition( + request, + postcondition_snapshot, + port_profile, + cw_profile, + operation=operation, + ) + return _RfCwTransaction( + result=result, + preflight_snapshot=preflight_snapshot, + postcondition_snapshot=postcondition_snapshot, + ) + except BaseException: + if main_entered and session_state.health is SessionHealth.HEALTHY: + session_state.degrade( + SessionHealth.UNCERTAIN, + reason="rf_cw_postcondition_unverified", + ) + raise + + def configure_modulation(self, request: RfModulationRequest) -> RfModulationResult: + return self._configure_modulation_transaction(request).result + + def configure_modulation_with_artifact( + self, + request: RfModulationRequest, + ) -> tuple[RfModulationResult, dict[str, object]]: + """Apply bounded M3 internal-sine modulation and retain typed evidence.""" + + transaction = self._configure_modulation_transaction(request) + return ( + transaction.result, + rf_source_modulation_operation_artifact( + request, + transaction.result, + preflight_snapshot=transaction.preflight_snapshot, + preflight_modulation_state=transaction.preflight_modulation_state, + postcondition_snapshot=transaction.postcondition_snapshot, + postcondition_modulation_snapshot=transaction.postcondition_modulation_snapshot, + ), + ) + + def _configure_modulation_transaction( + self, + request: RfModulationRequest, + ) -> _RfModulationTransaction: + """Apply one bounded M3 profile without retry or output recovery. + + M3 never enables RF output. It requires all modulation modes to be + disabled before the fixed driver sequence, then independently confirms + only the requested internal-sine mode and global modulation switch. + A failed or mismatched sequence is not retried and leaves the session + uncertain because the instrument-side profile may be partially changed. + """ + + if not isinstance(request, RfModulationRequest): + raise ConfigError("rf_source modulation configuration requires RfModulationRequest") + operation = "rf_source.modulation_configure" + self._require(operation, "rf_source.snapshot", "rf_source.modulation_configure") + mode_profile = self._validate_modulation_descriptor(request, operation) + with self._rf_source_session() as rf_source: + session_state = self.session_state + if session_state is None: + raise ConfigError(f"{operation} requires a connection-bound session state") + with session_state.transaction_lock: + if session_state.health is not SessionHealth.HEALTHY: + raise ConfigError(f"{operation} requires a healthy session") + preflight_snapshot = rf_source.get_rf_snapshot() + preflight_modulation_state = rf_source.get_rf_modulation_state(request.port_id) + self._validate_modulation_preflight( + request, + preflight_snapshot, + preflight_modulation_state, + operation=operation, + ) + main_entered = False + try: + main_entered = True + rf_source.configure_rf_modulation(request) + postcondition_snapshot = rf_source.get_rf_snapshot() + postcondition_modulation_snapshot = rf_source.get_rf_modulation_snapshot( + request.port_id, + request.kind, + ) + try: + result = self._validate_modulation_postcondition( + request, + postcondition_snapshot, + postcondition_modulation_snapshot, + mode_profile, + operation=operation, + ) + except ConfigError as exc: + raise RfModulationPostconditionError( + str(exc), + postcondition_snapshot=postcondition_snapshot, + postcondition_modulation_snapshot=postcondition_modulation_snapshot, + ) from exc + return _RfModulationTransaction( + result=result, + preflight_snapshot=preflight_snapshot, + preflight_modulation_state=preflight_modulation_state, + postcondition_snapshot=postcondition_snapshot, + postcondition_modulation_snapshot=postcondition_modulation_snapshot, + ) + except BaseException: + if main_entered and session_state.health is SessionHealth.HEALTHY: + session_state.degrade( + SessionHealth.UNCERTAIN, + reason="rf_modulation_postcondition_unverified", + ) + raise + + def disable_modulation( + self, + request: RfModulationDisableRequest, + ) -> RfModulationDisableResult: + return self._disable_modulation_transaction(request).result + + def disable_modulation_with_artifact( + self, + request: RfModulationDisableRequest, + ) -> tuple[RfModulationDisableResult, dict[str, object]]: + """Disable one active M3 mode and retain typed pre/postcondition evidence.""" + + transaction = self._disable_modulation_transaction(request) + return ( + transaction.result, + rf_source_modulation_disable_operation_artifact( + request, + transaction.result, + preflight_snapshot=transaction.preflight_snapshot, + preflight_modulation_state=transaction.preflight_modulation_state, + postcondition_snapshot=transaction.postcondition_snapshot, + postcondition_modulation_state=transaction.postcondition_modulation_state, + ), + ) + + def _disable_modulation_transaction( + self, + request: RfModulationDisableRequest, + ) -> _RfModulationDisableTransaction: + """Disable one known active M3 mode without retry or RF-output recovery. + + The operation is intentionally narrow: a write is only permitted when + RF is OFF and typed state proves that exactly ``request.kind`` is active. + An already disabled, internally consistent state is a no-write success. + Any write failure or ambiguous postcondition leaves the session uncertain + for a fresh, independently preflighted recovery attempt. + """ + + if not isinstance(request, RfModulationDisableRequest): + raise ConfigError("rf_source modulation disable requires RfModulationDisableRequest") + operation = "rf_source.modulation_disable" + self._require(operation, "rf_source.snapshot", "rf_source.modulation_disable") + self._validate_modulation_disable_descriptor(request, operation) + with self._rf_source_session() as rf_source: + session_state = self.session_state + if session_state is None: + raise ConfigError(f"{operation} requires a connection-bound session state") + with session_state.transaction_lock: + if session_state.health is not SessionHealth.HEALTHY: + raise ConfigError(f"{operation} requires a healthy session") + preflight_snapshot = rf_source.get_rf_snapshot() + preflight_modulation_state = rf_source.get_rf_modulation_state(request.port_id) + write_required = self._validate_modulation_disable_preflight( + request, + preflight_snapshot, + preflight_modulation_state, + operation=operation, + ) + if not write_required: + return _RfModulationDisableTransaction( + result=RfModulationDisableResult( + port_id=request.port_id, + kind=request.kind, + write_completed=False, + ), + preflight_snapshot=preflight_snapshot, + preflight_modulation_state=preflight_modulation_state, + postcondition_snapshot=preflight_snapshot, + postcondition_modulation_state=preflight_modulation_state, + ) + + main_entered = False + try: + main_entered = True + rf_source.disable_rf_modulation(request) + postcondition_snapshot = rf_source.get_rf_snapshot() + postcondition_modulation_state = rf_source.get_rf_modulation_state( + request.port_id + ) + self._validate_modulation_disable_postcondition( + request, + postcondition_snapshot, + postcondition_modulation_state, + operation=operation, + ) + return _RfModulationDisableTransaction( + result=RfModulationDisableResult( + port_id=request.port_id, + kind=request.kind, + write_completed=True, + ), + preflight_snapshot=preflight_snapshot, + preflight_modulation_state=preflight_modulation_state, + postcondition_snapshot=postcondition_snapshot, + postcondition_modulation_state=postcondition_modulation_state, + ) + except BaseException: + if main_entered and session_state.health is SessionHealth.HEALTHY: + session_state.degrade( + SessionHealth.UNCERTAIN, + reason="rf_modulation_disable_postcondition_unverified", + ) + raise + + def configure_pulse(self, request: RfPulseConfigureRequest) -> RfPulseConfigureResult: + return self._configure_pulse_transaction(request).result + + def configure_pulse_with_artifact( + self, + request: RfPulseConfigureRequest, + ) -> tuple[RfPulseConfigureResult, dict[str, object]]: + """Apply one RF-OFF internal single-pulse profile with typed evidence.""" + + transaction = self._configure_pulse_transaction(request) + return ( + transaction.result, + rf_source_pulse_operation_artifact( + request, + transaction.result, + preflight_snapshot=transaction.preflight_snapshot, + postcondition_snapshot=transaction.postcondition_snapshot, + postcondition_pulse_snapshot=transaction.postcondition_pulse_snapshot, + ), + ) + + def _configure_pulse_transaction( + self, + request: RfPulseConfigureRequest, + ) -> _RfPulseTransaction: + """Configure one disabled internal single-pulse profile without triggering. + + The initial M4 slice intentionally cannot arm or trigger a pulse. It + configures timing and polarity while the target RF output, pulse state, + modulation, and Sweep are all OFF, then requires the pulse to remain + disabled after independent profile readback. A failed main write or + postcondition is never retried and leaves the session uncertain. + """ + + if not isinstance(request, RfPulseConfigureRequest): + raise ConfigError("rf_source pulse configuration requires RfPulseConfigureRequest") + operation = "rf_source.pulse_configure" + self._require(operation, "rf_source.snapshot", "rf_source.pulse_configure") + mode_profile = self._validate_pulse_descriptor(request, operation) + with self._rf_source_session() as rf_source: + session_state = self.session_state + if session_state is None: + raise ConfigError(f"{operation} requires a connection-bound session state") + with session_state.transaction_lock: + if session_state.health is not SessionHealth.HEALTHY: + raise ConfigError(f"{operation} requires a healthy session") + preflight_snapshot = rf_source.get_rf_snapshot() + self._validate_pulse_preflight( + request, + preflight_snapshot, + operation=operation, + ) + main_entered = False + try: + main_entered = True + rf_source.configure_rf_pulse(request) + postcondition_snapshot = rf_source.get_rf_snapshot() + postcondition_pulse_snapshot = rf_source.get_rf_pulse_snapshot(request.port_id) + result = self._validate_pulse_postcondition( + request, + postcondition_snapshot, + postcondition_pulse_snapshot, + mode_profile, + operation=operation, + ) + return _RfPulseTransaction( + result=result, + preflight_snapshot=preflight_snapshot, + postcondition_snapshot=postcondition_snapshot, + postcondition_pulse_snapshot=postcondition_pulse_snapshot, + ) + except BaseException: + if main_entered and session_state.health is SessionHealth.HEALTHY: + session_state.degrade( + SessionHealth.UNCERTAIN, + reason="rf_pulse_postcondition_unverified", + ) + raise + + def set_pulse_output(self, request: RfPulseOutputRequest) -> RfPulseOutputResult: + """Set one declared physical Pulse-output interface without changing RF output.""" + + return self._set_pulse_output_transaction(request).result + + def set_pulse_output_with_artifact( + self, + request: RfPulseOutputRequest, + ) -> tuple[RfPulseOutputResult, dict[str, object]]: + """Set one declared physical Pulse-output interface with typed evidence. + + This operation is intentionally independent from ``configure_pulse``. + It never changes RF output, trigger configuration, Sweep, or a + receiving instrument. A failed write or readback is never retried and + leaves the session uncertain for an independently preflighted recovery. + """ + + transaction = self._set_pulse_output_transaction(request) + return ( + transaction.result, + rf_source_pulse_output_operation_artifact( + request, + transaction.result, + preflight_snapshot=transaction.preflight_snapshot, + preflight_pulse_output_snapshot=transaction.preflight_pulse_output_snapshot, + postcondition_snapshot=transaction.postcondition_snapshot, + postcondition_pulse_output_snapshot=transaction.postcondition_pulse_output_snapshot, + ), + ) + + def _set_pulse_output_transaction( + self, + request: RfPulseOutputRequest, + ) -> _RfPulseOutputTransaction: + """Run one bounded physical Pulse-output state transaction. + + Enabling requires the exact descriptor-declared internal Pulse profile + and the same RF-OFF baseline as the M4 Pulse configurator. Disabling + is deliberately narrower: it requires a readable declared interface + state but does not reject a caller's attempt to remove an auxiliary + output merely because the RF configuration has drifted. + """ + + if not isinstance(request, RfPulseOutputRequest): + raise ConfigError("rf_source pulse-output control requires RfPulseOutputRequest") + operation = ( + "rf_source.pulse_output_enable" + if request.enabled + else "rf_source.pulse_output_disable" + ) + self._require( + operation, + "rf_source.snapshot", + "rf_source.pulse_configure", + "rf_source.pulse_output", + ) + profile = self._validate_pulse_output_descriptor(request, operation) + with self._rf_source_session() as rf_source: + session_state = self.session_state + if session_state is None: + raise ConfigError(f"{operation} requires a connection-bound session state") + with session_state.transaction_lock: + if session_state.health is not SessionHealth.HEALTHY: + raise ConfigError(f"{operation} requires a healthy session") + preflight_snapshot = rf_source.get_rf_snapshot() + preflight_pulse_output_snapshot = rf_source.get_rf_pulse_output_snapshot( + request.port_id, + request.interface_id, + ) + if request.enabled: + current_enabled = self._validate_pulse_output_enable_snapshot( + request, + preflight_snapshot, + preflight_pulse_output_snapshot, + profile, + operation=operation, + ) + else: + current_enabled = self._validate_pulse_output_disable_snapshot( + request, + preflight_pulse_output_snapshot, + profile, + operation=operation, + ) + if current_enabled is request.enabled: + return _RfPulseOutputTransaction( + result=RfPulseOutputResult( + port_id=request.port_id, + interface_id=request.interface_id, + enabled=request.enabled, + write_completed=False, + ), + preflight_snapshot=preflight_snapshot, + preflight_pulse_output_snapshot=preflight_pulse_output_snapshot, + postcondition_snapshot=preflight_snapshot, + postcondition_pulse_output_snapshot=preflight_pulse_output_snapshot, + ) + + main_entered = False + try: + main_entered = True + rf_source.set_rf_pulse_output(request) + postcondition_snapshot = rf_source.get_rf_snapshot() + postcondition_pulse_output_snapshot = rf_source.get_rf_pulse_output_snapshot( + request.port_id, + request.interface_id, + ) + if request.enabled: + postcondition_enabled = self._validate_pulse_output_enable_snapshot( + request, + postcondition_snapshot, + postcondition_pulse_output_snapshot, + profile, + operation=operation, + ) + else: + postcondition_enabled = self._validate_pulse_output_disable_snapshot( + request, + postcondition_pulse_output_snapshot, + profile, + operation=operation, + ) + if postcondition_enabled is not request.enabled: + raise ConfigError( + f"{operation} postcondition does not match requested Pulse-output state" + ) + return _RfPulseOutputTransaction( + result=RfPulseOutputResult( + port_id=request.port_id, + interface_id=request.interface_id, + enabled=request.enabled, + write_completed=True, + ), + preflight_snapshot=preflight_snapshot, + preflight_pulse_output_snapshot=preflight_pulse_output_snapshot, + postcondition_snapshot=postcondition_snapshot, + postcondition_pulse_output_snapshot=postcondition_pulse_output_snapshot, + ) + except BaseException: + if main_entered and session_state.health is SessionHealth.HEALTHY: + session_state.degrade( + SessionHealth.UNCERTAIN, + reason="rf_pulse_output_postcondition_unverified", + ) + raise + + def configure_sweep(self, request: RfSweepConfigureRequest) -> RfSweepConfigureResult: + return self._configure_sweep_transaction(request).result + + def configure_sweep_with_artifact( + self, + request: RfSweepConfigureRequest, + ) -> tuple[RfSweepConfigureResult, dict[str, object]]: + """Apply one RF-OFF Step Sweep profile while Sweep stays disabled.""" + + transaction = self._configure_sweep_transaction(request) + return ( + transaction.result, + rf_source_sweep_operation_artifact( + request, + transaction.result, + preflight_snapshot=transaction.preflight_snapshot, + postcondition_snapshot=transaction.postcondition_snapshot, + postcondition_sweep_snapshot=transaction.postcondition_sweep_snapshot, + ), + ) + + def _configure_sweep_transaction( + self, + request: RfSweepConfigureRequest, + ) -> _RfSweepTransaction: + """Configure one disabled, frequency-only Step Sweep without triggering. + + This first Sweep slice cannot arm, trigger, fire, or select a Level + Sweep. It writes only the descriptor-bounded frequency profile and + explicitly requires RF output, modulation, Pulse, and Sweep to remain + disabled through the independent readback. + """ + + if not isinstance(request, RfSweepConfigureRequest): + raise ConfigError("rf_source Sweep configuration requires RfSweepConfigureRequest") + operation = "rf_source.sweep_configure" + self._require(operation, "rf_source.snapshot", "rf_source.sweep_configure") + mode_profile = self._validate_sweep_descriptor(request, operation) + with self._rf_source_session() as rf_source: + session_state = self.session_state + if session_state is None: + raise ConfigError(f"{operation} requires a connection-bound session state") + with session_state.transaction_lock: + if session_state.health is not SessionHealth.HEALTHY: + raise ConfigError(f"{operation} requires a healthy session") + preflight_snapshot = rf_source.get_rf_snapshot() + self._validate_sweep_preflight( + request, + preflight_snapshot, + operation=operation, + ) + main_entered = False + try: + main_entered = True + rf_source.configure_rf_sweep(request) + postcondition_snapshot = rf_source.get_rf_snapshot() + postcondition_sweep_snapshot = rf_source.get_rf_sweep_snapshot(request.port_id) + result = self._validate_sweep_postcondition( + request, + postcondition_snapshot, + postcondition_sweep_snapshot, + mode_profile, + operation=operation, + ) + return _RfSweepTransaction( + result=result, + preflight_snapshot=preflight_snapshot, + postcondition_snapshot=postcondition_snapshot, + postcondition_sweep_snapshot=postcondition_sweep_snapshot, + ) + except BaseException: + if main_entered and session_state.health is SessionHealth.HEALTHY: + session_state.degrade( + SessionHealth.UNCERTAIN, + reason="rf_sweep_postcondition_unverified", + ) + raise + + def set_output(self, request: RfOutputRequest) -> RfOutputResult: + return self._set_output_transaction(request).result + + def set_output_with_artifact( + self, + request: RfOutputRequest, + ) -> tuple[RfOutputResult, dict[str, object]]: + """Apply M2 RF output once and retain typed pre/postcondition evidence.""" + + transaction = self._set_output_transaction(request) + return ( + transaction.result, + rf_source_output_operation_artifact( + request, + transaction.result, + preflight_snapshot=transaction.preflight_snapshot, + postcondition_snapshot=transaction.postcondition_snapshot, + ), + ) + + def enable_modulated_output( + self, + request: RfModulatedOutputRequest, + ) -> RfModulatedOutputResult: + return self._enable_modulated_output_transaction(request).result + + def enable_modulated_output_with_artifact( + self, + request: RfModulatedOutputRequest, + ) -> tuple[RfModulatedOutputResult, dict[str, object]]: + """Enable RF once for one exactly verified active modulation profile. + + This does not configure modulation, turn RF back off after success, or + disable modulation. Those actions remain explicit, separate operations. + On an uncertain RF-ON result it uses the existing one-shot guarded OFF + recovery, never retries RF ON, and keeps the session uncertain. + """ + + transaction = self._enable_modulated_output_transaction(request) + return ( + transaction.result, + rf_source_modulated_output_operation_artifact( + request, + transaction.result, + preflight_snapshot=transaction.preflight_snapshot, + preflight_modulation_snapshot=transaction.preflight_modulation_snapshot, + postcondition_snapshot=transaction.postcondition_snapshot, + postcondition_modulation_snapshot=transaction.postcondition_modulation_snapshot, + ), + ) + + def _enable_modulated_output_transaction( + self, + request: RfModulatedOutputRequest, + ) -> _RfModulatedOutputTransaction: + """Run one explicit, profile-bound RF-ON transaction under active modulation.""" + + if not isinstance(request, RfModulatedOutputRequest): + raise ConfigError( + "rf_source modulated-output enable requires RfModulatedOutputRequest" + ) + operation = "rf_source.modulated_output_enable" + self._require( + operation, + "rf_source.snapshot", + "rf_source.output", + "rf_source.modulation_configure", + "rf_source.modulated_output_enable", + ) + ( + port_profile, + output_profile, + modulated_output_profile, + extensions, + ) = self._validate_modulated_output_descriptor(request, operation) + with self._rf_source_session() as rf_source: + session_state = self.session_state + if session_state is None: + raise ConfigError(f"{operation} requires a connection-bound session state") + with session_state.transaction_lock: + if session_state.health is not SessionHealth.HEALTHY: + raise ConfigError(f"{operation} requires a healthy session") + preflight_snapshot = rf_source.get_rf_snapshot() + preflight_modulation_snapshot = rf_source.get_rf_modulation_snapshot( + request.port_id, + request.kind, + ) + self._validate_modulated_output_enable_snapshot( + request, + preflight_snapshot, + preflight_modulation_snapshot, + port_profile, + output_profile, + modulated_output_profile, + extensions, + expected_output_enabled=False, + operation=operation, + ) + main_entered = False + try: + main_entered = True + rf_source.set_rf_output(RfOutputRequest(port_id=request.port_id, enabled=True)) + postcondition_snapshot = rf_source.get_rf_snapshot() + postcondition_modulation_snapshot = rf_source.get_rf_modulation_snapshot( + request.port_id, + request.kind, + ) + modulation_result = self._validate_modulated_output_enable_snapshot( + request, + postcondition_snapshot, + postcondition_modulation_snapshot, + port_profile, + output_profile, + modulated_output_profile, + extensions, + expected_output_enabled=True, + operation=operation, + ) + return _RfModulatedOutputTransaction( + result=RfModulatedOutputResult( + modulation=modulation_result, + write_completed=True, + ), + preflight_snapshot=preflight_snapshot, + preflight_modulation_snapshot=preflight_modulation_snapshot, + postcondition_snapshot=postcondition_snapshot, + postcondition_modulation_snapshot=postcondition_modulation_snapshot, + ) + except BaseException as exc: + if main_entered: + self._degrade_output_session_uncertain(session_state) + recovery = self._recover_rf_output_off( + rf_source, + request.port_id, + operation=operation, + ) + try: + setattr(exc, "rf_source_recovery", recovery) + except Exception: + pass + raise + + def _set_output_transaction(self, request: RfOutputRequest) -> _RfOutputTransaction: + """Execute one per-port M2 output transaction with bounded OFF recovery.""" + + if not isinstance(request, RfOutputRequest): + raise ConfigError("rf_source output control requires RfOutputRequest") + operation = "rf_source.output_enable" if request.enabled else "rf_source.output_disable" + self._require(operation, "rf_source.snapshot", "rf_source.output") + port_profile, output_profile, extensions = self._validate_output_descriptor( + request, + operation, + ) + with self._rf_source_session() as rf_source: + session_state = self.session_state + if session_state is None: + raise ConfigError(f"{operation} requires a connection-bound session state") + with session_state.transaction_lock: + if session_state.health is not SessionHealth.HEALTHY: + raise ConfigError(f"{operation} requires a healthy session") + preflight_snapshot = rf_source.get_rf_snapshot() + if request.enabled: + current_enabled = self._validate_output_enable_snapshot( + request, + preflight_snapshot, + port_profile, + output_profile, + extensions, + operation=operation, + ) + else: + current_enabled = self._output_state_if_observed( + self._snapshot_port( + preflight_snapshot, + request.port_id, + operation=operation, + ) + ) + if current_enabled is request.enabled: + return _RfOutputTransaction( + result=RfOutputResult( + port_id=request.port_id, + enabled=request.enabled, + write_completed=False, + ), + preflight_snapshot=preflight_snapshot, + postcondition_snapshot=preflight_snapshot, + ) + + main_entered = False + try: + main_entered = True + rf_source.set_rf_output(request) + postcondition_snapshot = rf_source.get_rf_snapshot() + if request.enabled: + postcondition_enabled = self._validate_output_enable_snapshot( + request, + postcondition_snapshot, + port_profile, + output_profile, + extensions, + operation=operation, + ) + if postcondition_enabled is not True: + raise ConfigError( + f"{operation} postcondition reports RF output OFF or unknown" + ) + else: + self._validate_output_disable_snapshot( + request, + postcondition_snapshot, + operation=operation, + ) + return _RfOutputTransaction( + result=RfOutputResult( + port_id=request.port_id, + enabled=request.enabled, + write_completed=True, + ), + preflight_snapshot=preflight_snapshot, + postcondition_snapshot=postcondition_snapshot, + ) + except BaseException as exc: + if main_entered: + if request.enabled: + self._degrade_output_session_uncertain(session_state) + recovery = self._recover_rf_output_off( + rf_source, + request.port_id, + operation=operation, + ) + try: + setattr(exc, "rf_source_recovery", recovery) + except Exception: + pass + else: + self._degrade_output_session_poisoned(session_state) + raise + + def _validate_output_descriptor( + self, + request: RfOutputRequest, + operation: str, + ) -> tuple[RfOutputPortProfile, RfOutputProfile, RfSourceDescriptorExtensions]: + descriptor = self.descriptor + extensions = None if descriptor is None else descriptor.rf_source_extensions + if not isinstance(extensions, RfSourceDescriptorExtensions): + raise ConfigError(f"{operation} requires validated rf_source_extensions") + port_profile = next( + (port for port in extensions.topology.ports if port.port_id == request.port_id), + None, + ) + if port_profile is None: + raise ConfigError(f"{operation} references an undeclared RF port") + feature = next( + (item for item in extensions.features if item.feature is RfFeature.OUTPUT), + None, + ) + if ( + feature is None + or request.port_id not in feature.port_ids + or RfFeatureDirection.ENABLE not in feature.directions + or RfFeatureDirection.DISABLE not in feature.directions + or not isinstance(feature.profile, RfOutputProfile) + or not feature.profile.output_readable + ): + raise ConfigError(f"{operation} requires a readable output profile for the target port") + return port_profile, feature.profile, extensions + + def _validate_modulated_output_descriptor( + self, + request: RfModulatedOutputRequest, + operation: str, + ) -> tuple[ + RfOutputPortProfile, + RfOutputProfile, + RfModulatedOutputProfile, + RfSourceDescriptorExtensions, + ]: + port_profile, output_profile, extensions = self._validate_output_descriptor( + RfOutputRequest(port_id=request.port_id, enabled=True), + operation, + ) + self._validate_modulation_descriptor(request.modulation, operation) + feature = next( + (item for item in extensions.features if item.feature is RfFeature.MODULATED_OUTPUT), + None, + ) + if ( + feature is None + or RfFeatureDirection.ENABLE not in feature.directions + or request.port_id not in feature.port_ids + or not isinstance(feature.profile, RfModulatedOutputProfile) + ): + raise ConfigError( + f"{operation} requires a modulated-output enable profile for the target port" + ) + mode_profile = next( + (item for item in feature.profile.mode_profiles if item.kind is request.kind), + None, + ) + if mode_profile is None: + raise ConfigError(f"{operation} does not support the requested modulation kind") + if ( + mode_profile.source is not RfModulationSource.INTERNAL + or mode_profile.waveform is not RfModulationWaveform.SINE + or mode_profile.value_unit is not request.modulation.value_unit + ): + raise ConfigError(f"{operation} requires an internal-sine profile for the requested kind") + if not mode_profile.value_min <= request.modulation.value <= mode_profile.value_max: + raise ConfigError(f"{operation} request value is outside the descriptor range") + if not ( + mode_profile.internal_frequency_min_hz + <= request.modulation.internal_frequency_hz + <= mode_profile.internal_frequency_max_hz + ): + raise ConfigError( + f"{operation} request internal_frequency_hz is outside the descriptor range" + ) + return port_profile, output_profile, feature.profile, extensions + + def _validate_modulated_output_enable_snapshot( + self, + request: RfModulatedOutputRequest, + snapshot: RfSourceSnapshot, + modulation_snapshot: RfModulationSnapshot, + port_profile: RfOutputPortProfile, + output_profile: RfOutputProfile, + modulated_output_profile: RfModulatedOutputProfile, + extensions: RfSourceDescriptorExtensions, + *, + expected_output_enabled: bool, + operation: str, + ) -> RfModulationResult: + del output_profile + port = self._snapshot_port(snapshot, request.port_id, operation=operation) + safety_port = self._output_safety_port(request.port_id, operation=operation) + frequency_hz = self._observed_value( + port.frequency_hz, + f"{operation} requires a readable RF frequency", + ) + power_dbm = self._observed_value( + port.power_dbm, + f"{operation} requires a readable RF power", + ) + output_enabled = self._observed_value( + port.output_enabled, + f"{operation} requires a readable RF output state", + ) + modulation = self._observed_value( + port.modulation, + f"{operation} requires a readable modulation state", + ) + pulse = self._observed_value( + port.pulse, + f"{operation} requires a readable Pulse state", + ) + sweep = self._observed_value( + port.sweep, + f"{operation} requires a readable Sweep state", + ) + protection = self._observed_value( + snapshot.protection, + f"{operation} requires a readable protection state", + ) + if not isinstance(frequency_hz, (int, float)) or isinstance(frequency_hz, bool): + raise ConfigError(f"{operation} requires a valid RF frequency") + if not isinstance(power_dbm, (int, float)) or isinstance(power_dbm, bool): + raise ConfigError(f"{operation} requires a valid RF power") + if not isinstance(output_enabled, bool): + raise ConfigError(f"{operation} requires a valid RF output state") + if output_enabled is not expected_output_enabled: + expected = "ON" if expected_output_enabled else "OFF" + raise ConfigError(f"{operation} requires target RF output {expected}") + if modulation is not RfModulationState.ENABLED: + raise ConfigError(f"{operation} requires modulation enabled") + if pulse is not RfPulseState.DISABLED: + raise ConfigError(f"{operation} requires Pulse disabled") + if sweep is not RfSweepState.DISABLED: + raise ConfigError(f"{operation} requires Sweep disabled") + if not isinstance(protection, RfProtectionStatus): + raise ConfigError(f"{operation} requires a valid protection state") + if not ( + port_profile.frequency_min_hz <= frequency_hz <= port_profile.frequency_max_hz + and safety_port.minimum_frequency_hz + <= frequency_hz + <= safety_port.maximum_frequency_hz + ): + raise ConfigError(f"{operation} requires RF frequency within descriptor and safety ranges") + if not ( + port_profile.power_min_dbm <= power_dbm <= port_profile.power_max_dbm + and power_dbm <= safety_port.maximum_power_dbm + and power_dbm <= modulated_output_profile.maximum_power_dbm + ): + raise ConfigError( + f"{operation} requires RF power within descriptor, modulated-output, and safety ranges" + ) + if safety_port.actual_termination_ohm != port_profile.power_reference_impedance_ohm: + raise ConfigError(f"{operation} requires actual termination to match RF power reference") + policies = {item.code: item for item in extensions.protection_conditions} + unknown = sorted(set(protection.active_codes) - set(policies)) + if unknown: + raise ConfigError(f"{operation} rejects an unknown active protection condition") + if any(policies[code].blocks_output_enable for code in protection.active_codes): + raise ConfigError(f"{operation} rejects an active blocking protection condition") + mode_profile = next( + ( + item + for item in modulated_output_profile.mode_profiles + if item.kind is request.kind + ), + None, + ) + if mode_profile is None: # defensive: descriptor validation already requires it. + raise ConfigError(f"{operation} does not support the requested modulation kind") + self._validate_modulation_snapshot_identity( + request.modulation, + modulation_snapshot, + mode_profile, + require_target_profile=True, + require_selected_fm_pm_kind=True, + operation=operation, + ) + if modulation_snapshot.enabled_modes != (request.kind,): + raise ConfigError(f"{operation} requires only the requested modulation mode") + if modulation_snapshot.global_enabled is not True: + raise ConfigError(f"{operation} requires global modulation enabled") + if modulation_snapshot.fault_codes: + raise ConfigError(f"{operation} rejects an active modulation fault condition") + if ( + modulation_snapshot.internal_frequency_hz != request.modulation.internal_frequency_hz + or modulation_snapshot.value != request.modulation.value + or modulation_snapshot.value_unit is not request.modulation.value_unit + ): + raise ConfigError(f"{operation} modulation readback does not match request") + return self._modulation_result(request.modulation) + + def _validate_output_enable_snapshot( + self, + request: RfOutputRequest, + snapshot: RfSourceSnapshot, + port_profile: RfOutputPortProfile, + output_profile: RfOutputProfile, + extensions: RfSourceDescriptorExtensions, + *, + operation: str, + ) -> bool: + del output_profile + port = self._snapshot_port(snapshot, request.port_id, operation=operation) + safety_port = self._output_safety_port(request.port_id, operation=operation) + frequency_hz = self._observed_value( + port.frequency_hz, + f"{operation} requires a readable RF frequency", + ) + power_dbm = self._observed_value( + port.power_dbm, + f"{operation} requires a readable RF power", + ) + output_enabled = self._observed_value( + port.output_enabled, + f"{operation} requires a readable RF output state", + ) + modulation = self._observed_value( + port.modulation, + f"{operation} requires a readable modulation state", + ) + pulse = self._observed_value( + port.pulse, + f"{operation} requires a readable Pulse state", + ) + sweep = self._observed_value( + port.sweep, + f"{operation} requires a readable Sweep state", + ) + protection = self._observed_value( + snapshot.protection, + f"{operation} requires a readable protection state", + ) + if not isinstance(frequency_hz, (int, float)) or isinstance(frequency_hz, bool): + raise ConfigError(f"{operation} requires a valid RF frequency") + if not isinstance(power_dbm, (int, float)) or isinstance(power_dbm, bool): + raise ConfigError(f"{operation} requires a valid RF power") + if not isinstance(output_enabled, bool): + raise ConfigError(f"{operation} requires a valid RF output state") + if modulation is not RfModulationState.DISABLED: + raise ConfigError(f"{operation} requires modulation disabled") + if pulse is not RfPulseState.DISABLED: + raise ConfigError(f"{operation} requires Pulse disabled") + if sweep is not RfSweepState.DISABLED: + raise ConfigError(f"{operation} requires Sweep disabled") + if not isinstance(protection, RfProtectionStatus): + raise ConfigError(f"{operation} requires a valid protection state") + if not ( + port_profile.frequency_min_hz <= frequency_hz <= port_profile.frequency_max_hz + and safety_port.minimum_frequency_hz + <= frequency_hz + <= safety_port.maximum_frequency_hz + ): + raise ConfigError(f"{operation} requires RF frequency within descriptor and safety ranges") + if not ( + port_profile.power_min_dbm <= power_dbm <= port_profile.power_max_dbm + and power_dbm <= safety_port.maximum_power_dbm + ): + raise ConfigError(f"{operation} requires RF power within descriptor and safety ranges") + if safety_port.actual_termination_ohm != port_profile.power_reference_impedance_ohm: + raise ConfigError(f"{operation} requires actual termination to match RF power reference") + policies = {item.code: item for item in extensions.protection_conditions} + unknown = sorted(set(protection.active_codes) - set(policies)) + if unknown: + raise ConfigError(f"{operation} rejects an unknown active protection condition") + if any(policies[code].blocks_output_enable for code in protection.active_codes): + raise ConfigError(f"{operation} rejects an active blocking protection condition") + return output_enabled + + def _validate_output_disable_snapshot( + self, + request: RfOutputRequest, + snapshot: RfSourceSnapshot, + *, + operation: str, + ) -> None: + port = self._snapshot_port(snapshot, request.port_id, operation=operation) + output_enabled = self._observed_value( + port.output_enabled, + f"{operation} requires a readable RF output state", + ) + if output_enabled is not False: + raise ConfigError(f"{operation} postcondition reports RF output ON or unknown") + + def _output_safety_port(self, port_id: str, *, operation: str) -> RfPortSafetyConfig: + safety_port = next( + (item for item in self._rf_source_config().safety_ports if item.port_id == port_id), + None, + ) + if safety_port is None: + raise ConfigError(f"{operation} requires complete safety configuration for the target RF port") + return safety_port + + @staticmethod + def _output_state_if_observed(port: RfPortSnapshot) -> bool | None: + if port.output_enabled.availability is not RfAvailability.VALUE: + return None + value = port.output_enabled.value + return value if isinstance(value, bool) else None + + def _recover_rf_output_off( + self, + rf_source: RfSourceDriver, + port_id: str, + *, + operation: str, + ) -> dict[str, str]: + """Attempt exactly one bounded, same-port OFF recovery after a failed ON.""" + + session_state = self.session_state + if session_state is None or session_state.health in { + SessionHealth.POISONED, + SessionHealth.CLOSED, + }: + return {"status": "not_attempted", "reason": "session_unavailable"} + coordinator = SessionTransactionCoordinator(session_state) + fields = ("rf_source.port.output_enabled",) + timeout_ms = self.config.connection.timeout_ms + request = RfOutputRequest(port_id=port_id, enabled=False) + try: + with coordinator.authorize( + operation_id="rf_source.output_recovery", + purpose=SessionPurpose.RECOVERY, + allowed_io={"write"}, + fields=fields, + timeout_ms=timeout_ms, + max_steps=1, + ): + rf_source.set_rf_output(request) + except BaseException: + return {"status": "off_failed", "session_health": session_state.health.value} + if session_state.health in {SessionHealth.POISONED, SessionHealth.CLOSED}: + return {"status": "off_sent_unverified", "reason": "session_unavailable"} + try: + with coordinator.authorize( + operation_id="rf_source.output_recovery_verify", + purpose=SessionPurpose.RECOVERY, + allowed_io={"query"}, + fields=fields, + timeout_ms=timeout_ms, + max_steps=16, + ): + snapshot = rf_source.get_rf_snapshot() + self._validate_output_disable_snapshot( + request, + snapshot, + operation=operation, + ) + except BaseException: + return {"status": "off_sent_unverified", "session_health": session_state.health.value} + return {"status": "off_verified", "session_health": session_state.health.value} + + @staticmethod + def _degrade_output_session_uncertain(session_state: InstrumentSessionState) -> None: + if session_state.health is SessionHealth.HEALTHY: + session_state.degrade( + SessionHealth.UNCERTAIN, + reason="rf_output_enable_unverified", + ) + + @staticmethod + def _degrade_output_session_poisoned(session_state: InstrumentSessionState) -> None: + if session_state.health not in {SessionHealth.POISONED, SessionHealth.CLOSED}: + session_state.degrade( + SessionHealth.POISONED, + reason="rf_output_disable_unverified", + ) + + def _validate_cw_descriptor( + self, + request: RfCwRequest, + operation: str, + ) -> tuple[RfOutputPortProfile, RfCwProfile]: + descriptor = self.descriptor + extensions = None if descriptor is None else descriptor.rf_source_extensions + if not isinstance(extensions, RfSourceDescriptorExtensions): + raise ConfigError(f"{operation} requires validated rf_source_extensions") + port_profile = next( + (port for port in extensions.topology.ports if port.port_id == request.port_id), + None, + ) + if port_profile is None: + raise ConfigError(f"{operation} references an undeclared RF port") + feature = next( + (item for item in extensions.features if item.feature is RfFeature.CW), + None, + ) + if ( + feature is None + or RfFeatureDirection.CONFIGURE not in feature.directions + or request.port_id not in feature.port_ids + or not isinstance(feature.profile, RfCwProfile) + ): + raise ConfigError(f"{operation} requires a configurable CW profile for the target port") + profile = feature.profile + if request.frequency_hz is not None: + if not profile.frequency_configurable: + raise ConfigError(f"{operation} requires a configurable CW frequency profile") + if not port_profile.frequency_min_hz <= request.frequency_hz <= port_profile.frequency_max_hz: + raise ConfigError(f"{operation} request frequency_hz is outside the descriptor range") + else: + assert request.power_dbm is not None + if not profile.power_configurable: + raise ConfigError(f"{operation} requires a configurable CW power profile") + if not port_profile.power_min_dbm <= request.power_dbm <= port_profile.power_max_dbm: + raise ConfigError(f"{operation} request power_dbm is outside the descriptor range") + return port_profile, profile + + def _validate_trigger_snapshot_descriptor( + self, + port_id: str, + operation: str, + ) -> RfTriggerProfile: + descriptor = self.descriptor + extensions = None if descriptor is None else descriptor.rf_source_extensions + if not isinstance(extensions, RfSourceDescriptorExtensions): + raise ConfigError(f"{operation} requires validated rf_source_extensions") + if not any(port.port_id == port_id for port in extensions.topology.ports): + raise ConfigError(f"{operation} references an undeclared RF port") + feature = next( + (item for item in extensions.features if item.feature is RfFeature.TRIGGER), + None, + ) + if ( + feature is None + or RfFeatureDirection.READ not in feature.directions + or port_id not in feature.port_ids + or not isinstance(feature.profile, RfTriggerProfile) + or not feature.profile.state_readable + ): + raise ConfigError( + f"{operation} requires a readable trigger profile for the target port" + ) + return feature.profile + + @staticmethod + def _validate_trigger_snapshot_readback( + port_id: str, + snapshot: object, + profile: RfTriggerProfile, + *, + operation: str, + ) -> None: + if not isinstance(snapshot, RfTriggerSnapshot): + raise ConfigError(f"{operation} driver returned an invalid trigger snapshot") + if snapshot.port_id != port_id: + raise ConfigError(f"{operation} trigger snapshot does not match the requested port") + if snapshot.pulse_trigger_mode not in profile.pulse_trigger_modes: + raise ConfigError(f"{operation} readback pulse trigger mode is outside the descriptor profile") + if snapshot.pulse_external_trigger_edge not in profile.pulse_external_trigger_edges: + raise ConfigError(f"{operation} readback external trigger edge is outside the descriptor profile") + if snapshot.pulse_external_gate_polarity not in profile.pulse_external_gate_polarities: + raise ConfigError(f"{operation} readback external gate polarity is outside the descriptor profile") + if snapshot.sweep_mode not in profile.sweep_modes: + raise ConfigError(f"{operation} readback Sweep mode is outside the descriptor profile") + if snapshot.sweep_period_trigger_mode not in profile.sweep_period_trigger_modes: + raise ConfigError( + f"{operation} readback Sweep-period trigger mode is outside the descriptor profile" + ) + if snapshot.sweep_point_trigger_mode not in profile.sweep_point_trigger_modes: + raise ConfigError( + f"{operation} readback Sweep-point trigger mode is outside the descriptor profile" + ) + + def _validate_pulse_descriptor( + self, + request: RfPulseConfigureRequest, + operation: str, + ) -> RfPulseModeProfile: + descriptor = self.descriptor + extensions = None if descriptor is None else descriptor.rf_source_extensions + if not isinstance(extensions, RfSourceDescriptorExtensions): + raise ConfigError(f"{operation} requires validated rf_source_extensions") + if not any(port.port_id == request.port_id for port in extensions.topology.ports): + raise ConfigError(f"{operation} references an undeclared RF port") + feature = next( + (item for item in extensions.features if item.feature is RfFeature.PULSE), + None, + ) + if ( + feature is None + or RfFeatureDirection.CONFIGURE not in feature.directions + or RfFeatureDirection.READ not in feature.directions + or request.port_id not in feature.port_ids + or not isinstance(feature.profile, RfPulseProfile) + or not feature.profile.configuration_readable + ): + raise ConfigError( + f"{operation} requires a readable configurable pulse profile for the target port" + ) + mode_profile = next( + ( + item + for item in feature.profile.mode_profiles + if item.source is RfPulseSource.INTERNAL and item.mode is RfPulseMode.SINGLE + ), + None, + ) + if mode_profile is None: + raise ConfigError(f"{operation} requires an internal single-pulse profile") + if request.polarity not in mode_profile.polarities: + raise ConfigError(f"{operation} request polarity is outside the descriptor profile") + if not mode_profile.period_min_s <= request.period_s <= mode_profile.period_max_s: + raise ConfigError(f"{operation} request period_s is outside the descriptor range") + if not mode_profile.width_min_s <= request.width_s <= mode_profile.width_max_s: + raise ConfigError(f"{operation} request width_s is outside the descriptor range") + if request.width_s > request.period_s - mode_profile.minimum_off_time_s: + raise ConfigError(f"{operation} request width_s violates the descriptor minimum off time") + return mode_profile + + def _validate_pulse_preflight( + self, + request: RfPulseConfigureRequest, + snapshot: RfSourceSnapshot, + *, + operation: str, + ) -> RfPortSnapshot: + port = self._snapshot_port(snapshot, request.port_id, operation=operation) + output_enabled = self._observed_value( + port.output_enabled, + f"{operation} requires a readable RF output state", + ) + if output_enabled is not False: + raise ConfigError(f"{operation} requires target RF output OFF") + modulation = self._observed_value( + port.modulation, + f"{operation} requires a readable modulation state", + ) + if modulation is not RfModulationState.DISABLED: + raise ConfigError(f"{operation} requires modulation disabled") + pulse = self._observed_value( + port.pulse, + f"{operation} requires a readable Pulse state", + ) + if pulse is not RfPulseState.DISABLED: + raise ConfigError(f"{operation} requires Pulse disabled") + sweep = self._observed_value( + port.sweep, + f"{operation} requires a readable Sweep state", + ) + if sweep is not RfSweepState.DISABLED: + raise ConfigError(f"{operation} requires Sweep disabled") + protection = self._observed_value( + snapshot.protection, + f"{operation} requires a readable protection state", + ) + if not isinstance(protection, RfProtectionStatus): + raise ConfigError(f"{operation} requires a valid protection state") + if protection.active_codes: + raise ConfigError(f"{operation} requires no active protection condition") + return port + + def _validate_pulse_postcondition( + self, + request: RfPulseConfigureRequest, + snapshot: RfSourceSnapshot, + pulse_snapshot: RfPulseSnapshot, + mode_profile: RfPulseModeProfile, + *, + operation: str, + ) -> RfPulseConfigureResult: + self._validate_pulse_preflight(request, snapshot, operation=operation) + if pulse_snapshot.port_id != request.port_id: + raise ConfigError(f"{operation} pulse snapshot does not match the requested port") + if pulse_snapshot.source is not mode_profile.source or pulse_snapshot.mode is not mode_profile.mode: + raise ConfigError(f"{operation} postcondition requires the declared internal single-pulse profile") + if pulse_snapshot.state is not RfPulseState.DISABLED: + raise ConfigError(f"{operation} postcondition requires Pulse disabled") + if ( + pulse_snapshot.period_s != request.period_s + or pulse_snapshot.width_s != request.width_s + or pulse_snapshot.polarity is not request.polarity + ): + raise ConfigError(f"{operation} pulse readback does not match request") + return RfPulseConfigureResult( + port_id=request.port_id, + period_s=request.period_s, + width_s=request.width_s, + polarity=request.polarity, + ) + + def _validate_pulse_output_descriptor( + self, + request: RfPulseOutputRequest, + operation: str, + ) -> RfPulseOutputProfile: + descriptor = self.descriptor + extensions = None if descriptor is None else descriptor.rf_source_extensions + if not isinstance(extensions, RfSourceDescriptorExtensions): + raise ConfigError(f"{operation} requires validated rf_source_extensions") + if not any(port.port_id == request.port_id for port in extensions.topology.ports): + raise ConfigError(f"{operation} references an undeclared RF port") + pulse_feature = next( + (item for item in extensions.features if item.feature is RfFeature.PULSE), + None, + ) + feature = next( + (item for item in extensions.features if item.feature is RfFeature.PULSE_OUTPUT), + None, + ) + if ( + pulse_feature is None + or RfFeatureDirection.CONFIGURE not in pulse_feature.directions + or RfFeatureDirection.READ not in pulse_feature.directions + or request.port_id not in pulse_feature.port_ids + or not isinstance(pulse_feature.profile, RfPulseProfile) + or not pulse_feature.profile.configuration_readable + ): + raise ConfigError( + f"{operation} requires a readable configurable base pulse profile for the target port" + ) + if ( + feature is None + or RfFeatureDirection.READ not in feature.directions + or RfFeatureDirection.ENABLE not in feature.directions + or RfFeatureDirection.DISABLE not in feature.directions + or request.port_id not in feature.port_ids + or not isinstance(feature.profile, RfPulseOutputProfile) + or not feature.profile.output_readable + ): + raise ConfigError( + f"{operation} requires a readable Pulse-output profile for the target port" + ) + profile = feature.profile + if profile.interface_id != request.interface_id: + raise ConfigError(f"{operation} references an undeclared physical Pulse-output interface") + self._validate_pulse_descriptor( + RfPulseConfigureRequest( + port_id=request.port_id, + period_s=profile.period_s, + width_s=profile.width_s, + polarity=profile.polarity, + ), + operation, + ) + return profile + + def _validate_pulse_output_snapshot_target( + self, + request: RfPulseOutputRequest, + snapshot: RfPulseOutputSnapshot, + profile: RfPulseOutputProfile, + *, + operation: str, + ) -> None: + if not isinstance(snapshot, RfPulseOutputSnapshot): + raise ConfigError(f"{operation} driver returned an invalid Pulse-output snapshot") + if snapshot.port_id != request.port_id or snapshot.interface_id != request.interface_id: + raise ConfigError(f"{operation} Pulse-output snapshot does not match the requested interface") + if snapshot.direction is not profile.direction: + raise ConfigError(f"{operation} Pulse-output direction is outside the descriptor profile") + + def _validate_pulse_output_profile_readback( + self, + snapshot: RfPulseOutputSnapshot, + profile: RfPulseOutputProfile, + *, + operation: str, + ) -> None: + if ( + snapshot.low_level_v != profile.low_level_v + or snapshot.high_level_v != profile.high_level_v + or snapshot.output_impedance_ohm != profile.output_impedance_ohm + or snapshot.source is not profile.source + or snapshot.mode is not profile.mode + or snapshot.period_s != profile.period_s + or snapshot.width_s != profile.width_s + or snapshot.polarity is not profile.polarity + or snapshot.pulse_state is not profile.pulse_state + ): + raise ConfigError( + f"{operation} Pulse-output readback does not match the descriptor profile" + ) + + def _validate_pulse_output_enable_snapshot( + self, + request: RfPulseOutputRequest, + rf_snapshot: RfSourceSnapshot, + pulse_output_snapshot: RfPulseOutputSnapshot, + profile: RfPulseOutputProfile, + *, + operation: str, + ) -> bool: + self._validate_pulse_preflight( + RfPulseConfigureRequest( + port_id=request.port_id, + period_s=profile.period_s, + width_s=profile.width_s, + polarity=profile.polarity, + ), + rf_snapshot, + operation=operation, + ) + self._validate_pulse_output_snapshot_target( + request, + pulse_output_snapshot, + profile, + operation=operation, + ) + self._validate_pulse_output_profile_readback( + pulse_output_snapshot, + profile, + operation=operation, + ) + return pulse_output_snapshot.enabled + + def _validate_pulse_output_disable_snapshot( + self, + request: RfPulseOutputRequest, + pulse_output_snapshot: RfPulseOutputSnapshot, + profile: RfPulseOutputProfile, + *, + operation: str, + ) -> bool: + self._validate_pulse_output_snapshot_target( + request, + pulse_output_snapshot, + profile, + operation=operation, + ) + return pulse_output_snapshot.enabled + + def _validate_sweep_descriptor( + self, + request: RfSweepConfigureRequest, + operation: str, + ) -> RfSweepModeProfile: + descriptor = self.descriptor + extensions = None if descriptor is None else descriptor.rf_source_extensions + if not isinstance(extensions, RfSourceDescriptorExtensions): + raise ConfigError(f"{operation} requires validated rf_source_extensions") + if not any(port.port_id == request.port_id for port in extensions.topology.ports): + raise ConfigError(f"{operation} references an undeclared RF port") + feature = next( + (item for item in extensions.features if item.feature is RfFeature.SWEEP), + None, + ) + if ( + feature is None + or RfFeatureDirection.CONFIGURE not in feature.directions + or RfFeatureDirection.READ not in feature.directions + or request.port_id not in feature.port_ids + or not isinstance(feature.profile, RfSweepProfile) + or not feature.profile.configuration_readable + ): + raise ConfigError( + f"{operation} requires a readable configurable Sweep profile for the target port" + ) + mode_profile = next( + ( + item + for item in feature.profile.mode_profiles + if ( + item.sweep_type is RfSweepType.STEP + and item.direction is RfSweepDirection.FORWARD + and item.shape is RfSweepShape.RAMP + and item.spacing is RfSweepSpacing.LINEAR + ) + ), + None, + ) + if mode_profile is None: + raise ConfigError( + f"{operation} requires a frequency-only forward linear Step Sweep profile" + ) + if not ( + mode_profile.frequency_min_hz + <= request.start_frequency_hz + <= mode_profile.frequency_max_hz + ): + raise ConfigError(f"{operation} request start_frequency_hz is outside the descriptor range") + if not ( + mode_profile.frequency_min_hz + <= request.stop_frequency_hz + <= mode_profile.frequency_max_hz + ): + raise ConfigError(f"{operation} request stop_frequency_hz is outside the descriptor range") + if not mode_profile.points_min <= request.points <= mode_profile.points_max: + raise ConfigError(f"{operation} request points is outside the descriptor range") + if not mode_profile.dwell_min_s <= request.dwell_s <= mode_profile.dwell_max_s: + raise ConfigError(f"{operation} request dwell_s is outside the descriptor range") + return mode_profile + + def _validate_sweep_preflight( + self, + request: RfSweepConfigureRequest, + snapshot: RfSourceSnapshot, + *, + operation: str, + ) -> RfPortSnapshot: + port = self._snapshot_port(snapshot, request.port_id, operation=operation) + output_enabled = self._observed_value( + port.output_enabled, + f"{operation} requires a readable RF output state", + ) + if output_enabled is not False: + raise ConfigError(f"{operation} requires target RF output OFF") + modulation = self._observed_value( + port.modulation, + f"{operation} requires a readable modulation state", + ) + if modulation is not RfModulationState.DISABLED: + raise ConfigError(f"{operation} requires modulation disabled") + pulse = self._observed_value( + port.pulse, + f"{operation} requires a readable Pulse state", + ) + if pulse is not RfPulseState.DISABLED: + raise ConfigError(f"{operation} requires Pulse disabled") + sweep = self._observed_value( + port.sweep, + f"{operation} requires a readable Sweep state", + ) + if sweep is not RfSweepState.DISABLED: + raise ConfigError(f"{operation} requires Sweep disabled") + protection = self._observed_value( + snapshot.protection, + f"{operation} requires a readable protection state", + ) + if not isinstance(protection, RfProtectionStatus): + raise ConfigError(f"{operation} requires a valid protection state") + if protection.active_codes: + raise ConfigError(f"{operation} requires no active protection condition") + return port + + def _validate_sweep_postcondition( + self, + request: RfSweepConfigureRequest, + snapshot: RfSourceSnapshot, + sweep_snapshot: RfSweepSnapshot, + mode_profile: RfSweepModeProfile, + *, + operation: str, + ) -> RfSweepConfigureResult: + self._validate_sweep_preflight(request, snapshot, operation=operation) + if sweep_snapshot.port_id != request.port_id: + raise ConfigError(f"{operation} Sweep snapshot does not match the requested port") + if ( + sweep_snapshot.sweep_type is not mode_profile.sweep_type + or sweep_snapshot.direction is not mode_profile.direction + or sweep_snapshot.shape is not mode_profile.shape + or sweep_snapshot.spacing is not mode_profile.spacing + ): + raise ConfigError( + f"{operation} postcondition requires the declared frequency-only Step Sweep profile" + ) + if sweep_snapshot.state is not RfSweepState.DISABLED: + raise ConfigError(f"{operation} postcondition requires Sweep disabled") + if ( + sweep_snapshot.start_frequency_hz != request.start_frequency_hz + or sweep_snapshot.stop_frequency_hz != request.stop_frequency_hz + or sweep_snapshot.points != request.points + or sweep_snapshot.dwell_s != request.dwell_s + ): + raise ConfigError(f"{operation} Sweep readback does not match request") + return RfSweepConfigureResult( + port_id=request.port_id, + start_frequency_hz=request.start_frequency_hz, + stop_frequency_hz=request.stop_frequency_hz, + points=request.points, + dwell_s=request.dwell_s, + ) + + def _validate_modulation_descriptor( + self, + request: RfModulationRequest, + operation: str, + ) -> RfModulationModeProfile: + descriptor = self.descriptor + extensions = None if descriptor is None else descriptor.rf_source_extensions + if not isinstance(extensions, RfSourceDescriptorExtensions): + raise ConfigError(f"{operation} requires validated rf_source_extensions") + if not any(port.port_id == request.port_id for port in extensions.topology.ports): + raise ConfigError(f"{operation} references an undeclared RF port") + feature = next( + (item for item in extensions.features if item.feature is RfFeature.MODULATION), + None, + ) + if ( + feature is None + or RfFeatureDirection.CONFIGURE not in feature.directions + or RfFeatureDirection.READ not in feature.directions + or request.port_id not in feature.port_ids + or not isinstance(feature.profile, RfModulationProfile) + or not feature.profile.configuration_readable + ): + raise ConfigError( + f"{operation} requires a readable configurable modulation profile for the target port" + ) + mode_profile = next( + (item for item in feature.profile.mode_profiles if item.kind is request.kind), + None, + ) + if mode_profile is None: + raise ConfigError(f"{operation} does not support the requested modulation kind") + if ( + mode_profile.source is not RfModulationSource.INTERNAL + or mode_profile.waveform is not RfModulationWaveform.SINE + or mode_profile.value_unit is not request.value_unit + ): + raise ConfigError(f"{operation} requires an internal-sine profile for the requested kind") + if not mode_profile.value_min <= request.value <= mode_profile.value_max: + raise ConfigError(f"{operation} request value is outside the descriptor range") + if not ( + mode_profile.internal_frequency_min_hz + <= request.internal_frequency_hz + <= mode_profile.internal_frequency_max_hz + ): + raise ConfigError( + f"{operation} request internal_frequency_hz is outside the descriptor range" + ) + return mode_profile + + def _validate_modulation_disable_descriptor( + self, + request: RfModulationDisableRequest, + operation: str, + ) -> None: + descriptor = self.descriptor + extensions = None if descriptor is None else descriptor.rf_source_extensions + if not isinstance(extensions, RfSourceDescriptorExtensions): + raise ConfigError(f"{operation} requires validated rf_source_extensions") + if not any(port.port_id == request.port_id for port in extensions.topology.ports): + raise ConfigError(f"{operation} references an undeclared RF port") + feature = next( + (item for item in extensions.features if item.feature is RfFeature.MODULATION), + None, + ) + if ( + feature is None + or RfFeatureDirection.DISABLE not in feature.directions + or RfFeatureDirection.READ not in feature.directions + or request.port_id not in feature.port_ids + or not isinstance(feature.profile, RfModulationProfile) + or not feature.profile.state_readable + ): + raise ConfigError( + f"{operation} requires a readable disable-capable modulation profile for the target port" + ) + + def _validate_cw_preflight( + self, + request: RfCwRequest, + snapshot: RfSourceSnapshot, + port_profile: RfOutputPortProfile, + profile: RfCwProfile, + *, + operation: str, + ) -> RfPortSnapshot: + del port_profile, profile + port = self._snapshot_port(snapshot, request.port_id, operation=operation) + output_enabled = self._observed_value( + port.output_enabled, + f"{operation} requires a readable RF output state", + ) + if output_enabled is not False: + raise ConfigError(f"{operation} requires target RF output OFF") + modulation = self._observed_value( + port.modulation, + f"{operation} requires a readable modulation state", + ) + if modulation is not RfModulationState.DISABLED: + raise ConfigError(f"{operation} requires modulation disabled") + pulse = self._observed_value( + port.pulse, + f"{operation} requires a readable Pulse state", + ) + if pulse is not RfPulseState.DISABLED: + raise ConfigError(f"{operation} requires Pulse disabled") + sweep = self._observed_value( + port.sweep, + f"{operation} requires a readable Sweep state", + ) + if sweep is not RfSweepState.DISABLED: + raise ConfigError(f"{operation} requires Sweep disabled") + protection = self._observed_value( + snapshot.protection, + f"{operation} requires a readable protection state", + ) + if not isinstance(protection, RfProtectionStatus): # defensive: snapshot validates this. + raise ConfigError(f"{operation} requires a valid protection state") + if protection.active_codes: + raise ConfigError(f"{operation} requires no active protection condition") + return port + + def _validate_cw_postcondition( + self, + request: RfCwRequest, + snapshot: RfSourceSnapshot, + port_profile: RfOutputPortProfile, + profile: RfCwProfile, + *, + operation: str, + ) -> RfCwResult: + port = self._validate_cw_preflight( + request, + snapshot, + port_profile, + profile, + operation=operation, + ) + if request.frequency_hz is not None: + frequency_hz = self._observed_value( + port.frequency_hz, + f"{operation} requires a readable frequency_hz readback", + ) + if frequency_hz != request.frequency_hz: + raise ConfigError(f"{operation} frequency_hz readback does not match request") + return RfCwResult(port_id=request.port_id, frequency_hz=float(frequency_hz)) + power_dbm = self._observed_value( + port.power_dbm, + f"{operation} requires a readable power_dbm readback", + ) + assert request.power_dbm is not None + if power_dbm != request.power_dbm: + raise ConfigError(f"{operation} power_dbm readback does not match request") + return RfCwResult(port_id=request.port_id, power_dbm=float(power_dbm)) + + def _validate_modulation_preflight( + self, + request: RfModulationRequest, + snapshot: RfSourceSnapshot, + modulation_state: RfModulationStateSnapshot, + *, + operation: str, + ) -> None: + self._validate_modulation_rf_snapshot( + request, + snapshot, + expected_modulation_state=RfModulationState.DISABLED, + operation=operation, + ) + if modulation_state.port_id != request.port_id: + raise ConfigError(f"{operation} modulation state does not match the requested port") + if modulation_state.global_enabled or modulation_state.enabled_modes: + raise ConfigError(f"{operation} requires all modulation modes disabled") + if modulation_state.fault_codes: + raise ConfigError(f"{operation} requires no active modulation fault condition") + + def _validate_modulation_postcondition( + self, + request: RfModulationRequest, + snapshot: RfSourceSnapshot, + modulation_snapshot: RfModulationSnapshot, + mode_profile: RfModulationModeProfile, + *, + operation: str, + ) -> RfModulationResult: + self._validate_modulation_rf_snapshot( + request, + snapshot, + expected_modulation_state=RfModulationState.ENABLED, + operation=operation, + ) + self._validate_modulation_snapshot_identity( + request, + modulation_snapshot, + mode_profile, + require_target_profile=True, + require_selected_fm_pm_kind=True, + operation=operation, + ) + if modulation_snapshot.enabled_modes != (request.kind,): + raise ConfigError(f"{operation} postcondition requires only the requested modulation mode") + if modulation_snapshot.global_enabled is not True: + raise ConfigError(f"{operation} postcondition requires global modulation enabled") + if modulation_snapshot.fault_codes: + raise ConfigError(f"{operation} postcondition reports an active modulation fault condition") + if ( + modulation_snapshot.internal_frequency_hz != request.internal_frequency_hz + or modulation_snapshot.value != request.value + or modulation_snapshot.value_unit is not request.value_unit + ): + raise ConfigError(f"{operation} modulation readback does not match request") + return self._modulation_result(request) + + @staticmethod + def _modulation_result(request: RfModulationRequest) -> RfModulationResult: + if request.kind is RfModulationKind.AM: + return RfModulationResult( + port_id=request.port_id, + kind=request.kind, + internal_frequency_hz=request.internal_frequency_hz, + depth_percent=request.value, + ) + if request.kind is RfModulationKind.FM: + return RfModulationResult( + port_id=request.port_id, + kind=request.kind, + internal_frequency_hz=request.internal_frequency_hz, + frequency_deviation_hz=request.value, + ) + return RfModulationResult( + port_id=request.port_id, + kind=request.kind, + internal_frequency_hz=request.internal_frequency_hz, + phase_deviation_rad=request.value, + ) + + def _validate_modulation_disable_preflight( + self, + request: RfModulationDisableRequest, + snapshot: RfSourceSnapshot, + modulation_state: RfModulationStateSnapshot, + *, + operation: str, + ) -> bool: + modulation = self._validate_modulation_safe_rf_snapshot( + request.port_id, + snapshot, + operation=operation, + ) + if modulation_state.port_id != request.port_id: + raise ConfigError(f"{operation} modulation state does not match the requested port") + if modulation_state.fault_codes: + raise ConfigError(f"{operation} requires no active modulation fault condition") + if modulation is RfModulationState.DISABLED: + if modulation_state.global_enabled or modulation_state.enabled_modes: + raise ConfigError( + f"{operation} rejects a disabled RF snapshot with active modulation state" + ) + return False + if modulation_state.enabled_modes != (request.kind,): + raise ConfigError(f"{operation} requires only the requested modulation mode to be active") + if modulation_state.global_enabled is not True: + raise ConfigError(f"{operation} requires global modulation enabled") + return True + + def _validate_modulation_disable_postcondition( + self, + request: RfModulationDisableRequest, + snapshot: RfSourceSnapshot, + modulation_state: RfModulationStateSnapshot, + *, + operation: str, + ) -> None: + modulation = self._validate_modulation_safe_rf_snapshot( + request.port_id, + snapshot, + operation=operation, + ) + if modulation is not RfModulationState.DISABLED: + raise ConfigError(f"{operation} postcondition requires modulation disabled") + if modulation_state.port_id != request.port_id: + raise ConfigError(f"{operation} modulation state does not match the requested port") + if modulation_state.enabled_modes: + raise ConfigError(f"{operation} postcondition requires all modulation modes disabled") + if modulation_state.global_enabled: + raise ConfigError(f"{operation} postcondition requires global modulation disabled") + if modulation_state.fault_codes: + raise ConfigError(f"{operation} postcondition reports an active modulation fault condition") + + def _validate_modulation_rf_snapshot( + self, + request: RfModulationRequest, + snapshot: RfSourceSnapshot, + *, + expected_modulation_state: RfModulationState, + operation: str, + ) -> None: + modulation = self._validate_modulation_safe_rf_snapshot( + request.port_id, + snapshot, + operation=operation, + ) + if modulation is not expected_modulation_state: + expected = expected_modulation_state.value + raise ConfigError(f"{operation} requires modulation state {expected}") + + def _validate_modulation_safe_rf_snapshot( + self, + port_id: str, + snapshot: RfSourceSnapshot, + *, + operation: str, + ) -> RfModulationState: + port = self._snapshot_port(snapshot, port_id, operation=operation) + output_enabled = self._observed_value( + port.output_enabled, + f"{operation} requires a readable RF output state", + ) + if output_enabled is not False: + raise ConfigError(f"{operation} requires target RF output OFF") + modulation = self._observed_value( + port.modulation, + f"{operation} requires a readable modulation state", + ) + if not isinstance(modulation, RfModulationState): + raise ConfigError(f"{operation} requires a valid modulation state") + pulse = self._observed_value( + port.pulse, + f"{operation} requires a readable Pulse state", + ) + if pulse is not RfPulseState.DISABLED: + raise ConfigError(f"{operation} requires Pulse disabled") + sweep = self._observed_value( + port.sweep, + f"{operation} requires a readable Sweep state", + ) + if sweep is not RfSweepState.DISABLED: + raise ConfigError(f"{operation} requires Sweep disabled") + protection = self._observed_value( + snapshot.protection, + f"{operation} requires a readable protection state", + ) + if not isinstance(protection, RfProtectionStatus): + raise ConfigError(f"{operation} requires a valid protection state") + if protection.active_codes: + raise ConfigError(f"{operation} requires no active protection condition") + return modulation + + @staticmethod + def _validate_modulation_snapshot_identity( + request: RfModulationRequest, + snapshot: RfModulationSnapshot, + mode_profile: RfModulationModeProfile, + *, + require_target_profile: bool, + require_selected_fm_pm_kind: bool, + operation: str, + ) -> None: + if snapshot.port_id != request.port_id or snapshot.kind is not request.kind: + raise ConfigError(f"{operation} modulation snapshot does not match the requested port and kind") + if require_target_profile and ( + snapshot.source is not mode_profile.source or snapshot.waveform is not mode_profile.waveform + ): + raise ConfigError( + f"{operation} postcondition requires the requested internal-sine source and waveform" + ) + if request.kind is RfModulationKind.AM: + if snapshot.selected_fm_pm_kind is not None: + raise ConfigError(f"{operation} AM snapshot has an unexpected FM/PM selection") + return + if snapshot.selected_fm_pm_kind not in { + RfModulationKind.FM, + RfModulationKind.PM, + }: + raise ConfigError(f"{operation} requires a readable FM/PM selection") + if ( + require_selected_fm_pm_kind + and snapshot.selected_fm_pm_kind is not request.kind + ): + raise ConfigError(f"{operation} postcondition does not select the requested FM/PM kind") + + @staticmethod + def _snapshot_port( + snapshot: RfSourceSnapshot, + port_id: str, + *, + operation: str, + ) -> RfPortSnapshot: + port = next((item for item in snapshot.ports if item.port_id == port_id), None) + if port is None: + raise ConfigError(f"{operation} snapshot omitted the target RF port") + return port + + @staticmethod + def _observed_value(observed: object, message: str) -> object: + if getattr(observed, "availability", None) is not RfAvailability.VALUE: + raise ConfigError(message) + return getattr(observed, "value", None) diff --git a/src/wavebench/services/run_artifacts.py b/src/wavebench/services/run_artifacts.py index b6f053b..e60696d 100644 --- a/src/wavebench/services/run_artifacts.py +++ b/src/wavebench/services/run_artifacts.py @@ -7,6 +7,7 @@ from typing import Any from wavebench.errors import ensure_error_envelope +from wavebench.instruments.rf_source_extensions import RF_SOURCE_OPERATION_ARTIFACT_SCHEMA from wavebench.instruments.source_extensions import SOURCE_OPERATION_ARTIFACT_SCHEMA from wavebench.services.run_plan import RunPlan from wavebench.services.source_state import RestorableSourceState @@ -70,6 +71,32 @@ def _validated_source_operations( return source_operations +def _validated_rf_source_operations( + rf_source_operations: list[dict[str, Any]] | None, +) -> list[dict[str, Any]] | None: + """Accept only schema-labelled RF-source operation artifacts.""" + + if rf_source_operations is None: + return None + if not isinstance(rf_source_operations, list) or any( + not isinstance(item, dict) for item in rf_source_operations + ): + raise TypeError("rf_source_operations must be a list of operation artifact objects") + if not rf_source_operations: + return None + for artifact in rf_source_operations: + if artifact.get("schema") != RF_SOURCE_OPERATION_ARTIFACT_SCHEMA: + raise ValueError("RF source operation artifact has an unsupported schema") + operation = artifact.get("operation") + if ( + not isinstance(operation, str) + or not operation.startswith("rf_source.") + or operation.strip() != operation + ): + raise ValueError("RF source operation artifact must have a trimmed rf_source.* operation") + return rf_source_operations + + def write_run_files( *, plan: RunPlan, @@ -82,6 +109,7 @@ def write_run_files( restore_error: dict[str, Any] | None = None, provenance: dict[str, Any] | None = None, source_operations: list[dict[str, Any]] | None = None, + rf_source_operations: list[dict[str, Any]] | None = None, ) -> None: run_data: dict[str, Any] = { "status": status, @@ -127,6 +155,9 @@ def write_run_files( validated_source_operations = _validated_source_operations(source_operations) if validated_source_operations is not None: run_data["source_operations"] = validated_source_operations + validated_rf_source_operations = _validated_rf_source_operations(rf_source_operations) + if validated_rf_source_operations is not None: + run_data["rf_source_operations"] = validated_rf_source_operations run_json_path.write_text( json.dumps(run_data, indent=2, ensure_ascii=False), encoding="utf-8", diff --git a/src/wavebench/services/run_plan.py b/src/wavebench/services/run_plan.py index b4b65ba..4fdbd42 100644 --- a/src/wavebench/services/run_plan.py +++ b/src/wavebench/services/run_plan.py @@ -21,6 +21,19 @@ "scope.capture", "sweep.frequency_response", "source.status", + "rf_source.status", + "rf_source.trigger_status", + "rf_source.set_frequency", + "rf_source.set_power_dbm", + "rf_source.modulation_configure", + "rf_source.modulation_disable", + "rf_source.modulated_output_enable", + "rf_source.pulse_configure", + "rf_source.pulse_output_enable", + "rf_source.pulse_output_disable", + "rf_source.sweep_configure", + "rf_source.output_enable", + "rf_source.output_disable", "source.set_freq", "source.arb_load", "source.set_func", @@ -64,6 +77,32 @@ "source.set_vpp": ("value_vpp",), "source.set_duty": ("duty_percent",), "source.output": ("state",), + "rf_source.trigger_status": ("port_id",), + "rf_source.set_frequency": ("port_id", "frequency_hz"), + "rf_source.set_power_dbm": ("port_id", "power_dbm"), + "rf_source.modulation_configure": ( + "port_id", + "modulation_kind", + "internal_frequency_hz", + ), + "rf_source.modulation_disable": ("port_id", "modulation_kind"), + "rf_source.modulated_output_enable": ( + "port_id", + "modulation_kind", + "internal_frequency_hz", + ), + "rf_source.pulse_configure": ("port_id", "period_s", "width_s", "polarity"), + "rf_source.pulse_output_enable": ("port_id", "interface_id"), + "rf_source.pulse_output_disable": ("port_id", "interface_id"), + "rf_source.sweep_configure": ( + "port_id", + "start_frequency_hz", + "stop_frequency_hz", + "points", + "dwell_s", + ), + "rf_source.output_enable": ("port_id",), + "rf_source.output_disable": ("port_id",), "source.basic_configure_v2": ("channel",), "source.output_enable_v2": ("channel",), "source.output_disable_v2": ("channel",), @@ -170,6 +209,27 @@ "on_failure", }, "source.status": {"channel", "on_failure"}, + "rf_source.status": {"on_failure"}, + "rf_source.trigger_status": {"on_failure"}, + "rf_source.set_frequency": {"on_failure"}, + "rf_source.set_power_dbm": {"on_failure"}, + "rf_source.modulation_configure": { + "depth_percent", + "frequency_deviation_hz", + "phase_deviation_rad", + "on_failure", + }, + "rf_source.modulation_disable": {"on_failure"}, + "rf_source.modulated_output_enable": { + "depth_percent", + "frequency_deviation_hz", + "phase_deviation_rad", + "on_failure", + }, + "rf_source.pulse_configure": set(), + "rf_source.sweep_configure": set(), + "rf_source.output_enable": {"on_failure"}, + "rf_source.output_disable": {"on_failure"}, "source.set_freq": {"channel", "on_failure"}, "source.arb_load": {"channel", "offset_v", "sample_rate_hz", "max_points", "byte_order", "output_on", "on_failure"}, "source.set_func": {"channel", "on_failure"}, @@ -228,6 +288,17 @@ "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.", + "rf_source.status": "Read a typed RF-source snapshot without changing output.", + "rf_source.trigger_status": "Read declared logical Pulse and Sweep trigger configuration without changing RF or trigger state.", + "rf_source.set_frequency": "Set one RF port frequency while its output, modulation, Pulse, and Sweep are OFF.", + "rf_source.set_power_dbm": "Set one RF port dBm level while its output, modulation, Pulse, and Sweep are OFF.", + "rf_source.modulation_configure": "Configure one OFF RF port with an internal-sine AM, FM, or PM profile; it does not enable RF output.", + "rf_source.modulation_disable": "Disable one known active internal-sine AM, FM, or PM mode while RF output is OFF; an already consistent disabled state makes no write.", + "rf_source.modulated_output_enable": "Enable one RF port only when its active internal-sine AM, FM, or PM profile exactly matches the requested bounded profile; it does not configure modulation or restore RF OFF.", + "rf_source.pulse_configure": "Configure one OFF RF port with a disabled internal single-pulse profile; it does not enable RF output or trigger a pulse.", + "rf_source.sweep_configure": "Configure one OFF RF port with a disabled frequency-only Step Sweep profile; it does not arm, fire, trigger, or enable RF output.", + "rf_source.output_enable": "Enable one RF port only after a fresh safety snapshot confirms the configured load, frequency, power, and inactive modulation, Pulse, Sweep, and blocking protection conditions.", + "rf_source.output_disable": "Disable one RF port and confirm OFF without requiring frequency, power, or protection readback.", "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.", "source.set_func": "Set source waveform function, for example SIN or SQU.", @@ -619,6 +690,98 @@ def _normalize_step_fields(index: int, kind: str, fields: dict[str, Any]) -> Non fields["state"] = state elif kind == "source.set_freq": fields["frequency_hz"] = _positive_float(fields["frequency_hz"], f"{prefix}.frequency_hz") + elif kind == "rf_source.set_frequency": + fields["port_id"] = _rf_port_id(fields["port_id"], f"{prefix}.port_id") + fields["frequency_hz"] = _positive_float( + fields["frequency_hz"], + f"{prefix}.frequency_hz", + ) + elif kind == "rf_source.trigger_status": + fields["port_id"] = _rf_port_id(fields["port_id"], f"{prefix}.port_id") + elif kind == "rf_source.set_power_dbm": + fields["port_id"] = _rf_port_id(fields["port_id"], f"{prefix}.port_id") + fields["power_dbm"] = _finite_float(fields["power_dbm"], f"{prefix}.power_dbm") + elif kind == "rf_source.modulation_disable": + fields["port_id"] = _rf_port_id(fields["port_id"], f"{prefix}.port_id") + modulation_kind = _non_empty_str( + fields["modulation_kind"], + f"{prefix}.modulation_kind", + ).lower() + if modulation_kind not in {"am", "fm", "pm"}: + raise ConfigError(f"{prefix}.modulation_kind must be one of am, fm, pm") + fields["modulation_kind"] = modulation_kind + elif kind in {"rf_source.modulation_configure", "rf_source.modulated_output_enable"}: + fields["port_id"] = _rf_port_id(fields["port_id"], f"{prefix}.port_id") + modulation_kind = _non_empty_str( + fields["modulation_kind"], + f"{prefix}.modulation_kind", + ).lower() + value_fields = { + "am": "depth_percent", + "fm": "frequency_deviation_hz", + "pm": "phase_deviation_rad", + } + expected_value_field = value_fields.get(modulation_kind) + if expected_value_field is None: + raise ConfigError(f"{prefix}.modulation_kind must be one of am, fm, pm") + present_value_fields = [field for field in value_fields.values() if field in fields] + if present_value_fields != [expected_value_field]: + raise ConfigError( + f"{prefix} {kind} requires only " + f"{expected_value_field} for modulation_kind {modulation_kind}" + ) + fields[expected_value_field] = _finite_float( + fields[expected_value_field], + f"{prefix}.{expected_value_field}", + ) + if fields[expected_value_field] < 0: + raise ConfigError(f"{prefix}.{expected_value_field} must be >= 0") + fields["modulation_kind"] = modulation_kind + fields["internal_frequency_hz"] = _positive_float( + fields["internal_frequency_hz"], + f"{prefix}.internal_frequency_hz", + ) + elif kind == "rf_source.pulse_configure": + fields["port_id"] = _rf_port_id(fields["port_id"], f"{prefix}.port_id") + period_s = _positive_float(fields["period_s"], f"{prefix}.period_s") + width_s = _positive_float(fields["width_s"], f"{prefix}.width_s") + if width_s >= period_s: + raise ConfigError(f"{prefix}.width_s must be less than period_s") + polarity = _non_empty_str(fields["polarity"], f"{prefix}.polarity").lower() + if polarity not in {"normal", "inverted"}: + raise ConfigError(f"{prefix}.polarity must be one of normal, inverted") + fields["period_s"] = period_s + fields["width_s"] = width_s + fields["polarity"] = polarity + elif kind in {"rf_source.pulse_output_enable", "rf_source.pulse_output_disable"}: + fields["port_id"] = _rf_port_id(fields["port_id"], f"{prefix}.port_id") + fields["interface_id"] = _rf_interface_id( + fields["interface_id"], + f"{prefix}.interface_id", + ) + elif kind == "rf_source.sweep_configure": + fields["port_id"] = _rf_port_id(fields["port_id"], f"{prefix}.port_id") + start_frequency_hz = _positive_float( + fields["start_frequency_hz"], + f"{prefix}.start_frequency_hz", + ) + stop_frequency_hz = _positive_float( + fields["stop_frequency_hz"], + f"{prefix}.stop_frequency_hz", + ) + if start_frequency_hz >= stop_frequency_hz: + raise ConfigError( + f"{prefix}.start_frequency_hz must be less than stop_frequency_hz" + ) + points = fields["points"] + if isinstance(points, bool) or not isinstance(points, int) or points < 2: + raise ConfigError(f"{prefix}.points must be an integer >= 2") + fields["start_frequency_hz"] = start_frequency_hz + fields["stop_frequency_hz"] = stop_frequency_hz + fields["points"] = points + fields["dwell_s"] = _positive_float(fields["dwell_s"], f"{prefix}.dwell_s") + elif kind in {"rf_source.output_enable", "rf_source.output_disable"}: + fields["port_id"] = _rf_port_id(fields["port_id"], f"{prefix}.port_id") elif kind == "source.arb_load": fields["file"] = _non_empty_str(fields["file"], f"{prefix}.file") fields["frequency_hz"] = _positive_float(fields["frequency_hz"], f"{prefix}.frequency_hz") @@ -1129,6 +1292,20 @@ def _finite_float(value: Any, name: str) -> float: return result +def _rf_port_id(value: Any, name: str) -> str: + token = _non_empty_str(value, name) + if _SOURCE_STORAGE_TOKEN.fullmatch(token) is None: + raise ConfigError(f"{name} must be a short safe RF port ID") + return token + + +def _rf_interface_id(value: Any, name: str) -> str: + token = _non_empty_str(value, name) + if _SOURCE_STORAGE_TOKEN.fullmatch(token) is None: + raise ConfigError(f"{name} must be a short safe RF physical interface ID") + return token + + def _table(raw: Any, name: str) -> dict[str, Any]: if raw is None: return {} diff --git a/src/wavebench/services/run_safety.py b/src/wavebench/services/run_safety.py index e0b8689..ad9e96a 100644 --- a/src/wavebench/services/run_safety.py +++ b/src/wavebench/services/run_safety.py @@ -21,6 +21,19 @@ def require_high_impedance(self, channel: int, *, allow_50ohm: bool = False) -> "scope.capture", "sweep.frequency_response", "source.status", + "rf_source.status", + "rf_source.trigger_status", + "rf_source.set_frequency", + "rf_source.set_power_dbm", + "rf_source.modulation_configure", + "rf_source.modulation_disable", + "rf_source.modulated_output_enable", + "rf_source.pulse_configure", + "rf_source.pulse_output_enable", + "rf_source.pulse_output_disable", + "rf_source.sweep_configure", + "rf_source.output_enable", + "rf_source.output_disable", "source.set_freq", "source.arb_load", "source.set_func", diff --git a/src/wavebench/services/run_service.py b/src/wavebench/services/run_service.py index fd12d0f..1d98956 100644 --- a/src/wavebench/services/run_service.py +++ b/src/wavebench/services/run_service.py @@ -22,6 +22,19 @@ ) from wavebench.instruments.capabilities import require_capabilities from wavebench.instruments.registry import resolve_instrument_descriptor +from wavebench.instruments.rf_source_extensions import ( + RfCwRequest, + RfModulatedOutputRequest, + RfModulationDisableRequest, + RfModulationKind, + RfModulationRequest, + RfOutputRequest, + RfPulseConfigureRequest, + RfPulseOutputRequest, + RfPulsePolarity, + RfSweepConfigureRequest, + rf_source_snapshot_operation_artifact, +) from wavebench.instruments.source_extensions import ( PatchAction, PatchValue, @@ -52,6 +65,9 @@ from wavebench.logging import CommandLogger from wavebench.services.power_service import PowerService from wavebench.services.dmm_service import DmmService +from wavebench.services.rf_source_service import RfSourceService +from wavebench.services.access_policy import access_policy +from wavebench.services.operation_specs import require_operation_spec from wavebench.services.frequency_response import ( analyze_frequency_response_point, build_fit_document, @@ -142,6 +158,8 @@ class RunInstrumentServices: compare=False, repr=False, ) + # Append-only: retain the positional layout of existing run service bundles. + rf_source: RfSourceService | None = None def audit_snapshot(self) -> dict[str, Any] | None: """Return transport-native counters without deriving them from logs.""" @@ -152,6 +170,7 @@ def audit_snapshot(self) -> dict[str, Any] | None: ("source", self.source), ("power", self.power), ("dmm", self.dmm), + ("rf_source", self.rf_source), ): if service is None: continue @@ -240,6 +259,17 @@ def verify(self, plan: RunPlan) -> list[RunPreflightRecord]: idn=self._source_service().idn(), ) ) + if "rf_source" in instruments: + rf_source = self.config.rf_source + if rf_source is None or not rf_source.resource: + raise ConfigError("rf_source resource is required by this run plan") + records.append( + RunPreflightRecord( + instrument="rf_source", + resource=rf_source.resource, + idn=self._rf_source_service().idn(), + ) + ) if "power" in instruments: power = self.config.power if power is None or not power.resource: @@ -269,8 +299,36 @@ def check(self, plan: RunPlan) -> None: reject_unsupported_steps(plan) self._check_frequency_response_baselines(plan) self._check_frequency_response_resumes(plan) + self._check_rf_source_access(plan) self._check_plan_capabilities(plan) + def _check_rf_source_access(self, plan: RunPlan) -> None: + """Reject RF operations by access policy before run lifecycle opens a session.""" + + operations = { + "rf_source.status": "rf_source.snapshot", + "rf_source.set_frequency": "rf_source.set_frequency", + "rf_source.set_power_dbm": "rf_source.set_power_dbm", + "rf_source.modulation_configure": "rf_source.modulation_configure", + "rf_source.modulation_disable": "rf_source.modulation_disable", + "rf_source.modulated_output_enable": "rf_source.modulated_output_enable", + "rf_source.pulse_configure": "rf_source.pulse_configure", + "rf_source.pulse_output_enable": "rf_source.pulse_output_enable", + "rf_source.pulse_output_disable": "rf_source.pulse_output_disable", + "rf_source.sweep_configure": "rf_source.sweep_configure", + "rf_source.output_enable": "rf_source.output_enable", + "rf_source.output_disable": "rf_source.output_disable", + } + relevant = [operations[step.kind] for step in plan.steps if step.kind in operations] + if not relevant: + return + rf_source = self.config.rf_source + if rf_source is None: + raise ConfigError("rf_source resource is required by this run plan") + policy = access_policy(rf_source.access, "rf_source.access") + for operation in relevant: + policy.require(require_operation_spec(operation), operation=operation) + def _check_frequency_response_resumes(self, plan: RunPlan) -> None: """Validate resume CSVs before any instrument session is opened.""" @@ -401,6 +459,37 @@ def add_source_output_gate_capability() -> None: ensure_calibration_dependencies() elif step.kind == "source.status": add("source", "source.status") + elif step.kind == "rf_source.status": + add("rf_source", "rf_source.snapshot") + elif step.kind == "rf_source.trigger_status": + add("rf_source", "rf_source.trigger_snapshot") + elif step.kind in {"rf_source.set_frequency", "rf_source.set_power_dbm"}: + add("rf_source", "rf_source.snapshot", "rf_source.cw_configure") + elif step.kind == "rf_source.modulation_configure": + add("rf_source", "rf_source.snapshot", "rf_source.modulation_configure") + elif step.kind == "rf_source.modulation_disable": + add("rf_source", "rf_source.snapshot", "rf_source.modulation_disable") + elif step.kind == "rf_source.modulated_output_enable": + add( + "rf_source", + "rf_source.snapshot", + "rf_source.output", + "rf_source.modulation_configure", + "rf_source.modulated_output_enable", + ) + elif step.kind == "rf_source.pulse_configure": + add("rf_source", "rf_source.snapshot", "rf_source.pulse_configure") + elif step.kind in {"rf_source.pulse_output_enable", "rf_source.pulse_output_disable"}: + add( + "rf_source", + "rf_source.snapshot", + "rf_source.pulse_configure", + "rf_source.pulse_output", + ) + elif step.kind == "rf_source.sweep_configure": + add("rf_source", "rf_source.snapshot", "rf_source.sweep_configure") + elif step.kind in {"rf_source.output_enable", "rf_source.output_disable"}: + add("rf_source", "rf_source.snapshot", "rf_source.output") elif step.kind == "source.set_freq": add("source", "source.set_frequency") source = self.config.source @@ -567,6 +656,7 @@ def run( records: list[RunStepRecord] = [] source_operations: list[dict[str, Any]] = [] + rf_source_operations: list[dict[str, Any]] = [] run_json_path = run_dir / "run.json" summary_csv_path = run_dir / "summary.csv" restore_state: list[RestorableSourceState] | None = None @@ -589,6 +679,10 @@ def append_source_operation_artifact(value: object) -> None: if isinstance(value, dict): source_operations.append(value) + def append_rf_source_operation_artifact(value: object) -> None: + if isinstance(value, dict): + rf_source_operations.append(value) + def refresh_provenance() -> None: instrument_io = services.audit_snapshot() if instrument_io is not None: @@ -627,6 +721,7 @@ def report_close_errors() -> None: restore_error=restore_error, provenance=provenance, source_operations=source_operations, + rf_source_operations=rf_source_operations, ) services.close_reporters.append(report_close_errors) @@ -674,10 +769,16 @@ def report_close_errors() -> None: append_source_operation_artifact( record.artifact.get("source_operation") ) + append_rf_source_operation_artifact( + record.artifact.get("rf_source_operation") + ) if step_failure is not None: append_source_operation_artifact( getattr(step_failure, "source_operation_artifact", None) ) + append_rf_source_operation_artifact( + getattr(step_failure, "rf_source_operation_artifact", None) + ) safety_gate = self._safety_gate_for_step(plan, step) gate_triggered = safety_gate["enabled"] and record.status in { "failed", @@ -776,6 +877,7 @@ def report_close_errors() -> None: restore_error=restore_error, provenance=provenance, source_operations=source_operations, + rf_source_operations=rf_source_operations, ) if restore_error is not None: raise ConfigError( @@ -793,6 +895,9 @@ def report_close_errors() -> None: append_source_operation_artifact( getattr(failure, "source_operation_artifact", None) ) + append_rf_source_operation_artifact( + getattr(failure, "rf_source_operation_artifact", None) + ) restore_error = restore_source_state( restore_state, source_service_factory=lambda: self._source_service(services=services), @@ -815,6 +920,7 @@ def report_close_errors() -> None: restore_error=restore_error, provenance=provenance, source_operations=source_operations, + rf_source_operations=rf_source_operations, ) if isinstance(exc, _FrequencyResponseExecutionError): raise failure from None @@ -869,6 +975,7 @@ def report_close_errors() -> None: restore_error=restore_error, provenance=provenance, source_operations=source_operations, + rf_source_operations=rf_source_operations, ) raise ConfigError("run plan source state restore failed: " + restore_error["message"]) @@ -886,6 +993,7 @@ def report_close_errors() -> None: restore_error=None, provenance=provenance, source_operations=source_operations, + rf_source_operations=rf_source_operations, ) result = RunResult( run_dir=run_dir, @@ -1133,6 +1241,131 @@ def _run_step( elif step.kind == "source.status": status = self._source_service(services=services).status(channel=step.fields.get("channel")) artifact = {"source_status": _status_payload(status)} + elif step.kind == "rf_source.status": + snapshot = self._rf_source_service(services=services).snapshot() + artifact = {"rf_source_operation": rf_source_snapshot_operation_artifact(snapshot)} + elif step.kind == "rf_source.trigger_status": + _, rf_source_operation = self._rf_source_service( + services=services + ).trigger_snapshot_with_artifact(step.fields["port_id"]) + artifact = {"rf_source_operation": rf_source_operation} + elif step.kind == "rf_source.set_frequency": + _, rf_source_operation = self._rf_source_service( + services=services + ).configure_cw_with_artifact( + RfCwRequest( + port_id=step.fields["port_id"], + frequency_hz=step.fields["frequency_hz"], + ) + ) + artifact = {"rf_source_operation": rf_source_operation} + elif step.kind == "rf_source.set_power_dbm": + _, rf_source_operation = self._rf_source_service( + services=services + ).configure_cw_with_artifact( + RfCwRequest( + port_id=step.fields["port_id"], + power_dbm=step.fields["power_dbm"], + ) + ) + artifact = {"rf_source_operation": rf_source_operation} + elif step.kind == "rf_source.modulation_disable": + fields = step.fields + _, rf_source_operation = self._rf_source_service( + services=services + ).disable_modulation_with_artifact( + RfModulationDisableRequest( + port_id=fields["port_id"], + kind=RfModulationKind(fields["modulation_kind"]), + ) + ) + artifact = {"rf_source_operation": rf_source_operation} + elif step.kind in { + "rf_source.modulation_configure", + "rf_source.modulated_output_enable", + }: + fields = step.fields + modulation_kind = RfModulationKind(fields["modulation_kind"]) + if modulation_kind is RfModulationKind.AM: + request = RfModulationRequest( + port_id=fields["port_id"], + kind=modulation_kind, + depth_percent=fields["depth_percent"], + internal_frequency_hz=fields["internal_frequency_hz"], + ) + elif modulation_kind is RfModulationKind.FM: + request = RfModulationRequest( + port_id=fields["port_id"], + kind=modulation_kind, + frequency_deviation_hz=fields["frequency_deviation_hz"], + internal_frequency_hz=fields["internal_frequency_hz"], + ) + else: + request = RfModulationRequest( + port_id=fields["port_id"], + kind=modulation_kind, + phase_deviation_rad=fields["phase_deviation_rad"], + internal_frequency_hz=fields["internal_frequency_hz"], + ) + rf_source_service = self._rf_source_service(services=services) + if step.kind == "rf_source.modulated_output_enable": + _, rf_source_operation = rf_source_service.enable_modulated_output_with_artifact( + RfModulatedOutputRequest(modulation=request) + ) + else: + _, rf_source_operation = rf_source_service.configure_modulation_with_artifact( + request + ) + artifact = {"rf_source_operation": rf_source_operation} + elif step.kind == "rf_source.pulse_configure": + fields = step.fields + _, rf_source_operation = self._rf_source_service( + services=services + ).configure_pulse_with_artifact( + RfPulseConfigureRequest( + port_id=fields["port_id"], + period_s=fields["period_s"], + width_s=fields["width_s"], + polarity=RfPulsePolarity(fields["polarity"]), + ) + ) + artifact = {"rf_source_operation": rf_source_operation} + elif step.kind in {"rf_source.pulse_output_enable", "rf_source.pulse_output_disable"}: + fields = step.fields + _, rf_source_operation = self._rf_source_service( + services=services + ).set_pulse_output_with_artifact( + RfPulseOutputRequest( + port_id=fields["port_id"], + interface_id=fields["interface_id"], + enabled=step.kind == "rf_source.pulse_output_enable", + ) + ) + artifact = {"rf_source_operation": rf_source_operation} + elif step.kind == "rf_source.sweep_configure": + fields = step.fields + _, rf_source_operation = self._rf_source_service( + services=services + ).configure_sweep_with_artifact( + RfSweepConfigureRequest( + port_id=fields["port_id"], + start_frequency_hz=fields["start_frequency_hz"], + stop_frequency_hz=fields["stop_frequency_hz"], + points=fields["points"], + dwell_s=fields["dwell_s"], + ) + ) + artifact = {"rf_source_operation": rf_source_operation} + elif step.kind in {"rf_source.output_enable", "rf_source.output_disable"}: + _, rf_source_operation = self._rf_source_service( + services=services + ).set_output_with_artifact( + RfOutputRequest( + port_id=step.fields["port_id"], + enabled=step.kind == "rf_source.output_enable", + ) + ) + artifact = {"rf_source_operation": rf_source_operation} elif step.kind == "source.basic_configure_v2": fields = step.fields _, source_operation = self._source_service(services=services).configure_basic_v2( @@ -2475,6 +2708,7 @@ def lease_for(resource: str) -> ResourceLease: source: SourceService | None = None power: PowerService | None = None dmm: DmmService | None = None + rf_source: RfSourceService | None = None source_guard = SourceStateGuard() power_guard = PowerStateGuard() @@ -2567,6 +2801,28 @@ def lease_for(resource: str) -> ResourceLease: session_state=bootstrap.session_state, lease=dmm_lease, ) + if "rf_source" in instruments: + logger = CommandLogger() + rf_source_config = self.config.rf_source + if rf_source_config is None or not rf_source_config.resource: + raise ConfigError("rf_source resource is required by this run plan") + rf_source_lease = lease_for(rf_source_config.resource) + bootstrap = RfSourceService( + config=self.config, + logger=logger, + lease=rf_source_lease, + ) + session = bootstrap.open_session() + stack.callback(close_session, session, "rf_source", bootstrap.transport) + rf_source = RfSourceService( + config=self.config, + logger=logger, + session=session, + descriptor=bootstrap.descriptor, + transport=bootstrap.transport, + session_state=bootstrap.session_state, + lease=rf_source_lease, + ) yield RunInstrumentServices( scope=scope, @@ -2574,13 +2830,14 @@ def lease_for(resource: str) -> ResourceLease: power=power, dmm=dmm, close_errors=close_errors, + rf_source=rf_source, ) def _plan_resource_values(self, instruments: set[str]) -> list[str]: resources: list[str] = [] if "scope" in instruments: resources.append(self.config.connection.resource) - for kind in ("source", "power", "dmm"): + for kind in ("source", "rf_source", "power", "dmm"): if kind not in instruments: continue section = getattr(self.config, kind) @@ -2599,6 +2856,15 @@ def _source_service(self, *, services: RunInstrumentServices | None = None) -> S return services.source return SourceService(config=self.config, logger=CommandLogger()) + def _rf_source_service( + self, + *, + services: RunInstrumentServices | None = None, + ) -> RfSourceService: + if services is not None and services.rf_source is not None: + return services.rf_source + return RfSourceService(config=self.config, logger=CommandLogger()) + def _dmm_service(self, *, services: RunInstrumentServices | None = None) -> DmmService: if services is not None and services.dmm is not None: return services.dmm diff --git a/tests/test_operation_specs.py b/tests/test_operation_specs.py index 07a0d10..3ff850b 100644 --- a/tests/test_operation_specs.py +++ b/tests/test_operation_specs.py @@ -43,6 +43,127 @@ def test_source_output_spec_describes_mutation_and_restore_boundary() -> None: assert spec.as_dict()["required_capabilities"] == ["source.output"] +def test_rf_source_m0_specs_are_read_only_and_exclusive() -> None: + identity = require_operation_spec("rf_source.idn") + snapshot = require_operation_spec("rf_source.snapshot") + + assert identity.instrument_kind == "rf_source" + assert identity.required_capabilities == ("rf_source.idn",) + assert identity.effect == "observe" + assert identity.mutates is False + assert snapshot.instrument_kind == "rf_source" + assert snapshot.required_capabilities == ("rf_source.snapshot",) + assert snapshot.effect == "stateful_read" + assert snapshot.mutates is False + assert snapshot.lease_mode == "exclusive" + assert snapshot.restore_coverage == "none-read-only" + assert snapshot.error_check_minimum == "disabled" + + +def test_rf_source_m1_cw_specs_require_snapshot_and_cw_capability() -> None: + frequency = require_operation_spec("rf_source.set_frequency") + power = require_operation_spec("rf_source.set_power_dbm") + + assert frequency.instrument_kind == "rf_source" + assert frequency.required_capabilities == ( + "rf_source.snapshot", + "rf_source.cw_configure", + ) + assert frequency.effect == "write" + assert frequency.changed_fields == ("rf_source.port.frequency_hz",) + assert frequency.restore_coverage == "none" + assert "rf_output_must_be_off" in frequency.risk_flags + assert frequency.safe_alternatives == ("rf_source.snapshot",) + assert power.required_capabilities == frequency.required_capabilities + assert power.effect == "write" + assert power.changed_fields == ("rf_source.port.power_dbm",) + + +def test_rf_source_m2_output_specs_require_snapshot_and_output_capability() -> None: + enable = require_operation_spec("rf_source.output_enable") + disable = require_operation_spec("rf_source.output_disable") + + assert enable.instrument_kind == "rf_source" + assert enable.required_capabilities == ("rf_source.snapshot", "rf_source.output") + assert enable.effect == "write" + assert enable.changed_fields == ("rf_source.port.output_enabled",) + assert enable.restore_coverage == "none" + assert "dangerous_output" in enable.risk_flags + assert enable.safe_alternatives == ("rf_source.snapshot",) + assert disable.required_capabilities == enable.required_capabilities + assert disable.effect == "write" + assert "safe_output_disable" in disable.risk_flags + + +def test_rf_source_modulation_disable_spec_requires_state_evidence_and_keeps_rf_off() -> None: + disable = require_operation_spec("rf_source.modulation_disable") + + assert disable.instrument_kind == "rf_source" + assert disable.required_capabilities == ( + "rf_source.snapshot", + "rf_source.modulation_disable", + ) + assert disable.effect == "write" + assert disable.changed_fields == ( + "rf_source.modulation.enabled_modes", + "rf_source.modulation.global_enabled", + ) + assert disable.restore_coverage == "none" + assert "rf_output_must_be_off" in disable.risk_flags + assert "safe_modulation_disable" in disable.risk_flags + assert disable.safe_alternatives == ("rf_source.snapshot",) + + +def test_rf_source_pulse_configure_spec_keeps_rf_and_pulse_off() -> None: + configure = require_operation_spec("rf_source.pulse_configure") + + assert configure.instrument_kind == "rf_source" + assert configure.required_capabilities == ( + "rf_source.snapshot", + "rf_source.pulse_configure", + ) + assert configure.effect == "write" + assert configure.changed_fields == ( + "rf_source.pulse.source", + "rf_source.pulse.mode", + "rf_source.pulse.period_s", + "rf_source.pulse.width_s", + "rf_source.pulse.polarity", + "rf_source.pulse.state", + ) + assert configure.restore_coverage == "none" + assert "rf_output_must_be_off" in configure.risk_flags + assert "pulse_state" in configure.risk_flags + assert configure.safe_alternatives == ("rf_source.snapshot",) + + +def test_rf_source_sweep_configure_spec_keeps_rf_and_sweep_off() -> None: + configure = require_operation_spec("rf_source.sweep_configure") + + assert configure.instrument_kind == "rf_source" + assert configure.required_capabilities == ( + "rf_source.snapshot", + "rf_source.sweep_configure", + ) + assert configure.effect == "write" + assert configure.changed_fields == ( + "rf_source.sweep.type", + "rf_source.sweep.direction", + "rf_source.sweep.shape", + "rf_source.sweep.spacing", + "rf_source.sweep.start_frequency_hz", + "rf_source.sweep.stop_frequency_hz", + "rf_source.sweep.points", + "rf_source.sweep.dwell_s", + "rf_source.sweep.state", + ) + assert configure.restore_coverage == "none" + assert "rf_output_must_be_off" in configure.risk_flags + assert "sweep_disabled" in configure.risk_flags + assert "trigger" not in configure.risk_flags + assert configure.safe_alternatives == ("rf_source.snapshot",) + + def test_source_v2_write_specs_match_their_static_operation_contracts() -> None: pairs = ( ( diff --git a/tests/test_rf_source_cli.py b/tests/test_rf_source_cli.py new file mode 100644 index 0000000..711bbf2 --- /dev/null +++ b/tests/test_rf_source_cli.py @@ -0,0 +1,589 @@ +from __future__ import annotations + +import io +import json +from contextlib import redirect_stdout +from types import SimpleNamespace +from unittest.mock import Mock, patch + +from wavebench.cli import _load_rf_source_service, build_parser, main +from wavebench.instruments.rf_source_extensions import ( + RfCwRequest, + RfCwResult, + RfModulatedOutputRequest, + RfModulatedOutputResult, + RfModulationDisableRequest, + RfModulationDisableResult, + RfModulationKind, + RfModulationRequest, + RfModulationResult, + RfOutputRequest, + RfOutputResult, + RfPulseConfigureRequest, + RfPulseConfigureResult, + RfPulsePolarity, + RfSweepConfigureRequest, + RfSweepConfigureResult, +) + + +def test_rf_source_parser_accepts_cw_modulation_pulse_sweep_and_output_commands() -> None: + identity = build_parser().parse_args( + ["rf-source", "idn", "--config", "rf.toml", "--resource", "TCPIP::rf::INSTR"] + ) + status = build_parser().parse_args(["rf-source", "status"]) + trigger_status = build_parser().parse_args( + ["rf-source", "trigger", "status", "--port", "rf_out"] + ) + frequency = build_parser().parse_args( + ["rf-source", "set-frequency", "--port", "rf_out", "4000000"] + ) + power = build_parser().parse_args( + ["rf-source", "set-power", "--port", "rf_out", "-20"] + ) + output = build_parser().parse_args( + ["rf-source", "output", "--port", "rf_out", "on"] + ) + modulation_am = build_parser().parse_args( + [ + "rf-source", + "modulation", + "configure-am", + "--port", + "rf_out", + "--depth-percent", + "50", + "--internal-frequency-hz", + "1000", + ] + ) + modulation_fm = build_parser().parse_args( + [ + "rf-source", + "modulation", + "configure-fm", + "--port", + "rf_out", + "--frequency-deviation-hz", + "10000", + "--internal-frequency-hz", + "1000", + ] + ) + modulation_pm = build_parser().parse_args( + [ + "rf-source", + "modulation", + "configure-pm", + "--port", + "rf_out", + "--phase-deviation-rad", + "1.5", + "--internal-frequency-hz", + "1000", + ] + ) + modulation_output_am = build_parser().parse_args( + [ + "rf-source", + "modulation", + "enable-output-am", + "--port", + "rf_out", + "--depth-percent", + "50", + "--internal-frequency-hz", + "1000", + ] + ) + modulation_disable = build_parser().parse_args( + [ + "rf-source", + "modulation", + "disable", + "--port", + "rf_out", + "--modulation-kind", + "am", + ] + ) + pulse = build_parser().parse_args( + [ + "rf-source", + "pulse", + "configure", + "--port", + "rf_out", + "--period-s", + "0.001", + "--width-s", + "0.0001", + "--polarity", + "inverted", + ] + ) + sweep = build_parser().parse_args( + [ + "rf-source", + "sweep", + "configure", + "--port", + "rf_out", + "--start-frequency-hz", + "1000000", + "--stop-frequency-hz", + "2000000", + "--points", + "11", + "--dwell-s", + "0.02", + ] + ) + + assert (identity.domain, identity.command) == ("rf-source", "idn") + assert identity.config == "rf.toml" + assert identity.resource == "TCPIP::rf::INSTR" + assert (status.domain, status.command) == ("rf-source", "status") + assert (trigger_status.domain, trigger_status.command, trigger_status.trigger_command) == ( + "rf-source", + "trigger", + "status", + ) + assert trigger_status.port == "rf_out" + assert (frequency.domain, frequency.command) == ("rf-source", "set-frequency") + assert frequency.port == "rf_out" + assert frequency.frequency_hz == 4_000_000.0 + assert (power.domain, power.command) == ("rf-source", "set-power") + assert power.power_dbm == -20.0 + assert (output.domain, output.command) == ("rf-source", "output") + assert output.port == "rf_out" + assert output.state == "on" + assert (modulation_am.domain, modulation_am.command) == ("rf-source", "modulation") + assert modulation_am.modulation_command == "configure-am" + assert modulation_am.depth_percent == 50.0 + assert modulation_fm.modulation_command == "configure-fm" + assert modulation_fm.frequency_deviation_hz == 10_000.0 + assert modulation_pm.modulation_command == "configure-pm" + assert modulation_pm.phase_deviation_rad == 1.5 + assert modulation_output_am.modulation_command == "enable-output-am" + assert modulation_output_am.depth_percent == 50.0 + assert modulation_disable.modulation_command == "disable" + assert modulation_disable.modulation_kind == "am" + assert (pulse.domain, pulse.command, pulse.pulse_command) == ( + "rf-source", + "pulse", + "configure", + ) + assert pulse.period_s == 0.001 + assert pulse.width_s == 0.0001 + assert pulse.polarity == "inverted" + assert (sweep.domain, sweep.command, sweep.sweep_command) == ( + "rf-source", + "sweep", + "configure", + ) + assert sweep.start_frequency_hz == 1_000_000.0 + assert sweep.stop_frequency_hz == 2_000_000.0 + assert sweep.points == 11 + assert sweep.dwell_s == 0.02 + + +def test_rf_source_cli_dispatches_identity_and_typed_snapshot() -> None: + service = Mock() + service.idn.return_value = "EXAMPLE,RF1,0,1" + service.snapshot.return_value = SimpleNamespace( + as_dict=lambda: { + "schema": "wavebench.rf_source.snapshot.v1", + "ports": [], + "protection": {"availability": "unknown"}, + } + ) + + identity_stdout = io.StringIO() + with patch("wavebench.cli._load_rf_source_service", return_value=service), redirect_stdout( + identity_stdout + ): + assert main(["rf-source", "idn"]) == 0 + assert identity_stdout.getvalue().strip() == "EXAMPLE,RF1,0,1" + service.idn.assert_called_once_with() + + status_stdout = io.StringIO() + with patch("wavebench.cli._load_rf_source_service", return_value=service), redirect_stdout( + status_stdout + ): + assert main(["--json", "rf-source", "status"]) == 0 + payload = json.loads(status_stdout.getvalue()) + assert payload["schema"] == "wavebench.cli.result.v1" + assert payload["result"]["schema"] == "wavebench.rf_source.snapshot.v1" + service.snapshot.assert_called_once_with() + + +def test_rf_source_cli_dispatches_read_only_trigger_snapshot() -> None: + service = Mock() + service.trigger_snapshot.return_value = SimpleNamespace( + as_dict=lambda: { + "schema": "wavebench.rf_source.trigger_snapshot.v1", + "port_id": "rf_out", + "pulse_trigger_mode": "automatic", + "sweep_mode": "continuous", + } + ) + + stdout = io.StringIO() + with patch("wavebench.cli._load_rf_source_service", return_value=service), redirect_stdout(stdout): + assert main(["--json", "rf-source", "trigger", "status", "--port", "rf_out"]) == 0 + + payload = json.loads(stdout.getvalue()) + assert payload["schema"] == "wavebench.cli.result.v1" + assert payload["result"]["schema"] == "wavebench.rf_source.trigger_snapshot.v1" + assert payload["result"]["port_id"] == "rf_out" + service.trigger_snapshot.assert_called_once_with("rf_out") + + +def test_rf_source_cli_dispatches_each_off_only_cw_request() -> None: + service = Mock() + service.configure_cw.side_effect = [ + RfCwResult(port_id="rf_out", frequency_hz=4_000_000.0), + RfCwResult(port_id="rf_out", power_dbm=-20.0), + ] + + frequency_stdout = io.StringIO() + with patch("wavebench.cli._load_rf_source_service", return_value=service), redirect_stdout( + frequency_stdout + ): + assert main(["--json", "rf-source", "set-frequency", "--port", "rf_out", "4000000"]) == 0 + frequency_payload = json.loads(frequency_stdout.getvalue()) + assert frequency_payload["result"]["frequency_hz"] == 4_000_000.0 + + power_stdout = io.StringIO() + with patch("wavebench.cli._load_rf_source_service", return_value=service), redirect_stdout( + power_stdout + ): + assert main(["--json", "rf-source", "set-power", "--port", "rf_out", "-20"]) == 0 + power_payload = json.loads(power_stdout.getvalue()) + assert power_payload["result"]["power_dbm"] == -20.0 + assert service.configure_cw.call_args_list == [ + ((RfCwRequest(port_id="rf_out", frequency_hz=4_000_000.0),), {}), + ((RfCwRequest(port_id="rf_out", power_dbm=-20.0),), {}), + ] + + +def test_rf_source_cli_dispatches_each_output_request() -> None: + service = Mock() + service.set_output.side_effect = [ + RfOutputResult(port_id="rf_out", enabled=True, write_completed=True), + RfOutputResult(port_id="rf_out", enabled=False, write_completed=False), + ] + + on_stdout = io.StringIO() + with patch("wavebench.cli._load_rf_source_service", return_value=service), redirect_stdout( + on_stdout + ): + assert main(["--json", "rf-source", "output", "--port", "rf_out", "on"]) == 0 + on_payload = json.loads(on_stdout.getvalue()) + assert on_payload["result"]["enabled"] is True + assert on_payload["result"]["write_completed"] is True + + off_stdout = io.StringIO() + with patch("wavebench.cli._load_rf_source_service", return_value=service), redirect_stdout( + off_stdout + ): + assert main(["--json", "rf-source", "output", "--port", "rf_out", "off"]) == 0 + off_payload = json.loads(off_stdout.getvalue()) + assert off_payload["result"]["enabled"] is False + assert off_payload["result"]["write_completed"] is False + assert service.set_output.call_args_list == [ + ((RfOutputRequest(port_id="rf_out", enabled=True),), {}), + ((RfOutputRequest(port_id="rf_out", enabled=False),), {}), + ] + + +def test_rf_source_cli_dispatches_each_internal_sine_modulation_request() -> None: + service = Mock() + service.configure_modulation.side_effect = [ + RfModulationResult( + port_id="rf_out", + kind=RfModulationKind.AM, + depth_percent=50.0, + internal_frequency_hz=1_000.0, + ), + RfModulationResult( + port_id="rf_out", + kind=RfModulationKind.FM, + frequency_deviation_hz=10_000.0, + internal_frequency_hz=1_000.0, + ), + RfModulationResult( + port_id="rf_out", + kind=RfModulationKind.PM, + phase_deviation_rad=1.5, + internal_frequency_hz=1_000.0, + ), + ] + + commands = ( + [ + "rf-source", + "modulation", + "configure-am", + "--port", + "rf_out", + "--depth-percent", + "50", + "--internal-frequency-hz", + "1000", + ], + [ + "rf-source", + "modulation", + "configure-fm", + "--port", + "rf_out", + "--frequency-deviation-hz", + "10000", + "--internal-frequency-hz", + "1000", + ], + [ + "rf-source", + "modulation", + "configure-pm", + "--port", + "rf_out", + "--phase-deviation-rad", + "1.5", + "--internal-frequency-hz", + "1000", + ], + ) + + with patch("wavebench.cli._load_rf_source_service", return_value=service): + for command in commands: + with redirect_stdout(io.StringIO()): + assert main(["--json", *command]) == 0 + + assert service.configure_modulation.call_args_list == [ + ( + ( + RfModulationRequest( + port_id="rf_out", + kind=RfModulationKind.AM, + depth_percent=50.0, + internal_frequency_hz=1_000.0, + ), + ), + {}, + ), + ( + ( + RfModulationRequest( + port_id="rf_out", + kind=RfModulationKind.FM, + frequency_deviation_hz=10_000.0, + internal_frequency_hz=1_000.0, + ), + ), + {}, + ), + ( + ( + RfModulationRequest( + port_id="rf_out", + kind=RfModulationKind.PM, + phase_deviation_rad=1.5, + internal_frequency_hz=1_000.0, + ), + ), + {}, + ), + ] + + +def test_rf_source_cli_dispatches_profile_bound_modulated_output_enable() -> None: + service = Mock() + service.enable_modulated_output.return_value = RfModulatedOutputResult( + modulation=RfModulationResult( + port_id="rf_out", + kind=RfModulationKind.AM, + depth_percent=50.0, + internal_frequency_hz=1_000.0, + ), + write_completed=True, + ) + + stdout = io.StringIO() + with patch("wavebench.cli._load_rf_source_service", return_value=service), redirect_stdout(stdout): + assert ( + main( + [ + "--json", + "rf-source", + "modulation", + "enable-output-am", + "--port", + "rf_out", + "--depth-percent", + "50", + "--internal-frequency-hz", + "1000", + ] + ) + == 0 + ) + + payload = json.loads(stdout.getvalue()) + assert payload["result"]["write_completed"] is True + service.enable_modulated_output.assert_called_once_with( + RfModulatedOutputRequest( + modulation=RfModulationRequest( + port_id="rf_out", + kind=RfModulationKind.AM, + depth_percent=50.0, + internal_frequency_hz=1_000.0, + ) + ) + ) + + +def test_rf_source_cli_dispatches_mode_specific_modulation_disable() -> None: + service = Mock() + service.disable_modulation.return_value = RfModulationDisableResult( + port_id="rf_out", + kind=RfModulationKind.AM, + write_completed=True, + ) + + stdout = io.StringIO() + with patch("wavebench.cli._load_rf_source_service", return_value=service), redirect_stdout(stdout): + assert ( + main( + [ + "--json", + "rf-source", + "modulation", + "disable", + "--port", + "rf_out", + "--modulation-kind", + "am", + ] + ) + == 0 + ) + + payload = json.loads(stdout.getvalue()) + assert payload["result"]["write_completed"] is True + service.disable_modulation.assert_called_once_with( + RfModulationDisableRequest(port_id="rf_out", kind=RfModulationKind.AM) + ) + + +def test_rf_source_cli_dispatches_disabled_internal_single_pulse_request() -> None: + service = Mock() + service.configure_pulse.return_value = RfPulseConfigureResult( + port_id="rf_out", + period_s=0.001, + width_s=0.0001, + polarity=RfPulsePolarity.INVERTED, + ) + + stdout = io.StringIO() + with patch("wavebench.cli._load_rf_source_service", return_value=service), redirect_stdout(stdout): + assert main( + [ + "--json", + "rf-source", + "pulse", + "configure", + "--port", + "rf_out", + "--period-s", + "0.001", + "--width-s", + "0.0001", + "--polarity", + "inverted", + ] + ) == 0 + + payload = json.loads(stdout.getvalue()) + assert payload["result"] == { + "port_id": "rf_out", + "period_s": 0.001, + "width_s": 0.0001, + "polarity": "inverted", + } + service.configure_pulse.assert_called_once_with( + RfPulseConfigureRequest( + port_id="rf_out", + period_s=0.001, + width_s=0.0001, + polarity=RfPulsePolarity.INVERTED, + ) + ) + + +def test_rf_source_cli_dispatches_disabled_frequency_only_step_sweep_request() -> None: + service = Mock() + service.configure_sweep.return_value = RfSweepConfigureResult( + port_id="rf_out", + start_frequency_hz=1_000_000.0, + stop_frequency_hz=2_000_000.0, + points=11, + dwell_s=0.02, + ) + + stdout = io.StringIO() + with patch("wavebench.cli._load_rf_source_service", return_value=service), redirect_stdout(stdout): + assert main( + [ + "--json", + "rf-source", + "sweep", + "configure", + "--port", + "rf_out", + "--start-frequency-hz", + "1000000", + "--stop-frequency-hz", + "2000000", + "--points", + "11", + "--dwell-s", + "0.02", + ] + ) == 0 + + payload = json.loads(stdout.getvalue()) + assert payload["result"] == { + "port_id": "rf_out", + "start_frequency_hz": 1_000_000.0, + "stop_frequency_hz": 2_000_000.0, + "points": 11, + "dwell_s": 0.02, + } + service.configure_sweep.assert_called_once_with( + RfSweepConfigureRequest( + port_id="rf_out", + start_frequency_hz=1_000_000.0, + stop_frequency_hz=2_000_000.0, + points=11, + dwell_s=0.02, + ) + ) + + +def test_rf_source_resource_override_does_not_touch_source_config() -> None: + updated = object() + config = SimpleNamespace(with_rf_source_resource=Mock(return_value=updated)) + args = SimpleNamespace(config="rf.toml", resource="TCPIP::rf::INSTR") + + with patch("wavebench.cli.load_config", return_value=config), patch( + "wavebench.cli.RfSourceService" + ) as service_type: + result = _load_rf_source_service(args) + + config.with_rf_source_resource.assert_called_once_with("TCPIP::rf::INSTR") + service_type.assert_called_once() + assert service_type.call_args.kwargs["config"] is updated + assert result is service_type.return_value diff --git a/tests/test_rf_source_config.py b/tests/test_rf_source_config.py new file mode 100644 index 0000000..866a9c9 --- /dev/null +++ b/tests/test_rf_source_config.py @@ -0,0 +1,190 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from wavebench.config import ( + AutoscaleConfig, + ConnectionConfig, + OutputConfig, + RfPortSafetyConfig, + RfSourceConfig, + ScopeConfig, + WaveBenchConfig, + WaveformConfig, + load_config, +) +from wavebench.errors import ConfigError + + +def _config_text(port_block: str) -> str: + return f'''\ +[connection] +resource = "TCPIP::scope::INSTR" + +[scope] + +[rf_source] +driver = "example.rf1" +resource = "TCPIP::rf::INSTR" +access = "read_only" + +[[rf_source.safety.ports]] +{port_block} +''' + + +def _accept_references(monkeypatch: pytest.MonkeyPatch) -> list[tuple[str, str]]: + calls: list[tuple[str, str]] = [] + + def accept(driver: str, *, expected_kind: str) -> None: + calls.append((driver, expected_kind)) + + monkeypatch.setattr( + "wavebench.instruments.registry.validate_instrument_reference", + accept, + ) + return calls + + +def test_loads_isolated_rf_source_config_and_safety_ports( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + path = tmp_path / "wavebench.toml" + path.write_text( + _config_text( + '''\ +port_id = "rf_out" +minimum_frequency_hz = 9000 +maximum_frequency_hz = 3000000000 +maximum_power_dbm = -20 +actual_termination_ohm = 50 +''' + ), + encoding="utf-8", + ) + calls = _accept_references(monkeypatch) + + config = load_config(path) + + assert config.rf_source == RfSourceConfig( + driver="example.rf1", + resource="TCPIP::rf::INSTR", + access="read_only", + safety_ports=( + RfPortSafetyConfig( + port_id="rf_out", + minimum_frequency_hz=9_000.0, + maximum_frequency_hz=3_000_000_000.0, + maximum_power_dbm=-20.0, + actual_termination_ohm=50.0, + ), + ), + ) + assert ("example.rf1", "rf_source") in calls + + +@pytest.mark.parametrize( + ("port_block", "message"), + ( + ( + '''\ +port_id = "rf out" +minimum_frequency_hz = 9000 +maximum_frequency_hz = 3000000000 +maximum_power_dbm = -20 +actual_termination_ohm = 50 +''', + "port_id", + ), + ( + '''\ +port_id = "rf_out" +minimum_frequency_hz = 3000000000 +maximum_frequency_hz = 9000 +maximum_power_dbm = -20 +actual_termination_ohm = 50 +''', + "maximum_frequency_hz", + ), + ( + '''\ +port_id = "rf_out" +minimum_frequency_hz = 9000 +maximum_frequency_hz = 3000000000 +maximum_power_dbm = -20 +actual_termination_ohm = nan +''', + "actual_termination_ohm", + ), + ), +) +def test_rejects_invalid_rf_port_safety_declarations( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + port_block: str, + message: str, +) -> None: + path = tmp_path / "wavebench.toml" + path.write_text(_config_text(port_block), encoding="utf-8") + _accept_references(monkeypatch) + + with pytest.raises(ConfigError, match=message): + load_config(path) + + +def test_rejects_duplicate_rf_port_safety_declarations( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + path = tmp_path / "wavebench.toml" + path.write_text( + _config_text( + '''\ +port_id = "rf_out" +minimum_frequency_hz = 9000 +maximum_frequency_hz = 3000000000 +maximum_power_dbm = -20 +actual_termination_ohm = 50 + +[[rf_source.safety.ports]] +port_id = "rf_out" +minimum_frequency_hz = 9000 +maximum_frequency_hz = 3000000000 +maximum_power_dbm = -20 +actual_termination_ohm = 50 +''' + ), + encoding="utf-8", + ) + _accept_references(monkeypatch) + + with pytest.raises(ConfigError, match="must be unique"): + load_config(path) + + +def test_resource_override_preserves_rf_source_safety_declarations() -> None: + safety_port = RfPortSafetyConfig("rf_out", 9_000.0, 3_000_000_000.0, -20.0, 50.0) + config = WaveBenchConfig( + connection=ConnectionConfig("lan", "TCPIP::scope::INSTR", 1_000, 1_000), + 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"), + rf_source=RfSourceConfig( + driver="example.rf1", + resource="TCPIP::old-rf::INSTR", + access="read_only", + safety_ports=(safety_port,), + ), + ) + + updated = config.with_rf_source_resource("TCPIP::new-rf::INSTR") + + assert updated.rf_source is not None + assert updated.rf_source.resource == "TCPIP::new-rf::INSTR" + assert updated.rf_source.access == "read_only" + assert updated.rf_source.safety_ports == (safety_port,) diff --git a/tests/test_rf_source_doctor.py b/tests/test_rf_source_doctor.py new file mode 100644 index 0000000..731c4d5 --- /dev/null +++ b/tests/test_rf_source_doctor.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from pathlib import Path + +from wavebench.config import ( + AutoscaleConfig, + ConnectionConfig, + OutputConfig, + RfSourceConfig, + ScopeConfig, + WaveBenchConfig, + WaveformConfig, +) +from wavebench.doctor import doctor_records + + +def _config(*, rf_resource: str | None) -> WaveBenchConfig: + return WaveBenchConfig( + connection=ConnectionConfig("lan", "TCPIP::scope::INSTR", 1_000, 1_000), + scope=ScopeConfig("rtm2032", "RTM2032", 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"), + rf_source=RfSourceConfig(driver="example.rf1", resource=rf_resource), + ) + + +def test_doctor_adds_rf_source_as_identity_only_target() -> None: + records = doctor_records( + _config(rf_resource="TCPIP::rf::INSTR"), + idn_probe=lambda resource, timeout_ms: { + "TCPIP::scope::INSTR": "Rohde&Schwarz,RTM2032,0,0", + "TCPIP::rf::INSTR": "Example,RF1,0,0", + }.get(resource), + ) + + assert [(record.target, record.severity) for record in records] == [ + ("scope", "ok"), + ("rf_source", "ok"), + ] + + +def test_doctor_reports_unconfigured_rf_source_resource_without_querying() -> None: + queried: list[str] = [] + records = doctor_records( + _config(rf_resource=None), + idn_probe=lambda resource, timeout_ms: queried.append(resource) or "unused", + ) + + rf_source = next(record for record in records if record.target == "rf_source") + assert rf_source.severity == "warning" + assert rf_source.idn is None + assert "" not in queried diff --git a/tests/test_rf_source_extensions.py b/tests/test_rf_source_extensions.py new file mode 100644 index 0000000..226a1ae --- /dev/null +++ b/tests/test_rf_source_extensions.py @@ -0,0 +1,596 @@ +from __future__ import annotations + +from dataclasses import replace + +import pytest + +from wavebench.errors import ConfigError +from wavebench.instruments.api import InstrumentDescriptor +from wavebench.instruments.capabilities import CAPABILITY_METHODS, validate_declared_capabilities +from wavebench.instruments.rf_source_capabilities import ( + RF_SOURCE_CAPABILITY_METHODS, + validate_rf_source_descriptor, + validate_rf_source_plugin_dependencies, +) +from wavebench.instruments.rf_source_extensions import ( + RF_SOURCE_CONTRACT_VERSION, + RF_SOURCE_OPERATION_ARTIFACT_SCHEMA, + RF_SOURCE_SNAPSHOT_SCHEMA, + RF_SOURCE_TRIGGER_SNAPSHOT_SCHEMA, + RfAvailability, + RfCwProfile, + RfCwRequest, + RfCwResult, + RfExternalGatePolarity, + RfExternalTriggerEdge, + RfFeature, + RfFeatureCapability, + RfFeatureDirection, + RfModulationState, + RfObserved, + RfOutputPortProfile, + RfOutputProfile, + RfOutputRequest, + RfOutputResult, + RfPortSnapshot, + RfProtectionConditionPolicy, + RfProtectionStatus, + RfPulseState, + RfReasonCode, + RfSourceDescriptorExtensions, + RfSourceSnapshot, + RfSourceTopology, + RfSweepState, + RfSweepMode, + RfSweepTriggerMode, + RfTriggerProfile, + RfTriggerSnapshot, + RfPulseTriggerMode, + rf_source_snapshot_document, + rf_source_snapshot_operation_artifact, + rf_source_trigger_snapshot_operation_artifact, + rf_trigger_snapshot_document, + rf_source_cw_operation_artifact, + rf_source_output_operation_artifact, +) + + +class RfDriver: + def close(self) -> None: + pass + + def idn(self) -> str: + return "EXAMPLE,RF1,0,1" + + def get_rf_snapshot(self) -> RfSourceSnapshot: + return snapshot() + + def get_rf_trigger_snapshot(self, port_id: str) -> RfTriggerSnapshot: + assert port_id == "rf_out" + return trigger_snapshot() + + def configure_cw(self, request: RfCwRequest) -> None: + del request + + def set_rf_output(self, request: RfOutputRequest) -> None: + del request + + +def topology() -> RfSourceTopology: + return RfSourceTopology( + ( + RfOutputPortProfile( + port_id="rf_out", + frequency_min_hz=9_000.0, + frequency_max_hz=3_000_000_000.0, + power_min_dbm=-110.0, + power_max_dbm=20.0, + power_reference_impedance_ohm=50.0, + ), + ) + ) + + +def extensions() -> RfSourceDescriptorExtensions: + return RfSourceDescriptorExtensions( + contract_version=RF_SOURCE_CONTRACT_VERSION, + topology=topology(), + features=( + RfFeatureCapability( + feature=RfFeature.CW, + directions=(RfFeatureDirection.READ,), + port_ids=("rf_out",), + profile=RfCwProfile(frequency_readable=True, power_readable=True), + ), + RfFeatureCapability( + feature=RfFeature.OUTPUT, + directions=(RfFeatureDirection.READ,), + port_ids=("rf_out",), + profile=RfOutputProfile(output_readable=True), + ), + ), + protection_conditions=( + RfProtectionConditionPolicy("overtemperature", blocks_output_enable=True), + ), + ) + + +def cw_extensions() -> RfSourceDescriptorExtensions: + return RfSourceDescriptorExtensions( + contract_version=RF_SOURCE_CONTRACT_VERSION, + topology=topology(), + features=( + RfFeatureCapability( + feature=RfFeature.CW, + directions=(RfFeatureDirection.CONFIGURE, RfFeatureDirection.READ), + port_ids=("rf_out",), + profile=RfCwProfile( + frequency_readable=True, + power_readable=True, + frequency_configurable=True, + power_configurable=True, + ), + ), + ), + ) + + +def output_extensions() -> RfSourceDescriptorExtensions: + return RfSourceDescriptorExtensions( + contract_version=RF_SOURCE_CONTRACT_VERSION, + topology=topology(), + features=( + RfFeatureCapability( + feature=RfFeature.OUTPUT, + directions=( + RfFeatureDirection.DISABLE, + RfFeatureDirection.ENABLE, + RfFeatureDirection.READ, + ), + port_ids=("rf_out",), + profile=RfOutputProfile(output_readable=True), + ), + ), + ) + + +def trigger_profile() -> RfTriggerProfile: + return RfTriggerProfile( + state_readable=True, + pulse_trigger_modes=( + RfPulseTriggerMode.AUTOMATIC, + RfPulseTriggerMode.BUS, + RfPulseTriggerMode.EXTERNAL, + RfPulseTriggerMode.EXTERNAL_GATE, + RfPulseTriggerMode.KEY, + ), + pulse_external_trigger_edges=( + RfExternalTriggerEdge.NEGATIVE, + RfExternalTriggerEdge.POSITIVE, + ), + pulse_external_gate_polarities=( + RfExternalGatePolarity.INVERTED, + RfExternalGatePolarity.NORMAL, + ), + sweep_modes=(RfSweepMode.CONTINUOUS, RfSweepMode.SINGLE), + sweep_period_trigger_modes=( + RfSweepTriggerMode.AUTOMATIC, + RfSweepTriggerMode.BUS, + RfSweepTriggerMode.EXTERNAL, + RfSweepTriggerMode.KEY, + ), + sweep_point_trigger_modes=( + RfSweepTriggerMode.AUTOMATIC, + RfSweepTriggerMode.BUS, + RfSweepTriggerMode.EXTERNAL, + RfSweepTriggerMode.KEY, + ), + ) + + +def trigger_extensions() -> RfSourceDescriptorExtensions: + return RfSourceDescriptorExtensions( + contract_version=RF_SOURCE_CONTRACT_VERSION, + topology=topology(), + features=( + RfFeatureCapability( + feature=RfFeature.TRIGGER, + directions=(RfFeatureDirection.READ,), + port_ids=("rf_out",), + profile=trigger_profile(), + ), + ), + ) + + +def descriptor(**changes: object) -> InstrumentDescriptor: + value = InstrumentDescriptor( + driver_id="example.rf1", + kind="rf_source", + display_name="Example RF Source", + manufacturer="Example", + models=("RF1",), + aliases=(), + capabilities=("rf_source.idn", "rf_source.snapshot"), + idn_patterns=("EXAMPLE,RF1",), + backends=("pyvisa",), + option_specs=(), + permissions=("instrument.io",), + factory=lambda context: RfDriver(), + wavebench_min_version="0.8.25", + wavebench_max_version="0.9.0", + rf_source_extensions=extensions(), + ) + return replace(value, **changes) + + +def snapshot() -> RfSourceSnapshot: + return RfSourceSnapshot( + ports=( + RfPortSnapshot( + port_id="rf_out", + frequency_hz=RfObserved.value_of(1_000_000.0), + power_dbm=RfObserved.value_of(-30.0), + output_enabled=RfObserved.value_of(False), + modulation=RfObserved.value_of(RfModulationState.DISABLED), + pulse=RfObserved.value_of(RfPulseState.DISABLED), + sweep=RfObserved.value_of(RfSweepState.DISABLED), + ), + ), + protection=RfObserved.value_of(RfProtectionStatus(active_codes=())), + ) + + +def trigger_snapshot() -> RfTriggerSnapshot: + return RfTriggerSnapshot( + port_id="rf_out", + pulse_trigger_mode=RfPulseTriggerMode.AUTOMATIC, + pulse_external_trigger_edge=RfExternalTriggerEdge.POSITIVE, + pulse_external_gate_polarity=RfExternalGatePolarity.NORMAL, + sweep_mode=RfSweepMode.CONTINUOUS, + sweep_period_trigger_mode=RfSweepTriggerMode.AUTOMATIC, + sweep_point_trigger_mode=RfSweepTriggerMode.AUTOMATIC, + ) + + +def test_rf_source_topology_and_features_are_strict() -> None: + with pytest.raises(ValueError, match="finite"): + RfOutputPortProfile("rf_out", float("nan"), 1.0, -1.0, 1.0, 50.0) + with pytest.raises(ValueError, match="must be positive"): + RfOutputPortProfile("rf_out", 1.0, 2.0, -1.0, 1.0, 0.0) + with pytest.raises(ValueError, match="sorted and unique"): + RfSourceTopology( + ( + RfOutputPortProfile("z", 1.0, 2.0, -1.0, 1.0, 50.0), + RfOutputPortProfile("a", 1.0, 2.0, -1.0, 1.0, 50.0), + ) + ) + with pytest.raises(ValueError, match="does not match feature"): + RfFeatureCapability( + feature=RfFeature.CW, + directions=(RfFeatureDirection.READ,), + port_ids=("rf_out",), + profile=RfOutputProfile(output_readable=True), + ) + with pytest.raises(ValueError, match="requires readable frequency"): + RfCwProfile( + frequency_readable=False, + power_readable=True, + frequency_configurable=True, + ) + with pytest.raises(ValueError, match="unknown port"): + replace( + extensions(), + features=( + RfFeatureCapability( + feature=RfFeature.CW, + directions=(RfFeatureDirection.READ,), + port_ids=("other",), + profile=RfCwProfile(frequency_readable=True, power_readable=True), + ), + ), + ) + + +def test_rf_observation_and_snapshot_reject_unsafe_values() -> None: + with pytest.raises(ValueError, match="must carry a value"): + RfObserved(RfAvailability.VALUE) + with pytest.raises(ValueError, match="require a registered reason_code"): + RfObserved(RfAvailability.UNKNOWN) + with pytest.raises(ValueError, match="cannot contain non-finite"): + RfObserved.value_of(float("inf")) + with pytest.raises(ValueError, match="invalid VALUE type"): + RfPortSnapshot( + port_id="rf_out", + frequency_hz=RfObserved.value_of(1.0), + power_dbm=RfObserved.value_of(0.0), + output_enabled=RfObserved.value_of("ON"), + modulation=RfObserved.missing(RfAvailability.UNSUPPORTED, RfReasonCode.DESCRIPTOR_UNSUPPORTED), + pulse=RfObserved.missing(RfAvailability.UNSUPPORTED, RfReasonCode.DESCRIPTOR_UNSUPPORTED), + sweep=RfObserved.missing(RfAvailability.UNSUPPORTED, RfReasonCode.DESCRIPTOR_UNSUPPORTED), + ) + + +def test_rf_cw_request_and_result_require_one_finite_field() -> None: + assert RfCwRequest(port_id="rf_out", frequency_hz=1_000_000.0).frequency_hz == 1_000_000.0 + assert RfCwResult(port_id="rf_out", power_dbm=-20.0).power_dbm == -20.0 + with pytest.raises(ValueError, match="exactly one"): + RfCwRequest(port_id="rf_out") + with pytest.raises(ValueError, match="exactly one"): + RfCwRequest(port_id="rf_out", frequency_hz=1.0, power_dbm=0.0) + with pytest.raises(ValueError, match="finite"): + RfCwResult(port_id="rf_out", frequency_hz=float("nan")) + + +def test_rf_output_request_and_result_require_explicit_boolean_state() -> None: + assert RfOutputRequest(port_id="rf_out", enabled=True).enabled is True + assert RfOutputResult(port_id="rf_out", enabled=False, write_completed=False).write_completed is False + with pytest.raises(ValueError, match="boolean"): + RfOutputRequest(port_id="rf_out", enabled=1) # type: ignore[arg-type] + with pytest.raises(ValueError, match="boolean"): + RfOutputResult(port_id="rf_out", enabled=False, write_completed=0) # type: ignore[arg-type] + + +def test_rf_trigger_profile_and_snapshot_are_complete_and_typed() -> None: + profile = trigger_profile() + value = trigger_snapshot() + + assert profile.state_readable is True + assert value.pulse_trigger_mode is RfPulseTriggerMode.AUTOMATIC + assert value.sweep_mode is RfSweepMode.CONTINUOUS + with pytest.raises(ValueError, match="must not be empty"): + RfTriggerProfile(state_readable=True) + with pytest.raises(ValueError, match="cannot declare trigger states"): + RfTriggerProfile( + state_readable=False, + pulse_trigger_modes=(RfPulseTriggerMode.AUTOMATIC,), + ) + with pytest.raises(ValueError, match="invalid type"): + RfTriggerSnapshot( + port_id="rf_out", + pulse_trigger_mode=RfPulseTriggerMode.AUTOMATIC, + pulse_external_trigger_edge=RfExternalTriggerEdge.POSITIVE, + pulse_external_gate_polarity=RfExternalGatePolarity.NORMAL, + sweep_mode="continuous", # type: ignore[arg-type] + sweep_period_trigger_mode=RfSweepTriggerMode.AUTOMATIC, + sweep_point_trigger_mode=RfSweepTriggerMode.AUTOMATIC, + ) + + +def test_rf_snapshot_document_and_artifact_are_structured_and_redacted() -> None: + value = snapshot() + document = rf_source_snapshot_document(value) + artifact = rf_source_snapshot_operation_artifact(value) + + assert document["schema"] == RF_SOURCE_SNAPSHOT_SCHEMA + assert document["ports"][0]["frequency_hz"]["value"] == 1_000_000.0 + assert artifact == { + "schema": RF_SOURCE_OPERATION_ARTIFACT_SCHEMA, + "operation": "rf_source.snapshot", + "snapshot": document, + } + + +def test_rf_trigger_snapshot_document_and_artifact_are_structured_and_redacted() -> None: + value = trigger_snapshot() + document = rf_trigger_snapshot_document(value) + artifact = rf_source_trigger_snapshot_operation_artifact(value) + + assert document == { + "schema": RF_SOURCE_TRIGGER_SNAPSHOT_SCHEMA, + "type": "RfTriggerSnapshot", + "port_id": "rf_out", + "pulse_trigger_mode": "automatic", + "pulse_external_trigger_edge": "positive", + "pulse_external_gate_polarity": "normal", + "sweep_mode": "continuous", + "sweep_period_trigger_mode": "automatic", + "sweep_point_trigger_mode": "automatic", + } + assert artifact == { + "schema": RF_SOURCE_OPERATION_ARTIFACT_SCHEMA, + "operation": "rf_source.trigger_snapshot", + "trigger_snapshot": document, + } + assert "resource" not in str(artifact) + + +def test_rf_cw_operation_artifact_uses_typed_pre_and_postcondition_evidence() -> None: + request = RfCwRequest(port_id="rf_out", frequency_hz=2_000_000.0) + result = RfCwResult(port_id="rf_out", frequency_hz=2_000_000.0) + preflight = snapshot() + postcondition = RfSourceSnapshot( + ports=( + RfPortSnapshot( + port_id="rf_out", + frequency_hz=RfObserved.value_of(2_000_000.0), + power_dbm=RfObserved.value_of(-30.0), + output_enabled=RfObserved.value_of(False), + modulation=RfObserved.value_of(RfModulationState.DISABLED), + pulse=RfObserved.value_of(RfPulseState.DISABLED), + sweep=RfObserved.value_of(RfSweepState.DISABLED), + ), + ), + protection=RfObserved.value_of(RfProtectionStatus(active_codes=())), + ) + + artifact = rf_source_cw_operation_artifact( + request, + result, + preflight_snapshot=preflight, + postcondition_snapshot=postcondition, + ) + + assert artifact["schema"] == RF_SOURCE_OPERATION_ARTIFACT_SCHEMA + assert artifact["operation"] == "rf_source.set_frequency" + assert artifact["request"]["frequency_hz"] == 2_000_000.0 + assert artifact["result"]["frequency_hz"] == 2_000_000.0 + assert "resource" not in str(artifact) + + +def test_rf_output_operation_artifact_uses_typed_pre_and_postcondition_evidence() -> None: + request = RfOutputRequest(port_id="rf_out", enabled=True) + result = RfOutputResult(port_id="rf_out", enabled=True, write_completed=True) + preflight = snapshot() + postcondition = replace( + preflight, + ports=(replace(preflight.ports[0], output_enabled=RfObserved.value_of(True)),), + ) + + artifact = rf_source_output_operation_artifact( + request, + result, + preflight_snapshot=preflight, + postcondition_snapshot=postcondition, + ) + + assert artifact["operation"] == "rf_source.output_enable" + assert artifact["result"]["write_completed"] is True + assert artifact["postcondition_snapshot"]["ports"][0]["output_enabled"]["value"] is True + with pytest.raises(ValueError, match="same target"): + rf_source_output_operation_artifact( + request, + RfOutputResult(port_id="rf_out", enabled=False, write_completed=True), + preflight_snapshot=preflight, + postcondition_snapshot=postcondition, + ) + + +def test_rf_descriptor_capabilities_and_driver_methods_are_validated() -> None: + value = descriptor() + + assert dict(RF_SOURCE_CAPABILITY_METHODS) == { + "rf_source.idn": ("idn",), + "rf_source.snapshot": ("get_rf_snapshot",), + "rf_source.trigger_snapshot": ("get_rf_trigger_snapshot",), + "rf_source.cw_configure": ("configure_cw",), + "rf_source.modulation_configure": ( + "get_rf_modulation_state", + "get_rf_modulation_snapshot", + "configure_rf_modulation", + ), + "rf_source.modulation_disable": ( + "get_rf_modulation_state", + "disable_rf_modulation", + ), + "rf_source.modulated_output_enable": ( + "get_rf_modulation_snapshot", + "set_rf_output", + ), + "rf_source.pulse_configure": ( + "get_rf_pulse_snapshot", + "configure_rf_pulse", + ), + "rf_source.pulse_output": ( + "get_rf_pulse_output_snapshot", + "set_rf_pulse_output", + ), + "rf_source.sweep_configure": ( + "get_rf_sweep_snapshot", + "configure_rf_sweep", + ), + "rf_source.output": ("set_rf_output",), + } + assert {key: CAPABILITY_METHODS[key] for key in RF_SOURCE_CAPABILITY_METHODS} == dict( + RF_SOURCE_CAPABILITY_METHODS + ) + validate_rf_source_descriptor(value) + validate_declared_capabilities(value, RfDriver()) + + with pytest.raises(TypeError, match="get_rf_snapshot"): + validate_declared_capabilities( + value, + type("IdentityOnly", (), {"close": lambda self: None, "idn": lambda self: "idn"})(), + ) + with pytest.raises(ConfigError, match="unknown capabilities"): + validate_rf_source_descriptor(replace(value, capabilities=("rf_source.idn", "rf_source.future"))) + with pytest.raises(ConfigError, match="CW feature"): + validate_rf_source_descriptor( + replace( + value, + capabilities=("rf_source.idn", "rf_source.snapshot", "rf_source.cw_configure"), + ) + ) + cw_descriptor = replace( + value, + capabilities=("rf_source.idn", "rf_source.snapshot", "rf_source.cw_configure"), + rf_source_extensions=cw_extensions(), + ) + validate_rf_source_descriptor(cw_descriptor) + validate_declared_capabilities(cw_descriptor, RfDriver()) + with pytest.raises(ConfigError, match="trigger feature with read direction"): + validate_rf_source_descriptor( + replace( + value, + capabilities=("rf_source.idn", "rf_source.snapshot", "rf_source.trigger_snapshot"), + ) + ) + trigger_descriptor = replace( + value, + capabilities=("rf_source.idn", "rf_source.snapshot", "rf_source.trigger_snapshot"), + rf_source_extensions=trigger_extensions(), + ) + validate_rf_source_descriptor(trigger_descriptor) + validate_declared_capabilities(trigger_descriptor, RfDriver()) + with pytest.raises(ConfigError, match="output ENABLE and DISABLE"): + validate_rf_source_descriptor( + replace( + value, + capabilities=("rf_source.idn", "rf_source.snapshot", "rf_source.output"), + ) + ) + output_descriptor = replace( + value, + capabilities=("rf_source.idn", "rf_source.snapshot", "rf_source.output"), + rf_source_extensions=output_extensions(), + ) + validate_rf_source_descriptor(output_descriptor) + validate_declared_capabilities(output_descriptor, RfDriver()) + + +def test_rf_source_kind_requires_extensions_and_uses_append_only_field() -> None: + with pytest.raises(ValueError, match="require rf_source_extensions"): + InstrumentDescriptor( + driver_id="example.missing", + kind="rf_source", + display_name="Missing", + manufacturer="Example", + models=("RF1",), + aliases=(), + capabilities=("rf_source.idn",), + idn_patterns=(), + backends=("pyvisa",), + option_specs=(), + permissions=("instrument.io",), + factory=lambda context: RfDriver(), + ) + with pytest.raises(ConfigError, match="require the rf_source.idn"): + validate_rf_source_descriptor(replace(descriptor(), capabilities=("rf_source.snapshot",))) + + +def test_rf_source_wheel_dependency_must_match_descriptor_interval() -> None: + value = descriptor() + + validate_rf_source_plugin_dependencies(value, ("wavebench>=0.8.25,<0.9",)) + validate_rf_source_plugin_dependencies( + value, + ( + "wavebench>=0.8.25,<0.9,!=0.8.26", + 'wavebench>=99; python_version < "3.0"', + ), + ) + + with pytest.raises(ConfigError, match="explicitly include >=0.8.25,<0.9.0"): + validate_rf_source_plugin_dependencies(value, ("wavebench>=0.8,<0.9",)) + with pytest.raises(ConfigError, match="expands or excludes"): + validate_rf_source_plugin_dependencies( + value, + ("wavebench>=0.8.25,<0.9,!=0.8.25",), + ) + with pytest.raises(ConfigError, match="exactly one active"): + validate_rf_source_plugin_dependencies( + value, + ('wavebench>=0.8.25,<0.9; python_version < "3.0"',), + ) + with pytest.raises(ConfigError, match="invalid Requires-Dist"): + validate_rf_source_plugin_dependencies(value, ("wavebench=>not-a-version",)) diff --git a/tests/test_rf_source_modulated_output_extensions.py b/tests/test_rf_source_modulated_output_extensions.py new file mode 100644 index 0000000..543ebd6 --- /dev/null +++ b/tests/test_rf_source_modulated_output_extensions.py @@ -0,0 +1,302 @@ +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from wavebench.errors import ConfigError +from wavebench.instruments.capabilities import CAPABILITY_METHODS +from wavebench.instruments.rf_source_capabilities import validate_rf_source_descriptor +from wavebench.instruments.rf_source_extensions import ( + RF_SOURCE_CONTRACT_VERSION, + RfFeature, + RfFeatureCapability, + RfFeatureDirection, + RfModulatedOutputProfile, + RfModulatedOutputRequest, + RfModulatedOutputResult, + RfModulationKind, + RfModulationModeProfile, + RfModulationProfile, + RfModulationRequest, + RfModulationResult, + RfModulationSnapshot, + RfModulationSource, + RfModulationState, + RfModulationStateSnapshot, + RfModulationValueUnit, + RfModulationWaveform, + RfObserved, + RfOutputPortProfile, + RfOutputProfile, + RfOutputRequest, + RfPortSnapshot, + RfProtectionStatus, + RfPulseState, + RfSourceDescriptorExtensions, + RfSourceSnapshot, + RfSourceTopology, + RfSweepState, + rf_source_modulated_output_operation_artifact, +) + + +def _am_profile(*, maximum: float = 100.0) -> RfModulationModeProfile: + return RfModulationModeProfile( + kind=RfModulationKind.AM, + value_unit=RfModulationValueUnit.PERCENT, + value_min=0.0, + value_max=maximum, + internal_frequency_min_hz=10.0, + internal_frequency_max_hz=100_000.0, + ) + + +def _modulation_profile() -> RfModulationProfile: + return RfModulationProfile( + state_readable=True, + configuration_readable=True, + mode_profiles=(_am_profile(),), + ) + + +def _topology() -> RfSourceTopology: + return RfSourceTopology( + ( + RfOutputPortProfile( + port_id="rf_out", + frequency_min_hz=9_000.0, + frequency_max_hz=3_000_000_000.0, + power_min_dbm=-110.0, + power_max_dbm=20.0, + power_reference_impedance_ohm=50.0, + ), + ) + ) + + +def _extensions( + *, + modulated_profile: RfModulatedOutputProfile | None = None, +) -> RfSourceDescriptorExtensions: + return RfSourceDescriptorExtensions( + contract_version=RF_SOURCE_CONTRACT_VERSION, + topology=_topology(), + features=( + RfFeatureCapability( + feature=RfFeature.MODULATED_OUTPUT, + directions=(RfFeatureDirection.ENABLE,), + port_ids=("rf_out",), + profile=modulated_profile + or RfModulatedOutputProfile( + maximum_power_dbm=-30.0, + mode_profiles=(_am_profile(maximum=50.0),), + ), + ), + RfFeatureCapability( + feature=RfFeature.MODULATION, + directions=(RfFeatureDirection.CONFIGURE, RfFeatureDirection.READ), + port_ids=("rf_out",), + profile=_modulation_profile(), + ), + RfFeatureCapability( + feature=RfFeature.OUTPUT, + directions=(RfFeatureDirection.DISABLE, RfFeatureDirection.ENABLE), + port_ids=("rf_out",), + profile=RfOutputProfile(output_readable=True), + ), + ), + ) + + +def _descriptor( + *capabilities: str, + extensions: RfSourceDescriptorExtensions | None = None, +) -> SimpleNamespace: + return SimpleNamespace( + driver_id="example.rf.modulated-output", + kind="rf_source", + models=("RF-MODULATED-OUTPUT",), + capabilities=capabilities, + wavebench_min_version="0.8.25", + wavebench_max_version="0.9.0", + rf_source_extensions=extensions or _extensions(), + ) + + +def _request() -> RfModulatedOutputRequest: + return RfModulatedOutputRequest( + modulation=RfModulationRequest( + port_id="rf_out", + kind=RfModulationKind.AM, + internal_frequency_hz=1_000.0, + depth_percent=50.0, + ) + ) + + +def _snapshot(*, output_enabled: bool) -> RfSourceSnapshot: + return RfSourceSnapshot( + ports=( + RfPortSnapshot( + port_id="rf_out", + frequency_hz=RfObserved.value_of(1_000_000.0), + power_dbm=RfObserved.value_of(-50.0), + output_enabled=RfObserved.value_of(output_enabled), + modulation=RfObserved.value_of(RfModulationState.ENABLED), + pulse=RfObserved.value_of(RfPulseState.DISABLED), + sweep=RfObserved.value_of(RfSweepState.DISABLED), + ), + ), + protection=RfObserved.value_of(RfProtectionStatus(active_codes=())), + ) + + +def _modulation_snapshot() -> RfModulationSnapshot: + return RfModulationSnapshot( + port_id="rf_out", + kind=RfModulationKind.AM, + source=RfModulationSource.INTERNAL, + waveform=RfModulationWaveform.SINE, + internal_frequency_hz=1_000.0, + depth_percent=50.0, + enabled_modes=(RfModulationKind.AM,), + global_enabled=True, + ) + + +class _Driver: + def close(self) -> None: + return None + + def idn(self) -> str: + return "EXAMPLE,RF-MODULATED-OUTPUT,0,1" + + def get_rf_snapshot(self) -> RfSourceSnapshot: + return _snapshot(output_enabled=False) + + def get_rf_modulation_state(self, port_id: str) -> RfModulationStateSnapshot: + return RfModulationStateSnapshot(port_id=port_id) + + def get_rf_modulation_snapshot( + self, + port_id: str, + kind: RfModulationKind, + ) -> RfModulationSnapshot: + assert port_id == "rf_out" + assert kind is RfModulationKind.AM + return _modulation_snapshot() + + def configure_rf_modulation(self, request: RfModulationRequest) -> None: + del request + + def set_rf_output(self, request: RfOutputRequest) -> None: + del request + + +def test_modulated_output_contract_is_explicit_and_typed() -> None: + request = _request() + result = RfModulatedOutputResult( + modulation=RfModulationResult( + port_id="rf_out", + kind=RfModulationKind.AM, + internal_frequency_hz=1_000.0, + depth_percent=50.0, + ), + write_completed=True, + ) + + assert request.port_id == "rf_out" + assert request.kind is RfModulationKind.AM + assert result.port_id == "rf_out" + assert result.kind is RfModulationKind.AM + with pytest.raises(ValueError, match="requires RfModulationRequest"): + RfModulatedOutputRequest(modulation=object()) # type: ignore[arg-type] + with pytest.raises(ValueError, match="must complete one RF-ON write"): + RfModulatedOutputResult(modulation=result.modulation, write_completed=False) + + +def test_modulated_output_profile_requires_nonempty_sorted_modes() -> None: + with pytest.raises(ValueError, match="invalid type"): + RfModulatedOutputProfile(maximum_power_dbm=-30.0, mode_profiles=()) + with pytest.raises(ValueError, match="finite"): + RfModulatedOutputProfile( + maximum_power_dbm=float("nan"), + mode_profiles=(_am_profile(),), + ) + + +def test_modulated_output_descriptor_requires_base_capabilities_and_subset_profile() -> None: + capabilities = ( + "rf_source.idn", + "rf_source.snapshot", + "rf_source.modulation_configure", + "rf_source.output", + "rf_source.modulated_output_enable", + ) + descriptor = _descriptor(*capabilities) + + assert CAPABILITY_METHODS["rf_source.modulated_output_enable"] == ( + "get_rf_modulation_snapshot", + "set_rf_output", + ) + validate_rf_source_descriptor(descriptor, _Driver()) + + missing_output = _descriptor( + "rf_source.idn", + "rf_source.snapshot", + "rf_source.modulation_configure", + "rf_source.modulated_output_enable", + ) + with pytest.raises(ConfigError, match="requires the rf_source.output capability"): + validate_rf_source_descriptor(missing_output) + + too_wide = _descriptor( + *capabilities, + extensions=_extensions( + modulated_profile=RfModulatedOutputProfile( + maximum_power_dbm=-30.0, + mode_profiles=(_am_profile(maximum=101.0),), + ) + ), + ) + with pytest.raises(ConfigError, match="must be a subset of modulation"): + validate_rf_source_descriptor(too_wide) + + excessive_power = _descriptor( + *capabilities, + extensions=_extensions( + modulated_profile=RfModulatedOutputProfile( + maximum_power_dbm=21.0, + mode_profiles=(_am_profile(maximum=50.0),), + ) + ), + ) + with pytest.raises(ConfigError, match="maximum_power_dbm"): + validate_rf_source_descriptor(excessive_power) + + +def test_modulated_output_artifact_keeps_exact_active_profile_before_and_after_on() -> None: + request = _request() + result = RfModulatedOutputResult( + modulation=RfModulationResult( + port_id="rf_out", + kind=RfModulationKind.AM, + internal_frequency_hz=1_000.0, + depth_percent=50.0, + ), + write_completed=True, + ) + artifact = rf_source_modulated_output_operation_artifact( + request, + result, + preflight_snapshot=_snapshot(output_enabled=False), + preflight_modulation_snapshot=_modulation_snapshot(), + postcondition_snapshot=_snapshot(output_enabled=True), + postcondition_modulation_snapshot=_modulation_snapshot(), + ) + + assert artifact["operation"] == "rf_source.modulated_output_enable" + assert artifact["request"]["modulation"]["kind"] == "am" + assert artifact["postcondition_snapshot"]["ports"][0]["output_enabled"]["value"] is True + assert artifact["preflight_modulation_snapshot"]["enabled_modes"] == ["am"] diff --git a/tests/test_rf_source_modulated_output_run.py b/tests/test_rf_source_modulated_output_run.py new file mode 100644 index 0000000..d12b658 --- /dev/null +++ b/tests/test_rf_source_modulated_output_run.py @@ -0,0 +1,241 @@ +from __future__ import annotations + +from contextlib import contextmanager +import json +from pathlib import Path +from tempfile import TemporaryDirectory +from types import SimpleNamespace +from unittest.mock import Mock, patch + +import pytest + +from wavebench.config import ( + AutoscaleConfig, + ConnectionConfig, + OutputConfig, + RfSourceConfig, + ScopeConfig, + WaveBenchConfig, + WaveformConfig, +) +from wavebench.errors import ConfigError +from wavebench.instruments.rf_source_extensions import ( + RfModulatedOutputRequest, + RfModulatedOutputResult, + RfModulationKind, + RfModulationRequest, + RfModulationResult, + RfModulationSnapshot, + RfModulationSource, + RfModulationState, + RfModulationWaveform, + RfObserved, + RfPortSnapshot, + RfProtectionStatus, + RfPulseState, + RfSourceSnapshot, + RfSweepState, + rf_source_modulated_output_operation_artifact, +) +from wavebench.logging import CommandLogger +from wavebench.services.execution_intent import build_execution_intent +from wavebench.services.run_plan import STEP_SCHEMAS, load_run_plan +from wavebench.services.run_service import RunInstrumentServices, RunService + + +def _config(directory: str, *, access: str = "read_write") -> WaveBenchConfig: + return WaveBenchConfig( + connection=ConnectionConfig("lan", "TCPIP::scope::INSTR", 1_000, 1_000), + scope=ScopeConfig("rtm2032", None, 1, False, True), + autoscale=AutoscaleConfig(True, True), + waveform=WaveformConfig("real", "lsbf", "dmax"), + output=OutputConfig( + Path(directory) / "data" / "raw", + "timestamp_label", + True, + True, + True, + True, + False, + ), + source_path=Path(directory) / "wavebench.toml", + rf_source=RfSourceConfig( + driver="example.rf.modulated-output", + resource="TCPIP::rf::INSTR", + access=access, # type: ignore[arg-type] + ), + ) + + +def _plan(directory: str): + path = Path(directory) / "plan.toml" + path.write_text( + "[[steps]]\n" + 'kind = "rf_source.modulated_output_enable"\n' + 'port_id = "rf_out"\n' + 'modulation_kind = "am"\n' + "depth_percent = 50\n" + "internal_frequency_hz = 1000\n", + encoding="utf-8", + ) + return load_run_plan(path) + + +def _descriptor(*capabilities: str) -> SimpleNamespace: + return SimpleNamespace( + driver_id="example.rf.modulated-output", + kind="rf_source", + capabilities=capabilities, + ) + + +def _snapshot(*, output_enabled: bool) -> RfSourceSnapshot: + return RfSourceSnapshot( + ports=( + RfPortSnapshot( + port_id="rf_out", + frequency_hz=RfObserved.value_of(1_000_000.0), + power_dbm=RfObserved.value_of(-50.0), + output_enabled=RfObserved.value_of(output_enabled), + modulation=RfObserved.value_of(RfModulationState.ENABLED), + pulse=RfObserved.value_of(RfPulseState.DISABLED), + sweep=RfObserved.value_of(RfSweepState.DISABLED), + ), + ), + protection=RfObserved.value_of(RfProtectionStatus(active_codes=())), + ) + + +def _modulation_snapshot() -> RfModulationSnapshot: + return RfModulationSnapshot( + port_id="rf_out", + kind=RfModulationKind.AM, + source=RfModulationSource.INTERNAL, + waveform=RfModulationWaveform.SINE, + internal_frequency_hz=1_000.0, + depth_percent=50.0, + enabled_modes=(RfModulationKind.AM,), + global_enabled=True, + ) + + +def _request() -> RfModulatedOutputRequest: + return RfModulatedOutputRequest( + modulation=RfModulationRequest( + port_id="rf_out", + kind=RfModulationKind.AM, + internal_frequency_hz=1_000.0, + depth_percent=50.0, + ) + ) + + +def _result() -> RfModulatedOutputResult: + return RfModulatedOutputResult( + modulation=RfModulationResult( + port_id="rf_out", + kind=RfModulationKind.AM, + internal_frequency_hz=1_000.0, + depth_percent=50.0, + ), + write_completed=True, + ) + + +def test_modulated_output_run_schema_and_intent_are_explicit_writes() -> None: + with TemporaryDirectory() as directory: + plan = _plan(directory) + intent = build_execution_intent(plan, _config(directory)) + + assert "rf_source.modulated_output_enable" in STEP_SCHEMAS + assert intent.operations[0]["operation"] == "rf_source.modulated_output_enable" + assert intent.operations[0]["effect"] == "write" + assert intent.operations[0]["parameters"] == { + "depth_percent": 50.0, + "internal_frequency_hz": 1_000.0, + "modulation_kind": "am", + "port_id": "rf_out", + } + + +def test_modulated_output_run_step_requires_capability_before_opening_a_session() -> None: + with TemporaryDirectory() as directory: + service = RunService(config=_config(directory), logger=CommandLogger()) + with patch( + "wavebench.services.run_service.resolve_instrument_descriptor", + return_value=_descriptor( + "rf_source.idn", + "rf_source.snapshot", + "rf_source.output", + "rf_source.modulation_configure", + ), + ), patch.object(service, "_run_instrument_services") as open_services: + with pytest.raises(ConfigError, match="rf_source.modulated_output_enable"): + service.run(_plan(directory)) + + open_services.assert_not_called() + + +def test_modulated_output_run_step_rejects_read_only_access_before_opening_a_session() -> None: + with TemporaryDirectory() as directory: + service = RunService(config=_config(directory, access="read_only"), logger=CommandLogger()) + with patch( + "wavebench.services.run_service.resolve_instrument_descriptor", + return_value=_descriptor( + "rf_source.idn", + "rf_source.snapshot", + "rf_source.output", + "rf_source.modulation_configure", + "rf_source.modulated_output_enable", + ), + ), patch.object(service, "_run_instrument_services") as open_services: + with pytest.raises(ConfigError, match="access policy 'read_only'"): + service.run(_plan(directory)) + + open_services.assert_not_called() + + +def test_modulated_output_run_step_dispatches_exact_profile_and_artifact() -> None: + with TemporaryDirectory() as directory: + plan = _plan(directory) + config = _config(directory) + request = _request() + result = _result() + artifact = rf_source_modulated_output_operation_artifact( + request, + result, + preflight_snapshot=_snapshot(output_enabled=False), + preflight_modulation_snapshot=_modulation_snapshot(), + postcondition_snapshot=_snapshot(output_enabled=True), + postcondition_modulation_snapshot=_modulation_snapshot(), + ) + rf_service = SimpleNamespace( + enable_modulated_output_with_artifact=Mock(return_value=(result, artifact)), + audit_snapshot=lambda: None, + ) + + class OfflineRfRunService(RunService): + @contextmanager + def _run_instrument_services(self, run_plan): + del run_plan + yield RunInstrumentServices(rf_source=rf_service) + + def _run_safety_guards(self, run_plan, *, services=None): + del run_plan, services + + with patch( + "wavebench.services.run_service.resolve_instrument_descriptor", + return_value=_descriptor( + "rf_source.idn", + "rf_source.snapshot", + "rf_source.output", + "rf_source.modulation_configure", + "rf_source.modulated_output_enable", + ), + ): + run_result = OfflineRfRunService(config=config, logger=CommandLogger()).run(plan) + + rf_service.enable_modulated_output_with_artifact.assert_called_once_with(request) + assert run_result.steps[0].artifact == {"rf_source_operation": artifact} + run_data = json.loads(run_result.run_json_path.read_text(encoding="utf-8")) + assert run_data["rf_source_operations"] == [artifact] diff --git a/tests/test_rf_source_modulated_output_service.py b/tests/test_rf_source_modulated_output_service.py new file mode 100644 index 0000000..266bffe --- /dev/null +++ b/tests/test_rf_source_modulated_output_service.py @@ -0,0 +1,393 @@ +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from wavebench.config import ( + AutoscaleConfig, + ConnectionConfig, + OutputConfig, + RfPortSafetyConfig, + RfSourceConfig, + ScopeConfig, + WaveBenchConfig, + WaveformConfig, +) +from wavebench.errors import AccessDeniedError, ConfigError +from wavebench.instruments.rf_source_extensions import ( + RF_SOURCE_CONTRACT_VERSION, + RfFeature, + RfFeatureCapability, + RfFeatureDirection, + RfModulatedOutputProfile, + RfModulatedOutputRequest, + RfModulatedOutputResult, + RfModulationKind, + RfModulationModeProfile, + RfModulationProfile, + RfModulationRequest, + RfModulationResult, + RfModulationSnapshot, + RfModulationSource, + RfModulationState, + RfModulationValueUnit, + RfModulationWaveform, + RfObserved, + RfOutputPortProfile, + RfOutputProfile, + RfOutputRequest, + RfPortSnapshot, + RfProtectionConditionPolicy, + RfProtectionStatus, + RfPulseState, + RfSourceDescriptorExtensions, + RfSourceSnapshot, + RfSourceTopology, + RfSweepState, +) +from wavebench.logging import CommandLogger +from wavebench.services.rf_source_service import RfSourceService +from wavebench.transport.session import InstrumentSessionState, SessionHealth + + +def _config(*, access: str = "read_write") -> WaveBenchConfig: + return WaveBenchConfig( + connection=ConnectionConfig("lan", "TCPIP::scope::INSTR", 1_000, 1_000), + 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"), + rf_source=RfSourceConfig( + driver="example.rf.modulated-output", + resource="TCPIP::rf::INSTR", + access=access, # type: ignore[arg-type] + safety_ports=( + RfPortSafetyConfig( + port_id="rf_out", + minimum_frequency_hz=9_000.0, + maximum_frequency_hz=3_000_000_000.0, + maximum_power_dbm=-30.0, + actual_termination_ohm=50.0, + ), + ), + ), + ) + + +def _mode_profile(*, value_max: float = 100.0) -> RfModulationModeProfile: + return RfModulationModeProfile( + kind=RfModulationKind.AM, + value_unit=RfModulationValueUnit.PERCENT, + value_min=0.0, + value_max=value_max, + internal_frequency_min_hz=10.0, + internal_frequency_max_hz=100_000.0, + ) + + +def _descriptor(*capabilities: str) -> SimpleNamespace: + return SimpleNamespace( + driver_id="example.rf.modulated-output", + capabilities=capabilities, + rf_source_extensions=RfSourceDescriptorExtensions( + contract_version=RF_SOURCE_CONTRACT_VERSION, + topology=RfSourceTopology( + ( + RfOutputPortProfile( + port_id="rf_out", + frequency_min_hz=9_000.0, + frequency_max_hz=3_000_000_000.0, + power_min_dbm=-110.0, + power_max_dbm=20.0, + power_reference_impedance_ohm=50.0, + ), + ) + ), + features=( + RfFeatureCapability( + feature=RfFeature.MODULATED_OUTPUT, + directions=(RfFeatureDirection.ENABLE,), + port_ids=("rf_out",), + profile=RfModulatedOutputProfile( + maximum_power_dbm=-30.0, + mode_profiles=(_mode_profile(value_max=50.0),), + ), + ), + RfFeatureCapability( + feature=RfFeature.MODULATION, + directions=(RfFeatureDirection.CONFIGURE, RfFeatureDirection.READ), + port_ids=("rf_out",), + profile=RfModulationProfile( + state_readable=True, + configuration_readable=True, + mode_profiles=(_mode_profile(),), + ), + ), + RfFeatureCapability( + feature=RfFeature.OUTPUT, + directions=(RfFeatureDirection.DISABLE, RfFeatureDirection.ENABLE), + port_ids=("rf_out",), + profile=RfOutputProfile(output_readable=True), + ), + ), + protection_conditions=( + RfProtectionConditionPolicy("overtemperature", True), + RfProtectionConditionPolicy("status_notice", False), + ), + ), + ) + + +def _request(*, depth_percent: float = 50.0) -> RfModulatedOutputRequest: + return RfModulatedOutputRequest( + modulation=RfModulationRequest( + port_id="rf_out", + kind=RfModulationKind.AM, + internal_frequency_hz=1_000.0, + depth_percent=depth_percent, + ) + ) + + +def _rf_snapshot( + *, + output_enabled: bool = False, + modulation: RfModulationState = RfModulationState.ENABLED, + pulse: RfPulseState = RfPulseState.DISABLED, + sweep: RfSweepState = RfSweepState.DISABLED, + power_dbm: float = -50.0, + protection_codes: tuple[str, ...] = (), +) -> RfSourceSnapshot: + return RfSourceSnapshot( + ports=( + RfPortSnapshot( + port_id="rf_out", + frequency_hz=RfObserved.value_of(1_000_000.0), + power_dbm=RfObserved.value_of(power_dbm), + output_enabled=RfObserved.value_of(output_enabled), + modulation=RfObserved.value_of(modulation), + pulse=RfObserved.value_of(pulse), + sweep=RfObserved.value_of(sweep), + ), + ), + protection=RfObserved.value_of(RfProtectionStatus(active_codes=protection_codes)), + ) + + +def _modulation_snapshot( + *, + enabled_modes: tuple[RfModulationKind, ...] = (RfModulationKind.AM,), + global_enabled: bool = True, + depth_percent: float = 50.0, + fault_codes: tuple[str, ...] = (), +) -> RfModulationSnapshot: + return RfModulationSnapshot( + port_id="rf_out", + kind=RfModulationKind.AM, + source=RfModulationSource.INTERNAL, + waveform=RfModulationWaveform.SINE, + internal_frequency_hz=1_000.0, + depth_percent=depth_percent, + enabled_modes=enabled_modes, + global_enabled=global_enabled, + fault_codes=fault_codes, + ) + + +class _Driver: + def __init__( + self, + rf_snapshots: list[RfSourceSnapshot], + modulation_snapshots: list[RfModulationSnapshot], + *, + raise_after_enable: bool = False, + ) -> None: + self.rf_snapshots = list(rf_snapshots) + self.modulation_snapshots = list(modulation_snapshots) + self.raise_after_enable = raise_after_enable + self.calls: list[str] = [] + self.output_requests: list[RfOutputRequest] = [] + + def close(self) -> None: + self.calls.append("close") + + def get_rf_snapshot(self) -> RfSourceSnapshot: + self.calls.append("snapshot") + if not self.rf_snapshots: + raise AssertionError("unexpected RF snapshot") + return self.rf_snapshots.pop(0) + + def get_rf_modulation_snapshot( + self, + port_id: str, + kind: RfModulationKind, + ) -> RfModulationSnapshot: + self.calls.append("modulation_snapshot") + assert port_id == "rf_out" + assert kind is RfModulationKind.AM + if not self.modulation_snapshots: + raise AssertionError("unexpected modulation snapshot") + return self.modulation_snapshots.pop(0) + + def set_rf_output(self, request: RfOutputRequest) -> None: + self.calls.append("set_rf_output") + self.output_requests.append(request) + if request.enabled and self.raise_after_enable: + raise ConfigError("RF ON failed after transmission") + + +def _service( + rf_snapshots: list[RfSourceSnapshot], + modulation_snapshots: list[RfModulationSnapshot], + *, + access: str = "read_write", + descriptor: SimpleNamespace | None = None, + raise_after_enable: bool = False, +) -> tuple[RfSourceService, _Driver]: + driver = _Driver( + rf_snapshots, + modulation_snapshots, + raise_after_enable=raise_after_enable, + ) + service = RfSourceService( + config=_config(access=access), + logger=CommandLogger(), + session=driver, + descriptor=descriptor + or _descriptor( + "rf_source.idn", + "rf_source.snapshot", + "rf_source.modulation_configure", + "rf_source.output", + "rf_source.modulated_output_enable", + ), + session_state=InstrumentSessionState(), + ) + return service, driver + + +def test_modulated_output_enable_uses_one_on_write_and_exact_pre_post_readback() -> None: + request = _request() + service, driver = _service( + [_rf_snapshot(output_enabled=False), _rf_snapshot(output_enabled=True)], + [_modulation_snapshot(), _modulation_snapshot()], + ) + + result, artifact = service.enable_modulated_output_with_artifact(request) + + assert result == RfModulatedOutputResult( + modulation=RfModulationResult( + port_id="rf_out", + kind=RfModulationKind.AM, + internal_frequency_hz=1_000.0, + depth_percent=50.0, + ), + write_completed=True, + ) + assert driver.output_requests == [RfOutputRequest(port_id="rf_out", enabled=True)] + assert driver.calls == [ + "snapshot", + "modulation_snapshot", + "set_rf_output", + "snapshot", + "modulation_snapshot", + ] + assert artifact["operation"] == "rf_source.modulated_output_enable" + assert artifact["postcondition_snapshot"]["ports"][0]["output_enabled"]["value"] is True + assert service.session_state is not None + assert service.session_state.health is SessionHealth.HEALTHY + + +@pytest.mark.parametrize( + ("rf_snapshot", "modulation_snapshot", "message"), + ( + (_rf_snapshot(output_enabled=True), _modulation_snapshot(), "target RF output OFF"), + (_rf_snapshot(), _modulation_snapshot(enabled_modes=()), "only the requested modulation mode"), + (_rf_snapshot(), _modulation_snapshot(global_enabled=False), "global modulation enabled"), + (_rf_snapshot(), _modulation_snapshot(fault_codes=("am_fault",)), "modulation fault"), + (_rf_snapshot(pulse=RfPulseState.ENABLED), _modulation_snapshot(), "Pulse disabled"), + (_rf_snapshot(sweep=RfSweepState.ENABLED), _modulation_snapshot(), "Sweep disabled"), + (_rf_snapshot(power_dbm=-20.0), _modulation_snapshot(), "modulated-output"), + ), +) +def test_modulated_output_enable_rejects_unsafe_preflight_without_write( + rf_snapshot: RfSourceSnapshot, + modulation_snapshot: RfModulationSnapshot, + message: str, +) -> None: + service, driver = _service([rf_snapshot], [modulation_snapshot]) + + with pytest.raises(ConfigError, match=message): + service.enable_modulated_output(_request()) + + assert driver.output_requests == [] + assert driver.calls == ["snapshot", "modulation_snapshot"] + assert service.session_state is not None + assert service.session_state.health is SessionHealth.HEALTHY + + +def test_modulated_output_enable_checks_capability_access_and_profile_before_driver_io() -> None: + missing, missing_driver = _service( + [], + [], + descriptor=_descriptor("rf_source.idn", "rf_source.snapshot", "rf_source.output"), + ) + with pytest.raises(ConfigError, match="rf_source.modulation_configure"): + missing.enable_modulated_output(_request()) + assert missing_driver.calls == [] + + read_only, read_only_driver = _service([], [], access="read_only") + with pytest.raises(AccessDeniedError, match="rf_source.modulated_output_enable"): + read_only.enable_modulated_output(_request()) + assert read_only_driver.calls == [] + + out_of_profile, out_of_profile_driver = _service([], []) + with pytest.raises(ConfigError, match="outside the descriptor range"): + out_of_profile.enable_modulated_output(_request(depth_percent=51.0)) + assert out_of_profile_driver.calls == [] + + +def test_modulated_output_enable_mismatch_or_write_failure_runs_one_off_recovery() -> None: + request = _request() + mismatch_service, mismatch_driver = _service( + [ + _rf_snapshot(output_enabled=False), + _rf_snapshot(output_enabled=False), + _rf_snapshot(output_enabled=False), + ], + [_modulation_snapshot(), _modulation_snapshot()], + ) + + with pytest.raises(ConfigError, match="target RF output ON") as mismatch: + mismatch_service.enable_modulated_output(request) + + assert mismatch_driver.output_requests == [ + RfOutputRequest(port_id="rf_out", enabled=True), + RfOutputRequest(port_id="rf_out", enabled=False), + ] + assert mismatch.value.rf_source_recovery == { + "status": "off_verified", + "session_health": "uncertain", + } + assert mismatch_service.session_state is not None + assert mismatch_service.session_state.health is SessionHealth.UNCERTAIN + + failed_service, failed_driver = _service( + [_rf_snapshot(output_enabled=False), _rf_snapshot(output_enabled=False)], + [_modulation_snapshot()], + raise_after_enable=True, + ) + + with pytest.raises(ConfigError, match="failed after transmission") as failed: + failed_service.enable_modulated_output(request) + + assert failed_driver.output_requests == [ + RfOutputRequest(port_id="rf_out", enabled=True), + RfOutputRequest(port_id="rf_out", enabled=False), + ] + assert failed.value.rf_source_recovery["status"] == "off_verified" + assert failed_service.session_state is not None + assert failed_service.session_state.health is SessionHealth.UNCERTAIN diff --git a/tests/test_rf_source_modulation_extensions.py b/tests/test_rf_source_modulation_extensions.py new file mode 100644 index 0000000..056b46d --- /dev/null +++ b/tests/test_rf_source_modulation_extensions.py @@ -0,0 +1,448 @@ +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from wavebench.instruments.capabilities import CAPABILITY_METHODS +from wavebench.instruments.rf_source_capabilities import validate_rf_source_descriptor +from wavebench.instruments.rf_source_extensions import ( + RF_SOURCE_CONTRACT_VERSION, + RF_SOURCE_MODULATION_STATE_SCHEMA, + RF_SOURCE_MODULATION_SNAPSHOT_SCHEMA, + RfCwRequest, + RfFeature, + RfFeatureCapability, + RfFeatureDirection, + RfModulationDisableRequest, + RfModulationDisableResult, + RfModulationKind, + RfModulationModeProfile, + RfModulationProfile, + RfModulationRequest, + RfModulationResult, + RfModulationSnapshot, + RfModulationSource, + RfModulationValueUnit, + RfModulationWaveform, + RfObserved, + RfOutputPortProfile, + RfPortSnapshot, + RfProtectionStatus, + RfPulseState, + RfSourceDescriptorExtensions, + RfSourceSnapshot, + RfSourceTopology, + RfSweepState, + RfModulationState, + RfModulationStateSnapshot, + rf_modulation_snapshot_document, + rf_modulation_state_snapshot_document, + rf_source_modulation_disable_operation_artifact, + rf_source_modulation_operation_artifact, +) + + +def _topology() -> RfSourceTopology: + return RfSourceTopology( + ( + RfOutputPortProfile( + port_id="rf_out", + frequency_min_hz=9_000.0, + frequency_max_hz=3_000_000_000.0, + power_min_dbm=-110.0, + power_max_dbm=20.0, + power_reference_impedance_ohm=50.0, + ), + ) + ) + + +def _profile() -> RfModulationProfile: + return RfModulationProfile( + state_readable=True, + configuration_readable=True, + mode_profiles=( + RfModulationModeProfile( + kind=RfModulationKind.AM, + value_unit=RfModulationValueUnit.PERCENT, + value_min=0.0, + value_max=100.0, + internal_frequency_min_hz=10.0, + internal_frequency_max_hz=100_000.0, + ), + RfModulationModeProfile( + kind=RfModulationKind.FM, + value_unit=RfModulationValueUnit.HZ, + value_min=0.1, + value_max=1_000_000.0, + internal_frequency_min_hz=10.0, + internal_frequency_max_hz=100_000.0, + ), + RfModulationModeProfile( + kind=RfModulationKind.PM, + value_unit=RfModulationValueUnit.RAD, + value_min=0.0, + value_max=5.0, + internal_frequency_min_hz=10.0, + internal_frequency_max_hz=100_000.0, + ), + ), + ) + + +def _rf_snapshot() -> RfSourceSnapshot: + return RfSourceSnapshot( + ports=( + RfPortSnapshot( + port_id="rf_out", + frequency_hz=RfObserved.value_of(1_000_000.0), + power_dbm=RfObserved.value_of(-50.0), + output_enabled=RfObserved.value_of(False), + modulation=RfObserved.value_of(RfModulationState.DISABLED), + pulse=RfObserved.value_of(RfPulseState.DISABLED), + sweep=RfObserved.value_of(RfSweepState.DISABLED), + ), + ), + protection=RfObserved.value_of(RfProtectionStatus(active_codes=())), + ) + + +def _modulation_snapshot( + *, + enabled: bool, +) -> RfModulationSnapshot: + return RfModulationSnapshot( + port_id="rf_out", + kind=RfModulationKind.AM, + source=RfModulationSource.INTERNAL, + waveform=RfModulationWaveform.SINE, + internal_frequency_hz=1_000.0, + depth_percent=50.0, + enabled_modes=(RfModulationKind.AM,) if enabled else (), + global_enabled=enabled, + ) + + +def _descriptor() -> SimpleNamespace: + return SimpleNamespace( + driver_id="example.rf.modulation", + kind="rf_source", + models=("RF-MOD",), + capabilities=( + "rf_source.idn", + "rf_source.snapshot", + "rf_source.modulation_configure", + ), + wavebench_min_version="0.8.25", + wavebench_max_version="0.9.0", + rf_source_extensions=RfSourceDescriptorExtensions( + contract_version=RF_SOURCE_CONTRACT_VERSION, + topology=_topology(), + features=( + RfFeatureCapability( + feature=RfFeature.MODULATION, + directions=(RfFeatureDirection.CONFIGURE, RfFeatureDirection.READ), + port_ids=("rf_out",), + profile=_profile(), + ), + ), + ), + ) + + +def _disable_descriptor() -> SimpleNamespace: + return SimpleNamespace( + driver_id="example.rf.modulation", + kind="rf_source", + models=("RF-MOD",), + capabilities=( + "rf_source.idn", + "rf_source.snapshot", + "rf_source.modulation_disable", + ), + wavebench_min_version="0.8.25", + wavebench_max_version="0.9.0", + rf_source_extensions=RfSourceDescriptorExtensions( + contract_version=RF_SOURCE_CONTRACT_VERSION, + topology=_topology(), + features=( + RfFeatureCapability( + feature=RfFeature.MODULATION, + directions=(RfFeatureDirection.DISABLE, RfFeatureDirection.READ), + port_ids=("rf_out",), + profile=RfModulationProfile(state_readable=True), + ), + ), + ), + ) + + +class _Driver: + def close(self) -> None: + return None + + def idn(self) -> str: + return "EXAMPLE,RF-MOD,0,1" + + def get_rf_snapshot(self) -> RfSourceSnapshot: + return _rf_snapshot() + + def configure_cw(self, request: RfCwRequest) -> None: + del request + + def get_rf_modulation_snapshot( + self, + port_id: str, + kind: RfModulationKind, + ) -> RfModulationSnapshot: + assert port_id == "rf_out" + assert kind is RfModulationKind.AM + return _modulation_snapshot(enabled=False) + + def get_rf_modulation_state(self, port_id: str): + assert port_id == "rf_out" + return RfModulationStateSnapshot(port_id=port_id) + + def configure_rf_modulation(self, request: RfModulationRequest) -> None: + del request + + def disable_rf_modulation(self, request: RfModulationDisableRequest) -> None: + del request + + +def test_modulation_contract_binds_request_value_to_kind() -> None: + am = RfModulationRequest( + port_id="rf_out", + kind=RfModulationKind.AM, + internal_frequency_hz=1_000.0, + depth_percent=50.0, + ) + fm = RfModulationRequest( + port_id="rf_out", + kind=RfModulationKind.FM, + internal_frequency_hz=1_000.0, + frequency_deviation_hz=10_000.0, + ) + pm = RfModulationRequest( + port_id="rf_out", + kind=RfModulationKind.PM, + internal_frequency_hz=1_000.0, + phase_deviation_rad=2.0, + ) + + assert am.value == 50.0 + assert am.value_unit is RfModulationValueUnit.PERCENT + assert fm.value == 10_000.0 + assert fm.value_unit is RfModulationValueUnit.HZ + assert pm.value == 2.0 + assert pm.value_unit is RfModulationValueUnit.RAD + + with pytest.raises(ValueError, match="exactly the parameter"): + RfModulationRequest( + port_id="rf_out", + kind=RfModulationKind.AM, + internal_frequency_hz=1_000.0, + frequency_deviation_hz=1.0, + ) + with pytest.raises(ValueError, match="finite"): + RfModulationRequest( + port_id="rf_out", + kind=RfModulationKind.PM, + internal_frequency_hz=float("nan"), + phase_deviation_rad=1.0, + ) + + +def test_modulation_profile_and_snapshot_are_strict() -> None: + with pytest.raises(ValueError, match="sorted and unique"): + RfModulationProfile( + state_readable=True, + configuration_readable=True, + mode_profiles=( + RfModulationModeProfile( + kind=RfModulationKind.PM, + value_unit=RfModulationValueUnit.RAD, + value_min=0.0, + value_max=5.0, + internal_frequency_min_hz=10.0, + internal_frequency_max_hz=100_000.0, + ), + RfModulationModeProfile( + kind=RfModulationKind.AM, + value_unit=RfModulationValueUnit.PERCENT, + value_min=0.0, + value_max=100.0, + internal_frequency_min_hz=10.0, + internal_frequency_max_hz=100_000.0, + ), + ), + ) + with pytest.raises(ValueError, match="configuration readback"): + RfModulationProfile(state_readable=False, configuration_readable=True) + with pytest.raises(ValueError, match="internal source"): + RfModulationModeProfile( + kind=RfModulationKind.AM, + value_unit=RfModulationValueUnit.PERCENT, + value_min=0.0, + value_max=100.0, + internal_frequency_min_hz=10.0, + internal_frequency_max_hz=100_000.0, + source=RfModulationSource.EXTERNAL, + ) + with pytest.raises(ValueError, match="sine waveform"): + RfModulationModeProfile( + kind=RfModulationKind.AM, + value_unit=RfModulationValueUnit.PERCENT, + value_min=0.0, + value_max=100.0, + internal_frequency_min_hz=10.0, + internal_frequency_max_hz=100_000.0, + waveform=RfModulationWaveform.SQUARE, + ) + with pytest.raises(ValueError, match="sorted by value"): + RfModulationSnapshot( + port_id="rf_out", + kind=RfModulationKind.AM, + source=RfModulationSource.INTERNAL, + waveform=RfModulationWaveform.SINE, + internal_frequency_hz=1_000.0, + depth_percent=50.0, + enabled_modes=(RfModulationKind.PM, RfModulationKind.AM), + global_enabled=True, + ) + with pytest.raises(ValueError, match="require a selected FM/PM kind"): + RfModulationSnapshot( + port_id="rf_out", + kind=RfModulationKind.FM, + source=RfModulationSource.INTERNAL, + waveform=RfModulationWaveform.SINE, + internal_frequency_hz=1_000.0, + frequency_deviation_hz=10_000.0, + ) + with pytest.raises(ValueError, match="cannot carry an FM/PM selection"): + RfModulationSnapshot( + port_id="rf_out", + kind=RfModulationKind.AM, + source=RfModulationSource.INTERNAL, + waveform=RfModulationWaveform.SINE, + internal_frequency_hz=1_000.0, + selected_fm_pm_kind=RfModulationKind.FM, + depth_percent=50.0, + ) + + +def test_modulation_descriptor_requires_readable_bounded_feature_and_methods() -> None: + descriptor = _descriptor() + + assert CAPABILITY_METHODS["rf_source.modulation_configure"] == ( + "get_rf_modulation_state", + "get_rf_modulation_snapshot", + "configure_rf_modulation", + ) + validate_rf_source_descriptor(descriptor, _Driver()) + + invalid = _descriptor() + invalid.rf_source_extensions = RfSourceDescriptorExtensions( + contract_version=RF_SOURCE_CONTRACT_VERSION, + topology=_topology(), + features=( + RfFeatureCapability( + feature=RfFeature.MODULATION, + directions=(RfFeatureDirection.CONFIGURE,), + port_ids=("rf_out",), + profile=_profile(), + ), + ), + ) + with pytest.raises(Exception, match="configure and read"): + validate_rf_source_descriptor(invalid) + + +def test_modulation_disable_descriptor_requires_state_read_and_disable_methods() -> None: + descriptor = _disable_descriptor() + + assert CAPABILITY_METHODS["rf_source.modulation_disable"] == ( + "get_rf_modulation_state", + "disable_rf_modulation", + ) + validate_rf_source_descriptor(descriptor, _Driver()) + + invalid = _disable_descriptor() + invalid.rf_source_extensions = RfSourceDescriptorExtensions( + contract_version=RF_SOURCE_CONTRACT_VERSION, + topology=_topology(), + features=( + RfFeatureCapability( + feature=RfFeature.MODULATION, + directions=(RfFeatureDirection.READ,), + port_ids=("rf_out",), + profile=RfModulationProfile(state_readable=True), + ), + ), + ) + with pytest.raises(Exception, match="disable and read"): + validate_rf_source_descriptor(invalid) + + +def test_modulation_artifact_keeps_typed_pre_and_postcondition_evidence() -> None: + request = RfModulationRequest( + port_id="rf_out", + kind=RfModulationKind.AM, + internal_frequency_hz=1_000.0, + depth_percent=50.0, + ) + result = RfModulationResult( + port_id="rf_out", + kind=RfModulationKind.AM, + internal_frequency_hz=1_000.0, + depth_percent=50.0, + ) + preflight_state = RfModulationStateSnapshot(port_id="rf_out") + postcondition = _modulation_snapshot(enabled=True) + + document = rf_modulation_snapshot_document(postcondition) + state_document = rf_modulation_state_snapshot_document(preflight_state) + artifact = rf_source_modulation_operation_artifact( + request, + result, + preflight_snapshot=_rf_snapshot(), + preflight_modulation_state=preflight_state, + postcondition_snapshot=_rf_snapshot(), + postcondition_modulation_snapshot=postcondition, + ) + + assert document["schema"] == RF_SOURCE_MODULATION_SNAPSHOT_SCHEMA + assert state_document["schema"] == RF_SOURCE_MODULATION_STATE_SCHEMA + assert artifact["operation"] == "rf_source.modulation_configure" + assert artifact["preflight_modulation_state"]["enabled_modes"] == [] + assert artifact["postcondition_modulation_snapshot"]["global_enabled"] is True + + +def test_modulation_disable_artifact_keeps_state_only_pre_and_postcondition_evidence() -> None: + request = RfModulationDisableRequest(port_id="rf_out", kind=RfModulationKind.AM) + result = RfModulationDisableResult( + port_id="rf_out", + kind=RfModulationKind.AM, + write_completed=True, + ) + preflight_state = RfModulationStateSnapshot( + port_id="rf_out", + enabled_modes=(RfModulationKind.AM,), + global_enabled=True, + ) + postcondition_state = RfModulationStateSnapshot(port_id="rf_out") + + artifact = rf_source_modulation_disable_operation_artifact( + request, + result, + preflight_snapshot=_rf_snapshot(), + preflight_modulation_state=preflight_state, + postcondition_snapshot=_rf_snapshot(), + postcondition_modulation_state=postcondition_state, + ) + + assert artifact["operation"] == "rf_source.modulation_disable" + assert artifact["result"]["write_completed"] is True + assert artifact["preflight_modulation_state"]["global_enabled"] is True + assert artifact["postcondition_modulation_state"]["enabled_modes"] == [] diff --git a/tests/test_rf_source_modulation_service.py b/tests/test_rf_source_modulation_service.py new file mode 100644 index 0000000..c5cdc52 --- /dev/null +++ b/tests/test_rf_source_modulation_service.py @@ -0,0 +1,668 @@ +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from wavebench.config import ( + AutoscaleConfig, + ConnectionConfig, + OutputConfig, + RfSourceConfig, + ScopeConfig, + WaveBenchConfig, + WaveformConfig, +) +from wavebench.errors import AccessDeniedError, ConfigError +from wavebench.instruments.rf_source_extensions import ( + RF_SOURCE_CONTRACT_VERSION, + RfFeature, + RfFeatureCapability, + RfFeatureDirection, + RfModulationDisableRequest, + RfModulationKind, + RfModulationModeProfile, + RfModulationProfile, + RfModulationRequest, + RfModulationSnapshot, + RfModulationSource, + RfModulationState, + RfModulationStateSnapshot, + RfModulationValueUnit, + RfModulationWaveform, + RfObserved, + RfOutputPortProfile, + RfPortSnapshot, + RfProtectionStatus, + RfPulseState, + RfSourceDescriptorExtensions, + RfSourceSnapshot, + RfSourceTopology, + RfSweepState, +) +from wavebench.logging import CommandLogger +from wavebench.services.rf_source_service import RfModulationPostconditionError, RfSourceService +from wavebench.transport.session import InstrumentSessionState, SessionHealth + + +def _config(*, access: str = "read_write") -> WaveBenchConfig: + return WaveBenchConfig( + connection=ConnectionConfig("lan", "TCPIP::scope::INSTR", 1_000, 1_000), + 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"), + rf_source=RfSourceConfig( + driver="example.rf.modulation", + resource="TCPIP::rf::INSTR", + access=access, # type: ignore[arg-type] + ), + ) + + +def _profile() -> RfModulationProfile: + return RfModulationProfile( + state_readable=True, + configuration_readable=True, + mode_profiles=( + RfModulationModeProfile( + kind=RfModulationKind.AM, + value_unit=RfModulationValueUnit.PERCENT, + value_min=0.0, + value_max=100.0, + internal_frequency_min_hz=10.0, + internal_frequency_max_hz=100_000.0, + ), + RfModulationModeProfile( + kind=RfModulationKind.FM, + value_unit=RfModulationValueUnit.HZ, + value_min=0.1, + value_max=1_000_000.0, + internal_frequency_min_hz=10.0, + internal_frequency_max_hz=100_000.0, + ), + RfModulationModeProfile( + kind=RfModulationKind.PM, + value_unit=RfModulationValueUnit.RAD, + value_min=0.0, + value_max=5.0, + internal_frequency_min_hz=10.0, + internal_frequency_max_hz=100_000.0, + ), + ), + ) + + +def _descriptor( + *capabilities: str, + profile: RfModulationProfile | None = None, + directions: tuple[RfFeatureDirection, ...] = ( + RfFeatureDirection.CONFIGURE, + RfFeatureDirection.READ, + ), +) -> SimpleNamespace: + return SimpleNamespace( + driver_id="example.rf.modulation", + capabilities=capabilities, + rf_source_extensions=RfSourceDescriptorExtensions( + contract_version=RF_SOURCE_CONTRACT_VERSION, + topology=RfSourceTopology( + ( + RfOutputPortProfile( + port_id="rf_out", + frequency_min_hz=9_000.0, + frequency_max_hz=3_000_000_000.0, + power_min_dbm=-110.0, + power_max_dbm=20.0, + power_reference_impedance_ohm=50.0, + ), + ) + ), + features=( + RfFeatureCapability( + feature=RfFeature.MODULATION, + directions=directions, + port_ids=("rf_out",), + profile=profile or _profile(), + ), + ), + ), + ) + + +def _rf_snapshot( + *, + output_enabled: bool = False, + modulation: RfModulationState = RfModulationState.DISABLED, + pulse: RfPulseState = RfPulseState.DISABLED, + sweep: RfSweepState = RfSweepState.DISABLED, + protection_codes: tuple[str, ...] = (), +) -> RfSourceSnapshot: + return RfSourceSnapshot( + ports=( + RfPortSnapshot( + port_id="rf_out", + frequency_hz=RfObserved.value_of(1_000_000.0), + power_dbm=RfObserved.value_of(-50.0), + output_enabled=RfObserved.value_of(output_enabled), + modulation=RfObserved.value_of(modulation), + pulse=RfObserved.value_of(pulse), + sweep=RfObserved.value_of(sweep), + ), + ), + protection=RfObserved.value_of(RfProtectionStatus(active_codes=protection_codes)), + ) + + +def _modulation_snapshot( + *, + kind: RfModulationKind = RfModulationKind.AM, + enabled: bool = False, + value: float = 50.0, + source: RfModulationSource = RfModulationSource.INTERNAL, + waveform: RfModulationWaveform = RfModulationWaveform.SINE, + selected_fm_pm_kind: RfModulationKind | None = None, + faults: tuple[str, ...] = (), +) -> RfModulationSnapshot: + fields: dict[str, object] = { + "port_id": "rf_out", + "kind": kind, + "source": source, + "waveform": waveform, + "internal_frequency_hz": 1_000.0, + "selected_fm_pm_kind": ( + selected_fm_pm_kind + if selected_fm_pm_kind is not None + else (kind if kind in {RfModulationKind.FM, RfModulationKind.PM} else None) + ), + "enabled_modes": (kind,) if enabled else (), + "global_enabled": enabled, + "fault_codes": faults, + } + if kind is RfModulationKind.AM: + fields["depth_percent"] = value + elif kind is RfModulationKind.FM: + fields["frequency_deviation_hz"] = value + else: + fields["phase_deviation_rad"] = value + return RfModulationSnapshot(**fields) # type: ignore[arg-type] + + +class _Driver: + def __init__( + self, + rf_snapshots: list[RfSourceSnapshot], + modulation_snapshots: list[RfModulationSnapshot], + ) -> None: + self.rf_snapshots = list(rf_snapshots) + self.modulation_snapshots = list(modulation_snapshots) + self.calls: list[str] = [] + self.requests: list[RfModulationRequest] = [] + self.disable_requests: list[RfModulationDisableRequest] = [] + + def close(self) -> None: + self.calls.append("close") + + def get_rf_snapshot(self) -> RfSourceSnapshot: + self.calls.append("snapshot") + if not self.rf_snapshots: + raise AssertionError("unexpected RF snapshot") + return self.rf_snapshots.pop(0) + + def get_rf_modulation_snapshot( + self, + port_id: str, + kind: RfModulationKind, + ) -> RfModulationSnapshot: + self.calls.append("modulation_snapshot") + assert port_id == "rf_out" + if not self.modulation_snapshots: + raise AssertionError("unexpected modulation snapshot") + snapshot = self.modulation_snapshots.pop(0) + assert snapshot.kind is kind + return snapshot + + def get_rf_modulation_state(self, port_id: str) -> RfModulationStateSnapshot: + self.calls.append("modulation_state") + assert port_id == "rf_out" + if not self.modulation_snapshots: + raise AssertionError("unexpected modulation state") + snapshot = self.modulation_snapshots.pop(0) + return RfModulationStateSnapshot( + port_id=snapshot.port_id, + enabled_modes=snapshot.enabled_modes, + global_enabled=snapshot.global_enabled, + fault_codes=snapshot.fault_codes, + ) + + def configure_rf_modulation(self, request: RfModulationRequest) -> None: + self.calls.append("configure_modulation") + self.requests.append(request) + + def disable_rf_modulation(self, request: RfModulationDisableRequest) -> None: + self.calls.append("disable_modulation") + self.disable_requests.append(request) + + +def _service( + rf_snapshots: list[RfSourceSnapshot], + modulation_snapshots: list[RfModulationSnapshot], + *, + access: str = "read_write", + descriptor: SimpleNamespace | None = None, +) -> tuple[RfSourceService, _Driver]: + driver = _Driver(rf_snapshots, modulation_snapshots) + service = RfSourceService( + config=_config(access=access), + logger=CommandLogger(), + session=driver, + descriptor=descriptor + or _descriptor( + "rf_source.idn", + "rf_source.snapshot", + "rf_source.modulation_configure", + ), + session_state=InstrumentSessionState(), + ) + return service, driver + + +def _am_request(*, depth_percent: float = 50.0) -> RfModulationRequest: + return RfModulationRequest( + port_id="rf_out", + kind=RfModulationKind.AM, + internal_frequency_hz=1_000.0, + depth_percent=depth_percent, + ) + + +def _am_disable_request() -> RfModulationDisableRequest: + return RfModulationDisableRequest(port_id="rf_out", kind=RfModulationKind.AM) + + +def test_modulation_uses_one_driver_sequence_and_independent_readback() -> None: + request = _am_request() + service, driver = _service( + [_rf_snapshot(), _rf_snapshot(modulation=RfModulationState.ENABLED)], + [_modulation_snapshot(), _modulation_snapshot(enabled=True)], + ) + + result, artifact = service.configure_modulation_with_artifact(request) + + assert result.kind is RfModulationKind.AM + assert result.depth_percent == 50.0 + assert driver.requests == [request] + assert driver.calls == [ + "snapshot", + "modulation_state", + "configure_modulation", + "snapshot", + "modulation_snapshot", + ] + assert artifact["operation"] == "rf_source.modulation_configure" + assert artifact["postcondition_modulation_snapshot"]["global_enabled"] is True + assert service.session_state is not None + assert service.session_state.health is SessionHealth.HEALTHY + + +def test_modulation_replaces_an_inactive_external_square_profile_with_internal_sine() -> None: + request = _am_request() + service, driver = _service( + [_rf_snapshot(), _rf_snapshot(modulation=RfModulationState.ENABLED)], + [ + _modulation_snapshot( + source=RfModulationSource.EXTERNAL, + waveform=RfModulationWaveform.SQUARE, + ), + _modulation_snapshot(enabled=True), + ], + ) + + result = service.configure_modulation(request) + + assert result.depth_percent == request.depth_percent + assert driver.requests == [request] + assert service.session_state is not None + assert service.session_state.health is SessionHealth.HEALTHY + + +def test_modulation_rejects_a_non_internal_sine_postcondition() -> None: + service, driver = _service( + [_rf_snapshot(), _rf_snapshot(modulation=RfModulationState.ENABLED)], + [ + _modulation_snapshot(), + _modulation_snapshot( + enabled=True, + source=RfModulationSource.EXTERNAL, + waveform=RfModulationWaveform.SQUARE, + ), + ], + ) + + with pytest.raises(ConfigError, match="postcondition requires the requested internal-sine"): + service.configure_modulation(_am_request()) + + assert len(driver.requests) == 1 + assert service.session_state is not None + assert service.session_state.health is SessionHealth.UNCERTAIN + + +def test_modulation_allows_off_only_fm_pm_selection_change_before_fixed_write() -> None: + request = RfModulationRequest( + port_id="rf_out", + kind=RfModulationKind.PM, + internal_frequency_hz=1_000.0, + phase_deviation_rad=2.0, + ) + service, driver = _service( + [_rf_snapshot(), _rf_snapshot(modulation=RfModulationState.ENABLED)], + [ + _modulation_snapshot( + kind=RfModulationKind.PM, + value=2.0, + selected_fm_pm_kind=RfModulationKind.FM, + ), + _modulation_snapshot( + kind=RfModulationKind.PM, + enabled=True, + value=2.0, + selected_fm_pm_kind=RfModulationKind.PM, + ), + ], + ) + + result = service.configure_modulation(request) + + assert result.kind is RfModulationKind.PM + assert driver.requests == [request] + assert driver.calls == [ + "snapshot", + "modulation_state", + "configure_modulation", + "snapshot", + "modulation_snapshot", + ] + + +def test_modulation_requires_fm_pm_selection_after_fixed_write() -> None: + request = RfModulationRequest( + port_id="rf_out", + kind=RfModulationKind.PM, + internal_frequency_hz=1_000.0, + phase_deviation_rad=2.0, + ) + service, driver = _service( + [_rf_snapshot(), _rf_snapshot(modulation=RfModulationState.ENABLED)], + [ + _modulation_snapshot( + kind=RfModulationKind.PM, + value=2.0, + selected_fm_pm_kind=RfModulationKind.FM, + ), + _modulation_snapshot( + kind=RfModulationKind.PM, + enabled=True, + value=2.0, + selected_fm_pm_kind=RfModulationKind.FM, + ), + ], + ) + + with pytest.raises(ConfigError, match="does not select the requested FM/PM kind"): + service.configure_modulation(request) + + assert driver.requests == [request] + assert service.session_state is not None + assert service.session_state.health is SessionHealth.UNCERTAIN + + +@pytest.mark.parametrize( + ("rf_snapshot", "modulation_snapshot", "message"), + ( + (_rf_snapshot(output_enabled=True), _modulation_snapshot(), "target RF output OFF"), + ( + _rf_snapshot(pulse=RfPulseState.ENABLED), + _modulation_snapshot(), + "Pulse disabled", + ), + ( + _rf_snapshot(sweep=RfSweepState.ENABLED), + _modulation_snapshot(), + "Sweep disabled", + ), + ( + _rf_snapshot(protection_codes=("overtemperature",)), + _modulation_snapshot(), + "active protection", + ), + (_rf_snapshot(), _modulation_snapshot(enabled=True), "all modulation modes disabled"), + ( + _rf_snapshot(), + _modulation_snapshot(faults=("am_overmodulation",)), + "modulation fault", + ), + ), +) +def test_modulation_rejects_unsafe_preflight_without_write( + rf_snapshot: RfSourceSnapshot, + modulation_snapshot: RfModulationSnapshot, + message: str, +) -> None: + service, driver = _service([rf_snapshot], [modulation_snapshot]) + + with pytest.raises(ConfigError, match=message): + service.configure_modulation(_am_request()) + + assert driver.requests == [] + assert driver.calls == ["snapshot", "modulation_state"] + assert service.session_state is not None + assert service.session_state.health is SessionHealth.HEALTHY + + +def test_modulation_checks_capability_access_and_descriptor_range_before_driver_io() -> None: + missing, missing_driver = _service( + [], + [], + descriptor=_descriptor("rf_source.idn", "rf_source.snapshot"), + ) + with pytest.raises(ConfigError, match="rf_source.modulation_configure"): + missing.configure_modulation(_am_request()) + assert missing_driver.calls == [] + + read_only, read_only_driver = _service([], [], access="read_only") + with pytest.raises(AccessDeniedError, match="rf_source.modulation_configure"): + read_only.configure_modulation(_am_request()) + assert read_only_driver.calls == [] + + range_service, range_driver = _service([], []) + with pytest.raises(ConfigError, match="outside the descriptor range"): + range_service.configure_modulation(_am_request(depth_percent=101.0)) + assert range_driver.calls == [] + + +def test_modulation_mismatch_is_not_retried_and_degrades_session() -> None: + service, driver = _service( + [_rf_snapshot(), _rf_snapshot(modulation=RfModulationState.ENABLED)], + [_modulation_snapshot(), _modulation_snapshot(enabled=True, value=49.0)], + ) + + with pytest.raises(ConfigError, match="readback does not match"): + service.configure_modulation(_am_request()) + + assert len(driver.requests) == 1 + assert driver.calls == [ + "snapshot", + "modulation_state", + "configure_modulation", + "snapshot", + "modulation_snapshot", + ] + assert service.session_state is not None + assert service.session_state.health is SessionHealth.UNCERTAIN + + +def test_pm_postcondition_error_retains_typed_readback_without_relaxing_match() -> None: + request = RfModulationRequest( + port_id="rf_out", + kind=RfModulationKind.PM, + internal_frequency_hz=1_000.0, + phase_deviation_rad=2.0, + ) + postcondition_rf = _rf_snapshot(modulation=RfModulationState.ENABLED) + postcondition_modulation = _modulation_snapshot( + kind=RfModulationKind.PM, + enabled=True, + value=1.25, + ) + service, driver = _service( + [ + _rf_snapshot(), + postcondition_rf, + ], + [ + _modulation_snapshot(kind=RfModulationKind.PM, value=2.0), + postcondition_modulation, + ], + ) + + with pytest.raises(RfModulationPostconditionError, match="readback does not match") as raised: + service.configure_modulation(request) + + assert raised.value.postcondition_snapshot is postcondition_rf + assert raised.value.postcondition_modulation_snapshot is postcondition_modulation + assert raised.value.postcondition_modulation_snapshot.phase_deviation_rad == 1.25 + assert driver.requests == [request] + assert service.session_state is not None + assert service.session_state.health is SessionHealth.UNCERTAIN + + +def _disable_descriptor() -> SimpleNamespace: + return _descriptor( + "rf_source.idn", + "rf_source.snapshot", + "rf_source.modulation_disable", + directions=(RfFeatureDirection.DISABLE, RfFeatureDirection.READ), + ) + + +def test_modulation_disable_uses_one_mode_global_sequence_and_independent_state_readback() -> None: + request = _am_disable_request() + service, driver = _service( + [ + _rf_snapshot(modulation=RfModulationState.ENABLED), + _rf_snapshot(modulation=RfModulationState.DISABLED), + ], + [_modulation_snapshot(enabled=True), _modulation_snapshot(enabled=False)], + descriptor=_disable_descriptor(), + ) + + result, artifact = service.disable_modulation_with_artifact(request) + + assert result.write_completed is True + assert driver.disable_requests == [request] + assert driver.calls == [ + "snapshot", + "modulation_state", + "disable_modulation", + "snapshot", + "modulation_state", + ] + assert artifact["operation"] == "rf_source.modulation_disable" + assert artifact["postcondition_modulation_state"]["enabled_modes"] == [] + assert service.session_state is not None + assert service.session_state.health is SessionHealth.HEALTHY + + +def test_modulation_disable_is_a_no_write_success_for_an_already_disabled_consistent_state() -> None: + service, driver = _service( + [_rf_snapshot()], + [_modulation_snapshot(enabled=False)], + descriptor=_disable_descriptor(), + ) + + result = service.disable_modulation(_am_disable_request()) + + assert result.write_completed is False + assert driver.disable_requests == [] + assert driver.calls == ["snapshot", "modulation_state"] + + +@pytest.mark.parametrize( + ("rf_snapshot", "modulation_snapshot", "message"), + ( + ( + _rf_snapshot(output_enabled=True, modulation=RfModulationState.ENABLED), + _modulation_snapshot(enabled=True), + "target RF output OFF", + ), + ( + _rf_snapshot(modulation=RfModulationState.ENABLED), + _modulation_snapshot(kind=RfModulationKind.FM, enabled=True), + "only the requested modulation mode", + ), + ), +) +def test_modulation_disable_rejects_unsafe_or_ambiguous_preflight_without_write( + rf_snapshot: RfSourceSnapshot, + modulation_snapshot: RfModulationSnapshot, + message: str, +) -> None: + service, driver = _service( + [rf_snapshot], + [modulation_snapshot], + descriptor=_disable_descriptor(), + ) + + with pytest.raises(ConfigError, match=message): + service.disable_modulation(_am_disable_request()) + + assert driver.disable_requests == [] + assert driver.calls == ["snapshot", "modulation_state"] + + +def test_modulation_disable_checks_capability_and_access_before_driver_io() -> None: + missing, missing_driver = _service( + [], + [], + descriptor=_descriptor("rf_source.idn", "rf_source.snapshot"), + ) + with pytest.raises(ConfigError, match="rf_source.modulation_disable"): + missing.disable_modulation(_am_disable_request()) + assert missing_driver.calls == [] + + read_only, read_only_driver = _service( + [], + [], + access="read_only", + descriptor=_disable_descriptor(), + ) + with pytest.raises(AccessDeniedError, match="rf_source.modulation_disable"): + read_only.disable_modulation(_am_disable_request()) + assert read_only_driver.calls == [] + + +def test_modulation_disable_postcondition_mismatch_is_not_retried_and_degrades_session() -> None: + service, driver = _service( + [ + _rf_snapshot(modulation=RfModulationState.ENABLED), + _rf_snapshot(modulation=RfModulationState.ENABLED), + ], + [_modulation_snapshot(enabled=True), _modulation_snapshot(enabled=True)], + descriptor=_disable_descriptor(), + ) + + with pytest.raises(ConfigError, match="postcondition requires modulation disabled"): + service.disable_modulation(_am_disable_request()) + + assert len(driver.disable_requests) == 1 + assert driver.calls == [ + "snapshot", + "modulation_state", + "disable_modulation", + "snapshot", + "modulation_state", + ] + assert service.session_state is not None + assert service.session_state.health is SessionHealth.UNCERTAIN diff --git a/tests/test_rf_source_pulse_extensions.py b/tests/test_rf_source_pulse_extensions.py new file mode 100644 index 0000000..9ccbacc --- /dev/null +++ b/tests/test_rf_source_pulse_extensions.py @@ -0,0 +1,254 @@ +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from wavebench.instruments.capabilities import CAPABILITY_METHODS +from wavebench.instruments.rf_source_capabilities import validate_rf_source_descriptor +from wavebench.instruments.rf_source_extensions import ( + RF_SOURCE_CONTRACT_VERSION, + RF_SOURCE_PULSE_SNAPSHOT_SCHEMA, + RfFeature, + RfFeatureCapability, + RfFeatureDirection, + RfModulationState, + RfObserved, + RfOutputPortProfile, + RfPortSnapshot, + RfProtectionStatus, + RfPulseConfigureRequest, + RfPulseConfigureResult, + RfPulseMode, + RfPulseModeProfile, + RfPulsePolarity, + RfPulseProfile, + RfPulseSnapshot, + RfPulseSource, + RfPulseState, + RfSourceDescriptorExtensions, + RfSourceSnapshot, + RfSourceTopology, + RfSweepState, + rf_pulse_snapshot_document, + rf_source_pulse_operation_artifact, +) + + +def _topology() -> RfSourceTopology: + return RfSourceTopology( + ( + RfOutputPortProfile( + port_id="rf_out", + frequency_min_hz=9_000.0, + frequency_max_hz=3_000_000_000.0, + power_min_dbm=-110.0, + power_max_dbm=20.0, + power_reference_impedance_ohm=50.0, + ), + ) + ) + + +def _pulse_mode() -> RfPulseModeProfile: + return RfPulseModeProfile( + source=RfPulseSource.INTERNAL, + mode=RfPulseMode.SINGLE, + polarities=(RfPulsePolarity.INVERTED, RfPulsePolarity.NORMAL), + period_min_s=40e-9, + period_max_s=170.0, + width_min_s=10e-9, + width_max_s=170.0 - 10e-9, + minimum_off_time_s=10e-9, + ) + + +def _pulse_profile() -> RfPulseProfile: + return RfPulseProfile( + state_readable=True, + configuration_readable=True, + mode_profiles=(_pulse_mode(),), + ) + + +def _snapshot() -> RfSourceSnapshot: + return RfSourceSnapshot( + ports=( + RfPortSnapshot( + port_id="rf_out", + frequency_hz=RfObserved.value_of(1_000_000.0), + power_dbm=RfObserved.value_of(-50.0), + output_enabled=RfObserved.value_of(False), + modulation=RfObserved.value_of(RfModulationState.DISABLED), + pulse=RfObserved.value_of(RfPulseState.DISABLED), + sweep=RfObserved.value_of(RfSweepState.DISABLED), + ), + ), + protection=RfObserved.value_of(RfProtectionStatus(active_codes=())), + ) + + +def _pulse_snapshot() -> RfPulseSnapshot: + return RfPulseSnapshot( + port_id="rf_out", + source=RfPulseSource.INTERNAL, + mode=RfPulseMode.SINGLE, + period_s=1e-3, + width_s=100e-6, + polarity=RfPulsePolarity.NORMAL, + state=RfPulseState.DISABLED, + ) + + +def _descriptor() -> SimpleNamespace: + return SimpleNamespace( + driver_id="example.rf.pulse", + kind="rf_source", + models=("RF-PULSE",), + capabilities=( + "rf_source.idn", + "rf_source.snapshot", + "rf_source.pulse_configure", + ), + wavebench_min_version="0.8.25", + wavebench_max_version="0.9.0", + rf_source_extensions=RfSourceDescriptorExtensions( + contract_version=RF_SOURCE_CONTRACT_VERSION, + topology=_topology(), + features=( + RfFeatureCapability( + feature=RfFeature.PULSE, + directions=(RfFeatureDirection.CONFIGURE, RfFeatureDirection.READ), + port_ids=("rf_out",), + profile=_pulse_profile(), + ), + ), + ), + ) + + +class _Driver: + def close(self) -> None: + return None + + def idn(self) -> str: + return "EXAMPLE,RF-PULSE,0,1" + + def get_rf_snapshot(self) -> RfSourceSnapshot: + return _snapshot() + + def get_rf_pulse_snapshot(self, port_id: str) -> RfPulseSnapshot: + assert port_id == "rf_out" + return _pulse_snapshot() + + def configure_rf_pulse(self, request: RfPulseConfigureRequest) -> None: + assert request.port_id == "rf_out" + + +def test_pulse_contract_rejects_unsafe_profiles_and_requests() -> None: + with pytest.raises(ValueError, match="internal source"): + RfPulseModeProfile( + source=RfPulseSource.EXTERNAL, + mode=RfPulseMode.SINGLE, + polarities=(RfPulsePolarity.NORMAL,), + period_min_s=40e-9, + period_max_s=170.0, + width_min_s=10e-9, + width_max_s=1.0, + minimum_off_time_s=10e-9, + ) + with pytest.raises(ValueError, match="single mode"): + RfPulseModeProfile( + source=RfPulseSource.INTERNAL, + mode=RfPulseMode.TRAIN, + polarities=(RfPulsePolarity.NORMAL,), + period_min_s=40e-9, + period_max_s=170.0, + width_min_s=10e-9, + width_max_s=1.0, + minimum_off_time_s=10e-9, + ) + with pytest.raises(ValueError, match="sorted by value"): + RfPulseModeProfile( + source=RfPulseSource.INTERNAL, + mode=RfPulseMode.SINGLE, + polarities=(RfPulsePolarity.NORMAL, RfPulsePolarity.INVERTED), + period_min_s=40e-9, + period_max_s=170.0, + width_min_s=10e-9, + width_max_s=1.0, + minimum_off_time_s=10e-9, + ) + with pytest.raises(ValueError, match="less than period"): + RfPulseConfigureRequest( + port_id="rf_out", + period_s=1e-3, + width_s=1e-3, + polarity=RfPulsePolarity.NORMAL, + ) + with pytest.raises(ValueError, match="finite"): + RfPulseConfigureRequest( + port_id="rf_out", + period_s=float("nan"), + width_s=1e-6, + polarity=RfPulsePolarity.NORMAL, + ) + + +def test_pulse_descriptor_requires_readable_bounded_profile_and_methods() -> None: + descriptor = _descriptor() + + assert CAPABILITY_METHODS["rf_source.pulse_configure"] == ( + "get_rf_pulse_snapshot", + "configure_rf_pulse", + ) + validate_rf_source_descriptor(descriptor, _Driver()) + + invalid = _descriptor() + invalid.rf_source_extensions = RfSourceDescriptorExtensions( + contract_version=RF_SOURCE_CONTRACT_VERSION, + topology=_topology(), + features=( + RfFeatureCapability( + feature=RfFeature.PULSE, + directions=(RfFeatureDirection.CONFIGURE,), + port_ids=("rf_out",), + profile=_pulse_profile(), + ), + ), + ) + with pytest.raises(Exception, match="configure and read"): + validate_rf_source_descriptor(invalid) + + +def test_pulse_snapshot_document_and_artifact_keep_typed_safe_evidence() -> None: + request = RfPulseConfigureRequest( + port_id="rf_out", + period_s=1e-3, + width_s=100e-6, + polarity=RfPulsePolarity.NORMAL, + ) + result = RfPulseConfigureResult( + port_id="rf_out", + period_s=1e-3, + width_s=100e-6, + polarity=RfPulsePolarity.NORMAL, + ) + pulse_snapshot = _pulse_snapshot() + + document = rf_pulse_snapshot_document(pulse_snapshot) + artifact = rf_source_pulse_operation_artifact( + request, + result, + preflight_snapshot=_snapshot(), + postcondition_snapshot=_snapshot(), + postcondition_pulse_snapshot=pulse_snapshot, + ) + + assert document["schema"] == RF_SOURCE_PULSE_SNAPSHOT_SCHEMA + assert document["source"] == "internal" + assert document["state"] == "disabled" + assert artifact["operation"] == "rf_source.pulse_configure" + assert artifact["request"]["period_s"] == 1e-3 + assert artifact["postcondition_pulse_snapshot"]["width_s"] == 100e-6 + assert "resource" not in str(artifact) diff --git a/tests/test_rf_source_pulse_output_cli.py b/tests/test_rf_source_pulse_output_cli.py new file mode 100644 index 0000000..a6a0715 --- /dev/null +++ b/tests/test_rf_source_pulse_output_cli.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +import io +import json +from contextlib import redirect_stdout +from unittest.mock import Mock, patch + +from wavebench.cli import build_parser, main +from wavebench.instruments.rf_source_extensions import ( + RfPulseOutputRequest, + RfPulseOutputResult, +) + + +def test_rf_source_parser_accepts_a_bounded_physical_pulse_output_command() -> None: + arguments = build_parser().parse_args( + [ + "rf-source", + "pulse-output", + "--port", + "rf_out", + "--interface", + "pulse_in_out", + "on", + ] + ) + + assert (arguments.domain, arguments.command) == ("rf-source", "pulse-output") + assert arguments.port == "rf_out" + assert arguments.interface_id == "pulse_in_out" + assert arguments.state == "on" + + +def test_rf_source_cli_dispatches_a_typed_physical_pulse_output_request() -> None: + service = Mock() + service.set_pulse_output.return_value = RfPulseOutputResult( + port_id="rf_out", + interface_id="pulse_in_out", + enabled=True, + write_completed=True, + ) + + stdout = io.StringIO() + with patch("wavebench.cli._load_rf_source_service", return_value=service), redirect_stdout(stdout): + assert ( + main( + [ + "--json", + "rf-source", + "pulse-output", + "--port", + "rf_out", + "--interface", + "pulse_in_out", + "on", + ] + ) + == 0 + ) + + payload = json.loads(stdout.getvalue()) + assert payload["result"] == { + "port_id": "rf_out", + "interface_id": "pulse_in_out", + "enabled": True, + "write_completed": True, + } + service.set_pulse_output.assert_called_once_with( + RfPulseOutputRequest( + port_id="rf_out", + interface_id="pulse_in_out", + enabled=True, + ) + ) diff --git a/tests/test_rf_source_pulse_output_extensions.py b/tests/test_rf_source_pulse_output_extensions.py new file mode 100644 index 0000000..8aad48e --- /dev/null +++ b/tests/test_rf_source_pulse_output_extensions.py @@ -0,0 +1,282 @@ +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from wavebench.instruments.capabilities import CAPABILITY_METHODS +from wavebench.instruments.rf_source_capabilities import validate_rf_source_descriptor +from wavebench.instruments.rf_source_extensions import ( + RF_SOURCE_CONTRACT_VERSION, + RF_SOURCE_PULSE_OUTPUT_SNAPSHOT_SCHEMA, + RfFeature, + RfFeatureCapability, + RfFeatureDirection, + RfModulationState, + RfObserved, + RfOutputPortProfile, + RfPortSnapshot, + RfProtectionStatus, + RfPulseConfigureRequest, + RfPulseMode, + RfPulseModeProfile, + RfPulseOutputDirection, + RfPulseOutputProfile, + RfPulseOutputRequest, + RfPulseOutputResult, + RfPulseOutputSnapshot, + RfPulsePolarity, + RfPulseProfile, + RfPulseSource, + RfPulseState, + RfSourceDescriptorExtensions, + RfSourceSnapshot, + RfSourceTopology, + RfSweepState, + rf_pulse_output_snapshot_document, + rf_source_pulse_output_operation_artifact, +) +from wavebench.services.operation_specs import require_operation_spec + + +def _topology() -> RfSourceTopology: + return RfSourceTopology( + ( + RfOutputPortProfile( + port_id="rf_out", + frequency_min_hz=9_000.0, + frequency_max_hz=3_000_000_000.0, + power_min_dbm=-110.0, + power_max_dbm=20.0, + power_reference_impedance_ohm=50.0, + ), + ) + ) + + +def _pulse_profile() -> RfPulseProfile: + return RfPulseProfile( + state_readable=True, + configuration_readable=True, + mode_profiles=( + RfPulseModeProfile( + source=RfPulseSource.INTERNAL, + mode=RfPulseMode.SINGLE, + polarities=(RfPulsePolarity.INVERTED, RfPulsePolarity.NORMAL), + period_min_s=40e-9, + period_max_s=170.0, + width_min_s=10e-9, + width_max_s=170.0 - 10e-9, + minimum_off_time_s=10e-9, + ), + ), + ) + + +def _pulse_output_profile() -> RfPulseOutputProfile: + return RfPulseOutputProfile( + interface_id="pulse_in_out", + direction=RfPulseOutputDirection.OUTPUT, + output_readable=True, + low_level_v=0.0, + high_level_v=3.3, + output_impedance_ohm=600.0, + source=RfPulseSource.INTERNAL, + mode=RfPulseMode.SINGLE, + period_s=1e-3, + width_s=100e-6, + polarity=RfPulsePolarity.NORMAL, + ) + + +def _snapshot() -> RfSourceSnapshot: + return RfSourceSnapshot( + ports=( + RfPortSnapshot( + port_id="rf_out", + frequency_hz=RfObserved.value_of(1_000_000.0), + power_dbm=RfObserved.value_of(-50.0), + output_enabled=RfObserved.value_of(False), + modulation=RfObserved.value_of(RfModulationState.DISABLED), + pulse=RfObserved.value_of(RfPulseState.DISABLED), + sweep=RfObserved.value_of(RfSweepState.DISABLED), + ), + ), + protection=RfObserved.value_of(RfProtectionStatus(active_codes=())), + ) + + +def _pulse_output_snapshot(*, enabled: bool = False) -> RfPulseOutputSnapshot: + return RfPulseOutputSnapshot( + port_id="rf_out", + interface_id="pulse_in_out", + direction=RfPulseOutputDirection.OUTPUT, + enabled=enabled, + low_level_v=0.0, + high_level_v=3.3, + output_impedance_ohm=600.0, + source=RfPulseSource.INTERNAL, + mode=RfPulseMode.SINGLE, + period_s=1e-3, + width_s=100e-6, + polarity=RfPulsePolarity.NORMAL, + pulse_state=RfPulseState.DISABLED, + ) + + +def _descriptor(*, directions: tuple[RfFeatureDirection, ...] | None = None) -> SimpleNamespace: + return SimpleNamespace( + driver_id="example.rf.pulse-output", + kind="rf_source", + models=("RF-PULSE-OUTPUT",), + capabilities=( + "rf_source.idn", + "rf_source.snapshot", + "rf_source.pulse_configure", + "rf_source.pulse_output", + ), + wavebench_min_version="0.8.25", + wavebench_max_version="0.9.0", + rf_source_extensions=RfSourceDescriptorExtensions( + contract_version=RF_SOURCE_CONTRACT_VERSION, + topology=_topology(), + features=( + RfFeatureCapability( + feature=RfFeature.PULSE, + directions=(RfFeatureDirection.CONFIGURE, RfFeatureDirection.READ), + port_ids=("rf_out",), + profile=_pulse_profile(), + ), + RfFeatureCapability( + feature=RfFeature.PULSE_OUTPUT, + directions=directions + or ( + RfFeatureDirection.DISABLE, + RfFeatureDirection.ENABLE, + RfFeatureDirection.READ, + ), + port_ids=("rf_out",), + profile=_pulse_output_profile(), + ), + ), + ), + ) + + +class _Driver: + def close(self) -> None: + return None + + def idn(self) -> str: + return "EXAMPLE,RF-PULSE-OUTPUT,0,1" + + def get_rf_snapshot(self) -> RfSourceSnapshot: + return _snapshot() + + def get_rf_pulse_snapshot(self, port_id: str): + assert port_id == "rf_out" + return None + + def configure_rf_pulse(self, request: RfPulseConfigureRequest) -> None: + assert request.port_id == "rf_out" + + def get_rf_pulse_output_snapshot(self, port_id: str, interface_id: str) -> RfPulseOutputSnapshot: + assert (port_id, interface_id) == ("rf_out", "pulse_in_out") + return _pulse_output_snapshot() + + def set_rf_pulse_output(self, request: RfPulseOutputRequest) -> None: + assert request.port_id == "rf_out" + + +def test_pulse_output_contract_requires_a_bounded_output_only_profile() -> None: + with pytest.raises(ValueError, match="high_level_v must exceed"): + RfPulseOutputProfile( + interface_id="pulse_in_out", + direction=RfPulseOutputDirection.OUTPUT, + output_readable=True, + low_level_v=3.3, + high_level_v=3.3, + output_impedance_ohm=600.0, + source=RfPulseSource.INTERNAL, + mode=RfPulseMode.SINGLE, + period_s=1e-3, + width_s=100e-6, + polarity=RfPulsePolarity.NORMAL, + ) + with pytest.raises(ValueError, match="Pulse modulation disabled"): + RfPulseOutputProfile( + interface_id="pulse_in_out", + direction=RfPulseOutputDirection.OUTPUT, + output_readable=True, + low_level_v=0.0, + high_level_v=3.3, + output_impedance_ohm=600.0, + source=RfPulseSource.INTERNAL, + mode=RfPulseMode.SINGLE, + period_s=1e-3, + width_s=100e-6, + polarity=RfPulsePolarity.NORMAL, + pulse_state=RfPulseState.ENABLED, + ) + + +def test_pulse_output_descriptor_requires_read_enable_disable_and_driver_methods() -> None: + descriptor = _descriptor() + + assert CAPABILITY_METHODS["rf_source.pulse_output"] == ( + "get_rf_pulse_output_snapshot", + "set_rf_pulse_output", + ) + validate_rf_source_descriptor(descriptor, _Driver()) + + invalid = _descriptor(directions=(RfFeatureDirection.ENABLE, RfFeatureDirection.READ)) + with pytest.raises(Exception, match="enable and disable"): + validate_rf_source_descriptor(invalid) + + +def test_pulse_output_snapshot_document_and_artifact_keep_typed_evidence() -> None: + request = RfPulseOutputRequest( + port_id="rf_out", + interface_id="pulse_in_out", + enabled=True, + ) + result = RfPulseOutputResult( + port_id="rf_out", + interface_id="pulse_in_out", + enabled=True, + write_completed=True, + ) + snapshot = _pulse_output_snapshot(enabled=True) + + document = rf_pulse_output_snapshot_document(snapshot) + artifact = rf_source_pulse_output_operation_artifact( + request, + result, + preflight_snapshot=_snapshot(), + preflight_pulse_output_snapshot=_pulse_output_snapshot(), + postcondition_snapshot=_snapshot(), + postcondition_pulse_output_snapshot=snapshot, + ) + + assert document["schema"] == RF_SOURCE_PULSE_OUTPUT_SNAPSHOT_SCHEMA + assert document["direction"] == "output" + assert document["high_level_v"] == 3.3 + assert artifact["operation"] == "rf_source.pulse_output_enable" + assert artifact["postcondition_pulse_output_snapshot"]["enabled"] is True + assert "resource" not in str(artifact) + + +def test_pulse_output_operation_specs_keep_enable_and_disable_separate() -> None: + enable = require_operation_spec("rf_source.pulse_output_enable") + disable = require_operation_spec("rf_source.pulse_output_disable") + + assert enable.required_capabilities == ( + "rf_source.snapshot", + "rf_source.pulse_configure", + "rf_source.pulse_output", + ) + assert enable.changed_fields == ("rf_source.physical_interface.pulse_output.enabled",) + assert enable.effect == "write" + assert "rf_output_must_be_off" in enable.risk_flags + assert "rf_output_must_be_off" not in disable.risk_flags + assert disable.changed_fields == enable.changed_fields diff --git a/tests/test_rf_source_pulse_output_run.py b/tests/test_rf_source_pulse_output_run.py new file mode 100644 index 0000000..4331e43 --- /dev/null +++ b/tests/test_rf_source_pulse_output_run.py @@ -0,0 +1,219 @@ +from __future__ import annotations + +from contextlib import contextmanager +import json +from pathlib import Path +from tempfile import TemporaryDirectory +from types import SimpleNamespace +from unittest.mock import Mock, patch + +import pytest + +from wavebench.config import ( + AutoscaleConfig, + ConnectionConfig, + OutputConfig, + RfSourceConfig, + ScopeConfig, + WaveBenchConfig, + WaveformConfig, +) +from wavebench.errors import ConfigError +from wavebench.instruments.rf_source_extensions import ( + RfModulationState, + RfObserved, + RfPortSnapshot, + RfProtectionStatus, + RfPulseMode, + RfPulseOutputDirection, + RfPulseOutputRequest, + RfPulseOutputResult, + RfPulseOutputSnapshot, + RfPulsePolarity, + RfPulseSource, + RfPulseState, + RfSourceSnapshot, + RfSweepState, + rf_source_pulse_output_operation_artifact, +) +from wavebench.logging import CommandLogger +from wavebench.services.execution_intent import build_execution_intent +from wavebench.services.run_plan import load_run_plan +from wavebench.services.run_service import RunInstrumentServices, RunService + + +def _config(directory: str, *, access: str = "read_write") -> WaveBenchConfig: + return WaveBenchConfig( + connection=ConnectionConfig("lan", "TCPIP::scope::INSTR", 1_000, 1_000), + scope=ScopeConfig("rtm2032", None, 1, False, True), + autoscale=AutoscaleConfig(True, True), + waveform=WaveformConfig("real", "lsbf", "dmax"), + output=OutputConfig( + Path(directory) / "data" / "raw", + "timestamp_label", + True, + True, + True, + True, + False, + ), + source_path=Path(directory) / "wavebench.toml", + rf_source=RfSourceConfig( + driver="example.rf.pulse-output", + resource="TCPIP::rf::INSTR", + access=access, # type: ignore[arg-type] + ), + ) + + +def _plan(directory: str, *, kind: str = "rf_source.pulse_output_enable"): + path = Path(directory) / "plan.toml" + path.write_text( + "[[steps]]\n" + f'kind = "{kind}"\n' + 'port_id = "rf_out"\n' + 'interface_id = "pulse_in_out"\n', + encoding="utf-8", + ) + return load_run_plan(path) + + +def _rf_snapshot() -> RfSourceSnapshot: + return RfSourceSnapshot( + ports=( + RfPortSnapshot( + port_id="rf_out", + frequency_hz=RfObserved.value_of(1_000_000.0), + power_dbm=RfObserved.value_of(-50.0), + output_enabled=RfObserved.value_of(False), + modulation=RfObserved.value_of(RfModulationState.DISABLED), + pulse=RfObserved.value_of(RfPulseState.DISABLED), + sweep=RfObserved.value_of(RfSweepState.DISABLED), + ), + ), + protection=RfObserved.value_of(RfProtectionStatus(active_codes=())), + ) + + +def _pulse_output_snapshot(*, enabled: bool) -> RfPulseOutputSnapshot: + return RfPulseOutputSnapshot( + port_id="rf_out", + interface_id="pulse_in_out", + direction=RfPulseOutputDirection.OUTPUT, + enabled=enabled, + low_level_v=0.0, + high_level_v=3.3, + output_impedance_ohm=600.0, + source=RfPulseSource.INTERNAL, + mode=RfPulseMode.SINGLE, + period_s=1e-3, + width_s=100e-6, + polarity=RfPulsePolarity.NORMAL, + pulse_state=RfPulseState.DISABLED, + ) + + +def _descriptor(*capabilities: str) -> SimpleNamespace: + return SimpleNamespace( + driver_id="example.rf.pulse-output", + kind="rf_source", + capabilities=capabilities, + ) + + +def test_rf_source_pulse_output_plan_is_typed_and_rejects_invalid_interface_ids() -> None: + with TemporaryDirectory() as directory: + plan = _plan(directory) + assert plan.steps[0].fields == { + "port_id": "rf_out", + "interface_id": "pulse_in_out", + } + intent = build_execution_intent(plan, _config(directory)) + assert intent.operations[0]["operation"] == "rf_source.pulse_output_enable" + assert intent.operations[0]["effect"] == "write" + assert intent.operations[0]["parameters"] == plan.steps[0].fields + + invalid = Path(directory) / "invalid.toml" + invalid.write_text( + "[[steps]]\n" + 'kind = "rf_source.pulse_output_enable"\n' + 'port_id = "rf_out"\n' + 'interface_id = "PULSE IN/OUT"\n', + encoding="utf-8", + ) + with pytest.raises(ConfigError, match="interface_id"): + load_run_plan(invalid) + + +def test_rf_source_pulse_output_run_preflights_capabilities_before_opening_sessions() -> None: + with TemporaryDirectory() as directory: + service = RunService(config=_config(directory), logger=CommandLogger()) + with patch( + "wavebench.services.run_service.resolve_instrument_descriptor", + return_value=_descriptor( + "rf_source.idn", + "rf_source.snapshot", + "rf_source.pulse_configure", + ), + ), patch.object(service, "_run_instrument_services") as open_services: + with pytest.raises(ConfigError, match="rf_source.pulse_output"): + service.run(_plan(directory)) + + open_services.assert_not_called() + + +def test_rf_source_pulse_output_run_dispatches_a_typed_artifact() -> None: + with TemporaryDirectory() as directory: + plan = _plan(directory) + request = RfPulseOutputRequest( + port_id="rf_out", + interface_id="pulse_in_out", + enabled=True, + ) + result = RfPulseOutputResult( + port_id="rf_out", + interface_id="pulse_in_out", + enabled=True, + write_completed=True, + ) + artifact = rf_source_pulse_output_operation_artifact( + request, + result, + preflight_snapshot=_rf_snapshot(), + preflight_pulse_output_snapshot=_pulse_output_snapshot(enabled=False), + postcondition_snapshot=_rf_snapshot(), + postcondition_pulse_output_snapshot=_pulse_output_snapshot(enabled=True), + ) + rf_service = SimpleNamespace( + set_pulse_output_with_artifact=Mock(return_value=(result, artifact)), + audit_snapshot=lambda: None, + ) + + class OfflineRfRunService(RunService): + @contextmanager + def _run_instrument_services(self, run_plan): + del run_plan + yield RunInstrumentServices(rf_source=rf_service) + + def _run_safety_guards(self, run_plan, *, services=None): + del run_plan, services + + descriptor = _descriptor( + "rf_source.idn", + "rf_source.snapshot", + "rf_source.pulse_configure", + "rf_source.pulse_output", + ) + with patch( + "wavebench.services.run_service.resolve_instrument_descriptor", + return_value=descriptor, + ): + run_result = OfflineRfRunService( + config=_config(directory), + logger=CommandLogger(), + ).run(plan) + + rf_service.set_pulse_output_with_artifact.assert_called_once_with(request) + assert run_result.steps[0].artifact == {"rf_source_operation": artifact} + run_data = json.loads(run_result.run_json_path.read_text(encoding="utf-8")) + assert run_data["rf_source_operations"] == [artifact] diff --git a/tests/test_rf_source_pulse_output_service.py b/tests/test_rf_source_pulse_output_service.py new file mode 100644 index 0000000..d2c65b5 --- /dev/null +++ b/tests/test_rf_source_pulse_output_service.py @@ -0,0 +1,401 @@ +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from wavebench.config import ( + AutoscaleConfig, + ConnectionConfig, + OutputConfig, + RfSourceConfig, + ScopeConfig, + WaveBenchConfig, + WaveformConfig, +) +from wavebench.errors import AccessDeniedError, ConfigError +from wavebench.instruments.rf_source_extensions import ( + RF_SOURCE_CONTRACT_VERSION, + RfFeature, + RfFeatureCapability, + RfFeatureDirection, + RfModulationState, + RfObserved, + RfOutputPortProfile, + RfPortSnapshot, + RfProtectionStatus, + RfPulseMode, + RfPulseModeProfile, + RfPulseOutputDirection, + RfPulseOutputProfile, + RfPulseOutputRequest, + RfPulseOutputSnapshot, + RfPulsePolarity, + RfPulseProfile, + RfPulseSource, + RfPulseState, + RfSourceDescriptorExtensions, + RfSourceSnapshot, + RfSourceTopology, + RfSweepState, +) +from wavebench.logging import CommandLogger +from wavebench.services.rf_source_service import RfSourceService +from wavebench.transport.session import InstrumentSessionState, SessionHealth + + +def _config(*, access: str = "read_write") -> WaveBenchConfig: + return WaveBenchConfig( + connection=ConnectionConfig("lan", "TCPIP::scope::INSTR", 1_000, 1_000), + 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"), + rf_source=RfSourceConfig( + driver="example.rf.pulse-output", + resource="TCPIP::rf::INSTR", + access=access, # type: ignore[arg-type] + ), + ) + + +def _pulse_profile() -> RfPulseProfile: + return RfPulseProfile( + state_readable=True, + configuration_readable=True, + mode_profiles=( + RfPulseModeProfile( + source=RfPulseSource.INTERNAL, + mode=RfPulseMode.SINGLE, + polarities=(RfPulsePolarity.INVERTED, RfPulsePolarity.NORMAL), + period_min_s=40e-9, + period_max_s=170.0, + width_min_s=10e-9, + width_max_s=170.0 - 10e-9, + minimum_off_time_s=10e-9, + ), + ), + ) + + +def _pulse_output_profile() -> RfPulseOutputProfile: + return RfPulseOutputProfile( + interface_id="pulse_in_out", + direction=RfPulseOutputDirection.OUTPUT, + output_readable=True, + low_level_v=0.0, + high_level_v=3.3, + output_impedance_ohm=600.0, + source=RfPulseSource.INTERNAL, + mode=RfPulseMode.SINGLE, + period_s=1e-3, + width_s=100e-6, + polarity=RfPulsePolarity.NORMAL, + ) + + +def _descriptor(*capabilities: str) -> SimpleNamespace: + return SimpleNamespace( + driver_id="example.rf.pulse-output", + capabilities=capabilities, + rf_source_extensions=RfSourceDescriptorExtensions( + contract_version=RF_SOURCE_CONTRACT_VERSION, + topology=RfSourceTopology( + ( + RfOutputPortProfile( + port_id="rf_out", + frequency_min_hz=9_000.0, + frequency_max_hz=3_000_000_000.0, + power_min_dbm=-110.0, + power_max_dbm=20.0, + power_reference_impedance_ohm=50.0, + ), + ) + ), + features=( + RfFeatureCapability( + feature=RfFeature.PULSE, + directions=(RfFeatureDirection.CONFIGURE, RfFeatureDirection.READ), + port_ids=("rf_out",), + profile=_pulse_profile(), + ), + RfFeatureCapability( + feature=RfFeature.PULSE_OUTPUT, + directions=( + RfFeatureDirection.DISABLE, + RfFeatureDirection.ENABLE, + RfFeatureDirection.READ, + ), + port_ids=("rf_out",), + profile=_pulse_output_profile(), + ), + ), + ), + ) + + +def _rf_snapshot( + *, + output_enabled: bool = False, + modulation: RfModulationState = RfModulationState.DISABLED, + pulse: RfPulseState = RfPulseState.DISABLED, + sweep: RfSweepState = RfSweepState.DISABLED, + protection_codes: tuple[str, ...] = (), +) -> RfSourceSnapshot: + return RfSourceSnapshot( + ports=( + RfPortSnapshot( + port_id="rf_out", + frequency_hz=RfObserved.value_of(1_000_000.0), + power_dbm=RfObserved.value_of(-50.0), + output_enabled=RfObserved.value_of(output_enabled), + modulation=RfObserved.value_of(modulation), + pulse=RfObserved.value_of(pulse), + sweep=RfObserved.value_of(sweep), + ), + ), + protection=RfObserved.value_of(RfProtectionStatus(active_codes=protection_codes)), + ) + + +def _pulse_output_snapshot( + *, + enabled: bool = False, + period_s: float = 1e-3, +) -> RfPulseOutputSnapshot: + return RfPulseOutputSnapshot( + port_id="rf_out", + interface_id="pulse_in_out", + direction=RfPulseOutputDirection.OUTPUT, + enabled=enabled, + low_level_v=0.0, + high_level_v=3.3, + output_impedance_ohm=600.0, + source=RfPulseSource.INTERNAL, + mode=RfPulseMode.SINGLE, + period_s=period_s, + width_s=100e-6, + polarity=RfPulsePolarity.NORMAL, + pulse_state=RfPulseState.DISABLED, + ) + + +class _Driver: + def __init__( + self, + rf_snapshots: list[RfSourceSnapshot], + pulse_output_snapshots: list[RfPulseOutputSnapshot], + *, + raise_after_write: bool = False, + ) -> None: + self.rf_snapshots = list(rf_snapshots) + self.pulse_output_snapshots = list(pulse_output_snapshots) + self.raise_after_write = raise_after_write + self.calls: list[str] = [] + self.requests: list[RfPulseOutputRequest] = [] + + def close(self) -> None: + self.calls.append("close") + + def get_rf_snapshot(self) -> RfSourceSnapshot: + self.calls.append("snapshot") + if not self.rf_snapshots: + raise AssertionError("unexpected RF snapshot") + return self.rf_snapshots.pop(0) + + def get_rf_pulse_output_snapshot( + self, + port_id: str, + interface_id: str, + ) -> RfPulseOutputSnapshot: + self.calls.append("pulse_output_snapshot") + assert (port_id, interface_id) == ("rf_out", "pulse_in_out") + if not self.pulse_output_snapshots: + raise AssertionError("unexpected Pulse-output snapshot") + return self.pulse_output_snapshots.pop(0) + + def set_rf_pulse_output(self, request: RfPulseOutputRequest) -> None: + self.calls.append("set_pulse_output") + self.requests.append(request) + if self.raise_after_write: + raise ConfigError("fake Pulse-output write failed after transmission") + + +def _request(*, enabled: bool = True) -> RfPulseOutputRequest: + return RfPulseOutputRequest( + port_id="rf_out", + interface_id="pulse_in_out", + enabled=enabled, + ) + + +def _service( + rf_snapshots: list[RfSourceSnapshot], + pulse_output_snapshots: list[RfPulseOutputSnapshot], + *, + access: str = "read_write", + descriptor: SimpleNamespace | None = None, + raise_after_write: bool = False, +) -> tuple[RfSourceService, _Driver]: + driver = _Driver( + rf_snapshots, + pulse_output_snapshots, + raise_after_write=raise_after_write, + ) + service = RfSourceService( + config=_config(access=access), + logger=CommandLogger(), + session=driver, + descriptor=descriptor + or _descriptor( + "rf_source.idn", + "rf_source.snapshot", + "rf_source.pulse_configure", + "rf_source.pulse_output", + ), + session_state=InstrumentSessionState(), + ) + return service, driver + + +def test_pulse_output_enable_uses_one_write_and_independent_readbacks() -> None: + request = _request() + service, driver = _service( + [_rf_snapshot(), _rf_snapshot()], + [_pulse_output_snapshot(), _pulse_output_snapshot(enabled=True)], + ) + + result, artifact = service.set_pulse_output_with_artifact(request) + + assert result.enabled is True + assert result.write_completed is True + assert driver.requests == [request] + assert driver.calls == [ + "snapshot", + "pulse_output_snapshot", + "set_pulse_output", + "snapshot", + "pulse_output_snapshot", + ] + assert artifact["operation"] == "rf_source.pulse_output_enable" + assert artifact["postcondition_pulse_output_snapshot"]["enabled"] is True + assert service.session_state is not None + assert service.session_state.health is SessionHealth.HEALTHY + + +@pytest.mark.parametrize( + ("snapshot", "message"), + ( + (_rf_snapshot(output_enabled=True), "target RF output OFF"), + (_rf_snapshot(modulation=RfModulationState.ENABLED), "modulation disabled"), + (_rf_snapshot(pulse=RfPulseState.ENABLED), "Pulse disabled"), + (_rf_snapshot(sweep=RfSweepState.ENABLED), "Sweep disabled"), + (_rf_snapshot(protection_codes=("overtemperature",)), "active protection"), + ), +) +def test_pulse_output_enable_rejects_unsafe_rf_preflight_without_write( + snapshot: RfSourceSnapshot, + message: str, +) -> None: + service, driver = _service([snapshot], [_pulse_output_snapshot()]) + + with pytest.raises(ConfigError, match=message): + service.set_pulse_output(_request()) + + assert driver.requests == [] + assert driver.calls == ["snapshot", "pulse_output_snapshot"] + assert service.session_state is not None + assert service.session_state.health is SessionHealth.HEALTHY + + +def test_pulse_output_enable_rejects_profile_mismatch_without_write() -> None: + service, driver = _service([_rf_snapshot()], [_pulse_output_snapshot(period_s=2e-3)]) + + with pytest.raises(ConfigError, match="does not match the descriptor profile"): + service.set_pulse_output(_request()) + + assert driver.requests == [] + assert driver.calls == ["snapshot", "pulse_output_snapshot"] + + +def test_pulse_output_disable_remains_available_for_a_drifted_profile() -> None: + request = _request(enabled=False) + service, driver = _service( + [_rf_snapshot(output_enabled=True), _rf_snapshot(output_enabled=True)], + [_pulse_output_snapshot(enabled=True, period_s=2e-3), _pulse_output_snapshot(enabled=False, period_s=2e-3)], + ) + + result = service.set_pulse_output(request) + + assert result.enabled is False + assert result.write_completed is True + assert driver.requests == [request] + assert driver.calls == [ + "snapshot", + "pulse_output_snapshot", + "set_pulse_output", + "snapshot", + "pulse_output_snapshot", + ] + + +def test_pulse_output_idempotence_preserves_the_readback_evidence_without_a_write() -> None: + request = _request() + service, driver = _service([_rf_snapshot()], [_pulse_output_snapshot(enabled=True)]) + + result, artifact = service.set_pulse_output_with_artifact(request) + + assert result.write_completed is False + assert driver.requests == [] + assert driver.calls == ["snapshot", "pulse_output_snapshot"] + assert artifact["preflight_pulse_output_snapshot"] == artifact[ + "postcondition_pulse_output_snapshot" + ] + + +def test_pulse_output_checks_capability_and_access_before_driver_io() -> None: + missing, missing_driver = _service( + [], + [], + descriptor=_descriptor("rf_source.idn", "rf_source.snapshot", "rf_source.pulse_configure"), + ) + with pytest.raises(ConfigError, match="rf_source.pulse_output"): + missing.set_pulse_output(_request()) + assert missing_driver.calls == [] + + read_only, read_only_driver = _service([], [], access="read_only") + with pytest.raises(AccessDeniedError, match="rf_source.pulse_output_enable"): + read_only.set_pulse_output(_request()) + assert read_only_driver.calls == [] + + +def test_pulse_output_write_failure_is_not_retried_and_degrades_session() -> None: + service, driver = _service( + [_rf_snapshot()], + [_pulse_output_snapshot()], + raise_after_write=True, + ) + + with pytest.raises(ConfigError, match="failed after transmission"): + service.set_pulse_output(_request()) + + assert driver.requests == [_request()] + assert driver.calls == ["snapshot", "pulse_output_snapshot", "set_pulse_output"] + assert service.session_state is not None + assert service.session_state.health is SessionHealth.UNCERTAIN + + +def test_pulse_output_postcondition_mismatch_is_not_retried_and_degrades_session() -> None: + service, driver = _service( + [_rf_snapshot(), _rf_snapshot()], + [_pulse_output_snapshot(), _pulse_output_snapshot(enabled=False)], + ) + + with pytest.raises(ConfigError, match="does not match requested"): + service.set_pulse_output(_request()) + + assert driver.requests == [_request()] + assert service.session_state is not None + assert service.session_state.health is SessionHealth.UNCERTAIN diff --git a/tests/test_rf_source_pulse_service.py b/tests/test_rf_source_pulse_service.py new file mode 100644 index 0000000..abab9ca --- /dev/null +++ b/tests/test_rf_source_pulse_service.py @@ -0,0 +1,346 @@ +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from wavebench.config import ( + AutoscaleConfig, + ConnectionConfig, + OutputConfig, + RfSourceConfig, + ScopeConfig, + WaveBenchConfig, + WaveformConfig, +) +from wavebench.errors import AccessDeniedError, ConfigError +from wavebench.instruments.rf_source_extensions import ( + RF_SOURCE_CONTRACT_VERSION, + RfFeature, + RfFeatureCapability, + RfFeatureDirection, + RfModulationState, + RfObserved, + RfOutputPortProfile, + RfPortSnapshot, + RfProtectionStatus, + RfPulseConfigureRequest, + RfPulseMode, + RfPulseModeProfile, + RfPulsePolarity, + RfPulseProfile, + RfPulseSnapshot, + RfPulseSource, + RfPulseState, + RfSourceDescriptorExtensions, + RfSourceSnapshot, + RfSourceTopology, + RfSweepState, +) +from wavebench.logging import CommandLogger +from wavebench.services.rf_source_service import RfSourceService +from wavebench.transport.session import InstrumentSessionState, SessionHealth + + +def _config(*, access: str = "read_write") -> WaveBenchConfig: + return WaveBenchConfig( + connection=ConnectionConfig("lan", "TCPIP::scope::INSTR", 1_000, 1_000), + 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"), + rf_source=RfSourceConfig( + driver="example.rf.pulse", + resource="TCPIP::rf::INSTR", + access=access, # type: ignore[arg-type] + ), + ) + + +def _pulse_profile() -> RfPulseProfile: + return RfPulseProfile( + state_readable=True, + configuration_readable=True, + mode_profiles=( + RfPulseModeProfile( + source=RfPulseSource.INTERNAL, + mode=RfPulseMode.SINGLE, + polarities=(RfPulsePolarity.INVERTED, RfPulsePolarity.NORMAL), + period_min_s=40e-9, + period_max_s=170.0, + width_min_s=10e-9, + width_max_s=170.0 - 10e-9, + minimum_off_time_s=10e-9, + ), + ), + ) + + +def _descriptor(*capabilities: str, profile: RfPulseProfile | None = None) -> SimpleNamespace: + return SimpleNamespace( + driver_id="example.rf.pulse", + capabilities=capabilities, + rf_source_extensions=RfSourceDescriptorExtensions( + contract_version=RF_SOURCE_CONTRACT_VERSION, + topology=RfSourceTopology( + ( + RfOutputPortProfile( + port_id="rf_out", + frequency_min_hz=9_000.0, + frequency_max_hz=3_000_000_000.0, + power_min_dbm=-110.0, + power_max_dbm=20.0, + power_reference_impedance_ohm=50.0, + ), + ) + ), + features=( + RfFeatureCapability( + feature=RfFeature.PULSE, + directions=(RfFeatureDirection.CONFIGURE, RfFeatureDirection.READ), + port_ids=("rf_out",), + profile=profile or _pulse_profile(), + ), + ), + ), + ) + + +def _rf_snapshot( + *, + output_enabled: bool = False, + modulation: RfModulationState = RfModulationState.DISABLED, + pulse: RfPulseState = RfPulseState.DISABLED, + sweep: RfSweepState = RfSweepState.DISABLED, + protection_codes: tuple[str, ...] = (), +) -> RfSourceSnapshot: + return RfSourceSnapshot( + ports=( + RfPortSnapshot( + port_id="rf_out", + frequency_hz=RfObserved.value_of(1_000_000.0), + power_dbm=RfObserved.value_of(-50.0), + output_enabled=RfObserved.value_of(output_enabled), + modulation=RfObserved.value_of(modulation), + pulse=RfObserved.value_of(pulse), + sweep=RfObserved.value_of(sweep), + ), + ), + protection=RfObserved.value_of(RfProtectionStatus(active_codes=protection_codes)), + ) + + +def _pulse_snapshot( + *, + period_s: float = 1e-3, + width_s: float = 100e-6, + polarity: RfPulsePolarity = RfPulsePolarity.NORMAL, + source: RfPulseSource = RfPulseSource.INTERNAL, + mode: RfPulseMode = RfPulseMode.SINGLE, + state: RfPulseState = RfPulseState.DISABLED, +) -> RfPulseSnapshot: + return RfPulseSnapshot( + port_id="rf_out", + source=source, + mode=mode, + period_s=period_s, + width_s=width_s, + polarity=polarity, + state=state, + ) + + +class _Driver: + def __init__( + self, + rf_snapshots: list[RfSourceSnapshot], + pulse_snapshots: list[RfPulseSnapshot], + *, + raise_after_write: bool = False, + ) -> None: + self.rf_snapshots = list(rf_snapshots) + self.pulse_snapshots = list(pulse_snapshots) + self.raise_after_write = raise_after_write + self.calls: list[str] = [] + self.requests: list[RfPulseConfigureRequest] = [] + + def close(self) -> None: + self.calls.append("close") + + def get_rf_snapshot(self) -> RfSourceSnapshot: + self.calls.append("snapshot") + if not self.rf_snapshots: + raise AssertionError("unexpected RF snapshot") + return self.rf_snapshots.pop(0) + + def get_rf_pulse_snapshot(self, port_id: str) -> RfPulseSnapshot: + self.calls.append("pulse_snapshot") + assert port_id == "rf_out" + if not self.pulse_snapshots: + raise AssertionError("unexpected pulse snapshot") + return self.pulse_snapshots.pop(0) + + def configure_rf_pulse(self, request: RfPulseConfigureRequest) -> None: + self.calls.append("configure_pulse") + self.requests.append(request) + if self.raise_after_write: + raise ConfigError("fake pulse write failed after transmission") + + +def _request(*, width_s: float = 100e-6) -> RfPulseConfigureRequest: + return RfPulseConfigureRequest( + port_id="rf_out", + period_s=1e-3, + width_s=width_s, + polarity=RfPulsePolarity.NORMAL, + ) + + +def _service( + rf_snapshots: list[RfSourceSnapshot], + pulse_snapshots: list[RfPulseSnapshot], + *, + access: str = "read_write", + descriptor: SimpleNamespace | None = None, + raise_after_write: bool = False, +) -> tuple[RfSourceService, _Driver]: + driver = _Driver( + rf_snapshots, + pulse_snapshots, + raise_after_write=raise_after_write, + ) + service = RfSourceService( + config=_config(access=access), + logger=CommandLogger(), + session=driver, + descriptor=descriptor + or _descriptor( + "rf_source.idn", + "rf_source.snapshot", + "rf_source.pulse_configure", + ), + session_state=InstrumentSessionState(), + ) + return service, driver + + +def test_pulse_configuration_uses_one_write_and_independent_readbacks() -> None: + request = _request() + service, driver = _service( + [_rf_snapshot(), _rf_snapshot()], + [_pulse_snapshot()], + ) + + result, artifact = service.configure_pulse_with_artifact(request) + + assert result.period_s == request.period_s + assert result.width_s == request.width_s + assert result.polarity is request.polarity + assert driver.requests == [request] + assert driver.calls == ["snapshot", "configure_pulse", "snapshot", "pulse_snapshot"] + assert artifact["operation"] == "rf_source.pulse_configure" + assert artifact["postcondition_pulse_snapshot"]["state"] == "disabled" + assert service.session_state is not None + assert service.session_state.health is SessionHealth.HEALTHY + + +@pytest.mark.parametrize( + ("snapshot", "message"), + ( + (_rf_snapshot(output_enabled=True), "target RF output OFF"), + (_rf_snapshot(modulation=RfModulationState.ENABLED), "modulation disabled"), + (_rf_snapshot(pulse=RfPulseState.ENABLED), "Pulse disabled"), + (_rf_snapshot(sweep=RfSweepState.ENABLED), "Sweep disabled"), + (_rf_snapshot(protection_codes=("overtemperature",)), "active protection"), + ), +) +def test_pulse_configuration_rejects_unsafe_preflight_without_write( + snapshot: RfSourceSnapshot, + message: str, +) -> None: + service, driver = _service([snapshot], []) + + with pytest.raises(ConfigError, match=message): + service.configure_pulse(_request()) + + assert driver.requests == [] + assert driver.calls == ["snapshot"] + assert service.session_state is not None + assert service.session_state.health is SessionHealth.HEALTHY + + +def test_pulse_configuration_checks_capability_access_and_static_profile_before_driver_io() -> None: + missing, missing_driver = _service( + [], + [], + descriptor=_descriptor("rf_source.idn", "rf_source.snapshot"), + ) + with pytest.raises(ConfigError, match="rf_source.pulse_configure"): + missing.configure_pulse(_request()) + assert missing_driver.calls == [] + + read_only, read_only_driver = _service([], [], access="read_only") + with pytest.raises(AccessDeniedError, match="rf_source.pulse_configure"): + read_only.configure_pulse(_request()) + assert read_only_driver.calls == [] + + range_service, range_driver = _service([], []) + with pytest.raises(ConfigError, match="outside the descriptor range"): + range_service.configure_pulse(_request(width_s=1e-9)) + assert range_driver.calls == [] + + off_time_service, off_time_driver = _service([], []) + with pytest.raises(ConfigError, match="minimum off time"): + off_time_service.configure_pulse( + RfPulseConfigureRequest( + port_id="rf_out", + period_s=40e-9, + width_s=35e-9, + polarity=RfPulsePolarity.NORMAL, + ) + ) + assert off_time_driver.calls == [] + + +@pytest.mark.parametrize( + "pulse_snapshot", + ( + _pulse_snapshot(width_s=99e-6), + _pulse_snapshot(source=RfPulseSource.EXTERNAL), + _pulse_snapshot(mode=RfPulseMode.TRAIN), + _pulse_snapshot(state=RfPulseState.ENABLED), + ), +) +def test_pulse_configuration_mismatch_is_not_retried_and_degrades_session( + pulse_snapshot: RfPulseSnapshot, +) -> None: + service, driver = _service( + [_rf_snapshot(), _rf_snapshot()], + [pulse_snapshot], + ) + + with pytest.raises(ConfigError): + service.configure_pulse(_request()) + + assert driver.requests == [_request()] + assert driver.calls == ["snapshot", "configure_pulse", "snapshot", "pulse_snapshot"] + assert service.session_state is not None + assert service.session_state.health is SessionHealth.UNCERTAIN + + +def test_pulse_configuration_write_failure_is_not_retried_and_degrades_session() -> None: + service, driver = _service( + [_rf_snapshot()], + [], + raise_after_write=True, + ) + + with pytest.raises(ConfigError, match="failed after transmission"): + service.configure_pulse(_request()) + + assert driver.requests == [_request()] + assert driver.calls == ["snapshot", "configure_pulse"] + assert service.session_state is not None + assert service.session_state.health is SessionHealth.UNCERTAIN diff --git a/tests/test_rf_source_run.py b/tests/test_rf_source_run.py new file mode 100644 index 0000000..bf918b5 --- /dev/null +++ b/tests/test_rf_source_run.py @@ -0,0 +1,1201 @@ +from __future__ import annotations + +from contextlib import contextmanager +import json +from pathlib import Path +from tempfile import TemporaryDirectory +from types import SimpleNamespace +from unittest.mock import Mock, patch + +import pytest + +from wavebench.config import ( + AutoscaleConfig, + ConnectionConfig, + OutputConfig, + RfSourceConfig, + ScopeConfig, + WaveBenchConfig, + WaveformConfig, +) +from wavebench.errors import ConfigError +from wavebench.instruments.rf_source_extensions import ( + RfCwRequest, + RfCwResult, + RfModulationDisableRequest, + RfModulationDisableResult, + RfModulationKind, + RfModulationRequest, + RfModulationResult, + RfModulationSnapshot, + RfModulationSource, + RfModulationState, + RfModulationStateSnapshot, + RfModulationWaveform, + RfObserved, + RfPortSnapshot, + RfProtectionStatus, + RfPulseConfigureRequest, + RfPulseConfigureResult, + RfPulseMode, + RfPulsePolarity, + RfPulseSnapshot, + RfPulseSource, + RfPulseState, + RfSourceSnapshot, + RfSweepConfigureRequest, + RfSweepConfigureResult, + RfSweepDirection, + RfSweepShape, + RfSweepSnapshot, + RfSweepSpacing, + RfSweepState, + RfSweepType, + RfExternalGatePolarity, + RfExternalTriggerEdge, + RfPulseTriggerMode, + RfSweepMode, + RfSweepTriggerMode, + RfTriggerSnapshot, + rf_source_cw_operation_artifact, + rf_source_modulation_disable_operation_artifact, + rf_source_modulation_operation_artifact, + rf_source_pulse_operation_artifact, + rf_source_sweep_operation_artifact, + RfOutputRequest, + RfOutputResult, + rf_source_output_operation_artifact, + rf_source_snapshot_operation_artifact, + rf_source_trigger_snapshot_operation_artifact, +) +from wavebench.logging import CommandLogger +from wavebench.services.execution_intent import build_execution_intent +from wavebench.services.run_artifacts import RunStepRecord, write_run_files +from wavebench.services.run_plan import STEP_SCHEMAS, load_run_plan +from wavebench.services.run_service import RunInstrumentServices, RunService + + +def _config(directory: str, *, access: str = "read_only") -> WaveBenchConfig: + return WaveBenchConfig( + connection=ConnectionConfig("lan", "TCPIP::scope::INSTR", 1_000, 1_000), + scope=ScopeConfig("rtm2032", None, 1, False, True), + autoscale=AutoscaleConfig(True, True), + waveform=WaveformConfig("real", "lsbf", "dmax"), + output=OutputConfig( + Path(directory) / "data" / "raw", + "timestamp_label", + True, + True, + True, + True, + False, + ), + source_path=Path(directory) / "wavebench.toml", + rf_source=RfSourceConfig( + driver="example.rf1", + resource="TCPIP::rf::INSTR", + access=access, # type: ignore[arg-type] + ), + ) + + +def _plan(directory: str): + path = Path(directory) / "plan.toml" + path.write_text('[[steps]]\nkind = "rf_source.status"\n', encoding="utf-8") + return load_run_plan(path) + + +def _cw_plan(directory: str, *, kind: str, field: str, value: float): + path = Path(directory) / "plan.toml" + path.write_text( + f'[[steps]]\nkind = "{kind}"\nport_id = "rf_out"\n{field} = {value}\n', + encoding="utf-8", + ) + return load_run_plan(path) + + +def _trigger_plan(directory: str): + path = Path(directory) / "plan.toml" + path.write_text( + '[[steps]]\nkind = "rf_source.trigger_status"\nport_id = "rf_out"\n', + encoding="utf-8", + ) + return load_run_plan(path) + + +def _output_plan(directory: str, *, kind: str): + path = Path(directory) / "plan.toml" + path.write_text( + f'[[steps]]\nkind = "{kind}"\nport_id = "rf_out"\n', + encoding="utf-8", + ) + return load_run_plan(path) + + +def _modulation_plan( + directory: str, + *, + modulation_kind: str = "am", + value_field: str = "depth_percent", + value: float = 50.0, +): + path = Path(directory) / "plan.toml" + path.write_text( + "[[steps]]\n" + 'kind = "rf_source.modulation_configure"\n' + 'port_id = "rf_out"\n' + f'modulation_kind = "{modulation_kind}"\n' + f"{value_field} = {value}\n" + "internal_frequency_hz = 1000\n", + encoding="utf-8", + ) + return load_run_plan(path) + + +def _modulation_disable_plan(directory: str, *, modulation_kind: str = "am"): + path = Path(directory) / "plan.toml" + path.write_text( + "[[steps]]\n" + 'kind = "rf_source.modulation_disable"\n' + 'port_id = "rf_out"\n' + f'modulation_kind = "{modulation_kind}"\n', + encoding="utf-8", + ) + return load_run_plan(path) + + +def _pulse_plan(directory: str): + path = Path(directory) / "plan.toml" + path.write_text( + "[[steps]]\n" + 'kind = "rf_source.pulse_configure"\n' + 'port_id = "rf_out"\n' + "period_s = 0.001\n" + "width_s = 0.0001\n" + 'polarity = "inverted"\n', + encoding="utf-8", + ) + return load_run_plan(path) + + +def _sweep_plan(directory: str): + path = Path(directory) / "plan.toml" + path.write_text( + "[[steps]]\n" + 'kind = "rf_source.sweep_configure"\n' + 'port_id = "rf_out"\n' + "start_frequency_hz = 1000000\n" + "stop_frequency_hz = 2000000\n" + "points = 11\n" + "dwell_s = 0.02\n", + encoding="utf-8", + ) + return load_run_plan(path) + + +def _snapshot() -> RfSourceSnapshot: + return RfSourceSnapshot( + ports=( + RfPortSnapshot( + port_id="rf_out", + frequency_hz=RfObserved.value_of(1_000_000.0), + power_dbm=RfObserved.value_of(-30.0), + output_enabled=RfObserved.value_of(False), + modulation=RfObserved.value_of(RfModulationState.DISABLED), + pulse=RfObserved.value_of(RfPulseState.DISABLED), + sweep=RfObserved.value_of(RfSweepState.DISABLED), + ), + ), + protection=RfObserved.value_of(RfProtectionStatus(active_codes=())), + ) + + +def _trigger_snapshot() -> RfTriggerSnapshot: + return RfTriggerSnapshot( + port_id="rf_out", + pulse_trigger_mode=RfPulseTriggerMode.AUTOMATIC, + pulse_external_trigger_edge=RfExternalTriggerEdge.POSITIVE, + pulse_external_gate_polarity=RfExternalGatePolarity.NORMAL, + sweep_mode=RfSweepMode.CONTINUOUS, + sweep_period_trigger_mode=RfSweepTriggerMode.AUTOMATIC, + sweep_point_trigger_mode=RfSweepTriggerMode.AUTOMATIC, + ) + + +def _descriptor(*capabilities: str) -> SimpleNamespace: + return SimpleNamespace( + driver_id="example.rf1", + kind="rf_source", + capabilities=capabilities, + ) + + +def test_rf_source_status_schema_and_intent_are_read_only() -> None: + with TemporaryDirectory() as directory: + plan = _plan(directory) + intent = build_execution_intent(plan, _config(directory)) + + assert STEP_SCHEMAS["rf_source.status"].required == () + assert intent.operations == ( + { + "step_index": 0, + "step_kind": "rf_source.status", + "operation": "rf_source.snapshot", + "instrument_kind": "rf_source", + "effect": "stateful_read", + "lease_mode": "exclusive", + "changed_fields": [], + "restore_coverage": "none-read-only", + "session_purpose": "normal", + "required_verified_fields": [], + "verification_fields": [], + "timeout_source": "connection.timeout_ms", + "risk_flags": ["state_dependent_query"], + "parameters": {}, + "policy": {"on_failure": "stop", "safety_gate": {}}, + }, + ) + + +def test_rf_trigger_status_schema_and_intent_are_read_only() -> None: + with TemporaryDirectory() as directory: + plan = _trigger_plan(directory) + intent = build_execution_intent(plan, _config(directory)) + + assert STEP_SCHEMAS["rf_source.trigger_status"].required == ("port_id",) + assert intent.operations == ( + { + "step_index": 0, + "step_kind": "rf_source.trigger_status", + "operation": "rf_source.trigger_snapshot", + "instrument_kind": "rf_source", + "effect": "stateful_read", + "lease_mode": "exclusive", + "changed_fields": [], + "restore_coverage": "none-read-only", + "session_purpose": "normal", + "required_verified_fields": [], + "verification_fields": [], + "timeout_source": "connection.timeout_ms", + "risk_flags": ["state_dependent_query", "trigger_configuration"], + "parameters": {"port_id": "rf_out"}, + "policy": {"on_failure": "stop", "safety_gate": {}}, + }, + ) + + +def test_rf_trigger_status_requires_capability_before_session_opens() -> None: + with TemporaryDirectory() as directory: + service = RunService(config=_config(directory), logger=CommandLogger()) + with patch( + "wavebench.services.run_service.resolve_instrument_descriptor", + return_value=_descriptor("rf_source.idn"), + ), patch.object(service, "_run_instrument_services") as open_services: + with pytest.raises(ConfigError, match="rf_source.trigger_snapshot"): + service.run(_trigger_plan(directory)) + + open_services.assert_not_called() + + +def test_rf_trigger_status_run_uses_isolated_service_and_artifact_namespace() -> None: + with TemporaryDirectory() as directory: + plan = _trigger_plan(directory) + snapshot = _trigger_snapshot() + artifact = rf_source_trigger_snapshot_operation_artifact(snapshot) + rf_service = SimpleNamespace( + trigger_snapshot_with_artifact=Mock(return_value=(snapshot, artifact)), + audit_snapshot=lambda: None, + ) + + class OfflineRfRunService(RunService): + @contextmanager + def _run_instrument_services(self, run_plan): + del run_plan + yield RunInstrumentServices(rf_source=rf_service) + + def _run_safety_guards(self, run_plan, *, services=None): + del run_plan, services + + with patch( + "wavebench.services.run_service.resolve_instrument_descriptor", + return_value=_descriptor("rf_source.idn", "rf_source.trigger_snapshot"), + ): + result = OfflineRfRunService(config=_config(directory), logger=CommandLogger()).run(plan) + + rf_service.trigger_snapshot_with_artifact.assert_called_once_with("rf_out") + assert result.steps[0].artifact == {"rf_source_operation": artifact} + run_data = json.loads(result.run_json_path.read_text(encoding="utf-8")) + assert run_data["rf_source_operations"] == [artifact] + assert "source_operations" not in run_data + + +def test_rf_sweep_configure_execution_intent_uses_its_declared_operation() -> None: + with TemporaryDirectory() as directory: + intent = build_execution_intent(_sweep_plan(directory), _config(directory, access="read_write")) + + assert intent.operations[0]["operation"] == "rf_source.sweep_configure" + assert intent.operations[0]["effect"] == "write" + + +def test_rf_source_status_requires_snapshot_capability_before_session_opens() -> None: + with TemporaryDirectory() as directory: + service = RunService(config=_config(directory), logger=CommandLogger()) + with patch( + "wavebench.services.run_service.resolve_instrument_descriptor", + return_value=_descriptor("rf_source.idn"), + ), patch.object(service, "_run_instrument_services") as open_services: + with pytest.raises(ConfigError, match="rf_source.snapshot"): + service.run(_plan(directory)) + + open_services.assert_not_called() + + +def test_rf_source_status_verify_and_run_use_the_isolated_service_and_artifact_namespace() -> None: + with TemporaryDirectory() as directory: + plan = _plan(directory) + descriptor = _descriptor("rf_source.idn", "rf_source.snapshot") + snapshot = _snapshot() + service = RunService(config=_config(directory), logger=CommandLogger()) + + with patch( + "wavebench.services.run_service.resolve_instrument_descriptor", + return_value=descriptor, + ), patch("wavebench.services.run_service.RfSourceService") as rf_service_type: + rf_service_type.return_value.idn.return_value = "EXAMPLE,RF1,0,1" + records = service.verify(plan) + + assert [(record.instrument, record.idn) for record in records] == [ + ("rf_source", "EXAMPLE,RF1,0,1"), + ] + + rf_service = SimpleNamespace(snapshot=Mock(return_value=snapshot), audit_snapshot=lambda: None) + + class OfflineRfRunService(RunService): + @contextmanager + def _run_instrument_services(self, run_plan): + del run_plan + yield RunInstrumentServices(rf_source=rf_service) + + def _run_safety_guards(self, run_plan, *, services=None): + del run_plan, services + + with patch( + "wavebench.services.run_service.resolve_instrument_descriptor", + return_value=descriptor, + ): + result = OfflineRfRunService(config=_config(directory), logger=CommandLogger()).run(plan) + + rf_service.snapshot.assert_called_once_with() + run_data = json.loads(result.run_json_path.read_text(encoding="utf-8")) + artifact = rf_source_snapshot_operation_artifact(snapshot) + assert result.steps[0].artifact == {"rf_source_operation": artifact} + assert run_data["rf_source_operations"] == [artifact] + assert "source_operations" not in run_data + + +def test_rf_source_operation_artifacts_are_validated_in_a_separate_root_namespace() -> None: + with TemporaryDirectory() as directory: + plan = _plan(directory) + artifact = rf_source_snapshot_operation_artifact(_snapshot()) + run_json_path = Path(directory) / "run.json" + write_run_files( + plan=plan, + run_json_path=run_json_path, + summary_csv_path=Path(directory) / "summary.csv", + status="ok", + records=[ + RunStepRecord( + index=0, + kind="rf_source.status", + status="ok", + fields={}, + artifact={"rf_source_operation": artifact}, + ) + ], + error=None, + rf_source_operations=[artifact], + ) + + data = json.loads(run_json_path.read_text(encoding="utf-8")) + assert data["rf_source_operations"] == [artifact] + assert "source_operations" not in data + + with pytest.raises(ValueError, match="rf_source"): + write_run_files( + plan=plan, + run_json_path=run_json_path, + summary_csv_path=Path(directory) / "summary.csv", + status="ok", + records=[], + error=None, + rf_source_operations=[ + {"schema": "wavebench.rf_source.operation.v1", "operation": "source.status"} + ], + ) + + +def test_rf_source_cw_steps_require_capability_before_opening_a_session() -> None: + with TemporaryDirectory() as directory: + service = RunService(config=_config(directory, access="read_write"), logger=CommandLogger()) + plan = _cw_plan( + directory, + kind="rf_source.set_frequency", + field="frequency_hz", + value=2_000_000.0, + ) + with patch( + "wavebench.services.run_service.resolve_instrument_descriptor", + return_value=_descriptor("rf_source.idn", "rf_source.snapshot"), + ), patch.object(service, "_run_instrument_services") as open_services: + with pytest.raises(ConfigError, match="rf_source.cw_configure"): + service.run(plan) + + open_services.assert_not_called() + + +def test_rf_source_cw_step_rejects_read_only_access_before_opening_a_session() -> None: + with TemporaryDirectory() as directory: + service = RunService(config=_config(directory), logger=CommandLogger()) + plan = _cw_plan( + directory, + kind="rf_source.set_frequency", + field="frequency_hz", + value=2_000_000.0, + ) + with patch( + "wavebench.services.run_service.resolve_instrument_descriptor", + return_value=_descriptor( + "rf_source.idn", + "rf_source.snapshot", + "rf_source.cw_configure", + ), + ), patch.object(service, "_run_instrument_services") as open_services: + with pytest.raises(ConfigError, match="access policy 'read_only'"): + service.run(plan) + + open_services.assert_not_called() + + +def test_rf_source_cw_step_has_write_intent_and_separate_artifact_namespace() -> None: + with TemporaryDirectory() as directory: + plan = _cw_plan( + directory, + kind="rf_source.set_power_dbm", + field="power_dbm", + value=-10.0, + ) + config = _config(directory, access="read_write") + intent = build_execution_intent(plan, config) + assert intent.operations[0]["operation"] == "rf_source.set_power_dbm" + assert intent.operations[0]["effect"] == "write" + assert intent.operations[0]["parameters"] == {"port_id": "rf_out", "power_dbm": -10.0} + + preflight = _snapshot() + postcondition = RfSourceSnapshot( + ports=( + RfPortSnapshot( + port_id="rf_out", + frequency_hz=RfObserved.value_of(1_000_000.0), + power_dbm=RfObserved.value_of(-10.0), + output_enabled=RfObserved.value_of(False), + modulation=RfObserved.value_of(RfModulationState.DISABLED), + pulse=RfObserved.value_of(RfPulseState.DISABLED), + sweep=RfObserved.value_of(RfSweepState.DISABLED), + ), + ), + protection=RfObserved.value_of(RfProtectionStatus(active_codes=())), + ) + result_value = RfCwResult(port_id="rf_out", power_dbm=-10.0) + artifact = rf_source_cw_operation_artifact( + request=RfCwRequest(port_id="rf_out", power_dbm=-10.0), + result=result_value, + preflight_snapshot=preflight, + postcondition_snapshot=postcondition, + ) + rf_service = SimpleNamespace( + configure_cw_with_artifact=Mock(return_value=(result_value, artifact)), + audit_snapshot=lambda: None, + ) + + class OfflineRfRunService(RunService): + @contextmanager + def _run_instrument_services(self, run_plan): + del run_plan + yield RunInstrumentServices(rf_source=rf_service) + + def _run_safety_guards(self, run_plan, *, services=None): + del run_plan, services + + descriptor = _descriptor( + "rf_source.idn", + "rf_source.snapshot", + "rf_source.cw_configure", + ) + with patch( + "wavebench.services.run_service.resolve_instrument_descriptor", + return_value=descriptor, + ): + run_result = OfflineRfRunService(config=config, logger=CommandLogger()).run(plan) + + rf_service.configure_cw_with_artifact.assert_called_once() + assert run_result.steps[0].artifact == {"rf_source_operation": artifact} + run_data = json.loads(run_result.run_json_path.read_text(encoding="utf-8")) + assert run_data["rf_source_operations"] == [artifact] + + +def test_rf_source_modulation_plan_requires_its_matching_value_field() -> None: + with TemporaryDirectory() as directory: + plan = _modulation_plan(directory) + assert plan.steps[0].fields == { + "port_id": "rf_out", + "modulation_kind": "am", + "depth_percent": 50.0, + "internal_frequency_hz": 1_000.0, + } + + invalid_path = Path(directory) / "invalid-plan.toml" + invalid_path.write_text( + "[[steps]]\n" + 'kind = "rf_source.modulation_configure"\n' + 'port_id = "rf_out"\n' + 'modulation_kind = "am"\n' + "frequency_deviation_hz = 1000\n" + "internal_frequency_hz = 1000\n", + encoding="utf-8", + ) + with pytest.raises(ConfigError, match="requires only depth_percent"): + load_run_plan(invalid_path) + + +def test_rf_source_modulation_step_requires_capability_before_opening_a_session() -> None: + with TemporaryDirectory() as directory: + service = RunService(config=_config(directory, access="read_write"), logger=CommandLogger()) + with patch( + "wavebench.services.run_service.resolve_instrument_descriptor", + return_value=_descriptor("rf_source.idn", "rf_source.snapshot"), + ), patch.object(service, "_run_instrument_services") as open_services: + with pytest.raises(ConfigError, match="rf_source.modulation_configure"): + service.run(_modulation_plan(directory)) + + open_services.assert_not_called() + + +def test_rf_source_modulation_step_rejects_read_only_access_before_opening_a_session() -> None: + with TemporaryDirectory() as directory: + service = RunService(config=_config(directory), logger=CommandLogger()) + with patch( + "wavebench.services.run_service.resolve_instrument_descriptor", + return_value=_descriptor( + "rf_source.idn", + "rf_source.snapshot", + "rf_source.modulation_configure", + ), + ), patch.object(service, "_run_instrument_services") as open_services: + with pytest.raises(ConfigError, match="access policy 'read_only'"): + service.run(_modulation_plan(directory)) + + open_services.assert_not_called() + + +def test_rf_source_modulation_step_has_write_intent_and_separate_artifact_namespace() -> None: + with TemporaryDirectory() as directory: + plan = _modulation_plan(directory) + config = _config(directory, access="read_write") + intent = build_execution_intent(plan, config) + assert intent.operations[0]["operation"] == "rf_source.modulation_configure" + assert intent.operations[0]["effect"] == "write" + assert intent.operations[0]["parameters"] == { + "port_id": "rf_out", + "modulation_kind": "am", + "depth_percent": 50.0, + "internal_frequency_hz": 1_000.0, + } + + request = RfModulationRequest( + port_id="rf_out", + kind=RfModulationKind.AM, + depth_percent=50.0, + internal_frequency_hz=1_000.0, + ) + result_value = RfModulationResult( + port_id="rf_out", + kind=RfModulationKind.AM, + depth_percent=50.0, + internal_frequency_hz=1_000.0, + ) + preflight_snapshot = _snapshot() + preflight_modulation_state = RfModulationStateSnapshot(port_id="rf_out") + postcondition_snapshot = RfSourceSnapshot( + ports=( + RfPortSnapshot( + port_id="rf_out", + frequency_hz=RfObserved.value_of(1_000_000.0), + power_dbm=RfObserved.value_of(-30.0), + output_enabled=RfObserved.value_of(False), + modulation=RfObserved.value_of(RfModulationState.ENABLED), + pulse=RfObserved.value_of(RfPulseState.DISABLED), + sweep=RfObserved.value_of(RfSweepState.DISABLED), + ), + ), + protection=RfObserved.value_of(RfProtectionStatus(active_codes=())), + ) + postcondition_modulation_snapshot = RfModulationSnapshot( + port_id="rf_out", + kind=RfModulationKind.AM, + source=RfModulationSource.INTERNAL, + waveform=RfModulationWaveform.SINE, + depth_percent=50.0, + internal_frequency_hz=1_000.0, + enabled_modes=(RfModulationKind.AM,), + global_enabled=True, + ) + artifact = rf_source_modulation_operation_artifact( + request=request, + result=result_value, + preflight_snapshot=preflight_snapshot, + preflight_modulation_state=preflight_modulation_state, + postcondition_snapshot=postcondition_snapshot, + postcondition_modulation_snapshot=postcondition_modulation_snapshot, + ) + rf_service = SimpleNamespace( + configure_modulation_with_artifact=Mock(return_value=(result_value, artifact)), + audit_snapshot=lambda: None, + ) + + class OfflineRfRunService(RunService): + @contextmanager + def _run_instrument_services(self, run_plan): + del run_plan + yield RunInstrumentServices(rf_source=rf_service) + + def _run_safety_guards(self, run_plan, *, services=None): + del run_plan, services + + descriptor = _descriptor( + "rf_source.idn", + "rf_source.snapshot", + "rf_source.modulation_configure", + ) + with patch( + "wavebench.services.run_service.resolve_instrument_descriptor", + return_value=descriptor, + ): + run_result = OfflineRfRunService(config=config, logger=CommandLogger()).run(plan) + + rf_service.configure_modulation_with_artifact.assert_called_once_with(request) + assert run_result.steps[0].artifact == {"rf_source_operation": artifact} + run_data = json.loads(run_result.run_json_path.read_text(encoding="utf-8")) + assert run_data["rf_source_operations"] == [artifact] + + +def test_rf_source_modulation_disable_plan_has_explicit_mode_only_schema_and_intent() -> None: + with TemporaryDirectory() as directory: + plan = _modulation_disable_plan(directory) + assert plan.steps[0].fields == { + "port_id": "rf_out", + "modulation_kind": "am", + } + assert "rf_source.modulation_disable" in STEP_SCHEMAS + intent = build_execution_intent(plan, _config(directory, access="read_write")) + assert intent.operations[0]["operation"] == "rf_source.modulation_disable" + assert intent.operations[0]["parameters"] == { + "port_id": "rf_out", + "modulation_kind": "am", + } + + invalid_path = Path(directory) / "invalid-modulation-disable.toml" + invalid_path.write_text( + "[[steps]]\n" + 'kind = "rf_source.modulation_disable"\n' + 'port_id = "rf_out"\n' + 'modulation_kind = "unknown"\n', + encoding="utf-8", + ) + with pytest.raises(ConfigError, match="must be one of am, fm, pm"): + load_run_plan(invalid_path) + + +def test_rf_source_modulation_disable_step_requires_capability_and_read_write_access() -> None: + with TemporaryDirectory() as directory: + plan = _modulation_disable_plan(directory) + missing_capability = RunService( + config=_config(directory, access="read_write"), + logger=CommandLogger(), + ) + with patch( + "wavebench.services.run_service.resolve_instrument_descriptor", + return_value=_descriptor("rf_source.idn", "rf_source.snapshot"), + ), patch.object(missing_capability, "_run_instrument_services") as open_services: + with pytest.raises(ConfigError, match="rf_source.modulation_disable"): + missing_capability.run(plan) + open_services.assert_not_called() + + read_only = RunService(config=_config(directory), logger=CommandLogger()) + with patch( + "wavebench.services.run_service.resolve_instrument_descriptor", + return_value=_descriptor( + "rf_source.idn", + "rf_source.snapshot", + "rf_source.modulation_disable", + ), + ), patch.object(read_only, "_run_instrument_services") as open_services: + with pytest.raises(ConfigError, match="access policy 'read_only'"): + read_only.run(plan) + open_services.assert_not_called() + + +def test_rf_source_modulation_disable_step_dispatches_typed_artifact() -> None: + with TemporaryDirectory() as directory: + plan = _modulation_disable_plan(directory) + config = _config(directory, access="read_write") + request = RfModulationDisableRequest(port_id="rf_out", kind=RfModulationKind.AM) + result = RfModulationDisableResult( + port_id="rf_out", + kind=RfModulationKind.AM, + write_completed=True, + ) + preflight_snapshot = RfSourceSnapshot( + ports=( + RfPortSnapshot( + port_id="rf_out", + frequency_hz=RfObserved.value_of(1_000_000.0), + power_dbm=RfObserved.value_of(-30.0), + output_enabled=RfObserved.value_of(False), + modulation=RfObserved.value_of(RfModulationState.ENABLED), + pulse=RfObserved.value_of(RfPulseState.DISABLED), + sweep=RfObserved.value_of(RfSweepState.DISABLED), + ), + ), + protection=RfObserved.value_of(RfProtectionStatus(active_codes=())), + ) + preflight_modulation_state = RfModulationStateSnapshot( + port_id="rf_out", + enabled_modes=(RfModulationKind.AM,), + global_enabled=True, + ) + postcondition_modulation_state = RfModulationStateSnapshot(port_id="rf_out") + artifact = rf_source_modulation_disable_operation_artifact( + request=request, + result=result, + preflight_snapshot=preflight_snapshot, + preflight_modulation_state=preflight_modulation_state, + postcondition_snapshot=_snapshot(), + postcondition_modulation_state=postcondition_modulation_state, + ) + rf_service = SimpleNamespace( + disable_modulation_with_artifact=Mock(return_value=(result, artifact)), + audit_snapshot=lambda: None, + ) + + class OfflineRfRunService(RunService): + @contextmanager + def _run_instrument_services(self, run_plan): + del run_plan + yield RunInstrumentServices(rf_source=rf_service) + + def _run_safety_guards(self, run_plan, *, services=None): + del run_plan, services + + descriptor = _descriptor( + "rf_source.idn", + "rf_source.snapshot", + "rf_source.modulation_disable", + ) + with patch( + "wavebench.services.run_service.resolve_instrument_descriptor", + return_value=descriptor, + ): + run_result = OfflineRfRunService(config=config, logger=CommandLogger()).run(plan) + + rf_service.disable_modulation_with_artifact.assert_called_once_with(request) + assert run_result.steps[0].artifact == {"rf_source_operation": artifact} + run_data = json.loads(run_result.run_json_path.read_text(encoding="utf-8")) + assert run_data["rf_source_operations"] == [artifact] + + +def test_rf_source_pulse_plan_normalizes_the_disabled_internal_single_subset() -> None: + with TemporaryDirectory() as directory: + plan = _pulse_plan(directory) + assert plan.steps[0].fields == { + "port_id": "rf_out", + "period_s": 0.001, + "width_s": 0.0001, + "polarity": "inverted", + } + + invalid_width_path = Path(directory) / "invalid-width.toml" + invalid_width_path.write_text( + "[[steps]]\n" + 'kind = "rf_source.pulse_configure"\n' + 'port_id = "rf_out"\n' + "period_s = 0.001\n" + "width_s = 0.001\n" + 'polarity = "normal"\n', + encoding="utf-8", + ) + with pytest.raises(ConfigError, match="width_s must be less than period_s"): + load_run_plan(invalid_width_path) + + invalid_polarity_path = Path(directory) / "invalid-polarity.toml" + invalid_polarity_path.write_text( + "[[steps]]\n" + 'kind = "rf_source.pulse_configure"\n' + 'port_id = "rf_out"\n' + "period_s = 0.001\n" + "width_s = 0.0001\n" + 'polarity = "external"\n', + encoding="utf-8", + ) + with pytest.raises(ConfigError, match="polarity must be one of normal, inverted"): + load_run_plan(invalid_polarity_path) + + +def test_rf_source_pulse_step_requires_capability_before_opening_a_session() -> None: + with TemporaryDirectory() as directory: + service = RunService(config=_config(directory, access="read_write"), logger=CommandLogger()) + with patch( + "wavebench.services.run_service.resolve_instrument_descriptor", + return_value=_descriptor("rf_source.idn", "rf_source.snapshot"), + ), patch.object(service, "_run_instrument_services") as open_services: + with pytest.raises(ConfigError, match="rf_source.pulse_configure"): + service.run(_pulse_plan(directory)) + + open_services.assert_not_called() + + +def test_rf_source_pulse_step_rejects_read_only_access_before_opening_a_session() -> None: + with TemporaryDirectory() as directory: + service = RunService(config=_config(directory), logger=CommandLogger()) + with patch( + "wavebench.services.run_service.resolve_instrument_descriptor", + return_value=_descriptor( + "rf_source.idn", + "rf_source.snapshot", + "rf_source.pulse_configure", + ), + ), patch.object(service, "_run_instrument_services") as open_services: + with pytest.raises(ConfigError, match="access policy 'read_only'"): + service.run(_pulse_plan(directory)) + + open_services.assert_not_called() + + +def test_rf_source_pulse_step_has_write_intent_and_separate_artifact_namespace() -> None: + with TemporaryDirectory() as directory: + plan = _pulse_plan(directory) + config = _config(directory, access="read_write") + intent = build_execution_intent(plan, config) + assert intent.operations[0]["operation"] == "rf_source.pulse_configure" + assert intent.operations[0]["effect"] == "write" + assert intent.operations[0]["parameters"] == { + "port_id": "rf_out", + "period_s": 0.001, + "width_s": 0.0001, + "polarity": "inverted", + } + + request = RfPulseConfigureRequest( + port_id="rf_out", + period_s=0.001, + width_s=0.0001, + polarity=RfPulsePolarity.INVERTED, + ) + result_value = RfPulseConfigureResult( + port_id="rf_out", + period_s=0.001, + width_s=0.0001, + polarity=RfPulsePolarity.INVERTED, + ) + preflight_snapshot = _snapshot() + postcondition_snapshot = _snapshot() + postcondition_pulse_snapshot = RfPulseSnapshot( + port_id="rf_out", + source=RfPulseSource.INTERNAL, + mode=RfPulseMode.SINGLE, + period_s=0.001, + width_s=0.0001, + polarity=RfPulsePolarity.INVERTED, + state=RfPulseState.DISABLED, + ) + artifact = rf_source_pulse_operation_artifact( + request=request, + result=result_value, + preflight_snapshot=preflight_snapshot, + postcondition_snapshot=postcondition_snapshot, + postcondition_pulse_snapshot=postcondition_pulse_snapshot, + ) + rf_service = SimpleNamespace( + configure_pulse_with_artifact=Mock(return_value=(result_value, artifact)), + audit_snapshot=lambda: None, + ) + + class OfflineRfRunService(RunService): + @contextmanager + def _run_instrument_services(self, run_plan): + del run_plan + yield RunInstrumentServices(rf_source=rf_service) + + def _run_safety_guards(self, run_plan, *, services=None): + del run_plan, services + + descriptor = _descriptor( + "rf_source.idn", + "rf_source.snapshot", + "rf_source.pulse_configure", + ) + with patch( + "wavebench.services.run_service.resolve_instrument_descriptor", + return_value=descriptor, + ): + run_result = OfflineRfRunService(config=config, logger=CommandLogger()).run(plan) + + rf_service.configure_pulse_with_artifact.assert_called_once_with(request) + assert run_result.steps[0].artifact == {"rf_source_operation": artifact} + run_data = json.loads(run_result.run_json_path.read_text(encoding="utf-8")) + assert run_data["rf_source_operations"] == [artifact] + + +def test_rf_source_sweep_plan_normalizes_the_disabled_frequency_only_subset() -> None: + with TemporaryDirectory() as directory: + plan = _sweep_plan(directory) + assert plan.steps[0].fields == { + "port_id": "rf_out", + "start_frequency_hz": 1_000_000.0, + "stop_frequency_hz": 2_000_000.0, + "points": 11, + "dwell_s": 0.02, + } + + invalid_order_path = Path(directory) / "invalid-order.toml" + invalid_order_path.write_text( + "[[steps]]\n" + 'kind = "rf_source.sweep_configure"\n' + 'port_id = "rf_out"\n' + "start_frequency_hz = 2000000\n" + "stop_frequency_hz = 1000000\n" + "points = 11\n" + "dwell_s = 0.02\n", + encoding="utf-8", + ) + with pytest.raises(ConfigError, match="less than stop_frequency_hz"): + load_run_plan(invalid_order_path) + + invalid_points_path = Path(directory) / "invalid-points.toml" + invalid_points_path.write_text( + "[[steps]]\n" + 'kind = "rf_source.sweep_configure"\n' + 'port_id = "rf_out"\n' + "start_frequency_hz = 1000000\n" + "stop_frequency_hz = 2000000\n" + "points = 1\n" + "dwell_s = 0.02\n", + encoding="utf-8", + ) + with pytest.raises(ConfigError, match="points must be an integer >= 2"): + load_run_plan(invalid_points_path) + + +def test_rf_source_sweep_step_requires_capability_before_opening_a_session() -> None: + with TemporaryDirectory() as directory: + service = RunService(config=_config(directory, access="read_write"), logger=CommandLogger()) + with patch( + "wavebench.services.run_service.resolve_instrument_descriptor", + return_value=_descriptor("rf_source.idn", "rf_source.snapshot"), + ), patch.object(service, "_run_instrument_services") as open_services: + with pytest.raises(ConfigError, match="rf_source.sweep_configure"): + service.run(_sweep_plan(directory)) + + open_services.assert_not_called() + + +def test_rf_source_sweep_step_rejects_read_only_access_before_opening_a_session() -> None: + with TemporaryDirectory() as directory: + service = RunService(config=_config(directory), logger=CommandLogger()) + with patch( + "wavebench.services.run_service.resolve_instrument_descriptor", + return_value=_descriptor( + "rf_source.idn", + "rf_source.snapshot", + "rf_source.sweep_configure", + ), + ), patch.object(service, "_run_instrument_services") as open_services: + with pytest.raises(ConfigError, match="access policy 'read_only'"): + service.run(_sweep_plan(directory)) + + open_services.assert_not_called() + + +def test_rf_source_sweep_step_has_write_intent_and_separate_artifact_namespace() -> None: + with TemporaryDirectory() as directory: + plan = _sweep_plan(directory) + config = _config(directory, access="read_write") + intent = build_execution_intent(plan, config) + assert intent.operations[0]["operation"] == "rf_source.sweep_configure" + assert intent.operations[0]["effect"] == "write" + assert intent.operations[0]["parameters"] == { + "port_id": "rf_out", + "start_frequency_hz": 1_000_000.0, + "stop_frequency_hz": 2_000_000.0, + "points": 11, + "dwell_s": 0.02, + } + + request = RfSweepConfigureRequest( + port_id="rf_out", + start_frequency_hz=1_000_000.0, + stop_frequency_hz=2_000_000.0, + points=11, + dwell_s=0.02, + ) + result_value = RfSweepConfigureResult( + port_id="rf_out", + start_frequency_hz=1_000_000.0, + stop_frequency_hz=2_000_000.0, + points=11, + dwell_s=0.02, + ) + preflight_snapshot = _snapshot() + postcondition_snapshot = _snapshot() + postcondition_sweep_snapshot = RfSweepSnapshot( + port_id="rf_out", + sweep_type=RfSweepType.STEP, + direction=RfSweepDirection.FORWARD, + shape=RfSweepShape.RAMP, + spacing=RfSweepSpacing.LINEAR, + start_frequency_hz=1_000_000.0, + stop_frequency_hz=2_000_000.0, + points=11, + dwell_s=0.02, + state=RfSweepState.DISABLED, + ) + artifact = rf_source_sweep_operation_artifact( + request=request, + result=result_value, + preflight_snapshot=preflight_snapshot, + postcondition_snapshot=postcondition_snapshot, + postcondition_sweep_snapshot=postcondition_sweep_snapshot, + ) + rf_service = SimpleNamespace( + configure_sweep_with_artifact=Mock(return_value=(result_value, artifact)), + audit_snapshot=lambda: None, + ) + + class OfflineRfRunService(RunService): + @contextmanager + def _run_instrument_services(self, run_plan): + del run_plan + yield RunInstrumentServices(rf_source=rf_service) + + def _run_safety_guards(self, run_plan, *, services=None): + del run_plan, services + + descriptor = _descriptor( + "rf_source.idn", + "rf_source.snapshot", + "rf_source.sweep_configure", + ) + with patch( + "wavebench.services.run_service.resolve_instrument_descriptor", + return_value=descriptor, + ): + run_result = OfflineRfRunService(config=config, logger=CommandLogger()).run(plan) + + rf_service.configure_sweep_with_artifact.assert_called_once_with(request) + assert run_result.steps[0].artifact == {"rf_source_operation": artifact} + run_data = json.loads(run_result.run_json_path.read_text(encoding="utf-8")) + assert run_data["rf_source_operations"] == [artifact] + + +def test_rf_source_output_step_requires_capability_before_opening_a_session() -> None: + with TemporaryDirectory() as directory: + service = RunService(config=_config(directory, access="read_write"), logger=CommandLogger()) + plan = _output_plan(directory, kind="rf_source.output_enable") + with patch( + "wavebench.services.run_service.resolve_instrument_descriptor", + return_value=_descriptor("rf_source.idn", "rf_source.snapshot"), + ), patch.object(service, "_run_instrument_services") as open_services: + with pytest.raises(ConfigError, match="rf_source.output"): + service.run(plan) + + open_services.assert_not_called() + + +def test_rf_source_output_step_rejects_read_only_access_before_opening_a_session() -> None: + with TemporaryDirectory() as directory: + service = RunService(config=_config(directory), logger=CommandLogger()) + plan = _output_plan(directory, kind="rf_source.output_disable") + with patch( + "wavebench.services.run_service.resolve_instrument_descriptor", + return_value=_descriptor( + "rf_source.idn", + "rf_source.snapshot", + "rf_source.output", + ), + ), patch.object(service, "_run_instrument_services") as open_services: + with pytest.raises(ConfigError, match="access policy 'read_only'"): + service.run(plan) + + open_services.assert_not_called() + + +def test_rf_source_output_step_has_write_intent_and_separate_artifact_namespace() -> None: + with TemporaryDirectory() as directory: + plan = _output_plan(directory, kind="rf_source.output_enable") + config = _config(directory, access="read_write") + intent = build_execution_intent(plan, config) + assert intent.operations[0]["operation"] == "rf_source.output_enable" + assert intent.operations[0]["effect"] == "write" + assert intent.operations[0]["parameters"] == {"port_id": "rf_out"} + + preflight = _snapshot() + postcondition = RfSourceSnapshot( + ports=( + RfPortSnapshot( + port_id="rf_out", + frequency_hz=RfObserved.value_of(1_000_000.0), + power_dbm=RfObserved.value_of(-30.0), + output_enabled=RfObserved.value_of(True), + modulation=RfObserved.value_of(RfModulationState.DISABLED), + pulse=RfObserved.value_of(RfPulseState.DISABLED), + sweep=RfObserved.value_of(RfSweepState.DISABLED), + ), + ), + protection=RfObserved.value_of(RfProtectionStatus(active_codes=())), + ) + request = RfOutputRequest(port_id="rf_out", enabled=True) + result_value = RfOutputResult(port_id="rf_out", enabled=True, write_completed=True) + artifact = rf_source_output_operation_artifact( + request=request, + result=result_value, + preflight_snapshot=preflight, + postcondition_snapshot=postcondition, + ) + rf_service = SimpleNamespace( + set_output_with_artifact=Mock(return_value=(result_value, artifact)), + audit_snapshot=lambda: None, + ) + + class OfflineRfRunService(RunService): + @contextmanager + def _run_instrument_services(self, run_plan): + del run_plan + yield RunInstrumentServices(rf_source=rf_service) + + def _run_safety_guards(self, run_plan, *, services=None): + del run_plan, services + + descriptor = _descriptor( + "rf_source.idn", + "rf_source.snapshot", + "rf_source.output", + ) + with patch( + "wavebench.services.run_service.resolve_instrument_descriptor", + return_value=descriptor, + ): + run_result = OfflineRfRunService(config=config, logger=CommandLogger()).run(plan) + + rf_service.set_output_with_artifact.assert_called_once_with(request) + assert run_result.steps[0].artifact == {"rf_source_operation": artifact} + run_data = json.loads(run_result.run_json_path.read_text(encoding="utf-8")) + assert run_data["rf_source_operations"] == [artifact] diff --git a/tests/test_rf_source_service.py b/tests/test_rf_source_service.py new file mode 100644 index 0000000..0a9e86e --- /dev/null +++ b/tests/test_rf_source_service.py @@ -0,0 +1,844 @@ +from __future__ import annotations + +from dataclasses import replace +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + +import pytest + +from wavebench.config import ( + AutoscaleConfig, + ConnectionConfig, + OutputConfig, + RfPortSafetyConfig, + RfSourceConfig, + ScopeConfig, + WaveBenchConfig, + WaveformConfig, +) +from wavebench.errors import AccessDeniedError, ConfigError +from wavebench.instruments.rf_source_extensions import ( + RF_SOURCE_CONTRACT_VERSION, + RfAvailability, + RfModulationState, + RfObserved, + RfCwProfile, + RfCwRequest, + RfCwResult, + RfFeature, + RfFeatureCapability, + RfFeatureDirection, + RfOutputPortProfile, + RfOutputProfile, + RfOutputRequest, + RfOutputResult, + RfPortSnapshot, + RfProtectionConditionPolicy, + RfProtectionStatus, + RfPulseState, + RfReasonCode, + RfSourceDescriptorExtensions, + RfSourceSnapshot, + RfSourceTopology, + RfSweepState, +) +from wavebench.logging import CommandLogger +from wavebench.services.rf_source_service import RfSourceService +from wavebench.transport.contracts import ReplayPolicy +from wavebench.transport.guarded import GuardedAuditedTransport +from wavebench.transport.session import InstrumentSessionState, SessionHealth + + +class FakeRfDriver: + def __init__(self) -> None: + self.calls: list[str] = [] + + def close(self) -> None: + self.calls.append("close") + + def idn(self) -> str: + self.calls.append("idn") + return "EXAMPLE,RF1,0,1" + + def get_rf_snapshot(self) -> RfSourceSnapshot: + self.calls.append("snapshot") + return _snapshot() + + +class FakeRfWriteDriver: + def __init__( + self, + snapshots: list[RfSourceSnapshot], + *, + raise_after_write: bool = False, + ) -> None: + self.snapshots = list(snapshots) + self.raise_after_write = raise_after_write + self.calls: list[str] = [] + self.requests: list[RfCwRequest] = [] + + def close(self) -> None: + self.calls.append("close") + + def idn(self) -> str: + self.calls.append("idn") + return "EXAMPLE,RF1,0,1" + + def get_rf_snapshot(self) -> RfSourceSnapshot: + self.calls.append("snapshot") + if not self.snapshots: + raise AssertionError("unexpected RF snapshot query") + return self.snapshots.pop(0) + + def configure_cw(self, request: RfCwRequest) -> None: + self.calls.append("configure_cw") + self.requests.append(request) + if self.raise_after_write: + raise ConfigError("fake CW write failed after transmission") + + +class FakeRfOutputDriver: + def __init__( + self, + snapshots: list[RfSourceSnapshot], + *, + raise_after_enable: bool = False, + raise_after_disable: bool = False, + ) -> None: + self.snapshots = list(snapshots) + self.raise_after_enable = raise_after_enable + self.raise_after_disable = raise_after_disable + self.calls: list[str] = [] + self.output_requests: list[RfOutputRequest] = [] + + def close(self) -> None: + self.calls.append("close") + + def idn(self) -> str: + self.calls.append("idn") + return "EXAMPLE,RF1,0,1" + + def get_rf_snapshot(self) -> RfSourceSnapshot: + self.calls.append("snapshot") + if not self.snapshots: + raise AssertionError("unexpected RF snapshot query") + return self.snapshots.pop(0) + + def set_rf_output(self, request: RfOutputRequest) -> None: + self.calls.append("set_rf_output") + self.output_requests.append(request) + if request.enabled and self.raise_after_enable: + raise ConfigError("fake RF ON failed after transmission") + if not request.enabled and self.raise_after_disable: + raise ConfigError("fake RF OFF failed after transmission") + + +class _GuardedOutputTransport: + resource = "fake-rf-output" + + def __init__(self) -> None: + self.queries: list[str] = [] + self.writes: list[str] = [] + + def record_event(self, direction: str, text: str) -> None: + del direction, text + + def query(self, command: str, *, replay: ReplayPolicy = ReplayPolicy.NO_REPLAY) -> str: + del replay + self.queries.append(command) + return "ok" + + def write(self, command: str) -> None: + self.writes.append(command) + + def close(self) -> None: + return None + + +class GuardedRfOutputDriver: + def __init__(self, snapshots: list[RfSourceSnapshot], state: InstrumentSessionState) -> None: + self.inner = _GuardedOutputTransport() + self.transport = GuardedAuditedTransport(self.inner, session_state=state) + self.snapshots = list(snapshots) + self.output_requests: list[RfOutputRequest] = [] + + def close(self) -> None: + self.transport.close() + + def idn(self) -> str: + return "EXAMPLE,RF1,0,1" + + def get_rf_snapshot(self) -> RfSourceSnapshot: + self.transport.query("RF:SNAPSHOT?") + if not self.snapshots: + raise AssertionError("unexpected RF snapshot query") + return self.snapshots.pop(0) + + def set_rf_output(self, request: RfOutputRequest) -> None: + self.output_requests.append(request) + self.transport.write("RF:OUTPUT ON" if request.enabled else "RF:OUTPUT OFF") + + +def _snapshot( + *, + frequency_hz: float = 1_000_000.0, + power_dbm: float = -30.0, + output_enabled: bool = False, + modulation: RfModulationState = RfModulationState.DISABLED, + pulse: RfPulseState = RfPulseState.DISABLED, + sweep: RfSweepState = RfSweepState.DISABLED, + protection_codes: tuple[str, ...] = (), +) -> RfSourceSnapshot: + return RfSourceSnapshot( + ports=( + RfPortSnapshot( + port_id="rf_out", + frequency_hz=RfObserved.value_of(frequency_hz), + power_dbm=RfObserved.value_of(power_dbm), + output_enabled=RfObserved.value_of(output_enabled), + modulation=RfObserved.value_of(modulation), + pulse=RfObserved.value_of(pulse), + sweep=RfObserved.value_of(sweep), + ), + ), + protection=RfObserved.value_of(RfProtectionStatus(active_codes=protection_codes)), + ) + + +def _config( + *, + access: str = "read_only", + safety_ports: tuple[RfPortSafetyConfig, ...] = (), +) -> WaveBenchConfig: + return WaveBenchConfig( + connection=ConnectionConfig("lan", "TCPIP::scope::INSTR", 1_000, 1_000), + 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"), + rf_source=RfSourceConfig( + driver="example.rf1", + resource="TCPIP::rf::INSTR", + access=access, # type: ignore[arg-type] + safety_ports=safety_ports, + ), + ) + + +def _descriptor(*capabilities: str) -> SimpleNamespace: + return SimpleNamespace(driver_id="example.rf1", capabilities=capabilities) + + +def _cw_descriptor( + *capabilities: str, + frequency_configurable: bool = True, + power_configurable: bool = True, +) -> SimpleNamespace: + return SimpleNamespace( + driver_id="example.rf1", + capabilities=capabilities, + rf_source_extensions=RfSourceDescriptorExtensions( + contract_version=RF_SOURCE_CONTRACT_VERSION, + topology=RfSourceTopology( + ( + RfOutputPortProfile( + port_id="rf_out", + frequency_min_hz=9_000.0, + frequency_max_hz=3_000_000_000.0, + power_min_dbm=-110.0, + power_max_dbm=20.0, + power_reference_impedance_ohm=50.0, + ), + ) + ), + features=( + RfFeatureCapability( + feature=RfFeature.CW, + directions=(RfFeatureDirection.CONFIGURE, RfFeatureDirection.READ), + port_ids=("rf_out",), + profile=RfCwProfile( + frequency_readable=True, + power_readable=True, + frequency_configurable=frequency_configurable, + power_configurable=power_configurable, + ), + ), + ), + protection_conditions=( + RfProtectionConditionPolicy("overtemperature", True), + ), + ), + ) + + +def _cw_service( + snapshots: list[RfSourceSnapshot], + *, + access: str = "read_write", + descriptor: SimpleNamespace | None = None, + raise_after_write: bool = False, +) -> tuple[RfSourceService, FakeRfWriteDriver]: + driver = FakeRfWriteDriver(snapshots, raise_after_write=raise_after_write) + service = RfSourceService( + config=_config(access=access), + logger=CommandLogger(), + session=driver, + descriptor=descriptor + or _cw_descriptor( + "rf_source.idn", + "rf_source.snapshot", + "rf_source.cw_configure", + ), + session_state=InstrumentSessionState(), + ) + return service, driver + + +def _output_descriptor(*capabilities: str) -> SimpleNamespace: + return SimpleNamespace( + driver_id="example.rf1", + capabilities=capabilities, + rf_source_extensions=RfSourceDescriptorExtensions( + contract_version=RF_SOURCE_CONTRACT_VERSION, + topology=RfSourceTopology( + ( + RfOutputPortProfile( + port_id="rf_out", + frequency_min_hz=9_000.0, + frequency_max_hz=3_000_000_000.0, + power_min_dbm=-110.0, + power_max_dbm=20.0, + power_reference_impedance_ohm=50.0, + ), + ) + ), + features=( + RfFeatureCapability( + feature=RfFeature.OUTPUT, + directions=( + RfFeatureDirection.DISABLE, + RfFeatureDirection.ENABLE, + RfFeatureDirection.READ, + ), + port_ids=("rf_out",), + profile=RfOutputProfile(output_readable=True), + ), + ), + protection_conditions=( + RfProtectionConditionPolicy("overtemperature", True), + RfProtectionConditionPolicy("status_notice", False), + ), + ), + ) + + +def _output_safety_port(*, termination_ohm: float = 50.0) -> RfPortSafetyConfig: + return RfPortSafetyConfig( + port_id="rf_out", + minimum_frequency_hz=9_000.0, + maximum_frequency_hz=3_000_000_000.0, + maximum_power_dbm=0.0, + actual_termination_ohm=termination_ohm, + ) + + +def _output_service( + snapshots: list[RfSourceSnapshot], + *, + access: str = "read_write", + safety_ports: tuple[RfPortSafetyConfig, ...] | None = None, + descriptor: SimpleNamespace | None = None, + raise_after_enable: bool = False, + raise_after_disable: bool = False, +) -> tuple[RfSourceService, FakeRfOutputDriver]: + driver = FakeRfOutputDriver( + snapshots, + raise_after_enable=raise_after_enable, + raise_after_disable=raise_after_disable, + ) + service = RfSourceService( + config=_config( + access=access, + safety_ports=( + (_output_safety_port(),) + if safety_ports is None + else safety_ports + ), + ), + logger=CommandLogger(), + session=driver, + descriptor=descriptor + or _output_descriptor( + "rf_source.idn", + "rf_source.snapshot", + "rf_source.output", + ), + session_state=InstrumentSessionState(), + ) + return service, driver + + +def test_idn_and_snapshot_are_one_shot_read_only_operations(monkeypatch: pytest.MonkeyPatch) -> None: + driver = FakeRfDriver() + service = RfSourceService( + config=_config(), + logger=CommandLogger(), + descriptor=_descriptor("rf_source.idn", "rf_source.snapshot"), + ) + monkeypatch.setattr(service, "_open_rf_source", lambda: driver) + + assert service.idn() == "EXAMPLE,RF1,0,1" + assert service.snapshot().ports[0].port_id == "rf_out" + assert driver.calls == ["idn", "close", "snapshot", "close"] + + +def test_snapshot_capability_and_access_are_checked_before_opening_transport( + monkeypatch: pytest.MonkeyPatch, +) -> None: + service = RfSourceService( + config=_config(), + logger=CommandLogger(), + descriptor=_descriptor("rf_source.idn"), + ) + opened = False + + def fail_open() -> FakeRfDriver: + nonlocal opened + opened = True + return FakeRfDriver() + + monkeypatch.setattr(service, "_open_rf_source", fail_open) + with pytest.raises(ConfigError, match="rf_source.snapshot"): + service.snapshot() + assert opened is False + + disabled = RfSourceService( + config=_config(access="disabled"), + logger=CommandLogger(), + descriptor=_descriptor("rf_source.idn", "rf_source.snapshot"), + ) + with patch.object(disabled, "_open_rf_source") as open_disabled: + with pytest.raises(AccessDeniedError, match="rf_source.idn"): + disabled.idn() + open_disabled.assert_not_called() + + +def test_snapshot_rejects_nonhealthy_bound_session_before_driver_call() -> None: + driver = FakeRfDriver() + service = RfSourceService( + config=_config(), + logger=CommandLogger(), + session=driver, + descriptor=_descriptor("rf_source.idn", "rf_source.snapshot"), + session_state=InstrumentSessionState(health=SessionHealth.UNCERTAIN), + ) + + with pytest.raises(ConfigError, match="healthy session"): + service.snapshot() + + assert driver.calls == [] + + +def test_one_shot_service_passes_owned_lease_to_factory() -> None: + driver = FakeRfDriver() + descriptor = _descriptor("rf_source.idn", "rf_source.snapshot") + opened = SimpleNamespace( + descriptor=descriptor, + transport=None, + session_state=None, + driver=driver, + ) + service = RfSourceService( + config=_config(), + logger=CommandLogger(), + descriptor=descriptor, + ) + + with patch( + "wavebench.services.rf_source_service.open_instrument_driver", + return_value=opened, + ) as factory: + assert service.idn() == "EXAMPLE,RF1,0,1" + + lease = factory.call_args.kwargs["lease"] + assert lease.resource == "tcpip::rf::instr" + assert driver.calls == ["idn", "close"] + + +def test_cw_configuration_uses_one_write_and_independent_snapshot_readback() -> None: + request = RfCwRequest(port_id="rf_out", frequency_hz=2_000_000.0) + service, driver = _cw_service( + [_snapshot(), _snapshot(frequency_hz=2_000_000.0)] + ) + + result = service.configure_cw(request) + + assert result.port_id == "rf_out" + assert result.frequency_hz == 2_000_000.0 + assert result.power_dbm is None + assert driver.requests == [request] + assert driver.calls == ["snapshot", "configure_cw", "snapshot"] + + +@pytest.mark.parametrize( + ("snapshot", "message"), + ( + (_snapshot(output_enabled=True), "target RF output OFF"), + (_snapshot(modulation=RfModulationState.ENABLED), "modulation disabled"), + (_snapshot(pulse=RfPulseState.ENABLED), "Pulse disabled"), + (_snapshot(sweep=RfSweepState.ENABLED), "Sweep disabled"), + (_snapshot(protection_codes=("overtemperature",)), "active protection"), + ), +) +def test_cw_configuration_rejects_unsafe_preflight_without_write( + snapshot: RfSourceSnapshot, + message: str, +) -> None: + service, driver = _cw_service([snapshot]) + + with pytest.raises(ConfigError, match=message): + service.configure_cw(RfCwRequest(port_id="rf_out", power_dbm=-20.0)) + + assert driver.requests == [] + assert driver.calls == ["snapshot"] + assert service.session_state is not None + assert service.session_state.health is SessionHealth.HEALTHY + + +def test_cw_configuration_checks_capability_access_and_static_profile_before_write() -> None: + request = RfCwRequest(port_id="rf_out", frequency_hz=2_000_000.0) + missing_capability = _cw_descriptor("rf_source.idn", "rf_source.snapshot") + service, driver = _cw_service([_snapshot()], descriptor=missing_capability) + + with pytest.raises(ConfigError, match="rf_source.cw_configure"): + service.configure_cw(request) + assert driver.calls == [] + + read_only, read_only_driver = _cw_service( + [_snapshot()], + access="read_only", + ) + with pytest.raises(AccessDeniedError, match="rf_source.set_frequency"): + read_only.configure_cw(request) + assert read_only_driver.calls == [] + + frequency_disabled = _cw_descriptor( + "rf_source.idn", + "rf_source.snapshot", + "rf_source.cw_configure", + frequency_configurable=False, + ) + profile_service, profile_driver = _cw_service( + [_snapshot()], + descriptor=frequency_disabled, + ) + with pytest.raises(ConfigError, match="configurable CW frequency"): + profile_service.configure_cw(request) + assert profile_driver.calls == [] + + range_service, range_driver = _cw_service([_snapshot()]) + with pytest.raises(ConfigError, match="outside the descriptor range"): + range_service.configure_cw(RfCwRequest(port_id="rf_out", frequency_hz=1.0)) + assert range_driver.calls == [] + + +def test_cw_configuration_mismatch_or_write_failure_is_not_retried() -> None: + request = RfCwRequest(port_id="rf_out", power_dbm=-10.0) + mismatch_service, mismatch_driver = _cw_service( + [_snapshot(), _snapshot(power_dbm=-11.0)] + ) + + with pytest.raises(ConfigError, match="power_dbm readback does not match"): + mismatch_service.configure_cw(request) + + assert mismatch_driver.requests == [request] + assert mismatch_driver.calls == ["snapshot", "configure_cw", "snapshot"] + assert mismatch_service.session_state is not None + assert mismatch_service.session_state.health is SessionHealth.UNCERTAIN + + failed_service, failed_driver = _cw_service( + [_snapshot()], + raise_after_write=True, + ) + with pytest.raises(ConfigError, match="failed after transmission"): + failed_service.configure_cw(request) + + assert failed_driver.requests == [request] + assert failed_driver.calls == ["snapshot", "configure_cw"] + assert failed_service.session_state is not None + assert failed_service.session_state.health is SessionHealth.UNCERTAIN + + +def test_cw_configuration_with_artifact_preserves_pre_and_postcondition_snapshots() -> None: + request = RfCwRequest(port_id="rf_out", power_dbm=-10.0) + service, driver = _cw_service([_snapshot(), _snapshot(power_dbm=-10.0)]) + + result, artifact = service.configure_cw_with_artifact(request) + + assert result == RfCwResult(port_id="rf_out", power_dbm=-10.0) + assert driver.requests == [request] + assert artifact["operation"] == "rf_source.set_power_dbm" + assert artifact["preflight_snapshot"]["ports"][0]["power_dbm"]["value"] == -30.0 + assert artifact["postcondition_snapshot"]["ports"][0]["power_dbm"]["value"] == -10.0 + + +def test_rf_output_enable_uses_one_write_and_independent_snapshot_readback() -> None: + request = RfOutputRequest(port_id="rf_out", enabled=True) + service, driver = _output_service([_snapshot(), _snapshot(output_enabled=True)]) + + result, artifact = service.set_output_with_artifact(request) + + assert result == RfOutputResult(port_id="rf_out", enabled=True, write_completed=True) + assert driver.output_requests == [request] + assert driver.calls == ["snapshot", "set_rf_output", "snapshot"] + assert artifact["operation"] == "rf_source.output_enable" + assert artifact["postcondition_snapshot"]["ports"][0]["output_enabled"]["value"] is True + + +@pytest.mark.parametrize( + ("snapshot", "safety_ports", "message"), + ( + (_snapshot(), (), "complete safety configuration"), + (_snapshot(), (_output_safety_port(termination_ohm=75.0),), "actual termination"), + (_snapshot(power_dbm=1.0), None, "RF power within descriptor and safety ranges"), + (_snapshot(modulation=RfModulationState.ENABLED), None, "modulation disabled"), + (_snapshot(pulse=RfPulseState.ENABLED), None, "Pulse disabled"), + (_snapshot(sweep=RfSweepState.ENABLED), None, "Sweep disabled"), + (_snapshot(protection_codes=("overtemperature",)), None, "blocking protection"), + (_snapshot(protection_codes=("unknown_code",)), None, "unknown active protection"), + ( + replace( + _snapshot(), + ports=( + replace( + _snapshot().ports[0], + frequency_hz=RfObserved.missing( + RfAvailability.UNKNOWN, + RfReasonCode.UNKNOWN_STATE, + ), + ), + ), + ), + None, + "readable RF frequency", + ), + ( + replace( + _snapshot(), + protection=RfObserved.missing( + RfAvailability.UNKNOWN, + RfReasonCode.UNKNOWN_STATE, + ), + ), + None, + "readable protection", + ), + ), +) +def test_rf_output_enable_rejects_unsafe_preflight_without_write( + snapshot: RfSourceSnapshot, + safety_ports: tuple[RfPortSafetyConfig, ...] | None, + message: str, +) -> None: + service, driver = _output_service([snapshot], safety_ports=safety_ports) + + with pytest.raises(ConfigError, match=message): + service.set_output(RfOutputRequest(port_id="rf_out", enabled=True)) + + assert driver.output_requests == [] + assert driver.calls == ["snapshot"] + assert service.session_state is not None + assert service.session_state.health is SessionHealth.HEALTHY + + +def test_rf_output_enable_checks_access_capability_and_static_profile_before_write() -> None: + request = RfOutputRequest(port_id="rf_out", enabled=True) + missing_capability = _output_descriptor("rf_source.idn", "rf_source.snapshot") + service, driver = _output_service([_snapshot()], descriptor=missing_capability) + + with pytest.raises(ConfigError, match="rf_source.output"): + service.set_output(request) + assert driver.calls == [] + + read_only, read_only_driver = _output_service([_snapshot()], access="read_only") + with pytest.raises(AccessDeniedError, match="rf_source.output_enable"): + read_only.set_output(request) + assert read_only_driver.calls == [] + + incomplete_profile = SimpleNamespace( + driver_id="example.rf1", + capabilities=("rf_source.idn", "rf_source.snapshot", "rf_source.output"), + rf_source_extensions=RfSourceDescriptorExtensions( + contract_version=RF_SOURCE_CONTRACT_VERSION, + topology=RfSourceTopology( + ( + RfOutputPortProfile( + port_id="rf_out", + frequency_min_hz=9_000.0, + frequency_max_hz=3_000_000_000.0, + power_min_dbm=-110.0, + power_max_dbm=20.0, + power_reference_impedance_ohm=50.0, + ), + ) + ), + features=( + RfFeatureCapability( + feature=RfFeature.OUTPUT, + directions=(RfFeatureDirection.READ,), + port_ids=("rf_out",), + profile=RfOutputProfile(output_readable=True), + ), + ), + ), + ) + profile_service, profile_driver = _output_service( + [_snapshot()], + descriptor=incomplete_profile, + ) + with pytest.raises(ConfigError, match="readable output profile"): + profile_service.set_output(request) + assert profile_driver.calls == [] + + +def test_rf_output_is_idempotent_without_a_write() -> None: + on_service, on_driver = _output_service([_snapshot(output_enabled=True)]) + on_result = on_service.set_output(RfOutputRequest(port_id="rf_out", enabled=True)) + + assert on_result == RfOutputResult(port_id="rf_out", enabled=True, write_completed=False) + assert on_driver.output_requests == [] + assert on_driver.calls == ["snapshot"] + + off_service, off_driver = _output_service( + [_snapshot(output_enabled=False)], + safety_ports=(), + ) + off_result = off_service.set_output(RfOutputRequest(port_id="rf_out", enabled=False)) + + assert off_result == RfOutputResult(port_id="rf_out", enabled=False, write_completed=False) + assert off_driver.output_requests == [] + assert off_driver.calls == ["snapshot"] + + +def test_rf_output_disable_ignores_on_only_unknowns_and_confirms_off() -> None: + before = _snapshot(output_enabled=True) + before = replace( + before, + ports=( + replace( + before.ports[0], + frequency_hz=RfObserved.missing( + RfAvailability.UNKNOWN, + RfReasonCode.UNKNOWN_STATE, + ), + power_dbm=RfObserved.missing( + RfAvailability.UNKNOWN, + RfReasonCode.UNKNOWN_STATE, + ), + ), + ), + protection=RfObserved.missing( + RfAvailability.UNKNOWN, + RfReasonCode.UNKNOWN_STATE, + ), + ) + service, driver = _output_service( + [before, _snapshot(output_enabled=False)], + safety_ports=(), + ) + + result = service.set_output(RfOutputRequest(port_id="rf_out", enabled=False)) + + assert result == RfOutputResult(port_id="rf_out", enabled=False, write_completed=True) + assert driver.output_requests == [RfOutputRequest(port_id="rf_out", enabled=False)] + assert driver.calls == ["snapshot", "set_rf_output", "snapshot"] + + +def test_rf_output_enable_postcondition_or_write_failure_runs_one_off_recovery() -> None: + request = RfOutputRequest(port_id="rf_out", enabled=True) + mismatch_service, mismatch_driver = _output_service( + [_snapshot(), _snapshot(output_enabled=False), _snapshot(output_enabled=False)] + ) + + with pytest.raises(ConfigError, match="postcondition reports RF output OFF") as mismatch: + mismatch_service.set_output(request) + + assert mismatch_driver.output_requests == [ + RfOutputRequest(port_id="rf_out", enabled=True), + RfOutputRequest(port_id="rf_out", enabled=False), + ] + assert mismatch.value.rf_source_recovery == { + "status": "off_verified", + "session_health": "uncertain", + } + assert mismatch_service.session_state is not None + assert mismatch_service.session_state.health is SessionHealth.UNCERTAIN + + failed_service, failed_driver = _output_service( + [_snapshot(), _snapshot(output_enabled=False)], + raise_after_enable=True, + ) + with pytest.raises(ConfigError, match="RF ON failed after transmission") as failed: + failed_service.set_output(request) + + assert failed_driver.output_requests == [ + RfOutputRequest(port_id="rf_out", enabled=True), + RfOutputRequest(port_id="rf_out", enabled=False), + ] + assert failed.value.rf_source_recovery["status"] == "off_verified" + assert failed_service.session_state is not None + assert failed_service.session_state.health is SessionHealth.UNCERTAIN + + +def test_rf_output_disable_unknown_result_is_not_retried() -> None: + request = RfOutputRequest(port_id="rf_out", enabled=False) + service, driver = _output_service( + [_snapshot(output_enabled=True)], + raise_after_disable=True, + ) + + with pytest.raises(ConfigError, match="RF OFF failed after transmission"): + service.set_output(request) + + assert driver.output_requests == [request] + assert driver.calls == ["snapshot", "set_rf_output"] + assert service.session_state is not None + assert service.session_state.health is SessionHealth.POISONED + + +def test_rf_output_recovery_uses_bounded_guarded_io_on_uncertain_session() -> None: + state = InstrumentSessionState() + driver = GuardedRfOutputDriver( + [_snapshot(), _snapshot(output_enabled=False), _snapshot(output_enabled=False)], + state, + ) + service = RfSourceService( + config=_config(access="read_write", safety_ports=(_output_safety_port(),)), + logger=CommandLogger(), + session=driver, + descriptor=_output_descriptor( + "rf_source.idn", + "rf_source.snapshot", + "rf_source.output", + ), + transport=driver.transport, + session_state=state, + ) + + with pytest.raises(ConfigError, match="postcondition reports RF output OFF") as raised: + service.set_output(RfOutputRequest(port_id="rf_out", enabled=True)) + + assert driver.output_requests == [ + RfOutputRequest(port_id="rf_out", enabled=True), + RfOutputRequest(port_id="rf_out", enabled=False), + ] + assert driver.inner.writes == ["RF:OUTPUT ON", "RF:OUTPUT OFF"] + assert driver.transport.counters.write_completed == 2 + assert driver.transport.counters.query_calls == 3 + assert raised.value.rf_source_recovery == { + "status": "off_verified", + "session_health": "uncertain", + } + assert state.health is SessionHealth.UNCERTAIN diff --git a/tests/test_rf_source_sweep_extensions.py b/tests/test_rf_source_sweep_extensions.py new file mode 100644 index 0000000..b39a924 --- /dev/null +++ b/tests/test_rf_source_sweep_extensions.py @@ -0,0 +1,244 @@ +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from wavebench.instruments.capabilities import CAPABILITY_METHODS +from wavebench.instruments.rf_source_capabilities import validate_rf_source_descriptor +from wavebench.instruments.rf_source_extensions import ( + RF_SOURCE_CONTRACT_VERSION, + RF_SOURCE_SWEEP_SNAPSHOT_SCHEMA, + RfFeature, + RfFeatureCapability, + RfFeatureDirection, + RfModulationState, + RfObserved, + RfOutputPortProfile, + RfPortSnapshot, + RfProtectionStatus, + RfPulseState, + RfSourceDescriptorExtensions, + RfSourceSnapshot, + RfSweepConfigureRequest, + RfSweepConfigureResult, + RfSweepDirection, + RfSweepModeProfile, + RfSweepProfile, + RfSweepShape, + RfSweepSnapshot, + RfSweepSpacing, + RfSweepState, + RfSweepType, + RfSourceTopology, + rf_source_sweep_operation_artifact, + rf_sweep_snapshot_document, +) + + +def _topology() -> RfSourceTopology: + return RfSourceTopology( + ( + RfOutputPortProfile( + port_id="rf_out", + frequency_min_hz=9_000.0, + frequency_max_hz=3_000_000_000.0, + power_min_dbm=-110.0, + power_max_dbm=20.0, + power_reference_impedance_ohm=50.0, + ), + ) + ) + + +def _sweep_mode() -> RfSweepModeProfile: + return RfSweepModeProfile( + sweep_type=RfSweepType.STEP, + direction=RfSweepDirection.FORWARD, + shape=RfSweepShape.RAMP, + spacing=RfSweepSpacing.LINEAR, + frequency_min_hz=9_000.0, + frequency_max_hz=3_000_000_000.0, + points_min=2, + points_max=65_535, + dwell_min_s=20e-3, + dwell_max_s=100.0, + ) + + +def _sweep_profile() -> RfSweepProfile: + return RfSweepProfile( + state_readable=True, + configuration_readable=True, + mode_profiles=(_sweep_mode(),), + ) + + +def _snapshot() -> RfSourceSnapshot: + return RfSourceSnapshot( + ports=( + RfPortSnapshot( + port_id="rf_out", + frequency_hz=RfObserved.value_of(1_000_000.0), + power_dbm=RfObserved.value_of(-50.0), + output_enabled=RfObserved.value_of(False), + modulation=RfObserved.value_of(RfModulationState.DISABLED), + pulse=RfObserved.value_of(RfPulseState.DISABLED), + sweep=RfObserved.value_of(RfSweepState.DISABLED), + ), + ), + protection=RfObserved.value_of(RfProtectionStatus(active_codes=())), + ) + + +def _sweep_snapshot() -> RfSweepSnapshot: + return RfSweepSnapshot( + port_id="rf_out", + sweep_type=RfSweepType.STEP, + direction=RfSweepDirection.FORWARD, + shape=RfSweepShape.RAMP, + spacing=RfSweepSpacing.LINEAR, + start_frequency_hz=1_000_000.0, + stop_frequency_hz=2_000_000.0, + points=11, + dwell_s=20e-3, + state=RfSweepState.DISABLED, + ) + + +def _descriptor() -> SimpleNamespace: + return SimpleNamespace( + driver_id="example.rf.sweep", + kind="rf_source", + models=("RF-SWEEP",), + capabilities=( + "rf_source.idn", + "rf_source.snapshot", + "rf_source.sweep_configure", + ), + wavebench_min_version="0.8.25", + wavebench_max_version="0.9.0", + rf_source_extensions=RfSourceDescriptorExtensions( + contract_version=RF_SOURCE_CONTRACT_VERSION, + topology=_topology(), + features=( + RfFeatureCapability( + feature=RfFeature.SWEEP, + directions=(RfFeatureDirection.CONFIGURE, RfFeatureDirection.READ), + port_ids=("rf_out",), + profile=_sweep_profile(), + ), + ), + ), + ) + + +class _Driver: + def close(self) -> None: + return None + + def idn(self) -> str: + return "EXAMPLE,RF-SWEEP,0,1" + + def get_rf_snapshot(self) -> RfSourceSnapshot: + return _snapshot() + + def get_rf_sweep_snapshot(self, port_id: str) -> RfSweepSnapshot: + assert port_id == "rf_out" + return _sweep_snapshot() + + def configure_rf_sweep(self, request: RfSweepConfigureRequest) -> None: + assert request.port_id == "rf_out" + + +def test_sweep_contract_rejects_unsafe_profiles_and_requests() -> None: + with pytest.raises(ValueError, match=">= 2"): + RfSweepModeProfile( + sweep_type=RfSweepType.STEP, + direction=RfSweepDirection.FORWARD, + shape=RfSweepShape.RAMP, + spacing=RfSweepSpacing.LINEAR, + frequency_min_hz=9_000.0, + frequency_max_hz=3_000_000_000.0, + points_min=1, + points_max=65_535, + dwell_min_s=20e-3, + dwell_max_s=100.0, + ) + with pytest.raises(ValueError, match="less than stop"): + RfSweepConfigureRequest( + port_id="rf_out", + start_frequency_hz=1_000_000.0, + stop_frequency_hz=1_000_000.0, + points=11, + dwell_s=20e-3, + ) + with pytest.raises(ValueError, match="integer"): + RfSweepConfigureRequest( + port_id="rf_out", + start_frequency_hz=1_000_000.0, + stop_frequency_hz=2_000_000.0, + points=True, + dwell_s=20e-3, + ) + + +def test_sweep_descriptor_requires_readable_bounded_profile_and_methods() -> None: + descriptor = _descriptor() + + assert CAPABILITY_METHODS["rf_source.sweep_configure"] == ( + "get_rf_sweep_snapshot", + "configure_rf_sweep", + ) + validate_rf_source_descriptor(descriptor, _Driver()) + + invalid = _descriptor() + invalid.rf_source_extensions = RfSourceDescriptorExtensions( + contract_version=RF_SOURCE_CONTRACT_VERSION, + topology=_topology(), + features=( + RfFeatureCapability( + feature=RfFeature.SWEEP, + directions=(RfFeatureDirection.CONFIGURE,), + port_ids=("rf_out",), + profile=_sweep_profile(), + ), + ), + ) + with pytest.raises(Exception, match="configure and read"): + validate_rf_source_descriptor(invalid) + + +def test_sweep_snapshot_document_and_artifact_keep_typed_safe_evidence() -> None: + request = RfSweepConfigureRequest( + port_id="rf_out", + start_frequency_hz=1_000_000.0, + stop_frequency_hz=2_000_000.0, + points=11, + dwell_s=20e-3, + ) + result = RfSweepConfigureResult( + port_id="rf_out", + start_frequency_hz=1_000_000.0, + stop_frequency_hz=2_000_000.0, + points=11, + dwell_s=20e-3, + ) + sweep_snapshot = _sweep_snapshot() + + document = rf_sweep_snapshot_document(sweep_snapshot) + artifact = rf_source_sweep_operation_artifact( + request, + result, + preflight_snapshot=_snapshot(), + postcondition_snapshot=_snapshot(), + postcondition_sweep_snapshot=sweep_snapshot, + ) + + assert document["schema"] == RF_SOURCE_SWEEP_SNAPSHOT_SCHEMA + assert document["sweep_type"] == "step" + assert document["state"] == "disabled" + assert artifact["operation"] == "rf_source.sweep_configure" + assert artifact["request"]["points"] == 11 + assert artifact["postcondition_sweep_snapshot"]["dwell_s"] == 20e-3 + assert "resource" not in str(artifact) diff --git a/tests/test_rf_source_sweep_service.py b/tests/test_rf_source_sweep_service.py new file mode 100644 index 0000000..9808068 --- /dev/null +++ b/tests/test_rf_source_sweep_service.py @@ -0,0 +1,354 @@ +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from wavebench.config import ( + AutoscaleConfig, + ConnectionConfig, + OutputConfig, + RfSourceConfig, + ScopeConfig, + WaveBenchConfig, + WaveformConfig, +) +from wavebench.errors import AccessDeniedError, ConfigError +from wavebench.instruments.rf_source_extensions import ( + RF_SOURCE_CONTRACT_VERSION, + RfFeature, + RfFeatureCapability, + RfFeatureDirection, + RfModulationState, + RfObserved, + RfOutputPortProfile, + RfPortSnapshot, + RfProtectionStatus, + RfPulseState, + RfSourceDescriptorExtensions, + RfSourceSnapshot, + RfSourceTopology, + RfSweepConfigureRequest, + RfSweepDirection, + RfSweepModeProfile, + RfSweepProfile, + RfSweepShape, + RfSweepSnapshot, + RfSweepSpacing, + RfSweepState, + RfSweepType, +) +from wavebench.logging import CommandLogger +from wavebench.services.rf_source_service import RfSourceService +from wavebench.transport.session import InstrumentSessionState, SessionHealth + + +def _config(*, access: str = "read_write") -> WaveBenchConfig: + return WaveBenchConfig( + connection=ConnectionConfig("lan", "TCPIP::scope::INSTR", 1_000, 1_000), + 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"), + rf_source=RfSourceConfig( + driver="example.rf.sweep", + resource="TCPIP::rf::INSTR", + access=access, # type: ignore[arg-type] + ), + ) + + +def _sweep_profile() -> RfSweepProfile: + return RfSweepProfile( + state_readable=True, + configuration_readable=True, + mode_profiles=( + RfSweepModeProfile( + sweep_type=RfSweepType.STEP, + direction=RfSweepDirection.FORWARD, + shape=RfSweepShape.RAMP, + spacing=RfSweepSpacing.LINEAR, + frequency_min_hz=9_000.0, + frequency_max_hz=3_000_000_000.0, + points_min=2, + points_max=65_535, + dwell_min_s=20e-3, + dwell_max_s=100.0, + ), + ), + ) + + +def _descriptor(*capabilities: str, profile: RfSweepProfile | None = None) -> SimpleNamespace: + return SimpleNamespace( + driver_id="example.rf.sweep", + capabilities=capabilities, + rf_source_extensions=RfSourceDescriptorExtensions( + contract_version=RF_SOURCE_CONTRACT_VERSION, + topology=RfSourceTopology( + ( + RfOutputPortProfile( + port_id="rf_out", + frequency_min_hz=9_000.0, + frequency_max_hz=3_000_000_000.0, + power_min_dbm=-110.0, + power_max_dbm=20.0, + power_reference_impedance_ohm=50.0, + ), + ) + ), + features=( + RfFeatureCapability( + feature=RfFeature.SWEEP, + directions=(RfFeatureDirection.CONFIGURE, RfFeatureDirection.READ), + port_ids=("rf_out",), + profile=profile or _sweep_profile(), + ), + ), + ), + ) + + +def _rf_snapshot( + *, + output_enabled: bool = False, + modulation: RfModulationState = RfModulationState.DISABLED, + pulse: RfPulseState = RfPulseState.DISABLED, + sweep: RfSweepState = RfSweepState.DISABLED, + protection_codes: tuple[str, ...] = (), +) -> RfSourceSnapshot: + return RfSourceSnapshot( + ports=( + RfPortSnapshot( + port_id="rf_out", + frequency_hz=RfObserved.value_of(1_000_000.0), + power_dbm=RfObserved.value_of(-50.0), + output_enabled=RfObserved.value_of(output_enabled), + modulation=RfObserved.value_of(modulation), + pulse=RfObserved.value_of(pulse), + sweep=RfObserved.value_of(sweep), + ), + ), + protection=RfObserved.value_of(RfProtectionStatus(active_codes=protection_codes)), + ) + + +def _sweep_snapshot( + *, + start_frequency_hz: float = 1_000_000.0, + stop_frequency_hz: float = 2_000_000.0, + points: int = 11, + dwell_s: float = 20e-3, + state: RfSweepState = RfSweepState.DISABLED, +) -> RfSweepSnapshot: + return RfSweepSnapshot( + port_id="rf_out", + sweep_type=RfSweepType.STEP, + direction=RfSweepDirection.FORWARD, + shape=RfSweepShape.RAMP, + spacing=RfSweepSpacing.LINEAR, + start_frequency_hz=start_frequency_hz, + stop_frequency_hz=stop_frequency_hz, + points=points, + dwell_s=dwell_s, + state=state, + ) + + +class _Driver: + def __init__( + self, + rf_snapshots: list[RfSourceSnapshot], + sweep_snapshots: list[RfSweepSnapshot], + *, + raise_after_write: bool = False, + ) -> None: + self.rf_snapshots = list(rf_snapshots) + self.sweep_snapshots = list(sweep_snapshots) + self.raise_after_write = raise_after_write + self.calls: list[str] = [] + self.requests: list[RfSweepConfigureRequest] = [] + + def close(self) -> None: + self.calls.append("close") + + def get_rf_snapshot(self) -> RfSourceSnapshot: + self.calls.append("snapshot") + if not self.rf_snapshots: + raise AssertionError("unexpected RF snapshot") + return self.rf_snapshots.pop(0) + + def get_rf_sweep_snapshot(self, port_id: str) -> RfSweepSnapshot: + self.calls.append("sweep_snapshot") + assert port_id == "rf_out" + if not self.sweep_snapshots: + raise AssertionError("unexpected Sweep snapshot") + return self.sweep_snapshots.pop(0) + + def configure_rf_sweep(self, request: RfSweepConfigureRequest) -> None: + self.calls.append("configure_sweep") + self.requests.append(request) + if self.raise_after_write: + raise ConfigError("fake Sweep write failed after transmission") + + +def _request(*, dwell_s: float = 20e-3) -> RfSweepConfigureRequest: + return RfSweepConfigureRequest( + port_id="rf_out", + start_frequency_hz=1_000_000.0, + stop_frequency_hz=2_000_000.0, + points=11, + dwell_s=dwell_s, + ) + + +def _service( + rf_snapshots: list[RfSourceSnapshot], + sweep_snapshots: list[RfSweepSnapshot], + *, + access: str = "read_write", + descriptor: SimpleNamespace | None = None, + raise_after_write: bool = False, +) -> tuple[RfSourceService, _Driver]: + driver = _Driver( + rf_snapshots, + sweep_snapshots, + raise_after_write=raise_after_write, + ) + service = RfSourceService( + config=_config(access=access), + logger=CommandLogger(), + session=driver, + descriptor=descriptor + or _descriptor( + "rf_source.idn", + "rf_source.snapshot", + "rf_source.sweep_configure", + ), + session_state=InstrumentSessionState(), + ) + return service, driver + + +def test_sweep_configuration_uses_one_write_and_independent_readbacks() -> None: + request = _request() + service, driver = _service( + [_rf_snapshot(), _rf_snapshot()], + [_sweep_snapshot()], + ) + + result, artifact = service.configure_sweep_with_artifact(request) + + assert result.start_frequency_hz == request.start_frequency_hz + assert result.stop_frequency_hz == request.stop_frequency_hz + assert result.points == request.points + assert result.dwell_s == request.dwell_s + assert driver.requests == [request] + assert driver.calls == ["snapshot", "configure_sweep", "snapshot", "sweep_snapshot"] + assert artifact["operation"] == "rf_source.sweep_configure" + assert artifact["postcondition_sweep_snapshot"]["state"] == "disabled" + assert service.session_state is not None + assert service.session_state.health is SessionHealth.HEALTHY + + +@pytest.mark.parametrize( + ("snapshot", "message"), + ( + (_rf_snapshot(output_enabled=True), "target RF output OFF"), + (_rf_snapshot(modulation=RfModulationState.ENABLED), "modulation disabled"), + (_rf_snapshot(pulse=RfPulseState.ENABLED), "Pulse disabled"), + (_rf_snapshot(sweep=RfSweepState.ENABLED), "Sweep disabled"), + (_rf_snapshot(protection_codes=("overtemperature",)), "active protection"), + ), +) +def test_sweep_configuration_rejects_unsafe_preflight_without_write( + snapshot: RfSourceSnapshot, + message: str, +) -> None: + service, driver = _service([snapshot], []) + + with pytest.raises(ConfigError, match=message): + service.configure_sweep(_request()) + + assert driver.requests == [] + assert driver.calls == ["snapshot"] + assert service.session_state is not None + assert service.session_state.health is SessionHealth.HEALTHY + + +def test_sweep_configuration_checks_capability_access_and_static_profile_before_driver_io() -> None: + missing, missing_driver = _service( + [], + [], + descriptor=_descriptor("rf_source.idn", "rf_source.snapshot"), + ) + with pytest.raises(ConfigError, match="rf_source.sweep_configure"): + missing.configure_sweep(_request()) + assert missing_driver.calls == [] + + read_only, read_only_driver = _service([], [], access="read_only") + with pytest.raises(AccessDeniedError, match="rf_source.sweep_configure"): + read_only.configure_sweep(_request()) + assert read_only_driver.calls == [] + + range_service, range_driver = _service([], []) + with pytest.raises(ConfigError, match="outside the descriptor range"): + range_service.configure_sweep( + RfSweepConfigureRequest( + port_id="rf_out", + start_frequency_hz=1_000_000.0, + stop_frequency_hz=2_000_000.0, + points=65_536, + dwell_s=20e-3, + ) + ) + assert range_driver.calls == [] + + dwell_service, dwell_driver = _service([], []) + with pytest.raises(ConfigError, match="dwell_s is outside the descriptor range"): + dwell_service.configure_sweep(_request(dwell_s=10e-3)) + assert dwell_driver.calls == [] + + +@pytest.mark.parametrize( + "sweep_snapshot", + ( + _sweep_snapshot(stop_frequency_hz=2_100_000.0), + _sweep_snapshot(points=12), + _sweep_snapshot(dwell_s=30e-3), + _sweep_snapshot(state=RfSweepState.ENABLED), + ), +) +def test_sweep_configuration_mismatch_is_not_retried_and_degrades_session( + sweep_snapshot: RfSweepSnapshot, +) -> None: + service, driver = _service( + [_rf_snapshot(), _rf_snapshot()], + [sweep_snapshot], + ) + + with pytest.raises(ConfigError): + service.configure_sweep(_request()) + + assert driver.requests == [_request()] + assert driver.calls == ["snapshot", "configure_sweep", "snapshot", "sweep_snapshot"] + assert service.session_state is not None + assert service.session_state.health is SessionHealth.UNCERTAIN + + +def test_sweep_configuration_write_failure_is_not_retried_and_degrades_session() -> None: + service, driver = _service( + [_rf_snapshot()], + [], + raise_after_write=True, + ) + + with pytest.raises(ConfigError, match="failed after transmission"): + service.configure_sweep(_request()) + + assert driver.requests == [_request()] + assert driver.calls == ["snapshot", "configure_sweep"] + assert service.session_state is not None + assert service.session_state.health is SessionHealth.UNCERTAIN diff --git a/tests/test_rf_source_trigger_service.py b/tests/test_rf_source_trigger_service.py new file mode 100644 index 0000000..224e3a1 --- /dev/null +++ b/tests/test_rf_source_trigger_service.py @@ -0,0 +1,251 @@ +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from wavebench.config import ( + AutoscaleConfig, + ConnectionConfig, + OutputConfig, + RfSourceConfig, + ScopeConfig, + WaveBenchConfig, + WaveformConfig, +) +from wavebench.errors import ConfigError +from wavebench.instruments.rf_source_extensions import ( + RF_SOURCE_CONTRACT_VERSION, + RfExternalGatePolarity, + RfExternalTriggerEdge, + RfFeature, + RfFeatureCapability, + RfFeatureDirection, + RfOutputPortProfile, + RfPulseTriggerMode, + RfSourceDescriptorExtensions, + RfSourceTopology, + RfSweepMode, + RfSweepTriggerMode, + RfTriggerProfile, + RfTriggerSnapshot, +) +from wavebench.logging import CommandLogger +from wavebench.services.rf_source_service import RfSourceService +from wavebench.transport.session import InstrumentSessionState, SessionHealth + + +def _config(*, access: str = "read_only") -> WaveBenchConfig: + return WaveBenchConfig( + connection=ConnectionConfig("lan", "TCPIP::scope::INSTR", 1_000, 1_000), + 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"), + rf_source=RfSourceConfig( + driver="example.rf.trigger", + resource="TCPIP::rf::INSTR", + access=access, # type: ignore[arg-type] + ), + ) + + +def _profile(*, state_readable: bool = True) -> RfTriggerProfile: + if not state_readable: + return RfTriggerProfile(state_readable=False) + return RfTriggerProfile( + state_readable=True, + pulse_trigger_modes=( + RfPulseTriggerMode.AUTOMATIC, + RfPulseTriggerMode.BUS, + RfPulseTriggerMode.EXTERNAL, + RfPulseTriggerMode.EXTERNAL_GATE, + RfPulseTriggerMode.KEY, + ), + pulse_external_trigger_edges=( + RfExternalTriggerEdge.NEGATIVE, + RfExternalTriggerEdge.POSITIVE, + ), + pulse_external_gate_polarities=( + RfExternalGatePolarity.INVERTED, + RfExternalGatePolarity.NORMAL, + ), + sweep_modes=(RfSweepMode.CONTINUOUS, RfSweepMode.SINGLE), + sweep_period_trigger_modes=( + RfSweepTriggerMode.AUTOMATIC, + RfSweepTriggerMode.BUS, + RfSweepTriggerMode.EXTERNAL, + RfSweepTriggerMode.KEY, + ), + sweep_point_trigger_modes=( + RfSweepTriggerMode.AUTOMATIC, + RfSweepTriggerMode.BUS, + RfSweepTriggerMode.EXTERNAL, + RfSweepTriggerMode.KEY, + ), + ) + + +def _descriptor( + *capabilities: str, + profile: RfTriggerProfile | None = None, +) -> SimpleNamespace: + return SimpleNamespace( + driver_id="example.rf.trigger", + capabilities=capabilities, + rf_source_extensions=RfSourceDescriptorExtensions( + contract_version=RF_SOURCE_CONTRACT_VERSION, + topology=RfSourceTopology( + ( + RfOutputPortProfile( + port_id="rf_out", + frequency_min_hz=9_000.0, + frequency_max_hz=3_000_000_000.0, + power_min_dbm=-110.0, + power_max_dbm=20.0, + power_reference_impedance_ohm=50.0, + ), + ) + ), + features=( + RfFeatureCapability( + feature=RfFeature.TRIGGER, + directions=(RfFeatureDirection.READ,), + port_ids=("rf_out",), + profile=profile or _profile(), + ), + ), + ), + ) + + +def _snapshot( + *, + port_id: str = "rf_out", + pulse_trigger_mode: RfPulseTriggerMode = RfPulseTriggerMode.AUTOMATIC, +) -> RfTriggerSnapshot: + return RfTriggerSnapshot( + port_id=port_id, + pulse_trigger_mode=pulse_trigger_mode, + pulse_external_trigger_edge=RfExternalTriggerEdge.POSITIVE, + pulse_external_gate_polarity=RfExternalGatePolarity.NORMAL, + sweep_mode=RfSweepMode.CONTINUOUS, + sweep_period_trigger_mode=RfSweepTriggerMode.AUTOMATIC, + sweep_point_trigger_mode=RfSweepTriggerMode.AUTOMATIC, + ) + + +class _Driver: + def __init__(self, snapshot: RfTriggerSnapshot) -> None: + self.snapshot = snapshot + self.calls: list[str] = [] + self.write_calls: list[str] = [] + + def close(self) -> None: + self.calls.append("close") + + def get_rf_trigger_snapshot(self, port_id: str) -> RfTriggerSnapshot: + self.calls.append("trigger_snapshot") + assert port_id == "rf_out" + return self.snapshot + + +def _service( + snapshot: RfTriggerSnapshot, + *, + access: str = "read_only", + descriptor: SimpleNamespace | None = None, +) -> tuple[RfSourceService, _Driver]: + driver = _Driver(snapshot) + service = RfSourceService( + config=_config(access=access), + logger=CommandLogger(), + session=driver, + descriptor=descriptor + or _descriptor("rf_source.idn", "rf_source.trigger_snapshot"), + session_state=InstrumentSessionState(), + ) + return service, driver + + +def test_trigger_snapshot_reads_declared_state_without_writes() -> None: + service, driver = _service(_snapshot()) + + result, artifact = service.trigger_snapshot_with_artifact("rf_out") + + assert result.pulse_trigger_mode is RfPulseTriggerMode.AUTOMATIC + assert driver.calls == ["trigger_snapshot"] + assert driver.write_calls == [] + assert artifact["operation"] == "rf_source.trigger_snapshot" + assert artifact["trigger_snapshot"]["port_id"] == "rf_out" + assert service.session_state is not None + assert service.session_state.health is SessionHealth.HEALTHY + + +def test_trigger_snapshot_rejects_missing_capability_or_profile_before_driver_io() -> None: + missing_capability, missing_capability_driver = _service( + _snapshot(), + descriptor=_descriptor("rf_source.idn"), + ) + with pytest.raises(ConfigError, match="rf_source.trigger_snapshot"): + missing_capability.trigger_snapshot("rf_out") + assert missing_capability_driver.calls == [] + assert missing_capability_driver.write_calls == [] + + unreadable_profile, unreadable_profile_driver = _service( + _snapshot(), + descriptor=_descriptor( + "rf_source.idn", + "rf_source.trigger_snapshot", + profile=_profile(state_readable=False), + ), + ) + with pytest.raises(ConfigError, match="readable trigger profile"): + unreadable_profile.trigger_snapshot("rf_out") + assert unreadable_profile_driver.calls == [] + assert unreadable_profile_driver.write_calls == [] + + wrong_port, wrong_port_driver = _service(_snapshot()) + with pytest.raises(ConfigError, match="undeclared RF port"): + wrong_port.trigger_snapshot("not_a_port") + assert wrong_port_driver.calls == [] + assert wrong_port_driver.write_calls == [] + + +@pytest.mark.parametrize( + ("snapshot", "message"), + ( + (_snapshot(port_id="other"), "does not match the requested port"), + ( + _snapshot(pulse_trigger_mode=RfPulseTriggerMode.BUS), + "pulse trigger mode is outside the descriptor profile", + ), + ), +) +def test_trigger_snapshot_rejects_unexpected_readback_without_writes( + snapshot: RfTriggerSnapshot, + message: str, +) -> None: + profile = _profile() + if snapshot.pulse_trigger_mode is RfPulseTriggerMode.BUS: + profile = RfTriggerProfile( + state_readable=True, + pulse_trigger_modes=(RfPulseTriggerMode.AUTOMATIC,), + pulse_external_trigger_edges=(RfExternalTriggerEdge.POSITIVE,), + pulse_external_gate_polarities=(RfExternalGatePolarity.NORMAL,), + sweep_modes=(RfSweepMode.CONTINUOUS,), + sweep_period_trigger_modes=(RfSweepTriggerMode.AUTOMATIC,), + sweep_point_trigger_modes=(RfSweepTriggerMode.AUTOMATIC,), + ) + service, driver = _service( + snapshot, + descriptor=_descriptor("rf_source.idn", "rf_source.trigger_snapshot", profile=profile), + ) + + with pytest.raises(ConfigError, match=message): + service.trigger_snapshot("rf_out") + + assert driver.calls == ["trigger_snapshot"] + assert driver.write_calls == [] diff --git a/tests/test_run_service.py b/tests/test_run_service.py index 2cd3109..56c86e5 100644 --- a/tests/test_run_service.py +++ b/tests/test_run_service.py @@ -2443,7 +2443,7 @@ def test_frequency_response_applies_referenced_software_baseline_without_rewriti def test_runs_source_v2_arbitrary_steps_without_putting_payload_in_artifacts(self): with TemporaryDirectory() as tmp: - payload = b"abc" + payload = b"wavebench-payload-artifact-sentinel-9d7cc41e" payload_path = Path(tmp) / "payload.bin" payload_path.write_bytes(payload) digest = "sha256:" + sha256(payload).hexdigest() @@ -2512,7 +2512,7 @@ def _run_safety_guards(self, plan, *, services=None): self.assertEqual(select_request.playback_mode.value, "dds") self.assertEqual(select_request.playback_frequency_hz, 1_000.0) self.assertEqual(run_data["source_operations"], artifacts) - self.assertNotIn("abc", json.dumps(run_data, ensure_ascii=False)) + self.assertNotIn(payload.decode("ascii"), json.dumps(run_data, ensure_ascii=False)) if __name__ == "__main__": diff --git a/tests/test_scope_extension_registry.py b/tests/test_scope_extension_registry.py index ede0fa1..b706d3d 100644 --- a/tests/test_scope_extension_registry.py +++ b/tests/test_scope_extension_registry.py @@ -170,11 +170,12 @@ def test_public_scope_capability_requires_new_core_floor() -> None: def test_scope_descriptor_extension_is_append_only_for_positional_compatibility() -> None: names = [field.name for field in fields(InstrumentDescriptor)] - assert names[-4:] == [ + assert names[-5:] == [ "config_fields", "resource_schemes", "scope_extensions", "source_extensions", + "rf_source_extensions", ] assert [field.name for field in fields(ScopeDescriptorExtensions)] == [ "screenshot_profile", diff --git a/tests/test_source_extensions.py b/tests/test_source_extensions.py index dcf69dc..be45cd7 100644 --- a/tests/test_source_extensions.py +++ b/tests/test_source_extensions.py @@ -496,11 +496,12 @@ def test_source_descriptor_append_only_and_replace_compatible() -> None: descriptor = source_descriptor(driver=SourceV2FakeDriver(combined=True)) names = [item.name for item in fields(InstrumentDescriptor)] - assert names[-4:] == [ + assert names[-5:] == [ "config_fields", "resource_schemes", "scope_extensions", "source_extensions", + "rf_source_extensions", ] assert replace(descriptor, summary="changed").source_extensions is descriptor.source_extensions diff --git a/wavebench.example.toml b/wavebench.example.toml index 9c4803e..d83fadb 100644 --- a/wavebench.example.toml +++ b/wavebench.example.toml @@ -176,6 +176,27 @@ settle_ms_after_set_frequency = 500 # maximum_ohm = 50.5 +# RF signal sources use an independent domain: Hz and dBm on stable port IDs, +# not the Vpp/channel model above. `read_only` remains the safe default for +# identity/status. Install the matching RF plugin before enabling this section. +# [rf_source] +# driver = "rigol.dsg830" +# resource = "TCPIP::192.0.2.13::INSTR" +# access = "read_only" + +# DSG830 A2 output ON/OFF additionally requires `access = "read_write"`, its +# production `rf_source.output` capability, and one complete local declaration +# per port. CW frequency/power writes remain separately gated. +# `actual_termination_ohm` is the physical termination evidence; it is not a +# display-load setting and WaveBench never converts dBm to Vpp. +# [[rf_source.safety.ports]] +# port_id = "rf_out" +# minimum_frequency_hz = 9000 +# maximum_frequency_hz = 3000000000 +# maximum_power_dbm = -20 +# actual_termination_ohm = 50 + + [power] # Power supply driver. Current read-only power support targets Rigol DP800 series. driver = "dp800"