diff --git a/docs/project/README.md b/docs/project/README.md index 721c94f..94fbfc5 100644 --- a/docs/project/README.md +++ b/docs/project/README.md @@ -28,10 +28,23 @@ ## rfcs:接口提案与决策 - [RFC 索引](rfcs/README.md) +- [scope 可移植性 RFC-0001~RFC-0009 组合说明](rfcs/WaveBench_scope可移植性RFC组合说明.md): + 将九份外部插件提案转换为核心裁决;RFC-0001 和 RFC-0003 的早期入口由现有更严格合同取代, + RFC-0008 在 R1.3 基础上增加标准 waveform bounded opt-in,RFC-0002、RFC-0004 和 RFC-0005 已完成 + 核心只读状态 V2;snapshot V2 仍未发布,主包和插件均未 opt-in,也不修改 legacy API、CLI 或 artifact; + RFC-0006a、RFC-0007a 和 RFC-0007b 已完成核心实现但尚未发布;后两者分别提供纯文本 statistics + selector/profile 与静态 FFT status profile。0007c cursor 已完成 global/indexed addressing、双 source、单位、 + availability、纯文本 budget 与无 CLI/artifact 的核心实现;0006b-0 已完成 core-only bounded transaction + 内核,0006b 已完成单通道 `global_acquisition` 与设备完成位的核心模型、profile、factory gate、executor 和 + Service 离线实现。RFC-0009 已为完成式 SINGLE control 追加 profile-gated mode-readback terminal STOP + proof,不开放 capture、average 或插件 capability。截图 V2 的全局预算为 `8388608/8388608/1/0`, + 仍不自动授权插件采用;当前不新增插件 capability 或硬件工作。 - [Source V2 能力、状态与复合输出安全 RFC](rfcs/WaveBench_source能力状态与复合输出安全RFC.md): `Accepted R6`;核心 `0.8.24` 开发线已实现 P0、M1–M4、M4.5、C1、M5-A 至 M5-D 与 C2, M6-A、M6-B 与 M6-C 已完成核心离线合同;真实插件验收与发布审计仍按里程碑推进。 - [transport 重放与 session 健康 RFC](rfcs/WaveBench_transport重放与session健康RFC.md) +- [scope 通用扩展接口 RFC](rfcs/WaveBench_scope通用扩展接口RFC.md):`Accepted R1.3`,定义 operation context、binary budget、截图、采集控制、trace、错误策略及恢复验证合同。 +- [标准波形有界二进制传输 RFC](rfcs/WaveBench_标准波形有界二进制传输RFC.md):`Implemented R1(未发布)`,标准 waveform/capture 已具备对 R1.3 bounded binary context 的 descriptor opt-in 接入、恢复边界和新旧核心/插件兼容矩阵;外部插件仍需单独验收。 ## contributing:开发和接入 diff --git "a/docs/project/contributing/WaveBench_\346\217\222\344\273\266\345\274\200\345\217\221\346\214\207\345\215\227.md" "b/docs/project/contributing/WaveBench_\346\217\222\344\273\266\345\274\200\345\217\221\346\214\207\345\215\227.md" index bec1ae8..bd95255 100644 --- "a/docs/project/contributing/WaveBench_\346\217\222\344\273\266\345\274\200\345\217\221\346\214\207\345\215\227.md" +++ "b/docs/project/contributing/WaveBench_\346\217\222\344\273\266\345\274\200\345\217\221\346\214\207\345\215\227.md" @@ -144,6 +144,20 @@ capability 名必须与 `kind` 同前缀。例如 scope 只能声明 `scope.*` - binary framing 与具体 resource/backend 的 EOM 能力一致,不能用短读、换行或 timeout 猜测边界; - fake conformance、包检查和实机验收分别通过后,再修改正式 descriptor。 +本段的 `0.8.23`/`0.8.24` 只表示开发线中的静态合同下限。正式核心发行物出现前,插件可在 +开发树进行离线验证,但不得发布提高后的 wheel 依赖、`wavebench_min_version` 或新增 scope +capability。发布时必须使用第一个实际包含完整合同的核心版本;同一版本号若存在不含合同的既有 +artifact,应改用可区分的更高版本。 + +`scope.channel_input_state_v2` 是独立的只读输入状态 capability,不需要 `ScopeDescriptorExtensions` +profile。它只报告 coupling、termination 和可解释的阻抗缺席状态;不设置终端,也不改变旧 +`scope.channel_coupling` 或标准 capture 的高阻前置检查。 + +`scope.digital_status_v2` 是独立的只读数字状态 capability,不需要 profile。driver 必须区分逐通道、 +POD 与 shared 状态;设备返回但无法映射的 token 使用 `"unknown"`,没有查询合同的字段使用 +`None` 和精确 `unavailable_fields`。它不证明数字 waveform 的逻辑编码、字节序或同次采集关系, +因此不能同时声明 `scope.digital_waveform`,除非另有该 capability 的完整证据。 + 未采用新增 capability 的旧插件不需要提高核心版本下限。旧 `scope capture --screenshot` 不承载 新 `scope.screenshot_v2`;新插件应使用独立截图 Service 或 `wavebench scope screenshot capture`。 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 05d6262..27e9051 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" @@ -139,7 +139,7 @@ def descriptor() -> InstrumentDescriptor: | `distribution`、`version`、`source`、`origin` | entry point 加载后由 registry 按已安装分发覆盖 | 不得用于插件内部授权、信任或功能分支 | | `scope_coupling_policy` | 值由类型约定为三种策略 | scope 必须准确声明;无法证明时使用 `unknown`,核心会默认拒绝无法确认高阻的采集 | | `config_fields` | 当前只展示;为空时由 `option_specs` 推导 `options.` | 只列出用户实际可配置的字段,不代表核心会按此字段授权 | -| `scope_extensions` | 仅允许 scope descriptor 使用,类型必须为 `ScopeDescriptorExtensions` | 为 R1.3 capability 提供静态截图、采集控制和 trace profile;旧插件保持 `None` | +| `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` | ### `scope_coupling_policy` @@ -232,6 +232,11 @@ factory 返回 driver 后,transport 的所有权转移给 driver。`close()` factory 必须同步返回,不得返回 coroutine、context manager 或 `(driver, transport)` tuple。factory 也不得启动无法由 `close()` 停止的线程或子进程。 +当 `scope_extensions.waveform_binary_profile` 或 `average_capture_profile_v2` 非空时,`open_transport()` 返回的 +guard 在 factory 返回、profile、Protocol 和 backend/resource 校验完成前拒绝全部仪器 I/O,并返回发送前错误 +`factory_construction_pending`。factory 必须把初始化中的仪器查询移到后续 Service 操作;验证失败时核心关闭 +已经打开的 transport。该门禁只覆盖 `DriverContext.open_transport()` 返回的 guard,不把可信 Python 插件当作沙箱。 + ## Capability 契约 核心在执行操作前检查所需 capability,缺失时不会打开 transport。driver 创建后,核心只检查对应属性是否可调用,不检查参数签名、返回类型、异常类型或操作语义。这些内容由 Protocol、model 和插件测试保证。 @@ -250,14 +255,17 @@ capability 必须与 descriptor 的 `kind` 使用相同前缀。当前 V2 loader | `scope.idn` | `idn` | | `scope.errors` | `errors` | | `scope.autoscale` | `autoscale` | -| `scope.fetch_waveform` | `fetch_waveform` | -| `scope.capture_waveform` | `capture_waveform` | -| `scope.capture_waveforms` | `capture_waveforms` | +| `scope.fetch_waveform` | profile 为 `None` 时为 `fetch_waveform`;有 `waveform_binary_profile` 时为 `fetch_waveform_bounded` 和 waveform transfer recovery 方法 | +| `scope.capture_waveform` | profile 为 `None` 时为 `capture_waveform`;有 `waveform_binary_profile` 时为 `capture_waveform_bounded` 和 waveform transfer recovery 方法 | +| `scope.capture_waveforms` | profile 为 `None` 时为 `capture_waveforms`;有 `waveform_binary_profile` 时为 `capture_waveforms_bounded` 和 waveform transfer recovery 方法 | | `scope.screenshot` | `screenshot_png` | | `scope.channel_coupling` | `channel_coupling` | +| `scope.channel_input_state_v2` | `get_channel_input_state_v2` | +| `scope.digital_status_v2` | `get_digital_status_v2` | | `scope.snapshot` | `get_snapshot` | | `scope.acquisition_status` | `get_acquisition_status` | | `scope.capture_average` | `capture_average` | +| `scope.capture_average_v2` | `snapshot_average_capture_state`、`set_average_acquisition_type_v2`、`get_average_configuration_v2`、`set_average_count_v2`、`acquire_average_single_v2`、`get_device_average_complete_v2`、`fetch_average_waveform_bounded`、`restore_average_capture_state`、`verify_average_capture_state_restored` | | `scope.digital_status` | `get_digital_status` | | `scope.digital_waveform` | `get_digital_waveform` | | `scope.history_timestamps` | `get_history_timestamps` | @@ -276,10 +284,73 @@ capability 必须与 descriptor 的 `kind` 使用相同前缀。当前 V2 loader `scope.capture_waveforms` 的固定语义是:先配置全部目标通道,只执行一次 acquisition 和 OPC 等待,再逐通道读取。不得静默退回逐通道重复触发。回调、失败时部分结果和返回字典的签名以 `MultiChannelScopeDriver` 为准。 +### 标准 waveform bounded profile + +`ScopeWaveformBinaryProfile` 是 `wavebench.instrument.v2` 的 additive descriptor extension,不新增 +capability。profile 为 `None` 时,三个标准 waveform capability 保持原有方法签名和调用顺序;profile +非空时,operations 必须与声明的 `scope.fetch_waveform`、`scope.capture_waveform`、 +`scope.capture_waveforms` 精确对应。 + +每个 `ScopeWaveformBinaryOperationProfile` 声明 response、operation-total、query-count、 +resynchronization 预算,以及 transfer-state 的 snapshot、restore、verify 步数和字段顺序。首版只接受 +IEEE definite block;`transport_trailing_hex` 是最多 16 bytes 的小写偶数字符十六进制序列,空字符串表示 +已验证的空 trailing,不表示忽略 trailing。 + +核心上限为单响应 8 MiB、单操作 64 MiB、256 次 binary query 和 64 KiB resynchronization。profile +只能收紧这些值。标准 bounded waveform 只接受核心 PyVISA 或 RsInstrument 的可证明 VISA `INSTR` 读取路径; +Serial、SocketIO 和第三方 duck transport 即使存在 `query_binary()` 也不会被视为已支持。 + +bounded driver 必须实现 `ScopeWaveformTransferRecoveryDriver`,以及对应的 +`ScopeBoundedWaveformFetchDriver`、`ScopeBoundedWaveformCaptureDriver` 或 +`ScopeBoundedMultiWaveformCaptureDriver`。`ScopeWaveformTransfer*` 是独立于已发布 +`ScopeTraceTransfer*` 的恢复模型;capture profile 必须覆盖采集、触发、时基、通道和 waveform +transfer 的完整恢复闭包。主读取必须使用受 core ledger 授权的 +`query_binary(..., framing=DEFINITE_BLOCK, replay=NO_REPLAY)`;不得调用 `query_bin_block()`、自行传递 +trailing/resynchronization 参数、重放或继续已发送的 binary query。 +core 会把 descriptor 的 framing 绑定到 ledger;请求 `MESSAGE` 或其他不匹配 framing 时会在发送前拒绝。 + +标准 `ScopeConfig.check_errors=true` 在 bounded 路径固定要求 `scope.error_drain_v1`;`false` 固定禁用 +typed drain。核心不使用 `scope.errors` 代替 typed drain。多通道 bounded 方法必须保留 +`on_channel_start` 和 `on_waveform` 的既有回调与部分结果语义。 + +### Average capture V2 profile + +`scope.capture_average_v2` 是独立 capability,不替换 `scope.capture_average`,也不使用 +`ScopeWaveformBinaryProfile`、标准 waveform capability 或 `ScopeWaveformTransfer*` 恢复类型。 +它只接受一个 channel、`global_acquisition` 和 `device_average_complete` 完成证据。descriptor 必须追加 +`scope_extensions.average_capture_profile_v2`,并同时声明: + +- `scope.idn`、`scope.acquisition_status_v2`、`scope.acquisition_run_state`、`scope.acquisition_control` 和 + `scope.channel_input_state_v2`; +- `ScopeAverageCaptureDriverV2` 的全部方法,以及 V2 输入状态和 run-state 方法; +- 只收紧核心上限的 `ScopeAverageCaptureBinaryProfile`:单响应 8 MiB、单操作 64 MiB、256 次 query 和 + 64 KiB resynchronization;首版固定 `DEFINITE_BLOCK` 和精确 trailing; +- 包含 run state、acquisition、trigger、timebase、channel 和 waveform transfer 的完整 restore order,以及 + snapshot、main、restore、verify step 上限。 + +核心执行固定顺序:identity、输入终端和停止状态 preflight,error-before,type write/readback,count +write/readback,停止状态复核,single completion,新鲜 `device_average_complete=True`,一次受 ledger 授权的 +`query_binary()`,error-after,restore 与 fresh verify。每次 setter 后必须立即返回完整 configuration readback; +成功和已证明同步的数据失败都恢复,失步后 session 进入 `poisoned` 且不再发送 cleanup I/O。 + +`ScopeConfig.check_errors=true` 固定要求 `scope.error_drain_v1`,并使用 before-and-after typed drain;`false` +固定禁用 drain。不得调用 legacy `capture_average()`、`query_bin_block()`,也不得将 STOP、OPC、count 变化或 +可读 waveform 当成平均完成证明。核心没有为该 V2 operation 增加 CLI、run-plan step、capture package writer 或 +artifact schema。 + ### Scope R1.3 扩展 R1.3 的 Protocol 和 model 从 `wavebench.instruments` 导出。声明任一新增 capability 时,wheel 依赖和 descriptor 的 `wavebench_min_version` 都必须为 `0.8.23` 或更高的 `0.8.x` 版本。 +`waveform_binary_profile` 与 bounded waveform Protocol 首次提供于核心 `0.8.24`,因此采用它们的 +插件必须把 wheel 依赖和 descriptor 下限同时提高到 `0.8.24` 或更高的 `0.8.x` 版本。 +`average_capture_profile_v2` 与 `scope.capture_average_v2` 也使用 `0.8.24` 开发线静态下限,但同样必须等待 +第一个实际包含完整合同的正式发行版本,才能修改 wheel 依赖、descriptor 版本门或 capability 声明。 + +上述 `0.8.23`/`0.8.24` 是核心开发线的静态合同下限,不是外部插件的发布授权。正式发行物尚未 +包含对应合同前,插件可以针对开发树做离线 conformance,但不得据此发布提高后的 +`Requires-Dist`、`wavebench_min_version` 或新增 capability。核心发布后,应把两处版本门同时改为 +第一个实际包含完整合同的发行版本;若同一版本号已有不含合同的 artifact,必须使用可区分的更高版本。 profile 依赖如下: @@ -288,10 +359,23 @@ profile 依赖如下: | `scope.screenshot_profile`、`scope.screenshot_v2` | `scope_extensions.screenshot_profile` | | `scope.acquisition_control` | `scope_extensions.acquisition_control_profile` | | `scope.trace_metadata`、`scope.fetch_trace` | `scope_extensions.trace_profile` | +| `scope.capture_average_v2` | `scope_extensions.average_capture_profile_v2` | `scope.acquisition_control` 还必须同时声明 `scope.acquisition_run_state`。缺少 profile、方法或核心 版本门时,核心会拒绝 descriptor;只实现方法而不声明 capability,不会产生隐式能力。 +`scope.channel_input_state_v2` 是 scope 可移植性 V2 的只读 capability,不需要 descriptor profile。 +它要求 `ScopeChannelInputStateV2` 的 coupling、termination、`impedance_ohm` 与 +`unavailable_fields` 符合公共模型,并在 factory 内启用 construction barrier。它不设置输入终端, +不替代旧 `scope.channel_coupling`,也不让标准 fetch/capture 改走 V2 安全门。当前开发线的静态 +下限为 `0.8.24`;正式发行前不得由插件提高 wheel 或 descriptor 版本门、声明该 capability。 + +`scope.digital_status_v2` 同样是无 profile 的只读 capability。它要求 +`ScopeDigitalChannelStatusV2` 精确表达逐通道、POD 和 shared 状态,使用 `"unknown"` 保留已查询的 +未知 token,并以 `unavailable_fields` 解释每个缺席值。它不产生 `scope.digital_waveform`,不读取 +waveform payload,也不改变旧 `scope.digital_status`。当前开发线的静态下限为 `0.8.24`;正式发行前 +不得由插件提高 wheel 或 descriptor 版本门、声明该 capability。 + 公共调用入口为 `ScopeService` 和以下 CLI: ```text @@ -613,7 +697,10 @@ capability 的高级配置保持 V1。插件不得把 capability 注册视为 - 更换 canonical `driver_id` 指向的设备族; - 缩小已发布 model 的有效输入范围,导致旧插件对象无法构造。 -新增独立 capability、model 的可选字段或 descriptor 展示字段,通常可以保持现有插件兼容。每次提高最低 WaveBench 版本时,应同步修改 wheel 依赖、descriptor 区间和插件测试矩阵。 +新增独立 capability、model 的可选字段或 descriptor 展示字段,通常可以保持现有插件兼容。标准 waveform +的 `waveform_binary_profile` 与 average 的 `average_capture_profile_v2` 和各自 bounded Protocol 组是已冻结的 +V2 additive 例外:只有相应 profile 非空的 descriptor 改用新方法集,旧 `ScopeDriver` 方法不变。每次提高最低 +WaveBench 版本时,应同步修改 wheel 依赖、descriptor 区间和插件测试矩阵。 ## 最小验证矩阵 diff --git a/docs/project/rfcs/README.md b/docs/project/rfcs/README.md index 9871f2e..7c38577 100644 --- a/docs/project/rfcs/README.md +++ b/docs/project/rfcs/README.md @@ -6,15 +6,38 @@ RFC 使用以下状态: - `Draft`:提案仍可修改,不得据此宣称接口已经提供; - `Accepted`:关键合同已经冻结,可以按实施顺序修改代码; +- `Implemented(未发布)`:开发线实现和离线验收已经完成,但尚未进入正式发行版,插件不得据此提高版本下限; - `Implemented`:合同已经进入正式版本,稳定用法已同步到 `reference/`; - `Superseded`:由后续 RFC 取代,并保留替代关系。 -## 当前 RFC +## Scope 可移植性编号系列 + +[scope 可移植性 RFC-0001~RFC-0009 组合说明](WaveBench_scope可移植性RFC组合说明.md) +记录九份提案的规范优先级、共同 unknown/unavailable 语义、追加式兼容合同、版本组合和 +实施顺序。编号与外部插件提出的问题一一对应,但核心裁决不照搬已被否决的早期 API。 + +| RFC | 状态 | 核心裁决 | +| --- | --- | --- | +| [RFC-0001:消费型文本查询与错误队列](WaveBench_scope可移植性RFC-0001_消费型文本查询.md) | `Superseded R1` | 使用 `ReplayPolicy.NO_REPLAY` 与 `scope.error_drain_v1`,不新增 `query_text_once()` | +| [RFC-0002:通道输入状态 V2](WaveBench_scope可移植性RFC-0002_通道输入状态.md) | `Implemented R1(未发布)` | 追加 coupling/termination 分离模型和 V2 安全判断,不修改旧 coupling 安全门 | +| [RFC-0003:截图 framing 与菜单合同](WaveBench_scope可移植性RFC-0003_截图framing与菜单.md) | `Superseded R1;预算 R1.1 已实现(未发布)` | 使用 `query_binary()`、screenshot profile 和 `scope.screenshot_v2`;核心上限为 `8388608/8388608/1/0` | +| [RFC-0004:数字通道状态 V2](WaveBench_scope可移植性RFC-0004_数字通道状态.md) | `Implemented R1(未发布)` | 追加字段可缺失且保留作用域的 digital status;waveform 另行取证 | +| [RFC-0005:可组合状态快照 V2](WaveBench_scope可移植性RFC-0005_可组合状态快照.md) | `Implemented R1(未发布)` | M3b 已完成核心模型、profile、Protocol、factory gate 与 Service;不改旧 CLI/artifact | +| [RFC-0006:采集状态与平均采集 V2](WaveBench_scope可移植性RFC-0006_采集状态与平均采集.md) | `Implemented R1(未发布;0006a/0006b-0/0006b 单通道)` | M4 已完成 status V2;M6 已完成通用 bounded transaction 与单通道 `global_acquisition`/设备完成位 executor;插件仍未 opt-in | +| [RFC-0007:统计、FFT 与光标读取 V2](WaveBench_scope可移植性RFC-0007_统计FFT与光标读取.md) | `Implemented R1(未发布;0007a/0007b/0007c)` | M5a/M5b/M5c 已完成 statistics/FFT/cursor 的 profile、零 I/O gate 与 Service;不改旧 CLI/artifact | +| [RFC-0008:有界波形传输裁决](WaveBench_scope可移植性RFC-0008_有界波形传输裁决.md) | `Implemented R1(未发布)` | 采用 descriptor profile、`query_binary()`、四维预算和核心恢复编排 | +| [RFC-0009:SINGLE 模式终态 STOP 完成证明](WaveBench_scope可移植性RFC-0009_SINGLE模式终态STOP证明.md) | `Implemented R1(未发布)` | 只在 profile 显式 opt-in 时接受 SINGLE mode-readback 后的首条 STOP;不开放 capture/average | + +本系列中的 `Draft` 不表示接口已经存在,也不授权开始代码、插件 capability 或硬件工作。`Superseded` +表示原提案入口已由更严格合同取代,不表示原始安全问题可以忽略。 + +## 基础与专题 RFC - [Source V2 能力、状态与复合输出安全 RFC](WaveBench_source能力状态与复合输出安全RFC.md): `Accepted R6`,核心 `0.8.24` 开发线已实现 P0、M1–M4、M4.5、C1、M5-A 至 M5-D 与 C2; M6-A、M6-B 与 M6-C 已完成核心离线合同;真实插件 opt-in、实机验收与发布门仍按里程碑逐阶段实施。 - [transport 重放与 session 健康 RFC](WaveBench_transport重放与session健康RFC.md):定义查询重放、结构化传输错误、共享 session 健康状态、恢复授权和版本迁移。 -- [scope 通用扩展接口 RFC](WaveBench_scope通用扩展接口RFC.md):`Accepted R1.3`,定义 operation context、binary budget、截图、采集控制、trace、错误策略及恢复验证合同。公共合同已进入核心 `0.8.23` 开发线;插件按 capability 单独 opt-in。 +- [scope 通用扩展接口 RFC](WaveBench_scope通用扩展接口RFC.md):`Accepted R1.3`,定义 operation context、binary budget、截图、采集控制、trace、错误策略及恢复验证合同;RFC-0009 追加 SINGLE mode-readback terminal STOP proof。公共合同已进入核心 `0.8.23` 开发线;插件按 capability 单独 opt-in。 - [scope 通用扩展接口 RFC:R1.3 Acceptance Addendum A1](WaveBench_scope通用扩展接口RFC-R1.3-acceptance-addendum.md):记录公共 capability 注册采用的 P0/P1 验收门和离线完成证据。 - [scope 通用扩展接口 RFC:核心实施说明](WaveBench_scope通用扩展接口RFC_核心实施说明.md):记录 backend、公共 Service/CLI、artifact、版本门、旧 capture 分流和插件迁移边界。 +- [标准波形有界二进制传输 RFC](WaveBench_标准波形有界二进制传输RFC.md):`Implemented R1(未发布)`,标准 `scope.fetch_waveform` 和 `scope.capture*` 可在 descriptor 显式 opt-in 时复用 R1.3 bounded binary context;外部插件 conformance 与实机验收仍单独进行。 diff --git "a/docs/project/rfcs/WaveBench_scope\345\217\257\347\247\273\346\244\215\346\200\247RFC-0001_\346\266\210\350\264\271\345\236\213\346\226\207\346\234\254\346\237\245\350\257\242.md" "b/docs/project/rfcs/WaveBench_scope\345\217\257\347\247\273\346\244\215\346\200\247RFC-0001_\346\266\210\350\264\271\345\236\213\346\226\207\346\234\254\346\237\245\350\257\242.md" new file mode 100644 index 0000000..aed5d38 --- /dev/null +++ "b/docs/project/rfcs/WaveBench_scope\345\217\257\347\247\273\346\244\215\346\200\247RFC-0001_\346\266\210\350\264\271\345\236\213\346\226\207\346\234\254\346\237\245\350\257\242.md" @@ -0,0 +1,124 @@ +# RFC-0001:消费型文本查询与错误队列 + +> 状态:`Superseded R1` +> 原提案:新增 `query_text_once()` +> 核心裁决:使用统一 replay 合同和 `scope.error_drain_v1` +> 相关规范:[transport 重放与 session 健康 RFC](WaveBench_transport重放与session健康RFC.md)、[scope 通用扩展接口 RFC](WaveBench_scope通用扩展接口RFC.md) +> 系列总览:[scope 可移植性 RFC 组合说明](WaveBench_scope可移植性RFC组合说明.md) + +## 摘要 + +消费型文本 query 的问题成立,但原提案的平行 transport 方法不再采用。核心已经为 +`InstrumentTransport.query()` 增加显式 `ReplayPolicy`,默认值为 +`ReplayPolicy.NO_REPLAY`。核心为错误队列提供受管的 `scope.error_drain_v1` 合同、phase executor +和对账规则;具体逐条 SCPI query、结束 token 与严格解析仍由 opt-in driver 实现。主包内建 scope +descriptor 当前没有声明该 capability。 + +本 RFC 记录替代关系,不新增运行代码。 + +## 问题 + +`:SYSTem:ERRor?`、`*ESR?` 和其他读后清除 query 可能在首次发送后已经消费设备状态。如果 +响应在返回途中失败,再次发送相同命令会观察下一条状态,调用方无法证明第一次响应内容或 +消费次数。 + +不可重放只解决「不得再次发送命令」,不消除读后清除的语义副作用。将这类 query 放入 +snapshot 或普通状态读取时,仍须在 operation 中声明其状态变化和 artifact 证据。 + +## 核心裁决 + +### Transport 入口 + +公共入口保持统一形态: + +~~~python +def query( + self, + command: str, + *, + replay: ReplayPolicy = ReplayPolicy.NO_REPLAY, +) -> str: ... +~~~ + +强制规则: + +1. `NO_REPLAY` 最多发送一次完整命令。 +2. 发送、响应进度或同步状态无法证明时返回结构化 `TransportIOError`。 +3. transport 不根据 SCPI 字符串猜测幂等性。 +4. `read_retry_attempts` 只影响显式 `SAFE_TO_REPLAY`。 +5. 不支持 continuation 的 backend 不得用完整重放代替继续读取。 + +因此不再增加 `query_text_once()`。平行方法会重复 replay、审计、session health 和 backend +一致性合同,也会让普通 query 与消费型 query 形成两套错误语义。 + +### 类型化错误队列 + +新 scope operation 需要错误检查时,只接受: + +~~~text +scope.error_drain_v1 -> drain_errors(max_records=...) +~~~ + +`drain_errors()` 必须: + +- 每次 query 只解析一条错误或一个文档化结束 token; +- 最多公开 `max_records` 条错误; +- 额外执行一次证明 query; +- 以 `query_count == len(records) + 1` 证明正常终止; +- 队列超过上限时保留脱敏的 overflow record,并以 `error_queue_incomplete` 失败; +- 与 guarded transport 的实际 query 增量对账; +- 不执行自动 clear、peek 或 binary I/O。 + +错误队列 query 失败必须保留 transport/session 异常,不能返回伪造的空队列。 + +## 旧 `scope.errors` 兼容边界 + +旧 `scope.errors(limit) -> list[str]` 保持原签名和成功值。需要随 operation 保存兼容诊断时, +核心可以生成 `legacy_unstructured` artifact: + +- `terminated=null`; +- `query_count=null`; +- 不从列表内容推导厂商结束 token; +- 不作为 R1.3 `required` 或 `if_supported` 的能力证明; +- 不与核心 `scope.error_drain_v1` 在同一次 operation 中双重 drain。 + +旧 `ScopeService.errors()` 仍直接返回 `list[str]`,不因为调用它而自动产生 artifact。 +`ScopeConfig.check_errors=true` 在 bounded waveform 路径中固定要求 +`scope.error_drain_v1`;`false` 固定禁用错误队列读取。旧 `scope.errors` 不能替代该门。 + +## Snapshot 与状态读取 + +`NO_REPLAY` 不表示消费型寄存器适合进入普通 snapshot: + +- 没有独立字段和 changed-field 合同时,`*ESR?` 不进入 snapshot; +- `*STB?` 若具有读后清除语义,首版可组合 snapshot 应保持 unavailable; +- 后续若确需暴露消费型状态,应新增明确的 stateful-read operation,不得伪装成无副作用字段。 + +因此 RFC-0005 不以本 RFC 为硬依赖。缺少消费型健康字段不会阻止其他 snapshot 分区返回。 + +## 插件采用条件 + +外部插件声明 `scope.error_drain_v1` 前必须提供: + +1. 厂商文档化的结束 token; +2. 错误 code/message 的严格解析; +3. 消息包含逗号、引号和控制字符的负向测试; +4. `max_records+1` 查询次数和 overflow 对账; +5. `NO_REPLAY` 发送次数测试; +6. 查询失败、session unhealthy 和 deadline 的结构化失败; +7. wheel 与 descriptor 指向首个正式包含该合同的核心版本。 + +插件没有该 capability 时可以继续保留旧 `scope.errors`,或在允许的 operation 中显式禁用 +错误检查;不能发送 `*CLS` 掩盖合同缺失。 + +## 验收与结案 + +核心侧结案条件: + +- transport 默认 `NO_REPLAY` 和结构化失败已有单元测试; +- `scope.error_drain_v1` 的 Protocol、phase、查询上限和 artifact 已冻结; +- 旧 `scope.errors` 的成功返回和 artifact 保持兼容; +- bounded waveform 的 `check_errors` 分流在主 I/O 前完成; +- 文档明确原 `query_text_once()` 提案已被取代。 + +具体仪器错误队列的厂商解析和实机验证不属于本核心 RFC 的完成范围。 diff --git "a/docs/project/rfcs/WaveBench_scope\345\217\257\347\247\273\346\244\215\346\200\247RFC-0002_\351\200\232\351\201\223\350\276\223\345\205\245\347\212\266\346\200\201.md" "b/docs/project/rfcs/WaveBench_scope\345\217\257\347\247\273\346\244\215\346\200\247RFC-0002_\351\200\232\351\201\223\350\276\223\345\205\245\347\212\266\346\200\201.md" new file mode 100644 index 0000000..9b1091e --- /dev/null +++ "b/docs/project/rfcs/WaveBench_scope\345\217\257\347\247\273\346\244\215\346\200\247RFC-0002_\351\200\232\351\201\223\350\276\223\345\205\245\347\212\266\346\200\201.md" @@ -0,0 +1,168 @@ +# RFC-0002:示波器通道输入状态 V2 + +> 状态:`Implemented R1(未发布)` +> 核心基线:现有 `scope.channel_coupling` 与高阻安全门 +> 目标:分开表达 coupling 与 termination +> 系列总览:[scope 可移植性 RFC 组合说明](WaveBench_scope可移植性RFC组合说明.md) + +## 摘要 + +现有 `channel_coupling(channel) -> str` 同时承载显示耦合和输入终端证明。部分仪器把 +termination 编入 `ACL/DCL/AC/DC` token,另一些仪器使用独立 query。为了保留两项真实状态, +本 RFC 已追加输入状态 V2,不修改旧 coupling token 或旧 capture 安全门。 + +## 核心实现状态 + +核心开发线已经提供 `ScopeChannelInputStateV2`、独立 Protocol、 +`scope.channel_input_state_v2`、`ScopeService.channel_input_state_v2()` 和 +`wavebench scope channel-input-state`。该 capability 不需要 descriptor profile,但会触发 +factory construction barrier;缺方法、无效版本或 factory 内 I/O 都在第一次仪器命令前失败。 + +R1 同时提供 V2 termination 的纯判断规则:`high_z` 通过,已明确允许时 `50_ohm` 通过, +`unknown` 始终拒绝。标准 fetch/capture 仍使用 legacy coupling gate,不会因为 descriptor +同时声明 V2 而改变调用顺序。 + +strict construction barrier 只由 descriptor 显式声明 `scope.channel_input_state_v2` 触发;driver +仅额外实现同名方法不会获得 capability,也不会改变旧 factory 行为。主包内建 descriptor 当前 +没有声明该 V2 capability。 + +本状态只表示核心离线实现完成。外部插件必须等待正式核心发行版本,再提高版本门或声明该 +capability。 + +## 当前问题 + +只读取 AC/DC/GND 无法判断输入是高阻还是 50 Ω。把两项设备状态压成旧 token 可以继续满足 +现有安全门,但会丢失原始 coupling 语义,也无法单独展示 termination。 + +反过来,如果直接把旧 `AC` 或 `DC` 当作高阻,也可能在 50 Ω 输入上继续执行采集。安全 +判断必须使用 descriptor 规定的终端策略和实际读取结果,不能靠字符串相似度推断。 + +## 公共模型 + +~~~python +ScopeInputCoupling = Literal["ac", "dc", "gnd", "unknown"] +ScopeInputTermination = Literal["high_z", "50_ohm", "unknown"] + + +@dataclass(frozen=True, slots=True) +class ScopeChannelInputStateV2: + channel: int + coupling: ScopeInputCoupling + termination: ScopeInputTermination + impedance_ohm: float | None = None + unavailable_fields: tuple[Literal["impedance_ohm"], ...] = () +~~~ + +Protocol 与 capability: + +~~~python +class ScopeChannelInputStateDriverV2(Protocol): + def get_channel_input_state_v2( + self, + channel: int, + ) -> ScopeChannelInputStateV2: ... +~~~ + +~~~text +scope.channel_input_state_v2 -> get_channel_input_state_v2 +~~~ + +operation 同样使用 `scope.channel_input_state_v2`。它是 +`stateful_read / exclusive`,不提供输入设置或终端切换。 + +## 模型不变量 + +- `channel` 是正的非 bool 整数; +- `impedance_ohm` 非空时必须为有限正数; +- `impedance_ohm` 只保存设备实际报告或厂商合同明确换算的值; +- `impedance_ohm is None` 时 `unavailable_fields == ("impedance_ohm",)`,非空时该 tuple + 必须为空; +- `termination` 是安全分类,不从 coupling 推导; +- `coupling="gnd"` 不隐含任何 termination; +- 设备返回未识别但语法完整的 token 时可返回 `"unknown"`; +- 响应缺失、格式损坏或 query 失败时 operation 失败,不能返回 `"unknown"`。 + +R1 冻结上述小写枚举和 availability 语义。后续修订不得退回大小写不一致的多组字符串。 + +所有 public dataclass 必须在 `__post_init__` 中执行本 RFC 的类型、范围、枚举和 +availability 不变量。构造失败属于参数或 driver contract failure;Service 不修正无效对象。 + +## 高阻安全规则 + +### Legacy 路径 + +没有声明新 capability 的 descriptor 继续使用现有 `scope.channel_coupling`: + +- `fixed-high-impedance` 继续按 descriptor 固定策略处理; +- `switchable-termination` 的既有组合 token 继续保持当前兼容行为; +- 旧 `require_high_impedance()`、CLI 参数和 capture 返回值不改变。 + +R1 不让旧 switchable descriptor 强制迁移,否则新核心会改变旧插件在发送前的行为。 + +### V2 路径 + +明确选择输入状态 V2 的新 operation 使用以下门: + +| termination | 默认结果 | 显式允许 50 Ω 后 | +| --- | --- | --- | +| `high_z` | 通过 | 通过 | +| `50_ohm` | 拒绝 | 通过 | +| `unknown` | 拒绝 | 仍拒绝 | + +`allow_50ohm` 只能授权已证明的 `50_ohm`,不能把 unknown 解释成 50 Ω。以后若让标准 +capture 采用 V2,必须由 descriptor 显式 opt-in,并单独冻结与 legacy coupling gate 的分流。 + +## Capability 与 factory + +- capability 声明但缺少 `get_channel_input_state_v2()` 时,factory 在仪器 I/O 前拒绝; +- 方法存在但 capability 未声明时,不产生隐式能力; +- 该 capability 不要求 descriptor profile; +- construction barrier 必须覆盖 opt-in factory 内的 query/write; +- 当前开发线的静态版本下限为 `0.8.24`;它不是外部插件的发行授权; +- 参数错误、无效 channel 和不支持的安全策略在仪器 I/O 前失败。 + +## 序列化 + +JSON 应分别保存: + +~~~json +{ + "channel": 1, + "coupling": "dc", + "termination": "high_z", + "impedance_ohm": null, + "unavailable_fields": ["impedance_ohm"] +} +~~~ + +`impedance_ohm=null` 表示没有精确数值,不影响已由独立设备 token 证明的 +`termination="high_z"`。CLI 不再把该对象格式化回 `DCL`,旧 coupling 命令仍可继续返回 +旧 token。 + +## 兼容性与迁移 + +1. 保留 `scope.channel_coupling -> channel_coupling`。 +2. 不修改旧 `ScopeDriver`。 +3. 不增加输入 setter。 +4. 不自动迁移 standard fetch/capture。 +5. 新插件可以同时声明旧 coupling 和 V2 input state;两项 capability 在运行时独立,V2 + operation 不为比较结果额外调用 legacy `channel_coupling()`。 +6. 插件 conformance 必须用同一组设备状态 fixture 验证 legacy token 与 V2 + coupling/termination 映射一致。若未来需要运行时交叉比较,应另增静态映射 profile 和固定 + query 顺序,不能隐式增加 I/O。 + +## 验收矩阵 + +- 模型:channel、有限阻抗、枚举和 bool-as-int 负向测试; +- 安全门:high-Z、50 Ω 默认拒绝、显式放行、unknown 始终拒绝; +- coupling:AC/DC/GND 与 termination 独立组合; +- factory:缺 capability、缺方法、额外方法和 construction barrier 零 I/O; +- failure:query/解析错误不转换成 unknown; +- compatibility:旧 coupling、旧 CLI、旧 fake、DS/RTM 和 standard capture 回归; +- serialization:字段稳定,null 不被替换成 0 或默认阻抗。 + +## 实施边界 + +本 RFC 不授权修改真实仪器输入终端,也不把输入状态 V2 自动加入所有 capture。R1 只提供 +纯读取和安全判定;随后需要真实 termination 的新 operation 必须显式依赖本 capability,不能 +改写 legacy capture 的前置检查。 diff --git "a/docs/project/rfcs/WaveBench_scope\345\217\257\347\247\273\346\244\215\346\200\247RFC-0003_\346\210\252\345\233\276framing\344\270\216\350\217\234\345\215\225.md" "b/docs/project/rfcs/WaveBench_scope\345\217\257\347\247\273\346\244\215\346\200\247RFC-0003_\346\210\252\345\233\276framing\344\270\216\350\217\234\345\215\225.md" new file mode 100644 index 0000000..1f2a62f --- /dev/null +++ "b/docs/project/rfcs/WaveBench_scope\345\217\257\347\247\273\346\244\215\346\200\247RFC-0003_\346\210\252\345\233\276framing\344\270\216\350\217\234\345\215\225.md" @@ -0,0 +1,138 @@ +# RFC-0003:示波器截图 framing 与菜单合同 + +> 状态:`Superseded R1`;截图 V2 预算 R1.1 已实现(未发布) +> 原提案:新增 `query_raw_bytes_once()` 和可空菜单布尔值 +> 核心裁决:使用 `query_binary()`、`ScopeScreenshotProfile` 和 `scope.screenshot_v2` +> 相关规范:[scope 通用扩展接口 RFC](WaveBench_scope通用扩展接口RFC.md) +> 系列总览:[scope 可移植性 RFC 组合说明](WaveBench_scope可移植性RFC组合说明.md) + +## 摘要 + +截图需要同时证明 transport framing、媒体完整性、菜单/颜色请求和临时状态恢复。核心 R1.3 +已经提供这组合同,因此不再增加「读取到 timeout 为止」或「返回任意 bytes」的平行 transport +入口。 + +本 RFC 记录替代关系。核心 API 已实现不表示任一现有内建 driver 或外部插件已经声明 +`scope.screenshot_v2`。 + +截图 V2 使用 scope R1.3 自己的 profile/validator/factory 语义。它不是 RFC-0002、RFC-0004 +strict V2 construction latch 的实例,不能从这两项只读状态 capability 反推出 screenshot V2 的 +latch 行为。 + +## 核心裁决 + +### Framing + +`query_binary()` 首版只接受: + +- `DEFINITE_BLOCK`:响应由 IEEE 488.2 `#N` 头声明 payload 长度; +- `MESSAGE`:具体 backend/resource 能证明 EOI、VISA END 或等价 message boundary。 + +换行、短 `recv()`、idle、timeout、PNG IEND 和暂时无数据都不是通用 transport message +边界。无法证明边界时,binary command 必须在发送前拒绝,或在发送后按结构化同步失败处理。 + +`query_raw_bytes_once()` 不采用,原因包括: + +- 缺少统一完成条件; +- 容易绕过 response/operation/query/resynchronization 预算; +- 会重复 replay 和 session health 合同; +- 容易把媒体结束标记误当成 transport 结束; +- backend 之间无法提供同形的成功证明。 + +如果未来出现既不是 definite block、也无法由 backend 报告 message END 的必要协议,应另立 +包含长度、EOM、预算和失步处理的新 RFC,不能把 raw bytes 方法作为无边界后门。 + +### 请求 tuple + +核心不再使用单个 `include_menu: bool` 表达所有设备。请求由明确 tuple 构成: + +~~~text +format = png +menu_mode = device | include | exclude +color_mode = device | color | monochrome | inverted +~~~ + +`device` 表示保留设备当前行为,不等于 `include` 或 `exclude`。descriptor profile 必须枚举 +实际支持的 request tuple;请求没有精确匹配时,在仪器 I/O 前拒绝。 + +菜单不可控制或无法证明时,只能声明 `device`,不能把 unknown 静默改写成 +`exclude`。结果中的 effective request 必须与已验证 variant 一致。 + +### 截图预算 R1.1 + +`scope.screenshot_v2` 的 response/operation 核心上限为 `8388608 / 8388608 / 1 / 0`。response +和 operation 同时提高到 8 MiB,与 trace/waveform 的单响应核心上限一致。该值覆盖厂商资料中 +已文档化的 `387,356`-byte TMC definite-block 截图示例,并为常见高分辨率 PNG 保留余量;query +上限仍为 1,resynchronization 预算仍为 0。 + +该调整不改变 `DEFINITE_BLOCK`/`MESSAGE` 的 framing 要求、精确 trailing、PNG 校验、operation +timeout 或超限 fail-closed 语义。descriptor profile 和 connection 限制只能进一步收紧;核心预算 +提高也不自动授权任一插件声明 screenshot capability。首个采用新预算的插件必须依赖包含该合同的 +正式核心版本,并完成自身的 framing、payload、恢复和实机验收。 + +## 媒体与 transport 分层 + +transport 只验证 framing、长度、精确 trailing、预算和同步。driver/核心媒体处理器负责: + +- PNG signature; +- IHDR 尺寸; +- chunk 长度与校验; +- IEND; +- profile 声明的 content trailing; +- media type。 + +位于 definite block payload 或 MESSAGE 数据中的 PNG 后缀属于 content trailing,不是 +transport trailing。两者都只能精确匹配,不能用 `rstrip()` 删除任意空白。 + +## 状态恢复 + +需要临时改变菜单或颜色时,顺序固定为: + +~~~text +preflight snapshot + -> main capture + -> error-after + -> restore + -> fresh verify +~~~ + +核心持有 baseline、context、session epoch 和一次性 nonce。driver 不得自行替换 baseline, +也不得在 `finally` 中绕过阶段授权恢复。恢复或 fresh verify 失败时不能返回截图成功值。 +session 已 `poisoned` 时禁止追加 restore、verify、IDN 或探测 I/O。 + +## 旧截图兼容边界 + +- 旧 `scope.screenshot` 与 `screenshot_png()` 保持不变; +- 同时声明旧 capability 和 `scope.screenshot_v2` 时,旧 capture 仍走 legacy screenshot; +- 只有 V2、没有旧 capability 时,旧 capture 的嵌入截图请求在 I/O 前拒绝; +- 独立 V2 命令不改变旧 `CaptureResult` 和部分 waveform 产物语义; +- 内建 RTM2032/DS1104 没有因为核心合同存在而自动获得 V2 capability。 + +## 插件采用条件 + +具体插件声明 screenshot V2 前必须逐 request variant 证明: + +1. 实际 framing 与 resource/backend; +2. response、operation、query 和 resynchronization 上限; +3. transport/content trailing; +4. PNG 完整性和尺寸; +5. menu/color 的 requested/effective 语义; +6. changed fields、restore order 和 step 上限; +7. 成功、媒体失败、transport 失败和恢复失败; +8. fresh readback 与最终 session health。 + +若设备只能证明 definite block 截图,但不能控制菜单,可以只声明 +`menu_mode="device"` 的受限 variant。无法证明 `exclude` 时不得复用旧 +`include_menu=False` 的表面语义。 + +## 验收与结案 + +核心侧结案条件: + +- `BinaryResponseFraming`、`query_binary()` 和四维预算已实现; +- `ScopeScreenshotRequest/Profile/Baseline/ScopeScreenshot` 已公开; +- capability、required Protocol、Service、CLI 和 artifact 已注册; +- legacy capture 分流和零 I/O 拒绝已有回归; +- 文档明确原 `query_raw_bytes_once()` 不再采用。 + +具体型号的 framing/menu 证据和实机验收仍属于插件范围。 diff --git "a/docs/project/rfcs/WaveBench_scope\345\217\257\347\247\273\346\244\215\346\200\247RFC-0004_\346\225\260\345\255\227\351\200\232\351\201\223\347\212\266\346\200\201.md" "b/docs/project/rfcs/WaveBench_scope\345\217\257\347\247\273\346\244\215\346\200\247RFC-0004_\346\225\260\345\255\227\351\200\232\351\201\223\347\212\266\346\200\201.md" new file mode 100644 index 0000000..f2f6ed1 --- /dev/null +++ "b/docs/project/rfcs/WaveBench_scope\345\217\257\347\247\273\346\244\215\346\200\247RFC-0004_\346\225\260\345\255\227\351\200\232\351\201\223\347\212\266\346\200\201.md" @@ -0,0 +1,226 @@ +# RFC-0004:可移植的数字通道状态 V2 + +> 状态:`Implemented R1(未发布)` +> 核心基线:现有 `ScopeDigitalChannelStatus` +> 目标:保留未知值、共享状态和字段作用域 +> 系列总览:[scope 可移植性 RFC 组合说明](WaveBench_scope可移植性RFC组合说明.md) + +## 摘要 + +现有 `ScopeDigitalChannelStatus` 要求一次填满 activity、technology、threshold、 +hysteresis、deskew、size、position、label 等字段。该模型适合能够查询完整状态的仪器, +不能诚实表达只提供其中一部分状态的设备。 + +本 RFC 已追加 digital status V2。新模型不把不可查询字段填成默认枚举或零值,也不把 +整机、POD 和逐通道状态混在同一作用域中。现有 digital waveform bitset 模型保持独立。 + +## 核心实现状态 + +核心开发线已经提供 `ScopeDigitalChannelStatusV2`、POD/shared 子模型、独立 Protocol、 +`scope.digital_status_v2`、`ScopeService.digital_status_v2()` 和 +`wavebench scope digital-status-v2`。该 capability 不需要 descriptor profile,但会触发 factory +construction barrier;缺方法、无效版本或 factory 内 I/O 都在第一次仪器命令前失败。 + +R1 冻结数字通道为零基非负整数,允许超过 15 以避免把单一厂商的通道数写成公共上限。标准 +`scope.digital_status`、`scope.digital_waveform`、旧模型、旧 CLI 和旧 capability 不改变。 +核心实现不表示任一具体设备已经具有 V2 或 digital waveform 能力;主包内建 descriptor 当前 +也没有声明 `scope.digital_status_v2`。 + +## 当前问题 + +跨厂商数字状态通常分成四类: + +- 模块或逻辑分析选件是否存在; +- D0~Dn 的逐通道显示、位置和标签; +- 一组数字通道共享的 POD 阈值; +- 整机共享的数字显示大小和 timing calibration。 + +旧模型中的 `threshold_coupled`、`deskew_s` 和 `size` 没有明确区分这些作用域。 +设备没有 activity、technology、hysteresis 或 label-enabled query 时,填入 `LOW`、 +`MANUAL`、`NORMAL`、`False` 或空字符串都会制造并不存在的设备状态。 + +## 公共模型 + +候选模型按作用域分层: + +~~~python +ScopeDigitalThresholdScope = Literal["channel", "pod", "unknown"] +ScopeDigitalActivityV2 = Literal["LOW", "HIGH", "TOGGLE", "unknown"] +ScopeDigitalTechnologyV2 = Literal["TTL", "ECL", "CMOS", "MANUAL", "unknown"] +ScopeDigitalHysteresisV2 = Literal["MAXIMUM", "ROBUST", "NORMAL", "unknown"] +ScopeDigitalSizeV2 = Literal[ + "SMALL", + "MEDIUM", + "LARGE", + "DIV1", + "DIV2", + "DIV4", + "DIV8", + "unknown", +] + +ScopeDigitalStatusFieldV2 = Literal[ + "displayed", + "position_div", + "label", + "label_enabled", + "activity", + "technology", + "hysteresis", + "pod", + "pod.threshold_v", + "pod.threshold_scope", + "shared", + "shared.module_present", + "shared.timing_calibration_s", + "shared.size", +] + + +@dataclass(frozen=True, slots=True) +class ScopeDigitalPodStatusV2: + start_channel: int + stop_channel: int + threshold_v: float | None = None + threshold_scope: ScopeDigitalThresholdScope | None = None + + +@dataclass(frozen=True, slots=True) +class ScopeDigitalSharedStatusV2: + module_present: bool | None = None + timing_calibration_s: float | None = None + size: ScopeDigitalSizeV2 | None = None + + +@dataclass(frozen=True, slots=True) +class ScopeDigitalChannelStatusV2: + channel: int + displayed: bool | None = None + position_div: float | None = None + label: str | None = None + label_enabled: bool | None = None + activity: ScopeDigitalActivityV2 | None = None + technology: ScopeDigitalTechnologyV2 | None = None + hysteresis: ScopeDigitalHysteresisV2 | None = None + pod: ScopeDigitalPodStatusV2 | None = None + shared: ScopeDigitalSharedStatusV2 | None = None + unavailable_fields: tuple[ScopeDigitalStatusFieldV2, ...] = () +~~~ + +R1 冻结上述分层字段和 V2 枚举中的 `"unknown"`,不修改旧 Literal。`timing_calibration_s` 保持 +shared 状态;后续若需独立整机对象,必须追加新字段而不能改变本模型含义。字段作用域和 +unknown/unavailable 区别不得丢失。 + +候选 Protocol 与 capability: + +~~~python +class ScopeDigitalStatusDriverV2(Protocol): + def get_digital_status_v2( + self, + channel: int, + ) -> ScopeDigitalChannelStatusV2: ... +~~~ + +~~~text +scope.digital_status_v2 -> get_digital_status_v2 +~~~ + +该 operation 是 `stateful_read / exclusive`。它不触发 acquisition,也不读取 waveform +推断 activity。 + +## 不变量 + +### 通道与 POD + +- channel、POD 起止编号都是零基非负的非 bool 整数; +- POD 范围必须包含当前 channel; +- `start_channel <= stop_channel`; +- POD 阈值不能伪装成逐通道独立阈值; +- `threshold_scope="pod"` 时必须存在 POD 范围; +- threshold、position 和 timing 数值必须有限。 +- shared 分区存在时至少一个叶字段非空;全为空时使用 `shared=None` 和父 unavailable path。 + +### 未提供字段 + +- 静态不可查询字段为 `None`; +- 对应路径进入已排序、去重的 `unavailable_fields`; +- 设备明确返回 unknown token 时使用类型允许的 unknown 值,不把它写成 unavailable; +- 单次 query 失败、响应截断或解析失败使 operation 失败; +- 全局字段不能复制后再描述为「逐通道独立读值」。 + +`unavailable_fields` 只接受 `ScopeDigitalStatusFieldV2`。pod/shared 整体为 `None` 时只记录 +父路径,不再记录其子路径;分区存在时父路径不得出现,空叶字段各自记录叶路径。同一结果 +不得同时包含父路径与其子路径。每个 `None` 必须恰好由一个路径解释,非空字段不得出现在 +路径集合。 + +### 标签 + +`label=""` 只能表示设备实际返回空标签。`label=None` 表示没有可证明的标签 query。 +`label_enabled=None` 不能从标签字符串是否为空推导。 + +所有候选 public dataclass 必须在 `__post_init__` 中执行本 RFC 的类型、有限数值、作用域、 +枚举和 availability 不变量。构造失败属于参数或 driver contract failure;Service 不修正 +无效对象。 + +## Capability 与 Service + +- capability 声明但缺少 V2 方法时,factory 在仪器 I/O 前拒绝; +- 方法存在但 capability 未声明时不暴露 Service; +- 参数错误在 I/O 前拒绝; +- Service 返回 V2 模型,不适配成旧 `ScopeDigitalChannelStatus`; +- CLI 使用追加的 `scope digital-status-v2 --channel ` 入口,旧 `digital-status` 输出不改变; +- capability explain 应明确显示「旧 digital status」与「V2 digital status」是两项独立能力。 + +## 与 digital waveform 的边界 + +`ScopeDigitalWaveform` 的 `uint16` bitset 足以表达 D0~D15 的逻辑结果,本 RFC 不修改该 +模型。具体插件在声明 `scope.digital_waveform` 前仍须证明: + +- BYTE/WORD payload 到 LOW/HIGH 的编码; +- 多字节样本的字节序; +- 数字 channel 到 bit 位置的映射; +- 点数、X 轴和同次 acquisition 关系; +- transfer 状态恢复。 + +digital status V2 通过不能替代这些证据,也不能自动产生 digital waveform capability。 + +## 兼容性 + +1. 保留 `scope.digital_status -> get_digital_status`。 +2. 不修改旧 `ScopeDigitalChannelStatus` 必填字段。 +3. 旧 driver 可以只实现旧 capability。 +4. 新 driver 可以同时声明两项能力,但不得用 V2 的 `None` 填充旧必填模型。 +5. 内建 descriptor 没有声明 digital capability 时继续在 capability gate 拒绝,不新增探测。 +6. 序列化保留 `null` 和稳定 unavailable paths,不格式化成默认枚举。 + +## 核心离线验收 + +`tests/test_scope_digital_status_v2.py` 覆盖: + +- 嵌套 POD/shared 作用域、有限数值、unknown、空标签和完整/部分序列化; +- 父/叶 unavailable path 的精确集合、稳定顺序、重复、遗漏和类型错误; +- 独立 Protocol、capability、OperationSpec、版本门和额外方法不产生隐式 capability; +- factory construction barrier、缺方法关闭、Service 仅调用 V2 方法、返回通道回显; +- 追加 CLI 文本/JSON,以及双 capability descriptor 继续走旧 `digital-status`。 + +`scope.digital_waveform` 不在本 RFC 的实现范围。R1 没有为它创建 profile、decoder、payload 编码或 +新的 descriptor 声明。 + +## 验收矩阵 + +- 模型:channel/POD 范围、有限值、bool-as-int、稳定 unavailable paths; +- 作用域:逐通道、POD 与 shared 字段不互相冒充; +- unknown:静态 unavailable、查询后 unknown、I/O/解析失败三者分离; +- label:空字符串、不可查询和 enabled unknown; +- capability:缺方法、额外方法、未声明能力和 construction barrier 零 I/O; +- Service/JSON:null、枚举和字段路径稳定; +- legacy:旧模型、旧 capability、CLI、fake 和内建 driver 回归; +- waveform:V2 status 不产生 waveform capability。 + +## 非目标 + +- 不通过 waveform 推断 activity; +- 不根据阈值猜测 TTL、ECL 或 CMOS technology; +- 不增加数字 channel setter; +- 不在本 RFC 中冻结数字 waveform 编码; +- 不把某个厂商的 POD 布局写成所有示波器的固定通道数。 diff --git "a/docs/project/rfcs/WaveBench_scope\345\217\257\347\247\273\346\244\215\346\200\247RFC-0005_\345\217\257\347\273\204\345\220\210\347\212\266\346\200\201\345\277\253\347\205\247.md" "b/docs/project/rfcs/WaveBench_scope\345\217\257\347\247\273\346\244\215\346\200\247RFC-0005_\345\217\257\347\273\204\345\220\210\347\212\266\346\200\201\345\277\253\347\205\247.md" new file mode 100644 index 0000000..611f539 --- /dev/null +++ "b/docs/project/rfcs/WaveBench_scope\345\217\257\347\247\273\346\244\215\346\200\247RFC-0005_\345\217\257\347\273\204\345\220\210\347\212\266\346\200\201\345\277\253\347\205\247.md" @@ -0,0 +1,382 @@ +# RFC-0005:可组合的示波器状态快照 V2 + +> 状态:`Implemented R1(未发布)` +> 核心基线:现有完整 `ScopeSnapshot` 与 `status_summary()` +> 目标:允许设备返回可证明的类型化分区,不伪造缺失字段 +> 系列总览:[scope 可移植性 RFC 组合说明](WaveBench_scope可移植性RFC组合说明.md) +> R1 范围:允许追加核心模型、profile、Protocol、capability、factory gate 与 Service;不新增 CLI、artifact、run plan 或插件 opt-in + +## 摘要 + +现有 `scope.snapshot` 要求一次返回 identity、health、channel、timebase、probe、waveform +和 trigger 七个完整分区。部分仪器能可靠读取其中大部分字段,却无法证明少数厂商特有状态。 +为了补齐旧模型而填入零值、空字符串或默认枚举,会把「设备没有报告」变成错误事实。 + +本 RFC 提议追加 snapshot V2。身份仍是必需基线,其他分区和设备相关叶字段可以明确 +unavailable。现有完整 snapshot 和 partial summary 均保持原样。 + +## 核心实现状态 + +核心开发线已经提供 `ScopeSnapshotV2` 与六个可空分区模型、`ScopeSnapshotProfileV2`、 +`ScopeSnapshotDriverV2`、`scope.snapshot_v2`、strict factory construction barrier 和 +`ScopeService.snapshot_v2(channel)`。Service 只把验证后的 `readable_fields` 原样交给 driver, +在单个受预算的文本 `query()` phase 中执行,不调用 legacy identity preflight、error drain、 +`*STB?`、`*ESR?`、binary query、write 或 acquisition。 + +R1 不新增 V2 CLI、artifact 或 run plan step,也不修改 `ScopeSnapshot`、`scope.snapshot`、 +`status()`、`status_summary()` 或旧 `scope status`。主包内建 descriptor 和外部 MSO8000 插件均未 +声明 `scope.snapshot_v2`;核心实现不表示任何具体仪器已经 opt-in。 + +## 当前边界 + +核心当前提供两条不同入口: + +- `scope.snapshot`:只有 driver 能填满现有 `ScopeSnapshot` 时才能声明; +- `status_summary()`:缺少完整 snapshot 时,只聚合已有 IDN 和 coupling。 + +partial summary 是安全降级,不是可组合 typed snapshot。直接向它增加不稳定字段会改变旧 +JSON 和 CLI 形状,也无法表达字段级不可提供原因。 + +## 候选公共模型 + +~~~python +ScopeSnapshotFieldV2 = Literal[ + "identity.manufacturer", + "identity.model", + "identity.serial_number", + "identity.firmware", + "identity.options", + "health.status_byte", + "health.operation_condition", + "health.questionable_condition", + "health.acquisition_available", + "health.acquisition_count", + "health.sample_rate_hz", + "health.error_queue_nonempty", + "health.waiting_for_trigger", + "channel.channel", + "channel.enabled", + "channel.coupling", + "channel.range_v", + "channel.scale_v_per_div", + "channel.offset_v", + "channel.position_div", + "channel.bandwidth_hz", + "channel.polarity", + "channel.skew_s", + "channel.label", + "channel.label_enabled", + "channel.overloaded", + "channel.acquisition_type", + "timebase.acquisition_time_s", + "timebase.divisions", + "timebase.position_s", + "timebase.range_s", + "timebase.reference_percent", + "timebase.scale_s_per_div", + "timebase.roll_enabled", + "probe.channel", + "probe.attenuation_factor", + "probe.bandwidth_hz", + "probe.capacitance_f", + "probe.impedance_ohm", + "probe.name", + "probe.probe_type", + "waveform.channel", + "waveform.x_start_s", + "waveform.x_stop_s", + "waveform.points", + "waveform.values_per_sample", + "waveform.x_increment_s", + "waveform.x_origin_s", + "waveform.y_increment_v", + "waveform.y_origin_v", + "waveform.y_resolution_bits", + "trigger.trigger_type", + "trigger.source_channel", + "trigger.mode", + "trigger.slope", + "trigger.coupling", + "trigger.level_v", + "trigger.hysteresis_mode", + "trigger.holdoff_mode", + "trigger.holdoff_time_s", +] + + +@dataclass(frozen=True, slots=True) +class ScopeHealthSnapshotV2: + status_byte: int | None = None + operation_condition: int | None = None + questionable_condition: int | None = None + acquisition_available: int | None = None + acquisition_count: int | None = None + sample_rate_hz: float | None = None + error_queue_nonempty: bool | None = None + waiting_for_trigger: bool | None = None + + +@dataclass(frozen=True, slots=True) +class ScopeAnalogChannelSnapshotV2: + channel: int + enabled: bool | None = None + coupling: str | None = None + range_v: float | None = None + scale_v_per_div: float | None = None + offset_v: float | None = None + position_div: float | None = None + bandwidth_hz: float | None = None + polarity: str | None = None + skew_s: float | None = None + label: str | None = None + label_enabled: bool | None = None + overloaded: bool | None = None + acquisition_type: str | None = None + + +@dataclass(frozen=True, slots=True) +class ScopeTimebaseSnapshotV2: + acquisition_time_s: float | None = None + divisions: int | None = None + position_s: float | None = None + range_s: float | None = None + reference_percent: float | None = None + scale_s_per_div: float | None = None + roll_enabled: bool | None = None + + +@dataclass(frozen=True, slots=True) +class ScopeProbeSnapshotV2: + channel: int + attenuation_factor: float | None = None + bandwidth_hz: float | None = None + capacitance_f: float | None = None + impedance_ohm: float | None = None + name: str | None = None + probe_type: str | None = None + + +@dataclass(frozen=True, slots=True) +class ScopeWaveformMetadataSnapshotV2: + channel: int + x_start_s: float | None = None + x_stop_s: float | None = None + points: int | None = None + values_per_sample: int | None = None + x_increment_s: float | None = None + x_origin_s: float | None = None + y_increment_v: float | None = None + y_origin_v: float | None = None + y_resolution_bits: int | None = None + + +@dataclass(frozen=True, slots=True) +class ScopeTriggerSnapshotV2: + trigger_type: str + source_channel: int | None = None + mode: str | None = None + slope: str | None = None + coupling: str | None = None + level_v: float | None = None + hysteresis_mode: str | None = None + holdoff_mode: str | None = None + holdoff_time_s: float | None = None + + +@dataclass(frozen=True, slots=True) +class ScopeSnapshotV2: + identity: ScopeIdentitySnapshot + health: ScopeHealthSnapshotV2 | None = None + channel: ScopeAnalogChannelSnapshotV2 | None = None + timebase: ScopeTimebaseSnapshotV2 | None = None + probe: ScopeProbeSnapshotV2 | None = None + waveform: ScopeWaveformMetadataSnapshotV2 | None = None + trigger: ScopeTriggerSnapshotV2 | None = None + unavailable_fields: tuple[ScopeSnapshotFieldV2, ...] = () + not_applicable_fields: tuple[ScopeSnapshotFieldV2, ...] = () + + +@dataclass(frozen=True, slots=True) +class ScopeSnapshotProfileV2: + readable_fields: tuple[ScopeSnapshotFieldV2, ...] + max_queries: int + conditionally_applicable_fields: tuple[ScopeSnapshotFieldV2, ...] = () + allowed_effect: Literal["pure_read"] = "pure_read" +~~~ + +每个 V2 子模型同样把非公共、非必有叶字段设为可空。仅把整个分区改成 `None` 而继续要求 +分区内部所有旧字段非空,不能解决跨厂商问题。 + +`ScopeSnapshotFieldV2` 是公共模型字段路径的完整封闭集合。profile 的 +`readable_fields` 非空、唯一,并且必须包含全部 `identity.*` 字段; +`conditionally_applicable_fields` 唯一且是 readable fields 的子集,不得包含 identity 或 +分区身份字段。`max_queries` 是有限正的非 bool 整数。首版 +`allowed_effect` 固定为 `"pure_read"`,不接受临时写入、binary query 或消费型 query。 +profile 追加到 `ScopeDescriptorExtensions.snapshot_profile_v2`;运行时结果必须遵守 +readable/conditional 的精确字段合同。两个 availability tuple 均按 +`ScopeSnapshotFieldV2` 的声明顺序排序;snapshot V2 不使用父分区路径。 + +channel、probe 和 waveform 分区存在时,其 `channel` 必须等于请求 channel;trigger 分区 +存在时 `trigger_type` 必须是非空 safe token。profile 只要声明某分区的其他字段,就必须同时 +声明该分区的 channel 或 trigger-type 身份字段。timebase/health 没有分区身份字段,按叶字段 +逐项声明。分区身份字段必须是非条件 readable。 + +候选 Protocol 与 capability: + +~~~python +class ScopeSnapshotDriverV2(Protocol): + def get_snapshot_v2( + self, + channel: int, + *, + fields: tuple[ScopeSnapshotFieldV2, ...], + ) -> ScopeSnapshotV2: ... +~~~ + +~~~text +scope.snapshot_v2 -> get_snapshot_v2 +~~~ + +R1 的 Service 名为 `ScopeService.snapshot_v2(channel)`。R1 不定义 V2 CLI、run plan step 或 +operation artifact,也不得让旧 `scope status` 静默改变返回 schema。调用方不选择 +fields;核心只把已验证 profile 的 `readable_fields` 原样传给 driver,避免任意字段列表扩大 +query 面。 + +## R1 文档裁决 + +### Identity 与文本查询预算 + +`get_snapshot_v2()` 必须在自己的纯文本读取 phase 中读取当前连接代次的 identity,并在同一调用中 +构造完整 `ScopeIdentitySnapshot`。`manufacturer`、`model`、`serial_number` 和 `firmware` 必须是 +本次读取证明的非空文本;`options=()` 只有在本次读取明确证明没有选件时才有效。不得从 +descriptor、缓存、先前 Service 调用或型号常量补齐任何 identity 字段。 + +`profile.max_queries` 的计数窗口恰好覆盖一次 `get_snapshot_v2()` 调用:从进入 driver 方法到返回 +或抛出为止,guarded transport 实际发送的每一次 `query()` 都计入,包括 identity、条件适用性 +判定和所有字段读取。schema 校验不计数;核心不得在该窗口外额外发送 IDN、错误队列、`*STB?`、 +`*ESR?` 或其他 preflight query。phase 的允许 I/O 精确为文本 `query`,不允许 +`query_float_list()`、binary query、write 或 acquisition。 + +### 返回与诊断边界 + +snapshot V2 是本系列的独立只读 operation,不复用 scope R1.3 `ScopeExtensionService`、 +`ScopeExtensionOperationResult` 或 `wavebench.scope.operation.v1` artifact。候选 Service 直接返回 +`ScopeSnapshotV2`;直接 JSON 序列化保留 `null` 与两个 availability tuple。若以后需要保存 +operation diagnostics,必须通过单独的已接受 artifact RFC 定义版本、脱敏和兼容 reader,不能把 +R1.3 envelope 当作通用容器。 + +## 可组合规则 + +### Identity + +identity 的唯一有效来源和 query 预算以「R1 文档裁决」为准。不得从 descriptor 的型号字符串、 +缓存或旧 operation 构造仪器身份。 + +### 分区 + +- 完整分区不可提供时,该分区为 `None`,并记录该分区的全部封闭叶路径; +- 分区存在但某个叶字段不可提供时,该叶字段为 `None`,并记录叶路径; +- 路径使用公共模型字段名,不使用厂商命令; +- 相同设备/profile 下的静态 unavailable 集应稳定; +- driver 不得因一次 query 失败临时把字段改成 unavailable 后返回成功。 + +上面的「运行时收紧」只允许 conditionally applicable 字段根据当前已查询状态进入 +`not_applicable_fields`。profile 的 readable 非条件字段必须成功返回非空值,不能在运行时 +改成 unavailable。profile 未列入 readable 的字段固定为 `None` 并进入 +`unavailable_fields`;readable 条件字段当前不适用时固定为 `None` 并进入 +`not_applicable_fields`。两组路径分别排序、去重、互斥,每个空叶字段恰好由其中一组解释, +非空字段不得出现在任一组。 + +当完整分区没有任何 readable 字段时,结果中的分区为 `None`,序列化器将该分区的全部封闭 +叶路径规范化到 unavailable 集;公共模型不另引入不属于 `ScopeSnapshotFieldV2` 的父路径。 + +### 查询失败 + +声明可查询的字段发生 transport、协议或解析失败时,整个 operation 失败。已经取得的分区可以 +作为脱敏 diagnostics 保存,但不能构造部分成功的 `ScopeSnapshotV2`。 + +这条规则避免同一 capability 在通信故障时悄悄缩小返回内容。以后若需要流式 partial result, +应新增明确的结果 envelope。 + +## 消费型状态 + +`NO_REPLAY` 只保证命令不被完整重放,不会消除读后清除副作用。首版 snapshot V2 中: + +- `*STB?`、`*ESR?` 和错误队列默认不查询; +- `status_byte`、condition register 和 `error_queue_nonempty` 没有无损合同时保持 + unavailable; +- 不为填充 health 分区调用 `scope.error_drain_v1`; +- snapshot 不隐式 clear、截图、读取 waveform payload 或触发 acquisition。 + +因此 RFC-0001 不是 snapshot V2 的硬依赖。一个可选健康字段不能阻塞 channel、timebase、 +probe、waveform metadata 或 trigger 分区。 + +## 状态读取边界 + +snapshot V2 是 `stateful_read / exclusive`,R1 只允许纯文本状态 query。核心把 phase 的 +allowed I/O 固定为 text query,并按「R1 文档裁决」中的单次 driver 调用窗口将 guarded transport +的实际 query 增量与 `profile.max_queries` 对账。 + +若某个 waveform metadata query 会改变 source、format、range 或其他仪器状态,该字段在 R1 +保持 unavailable。以后若确需可恢复读取,必须另行增加 profile、typed baseline、 +restore/verification Protocol 和 changed/verification fields;不能把临时写入隐藏在 +`get_snapshot_v2()` 中。 + +## Capability 与 factory + +- capability 声明但缺 V2 方法时,在 factory 后、第一次仪器 I/O 前拒绝; +- 方法存在但 capability 未声明时不自动暴露; +- invalid channel 和请求 schema 在 I/O 前拒绝; +- construction barrier 覆盖 opt-in factory; +- capability 声明要求 `snapshot_profile_v2` 非空且通过静态校验; +- driver 的 fields/result 必须遵守 readable/conditional/unavailable 精确集合,不能扩大或 + 静默缩小 descriptor 事实; +- query count 超出 profile 时是 driver contract violation,operation 失败。 + +所有候选 public dataclass,包括嵌套 V2 子模型,都必须在 `__post_init__` 中执行类型、有限 +数值、字段集合和 availability 不变量。构造失败属于参数或 driver contract failure;Service +不修正无效对象。 + +## 兼容性 + +1. 保留现有 `ScopeSnapshot`、`scope.snapshot` 和 `get_snapshot()`。 +2. 保留 `status()`、`status_summary()`、`--strict` 和既有 JSON/text 输出。 +3. 新模型不通过 adapter 填充旧模型的强制字段。 +4. 旧 driver、fake 和内建 descriptor 无需实现 V2。 +5. 新 driver 可以同时声明完整旧 snapshot 和 V2;相同字段的值必须一致。 +6. V2 capability 不表示返回所有分区,只表示返回对象遵守本 RFC 的可用性语义。 + +## 序列化与 artifact 边界 + +R1 只定义 `ScopeSnapshotV2` 的直接 JSON 语义:可空字段序列化为 `null`,availability +tuple 按封闭字段顺序序列化为字符串数组。它不定义 CLI 文本格式、operation artifact 或 run plan +持久化。不得把 identity、原始 SCPI、真实 resource、序列号或完整设备响应写入诊断。 + +若后续 Accepted RFC 需要持久化结果,artifact 必须另行定义 schema/version、requested channel、 +available sections、脱敏 identity 摘要、query-count 摘要、session-epoch 关联与 failure diagnostics; +不能改变现有 `scope status` JSON,也不能复用 R1.3 extension artifact 作为未版本化的容器。 + +## 验收矩阵 + +- 模型:分区/叶字段可空、有限数值、路径稳定和 identity 必需; +- 一致性:每个 unavailable 路径对应空字段,非空字段不在路径集合; +- failure:单次 query/解析失败不降级成成功; +- consumption:不隐式读取 `*ESR?`、错误队列、截图或 waveform data; +- stateful metadata:如有临时写入,changed/restore/fresh verify 全覆盖; +- capability/factory:缺方法、额外方法、未声明能力和 construction barrier 零 I/O; +- legacy:完整 snapshot、partial summary、strict status、CLI 和 JSON 黄金基线; +- cross-driver:至少两个不同仪器族的 fixture 能返回不同分区子集。 + +## 核心离线验收 + +R1 已验证:Service 使用 `ScopeService.snapshot_v2(channel)`、identity 在受计数的 driver phase 内 +新鲜读取,且不提供 V2 CLI/artifact。`tests/test_scope_snapshot_v2.py`、 +`tests/test_scope_extension_registry.py`、`tests/test_scope_snapshot.py` 和 +`tests/test_scope_portability_m0.py` 覆盖: + +1. profile/result 的完整、部分、条件字段和负向矩阵; +2. identity、text query budget、query overrun 与非 query I/O 的发送前拒绝; +3. construction barrier、缺方法关闭、旧 `status()`/`status_summary()` 和 legacy factory 的零 I/O + 兼容矩阵; +4. 内建 legacy descriptor 不含 V2 capability,外部 MSO8000 descriptor 仍可离线加载且未声明 + snapshot V2。 diff --git "a/docs/project/rfcs/WaveBench_scope\345\217\257\347\247\273\346\244\215\346\200\247RFC-0006_\351\207\207\351\233\206\347\212\266\346\200\201\344\270\216\345\271\263\345\235\207\351\207\207\351\233\206.md" "b/docs/project/rfcs/WaveBench_scope\345\217\257\347\247\273\346\244\215\346\200\247RFC-0006_\351\207\207\351\233\206\347\212\266\346\200\201\344\270\216\345\271\263\345\235\207\351\207\207\351\233\206.md" new file mode 100644 index 0000000..ef70e65 --- /dev/null +++ "b/docs/project/rfcs/WaveBench_scope\345\217\257\347\247\273\346\244\215\346\200\247RFC-0006_\351\207\207\351\233\206\347\212\266\346\200\201\344\270\216\345\271\263\345\235\207\351\207\207\351\233\206.md" @@ -0,0 +1,806 @@ +# RFC-0006:可移植的采集状态与平均采集 V2 + +> 状态:`Implemented R1(未发布;0006a/0006b-0/0006b 单通道)` +> 核心基线:legacy acquisition/average API 与 R1.3 acquisition control +> 范围:普通采集状态 V2、平均配置和平均采集事务 +> 系列总览:[scope 可移植性 RFC 组合说明](WaveBench_scope可移植性RFC组合说明.md) +> 本轮范围:0006a 已完成核心只读模型、profile、factory gate 与 Service;0006b-0 已完成内部 +> bounded transaction 内核;0006b R1 已完成单通道 core-only public implementation,插件 opt-in 仍为 Draft + +## 摘要 + +本 RFC 把两个不同问题分开: + +1. RFC-0006a:读取设备实际具备的 acquisition、average 和 segmented 状态; +2. RFC-0006b:配置平均采集、证明完成、取得波形并恢复全部状态。 + +核心 R1.3 已经实现 run state、continuous start、完成式 single 和 stop。该合同不等于平均状态, +也不能由 SINGLE 完成证明推出 average complete。本 RFC 复用 R1.3 控制,不再建立第二套 +start/single/stop API。 + +## 当前问题 + +现有 `ScopeAcquisitionStatus` 强制要求平均和分段字段同时存在。现有 +`ScopeAverageConfiguration` 又固定要求 `single_count` 和逐通道 arithmetic。不同设备可能 +使用全局 acquisition type、逐通道 arithmetic 或两者组合,不能用默认值把一种机制伪装成 +另一种。 + +现有 `ScopeAverageCaptureRequest` 还把平均次数固定为 2~1024 的 2 次幂。设备范围应由有限的 +核心硬上限与 descriptor profile 共同收紧,不能把某一仪器的限制写成跨厂商事实。 + +## 与 R1.3 acquisition control 的关系 + +以下公共合同直接复用: + +- `ScopeAcquisitionRunState`; +- `ScopeAcquisitionControlProfile`; +- `scope.acquisition_run_state`; +- `scope.acquisition_control`; +- start/single/stop 的 baseline、completion proof、failure cleanup 和 fresh verification。 + +复用边界: + +- run state 只描述当前观察; +- `ScopeAcquisitionCompletion` 只证明一次 SINGLE; +- `STOP` 不证明平均累积完成; +- `*OPC?` 不证明平均累积完成; +- acquisition count 的变化不自动等于平均次数已经满足。 + +## RFC-0006a:采集状态 V2 + +### R1 模型 + +~~~python +ScopeAcquisitionStatusFieldV2 = Literal[ + "acquisition_type", + "run_state", + "sample_rate_hz", + "memory_depth", + "average", + "average.configured_count", + "average.complete", + "segmented", + "segmented.option_installed", + "segmented.enabled", + "segmented.maximum_enabled", + "segmented.capacity", + "segmented.available", +] + + +@dataclass(frozen=True, slots=True) +class ScopeAverageStatusV2: + configured_count: int + complete: bool | None = None + + +@dataclass(frozen=True, slots=True) +class ScopeSegmentedStatusV2: + option_installed: bool | None = None + enabled: bool | None = None + maximum_enabled: bool | None = None + capacity: int | None = None + available: int | None = None + + +@dataclass(frozen=True, slots=True) +class ScopeAcquisitionStatusV2: + acquisition_type: str | None = None + run_state: ScopeAcquisitionRunState | None = None + sample_rate_hz: float | None = None + memory_depth: int | None = None + average: ScopeAverageStatusV2 | None = None + segmented: ScopeSegmentedStatusV2 | None = None + unavailable_fields: tuple[ScopeAcquisitionStatusFieldV2, ...] = () + not_applicable_fields: tuple[ScopeAcquisitionStatusFieldV2, ...] = () +~~~ + +~~~python +SCOPE_ACQUISITION_STATUS_V2_MAX_QUERIES = 32 + + +@dataclass(frozen=True, slots=True) +class ScopeAcquisitionStatusProfileV2: + readable_fields: tuple[ScopeAcquisitionStatusFieldV2, ...] + max_queries: int + conditionally_applicable_fields: tuple[ScopeAcquisitionStatusFieldV2, ...] = () + allowed_effect: Literal["pure_read"] = "pure_read" +~~~ + +该 profile 以 append-only 的 `ScopeDescriptorExtensions.acquisition_status_profile_v2` 追加,且只有 +声明 `scope.acquisition_status_v2` 的 descriptor 才能提供它。它不是 R1.3 acquisition control profile, +也不改变 legacy `ScopeAcquisitionStatus`。 + +`readable_fields` 是本设备可能返回值的完整字段闭包,按 `ScopeAcquisitionStatusFieldV2` 声明顺序排列、 +去重,必须包含非条件的 `"acquisition_type"`。`"average"`/`"segmented"` 是分区路径:任何子路径 +出现时,父路径必须出现;`"average"` 还要求 `"average.configured_count"`,`"segmented"` 至少要求一个 +segmented 叶路径。`conditionally_applicable_fields` 是 `readable_fields` 的子集,不能包含 +`"acquisition_type"`;条件父路径可在当前 mode 下覆盖其全部子路径,包括 profile 未声明的静态叶字段。 + +`max_queries` 是 `1..32` 的非 bool 整数。它计数从进入 `get_acquisition_status_v2()` 到返回或抛出期间的 +全部受 guard 计数的文本 `query()`,包括 acquisition type、适用性、run state 和所有叶字段的判定。 +这个 phase 只允许 `query()`;禁止 write、binary query、`query_float_list()`、legacy `idn()`、`*STB?`、 +`*ESR?`、error drain、acquisition control 或任何额外 Service preflight。超额是 driver 合同违反,operation +失败,不重放或续读。 + +`run_state` 应直接复用 R1.3 类型,不再定义含义重叠的字符串。若设备没有声明 +`scope.acquisition_run_state`,status V2 可以把该分区保持 unavailable;不得由 trigger token +临时拼出一个弱化对象。 + +### R1 capability + +~~~python +class ScopeAcquisitionStatusDriverV2(Protocol): + def get_acquisition_status_v2( + self, + *, + fields: tuple[ScopeAcquisitionStatusFieldV2, ...], + ) -> ScopeAcquisitionStatusV2: ... +~~~ + +~~~text +scope.acquisition_status_v2 -> get_acquisition_status_v2 +~~~ + +该 operation 是 `stateful_read / exclusive`。它不触发、停止或重新配置 acquisition;Service 只调用这个 +V2 方法一次,并将 profile 的 `readable_fields` 原样传入,不调用 legacy status、average capture 或 R1.3 +acquisition-control Service。 + +### 状态不变量 + +- average configured count 是正的非 bool 整数; +- `complete=None` 表示设备没有完成位; +- segmented 数量非空时为非负非 bool 整数; +- sample rate 为有限正数,memory depth 为正的非 bool 整数; +- acquisition type 使用规范化 safe token,不保存原始 SCPI; +- `STOP`、`*OPC?`、已配置 count 或 elapsed time 不得填充 `complete=True`; +- 声明可读的字段 query 失败时 operation 失败,不改写为 unavailable。 + +`unavailable_fields` 只接受 `ScopeAcquisitionStatusFieldV2`。路径必须合法、唯一、排序,并满足: + +- `unavailable_fields` 只表示 descriptor 静态无法提供的字段; +- `not_applicable_fields` 只表示当前已读取 mode 下没有语义的字段; +- 两个 tuple 都按 `ScopeAcquisitionStatusFieldV2` 声明顺序排序、去重且彼此互斥; +- 分区整体为 `None` 时,在其中恰好一组记录 `"average"`、`"segmented"` 或 `"run_state"`, + 不再同时记录其子路径; +- 分区存在但叶字段不可提供或当前不适用时,只记录相应叶路径,例如 + `"average.complete"`; +- 路径对应字段必须为 `None`;非空字段不得列入任一 tuple; +- 同一结果不得在任一 tuple 内或两者之间同时包含父路径和其子路径。 + +两个 availability tuple 是结果 `None` 值的最小、精确覆盖:普通顶层字段为 `None` 时使用自己的路径; +`average`/`segmented` 整体为 `None` 时只使用父路径,父路径覆盖其全部子路径;分区存在时只使用其中 +为 `None` 的叶路径。父路径不得和任何子路径同时出现,即使它们位于不同 tuple。`run_state` 没有子路径, +为 `None` 时只使用 `"run_state"`。 + +profile 和结果还必须相互收紧:未列入 `readable_fields` 的字段只能由 `unavailable_fields` 覆盖;已列入 +且非条件的字段必须有值,除非被当前 `not_applicable_fields` 中的条件父路径覆盖;条件字段可以有值,或由 +自己/条件父路径进入 `not_applicable_fields`。声明可读字段的 query、解析或类型验证失败必须使 operation +失败,不能转写为任一 availability path。 + +当前 acquisition mode 不是 average 时,整个 `average` 分区或只在该 mode 下无意义的叶字段必须 +进入 `not_applicable_fields`,不能伪装成静态 unavailable。当前 mode 是 average 但设备没有完成 +位时,才将 `"average.complete"` 记录为静态 unavailable;`complete=True` 只能来自文档化的完成位。 +segmented 分区也按相同规则区分「设备没有查询合同」和「当前配置下没有语义」。 + +若 profile 不含 `"run_state"`,结果必须将它静态标记为 unavailable。若 profile 包含它,descriptor 必须 +同时声明 `scope.acquisition_run_state`;V2 driver 可以在同一 query budget 内读取自身的状态证据,但不得调用 +`ScopeExtensionService`、legacy `get_acquisition_status()` 或从 trigger token 推导 run state。已声明独立 +run-state capability 不强制 profile 返回该字段。 + +所有 R1 dataclass 必须在 `__post_init__` 中执行上述验证,不能只依赖 Service 文本约定。 + +### RFC-0006a R1 核心实现状态 + +核心开发线已实现 `ScopeAcquisitionStatusV2`、average/segmented 分区模型、append-only +`ScopeAcquisitionStatusProfileV2`、独立 Protocol、`scope.acquisition_status_v2`、strict factory +construction barrier、portability-V2 `OperationSpec` 和 `ScopeService.acquisition_status_v2()`。 + +`scope.acquisition_status_v2` 与 profile、`ScopeAcquisitionStatusDriverV2.get_acquisition_status_v2()` 一一对应。 +factory 在首次仪器 I/O 前必须同时校验 capability、append-only profile、严格核心版本门、可调用方法,以及 +上述 run-state 条件依赖;声明该 capability 时启用 strict construction barrier。factory 返回 driver、profile 和 +Protocol/backend 校验完成前,所有 query、write、binary 和 OPC I/O 都必须以 +`factory_construction_pending` 发送前拒绝;失败时关闭已开的 transport。额外存在但未声明的 V2 方法不能产生 +capability 或 latch。 + +该 operation 使用独立 portability-V2 `OperationSpec`:60 秒 deadline、`stateful_read / exclusive`、 +`error_check_minimum="disabled"`、无 required verified fields、无 restore coverage。`ScopeService` +`acquisition_status_v2()` 在一个受 `max_queries` 限制的 normal phase 中运行,因此没有 legacy identity +preflight、R1.3 identity preflight、error drain 或 `scope.acquisition_run_state` Service 调用。 + +R1 不新增 V2 CLI、run-plan step、artifact 或持久化 JSON schema。现有 `wavebench scope acquisition-status`、 +其文本/JSON 输出、`ScopeService.acquisition_status()`、`ScopeAcquisitionStatus`、 +`scope.acquisition_status -> get_acquisition_status()` 均保持不变;即使 descriptor 同时声明 V1 和 V2,旧命令 +也只走 legacy 路由。V2 Service 不得 fallback 到 V1 status、`capture_average()` 或 R1.3 control。 + +R1 的核心离线实现已通过模型/profile、capability、factory、纯文本 query budget、legacy route 和 +MSO8000 未 opt-in 兼容回归。主包内建 descriptor 和外部插件在独立 conformance、版本下限和硬件证据完成前 +不得声明该 capability;status V2 的核心实现或发布也不授权 RFC-0006b average capture V2。 + +## RFC-0006b:平均采集 V2 + +### R1 单通道请求与配置 + +~~~python +ScopeAverageMechanism = Literal["global_acquisition"] +ScopeAverageCompletionEvidence = Literal["device_average_complete"] + +SCOPE_AVERAGE_COUNT_MAX_V2 = 65_536 + + +@dataclass(frozen=True, slots=True) +class ScopeAverageCaptureRequestV2: + channels: tuple[int, ...] + average_count: int + mechanism: ScopeAverageMechanism + acquisition_stopped: Literal[True] + points: str = "dmax" + allow_50ohm: bool = False + + +@dataclass(frozen=True, slots=True) +class ScopeAverageConfigurationV2: + mechanism: ScopeAverageMechanism + acquisition_type: str + average_count: int + + +@dataclass(frozen=True, slots=True) +class ScopeAverageCompletionProofV2: + evidence: ScopeAverageCompletionEvidence + mechanism: ScopeAverageMechanism + configured_average_count: int + configuration_readback: ScopeAverageConfigurationV2 + acquisition_completion: ScopeAcquisitionCompletion + device_average_complete: Literal[True] + contract_id: str + context_id: str + session_epoch: str + acquisition_baseline_nonce_digest: str + + +@dataclass(frozen=True, slots=True) +class ScopeAverageCaptureResultV2: + request: ScopeAverageCaptureRequestV2 + waveforms: tuple[WaveformData, ...] + configuration_before: ScopeAverageConfigurationV2 + configuration_after: ScopeAverageConfigurationV2 + run_state_before: ScopeAcquisitionRunState + run_state_after: ScopeAcquisitionRunState + completion: ScopeAverageCompletionProofV2 + restore: "ScopeAverageCaptureRestoreResult" + verification: "ScopeAverageCaptureVerification" +~~~ + +R1 只接受一个 request channel、`global_acquisition` 和 `device_average_complete`。`channels` 保留 tuple +形状以便后续追加多通道变体,但 R1 构造和 profile preflight 必须要求其长度恰为 1。`channel_arithmetic`、 +`combined`、callbacks、partial result 和 `documented_single_completion` 均不属于 R1,必须通过后续独立 +接受的 R2 合同追加。 + +成功结果没有 `"unknown"` completion evidence。completion 无法证明、`device_average_complete` 为 false、 +类型不符或 query/parse 失败均为 `completion_unproven` operation failure;不得返回部分 waveform、 +缓存结果或带空 completion 的成功对象。 + +`waveforms` 固定为按 request channel 顺序排列的 `tuple[WaveformData, ...]`,R1 长度恰为 1。 +`WaveformData.channel` 是规范 channel 载体;结果 verifier 要求唯一 channel 与 request 精确相等。 +R1 只提供 Service dataclass 返回,不创建 V2 CLI、run-plan step、capture package writer 或新的 artifact +schema;operation diagnostics 只使用既有脱敏 scope-operation / error-check envelope。 + +### 模型不变量 + +R1 dataclass 必须通过 `__post_init__` 冻结以下规则: + +- request channels 为长度恰 1 的 tuple,其中 channel 为正的非 bool 整数; +- average count 是 `2..SCOPE_AVERAGE_COUNT_MAX_V2` 的非 bool 整数,并由 profile 进一步收紧; +- mechanism 只能为 `"global_acquisition"`; +- `acquisition_stopped is True`,不接受 truthy 值; +- `allow_50ohm` 必须是真 bool;它是本 operation 的显式输入安全授权,不改变 descriptor 或仪器设置; +- points 先由标准 waveform points 规范化器收敛到 `"def"`、`"max"` 或 `"dmax"`,再由 profile 的 + `supported_points` 发送前校验; +- configuration 的 acquisition type 是非空 safe token,mechanism/count 与 request 和 profile 精确一致; +- waveforms 的唯一 channel 与 request 精确相等; +- configuration after 必须等于 before,run state before/after 都是已停止 baseline; +- restore 必须为 completed,并精确覆盖 profile restore order; +- verification 必须为 verified,覆盖相同字段且没有 mismatch; +- completion proof 的 mechanism/count/readback、context/epoch 和 child-baseline nonce digest 与 request、 + profile 和生效配置精确一致;`device_average_complete` 必须是真正的 `True`。 + +### Descriptor profile + +平均采集是写入和 acquisition operation,必须由 descriptor 显式 opt-in: + +~~~python +ScopeAverageCaptureField = Literal[ + "scope.run_state", + "scope.acquisition", + "scope.trigger", + "scope.timebase", + "scope.channel_display", + "scope.channel_vertical", + "scope.waveform_source", + "scope.waveform_mode", + "scope.query_response_header", + "scope.waveform_format", + "scope.waveform_byte_order", + "scope.waveform_points", + "scope.waveform_transfer_window", +] + + +@dataclass(frozen=True, slots=True) +class ScopeAverageCaptureBinaryProfile: + response_max_bytes: int + operation_max_bytes: int + query_max_count: int + resynchronization_max_bytes: int + framing: BinaryResponseFraming = BinaryResponseFraming.DEFINITE_BLOCK + transport_trailing_hex: str = "" + + +@dataclass(frozen=True, slots=True) +class ScopeAverageCaptureProfileV2: + global_acquisition_type: str + completion_contract_id: str + channel_range: tuple[int, int] + supported_points: tuple[str, ...] + average_count_min: int + average_count_max: int + requires_power_of_two: bool + binary: ScopeAverageCaptureBinaryProfile + restore_order: tuple[ScopeAverageCaptureField, ...] + snapshot_max_steps: int + main_max_steps: int + restore_max_steps: int + verify_max_steps: int +~~~ + +该 profile 以 append-only 的 `ScopeDescriptorExtensions.average_capture_profile_v2` 追加。规则: + +- 最小值和最大值为非 bool 整数,且 `2 <= min <= max`; +- `global_acquisition_type` 与 `completion_contract_id` 都是非空、长度受限的 safe token;后者只标识 + 已审查的设备完成位合同,不替代厂商文档、离线 fixture 或实机验收; +- channel range 是两个满足 `1 <= min <= max` 的非 bool 整数;R1 request 的唯一 channel 必须落入范围; +- `supported_points` 非空、唯一,并使用标准 waveform points 规范化结果; +- profile 只能收紧核心硬上限; +- `average_count_max <= SCOPE_AVERAGE_COUNT_MAX_V2`;首版核心上限 `65_536` 是有限资源门, + 不表示任一设备支持该值,descriptor 必须进一步收紧; +- `requires_power_of_two` 必须是真正的 bool; +- binary framing 首版固定为 `DEFINITE_BLOCK`,trailing 是最长 16 bytes 的精确小写 + 十六进制;response/total/query 为有限正的非 bool 整数,resynchronization 为有限非负的 + 非 bool 整数; +- binary profile、OperationSpec 和 connection limit 逐项取最小值,且不得超过 RFC-0008 的 + `8 MiB / 64 MiB / 256 / 64 KiB` 核心 ceiling; +- 首版沿用 RFC-0008 的可信 PyVISA/RsInstrument VISA `INSTR` backend gate;Serial、 + SocketIO、duck transport 或仅实现公开 `query_binary()` 的对象在 binary command 前拒绝; +- restore order 唯一、按 `ScopeAverageCaptureField` 声明顺序排列,且 R1 必须覆盖全部字段; + `scope.acquisition` 是 acquisition type/count 的唯一 restore owner,不能用 + `scope.acquisition.type` 或 `scope.acquisition.average_count` 替代; +- snapshot/restore/verify step 上限有限且不少于 restore field 数;`main_max_steps` 是 `8..128` 的 + 非 bool 整数,覆盖两次 configuration write、两次即时 readback、stopped recheck、一次 SINGLE、 + 一次 fresh device-complete read 和一次 bounded waveform fetch; +- capability、profile 和 required Protocol 必须一一对应; +- 运行时设备 query 只能收紧 profile,不能扩大 descriptor 声明。 + +### 与 RFC-0008 的二进制边界 + +`ScopeAverageCaptureBinaryProfile` 是 average capture 自己的 R1 profile,不是 +`ScopeWaveformBinaryProfile.operations` 的新 operation kind,也不复用标准 waveform 的 +`fetch`/`capture_single`/`capture_multiple` profile。它自己的 `supported_points` 是 average +points 的唯一发送前依据;不得要求或推断 `scope.fetch_waveform` capability,不能把 average +读取伪装成标准 fetch。 + +两条路径只应共享已经冻结的 transport 安全语义:`query_binary()`、精确 trailing、四维限制的 +逐项取最小值、可信 backend gate、no-replay 与 poisoned session。RFC-0008 的 profile schema、 +capability 映射和标准 waveform 恢复闭包保持不变。 + +average 不得复制或绕过 RFC-0008 的安全逻辑;它只可接入已由下节 0006b-0 冻结的通用 bounded +transaction 内核。0006b-0 本身没有创建 `ScopeAverageCaptureBinaryProfile`、`scope.capture_average_v2`、 +相关 Protocol、CLI 或 descriptor 字段;这些 R1 average 专属类型仅由本节的单通道合同授权。 + +### RFC-0006b-0:通用 bounded transaction 前置合同 + +本节是 core-only 的 `Accepted R1` 内部合同。它把 RFC-0008 已有的 `ScopeBinaryLimits`、 +`BinaryQueryLedger`、`ScopeOperationContextCoordinator`、guarded `query_binary()` 和可信 backend +检查明确为可被未来 scope acquire/write operation 复用的最小内核;它不改变标准 waveform profile、 +capability、baseline 或 executor,也不向插件暴露新入口。 + +#### 有效 binary 限制 + +任何 bounded operation 必须在创建 context 前同时得到完整的 operation-spec、descriptor-profile,以及完整或 +未配置(视为 `+∞`)的 connection 四维限制。有效限制逐项计算为: + +~~~text +effective_response_max = min(spec response, profile response, connection response) +effective_operation_max = min(spec total, profile total, connection total) +effective_query_max_count = min(spec query count, profile query count, connection query count) +effective_resynchronization_max = min(spec resync, profile resync, connection resync) +~~~ + +前三项是正的非 bool 整数,resynchronization 是非负的非 bool 整数,且 total 不小于 response。 +profile 和 spec 缺任一项时在任何 I/O 前拒绝。connection 限制只能由核心配置/Service 传入;未配置某一 +connection 层时,该层在内部交集计算中视为 `+∞`,不能由 driver、descriptor 或调用方构造、扩张或重置。 +结果被 opaque ledger 固定,driver 只能把单次 `max_bytes` 进一步收紧。 + +#### factory backend 与 construction barrier + +可信 binary backend 是 factory-only、operation-agnostic 的验证结论:仅核心验证过的 PyVISA 或 +RsInstrument VISA message-based `INSTR` 路径可获得该标记。Serial、SocketIO、duck transport 或仅实现 +公开 `query_binary()` 的对象都不通过。该标记绑定 factory 创建的 `GuardedAuditedTransport` 和同一 session +epoch,不可由 driver、profile 或 Service 设置;它不蕴含标准 waveform capability。 + +未来声明任何 bounded-binary V2 capability 时,该 capability 必须进入 strict construction latch 集合。 +factory 可以打开 transport,但必须在 factory 返回、capability/profile/required-method/backend 通过全部静态 +验证后才释放 latch;此前任何 I/O 均以 `factory_construction_pending` 发送前拒绝。profile 或方法缺失、 +backend 不可信、transport 数量不是预期值时,关闭已开的 transport 且零仪器 I/O。标准 waveform 继续使用 +同一 generic 验证结论,不改变其 descriptor profile/capability 映射。 + +#### context、ledger 与失败收敛 + +每个 bounded operation 只创建一个 core-owned context、absolute deadline 和 binary ledger;phase 严格顺序且 +不可嵌套,binary I/O 仅在 main phase。ledger 不跨 context/epoch 复用,不退款、不重建,也不允许 replay、 +continuation 或 legacy `query_bin_block()` fallback。进入 cleanup 后 ledger 立即失效。 + +同步已证明的 application/data failure 可以在同一 deadline 的静态 recovery/verification phase 中恢复;同步未知、 +framing/termination 恢复失败或 resynchronization 超限立即 poisoned,之后禁止所有 backend I/O。cleanup 未完成、 +restore/verify 失败或不能证明恢复,也必须以 poisoned 收敛且不得返回成功值。主异常保持 primary cause; +artifact 只记录现有 scope operation schema 中的 phase history、budget 摘要、session health、baseline nonce digest、 +restore/verification outcome 和脱敏 cleanup diagnostics,不保存命令、payload 或 nonce 原文。 + +#### 0006b 接入条件 + +0006b 后续的 average profile、baseline、Protocol 和 executor 必须分别拥有自己的字段闭包、restore owner 和 +OperationSpec;只能向本内核提交 limits、framing、phase specs 与 recovery/verification 闭包。它不得复用 +`ScopeWaveformBinaryProfile.operations`、标准 waveform capability、`ScopeWaveformTransfer*` 类型或 +`BoundedWaveformExecutor` 的业务编排。average 公共代码必须另行接受,并同时冻结 completion evidence、 +composite baseline 精确覆盖、error policy 和无 CLI/artifact 边界;本文件后续的 R1 单通道合同完成了该裁决。 + +#### 0006b-0 R1 核心实现状态 + +核心开发线已将 factory-owned bounded backend marker 从 waveform 专用内部名称收敛为 operation-agnostic +bounded-binary marker:标准 waveform executor 继续检查同一 factory 验证结论,旧私有 waveform marker 和 +validator 仅保留为委托到 generic 实现的兼容别名,不产生第二份状态或旁路。可信 backend 仍仅限 +PyVISA/RsInstrument VISA `INSTR`,construction latch、transport 关闭和零 I/O failure 语义不变。 + +`ScopeOperationContextCoordinator` 已有的 `ScopeBinaryLimits.intersect()` 现以离线回归固定为 +spec/profile/connection 三方逐项最小值;未提供 connection 限制仍等价于 `+∞`,直到另一个核心配置合同 +引入真实 connection binary-limit source。该内部 refactor 本身没有创建 average capability、profile、Protocol、 +descriptor 字段、CLI、artifact 或插件 opt-in。核心和外部 MSO8000 插件的既有离线回归均通过。 + +### R1 baseline 与恢复 Protocol + +R1.3 acquisition baseline 只覆盖 run state、trigger 和 acquisition token,不能覆盖完整 average +配置和 waveform transfer。R1 average 使用独立的完整 baseline,并仅把 R1.3 baseline 作为同一 parent +context 内完成证明的 child baseline: + +~~~python +@dataclass(frozen=True, slots=True) +class ScopeAverageCaptureStateSnapshot: + captured_fields: tuple[ScopeAverageCaptureField, ...] + configuration: ScopeAverageConfigurationV2 + run_state: ScopeAcquisitionRunState + run_state_token: str | None = None + acquisition_token: str | None = None + trigger_token: str | None = None + timebase_token: str | None = None + channel_display_token: str | None = None + channel_vertical_token: str | None = None + waveform_source_token: str | None = None + waveform_mode_token: str | None = None + query_response_header_token: str | None = None + waveform_format_token: str | None = None + waveform_byte_order_token: str | None = None + waveform_points_token: str | None = None + waveform_transfer_window_token: str | None = None + + +@dataclass(frozen=True, slots=True) +class ScopeAverageCaptureBaseline: + context_id: str + session_epoch: str + baseline_nonce: str + snapshot: ScopeAverageCaptureStateSnapshot + restore_order: tuple[ScopeAverageCaptureField, ...] + acquisition_baseline: ScopeAcquisitionControlBaseline + + +@dataclass(frozen=True, slots=True) +class ScopeAverageCaptureRestoreResult: + status: Literal["completed", "failed", "not_attempted"] + attempted_fields: tuple[ScopeAverageCaptureField, ...] + restored_fields: tuple[ScopeAverageCaptureField, ...] + error_code: str | None = None + + +@dataclass(frozen=True, slots=True) +class ScopeAverageCaptureVerification: + status: Literal["verified", "mismatch", "unavailable"] + verified_fields: tuple[ScopeAverageCaptureField, ...] + mismatched_fields: tuple[ScopeAverageCaptureField, ...] + error_code: str | None = None +~~~ + +`ScopeAverageCaptureStateSnapshot` 是 average 自己的 token 闭包,不复用 +`ScopeWaveformTransferStateSnapshot`、`ScopeWaveformTransferBaseline`、restore result 或 verification +类型。`captured_fields` 必须与 parent restore order 精确相等;每个 captured field 只有一个 non-empty +safe token。typed `run_state` 和 `configuration` 是 token 的语义投影,不是第二个 restore owner。 + +`scope.acquisition` 是 acquisition type 和 average count 的唯一 restore owner。`scope.acquisition.type` +和 `scope.acquisition.average_count` 只可作为 changed/postcondition/fresh-verification fields,不能进入 +average restore order。`ScopeAverageCaptureBaseline.acquisition_baseline` 的 context/epoch 必须与 parent +相同、nonce 必须不同;其 run state、trigger token 和 acquisition token 必须分别与 average snapshot 的投影及 +对应 token 一致。child baseline 只用于 `ScopeAcquisitionCompletion` 验证,不能触发第二次 restore。 + +R1 driver facet: + +~~~python +class ScopeAverageCaptureDriverV2(ScopeAcquisitionRunStateDriver, Protocol): + def snapshot_average_capture_state( + self, + fields: tuple[ScopeAverageCaptureField, ...], + ) -> ScopeAverageCaptureStateSnapshot: ... + + def set_average_acquisition_type_v2( + self, + acquisition_type: str, + *, + baseline: ScopeAverageCaptureBaseline, + ) -> None: ... + + def get_average_configuration_v2( + self, + *, + baseline: ScopeAverageCaptureBaseline, + ) -> ScopeAverageConfigurationV2: ... + + def set_average_count_v2( + self, + average_count: int, + *, + baseline: ScopeAverageCaptureBaseline, + ) -> None: ... + + def acquire_average_single_v2( + self, + *, + baseline: ScopeAverageCaptureBaseline, + deadline: float, + ) -> ScopeAcquisitionCompletion: ... + + def get_device_average_complete_v2( + self, + *, + baseline: ScopeAverageCaptureBaseline, + ) -> bool: ... + + def fetch_average_waveform_bounded( + self, + channel: int, + *, + points: str, + baseline: ScopeAverageCaptureBaseline, + ) -> WaveformData: ... + + def restore_average_capture_state( + self, + baseline: ScopeAverageCaptureBaseline, + ) -> ScopeAverageCaptureRestoreResult: ... + + def verify_average_capture_state_restored( + self, + baseline: ScopeAverageCaptureBaseline, + ) -> ScopeAverageCaptureStateSnapshot: ... +~~~ + +core 只在同一 parent context 中调用这些方法。两次 setter 分别代表唯一的 global acquisition type 写入和 +average count 写入;每次 setter 返回后,core 必须立即调用 `get_average_configuration_v2()`,确认 type 或 +完整 type/count,之后才允许下一次 write、SINGLE 或 binary I/O。最终 aggregate configuration 不是替代 +每次 readback 的证据。 + +`acquire_average_single_v2()` 只可使用 parent baseline 中的 child acquisition baseline,并返回 +`ScopeAcquisitionCompletion`。core 使用现有 acquisition-control profile 对该 completion 进行验证;它不得调用 +公开的 `ScopeService.acquire_single()` 或 `ScopeExtensionService.acquire_single()`,也不得创建嵌套 +operation/context、独立 deadline、error phase 或 binary ledger。child baseline 不能重置 parent 的任何预算。 + +average operation 无论主流程成功还是失败,都恢复完整 +`ScopeAverageCaptureBaseline`。这与 R1.3 独立 SINGLE 成功后有意保留停止记录的语义不同, +不能直接复用 SINGLE 的「成功不恢复」结论。 + +### R1 capability、依赖与 OperationSpec + +R1 capability: + +~~~text +scope.capture_average_v2 +~~~ + +声明该 capability 必须同时满足: + +- `scope.idn`; +- `scope.acquisition_status_v2`; +- `scope.acquisition_run_state`; +- `scope.acquisition_control`; +- `scope.channel_input_state_v2`; +- `ScopeDescriptorExtensions.average_capture_profile_v2`; +- `get_channel_input_state_v2()`、`get_acquisition_run_state()` 和上述 average + snapshot/set/readback/acquire/completion/fetch/restore/verify 方法。 + +average capture 使用自己的 `ScopeAverageCaptureBinaryProfile` 和 +`fetch_average_waveform_bounded()`,不把 RFC-0008 的标准 `operation_kind="fetch"` 复用为 +另一个 public operation,也不要求 descriptor 声明 `scope.fetch_waveform`。两条路径只共享 +`query_binary()`、四维 ledger、backend gate、no-replay 和 poison 合同;方法存在不产生标准 +fetch capability。插件可以复用私有 preamble/decoder 代码,但两个 profile 分别校验。 + +R1 的上述 capability/profile/Protocol/OperationSpec 已在核心开发线实现并通过离线回归;它们仍不授权 +内建 descriptor 或外部插件声明该 capability。 + +`scope.channel_input_state_v2` 是 R1 的必需依赖,不回退 legacy coupling; +`scope.error_drain_v1` 是有效 error policy 非 disabled 时的条件依赖。factory 必须将静态依赖、 +profile 和全部 required methods 一次校验,不得先按 legacy average 方法拒绝后再尝试 V2。 + +R1 `OperationSpec` 使用 `effect="acquire"`、exclusive lease、`restore_coverage="average-capture-baseline"`、 +60 秒核心 deadline 和 RFC-0008 四维 binary ceiling。其 restore/cleanup verification fields 是完整 +`ScopeAverageCaptureField` 闭包;`scope.acquisition.type` 和 `scope.acquisition.average_count` 只追加到 +changed/postcondition verification fields。有效 binary 限制由核心 ceiling、average binary profile 和 connection +limit 逐项取最小值。 + +### 核心编排 + +平均采集不得只交给插件中的一个自由事务方法。core 必须持有 operation context、baseline、 +deadline、ledger 和恢复授权。R1 顺序为: + +~~~text +identity + exact input-state safety + stopped snapshot preflight + -> error-before + -> one MAIN: type write/readback -> count write/readback -> stopped recheck + -> SINGLE completion -> fresh device-average-complete -> one bounded waveform fetch + -> error-after + -> restore + -> fresh verify +~~~ + +R1 的 preflight 在任何 write 前读取唯一 channel 的 `ScopeChannelInputStateV2` 和 average snapshot。 +输入 state 的 channel 必须等于 request;只有 `high_z` 默认通过,`50_ohm` 还必须由 +`request.allow_50ohm is True` 显式授权,`unknown`、query/parse failure、channel mismatch 或非 stopped state +均发送前失败。core 不发送 STOP 来满足这一前置条件。main 只能进入一次;不存在第二 channel、callback、 +partial result、自动重触发或中间续读。 + +### 必须覆盖的状态 + +R1 changed/restore/verification 字段使用下列集合: + +~~~text +scope.run_state +scope.acquisition +scope.acquisition.type +scope.acquisition.average_count +scope.trigger +scope.timebase +scope.channel_display +scope.channel_vertical +scope.waveform_source +scope.waveform_mode +scope.query_response_header +scope.waveform_format +scope.waveform_byte_order +scope.waveform_points +scope.waveform_transfer_window +~~~ + +`scope.acquisition.type` 和 `scope.acquisition.average_count` 属于 granular changed/verification fields; +restore order 只使用其 owner `scope.acquisition`。R1 profile 不得排除任何 +`ScopeAverageCaptureField`。不能只比较 `configuration_before == configuration_after` 而遗漏 run state、 +全局 acquisition type 或 transfer token。成功结果还必须满足 configuration before/after 的规范化值相等、 +run state 回到 stopped baseline,并由 fresh readback 证明;只比较 driver 返回对象不构成恢复证据。 + +### 完成证明 + +R1 只接受 `device_average_complete`:设备必须有文档化完成位,且在同一 main phase、configuration +readback 和已验证的 SINGLE completion 后,以新鲜 query 返回真正的 `True`。仅观察到 trigger `STOP`、 +`*OPC?` 完成、count 变化或波形可读不足以采用该证据。 + +`completion_contract_id` 标识 descriptor 声明的完成位合同,不代替厂商文档、fixture 或实机验收;没有完成位、 +不能读到 true 或无法关联到 profile contract 时不得声明 R1 capability。`documented_single_completion` +必须通过后续 R2 接受门,冻结厂商文档适用范围、fixture/实机证据和 core verifier 后才能加入。 +RFC-0009 的 `single_mode_readback_then_stopped` 是 control-only proof,不能作为 R1 average capture +的 child acquisition completion;即使同时读到设备完成位,average executor 也必须在 binary fetch 前拒绝。 + +`ScopeAverageCompletionProofV2` 还必须满足: + +- configuration readback 与 request 的 mechanism/count 精确一致; +- acquisition completion 绑定本 operation 派生的子 baseline,并证明一次新采集; +- acquisition completion 不得使用 RFC-0009 的 `single_mode_readback_then_stopped`; +- `device_average_complete` 分支要求 fresh `device_average_complete is True`; +- contract ID 与 profile 的 completion contract ID 精确一致; +- context、session epoch 和 child-baseline nonce digest 与 parent baseline 精确一致; +- completion、configuration readback、parent baseline 和 session epoch 由核心 verifier + 交叉核对; +- 普通 R1.3 `ScopeAcquisitionCompletion` 单独存在不足以构造成功 proof。 + +### 输入和前置条件 + +- request 必须显式确认 acquisition 已停止; +- core fresh-readback 仍须验证实际 run state; +- 唯一 channel 符合 descriptor range; +- average count 落在核心和 profile 的交集;binary response/total/query/resync 另按三方限制交集计算; +- R1 的新路径依赖 RFC-0002 input-state,不以 switchable/固定 termination 区分或回退 legacy coupling; +- `high_z` 通过,已证明的 `50_ohm` 只在显式授权下通过,unknown 拒绝; +- 波形读取必须使用本 RFC 的 average binary profile 和同一 opaque ledger;不得回退 + `query_bin_block()` 或标准 legacy fetch。 + +### 失败与恢复 + +1. 参数、capability、profile、输入安全和完成证据配置在主 I/O 前校验。 +2. 每次 configuration write 后立即 readback;设备自动取整、type/count 错配或 stopped state 漂移时失败。 +3. 同步仍可证明的数据或换算失败执行 restore + fresh verify。 +4. 完成证据不足时,记录主失败并恢复所有已写字段。 +5. session `poisoned` 后禁止 STOP、restore、verify、截图和探测 I/O。 +6. transport 主异常保持 primary cause;cleanup diagnostics 不得覆盖。 +7. restore 或 verify 失败时不返回波形成功值。 +8. 不自动重连、不重放 acquisition/binary query,也不追加第二 channel。 + +## 错误检查策略 + +average capture 是 `acquire` operation。R1 固定从 `ScopeConfig.check_errors` 得到以下唯一映射: + +~~~text +true -> ErrorCheckSpec(policy="required", timing="before_and_after", max_records=16, + on_instrument_error="fail") +false -> ErrorCheckSpec(policy="disabled") +~~~ + +required 路径在 first write 前要求 `scope.error_drain_v1` 与 callable `drain_errors()`,并完成 +error-before。error-after 只在 final main I/O 成功后、restore 前执行;main failure 或 session unhealthy +导致无法执行时,既有 error artifact 必须分别记录 `main_operation_failed` 或 `session_unhealthy`。 +recovery/verification 固定不读取错误队列;legacy `scope.errors` 不得代替 typed drain。 + +## 兼容性 + +1. 旧 `ScopeAcquisitionStatus`、`ScopeAverageCaptureRequest`、 + `ScopeAverageConfiguration` 和 `ScopeAverageCaptureResult` 不改。 +2. 旧 `scope.acquisition_status` 和 `scope.capture_average` 不改 capability 映射。 +3. R1.3 acquisition control 不改名、不扩成 average completion。 +4. 新 capability 使用独立 V2 模型和 profile。 +5. 旧 CLI 命令继续走 legacy Service;0006a 和 0006b R1 均不新增 V2 CLI、run plan step、capture package + 格式或 artifact schema。 +6. 内建 driver 没有 opt-in 时继续在 capability gate 拒绝,不做探测。 +7. status V2 发布不自动授权 average capture V2。 +8. average core 合同发布不自动授权某个型号的 completion evidence。 +9. `scope.capture_average_v2` 不调用 legacy `capture_average()`,也不回退 legacy。 + +## 验收矩阵 + +### Status V2 + +- average/segmented 分区分别可用、unavailable 或当前 mode 下 not applicable; +- STOP、OPC、configured count 和 elapsed time 都不能伪造 complete; +- 数值、safe token、unavailable paths 和 query failure; +- capability/Protocol/factory 零 I/O; +- legacy acquisition status 与 R1.3 control 回归。 + +### Average capture V2 + +- R1 profile range、`global_acquisition`、power-of-two、canonical points 和 `device_average_complete`; +- 单 channel/stopped/input-state/count 的发送前拒绝,以及 `50_ohm` 的显式 request 授权; +- type write/readback、count write/readback、stopped recheck、child-baseline-bound SINGLE 与 fresh complete bit; +- bounded response/total/query/resync/deadline、factory generic backend gate 和 no-replay; +- 独立 average baseline、restore owner、nonce digest、step 上限、fresh verify 与成功后同样 restore; +- 数据错误后恢复、同步失步后零追加 I/O、primary exception 和 cleanup poison; +- before/after typed error drain 及固定 omission artifact; +- legacy average、standard waveform、CLI、run plan、artifact reader 和 builtin descriptor 回归; +- R2 前置:多通道、callbacks、partial result、channel arithmetic、combined mechanism 和 + `documented_single_completion` 均不得作为 R1 成功路径。 + +## 接受与实施顺序 + +RFC-0006a/0006b-0/0006b 已完成核心离线实现但尚未发布。RFC-0006b R1 已实现单通道 +`global_acquisition + device_average_complete` 的独立 model/profile/baseline/Protocol/OperationSpec/Service/ +factory gate 和离线验收;它不授权 CLI、run plan、artifact schema、内建 descriptor、外部插件 opt-in 或任何 +R2 变体。后续实施顺序为: + +1. 只有 R1 单通道回归、发行兼容矩阵和独立设备证据完成后,才评审同次多通道; +2. `channel_arithmetic`、`combined`、callbacks、partial result 和 `documented_single_completion` 必须各自 + 通过独立 Accepted 附录; +3. 插件只在首个包含完整合同的正式核心发行版、离线 conformance 和对应实机证据均具备后单独 opt-in。 + +具体设备在 average mode 下缺少平均完成证据时,可以采用 status V2 并将 +`average.complete=None` 记录为 unavailable,但不能声明 average capture V2。 diff --git "a/docs/project/rfcs/WaveBench_scope\345\217\257\347\247\273\346\244\215\346\200\247RFC-0007_\347\273\237\350\256\241FFT\344\270\216\345\205\211\346\240\207\350\257\273\345\217\226.md" "b/docs/project/rfcs/WaveBench_scope\345\217\257\347\247\273\346\244\215\346\200\247RFC-0007_\347\273\237\350\256\241FFT\344\270\216\345\205\211\346\240\207\350\257\273\345\217\226.md" new file mode 100644 index 0000000..6234b2c --- /dev/null +++ "b/docs/project/rfcs/WaveBench_scope\345\217\257\347\247\273\346\244\215\346\200\247RFC-0007_\347\273\237\350\256\241FFT\344\270\216\345\205\211\346\240\207\350\257\273\345\217\226.md" @@ -0,0 +1,526 @@ +# RFC-0007:可移植的统计、FFT 与光标读取 V2 + +> 状态:`Implemented R1(未发布;0007a/0007b/0007c)` +> 核心基线:现有 statistics、FFT status、math metadata 与 cursor readout +> 范围:三个独立的只读 V2 capability +> 系列总览:[scope 可移植性 RFC 组合说明](WaveBench_scope可移植性RFC组合说明.md) +> 本轮范围:0007a/0007b/0007c 已完成核心模型、profile、factory gate 与 Service; +> 仍不创建 V2 CLI、artifact、run plan step 或插件 opt-in + +## 摘要 + +现有统计、FFT 和光标模型分别固定了 slot、RTM 风格 FFT 字段和单 source/固定单位。三项问题 +只共享「不伪造未知值」原则,I/O、寻址和结果模型彼此独立。本 RFC 将它们拆成三个 capability, +允许分别实现、发布和验收。 + +现有 `scope.math_metadata` 保持有效;它提供 waveform preamble 和轴信息,不等于 FFT 状态。 +现有插件已经公开的窄 cursor 子集只要能无损映射,继续保留。 + +## RFC-0007a:测量统计 selector + +### 问题 + +旧 API 以整数 `slot` 寻址。部分设备只能用 measurement item 和 source 组合查询,且不能把 +前面板历史位置反查为 item/source。把位置编号当作可查询 slot 会选择错误的测量项。 + +### R1 模型 + +~~~python +@dataclass(frozen=True, slots=True) +class ScopeMeasurementSelector: + slot: int | None = None + item: str | None = None + sources: tuple[str, ...] = () + + +@dataclass(frozen=True, slots=True) +class ScopeMeasurementStatisticsRequestV2: + selector: ScopeMeasurementSelector + configured: bool + include_buffer: bool = False + acquisition_stopped: bool = False + + +ScopeMeasurementSelectorMode = Literal["slot", "item_sources"] + + +@dataclass(frozen=True, slots=True) +class ScopeMeasurementStatisticsProfileV2: + selector_modes: tuple[ScopeMeasurementSelectorMode, ...] + max_queries: int + supports_buffer: Literal[False] = False + slot_range: tuple[int, int] | None = None + supported_items: tuple[str, ...] = () + item_source_count_range: tuple[int, int] | None = None + allowed_effect: Literal["pure_read"] = "pure_read" + + +@dataclass(frozen=True, slots=True) +class ScopeMeasurementStatisticsV2: + selector: ScopeMeasurementSelector + category: str + actual: float + average: float + standard_deviation: float + minimum: float + maximum: float + waveform_count: int + buffered_values: tuple[float, ...] | None = None +~~~ + +R1 选择「完整统计成功值」:`actual`、`average`、`standard_deviation`、`minimum`、`maximum` 与 +`waveform_count` 必须在成功结果中全部存在并通过有限数/范围校验。设备对任一聚合项返回空值、 +未支持或无法解析时,operation 失败;不为 statistics V2 新增 availability path,也不把空值、 +零值或旧缓存伪装成结果。这个保守边界允许旧 statistics API 继续保留其既有可空语义。 + +selector 必须且只能采用一种模式,且输入本身就是唯一规范形:核心不排序、不去重、不改大小写;结果的 +`selector` 必须与 request selector 严格相等。 + +- slot 模式:`slot` 为正的非 bool 整数,`item is None`,`sources == ()`; +- item 模式:`slot is None`,item 为非空 safe token,sources 非空且唯一。 + +结果必须回显规范化 selector,不能只返回 category 而丢失 source。 + +### Buffer + +R1 不读取 statistics 历史 buffer。`ScopeMeasurementStatisticsProfileV2.supports_buffer` 因而只能为 +真正的 `False`,`include_buffer=True` 必须在打开 session/发送任何仪器 I/O 前失败,成功结果的 +`buffered_values` 必须为 `None`。不得: + +- 返回空 tuple 伪装支持; +- 重复查询 CURRENT 构造历史; +- 从 WaveBench 进程中的旧结果拼接设备 buffer; +- 以 binary、消费性 query 或未受独立 bounded/恢复合同保护的读取声称支持 buffer。 + +未来若设备有真实 buffer query,必须通过单独的 Accepted 附录冻结 response/operation/query/ +resynchronization 限制、是否消费记录、session 后果与 restore/verify 需求;不能只把 +`supports_buffer` 改为 `True`。 + +`ScopeMeasurementStatisticsProfileV2` 以 append-only 的 +`ScopeDescriptorExtensions.measurement_statistics_profile_v2` 追加。selector modes 非空、唯一并按 +`("slot", "item_sources")` 的声明顺序排列;`supports_buffer` 必须是真正的 `False`。`max_queries` 是 +`1..32` 的非 bool 整数,`allowed_effect` 固定为 `"pure_read"`。包含 `slot` 时 slot range 必须为两个满足 +`1 <= min <= max` 的非 bool 整数,否则必须为 `None`。包含 `item_sources` 时 +`supported_items` 非空、唯一且均为 safe token,source count range 必须为两个满足 +`1 <= min <= max` 的非 bool 整数;不包含该模式时 items 为空且 range 为 `None`。 + +请求的 selector mode、source 数量和 buffer 需求由核心在统计 I/O 前与 profile 比较。profile +还必须校验 slot 范围或 item allowlist。上述检查在打开 scope session 前执行;任何不匹配均发送前失败, +不能把这些判断推迟到已执行 CURRENT query 之后。 + +`max_queries` 计数从进入 `get_measurement_statistics_v2()` 到返回或抛出期间的全部受 guard 计数的 +文本 `query()`,包括 configured 判定、selector 状态和六项统计值。该 phase 只允许 `query()`;禁止 +write、binary query、`query_float_list()`、legacy `idn()`、`*STB?`、`*ESR?`、error drain、 +acquisition control 和任何额外 Service preflight。超额是 driver 合同违反,operation 失败,不重放或续读。 + +`configured is True` 是调用方的明确意图确认;`False` 或 truthy 非 bool 在本地、零 I/O 拒绝。driver 仍须 +在同一预算内取得新鲜证据,证明请求 selector 当前可被精确读取;未配置、selector 不匹配、状态模糊或读取 +期间漂移均为 operation failure,不转成 availability、零值或缓存结果。`acquisition_stopped` 仍须是真正的 +bool,但 R1 buffer 被拒绝,不据此调用 stop/single/control 或改变 acquisition。 + +R1 capability: + +~~~text +scope.measurement_statistics_v2 -> get_measurement_statistics_v2 +~~~ + +## RFC-0007b:FFT status V2 + +### 问题 + +旧 `ScopeFftStatus` 强制要求 average complete、RBW 和 sample rate,却不能承载 source、 +window、vertical unit 与 frequency range。设备没有相应 query 时,不能用全局 acquisition +sample rate、频率跨度或点数推导这些字段。 + +### R1 模型 + +~~~python +ScopeFftStatusFieldV2 = Literal[ + "source", + "window", + "vertical_unit", + "frequency_start_hz", + "frequency_stop_hz", + "average_complete", + "resolution_bandwidth_hz", + "sample_rate_hz", +] + + +@dataclass(frozen=True, slots=True) +class ScopeFftStatusV2: + math_index: int + source: str | None = None + window: str | None = None + vertical_unit: str | None = None + frequency_start_hz: float | None = None + frequency_stop_hz: float | None = None + average_complete: bool | None = None + resolution_bandwidth_hz: float | None = None + sample_rate_hz: float | None = None + unavailable_fields: tuple[ScopeFftStatusFieldV2, ...] = () +~~~ + +~~~python +SCOPE_FFT_STATUS_V2_MAX_QUERIES = 32 + + +@dataclass(frozen=True, slots=True) +class ScopeFftStatusProfileV2: + readable_fields: tuple[ScopeFftStatusFieldV2, ...] + max_queries: int + allowed_effect: Literal["pure_read"] = "pure_read" +~~~ + +这不是 request variant profile:它只声明同一 descriptor 对已配置 FFT 状态的静态可读字段、文本查询预算和 +纯读取效果。它以 append-only 的 `ScopeDescriptorExtensions.fft_status_profile_v2` 追加,并与 +`scope.fft_status_v2` 和 `ScopeFftStatusDriverV2.get_fft_status_v2()` 一一对应。 + +`readable_fields` 非空、唯一,按 `ScopeFftStatusFieldV2` 声明顺序排列;`max_queries` 为 `1..32` 的 +非 bool 整数,`allowed_effect` 固定为 `"pure_read"`。未列入 profile 的字段只能为 `None` 并进入 +`unavailable_fields`;已列入的字段必须有值且不得进入 `unavailable_fields`。R1 没有 FFT +`not_applicable_fields`:一个字段不是 profile 的静态可读字段,就不能因为某次 query 失败被临时降级。 + +不变量: + +- math index 是正的非 bool 整数; +- 频率、RBW 和 sample rate 非空时必须有限,RBW/sample rate 为正; +- start/stop 同时存在时满足 `start < stop`; +- source/window/unit 使用规范化 safe token; +- 无 query 的字段为 `None` 并进入 unavailable paths; +- 一次 query 或解析失败使 operation 失败; +- 不隐式读取 FFT waveform; +- 不从 `scope.math_metadata` 推导配置状态。 + +`unavailable_fields` 只接受 `ScopeFftStatusFieldV2`,按定义顺序输出且不重复。每个空字段 +必须存在对应路径,每个非空字段不得进入路径集合。frequency start/stop 若只能成对查询,则 +必须同时有值或同时 unavailable;一次 query/parse failure 不能转成 unavailable。 + +R1 capability: + +~~~text +scope.fft_status_v2 -> get_fft_status_v2 +~~~ + +## RFC-0007c:带单位的光标读数 V2 + +### 问题 + +旧 `ScopeCursorReadout` 只有一个 source,并把水平差固定为秒和倒数赫兹。双 source、 +tracking、角度、百分比和 source-defined vertical unit 无法无损映射。 + +### R1 模型 + +~~~python +ScopeCursorUnit = Literal["s", "Hz", "degree", "percent", "source"] +ScopeCursorReadoutFieldV2 = Literal[ + "cursor_index", + "source_a", + "source_b", + "x_a", + "x_b", + "x_delta", + "inverse_x_delta", + "y_a", + "y_b", + "y_delta", +] + + +@dataclass(frozen=True, slots=True) +class ScopeCursorQuantity: + value: float + unit: ScopeCursorUnit + source_unit: str | None = None + + +@dataclass(frozen=True, slots=True) +class ScopeCursorReadoutV2: + cursor_index: int | None + mode: str + function: str + source_a: str | None + source_b: str | None + x_a: ScopeCursorQuantity | None = None + x_b: ScopeCursorQuantity | None = None + x_delta: ScopeCursorQuantity | None = None + inverse_x_delta: ScopeCursorQuantity | None = None + y_a: ScopeCursorQuantity | None = None + y_b: ScopeCursorQuantity | None = None + y_delta: ScopeCursorQuantity | None = None + unavailable_fields: tuple[ScopeCursorReadoutFieldV2, ...] = () + not_applicable_fields: tuple[ScopeCursorReadoutFieldV2, ...] = () +~~~ + +~~~python +ScopeCursorAddressing = Literal["global", "indexed"] + + +@dataclass(frozen=True, slots=True) +class ScopeCursorReadoutProfileV2: + readable_fields: tuple[ScopeCursorReadoutFieldV2, ...] + conditionally_applicable_fields: tuple[ScopeCursorReadoutFieldV2, ...] + addressing: ScopeCursorAddressing + max_queries: int + allowed_effect: Literal["pure_read"] = "pure_read" +~~~ + +`ScopeCursorQuantity` 不变量: + +- value 为有限数值; +- 只有 `unit="source"` 时允许非空 `source_unit`; +- 非 source unit 时 `source_unit is None`; +- source unit 是规范化、长度受限的可见单位,不包含原始命令或资源信息。 + +readout 不变量: + +- A/B source 分别保存,不拼接成一个字符串; +- 不同 source 不得只保留其中一个; +- 缺少某个 quantity 时为 `None`; +- Hz、degree 和 percent 不能放入旧 `x_delta_s`; +- mode/function/source query 失败时 operation 失败; +- mode/function 必须是非空、规范化且长度受限的 safe token;它们不是 availability 字段; +- cursor index 为 `None` 只表示设备采用全局 cursor,不伪造公共 index;它不能表示未知 index、 + query 失败或解析失败; +- `source_a` 和 `source_b` 必须同时有值或同时为 `None`,不得只保留其中一个;两者为 `None` 时也必须 + 使用同一 availability 分类。 + +`unavailable_fields` 表示设备没有可证明的 query;`not_applicable_fields` 表示字段在当前 +已查询 mode/function 下没有语义。两组路径来自包含 cursor index、A/B source 和各 quantity +名称的封闭集合,分别排序、去重、互斥。每个值为 `None` 的字段必须恰好由其中一组解释, +非空字段不得出现在任一组。query/parse failure 不能进入这两组。 + +`ScopeCursorReadoutProfileV2` 是 descriptor append-only 的 +`ScopeDescriptorExtensions.cursor_readout_profile_v2`。它不是请求 variant profile:它只约束该设备的 +寻址方式、静态可读字段、当前 mode/function 可条件适用字段和纯文本 query 预算。 + +- `readable_fields` 非空、唯一,并按 `ScopeCursorReadoutFieldV2` 声明顺序排列;至少包含一个 quantity + 字段。`source_a` 和 `source_b` 必须同时出现或同时不出现; +- `conditionally_applicable_fields` 唯一、按同一顺序排列,是 `readable_fields` 的子集;source A/B + 也必须同时出现或同时不出现; +- `addressing="global"` 时,request 必须为 `cursor_index=None`,result 也必须为 `None`,并且 + `cursor_index` 必须进入 `not_applicable_fields`;它不得进入 profile 的 readable/conditional 字段; +- `addressing="indexed"` 时,request 必须是正的非 bool 整数,result 必须精确回显该 index,且 + `cursor_index` 必须是 readable、非 conditional 字段; +- 除上述 global index 特例外,未列入 `readable_fields` 的字段必须为 `None` 且进入 + `unavailable_fields`;列入但不 conditional 的字段必须有值;列入 conditional 的字段必须有值,或仅因本次 + 已新鲜查询的 mode/function 没有语义而进入 `not_applicable_fields`。任何 query/parse failure 都不能用 + unavailable/not-applicable 隐藏; +- `max_queries` 必须是 `1..32` 的非 bool 整数,`allowed_effect` 固定为 `"pure_read"`。 + +`configured_cursor is True` 是调用方的明确意图确认;`False` 或 truthy 非 bool 必须在打开 session/发送任何 +I/O 前拒绝。driver 仍须在同一预算内新鲜证明目标寻址、当前配置、mode/function/source 和读取期间稳定性; +未配置、地址错配、状态模糊或漂移是 operation failure,而不是 availability。计数窗口覆盖一次 +`get_cursor_readout_v2()` 从进入至返回/抛出期间全部受 guard 计数的文本 `query()`,包括寻址、当前 cursor +配置、mode/function、A/B source、适用性和 quantity。该 phase 只允许 `query()`;禁止 write、binary query、 +`query_float_list()`、legacy `idn()`、`*STB?`、`*ESR?`、error drain、math metadata、legacy cursor 和任何 +额外 Service preflight。超额、重放或续读均为 failure。 + +R1 capability: + +~~~text +scope.cursor_readout_v2 -> get_cursor_readout_v2 +~~~ + +## Protocol 与 operation + +三项分别使用独立 Protocol: + +~~~python +class ScopeMeasurementStatisticsDriverV2(Protocol): + def get_measurement_statistics_v2( + self, + request: ScopeMeasurementStatisticsRequestV2, + ) -> ScopeMeasurementStatisticsV2: ... + + +class ScopeFftStatusDriverV2(Protocol): + def get_fft_status_v2( + self, + math_index: int, + *, + configured_fft: bool, + ) -> ScopeFftStatusV2: ... + + +class ScopeCursorReadoutDriverV2(Protocol): + def get_cursor_readout_v2( + self, + cursor_index: int | None, + *, + configured_cursor: bool, + ) -> ScopeCursorReadoutV2: ... +~~~ + +三个 operation 都是独立的 `stateful_read / exclusive`,不形成 `scope.analysis_v2` 这样的捆绑能力。 +0007a、0007b 和 0007c R1 已分别注册 capability、OperationSpec 和 Service;0007c 使用同样的核心实现边界: +60 秒 deadline、`stateful_read / exclusive`、`error_check_minimum="disabled"`、无 required verified fields、 +无 restore coverage,以及一次只允许 `query()` 的 profile budget phase。 + +核心开发线已实现 `scope.measurement_statistics_v2` 的模型、profile、Protocol、factory gate、OperationSpec +和 Service,不新增 `measurement-statistics-v2` CLI、artifact、run plan step 或持久化 JSON schema。已有 +`measurement-statistics`、`fft-status` 和 `cursor-readout` 继续只调用 legacy Service;后续 CLI +只有在单项 capability 另行冻结参数、JSON 成功形状和 artifact 版本后才能追加。 + +`scope.measurement_statistics_v2` 与 +`ScopeDescriptorExtensions.measurement_statistics_profile_v2`、 +`ScopeMeasurementStatisticsDriverV2.get_measurement_statistics_v2()` 一一对应。它使用独立 +portability-V2 `OperationSpec`:60 秒 deadline、`stateful_read / exclusive`、 +`error_check_minimum="disabled"`、无 required verified fields、无 restore coverage。Service 只调用 V2 +driver 方法一次,先执行 profile 的零 I/O request preflight,再进入只允许 `query()` 的 `max_queries` +phase;不得调用 legacy statistics、legacy identity preflight、error drain 或 R1.3 extension Service。 + +FFT 和 cursor 首版没有请求 variant profile:它们读取当前已配置状态,是否支持该状态只能在有限状态 query 后判断。 +0007b 的静态 FFT status profile 不改变这一点;它不选择 request variant,只约束已经证明为 FFT 的字段闭包和 +query budget。statistics、FFT 和 cursor capability 声明都触发 strict construction barrier:factory 返回、 +profile/Protocol/capability 校验完成前, +所有仪器 I/O 都以 `factory_construction_pending` 发送前拒绝;required method/profile 缺失时关闭已开的 +transport。0007c 采用同一 barrier:`scope.cursor_readout_v2` 与 profile、Protocol 和 +`get_cursor_readout_v2()` 一一对应;缺 profile/方法在 factory 后、首次 I/O 前失败并关闭已开的 transport; +方法存在但 capability 未声明不触发 latch。 + +0007b 使用独立 portability-V2 `OperationSpec`:60 秒 deadline、`stateful_read / exclusive`、 +`error_check_minimum="disabled"`、无 required verified fields、无 restore coverage。Service 在 session 外拒绝 +非正/bool `math_index` 和 `configured_fft is not True`;后者只是调用意图,driver 必须在一个只允许 +`query()` 的 `max_queries` phase 内提供新鲜证据,证明该 index 当前是目标 FFT。非 FFT、未配置、索引不匹配、 +状态模糊或读取期间漂移均为 operation failure。 + +该 phase 计数一次 `get_fft_status_v2()` 从进入到返回/抛出期间的全部受 guard 计数的文本 `query()`,包括 +FFT 配置证明和所有可读字段。禁止 write、binary query、`query_float_list()`、FFT waveform、legacy `idn()`、 +`*STB?`、`*ESR?`、error drain、math metadata、legacy FFT 或任何额外 Service preflight;超额、重放或续读 +均为 failure。Service 只调用 V2 driver 方法一次,不得 fallback 到 legacy `get_fft_status()`。 + +0007b R1 不新增 `fft-status-v2` CLI、artifact、run-plan step 或持久化 JSON schema。既有 +`wavebench scope fft-status`、legacy model、Protocol、capability 和 JSON 继续只走 legacy Service。 + +核心开发线已实现 `ScopeFftStatusV2`、`ScopeFftStatusProfileV2`、独立 Protocol、 +`scope.fft_status_v2`、strict factory construction barrier、portability-V2 `OperationSpec` 和 +`ScopeService.fft_status_v2()`。核心离线实现覆盖 model/profile、capability、factory、query budget、 +non-query I/O 拒绝、math metadata 隔离和 legacy route;主包内建 descriptor 和外部插件在独立 conformance、 +版本下限和硬件证据完成前不得声明 FFT V2;它不与 cursor V2 共享 capability、路由或状态 query。 + +0007c R1 核心离线实现已提供 `ScopeCursorQuantity`、`ScopeCursorReadoutV2`、带 global/indexed +addressing 的 append-only profile、独立 Protocol、`scope.cursor_readout_v2`、strict factory construction +barrier、portability-V2 `OperationSpec` 和 `ScopeService.cursor_readout_v2()`。Service 在打开 session 前拒绝 +非法 index、非真 `configured_cursor` 和 profile/addressing 不匹配;在 factory-owned session 中只调用 V2 driver +一次,并在只允许 `query()` 的 `1..32` budget phase 中验证 result。它不调用 legacy cursor、identity preflight、 +error drain、math metadata、binary 或 write;缺 shared session state 的 transport 同样发送前 fail-closed。 +全局 cursor 以 `cursor_index=None` 加 `not_applicable_fields=("cursor_index",)` 表示,indexed cursor 必须精确 +echo request。实现还限制 `source_unit` 为可见单位 token,拒绝 SCPI/resource 形态。 + +`tests/test_scope_cursor_readout_v2.py` 覆盖五种 unit、global/indexed 与双 source、static unavailable/ +conditional not-applicable、profile/capability/factory、query budget、non-query I/O、missing shared state、legacy +route 和两个离线 fixture。核心完整离线回归与外部 MSO8000 插件在新 core source 下的既有回归均通过。内建 +descriptor 和外部插件仍未声明 cursor V2;本实现不新增 V2 CLI、artifact、run plan step 或硬件工作。 + +0007a R1 核心离线实现已覆盖 selector/profile 的发送前拒绝、完整结果和 selector echo、buffer result 拒绝、 +factory zero-I/O、受预算文本 query、non-query I/O 拒绝和 legacy route。它本身不授权 0007b;后者只由本节 +单独接受的 FFT 合同授权。主包内建 descriptor 和外部插件在独立 conformance、版本下限和硬件证据完成前不得 +声明 statistics、FFT 或 cursor V2;0007a/0007b/0007c 都不授权其他项的插件 opt-in。 + +## 模型校验 + +0007a/0007b/0007c R1 dataclass 必须以 `__post_init__` 实现本 RFC 的不变量: + +- selector 精确 XOR,slot/index/count 均拒绝 bool; +- item/source/mode/function/unit 使用长度受限的 safe token; +- sources 和 channel-like 集合非空时唯一; +- 所有统计值、buffer 值、FFT 数值和 cursor quantity 都是有限数; +- waveform count 为非负的非 bool 整数; +- statistics result selector 必须等于请求 selector;R1 的 `buffered_values is None`; +- statistics V2 不允许部分聚合成功:五个数值统计项和 `waveform_count` 缺失、空值或非有限值均为 + operation failure,不进入 unavailable/not-applicable; +- FFT start/stop、RBW/sample rate 和 unavailable paths 相互一致; +- cursor unit/source-unit 配对; +- cursor unavailable/not-applicable 路径封闭、互斥并与 `None` 精确对应。 + +模型构造失败是参数或 driver contract failure,不得在 Service 中自动修正常量、排序 sources +或删除重复字段。 + +## 前置条件 + +- `configured`、`configured_fft` 和 `configured_cursor` 必须分别为真正的 `True`;调用方确认不能替代 + driver 的新鲜状态解析; +- 统计 selector/buffer 的静态不支持由 profile 在打开 session/任何 I/O 前拒绝; +- FFT 或 cursor 的当前配置只有在有限状态 query 后才能识别;0007b/0007c 的 query failure 不得写成 + unavailable,且不支持时在任何 write/binary I/O 前 fail-closed,并保留已经发生的 stateful-read 审计; +- operation 不修改前面板配置来制造一个可读状态; +- 若某个查询本身具有消费或状态副作用,必须在 `OperationSpec` 明确声明,不能作为普通 read + 隐藏。 + +## 现有窄 cursor 子集 + +旧 capability 可以继续只支持能够无损映射到 `ScopeCursorReadout` 的子集,例如: + +- 固定公共 index; +- manual mode; +- A/B 同源; +- TIME + SEC; +- AMPL + source unit。 + +其他模式在 legacy 结果构造前拒绝是正确的 fail-closed 行为。V2 发布后,插件可以独立声明 +V2 以扩展双源和多单位;不得要求旧 capability 自动升级。 + +## 与其他分析能力的边界 + +- `scope.math_metadata` 继续提供 preamble/轴信息,不承载 FFT 配置; +- `scope.reference_metadata` 需要真实 reference 轴、点数和 source 证据; +- `scope.history_timestamps` 需要逐帧时间语义; +- spectrum、FFT phase、reference/history 不因本 RFC 自动开放; +- 本 RFC 不配置 measurement、FFT 或 cursor,只读取已经配置的状态。 + +## 兼容性 + +1. 保留旧 statistics、FFT 和 cursor 模型、Protocol、capability 与 CLI。 +2. 三项 V2 分别追加,任何一项不依赖另外两项。 +3. 旧统计 slot 不适配成 item/source 猜测。 +4. 旧 FFT 强制字段不通过默认值填充。 +5. 旧 cursor 的成功子集继续返回旧模型。 +6. 新 capability 未声明时,旧 driver/fake/内建 descriptor 不需要新方法。 +7. 方法存在不产生 capability;缺方法的声明在 factory 后、第一次 I/O 前拒绝。 +8. JSON 保留 selector、source、unit 和 null,不回写到含义错误的旧字段。 +9. 0007a/0007b/0007c R1 不新增 V2 CLI、artifact 或 run plan step,也不改变旧命令路由。`cursor_readout_v2()` + 仅返回 dataclass;若未来需要 CLI/artifact,必须先通过独立 Accepted 附录冻结 global/index 参数、成功 JSON + 的字段/null/path 顺序和版本化 artifact schema。 + +## 验收矩阵 + +### Statistics(0007a R1) + +- selector XOR、slot 范围、item/source safe token 和 source unique; +- selector mode、source count 和 buffer profile; +- result selector 回显; +- R1 buffer 拒绝、零 I/O 行为,以及未来 buffer 另行接受门; +- 非 finite 数值、waveform count 与解析失败; +- 旧 slot API 回归。 + +### FFT(0007b R1) + +- optional fields、有限值、start/stop 和 unavailable paths; +- 不从全局 sample rate/span/points 推导; +- profile/readable-fields、configured precondition 与纯文本 query budget; +- math metadata 不触发 FFT status 的隐式成功; +- 旧 FFT API 回归。 + +### Cursor + +- global/indexed profile、正的非 bool index 和 `configured_cursor=True` 的零 I/O preflight; +- A/B 同源与双源、source 成对约束,以及秒、赫兹、角度、百分比和 source/source-unit 配对; +- static unavailable、conditional not-applicable、global index 特例与每个 `None` 的精确 path; +- mode/function/source/quantity 错配、parse failure、超 budget 和 non-query I/O 不得伪装成 availability; +- capability/profile/method 一一对应、strict factory latch、legacy route 完全隔离; +- legacy 窄子集继续成功,无法无损映射的配置继续拒绝;至少两种仪器族或 fixture 覆盖不同 unit/optional 组合。 + +### 共同 + +- capability/Protocol/construction barrier 零 I/O; +- query/解析失败不转成 unavailable; +- Service 追加式兼容,且 0007a/0007b R1 不改变 CLI; +- 至少两个不同仪器族或 fixture 证明不同 selector/optional/unit 组合。 + +## 接受与实施顺序 + +RFC-0007a/0007b/0007c 已完成核心离线实现但尚未发布。0007c 按本文件冻结的 model/profile/addressing、 +availability、pure-text budget、strict factory barrier、Service 边界和无 CLI/artifact 完成上述验收矩阵;它仍不 +授权 descriptor opt-in、插件 conformance 分支、版本下限升级或硬件验收。任何一项通过不改变另两项状态。具体 +插件只有在对应核心合同正式发布、离线 conformance 完成并获得设备证据后,才可以声明相应 capability。 diff --git "a/docs/project/rfcs/WaveBench_scope\345\217\257\347\247\273\346\244\215\346\200\247RFC-0008_\346\234\211\347\225\214\346\263\242\345\275\242\344\274\240\350\276\223\350\243\201\345\206\263.md" "b/docs/project/rfcs/WaveBench_scope\345\217\257\347\247\273\346\244\215\346\200\247RFC-0008_\346\234\211\347\225\214\346\263\242\345\275\242\344\274\240\350\276\223\350\243\201\345\206\263.md" new file mode 100644 index 0000000..bf95563 --- /dev/null +++ "b/docs/project/rfcs/WaveBench_scope\345\217\257\347\247\273\346\244\215\346\200\247RFC-0008_\346\234\211\347\225\214\346\263\242\345\275\242\344\274\240\350\276\223\350\243\201\345\206\263.md" @@ -0,0 +1,169 @@ +# RFC-0008:标准波形有界二进制传输裁决 + +> 状态:`Implemented R1(未发布)` +> 规范正文:[标准波形有界二进制传输 RFC](WaveBench_标准波形有界二进制传输RFC.md) +> 外部验收:[MSO8104 受控实机验收记录](https://github.com/Scaxlibur/wavebench-instrument-plugins/blob/5a760c954f75dc69909bfde04cb5cd7837364ab3/packages/wavebench-rigol-mso8000/doc/MSO8104_HARDWARE_ACCEPTANCE.md) +> 核心基线:WaveBench `0.8.24` 开发线 +> 系列总览:[scope 可移植性 RFC 组合说明](WaveBench_scope可移植性RFC组合说明.md) + +## 摘要 + +原插件提案要求 waveform definite block 能声明 payload 后的 transport trailing,并限制单次 +响应大小。核心接受问题,但不采用向 legacy `query_bin_block()` 增加 +`expect_termination` 和 `max_bytes` 的方案。 + +核心 P0~P3 已使用 descriptor-owned `ScopeWaveformBinaryProfile`、 +`query_binary()`、四维 budget、独立 waveform baseline 和核心恢复编排实现。本文只记录 +编号映射与裁决摘要;字段、常量和失败语义以规范正文为准。 + +## 证据分层 + +早期 legacy 超时只能证明: + +- 当时的读取方式不能证明响应边界; +- 增加 timeout 或重试不能修复同步证明; +- 不能由一次超时判断 payload 后究竟是空 trailing、`LF`、其他字节或读取设置不匹配。 + +后续受控 bounded 读取才为一个明确的型号、固件和 LAN/PyVISA 组合提供 +`DEFINITE_BLOCK + LF` 证据。该结果必须写入 descriptor 的精确 +`transport_trailing_hex`,不能外推到其他型号、固件、resource/backend、MAX、DMAX 或 capture。 + +核心离线 fixture 同时覆盖空、`LF`、`CRLF` 和错误 trailing,但 fixture 覆盖不表示具体 +仪器支持这些变体。 + +## 核心裁决 + +### 不修改 legacy 入口 + +~~~python +def query_bin_block( + self, + command: str, + *, + replay: ReplayPolicy = ReplayPolicy.NO_REPLAY, +) -> bytes: ... +~~~ + +该方法保持原签名。active bounded phase 中调用它时,在发送前以 +`binary_legacy_entry_unsupported` 拒绝。bounded 失败后不得回退 legacy。 + +### Descriptor opt-in + +`ScopeDescriptorExtensions.waveform_binary_profile` 为 `None` 时继续使用旧 driver 方法。 +profile 非空时: + +- 只允许声明 descriptor 已有的标准 waveform capability; +- fetch、单通道 capture 和多通道 capture 分别声明,不能互推; +- 首版 framing 固定为 `DEFINITE_BLOCK`; +- trailing 使用最长 16 bytes 的精确十六进制; +- response、operation total、query count 和 resynchronization 均为有限预算; +- profile、OperationSpec 和 connection limit 逐项取最小值; +- descriptor 不能扩大核心硬上限; +- capability、profile、bounded Protocol 和恢复方法必须一一对应。 + +核心硬上限为: + +~~~text +8 MiB / 64 MiB / 256 queries / 64 KiB resynchronization +~~~ + +这些上限只约束 opt-in 路径,不改变 legacy DS1000Z/DS1104/RTM2032。 + +### 核心编排 + +标准 Service 的 bounded 路径使用: + +~~~text +identity + snapshot + -> error-before + -> main + -> error-after + -> restore + -> fresh verify +~~~ + +多通道 capture 使用一次 acquisition、一个 baseline、一个 deadline 和一个 ledger。已完成 +通道的 callback/partial artifact 时序保持原样,每个请求通道最多产生一次 waveform callback, +且 callback 内容必须与最终结果一致。 + +### Backend 和 factory + +首版只接受核心已验证的 PyVISA/RsInstrument VISA `INSTR` bounded backend。Serial、 +SocketIO、第三方 duck transport 或只实现公开 `query_binary()` 的对象,都不能仅凭方法存在 +获得能力。 + +profile 非空时 construction barrier 在 factory 完成后验证: + +- bounded Protocol; +- profile/capability 对应; +- backend/resource; +- 核心版本。 + +验证前所有仪器 I/O 以 `factory_construction_pending` 拒绝;失败时关闭 transport。 + +## 错误检查 + +`ScopeConfig.check_errors=true` 固定映射为 +`ErrorCheckSpec(policy="required")`,要求 `scope.error_drain_v1`。`false` 固定为 disabled。 +路径选择发生在 legacy `scope.errors` gate 前,旧 `scope.errors` 不能冒充类型化 drain。 + +## 失败语义 + +- binary query 最多发送一次,不重放、不续读、不从中间 chunk 继续; +- 响应边界已经证明后的 payload/preamble/scaling 失败仍执行 restore + fresh verify; +- 同步无法证明或已失步时 session 进入 `poisoned`; +- poisoned 后禁止 STOP、restore、verify、截图、IDN 和探测 query; +- transport 主异常保持 primary cause; +- restore/verify 失败时不返回 waveform 成功值; +- 不自动重连。 + +## 兼容性 + +1. capability 名称仍为 `scope.fetch_waveform`、`scope.capture_waveform` 和 + `scope.capture_waveforms`,不新增 `_bounded` 名称。 +2. 旧 `ScopeDriver` 方法签名不变。 +3. bounded Protocol 只对 profile opt-in driver 生效。 +4. `WaveformData`、`CaptureResult`、`MultiCaptureResult`、CLI、run plan 和旧 artifact + 成功形状不变。 +5. 旧 descriptor 不需要 `query_binary()` 或新 baseline 方法。 +6. 新 descriptor 在旧核心中由 wheel 依赖或 descriptor load 阶段拒绝,并保持零仪器 I/O。 +7. 外部插件只有在首个正式包含完整合同的核心版本发布后,才能同步提高 wheel/descriptor + 下限。 + +开发线当前使用 `0.8.24` 作为 profile 静态下限。插件发布时仍必须核对实际发行物;如果 +`0.8.24` 不能唯一表示包含该合同的正式版本,应改用首个可区分的更高版本。 + +## 核心完成与插件边界 + +核心 P0~P3 已完成: + +- profile、模型与 capability-aware validator; +- construction barrier 和可信 backend gate; +- bounded executor、typed error policy 和 core-owned recovery; +- 空/`LF`/`CRLF`、预算、分块、多通道、no-replay、poison 和 compatibility 回归。 + +上述完成范围是核心 mock/fake 与离线回归,主包内建 scope descriptor 当前没有声明 +`waveform_binary_profile`。因此它既不表示 RTM2032/DS1104 已启用 bounded 路径,也不构成任何 +插件 wheel 可以满足的已发布版本门。 + +仍属于 P4: + +- 插件 descriptor/driver opt-in; +- 每个型号、固件和 resource/backend 的 trailing; +- X/Y 换算和已知信号测量; +- transfer state 的 fresh readback; +- CH2、MAX、DMAX、分块、多通道和 capture; +- 外部 source 前后独立 OFF 证据。 + +本核心分支的离线实现不能替代这些插件与实机证据。 + +## 已否决方案 + +- 给 `query_bin_block()` 增加 `expect_termination`; +- 由 driver 向 `query_binary()` 临时传 trailing; +- 全局关闭 PyVISA termination 等待; +- 插件直接访问 backend session; +- 增大 timeout 或失败后重试; +- bounded 失败后回退 legacy; +- 以短记录 fetch 成功推导 MAX/DMAX/capture; +- 在 poisoned session 上继续恢复。 diff --git "a/docs/project/rfcs/WaveBench_scope\345\217\257\347\247\273\346\244\215\346\200\247RFC-0009_SINGLE\346\250\241\345\274\217\347\273\210\346\200\201STOP\350\257\201\346\230\216.md" "b/docs/project/rfcs/WaveBench_scope\345\217\257\347\247\273\346\244\215\346\200\247RFC-0009_SINGLE\346\250\241\345\274\217\347\273\210\346\200\201STOP\350\257\201\346\230\216.md" new file mode 100644 index 0000000..805c1ef --- /dev/null +++ "b/docs/project/rfcs/WaveBench_scope\345\217\257\347\247\273\346\244\215\346\200\247RFC-0009_SINGLE\346\250\241\345\274\217\347\273\210\346\200\201STOP\350\257\201\346\230\216.md" @@ -0,0 +1,106 @@ +# WaveBench scope 可移植性 RFC-0009:SINGLE 模式读回后的终态 STOP 完成证明 + +> 状态:`Implemented R1(未发布)` +> 核心基线:WaveBench `0.8.24` 开发线 +> 依赖:[scope 通用扩展接口 RFC](WaveBench_scope通用扩展接口RFC.md) +> 范围:完成式 `scope.acquisition_control` 的附加证明分支 + +## 目的 + +`scope.acquisition_single` 的成功结果表示本次 SINGLE 已完成。普通的 `STOP`、固定等待、 +`*OPC?`、现有波形或 preamble count 都不能单独证明这一点。 + +某些设备的单条 SINGLE 命令会同时选择 SINGLE 模式并 arm。在 LAN 轮询中,触发可能在首条 +状态查询前已经完成,因而首个状态就是 `STOP`。RFC-0009 为这种设备定义一个比普通 +`state_transition` 更窄的可选证明;默认行为仍然拒绝首条 `STOP`。 + +该合同只证明完成式 SINGLE 控制操作,不证明波形记录的新鲜性,也不证明平均累积完成。 + +## 核心合同 + +`ScopeCompletionProof` 追加: + +```python +"single_mode_readback_then_stopped" +``` + +`ScopeAcquisitionControlProfile` 在末尾追加: + +```python +single_mode_readback_allows_terminal_stop: bool = False +``` + +`ScopeAcquisitionCompletion` 在现有可选字段末尾追加: + +```python +post_arm_trigger_mode: ScopeTriggerMode | None = None +``` + +profile 字段必须是实际的 `bool`。默认值为 `False`,因此现有 descriptor、driver 和完成 +证明保持原样。`post_arm_trigger_mode` 只能记录 SINGLE 写入后实际查询到的模式,不能从 +run-state 推测。 + +核心只在以下条件同时成立时接受新证明: + +1. `profile.single_mode_readback_allows_terminal_stop is True`; +2. `completion.post_arm_trigger_mode == "single"`; +3. `completion.state.phase == "stopped"`,且 `completion.state.trigger_mode == "single"`; +4. `completion.observed_states == (completion.state,)`; +5. `baseline_count`、`completed_count`、`baseline_identity` 和 `completed_identity` 都是 `None`; +6. 原有的 `original_state`、proof baseline stage、atomic-arm baseline 和终态校验全部通过。 + +该分支不要求 `state_transition`,也不解释或放宽另外三种 proof。缺任一条件时,核心拒绝 +completion,并沿用原有失败处理。 + +## Driver 顺序与失败处理 + +dataclass 不能证明实际 I/O 顺序。声明该 profile 开关的插件必须用 conformance test 固定以下 +顺序: + +```text +不可重放的 SINGLE 写入 + ↓ +SINGLE 模式读回 == single + ↓ +第一条 acquisition-state 读取 == stopped +``` + +模式读回和第一条状态读取必须发生在 SINGLE 写入之后,且在任何轮询或等待之前。首条状态为 +`waiting` 时继续沿用 `state_transition`;模式不匹配、状态为其他 token、超时、after-error、 +transport 错误或模型错误都不能构造本证明。 + +现有的 exclusive lease、deadline、failure cleanup、fresh verification 和 poisoned-session 零追加 +I/O 语义不变。成功的 SINGLE 保持设备的 SINGLE/STOP 后置状态;失败路径仍只恢复 operation +开始前的 trigger/acquisition 配置。 + +## 兼容性与边界 + +- 不新增 capability、Service 入口、CLI、run-plan step 或独立成功返回类型;仅向既有 + `ScopeAcquisitionCompletion` 追加可选的 `post_arm_trigger_mode` 字段。 + 仍使用 `scope.acquisition_control` 和 `ScopeService.acquire_single()`。 +- 旧 profile 因默认 `False` 继续拒绝首条 `STOP`;旧 completion 不需要填写 + `post_arm_trigger_mode`。 +- 该证明不开放 `scope.capture_waveform`、`scope.capture_waveforms`、运行态 MAX/DMAX、 + record/replay 或 `scope.capture_average_v2`。 +- average capture R1 仍只接受 `device_average_complete`。未来的 + `documented_single_completion` 需要独立的 R2 接受门;RFC-0009 不能替代平均完成位。 + +## 插件采用门 + +具体插件只能在以下条件全部满足后,把 +`single_mode_readback_allows_terminal_stop` 设为 `True` 并声明 `scope.acquisition_control`: + +1. 厂商资料明确说明该 SINGLE 命令的 mode/arm/stop 语义,并限定适用型号和固件; +2. driver conformance fixture 覆盖精确 query 顺序、`waiting → stopped` 旧路径以及模式/状态 + 不匹配、超时和 transport 失败; +3. 低压实机验收覆盖成功 trace、failure restore、fresh verification 与最终安全状态; +4. wheel 与 descriptor 的最低核心版本指向首个实际发布且包含本合同的核心版本。 + +核心 R1 的离线实现不构成任何型号的实机结论。本仓库本次不修改外部插件 descriptor 或 driver, +也不进行仪器 I/O。 + +## 离线验收 + +核心测试覆盖 profile 默认拒绝、精确合法 completion、错误的模式读回、多个 observed states、 +count/identity 混入、非 bool profile 值,以及 Service 接受合法新 proof 的路径。既有 +identity/count/state-transition 证明和 legacy descriptor 继续由完整回归覆盖。 diff --git "a/docs/project/rfcs/WaveBench_scope\345\217\257\347\247\273\346\244\215\346\200\247RFC\347\273\204\345\220\210\350\257\264\346\230\216.md" "b/docs/project/rfcs/WaveBench_scope\345\217\257\347\247\273\346\244\215\346\200\247RFC\347\273\204\345\220\210\350\257\264\346\230\216.md" new file mode 100644 index 0000000..74bf829 --- /dev/null +++ "b/docs/project/rfcs/WaveBench_scope\345\217\257\347\247\273\346\244\215\346\200\247RFC\347\273\204\345\220\210\350\257\264\346\230\216.md" @@ -0,0 +1,454 @@ +# WaveBench scope 可移植性 RFC-0001~RFC-0009 组合说明 + +> 状态:`Accepted R1` +> 核心基线:WaveBench `0.8.24` 开发线 +> 范围:核心接口裁决、兼容边界与实施顺序 +> 证据来源:WaveBench Instrument Plugins 中的 MSO8000 提案 +> 相关规范:[transport 重放与 session 健康 RFC](WaveBench_transport重放与session健康RFC.md)、[scope 通用扩展接口 RFC](WaveBench_scope通用扩展接口RFC.md)、[标准波形有界二进制传输 RFC](WaveBench_标准波形有界二进制传输RFC.md) + +## 摘要 + +本系列把外部插件提出的 RFC-0001~RFC-0009 转换为 WaveBench 核心侧的厂商无关裁决。 +插件 RFC 是问题和设备证据的来源,不是核心公共接口的事实源。核心只有在对应文档进入 +`Accepted`、实现通过离线验收并随正式版本发布后,外部插件才可以依赖新增合同。 + +前八份提案不能按原始草案逐项照搬。RFC-0001、RFC-0003 和 RFC-0008 的早期 transport +入口已经被更严格的核心合同取代;RFC-0002、RFC-0004、RFC-0005、RFC-0006a 和 +RFC-0007 的追加式 V2 模型已在开发线完成离线实现但尚未发布。RFC-0006b 的单通道 +`global_acquisition`/设备完成位合同也已完成核心离线实现;插件采用、版本门和硬件验收仍待独立推进。 +被取代不表示问题不存在,而是表示不能再实现已否决的平行 API。 + +RFC-0009 是 R1.3 acquisition control 的窄补充:仅当 descriptor 明确声明 SINGLE 模式读回 +可以约束首条终态 STOP 时,才追加完成证明分支。它不改变 average/capture 的完成和新鲜性要求。 + +M0 冻结本组合说明和 legacy 黄金基线。文档接受本身不授权插件 capability 声明或真实仪器 +操作;`Implemented R1(未发布)` 只记录已经提交并完成离线验证的核心代码。仍为 `Draft` +的单项 RFC 必须先完成其列出的文档裁决并进入 `Accepted`,才可以另行安排追加式实现。 +RFC-0006b 已按该顺序完成核心本地实现;它仍不授权插件 opt-in 或任何实机工作。 + +## 规范优先级 + +出现歧义时按以下顺序解释: + +1. 已接受的 transport、scope R1.3 和标准波形有界二进制传输核心 RFC; +2. 本系列中已经进入 `Accepted` 或 `Implemented` 的编号 RFC; +3. 本系列中仍为 `Draft` 的候选模型; +4. 外部插件仓库中的设备提案和厂商证据。 + +`Draft` 中的类型名、capability 名和字段仍可调整。插件不得只根据候选代码块调用尚未发布的 +方法。 + +## RFC 状态 + +| RFC | 核心裁决 | 当前状态 | 核心开发范围 | +| --- | --- | --- | --- | +| [RFC-0001](WaveBench_scope可移植性RFC-0001_消费型文本查询.md) | 原 `query_text_once()` 被统一 replay 合同和 `scope.error_drain_v1` 取代 | `Superseded R1` | 只保留回归、迁移说明和插件采用条件 | +| [RFC-0002](WaveBench_scope可移植性RFC-0002_通道输入状态.md) | 追加 coupling/termination 分离的输入状态 V2 | `Implemented R1(未发布)` | 纯读取、独立安全判断与 construction barrier;旧 coupling 路径不变 | +| [RFC-0003](WaveBench_scope可移植性RFC-0003_截图framing与菜单.md) | 原 `query_raw_bytes_once()` 被 `query_binary()` 和 screenshot profile 取代 | `Superseded R1;预算 R1.1 已实现(未发布)` | 核心截图上限为 `8388608/8388608/1/0`;具体插件仍需 framing、菜单和恢复证据 | +| [RFC-0004](WaveBench_scope可移植性RFC-0004_数字通道状态.md) | 追加保留未知值和字段作用域的 digital status V2 | `Implemented R1(未发布)` | 只处理状态;digital waveform 另行取证 | +| [RFC-0005](WaveBench_scope可移植性RFC-0005_可组合状态快照.md) | 追加可组合、字段可缺失的 snapshot V2 | `Implemented R1(未发布)` | M3b 已完成核心模型/profile/Protocol/factory gate/Service;不修改完整 snapshot、partial summary 或旧 CLI | +| [RFC-0006](WaveBench_scope可移植性RFC-0006_采集状态与平均采集.md) | 复用 R1.3 acquisition control,另增 status V2 和 average capture V2 | `Implemented R1(未发布;0006a/0006b-0/0006b 单通道)` | M4 已完成 status V2;M6 已完成通用 bounded transaction、单通道 profile、factory gate、executor 与 Service;插件仍未 opt-in | +| [RFC-0007](WaveBench_scope可移植性RFC-0007_统计FFT与光标读取.md) | 拆成统计 selector、FFT status 和 cursor quantity 三项 V2 | `Implemented R1(未发布;0007a/0007b/0007c)` | M5a/M5b/M5c 已完成 statistics/FFT/cursor 的 profile、零 I/O gate 与 Service;不改旧 CLI/artifact | +| [RFC-0008](WaveBench_scope可移植性RFC-0008_有界波形传输裁决.md) | 使用 descriptor profile、`query_binary()` 和核心恢复编排 | `Implemented R1(未发布)` | P0~P3 已完成;插件 opt-in 与实机验收不在本分支 | +| [RFC-0009](WaveBench_scope可移植性RFC-0009_SINGLE模式终态STOP证明.md) | 在 R1.3 SINGLE control 中追加受 profile 约束的 mode-readback terminal STOP proof | `Implemented R1(未发布)` | 只改 completion verifier;不新增 capability、CLI 或插件 opt-in | + +## 本轮文档冻结 + +RFC-0006b 的文档接受后,核心已追加独立 model/profile/baseline/Protocol、strict factory gate、 +bounded executor、Service 与离线回归;旧接口、CLI、run plan schema、capture package 和插件 descriptor +均未修改。已经标为 `Implemented R1(未发布)` 的 RFC-0002、RFC-0004 和 RFC-0008 仍不得被解释为主包 +内建 driver 或任一外部插件已经 opt-in;当前开发线版本 `0.8.24` 也不是可供插件声明最低版本的 +正式发行物。 + +RFC-0005 和 RFC-0006a 的 R1 核心实现已完成但尚未发布,外部插件仍不得据此声明 capability; +RFC-0007a/0007b/0007c 的 R1 核心实现也已完成但尚未发布,外部插件仍不得据此声明 capability;0007c 的 +global/indexed profile、factory gate、Service 和离线验收只属于核心,不授权插件 opt-in、版本下限升级或硬件 +conformance 分支。RFC-0006b-0 已完成 core-only bounded transaction 内核;0006b 已完成单通道 +`global_acquisition`/设备完成位的核心 public model、Protocol、Service、factory 接入和离线 conformance。 +它同样不得创建插件 opt-in、版本下限升级或硬件 conformance 分支。多通道、arithmetic 和 +documented-SINGLE 仍是候选,在各自进入 `Accepted` 前不得创建 capability、Protocol、Service、CLI、 +descriptor profile 或插件 conformance 分支。RFC-0001、RFC-0003 的原始 +入口已经被取代,不重新实施。 + +RFC-0009 已完成 core-only 的 profile/completion 追加字段、验证器和离线回归。其默认 profile +继续拒绝首条 `STOP`;插件 descriptor、driver、版本门和实机验收不在本次范围内。 + +## 共同术语 + +### 未提供、未知与失败 + +V2 读取模型统一区分三类结果: + +- `None`:当前结果没有该字段值;原因必须由模型定义为静态 unavailable 或当前配置下 + not applicable,不能只留下无解释的空值; +- `"unknown"`:查询已完成,但设备返回的状态不能无损映射到公共枚举; +- operation failure:声明可读的 query 发生 I/O、协议或解析失败。 + +一次查询失败不得转换为 `None` 或 `"unknown"`。未知值不是错误吞并机制。 + +带 `unavailable_fields` 的模型还必须满足: + +- 路径使用稳定的点分隔字段名; +- 路径已排序且不重复; +- 每个静态不可提供的叶字段都能由路径解释; +- 非空字段不得同时列入 `unavailable_fields`; +- 某个完整分区不可提供时,按具体模型的封闭路径规范记录父路径或全部叶路径;同一模型只能 + 选择一种规范表示,不能同时记录父路径和子路径,也不能制造虚假的叶字段默认值。 + +除非具体 RFC 另有声明,路径的「排序」均指其封闭 `Literal` 声明顺序,而不是字典序。完整 +分区的表示由各模型自行冻结:RFC-0005 的 snapshot V2 只记录全部封闭叶路径;已经把父路径 +列入其封闭路径集的模型,例如 RFC-0004 的 `pod`/`shared` 或 RFC-0006 的 `average`/`segmented`, +才可以使用父路径。不得把一种模型的表示规则套用到另一种模型。 + +模型若允许当前 mode 下不适用的字段,必须另设封闭、稳定的 `not_applicable_fields` 或等价 +typed reason;它不能与 `unavailable_fields` 混用。没有该机制的模型中,`None` 只允许表示 +unavailable。 + +### Safe token + +除非字段明确承载用户标签、身份文本或 source-defined unit,文档中的 safe token 均使用 +`^[A-Za-z0-9][A-Za-z0-9._:+/-]{0,63}$`。它不得包含空白、控制字符、引号、逗号、资源地址或 +原始 SCPI 响应片段。无法无损归一化到这一集合的已查询状态,应使用该模型定义的 `"unknown"`, +或使 operation 失败;不得把原始响应写入 artifact。 + +### 状态作用域 + +逐通道、POD、整机和当前 operation 的状态不得压入同一个含义不明的标量。共享阈值、 +全局数字显示大小、全局 acquisition type 和逐通道显示状态必须保留各自作用域。 + +### 可证明完成 + +状态为 `STOP`、文本 query 返回成功或 `*OPC?` 完成,只能证明各自协议事件。除非 descriptor +profile 和设备证据明确建立等价关系,否则不能推出平均累积完成、物理触发完成或波形 +记录已经更新。 + +## 共同兼容合同 + +### 追加式公共 API + +1. 不修改现有 `ScopeDriver` 方法签名。 +2. 不把现有强制字段改为可空,从而改变旧模型的类型语义。 +3. V2 模型、Protocol、capability 和 Service 方法全部追加。 +4. 新字段只追加到 descriptor 扩展末尾,并提供保持旧行为的默认值。 +5. driver 额外实现方法不产生隐式 capability。 +6. capability 未声明、方法缺失或 profile 无效时,在目标 operation 的第一次仪器 I/O 前拒绝。 + +旧 `ScopeSnapshot`、`ScopeDigitalChannelStatus`、`ScopeAcquisitionStatus`、 +`ScopeFftStatus`、`ScopeCursorReadout`、`WaveformData`、`CaptureResult` 和 +`MultiCaptureResult` 的字段与成功语义保持不变。 + +### Legacy 路径 + +没有声明新 capability/profile 的 descriptor 始终进入 legacy 路径: + +- 不要求旧 driver 或 fake 补空方法; +- 不增加新的 transport 关键字; +- 不改变既有 CLI 文本、JSON、run plan 或 artifact; +- 不改变 DS1000Z、DS1104 和 RTM2032 的现有读取路径; +- 不把旧 `scope.errors` 升级成类型化 error drain。 + +### Construction barrier + +本编号系列中明确要求 strict V2 opt-in 的 capability 必须触发核心 construction barrier。latch +条件是 descriptor capabilities 与核心登记的严格 V2 capability 集合存在交集,不能用某一个 +profile 是否非空代替。factory 可以打开 +transport,但在 factory 返回、capability/Protocol/profile/backend 校验完成前,guarded +transport 必须拒绝全部仪器 I/O。验证失败后关闭 transport,不发送 IDN、探测 query 或恢复命令。 + +该门只约束显式 opt-in descriptor。旧 descriptor 的 factory 行为不变。 + +RFC-0003 的 `scope.screenshot_v2` 是既有 scope R1.3 profile 合同,遵循其专用 validator 和 +factory 语义;不能因为本编号系列中的输入/数字状态 V2 使用 strict latch,就把该 latch 规则 +反向推广到截图 V2。后续 Draft RFC 若需要 latch,必须在单项 RFC 中显式声明并注册。 + +### Service、CLI 与 run plan + +每项新 capability 至少需要公共模型、Protocol、operation registry、Service、序列化和 +capability explain 共同冻结。CLI 只能追加命令,不得让旧命令静默改走 V2。 + +RFC-0005 R1、RFC-0006a R1、RFC-0006b R1、RFC-0007a/0007b/0007c R1 均不新增 V2 CLI 或 +run plan step;旧 `scope status`、`acquisition-status`、`capture-average`、`measurement-statistics`、 +`fft-status` 和 `cursor-readout` 继续只路由到 legacy Service。若单项 RFC 要新增 CLI 或 artifact, +必须先冻结新命令名、参数、JSON 成功形状和 artifact 版本,不能借用旧命令名或 R1.3 extension envelope。 + +本系列不自动增加 run plan step。只有在 operation 的持久化结果、恢复语义和旧 reader +兼容性已经单独评审后,才允许扩展 run plan schema。 + +## 核心与插件版本 + +| 组合 | 预期行为 | +| --- | --- | +| 旧核心 + 旧插件 | 保持原版本行为 | +| 新核心 + 旧插件 | 未 opt-in,继续使用 legacy 模型和方法 | +| 旧核心 + 新插件 | 正常安装由 wheel 依赖拒绝;强制安装仍须在仪器 I/O 前失败 | +| 新核心 + 新插件 | 只开放 descriptor 明确声明且已经验收的能力 | + +新增合同的最低版本必须指向「第一个实际发布且包含完整合同的核心版本」。开发树中的版本字符串 +不能单独作为发布证据。如果同一版本号可能对应不含合同的既有 artifact,发布前必须改用可区分的 +更高版本。 + +只要新合同保持追加式,`wavebench.instrument.v2` 可以继续使用。若实现需要删除旧字段、 +改变旧方法签名或改变旧成功返回语义,必须另立不兼容 API RFC,不能借本系列静默修改。 + +## 依赖与实施顺序 + +~~~text +transport R1 + scope R1.3 + ├─ RFC-0001:消费型文本与 error drain 裁决 + ├─ RFC-0003:截图 framing/profile 裁决 + └─ RFC-0008:标准 waveform bounded binary + +scope R1.3 acquisition control + └─ RFC-0009:SINGLE mode-readback terminal STOP proof + +共同 unknown/unavailable 语义 + ├─ RFC-0002:输入状态 V2 + ├─ RFC-0004:数字状态 V2 + ├─ RFC-0005:snapshot V2 + ├─ RFC-0006a:acquisition status V2 + └─ RFC-0007a/b/c:统计、FFT、光标 + +RFC-0002 + RFC-0006a + RFC-0008 + └─ RFC-0006b:average capture V2 +~~~ + +推荐里程碑如下。M0~M7 是已完成的历史记录;M8 记录 RFC-0009 的核心追加实现, +不等同于正式发布或插件硬件验收: + +1. M0:冻结本组合说明和 legacy 黄金基线; +2. M1:完成 RFC-0001、RFC-0003、RFC-0008 的结案回归; +3. M2:实现 RFC-0002; +4. M3:RFC-0004 和 RFC-0005 已完成核心离线实现; +5. M4:RFC-0006a 已完成只读模型/profile、factory gate、纯文本 budget Service 与离线兼容回归; +6. M5:0007a 已完成完整 statistics 成功值、selector/profile、纯文本 budget、factory gate 与 Service; + 0007b 已完成静态 FFT profile、configured、文本 budget、factory gate 与 Service;0007c 已完成 global/indexed + profile、单位/path、文本 budget、factory gate 与 Service; +7. M6:已完成 RFC-0006b-0 可复用 bounded transaction 基础,以及单通道 + `global_acquisition` 平均采集的独立 profile、factory gate、executor 与 Service; +8. M7:在每项已接受且已实现后,完成跨版本、发行产物和完整离线验收。 +9. M8:实现 RFC-0009 的 profile-gated SINGLE terminal STOP proof,并保持插件采用和实机验收独立。 + +每个里程碑应拆成可独立回滚的小提交,不把模型、factory、Service、CLI 和插件采用压入同一个 +提交。 + +## M0 冻结记录 + +本组合说明以 `Accepted R1` 冻结以下共同边界:编号映射、替代关系、unknown/unavailable +语义、legacy 不变量、construction barrier、四种核心/插件组合和 M1~M8 顺序。 + +离线黄金基线位于 `tests/test_scope_portability_m0.py`,覆盖: + +- RTM2032 与 DS1104/DS1000Z 的 canonical ID、alias、完整 legacy capability tuple、版本范围、 + extension 缺席和 capability explain; +- 两个内建 descriptor 的 standard fetch/capture 继续调用 legacy driver 方法; +- 旧/新核心与旧/新 descriptor 的四组合,其中旧核心强制加载新 descriptor 时在 factory 和 + 仪器 I/O 前由版本门拒绝; +- 新 descriptor 只公开显式 capability/profile,不因额外方法或 profile 获得其他 capability。 + +M0 冻结本身不把后续 RFC 自动升为已实现;每项仍须在其里程碑完成所接受的模型、Protocol、factory、 +Service 和兼容回归后更新状态。RFC-0006a 已在 M4 完成核心离线实现,其 R1 明确不包含 CLI。 + +## M1 完成记录 + +RFC-0001、RFC-0003 与 RFC-0008 的核心替代合同已完成离线回归,不新增 +`query_text_once()`、`query_raw_bytes_once()` 或扩展 legacy `query_bin_block()`。 + +- `tests/test_transport_call_classification.py` 将 `query_binary()` 纳入核心 driver 的显式 replay + 静态检查; +- `tests/test_transport_replay_matrix.py` 固定 PyVISA、RsInstrument 与 Serial 的文本/legacy binary + query 在 `no_replay`、已证明安全的 replay 和 continuation 三种策略下的发送次数; +- `tests/test_scope_binary_contract.py` 固定 PyVISA/RsInstrument bounded binary 的单次发送,以及 + guarded bounded phase 对 replay/continuation 的发送前拒绝,且不调用后端; +- `tests/test_scope_portability_m0.py` 继续覆盖内建 RTM2032、DS1104/DS1000Z 的 legacy 路由,确保 + 新 profile 不改变其 fetch/capture 行为。 + +`scope.error_drain_v1`、screenshot V2 和标准 waveform bounded profile 仍是开发线中 +`Implemented(未发布)` 的核心合同。外部插件采用必须等待第一个实际包含完整合同的正式发行版本; +开发树中的 `0.8.23`/`0.8.24` 只作为静态校验下限,不能单独证明可发布的版本门。 + +RFC-0003 的截图 V2 预算已由 `262144/262144/1/0` 提高到 `8388608/8388608/1/0`,与 trace/waveform +的单响应核心上限一致。该调整覆盖已文档化的 `387,356`-byte definite-block 示例,并为常见高分辨率 +PNG 保留余量;单次 query、零 resynchronization、精确 trailing、PNG +校验和超限 fail-closed;它不自动声明任何插件 capability,也不构成设备实机吞吐结论。 + +## M2 完成记录 + +RFC-0002 已完成核心离线实现:`ScopeChannelInputStateV2` 保留独立 coupling、termination 和 +可解释的 `impedance_ohm` 缺席状态;Protocol、capability、OperationSpec、factory construction +barrier、Service、CLI 和 capability explain 均已注册。 + +`tests/test_scope_input_state_v2.py` 覆盖模型不变量、版本门、缺方法和额外方法、factory 零 I/O、 +V2 安全判断、Service/CLI JSON,以及同时声明 V2 时 legacy high-impedance gate 继续只读 +`channel_coupling()`。R1 不把 V2 自动接入标准 fetch/capture,也不授权插件或具体型号声明该能力。 + +## M3a 完成记录 + +RFC-0004 已完成核心离线实现:`ScopeDigitalChannelStatusV2` 明确分开逐通道、POD 和 shared +状态;`"unknown"` 表示已成功查询但无法无损映射,`None` 只在精确的 `unavailable_fields` 路径中 +表示不可提供。独立 Protocol、capability、OperationSpec、factory construction barrier、Service、CLI +和 capability explain 已注册。 + +`tests/test_scope_digital_status_v2.py` 覆盖模型、factory、Service、CLI 和 legacy dual-capability +分流。R1 不创建 digital waveform decoder 或 payload 合同;MSO8000 当前没有数字 status/waveform +driver、descriptor capability 或离线数字 fixture,仍不得 opt-in。 + +## M3b 完成记录 + +RFC-0005 已完成核心离线实现:`ScopeSnapshotV2` 对六个可空分区和全部封闭叶路径执行精确 +unavailable/not-applicable 校验;`ScopeSnapshotProfileV2` 强制 identity、分区身份字段、纯文本 +query 上限和条件字段。独立 Protocol、capability、OperationSpec、strict factory construction barrier +和 `ScopeService.snapshot_v2()` 已注册;Service 不运行 legacy identity preflight,也不进入 R1.3 +extension service 或旧 `scope status` 路由。 + +`tests/test_scope_snapshot_v2.py` 覆盖模型、profile、capability、factory、query budget、非 query I/O +拒绝和 legacy route。核心完整离线回归通过;外部 MSO8000 插件在新 core source 下的离线测试通过, +其 descriptor 仍未声明 snapshot V2。R1 不新增 CLI、artifact、run plan 或任何主包/插件 opt-in。 + +## M4 完成记录 + +RFC-0006a 已完成核心离线实现:`ScopeAcquisitionStatusV2` 的 average/segmented 父子 availability +路径精确区分 static unavailable 与当前 mode 的 not applicable;`ScopeAcquisitionStatusProfileV2` 强制 +`acquisition_type`、`1..32` 的纯文本 query budget、条件分区和 run-state capability 依赖。独立 Protocol、 +capability、strict factory construction barrier、portability-V2 `OperationSpec` 和 +`ScopeService.acquisition_status_v2()` 均已注册。 + +Service 只调用 V2 driver 方法一次,传递 profile 的 `readable_fields`,并在一个只允许 `query()` 的受预算 +phase 中验证返回值;它不执行 legacy identity preflight、R1.3 acquisition-control Service、error drain、 +`*STB?`、`*ESR?`、binary 或 write。旧 `scope acquisition-status`、legacy status 模型、CLI、artifact 和 +run-plan 均不变。`tests/test_scope_acquisition_status_v2.py` 覆盖模型、profile、factory、query budget、 +non-query I/O 拒绝和 legacy route;核心完整离线回归与外部 MSO8000 插件新 core source 回归均通过。内建 +descriptor 和插件仍未声明 status V2;0006b-0 已完成内部 bounded transaction 内核,0006b 已完成 +单通道 core-only 实现,仍不授权插件 opt-in。 + +## M5a 完成记录 + +RFC-0007a 已完成核心离线实现:`ScopeMeasurementSelector` 以 slot 或 item/source 精确 XOR; +`ScopeMeasurementStatisticsV2` 只接受完整、有限的六项统计值;append-only profile 在打开 session 前校验 +selector mode、slot/item/source count、`configured=True` 和 R1 buffer 拒绝。独立 Protocol、capability、 +strict factory construction barrier、portability-V2 `OperationSpec` 和 +`ScopeService.measurement_statistics_v2()` 均已注册。 + +Service 只调用 V2 driver 方法一次,并在一个只允许 `query()` 的 `1..32` budget phase 内验证 selector echo +与完整结果;它不调用 legacy statistics、legacy identity preflight、error drain、binary 或 write。旧 +`scope measurement-statistics` CLI、slot API、nullable legacy result、artifact 和 run-plan 均保持原样。 +`tests/test_scope_measurement_statistics_v2.py` 覆盖模型、profile、factory、query budget、buffer 零 I/O 和 +legacy route;核心完整离线回归与外部 MSO8000 插件新 core source 回归均通过。内建 descriptor 和插件仍未 +声明 statistics V2;0007b/0007c 均已完成独立核心离线实现。 + +## M5b 完成记录 + +RFC-0007b 已完成核心离线实现:`ScopeFftStatusV2` 以精确 unavailable paths 表示可证明的静态字段缺失, +并强制 frequency start/stop 成对出现、RBW/sample rate 为有限正数;append-only +`ScopeFftStatusProfileV2` 锁定静态字段闭包与 `1..32` 的纯文本 query budget。独立 Protocol、capability、 +strict factory construction barrier、portability-V2 `OperationSpec` 和 `ScopeService.fft_status_v2()` +均已注册。 + +Service 在打开 session 前拒绝非正 index 或 `configured_fft is not True`,只调用 V2 driver 一次,并在只允许 +`query()` 的 budget phase 内验证结果;它不调用 math metadata、legacy FFT、identity preflight、error drain、 +binary 或 write。旧 `scope fft-status` CLI、legacy strong-field model、artifact 和 run-plan 均保持原样。 +`tests/test_scope_fft_status_v2.py` 覆盖 model/profile、factory、query budget、math metadata 隔离与 legacy route; +核心完整离线回归与外部 MSO8000 插件新 core source 回归均通过。内建 descriptor 和插件仍未声明 FFT V2; +0007c 已完成独立核心离线实现。 + +## M5c 完成记录 + +RFC-0007c 已完成核心离线实现:`ScopeCursorReadoutV2` 保留 global/indexed addressing、A/B source、五种 +quantity unit、source-defined unit 与精确 unavailable/not-applicable path;append-only +`ScopeCursorReadoutProfileV2` 锁定静态可读字段、conditional fields、寻址和 `1..32` 的纯文本 query budget。 +独立 Protocol、capability、strict factory construction barrier、portability-V2 `OperationSpec` 和 +`ScopeService.cursor_readout_v2()` 均已注册。 + +Service 在打开 session 前拒绝非真 `configured_cursor`、非法 index 和 profile/addressing 不匹配;在共享 session +state 存在时只调用 V2 driver 一次,并限制为 query-only budget phase。global cursor 的 `None` index 必须以 +not-applicable path 明示,indexed 结果必须精确 echo request;不存在 shared state 的 transport 发送前失败。 +旧 `scope cursor-readout` CLI、legacy model/Protocol、artifact 和 run-plan 均保持原样。 +`tests/test_scope_cursor_readout_v2.py` 覆盖模型/profile、factory、五种 unit、两个 fixture、query budget、 +non-query I/O、missing-state fail-closed 与 legacy route;核心完整离线回归与外部 MSO8000 插件新 core source +回归均通过。内建 descriptor 和插件仍未声明 cursor V2。 + +## M6 完成记录 + +RFC-0006b-0 已完成 operation-agnostic bounded binary 内核:factory 只对可信的 PyVISA/RsInstrument +VISA `INSTR` backend 标记同一 factory-owned guarded transport;`ScopeBinaryLimits` 将 operation spec、 +descriptor profile 与 connection 限制逐项取最小值;一个 operation 只持有一个 context、deadline 和 ledger。 +标准 waveform 继续使用这一内部结论,旧私有 waveform 标记只是兼容别名。 + +RFC-0006b R1 已完成 core-only 单通道 `global_acquisition + device_average_complete` 实现:独立的 +request/configuration/completion/result、profile、parent baseline 与 child acquisition baseline、Protocol、 +strict factory gate、OperationSpec、core-owned executor 和 `ScopeService.capture_average_v2()` 均已追加。 +main 固定为 type write/readback、count write/readback、stopped recheck、single completion、fresh device +complete 和一次 bounded fetch;成功和已证明同步失败都执行 restore/fresh verify,失步后不追加 backend I/O。 + +这条路径不调用 legacy `capture_average()`,不复用标准 waveform profile 或 executor,不新增 CLI、run-plan +step、capture package/artifact schema,也不修改内建 descriptor 或外部插件 capability。多通道、arithmetic、 +callbacks、partial result 与 `documented_single_completion` 继续留在 R2 接受门。 + +## M7 完成记录 + +核心 M7 离线验收已完成:完整 pytest 为 `1787 passed, 1 skipped, 131 subtests passed`;wheel/sdist +离线安装与项目元数据聚焦测试为 `3 passed`;Ruff 和 `git diff --check` 均通过。M0 的内建 legacy +descriptor/Service 路由和新旧核心/插件矩阵继续在回归中保持 fail-closed。 + +外部 MSO8000 插件只作为只读兼容对象验证:在本分支 `src/` 下运行既有离线测试为 `171 passed`,Ruff 和 +`wavebench plugin package check` 均通过,插件工作树保持干净。它没有声明 `scope.capture_average_v2` 或任何 +前置 V2 capability,因此这不是插件 conformance、版本门升级或硬件验收。 + +M7 的完成只证明当前核心分支的本地代码、发行物布局和旧插件兼容性。正式核心发布、任一插件 opt-in 和真实 +仪器验证仍须在独立范围内进行。 + +## M8 完成记录 + +RFC-0009 已在核心追加 `single_mode_readback_allows_terminal_stop` 和 +`post_arm_trigger_mode`,并把 `single_mode_readback_then_stopped` 加入 completion verifier。 +该分支要求显式 profile opt-in、SINGLE 模式 readback、唯一终态 `stopped/single` 观察和空的 +count/identity evidence;其他 proof 不变。average capture V2 在读取完成位和 binary fetch 前显式 +拒绝该 control-only proof。 + +核心没有新增 capability、Service 入口、CLI、run-plan 或插件声明。离线测试覆盖合法 proof、默认 +拒绝、非法 readback/observed state/count evidence 和 Service 成功路径。具体设备仍须在插件仓库完成 +命令顺序 conformance、版本门和实机恢复验收后,才能设 profile flag 或声明 control capability。 + +## 后续 Draft 验证与接受门 + +本轮已在单项 RFC 中冻结以下文档语义:RFC-0005 的 identity 新鲜来源、text query 计数、封闭 +availability 与独立返回边界;RFC-0006a 的 profile、文本预算、父/子 availability、run-state 条件依赖 +与 legacy 路由;RFC-0007a 的完整 statistics 成功值、selector/profile、R1 buffer 拒绝、纯文本 budget 和 +无 CLI/artifact 边界;RFC-0007c 的 global/indexed addressing、availability、纯文本 budget、strict latch 和 +无 CLI/artifact 边界。 + +RFC-0005、RFC-0006a、RFC-0007a/0007b/0007c 已分别完成 M3b/M4/M5a/M5b/M5c 核心离线矩阵;M6 已完成 +0006b-0 的 generic bounded transaction 内核,以及 0006b 单通道 average 的 profile、baseline、Protocol、 +factory gate、core-owned executor、Service 与离线 conformance。插件 opt-in 继续等待独立 conformance、 +首个正式核心版本和硬件证据。 + +M7 只补发行产物、旧/新核心与插件组合及完整离线验收;外部插件 capability 与硬件验收不因核心实现而启动。 + +## 共同验收门 + +每项新合同至少覆盖: + +- dataclass 的类型、范围、有限数值、互斥字段和稳定序列化; +- capability 与 required Protocol 的一一对应; +- descriptor opt-in、缺方法、缺 profile 和额外方法的零 I/O 行为; +- legacy descriptor、旧 fake 和内建 driver 回归; +- I/O/解析失败不会伪装成 unavailable; +- 四种核心/插件版本组合; +- wheel/sdist 构建和隔离安装; +- 完整 `pytest`、Ruff、中文文档规则和 `git diff --check`。 + +状态写入或 acquisition operation 还必须覆盖 core-owned baseline、阶段顺序、deadline、失败 +恢复、fresh verification、异常优先级和 poisoned 后零追加 I/O。 + +## 不属于核心文档完成的范围 + +以下项目继续由插件仓库和受控实机验收负责: + +- 具体错误队列的结束 token 与厂商错误格式; +- screenshot 命令的实际 framing、菜单和颜色语义; +- 数字 waveform 的 LOW/HIGH 编码与 WORD 字节序; +- 平均采集完成证据; +- reference/history 语义和扩展 cursor/FFT 模式; +- waveform 的 MAX、DMAX、分块、多通道和 capture 硬件验收。 + +核心模型发布不等于某个型号已经具备对应 capability。型号、固件、resource/backend 和 +请求范围必须分别验收,不能由单通道短记录或离线 fake 外推。 diff --git "a/docs/project/rfcs/WaveBench_scope\351\200\232\347\224\250\346\211\251\345\261\225\346\216\245\345\217\243RFC-R1.3-acceptance-addendum.md" "b/docs/project/rfcs/WaveBench_scope\351\200\232\347\224\250\346\211\251\345\261\225\346\216\245\345\217\243RFC-R1.3-acceptance-addendum.md" index 2fb9be8..17f1f69 100644 --- "a/docs/project/rfcs/WaveBench_scope\351\200\232\347\224\250\346\211\251\345\261\225\346\216\245\345\217\243RFC-R1.3-acceptance-addendum.md" +++ "b/docs/project/rfcs/WaveBench_scope\351\200\232\347\224\250\346\211\251\345\261\225\346\216\245\345\217\243RFC-R1.3-acceptance-addendum.md" @@ -43,7 +43,7 @@ | operation | response / total / query / resync | default timeout | | --- | --- | --- | - | `scope.screenshot_v2` | `262144 / 262144 / 1 / 0` | `5000 ms` | + | `scope.screenshot_v2` | `8388608 / 8388608 / 1 / 0` | `5000 ms` | | `scope.acquisition_start/single` | binary `—` | `30000 ms` | | `scope.fetch_trace` | `8388608 / 67108864 / 256 / 65536` | `60000 ms` | diff --git "a/docs/project/rfcs/WaveBench_scope\351\200\232\347\224\250\346\211\251\345\261\225\346\216\245\345\217\243RFC.md" "b/docs/project/rfcs/WaveBench_scope\351\200\232\347\224\250\346\211\251\345\261\225\346\216\245\345\217\243RFC.md" index de526d4..e26a5cf 100644 --- "a/docs/project/rfcs/WaveBench_scope\351\200\232\347\224\250\346\211\251\345\261\225\346\216\245\345\217\243RFC.md" +++ "b/docs/project/rfcs/WaveBench_scope\351\200\232\347\224\250\346\211\251\345\261\225\346\216\245\345\217\243RFC.md" @@ -164,7 +164,8 @@ R1.3 暂定四个独立 binary 限制:`binary_response_max_bytes` 限制每次 短生命周期的 `BinaryQueryBudget`。transport 每次 binary query 都必须验证 budget 与 operation context、phase、 correlation 和 session epoch 匹配;插件只能进一步收紧单次上限,不能提高或重置累计额度。没有 budget 的新 -`query_binary()` 调用在发送前拒绝;旧 `query_bin_block()` 兼容入口使用核心固定有限上限。 +`query_binary()` 调用在发送前拒绝;旧 `query_bin_block()` 兼容入口只保留给 legacy operation, +在 active binary budget phase 中必须在发送前拒绝。 现有 `verification_fields` 只表示按 `restore_coverage` 恢复到 baseline 后必须闭合的字段, 不用于表示读操作的观察结果,也不用于证明有意保留的控制状态。 @@ -213,7 +214,7 @@ error policy 和 binary budget 必须可序列化、可审计。 | operation | capability | effect / lease | changed_fields | restore_coverage | required_verified_fields | verification_fields | postcondition / cleanup fields | risk_flags | timeout_source | binary response / operation / query / resync limits | error minimum | 最低 access | Service / CLI / artifact | | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | | `scope.screenshot_profile` | `scope.screenshot_profile` | `stateful_read` / `exclusive` | `none` | `none` | `scope.identity` | `none` | — / — | `profile_query` | `operation.timeout_ms=5000` | — | — | `read_only` | `ScopeService.screenshot_profile()` / `wavebench scope screenshot profile` / `screenshot.profile` | -| `scope.screenshot_v2` | `scope.screenshot_v2` | `write` / `exclusive` | `scope.display_menu`, `scope.display_color`, `scope.error_queue`, `output.screenshot` | `screenshot-baseline-only` | `scope.identity` | `scope.display_menu`, `scope.display_color` | — / `scope.display_menu`, `scope.display_color` | `front_panel_state`, `binary_response`, `temporary_display_setup` | `operation.timeout_ms=5000` | `262144 / 262144 / 1 / 0` | `disabled` | `read_write` | `ScopeService.screenshot_v2(request)` / `wavebench scope screenshot capture` / `screenshot`、`effective_request`、`media_type`、`dimensions`、`framing` | +| `scope.screenshot_v2` | `scope.screenshot_v2` | `write` / `exclusive` | `scope.display_menu`, `scope.display_color`, `scope.error_queue`, `output.screenshot` | `screenshot-baseline-only` | `scope.identity` | `scope.display_menu`, `scope.display_color` | — / `scope.display_menu`, `scope.display_color` | `front_panel_state`, `binary_response`, `temporary_display_setup` | `operation.timeout_ms=5000` | `8388608 / 8388608 / 1 / 0` | `disabled` | `read_write` | `ScopeService.screenshot_v2(request)` / `wavebench scope screenshot capture` / `screenshot`、`effective_request`、`media_type`、`dimensions`、`framing` | | `scope.acquisition_run_state` | `scope.acquisition_run_state` | `stateful_read` / `exclusive` | `none` | `none` | `scope.identity` | `none` | — / — | `state_observation` | `operation.timeout_ms=5000` | — | — | `read_only` | `ScopeService.acquisition_run_state()` / `wavebench scope acquisition status` / `acquisition.run_state` | | `scope.acquisition_start` | `scope.acquisition_control` + `scope.acquisition_run_state` | `write` / `exclusive` | `scope.run_state`, `scope.trigger`, `scope.acquisition`, `scope.error_queue` | `failure-cleanup-only` | `scope.identity` | `scope.trigger`, `scope.acquisition` | `scope.run_state`, `scope.trigger`, `scope.acquisition` / `scope.run_state`, `scope.trigger`, `scope.acquisition` | `trigger`, `acquisition_state`, `recovery_required` | `operation.timeout_ms=30000` | — | `disabled` | `read_write` | `ScopeService.start_acquisition(request)` / `wavebench scope acquisition start` / `acquisition.control`、`effective_trigger_mode`、`postcondition`、`cleanup` | | `scope.acquisition_single` | `scope.acquisition_control` + `scope.acquisition_run_state` | `acquire` / `exclusive` | `scope.run_state`, `scope.trigger`, `scope.acquisition`, `scope.error_queue` | `failure-cleanup-only` | `scope.identity` | `scope.trigger`, `scope.acquisition` | `scope.run_state`, `scope.trigger`, `scope.acquisition` / `scope.run_state`, `scope.trigger`, `scope.acquisition` | `trigger`, `acquisition_state`, `recovery_required` | `operation.timeout_ms=30000` | — | `disabled` | `read_write` | `ScopeService.acquire_single()` / `wavebench scope acquisition single` / `acquisition.control`、`postcondition`、`completion_proof`、`cleanup` | @@ -225,8 +226,8 @@ R1.3 acceptance addendum 固定下列数值;表中 binary 列依次为 respons count、resynchronization bytes: ```python -SCOPE_SCREENSHOT_BINARY_RESPONSE_MAX_BYTES = 262_144 -SCOPE_SCREENSHOT_BINARY_OPERATION_MAX_BYTES = 262_144 +SCOPE_SCREENSHOT_BINARY_RESPONSE_MAX_BYTES = 8_388_608 +SCOPE_SCREENSHOT_BINARY_OPERATION_MAX_BYTES = 8_388_608 SCOPE_SCREENSHOT_BINARY_QUERY_MAX_COUNT = 1 SCOPE_SCREENSHOT_BINARY_RESYNCHRONIZATION_MAX_BYTES = 0 @@ -242,7 +243,10 @@ SCOPE_TRACE_OPERATION_TIMEOUT_MS = 60_000 ``` 这些值是核心上限,不是 driver 默认值;descriptor/profile/connection 只能收紧,不能提高。 -`scope.screenshot_v2` 首版单次 PNG 上限为 256 KiB,依据已有 SDS raw PNG 证据保留明确余量; +`scope.screenshot_v2` 的 R1.1 单次 PNG 上限为 8 MiB,与 trace/waveform 的单响应核心上限一致。 +该值覆盖已文档化的 `387,356`-byte TMC definite-block 截图示例,并为常见高分辨率 PNG 保留余量; +它不构成所有型号、固件、颜色或菜单状态的吞吐承诺。单次 query、 +零 resynchronization 和 5 秒 operation timeout 不变;具体 descriptor profile 仍须按设备证据收紧。 `scope.fetch_trace` 每次 response 上限为 8 MiB、一次 operation 总上限为 64 MiB、最多 256 次 binary query,并允许最多丢弃 64 KiB 以证明边界。超出 resynchronization 上限或无法证明边界时, 核心固定关闭 transport 并将 session 标记为 `poisoned`,不由 backend 自行选择 close/poison @@ -653,11 +657,10 @@ profile 或 connection 没有对应限制时只是不进一步收紧,不能取 Service 在第一个阶段授权前向 operation context 安装与 context/operation/correlation/epoch 绑定的 opaque budget ledger。没有 ledger 或 active phase 不允许 binary I/O 的新 `query_binary()` 调用必须在 `BEFORE_SEND` 以 `NOT_SENT` 拒绝。现有 `query_bin_block()` 保留 -为 definite block 兼容入口。它在 legacy operation 中使用核心固定的有限兼容上限;在已安装 -R1.3 budget 的新 operation 中必须消耗同一个 response/operation/query/resync budget,不得建立第二套 -兼容额度。新 operation 若无 R1.3 budget,通过 `query_bin_block()` 也必须在发送前拒绝。 -核心冻结 legacy 兼容上限时必须覆盖现有已接受 operation 的合法 payload,或给旧 operation 保留 -独立的有限 spec;不能在没有迁移说明的情况下静默降低既有波形读取上限。 +为 definite block 兼容入口,只允许没有 active binary budget 的 legacy operation。在已安装 R1.3 +budget 的新 operation 中,它必须以 `binary_legacy_entry_unsupported` 在发送前拒绝,不得消耗、 +重建或模拟 response/operation/query/resync budget。新 operation 若没有 R1.3 budget, +`query_binary()` 同样在发送前拒绝。 如果 operation 使用 binary profile,则每个参与的 variant 必须提供有限正整数的 response、 operation-total 和 query-count,以及有限非负整数的 resynchronization 限制;没有 profile 层的 operation 直接使用 @@ -910,7 +913,7 @@ class ScopeScreenshotDriver(InstrumentDriver, Protocol): `variants` MUST 非空,每个 request 只能出现一次;R1.3 首版只接受 `png` / `image/png`。 两个 byte 上限 MUST 为正数,`resynchronization_max_bytes` MUST 为非 bool 非负整数; descriptor variant 的 response/operation/query/resynchronization 值不得超过 -`262144/262144/1/0`,connection 或更严格 profile 只能进一步收紧; +`8388608/8388608/1/0`,connection 或更严格 profile 只能进一步收紧; 首版 screenshot 只允许一次 binary response,因此 `query_max_count == 1` 且 `operation_max_bytes == response_max_bytes`。尺寸范围的上下界必须为正数且满足 @@ -1053,6 +1056,7 @@ class ScopeAcquisitionControlProfile: verify_max_steps: int identity_semantics: ScopeAcquisitionIdentitySemantics atomic_arm_preserves_count_mode_semantics: bool = False + single_mode_readback_allows_terminal_stop: bool = False @dataclass(frozen=True) class ScopeAcquisitionControlSnapshot: @@ -1090,6 +1094,7 @@ ScopeCompletionProof = Literal[ "count_delta_with_epoch", "identity_delta", "state_transition", + "single_mode_readback_then_stopped", ] @dataclass(frozen=True) @@ -1113,6 +1118,7 @@ class ScopeAcquisitionCompletion: baseline_identity: str | None = None completed_identity: str | None = None observed_states: tuple[ScopeAcquisitionRunState, ...] = () + post_arm_trigger_mode: ScopeTriggerMode | None = None ``` `ScopeAcquisitionControlProfile` 是 descriptor 静态事实,不是 driver 在 operation 中自报的 @@ -1124,7 +1130,7 @@ driver 返回值扩大 descriptor profile。 profile 不变量为: - `supported_continuous_modes` 非空、唯一,且只包含 `auto/normal/roll`; -- 两个 bool 字段必须是真正的 `bool`; +- 三个 bool 字段必须是真正的 `bool`; - `failure_restore_order` 必须恰好各包含一次 `scope.trigger` 和 `scope.acquisition`; 顺序是核心恢复授权与 driver 实现的唯一事实源; - 三个 `*_max_steps` 必须是 `1..64` 的非 bool 整数;snapshot/verify 各至少覆盖 run state、 @@ -1135,10 +1141,12 @@ profile 不变量为: - `single_arm_semantics="atomic_configure_and_arm"` 时,只有 `atomic_arm_preserves_count_mode_semantics=true` 且 `arm_resets_acquisition_count=false` 时, operation verifier 才可以在原始状态确为 `trigger_mode="single"` 时把 count 作为辅助证据; -- `arm_resets_acquisition_count=true` 时任何 arm 路径都不得使用 `count_delta_with_epoch`;只能使用 - identity 或状态迁移证据。 -- `identity_semantics="unique_within_session_epoch"` 才允许 `identity_delta`; - `unknown` 时核心不得接受 identity proof,只能使用满足本节要求的 state transition。 +- `arm_resets_acquisition_count=true` 时任何 arm 路径都不得使用 `count_delta_with_epoch`; + `identity_delta`、`state_transition` 和满足 RFC-0009 的 terminal STOP proof 仍各自按其条件校验。 +- `identity_semantics="unique_within_session_epoch"` 只约束 `identity_delta`;`unknown` 时核心不得接受 + identity proof,但不改变 `state_transition` 或满足 RFC-0009 的 terminal STOP proof 的独立条件。 +- `single_mode_readback_allows_terminal_stop=true` 只授权 RFC-0009 定义的终态 STOP proof; + 它不替代 SINGLE arm、count、identity 或失败恢复语义。 核心构造 `ScopeAcquisitionControlBaseline` 时必须把固定的 `("scope.run_state", *profile.failure_restore_order)` 写入 `restore_order`;baseline 中的顺序 @@ -1177,6 +1185,11 @@ driver 若在 restore/verify 中抛出 transport 或协议异常,核心必须 `state_transition` 要求保留本节的最小观察序列,且不依赖 count;若该分支同时携带 count, 仍必须提供未变化的 `counter_epoch`,否则核心必须忽略 count 并按纯状态迁移验证。任一终态或证据不完整只能抛出 `completion_unproven`,不得返回一个携带「不可用」proof 的成功对象。 +`single_mode_readback_then_stopped` 只在 profile 明确 opt-in、SINGLE 写入后的 mode readback +为 `single`、最终 state 为 `stopped/single`、`observed_states` 恰为最终 state,且四个 +count/identity proof 字段均为空时成立。该分支的顺序与插件采用门由 +[RFC-0009](WaveBench_scope可移植性RFC-0009_SINGLE模式终态STOP证明.md)冻结;普通 state query +不能自行构造它。 `identity_delta` 也不能仅凭两个 token 不同就成立;核心只在已验证 descriptor profile 的 `identity_semantics="unique_within_session_epoch"` 时接受该 proof。`unknown` 或 profile 缺失时,即使 fixture 观察到 token 不同,也只能使用完整 `state_transition`,或拒绝完成证明。 @@ -1197,7 +1210,7 @@ forced trigger 是瞬时 action/event,不是可 query-back 的持久 `ScopeTri | `scope.acquisition_start`(driver: `start_continuous`) | `stopped`、`ready`、`complete` | 写入后回读 `ready`/`arming`/`waiting`/`acquiring`/`rolling` | 写后回读失败,保留 session health 和 cleanup 结果 | | `scope.acquisition_single`(driver: `acquire_single`) | `stopped`、`ready`、`complete` | 记录基线,观察新采集状态,再到 `complete/stopped` 且有 proof | 只观察到 arm 不算成功;超时进入失败 cleanup | | trigger accepted | `arming`、`waiting`、`ready` | `acquiring` 或 `complete` | 外部变化时回读为 `unknown` | -| acquisition complete | 已观察到 `arming`、`waiting`、`ready` 或 `acquiring` 中至少一个新采集状态 | `complete` 或 `stopped` 且有 completion proof | 轮询可以跳过瞬时 `acquiring`;只有原本已 `stopped` 不足以证明新采集完成 | +| acquisition complete | 已观察到 `arming`、`waiting`、`ready` 或 `acquiring` 中至少一个新采集状态;或满足 RFC-0009 的 profile-gated SINGLE mode-readback 条件 | `complete` 或 `stopped` 且有 completion proof | 轮询可以跳过瞬时 `acquiring`;只有原本已 `stopped` 不足以证明新采集完成 | | normal `scope.acquisition_stop`(driver: `stop_acquisition`) | `stopped`、`ready`、`arming`、`waiting`、`acquiring`、`rolling`、`stopping` 或 `complete` | 回读 `stopped` | 幂等;回读失败时保留 `uncertain`/`poisoned` | | recovery STOP | 核心已签发有界 recovery authorization 的 `healthy/uncertain` session;phase 可为 `unknown/error` | 只写 STOP 并回读 `stopped` | 不得向 `poisoned` session 发送;结果只记入 cleanup,不伪装成 normal success | | 外部/设备错误 | 任意 | `error` 或 `unknown` | 必须重新查询确认,不能继续普通 I/O | @@ -1314,11 +1327,12 @@ original 已为 `trigger_mode="single"`,且 `ScopeAcquisitionControlProfile` `single_arm_semantics="atomic_configure_and_arm"`、 `atomic_arm_preserves_count_mode_semantics=true` 和 `arm_resets_acquisition_count=false` 时, 才能把 count 作为辅助证据;最终 proof 仍必须同时满足未变化的 `counter_epoch` 和有效 -`state_transition`。否则必须改用 identity delta 或完整的 state transition。 +`state_transition`。否则必须改用 identity delta、完整的 state transition,或满足 RFC-0009 的 +profile-gated mode-readback terminal STOP proof。 真正 arm 后,Service/driver 在同一 deadline 内等待新 acquisition 完成。只有看到有效 -identity 变化,或看到 R1.3 暂定的最小状态序列后,才能成功返回 completion -proof;调用前本来就是 `stopped` 不能单独作为完成条件。没有 completion proof 时 +identity 变化、R1.3 暂定的最小状态序列,或满足 RFC-0009 的 profile-gated mode-readback terminal +STOP 条件后,才能成功返回 completion proof;调用前本来就是 `stopped` 不能单独作为完成条件。没有 completion proof 时 返回 `completion_unproven`,不得返回成功 waveform。 R1.3 暂定的最小 `state_transition` proof 为:SINGLE 写入并 query-back 后至少观察一次 @@ -1326,7 +1340,9 @@ R1.3 暂定的最小 `state_transition` proof 为:SINGLE 写入并 query-back `trigger_mode="single"`,且 `(phase, trigger_mode)` 不得与 `proof_baseline_state` 相同。随后必须观察 `complete` 或 `stopped`。如果仪器把 写后第一个查询直接返回 `stopped`,且 count/identity 均不变或不可用,则 completion -unproven。后续若跨厂商 fixture 证明该序列仍不通用,应保持 `[OPEN]`,不得由单个插件放宽。 +unproven,除非满足 [RFC-0009](WaveBench_scope可移植性RFC-0009_SINGLE模式终态STOP证明.md) +规定的 profile-gated mode-readback proof。后续若跨厂商 fixture 证明该序列仍不通用,应保持 +`[OPEN]`,不得由单个插件放宽。 arm-only API 不属于 R1.3;未来如确有非阻塞需要,应新增 `scope.acquisition_arm_single`,其 effect 为 `write`、成功输出只证明已 arm,不能复用 `scope.acquisition_single` 的成功合同。 @@ -2156,9 +2172,10 @@ scope.error_drain_v1 - 为没有错误队列的仪器返回空列表; - 超过 `max_bytes` 后在 healthy session 中留下未消费响应。 -## 十一、R1.3 暂定安全结论与待决问题 +## 十一、R1.3 已冻结安全结论与后续问题 -以下是 Draft 阶段暂定的安全不变量,不代表 schema、常量或核心实现已经接受: +以下安全不变量已经随 R1.3 接受,并由第十二节的验收门限定。后续问题不得反向扩大已经注册 +capability 的语义,也不表示具体插件已经完成 opt-in 或实机验收: 1. 采集 start、完成式 single、stop 是三个 action-specific operation;共享 capability 不改变 各自 effect、postcondition、失败 cleanup 或最低 access。descriptor 的 @@ -2189,24 +2206,25 @@ scope.error_drain_v1 有效 `state_transition` 联合时作为辅助证据,任何回绕、复位或非严格递增都必须转用 `identity_delta`/`state_transition`。旧 `scope.errors` 不提供 R1.3 终止证明。 -剩余待决问题: +后续独立问题: -1. `OperationSpec` 的完整输入/输出序列化、取消、幂等性和并发字段仍未公开冻结;在这些字段 - 冻结前,核心只能实现第 12 节列出的内部 / feature-gated 骨架。 -2. 各 backend 是否能在固定 resynchronization 上限内安全 drain 仍需 fixture;超出上限时 - close + `poisoned` 已是 R1.3 的统一默认,不再由 backend 自选。 -3. 哪些 PyVISA resource class 和 RsInstrument API 能稳定证明 message END。 +1. 更通用的 operation 输入/输出序列化、取消、幂等性和并发元数据若有需求,应由独立 RFC + 追加;当前已注册 operation 的输入、结果和 artifact 形状保持不变。 +2. 当前批准的 backend/resource 已有固定 resynchronization fixture;其他 backend 只有补齐 + 同等 fixture 后才能 opt-in。超出上限时 close + `poisoned` 仍是统一默认。 +3. 当前只认可已验证的 PyVISA/RsInstrument VISA `INSTR` 路径;新增 resource class 或 API + 的 message END 证明属于后续 backend 扩展。 4. `READ_CONTINUATION_ONLY` 的 core-issued continuation token 和返回模型如何授权。 -5. 旧 screenshot adapter 的具体拒绝码和更多 profile variant 仍待 fixture;profile 来源已固定为 - descriptor 或 descriptor/query 的 `combined` 交集。 +5. 更多 screenshot profile variant 仍待独立 fixture;旧 capture 与 V2 的拒绝/分流语义已经 + 冻结,profile 来源仍为 descriptor 或 descriptor/query 的 `combined` 交集。 6. poisoned session 的 recovery/reopen API 仍属 transport 生命周期设计;在该 API 冻结前, 新 capability 不得从 poisoned session 继续 I/O。 7. 暂定的 completion state-transition 序列及 counter-epoch 联合条件能否通过第二个厂商 fixture,是否需要进一步收紧;更丰富的计数器世代语义仍待单独 RFC。 8. `spectrum` 是否作为独立 `ScopeTraceKind`,以及单位校验复用哪些现有核心模型;该项已 明确排除在 R1.3 公共 fetch scope 外,移入后续 trace-extensions RFC。 -9. binary operation/profile 的连接项如何映射到不同 backend 仍需核心实现细节;R1.3 的 - operation 常量、profile 收紧规则和超限 close/poison 默认已冻结。 +9. 当前批准 backend 的 binary operation/profile 映射已实现;其他 backend 必须复用相同 + operation 常量、profile 收紧规则和超限 close/poison 默认。 10. error queue 的未来 peek/clear operation 仍待独立设计;R1.3 timing 默认固定为 `before_and_after`,未知能力不得增加 skip 分支。 @@ -2247,7 +2265,7 @@ fixture、版本门和实机验收后,才能在正式 descriptor 中声明新 2. **capability/descriptor**:`ScopeDescriptorExtensions` 字段、中央 `CAPABILITY_METHODS` 映射和各 required Protocol 已实现;缺失 profile/method 时在零 I/O 阶段拒绝,额外方法不产生隐式 capability。 -3. **numeric and deadline constants**:截图 `262144/262144/1/0`、trace +3. **numeric and deadline constants**:截图 `8388608/8388608/1/0`、trace `8388608/67108864/256/65536`、operation timeout `5000/30000/60000 ms` 已作为核心常量 实现;profile/connection 只能收紧,超出同步界限统一 close + `poisoned`。 4. **error timing**:默认 `before_and_after`、recovery 固定 `disabled`、每次 I/O 受绝对 @@ -2263,7 +2281,8 @@ fixture、版本门和实机验收后,才能在正式 descriptor 中声明新 nonce 按 `fresh -> passed_to_main -> restore_attempted -> consumed` 一次性消费,重放在 I/O 前拒绝,artifact 只留摘要。 - **identity proof**:`ScopeAcquisitionControlProfile.identity_semantics` 必须为 - `unique_within_session_epoch` 才能使用 `identity_delta`;否则只能使用完整 state transition。 + `unique_within_session_epoch` 才能使用 `identity_delta`;该条件只约束 identity proof。 + `state_transition` 和 RFC-0009 的 profile-gated terminal STOP proof 仍按各自条件校验。 - **phase API bridge**:核心通过 `ScopeOperationContextCoordinator.authorize_phase()` 包裹 当前 normal gate 与 `SessionTransactionCoordinator.authorize()`,并用 sidecar/扩展记录绑定 context、phase、fields、allowed I/O、deadline 和 max steps;driver 不接收 session token。 diff --git "a/docs/project/rfcs/WaveBench_scope\351\200\232\347\224\250\346\211\251\345\261\225\346\216\245\345\217\243RFC_\346\240\270\345\277\203\345\256\236\346\226\275\350\257\264\346\230\216.md" "b/docs/project/rfcs/WaveBench_scope\351\200\232\347\224\250\346\211\251\345\261\225\346\216\245\345\217\243RFC_\346\240\270\345\277\203\345\256\236\346\226\275\350\257\264\346\230\216.md" index af1c46a..a3ebfe1 100644 --- "a/docs/project/rfcs/WaveBench_scope\351\200\232\347\224\250\346\211\251\345\261\225\346\216\245\345\217\243RFC_\346\240\270\345\277\203\345\256\236\346\226\275\350\257\264\346\230\216.md" +++ "b/docs/project/rfcs/WaveBench_scope\351\200\232\347\224\250\346\211\251\345\261\225\346\216\245\345\217\243RFC_\346\240\270\345\277\203\345\256\236\346\226\275\350\257\264\346\230\216.md" @@ -8,7 +8,7 @@ 本文记录 R1.3 在核心中的实际边界。公共合同已经进入开发线,但这不表示任何现有插件自动获得 新能力,也不构成未执行实机验收的型号覆盖声明。 -## 已发布的核心合同 +## 核心已实现的公共合同 - `ScopeDescriptorExtensions`、截图、采集控制、trace、错误策略和恢复模型已从 `wavebench.instruments` 导出; diff --git "a/docs/project/rfcs/WaveBench_\346\240\207\345\207\206\346\263\242\345\275\242\346\234\211\347\225\214\344\272\214\350\277\233\345\210\266\344\274\240\350\276\223RFC.md" "b/docs/project/rfcs/WaveBench_\346\240\207\345\207\206\346\263\242\345\275\242\346\234\211\347\225\214\344\272\214\350\277\233\345\210\266\344\274\240\350\276\223RFC.md" new file mode 100644 index 0000000..6d83cbb --- /dev/null +++ "b/docs/project/rfcs/WaveBench_\346\240\207\345\207\206\346\263\242\345\275\242\346\234\211\347\225\214\344\272\214\350\277\233\345\210\266\344\274\240\350\276\223RFC.md" @@ -0,0 +1,483 @@ +# WaveBench 标准波形有界二进制传输 RFC + +> 状态:`Implemented R1(未发布)` +> 修订:`R3` +> 核心基线:WaveBench `0.8.24`,`master@dc7ce5b` +> 相关规范:[scope 通用扩展接口 RFC](WaveBench_scope通用扩展接口RFC.md)、[transport 重放与 session 健康 RFC](WaveBench_transport重放与session健康RFC.md) +> 证据来源:WaveBench Instrument Plugins 中的 MSO8000 `RFC-0008` +> 外部验收:[MSO8104 受控实机验收记录](https://github.com/Scaxlibur/wavebench-instrument-plugins/blob/5a760c954f75dc69909bfde04cb5cd7837364ab3/packages/wavebench-rigol-mso8000/doc/MSO8104_HARDWARE_ACCEPTANCE.md) +> 编号裁决:[scope 可移植性 RFC-0008](WaveBench_scope可移植性RFC-0008_有界波形传输裁决.md) +> 目标版本:包含本实现的下一个 `0.8.x` 发布;外部插件在该版本发布前不得提高版本下限或声明新范围 + +## 摘要 + +WaveBench `0.8.24` 已有 `query_binary()`、精确 transport trailing、四维 binary budget、 +operation context 和 session poison 合同。这些能力已用于 R1.3 screenshot 和 trace +operation,但标准 `scope.fetch_waveform`、`scope.capture_waveform` 和 +`scope.capture_waveforms` 仍直接调用 driver 的 legacy 方法。该路径没有 binary operation +context,无法为每个响应声明精确 trailing,也无法在发送前安装单响应、操作 +总量、查询次数和重同步预算。 + +本 RFC 记录标准波形 Service 在 descriptor 显式 opt-in 时复用已有 bounded binary +context。不给 `query_bin_block()` 增加 `expect_termination`、`max_bytes` 或其他平行 +关键字;不改动没有 opt-in 的旧插件和内建 driver。 + +本文记录核心合同和实施边界。核心实现已在当前分支完成,但不表示外部插件已经完成 +conformance 或实机验收;在 P4 前,插件不得据此恢复波形或采集 capability。 + +## 问题与证据边界 + +MSO8104 在 WaveBench `0.8.24`、LAN/PyVISA、固件 `00.02.02` 上已返回有效的 +10 字段 preamble,其中声明 `1000` 个 BYTE 点。后续 `:WAVeform:DATA?` 在 legacy +`query_bin_block()` 中等待 PyVISA 默认终止语并超时。核心无法证明响应边界,因此 +把 session 标记为 `poisoned`,并在发送前拒绝 driver 后续的 transfer-state restore。 + +该次 legacy 超时只支持以下结论: + +- legacy 路径无法表达该型号、固件和 transport 组合的精确 trailing; +- 仅凭超时不能判断 payload 后是空、`LF`、其他字节或读取设置不匹配; +- 增加 timeout 不能证明同步,也不能把失败查询变成可重放查询; +- 全局关闭 termination 等待会改变其他 RIGOL 和 R&S driver 的已有行为; +- capability 只能在新核心合同发布且该型号重新完成实机验收后恢复。 + +后续受控 bounded 读取为同一明确组合提供了 `DEFINITE_BLOCK + LF` 证据。该结果不证明 +MSO8000 全系列、其他固件、USB/GPIB 或其他 backend 具有相同 trailing 行为。核心 profile +必须保持精确声明,不能从型号名称或单次成功读取外推。 + +## 当前核心基线 + +| 范围 | `0.8.24` 现状 | 本 RFC 的处理 | +| --- | --- | --- | +| `BinaryQueryResult` | 已校验 framing、声明长度、consumed bytes、精确 trailing 和 `synchronization=proven` | 直接复用 | +| `BinaryQueryLedger` | 已管理 response、operation total、query count、resynchronization 和 deadline | 直接复用 | +| `GuardedAuditedTransport.query_binary()` | 必须在带 opaque budget 的有效 phase 中调用 | 作为 bounded 路径的唯一 binary 入口 | +| `ScopeOperationContextCoordinator` | 已在 R1.3 操作中生成单一 ledger 并顺序授权 phase | 扩展到 opt-in 的标准波形操作 | +| `scope.fetch_trace` | 已使用 bounded binary 和 core-owned transfer baseline | 作为实施参考,不作为标准 capture 的替代品 | +| 标准 waveform/capture | Service 直接调用 legacy driver 方法 | 只在 descriptor opt-in 后使用 bounded 路径 | +| `query_bin_block()` | 在无 budget 的 legacy 操作中保持旧行为;在已安装 budget 的 phase 中发送前拒绝 | 保持该边界,不新增平行配置 | + +R1.3 文档曾描述 legacy `query_bin_block()` 在新 operation 内消耗同一 ledger。当前安全边界是在 +budget phase 中拒绝 legacy 入口;R1.3 的历史候选描述已同步为该规则,避免两份公开合同冲突。 + +## 目标 + +- 让 opt-in 的标准 `fetch` 和 `capture` 在发送 binary query 前获得有限 budget。 +- 让 descriptor 精确声明 definite block 后的 transport trailing bytes。 +- 让单通道、多通道和分块传输共享一个 operation-total 和 query-count ledger。 +- 让 core-owned baseline 在主读取失败时仍可用,并保留主异常的优先级。 +- 保持旧插件、内建 driver、CLI、run plan、成功返回对象和旧 artifact 的兼容行为。 +- 在 capability 恢复前分别完成核心离线合同、插件 conformance 和受控实机验收。 + +## 非目标 + +- 不删除或改名现有 `scope.fetch_waveform`、`scope.capture_waveform` 和 + `scope.capture_waveforms` capability。 +- 不把 `scope.fetch_trace` 适配成标准 capture;它不承担现有 acquisition、多通道 + partial result 和采集包语义。 +- 不开放 raw SCPI、backend session、调用方自定义 terminator 或 parser callback。 +- 不为 `query_bin_block()` 增加仪器特例、大小预算或 trailing 关键字。 +- 不在本 RFC 中实现自动重连、断点续传或 binary continuation token。 +- 不从一个 MSO8104 实机证据外推其他型号、固件或 transport。 +- 不在核心合同发布前修改任何外部插件的 wheel/descriptor 版本下限。 + +## 已实现合同 + +### 1. Legacy 边界 + +`InstrumentTransport.query_bin_block()` 保持当前签名和行为: + +```python +def query_bin_block( + self, + command: str, + *, + replay: ReplayPolicy = ReplayPolicy.NO_REPLAY, +) -> bytes: ... +``` + +强制规则: + +1. 无 bounded profile 的旧 operation 仍可调用该方法; +2. active phase 含 `BinaryQueryBudget` 时,该方法必须在发送前以 + `binary_legacy_entry_unsupported` 拒绝; +3. 核心不得在捕获 `TypeError` 后删除新关键字并回退到 legacy 调用; +4. driver 声明 bounded profile 后仍调用 `query_bin_block()` 属于合同违反,不得自动降级。 + +### 2. Descriptor profile + +公共模型为: + +```python +@dataclass(frozen=True, slots=True) +class ScopeWaveformBinaryOperationProfile: + operation_kind: Literal["fetch", "capture_single", "capture_multiple"] + response_max_bytes: int + operation_max_bytes: int + query_max_count: int + resynchronization_max_bytes: int + restore_order: tuple[ScopeWaveformTransferField, ...] + snapshot_max_steps: int + restore_max_steps: int + verify_max_steps: int + +@dataclass(frozen=True, slots=True) +class ScopeWaveformBinaryProfile: + operations: tuple[ScopeWaveformBinaryOperationProfile, ...] + framing: BinaryResponseFraming = BinaryResponseFraming.DEFINITE_BLOCK + transport_trailing_hex: str = "" + +@dataclass(frozen=True, slots=True) +class ScopeDescriptorExtensions: + # 保留已有字段与位置顺序。 + waveform_binary_profile: ScopeWaveformBinaryProfile | None = None +``` + +profile 规则: + +- `waveform_binary_profile is None` 表示使用旧路径,不表示默认空 trailing; +- profile 仅适用于 descriptor 已声明的标准波形 capability,不产生隐式 capability; +- 首版只接受 `DEFINITE_BLOCK`,不把 timeout、换行或暂时无数据解释为 message boundary; +- core 将该 framing 绑定到 opaque binary ledger;driver 请求其他 framing 时在发送前拒绝; +- `transport_trailing_hex` 是小写、偶数长度、最长 16 bytes 的精确序列;空字符串只 + 表示已验证的空 trailing,不表示「不检查」; +- `operations` 中每个 `operation_kind` 最多出现一次,并与 descriptor 已声明的 capability + 精确对应;`fetch`、`capture_single` 和 `capture_multiple` 不能互相推导; +- 每个 operation profile 的 response、operation-total 和 query-count 上限必须是有限 + 正整数,`resynchronization_max_bytes` 必须是有限的非 bool 非负整数; +- 每个 operation profile 的 `restore_order` 必须唯一,且只覆盖该操作可能改动的 + transfer fields。不得因 `capture_multiple` 会改动 run state,就让纯 `fetch` 执行多余的 + run-state restore 写入; +- 声明 `waveform_binary_profile` 的 descriptor `wavebench_min_version` 必须为 `0.8.24` 或更高版本; + 外部 wheel 的 `Requires-Dist` 也必须使用相同或更高的下限; +- core `OperationSpec`、descriptor profile 和 connection limit 三者取逐项最小值;profile 不能 + 扩大核心硬上限; +- bounded profile 只能与核心已验证的 backend/resource 能力组合使用。第三方 transport 仅 + 实现公共 `query_binary()` 不足以证明它在读取前获得 trailing 和 resynchronization + 上限;没有明确 bounded-backend 能力时必须在 binary command 发送前拒绝; +- 没有有效 profile 或必需方法时,bounded driver 必须在第一次仪器 I/O 前 fail + closed。该保证需要下文的 opt-in factory construction barrier,不能只依赖 factory 返回后的 + capability validator。 + +`ScopeWaveformTransferField` 是标准 waveform 的独立恢复字段集: + +```text +scope.run_state +scope.acquisition +scope.trigger +scope.timebase +scope.channel_display +scope.channel_vertical +scope.waveform_source +scope.waveform_mode +scope.query_response_header +scope.waveform_format +scope.waveform_byte_order +scope.waveform_points +scope.waveform_transfer_window +``` + +`ScopeWaveformTransfer*` 是与 `ScopeTraceTransfer*` 分离的冻结 snapshot、baseline、restore-result +和 verification 模型;现有 trace 类型的导入、类型身份和语义不变。`fetch` 可以只声明实际会改动的 +字段;`capture_single` 和 `capture_multiple` 必须覆盖上述完整字段集,确保 time range、vertical scale、 +采集和 transfer setup 不会在成功后遗留仪器状态。 + +### 3. Driver 与恢复边界 + +bounded profile 路径保留现有 capability 名称,但不修改已发布的 `ScopeDriver` 方法参数。 +核心根据 profile 使用独立、可选的 driver Protocol: + +```python +class ScopeWaveformTransferRecoveryDriver(Protocol): + def snapshot_waveform_transfer_state( + self, + fields: tuple[ScopeWaveformTransferField, ...], + ) -> ScopeWaveformTransferStateSnapshot: ... + + def restore_waveform_transfer_state( + self, + baseline: ScopeWaveformTransferBaseline, + ) -> ScopeWaveformTransferRestoreResult: ... + + def verify_waveform_transfer_state_restored( + self, + baseline: ScopeWaveformTransferBaseline, + ) -> ScopeWaveformTransferStateSnapshot: ... + +class ScopeBoundedWaveformFetchDriver( + ScopeWaveformTransferRecoveryDriver, + Protocol, +): + def fetch_waveform_bounded( + self, + channel: int, + points: str, + *, + baseline: ScopeWaveformTransferBaseline, + ) -> WaveformData: ... + +class ScopeBoundedWaveformCaptureDriver( + ScopeWaveformTransferRecoveryDriver, + Protocol, +): + def capture_waveform_bounded( + self, + channel: int, + points: str, + *, + time_range_s: float | None, + vertical_scale_v_per_div: float | None, + baseline: ScopeWaveformTransferBaseline, + ) -> WaveformData: ... + +class ScopeBoundedMultiWaveformCaptureDriver( + ScopeWaveformTransferRecoveryDriver, + Protocol, +): + def capture_waveforms_bounded( + self, + channels: list[int], + points: str, + *, + time_range_s: float | None, + vertical_scale_v_per_div: float | None, + on_channel_start: Callable[[int | None], None] | None, + on_waveform: Callable[[int, WaveformData], None] | None, + baseline: ScopeWaveformTransferBaseline, + ) -> dict[int, WaveformData]: ... +``` + +上述方法不加入所有 `ScopeDriver` 的必需方法集。只有 descriptor profile 非空时, +factory 才使用专用 validator 检查已声明 capability 对应的 bounded 方法和全部恢复方法。 +旧 `fetch_waveform()`、`capture_waveform()` 和 `capture_waveforms()` 签名保持不变;旧 driver 和 fake +不需要补空实现。profile-specific method mapping 已作为 `wavebench.instrument.v2` 的 additive +extension 冻结:只有 profile 非空的 descriptor 才改用新方法集。 + +validator 的候选分流为: + +| capability | profile 为 `None` | profile 非空 | +| --- | --- | --- | +| `scope.fetch_waveform` | 要求 `fetch_waveform()` | 要求 `fetch_waveform_bounded()` 和恢复方法 | +| `scope.capture_waveform` | 要求 `capture_waveform()` | 要求 `capture_waveform_bounded()` 和恢复方法 | +| `scope.capture_waveforms` | 要求 `capture_waveforms()` | 要求 `capture_waveforms_bounded()` 和恢复方法 | + +新核心不先按旧 `CAPABILITY_METHODS` 拒绝 opt-in driver,再尝试 bounded validator。这三项必须由 +同一 profile-aware validator 一次裁决;其他 capability 继续使用现有全局映射。额外方法不产生隐式 +capability。 + +bounded driver 的强制规则: + +1. 主方法使用 `query_binary(framing=DEFINITE_BLOCK, max_bytes=expected_payload_bytes, + replay=NO_REPLAY)`;driver 不传入 trailing 或 resynchronization 参数; +2. preamble、分块边界和样本换算仍属于 driver,transport 只解析 framing 和字节边界; +3. 主方法不再在 `finally` 中自行 restore。核心在独立 phase 中持有 baseline、调用 restore 并 + 执行 fresh verify; +4. 单通道、多通道和所有 application chunk 必须共用同一 context、baseline、deadline 和 + binary ledger; +5. driver 无权构造、替换或持久化 baseline nonce,也无权重置 budget; +6. bounded 方法不接收 `check_errors`;错误检查只由 core R1.3 executor 管理,避免 core 和 + driver 双重消耗错误队列。 + +当配置要求错误检查时,bounded profile 路径必须声明并实现 `scope.error_drain_v1`。 +未声明该 capability 的 driver 只能在有效策略为 `disabled` 时执行;核心不使用 legacy +`scope.errors` 伪造类型化 drain 证据。 + +opt-in factory 使用 construction barrier。当 descriptor profile 非空时, +`DriverContext.open_transport()` 可以建立 transport 和 session,但在 factory 返回、bounded Protocol/ +profile 验证和 backend/resource 能力验证完成前,guarded transport 必须以 +`factory_construction_pending` 在发送前拒绝全部仪器 I/O。验证 +失败时关闭已打开的 transport,不发送 IDN 或其他探测命令。该 barrier 只对 opt-in +descriptor 生效,不改变旧插件 factory 的已发布行为。首版只接受核心 PyVISA 或 +RsInstrument 的可证明 VISA `INSTR` bounded-binary 路径;Serial、SocketIO 和第三方 duck transport +在 factory 验证阶段拒绝。 + +### 4. Service 编排 + +`ScopeService` 根据 descriptor profile 选择路径: + +| 操作 | 无 bounded profile | 有 bounded profile | +| --- | --- | --- | +| `fetch_waveform` | 保持当前直接 driver 调用 | `preflight snapshot -> error_before? -> main -> error_after? -> restore -> fresh verify` | +| `capture_waveform` | 保持当前 driver-owned transaction | 同一 context 内完成 acquisition 和有界波形读取,再 restore/verify transfer state | +| `capture_waveforms` | 保持当前多通道部分结果语义 | 一次 acquisition、一个 baseline、一个 ledger;逐通道读取不重建 context;提供 callback 时每个请求通道恰好回调一次,且回调 waveform 必须与最终 map 一致 | + +路径选择必须发生在旧 `scope.errors` 前置 capability gate 之前。legacy 分支继续把 +`ScopeConfig.check_errors=true` 映射为对 `scope.errors` 的要求,并保持 driver-owned 查错。bounded +分支不要求 `scope.errors`,而是固定映射: + +```text +ScopeConfig.check_errors = true -> ErrorCheckSpec(policy="required") +ScopeConfig.check_errors = false -> ErrorCheckSpec(policy="disabled") +``` + +bounded 分支的 `required` 必须由 `scope.error_drain_v1` 满足;无该 capability 时在 before-drain 和 +主操作 I/O 前拒绝。首版不从插件或 profile 接受另一个默认策略,也不用同时声明的 +legacy `scope.errors` 代替 typed drain。 + +标准 `OperationSpec` 的公开 operation 名、capability 要求、effect、changed fields 和成功 +返回对象保持不变。bounded executor 使用私有 overlay,固定 60 秒 operation deadline,并冻结 +`8 MiB / 64 MiB / 256 queries / 64 KiB resynchronization` 的四维 core ceiling。该 ceiling +只作用于 profile opt-in 路径,不能改变 DS1104/DS1000Z、RTM2032 或其他 legacy driver 的行为; +descriptor 只能收紧,不能扩大。无法分块且超过单响应上限的未来设备需要新的兼容性与内存证据。 + +### 5. 失败、poison 与异常优先级 + +强制状态规则: + +1. binary query 未证明同步或已失步时,session 进入 `poisoned`,binary ledger 失效; +2. `poisoned` 后不签发 restore 或 verification authorization,不向 backend 发送 STOP、回写、 + IDN 或探测查询; +3. transport 主异常必须保留为最终异常的 primary cause;「因 session 已 poisoned 而未恢复」 + 只进入 cleanup diagnostics,不能覆盖 `TransportIOError`; +4. binary response 已证明边界,但 payload 长度、preamble、数值换算或 application chunk 校验 + 失败时,核心仍执行有界 restore 和 fresh verify; +5. 主操作成功但 restore/verify 失败时,不返回波形成功值;session 按已有 health + 合同保持 `uncertain` 或 `poisoned`; +6. 失败后不自动重连、不重放 binary query、不从中间 chunk 继续。 + +## 兼容性合同 + +### 核心与插件组合 + +| 组合 | 预期行为 | 拒绝点或证据 | +| --- | --- | --- | +| 旧核心 + 旧插件 | 保持原版行为 | 不读取新 profile | +| 新核心 + 旧插件 | profile 为 `None`,继续调用 legacy driver 方法 | 现有命令序列、参数和返回对象不变 | +| 旧核心 + 新 bounded 插件 | 正常安装被 wheel `Requires-Dist` 拒绝;强行安装后不允许仪器 I/O | 旧核心可能在 entry-point 构造新 slotted descriptor 字段时直接报 plugin-load error,未必能进入 descriptor 版本比较 | +| 新核心 + 新 bounded 插件 | 使用 profile、context、`query_binary()` 和 core-owned recovery | conformance 与实机验收通过后才声明 capability | + +四组表描述已按 `wavebench.instrument.v2` additive extension 实现。无论是 wheel 依赖拒绝, +还是强制安装后的 descriptor load error,旧核心组合都不得进行仪器 I/O。 + +### 旧 driver、transport 和 fake + +- 现有 DS1104/DS1000Z 和 RTM2032 descriptor 不自动增加 profile,首个核心实现不得改变它们的 + binary 读取、float-list 读取或 screenshot 行为。 +- 旧 duck-typed transport 和 fake 只在 descriptor opt-in 后才需要 `query_binary()` 和新 baseline 方法。 + 没有 opt-in 的旧测试不传入新关键字。 +- 新核心读取 profile 时使用明确的可选字段语义;不得把旧 descriptor 中缺少该字段 + 解释为已验证的空 trailing。 +- 已 opt-in 但 backend 没有 `query_binary()`、无法执行有界 definite-block 读取,或不支持该 + resource class 时,必须在 binary command 发送前返回结构化错误;不回退到 + `query_bin_block()`。 +- `InstrumentTransport` 保留 `query_bin_block()` 和已有 `replay` 关键字。本 RFC 不删除方法, + 也不改变旧调用的默认重放策略。 + +### 公共调用面 + +- CLI 命令、run plan step、`ScopeService.fetch_waveform()`、`CaptureResult`、 + `MultiCaptureResult` 和 `WaveformData` 的公共形状保持不变。 +- capability 名称保持不变;profile 只选择实现合同,不产生 `_v2` 或 `_bounded` + 平行 capability。 +- 成功返回不改为 `ScopeExtensionOperationResult`。phase、budget 和 cleanup 证据使用现有 + context diagnostics;若后续加入 capture metadata,只能以可选的 additive 字段进入,并为旧 + reader 保留回归测试。 +- 不根据 profile 改变 operation effect、access policy、lease mode 或高阻安全门。 + +### 版本和 API + +- `ScopeDescriptorExtensions` 的新字段放在字段末尾,保留现有位置参数顺序。 +- 已发布的 `ScopeDriver` 方法名、参数和返回 model 保持不变。`ScopeWaveformBinaryProfile` 与独立 + bounded waveform Protocol 组是 `wavebench.instrument.v2` 的 additive extension;profile 为 + `None` 时始终保留旧方法集。 +- 首个包含完整合同的核心版本发布后,opt-in 插件必须同步提高 wheel 和 descriptor + 下限,并重新评审 `<0.9` 上限。 +- 正常安装中,wheel 依赖先拒绝旧核心。如果使用不安全方式强制安装,旧核心可以在新 + slotted descriptor 字段的构造期返回稳定 plugin-load error;不强求它在无法构造新对象 + 时继续执行 descriptor 版本比较,但必须保持零仪器 I/O。 +- 新核心中任何缺 profile、缺 bounded Protocol 方法、backend/resource 不支持或版本不兼容的 + opt-in 组合,都必须由 construction barrier 和静态 validator 在第一次仪器 I/O 前拒绝。 + +## 验收矩阵 + +### 核心离线合同 + +| 范围 | 必测内容 | +| --- | --- | +| profile | `None` legacy 路径、精确空/非空 trailing、整数上限、字段顺序、缺方法零 I/O 拒绝 | +| factory barrier | opt-in factory 可打开 transport 但在 post-factory validator 完成前的 query/write/binary 全部零发送;验证失败关闭 transport | +| framing | 空 trailing、`LF`、`CRLF`、缺失 trailing、额外 trailing、malformed header、截断 payload | +| budget | per-response、operation-total、query-count、resynchronization、deadline;跨 chunk/phase/channel 不重置 | +| no replay | 发送后失败只有一次 binary command;不从中间 chunk 继续 | +| recovery | 已证明同步的数据错误执行 restore + fresh verify;restore 失败不返回成功 | +| poison | 失步后 backend 收到零条 restore/verify 仪器命令;原 `TransportIOError` 保持 primary | +| multiple | 一次 acquisition、一个 context、一个 ledger,保留现有 partial result/callback 顺序;每个通道只允许一次 waveform callback,且 callback 与最终返回 map 必须一致 | +| errors | 旧 `check_errors` bool 分别映射为 `required/disabled`;路径选择在 legacy `scope.errors` gate 前完成;`core_v1` 唯一执行者;不双重 drain | +| audit | framing、声明/消费字节、trailing 长度、budget 前后摘要、session health、cleanup 原因 | + +### 兼容性验收 + +1. 旧 descriptor 在新核心下发出与基线一致的 driver 调用,不多传 `baseline` 或新 transport + 关键字。 +2. 旧 duck-typed fake 没有 `query_binary()` 时,非 opt-in 测试仍通过。 +3. 新 descriptor 在旧核心下由 wheel 依赖门拒绝;强制安装时允许在新 slotted field + 构造期返回 plugin-load error,但不允许仪器 I/O。 +4. 内建 DS1104/DS1000Z 的分块读取、RTM2032 的 float-list 读取和旧 screenshot 行为保持回归。 +5. CLI text/JSON、run plan、`WaveformData`、capture 目录和旧 metadata reader 保持兼容。 +6. capability discovery 不因 profile 存在而新增未声明 capability,也不因 driver 方法存在而自动 + opt-in。 +7. backend/resource 不支持 bounded contract 时在 binary command 发送前拒绝,session 保持 + `healthy`。 +8. `capture_waveforms_bounded()` 保留 `on_channel_start` 和 `on_waveform` 回调、调用顺序和已完成通道 + 的 partial artifact;cleanup 失败不删除已生成证据。 +9. `check_errors=true` 的 bounded descriptor 只声明 `scope.error_drain_v1` 也能进入 core executor, + 不会先被 legacy `scope.errors` capability gate 拒绝。 + +### 插件与实机验收 + +一个插件恢复标准波形 capability 前至少需要: + +1. descriptor profile、bounded driver 合同、包检查和新旧核心组合测试通过; +2. 对声明的每个型号、固件和 resource/backend 组合单独证明 trailing; +3. 单通道 `DEF` 的 payload 长度、X/Y 换算和已知信号闭环在阈值内; +4. transfer state restore 由独立 fresh readback 证明; +5. 单通道、多通道、分块长记录、总预算和 no-replay 按顺序验收; +6. 涉及外部 source 时,每步前后独立确认输出关闭,不用 scope session 的健康状态 + 代替 source 状态证据。 + +## 实施里程碑 + +| 里程碑 | 范围 | 退出条件 | +| --- | --- | --- | +| P0 | 已完成:冻结 core ceiling、独立 waveform recovery 模型、V2 additive 分流和错误检查映射 | 不再保留核心 API 未决项 | +| P1 | 已完成:profile、descriptor validation、opt-in factory construction barrier 和 transfer recovery 合同 | 模型、版本组合、factory 零 I/O 与失败 close 测试覆盖 | +| P2 | 已完成:标准 Service 接入 operation context、独立 bounded Protocol、typed error executor、同一 ledger 和 core-owned recovery | 单通道、多通道、callback、主失败、cleanup 和 poison 故障注入覆盖 | +| P3 | 已完成:可信 backend/resource gate、legacy 分流、CLI/run/artifact 兼容回归 | 聚焦测试、完整离线回归、Ruff、文档规则和 diff 检查通过后可随核心发布 | +| P4 | 在核心发布后由外部插件单独 opt-in 并执行受控实机验收 | 插件提高版本门,只恢复已完成验收的 capability | + +P0–P3 不修改外部插件仓库;P4 不属于本核心分支的自动延伸。本文所称「已完成」仅指核心 +离线实现,未包含插件版本门、硬件连接或实机验收。 + +## 已否决方案 + +- 向 `query_bin_block()` 增加 `expect_termination: bool`:无法表达精确非空 trailing、总预算、 + query count 或 resynchronization,并形成第二套 binary 合同。 +- 向 `query_binary()` 公开 `transport_trailing` 关键字:会让 driver 在 operation 中临时放宽 + descriptor 和 budget 的静态边界。 +- 全局关闭 PyVISA termination 等待:会改变未 opt-in driver 的边界并可能遗留字节。 +- 插件直接访问 PyVISA/RsInstrument session:绕过 lease、access、audit、deadline 和 session + health。 +- 把 `fetch_trace` 无条件转成 `WaveformData`:不能覆盖现有 capture acquisition、多通道部分结果和 + 采集包合同。 +- 在 profile 路径失败后回退 legacy:无法证明前一条 binary response 的边界,也会隐藏 + conformance 缺口。 +- 新增 `_v2` 或 `_bounded` capability:标准实验动作不变,平行 capability 会让 CLI、 + run plan 和插件发现承担双重语义。本 RFC 使用显式 profile 和可选的 bounded + driver Protocol 选择更强的实现合同;Protocol 方法名不是新 capability。 + +## 已冻结的实现决定与剩余边界 + +1. 标准 waveform core ceiling 为 `8 MiB / 64 MiB / 256 / 64 KiB`,只约束 profile opt-in 路径。 +2. `ScopeWaveformTransfer*` 是独立于 `ScopeTraceTransfer*` 的恢复模型;旧 trace import 和类型身份 + 保持不变,capture profile 必须声明完整恢复闭包。 +3. profile-specific bounded waveform Protocol 组属于 `wavebench.instrument.v2` additive extension;旧 + `ScopeDriver` 方法不增加参数。 +4. `fetch_waveform()` 仍只返回 `WaveformData`。失败时 context、budget 和 cleanup 证据附加到结构化 + exception diagnostics,不复制命令或 payload。 +5. bounded main phase 只允许 `query_binary()`;`query_bin_block()` 在发送前以 + `binary_legacy_entry_unsupported` 拒绝。 +6. construction barrier 在 profile 非空的 factory 中使用稳定错误码 + `factory_construction_pending`;验证失败关闭 transport,且不发送仪器命令。 +7. `ScopeConfig.check_errors=true` 固定要求 `scope.error_drain_v1`,`false` 固定禁用 typed drain; + 该分流发生在 legacy `scope.errors` capability gate 前。 +8. P4 仍需由插件单独提高版本门、完成 conformance 和受控实机验收。MSO8000 的 `LF` trailing、 + 分块预算、双通道和恢复证据不能由本核心离线实现替代。 diff --git a/src/wavebench/cli.py b/src/wavebench/cli.py index 2299535..5940d98 100644 --- a/src/wavebench/cli.py +++ b/src/wavebench/cli.py @@ -58,8 +58,10 @@ _print_run_preflight, _print_scope_acquisition_status, _print_scope_average_capture, + _print_scope_channel_input_state, _print_scope_history_timestamps, _print_scope_digital_status, + _print_scope_digital_status_v2, _print_scope_digital_waveform, _print_scope_measurement_statistics, _print_scope_cursor_readout, @@ -1700,6 +1702,14 @@ def _main(argv: list[str] | None = None) -> int: else: _print_scope_snapshot(result) return 0 + if args.command == "channel-input-state": + channel = args.channel or service.config.scope.default_channel + result = service.channel_input_state_v2(channel) + if args.json: + _emit_json_result(_json_payload(result)) + else: + _print_scope_channel_input_state(result) + return 0 if args.command == "acquisition-status": _print_scope_acquisition_status(service.acquisition_status()) return 0 @@ -1720,6 +1730,13 @@ def _main(argv: list[str] | None = None) -> int: if args.command == "digital-status": _print_scope_digital_status(service.digital_status(channel=args.channel)) return 0 + if args.command == "digital-status-v2": + result = service.digital_status_v2(channel=args.channel) + if args.json: + _emit_json_result(_json_payload(result)) + else: + _print_scope_digital_status_v2(result) + return 0 if args.command == "digital-waveform": output_path = None if args.output: diff --git a/src/wavebench/cli_output.py b/src/wavebench/cli_output.py index 70814b0..38cc75d 100644 --- a/src/wavebench/cli_output.py +++ b/src/wavebench/cli_output.py @@ -28,11 +28,13 @@ PowerStatus, ScopeAcquisitionStatus, ScopeAverageCaptureResult, + ScopeChannelInputStateV2, ScopeHistoryTimestamps, ScopeMeasurementStatistics, ScopeCursorReadout, ScopeDerivedWaveformMetadata, ScopeDigitalChannelStatus, + ScopeDigitalChannelStatusV2, ScopeDigitalWaveform, ScopeFftStatus, ScopeSnapshot, @@ -443,6 +445,15 @@ def scalar(value: object) -> str: print(f"segmented.available={scalar(status.segments_available)}") +def _print_scope_channel_input_state(state: ScopeChannelInputStateV2) -> None: + impedance = "n/a" if state.impedance_ohm is None else f"{state.impedance_ohm:.12g}" + print(f"input.channel={state.channel}") + print(f"input.coupling={state.coupling}") + print(f"input.termination={state.termination}") + print(f"input.impedance_ohm={impedance}") + print("input.unavailable_fields=" + (",".join(state.unavailable_fields) or "none")) + + def _print_scope_average_capture(result: ScopeAverageCaptureResult) -> None: print("average.channels=" + ",".join(str(channel) for channel in result.request.channels)) print(f"average.count={result.request.average_count}") @@ -479,6 +490,46 @@ def _print_scope_digital_status(status: ScopeDigitalChannelStatus) -> None: print(f"digital.label_enabled={'true' if status.label_enabled else 'false'}") +def _print_scope_digital_status_v2(status: ScopeDigitalChannelStatusV2) -> None: + def scalar(value: object) -> str: + if value is None: + return "n/a" + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, float): + return f"{value:.12g}" + return str(value) + + print(f"digital_v2.channel={status.channel}") + print(f"digital_v2.displayed={scalar(status.displayed)}") + print(f"digital_v2.position_div={scalar(status.position_div)}") + print(f"digital_v2.label={scalar(status.label)}") + print(f"digital_v2.label_enabled={scalar(status.label_enabled)}") + print(f"digital_v2.activity={scalar(status.activity)}") + print(f"digital_v2.technology={scalar(status.technology)}") + print(f"digital_v2.hysteresis={scalar(status.hysteresis)}") + if status.pod is None: + print("digital_v2.pod=n/a") + else: + print(f"digital_v2.pod.start_channel={status.pod.start_channel}") + print(f"digital_v2.pod.stop_channel={status.pod.stop_channel}") + print(f"digital_v2.pod.threshold_v={scalar(status.pod.threshold_v)}") + print(f"digital_v2.pod.threshold_scope={scalar(status.pod.threshold_scope)}") + if status.shared is None: + print("digital_v2.shared=n/a") + else: + print(f"digital_v2.shared.module_present={scalar(status.shared.module_present)}") + print( + "digital_v2.shared.timing_calibration_s=" + + scalar(status.shared.timing_calibration_s) + ) + print(f"digital_v2.shared.size={scalar(status.shared.size)}") + print( + "digital_v2.unavailable_fields=" + + (",".join(status.unavailable_fields) or "none") + ) + + def _print_scope_digital_waveform( waveform: ScopeDigitalWaveform, *, diff --git a/src/wavebench/cli_parser.py b/src/wavebench/cli_parser.py index cb23b57..6e8fbca 100644 --- a/src/wavebench/cli_parser.py +++ b/src/wavebench/cli_parser.py @@ -981,6 +981,13 @@ def build_parser() -> argparse.ArgumentParser: ) add_runtime_options(status) + channel_input_state = scope_sub.add_parser( + "channel-input-state", + help="Query typed coupling and termination state for one analog channel", + ) + channel_input_state.add_argument("--channel", type=int, default=None) + add_runtime_options(channel_input_state) + acquisition_status = scope_sub.add_parser( "acquisition-status", help="Query read-only average and segmented-acquisition state", @@ -1035,6 +1042,18 @@ def build_parser() -> argparse.ArgumentParser: ) add_runtime_options(digital_status) + digital_status_v2 = scope_sub.add_parser( + "digital-status-v2", + help="Query a portable, field-aware state for one MSO digital channel", + ) + digital_status_v2.add_argument( + "--channel", + type=int, + required=True, + help="Zero-based digital channel number (for example, 0 for D0)", + ) + add_runtime_options(digital_status_v2) + digital_waveform = scope_sub.add_parser( "digital-waveform", help="Read existing MSO digital waveforms and merge Dn into uint16 bit n", diff --git a/src/wavebench/instruments/__init__.py b/src/wavebench/instruments/__init__.py index 2d09409..279484c 100644 --- a/src/wavebench/instruments/__init__.py +++ b/src/wavebench/instruments/__init__.py @@ -12,12 +12,18 @@ ScopeAcquisitionStatusDriver, ScopeAverageCaptureDriver, ScopeAnalysisReadDriver, + ScopeChannelInputStateDriverV2, ScopeDriver, ScopeDigitalStatusDriver, + ScopeDigitalStatusDriverV2, ScopeDigitalWaveformDriver, ScopeHistoryTimestampsDriver, ScopeMeasurementStatisticsDriver, + ScopeMeasurementStatisticsDriverV2, + ScopeFftStatusDriverV2, + ScopeCursorReadoutDriverV2, ScopeSnapshotDriver, + ScopeSnapshotDriverV2, SourceAmModulationControlDriver, SourceAmModulationProfileDriver, SourceBurstControlDriver, @@ -62,9 +68,25 @@ ScopeAverageCaptureRequest, ScopeAverageCaptureResult, ScopeAverageConfiguration, + ScopeChannelInputStateFieldV2, + ScopeChannelInputStateV2, ScopeCursorReadout, + ScopeCursorReadoutFieldV2, + ScopeCursorReadoutV2, + ScopeCursorQuantity, + ScopeCursorUnit, + SCOPE_CURSOR_READOUT_V2_FIELD_ORDER, ScopeDerivedWaveformMetadata, ScopeDigitalChannelStatus, + ScopeDigitalChannelStatusV2, + ScopeDigitalActivityV2, + ScopeDigitalHysteresisV2, + ScopeDigitalPodStatusV2, + ScopeDigitalSharedStatusV2, + ScopeDigitalSizeV2, + ScopeDigitalStatusFieldV2, + ScopeDigitalTechnologyV2, + ScopeDigitalThresholdScope, ScopeDigitalWaveform, ScopeDigitalWaveformRequest, ScopeAnalogChannelSnapshot, @@ -73,12 +95,30 @@ ScopeHistoryTimestamp, ScopeHistoryTimestamps, ScopeFftStatus, + ScopeFftStatusFieldV2, + ScopeFftStatusV2, + SCOPE_FFT_STATUS_V2_FIELD_ORDER, ScopeMeasurementStatistics, + ScopeMeasurementSelector, + ScopeMeasurementSelectorMode, + ScopeMeasurementStatisticsRequestV2, + ScopeMeasurementStatisticsV2, ScopeIdentitySnapshot, + ScopeInputCoupling, + ScopeInputTermination, ScopeProbeSnapshot, ScopeSnapshot, + ScopeSnapshotFieldV2, + ScopeSnapshotV2, ScopeTimebaseSnapshot, + ScopeTimebaseSnapshotV2, ScopeWaveformMetadataSnapshot, + ScopeWaveformMetadataSnapshotV2, + ScopeHealthSnapshotV2, + ScopeAnalogChannelSnapshotV2, + ScopeProbeSnapshotV2, + ScopeTriggerSnapshotV2, + SCOPE_SNAPSHOT_V2_FIELD_ORDER, SourceAmModulationConfiguration, SourceAmModulationProfile, SourceBurstConfiguration, @@ -156,10 +196,29 @@ "ScopeAverageConfiguration", "ScopeAnalysisReadDriver", "ScopeAnalogChannelSnapshot", + "ScopeChannelInputStateDriverV2", + "ScopeChannelInputStateFieldV2", + "ScopeChannelInputStateV2", "ScopeCursorReadout", + "ScopeCursorReadoutDriverV2", + "ScopeCursorReadoutFieldV2", + "ScopeCursorReadoutV2", + "ScopeCursorQuantity", + "ScopeCursorUnit", + "SCOPE_CURSOR_READOUT_V2_FIELD_ORDER", "ScopeDerivedWaveformMetadata", "ScopeDigitalChannelStatus", + "ScopeDigitalChannelStatusV2", + "ScopeDigitalActivityV2", + "ScopeDigitalHysteresisV2", "ScopeDigitalStatusDriver", + "ScopeDigitalStatusDriverV2", + "ScopeDigitalPodStatusV2", + "ScopeDigitalSharedStatusV2", + "ScopeDigitalSizeV2", + "ScopeDigitalStatusFieldV2", + "ScopeDigitalTechnologyV2", + "ScopeDigitalThresholdScope", "ScopeDigitalWaveform", "ScopeDigitalWaveformDriver", "ScopeDigitalWaveformRequest", @@ -169,15 +228,36 @@ "ScopeHistoryTimestamp", "ScopeHistoryTimestamps", "ScopeFftStatus", + "ScopeFftStatusDriverV2", + "ScopeFftStatusFieldV2", + "ScopeFftStatusV2", + "SCOPE_FFT_STATUS_V2_FIELD_ORDER", "ScopeHistoryTimestampsDriver", "ScopeMeasurementStatistics", "ScopeMeasurementStatisticsDriver", + "ScopeMeasurementStatisticsDriverV2", + "ScopeMeasurementSelector", + "ScopeMeasurementSelectorMode", + "ScopeMeasurementStatisticsRequestV2", + "ScopeMeasurementStatisticsV2", "ScopeIdentitySnapshot", + "ScopeInputCoupling", + "ScopeInputTermination", "ScopeProbeSnapshot", "ScopeSnapshot", + "ScopeSnapshotFieldV2", + "ScopeSnapshotV2", "ScopeSnapshotDriver", + "ScopeSnapshotDriverV2", "ScopeTimebaseSnapshot", + "ScopeTimebaseSnapshotV2", "ScopeWaveformMetadataSnapshot", + "ScopeWaveformMetadataSnapshotV2", + "ScopeHealthSnapshotV2", + "ScopeAnalogChannelSnapshotV2", + "ScopeProbeSnapshotV2", + "ScopeTriggerSnapshotV2", + "SCOPE_SNAPSHOT_V2_FIELD_ORDER", "SourceBurstConfiguration", "SourceBurstControlDriver", "SourceBurstProfile", diff --git a/src/wavebench/instruments/capabilities.py b/src/wavebench/instruments/capabilities.py index 371981c..8dfe903 100644 --- a/src/wavebench/instruments/capabilities.py +++ b/src/wavebench/instruments/capabilities.py @@ -15,6 +15,15 @@ ) +_PROFILE_AWARE_WAVEFORM_CAPABILITIES = frozenset( + { + "scope.fetch_waveform", + "scope.capture_waveform", + "scope.capture_waveforms", + } +) + + CAPABILITY_METHODS: dict[str, tuple[str, ...]] = { "scope.idn": ("idn",), "scope.errors": ("errors",), @@ -117,7 +126,16 @@ def validate_declared_capabilities( ) -> None: if not callable(getattr(driver, "close", None)): raise TypeError("factory returned a driver without callable close()") + extensions = descriptor.scope_extensions + waveform_binary_profile = ( + extensions.waveform_binary_profile if extensions is not None else None + ) for capability in descriptor.capabilities: + if ( + waveform_binary_profile is not None + and capability in _PROFILE_AWARE_WAVEFORM_CAPABILITIES + ): + continue methods = CAPABILITY_METHODS.get(capability) if methods is None: raise TypeError(f"descriptor declares unknown capability {capability!r}") diff --git a/src/wavebench/instruments/contracts.py b/src/wavebench/instruments/contracts.py index fcd9c2d..e3a212d 100644 --- a/src/wavebench/instruments/contracts.py +++ b/src/wavebench/instruments/contracts.py @@ -22,15 +22,23 @@ ScopeAverageCaptureRequest, ScopeAverageCaptureResult, ScopeAcquisitionStatus, + ScopeChannelInputStateV2, ScopeHistoryTimestamps, ScopeMeasurementStatistics, + ScopeMeasurementStatisticsRequestV2, + ScopeMeasurementStatisticsV2, ScopeCursorReadout, + ScopeCursorReadoutV2, ScopeDerivedWaveformMetadata, ScopeDigitalChannelStatus, + ScopeDigitalChannelStatusV2, ScopeDigitalWaveform, ScopeDigitalWaveformRequest, ScopeFftStatus, + ScopeFftStatusV2, ScopeSnapshot, + ScopeSnapshotFieldV2, + ScopeSnapshotV2, SourceAmModulationConfiguration, SourceAmModulationProfile, SourceBurstConfiguration, @@ -117,11 +125,26 @@ class ScopeSnapshotDriver(InstrumentDriver, Protocol): def get_snapshot(self, channel: int) -> ScopeSnapshot: ... +@runtime_checkable +class ScopeSnapshotDriverV2(InstrumentDriver, Protocol): + def get_snapshot_v2( + self, + channel: int, + *, + fields: tuple[ScopeSnapshotFieldV2, ...], + ) -> ScopeSnapshotV2: ... + + @runtime_checkable class ScopeAcquisitionStatusDriver(InstrumentDriver, Protocol): def get_acquisition_status(self) -> ScopeAcquisitionStatus: ... +@runtime_checkable +class ScopeChannelInputStateDriverV2(InstrumentDriver, Protocol): + def get_channel_input_state_v2(self, channel: int) -> ScopeChannelInputStateV2: ... + + @runtime_checkable class ScopeAverageCaptureDriver(InstrumentDriver, Protocol): def capture_average( @@ -135,6 +158,11 @@ class ScopeDigitalStatusDriver(InstrumentDriver, Protocol): def get_digital_status(self, channel: int) -> ScopeDigitalChannelStatus: ... +@runtime_checkable +class ScopeDigitalStatusDriverV2(InstrumentDriver, Protocol): + def get_digital_status_v2(self, channel: int) -> ScopeDigitalChannelStatusV2: ... + + @runtime_checkable class ScopeDigitalWaveformDriver(InstrumentDriver, Protocol): def get_digital_waveform( @@ -160,6 +188,34 @@ def get_measurement_statistics( ) -> ScopeMeasurementStatistics: ... +@runtime_checkable +class ScopeMeasurementStatisticsDriverV2(InstrumentDriver, Protocol): + def get_measurement_statistics_v2( + self, + request: ScopeMeasurementStatisticsRequestV2, + ) -> ScopeMeasurementStatisticsV2: ... + + +@runtime_checkable +class ScopeFftStatusDriverV2(InstrumentDriver, Protocol): + def get_fft_status_v2( + self, + math_index: int, + *, + configured_fft: bool, + ) -> ScopeFftStatusV2: ... + + +@runtime_checkable +class ScopeCursorReadoutDriverV2(InstrumentDriver, Protocol): + def get_cursor_readout_v2( + self, + cursor_index: int | None, + *, + configured_cursor: bool, + ) -> ScopeCursorReadoutV2: ... + + @runtime_checkable class ScopeAnalysisReadDriver(InstrumentDriver, Protocol): def get_math_waveform_metadata(self, math_index: int) -> ScopeDerivedWaveformMetadata: ... diff --git a/src/wavebench/instruments/factory.py b/src/wavebench/instruments/factory.py index 8b087c2..4117db3 100644 --- a/src/wavebench/instruments/factory.py +++ b/src/wavebench/instruments/factory.py @@ -11,6 +11,7 @@ from wavebench.services.access_policy import AccessMode, normalize_access_mode from wavebench.services.resource_lease import ResourceLease, resource_fingerprint from wavebench.transport.base import InstrumentTransport +from wavebench.transport.binary import visa_binary_contract_supported from wavebench.transport.guarded import GuardedAuditedTransport from wavebench.transport.pyvisa_transport import PyVisaTransport from wavebench.transport.rsinstrument_transport import RsInstrumentTransport @@ -21,6 +22,7 @@ from .capabilities import validate_declared_capabilities from .contracts import InstrumentDriver from .registry import resolve_instrument_descriptor +from .scope_extension_capabilities import SCOPE_STRICT_V2_CAPABILITIES RSINSTRUMENT_BACKENDS = ( "rsinstrument-socket", @@ -64,6 +66,23 @@ def open_instrument_driver( driver_reference, expected_kind=expected_kind, ) + waveform_binary_profile = ( + descriptor.scope_extensions.waveform_binary_profile + if descriptor.scope_extensions is not None + else None + ) + average_capture_profile = ( + descriptor.scope_extensions.average_capture_profile_v2 + if descriptor.scope_extensions is not None + else None + ) + bounded_binary_profile_opt_in = ( + waveform_binary_profile is not None or average_capture_profile is not None + ) + strict_v2_capability_opt_in = bool( + set(descriptor.capabilities) & SCOPE_STRICT_V2_CAPABILITIES + ) + construction_latched = bounded_binary_profile_opt_in or strict_v2_capability_opt_in backend = _select_backend(configured_backend, descriptor.backends) _validate_resource_scheme(resource, descriptor.resource_schemes) try: @@ -71,7 +90,7 @@ def open_instrument_driver( except (TypeError, ValueError) as exc: raise ConfigError(f"invalid options for instrument driver {descriptor.driver_id!r}: {exc}") from exc - opened_transports: list[InstrumentTransport] = [] + opened_transports: list[GuardedAuditedTransport] = [] opened_session_state: InstrumentSessionState | None = None def open_transport() -> InstrumentTransport: @@ -104,6 +123,7 @@ def open_transport() -> InstrumentTransport: lease=lease, release_lease_on_close=lease_acquired_here, session_state=session_state, + construction_latched=construction_latched, ) opened_session_state = session_state except Exception: @@ -134,6 +154,20 @@ def open_transport() -> InstrumentTransport: try: driver = descriptor.factory(context) validate_declared_capabilities(descriptor, driver) + if bounded_binary_profile_opt_in: + if len(opened_transports) != 1: + raise ConfigError( + f"instrument driver {descriptor.driver_id!r} bounded binary profile " + "requires exactly one context transport" + ) + _validate_bounded_binary_transport( + descriptor=descriptor, + transport=opened_transports[0], + ) + opened_transports[0]._mark_bounded_binary_backend_verified() + if construction_latched: + for transport in opened_transports: + transport._release_construction_latch() except Exception as exc: _close_factory_failure(driver if "driver" in locals() else None, opened_transports) if isinstance(exc, ConfigError): @@ -283,3 +317,38 @@ def _close_factory_failure( transport.close() except Exception: pass + + +def _validate_bounded_binary_transport( + *, + descriptor: InstrumentDescriptor, + transport: GuardedAuditedTransport, +) -> None: + """Accept only core-owned backends that prove the full bounded VISA path.""" + + inner = transport.inner + raw_session: object | None = None + if isinstance(inner, PyVisaTransport): + raw_session = inner.session + elif isinstance(inner, RsInstrumentTransport): + get_session_handle = getattr(inner.session, "get_session_handle", None) + if callable(get_session_handle): + try: + raw_session = get_session_handle() + except Exception: + raw_session = None + if raw_session is None or not visa_binary_contract_supported(raw_session): + raise ConfigError( + f"instrument driver {descriptor.driver_id!r} bounded binary operation requires " + "a bounded PyVISA or RsInstrument INSTR resource" + ) + + +def _validate_waveform_binary_transport( + *, + descriptor: InstrumentDescriptor, + transport: GuardedAuditedTransport, +) -> None: + """Compatibility wrapper for the former waveform-specific internal validator.""" + + _validate_bounded_binary_transport(descriptor=descriptor, transport=transport) diff --git a/src/wavebench/instruments/models.py b/src/wavebench/instruments/models.py index 3f00e68..bbff409 100644 --- a/src/wavebench/instruments/models.py +++ b/src/wavebench/instruments/models.py @@ -3,6 +3,7 @@ from dataclasses import dataclass from datetime import datetime from math import isclose, isfinite +import re from typing import Literal import numpy as np @@ -121,6 +122,506 @@ class ScopeSnapshot: trigger: ScopeEdgeTriggerSnapshot +ScopeSnapshotFieldV2 = Literal[ + "identity.manufacturer", + "identity.model", + "identity.serial_number", + "identity.firmware", + "identity.options", + "health.status_byte", + "health.operation_condition", + "health.questionable_condition", + "health.acquisition_available", + "health.acquisition_count", + "health.sample_rate_hz", + "health.error_queue_nonempty", + "health.waiting_for_trigger", + "channel.channel", + "channel.enabled", + "channel.coupling", + "channel.range_v", + "channel.scale_v_per_div", + "channel.offset_v", + "channel.position_div", + "channel.bandwidth_hz", + "channel.polarity", + "channel.skew_s", + "channel.label", + "channel.label_enabled", + "channel.overloaded", + "channel.acquisition_type", + "timebase.acquisition_time_s", + "timebase.divisions", + "timebase.position_s", + "timebase.range_s", + "timebase.reference_percent", + "timebase.scale_s_per_div", + "timebase.roll_enabled", + "probe.channel", + "probe.attenuation_factor", + "probe.bandwidth_hz", + "probe.capacitance_f", + "probe.impedance_ohm", + "probe.name", + "probe.probe_type", + "waveform.channel", + "waveform.x_start_s", + "waveform.x_stop_s", + "waveform.points", + "waveform.values_per_sample", + "waveform.x_increment_s", + "waveform.x_origin_s", + "waveform.y_increment_v", + "waveform.y_origin_v", + "waveform.y_resolution_bits", + "trigger.trigger_type", + "trigger.source_channel", + "trigger.mode", + "trigger.slope", + "trigger.coupling", + "trigger.level_v", + "trigger.hysteresis_mode", + "trigger.holdoff_mode", + "trigger.holdoff_time_s", +] + +SCOPE_SNAPSHOT_V2_FIELD_ORDER: tuple[ScopeSnapshotFieldV2, ...] = ( + "identity.manufacturer", + "identity.model", + "identity.serial_number", + "identity.firmware", + "identity.options", + "health.status_byte", + "health.operation_condition", + "health.questionable_condition", + "health.acquisition_available", + "health.acquisition_count", + "health.sample_rate_hz", + "health.error_queue_nonempty", + "health.waiting_for_trigger", + "channel.channel", + "channel.enabled", + "channel.coupling", + "channel.range_v", + "channel.scale_v_per_div", + "channel.offset_v", + "channel.position_div", + "channel.bandwidth_hz", + "channel.polarity", + "channel.skew_s", + "channel.label", + "channel.label_enabled", + "channel.overloaded", + "channel.acquisition_type", + "timebase.acquisition_time_s", + "timebase.divisions", + "timebase.position_s", + "timebase.range_s", + "timebase.reference_percent", + "timebase.scale_s_per_div", + "timebase.roll_enabled", + "probe.channel", + "probe.attenuation_factor", + "probe.bandwidth_hz", + "probe.capacitance_f", + "probe.impedance_ohm", + "probe.name", + "probe.probe_type", + "waveform.channel", + "waveform.x_start_s", + "waveform.x_stop_s", + "waveform.points", + "waveform.values_per_sample", + "waveform.x_increment_s", + "waveform.x_origin_s", + "waveform.y_increment_v", + "waveform.y_origin_v", + "waveform.y_resolution_bits", + "trigger.trigger_type", + "trigger.source_channel", + "trigger.mode", + "trigger.slope", + "trigger.coupling", + "trigger.level_v", + "trigger.hysteresis_mode", + "trigger.holdoff_mode", + "trigger.holdoff_time_s", +) +SCOPE_SNAPSHOT_V2_FIELDS = frozenset(SCOPE_SNAPSHOT_V2_FIELD_ORDER) +_SCOPE_SNAPSHOT_V2_TOKEN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:+/-]{0,63}$") + + +def _scope_snapshot_v2_required_channel(value: object, *, label: str) -> None: + if isinstance(value, bool) or not isinstance(value, int) or value < 1: + raise ValueError(f"{label} must be a positive integer") + + +def _scope_snapshot_v2_optional_int(value: object, *, label: str) -> None: + if value is not None and (isinstance(value, bool) or not isinstance(value, int)): + raise ValueError(f"{label} must be an integer when provided") + + +def _scope_snapshot_v2_optional_float(value: object, *, label: str) -> None: + if value is not None and ( + isinstance(value, bool) or not isinstance(value, (int, float)) or not isfinite(value) + ): + raise ValueError(f"{label} must be finite when provided") + + +def _scope_snapshot_v2_optional_bool(value: object, *, label: str) -> None: + if value is not None and not isinstance(value, bool): + raise ValueError(f"{label} must be bool when provided") + + +def _scope_snapshot_v2_optional_str(value: object, *, label: str) -> None: + if value is not None and not isinstance(value, str): + raise ValueError(f"{label} must be str when provided") + + +@dataclass(frozen=True, slots=True) +class ScopeHealthSnapshotV2: + status_byte: int | None = None + operation_condition: int | None = None + questionable_condition: int | None = None + acquisition_available: int | None = None + acquisition_count: int | None = None + sample_rate_hz: float | None = None + error_queue_nonempty: bool | None = None + waiting_for_trigger: bool | None = None + + def __post_init__(self) -> None: + for label, value in ( + ("health status_byte", self.status_byte), + ("health operation_condition", self.operation_condition), + ("health questionable_condition", self.questionable_condition), + ("health acquisition_available", self.acquisition_available), + ("health acquisition_count", self.acquisition_count), + ): + _scope_snapshot_v2_optional_int(value, label=label) + _scope_snapshot_v2_optional_float(self.sample_rate_hz, label="health sample_rate_hz") + _scope_snapshot_v2_optional_bool( + self.error_queue_nonempty, + label="health error_queue_nonempty", + ) + _scope_snapshot_v2_optional_bool( + self.waiting_for_trigger, + label="health waiting_for_trigger", + ) + + +@dataclass(frozen=True, slots=True) +class ScopeAnalogChannelSnapshotV2: + channel: int + enabled: bool | None = None + coupling: str | None = None + range_v: float | None = None + scale_v_per_div: float | None = None + offset_v: float | None = None + position_div: float | None = None + bandwidth_hz: float | None = None + polarity: str | None = None + skew_s: float | None = None + label: str | None = None + label_enabled: bool | None = None + overloaded: bool | None = None + acquisition_type: str | None = None + + def __post_init__(self) -> None: + _scope_snapshot_v2_required_channel(self.channel, label="snapshot channel") + _scope_snapshot_v2_optional_bool(self.enabled, label="snapshot channel enabled") + for label, value in ( + ("snapshot channel coupling", self.coupling), + ("snapshot channel polarity", self.polarity), + ("snapshot channel label", self.label), + ("snapshot channel acquisition_type", self.acquisition_type), + ): + _scope_snapshot_v2_optional_str(value, label=label) + for label, value in ( + ("snapshot channel range_v", self.range_v), + ("snapshot channel scale_v_per_div", self.scale_v_per_div), + ("snapshot channel offset_v", self.offset_v), + ("snapshot channel position_div", self.position_div), + ("snapshot channel bandwidth_hz", self.bandwidth_hz), + ("snapshot channel skew_s", self.skew_s), + ): + _scope_snapshot_v2_optional_float(value, label=label) + _scope_snapshot_v2_optional_bool( + self.label_enabled, + label="snapshot channel label_enabled", + ) + _scope_snapshot_v2_optional_bool(self.overloaded, label="snapshot channel overloaded") + + +@dataclass(frozen=True, slots=True) +class ScopeTimebaseSnapshotV2: + acquisition_time_s: float | None = None + divisions: int | None = None + position_s: float | None = None + range_s: float | None = None + reference_percent: float | None = None + scale_s_per_div: float | None = None + roll_enabled: bool | None = None + + def __post_init__(self) -> None: + _scope_snapshot_v2_optional_float( + self.acquisition_time_s, + label="snapshot timebase acquisition_time_s", + ) + _scope_snapshot_v2_optional_int(self.divisions, label="snapshot timebase divisions") + for label, value in ( + ("snapshot timebase position_s", self.position_s), + ("snapshot timebase range_s", self.range_s), + ("snapshot timebase reference_percent", self.reference_percent), + ("snapshot timebase scale_s_per_div", self.scale_s_per_div), + ): + _scope_snapshot_v2_optional_float(value, label=label) + _scope_snapshot_v2_optional_bool( + self.roll_enabled, + label="snapshot timebase roll_enabled", + ) + + +@dataclass(frozen=True, slots=True) +class ScopeProbeSnapshotV2: + channel: int + attenuation_factor: float | None = None + bandwidth_hz: float | None = None + capacitance_f: float | None = None + impedance_ohm: float | None = None + name: str | None = None + probe_type: str | None = None + + def __post_init__(self) -> None: + _scope_snapshot_v2_required_channel(self.channel, label="snapshot probe channel") + for label, value in ( + ("snapshot probe attenuation_factor", self.attenuation_factor), + ("snapshot probe bandwidth_hz", self.bandwidth_hz), + ("snapshot probe capacitance_f", self.capacitance_f), + ("snapshot probe impedance_ohm", self.impedance_ohm), + ): + _scope_snapshot_v2_optional_float(value, label=label) + _scope_snapshot_v2_optional_str(self.name, label="snapshot probe name") + _scope_snapshot_v2_optional_str(self.probe_type, label="snapshot probe probe_type") + + +@dataclass(frozen=True, slots=True) +class ScopeWaveformMetadataSnapshotV2: + channel: int + x_start_s: float | None = None + x_stop_s: float | None = None + points: int | None = None + values_per_sample: int | None = None + x_increment_s: float | None = None + x_origin_s: float | None = None + y_increment_v: float | None = None + y_origin_v: float | None = None + y_resolution_bits: int | None = None + + def __post_init__(self) -> None: + _scope_snapshot_v2_required_channel(self.channel, label="snapshot waveform channel") + for label, value in ( + ("snapshot waveform x_start_s", self.x_start_s), + ("snapshot waveform x_stop_s", self.x_stop_s), + ("snapshot waveform x_increment_s", self.x_increment_s), + ("snapshot waveform x_origin_s", self.x_origin_s), + ("snapshot waveform y_increment_v", self.y_increment_v), + ("snapshot waveform y_origin_v", self.y_origin_v), + ): + _scope_snapshot_v2_optional_float(value, label=label) + _scope_snapshot_v2_optional_int(self.points, label="snapshot waveform points") + _scope_snapshot_v2_optional_int( + self.values_per_sample, + label="snapshot waveform values_per_sample", + ) + _scope_snapshot_v2_optional_int( + self.y_resolution_bits, + label="snapshot waveform y_resolution_bits", + ) + + +@dataclass(frozen=True, slots=True) +class ScopeTriggerSnapshotV2: + trigger_type: str + source_channel: int | None = None + mode: str | None = None + slope: str | None = None + coupling: str | None = None + level_v: float | None = None + hysteresis_mode: str | None = None + holdoff_mode: str | None = None + holdoff_time_s: float | None = None + + def __post_init__(self) -> None: + if not isinstance(self.trigger_type, str) or _SCOPE_SNAPSHOT_V2_TOKEN.fullmatch( + self.trigger_type + ) is None: + raise ValueError("snapshot trigger_type must be a safe token") + _scope_snapshot_v2_optional_int( + self.source_channel, + label="snapshot trigger source_channel", + ) + for label, value in ( + ("snapshot trigger mode", self.mode), + ("snapshot trigger slope", self.slope), + ("snapshot trigger coupling", self.coupling), + ("snapshot trigger hysteresis_mode", self.hysteresis_mode), + ("snapshot trigger holdoff_mode", self.holdoff_mode), + ): + _scope_snapshot_v2_optional_str(value, label=label) + _scope_snapshot_v2_optional_float(self.level_v, label="snapshot trigger level_v") + _scope_snapshot_v2_optional_float( + self.holdoff_time_s, + label="snapshot trigger holdoff_time_s", + ) + + +@dataclass(frozen=True, slots=True) +class ScopeSnapshotV2: + """Portable scope snapshot with explicit availability for every optional leaf.""" + + identity: ScopeIdentitySnapshot + health: ScopeHealthSnapshotV2 | None = None + channel: ScopeAnalogChannelSnapshotV2 | None = None + timebase: ScopeTimebaseSnapshotV2 | None = None + probe: ScopeProbeSnapshotV2 | None = None + waveform: ScopeWaveformMetadataSnapshotV2 | None = None + trigger: ScopeTriggerSnapshotV2 | None = None + unavailable_fields: tuple[ScopeSnapshotFieldV2, ...] = () + not_applicable_fields: tuple[ScopeSnapshotFieldV2, ...] = () + + def __post_init__(self) -> None: + if not isinstance(self.identity, ScopeIdentitySnapshot): + raise TypeError("snapshot V2 identity has an invalid type") + identity_values = ( + self.identity.manufacturer, + self.identity.model, + self.identity.serial_number, + self.identity.firmware, + ) + if any(not isinstance(value, str) or not value.strip() for value in identity_values): + raise ValueError("snapshot V2 identity fields must be non-empty strings") + if not isinstance(self.identity.options, tuple) or any( + not isinstance(value, str) or not value.strip() for value in self.identity.options + ): + raise ValueError("snapshot V2 identity options must be a tuple of non-empty strings") + for label, value, expected in ( + ("health", self.health, ScopeHealthSnapshotV2), + ("channel", self.channel, ScopeAnalogChannelSnapshotV2), + ("timebase", self.timebase, ScopeTimebaseSnapshotV2), + ("probe", self.probe, ScopeProbeSnapshotV2), + ("waveform", self.waveform, ScopeWaveformMetadataSnapshotV2), + ("trigger", self.trigger, ScopeTriggerSnapshotV2), + ): + if value is not None and not isinstance(value, expected): + raise TypeError(f"snapshot V2 {label} has an invalid type") + unavailable = self._availability_paths( + self.unavailable_fields, + label="unavailable_fields", + ) + not_applicable = self._availability_paths( + self.not_applicable_fields, + label="not_applicable_fields", + ) + if set(unavailable) & set(not_applicable): + raise ValueError("snapshot V2 availability paths must be mutually exclusive") + missing = { + field_name + for field_name, value in self.field_values().items() + if value is None + } + if set(unavailable) | set(not_applicable) != missing: + raise ValueError( + "snapshot V2 availability paths must exactly describe missing fields" + ) + + @staticmethod + def _availability_paths( + paths: object, + *, + label: str, + ) -> tuple[ScopeSnapshotFieldV2, ...]: + if not isinstance(paths, tuple): + raise TypeError(f"snapshot V2 {label} must be a tuple") + if len(set(paths)) != len(paths): + raise ValueError(f"snapshot V2 {label} must not contain duplicates") + if not set(paths) <= SCOPE_SNAPSHOT_V2_FIELDS: + raise ValueError(f"snapshot V2 {label} contain unsupported paths") + expected = tuple(field for field in SCOPE_SNAPSHOT_V2_FIELD_ORDER if field in paths) + if paths != expected: + raise ValueError(f"snapshot V2 {label} must use stable field order") + return paths + + def field_values(self) -> dict[ScopeSnapshotFieldV2, object | None]: + health = self.health + channel = self.channel + timebase = self.timebase + probe = self.probe + waveform = self.waveform + trigger = self.trigger + return { + "identity.manufacturer": self.identity.manufacturer, + "identity.model": self.identity.model, + "identity.serial_number": self.identity.serial_number, + "identity.firmware": self.identity.firmware, + "identity.options": self.identity.options, + "health.status_byte": None if health is None else health.status_byte, + "health.operation_condition": None if health is None else health.operation_condition, + "health.questionable_condition": None if health is None else health.questionable_condition, + "health.acquisition_available": None if health is None else health.acquisition_available, + "health.acquisition_count": None if health is None else health.acquisition_count, + "health.sample_rate_hz": None if health is None else health.sample_rate_hz, + "health.error_queue_nonempty": None if health is None else health.error_queue_nonempty, + "health.waiting_for_trigger": None if health is None else health.waiting_for_trigger, + "channel.channel": None if channel is None else channel.channel, + "channel.enabled": None if channel is None else channel.enabled, + "channel.coupling": None if channel is None else channel.coupling, + "channel.range_v": None if channel is None else channel.range_v, + "channel.scale_v_per_div": None if channel is None else channel.scale_v_per_div, + "channel.offset_v": None if channel is None else channel.offset_v, + "channel.position_div": None if channel is None else channel.position_div, + "channel.bandwidth_hz": None if channel is None else channel.bandwidth_hz, + "channel.polarity": None if channel is None else channel.polarity, + "channel.skew_s": None if channel is None else channel.skew_s, + "channel.label": None if channel is None else channel.label, + "channel.label_enabled": None if channel is None else channel.label_enabled, + "channel.overloaded": None if channel is None else channel.overloaded, + "channel.acquisition_type": None if channel is None else channel.acquisition_type, + "timebase.acquisition_time_s": None if timebase is None else timebase.acquisition_time_s, + "timebase.divisions": None if timebase is None else timebase.divisions, + "timebase.position_s": None if timebase is None else timebase.position_s, + "timebase.range_s": None if timebase is None else timebase.range_s, + "timebase.reference_percent": None if timebase is None else timebase.reference_percent, + "timebase.scale_s_per_div": None if timebase is None else timebase.scale_s_per_div, + "timebase.roll_enabled": None if timebase is None else timebase.roll_enabled, + "probe.channel": None if probe is None else probe.channel, + "probe.attenuation_factor": None if probe is None else probe.attenuation_factor, + "probe.bandwidth_hz": None if probe is None else probe.bandwidth_hz, + "probe.capacitance_f": None if probe is None else probe.capacitance_f, + "probe.impedance_ohm": None if probe is None else probe.impedance_ohm, + "probe.name": None if probe is None else probe.name, + "probe.probe_type": None if probe is None else probe.probe_type, + "waveform.channel": None if waveform is None else waveform.channel, + "waveform.x_start_s": None if waveform is None else waveform.x_start_s, + "waveform.x_stop_s": None if waveform is None else waveform.x_stop_s, + "waveform.points": None if waveform is None else waveform.points, + "waveform.values_per_sample": None if waveform is None else waveform.values_per_sample, + "waveform.x_increment_s": None if waveform is None else waveform.x_increment_s, + "waveform.x_origin_s": None if waveform is None else waveform.x_origin_s, + "waveform.y_increment_v": None if waveform is None else waveform.y_increment_v, + "waveform.y_origin_v": None if waveform is None else waveform.y_origin_v, + "waveform.y_resolution_bits": None if waveform is None else waveform.y_resolution_bits, + "trigger.trigger_type": None if trigger is None else trigger.trigger_type, + "trigger.source_channel": None if trigger is None else trigger.source_channel, + "trigger.mode": None if trigger is None else trigger.mode, + "trigger.slope": None if trigger is None else trigger.slope, + "trigger.coupling": None if trigger is None else trigger.coupling, + "trigger.level_v": None if trigger is None else trigger.level_v, + "trigger.hysteresis_mode": None if trigger is None else trigger.hysteresis_mode, + "trigger.holdoff_mode": None if trigger is None else trigger.holdoff_mode, + "trigger.holdoff_time_s": None if trigger is None else trigger.holdoff_time_s, + } + + @dataclass(frozen=True) class ScopeAcquisitionStatus: average_count: int @@ -132,6 +633,47 @@ class ScopeAcquisitionStatus: segments_available: int | None +ScopeInputCoupling = Literal["ac", "dc", "gnd", "unknown"] +ScopeInputTermination = Literal["high_z", "50_ohm", "unknown"] +ScopeChannelInputStateFieldV2 = Literal["impedance_ohm"] + + +@dataclass(frozen=True, slots=True) +class ScopeChannelInputStateV2: + """Typed read-only coupling and termination state for one analog channel.""" + + channel: int + coupling: ScopeInputCoupling + termination: ScopeInputTermination + impedance_ohm: float | None = None + unavailable_fields: tuple[ScopeChannelInputStateFieldV2, ...] = () + + def __post_init__(self) -> None: + if isinstance(self.channel, bool) or not isinstance(self.channel, int) or self.channel < 1: + raise ValueError("scope input-state channel must be a positive integer") + if self.coupling not in {"ac", "dc", "gnd", "unknown"}: + raise ValueError("scope input-state coupling is invalid") + if self.termination not in {"high_z", "50_ohm", "unknown"}: + raise ValueError("scope input-state termination is invalid") + if not isinstance(self.unavailable_fields, tuple): + raise TypeError("scope input-state unavailable_fields must be a tuple") + if self.impedance_ohm is None: + if self.unavailable_fields != ("impedance_ohm",): + raise ValueError( + "scope input-state missing impedance must be marked unavailable" + ) + return + if ( + isinstance(self.impedance_ohm, bool) + or not isinstance(self.impedance_ohm, (int, float)) + or not isfinite(self.impedance_ohm) + or self.impedance_ohm <= 0 + ): + raise ValueError("scope input-state impedance must be a finite positive number") + if self.unavailable_fields: + raise ValueError("scope input-state available impedance cannot be unavailable") + + ScopeChannelArithmetic = Literal["OFF", "ENVELOPE", "AVERAGE", "SMOOTH", "FILTER"] ScopeDigitalActivity = Literal["LOW", "HIGH", "TOGGLE"] ScopeDigitalTechnology = Literal["TTL", "ECL", "CMOS", "MANUAL"] @@ -157,6 +699,213 @@ class ScopeDigitalChannelStatus: label_enabled: bool +ScopeDigitalThresholdScope = Literal["channel", "pod", "unknown"] +ScopeDigitalActivityV2 = Literal["LOW", "HIGH", "TOGGLE", "unknown"] +ScopeDigitalTechnologyV2 = Literal["TTL", "ECL", "CMOS", "MANUAL", "unknown"] +ScopeDigitalHysteresisV2 = Literal["MAXIMUM", "ROBUST", "NORMAL", "unknown"] +ScopeDigitalSizeV2 = Literal[ + "SMALL", + "MEDIUM", + "LARGE", + "DIV1", + "DIV2", + "DIV4", + "DIV8", + "unknown", +] +ScopeDigitalStatusFieldV2 = Literal[ + "displayed", + "position_div", + "label", + "label_enabled", + "activity", + "technology", + "hysteresis", + "pod", + "pod.threshold_v", + "pod.threshold_scope", + "shared", + "shared.module_present", + "shared.timing_calibration_s", + "shared.size", +] + +_SCOPE_DIGITAL_STATUS_V2_FIELD_ORDER: tuple[ScopeDigitalStatusFieldV2, ...] = ( + "displayed", + "position_div", + "label", + "label_enabled", + "activity", + "technology", + "hysteresis", + "pod", + "pod.threshold_v", + "pod.threshold_scope", + "shared", + "shared.module_present", + "shared.timing_calibration_s", + "shared.size", +) +_SCOPE_DIGITAL_STATUS_V2_FIELDS = frozenset(_SCOPE_DIGITAL_STATUS_V2_FIELD_ORDER) + + +def _scope_v2_nonnegative_int(value: object, *, label: str) -> int: + if isinstance(value, bool) or not isinstance(value, int) or value < 0: + raise ValueError(f"{label} must be a non-negative integer") + return value + + +def _scope_v2_optional_finite(value: object, *, label: str) -> float | None: + if value is None: + return None + if isinstance(value, bool) or not isinstance(value, (int, float)) or not isfinite(value): + raise ValueError(f"{label} must be finite when provided") + return float(value) + + +@dataclass(frozen=True, slots=True) +class ScopeDigitalPodStatusV2: + start_channel: int + stop_channel: int + threshold_v: float | None = None + threshold_scope: ScopeDigitalThresholdScope | None = None + + def __post_init__(self) -> None: + _scope_v2_nonnegative_int(self.start_channel, label="digital pod start_channel") + _scope_v2_nonnegative_int(self.stop_channel, label="digital pod stop_channel") + if self.start_channel > self.stop_channel: + raise ValueError("digital pod start_channel must not exceed stop_channel") + _scope_v2_optional_finite(self.threshold_v, label="digital pod threshold_v") + if self.threshold_scope is not None and self.threshold_scope not in { + "channel", + "pod", + "unknown", + }: + raise ValueError("digital pod threshold_scope is invalid") + + +@dataclass(frozen=True, slots=True) +class ScopeDigitalSharedStatusV2: + module_present: bool | None = None + timing_calibration_s: float | None = None + size: ScopeDigitalSizeV2 | None = None + + def __post_init__(self) -> None: + if self.module_present is not None and not isinstance(self.module_present, bool): + raise ValueError("digital shared module_present must be bool when provided") + _scope_v2_optional_finite( + self.timing_calibration_s, + label="digital shared timing_calibration_s", + ) + if self.size is not None and self.size not in { + "SMALL", + "MEDIUM", + "LARGE", + "DIV1", + "DIV2", + "DIV4", + "DIV8", + "unknown", + }: + raise ValueError("digital shared size is invalid") + if self.module_present is None and self.timing_calibration_s is None and self.size is None: + raise ValueError("digital shared status must contain at least one available field") + + +@dataclass(frozen=True, slots=True) +class ScopeDigitalChannelStatusV2: + """Portable digital channel state that does not invent unavailable device fields.""" + + channel: int + displayed: bool | None = None + position_div: float | None = None + label: str | None = None + label_enabled: bool | None = None + activity: ScopeDigitalActivityV2 | None = None + technology: ScopeDigitalTechnologyV2 | None = None + hysteresis: ScopeDigitalHysteresisV2 | None = None + pod: ScopeDigitalPodStatusV2 | None = None + shared: ScopeDigitalSharedStatusV2 | None = None + unavailable_fields: tuple[ScopeDigitalStatusFieldV2, ...] = () + + def __post_init__(self) -> None: + _scope_v2_nonnegative_int(self.channel, label="digital status channel") + if self.displayed is not None and not isinstance(self.displayed, bool): + raise ValueError("digital status displayed must be bool when provided") + _scope_v2_optional_finite(self.position_div, label="digital status position_div") + if self.label is not None and not isinstance(self.label, str): + raise ValueError("digital status label must be str when provided") + if self.label_enabled is not None and not isinstance(self.label_enabled, bool): + raise ValueError("digital status label_enabled must be bool when provided") + if self.activity is not None and self.activity not in {"LOW", "HIGH", "TOGGLE", "unknown"}: + raise ValueError("digital status activity is invalid") + if self.technology is not None and self.technology not in { + "TTL", + "ECL", + "CMOS", + "MANUAL", + "unknown", + }: + raise ValueError("digital status technology is invalid") + if self.hysteresis is not None and self.hysteresis not in { + "MAXIMUM", + "ROBUST", + "NORMAL", + "unknown", + }: + raise ValueError("digital status hysteresis is invalid") + if self.pod is not None: + if not isinstance(self.pod, ScopeDigitalPodStatusV2): + raise TypeError("digital status pod has an invalid type") + if not self.pod.start_channel <= self.channel <= self.pod.stop_channel: + raise ValueError("digital pod range must include the requested channel") + if self.shared is not None and not isinstance(self.shared, ScopeDigitalSharedStatusV2): + raise TypeError("digital status shared has an invalid type") + if not isinstance(self.unavailable_fields, tuple): + raise TypeError("digital status unavailable_fields must be a tuple") + if len(set(self.unavailable_fields)) != len(self.unavailable_fields): + raise ValueError("digital status unavailable_fields must not contain duplicates") + if not set(self.unavailable_fields) <= _SCOPE_DIGITAL_STATUS_V2_FIELDS: + raise ValueError("digital status unavailable_fields contain unsupported paths") + + expected_unavailable: set[ScopeDigitalStatusFieldV2] = set() + for field_name, value in ( + ("displayed", self.displayed), + ("position_div", self.position_div), + ("label", self.label), + ("label_enabled", self.label_enabled), + ("activity", self.activity), + ("technology", self.technology), + ("hysteresis", self.hysteresis), + ): + if value is None: + expected_unavailable.add(field_name) # type: ignore[arg-type] + if self.pod is None: + expected_unavailable.add("pod") + else: + if self.pod.threshold_v is None: + expected_unavailable.add("pod.threshold_v") + if self.pod.threshold_scope is None: + expected_unavailable.add("pod.threshold_scope") + if self.shared is None: + expected_unavailable.add("shared") + else: + if self.shared.module_present is None: + expected_unavailable.add("shared.module_present") + if self.shared.timing_calibration_s is None: + expected_unavailable.add("shared.timing_calibration_s") + if self.shared.size is None: + expected_unavailable.add("shared.size") + expected = tuple( + field for field in _SCOPE_DIGITAL_STATUS_V2_FIELD_ORDER if field in expected_unavailable + ) + if self.unavailable_fields != expected: + raise ValueError( + "digital status unavailable_fields must exactly describe missing fields " + "in stable order" + ) + + @dataclass(frozen=True) class ScopeDigitalWaveformRequest: channels: tuple[int, ...] @@ -348,6 +1097,118 @@ class ScopeMeasurementStatistics: buffered_values: tuple[float, ...] | None = None +ScopeMeasurementSelectorMode = Literal["slot", "item_sources"] +_SCOPE_MEASUREMENT_STATISTICS_V2_TOKEN = re.compile( + r"^[A-Za-z0-9][A-Za-z0-9._:+/-]{0,63}$" +) + + +@dataclass(frozen=True, slots=True) +class ScopeMeasurementSelector: + slot: int | None = None + item: str | None = None + sources: tuple[str, ...] = () + + def __post_init__(self) -> None: + if not isinstance(self.sources, tuple): + raise TypeError("measurement selector sources must be a tuple") + if self.slot is not None: + if isinstance(self.slot, bool) or not isinstance(self.slot, int) or self.slot < 1: + raise ValueError("measurement selector slot must be a positive integer") + if self.item is not None or self.sources: + raise ValueError("slot measurement selector cannot include item or sources") + return + if ( + not isinstance(self.item, str) + or _SCOPE_MEASUREMENT_STATISTICS_V2_TOKEN.fullmatch(self.item) is None + ): + raise ValueError("item measurement selector requires a safe item token") + if not self.sources: + raise ValueError("item measurement selector requires at least one source") + if len(set(self.sources)) != len(self.sources): + raise ValueError("item measurement selector sources must be unique") + for source in self.sources: + if ( + not isinstance(source, str) + or _SCOPE_MEASUREMENT_STATISTICS_V2_TOKEN.fullmatch(source) is None + ): + raise ValueError("item measurement selector sources must be safe tokens") + + @property + def mode(self) -> ScopeMeasurementSelectorMode: + return "slot" if self.slot is not None else "item_sources" + + +@dataclass(frozen=True, slots=True) +class ScopeMeasurementStatisticsRequestV2: + selector: ScopeMeasurementSelector + configured: bool + include_buffer: bool = False + acquisition_stopped: bool = False + + def __post_init__(self) -> None: + if not isinstance(self.selector, ScopeMeasurementSelector): + raise TypeError("measurement statistics V2 selector has an invalid type") + if self.configured is not True: + raise ValueError("measurement statistics V2 requires configured=True") + if not isinstance(self.include_buffer, bool): + raise TypeError("measurement statistics V2 include_buffer must be bool") + if not isinstance(self.acquisition_stopped, bool): + raise TypeError("measurement statistics V2 acquisition_stopped must be bool") + + +@dataclass(frozen=True, slots=True) +class ScopeMeasurementStatisticsV2: + selector: ScopeMeasurementSelector + category: str + actual: float + average: float + standard_deviation: float + minimum: float + maximum: float + waveform_count: int + buffered_values: tuple[float, ...] | None = None + + def __post_init__(self) -> None: + if not isinstance(self.selector, ScopeMeasurementSelector): + raise TypeError("measurement statistics V2 selector has an invalid type") + if ( + not isinstance(self.category, str) + or _SCOPE_MEASUREMENT_STATISTICS_V2_TOKEN.fullmatch(self.category) is None + ): + raise ValueError("measurement statistics V2 category must be a safe token") + for label, value in ( + ("actual", self.actual), + ("average", self.average), + ("standard_deviation", self.standard_deviation), + ("minimum", self.minimum), + ("maximum", self.maximum), + ): + if ( + isinstance(value, bool) + or not isinstance(value, (int, float)) + or not isfinite(value) + ): + raise ValueError(f"measurement statistics V2 {label} must be finite") + if ( + isinstance(self.waveform_count, bool) + or not isinstance(self.waveform_count, int) + or self.waveform_count < 0 + ): + raise ValueError("measurement statistics V2 waveform_count must be non-negative") + if self.buffered_values is None: + return + if not isinstance(self.buffered_values, tuple): + raise TypeError("measurement statistics V2 buffered_values must be a tuple") + for value in self.buffered_values: + if ( + isinstance(value, bool) + or not isinstance(value, (int, float)) + or not isfinite(value) + ): + raise ValueError("measurement statistics V2 buffered_values must be finite") + + @dataclass(frozen=True) class ScopeDerivedWaveformMetadata: source_kind: Literal["math", "reference"] @@ -372,6 +1233,293 @@ class ScopeFftStatus: sample_rate_hz: float +ScopeFftStatusFieldV2 = Literal[ + "source", + "window", + "vertical_unit", + "frequency_start_hz", + "frequency_stop_hz", + "average_complete", + "resolution_bandwidth_hz", + "sample_rate_hz", +] +SCOPE_FFT_STATUS_V2_FIELD_ORDER: tuple[ScopeFftStatusFieldV2, ...] = ( + "source", + "window", + "vertical_unit", + "frequency_start_hz", + "frequency_stop_hz", + "average_complete", + "resolution_bandwidth_hz", + "sample_rate_hz", +) +_SCOPE_FFT_STATUS_V2_FIELDS = frozenset(SCOPE_FFT_STATUS_V2_FIELD_ORDER) +_SCOPE_FFT_STATUS_V2_TOKEN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:+/-]{0,63}$") + + +@dataclass(frozen=True, slots=True) +class ScopeFftStatusV2: + math_index: int + source: str | None = None + window: str | None = None + vertical_unit: str | None = None + frequency_start_hz: float | None = None + frequency_stop_hz: float | None = None + average_complete: bool | None = None + resolution_bandwidth_hz: float | None = None + sample_rate_hz: float | None = None + unavailable_fields: tuple[ScopeFftStatusFieldV2, ...] = () + + def __post_init__(self) -> None: + if isinstance(self.math_index, bool) or not isinstance(self.math_index, int) or self.math_index < 1: + raise ValueError("FFT status V2 math_index must be a positive integer") + for label, value in ( + ("source", self.source), + ("window", self.window), + ("vertical_unit", self.vertical_unit), + ): + if value is not None and ( + not isinstance(value, str) + or _SCOPE_FFT_STATUS_V2_TOKEN.fullmatch(value) is None + ): + raise ValueError(f"FFT status V2 {label} must be a safe token") + for label, value in ( + ("frequency_start_hz", self.frequency_start_hz), + ("frequency_stop_hz", self.frequency_stop_hz), + ("resolution_bandwidth_hz", self.resolution_bandwidth_hz), + ("sample_rate_hz", self.sample_rate_hz), + ): + if value is not None and ( + isinstance(value, bool) + or not isinstance(value, (int, float)) + or not isfinite(value) + ): + raise ValueError(f"FFT status V2 {label} must be finite") + if self.average_complete is not None and not isinstance(self.average_complete, bool): + raise ValueError("FFT status V2 average_complete must be bool when provided") + if ( + self.frequency_start_hz is None + ) != ( + self.frequency_stop_hz is None + ): + raise ValueError("FFT status V2 frequency range must be present or unavailable together") + if ( + self.frequency_start_hz is not None + and self.frequency_stop_hz is not None + and self.frequency_start_hz >= self.frequency_stop_hz + ): + raise ValueError("FFT status V2 frequency_start_hz must be below frequency_stop_hz") + for label, value in ( + ("resolution_bandwidth_hz", self.resolution_bandwidth_hz), + ("sample_rate_hz", self.sample_rate_hz), + ): + if value is not None and value <= 0: + raise ValueError(f"FFT status V2 {label} must be positive") + if not isinstance(self.unavailable_fields, tuple): + raise TypeError("FFT status V2 unavailable_fields must be a tuple") + if len(set(self.unavailable_fields)) != len(self.unavailable_fields): + raise ValueError("FFT status V2 unavailable_fields must not contain duplicates") + if not set(self.unavailable_fields) <= _SCOPE_FFT_STATUS_V2_FIELDS: + raise ValueError("FFT status V2 unavailable_fields contain unsupported paths") + expected = tuple( + field_name + for field_name in SCOPE_FFT_STATUS_V2_FIELD_ORDER + if self.field_values()[field_name] is None + ) + if self.unavailable_fields != expected: + raise ValueError( + "FFT status V2 unavailable_fields must exactly describe missing fields " + "in stable order" + ) + + def field_values(self) -> dict[ScopeFftStatusFieldV2, object | None]: + return { + "source": self.source, + "window": self.window, + "vertical_unit": self.vertical_unit, + "frequency_start_hz": self.frequency_start_hz, + "frequency_stop_hz": self.frequency_stop_hz, + "average_complete": self.average_complete, + "resolution_bandwidth_hz": self.resolution_bandwidth_hz, + "sample_rate_hz": self.sample_rate_hz, + } + + +ScopeCursorUnit = Literal["s", "Hz", "degree", "percent", "source"] +ScopeCursorReadoutFieldV2 = Literal[ + "cursor_index", + "source_a", + "source_b", + "x_a", + "x_b", + "x_delta", + "inverse_x_delta", + "y_a", + "y_b", + "y_delta", +] +SCOPE_CURSOR_READOUT_V2_FIELD_ORDER: tuple[ScopeCursorReadoutFieldV2, ...] = ( + "cursor_index", + "source_a", + "source_b", + "x_a", + "x_b", + "x_delta", + "inverse_x_delta", + "y_a", + "y_b", + "y_delta", +) +_SCOPE_CURSOR_READOUT_V2_FIELDS = frozenset(SCOPE_CURSOR_READOUT_V2_FIELD_ORDER) +_SCOPE_CURSOR_READOUT_V2_TOKEN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:+/-]{0,63}$") +_SCOPE_CURSOR_SOURCE_UNIT = re.compile(r"^[A-Za-z][A-Za-z0-9._/%^/-]{0,31}$") +_SCOPE_CURSOR_SOURCE_UNIT_RESOURCE_PREFIXES = ( + "ASRL", + "GPIB", + "SOCKET", + "TCPIP", + "USB", +) + + +@dataclass(frozen=True, slots=True) +class ScopeCursorQuantity: + value: float + unit: ScopeCursorUnit + source_unit: str | None = None + + def __post_init__(self) -> None: + if ( + isinstance(self.value, bool) + or not isinstance(self.value, (int, float)) + or not isfinite(self.value) + ): + raise ValueError("cursor quantity value must be finite") + if not isinstance(self.unit, str) or self.unit not in { + "s", + "Hz", + "degree", + "percent", + "source", + }: + raise ValueError("cursor quantity unit is unsupported") + if self.source_unit is not None: + if self.unit != "source": + raise ValueError("cursor quantity source_unit requires unit='source'") + if ( + not isinstance(self.source_unit, str) + or _SCOPE_CURSOR_SOURCE_UNIT.fullmatch(self.source_unit) is None + or self.source_unit.upper().startswith( + _SCOPE_CURSOR_SOURCE_UNIT_RESOURCE_PREFIXES + ) + ): + raise ValueError("cursor quantity source_unit must be a visible unit token") + + +@dataclass(frozen=True, slots=True) +class ScopeCursorReadoutV2: + cursor_index: int | None + mode: str + function: str + source_a: str | None + source_b: str | None + x_a: ScopeCursorQuantity | None = None + x_b: ScopeCursorQuantity | None = None + x_delta: ScopeCursorQuantity | None = None + inverse_x_delta: ScopeCursorQuantity | None = None + y_a: ScopeCursorQuantity | None = None + y_b: ScopeCursorQuantity | None = None + y_delta: ScopeCursorQuantity | None = None + unavailable_fields: tuple[ScopeCursorReadoutFieldV2, ...] = () + not_applicable_fields: tuple[ScopeCursorReadoutFieldV2, ...] = () + + def __post_init__(self) -> None: + if self.cursor_index is not None and ( + isinstance(self.cursor_index, bool) + or not isinstance(self.cursor_index, int) + or self.cursor_index < 1 + ): + raise ValueError("cursor readout V2 cursor_index must be a positive integer or None") + for label, value in (("mode", self.mode), ("function", self.function)): + if ( + not isinstance(value, str) + or _SCOPE_CURSOR_READOUT_V2_TOKEN.fullmatch(value) is None + ): + raise ValueError(f"cursor readout V2 {label} must be a safe token") + if (self.source_a is None) != (self.source_b is None): + raise ValueError("cursor readout V2 sources must be present or unavailable together") + for label, value in (("source_a", self.source_a), ("source_b", self.source_b)): + if value is not None and ( + not isinstance(value, str) + or _SCOPE_CURSOR_READOUT_V2_TOKEN.fullmatch(value) is None + ): + raise ValueError(f"cursor readout V2 {label} must be a safe token") + for label, value in self.field_values().items(): + if label in {"cursor_index", "source_a", "source_b"} or value is None: + continue + if not isinstance(value, ScopeCursorQuantity): + raise TypeError(f"cursor readout V2 {label} must be a ScopeCursorQuantity") + self._validate_availability_paths() + + def _validate_availability_paths(self) -> None: + for label, fields in ( + ("unavailable_fields", self.unavailable_fields), + ("not_applicable_fields", self.not_applicable_fields), + ): + if not isinstance(fields, tuple): + raise TypeError(f"cursor readout V2 {label} must be a tuple") + if len(set(fields)) != len(fields): + raise ValueError(f"cursor readout V2 {label} must not contain duplicates") + if not set(fields) <= _SCOPE_CURSOR_READOUT_V2_FIELDS: + raise ValueError(f"cursor readout V2 {label} contain unsupported paths") + expected = tuple( + field_name + for field_name in SCOPE_CURSOR_READOUT_V2_FIELD_ORDER + if field_name in fields + ) + if fields != expected: + raise ValueError( + f"cursor readout V2 {label} must use stable field order" + ) + unavailable = set(self.unavailable_fields) + not_applicable = set(self.not_applicable_fields) + if unavailable & not_applicable: + raise ValueError("cursor readout V2 availability paths must be disjoint") + missing = { + field_name + for field_name, value in self.field_values().items() + if value is None + } + if unavailable | not_applicable != missing: + raise ValueError( + "cursor readout V2 availability paths must exactly describe missing fields" + ) + source_unavailable = { + field_name in unavailable for field_name in ("source_a", "source_b") + } + source_not_applicable = { + field_name in not_applicable for field_name in ("source_a", "source_b") + } + if len(source_unavailable) != 1 or len(source_not_applicable) != 1: + raise ValueError( + "cursor readout V2 sources must use the same availability classification" + ) + + def field_values(self) -> dict[ScopeCursorReadoutFieldV2, object | None]: + return { + "cursor_index": self.cursor_index, + "source_a": self.source_a, + "source_b": self.source_b, + "x_a": self.x_a, + "x_b": self.x_b, + "x_delta": self.x_delta, + "inverse_x_delta": self.inverse_x_delta, + "y_a": self.y_a, + "y_b": self.y_b, + "y_delta": self.y_delta, + } + + @dataclass(frozen=True) class ScopeCursorReadout: cursor_index: int diff --git a/src/wavebench/instruments/scope_extension_capabilities.py b/src/wavebench/instruments/scope_extension_capabilities.py index 0997f4d..3bbfb03 100644 --- a/src/wavebench/instruments/scope_extension_capabilities.py +++ b/src/wavebench/instruments/scope_extension_capabilities.py @@ -13,6 +13,36 @@ SCOPE_EXTENSIONS_MIN_CORE_VERSION = "0.8.23" +SCOPE_WAVEFORM_BINARY_MIN_CORE_VERSION = "0.8.24" +SCOPE_PORTABILITY_V2_MIN_CORE_VERSION = "0.8.24" +SCOPE_STRICT_V2_CAPABILITIES = frozenset( + { + "scope.channel_input_state_v2", + "scope.digital_status_v2", + "scope.snapshot_v2", + "scope.acquisition_status_v2", + "scope.measurement_statistics_v2", + "scope.fft_status_v2", + "scope.cursor_readout_v2", + "scope.capture_average_v2", + } +) + +_WAVEFORM_BINARY_CAPABILITY_BY_OPERATION = { + "fetch": "scope.fetch_waveform", + "capture_single": "scope.capture_waveform", + "capture_multiple": "scope.capture_waveforms", +} +_WAVEFORM_BINARY_METHOD_BY_OPERATION = { + "fetch": "fetch_waveform_bounded", + "capture_single": "capture_waveform_bounded", + "capture_multiple": "capture_waveforms_bounded", +} +_WAVEFORM_BINARY_RECOVERY_METHODS = ( + "snapshot_waveform_transfer_state", + "restore_waveform_transfer_state", + "verify_waveform_transfer_state_restored", +) SCOPE_CAPABILITY_METHODS: Mapping[str, tuple[str, ...]] = MappingProxyType( @@ -44,6 +74,24 @@ "verify_trace_transfer_state_restored", ), "scope.error_drain_v1": ("drain_errors",), + "scope.channel_input_state_v2": ("get_channel_input_state_v2",), + "scope.digital_status_v2": ("get_digital_status_v2",), + "scope.snapshot_v2": ("get_snapshot_v2",), + "scope.acquisition_status_v2": ("get_acquisition_status_v2",), + "scope.measurement_statistics_v2": ("get_measurement_statistics_v2",), + "scope.fft_status_v2": ("get_fft_status_v2",), + "scope.cursor_readout_v2": ("get_cursor_readout_v2",), + "scope.capture_average_v2": ( + "snapshot_average_capture_state", + "set_average_acquisition_type_v2", + "get_average_configuration_v2", + "set_average_count_v2", + "acquire_average_single_v2", + "get_device_average_complete_v2", + "fetch_average_waveform_bounded", + "restore_average_capture_state", + "verify_average_capture_state_restored", + ), } ) @@ -59,8 +107,16 @@ def validate_scope_descriptor( ) -> None: """Validate scope extension declarations before instrument I/O.""" - declared = set(descriptor.capabilities) & set(SCOPE_CAPABILITY_METHODS) - if not declared: + extensions = descriptor.scope_extensions + waveform_profile = ( + extensions.waveform_binary_profile if extensions is not None else None + ) + average_profile = ( + extensions.average_capture_profile_v2 if extensions is not None else None + ) + declared_capabilities = set(descriptor.capabilities) + declared = declared_capabilities & set(SCOPE_CAPABILITY_METHODS) + if not declared and waveform_profile is None and average_profile is None: return if descriptor.kind != "scope": raise ConfigError("scope extension capabilities require a scope descriptor") @@ -75,22 +131,41 @@ def validate_scope_descriptor( "scope extension capabilities require wavebench_min_version " f">= {SCOPE_EXTENSIONS_MIN_CORE_VERSION}" ) + if declared & SCOPE_STRICT_V2_CAPABILITIES and minimum < Version( + SCOPE_PORTABILITY_V2_MIN_CORE_VERSION + ): + raise ConfigError( + "scope portability V2 capabilities require wavebench_min_version " + f">= {SCOPE_PORTABILITY_V2_MIN_CORE_VERSION}" + ) dependencies = { "scope.acquisition_control": {"scope.acquisition_run_state"}, + "scope.capture_average_v2": { + "scope.idn", + "scope.acquisition_status_v2", + "scope.acquisition_run_state", + "scope.acquisition_control", + "scope.channel_input_state_v2", + }, } for capability, required in dependencies.items(): - if capability in declared and not required <= declared: + if capability in declared and not required <= declared_capabilities: raise ConfigError( f"instrument {descriptor.driver_id!r} capability {capability!r} requires " + ", ".join(sorted(required)) ) - extensions = descriptor.scope_extensions profile_requirements = { "scope.screenshot_profile": "screenshot_profile", "scope.screenshot_v2": "screenshot_profile", "scope.acquisition_control": "acquisition_control_profile", "scope.trace_metadata": "trace_profile", "scope.fetch_trace": "trace_profile", + "scope.snapshot_v2": "snapshot_profile_v2", + "scope.acquisition_status_v2": "acquisition_status_profile_v2", + "scope.measurement_statistics_v2": "measurement_statistics_profile_v2", + "scope.fft_status_v2": "fft_status_profile_v2", + "scope.cursor_readout_v2": "cursor_readout_profile_v2", + "scope.capture_average_v2": "average_capture_profile_v2", } for capability in sorted(declared): profile_name = profile_requirements.get(capability) @@ -113,6 +188,121 @@ def validate_scope_descriptor( f"instrument {descriptor.driver_id!r} capability {capability!r} " f"requires callable method(s): {', '.join(missing)}" ) + acquisition_status_profile = ( + extensions.acquisition_status_profile_v2 if extensions is not None else None + ) + if ( + "scope.acquisition_status_v2" in declared + and acquisition_status_profile is not None + and "run_state" in acquisition_status_profile.readable_fields + and "scope.acquisition_run_state" not in declared + ): + raise ConfigError( + f"instrument {descriptor.driver_id!r} capability 'scope.acquisition_status_v2' " + "requires scope.acquisition_run_state when its profile reads run_state" + ) + _validate_waveform_binary_profile( + descriptor, + driver=driver, + waveform_profile=waveform_profile, + require_public_version=_require_public_version, + ) + _validate_average_capture_profile( + descriptor, + average_profile=average_profile, + require_public_version=_require_public_version, + ) + + +def _validate_waveform_binary_profile( + descriptor: InstrumentDescriptor, + *, + driver: object | None, + waveform_profile: object | None, + require_public_version: bool, +) -> None: + """Validate optional standard-waveform bounded-contract declarations.""" + + if waveform_profile is None: + return + if descriptor.kind != "scope": + raise ConfigError("waveform binary profiles require a scope descriptor") + if require_public_version: + try: + minimum = Version(descriptor.wavebench_min_version) + contract_minimum = Version(SCOPE_WAVEFORM_BINARY_MIN_CORE_VERSION) + except InvalidVersion as exc: + raise ConfigError("waveform binary descriptor has an invalid core version") from exc + if minimum < contract_minimum: + raise ConfigError( + "waveform binary profiles require wavebench_min_version " + f">= {SCOPE_WAVEFORM_BINARY_MIN_CORE_VERSION}" + ) + declared = set(descriptor.capabilities) + waveform_capabilities = set(_WAVEFORM_BINARY_CAPABILITY_BY_OPERATION.values()) + declared_waveform = declared & waveform_capabilities + expected_by_operation = _WAVEFORM_BINARY_CAPABILITY_BY_OPERATION + profile_operations = {item.operation_kind for item in waveform_profile.operations} + expected_operations = { + operation + for operation, capability in expected_by_operation.items() + if capability in declared_waveform + } + if profile_operations != expected_operations: + raise ConfigError( + f"instrument {descriptor.driver_id!r} waveform binary profile operations must " + "match declared standard waveform capabilities exactly" + ) + if "scope.idn" not in declared: + raise ConfigError( + f"instrument {descriptor.driver_id!r} waveform binary profile requires " + "capability 'scope.idn'" + ) + if driver is None: + return + missing = list(_WAVEFORM_BINARY_RECOVERY_METHODS) + missing.extend( + _WAVEFORM_BINARY_METHOD_BY_OPERATION[operation] + for operation in sorted(profile_operations) + ) + unavailable = tuple( + method for method in missing if not callable(getattr(driver, method, None)) + ) + if unavailable: + raise ConfigError( + f"instrument {descriptor.driver_id!r} waveform binary profile requires " + f"callable method(s): {', '.join(unavailable)}" + ) + + +def _validate_average_capture_profile( + descriptor: InstrumentDescriptor, + *, + average_profile: object | None, + require_public_version: bool, +) -> None: + """Validate the standalone average-capture V2 profile declaration.""" + + if average_profile is None: + return + if descriptor.kind != "scope": + raise ConfigError("average capture profiles require a scope descriptor") + if require_public_version: + try: + minimum = Version(descriptor.wavebench_min_version) + contract_minimum = Version(SCOPE_PORTABILITY_V2_MIN_CORE_VERSION) + except InvalidVersion as exc: + raise ConfigError("average capture descriptor has an invalid core version") from exc + if minimum < contract_minimum: + raise ConfigError( + "average capture profiles require wavebench_min_version " + f">= {SCOPE_PORTABILITY_V2_MIN_CORE_VERSION}" + ) + if "scope.capture_average_v2" not in descriptor.capabilities: + raise ConfigError( + f"instrument {descriptor.driver_id!r} average capture profile requires " + "capability 'scope.capture_average_v2'" + ) def validate_experimental_scope_descriptor( @@ -136,6 +326,9 @@ def validate_experimental_scope_descriptor( "EXPERIMENTAL_SCOPE_CAPABILITY_METHODS", "SCOPE_CAPABILITY_METHODS", "SCOPE_EXTENSIONS_MIN_CORE_VERSION", + "SCOPE_PORTABILITY_V2_MIN_CORE_VERSION", + "SCOPE_STRICT_V2_CAPABILITIES", + "SCOPE_WAVEFORM_BINARY_MIN_CORE_VERSION", "validate_experimental_scope_descriptor", "validate_scope_descriptor", ] diff --git a/src/wavebench/instruments/scope_extensions.py b/src/wavebench/instruments/scope_extensions.py index 83aad90..ffed43b 100644 --- a/src/wavebench/instruments/scope_extensions.py +++ b/src/wavebench/instruments/scope_extensions.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass from math import isfinite import re @@ -11,14 +12,41 @@ import numpy as np from wavebench.scope_extension_constants import ( + SCOPE_AVERAGE_CAPTURE_V2_BINARY_OPERATION_MAX_BYTES, + SCOPE_AVERAGE_CAPTURE_V2_BINARY_QUERY_MAX_COUNT, + SCOPE_AVERAGE_CAPTURE_V2_BINARY_RESPONSE_MAX_BYTES, + SCOPE_AVERAGE_CAPTURE_V2_BINARY_RESYNCHRONIZATION_MAX_BYTES, + SCOPE_AVERAGE_COUNT_MAX_V2, + SCOPE_ACQUISITION_STATUS_V2_MAX_QUERIES, + SCOPE_CURSOR_READOUT_V2_MAX_QUERIES, + SCOPE_FFT_STATUS_V2_MAX_QUERIES, + SCOPE_MEASUREMENT_STATISTICS_V2_MAX_QUERIES, SCOPE_SCREENSHOT_BINARY_OPERATION_MAX_BYTES, SCOPE_SCREENSHOT_BINARY_RESPONSE_MAX_BYTES, SCOPE_SCREENSHOT_BINARY_RESYNCHRONIZATION_MAX_BYTES, SCOPE_TRACE_MAX_POINTS, + SCOPE_WAVEFORM_BINARY_OPERATION_MAX_BYTES, + SCOPE_WAVEFORM_BINARY_QUERY_MAX_COUNT, + SCOPE_WAVEFORM_BINARY_RESPONSE_MAX_BYTES, + SCOPE_WAVEFORM_BINARY_RESYNCHRONIZATION_MAX_BYTES, ) from wavebench.transport.contracts import BinaryResponseFraming from .contracts import InstrumentDriver +from .models import ( + SCOPE_SNAPSHOT_V2_FIELD_ORDER, + SCOPE_CURSOR_READOUT_V2_FIELD_ORDER, + SCOPE_FFT_STATUS_V2_FIELD_ORDER, + ScopeCursorReadoutFieldV2, + ScopeCursorReadoutV2, + ScopeMeasurementStatisticsRequestV2, + ScopeMeasurementStatisticsV2, + ScopeFftStatusFieldV2, + ScopeFftStatusV2, + ScopeSnapshotFieldV2, + ScopeSnapshotV2, + WaveformData, +) _SAFE_TOKEN = re.compile(r"^[A-Za-z0-9_.:-]{1,64}$") @@ -441,7 +469,56 @@ def __post_init__(self) -> None: "scope.trigger", "scope.acquisition", ] -ScopeCompletionProof = Literal["count_delta_with_epoch", "identity_delta", "state_transition"] +ScopeCompletionProof = Literal[ + "count_delta_with_epoch", + "identity_delta", + "state_transition", + "single_mode_readback_then_stopped", +] +ScopeAcquisitionStatusFieldV2 = Literal[ + "acquisition_type", + "run_state", + "sample_rate_hz", + "memory_depth", + "average", + "average.configured_count", + "average.complete", + "segmented", + "segmented.option_installed", + "segmented.enabled", + "segmented.maximum_enabled", + "segmented.capacity", + "segmented.available", +] +SCOPE_ACQUISITION_STATUS_V2_FIELD_ORDER: tuple[ScopeAcquisitionStatusFieldV2, ...] = ( + "acquisition_type", + "run_state", + "sample_rate_hz", + "memory_depth", + "average", + "average.configured_count", + "average.complete", + "segmented", + "segmented.option_installed", + "segmented.enabled", + "segmented.maximum_enabled", + "segmented.capacity", + "segmented.available", +) +_ACQUISITION_STATUS_V2_FIELDS = frozenset(SCOPE_ACQUISITION_STATUS_V2_FIELD_ORDER) +_ACQUISITION_STATUS_V2_PARTITION_FIELDS = { + "average": ( + "average.configured_count", + "average.complete", + ), + "segmented": ( + "segmented.option_installed", + "segmented.enabled", + "segmented.maximum_enabled", + "segmented.capacity", + "segmented.available", + ), +} _ACQUISITION_SETTING_FIELDS = {"scope.trigger", "scope.acquisition"} _ACQUISITION_RESTORE_FIELDS = {"scope.run_state", *_ACQUISITION_SETTING_FIELDS} _ACQUISITION_PHASES = { @@ -469,6 +546,7 @@ class ScopeAcquisitionControlProfile: verify_max_steps: int identity_semantics: ScopeAcquisitionIdentitySemantics atomic_arm_preserves_count_mode_semantics: bool = False + single_mode_readback_allows_terminal_stop: bool = False def __post_init__(self) -> None: modes = _unique_tuple( @@ -482,8 +560,10 @@ def __post_init__(self) -> None: {"configure_then_arm", "atomic_configure_and_arm"}, label="single arm semantics", ) - if not isinstance(self.arm_resets_acquisition_count, bool) or not isinstance( - self.atomic_arm_preserves_count_mode_semantics, bool + if ( + not isinstance(self.arm_resets_acquisition_count, bool) + or not isinstance(self.atomic_arm_preserves_count_mode_semantics, bool) + or not isinstance(self.single_mode_readback_allows_terminal_stop, bool) ): raise TypeError("acquisition profile flags must be bool") restore = _unique_tuple(self.failure_restore_order, label="failure_restore_order") @@ -539,6 +619,663 @@ def __post_init__(self) -> None: _optional_safe_token(self.acquisition_identity, label="acquisition_identity") +@dataclass(frozen=True, slots=True) +class ScopeAverageStatusV2: + configured_count: int + complete: bool | None = None + + def __post_init__(self) -> None: + _strict_int( + self.configured_count, + label="average configured_count", + minimum=1, + ) + if self.complete is not None and not isinstance(self.complete, bool): + raise ValueError("average complete must be bool when provided") + + +@dataclass(frozen=True, slots=True) +class ScopeSegmentedStatusV2: + option_installed: bool | None = None + enabled: bool | None = None + maximum_enabled: bool | None = None + capacity: int | None = None + available: int | None = None + + def __post_init__(self) -> None: + for label, value in ( + ("segmented option_installed", self.option_installed), + ("segmented enabled", self.enabled), + ("segmented maximum_enabled", self.maximum_enabled), + ): + if value is not None and not isinstance(value, bool): + raise ValueError(f"{label} must be bool when provided") + for label, value in ( + ("segmented capacity", self.capacity), + ("segmented available", self.available), + ): + if value is not None: + _strict_int(value, label=label, minimum=0) + + +@dataclass(frozen=True, slots=True) +class ScopeAcquisitionStatusV2: + """Portable acquisition state with explicit static and mode-dependent absence.""" + + acquisition_type: str | None = None + run_state: ScopeAcquisitionRunState | None = None + sample_rate_hz: float | None = None + memory_depth: int | None = None + average: ScopeAverageStatusV2 | None = None + segmented: ScopeSegmentedStatusV2 | None = None + unavailable_fields: tuple[ScopeAcquisitionStatusFieldV2, ...] = () + not_applicable_fields: tuple[ScopeAcquisitionStatusFieldV2, ...] = () + + def __post_init__(self) -> None: + if self.acquisition_type is not None: + _safe_token(self.acquisition_type, label="acquisition type") + if self.run_state is not None and not isinstance( + self.run_state, + ScopeAcquisitionRunState, + ): + raise TypeError("acquisition status V2 run_state has an invalid type") + if self.sample_rate_hz is not None: + sample_rate_hz = _finite( + self.sample_rate_hz, + label="acquisition status V2 sample_rate_hz", + ) + if sample_rate_hz <= 0: + raise ValueError("acquisition status V2 sample_rate_hz must be positive") + if self.memory_depth is not None: + _strict_int( + self.memory_depth, + label="acquisition status V2 memory_depth", + minimum=1, + ) + if self.average is not None and not isinstance(self.average, ScopeAverageStatusV2): + raise TypeError("acquisition status V2 average has an invalid type") + if self.segmented is not None and not isinstance(self.segmented, ScopeSegmentedStatusV2): + raise TypeError("acquisition status V2 segmented has an invalid type") + + unavailable = self._availability_paths( + self.unavailable_fields, + label="unavailable_fields", + ) + not_applicable = self._availability_paths( + self.not_applicable_fields, + label="not_applicable_fields", + ) + if set(unavailable) & set(not_applicable): + raise ValueError("acquisition status V2 availability paths must be mutually exclusive") + all_paths = set(unavailable) | set(not_applicable) + for parent, children in _ACQUISITION_STATUS_V2_PARTITION_FIELDS.items(): + if parent in all_paths and set(children) & all_paths: + raise ValueError( + "acquisition status V2 availability paths cannot mix partition and leaf paths" + ) + expected_missing = set(self._missing_paths()) + if all_paths != expected_missing: + raise ValueError( + "acquisition status V2 availability paths must exactly describe missing fields" + ) + + @staticmethod + def _availability_paths( + paths: object, + *, + label: str, + ) -> tuple[ScopeAcquisitionStatusFieldV2, ...]: + if not isinstance(paths, tuple): + raise TypeError(f"acquisition status V2 {label} must be a tuple") + if len(set(paths)) != len(paths): + raise ValueError(f"acquisition status V2 {label} must not contain duplicates") + if not set(paths) <= _ACQUISITION_STATUS_V2_FIELDS: + raise ValueError(f"acquisition status V2 {label} contain unsupported paths") + expected = tuple( + field_name + for field_name in SCOPE_ACQUISITION_STATUS_V2_FIELD_ORDER + if field_name in paths + ) + if paths != expected: + raise ValueError( + f"acquisition status V2 {label} must use stable field order" + ) + return paths + + def _missing_paths(self) -> tuple[ScopeAcquisitionStatusFieldV2, ...]: + missing: set[ScopeAcquisitionStatusFieldV2] = set() + if self.acquisition_type is None: + missing.add("acquisition_type") + if self.run_state is None: + missing.add("run_state") + if self.sample_rate_hz is None: + missing.add("sample_rate_hz") + if self.memory_depth is None: + missing.add("memory_depth") + if self.average is None: + missing.add("average") + elif self.average.complete is None: + missing.add("average.complete") + if self.segmented is None: + missing.add("segmented") + else: + for field_name, value in ( + ("segmented.option_installed", self.segmented.option_installed), + ("segmented.enabled", self.segmented.enabled), + ("segmented.maximum_enabled", self.segmented.maximum_enabled), + ("segmented.capacity", self.segmented.capacity), + ("segmented.available", self.segmented.available), + ): + if value is None: + missing.add(field_name) # type: ignore[arg-type] + return tuple( + field_name + for field_name in SCOPE_ACQUISITION_STATUS_V2_FIELD_ORDER + if field_name in missing + ) + + def field_values(self) -> dict[ScopeAcquisitionStatusFieldV2, object | None]: + average = self.average + segmented = self.segmented + return { + "acquisition_type": self.acquisition_type, + "run_state": self.run_state, + "sample_rate_hz": self.sample_rate_hz, + "memory_depth": self.memory_depth, + "average": average, + "average.configured_count": ( + None if average is None else average.configured_count + ), + "average.complete": None if average is None else average.complete, + "segmented": segmented, + "segmented.option_installed": ( + None if segmented is None else segmented.option_installed + ), + "segmented.enabled": None if segmented is None else segmented.enabled, + "segmented.maximum_enabled": ( + None if segmented is None else segmented.maximum_enabled + ), + "segmented.capacity": None if segmented is None else segmented.capacity, + "segmented.available": None if segmented is None else segmented.available, + } + + +@dataclass(frozen=True, slots=True) +class ScopeAcquisitionStatusProfileV2: + """Descriptor-owned pure-text query contract for acquisition status V2.""" + + readable_fields: tuple[ScopeAcquisitionStatusFieldV2, ...] + max_queries: int + conditionally_applicable_fields: tuple[ScopeAcquisitionStatusFieldV2, ...] = () + allowed_effect: Literal["pure_read"] = "pure_read" + + def __post_init__(self) -> None: + readable = self._profile_paths(self.readable_fields, label="readable_fields") + if not readable: + raise ValueError("acquisition status V2 readable_fields must not be empty") + if "acquisition_type" not in readable: + raise ValueError( + "acquisition status V2 readable_fields must include acquisition_type" + ) + conditional = self._profile_paths( + self.conditionally_applicable_fields, + label="conditionally_applicable_fields", + ) + if not set(conditional) <= set(readable): + raise ValueError("acquisition status V2 conditional fields must be readable") + if "acquisition_type" in conditional: + raise ValueError("acquisition status V2 acquisition_type cannot be conditional") + _strict_int( + self.max_queries, + label="acquisition status V2 max_queries", + minimum=1, + maximum=SCOPE_ACQUISITION_STATUS_V2_MAX_QUERIES, + ) + _literal(self.allowed_effect, {"pure_read"}, label="acquisition status V2 effect") + readable_set = set(readable) + for parent, children in _ACQUISITION_STATUS_V2_PARTITION_FIELDS.items(): + readable_children = readable_set & set(children) + if readable_children and parent not in readable_set: + raise ValueError( + f"acquisition status V2 {parent} fields require {parent!r}" + ) + if parent not in readable_set: + continue + if parent == "average" and "average.configured_count" not in readable_set: + raise ValueError( + "acquisition status V2 average requires average.configured_count" + ) + if parent == "segmented" and not readable_children: + raise ValueError( + "acquisition status V2 segmented requires a readable leaf field" + ) + + @staticmethod + def _profile_paths( + paths: object, + *, + label: str, + ) -> tuple[ScopeAcquisitionStatusFieldV2, ...]: + if not isinstance(paths, tuple): + raise TypeError(f"acquisition status V2 {label} must be a tuple") + if len(set(paths)) != len(paths): + raise ValueError(f"acquisition status V2 {label} must not contain duplicates") + if not set(paths) <= _ACQUISITION_STATUS_V2_FIELDS: + raise ValueError(f"acquisition status V2 {label} contain unsupported paths") + expected = tuple( + field_name + for field_name in SCOPE_ACQUISITION_STATUS_V2_FIELD_ORDER + if field_name in paths + ) + if paths != expected: + raise ValueError( + f"acquisition status V2 {label} must use stable field order" + ) + return paths + + def validate_result(self, result: ScopeAcquisitionStatusV2) -> None: + """Reject results that expand or silently shrink this descriptor profile.""" + + if not isinstance(result, ScopeAcquisitionStatusV2): + raise TypeError("acquisition status V2 driver returned an invalid result") + values = result.field_values() + readable = set(self.readable_fields) + conditional = set(self.conditionally_applicable_fields) + unavailable = set(result.unavailable_fields) + not_applicable = set(result.not_applicable_fields) + for field_name in SCOPE_ACQUISITION_STATUS_V2_FIELD_ORDER: + value = values[field_name] + unavailable_path = self._covering_availability_path(unavailable, field_name) + not_applicable_path = self._covering_availability_path( + not_applicable, + field_name, + ) + parent = self._parent_path(field_name) + is_conditional = field_name in conditional or parent in conditional + if field_name not in readable: + if value is not None: + raise ValueError( + "acquisition status V2 result provided a field outside the descriptor profile" + ) + if unavailable_path is not None and not_applicable_path is None: + continue + if not_applicable_path is not None and parent in conditional: + continue + raise ValueError( + "acquisition status V2 result provided a field outside the descriptor profile" + ) + if is_conditional: + if value is None and ( + not_applicable_path is None or unavailable_path is not None + ): + raise ValueError( + "acquisition status V2 conditional fields must be marked not applicable" + ) + if value is not None and ( + unavailable_path is not None or not_applicable_path is not None + ): + raise ValueError( + "acquisition status V2 available conditional fields cannot have an availability path" + ) + continue + if value is None or unavailable_path is not None or not_applicable_path is not None: + raise ValueError( + "acquisition status V2 non-conditional readable fields must have a value" + ) + + @staticmethod + def _parent_path( + field_name: ScopeAcquisitionStatusFieldV2, + ) -> ScopeAcquisitionStatusFieldV2 | None: + for parent, children in _ACQUISITION_STATUS_V2_PARTITION_FIELDS.items(): + if field_name in children: + return parent # type: ignore[return-value] + return None + + @classmethod + def _covering_availability_path( + cls, + paths: set[ScopeAcquisitionStatusFieldV2], + field_name: ScopeAcquisitionStatusFieldV2, + ) -> ScopeAcquisitionStatusFieldV2 | None: + if field_name in paths: + return field_name + parent = cls._parent_path(field_name) + if parent is not None and parent in paths: + return parent + return None + + +_MEASUREMENT_STATISTICS_V2_SELECTOR_MODE_ORDER = ("slot", "item_sources") + + +@dataclass(frozen=True, slots=True) +class ScopeMeasurementStatisticsProfileV2: + """Descriptor-owned pure-text query contract for complete statistics V2.""" + + selector_modes: tuple[Literal["slot", "item_sources"], ...] + max_queries: int + supports_buffer: Literal[False] = False + slot_range: tuple[int, int] | None = None + supported_items: tuple[str, ...] = () + item_source_count_range: tuple[int, int] | None = None + allowed_effect: Literal["pure_read"] = "pure_read" + + def __post_init__(self) -> None: + modes = _unique_tuple(self.selector_modes, label="statistics selector_modes") + if not modes or not set(modes) <= set(_MEASUREMENT_STATISTICS_V2_SELECTOR_MODE_ORDER): + raise ValueError("statistics selector_modes are invalid") + expected_modes = tuple( + mode + for mode in _MEASUREMENT_STATISTICS_V2_SELECTOR_MODE_ORDER + if mode in modes + ) + if modes != expected_modes: + raise ValueError("statistics selector_modes must use stable mode order") + _strict_int( + self.max_queries, + label="statistics max_queries", + minimum=1, + maximum=SCOPE_MEASUREMENT_STATISTICS_V2_MAX_QUERIES, + ) + if not isinstance(self.supports_buffer, bool): + raise TypeError("statistics supports_buffer must be bool") + if self.supports_buffer is not False: + raise ValueError("statistics V2 R1 does not support statistics buffers") + _literal(self.allowed_effect, {"pure_read"}, label="statistics effect") + + if "slot" in modes: + self._validate_range(self.slot_range, label="statistics slot_range") + elif self.slot_range is not None: + raise ValueError("statistics slot_range requires the slot selector mode") + + if "item_sources" in modes: + items = _unique_tuple(self.supported_items, label="statistics supported_items") + if not items: + raise ValueError("statistics item_sources mode requires supported_items") + for item in items: + _safe_token(item, label="statistics supported item") + self._validate_range( + self.item_source_count_range, + label="statistics item_source_count_range", + ) + elif self.supported_items or self.item_source_count_range is not None: + raise ValueError( + "statistics supported_items and item_source_count_range require item_sources" + ) + + @staticmethod + def _validate_range(value: object, *, label: str) -> tuple[int, int]: + if not isinstance(value, tuple) or len(value) != 2: + raise ValueError(f"{label} must be a two-integer tuple") + lower, upper = value + _strict_int(lower, label=f"{label} lower", minimum=1) + _strict_int(upper, label=f"{label} upper", minimum=1) + if lower > upper: + raise ValueError(f"{label} lower bound must not exceed upper bound") + return lower, upper + + def validate_request(self, request: ScopeMeasurementStatisticsRequestV2) -> None: + """Reject unsupported statistics requests before opening an instrument session.""" + + if not isinstance(request, ScopeMeasurementStatisticsRequestV2): + raise TypeError("measurement statistics V2 request has an invalid type") + if request.configured is not True: + raise ValueError("measurement statistics V2 requires configured=True") + selector = request.selector + if selector.mode not in self.selector_modes: + raise ValueError("measurement statistics V2 selector mode is not supported") + if selector.mode == "slot": + if self.slot_range is None or selector.slot is None: + raise ValueError("measurement statistics V2 slot selector is not supported") + lower, upper = self.slot_range + if not lower <= selector.slot <= upper: + raise ValueError("measurement statistics V2 slot is outside the descriptor profile") + else: + if selector.item not in self.supported_items: + raise ValueError("measurement statistics V2 item is outside the descriptor profile") + if self.item_source_count_range is None: + raise ValueError("measurement statistics V2 item selector is not supported") + lower, upper = self.item_source_count_range + if not lower <= len(selector.sources) <= upper: + raise ValueError( + "measurement statistics V2 source count is outside the descriptor profile" + ) + if request.include_buffer: + raise ValueError("measurement statistics V2 R1 does not support statistics buffers") + + def validate_result( + self, + result: ScopeMeasurementStatisticsV2, + *, + request: ScopeMeasurementStatisticsRequestV2, + ) -> None: + """Reject result selectors and buffer values outside the R1 contract.""" + + if not isinstance(result, ScopeMeasurementStatisticsV2): + raise TypeError("measurement statistics V2 driver returned an invalid result") + if result.selector != request.selector: + raise ValueError("measurement statistics V2 result selector does not match request") + if result.buffered_values is not None: + raise ValueError("measurement statistics V2 R1 result must not include a buffer") + + +@dataclass(frozen=True, slots=True) +class ScopeFftStatusProfileV2: + """Descriptor-owned pure-text query contract for the current FFT status.""" + + readable_fields: tuple[ScopeFftStatusFieldV2, ...] + max_queries: int + allowed_effect: Literal["pure_read"] = "pure_read" + + def __post_init__(self) -> None: + if not isinstance(self.readable_fields, tuple): + raise TypeError("FFT status readable_fields must be a tuple") + if not self.readable_fields: + raise ValueError("FFT status readable_fields must not be empty") + if len(set(self.readable_fields)) != len(self.readable_fields): + raise ValueError("FFT status readable_fields must not contain duplicates") + if not set(self.readable_fields) <= set(SCOPE_FFT_STATUS_V2_FIELD_ORDER): + raise ValueError("FFT status readable_fields contain unsupported paths") + expected = tuple( + field_name + for field_name in SCOPE_FFT_STATUS_V2_FIELD_ORDER + if field_name in self.readable_fields + ) + if self.readable_fields != expected: + raise ValueError("FFT status readable_fields must use stable field order") + start_present = "frequency_start_hz" in self.readable_fields + stop_present = "frequency_stop_hz" in self.readable_fields + if start_present != stop_present: + raise ValueError( + "FFT status frequency range fields must be readable together" + ) + _strict_int( + self.max_queries, + label="FFT status max_queries", + minimum=1, + maximum=SCOPE_FFT_STATUS_V2_MAX_QUERIES, + ) + _literal(self.allowed_effect, {"pure_read"}, label="FFT status effect") + + def validate_result(self, result: ScopeFftStatusV2, *, math_index: int) -> None: + """Reject results that expand or silently shrink this FFT profile.""" + + if not isinstance(result, ScopeFftStatusV2): + raise TypeError("FFT status V2 driver returned an invalid result") + if result.math_index != math_index: + raise ValueError("FFT status V2 driver returned the wrong math_index") + readable = set(self.readable_fields) + unavailable = set(result.unavailable_fields) + for field_name, value in result.field_values().items(): + if field_name in readable: + if value is None or field_name in unavailable: + raise ValueError("FFT status V2 readable fields must have a value") + elif value is not None or field_name not in unavailable: + raise ValueError( + "FFT status V2 result provided a field outside the descriptor profile" + ) + + +ScopeCursorAddressing = Literal["global", "indexed"] +_SCOPE_CURSOR_READOUT_V2_QUANTITY_FIELDS = frozenset( + { + "x_a", + "x_b", + "x_delta", + "inverse_x_delta", + "y_a", + "y_b", + "y_delta", + } +) + + +@dataclass(frozen=True, slots=True) +class ScopeCursorReadoutProfileV2: + """Descriptor-owned pure-text query contract for the current cursor readout.""" + + readable_fields: tuple[ScopeCursorReadoutFieldV2, ...] + conditionally_applicable_fields: tuple[ScopeCursorReadoutFieldV2, ...] + addressing: ScopeCursorAddressing + max_queries: int + allowed_effect: Literal["pure_read"] = "pure_read" + + def __post_init__(self) -> None: + self._validate_fields(self.readable_fields, label="cursor readout readable_fields") + if not self.readable_fields: + raise ValueError("cursor readout readable_fields must not be empty") + self._validate_fields( + self.conditionally_applicable_fields, + label="cursor readout conditionally_applicable_fields", + ) + readable = set(self.readable_fields) + conditional = set(self.conditionally_applicable_fields) + if not conditional <= readable: + raise ValueError( + "cursor readout conditionally_applicable_fields must be readable" + ) + if not readable & _SCOPE_CURSOR_READOUT_V2_QUANTITY_FIELDS: + raise ValueError("cursor readout readable_fields must include a quantity") + self._validate_source_pair(readable, label="cursor readout readable_fields") + self._validate_source_pair( + conditional, + label="cursor readout conditionally_applicable_fields", + ) + _literal(self.addressing, {"global", "indexed"}, label="cursor readout addressing") + cursor_readable = "cursor_index" in readable + cursor_conditional = "cursor_index" in conditional + if self.addressing == "global" and (cursor_readable or cursor_conditional): + raise ValueError("global cursor addressing must not make cursor_index readable") + if self.addressing == "indexed" and ( + not cursor_readable or cursor_conditional + ): + raise ValueError( + "indexed cursor addressing requires a non-conditional cursor_index" + ) + _strict_int( + self.max_queries, + label="cursor readout max_queries", + minimum=1, + maximum=SCOPE_CURSOR_READOUT_V2_MAX_QUERIES, + ) + _literal(self.allowed_effect, {"pure_read"}, label="cursor readout effect") + + @staticmethod + def _validate_fields( + fields: object, + *, + label: str, + ) -> None: + if not isinstance(fields, tuple): + raise TypeError(f"{label} must be a tuple") + if len(set(fields)) != len(fields): + raise ValueError(f"{label} must not contain duplicates") + if not set(fields) <= set(SCOPE_CURSOR_READOUT_V2_FIELD_ORDER): + raise ValueError(f"{label} contain unsupported paths") + expected = tuple( + field_name + for field_name in SCOPE_CURSOR_READOUT_V2_FIELD_ORDER + if field_name in fields + ) + if fields != expected: + raise ValueError(f"{label} must use stable field order") + + @staticmethod + def _validate_source_pair(fields: set[str], *, label: str) -> None: + if ("source_a" in fields) != ("source_b" in fields): + raise ValueError(f"{label} must include source_a and source_b together") + + def validate_request( + self, + *, + cursor_index: int | None, + configured_cursor: bool, + ) -> None: + """Reject an incompatible cursor addressing request before instrument I/O.""" + + if configured_cursor is not True: + raise ValueError("cursor readout V2 requires configured_cursor=True") + if self.addressing == "global": + if cursor_index is not None: + raise ValueError("global cursor addressing requires cursor_index=None") + return + if ( + isinstance(cursor_index, bool) + or not isinstance(cursor_index, int) + or cursor_index < 1 + ): + raise ValueError("indexed cursor addressing requires a positive cursor_index") + + def validate_result( + self, + result: ScopeCursorReadoutV2, + *, + cursor_index: int | None, + ) -> None: + """Reject results that expand or silently shrink this cursor profile.""" + + if not isinstance(result, ScopeCursorReadoutV2): + raise TypeError("cursor readout V2 driver returned an invalid result") + unavailable = set(result.unavailable_fields) + not_applicable = set(result.not_applicable_fields) + if self.addressing == "global": + if result.cursor_index is not None: + raise ValueError("global cursor readout V2 must return cursor_index=None") + if ( + "cursor_index" not in not_applicable + or "cursor_index" in unavailable + ): + raise ValueError( + "global cursor readout V2 must mark cursor_index not applicable" + ) + elif result.cursor_index != cursor_index: + raise ValueError("indexed cursor readout V2 returned the wrong cursor_index") + readable = set(self.readable_fields) + conditional = set(self.conditionally_applicable_fields) + for field_name, value in result.field_values().items(): + if self.addressing == "global" and field_name == "cursor_index": + continue + if field_name not in readable: + if ( + value is not None + or field_name not in unavailable + or field_name in not_applicable + ): + raise ValueError( + "cursor readout V2 result provided a field outside the descriptor profile" + ) + continue + if field_name in conditional: + if value is None and field_name not in not_applicable: + raise ValueError( + "cursor readout V2 conditional fields must be present or not applicable" + ) + continue + if value is None or field_name in unavailable or field_name in not_applicable: + raise ValueError("cursor readout V2 readable fields must have a value") + + @dataclass(frozen=True, slots=True) class ScopeAcquisitionControlSnapshot: run_state: ScopeAcquisitionRunState @@ -639,6 +1376,7 @@ class ScopeAcquisitionCompletion: baseline_identity: str | None = None completed_identity: str | None = None observed_states: tuple[ScopeAcquisitionRunState, ...] = () + post_arm_trigger_mode: ScopeTriggerMode | None = None def __post_init__(self) -> None: if any( @@ -653,9 +1391,20 @@ def __post_init__(self) -> None: ) _literal( self.proof, - {"count_delta_with_epoch", "identity_delta", "state_transition"}, + { + "count_delta_with_epoch", + "identity_delta", + "state_transition", + "single_mode_readback_then_stopped", + }, label="completion proof", ) + if self.post_arm_trigger_mode is not None: + _literal( + self.post_arm_trigger_mode, + {"auto", "normal", "single", "roll", "unknown"}, + label="post-arm trigger mode", + ) if not isinstance(self.observed_states, tuple) or not self.observed_states: raise ValueError("completion proof must retain observed states") if any(not isinstance(item, ScopeAcquisitionRunState) for item in self.observed_states): @@ -716,7 +1465,26 @@ def validate_acquisition_completion( ) for item in completion.observed_states[:-1] ) - if completion.proof == "identity_delta": + if completion.proof == "single_mode_readback_then_stopped": + if not profile.single_mode_readback_allows_terminal_stop: + raise ValueError("terminal STOP proof is not enabled by the acquisition profile") + if completion.post_arm_trigger_mode != "single": + raise ValueError("terminal STOP proof requires a post-arm single-mode readback") + if completion.state.phase != "stopped" or completion.state.trigger_mode != "single": + raise ValueError("terminal STOP proof requires a stopped single-mode state") + if completion.observed_states != (completion.state,): + raise ValueError("terminal STOP proof requires exactly one terminal observed state") + if any( + value is not None + for value in ( + completion.baseline_count, + completion.completed_count, + completion.baseline_identity, + completion.completed_identity, + ) + ): + raise ValueError("terminal STOP proof cannot claim count or identity evidence") + elif completion.proof == "identity_delta": if profile.identity_semantics != "unique_within_session_epoch": raise ValueError("identity proof requires unique-within-epoch semantics") if ( @@ -748,21 +1516,15 @@ def validate_acquisition_completion( raise ValueError("completion proof has an invalid terminal phase") -ScopeTraceKind = Literal["analog", "digital", "math", "reference", "spectrum"] -ScopeAxisKind = Literal["time", "frequency", "index", "unknown"] -ScopeAxisUnit = Literal["s", "Hz", "1", "unknown"] -ScopeTraceUnit = Literal["v", "mv", "db", "dbm", "1", "unknown"] -ScopeTraceMagnitudeSemantics = Literal["absolute", "relative", "linear", "unknown"] -ScopeTraceOperation = Literal[ - "identity", - "reference_copy", - "fft_magnitude", - "fft_phase", - "device_other", - "unknown", -] -ScopeTraceTransferField = Literal[ +ScopeAverageMechanism = Literal["global_acquisition"] +ScopeAverageCompletionEvidence = Literal["device_average_complete"] +ScopeAverageCaptureField = Literal[ "scope.run_state", + "scope.acquisition", + "scope.trigger", + "scope.timebase", + "scope.channel_display", + "scope.channel_vertical", "scope.waveform_source", "scope.waveform_mode", "scope.query_response_header", @@ -771,10 +1533,13 @@ def validate_acquisition_completion( "scope.waveform_points", "scope.waveform_transfer_window", ] -_TRACE_KINDS = {"analog", "digital", "math", "reference", "spectrum"} -_TRACE_FETCHABLE_KINDS = {"analog", "digital", "reference"} -_TRACE_TRANSFER_FIELDS = { +SCOPE_AVERAGE_CAPTURE_FIELD_ORDER: tuple[ScopeAverageCaptureField, ...] = ( "scope.run_state", + "scope.acquisition", + "scope.trigger", + "scope.timebase", + "scope.channel_display", + "scope.channel_vertical", "scope.waveform_source", "scope.waveform_mode", "scope.query_response_header", @@ -782,9 +1547,15 @@ def validate_acquisition_completion( "scope.waveform_byte_order", "scope.waveform_points", "scope.waveform_transfer_window", -} -_TRACE_TOKEN_ATTRS = { +) +_AVERAGE_CAPTURE_FIELDS = frozenset(SCOPE_AVERAGE_CAPTURE_FIELD_ORDER) +_AVERAGE_CAPTURE_TOKEN_ATTRS = { "scope.run_state": "run_state_token", + "scope.acquisition": "acquisition_token", + "scope.trigger": "trigger_token", + "scope.timebase": "timebase_token", + "scope.channel_display": "channel_display_token", + "scope.channel_vertical": "channel_vertical_token", "scope.waveform_source": "waveform_source_token", "scope.waveform_mode": "waveform_mode_token", "scope.query_response_header": "query_response_header_token", @@ -793,118 +1564,647 @@ def validate_acquisition_completion( "scope.waveform_points": "waveform_points_token", "scope.waveform_transfer_window": "waveform_transfer_window_token", } +_AVERAGE_CAPTURE_POINTS = ("def", "max", "dmax") + + +def _average_capture_field_tuple( + values: object, + *, + label: str, + allow_empty: bool = False, +) -> tuple[ScopeAverageCaptureField, ...]: + if not isinstance(values, tuple): + raise TypeError(f"{label} must be a tuple") + if not values and not allow_empty: + raise ValueError(f"{label} must not be empty") + if len(set(values)) != len(values) or not set(values) <= _AVERAGE_CAPTURE_FIELDS: + raise ValueError(f"{label} must contain supported unique average capture fields") + expected = tuple( + field_name for field_name in SCOPE_AVERAGE_CAPTURE_FIELD_ORDER if field_name in values + ) + if values != expected: + raise ValueError(f"{label} must use stable average capture field order") + return values @dataclass(frozen=True, slots=True) -class ScopeTraceRef: - kind: ScopeTraceKind - index: int | None = None - name: str | None = None +class ScopeAverageCaptureRequestV2: + channels: tuple[int, ...] + average_count: int + mechanism: ScopeAverageMechanism + acquisition_stopped: Literal[True] + points: str = "dmax" + allow_50ohm: bool = False def __post_init__(self) -> None: - _literal(self.kind, _TRACE_KINDS, label="trace kind") - if (self.index is None) == (self.name is None): - raise ValueError("trace reference must provide exactly one of index or name") - if self.index is not None: - if self.kind == "digital": - _strict_int(self.index, label="digital trace index", minimum=0, maximum=15) - else: - _strict_int(self.index, label="trace index", minimum=1, maximum=65_535) - if self.name is not None and ( - not isinstance(self.name, str) - or not 1 <= len(self.name) <= 64 - or self.name.strip() != self.name - or not self.name.isprintable() - ): - raise ValueError("trace name must be 1..64 printable trimmed code points") + if not isinstance(self.channels, tuple) or len(self.channels) != 1: + raise ValueError("average capture V2 requires exactly one channel") + channel = self.channels[0] + _strict_int(channel, label="average capture V2 channel", minimum=1) + _strict_int( + self.average_count, + label="average capture V2 average_count", + minimum=2, + maximum=SCOPE_AVERAGE_COUNT_MAX_V2, + ) + _literal( + self.mechanism, + {"global_acquisition"}, + label="average capture V2 mechanism", + ) + if self.acquisition_stopped is not True: + raise ValueError("average capture V2 requires acquisition_stopped=True") + _literal( + self.points, + set(_AVERAGE_CAPTURE_POINTS), + label="average capture V2 points", + ) + if not isinstance(self.allow_50ohm, bool): + raise TypeError("average capture V2 allow_50ohm must be bool") @dataclass(frozen=True, slots=True) -class ScopeAxisMetadata: - kind: ScopeAxisKind - unit: ScopeAxisUnit - start: float | None - increment: float | None - points: int +class ScopeAverageConfigurationV2: + mechanism: ScopeAverageMechanism + acquisition_type: str + average_count: int def __post_init__(self) -> None: - _literal(self.kind, {"time", "frequency", "index", "unknown"}, label="axis kind") - _literal(self.unit, {"s", "Hz", "1", "unknown"}, label="axis unit") - points = _strict_int(self.points, label="axis points", minimum=1, maximum=SCOPE_TRACE_MAX_POINTS) - if self.kind == "unknown": - if self.unit != "unknown" or self.start is not None or self.increment is not None: - raise ValueError("unknown axes cannot claim units or scaling") - return - expected_unit = {"time": "s", "frequency": "Hz", "index": "1"}[self.kind] - if self.unit != expected_unit: - raise ValueError("axis kind and unit are inconsistent") - start = _finite(self.start, label="axis start") - increment = _finite(self.increment, label="axis increment") - if increment <= 0: - raise ValueError("axis increment must be positive") - if not isfinite(start + increment * (points - 1)): - raise ValueError("axis final coordinate must be finite") - if self.kind == "frequency" and start < 0: - raise ValueError("frequency axes cannot start below zero") + _literal( + self.mechanism, + {"global_acquisition"}, + label="average configuration mechanism", + ) + _safe_token(self.acquisition_type, label="average configuration acquisition_type") + _strict_int( + self.average_count, + label="average configuration average_count", + minimum=1, + maximum=SCOPE_AVERAGE_COUNT_MAX_V2, + ) @dataclass(frozen=True, slots=True) -class ScopeTraceMetadata: - source: ScopeTraceRef - x_axis: ScopeAxisMetadata - y_unit: ScopeTraceUnit - y_semantics: ScopeTraceMagnitudeSemantics - value_encoding: Literal["real", "digital_bitmask"] - y_increment: float | None = None - y_origin: float | None = None - y_resolution_bits: int | None = None - operation: ScopeTraceOperation = "unknown" - inputs: tuple[ScopeTraceRef, ...] = () - digital_channels: tuple[int, ...] = () - fetchable: bool = False +class ScopeAverageCompletionProofV2: + evidence: ScopeAverageCompletionEvidence + mechanism: ScopeAverageMechanism + configured_average_count: int + configuration_readback: ScopeAverageConfigurationV2 + acquisition_completion: ScopeAcquisitionCompletion + device_average_complete: Literal[True] + contract_id: str + context_id: str + session_epoch: str + acquisition_baseline_nonce_digest: str def __post_init__(self) -> None: - if not isinstance(self.source, ScopeTraceRef) or not isinstance( - self.x_axis, ScopeAxisMetadata - ): - raise TypeError("trace metadata source or axis has an invalid type") - _literal(self.y_unit, {"v", "mv", "db", "dbm", "1", "unknown"}, label="trace unit") _literal( - self.y_semantics, - {"absolute", "relative", "linear", "unknown"}, - label="trace magnitude semantics", + self.evidence, + {"device_average_complete"}, + label="average completion evidence", ) - _literal(self.value_encoding, {"real", "digital_bitmask"}, label="value encoding") _literal( - self.operation, - {"identity", "reference_copy", "fft_magnitude", "fft_phase", "device_other", "unknown"}, - label="trace operation", + self.mechanism, + {"global_acquisition"}, + label="average completion mechanism", ) - if not isinstance(self.fetchable, bool): - raise TypeError("trace fetchable must be bool") - inputs = _unique_tuple(self.inputs, label="trace inputs") - if any(not isinstance(item, ScopeTraceRef) for item in inputs): - raise TypeError("trace inputs have an invalid type") - channels = _unique_tuple(self.digital_channels, label="digital_channels") - for channel in channels: - _strict_int(channel, label="digital channel", minimum=0, maximum=15) - scaling_values = (self.y_increment, self.y_origin) - if (self.y_increment is None) != (self.y_origin is None): - raise ValueError("trace y increment and origin must be present together") - if self.y_increment is not None: - if _finite(self.y_increment, label="y_increment") == 0: - raise ValueError("trace y increment cannot be zero") - _finite(self.y_origin, label="y_origin") - if self.y_resolution_bits is not None: - _strict_int(self.y_resolution_bits, label="y_resolution_bits", minimum=1, maximum=64) - if self.value_encoding == "digital_bitmask": - if ( - self.source.kind != "digital" - or self.y_unit != "1" - or self.y_semantics != "unknown" - or scaling_values != (None, None) - or self.y_resolution_bits is not None - or self.source.index is None + _strict_int( + self.configured_average_count, + label="average completion configured_average_count", + minimum=2, + maximum=SCOPE_AVERAGE_COUNT_MAX_V2, + ) + if not isinstance(self.configuration_readback, ScopeAverageConfigurationV2): + raise TypeError("average completion configuration_readback has an invalid type") + if self.configuration_readback.mechanism != self.mechanism: + raise ValueError("average completion mechanism does not match configuration readback") + if self.configuration_readback.average_count != self.configured_average_count: + raise ValueError("average completion count does not match configuration readback") + if not isinstance(self.acquisition_completion, ScopeAcquisitionCompletion): + raise TypeError("average completion acquisition_completion has an invalid type") + if self.acquisition_completion.proof == "single_mode_readback_then_stopped": + raise ValueError("average completion cannot use a terminal STOP proof") + if self.device_average_complete is not True: + raise ValueError("average completion requires device_average_complete=True") + _safe_token(self.contract_id, label="average completion contract_id") + _safe_token(self.context_id, label="average completion context_id") + _safe_token(self.session_epoch, label="average completion session_epoch") + if ( + not isinstance(self.acquisition_baseline_nonce_digest, str) + or re.fullmatch(r"[0-9a-f]{16}", self.acquisition_baseline_nonce_digest) is None + ): + raise ValueError("average completion baseline nonce digest must be 16 lowercase hex chars") + + +@dataclass(frozen=True, slots=True) +class ScopeAverageCaptureStateSnapshot: + captured_fields: tuple[ScopeAverageCaptureField, ...] + configuration: ScopeAverageConfigurationV2 + run_state: ScopeAcquisitionRunState + run_state_token: str | None = None + acquisition_token: str | None = None + trigger_token: str | None = None + timebase_token: str | None = None + channel_display_token: str | None = None + channel_vertical_token: str | None = None + waveform_source_token: str | None = None + waveform_mode_token: str | None = None + query_response_header_token: str | None = None + waveform_format_token: str | None = None + waveform_byte_order_token: str | None = None + waveform_points_token: str | None = None + waveform_transfer_window_token: str | None = None + + def __post_init__(self) -> None: + fields = _average_capture_field_tuple( + self.captured_fields, + label="average capture snapshot captured_fields", + ) + if not isinstance(self.configuration, ScopeAverageConfigurationV2): + raise TypeError("average capture snapshot configuration has an invalid type") + if not isinstance(self.run_state, ScopeAcquisitionRunState): + raise TypeError("average capture snapshot run_state has an invalid type") + for field_name, attr_name in _AVERAGE_CAPTURE_TOKEN_ATTRS.items(): + token = _optional_safe_token(getattr(self, attr_name), label=attr_name) + if (field_name in fields) != (token is not None): + raise ValueError(f"{attr_name} presence must match captured fields") + + +@dataclass(frozen=True, slots=True) +class ScopeAverageCaptureBaseline: + context_id: str + session_epoch: str + baseline_nonce: str + snapshot: ScopeAverageCaptureStateSnapshot + restore_order: tuple[ScopeAverageCaptureField, ...] + acquisition_baseline: ScopeAcquisitionControlBaseline + + def __post_init__(self) -> None: + _safe_token(self.context_id, label="average capture baseline context_id") + _safe_token(self.session_epoch, label="average capture baseline session_epoch") + _safe_token(self.baseline_nonce, label="average capture baseline nonce") + if not isinstance(self.snapshot, ScopeAverageCaptureStateSnapshot): + raise TypeError("average capture baseline snapshot has an invalid type") + restore_order = _average_capture_field_tuple( + self.restore_order, + label="average capture restore_order", + ) + if restore_order != self.snapshot.captured_fields: + raise ValueError("average capture restore order must match snapshot fields exactly") + if not isinstance(self.acquisition_baseline, ScopeAcquisitionControlBaseline): + raise TypeError("average capture acquisition baseline has an invalid type") + acquisition_baseline = self.acquisition_baseline + if ( + acquisition_baseline.context_id != self.context_id + or acquisition_baseline.session_epoch != self.session_epoch + or acquisition_baseline.baseline_nonce == self.baseline_nonce + ): + raise ValueError("average capture child baseline has an invalid context binding") + if acquisition_baseline.snapshot.run_state != self.snapshot.run_state: + raise ValueError("average capture child baseline run state does not match parent") + if acquisition_baseline.snapshot.trigger_state_token != self.snapshot.trigger_token: + raise ValueError("average capture child baseline trigger token does not match parent") + if acquisition_baseline.snapshot.acquisition_state_token != self.snapshot.acquisition_token: + raise ValueError("average capture child baseline acquisition token does not match parent") + + +@dataclass(frozen=True, slots=True) +class ScopeAverageCaptureRestoreResult: + status: Literal["completed", "failed", "not_attempted"] + attempted_fields: tuple[ScopeAverageCaptureField, ...] + restored_fields: tuple[ScopeAverageCaptureField, ...] + error_code: str | None = None + + def __post_init__(self) -> None: + _literal(self.status, {"completed", "failed", "not_attempted"}, label="restore status") + _average_capture_field_tuple( + self.attempted_fields, + label="average capture attempted_fields", + allow_empty=True, + ) + _average_capture_field_tuple( + self.restored_fields, + label="average capture restored_fields", + allow_empty=True, + ) + _optional_safe_token(self.error_code, label="average capture restore error_code") + + def validate_for(self, baseline: ScopeAverageCaptureBaseline) -> None: + if not isinstance(baseline, ScopeAverageCaptureBaseline): + raise TypeError("average capture restore baseline has an invalid type") + _validate_prefix_and_subsequence( + expected=baseline.restore_order, + attempted=self.attempted_fields, + completed=self.restored_fields, + status=self.status, + ) + + +@dataclass(frozen=True, slots=True) +class ScopeAverageCaptureVerification: + status: Literal["verified", "mismatch", "unavailable"] + verified_fields: tuple[ScopeAverageCaptureField, ...] + mismatched_fields: tuple[ScopeAverageCaptureField, ...] + error_code: str | None = None + + def __post_init__(self) -> None: + _literal( + self.status, + {"verified", "mismatch", "unavailable"}, + label="average capture verification status", + ) + verified = _average_capture_field_tuple( + self.verified_fields, + label="average capture verified_fields", + allow_empty=True, + ) + mismatched = _average_capture_field_tuple( + self.mismatched_fields, + label="average capture mismatched_fields", + allow_empty=True, + ) + if set(verified) & set(mismatched): + raise ValueError("average capture verification fields overlap") + _optional_safe_token(self.error_code, label="average capture verification error_code") + if self.status == "verified" and mismatched: + raise ValueError("verified average capture state cannot contain mismatches") + if self.status == "mismatch" and not mismatched: + raise ValueError("mismatched average capture verification requires mismatch fields") + if self.status == "unavailable" and (verified or mismatched): + raise ValueError("unavailable average capture verification cannot claim fields") + + def validate_for(self, baseline: ScopeAverageCaptureBaseline) -> None: + if not isinstance(baseline, ScopeAverageCaptureBaseline): + raise TypeError("average capture verification baseline has an invalid type") + if self.status == "verified" and self.verified_fields != baseline.restore_order: + raise ValueError("verified average capture state must cover every restore field") + if not set(self.verified_fields + self.mismatched_fields) <= set(baseline.restore_order): + raise ValueError("average capture verification fields exceed the restore closure") + + +@dataclass(frozen=True, slots=True) +class ScopeAverageCaptureResultV2: + request: ScopeAverageCaptureRequestV2 + waveforms: tuple[WaveformData, ...] + configuration_before: ScopeAverageConfigurationV2 + configuration_after: ScopeAverageConfigurationV2 + run_state_before: ScopeAcquisitionRunState + run_state_after: ScopeAcquisitionRunState + completion: ScopeAverageCompletionProofV2 + restore: ScopeAverageCaptureRestoreResult + verification: ScopeAverageCaptureVerification + + def __post_init__(self) -> None: + if not isinstance(self.request, ScopeAverageCaptureRequestV2): + raise TypeError("average capture result request has an invalid type") + if ( + not isinstance(self.waveforms, tuple) + or len(self.waveforms) != 1 + or not isinstance(self.waveforms[0], WaveformData) + or self.waveforms[0].channel != self.request.channels[0] + ): + raise ValueError("average capture result waveform does not match the request") + for label, value, expected in ( + ("configuration_before", self.configuration_before, ScopeAverageConfigurationV2), + ("configuration_after", self.configuration_after, ScopeAverageConfigurationV2), + ("run_state_before", self.run_state_before, ScopeAcquisitionRunState), + ("run_state_after", self.run_state_after, ScopeAcquisitionRunState), + ("completion", self.completion, ScopeAverageCompletionProofV2), + ("restore", self.restore, ScopeAverageCaptureRestoreResult), + ("verification", self.verification, ScopeAverageCaptureVerification), + ): + if not isinstance(value, expected): + raise TypeError(f"average capture result {label} has an invalid type") + if self.configuration_after != self.configuration_before: + raise ValueError("average capture result configuration was not restored") + if self.run_state_before.phase != "stopped" or self.run_state_after != self.run_state_before: + raise ValueError("average capture result run state was not restored to a stopped baseline") + if self.completion.mechanism != self.request.mechanism or ( + self.completion.configured_average_count != self.request.average_count + ): + raise ValueError("average capture completion proof does not match the request") + if self.restore.status != "completed" or self.verification.status != "verified": + raise ValueError("average capture result requires completed restore and verification") + + +@dataclass(frozen=True, slots=True) +class ScopeAverageCaptureBinaryProfile: + response_max_bytes: int + operation_max_bytes: int + query_max_count: int + resynchronization_max_bytes: int + framing: BinaryResponseFraming = BinaryResponseFraming.DEFINITE_BLOCK + transport_trailing_hex: str = "" + + def __post_init__(self) -> None: + object.__setattr__(self, "framing", BinaryResponseFraming(self.framing)) + if self.framing is not BinaryResponseFraming.DEFINITE_BLOCK: + raise ValueError("average capture binary profiles only support definite-block framing") + _strict_int( + self.response_max_bytes, + label="average capture response_max_bytes", + minimum=1, + maximum=SCOPE_AVERAGE_CAPTURE_V2_BINARY_RESPONSE_MAX_BYTES, + ) + _strict_int( + self.operation_max_bytes, + label="average capture operation_max_bytes", + minimum=1, + maximum=SCOPE_AVERAGE_CAPTURE_V2_BINARY_OPERATION_MAX_BYTES, + ) + if self.operation_max_bytes < self.response_max_bytes: + raise ValueError("average capture operation limit cannot be smaller than response limit") + _strict_int( + self.query_max_count, + label="average capture query_max_count", + minimum=1, + maximum=SCOPE_AVERAGE_CAPTURE_V2_BINARY_QUERY_MAX_COUNT, + ) + _strict_int( + self.resynchronization_max_bytes, + label="average capture resynchronization_max_bytes", + minimum=0, + maximum=SCOPE_AVERAGE_CAPTURE_V2_BINARY_RESYNCHRONIZATION_MAX_BYTES, + ) + _hex_bytes(self.transport_trailing_hex, label="average capture transport_trailing_hex") + + @property + def transport_trailing(self) -> bytes: + return bytes.fromhex(self.transport_trailing_hex) + + +@dataclass(frozen=True, slots=True) +class ScopeAverageCaptureProfileV2: + global_acquisition_type: str + completion_contract_id: str + channel_range: tuple[int, int] + supported_points: tuple[str, ...] + average_count_min: int + average_count_max: int + requires_power_of_two: bool + binary: ScopeAverageCaptureBinaryProfile + restore_order: tuple[ScopeAverageCaptureField, ...] + snapshot_max_steps: int + main_max_steps: int + restore_max_steps: int + verify_max_steps: int + + def __post_init__(self) -> None: + _safe_token(self.global_acquisition_type, label="average capture global_acquisition_type") + _safe_token(self.completion_contract_id, label="average capture completion_contract_id") + if ( + not isinstance(self.channel_range, tuple) + or len(self.channel_range) != 2 + ): + raise ValueError("average capture channel_range must contain exactly two integers") + minimum_channel = _strict_int( + self.channel_range[0], + label="average capture channel_range minimum", + minimum=1, + ) + maximum_channel = _strict_int( + self.channel_range[1], + label="average capture channel_range maximum", + minimum=1, + ) + if minimum_channel > maximum_channel: + raise ValueError("average capture channel_range minimum exceeds maximum") + if not isinstance(self.supported_points, tuple) or not self.supported_points: + raise ValueError("average capture supported_points must be a non-empty tuple") + if len(set(self.supported_points)) != len(self.supported_points) or not set( + self.supported_points + ) <= set(_AVERAGE_CAPTURE_POINTS): + raise ValueError("average capture supported_points contain unsupported or duplicate values") + expected_points = tuple( + point for point in _AVERAGE_CAPTURE_POINTS if point in self.supported_points + ) + if self.supported_points != expected_points: + raise ValueError("average capture supported_points must use stable point order") + minimum_count = _strict_int( + self.average_count_min, + label="average capture average_count_min", + minimum=2, + maximum=SCOPE_AVERAGE_COUNT_MAX_V2, + ) + maximum_count = _strict_int( + self.average_count_max, + label="average capture average_count_max", + minimum=2, + maximum=SCOPE_AVERAGE_COUNT_MAX_V2, + ) + if minimum_count > maximum_count: + raise ValueError("average capture average_count_min exceeds average_count_max") + if not isinstance(self.requires_power_of_two, bool): + raise TypeError("average capture requires_power_of_two must be bool") + if not isinstance(self.binary, ScopeAverageCaptureBinaryProfile): + raise TypeError("average capture binary profile has an invalid type") + restore_order = _average_capture_field_tuple( + self.restore_order, + label="average capture profile restore_order", + ) + if restore_order != SCOPE_AVERAGE_CAPTURE_FIELD_ORDER: + raise ValueError("average capture profile restore_order must cover the full R1 closure") + for label, value in ( + ("snapshot_max_steps", self.snapshot_max_steps), + ("restore_max_steps", self.restore_max_steps), + ("verify_max_steps", self.verify_max_steps), + ): + _strict_int(value, label=f"average capture {label}", minimum=len(restore_order), maximum=64) + _strict_int( + self.main_max_steps, + label="average capture main_max_steps", + minimum=8, + maximum=128, + ) + + def validate_request(self, request: ScopeAverageCaptureRequestV2) -> None: + if not isinstance(request, ScopeAverageCaptureRequestV2): + raise TypeError("average capture request has an invalid type") + channel = request.channels[0] + if not self.channel_range[0] <= channel <= self.channel_range[1]: + raise ValueError("average capture channel is outside the descriptor range") + if request.points not in self.supported_points: + raise ValueError("average capture points are unsupported by the descriptor profile") + if not self.average_count_min <= request.average_count <= self.average_count_max: + raise ValueError("average capture count is outside the descriptor range") + if self.requires_power_of_two and request.average_count & (request.average_count - 1): + raise ValueError("average capture count must be a power of two") + + def validate_configuration( + self, + configuration: ScopeAverageConfigurationV2, + *, + request: ScopeAverageCaptureRequestV2 | None = None, + ) -> None: + if not isinstance(configuration, ScopeAverageConfigurationV2): + raise TypeError("average capture configuration has an invalid type") + if configuration.mechanism != "global_acquisition": + raise ValueError("average capture configuration mechanism is unsupported") + if configuration.acquisition_type != self.global_acquisition_type: + raise ValueError("average capture configuration type does not match the profile") + if request is not None: + self.validate_request(request) + if configuration.average_count != request.average_count: + raise ValueError("average capture configuration count does not match the request") + + +ScopeTraceKind = Literal["analog", "digital", "math", "reference", "spectrum"] +ScopeAxisKind = Literal["time", "frequency", "index", "unknown"] +ScopeAxisUnit = Literal["s", "Hz", "1", "unknown"] +ScopeTraceUnit = Literal["v", "mv", "db", "dbm", "1", "unknown"] +ScopeTraceMagnitudeSemantics = Literal["absolute", "relative", "linear", "unknown"] +ScopeTraceOperation = Literal[ + "identity", + "reference_copy", + "fft_magnitude", + "fft_phase", + "device_other", + "unknown", +] +ScopeTraceTransferField = Literal[ + "scope.run_state", + "scope.waveform_source", + "scope.waveform_mode", + "scope.query_response_header", + "scope.waveform_format", + "scope.waveform_byte_order", + "scope.waveform_points", + "scope.waveform_transfer_window", +] +_TRACE_KINDS = {"analog", "digital", "math", "reference", "spectrum"} +_TRACE_FETCHABLE_KINDS = {"analog", "digital", "reference"} +_TRACE_TRANSFER_FIELDS = { + "scope.run_state", + "scope.waveform_source", + "scope.waveform_mode", + "scope.query_response_header", + "scope.waveform_format", + "scope.waveform_byte_order", + "scope.waveform_points", + "scope.waveform_transfer_window", +} +_TRACE_TOKEN_ATTRS = { + "scope.run_state": "run_state_token", + "scope.waveform_source": "waveform_source_token", + "scope.waveform_mode": "waveform_mode_token", + "scope.query_response_header": "query_response_header_token", + "scope.waveform_format": "waveform_format_token", + "scope.waveform_byte_order": "waveform_byte_order_token", + "scope.waveform_points": "waveform_points_token", + "scope.waveform_transfer_window": "waveform_transfer_window_token", +} + + +@dataclass(frozen=True, slots=True) +class ScopeTraceRef: + kind: ScopeTraceKind + index: int | None = None + name: str | None = None + + def __post_init__(self) -> None: + _literal(self.kind, _TRACE_KINDS, label="trace kind") + if (self.index is None) == (self.name is None): + raise ValueError("trace reference must provide exactly one of index or name") + if self.index is not None: + if self.kind == "digital": + _strict_int(self.index, label="digital trace index", minimum=0, maximum=15) + else: + _strict_int(self.index, label="trace index", minimum=1, maximum=65_535) + if self.name is not None and ( + not isinstance(self.name, str) + or not 1 <= len(self.name) <= 64 + or self.name.strip() != self.name + or not self.name.isprintable() + ): + raise ValueError("trace name must be 1..64 printable trimmed code points") + + +@dataclass(frozen=True, slots=True) +class ScopeAxisMetadata: + kind: ScopeAxisKind + unit: ScopeAxisUnit + start: float | None + increment: float | None + points: int + + def __post_init__(self) -> None: + _literal(self.kind, {"time", "frequency", "index", "unknown"}, label="axis kind") + _literal(self.unit, {"s", "Hz", "1", "unknown"}, label="axis unit") + points = _strict_int(self.points, label="axis points", minimum=1, maximum=SCOPE_TRACE_MAX_POINTS) + if self.kind == "unknown": + if self.unit != "unknown" or self.start is not None or self.increment is not None: + raise ValueError("unknown axes cannot claim units or scaling") + return + expected_unit = {"time": "s", "frequency": "Hz", "index": "1"}[self.kind] + if self.unit != expected_unit: + raise ValueError("axis kind and unit are inconsistent") + start = _finite(self.start, label="axis start") + increment = _finite(self.increment, label="axis increment") + if increment <= 0: + raise ValueError("axis increment must be positive") + if not isfinite(start + increment * (points - 1)): + raise ValueError("axis final coordinate must be finite") + if self.kind == "frequency" and start < 0: + raise ValueError("frequency axes cannot start below zero") + + +@dataclass(frozen=True, slots=True) +class ScopeTraceMetadata: + source: ScopeTraceRef + x_axis: ScopeAxisMetadata + y_unit: ScopeTraceUnit + y_semantics: ScopeTraceMagnitudeSemantics + value_encoding: Literal["real", "digital_bitmask"] + y_increment: float | None = None + y_origin: float | None = None + y_resolution_bits: int | None = None + operation: ScopeTraceOperation = "unknown" + inputs: tuple[ScopeTraceRef, ...] = () + digital_channels: tuple[int, ...] = () + fetchable: bool = False + + def __post_init__(self) -> None: + if not isinstance(self.source, ScopeTraceRef) or not isinstance( + self.x_axis, ScopeAxisMetadata + ): + raise TypeError("trace metadata source or axis has an invalid type") + _literal(self.y_unit, {"v", "mv", "db", "dbm", "1", "unknown"}, label="trace unit") + _literal( + self.y_semantics, + {"absolute", "relative", "linear", "unknown"}, + label="trace magnitude semantics", + ) + _literal(self.value_encoding, {"real", "digital_bitmask"}, label="value encoding") + _literal( + self.operation, + {"identity", "reference_copy", "fft_magnitude", "fft_phase", "device_other", "unknown"}, + label="trace operation", + ) + if not isinstance(self.fetchable, bool): + raise TypeError("trace fetchable must be bool") + inputs = _unique_tuple(self.inputs, label="trace inputs") + if any(not isinstance(item, ScopeTraceRef) for item in inputs): + raise TypeError("trace inputs have an invalid type") + channels = _unique_tuple(self.digital_channels, label="digital_channels") + for channel in channels: + _strict_int(channel, label="digital channel", minimum=0, maximum=15) + scaling_values = (self.y_increment, self.y_origin) + if (self.y_increment is None) != (self.y_origin is None): + raise ValueError("trace y increment and origin must be present together") + if self.y_increment is not None: + if _finite(self.y_increment, label="y_increment") == 0: + raise ValueError("trace y increment cannot be zero") + _finite(self.y_origin, label="y_origin") + if self.y_resolution_bits is not None: + _strict_int(self.y_resolution_bits, label="y_resolution_bits", minimum=1, maximum=64) + if self.value_encoding == "digital_bitmask": + if ( + self.source.kind != "digital" + or self.y_unit != "1" + or self.y_semantics != "unknown" + or scaling_values != (None, None) + or self.y_resolution_bits is not None + or self.source.index is None or channels != (self.source.index,) ): raise ValueError("digital trace metadata is inconsistent") @@ -1073,6 +2373,354 @@ def __post_init__(self) -> None: raise ValueError("unavailable transfer verification cannot claim fields") +# Standard waveform capture is broader than trace fetch: a standard capture +# may configure acquisition, trigger, timebase and channel state before it +# performs the temporary waveform-transfer setup. Keep its recovery proof +# separate from the frozen trace model so the capture closure is explicit. +ScopeWaveformTransferField = Literal[ + "scope.run_state", + "scope.acquisition", + "scope.trigger", + "scope.timebase", + "scope.channel_display", + "scope.channel_vertical", + "scope.waveform_source", + "scope.waveform_mode", + "scope.query_response_header", + "scope.waveform_format", + "scope.waveform_byte_order", + "scope.waveform_points", + "scope.waveform_transfer_window", +] +_WAVEFORM_TRANSFER_FIELDS = { + "scope.run_state", + "scope.acquisition", + "scope.trigger", + "scope.timebase", + "scope.channel_display", + "scope.channel_vertical", + "scope.waveform_source", + "scope.waveform_mode", + "scope.query_response_header", + "scope.waveform_format", + "scope.waveform_byte_order", + "scope.waveform_points", + "scope.waveform_transfer_window", +} +_WAVEFORM_TOKEN_ATTRS = { + "scope.run_state": "run_state_token", + "scope.acquisition": "acquisition_token", + "scope.trigger": "trigger_token", + "scope.timebase": "timebase_token", + "scope.channel_display": "channel_display_token", + "scope.channel_vertical": "channel_vertical_token", + "scope.waveform_source": "waveform_source_token", + "scope.waveform_mode": "waveform_mode_token", + "scope.query_response_header": "query_response_header_token", + "scope.waveform_format": "waveform_format_token", + "scope.waveform_byte_order": "waveform_byte_order_token", + "scope.waveform_points": "waveform_points_token", + "scope.waveform_transfer_window": "waveform_transfer_window_token", +} +_WAVEFORM_CAPTURE_RECOVERY_FIELDS = frozenset(_WAVEFORM_TRANSFER_FIELDS) + + +@dataclass(frozen=True, slots=True) +class ScopeWaveformTransferStateSnapshot: + captured_fields: tuple[ScopeWaveformTransferField, ...] + run_state_token: str | None = None + acquisition_token: str | None = None + trigger_token: str | None = None + timebase_token: str | None = None + channel_display_token: str | None = None + channel_vertical_token: str | None = None + waveform_source_token: str | None = None + waveform_mode_token: str | None = None + query_response_header_token: str | None = None + waveform_format_token: str | None = None + waveform_byte_order_token: str | None = None + waveform_points_token: str | None = None + waveform_transfer_window_token: str | None = None + + def __post_init__(self) -> None: + fields = _unique_tuple(self.captured_fields, label="captured_fields") + if not set(fields) <= _WAVEFORM_TRANSFER_FIELDS: + raise ValueError("waveform transfer snapshot contains unsupported fields") + for field_name, attr_name in _WAVEFORM_TOKEN_ATTRS.items(): + token = _optional_safe_token(getattr(self, attr_name), label=attr_name) + if (field_name in fields) != (token is not None): + raise ValueError(f"{attr_name} presence must match captured fields") + + +@dataclass(frozen=True, slots=True) +class ScopeWaveformTransferBaseline: + context_id: str + session_epoch: str + baseline_nonce: str + snapshot: ScopeWaveformTransferStateSnapshot + restore_order: tuple[ScopeWaveformTransferField, ...] + + def __post_init__(self) -> None: + _safe_token(self.context_id, label="context_id") + _safe_token(self.session_epoch, label="session_epoch") + _safe_token(self.baseline_nonce, label="baseline_nonce") + if not isinstance(self.snapshot, ScopeWaveformTransferStateSnapshot): + raise TypeError("waveform transfer baseline snapshot has an invalid type") + order = _unique_tuple(self.restore_order, label="restore_order") + if set(order) != set(self.snapshot.captured_fields): + raise ValueError("waveform transfer restore order must cover captured fields exactly") + + +@dataclass(frozen=True, slots=True) +class ScopeWaveformTransferRestoreResult: + status: Literal["completed", "failed", "not_attempted"] + attempted_fields: tuple[ScopeWaveformTransferField, ...] + restored_fields: tuple[ScopeWaveformTransferField, ...] + error_code: str | None = None + + def __post_init__(self) -> None: + _literal(self.status, {"completed", "failed", "not_attempted"}, label="restore status") + attempted = _unique_tuple(self.attempted_fields, label="attempted_fields") + restored = _unique_tuple(self.restored_fields, label="restored_fields") + if not set(attempted + restored) <= _WAVEFORM_TRANSFER_FIELDS: + raise ValueError("waveform transfer restore fields are invalid") + _optional_safe_token(self.error_code, label="error_code") + + def validate_for(self, baseline: ScopeWaveformTransferBaseline) -> None: + _literal(self.status, {"completed", "failed", "not_attempted"}, label="restore status") + _optional_safe_token(self.error_code, label="error_code") + _validate_prefix_and_subsequence( + expected=baseline.restore_order, + attempted=self.attempted_fields, + completed=self.restored_fields, + status=self.status, + ) + + +@dataclass(frozen=True, slots=True) +class ScopeWaveformTransferVerification: + status: Literal["verified", "mismatch", "unavailable"] + verified_fields: tuple[ScopeWaveformTransferField, ...] + mismatched_fields: tuple[ScopeWaveformTransferField, ...] + error_code: str | None = None + + def __post_init__(self) -> None: + _literal(self.status, {"verified", "mismatch", "unavailable"}, label="verification status") + verified = _unique_tuple(self.verified_fields, label="verified_fields") + mismatched = _unique_tuple(self.mismatched_fields, label="mismatched_fields") + if set(verified) & set(mismatched) or not set( + verified + mismatched + ) <= _WAVEFORM_TRANSFER_FIELDS: + raise ValueError("waveform transfer verification fields are inconsistent") + _optional_safe_token(self.error_code, label="error_code") + if self.status == "verified" and mismatched: + raise ValueError("verified transfer state cannot contain mismatches") + if self.status == "mismatch" and not mismatched: + raise ValueError("mismatched transfer verification requires mismatched fields") + if self.status == "unavailable" and (verified or mismatched): + raise ValueError("unavailable transfer verification cannot claim fields") + +ScopeWaveformBinaryOperationKind = Literal["fetch", "capture_single", "capture_multiple"] +_WAVEFORM_BINARY_OPERATION_KINDS = {"fetch", "capture_single", "capture_multiple"} + + +@dataclass(frozen=True, slots=True) +class ScopeWaveformBinaryOperationProfile: + """Per-standard-operation limits and recovery closure for bounded waveform I/O.""" + + operation_kind: ScopeWaveformBinaryOperationKind + response_max_bytes: int + operation_max_bytes: int + query_max_count: int + resynchronization_max_bytes: int + restore_order: tuple[ScopeWaveformTransferField, ...] + snapshot_max_steps: int + restore_max_steps: int + verify_max_steps: int + + def __post_init__(self) -> None: + _literal( + self.operation_kind, + _WAVEFORM_BINARY_OPERATION_KINDS, + label="waveform binary operation kind", + ) + _strict_int( + self.response_max_bytes, + label="response_max_bytes", + minimum=1, + maximum=SCOPE_WAVEFORM_BINARY_RESPONSE_MAX_BYTES, + ) + _strict_int( + self.operation_max_bytes, + label="operation_max_bytes", + minimum=1, + maximum=SCOPE_WAVEFORM_BINARY_OPERATION_MAX_BYTES, + ) + if self.operation_max_bytes < self.response_max_bytes: + raise ValueError("waveform operation limit cannot be smaller than response limit") + _strict_int( + self.query_max_count, + label="query_max_count", + minimum=1, + maximum=SCOPE_WAVEFORM_BINARY_QUERY_MAX_COUNT, + ) + _strict_int( + self.resynchronization_max_bytes, + label="resynchronization_max_bytes", + minimum=0, + maximum=SCOPE_WAVEFORM_BINARY_RESYNCHRONIZATION_MAX_BYTES, + ) + restore = _unique_tuple(self.restore_order, label="restore_order") + if not restore or not set(restore) <= _WAVEFORM_TRANSFER_FIELDS: + raise ValueError("waveform restore order must contain supported transfer fields") + for field_name in restore: + if not isinstance(field_name, str): + raise TypeError("waveform restore fields must be strings") + if self.operation_kind in {"capture_single", "capture_multiple"} and not ( + _WAVEFORM_CAPTURE_RECOVERY_FIELDS <= set(restore) + ): + raise ValueError( + "waveform capture restore order must cover acquisition and transfer recovery fields" + ) + steps = (self.snapshot_max_steps, self.restore_max_steps, self.verify_max_steps) + for label, value in zip( + ("snapshot_max_steps", "restore_max_steps", "verify_max_steps"), + steps, + strict=True, + ): + _strict_int(value, label=label, minimum=len(restore), maximum=64) + + +@dataclass(frozen=True, slots=True) +class ScopeWaveformBinaryProfile: + """Descriptor-owned bounded definite-block contract for standard waveform operations.""" + + operations: tuple[ScopeWaveformBinaryOperationProfile, ...] + framing: BinaryResponseFraming = BinaryResponseFraming.DEFINITE_BLOCK + transport_trailing_hex: str = "" + + def __post_init__(self) -> None: + object.__setattr__(self, "framing", BinaryResponseFraming(self.framing)) + if self.framing is not BinaryResponseFraming.DEFINITE_BLOCK: + raise ValueError("waveform binary profiles only support definite-block framing") + if not isinstance(self.operations, tuple) or not self.operations: + raise ValueError("waveform binary profile operations must be a non-empty tuple") + if any(not isinstance(item, ScopeWaveformBinaryOperationProfile) for item in self.operations): + raise TypeError("waveform binary profile operations have an invalid type") + kinds = tuple(item.operation_kind for item in self.operations) + if len(set(kinds)) != len(kinds): + raise ValueError("waveform binary profile operation kinds must be unique") + _hex_bytes(self.transport_trailing_hex, label="transport_trailing_hex") + + @property + def transport_trailing(self) -> bytes: + return bytes.fromhex(self.transport_trailing_hex) + + def operation_for( + self, + operation_kind: ScopeWaveformBinaryOperationKind, + ) -> ScopeWaveformBinaryOperationProfile: + for operation in self.operations: + if operation.operation_kind == operation_kind: + return operation + raise ValueError(f"waveform binary profile has no {operation_kind!r} operation") + + +_SCOPE_SNAPSHOT_V2_IDENTITY_FIELDS = frozenset( + { + "identity.manufacturer", + "identity.model", + "identity.serial_number", + "identity.firmware", + "identity.options", + } +) +_SCOPE_SNAPSHOT_V2_PARTITION_IDENTITIES = { + "channel": "channel.channel", + "probe": "probe.channel", + "waveform": "waveform.channel", + "trigger": "trigger.trigger_type", +} + + +@dataclass(frozen=True, slots=True) +class ScopeSnapshotProfileV2: + """Descriptor-owned pure-text query contract for a composable scope snapshot.""" + + readable_fields: tuple[ScopeSnapshotFieldV2, ...] + max_queries: int + conditionally_applicable_fields: tuple[ScopeSnapshotFieldV2, ...] = () + allowed_effect: Literal["pure_read"] = "pure_read" + + def __post_init__(self) -> None: + readable = _unique_tuple(self.readable_fields, label="snapshot readable_fields") + if not readable or not set(readable) <= set(SCOPE_SNAPSHOT_V2_FIELD_ORDER): + raise ValueError("snapshot readable_fields must contain supported unique fields") + if not _SCOPE_SNAPSHOT_V2_IDENTITY_FIELDS <= set(readable): + raise ValueError("snapshot readable_fields must include all identity fields") + conditional = _unique_tuple( + self.conditionally_applicable_fields, + label="snapshot conditionally_applicable_fields", + ) + if not set(conditional) <= set(readable): + raise ValueError("snapshot conditional fields must be readable") + prohibited_conditional = _SCOPE_SNAPSHOT_V2_IDENTITY_FIELDS | set( + _SCOPE_SNAPSHOT_V2_PARTITION_IDENTITIES.values() + ) + if set(conditional) & prohibited_conditional: + raise ValueError("snapshot identity fields cannot be conditional") + _strict_int(self.max_queries, label="snapshot max_queries", minimum=1) + _literal(self.allowed_effect, {"pure_read"}, label="snapshot allowed_effect") + readable_set = set(readable) + for partition, identity_field in _SCOPE_SNAPSHOT_V2_PARTITION_IDENTITIES.items(): + prefix = f"{partition}." + if any(field_name.startswith(prefix) and field_name != identity_field for field_name in readable): + if identity_field not in readable_set: + raise ValueError( + f"snapshot {partition} fields require {identity_field!r}" + ) + + def validate_result(self, result: ScopeSnapshotV2, *, channel: int) -> None: + """Reject results that expand or silently shrink this descriptor's profile.""" + + if not isinstance(result, ScopeSnapshotV2): + raise TypeError("snapshot V2 driver returned an invalid result") + if isinstance(channel, bool) or not isinstance(channel, int) or channel < 1: + raise ValueError("snapshot V2 channel must be a positive integer") + for section_name in ("channel", "probe", "waveform"): + section = getattr(result, section_name) + if section is not None and section.channel != channel: + raise ValueError(f"snapshot V2 {section_name} returned the wrong channel") + values = result.field_values() + readable = set(self.readable_fields) + conditional = set(self.conditionally_applicable_fields) + unavailable = set(result.unavailable_fields) + not_applicable = set(result.not_applicable_fields) + for field_name in SCOPE_SNAPSHOT_V2_FIELD_ORDER: + value = values[field_name] + has_value = value is not None + if field_name not in readable: + if has_value or field_name not in unavailable: + raise ValueError( + "snapshot V2 result provided a field outside the descriptor profile" + ) + continue + if field_name in conditional: + if not has_value and field_name not in not_applicable: + raise ValueError( + "snapshot V2 conditional fields must be marked not applicable" + ) + if has_value and (field_name in unavailable or field_name in not_applicable): + raise ValueError( + "snapshot V2 available conditional fields cannot have an availability path" + ) + continue + if not has_value or field_name in unavailable or field_name in not_applicable: + raise ValueError( + "snapshot V2 non-conditional readable fields must have a value" + ) + + @dataclass(frozen=True, slots=True) class ScopeTraceProfile: fetchable_kinds: tuple[Literal["analog", "digital", "reference"], ...] @@ -1225,6 +2873,13 @@ class ScopeDescriptorExtensions: screenshot_profile: ScopeScreenshotProfile | None = None acquisition_control_profile: ScopeAcquisitionControlProfile | None = None trace_profile: ScopeTraceProfile | None = None + waveform_binary_profile: ScopeWaveformBinaryProfile | None = None + snapshot_profile_v2: ScopeSnapshotProfileV2 | None = None + acquisition_status_profile_v2: ScopeAcquisitionStatusProfileV2 | None = None + measurement_statistics_profile_v2: ScopeMeasurementStatisticsProfileV2 | None = None + fft_status_profile_v2: ScopeFftStatusProfileV2 | None = None + cursor_readout_profile_v2: ScopeCursorReadoutProfileV2 | None = None + average_capture_profile_v2: ScopeAverageCaptureProfileV2 | None = None def __post_init__(self) -> None: for label, value, expected in ( @@ -1235,6 +2890,41 @@ def __post_init__(self) -> None: ScopeAcquisitionControlProfile, ), ("trace_profile", self.trace_profile, ScopeTraceProfile), + ( + "waveform_binary_profile", + self.waveform_binary_profile, + ScopeWaveformBinaryProfile, + ), + ( + "snapshot_profile_v2", + self.snapshot_profile_v2, + ScopeSnapshotProfileV2, + ), + ( + "acquisition_status_profile_v2", + self.acquisition_status_profile_v2, + ScopeAcquisitionStatusProfileV2, + ), + ( + "measurement_statistics_profile_v2", + self.measurement_statistics_profile_v2, + ScopeMeasurementStatisticsProfileV2, + ), + ( + "fft_status_profile_v2", + self.fft_status_profile_v2, + ScopeFftStatusProfileV2, + ), + ( + "cursor_readout_profile_v2", + self.cursor_readout_profile_v2, + ScopeCursorReadoutProfileV2, + ), + ( + "average_capture_profile_v2", + self.average_capture_profile_v2, + ScopeAverageCaptureProfileV2, + ), ): if value is not None and not isinstance(value, expected): raise TypeError(f"{label} has an invalid type") @@ -1278,6 +2968,74 @@ class ScopeAcquisitionRunStateDriver(InstrumentDriver, Protocol): def get_acquisition_run_state(self) -> ScopeAcquisitionRunState: ... +@runtime_checkable +class ScopeAcquisitionStatusDriverV2(InstrumentDriver, Protocol): + def get_acquisition_status_v2( + self, + *, + fields: tuple[ScopeAcquisitionStatusFieldV2, ...], + ) -> ScopeAcquisitionStatusV2: ... + + +@runtime_checkable +class ScopeAverageCaptureDriverV2(ScopeAcquisitionRunStateDriver, Protocol): + def snapshot_average_capture_state( + self, + fields: tuple[ScopeAverageCaptureField, ...], + ) -> ScopeAverageCaptureStateSnapshot: ... + + def set_average_acquisition_type_v2( + self, + acquisition_type: str, + *, + baseline: ScopeAverageCaptureBaseline, + ) -> None: ... + + def get_average_configuration_v2( + self, + *, + baseline: ScopeAverageCaptureBaseline, + ) -> ScopeAverageConfigurationV2: ... + + def set_average_count_v2( + self, + average_count: int, + *, + baseline: ScopeAverageCaptureBaseline, + ) -> None: ... + + def acquire_average_single_v2( + self, + *, + baseline: ScopeAverageCaptureBaseline, + deadline: float, + ) -> ScopeAcquisitionCompletion: ... + + def get_device_average_complete_v2( + self, + *, + baseline: ScopeAverageCaptureBaseline, + ) -> bool: ... + + def fetch_average_waveform_bounded( + self, + channel: int, + *, + points: str, + baseline: ScopeAverageCaptureBaseline, + ) -> WaveformData: ... + + def restore_average_capture_state( + self, + baseline: ScopeAverageCaptureBaseline, + ) -> ScopeAverageCaptureRestoreResult: ... + + def verify_average_capture_state_restored( + self, + baseline: ScopeAverageCaptureBaseline, + ) -> ScopeAverageCaptureStateSnapshot: ... + + @runtime_checkable class ScopeAcquisitionControlRecoveryDriver(InstrumentDriver, Protocol): def snapshot_acquisition_control(self) -> ScopeAcquisitionControlSnapshot: ... @@ -1354,6 +3112,72 @@ def fetch_trace( ) -> ScopeTraceData: ... +@runtime_checkable +class ScopeWaveformTransferRecoveryDriver(InstrumentDriver, Protocol): + def snapshot_waveform_transfer_state( + self, + fields: tuple[ScopeWaveformTransferField, ...], + ) -> ScopeWaveformTransferStateSnapshot: ... + + def restore_waveform_transfer_state( + self, + baseline: ScopeWaveformTransferBaseline, + ) -> ScopeWaveformTransferRestoreResult: ... + + def verify_waveform_transfer_state_restored( + self, + baseline: ScopeWaveformTransferBaseline, + ) -> ScopeWaveformTransferStateSnapshot: ... + + +@runtime_checkable +class ScopeBoundedWaveformFetchDriver( + ScopeWaveformTransferRecoveryDriver, + Protocol, +): + def fetch_waveform_bounded( + self, + channel: int, + points: str = "dmax", + *, + baseline: ScopeWaveformTransferBaseline, + ) -> WaveformData: ... + + +@runtime_checkable +class ScopeBoundedWaveformCaptureDriver( + ScopeWaveformTransferRecoveryDriver, + Protocol, +): + def capture_waveform_bounded( + self, + channel: int, + points: str = "dmax", + *, + time_range_s: float | None = None, + vertical_scale_v_per_div: float | None = None, + baseline: ScopeWaveformTransferBaseline, + ) -> WaveformData: ... + + +@runtime_checkable +class ScopeBoundedMultiWaveformCaptureDriver( + ScopeWaveformTransferRecoveryDriver, + Protocol, +): + def capture_waveforms_bounded( + self, + channels: list[int], + points: str = "dmax", + *, + time_range_s: float | None = None, + vertical_scale_v_per_div: float | None = None, + on_channel_start: Callable[[int | None], None] | None = None, + on_waveform: Callable[[int, WaveformData], None] | None = None, + baseline: ScopeWaveformTransferBaseline, + ) -> dict[int, WaveformData]: ... + + @runtime_checkable class ScopeErrorDrainDriver(InstrumentDriver, Protocol): def drain_errors(self, *, max_records: int) -> ErrorDrainResult: ... @@ -1362,5 +3186,19 @@ def drain_errors(self, *, max_records: int) -> ErrorDrainResult: ... __all__ = [ name for name in globals() - if name.startswith("Scope") or name.startswith("Error") or name == "DriverErrorRecord" + if name.startswith("Scope") + or name.startswith("Error") + or name + in { + "SCOPE_AVERAGE_CAPTURE_FIELD_ORDER", + "SCOPE_AVERAGE_COUNT_MAX_V2", + "DriverErrorRecord", + "SCOPE_ACQUISITION_STATUS_V2_FIELD_ORDER", + "SCOPE_ACQUISITION_STATUS_V2_MAX_QUERIES", + "SCOPE_CURSOR_READOUT_V2_FIELD_ORDER", + "SCOPE_CURSOR_READOUT_V2_MAX_QUERIES", + "SCOPE_FFT_STATUS_V2_FIELD_ORDER", + "SCOPE_FFT_STATUS_V2_MAX_QUERIES", + "SCOPE_MEASUREMENT_STATISTICS_V2_MAX_QUERIES", + } ] diff --git a/src/wavebench/scope_extension_constants.py b/src/wavebench/scope_extension_constants.py index 1f10ca1..239646c 100644 --- a/src/wavebench/scope_extension_constants.py +++ b/src/wavebench/scope_extension_constants.py @@ -1,17 +1,37 @@ """Dependency-neutral constants for the public scope extension contract.""" -SCOPE_SCREENSHOT_BINARY_RESPONSE_MAX_BYTES = 262_144 -SCOPE_SCREENSHOT_BINARY_OPERATION_MAX_BYTES = 262_144 +SCOPE_SCREENSHOT_BINARY_RESPONSE_MAX_BYTES = 8_388_608 +SCOPE_SCREENSHOT_BINARY_OPERATION_MAX_BYTES = 8_388_608 SCOPE_SCREENSHOT_BINARY_QUERY_MAX_COUNT = 1 SCOPE_SCREENSHOT_BINARY_RESYNCHRONIZATION_MAX_BYTES = 0 SCOPE_TRACE_BINARY_RESPONSE_MAX_BYTES = 8_388_608 SCOPE_TRACE_BINARY_OPERATION_MAX_BYTES = 67_108_864 SCOPE_TRACE_BINARY_QUERY_MAX_COUNT = 256 SCOPE_TRACE_BINARY_RESYNCHRONIZATION_MAX_BYTES = 65_536 +SCOPE_WAVEFORM_BINARY_RESPONSE_MAX_BYTES = 8_388_608 +SCOPE_WAVEFORM_BINARY_OPERATION_MAX_BYTES = 67_108_864 +SCOPE_WAVEFORM_BINARY_QUERY_MAX_COUNT = 256 +SCOPE_WAVEFORM_BINARY_RESYNCHRONIZATION_MAX_BYTES = 65_536 +SCOPE_AVERAGE_COUNT_MAX_V2 = 65_536 +SCOPE_AVERAGE_CAPTURE_V2_BINARY_RESPONSE_MAX_BYTES = 8_388_608 +SCOPE_AVERAGE_CAPTURE_V2_BINARY_OPERATION_MAX_BYTES = 67_108_864 +SCOPE_AVERAGE_CAPTURE_V2_BINARY_QUERY_MAX_COUNT = 256 +SCOPE_AVERAGE_CAPTURE_V2_BINARY_RESYNCHRONIZATION_MAX_BYTES = 65_536 SCOPE_PROFILE_OPERATION_TIMEOUT_MS = 5_000 SCOPE_SCREENSHOT_OPERATION_TIMEOUT_MS = 5_000 SCOPE_ACQUISITION_OPERATION_TIMEOUT_MS = 30_000 SCOPE_TRACE_OPERATION_TIMEOUT_MS = 60_000 +SCOPE_WAVEFORM_OPERATION_TIMEOUT_MS = 60_000 +SCOPE_SNAPSHOT_V2_OPERATION_TIMEOUT_MS = 60_000 +SCOPE_ACQUISITION_STATUS_V2_MAX_QUERIES = 32 +SCOPE_ACQUISITION_STATUS_V2_OPERATION_TIMEOUT_MS = 60_000 +SCOPE_MEASUREMENT_STATISTICS_V2_MAX_QUERIES = 32 +SCOPE_MEASUREMENT_STATISTICS_V2_OPERATION_TIMEOUT_MS = 60_000 +SCOPE_FFT_STATUS_V2_MAX_QUERIES = 32 +SCOPE_FFT_STATUS_V2_OPERATION_TIMEOUT_MS = 60_000 +SCOPE_CURSOR_READOUT_V2_MAX_QUERIES = 32 +SCOPE_CURSOR_READOUT_V2_OPERATION_TIMEOUT_MS = 60_000 +SCOPE_AVERAGE_CAPTURE_V2_OPERATION_TIMEOUT_MS = 60_000 SCOPE_TRACE_MAX_POINTS = 8_388_608 __all__ = [name for name in globals() if name.startswith("SCOPE_")] diff --git a/src/wavebench/services/operation_specs.py b/src/wavebench/services/operation_specs.py index e94630c..5cacabf 100644 --- a/src/wavebench/services/operation_specs.py +++ b/src/wavebench/services/operation_specs.py @@ -14,13 +14,23 @@ from wavebench.errors import ConfigError from wavebench.scope_extension_constants import ( + SCOPE_AVERAGE_CAPTURE_V2_BINARY_OPERATION_MAX_BYTES, + SCOPE_AVERAGE_CAPTURE_V2_BINARY_QUERY_MAX_COUNT, + SCOPE_AVERAGE_CAPTURE_V2_BINARY_RESPONSE_MAX_BYTES, + SCOPE_AVERAGE_CAPTURE_V2_BINARY_RESYNCHRONIZATION_MAX_BYTES, + SCOPE_AVERAGE_CAPTURE_V2_OPERATION_TIMEOUT_MS, SCOPE_ACQUISITION_OPERATION_TIMEOUT_MS, + SCOPE_ACQUISITION_STATUS_V2_OPERATION_TIMEOUT_MS, + SCOPE_CURSOR_READOUT_V2_OPERATION_TIMEOUT_MS, + SCOPE_MEASUREMENT_STATISTICS_V2_OPERATION_TIMEOUT_MS, + SCOPE_FFT_STATUS_V2_OPERATION_TIMEOUT_MS, SCOPE_PROFILE_OPERATION_TIMEOUT_MS, SCOPE_SCREENSHOT_BINARY_OPERATION_MAX_BYTES, SCOPE_SCREENSHOT_BINARY_QUERY_MAX_COUNT, SCOPE_SCREENSHOT_BINARY_RESPONSE_MAX_BYTES, SCOPE_SCREENSHOT_BINARY_RESYNCHRONIZATION_MAX_BYTES, SCOPE_SCREENSHOT_OPERATION_TIMEOUT_MS, + SCOPE_SNAPSHOT_V2_OPERATION_TIMEOUT_MS, SCOPE_TRACE_BINARY_OPERATION_MAX_BYTES, SCOPE_TRACE_BINARY_QUERY_MAX_COUNT, SCOPE_TRACE_BINARY_RESPONSE_MAX_BYTES, @@ -329,6 +339,30 @@ def _spec( *_SCOPE_CAPTURE_TRANSFER_STATE_FIELDS, "scope.capture_identity", ) +_SCOPE_AVERAGE_CAPTURE_RESTORE_FIELDS = ( + "scope.run_state", + "scope.acquisition", + "scope.trigger", + "scope.timebase", + "scope.channel_display", + "scope.channel_vertical", + "scope.waveform_source", + "scope.waveform_mode", + "scope.query_response_header", + "scope.waveform_format", + "scope.waveform_byte_order", + "scope.waveform_points", + "scope.waveform_transfer_window", +) +_SCOPE_AVERAGE_CAPTURE_GRANULAR_FIELDS = ( + "scope.acquisition.type", + "scope.acquisition.average_count", +) +_SCOPE_AVERAGE_CAPTURE_CHANGED_FIELDS = ( + *_SCOPE_AVERAGE_CAPTURE_RESTORE_FIELDS, + *_SCOPE_AVERAGE_CAPTURE_GRANULAR_FIELDS, + "scope.error_queue", +) _BUILTIN_SPECS = ( @@ -356,6 +390,13 @@ def _spec( effect="stateful_read", safe_alternatives=("scope.idn", "scope.channel_coupling"), ), + _spec( + "scope.channel_input_state_v2", + "scope", + required_capabilities=("scope.channel_input_state_v2",), + effect="stateful_read", + lease_mode="exclusive", + ), _spec("scope.acquisition_status", "scope", required_capabilities=("scope.acquisition_status",), effect="stateful_read"), _spec("scope.channel_coupling", "scope", required_capabilities=("scope.channel_coupling",), effect="stateful_read"), _spec( @@ -393,6 +434,13 @@ def _spec( _spec("scope.fetch_waveform", "scope", required_capabilities=("scope.fetch_waveform",), effect="acquire", changed_fields=_SCOPE_CAPTURE_CHANGED_FIELDS, restore_coverage="capture-baseline-only", required_verified_fields=("scope.identity",), verification_fields=_SCOPE_CAPTURE_VERIFICATION_FIELDS, risk_flags=("acquisition_state", "temporary_transfer_setup")), _spec("scope.capture_average", "scope", required_capabilities=("scope.capture_average",), effect="acquire", changed_fields=("acquisition", "waveform_package"), risk_flags=("trigger", "acquisition_state")), _spec("scope.digital_status", "scope", required_capabilities=("scope.digital_status",), effect="stateful_read"), + _spec( + "scope.digital_status_v2", + "scope", + required_capabilities=("scope.digital_status_v2",), + effect="stateful_read", + lease_mode="exclusive", + ), _spec("scope.digital_waveform", "scope", required_capabilities=("scope.digital_waveform",), effect="acquire", changed_fields=("acquisition", "waveform_package"), risk_flags=("trigger", "acquisition_state")), _spec("scope.history_timestamps", "scope", required_capabilities=("scope.history_timestamps",), effect="stateful_read"), _spec("scope.measurement_statistics", "scope", required_capabilities=("scope.measurement_statistics",), effect="stateful_read"), @@ -1187,10 +1235,106 @@ def _scope_operation( {spec.operation: spec for spec in _SCOPE_EXTENSION_SPECS} ) +_SCOPE_PORTABILITY_V2_SPECS = ( + _spec( + "scope.snapshot_v2", + "scope", + required_capabilities=("scope.snapshot_v2",), + effect="stateful_read", + lease_mode="exclusive", + restore_coverage="none-read-only", + timeout_source="operation.timeout_ms", + operation_timeout_ms=SCOPE_SNAPSHOT_V2_OPERATION_TIMEOUT_MS, + error_check_minimum="disabled", + risk_flags=("profile_query",), + ), + _spec( + "scope.acquisition_status_v2", + "scope", + required_capabilities=("scope.acquisition_status_v2",), + effect="stateful_read", + lease_mode="exclusive", + restore_coverage="none-read-only", + timeout_source="operation.timeout_ms", + operation_timeout_ms=SCOPE_ACQUISITION_STATUS_V2_OPERATION_TIMEOUT_MS, + error_check_minimum="disabled", + risk_flags=("profile_query",), + ), + _scope_operation( + "scope.capture_average_v2", + required_capabilities=("scope.capture_average_v2",), + effect="acquire", + timeout_ms=SCOPE_AVERAGE_CAPTURE_V2_OPERATION_TIMEOUT_MS, + changed_fields=_SCOPE_AVERAGE_CAPTURE_CHANGED_FIELDS, + restore_coverage="average-capture-baseline", + verification_fields=( + "scope.identity", + *_SCOPE_AVERAGE_CAPTURE_CHANGED_FIELDS, + ), + postcondition_fields=_SCOPE_AVERAGE_CAPTURE_GRANULAR_FIELDS, + cleanup_verification_fields=_SCOPE_AVERAGE_CAPTURE_RESTORE_FIELDS, + risk_flags=( + "trigger", + "acquisition_state", + "temporary_transfer_setup", + "binary_response", + "recovery_required", + ), + binary_limits=( + SCOPE_AVERAGE_CAPTURE_V2_BINARY_RESPONSE_MAX_BYTES, + SCOPE_AVERAGE_CAPTURE_V2_BINARY_OPERATION_MAX_BYTES, + SCOPE_AVERAGE_CAPTURE_V2_BINARY_QUERY_MAX_COUNT, + SCOPE_AVERAGE_CAPTURE_V2_BINARY_RESYNCHRONIZATION_MAX_BYTES, + ), + error_check_minimum="disabled", + ), + _spec( + "scope.measurement_statistics_v2", + "scope", + required_capabilities=("scope.measurement_statistics_v2",), + effect="stateful_read", + lease_mode="exclusive", + restore_coverage="none-read-only", + timeout_source="operation.timeout_ms", + operation_timeout_ms=SCOPE_MEASUREMENT_STATISTICS_V2_OPERATION_TIMEOUT_MS, + error_check_minimum="disabled", + risk_flags=("profile_query",), + ), + _spec( + "scope.fft_status_v2", + "scope", + required_capabilities=("scope.fft_status_v2",), + effect="stateful_read", + lease_mode="exclusive", + restore_coverage="none-read-only", + timeout_source="operation.timeout_ms", + operation_timeout_ms=SCOPE_FFT_STATUS_V2_OPERATION_TIMEOUT_MS, + error_check_minimum="disabled", + risk_flags=("profile_query",), + ), + _spec( + "scope.cursor_readout_v2", + "scope", + required_capabilities=("scope.cursor_readout_v2",), + effect="stateful_read", + lease_mode="exclusive", + restore_coverage="none-read-only", + timeout_source="operation.timeout_ms", + operation_timeout_ms=SCOPE_CURSOR_READOUT_V2_OPERATION_TIMEOUT_MS, + error_check_minimum="disabled", + risk_flags=("profile_query",), + ), +) + +SCOPE_PORTABILITY_V2_OPERATION_SPECS: Mapping[str, OperationSpec] = MappingProxyType( + {spec.operation: spec for spec in _SCOPE_PORTABILITY_V2_SPECS} +) + OPERATION_REGISTRY = OperationRegistry( { **{spec.operation: spec for spec in _BUILTIN_SPECS}, **SCOPE_OPERATION_SPECS, + **SCOPE_PORTABILITY_V2_OPERATION_SPECS, } ) diff --git a/src/wavebench/services/scope_average_capture_executor.py b/src/wavebench/services/scope_average_capture_executor.py new file mode 100644 index 0000000..d0f7092 --- /dev/null +++ b/src/wavebench/services/scope_average_capture_executor.py @@ -0,0 +1,592 @@ +"""Core-owned bounded execution for RFC-0006b average capture V2.""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass, is_dataclass +from hashlib import sha256 +from types import MappingProxyType +from typing import Any, Mapping +from uuid import uuid4 + +from wavebench.config import normalize_waveform_points +from wavebench.errors import ConfigError, DataError, InstrumentError +from wavebench.instruments.api import InstrumentDescriptor +from wavebench.instruments.models import ScopeChannelInputStateV2, WaveformData +from wavebench.instruments.scope_extension_capabilities import validate_scope_descriptor +from wavebench.instruments.scope_extensions import ( + ErrorCheckSpec, + ScopeAcquisitionControlBaseline, + ScopeAcquisitionControlProfile, + ScopeAcquisitionControlSnapshot, + ScopeAcquisitionCompletion, + ScopeAcquisitionRunState, + ScopeAverageCaptureBaseline, + ScopeAverageCaptureProfileV2, + ScopeAverageCaptureRequestV2, + ScopeAverageCaptureRestoreResult, + ScopeAverageCaptureResultV2, + ScopeAverageCaptureStateSnapshot, + ScopeAverageCaptureVerification, + ScopeAverageCompletionProofV2, + ScopeAverageConfigurationV2, + validate_acquisition_completion, +) +from wavebench.transport.guarded import GuardedAuditedTransport +from wavebench.transport.session import InstrumentSessionState, SessionHealth + +from .operation_specs import require_operation_spec +from .scope_error_policy import ScopeErrorPolicyExecutor +from .scope_phase_coordinator import ( + OperationPhase, + ScopeBaselineHandle, + ScopeBinaryLimits, + ScopeOperationContextCoordinator, +) + + +_AVERAGE_MAIN_IO = { + "query", + "query_float_list", + "query_opc", + "write", + "write_bytes", + "query_binary", +} + + +def _json_safe(value: Any) -> Any: + if is_dataclass(value): + return _json_safe(asdict(value)) + if isinstance(value, Mapping): + return {str(key): _json_safe(item) for key, item in value.items()} + if isinstance(value, (tuple, list)): + return [_json_safe(item) for item in value] + if isinstance(value, (str, int, float, bool, type(None))): + return value + return str(value) + + +@dataclass(frozen=True, slots=True) +class AverageCaptureV2ExecutionResult: + """Private handoff preserving the public V2 result's stable shape.""" + + value: ScopeAverageCaptureResultV2 + identity: str + diagnostics: Mapping[str, object] + + def __post_init__(self) -> None: + object.__setattr__(self, "diagnostics", MappingProxyType(dict(self.diagnostics))) + + +@dataclass(slots=True) +class ScopeAverageCaptureExecutor: + """Execute one single-channel average capture under one core-owned context.""" + + driver: object + descriptor: InstrumentDescriptor + session_state: InstrumentSessionState + connection_timeout_ms: int + transport: GuardedAuditedTransport | None = None + + def __post_init__(self) -> None: + if isinstance(self.connection_timeout_ms, bool) or not isinstance( + self.connection_timeout_ms, + int, + ) or self.connection_timeout_ms < 1: + raise ValueError("connection_timeout_ms must be a positive integer") + if self.session_state.health is not SessionHealth.HEALTHY: + raise ConfigError("average capture V2 requires a healthy session") + if ( + not isinstance(self.transport, GuardedAuditedTransport) + or self.transport.session_state is not self.session_state + or not self.transport._has_verified_bounded_binary_backend() + ): + raise ConfigError( + "average capture V2 requires a factory-validated bounded transport" + ) + if "scope.idn" not in self.descriptor.capabilities: + raise ConfigError("average capture V2 requires scope.idn") + if not callable(getattr(self.driver, "idn", None)): + raise ConfigError("average capture V2 requires callable idn()") + validate_scope_descriptor(self.descriptor, driver=self.driver) + self._profile() + + @property + def capabilities(self) -> frozenset[str]: + return frozenset(self.descriptor.capabilities) + + def execute( + self, + request: ScopeAverageCaptureRequestV2, + *, + check_errors: bool, + ) -> AverageCaptureV2ExecutionResult: + profile = self._profile() + try: + profile.validate_request(request) + except (TypeError, ValueError) as exc: + raise ConfigError(f"invalid average capture V2 request: {exc}") from exc + if not isinstance(check_errors, bool): + raise TypeError("average capture V2 check_errors must be bool") + spec = require_operation_spec("scope.capture_average_v2") + binary = profile.binary + context = ScopeOperationContextCoordinator( + session_state=self.session_state, + spec=spec, + connection_timeout_ms=self.connection_timeout_ms, + profile_binary_limits=ScopeBinaryLimits( + response_max_bytes=binary.response_max_bytes, + operation_max_bytes=binary.operation_max_bytes, + query_max_count=binary.query_max_count, + resynchronization_max_bytes=binary.resynchronization_max_bytes, + ), + transport_trailing=binary.transport_trailing, + required_binary_framing=binary.framing, + enabled=True, + ) + error_spec = ErrorCheckSpec( + policy="required" if check_errors else "disabled", + timing="before_and_after", + max_records=16, + on_instrument_error="fail", + ) + try: + error_executor = ScopeErrorPolicyExecutor( + driver=self.driver, + capabilities=self.capabilities, + operation_spec=spec, + error_spec=error_spec, + correlation_id=context.correlation_id, + ) + except BaseException: + context.complete() + raise + + handle: ScopeBaselineHandle | None = None + baseline: ScopeAverageCaptureBaseline | None = None + identity: str | None = None + completion: ScopeAverageCompletionProofV2 | None = None + waveform: WaveformData | None = None + primary: BaseException | None = None + cleanup_error: BaseException | None = None + cleanup_diagnostics: dict[str, object] = {} + cleanup_snapshot: ScopeAverageCaptureStateSnapshot | None = None + restore_result: ScopeAverageCaptureRestoreResult | None = None + verification: ScopeAverageCaptureVerification | None = None + main_entered = False + try: + fields = tuple(profile.restore_order) + preflight = context.make_phase_spec( + OperationPhase.PREFLIGHT, + allowed_io={"query"}, + fields={"scope.identity", *fields}, + max_steps=2 + profile.snapshot_max_steps, + ) + with context.authorize_phase(preflight) as authorization: + identity = self._verify_identity() + self._read_and_validate_input_state(request) + snapshot = self.driver.snapshot_average_capture_state(fields) + self._validate_snapshot(snapshot, fields) + if snapshot.run_state.phase != "stopped": + raise ConfigError("average capture V2 requires a fresh stopped run state") + handle = context.create_baseline( + kind="average_capture", + fields=fields, + restore_order=fields, + ) + baseline = self._make_baseline(handle, snapshot) + context.pass_baseline_to_main(handle) + context.complete_phase_verification( + authorization, + io_kind="query", + fields={"scope.identity", *fields}, + ) + + error_executor.run(context, phase="before") + main = context.make_phase_spec( + OperationPhase.MAIN, + allowed_io=_AVERAGE_MAIN_IO, + fields=set(spec.changed_fields), + max_steps=profile.main_max_steps, + ) + try: + with context.authorize_phase(main): + main_entered = True + assert baseline is not None + error_executor.mark_main_sent() + self.driver.set_average_acquisition_type_v2( + profile.global_acquisition_type, + baseline=baseline, + ) + type_readback = self.driver.get_average_configuration_v2( + baseline=baseline, + ) + self._validate_type_readback(profile, type_readback) + self.driver.set_average_count_v2( + request.average_count, + baseline=baseline, + ) + configuration_readback = self.driver.get_average_configuration_v2( + baseline=baseline, + ) + self._validate_configuration_readback( + profile, + request, + configuration_readback, + ) + stopped_recheck = self.driver.get_acquisition_run_state() + self._validate_stopped_recheck(stopped_recheck, baseline) + acquisition_completion = self.driver.acquire_average_single_v2( + baseline=baseline, + deadline=context.main_deadline, + ) + self._validate_acquisition_completion( + acquisition_completion, + baseline, + ) + if self.driver.get_device_average_complete_v2(baseline=baseline) is not True: + raise DataError("average capture completion_unproven") + waveform = self.driver.fetch_average_waveform_bounded( + request.channels[0], + points=normalize_waveform_points(request.points), + baseline=baseline, + ) + if not isinstance(waveform, WaveformData) or waveform.channel != request.channels[0]: + raise DataError( + "average capture V2 driver returned a waveform with a mismatched channel" + ) + self._require_binary_main_query(context) + completion = ScopeAverageCompletionProofV2( + evidence="device_average_complete", + mechanism=request.mechanism, + configured_average_count=request.average_count, + configuration_readback=configuration_readback, + acquisition_completion=acquisition_completion, + device_average_complete=True, + contract_id=profile.completion_contract_id, + context_id=baseline.context_id, + session_epoch=baseline.session_epoch, + acquisition_baseline_nonce_digest=sha256( + baseline.acquisition_baseline.baseline_nonce.encode("ascii") + ).hexdigest()[:16], + ) + error_executor.run(context, phase="after") + except BaseException as exc: + primary = exc + if error_executor.wants("after") and not context.has_phase(OperationPhase.ERROR_AFTER): + error_executor.omit_after( + "session_unhealthy" + if self.session_state.health is not SessionHealth.HEALTHY + else "main_operation_failed" + ) + + if ( + handle is not None + and main_entered + and self.session_state.health is not SessionHealth.POISONED + ): + context.mark_cleanup_required() + ( + cleanup_error, + cleanup_snapshot, + restore_result, + verification, + cleanup_diagnostics, + ) = self._cleanup( + context, + handle, + baseline, + profile, + failed=primary is not None, + ) + context.complete() + if primary is not None: + self._attach_diagnostics( + primary, + context, + error_executor, + cleanup_error=cleanup_error, + extra={"average_capture_cleanup": cleanup_diagnostics}, + ) + raise primary + if cleanup_error is not None: + self._attach_diagnostics( + cleanup_error, + context, + error_executor, + extra={"average_capture_cleanup": cleanup_diagnostics}, + ) + raise cleanup_error + assert baseline is not None + assert identity is not None + assert completion is not None + assert waveform is not None + assert cleanup_snapshot is not None + assert restore_result is not None + assert verification is not None + result = ScopeAverageCaptureResultV2( + request=request, + waveforms=(waveform,), + configuration_before=baseline.snapshot.configuration, + configuration_after=cleanup_snapshot.configuration, + run_state_before=baseline.snapshot.run_state, + run_state_after=cleanup_snapshot.run_state, + completion=completion, + restore=restore_result, + verification=verification, + ) + return AverageCaptureV2ExecutionResult( + value=result, + identity=identity, + diagnostics={ + "scope_operation": context.artifact(), + "error_check": dict(error_executor.artifact), + "average_capture_cleanup": cleanup_diagnostics, + }, + ) + except BaseException as exc: + if not context.terminal: + context.complete() + if not hasattr(exc, "scope_operation_diagnostics"): + self._attach_diagnostics(exc, context, error_executor) + raise + + def _cleanup( + self, + context: ScopeOperationContextCoordinator, + handle: ScopeBaselineHandle, + baseline: ScopeAverageCaptureBaseline | None, + profile: ScopeAverageCaptureProfileV2, + *, + failed: bool, + ) -> tuple[ + BaseException | None, + ScopeAverageCaptureStateSnapshot | None, + ScopeAverageCaptureRestoreResult | None, + ScopeAverageCaptureVerification | None, + dict[str, object], + ]: + assert baseline is not None + restore_result: ScopeAverageCaptureRestoreResult | None = None + verification: ScopeAverageCaptureVerification | None = None + observed: ScopeAverageCaptureStateSnapshot | None = None + error: BaseException | None = None + phase = OperationPhase.FAILURE_CLEANUP if failed else OperationPhase.SUCCESS_RESTORE + try: + restore = context.make_phase_spec( + phase, + allowed_io={"write"}, + fields=handle.fields, + max_steps=profile.restore_max_steps, + ) + with context.authorize_phase(restore): + context.begin_restore(handle) + try: + restore_result = self.driver.restore_average_capture_state(baseline) + if not isinstance(restore_result, ScopeAverageCaptureRestoreResult): + raise TypeError( + "restore_average_capture_state() returned an invalid result" + ) + restore_result.validate_for(baseline) + succeeded = restore_result.status == "completed" + except BaseException: + context.finish_restore(handle, succeeded=False) + raise + context.finish_restore(handle, succeeded=succeeded) + if not succeeded: + raise InstrumentError("average capture restore did not complete") + except BaseException as exc: + error = exc + + if self.session_state.health is not SessionHealth.POISONED: + try: + verify = context.make_phase_spec( + OperationPhase.CLEANUP_VERIFICATION, + allowed_io={"query"}, + fields=handle.fields, + max_steps=profile.verify_max_steps, + ) + with context.authorize_phase(verify) as authorization: + context.begin_verification(handle) + observed = self.driver.verify_average_capture_state_restored(baseline) + self._validate_snapshot(observed, tuple(handle.fields)) + matched = observed == baseline.snapshot + verification = ScopeAverageCaptureVerification( + status="verified" if matched else "mismatch", + verified_fields=tuple(handle.fields) if matched else (), + mismatched_fields=() if matched else tuple(handle.fields), + ) + verification.validate_for(baseline) + context.finish_verification( + handle, + authorization, + io_kind="query", + verified_fields=tuple(handle.fields), + matched=matched, + ) + except BaseException as exc: + error = error or exc + return error, observed, restore_result, verification, { + "restore": _json_safe(restore_result), + "verification": _json_safe(verification), + } + + def _profile(self) -> ScopeAverageCaptureProfileV2: + extensions = self.descriptor.scope_extensions + profile = extensions.average_capture_profile_v2 if extensions is not None else None + if profile is None: + raise ConfigError("average capture V2 requires an average capture profile") + return profile + + @staticmethod + def _validate_snapshot( + snapshot: object, + fields: tuple[str, ...], + ) -> None: + if not isinstance(snapshot, ScopeAverageCaptureStateSnapshot): + raise DataError("average capture state snapshot has an invalid type") + if tuple(snapshot.captured_fields) != fields: + raise DataError("average capture snapshot fields do not match the profile") + + @staticmethod + def _make_baseline( + handle: ScopeBaselineHandle, + snapshot: ScopeAverageCaptureStateSnapshot, + ) -> ScopeAverageCaptureBaseline: + assert snapshot.trigger_token is not None + assert snapshot.acquisition_token is not None + acquisition_baseline = ScopeAcquisitionControlBaseline( + context_id=handle.context_id, + session_epoch=handle.session_epoch, + baseline_nonce=uuid4().hex, + snapshot=ScopeAcquisitionControlSnapshot( + run_state=snapshot.run_state, + trigger_state_token=snapshot.trigger_token, + acquisition_state_token=snapshot.acquisition_token, + ), + restore_order=("scope.run_state", "scope.trigger", "scope.acquisition"), + ) + return ScopeAverageCaptureBaseline( + context_id=handle.context_id, + session_epoch=handle.session_epoch, + baseline_nonce=handle.baseline_nonce, + snapshot=snapshot, + restore_order=tuple(handle.restore_order), + acquisition_baseline=acquisition_baseline, + ) + + def _read_and_validate_input_state(self, request: ScopeAverageCaptureRequestV2) -> None: + state = self.driver.get_channel_input_state_v2(request.channels[0]) + if not isinstance(state, ScopeChannelInputStateV2): + raise DataError("average capture V2 input-state driver returned an invalid result") + if state.channel != request.channels[0]: + raise DataError("average capture V2 input-state driver returned the wrong channel") + if state.termination == "high_z": + return + if state.termination == "50_ohm" and request.allow_50ohm is True: + return + if state.termination == "50_ohm": + raise ConfigError( + "average capture V2 requires high impedance unless allow_50ohm=True" + ) + raise ConfigError("average capture V2 rejects unknown input termination") + + @staticmethod + def _validate_type_readback( + profile: ScopeAverageCaptureProfileV2, + configuration: object, + ) -> None: + if not isinstance(configuration, ScopeAverageConfigurationV2): + raise DataError("average capture type readback has an invalid type") + try: + profile.validate_configuration(configuration) + except (TypeError, ValueError) as exc: + raise DataError(f"average capture type readback is invalid: {exc}") from exc + + @staticmethod + def _validate_configuration_readback( + profile: ScopeAverageCaptureProfileV2, + request: ScopeAverageCaptureRequestV2, + configuration: object, + ) -> None: + if not isinstance(configuration, ScopeAverageConfigurationV2): + raise DataError("average capture configuration readback has an invalid type") + try: + profile.validate_configuration(configuration, request=request) + except (TypeError, ValueError) as exc: + raise DataError(f"average capture configuration readback is invalid: {exc}") from exc + + @staticmethod + def _validate_stopped_recheck( + state: object, + baseline: ScopeAverageCaptureBaseline, + ) -> None: + if not isinstance(state, ScopeAcquisitionRunState): + raise DataError("average capture stopped recheck has an invalid type") + if state.phase != "stopped" or state != baseline.snapshot.run_state: + raise DataError("average capture stopped recheck drifted from the baseline") + + def _validate_acquisition_completion( + self, + completion: object, + baseline: ScopeAverageCaptureBaseline, + ) -> None: + if not isinstance(completion, ScopeAcquisitionCompletion): + raise DataError("average capture single completion has an invalid type") + if completion.proof == "single_mode_readback_then_stopped": + raise DataError("average capture V2 does not accept a terminal STOP completion proof") + extensions = self.descriptor.scope_extensions + profile = extensions.acquisition_control_profile if extensions is not None else None + if not isinstance(profile, ScopeAcquisitionControlProfile): + raise ConfigError("average capture V2 requires an acquisition control profile") + try: + validate_acquisition_completion( + completion, + baseline=baseline.acquisition_baseline, + profile=profile, + ) + except (TypeError, ValueError) as exc: + raise DataError(f"average capture single completion is invalid: {exc}") from exc + + @staticmethod + def _require_binary_main_query(context: ScopeOperationContextCoordinator) -> None: + ledger = context.binary_ledger + if ledger is None: + raise DataError("average capture V2 has no binary ledger") + snapshot = ledger.snapshot() + if snapshot["remaining_query_count"] == snapshot["query_max_count"]: + raise DataError("average capture V2 driver did not issue a binary query") + + @staticmethod + def _verify_identity_value(value: object) -> str: + if not isinstance(value, str) or not value.strip(): + raise DataError("scope identity verification returned an empty response") + return value + + def _verify_identity(self) -> str: + return self._verify_identity_value(self.driver.idn()) + + @staticmethod + def _attach_diagnostics( + exc: BaseException, + context: ScopeOperationContextCoordinator, + error_executor: ScopeErrorPolicyExecutor, + *, + cleanup_error: BaseException | None = None, + extra: Mapping[str, object] | None = None, + ) -> None: + diagnostics: dict[str, object] = { + "scope_operation": context.artifact(), + "error_check": dict(error_executor.artifact), + } + if cleanup_error is not None: + diagnostics["cleanup_error_type"] = type(cleanup_error).__name__ + if extra: + diagnostics.update(extra) + try: + setattr(exc, "scope_operation_diagnostics", diagnostics) + except Exception: + pass + + +__all__ = ["AverageCaptureV2ExecutionResult", "ScopeAverageCaptureExecutor"] diff --git a/src/wavebench/services/scope_phase_coordinator.py b/src/wavebench/services/scope_phase_coordinator.py index 47ee213..159aacf 100644 --- a/src/wavebench/services/scope_phase_coordinator.py +++ b/src/wavebench/services/scope_phase_coordinator.py @@ -14,6 +14,7 @@ from wavebench.errors import ConfigError from wavebench.transport.binary import BinaryQueryBudget, BinaryQueryLedger +from wavebench.transport.contracts import BinaryResponseFraming from wavebench.transport.session import ( InstrumentSessionState, SessionAuthorization, @@ -287,6 +288,7 @@ def __init__( profile_binary_limits: ScopeBinaryLimits | None = None, connection_binary_limits: ScopeBinaryLimits | None = None, transport_trailing: bytes = b"", + required_binary_framing: BinaryResponseFraming | None = None, enabled: bool = False, now: float | None = None, ) -> None: @@ -300,6 +302,8 @@ def __init__( raise ValueError("connection_timeout_ms must be a positive integer") if session_state.health is not SessionHealth.HEALTHY: raise ValueError("new scope operations require a healthy session") + if required_binary_framing is not None: + required_binary_framing = BinaryResponseFraming(required_binary_framing) current = time.monotonic() if now is None else float(now) hard_deadline = current + (spec.operation_timeout_ms / 1000.0) if caller_deadline is not None: @@ -357,6 +361,7 @@ def __init__( query_max_count=limits.query_max_count, resynchronization_max_bytes=limits.resynchronization_max_bytes, transport_trailing=transport_trailing, + required_framing=required_binary_framing, ) self._binary_budget = self._binary_ledger.issue_budget() diff --git a/src/wavebench/services/scope_service.py b/src/wavebench/services/scope_service.py index e71b828..d9758a9 100644 --- a/src/wavebench/services/scope_service.py +++ b/src/wavebench/services/scope_service.py @@ -3,12 +3,14 @@ import csv import json import os +import time import traceback -from collections.abc import Iterator +from collections.abc import Iterator, Mapping from contextlib import contextmanager from dataclasses import asdict, dataclass from pathlib import Path from typing import Any, cast +from uuid import uuid4 import numpy as np @@ -22,35 +24,59 @@ ScopeAcquisitionStatusDriver, ScopeAverageCaptureDriver, ScopeAnalysisReadDriver, + ScopeChannelInputStateDriverV2, + ScopeCursorReadoutDriverV2, ScopeDriver, ScopeDigitalStatusDriver, + ScopeDigitalStatusDriverV2, ScopeDigitalWaveformDriver, + ScopeFftStatusDriverV2, ScopeHistoryTimestampsDriver, ScopeMeasurementStatisticsDriver, + ScopeMeasurementStatisticsDriverV2, ScopeSnapshotDriver, + ScopeSnapshotDriverV2, ) from wavebench.instruments.factory import open_instrument_driver from wavebench.instruments.models import ( ScopeAcquisitionStatus, ScopeAverageCaptureRequest, ScopeAverageCaptureResult, + ScopeChannelInputStateV2, ScopeCursorReadout, + ScopeCursorReadoutV2, ScopeDerivedWaveformMetadata, ScopeDigitalChannelStatus, + ScopeDigitalChannelStatusV2, ScopeDigitalWaveform, ScopeDigitalWaveformRequest, ScopeFftStatus, + ScopeFftStatusV2, ScopeHistoryTimestamps, ScopeMeasurementStatistics, + ScopeMeasurementStatisticsRequestV2, + ScopeMeasurementStatisticsV2, ScopeSnapshot, + ScopeSnapshotV2, WaveformData, ) from wavebench.instruments.registry import resolve_instrument_descriptor from wavebench.instruments.scope_extensions import ( ErrorCheckSpec, ScopeContinuousAcquisitionRequest, + ScopeAcquisitionStatusDriverV2, + ScopeAcquisitionStatusProfileV2, + ScopeAcquisitionStatusV2, + ScopeAverageCaptureProfileV2, + ScopeAverageCaptureRequestV2, + ScopeAverageCaptureResultV2, + ScopeCursorReadoutProfileV2, + ScopeFftStatusProfileV2, + ScopeMeasurementStatisticsProfileV2, ScopeScreenshotRequest, + ScopeSnapshotProfileV2, ScopeTraceRef, + ScopeWaveformBinaryProfile, ) from wavebench.logging import CommandLogger from wavebench.services.access_policy import access_policy @@ -61,7 +87,10 @@ ScopeExtensionOperationResult, ScopeExtensionService, ) +from wavebench.services.scope_waveform_executor import BoundedWaveformExecutor +from wavebench.services.scope_average_capture_executor import ScopeAverageCaptureExecutor from wavebench.transport.base import InstrumentTransport +from wavebench.transport.guarded import GuardedAuditedTransport from wavebench.transport.session import ( InstrumentSessionState, SessionHealth, @@ -144,6 +173,29 @@ def assert_scope_high_impedance( ) +def assert_scope_input_state_safe( + state: ScopeChannelInputStateV2, + *, + allow_50ohm: bool = False, +) -> ScopeChannelInputStateV2: + """Apply the V2 termination policy without changing any legacy capture route.""" + + if state.termination == "high_z": + return state + if state.termination == "50_ohm" and allow_50ohm is True: + return state + if state.termination == "50_ohm": + raise ConfigError( + f"scope CH{state.channel} input termination is 50 ohm; default use requires " + "high impedance. Pass --allow-50ohm only when the test setup explicitly " + "accepts the measured 50 ohm input." + ) + raise ConfigError( + f"scope CH{state.channel} input termination is unknown; refusing use even when " + "--allow-50ohm was requested." + ) + + @dataclass(frozen=True) class CaptureResult: package_dir: Path @@ -351,6 +403,63 @@ def status(self, channel: int) -> ScopeSnapshot: with self._scope_session() as scope: return cast(ScopeSnapshotDriver, scope).get_snapshot(channel) + def snapshot_v2(self, channel: int) -> ScopeSnapshotV2: + if isinstance(channel, bool) or not isinstance(channel, int) or channel < 1: + raise ConfigError("scope snapshot V2 channel must be a positive integer") + spec = self._require("scope.snapshot_v2", "scope.snapshot_v2") + profile = self._snapshot_v2_profile() + if profile is None: + raise ConfigError("scope snapshot V2 requires scope_extensions.snapshot_profile_v2") + with self._scope_session() as scope: + return self._execute_snapshot_v2( + cast(ScopeSnapshotDriverV2, scope), + channel=channel, + profile=profile, + spec=spec, + ) + + def _execute_snapshot_v2( + self, + scope: ScopeSnapshotDriverV2, + *, + channel: int, + profile: ScopeSnapshotProfileV2, + spec: OperationSpec, + ) -> ScopeSnapshotV2: + state = self.session_state + guarded_transport = ( + self.transport if isinstance(self.transport, GuardedAuditedTransport) else None + ) + query_calls_before = ( + guarded_transport.counters.query_calls if guarded_transport is not None else None + ) + if state is None: + result = scope.get_snapshot_v2(channel, fields=profile.readable_fields) + else: + timeout_ms = self._operation_timeout_ms(spec) + coordinator = SessionTransactionCoordinator(state) + with coordinator.authorize_normal( + operation_id=spec.operation, + allowed_io=("query",), + fields=("scope.snapshot_v2",), + timeout_ms=timeout_ms, + max_steps=profile.max_queries, + context_id="scope_snapshot_v2", + correlation_id=uuid4().hex, + phase="main", + absolute_deadline=time.monotonic() + (timeout_ms / 1000.0), + ): + result = scope.get_snapshot_v2(channel, fields=profile.readable_fields) + if query_calls_before is not None and guarded_transport is not None: + query_calls = guarded_transport.counters.query_calls - query_calls_before + if query_calls > profile.max_queries: + raise DataError("scope snapshot V2 exceeded its descriptor query budget") + try: + profile.validate_result(result, channel=channel) + except (TypeError, ValueError) as exc: + raise DataError(f"scope snapshot V2 driver returned an invalid result: {exc}") from exc + return result + def status_summary(self, channel: int, *, strict: bool = False) -> ScopeStatusSummary: """Return a complete snapshot when available, otherwise a read-only partial summary.""" @@ -391,6 +500,69 @@ def acquisition_status(self) -> ScopeAcquisitionStatus: with self._scope_session() as scope: return cast(ScopeAcquisitionStatusDriver, scope).get_acquisition_status() + def acquisition_status_v2(self) -> ScopeAcquisitionStatusV2: + spec = self._require( + "scope.acquisition_status_v2", + "scope.acquisition_status_v2", + ) + profile = self._acquisition_status_v2_profile() + if profile is None: + raise ConfigError( + "scope acquisition status V2 requires " + "scope_extensions.acquisition_status_profile_v2" + ) + with self._scope_session() as scope: + return self._execute_acquisition_status_v2( + cast(ScopeAcquisitionStatusDriverV2, scope), + profile=profile, + spec=spec, + ) + + def _execute_acquisition_status_v2( + self, + scope: ScopeAcquisitionStatusDriverV2, + *, + profile: ScopeAcquisitionStatusProfileV2, + spec: OperationSpec, + ) -> ScopeAcquisitionStatusV2: + state = self.session_state + guarded_transport = ( + self.transport if isinstance(self.transport, GuardedAuditedTransport) else None + ) + query_calls_before = ( + guarded_transport.counters.query_calls if guarded_transport is not None else None + ) + if state is None: + result = scope.get_acquisition_status_v2(fields=profile.readable_fields) + else: + timeout_ms = self._operation_timeout_ms(spec) + coordinator = SessionTransactionCoordinator(state) + with coordinator.authorize_normal( + operation_id=spec.operation, + allowed_io=("query",), + fields=("scope.acquisition_status_v2",), + timeout_ms=timeout_ms, + max_steps=profile.max_queries, + context_id="scope_acquisition_status_v2", + correlation_id=uuid4().hex, + phase="main", + absolute_deadline=time.monotonic() + (timeout_ms / 1000.0), + ): + result = scope.get_acquisition_status_v2(fields=profile.readable_fields) + if query_calls_before is not None and guarded_transport is not None: + query_calls = guarded_transport.counters.query_calls - query_calls_before + if query_calls > profile.max_queries: + raise DataError( + "scope acquisition status V2 exceeded its descriptor query budget" + ) + try: + profile.validate_result(result) + except (TypeError, ValueError) as exc: + raise DataError( + f"scope acquisition status V2 driver returned an invalid result: {exc}" + ) from exc + return result + def capture_average( self, *, @@ -424,6 +596,39 @@ def capture_average( ) return cast(ScopeAverageCaptureDriver, scope).capture_average(request) + def capture_average_v2( + self, + request: ScopeAverageCaptureRequestV2, + ) -> ScopeAverageCaptureResultV2: + if not isinstance(request, ScopeAverageCaptureRequestV2): + raise ConfigError("scope average capture V2 request has an invalid type") + profile = self._average_capture_v2_profile() + if profile is None: + raise ConfigError( + "scope average capture V2 requires " + "scope_extensions.average_capture_profile_v2" + ) + try: + profile.validate_request(request) + except (TypeError, ValueError) as exc: + raise ConfigError(f"invalid scope average capture V2 request: {exc}") from exc + required = [ + "scope.capture_average_v2", + "scope.idn", + "scope.acquisition_status_v2", + "scope.acquisition_run_state", + "scope.acquisition_control", + "scope.channel_input_state_v2", + ] + if self.config.scope.check_errors: + required.append("scope.error_drain_v1") + self._require("scope.capture_average_v2", *required) + with self._scope_session() as scope: + return self._average_capture_v2_executor(scope).execute( + request, + check_errors=self.config.scope.check_errors, + ).value + def history_timestamps(self, channel: int) -> ScopeHistoryTimestamps: self._require("scope.history_timestamps", "scope.history_timestamps") with self._scope_session() as scope: @@ -434,6 +639,18 @@ def digital_status(self, channel: int) -> ScopeDigitalChannelStatus: with self._scope_session() as scope: return cast(ScopeDigitalStatusDriver, scope).get_digital_status(channel) + def digital_status_v2(self, channel: int) -> ScopeDigitalChannelStatusV2: + if isinstance(channel, bool) or not isinstance(channel, int) or channel < 0: + raise ConfigError("digital status V2 channel must be a non-negative integer") + self._require("scope.digital_status_v2", "scope.digital_status_v2") + with self._scope_session() as scope: + result = cast(ScopeDigitalStatusDriverV2, scope).get_digital_status_v2(channel) + if not isinstance(result, ScopeDigitalChannelStatusV2): + raise DataError("digital status V2 driver returned an invalid result") + if result.channel != channel: + raise DataError("digital status V2 driver returned the wrong channel") + return result + def digital_waveform( self, *, @@ -470,6 +687,80 @@ def measurement_statistics( acquisition_stopped=acquisition_stopped, ) + def measurement_statistics_v2( + self, + request: ScopeMeasurementStatisticsRequestV2, + ) -> ScopeMeasurementStatisticsV2: + if not isinstance(request, ScopeMeasurementStatisticsRequestV2): + raise ConfigError("measurement statistics V2 request has an invalid type") + spec = self._require( + "scope.measurement_statistics_v2", + "scope.measurement_statistics_v2", + ) + profile = self._measurement_statistics_v2_profile() + if profile is None: + raise ConfigError( + "scope measurement statistics V2 requires " + "scope_extensions.measurement_statistics_profile_v2" + ) + try: + profile.validate_request(request) + except (TypeError, ValueError) as exc: + raise ConfigError(f"invalid measurement statistics V2 request: {exc}") from exc + with self._scope_session() as scope: + return self._execute_measurement_statistics_v2( + cast(ScopeMeasurementStatisticsDriverV2, scope), + request=request, + profile=profile, + spec=spec, + ) + + def _execute_measurement_statistics_v2( + self, + scope: ScopeMeasurementStatisticsDriverV2, + *, + request: ScopeMeasurementStatisticsRequestV2, + profile: ScopeMeasurementStatisticsProfileV2, + spec: OperationSpec, + ) -> ScopeMeasurementStatisticsV2: + state = self.session_state + guarded_transport = ( + self.transport if isinstance(self.transport, GuardedAuditedTransport) else None + ) + query_calls_before = ( + guarded_transport.counters.query_calls if guarded_transport is not None else None + ) + if state is None: + result = scope.get_measurement_statistics_v2(request) + else: + timeout_ms = self._operation_timeout_ms(spec) + coordinator = SessionTransactionCoordinator(state) + with coordinator.authorize_normal( + operation_id=spec.operation, + allowed_io=("query",), + fields=("scope.measurement_statistics_v2",), + timeout_ms=timeout_ms, + max_steps=profile.max_queries, + context_id="scope_measurement_statistics_v2", + correlation_id=uuid4().hex, + phase="main", + absolute_deadline=time.monotonic() + (timeout_ms / 1000.0), + ): + result = scope.get_measurement_statistics_v2(request) + if query_calls_before is not None and guarded_transport is not None: + query_calls = guarded_transport.counters.query_calls - query_calls_before + if query_calls > profile.max_queries: + raise DataError( + "scope measurement statistics V2 exceeded its descriptor query budget" + ) + try: + profile.validate_result(result, request=request) + except (TypeError, ValueError) as exc: + raise DataError( + f"scope measurement statistics V2 driver returned an invalid result: {exc}" + ) from exc + return result + def math_waveform_metadata(self, math_index: int) -> ScopeDerivedWaveformMetadata: self._require("scope.math_metadata", "scope.math_metadata") with self._scope_session() as scope: @@ -485,6 +776,163 @@ def fft_status(self, math_index: int, *, configured_fft: bool) -> ScopeFftStatus configured_fft=configured_fft, ) + def fft_status_v2( + self, + math_index: int, + *, + configured_fft: bool, + ) -> ScopeFftStatusV2: + if isinstance(math_index, bool) or not isinstance(math_index, int) or math_index < 1: + raise ConfigError("FFT status V2 math_index must be a positive integer") + if configured_fft is not True: + raise ConfigError("FFT status V2 requires configured_fft=True") + spec = self._require("scope.fft_status_v2", "scope.fft_status_v2") + profile = self._fft_status_v2_profile() + if profile is None: + raise ConfigError("scope FFT status V2 requires scope_extensions.fft_status_profile_v2") + with self._scope_session() as scope: + return self._execute_fft_status_v2( + cast(ScopeFftStatusDriverV2, scope), + math_index=math_index, + configured_fft=configured_fft, + profile=profile, + spec=spec, + ) + + def _execute_fft_status_v2( + self, + scope: ScopeFftStatusDriverV2, + *, + math_index: int, + configured_fft: bool, + profile: ScopeFftStatusProfileV2, + spec: OperationSpec, + ) -> ScopeFftStatusV2: + state = self.session_state + guarded_transport = ( + self.transport if isinstance(self.transport, GuardedAuditedTransport) else None + ) + query_calls_before = ( + guarded_transport.counters.query_calls if guarded_transport is not None else None + ) + if state is None: + result = scope.get_fft_status_v2(math_index, configured_fft=configured_fft) + else: + timeout_ms = self._operation_timeout_ms(spec) + coordinator = SessionTransactionCoordinator(state) + with coordinator.authorize_normal( + operation_id=spec.operation, + allowed_io=("query",), + fields=("scope.fft_status_v2",), + timeout_ms=timeout_ms, + max_steps=profile.max_queries, + context_id="scope_fft_status_v2", + correlation_id=uuid4().hex, + phase="main", + absolute_deadline=time.monotonic() + (timeout_ms / 1000.0), + ): + result = scope.get_fft_status_v2(math_index, configured_fft=configured_fft) + if query_calls_before is not None and guarded_transport is not None: + query_calls = guarded_transport.counters.query_calls - query_calls_before + if query_calls > profile.max_queries: + raise DataError("scope FFT status V2 exceeded its descriptor query budget") + try: + profile.validate_result(result, math_index=math_index) + except (TypeError, ValueError) as exc: + raise DataError(f"scope FFT status V2 driver returned an invalid result: {exc}") from exc + return result + + def cursor_readout_v2( + self, + cursor_index: int | None, + *, + configured_cursor: bool, + ) -> ScopeCursorReadoutV2: + if cursor_index is not None and ( + isinstance(cursor_index, bool) + or not isinstance(cursor_index, int) + or cursor_index < 1 + ): + raise ConfigError("cursor readout V2 cursor_index must be a positive integer or None") + if configured_cursor is not True: + raise ConfigError("cursor readout V2 requires configured_cursor=True") + spec = self._require("scope.cursor_readout_v2", "scope.cursor_readout_v2") + profile = self._cursor_readout_v2_profile() + if profile is None: + raise ConfigError( + "scope cursor readout V2 requires scope_extensions.cursor_readout_profile_v2" + ) + try: + profile.validate_request( + cursor_index=cursor_index, + configured_cursor=configured_cursor, + ) + except (TypeError, ValueError) as exc: + raise ConfigError(f"invalid cursor readout V2 request: {exc}") from exc + with self._scope_session() as scope: + return self._execute_cursor_readout_v2( + cast(ScopeCursorReadoutDriverV2, scope), + cursor_index=cursor_index, + configured_cursor=configured_cursor, + profile=profile, + spec=spec, + ) + + def _execute_cursor_readout_v2( + self, + scope: ScopeCursorReadoutDriverV2, + *, + cursor_index: int | None, + configured_cursor: bool, + profile: ScopeCursorReadoutProfileV2, + spec: OperationSpec, + ) -> ScopeCursorReadoutV2: + state = self.session_state + guarded_transport = ( + self.transport if isinstance(self.transport, GuardedAuditedTransport) else None + ) + query_calls_before = ( + guarded_transport.counters.query_calls if guarded_transport is not None else None + ) + if state is None and self.transport is not None: + raise ConfigError( + "scope cursor readout V2 requires a shared instrument session state" + ) + if state is None: + result = scope.get_cursor_readout_v2( + cursor_index, + configured_cursor=configured_cursor, + ) + else: + timeout_ms = self._operation_timeout_ms(spec) + coordinator = SessionTransactionCoordinator(state) + with coordinator.authorize_normal( + operation_id=spec.operation, + allowed_io=("query",), + fields=("scope.cursor_readout_v2",), + timeout_ms=timeout_ms, + max_steps=profile.max_queries, + context_id="scope_cursor_readout_v2", + correlation_id=uuid4().hex, + phase="main", + absolute_deadline=time.monotonic() + (timeout_ms / 1000.0), + ): + result = scope.get_cursor_readout_v2( + cursor_index, + configured_cursor=configured_cursor, + ) + if query_calls_before is not None and guarded_transport is not None: + query_calls = guarded_transport.counters.query_calls - query_calls_before + if query_calls > profile.max_queries: + raise DataError("scope cursor readout V2 exceeded its descriptor query budget") + try: + profile.validate_result(result, cursor_index=cursor_index) + except (TypeError, ValueError) as exc: + raise DataError( + f"scope cursor readout V2 driver returned an invalid result: {exc}" + ) from exc + return result + def reference_waveform_metadata( self, reference_index: int, @@ -648,6 +1096,20 @@ def channel_coupling(self, channel: int) -> str: with self._scope_session() as scope: return scope.channel_coupling(channel) + def channel_input_state_v2(self, channel: int) -> ScopeChannelInputStateV2: + if isinstance(channel, bool) or not isinstance(channel, int) or channel < 1: + raise ConfigError("scope input-state channel must be a positive integer") + self._require("scope.channel_input_state_v2", "scope.channel_input_state_v2") + with self._scope_session() as scope: + result = cast(ScopeChannelInputStateDriverV2, scope).get_channel_input_state_v2( + channel + ) + if not isinstance(result, ScopeChannelInputStateV2): + raise DataError("scope input-state V2 driver returned an invalid result") + if result.channel != channel: + raise DataError("scope input-state V2 driver returned the wrong channel") + return result + def require_high_impedance(self, channel: int, *, allow_50ohm: bool = False) -> str: coupling = self.channel_coupling(channel) descriptor = self.descriptor or resolve_instrument_descriptor( @@ -678,11 +1140,24 @@ def fetch_waveform(self, channel: int) -> WaveformData: raise ConfigError("MVP-1 only supports waveform.format = 'real'") if self.config.waveform.byte_order.lower() != "lsbf": raise ConfigError("MVP-1 only supports waveform.byte_order = 'lsbf'") + bounded_profile = self._waveform_binary_profile() required = ["scope.fetch_waveform"] - if self.config.scope.check_errors: + if bounded_profile is not None: + required.append("scope.idn") + if self.config.scope.check_errors: + required.append("scope.error_drain_v1") + elif self.config.scope.check_errors: required.append("scope.errors") self._require("scope.fetch_waveform", *required) with self._scope_session() as scope: + if bounded_profile is not None: + result = self._bounded_waveform_executor(scope).fetch( + channel=channel, + points=self.config.waveform.points, + check_errors=self.config.scope.check_errors, + ) + assert isinstance(result.value, WaveformData) + return result.value self._session_preflight("scope.fetch_waveform", scope) return scope.fetch_waveform( channel=channel, @@ -753,6 +1228,120 @@ def _legacy_capture_screenshot_capability(self) -> str: ) return "scope.screenshot" + def _waveform_binary_profile(self) -> ScopeWaveformBinaryProfile | None: + descriptor = self.descriptor or resolve_instrument_descriptor( + self.config.scope.driver, + expected_kind="scope", + ) + extensions = getattr(descriptor, "scope_extensions", None) + return getattr(extensions, "waveform_binary_profile", None) + + def _snapshot_v2_profile(self) -> ScopeSnapshotProfileV2 | None: + descriptor = self.descriptor or resolve_instrument_descriptor( + self.config.scope.driver, + expected_kind="scope", + ) + extensions = getattr(descriptor, "scope_extensions", None) + profile = getattr(extensions, "snapshot_profile_v2", None) + if profile is not None and not isinstance(profile, ScopeSnapshotProfileV2): + raise ConfigError("scope snapshot V2 descriptor profile has an invalid type") + return profile + + def _acquisition_status_v2_profile(self) -> ScopeAcquisitionStatusProfileV2 | None: + descriptor = self.descriptor or resolve_instrument_descriptor( + self.config.scope.driver, + expected_kind="scope", + ) + extensions = getattr(descriptor, "scope_extensions", None) + profile = getattr(extensions, "acquisition_status_profile_v2", None) + if profile is not None and not isinstance(profile, ScopeAcquisitionStatusProfileV2): + raise ConfigError("scope acquisition status V2 descriptor profile has an invalid type") + if ( + profile is not None + and "run_state" in profile.readable_fields + and "scope.acquisition_run_state" not in descriptor.capabilities + ): + raise ConfigError( + "scope acquisition status V2 profile reads run_state but the descriptor " + "does not declare scope.acquisition_run_state" + ) + return profile + + def _average_capture_v2_profile(self) -> ScopeAverageCaptureProfileV2 | None: + descriptor = self.descriptor or resolve_instrument_descriptor( + self.config.scope.driver, + expected_kind="scope", + ) + extensions = getattr(descriptor, "scope_extensions", None) + profile = getattr(extensions, "average_capture_profile_v2", None) + if profile is not None and not isinstance(profile, ScopeAverageCaptureProfileV2): + raise ConfigError("scope average capture V2 descriptor profile has an invalid type") + return profile + + def _measurement_statistics_v2_profile( + self, + ) -> ScopeMeasurementStatisticsProfileV2 | None: + descriptor = self.descriptor or resolve_instrument_descriptor( + self.config.scope.driver, + expected_kind="scope", + ) + extensions = getattr(descriptor, "scope_extensions", None) + profile = getattr(extensions, "measurement_statistics_profile_v2", None) + if profile is not None and not isinstance(profile, ScopeMeasurementStatisticsProfileV2): + raise ConfigError("scope measurement statistics V2 descriptor profile has an invalid type") + return profile + + def _fft_status_v2_profile(self) -> ScopeFftStatusProfileV2 | None: + descriptor = self.descriptor or resolve_instrument_descriptor( + self.config.scope.driver, + expected_kind="scope", + ) + extensions = getattr(descriptor, "scope_extensions", None) + profile = getattr(extensions, "fft_status_profile_v2", None) + if profile is not None and not isinstance(profile, ScopeFftStatusProfileV2): + raise ConfigError("scope FFT status V2 descriptor profile has an invalid type") + return profile + + def _cursor_readout_v2_profile(self) -> ScopeCursorReadoutProfileV2 | None: + descriptor = self.descriptor or resolve_instrument_descriptor( + self.config.scope.driver, + expected_kind="scope", + ) + extensions = getattr(descriptor, "scope_extensions", None) + profile = getattr(extensions, "cursor_readout_profile_v2", None) + if profile is not None and not isinstance(profile, ScopeCursorReadoutProfileV2): + raise ConfigError("scope cursor readout V2 descriptor profile has an invalid type") + return profile + + def _bounded_waveform_executor(self, scope: object) -> BoundedWaveformExecutor: + if self.descriptor is None or self.session_state is None: + raise ConfigError( + "bounded waveform operations require a factory-owned descriptor and session" + ) + return BoundedWaveformExecutor( + driver=scope, + descriptor=self.descriptor, + session_state=self.session_state, + connection_timeout_ms=self.config.connection.timeout_ms, + transport=self.transport if isinstance(self.transport, GuardedAuditedTransport) else None, + ) + + def _average_capture_v2_executor(self, scope: object) -> ScopeAverageCaptureExecutor: + if self.descriptor is None or self.session_state is None: + raise ConfigError( + "average capture V2 requires a factory-owned descriptor and session" + ) + return ScopeAverageCaptureExecutor( + driver=scope, + descriptor=self.descriptor, + session_state=self.session_state, + connection_timeout_ms=self.config.connection.timeout_ms, + transport=self.transport if isinstance(self.transport, GuardedAuditedTransport) else None, + ) + + def _can_attempt_failure_screenshot(self) -> bool: + return self.session_state is None or self.session_state.health is not SessionHealth.POISONED + def _waveform_metadata(self, waveform: WaveformData) -> dict[str, Any]: return { "header": { @@ -801,6 +1390,9 @@ def rewrite_failed_paths(value: Any) -> Any: "error": {"type": type(exc).__name__, "message": str(exc)}, "files": {"commands": str(failed_dir / "commands.log")} if commands_log_path is not None else {}, } + diagnostics = getattr(exc, "scope_operation_diagnostics", None) + if isinstance(diagnostics, Mapping): + partial_metadata["scope_operation_diagnostics"] = dict(diagnostics) if partial is not None: partial_metadata.update(rewrite_failed_paths(partial)) (failed_dir / "metadata.partial.json").write_text( @@ -809,8 +1401,11 @@ def rewrite_failed_paths(value: Any) -> Any: ) def capture_waveform(self, channel: int, label: str) -> CaptureResult: + bounded_profile = self._waveform_binary_profile() required = ["scope.idn", "scope.capture_waveform"] - if self.config.scope.check_errors: + if bounded_profile is not None and self.config.scope.check_errors: + required.append("scope.error_drain_v1") + elif self.config.scope.check_errors: required.append("scope.errors") if self.config.output.save_screenshot: required.append(self._legacy_capture_screenshot_capability()) @@ -837,17 +1432,29 @@ def capture_waveform(self, channel: int, label: str) -> CaptureResult: screenshot_error: dict[str, str] | None = None try: with self._scope_session() as scope: - evidence = self._session_preflight("scope.capture", scope) - instrument_idn = evidence.get("scope.identity") or scope.idn() - capture_kwargs = { - "channel": channel, - "points": self.config.waveform.points, - "check_errors": self.config.scope.check_errors, - "time_range_s": self.config.waveform.time_range_s, - } - if self.config.waveform.vertical_scale_v_per_div is not None: - capture_kwargs["vertical_scale_v_per_div"] = self.config.waveform.vertical_scale_v_per_div - waveform = scope.capture_waveform(**capture_kwargs) + if bounded_profile is not None: + result = self._bounded_waveform_executor(scope).capture_single( + channel=channel, + points=self.config.waveform.points, + time_range_s=self.config.waveform.time_range_s, + vertical_scale_v_per_div=self.config.waveform.vertical_scale_v_per_div, + check_errors=self.config.scope.check_errors, + ) + assert isinstance(result.value, WaveformData) + waveform = result.value + instrument_idn = result.identity + else: + evidence = self._session_preflight("scope.capture", scope) + instrument_idn = evidence.get("scope.identity") or scope.idn() + capture_kwargs = { + "channel": channel, + "points": self.config.waveform.points, + "check_errors": self.config.scope.check_errors, + "time_range_s": self.config.waveform.time_range_s, + } + if self.config.waveform.vertical_scale_v_per_div is not None: + capture_kwargs["vertical_scale_v_per_div"] = self.config.waveform.vertical_scale_v_per_div + waveform = scope.capture_waveform(**capture_kwargs) screenshot_path, screenshot_error = self._write_screenshot_file(package_dir, scope) except Exception as exc: self._failed_capture_package( @@ -889,8 +1496,11 @@ def capture_waveforms(self, channels: list[int], label: str) -> MultiCaptureResu raise ConfigError("at least one channel is required") if len(set(channels)) != len(channels): raise ConfigError("duplicate channels are not allowed") + bounded_profile = self._waveform_binary_profile() required = ["scope.idn", "scope.capture_waveforms"] - if self.config.scope.check_errors: + if bounded_profile is not None and self.config.scope.check_errors: + required.append("scope.error_drain_v1") + elif self.config.scope.check_errors: required.append("scope.errors") if self.config.output.save_screenshot: required.append(self._legacy_capture_screenshot_capability()) @@ -927,16 +1537,6 @@ def capture_waveforms(self, channels: list[int], label: str) -> MultiCaptureResu scope = opened_scope try: stage = "identify" - evidence = self._session_preflight("scope.capture_multiple", scope) - instrument_idn = evidence.get("scope.identity") or scope.idn() - capture_kwargs: dict[str, Any] = { - "channels": channels, - "points": self.config.waveform.points, - "check_errors": self.config.scope.check_errors, - "time_range_s": self.config.waveform.time_range_s, - } - if self.config.waveform.vertical_scale_v_per_div is not None: - capture_kwargs["vertical_scale_v_per_div"] = self.config.waveform.vertical_scale_v_per_div def start_channel(channel: int | None) -> None: nonlocal failed_channel, stage @@ -961,9 +1561,33 @@ def save_waveform(channel: int, waveform: WaveformData) -> None: stage = "acquire" failed_channel = None - capture_kwargs["on_channel_start"] = start_channel - capture_kwargs["on_waveform"] = save_waveform - returned_waveforms = scope.capture_waveforms(**capture_kwargs) + if bounded_profile is not None: + result = self._bounded_waveform_executor(scope).capture_multiple( + channels=channels, + points=self.config.waveform.points, + time_range_s=self.config.waveform.time_range_s, + vertical_scale_v_per_div=self.config.waveform.vertical_scale_v_per_div, + check_errors=self.config.scope.check_errors, + on_channel_start=start_channel, + on_waveform=save_waveform, + ) + assert isinstance(result.value, dict) + returned_waveforms = result.value + instrument_idn = result.identity + else: + evidence = self._session_preflight("scope.capture_multiple", scope) + instrument_idn = evidence.get("scope.identity") or scope.idn() + capture_kwargs: dict[str, Any] = { + "channels": channels, + "points": self.config.waveform.points, + "check_errors": self.config.scope.check_errors, + "time_range_s": self.config.waveform.time_range_s, + } + if self.config.waveform.vertical_scale_v_per_div is not None: + capture_kwargs["vertical_scale_v_per_div"] = self.config.waveform.vertical_scale_v_per_div + capture_kwargs["on_channel_start"] = start_channel + capture_kwargs["on_waveform"] = save_waveform + returned_waveforms = scope.capture_waveforms(**capture_kwargs) for channel in channels: if channel not in waveforms: save_waveform(channel, returned_waveforms[channel]) @@ -973,7 +1597,7 @@ def save_waveform(channel: int, waveform: WaveformData) -> None: package_dir, scope ) except Exception: - if self.config.output.save_screenshot: + if self.config.output.save_screenshot and self._can_attempt_failure_screenshot(): screenshot_path, screenshot_error = self._write_screenshot_file( package_dir, scope ) diff --git a/src/wavebench/services/scope_waveform_executor.py b/src/wavebench/services/scope_waveform_executor.py new file mode 100644 index 0000000..55379c2 --- /dev/null +++ b/src/wavebench/services/scope_waveform_executor.py @@ -0,0 +1,613 @@ +"""Core-owned bounded execution for opt-in standard scope waveform operations.""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping +from dataclasses import asdict, dataclass, is_dataclass, replace +from hashlib import sha256 +from types import MappingProxyType +from typing import Any + +import numpy as np + +from wavebench.errors import ConfigError, DataError, InstrumentError +from wavebench.instruments.api import InstrumentDescriptor +from wavebench.instruments.models import WaveformData +from wavebench.instruments.scope_extension_capabilities import validate_scope_descriptor +from wavebench.instruments.scope_extensions import ( + ErrorCheckSpec, + ScopeWaveformBinaryOperationKind, + ScopeWaveformBinaryOperationProfile, + ScopeWaveformBinaryProfile, + ScopeWaveformTransferBaseline, + ScopeWaveformTransferRestoreResult, + ScopeWaveformTransferStateSnapshot, + ScopeWaveformTransferVerification, +) +from wavebench.scope_extension_constants import ( + SCOPE_WAVEFORM_BINARY_OPERATION_MAX_BYTES, + SCOPE_WAVEFORM_BINARY_QUERY_MAX_COUNT, + SCOPE_WAVEFORM_BINARY_RESPONSE_MAX_BYTES, + SCOPE_WAVEFORM_BINARY_RESYNCHRONIZATION_MAX_BYTES, + SCOPE_WAVEFORM_OPERATION_TIMEOUT_MS, +) +from wavebench.transport.guarded import GuardedAuditedTransport +from wavebench.transport.session import InstrumentSessionState, SessionHealth + +from .operation_specs import OperationSpec, require_operation_spec +from .scope_error_policy import ScopeErrorPolicyExecutor +from .scope_phase_coordinator import ( + OperationPhase, + ScopeBaselineHandle, + ScopeBinaryLimits, + ScopeOperationContextCoordinator, +) + + +_TEXT_READ_IO = {"query", "query_float_list", "query_opc"} +_WAVEFORM_MAIN_IO = {*_TEXT_READ_IO, "write", "write_bytes", "query_binary"} +_WAVEFORM_MAIN_MAX_STEPS = 512 +_OPERATION_ID_BY_KIND = { + "fetch": "scope.fetch_waveform", + "capture_single": "scope.capture", + "capture_multiple": "scope.capture_multiple", +} +_WaveformCallbackEvidence = tuple[object, str, tuple[int, ...], bytes] + + +def _json_safe(value: Any) -> Any: + if is_dataclass(value): + return _json_safe(asdict(value)) + if isinstance(value, Mapping): + return {str(key): _json_safe(item) for key, item in value.items()} + if isinstance(value, (tuple, list)): + return [_json_safe(item) for item in value] + if isinstance(value, (str, int, float, bool, type(None))): + return value + return str(value) + + +@dataclass(frozen=True, slots=True) +class BoundedWaveformExecutionResult: + """Private handoff preserving the stable public waveform return values.""" + + value: object + identity: str + diagnostics: Mapping[str, object] + + def __post_init__(self) -> None: + object.__setattr__(self, "diagnostics", MappingProxyType(dict(self.diagnostics))) + + +@dataclass(slots=True) +class BoundedWaveformExecutor: + """Run one descriptor-opted-in standard waveform operation under one ledger.""" + + driver: object + descriptor: InstrumentDescriptor + session_state: InstrumentSessionState + connection_timeout_ms: int + transport: GuardedAuditedTransport | None = None + + def __post_init__(self) -> None: + if isinstance(self.connection_timeout_ms, bool) or not isinstance( + self.connection_timeout_ms, int + ) or self.connection_timeout_ms < 1: + raise ValueError("connection_timeout_ms must be a positive integer") + if self.session_state.health is not SessionHealth.HEALTHY: + raise ConfigError("bounded waveform operations require a healthy session") + if ( + not isinstance(self.transport, GuardedAuditedTransport) + or self.transport.session_state is not self.session_state + or not self.transport._has_verified_bounded_binary_backend() + ): + raise ConfigError( + "bounded waveform operations require a factory-validated bounded transport" + ) + if "scope.idn" not in self.descriptor.capabilities: + raise ConfigError("bounded waveform operations require scope.idn") + if not callable(getattr(self.driver, "idn", None)): + raise ConfigError("bounded waveform operations require callable idn()") + validate_scope_descriptor(self.descriptor, driver=self.driver) + self._profile() + + @property + def capabilities(self) -> frozenset[str]: + return frozenset(self.descriptor.capabilities) + + def fetch( + self, + *, + channel: int, + points: str, + check_errors: bool, + ) -> BoundedWaveformExecutionResult: + requested_channel = self._validate_requested_channels((channel,))[0] + return self._execute( + operation_kind="fetch", + check_errors=check_errors, + expected_channel=requested_channel, + action=lambda baseline: self.driver.fetch_waveform_bounded( + requested_channel, + points, + baseline=baseline, + ), + ) + + def capture_single( + self, + *, + channel: int, + points: str, + time_range_s: float | None, + vertical_scale_v_per_div: float | None, + check_errors: bool, + ) -> BoundedWaveformExecutionResult: + requested_channel = self._validate_requested_channels((channel,))[0] + return self._execute( + operation_kind="capture_single", + check_errors=check_errors, + expected_channel=requested_channel, + action=lambda baseline: self.driver.capture_waveform_bounded( + requested_channel, + points, + time_range_s=time_range_s, + vertical_scale_v_per_div=vertical_scale_v_per_div, + baseline=baseline, + ), + ) + + def capture_multiple( + self, + *, + channels: list[int], + points: str, + time_range_s: float | None, + vertical_scale_v_per_div: float | None, + check_errors: bool, + on_channel_start: Callable[[int | None], None] | None, + on_waveform: Callable[[int, WaveformData], None] | None, + ) -> BoundedWaveformExecutionResult: + requested_channels = self._validate_requested_channels(tuple(channels)) + callback_evidence: dict[int, _WaveformCallbackEvidence] = {} + checked_on_channel_start, checked_on_waveform = self._validated_multi_callbacks( + requested_channels, + on_channel_start=on_channel_start, + on_waveform=on_waveform, + callback_evidence=callback_evidence, + ) + return self._execute( + operation_kind="capture_multiple", + check_errors=check_errors, + expected_channels=requested_channels, + callback_evidence=callback_evidence, + require_waveform_callbacks=on_waveform is not None, + action=lambda baseline: self.driver.capture_waveforms_bounded( + list(requested_channels), + points, + time_range_s=time_range_s, + vertical_scale_v_per_div=vertical_scale_v_per_div, + on_channel_start=checked_on_channel_start, + on_waveform=checked_on_waveform, + baseline=baseline, + ), + ) + + def _execute( + self, + *, + operation_kind: ScopeWaveformBinaryOperationKind, + check_errors: bool, + action: Callable[[ScopeWaveformTransferBaseline], object], + expected_channel: int | None = None, + expected_channels: tuple[int, ...] | None = None, + callback_evidence: Mapping[int, _WaveformCallbackEvidence] | None = None, + require_waveform_callbacks: bool = False, + ) -> BoundedWaveformExecutionResult: + profile = self._profile() + operation_profile = profile.operation_for(operation_kind) + spec = self._bounded_operation_spec(operation_kind) + context = ScopeOperationContextCoordinator( + session_state=self.session_state, + spec=spec, + connection_timeout_ms=self.connection_timeout_ms, + profile_binary_limits=ScopeBinaryLimits( + response_max_bytes=operation_profile.response_max_bytes, + operation_max_bytes=operation_profile.operation_max_bytes, + query_max_count=operation_profile.query_max_count, + resynchronization_max_bytes=operation_profile.resynchronization_max_bytes, + ), + transport_trailing=profile.transport_trailing, + required_binary_framing=profile.framing, + enabled=True, + ) + error_spec = ErrorCheckSpec(policy="required" if check_errors else "disabled") + try: + error_executor = ScopeErrorPolicyExecutor( + driver=self.driver, + capabilities=self.capabilities, + operation_spec=spec, + error_spec=error_spec, + correlation_id=context.correlation_id, + ) + except BaseException: + context.complete() + raise + + handle: ScopeBaselineHandle | None = None + baseline: ScopeWaveformTransferBaseline | None = None + identity: str | None = None + value: object | None = None + primary: BaseException | None = None + cleanup_error: BaseException | None = None + cleanup_diagnostics: dict[str, object] = {} + main_entered = False + try: + fields = tuple(operation_profile.restore_order) + preflight = context.make_phase_spec( + OperationPhase.PREFLIGHT, + allowed_io={"query"}, + fields={"scope.identity", *fields}, + max_steps=1 + operation_profile.snapshot_max_steps, + ) + with context.authorize_phase(preflight) as authorization: + identity = self._verify_identity() + snapshot = self.driver.snapshot_waveform_transfer_state(fields) + self._validate_snapshot(snapshot, fields) + handle = context.create_baseline( + kind="waveform_transfer", + fields=fields, + restore_order=fields, + ) + baseline = ScopeWaveformTransferBaseline( + context_id=handle.context_id, + session_epoch=handle.session_epoch, + baseline_nonce=handle.baseline_nonce, + snapshot=snapshot, + restore_order=fields, + ) + context.pass_baseline_to_main(handle) + context.complete_phase_verification( + authorization, + io_kind="query", + fields={"scope.identity", *fields}, + ) + error_executor.run(context, phase="before") + main = context.make_phase_spec( + OperationPhase.MAIN, + allowed_io=_WAVEFORM_MAIN_IO, + fields=set(spec.changed_fields), + max_steps=_WAVEFORM_MAIN_MAX_STEPS, + ) + try: + with context.authorize_phase(main): + main_entered = True + error_executor.mark_main_sent() + assert baseline is not None + value = action(baseline) + self._validate_value( + operation_kind, + value, + expected_channel=expected_channel, + expected_channels=expected_channels, + callback_evidence=callback_evidence, + require_waveform_callbacks=require_waveform_callbacks, + ) + self._require_binary_main_query(context) + error_executor.run(context, phase="after") + except BaseException as exc: + primary = exc + if error_executor.wants("after") and not context.has_phase( + OperationPhase.ERROR_AFTER + ): + error_executor.omit_after( + "session_unhealthy" + if self.session_state.health is not SessionHealth.HEALTHY + else "main_operation_failed" + ) + if ( + handle is not None + and main_entered + and self.session_state.health is not SessionHealth.POISONED + ): + context.mark_cleanup_required() + cleanup_error, cleanup_diagnostics = self._cleanup( + context, + handle, + baseline, + operation_profile, + failed=primary is not None, + ) + context.complete() + if primary is not None: + self._attach_diagnostics( + primary, + context, + error_executor, + cleanup_error=cleanup_error, + extra={"waveform_cleanup": cleanup_diagnostics}, + ) + raise primary + if cleanup_error is not None: + self._attach_diagnostics( + cleanup_error, + context, + error_executor, + extra={"waveform_cleanup": cleanup_diagnostics}, + ) + raise cleanup_error + assert identity is not None + return BoundedWaveformExecutionResult( + value=value, + identity=identity, + diagnostics={ + "scope_operation": context.artifact(), + "error_check": dict(error_executor.artifact), + "waveform_cleanup": cleanup_diagnostics, + }, + ) + except BaseException as exc: + if not context.terminal: + context.complete() + if not hasattr(exc, "scope_operation_diagnostics"): + self._attach_diagnostics(exc, context, error_executor) + raise + + def _cleanup( + self, + context: ScopeOperationContextCoordinator, + handle: ScopeBaselineHandle, + baseline: ScopeWaveformTransferBaseline | None, + profile: ScopeWaveformBinaryOperationProfile, + *, + failed: bool, + ) -> tuple[BaseException | None, dict[str, object]]: + assert baseline is not None + restore_result: ScopeWaveformTransferRestoreResult | None = None + verification: ScopeWaveformTransferVerification | None = None + error: BaseException | None = None + phase = OperationPhase.FAILURE_CLEANUP if failed else OperationPhase.SUCCESS_RESTORE + try: + restore = context.make_phase_spec( + phase, + allowed_io={"write"}, + fields=handle.fields, + max_steps=profile.restore_max_steps, + ) + with context.authorize_phase(restore): + context.begin_restore(handle) + try: + restore_result = self.driver.restore_waveform_transfer_state(baseline) + if not isinstance(restore_result, ScopeWaveformTransferRestoreResult): + raise TypeError("restore_waveform_transfer_state() returned an invalid result") + restore_result.validate_for(baseline) + succeeded = restore_result.status == "completed" + except BaseException: + context.finish_restore(handle, succeeded=False) + raise + context.finish_restore(handle, succeeded=succeeded) + if not succeeded: + raise InstrumentError("waveform transfer restore did not complete") + except BaseException as exc: + error = exc + + if self.session_state.health is not SessionHealth.POISONED: + try: + verify = context.make_phase_spec( + OperationPhase.CLEANUP_VERIFICATION, + allowed_io={"query"}, + fields=handle.fields, + max_steps=profile.verify_max_steps, + ) + with context.authorize_phase(verify) as authorization: + context.begin_verification(handle) + observed = self.driver.verify_waveform_transfer_state_restored(baseline) + self._validate_snapshot(observed, tuple(handle.fields)) + matched = observed == baseline.snapshot + verification = ScopeWaveformTransferVerification( + status="verified" if matched else "mismatch", + verified_fields=tuple(handle.fields) if matched else (), + mismatched_fields=() if matched else tuple(handle.fields), + ) + context.finish_verification( + handle, + authorization, + io_kind="query", + verified_fields=tuple(handle.fields), + matched=matched, + ) + except BaseException as exc: + error = error or exc + return error, { + "restore": _json_safe(restore_result), + "verification": _json_safe(verification), + } + + def _profile(self) -> ScopeWaveformBinaryProfile: + extensions = self.descriptor.scope_extensions + profile = extensions.waveform_binary_profile if extensions is not None else None + if profile is None: + raise ConfigError("bounded waveform executor requires a waveform binary profile") + return profile + + @staticmethod + def _validate_snapshot( + snapshot: object, + fields: tuple[str, ...], + ) -> None: + if not isinstance(snapshot, ScopeWaveformTransferStateSnapshot): + raise DataError("waveform transfer snapshot has an invalid type") + if tuple(snapshot.captured_fields) != fields: + raise DataError("waveform transfer snapshot fields do not match the profile") + + @staticmethod + def _validate_value( + operation_kind: ScopeWaveformBinaryOperationKind, + value: object, + *, + expected_channel: int | None, + expected_channels: tuple[int, ...] | None, + callback_evidence: Mapping[int, _WaveformCallbackEvidence] | None, + require_waveform_callbacks: bool, + ) -> None: + if operation_kind in {"fetch", "capture_single"}: + if not isinstance(value, WaveformData): + raise DataError("bounded waveform driver returned an invalid waveform") + if expected_channel is None or value.channel != expected_channel: + raise DataError("bounded waveform driver returned a waveform with a mismatched channel") + return + if expected_channels is None or not isinstance(value, dict): + raise DataError("bounded multi-waveform driver returned an invalid waveform map") + if any(isinstance(channel, bool) or not isinstance(channel, int) for channel in value): + raise DataError("bounded multi-waveform driver returned an invalid waveform map") + expected = frozenset(expected_channels) + actual = frozenset(value) + if actual != expected: + raise DataError( + "bounded multi-waveform driver returned a channel set that does not match the request" + ) + if require_waveform_callbacks and ( + callback_evidence is None or frozenset(callback_evidence) != expected + ): + raise DataError( + "bounded multi-waveform driver did not emit exactly one waveform callback for every channel" + ) + for channel, waveform in value.items(): + if not isinstance(waveform, WaveformData) or waveform.channel != channel: + raise DataError( + "bounded multi-waveform driver returned a waveform with a mismatched channel" + ) + if ( + callback_evidence is not None + and channel in callback_evidence + and callback_evidence[channel] != BoundedWaveformExecutor._waveform_evidence(waveform) + ): + raise DataError( + "bounded multi-waveform driver returned a waveform that does not match its callback" + ) + + @staticmethod + def _validate_requested_channels(channels: tuple[object, ...]) -> tuple[int, ...]: + if not channels: + raise ConfigError("at least one channel is required") + if any(isinstance(channel, bool) or not isinstance(channel, int) or channel < 1 for channel in channels): + raise ConfigError("bounded multi-waveform channels must be positive integers") + typed_channels = tuple(channels) + if len(set(typed_channels)) != len(typed_channels): + raise ConfigError("duplicate channels are not allowed") + return typed_channels + + @staticmethod + def _validated_multi_callbacks( + requested_channels: tuple[int, ...], + *, + on_channel_start: Callable[[int | None], None] | None, + on_waveform: Callable[[int, WaveformData], None] | None, + callback_evidence: dict[int, _WaveformCallbackEvidence], + ) -> tuple[ + Callable[[int | None], None] | None, + Callable[[int, WaveformData], None] | None, + ]: + expected = frozenset(requested_channels) + + checked_start: Callable[[int | None], None] | None = None + if on_channel_start is not None: + + def checked_start(channel: int | None) -> None: + if channel is not None and ( + isinstance(channel, bool) or not isinstance(channel, int) or channel not in expected + ): + raise DataError("bounded multi-waveform callback started an unrequested channel") + on_channel_start(channel) + + checked_waveform: Callable[[int, WaveformData], None] | None = None + if on_waveform is not None: + + def checked_waveform(channel: int, waveform: WaveformData) -> None: + if ( + isinstance(channel, bool) + or not isinstance(channel, int) + or channel not in expected + or not isinstance(waveform, WaveformData) + or waveform.channel != channel + ): + raise DataError("bounded multi-waveform callback returned an invalid waveform") + if channel in callback_evidence: + raise DataError("bounded multi-waveform callback emitted a channel more than once") + callback_evidence[channel] = BoundedWaveformExecutor._waveform_evidence(waveform) + on_waveform(channel, waveform) + + return checked_start, checked_waveform + + @staticmethod + def _waveform_evidence(waveform: WaveformData) -> _WaveformCallbackEvidence: + values = np.asarray(waveform.voltages_v) + contiguous = np.ascontiguousarray(values) + return ( + waveform.header, + contiguous.dtype.str, + tuple(contiguous.shape), + sha256(contiguous.tobytes()).digest(), + ) + + @staticmethod + def _require_binary_main_query(context: ScopeOperationContextCoordinator) -> None: + ledger = context.binary_ledger + if ledger is None: + raise DataError("bounded waveform operation has no binary ledger") + snapshot = ledger.snapshot() + if snapshot["remaining_query_count"] == snapshot["query_max_count"]: + raise DataError("bounded waveform driver did not issue a binary query") + + @staticmethod + def _verify_identity_value(value: object) -> str: + if not isinstance(value, str) or not value.strip(): + raise DataError("scope identity verification returned an empty response") + return value + + def _verify_identity(self) -> str: + return self._verify_identity_value(self.driver.idn()) + + @staticmethod + def _bounded_operation_spec( + operation_kind: ScopeWaveformBinaryOperationKind, + ) -> OperationSpec: + operation = _OPERATION_ID_BY_KIND[operation_kind] + base = require_operation_spec(operation) + return replace( + base, + timeout_source="operation.timeout_ms", + operation_timeout_ms=SCOPE_WAVEFORM_OPERATION_TIMEOUT_MS, + binary_response_max_bytes=SCOPE_WAVEFORM_BINARY_RESPONSE_MAX_BYTES, + binary_operation_max_bytes=SCOPE_WAVEFORM_BINARY_OPERATION_MAX_BYTES, + binary_query_max_count=SCOPE_WAVEFORM_BINARY_QUERY_MAX_COUNT, + binary_resynchronization_max_bytes=( + SCOPE_WAVEFORM_BINARY_RESYNCHRONIZATION_MAX_BYTES + ), + error_check_minimum="disabled", + ) + + @staticmethod + def _attach_diagnostics( + exc: BaseException, + context: ScopeOperationContextCoordinator, + error_executor: ScopeErrorPolicyExecutor, + *, + cleanup_error: BaseException | None = None, + extra: Mapping[str, object] | None = None, + ) -> None: + diagnostics: dict[str, object] = { + "scope_operation": context.artifact(), + "error_check": dict(error_executor.artifact), + } + if cleanup_error is not None: + diagnostics["cleanup_error_type"] = type(cleanup_error).__name__ + if extra: + diagnostics.update(extra) + try: + setattr(exc, "scope_operation_diagnostics", diagnostics) + except Exception: + pass + + +__all__ = ["BoundedWaveformExecutionResult", "BoundedWaveformExecutor"] diff --git a/src/wavebench/transport/binary.py b/src/wavebench/transport/binary.py index 9f64412..96b6776 100644 --- a/src/wavebench/transport/binary.py +++ b/src/wavebench/transport/binary.py @@ -112,6 +112,7 @@ class BinaryQueryLedger: "query_max_count", "resynchronization_max_bytes", "transport_trailing", + "required_framing", "_remaining_operation_bytes", "_remaining_query_count", "_discarded_bytes", @@ -134,6 +135,7 @@ def __init__( query_max_count: int, resynchronization_max_bytes: int, transport_trailing: bytes = b"", + required_framing: BinaryResponseFraming | None = None, ledger_id: str | None = None, ) -> None: self.context_id = _binding(context_id, label="context_id") @@ -163,6 +165,13 @@ def __init__( if len(transport_trailing) > 16: raise ValueError("transport_trailing cannot exceed 16 bytes") self.transport_trailing = transport_trailing + self.required_framing = ( + BinaryResponseFraming(required_framing) + if required_framing is not None + else None + ) + if self.required_framing is BinaryResponseFraming.MESSAGE and transport_trailing: + raise ValueError("message-framed ledgers cannot declare transport trailing bytes") self.ledger_id = _binding(ledger_id or uuid4().hex, label="ledger_id") self._remaining_operation_bytes = self.operation_max_bytes self._remaining_query_count = self.query_max_count @@ -287,6 +296,9 @@ def snapshot(self) -> dict[str, object]: "resynchronization_max_bytes": self.resynchronization_max_bytes, "discarded_bytes": self._discarded_bytes, "transport_trailing_bytes": len(self.transport_trailing), + "required_framing": ( + self.required_framing.value if self.required_framing is not None else None + ), } @property @@ -484,6 +496,16 @@ def visa_message_boundary_supported(session: object) -> bool: return resource_name.startswith(("GPIB", "TCPIP", "USB", "VXI", "PXI")) +def visa_binary_contract_supported(session: object) -> bool: + """Return whether a VISA handle can execute the core bounded-binary contract.""" + + return ( + visa_message_boundary_supported(session) + and callable(getattr(session, "write", None)) + and _has_low_level_visa_read(session) + ) + + def query_visa_binary_response( *, session: object, diff --git a/src/wavebench/transport/guarded.py b/src/wavebench/transport/guarded.py index 656ac6d..4b5cb88 100644 --- a/src/wavebench/transport/guarded.py +++ b/src/wavebench/transport/guarded.py @@ -81,7 +81,9 @@ class GuardedAuditedTransport: lease: ResourceLease | None = None release_lease_on_close: bool = True session_state: InstrumentSessionState = field(default_factory=InstrumentSessionState) + construction_latched: bool = False _closed: bool = field(default=False, init=False, repr=False) + _bounded_binary_backend_verified: bool = field(default=False, init=False, repr=False) def __setattr__(self, name: str, value: object) -> None: # A guard and its session state form one connection epoch. Rebinding @@ -93,6 +95,8 @@ def __setattr__(self, name: str, value: object) -> None: def __post_init__(self) -> None: self.access = normalize_access_mode(self.access, "access") + if not isinstance(self.construction_latched, bool): + raise TypeError("construction_latched must be bool") @property def resource(self) -> str: @@ -196,6 +200,7 @@ def query_bin_block( with self.session_state.transaction_lock: self._check_access("query_bin_block") self.counters.binary_query_calls += 1 + self._check_construction_latch("query_bin_block") active = self.session_state._active_authorization() if active is not None and active.binary_budget is not None: self.counters.blocked_binary_query_calls += 1 @@ -222,6 +227,7 @@ def query_binary( framing = BinaryResponseFraming(framing) self._check_access("query_binary") self.counters.binary_query_calls += 1 + self._check_construction_latch("query_binary") if replay is ReplayPolicy.READ_CONTINUATION_ONLY: self.counters.blocked_binary_query_calls += 1 raise self._binary_preflight_error("binary_continuation_unsupported", replay) @@ -233,6 +239,14 @@ def query_binary( if active is None or not isinstance(active.binary_budget, BinaryQueryBudget): self.counters.blocked_binary_query_calls += 1 raise self._binary_preflight_error("binary_budget_missing", replay) + if replay is not ReplayPolicy.NO_REPLAY: + self.counters.blocked_binary_query_calls += 1 + raise self._binary_preflight_error("binary_replay_unsupported", replay) + budget = active.binary_budget + ledger = budget._ledger + if ledger.required_framing is not None and framing is not ledger.required_framing: + self.counters.blocked_binary_query_calls += 1 + raise self._binary_preflight_error("binary_framing_profile_unsupported", replay) authorization = self._gate("query_binary") assert authorization is active remaining_ms = int( @@ -242,8 +256,6 @@ def query_binary( self.counters.blocked_binary_query_calls += 1 raise self._deadline_preflight_error("query_binary", replay) effective_timeout_ms = min(authorization.io_timeout_ms, remaining_ms) - budget = active.binary_budget - ledger = budget._ledger try: reservation = ledger.reserve( budget, @@ -403,6 +415,42 @@ def audit_snapshot(self) -> dict[str, Any]: "session": self.session_state.snapshot(), } + def _release_construction_latch(self) -> None: + """Release the factory-owned opt-in I/O latch after static validation.""" + + with self.session_state.transaction_lock: + if not self.construction_latched: + return + if self._closed or self.session_state.health is not SessionHealth.HEALTHY: + raise RuntimeError("cannot release construction latch on an unavailable transport") + self.construction_latched = False + + def _mark_bounded_binary_backend_verified(self) -> None: + """Record the factory's successful bounded-binary backend conformance check.""" + + with self.session_state.transaction_lock: + if self._closed or self.session_state.health is not SessionHealth.HEALTHY: + raise RuntimeError("cannot verify bounded backend on an unavailable transport") + self._bounded_binary_backend_verified = True + + def _has_verified_bounded_binary_backend(self) -> bool: + with self.session_state.transaction_lock: + return ( + self._bounded_binary_backend_verified + and not self._closed + and self.session_state.health is SessionHealth.HEALTHY + ) + + def _mark_bounded_waveform_backend_verified(self) -> None: + """Compatibility alias for the former waveform-specific internal marker.""" + + self._mark_bounded_binary_backend_verified() + + def _has_verified_bounded_waveform_backend(self) -> bool: + """Compatibility alias for the former waveform-specific internal predicate.""" + + return self._has_verified_bounded_binary_backend() + def _check_access(self, operation: str, *, write: bool = False) -> None: if write and self.access != "read_write": if operation == "write": @@ -424,6 +472,7 @@ def _gate(self, io_kind: str) -> SessionAuthorization | None: }: self.counters.blocked_session_io += 1 raise self._session_denied(io_kind) + self._check_construction_latch(io_kind) try: authorization = self.session_state._consume_authorization(io_kind) except ValueError as exc: @@ -434,6 +483,12 @@ def _gate(self, io_kind: str) -> SessionAuthorization | None: raise self._session_denied(io_kind) return authorization + def _check_construction_latch(self, io_kind: str) -> None: + if not self.construction_latched: + return + self.counters.blocked_session_io += 1 + raise self._construction_latch_error(io_kind) + def _record_success( self, authorization: SessionAuthorization | None, @@ -530,6 +585,22 @@ def _deadline_preflight_error( discarded_bytes=0, ) + @staticmethod + def _construction_latch_error(io_kind: str) -> TransportIOError: + return TransportIOError( + "instrument I/O is blocked until factory construction validation completes", + operation=io_kind, + phase=TransportPhase.BEFORE_SEND, + replay_policy=ReplayPolicy.NO_REPLAY, + command_transmission=CommandTransmission.NOT_SENT, + response_progress=ResponseProgress.NONE, + synchronization=Synchronization.PROVEN, + attempts=0, + reason_code="factory_construction_pending", + consumed_bytes=0, + discarded_bytes=0, + ) + def _close_poisoned_backend(self) -> None: """Close a framing-lost backend while preserving the poisoned diagnosis.""" diff --git a/tests/test_scope_acquisition_status_v2.py b/tests/test_scope_acquisition_status_v2.py new file mode 100644 index 0000000..b68731b --- /dev/null +++ b/tests/test_scope_acquisition_status_v2.py @@ -0,0 +1,687 @@ +from __future__ import annotations + +from dataclasses import asdict, replace +from types import SimpleNamespace + +import pytest + +from wavebench.errors import ConfigError, DataError, SessionHealthError, TransportIOError +from wavebench.instruments import ( + InstrumentDescriptor, + SCOPE_ACQUISITION_STATUS_V2_FIELD_ORDER, + ScopeAcquisitionRunState, + ScopeAcquisitionStatusDriverV2, + ScopeAcquisitionStatusFieldV2, + ScopeAcquisitionStatusV2, + ScopeAverageStatusV2, + ScopeSegmentedStatusV2, +) +from wavebench.instruments.capabilities import CAPABILITY_METHODS, validate_declared_capabilities +from wavebench.instruments.contracts import ScopeAcquisitionStatusDriver +from wavebench.instruments.factory import open_instrument_driver +from wavebench.instruments.scope_extension_capabilities import ( + SCOPE_CAPABILITY_METHODS, + SCOPE_STRICT_V2_CAPABILITIES, + validate_scope_descriptor, +) +from wavebench.instruments.scope_extensions import ( + ScopeAcquisitionStatusProfileV2, + ScopeDescriptorExtensions, +) +from wavebench.logging import CommandLogger +from wavebench.services.operation_specs import ( + SCOPE_OPERATION_SPECS, + SCOPE_PORTABILITY_V2_OPERATION_SPECS, + require_operation_spec, +) +from wavebench.transport.contracts import ReplayPolicy +from wavebench.transport.guarded import GuardedAuditedTransport +from wavebench.services.scope_service import ScopeService + + +_MINIMAL_FIELDS: tuple[ScopeAcquisitionStatusFieldV2, ...] = ("acquisition_type",) +_AVERAGE_FIELDS: tuple[ScopeAcquisitionStatusFieldV2, ...] = ( + "acquisition_type", + "sample_rate_hz", + "memory_depth", + "average", + "average.configured_count", +) + + +def _profile( + *, + readable_fields: tuple[ScopeAcquisitionStatusFieldV2, ...] = _MINIMAL_FIELDS, + max_queries: int = 1, + conditionally_applicable_fields: tuple[ScopeAcquisitionStatusFieldV2, ...] = (), +) -> ScopeAcquisitionStatusProfileV2: + return ScopeAcquisitionStatusProfileV2( + readable_fields=readable_fields, + max_queries=max_queries, + conditionally_applicable_fields=conditionally_applicable_fields, + ) + + +def _minimal_result() -> ScopeAcquisitionStatusV2: + return ScopeAcquisitionStatusV2( + acquisition_type="NORMAL", + unavailable_fields=( + "run_state", + "sample_rate_hz", + "memory_depth", + "average", + "segmented", + ), + ) + + +def _not_average_result() -> ScopeAcquisitionStatusV2: + return ScopeAcquisitionStatusV2( + acquisition_type="NORMAL", + sample_rate_hz=1_000_000.0, + memory_depth=1_000, + unavailable_fields=("run_state", "segmented"), + not_applicable_fields=("average",), + ) + + +def _complete_result() -> ScopeAcquisitionStatusV2: + return ScopeAcquisitionStatusV2( + acquisition_type="AVERAGES", + run_state=ScopeAcquisitionRunState("stopped", "normal", "STOP"), + sample_rate_hz=1_000_000.0, + memory_depth=1_000, + average=ScopeAverageStatusV2(configured_count=16, complete=True), + segmented=ScopeSegmentedStatusV2( + option_installed=True, + enabled=False, + maximum_enabled=False, + capacity=1_024, + available=0, + ), + ) + + +def _descriptor( + *, + profile: ScopeAcquisitionStatusProfileV2 | None = None, + minimum: str = "0.8.24", + capabilities: tuple[str, ...] = (), + extensions: bool = True, +) -> InstrumentDescriptor: + return InstrumentDescriptor( + driver_id="example.acquisition-status-v2", + kind="scope", + display_name="Example scope", + manufacturer="Example", + models=("EX1",), + aliases=(), + capabilities=capabilities or ("scope.acquisition_status_v2",), + idn_patterns=("EXAMPLE,EX1",), + backends=("pyvisa",), + option_specs=(), + permissions=("instrument.io",), + factory=lambda _context: object(), + wavebench_min_version=minimum, + scope_extensions=( + ScopeDescriptorExtensions( + acquisition_status_profile_v2=profile or _profile() + ) + if extensions + else None + ), + ) + + +def test_acquisition_status_v2_models_serialize_complete_and_partition_absence() -> None: + complete = _complete_result() + not_average = _not_average_result() + + assert asdict(complete)["average"] == {"configured_count": 16, "complete": True} + assert asdict(complete)["segmented"]["capacity"] == 1_024 + assert not_average.average is None + assert not_average.not_applicable_fields == ("average",) + assert not_average.unavailable_fields == ("run_state", "segmented") + assert not_average.field_values()["average.complete"] is None + assert SCOPE_ACQUISITION_STATUS_V2_FIELD_ORDER == ( + "acquisition_type", + "run_state", + "sample_rate_hz", + "memory_depth", + "average", + "average.configured_count", + "average.complete", + "segmented", + "segmented.option_installed", + "segmented.enabled", + "segmented.maximum_enabled", + "segmented.capacity", + "segmented.available", + ) + + +@pytest.mark.parametrize( + ("factory", "error_type", "message"), + ( + (lambda: ScopeAverageStatusV2(True), ValueError, "configured_count"), + (lambda: ScopeAverageStatusV2(2, complete=1), ValueError, "complete"), + (lambda: ScopeSegmentedStatusV2(capacity=True), ValueError, "capacity"), + (lambda: ScopeSegmentedStatusV2(available=-1), ValueError, "available"), + ( + lambda: ScopeAcquisitionStatusV2( + acquisition_type="not safe", + unavailable_fields=( + "run_state", + "sample_rate_hz", + "memory_depth", + "average", + "segmented", + ), + ), + ValueError, + "safe token", + ), + ( + lambda: ScopeAcquisitionStatusV2( + acquisition_type="NORMAL", + sample_rate_hz=0.0, + unavailable_fields=("run_state", "memory_depth", "average", "segmented"), + ), + ValueError, + "positive", + ), + ( + lambda: ScopeAcquisitionStatusV2( + acquisition_type="NORMAL", + memory_depth=True, + unavailable_fields=("run_state", "sample_rate_hz", "average", "segmented"), + ), + ValueError, + "memory_depth", + ), + ), +) +def test_acquisition_status_v2_nested_models_reject_invalid_values( + factory, + error_type: type[Exception], + message: str, +) -> None: + with pytest.raises(error_type, match=message): + factory() + + +@pytest.mark.parametrize( + ("changes", "error_type", "message"), + ( + ( + {"unavailable_fields": tuple(reversed(_minimal_result().unavailable_fields))}, + ValueError, + "stable field order", + ), + ( + {"unavailable_fields": ("run_state", "run_state", "sample_rate_hz", "memory_depth", "average", "segmented")}, + ValueError, + "duplicates", + ), + ( + {"unavailable_fields": ("run_state", "sample_rate_hz", "memory_depth", "average", "average.complete", "segmented")}, + ValueError, + "cannot mix partition and leaf", + ), + ( + { + "unavailable_fields": ("run_state", "sample_rate_hz", "memory_depth", "segmented"), + "not_applicable_fields": ("average.complete",), + }, + ValueError, + "exactly describe missing", + ), + ( + { + "unavailable_fields": ("run_state", "sample_rate_hz", "memory_depth", "average", "segmented"), + "not_applicable_fields": ("average",), + }, + ValueError, + "mutually exclusive", + ), + ), +) +def test_acquisition_status_v2_requires_exact_availability_coverage( + changes: dict[str, object], + error_type: type[Exception], + message: str, +) -> None: + values = { + "acquisition_type": "NORMAL", + "unavailable_fields": _minimal_result().unavailable_fields, + "not_applicable_fields": _minimal_result().not_applicable_fields, + } + values.update(changes) + with pytest.raises(error_type, match=message): + ScopeAcquisitionStatusV2(**values) # type: ignore[arg-type] + + +@pytest.mark.parametrize( + ("kwargs", "message"), + ( + ({"readable_fields": ()}, "must not be empty"), + ({"readable_fields": ("sample_rate_hz",)}, "include acquisition_type"), + ( + {"readable_fields": ("acquisition_type", "average.configured_count")}, + "average fields require", + ), + ( + {"readable_fields": ("acquisition_type", "average")}, + "average requires", + ), + ( + {"readable_fields": ("acquisition_type", "segmented")}, + "segmented requires", + ), + ({"conditionally_applicable_fields": ("acquisition_type",)}, "cannot be conditional"), + ({"max_queries": True}, "integer"), + ({"max_queries": 33}, "1..32"), + ), +) +def test_acquisition_status_v2_profile_rejects_invalid_static_contract( + kwargs: dict[str, object], + message: str, +) -> None: + values: dict[str, object] = { + "readable_fields": _MINIMAL_FIELDS, + "max_queries": 1, + "conditionally_applicable_fields": (), + } + values.update(kwargs) + with pytest.raises((TypeError, ValueError), match=message): + ScopeAcquisitionStatusProfileV2(**values) # type: ignore[arg-type] + + +def test_acquisition_status_v2_profile_enforces_parent_conditional_result_contract() -> None: + profile = _profile( + readable_fields=_AVERAGE_FIELDS, + max_queries=3, + conditionally_applicable_fields=("average",), + ) + + profile.validate_result(_not_average_result()) + profile.validate_result( + ScopeAcquisitionStatusV2( + acquisition_type="AVERAGES", + sample_rate_hz=1_000_000.0, + memory_depth=1_000, + average=ScopeAverageStatusV2(configured_count=16), + unavailable_fields=("run_state", "average.complete", "segmented"), + ) + ) + with pytest.raises(ValueError, match="non-conditional readable fields"): + profile.validate_result(_minimal_result()) + + +def test_acquisition_status_v2_is_additive_and_registered_without_r13_membership() -> None: + class Driver: + def idn(self) -> str: + return "EXAMPLE,EX1,SN-1,1.0" + + def close(self) -> None: + pass + + def get_acquisition_status_v2( + self, + *, + fields: tuple[ScopeAcquisitionStatusFieldV2, ...], + ) -> ScopeAcquisitionStatusV2: + assert fields == _MINIMAL_FIELDS + return _minimal_result() + + assert isinstance(Driver(), ScopeAcquisitionStatusDriverV2) + assert "get_acquisition_status_v2" not in ScopeAcquisitionStatusDriver.__dict__ + assert SCOPE_CAPABILITY_METHODS["scope.acquisition_status_v2"] == ( + "get_acquisition_status_v2", + ) + assert CAPABILITY_METHODS["scope.acquisition_status_v2"] == ( + "get_acquisition_status_v2", + ) + assert "scope.acquisition_status_v2" in SCOPE_STRICT_V2_CAPABILITIES + assert "scope.acquisition_status_v2" not in SCOPE_OPERATION_SPECS + spec = SCOPE_PORTABILITY_V2_OPERATION_SPECS["scope.acquisition_status_v2"] + assert require_operation_spec("scope.acquisition_status_v2") is spec + assert spec.effect == "stateful_read" + assert spec.lease_mode == "exclusive" + assert spec.required_verified_fields == () + assert spec.error_check_minimum == "disabled" + + +def test_acquisition_status_v2_descriptor_requires_profile_floor_method_and_run_state_dependency() -> None: + with pytest.raises(ConfigError, match="scope portability V2 capabilities require.*0.8.24"): + validate_scope_descriptor(_descriptor(minimum="0.8.23")) + with pytest.raises(ConfigError, match="acquisition_status_profile_v2"): + validate_scope_descriptor(_descriptor(extensions=False)) + + class MissingMethod: + def close(self) -> None: + pass + + with pytest.raises(TypeError, match="get_acquisition_status_v2"): + validate_declared_capabilities(_descriptor(), MissingMethod()) + + run_state_profile = _profile( + readable_fields=("acquisition_type", "run_state"), + ) + with pytest.raises(ConfigError, match="requires scope.acquisition_run_state"): + validate_scope_descriptor(_descriptor(profile=run_state_profile)) + validate_scope_descriptor( + _descriptor( + profile=run_state_profile, + capabilities=("scope.acquisition_status_v2", "scope.acquisition_run_state"), + ) + ) + + +def test_acquisition_status_v2_method_does_not_create_an_implicit_capability() -> None: + descriptor = _descriptor(capabilities=("scope.idn",), extensions=False) + + class Driver: + def idn(self) -> str: + return "EXAMPLE,EX1,SN-1,1.0" + + def close(self) -> None: + pass + + def get_acquisition_status_v2( + self, + *, + fields: tuple[ScopeAcquisitionStatusFieldV2, ...], + ) -> ScopeAcquisitionStatusV2: + raise AssertionError(f"undeclared status V2 must not be called for {fields!r}") + + validate_declared_capabilities(descriptor, Driver()) + assert "scope.acquisition_status_v2" not in descriptor.capabilities + + +def _service( + *, + profile: ScopeAcquisitionStatusProfileV2, + driver: object, + transport: GuardedAuditedTransport | None = None, +) -> ScopeService: + return ScopeService( + config=SimpleNamespace( + scope=SimpleNamespace(driver="example.acquisition-status-v2"), + connection=SimpleNamespace(timeout_ms=1_000), + ), + logger=SimpleNamespace(), + session=driver, + descriptor=SimpleNamespace( + driver_id="example.acquisition-status-v2", + capabilities=("scope.acquisition_status_v2",), + scope_extensions=ScopeDescriptorExtensions( + acquisition_status_profile_v2=profile, + ), + ), + transport=transport, + session_state=None if transport is None else transport.session_state, + ) + + +def test_acquisition_status_v2_service_only_calls_v2_and_keeps_legacy_route_separate() -> None: + calls: list[tuple[str, tuple[ScopeAcquisitionStatusFieldV2, ...] | None]] = [] + driver = SimpleNamespace( + get_acquisition_status_v2=lambda *, fields: calls.append(("v2", fields)) + or _minimal_result(), + get_acquisition_status=lambda: calls.append(("legacy", None)) + or (_ for _ in ()).throw(AssertionError("legacy status must stay separate")), + ) + service = _service(profile=_profile(), driver=driver) + + assert service.acquisition_status_v2() == _minimal_result() + assert calls == [("v2", _MINIMAL_FIELDS)] + + legacy = object() + driver.get_acquisition_status = lambda: calls.append(("legacy", None)) or legacy + service.descriptor = SimpleNamespace( + driver_id="example.acquisition-status-v2", + capabilities=("scope.acquisition_status", "scope.acquisition_status_v2"), + scope_extensions=ScopeDescriptorExtensions( + acquisition_status_profile_v2=_profile(), + ), + ) + assert service.acquisition_status() is legacy + assert calls == [("v2", _MINIMAL_FIELDS), ("legacy", None)] + + +def test_acquisition_status_v2_service_rejects_invalid_result_without_fallback() -> None: + service = _service( + profile=_profile(), + driver=SimpleNamespace(get_acquisition_status_v2=lambda *, fields: object()), + ) + + with pytest.raises(DataError, match="invalid result"): + service.acquisition_status_v2() + + +def test_acquisition_status_v2_service_rechecks_run_state_profile_dependency() -> None: + run_state_profile = _profile( + readable_fields=("acquisition_type", "run_state"), + ) + service = _service( + profile=run_state_profile, + driver=SimpleNamespace(get_acquisition_status_v2=lambda *, fields: _minimal_result()), + ) + + with pytest.raises(ConfigError, match="does not declare scope.acquisition_run_state"): + service.acquisition_status_v2() + + +class _InnerTransport: + resource = "TCPIP::example::INSTR" + + def __init__(self) -> None: + self.queries: list[str] = [] + self.writes: list[str] = [] + + def record_event(self, _direction: str, _text: str) -> None: + pass + + def query(self, command: str, *, replay: ReplayPolicy = ReplayPolicy.NO_REPLAY) -> str: + assert replay is ReplayPolicy.NO_REPLAY + self.queries.append(command) + return "NORMAL" + + def write(self, command: str) -> None: + self.writes.append(command) + + def close(self) -> None: + pass + + +def test_acquisition_status_v2_uses_only_budgeted_text_queries_without_identity_preflight() -> None: + inner = _InnerTransport() + transport = GuardedAuditedTransport(inner) + + class Driver: + def idn(self) -> str: + raise AssertionError("acquisition status V2 must not call legacy identity preflight") + + def get_acquisition_status_v2( + self, + *, + fields: tuple[ScopeAcquisitionStatusFieldV2, ...], + ) -> ScopeAcquisitionStatusV2: + assert fields == _MINIMAL_FIELDS + transport.query("ACQ:TYPE?") + return _minimal_result() + + service = _service(profile=_profile(max_queries=1), driver=Driver(), transport=transport) + + assert service.acquisition_status_v2() == _minimal_result() + assert inner.queries == ["ACQ:TYPE?"] + assert inner.writes == [] + assert transport.counters.query_calls == 1 + + +@pytest.mark.parametrize("mode", ("overrun", "write")) +def test_acquisition_status_v2_rejects_overrun_and_non_query_io_before_backend_send( + mode: str, +) -> None: + inner = _InnerTransport() + transport = GuardedAuditedTransport(inner) + + class Driver: + def get_acquisition_status_v2( + self, + *, + fields: tuple[ScopeAcquisitionStatusFieldV2, ...], + ) -> ScopeAcquisitionStatusV2: + assert fields == _MINIMAL_FIELDS + transport.query("ACQ:TYPE?") + if mode == "overrun": + transport.query("ACQ:EXTRA?") + else: + transport.write("ACQ:BAD") + return _minimal_result() + + service = _service(profile=_profile(max_queries=1), driver=Driver(), transport=transport) + + with pytest.raises(SessionHealthError): + service.acquisition_status_v2() + assert inner.queries == ["ACQ:TYPE?"] + assert inner.writes == [] + + +class _FactoryTransport: + resource = "TCPIP::example::INSTR" + + def __init__(self) -> None: + self.queries: list[str] = [] + self.closed = 0 + + def record_event(self, _direction: str, _text: str) -> None: + pass + + def query(self, command: str, *, replay: ReplayPolicy = ReplayPolicy.NO_REPLAY) -> str: + assert replay is ReplayPolicy.NO_REPLAY + self.queries.append(command) + return "ok" + + def close(self) -> None: + self.closed += 1 + + +def _open_factory_descriptor() -> object: + return open_instrument_driver( + driver_reference="example.acquisition-status-v2", + expected_kind="scope", + resource="TCPIP::example::INSTR", + configured_backend="pyvisa", + timeout_ms=1_000, + opc_timeout_ms=2_000, + read_retry_attempts=1, + read_retry_delay_ms=0, + logger=CommandLogger(), + ) + + +def test_acquisition_status_v2_factory_latch_blocks_construction_io_until_validation( + monkeypatch: pytest.MonkeyPatch, +) -> None: + inner = _FactoryTransport() + errors: list[TransportIOError] = [] + + class Driver: + def idn(self) -> str: + return "EXAMPLE,EX1,SN-1,1.0" + + def close(self) -> None: + pass + + def get_acquisition_status_v2( + self, + *, + fields: tuple[ScopeAcquisitionStatusFieldV2, ...], + ) -> ScopeAcquisitionStatusV2: + assert fields == _MINIMAL_FIELDS + return _minimal_result() + + def factory(context): + transport = context.open_transport() + with pytest.raises(TransportIOError) as raised: + transport.query("*IDN?") + errors.append(raised.value) + return Driver() + + descriptor = replace(_descriptor(), factory=factory) + monkeypatch.setattr( + "wavebench.instruments.factory.resolve_instrument_descriptor", + lambda _reference, expected_kind: descriptor, + ) + monkeypatch.setattr("wavebench.instruments.factory._open_transport", lambda **_kwargs: inner) + + opened = _open_factory_descriptor() + + assert [error.reason_code for error in errors] == ["factory_construction_pending"] + assert errors[0].attempts == 0 + assert inner.queries == [] + assert opened.transport is not None + assert opened.transport.query("*IDN?") == "ok" + assert inner.queries == ["*IDN?"] + + +def test_acquisition_status_v2_factory_closes_missing_method_without_io( + monkeypatch: pytest.MonkeyPatch, +) -> None: + inner = _FactoryTransport() + + class MissingMethod: + def close(self) -> None: + pass + + descriptor = replace( + _descriptor(), + factory=lambda context: (context.open_transport(), MissingMethod())[1], + ) + monkeypatch.setattr( + "wavebench.instruments.factory.resolve_instrument_descriptor", + lambda _reference, expected_kind: descriptor, + ) + monkeypatch.setattr("wavebench.instruments.factory._open_transport", lambda **_kwargs: inner) + + with pytest.raises(ConfigError, match="get_acquisition_status_v2"): + _open_factory_descriptor() + + assert inner.queries == [] + assert inner.closed == 1 + + +def test_undeclared_acquisition_status_v2_method_does_not_latch_legacy_factory( + monkeypatch: pytest.MonkeyPatch, +) -> None: + inner = _FactoryTransport() + + class LegacyDriver: + def idn(self) -> str: + return "EXAMPLE,EX1,SN-1,1.0" + + def close(self) -> None: + pass + + def get_acquisition_status_v2( + self, + *, + fields: tuple[ScopeAcquisitionStatusFieldV2, ...], + ) -> ScopeAcquisitionStatusV2: + raise AssertionError(f"undeclared status V2 must not be called for {fields!r}") + + descriptor = replace( + _descriptor(capabilities=("scope.idn",), extensions=False), + factory=lambda context: (context.open_transport().query("*IDN?"), LegacyDriver())[1], + ) + monkeypatch.setattr( + "wavebench.instruments.factory.resolve_instrument_descriptor", + lambda _reference, expected_kind: descriptor, + ) + monkeypatch.setattr("wavebench.instruments.factory._open_transport", lambda **_kwargs: inner) + + _open_factory_descriptor() + + assert inner.queries == ["*IDN?"] diff --git a/tests/test_scope_average_capture_v2.py b/tests/test_scope_average_capture_v2.py new file mode 100644 index 0000000..32662d7 --- /dev/null +++ b/tests/test_scope_average_capture_v2.py @@ -0,0 +1,930 @@ +from __future__ import annotations + +from dataclasses import replace +import numpy as np +import pytest + +from wavebench.errors import ConfigError, DataError, TransportIOError +from wavebench.instruments.api import InstrumentDescriptor +from wavebench.instruments.capabilities import CAPABILITY_METHODS +from wavebench.instruments.contracts import ScopeAverageCaptureDriver +from wavebench.instruments.factory import open_instrument_driver +from wavebench.instruments.scope_extension_capabilities import ( + SCOPE_CAPABILITY_METHODS, + SCOPE_STRICT_V2_CAPABILITIES, +) +from wavebench.instruments.models import ( + ScopeChannelInputStateV2, + WaveformData, + WaveformHeader, +) +from wavebench.instruments.scope_extensions import ( + ErrorDrainResult, + SCOPE_AVERAGE_CAPTURE_FIELD_ORDER, + ScopeAcquisitionCompletion, + ScopeAcquisitionControlBaseline, + ScopeAcquisitionControlProfile, + ScopeAcquisitionControlSnapshot, + ScopeAcquisitionRunState, + ScopeAcquisitionStatusProfileV2, + ScopeAverageCaptureBaseline, + ScopeAverageCaptureBinaryProfile, + ScopeAverageCaptureProfileV2, + ScopeAverageCaptureRequestV2, + ScopeAverageCaptureRestoreResult, + ScopeAverageCaptureResultV2, + ScopeAverageCaptureStateSnapshot, + ScopeAverageCaptureVerification, + ScopeAverageCompletionProofV2, + ScopeAverageConfigurationV2, + ScopeDescriptorExtensions, +) +from wavebench.services.scope_average_capture_executor import ScopeAverageCaptureExecutor +from wavebench.services.operation_specs import ( + SCOPE_OPERATION_SPECS, + SCOPE_PORTABILITY_V2_OPERATION_SPECS, + require_operation_spec, +) +from wavebench.services.scope_service import ScopeService +from wavebench.logging import CommandLogger +from wavebench.transport.contracts import ( + BinaryQueryResult, + BinaryResponseFraming, + CommandTransmission, + ReplayPolicy, + ResponseProgress, + Synchronization, + TransportPhase, +) +from wavebench.transport.guarded import GuardedAuditedTransport +from wavebench.transport.session import InstrumentSessionState, SessionHealth + + +def _stopped_state() -> ScopeAcquisitionRunState: + return ScopeAcquisitionRunState( + phase="stopped", + trigger_mode="single", + raw_state="STOP", + acquisition_count=3, + counter_epoch="epoch-1", + acquisition_identity="identity-1", + ) + + +def _completion() -> ScopeAcquisitionCompletion: + stopped = _stopped_state() + arming = ScopeAcquisitionRunState( + phase="arming", + trigger_mode="single", + raw_state="ARM", + acquisition_count=3, + counter_epoch="epoch-1", + acquisition_identity="identity-1", + ) + completed = ScopeAcquisitionRunState( + phase="complete", + trigger_mode="single", + raw_state="COMPLETE", + acquisition_count=4, + counter_epoch="epoch-1", + acquisition_identity="identity-2", + ) + return ScopeAcquisitionCompletion( + state=completed, + original_state=stopped, + proof_baseline_state=stopped, + proof_baseline_stage="configured_pre_arm", + proof="identity_delta", + baseline_identity="identity-1", + completed_identity="identity-2", + observed_states=(arming, completed), + ) + + +def _terminal_stop_completion() -> ScopeAcquisitionCompletion: + stopped = _stopped_state() + return ScopeAcquisitionCompletion( + state=stopped, + original_state=stopped, + proof_baseline_state=stopped, + proof_baseline_stage="original_atomic_arm", + proof="single_mode_readback_then_stopped", + observed_states=(stopped,), + post_arm_trigger_mode="single", + ) + + +def _snapshot( + *, + configuration: ScopeAverageConfigurationV2 | None = None, +) -> ScopeAverageCaptureStateSnapshot: + return ScopeAverageCaptureStateSnapshot( + captured_fields=SCOPE_AVERAGE_CAPTURE_FIELD_ORDER, + configuration=configuration + or ScopeAverageConfigurationV2( + mechanism="global_acquisition", + acquisition_type="normal", + average_count=1, + ), + run_state=_stopped_state(), + **{ + { + "scope.run_state": "run_state_token", + "scope.acquisition": "acquisition_token", + "scope.trigger": "trigger_token", + "scope.timebase": "timebase_token", + "scope.channel_display": "channel_display_token", + "scope.channel_vertical": "channel_vertical_token", + "scope.waveform_source": "waveform_source_token", + "scope.waveform_mode": "waveform_mode_token", + "scope.query_response_header": "query_response_header_token", + "scope.waveform_format": "waveform_format_token", + "scope.waveform_byte_order": "waveform_byte_order_token", + "scope.waveform_points": "waveform_points_token", + "scope.waveform_transfer_window": "waveform_transfer_window_token", + }[field_name]: f"token-{index}" + for index, field_name in enumerate(SCOPE_AVERAGE_CAPTURE_FIELD_ORDER) + }, + ) + + +def _baseline() -> ScopeAverageCaptureBaseline: + snapshot = _snapshot() + acquisition_baseline = ScopeAcquisitionControlBaseline( + context_id="context-1", + session_epoch="epoch-1", + baseline_nonce="child-nonce", + snapshot=ScopeAcquisitionControlSnapshot( + run_state=snapshot.run_state, + trigger_state_token=snapshot.trigger_token or "missing", + acquisition_state_token=snapshot.acquisition_token or "missing", + ), + restore_order=("scope.run_state", "scope.trigger", "scope.acquisition"), + ) + return ScopeAverageCaptureBaseline( + context_id="context-1", + session_epoch="epoch-1", + baseline_nonce="parent-nonce", + snapshot=snapshot, + restore_order=SCOPE_AVERAGE_CAPTURE_FIELD_ORDER, + acquisition_baseline=acquisition_baseline, + ) + + +def _profile() -> ScopeAverageCaptureProfileV2: + return ScopeAverageCaptureProfileV2( + global_acquisition_type="average", + completion_contract_id="device-average-complete-v1", + channel_range=(1, 4), + supported_points=("def", "dmax"), + average_count_min=2, + average_count_max=64, + requires_power_of_two=True, + binary=ScopeAverageCaptureBinaryProfile( + response_max_bytes=1_024, + operation_max_bytes=4_096, + query_max_count=4, + resynchronization_max_bytes=0, + transport_trailing_hex="0a", + ), + restore_order=SCOPE_AVERAGE_CAPTURE_FIELD_ORDER, + snapshot_max_steps=len(SCOPE_AVERAGE_CAPTURE_FIELD_ORDER), + main_max_steps=8, + restore_max_steps=len(SCOPE_AVERAGE_CAPTURE_FIELD_ORDER), + verify_max_steps=len(SCOPE_AVERAGE_CAPTURE_FIELD_ORDER), + ) + + +def _waveform() -> WaveformData: + return WaveformData( + channel=1, + header=WaveformHeader(0.0, 1e-3, 2), + voltages_v=np.array([0.0, 1.0]), + ) + + +def test_average_capture_v2_profile_validates_the_single_channel_r1_contract() -> None: + request = ScopeAverageCaptureRequestV2( + channels=(1,), + average_count=4, + mechanism="global_acquisition", + acquisition_stopped=True, + points="dmax", + ) + profile = _profile() + + profile.validate_request(request) + profile.validate_configuration( + ScopeAverageConfigurationV2("global_acquisition", "average", 4), + request=request, + ) + assert profile.binary.transport_trailing == b"\n" + + +def test_average_capture_v2_is_additive_and_uses_its_own_registered_contract() -> None: + class Driver: + def idn(self) -> str: + return "EXAMPLE,AVG-1,SN-1,1.0" + + def close(self) -> None: + pass + + def get_acquisition_run_state(self) -> ScopeAcquisitionRunState: + return _stopped_state() + + def snapshot_average_capture_state(self, fields): + return _snapshot() + + def set_average_acquisition_type_v2(self, acquisition_type, *, baseline) -> None: + pass + + def get_average_configuration_v2(self, *, baseline): + return ScopeAverageConfigurationV2("global_acquisition", "average", 4) + + def set_average_count_v2(self, average_count, *, baseline) -> None: + pass + + def acquire_average_single_v2(self, *, baseline, deadline): + return _completion() + + def get_device_average_complete_v2(self, *, baseline): + return True + + def fetch_average_waveform_bounded(self, channel, *, points, baseline): + return _waveform() + + def restore_average_capture_state(self, baseline): + return ScopeAverageCaptureRestoreResult("completed", baseline.restore_order, baseline.restore_order) + + def verify_average_capture_state_restored(self, baseline): + return baseline.snapshot + + from wavebench.instruments.scope_extensions import ScopeAverageCaptureDriverV2 + + assert isinstance(Driver(), ScopeAverageCaptureDriverV2) + assert "capture_average_v2" not in ScopeAverageCaptureDriver.__dict__ + assert SCOPE_CAPABILITY_METHODS["scope.capture_average_v2"] == ( + "snapshot_average_capture_state", + "set_average_acquisition_type_v2", + "get_average_configuration_v2", + "set_average_count_v2", + "acquire_average_single_v2", + "get_device_average_complete_v2", + "fetch_average_waveform_bounded", + "restore_average_capture_state", + "verify_average_capture_state_restored", + ) + assert CAPABILITY_METHODS["scope.capture_average_v2"] == SCOPE_CAPABILITY_METHODS[ + "scope.capture_average_v2" + ] + assert "scope.capture_average_v2" in SCOPE_STRICT_V2_CAPABILITIES + assert "scope.capture_average_v2" not in SCOPE_OPERATION_SPECS + spec = SCOPE_PORTABILITY_V2_OPERATION_SPECS["scope.capture_average_v2"] + assert require_operation_spec("scope.capture_average_v2") is spec + assert spec.effect == "acquire" + assert spec.lease_mode == "exclusive" + assert spec.restore_coverage == "average-capture-baseline" + assert spec.error_check_minimum == "disabled" + + +@pytest.mark.parametrize( + "kwargs, message", + [ + ( + { + "channels": (1, 2), + "average_count": 4, + "mechanism": "global_acquisition", + "acquisition_stopped": True, + }, + "exactly one channel", + ), + ( + { + "channels": (1,), + "average_count": 3, + "mechanism": "global_acquisition", + "acquisition_stopped": True, + }, + "power of two", + ), + ], +) +def test_average_capture_v2_request_and_profile_reject_outside_r1( + kwargs: dict[str, object], + message: str, +) -> None: + if len(kwargs["channels"]) != 1: # type: ignore[arg-type] + with pytest.raises(ValueError, match=message): + ScopeAverageCaptureRequestV2(**kwargs) # type: ignore[arg-type] + return + request = ScopeAverageCaptureRequestV2(**kwargs) # type: ignore[arg-type] + with pytest.raises(ValueError, match=message): + _profile().validate_request(request) + + +def test_average_capture_v2_baseline_binds_the_child_acquisition_baseline() -> None: + baseline = _baseline() + + assert baseline.acquisition_baseline.context_id == baseline.context_id + assert baseline.acquisition_baseline.baseline_nonce != baseline.baseline_nonce + + with pytest.raises(ValueError, match="child baseline trigger token"): + ScopeAverageCaptureBaseline( + context_id=baseline.context_id, + session_epoch=baseline.session_epoch, + baseline_nonce=baseline.baseline_nonce, + snapshot=baseline.snapshot, + restore_order=baseline.restore_order, + acquisition_baseline=ScopeAcquisitionControlBaseline( + context_id=baseline.context_id, + session_epoch=baseline.session_epoch, + baseline_nonce="other-child", + snapshot=ScopeAcquisitionControlSnapshot( + run_state=baseline.snapshot.run_state, + trigger_state_token="wrong", + acquisition_state_token=baseline.snapshot.acquisition_token or "missing", + ), + restore_order=("scope.run_state", "scope.trigger", "scope.acquisition"), + ), + ) + + +def test_average_capture_v2_result_requires_full_restore_and_fresh_verification() -> None: + baseline = _baseline() + request = ScopeAverageCaptureRequestV2( + channels=(1,), + average_count=4, + mechanism="global_acquisition", + acquisition_stopped=True, + ) + configured = ScopeAverageConfigurationV2("global_acquisition", "average", 4) + completion = ScopeAverageCompletionProofV2( + evidence="device_average_complete", + mechanism="global_acquisition", + configured_average_count=4, + configuration_readback=configured, + acquisition_completion=_completion(), + device_average_complete=True, + contract_id="device-average-complete-v1", + context_id=baseline.context_id, + session_epoch=baseline.session_epoch, + acquisition_baseline_nonce_digest="0123456789abcdef", + ) + + with pytest.raises(ValueError, match="terminal STOP"): + replace(completion, acquisition_completion=_terminal_stop_completion()) + restore = ScopeAverageCaptureRestoreResult( + "completed", + baseline.restore_order, + baseline.restore_order, + ) + restore.validate_for(baseline) + verification = ScopeAverageCaptureVerification( + "verified", + baseline.restore_order, + (), + ) + verification.validate_for(baseline) + + result = ScopeAverageCaptureResultV2( + request=request, + waveforms=(_waveform(),), + configuration_before=baseline.snapshot.configuration, + configuration_after=baseline.snapshot.configuration, + run_state_before=baseline.snapshot.run_state, + run_state_after=baseline.snapshot.run_state, + completion=completion, + restore=restore, + verification=verification, + ) + + assert result.waveforms[0].channel == 1 + + +class _Backend: + _wavebench_binary_budget_parameters = True + resource = "fake" + + def __init__(self) -> None: + self.events: list[str] = [] + self.fail_binary_sync = False + self.closed = 0 + + def record_event(self, _direction: str, _text: str) -> None: + pass + + def query(self, command: str, *, replay: ReplayPolicy = ReplayPolicy.NO_REPLAY) -> str: + assert replay is ReplayPolicy.NO_REPLAY + self.events.append(command) + return "EXAMPLE,AVG-1,SN-1,1.0" + + def query_binary( + self, + command: str, + *, + framing: BinaryResponseFraming, + max_bytes: int, + timeout_ms: int | None = None, + replay: ReplayPolicy = ReplayPolicy.NO_REPLAY, + _transport_trailing: bytes = b"", + _resynchronization_max_bytes: int = 0, + ) -> BinaryQueryResult: + assert framing is BinaryResponseFraming.DEFINITE_BLOCK + assert replay is ReplayPolicy.NO_REPLAY + self.events.append(command) + if self.fail_binary_sync: + raise TransportIOError( + "truncated binary response", + operation="query_binary", + phase=TransportPhase.READING, + replay_policy=replay, + command_transmission=CommandTransmission.SENT, + response_progress=ResponseProgress.PARTIAL, + synchronization=Synchronization.LOST, + attempts=1, + reason_code="binary_truncated", + consumed_bytes=1, + ) + payload = b"\x00\x01" + assert len(payload) <= max_bytes + return BinaryQueryResult( + data=payload, + framing=framing, + declared_length=len(payload), + framing_header_bytes=3, + consumed_bytes=3 + len(payload) + len(_transport_trailing), + transport_trailing_bytes=_transport_trailing, + ) + + def write(self, command: str) -> None: + self.events.append(command) + + def close(self) -> None: + self.closed += 1 + + +class _Driver: + def __init__(self, transport: GuardedAuditedTransport) -> None: + self.transport = transport + self.events: list[str] = [] + self.configuration = ScopeAverageConfigurationV2( + "global_acquisition", + "normal", + 1, + ) + self.complete = True + self.fail_after_binary = False + self.skip_binary = False + self.acquisition_completion: ScopeAcquisitionCompletion | None = None + self.restore_calls = 0 + self.verify_calls = 0 + self.drain_calls = 0 + self.legacy_average_calls = 0 + self.input_termination = "high_z" + + def close(self) -> None: + pass + + def idn(self) -> str: + self.events.append("idn") + return self.transport.query("*IDN?", replay=ReplayPolicy.NO_REPLAY) + + def get_channel_input_state_v2(self, channel: int) -> ScopeChannelInputStateV2: + self.events.append("input") + self.transport.query("INPUT?", replay=ReplayPolicy.NO_REPLAY) + return ScopeChannelInputStateV2( + channel, + "dc", + self.input_termination, + unavailable_fields=("impedance_ohm",), + ) + + def get_acquisition_status_v2(self, *, fields): + raise AssertionError("average capture V2 must not call acquisition-status V2") + + def get_acquisition_run_state(self) -> ScopeAcquisitionRunState: + self.events.append("stopped-recheck") + self.transport.query("RUN?", replay=ReplayPolicy.NO_REPLAY) + return _stopped_state() + + def start_continuous(self, **_kwargs): + raise AssertionError("average capture V2 must not start continuous acquisition") + + def stop_acquisition(self): + raise AssertionError("average capture V2 must not send STOP") + + def acquire_single(self, **_kwargs): + raise AssertionError("average capture V2 must not use the public single Service path") + + def snapshot_acquisition_control(self): + raise AssertionError("average capture V2 owns its child baseline") + + def restore_acquisition_control(self, _baseline): + raise AssertionError("average capture V2 must restore its parent baseline once") + + def verify_acquisition_control_restored(self, _baseline): + raise AssertionError("average capture V2 must verify its parent baseline once") + + def snapshot_average_capture_state(self, fields): + self.events.append("snapshot") + self.transport.query("SNAP?", replay=ReplayPolicy.NO_REPLAY) + assert tuple(fields) == SCOPE_AVERAGE_CAPTURE_FIELD_ORDER + return _snapshot(configuration=self.configuration) + + def set_average_acquisition_type_v2(self, acquisition_type: str, *, baseline) -> None: + self.events.append("set-type") + self.transport.write(f"TYPE {acquisition_type}") + self.configuration = ScopeAverageConfigurationV2( + "global_acquisition", + acquisition_type, + self.configuration.average_count, + ) + + def get_average_configuration_v2(self, *, baseline) -> ScopeAverageConfigurationV2: + self.events.append("read-config") + self.transport.query("CONFIG?", replay=ReplayPolicy.NO_REPLAY) + return self.configuration + + def set_average_count_v2(self, average_count: int, *, baseline) -> None: + self.events.append("set-count") + self.transport.write(f"COUNT {average_count}") + self.configuration = ScopeAverageConfigurationV2( + "global_acquisition", + self.configuration.acquisition_type, + average_count, + ) + + def acquire_average_single_v2(self, *, baseline, deadline: float) -> ScopeAcquisitionCompletion: + self.events.append("single") + self.transport.write("SINGLE") + return self.acquisition_completion or _completion() + + def get_device_average_complete_v2(self, *, baseline) -> bool: + self.events.append("complete") + self.transport.query("AVERAGE:COMPLETE?", replay=ReplayPolicy.NO_REPLAY) + return self.complete + + def fetch_average_waveform_bounded(self, channel: int, *, points: str, baseline) -> WaveformData: + self.events.append("fetch") + assert points == "DMAX" + if not self.skip_binary: + self.transport.query_binary( + "DATA?", + framing=BinaryResponseFraming.DEFINITE_BLOCK, + max_bytes=2, + replay=ReplayPolicy.NO_REPLAY, + ) + if self.fail_after_binary: + raise DataError("conversion failed") + return _waveform() + + def restore_average_capture_state(self, baseline) -> ScopeAverageCaptureRestoreResult: + self.events.append("restore") + self.restore_calls += 1 + self.transport.write("RESTORE") + self.configuration = baseline.snapshot.configuration + return ScopeAverageCaptureRestoreResult( + "completed", + baseline.restore_order, + baseline.restore_order, + ) + + def verify_average_capture_state_restored(self, baseline) -> ScopeAverageCaptureStateSnapshot: + self.events.append("verify") + self.verify_calls += 1 + self.transport.query("VERIFY?", replay=ReplayPolicy.NO_REPLAY) + return baseline.snapshot + + def drain_errors(self, *, max_records: int) -> ErrorDrainResult: + self.events.append("drain") + self.drain_calls += 1 + self.transport.query("ERR?", replay=ReplayPolicy.NO_REPLAY) + return ErrorDrainResult(records=(), terminated=True, query_count=1) + + def capture_average(self, *_args, **_kwargs): + self.legacy_average_calls += 1 + raise AssertionError("average capture V2 must not use legacy capture_average") + + +def _acquisition_profile( + *, + single_arm_semantics: str = "configure_then_arm", + single_mode_readback_allows_terminal_stop: bool = False, +) -> ScopeAcquisitionControlProfile: + return ScopeAcquisitionControlProfile( + supported_continuous_modes=("normal",), + single_arm_semantics=single_arm_semantics, # type: ignore[arg-type] + arm_resets_acquisition_count=False, + failure_restore_order=("scope.trigger", "scope.acquisition"), + snapshot_max_steps=3, + restore_max_steps=3, + verify_max_steps=3, + identity_semantics="unique_within_session_epoch", + single_mode_readback_allows_terminal_stop=single_mode_readback_allows_terminal_stop, + ) + + +def _descriptor( + *, + error_drain: bool = True, + acquisition_profile: ScopeAcquisitionControlProfile | None = None, +) -> InstrumentDescriptor: + capabilities = ( + "scope.idn", + "scope.capture_average_v2", + "scope.acquisition_status_v2", + "scope.acquisition_run_state", + "scope.acquisition_control", + "scope.channel_input_state_v2", + ) + (("scope.error_drain_v1",) if error_drain else ()) + return InstrumentDescriptor( + driver_id="example.average-capture-v2", + kind="scope", + display_name="Example average scope", + manufacturer="Example", + models=("AVG-1",), + aliases=(), + capabilities=capabilities, + idn_patterns=("EXAMPLE,AVG-1",), + backends=("pyvisa",), + option_specs=(), + permissions=("instrument.io",), + factory=lambda _context: object(), + wavebench_min_version="0.8.24", + scope_extensions=ScopeDescriptorExtensions( + acquisition_control_profile=acquisition_profile or _acquisition_profile(), + acquisition_status_profile_v2=ScopeAcquisitionStatusProfileV2( + readable_fields=("acquisition_type",), + max_queries=1, + ), + average_capture_profile_v2=_profile(), + ), + ) + + +def _executor( + backend: _Backend, + driver: _Driver, + *, + descriptor: InstrumentDescriptor | None = None, +) -> ScopeAverageCaptureExecutor: + transport = driver.transport + return ScopeAverageCaptureExecutor( + driver=driver, + descriptor=descriptor or _descriptor(), + session_state=transport.session_state, + connection_timeout_ms=10_000, + transport=transport, + ) + + +def _bounded_transport(backend: _Backend) -> GuardedAuditedTransport: + state = InstrumentSessionState(epoch_id="epoch-1") + transport = GuardedAuditedTransport(backend, session_state=state) + transport._mark_bounded_binary_backend_verified() + return transport + + +def _request(*, allow_50ohm: bool = False) -> ScopeAverageCaptureRequestV2: + return ScopeAverageCaptureRequestV2( + channels=(1,), + average_count=4, + mechanism="global_acquisition", + acquisition_stopped=True, + allow_50ohm=allow_50ohm, + ) + + +def test_average_capture_v2_executes_one_core_owned_single_channel_transaction() -> None: + backend = _Backend() + driver = _Driver(_bounded_transport(backend)) + + result = _executor(backend, driver).execute(_request(), check_errors=True) + + assert result.value.waveforms[0].channel == 1 + assert np.array_equal(result.value.waveforms[0].voltages_v, np.array([0.0, 1.0])) + assert driver.events == [ + "idn", + "input", + "snapshot", + "drain", + "set-type", + "read-config", + "set-count", + "read-config", + "stopped-recheck", + "single", + "complete", + "fetch", + "drain", + "restore", + "verify", + ] + assert driver.restore_calls == 1 + assert driver.verify_calls == 1 + assert driver.drain_calls == 2 + assert driver.legacy_average_calls == 0 + assert driver.transport.session_state.health is SessionHealth.HEALTHY + + +def test_average_capture_v2_completion_failure_restores_without_binary_fetch() -> None: + backend = _Backend() + driver = _Driver(_bounded_transport(backend)) + driver.complete = False + + with pytest.raises(DataError, match="completion_unproven"): + _executor(backend, driver).execute(_request(), check_errors=False) + + assert driver.restore_calls == 1 + assert driver.verify_calls == 1 + assert "fetch" not in driver.events + assert driver.drain_calls == 0 + + +def test_average_capture_v2_rejects_terminal_stop_proof_before_complete_or_fetch() -> None: + backend = _Backend() + driver = _Driver(_bounded_transport(backend)) + driver.acquisition_completion = _terminal_stop_completion() + descriptor = _descriptor( + acquisition_profile=_acquisition_profile( + single_arm_semantics="atomic_configure_and_arm", + single_mode_readback_allows_terminal_stop=True, + ) + ) + + with pytest.raises(DataError, match="does not accept a terminal STOP"): + _executor(backend, driver, descriptor=descriptor).execute(_request(), check_errors=False) + + assert driver.restore_calls == 1 + assert driver.verify_calls == 1 + assert "complete" not in driver.events + assert "fetch" not in driver.events + + +def test_average_capture_v2_data_failure_after_proven_boundary_restores() -> None: + backend = _Backend() + driver = _Driver(_bounded_transport(backend)) + driver.fail_after_binary = True + + with pytest.raises(DataError, match="conversion failed"): + _executor(backend, driver).execute(_request(), check_errors=False) + + assert driver.restore_calls == 1 + assert driver.verify_calls == 1 + assert driver.transport.session_state.health is SessionHealth.HEALTHY + + +def test_average_capture_v2_lost_binary_sync_poisoned_session_skips_cleanup() -> None: + backend = _Backend() + backend.fail_binary_sync = True + driver = _Driver(_bounded_transport(backend)) + + with pytest.raises(TransportIOError): + _executor(backend, driver).execute(_request(), check_errors=False) + + assert driver.restore_calls == 0 + assert driver.verify_calls == 0 + assert driver.transport.session_state.health is SessionHealth.POISONED + + +def test_average_capture_v2_input_safety_requires_explicit_50_ohm_authorization() -> None: + backend = _Backend() + driver = _Driver(_bounded_transport(backend)) + driver.input_termination = "50_ohm" + + with pytest.raises(ConfigError, match="allow_50ohm"): + _executor(backend, driver).execute(_request(), check_errors=False) + assert driver.events == ["idn", "input"] + + backend = _Backend() + driver = _Driver(_bounded_transport(backend)) + driver.input_termination = "50_ohm" + _executor(backend, driver).execute(_request(allow_50ohm=True), check_errors=False) + assert driver.restore_calls == 1 + + +def _service(driver: _Driver, *, check_errors: bool = False) -> ScopeService: + return ScopeService( + config=type( + "Config", + (), + { + "scope": type( + "Scope", + (), + { + "driver": "example.average-capture-v2", + "access": "read_write", + "check_errors": check_errors, + }, + )(), + "connection": type("Connection", (), {"timeout_ms": 10_000})(), + }, + )(), + logger=CommandLogger(), + session=driver, + descriptor=_descriptor(error_drain=check_errors), + transport=driver.transport, + session_state=driver.transport.session_state, + ) + + +def test_average_capture_v2_service_uses_only_the_v2_executor_route() -> None: + backend = _Backend() + driver = _Driver(_bounded_transport(backend)) + + result = _service(driver).capture_average_v2(_request()) + + assert result.waveforms[0].channel == 1 + assert driver.legacy_average_calls == 0 + + +def test_average_capture_v2_service_requires_typed_error_drain_before_opening_session() -> None: + backend = _Backend() + driver = _Driver(_bounded_transport(backend)) + service = _service(driver, check_errors=True) + service.descriptor = _descriptor(error_drain=False) + service._open_scope = lambda: pytest.fail("missing error drain must fail before opening scope") + + with pytest.raises(ConfigError, match="scope.error_drain_v1"): + service.capture_average_v2(_request()) + assert driver.events == [] + + +def _open_factory_descriptor() -> object: + return open_instrument_driver( + driver_reference="example.average-capture-v2", + expected_kind="scope", + resource="TCPIP::example::INSTR", + configured_backend="pyvisa", + timeout_ms=1_000, + opc_timeout_ms=2_000, + read_retry_attempts=1, + read_retry_delay_ms=0, + logger=CommandLogger(), + ) + + +def test_average_capture_v2_factory_latch_blocks_construction_io( + monkeypatch: pytest.MonkeyPatch, +) -> None: + backend = _Backend() + errors: list[TransportIOError] = [] + + def factory(context): + transport = context.open_transport() + with pytest.raises(TransportIOError) as raised: + transport.query("*IDN?") + errors.append(raised.value) + return _Driver(transport) + + descriptor = replace(_descriptor(), factory=factory) + monkeypatch.setattr( + "wavebench.instruments.factory.resolve_instrument_descriptor", + lambda _reference, expected_kind: descriptor, + ) + monkeypatch.setattr( + "wavebench.instruments.factory._open_transport", + lambda **_kwargs: backend, + ) + monkeypatch.setattr( + "wavebench.instruments.factory._validate_bounded_binary_transport", + lambda **_kwargs: None, + ) + + opened = _open_factory_descriptor() + + assert [error.reason_code for error in errors] == ["factory_construction_pending"] + assert errors[0].attempts == 0 + assert backend.events == [] + assert opened.transport is not None + assert opened.transport._has_verified_bounded_binary_backend() + + +def test_average_capture_v2_factory_rejects_missing_method_without_io( + monkeypatch: pytest.MonkeyPatch, +) -> None: + backend = _Backend() + + class MissingFetch(_Driver): + fetch_average_waveform_bounded = None + + descriptor = replace( + _descriptor(), + factory=lambda context: MissingFetch(context.open_transport()), + ) + monkeypatch.setattr( + "wavebench.instruments.factory.resolve_instrument_descriptor", + lambda _reference, expected_kind: descriptor, + ) + monkeypatch.setattr( + "wavebench.instruments.factory._open_transport", + lambda **_kwargs: backend, + ) + + with pytest.raises(ConfigError, match="fetch_average_waveform_bounded"): + _open_factory_descriptor() + + assert backend.events == [] + assert backend.closed == 1 diff --git a/tests/test_scope_binary_contract.py b/tests/test_scope_binary_contract.py index f036fb4..a352e88 100644 --- a/tests/test_scope_binary_contract.py +++ b/tests/test_scope_binary_contract.py @@ -295,6 +295,41 @@ def test_guarded_binary_query_requires_context_budget_and_debits_it() -> None: context.complete() +@pytest.mark.parametrize( + ("replay", "reason_code"), + ( + (ReplayPolicy.SAFE_TO_REPLAY, "binary_replay_unsupported"), + (ReplayPolicy.READ_CONTINUATION_ONLY, "binary_continuation_unsupported"), + ), +) +def test_guarded_bounded_binary_rejects_replay_before_backend_send( + replay: ReplayPolicy, + reason_code: str, +) -> None: + backend = _BinaryBackend() + guarded = GuardedAuditedTransport(backend) # type: ignore[arg-type] + context = _binary_context(guarded) + phase = context.make_phase_spec( + OperationPhase.MAIN, + allowed_io={"query_binary"}, + fields={"scope.waveform_transfer_window"}, + max_steps=1, + ) + + with context.authorize_phase(phase): + with pytest.raises(TransportIOError) as raised: + guarded.query_binary( + "DATA?", + framing=BinaryResponseFraming.DEFINITE_BLOCK, + max_bytes=4, + replay=replay, + ) + + assert raised.value.reason_code == reason_code + assert raised.value.attempts == 0 + assert backend.calls == 0 + + def test_guarded_binary_contract_violation_poison_closes_backend() -> None: backend = _BinaryBackend(wrong_result=True) guarded = GuardedAuditedTransport(backend) # type: ignore[arg-type] @@ -440,6 +475,26 @@ def test_real_backends_stream_bounded_definite_blocks_and_restore_settings(backe assert rs_session.write_str_calls == 0 +@pytest.mark.parametrize("backend", ["pyvisa", "rsinstrument"]) +@pytest.mark.parametrize("replay", [ReplayPolicy.NO_REPLAY, ReplayPolicy.SAFE_TO_REPLAY]) +def test_visa_binary_query_is_always_one_send(backend: str, replay: ReplayPolicy) -> None: + raw = _FakeVisaSession(b"#14data") + if backend == "pyvisa": + transport = PyVisaTransport("fake", object(), raw, CommandLogger()) + else: + transport = RsInstrumentTransport("fake", _FakeRsSession(raw), CommandLogger()) + + result = transport.query_binary( + "DATA?", + framing=BinaryResponseFraming.DEFINITE_BLOCK, + max_bytes=4, + replay=replay, + ) + + assert result.data == b"data" + assert raw.commands == ["DATA?"] + + @pytest.mark.parametrize("backend", ["pyvisa", "rsinstrument"]) def test_visa_message_framing_requires_and_uses_proven_eom(backend: str) -> None: raw = _FakeVisaSession(b"png") diff --git a/tests/test_scope_cursor_readout_v2.py b/tests/test_scope_cursor_readout_v2.py new file mode 100644 index 0000000..7665adf --- /dev/null +++ b/tests/test_scope_cursor_readout_v2.py @@ -0,0 +1,837 @@ +from __future__ import annotations + +from dataclasses import asdict, replace +from types import SimpleNamespace + +import pytest + +from wavebench.errors import ConfigError, DataError, SessionHealthError, TransportIOError +from wavebench.instruments import ( + InstrumentDescriptor, + SCOPE_CURSOR_READOUT_V2_FIELD_ORDER, + SCOPE_CURSOR_READOUT_V2_MAX_QUERIES, + ScopeCursorQuantity, + ScopeCursorReadoutDriverV2, + ScopeCursorReadoutV2, +) +from wavebench.instruments.capabilities import CAPABILITY_METHODS, validate_declared_capabilities +from wavebench.instruments.contracts import ScopeAnalysisReadDriver +from wavebench.instruments.factory import open_instrument_driver +from wavebench.instruments.models import ScopeCursorReadout +from wavebench.instruments.scope_extension_capabilities import ( + SCOPE_CAPABILITY_METHODS, + SCOPE_STRICT_V2_CAPABILITIES, + validate_scope_descriptor, +) +from wavebench.instruments.scope_extensions import ( + SCOPE_CURSOR_READOUT_V2_MAX_QUERIES as SCOPE_CURSOR_READOUT_V2_MAX_QUERIES_FROM_PROFILE, + ScopeCursorReadoutProfileV2, + ScopeDescriptorExtensions, +) +from wavebench.logging import CommandLogger +from wavebench.services.operation_specs import ( + SCOPE_OPERATION_SPECS, + SCOPE_PORTABILITY_V2_OPERATION_SPECS, + require_operation_spec, +) +from wavebench.services.scope_service import ScopeService +from wavebench.transport.contracts import ReplayPolicy +from wavebench.transport.guarded import GuardedAuditedTransport + + +_READABLE_FIELDS = ("source_a", "source_b", "x_delta") +_UNAVAILABLE_FIELDS = ( + "x_a", + "x_b", + "inverse_x_delta", + "y_a", + "y_b", + "y_delta", +) + + +def _profile( + *, + readable_fields: tuple[str, ...] = _READABLE_FIELDS, + conditionally_applicable_fields: tuple[str, ...] = (), + addressing: str = "global", + max_queries: int = 1, +) -> ScopeCursorReadoutProfileV2: + return ScopeCursorReadoutProfileV2( + readable_fields=readable_fields, # type: ignore[arg-type] + conditionally_applicable_fields=conditionally_applicable_fields, # type: ignore[arg-type] + addressing=addressing, # type: ignore[arg-type] + max_queries=max_queries, + ) + + +def _quantity(value: float = 0.001, unit: str = "s") -> ScopeCursorQuantity: + return ScopeCursorQuantity(value=value, unit=unit) # type: ignore[arg-type] + + +def _global_result() -> ScopeCursorReadoutV2: + return ScopeCursorReadoutV2( + cursor_index=None, + mode="MAN", + function="TIME", + source_a="CH1", + source_b="CH2", + x_delta=_quantity(), + unavailable_fields=_UNAVAILABLE_FIELDS, + not_applicable_fields=("cursor_index",), + ) + + +def _indexed_result(cursor_index: int = 1) -> ScopeCursorReadoutV2: + return ScopeCursorReadoutV2( + cursor_index=cursor_index, + mode="MAN", + function="TIME", + source_a="CH1", + source_b="CH1", + x_delta=_quantity(), + unavailable_fields=_UNAVAILABLE_FIELDS, + ) + + +def _conditional_global_result() -> ScopeCursorReadoutV2: + return ScopeCursorReadoutV2( + cursor_index=None, + mode="MEAS", + function="VMAX", + source_a=None, + source_b=None, + x_delta=_quantity(), + unavailable_fields=_UNAVAILABLE_FIELDS, + not_applicable_fields=("cursor_index", "source_a", "source_b"), + ) + + +def _expanded_global_result() -> ScopeCursorReadoutV2: + return ScopeCursorReadoutV2( + cursor_index=None, + mode="MAN", + function="TIME", + source_a="CH1", + source_b="CH2", + x_delta=_quantity(), + y_delta=_quantity(2.0, "percent"), + unavailable_fields=( + "x_a", + "x_b", + "inverse_x_delta", + "y_a", + "y_b", + ), + not_applicable_fields=("cursor_index",), + ) + + +def _descriptor( + *, + profile: ScopeCursorReadoutProfileV2 | None = None, + minimum: str = "0.8.24", + capabilities: tuple[str, ...] = (), + extensions: bool = True, +) -> InstrumentDescriptor: + return InstrumentDescriptor( + driver_id="example.cursor-readout-v2", + kind="scope", + display_name="Example scope", + manufacturer="Example", + models=("EX1",), + aliases=(), + capabilities=capabilities or ("scope.cursor_readout_v2",), + idn_patterns=("EXAMPLE,EX1",), + backends=("pyvisa",), + option_specs=(), + permissions=("instrument.io",), + factory=lambda _context: object(), + wavebench_min_version=minimum, + scope_extensions=( + ScopeDescriptorExtensions(cursor_readout_profile_v2=profile or _profile()) + if extensions + else None + ), + ) + + +def test_cursor_readout_v2_models_serialize_global_and_indexed_readouts() -> None: + global_result = _global_result() + indexed_result = _indexed_result(2) + source_quantity = ScopeCursorQuantity(2.0, "source", source_unit="V") + + assert asdict(global_result)["x_delta"] == { + "value": 0.001, + "unit": "s", + "source_unit": None, + } + assert global_result.not_applicable_fields == ("cursor_index",) + assert indexed_result.cursor_index == 2 + assert indexed_result.unavailable_fields == _UNAVAILABLE_FIELDS + assert source_quantity.source_unit == "V" + assert SCOPE_CURSOR_READOUT_V2_FIELD_ORDER == ( + "cursor_index", + "source_a", + "source_b", + "x_a", + "x_b", + "x_delta", + "inverse_x_delta", + "y_a", + "y_b", + "y_delta", + ) + + +@pytest.mark.parametrize( + ("unit", "source_unit"), + ( + ("s", None), + ("Hz", None), + ("degree", None), + ("percent", None), + ("source", "V"), + ), +) +def test_cursor_quantity_accepts_every_contract_unit( + unit: str, + source_unit: str | None, +) -> None: + quantity = ScopeCursorQuantity(1.0, unit, source_unit=source_unit) # type: ignore[arg-type] + + assert quantity.unit == unit + assert quantity.source_unit == source_unit + + +@pytest.mark.parametrize( + ("factory", "error_type", "message"), + ( + ( + lambda: ScopeCursorQuantity(float("nan"), "s"), + ValueError, + "finite", + ), + ( + lambda: ScopeCursorQuantity(1.0, "unknown"), + ValueError, + "unsupported", + ), + ( + lambda: ScopeCursorQuantity(1.0, "s", source_unit="V"), + ValueError, + "requires", + ), + ( + lambda: ScopeCursorQuantity(1.0, "source", source_unit="not safe"), + ValueError, + "visible unit", + ), + ( + lambda: ScopeCursorQuantity( + 1.0, + "source", + source_unit="TCPIP::host::INSTR", + ), + ValueError, + "visible unit", + ), + ( + lambda: ScopeCursorReadoutV2( + True, + "MAN", + "TIME", + "CH1", + "CH1", + ), + ValueError, + "positive integer", + ), + ( + lambda: ScopeCursorReadoutV2( + None, + "MAN", + "TIME", + "CH1", + None, + ), + ValueError, + "together", + ), + ( + lambda: ScopeCursorReadoutV2( + None, + "MAN", + "TIME", + "CH1", + "CH1", + x_delta=object(), # type: ignore[arg-type] + ), + TypeError, + "ScopeCursorQuantity", + ), + ( + lambda: ScopeCursorReadoutV2( + None, + "MAN", + "TIME", + "CH1", + "CH1", + x_delta=_quantity(), + unavailable_fields=_UNAVAILABLE_FIELDS, + not_applicable_fields=("cursor_index", "x_delta"), + ), + ValueError, + "exactly describe", + ), + ( + lambda: ScopeCursorReadoutV2( + None, + "MAN", + "TIME", + None, + None, + x_delta=_quantity(), + unavailable_fields=( + "source_a", + "x_a", + "x_b", + "inverse_x_delta", + "y_a", + "y_b", + "y_delta", + ), + not_applicable_fields=("cursor_index", "source_b"), + ), + ValueError, + "same availability", + ), + ( + lambda: ScopeCursorReadoutV2( + None, + "MAN", + "TIME", + "CH1", + "CH1", + x_delta=_quantity(), + unavailable_fields=tuple(reversed(_UNAVAILABLE_FIELDS)), + not_applicable_fields=("cursor_index",), + ), + ValueError, + "stable field order", + ), + ), +) +def test_cursor_readout_v2_models_reject_invalid_or_ambiguous_values( + factory, + error_type: type[Exception], + message: str, +) -> None: + with pytest.raises(error_type, match=message): + factory() + + +@pytest.mark.parametrize( + ("kwargs", "message"), + ( + ({"readable_fields": ()}, "must not be empty"), + ({"readable_fields": ("source_a", "x_delta")}, "together"), + ({"readable_fields": ("source_a", "source_b")}, "include a quantity"), + ({"readable_fields": ("x_delta", "source_a", "source_b")}, "stable field order"), + ({"conditionally_applicable_fields": ("y_delta",)}, "must be readable"), + ({"readable_fields": ("cursor_index", "source_a", "source_b", "x_delta")}, "global"), + ({"addressing": "indexed"}, "requires a non-conditional cursor_index"), + ( + { + "addressing": "indexed", + "readable_fields": ("cursor_index", "source_a", "source_b", "x_delta"), + "conditionally_applicable_fields": ("cursor_index",), + }, + "non-conditional", + ), + ({"max_queries": True}, "integer"), + ({"max_queries": 33}, "1..32"), + ), +) +def test_cursor_readout_v2_profile_rejects_invalid_static_contract( + kwargs: dict[str, object], + message: str, +) -> None: + values: dict[str, object] = { + "readable_fields": _READABLE_FIELDS, + "conditionally_applicable_fields": (), + "addressing": "global", + "max_queries": 1, + } + values.update(kwargs) + with pytest.raises((TypeError, ValueError), match=message): + ScopeCursorReadoutProfileV2(**values) # type: ignore[arg-type] + + +def test_cursor_readout_v2_profile_enforces_global_index_and_conditional_paths() -> None: + global_profile = _profile() + global_profile.validate_request(cursor_index=None, configured_cursor=True) + global_profile.validate_result(_global_result(), cursor_index=None) + with pytest.raises(ValueError, match="global cursor addressing"): + global_profile.validate_request(cursor_index=1, configured_cursor=True) + with pytest.raises(ValueError, match="global cursor readout"): + global_profile.validate_result(_indexed_result(), cursor_index=None) + + conditional_profile = _profile( + conditionally_applicable_fields=("source_a", "source_b"), + ) + conditional_profile.validate_result(_conditional_global_result(), cursor_index=None) + with pytest.raises(ValueError, match="outside the descriptor profile"): + conditional_profile.validate_result(_expanded_global_result(), cursor_index=None) + + indexed_profile = _profile( + readable_fields=("cursor_index", "source_a", "source_b", "x_delta"), + addressing="indexed", + ) + indexed_profile.validate_request(cursor_index=1, configured_cursor=True) + indexed_profile.validate_result(_indexed_result(), cursor_index=1) + with pytest.raises(ValueError, match="wrong cursor_index"): + indexed_profile.validate_result(_indexed_result(2), cursor_index=1) + + +def test_cursor_readout_v2_is_additive_and_registered_without_legacy_membership() -> None: + class Driver: + def idn(self) -> str: + return "EXAMPLE,EX1,SN-1,1.0" + + def close(self) -> None: + pass + + def get_cursor_readout_v2( + self, + cursor_index: int | None, + *, + configured_cursor: bool, + ) -> ScopeCursorReadoutV2: + assert cursor_index is None + assert configured_cursor is True + return _global_result() + + assert isinstance(Driver(), ScopeCursorReadoutDriverV2) + assert "get_cursor_readout_v2" not in ScopeAnalysisReadDriver.__dict__ + assert SCOPE_CAPABILITY_METHODS["scope.cursor_readout_v2"] == ( + "get_cursor_readout_v2", + ) + assert CAPABILITY_METHODS["scope.cursor_readout_v2"] == ("get_cursor_readout_v2",) + assert "scope.cursor_readout_v2" in SCOPE_STRICT_V2_CAPABILITIES + assert "scope.cursor_readout_v2" not in SCOPE_OPERATION_SPECS + spec = SCOPE_PORTABILITY_V2_OPERATION_SPECS["scope.cursor_readout_v2"] + assert require_operation_spec("scope.cursor_readout_v2") is spec + assert spec.effect == "stateful_read" + assert spec.lease_mode == "exclusive" + assert spec.required_verified_fields == () + assert spec.error_check_minimum == "disabled" + assert SCOPE_CURSOR_READOUT_V2_MAX_QUERIES == 32 + assert SCOPE_CURSOR_READOUT_V2_MAX_QUERIES_FROM_PROFILE == 32 + + +def test_cursor_readout_v2_descriptor_requires_profile_core_floor_and_callable_method() -> None: + with pytest.raises(ConfigError, match="scope portability V2 capabilities require.*0.8.24"): + validate_scope_descriptor(_descriptor(minimum="0.8.23")) + with pytest.raises(ConfigError, match="cursor_readout_profile_v2"): + validate_scope_descriptor(_descriptor(extensions=False)) + + class MissingMethod: + def close(self) -> None: + pass + + with pytest.raises(TypeError, match="get_cursor_readout_v2"): + validate_declared_capabilities(_descriptor(), MissingMethod()) + + +def test_cursor_readout_v2_method_does_not_create_an_implicit_capability() -> None: + descriptor = _descriptor(capabilities=("scope.idn",), extensions=False) + + class Driver: + def idn(self) -> str: + return "EXAMPLE,EX1,SN-1,1.0" + + def close(self) -> None: + pass + + def get_cursor_readout_v2( + self, + cursor_index: int | None, + *, + configured_cursor: bool, + ) -> ScopeCursorReadoutV2: + raise AssertionError( + "undeclared cursor V2 must not be called " + f"for {cursor_index}/{configured_cursor}" + ) + + validate_declared_capabilities(descriptor, Driver()) + assert "scope.cursor_readout_v2" not in descriptor.capabilities + + +def _service( + *, + profile: ScopeCursorReadoutProfileV2, + driver: object, + transport: GuardedAuditedTransport | None = None, +) -> ScopeService: + return ScopeService( + config=SimpleNamespace( + scope=SimpleNamespace(driver="example.cursor-readout-v2"), + connection=SimpleNamespace(timeout_ms=1_000), + ), + logger=SimpleNamespace(), + session=driver, + descriptor=SimpleNamespace( + driver_id="example.cursor-readout-v2", + capabilities=("scope.cursor_readout_v2",), + scope_extensions=ScopeDescriptorExtensions(cursor_readout_profile_v2=profile), + ), + transport=transport, + session_state=None if transport is None else transport.session_state, + ) + + +def test_cursor_readout_v2_service_only_calls_v2_and_keeps_legacy_route_separate() -> None: + calls: list[tuple[str, int | None]] = [] + driver = SimpleNamespace( + get_cursor_readout_v2=lambda cursor_index, *, configured_cursor: calls.append( + ("v2", cursor_index) + ) + or _global_result(), + get_cursor_readout=lambda *_args, **_kwargs: (_ for _ in ()).throw( + AssertionError("legacy cursor must stay separate") + ), + ) + service = _service(profile=_profile(), driver=driver) + + assert service.cursor_readout_v2(None, configured_cursor=True) == _global_result() + assert calls == [("v2", None)] + + legacy = ScopeCursorReadout(cursor_index=1, source="CH1", function="TIME") + driver.get_cursor_readout = ( + lambda index, *, configured_cursor: calls.append(("legacy", index)) or legacy + ) + service.descriptor = SimpleNamespace( + driver_id="example.cursor-readout-v2", + capabilities=("scope.cursor_readout", "scope.cursor_readout_v2"), + scope_extensions=ScopeDescriptorExtensions(cursor_readout_profile_v2=_profile()), + ) + assert service.cursor_readout(1, configured_cursor=True) is legacy + assert calls == [("v2", None), ("legacy", 1)] + + +@pytest.mark.parametrize( + ("cursor_index", "configured_cursor", "message"), + ( + (True, True, "positive integer"), + (0, True, "positive integer"), + ("1", True, "positive integer"), + (None, False, "configured_cursor=True"), + (None, 1, "configured_cursor=True"), + (1, True, "global cursor addressing"), + ), +) +def test_cursor_readout_v2_service_rejects_invalid_preconditions_before_opening_scope( + cursor_index: object, + configured_cursor: object, + message: str, +) -> None: + service = _service( + profile=_profile(), + driver=SimpleNamespace(get_cursor_readout_v2=lambda *_args, **_kwargs: _global_result()), + ) + service._open_scope = lambda: pytest.fail("invalid cursor V2 request must not open scope") + + with pytest.raises(ConfigError, match=message): + service.cursor_readout_v2( # type: ignore[arg-type] + cursor_index, + configured_cursor=configured_cursor, + ) + + +def test_cursor_readout_v2_service_rejects_invalid_result_without_fallback() -> None: + service = _service( + profile=_profile(), + driver=SimpleNamespace(get_cursor_readout_v2=lambda *_args, **_kwargs: object()), + ) + + with pytest.raises(DataError, match="invalid result"): + service.cursor_readout_v2(None, configured_cursor=True) + + +def test_cursor_readout_v2_second_fixture_exercises_indexed_hz_and_source_units() -> None: + profile = _profile( + readable_fields=( + "cursor_index", + "source_a", + "source_b", + "x_delta", + "y_delta", + ), + addressing="indexed", + ) + result = ScopeCursorReadoutV2( + cursor_index=2, + mode="TRAC", + function="TIME", + source_a="CH1", + source_b="MATH1", + x_delta=ScopeCursorQuantity(1_000.0, "Hz"), + y_delta=ScopeCursorQuantity(2.0, "source", source_unit="V"), + unavailable_fields=("x_a", "x_b", "inverse_x_delta", "y_a", "y_b"), + ) + + service = _service( + profile=profile, + driver=SimpleNamespace( + get_cursor_readout_v2=lambda cursor_index, *, configured_cursor: result + ), + ) + + assert service.cursor_readout_v2(2, configured_cursor=True) is result + + +class _InnerTransport: + resource = "TCPIP::example::INSTR" + + def __init__(self) -> None: + self.queries: list[str] = [] + self.writes: list[str] = [] + + def record_event(self, _direction: str, _text: str) -> None: + pass + + def query(self, command: str, *, replay: ReplayPolicy = ReplayPolicy.NO_REPLAY) -> str: + assert replay is ReplayPolicy.NO_REPLAY + self.queries.append(command) + return "MAN" + + def write(self, command: str) -> None: + self.writes.append(command) + + def close(self) -> None: + pass + + +def test_cursor_readout_v2_uses_only_budgeted_text_queries_without_legacy_preflight() -> None: + inner = _InnerTransport() + transport = GuardedAuditedTransport(inner) + + class Driver: + def idn(self) -> str: + raise AssertionError("cursor V2 must not call legacy identity preflight") + + def get_cursor_readout(self, *_args, **_kwargs) -> ScopeCursorReadout: + raise AssertionError("cursor V2 must not call legacy cursor") + + def get_cursor_readout_v2( + self, + cursor_index: int | None, + *, + configured_cursor: bool, + ) -> ScopeCursorReadoutV2: + assert cursor_index is None + assert configured_cursor is True + transport.query(":CURS:MODE?") + return _global_result() + + service = _service(profile=_profile(max_queries=1), driver=Driver(), transport=transport) + + assert service.cursor_readout_v2(None, configured_cursor=True) == _global_result() + assert inner.queries == [":CURS:MODE?"] + assert inner.writes == [] + assert transport.counters.query_calls == 1 + + +@pytest.mark.parametrize("mode", ("overrun", "write")) +def test_cursor_readout_v2_rejects_overrun_and_non_query_io_before_backend_send( + mode: str, +) -> None: + inner = _InnerTransport() + transport = GuardedAuditedTransport(inner) + + class Driver: + def get_cursor_readout_v2( + self, + cursor_index: int | None, + *, + configured_cursor: bool, + ) -> ScopeCursorReadoutV2: + assert cursor_index is None + assert configured_cursor is True + transport.query(":CURS:MODE?") + if mode == "overrun": + transport.query(":CURS:FUNC?") + else: + transport.write(":CURS:BAD") + return _global_result() + + service = _service(profile=_profile(max_queries=1), driver=Driver(), transport=transport) + + with pytest.raises(SessionHealthError): + service.cursor_readout_v2(None, configured_cursor=True) + assert inner.queries == [":CURS:MODE?"] + assert inner.writes == [] + + +def test_cursor_readout_v2_rejects_guarded_transport_without_shared_session_state() -> None: + inner = _InnerTransport() + transport = GuardedAuditedTransport(inner) + service = _service( + profile=_profile(), + transport=transport, + driver=SimpleNamespace( + get_cursor_readout_v2=lambda *_args, **_kwargs: transport.write(":CURS:BAD") + ), + ) + object.__setattr__(service, "session_state", None) + + with pytest.raises(ConfigError, match="shared instrument session state"): + service.cursor_readout_v2(None, configured_cursor=True) + assert inner.queries == [] + assert inner.writes == [] + + +class _FactoryTransport: + resource = "TCPIP::example::INSTR" + + def __init__(self) -> None: + self.queries: list[str] = [] + self.closed = 0 + + def record_event(self, _direction: str, _text: str) -> None: + pass + + def query(self, command: str, *, replay: ReplayPolicy = ReplayPolicy.NO_REPLAY) -> str: + assert replay is ReplayPolicy.NO_REPLAY + self.queries.append(command) + return "ok" + + def close(self) -> None: + self.closed += 1 + + +def _open_factory_descriptor() -> object: + return open_instrument_driver( + driver_reference="example.cursor-readout-v2", + expected_kind="scope", + resource="TCPIP::example::INSTR", + configured_backend="pyvisa", + timeout_ms=1_000, + opc_timeout_ms=2_000, + read_retry_attempts=1, + read_retry_delay_ms=0, + logger=CommandLogger(), + ) + + +def test_cursor_readout_v2_factory_latch_blocks_construction_io_until_validation( + monkeypatch: pytest.MonkeyPatch, +) -> None: + inner = _FactoryTransport() + errors: list[TransportIOError] = [] + + class Driver: + def idn(self) -> str: + return "EXAMPLE,EX1,SN-1,1.0" + + def close(self) -> None: + pass + + def get_cursor_readout_v2( + self, + cursor_index: int | None, + *, + configured_cursor: bool, + ) -> ScopeCursorReadoutV2: + assert cursor_index is None + assert configured_cursor is True + return _global_result() + + def factory(context): + transport = context.open_transport() + with pytest.raises(TransportIOError) as raised: + transport.query("*IDN?") + errors.append(raised.value) + return Driver() + + descriptor = replace(_descriptor(), factory=factory) + monkeypatch.setattr( + "wavebench.instruments.factory.resolve_instrument_descriptor", + lambda _reference, expected_kind: descriptor, + ) + monkeypatch.setattr("wavebench.instruments.factory._open_transport", lambda **_kwargs: inner) + + opened = _open_factory_descriptor() + + assert [error.reason_code for error in errors] == ["factory_construction_pending"] + assert errors[0].attempts == 0 + assert inner.queries == [] + assert opened.transport is not None + assert opened.transport.query("*IDN?") == "ok" + assert inner.queries == ["*IDN?"] + + +def test_cursor_readout_v2_factory_closes_missing_method_without_io( + monkeypatch: pytest.MonkeyPatch, +) -> None: + inner = _FactoryTransport() + + class MissingMethod: + def close(self) -> None: + pass + + descriptor = replace( + _descriptor(), + factory=lambda context: (context.open_transport(), MissingMethod())[1], + ) + monkeypatch.setattr( + "wavebench.instruments.factory.resolve_instrument_descriptor", + lambda _reference, expected_kind: descriptor, + ) + monkeypatch.setattr("wavebench.instruments.factory._open_transport", lambda **_kwargs: inner) + + with pytest.raises(ConfigError, match="get_cursor_readout_v2"): + _open_factory_descriptor() + + assert inner.queries == [] + assert inner.closed == 1 + + +def test_undeclared_cursor_readout_v2_method_does_not_latch_legacy_factory( + monkeypatch: pytest.MonkeyPatch, +) -> None: + inner = _FactoryTransport() + + class LegacyDriver: + def idn(self) -> str: + return "EXAMPLE,EX1,SN-1,1.0" + + def close(self) -> None: + pass + + def get_cursor_readout_v2( + self, + cursor_index: int | None, + *, + configured_cursor: bool, + ) -> ScopeCursorReadoutV2: + raise AssertionError( + "undeclared cursor V2 must not be called " + f"for {cursor_index}/{configured_cursor}" + ) + + descriptor = replace( + _descriptor(capabilities=("scope.idn",), extensions=False), + factory=lambda context: (context.open_transport().query("*IDN?"), LegacyDriver())[1], + ) + monkeypatch.setattr( + "wavebench.instruments.factory.resolve_instrument_descriptor", + lambda _reference, expected_kind: descriptor, + ) + monkeypatch.setattr("wavebench.instruments.factory._open_transport", lambda **_kwargs: inner) + + _open_factory_descriptor() + + assert inner.queries == ["*IDN?"] diff --git a/tests/test_scope_digital_status_v2.py b/tests/test_scope_digital_status_v2.py new file mode 100644 index 0000000..513f6f8 --- /dev/null +++ b/tests/test_scope_digital_status_v2.py @@ -0,0 +1,534 @@ +from __future__ import annotations + +from dataclasses import asdict, replace +import io +import json +from contextlib import redirect_stdout +from types import SimpleNamespace +from unittest.mock import patch + +import pytest + +from wavebench.cli import main +from wavebench.errors import ConfigError, DataError, TransportIOError +from wavebench.instruments import ( + InstrumentDescriptor, + ScopeDigitalChannelStatusV2, + ScopeDigitalPodStatusV2, + ScopeDigitalSharedStatusV2, + ScopeDigitalStatusDriverV2, +) +from wavebench.instruments.capabilities import CAPABILITY_METHODS, validate_declared_capabilities +from wavebench.instruments.contracts import ScopeDigitalStatusDriver +from wavebench.instruments.factory import open_instrument_driver +from wavebench.instruments.models import ScopeDigitalChannelStatus +from wavebench.instruments.scope_extension_capabilities import ( + SCOPE_CAPABILITY_METHODS, + SCOPE_STRICT_V2_CAPABILITIES, + validate_scope_descriptor, +) +from wavebench.logging import CommandLogger +from wavebench.services.capability_explain import explain_operation +from wavebench.services.operation_specs import require_operation_spec +from wavebench.services.scope_service import ScopeService +from wavebench.transport.contracts import ReplayPolicy + + +def _descriptor( + *, + minimum: str = "0.8.24", + capabilities: tuple[str, ...] = (), +) -> InstrumentDescriptor: + return InstrumentDescriptor( + driver_id="example.digital-status", + kind="scope", + display_name="Example MSO", + manufacturer="Example", + models=("MSO1",), + aliases=(), + capabilities=capabilities or ("scope.digital_status_v2",), + idn_patterns=("EXAMPLE,MSO1",), + backends=("pyvisa",), + option_specs=(), + permissions=("instrument.io",), + factory=lambda _context: object(), + wavebench_min_version=minimum, + ) + + +def _complete_status(channel: int = 3) -> ScopeDigitalChannelStatusV2: + return ScopeDigitalChannelStatusV2( + channel=channel, + displayed=True, + position_div=-1.5, + label="DATA", + label_enabled=False, + activity="TOGGLE", + technology="TTL", + hysteresis="NORMAL", + pod=ScopeDigitalPodStatusV2( + start_channel=0, + stop_channel=7, + threshold_v=1.4, + threshold_scope="pod", + ), + shared=ScopeDigitalSharedStatusV2( + module_present=True, + timing_calibration_s=0.0, + size="SMALL", + ), + ) + + +def test_digital_status_v2_serializes_complete_and_partial_scoped_state() -> None: + complete = _complete_status() + partial = ScopeDigitalChannelStatusV2( + channel=0, + displayed=False, + label_enabled=True, + activity="unknown", + shared=ScopeDigitalSharedStatusV2(module_present=True), + unavailable_fields=( + "position_div", + "label", + "technology", + "hysteresis", + "pod", + "shared.timing_calibration_s", + "shared.size", + ), + ) + + assert asdict(complete)["unavailable_fields"] == () + assert asdict(complete)["pod"] == { + "start_channel": 0, + "stop_channel": 7, + "threshold_v": 1.4, + "threshold_scope": "pod", + } + assert asdict(partial) == { + "channel": 0, + "displayed": False, + "position_div": None, + "label": None, + "label_enabled": True, + "activity": "unknown", + "technology": None, + "hysteresis": None, + "pod": None, + "shared": { + "module_present": True, + "timing_calibration_s": None, + "size": None, + }, + "unavailable_fields": ( + "position_div", + "label", + "technology", + "hysteresis", + "pod", + "shared.timing_calibration_s", + "shared.size", + ), + } + + +@pytest.mark.parametrize( + ("factory", "error_type", "message"), + ( + ( + lambda: ScopeDigitalPodStatusV2(3, 2), + ValueError, + "must not exceed", + ), + ( + lambda: ScopeDigitalPodStatusV2(True, 2), + ValueError, + "non-negative", + ), + ( + lambda: ScopeDigitalPodStatusV2(0, 1, threshold_v=float("nan")), + ValueError, + "finite", + ), + ( + lambda: ScopeDigitalSharedStatusV2(), + ValueError, + "at least one", + ), + ( + lambda: ScopeDigitalSharedStatusV2(module_present=1), + ValueError, + "must be bool", + ), + ( + lambda: ScopeDigitalSharedStatusV2(size="LARGEISH"), + ValueError, + "size", + ), + ), +) +def test_digital_status_v2_nested_models_reject_invalid_values( + factory, + error_type: type[Exception], + message: str, +) -> None: + with pytest.raises(error_type, match=message): + factory() + + +@pytest.mark.parametrize( + ("changes", "error_type", "message"), + ( + ({"channel": True}, ValueError, "non-negative"), + ({"channel": -1}, ValueError, "non-negative"), + ({"displayed": 1}, ValueError, "displayed"), + ({"position_div": float("inf")}, ValueError, "position_div"), + ({"label": False}, ValueError, "label"), + ({"activity": "PULSE"}, ValueError, "activity"), + ({"pod": ScopeDigitalPodStatusV2(8, 9)}, ValueError, "include"), + ({"shared": object()}, TypeError, "shared"), + ({"unavailable_fields": ("pod.threshold_v",)}, ValueError, "exactly describe"), + ({"unavailable_fields": ("pod", "pod")}, ValueError, "duplicates"), + ({"unavailable_fields": ("missing",)}, ValueError, "unsupported"), + ), +) +def test_digital_status_v2_rejects_invalid_or_ambiguous_availability( + changes: dict[str, object], + error_type: type[Exception], + message: str, +) -> None: + complete = _complete_status() + values = { + "channel": complete.channel, + "displayed": complete.displayed, + "position_div": complete.position_div, + "label": complete.label, + "label_enabled": complete.label_enabled, + "activity": complete.activity, + "technology": complete.technology, + "hysteresis": complete.hysteresis, + "pod": complete.pod, + "shared": complete.shared, + "unavailable_fields": complete.unavailable_fields, + } + values.update(changes) + with pytest.raises(error_type, match=message): + ScopeDigitalChannelStatusV2(**values) # type: ignore[arg-type] + + +def test_digital_status_v2_is_an_additive_protocol_and_registered_capability() -> None: + class Driver: + def idn(self) -> str: + return "EXAMPLE,MSO1" + + def close(self) -> None: + pass + + def get_digital_status_v2(self, channel: int) -> ScopeDigitalChannelStatusV2: + return _complete_status(channel) + + assert isinstance(Driver(), ScopeDigitalStatusDriverV2) + assert "get_digital_status_v2" not in ScopeDigitalStatusDriver.__dict__ + assert SCOPE_CAPABILITY_METHODS["scope.digital_status_v2"] == ( + "get_digital_status_v2", + ) + assert CAPABILITY_METHODS["scope.digital_status_v2"] == ( + "get_digital_status_v2", + ) + assert { + "scope.channel_input_state_v2", + "scope.digital_status_v2", + } <= SCOPE_STRICT_V2_CAPABILITIES + + +def test_digital_status_v2_descriptor_requires_core_floor_and_callable_method() -> None: + with pytest.raises(ConfigError, match="scope portability V2 capabilities require.*0.8.24"): + validate_scope_descriptor(_descriptor(minimum="0.8.23")) + + class MissingMethod: + def close(self) -> None: + pass + + with pytest.raises(TypeError, match="get_digital_status_v2"): + validate_declared_capabilities(_descriptor(), MissingMethod()) + + +def test_digital_status_v2_method_does_not_create_an_implicit_capability() -> None: + descriptor = _descriptor(capabilities=("scope.idn",)) + + class Driver: + def idn(self) -> str: + return "EXAMPLE,MSO1" + + def close(self) -> None: + pass + + def get_digital_status_v2(self, channel: int) -> ScopeDigitalChannelStatusV2: + raise AssertionError(f"undeclared V2 method must not be called for D{channel}") + + validate_declared_capabilities(descriptor, Driver()) + assert "scope.digital_status_v2" not in descriptor.capabilities + assert "scope.digital_waveform" not in descriptor.capabilities + + +def test_digital_status_v2_operation_is_a_stateful_exclusive_read() -> None: + spec = require_operation_spec("scope.digital_status_v2") + + assert spec.instrument_kind == "scope" + assert spec.required_capabilities == ("scope.digital_status_v2",) + assert spec.effect == "stateful_read" + assert spec.lease_mode == "exclusive" + + +def test_digital_status_v2_service_uses_only_v2_driver_method() -> None: + expected = _complete_status(5) + calls: list[tuple[str, int]] = [] + driver = SimpleNamespace( + get_digital_status_v2=lambda channel: calls.append(("v2", channel)) or expected, + get_digital_status=lambda channel: (_ for _ in ()).throw( + AssertionError(f"legacy digital status was called for D{channel}") + ), + ) + service = ScopeService( + config=SimpleNamespace(scope=SimpleNamespace(driver="example.digital-status")), + logger=SimpleNamespace(), + session=driver, + descriptor=SimpleNamespace( + driver_id="example.digital-status", + capabilities=("scope.digital_status_v2",), + ), + ) + + assert service.digital_status_v2(5) == expected + assert calls == [("v2", 5)] + + +def test_digital_status_v2_service_rejects_invalid_or_wrong_channel_without_fallback() -> None: + service = ScopeService( + config=SimpleNamespace(scope=SimpleNamespace(driver="example.digital-status")), + logger=SimpleNamespace(), + descriptor=SimpleNamespace( + driver_id="example.digital-status", + capabilities=("scope.digital_status_v2",), + ), + ) + with patch.object(service, "_open_scope") as open_scope: + with pytest.raises(ConfigError, match="non-negative"): + service.digital_status_v2(-1) + open_scope.assert_not_called() + + service.session = SimpleNamespace(get_digital_status_v2=lambda _channel: _complete_status(6)) + with pytest.raises(DataError, match="wrong channel"): + service.digital_status_v2(5) + + +def test_digital_status_v2_does_not_change_legacy_digital_status_route() -> None: + legacy = ScopeDigitalChannelStatus( + channel=1, + group_start_channel=0, + group_stop_channel=7, + displayed=True, + activity="TOGGLE", + technology="TTL", + threshold_v=1.4, + threshold_coupled=False, + hysteresis="NORMAL", + deskew_s=0.0, + size="SMALL", + position_div=0.0, + label="D1", + label_enabled=True, + ) + calls: list[tuple[str, int]] = [] + driver = SimpleNamespace( + get_digital_status=lambda channel: calls.append(("legacy", channel)) or legacy, + get_digital_status_v2=lambda channel: calls.append(("v2", channel)) + or _complete_status(channel), + ) + service = ScopeService( + config=SimpleNamespace(scope=SimpleNamespace(driver="example.digital-status")), + logger=SimpleNamespace(), + session=driver, + descriptor=SimpleNamespace( + driver_id="example.digital-status", + capabilities=("scope.digital_status", "scope.digital_status_v2"), + ), + ) + + assert service.digital_status(1) is legacy + assert calls == [("legacy", 1)] + + +def test_digital_status_v2_capability_explain_and_cli_output_are_additive() -> None: + descriptor = SimpleNamespace( + driver_id="example.digital-status", + kind="scope", + capabilities=("scope.digital_status_v2",), + ) + explanation = explain_operation("scope.digital_status_v2", descriptor=descriptor) + assert explanation.status == "supported" + assert explanation.spec is not None + assert explanation.spec.effect == "stateful_read" + + expected = ScopeDigitalChannelStatusV2( + channel=0, + activity="unknown", + shared=ScopeDigitalSharedStatusV2(module_present=True), + unavailable_fields=( + "displayed", + "position_div", + "label", + "label_enabled", + "technology", + "hysteresis", + "pod", + "shared.timing_calibration_s", + "shared.size", + ), + ) + calls: list[int] = [] + service = SimpleNamespace(digital_status_v2=lambda channel: calls.append(channel) or expected) + stdout = io.StringIO() + with patch("wavebench.cli._load_service", return_value=service), redirect_stdout(stdout): + code = main(["scope", "digital-status-v2", "--channel", "0"]) + + assert code == 0 + assert calls == [0] + assert stdout.getvalue().splitlines() == [ + "digital_v2.channel=0", + "digital_v2.displayed=n/a", + "digital_v2.position_div=n/a", + "digital_v2.label=n/a", + "digital_v2.label_enabled=n/a", + "digital_v2.activity=unknown", + "digital_v2.technology=n/a", + "digital_v2.hysteresis=n/a", + "digital_v2.pod=n/a", + "digital_v2.shared.module_present=true", + "digital_v2.shared.timing_calibration_s=n/a", + "digital_v2.shared.size=n/a", + "digital_v2.unavailable_fields=displayed,position_div,label,label_enabled,technology,hysteresis,pod,shared.timing_calibration_s,shared.size", + ] + + stdout = io.StringIO() + with patch("wavebench.cli._load_service", return_value=service), redirect_stdout(stdout): + code = main(["scope", "digital-status-v2", "--channel", "0", "--json"]) + + assert code == 0 + payload = json.loads(stdout.getvalue()) + assert payload["schema"] == "wavebench.cli.result.v1" + assert payload["result"]["channel"] == 0 + assert payload["result"]["activity"] == "unknown" + assert payload["result"]["pod"] is None + assert payload["result"]["shared"]["module_present"] is True + assert payload["result"]["unavailable_fields"] == [ + "displayed", + "position_div", + "label", + "label_enabled", + "technology", + "hysteresis", + "pod", + "shared.timing_calibration_s", + "shared.size", + ] + + +class _FactoryTransport: + resource = "TCPIP::example::INSTR" + + def __init__(self) -> None: + self.queries: list[str] = [] + self.closed = 0 + + def record_event(self, _direction: str, _text: str) -> None: + pass + + def query(self, command: str, *, replay: ReplayPolicy = ReplayPolicy.NO_REPLAY) -> str: + assert replay is ReplayPolicy.NO_REPLAY + self.queries.append(command) + return "ok" + + def close(self) -> None: + self.closed += 1 + + +def _open_factory_descriptor() -> object: + return open_instrument_driver( + driver_reference="example.digital-status", + expected_kind="scope", + resource="TCPIP::example::INSTR", + configured_backend="pyvisa", + timeout_ms=1_000, + opc_timeout_ms=2_000, + read_retry_attempts=1, + read_retry_delay_ms=0, + logger=CommandLogger(), + ) + + +def test_digital_status_v2_factory_latch_blocks_io_until_capability_validation( + monkeypatch: pytest.MonkeyPatch, +) -> None: + inner = _FactoryTransport() + errors: list[TransportIOError] = [] + + class Driver: + def idn(self) -> str: + return "EXAMPLE,MSO1" + + def close(self) -> None: + pass + + def get_digital_status_v2(self, channel: int) -> ScopeDigitalChannelStatusV2: + return _complete_status(channel) + + def factory(context): + transport = context.open_transport() + with pytest.raises(TransportIOError) as raised: + transport.query("*IDN?") + errors.append(raised.value) + return Driver() + + descriptor = replace(_descriptor(), factory=factory) + monkeypatch.setattr( + "wavebench.instruments.factory.resolve_instrument_descriptor", + lambda _reference, expected_kind: descriptor, + ) + monkeypatch.setattr("wavebench.instruments.factory._open_transport", lambda **_kwargs: inner) + + opened = _open_factory_descriptor() + + assert [error.reason_code for error in errors] == ["factory_construction_pending"] + assert errors[0].attempts == 0 + assert inner.queries == [] + assert opened.transport is not None + assert opened.transport.query("*IDN?") == "ok" + assert inner.queries == ["*IDN?"] + + +def test_digital_status_v2_factory_closes_on_missing_method_without_io( + monkeypatch: pytest.MonkeyPatch, +) -> None: + inner = _FactoryTransport() + + class MissingMethod: + def close(self) -> None: + pass + + descriptor = replace( + _descriptor(), + factory=lambda context: (context.open_transport(), MissingMethod())[1], + ) + monkeypatch.setattr( + "wavebench.instruments.factory.resolve_instrument_descriptor", + lambda _reference, expected_kind: descriptor, + ) + monkeypatch.setattr("wavebench.instruments.factory._open_transport", lambda **_kwargs: inner) + + with pytest.raises(ConfigError, match="get_digital_status_v2"): + _open_factory_descriptor() + + assert inner.queries == [] + assert inner.closed == 1 diff --git a/tests/test_scope_extension_models.py b/tests/test_scope_extension_models.py index 53b0edd..bd871bd 100644 --- a/tests/test_scope_extension_models.py +++ b/tests/test_scope_extension_models.py @@ -1,5 +1,7 @@ from __future__ import annotations +from dataclasses import fields, replace + import numpy as np import pytest import zlib @@ -25,24 +27,29 @@ ScopeTraceProfile, ScopeTraceRef, ScopeTraceTransferStateSnapshot, + ScopeWaveformBinaryOperationProfile, + ScopeWaveformBinaryProfile, + ScopeWaveformTransferStateSnapshot, validate_acquisition_completion, ) +from wavebench.scope_extension_constants import ( + SCOPE_SCREENSHOT_BINARY_OPERATION_MAX_BYTES, + SCOPE_SCREENSHOT_BINARY_RESPONSE_MAX_BYTES, +) from wavebench.transport.contracts import BinaryResponseFraming -def _png(width: int = 2, height: int = 3) -> bytes: +def _png(width: int = 2, height: int = 3, *, private_chunk_bytes: int = 0) -> bytes: def chunk(kind: bytes, payload: bytes) -> bytes: crc = zlib.crc32(kind + payload) & 0xFFFFFFFF return len(payload).to_bytes(4, "big") + kind + payload + crc.to_bytes(4, "big") ihdr = width.to_bytes(4, "big") + height.to_bytes(4, "big") + b"\x08\x02\x00\x00\x00" rows = b"".join(b"\x00" + b"\x00" * (width * 3) for _ in range(height)) - return ( - b"\x89PNG\r\n\x1a\n" - + chunk(b"IHDR", ihdr) - + chunk(b"IDAT", zlib.compress(rows)) - + chunk(b"IEND", b"") - ) + payload = b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", ihdr) + chunk(b"IDAT", zlib.compress(rows)) + if private_chunk_bytes: + payload += chunk(b"raNd", b"x" * private_chunk_bytes) + return payload + chunk(b"IEND", b"") def test_scope_extension_types_are_exported_from_stable_instrument_api() -> None: @@ -57,8 +64,8 @@ def test_screenshot_profile_uses_exact_request_tuples_and_fixed_limits() -> None request=request, media_type="image/png", framing=BinaryResponseFraming.DEFINITE_BLOCK, - response_max_bytes=262_144, - operation_max_bytes=262_144, + response_max_bytes=SCOPE_SCREENSHOT_BINARY_RESPONSE_MAX_BYTES, + operation_max_bytes=SCOPE_SCREENSHOT_BINARY_OPERATION_MAX_BYTES, resynchronization_max_bytes=0, changed_fields=(), restore_order=(), @@ -68,6 +75,10 @@ def test_screenshot_profile_uses_exact_request_tuples_and_fixed_limits() -> None ) profile = ScopeScreenshotProfile((variant,)) + assert ( + SCOPE_SCREENSHOT_BINARY_RESPONSE_MAX_BYTES, + SCOPE_SCREENSHOT_BINARY_OPERATION_MAX_BYTES, + ) == (8_388_608, 8_388_608) assert profile.select(request) is variant with pytest.raises(ValueError, match="exactly one"): profile.select(ScopeScreenshotRequest(menu_mode="exclude")) @@ -85,6 +96,21 @@ def test_screenshot_profile_uses_exact_request_tuples_and_fixed_limits() -> None restore_max_steps=0, verify_max_steps=0, ) + with pytest.raises(ValueError, match="response_max_bytes"): + replace( + variant, + response_max_bytes=SCOPE_SCREENSHOT_BINARY_RESPONSE_MAX_BYTES + 1, + operation_max_bytes=SCOPE_SCREENSHOT_BINARY_OPERATION_MAX_BYTES + 1, + ) + with pytest.raises(ValueError, match="operation_max_bytes"): + replace( + variant, + operation_max_bytes=SCOPE_SCREENSHOT_BINARY_OPERATION_MAX_BYTES + 1, + ) + with pytest.raises(ValueError, match="resynchronization_max_bytes"): + replace(variant, resynchronization_max_bytes=1) + with pytest.raises(ValueError, match="exactly 1"): + replace(variant, query_max_count=2) def test_screenshot_state_tokens_and_png_dimensions_are_verified() -> None: @@ -105,6 +131,37 @@ def test_screenshot_state_tokens_and_png_dimensions_are_verified() -> None: framing=BinaryResponseFraming.DEFINITE_BLOCK, ) assert screenshot.width_px == 2 + base_png = _png() + documented_payload = _png( + private_chunk_bytes=387_356 - len(base_png) - 12, + ) + assert len(documented_payload) == 387_356 + assert len(documented_payload) <= SCOPE_SCREENSHOT_BINARY_RESPONSE_MAX_BYTES + assert ScopeScreenshot( + data=documented_payload, + media_type="image/png", + width_px=2, + height_px=3, + requested=ScopeScreenshotRequest(), + effective=ScopeScreenshotRequest(), + framing=BinaryResponseFraming.DEFINITE_BLOCK, + ).width_px == 2 + oversized_payload = _png( + private_chunk_bytes=( + SCOPE_SCREENSHOT_BINARY_RESPONSE_MAX_BYTES + 1 - len(base_png) - 12 + ), + ) + assert len(oversized_payload) == SCOPE_SCREENSHOT_BINARY_RESPONSE_MAX_BYTES + 1 + with pytest.raises(ValueError, match="not a PNG"): + ScopeScreenshot( + data=oversized_payload, + media_type="image/png", + width_px=2, + height_px=3, + requested=ScopeScreenshotRequest(), + effective=ScopeScreenshotRequest(), + framing=BinaryResponseFraming.DEFINITE_BLOCK, + ) with pytest.raises(ValueError, match="dimensions"): ScopeScreenshot( data=_png(), @@ -132,16 +189,19 @@ def test_screenshot_state_tokens_and_png_dimensions_are_verified() -> None: def _acquisition_profile( *, identity_semantics: str = "unique_within_session_epoch", + single_arm_semantics: str = "configure_then_arm", + single_mode_readback_allows_terminal_stop: bool = False, ) -> ScopeAcquisitionControlProfile: return ScopeAcquisitionControlProfile( supported_continuous_modes=("auto", "normal"), - single_arm_semantics="configure_then_arm", + single_arm_semantics=single_arm_semantics, # type: ignore[arg-type] arm_resets_acquisition_count=False, failure_restore_order=("scope.trigger", "scope.acquisition"), snapshot_max_steps=3, restore_max_steps=3, verify_max_steps=3, identity_semantics=identity_semantics, # type: ignore[arg-type] + single_mode_readback_allows_terminal_stop=single_mode_readback_allows_terminal_stop, ) @@ -183,6 +243,199 @@ def test_acquisition_identity_proof_depends_on_descriptor_semantics() -> None: ) +def test_acquisition_terminal_stop_proof_requires_explicit_mode_readback_contract() -> None: + original = ScopeAcquisitionRunState("stopped", "normal", "STOP") + completed = ScopeAcquisitionRunState("stopped", "single", "STOP") + baseline = ScopeAcquisitionControlBaseline( + "ctx", + "epoch", + "nonce", + ScopeAcquisitionControlSnapshot(original, "TRIG", "ACQ"), + ("scope.run_state", "scope.trigger", "scope.acquisition"), + ) + completion = ScopeAcquisitionCompletion( + state=completed, + original_state=original, + proof_baseline_state=original, + proof_baseline_stage="original_atomic_arm", + proof="single_mode_readback_then_stopped", + observed_states=(completed,), + post_arm_trigger_mode="single", + ) + profile = _acquisition_profile( + single_arm_semantics="atomic_configure_and_arm", + single_mode_readback_allows_terminal_stop=True, + ) + + validate_acquisition_completion(completion, baseline=baseline, profile=profile) + + with pytest.raises(ValueError, match="not enabled"): + validate_acquisition_completion( + completion, + baseline=baseline, + profile=_acquisition_profile(single_arm_semantics="atomic_configure_and_arm"), + ) + with pytest.raises(ValueError, match="mode readback"): + validate_acquisition_completion( + replace(completion, post_arm_trigger_mode="normal"), + baseline=baseline, + profile=profile, + ) + for invalid_state in ( + ScopeAcquisitionRunState("complete", "single", "COMPLETE"), + ScopeAcquisitionRunState("stopped", "normal", "STOP"), + ): + with pytest.raises(ValueError, match="stopped single-mode"): + validate_acquisition_completion( + replace( + completion, + state=invalid_state, + observed_states=(invalid_state,), + ), + baseline=baseline, + profile=profile, + ) + with pytest.raises(ValueError, match="exactly one"): + validate_acquisition_completion( + replace( + completion, + observed_states=( + ScopeAcquisitionRunState("waiting", "single", "WAIT"), + completed, + ), + ), + baseline=baseline, + profile=profile, + ) + counted_completed = replace(completed, acquisition_count=0) + with pytest.raises(ValueError, match="count or identity"): + validate_acquisition_completion( + replace( + completion, + state=counted_completed, + observed_states=(counted_completed,), + completed_count=0, + ), + baseline=baseline, + profile=profile, + ) + identified_completed = replace(completed, acquisition_identity="completed") + with pytest.raises(ValueError, match="count or identity"): + validate_acquisition_completion( + replace( + completion, + state=identified_completed, + observed_states=(identified_completed,), + completed_identity="completed", + ), + baseline=baseline, + profile=profile, + ) + with pytest.raises(TypeError, match="must be bool"): + _acquisition_profile(single_mode_readback_allows_terminal_stop=1) # type: ignore[arg-type] + with pytest.raises(ValueError, match="post-arm trigger mode"): + replace(completion, post_arm_trigger_mode="invalid") + + +def test_acquisition_terminal_stop_fields_are_append_only_with_legacy_defaults() -> None: + profile = ScopeAcquisitionControlProfile( + ("auto", "normal"), + "configure_then_arm", + False, + ("scope.trigger", "scope.acquisition"), + 3, + 3, + 3, + "unknown", + False, + ) + original = ScopeAcquisitionRunState("stopped", "normal", "STOP") + stopped = ScopeAcquisitionRunState("stopped", "single", "STOP") + completion = ScopeAcquisitionCompletion( + stopped, + original, + original, + "original_atomic_arm", + "state_transition", + None, + None, + None, + None, + (ScopeAcquisitionRunState("waiting", "single", "WAIT"), stopped), + ) + + assert [field.name for field in fields(ScopeAcquisitionControlProfile)][-2:] == [ + "atomic_arm_preserves_count_mode_semantics", + "single_mode_readback_allows_terminal_stop", + ] + assert profile.single_mode_readback_allows_terminal_stop is False + assert [field.name for field in fields(ScopeAcquisitionCompletion)][-2:] == [ + "observed_states", + "post_arm_trigger_mode", + ] + assert completion.post_arm_trigger_mode is None + + +def test_existing_acquisition_count_and_state_transition_proofs_remain_valid() -> None: + original = ScopeAcquisitionRunState( + "stopped", + "normal", + "STOP", + acquisition_count=3, + counter_epoch="epoch", + ) + proof_baseline = ScopeAcquisitionRunState( + "ready", + "single", + "READY", + acquisition_count=3, + counter_epoch="epoch", + ) + arming = ScopeAcquisitionRunState( + "arming", + "single", + "ARM", + acquisition_count=3, + counter_epoch="epoch", + ) + stopped = ScopeAcquisitionRunState( + "stopped", + "single", + "STOP", + acquisition_count=4, + counter_epoch="epoch", + ) + baseline = ScopeAcquisitionControlBaseline( + "ctx", + "epoch", + "nonce", + ScopeAcquisitionControlSnapshot(original, "TRIG", "ACQ"), + ("scope.run_state", "scope.trigger", "scope.acquisition"), + ) + profile = _acquisition_profile(identity_semantics="unknown") + state_transition = ScopeAcquisitionCompletion( + state=stopped, + original_state=original, + proof_baseline_state=proof_baseline, + proof_baseline_stage="configured_pre_arm", + proof="state_transition", + observed_states=(arming, stopped), + ) + count_delta = ScopeAcquisitionCompletion( + state=stopped, + original_state=original, + proof_baseline_state=proof_baseline, + proof_baseline_stage="configured_pre_arm", + proof="count_delta_with_epoch", + baseline_count=3, + completed_count=4, + observed_states=(arming, stopped), + ) + + validate_acquisition_completion(state_transition, baseline=baseline, profile=profile) + validate_acquisition_completion(count_delta, baseline=baseline, profile=profile) + + def test_trace_models_copy_arrays_and_enforce_r1_3_fetch_scope() -> None: metadata = ScopeTraceMetadata( source=ScopeTraceRef("analog", index=1), @@ -258,6 +511,71 @@ def test_trace_profile_and_snapshot_close_each_transfer_field() -> None: ) +def test_waveform_binary_profile_is_bounded_and_uses_capture_complete_transfer_models() -> None: + fields = ("scope.waveform_source", "scope.waveform_format") + operation = ScopeWaveformBinaryOperationProfile( + operation_kind="fetch", + response_max_bytes=8_388_608, + operation_max_bytes=67_108_864, + query_max_count=256, + resynchronization_max_bytes=65_536, + restore_order=fields, + snapshot_max_steps=2, + restore_max_steps=2, + verify_max_steps=2, + ) + profile = ScopeWaveformBinaryProfile( + operations=(operation,), + transport_trailing_hex="0d0a", + ) + + assert profile.transport_trailing == b"\r\n" + assert profile.operation_for("fetch") is operation + assert ScopeWaveformTransferStateSnapshot is not ScopeTraceTransferStateSnapshot + ScopeWaveformTransferStateSnapshot( + captured_fields=("scope.timebase", "scope.channel_vertical"), + timebase_token="TIMEBASE", + channel_vertical_token="CHANNEL_VERTICAL", + ) + assert public_instruments.ScopeWaveformBinaryProfile is ScopeWaveformBinaryProfile + assert ScopeWaveformBinaryProfile(operations=(operation,)).transport_trailing == b"" + assert ScopeWaveformBinaryProfile( + operations=(operation,), transport_trailing_hex="0a" + ).transport_trailing == b"\n" + + with pytest.raises(ValueError, match="definite-block"): + ScopeWaveformBinaryProfile( + operations=(operation,), + framing=BinaryResponseFraming.MESSAGE, + ) + with pytest.raises(ValueError, match="lowercase"): + ScopeWaveformBinaryProfile(operations=(operation,), transport_trailing_hex="0A") + with pytest.raises(ValueError, match="operation limit"): + ScopeWaveformBinaryOperationProfile( + operation_kind="fetch", + response_max_bytes=2, + operation_max_bytes=1, + query_max_count=1, + resynchronization_max_bytes=0, + restore_order=("scope.waveform_source",), + snapshot_max_steps=1, + restore_max_steps=1, + verify_max_steps=1, + ) + with pytest.raises(ValueError, match="capture restore order"): + ScopeWaveformBinaryOperationProfile( + operation_kind="capture_single", + response_max_bytes=1, + operation_max_bytes=1, + query_max_count=1, + resynchronization_max_bytes=0, + restore_order=("scope.waveform_source",), + snapshot_max_steps=1, + restore_max_steps=1, + verify_max_steps=1, + ) + + def test_descriptor_extension_rejects_queried_only_profile() -> None: variant = ScopeScreenshotVariant( request=ScopeScreenshotRequest(), diff --git a/tests/test_scope_extension_registry.py b/tests/test_scope_extension_registry.py index 71e6804..ede0fa1 100644 --- a/tests/test_scope_extension_registry.py +++ b/tests/test_scope_extension_registry.py @@ -13,10 +13,15 @@ validate_scope_descriptor, ) from wavebench.instruments.scope_extensions import ( + SCOPE_AVERAGE_CAPTURE_FIELD_ORDER, + ScopeAverageCaptureBinaryProfile, + ScopeAverageCaptureProfileV2, ScopeDescriptorExtensions, ScopeScreenshotProfile, ScopeScreenshotRequest, ScopeScreenshotVariant, + ScopeWaveformBinaryOperationProfile, + ScopeWaveformBinaryProfile, ) from wavebench.services.operation_specs import OperationSpec, get_operation_spec from wavebench.services.scope_error_policy import legacy_scope_error_artifact @@ -47,7 +52,72 @@ def _profile() -> ScopeScreenshotProfile: ) -def _descriptor(*, capabilities: tuple[str, ...], extensions=True) -> InstrumentDescriptor: +def _waveform_profile(*, operation_kind: str = "fetch") -> ScopeWaveformBinaryProfile: + fields = ( + ("scope.waveform_source",) + if operation_kind == "fetch" + else ( + "scope.run_state", + "scope.acquisition", + "scope.trigger", + "scope.timebase", + "scope.channel_display", + "scope.channel_vertical", + "scope.waveform_source", + "scope.waveform_mode", + "scope.query_response_header", + "scope.waveform_format", + "scope.waveform_byte_order", + "scope.waveform_points", + "scope.waveform_transfer_window", + ) + ) + return ScopeWaveformBinaryProfile( + operations=( + ScopeWaveformBinaryOperationProfile( + operation_kind=operation_kind, # type: ignore[arg-type] + response_max_bytes=1_024, + operation_max_bytes=4_096, + query_max_count=4, + resynchronization_max_bytes=0, + restore_order=fields, + snapshot_max_steps=len(fields), + restore_max_steps=len(fields), + verify_max_steps=len(fields), + ), + ) + ) + + +def _average_capture_profile() -> ScopeAverageCaptureProfileV2: + return ScopeAverageCaptureProfileV2( + global_acquisition_type="average", + completion_contract_id="device-average-complete-v1", + channel_range=(1, 4), + supported_points=("def",), + average_count_min=2, + average_count_max=64, + requires_power_of_two=True, + binary=ScopeAverageCaptureBinaryProfile( + response_max_bytes=1_024, + operation_max_bytes=4_096, + query_max_count=4, + resynchronization_max_bytes=0, + ), + restore_order=SCOPE_AVERAGE_CAPTURE_FIELD_ORDER, + snapshot_max_steps=len(SCOPE_AVERAGE_CAPTURE_FIELD_ORDER), + main_max_steps=8, + restore_max_steps=len(SCOPE_AVERAGE_CAPTURE_FIELD_ORDER), + verify_max_steps=len(SCOPE_AVERAGE_CAPTURE_FIELD_ORDER), + ) + + +def _descriptor( + *, + capabilities: tuple[str, ...], + extensions=True, + wavebench_min_version: str = "0.8.23", +) -> InstrumentDescriptor: return InstrumentDescriptor( driver_id="example.scope", kind="scope", @@ -61,7 +131,7 @@ def _descriptor(*, capabilities: tuple[str, ...], extensions=True) -> Instrument option_specs=(), permissions=("instrument.io",), factory=lambda context: object(), - wavebench_min_version="0.8.23", + wavebench_min_version=wavebench_min_version, scope_extensions=( ScopeDescriptorExtensions(screenshot_profile=_profile()) if extensions else None ), @@ -106,6 +176,18 @@ def test_scope_descriptor_extension_is_append_only_for_positional_compatibility( "scope_extensions", "source_extensions", ] + assert [field.name for field in fields(ScopeDescriptorExtensions)] == [ + "screenshot_profile", + "acquisition_control_profile", + "trace_profile", + "waveform_binary_profile", + "snapshot_profile_v2", + "acquisition_status_profile_v2", + "measurement_statistics_profile_v2", + "fft_status_profile_v2", + "cursor_readout_profile_v2", + "average_capture_profile_v2", + ] def test_new_old_core_plugin_capability_matrix_is_fail_closed() -> None: @@ -136,6 +218,127 @@ def get_screenshot_profile(self): assert "scope.screenshot_profile" not in descriptor.capabilities +def test_waveform_binary_profile_selects_bounded_v2_methods_without_creating_capabilities() -> None: + descriptor = _descriptor( + capabilities=("scope.idn", "scope.fetch_waveform"), + extensions=False, + wavebench_min_version="0.8.24", + ) + descriptor = replace( + descriptor, + scope_extensions=ScopeDescriptorExtensions( + waveform_binary_profile=_waveform_profile() + ), + ) + + class Driver: + def close(self) -> None: + pass + + def idn(self) -> str: + return "EXAMPLE,EX1" + + def snapshot_waveform_transfer_state(self, fields): + return object() + + def restore_waveform_transfer_state(self, baseline): + return object() + + def verify_waveform_transfer_state_restored(self, baseline): + return object() + + def fetch_waveform_bounded(self, channel, points="dmax", *, baseline): + return object() + + validate_declared_capabilities(descriptor, Driver()) + assert "scope.fetch_waveform_bounded" not in descriptor.capabilities + + +def test_waveform_binary_profile_must_match_capabilities_and_bounded_methods() -> None: + descriptor = _descriptor( + capabilities=("scope.idn", "scope.capture_waveform"), + extensions=False, + wavebench_min_version="0.8.24", + ) + descriptor = replace( + descriptor, + scope_extensions=ScopeDescriptorExtensions( + waveform_binary_profile=_waveform_profile(operation_kind="fetch") + ), + ) + with pytest.raises(ConfigError, match="operations must match"): + validate_scope_descriptor(descriptor) + + descriptor = _descriptor( + capabilities=("scope.idn", "scope.fetch_waveform"), + extensions=False, + wavebench_min_version="0.8.24", + ) + descriptor = replace( + descriptor, + scope_extensions=ScopeDescriptorExtensions( + waveform_binary_profile=_waveform_profile() + ), + ) + + class MissingBoundedMethod: + def close(self) -> None: + pass + + def idn(self) -> str: + return "EXAMPLE,EX1" + + def snapshot_waveform_transfer_state(self, fields): + return object() + + def restore_waveform_transfer_state(self, baseline): + return object() + + def verify_waveform_transfer_state_restored(self, baseline): + return object() + + def fetch_waveform(self, channel, points="dmax", check_errors=True): + return object() + + with pytest.raises(ConfigError, match="fetch_waveform_bounded"): + validate_declared_capabilities(descriptor, MissingBoundedMethod()) + + +def test_waveform_binary_profile_requires_its_own_core_version_floor() -> None: + descriptor = _descriptor( + capabilities=("scope.idn", "scope.fetch_waveform"), + extensions=False, + ) + descriptor = replace( + descriptor, + scope_extensions=ScopeDescriptorExtensions( + waveform_binary_profile=_waveform_profile() + ), + ) + + with pytest.raises(ConfigError, match="0.8.24"): + validate_scope_descriptor(descriptor) + + +def test_average_capture_profile_requires_its_capability_and_static_dependencies() -> None: + descriptor = replace( + _descriptor( + capabilities=("scope.idn",), + extensions=False, + wavebench_min_version="0.8.24", + ), + scope_extensions=ScopeDescriptorExtensions( + average_capture_profile_v2=_average_capture_profile() + ), + ) + with pytest.raises(ConfigError, match="scope.capture_average_v2"): + validate_scope_descriptor(descriptor) + + descriptor = replace(descriptor, capabilities=("scope.capture_average_v2",)) + with pytest.raises(ConfigError, match="scope.acquisition_control"): + validate_scope_descriptor(descriptor) + + def test_private_descriptor_gate_requires_explicit_enable_profile_and_methods() -> None: descriptor = _descriptor(capabilities=("scope.screenshot_profile",)) with pytest.raises(ConfigError, match="disabled"): @@ -166,7 +369,7 @@ def test_scope_extension_operation_specs_freeze_timeout_and_binary_limits() -> N screenshot.binary_operation_max_bytes, screenshot.binary_query_max_count, screenshot.binary_resynchronization_max_bytes, - ) == (262_144, 262_144, 1, 0) + ) == (8_388_608, 8_388_608, 1, 0) trace = SCOPE_OPERATION_SPECS["scope.fetch_trace"] assert trace.operation_timeout_ms == 60_000 @@ -229,4 +432,5 @@ def test_embedded_screenshot_parent_specs_have_complete_static_field_closure() - spec.cleanup_verification_fields ) assert "scope.screenshot_v2" in spec.optional_capabilities - assert spec.binary_response_max_bytes == 262_144 + assert spec.binary_response_max_bytes == 8_388_608 + assert spec.binary_operation_max_bytes == 8_388_608 diff --git a/tests/test_scope_extension_service.py b/tests/test_scope_extension_service.py index 19a930e..dcce265 100644 --- a/tests/test_scope_extension_service.py +++ b/tests/test_scope_extension_service.py @@ -32,6 +32,10 @@ ScopeTraceTransferRestoreResult, ScopeTraceTransferStateSnapshot, ) +from wavebench.scope_extension_constants import ( + SCOPE_SCREENSHOT_BINARY_OPERATION_MAX_BYTES, + SCOPE_SCREENSHOT_BINARY_RESPONSE_MAX_BYTES, +) from wavebench.services.scope_extension_service import ( ExperimentalScopeExtensionService, ScopeExtensionService, @@ -67,6 +71,7 @@ def __init__(self) -> None: self.queries: list[str] = [] self.writes: list[str] = [] self.binary_queries = 0 + self.binary_max_bytes: list[int] = [] def record_event(self, direction: str, text: str) -> None: pass @@ -106,6 +111,7 @@ def query_binary( _resynchronization_max_bytes: int = 0, ) -> BinaryQueryResult: self.binary_queries += 1 + self.binary_max_bytes.append(max_bytes) data = _png() if "SCREEN" in command else b"\x01\x02" assert len(data) <= max_bytes if framing is BinaryResponseFraming.MESSAGE: @@ -147,12 +153,19 @@ def close(self) -> None: class _Driver: - def __init__(self, transport: GuardedAuditedTransport) -> None: + def __init__( + self, + transport: GuardedAuditedTransport, + *, + terminal_stop_completion: bool = False, + ) -> None: self.transport = transport self.fail_screenshot = False self.trace_verify_mismatch = False self.fail_single_completion = False self.fail_stop_once = False + self.terminal_stop_completion = terminal_stop_completion + self.terminal_stop_mode = "single" self.restore_calls = 0 self.error_records: tuple[DriverErrorRecord, ...] = () self.error_queries = 0 @@ -166,8 +179,8 @@ def __init__(self, transport: GuardedAuditedTransport) -> None: ), media_type="image/png", framing=BinaryResponseFraming.DEFINITE_BLOCK, - response_max_bytes=262_144, - operation_max_bytes=262_144, + response_max_bytes=SCOPE_SCREENSHOT_BINARY_RESPONSE_MAX_BYTES, + operation_max_bytes=SCOPE_SCREENSHOT_BINARY_OPERATION_MAX_BYTES, resynchronization_max_bytes=0, changed_fields=("scope.display_menu", "scope.display_color"), restore_order=("scope.display_menu", "scope.display_color"), @@ -181,13 +194,18 @@ def __init__(self, transport: GuardedAuditedTransport) -> None: ) self.acquisition_profile = ScopeAcquisitionControlProfile( supported_continuous_modes=("auto", "normal"), - single_arm_semantics="configure_then_arm", + single_arm_semantics=( + "atomic_configure_and_arm" + if terminal_stop_completion + else "configure_then_arm" + ), arm_resets_acquisition_count=False, failure_restore_order=("scope.trigger", "scope.acquisition"), snapshot_max_steps=3, restore_max_steps=3, verify_max_steps=3, identity_semantics="unique_within_session_epoch", + single_mode_readback_allows_terminal_stop=terminal_stop_completion, ) self.trace_profile = ScopeTraceProfile( fetchable_kinds=("analog",), @@ -243,7 +261,7 @@ def capture_screenshot(self, request, *, baseline): binary = self.transport.query_binary( "SCREEN?", framing=BinaryResponseFraming.DEFINITE_BLOCK, - max_bytes=262_144, + max_bytes=SCOPE_SCREENSHOT_BINARY_RESPONSE_MAX_BYTES, ) if self.fail_screenshot: raise DataError("screenshot parser failed") @@ -304,6 +322,19 @@ def stop_acquisition(self): def acquire_single(self, *, baseline, deadline): self.transport.write("TRIGGER SINGLE") + if self.terminal_stop_completion: + self.transport.query("TRIGGER SWEEP?") + self.transport.query("RUN STATE?") + completed = ScopeAcquisitionRunState("stopped", "single", "STOP") + return ScopeAcquisitionCompletion( + state=completed, + original_state=baseline.snapshot.run_state, + proof_baseline_state=baseline.snapshot.run_state, + proof_baseline_stage="original_atomic_arm", + proof="single_mode_readback_then_stopped", + observed_states=(completed,), + post_arm_trigger_mode=self.terminal_stop_mode, + ) self.transport.query("READY?") proof_baseline = ScopeAcquisitionRunState( "ready", @@ -427,10 +458,14 @@ def drain_errors(self, *, max_records): ) -def _service(*, error_capability: bool = False): +def _service( + *, + error_capability: bool = False, + terminal_stop_completion: bool = False, +): backend = _Backend() transport = GuardedAuditedTransport(backend) - driver = _Driver(transport) + driver = _Driver(transport, terminal_stop_completion=terminal_stop_completion) capabilities = [ "scope.idn", "scope.screenshot_profile", @@ -482,6 +517,7 @@ def test_screenshot_success_restores_and_verifies_before_return() -> None: assert transport.session_state.health is SessionHealth.HEALTHY assert driver.restore_calls == 1 assert backend.binary_queries == 1 + assert backend.binary_max_bytes == [SCOPE_SCREENSHOT_BINARY_RESPONSE_MAX_BYTES] assert [phase["phase"] for phase in result.diagnostics["phases"]] == [ "preflight", "main", @@ -564,6 +600,30 @@ def test_acquisition_success_keeps_postcondition_and_failure_restores_baseline() assert raised.value.scope_operation_diagnostics["cleanup"]["verification"]["status"] == "verified" +def test_acquisition_service_accepts_profile_gated_terminal_stop_proof() -> None: + service, driver, transport, _ = _service(terminal_stop_completion=True) + + result = service.acquire_single() + + assert result.value.proof == "single_mode_readback_then_stopped" + assert result.value.post_arm_trigger_mode == "single" + assert result.value.observed_states == (result.value.state,) + assert driver.restore_calls == 0 + assert transport.session_state.health is SessionHealth.HEALTHY + + +def test_acquisition_service_restores_after_invalid_terminal_stop_proof() -> None: + service, driver, transport, _ = _service(terminal_stop_completion=True) + driver.terminal_stop_mode = "normal" + + with pytest.raises(ValueError, match="mode readback") as raised: + service.acquire_single() + + assert driver.restore_calls == 1 + assert transport.session_state.health is SessionHealth.HEALTHY + assert raised.value.scope_operation_diagnostics["cleanup"]["verification"]["status"] == "verified" + + def test_failed_normal_stop_uses_bounded_recovery_stop_and_preserves_primary() -> None: service, driver, transport, _ = _service() driver.run_state = ScopeAcquisitionRunState("acquiring", "normal", "RUN") diff --git a/tests/test_scope_fft_status_v2.py b/tests/test_scope_fft_status_v2.py new file mode 100644 index 0000000..379c8e6 --- /dev/null +++ b/tests/test_scope_fft_status_v2.py @@ -0,0 +1,650 @@ +from __future__ import annotations + +from dataclasses import asdict, replace +from types import SimpleNamespace + +import pytest + +from wavebench.errors import ConfigError, DataError, SessionHealthError, TransportIOError +from wavebench.instruments import ( + InstrumentDescriptor, + SCOPE_FFT_STATUS_V2_FIELD_ORDER, + ScopeFftStatusDriverV2, + ScopeFftStatusV2, +) +from wavebench.instruments.capabilities import CAPABILITY_METHODS, validate_declared_capabilities +from wavebench.instruments.contracts import ScopeAnalysisReadDriver +from wavebench.instruments.factory import open_instrument_driver +from wavebench.instruments.models import ScopeFftStatus +from wavebench.instruments.scope_extension_capabilities import ( + SCOPE_CAPABILITY_METHODS, + SCOPE_STRICT_V2_CAPABILITIES, + validate_scope_descriptor, +) +from wavebench.instruments.scope_extensions import ( + ScopeDescriptorExtensions, + ScopeFftStatusProfileV2, +) +from wavebench.logging import CommandLogger +from wavebench.services.operation_specs import ( + SCOPE_OPERATION_SPECS, + SCOPE_PORTABILITY_V2_OPERATION_SPECS, + require_operation_spec, +) +from wavebench.services.scope_service import ScopeService +from wavebench.transport.contracts import ReplayPolicy +from wavebench.transport.guarded import GuardedAuditedTransport + + +_READABLE_FIELDS = ( + "source", + "window", + "vertical_unit", + "frequency_start_hz", + "frequency_stop_hz", +) +_UNAVAILABLE_FIELDS = ( + "average_complete", + "resolution_bandwidth_hz", + "sample_rate_hz", +) + + +def _profile( + *, + readable_fields: tuple[str, ...] = _READABLE_FIELDS, + max_queries: int = 1, +) -> ScopeFftStatusProfileV2: + return ScopeFftStatusProfileV2( + readable_fields=readable_fields, # type: ignore[arg-type] + max_queries=max_queries, + ) + + +def _result(math_index: int = 1) -> ScopeFftStatusV2: + return ScopeFftStatusV2( + math_index=math_index, + source="CH1", + window="HANNING", + vertical_unit="DBV", + frequency_start_hz=0.0, + frequency_stop_hz=1_000_000.0, + unavailable_fields=_UNAVAILABLE_FIELDS, + ) + + +def _complete_result(math_index: int = 1) -> ScopeFftStatusV2: + return ScopeFftStatusV2( + math_index=math_index, + source="CH1", + window="HANNING", + vertical_unit="DBV", + frequency_start_hz=0.0, + frequency_stop_hz=1_000_000.0, + average_complete=True, + resolution_bandwidth_hz=100.0, + sample_rate_hz=2_000_000.0, + ) + + +def _descriptor( + *, + profile: ScopeFftStatusProfileV2 | None = None, + minimum: str = "0.8.24", + capabilities: tuple[str, ...] = (), + extensions: bool = True, +) -> InstrumentDescriptor: + return InstrumentDescriptor( + driver_id="example.fft-status-v2", + kind="scope", + display_name="Example scope", + manufacturer="Example", + models=("EX1",), + aliases=(), + capabilities=capabilities or ("scope.fft_status_v2",), + idn_patterns=("EXAMPLE,EX1",), + backends=("pyvisa",), + option_specs=(), + permissions=("instrument.io",), + factory=lambda _context: object(), + wavebench_min_version=minimum, + scope_extensions=( + ScopeDescriptorExtensions(fft_status_profile_v2=profile or _profile()) + if extensions + else None + ), + ) + + +def test_fft_status_v2_models_serialize_partial_and_complete_readouts() -> None: + partial = _result() + complete = _complete_result(2) + + assert asdict(partial)["source"] == "CH1" + assert partial.unavailable_fields == _UNAVAILABLE_FIELDS + assert complete.unavailable_fields == () + assert complete.resolution_bandwidth_hz == 100.0 + assert SCOPE_FFT_STATUS_V2_FIELD_ORDER == ( + "source", + "window", + "vertical_unit", + "frequency_start_hz", + "frequency_stop_hz", + "average_complete", + "resolution_bandwidth_hz", + "sample_rate_hz", + ) + + +@pytest.mark.parametrize( + ("factory", "error_type", "message"), + ( + (lambda: ScopeFftStatusV2(True, unavailable_fields=SCOPE_FFT_STATUS_V2_FIELD_ORDER), ValueError, "positive integer"), + ( + lambda: ScopeFftStatusV2( + 1, + source="not safe", + unavailable_fields=( + "window", + "vertical_unit", + "frequency_start_hz", + "frequency_stop_hz", + "average_complete", + "resolution_bandwidth_hz", + "sample_rate_hz", + ), + ), + ValueError, + "safe token", + ), + ( + lambda: ScopeFftStatusV2( + 1, + frequency_start_hz=0.0, + unavailable_fields=( + "source", + "window", + "vertical_unit", + "frequency_stop_hz", + "average_complete", + "resolution_bandwidth_hz", + "sample_rate_hz", + ), + ), + ValueError, + "present or unavailable together", + ), + ( + lambda: ScopeFftStatusV2( + 1, + frequency_start_hz=2.0, + frequency_stop_hz=1.0, + unavailable_fields=( + "source", + "window", + "vertical_unit", + "average_complete", + "resolution_bandwidth_hz", + "sample_rate_hz", + ), + ), + ValueError, + "below", + ), + ( + lambda: ScopeFftStatusV2( + 1, + resolution_bandwidth_hz=0.0, + unavailable_fields=( + "source", + "window", + "vertical_unit", + "frequency_start_hz", + "frequency_stop_hz", + "average_complete", + "sample_rate_hz", + ), + ), + ValueError, + "positive", + ), + ( + lambda: ScopeFftStatusV2( + 1, + unavailable_fields=("source", "source", "window", "vertical_unit", "frequency_start_hz", "frequency_stop_hz", "average_complete", "resolution_bandwidth_hz", "sample_rate_hz"), + ), + ValueError, + "duplicates", + ), + ( + lambda: ScopeFftStatusV2( + 1, + unavailable_fields=tuple(reversed(SCOPE_FFT_STATUS_V2_FIELD_ORDER)), + ), + ValueError, + "stable order", + ), + ), +) +def test_fft_status_v2_models_reject_invalid_or_ambiguous_values( + factory, + error_type: type[Exception], + message: str, +) -> None: + with pytest.raises(error_type, match=message): + factory() + + +@pytest.mark.parametrize( + ("kwargs", "message"), + ( + ({"readable_fields": ()}, "must not be empty"), + ({"readable_fields": tuple(reversed(_READABLE_FIELDS))}, "stable field order"), + ({"readable_fields": ("source", "unknown")}, "unsupported"), + ({"readable_fields": ("frequency_start_hz",)}, "together"), + ({"max_queries": True}, "integer"), + ({"max_queries": 33}, "1..32"), + ), +) +def test_fft_status_v2_profile_rejects_invalid_static_contract( + kwargs: dict[str, object], + message: str, +) -> None: + values: dict[str, object] = { + "readable_fields": _READABLE_FIELDS, + "max_queries": 1, + } + values.update(kwargs) + with pytest.raises((TypeError, ValueError), match=message): + ScopeFftStatusProfileV2(**values) # type: ignore[arg-type] + + +def test_fft_status_v2_profile_enforces_static_result_boundary() -> None: + profile = _profile() + + profile.validate_result(_result(), math_index=1) + with pytest.raises(ValueError, match="outside the descriptor profile"): + profile.validate_result(_complete_result(), math_index=1) + with pytest.raises(ValueError, match="wrong math_index"): + profile.validate_result(_result(2), math_index=1) + + +def test_fft_status_v2_is_additive_and_registered_without_r13_membership() -> None: + class Driver: + def idn(self) -> str: + return "EXAMPLE,EX1,SN-1,1.0" + + def close(self) -> None: + pass + + def get_fft_status_v2( + self, + math_index: int, + *, + configured_fft: bool, + ) -> ScopeFftStatusV2: + assert configured_fft is True + return _result(math_index) + + assert isinstance(Driver(), ScopeFftStatusDriverV2) + assert "get_fft_status_v2" not in ScopeAnalysisReadDriver.__dict__ + assert SCOPE_CAPABILITY_METHODS["scope.fft_status_v2"] == ("get_fft_status_v2",) + assert CAPABILITY_METHODS["scope.fft_status_v2"] == ("get_fft_status_v2",) + assert "scope.fft_status_v2" in SCOPE_STRICT_V2_CAPABILITIES + assert "scope.fft_status_v2" not in SCOPE_OPERATION_SPECS + spec = SCOPE_PORTABILITY_V2_OPERATION_SPECS["scope.fft_status_v2"] + assert require_operation_spec("scope.fft_status_v2") is spec + assert spec.effect == "stateful_read" + assert spec.lease_mode == "exclusive" + assert spec.required_verified_fields == () + assert spec.error_check_minimum == "disabled" + + +def test_fft_status_v2_descriptor_requires_profile_core_floor_and_callable_method() -> None: + with pytest.raises(ConfigError, match="scope portability V2 capabilities require.*0.8.24"): + validate_scope_descriptor(_descriptor(minimum="0.8.23")) + with pytest.raises(ConfigError, match="fft_status_profile_v2"): + validate_scope_descriptor(_descriptor(extensions=False)) + + class MissingMethod: + def close(self) -> None: + pass + + with pytest.raises(TypeError, match="get_fft_status_v2"): + validate_declared_capabilities(_descriptor(), MissingMethod()) + + +def test_fft_status_v2_method_does_not_create_an_implicit_capability() -> None: + descriptor = _descriptor(capabilities=("scope.idn",), extensions=False) + + class Driver: + def idn(self) -> str: + return "EXAMPLE,EX1,SN-1,1.0" + + def close(self) -> None: + pass + + def get_fft_status_v2( + self, + math_index: int, + *, + configured_fft: bool, + ) -> ScopeFftStatusV2: + raise AssertionError( + f"undeclared FFT V2 must not be called for {math_index}/{configured_fft}" + ) + + validate_declared_capabilities(descriptor, Driver()) + assert "scope.fft_status_v2" not in descriptor.capabilities + + +def _service( + *, + profile: ScopeFftStatusProfileV2, + driver: object, + transport: GuardedAuditedTransport | None = None, +) -> ScopeService: + return ScopeService( + config=SimpleNamespace( + scope=SimpleNamespace(driver="example.fft-status-v2"), + connection=SimpleNamespace(timeout_ms=1_000), + ), + logger=SimpleNamespace(), + session=driver, + descriptor=SimpleNamespace( + driver_id="example.fft-status-v2", + capabilities=("scope.fft_status_v2",), + scope_extensions=ScopeDescriptorExtensions(fft_status_profile_v2=profile), + ), + transport=transport, + session_state=None if transport is None else transport.session_state, + ) + + +def test_fft_status_v2_service_only_calls_v2_and_keeps_legacy_route_separate() -> None: + calls: list[tuple[str, int]] = [] + driver = SimpleNamespace( + get_fft_status_v2=lambda math_index, *, configured_fft: calls.append(("v2", math_index)) + or _result(math_index), + get_fft_status=lambda *_args, **_kwargs: (_ for _ in ()).throw( + AssertionError("legacy FFT must stay separate") + ), + get_math_waveform_metadata=lambda _index: (_ for _ in ()).throw( + AssertionError("math metadata must not be used for FFT V2") + ), + ) + service = _service(profile=_profile(), driver=driver) + + assert service.fft_status_v2(1, configured_fft=True) == _result() + assert calls == [("v2", 1)] + + legacy = ScopeFftStatus( + math_index=1, + average_complete=True, + resolution_bandwidth_hz=100.0, + sample_rate_hz=2_000_000.0, + ) + driver.get_fft_status = lambda index, *, configured_fft: calls.append(("legacy", index)) or legacy + service.descriptor = SimpleNamespace( + driver_id="example.fft-status-v2", + capabilities=("scope.fft_status", "scope.fft_status_v2"), + scope_extensions=ScopeDescriptorExtensions(fft_status_profile_v2=_profile()), + ) + assert service.fft_status(1, configured_fft=True) is legacy + assert calls == [("v2", 1), ("legacy", 1)] + + +@pytest.mark.parametrize( + ("math_index", "configured_fft", "message"), + ( + (0, True, "positive integer"), + (True, True, "positive integer"), + (1, False, "configured_fft=True"), + (1, 1, "configured_fft=True"), + ), +) +def test_fft_status_v2_service_rejects_invalid_preconditions_before_opening_scope( + math_index: object, + configured_fft: object, + message: str, +) -> None: + service = _service( + profile=_profile(), + driver=SimpleNamespace(get_fft_status_v2=lambda *_args, **_kwargs: _result()), + ) + service._open_scope = lambda: pytest.fail("invalid FFT V2 request must not open scope") + + with pytest.raises(ConfigError, match=message): + service.fft_status_v2(math_index, configured_fft=configured_fft) # type: ignore[arg-type] + + +def test_fft_status_v2_service_rejects_invalid_result_without_fallback() -> None: + service = _service( + profile=_profile(), + driver=SimpleNamespace(get_fft_status_v2=lambda *_args, **_kwargs: object()), + ) + + with pytest.raises(DataError, match="invalid result"): + service.fft_status_v2(1, configured_fft=True) + + +class _InnerTransport: + resource = "TCPIP::example::INSTR" + + def __init__(self) -> None: + self.queries: list[str] = [] + self.writes: list[str] = [] + + def record_event(self, _direction: str, _text: str) -> None: + pass + + def query(self, command: str, *, replay: ReplayPolicy = ReplayPolicy.NO_REPLAY) -> str: + assert replay is ReplayPolicy.NO_REPLAY + self.queries.append(command) + return "FFT" + + def write(self, command: str) -> None: + self.writes.append(command) + + def close(self) -> None: + pass + + +def test_fft_status_v2_uses_only_budgeted_text_queries_without_identity_or_math_preflight() -> None: + inner = _InnerTransport() + transport = GuardedAuditedTransport(inner) + + class Driver: + def idn(self) -> str: + raise AssertionError("FFT V2 must not call legacy identity preflight") + + def get_math_waveform_metadata(self, _index: int) -> object: + raise AssertionError("FFT V2 must not query math metadata") + + def get_fft_status_v2( + self, + math_index: int, + *, + configured_fft: bool, + ) -> ScopeFftStatusV2: + assert math_index == 1 + assert configured_fft is True + transport.query("MATH1:TYPE?") + return _result(math_index) + + service = _service(profile=_profile(max_queries=1), driver=Driver(), transport=transport) + + assert service.fft_status_v2(1, configured_fft=True) == _result() + assert inner.queries == ["MATH1:TYPE?"] + assert inner.writes == [] + assert transport.counters.query_calls == 1 + + +@pytest.mark.parametrize("mode", ("overrun", "write")) +def test_fft_status_v2_rejects_overrun_and_non_query_io_before_backend_send(mode: str) -> None: + inner = _InnerTransport() + transport = GuardedAuditedTransport(inner) + + class Driver: + def get_fft_status_v2( + self, + math_index: int, + *, + configured_fft: bool, + ) -> ScopeFftStatusV2: + assert configured_fft is True + transport.query("MATH1:TYPE?") + if mode == "overrun": + transport.query("MATH1:EXTRA?") + else: + transport.write("MATH1:BAD") + return _result(math_index) + + service = _service(profile=_profile(max_queries=1), driver=Driver(), transport=transport) + + with pytest.raises(SessionHealthError): + service.fft_status_v2(1, configured_fft=True) + assert inner.queries == ["MATH1:TYPE?"] + assert inner.writes == [] + + +class _FactoryTransport: + resource = "TCPIP::example::INSTR" + + def __init__(self) -> None: + self.queries: list[str] = [] + self.closed = 0 + + def record_event(self, _direction: str, _text: str) -> None: + pass + + def query(self, command: str, *, replay: ReplayPolicy = ReplayPolicy.NO_REPLAY) -> str: + assert replay is ReplayPolicy.NO_REPLAY + self.queries.append(command) + return "ok" + + def close(self) -> None: + self.closed += 1 + + +def _open_factory_descriptor() -> object: + return open_instrument_driver( + driver_reference="example.fft-status-v2", + expected_kind="scope", + resource="TCPIP::example::INSTR", + configured_backend="pyvisa", + timeout_ms=1_000, + opc_timeout_ms=2_000, + read_retry_attempts=1, + read_retry_delay_ms=0, + logger=CommandLogger(), + ) + + +def test_fft_status_v2_factory_latch_blocks_construction_io_until_validation( + monkeypatch: pytest.MonkeyPatch, +) -> None: + inner = _FactoryTransport() + errors: list[TransportIOError] = [] + + class Driver: + def idn(self) -> str: + return "EXAMPLE,EX1,SN-1,1.0" + + def close(self) -> None: + pass + + def get_fft_status_v2( + self, + math_index: int, + *, + configured_fft: bool, + ) -> ScopeFftStatusV2: + assert configured_fft is True + return _result(math_index) + + def factory(context): + transport = context.open_transport() + with pytest.raises(TransportIOError) as raised: + transport.query("*IDN?") + errors.append(raised.value) + return Driver() + + descriptor = replace(_descriptor(), factory=factory) + monkeypatch.setattr( + "wavebench.instruments.factory.resolve_instrument_descriptor", + lambda _reference, expected_kind: descriptor, + ) + monkeypatch.setattr("wavebench.instruments.factory._open_transport", lambda **_kwargs: inner) + + opened = _open_factory_descriptor() + + assert [error.reason_code for error in errors] == ["factory_construction_pending"] + assert errors[0].attempts == 0 + assert inner.queries == [] + assert opened.transport is not None + assert opened.transport.query("*IDN?") == "ok" + assert inner.queries == ["*IDN?"] + + +def test_fft_status_v2_factory_closes_missing_method_without_io( + monkeypatch: pytest.MonkeyPatch, +) -> None: + inner = _FactoryTransport() + + class MissingMethod: + def close(self) -> None: + pass + + descriptor = replace( + _descriptor(), + factory=lambda context: (context.open_transport(), MissingMethod())[1], + ) + monkeypatch.setattr( + "wavebench.instruments.factory.resolve_instrument_descriptor", + lambda _reference, expected_kind: descriptor, + ) + monkeypatch.setattr("wavebench.instruments.factory._open_transport", lambda **_kwargs: inner) + + with pytest.raises(ConfigError, match="get_fft_status_v2"): + _open_factory_descriptor() + + assert inner.queries == [] + assert inner.closed == 1 + + +def test_undeclared_fft_status_v2_method_does_not_latch_legacy_factory( + monkeypatch: pytest.MonkeyPatch, +) -> None: + inner = _FactoryTransport() + + class LegacyDriver: + def idn(self) -> str: + return "EXAMPLE,EX1,SN-1,1.0" + + def close(self) -> None: + pass + + def get_fft_status_v2( + self, + math_index: int, + *, + configured_fft: bool, + ) -> ScopeFftStatusV2: + raise AssertionError( + f"undeclared FFT V2 must not be called for {math_index}/{configured_fft}" + ) + + descriptor = replace( + _descriptor(capabilities=("scope.idn",), extensions=False), + factory=lambda context: (context.open_transport().query("*IDN?"), LegacyDriver())[1], + ) + monkeypatch.setattr( + "wavebench.instruments.factory.resolve_instrument_descriptor", + lambda _reference, expected_kind: descriptor, + ) + monkeypatch.setattr("wavebench.instruments.factory._open_transport", lambda **_kwargs: inner) + + _open_factory_descriptor() + + assert inner.queries == ["*IDN?"] diff --git a/tests/test_scope_input_state_v2.py b/tests/test_scope_input_state_v2.py new file mode 100644 index 0000000..64dac3b --- /dev/null +++ b/tests/test_scope_input_state_v2.py @@ -0,0 +1,485 @@ +from __future__ import annotations + +from dataclasses import asdict, replace +import io +import json +from contextlib import redirect_stdout +from types import SimpleNamespace +from unittest.mock import patch + +import pytest + +from wavebench.cli import main +from wavebench.errors import ConfigError, DataError, TransportIOError +from wavebench.instruments import ( + InstrumentDescriptor, + ScopeChannelInputStateDriverV2, + ScopeChannelInputStateV2, +) +from wavebench.instruments.capabilities import CAPABILITY_METHODS, validate_declared_capabilities +from wavebench.instruments.contracts import ScopeDriver +from wavebench.instruments.factory import open_instrument_driver +from wavebench.instruments.registry import build_instrument_registry +from wavebench.instruments.scope_extension_capabilities import ( + SCOPE_CAPABILITY_METHODS, + SCOPE_STRICT_V2_CAPABILITIES, + validate_scope_descriptor, +) +from wavebench.services.operation_specs import require_operation_spec +from wavebench.services.capability_explain import explain_operation +from wavebench.services.scope_service import ScopeService, assert_scope_input_state_safe +from wavebench.logging import CommandLogger +from wavebench.transport.contracts import ReplayPolicy + + +def _descriptor(*, minimum: str = "0.8.24", capabilities: tuple[str, ...] = ()) -> InstrumentDescriptor: + return InstrumentDescriptor( + driver_id="example.input-state", + kind="scope", + display_name="Example Scope", + manufacturer="Example", + models=("EX1",), + aliases=(), + capabilities=capabilities or ("scope.channel_input_state_v2",), + idn_patterns=("EXAMPLE,EX1",), + backends=("pyvisa",), + option_specs=(), + permissions=("instrument.io",), + factory=lambda _context: object(), + wavebench_min_version=minimum, + ) + + +def test_input_state_v2_serializes_available_and_unavailable_impedance() -> None: + available = ScopeChannelInputStateV2( + channel=1, + coupling="dc", + termination="high_z", + impedance_ohm=1_000_000.0, + ) + unavailable = ScopeChannelInputStateV2( + channel=2, + coupling="unknown", + termination="unknown", + impedance_ohm=None, + unavailable_fields=("impedance_ohm",), + ) + + assert asdict(available) == { + "channel": 1, + "coupling": "dc", + "termination": "high_z", + "impedance_ohm": 1_000_000.0, + "unavailable_fields": (), + } + assert asdict(unavailable) == { + "channel": 2, + "coupling": "unknown", + "termination": "unknown", + "impedance_ohm": None, + "unavailable_fields": ("impedance_ohm",), + } + + +@pytest.mark.parametrize( + ("changes", "error_type", "message"), + ( + ({"channel": True}, ValueError, "positive"), + ({"channel": 0}, ValueError, "positive"), + ({"coupling": "DC"}, ValueError, "coupling"), + ({"termination": "75_ohm"}, ValueError, "termination"), + ({"impedance_ohm": False}, ValueError, "finite positive"), + ({"impedance_ohm": 0.0}, ValueError, "finite positive"), + ({"impedance_ohm": float("nan")}, ValueError, "finite positive"), + ({"impedance_ohm": None, "unavailable_fields": ()}, ValueError, "marked unavailable"), + ({"impedance_ohm": None, "unavailable_fields": ("other",)}, ValueError, "marked unavailable"), + ({"unavailable_fields": ["impedance_ohm"]}, TypeError, "must be a tuple"), + ({"unavailable_fields": ("impedance_ohm",)}, ValueError, "available impedance"), + ), +) +def test_input_state_v2_rejects_invalid_or_ambiguous_values( + changes: dict[str, object], + error_type: type[Exception], + message: str, +) -> None: + values: dict[str, object] = { + "channel": 1, + "coupling": "ac", + "termination": "50_ohm", + "impedance_ohm": 50.0, + "unavailable_fields": (), + } + values.update(changes) + + with pytest.raises(error_type, match=message): + ScopeChannelInputStateV2(**values) # type: ignore[arg-type] + + +def test_input_state_v2_is_an_additive_protocol_and_registered_capability() -> None: + class Driver: + def idn(self) -> str: + return "EXAMPLE,EX1" + + def close(self) -> None: + pass + + def get_channel_input_state_v2(self, channel: int) -> ScopeChannelInputStateV2: + return ScopeChannelInputStateV2( + channel=channel, + coupling="dc", + termination="high_z", + impedance_ohm=1_000_000.0, + ) + + assert isinstance(Driver(), ScopeChannelInputStateDriverV2) + assert "get_channel_input_state_v2" not in ScopeDriver.__dict__ + assert SCOPE_CAPABILITY_METHODS["scope.channel_input_state_v2"] == ( + "get_channel_input_state_v2", + ) + assert CAPABILITY_METHODS["scope.channel_input_state_v2"] == ( + "get_channel_input_state_v2", + ) + assert "scope.channel_input_state_v2" in SCOPE_STRICT_V2_CAPABILITIES + + +def test_input_state_v2_descriptor_requires_its_own_core_floor_and_method() -> None: + descriptor = _descriptor(minimum="0.8.23") + with pytest.raises(ConfigError, match="scope portability V2 capabilities require.*0.8.24"): + validate_scope_descriptor(descriptor) + + class MissingMethod: + def close(self) -> None: + pass + + with pytest.raises(TypeError, match="get_channel_input_state_v2"): + validate_declared_capabilities(_descriptor(), MissingMethod()) + + +def test_input_state_v2_method_does_not_create_an_implicit_capability() -> None: + descriptor = _descriptor(capabilities=("scope.idn",)) + + class Driver: + def close(self) -> None: + pass + + def idn(self) -> str: + return "EXAMPLE,EX1" + + def get_channel_input_state_v2(self, channel: int) -> ScopeChannelInputStateV2: + raise AssertionError("unrequested capability must not be used") + + validate_declared_capabilities(descriptor, Driver()) + assert "scope.channel_input_state_v2" not in descriptor.capabilities + + +def test_input_state_v2_operation_is_a_stateful_exclusive_read() -> None: + spec = require_operation_spec("scope.channel_input_state_v2") + + assert spec.instrument_kind == "scope" + assert spec.required_capabilities == ("scope.channel_input_state_v2",) + assert spec.effect == "stateful_read" + assert spec.lease_mode == "exclusive" + + +def test_input_state_v2_service_reads_only_the_v2_driver_method() -> None: + expected = ScopeChannelInputStateV2( + channel=2, + coupling="dc", + termination="high_z", + impedance_ohm=1_000_000.0, + ) + calls: list[tuple[str, int]] = [] + driver = SimpleNamespace( + get_channel_input_state_v2=lambda channel: calls.append(("v2", channel)) or expected, + channel_coupling=lambda channel: (_ for _ in ()).throw( + AssertionError(f"legacy coupling was called for CH{channel}") + ), + ) + service = ScopeService( + config=SimpleNamespace(scope=SimpleNamespace(driver="example.input-state")), + logger=SimpleNamespace(), + session=driver, + descriptor=SimpleNamespace( + driver_id="example.input-state", + capabilities=("scope.channel_input_state_v2",), + ), + ) + + assert service.channel_input_state_v2(2) == expected + assert calls == [("v2", 2)] + + +@pytest.mark.parametrize( + ("termination", "allow_50ohm", "allowed"), + ( + ("high_z", False, True), + ("high_z", True, True), + ("50_ohm", False, False), + ("50_ohm", True, True), + ("unknown", False, False), + ("unknown", True, False), + ), +) +def test_input_state_v2_safety_policy_is_strict_and_separate_from_legacy_capture( + termination: str, + allow_50ohm: bool, + allowed: bool, +) -> None: + state = ScopeChannelInputStateV2( + channel=1, + coupling="dc", + termination=termination, # type: ignore[arg-type] + impedance_ohm=None, + unavailable_fields=("impedance_ohm",), + ) + + if allowed: + assert assert_scope_input_state_safe(state, allow_50ohm=allow_50ohm) is state + else: + with pytest.raises(ConfigError, match="50 ohm|unknown"): + assert_scope_input_state_safe(state, allow_50ohm=allow_50ohm) + + +def test_input_state_v2_service_rejects_invalid_or_wrong_channel_before_legacy_fallback() -> None: + service = ScopeService( + config=SimpleNamespace(scope=SimpleNamespace(driver="example.input-state")), + logger=SimpleNamespace(), + descriptor=SimpleNamespace( + driver_id="example.input-state", + capabilities=("scope.channel_input_state_v2",), + ), + ) + + with patch.object(service, "_open_scope") as open_scope: + with pytest.raises(ConfigError, match="positive integer"): + service.channel_input_state_v2(0) + open_scope.assert_not_called() + + service.session = SimpleNamespace( + get_channel_input_state_v2=lambda _channel: ScopeChannelInputStateV2( + channel=2, + coupling="dc", + termination="high_z", + impedance_ohm=1_000_000.0, + ) + ) + with pytest.raises(DataError, match="wrong channel"): + service.channel_input_state_v2(1) + + +def test_input_state_v2_does_not_change_legacy_high_impedance_gate() -> None: + legacy = build_instrument_registry(include_entry_points=False).resolve( + "rtm2032", + expected_kind="scope", + ) + descriptor = replace( + legacy, + capabilities=(*legacy.capabilities, "scope.channel_input_state_v2"), + ) + calls: list[tuple[str, int]] = [] + driver = SimpleNamespace( + channel_coupling=lambda channel: calls.append(("legacy", channel)) or "DCL", + get_channel_input_state_v2=lambda channel: calls.append(("v2", channel)) + or ScopeChannelInputStateV2( + channel=channel, + coupling="dc", + termination="high_z", + impedance_ohm=1_000_000.0, + ), + ) + service = ScopeService( + config=SimpleNamespace(scope=SimpleNamespace(driver="rtm2032")), + logger=SimpleNamespace(), + session=driver, + descriptor=descriptor, + ) + + assert service.require_high_impedance(1) == "DCL" + assert calls == [("legacy", 1)] + + +def test_input_state_v2_capability_explain_and_cli_output_are_additive() -> None: + descriptor = SimpleNamespace( + driver_id="example.input-state", + kind="scope", + capabilities=("scope.channel_input_state_v2",), + ) + explanation = explain_operation("scope.channel_input_state_v2", descriptor=descriptor) + assert explanation.status == "supported" + assert explanation.spec is not None + assert explanation.spec.effect == "stateful_read" + + expected = ScopeChannelInputStateV2( + channel=2, + coupling="gnd", + termination="unknown", + impedance_ohm=None, + unavailable_fields=("impedance_ohm",), + ) + calls: list[int] = [] + service = SimpleNamespace( + channel_input_state_v2=lambda channel: calls.append(channel) or expected, + ) + stdout = io.StringIO() + with patch("wavebench.cli._load_service", return_value=service), redirect_stdout(stdout): + code = main(["scope", "channel-input-state", "--channel", "2"]) + + assert code == 0 + assert calls == [2] + assert stdout.getvalue().splitlines() == [ + "input.channel=2", + "input.coupling=gnd", + "input.termination=unknown", + "input.impedance_ohm=n/a", + "input.unavailable_fields=impedance_ohm", + ] + + stdout = io.StringIO() + with patch("wavebench.cli._load_service", return_value=service), redirect_stdout(stdout): + code = main(["scope", "channel-input-state", "--channel", "2", "--json"]) + + assert code == 0 + payload = json.loads(stdout.getvalue()) + assert payload["schema"] == "wavebench.cli.result.v1" + assert payload["result"] == { + "channel": 2, + "coupling": "gnd", + "termination": "unknown", + "impedance_ohm": None, + "unavailable_fields": ["impedance_ohm"], + } + + +class _FactoryTransport: + resource = "TCPIP::example::INSTR" + + def __init__(self) -> None: + self.queries: list[str] = [] + self.closed = 0 + + def record_event(self, _direction: str, _text: str) -> None: + pass + + def query(self, command: str, *, replay: ReplayPolicy = ReplayPolicy.NO_REPLAY) -> str: + assert replay is ReplayPolicy.NO_REPLAY + self.queries.append(command) + return "ok" + + def close(self) -> None: + self.closed += 1 + + +def _open_factory_descriptor() -> object: + return open_instrument_driver( + driver_reference="example.input-state", + expected_kind="scope", + resource="TCPIP::example::INSTR", + configured_backend="pyvisa", + timeout_ms=1_000, + opc_timeout_ms=2_000, + read_retry_attempts=1, + read_retry_delay_ms=0, + logger=CommandLogger(), + ) + + +def test_input_state_v2_factory_latch_blocks_io_until_capability_validation( + monkeypatch: pytest.MonkeyPatch, +) -> None: + inner = _FactoryTransport() + errors: list[TransportIOError] = [] + + class Driver: + def idn(self) -> str: + return "EXAMPLE,EX1" + + def close(self) -> None: + pass + + def get_channel_input_state_v2(self, channel: int) -> ScopeChannelInputStateV2: + return ScopeChannelInputStateV2( + channel=channel, + coupling="dc", + termination="high_z", + impedance_ohm=1_000_000.0, + ) + + def factory(context): + transport = context.open_transport() + with pytest.raises(TransportIOError) as raised: + transport.query("*IDN?") + errors.append(raised.value) + return Driver() + + descriptor = replace(_descriptor(), factory=factory) + monkeypatch.setattr( + "wavebench.instruments.factory.resolve_instrument_descriptor", + lambda _reference, expected_kind: descriptor, + ) + monkeypatch.setattr("wavebench.instruments.factory._open_transport", lambda **_kwargs: inner) + + opened = _open_factory_descriptor() + + assert len(errors) == 1 + assert errors[0].reason_code == "factory_construction_pending" + assert errors[0].attempts == 0 + assert inner.queries == [] + assert opened.transport is not None + assert opened.transport.query("*IDN?") == "ok" + assert inner.queries == ["*IDN?"] + + +def test_input_state_v2_factory_closes_on_missing_method_without_io( + monkeypatch: pytest.MonkeyPatch, +) -> None: + inner = _FactoryTransport() + + class MissingMethod: + def close(self) -> None: + pass + + descriptor = replace( + _descriptor(), + factory=lambda context: (context.open_transport(), MissingMethod())[1], + ) + monkeypatch.setattr( + "wavebench.instruments.factory.resolve_instrument_descriptor", + lambda _reference, expected_kind: descriptor, + ) + monkeypatch.setattr("wavebench.instruments.factory._open_transport", lambda **_kwargs: inner) + + with pytest.raises(ConfigError, match="get_channel_input_state_v2"): + _open_factory_descriptor() + + assert inner.queries == [] + assert inner.closed == 1 + + +def test_extra_input_state_method_does_not_latch_legacy_factory( + monkeypatch: pytest.MonkeyPatch, +) -> None: + inner = _FactoryTransport() + + class LegacyDriver: + def idn(self) -> str: + return "EXAMPLE,EX1" + + def close(self) -> None: + pass + + def get_channel_input_state_v2(self, channel: int) -> ScopeChannelInputStateV2: + raise AssertionError("undeclared method must not be used") + + descriptor = replace( + _descriptor(capabilities=("scope.idn",)), + factory=lambda context: (context.open_transport().query("*IDN?"), LegacyDriver())[1], + ) + monkeypatch.setattr( + "wavebench.instruments.factory.resolve_instrument_descriptor", + lambda _reference, expected_kind: descriptor, + ) + monkeypatch.setattr("wavebench.instruments.factory._open_transport", lambda **_kwargs: inner) + + _open_factory_descriptor() + + assert inner.queries == ["*IDN?"] diff --git a/tests/test_scope_measurement_statistics_v2.py b/tests/test_scope_measurement_statistics_v2.py new file mode 100644 index 0000000..8e059d0 --- /dev/null +++ b/tests/test_scope_measurement_statistics_v2.py @@ -0,0 +1,656 @@ +from __future__ import annotations + +from dataclasses import asdict, replace +from types import SimpleNamespace + +import pytest + +from wavebench.errors import ConfigError, DataError, SessionHealthError, TransportIOError +from wavebench.instruments import ( + InstrumentDescriptor, + ScopeMeasurementSelector, + ScopeMeasurementStatisticsDriverV2, + ScopeMeasurementStatisticsRequestV2, + ScopeMeasurementStatisticsV2, +) +from wavebench.instruments.capabilities import CAPABILITY_METHODS, validate_declared_capabilities +from wavebench.instruments.contracts import ScopeMeasurementStatisticsDriver +from wavebench.instruments.factory import open_instrument_driver +from wavebench.instruments.models import ScopeMeasurementStatistics +from wavebench.instruments.scope_extension_capabilities import ( + SCOPE_CAPABILITY_METHODS, + SCOPE_STRICT_V2_CAPABILITIES, + validate_scope_descriptor, +) +from wavebench.instruments.scope_extensions import ( + ScopeDescriptorExtensions, + ScopeMeasurementStatisticsProfileV2, +) +from wavebench.logging import CommandLogger +from wavebench.services.operation_specs import ( + SCOPE_OPERATION_SPECS, + SCOPE_PORTABILITY_V2_OPERATION_SPECS, + require_operation_spec, +) +from wavebench.services.scope_service import ScopeService +from wavebench.transport.contracts import ReplayPolicy +from wavebench.transport.guarded import GuardedAuditedTransport + + +def _slot_selector(slot: int = 1) -> ScopeMeasurementSelector: + return ScopeMeasurementSelector(slot=slot) + + +def _item_selector() -> ScopeMeasurementSelector: + return ScopeMeasurementSelector(item="VPP", sources=("CH1",)) + + +def _slot_request(slot: int = 1, *, include_buffer: bool = False) -> ScopeMeasurementStatisticsRequestV2: + return ScopeMeasurementStatisticsRequestV2( + selector=_slot_selector(slot), + configured=True, + include_buffer=include_buffer, + ) + + +def _item_request() -> ScopeMeasurementStatisticsRequestV2: + return ScopeMeasurementStatisticsRequestV2( + selector=_item_selector(), + configured=True, + ) + + +def _result( + request: ScopeMeasurementStatisticsRequestV2, +) -> ScopeMeasurementStatisticsV2: + return ScopeMeasurementStatisticsV2( + selector=request.selector, + category="VOLTAGE", + actual=1.0, + average=1.1, + standard_deviation=0.1, + minimum=0.9, + maximum=1.2, + waveform_count=16, + ) + + +def _slot_profile(max_queries: int = 1) -> ScopeMeasurementStatisticsProfileV2: + return ScopeMeasurementStatisticsProfileV2( + selector_modes=("slot",), + max_queries=max_queries, + slot_range=(1, 4), + ) + + +def _item_profile(max_queries: int = 6) -> ScopeMeasurementStatisticsProfileV2: + return ScopeMeasurementStatisticsProfileV2( + selector_modes=("item_sources",), + max_queries=max_queries, + supported_items=("VPP", "VRMS"), + item_source_count_range=(1, 2), + ) + + +def _descriptor( + *, + profile: ScopeMeasurementStatisticsProfileV2 | None = None, + minimum: str = "0.8.24", + capabilities: tuple[str, ...] = (), + extensions: bool = True, +) -> InstrumentDescriptor: + return InstrumentDescriptor( + driver_id="example.statistics-v2", + kind="scope", + display_name="Example scope", + manufacturer="Example", + models=("EX1",), + aliases=(), + capabilities=capabilities or ("scope.measurement_statistics_v2",), + idn_patterns=("EXAMPLE,EX1",), + backends=("pyvisa",), + option_specs=(), + permissions=("instrument.io",), + factory=lambda _context: object(), + wavebench_min_version=minimum, + scope_extensions=( + ScopeDescriptorExtensions( + measurement_statistics_profile_v2=profile or _slot_profile(), + ) + if extensions + else None + ), + ) + + +def test_measurement_statistics_v2_models_preserve_slot_and_item_source_selectors() -> None: + slot_request = _slot_request(2) + item_request = _item_request() + result = _result(item_request) + + assert slot_request.selector.mode == "slot" + assert item_request.selector.mode == "item_sources" + assert asdict(result) == { + "selector": {"slot": None, "item": "VPP", "sources": ("CH1",)}, + "category": "VOLTAGE", + "actual": 1.0, + "average": 1.1, + "standard_deviation": 0.1, + "minimum": 0.9, + "maximum": 1.2, + "waveform_count": 16, + "buffered_values": None, + } + + +@pytest.mark.parametrize( + ("factory", "error_type", "message"), + ( + (lambda: ScopeMeasurementSelector(), ValueError, "safe item token"), + (lambda: ScopeMeasurementSelector(slot=True), ValueError, "positive integer"), + (lambda: ScopeMeasurementSelector(slot=1, item="VPP"), ValueError, "cannot include"), + (lambda: ScopeMeasurementSelector(item="VPP", sources=()), ValueError, "at least one"), + ( + lambda: ScopeMeasurementSelector(item="VPP", sources=("CH1", "CH1")), + ValueError, + "unique", + ), + ( + lambda: ScopeMeasurementStatisticsRequestV2( + selector=_slot_selector(), + configured=False, + ), + ValueError, + "configured=True", + ), + ( + lambda: ScopeMeasurementStatisticsRequestV2( + selector=_slot_selector(), + configured=True, + include_buffer=1, # type: ignore[arg-type] + ), + TypeError, + "include_buffer", + ), + ( + lambda: ScopeMeasurementStatisticsV2( + selector=_slot_selector(), + category="VOLTAGE", + actual=float("nan"), + average=1.0, + standard_deviation=0.0, + minimum=1.0, + maximum=1.0, + waveform_count=1, + ), + ValueError, + "actual", + ), + ( + lambda: ScopeMeasurementStatisticsV2( + selector=_slot_selector(), + category="VOLTAGE", + actual=1.0, + average=1.0, + standard_deviation=0.0, + minimum=1.0, + maximum=1.0, + waveform_count=True, + ), + ValueError, + "waveform_count", + ), + ), +) +def test_measurement_statistics_v2_models_reject_ambiguous_or_partial_values( + factory, + error_type: type[Exception], + message: str, +) -> None: + with pytest.raises(error_type, match=message): + factory() + + +@pytest.mark.parametrize( + ("kwargs", "message"), + ( + ({"selector_modes": ()}, "invalid"), + ({"selector_modes": ("item_sources", "slot")}, "stable mode order"), + ({"selector_modes": ("slot",), "slot_range": None}, "two-integer"), + ( + { + "selector_modes": ("item_sources",), + "slot_range": None, + "supported_items": (), + }, + "requires supported_items", + ), + ( + { + "selector_modes": ("item_sources",), + "slot_range": None, + "supported_items": ("VPP",), + "item_source_count_range": None, + }, + "two-integer", + ), + ({"supports_buffer": True}, "does not support"), + ({"max_queries": True}, "integer"), + ({"max_queries": 33}, "1..32"), + ), +) +def test_measurement_statistics_v2_profile_rejects_invalid_static_contract( + kwargs: dict[str, object], + message: str, +) -> None: + values: dict[str, object] = { + "selector_modes": ("slot",), + "max_queries": 1, + "slot_range": (1, 4), + } + values.update(kwargs) + with pytest.raises((TypeError, ValueError), match=message): + ScopeMeasurementStatisticsProfileV2(**values) # type: ignore[arg-type] + + +def test_measurement_statistics_v2_profile_preflights_selectors_and_rejects_buffers() -> None: + profile = _item_profile() + + profile.validate_request(_item_request()) + with pytest.raises(ValueError, match="selector mode"): + profile.validate_request(_slot_request()) + with pytest.raises(ValueError, match="outside the descriptor profile"): + profile.validate_request( + ScopeMeasurementStatisticsRequestV2( + selector=ScopeMeasurementSelector(item="FREQ", sources=("CH1",)), + configured=True, + ) + ) + with pytest.raises(ValueError, match="does not support statistics buffers"): + _slot_profile().validate_request(_slot_request(include_buffer=True)) + + +def test_measurement_statistics_v2_profile_requires_result_selector_echo_and_no_buffer() -> None: + request = _item_request() + profile = _item_profile() + + profile.validate_result(_result(request), request=request) + with pytest.raises(ValueError, match="does not match request"): + profile.validate_result(_result(_slot_request()), request=request) + with pytest.raises(ValueError, match="must not include a buffer"): + profile.validate_result( + replace(_result(request), buffered_values=(1.0,)), + request=request, + ) + + +def test_measurement_statistics_v2_is_additive_and_registered_without_r13_membership() -> None: + class Driver: + def idn(self) -> str: + return "EXAMPLE,EX1,SN-1,1.0" + + def close(self) -> None: + pass + + def get_measurement_statistics_v2( + self, + request: ScopeMeasurementStatisticsRequestV2, + ) -> ScopeMeasurementStatisticsV2: + return _result(request) + + assert isinstance(Driver(), ScopeMeasurementStatisticsDriverV2) + assert "get_measurement_statistics_v2" not in ScopeMeasurementStatisticsDriver.__dict__ + assert SCOPE_CAPABILITY_METHODS["scope.measurement_statistics_v2"] == ( + "get_measurement_statistics_v2", + ) + assert CAPABILITY_METHODS["scope.measurement_statistics_v2"] == ( + "get_measurement_statistics_v2", + ) + assert "scope.measurement_statistics_v2" in SCOPE_STRICT_V2_CAPABILITIES + assert "scope.measurement_statistics_v2" not in SCOPE_OPERATION_SPECS + spec = SCOPE_PORTABILITY_V2_OPERATION_SPECS["scope.measurement_statistics_v2"] + assert require_operation_spec("scope.measurement_statistics_v2") is spec + assert spec.effect == "stateful_read" + assert spec.lease_mode == "exclusive" + assert spec.required_verified_fields == () + assert spec.error_check_minimum == "disabled" + + +def test_measurement_statistics_v2_descriptor_requires_profile_core_floor_and_callable_method() -> None: + with pytest.raises(ConfigError, match="scope portability V2 capabilities require.*0.8.24"): + validate_scope_descriptor(_descriptor(minimum="0.8.23")) + with pytest.raises(ConfigError, match="measurement_statistics_profile_v2"): + validate_scope_descriptor(_descriptor(extensions=False)) + + class MissingMethod: + def close(self) -> None: + pass + + with pytest.raises(TypeError, match="get_measurement_statistics_v2"): + validate_declared_capabilities(_descriptor(), MissingMethod()) + + +def test_measurement_statistics_v2_method_does_not_create_an_implicit_capability() -> None: + descriptor = _descriptor(capabilities=("scope.idn",), extensions=False) + + class Driver: + def idn(self) -> str: + return "EXAMPLE,EX1,SN-1,1.0" + + def close(self) -> None: + pass + + def get_measurement_statistics_v2( + self, + request: ScopeMeasurementStatisticsRequestV2, + ) -> ScopeMeasurementStatisticsV2: + raise AssertionError(f"undeclared V2 method must not be called for {request!r}") + + validate_declared_capabilities(descriptor, Driver()) + assert "scope.measurement_statistics_v2" not in descriptor.capabilities + + +def _service( + *, + profile: ScopeMeasurementStatisticsProfileV2, + driver: object, + transport: GuardedAuditedTransport | None = None, +) -> ScopeService: + return ScopeService( + config=SimpleNamespace( + scope=SimpleNamespace(driver="example.statistics-v2"), + connection=SimpleNamespace(timeout_ms=1_000), + ), + logger=SimpleNamespace(), + session=driver, + descriptor=SimpleNamespace( + driver_id="example.statistics-v2", + capabilities=("scope.measurement_statistics_v2",), + scope_extensions=ScopeDescriptorExtensions( + measurement_statistics_profile_v2=profile, + ), + ), + transport=transport, + session_state=None if transport is None else transport.session_state, + ) + + +def test_measurement_statistics_v2_service_only_calls_v2_and_keeps_legacy_route_separate() -> None: + request = _slot_request() + calls: list[str] = [] + driver = SimpleNamespace( + get_measurement_statistics_v2=lambda received: calls.append("v2") + or _result(received), + get_measurement_statistics=lambda *_args, **_kwargs: (_ for _ in ()).throw( + AssertionError("legacy statistics must stay separate") + ), + ) + service = _service(profile=_slot_profile(), driver=driver) + + assert service.measurement_statistics_v2(request) == _result(request) + assert calls == ["v2"] + + legacy = ScopeMeasurementStatistics( + slot=1, + category="VOLTAGE", + actual=None, + average=None, + standard_deviation=None, + minimum=None, + maximum=None, + waveform_count=0, + ) + driver.get_measurement_statistics = lambda *_args, **_kwargs: calls.append("legacy") or legacy + service.descriptor = SimpleNamespace( + driver_id="example.statistics-v2", + capabilities=("scope.measurement_statistics", "scope.measurement_statistics_v2"), + scope_extensions=ScopeDescriptorExtensions( + measurement_statistics_profile_v2=_slot_profile(), + ), + ) + assert service.measurement_statistics(1, configured_slot=True) is legacy + assert calls == ["v2", "legacy"] + + +def test_measurement_statistics_v2_profile_rejects_before_opening_scope() -> None: + service = _service( + profile=_slot_profile(), + driver=SimpleNamespace(get_measurement_statistics_v2=lambda request: _result(request)), + ) + service._open_scope = lambda: pytest.fail("unsupported V2 request must not open scope") + + with pytest.raises(ConfigError, match="selector mode"): + service.measurement_statistics_v2(_item_request()) + with pytest.raises(ConfigError, match="does not support statistics buffers"): + service.measurement_statistics_v2(_slot_request(include_buffer=True)) + + +def test_measurement_statistics_v2_service_rejects_invalid_result_without_fallback() -> None: + request = _slot_request() + service = _service( + profile=_slot_profile(), + driver=SimpleNamespace(get_measurement_statistics_v2=lambda _request: object()), + ) + + with pytest.raises(DataError, match="invalid result"): + service.measurement_statistics_v2(request) + + service.session = SimpleNamespace( + get_measurement_statistics_v2=lambda received: replace( + _result(received), + buffered_values=(1.0,), + ) + ) + with pytest.raises(DataError, match="must not include a buffer"): + service.measurement_statistics_v2(request) + + +class _InnerTransport: + resource = "TCPIP::example::INSTR" + + def __init__(self) -> None: + self.queries: list[str] = [] + self.writes: list[str] = [] + + def record_event(self, _direction: str, _text: str) -> None: + pass + + def query(self, command: str, *, replay: ReplayPolicy = ReplayPolicy.NO_REPLAY) -> str: + assert replay is ReplayPolicy.NO_REPLAY + self.queries.append(command) + return "1.0" + + def write(self, command: str) -> None: + self.writes.append(command) + + def close(self) -> None: + pass + + +def test_measurement_statistics_v2_uses_only_budgeted_text_queries_without_identity_preflight() -> None: + request = _slot_request() + inner = _InnerTransport() + transport = GuardedAuditedTransport(inner) + + class Driver: + def idn(self) -> str: + raise AssertionError("statistics V2 must not call legacy identity preflight") + + def get_measurement_statistics_v2( + self, + received: ScopeMeasurementStatisticsRequestV2, + ) -> ScopeMeasurementStatisticsV2: + assert received == request + transport.query("MEAS:CURRENT?") + return _result(received) + + service = _service(profile=_slot_profile(max_queries=1), driver=Driver(), transport=transport) + + assert service.measurement_statistics_v2(request) == _result(request) + assert inner.queries == ["MEAS:CURRENT?"] + assert inner.writes == [] + assert transport.counters.query_calls == 1 + + +@pytest.mark.parametrize("mode", ("overrun", "write")) +def test_measurement_statistics_v2_rejects_overrun_and_non_query_io_before_backend_send( + mode: str, +) -> None: + request = _slot_request() + inner = _InnerTransport() + transport = GuardedAuditedTransport(inner) + + class Driver: + def get_measurement_statistics_v2( + self, + received: ScopeMeasurementStatisticsRequestV2, + ) -> ScopeMeasurementStatisticsV2: + assert received == request + transport.query("MEAS:CURRENT?") + if mode == "overrun": + transport.query("MEAS:EXTRA?") + else: + transport.write("MEAS:BAD") + return _result(received) + + service = _service(profile=_slot_profile(max_queries=1), driver=Driver(), transport=transport) + + with pytest.raises(SessionHealthError): + service.measurement_statistics_v2(request) + assert inner.queries == ["MEAS:CURRENT?"] + assert inner.writes == [] + + +class _FactoryTransport: + resource = "TCPIP::example::INSTR" + + def __init__(self) -> None: + self.queries: list[str] = [] + self.closed = 0 + + def record_event(self, _direction: str, _text: str) -> None: + pass + + def query(self, command: str, *, replay: ReplayPolicy = ReplayPolicy.NO_REPLAY) -> str: + assert replay is ReplayPolicy.NO_REPLAY + self.queries.append(command) + return "ok" + + def close(self) -> None: + self.closed += 1 + + +def _open_factory_descriptor() -> object: + return open_instrument_driver( + driver_reference="example.statistics-v2", + expected_kind="scope", + resource="TCPIP::example::INSTR", + configured_backend="pyvisa", + timeout_ms=1_000, + opc_timeout_ms=2_000, + read_retry_attempts=1, + read_retry_delay_ms=0, + logger=CommandLogger(), + ) + + +def test_measurement_statistics_v2_factory_latch_blocks_construction_io_until_validation( + monkeypatch: pytest.MonkeyPatch, +) -> None: + inner = _FactoryTransport() + errors: list[TransportIOError] = [] + + class Driver: + def idn(self) -> str: + return "EXAMPLE,EX1,SN-1,1.0" + + def close(self) -> None: + pass + + def get_measurement_statistics_v2( + self, + request: ScopeMeasurementStatisticsRequestV2, + ) -> ScopeMeasurementStatisticsV2: + return _result(request) + + def factory(context): + transport = context.open_transport() + with pytest.raises(TransportIOError) as raised: + transport.query("*IDN?") + errors.append(raised.value) + return Driver() + + descriptor = replace(_descriptor(), factory=factory) + monkeypatch.setattr( + "wavebench.instruments.factory.resolve_instrument_descriptor", + lambda _reference, expected_kind: descriptor, + ) + monkeypatch.setattr("wavebench.instruments.factory._open_transport", lambda **_kwargs: inner) + + opened = _open_factory_descriptor() + + assert [error.reason_code for error in errors] == ["factory_construction_pending"] + assert errors[0].attempts == 0 + assert inner.queries == [] + assert opened.transport is not None + assert opened.transport.query("*IDN?") == "ok" + assert inner.queries == ["*IDN?"] + + +def test_measurement_statistics_v2_factory_closes_missing_method_without_io( + monkeypatch: pytest.MonkeyPatch, +) -> None: + inner = _FactoryTransport() + + class MissingMethod: + def close(self) -> None: + pass + + descriptor = replace( + _descriptor(), + factory=lambda context: (context.open_transport(), MissingMethod())[1], + ) + monkeypatch.setattr( + "wavebench.instruments.factory.resolve_instrument_descriptor", + lambda _reference, expected_kind: descriptor, + ) + monkeypatch.setattr("wavebench.instruments.factory._open_transport", lambda **_kwargs: inner) + + with pytest.raises(ConfigError, match="get_measurement_statistics_v2"): + _open_factory_descriptor() + + assert inner.queries == [] + assert inner.closed == 1 + + +def test_undeclared_measurement_statistics_v2_method_does_not_latch_legacy_factory( + monkeypatch: pytest.MonkeyPatch, +) -> None: + inner = _FactoryTransport() + + class LegacyDriver: + def idn(self) -> str: + return "EXAMPLE,EX1,SN-1,1.0" + + def close(self) -> None: + pass + + def get_measurement_statistics_v2( + self, + request: ScopeMeasurementStatisticsRequestV2, + ) -> ScopeMeasurementStatisticsV2: + raise AssertionError(f"undeclared statistics V2 must not be called for {request!r}") + + descriptor = replace( + _descriptor(capabilities=("scope.idn",), extensions=False), + factory=lambda context: (context.open_transport().query("*IDN?"), LegacyDriver())[1], + ) + monkeypatch.setattr( + "wavebench.instruments.factory.resolve_instrument_descriptor", + lambda _reference, expected_kind: descriptor, + ) + monkeypatch.setattr("wavebench.instruments.factory._open_transport", lambda **_kwargs: inner) + + _open_factory_descriptor() + + assert inner.queries == ["*IDN?"] diff --git a/tests/test_scope_phase_coordinator.py b/tests/test_scope_phase_coordinator.py index 398ff71..8d368e1 100644 --- a/tests/test_scope_phase_coordinator.py +++ b/tests/test_scope_phase_coordinator.py @@ -6,6 +6,7 @@ from wavebench.services.scope_phase_coordinator import ( BaselineUseState, OperationPhase, + ScopeBinaryLimits, ScopeOperationContextCoordinator, ) from wavebench.transport.contracts import ReplayPolicy @@ -269,6 +270,43 @@ def test_context_reserves_cleanup_time_inside_hard_operation_deadline() -> None: assert 1_000 <= context.cleanup_reserve_ms <= 5_000 +def test_context_intersects_spec_profile_and_connection_binary_limits() -> None: + transport = GuardedAuditedTransport(_TextTransport()) # type: ignore[arg-type] + context = ScopeOperationContextCoordinator( + session_state=transport.session_state, + spec=EXPERIMENTAL_SCOPE_OPERATION_SPECS["scope.fetch_trace"], + connection_timeout_ms=1_000, + profile_binary_limits=ScopeBinaryLimits( + response_max_bytes=4_096, + operation_max_bytes=16_384, + query_max_count=8, + resynchronization_max_bytes=512, + ), + connection_binary_limits=ScopeBinaryLimits( + response_max_bytes=2_048, + operation_max_bytes=8_192, + query_max_count=4, + resynchronization_max_bytes=256, + ), + enabled=True, + ) + + assert context.binary_ledger is not None + assert context.binary_ledger.snapshot() == { + "ledger_id": context.binary_ledger.ledger_id, + "active": True, + "per_response_max_bytes": 2_048, + "operation_max_bytes": 8_192, + "remaining_operation_bytes": 8_192, + "query_max_count": 4, + "remaining_query_count": 4, + "resynchronization_max_bytes": 256, + "discarded_bytes": 0, + "transport_trailing_bytes": 0, + "required_framing": None, + } + + def test_main_cannot_skip_preflight() -> None: _, context = _context() main = context.make_phase_spec( diff --git a/tests/test_scope_portability_m0.py b/tests/test_scope_portability_m0.py new file mode 100644 index 0000000..42002c6 --- /dev/null +++ b/tests/test_scope_portability_m0.py @@ -0,0 +1,302 @@ +from __future__ import annotations + +from pathlib import Path + +import numpy as np +import pytest + +from wavebench.config import ( + AutoscaleConfig, + ConnectionConfig, + OutputConfig, + ScopeConfig, + WaveBenchConfig, + WaveformConfig, +) +from wavebench.errors import ConfigError +from wavebench.instruments.api import InstrumentDescriptor +from wavebench.instruments.models import WaveformData, WaveformHeader +from wavebench.instruments.registry import InstrumentRegistry, build_instrument_registry +from wavebench.instruments.scope_extensions import ( + ScopeDescriptorExtensions, + ScopeWaveformBinaryOperationProfile, + ScopeWaveformBinaryProfile, +) +from wavebench.logging import CommandLogger +from wavebench.services.capability_explain import explain_operation +from wavebench.services.scope_service import ScopeService + + +_LEGACY_SCOPE_CAPABILITIES = ( + "scope.idn", + "scope.errors", + "scope.autoscale", + "scope.fetch_waveform", + "scope.capture_waveform", + "scope.capture_waveforms", + "scope.screenshot", + "scope.channel_coupling", +) +_V2_SCOPE_CAPABILITIES = frozenset( + { + "scope.screenshot_profile", + "scope.screenshot_v2", + "scope.acquisition_run_state", + "scope.acquisition_control", + "scope.trace_metadata", + "scope.fetch_trace", + "scope.error_drain_v1", + "scope.channel_input_state_v2", + "scope.digital_status_v2", + "scope.snapshot_v2", + "scope.acquisition_status_v2", + "scope.capture_average_v2", + "scope.measurement_statistics_v2", + "scope.fft_status_v2", + "scope.cursor_readout_v2", + } +) + + +@pytest.mark.parametrize( + ("canonical", "aliases", "backend", "coupling_policy"), + ( + ("rohde-schwarz.rtm2032", ("rtm2032",), "rsinstrument", "switchable-termination"), + ("rigol.ds1104", ("ds1104", "ds1000z"), "pyvisa", "fixed-high-impedance"), + ), +) +def test_m0_builtin_scope_descriptors_keep_the_legacy_golden_contract( + canonical: str, + aliases: tuple[str, ...], + backend: str, + coupling_policy: str, +) -> None: + registry = build_instrument_registry(include_entry_points=False) + expected = registry.resolve(canonical, expected_kind="scope") + + for reference in (canonical, *aliases): + descriptor = registry.resolve(reference, expected_kind="scope") + + assert descriptor == expected + assert descriptor.driver_id == canonical + assert descriptor.capabilities == _LEGACY_SCOPE_CAPABILITIES + assert descriptor.backends == (backend,) + assert descriptor.scope_coupling_policy == coupling_policy + assert descriptor.scope_extensions is None + assert descriptor.wavebench_min_version == "0.8.0" + assert descriptor.wavebench_max_version == "0.9.0" + assert not (set(descriptor.capabilities) & _V2_SCOPE_CAPABILITIES) + + for operation in ("scope.fetch_waveform", "scope.capture", "scope.capture_multiple"): + explanation = explain_operation(operation, descriptor=descriptor) + assert explanation.status == "supported" + assert explanation.missing_capabilities == () + + +class _LegacyRoutingScope: + def __init__(self) -> None: + self.calls: list[str] = [] + + def idn(self) -> str: + self.calls.append("idn") + return "EXAMPLE,SCOPE,SERIAL,FIRMWARE" + + def fetch_waveform(self, *, channel: int, points: str, check_errors: bool) -> WaveformData: + self.calls.append("fetch_waveform") + assert channel == 1 + assert points == "DEF" + assert check_errors is False + return _waveform(channel) + + def capture_waveform( + self, + *, + channel: int, + points: str, + check_errors: bool, + time_range_s: float | None, + ) -> WaveformData: + self.calls.append("capture_waveform") + assert channel == 1 + assert points == "DEF" + assert check_errors is False + assert time_range_s is None + return _waveform(channel) + + def close(self) -> None: + pass + + +@pytest.mark.parametrize("reference", ("rtm2032", "ds1104", "ds1000z")) +def test_m0_legacy_scope_service_routes_standard_waveform_operations_to_legacy_driver( + tmp_path: Path, + reference: str, +) -> None: + descriptor = build_instrument_registry(include_entry_points=False).resolve( + reference, + expected_kind="scope", + ) + driver = _LegacyRoutingScope() + service = ScopeService( + config=_scope_config(tmp_path, driver=reference), + logger=CommandLogger(), + session=driver, + descriptor=descriptor, + ) + + assert service._waveform_binary_profile() is None + assert service.fetch_waveform(1).channel == 1 + assert service.capture_waveform(channel=1, label="m0-legacy").waveform.channel == 1 + assert driver.calls == ["fetch_waveform", "idn", "capture_waveform"] + + +class _ExternalEntryPoint: + group = "wavebench.instruments" + + def __init__(self, name: str, descriptor: InstrumentDescriptor) -> None: + self.name = name + self.descriptor = descriptor + self.load_count = 0 + self.dist = None + + def load(self) -> InstrumentDescriptor: + self.load_count += 1 + return self.descriptor + + +def _external_scope_descriptor(*, bounded: bool, factory_calls: list[str]) -> InstrumentDescriptor: + profile = _bounded_profile() if bounded else None + capabilities = ( + ("scope.idn", "scope.fetch_waveform") if bounded else ("scope.idn", "scope.fetch_waveform") + ) + + def factory(_context: object) -> object: + factory_calls.append("factory") + raise AssertionError("descriptor resolution must not instantiate a driver") + + return InstrumentDescriptor( + driver_id="example.scope-plugin", + kind="scope", + display_name="Example external scope", + manufacturer="Example", + models=("EX1",), + aliases=(), + capabilities=capabilities, + idn_patterns=("EXAMPLE,EX1",), + backends=("pyvisa",), + option_specs=(), + permissions=("instrument.io",), + factory=factory, + distribution="wavebench-example-scope", + wavebench_min_version="0.8.24" if bounded else "0.8.0", + wavebench_max_version="0.9.0", + scope_extensions=( + ScopeDescriptorExtensions(waveform_binary_profile=profile) if profile is not None else None + ), + ) + + +def _bounded_profile() -> ScopeWaveformBinaryProfile: + return ScopeWaveformBinaryProfile( + operations=( + ScopeWaveformBinaryOperationProfile( + operation_kind="fetch", + response_max_bytes=1_024, + operation_max_bytes=4_096, + query_max_count=4, + resynchronization_max_bytes=0, + restore_order=("scope.waveform_source",), + snapshot_max_steps=1, + restore_max_steps=1, + verify_max_steps=1, + ), + ) + ) + + +@pytest.mark.parametrize( + ("core_version", "bounded_plugin", "loads"), + ( + ("0.8.23", False, True), + ("0.8.24", False, True), + ("0.8.23", True, False), + ("0.8.24", True, True), + ), + ids=("old-core-old-plugin", "new-core-old-plugin", "old-core-new-plugin", "new-core-new-plugin"), +) +def test_m0_core_plugin_compatibility_matrix_is_resolved_before_factory_or_io( + monkeypatch: pytest.MonkeyPatch, + core_version: str, + bounded_plugin: bool, + loads: bool, +) -> None: + factory_calls: list[str] = [] + descriptor = _external_scope_descriptor( + bounded=bounded_plugin, + factory_calls=factory_calls, + ) + entry_point = _ExternalEntryPoint(descriptor.driver_id, descriptor) + registry = InstrumentRegistry(builtins=(), external_entry_points=(entry_point,)) + monkeypatch.setattr("wavebench.instruments.registry.__version__", core_version) + + if not loads: + with pytest.raises(ConfigError, match="supports WaveBench >=0.8.24, <0.9.0"): + registry.resolve(descriptor.driver_id, expected_kind="scope") + assert entry_point.load_count == 1 + assert factory_calls == [] + return + + resolved = registry.resolve(descriptor.driver_id, expected_kind="scope") + + assert entry_point.load_count == 1 + assert factory_calls == [] + assert resolved.scope_extensions is not None if bounded_plugin else resolved.scope_extensions is None + if bounded_plugin: + assert resolved.capabilities == ("scope.idn", "scope.fetch_waveform") + assert resolved.scope_extensions.waveform_binary_profile is not None + assert { + "scope.error_drain_v1", + "scope.screenshot_v2", + "scope.capture_waveform", + "scope.capture_waveforms", + }.isdisjoint(resolved.capabilities) + else: + assert resolved.scope_extensions is None + + +def _waveform(channel: int) -> WaveformData: + return WaveformData( + channel=channel, + header=WaveformHeader(x_start=0.0, x_stop=0.002, points=3, segment=1), + voltages_v=np.array([0.0, 1.0, 0.0], dtype=np.float64), + ) + + +def _scope_config(tmp_path: Path, *, driver: str) -> WaveBenchConfig: + return WaveBenchConfig( + connection=ConnectionConfig( + backend="lan", + resource="TCPIP::example::INSTR", + timeout_ms=1_000, + opc_timeout_ms=2_000, + ), + scope=ScopeConfig( + driver=driver, + model_hint=None, + default_channel=1, + reset_before_run=False, + check_errors=False, + ), + autoscale=AutoscaleConfig(wait_opc=True, check_errors=False), + waveform=WaveformConfig(format="real", byte_order="lsbf", points="DEF"), + output=OutputConfig( + directory=tmp_path, + package_naming="timestamp_label", + save_csv=False, + save_npy=False, + save_json=False, + save_commands_log=False, + save_screenshot=False, + ), + source_path=tmp_path / "wavebench.toml", + ) diff --git a/tests/test_scope_snapshot_v2.py b/tests/test_scope_snapshot_v2.py new file mode 100644 index 0000000..d5b74d5 --- /dev/null +++ b/tests/test_scope_snapshot_v2.py @@ -0,0 +1,646 @@ +from __future__ import annotations + +from dataclasses import asdict, replace +from types import SimpleNamespace + +import pytest + +from wavebench.errors import ConfigError, DataError, SessionHealthError, TransportIOError +from wavebench.instruments import ( + InstrumentDescriptor, + SCOPE_SNAPSHOT_V2_FIELD_ORDER, + ScopeAnalogChannelSnapshotV2, + ScopeHealthSnapshotV2, + ScopeIdentitySnapshot, + ScopeSnapshotDriverV2, + ScopeSnapshotV2, +) +from wavebench.instruments.capabilities import CAPABILITY_METHODS, validate_declared_capabilities +from wavebench.instruments.contracts import ScopeSnapshotDriver +from wavebench.instruments.factory import open_instrument_driver +from wavebench.instruments.scope_extension_capabilities import ( + SCOPE_CAPABILITY_METHODS, + SCOPE_STRICT_V2_CAPABILITIES, + validate_scope_descriptor, +) +from wavebench.instruments.scope_extensions import ( + ScopeDescriptorExtensions, + ScopeSnapshotProfileV2, +) +from wavebench.logging import CommandLogger +from wavebench.services.capability_explain import explain_operation +from wavebench.services.operation_specs import ( + SCOPE_OPERATION_SPECS, + SCOPE_PORTABILITY_V2_OPERATION_SPECS, + require_operation_spec, +) +from wavebench.services.scope_service import ScopeService +from wavebench.transport.contracts import ReplayPolicy +from wavebench.transport.guarded import GuardedAuditedTransport + + +_IDENTITY_FIELDS = ( + "identity.manufacturer", + "identity.model", + "identity.serial_number", + "identity.firmware", + "identity.options", +) + + +def _profile( + *, + readable_fields: tuple[str, ...] = _IDENTITY_FIELDS, + max_queries: int = 1, + conditionally_applicable_fields: tuple[str, ...] = (), +) -> ScopeSnapshotProfileV2: + return ScopeSnapshotProfileV2( + readable_fields=readable_fields, # type: ignore[arg-type] + max_queries=max_queries, + conditionally_applicable_fields=conditionally_applicable_fields, # type: ignore[arg-type] + ) + + +def _descriptor( + *, + profile: ScopeSnapshotProfileV2 | None = None, + minimum: str = "0.8.24", + capabilities: tuple[str, ...] = (), + extensions: bool = True, +) -> InstrumentDescriptor: + return InstrumentDescriptor( + driver_id="example.snapshot-v2", + kind="scope", + display_name="Example scope", + manufacturer="Example", + models=("EX1",), + aliases=(), + capabilities=capabilities or ("scope.snapshot_v2",), + idn_patterns=("EXAMPLE,EX1",), + backends=("pyvisa",), + option_specs=(), + permissions=("instrument.io",), + factory=lambda _context: object(), + wavebench_min_version=minimum, + scope_extensions=( + ScopeDescriptorExtensions(snapshot_profile_v2=profile or _profile()) + if extensions + else None + ), + ) + + +def _identity() -> ScopeIdentitySnapshot: + return ScopeIdentitySnapshot( + manufacturer="Example", + model="EX1", + serial_number="SN-1", + firmware="1.0", + options=("OPT1",), + ) + + +def _unavailable(*available: str, not_applicable: tuple[str, ...] = ()) -> tuple[str, ...]: + available_set = set(available) + not_applicable_set = set(not_applicable) + return tuple( + field_name + for field_name in SCOPE_SNAPSHOT_V2_FIELD_ORDER + if field_name not in available_set and field_name not in not_applicable_set + ) + + +def _identity_only_snapshot(channel: int = 1) -> ScopeSnapshotV2: + del channel + return ScopeSnapshotV2( + identity=_identity(), + unavailable_fields=_unavailable(*_IDENTITY_FIELDS), + ) + + +def _snapshot_with_health( + *, + sample_rate_hz: float | None, + waiting_for_trigger: bool | None = None, +) -> ScopeSnapshotV2: + available = (*_IDENTITY_FIELDS, "health.sample_rate_hz") + not_applicable = ( + ("health.waiting_for_trigger",) if waiting_for_trigger is None else () + ) + if sample_rate_hz is None: + available = _IDENTITY_FIELDS + return ScopeSnapshotV2( + identity=_identity(), + health=ScopeHealthSnapshotV2( + sample_rate_hz=sample_rate_hz, + waiting_for_trigger=waiting_for_trigger, + ), + unavailable_fields=_unavailable(*available, not_applicable=not_applicable), + not_applicable_fields=not_applicable, # type: ignore[arg-type] + ) + + +def test_snapshot_v2_model_serializes_identity_only_and_partial_health() -> None: + identity_only = _identity_only_snapshot() + partial = _snapshot_with_health(sample_rate_hz=1_000_000.0) + + assert asdict(identity_only)["identity"]["model"] == "EX1" + assert asdict(identity_only)["health"] is None + assert identity_only.unavailable_fields == _unavailable(*_IDENTITY_FIELDS) + assert partial.health is not None + assert partial.health.sample_rate_hz == 1_000_000.0 + assert "health.sample_rate_hz" not in partial.unavailable_fields + assert partial.not_applicable_fields == ("health.waiting_for_trigger",) + + +@pytest.mark.parametrize( + ("factory", "error_type", "message"), + ( + ( + lambda: ScopeAnalogChannelSnapshotV2(channel=True), + ValueError, + "positive integer", + ), + ( + lambda: ScopeHealthSnapshotV2(sample_rate_hz=float("nan")), + ValueError, + "finite", + ), + ( + lambda: ScopeSnapshotV2( + identity=_identity(), + unavailable_fields=tuple(reversed(_unavailable(*_IDENTITY_FIELDS))), # type: ignore[arg-type] + ), + ValueError, + "stable field order", + ), + ( + lambda: ScopeSnapshotV2( + identity=_identity(), + unavailable_fields=_unavailable(*_IDENTITY_FIELDS), + not_applicable_fields=("health.status_byte",), + ), + ValueError, + "mutually exclusive", + ), + ), +) +def test_snapshot_v2_models_reject_invalid_or_ambiguous_values( + factory, + error_type: type[Exception], + message: str, +) -> None: + with pytest.raises(error_type, match=message): + factory() + + +@pytest.mark.parametrize( + ("kwargs", "message"), + ( + ({"readable_fields": ()}, "must contain"), + ({"readable_fields": _IDENTITY_FIELDS[:-1]}, "include all identity"), + ( + { + "readable_fields": (*_IDENTITY_FIELDS, "channel.enabled"), + }, + "require 'channel.channel'", + ), + ( + { + "conditionally_applicable_fields": ("identity.model",), + }, + "cannot be conditional", + ), + ({"max_queries": True}, "integer"), + ), +) +def test_snapshot_v2_profile_rejects_invalid_static_contract( + kwargs: dict[str, object], + message: str, +) -> None: + values: dict[str, object] = { + "readable_fields": _IDENTITY_FIELDS, + "max_queries": 1, + "conditionally_applicable_fields": (), + } + values.update(kwargs) + with pytest.raises((TypeError, ValueError), match=message): + ScopeSnapshotProfileV2(**values) # type: ignore[arg-type] + + +def test_snapshot_v2_profile_enforces_readable_and_conditional_result_contract() -> None: + profile = _profile( + readable_fields=(*_IDENTITY_FIELDS, "health.sample_rate_hz", "health.waiting_for_trigger"), + max_queries=2, + conditionally_applicable_fields=("health.waiting_for_trigger",), + ) + result = _snapshot_with_health(sample_rate_hz=1_000_000.0) + + profile.validate_result(result, channel=1) + + with pytest.raises(ValueError, match="non-conditional readable fields"): + profile.validate_result(_identity_only_snapshot(), channel=1) + + +def test_snapshot_v2_is_additive_and_registered_without_r13_membership() -> None: + class Driver: + def idn(self) -> str: + return "EXAMPLE,EX1,SN-1,1.0" + + def close(self) -> None: + pass + + def get_snapshot_v2( + self, + channel: int, + *, + fields: tuple[str, ...], + ) -> ScopeSnapshotV2: + assert channel == 1 + assert fields == _IDENTITY_FIELDS + return _identity_only_snapshot() + + assert isinstance(Driver(), ScopeSnapshotDriverV2) + assert "get_snapshot_v2" not in ScopeSnapshotDriver.__dict__ + assert SCOPE_CAPABILITY_METHODS["scope.snapshot_v2"] == ("get_snapshot_v2",) + assert CAPABILITY_METHODS["scope.snapshot_v2"] == ("get_snapshot_v2",) + assert "scope.snapshot_v2" in SCOPE_STRICT_V2_CAPABILITIES + assert "scope.snapshot_v2" not in SCOPE_OPERATION_SPECS + spec = SCOPE_PORTABILITY_V2_OPERATION_SPECS["scope.snapshot_v2"] + assert require_operation_spec("scope.snapshot_v2") is spec + assert spec.effect == "stateful_read" + assert spec.lease_mode == "exclusive" + assert spec.required_verified_fields == () + assert spec.error_check_minimum == "disabled" + + +def test_snapshot_v2_descriptor_requires_profile_core_floor_and_callable_method() -> None: + with pytest.raises(ConfigError, match="scope portability V2 capabilities require.*0.8.24"): + validate_scope_descriptor(_descriptor(minimum="0.8.23")) + with pytest.raises(ConfigError, match="snapshot_profile_v2"): + validate_scope_descriptor(_descriptor(extensions=False)) + + class MissingMethod: + def close(self) -> None: + pass + + with pytest.raises(TypeError, match="get_snapshot_v2"): + validate_declared_capabilities(_descriptor(), MissingMethod()) + + +def test_snapshot_v2_method_does_not_create_an_implicit_capability() -> None: + descriptor = _descriptor(capabilities=("scope.idn",), extensions=False) + + class Driver: + def idn(self) -> str: + return "EXAMPLE,EX1,SN-1,1.0" + + def close(self) -> None: + pass + + def get_snapshot_v2( + self, + _channel: int, + *, + fields: tuple[str, ...], + ) -> ScopeSnapshotV2: + raise AssertionError(f"undeclared snapshot V2 must not be called for {fields!r}") + + validate_declared_capabilities(descriptor, Driver()) + assert "scope.snapshot_v2" not in descriptor.capabilities + + +def test_snapshot_v2_service_only_calls_v2_and_keeps_legacy_status_separate() -> None: + calls: list[tuple[int, tuple[str, ...]]] = [] + driver = SimpleNamespace( + get_snapshot_v2=lambda channel, *, fields: calls.append((channel, fields)) + or _identity_only_snapshot(channel), + get_snapshot=lambda _channel: (_ for _ in ()).throw( + AssertionError("legacy get_snapshot must not be called") + ), + ) + profile = _profile() + service = ScopeService( + config=SimpleNamespace( + scope=SimpleNamespace(driver="example.snapshot-v2"), + connection=SimpleNamespace(timeout_ms=1_000), + ), + logger=SimpleNamespace(), + session=driver, + descriptor=SimpleNamespace( + driver_id="example.snapshot-v2", + capabilities=("scope.snapshot_v2",), + scope_extensions=ScopeDescriptorExtensions(snapshot_profile_v2=profile), + ), + ) + + assert service.snapshot_v2(1) == _identity_only_snapshot(1) + assert calls == [(1, _IDENTITY_FIELDS)] + + +def test_snapshot_v2_does_not_change_legacy_status_route() -> None: + legacy = object() + calls: list[tuple[str, int]] = [] + driver = SimpleNamespace( + get_snapshot=lambda channel: calls.append(("legacy", channel)) or legacy, + get_snapshot_v2=lambda channel, *, fields: calls.append(("v2", channel)) + or _identity_only_snapshot(channel), + ) + service = ScopeService( + config=SimpleNamespace( + scope=SimpleNamespace(driver="example.snapshot-v2"), + connection=SimpleNamespace(timeout_ms=1_000), + ), + logger=SimpleNamespace(), + session=driver, + descriptor=SimpleNamespace( + driver_id="example.snapshot-v2", + capabilities=("scope.snapshot", "scope.snapshot_v2"), + scope_extensions=ScopeDescriptorExtensions(snapshot_profile_v2=_profile()), + ), + ) + + assert service.status(2) is legacy + assert calls == [("legacy", 2)] + + +def test_snapshot_v2_capability_explain_is_additive() -> None: + explanation = explain_operation( + "scope.snapshot_v2", + descriptor=SimpleNamespace( + driver_id="example.snapshot-v2", + kind="scope", + capabilities=("scope.snapshot_v2",), + ), + ) + + assert explanation.status == "supported" + assert explanation.spec is not None + assert explanation.spec.required_verified_fields == () + + +def test_snapshot_v2_service_rejects_invalid_channel_or_result_before_fallback( + monkeypatch: pytest.MonkeyPatch, +) -> None: + profile = _profile() + service = ScopeService( + config=SimpleNamespace( + scope=SimpleNamespace(driver="example.snapshot-v2"), + connection=SimpleNamespace(timeout_ms=1_000), + ), + logger=SimpleNamespace(), + descriptor=SimpleNamespace( + driver_id="example.snapshot-v2", + capabilities=("scope.snapshot_v2",), + scope_extensions=ScopeDescriptorExtensions(snapshot_profile_v2=profile), + ), + ) + monkeypatch.setattr(service, "_open_scope", lambda: pytest.fail("must not open")) + with pytest.raises(ConfigError, match="positive integer"): + service.snapshot_v2(0) + + service.session = SimpleNamespace( + get_snapshot_v2=lambda _channel, *, fields: object(), + ) + with pytest.raises(DataError, match="invalid result"): + service.snapshot_v2(1) + + +class _InnerTransport: + resource = "TCPIP::example::INSTR" + + def __init__(self) -> None: + self.queries: list[str] = [] + self.writes: list[str] = [] + self.closed = 0 + + def record_event(self, _direction: str, _text: str) -> None: + pass + + def query(self, command: str, *, replay: ReplayPolicy = ReplayPolicy.NO_REPLAY) -> str: + assert replay is ReplayPolicy.NO_REPLAY + self.queries.append(command) + return "EXAMPLE,EX1,SN-1,1.0" + + def write(self, command: str) -> None: + self.writes.append(command) + + def close(self) -> None: + self.closed += 1 + + +def _budgeted_service( + *, + profile: ScopeSnapshotProfileV2, + driver: object, + transport: GuardedAuditedTransport, +) -> ScopeService: + return ScopeService( + config=SimpleNamespace( + scope=SimpleNamespace(driver="example.snapshot-v2"), + connection=SimpleNamespace(timeout_ms=1_000), + ), + logger=SimpleNamespace(), + session=driver, + descriptor=SimpleNamespace( + driver_id="example.snapshot-v2", + capabilities=("scope.snapshot_v2",), + scope_extensions=ScopeDescriptorExtensions(snapshot_profile_v2=profile), + ), + transport=transport, + session_state=transport.session_state, + ) + + +def test_snapshot_v2_service_uses_only_budgeted_text_queries_without_identity_preflight() -> None: + inner = _InnerTransport() + transport = GuardedAuditedTransport(inner) + + class Driver: + def idn(self) -> str: + raise AssertionError("snapshot V2 must not call the legacy identity preflight") + + def get_snapshot_v2( + self, + _channel: int, + *, + fields: tuple[str, ...], + ) -> ScopeSnapshotV2: + assert fields == _IDENTITY_FIELDS + transport.query("SNAP:IDN?") + return _identity_only_snapshot() + + service = _budgeted_service(profile=_profile(max_queries=1), driver=Driver(), transport=transport) + + assert service.snapshot_v2(1) == _identity_only_snapshot() + assert inner.queries == ["SNAP:IDN?"] + assert inner.writes == [] + assert transport.counters.query_calls == 1 + + +@pytest.mark.parametrize("mode", ("overrun", "write")) +def test_snapshot_v2_service_rejects_overrun_and_non_query_io_before_backend_send(mode: str) -> None: + inner = _InnerTransport() + transport = GuardedAuditedTransport(inner) + + class Driver: + def get_snapshot_v2( + self, + _channel: int, + *, + fields: tuple[str, ...], + ) -> ScopeSnapshotV2: + assert fields == _IDENTITY_FIELDS + transport.query("SNAP:IDN?") + if mode == "overrun": + transport.query("SNAP:EXTRA?") + else: + transport.write("SNAP:BAD") + return _identity_only_snapshot() + + service = _budgeted_service(profile=_profile(max_queries=1), driver=Driver(), transport=transport) + + with pytest.raises(SessionHealthError): + service.snapshot_v2(1) + assert inner.queries == ["SNAP:IDN?"] + assert inner.writes == [] + + +class _FactoryTransport: + resource = "TCPIP::example::INSTR" + + def __init__(self) -> None: + self.queries: list[str] = [] + self.closed = 0 + + def record_event(self, _direction: str, _text: str) -> None: + pass + + def query(self, command: str, *, replay: ReplayPolicy = ReplayPolicy.NO_REPLAY) -> str: + assert replay is ReplayPolicy.NO_REPLAY + self.queries.append(command) + return "ok" + + def close(self) -> None: + self.closed += 1 + + +def _open_factory_descriptor() -> object: + return open_instrument_driver( + driver_reference="example.snapshot-v2", + expected_kind="scope", + resource="TCPIP::example::INSTR", + configured_backend="pyvisa", + timeout_ms=1_000, + opc_timeout_ms=2_000, + read_retry_attempts=1, + read_retry_delay_ms=0, + logger=CommandLogger(), + ) + + +def test_snapshot_v2_factory_latch_blocks_construction_io_until_validation( + monkeypatch: pytest.MonkeyPatch, +) -> None: + inner = _FactoryTransport() + errors: list[TransportIOError] = [] + + class Driver: + def idn(self) -> str: + return "EXAMPLE,EX1,SN-1,1.0" + + def close(self) -> None: + pass + + def get_snapshot_v2( + self, + _channel: int, + *, + fields: tuple[str, ...], + ) -> ScopeSnapshotV2: + assert fields == _IDENTITY_FIELDS + return _identity_only_snapshot() + + def factory(context): + transport = context.open_transport() + with pytest.raises(TransportIOError) as raised: + transport.query("*IDN?") + errors.append(raised.value) + return Driver() + + descriptor = replace(_descriptor(), factory=factory) + monkeypatch.setattr( + "wavebench.instruments.factory.resolve_instrument_descriptor", + lambda _reference, expected_kind: descriptor, + ) + monkeypatch.setattr("wavebench.instruments.factory._open_transport", lambda **_kwargs: inner) + + opened = _open_factory_descriptor() + + assert [error.reason_code for error in errors] == ["factory_construction_pending"] + assert errors[0].attempts == 0 + assert inner.queries == [] + assert opened.transport is not None + assert opened.transport.query("*IDN?") == "ok" + assert inner.queries == ["*IDN?"] + + +def test_snapshot_v2_factory_closes_missing_method_without_io( + monkeypatch: pytest.MonkeyPatch, +) -> None: + inner = _FactoryTransport() + + class MissingMethod: + def close(self) -> None: + pass + + descriptor = replace( + _descriptor(), + factory=lambda context: (context.open_transport(), MissingMethod())[1], + ) + monkeypatch.setattr( + "wavebench.instruments.factory.resolve_instrument_descriptor", + lambda _reference, expected_kind: descriptor, + ) + monkeypatch.setattr("wavebench.instruments.factory._open_transport", lambda **_kwargs: inner) + + with pytest.raises(ConfigError, match="get_snapshot_v2"): + _open_factory_descriptor() + + assert inner.queries == [] + assert inner.closed == 1 + + +def test_undeclared_snapshot_v2_method_does_not_latch_legacy_factory( + monkeypatch: pytest.MonkeyPatch, +) -> None: + inner = _FactoryTransport() + + class LegacyDriver: + def idn(self) -> str: + return "EXAMPLE,EX1,SN-1,1.0" + + def close(self) -> None: + pass + + def get_snapshot_v2( + self, + _channel: int, + *, + fields: tuple[str, ...], + ) -> ScopeSnapshotV2: + raise AssertionError(f"undeclared snapshot V2 must not be called for {fields!r}") + + descriptor = replace( + _descriptor(capabilities=("scope.idn",), extensions=False), + factory=lambda context: (context.open_transport().query("*IDN?"), LegacyDriver())[1], + ) + monkeypatch.setattr( + "wavebench.instruments.factory.resolve_instrument_descriptor", + lambda _reference, expected_kind: descriptor, + ) + monkeypatch.setattr("wavebench.instruments.factory._open_transport", lambda **_kwargs: inner) + + _open_factory_descriptor() + + assert inner.queries == ["*IDN?"] diff --git a/tests/test_scope_waveform_executor.py b/tests/test_scope_waveform_executor.py new file mode 100644 index 0000000..786f674 --- /dev/null +++ b/tests/test_scope_waveform_executor.py @@ -0,0 +1,767 @@ +from __future__ import annotations + +import json +from pathlib import Path +from tempfile import TemporaryDirectory +from types import SimpleNamespace + +import numpy as np +import pytest + +from wavebench.errors import ConfigError, DataError, InstrumentError, TransportIOError +from wavebench.config import ( + AutoscaleConfig, + ConnectionConfig, + OutputConfig, + ScopeConfig, + WaveBenchConfig, + WaveformConfig, +) +from wavebench.instruments.api import InstrumentDescriptor +from wavebench.instruments.models import WaveformData, WaveformHeader +from wavebench.instruments.scope_extensions import ( + ErrorDrainResult, + ScopeDescriptorExtensions, + ScopeWaveformBinaryOperationProfile, + ScopeWaveformBinaryProfile, + ScopeWaveformTransferRestoreResult, + ScopeWaveformTransferStateSnapshot, +) +from wavebench.logging import CommandLogger +from wavebench.services.scope_service import ScopeService +from wavebench.services.scope_waveform_executor import BoundedWaveformExecutor +from wavebench.transport.contracts import ( + BinaryQueryResult, + BinaryResponseFraming, + CommandTransmission, + ReplayPolicy, + ResponseProgress, + Synchronization, + TransportPhase, +) +from wavebench.transport.guarded import GuardedAuditedTransport +from wavebench.transport.session import InstrumentSessionState, SessionHealth + + +_FETCH_FIELDS = ("scope.waveform_source",) +_CAPTURE_FIELDS = ( + "scope.run_state", + "scope.acquisition", + "scope.trigger", + "scope.timebase", + "scope.channel_display", + "scope.channel_vertical", + "scope.waveform_source", + "scope.waveform_mode", + "scope.query_response_header", + "scope.waveform_format", + "scope.waveform_byte_order", + "scope.waveform_points", + "scope.waveform_transfer_window", +) +_TOKEN_ATTRS = { + "scope.run_state": "run_state_token", + "scope.acquisition": "acquisition_token", + "scope.trigger": "trigger_token", + "scope.timebase": "timebase_token", + "scope.channel_display": "channel_display_token", + "scope.channel_vertical": "channel_vertical_token", + "scope.waveform_source": "waveform_source_token", + "scope.waveform_mode": "waveform_mode_token", + "scope.query_response_header": "query_response_header_token", + "scope.waveform_format": "waveform_format_token", + "scope.waveform_byte_order": "waveform_byte_order_token", + "scope.waveform_points": "waveform_points_token", + "scope.waveform_transfer_window": "waveform_transfer_window_token", +} + + +def _fields_for(operation_kind: str) -> tuple[str, ...]: + return _FETCH_FIELDS if operation_kind == "fetch" else _CAPTURE_FIELDS + + +def _snapshot(fields: tuple[str, ...]) -> ScopeWaveformTransferStateSnapshot: + return ScopeWaveformTransferStateSnapshot( + captured_fields=fields, + **{_TOKEN_ATTRS[field]: f"token-{index}" for index, field in enumerate(fields)}, + ) + + +class _Backend: + _wavebench_binary_budget_parameters = True + resource = "fake" + + def __init__(self) -> None: + self.queries: list[str] = [] + self.writes: list[str] = [] + self.binary_trailing: list[bytes] = [] + self.fail_binary_sync = False + self.closed = 0 + + def record_event(self, direction: str, text: str) -> None: + pass + + def query(self, command: str, *, replay: ReplayPolicy = ReplayPolicy.NO_REPLAY) -> str: + self.queries.append(command) + return "EXAMPLE,EX1" if command == "*IDN?" else "ok" + + def query_float_list(self, command: str, *, timeout_ms=None, replay=ReplayPolicy.NO_REPLAY): + self.queries.append(command) + return [1.0] + + def query_bin_block(self, command: str, *, replay=ReplayPolicy.NO_REPLAY) -> bytes: + self.queries.append(command) + return b"legacy" + + def query_binary( + self, + command: str, + *, + framing: BinaryResponseFraming, + max_bytes: int, + timeout_ms: int | None = None, + replay: ReplayPolicy = ReplayPolicy.NO_REPLAY, + _transport_trailing: bytes = b"", + _resynchronization_max_bytes: int = 0, + ) -> BinaryQueryResult: + self.queries.append(command) + self.binary_trailing.append(_transport_trailing) + if self.fail_binary_sync: + raise TransportIOError( + "truncated binary response", + operation="query_binary", + phase=TransportPhase.READING, + replay_policy=replay, + command_transmission=CommandTransmission.SENT, + response_progress=ResponseProgress.PARTIAL, + synchronization=Synchronization.LOST, + attempts=1, + reason_code="binary_truncated", + consumed_bytes=1, + ) + payload = b"\x00\x01" + assert framing is BinaryResponseFraming.DEFINITE_BLOCK + assert len(payload) <= max_bytes + return BinaryQueryResult( + data=payload, + framing=framing, + declared_length=len(payload), + framing_header_bytes=3, + consumed_bytes=3 + len(payload) + len(_transport_trailing), + transport_trailing_bytes=_transport_trailing, + ) + + def query_opc(self, *, replay: ReplayPolicy = ReplayPolicy.NO_REPLAY) -> str: + self.queries.append("*OPC?") + return "1" + + def write(self, command: str) -> None: + self.writes.append(command) + + def write_bytes(self, command: bytes) -> None: + self.writes.append("BINARY_WRITE") + + def close(self) -> None: + self.closed += 1 + + +def _waveform(channel: int = 1) -> WaveformData: + return WaveformData( + channel=channel, + header=WaveformHeader(0.0, 1e-3, 2), + voltages_v=np.array([0.0, 1.0]), + ) + + +def _bounded_transport( + backend: _Backend, + state: InstrumentSessionState, +) -> GuardedAuditedTransport: + transport = GuardedAuditedTransport(backend, session_state=state) + transport._mark_bounded_binary_backend_verified() + return transport + + +def test_waveform_backend_marker_compatibility_aliases_share_generic_state() -> None: + transport = GuardedAuditedTransport( + _Backend(), + session_state=InstrumentSessionState(epoch_id="marker-alias"), + ) + + assert not transport._has_verified_bounded_binary_backend() + assert not transport._has_verified_bounded_waveform_backend() + transport._mark_bounded_waveform_backend_verified() + assert transport._has_verified_bounded_binary_backend() + assert transport._has_verified_bounded_waveform_backend() + + +class _Driver: + def __init__(self, transport: GuardedAuditedTransport) -> None: + self.transport = transport + self.fail_after_binary = False + self.use_legacy_binary = False + self.skip_binary = False + self.use_safe_binary_replay = False + self.use_message_binary_framing = False + self.restore_calls = 0 + self.restore_fail = False + self.verify_calls = 0 + self.drain_calls = 0 + self.legacy_fetch_calls = 0 + self.fail_channel: int | None = None + self.return_channel_override: int | None = None + self.multi_result_override: dict[int, WaveformData] | None = None + self.callback_waveform_override: WaveformData | None = None + self.duplicate_waveform_callback = False + self.skip_waveform_callbacks = False + + def idn(self) -> str: + return self.transport.query("*IDN?", replay=ReplayPolicy.NO_REPLAY) + + def close(self) -> None: + self.transport.close() + + def snapshot_waveform_transfer_state(self, fields): + self.transport.query("SNAP?", replay=ReplayPolicy.NO_REPLAY) + return _snapshot(tuple(fields)) + + def restore_waveform_transfer_state(self, baseline): + self.restore_calls += 1 + self.transport.write("RESTORE") + if self.restore_fail: + return ScopeWaveformTransferRestoreResult("failed", (), ()) + return ScopeWaveformTransferRestoreResult( + "completed", + baseline.restore_order, + baseline.restore_order, + ) + + def verify_waveform_transfer_state_restored(self, baseline): + self.verify_calls += 1 + self.transport.query("VERIFY?", replay=ReplayPolicy.NO_REPLAY) + return _snapshot(tuple(baseline.snapshot.captured_fields)) + + def fetch_waveform_bounded(self, channel, points="dmax", *, baseline): + self.transport.query("PRE?", replay=ReplayPolicy.NO_REPLAY) + if self.use_legacy_binary: + self.transport.query_bin_block("DATA?", replay=ReplayPolicy.NO_REPLAY) + raise AssertionError("legacy binary query unexpectedly returned") + if not self.skip_binary: + self.transport.query_binary( + "DATA?", + framing=( + BinaryResponseFraming.MESSAGE + if self.use_message_binary_framing + else BinaryResponseFraming.DEFINITE_BLOCK + ), + max_bytes=2, + replay=( + ReplayPolicy.SAFE_TO_REPLAY + if self.use_safe_binary_replay + else ReplayPolicy.NO_REPLAY + ), + ) + if self.fail_after_binary: + raise DataError("conversion failed") + return _waveform( + self.return_channel_override + if self.return_channel_override is not None + else channel + ) + + def capture_waveform_bounded( + self, + channel, + points="dmax", + *, + time_range_s=None, + vertical_scale_v_per_div=None, + baseline, + ): + return self.fetch_waveform_bounded(channel, points, baseline=baseline) + + def capture_waveforms_bounded( + self, + channels, + points="dmax", + *, + time_range_s=None, + vertical_scale_v_per_div=None, + on_channel_start=None, + on_waveform=None, + baseline, + ): + if self.multi_result_override is not None: + self.transport.query_binary( + "DATA?", + framing=BinaryResponseFraming.DEFINITE_BLOCK, + max_bytes=2, + replay=ReplayPolicy.NO_REPLAY, + ) + return self.multi_result_override + result: dict[int, WaveformData] = {} + for channel in channels: + if on_channel_start is not None: + on_channel_start(channel) + if self.fail_channel == channel: + raise DataError(f"CH{channel} bounded read failed") + waveform = self.fetch_waveform_bounded(channel, points, baseline=baseline) + result[channel] = waveform + if on_waveform is not None and not self.skip_waveform_callbacks: + callback_waveform = ( + self.callback_waveform_override + if self.callback_waveform_override is not None + and self.callback_waveform_override.channel == channel + else waveform + ) + on_waveform(channel, callback_waveform) + if self.duplicate_waveform_callback: + on_waveform(channel, waveform) + return result + + def drain_errors(self, *, max_records: int) -> ErrorDrainResult: + self.drain_calls += 1 + self.transport.query("ERR?", replay=ReplayPolicy.NO_REPLAY) + return ErrorDrainResult(records=(), terminated=True, query_count=1) + + def fetch_waveform(self, *args, **kwargs): + self.legacy_fetch_calls += 1 + raise AssertionError("legacy waveform route must not be used by an opt-in descriptor") + + +def _profile(*, operation_kind: str = "fetch", trailing: str = "0d0a") -> ScopeWaveformBinaryProfile: + fields = _fields_for(operation_kind) + return ScopeWaveformBinaryProfile( + operations=( + ScopeWaveformBinaryOperationProfile( + operation_kind=operation_kind, # type: ignore[arg-type] + response_max_bytes=1_024, + operation_max_bytes=4_096, + query_max_count=4, + resynchronization_max_bytes=0, + restore_order=fields, + snapshot_max_steps=len(fields), + restore_max_steps=len(fields), + verify_max_steps=len(fields), + ), + ), + transport_trailing_hex=trailing, + ) + + +def _descriptor( + *, + operation_kind: str = "fetch", + error_drain: bool = False, +) -> InstrumentDescriptor: + capability = { + "fetch": "scope.fetch_waveform", + "capture_single": "scope.capture_waveform", + "capture_multiple": "scope.capture_waveforms", + }[operation_kind] + capabilities = ("scope.idn", capability) + (("scope.error_drain_v1",) if error_drain else ()) + return InstrumentDescriptor( + driver_id="example.waveform", + kind="scope", + display_name="Example", + manufacturer="Example", + models=("EX1",), + aliases=(), + capabilities=capabilities, + idn_patterns=("EXAMPLE",), + backends=("pyvisa",), + option_specs=(), + permissions=("instrument.io",), + factory=lambda context: object(), + wavebench_min_version="0.8.24", + scope_extensions=ScopeDescriptorExtensions( + waveform_binary_profile=_profile(operation_kind=operation_kind) + ), + ) + + +def _executor( + *, + operation_kind: str = "fetch", + error_drain: bool = False, +) -> tuple[BoundedWaveformExecutor, _Driver, _Backend, InstrumentSessionState]: + backend = _Backend() + state = InstrumentSessionState(epoch_id="bounded-epoch") + transport = _bounded_transport(backend, state) + driver = _Driver(transport) + return ( + BoundedWaveformExecutor( + driver=driver, + descriptor=_descriptor(operation_kind=operation_kind, error_drain=error_drain), + session_state=state, + connection_timeout_ms=5_000, + transport=transport, + ), + driver, + backend, + state, + ) + + +def test_bounded_fetch_uses_profile_trailing_one_ledger_and_core_owned_recovery() -> None: + executor, driver, backend, state = _executor() + + result = executor.fetch(channel=1, points="DEF", check_errors=False) + + assert isinstance(result.value, WaveformData) + assert result.identity == "EXAMPLE,EX1" + assert backend.binary_trailing == [b"\r\n"] + assert driver.restore_calls == 1 + assert driver.verify_calls == 1 + assert backend.writes == ["RESTORE"] + assert state.health is SessionHealth.HEALTHY + assert result.diagnostics["scope_operation"]["binary_budget"]["remaining_query_count"] == 3 + + +def test_bounded_executor_rejects_unverified_transport_before_any_io() -> None: + backend = _Backend() + state = InstrumentSessionState(epoch_id="unverified-bounded-epoch") + transport = GuardedAuditedTransport(backend, session_state=state) + driver = _Driver(transport) + + with pytest.raises(ConfigError, match="factory-validated"): + BoundedWaveformExecutor( + driver=driver, + descriptor=_descriptor(), + session_state=state, + connection_timeout_ms=5_000, + transport=transport, + ) + + assert backend.queries == [] + assert backend.writes == [] + + +def test_proven_data_failure_restores_but_lost_sync_never_attempts_cleanup() -> None: + executor, driver, backend, state = _executor() + driver.fail_after_binary = True + + with pytest.raises(DataError, match="conversion failed"): + executor.fetch(channel=1, points="DEF", check_errors=False) + + assert driver.restore_calls == 1 + assert driver.verify_calls == 1 + assert state.health is SessionHealth.HEALTHY + + executor, driver, backend, state = _executor() + backend.fail_binary_sync = True + + with pytest.raises(TransportIOError) as raised: + executor.fetch(channel=1, points="DEF", check_errors=False) + + assert raised.value.synchronization is Synchronization.LOST + assert driver.restore_calls == 0 + assert driver.verify_calls == 0 + assert backend.writes == [] + assert state.health is SessionHealth.POISONED + + +def test_bounded_driver_cannot_fall_back_to_legacy_binary_entry() -> None: + executor, driver, backend, state = _executor() + driver.use_legacy_binary = True + + with pytest.raises(TransportIOError) as raised: + executor.fetch(channel=1, points="DEF", check_errors=False) + + assert raised.value.reason_code == "binary_legacy_entry_unsupported" + assert backend.binary_trailing == [] + assert driver.restore_calls == 1 + assert driver.verify_calls == 1 + assert state.health is SessionHealth.HEALTHY + + +def test_bounded_driver_cannot_opt_into_binary_replay() -> None: + executor, driver, backend, state = _executor() + driver.use_safe_binary_replay = True + + with pytest.raises(TransportIOError) as raised: + executor.fetch(channel=1, points="DEF", check_errors=False) + + assert raised.value.reason_code == "binary_replay_unsupported" + assert backend.binary_trailing == [] + assert driver.restore_calls == 1 + assert driver.verify_calls == 1 + assert state.health is SessionHealth.HEALTHY + + +def test_bounded_driver_cannot_change_the_profile_binary_framing() -> None: + executor, driver, backend, state = _executor() + driver.use_message_binary_framing = True + + with pytest.raises(TransportIOError) as raised: + executor.fetch(channel=1, points="DEF", check_errors=False) + + assert raised.value.reason_code == "binary_framing_profile_unsupported" + assert backend.binary_trailing == [] + assert driver.restore_calls == 1 + assert driver.verify_calls == 1 + assert state.health is SessionHealth.HEALTHY + + +def test_bounded_driver_must_issue_at_least_one_binary_query() -> None: + executor, driver, _, state = _executor() + driver.skip_binary = True + + with pytest.raises(DataError, match="did not issue a binary query"): + executor.fetch(channel=1, points="DEF", check_errors=False) + + assert driver.restore_calls == 1 + assert driver.verify_calls == 1 + assert state.health is SessionHealth.HEALTHY + + +def test_bounded_restore_failure_prevents_success_and_poison_latches_the_session() -> None: + executor, driver, _, state = _executor() + driver.restore_fail = True + + with pytest.raises(InstrumentError, match="restore did not complete"): + executor.fetch(channel=1, points="DEF", check_errors=False) + + assert driver.restore_calls == 1 + assert driver.verify_calls == 1 + assert state.health is SessionHealth.POISONED + + +def test_bounded_single_waveform_requires_the_requested_channel() -> None: + executor, driver, _, state = _executor() + driver.return_channel_override = 2 + + with pytest.raises(DataError, match="mismatched channel"): + executor.fetch(channel=1, points="DEF", check_errors=False) + + assert driver.restore_calls == 1 + assert driver.verify_calls == 1 + assert state.health is SessionHealth.HEALTHY + + +def test_bounded_path_uses_typed_error_drain_and_service_dispatches_before_legacy_gate() -> None: + executor, driver, backend, _ = _executor(error_drain=True) + + result = executor.fetch(channel=1, points="DEF", check_errors=True) + + assert isinstance(result.value, WaveformData) + assert driver.drain_calls == 2 + assert driver.legacy_fetch_calls == 0 + + executor, _, backend, _ = _executor(error_drain=False) + with pytest.raises(ConfigError, match="scope.error_drain_v1"): + executor.fetch(channel=1, points="DEF", check_errors=True) + assert backend.queries == [] + + backend = _Backend() + state = InstrumentSessionState(epoch_id="service-bounded") + driver = _Driver(_bounded_transport(backend, state)) + descriptor = _descriptor() + service = ScopeService( + config=SimpleNamespace( + scope=SimpleNamespace(driver="example.waveform", access="read_write", check_errors=False), + connection=SimpleNamespace(timeout_ms=5_000), + waveform=SimpleNamespace(format="real", byte_order="lsbf", points="DEF"), + ), + logger=CommandLogger(), + session=driver, + descriptor=descriptor, + transport=driver.transport, + session_state=state, + ) + + waveform = service.fetch_waveform(1) + + assert isinstance(waveform, WaveformData) + assert driver.legacy_fetch_calls == 0 + + +def test_bounded_multi_capture_preserves_callbacks_and_one_cleanup_transaction() -> None: + executor, driver, _, _ = _executor(operation_kind="capture_multiple") + events: list[tuple[str, int]] = [] + + result = executor.capture_multiple( + channels=[1, 2], + points="DEF", + time_range_s=None, + vertical_scale_v_per_div=None, + check_errors=False, + on_channel_start=lambda channel: events.append(("start", int(channel))), + on_waveform=lambda channel, waveform: events.append(("waveform", channel)), + ) + + assert set(result.value) == {1, 2} + assert events == [("start", 1), ("waveform", 1), ("start", 2), ("waveform", 2)] + assert driver.restore_calls == 1 + assert driver.verify_calls == 1 + + +def test_bounded_single_capture_uses_complete_recovery_closure() -> None: + executor, driver, _, state = _executor(operation_kind="capture_single") + + result = executor.capture_single( + channel=1, + points="DEF", + time_range_s=0.001, + vertical_scale_v_per_div=0.5, + check_errors=False, + ) + + assert isinstance(result.value, WaveformData) + assert driver.restore_calls == 1 + assert driver.verify_calls == 1 + assert state.health is SessionHealth.HEALTHY + + +@pytest.mark.parametrize( + ("returned", "message"), + ( + ({1: _waveform(1)}, "channel set"), + ({1: _waveform(2), 2: _waveform(2)}, "mismatched channel"), + ), +) +def test_bounded_multi_capture_rejects_incomplete_or_mismatched_return_maps( + returned: dict[int, WaveformData], + message: str, +) -> None: + executor, driver, _, state = _executor(operation_kind="capture_multiple") + driver.multi_result_override = returned + + with pytest.raises(DataError, match=message): + executor.capture_multiple( + channels=[1, 2], + points="DEF", + time_range_s=None, + vertical_scale_v_per_div=None, + check_errors=False, + on_channel_start=None, + on_waveform=None, + ) + + assert driver.restore_calls == 1 + assert driver.verify_calls == 1 + assert state.health is SessionHealth.HEALTHY + + +def test_bounded_multi_capture_reconciles_callbacks_with_the_return_map() -> None: + executor, driver, _, state = _executor(operation_kind="capture_multiple") + driver.callback_waveform_override = WaveformData( + channel=1, + header=WaveformHeader(0.0, 1e-3, 2), + voltages_v=np.array([9.0, 10.0]), + ) + + with pytest.raises(DataError, match="does not match its callback"): + executor.capture_multiple( + channels=[1, 2], + points="DEF", + time_range_s=None, + vertical_scale_v_per_div=None, + check_errors=False, + on_channel_start=lambda channel: None, + on_waveform=lambda channel, waveform: None, + ) + + assert driver.restore_calls == 1 + assert driver.verify_calls == 1 + assert state.health is SessionHealth.HEALTHY + + +def test_bounded_multi_capture_rejects_duplicate_waveform_callbacks() -> None: + executor, driver, _, state = _executor(operation_kind="capture_multiple") + driver.duplicate_waveform_callback = True + + with pytest.raises(DataError, match="more than once"): + executor.capture_multiple( + channels=[1, 2], + points="DEF", + time_range_s=None, + vertical_scale_v_per_div=None, + check_errors=False, + on_channel_start=lambda channel: None, + on_waveform=lambda channel, waveform: None, + ) + + assert driver.restore_calls == 1 + assert driver.verify_calls == 1 + assert state.health is SessionHealth.HEALTHY + + +def test_bounded_multi_capture_requires_callbacks_when_the_caller_provides_one() -> None: + executor, driver, _, state = _executor(operation_kind="capture_multiple") + driver.skip_waveform_callbacks = True + + with pytest.raises(DataError, match="did not emit exactly one"): + executor.capture_multiple( + channels=[1, 2], + points="DEF", + time_range_s=None, + vertical_scale_v_per_div=None, + check_errors=False, + on_channel_start=lambda channel: None, + on_waveform=lambda channel, waveform: None, + ) + + assert driver.restore_calls == 1 + assert driver.verify_calls == 1 + assert state.health is SessionHealth.HEALTHY + + +def test_bounded_scope_service_preserves_partial_multichannel_artifacts() -> None: + with TemporaryDirectory() as tmp: + backend = _Backend() + state = InstrumentSessionState(epoch_id="service-bounded-multi") + driver = _Driver(_bounded_transport(backend, state)) + driver.fail_channel = 2 + config = WaveBenchConfig( + connection=ConnectionConfig( + backend="lan", + resource="TCPIP::fake::INSTR", + timeout_ms=5_000, + opc_timeout_ms=5_000, + ), + scope=ScopeConfig( + driver="example.waveform", + model_hint=None, + default_channel=1, + reset_before_run=False, + check_errors=False, + ), + autoscale=AutoscaleConfig(wait_opc=True, check_errors=True), + waveform=WaveformConfig( + format="real", + byte_order="lsbf", + points="DEF", + time_range_s=0.001, + ), + output=OutputConfig( + directory=Path(tmp), + package_naming="timestamp_label", + save_csv=False, + save_npy=True, + save_json=True, + save_commands_log=False, + save_screenshot=False, + ), + source_path=Path(tmp) / "wavebench.toml", + ) + service = ScopeService( + config=config, + logger=CommandLogger(), + session=driver, + descriptor=_descriptor(operation_kind="capture_multiple"), + transport=driver.transport, + session_state=state, + ) + + with pytest.raises(DataError, match="CH2 bounded read failed"): + service.capture_waveforms(channels=[1, 2], label="bounded-partial") + + [failed_dir] = Path(tmp).glob("*bounded-partial_failed") + assert (failed_dir / "ch1.npy").exists() + assert not (failed_dir / "ch2.npy").exists() + metadata = json.loads((failed_dir / "metadata.partial.json").read_text("utf-8")) + assert metadata["completed_channels"] == [1] + assert metadata["failed_channel"] == 2 + assert metadata["stage"] == "read_waveform" + assert "scope_operation_diagnostics" in metadata diff --git a/tests/test_transport_call_classification.py b/tests/test_transport_call_classification.py index d3bb7ed..8c262e2 100644 --- a/tests/test_transport_call_classification.py +++ b/tests/test_transport_call_classification.py @@ -5,7 +5,9 @@ _ROOT = Path(__file__).parents[1] -_QUERY_METHODS = frozenset({"query", "query_opc", "query_bin_block", "query_float_list"}) +_QUERY_METHODS = frozenset( + {"query", "query_opc", "query_bin_block", "query_binary", "query_float_list"} +) def _direct_transport_call(node: ast.Call, *, plugin: bool = False) -> bool: diff --git a/tests/test_transport_replay_matrix.py b/tests/test_transport_replay_matrix.py new file mode 100644 index 0000000..9d30f0b --- /dev/null +++ b/tests/test_transport_replay_matrix.py @@ -0,0 +1,279 @@ +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from wavebench.errors import TransportIOError +from wavebench.logging import CommandLogger +from wavebench.transport.contracts import ( + BinaryResponseFraming, + CommandTransmission, + ReplayPolicy, + ResponseProgress, + Synchronization, + TransportPhase, +) +from wavebench.transport.pyvisa_transport import PyVisaTransport +from wavebench.transport.rsinstrument_transport import RsInstrumentTransport +from wavebench.transport.serial_transport import SerialTransport + + +def _retryable_failure(replay: ReplayPolicy) -> TransportIOError: + return TransportIOError( + "response did not start after a proven-safe exchange", + operation="query", + phase=TransportPhase.READING, + replay_policy=replay, + command_transmission=CommandTransmission.SENT, + response_progress=ResponseProgress.NONE, + synchronization=Synchronization.PROVEN, + attempts=1, + ) + + +class _ReplayablePyVisaSession: + def __init__(self, *, failure_replay: ReplayPolicy) -> None: + self.failure_replay = failure_replay + self.calls = 0 + self.failures_remaining = 1 + self.timeout = 1_000 + + def _attempt(self, result: object) -> object: + self.calls += 1 + if self.failures_remaining: + self.failures_remaining -= 1 + raise _retryable_failure(self.failure_replay) + return result + + def query(self, command: str) -> str: + result = "1.0,2.0" if command == "MEAS?" else "1" if command == "*OPC?" else "ok" + return str(self._attempt(result)) + + def query_binary_values(self, _command: str, *, datatype: str, container: type[bytes]) -> bytes: + assert datatype == "B" + assert container is bytes + return bytes(self._attempt(b"data")) + + +class _ReplayableRsInstrumentSession: + def __init__(self, *, failure_replay: ReplayPolicy) -> None: + self.failure_replay = failure_replay + self.calls = 0 + self.failures_remaining = 1 + self.visa_timeout = 1_000 + self.events = SimpleNamespace(io_events_include_data=True, on_read_handler=None) + + def _attempt(self, result: object) -> object: + self.calls += 1 + if self.failures_remaining: + self.failures_remaining -= 1 + raise _retryable_failure(self.failure_replay) + return result + + def query_str(self, _command: str) -> str: + return str(self._attempt("ok")) + + def query_bin_or_ascii_float_list(self, _command: str) -> list[float]: + return list(self._attempt([1.0, 2.0])) + + def query_bin_block(self, _command: str) -> bytes: + return bytes(self._attempt(b"data")) + + def query_opc(self) -> str: + return str(self._attempt("1")) + + +def _backend_with_retryable_first_read( + backend: str, + *, + failure_replay: ReplayPolicy, +) -> tuple[object, object]: + if backend == "pyvisa": + session = _ReplayablePyVisaSession(failure_replay=failure_replay) + return ( + PyVisaTransport( + "TCPIP::example::INSTR", + object(), + session, + CommandLogger(), + read_retry_attempts=1, + read_retry_delay_ms=0, + ), + session, + ) + if backend == "rsinstrument": + session = _ReplayableRsInstrumentSession(failure_replay=failure_replay) + return ( + RsInstrumentTransport( + "TCPIP::example::INSTR", + session, + CommandLogger(), + read_retry_attempts=1, + read_retry_delay_ms=0, + ), + session, + ) + raise AssertionError(f"unexpected backend: {backend}") + + +def _call_text_query_entry(transport: object, entry: str, replay: ReplayPolicy) -> object: + if entry == "query": + return transport.query("TEXT?", replay=replay) # type: ignore[attr-defined] + if entry == "query_float_list": + return transport.query_float_list("MEAS?", replay=replay) # type: ignore[attr-defined] + if entry == "query_bin_block": + return transport.query_bin_block("DATA?", replay=replay) # type: ignore[attr-defined] + if entry == "query_opc": + return transport.query_opc(replay=replay) # type: ignore[attr-defined] + raise AssertionError(f"unexpected entry: {entry}") + + +@pytest.mark.parametrize("backend", ("pyvisa", "rsinstrument")) +@pytest.mark.parametrize( + ("entry", "expected"), + ( + ("query", "ok"), + ("query_float_list", [1.0, 2.0]), + ("query_bin_block", b"data"), + ("query_opc", "1"), + ), +) +@pytest.mark.parametrize( + ("replay", "expected_calls"), + ( + (ReplayPolicy.NO_REPLAY, 1), + (ReplayPolicy.SAFE_TO_REPLAY, 2), + (ReplayPolicy.READ_CONTINUATION_ONLY, 0), + ), +) +def test_visa_text_query_entries_have_exact_replay_send_counts( + backend: str, + entry: str, + expected: object, + replay: ReplayPolicy, + expected_calls: int, +) -> None: + transport, session = _backend_with_retryable_first_read( + backend, + failure_replay=replay, + ) + + if replay is ReplayPolicy.READ_CONTINUATION_ONLY: + with pytest.raises(TransportIOError) as raised: + _call_text_query_entry(transport, entry, replay) + assert raised.value.attempts == 0 + elif replay is ReplayPolicy.NO_REPLAY: + with pytest.raises(TransportIOError) as raised: + _call_text_query_entry(transport, entry, replay) + assert raised.value.attempts == 1 + else: + assert _call_text_query_entry(transport, entry, replay) == expected + + assert session.calls == expected_calls # type: ignore[attr-defined] + + +class _ReplayableSerialSession: + def __init__(self, *, failure_replay: ReplayPolicy) -> None: + self.failure_replay = failure_replay + self.write_calls = 0 + self.writes: list[bytes] = [] + self.failures_remaining = 1 + + def write(self, payload: bytes) -> int: + self.write_calls += 1 + self.writes.append(payload) + if self.failures_remaining: + self.failures_remaining -= 1 + raise _retryable_failure(self.failure_replay) + return len(payload) + + def flush(self) -> None: + pass + + def read_until(self, _termination: bytes) -> bytes: + return b"1.0,2.0\n" + + +def _call_serial_text_query_entry( + transport: SerialTransport, + entry: str, + replay: ReplayPolicy, +) -> object: + if entry == "query": + return transport.query("MEAS?", replay=replay) + if entry == "query_float_list": + return transport.query_float_list("MEAS?", replay=replay) + if entry == "query_opc": + return transport.query_opc(replay=replay) + raise AssertionError(f"unexpected entry: {entry}") + + +@pytest.mark.parametrize( + ("entry", "expected"), + ( + ("query", "1.0,2.0"), + ("query_float_list", [1.0, 2.0]), + ("query_opc", "1.0,2.0"), + ), +) +@pytest.mark.parametrize( + ("replay", "expected_calls"), + ( + (ReplayPolicy.NO_REPLAY, 1), + (ReplayPolicy.SAFE_TO_REPLAY, 2), + (ReplayPolicy.READ_CONTINUATION_ONLY, 0), + ), +) +def test_serial_text_query_entries_have_exact_replay_send_counts( + entry: str, + expected: object, + replay: ReplayPolicy, + expected_calls: int, +) -> None: + session = _ReplayableSerialSession(failure_replay=replay) + transport = SerialTransport( + "/dev/ttyUSB0", + session, + CommandLogger(), + read_retry_attempts=1, + read_retry_delay_ms=0, + ) + + if replay is ReplayPolicy.READ_CONTINUATION_ONLY: + with pytest.raises(TransportIOError) as raised: + _call_serial_text_query_entry(transport, entry, replay) + assert raised.value.attempts == 0 + elif replay is ReplayPolicy.NO_REPLAY: + with pytest.raises(TransportIOError) as raised: + _call_serial_text_query_entry(transport, entry, replay) + assert raised.value.attempts == 1 + else: + assert _call_serial_text_query_entry(transport, entry, replay) == expected + + assert session.write_calls == expected_calls + assert len(session.writes) == expected_calls + + +@pytest.mark.parametrize("entry", ("query_bin_block", "query_binary")) +@pytest.mark.parametrize("replay", tuple(ReplayPolicy)) +def test_serial_binary_query_entries_reject_before_any_send( + entry: str, + replay: ReplayPolicy, +) -> None: + session = _ReplayableSerialSession(failure_replay=replay) + transport = SerialTransport("/dev/ttyUSB0", session, CommandLogger()) + + with pytest.raises(TransportIOError) as raised: + if entry == "query_bin_block": + transport.query_bin_block("DATA?", replay=replay) + else: + transport.query_binary( + "DATA?", + framing=BinaryResponseFraming.DEFINITE_BLOCK, + max_bytes=16, + replay=replay, + ) + + assert raised.value.attempts == 0 + assert session.write_calls == 0 diff --git a/tests/test_waveform_binary_factory.py b/tests/test_waveform_binary_factory.py new file mode 100644 index 0000000..4b21003 --- /dev/null +++ b/tests/test_waveform_binary_factory.py @@ -0,0 +1,274 @@ +from __future__ import annotations + +import pytest + +from wavebench.errors import ConfigError, TransportIOError +from wavebench.instruments.api import InstrumentDescriptor +from wavebench.instruments.factory import open_instrument_driver +from wavebench.instruments.scope_extensions import ( + ScopeDescriptorExtensions, + ScopeWaveformBinaryOperationProfile, + ScopeWaveformBinaryProfile, +) +from wavebench.logging import CommandLogger +from wavebench.transport.contracts import BinaryResponseFraming, ReplayPolicy + + +class _InnerTransport: + resource = "TCPIP::example::INSTR" + + def __init__(self) -> None: + self.queries: list[str] = [] + self.writes: list[str] = [] + self.closed = 0 + + def record_event(self, direction: str, text: str) -> None: + pass + + def query(self, command: str, *, replay: ReplayPolicy = ReplayPolicy.NO_REPLAY) -> str: + self.queries.append(command) + return "ok" + + def query_float_list( + self, + command: str, + *, + timeout_ms: int | None = None, + replay: ReplayPolicy = ReplayPolicy.NO_REPLAY, + ) -> list[float]: + self.queries.append(command) + return [1.0] + + def query_bin_block( + self, + command: str, + *, + replay: ReplayPolicy = ReplayPolicy.NO_REPLAY, + ) -> bytes: + self.queries.append(command) + return b"" + + def query_binary(self, *args, **kwargs): + self.queries.append("BINARY") + raise AssertionError("construction latch should reject before the backend") + + def query_opc(self, *, replay: ReplayPolicy = ReplayPolicy.NO_REPLAY) -> str: + self.queries.append("*OPC?") + return "1" + + def write(self, command: str) -> None: + self.writes.append(command) + + def write_bytes(self, command: bytes) -> None: + self.writes.append("BINARY_WRITE") + + def close(self) -> None: + self.closed += 1 + + +def _profile() -> ScopeWaveformBinaryProfile: + return ScopeWaveformBinaryProfile( + operations=( + ScopeWaveformBinaryOperationProfile( + operation_kind="fetch", + response_max_bytes=1_024, + operation_max_bytes=4_096, + query_max_count=4, + resynchronization_max_bytes=0, + restore_order=("scope.waveform_source",), + snapshot_max_steps=1, + restore_max_steps=1, + verify_max_steps=1, + ), + ) + ) + + +def _descriptor(*, factory, profile: ScopeWaveformBinaryProfile | None) -> InstrumentDescriptor: + capabilities = ("scope.idn", "scope.fetch_waveform") if profile is not None else ("scope.idn",) + return InstrumentDescriptor( + driver_id="example.waveform", + kind="scope", + display_name="Example", + manufacturer="Example", + models=("EX1",), + aliases=(), + capabilities=capabilities, + idn_patterns=("EXAMPLE",), + backends=("pyvisa",), + option_specs=(), + permissions=("instrument.io",), + factory=factory, + wavebench_min_version="0.8.24", + scope_extensions=( + ScopeDescriptorExtensions(waveform_binary_profile=profile) + if profile is not None + else None + ), + ) + + +class _BoundedDriver: + def idn(self) -> str: + return "EXAMPLE,EX1" + + def close(self) -> None: + pass + + def snapshot_waveform_transfer_state(self, fields): + return object() + + def restore_waveform_transfer_state(self, baseline): + return object() + + def verify_waveform_transfer_state_restored(self, baseline): + return object() + + def fetch_waveform_bounded(self, channel, points="dmax", *, baseline): + return object() + + +def _open() -> object: + return open_instrument_driver( + driver_reference="example.waveform", + expected_kind="scope", + resource="TCPIP::example::INSTR", + configured_backend="pyvisa", + timeout_ms=1_000, + opc_timeout_ms=2_000, + read_retry_attempts=1, + read_retry_delay_ms=1, + logger=CommandLogger(), + ) + + +def test_opt_in_factory_latch_blocks_all_instrument_io_until_validation(monkeypatch) -> None: + inner = _InnerTransport() + errors: list[TransportIOError] = [] + + def factory(context): + transport = context.open_transport() + calls = ( + lambda: transport.write("STOP"), + lambda: transport.write_bytes(b"x"), + lambda: transport.query("*IDN?"), + lambda: transport.query_float_list("MEAS?"), + lambda: transport.query_bin_block("WAV?"), + lambda: transport.query_binary( + "WAV?", + framing=BinaryResponseFraming.DEFINITE_BLOCK, + max_bytes=1, + ), + lambda: transport.query_opc(), + ) + for call in calls: + with pytest.raises(TransportIOError) as raised: + call() + errors.append(raised.value) + return _BoundedDriver() + + descriptor = _descriptor(factory=factory, profile=_profile()) + monkeypatch.setattr( + "wavebench.instruments.factory.resolve_instrument_descriptor", + lambda reference, expected_kind: descriptor, + ) + monkeypatch.setattr("wavebench.instruments.factory._open_transport", lambda **kwargs: inner) + monkeypatch.setattr( + "wavebench.instruments.factory._validate_bounded_binary_transport", + lambda **kwargs: None, + ) + + opened = _open() + + assert len(errors) == 7 + assert all(error.reason_code == "factory_construction_pending" for error in errors) + assert all(error.attempts == 0 for error in errors) + assert inner.queries == [] + assert inner.writes == [] + assert opened.transport._has_verified_bounded_binary_backend() + assert opened.transport._has_verified_bounded_waveform_backend() + assert opened.transport.query("*IDN?") == "ok" + assert inner.queries == ["*IDN?"] + + +def test_opt_in_factory_validation_failure_closes_without_probe(monkeypatch) -> None: + inner = _InnerTransport() + + class MissingBoundedDriver: + def idn(self) -> str: + return "EXAMPLE,EX1" + + def close(self) -> None: + pass + + def snapshot_waveform_transfer_state(self, fields): + return object() + + def restore_waveform_transfer_state(self, baseline): + return object() + + def verify_waveform_transfer_state_restored(self, baseline): + return object() + + descriptor = _descriptor( + factory=lambda context: (context.open_transport(), MissingBoundedDriver())[1], + profile=_profile(), + ) + monkeypatch.setattr( + "wavebench.instruments.factory.resolve_instrument_descriptor", + lambda reference, expected_kind: descriptor, + ) + monkeypatch.setattr("wavebench.instruments.factory._open_transport", lambda **kwargs: inner) + + with pytest.raises(ConfigError, match="fetch_waveform_bounded"): + _open() + + assert inner.queries == [] + assert inner.writes == [] + assert inner.closed == 1 + + +def test_opt_in_factory_rejects_untrusted_backend_without_probe(monkeypatch) -> None: + inner = _InnerTransport() + descriptor = _descriptor( + factory=lambda context: (context.open_transport(), _BoundedDriver())[1], + profile=_profile(), + ) + monkeypatch.setattr( + "wavebench.instruments.factory.resolve_instrument_descriptor", + lambda reference, expected_kind: descriptor, + ) + monkeypatch.setattr("wavebench.instruments.factory._open_transport", lambda **kwargs: inner) + + with pytest.raises(ConfigError, match="bounded PyVISA or RsInstrument"): + _open() + + assert inner.queries == [] + assert inner.writes == [] + assert inner.closed == 1 + + +def test_legacy_factory_is_not_latched(monkeypatch) -> None: + inner = _InnerTransport() + + class LegacyDriver: + def idn(self) -> str: + return "EXAMPLE,EX1" + + def close(self) -> None: + pass + + def factory(context): + context.open_transport().query("*IDN?") + return LegacyDriver() + + descriptor = _descriptor(factory=factory, profile=None) + monkeypatch.setattr( + "wavebench.instruments.factory.resolve_instrument_descriptor", + lambda reference, expected_kind: descriptor, + ) + monkeypatch.setattr("wavebench.instruments.factory._open_transport", lambda **kwargs: inner) + + _open() + + assert inner.queries == ["*IDN?"]