diff --git a/README.md b/README.md index 36ac487..ea217ee 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ WaveBench 仪器插件的独立源码仓库。仓库计划按“一台仪器或 ## 当前状态 -正式源码包已进入独立维护:`wavebench-rigol-ds1000z`、`wavebench-rigol-dg4000`、LAN-only 的 `wavebench-rigol-dm3000`、`wavebench-rigol-dp800` 与 `wavebench-rohde-schwarz-rtm2000` 已完成离线、受管生命周期和受控实机验收;`wavebench-shengpu-sp3000a` 已进入 SP30120 M3.5,保留最小 query-only descriptor 并提供五项经认证的厂商专用 RF-OFF 控制。前五个包是 WaveBench 预装驱动的可选外置发行版,用于独立升级、特定 transport 或后续扩展,并不替代或淘汰主包的开箱即用基线。WaveBench v0.8.0 已提供本地 package check、受管安装、状态查询、升级/降级、卸载和保守事务恢复;本仓库只维护插件源码,不重复实现安装器或远程 catalog。 +正式源码包已进入独立维护:`wavebench-rigol-ds1000z`、`wavebench-rigol-dg4000`、LAN-only 的 `wavebench-rigol-dm3000`、`wavebench-rigol-dp800` 与 `wavebench-rohde-schwarz-rtm2000` 已完成离线、受管生命周期和受控实机验收;`wavebench-rigol-dg1000` 当前完成离线与受管生命周期验证,并保持 basic source 控制面;`wavebench-shengpu-sp3000a` 已进入 SP30120 M3.5,保留最小 query-only descriptor 并提供五项经认证的厂商专用 RF-OFF 控制。这些包是 WaveBench 预装驱动或新增仪器族的可选外置发行版,用于独立升级、特定 transport 或后续扩展,并不替代或淘汰主包的开箱即用基线。WaveBench v0.8.0 已提供本地 package check、受管安装、状态查询、升级/降级、卸载和保守事务恢复;本仓库只维护插件源码,不重复实现安装器或远程 catalog。 > [!IMPORTANT] > WaveBench `v0.7.0` 尚不包含 Instrument API V2、受管插件生命周期或覆盖槽位。本仓库当前包面向 WaveBench `v0.8.0` release,并统一要求 `wavebench>=0.8,<0.9`;它们不能与 `v0.7.0` 配套运行,也不能自动假定兼容未来 `0.9`。 @@ -15,6 +15,7 @@ WaveBench 仪器插件的独立源码仓库。仓库计划按“一台仪器或 ```text packages/ +├── wavebench-rigol-dg1000/ ├── wavebench-rigol-dg4000/ ├── wavebench-rigol-dm3000/ ├── wavebench-rigol-dp800/ @@ -37,6 +38,7 @@ WaveBench 主包长期预装 RTM2000、DS1000Z、DG4000、DP800 和 DM3000 五 ## 当前插件 - [`wavebench-rigol-ds1000z`](packages/wavebench-rigol-ds1000z/README.md):四通道 RIGOL DS1104Z / DS1000Z 系列,canonical ID `rigol.ds1000z`。 +- [`wavebench-rigol-dg1000`](packages/wavebench-rigol-dg1000/README.md):双通道 RIGOL DG1000/DG1000Z 系列,canonical ID `rigol.dg1000`,basic source 控制面。 - [`wavebench-rigol-dg4000`](packages/wavebench-rigol-dg4000/README.md):双通道 RIGOL DG4202 / DG4000 系列,canonical ID `rigol.dg4202`。 - [`wavebench-rigol-dm3000`](packages/wavebench-rigol-dm3000/README.md):LAN-only RIGOL DM3000 / DM3058 数字万用表,canonical ID `rigol.dm3000`;短 alias 保留内建双 backend fallback。 - [`wavebench-rigol-dp800`](packages/wavebench-rigol-dp800/README.md):RIGOL DP800 / DP832 / DP832A 可编程直流电源,canonical ID `rigol.dp800`;短 alias 保留内建 fallback。 diff --git a/doc/README_EN.md b/doc/README_EN.md index ba52a46..821e35f 100644 --- a/doc/README_EN.md +++ b/doc/README_EN.md @@ -6,7 +6,7 @@ This repository is reserved for independently packaged WaveBench instrument plug ## Current status -The source packages are maintained independently: `wavebench-rigol-ds1000z`, `wavebench-rigol-dg4000`, the LAN-only `wavebench-rigol-dm3000`, `wavebench-rigol-dp800`, and `wavebench-rohde-schwarz-rtm2000` have completed offline, managed-lifecycle, and controlled hardware acceptance; `wavebench-shengpu-sp3000a` is at SP30120 M3.5 with a minimal query-only descriptor and five certified vendor-specific RF-OFF controls. The first five are optional external editions of drivers that remain bundled with WaveBench for first-use operation; they provide an independent upgrade, transport, and extension path rather than replacing or deprecating the bundled baseline. WaveBench v0.8.0 provides local package inspection and managed install, status, upgrade, downgrade, removal, and conservative transaction recovery. This repository owns plugin source packages and does not duplicate the installer or a remote catalog. +The source packages are maintained independently: `wavebench-rigol-ds1000z`, `wavebench-rigol-dg4000`, the LAN-only `wavebench-rigol-dm3000`, `wavebench-rigol-dp800`, and `wavebench-rohde-schwarz-rtm2000` have completed offline, managed-lifecycle, and controlled hardware acceptance; `wavebench-rigol-dg1000` currently has offline and managed-lifecycle coverage and keeps a basic source control surface; `wavebench-shengpu-sp3000a` is at SP30120 M3.5 with a minimal query-only descriptor and five certified vendor-specific RF-OFF controls. These packages are optional external editions of bundled drivers or new instrument families; they provide an independent upgrade, transport, and extension path rather than replacing or deprecating the bundled baseline. WaveBench v0.8.0 provides local package inspection and managed install, status, upgrade, downgrade, removal, and conservative transaction recovery. This repository owns plugin source packages and does not duplicate the installer or a remote catalog. > [!IMPORTANT] > The WaveBench `v0.7.0` release does not contain Instrument API V2, the managed plugin lifecycle, or canonical override slots. These packages target the WaveBench `v0.8.0` release and uniformly require `wavebench>=0.8,<0.9`; they do not run with `v0.7.0` and do not assume compatibility with a future `0.9` core. @@ -15,6 +15,7 @@ The source packages are maintained independently: `wavebench-rigol-ds1000z`, `wa ```text packages/ +├── wavebench-rigol-dg1000/ ├── wavebench-rigol-dg4000/ ├── wavebench-rigol-dm3000/ ├── wavebench-rigol-dp800/ @@ -37,6 +38,7 @@ WaveBench permanently bundles the RTM2000, DS1000Z, DG4000, DP800, and DM3000 fa ## Current plugin - [`wavebench-rigol-ds1000z`](../packages/wavebench-rigol-ds1000z/README_EN.md): four-channel RIGOL DS1104Z / DS1000Z series, canonical ID `rigol.ds1000z`. +- [`wavebench-rigol-dg1000`](../packages/wavebench-rigol-dg1000/README_EN.md): dual-channel RIGOL DG1000/DG1000Z series, canonical ID `rigol.dg1000`, basic source control surface. - [`wavebench-rigol-dg4000`](../packages/wavebench-rigol-dg4000/README_EN.md): dual-channel RIGOL DG4202 / DG4000 series, canonical ID `rigol.dg4202`. - [`wavebench-rigol-dm3000`](../packages/wavebench-rigol-dm3000/README_EN.md): LAN-only RIGOL DM3000 / DM3058 multimeter, canonical ID `rigol.dm3000`; short aliases retain the built-in dual-backend fallback. - [`wavebench-rigol-dp800`](../packages/wavebench-rigol-dp800/README_EN.md): RIGOL DP800 / DP832 / DP832A programmable DC power supply, canonical ID `rigol.dp800`; its short alias remains on the built-in fallback. diff --git a/packages/README.md b/packages/README.md index a673387..5402511 100644 --- a/packages/README.md +++ b/packages/README.md @@ -2,7 +2,7 @@ [English](README_EN.md) -该目录用于放置独立的 WaveBench 仪器插件源码包。当前正式包为 `wavebench-rigol-ds1000z`、`wavebench-rigol-dg4000`、LAN-only 的 `wavebench-rigol-dm3000`、`wavebench-rigol-dp800`、已完成 SocketIO 数据路径实机验收的 `wavebench-rohde-schwarz-rtm2000` 和 M3 query-only 的 `wavebench-shengpu-sp3000a`。前五个包是主包预装驱动的可选外置发行版;后续也按仪器或紧密相关系列逐包维护,但不以移除预装驱动为目标,也不在这里冻结第二套 manifest、安装器或 catalog 协议。 +该目录用于放置独立的 WaveBench 仪器插件源码包。当前正式包为 `wavebench-rigol-ds1000z`、`wavebench-rigol-dg1000`、`wavebench-rigol-dg4000`、LAN-only 的 `wavebench-rigol-dm3000`、`wavebench-rigol-dp800`、已完成 SocketIO 数据路径实机验收的 `wavebench-rohde-schwarz-rtm2000` 和 M3 query-only 的 `wavebench-shengpu-sp3000a`。这些包是主包预装驱动或新增仪器族的可选外置发行版;后续也按仪器或紧密相关系列逐包维护,但不以移除预装驱动为目标,也不在这里冻结第二套 manifest、安装器或 catalog 协议。 这些包当前对齐 WaveBench `v0.8.0` release,并统一声明 `wavebench>=0.8,<0.9`。它们不能与 `v0.7.0` 配套运行,也不会把未来 `0.9` 自动视为兼容版本。 diff --git a/packages/README_EN.md b/packages/README_EN.md index 8f73d23..9af7b88 100644 --- a/packages/README_EN.md +++ b/packages/README_EN.md @@ -2,7 +2,7 @@ [中文](README.md) -This directory contains independently packaged WaveBench instrument plugins. The current maintained packages are `wavebench-rigol-ds1000z`, `wavebench-rigol-dg4000`, the LAN-only `wavebench-rigol-dm3000`, `wavebench-rigol-dp800`, the SocketIO-data-path hardware-accepted `wavebench-rohde-schwarz-rtm2000`, and the M3 query-only `wavebench-shengpu-sp3000a`. The first five are optional external editions of drivers bundled with the main package. Future packages should continue the one-instrument-or-family layout without treating bundled-driver removal as a goal or defining a second manifest, installer, or catalog protocol here. +This directory contains independently packaged WaveBench instrument plugins. The current maintained packages are `wavebench-rigol-ds1000z`, `wavebench-rigol-dg1000`, `wavebench-rigol-dg4000`, the LAN-only `wavebench-rigol-dm3000`, `wavebench-rigol-dp800`, the SocketIO-data-path hardware-accepted `wavebench-rohde-schwarz-rtm2000`, and the M3 query-only `wavebench-shengpu-sp3000a`. These packages are optional external editions of bundled drivers or new instrument families. Future packages should continue the one-instrument-or-family layout without treating bundled-driver removal as a goal or defining a second manifest, installer, or catalog protocol here. These packages target the WaveBench `v0.8.0` release and uniformly declare `wavebench>=0.8,<0.9`. They do not run with `v0.7.0` release and do not automatically assume compatibility with a future `0.9` core. diff --git a/packages/wavebench-rigol-dg1000/LICENSE b/packages/wavebench-rigol-dg1000/LICENSE new file mode 100644 index 0000000..1455c59 --- /dev/null +++ b/packages/wavebench-rigol-dg1000/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Scaxlibur + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/wavebench-rigol-dg1000/README.md b/packages/wavebench-rigol-dg1000/README.md new file mode 100644 index 0000000..07d15b3 --- /dev/null +++ b/packages/wavebench-rigol-dg1000/README.md @@ -0,0 +1,91 @@ +# WaveBench RIGOL DG1000 插件 + +[English](README_EN.md) + +面向双通道 RIGOL DG1022、DG1022A、DG1022Z、DG1032Z 和 DG1062Z +函数/任意波形发生器的 WaveBench 可执行仪器插件。 + +## 身份与兼容范围 + +- distribution:`wavebench-rigol-dg1000` +- canonical driver ID:`rigol.dg1000` +- WaveBench:`>=0.8,<0.9` +- Python:`>=3.11` +- transport backend:`pyvisa` + +该插件声明 canonical ID `rigol.dg1000`,不声明短 alias。安装后,需要在配置中显式使用 +`driver = "rigol.dg1000"` 才会选择此外置实现。 + +## 能力 + +- `*IDN?` 与错误队列; +- CH1/CH2 输出、函数、频率、VPP 幅度、offset、phase、sweep 状态和方波占空比读取; +- 固定频率、函数、VPP 幅度和方波占空比设置; +- 显式输出开关; +- 设置固定频率前可按 WaveBench source 配置显式关闭 sweep。 + +当前公开能力只覆盖 basic source 控制面。插件不声明 DG4000/DG4202 专用的任意波上传、harmonic +mode、modulation、burst、counter profile 或完整 sweep profile。DG1000 前面板或厂商协议中的 +加法/谐波叠加功能不在本插件的受支持能力内;启用该功能时,基波与谐波的实际输出很可能与设置值 +不一致,不能把 basic status 回读视为准确的谐波验收依据。WaveBench core 继续负责安全上限、 +Service、run plan、状态恢复和 artifact;插件只负责 DG1000 系列 SCPI、解析和回读。 + +驱动同时覆盖两种已知命令布局:DG1022/DG1022A 的 legacy `:CH2` 后缀布局,以及 +DG1022Z/DG1032Z/DG1062Z 的 `:SOUR:` 前缀布局。未识别型号会 fail closed。 + +## 安全边界 + +descriptor 导入不连接仪器。factory 只通过 WaveBench `DriverContext` 打开当前配置的 transport。 +默认离线测试使用 FakeTransport,不扫描资源、不连接仪器,也不发送真实 SCPI。写命令失败后, +当前 driver 实例会锁停后续配置写入,要求调用方重新打开会话并独立验证设备状态。输出启用 +后的回读或错误队列检查失败时,driver 会强制关闭对应输出并回读确认;若 OFF 无法确认,配置 +写入同样锁停。legacy DG1022/DG1022A 的 sweep 只属于 CH1,CH2 状态固定规范化为 FIX/OFF, +设置 CH2 频率不会查询或关闭 CH1 sweep。 + +配置示例使用 RFC 5737 文档保留地址: + +```toml +[source] +driver = "rigol.dg1000" +resource = "TCPIP::192.0.2.30::INSTR" +default_channel = 1 +check_errors = true +ensure_fix_mode_on_set_frequency = true +settle_ms_after_set_frequency = 500 +``` + +真实仪器地址、序列号、波形、截图和命令日志不得提交。 + +## 许可证 + +本插件采用 [MIT License](LICENSE)。 + +## 开发验证 + +在已安装匹配的 WaveBench `v0.8.0` release 环境中: + +```bash +python -m pytest -q packages/wavebench-rigol-dg1000/tests +python -m ruff check packages/wavebench-rigol-dg1000 +python -m wavebench plugin package check packages/wavebench-rigol-dg1000 +python -m wavebench plugin install packages/wavebench-rigol-dg1000 --dry-run +``` + +日常源码开发可使用仓库级 [editable 开发环境](../../doc/DEVELOPMENT.md);正式验收仍使用真实 +wheel 和一次性虚拟环境。 +型号覆盖和能力边界见 [DG1000 覆盖矩阵](doc/DG1000_COVERAGE_MATRIX.md)。 + +## 实机验收边界 + +`0.1.0` 的公开门禁目前覆盖离线 FakeTransport、受管安装生命周期和 wheel 检查。DG1032Z +直连示波器的闭环实验台可用于后续实机门禁;在形成可复现、已脱敏的实机记录前,本包不把 +DG1032Z 行为外推到 DG1022Z/DG1062Z 或 legacy DG1022/DG1022A 命令布局。 + +实机验收记录不得提交真实资源、序列号、原始波形、截图或命令日志。当前 capability 仍只代表 +basic source 控制面,不覆盖任意波上传、offset/symmetry setter、modulation、burst、counter 或 +完整 sweep profile。 + +## 来源 + +`0.1.0`:从 WaveBench 主仓 DG1000 草案实现迁移为独立插件包,保留 vendor driver、descriptor、 +entry point 和 FakeTransport 测试;当前只声明 basic source 能力。 diff --git a/packages/wavebench-rigol-dg1000/README_EN.md b/packages/wavebench-rigol-dg1000/README_EN.md new file mode 100644 index 0000000..7f5c5a6 --- /dev/null +++ b/packages/wavebench-rigol-dg1000/README_EN.md @@ -0,0 +1,102 @@ +# WaveBench RIGOL DG1000 Plugin + +[中文](README.md) + +An executable WaveBench instrument plugin for dual-channel RIGOL DG1022, DG1022A, DG1022Z, +DG1032Z, and DG1062Z function/arbitrary waveform generators. + +## Identity and compatibility + +- Distribution: `wavebench-rigol-dg1000` +- Canonical driver ID: `rigol.dg1000` +- WaveBench: `>=0.8,<0.9` +- Python: `>=3.11` +- Transport backend: `pyvisa` + +The plugin declares the canonical ID `rigol.dg1000` and no short aliases. After installation, +select it explicitly with `driver = "rigol.dg1000"` in the WaveBench configuration. + +## Capabilities + +- `*IDN?` and error queue; +- CH1/CH2 output, function, frequency, VPP amplitude, offset, phase, sweep state, and square duty + cycle status; +- fixed frequency, function, VPP amplitude, and square duty-cycle settings; +- explicit output control; +- optional explicit sweep disable before setting a fixed frequency through WaveBench source config. + +The public surface is intentionally limited to basic source control. The plugin does not claim the +DG4000/DG4202 arbitrary-waveform upload path, harmonic mode, modulation, burst, counter profile, +or a complete sweep profile. The DG1000 front-panel/vendor add or harmonic-superposition feature +is outside this plugin's supported surface; when it is enabled, the actual fundamental and harmonic +outputs are likely to differ from the configured values, so basic status readback must not be used +as accurate harmonic acceptance evidence. WaveBench core retains safety limits, services, run +plans, state restoration, and artifacts; this package owns only DG1000-family SCPI, parsing, and +readback. + +The driver covers two known command layouts: the legacy `:CH2` suffix layout used by DG1022/DG1022A +and the `:SOUR:` prefixed layout used by DG1022Z/DG1032Z/DG1062Z. Unknown models fail closed. + +## Safety boundary + +Descriptor import performs no instrument I/O. The factory opens only the configured transport +through WaveBench `DriverContext`. Default offline tests use FakeTransport only; they do not scan +resources, connect to instruments, or send real SCPI. After an ambiguous write, the driver instance +latches later configuration writes off and requires the caller to reopen the session and verify +instrument state independently. If readback or error-queue checks fail after output enable, the +driver forces that output OFF and verifies the readback; an unverified OFF recovery also latches +configuration writes. Sweep is CH1-only on legacy DG1022/DG1022A models, so CH2 status is normalized +to FIX/OFF and CH2 frequency writes never query or disable the CH1 sweep. + +The example uses an RFC 5737 documentation address: + +```toml +[source] +driver = "rigol.dg1000" +resource = "TCPIP::192.0.2.30::INSTR" +default_channel = 1 +check_errors = true +ensure_fix_mode_on_set_frequency = true +settle_ms_after_set_frequency = 500 +``` + +Do not commit real addresses, serial numbers, waveforms, screenshots, or command logs. + +## License + +This plugin is licensed under the [MIT License](LICENSE). + +## Development checks + +Run the package tests, Ruff, WaveBench package inspection, and a managed-install dry run from an +environment containing the matching WaveBench `v0.8.0` release. + +```bash +python -m pytest -q packages/wavebench-rigol-dg1000/tests +python -m ruff check packages/wavebench-rigol-dg1000 +python -m wavebench plugin package check packages/wavebench-rigol-dg1000 +python -m wavebench plugin install packages/wavebench-rigol-dg1000 --dry-run +``` + +Use the repository-level [editable development environment](../../doc/DEVELOPMENT_EN.md) for daily +source work. Formal acceptance still uses a real wheel and a disposable virtual environment. +See the [DG1000 coverage matrix](doc/DG1000_COVERAGE_MATRIX_EN.md) for model coverage and +capability boundaries. + +## Hardware acceptance boundary + +The public `0.1.0` gate currently covers offline FakeTransport tests, managed-install lifecycle +checks, and wheel checks. A DG1032Z-to-oscilloscope closed-loop bench can serve as a future +hardware gate. Until a reproducible, sanitized hardware record exists, this package does not +extrapolate DG1032Z behavior to DG1022Z/DG1062Z or to the legacy DG1022/DG1022A command layout. + +Hardware records must not commit real resources, serial numbers, raw waveforms, screenshots, or +command logs. The current capability surface still means basic source control only; it does not +cover arbitrary-waveform upload, offset/symmetry setters, modulation, burst, counter, or a complete +sweep profile. + +## Provenance + +- `0.1.0` migrated the WaveBench core DG1000 draft into an independent plugin package, preserving + the vendor driver, descriptor, entry point, and FakeTransport tests. The package currently + declares only basic source capabilities. diff --git a/packages/wavebench-rigol-dg1000/doc/DG1000_COVERAGE_MATRIX.md b/packages/wavebench-rigol-dg1000/doc/DG1000_COVERAGE_MATRIX.md new file mode 100644 index 0000000..50a17b2 --- /dev/null +++ b/packages/wavebench-rigol-dg1000/doc/DG1000_COVERAGE_MATRIX.md @@ -0,0 +1,39 @@ +# RIGOL DG1000 覆盖矩阵 + +[English](DG1000_COVERAGE_MATRIX_EN.md) + +本文记录 `wavebench-rigol-dg1000` `0.1.0` 的公开能力边界。默认门禁只使用 +FakeTransport、wheel 检查和受管安装生命周期检查;真实仪器验收必须单独授权,并且只能提交脱敏 +结论,不能提交真实 resource、序列号、波形、截图或命令日志。 + +## 型号范围 + +| 型号族 | 命令布局 | 当前状态 | +| --- | --- | --- | +| DG1022 / DG1022A | legacy `:CH2` 后缀 | FakeTransport 覆盖;未声明实机验收完成 | +| DG1022Z / DG1032Z / DG1062Z | `:SOUR:` 前缀 | FakeTransport 覆盖;DG1032Z 可作为后续闭环实机门禁 | + +## Capability 边界 + +| WaveBench capability | 状态 | 说明 | +| --- | --- | --- | +| `source.idn` | 已声明 | `*IDN?` 只读身份 | +| `source.errors` | 已声明 | `SYST:ERR?` 错误队列 | +| `source.status` | 已声明 | CH1/CH2 basic status,包括输出、函数、频率、VPP、offset、phase、sweep 和方波占空比;legacy CH2 的 sweep 固定规范化为 OFF | +| `source.set_frequency` | 已声明 | 固定频率设置;source-layout 型号可按配置关闭目标通道 sweep;legacy CH2 不触碰 CH1 sweep | +| `source.set_function` | 已声明 | 基本函数设置 | +| `source.set_amplitude_vpp` | 已声明 | VPP 幅度设置 | +| `source.set_square_duty_cycle` | 已声明 | 方波占空比设置 | +| `source.output` | 已声明 | 显式输出开关 | +| `source.harmonic_profile` / `source.harmonic_configure` | 未声明 | DG1000 加法/谐波叠加功能不在支持面;启用时基波与谐波实际输出大概率与设置值不同,不准确 | +| `source.arbitrary_upload` | 未声明 | 不复用 DG4000/DG4202 任意波上传路径 | +| modulation / burst / counter / full sweep profile | 未声明 | 不把厂商菜单能力映射成 WaveBench 通用 capability | + +## 验收原则 + +- capability 只代表已实现并测试的行为; +- descriptor 导入不得连接仪器、扫描端口或发送 SCPI; +- factory 只能通过 `DriverContext.open_transport()` 打开当前配置的 transport; +- 写入失败后不自动重试输出、trigger 或已开始消费响应的数据路径; +- 输出启用后的事务失败只执行安全方向的 OFF 恢复并要求回读确认,不重试 ON; +- 实机验收应记录可恢复状态、外部测量证据和输出关闭检查,并在公开提交中脱敏。 diff --git a/packages/wavebench-rigol-dg1000/doc/DG1000_COVERAGE_MATRIX_EN.md b/packages/wavebench-rigol-dg1000/doc/DG1000_COVERAGE_MATRIX_EN.md new file mode 100644 index 0000000..83eae5a --- /dev/null +++ b/packages/wavebench-rigol-dg1000/doc/DG1000_COVERAGE_MATRIX_EN.md @@ -0,0 +1,43 @@ +# RIGOL DG1000 coverage matrix + +[中文](DG1000_COVERAGE_MATRIX.md) + +This document records the public boundary for `wavebench-rigol-dg1000` `0.1.0`. The default gate +uses FakeTransport tests, wheel checks, and managed-install lifecycle checks only. Hardware +acceptance must be separately authorized, and public records must contain sanitized conclusions +instead of real resources, serial numbers, waveforms, screenshots, or command logs. + +## Model scope + +| Model family | Command layout | Current status | +| --- | --- | --- | +| DG1022 / DG1022A | legacy `:CH2` suffix | Covered by FakeTransport; no completed hardware acceptance is claimed | +| DG1022Z / DG1032Z / DG1062Z | `:SOUR:` prefix | Covered by FakeTransport; DG1032Z can be used for a future closed-loop hardware gate | + +## Capability boundary + +| WaveBench capability | Status | Notes | +| --- | --- | --- | +| `source.idn` | Declared | `*IDN?` identity readback | +| `source.errors` | Declared | `SYST:ERR?` error queue | +| `source.status` | Declared | CH1/CH2 basic status: output, function, frequency, VPP, offset, phase, sweep, and square duty cycle; legacy CH2 sweep is normalized to OFF | +| `source.set_frequency` | Declared | Fixed-frequency setting; source-layout models may disable the target-channel sweep; legacy CH2 never touches CH1 sweep | +| `source.set_function` | Declared | Basic function setting | +| `source.set_amplitude_vpp` | Declared | VPP amplitude setting | +| `source.set_square_duty_cycle` | Declared | Square duty-cycle setting | +| `source.output` | Declared | Explicit output switching | +| `source.harmonic_profile` / `source.harmonic_configure` | Not declared | DG1000 add / harmonic-superposition mode is outside the supported surface; when enabled, actual fundamental and harmonic outputs are likely different from configured values and inaccurate | +| `source.arbitrary_upload` | Not declared | Does not reuse the DG4000/DG4202 arbitrary-waveform upload path | +| modulation / burst / counter / full sweep profile | Not declared | Vendor menu features are not mapped to generic WaveBench capabilities | + +## Acceptance rules + +- capabilities describe implemented and tested behavior only; +- descriptor import must not connect to instruments, scan ports, or send SCPI; +- the factory must open the configured transport only through `DriverContext.open_transport()`; +- after write failures, do not automatically retry output switching, triggers, or data paths whose + response has started being consumed; +- a failed output-enable transaction performs only a safety-direction OFF recovery with verified + readback; it never retries ON; +- hardware acceptance should record recoverable state, external measurement evidence, and output-off + checks, with public submissions sanitized. diff --git a/packages/wavebench-rigol-dg1000/pyproject.toml b/packages/wavebench-rigol-dg1000/pyproject.toml new file mode 100644 index 0000000..67d16a2 --- /dev/null +++ b/packages/wavebench-rigol-dg1000/pyproject.toml @@ -0,0 +1,29 @@ +[build-system] +requires = ["hatchling>=1.25"] +build-backend = "hatchling.build" + +[project] +name = "wavebench-rigol-dg1000" +version = "0.1.0" +description = "RIGOL DG1000 function generator plugin for WaveBench" +readme = "README.md" +requires-python = ">=3.11" +license = "MIT" +license-files = ["LICENSE"] +dependencies = ["wavebench>=0.8,<0.9"] + +[project.optional-dependencies] +dev = ["pytest>=8.0.0", "ruff>=0.8.0"] + +[project.entry-points."wavebench.instruments"] +"rigol.dg1000" = "wavebench_rigol_dg1000:descriptor" + +[tool.hatch.build.targets.wheel] +packages = ["src/wavebench_rigol_dg1000"] + +[tool.pytest.ini_options] +pythonpath = ["src"] +testpaths = ["tests"] + +[tool.ruff] +line-length = 100 diff --git a/packages/wavebench-rigol-dg1000/src/wavebench_rigol_dg1000/__init__.py b/packages/wavebench-rigol-dg1000/src/wavebench_rigol_dg1000/__init__.py new file mode 100644 index 0000000..7430000 --- /dev/null +++ b/packages/wavebench-rigol-dg1000/src/wavebench_rigol_dg1000/__init__.py @@ -0,0 +1,3 @@ +from .descriptor import descriptor + +__all__ = ["descriptor"] diff --git a/packages/wavebench-rigol-dg1000/src/wavebench_rigol_dg1000/descriptor.py b/packages/wavebench-rigol-dg1000/src/wavebench_rigol_dg1000/descriptor.py new file mode 100644 index 0000000..15ba17a --- /dev/null +++ b/packages/wavebench-rigol-dg1000/src/wavebench_rigol_dg1000/descriptor.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from wavebench.instruments import InstrumentDescriptor + + +def _open_driver(context): + from .driver import DG1000Source + + return DG1000Source( + transport=context.open_transport(), + check_errors_after_ops=bool(context.settings["check_errors"]), + ) + + +def descriptor() -> InstrumentDescriptor: + return InstrumentDescriptor( + driver_id="rigol.dg1000", + kind="source", + display_name="RIGOL DG1000/DG1000Z Function/Arbitrary Waveform Generator", + manufacturer="RIGOL Technologies", + models=("DG1022", "DG1022A", "DG1022Z", "DG1032Z", "DG1062Z"), + aliases=(), + capabilities=( + "source.idn", + "source.errors", + "source.status", + "source.set_frequency", + "source.set_function", + "source.set_amplitude_vpp", + "source.set_square_duty_cycle", + "source.output", + ), + idn_patterns=("RIGOL TECHNOLOGIES,DG10",), + backends=("pyvisa",), + option_specs=(), + permissions=("instrument.io", "configured-resource-only"), + factory=_open_driver, + summary=( + "Installable RIGOL DG1000-series source driver for basic frequency, " + "waveform, amplitude, duty-cycle, and output control." + ), + wavebench_min_version="0.8.0", + wavebench_max_version="0.9.0", + distribution="wavebench-rigol-dg1000", + version="0.1.0", + source="entry_point:rigol.dg1000", + config_fields=( + "source.resource", + "source.driver", + "safety_limits.max_source_vpp", + ), + ) diff --git a/packages/wavebench-rigol-dg1000/src/wavebench_rigol_dg1000/driver.py b/packages/wavebench-rigol-dg1000/src/wavebench_rigol_dg1000/driver.py new file mode 100644 index 0000000..0a1d5ea --- /dev/null +++ b/packages/wavebench-rigol-dg1000/src/wavebench_rigol_dg1000/driver.py @@ -0,0 +1,449 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +import logging +from math import isclose, isfinite +from threading import RLock + +from wavebench.errors import DataError, InstrumentError +from wavebench.instruments import SourceStatus + + +_LOG = logging.getLogger(__name__) +_LEGACY_MODELS = frozenset({"DG1022", "DG1022A"}) +_SOURCE_LAYOUT_MODELS = frozenset({"DG1022Z", "DG1032Z", "DG1062Z"}) +_KNOWN_MODELS = _LEGACY_MODELS | _SOURCE_LAYOUT_MODELS +_FUNCTION_ALIASES = { + "SIN": "SIN", + "SINE": "SIN", + "SINUSOID": "SIN", + "SQU": "SQU", + "SQUARE": "SQU", + "RAMP": "RAMP", + "TRI": "RAMP", + "TRIANGLE": "RAMP", + "PULS": "PULS", + "PULSE": "PULS", + "NOIS": "NOIS", + "NOISE": "NOIS", + "DC": "DC", + "USER": "USER", + "ARB": "USER", +} +_RESTORABLE_FUNCTIONS = frozenset({"SIN", "SQU", "RAMP", "PULS", "NOIS", "DC", "USER"}) + + +def _validate_channel(channel: int) -> None: + if channel not in (1, 2): + raise DataError("DG1000 channel must be 1 or 2") + + +def _finite_float(value: object, *, field_name: str) -> float: + text = str(value).strip().strip('"') + if ":" in text: + text = text.split(":", 1)[1] + try: + parsed = float(text) + except (TypeError, ValueError) as exc: + raise DataError(f"{field_name} must be a finite number") from exc + if not isfinite(parsed): + raise DataError(f"{field_name} must be a finite number") + return parsed + + +def _normalize_enum(value: object, *, field_name: str, aliases: dict[str, str]) -> str: + text = str(value).strip().strip('"') + if ":" in text: + text = text.split(":", 1)[1] + normalized = text.upper() + try: + return aliases[normalized] + except KeyError as exc: + raise DataError(f"unexpected {field_name} response: {value!r}") from exc + + +def _parse_identity(response: str) -> tuple[str, str, str, str]: + parts = tuple(item.strip() for item in response.split(",")) + if len(parts) != 4 or any(not item for item in parts): + raise DataError("unexpected DG1000 *IDN? response") + manufacturer, model, serial_number, firmware = parts + if manufacturer.upper() != "RIGOL TECHNOLOGIES": + raise DataError(f"unexpected DG1000 manufacturer: {manufacturer!r}") + normalized_model = model.upper() + if normalized_model not in _KNOWN_MODELS: + raise DataError(f"unsupported DG1000 model: {model!r}") + return manufacturer, normalized_model, serial_number, firmware + + +def _channel_suffix(channel: int) -> str: + _validate_channel(channel) + return "" if channel == 1 else ":CH2" + + +def _parse_apply_function(response: str) -> str | None: + text = response.strip().strip('"') + if ":" in text: + text = text.split(":", 1)[1].strip().strip('"') + first = text.split(",", 1)[0].strip().strip('"').upper() + if not first: + return None + return _FUNCTION_ALIASES.get(first) + + +class _AmbiguousWriteError(InstrumentError): + pass + + +@dataclass +class DG1000Source: + transport: object + check_errors_after_ops: bool = True + _io_lock: RLock = field(default_factory=RLock, init=False, repr=False) + _identity: tuple[str, str, str, str] | None = field(default=None, init=False, repr=False) + _configuration_writes_blocked: bool = field(default=False, init=False, repr=False) + + def _ensure_identity(self) -> tuple[str, str, str, str]: + if self._identity is None: + self._identity = _parse_identity(self.transport.query("*IDN?")) + return self._identity + + def _uses_source_layout(self) -> bool: + return self._ensure_identity()[1] in _SOURCE_LAYOUT_MODELS + + def _channel_prefix(self, channel: int) -> str: + _validate_channel(channel) + if self._uses_source_layout(): + return f":SOUR{channel}:" + return "" + + def _output_command(self, channel: int) -> str: + _validate_channel(channel) + return f":OUTP{channel}" if self._uses_source_layout() else f"OUTP{_channel_suffix(channel)}" + + def _function_command(self, channel: int) -> str: + return f"{self._channel_prefix(channel)}FUNC{_channel_suffix(channel) if not self._uses_source_layout() else ''}" + + def _frequency_command(self, channel: int) -> str: + return f"{self._channel_prefix(channel)}FREQ{_channel_suffix(channel) if not self._uses_source_layout() else ''}" + + def _voltage_command(self, channel: int) -> str: + return f"{self._channel_prefix(channel)}VOLT{_channel_suffix(channel) if not self._uses_source_layout() else ''}" + + def _voltage_unit_command(self, channel: int) -> str: + return f"{self._channel_prefix(channel)}VOLT:UNIT{_channel_suffix(channel) if not self._uses_source_layout() else ''}" + + def _voltage_offset_command(self, channel: int) -> str: + return f"{self._channel_prefix(channel)}VOLT:OFFS{_channel_suffix(channel) if not self._uses_source_layout() else ''}" + + def _phase_command(self, channel: int) -> str: + return f"{self._channel_prefix(channel)}PHAS{_channel_suffix(channel) if not self._uses_source_layout() else ''}" + + def _square_duty_command(self, channel: int) -> str: + return f"{self._channel_prefix(channel)}FUNC:SQU:DCYC{_channel_suffix(channel) if not self._uses_source_layout() else ''}" + + def _apply_query(self, channel: int) -> str: + if self._uses_source_layout(): + return f":SOUR{channel}:APPL?" + return "APPL?" if channel == 1 else "APPL:CH2?" + + def _sweep_state_command(self, channel: int) -> str: + if self._uses_source_layout(): + return f":SOUR{channel}:SWE:STAT" + return "SWE:STAT" + + @staticmethod + def _check_errors_enabled(requested: bool) -> bool: + return requested + + def _ensure_configuration_write_allowed(self) -> None: + if self._configuration_writes_blocked: + raise InstrumentError( + "DG1000 configuration writes are blocked after an ambiguous write; " + "reopen the instrument session and verify state" + ) + + def _write(self, command: str) -> None: + try: + self.transport.write(command) + except Exception as exc: + self._configuration_writes_blocked = True + _LOG.error("DG1000 write outcome is ambiguous; configuration writes are blocked") + raise _AmbiguousWriteError( + f"DG1000 write result is unknown for {command!r}: {exc}" + ) from exc + + @staticmethod + def _numeric_matches(actual: float | None, expected: float) -> bool: + return actual is not None and isclose( + actual, + expected, + rel_tol=1.0e-6, + abs_tol=1.0e-6, + ) + + def _query_output(self, channel: int) -> str: + return _normalize_enum( + self.transport.query(f"{self._output_command(channel)}?"), + field_name="output state", + aliases={"0": "OFF", "OFF": "OFF", "1": "ON", "ON": "ON"}, + ) + + def _force_output_off(self, channel: int) -> None: + self._write(f"{self._output_command(channel)} OFF") + if self._query_output(channel) != "OFF": + raise InstrumentError("DG1000 output OFF recovery could not be verified") + + def _query_function(self, channel: int) -> str: + raw = self.transport.query(f"{self._function_command(channel)}?") + function = _normalize_enum( + raw, + field_name="function", + aliases=_FUNCTION_ALIASES, + ) + if function != "USER": + return function + apply_function = _parse_apply_function(self.transport.query(self._apply_query(channel))) + return apply_function or function + + def _query_amplitude_unit(self, channel: int) -> str: + return _normalize_enum( + self.transport.query(f"{self._voltage_unit_command(channel)}?"), + field_name="amplitude unit", + aliases={"VPP": "VPP", "VRMS": "VRMS", "DBM": "DBM"}, + ) + + def _query_sweep_enabled(self, channel: int) -> str: + # Legacy DG1000 sweep is CH1-only; querying it for CH2 would read CH1 state. + if not self._uses_source_layout() and channel == 2: + return "OFF" + return _normalize_enum( + self.transport.query(f"{self._sweep_state_command(channel)}?"), + field_name="sweep state", + aliases={"0": "OFF", "OFF": "OFF", "1": "ON", "ON": "ON"}, + ) + + def _finish_transaction(self, *, channel: int, check_errors: bool) -> SourceStatus: + status = self.get_status(channel) + if self._check_errors_enabled(check_errors): + self.assert_no_errors() + return status + + def idn(self) -> str: + with self._io_lock: + response = self.transport.query("*IDN?") + self._identity = _parse_identity(response) + return response + + def errors(self, limit: int = 8) -> list[str]: + if isinstance(limit, bool) or not isinstance(limit, int) or limit < 1: + raise DataError("error query limit must be a positive integer") + with self._io_lock: + errors: list[str] = [] + for _ in range(limit): + response = self.transport.query("SYST:ERR?").strip() + if not response: + raise DataError("empty DG1000 error-queue response") + errors.append(response) + if response.startswith("0") or "No error" in response: + break + return errors + + def assert_no_errors(self) -> None: + with self._io_lock: + active = [ + item + for item in self.errors() + if not (item.startswith("0") or "No error" in item) + ] + if active: + raise InstrumentError("instrument error queue is not empty: " + "; ".join(active)) + + def get_status(self, channel: int) -> SourceStatus: + _validate_channel(channel) + with self._io_lock: + self._ensure_identity() + output = self._query_output(channel) + function = self._query_function(channel) + frequency_hz = _finite_float( + self.transport.query(f"{self._frequency_command(channel)}?"), + field_name="frequency", + ) + amplitude = _finite_float( + self.transport.query(f"{self._voltage_command(channel)}?"), + field_name="amplitude", + ) + amplitude_unit = self._query_amplitude_unit(channel) + offset_v = _finite_float( + self.transport.query(f"{self._voltage_offset_command(channel)}?"), + field_name="offset", + ) + phase_deg = _finite_float( + self.transport.query(f"{self._phase_command(channel)}?"), + field_name="phase", + ) + sweep_enabled = self._query_sweep_enabled(channel) + duty = _finite_float( + self.transport.query(f"{self._square_duty_command(channel)}?"), + field_name="square duty cycle", + ) + if duty <= 0 or duty >= 100: + raise DataError("square duty cycle response must be > 0 and < 100") + return SourceStatus( + channel=channel, + output=output, + function=function, + frequency_hz=frequency_hz, + amplitude=amplitude, + amplitude_unit=amplitude_unit, + offset_v=offset_v, + phase_deg=phase_deg, + frequency_mode="SWE" if sweep_enabled == "ON" else "FIX", + sweep_enabled=sweep_enabled, + apply_raw=self.transport.query(self._apply_query(channel)).strip(), + square_duty_cycle_percent=duty, + ) + + def set_frequency( + self, + channel: int, + value_hz: float, + *, + ensure_fix_mode: bool = True, + check_errors: bool = True, + ) -> SourceStatus: + _validate_channel(channel) + value_hz = _finite_float(value_hz, field_name="frequency") + if value_hz <= 0: + raise DataError("frequency must be > 0") + with self._io_lock: + self._ensure_identity() + self._ensure_configuration_write_allowed() + if ensure_fix_mode and self._query_sweep_enabled(channel) != "OFF": + self._write(f"{self._sweep_state_command(channel)} OFF") + if self._query_sweep_enabled(channel) != "OFF": + raise InstrumentError("DG1000 sweep state did not converge to OFF") + self._write(f"{self._frequency_command(channel)} {value_hz:.12g}") + actual = _finite_float( + self.transport.query(f"{self._frequency_command(channel)}?"), + field_name="frequency", + ) + if not self._numeric_matches(actual, value_hz): + raise InstrumentError("DG1000 frequency write readback mismatch") + return self._finish_transaction(channel=channel, check_errors=check_errors) + + def set_output( + self, + channel: int, + enabled: bool, + *, + check_errors: bool = True, + ) -> SourceStatus: + _validate_channel(channel) + if not isinstance(enabled, bool): + raise DataError("output enabled must be a boolean") + with self._io_lock: + self._ensure_identity() + if enabled: + self._ensure_configuration_write_allowed() + target = "ON" if enabled else "OFF" + try: + self._write(f"{self._output_command(channel)} {target}") + if self._query_output(channel) != target: + raise InstrumentError("DG1000 output write readback mismatch") + return self._finish_transaction(channel=channel, check_errors=check_errors) + except Exception: + if enabled: + _LOG.warning( + "DG1000 output enable failed; forcing channel %s OFF", + channel, + ) + try: + self._force_output_off(channel) + except Exception as recovery_error: + self._configuration_writes_blocked = True + _LOG.error( + "DG1000 channel %s OFF recovery could not be verified; " + "configuration writes are blocked", + channel, + ) + raise InstrumentError( + "DG1000 output transaction failed and OFF recovery could not be " + "verified; configuration writes are blocked" + ) from recovery_error + raise + + def set_function( + self, + channel: int, + function: str, + *, + check_errors: bool = True, + ) -> SourceStatus: + _validate_channel(channel) + normalized = str(function).strip().upper() + if normalized not in _FUNCTION_ALIASES: + raise DataError("function must be one of: sin, squ, ramp/triangle, puls, nois, dc, user") + expected = _FUNCTION_ALIASES[normalized] + if expected not in _RESTORABLE_FUNCTIONS: + raise DataError("unsupported DG1000 function") + with self._io_lock: + self._ensure_identity() + self._ensure_configuration_write_allowed() + self._write(f"{self._function_command(channel)} {expected}") + actual = self._query_function(channel) + if actual != expected: + raise InstrumentError("DG1000 function write readback mismatch") + return self._finish_transaction(channel=channel, check_errors=check_errors) + + def set_amplitude_vpp( + self, + channel: int, + value_vpp: float, + *, + check_errors: bool = True, + ) -> SourceStatus: + _validate_channel(channel) + value_vpp = _finite_float(value_vpp, field_name="amplitude") + if value_vpp <= 0: + raise DataError("amplitude must be > 0") + with self._io_lock: + self._ensure_identity() + self._ensure_configuration_write_allowed() + self._write(f"{self._voltage_unit_command(channel)} VPP") + if self._query_amplitude_unit(channel) != "VPP": + raise InstrumentError("DG1000 amplitude-unit write readback mismatch") + self._write(f"{self._voltage_command(channel)} {value_vpp:.12g}") + actual = _finite_float( + self.transport.query(f"{self._voltage_command(channel)}?"), + field_name="amplitude", + ) + if not self._numeric_matches(actual, value_vpp): + raise InstrumentError("DG1000 amplitude write readback mismatch") + return self._finish_transaction(channel=channel, check_errors=check_errors) + + def set_square_duty_cycle( + self, + channel: int, + duty_percent: float, + *, + check_errors: bool = True, + ) -> SourceStatus: + _validate_channel(channel) + duty_percent = _finite_float(duty_percent, field_name="duty cycle percent") + if duty_percent <= 0 or duty_percent >= 100: + raise DataError("duty cycle percent must be > 0 and < 100") + with self._io_lock: + self._ensure_identity() + self._ensure_configuration_write_allowed() + self._write(f"{self._square_duty_command(channel)} {duty_percent:.12g}") + actual = _finite_float( + self.transport.query(f"{self._square_duty_command(channel)}?"), + field_name="square duty cycle", + ) + if not self._numeric_matches(actual, duty_percent): + raise InstrumentError("DG1000 duty-cycle write readback mismatch") + return self._finish_transaction(channel=channel, check_errors=check_errors) + + def close(self) -> None: + with self._io_lock: + self.transport.close() diff --git a/packages/wavebench-rigol-dg1000/tests/conftest.py b/packages/wavebench-rigol-dg1000/tests/conftest.py new file mode 100644 index 0000000..c642ca6 --- /dev/null +++ b/packages/wavebench-rigol-dg1000/tests/conftest.py @@ -0,0 +1,8 @@ +from __future__ import annotations + +from pathlib import Path +import sys + + +PACKAGE_SRC = Path(__file__).resolve().parents[1] / "src" +sys.path.insert(0, str(PACKAGE_SRC)) diff --git a/packages/wavebench-rigol-dg1000/tests/test_driver.py b/packages/wavebench-rigol-dg1000/tests/test_driver.py new file mode 100644 index 0000000..fd874fc --- /dev/null +++ b/packages/wavebench-rigol-dg1000/tests/test_driver.py @@ -0,0 +1,553 @@ +import unittest +from inspect import signature + +from wavebench_rigol_dg1000 import descriptor +from wavebench_rigol_dg1000.driver import DG1000Source +from wavebench.errors import DataError, InstrumentError + + +class FakeDG1000Transport: + def __init__(self): + self.writes = [] + self.queries = [] + self.error_queue = ['0,"No error"'] + self.state = { + 1: { + "out": "OFF", + "func": "SIN", + "freq": 1000.0, + "volt": 1.0, + "unit": "VPP", + "offs": 0.0, + "phas": 0.0, + "duty": 50.0, + "apply": 'CH1:"SIN,1.000000e+03,1.000000e+00,0.000000e+00"', + }, + 2: { + "out": "ON", + "func": "RAMP", + "freq": 1500.0, + "volt": 2.0, + "unit": "VPP", + "offs": 0.5, + "phas": 10.0, + "duty": 30.0, + "apply": 'CH2:"RAMP,1.500000e+03,2.000000e+00,5.000000e-01"', + }, + } + self.sweep = "OFF" + + def write(self, command: str) -> None: + self.writes.append(command) + if command == "SWE:STAT OFF": + self.sweep = "OFF" + return + channel, key, value = self._parse_write(command) + if key == "out": + self.state[channel]["out"] = value + elif key == "func": + self.state[channel]["func"] = value + elif key == "freq": + self.state[channel]["freq"] = float(value) + elif key == "unit": + self.state[channel]["unit"] = value + elif key == "volt": + self.state[channel]["volt"] = float(value) + elif key == "duty": + self.state[channel]["duty"] = float(value) + + def _parse_write(self, command: str): + parts = command.split() + head = parts[0] + value = parts[1] + if head == "OUTP": + return 1, "out", value + if head == "OUTP:CH2": + return 2, "out", value + if head == "FUNC": + return 1, "func", value + if head == "FUNC:CH2": + return 2, "func", value + if head == "FREQ": + return 1, "freq", value + if head == "FREQ:CH2": + return 2, "freq", value + if head == "VOLT:UNIT": + return 1, "unit", value + if head == "VOLT:UNIT:CH2": + return 2, "unit", value + if head == "VOLT": + return 1, "volt", value + if head == "VOLT:CH2": + return 2, "volt", value + if head == "FUNC:SQU:DCYC": + return 1, "duty", value + if head == "FUNC:SQU:DCYC:CH2": + return 2, "duty", value + raise AssertionError(f"unexpected write {command!r}") + + def query(self, command: str) -> str: + self.queries.append(command) + if command == "*IDN?": + return "Rigol Technologies,DG1022,SN,FW" + if command == "SYST:ERR?": + if self.error_queue: + return self.error_queue.pop(0) + return '0,"No error"' + if command == "SWE:STAT?": + return self.sweep + if command == "APPL?": + return self.state[1]["apply"] + if command == "APPL:CH2?": + return self.state[2]["apply"] + for channel in (1, 2): + prefix = "" if channel == 1 else ":CH2" + state = self.state[channel] + mapping = { + f"OUTP{prefix}?": state["out"], + f"FUNC{prefix}?": f"CH{channel}:{state['func']}", + f"FREQ{prefix}?": ( + str(state["freq"]) if channel == 1 else f"CH2:{state['freq']}" + ), + f"VOLT{prefix}?": ( + str(state["volt"]) if channel == 1 else f"CH2:{state['volt']}" + ), + f"VOLT:UNIT{prefix}?": state["unit"], + f"VOLT:OFFS{prefix}?": ( + str(state["offs"]) if channel == 1 else f"CH2:{state['offs']}" + ), + f"PHAS{prefix}?": str(state["phas"]), + f"FUNC:SQU:DCYC{prefix}?": str(state["duty"]), + } + if command in mapping: + return mapping[command] + raise AssertionError(f"unexpected query {command!r}") + + def close(self) -> None: + pass + + +class FakeDG1000ZTransport: + def __init__(self): + self.writes = [] + self.queries = [] + self.error_queue = ['0,"No error"'] + self.state = { + 1: { + "out": "OFF", + "func": "SIN", + "freq": 1000.0, + "volt": 1.0, + "unit": "VPP", + "offs": 0.0, + "phas": 0.0, + "duty": 50.0, + "apply": '"SIN,1.000000E+03,1.000000E+00,0.000000E+00,0.000000E+00"', + "sweep": "OFF", + }, + 2: { + "out": "ON", + "func": "RAMP", + "freq": 50000.0, + "volt": 1.0, + "unit": "VPP", + "offs": 0.5, + "phas": 0.0, + "duty": 30.0, + "apply": '"RAMP,5.000000E+04,1.000000E+00,5.000000E-01,0.000000E+00"', + "sweep": "OFF", + }, + } + + def write(self, command: str) -> None: + self.writes.append(command) + parts = command.split() + head = parts[0] + value = parts[1] + for channel in (1, 2): + prefix = f":SOUR{channel}:" + if head == f":OUTP{channel}": + self.state[channel]["out"] = value + return + if head == f"{prefix}FUNC": + self.state[channel]["func"] = value + return + if head == f"{prefix}FREQ": + self.state[channel]["freq"] = float(value) + return + if head == f"{prefix}VOLT:UNIT": + self.state[channel]["unit"] = value + return + if head == f"{prefix}VOLT": + self.state[channel]["volt"] = float(value) + return + if head == f"{prefix}FUNC:SQU:DCYC": + self.state[channel]["duty"] = float(value) + return + if head == f"{prefix}SWE:STAT": + self.state[channel]["sweep"] = value + return + raise AssertionError(f"unexpected write {command!r}") + + def query(self, command: str) -> str: + self.queries.append(command) + if command == "*IDN?": + return "Rigol Technologies,DG1032Z,SN,06.01.13" + if command == "SYST:ERR?": + if self.error_queue: + return self.error_queue.pop(0) + return '0,"No error"' + for channel in (1, 2): + state = self.state[channel] + prefix = f":SOUR{channel}:" + mapping = { + f":OUTP{channel}?": state["out"], + f"{prefix}FUNC?": state["func"], + f"{prefix}FREQ?": str(state["freq"]), + f"{prefix}VOLT?": str(state["volt"]), + f"{prefix}VOLT:UNIT?": state["unit"], + f"{prefix}VOLT:OFFS?": str(state["offs"]), + f"{prefix}PHAS?": str(state["phas"]), + f"{prefix}SWE:STAT?": state["sweep"], + f"{prefix}FUNC:SQU:DCYC?": str(state["duty"]), + f"{prefix}APPL?": state["apply"], + } + if command in mapping: + return mapping[command] + raise AssertionError(f"unexpected query {command!r}") + + def close(self) -> None: + pass + + +class ErrorQueryFailsAfterOutputOn(FakeDG1000ZTransport): + def __init__(self): + super().__init__() + self._fail_next_error_query = False + + def write(self, command: str) -> None: + super().write(command) + if command == ":OUTP1 ON": + self._fail_next_error_query = True + + def query(self, command: str) -> str: + if command == "SYST:ERR?" and self._fail_next_error_query: + self._fail_next_error_query = False + raise RuntimeError("injected error queue failure") + return super().query(command) + + +class OutputReadbackFailsAfterOutputOn(FakeDG1000ZTransport): + def __init__(self): + super().__init__() + self._fail_next_output_query = False + + def write(self, command: str) -> None: + super().write(command) + if command == ":OUTP1 ON": + self._fail_next_output_query = True + + def query(self, command: str) -> str: + if command == ":OUTP1?" and self._fail_next_output_query: + self._fail_next_output_query = False + raise RuntimeError("injected output readback failure") + return super().query(command) + + +class AmbiguousFrequencyWrite(FakeDG1000ZTransport): + def write(self, command: str) -> None: + super().write(command) + if command == ":SOUR1:FREQ 2000": + raise OSError("injected ambiguous frequency write") + + +class AmbiguousOutputOnWrite(FakeDG1000ZTransport): + def write(self, command: str) -> None: + super().write(command) + if command == ":OUTP1 ON": + raise OSError("injected ambiguous output write") + + +class OutputOffRecoveryCannotBeVerified(ErrorQueryFailsAfterOutputOn): + def __init__(self): + super().__init__() + self._fail_off_readback = False + + def write(self, command: str) -> None: + super().write(command) + if command == ":OUTP1 OFF": + self._fail_off_readback = True + + def query(self, command: str) -> str: + if command == ":OUTP1?" and self._fail_off_readback: + raise OSError("injected OFF readback failure") + return super().query(command) + + +class UserReadbackAfterDcWrite(FakeDG1000ZTransport): + def write(self, command: str) -> None: + super().write(command) + if command == ":SOUR1:FUNC DC": + self.state[1]["func"] = "USER" + self.state[1]["apply"] = '"USER,1.000000E+03,1.000000E+00,0.000000E+00"' + + +class DG1000Tests(unittest.TestCase): + def test_source_setters_default_to_error_queue_checks(self): + for method_name in ( + "set_frequency", + "set_output", + "set_function", + "set_amplitude_vpp", + "set_square_duty_cycle", + ): + parameter = signature(getattr(DG1000Source, method_name)).parameters["check_errors"] + self.assertIs(parameter.default, True) + + def test_descriptor_exposes_basic_dg1000_source_capabilities(self): + plugin = descriptor() + + self.assertEqual(plugin.driver_id, "rigol.dg1000") + self.assertEqual(plugin.kind, "source") + self.assertEqual(plugin.distribution, "wavebench-rigol-dg1000") + self.assertEqual(plugin.aliases, ()) + self.assertEqual(plugin.wavebench_min_version, "0.8.0") + self.assertEqual(plugin.wavebench_max_version, "0.9.0") + self.assertEqual( + plugin.models, + ("DG1022", "DG1022A", "DG1022Z", "DG1032Z", "DG1062Z"), + ) + self.assertIn("source.status", plugin.capabilities) + self.assertIn("source.set_frequency", plugin.capabilities) + self.assertNotIn("source.harmonic_profile", plugin.capabilities) + self.assertNotIn("source.harmonic_configure", plugin.capabilities) + self.assertNotIn("source.arbitrary_upload", plugin.capabilities) + + def test_get_status_reads_channel_two_with_dg1000_command_layout(self): + transport = FakeDG1000Transport() + driver = DG1000Source(transport=transport, check_errors_after_ops=True) + + status = driver.get_status(2) + + self.assertEqual(status.channel, 2) + self.assertEqual(status.output, "ON") + self.assertEqual(status.function, "RAMP") + self.assertEqual(status.frequency_hz, 1500.0) + self.assertEqual(status.amplitude, 2.0) + self.assertEqual(status.offset_v, 0.5) + self.assertEqual(status.frequency_mode, "FIX") + self.assertEqual(status.sweep_enabled, "OFF") + self.assertEqual(status.square_duty_cycle_percent, 30.0) + + def test_get_status_reads_channel_two_with_dg1000z_source_layout(self): + transport = FakeDG1000ZTransport() + driver = DG1000Source(transport=transport, check_errors_after_ops=True) + + status = driver.get_status(2) + + self.assertEqual(status.channel, 2) + self.assertEqual(status.output, "ON") + self.assertEqual(status.function, "RAMP") + self.assertEqual(status.frequency_hz, 50000.0) + self.assertEqual(status.amplitude, 1.0) + self.assertEqual(status.offset_v, 0.5) + self.assertEqual(status.frequency_mode, "FIX") + self.assertEqual(status.sweep_enabled, "OFF") + self.assertEqual(status.square_duty_cycle_percent, 30.0) + self.assertIn(":OUTP2?", transport.queries) + self.assertIn(":SOUR2:FUNC?", transport.queries) + self.assertIn(":SOUR2:APPL?", transport.queries) + + def test_rejects_channel_outside_dg1000_dual_channel_range_before_io(self): + transport = FakeDG1000Transport() + driver = DG1000Source(transport=transport, check_errors_after_ops=True) + + with self.assertRaisesRegex(DataError, "DG1000 channel must be 1 or 2"): + driver.get_status(3) + + self.assertEqual(transport.writes, []) + + def test_set_channel_two_frequency_uses_dg1000_ch2_suffix(self): + transport = FakeDG1000Transport() + transport.sweep = "ON" + driver = DG1000Source(transport=transport, check_errors_after_ops=True) + + status = driver.set_frequency(2, 2000.0, check_errors=True) + + self.assertEqual(transport.writes[0], "FREQ:CH2 2000") + self.assertNotIn("SWE:STAT OFF", transport.writes) + self.assertNotIn("SWE:STAT?", transport.queries) + self.assertEqual(status.frequency_hz, 2000.0) + self.assertEqual(status.frequency_mode, "FIX") + self.assertEqual(status.sweep_enabled, "OFF") + + def test_set_channel_one_frequency_disables_sweep_when_requested(self): + transport = FakeDG1000Transport() + transport.sweep = "ON" + driver = DG1000Source(transport=transport, check_errors_after_ops=True) + + status = driver.set_frequency(1, 2000.0, ensure_fix_mode=True, check_errors=True) + + self.assertEqual(transport.writes[:2], ["SWE:STAT OFF", "FREQ 2000"]) + self.assertEqual(status.frequency_mode, "FIX") + + def test_set_channel_two_frequency_uses_dg1000z_source_layout_and_disables_sweep(self): + transport = FakeDG1000ZTransport() + transport.state[2]["sweep"] = "ON" + driver = DG1000Source(transport=transport, check_errors_after_ops=True) + + status = driver.set_frequency(2, 2000.0, ensure_fix_mode=True, check_errors=True) + + self.assertEqual(transport.writes[:2], [":SOUR2:SWE:STAT OFF", ":SOUR2:FREQ 2000"]) + self.assertEqual(status.frequency_hz, 2000.0) + self.assertEqual(status.frequency_mode, "FIX") + + def test_set_function_writes_normalized_dg1000_function(self): + transport = FakeDG1000Transport() + driver = DG1000Source(transport=transport, check_errors_after_ops=True) + + status = driver.set_function(2, "triangle", check_errors=True) + + self.assertEqual(transport.writes[0], "FUNC:CH2 RAMP") + self.assertEqual(status.function, "RAMP") + + def test_set_dc_rejects_ambiguous_user_readback(self): + transport = UserReadbackAfterDcWrite() + driver = DG1000Source(transport=transport, check_errors_after_ops=True) + + with self.assertRaisesRegex(InstrumentError, "function write readback mismatch"): + driver.set_function(1, "dc", check_errors=True) + + def test_set_amplitude_vpp_sets_channel_specific_unit_and_amplitude(self): + transport = FakeDG1000Transport() + driver = DG1000Source(transport=transport, check_errors_after_ops=True) + + status = driver.set_amplitude_vpp(2, 3.3, check_errors=True) + + self.assertEqual(transport.writes[:2], ["VOLT:UNIT:CH2 VPP", "VOLT:CH2 3.3"]) + self.assertEqual(status.amplitude, 3.3) + self.assertEqual(status.amplitude_unit, "VPP") + + def test_set_amplitude_vpp_uses_dg1000z_source_layout(self): + transport = FakeDG1000ZTransport() + driver = DG1000Source(transport=transport, check_errors_after_ops=True) + + status = driver.set_amplitude_vpp(2, 3.3, check_errors=True) + + self.assertEqual(transport.writes[:2], [":SOUR2:VOLT:UNIT VPP", ":SOUR2:VOLT 3.3"]) + self.assertEqual(status.amplitude, 3.3) + self.assertEqual(status.amplitude_unit, "VPP") + + def test_set_square_duty_cycle_uses_channel_specific_dg1000_command(self): + transport = FakeDG1000Transport() + driver = DG1000Source(transport=transport, check_errors_after_ops=True) + + status = driver.set_square_duty_cycle(2, 25.0, check_errors=True) + + self.assertEqual(transport.writes[0], "FUNC:SQU:DCYC:CH2 25") + self.assertEqual(status.square_duty_cycle_percent, 25.0) + + def test_set_square_duty_cycle_uses_dg1000z_source_layout(self): + transport = FakeDG1000ZTransport() + driver = DG1000Source(transport=transport, check_errors_after_ops=True) + + status = driver.set_square_duty_cycle(2, 25.0, check_errors=True) + + self.assertEqual(transport.writes[0], ":SOUR2:FUNC:SQU:DCYC 25") + self.assertEqual(status.square_duty_cycle_percent, 25.0) + + def test_set_output_writes_dg1000_channel_output(self): + transport = FakeDG1000Transport() + driver = DG1000Source(transport=transport, check_errors_after_ops=True) + + status = driver.set_output(2, False, check_errors=True) + + self.assertEqual(transport.writes[0], "OUTP:CH2 OFF") + self.assertEqual(status.output, "OFF") + + def test_set_output_uses_dg1000z_source_layout(self): + transport = FakeDG1000ZTransport() + driver = DG1000Source(transport=transport, check_errors_after_ops=True) + + status = driver.set_output(2, False, check_errors=True) + + self.assertEqual(transport.writes[0], ":OUTP2 OFF") + self.assertEqual(status.output, "OFF") + + def test_output_enable_failure_forces_and_verifies_output_off(self): + transport = ErrorQueryFailsAfterOutputOn() + driver = DG1000Source(transport=transport, check_errors_after_ops=True) + + with self.assertRaisesRegex(RuntimeError, "injected error queue failure"): + driver.set_output(1, True, check_errors=True) + + self.assertEqual(transport.writes[-1], ":OUTP1 OFF") + self.assertEqual(transport.state[1]["out"], "OFF") + self.assertEqual(transport.queries[-1], ":OUTP1?") + + def test_output_enable_readback_failure_forces_and_verifies_output_off(self): + transport = OutputReadbackFailsAfterOutputOn() + driver = DG1000Source(transport=transport, check_errors_after_ops=True) + + with self.assertRaisesRegex(RuntimeError, "injected output readback failure"): + driver.set_output(1, True, check_errors=True) + + self.assertEqual(transport.writes[-1], ":OUTP1 OFF") + self.assertEqual(transport.state[1]["out"], "OFF") + self.assertEqual(transport.queries[-1], ":OUTP1?") + + def test_ambiguous_configuration_write_latches_and_blocks_later_writes(self): + transport = AmbiguousFrequencyWrite() + driver = DG1000Source(transport=transport, check_errors_after_ops=True) + + with self.assertRaisesRegex(InstrumentError, "write result is unknown"): + driver.set_frequency(1, 2000.0, check_errors=True) + + writes_after_failure = list(transport.writes) + queries_after_failure = list(transport.queries) + with self.assertRaisesRegex(InstrumentError, "configuration writes are blocked"): + driver.set_function(1, "square", check_errors=True) + + self.assertEqual(transport.writes, writes_after_failure) + self.assertEqual(transport.queries, queries_after_failure) + + def test_ambiguous_output_enable_stays_latched_after_verified_off_recovery(self): + transport = AmbiguousOutputOnWrite() + driver = DG1000Source(transport=transport, check_errors_after_ops=True) + + with self.assertRaisesRegex(InstrumentError, "write result is unknown"): + driver.set_output(1, True, check_errors=True) + + self.assertEqual(transport.writes[-1], ":OUTP1 OFF") + self.assertEqual(transport.state[1]["out"], "OFF") + with self.assertRaisesRegex(InstrumentError, "configuration writes are blocked"): + driver.set_frequency(1, 2000.0, check_errors=True) + + def test_latched_driver_still_allows_output_off(self): + transport = AmbiguousFrequencyWrite() + driver = DG1000Source(transport=transport, check_errors_after_ops=True) + + with self.assertRaisesRegex(InstrumentError, "write result is unknown"): + driver.set_frequency(1, 2000.0, check_errors=True) + + status = driver.set_output(1, False, check_errors=True) + + self.assertEqual(status.output, "OFF") + self.assertEqual(transport.writes[-1], ":OUTP1 OFF") + + def test_unverified_output_off_recovery_latches_configuration_writes(self): + transport = OutputOffRecoveryCannotBeVerified() + driver = DG1000Source(transport=transport, check_errors_after_ops=True) + + with self.assertRaisesRegex(InstrumentError, "OFF recovery could not be verified"): + driver.set_output(1, True, check_errors=True) + + writes_after_failure = list(transport.writes) + with self.assertRaisesRegex(InstrumentError, "configuration writes are blocked"): + driver.set_frequency(1, 2000.0, check_errors=True) + + self.assertEqual(transport.writes, writes_after_failure) + + +if __name__ == "__main__": + unittest.main() diff --git a/packages/wavebench-rigol-dg1000/tests/test_wheel.py b/packages/wavebench-rigol-dg1000/tests/test_wheel.py new file mode 100644 index 0000000..68c6414 --- /dev/null +++ b/packages/wavebench-rigol-dg1000/tests/test_wheel.py @@ -0,0 +1,164 @@ +from __future__ import annotations + +from importlib.metadata import PathDistribution +import os +from pathlib import Path +import subprocess +import sys +import sysconfig +from zipfile import ZipFile + +import wavebench + + +PACKAGE_ROOT = Path(__file__).resolve().parents[1] +PACKAGE_VERSION = "0.1.0" + + +def test_wheel_contains_license_and_single_entry_point(tmp_path: Path) -> None: + wheelhouse = tmp_path / "wheelhouse" + wheelhouse.mkdir() + _run( + [ + sys.executable, + "-m", + "pip", + "wheel", + "--no-build-isolation", + "--no-deps", + "--no-index", + "--disable-pip-version-check", + "--wheel-dir", + str(wheelhouse), + str(PACKAGE_ROOT), + ], + cwd=tmp_path, + ) + wheel = next(wheelhouse.glob(f"wavebench_rigol_dg1000-{PACKAGE_VERSION}-*.whl")) + + with ZipFile(wheel) as archive: + names = archive.namelist() + archive.extractall(tmp_path / "unpacked") + + assert any(name.endswith(".dist-info/licenses/LICENSE") for name in names) + dist_info = next((tmp_path / "unpacked").glob("*.dist-info")) + distribution = PathDistribution(dist_info) + entry_points = [ + item for item in distribution.entry_points if item.group == "wavebench.instruments" + ] + + assert distribution.metadata["License-Expression"] == "MIT" + assert distribution.version == PACKAGE_VERSION + requires_dist = distribution.metadata.get_all("Requires-Dist") or [] + assert any(requirement.replace(" ", "") == "wavebench<0.9,>=0.8" for requirement in requires_dist) + assert [(item.name, item.value) for item in entry_points] == [ + ("rigol.dg1000", "wavebench_rigol_dg1000:descriptor") + ] + + +def test_wheel_install_discovery_and_uninstall_without_instrument_io(tmp_path: Path) -> None: + wheelhouse = tmp_path / "wheelhouse" + wheelhouse.mkdir() + _run( + [ + sys.executable, + "-m", + "pip", + "wheel", + "--no-build-isolation", + "--no-deps", + "--no-index", + "--disable-pip-version-check", + "--wheel-dir", + str(wheelhouse), + str(PACKAGE_ROOT), + ], + cwd=tmp_path, + ) + plugin_wheel = next(wheelhouse.glob(f"wavebench_rigol_dg1000-{PACKAGE_VERSION}-*.whl")) + venv_dir = tmp_path / "venv" + _run([sys.executable, "-m", "venv", str(venv_dir)], cwd=tmp_path) + python = venv_dir / "bin" / "python" + purelib = _run( + [str(python), "-c", "import sysconfig; print(sysconfig.get_paths()['purelib'])"], + cwd=tmp_path, + ).stdout.strip() + _write_isolated_runtime_bridge(purelib=Path(purelib), workspace=tmp_path) + _run( + [ + str(python), + "-m", + "pip", + "install", + "--isolated", + "--no-deps", + "--no-index", + "--disable-pip-version-check", + str(plugin_wheel), + ], + cwd=tmp_path, + ) + discovery_script = """ +from importlib.metadata import entry_points +from wavebench.instruments.registry import build_instrument_registry +from wavebench.transport.pyvisa_transport import PyVisaTransport + +def forbidden(*args, **kwargs): + raise AssertionError("plugin descriptor import attempted instrument I/O") + +PyVisaTransport.open = forbidden +points = list(entry_points().select(group="wavebench.instruments")) +assert [point.name for point in points] == ["rigol.dg1000"] +descriptor = points[0].load()() +assert descriptor.driver_id == "rigol.dg1000" +assert descriptor.distribution == "wavebench-rigol-dg1000" +registry = build_instrument_registry(include_entry_points=True) +canonical = registry.resolve("rigol.dg1000", expected_kind="source") +assert canonical.origin == "entry_point" +assert canonical.distribution == "wavebench-rigol-dg1000" +""" + _run([str(python), "-I", "-c", discovery_script], cwd=tmp_path) + _run( + [ + str(python), + "-m", + "pip", + "uninstall", + "--yes", + "wavebench-rigol-dg1000", + ], + cwd=tmp_path, + ) + uninstall_script = """ +from importlib.metadata import entry_points + +assert not entry_points().select(group="wavebench.instruments") +""" + _run([str(python), "-I", "-c", uninstall_script], cwd=tmp_path) + + +def _run(command: list[str], *, cwd: Path) -> subprocess.CompletedProcess[str]: + return subprocess.run( + command, + cwd=cwd, + text=True, + capture_output=True, + check=True, + ) + + +def _write_isolated_runtime_bridge(*, purelib: Path, workspace: Path) -> None: + bridge = workspace / "runtime-bridge" + bridge.mkdir() + for source in Path(sysconfig.get_paths()["purelib"]).iterdir(): + name = source.name + if ( + name.startswith("wavebench") + or name.endswith((".dist-info", ".egg-info", ".pth", ".egg")) + ): + continue + os.symlink(source, bridge / name, target_is_directory=source.is_dir()) + Path(purelib, "wavebench-test-runtime.pth").write_text( + str(Path(wavebench.__file__).resolve().parents[1]) + "\n" + str(bridge) + "\n", + encoding="utf-8", + ) diff --git a/tests/test_dev_env.py b/tests/test_dev_env.py index 0892cd9..5ee4dde 100644 --- a/tests/test_dev_env.py +++ b/tests/test_dev_env.py @@ -19,6 +19,7 @@ def test_discovers_only_installable_plugin_packages(): projects = DEV_ENV.discover_installable_plugins(ROOT) assert [project.distribution for project in projects] == [ + "wavebench-rigol-dg1000", "wavebench-rigol-dg4000", "wavebench-rigol-dm3000", "wavebench-rigol-dp800", @@ -27,6 +28,7 @@ def test_discovers_only_installable_plugin_packages(): "wavebench-shengpu-sp3000a", ] assert [project.driver_ids for project in projects] == [ + ("rigol.dg1000",), ("rigol.dg4202",), ("rigol.dm3000",), ("rigol.dp800",), @@ -42,9 +44,9 @@ def test_expected_state_tracks_core_and_plugin_metadata(): assert state["schema_version"] == 1 assert state["build_requirements"] == ["hatchling>=1.25"] assert state["wavebench"]["distribution"] == "wavebench" - assert state["wavebench"]["version"] == "0.8.14" + assert state["wavebench"]["version"] == "0.8.22" assert len(state["wavebench"]["pyproject_sha256"]) == 64 - assert len(state["plugins"]) == 6 + assert len(state["plugins"]) == 7 assert all(len(plugin["pyproject_sha256"]) == 64 for plugin in state["plugins"]) @@ -59,6 +61,7 @@ def test_sync_command_uses_standard_editable_installs(): ] assert editable_targets[0].endswith("/wavebench[dev]") assert editable_targets[1:] == [ + f"{ROOT / 'packages/wavebench-rigol-dg1000'}[dev]", f"{ROOT / 'packages/wavebench-rigol-dg4000'}[dev]", f"{ROOT / 'packages/wavebench-rigol-dm3000'}[dev]", f"{ROOT / 'packages/wavebench-rigol-dp800'}[dev]",