diff --git a/.gitignore b/.gitignore index 4e9f5cb..19f8666 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Rei scratchpad / local helper workspace tool-of-rei/ +.codegraph/ # Local agent/Codex metadata /.agents/* 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 bd95255..356f594 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" @@ -246,6 +246,24 @@ selection 与 storage digest、允许 playback mode;true-ARB 还要求 sample selection 只允许目标输出 OFF,完成后仍为 OFF,不会隐式 ON。声明任一 ARB V2 capability 后,V1 `upload_arbitrary_waveform` 会在本地文件读取和仪器 I/O 前被拒绝,不能把混合 upload/selection/ON 的旧 route 部分映射。 +单一、无命名的易失 ARB 工作区使用 `source.arbitrary_volatile_replace_v2`。driver 实现 +`replace_source_arbitrary_volatile_v2(request, payload)`;request 只记录精确 payload 的主机 SHA-256、字节数和点数, +不包含 payload。ARB profile 必须同时声明 `volatile_replace_min_points`、`volatile_replace_max_points` 与 +`volatile_replace_max_payload_bytes`;descriptor 还必须声明 `source.output_v2`,以便二进制写入后发生异常时由 Core +只尝试一次 OFF 收敛。上传后必须独立确认当前 basic waveform 为 `arbitrary`、已选择 driver 返回的工作区 ID,且目标输出仍为 OFF。 + +该 capability 不表示具名 storage,不得填造设备侧 digest、内容读回或旧内容可恢复性。它也不等价于旧 +`upload_arbitrary_waveform`:旧 route 还包含播放频率、Vpp/offset、可选输出 ON 和旧 artifact 语义。不得将旧 route +部分改写为 volatile replace;需要公开该组合时,应另行定义完整的复合 capability、artifact 与验收。 + +Counter 使用独立的 `source.counter_configure_v2`、`source.counter_enable_v2` 与 +`source.counter_measure_v2`。descriptor 必须声明 Counter `READ`,配置还需 `CONFIGURE` 与可读的 +`configurable_fields`,启停还需成对的 `ENABLE`/`DISABLE` 与 `enabled_configurable`。每次配置 request +只能设置 coupling、input impedance、attenuation、trigger level 或 statistics enable 中的一项;Core 不会因配置或 +测量自动启用 Counter,也不会写 `AUTO`、gate、HF、sensitivity、display 或 statistics clear。配置和启停在写后必须独立 +回读;结果不明时不自动回滚或 disable,连接保守失效。测量只允许已启用的输入,并以单条受授权查询进入 driver。当前 +CLI 与 run schema 不提供这三项入口;production descriptor 必须另有对应的实机证据才能声明 capability。 + 跨通道 Combine、Coupling、Tracking 和相位关系分别使用 `source.combine_configure_v2`、 `source.coupling_configure_v2`、`source.tracking_configure_v2` 与 `source.phase_relation_configure_v2`。每项都使用 独立 driver method,request 只包含递增且唯一的 channel set 与 enabled state。descriptor 必须为该 relation 的 diff --git "a/docs/project/guides/WaveBench_run_plan_\344\275\277\347\224\250\346\214\207\345\215\227.md" "b/docs/project/guides/WaveBench_run_plan_\344\275\277\347\224\250\346\214\207\345\215\227.md" index f3824b2..815458e 100644 --- "a/docs/project/guides/WaveBench_run_plan_\344\275\277\347\224\250\346\214\207\345\215\227.md" +++ "b/docs/project/guides/WaveBench_run_plan_\344\275\277\347\224\250\346\214\207\345\215\227.md" @@ -419,7 +419,7 @@ state = "on" ### Source V2 基础、高级配置与 ARB 写 step -声明 `source.snapshot_v2` 与对应写 capability 的插件可以使用十七个 Source V2 step。基础配置只在目标输出已关闭时执行;输出 ON 与 OFF 分别使用不同 step: +声明 `source.snapshot_v2` 与对应写 capability 的插件可以使用 Source V2 step。基础配置只在目标输出已关闭时执行;输出 ON 与 OFF 分别使用不同 step: ```toml [[steps]] @@ -438,6 +438,23 @@ channel = 1 kind = "source.output_disable_v2" channel = 1 +[[steps]] +kind = "source.counter_configure_v2" +input_id = "counter" +coupling = "ac" + +[[steps]] +kind = "source.counter_enable_v2" +input_id = "counter" + +[[steps]] +kind = "source.counter_measure_v2" +input_id = "counter" + +[[steps]] +kind = "source.counter_disable_v2" +input_id = "counter" + [[steps]] kind = "source.harmonics_configure_v2" channel = 1 @@ -522,6 +539,12 @@ enabled = true 要求 `channel`、整数 `order >= 2` 与 `all`、`even`、`odd` 之一的 `preset`;核心还会在执行前检查运行时 profile 是否支持该 order 和预设。`source.modulation_configure_v2` 要求 `channel`、位于 `[0, 100]` 的 `depth_percent` 与有限正值 `internal_frequency_hz`;它只配置内部正弦 AM。 +`source.counter_configure_v2` 要求安全 token 形式的 `input_id`,并在 coupling、 +`impedance_ohm`、`attenuation`、`trigger_level_v` 与 `statistics_enabled` 中恰好指定一个字段。 +它不启用 Counter。`source.counter_enable_v2` 与 `source.counter_disable_v2` 只接受 `input_id`, +不隐式改写输入配置;`source.counter_measure_v2` 也只接受 `input_id`,要求 Counter 已启用。 +Counter 测量 artifact 位于该 step 的 `counter_measurement`,不进入 `source_operations`。实际信号、 +输入阻抗与最大 Vpp 仍必须由计划 safety 和接线确认;Counter step 不替代输出安全门。 `source.modulation_pm_configure_v2` 要求 `channel`、位于 `[0, 360]` 的 `phase_deviation_deg` 与有限正值 `internal_frequency_hz`;它只配置内部正弦 PM。 `source.modulation_fm_configure_v2` 要求 `channel`、有限正值 `frequency_deviation_hz` 与有限正值 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 8c0db84..5248bad 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" @@ -429,7 +429,11 @@ I/O 前拒绝嵌入请求。需要 v2 截图时使用独立的 `scope screenshot | `source.arbitrary_upload` | `upload_dg4000_dac14_block` | | `source.snapshot_v2` | `execute_source_query_plan_v2` | | `source.basic_configure_v2` | `configure_source_basic_v2` | +| `source.basic_live_configure_v2` | `configure_source_basic_live_v2` | | `source.output_v2` | `set_source_output_v2` | +| `source.counter_configure_v2` | `configure_source_counter_v2` | +| `source.counter_enable_v2` | `set_source_counter_enabled_v2` | +| `source.counter_measure_v2` | `measure_source_counter_v2` | | `source.harmonics_configure_v2` | `configure_source_harmonics_v2` | | `source.harmonics_disable_v2` | `disable_source_harmonics_v2` | | `source.modulation_configure_v2` | `configure_source_modulation_v2` | @@ -448,7 +452,8 @@ I/O 前拒绝嵌入请求。需要 v2 截图时使用独立的 `scope screenshot ### Source V2 扩展 -`source.snapshot_v2`、`source.basic_configure_v2`、`source.output_v2`、 +`source.snapshot_v2`、`source.basic_configure_v2`、`source.basic_live_configure_v2`、`source.output_v2`、 +`source.counter_configure_v2`、`source.counter_enable_v2`、`source.counter_measure_v2`、 `source.harmonics_configure_v2`、`source.harmonics_disable_v2`、`source.modulation_configure_v2`、`source.modulation_pm_configure_v2`、`source.modulation_fm_configure_v2`、`source.modulation_pwm_configure_v2`、`source.sweep_configure_v2`、`source.burst_configure_v2`、`source.pulse_configure_v2`、`source.arbitrary_storage_v2`、`source.arbitrary_select_v2`、`source.combine_configure_v2`、`source.coupling_configure_v2`、`source.tracking_configure_v2` 和 `source.phase_relation_configure_v2` 从核心 `0.8.24` 开始提供,仍使用 `wavebench.instrument.v2`。采用任一 Source V2 capability 的 wheel 依赖和 descriptor `wavebench_min_version` 都必须为 `0.8.24` 或更高的 `0.8.x` 版本。 @@ -456,7 +461,8 @@ wheel 依赖和 descriptor `wavebench_min_version` 都必须为 `0.8.24` 或更 修改 descriptor 或提高版本下限。 插件从 `wavebench.instruments` 导入 `SourceDescriptorExtensions`、`SourceSnapshotV2Driver`、 -`SourceBasicConfigureV2Driver`、`SourceOutputV2Driver`、`SourceHarmonicConfigureV2Driver`、`SourceHarmonicDisableV2Driver`、 +`SourceBasicConfigureV2Driver`、`SourceBasicLiveConfigureV2Driver`、`SourceOutputV2Driver`、 +`SourceCounterConfigureV2Driver`、`SourceCounterEnableV2Driver`、`SourceCounterMeasureV2Driver`、`SourceHarmonicConfigureV2Driver`、`SourceHarmonicDisableV2Driver`、 `SourceModulationConfigureV2Driver`、`SourcePmModulationConfigureV2Driver`、`SourceFmModulationConfigureV2Driver`、`SourcePwmModulationConfigureV2Driver`、`SourceSweepConfigureV2Driver`、`SourceBurstConfigureV2Driver`、`SourcePulseConfigureV2Driver`、`SourceArbitraryStorageV2Driver`、`SourceArbitrarySelectV2Driver`、`SourceCombineConfigureV2Driver`、`SourceCouplingConfigureV2Driver`、`SourceTrackingConfigureV2Driver`、`SourcePhaseRelationConfigureV2Driver`、query plan/execution record 和各类 typed profile。核心签发 semantic query plan;snapshot driver 只负责将 item 转成合法的厂商协议查询并返回类型化执行记录。插件不得返回完整 `SourceSnapshotV2`,也不得自行判定 `UNSUPPORTED`、 @@ -477,7 +483,11 @@ wavebench source snapshot-v2 ```text SourceService.configure_basic_v2(request, *, correlation_id=None) +SourceService.configure_basic_live_v2(request, *, correlation_id=None) SourceService.set_output_v2(request, *, correlation_id=None) +SourceService.configure_counter_v2(request, *, correlation_id=None) +SourceService.set_counter_enabled_v2(request, *, correlation_id=None) +SourceService.measure_counter_v2(request, *, correlation_id=None) SourceService.configure_harmonics_v2(request, *, correlation_id=None) SourceService.disable_harmonics_v2(request, *, correlation_id=None) SourceService.configure_modulation_v2(request, *, correlation_id=None) @@ -490,7 +500,12 @@ SourceService.configure_pulse_v2(request, *, correlation_id=None) SourceService.mutate_arbitrary_storage_v2(request, *, payload, correlation_id=None) SourceService.select_arbitrary_v2(request, *, correlation_id=None) wavebench source basic-configure-v2 --channel N ... +wavebench source basic-live-configure-v2 --channel N (--frequency-hz HZ | --amplitude-vpp VPP) wavebench source output-v2 --channel N on|off +wavebench source counter-configure-v2 --input-id INPUT_ID +wavebench source counter-enable-v2 --input-id INPUT_ID +wavebench source counter-disable-v2 --input-id INPUT_ID +wavebench source counter-measure-v2 --input-id INPUT_ID wavebench source harmonics-configure-v2 --channel N --order N --preset all|even|odd wavebench source harmonics-disable-v2 --channel N wavebench source modulation-configure-v2 --channel N --depth-percent PERCENT --internal-frequency-hz HZ @@ -504,7 +519,8 @@ wavebench source arbitrary-storage-v2 --channel N --slot-id SLOT --payload-file wavebench source arbitrary-select-v2 --channel N --slot-id SLOT --playback-mode dds|true-arb (--playback-frequency-hz HZ | --sample-rate-hz HZ) ``` -十三个 Service 方法分别返回 `(typed_result, operation_artifact)`。`operation_artifact` 使用 +十六个 mutation Service 方法分别返回 `(typed_result, operation_artifact)`; +`measure_counter_v2()` 是只读方法,直接返回 `SourceCounterMeasureResult`。`operation_artifact` 使用 `wavebench.source.operation.v1`,不得包含 raw SCPI、完整响应、资源地址、序列号、授权 token 或 nonce。 `source.harmonics_configure_v2` 只允许单通道的 `all`、`even`、`odd` 预设。descriptor 必须同时声明 @@ -566,9 +582,10 @@ selection request 使用 DDS 的 `playback_frequency_hz` 或 true-ARB 的 `sampl 并回读 Basic `arbitrary` waveform、selected slot、mode、对应速率和 storage digest;它不会授权 output ON。若声明任一 ARB V2 capability,V1 `upload_arbitrary_waveform` 会在读取本地 waveform 文件、构造块或发送仪器 I/O 前拒绝。 -run plan 接受 `source.basic_configure_v2`、`source.output_enable_v2`、`source.output_disable_v2`、 -`source.harmonics_configure_v2`、`source.harmonics_disable_v2`、`source.modulation_configure_v2`、`source.modulation_pm_configure_v2`、`source.modulation_fm_configure_v2`、`source.modulation_pwm_configure_v2`、`source.sweep_configure_v2`、`source.burst_configure_v2`、`source.pulse_configure_v2`、`source.arbitrary_storage_v2`、`source.arbitrary_select_v2`、`source.combine_configure_v2`、`source.coupling_configure_v2`、`source.tracking_configure_v2` 与 `source.phase_relation_configure_v2` 十八个 Source V2 step;它们的 artifact 只在实际执行时写入 -`run.json.source_operations`。 +run plan 接受 `source.basic_configure_v2`、`source.basic_live_configure_v2`、`source.output_enable_v2`、`source.output_disable_v2`、 +`source.counter_configure_v2`、`source.counter_enable_v2`、`source.counter_disable_v2`、`source.counter_measure_v2`、 +`source.harmonics_configure_v2`、`source.harmonics_disable_v2`、`source.modulation_configure_v2`、`source.modulation_pm_configure_v2`、`source.modulation_fm_configure_v2`、`source.modulation_pwm_configure_v2`、`source.sweep_configure_v2`、`source.burst_configure_v2`、`source.pulse_configure_v2`、`source.arbitrary_storage_v2`、`source.arbitrary_select_v2`、`source.combine_configure_v2`、`source.coupling_configure_v2`、`source.tracking_configure_v2` 与 `source.phase_relation_configure_v2` 二十三个 Source V2 step;mutation artifact 只在实际执行时写入 +`run.json.source_operations`,而 `source.counter_measure_v2` 使用其 step 专有的 `counter_measurement` artifact。 旧 `source.*` setter、output、trigger 和 ARB 路径继续保留。双合同插件上,四个 basic setter 与 `set_output` 会进入相应 V2 transaction;restore、ARB upload 和 V2 output 重叠的 trigger 在仪器 I/O 前 diff --git "a/docs/project/rfcs/WaveBench_source\350\203\275\345\212\233\347\212\266\346\200\201\344\270\216\345\244\215\345\220\210\350\276\223\345\207\272\345\256\211\345\205\250RFC.md" "b/docs/project/rfcs/WaveBench_source\350\203\275\345\212\233\347\212\266\346\200\201\344\270\216\345\244\215\345\220\210\350\276\223\345\207\272\345\256\211\345\205\250RFC.md" index acf1fe0..b13b094 100644 --- "a/docs/project/rfcs/WaveBench_source\350\203\275\345\212\233\347\212\266\346\200\201\344\270\216\345\244\215\345\220\210\350\276\223\345\207\272\345\256\211\345\205\250RFC.md" +++ "b/docs/project/rfcs/WaveBench_source\350\203\275\345\212\233\347\212\266\346\200\201\344\270\216\345\244\215\345\220\210\350\276\223\345\207\272\345\256\211\345\205\250RFC.md" @@ -7,9 +7,12 @@ > 实施状态:P0、M1–M4、M4.5、C1、M5-A、M5-B、M5-C、M5-D、C2 与 M6-A 的 Harmonic 配置/关闭、内部 AM、WIDTH Pulse、内部 PM、内部 Triggered Burst、内部 FM、内部 PWM、内部 Sweep 子项已进入核心 > `0.8.24` 开发线;R7 已接受。 > 当前注册 `source.snapshot_v2`、`source.basic_configure_v2`、`source.output_v2` 和 -> `source.harmonics_configure_v2`、`source.harmonics_disable_v2`、`source.modulation_configure_v2`、`source.pulse_configure_v2`、`source.modulation_pm_configure_v2`、`source.burst_configure_v2`、`source.modulation_fm_configure_v2`、`source.modulation_pwm_configure_v2`、`source.sweep_configure_v2`;M5-A 只冻结公共合同与 descriptor 校验,M5-B/M5-C 提供事务底座, +> `source.harmonics_configure_v2`、`source.harmonics_disable_v2`、`source.modulation_configure_v2`、`source.pulse_configure_v2`、`source.modulation_pm_configure_v2`、`source.burst_configure_v2`、`source.burst_fire_v2`、`source.modulation_fm_configure_v2`、`source.modulation_pwm_configure_v2`、`source.sweep_configure_v2`、`source.sweep_fire_v2`、`source.arbitrary_volatile_replace_v2`、`source.arbitrary_workspace_volatile_replace_v2` 及三项 Counter capability;M5-A 只冻结公共合同与 descriptor 校验,M5-B/M5-C 提供事务底座, > M5-D 已开放受限的 Source V2 写入口,C2 已补齐候选发布的核心兼容与离线发布物门。M6-A 已完成; > 在该里程碑范围内,Harmonic、内部 AM、WIDTH Pulse、内部 PM、内部 Triggered Burst、内部 FM、内部 PWM 与内部 Sweep 子项均具备公开 Service、CLI 与 run plan 入口。 +> 本分支另记录 R8 候选设计:修正 Coupling 写合同,并拆分 Noise Overlay 与 Sync 写事务。 +> 该候选设计尚未接受或实现;它不新增 Noise/Sync capability,也不改变已注册的 M6-C +> 布尔 Coupling capability,不影响 R7 的公开行为。 > [!IMPORTANT] > `Accepted R5` 在 R4 的 operation context、受影响字段闭包、phase、nonce、cleanup reserve @@ -53,6 +56,7 @@ Harmonic、Modulation、Sweep、Burst、Pulse、Noise、DC、ARB、Counter、Com | R5 | Accepted | 冻结 M4.5 的 V1 写路由清单和 additive artifact 边界,并实现 C1 的受管 wheel/descriptor PEP 440 交叉门与 V1/V2 兼容 fixture;不注册任何 V2 写 capability | | R6 | Accepted | 冻结基本写入安全、核心接口归属和兼容边界;授权按 M5-A → M5-B → M5-C → M5-D → C2 → M6-A → M6-B → M6-C → M7 → C3 实施 | | R7 | Accepted | 为已关闭输出的 Harmonic 状态增加独立关闭 capability;不改变 basic 写入、V1 签名或输出 ON 准入 | +| R8 候选 | Proposed | 为参数化 Coupling、Noise Overlay 和独立 Sync 物理端口设计写 capability 与恢复事务;不改变当前 capability 注册表 | ## Accepted R5 范围 @@ -212,8 +216,9 @@ mutation 或恢复写入的规则仍作为对应写 capability 的后续准入 15. artifact 不记录授权 token、baseline nonce、完整仪器响应、真实资源串、序列号或凭据。 16. 插件未声明 Source V2 时,核心不会从型号、方法存在或 V1 profile 自动推导 V2 写能力。 17. Source V2 能量增加操作必须显式配置 Vpp 与端口绝对电压上下限;缺失不表示无限制。 -18. Source V2 首个可写修订只允许相关输出 OFF 时配置;该限制不追溯改变 V1 行为,也不表示 - 仪器硬件不支持 live mutation。 +18. `source.basic_configure_v2` 与高级配置仍只允许相关输出 OFF 时执行。只有独立声明 + `source.basic_live_configure_v2` 的设备,才允许在输出已证明为 ON 时执行受限单字段修改;该能力 + 不追溯改变未声明能力的 V1 行为。 19. storage mutation、波形选择/配置和输出 ON 是三个独立 operation,不共享一次准入决定。 ## R2 公共集成合同 @@ -245,6 +250,7 @@ class SourceDescriptorExtensions: features: tuple[SourceFeatureCapability, ...] query_contract: SourceQueryContract safety_profile: SourceSafetyProfile = SourceSafetyProfile() + v1_route_migration_enabled: bool = True @dataclass(frozen=True, slots=True) @@ -273,6 +279,12 @@ class SourceFeatureCapability: - profile 只能收紧核心数值上限、deadline 和恢复步骤,不能放宽; - 使用 Source V2 的插件必须提高 wheel 与 descriptor 的最低核心版本。 +`v1_route_migration_enabled` 是 append-only 的 descriptor 迁移策略,默认值为 `true`。默认策略下, +核心按已注册 V2 capability 接管可无损映射的 V1 route,或在无法无损映射时于 I/O 前拒绝。 +设为 `false` 时,显式 V2 Service、CLI 与 run step 仍可用,但 Basic/Output 的既有 V1 setter、 +restore、ARB upload 与 trigger 不因这几个 V2 capability 自动换路。该开关不取消其它已单独声明 +V2 capability 的重叠 route 安全门。 + R2 不改变现有 eager factory 合同:factory 可以调用 `DriverContext.open_transport()`。因此 Protocol 方法缺失能够保证「零 Source operation 命令」,不能保证「零连接建立」。离线 A0 与插件 发布检查必须在真实资源使用前发现这类声明错误。插件绕过 `DriverContext` 自行访问设备或网络 @@ -347,7 +359,6 @@ SourceBurstCapabilityProfile SourceBurstMode SourceCascadeState SourceChannelStateV2 -SourceClockSyncCapabilityProfile SourceComponentAmplitude SourceConstraintApplicability SourceCounterCapabilityProfile @@ -578,6 +589,87 @@ SourcePhaseRelationConfigureV2Driver SOURCE_PHASE_RELATION_CONFIGURE_V2_OPERATION_CONTRACT ``` +首次稳定版 Coupling 只读模型修正在上述清单末尾追加以下精确条目: + +```text +SourceCouplingCapabilityProfile +SourceCouplingDimension +SourceCouplingDimensionState +SourceCouplingParameter +SourceCouplingParameterKind +SourceCouplingState +``` + +首次稳定版 Sync 只读模型修正在上述清单末尾追加以下精确条目: + +```text +SourceReferenceClockCapabilityProfile +SourceSyncCapabilityProfile +SourceSyncPolarity +SourceCascadeCapabilityProfile +``` + +首次稳定版 Noise Overlay 只读模型在上述清单末尾追加以下精确条目: + +```text +NoiseOverlayFacet +SourceNoiseOverlayCapabilityProfile +SourceNoiseOverlayScale +SourceNoiseOverlayScaleKind +``` + +D1-2/输出开启时的受限 Basic 修改在上述清单末尾追加以下精确条目: + +```text +SourceBasicLiveConfigureResult +SourceBasicLiveConfigureV2Driver +SOURCE_BASIC_LIVE_CONFIGURE_V2_OPERATION_CONTRACT +``` + +D1-3/Burst 与 Sweep fire 在上述清单末尾追加以下精确条目: + +```text +SourceFireRequest +SourceFireResult +SourceBurstFireV2Driver +SourceSweepFireV2Driver +SOURCE_BURST_FIRE_V2_OPERATION_CONTRACT +SOURCE_SWEEP_FIRE_V2_OPERATION_CONTRACT +``` + +D1-4/volatile ARB 与 Counter 收口在上述清单末尾追加以下精确条目: + +```text +SourceArbitraryVolatileReplaceRequest +SourceArbitraryVolatileReplaceResult +SourceArbitraryVolatileReplaceV2Driver +SOURCE_ARBITRARY_VOLATILE_REPLACE_V2_OPERATION_CONTRACT +SourceCounterConfigurationField +SourceCounterConfigurationPatch +SourceCounterConfigureRequest +SourceCounterConfigureResult +SourceCounterConfigureV2Driver +SOURCE_COUNTER_CONFIGURE_V2_OPERATION_CONTRACT +SourceCounterEnableRequest +SourceCounterEnableResult +SourceCounterEnableV2Driver +SOURCE_COUNTER_ENABLE_V2_OPERATION_CONTRACT +SOURCE_COUNTER_DISABLE_V2_OPERATION_CONTRACT +SourceCounterMeasureRequest +SourceCounterMeasureResult +SourceCounterMeasureV2Driver +``` + +D1-5/无通道 VOLATILE workspace 在上述清单末尾追加以下精确条目: + +```text +SourceArbitraryWorkspaceCapabilityProfile +SourceArbitraryWorkspaceVolatileReplaceRequest +SourceArbitraryWorkspaceVolatileReplaceResult +SourceArbitraryWorkspaceVolatileReplaceV2Driver +SOURCE_ARBITRARY_WORKSPACE_VOLATILE_REPLACE_V2_OPERATION_CONTRACT +``` + ### capability 与 Protocol capability 仍是粗粒度路由,精确功能和方向由 `SourceDescriptorExtensions` 收紧。 @@ -607,6 +699,7 @@ R2 否决统一的 `source.patch_v2`、`source.arm_v2` 和 `source.fire_v2`。 | capability | required method | 范围 | | --- | --- | --- | | `source.basic_configure_v2` | `configure_source_basic_v2` | 基础函数、频率、Vpp、偏置和方波参数 | +| `source.basic_live_configure_v2` | `configure_source_basic_live_v2` | 输出 ON 时单独修改频率或 Vpp | | `source.output_v2` | `set_source_output_v2` | 单独的 ON/OFF 转换 | | `source.harmonics_configure_v2` | `configure_source_harmonics_v2` | 谐波配置 | | `source.harmonics_disable_v2` | `disable_source_harmonics_v2` | 关闭 Harmonic 状态 | @@ -619,6 +712,11 @@ R2 否决统一的 `source.patch_v2`、`source.arm_v2` 和 `source.fire_v2`。 | `source.burst_configure_v2` | `configure_source_burst_v2` | Burst 配置 | | `source.arbitrary_storage_v2` | `mutate_source_arbitrary_storage_v2` | 创建或覆盖 ARB 存储槽位 | | `source.arbitrary_select_v2` | `select_source_arbitrary_v2` | 选择并配置已存在的 ARB | +| `source.arbitrary_volatile_replace_v2` | `replace_source_arbitrary_volatile_v2` | 替换通道唯一的易失 ARB 工作区;上传会选择该工作区 | +| `source.arbitrary_workspace_volatile_replace_v2` | `replace_source_arbitrary_workspace_volatile_v2` | 替换无通道归属的易失工作区;不声明任一通道已选择该内容 | +| `source.counter_configure_v2` | `configure_source_counter_v2` | 单字段 Counter 输入配置 | +| `source.counter_enable_v2` | `set_source_counter_enabled_v2` | 单独启用或关闭 Counter | +| `source.counter_measure_v2` | `measure_source_counter_v2` | 对已启用 Counter 的只读测量 | | `source.combine_configure_v2` | `configure_source_combine_v2` | Combine 关系 | | `source.coupling_configure_v2` | `configure_source_coupling_v2` | Coupling 关系 | | `source.tracking_configure_v2` | `configure_source_tracking_v2` | Tracking 关系 | @@ -634,8 +732,8 @@ R2 否决统一的 `source.patch_v2`、`source.arm_v2` 和 `source.fire_v2`。 R6/M6-A 将 `source.modulation_pm_configure_v2`、`source.burst_configure_v2` 与 `source.modulation_fm_configure_v2`、`source.modulation_pwm_configure_v2`、`source.sweep_configure_v2` 明确加入已授权的窄 capability。 现有 `source.modulation_configure_v2` 保持内部 AM 的首版语义,不因 PM 子项扩张;分离 capability 使 PM-only -插件不会改变 V1 AM route,也使 AM-only 插件无需提供 PM 入口。Burst capability 也不授权 arm、fire 或任何 -输出开启路径。 +插件不会改变 V1 AM route,也使 AM-only 插件无需提供 PM 入口。配置 capability 不授权 arm、fire 或任何 +输出开启路径。D1-3 另行注册 `source.burst_fire_v2` 与 `source.sweep_fire_v2`;两者仍不授权 arm。 Source V2 驱动不接收 `SessionAuthorization`、`InstrumentSessionState` 或 raw transport handle。 核心在授权 phase 中调用已冻结的 driver 方法,driver 只返回公共类型化 model。 @@ -836,6 +934,8 @@ epoch 和 baseline。R4 已把这些两个 model、固定 phase 和 core-only co `UNKNOWN` energy/storage effect 在 I/O 前拒绝。`POTENTIAL_WHILE_OFF` 要求全部 `required_off_outputs` 已通过 fresh readback 证明 OFF,并且 operation 本身不能打开输出。 +`LIVE_MUTATION` 要求目标输出已通过 fresh readback 证明 ON,并且只能执行 capability 明确声明的 +单字段修改;失败恢复只能关闭输出。 `MAY_INCREASE` 与 `EMIT` 必须通过统一预算门。`DECREASE_ONLY` 不需要预算,但仍需 access、 session、operation context 和 postcondition。 @@ -844,21 +944,22 @@ session、operation context 和 postcondition。 | 路径 | 统一要求 | | --- | --- | | V2 输出 ON | fresh 一致 snapshot + 预算 + 写后回读 | -| V1 同义写入口调用双合同驱动 | 在 Service 边界映射到对应 V2 operation,无法无损映射时在 I/O 前拒绝 | +| V1 同义写入口调用双合同驱动 | 当 descriptor 未关闭 V1 route migration 时,在 Service 边界映射到对应 V2 operation;无法无损映射时在 I/O 前拒绝 | | `source.arb_load output_on=true` | 先在输出 OFF 的配置 phase 完成上传或选择,再用 fresh snapshot 签发只授权下一次 ON 的新决定 | -| 输出 ON 时的 Source V2 setter/patch | 首版在 I/O 前拒绝;未来若允许 live mutation,必须对完整目标状态使用专项预算合同 | +| 输出 ON 时的 Source V2 setter/patch | 仅 `source.basic_live_configure_v2` 可单独修改频率或 Vpp;其它 patch 在 I/O 前拒绝 | | arm/fire/trigger | 在可能发出信号前完成预算与接线证据检查 | | 恢复为 ON | 作为独立、显式授权的 ON 操作重新计算预算 | V1 驱动未 opt in 时继续使用现有 V1 路径,不伪装成已获得 Source V2 复合安全保证。 -Source V2 的首版 live-mutation 禁令不追溯改变 V1 驱动的既有行为,也不表示硬件本身不支持 -ON 状态写入。 +未声明 `source.basic_live_configure_v2` 的 V2 驱动继续拒绝 ON 状态配置。V1-only 驱动保持既有 +行为,但不因此获得 Source V2 的 live mutation 安全保证。 每个 V2 写 capability 必须在 `SourceOperationContract` 中登记其 V1 等价入口、重叠字段和可能发出 -信号的间接入口。双合同驱动声明该 capability 后,只有落入这些集合的 V1 路径必须映射到 V2 -operation 或在 I/O 前拒绝;字段闭包完全不相交的 V1 operation 可以继续走 V1。核心仍需审计完整 -V1 写表面,防止遗漏隐式副作用。OFF 不需要复合预算,但不能绕过 access、session health、 -operation context 和必要回读。插件不能通过保留旧方法名重新引入同字段或同发信号路径的旁路。 +信号的间接入口。双合同驱动默认对落入这些集合的 V1 路径映射到 V2 operation 或在 I/O 前拒绝; +若 descriptor 明确关闭 V1 route migration,则保留完整 V1 合同,只有显式 V2 入口调用新 operation。 +字段闭包完全不相交的 V1 operation 可以继续走 V1。核心仍需审计完整 V1 写表面,防止遗漏隐式副作用。 +OFF 不需要复合预算,但不能绕过 access、session health、operation context 和必要回读。插件不能通过 +保留旧方法名重新引入同字段或同发信号路径的旁路。 `source.output_v2` 的等价集合至少包括 V1 `set_output(ON)`、ARB 的 `output_on=True`、会发出信号的 trigger/fire 和恢复 ON;`source.arbitrary_storage_v2` 至少接管 V1 上传入口,但不因此接管无关的 @@ -937,6 +1038,7 @@ class SourceFeature(StrEnum): BURST = "burst" PULSE = "pulse" ARBITRARY = "arbitrary" + ARBITRARY_WORKSPACE = "arbitrary_workspace" COUNTER = "counter" REFERENCE_CLOCK = "reference_clock" SYNC = "sync" @@ -991,6 +1093,8 @@ facet 辅助 enum 也使用封闭 value 集: - `SourceAmplitudeUnit`:`vpp`、`vrms`、`dbm`、`v`、`unknown`; - `SourceOutputPolarity`:`normal`、`inverted`、`unknown`; +- `SourceSyncPolarity`:`positive`、`negative`、`unknown`; +- `SourceNoiseOverlayScaleKind`:`percent`、`ratio`、`ratio_db`; - `SourceLoadKind`:`high_impedance`、`resistive`、`unknown`; - `SourceModulationKind`:`am`、`dsb_am`、`fm`、`pm`、`pwm`、`ask`、`fsk`、`psk`、`other`; - `SourceModulationSource`:`internal`、`external`、`channel`、`unknown`; @@ -1014,8 +1118,8 @@ facet 辅助 enum 也使用封闭 value 集: 机器可读 feature ID 按作用域分为: -- channel:`basic`、`output`、`harmonics`、`modulation`、`sweep`、`burst`、`pulse`、`arbitrary`; -- system:`counter`、`reference_clock`、`sync`、`cascade`; +- channel:`basic`、`output`、`noise_overlay`、`harmonics`、`modulation`、`sweep`、`burst`、`pulse`、`arbitrary`、`sync`; +- system:`counter`、`reference_clock`、`cascade`; - cross-channel:`combine`、`tracking`、`coupling`、`copy`、`phase_relation`、`shared_power`。 正文中的 Harmonic、Sync、Combine 等首字母大写名称只是展示术语;注册表、artifact 和 @@ -1024,7 +1128,7 @@ descriptor 一律使用上述小写 ID,不允许通过大小写或单复数增 feature 集合是核心注册表,不接受插件自定义任意字符串作为新安全语义。厂商专用功能可继续 使用独立 capability,但未经核心注册时不进入通用 Source V2 预算或恢复。 -R2 冻结以下 11 个只读 capability profile。布尔字段只声明该值能否读取,不提供写授权;tuple +R2 冻结以下只读 capability profile。布尔字段只声明该值能否读取,不提供写授权;tuple 使用 enum value 或 ID 的升序并且不重复。 ```python @@ -1045,6 +1149,12 @@ class SourceOutputCapabilityProfile: polarity_readable: bool +@dataclass(frozen=True, slots=True) +class SourceNoiseOverlayCapabilityProfile: + enabled_readable: bool + scale_kinds: tuple[SourceNoiseOverlayScaleKind, ...] = () + + @dataclass(frozen=True, slots=True) class SourceHarmonicCapabilityProfile: minimum_order: int @@ -1095,6 +1205,14 @@ class SourceArbitraryCapabilityProfile: storage_max_payload_bytes: int | None = None +@dataclass(frozen=True, slots=True) +class SourceArbitraryWorkspaceCapabilityProfile: + workspace_id: str + volatile_replace_min_points: int + volatile_replace_max_points: int + volatile_replace_max_payload_bytes: int + + @dataclass(frozen=True, slots=True) class SourceCounterCapabilityProfile: input_ids: tuple[str, ...] @@ -1104,10 +1222,35 @@ class SourceCounterCapabilityProfile: @dataclass(frozen=True, slots=True) -class SourceClockSyncCapabilityProfile: - reference_clock_modes: tuple[SourceReferenceClockMode, ...] - sync_readable: bool - cascade_readable: bool +class SourceReferenceClockCapabilityProfile: + modes: tuple[SourceReferenceClockMode, ...] + frequency_readable: bool + lock_state_readable: bool + + +@dataclass(frozen=True, slots=True) +class SourceSyncCapabilityProfile: + enabled_readable: bool + polarity_readable: bool + source_channel_readable: bool + source_channels: tuple[int, ...] = () + + +@dataclass(frozen=True, slots=True) +class SourceCascadeCapabilityProfile: + enabled_readable: bool + role_readable: bool + + +@dataclass(frozen=True, slots=True) +class SourceCouplingCapabilityProfile: + dimensions: tuple[SourceCouplingDimension, ...] + parameter_kinds: tuple[SourceCouplingParameterKind, ...] + supported_channel_sets: tuple[tuple[int, ...], ...] + global_state_readable: bool + reference_channel_readable: bool + relation_graph_readable: bool + configuration_readable: bool = False @dataclass(frozen=True, slots=True) @@ -1125,14 +1268,19 @@ class SourceCrossChannelCapabilityProfile: SourceFeatureProfile: TypeAlias = ( SourceBasicCapabilityProfile | SourceOutputCapabilityProfile + | SourceNoiseOverlayCapabilityProfile | SourceHarmonicCapabilityProfile | SourceModulationCapabilityProfile | SourceSweepCapabilityProfile | SourceBurstCapabilityProfile | SourcePulseCapabilityProfile | SourceArbitraryCapabilityProfile + | SourceArbitraryWorkspaceCapabilityProfile | SourceCounterCapabilityProfile - | SourceClockSyncCapabilityProfile + | SourceReferenceClockCapabilityProfile + | SourceSyncCapabilityProfile + | SourceCascadeCapabilityProfile + | SourceCouplingCapabilityProfile | SourceCrossChannelCapabilityProfile ) ``` @@ -1140,6 +1288,18 @@ SourceFeatureProfile: TypeAlias = ( feature 与 profile 类型使用固定映射;例如 `HARMONICS` 只能使用 `SourceHarmonicCapabilityProfile`。union 新增成员属于公共合同扩展,必须由核心注册并补版本门。 +Noise Overlay 的 `enabled_readable=False` 时,snapshot 不得返回 `enabled=VALUE`。 +`scales=VALUE` 携带的 scale kind 必须与 `scale_kinds` 完全一致;查询失败仍使用 +对应的非 `VALUE` availability,不得删除已声明的 kind 或补造默认值。 + +Sync 的 `enabled_readable`、`polarity_readable` 和 `source_channel_readable` 也使用同样的 +单向诚实声明:未声明可读的字段不得返回 `VALUE`,运行时暂时无法取得时可以返回 +合适的非 `VALUE` availability。Coupling 的 `CHANNEL_SET` 必须存在于 +`supported_channel_sets`;snapshot 中的 dimensions 必须与 profile 完全一致,已返回的 +parameter kind 必须属于 `parameter_kinds`。`global_state_readable=False` 或 +`reference_channel_readable=False` 时,对应字段不得返回 `VALUE`。全局开关与各 +dimension 开关可以表示主开关和保留配置,核心不强制两者必须相等。 + ### facet 作用域 ```python @@ -1169,10 +1329,10 @@ class SourceTopologyContract: 格式的 `input_id`;`INSTRUMENT` 不携带这些字段。所有通道必须属于 topology。 `SourceTopologyContract.channels` 必须递增、唯一且非空;`input_ids` 必须排序稳定且不重复。 -- `basic`、`output`、`harmonics`、`modulation`、`pulse`、`sweep`、`burst` 和 `arbitrary` +- `basic`、`output`、`noise_overlay`、`harmonics`、`modulation`、`pulse`、`sweep`、`burst`、`arbitrary` 和 `sync` 通常属于 `CHANNEL`; - `combine`、`coupling` 和 `tracking` 属于 `CHANNEL_SET`,并明确列出关系参与者; -- `reference_clock`、`sync` 和 `cascade` 属于 `INSTRUMENT` 或 `CHANNEL_SET`; +- `reference_clock` 和 `cascade` 属于 `INSTRUMENT`; - `counter` 通常属于独立 `INPUT`,只有存在已声明路由关系时才参与输出预算。 descriptor 中的 topology 是静态上界。实际 capability 必须根据已验证的型号、固件、选件和 @@ -1188,6 +1348,7 @@ class SourceFieldId(StrEnum): IDENTITY = "source.identity" BASIC = "source.channel.basic" OUTPUT = "source.channel.output" + NOISE_OVERLAY = "source.channel.noise_overlay" DISPLAY_LOAD = "source.channel.display_load" HARMONICS = "source.channel.harmonics" MODULATION = "source.channel.modulation" @@ -1196,6 +1357,7 @@ class SourceFieldId(StrEnum): PULSE = "source.channel.pulse" ARBITRARY_SELECTION = "source.channel.arbitrary_selection" ARBITRARY_STORAGE = "source.channel.arbitrary_storage" + ARBITRARY_WORKSPACE = "source.instrument.arbitrary_workspace" ARM_STATE = "source.channel.arm_state" TRIGGER_STATE = "source.channel.trigger_state" COMBINE = "source.cross_channel.combine" @@ -1205,7 +1367,7 @@ class SourceFieldId(StrEnum): PHASE_RELATION = "source.cross_channel.phase_relation" RELATION_GRAPH = "source.cross_channel.relation_graph" REFERENCE_CLOCK = "source.instrument.reference_clock" - SYNC = "source.instrument.sync" + SYNC = "source.channel.sync" CASCADE = "source.instrument.cascade" SHARED_POWER = "source.instrument.shared_power" COUNTER = "source.input.counter" @@ -1519,17 +1681,22 @@ class ArbitraryFacet: storage_digest: Observed[str] +@dataclass(frozen=True, slots=True) +class NoiseOverlayFacet: + enabled: Observed[bool] + scales: Observed[tuple[SourceNoiseOverlayScale, ...]] + + @dataclass(frozen=True, slots=True) class SourceSystemStateV2: counters: tuple[SourceCounterInputState, ...] reference_clock: Observed[SourceReferenceClockState] - sync: Observed[SourceSyncState] cascade: Observed[SourceCascadeState] @dataclass(frozen=True, slots=True) class SourceCrossChannelStateV2: - relations: tuple[SourceRelationState, ...] + relations: tuple[SourceRelationState | SourceCouplingState, ...] relation_graph: Observed[SourceRelationGraph] shared_power: Observed[SourceSharedPowerState] @@ -1545,6 +1712,8 @@ class SourceChannelStateV2: burst: Observed[BurstFacet] pulse: Observed[PulseFacet] arbitrary: Observed[ArbitraryFacet] + sync: Observed[SourceSyncState] + noise_overlay: Observed[NoiseOverlayFacet] @dataclass(frozen=True, slots=True) @@ -1573,7 +1742,9 @@ class SourceSnapshotV2: gate_time_s, trigger_level_v, statistics_enabled)`、 `SourceReferenceClockState(mode, frequency_hz, locked)`、 `SourceSyncState(enabled, polarity, source_channel)`、`SourceCascadeState(enabled, role)`、 -`SourceRelationState(feature, channels, enabled)` 和 +`SourceNoiseOverlayScale(kind, value)`、 +`SourceRelationState(feature, channels, enabled)`、 +`SourceCouplingState(feature, channels, enabled, reference_channel, dimensions)` 和 `SourceSharedPowerState(participants, active_power_upper_w, hard_limit_w)`。 每个数值必须有限;频率、时间、阻抗、点数、阶次和功率等非负量不得为负;百分比范围为 @@ -2086,6 +2257,7 @@ class SourceBudgetBlockerCode(StrEnum): MODULATION_CONSTRAINT_MISSING = "modulation_constraint_missing" ARBITRARY_OVERSHOOT_MISSING = "arbitrary_overshoot_missing" NOISE_PEAK_MISSING = "noise_peak_missing" + NOISE_OVERLAY_BOUND_MISSING = "noise_overlay_bound_missing" SWEEP_DERATING_MISSING = "sweep_derating_missing" ACTIVE_CHANNEL_UNKNOWN = "active_channel_unknown" COMBINE_STATE_UNAVAILABLE = "combine_state_unavailable" @@ -2254,6 +2426,10 @@ vpp_upper_v = maximum_v_upper - minimum_v_lower - ARB 必须计入样本归一化、输出滤波或插值可能产生的 overshoot 边界;无法给出边界时为 `UNKNOWN`; - Sweep 在完整频率范围内取最大边界并应用频率降额; - Noise 只有在型号和固件范围内存在已审计的硬峰值上界时,才能进入 `HARD_CONSERVATIVE`; +- Noise Overlay 与基本 Noise 波形是两个独立 contributor。当前只读模型没有冻结叠加噪声的硬电压 + 边界;只有 runtime 明确为 `UNSUPPORTED`/`NOT_APPLICABLE`,或 `enabled=VALUE(False)` 时, + 才能沿用基本波形预算。已启用或启用状态不是 `VALUE` 时返回 + `NOISE_OVERLAY_BOUND_MISSING`;`SourceNoisePeakConstraint` 不得替代该边界; - RMS 无法保守计算时可以为 `None`,但若配置了 RMS 上限,该缺失会成为 blocker; - 多通道共享功率限制必须由 typed constraint 表示或返回可审查的计算结果,不允许使用自由文本声明。 @@ -2302,8 +2478,9 @@ class SafetyLimitsConfig: V1 CLI 或未 opt in 的 V1 driver。 缺少显式安全轴仍允许 `source.snapshot_v2`、正常 OFF、disable,以及已经证明不会发出信号的 -输出 OFF 配置或独立 storage mutation。ON、fire、恢复 ON、可能发出信号的 trigger、live mutation, -以及能量影响为 unknown 的 operation 必须零仪器 I/O 拒绝。 +输出 OFF 配置或独立 storage mutation。ON、fire、恢复 ON、可能发出信号的 trigger、严格预算型 +live mutation,以及能量影响为 unknown 的 operation 必须零仪器 I/O 拒绝。D1-2 的受限 Basic +live mutation 使用 R6 数值门,不要求 R3 的完整复合预算模型。 示例配置中的「缺失表示不限」只能继续描述 V1。Source V2 文案必须明确:缺失表示没有能量转换 授权,而不是无限制。 @@ -2417,9 +2594,9 @@ topology 或 profile 只要声明共享功率关系,就必须提供完整 type context 结束都会使它失效;获准的 ON/fire/trigger action 会一次性消费它。首版禁止用一个决定 同时授权 ARB 上传和 ON,也禁止输出 ON 时执行多字段 live patch。 -R2 规定未来 Source V2 首个可写修订中,高级配置只允许在所有相关输出 OFF 时执行,不自动执行 -「关闭 → 配置 → 重新开启」。未来若允许 live mutation,必须通过本 RFC 修订并复用同一 -写后预算门。该规则不追溯改变未 opt in 的 V1 驱动行为。 +高级配置只允许在所有相关输出 OFF 时执行,不自动执行「关闭 → 配置 → 重新开启」。D1-2 仅为 +Basic 的频率与 Vpp 增加独立单字段 live operation,不授权波形、Offset、占空比、高级功能、ARB +或跨通道关系的 ON 状态修改。该规则不追溯改变未 opt in 的 V1 驱动行为。 OFF 不需要复合预算,但仍需要正常 OFF 权限或核心签发的 recovery 授权。`poisoned` 连接不得 为了 OFF 再发送协议 I/O。 @@ -2607,7 +2784,8 @@ scheme、原始等级、按 `wavebench.source.a0-a5.v1` 重新评定的等级和 | 新核心 + 旧插件 | `source_extensions=None`,保持 V1 路径,不推导 V2 写能力 | | 旧核心 + 新插件 | 受管安装由 wheel `Requires-Dist` 在 entry point import 前拒绝;绕过 package inspection 的直接 `pip --no-deps` 或手工安装不承诺零导入,且不属于支持组合 | | 新核心 + 新插件 | 只对明确声明并通过验证的 Source V2 capability 使用新合同 | -| 新核心 + 同时声明 V1/V2 的新插件 | 新 operation 只使用 V2;同义或副作用重叠的旧写入口映射/拒绝,不相交的旧 operation 保持 V1;单次事务不混用两套安全视图 | +| 新核心 + 同时声明 V1/V2 的新插件 | 新 operation 只使用 V2;默认策略下,同义或副作用重叠的旧写入口映射/拒绝,不相交的旧 operation 保持 V1;单次事务不混用两套安全视图 | +| 新核心 + `v1_route_migration_enabled=false` 的双合同插件 | 只有显式 V2 operation 使用 V2;Basic/Output 自动迁移与由它们引出的 gate 不接管旧 route。已单独声明的高级 V2 capability 仍保留其真实字段/发信号重叠 gate,不能用不完整的 V2 组合绕过 legacy composite transaction | R2 决定保持 `wavebench.instrument.v2`。`source_extensions` 是带默认值的末尾扩展,新 Protocol 不改变现有 `SourceDriver`,新 capability 通过最低核心版本门显式 opt in。只有删除 Source V1、 @@ -2733,7 +2911,7 @@ status、output 和基础 setter,也包括已经发布的 profile、configure 双合同驱动声明某项 V2 写 capability 后,同义或副作用闭包重叠的 V1 写入口必须在 Service 边界 映射到对应 V2 operation,无法无损映射时在 I/O 前拒绝;经审计确认字段闭包和发信号路径均不相交 的 V1 operation 可以继续保持原行为。未声明 V2 写 capability 的 V1-only 驱动保持原行为; -Source V2 首版禁止 live mutation 不追溯改变该路径。 +未声明 `source.basic_live_configure_v2` 的 V2 驱动仍不得经 V1 setter 绕过 ON 状态门。 ### 独立 P0 缺陷修复 @@ -2897,7 +3075,7 @@ R5 已加入以下纯离线兼容 fixture,作为上述要求的持续回归: ## Accepted 决议基线 -1. 11 个 `SourceFeatureProfile`、8 个 channel facet、2 个非通道状态、嵌套 helper、reason code 和 +1. 闭合的 `SourceFeatureProfile` union、8 个 channel facet、2 个非通道状态、嵌套 helper、reason code 和 canonical serializer 按本文冻结,不保留 `object` 或自由 mapping。 2. `source_extensions.__all__`、顶层 identity re-export 和 descriptor append-only 布局按本文冻结。 3. `source.snapshot_v2` 的 `OperationSpec`、Service、CLI JSON、snapshot document 和只读 operation @@ -2970,7 +3148,8 @@ R2 的本段只约束 R2–R5 的 snapshot-only 阶段。R6 已为后续基础 - 复合预算由核心统一消费;插件提供厂商状态、typed constraint 和协议回读。 - `max_source_vpp` 与端口绝对电压上下限必须显式配置,不能互相推导;缺失时 V2 能量操作失败关闭。 - 只有 `HARD_CONSERVATIVE` 可以支持输出 ON 准入。 -- Source V2 首个可写修订禁止 live mutation;未 opt in 的 V1 行为不变。 +- Basic live mutation 是独立 capability,只允许输出 ON 时单独修改频率或 Vpp;未 opt in 的 V1 + 行为不变。 - ARB storage、selection/configuration 和 ON 使用三个独立 operation。 - Source 事务复用核心 session health 和授权底座,不新增平行 `state_uncertain` 布尔值。 - 失败恢复默认以 OFF 结束;重新 ON 是新的授权操作。 @@ -3003,8 +3182,8 @@ Vpp、Offset 和输出状态的设备正常使用信号发生器功能,而不 基础 Source V2 功能。 3. 若同时配置了 `min_source_port_voltage_v` 和 `max_source_port_voltage_v`,核心以 `offset ± Vpp / 2` 检查该端口区间;两个配置均缺失时,不增加额外端口电压限制。 -4. 基础配置要求目标通道在写前为 OFF;不支持 V2 live mutation。独立端口可以同时保持 ON,核心 - 不为缺少共享功率或热模型而全局拒绝。 +4. `source.basic_configure_v2` 要求目标通道在写前为 OFF。独立端口可以同时保持 ON,核心不为 + 缺少共享功率或热模型而全局拒绝。 5. 每个目标字段最多写入一次;写后必须独立回读。结果不明时不得重试;在 session 仍允许 recovery I/O 时请求受影响端口 OFF,`poisoned` session 仍遵守 transport RFC 的 close-only 规则。 6. `source.output_v2` 的 OFF 不因 Vpp、Offset、端接或预算信息缺失而拒绝;ON 使用 fresh snapshot @@ -3014,6 +3193,23 @@ Vpp、Offset 和输出状态的设备正常使用信号发生器功能,而不 插值上界、复杂负载或共享热功率模型。R3 的严格预算模型继续保留给明确选择它的后续 capability, 不能反向限制 R6 的 basic/output 正常路径。 +### D1-2 受限 Basic live mutation + +`source.basic_live_configure_v2` 是独立 capability,不改变 `source.basic_configure_v2` 的 OFF-only +语义。descriptor 必须同时声明 `source.snapshot_v2`、`source.basic_configure_v2`、 +`source.output_v2`,并在每个 Basic profile 中逐字段声明是否允许在线修改频率和 Vpp。 + +每次请求只允许 `frequency_hz` 或 `amplitude_vpp` 中一个字段为 `SET`;waveform、Offset、占空比和 +多字段 patch 在 MAIN 前拒绝。preflight 必须证明 snapshot 一致、目标输出为 ON、频率模式为 FIXED, +并取得最终 Vpp 与 Offset。目标和回读值继续使用 R6 的 `max_source_vpp` 与端口绝对电压检查。 + +MAIN 只调用一次 `configure_source_basic_live_v2()`。结果与 fresh postcondition 必须逐项证明请求值、 +最终 Vpp、Offset 和输出仍为 ON。结果未知、driver 异常或后置条件失败时不重试,也不恢复 ON;Core +只允许一次 `source.output_v2` OFF recovery 与独立回读。V1 `set_frequency()` 和 +`set_amplitude_vpp()` 只在 descriptor 保持默认 migration 策略时,才按已证明的输出状态选择 +OFF-only 或 live operation。显式 P1 入口为 `basic-live-configure-v2` CLI 与 +`source.basic_live_configure_v2` run step;频响、离散扫频与 TUI 继续复用既有 setter。 + Noise 若插件回读的幅度是最终输出 `VPP`,按普通基础波形使用 `offset ± Vpp / 2`;不要求独立 `SourceNoisePeakConstraint`。若设备只能提供标称值、RMS 或载波幅度,插件不得为该模式声明 `source.output_v2`,直到能够返回最终 Vpp 或定义专项 capability。 @@ -3034,10 +3230,12 @@ Noise 若插件回读的幅度是最终输出 `VPP`,按普通基础波形使 artifact 和错误路径,不能用一个方向不明确的 contract 混合表示。 - 新类型、descriptor 字段和 artifact 键必须 append-only;既有 `SourceDriver`、`SourceStatus`、 V1 CLI、V1 run step、V1 JSON 和 V1 artifact 不改变语义。 -- V1-only 插件继续执行 V1 路径。双合同插件声明某项 V2 capability 后,核心在 M5-D 将同义或副作用 - 重叠的 V1 route 映射到 V2。`set_function` 有一项兼容例外:目标波形未在当前 V2 Basic profile 声明, - 或 V2 preflight 无法为当前旧状态提供最终 Vpp/Offset 时,核心继续调用既有 V1 setter,不进入 V2 MAIN - 写入;其余无法无损映射的重叠 route 在仪器 I/O 前拒绝。不相交的 V1 route 保持原行为。 +- V1-only 插件继续执行 V1 路径。双合同插件默认在 M5-D 将同义或副作用重叠的 V1 route 映射到 V2; + `v1_route_migration_enabled=false` 可使显式 V2 Basic/Output surface 与其 legacy transaction 并存, + 但不取消任何已单独声明高级 V2 capability 的真实重叠安全门。 + `set_function` 有一项兼容例外:目标波形未在当前 V2 Basic profile 声明,或 V2 preflight 无法为当前 + 旧状态提供最终 Vpp/Offset 时,核心继续调用既有 V1 setter,不进入 V2 MAIN 写入;其余无法无损映射 + 的重叠 route 在仪器 I/O 前拒绝。不相交的 V1 route 保持原行为。 - M5-D 在同一开发线内先完成 Service/CLI,再增加 V2 run plan step、intent 和 artifact;中间不发布 稳定写接口。 @@ -3064,9 +3262,10 @@ M5-A 只增加闭合的单通道 model,不提供自由 mapping 或通用 patch 声明任一 M5-A 写 capability 的 descriptor 必须同时声明 `source.snapshot_v2`。基础配置要求同一 channel 的 Basic 支持 `READ` 与 `CONFIGURE`,并能回读最终 Vpp、Offset 和输出状态;输出 capability -要求同一 channel 的 Output 支持 `READ`、`ENABLE` 与 `DISABLE`,并能回读输出状态。启用动作在运行时 -另行要求同一 channel 可返回最终 Vpp 与 Offset;关闭动作不以它们为条件。方向、profile、channel 或 -required method 不匹配时,在 factory 及仪器 I/O 前失败。 +至少要求同一 channel 的 Output 支持 `READ` 与 `DISABLE`,并能回读输出状态。`ENABLE` 是可选方向;若声明, +每个可开启 channel 必须也支持 `DISABLE`。这样可为仅需安全关闭的受限 capability 提供 output substrate, +而任意 enable request 仍会在运行时方向校验后、写入前失败。启用动作另行要求同一 channel 可返回最终 Vpp 与 +Offset;关闭动作不以它们为条件。方向、profile、channel 或 required method 不匹配时,在 factory 或相应写入前失败。 M5-A 不增加 `SourceService` 写方法、CLI 写命令或 run plan step;现有 V1 setter、CLI、run plan、TUI 和 artifact 保持原样。capability 注册只让核心识别插件合同,不构成可调用写入口。 @@ -3133,33 +3332,38 @@ M5-D 将 M5-B/M5-C 的唯一核心事务开放为以下 Service 方法: ```python SourceService.configure_basic_v2(request, *, correlation_id=None) +SourceService.configure_basic_live_v2(request, *, correlation_id=None) SourceService.set_output_v2(request, *, correlation_id=None) ``` -两者分别返回 `(typed_result, operation_artifact)`。`typed_result` 是已冻结的 +三者分别返回 `(typed_result, operation_artifact)`。`typed_result` 是已冻结的 `SourceBasicConfigureResult` 或 `SourceOutputResult`;`operation_artifact` 使用 `wavebench.source.operation.v1`。Service 不重新实现 preflight、写入、回读、recovery 或 session health -逻辑,所有入口继续复用 M5-B/M5-C 事务。 +逻辑,所有入口继续复用 M5-B/M5-C 与 D1-2 事务。 -CLI 是 additive 的两个新子命令: +CLI 是 additive 的三个新子命令: ```text wavebench source basic-configure-v2 --channel N \ [--waveform sine|square|ramp|pulse|noise|dc] \ [--frequency-hz HZ] [--amplitude-vpp VPP] [--offset-v V] \ [--square-duty-cycle-percent PERCENT] +wavebench source basic-live-configure-v2 --channel N \ + [--frequency-hz HZ | --amplitude-vpp VPP] wavebench source output-v2 --channel N on|off ``` -`basic-configure-v2` 至少需要一个 basic 字段。普通模式直接输出 operation artifact;`--json` 将它置于 -`wavebench.cli.result.v1.result`。写后失败时,CLI 的 `wavebench.error.v1` 会附加脱敏的 -`source_operation_artifact`。两个新命令不改变既有 V1 CLI 参数或成功 JSON。 +`basic-configure-v2` 至少需要一个 basic 字段。`basic-live-configure-v2` 只接受一个 frequency 或 +Vpp 字段,且由 Service 证明输出为 ON、模式为 FIX。普通模式直接输出 operation artifact;`--json` 将它 +置于 `wavebench.cli.result.v1.result`。写后失败时,CLI 的 `wavebench.error.v1` 会附加脱敏的 +`source_operation_artifact`。三个新命令不改变既有 V1 CLI 参数或成功 JSON。 -run plan 使用三个有方向 step,避免在 intent 中把 ON/OFF 混为同一 operation: +run plan 使用四个有方向 step,避免在 intent 中把 ON/OFF 混为同一 operation: | step kind | 必填字段 | 允许的额外字段 | 对应 operation | | --- | --- | --- | --- | | `source.basic_configure_v2` | `channel` | `waveform_kind`、`frequency_hz`、`amplitude_vpp`、`offset_v`、`square_duty_cycle_percent`;至少一个 | `source.basic_configure_v2` | +| `source.basic_live_configure_v2` | `channel` | `frequency_hz` 或 `amplitude_vpp`;恰好一个 | `source.basic_live_configure_v2` | | `source.output_enable_v2` | `channel` | 无 | `source.output_enable_v2` | | `source.output_disable_v2` | `channel` | 无 | `source.output_disable_v2` | @@ -3169,7 +3373,8 @@ run plan 使用三个有方向 step,避免在 intent 中把 ON/OFF 混为同 operation artifact 同时保存到 step 的 `artifact.source_operation` 和非空根键 `run.json.source_operations`;写后失败带来的 artifact 也会保存。没有 V2 step 的 V1 run 保持没有该根键。 -双合同插件按当前已注册 V2 contract 路由,不混用 V1 状态视图来做 V2 安全决策: +`v1_route_migration_enabled=true` 的双合同插件按当前已注册 V2 contract 路由,不混用 V1 状态视图 +来做 V2 安全决策: | 已声明 V2 capability | V1 route | M5-D 行为 | | --- | --- | --- | @@ -3179,8 +3384,11 @@ operation artifact 同时保存到 step 的 `artifact.source_operation` 和非 | `source.output_v2` | `trigger_burst`、`trigger_sweep` | 属于可能发信号的重叠 route,在仪器 I/O 前拒绝。 | | 当前两个写 capability 均未覆盖 | `configure_coupling`、`configure_harmonics`、AM/FM/PM/PWM、`configure_pulse`、`configure_burst`、`configure_sweep` | 保持 V1 路径,等待对应 feature 的 V2 capability。 | -V1-only 插件继续使用原 V1 route。双合同 V1 setter 的返回值仅为兼容显示而从 V2 postcondition -flatten 为 `SourceStatus`;该 adapter 不参与 V2 preflight、预算、恢复或 capability 决策。 +设为 `false` 的双合同插件保留上述由 Basic/Output 自动迁移或自动 gate 的 V1 route;只有显式 V2 Service、CLI +或 run step 调用这些 V2 transaction。已单独声明的高级 capability 仍会在其相同字段或发信号的 V1 route 上 +失败关闭,不能借该开关绕过。该选择适用于 V1 composite transaction 无法由当前的窄 V2 operation 等价表示的设备。 +V1-only 插件继续使用原 V1 route。默认迁移的双合同 V1 setter 返回值仅为兼容显示而从 V2 +postcondition flatten 为 `SourceStatus`;该 adapter 不参与 V2 preflight、预算、恢复或 capability 决策。 ### C2 核心兼容与候选发布门 @@ -3345,27 +3553,29 @@ descriptor 必须同时声明 Modulation `READ`/`CONFIGURE`、`pm`、`internal `steps[].artifact.source_operation` 与非空的 `run.json.source_operations`。这些入口不构成任何真实插件的写 capability 声明或实机验收。 -### M6-A 已实现子能力:内部 Triggered Burst +### M6-A 已实现子能力:受控 Triggered Burst M6-A 的后续单通道能力按以下顺序开发:内部 FM、内部 PWM、Sweep。每个子项各自 拥有 capability、typed request/result、descriptor readback 条件、OFF-only 事务、V1 路由审计、CLI 与 run plan step;不得借已完成子项扩大其它高级功能的范围。该顺序只决定核心开发节奏,不要求插件同时声明全部能力。 -`source.burst_configure_v2` 覆盖单通道、输出 OFF 时的内部 Triggered Burst。 -typed request 固定为 `channel`、`cycles`、`phase_deg`、`internal_period_s` 与 `delay_s`。配置完成后 Burst 必为 -enabled、mode 必为 `triggered`、trigger source 必为 `internal`、trigger slope 必为 `positive`、trigger output +`source.burst_configure_v2` 覆盖单通道、输出 OFF 时的受控 Triggered Burst。 +typed request 包含 `channel`、`cycles`、`phase_deg`、`internal_period_s`、`delay_s` 与末尾默认值为 +`internal` 的 `trigger_source`;触发源只允许 `internal` 或 `manual`。配置完成后 Burst 必为 +enabled、mode 必为 `triggered`、trigger source 必须与请求一致、trigger slope 必为 `positive`、trigger output 必为 `off`。`cycles` 为 `[1, 500000]` 的整数,`phase_deg` 为 `[0, 360]` 的有限值, -`internal_period_s` 为有限正值,`delay_s` 为 `[0, 85]` 的有限值。这些是内部 Triggered 配置的类型边界, +`internal_period_s` 为有限正值,`delay_s` 为 `[0, 85]` 的有限值。这些是受控 Triggered 配置的类型边界, 不是额外的负载、RMS、热或共享功率安全门。 -首版不包含 Gated、Infinity、外部/手动 trigger、Gate polarity、trigger slope/output 自定义、disable、 +配置 capability 不包含 Gated、Infinity、外部 trigger、Gate polarity、trigger slope/output 自定义、disable、 partial patch、arm、fire、同步、自动波形切换、输出 ON 或输出恢复。配置不会发出 Burst,也不会为后续输出 ON 提供额外授权;`source.output_v2` 仍按 R6 的基础 Vpp/Offset 规则独立决策。 `SourceBurstCapabilityProfile` 在现有字段末尾追加 -`triggered_internal_configuration_readable: bool = False`。该字段为真时,表示 enabled、mode、cycles、phase、 -internal period、delay 和完整 trigger state 都能以纯读 snapshot 独立回读。声明本 capability 的 descriptor -还必须在同一 channel 声明 Burst `READ`/`CONFIGURE`、`triggered` mode、`internal` trigger source、 +`triggered_internal_configuration_readable: bool = False` 和 +`triggered_manual_configuration_readable: bool = False`。两个字段分别表示对应触发源下的 enabled、mode、cycles、 +phase、internal period、delay 和完整 trigger state 都能以纯读 snapshot 独立回读。声明本 capability 的 descriptor +还必须在同一 channel 声明 Burst `READ`/`CONFIGURE`、`triggered` mode、至少一个受支持的 trigger source、 `timing_readable = true`,以及 Output `READ` 与 output state readback。 该 operation 使用 `POTENTIAL_WHILE_OFF`,静态字段闭包为同一 channel 的 Burst/Output 和仪器 Identity。 @@ -3373,9 +3583,10 @@ internal period、delay 和完整 trigger state 都能以纯读 snapshot 独立 V2 OFF recovery;postcondition 必须逐项确认 request 值、固定 Triggered 语义及 output 仍为 OFF。它不恢复先前 Burst state。 -双合同插件声明 `source.burst_configure_v2` 后,V1 `configure_burst`、`trigger_burst` 与 restore 路径必须在 -仪器 I/O 前拒绝:V1 route 可表示 Gated、Infinity、手动/外部 trigger 和实际 fire,不能无损映射到这个 -仅配置的 V2 子集。V1-only 插件及未声明该 capability 的双合同插件继续走 V1 路径。 +双合同插件只声明 `source.burst_configure_v2` 时,V1 `configure_burst`、`trigger_burst` 与 restore 路径必须在 +仪器 I/O 前拒绝:V1 route 可表示 Gated、Infinity、外部 trigger 和实际 fire,不能无损映射到这个仅配置的 +V2 子集。插件另行声明 `source.burst_fire_v2` 后,V1 `trigger_burst` 可映射到独立 fire operation;V1-only +插件及未声明相关 capability 的双合同插件继续走 V1 路径。 当前核心开发线已提供 `SourceService.configure_burst_v2()`、 `wavebench source burst-configure-v2 --channel N --cycles N --phase-deg DEG --internal-period-s S --delay-s S` 和 @@ -3452,29 +3663,38 @@ capability 独立决定。 Sweep 的窄 capability、typed model 与 descriptor readback 条件已冻结,并由下列公开事务完成接口收口。 -### M6-A 已实现子能力:内部 Sweep +### M6-A 已实现子能力:受控 Sweep -`source.sweep_configure_v2` 的首个范围只覆盖单通道、输出 OFF 时的内建 Sweep 配置。typed request 固定为 -`channel`、`start_hz`、`stop_hz`、`spacing`、`steps` 和 `sweep_time_s`:频率必须为有限正值且 +`source.sweep_configure_v2` 的首个范围只覆盖单通道、输出 OFF 时的内建 Sweep 配置。typed request 包含 +`channel`、`start_hz`、`stop_hz`、`spacing`、`steps`、`sweep_time_s` 与末尾默认值为 `internal` 的 +`trigger_source`;触发源只允许 `internal` 或 `manual`。频率必须为有限正值且 `start_hz <= stop_hz`;`spacing` 只能是 `linear`、`logarithmic` 或 `step`;steps 位于 `[2, 2048]`, sweep time 位于 `[0.001, 300]` 秒。这些值域沿用既有 Source V1 的配置合同,不作为新的电气安全预算。 -配置完成后 Sweep 必为 enabled,三个 hold/return 时间均为零;trigger 固定为 internal、positive、trigger output OFF, +配置完成后 Sweep 必为 enabled,三个 hold/return 时间均为零;trigger source 必须与请求一致,slope 固定为 +positive,trigger output 固定为 OFF, marker 固定为 disabled。Basic 的 `frequency_mode` 必须独立回读为 `sweep`,因此该 operation 的字段闭包包含同一 -channel 的 Basic/Sweep/Output 和仪器 Identity。它不提供 center/span、partial patch、hold/return 自定义、marker、 -外部/手动/BUS trigger、arm、fire、trigger output、隐式输出 ON 或从 Sweep 回到固定频率。 +channel 的 Basic/Sweep/Output 和仪器 Identity。配置 capability 不提供 center/span、partial patch、 +hold/return 自定义、marker、外部/BUS trigger、arm、fire、trigger output、隐式输出 ON 或从 Sweep 回到固定频率。 -descriptor 必须声明 Sweep `READ`/`CONFIGURE`、至少一个 spacing、internal trigger、timing/marker 与完整配置 +descriptor 必须声明 Sweep `READ`/`CONFIGURE`、至少一个 spacing、internal 或 manual trigger、timing/marker 与完整配置 readback;同一 channel 的 Basic `READ` 必须声明 `sweep` frequency mode,Output `READ` 与 output state readback 也为必需。核心在运行时按 request 检查所选 spacing,不要求每个设备支持全部三种 spacing。 +部分设备在启用 Sweep 时会隐式关闭已有 Burst 或 Modulation。`SourceSweepCapabilityProfile` 因而在既有字段末尾 +追加默认空的 `implicit_disable_features`;只允许声明 `burst` 与 `modulation`。声明该副作用的 driver 必须为相同 +channel 提供无条件、required 的纯读 facet 和明确的 inactive readback。Core 将这些字段加入本次动态 transaction +closure,在 preflight 与 postcondition 都要求 `enabled = false`;它不自动关闭、恢复或重新启用它们。未声明该字段的 +设备继续使用原有 Basic/Output/Sweep 闭包,不增加查询或能力门。 + 该 operation 使用 `POTENTIAL_WHILE_OFF`,复用 fresh consistent snapshot、目标 output OFF、单次 driver 写、独立 postcondition 和主写入后的最多一次 V2 OFF recovery;没有额外 RMS、端接、热、共享功率或 trigger 接线门。本子项只 授权配置,不构成任何 fire 或输出 ON 授权。 -双合同插件声明 `source.sweep_configure_v2` 后,V1 `configure_sweep`、`trigger_sweep` 与 restore 必须在仪器 -I/O 前拒绝,不能把 V1 的 center/span、外部/手动 trigger、marker 或 fire 语义部分映射进本范围。V1-only 插件和 -未声明该 capability 的双合同插件继续使用既有 V1 Sweep 路径。 +双合同插件只声明 `source.sweep_configure_v2` 时,V1 `configure_sweep`、`trigger_sweep` 与 restore 必须在仪器 +I/O 前拒绝,不能把 V1 的 center/span、外部 trigger、marker 或 fire 语义部分映射进本范围。插件另行声明 +`source.sweep_fire_v2` 后,V1 `trigger_sweep` 可映射到独立 fire operation;V1-only 插件和未声明相关 +capability 的双合同插件继续使用既有 V1 Sweep 路径。 当前核心开发线已提供 `SourceService.configure_sweep_v2()`、 `wavebench source sweep-configure-v2 --channel N --start-hz F --stop-hz F --spacing linear|logarithmic|step --steps N --sweep-time-s S` 和 @@ -3482,6 +3702,130 @@ I/O 前拒绝,不能把 V1 的 center/span、外部/手动 trigger、marker 独立回读、写后失败的一次 V2 OFF recovery 与 `wavebench.source.operation.v1` artifact;run step 的 artifact 同时写入 `steps[].artifact.source_operation` 与非空的 `run.json.source_operations`。这些入口不构成任何真实插件的写 capability 声明或实机验收。 +### D1-3 已实现子能力:Burst 与 Sweep fire + +`source.burst_fire_v2` 与 `source.sweep_fire_v2` 使用共享的 `SourceFireRequest(channel)` 和 +`SourceFireResult(channel)`,但保留独立 capability、driver Protocol、operation contract 和 artifact。fire +capability 分别依赖对应 configure capability 与 `source.output_v2`;descriptor 必须在同一 channel 声明 +`FIRE` direction、manual trigger、manual 模式下的完整配置回读、最终 Vpp/Offset 和输出状态回读。 + +Core 只接受由同一个 `SourceService` 和 session epoch 成功完成的对应 V2 配置。配置成功后形成绑定已验证 +Burst/Sweep facet 摘要的内存 receipt;重新配置开始前清除旧 receipt,连接代次改变或 fire 主写入失败后也 +不再接受旧 receipt。receipt 不写入插件,也不替代 fire 前的 fresh snapshot。 + +fire preflight 必须证明 snapshot 一致、目标输出为 ON、Vpp/Offset 满足 R6 数值门,并且当前 Burst 或 Sweep +配置仍使用 manual trigger,且与同 session receipt 的 facet 摘要一致。MAIN 只调用一次 +`fire_source_burst_v2()` 或 +`fire_source_sweep_v2()`;结果未知不得重试。postcondition 只证明输出仍为 ON、配置未改变和 session 健康, +不能证明物理端口已经产生 Burst 或 Sweep。artifact 固定记录 `emission_verified = false` 与 +`external_measurement_required = true`。 + +driver 异常、结果类型错误或后置条件失败时,Core 清除 receipt,只允许一次 V2 output OFF recovery 与独立回读, +不会重新 fire 或恢复 ON。D1-3 不增加独立 CLI fire 命令:独立命令无法保留 configure receipt 所属的 session。 +`source.sweep_configure_v2` CLI 与 run step 可显式传入 `trigger_source=manual`;Core 另提供只在 run plan 中使用的 +`source.sweep_fire_v2` step。该 step 必须在同一计划的 manual configure、`source.output_enable_v2` 之后执行, +并以 `source.output_disable_v2` 结束。Burst 仍没有对应 run step。现有 V1 trigger 仅在对应 fire capability 已声明且 +同 session receipt 有效时映射到本 operation。为保持已有 operation artifact 字节形状,默认 +`trigger_source=internal` 不写入 request payload,manual 请求则显式记录该字段。物理发出能力必须在具体插件的 +A4 实机验收中由外部测量证明。 + +### D1-4 合同冻结:volatile ARB 与 Counter + +`source.arbitrary_storage_v2` 继续只表示可命名、可读回摘要和大小、可在写前执行 +CAS 的存储槽位。它的写入后置条件要求选中状态和输出状态保持不变。单一的易失 +ARB 工作区不满足这些前提,必须使用独立的 +`source.arbitrary_volatile_replace_v2`。其 request 只含 channel、传入精确 bytes 的 +SHA-256、字节数和点数;实际 bytes 不进入 request、artifact 或 run JSON。 + +volatile replace 明确承认上传会选择该工作区,并可能改变与波形长度相关的设备状态。 +前置条件为目标输出 OFF;主写只能调用一次;后置条件独立确认 ARB waveform 已选中、 +basic waveform 为 arbitrary、输出仍 OFF。结果必须分别记录 host bytes 的身份、当前 +selected waveform ID、内容是否能由设备读回验证、以及旧 volatile 内容是否可恢复。 +不能读取摘要或原始 bytes 的设备不得把 host digest 填为设备 readback,也不得声称 +可恢复旧内容。二进制写一旦尝试且后续失败,Core 只可尝试一次输出 OFF 收敛;旧内容 +保持 `unrecoverable`,不得重传或 rollback。 + +Core 提供 additive CLI: + +```text +wavebench source arbitrary-volatile-replace-v2 --channel N --payload-file FILE --point-count N +``` + +CLI 在创建 Source Service 前读取本地 payload、计算摘要并构造 typed request;文件不可读或 request 无效时不打开仪器。 +run plan 使用 `source.arbitrary_volatile_replace_v2`,字段为 `channel`、相对 plan 的 `file` 和 `point_count`。 +execution intent 仅保存文件名、摘要与大小,Source operation artifact 不保存 payload 或本地路径。该核心入口不构成任何 +真实插件的 capability 声明;声明后会与 legacy ARB upload 形成 V1 overlap gate,必须单独完成等价性审计与实机验收。 +若 legacy upload 同时负责 binary 传输、频率/Vpp/offset、`output_on`、错误队列和 basic restore,当前窄的 volatile +replace operation 不能作为无损桥接。`v1_route_migration_enabled = false` 只关闭 Basic/Output 的自动迁移,不能解除 +这一已声明高级 capability 与 V1 composite transaction 的重叠门;保留 legacy 路由或另立完整的复合合同是仅有的 +兼容选择。 + +### D1-5 合同冻结:无通道 VOLATILE workspace + +有些设备把 `VOLATILE` binary 写定义为「当前通道」动作,却不提供可读或可写的 SCPI selector。此时不得把它塞入 +`source.arbitrary_volatile_replace_v2`:该操作的 `channel`、selection/basic 后置条件和单通道 recovery 都会成为虚假承诺。 + +`source.arbitrary_workspace_volatile_replace_v2` 是独立 opt-in 合同。它使用 `ARBITRARY_WORKSPACE` 的 +`INSTRUMENT` scope 和独立 `SourceArbitraryWorkspaceCapabilityProfile`;request 不含 channel,只带 payload SHA-256、 +字节数和点数。结果只确认名为 `workspace_id` 的无通道工作区已尝试写入,且明确携带 +`content_readback_verified` 与 `previous_content_restorable`。它不会声称哪一路已选择 USER,也不会把 host digest +伪装成设备内容读回。 + +前置和后置条件均要求 topology 的全部输出 OFF。MAIN 只能发一次 binary write;二义写、driver 异常或后置条件失败后, +Core 对每个仍可用的 topology 通道最多执行一次 OFF recovery;一旦 recovery I/O 使 session poisoned,后续物理 I/O +必须停止。它不重传、不恢复旧内容,也不允许后续配置写。CLI 为: + +```text +wavebench source arbitrary-workspace-volatile-replace-v2 --payload-file FILE --point-count N +``` + +run step 为 `source.arbitrary_workspace_volatile_replace_v2`,字段只有相对 plan 的 `file` 和 `point_count`。intent 与 +artifact 只记录 payload 身份信息,并显式标记 `channel_selection=unverified` 和旧内容不可恢复。该操作不等价、也不迁移 +legacy `source.arbitrary_upload`;它只有在独立 descriptor 显式声明且具备全拓扑 output read/disable 支撑时才可路由。 + +Counter 按副作用拆开,而不是继续沿用 V1 的「完整 profile 一次设置」模型: + +- `source.counter_configure_v2` 只允许一个显式字段:AC/DC coupling、输入阻抗、衰减、 + trigger level 或 statistics enable。每个字段均需独立回读;不会暗中写 50 Ω、默认 + gate time 或其它默认值。 +- `source.counter_enable_v2` 只改变 counter ON/OFF;它不会配置输入、清零统计或取得测量。 +- `source.counter_measure_v2` 仅对已启用的 counter 做读取;它不会 enable、调用 AUTO、 + 写 gate time 或统计 clear。 + +`AUTO` gate-time、无法精确表达的厂商 preset、HF rejection、sensitivity、statistics display +和 statistics clear 不进入这组首版合同。前四项需要各自可读的通用状态模型;clear 是 +破坏性动作,必须以后续单独 capability 明确授权。D1-4 冻结 model、Protocol 和操作 +元数据;D6 在不改变 V1 路由的前提下,逐项注册以下 capability: + +```text +source.counter_configure_v2 +source.counter_enable_v2 +source.counter_measure_v2 +``` + +### D6 已实现:Counter V2 入口与安全语义 + +Core 提供四个显式 CLI 入口: + +```text +wavebench source counter-configure-v2 --input-id INPUT_ID +wavebench source counter-enable-v2 --input-id INPUT_ID +wavebench source counter-disable-v2 --input-id INPUT_ID +wavebench source counter-measure-v2 --input-id INPUT_ID +``` + +run plan 对应 `source.counter_configure_v2`、`source.counter_enable_v2`、 +`source.counter_disable_v2` 与 `source.counter_measure_v2`。配置 step 必须恰好指定一个 +字段;measure 只写入该 step 的 `counter_measurement` typed artifact,不伪造 mutation +artifact,也不写入 `run.json.source_operations`。 + +Counter 在刚启用或无输入时可能返回暂未形成的零测量。V2 snapshot 将这种已收到但不合法的 +测量标为 `UNAVAILABLE`/`response_invalid_value`,仍保留已读到的 enabled 与配置字段, +以便安全 disable 事务继续执行。legacy `source.counter_profile` 与 +`source.counter_measure_v2` 保持严格:前者仍拒绝不完整 profile,后者仍只接受有效五元组。 +当 run step 抛出预期的 WaveBench 错误且计划配置了 safety gate 时,Core 先执行已授权的 +输出 OFF,再把失败写入 run artifact。 + ### M6-B 已实现:ARB storage 与 selection M6-B 使用两个独立 capability,不把上传、选择、基本幅度配置或输出 ON 合并为一个 driver 调用: @@ -3590,6 +3934,169 @@ relation graph 摘要、阶段和可选 recovery;不记录 raw SCPI、授权 t 声明任一 M6-C capability 后,V1 restore 也在 I/O 前拒绝。没有声明对应 capability 的 V1-only 或双合同插件继续走 既有 V1 route。当前只有核心 A0 离线 fixture,未声明真实插件 capability,也没有执行实机验收。 +## R8 候选:Coupling、Noise Overlay 与 Sync 写事务 + +本节是首次稳定版前的候选设计,不是 R7 的实施授权。当前 Core 已有 M6-C 的布尔 +`source.coupling_configure_v2` Service/CLI/run plan 骨架,但没有生产插件声明该 capability; +Noise Overlay 与 Sync 仍只有类型化读取。R8 不改变这些当前事实,任何插件都不得依据本节自行 +声明参数化 Coupling、Noise Overlay 或 Sync 写能力。 + +### 跨设备交集 + +当前普通信号发生器证据来自 DG4000 与 SDG2000X,两者不能共用厂商命令模型: + +- DG4000 的 Coupling 使用 CH1/CH2 基准通道,以及 amplitude、frequency、phase 三个独立 + deviation;SDG2000X 还存在 ratio/deviation 选择、方向和只在活动状态返回的字段。 +- DG4000 的 Noise Overlay 提供每通道 enabled 与 percent scale;SDG2000X 的 Noise Add + 只证明 enabled 状态,不能假设存在相同比例语义。 +- DG4000 的 Sync 提供每通道 enabled 与 polarity;SDG2000X 提供 enabled 与 routing type, + 但没有同构的 polarity。 + +因此 Core 只保留已类型化的交集:Coupling 使用带 dimension 与 parameter kind 的目标;Noise +Overlay 使用可为空的 typed scale tuple;Sync 的共同字段只有 enabled。DG 的基准通道、SDG 的 +ratio/direction 与 routing type 只有在公共类型能够无损表达时才进入 Core,不使用自由 mapping +或厂商字符串补齐。 + +### Coupling 合同修正 + +现有、尚未稳定发布的 `source.coupling_configure_v2` 只接收 `channels + enabled`,与已经稳定的 +参数化 `SourceCouplingState` 不等价。首次稳定版前直接修正该 capability,不新增第二个 +`source.coupling_parameters_configure_v2`。当前没有生产插件声明该写 capability,因此无需保留两套 +重叠 API。 + +候选公共类型为完整目标,不使用 partial patch: + +```python +@dataclass(frozen=True, slots=True) +class SourceCouplingDimensionTarget: + dimension: SourceCouplingDimension + enabled: bool + parameter: SourceCouplingParameter + + +@dataclass(frozen=True, slots=True) +class SourceCouplingConfigureRequest: + channels: tuple[int, ...] + enabled: bool + reference_channel: int + dimensions: tuple[SourceCouplingDimensionTarget, ...] + + +@dataclass(frozen=True, slots=True) +class SourceCouplingConfigureResult: + coupling: SourceCouplingState + outputs: tuple[SourceRelationOutputState, ...] +``` + +request 的 channel set 必须由 profile 声明;dimensions 必须与 profile 完全一致;parameter kind +必须属于对应 dimension 和 profile。`configuration_readable`、`global_state_readable`、 +`reference_channel_readable` 与 `relation_graph_readable` 必须全部为真。无法完整读取配置、关系图或 +受影响端口时,Core 在 MAIN 前拒绝,不退回旧布尔关系写入。 + +事务继续使用 `source.coupling_configure_v2`,direction 为 `CONFIGURE`,energy effect 为 +`POTENTIAL_WHILE_OFF`。Core 先冻结 relation graph closure,要求 closure 内全部主输出为 OFF, +再调用一次 driver 方法。driver 可按设备协议执行多字段写入,但每个目标字段在 MAIN 中最多写一次; +写后必须返回完整 Coupling state,Core 再用 fresh snapshot 独立验证 target、graph 和全部 OFF 状态。 + +### Noise Overlay 合同 + +新增候选 capability `source.noise_overlay_configure_v2`,required method 为 +`configure_source_noise_overlay_v2`。它只在目标主输出已经 OFF 时配置,不打开输出: + +```python +@dataclass(frozen=True, slots=True) +class SourceNoiseOverlayConfigureRequest: + channel: int + enabled: bool + scales: tuple[SourceNoiseOverlayScale, ...] + + +@dataclass(frozen=True, slots=True) +class SourceNoiseOverlayConfigureResult: + channel: int + noise_overlay: NoiseOverlayFacet + output_enabled: bool +``` + +request 的 scale kind tuple 必须与 runtime profile 完全一致;只支持 enabled 的设备使用空 tuple, +不能伪造 percent 或默认 scale。descriptor 必须声明 Noise Overlay `READ`/`CONFIGURE`、 +`enabled_readable = true` 和完整配置回读;同一通道的 Output 必须支持 READ。 + +该 operation 的 direction 为 `CONFIGURE`,energy effect 为 `POTENTIAL_WHILE_OFF`,字段闭包至少包含 +Identity、目标 Output 与 Noise Overlay。MAIN 只调用一次 driver 方法;postcondition 必须逐项确认 +enabled、scales 和 Output OFF。 + +配置成功不构成后续输出 ON 授权。Noise Overlay 启用后,只有设备在当前 scale、型号、固件和基础 +波形范围内提供确定性硬边界时,严格复合预算才可能放行主输出。当前 `SourceNoisePeakConstraint` +只描述基本 Noise 波形,不能替代 Noise Overlay 的独立边界;缺少该边界时继续返回 +`noise_overlay_bound_missing`。DG4000 手册只给出 `0–50 %` 的比例,没有确定性峰值保证,因此 +DG4202 即使未来声明配置 capability,也不能据此声明带 Noise Overlay 的输出 ON 已获准入。 + +### Sync 配置与物理端口输出 + +Sync 必须拆成配置和物理端口开关,不能把 polarity 写入与开始发出同步信号混为一个 operation: + +```text +source.sync_configure_v2 capability -> configure_source_sync_v2 +source.sync_output_v2 capability -> set_source_sync_output_v2 +``` + +两个 capability 都要求 Core 先建立 Sync 物理端口 topology:稳定 port ID、逻辑通道到物理端口的 +绑定,以及共享端口的 closure 规则。没有该绑定时,`SourceSyncState.enabled = false` 只能说明逻辑 +状态,不能证明目标物理端口已经 OFF,也不能确定 recovery OFF 的完整范围,因此 configure、enable +和 disable 均不得注册。 + +端口 topology 完成后,`source.sync_configure_v2` 只允许在对应 Sync 物理端口已证明为 OFF 时设置 +Core 已建模且 profile 声明可写的 polarity 或 source channel。它使用 feature-specific +`PatchValue`,至少一个字段为 SET;driver result 和 fresh postcondition 必须返回完整、仍为 disabled +的 `SourceSyncState`。SDG2000X 的 routing type 在当前 Core 模型中不可表达,因此不能通过该 +capability 写入。 + +`source.sync_output_v2` 是一个 capability,并要求同一个 driver method。descriptor 的 `ENABLE`/ +`DISABLE` direction 分别映射到两个独立的 `SourceOperationContract` 与 `OperationSpec`: + +| operation | direction | energy effect | 最小门槛 | +| --- | --- | --- | --- | +| `source.sync_output_enable_v2` | `ENABLE` | `EMIT` | 明确物理端口、端接/电气上界、A5 接线证据和 fresh Sync state | +| `source.sync_output_disable_v2` | `DISABLE` | `DECREASE_ONLY` | Sync enabled 可读、session 允许正常或 recovery I/O | + +当前 `SourceSyncState` 只描述逻辑通道,不足以证明物理端口。物理 topology 还必须为 enable 增加 +该端口的电压边界和端接要求。共享一个物理 Sync/Aux 端口的多个逻辑通道必须进入同一 closure。 +不得从主 Output 的 Vpp、显示负载或 +`max_source_vpp` 推断数字 Sync 端口安全,也不得将「主输出 OFF」解释为「Sync 端口 OFF」。 + +在物理 port ID/binding/closure 进入 Core 前,任何通用 Sync 写 capability 都不得注册。该模型完成 +并通过 A0 后,可以先评审 configure 与 disable;enable 还必须等待电气 profile 和 A5 证据。 + +### 恢复顺序与失败状态 + +三个功能都使用 core-owned 完整 baseline;任何 baseline 字段不是 `VALUE`、snapshot 不一致或 +relation/port closure 不完整时,MAIN 前零写拒绝。恢复不会重新开启主输出,也不会自动重新开启 +Sync 物理端口。 + +| 功能 | FAILURE_SAFE_STATE | FAILURE_RESTORE | CLEANUP_VERIFICATION | +| --- | --- | --- | --- | +| Coupling | 对冻结 closure 内每个主输出至多发送一次 V2 OFF | 仅在 session 仍允许 recovery 且 baseline 完整时,按一次完整 target 恢复 Coupling;设备适配器应先解除 Coupling,再恢复基准、参数和各维状态 | 完整 Coupling、relation graph 与全部主输出 OFF | +| Noise Overlay | 目标主输出至多发送一次 V2 OFF | 先保持 Noise disabled,再恢复 scale,最后按 baseline 恢复 enabled;整个阶段主输出保持 OFF | Noise Overlay 完整 target 与主输出 OFF | +| Sync 配置 | 在物理 port binding 已冻结后,对 closure 中的 Sync 端口至多发送一次 OFF;只在 descriptor 声明副作用时关闭相关主输出 | 在 Sync 端口保持 OFF 时恢复 polarity/source channel;不恢复原 Sync ON | Sync 完整配置与全部绑定物理端口 OFF | +| Sync enable | 对目标 Sync 物理端口至多发送一次 OFF | 不执行重新供能的 restore | Sync 端口 OFF readback 与 session health | + +每个 MAIN 字段和每个恢复字段都至多尝试一次;结果未知不重试。`poisoned` session 只关闭连接, +不发送 OFF 或验证查询。任一恢复或回读失败时,artifact 必须记录 partial/unknown 状态,并把后续 +输出 ON、Sync enable 和相关配置写入保持为失败关闭。 + +### 实施顺序与退出门 + +1. 先用 Core fake driver 替换尚未发布的布尔 Coupling request,并补完整 target、graph drift、 + MAIN/restore 每步故障注入;此阶段不修改真实插件 descriptor。 +2. 增加 Noise Overlay configure 的类型、静态门和 OFF-only transaction;输出 ON 继续受独立硬边界阻断。 +3. 先增加 Sync port ID/binding/closure topology,再设计 configure 与 disable;enable 只有在至少 + 两种普通信号发生器协议形态完成 A0,并由具体型号完成电气 profile 与 A5 物理端口证据后才能评审。 +4. 每项分别增加 Service、CLI、run plan、V1 route 分类和 operation artifact;不得以一个通用 advanced + configure 入口合并。 +5. 完成 Core 全量回归、真实 wheel/sdist、插件包检查和双合同兼容矩阵后,才可将 R8 候选改为 + Accepted。当前分支不执行这些写入,也不提升任何生产 descriptor。 + ### R6 延后事项 RMS、统计 Noise、反应性/非线性负载、ARB 插值过冲、共享热功率、manifest 签名和信任根不阻塞 diff --git a/src/wavebench/cli.py b/src/wavebench/cli.py index fd5abc5..1de1752 100644 --- a/src/wavebench/cli.py +++ b/src/wavebench/cli.py @@ -279,7 +279,11 @@ def _json_payload(value: object) -> object: return value -def _source_basic_configure_v2_request(args: argparse.Namespace): +def _source_basic_configure_v2_request( + args: argparse.Namespace, + *, + command: str = "basic-configure-v2", +): from .instruments.source_extensions import ( PatchAction, PatchValue, @@ -289,18 +293,18 @@ def _source_basic_configure_v2_request(args: argparse.Namespace): ) values = { - "waveform_kind": args.waveform, - "frequency_hz": args.frequency_hz, - "amplitude_vpp": args.amplitude_vpp, - "offset_v": args.offset_v, - "square_duty_cycle_percent": args.square_duty_cycle_percent, + "waveform_kind": getattr(args, "waveform", None), + "frequency_hz": getattr(args, "frequency_hz", None), + "amplitude_vpp": getattr(args, "amplitude_vpp", None), + "offset_v": getattr(args, "offset_v", None), + "square_duty_cycle_percent": getattr(args, "square_duty_cycle_percent", None), } if all(value is None for value in values.values()): - raise ConfigError("source basic-configure-v2 requires at least one basic field") + raise ConfigError(f"source {command} requires at least one basic field") waveform = ( - PatchValue(PatchAction.SET, SourceWaveformKind(args.waveform)) - if args.waveform is not None + PatchValue(PatchAction.SET, SourceWaveformKind(values["waveform_kind"])) + if values["waveform_kind"] is not None else PatchValue(PatchAction.KEEP) ) @@ -315,14 +319,52 @@ def patch_value(value: object): channel=args.channel, patch=SourceBasicPatch( waveform_kind=waveform, - frequency_hz=patch_value(args.frequency_hz), - amplitude_vpp=patch_value(args.amplitude_vpp), - offset_v=patch_value(args.offset_v), - square_duty_cycle_percent=patch_value(args.square_duty_cycle_percent), + frequency_hz=patch_value(values["frequency_hz"]), + amplitude_vpp=patch_value(values["amplitude_vpp"]), + offset_v=patch_value(values["offset_v"]), + square_duty_cycle_percent=patch_value(values["square_duty_cycle_percent"]), ), ) +def _source_counter_configure_v2_request(args: argparse.Namespace): + from .instruments.source_extensions import ( + PatchAction, + PatchValue, + SourceCounterConfigurationPatch, + SourceCounterConfigureRequest, + SourceInputCoupling, + ) + + coupling = getattr(args, "coupling", None) + statistics_enabled = getattr(args, "statistics_enabled", None) + + def patch_value(value: object): + return ( + PatchValue(PatchAction.SET, value) + if value is not None + else PatchValue(PatchAction.KEEP) + ) + + try: + return SourceCounterConfigureRequest( + input_id=args.input_id, + patch=SourceCounterConfigurationPatch( + coupling=patch_value( + SourceInputCoupling(coupling) if coupling is not None else None + ), + impedance_ohm=patch_value(getattr(args, "impedance_ohm", None)), + attenuation=patch_value(getattr(args, "attenuation", None)), + trigger_level_v=patch_value(getattr(args, "trigger_level_v", None)), + statistics_enabled=patch_value( + statistics_enabled == "on" if statistics_enabled is not None else None + ), + ), + ) + except ValueError as exc: + raise ConfigError(str(exc)) from exc + + def _source_cross_channel_configure_v2_request( args: argparse.Namespace, request_type: type[object], @@ -405,6 +447,7 @@ def _source_sweep_configure_v2_request(args: argparse.Namespace): from .instruments.source_extensions import ( SourceSweepConfigureRequest, SourceSweepSpacing, + SourceTriggerSource, ) try: @@ -415,6 +458,7 @@ def _source_sweep_configure_v2_request(args: argparse.Namespace): spacing=SourceSweepSpacing(args.spacing), steps=args.steps, sweep_time_s=args.sweep_time_s, + trigger_source=SourceTriggerSource(args.trigger_source), ) except ValueError as exc: raise ConfigError(str(exc)) from exc @@ -455,6 +499,58 @@ def _source_arbitrary_storage_v2_request( raise ConfigError(str(exc)) from exc +def _source_arbitrary_volatile_replace_v2_request( + args: argparse.Namespace, +) -> tuple[object, bytes]: + from .instruments.source_extensions import SourceArbitraryVolatileReplaceRequest + + payload_path = Path(args.payload_file) + try: + payload = payload_path.read_bytes() + except OSError as exc: + raise ConfigError( + f"source.arbitrary_volatile_replace_v2 payload file is unreadable: {payload_path}" + ) from exc + try: + return ( + SourceArbitraryVolatileReplaceRequest( + channel=args.channel, + payload_sha256="sha256:" + sha256(payload).hexdigest(), + payload_size_bytes=len(payload), + point_count=args.point_count, + ), + payload, + ) + except ValueError as exc: + raise ConfigError(str(exc)) from exc + + +def _source_arbitrary_workspace_volatile_replace_v2_request( + args: argparse.Namespace, +) -> tuple[object, bytes]: + from .instruments.source_extensions import SourceArbitraryWorkspaceVolatileReplaceRequest + + payload_path = Path(args.payload_file) + try: + payload = payload_path.read_bytes() + except OSError as exc: + raise ConfigError( + "source.arbitrary_workspace_volatile_replace_v2 payload file is unreadable: " + f"{payload_path}" + ) from exc + try: + return ( + SourceArbitraryWorkspaceVolatileReplaceRequest( + payload_sha256="sha256:" + sha256(payload).hexdigest(), + payload_size_bytes=len(payload), + point_count=args.point_count, + ), + payload, + ) + except ValueError as exc: + raise ConfigError(str(exc)) from exc + + def _source_arbitrary_select_v2_request(args: argparse.Namespace): from .instruments.source_extensions import ( SourceArbitraryPlaybackMode, @@ -1293,6 +1389,32 @@ def _main(argv: list[str] | None = None) -> int: else: print(json.dumps(payload, indent=2, ensure_ascii=False)) return 0 + if args.command == "arbitrary-volatile-replace-v2": + request, volatile_payload = _source_arbitrary_volatile_replace_v2_request(args) + service = _load_source_service(args) + _, payload = service.replace_arbitrary_volatile_v2( + request, + payload=volatile_payload, + ) + if args.json: + _emit_json_result(payload) + else: + print(json.dumps(payload, indent=2, ensure_ascii=False)) + return 0 + if args.command == "arbitrary-workspace-volatile-replace-v2": + request, workspace_payload = _source_arbitrary_workspace_volatile_replace_v2_request( + args + ) + service = _load_source_service(args) + _, payload = service.replace_arbitrary_workspace_volatile_v2( + request, + payload=workspace_payload, + ) + if args.json: + _emit_json_result(payload) + else: + print(json.dumps(payload, indent=2, ensure_ascii=False)) + return 0 service = _load_source_service(args) if args.command == "idn": print(service.idn()) @@ -1404,6 +1526,18 @@ def _main(argv: list[str] | None = None) -> int: else: print(json.dumps(payload, indent=2, ensure_ascii=False)) return 0 + if args.command == "basic-live-configure-v2": + _, payload = service.configure_basic_live_v2( + _source_basic_configure_v2_request( + args, + command="basic-live-configure-v2", + ) + ) + if args.json: + _emit_json_result(payload) + else: + print(json.dumps(payload, indent=2, ensure_ascii=False)) + return 0 if args.command == "output-v2": from wavebench.instruments.source_extensions import SourceOutputRequest @@ -1418,6 +1552,45 @@ def _main(argv: list[str] | None = None) -> int: else: print(json.dumps(payload, indent=2, ensure_ascii=False)) return 0 + if args.command == "counter-configure-v2": + _, payload = service.configure_counter_v2( + _source_counter_configure_v2_request(args) + ) + if args.json: + _emit_json_result(payload) + else: + print(json.dumps(payload, indent=2, ensure_ascii=False)) + return 0 + if args.command in {"counter-enable-v2", "counter-disable-v2"}: + from wavebench.instruments.source_extensions import SourceCounterEnableRequest + + _, payload = service.set_counter_enabled_v2( + SourceCounterEnableRequest( + input_id=args.input_id, + enabled=args.command == "counter-enable-v2", + ) + ) + if args.json: + _emit_json_result(payload) + else: + print(json.dumps(payload, indent=2, ensure_ascii=False)) + return 0 + if args.command == "counter-measure-v2": + from wavebench.instruments.source_extensions import ( + SourceCounterMeasureRequest, + source_v2_to_data, + ) + + payload = source_v2_to_data( + service.measure_counter_v2( + SourceCounterMeasureRequest(input_id=args.input_id) + ) + ) + if args.json: + _emit_json_result(payload) + else: + print(json.dumps(payload, indent=2, ensure_ascii=False)) + return 0 if args.command == "harmonics-configure-v2": from wavebench.instruments.source_extensions import ( SourceHarmonicConfigureRequest, diff --git a/src/wavebench/cli_output.py b/src/wavebench/cli_output.py index 38cc75d..ab7c920 100644 --- a/src/wavebench/cli_output.py +++ b/src/wavebench/cli_output.py @@ -388,6 +388,20 @@ def _print_dmm_dcv_impedance_configuration( print(f"changed={'true' if result.changed else 'false'}") +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) + + +def _number(value: float | None) -> str: + return "n/a" if value is None else f"{value:.12g}" + + def _print_scope_snapshot(snapshot: ScopeSnapshot | ScopeStatusSummary) -> None: if isinstance(snapshot, ScopeStatusSummary): print(f"status={snapshot.status}") @@ -402,15 +416,6 @@ def _print_scope_snapshot(snapshot: ScopeSnapshot | ScopeStatusSummary) -> None: return snapshot = snapshot.snapshot - 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) - sections = ( ("identity", snapshot.identity), ("health", snapshot.health), @@ -425,7 +430,7 @@ def scalar(value: object) -> str: if isinstance(value, tuple): print(f"{section_name}.{name}=" + ",".join(str(item) for item in value)) else: - print(f"{section_name}.{name}={scalar(value)}") + print(f"{section_name}.{name}={_scalar(value)}") def _print_scope_acquisition_status(status: ScopeAcquisitionStatus) -> None: @@ -491,39 +496,30 @@ def _print_scope_digital_status(status: ScopeDigitalChannelStatus) -> None: 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)}") + 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)}") + 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(f"digital_v2.shared.module_present={_scalar(status.shared.module_present)}") print( "digital_v2.shared.timing_calibration_s=" - + scalar(status.shared.timing_calibration_s) + + _scalar(status.shared.timing_calibration_s) ) - print(f"digital_v2.shared.size={scalar(status.shared.size)}") + print(f"digital_v2.shared.size={_scalar(status.shared.size)}") print( "digital_v2.unavailable_fields=" + (",".join(status.unavailable_fields) or "none") @@ -546,21 +542,18 @@ def _print_scope_digital_waveform( def _print_scope_measurement_statistics(stats: ScopeMeasurementStatistics) -> None: - def number(value: float | None) -> str: - return "n/a" if value is None else f"{value:.12g}" - print(f"measurement.slot={stats.slot}") print(f"measurement.category={stats.category}") - print(f"measurement.actual={number(stats.actual)}") - print(f"measurement.average={number(stats.average)}") - print(f"measurement.standard_deviation={number(stats.standard_deviation)}") - print(f"measurement.minimum={number(stats.minimum)}") - print(f"measurement.maximum={number(stats.maximum)}") + print(f"measurement.actual={_number(stats.actual)}") + print(f"measurement.average={_number(stats.average)}") + print(f"measurement.standard_deviation={_number(stats.standard_deviation)}") + print(f"measurement.minimum={_number(stats.minimum)}") + print(f"measurement.maximum={_number(stats.maximum)}") print(f"measurement.waveform_count={stats.waveform_count}") if stats.buffered_values is None: print("measurement.buffer=n/a") else: - print("measurement.buffer=" + ",".join(number(value) for value in stats.buffered_values)) + print("measurement.buffer=" + ",".join(_number(value) for value in stats.buffered_values)) def _print_scope_derived_waveform_metadata( @@ -591,19 +584,16 @@ def _print_scope_fft_status(status: ScopeFftStatus) -> None: def _print_scope_cursor_readout(readout: ScopeCursorReadout) -> None: - def number(value: float | None) -> str: - return "n/a" if value is None else f"{value:.12g}" - print(f"cursor.index={readout.cursor_index}") print(f"cursor.source={readout.source}") print(f"cursor.function={readout.function}") - print(f"cursor.result={number(readout.result)}") - print(f"cursor.x_delta_s={number(readout.x_delta_s)}") - print(f"cursor.inverse_x_delta_hz={number(readout.inverse_x_delta_hz)}") - print(f"cursor.y_delta={number(readout.y_delta)}") - print(f"cursor.inverse_y_delta={number(readout.inverse_y_delta)}") - print(f"cursor.x_ratio={number(readout.x_ratio)}") - print(f"cursor.y_ratio={number(readout.y_ratio)}") + print(f"cursor.result={_number(readout.result)}") + print(f"cursor.x_delta_s={_number(readout.x_delta_s)}") + print(f"cursor.inverse_x_delta_hz={_number(readout.inverse_x_delta_hz)}") + print(f"cursor.y_delta={_number(readout.y_delta)}") + print(f"cursor.inverse_y_delta={_number(readout.inverse_y_delta)}") + print(f"cursor.x_ratio={_number(readout.x_ratio)}") + print(f"cursor.y_ratio={_number(readout.y_ratio)}") def _print_dmm_function_status(function: str) -> None: print(f"功能 / Function: {function}") diff --git a/src/wavebench/cli_parser.py b/src/wavebench/cli_parser.py index fcc59a8..6462451 100644 --- a/src/wavebench/cli_parser.py +++ b/src/wavebench/cli_parser.py @@ -842,6 +842,15 @@ def build_parser() -> argparse.ArgumentParser: ) add_runtime_options(source_basic_configure_v2) + source_basic_live_configure_v2 = source_sub.add_parser( + "basic-live-configure-v2", + help="Change one enabled Source V2 channel frequency or Vpp without output cycling", + ) + source_basic_live_configure_v2.add_argument("--channel", type=int, required=True) + source_basic_live_configure_v2.add_argument("--frequency-hz", type=float, default=None) + source_basic_live_configure_v2.add_argument("--amplitude-vpp", type=float, default=None) + add_runtime_options(source_basic_live_configure_v2) + source_output_v2 = source_sub.add_parser( "output-v2", help="Turn one Source V2 channel output on or off", @@ -850,6 +859,38 @@ def build_parser() -> argparse.ArgumentParser: source_output_v2.add_argument("state", choices=("on", "off")) add_runtime_options(source_output_v2) + source_counter_configure_v2 = source_sub.add_parser( + "counter-configure-v2", + help="Configure exactly one declared Source V2 Counter field without enabling it", + ) + source_counter_configure_v2.add_argument("--input-id", required=True) + source_counter_field = source_counter_configure_v2.add_mutually_exclusive_group(required=True) + source_counter_field.add_argument("--coupling", choices=("ac", "dc")) + source_counter_field.add_argument("--impedance-ohm", type=float) + source_counter_field.add_argument("--attenuation", type=int) + source_counter_field.add_argument("--trigger-level-v", type=float) + source_counter_field.add_argument("--statistics-enabled", choices=("on", "off")) + add_runtime_options(source_counter_configure_v2) + + for command, enabled in ( + ("counter-enable-v2", True), + ("counter-disable-v2", False), + ): + source_counter_output_v2 = source_sub.add_parser( + command, + help=("Enable" if enabled else "Disable") + + " one declared Source V2 Counter input", + ) + source_counter_output_v2.add_argument("--input-id", required=True) + add_runtime_options(source_counter_output_v2) + + source_counter_measure_v2 = source_sub.add_parser( + "counter-measure-v2", + help="Read one already-enabled declared Source V2 Counter input", + ) + source_counter_measure_v2.add_argument("--input-id", required=True) + add_runtime_options(source_counter_measure_v2) + source_harmonics_configure_v2 = source_sub.add_parser( "harmonics-configure-v2", help="Configure one OFF Source V2 channel with a declared Harmonic preset", @@ -955,6 +996,11 @@ def build_parser() -> argparse.ArgumentParser: ) source_sweep_configure_v2.add_argument("--steps", type=int, required=True) source_sweep_configure_v2.add_argument("--sweep-time-s", type=float, required=True) + source_sweep_configure_v2.add_argument( + "--trigger-source", + choices=("internal", "manual"), + default="internal", + ) add_runtime_options(source_sweep_configure_v2) source_burst_configure_v2 = source_sub.add_parser( @@ -1006,6 +1052,33 @@ def build_parser() -> argparse.ArgumentParser: source_arbitrary_storage_v2.add_argument("--expected-previous-sha256") add_runtime_options(source_arbitrary_storage_v2) + source_arbitrary_volatile_replace_v2 = source_sub.add_parser( + "arbitrary-volatile-replace-v2", + help=( + "Replace the selected Source V2 volatile ARB workspace while output is OFF; " + "the previous workspace content is not recoverable" + ), + ) + source_arbitrary_volatile_replace_v2.add_argument("--channel", type=int, required=True) + source_arbitrary_volatile_replace_v2.add_argument("--payload-file", required=True) + source_arbitrary_volatile_replace_v2.add_argument("--point-count", type=int, required=True) + add_runtime_options(source_arbitrary_volatile_replace_v2) + + source_arbitrary_workspace_volatile_replace_v2 = source_sub.add_parser( + "arbitrary-workspace-volatile-replace-v2", + help=( + "Replace an unscoped Source V2 volatile ARB workspace while every output is OFF; " + "the affected channel is not identified and the previous content is not recoverable" + ), + ) + source_arbitrary_workspace_volatile_replace_v2.add_argument("--payload-file", required=True) + source_arbitrary_workspace_volatile_replace_v2.add_argument( + "--point-count", + type=int, + required=True, + ) + add_runtime_options(source_arbitrary_workspace_volatile_replace_v2) + source_arbitrary_select_v2 = source_sub.add_parser( "arbitrary-select-v2", help="Select one named Source V2 ARB waveform while the target output is OFF", diff --git a/src/wavebench/config.py b/src/wavebench/config.py index 9dae508..9076ac5 100644 --- a/src/wavebench/config.py +++ b/src/wavebench/config.py @@ -1,6 +1,6 @@ from __future__ import annotations -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace as _replace from math import isfinite from pathlib import Path import re @@ -384,52 +384,13 @@ class WaveBenchConfig: def with_connection_timeout_ms(self, timeout_ms: int) -> "WaveBenchConfig": if timeout_ms <= 0: raise ConfigError("connection timeout must be > 0") - return WaveBenchConfig( - connection=ConnectionConfig( - backend=self.connection.backend, - resource=self.connection.resource, - timeout_ms=timeout_ms, - opc_timeout_ms=self.connection.opc_timeout_ms, - read_retry_attempts=self.connection.read_retry_attempts, - read_retry_delay_ms=self.connection.read_retry_delay_ms, - ), - scope=self.scope, - autoscale=self.autoscale, - waveform=self.waveform, - output=self.output, - source_path=self.source_path, - source=self.source, - power=self.power, - dmm=self.dmm, - quality=self.quality, - safety_limits=self.safety_limits, - tui=self.tui, - rf_source=self.rf_source, + return _replace( + self, + connection=_replace(self.connection, timeout_ms=timeout_ms), ) def with_resource(self, resource: str) -> "WaveBenchConfig": - return WaveBenchConfig( - connection=ConnectionConfig( - backend=self.connection.backend, - resource=resource, - timeout_ms=self.connection.timeout_ms, - opc_timeout_ms=self.connection.opc_timeout_ms, - read_retry_attempts=self.connection.read_retry_attempts, - read_retry_delay_ms=self.connection.read_retry_delay_ms, - ), - scope=self.scope, - autoscale=self.autoscale, - waveform=self.waveform, - output=self.output, - source_path=self.source_path, - source=self.source, - power=self.power, - dmm=self.dmm, - quality=self.quality, - safety_limits=self.safety_limits, - tui=self.tui, - rf_source=self.rf_source, - ) + return _replace(self, connection=_replace(self.connection, resource=resource)) def with_output_overrides( self, @@ -439,28 +400,15 @@ def with_output_overrides( save_json: bool | None = None, save_screenshot: bool | None = None, ) -> "WaveBenchConfig": - return WaveBenchConfig( - connection=self.connection, - scope=self.scope, - autoscale=self.autoscale, - waveform=self.waveform, - output=OutputConfig( - directory=self.output.directory, - package_naming=self.output.package_naming, + return _replace( + self, + output=_replace( + self.output, save_csv=self.output.save_csv if save_csv is None else save_csv, save_npy=self.output.save_npy if save_npy is None else save_npy, save_json=self.output.save_json if save_json is None else save_json, - save_commands_log=self.output.save_commands_log, save_screenshot=self.output.save_screenshot if save_screenshot is None else save_screenshot, ), - source_path=self.source_path, - source=self.source, - power=self.power, - dmm=self.dmm, - quality=self.quality, - safety_limits=self.safety_limits, - tui=self.tui, - rf_source=self.rf_source, ) def with_waveform_overrides( @@ -476,13 +424,10 @@ def with_waveform_overrides( target_vpp: float | None = None, min_signal_vpp: float | None = None, ) -> "WaveBenchConfig": - return WaveBenchConfig( - connection=self.connection, - scope=self.scope, - autoscale=self.autoscale, - waveform=WaveformConfig( - format=self.waveform.format, - byte_order=self.waveform.byte_order, + return _replace( + self, + waveform=_replace( + self.waveform, points=self.waveform.points if points is None else normalize_waveform_points(points), time_range_s=self.waveform.time_range_s if time_range_s is None else time_range_s, expected_frequency_hz=( @@ -503,15 +448,6 @@ def with_waveform_overrides( target_vpp=self.waveform.target_vpp if target_vpp is None else target_vpp, min_signal_vpp=self.waveform.min_signal_vpp if min_signal_vpp is None else min_signal_vpp, ), - output=self.output, - source_path=self.source_path, - source=self.source, - power=self.power, - dmm=self.dmm, - quality=self.quality, - safety_limits=self.safety_limits, - tui=self.tui, - rf_source=self.rf_source, ) def with_source_resource(self, resource: str) -> "WaveBenchConfig": @@ -523,31 +459,7 @@ def with_source_resource(self, resource: str) -> "WaveBenchConfig": ensure_fix_mode_on_set_frequency=True, settle_ms_after_set_frequency=0, ) - return WaveBenchConfig( - connection=self.connection, - scope=self.scope, - autoscale=self.autoscale, - waveform=self.waveform, - output=self.output, - source_path=self.source_path, - source=SourceConfig( - driver=source.driver, - resource=resource, - default_channel=source.default_channel, - check_errors=source.check_errors, - ensure_fix_mode_on_set_frequency=source.ensure_fix_mode_on_set_frequency, - settle_ms_after_set_frequency=source.settle_ms_after_set_frequency, - options=source.options, - access=source.access, - terminations=source.terminations, - ), - power=self.power, - dmm=self.dmm, - quality=self.quality, - safety_limits=self.safety_limits, - tui=self.tui, - rf_source=self.rf_source, - ) + return _replace(self, source=_replace(source, resource=resource)) def with_power_resource(self, resource: str) -> "WaveBenchConfig": power = self.power or PowerConfig( @@ -558,30 +470,7 @@ def with_power_resource(self, resource: str) -> "WaveBenchConfig": settle_ms_after_set=2000, settle_ms_after_output=1000, ) - return WaveBenchConfig( - connection=self.connection, - scope=self.scope, - autoscale=self.autoscale, - waveform=self.waveform, - output=self.output, - source_path=self.source_path, - source=self.source, - power=PowerConfig( - driver=power.driver, - resource=resource, - default_channel=power.default_channel, - check_errors=power.check_errors, - settle_ms_after_set=power.settle_ms_after_set, - settle_ms_after_output=power.settle_ms_after_output, - options=power.options, - access=power.access, - ), - dmm=self.dmm, - quality=self.quality, - safety_limits=self.safety_limits, - tui=self.tui, - rf_source=self.rf_source, - ) + return _replace(self, power=_replace(power, resource=resource)) def with_dmm_resource(self, resource: str) -> "WaveBenchConfig": dmm = self.dmm or DmmConfig( @@ -597,38 +486,14 @@ def with_dmm_resource(self, resource: str) -> "WaveBenchConfig": settle_ms_after_function_change=500, ) is_tcpip = resource.upper().startswith("TCPIP") - return WaveBenchConfig( - connection=self.connection, - scope=self.scope, - autoscale=self.autoscale, - waveform=self.waveform, - output=self.output, - source_path=self.source_path, - source=self.source, - power=self.power, - dmm=DmmConfig( + return _replace( + self, + dmm=_replace( + dmm, driver="dm3058" if is_tcpip else dmm.driver, resource=resource, backend="lan" if is_tcpip else dmm.backend, - baudrate=dmm.baudrate, - bytesize=dmm.bytesize, - parity=dmm.parity, - stopbits=dmm.stopbits, - timeout_ms=dmm.timeout_ms, - settle_ms_before_read=dmm.settle_ms_before_read, - settle_ms_after_function_change=dmm.settle_ms_after_function_change, - options=dmm.options, - write_termination=dmm.write_termination, - read_termination=dmm.read_termination, - xonxoff=dmm.xonxoff, - rtscts=dmm.rtscts, - dsrdtr=dmm.dsrdtr, - access=dmm.access, ), - quality=self.quality, - safety_limits=self.safety_limits, - tui=self.tui, - rf_source=self.rf_source, ) def with_rf_source_resource(self, resource: str) -> "WaveBenchConfig": @@ -636,27 +501,7 @@ def with_rf_source_resource(self, resource: str) -> "WaveBenchConfig": driver="rigol.dsg830", resource=None, ) - return WaveBenchConfig( - connection=self.connection, - scope=self.scope, - autoscale=self.autoscale, - waveform=self.waveform, - output=self.output, - source_path=self.source_path, - source=self.source, - power=self.power, - dmm=self.dmm, - quality=self.quality, - safety_limits=self.safety_limits, - tui=self.tui, - rf_source=RfSourceConfig( - driver=rf_source.driver, - resource=resource, - options=rf_source.options, - access=rf_source.access, - safety_ports=rf_source.safety_ports, - ), - ) + return _replace(self, rf_source=_replace(rf_source, resource=resource)) def load_config(path: str | Path = "wavebench.toml") -> WaveBenchConfig: config_path = Path(path) diff --git a/src/wavebench/instruments/factory.py b/src/wavebench/instruments/factory.py index 4117db3..6cd26a2 100644 --- a/src/wavebench/instruments/factory.py +++ b/src/wavebench/instruments/factory.py @@ -342,13 +342,3 @@ def _validate_bounded_binary_transport( 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 bbff409..45d067e 100644 --- a/src/wavebench/instruments/models.py +++ b/src/wavebench/instruments/models.py @@ -2642,7 +2642,7 @@ def __post_init__(self) -> None: raise ValueError("unsupported source sweep trigger slope") if self.trigger_out not in {"OFF", "POSITIVE", "NEGATIVE"}: raise ValueError("unsupported source sweep trigger output") - if not self.start_hz <= self.marker_frequency_hz <= self.stop_hz: + if self.marker_enabled and not self.start_hz <= self.marker_frequency_hz <= self.stop_hz: raise ValueError("source sweep marker frequency must be within start and stop") if self.marker_enabled and self.spacing == "STEP": raise ValueError("source sweep marker cannot be enabled with step spacing") diff --git a/src/wavebench/instruments/source_conformance.py b/src/wavebench/instruments/source_conformance.py index 9487ae9..ebc321f 100644 --- a/src/wavebench/instruments/source_conformance.py +++ b/src/wavebench/instruments/source_conformance.py @@ -110,6 +110,10 @@ SourceFeature.SWEEP, frozenset({SourceFeatureDirection.CONFIGURE}), ), + "source.sweep_fire_v2": ( + SourceFeature.SWEEP, + frozenset({SourceFeatureDirection.FIRE}), + ), "source.arbitrary_storage_v2": ( SourceFeature.ARBITRARY, frozenset({SourceFeatureDirection.CONFIGURE}), @@ -118,6 +122,26 @@ SourceFeature.ARBITRARY, frozenset({SourceFeatureDirection.CONFIGURE}), ), + "source.arbitrary_volatile_replace_v2": ( + SourceFeature.ARBITRARY, + frozenset({SourceFeatureDirection.CONFIGURE}), + ), + "source.arbitrary_workspace_volatile_replace_v2": ( + SourceFeature.ARBITRARY_WORKSPACE, + frozenset({SourceFeatureDirection.CONFIGURE}), + ), + "source.counter_configure_v2": ( + SourceFeature.COUNTER, + frozenset({SourceFeatureDirection.CONFIGURE}), + ), + "source.counter_enable_v2": ( + SourceFeature.COUNTER, + frozenset({SourceFeatureDirection.ENABLE, SourceFeatureDirection.DISABLE}), + ), + "source.counter_measure_v2": ( + SourceFeature.COUNTER, + frozenset({SourceFeatureDirection.READ}), + ), "source.combine_configure_v2": ( SourceFeature.COMBINE, frozenset({SourceFeatureDirection.CONFIGURE}), diff --git a/src/wavebench/instruments/source_extension_capabilities.py b/src/wavebench/instruments/source_extension_capabilities.py index 8c6c842..67d5f9c 100644 --- a/src/wavebench/instruments/source_extension_capabilities.py +++ b/src/wavebench/instruments/source_extension_capabilities.py @@ -17,6 +17,9 @@ SOURCE_SNAPSHOT_MIN_CORE_VERSION, SourceAmplitudeUnit, SourceArbitraryCapabilityProfile, + SourceArbitraryWorkspaceCapabilityProfile, + SourceCouplingCapabilityProfile, + SourceCounterCapabilityProfile, SourceCrossChannelCapabilityProfile, SourceDescriptorExtensions, SourceAnchorField, @@ -49,21 +52,35 @@ { "source.snapshot_v2": ("execute_source_query_plan_v2",), "source.basic_configure_v2": ("configure_source_basic_v2",), + "source.basic_live_configure_v2": ( + "configure_source_basic_live_v2", + ), "source.harmonics_configure_v2": ("configure_source_harmonics_v2",), "source.harmonics_disable_v2": ("disable_source_harmonics_v2",), "source.modulation_configure_v2": ("configure_source_modulation_v2",), "source.pulse_configure_v2": ("configure_source_pulse_v2",), "source.modulation_pm_configure_v2": ("configure_source_pm_modulation_v2",), "source.burst_configure_v2": ("configure_source_burst_v2",), + "source.burst_fire_v2": ("fire_source_burst_v2",), "source.modulation_fm_configure_v2": ("configure_source_fm_modulation_v2",), "source.modulation_pwm_configure_v2": ("configure_source_pwm_modulation_v2",), "source.sweep_configure_v2": ("configure_source_sweep_v2",), + "source.sweep_fire_v2": ("fire_source_sweep_v2",), "source.output_v2": ("set_source_output_v2",), "source.arbitrary_storage_v2": ( "read_source_arbitrary_storage_v2", "mutate_source_arbitrary_storage_v2", ), "source.arbitrary_select_v2": ("select_source_arbitrary_v2",), + "source.arbitrary_volatile_replace_v2": ( + "replace_source_arbitrary_volatile_v2", + ), + "source.arbitrary_workspace_volatile_replace_v2": ( + "replace_source_arbitrary_workspace_volatile_v2", + ), + "source.counter_configure_v2": ("configure_source_counter_v2",), + "source.counter_enable_v2": ("set_source_counter_enabled_v2",), + "source.counter_measure_v2": ("measure_source_counter_v2",), "source.combine_configure_v2": ("configure_source_combine_v2",), "source.coupling_configure_v2": ("configure_source_coupling_v2",), "source.tracking_configure_v2": ("configure_source_tracking_v2",), @@ -76,18 +93,25 @@ _SOURCE_WRITE_CAPABILITIES = frozenset( { "source.basic_configure_v2", + "source.basic_live_configure_v2", "source.harmonics_configure_v2", "source.harmonics_disable_v2", "source.modulation_configure_v2", "source.pulse_configure_v2", "source.modulation_pm_configure_v2", "source.burst_configure_v2", + "source.burst_fire_v2", "source.modulation_fm_configure_v2", "source.modulation_pwm_configure_v2", "source.sweep_configure_v2", + "source.sweep_fire_v2", "source.output_v2", "source.arbitrary_storage_v2", "source.arbitrary_select_v2", + "source.arbitrary_volatile_replace_v2", + "source.arbitrary_workspace_volatile_replace_v2", + "source.counter_configure_v2", + "source.counter_enable_v2", "source.combine_configure_v2", "source.coupling_configure_v2", "source.tracking_configure_v2", @@ -120,6 +144,7 @@ def validate_source_descriptor(descriptor: object, driver: object | None = None) ) _validate_source_version_range(descriptor) _validate_read_contract(extensions) + _validate_counter_capabilities(extensions, frozenset(declared)) _validate_write_contract(extensions, frozenset(declared) & _SOURCE_WRITE_CAPABILITIES) if driver is not None: for capability in declared: @@ -218,13 +243,20 @@ def _validate_read_contract(extensions: SourceDescriptorExtensions) -> None: for feature in extensions.features } for feature in extensions.features: + unreadable_workspace = ( + feature.feature is SourceFeature.ARBITRARY_WORKSPACE + and feature.scope is SourceFacetScope.INSTRUMENT + and feature.support is SupportState.SUPPORTED + and SourceFeatureDirection.CONFIGURE in feature.directions + and SourceFeatureDirection.READ not in feature.directions + ) if feature.support.value == "supported" and ( SourceFeatureDirection.READ not in feature.directions - ): + ) and not unreadable_workspace: raise ConfigError( f"supported Source V2 feature {feature.feature.value!r} must declare read" ) - if feature.support.value == "supported" and not any( + if feature.support.value == "supported" and not unreadable_workspace and not any( facet.feature is feature.feature and facet.scope is feature.scope for facet in extensions.query_contract.facets ): @@ -254,6 +286,7 @@ def _validate_read_contract(extensions: SourceDescriptorExtensions) -> None: field_features = { SourceFieldId.BASIC: frozenset({SourceFeature.BASIC}), SourceFieldId.OUTPUT: frozenset({SourceFeature.OUTPUT}), + SourceFieldId.NOISE_OVERLAY: frozenset({SourceFeature.NOISE_OVERLAY}), SourceFieldId.DISPLAY_LOAD: frozenset({SourceFeature.OUTPUT}), SourceFieldId.HARMONICS: frozenset({SourceFeature.HARMONICS}), SourceFieldId.MODULATION: frozenset({SourceFeature.MODULATION}), @@ -320,6 +353,77 @@ def _validate_read_contract(extensions: SourceDescriptorExtensions) -> None: ) +def _validate_counter_capabilities( + extensions: SourceDescriptorExtensions, + capabilities: frozenset[str], +) -> None: + counter_capabilities = { + "source.counter_configure_v2", + "source.counter_enable_v2", + "source.counter_measure_v2", + } + if not counter_capabilities & capabilities: + return + features = tuple( + feature + for feature in extensions.features + if ( + feature.feature is SourceFeature.COUNTER + and feature.scope is SourceFacetScope.INPUT + and feature.support is SupportState.SUPPORTED + and SourceFeatureDirection.READ in feature.directions + and isinstance(feature.profile, SourceCounterCapabilityProfile) + ) + ) + if len(features) != 1: + raise ConfigError( + "Counter V2 capabilities require one readable INPUT counter feature" + ) + feature = features[0] + profile = feature.profile + assert isinstance(profile, SourceCounterCapabilityProfile) + if not any( + facet.feature is SourceFeature.COUNTER + and facet.scope is SourceFacetScope.INPUT + and facet.fields == (SourceFieldId.COUNTER,) + for facet in extensions.query_contract.facets + ): + raise ConfigError("Counter V2 capabilities require a readable counter query facet") + if "source.counter_configure_v2" in capabilities: + if SourceFeatureDirection.CONFIGURE not in feature.directions: + raise ConfigError( + "source.counter_configure_v2 requires counter CONFIGURE direction" + ) + if not profile.configuration_readable or not profile.configurable_fields: + raise ConfigError( + "source.counter_configure_v2 requires readable configurable counter fields" + ) + if "source.counter_enable_v2" in capabilities: + if not { + SourceFeatureDirection.ENABLE, + SourceFeatureDirection.DISABLE, + } <= set(feature.directions): + raise ConfigError( + "source.counter_enable_v2 requires counter ENABLE and DISABLE directions" + ) + if not profile.enabled_configurable: + raise ConfigError( + "source.counter_enable_v2 requires an enabled_configurable counter profile" + ) + if "source.counter_measure_v2" in capabilities: + if not profile.measurement_kinds: + raise ConfigError( + "source.counter_measure_v2 requires declared counter measurement kinds" + ) + if profile.query_effect not in { + SourceQueryEffect.PURE_READ, + SourceQueryEffect.STATEFUL_CONSUMING_READ, + }: + raise ConfigError( + "source.counter_measure_v2 requires a known read-only counter query effect" + ) + + def _validate_write_contract( extensions: SourceDescriptorExtensions, capabilities: frozenset[str], @@ -330,6 +434,11 @@ def _validate_write_contract( basic_readable = _channels_with_basic_final_vpp(extensions) output_readable = _channels_with_output_readback(extensions) + output_disabled = _channels_with_direction( + extensions, + SourceFeature.OUTPUT, + SourceFeatureDirection.DISABLE, + ) if "source.basic_configure_v2" in capabilities: configurable = _channels_with_direction( @@ -350,6 +459,51 @@ def _validate_write_contract( "source.basic_configure_v2 requires readable output state on every channel" ) + if "source.basic_live_configure_v2" in capabilities: + required = {"source.basic_configure_v2", "source.output_v2"} + missing = required - capabilities + if missing: + raise ConfigError( + "source.basic_live_configure_v2 requires source.basic_configure_v2 " + "and source.output_v2" + ) + configurable = _channels_with_direction( + extensions, + SourceFeature.BASIC, + SourceFeatureDirection.CONFIGURE, + ) + live_configurable = frozenset( + channel + for feature in extensions.features + if ( + feature.feature is SourceFeature.BASIC + and feature.scope is SourceFacetScope.CHANNEL + and feature.support is SupportState.SUPPORTED + and SourceFeatureDirection.CONFIGURE in feature.directions + and isinstance(feature.profile, SourceBasicCapabilityProfile) + and SourceFrequencyMode.FIXED in feature.profile.frequency_modes + and ( + feature.profile.live_frequency_configurable + or feature.profile.live_amplitude_vpp_configurable + ) + ) + for channel in feature.channels + ) + if not configurable or not configurable <= live_configurable: + raise ConfigError( + "source.basic_live_configure_v2 requires per-channel fixed-mode live " + "frequency or Vpp declarations" + ) + if not configurable <= basic_readable: + raise ConfigError( + "source.basic_live_configure_v2 requires readable final VPP and Offset " + "on every channel" + ) + if not configurable <= output_readable: + raise ConfigError( + "source.basic_live_configure_v2 requires readable output state on every channel" + ) + if "source.harmonics_configure_v2" in capabilities: configurable = _channels_with_direction( extensions, @@ -506,10 +660,10 @@ def _validate_write_contract( raise ConfigError( "source.burst_configure_v2 requires burst feature CONFIGURE directions" ) - readable = _channels_with_burst_triggered_internal_configuration_readback(extensions) + readable = _channels_with_burst_triggered_configuration_readback(extensions) if not configurable <= readable: raise ConfigError( - "source.burst_configure_v2 requires readable internal triggered burst " + "source.burst_configure_v2 requires readable internal or manual triggered burst " "configuration on every channel" ) if not configurable <= output_readable: @@ -517,6 +671,31 @@ def _validate_write_contract( "source.burst_configure_v2 requires readable output state on every channel" ) + if "source.burst_fire_v2" in capabilities: + required = {"source.burst_configure_v2", "source.output_v2"} + if not required <= capabilities: + raise ConfigError( + "source.burst_fire_v2 requires source.burst_configure_v2 and source.output_v2" + ) + fireable = _channels_with_direction( + extensions, + SourceFeature.BURST, + SourceFeatureDirection.FIRE, + ) + readable = _channels_with_burst_triggered_configuration_readback( + extensions, + trigger_source=SourceTriggerSource.MANUAL, + ) + if not fireable or not fireable <= readable: + raise ConfigError( + "source.burst_fire_v2 requires readable manual triggered burst " + "configuration on every FIRE channel" + ) + if not fireable <= basic_readable or not fireable <= output_readable: + raise ConfigError( + "source.burst_fire_v2 requires readable final VPP, Offset and output state" + ) + if "source.sweep_configure_v2" in capabilities: configurable = _channels_with_direction( extensions, @@ -530,13 +709,39 @@ def _validate_write_contract( readable = _channels_with_sweep_configuration_readback(extensions) if not configurable <= readable: raise ConfigError( - "source.sweep_configure_v2 requires readable internal sweep configuration " + "source.sweep_configure_v2 requires readable internal or manual sweep configuration " "and sweep frequency mode on every channel" ) if not configurable <= output_readable: raise ConfigError( "source.sweep_configure_v2 requires readable output state on every channel" ) + _validate_sweep_implicit_disable_features(extensions, configurable) + + if "source.sweep_fire_v2" in capabilities: + required = {"source.sweep_configure_v2", "source.output_v2"} + if not required <= capabilities: + raise ConfigError( + "source.sweep_fire_v2 requires source.sweep_configure_v2 and source.output_v2" + ) + fireable = _channels_with_direction( + extensions, + SourceFeature.SWEEP, + SourceFeatureDirection.FIRE, + ) + readable = _channels_with_sweep_configuration_readback( + extensions, + trigger_source=SourceTriggerSource.MANUAL, + ) + if not fireable or not fireable <= readable: + raise ConfigError( + "source.sweep_fire_v2 requires readable manual sweep configuration " + "on every FIRE channel" + ) + if not fireable <= basic_readable or not fireable <= output_readable: + raise ConfigError( + "source.sweep_fire_v2 requires readable final VPP, Offset and output state" + ) if "source.arbitrary_storage_v2" in capabilities: configurable = _channels_with_direction( @@ -586,6 +791,75 @@ def _validate_write_contract( "source.arbitrary_select_v2 requires readable output state on every channel" ) + if "source.arbitrary_volatile_replace_v2" in capabilities: + if "source.output_v2" not in capabilities: + raise ConfigError( + "source.arbitrary_volatile_replace_v2 requires source.output_v2" + ) + configurable = _channels_with_direction( + extensions, + SourceFeature.ARBITRARY, + SourceFeatureDirection.CONFIGURE, + ) + if not configurable: + raise ConfigError( + "source.arbitrary_volatile_replace_v2 requires arbitrary feature CONFIGURE directions" + ) + readable = _channels_with_arbitrary_volatile_replace_readback(extensions) + if not configurable <= readable: + raise ConfigError( + "source.arbitrary_volatile_replace_v2 requires readable selected state and " + "volatile replace limits on every channel" + ) + arbitrary_basic = _channels_with_arbitrary_basic_readback(extensions) + if not configurable <= arbitrary_basic: + raise ConfigError( + "source.arbitrary_volatile_replace_v2 requires readable arbitrary basic waveform " + "state on every channel" + ) + if not configurable <= output_readable: + raise ConfigError( + "source.arbitrary_volatile_replace_v2 requires readable output state on every " + "channel" + ) + + if "source.arbitrary_workspace_volatile_replace_v2" in capabilities: + if "source.output_v2" not in capabilities: + raise ConfigError( + "source.arbitrary_workspace_volatile_replace_v2 requires source.output_v2" + ) + profiles = tuple( + feature.profile + for feature in extensions.features + if ( + feature.feature is SourceFeature.ARBITRARY_WORKSPACE + and feature.scope is SourceFacetScope.INSTRUMENT + and feature.support is SupportState.SUPPORTED + and SourceFeatureDirection.CONFIGURE in feature.directions + and isinstance(feature.profile, SourceArbitraryWorkspaceCapabilityProfile) + ) + ) + if len(profiles) != 1: + raise ConfigError( + "source.arbitrary_workspace_volatile_replace_v2 requires one configured " + "instrument arbitrary workspace profile" + ) + if len(extensions.topology.channels) > 8: + raise ConfigError( + "source.arbitrary_workspace_volatile_replace_v2 supports at most eight " + "protected output channels" + ) + if not set(extensions.topology.channels) <= output_readable: + raise ConfigError( + "source.arbitrary_workspace_volatile_replace_v2 requires readable output " + "state on every topology channel" + ) + if not set(extensions.topology.channels) <= output_disabled: + raise ConfigError( + "source.arbitrary_workspace_volatile_replace_v2 requires output DISABLE " + "support on every topology channel" + ) + _validate_cross_channel_write_capability( extensions, capabilities, @@ -622,11 +896,16 @@ def _validate_write_contract( SourceFeature.OUTPUT, SourceFeatureDirection.DISABLE, ) - if not enabled or enabled != disabled: + if not disabled: + raise ConfigError( + "source.output_v2 requires output DISABLE directions" + ) + if not enabled <= disabled: raise ConfigError( - "source.output_v2 requires matching output ENABLE and DISABLE directions" + "source.output_v2 requires every output ENABLE direction to have matching " + "DISABLE support" ) - if not enabled <= output_readable: + if not disabled <= output_readable: raise ConfigError( "source.output_v2 requires readable output state on every channel" ) @@ -641,6 +920,11 @@ def _validate_cross_channel_write_capability( ) -> None: if capability not in capabilities: return + profile_type = ( + SourceCouplingCapabilityProfile + if feature_kind is SourceFeature.COUPLING + else SourceCrossChannelCapabilityProfile + ) configurable = tuple( feature for feature in extensions.features @@ -650,7 +934,7 @@ def _validate_cross_channel_write_capability( and feature.support is SupportState.SUPPORTED and SourceFeatureDirection.CONFIGURE in feature.directions and SourceFeatureDirection.READ in feature.directions - and isinstance(feature.profile, SourceCrossChannelCapabilityProfile) + and isinstance(feature.profile, profile_type) ) ) if not configurable: @@ -659,11 +943,19 @@ def _validate_cross_channel_write_capability( ) for feature in configurable: profile = feature.profile - if ( - feature_kind not in profile.relation_kinds - or feature.channels not in profile.supported_channel_sets - or not profile.configuration_readable - ): + if isinstance(profile, SourceCouplingCapabilityProfile): + readable = ( + feature.channels in profile.supported_channel_sets + and profile.global_state_readable + and profile.configuration_readable + ) + else: + readable = ( + feature_kind in profile.relation_kinds + and feature.channels in profile.supported_channel_sets + and profile.configuration_readable + ) + if not readable: raise ConfigError( f"{capability} requires readable declared {feature_kind.value} relation state" ) @@ -675,7 +967,7 @@ def _validate_cross_channel_write_capability( and feature.scope is SourceFacetScope.INSTRUMENT and feature.support is SupportState.SUPPORTED and SourceFeatureDirection.READ in feature.directions - and isinstance(feature.profile, SourceCrossChannelCapabilityProfile) + and isinstance(feature.profile, profile_type) and feature.profile.relation_graph_readable ) ) @@ -898,9 +1190,16 @@ def _channels_with_pulse_width_configuration_readback( ) -def _channels_with_burst_triggered_internal_configuration_readback( +def _channels_with_burst_triggered_configuration_readback( extensions: SourceDescriptorExtensions, + *, + trigger_source: SourceTriggerSource | None = None, ) -> frozenset[int]: + allowed_sources = ( + {SourceTriggerSource.INTERNAL, SourceTriggerSource.MANUAL} + if trigger_source is None + else {trigger_source} + ) return frozenset( channel for feature in extensions.features @@ -911,9 +1210,19 @@ def _channels_with_burst_triggered_internal_configuration_readback( and SourceFeatureDirection.READ in feature.directions and isinstance(feature.profile, SourceBurstCapabilityProfile) and SourceBurstMode.TRIGGERED in feature.profile.modes - and SourceTriggerSource.INTERNAL in feature.profile.trigger_sources and feature.profile.timing_readable - and feature.profile.triggered_internal_configuration_readable + and ( + ( + SourceTriggerSource.INTERNAL in allowed_sources + and SourceTriggerSource.INTERNAL in feature.profile.trigger_sources + and feature.profile.triggered_internal_configuration_readable + ) + or ( + SourceTriggerSource.MANUAL in allowed_sources + and SourceTriggerSource.MANUAL in feature.profile.trigger_sources + and feature.profile.triggered_manual_configuration_readable + ) + ) ) for channel in feature.channels ) @@ -921,7 +1230,14 @@ def _channels_with_burst_triggered_internal_configuration_readback( def _channels_with_sweep_configuration_readback( extensions: SourceDescriptorExtensions, + *, + trigger_source: SourceTriggerSource | None = None, ) -> frozenset[int]: + allowed_sources = ( + {SourceTriggerSource.INTERNAL, SourceTriggerSource.MANUAL} + if trigger_source is None + else {trigger_source} + ) sweep_channels = frozenset( channel for feature in extensions.features @@ -932,7 +1248,10 @@ def _channels_with_sweep_configuration_readback( and SourceFeatureDirection.READ in feature.directions and isinstance(feature.profile, SourceSweepCapabilityProfile) and bool(feature.profile.spacing_modes) - and SourceTriggerSource.INTERNAL in feature.profile.trigger_sources + and any( + source in feature.profile.trigger_sources + for source in allowed_sources + ) and feature.profile.timing_readable and feature.profile.marker_readable and feature.profile.configuration_readable @@ -955,6 +1274,72 @@ def _channels_with_sweep_configuration_readback( return sweep_channels & basic_sweep_channels +def _validate_sweep_implicit_disable_features( + extensions: SourceDescriptorExtensions, + configurable: frozenset[int], +) -> None: + readable_channels = { + feature: _channels_with_inactive_feature_readback(extensions, feature) + for feature in (SourceFeature.BURST, SourceFeature.MODULATION) + } + for sweep in extensions.features: + if ( + sweep.feature is not SourceFeature.SWEEP + or sweep.scope is not SourceFacetScope.CHANNEL + or sweep.support is not SupportState.SUPPORTED + or SourceFeatureDirection.CONFIGURE not in sweep.directions + or not isinstance(sweep.profile, SourceSweepCapabilityProfile) + ): + continue + for channel in set(sweep.channels) & configurable: + for feature in sweep.profile.implicit_disable_features: + if channel not in readable_channels[feature]: + raise ConfigError( + "source.sweep_configure_v2 requires readable inactive " + f"{feature.value} state on every configured channel" + ) + + +def _channels_with_inactive_feature_readback( + extensions: SourceDescriptorExtensions, + feature: SourceFeature, +) -> frozenset[int]: + field = { + SourceFeature.BURST: SourceFieldId.BURST, + SourceFeature.MODULATION: SourceFieldId.MODULATION, + }.get(feature) + if field is None: + raise ValueError("sweep implicit disable feature is unsupported") + has_required_unconditional_query = any( + facet.feature is feature + and facet.scope is SourceFacetScope.CHANNEL + and facet.fields == (field,) + and not facet.activation_any + and facet.required + for facet in extensions.query_contract.facets + ) + if not has_required_unconditional_query: + return frozenset() + return frozenset( + channel + for candidate in extensions.features + if ( + candidate.feature is feature + and candidate.scope is SourceFacetScope.CHANNEL + and candidate.support is SupportState.SUPPORTED + and SourceFeatureDirection.READ in candidate.directions + and ( + isinstance(candidate.profile, SourceBurstCapabilityProfile) + and candidate.profile.inactive_readable + if feature is SourceFeature.BURST + else isinstance(candidate.profile, SourceModulationCapabilityProfile) + and candidate.profile.inactive_readable + ) + ) + for channel in candidate.channels + ) + + def _channels_with_arbitrary_storage_mutation_readback( extensions: SourceDescriptorExtensions, ) -> frozenset[int]: @@ -1001,6 +1386,27 @@ def _channels_with_arbitrary_selection_readback( ) +def _channels_with_arbitrary_volatile_replace_readback( + extensions: SourceDescriptorExtensions, +) -> frozenset[int]: + return frozenset( + channel + for feature in extensions.features + if ( + feature.feature is SourceFeature.ARBITRARY + and feature.scope is SourceFacetScope.CHANNEL + and feature.support is SupportState.SUPPORTED + and SourceFeatureDirection.READ in feature.directions + and isinstance(feature.profile, SourceArbitraryCapabilityProfile) + and feature.profile.selection_readable + and feature.profile.volatile_replace_min_points is not None + and feature.profile.volatile_replace_max_points is not None + and feature.profile.volatile_replace_max_payload_bytes is not None + ) + for channel in feature.channels + ) + + def _channels_with_arbitrary_basic_readback( extensions: SourceDescriptorExtensions, ) -> frozenset[int]: @@ -1025,7 +1431,10 @@ def _validate_declared_write_directions( ) -> None: capabilities_by_direction = { (SourceFeature.BASIC, SourceFeatureDirection.CONFIGURE): frozenset( - {"source.basic_configure_v2"} + { + "source.basic_configure_v2", + "source.basic_live_configure_v2", + } ), (SourceFeature.HARMONICS, SourceFeatureDirection.CONFIGURE): frozenset( {"source.harmonics_configure_v2"} @@ -1047,15 +1456,34 @@ def _validate_declared_write_directions( (SourceFeature.BURST, SourceFeatureDirection.CONFIGURE): frozenset( {"source.burst_configure_v2"} ), + (SourceFeature.BURST, SourceFeatureDirection.FIRE): frozenset( + {"source.burst_fire_v2"} + ), (SourceFeature.SWEEP, SourceFeatureDirection.CONFIGURE): frozenset( {"source.sweep_configure_v2"} ), + (SourceFeature.SWEEP, SourceFeatureDirection.FIRE): frozenset( + {"source.sweep_fire_v2"} + ), (SourceFeature.ARBITRARY, SourceFeatureDirection.CONFIGURE): frozenset( { "source.arbitrary_storage_v2", "source.arbitrary_select_v2", + "source.arbitrary_volatile_replace_v2", } ), + (SourceFeature.ARBITRARY_WORKSPACE, SourceFeatureDirection.CONFIGURE): frozenset( + {"source.arbitrary_workspace_volatile_replace_v2"} + ), + (SourceFeature.COUNTER, SourceFeatureDirection.CONFIGURE): frozenset( + {"source.counter_configure_v2"} + ), + (SourceFeature.COUNTER, SourceFeatureDirection.ENABLE): frozenset( + {"source.counter_enable_v2"} + ), + (SourceFeature.COUNTER, SourceFeatureDirection.DISABLE): frozenset( + {"source.counter_enable_v2"} + ), (SourceFeature.COMBINE, SourceFeatureDirection.CONFIGURE): frozenset( {"source.combine_configure_v2"} ), diff --git a/src/wavebench/instruments/source_extensions.py b/src/wavebench/instruments/source_extensions.py index b57bc9b..b991b45 100644 --- a/src/wavebench/instruments/source_extensions.py +++ b/src/wavebench/instruments/source_extensions.py @@ -165,12 +165,14 @@ def _contains_nonfinite(value: object) -> bool: class SourceFeature(StrEnum): BASIC = "basic" OUTPUT = "output" + NOISE_OVERLAY = "noise_overlay" HARMONICS = "harmonics" MODULATION = "modulation" SWEEP = "sweep" BURST = "burst" PULSE = "pulse" ARBITRARY = "arbitrary" + ARBITRARY_WORKSPACE = "arbitrary_workspace" COUNTER = "counter" REFERENCE_CLOCK = "reference_clock" SYNC = "sync" @@ -235,6 +237,18 @@ class SourceOutputPolarity(StrEnum): UNKNOWN = "unknown" +class SourceSyncPolarity(StrEnum): + POSITIVE = "positive" + NEGATIVE = "negative" + UNKNOWN = "unknown" + + +class SourceNoiseOverlayScaleKind(StrEnum): + PERCENT = "percent" + RATIO = "ratio" + RATIO_DB = "ratio_db" + + class SourceLoadKind(StrEnum): HIGH_IMPEDANCE = "high_impedance" RESISTIVE = "resistive" @@ -345,12 +359,53 @@ class SourceCounterMeasurementKind(StrEnum): UNKNOWN = "unknown" +class SourceCounterConfigurationField(StrEnum): + """A counter setting with a portable, independently readable meaning. + + The deliberately short set excludes vendor presets (for example an + ``AUTO`` gate-time action), display preferences, and destructive + statistics clears. Those need their own contract instead of becoming + incidental side effects of a measurement request. + """ + + COUPLING = "coupling" + IMPEDANCE_OHM = "impedance_ohm" + ATTENUATION = "attenuation" + TRIGGER_LEVEL_V = "trigger_level_v" + STATISTICS_ENABLED = "statistics_enabled" + + class SourceInputCoupling(StrEnum): AC = "ac" DC = "dc" UNKNOWN = "unknown" +class SourceCouplingDimension(StrEnum): + AMPLITUDE = "amplitude" + FREQUENCY = "frequency" + PHASE = "phase" + + +class SourceCouplingParameterKind(StrEnum): + AMPLITUDE_DEVIATION_VPP = "amplitude_deviation_vpp" + AMPLITUDE_RATIO = "amplitude_ratio" + FREQUENCY_DEVIATION_HZ = "frequency_deviation_hz" + FREQUENCY_RATIO = "frequency_ratio" + PHASE_DEVIATION_DEG = "phase_deviation_deg" + PHASE_RATIO = "phase_ratio" + + +_COUPLING_PARAMETER_DIMENSIONS = { + SourceCouplingParameterKind.AMPLITUDE_DEVIATION_VPP: SourceCouplingDimension.AMPLITUDE, + SourceCouplingParameterKind.AMPLITUDE_RATIO: SourceCouplingDimension.AMPLITUDE, + SourceCouplingParameterKind.FREQUENCY_DEVIATION_HZ: SourceCouplingDimension.FREQUENCY, + SourceCouplingParameterKind.FREQUENCY_RATIO: SourceCouplingDimension.FREQUENCY, + SourceCouplingParameterKind.PHASE_DEVIATION_DEG: SourceCouplingDimension.PHASE, + SourceCouplingParameterKind.PHASE_RATIO: SourceCouplingDimension.PHASE, +} + + class SourceReferenceClockMode(StrEnum): INTERNAL = "internal" EXTERNAL = "external" @@ -468,6 +523,8 @@ class SourceBasicCapabilityProfile: offset_readable: bool phase_readable: bool square_duty_readable: bool + live_frequency_configurable: bool = False + live_amplitude_vpp_configurable: bool = False def __post_init__(self) -> None: _require_enum_tuple(self.waveform_kinds, SourceWaveformKind, "basic waveform_kinds") @@ -476,6 +533,14 @@ def __post_init__(self) -> None: _require_bool(self.offset_readable, "basic offset_readable") _require_bool(self.phase_readable, "basic phase_readable") _require_bool(self.square_duty_readable, "basic square_duty_readable") + _require_bool( + self.live_frequency_configurable, + "basic live_frequency_configurable", + ) + _require_bool( + self.live_amplitude_vpp_configurable, + "basic live_amplitude_vpp_configurable", + ) @dataclass(frozen=True, slots=True) @@ -490,6 +555,21 @@ def __post_init__(self) -> None: _require_bool(self.polarity_readable, "output polarity_readable") +@dataclass(frozen=True, slots=True) +class SourceNoiseOverlayCapabilityProfile: + enabled_readable: bool + scale_kinds: tuple[SourceNoiseOverlayScaleKind, ...] = () + + def __post_init__(self) -> None: + _require_bool(self.enabled_readable, "noise overlay enabled_readable") + _require_enum_tuple( + self.scale_kinds, + SourceNoiseOverlayScaleKind, + "noise overlay scale_kinds", + allow_empty=True, + ) + + @dataclass(frozen=True, slots=True) class SourceHarmonicCapabilityProfile: minimum_order: int @@ -550,6 +630,7 @@ class SourceSweepCapabilityProfile: timing_readable: bool marker_readable: bool configuration_readable: bool = False + implicit_disable_features: tuple[SourceFeature, ...] = () def __post_init__(self) -> None: _require_enum_tuple(self.spacing_modes, SourceSweepSpacing, "sweep spacing_modes") @@ -557,6 +638,19 @@ def __post_init__(self) -> None: _require_bool(self.timing_readable, "sweep timing_readable") _require_bool(self.marker_readable, "sweep marker_readable") _require_bool(self.configuration_readable, "sweep configuration_readable") + _require_enum_tuple( + self.implicit_disable_features, + SourceFeature, + "sweep implicit_disable_features", + allow_empty=True, + ) + if not set(self.implicit_disable_features) <= { + SourceFeature.BURST, + SourceFeature.MODULATION, + }: + raise ValueError( + "sweep implicit_disable_features only supports burst and modulation" + ) @dataclass(frozen=True, slots=True) @@ -566,6 +660,8 @@ class SourceBurstCapabilityProfile: timing_readable: bool gate_readable: bool triggered_internal_configuration_readable: bool = False + triggered_manual_configuration_readable: bool = False + inactive_readable: bool = False def __post_init__(self) -> None: _require_enum_tuple(self.modes, SourceBurstMode, "burst modes") @@ -576,6 +672,11 @@ def __post_init__(self) -> None: self.triggered_internal_configuration_readable, "burst triggered_internal_configuration_readable", ) + _require_bool( + self.triggered_manual_configuration_readable, + "burst triggered_manual_configuration_readable", + ) + _require_bool(self.inactive_readable, "burst inactive_readable") @dataclass(frozen=True, slots=True) @@ -604,6 +705,9 @@ class SourceArbitraryCapabilityProfile: storage_slot_metadata_readable: bool = False storage_write_modes: tuple[SourceStorageWriteMode, ...] = () storage_max_payload_bytes: int | None = None + volatile_replace_min_points: int | None = None + volatile_replace_max_points: int | None = None + volatile_replace_max_payload_bytes: int | None = None def __post_init__(self) -> None: _require_enum_tuple( @@ -637,6 +741,75 @@ def __post_init__(self) -> None: raise ValueError( "arbitrary storage_max_payload_bytes requires storage_write_modes" ) + volatile_limits = ( + self.volatile_replace_min_points, + self.volatile_replace_max_points, + self.volatile_replace_max_payload_bytes, + ) + if any(value is not None for value in volatile_limits): + if any(value is None for value in volatile_limits): + raise ValueError( + "arbitrary volatile replace limits must be provided together" + ) + _require_int( + self.volatile_replace_min_points, + "arbitrary volatile_replace_min_points", + minimum=1, + ) + _require_int( + self.volatile_replace_max_points, + "arbitrary volatile_replace_max_points", + minimum=1, + ) + _require_int( + self.volatile_replace_max_payload_bytes, + "arbitrary volatile_replace_max_payload_bytes", + minimum=1, + ) + assert self.volatile_replace_min_points is not None + assert self.volatile_replace_max_points is not None + if self.volatile_replace_min_points > self.volatile_replace_max_points: + raise ValueError( + "arbitrary volatile_replace_min_points must not exceed " + "volatile_replace_max_points" + ) + + +@dataclass(frozen=True, slots=True) +class SourceArbitraryWorkspaceCapabilityProfile: + """One unscoped volatile arbitrary-waveform workspace. + + This profile deliberately describes storage only. It does not assert that + a binary write selects a waveform on any particular output channel. + """ + + workspace_id: str + volatile_replace_min_points: int + volatile_replace_max_points: int + volatile_replace_max_payload_bytes: int + + def __post_init__(self) -> None: + _require_token(self.workspace_id, "arbitrary workspace_id") + _require_int( + self.volatile_replace_min_points, + "arbitrary workspace volatile_replace_min_points", + minimum=1, + ) + _require_int( + self.volatile_replace_max_points, + "arbitrary workspace volatile_replace_max_points", + minimum=1, + ) + _require_int( + self.volatile_replace_max_payload_bytes, + "arbitrary workspace volatile_replace_max_payload_bytes", + minimum=1, + ) + if self.volatile_replace_min_points > self.volatile_replace_max_points: + raise ValueError( + "arbitrary workspace volatile_replace_min_points must not exceed " + "volatile_replace_max_points" + ) class SourceQueryEffect(StrEnum): @@ -652,6 +825,9 @@ class SourceCounterCapabilityProfile: measurement_kinds: tuple[SourceCounterMeasurementKind, ...] configuration_readable: bool query_effect: SourceQueryEffect + readable_configuration_fields: tuple[SourceCounterConfigurationField, ...] = () + configurable_fields: tuple[SourceCounterConfigurationField, ...] = () + enabled_configurable: bool = False def __post_init__(self) -> None: _require_token_tuple(self.input_ids, "counter input_ids", allow_empty=False) @@ -663,22 +839,113 @@ def __post_init__(self) -> None: _require_bool(self.configuration_readable, "counter configuration_readable") if not isinstance(self.query_effect, SourceQueryEffect): raise ValueError("counter query_effect has an invalid type") + _require_enum_tuple( + self.readable_configuration_fields, + SourceCounterConfigurationField, + "counter readable_configuration_fields", + allow_empty=True, + ) + _require_enum_tuple( + self.configurable_fields, + SourceCounterConfigurationField, + "counter configurable_fields", + allow_empty=True, + ) + _require_bool(self.enabled_configurable, "counter enabled_configurable") + if self.readable_configuration_fields and not self.configuration_readable: + raise ValueError( + "counter readable_configuration_fields require configuration_readable" + ) + if not set(self.configurable_fields) <= set(self.readable_configuration_fields): + raise ValueError( + "counter configurable_fields require matching readable_configuration_fields" + ) + + +@dataclass(frozen=True, slots=True) +class SourceReferenceClockCapabilityProfile: + modes: tuple[SourceReferenceClockMode, ...] + frequency_readable: bool + lock_state_readable: bool + + def __post_init__(self) -> None: + _require_enum_tuple(self.modes, SourceReferenceClockMode, "reference clock modes") + _require_bool(self.frequency_readable, "reference clock frequency_readable") + _require_bool(self.lock_state_readable, "reference clock lock_state_readable") + + +@dataclass(frozen=True, slots=True) +class SourceSyncCapabilityProfile: + enabled_readable: bool + polarity_readable: bool + source_channel_readable: bool + source_channels: tuple[int, ...] = () + + def __post_init__(self) -> None: + _require_bool(self.enabled_readable, "sync enabled_readable") + _require_bool(self.polarity_readable, "sync polarity_readable") + _require_bool(self.source_channel_readable, "sync source_channel_readable") + _require_positive_channels( + self.source_channels, + "sync source_channels", + allow_empty=not self.source_channel_readable, + ) @dataclass(frozen=True, slots=True) -class SourceClockSyncCapabilityProfile: - reference_clock_modes: tuple[SourceReferenceClockMode, ...] - sync_readable: bool - cascade_readable: bool +class SourceCascadeCapabilityProfile: + enabled_readable: bool + role_readable: bool def __post_init__(self) -> None: + _require_bool(self.enabled_readable, "cascade enabled_readable") + _require_bool(self.role_readable, "cascade role_readable") + + +@dataclass(frozen=True, slots=True) +class SourceCouplingCapabilityProfile: + dimensions: tuple[SourceCouplingDimension, ...] + parameter_kinds: tuple[SourceCouplingParameterKind, ...] + supported_channel_sets: tuple[tuple[int, ...], ...] + global_state_readable: bool + reference_channel_readable: bool + relation_graph_readable: bool + configuration_readable: bool = False + + def __post_init__(self) -> None: + _require_enum_tuple( + self.dimensions, + SourceCouplingDimension, + "coupling dimensions", + ) _require_enum_tuple( - self.reference_clock_modes, - SourceReferenceClockMode, - "clock reference_clock_modes", + self.parameter_kinds, + SourceCouplingParameterKind, + "coupling parameter_kinds", ) - _require_bool(self.sync_readable, "clock sync_readable") - _require_bool(self.cascade_readable, "clock cascade_readable") + if any( + _COUPLING_PARAMETER_DIMENSIONS[kind] not in self.dimensions + for kind in self.parameter_kinds + ): + raise ValueError("coupling parameter_kinds reference an unsupported dimension") + if not isinstance(self.supported_channel_sets, tuple): + raise ValueError("coupling supported_channel_sets must be a tuple") + for channel_set in self.supported_channel_sets: + _require_positive_channels(channel_set, "coupling supported channel set") + if len(channel_set) < 2: + raise ValueError("coupling channel sets require at least two channels") + if ( + len(set(self.supported_channel_sets)) != len(self.supported_channel_sets) + or tuple(sorted(self.supported_channel_sets)) != self.supported_channel_sets + ): + raise ValueError("coupling supported_channel_sets must be sorted and unique") + _require_bool(self.global_state_readable, "coupling global_state_readable") + _require_bool( + self.reference_channel_readable, + "coupling reference_channel_readable", + ) + _require_bool(self.relation_graph_readable, "coupling relation_graph_readable") + _require_bool(self.configuration_readable, "coupling configuration_readable") @dataclass(frozen=True, slots=True) @@ -694,7 +961,6 @@ def __post_init__(self) -> None: allowed = { SourceFeature.COMBINE, SourceFeature.TRACKING, - SourceFeature.COUPLING, SourceFeature.COPY, SourceFeature.PHASE_RELATION, SourceFeature.SHARED_POWER, @@ -726,14 +992,19 @@ def __post_init__(self) -> None: SourceFeatureProfile: TypeAlias = ( SourceBasicCapabilityProfile | SourceOutputCapabilityProfile + | SourceNoiseOverlayCapabilityProfile | SourceHarmonicCapabilityProfile | SourceModulationCapabilityProfile | SourceSweepCapabilityProfile | SourceBurstCapabilityProfile | SourcePulseCapabilityProfile | SourceArbitraryCapabilityProfile + | SourceArbitraryWorkspaceCapabilityProfile | SourceCounterCapabilityProfile - | SourceClockSyncCapabilityProfile + | SourceReferenceClockCapabilityProfile + | SourceSyncCapabilityProfile + | SourceCascadeCapabilityProfile + | SourceCouplingCapabilityProfile | SourceCrossChannelCapabilityProfile ) @@ -785,6 +1056,7 @@ class SourceFieldId(StrEnum): IDENTITY = "source.identity" BASIC = "source.channel.basic" OUTPUT = "source.channel.output" + NOISE_OVERLAY = "source.channel.noise_overlay" DISPLAY_LOAD = "source.channel.display_load" HARMONICS = "source.channel.harmonics" MODULATION = "source.channel.modulation" @@ -793,6 +1065,7 @@ class SourceFieldId(StrEnum): PULSE = "source.channel.pulse" ARBITRARY_SELECTION = "source.channel.arbitrary_selection" ARBITRARY_STORAGE = "source.channel.arbitrary_storage" + ARBITRARY_WORKSPACE = "source.instrument.arbitrary_workspace" ARM_STATE = "source.channel.arm_state" TRIGGER_STATE = "source.channel.trigger_state" COMBINE = "source.cross_channel.combine" @@ -802,7 +1075,7 @@ class SourceFieldId(StrEnum): PHASE_RELATION = "source.cross_channel.phase_relation" RELATION_GRAPH = "source.cross_channel.relation_graph" REFERENCE_CLOCK = "source.instrument.reference_clock" - SYNC = "source.instrument.sync" + SYNC = "source.channel.sync" CASCADE = "source.instrument.cascade" SHARED_POWER = "source.instrument.shared_power" COUNTER = "source.input.counter" @@ -812,6 +1085,7 @@ class SourceFieldId(StrEnum): SourceFieldId.IDENTITY: frozenset({SourceFacetScope.INSTRUMENT}), SourceFieldId.BASIC: frozenset({SourceFacetScope.CHANNEL}), SourceFieldId.OUTPUT: frozenset({SourceFacetScope.CHANNEL}), + SourceFieldId.NOISE_OVERLAY: frozenset({SourceFacetScope.CHANNEL}), SourceFieldId.DISPLAY_LOAD: frozenset({SourceFacetScope.CHANNEL}), SourceFieldId.HARMONICS: frozenset({SourceFacetScope.CHANNEL}), SourceFieldId.MODULATION: frozenset({SourceFacetScope.CHANNEL}), @@ -820,6 +1094,7 @@ class SourceFieldId(StrEnum): SourceFieldId.PULSE: frozenset({SourceFacetScope.CHANNEL}), SourceFieldId.ARBITRARY_SELECTION: frozenset({SourceFacetScope.CHANNEL}), SourceFieldId.ARBITRARY_STORAGE: frozenset({SourceFacetScope.CHANNEL}), + SourceFieldId.ARBITRARY_WORKSPACE: frozenset({SourceFacetScope.INSTRUMENT}), SourceFieldId.ARM_STATE: frozenset({SourceFacetScope.CHANNEL}), SourceFieldId.TRIGGER_STATE: frozenset({SourceFacetScope.CHANNEL}), SourceFieldId.COMBINE: frozenset({SourceFacetScope.CHANNEL_SET}), @@ -829,12 +1104,8 @@ class SourceFieldId(StrEnum): SourceFieldId.PHASE_RELATION: frozenset({SourceFacetScope.CHANNEL_SET}), SourceFieldId.RELATION_GRAPH: frozenset({SourceFacetScope.INSTRUMENT}), SourceFieldId.REFERENCE_CLOCK: frozenset({SourceFacetScope.INSTRUMENT}), - SourceFieldId.SYNC: frozenset( - {SourceFacetScope.INSTRUMENT, SourceFacetScope.CHANNEL_SET} - ), - SourceFieldId.CASCADE: frozenset( - {SourceFacetScope.INSTRUMENT, SourceFacetScope.CHANNEL_SET} - ), + SourceFieldId.SYNC: frozenset({SourceFacetScope.CHANNEL}), + SourceFieldId.CASCADE: frozenset({SourceFacetScope.INSTRUMENT}), SourceFieldId.SHARED_POWER: frozenset({SourceFacetScope.INSTRUMENT}), SourceFieldId.COUNTER: frozenset({SourceFacetScope.INPUT}), } @@ -860,6 +1131,7 @@ class SourceEnergyEffect(StrEnum): NONE = "none" DECREASE_ONLY = "decrease_only" POTENTIAL_WHILE_OFF = "potential_while_off" + LIVE_MUTATION = "live_mutation" MAY_INCREASE = "may_increase" EMIT = "emit" UNKNOWN = "unknown" @@ -1066,6 +1338,39 @@ def __post_init__(self) -> None: ) +SOURCE_BASIC_LIVE_CONFIGURE_V2_OPERATION_CONTRACT = SourceOperationContract( + operation="source.basic_live_configure_v2", + capability="source.basic_live_configure_v2", + feature=SourceFeature.BASIC, + direction=SourceFeatureDirection.CONFIGURE, + energy_effect=SourceEnergyEffect.LIVE_MUTATION, + storage_effect=SourceStorageEffect.NONE, + required_fields=( + SourceFieldId.BASIC, + SourceFieldId.OUTPUT, + SourceFieldId.IDENTITY, + ), + changed_fields=(SourceFieldId.BASIC,), + postcondition_fields=( + SourceFieldId.BASIC, + SourceFieldId.OUTPUT, + ), + cleanup_verification_fields=(SourceFieldId.OUTPUT,), + v1_equivalent_routes=( + SourceV1WriteRouteId.SET_AMPLITUDE_VPP, + SourceV1WriteRouteId.SET_FREQUENCY, + ), + v1_overlapping_routes=( + SourceV1WriteRouteId.RESTORE, + SourceV1WriteRouteId.UPLOAD_ARBITRARY, + ), + operation_timeout_ms=5_000, + main_max_steps=1, + recovery_max_steps=2, + verification_max_steps=2, +) + + SOURCE_HARMONICS_CONFIGURE_V2_OPERATION_CONTRACT = SourceOperationContract( operation="source.harmonics_configure_v2", capability="source.harmonics_configure_v2", @@ -1217,6 +1522,38 @@ def __post_init__(self) -> None: ) +SOURCE_BURST_FIRE_V2_OPERATION_CONTRACT = SourceOperationContract( + operation="source.burst_fire_v2", + capability="source.burst_fire_v2", + feature=SourceFeature.BURST, + direction=SourceFeatureDirection.FIRE, + energy_effect=SourceEnergyEffect.EMIT, + storage_effect=SourceStorageEffect.NONE, + required_fields=( + SourceFieldId.BASIC, + SourceFieldId.BURST, + SourceFieldId.OUTPUT, + SourceFieldId.IDENTITY, + ), + changed_fields=(SourceFieldId.BURST,), + postcondition_fields=( + SourceFieldId.BURST, + SourceFieldId.OUTPUT, + ), + cleanup_verification_fields=(SourceFieldId.OUTPUT,), + v1_equivalent_routes=(SourceV1WriteRouteId.TRIGGER_BURST,), + v1_overlapping_routes=( + SourceV1WriteRouteId.CONFIGURE_BURST, + SourceV1WriteRouteId.RESTORE, + SourceV1WriteRouteId.SET_OUTPUT, + ), + operation_timeout_ms=5_000, + main_max_steps=1, + recovery_max_steps=1, + verification_max_steps=2, +) + + SOURCE_SWEEP_CONFIGURE_V2_OPERATION_CONTRACT = SourceOperationContract( operation="source.sweep_configure_v2", capability="source.sweep_configure_v2", @@ -1253,6 +1590,38 @@ def __post_init__(self) -> None: ) +SOURCE_SWEEP_FIRE_V2_OPERATION_CONTRACT = SourceOperationContract( + operation="source.sweep_fire_v2", + capability="source.sweep_fire_v2", + feature=SourceFeature.SWEEP, + direction=SourceFeatureDirection.FIRE, + energy_effect=SourceEnergyEffect.EMIT, + storage_effect=SourceStorageEffect.NONE, + required_fields=( + SourceFieldId.BASIC, + SourceFieldId.OUTPUT, + SourceFieldId.SWEEP, + SourceFieldId.IDENTITY, + ), + changed_fields=(SourceFieldId.SWEEP,), + postcondition_fields=( + SourceFieldId.OUTPUT, + SourceFieldId.SWEEP, + ), + cleanup_verification_fields=(SourceFieldId.OUTPUT,), + v1_equivalent_routes=(SourceV1WriteRouteId.TRIGGER_SWEEP,), + v1_overlapping_routes=( + SourceV1WriteRouteId.CONFIGURE_SWEEP, + SourceV1WriteRouteId.RESTORE, + SourceV1WriteRouteId.SET_OUTPUT, + ), + operation_timeout_ms=5_000, + main_max_steps=1, + recovery_max_steps=1, + verification_max_steps=2, +) + + SOURCE_FM_MODULATION_CONFIGURE_V2_OPERATION_CONTRACT = SourceOperationContract( operation="source.modulation_fm_configure_v2", capability="source.modulation_fm_configure_v2", @@ -1456,6 +1825,121 @@ def __post_init__(self) -> None: ) +SOURCE_ARBITRARY_VOLATILE_REPLACE_V2_OPERATION_CONTRACT = SourceOperationContract( + operation="source.arbitrary_volatile_replace_v2", + capability="source.arbitrary_volatile_replace_v2", + feature=SourceFeature.ARBITRARY, + direction=SourceFeatureDirection.CONFIGURE, + energy_effect=SourceEnergyEffect.POTENTIAL_WHILE_OFF, + storage_effect=SourceStorageEffect.REPLACE, + required_fields=( + SourceFieldId.BASIC, + SourceFieldId.OUTPUT, + SourceFieldId.IDENTITY, + ), + changed_fields=( + SourceFieldId.ARBITRARY_SELECTION, + SourceFieldId.ARBITRARY_STORAGE, + SourceFieldId.BASIC, + ), + postcondition_fields=( + SourceFieldId.ARBITRARY_SELECTION, + SourceFieldId.BASIC, + SourceFieldId.OUTPUT, + ), + cleanup_verification_fields=(SourceFieldId.OUTPUT,), + v1_equivalent_routes=(), + v1_overlapping_routes=(SourceV1WriteRouteId.UPLOAD_ARBITRARY,), + operation_timeout_ms=5_000, + main_max_steps=1, + recovery_max_steps=1, + verification_max_steps=2, +) + + +SOURCE_ARBITRARY_WORKSPACE_VOLATILE_REPLACE_V2_OPERATION_CONTRACT = SourceOperationContract( + operation="source.arbitrary_workspace_volatile_replace_v2", + capability="source.arbitrary_workspace_volatile_replace_v2", + feature=SourceFeature.ARBITRARY_WORKSPACE, + direction=SourceFeatureDirection.CONFIGURE, + energy_effect=SourceEnergyEffect.POTENTIAL_WHILE_OFF, + storage_effect=SourceStorageEffect.REPLACE, + required_fields=( + SourceFieldId.OUTPUT, + SourceFieldId.IDENTITY, + ), + changed_fields=(SourceFieldId.ARBITRARY_WORKSPACE,), + postcondition_fields=(SourceFieldId.OUTPUT,), + cleanup_verification_fields=(SourceFieldId.OUTPUT,), + v1_equivalent_routes=(), + v1_overlapping_routes=(SourceV1WriteRouteId.UPLOAD_ARBITRARY,), + operation_timeout_ms=5_000, + main_max_steps=1, + recovery_max_steps=8, + verification_max_steps=2, +) + + +SOURCE_COUNTER_CONFIGURE_V2_OPERATION_CONTRACT = SourceOperationContract( + operation="source.counter_configure_v2", + capability="source.counter_configure_v2", + feature=SourceFeature.COUNTER, + direction=SourceFeatureDirection.CONFIGURE, + energy_effect=SourceEnergyEffect.NONE, + storage_effect=SourceStorageEffect.NONE, + required_fields=(SourceFieldId.IDENTITY, SourceFieldId.COUNTER), + changed_fields=(SourceFieldId.COUNTER,), + postcondition_fields=(SourceFieldId.COUNTER,), + cleanup_verification_fields=(), + v1_equivalent_routes=(), + v1_overlapping_routes=(), + operation_timeout_ms=5_000, + main_max_steps=1, + recovery_max_steps=1, + verification_max_steps=2, +) + + +SOURCE_COUNTER_ENABLE_V2_OPERATION_CONTRACT = SourceOperationContract( + operation="source.counter_enable_v2", + capability="source.counter_enable_v2", + feature=SourceFeature.COUNTER, + direction=SourceFeatureDirection.ENABLE, + energy_effect=SourceEnergyEffect.NONE, + storage_effect=SourceStorageEffect.NONE, + required_fields=(SourceFieldId.IDENTITY, SourceFieldId.COUNTER), + changed_fields=(SourceFieldId.COUNTER,), + postcondition_fields=(SourceFieldId.COUNTER,), + cleanup_verification_fields=(), + v1_equivalent_routes=(), + v1_overlapping_routes=(), + operation_timeout_ms=5_000, + main_max_steps=1, + recovery_max_steps=1, + verification_max_steps=2, +) + + +SOURCE_COUNTER_DISABLE_V2_OPERATION_CONTRACT = SourceOperationContract( + operation="source.counter_disable_v2", + capability="source.counter_enable_v2", + feature=SourceFeature.COUNTER, + direction=SourceFeatureDirection.DISABLE, + energy_effect=SourceEnergyEffect.DECREASE_ONLY, + storage_effect=SourceStorageEffect.NONE, + required_fields=(SourceFieldId.IDENTITY, SourceFieldId.COUNTER), + changed_fields=(SourceFieldId.COUNTER,), + postcondition_fields=(SourceFieldId.COUNTER,), + cleanup_verification_fields=(), + v1_equivalent_routes=(), + v1_overlapping_routes=(), + operation_timeout_ms=5_000, + main_max_steps=1, + recovery_max_steps=1, + verification_max_steps=2, +) + + def _source_cross_channel_configure_operation_contract( *, operation: str, @@ -1816,19 +2300,21 @@ def __post_init__(self) -> None: _FEATURE_PROFILE_TYPES: dict[SourceFeature, type[object]] = { SourceFeature.BASIC: SourceBasicCapabilityProfile, SourceFeature.OUTPUT: SourceOutputCapabilityProfile, + SourceFeature.NOISE_OVERLAY: SourceNoiseOverlayCapabilityProfile, SourceFeature.HARMONICS: SourceHarmonicCapabilityProfile, SourceFeature.MODULATION: SourceModulationCapabilityProfile, SourceFeature.SWEEP: SourceSweepCapabilityProfile, SourceFeature.BURST: SourceBurstCapabilityProfile, SourceFeature.PULSE: SourcePulseCapabilityProfile, SourceFeature.ARBITRARY: SourceArbitraryCapabilityProfile, + SourceFeature.ARBITRARY_WORKSPACE: SourceArbitraryWorkspaceCapabilityProfile, SourceFeature.COUNTER: SourceCounterCapabilityProfile, - SourceFeature.REFERENCE_CLOCK: SourceClockSyncCapabilityProfile, - SourceFeature.SYNC: SourceClockSyncCapabilityProfile, - SourceFeature.CASCADE: SourceClockSyncCapabilityProfile, + SourceFeature.REFERENCE_CLOCK: SourceReferenceClockCapabilityProfile, + SourceFeature.SYNC: SourceSyncCapabilityProfile, + SourceFeature.CASCADE: SourceCascadeCapabilityProfile, SourceFeature.COMBINE: SourceCrossChannelCapabilityProfile, SourceFeature.TRACKING: SourceCrossChannelCapabilityProfile, - SourceFeature.COUPLING: SourceCrossChannelCapabilityProfile, + SourceFeature.COUPLING: SourceCouplingCapabilityProfile, SourceFeature.COPY: SourceCrossChannelCapabilityProfile, SourceFeature.PHASE_RELATION: SourceCrossChannelCapabilityProfile, SourceFeature.SHARED_POWER: SourceCrossChannelCapabilityProfile, @@ -1837,20 +2323,18 @@ def __post_init__(self) -> None: _FEATURE_SCOPES: dict[SourceFeature, frozenset[SourceFacetScope]] = { SourceFeature.BASIC: frozenset({SourceFacetScope.CHANNEL}), SourceFeature.OUTPUT: frozenset({SourceFacetScope.CHANNEL}), + SourceFeature.NOISE_OVERLAY: frozenset({SourceFacetScope.CHANNEL}), SourceFeature.HARMONICS: frozenset({SourceFacetScope.CHANNEL}), SourceFeature.MODULATION: frozenset({SourceFacetScope.CHANNEL}), SourceFeature.SWEEP: frozenset({SourceFacetScope.CHANNEL}), SourceFeature.BURST: frozenset({SourceFacetScope.CHANNEL}), SourceFeature.PULSE: frozenset({SourceFacetScope.CHANNEL}), SourceFeature.ARBITRARY: frozenset({SourceFacetScope.CHANNEL}), + SourceFeature.ARBITRARY_WORKSPACE: frozenset({SourceFacetScope.INSTRUMENT}), SourceFeature.COUNTER: frozenset({SourceFacetScope.INPUT}), SourceFeature.REFERENCE_CLOCK: frozenset({SourceFacetScope.INSTRUMENT}), - SourceFeature.SYNC: frozenset( - {SourceFacetScope.INSTRUMENT, SourceFacetScope.CHANNEL_SET} - ), - SourceFeature.CASCADE: frozenset( - {SourceFacetScope.INSTRUMENT, SourceFacetScope.CHANNEL_SET} - ), + SourceFeature.SYNC: frozenset({SourceFacetScope.CHANNEL}), + SourceFeature.CASCADE: frozenset({SourceFacetScope.INSTRUMENT}), SourceFeature.COMBINE: frozenset( {SourceFacetScope.CHANNEL_SET, SourceFacetScope.INSTRUMENT} ), @@ -2018,6 +2502,7 @@ class SourceBudgetBlockerCode(StrEnum): MODULATION_CONSTRAINT_MISSING = "modulation_constraint_missing" ARBITRARY_OVERSHOOT_MISSING = "arbitrary_overshoot_missing" NOISE_PEAK_MISSING = "noise_peak_missing" + NOISE_OVERLAY_BOUND_MISSING = "noise_overlay_bound_missing" SWEEP_DERATING_MISSING = "sweep_derating_missing" ACTIVE_CHANNEL_UNKNOWN = "active_channel_unknown" COMBINE_STATE_UNAVAILABLE = "combine_state_unavailable" @@ -2565,6 +3050,43 @@ def __post_init__(self) -> None: _require_bool(self.enabled.value, "output enabled value") +@dataclass(frozen=True, slots=True) +class SourceNoiseOverlayScale: + kind: SourceNoiseOverlayScaleKind + value: float + + def __post_init__(self) -> None: + if not isinstance(self.kind, SourceNoiseOverlayScaleKind): + raise ValueError("noise overlay scale kind has an invalid type") + _require_finite( + self.value, + "noise overlay scale value", + minimum=None if self.kind is SourceNoiseOverlayScaleKind.RATIO_DB else 0.0, + maximum=100.0 if self.kind is SourceNoiseOverlayScaleKind.PERCENT else None, + ) + + +@dataclass(frozen=True, slots=True) +class NoiseOverlayFacet: + enabled: Observed[bool] + scales: Observed[tuple[SourceNoiseOverlayScale, ...]] + + def __post_init__(self) -> None: + _require_observed(self.enabled, "noise overlay enabled") + _require_observed(self.scales, "noise overlay scales") + if self.enabled.availability is Availability.VALUE: + _require_bool(self.enabled.value, "noise overlay enabled value") + if self.scales.availability is Availability.VALUE: + scales = self.scales.value + if not isinstance(scales, tuple) or any( + not isinstance(item, SourceNoiseOverlayScale) for item in scales + ): + raise ValueError("noise overlay scales value has an invalid type") + keys = tuple(item.kind.value for item in scales) + if len(set(keys)) != len(keys) or tuple(sorted(keys)) != keys: + raise ValueError("noise overlay scales must be sorted and unique") + + @dataclass(frozen=True, slots=True) class SourceBasicPatch: waveform_kind: PatchValue[SourceWaveformKind] = PatchValue(PatchAction.KEEP) @@ -2675,6 +3197,70 @@ def __post_init__(self) -> None: ) +@dataclass(frozen=True, slots=True) +class SourceArbitraryVolatileReplaceRequest: + """Replace one channel's un-named, volatile arbitrary-waveform workspace. + + ``payload`` is intentionally passed separately to the driver. The request + carries only a host-computed identity for those exact bytes; it does not + pretend that a device can expose a named slot, CAS token, or authoritative + payload digest. + """ + + channel: int + payload_sha256: str + payload_size_bytes: int + point_count: int + + def __post_init__(self) -> None: + _require_int(self.channel, "source arbitrary volatile replace channel", minimum=1) + if not isinstance(self.payload_sha256, str) or _SHA256.fullmatch(self.payload_sha256) is None: + raise ValueError( + "source arbitrary volatile replace payload_sha256 must be " + "sha256:<64 lowercase hex>" + ) + _require_int( + self.payload_size_bytes, + "source arbitrary volatile replace payload_size_bytes", + minimum=1, + ) + _require_int( + self.point_count, + "source arbitrary volatile replace point_count", + minimum=1, + ) + + +@dataclass(frozen=True, slots=True) +class SourceArbitraryWorkspaceVolatileReplaceRequest: + """Replace an unscoped volatile arbitrary-waveform workspace. + + The request deliberately carries no channel: devices using this contract do + not expose a verified selector for the workspace write. + """ + + payload_sha256: str + payload_size_bytes: int + point_count: int + + def __post_init__(self) -> None: + if not isinstance(self.payload_sha256, str) or _SHA256.fullmatch(self.payload_sha256) is None: + raise ValueError( + "source arbitrary workspace volatile replace payload_sha256 must be " + "sha256:<64 lowercase hex>" + ) + _require_int( + self.payload_size_bytes, + "source arbitrary workspace volatile replace payload_size_bytes", + minimum=1, + ) + _require_int( + self.point_count, + "source arbitrary workspace volatile replace point_count", + minimum=1, + ) + + @dataclass(frozen=True, slots=True) class SourceArbitraryStorageSlot: channel: int @@ -2744,6 +3330,89 @@ def __post_init__(self) -> None: raise ValueError("source arbitrary true-ARB sample_rate_hz must be > 0") +@dataclass(frozen=True, slots=True) +class SourceCounterConfigurationPatch: + """One explicit, independently verified counter configuration change.""" + + coupling: PatchValue[SourceInputCoupling] = PatchValue(PatchAction.KEEP) + impedance_ohm: PatchValue[float] = PatchValue(PatchAction.KEEP) + attenuation: PatchValue[int] = PatchValue(PatchAction.KEEP) + trigger_level_v: PatchValue[float] = PatchValue(PatchAction.KEEP) + statistics_enabled: PatchValue[bool] = PatchValue(PatchAction.KEEP) + + def __post_init__(self) -> None: + values = ( + ("coupling", self.coupling), + ("impedance_ohm", self.impedance_ohm), + ("attenuation", self.attenuation), + ("trigger_level_v", self.trigger_level_v), + ("statistics_enabled", self.statistics_enabled), + ) + if any(not isinstance(value, PatchValue) for _, value in values): + raise ValueError("counter configuration patch values must be PatchValue") + if sum(value.action is PatchAction.SET for _, value in values) != 1: + raise ValueError("counter configuration patch requires exactly one SET value") + if self.coupling.action is PatchAction.SET and self.coupling.value not in { + SourceInputCoupling.AC, + SourceInputCoupling.DC, + }: + raise ValueError("counter configuration patch coupling must be AC or DC") + if self.impedance_ohm.action is PatchAction.SET: + _require_finite( + self.impedance_ohm.value, + "counter configuration patch impedance_ohm", + minimum=0.0, + ) + assert self.impedance_ohm.value is not None + if self.impedance_ohm.value <= 0: + raise ValueError("counter configuration patch impedance_ohm must be > 0") + if self.attenuation.action is PatchAction.SET: + _require_int( + self.attenuation.value, + "counter configuration patch attenuation", + minimum=1, + ) + if self.trigger_level_v.action is PatchAction.SET: + _require_finite( + self.trigger_level_v.value, + "counter configuration patch trigger_level_v", + ) + if self.statistics_enabled.action is PatchAction.SET: + _require_bool( + self.statistics_enabled.value, + "counter configuration patch statistics_enabled", + ) + + +@dataclass(frozen=True, slots=True) +class SourceCounterConfigureRequest: + input_id: str + patch: SourceCounterConfigurationPatch + + def __post_init__(self) -> None: + _require_token(self.input_id, "source counter configure input_id") + if not isinstance(self.patch, SourceCounterConfigurationPatch): + raise ValueError("source counter configure patch has an invalid type") + + +@dataclass(frozen=True, slots=True) +class SourceCounterEnableRequest: + input_id: str + enabled: bool + + def __post_init__(self) -> None: + _require_token(self.input_id, "source counter enable input_id") + _require_bool(self.enabled, "source counter enable enabled") + + +@dataclass(frozen=True, slots=True) +class SourceCounterMeasureRequest: + input_id: str + + def __post_init__(self) -> None: + _require_token(self.input_id, "source counter measure input_id") + + def _validate_cross_channel_configure_request( *, channels: object, @@ -2884,6 +3553,7 @@ class SourceBurstConfigureRequest: phase_deg: float internal_period_s: float delay_s: float + trigger_source: SourceTriggerSource = SourceTriggerSource.INTERNAL def __post_init__(self) -> None: _require_int(self.channel, "source burst configure channel", minimum=1) @@ -2909,6 +3579,13 @@ def __post_init__(self) -> None: minimum=0.0, maximum=85.0, ) + if not isinstance(self.trigger_source, SourceTriggerSource) or self.trigger_source not in { + SourceTriggerSource.INTERNAL, + SourceTriggerSource.MANUAL, + }: + raise ValueError( + "source burst configure trigger_source must be internal or manual" + ) @dataclass(frozen=True, slots=True) @@ -2919,6 +3596,7 @@ class SourceSweepConfigureRequest: spacing: SourceSweepSpacing steps: int sweep_time_s: float + trigger_source: SourceTriggerSource = SourceTriggerSource.INTERNAL def __post_init__(self) -> None: _require_int(self.channel, "source sweep configure channel", minimum=1) @@ -2946,6 +3624,29 @@ def __post_init__(self) -> None: minimum=0.001, maximum=300.0, ) + if not isinstance(self.trigger_source, SourceTriggerSource) or self.trigger_source not in { + SourceTriggerSource.INTERNAL, + SourceTriggerSource.MANUAL, + }: + raise ValueError( + "source sweep configure trigger_source must be internal or manual" + ) + + +@dataclass(frozen=True, slots=True) +class SourceFireRequest: + channel: int + + def __post_init__(self) -> None: + _require_int(self.channel, "source fire channel", minimum=1) + + +@dataclass(frozen=True, slots=True) +class SourceFireResult: + channel: int + + def __post_init__(self) -> None: + _require_int(self.channel, "source fire result channel", minimum=1) @dataclass(frozen=True, slots=True) @@ -3100,6 +3801,47 @@ def __post_init__(self) -> None: ) +@dataclass(frozen=True, slots=True) +class SourceBasicLiveConfigureResult: + channel: int + basic: BasicWaveFacet + output_enabled: bool + + def __post_init__(self) -> None: + _require_int(self.channel, "source basic live configure result channel", minimum=1) + if not isinstance(self.basic, BasicWaveFacet): + raise ValueError("source basic live configure result basic has an invalid type") + _require_bool( + self.output_enabled, + "source basic live configure result output_enabled", + ) + if not self.output_enabled: + raise ValueError( + "source basic live configure result requires output_enabled=True" + ) + if ( + self.basic.amplitude.availability is not Availability.VALUE + or not isinstance(self.basic.amplitude.value, SourceAmplitude) + or self.basic.amplitude.value.unit is not SourceAmplitudeUnit.VPP + ): + raise ValueError( + "source basic live configure result requires a final VPP amplitude readback" + ) + _require_finite( + self.basic.amplitude.value.value, + "source basic live configure result final_amplitude", + minimum=0.0, + ) + if self.basic.offset_v.availability is not Availability.VALUE: + raise ValueError( + "source basic live configure result requires a final offset readback" + ) + _require_finite( + self.basic.offset_v.value, + "source basic live configure result final_offset_v", + ) + + @dataclass(frozen=True, slots=True) class SourceOutputResult: channel: int @@ -3168,6 +3910,113 @@ def __post_init__(self) -> None: raise ValueError("source arbitrary storage result requires readback_verified=True") +@dataclass(frozen=True, slots=True) +class SourceArbitraryVolatileReplaceResult: + channel: int + payload_sha256: str + payload_size_bytes: int + point_count: int + selected_waveform_id: str + write_completed: bool + content_readback_verified: bool + previous_content_restorable: bool + + def __post_init__(self) -> None: + _require_int( + self.channel, + "source arbitrary volatile replace result channel", + minimum=1, + ) + if not isinstance(self.payload_sha256, str) or _SHA256.fullmatch(self.payload_sha256) is None: + raise ValueError( + "source arbitrary volatile replace result payload_sha256 must be " + "sha256:<64 lowercase hex>" + ) + _require_int( + self.payload_size_bytes, + "source arbitrary volatile replace result payload_size_bytes", + minimum=1, + ) + _require_int( + self.point_count, + "source arbitrary volatile replace result point_count", + minimum=1, + ) + _require_token( + self.selected_waveform_id, + "source arbitrary volatile replace result selected_waveform_id", + ) + _require_bool( + self.write_completed, + "source arbitrary volatile replace result write_completed", + ) + _require_bool( + self.content_readback_verified, + "source arbitrary volatile replace result content_readback_verified", + ) + _require_bool( + self.previous_content_restorable, + "source arbitrary volatile replace result previous_content_restorable", + ) + if not self.write_completed: + raise ValueError( + "source arbitrary volatile replace result requires write_completed=True" + ) + + +@dataclass(frozen=True, slots=True) +class SourceArbitraryWorkspaceVolatileReplaceResult: + workspace_id: str + payload_sha256: str + payload_size_bytes: int + point_count: int + write_completed: bool + content_readback_verified: bool + previous_content_restorable: bool + + def __post_init__(self) -> None: + _require_token( + self.workspace_id, + "source arbitrary workspace volatile replace result workspace_id", + ) + if not isinstance(self.payload_sha256, str) or _SHA256.fullmatch(self.payload_sha256) is None: + raise ValueError( + "source arbitrary workspace volatile replace result payload_sha256 must be " + "sha256:<64 lowercase hex>" + ) + _require_int( + self.payload_size_bytes, + "source arbitrary workspace volatile replace result payload_size_bytes", + minimum=1, + ) + _require_int( + self.point_count, + "source arbitrary workspace volatile replace result point_count", + minimum=1, + ) + _require_bool( + self.write_completed, + "source arbitrary workspace volatile replace result write_completed", + ) + _require_bool( + self.content_readback_verified, + "source arbitrary workspace volatile replace result content_readback_verified", + ) + _require_bool( + self.previous_content_restorable, + "source arbitrary workspace volatile replace result previous_content_restorable", + ) + if not self.write_completed: + raise ValueError( + "source arbitrary workspace volatile replace result requires write_completed=True" + ) + if self.previous_content_restorable: + raise ValueError( + "source arbitrary workspace volatile replace result cannot claim previous " + "content is restorable" + ) + + @dataclass(frozen=True, slots=True) class HarmonicFacet: enabled: Observed[bool] @@ -3473,13 +4322,6 @@ def __post_init__(self) -> None: if observed.availability is not Availability.VALUE: raise ValueError(f"source burst configure result requires {label} readback") values[label] = observed.value - SourceBurstConfigureRequest( - channel=self.channel, - cycles=values["cycles"], # type: ignore[arg-type] - phase_deg=values["phase_deg"], # type: ignore[arg-type] - internal_period_s=values["internal_period_s"], # type: ignore[arg-type] - delay_s=values["delay_s"], # type: ignore[arg-type] - ) if self.burst.trigger.availability is not Availability.VALUE or not isinstance( self.burst.trigger.value, SourceTriggerState, @@ -3488,9 +4330,20 @@ def __post_init__(self) -> None: trigger = self.burst.trigger.value if ( trigger.source.availability is not Availability.VALUE - or trigger.source.value is not SourceTriggerSource.INTERNAL + or trigger.source.value + not in {SourceTriggerSource.INTERNAL, SourceTriggerSource.MANUAL} ): - raise ValueError("source burst configure result requires internal trigger readback") + raise ValueError( + "source burst configure result requires internal or manual trigger readback" + ) + SourceBurstConfigureRequest( + channel=self.channel, + cycles=values["cycles"], # type: ignore[arg-type] + phase_deg=values["phase_deg"], # type: ignore[arg-type] + internal_period_s=values["internal_period_s"], # type: ignore[arg-type] + delay_s=values["delay_s"], # type: ignore[arg-type] + trigger_source=trigger.source.value, + ) if ( trigger.slope.availability is not Availability.VALUE or trigger.slope.value is not SourceTriggerSlope.POSITIVE @@ -3749,14 +4602,6 @@ def __post_init__(self) -> None: if observed.availability is not Availability.VALUE: raise ValueError(f"source sweep configure result requires {label} readback") values[label] = observed.value - SourceSweepConfigureRequest( - channel=self.channel, - start_hz=values["start_hz"], # type: ignore[arg-type] - stop_hz=values["stop_hz"], # type: ignore[arg-type] - spacing=values["spacing"], # type: ignore[arg-type] - steps=values["steps"], # type: ignore[arg-type] - sweep_time_s=values["sweep_time_s"], # type: ignore[arg-type] - ) for label, observed in ( ("start_hold_s", self.sweep.start_hold_s), ("stop_hold_s", self.sweep.stop_hold_s), @@ -3774,9 +4619,21 @@ def __post_init__(self) -> None: trigger = self.sweep.trigger.value if ( trigger.source.availability is not Availability.VALUE - or trigger.source.value is not SourceTriggerSource.INTERNAL + or trigger.source.value + not in {SourceTriggerSource.INTERNAL, SourceTriggerSource.MANUAL} ): - raise ValueError("source sweep configure result requires internal trigger readback") + raise ValueError( + "source sweep configure result requires internal or manual trigger readback" + ) + SourceSweepConfigureRequest( + channel=self.channel, + start_hz=values["start_hz"], # type: ignore[arg-type] + stop_hz=values["stop_hz"], # type: ignore[arg-type] + spacing=values["spacing"], # type: ignore[arg-type] + steps=values["steps"], # type: ignore[arg-type] + sweep_time_s=values["sweep_time_s"], # type: ignore[arg-type] + trigger_source=trigger.source.value, + ) if ( trigger.slope.availability is not Availability.VALUE or trigger.slope.value is not SourceTriggerSlope.POSITIVE @@ -4047,6 +4904,49 @@ def __post_init__(self) -> None: _require_finite(self.trigger_level_v.value, "counter trigger_level_v") +@dataclass(frozen=True, slots=True) +class SourceCounterConfigureResult: + input_id: str + state: SourceCounterInputState + + def __post_init__(self) -> None: + _require_token(self.input_id, "source counter configure result input_id") + if not isinstance(self.state, SourceCounterInputState): + raise ValueError("source counter configure result state has an invalid type") + if self.state.input_id != self.input_id: + raise ValueError("source counter configure result input_id does not match state") + + +@dataclass(frozen=True, slots=True) +class SourceCounterEnableResult: + input_id: str + enabled: bool + + def __post_init__(self) -> None: + _require_token(self.input_id, "source counter enable result input_id") + _require_bool(self.enabled, "source counter enable result enabled") + + +@dataclass(frozen=True, slots=True) +class SourceCounterMeasureResult: + input_id: str + measurements: tuple[SourceCounterMeasurementV2, ...] + + def __post_init__(self) -> None: + _require_token(self.input_id, "source counter measure result input_id") + if not isinstance(self.measurements, tuple) or not self.measurements: + raise ValueError("source counter measure result requires measurements") + if any(not isinstance(item, SourceCounterMeasurementV2) for item in self.measurements): + raise ValueError( + "source counter measure result measurements have an invalid type" + ) + kinds = tuple(item.kind.value for item in self.measurements) + if len(set(kinds)) != len(kinds) or tuple(sorted(kinds)) != kinds: + raise ValueError( + "source counter measure result measurements must be sorted by kind and unique" + ) + + @dataclass(frozen=True, slots=True) class SourceReferenceClockState: mode: Observed[SourceReferenceClockMode] @@ -4066,7 +4966,7 @@ def __post_init__(self) -> None: @dataclass(frozen=True, slots=True) class SourceSyncState: enabled: Observed[bool] - polarity: Observed[SourceOutputPolarity] + polarity: Observed[SourceSyncPolarity] source_channel: Observed[int] def __post_init__(self) -> None: @@ -4075,10 +4975,81 @@ def __post_init__(self) -> None: _require_observed(self.source_channel, "sync source_channel") if self.enabled.availability is Availability.VALUE: _require_bool(self.enabled.value, "sync enabled value") + if self.polarity.availability is Availability.VALUE and not isinstance( + self.polarity.value, + SourceSyncPolarity, + ): + raise ValueError("sync polarity value has an invalid type") if self.source_channel.availability is Availability.VALUE: _require_int(self.source_channel.value, "sync source_channel value", minimum=1) +@dataclass(frozen=True, slots=True) +class SourceCouplingParameter: + kind: SourceCouplingParameterKind + value: float + + def __post_init__(self) -> None: + if not isinstance(self.kind, SourceCouplingParameterKind): + raise ValueError("source coupling parameter kind has an invalid type") + _require_finite(self.value, "source coupling parameter value") + + +@dataclass(frozen=True, slots=True) +class SourceCouplingDimensionState: + dimension: SourceCouplingDimension + enabled: Observed[bool] + parameter: Observed[SourceCouplingParameter] + + def __post_init__(self) -> None: + if not isinstance(self.dimension, SourceCouplingDimension): + raise ValueError("source coupling dimension has an invalid type") + _require_observed(self.enabled, "source coupling dimension enabled") + _require_observed(self.parameter, "source coupling dimension parameter") + if self.enabled.availability is Availability.VALUE: + _require_bool(self.enabled.value, "source coupling dimension enabled value") + if self.parameter.availability is Availability.VALUE: + if not isinstance(self.parameter.value, SourceCouplingParameter): + raise ValueError("source coupling dimension parameter has an invalid type") + if _COUPLING_PARAMETER_DIMENSIONS[self.parameter.value.kind] is not self.dimension: + raise ValueError("source coupling parameter does not match its dimension") + + +@dataclass(frozen=True, slots=True) +class SourceCouplingState: + feature: SourceFeature + channels: tuple[int, ...] + enabled: Observed[bool] + reference_channel: Observed[int] + dimensions: tuple[SourceCouplingDimensionState, ...] + + def __post_init__(self) -> None: + if self.feature is not SourceFeature.COUPLING: + raise ValueError("source coupling state feature must be coupling") + _require_positive_channels(self.channels, "source coupling state channels") + if len(self.channels) < 2: + raise ValueError("source coupling state requires two or more channels") + _require_observed(self.enabled, "source coupling state enabled") + _require_observed(self.reference_channel, "source coupling state reference_channel") + if self.enabled.availability is Availability.VALUE: + _require_bool(self.enabled.value, "source coupling state enabled value") + if self.reference_channel.availability is Availability.VALUE: + _require_int( + self.reference_channel.value, + "source coupling state reference_channel value", + minimum=1, + ) + if self.reference_channel.value not in self.channels: + raise ValueError("source coupling reference_channel must be a participant") + if not isinstance(self.dimensions, tuple) or not self.dimensions or any( + not isinstance(item, SourceCouplingDimensionState) for item in self.dimensions + ): + raise ValueError("source coupling dimensions have an invalid type") + keys = tuple(item.dimension.value for item in self.dimensions) + if len(set(keys)) != len(keys) or tuple(sorted(keys)) != keys: + raise ValueError("source coupling dimensions must be sorted and unique") + + @dataclass(frozen=True, slots=True) class SourceCascadeState: enabled: Observed[bool] @@ -4103,7 +5074,6 @@ def __post_init__(self) -> None: if self.feature not in { SourceFeature.COMBINE, SourceFeature.TRACKING, - SourceFeature.COUPLING, SourceFeature.COPY, SourceFeature.PHASE_RELATION, }: @@ -4137,7 +5107,7 @@ class SourceCrossChannelConfigureResult: feature: SourceFeature channels: tuple[int, ...] enabled: bool - relation: SourceRelationState + relation: SourceRelationState | SourceCouplingState outputs: tuple[SourceRelationOutputState, ...] def __post_init__(self) -> None: @@ -4157,7 +5127,7 @@ def __post_init__(self) -> None: "source cross-channel configure result requires two or more channels" ) _require_bool(self.enabled, "source cross-channel configure result enabled") - if not isinstance(self.relation, SourceRelationState): + if not isinstance(self.relation, (SourceRelationState, SourceCouplingState)): raise ValueError( "source cross-channel configure result relation has an invalid type" ) @@ -4209,7 +5179,6 @@ def __post_init__(self) -> None: class SourceSystemStateV2: counters: tuple[SourceCounterInputState, ...] reference_clock: Observed[SourceReferenceClockState] - sync: Observed[SourceSyncState] cascade: Observed[SourceCascadeState] def __post_init__(self) -> None: @@ -4221,19 +5190,19 @@ def __post_init__(self) -> None: if len(set(ids)) != len(ids) or tuple(sorted(ids)) != ids: raise ValueError("source system counters must be sorted by input_id and unique") _require_observed(self.reference_clock, "source system reference_clock") - _require_observed(self.sync, "source system sync") _require_observed(self.cascade, "source system cascade") @dataclass(frozen=True, slots=True) class SourceCrossChannelStateV2: - relations: tuple[SourceRelationState, ...] + relations: tuple[SourceRelationState | SourceCouplingState, ...] relation_graph: Observed[SourceRelationGraph] shared_power: Observed[SourceSharedPowerState] def __post_init__(self) -> None: if not isinstance(self.relations, tuple) or any( - not isinstance(item, SourceRelationState) for item in self.relations + not isinstance(item, (SourceRelationState, SourceCouplingState)) + for item in self.relations ): raise ValueError("source cross-channel relations have an invalid type") keys = tuple((item.feature.value, item.channels) for item in self.relations) @@ -4254,6 +5223,8 @@ class SourceChannelStateV2: burst: Observed[BurstFacet] pulse: Observed[PulseFacet] arbitrary: Observed[ArbitraryFacet] + sync: Observed[SourceSyncState] + noise_overlay: Observed[NoiseOverlayFacet] def __post_init__(self) -> None: _require_int(self.channel, "source channel state channel", minimum=1) @@ -4266,6 +5237,8 @@ def __post_init__(self) -> None: ("burst", self.burst), ("pulse", self.pulse), ("arbitrary", self.arbitrary), + ("sync", self.sync), + ("noise_overlay", self.noise_overlay), ): _require_observed(value, f"source channel state {name}") @@ -4445,6 +5418,7 @@ class SourceQueryItemOutcome(StrEnum): SourceRuntimeIdentity | BasicWaveFacet | OutputFacet + | NoiseOverlayFacet | SourceDisplayLoad | HarmonicFacet | ModulationFacet @@ -4455,6 +5429,7 @@ class SourceQueryItemOutcome(StrEnum): | SourceCounterInputState | SourceReferenceClockState | SourceSyncState + | SourceCouplingState | SourceCascadeState | SourceRelationState | SourceRelationGraph @@ -4468,6 +5443,7 @@ class SourceQueryItemOutcome(StrEnum): SourceFieldId.IDENTITY: SourceRuntimeIdentity, SourceFieldId.BASIC: BasicWaveFacet, SourceFieldId.OUTPUT: OutputFacet, + SourceFieldId.NOISE_OVERLAY: NoiseOverlayFacet, SourceFieldId.DISPLAY_LOAD: SourceDisplayLoad, SourceFieldId.HARMONICS: HarmonicFacet, SourceFieldId.MODULATION: ModulationFacet, @@ -4479,7 +5455,7 @@ class SourceQueryItemOutcome(StrEnum): SourceFieldId.ARM_STATE: bool, SourceFieldId.TRIGGER_STATE: bool, SourceFieldId.COMBINE: SourceRelationState, - SourceFieldId.COUPLING: SourceRelationState, + SourceFieldId.COUPLING: SourceCouplingState, SourceFieldId.TRACKING: SourceRelationState, SourceFieldId.COPY: SourceRelationState, SourceFieldId.PHASE_RELATION: SourceRelationState, @@ -4586,6 +5562,7 @@ class SourceDescriptorExtensions: features: tuple[SourceFeatureCapability, ...] query_contract: SourceQueryContract safety_profile: SourceSafetyProfile = SourceSafetyProfile() + v1_route_migration_enabled: bool = True def __post_init__(self) -> None: if self.contract_version != SOURCE_CONTRACT_VERSION: @@ -4608,6 +5585,10 @@ def __post_init__(self) -> None: feature.profile.input_ids ) <= set(self.topology.input_ids): raise ValueError("source counter profile references an unknown input_id") + if isinstance(feature.profile, SourceSyncCapabilityProfile) and not set( + feature.profile.source_channels + ) <= set(self.topology.channels): + raise ValueError("source sync profile references an unknown source channel") if isinstance(feature.profile, SourceCrossChannelCapabilityProfile) and any( not set(channel_set) <= set(self.topology.channels) for channel_set in feature.profile.supported_channel_sets @@ -4615,10 +5596,27 @@ def __post_init__(self) -> None: raise ValueError( "source cross-channel profile references an unknown channel" ) + if isinstance(feature.profile, SourceCouplingCapabilityProfile) and any( + not set(channel_set) <= set(self.topology.channels) + for channel_set in feature.profile.supported_channel_sets + ): + raise ValueError("source coupling profile references an unknown channel") + if ( + isinstance(feature.profile, SourceCouplingCapabilityProfile) + and feature.scope is SourceFacetScope.CHANNEL_SET + and feature.channels not in feature.profile.supported_channel_sets + ): + raise ValueError( + "source coupling feature channel set is not declared by its profile" + ) if not isinstance(self.query_contract, SourceQueryContract): raise ValueError("source descriptor query_contract has an invalid type") if not isinstance(self.safety_profile, SourceSafetyProfile): raise ValueError("source descriptor safety_profile has an invalid type") + _require_bool( + self.v1_route_migration_enabled, + "source descriptor v1_route_migration_enabled", + ) class SnapshotConsistencyState(StrEnum): @@ -4739,6 +5737,14 @@ def configure_source_basic_v2( ) -> SourceBasicConfigureResult: ... +@runtime_checkable +class SourceBasicLiveConfigureV2Driver(InstrumentDriver, Protocol): + def configure_source_basic_live_v2( + self, + request: SourceBasicConfigureRequest, + ) -> SourceBasicLiveConfigureResult: ... + + @runtime_checkable class SourceHarmonicConfigureV2Driver(InstrumentDriver, Protocol): def configure_source_harmonics_v2( @@ -4779,6 +5785,14 @@ def configure_source_burst_v2( ) -> SourceBurstConfigureResult: ... +@runtime_checkable +class SourceBurstFireV2Driver(InstrumentDriver, Protocol): + def fire_source_burst_v2( + self, + request: SourceFireRequest, + ) -> SourceFireResult: ... + + @runtime_checkable class SourceFmModulationConfigureV2Driver(InstrumentDriver, Protocol): def configure_source_fm_modulation_v2( @@ -4803,6 +5817,14 @@ def configure_source_sweep_v2( ) -> SourceSweepConfigureResult: ... +@runtime_checkable +class SourceSweepFireV2Driver(InstrumentDriver, Protocol): + def fire_source_sweep_v2( + self, + request: SourceFireRequest, + ) -> SourceFireResult: ... + + @runtime_checkable class SourcePulseConfigureV2Driver(InstrumentDriver, Protocol): def configure_source_pulse_v2( @@ -4834,6 +5856,24 @@ def mutate_source_arbitrary_storage_v2( ) -> SourceArbitraryStorageResult: ... +@runtime_checkable +class SourceArbitraryVolatileReplaceV2Driver(InstrumentDriver, Protocol): + def replace_source_arbitrary_volatile_v2( + self, + request: SourceArbitraryVolatileReplaceRequest, + payload: bytes, + ) -> SourceArbitraryVolatileReplaceResult: ... + + +@runtime_checkable +class SourceArbitraryWorkspaceVolatileReplaceV2Driver(InstrumentDriver, Protocol): + def replace_source_arbitrary_workspace_volatile_v2( + self, + request: SourceArbitraryWorkspaceVolatileReplaceRequest, + payload: bytes, + ) -> SourceArbitraryWorkspaceVolatileReplaceResult: ... + + @runtime_checkable class SourceArbitrarySelectV2Driver(InstrumentDriver, Protocol): def select_source_arbitrary_v2( @@ -4842,6 +5882,30 @@ def select_source_arbitrary_v2( ) -> SourceArbitrarySelectResult: ... +@runtime_checkable +class SourceCounterConfigureV2Driver(InstrumentDriver, Protocol): + def configure_source_counter_v2( + self, + request: SourceCounterConfigureRequest, + ) -> SourceCounterConfigureResult: ... + + +@runtime_checkable +class SourceCounterEnableV2Driver(InstrumentDriver, Protocol): + def set_source_counter_enabled_v2( + self, + request: SourceCounterEnableRequest, + ) -> SourceCounterEnableResult: ... + + +@runtime_checkable +class SourceCounterMeasureV2Driver(InstrumentDriver, Protocol): + def measure_source_counter_v2( + self, + request: SourceCounterMeasureRequest, + ) -> SourceCounterMeasureResult: ... + + @runtime_checkable class SourceCombineConfigureV2Driver(InstrumentDriver, Protocol): def configure_source_combine_v2( @@ -4874,8 +5938,32 @@ def configure_source_phase_relation_v2( ) -> SourceCrossChannelConfigureResult: ... -def source_v2_to_data(value: object) -> object: - """Convert Source V2 public values into strict JSON-compatible data.""" +def _source_v2_omits_additive_default(value: object, field_name: str) -> bool: + """Keep additive defaults out of canonical hashes for older plugin wheels.""" + + if isinstance(value, SourceBasicCapabilityProfile): + return ( + field_name in { + "live_frequency_configurable", + "live_amplitude_vpp_configurable", + } + and getattr(value, field_name) is False + ) + if isinstance(value, SourceSweepCapabilityProfile): + return field_name == "implicit_disable_features" and value.implicit_disable_features == () + if isinstance(value, SourceBurstCapabilityProfile): + return ( + field_name + in {"triggered_manual_configuration_readable", "inactive_readable"} + and getattr(value, field_name) is False + ) + if isinstance(value, SourceDescriptorExtensions): + return field_name == "v1_route_migration_enabled" and value.v1_route_migration_enabled is True + return False + + +def _source_v2_to_data(value: object, *, canonical: bool) -> object: + """Convert Source V2 values, optionally preserving historical hash semantics.""" if isinstance(value, StrEnum): return value.value @@ -4886,22 +5974,36 @@ def source_v2_to_data(value: object) -> object: raise ValueError("Source V2 JSON cannot contain non-finite floats") return value if isinstance(value, tuple): - return [source_v2_to_data(item) for item in value] + return [_source_v2_to_data(item, canonical=canonical) for item in value] if isinstance(value, dict): if any(not isinstance(key, str) for key in value): raise TypeError("Source V2 JSON object keys must be strings") - return {key: source_v2_to_data(value[key]) for key in sorted(value)} + return { + key: _source_v2_to_data(value[key], canonical=canonical) + for key in sorted(value) + } if is_dataclass(value) and not isinstance(value, type): payload: dict[str, object] = {"type": type(value).__name__} for item in fields(value): - payload[item.name] = source_v2_to_data(getattr(value, item.name)) + if canonical and _source_v2_omits_additive_default(value, item.name): + continue + payload[item.name] = _source_v2_to_data( + getattr(value, item.name), + canonical=canonical, + ) return payload raise TypeError(f"unsupported Source V2 JSON value: {type(value).__name__}") +def source_v2_to_data(value: object) -> object: + """Convert Source V2 public values into strict JSON-compatible data.""" + + return _source_v2_to_data(value, canonical=False) + + def source_v2_canonical_json(value: object) -> str: return json.dumps( - source_v2_to_data(value), + _source_v2_to_data(value, canonical=True), ensure_ascii=False, allow_nan=False, sort_keys=True, @@ -5006,7 +6108,6 @@ def source_snapshot_timestamp_utc() -> str: "SourceBurstMode", "SourceCascadeState", "SourceChannelStateV2", - "SourceClockSyncCapabilityProfile", "SourceComponentAmplitude", "SourceConstraintApplicability", "SourceCounterCapabilityProfile", @@ -5177,4 +6278,50 @@ def source_snapshot_timestamp_utc() -> str: "SourcePhaseRelationConfigureRequest", "SourcePhaseRelationConfigureV2Driver", "SOURCE_PHASE_RELATION_CONFIGURE_V2_OPERATION_CONTRACT", + "SourceCouplingCapabilityProfile", + "SourceCouplingDimension", + "SourceCouplingDimensionState", + "SourceCouplingParameter", + "SourceCouplingParameterKind", + "SourceCouplingState", + "SourceReferenceClockCapabilityProfile", + "SourceSyncCapabilityProfile", + "SourceSyncPolarity", + "SourceCascadeCapabilityProfile", + "NoiseOverlayFacet", + "SourceNoiseOverlayCapabilityProfile", + "SourceNoiseOverlayScale", + "SourceNoiseOverlayScaleKind", + "SourceBasicLiveConfigureResult", + "SourceBasicLiveConfigureV2Driver", + "SOURCE_BASIC_LIVE_CONFIGURE_V2_OPERATION_CONTRACT", + "SourceFireRequest", + "SourceFireResult", + "SourceBurstFireV2Driver", + "SourceSweepFireV2Driver", + "SOURCE_BURST_FIRE_V2_OPERATION_CONTRACT", + "SOURCE_SWEEP_FIRE_V2_OPERATION_CONTRACT", + "SourceArbitraryVolatileReplaceRequest", + "SourceArbitraryVolatileReplaceResult", + "SourceArbitraryVolatileReplaceV2Driver", + "SOURCE_ARBITRARY_VOLATILE_REPLACE_V2_OPERATION_CONTRACT", + "SourceCounterConfigurationField", + "SourceCounterConfigurationPatch", + "SourceCounterConfigureRequest", + "SourceCounterConfigureResult", + "SourceCounterConfigureV2Driver", + "SOURCE_COUNTER_CONFIGURE_V2_OPERATION_CONTRACT", + "SourceCounterEnableRequest", + "SourceCounterEnableResult", + "SourceCounterEnableV2Driver", + "SOURCE_COUNTER_ENABLE_V2_OPERATION_CONTRACT", + "SOURCE_COUNTER_DISABLE_V2_OPERATION_CONTRACT", + "SourceCounterMeasureRequest", + "SourceCounterMeasureResult", + "SourceCounterMeasureV2Driver", + "SourceArbitraryWorkspaceCapabilityProfile", + "SourceArbitraryWorkspaceVolatileReplaceRequest", + "SourceArbitraryWorkspaceVolatileReplaceResult", + "SourceArbitraryWorkspaceVolatileReplaceV2Driver", + "SOURCE_ARBITRARY_WORKSPACE_VOLATILE_REPLACE_V2_OPERATION_CONTRACT", ] diff --git a/src/wavebench/plugins/lifecycle.py b/src/wavebench/plugins/lifecycle.py index ccd36b5..9f63aa9 100644 --- a/src/wavebench/plugins/lifecycle.py +++ b/src/wavebench/plugins/lifecycle.py @@ -23,13 +23,14 @@ from .package_inspect import ( PluginPackage, + WheelEntryPoint, build_subprocess_environment, inspect_plugin_package, ) -LEDGER_SCHEMA_VERSION = 1 -JOURNAL_SCHEMA_VERSION = 1 +LEDGER_SCHEMA_VERSION = 2 +JOURNAL_SCHEMA_VERSION = 2 @dataclass(frozen=True) @@ -164,7 +165,7 @@ def installed(self) -> tuple[InstalledPlugin, ...]: file_owners = self._file_owners() if self._ledger_plugins(ledger) else {} results: list[InstalledPlugin] = [] managed_distributions: set[str] = set() - for driver_id, raw_record in sorted(self._ledger_plugins(ledger).items()): + for raw_record in self._ledger_plugins(ledger).values(): record = self._record(raw_record) normalized = canonicalize_name(record["distribution"]) managed_distributions.add(normalized) @@ -177,14 +178,16 @@ def installed(self) -> tuple[InstalledPlugin, ...]: detail = "multiple installed distributions match / 存在多个同名分发" else: item = matches[0] - expected_entry = (driver_id, record["entry_point"]) + expected_entries = self._record_entry_point_pairs(record) actual_entries = tuple( - (entry["name"], entry["value"]) - for entry in item.get("entry_points", ()) + sorted( + (str(entry["name"]), str(entry["value"])) + for entry in item.get("entry_points", ()) + ) ) healthy = ( item.get("version") == record["version"] - and expected_entry in actual_entries + and actual_entries == expected_entries and item.get("integrity") is True and item.get("metadata_sha256") == record["metadata_sha256"] and item.get("record_sha256") == record["installed_record_sha256"] @@ -201,15 +204,16 @@ def installed(self) -> tuple[InstalledPlugin, ...]: else: status = "healthy" if healthy else "drifted" detail = "" if healthy else "installed metadata or files drifted / 安装元数据或文件已漂移" - results.append( + results.extend( InstalledPlugin( - driver_id=driver_id, + driver_id=entry.driver_id, distribution=record["distribution"], version=record["version"], status=status, wheel_sha256=record["wheel_sha256"], detail=detail, ) + for entry in self._record_entry_points(record) ) for normalized, matches in sorted(inventory.items()): if normalized in managed_distributions: @@ -291,7 +295,7 @@ def remove(self, driver_id: str, *, dry_run: bool = False) -> LifecycleResult: ledger = self._load_ledger(environment) current = self.info(driver_id) self._require_healthy(current) - record = self._record(self._ledger_plugins(ledger)[driver_id]) + record = self._record_for_driver_id(ledger, driver_id) self._assert_distribution_file_ownership(record["distribution"]) rollback_wheel = self._record_wheel(record) journal = self._journal( @@ -308,7 +312,7 @@ def remove(self, driver_id: str, *, dry_run: bool = False) -> LifecycleResult: self._update_journal(journal, "pip_finished") if self._distribution_inventory(record["distribution"]): raise ConfigError("plugin remove postflight failed / 插件卸载后检查失败") - updated = self._without_record(ledger, driver_id) + updated = self._without_record(ledger, record["normalized_distribution"]) self._write_json(self.ledger_path, updated) self._update_journal(journal, "ledger_committed") self._remove_journal() @@ -327,6 +331,9 @@ def recover(self) -> LifecycleResult: if not self.journal_path.exists(): return LifecycleResult("nothing-to-recover", "", "", "") journal = self._read_json(self.journal_path, "transaction journal") + if journal.get("schema_version") == 1: + journal = self._migrate_journal_v1(journal) + self._write_json(self.journal_path, journal) self._validate_journal(journal, environment) return self._recover_journal(journal, environment) @@ -339,7 +346,18 @@ def _replace( ) -> LifecycleResult: with self._inspected_input(path) as package: self._assert_package_identity(package) - driver_id = package.driver_ids[0] + ledger = self._load_ledger(self.environment()) + current_record = self._record_for_distribution( + ledger, + package.normalized_distribution, + ) + self._assert_replacement_entry_points(package, current_record) + self._assert_additional_entry_points_available( + package, + current_record, + ledger, + ) + driver_id = self._record_entry_points(current_record)[0].driver_id current = self.info(driver_id) self._require_healthy(current) if canonicalize_name(current.distribution) != package.normalized_distribution: @@ -378,7 +396,12 @@ def _replace( environment = self.environment() self._assert_no_pending_journal() ledger = self._load_ledger(environment) - record = self._record(self._ledger_plugins(ledger).get(driver_id)) + record = self._record_for_distribution( + ledger, + package.normalized_distribution, + ) + self._assert_replacement_entry_points(package, record) + self._assert_additional_entry_points_available(package, record, ledger) locked_current = self.info(driver_id) if locked_current.status != "healthy": raise ConfigError("managed plugin must be healthy before replacement / 替换前插件必须健康") @@ -421,7 +444,7 @@ def _run_install_transaction( package: PluginPackage, cached_wheel: Path, ledger: dict[str, object], - previous_record: dict[str, str] | None, + previous_record: dict[str, object] | None, ) -> None: environment = self.environment() record = self._package_record(package, cached_wheel) @@ -441,7 +464,7 @@ def _run_install_transaction( record["installed_files_sha256"] = postflight["files_sha256"] record["installed_record_sha256"] = postflight["record_sha256"] self._update_journal(journal, "postflight_finished") - updated = self._with_record(ledger, package.driver_ids[0], record) + updated = self._with_record(ledger, record) self._write_json(self.ledger_path, updated) self._update_journal(journal, "ledger_committed") self._remove_journal() @@ -459,7 +482,7 @@ def _run_install_transaction( def _rollback_uninstall( self, - record: dict[str, str], + record: dict[str, object], ledger: dict[str, object], journal: dict[str, object], original: Exception, @@ -481,7 +504,7 @@ def _rollback_uninstall( def _rollback_install( self, wheel: Path, - record: dict[str, str], + record: dict[str, object], ledger: dict[str, object], journal: dict[str, object], original: Exception, @@ -520,9 +543,10 @@ def _recover_journal( if not isinstance(before, dict) or not isinstance(package, dict): raise ConfigError("invalid plugin transaction journal / 插件事务日志无效") record = self._record(package) - driver_id = record["driver_id"] + driver_id = self._record_entry_points(record)[0].driver_id + record_key = record["normalized_distribution"] current_ledger = self._load_ledger(environment) - before_record_raw = self._ledger_plugins(before).get(driver_id) + before_record_raw = self._ledger_plugins(before).get(record_key) before_record = self._record(before_record_raw) if before_record_raw is not None else None before_matches = ( self._distribution_absent(record) @@ -543,9 +567,9 @@ def _recover_journal( if stage == "ledger_committed": if operation == "remove": - ledger_matches = driver_id not in self._ledger_plugins(current_ledger) + ledger_matches = record_key not in self._ledger_plugins(current_ledger) else: - ledger_record = self._ledger_plugins(current_ledger).get(driver_id) + ledger_record = self._ledger_plugins(current_ledger).get(record_key) ledger_matches = ledger_record == package if not desired_matches or not ledger_matches: return self._recovery_required() @@ -555,12 +579,12 @@ def _recover_journal( if stage in {"pip_started", "pip_finished", "postflight_finished"}: if desired_matches: if operation == "remove": - updated = self._without_record(before, driver_id) + updated = self._without_record(before, record_key) else: postflight = self._postflight(record) record["installed_files_sha256"] = str(postflight["files_sha256"]) record["installed_record_sha256"] = str(postflight["record_sha256"]) - updated = self._with_record(before, driver_id, record) + updated = self._with_record(before, record) self._write_json(self.ledger_path, updated) self._remove_journal() return LifecycleResult("recovered-to-desired", driver_id, record["distribution"], record["version"]) @@ -584,7 +608,7 @@ def _recovery_required() -> LifecycleResult: def _record_matches_environment( self, - record: dict[str, str], + record: dict[str, object], *, allow_empty_digest: bool = False, ) -> bool: @@ -592,16 +616,18 @@ def _record_matches_environment( if len(matches) != 1: return False item = matches[0] - expected_entry = (record["driver_id"], record["entry_point"]) + expected_entries = self._record_entry_point_pairs(record) actual_entries = tuple( - (entry["name"], entry["value"]) - for entry in item.get("entry_points", ()) + sorted( + (str(entry["name"]), str(entry["value"])) + for entry in item.get("entry_points", ()) + ) ) digest_matches = item.get("files_sha256") == record["installed_files_sha256"] record_matches = item.get("record_sha256") == record["installed_record_sha256"] metadata_matches = bool( item.get("version") == record["version"] - and expected_entry in actual_entries + and actual_entries == expected_entries and item.get("integrity") is True and item.get("metadata_sha256") == record["metadata_sha256"] ) @@ -621,20 +647,21 @@ def _record_matches_environment( return False return True - def _distribution_absent(self, record: dict[str, str]) -> bool: + def _distribution_absent(self, record: dict[str, object]) -> bool: return not self._distribution_inventory(record["distribution"]) def _assert_package_identity(self, package: PluginPackage) -> None: - driver_id = package.driver_ids[0] builtin_references = { reference for descriptor in BUILTIN_INSTRUMENTS for reference in (descriptor.driver_id, *descriptor.aliases) } - if driver_id in builtin_references: + for driver_id in package.driver_ids: + if driver_id not in builtin_references: + continue expected_distribution = BUILTIN_MIGRATION_DISTRIBUTIONS.get(driver_id) if package.normalized_distribution == canonicalize_name(expected_distribution or ""): - return + continue raise ConfigError( f"external plugin conflicts with built-in driver / " f"外置插件与内置驱动冲突: {driver_id}" @@ -645,15 +672,28 @@ def _assert_first_install_allowed( package: PluginPackage, ledger: dict[str, object], ) -> None: - driver_id = package.driver_ids[0] - if driver_id in self._ledger_plugins(ledger): + records = tuple( + self._record(raw_record) + for raw_record in self._ledger_plugins(ledger).values() + ) + if package.normalized_distribution in self._ledger_plugins(ledger): raise ConfigError("plugin is already managed; use upgrade or downgrade / 插件已受管,请使用升级或降级") + managed_driver_ids = { + entry.driver_id + for record in records + for entry in self._record_entry_points(record) + } + if managed_driver_ids.intersection(package.driver_ids): + raise ConfigError("managed driver ID is already installed / 受管的驱动 ID 已安装") inventory = self._inventory() if package.normalized_distribution in inventory: raise ConfigError("unmanaged distribution is already installed / 未受管的同名分发已安装") for matches in inventory.values(): for item in matches: - if any(entry["name"] == driver_id for entry in item.get("entry_points", ())): + if any( + str(entry["name"]) in package.driver_ids + for entry in item.get("entry_points", ()) + ): raise ConfigError("unmanaged driver ID is already installed / 未受管的同名驱动已安装") self._assert_no_file_ownership_conflicts( package, @@ -736,7 +776,7 @@ def _cache_wheel(self, package: PluginPackage) -> Path: self._fsync_directory(target_dir) return target - def _record_wheel(self, record: dict[str, str]) -> Path: + def _record_wheel(self, record: dict[str, object]) -> Path: candidate = self.state_dir / record["wheel_cache"] try: candidate.resolve().relative_to(self.wheel_cache.resolve()) @@ -746,14 +786,15 @@ def _record_wheel(self, record: dict[str, str]) -> Path: raise ConfigError("managed rollback wheel is missing or changed / 受管回滚 wheel 缺失或已变化") return candidate - def _package_record(self, package: PluginPackage, cached_wheel: Path) -> dict[str, str]: - entry = package.entry_points[0] + def _package_record(self, package: PluginPackage, cached_wheel: Path) -> dict[str, object]: return { - "driver_id": entry.driver_id, "distribution": package.distribution, "normalized_distribution": package.normalized_distribution, "version": package.version, - "entry_point": entry.value, + "entry_points": [ + {"driver_id": entry.driver_id, "value": entry.value} + for entry in package.entry_points + ], "wheel_sha256": package.sha256, "metadata_sha256": package.metadata_sha256, "record_sha256": package.record_sha256, @@ -762,7 +803,7 @@ def _package_record(self, package: PluginPackage, cached_wheel: Path) -> dict[st "installed_record_sha256": "", } - def _postflight(self, record: dict[str, str]) -> dict[str, object]: + def _postflight(self, record: dict[str, object]) -> dict[str, object]: script = r''' import base64 import csv @@ -783,12 +824,20 @@ def _postflight(self, record: dict[str, str]) -> dict[str, object]: ( expected_name, expected_version, - expected_driver, - expected_value, + expected_entries_json, expected_metadata_sha256, expected_installed_record_sha256, wheel_path, ) = __import__("sys").argv[1:] +try: + expected_entries = tuple( + sorted( + (str(item["driver_id"]), str(item["value"])) + for item in json.loads(expected_entries_json) + ) + ) +except (KeyError, TypeError, ValueError, json.JSONDecodeError): + raise SystemExit("expected entry point record is invalid") paths = list(dict.fromkeys((sysconfig.get_paths()["purelib"], sysconfig.get_paths()["platlib"]))) matches = [] for dist in distributions(path=paths): @@ -817,27 +866,34 @@ def installed_metadata_hash(suffix): if expected_installed_record_sha256 and installed_record_sha256 != expected_installed_record_sha256: raise SystemExit("installed RECORD hash mismatch") entries = [entry for entry in dist.entry_points if entry.group == "wavebench.instruments"] -if len(entries) != 1 or entries[0].name != expected_driver or entries[0].value != expected_value: +actual_entries = tuple(sorted((entry.name, entry.value) for entry in entries)) +if actual_entries != expected_entries: raise SystemExit("installed entry point mismatch") -descriptor = descriptor_from_entry_point(entries[0].load()) -if descriptor.driver_id != expected_driver or descriptor.aliases: - raise SystemExit("descriptor identity mismatch") -descriptor = descriptor.with_distribution( - distribution=dist.metadata.get("Name", ""), - version=dist.version, - source=f"entry_point:{expected_driver}", - origin="entry_point", -) -_validate_descriptor(descriptor, expected_kind=None) -validate_source_conformance_distribution(descriptor, dist) -validate_source_plugin_dependencies( - descriptor, - tuple(dist.metadata.get_all("Requires-Dist") or ()), -) -validate_rf_source_plugin_dependencies( - descriptor, - tuple(dist.metadata.get_all("Requires-Dist") or ()), -) +for expected_driver, expected_value in expected_entries: + entry = next( + item + for item in entries + if item.name == expected_driver and item.value == expected_value + ) + descriptor = descriptor_from_entry_point(entry.load()) + if descriptor.driver_id != expected_driver or descriptor.aliases: + raise SystemExit("descriptor identity mismatch") + descriptor = descriptor.with_distribution( + distribution=dist.metadata.get("Name", ""), + version=dist.version, + source=f"entry_point:{expected_driver}", + origin="entry_point", + ) + _validate_descriptor(descriptor, expected_kind=None) + validate_source_conformance_distribution(descriptor, dist) + validate_source_plugin_dependencies( + descriptor, + tuple(dist.metadata.get_all("Requires-Dist") or ()), + ) + validate_rf_source_plugin_dependencies( + descriptor, + tuple(dist.metadata.get_all("Requires-Dist") or ()), + ) with zipfile.ZipFile(wheel_path) as archive: record_names = [name for name in archive.namelist() if name.endswith(".dist-info/RECORD")] if len(record_names) != 1: @@ -877,7 +933,7 @@ def installed_metadata_hash(suffix): file_rows.append((str(item), encoded)) files_digest = hashlib.sha256(json.dumps(sorted(file_rows)).encode()).hexdigest() print(json.dumps({ - "driver_id": descriptor.driver_id, + "driver_ids": [driver_id for driver_id, _value in expected_entries], "files_sha256": files_digest, "record_sha256": installed_record_sha256, })) @@ -891,8 +947,13 @@ def installed_metadata_hash(suffix): script, canonicalize_name(record["distribution"]), record["version"], - record["driver_id"], - record["entry_point"], + json.dumps( + [ + {"driver_id": entry.driver_id, "value": entry.value} + for entry in self._record_entry_points(record) + ], + sort_keys=True, + ), record["metadata_sha256"], record["installed_record_sha256"], str(self._record_wheel(record)), @@ -1125,7 +1186,10 @@ def _load_ledger(self, environment: EnvironmentInfo) -> dict[str, object]: if not self.ledger_path.exists(): return self.empty_ledger(environment) ledger = self._read_json(self.ledger_path, "plugin ledger") - if ledger.get("schema_version") != LEDGER_SCHEMA_VERSION: + schema_version = ledger.get("schema_version") + if schema_version == 1: + ledger = self._migrate_ledger_v1(ledger) + elif schema_version != LEDGER_SCHEMA_VERSION: raise ConfigError("unsupported plugin ledger schema / 不支持的插件账本 schema") stored_environment = ledger.get("environment") if not isinstance(stored_environment, dict) or stored_environment.get("fingerprint") != environment.fingerprint: @@ -1141,7 +1205,7 @@ def _ledger_plugins(ledger: dict[str, object]) -> dict[str, object]: return plugins @staticmethod - def _record(value: object) -> dict[str, str]: + def _legacy_record(value: object) -> dict[str, str]: if not isinstance(value, dict): raise ConfigError("invalid managed plugin record / 受管插件记录无效") required = ( @@ -1161,21 +1225,196 @@ def _record(value: object) -> dict[str, str]: raise ConfigError("invalid managed plugin record / 受管插件记录无效") return {field: str(value[field]) for field in required} - def _with_record( + def _migrate_ledger_v1(self, ledger: dict[str, object]) -> dict[str, object]: + migrated: dict[str, object] = {} + for driver_id, raw_record in self._ledger_plugins(ledger).items(): + legacy = self._legacy_record(raw_record) + if driver_id != legacy["driver_id"]: + raise ConfigError("invalid plugin ledger / 插件账本无效") + normalized = legacy["normalized_distribution"] + if normalized in migrated: + raise ConfigError("legacy plugin ledger has duplicate distributions / 旧插件账本存在重复分发") + migrated[normalized] = self._legacy_record_to_record(legacy) + return { + "schema_version": LEDGER_SCHEMA_VERSION, + "environment": ledger.get("environment"), + "generation": ledger.get("generation", 0), + "plugins": migrated, + } + + def _migrate_journal_v1(self, journal: dict[str, object]) -> dict[str, object]: + before = journal.get("before_ledger") + package = journal.get("package") + if not isinstance(before, dict) or not isinstance(package, dict): + raise ConfigError("invalid plugin transaction journal / 插件事务日志无效") + migrated = dict(journal) + migrated["schema_version"] = JOURNAL_SCHEMA_VERSION + migrated["before_ledger"] = self._migrate_ledger_v1(before) + migrated["package"] = self._legacy_record_to_record(self._legacy_record(package)) + return migrated + + @staticmethod + def _legacy_record_to_record(legacy: dict[str, str]) -> dict[str, object]: + return { + "distribution": legacy["distribution"], + "normalized_distribution": legacy["normalized_distribution"], + "version": legacy["version"], + "entry_points": [ + { + "driver_id": legacy["driver_id"], + "value": legacy["entry_point"], + } + ], + "wheel_sha256": legacy["wheel_sha256"], + "metadata_sha256": legacy["metadata_sha256"], + "record_sha256": legacy["record_sha256"], + "wheel_cache": legacy["wheel_cache"], + "installed_files_sha256": legacy["installed_files_sha256"], + "installed_record_sha256": legacy["installed_record_sha256"], + } + + @staticmethod + def _record(value: object) -> dict[str, object]: + if not isinstance(value, dict): + raise ConfigError("invalid managed plugin record / 受管插件记录无效") + required = ( + "distribution", + "normalized_distribution", + "version", + "wheel_sha256", + "metadata_sha256", + "record_sha256", + "wheel_cache", + "installed_files_sha256", + "installed_record_sha256", + ) + if any(not isinstance(value.get(field), str) for field in required): + raise ConfigError("invalid managed plugin record / 受管插件记录无效") + entries = value.get("entry_points") + if not isinstance(entries, list) or not entries: + raise ConfigError("invalid managed plugin record / 受管插件记录无效") + points: list[WheelEntryPoint] = [] + for entry in entries: + if not isinstance(entry, dict): + raise ConfigError("invalid managed plugin record / 受管插件记录无效") + driver_id = entry.get("driver_id") + entry_point = entry.get("value") + if ( + not isinstance(driver_id, str) + or not driver_id + or driver_id.strip() != driver_id + or any(character.isspace() for character in driver_id) + or not isinstance(entry_point, str) + or not entry_point.strip() + or ":" not in entry_point + ): + raise ConfigError("invalid managed plugin record / 受管插件记录无效") + points.append(WheelEntryPoint(driver_id, entry_point)) + ordered = tuple(sorted(points, key=lambda entry: entry.driver_id)) + if tuple(points) != ordered or len({entry.driver_id for entry in points}) != len(points): + raise ConfigError("invalid managed plugin record / 受管插件记录无效") + record = {field: str(value[field]) for field in required} + if record["normalized_distribution"] != canonicalize_name(record["distribution"]): + raise ConfigError("invalid managed plugin record / 受管插件记录无效") + record["entry_points"] = [ + {"driver_id": entry.driver_id, "value": entry.value} + for entry in points + ] + return record + + @staticmethod + def _record_entry_points(record: dict[str, object]) -> tuple[WheelEntryPoint, ...]: + return tuple( + WheelEntryPoint(str(entry["driver_id"]), str(entry["value"])) + for entry in record["entry_points"] + ) + + @classmethod + def _record_entry_point_pairs(cls, record: dict[str, object]) -> tuple[tuple[str, str], ...]: + return tuple((entry.driver_id, entry.value) for entry in cls._record_entry_points(record)) + + def _record_for_distribution( + self, + ledger: dict[str, object], + normalized_distribution: str, + ) -> dict[str, object]: + raw_record = self._ledger_plugins(ledger).get(normalized_distribution) + if raw_record is None: + raise ConfigError("managed plugin not found / 未找到受管插件") + return self._record(raw_record) + + def _record_for_driver_id( self, ledger: dict[str, object], driver_id: str, - record: dict[str, str], + ) -> dict[str, object]: + for raw_record in self._ledger_plugins(ledger).values(): + record = self._record(raw_record) + if any(entry.driver_id == driver_id for entry in self._record_entry_points(record)): + return record + raise ConfigError(f"installed plugin not found / 未找到已安装插件: {driver_id}") + + def _assert_replacement_entry_points( + self, + package: PluginPackage, + record: dict[str, object], + ) -> None: + expected = set(self._record_entry_point_pairs(record)) + actual = {(entry.driver_id, entry.value) for entry in package.entry_points} + if not expected <= actual: + raise ConfigError( + "replacement entry points do not preserve managed plugin / " + "替换包的 entry point 未保留受管插件" + ) + + def _assert_additional_entry_points_available( + self, + package: PluginPackage, + record: dict[str, object], + ledger: dict[str, object], + ) -> None: + existing_driver_ids = { + entry.driver_id for entry in self._record_entry_points(record) + } + additional_driver_ids = set(package.driver_ids) - existing_driver_ids + if not additional_driver_ids: + return + for raw_record in self._ledger_plugins(ledger).values(): + candidate = self._record(raw_record) + if candidate["normalized_distribution"] == record["normalized_distribution"]: + continue + if additional_driver_ids.intersection( + entry.driver_id for entry in self._record_entry_points(candidate) + ): + raise ConfigError("managed driver ID is already installed / 受管的驱动 ID 已安装") + for normalized, matches in self._inventory().items(): + if normalized == record["normalized_distribution"]: + continue + for item in matches: + if any( + str(entry["name"]) in additional_driver_ids + for entry in item.get("entry_points", ()) + ): + raise ConfigError("unmanaged driver ID is already installed / 未受管的同名驱动已安装") + + def _with_record( + self, + ledger: dict[str, object], + record: dict[str, object], ) -> dict[str, object]: updated = json.loads(json.dumps(ledger)) updated["generation"] = int(updated.get("generation", 0)) + 1 - self._ledger_plugins(updated)[driver_id] = record + self._ledger_plugins(updated)[record["normalized_distribution"]] = record return updated - def _without_record(self, ledger: dict[str, object], driver_id: str) -> dict[str, object]: + def _without_record( + self, + ledger: dict[str, object], + normalized_distribution: str, + ) -> dict[str, object]: updated = json.loads(json.dumps(ledger)) updated["generation"] = int(updated.get("generation", 0)) + 1 - del self._ledger_plugins(updated)[driver_id] + del self._ledger_plugins(updated)[normalized_distribution] return updated def _journal( diff --git a/src/wavebench/plugins/package_inspect.py b/src/wavebench/plugins/package_inspect.py index 11811cd..1819fa1 100644 --- a/src/wavebench/plugins/package_inspect.py +++ b/src/wavebench/plugins/package_inspect.py @@ -408,11 +408,6 @@ def _validated_entry_points(items: Iterable[tuple[str, object]]) -> tuple[WheelE result.append(WheelEntryPoint(name, value.strip())) if not result: raise ConfigError("plugin wheel has no instrument entry points / 插件 wheel 没有仪器 entry point") - if len(result) != 1: - raise ConfigError( - "plugin wheel must provide exactly one instrument entry point / " - "插件 wheel 必须恰好提供一个仪器 entry point" - ) return tuple(sorted(result, key=lambda item: item.driver_id)) diff --git a/src/wavebench/report/html.py b/src/wavebench/report/html.py index 8021b81..3593600 100644 --- a/src/wavebench/report/html.py +++ b/src/wavebench/report/html.py @@ -12,6 +12,7 @@ from wavebench.data.packages import FrequencyResponsePackage, RunPackage from wavebench.errors import ConfigError from wavebench.report.plot3d import ( + _finite_float, build_surface_payload, plotly_head_tag, plotly_initializer, @@ -29,7 +30,7 @@ def write_run_report_html(run: RunPackage, output_path: str | Path | None = None for response in run.frequency_responses ) plotly_asset = write_plotly_asset(path.parent) if has_surface else None - plotly_url = _relative_url(plotly_asset, path.parent) if plotly_asset is not None else None + plotly_url = artifact_url(plotly_asset, path.parent) if plotly_asset is not None else None path.write_text( render_run_report_html(run, output_dir=path.parent, plotly_url=plotly_url), encoding="utf-8" ) @@ -427,7 +428,7 @@ def _build_report_manifest( package_record = { "step_index": step_index, "package": package, - "path": _relative_url(package_dir, output_dir), + "path": artifact_url(package_dir, output_dir), "exists": package_dir.exists(), } capture_packages.append(package_record) @@ -438,7 +439,7 @@ def _build_report_manifest( if npy_text: npy_path = _resolve_capture_file_path(run.path, package_dir, str(npy_text)) npy_exists = npy_path.exists() - source_npy = _relative_url(npy_path, output_dir) + source_npy = artifact_url(npy_path, output_dir) if not npy_exists: warnings.append(f"step {step_index} ch{channel}: waveform npy missing: {npy_text}") else: @@ -464,7 +465,7 @@ def _build_report_manifest( { "step_index": reference.step_index, "package": reference.package, - "path": _relative_url(package_dir, output_dir), + "path": artifact_url(package_dir, output_dir), "exists": package_dir.exists(), } ) @@ -474,21 +475,21 @@ def _build_report_manifest( ) return { "schema": "wavebench.report_manifest.v1", - "report": _relative_url(report_path, output_dir), - "run_json": _relative_url(run.run_json_path, output_dir), - "summary_csv": _relative_url(run.summary_csv_path, output_dir) if run.summary_csv_path is not None else None, - "frequency_response_csv": _relative_url(run.frequency_response_csv_path, output_dir) + "report": artifact_url(report_path, output_dir), + "run_json": artifact_url(run.run_json_path, output_dir), + "summary_csv": artifact_url(run.summary_csv_path, output_dir) if run.summary_csv_path is not None else None, + "frequency_response_csv": artifact_url(run.frequency_response_csv_path, output_dir) if run.frequency_response_csv_path is not None else None, - "frequency_response_fit_json": _relative_url(run.frequency_response_fit_path, output_dir) + "frequency_response_fit_json": artifact_url(run.frequency_response_fit_path, output_dir) if run.frequency_response_fit_path is not None else None, - "frequency_response_calibration_csv": _relative_url( + "frequency_response_calibration_csv": artifact_url( run.frequency_response_calibration_csv_path, output_dir ) if run.frequency_response_calibration_csv_path is not None else None, - "frequency_response_calibration_json": _relative_url( + "frequency_response_calibration_json": artifact_url( run.frequency_response_calibration_path, output_dir ) if run.frequency_response_calibration_path is not None @@ -496,14 +497,14 @@ def _build_report_manifest( "frequency_responses": [ { "label": response.label, - "directory": _relative_url(response.directory, output_dir), - "csv": _relative_url(response.csv_path, output_dir) + "directory": artifact_url(response.directory, output_dir), + "csv": artifact_url(response.csv_path, output_dir) if response.csv_path is not None else None, - "baseline_json": _relative_url(response.baseline_path, output_dir) + "baseline_json": artifact_url(response.baseline_path, output_dir) if response.baseline_path is not None else None, - "calibration_json": _relative_url(response.calibration_path, output_dir) + "calibration_json": artifact_url(response.calibration_path, output_dir) if response.calibration_path is not None else None, "status": response.manifest_entry.get("status"), @@ -517,7 +518,7 @@ def _build_report_manifest( { "step_index": item.step_index, "package": item.package, - "path": _relative_url(item.path, output_dir), + "path": artifact_url(item.path, output_dir), } for item in screenshots ], @@ -526,7 +527,7 @@ def _build_report_manifest( [ { "kind": "plotly.js", - "path": _relative_url(interactive_asset_path, output_dir), + "path": artifact_url(interactive_asset_path, output_dir), "exists": interactive_asset_path.exists(), } ] @@ -1648,14 +1649,6 @@ def _short_svg_legend_label(label: str, *, limit: int = 30) -> str: return normalized if len(normalized) <= limit else normalized[: limit - 1] + "…" -def _finite_float(value: Any) -> float | None: - try: - numeric = float(value) - except (TypeError, ValueError): - return None - return numeric if np.isfinite(numeric) else None - - def _metric_label(metric: str) -> str: labels = { "frequency_estimate_hz": "频率 / Frequency", @@ -1970,7 +1963,7 @@ def _collect_artifact_links( step_index="-", kind="运行记录 / Run JSON", label="run.json", - href=_relative_url(run.run_json_path, output_dir), + href=artifact_url(run.run_json_path, output_dir), status=_availability_text(run.run_json_path.exists()), ) ] @@ -1980,7 +1973,7 @@ def _collect_artifact_links( step_index="-", kind="摘要 CSV / Summary CSV", label="summary.csv", - href=_relative_url(run.summary_csv_path, output_dir), + href=artifact_url(run.summary_csv_path, output_dir), status=_availability_text(run.summary_csv_path.exists()), ) ) @@ -1990,7 +1983,7 @@ def _collect_artifact_links( step_index="-", kind="频率响应 CSV / Frequency response CSV", label="frequency_response.csv", - href=_relative_url(run.frequency_response_csv_path, output_dir), + href=artifact_url(run.frequency_response_csv_path, output_dir), status=_availability_text(run.frequency_response_csv_path.exists()), ) ) @@ -2000,7 +1993,7 @@ def _collect_artifact_links( step_index="-", kind="频响拟合 JSON / Frequency response fit JSON", label="frequency_response_fit.json", - href=_relative_url(run.frequency_response_fit_path, output_dir), + href=artifact_url(run.frequency_response_fit_path, output_dir), status=_availability_text(run.frequency_response_fit_path.exists()), ) ) @@ -2010,7 +2003,7 @@ def _collect_artifact_links( step_index="-", kind="二维校准 CSV / 2D calibration CSV", label="frequency_response_calibration.csv", - href=_relative_url(run.frequency_response_calibration_csv_path, output_dir), + href=artifact_url(run.frequency_response_calibration_csv_path, output_dir), status=_availability_text(run.frequency_response_calibration_csv_path.exists()), ) ) @@ -2020,7 +2013,7 @@ def _collect_artifact_links( step_index="-", kind="二维校准 JSON / 2D calibration JSON", label="frequency_response_calibration.json", - href=_relative_url(run.frequency_response_calibration_path, output_dir), + href=artifact_url(run.frequency_response_calibration_path, output_dir), status=_availability_text(run.frequency_response_calibration_path.exists()), ) ) @@ -2032,7 +2025,7 @@ def _collect_artifact_links( step_index=str(response.step_index) if response.step_index is not None else "-", kind=f"{prefix}软件基线 JSON / Software baseline JSON", label="frequency_response_baseline.json", - href=_relative_url(response.baseline_path, output_dir), + href=artifact_url(response.baseline_path, output_dir), status=_availability_text(response.baseline_path.exists()), ) ) @@ -2048,7 +2041,7 @@ def _collect_artifact_links( step_index=str(response.step_index) if response.step_index is not None else "-", kind=f"{prefix}定点 {name.upper()} / Fixed-point {name.upper()}", label=path.name, - href=_relative_url(path, output_dir), + href=artifact_url(path, output_dir), status=_availability_text(path.exists()), ) ) @@ -2060,7 +2053,7 @@ def _collect_artifact_links( step_index=reference.step_index, kind="采集包 / Capture package", label=reference.package, - href=_relative_url(package_dir, output_dir), + href=artifact_url(package_dir, output_dir), status=_availability_text(package_dir.exists()), ) ) @@ -2088,7 +2081,7 @@ def _collect_artifact_links( step_index=reference.step_index, kind="波形原始数据 / Waveform raw artifact", label=f"ch{channel} {npy_name}", - href=_relative_url(npy_path, output_dir), + href=artifact_url(npy_path, output_dir), status=_availability_text(True), ) ) @@ -2380,7 +2373,7 @@ def _collect_screenshots(run: RunPackage, output_dir: Path) -> list[ReportScreen step_index=reference.step_index, package=reference.package, path=screenshot_path, - src=_relative_url(screenshot_path, output_dir), + src=artifact_url(screenshot_path, output_dir), ) ) return screenshots @@ -2490,7 +2483,3 @@ def _project_root_from_run_path(run_path: Path) -> Path: if len(parts) >= 3 and parts[-3:-1] == ("data", "runs"): return Path(*parts[:-3]) if len(parts[:-3]) > 0 else Path(".") return run_path.parent - - -def _relative_url(path: Path, output_dir: Path) -> str: - return artifact_url(path, output_dir) diff --git a/src/wavebench/report/index.py b/src/wavebench/report/index.py index df812e4..761c0cd 100644 --- a/src/wavebench/report/index.py +++ b/src/wavebench/report/index.py @@ -228,14 +228,10 @@ def _artifact(entry: dict[str, Any], key: str) -> str | None: def _html_link(path_text: str | None, output_dir: Path, label: str) -> str: if not path_text: return f'{html.escape(label)}: missing' - rel = _relative_path(Path(path_text), output_dir) + rel = artifact_url(Path(path_text), output_dir) return f'{html.escape(label)}' -def _relative_path(path: Path, output_dir: Path) -> str: - return artifact_url(path, output_dir) - - def _nested(obj: dict[str, Any], *keys: str) -> Any: cur: Any = obj for key in keys: diff --git a/src/wavebench/services/execution_intent.py b/src/wavebench/services/execution_intent.py index cad40b2..4bb07cf 100644 --- a/src/wavebench/services/execution_intent.py +++ b/src/wavebench/services/execution_intent.py @@ -20,48 +20,15 @@ _STEP_OPERATIONS = { "scope.auto": "scope.autoscale", - "scope.capture": "scope.capture", "sweep.frequency_response": "scope.capture_waveforms", - "source.status": "source.status", "rf_source.status": "rf_source.snapshot", "rf_source.trigger_status": "rf_source.trigger_snapshot", - "rf_source.set_frequency": "rf_source.set_frequency", - "rf_source.set_power_dbm": "rf_source.set_power_dbm", - "rf_source.modulation_configure": "rf_source.modulation_configure", - "rf_source.modulation_disable": "rf_source.modulation_disable", - "rf_source.modulated_output_enable": "rf_source.modulated_output_enable", - "rf_source.pulse_configure": "rf_source.pulse_configure", - "rf_source.sweep_configure": "rf_source.sweep_configure", - "rf_source.output_enable": "rf_source.output_enable", - "rf_source.output_disable": "rf_source.output_disable", "source.arb_load": "source.arbitrary_upload", "source.set_freq": "source.set_frequency", "source.set_func": "source.set_function", "source.set_vpp": "source.set_amplitude_vpp", "source.set_duty": "source.set_square_duty_cycle", - "source.output": "source.output", - "source.basic_configure_v2": "source.basic_configure_v2", - "source.output_enable_v2": "source.output_enable_v2", - "source.output_disable_v2": "source.output_disable_v2", - "source.harmonics_configure_v2": "source.harmonics_configure_v2", - "source.harmonics_disable_v2": "source.harmonics_disable_v2", - "source.modulation_configure_v2": "source.modulation_configure_v2", - "source.modulation_pm_configure_v2": "source.modulation_pm_configure_v2", - "source.modulation_fm_configure_v2": "source.modulation_fm_configure_v2", - "source.modulation_pwm_configure_v2": "source.modulation_pwm_configure_v2", - "source.sweep_configure_v2": "source.sweep_configure_v2", - "source.burst_configure_v2": "source.burst_configure_v2", - "source.pulse_configure_v2": "source.pulse_configure_v2", - "source.arbitrary_storage_v2": "source.arbitrary_storage_v2", - "source.arbitrary_select_v2": "source.arbitrary_select_v2", - "source.combine_configure_v2": "source.combine_configure_v2", - "source.coupling_configure_v2": "source.coupling_configure_v2", - "source.tracking_configure_v2": "source.tracking_configure_v2", - "source.phase_relation_configure_v2": "source.phase_relation_configure_v2", - "power.status": "power.status", "power.set": "power.set_voltage_current_limit", - "power.output": "power.output", - "dmm.read": "dmm.read", "sleep": "run.sleep", } diff --git a/src/wavebench/services/operation_specs.py b/src/wavebench/services/operation_specs.py index 915d4a8..226e777 100644 --- a/src/wavebench/services/operation_specs.py +++ b/src/wavebench/services/operation_specs.py @@ -496,6 +496,38 @@ def _spec( error_check_minimum="disabled", risk_flags=("source_v2", "output_must_be_off"), ), + _spec( + "source.basic_live_configure_v2", + "source", + required_capabilities=( + "source.basic_live_configure_v2", + "source.basic_configure_v2", + "source.output_v2", + ), + effect="write", + lease_mode="exclusive", + changed_fields=("source.channel.basic",), + restore_coverage="source-v2-live-basic", + required_verified_fields=( + "source.identity", + "source.channel.basic", + "source.channel.output", + ), + verification_fields=( + "source.identity", + "source.channel.basic", + "source.channel.output", + ), + postcondition_fields=( + "source.channel.basic", + "source.channel.output", + ), + cleanup_verification_fields=("source.channel.output",), + timeout_source="operation.timeout_ms", + operation_timeout_ms=5_000, + error_check_minimum="disabled", + risk_flags=("source_v2", "output_must_be_on", "live_signal_mutation"), + ), _spec( "source.harmonics_configure_v2", "source", @@ -677,6 +709,43 @@ def _spec( error_check_minimum="disabled", risk_flags=("source_v2", "output_must_be_off", "sweep_internal_no_fire"), ), + _spec( + "source.sweep_fire_v2", + "source", + required_capabilities=( + "source.sweep_fire_v2", + "source.sweep_configure_v2", + "source.output_v2", + ), + effect="write", + lease_mode="exclusive", + changed_fields=("source.channel.sweep",), + restore_coverage="source-v2-sweep-fire", + required_verified_fields=( + "source.identity", + "source.channel.basic", + "source.channel.output", + "source.channel.sweep", + ), + verification_fields=( + "source.identity", + "source.channel.basic", + "source.channel.output", + "source.channel.sweep", + ), + postcondition_fields=("source.channel.output", "source.channel.sweep"), + cleanup_verification_fields=("source.channel.output",), + timeout_source="operation.timeout_ms", + operation_timeout_ms=5_000, + error_check_minimum="disabled", + risk_flags=( + "source_v2", + "persistent_session_required", + "output_must_be_on", + "emits_signal", + "no_retry", + ), + ), _spec( "source.burst_configure_v2", "source", @@ -702,6 +771,43 @@ def _spec( error_check_minimum="disabled", risk_flags=("source_v2", "output_must_be_off", "burst_internal_triggered_only"), ), + _spec( + "source.burst_fire_v2", + "source", + required_capabilities=( + "source.burst_fire_v2", + "source.burst_configure_v2", + "source.output_v2", + ), + effect="write", + lease_mode="exclusive", + changed_fields=("source.channel.burst",), + restore_coverage="source-v2-burst-fire", + required_verified_fields=( + "source.identity", + "source.channel.basic", + "source.channel.burst", + "source.channel.output", + ), + verification_fields=( + "source.identity", + "source.channel.basic", + "source.channel.burst", + "source.channel.output", + ), + postcondition_fields=("source.channel.burst", "source.channel.output"), + cleanup_verification_fields=("source.channel.output",), + timeout_source="operation.timeout_ms", + operation_timeout_ms=5_000, + error_check_minimum="disabled", + risk_flags=( + "source_v2", + "persistent_session_required", + "output_must_be_on", + "emits_signal", + "no_retry", + ), + ), _spec( "source.pulse_configure_v2", "source", @@ -791,6 +897,137 @@ def _spec( error_check_minimum="disabled", risk_flags=("source_v2", "output_must_be_off", "arbitrary_selection"), ), + _spec( + "source.arbitrary_volatile_replace_v2", + "source", + required_capabilities=("source.arbitrary_volatile_replace_v2",), + effect="write", + lease_mode="exclusive", + changed_fields=( + "source.channel.arbitrary_selection", + "source.channel.arbitrary_storage", + "source.channel.basic", + ), + restore_coverage="source-v2-arbitrary-volatile", + required_verified_fields=( + "source.identity", + "source.channel.arbitrary_selection", + "source.channel.basic", + "source.channel.output", + ), + verification_fields=( + "source.identity", + "source.channel.arbitrary_selection", + "source.channel.basic", + "source.channel.output", + ), + postcondition_fields=( + "source.channel.arbitrary_selection", + "source.channel.basic", + "source.channel.output", + ), + cleanup_verification_fields=("source.channel.output",), + timeout_source="operation.timeout_ms", + operation_timeout_ms=5_000, + error_check_minimum="disabled", + risk_flags=( + "source_v2", + "output_must_be_off", + "arbitrary_volatile_replace", + "payload_not_artifact", + "no_retry", + ), + ), + _spec( + "source.arbitrary_workspace_volatile_replace_v2", + "source", + required_capabilities=("source.arbitrary_workspace_volatile_replace_v2",), + effect="write", + lease_mode="exclusive", + changed_fields=("source.instrument.arbitrary_workspace",), + restore_coverage="source-v2-arbitrary-workspace-volatile", + required_verified_fields=( + "source.identity", + "source.channel.output", + ), + verification_fields=( + "source.identity", + "source.channel.output", + ), + postcondition_fields=("source.channel.output",), + cleanup_verification_fields=("source.channel.output",), + timeout_source="operation.timeout_ms", + operation_timeout_ms=5_000, + error_check_minimum="disabled", + risk_flags=( + "source_v2", + "all_outputs_must_be_off", + "arbitrary_workspace_volatile_replace", + "unscoped_workspace", + "payload_not_artifact", + "no_retry", + ), + ), + _spec( + "source.counter_configure_v2", + "source", + required_capabilities=("source.counter_configure_v2",), + effect="write", + lease_mode="exclusive", + changed_fields=("source.input.counter",), + restore_coverage="source-v2-counter-no-rollback", + required_verified_fields=("source.identity", "source.input.counter"), + verification_fields=("source.identity", "source.input.counter"), + postcondition_fields=("source.input.counter",), + timeout_source="operation.timeout_ms", + operation_timeout_ms=5_000, + error_check_minimum="disabled", + risk_flags=("source_v2", "counter_input_configuration", "no_automatic_rollback"), + ), + _spec( + "source.counter_enable_v2", + "source", + required_capabilities=("source.counter_enable_v2",), + effect="write", + lease_mode="exclusive", + changed_fields=("source.input.counter",), + restore_coverage="source-v2-counter-no-rollback", + required_verified_fields=("source.identity", "source.input.counter"), + verification_fields=("source.identity", "source.input.counter"), + postcondition_fields=("source.input.counter",), + timeout_source="operation.timeout_ms", + operation_timeout_ms=5_000, + error_check_minimum="disabled", + risk_flags=("source_v2", "counter_enable", "no_automatic_rollback"), + ), + _spec( + "source.counter_disable_v2", + "source", + required_capabilities=("source.counter_enable_v2",), + effect="write", + lease_mode="exclusive", + changed_fields=("source.input.counter",), + restore_coverage="source-v2-counter-no-rollback", + required_verified_fields=("source.identity", "source.input.counter"), + verification_fields=("source.identity", "source.input.counter"), + postcondition_fields=("source.input.counter",), + timeout_source="operation.timeout_ms", + operation_timeout_ms=5_000, + error_check_minimum="disabled", + risk_flags=("source_v2", "counter_disable", "no_automatic_rollback"), + ), + _spec( + "source.counter_measure_v2", + "source", + required_capabilities=("source.counter_measure_v2",), + effect="stateful_read", + lease_mode="exclusive", + restore_coverage="none-read-only", + timeout_source="operation.timeout_ms", + operation_timeout_ms=5_000, + error_check_minimum="disabled", + risk_flags=("counter_measurement_query",), + ), _spec( "source.combine_configure_v2", "source", diff --git a/src/wavebench/services/run_artifacts.py b/src/wavebench/services/run_artifacts.py index e60696d..acc7710 100644 --- a/src/wavebench/services/run_artifacts.py +++ b/src/wavebench/services/run_artifacts.py @@ -40,61 +40,34 @@ def write_step_record(steps_dir: Path, record: RunStepRecord) -> None: ) -def _validated_source_operations( - source_operations: list[dict[str, Any]] | None, -) -> list[dict[str, Any]] | None: - """Accept only real, schema-labelled Source V2 operation artifacts. - - The namespace stays absent for all V1 runs. Feature-specific V2 operation - code owns the rest of each artifact's shape, but it cannot accidentally - insert an arbitrary unlabelled dictionary at the run root. - """ - - if source_operations is None: - return None - if not isinstance(source_operations, list) or any( - not isinstance(item, dict) for item in source_operations - ): - raise TypeError("source_operations must be a list of operation artifact objects") - if not source_operations: - return None - for artifact in source_operations: - if artifact.get("schema") != SOURCE_OPERATION_ARTIFACT_SCHEMA: - raise ValueError("source operation artifact has an unsupported schema") - operation = artifact.get("operation") - if ( - not isinstance(operation, str) - or not operation.startswith("source.") - or operation.strip() != operation - ): - raise ValueError("source operation artifact must have a trimmed source.* operation") - return source_operations - - -def _validated_rf_source_operations( - rf_source_operations: list[dict[str, Any]] | None, +def _validated_operations( + operations: list[dict[str, Any]] | None, + *, + field: str, + label: str, + schema: str, + prefix: str, ) -> list[dict[str, Any]] | None: - """Accept only schema-labelled RF-source operation artifacts.""" - - if rf_source_operations is None: + """Accept only schema-labelled operation artifacts.""" + if operations is None: return None - if not isinstance(rf_source_operations, list) or any( - not isinstance(item, dict) for item in rf_source_operations - ): - raise TypeError("rf_source_operations must be a list of operation artifact objects") - if not rf_source_operations: + if not isinstance(operations, list) or any(not isinstance(item, dict) for item in operations): + raise TypeError(f"{field} must be a list of operation artifact objects") + if not operations: return None - for artifact in rf_source_operations: - if artifact.get("schema") != RF_SOURCE_OPERATION_ARTIFACT_SCHEMA: - raise ValueError("RF source operation artifact has an unsupported schema") + for artifact in operations: + if artifact.get("schema") != schema: + raise ValueError(f"{label} operation artifact has an unsupported schema") operation = artifact.get("operation") if ( not isinstance(operation, str) - or not operation.startswith("rf_source.") + or not operation.startswith(prefix) or operation.strip() != operation ): - raise ValueError("RF source operation artifact must have a trimmed rf_source.* operation") - return rf_source_operations + raise ValueError( + f"{label} operation artifact must have a trimmed {prefix}* operation" + ) + return operations def write_run_files( @@ -152,10 +125,22 @@ def write_run_files( # Keep the V2 namespace absent until an actual Source V2 operation has a # typed artifact to place in it. In particular, this must not alter the # byte representation of existing V1 run artifacts. - validated_source_operations = _validated_source_operations(source_operations) + validated_source_operations = _validated_operations( + source_operations, + field="source_operations", + label="source", + schema=SOURCE_OPERATION_ARTIFACT_SCHEMA, + prefix="source.", + ) if validated_source_operations is not None: run_data["source_operations"] = validated_source_operations - validated_rf_source_operations = _validated_rf_source_operations(rf_source_operations) + validated_rf_source_operations = _validated_operations( + rf_source_operations, + field="rf_source_operations", + label="RF source", + schema=RF_SOURCE_OPERATION_ARTIFACT_SCHEMA, + prefix="rf_source.", + ) if validated_rf_source_operations is not None: run_data["rf_source_operations"] = validated_rf_source_operations run_json_path.write_text( diff --git a/src/wavebench/services/run_plan.py b/src/wavebench/services/run_plan.py index 4fdbd42..0bf7488 100644 --- a/src/wavebench/services/run_plan.py +++ b/src/wavebench/services/run_plan.py @@ -41,8 +41,13 @@ "source.set_duty", "source.output", "source.basic_configure_v2", + "source.basic_live_configure_v2", "source.output_enable_v2", "source.output_disable_v2", + "source.counter_configure_v2", + "source.counter_enable_v2", + "source.counter_disable_v2", + "source.counter_measure_v2", "source.harmonics_configure_v2", "source.harmonics_disable_v2", "source.modulation_configure_v2", @@ -50,9 +55,12 @@ "source.modulation_fm_configure_v2", "source.modulation_pwm_configure_v2", "source.sweep_configure_v2", + "source.sweep_fire_v2", "source.burst_configure_v2", "source.pulse_configure_v2", "source.arbitrary_storage_v2", + "source.arbitrary_volatile_replace_v2", + "source.arbitrary_workspace_volatile_replace_v2", "source.arbitrary_select_v2", "source.combine_configure_v2", "source.coupling_configure_v2", @@ -104,8 +112,13 @@ "rf_source.output_enable": ("port_id",), "rf_source.output_disable": ("port_id",), "source.basic_configure_v2": ("channel",), + "source.basic_live_configure_v2": ("channel",), "source.output_enable_v2": ("channel",), "source.output_disable_v2": ("channel",), + "source.counter_configure_v2": ("input_id",), + "source.counter_enable_v2": ("input_id",), + "source.counter_disable_v2": ("input_id",), + "source.counter_measure_v2": ("input_id",), "source.harmonics_configure_v2": ("channel", "order", "preset"), "source.harmonics_disable_v2": ("channel",), "source.modulation_configure_v2": ("channel", "depth_percent", "internal_frequency_hz"), @@ -131,6 +144,7 @@ "steps", "sweep_time_s", ), + "source.sweep_fire_v2": ("channel",), "source.burst_configure_v2": ( "channel", "cycles", @@ -146,6 +160,8 @@ "trailing_transition_s", ), "source.arbitrary_storage_v2": ("channel", "slot_id", "file", "write_mode"), + "source.arbitrary_volatile_replace_v2": ("channel", "file", "point_count"), + "source.arbitrary_workspace_volatile_replace_v2": ("file", "point_count"), "source.arbitrary_select_v2": ("channel", "slot_id", "playback_mode"), "source.combine_configure_v2": ("channels", "enabled"), "source.coupling_configure_v2": ("channels", "enabled"), @@ -244,8 +260,24 @@ "square_duty_cycle_percent", "on_failure", }, + "source.basic_live_configure_v2": { + "frequency_hz", + "amplitude_vpp", + "on_failure", + }, "source.output_enable_v2": {"on_failure"}, "source.output_disable_v2": {"on_failure"}, + "source.counter_configure_v2": { + "coupling", + "impedance_ohm", + "attenuation", + "trigger_level_v", + "statistics_enabled", + "on_failure", + }, + "source.counter_enable_v2": {"on_failure"}, + "source.counter_disable_v2": {"on_failure"}, + "source.counter_measure_v2": {"on_failure"}, "source.harmonics_configure_v2": {"on_failure"}, "source.harmonics_disable_v2": {"on_failure"}, "source.modulation_configure_v2": {"on_failure"}, @@ -256,10 +288,13 @@ "width_deviation_s", "on_failure", }, - "source.sweep_configure_v2": {"on_failure"}, + "source.sweep_configure_v2": {"trigger_source", "on_failure"}, + "source.sweep_fire_v2": {"on_failure"}, "source.burst_configure_v2": {"on_failure"}, "source.pulse_configure_v2": {"on_failure"}, "source.arbitrary_storage_v2": {"expected_previous_sha256", "on_failure"}, + "source.arbitrary_volatile_replace_v2": {"on_failure"}, + "source.arbitrary_workspace_volatile_replace_v2": {"on_failure"}, "source.arbitrary_select_v2": { "playback_frequency_hz", "sample_rate_hz", @@ -306,8 +341,13 @@ "source.set_duty": "Set square-wave duty cycle in percent; valid range is 0 < duty_percent < 100.", "source.output": "Turn source channel output on or off.", "source.basic_configure_v2": "Configure one Source V2 channel while its output is OFF. At least one basic field is required.", + "source.basic_live_configure_v2": "Change exactly one declared frequency or Vpp field while one Source V2 channel remains enabled.", "source.output_enable_v2": "Turn one Source V2 channel output on after a fresh V2 readback.", "source.output_disable_v2": "Turn one Source V2 channel output off without requiring Vpp or offset readback.", + "source.counter_configure_v2": "Configure exactly one declared Source V2 Counter field without enabling the Counter.", + "source.counter_enable_v2": "Enable one declared Source V2 Counter input after a fresh V2 readback.", + "source.counter_disable_v2": "Disable one declared Source V2 Counter input without changing its configuration.", + "source.counter_measure_v2": "Read one already-enabled Source V2 Counter input without changing its configuration.", "source.harmonics_configure_v2": "Configure one OFF Source V2 channel with a declared Harmonic preset; it does not enable output.", "source.harmonics_disable_v2": "Disable Harmonic on one OFF Source V2 channel; it does not enable output.", "source.modulation_configure_v2": "Configure one OFF Source V2 channel with internal sine AM; it does not enable output.", @@ -315,9 +355,12 @@ "source.modulation_fm_configure_v2": "Configure one OFF Source V2 channel with internal sine FM; it does not enable output.", "source.modulation_pwm_configure_v2": "Configure one OFF Source V2 channel with internal sine PWM; it does not enable output.", "source.sweep_configure_v2": "Configure one OFF Source V2 channel with an internal sweep; it does not enable or fire output.", + "source.sweep_fire_v2": "Fire one already configured manual Source V2 sweep in the same run session; external measurement is still required.", "source.burst_configure_v2": "Configure one OFF Source V2 channel with an internal Triggered Burst; it does not enable or fire output.", "source.pulse_configure_v2": "Configure one OFF Source V2 channel with a WIDTH pulse shape; it does not enable output.", "source.arbitrary_storage_v2": "Write one named Source V2 ARB storage slot without selecting or enabling it. The payload file is recorded by digest only.", + "source.arbitrary_volatile_replace_v2": "Replace one volatile Source V2 ARB workspace while output is OFF. The previous workspace content is not recoverable; the payload file is recorded by digest only.", + "source.arbitrary_workspace_volatile_replace_v2": "Replace one unscoped volatile Source V2 ARB workspace only while every topology output is OFF. It does not identify an affected channel; the previous workspace content is not recoverable and the payload file is recorded by digest only.", "source.arbitrary_select_v2": "Select one named Source V2 ARB waveform while the target output is OFF; it does not enable output.", "source.combine_configure_v2": "Enable or disable one declared Source V2 Combine relation while every affected output is OFF.", "source.coupling_configure_v2": "Enable or disable one declared Source V2 Coupling relation while every affected output is OFF.", @@ -841,6 +884,52 @@ def _normalize_step_fields(index: int, kind: str, fields: dict[str, Any]) -> Non if not 0 <= duty <= 100: raise ConfigError(f"{prefix}.square_duty_cycle_percent must be in [0, 100]") fields["square_duty_cycle_percent"] = duty + elif kind == "source.basic_live_configure_v2": + live_fields = {"frequency_hz", "amplitude_vpp"} + selected = live_fields & fields.keys() + if len(selected) != 1: + raise ConfigError( + f"{prefix} source.basic_live_configure_v2 requires exactly one frequency_hz or amplitude_vpp" + ) + field = next(iter(selected)) + value = _finite_float(fields[field], f"{prefix}.{field}") + if value < 0: + raise ConfigError(f"{prefix}.{field} must be >= 0") + fields[field] = value + elif kind == "source.counter_configure_v2": + fields["input_id"] = _non_empty_str(fields["input_id"], f"{prefix}.input_id") + configurable = { + "coupling", + "impedance_ohm", + "attenuation", + "trigger_level_v", + "statistics_enabled", + } + selected = configurable & fields.keys() + if len(selected) != 1: + raise ConfigError( + f"{prefix} source.counter_configure_v2 requires exactly one Counter field" + ) + field = next(iter(selected)) + if field == "coupling": + coupling = _non_empty_str(fields[field], f"{prefix}.{field}").lower() + if coupling not in {"ac", "dc"}: + raise ConfigError(f"{prefix}.{field} must be 'ac' or 'dc'") + fields[field] = coupling + elif field == "impedance_ohm": + fields[field] = _positive_float(fields[field], f"{prefix}.{field}") + elif field == "attenuation": + fields[field] = _positive_int(fields[field], f"{prefix}.{field}") + elif field == "trigger_level_v": + fields[field] = _finite_float(fields[field], f"{prefix}.{field}") + elif not isinstance(fields[field], bool): + raise ConfigError(f"{prefix}.{field} must be true or false") + elif kind in { + "source.counter_enable_v2", + "source.counter_disable_v2", + "source.counter_measure_v2", + }: + fields["input_id"] = _non_empty_str(fields["input_id"], f"{prefix}.input_id") elif kind == "source.harmonics_configure_v2": order = fields["order"] if isinstance(order, bool) or not isinstance(order, int): @@ -943,6 +1032,16 @@ def _normalize_step_fields(index: int, kind: str, fields: dict[str, Any]) -> Non fields["stop_hz"] = stop_hz fields["spacing"] = spacing fields["sweep_time_s"] = sweep_time_s + if "trigger_source" in fields: + trigger_source = _non_empty_str( + fields["trigger_source"], + f"{prefix}.trigger_source", + ).lower() + if trigger_source not in {"internal", "manual"}: + raise ConfigError( + f"{prefix}.trigger_source must be internal or manual" + ) + fields["trigger_source"] = trigger_source elif kind == "source.burst_configure_v2": cycles = fields["cycles"] if isinstance(cycles, bool) or not isinstance(cycles, int): @@ -1005,6 +1104,18 @@ def _normalize_step_fields(index: int, kind: str, fields: dict[str, Any]) -> Non f"{prefix}.expected_previous_sha256 must be sha256:<64 lowercase hex>" ) fields["write_mode"] = write_mode + elif kind == "source.arbitrary_volatile_replace_v2": + fields["file"] = _non_empty_str(fields["file"], f"{prefix}.file") + fields["point_count"] = _positive_int( + fields["point_count"], + f"{prefix}.point_count", + ) + elif kind == "source.arbitrary_workspace_volatile_replace_v2": + fields["file"] = _non_empty_str(fields["file"], f"{prefix}.file") + fields["point_count"] = _positive_int( + fields["point_count"], + f"{prefix}.point_count", + ) elif kind == "source.arbitrary_select_v2": fields["slot_id"] = _non_empty_str(fields["slot_id"], f"{prefix}.slot_id") if _SOURCE_STORAGE_TOKEN.fullmatch(fields["slot_id"]) is None: diff --git a/src/wavebench/services/run_safety.py b/src/wavebench/services/run_safety.py index ad9e96a..ac4fccc 100644 --- a/src/wavebench/services/run_safety.py +++ b/src/wavebench/services/run_safety.py @@ -41,8 +41,13 @@ def require_high_impedance(self, channel: int, *, allow_50ohm: bool = False) -> "source.set_duty", "source.output", "source.basic_configure_v2", + "source.basic_live_configure_v2", "source.output_enable_v2", "source.output_disable_v2", + "source.counter_configure_v2", + "source.counter_enable_v2", + "source.counter_disable_v2", + "source.counter_measure_v2", "source.harmonics_configure_v2", "source.harmonics_disable_v2", "source.modulation_configure_v2", @@ -50,9 +55,12 @@ def require_high_impedance(self, channel: int, *, allow_50ohm: bool = False) -> "source.modulation_fm_configure_v2", "source.modulation_pwm_configure_v2", "source.sweep_configure_v2", + "source.sweep_fire_v2", "source.burst_configure_v2", "source.pulse_configure_v2", "source.arbitrary_storage_v2", + "source.arbitrary_volatile_replace_v2", + "source.arbitrary_workspace_volatile_replace_v2", "source.arbitrary_select_v2", "source.combine_configure_v2", "source.coupling_configure_v2", @@ -73,7 +81,10 @@ def check_run_plan_safety_limits(plan: RunPlan, limits: SafetyLimitsConfig) -> N config_key="max_source_vpp", unit="Vpp", ) - elif step.kind == "source.basic_configure_v2" and "amplitude_vpp" in step.fields: + elif step.kind in { + "source.basic_configure_v2", + "source.basic_live_configure_v2", + } and "amplitude_vpp" in step.fields: _check_limit( step.fields["amplitude_vpp"], limits.max_source_vpp, diff --git a/src/wavebench/services/run_service.py b/src/wavebench/services/run_service.py index 1d98956..bb68ddf 100644 --- a/src/wavebench/services/run_service.py +++ b/src/wavebench/services/run_service.py @@ -41,15 +41,23 @@ SourceArbitraryPlaybackMode, SourceArbitrarySelectRequest, SourceArbitraryStorageRequest, + SourceArbitraryVolatileReplaceRequest, + SourceArbitraryWorkspaceVolatileReplaceRequest, SourceBasicConfigureRequest, SourceBasicPatch, SourceBurstConfigureRequest, SourceCombineConfigureRequest, + SourceCounterConfigurationPatch, + SourceCounterConfigureRequest, + SourceCounterEnableRequest, + SourceCounterMeasureRequest, SourceCouplingConfigureRequest, SourceFmModulationConfigureRequest, + SourceFireRequest, SourceHarmonicConfigureRequest, SourceHarmonicDisableRequest, SourceHarmonicPreset, + SourceInputCoupling, SourceModulationConfigureRequest, SourceOutputRequest, SourcePhaseRelationConfigureRequest, @@ -58,9 +66,11 @@ SourcePulseConfigureRequest, SourceSweepConfigureRequest, SourceSweepSpacing, + SourceTriggerSource, SourceTrackingConfigureRequest, SourceWaveformKind, SourceStorageWriteMode, + source_v2_to_data, ) from wavebench.logging import CommandLogger from wavebench.services.power_service import PowerService @@ -426,6 +436,33 @@ def add_source_output_gate_capability() -> None: else: add("source", "source.output") + def add_source_restore_capabilities() -> None: + source = self.config.source + if source is None or not source.resource: + add("source", "source.status") + return + descriptor = resolve_instrument_descriptor( + source.driver, + expected_kind="source", + ) + v2_restore = { + "source.snapshot_v2", + "source.basic_configure_v2", + "source.output_v2", + } + if v2_restore.issubset(descriptor.capabilities): + add("source", *v2_restore) + return + add( + "source", + "source.status", + "source.set_function", + "source.set_amplitude_vpp", + "source.set_frequency", + "source.set_square_duty_cycle", + "source.output", + ) + for step in plan.steps: if step.kind == "scope.auto": add("scope", "scope.autoscale") @@ -511,8 +548,22 @@ def add_source_output_gate_capability() -> None: add("source", "source.status") elif step.kind == "source.basic_configure_v2": add("source", "source.snapshot_v2", "source.basic_configure_v2") + elif step.kind == "source.basic_live_configure_v2": + add( + "source", + "source.snapshot_v2", + "source.basic_configure_v2", + "source.basic_live_configure_v2", + "source.output_v2", + ) elif step.kind in {"source.output_enable_v2", "source.output_disable_v2"}: add("source", "source.snapshot_v2", "source.output_v2") + elif step.kind == "source.counter_configure_v2": + add("source", "source.snapshot_v2", "source.counter_configure_v2") + elif step.kind in {"source.counter_enable_v2", "source.counter_disable_v2"}: + add("source", "source.snapshot_v2", "source.counter_enable_v2") + elif step.kind == "source.counter_measure_v2": + add("source", "source.snapshot_v2", "source.counter_measure_v2") elif step.kind == "source.harmonics_configure_v2": add("source", "source.snapshot_v2", "source.harmonics_configure_v2") elif step.kind == "source.harmonics_disable_v2": @@ -527,12 +578,29 @@ def add_source_output_gate_capability() -> None: add("source", "source.snapshot_v2", "source.modulation_pwm_configure_v2") elif step.kind == "source.sweep_configure_v2": add("source", "source.snapshot_v2", "source.sweep_configure_v2") + elif step.kind == "source.sweep_fire_v2": + add( + "source", + "source.snapshot_v2", + "source.sweep_configure_v2", + "source.sweep_fire_v2", + "source.output_v2", + ) elif step.kind == "source.burst_configure_v2": add("source", "source.snapshot_v2", "source.burst_configure_v2") elif step.kind == "source.pulse_configure_v2": add("source", "source.snapshot_v2", "source.pulse_configure_v2") elif step.kind == "source.arbitrary_storage_v2": add("source", "source.snapshot_v2", "source.arbitrary_storage_v2") + elif step.kind == "source.arbitrary_volatile_replace_v2": + add("source", "source.snapshot_v2", "source.arbitrary_volatile_replace_v2") + elif step.kind == "source.arbitrary_workspace_volatile_replace_v2": + add( + "source", + "source.snapshot_v2", + "source.arbitrary_workspace_volatile_replace_v2", + "source.output_v2", + ) elif step.kind == "source.arbitrary_select_v2": add("source", "source.snapshot_v2", "source.arbitrary_select_v2") elif step.kind == "source.combine_configure_v2": @@ -567,15 +635,7 @@ def add_source_output_gate_capability() -> None: add("power", "power.output") if plan.restore.source_state: - add( - "source", - "source.status", - "source.set_function", - "source.set_amplitude_vpp", - "source.set_frequency", - "source.set_square_duty_cycle", - "source.output", - ) + add_source_restore_capabilities() if plan.safety.safety_gate: if plan.safety.off_source_channels or any( item.kind.startswith("source.") or item.kind == "sweep.frequency_response" @@ -766,6 +826,22 @@ def report_close_errors() -> None: ) }, ) + except WaveBenchError as exc: + if not self._safety_gate_for_step(plan, step)["enabled"]: + raise + step_failure = exc + record = RunStepRecord( + index=step.index, + kind=step.kind, + status="failed", + fields=step.fields, + artifact={ + "error": error_envelope( + exc, + operation=f"run.step.{step.kind}", + ) + }, + ) append_source_operation_artifact( record.artifact.get("source_operation") ) @@ -1366,9 +1442,15 @@ def _run_step( ) ) artifact = {"rf_source_operation": rf_source_operation} - elif step.kind == "source.basic_configure_v2": + elif step.kind in {"source.basic_configure_v2", "source.basic_live_configure_v2"}: fields = step.fields - _, source_operation = self._source_service(services=services).configure_basic_v2( + source_service = self._source_service(services=services) + configure = ( + source_service.configure_basic_live_v2 + if step.kind == "source.basic_live_configure_v2" + else source_service.configure_basic_v2 + ) + _, source_operation = configure( SourceBasicConfigureRequest( channel=fields["channel"], patch=SourceBasicPatch( @@ -1414,6 +1496,57 @@ def _run_step( SourceOutputRequest(channel=step.fields["channel"], enabled=False) ) artifact = {"source_operation": source_operation} + elif step.kind == "source.counter_configure_v2": + fields = step.fields + _, source_operation = self._source_service(services=services).configure_counter_v2( + SourceCounterConfigureRequest( + input_id=fields["input_id"], + patch=SourceCounterConfigurationPatch( + coupling=( + PatchValue( + PatchAction.SET, + SourceInputCoupling(fields["coupling"]), + ) + if "coupling" in fields + else PatchValue(PatchAction.KEEP) + ), + impedance_ohm=( + PatchValue(PatchAction.SET, fields["impedance_ohm"]) + if "impedance_ohm" in fields + else PatchValue(PatchAction.KEEP) + ), + attenuation=( + PatchValue(PatchAction.SET, fields["attenuation"]) + if "attenuation" in fields + else PatchValue(PatchAction.KEEP) + ), + trigger_level_v=( + PatchValue(PatchAction.SET, fields["trigger_level_v"]) + if "trigger_level_v" in fields + else PatchValue(PatchAction.KEEP) + ), + statistics_enabled=( + PatchValue(PatchAction.SET, fields["statistics_enabled"]) + if "statistics_enabled" in fields + else PatchValue(PatchAction.KEEP) + ), + ), + ) + ) + artifact = {"source_operation": source_operation} + elif step.kind in {"source.counter_enable_v2", "source.counter_disable_v2"}: + _, source_operation = self._source_service(services=services).set_counter_enabled_v2( + SourceCounterEnableRequest( + input_id=step.fields["input_id"], + enabled=step.kind == "source.counter_enable_v2", + ) + ) + artifact = {"source_operation": source_operation} + elif step.kind == "source.counter_measure_v2": + result = self._source_service(services=services).measure_counter_v2( + SourceCounterMeasureRequest(input_id=step.fields["input_id"]) + ) + artifact = {"counter_measurement": source_v2_to_data(result)} elif step.kind == "source.harmonics_configure_v2": _, source_operation = self._source_service(services=services).configure_harmonics_v2( SourceHarmonicConfigureRequest( @@ -1474,9 +1607,17 @@ def _run_step( spacing=SourceSweepSpacing(step.fields["spacing"]), steps=step.fields["steps"], sweep_time_s=step.fields["sweep_time_s"], + trigger_source=SourceTriggerSource( + step.fields.get("trigger_source", "internal") + ), ) ) artifact = {"source_operation": source_operation} + elif step.kind == "source.sweep_fire_v2": + _, source_operation = self._source_service(services=services).fire_sweep_v2( + SourceFireRequest(channel=step.fields["channel"]) + ) + artifact = {"source_operation": source_operation} elif step.kind == "source.burst_configure_v2": _, source_operation = self._source_service(services=services).configure_burst_v2( SourceBurstConfigureRequest( @@ -1521,6 +1662,49 @@ def _run_step( payload=payload, ) artifact = {"source_operation": source_operation} + elif step.kind == "source.arbitrary_volatile_replace_v2": + payload_path = Path(step.fields["file"]) + if not payload_path.is_absolute(): + payload_path = plan.path.parent / payload_path + try: + payload = payload_path.read_bytes() + except OSError as exc: + raise ConfigError( + "source.arbitrary_volatile_replace_v2 payload file is unreadable: " + f"{payload_path}" + ) from exc + _, source_operation = self._source_service(services=services).replace_arbitrary_volatile_v2( + SourceArbitraryVolatileReplaceRequest( + channel=step.fields["channel"], + payload_sha256="sha256:" + sha256(payload).hexdigest(), + payload_size_bytes=len(payload), + point_count=step.fields["point_count"], + ), + payload=payload, + ) + artifact = {"source_operation": source_operation} + elif step.kind == "source.arbitrary_workspace_volatile_replace_v2": + payload_path = Path(step.fields["file"]) + if not payload_path.is_absolute(): + payload_path = plan.path.parent / payload_path + try: + payload = payload_path.read_bytes() + except OSError as exc: + raise ConfigError( + "source.arbitrary_workspace_volatile_replace_v2 payload file is " + f"unreadable: {payload_path}" + ) from exc + _, source_operation = self._source_service( + services=services + ).replace_arbitrary_workspace_volatile_v2( + SourceArbitraryWorkspaceVolatileReplaceRequest( + payload_sha256="sha256:" + sha256(payload).hexdigest(), + payload_size_bytes=len(payload), + point_count=step.fields["point_count"], + ), + payload=payload, + ) + artifact = {"source_operation": source_operation} elif step.kind == "source.arbitrary_select_v2": _, source_operation = self._source_service(services=services).select_arbitrary_v2( SourceArbitrarySelectRequest( diff --git a/src/wavebench/services/scope_average_capture_executor.py b/src/wavebench/services/scope_average_capture_executor.py index d0f7092..de7a551 100644 --- a/src/wavebench/services/scope_average_capture_executor.py +++ b/src/wavebench/services/scope_average_capture_executor.py @@ -2,10 +2,10 @@ from __future__ import annotations -from dataclasses import asdict, dataclass, is_dataclass +from dataclasses import dataclass from hashlib import sha256 from types import MappingProxyType -from typing import Any, Mapping +from typing import Mapping from uuid import uuid4 from wavebench.config import normalize_waveform_points @@ -36,6 +36,7 @@ from .operation_specs import require_operation_spec from .scope_error_policy import ScopeErrorPolicyExecutor +from .scope_extension_service import _json_safe from .scope_phase_coordinator import ( OperationPhase, ScopeBaselineHandle, @@ -54,18 +55,6 @@ } -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.""" diff --git a/src/wavebench/services/scope_service.py b/src/wavebench/services/scope_service.py index d9758a9..c3a6611 100644 --- a/src/wavebench/services/scope_service.py +++ b/src/wavebench/services/scope_service.py @@ -9,7 +9,7 @@ from contextlib import contextmanager from dataclasses import asdict, dataclass from pathlib import Path -from typing import Any, cast +from typing import Any, TypeVar, cast from uuid import uuid4 import numpy as np @@ -101,6 +101,8 @@ HIGH_IMPEDANCE_COUPLINGS = {"DCL", "DCLIMIT", "ACL", "ACLIMIT"} LOW_IMPEDANCE_COUPLINGS = {"DC", "AC"} +_ProfileT = TypeVar("_ProfileT") + @dataclass(frozen=True) class ScopeStatusSummary: @@ -1236,17 +1238,25 @@ def _waveform_binary_profile(self) -> ScopeWaveformBinaryProfile | None: extensions = getattr(descriptor, "scope_extensions", None) return getattr(extensions, "waveform_binary_profile", None) - def _snapshot_v2_profile(self) -> ScopeSnapshotProfileV2 | None: + def _v2_profile( + self, + field: str, + profile_type: type[_ProfileT], + label: str, + ) -> _ProfileT | 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") + profile = getattr(extensions, field, None) + if profile is not None and not isinstance(profile, profile_type): + raise ConfigError(f"scope {label} V2 descriptor profile has an invalid type") return profile + def _snapshot_v2_profile(self) -> ScopeSnapshotProfileV2 | None: + return self._v2_profile("snapshot_profile_v2", ScopeSnapshotProfileV2, "snapshot") + def _acquisition_status_v2_profile(self) -> ScopeAcquisitionStatusProfileV2 | None: descriptor = self.descriptor or resolve_instrument_descriptor( self.config.scope.driver, @@ -1268,50 +1278,27 @@ def _acquisition_status_v2_profile(self) -> ScopeAcquisitionStatusProfileV2 | No return profile def _average_capture_v2_profile(self) -> ScopeAverageCaptureProfileV2 | None: - descriptor = self.descriptor or resolve_instrument_descriptor( - self.config.scope.driver, - expected_kind="scope", + return self._v2_profile( + "average_capture_profile_v2", ScopeAverageCaptureProfileV2, + "average capture", ) - 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", + return self._v2_profile( + "measurement_statistics_profile_v2", ScopeMeasurementStatisticsProfileV2, + "measurement statistics", ) - 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 + return self._v2_profile("fft_status_profile_v2", ScopeFftStatusProfileV2, "FFT status") def _cursor_readout_v2_profile(self) -> ScopeCursorReadoutProfileV2 | None: - descriptor = self.descriptor or resolve_instrument_descriptor( - self.config.scope.driver, - expected_kind="scope", + return self._v2_profile( + "cursor_readout_profile_v2", ScopeCursorReadoutProfileV2, + "cursor readout", ) - 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: diff --git a/src/wavebench/services/scope_waveform_executor.py b/src/wavebench/services/scope_waveform_executor.py index 55379c2..655c344 100644 --- a/src/wavebench/services/scope_waveform_executor.py +++ b/src/wavebench/services/scope_waveform_executor.py @@ -3,10 +3,9 @@ from __future__ import annotations from collections.abc import Callable, Mapping -from dataclasses import asdict, dataclass, is_dataclass, replace +from dataclasses import dataclass, replace from hashlib import sha256 from types import MappingProxyType -from typing import Any import numpy as np @@ -36,6 +35,7 @@ from .operation_specs import OperationSpec, require_operation_spec from .scope_error_policy import ScopeErrorPolicyExecutor +from .scope_extension_service import _json_safe from .scope_phase_coordinator import ( OperationPhase, ScopeBaselineHandle, @@ -55,18 +55,6 @@ _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.""" diff --git a/src/wavebench/services/source_budget.py b/src/wavebench/services/source_budget.py index 92b6955..dd31c9d 100644 --- a/src/wavebench/services/source_budget.py +++ b/src/wavebench/services/source_budget.py @@ -541,6 +541,20 @@ def _reference_bounds( proof = BudgetProofStrength.HARD_CONSERVATIVE constraint_ids: set[str] = set() + if channel.noise_overlay.availability not in { + Availability.UNSUPPORTED, + Availability.NOT_APPLICABLE, + }: + if channel.noise_overlay.availability is not Availability.VALUE: + blockers.add(SourceBudgetBlockerCode.NOISE_OVERLAY_BOUND_MISSING) + else: + noise_overlay = channel.noise_overlay.value + if ( + noise_overlay.enabled.availability is not Availability.VALUE + or noise_overlay.enabled.value + ): + blockers.add(SourceBudgetBlockerCode.NOISE_OVERLAY_BOUND_MISSING) + if facts.waveform_kind is SourceWaveformKind.NOISE: constraints, noise_proof = _constraints( request, diff --git a/src/wavebench/services/source_operation_context.py b/src/wavebench/services/source_operation_context.py index 8027a27..69dd147 100644 --- a/src/wavebench/services/source_operation_context.py +++ b/src/wavebench/services/source_operation_context.py @@ -26,6 +26,7 @@ SourceEnergyEffect, SourceFieldId, SourceFieldRef, + SourceFeatureDirection, SourceOperationContract, SourceScopeRef, SourceStorageEffect, @@ -853,11 +854,19 @@ def _validate_closure_inputs( raise ValueError("source restore order and non-restorable fields overlap") if any(item.field in _NON_REENERGIZING_RESTORE_FIELDS for item in restore_order): raise ValueError("source failure restore cannot re-enable output, arm, or trigger fields") - if self.operation_contract.energy_effect in { - SourceEnergyEffect.MAY_INCREASE, - SourceEnergyEffect.EMIT, - } and not required_off_outputs: + if ( + self.operation_contract.energy_effect is SourceEnergyEffect.MAY_INCREASE + or ( + self.operation_contract.energy_effect is SourceEnergyEffect.EMIT + and self.operation_contract.direction is not SourceFeatureDirection.FIRE + ) + ) and not required_off_outputs: raise ValueError("energy-increasing Source operations require explicit OFF outputs") + if ( + self.operation_contract.direction is SourceFeatureDirection.FIRE + and not emergency_off_outputs + ): + raise ValueError("Source fire operations require explicit emergency OFF outputs") def _build_closure( self, diff --git a/src/wavebench/services/source_service.py b/src/wavebench/services/source_service.py index d38eebc..5fef9a6 100644 --- a/src/wavebench/services/source_service.py +++ b/src/wavebench/services/source_service.py @@ -2,11 +2,12 @@ from collections.abc import Iterator from contextlib import contextmanager -from dataclasses import dataclass +from dataclasses import dataclass, field as dataclass_field from hashlib import sha256 from math import isfinite import time from typing import cast +from uuid import uuid4 from wavebench.arbitrary import build_dg4000_dac14_binary_block, load_arbitrary_waveform from wavebench.config import SourceConfig, WaveBenchConfig @@ -75,8 +76,15 @@ PatchValue, SOURCE_ARBITRARY_SELECT_V2_OPERATION_CONTRACT, SOURCE_ARBITRARY_STORAGE_V2_OPERATION_CONTRACT, + SOURCE_ARBITRARY_VOLATILE_REPLACE_V2_OPERATION_CONTRACT, + SOURCE_ARBITRARY_WORKSPACE_VOLATILE_REPLACE_V2_OPERATION_CONTRACT, SOURCE_BASIC_CONFIGURE_V2_OPERATION_CONTRACT, + SOURCE_BASIC_LIVE_CONFIGURE_V2_OPERATION_CONTRACT, SOURCE_BURST_CONFIGURE_V2_OPERATION_CONTRACT, + SOURCE_BURST_FIRE_V2_OPERATION_CONTRACT, + SOURCE_COUNTER_CONFIGURE_V2_OPERATION_CONTRACT, + SOURCE_COUNTER_DISABLE_V2_OPERATION_CONTRACT, + SOURCE_COUNTER_ENABLE_V2_OPERATION_CONTRACT, SOURCE_COMBINE_CONFIGURE_V2_OPERATION_CONTRACT, SOURCE_COUPLING_CONFIGURE_V2_OPERATION_CONTRACT, SOURCE_CONTRACT_VERSION, @@ -91,6 +99,7 @@ SOURCE_PWM_MODULATION_CONFIGURE_V2_OPERATION_CONTRACT, SOURCE_PHASE_RELATION_CONFIGURE_V2_OPERATION_CONTRACT, SOURCE_SWEEP_CONFIGURE_V2_OPERATION_CONTRACT, + SOURCE_SWEEP_FIRE_V2_OPERATION_CONTRACT, SOURCE_TRACKING_CONFIGURE_V2_OPERATION_CONTRACT, SnapshotConsistencyState, SourceDescriptorExtensions, @@ -105,20 +114,44 @@ SourceArbitraryStorageResult, SourceArbitraryStorageSlot, SourceArbitraryStorageV2Driver, + SourceArbitraryVolatileReplaceRequest, + SourceArbitraryVolatileReplaceResult, + SourceArbitraryVolatileReplaceV2Driver, + SourceArbitraryWorkspaceCapabilityProfile, + SourceArbitraryWorkspaceVolatileReplaceRequest, + SourceArbitraryWorkspaceVolatileReplaceResult, + SourceArbitraryWorkspaceVolatileReplaceV2Driver, SourceBasicCapabilityProfile, SourceBasicConfigureRequest, SourceBasicConfigureResult, SourceBasicConfigureV2Driver, + SourceBasicLiveConfigureResult, + SourceBasicLiveConfigureV2Driver, SourceBasicPatch, SourceBurstCapabilityProfile, SourceBurstConfigureRequest, SourceBurstConfigureResult, SourceBurstConfigureV2Driver, + SourceBurstFireV2Driver, SourceBurstMode, SourceCombineConfigureRequest, SourceCombineConfigureV2Driver, + SourceCouplingCapabilityProfile, SourceCouplingConfigureRequest, SourceCouplingConfigureV2Driver, + SourceCouplingState, + SourceCounterCapabilityProfile, + SourceCounterConfigurationField, + SourceCounterConfigureRequest, + SourceCounterConfigureResult, + SourceCounterConfigureV2Driver, + SourceCounterEnableRequest, + SourceCounterEnableResult, + SourceCounterEnableV2Driver, + SourceCounterInputState, + SourceCounterMeasureRequest, + SourceCounterMeasureResult, + SourceCounterMeasureV2Driver, SourceCrossChannelCapabilityProfile, SourceCrossChannelConfigureResult, SourceFacetScope, @@ -130,6 +163,8 @@ SourceFmModulationConfigureRequest, SourceFmModulationConfigureResult, SourceFmModulationConfigureV2Driver, + SourceFireRequest, + SourceFireResult, SourceHarmonicCapabilityProfile, SourceHarmonicDisableRequest, SourceHarmonicDisableResult, @@ -168,9 +203,11 @@ SourceSweepConfigureRequest, SourceSweepConfigureResult, SourceSweepConfigureV2Driver, + SourceSweepFireV2Driver, SourceSweepMarker, SourceSnapshotV2, SourceSnapshotV2Driver, + SourceSystemStateV2, SourceTriggerOutput, SourceTriggerSlope, SourceTriggerSource, @@ -182,6 +219,7 @@ SourceTrackingConfigureV2Driver, SourceV1WriteRouteId, SourceWaveformKind, + SourceQueryEffect, SupportState, source_v2_digest, source_v2_to_data, @@ -195,7 +233,11 @@ from wavebench.services.session_alias import SessionStateAliasMixin from wavebench.services.state_guard import SourceStateGuard from wavebench.transport.base import InstrumentTransport -from wavebench.transport.session import InstrumentSessionState +from wavebench.transport.session import ( + InstrumentSessionState, + SessionHealth, + SessionTransactionCoordinator, +) from wavebench.services.source_snapshot_v2 import ( SOURCE_SNAPSHOT_OPERATION_TIMEOUT_MS, SourceSnapshotContractError, @@ -207,14 +249,13 @@ SourceOperationContextCoordinator, SourceOperationPhase, ) -from wavebench.transport.session import SessionHealth @dataclass(frozen=True, slots=True) class _SourceBasicConfigureV2Transaction: """Core transaction result shared by public and V1-adapter routes.""" - result: SourceBasicConfigureResult + result: SourceBasicConfigureResult | SourceBasicLiveConfigureResult artifact: dict[str, object] snapshot: SourceSnapshotV2 @@ -223,6 +264,14 @@ class _SourceV2BasicLegacyFallback(ConfigError): """A V1 setter has no lossless representation in the active V2 basic profile.""" +class _SourceV2BasicRequiresLiveMutation(ConfigError): + """An OFF-only basic transaction found the target output enabled.""" + + +class _SourceV2BasicRequiresOffMutation(ConfigError): + """A live basic transaction found the target output disabled.""" + + @dataclass(frozen=True, slots=True) class _SourceHarmonicConfigureV2Transaction: """Core transaction result shared by the Harmonic public route.""" @@ -295,6 +344,15 @@ class _SourceBurstConfigureV2Transaction: snapshot: SourceSnapshotV2 +@dataclass(frozen=True, slots=True) +class _SourceFireV2Transaction: + """Core transaction result shared by Burst and Sweep fire routes.""" + + result: SourceFireResult + artifact: dict[str, object] + snapshot: SourceSnapshotV2 + + @dataclass(frozen=True, slots=True) class _SourcePulseConfigureV2Transaction: """Core transaction result shared by the WIDTH Pulse public route.""" @@ -331,6 +389,33 @@ class _SourceArbitrarySelectV2Transaction: snapshot: SourceSnapshotV2 +@dataclass(frozen=True, slots=True) +class _SourceArbitraryVolatileReplaceV2Transaction: + """Core transaction result for one unnamed, volatile ARB workspace replacement.""" + + result: SourceArbitraryVolatileReplaceResult + artifact: dict[str, object] + snapshot: SourceSnapshotV2 + + +@dataclass(frozen=True, slots=True) +class _SourceArbitraryWorkspaceVolatileReplaceV2Transaction: + """Core transaction result for one unscoped volatile ARB workspace replacement.""" + + result: SourceArbitraryWorkspaceVolatileReplaceResult + artifact: dict[str, object] + snapshot: SourceSnapshotV2 + + +@dataclass(frozen=True, slots=True) +class _SourceCounterV2Transaction: + """Core transaction result for one independently verified Counter mutation.""" + + result: SourceCounterConfigureResult | SourceCounterEnableResult + artifact: dict[str, object] + snapshot: SourceSnapshotV2 + + @dataclass(frozen=True, slots=True) class _SourceCrossChannelConfigureV2Transaction: """Core transaction result shared by the four M6-C relation routes.""" @@ -346,7 +431,7 @@ class _SourceCrossChannelClosure: feature: SourceFeature relation_field: SourceFieldId - relation: SourceRelationState + relation: SourceRelationState | SourceCouplingState relation_graph: SourceRelationGraph affected_channels: tuple[int, ...] fields: tuple[SourceFieldRef, ...] @@ -363,6 +448,12 @@ class SourceService(SessionStateAliasMixin): session_state: InstrumentSessionState | None = None lease: ResourceLease | None = None state_guard: SourceStateGuard | None = None + _v2_fire_receipts: dict[tuple[SourceFeature, int, str], str] = dataclass_field( + default_factory=dict, + init=False, + repr=False, + compare=False, + ) def _require(self, operation: str, *capabilities: str) -> None: source = self._source_config() @@ -404,6 +495,74 @@ def _declared_source_capabilities(self) -> tuple[str, ...]: def _declares_source_v2_capability(self, capability: str) -> bool: return capability in self._declared_source_capabilities() + def _maps_v1_routes_to_source_v2(self, capability: str) -> bool: + """Return whether a declared V2 capability owns its legacy V1 routes.""" + + self._declared_source_capabilities() + extensions = None if self.descriptor is None else getattr( + self.descriptor, + "source_extensions", + None, + ) + return ( + self._declares_source_v2_capability(capability) + and ( + not isinstance(extensions, SourceDescriptorExtensions) + or extensions.v1_route_migration_enabled + ) + ) + + def _declares_source_v2_basic_restore(self) -> bool: + capabilities = set(self._declared_source_capabilities()) + return self._maps_v1_routes_to_source_v2("source.basic_configure_v2") and { + "source.snapshot_v2", + "source.basic_configure_v2", + "source.output_v2", + }.issubset(capabilities) + + def _clear_source_v2_fire_receipt( + self, + feature: SourceFeature, + channel: int, + ) -> None: + for receipt in tuple(self._v2_fire_receipts): + if receipt[:2] == (feature, channel): + del self._v2_fire_receipts[receipt] + + def _record_source_v2_fire_receipt( + self, + feature: SourceFeature, + channel: int, + feature_state: BurstFacet | SweepFacet, + ) -> None: + session_state = self.session_state + if self.session is None or session_state is None: + return + self._v2_fire_receipts[(feature, channel, session_state.epoch_id)] = ( + source_v2_digest(feature_state) + ) + + def _require_source_v2_fire_receipt( + self, + feature: SourceFeature, + channel: int, + *, + operation: str, + ) -> str: + if self.session is None: + raise ConfigError(f"{operation} requires a persistent source session") + session_state = self.session_state + if session_state is None: + raise ConfigError(f"{operation} requires a connection-bound session state") + receipt = self._v2_fire_receipts.get( + (feature, channel, session_state.epoch_id) + ) + if receipt is None: + raise ConfigError( + f"{operation} requires {feature.value} configuration from the same session" + ) + return receipt + def _reject_v1_route_for_source_v2( self, route: SourceV1WriteRouteId, @@ -535,6 +694,22 @@ def configure_basic_v2( request, correlation_id=correlation_id, ) + assert isinstance(transaction.result, SourceBasicConfigureResult) + return transaction.result, transaction.artifact + + def configure_basic_live_v2( + self, + request: SourceBasicConfigureRequest, + *, + correlation_id: str | None = None, + ) -> tuple[SourceBasicLiveConfigureResult, dict[str, object]]: + """Change one declared frequency or Vpp field while output remains enabled.""" + + transaction = self._configure_basic_live_v2_transaction( + request, + correlation_id=correlation_id, + ) + assert isinstance(transaction.result, SourceBasicLiveConfigureResult) return transaction.result, transaction.artifact def configure_harmonics_v2( @@ -629,8 +804,32 @@ def configure_sweep_v2( ) -> tuple[SourceSweepConfigureResult, dict[str, object]]: """Configure one OFF source channel with the declared internal Sweep scope.""" - transaction = self._configure_sweep_v2_transaction( + if isinstance(request, SourceSweepConfigureRequest): + self._clear_source_v2_fire_receipt(SourceFeature.SWEEP, request.channel) + transaction = self._configure_sweep_v2_transaction(request, correlation_id=correlation_id) + _, configured_sweep, _ = self._source_v2_sweep_target( + transaction.snapshot, + transaction.result.channel, + operation="source.sweep_configure_v2", + ) + self._record_source_v2_fire_receipt( + SourceFeature.SWEEP, + transaction.result.channel, + configured_sweep, + ) + return transaction.result, transaction.artifact + + def fire_sweep_v2( + self, + request: SourceFireRequest, + *, + correlation_id: str | None = None, + ) -> tuple[SourceFireResult, dict[str, object]]: + """Fire one configured internal Sweep on its persistent source session.""" + + transaction = self._fire_source_v2_transaction( request, + feature=SourceFeature.SWEEP, correlation_id=correlation_id, ) return transaction.result, transaction.artifact @@ -643,8 +842,32 @@ def configure_burst_v2( ) -> tuple[SourceBurstConfigureResult, dict[str, object]]: """Configure one OFF source channel with the internal Triggered Burst scope.""" - transaction = self._configure_burst_v2_transaction( + if isinstance(request, SourceBurstConfigureRequest): + self._clear_source_v2_fire_receipt(SourceFeature.BURST, request.channel) + transaction = self._configure_burst_v2_transaction(request, correlation_id=correlation_id) + configured_burst, _ = self._source_v2_burst_target( + transaction.snapshot, + transaction.result.channel, + operation="source.burst_configure_v2", + ) + self._record_source_v2_fire_receipt( + SourceFeature.BURST, + transaction.result.channel, + configured_burst, + ) + return transaction.result, transaction.artifact + + def fire_burst_v2( + self, + request: SourceFireRequest, + *, + correlation_id: str | None = None, + ) -> tuple[SourceFireResult, dict[str, object]]: + """Fire one configured internal Burst on its persistent source session.""" + + transaction = self._fire_source_v2_transaction( request, + feature=SourceFeature.BURST, correlation_id=correlation_id, ) return transaction.result, transaction.artifact @@ -707,6 +930,89 @@ def select_arbitrary_v2( ) return transaction.result, transaction.artifact + def replace_arbitrary_volatile_v2( + self, + request: SourceArbitraryVolatileReplaceRequest, + *, + payload: bytes, + correlation_id: str | None = None, + ) -> tuple[SourceArbitraryVolatileReplaceResult, dict[str, object]]: + """Replace one unnamed volatile ARB workspace while its target output is OFF.""" + + transaction = self._replace_arbitrary_volatile_v2_transaction( + request, + payload=payload, + correlation_id=correlation_id, + ) + return transaction.result, transaction.artifact + + def replace_arbitrary_workspace_volatile_v2( + self, + request: SourceArbitraryWorkspaceVolatileReplaceRequest, + *, + payload: bytes, + correlation_id: str | None = None, + ) -> tuple[SourceArbitraryWorkspaceVolatileReplaceResult, dict[str, object]]: + """Replace an unscoped volatile workspace after every source output is OFF.""" + + transaction = self._replace_arbitrary_workspace_volatile_v2_transaction( + request, + payload=payload, + correlation_id=correlation_id, + ) + return transaction.result, transaction.artifact + + def configure_counter_v2( + self, + request: SourceCounterConfigureRequest, + *, + correlation_id: str | None = None, + ) -> tuple[SourceCounterConfigureResult, dict[str, object]]: + """Apply one independently readable Counter input setting.""" + + transaction = self._mutate_counter_v2_transaction( + request, + operation="source.counter_configure_v2", + operation_contract=SOURCE_COUNTER_CONFIGURE_V2_OPERATION_CONTRACT, + correlation_id=correlation_id, + ) + assert isinstance(transaction.result, SourceCounterConfigureResult) + return transaction.result, transaction.artifact + + def set_counter_enabled_v2( + self, + request: SourceCounterEnableRequest, + *, + correlation_id: str | None = None, + ) -> tuple[SourceCounterEnableResult, dict[str, object]]: + """Enable or disable Counter without changing its input configuration.""" + + if not isinstance(request, SourceCounterEnableRequest): + raise ConfigError("source.counter_enable_v2 requires SourceCounterEnableRequest") + operation = "source.counter_enable_v2" if request.enabled else "source.counter_disable_v2" + transaction = self._mutate_counter_v2_transaction( + request, + operation=operation, + operation_contract=( + SOURCE_COUNTER_ENABLE_V2_OPERATION_CONTRACT + if request.enabled + else SOURCE_COUNTER_DISABLE_V2_OPERATION_CONTRACT + ), + correlation_id=correlation_id, + ) + assert isinstance(transaction.result, SourceCounterEnableResult) + return transaction.result, transaction.artifact + + def measure_counter_v2( + self, + request: SourceCounterMeasureRequest, + *, + correlation_id: str | None = None, + ) -> SourceCounterMeasureResult: + """Read one already-enabled Counter input without changing any Counter setting.""" + + return self._measure_counter_v2(request, correlation_id=correlation_id) + def configure_combine_v2( self, request: SourceCombineConfigureRequest, @@ -840,46 +1146,54 @@ def _configure_basic_v2_transaction( request: SourceBasicConfigureRequest, *, correlation_id: str | None = None, + _live: bool = False, ) -> _SourceBasicConfigureV2Transaction: - """Execute the private M5-B basic-write transaction. - - This method deliberately remains private until M5-D owns the public - Service, CLI, run-plan and V1 dual-contract routes. It is the single - core path that M5-B tests use to prove the write/recovery contract. - """ + """Execute the shared OFF-only or restricted live basic transaction.""" - if not isinstance(request, SourceBasicConfigureRequest): - raise ConfigError("source.basic_configure_v2 requires SourceBasicConfigureRequest") - self._require( - "source.basic_configure_v2", - "source.snapshot_v2", - "source.basic_configure_v2", + operation = ( + "source.basic_live_configure_v2" + if _live + else "source.basic_configure_v2" + ) + contract = ( + SOURCE_BASIC_LIVE_CONFIGURE_V2_OPERATION_CONTRACT + if _live + else SOURCE_BASIC_CONFIGURE_V2_OPERATION_CONTRACT ) + if not isinstance(request, SourceBasicConfigureRequest): + raise ConfigError(f"{operation} requires SourceBasicConfigureRequest") + capabilities = ["source.snapshot_v2", contract.capability] + if _live: + capabilities.extend(("source.basic_configure_v2", "source.output_v2")) + self._require(operation, *capabilities) with self._source_session() as source: descriptor = self.descriptor extensions = None if descriptor is None else descriptor.source_extensions session_state = self.session_state if not isinstance(extensions, SourceDescriptorExtensions): - raise ConfigError( - "source.basic_configure_v2 requires validated source_extensions" - ) + raise ConfigError(f"{operation} requires validated source_extensions") if session_state is None: - raise ConfigError( - "source.basic_configure_v2 requires a connection-bound session state" - ) + raise ConfigError(f"{operation} requires a connection-bound session state") fields = self._source_basic_v2_fields(request.channel) output_field = next( field for field in fields if field.field is SourceFieldId.OUTPUT ) context = SourceOperationContextCoordinator( session_state=session_state, - operation_spec=require_operation_spec("source.basic_configure_v2"), - operation_contract=SOURCE_BASIC_CONFIGURE_V2_OPERATION_CONTRACT, + operation_spec=require_operation_spec(operation), + operation_contract=contract, connection_timeout_ms=self.config.connection.timeout_ms, baseline_snapshot_digest=None, fields=fields, required_off_outputs=( - SourceScopeRef(SourceFacetScope.CHANNEL, channel=request.channel), + () + if _live + else ( + SourceScopeRef( + SourceFacetScope.CHANNEL, + channel=request.channel, + ), + ) ), emergency_off_outputs=( SourceScopeRef(SourceFacetScope.CHANNEL, channel=request.channel), @@ -893,7 +1207,7 @@ def _configure_basic_v2_transaction( ) preflight_snapshot: SourceSnapshotV2 | None = None postcondition_snapshot: SourceSnapshotV2 | None = None - result: SourceBasicConfigureResult | None = None + result: SourceBasicConfigureResult | SourceBasicLiveConfigureResult | None = None main_entered = False failure: BaseException | None = None recovery: dict[str, object] | None = None @@ -914,14 +1228,22 @@ def _configure_basic_v2_transaction( preflight_basic, preflight_output = self._source_v2_target( preflight_snapshot, request.channel, - operation="source.basic_configure_v2", - ) - self._validate_source_basic_v2_preflight( - request, - preflight_snapshot, - preflight_basic, - preflight_output, + operation=operation, ) + if _live: + self._validate_source_basic_live_v2_preflight( + request, + preflight_snapshot, + preflight_basic, + preflight_output, + ) + else: + self._validate_source_basic_v2_preflight( + request, + preflight_snapshot, + preflight_basic, + preflight_output, + ) context.bind_baseline_snapshot_digest( source_v2_digest((request.channel, preflight_basic, preflight_output)) ) @@ -937,15 +1259,23 @@ def _configure_basic_v2_transaction( fields=( next(field for field in fields if field.field is SourceFieldId.BASIC), ), - max_steps=SOURCE_BASIC_CONFIGURE_V2_OPERATION_CONTRACT.main_max_steps, + max_steps=contract.main_max_steps, ) try: with context.authorize_phase(main): main_entered = True - result = cast(SourceBasicConfigureV2Driver, source).configure_source_basic_v2( - request - ) - self._validate_source_basic_v2_result(request, result) + if _live: + result = cast( + SourceBasicLiveConfigureV2Driver, + source, + ).configure_source_basic_live_v2(request) + self._validate_source_basic_live_v2_result(request, result) + else: + result = cast( + SourceBasicConfigureV2Driver, + source, + ).configure_source_basic_v2(request) + self._validate_source_basic_v2_result(request, result) except BaseException as exc: failure = exc @@ -974,17 +1304,28 @@ def _configure_basic_v2_transaction( self._source_v2_target( postcondition_snapshot, request.channel, - operation="source.basic_configure_v2", + operation=operation, ) ) assert result is not None - self._validate_source_basic_v2_postcondition( - request, - result, - postcondition_snapshot, - postcondition_basic, - postcondition_output, - ) + if _live: + assert isinstance(result, SourceBasicLiveConfigureResult) + self._validate_source_basic_live_v2_postcondition( + request, + result, + postcondition_snapshot, + postcondition_basic, + postcondition_output, + ) + else: + assert isinstance(result, SourceBasicConfigureResult) + self._validate_source_basic_v2_postcondition( + request, + result, + postcondition_snapshot, + postcondition_basic, + postcondition_output, + ) context.complete_phase_verification( authorization, io_kind="query", @@ -1010,7 +1351,7 @@ def _configure_basic_v2_transaction( request.channel, extensions, output_field, - operation="source.basic_configure_v2", + operation=operation, ) except BaseException: recovery = { @@ -1027,6 +1368,8 @@ def _configure_basic_v2_transaction( postcondition_snapshot=postcondition_snapshot, result=result, recovery=recovery, + capability=contract.capability, + output_expected=("on" if _live else "off"), ) raise failure @@ -1042,6 +1385,8 @@ def _configure_basic_v2_transaction( preflight_snapshot=preflight_snapshot, postcondition_snapshot=postcondition_snapshot, result=result, + capability=contract.capability, + output_expected=("on" if _live else "off"), ), snapshot=postcondition_snapshot, ) @@ -1050,6 +1395,18 @@ def _configure_basic_v2_transaction( context.complete() raise + def _configure_basic_live_v2_transaction( + self, + request: SourceBasicConfigureRequest, + *, + correlation_id: str | None = None, + ) -> _SourceBasicConfigureV2Transaction: + return self._configure_basic_v2_transaction( + request, + correlation_id=correlation_id, + _live=True, + ) + def _set_output_v2_transaction( self, request: SourceOutputRequest, @@ -2481,12 +2838,23 @@ def _configure_sweep_v2_transaction( raise ConfigError(f"{operation} requires validated source_extensions") if session_state is None: raise ConfigError(f"{operation} requires a connection-bound session state") - fields = self._source_sweep_v2_fields(request.channel) + interlock_features = self._source_sweep_v2_interlock_features( + extensions, + request.channel, + operation=operation, + ) + fields = self._source_sweep_v2_fields( + request.channel, + interlock_features=interlock_features, + ) basic_field = next(field for field in fields if field.field is SourceFieldId.BASIC) output_field = next( field for field in fields if field.field is SourceFieldId.OUTPUT ) sweep_field = next(field for field in fields if field.field is SourceFieldId.SWEEP) + postcondition_fields = tuple( + field for field in fields if field.field is not SourceFieldId.IDENTITY + ) target_scope = SourceScopeRef(SourceFacetScope.CHANNEL, channel=request.channel) context = SourceOperationContextCoordinator( session_state=session_state, @@ -2498,16 +2866,7 @@ def _configure_sweep_v2_transaction( required_off_outputs=(target_scope,), emergency_off_outputs=(target_scope,), restore_order=(), - non_restorable_fields=tuple( - field - for field in fields - if field.field - in { - SourceFieldId.BASIC, - SourceFieldId.OUTPUT, - SourceFieldId.SWEEP, - } - ), + non_restorable_fields=postcondition_fields, correlation_id=correlation_id, ) preflight_snapshot: SourceSnapshotV2 | None = None @@ -2544,6 +2903,12 @@ def _configure_sweep_v2_transaction( preflight_sweep, preflight_output, ) + preflight_interlocks = self._source_sweep_v2_interlock_observations( + preflight_snapshot, + request.channel, + interlock_features, + operation=operation, + ) context.bind_baseline_snapshot_digest( source_v2_digest( ( @@ -2551,6 +2916,7 @@ def _configure_sweep_v2_transaction( preflight_basic, preflight_sweep, preflight_output, + preflight_interlocks, ) ) ) @@ -2582,7 +2948,7 @@ def _configure_sweep_v2_transaction( postcondition = context.make_phase_spec( SourceOperationPhase.POSTCONDITION, allowed_io={"query"}, - fields=(basic_field, output_field, sweep_field), + fields=postcondition_fields, max_steps=extensions.query_contract.max_queries, ) with context.authorize_phase(postcondition) as authorization: @@ -2612,7 +2978,7 @@ def _configure_sweep_v2_transaction( context.complete_phase_verification( authorization, io_kind="query", - fields=(basic_field, output_field, sweep_field), + fields=postcondition_fields, ) except BaseException as exc: failure = exc @@ -2854,21 +3220,42 @@ def _configure_burst_v2_transaction( context.complete() raise - def _configure_pulse_v2_transaction( + def _fire_source_v2_transaction( self, - request: SourcePulseConfigureRequest, + request: SourceFireRequest, *, + feature: SourceFeature, correlation_id: str | None = None, - ) -> _SourcePulseConfigureV2Transaction: - """Execute one declared WIDTH Pulse transaction while output is OFF.""" - - operation = "source.pulse_configure_v2" - if not isinstance(request, SourcePulseConfigureRequest): - raise ConfigError(f"{operation} requires SourcePulseConfigureRequest") + ) -> _SourceFireV2Transaction: + """Fire one configured Burst or Sweep without retrying the command.""" + + if feature is SourceFeature.BURST: + operation = "source.burst_fire_v2" + capability = "source.burst_fire_v2" + configure_capability = "source.burst_configure_v2" + contract = SOURCE_BURST_FIRE_V2_OPERATION_CONTRACT + feature_field_id = SourceFieldId.BURST + elif feature is SourceFeature.SWEEP: + operation = "source.sweep_fire_v2" + capability = "source.sweep_fire_v2" + configure_capability = "source.sweep_configure_v2" + contract = SOURCE_SWEEP_FIRE_V2_OPERATION_CONTRACT + feature_field_id = SourceFieldId.SWEEP + else: # pragma: no cover - private callers pass one of the two declared features. + raise ValueError("source fire feature must be Burst or Sweep") + if not isinstance(request, SourceFireRequest): + raise ConfigError(f"{operation} requires SourceFireRequest") self._require( operation, "source.snapshot_v2", - "source.pulse_configure_v2", + capability, + configure_capability, + "source.output_v2", + ) + configuration_digest = self._require_source_v2_fire_receipt( + feature, + request.channel, + operation=operation, ) with self._source_session() as source: descriptor = self.descriptor @@ -2878,23 +3265,247 @@ def _configure_pulse_v2_transaction( raise ConfigError(f"{operation} requires validated source_extensions") if session_state is None: raise ConfigError(f"{operation} requires a connection-bound session state") - fields = self._source_pulse_v2_fields(request.channel) - pulse_field = next(field for field in fields if field.field is SourceFieldId.PULSE) + fields = self._source_fire_v2_fields(request.channel, feature_field_id) + feature_field = next(field for field in fields if field.field is feature_field_id) output_field = next( field for field in fields if field.field is SourceFieldId.OUTPUT ) - target_scope = SourceScopeRef(SourceFacetScope.CHANNEL, channel=request.channel) + target_scope = SourceScopeRef( + SourceFacetScope.CHANNEL, + channel=request.channel, + ) context = SourceOperationContextCoordinator( session_state=session_state, operation_spec=require_operation_spec(operation), - operation_contract=SOURCE_PULSE_CONFIGURE_V2_OPERATION_CONTRACT, + operation_contract=contract, connection_timeout_ms=self.config.connection.timeout_ms, baseline_snapshot_digest=None, fields=fields, - required_off_outputs=(target_scope,), + required_off_outputs=(), emergency_off_outputs=(target_scope,), restore_order=(), - non_restorable_fields=(output_field, pulse_field), + non_restorable_fields=tuple( + item + for item in fields + if item.field in {feature_field_id, SourceFieldId.OUTPUT} + ), + correlation_id=correlation_id, + ) + preflight_snapshot: SourceSnapshotV2 | None = None + postcondition_snapshot: SourceSnapshotV2 | None = None + result: SourceFireResult | None = None + main_entered = False + failure: BaseException | None = None + recovery: dict[str, object] | None = None + + try: + preflight = context.make_phase_spec( + SourceOperationPhase.PREFLIGHT, + allowed_io={"query"}, + fields=fields, + max_steps=extensions.query_contract.max_queries, + ) + with context.authorize_phase(preflight) as authorization: + preflight_snapshot = self._snapshot_v2_with_open_source( + source, + correlation_id=context.correlation_id, + deadline=authorization.deadline, + ) + preflight_basic, preflight_feature, preflight_output = ( + self._source_v2_fire_target( + preflight_snapshot, + request.channel, + feature=feature, + operation=operation, + ) + ) + self._validate_source_fire_v2_preflight( + request, + feature=feature, + snapshot=preflight_snapshot, + basic=preflight_basic, + feature_state=preflight_feature, + output=preflight_output, + configuration_digest=configuration_digest, + operation=operation, + ) + context.bind_baseline_snapshot_digest( + source_v2_digest( + ( + request.channel, + preflight_basic, + preflight_feature, + preflight_output, + ) + ) + ) + context.complete_phase_verification( + authorization, + io_kind="query", + fields=fields, + ) + + main = context.make_phase_spec( + SourceOperationPhase.MAIN, + allowed_io={"write"}, + fields=(feature_field,), + max_steps=contract.main_max_steps, + ) + try: + with context.authorize_phase(main): + main_entered = True + if feature is SourceFeature.BURST: + result = cast( + SourceBurstFireV2Driver, + source, + ).fire_source_burst_v2(request) + else: + result = cast( + SourceSweepFireV2Driver, + source, + ).fire_source_sweep_v2(request) + self._validate_source_fire_v2_result( + request, + result, + operation=operation, + ) + except BaseException as exc: + failure = exc + + if failure is None: + try: + postcondition = context.make_phase_spec( + SourceOperationPhase.POSTCONDITION, + allowed_io={"query"}, + fields=fields, + max_steps=extensions.query_contract.max_queries, + ) + with context.authorize_phase(postcondition) as authorization: + postcondition_snapshot = self._snapshot_v2_with_open_source( + source, + correlation_id=context.correlation_id, + deadline=authorization.deadline, + ) + post_basic, post_feature, post_output = ( + self._source_v2_fire_target( + postcondition_snapshot, + request.channel, + feature=feature, + operation=operation, + ) + ) + self._validate_source_fire_v2_postcondition( + request, + feature=feature, + snapshot=postcondition_snapshot, + basic=post_basic, + feature_state=post_feature, + output=post_output, + configuration_digest=configuration_digest, + operation=operation, + ) + context.complete_phase_verification( + authorization, + io_kind="query", + fields=fields, + ) + except BaseException as exc: + failure = exc + + if failure is not None: + if main_entered: + self._clear_source_v2_fire_receipt(feature, request.channel) + try: + context.mark_failure_required() + recovery = self._recover_source_v2_output_off( + context, + source, + request.channel, + extensions, + output_field, + operation=operation, + ) + except BaseException: + recovery = { + "status": "recovery_setup_failed", + "session_health": session_state.health.value, + } + context.complete() + if main_entered: + self._attach_source_fire_v2_diagnostics( + failure, + context=context, + request=request, + feature=feature, + preflight_snapshot=preflight_snapshot, + postcondition_snapshot=postcondition_snapshot, + result=result, + recovery=recovery, + ) + raise failure + + context.complete() + assert result is not None + assert preflight_snapshot is not None + assert postcondition_snapshot is not None + return _SourceFireV2Transaction( + result=result, + artifact=self._source_fire_v2_artifact( + context=context, + request=request, + feature=feature, + preflight_snapshot=preflight_snapshot, + postcondition_snapshot=postcondition_snapshot, + result=result, + ), + snapshot=postcondition_snapshot, + ) + except BaseException: + if not context.terminal: + context.complete() + raise + + def _configure_pulse_v2_transaction( + self, + request: SourcePulseConfigureRequest, + *, + correlation_id: str | None = None, + ) -> _SourcePulseConfigureV2Transaction: + """Execute one declared WIDTH Pulse transaction while output is OFF.""" + + operation = "source.pulse_configure_v2" + if not isinstance(request, SourcePulseConfigureRequest): + raise ConfigError(f"{operation} requires SourcePulseConfigureRequest") + self._require( + operation, + "source.snapshot_v2", + "source.pulse_configure_v2", + ) + with self._source_session() as source: + descriptor = self.descriptor + extensions = None if descriptor is None else descriptor.source_extensions + session_state = self.session_state + if not isinstance(extensions, SourceDescriptorExtensions): + raise ConfigError(f"{operation} requires validated source_extensions") + if session_state is None: + raise ConfigError(f"{operation} requires a connection-bound session state") + fields = self._source_pulse_v2_fields(request.channel) + pulse_field = next(field for field in fields if field.field is SourceFieldId.PULSE) + output_field = next( + field for field in fields if field.field is SourceFieldId.OUTPUT + ) + target_scope = SourceScopeRef(SourceFacetScope.CHANNEL, channel=request.channel) + context = SourceOperationContextCoordinator( + session_state=session_state, + operation_spec=require_operation_spec(operation), + operation_contract=SOURCE_PULSE_CONFIGURE_V2_OPERATION_CONTRACT, + connection_timeout_ms=self.config.connection.timeout_ms, + baseline_snapshot_digest=None, + fields=fields, + required_off_outputs=(target_scope,), + emergency_off_outputs=(target_scope,), + restore_order=(), + non_restorable_fields=(output_field, pulse_field), correlation_id=correlation_id, ) preflight_snapshot: SourceSnapshotV2 | None = None @@ -3460,30 +4071,24 @@ def _select_arbitrary_v2_transaction( context.complete() raise - def _configure_cross_channel_v2_transaction( + def _replace_arbitrary_volatile_v2_transaction( self, - request: object, + request: SourceArbitraryVolatileReplaceRequest, *, - operation: str, - feature: SourceFeature, - relation_field: SourceFieldId, - operation_contract: SourceOperationContract, + payload: bytes, correlation_id: str | None = None, - ) -> _SourceCrossChannelConfigureV2Transaction: - """Run one graph-bounded M6-C relation mutation. - - The discovery snapshot exists solely to freeze a complete affected-port - closure before creating the operation context. The authorized - preflight repeats that discovery and rejects graph drift before MAIN. - It never broadens independent, unrelated ports into this transaction. - """ + ) -> _SourceArbitraryVolatileReplaceV2Transaction: + """Replace one unnamed volatile ARB workspace without claiming content readback.""" - channels, enabled = self._source_cross_channel_request_parts( - request, - feature=feature, - operation=operation, + operation = "source.arbitrary_volatile_replace_v2" + if not isinstance(request, SourceArbitraryVolatileReplaceRequest): + raise ConfigError(f"{operation} requires SourceArbitraryVolatileReplaceRequest") + self._validate_source_arbitrary_volatile_replace_v2_payload(request, payload) + self._require( + operation, + "source.snapshot_v2", + "source.arbitrary_volatile_replace_v2", ) - self._require(operation, "source.snapshot_v2", operation) with self._source_session() as source: descriptor = self.descriptor extensions = None if descriptor is None else descriptor.source_extensions @@ -3492,52 +4097,785 @@ def _configure_cross_channel_v2_transaction( raise ConfigError(f"{operation} requires validated source_extensions") if session_state is None: raise ConfigError(f"{operation} requires a connection-bound session state") - - discovery_snapshot = self._snapshot_v2_with_open_source( - source, - correlation_id=correlation_id, + fields = self._source_arbitrary_volatile_replace_v2_fields(request.channel) + selection_field = next( + field + for field in fields + if field.field is SourceFieldId.ARBITRARY_SELECTION ) - discovery_closure = self._source_cross_channel_closure( - discovery_snapshot, - channels=channels, - feature=feature, - relation_field=relation_field, - operation=operation, + storage_field = next( + field + for field in fields + if field.field is SourceFieldId.ARBITRARY_STORAGE ) - self._validate_source_cross_channel_preflight( - discovery_snapshot, - discovery_closure, - operation=operation, + basic_field = next(field for field in fields if field.field is SourceFieldId.BASIC) + output_field = next( + field for field in fields if field.field is SourceFieldId.OUTPUT ) - if ( - len(discovery_closure.output_fields) - > operation_contract.recovery_max_steps - ): - raise ConfigError( - f"{operation} affected output range exceeds the declared recovery bound" - ) - output_scopes = tuple( - SourceScopeRef(SourceFacetScope.CHANNEL, channel=channel) - for channel in discovery_closure.affected_channels + identity_field = next( + field for field in fields if field.field is SourceFieldId.IDENTITY ) + preflight_fields = (basic_field, output_field, identity_field) + postcondition_fields = (selection_field, basic_field, output_field) + target_scope = SourceScopeRef(SourceFacetScope.CHANNEL, channel=request.channel) context = SourceOperationContextCoordinator( session_state=session_state, operation_spec=require_operation_spec(operation), - operation_contract=operation_contract, + operation_contract=SOURCE_ARBITRARY_VOLATILE_REPLACE_V2_OPERATION_CONTRACT, connection_timeout_ms=self.config.connection.timeout_ms, baseline_snapshot_digest=None, - fields=discovery_closure.fields, - required_off_outputs=output_scopes, - emergency_off_outputs=output_scopes, + fields=fields, + required_off_outputs=(target_scope,), + emergency_off_outputs=(target_scope,), restore_order=(), - non_restorable_fields=discovery_closure.fields, - correlation_id=correlation_id, - ) - preflight_snapshot: SourceSnapshotV2 | None = None - postcondition_snapshot: SourceSnapshotV2 | None = None - result: SourceCrossChannelConfigureResult | None = None - closure = discovery_closure - wrote_main = False + non_restorable_fields=tuple( + field + for field in fields + if field.field + in { + SourceFieldId.ARBITRARY_SELECTION, + SourceFieldId.ARBITRARY_STORAGE, + SourceFieldId.BASIC, + SourceFieldId.OUTPUT, + } + ), + correlation_id=correlation_id, + ) + preflight_snapshot: SourceSnapshotV2 | None = None + postcondition_snapshot: SourceSnapshotV2 | None = None + result: SourceArbitraryVolatileReplaceResult | None = None + main_entered = False + failure: BaseException | None = None + recovery: dict[str, object] | None = None + + try: + preflight = context.make_phase_spec( + SourceOperationPhase.PREFLIGHT, + allowed_io={"query"}, + fields=preflight_fields, + max_steps=extensions.query_contract.max_queries, + ) + with context.authorize_phase(preflight) as authorization: + preflight_snapshot = self._snapshot_v2_with_open_source( + source, + correlation_id=context.correlation_id, + deadline=authorization.deadline, + ) + preflight_basic, preflight_output = self._source_v2_target( + preflight_snapshot, + request.channel, + operation=operation, + ) + self._validate_source_arbitrary_volatile_replace_v2_preflight( + request, + preflight_snapshot, + preflight_basic, + preflight_output, + ) + context.bind_baseline_snapshot_digest( + source_v2_digest( + ( + request.channel, + preflight_basic, + preflight_output, + ) + ) + ) + context.complete_phase_verification( + authorization, + io_kind="query", + fields=preflight_fields, + ) + + main = context.make_phase_spec( + SourceOperationPhase.MAIN, + allowed_io={"write_bytes"}, + fields=(selection_field, storage_field, basic_field), + max_steps=SOURCE_ARBITRARY_VOLATILE_REPLACE_V2_OPERATION_CONTRACT.main_max_steps, + ) + try: + with context.authorize_phase(main): + main_entered = True + result = cast( + SourceArbitraryVolatileReplaceV2Driver, + source, + ).replace_source_arbitrary_volatile_v2(request, payload) + self._validate_source_arbitrary_volatile_replace_v2_result(request, result) + except BaseException as exc: + failure = exc + + if failure is None: + try: + postcondition = context.make_phase_spec( + SourceOperationPhase.POSTCONDITION, + allowed_io={"query"}, + fields=postcondition_fields, + max_steps=extensions.query_contract.max_queries, + ) + with context.authorize_phase(postcondition) as authorization: + postcondition_snapshot = self._snapshot_v2_with_open_source( + source, + correlation_id=context.correlation_id, + deadline=authorization.deadline, + ) + ( + postcondition_basic, + postcondition_arbitrary, + postcondition_output, + ) = self._source_v2_arbitrary_select_target( + postcondition_snapshot, + request.channel, + operation=operation, + ) + assert result is not None + self._validate_source_arbitrary_volatile_replace_v2_postcondition( + result, + postcondition_snapshot, + postcondition_basic, + postcondition_arbitrary, + postcondition_output, + ) + context.complete_phase_verification( + authorization, + io_kind="query", + fields=postcondition_fields, + ) + except BaseException as exc: + failure = exc + + if failure is not None: + if main_entered: + try: + context.mark_failure_required() + recovery = self._recover_source_v2_output_off( + context, + source, + request.channel, + extensions, + output_field, + operation=operation, + ) + except BaseException: + recovery = { + "status": "recovery_setup_failed", + "session_health": session_state.health.value, + } + context.complete() + if main_entered: + self._attach_source_arbitrary_volatile_replace_v2_diagnostics( + failure, + context=context, + request=request, + preflight_snapshot=preflight_snapshot, + postcondition_snapshot=postcondition_snapshot, + result=result, + recovery=recovery, + ) + raise failure + + context.complete() + assert result is not None + assert preflight_snapshot is not None + assert postcondition_snapshot is not None + return _SourceArbitraryVolatileReplaceV2Transaction( + result=result, + artifact=self._source_arbitrary_volatile_replace_v2_artifact( + context=context, + request=request, + preflight_snapshot=preflight_snapshot, + postcondition_snapshot=postcondition_snapshot, + result=result, + ), + snapshot=postcondition_snapshot, + ) + except BaseException: + if not context.terminal: + context.complete() + raise + + def _replace_arbitrary_workspace_volatile_v2_transaction( + self, + request: SourceArbitraryWorkspaceVolatileReplaceRequest, + *, + payload: bytes, + correlation_id: str | None = None, + ) -> _SourceArbitraryWorkspaceVolatileReplaceV2Transaction: + """Replace an unscoped volatile workspace without claiming channel selection.""" + + operation = "source.arbitrary_workspace_volatile_replace_v2" + if not isinstance(request, SourceArbitraryWorkspaceVolatileReplaceRequest): + raise ConfigError( + f"{operation} requires SourceArbitraryWorkspaceVolatileReplaceRequest" + ) + self._validate_source_arbitrary_workspace_volatile_replace_v2_payload(request, payload) + self._require( + operation, + "source.snapshot_v2", + "source.arbitrary_workspace_volatile_replace_v2", + ) + with self._source_session() as source: + descriptor = self.descriptor + extensions = None if descriptor is None else descriptor.source_extensions + session_state = self.session_state + if not isinstance(extensions, SourceDescriptorExtensions): + raise ConfigError(f"{operation} requires validated source_extensions") + if session_state is None: + raise ConfigError(f"{operation} requires a connection-bound session state") + channels = extensions.topology.channels + fields = self._source_arbitrary_workspace_volatile_replace_v2_fields(channels) + workspace_field = next( + field for field in fields if field.field is SourceFieldId.ARBITRARY_WORKSPACE + ) + identity_field = next(field for field in fields if field.field is SourceFieldId.IDENTITY) + output_fields = tuple( + field for field in fields if field.field is SourceFieldId.OUTPUT + ) + output_scopes = tuple( + SourceScopeRef(SourceFacetScope.CHANNEL, channel=channel) + for channel in channels + ) + context = SourceOperationContextCoordinator( + session_state=session_state, + operation_spec=require_operation_spec(operation), + operation_contract=SOURCE_ARBITRARY_WORKSPACE_VOLATILE_REPLACE_V2_OPERATION_CONTRACT, + connection_timeout_ms=self.config.connection.timeout_ms, + baseline_snapshot_digest=None, + fields=fields, + required_off_outputs=output_scopes, + emergency_off_outputs=output_scopes, + restore_order=(), + non_restorable_fields=tuple( + field + for field in fields + if field.field + in { + SourceFieldId.ARBITRARY_WORKSPACE, + SourceFieldId.OUTPUT, + } + ), + correlation_id=correlation_id, + ) + preflight_snapshot: SourceSnapshotV2 | None = None + postcondition_snapshot: SourceSnapshotV2 | None = None + result: SourceArbitraryWorkspaceVolatileReplaceResult | None = None + main_entered = False + failure: BaseException | None = None + recovery: dict[str, object] | None = None + + try: + preflight = context.make_phase_spec( + SourceOperationPhase.PREFLIGHT, + allowed_io={"query"}, + fields=(*output_fields, identity_field), + max_steps=extensions.query_contract.max_queries, + ) + with context.authorize_phase(preflight) as authorization: + preflight_snapshot = self._snapshot_v2_with_open_source( + source, + correlation_id=context.correlation_id, + deadline=authorization.deadline, + ) + profile = self._source_arbitrary_workspace_runtime_profile( + preflight_snapshot, + operation=operation, + ) + preflight_outputs = tuple( + self._source_v2_output_target( + preflight_snapshot, + channel, + operation=operation, + ) + for channel in channels + ) + self._validate_source_arbitrary_workspace_volatile_replace_v2_preflight( + request, + preflight_snapshot, + profile, + preflight_outputs, + ) + context.bind_baseline_snapshot_digest( + source_v2_digest( + { + "workspace": profile, + "channels": channels, + "outputs": preflight_outputs, + } + ) + ) + context.complete_phase_verification( + authorization, + io_kind="query", + fields=(*output_fields, identity_field), + ) + + main = context.make_phase_spec( + SourceOperationPhase.MAIN, + allowed_io={"write_bytes"}, + fields=(workspace_field,), + max_steps=SOURCE_ARBITRARY_WORKSPACE_VOLATILE_REPLACE_V2_OPERATION_CONTRACT.main_max_steps, + ) + try: + with context.authorize_phase(main): + main_entered = True + result = cast( + SourceArbitraryWorkspaceVolatileReplaceV2Driver, + source, + ).replace_source_arbitrary_workspace_volatile_v2(request, payload) + self._validate_source_arbitrary_workspace_volatile_replace_v2_result( + request, + profile, + result, + ) + except BaseException as exc: + failure = exc + + if failure is None: + try: + postcondition = context.make_phase_spec( + SourceOperationPhase.POSTCONDITION, + allowed_io={"query"}, + fields=output_fields, + max_steps=extensions.query_contract.max_queries, + ) + with context.authorize_phase(postcondition) as authorization: + postcondition_snapshot = self._snapshot_v2_with_open_source( + source, + correlation_id=context.correlation_id, + deadline=authorization.deadline, + ) + postcondition_outputs = tuple( + self._source_v2_output_target( + postcondition_snapshot, + channel, + operation=operation, + ) + for channel in channels + ) + self._validate_source_arbitrary_workspace_volatile_replace_v2_postcondition( + postcondition_snapshot, + postcondition_outputs, + ) + context.complete_phase_verification( + authorization, + io_kind="query", + fields=output_fields, + ) + except BaseException as exc: + failure = exc + + if failure is not None: + if main_entered: + try: + context.mark_failure_required() + recovery = self._recover_source_v2_outputs_off( + context, + source, + channels, + extensions, + output_fields, + operation=operation, + ) + except BaseException: + recovery = { + "status": "recovery_setup_failed", + "session_health": session_state.health.value, + } + context.complete() + if main_entered: + self._attach_source_arbitrary_workspace_volatile_replace_v2_diagnostics( + failure, + context=context, + request=request, + preflight_snapshot=preflight_snapshot, + postcondition_snapshot=postcondition_snapshot, + result=result, + recovery=recovery, + ) + raise failure + + context.complete() + assert result is not None + assert preflight_snapshot is not None + assert postcondition_snapshot is not None + return _SourceArbitraryWorkspaceVolatileReplaceV2Transaction( + result=result, + artifact=self._source_arbitrary_workspace_volatile_replace_v2_artifact( + context=context, + request=request, + preflight_snapshot=preflight_snapshot, + postcondition_snapshot=postcondition_snapshot, + result=result, + ), + snapshot=postcondition_snapshot, + ) + except BaseException: + if not context.terminal: + context.complete() + raise + + def _mutate_counter_v2_transaction( + self, + request: SourceCounterConfigureRequest | SourceCounterEnableRequest, + *, + operation: str, + operation_contract: SourceOperationContract, + correlation_id: str | None = None, + ) -> _SourceCounterV2Transaction: + """Run one Counter configuration or enable-state mutation with no rollback.""" + + if operation == "source.counter_configure_v2": + if not isinstance(request, SourceCounterConfigureRequest): + raise ConfigError(f"{operation} requires SourceCounterConfigureRequest") + elif operation in {"source.counter_enable_v2", "source.counter_disable_v2"}: + if not isinstance(request, SourceCounterEnableRequest): + raise ConfigError(f"{operation} requires SourceCounterEnableRequest") + else: # pragma: no cover - private callers fix the operation set above. + raise ValueError("unsupported Counter V2 mutation operation") + self._require(operation, "source.snapshot_v2", operation_contract.capability) + with self._source_session() as source: + descriptor = self.descriptor + extensions = None if descriptor is None else descriptor.source_extensions + session_state = self.session_state + if not isinstance(extensions, SourceDescriptorExtensions): + raise ConfigError(f"{operation} requires validated source_extensions") + if session_state is None: + raise ConfigError(f"{operation} requires a connection-bound session state") + fields = self._source_counter_v2_fields(request.input_id) + counter_field = next( + field for field in fields if field.field is SourceFieldId.COUNTER + ) + context = SourceOperationContextCoordinator( + session_state=session_state, + operation_spec=require_operation_spec(operation), + operation_contract=operation_contract, + connection_timeout_ms=self.config.connection.timeout_ms, + baseline_snapshot_digest=None, + fields=fields, + required_off_outputs=(), + emergency_off_outputs=(), + restore_order=(), + non_restorable_fields=(counter_field,), + correlation_id=correlation_id, + ) + preflight_snapshot: SourceSnapshotV2 | None = None + postcondition_snapshot: SourceSnapshotV2 | None = None + result: SourceCounterConfigureResult | SourceCounterEnableResult | None = None + wrote_main = False + main_entered = False + failure: BaseException | None = None + recovery: dict[str, object] | None = None + + try: + preflight = context.make_phase_spec( + SourceOperationPhase.PREFLIGHT, + allowed_io={"query"}, + fields=fields, + max_steps=extensions.query_contract.max_queries, + ) + with context.authorize_phase(preflight) as authorization: + preflight_snapshot = self._snapshot_v2_with_open_source( + source, + correlation_id=context.correlation_id, + deadline=authorization.deadline, + ) + counter, profile = self._source_v2_counter_target( + preflight_snapshot, + request.input_id, + direction=operation_contract.direction, + operation=operation, + ) + if isinstance(request, SourceCounterConfigureRequest): + wrote_main = self._validate_source_counter_configure_v2_preflight( + request, + preflight_snapshot, + counter, + profile, + ) + if not wrote_main: + result = SourceCounterConfigureResult(request.input_id, counter) + else: + wrote_main = self._validate_source_counter_enable_v2_preflight( + request, + preflight_snapshot, + counter, + profile, + operation=operation, + ) + if not wrote_main: + result = SourceCounterEnableResult(request.input_id, request.enabled) + context.bind_baseline_snapshot_digest( + source_v2_digest((request.input_id, counter)) + ) + context.complete_phase_verification( + authorization, + io_kind="query", + fields=fields, + ) + + if wrote_main: + main = context.make_phase_spec( + SourceOperationPhase.MAIN, + allowed_io={"write"}, + fields=(counter_field,), + max_steps=operation_contract.main_max_steps, + ) + try: + with context.authorize_phase(main): + main_entered = True + if isinstance(request, SourceCounterConfigureRequest): + result = cast( + SourceCounterConfigureV2Driver, + source, + ).configure_source_counter_v2(request) + self._validate_source_counter_configure_v2_result(request, result) + else: + result = cast( + SourceCounterEnableV2Driver, + source, + ).set_source_counter_enabled_v2(request) + self._validate_source_counter_enable_v2_result( + request, + result, + operation=operation, + ) + except BaseException as exc: + failure = exc + + if failure is None and wrote_main: + try: + postcondition = context.make_phase_spec( + SourceOperationPhase.POSTCONDITION, + allowed_io={"query"}, + fields=(counter_field,), + max_steps=extensions.query_contract.max_queries, + ) + with context.authorize_phase(postcondition) as authorization: + postcondition_snapshot = self._snapshot_v2_with_open_source( + source, + correlation_id=context.correlation_id, + deadline=authorization.deadline, + ) + counter, profile = self._source_v2_counter_target( + postcondition_snapshot, + request.input_id, + direction=operation_contract.direction, + operation=operation, + ) + assert result is not None + if isinstance(request, SourceCounterConfigureRequest): + self._validate_source_counter_configure_v2_postcondition( + request, + result, + postcondition_snapshot, + counter, + profile, + ) + else: + self._validate_source_counter_enable_v2_postcondition( + request, + result, + postcondition_snapshot, + counter, + profile, + operation=operation, + ) + context.complete_phase_verification( + authorization, + io_kind="query", + fields=(counter_field,), + ) + except BaseException as exc: + failure = exc + + if failure is not None: + if main_entered: + context.mark_failure_required() + recovery = { + "status": "not_attempted", + "reason": "counter_state_not_rollback_safe", + } + context.complete() + if main_entered: + self._attach_source_counter_v2_diagnostics( + failure, + context=context, + request=request, + preflight_snapshot=preflight_snapshot, + postcondition_snapshot=postcondition_snapshot, + result=result, + wrote_main=wrote_main, + recovery=recovery, + capability=operation_contract.capability, + ) + raise failure + + context.complete() + assert result is not None + assert preflight_snapshot is not None + return _SourceCounterV2Transaction( + result=result, + artifact=self._source_counter_v2_artifact( + context=context, + request=request, + preflight_snapshot=preflight_snapshot, + postcondition_snapshot=postcondition_snapshot, + result=result, + wrote_main=wrote_main, + capability=operation_contract.capability, + ), + snapshot=( + postcondition_snapshot + if postcondition_snapshot is not None + else preflight_snapshot + ), + ) + except BaseException: + if not context.terminal: + context.complete() + raise + + def _measure_counter_v2( + self, + request: SourceCounterMeasureRequest, + *, + correlation_id: str | None = None, + ) -> SourceCounterMeasureResult: + operation = "source.counter_measure_v2" + if not isinstance(request, SourceCounterMeasureRequest): + raise ConfigError(f"{operation} requires SourceCounterMeasureRequest") + self._require(operation, "source.snapshot_v2", "source.counter_measure_v2") + spec = require_operation_spec(operation) + with self._source_session() as source: + descriptor = self.descriptor + extensions = None if descriptor is None else descriptor.source_extensions + session_state = self.session_state + if not isinstance(extensions, SourceDescriptorExtensions): + raise ConfigError(f"{operation} requires validated source_extensions") + if session_state is None: + raise ConfigError(f"{operation} requires a connection-bound session state") + timeout_ms = spec.operation_timeout_ms + if timeout_ms is None: # pragma: no cover - registry invariant. + raise ConfigError(f"{operation} requires an operation timeout") + timeout_ms = min(timeout_ms, self.config.connection.timeout_ms) + with session_state.transaction_lock: + snapshot = self._snapshot_v2_with_open_source( + source, + correlation_id=correlation_id, + ) + counter, profile = self._source_v2_counter_target( + snapshot, + request.input_id, + direction=SourceFeatureDirection.READ, + operation=operation, + ) + self._validate_source_counter_measure_v2_preflight( + snapshot, + counter, + profile, + operation=operation, + ) + coordinator = SessionTransactionCoordinator(session_state) + with coordinator.authorize_normal( + operation_id=operation, + allowed_io=("query",), + fields=(SourceFieldId.COUNTER.value,), + timeout_ms=timeout_ms, + max_steps=1, + context_id="source_counter_measure_v2", + correlation_id=uuid4().hex, + phase="main", + absolute_deadline=time.monotonic() + (timeout_ms / 1000.0), + ): + result = cast( + SourceCounterMeasureV2Driver, + source, + ).measure_source_counter_v2(request) + self._validate_source_counter_measure_v2_result( + request, + result, + profile, + operation=operation, + ) + return result + + def _configure_cross_channel_v2_transaction( + self, + request: object, + *, + operation: str, + feature: SourceFeature, + relation_field: SourceFieldId, + operation_contract: SourceOperationContract, + correlation_id: str | None = None, + ) -> _SourceCrossChannelConfigureV2Transaction: + """Run one graph-bounded M6-C relation mutation. + + The discovery snapshot exists solely to freeze a complete affected-port + closure before creating the operation context. The authorized + preflight repeats that discovery and rejects graph drift before MAIN. + It never broadens independent, unrelated ports into this transaction. + """ + + channels, enabled = self._source_cross_channel_request_parts( + request, + feature=feature, + operation=operation, + ) + self._require(operation, "source.snapshot_v2", operation) + with self._source_session() as source: + descriptor = self.descriptor + extensions = None if descriptor is None else descriptor.source_extensions + session_state = self.session_state + if not isinstance(extensions, SourceDescriptorExtensions): + raise ConfigError(f"{operation} requires validated source_extensions") + if session_state is None: + raise ConfigError(f"{operation} requires a connection-bound session state") + + discovery_snapshot = self._snapshot_v2_with_open_source( + source, + correlation_id=correlation_id, + ) + discovery_closure = self._source_cross_channel_closure( + discovery_snapshot, + channels=channels, + feature=feature, + relation_field=relation_field, + operation=operation, + ) + self._validate_source_cross_channel_preflight( + discovery_snapshot, + discovery_closure, + operation=operation, + ) + if ( + len(discovery_closure.output_fields) + > operation_contract.recovery_max_steps + ): + raise ConfigError( + f"{operation} affected output range exceeds the declared recovery bound" + ) + output_scopes = tuple( + SourceScopeRef(SourceFacetScope.CHANNEL, channel=channel) + for channel in discovery_closure.affected_channels + ) + context = SourceOperationContextCoordinator( + session_state=session_state, + operation_spec=require_operation_spec(operation), + operation_contract=operation_contract, + connection_timeout_ms=self.config.connection.timeout_ms, + baseline_snapshot_digest=None, + fields=discovery_closure.fields, + required_off_outputs=output_scopes, + emergency_off_outputs=output_scopes, + restore_order=(), + non_restorable_fields=discovery_closure.fields, + correlation_id=correlation_id, + ) + preflight_snapshot: SourceSnapshotV2 | None = None + postcondition_snapshot: SourceSnapshotV2 | None = None + result: SourceCrossChannelConfigureResult | None = None + closure = discovery_closure + wrote_main = False main_entered = False failure: BaseException | None = None recovery: dict[str, object] | None = None @@ -3838,6 +5176,11 @@ def _validate_source_cross_channel_runtime_profile( feature: SourceFeature, operation: str, ) -> None: + profile_type = ( + SourceCouplingCapabilityProfile + if feature is SourceFeature.COUPLING + else SourceCrossChannelCapabilityProfile + ) configurable = next( ( item @@ -3848,18 +5191,26 @@ def _validate_source_cross_channel_runtime_profile( and item.support is SupportState.SUPPORTED and SourceFeatureDirection.READ in item.directions and SourceFeatureDirection.CONFIGURE in item.directions - and isinstance(item.profile, SourceCrossChannelCapabilityProfile) + and isinstance(item.profile, profile_type) ), None, ) if configurable is None: raise ConfigError(f"{operation} is not available for the runtime channel set") profile = configurable.profile - if ( - feature not in profile.relation_kinds - or channels not in profile.supported_channel_sets - or not profile.configuration_readable - ): + if isinstance(profile, SourceCouplingCapabilityProfile): + readable = ( + channels in profile.supported_channel_sets + and profile.global_state_readable + and profile.configuration_readable + ) + else: + readable = ( + feature in profile.relation_kinds + and channels in profile.supported_channel_sets + and profile.configuration_readable + ) + if not readable: raise ConfigError(f"{operation} requires readable declared relation configuration") graph = next( ( @@ -3869,7 +5220,7 @@ def _validate_source_cross_channel_runtime_profile( and item.scope is SourceFacetScope.INSTRUMENT and item.support is SupportState.SUPPORTED and SourceFeatureDirection.READ in item.directions - and isinstance(item.profile, SourceCrossChannelCapabilityProfile) + and isinstance(item.profile, profile_type) and item.profile.relation_graph_readable ), None, @@ -3879,7 +5230,7 @@ def _validate_source_cross_channel_runtime_profile( def _source_cross_channel_fields( self, - relations: tuple[SourceRelationState, ...], + relations: tuple[SourceRelationState | SourceCouplingState, ...], *, feature: SourceFeature, relation_field: SourceFieldId, @@ -3914,6 +5265,8 @@ def _source_cross_channel_fields( SourceFieldId.BURST, SourceFieldId.PULSE, SourceFieldId.ARBITRARY_SELECTION, + SourceFieldId.SYNC, + SourceFieldId.NOISE_OVERLAY, } relation_fields = { SourceFieldId.COMBINE, @@ -3926,7 +5279,6 @@ def _source_cross_channel_fields( SourceFieldId.IDENTITY, SourceFieldId.RELATION_GRAPH, SourceFieldId.REFERENCE_CLOCK, - SourceFieldId.SYNC, SourceFieldId.CASCADE, SourceFieldId.SHARED_POWER, } @@ -4058,6 +5410,8 @@ def _source_cross_channel_snapshot_observation( SourceFieldId.BURST: target.burst, SourceFieldId.PULSE: target.pulse, SourceFieldId.ARBITRARY_SELECTION: target.arbitrary, + SourceFieldId.SYNC: target.sync, + SourceFieldId.NOISE_OVERLAY: target.noise_overlay, } if field.field is SourceFieldId.DISPLAY_LOAD: if target.output.availability is not Availability.VALUE or not isinstance( @@ -4098,7 +5452,6 @@ def _source_cross_channel_snapshot_observation( return snapshot.system system_values = { SourceFieldId.REFERENCE_CLOCK: snapshot.system.value.reference_clock, - SourceFieldId.SYNC: snapshot.system.value.sync, SourceFieldId.CASCADE: snapshot.system.value.cascade, } try: @@ -4320,6 +5673,36 @@ def _source_burst_v2_fields(channel: int) -> tuple[SourceFieldRef, ...]: ) ) + @staticmethod + def _source_fire_v2_fields( + channel: int, + feature_field: SourceFieldId, + ) -> tuple[SourceFieldRef, ...]: + if feature_field not in {SourceFieldId.BURST, SourceFieldId.SWEEP}: + raise ValueError("source fire field must be Burst or Sweep") + target = SourceScopeRef(SourceFacetScope.CHANNEL, channel=channel) + fields = ( + SourceFieldRef(SourceFieldId.BASIC, target), + SourceFieldRef(feature_field, target), + SourceFieldRef(SourceFieldId.OUTPUT, target), + SourceFieldRef( + SourceFieldId.IDENTITY, + SourceScopeRef(SourceFacetScope.INSTRUMENT), + ), + ) + return tuple( + sorted( + fields, + key=lambda item: ( + item.field.value, + item.target.scope.value, + -1 if item.target.channel is None else item.target.channel, + item.target.channels, + "" if item.target.input_id is None else item.target.input_id, + ), + ) + ) + @staticmethod def _source_pulse_v2_fields(channel: int) -> tuple[SourceFieldRef, ...]: target = SourceScopeRef(SourceFacetScope.CHANNEL, channel=channel) @@ -4345,12 +5728,52 @@ def _source_pulse_v2_fields(channel: int) -> tuple[SourceFieldRef, ...]: ) @staticmethod - def _source_sweep_v2_fields(channel: int) -> tuple[SourceFieldRef, ...]: + def _source_sweep_v2_interlock_features( + extensions: SourceDescriptorExtensions, + channel: int, + *, + operation: str, + ) -> tuple[SourceFeature, ...]: + feature = next( + ( + candidate + for candidate in extensions.features + if candidate.feature is SourceFeature.SWEEP + and candidate.scope is SourceFacetScope.CHANNEL + and channel in candidate.channels + and candidate.support is SupportState.SUPPORTED + and SourceFeatureDirection.CONFIGURE in candidate.directions + ), + None, + ) + if feature is None or not isinstance(feature.profile, SourceSweepCapabilityProfile): + raise ConfigError(f"{operation} requires declared sweep configuration support") + return feature.profile.implicit_disable_features + + @staticmethod + def _source_sweep_v2_interlock_field(feature: SourceFeature) -> SourceFieldId: + if feature is SourceFeature.BURST: + return SourceFieldId.BURST + if feature is SourceFeature.MODULATION: + return SourceFieldId.MODULATION + raise ValueError("sweep interlock feature is unsupported") + + @classmethod + def _source_sweep_v2_fields( + cls, + channel: int, + *, + interlock_features: tuple[SourceFeature, ...] = (), + ) -> tuple[SourceFieldRef, ...]: target = SourceScopeRef(SourceFacetScope.CHANNEL, channel=channel) fields = ( SourceFieldRef(SourceFieldId.BASIC, target), SourceFieldRef(SourceFieldId.OUTPUT, target), SourceFieldRef(SourceFieldId.SWEEP, target), + *( + SourceFieldRef(cls._source_sweep_v2_interlock_field(feature), target) + for feature in interlock_features + ), SourceFieldRef( SourceFieldId.IDENTITY, SourceScopeRef(SourceFacetScope.INSTRUMENT), @@ -4419,6 +5842,86 @@ def _source_arbitrary_select_v2_fields(channel: int) -> tuple[SourceFieldRef, .. ) ) + @staticmethod + def _source_arbitrary_volatile_replace_v2_fields( + channel: int, + ) -> tuple[SourceFieldRef, ...]: + target = SourceScopeRef(SourceFacetScope.CHANNEL, channel=channel) + fields = ( + SourceFieldRef(SourceFieldId.ARBITRARY_SELECTION, target), + SourceFieldRef(SourceFieldId.ARBITRARY_STORAGE, target), + SourceFieldRef(SourceFieldId.BASIC, target), + SourceFieldRef(SourceFieldId.OUTPUT, target), + SourceFieldRef( + SourceFieldId.IDENTITY, + SourceScopeRef(SourceFacetScope.INSTRUMENT), + ), + ) + return tuple( + sorted( + fields, + key=lambda field: ( + field.field.value, + field.target.scope.value, + -1 if field.target.channel is None else field.target.channel, + field.target.channels, + "" if field.target.input_id is None else field.target.input_id, + ), + ) + ) + + @staticmethod + def _source_arbitrary_workspace_volatile_replace_v2_fields( + channels: tuple[int, ...], + ) -> tuple[SourceFieldRef, ...]: + instrument = SourceScopeRef(SourceFacetScope.INSTRUMENT) + fields = ( + SourceFieldRef(SourceFieldId.ARBITRARY_WORKSPACE, instrument), + SourceFieldRef(SourceFieldId.IDENTITY, instrument), + *( + SourceFieldRef( + SourceFieldId.OUTPUT, + SourceScopeRef(SourceFacetScope.CHANNEL, channel=channel), + ) + for channel in channels + ), + ) + return tuple( + sorted( + fields, + key=lambda field: ( + field.field.value, + field.target.scope.value, + -1 if field.target.channel is None else field.target.channel, + field.target.channels, + "" if field.target.input_id is None else field.target.input_id, + ), + ) + ) + + @staticmethod + def _source_counter_v2_fields(input_id: str) -> tuple[SourceFieldRef, ...]: + target = SourceScopeRef(SourceFacetScope.INPUT, input_id=input_id) + fields = ( + SourceFieldRef(SourceFieldId.COUNTER, target), + SourceFieldRef( + SourceFieldId.IDENTITY, + SourceScopeRef(SourceFacetScope.INSTRUMENT), + ), + ) + return tuple( + sorted( + fields, + key=lambda field: ( + field.field.value, + field.target.scope.value, + -1 if field.target.channel is None else field.target.channel, + field.target.channels, + "" if field.target.input_id is None else field.target.input_id, + ), + ) + ) + @classmethod def _source_output_v2_fields( cls, @@ -4435,6 +5938,44 @@ def _source_output_v2_fields( ), ) + @staticmethod + def _source_v2_counter_target( + snapshot: SourceSnapshotV2, + input_id: str, + *, + direction: SourceFeatureDirection, + operation: str, + ) -> tuple[SourceCounterInputState, SourceCounterCapabilityProfile]: + features = tuple( + feature + for feature in snapshot.runtime_profile.features + if ( + feature.feature is SourceFeature.COUNTER + and feature.scope is SourceFacetScope.INPUT + and feature.support is SupportState.SUPPORTED + and direction in feature.directions + and isinstance(feature.profile, SourceCounterCapabilityProfile) + ) + ) + if len(features) != 1: + raise ConfigError(f"{operation} requires a runtime Counter {direction.value} profile") + profile = features[0].profile + assert isinstance(profile, SourceCounterCapabilityProfile) + if input_id not in profile.input_ids: + raise ConfigError(f"{operation} input_id is unsupported by the runtime profile") + if ( + snapshot.system.availability is not Availability.VALUE + or not isinstance(snapshot.system.value, SourceSystemStateV2) + ): + raise ConfigError(f"{operation} requires readable Counter system state") + counter = next( + (item for item in snapshot.system.value.counters if item.input_id == input_id), + None, + ) + if counter is None: + raise ConfigError(f"{operation} input_id is absent from snapshot") + return counter, profile + @staticmethod def _source_v2_target( snapshot: SourceSnapshotV2, @@ -4572,6 +6113,34 @@ def _source_v2_sweep_target( raise ConfigError(f"{operation} requires readable output state") return target.basic.value, target.sweep.value, target.output.value + def _source_v2_fire_target( + self, + snapshot: SourceSnapshotV2, + channel: int, + *, + feature: SourceFeature, + operation: str, + ) -> tuple[BasicWaveFacet, BurstFacet | SweepFacet, OutputFacet]: + if feature is SourceFeature.SWEEP: + return self._source_v2_sweep_target( + snapshot, + channel, + operation=operation, + ) + if feature is SourceFeature.BURST: + basic, output = self._source_v2_target( + snapshot, + channel, + operation=operation, + ) + burst, _ = self._source_v2_burst_target( + snapshot, + channel, + operation=operation, + ) + return basic, burst, output + raise ValueError("source fire feature must be Burst or Sweep") + @staticmethod def _source_v2_arbitrary_target( snapshot: SourceSnapshotV2, @@ -4757,8 +6326,12 @@ def _validate_source_basic_v2_preflight( ) -> None: if snapshot.consistency.state is not SnapshotConsistencyState.CONSISTENT: raise ConfigError("source.basic_configure_v2 requires a fresh consistent snapshot") - if output.enabled.availability is not Availability.VALUE or output.enabled.value is not False: + if output.enabled.availability is not Availability.VALUE: raise ConfigError("source.basic_configure_v2 requires target output OFF") + if output.enabled.value is not False: + raise _SourceV2BasicRequiresLiveMutation( + "source.basic_configure_v2 requires target output OFF" + ) if not any( feature.feature is SourceFeature.BASIC and feature.scope is SourceFacetScope.CHANNEL @@ -4816,6 +6389,88 @@ def _validate_source_basic_v2_preflight( operation="source.basic_configure_v2", ) + def _validate_source_basic_live_v2_preflight( + self, + request: SourceBasicConfigureRequest, + snapshot: SourceSnapshotV2, + basic: BasicWaveFacet, + output: OutputFacet, + ) -> None: + operation = "source.basic_live_configure_v2" + if snapshot.consistency.state is not SnapshotConsistencyState.CONSISTENT: + raise ConfigError(f"{operation} requires a fresh consistent snapshot") + if output.enabled.availability is not Availability.VALUE: + raise ConfigError(f"{operation} requires target output ON") + if output.enabled.value is not True: + raise _SourceV2BasicRequiresOffMutation( + f"{operation} requires target output ON" + ) + runtime_basic = next( + ( + feature + for feature in snapshot.runtime_profile.features + if feature.feature is SourceFeature.BASIC + and feature.scope is SourceFacetScope.CHANNEL + and request.channel in feature.channels + and feature.support is SupportState.SUPPORTED + and SourceFeatureDirection.CONFIGURE in feature.directions + ), + None, + ) + if runtime_basic is None or not isinstance( + runtime_basic.profile, + SourceBasicCapabilityProfile, + ): + raise ConfigError( + f"{operation} is not available for the runtime target channel" + ) + patch = request.patch + set_fields = tuple( + name + for name, value in ( + ("waveform_kind", patch.waveform_kind), + ("frequency_hz", patch.frequency_hz), + ("amplitude_vpp", patch.amplitude_vpp), + ("offset_v", patch.offset_v), + ("square_duty_cycle_percent", patch.square_duty_cycle_percent), + ) + if value.action is PatchAction.SET + ) + if len(set_fields) != 1 or set_fields[0] not in { + "frequency_hz", + "amplitude_vpp", + }: + raise ConfigError( + f"{operation} requires exactly one frequency_hz or amplitude_vpp SET" + ) + profile = runtime_basic.profile + if set_fields[0] == "frequency_hz" and not profile.live_frequency_configurable: + raise ConfigError(f"{operation} frequency_hz is not declared live-configurable") + if ( + set_fields[0] == "amplitude_vpp" + and not profile.live_amplitude_vpp_configurable + ): + raise ConfigError(f"{operation} amplitude_vpp is not declared live-configurable") + if ( + basic.frequency_mode.availability is not Availability.VALUE + or basic.frequency_mode.value is not SourceFrequencyMode.FIXED + ): + raise ConfigError(f"{operation} requires fixed frequency mode") + current_vpp, current_offset = self._source_v2_amplitude_offset( + basic, + operation=operation, + ) + requested_vpp = ( + float(patch.amplitude_vpp.value) + if patch.amplitude_vpp.action is PatchAction.SET + else current_vpp + ) + self._check_source_v2_final_output_limits( + requested_vpp, + current_offset, + operation=operation, + ) + @staticmethod def _source_harmonic_runtime_profile( snapshot: SourceSnapshotV2, @@ -5533,6 +7188,7 @@ def _source_sweep_runtime_profile( *, channel: int, operation: str, + direction: SourceFeatureDirection = SourceFeatureDirection.CONFIGURE, ) -> SourceSweepCapabilityProfile: feature = next( ( @@ -5542,7 +7198,7 @@ def _source_sweep_runtime_profile( and candidate.scope is SourceFacetScope.CHANNEL and channel in candidate.channels and candidate.support is SupportState.SUPPORTED - and SourceFeatureDirection.CONFIGURE in candidate.directions + and direction in candidate.directions ), None, ) @@ -5573,6 +7229,61 @@ def _source_sweep_basic_runtime_profile( raise ConfigError(f"{operation} requires readable basic state at runtime") return feature.profile + @classmethod + def _source_sweep_v2_interlock_observations( + cls, + snapshot: SourceSnapshotV2, + channel: int, + features: tuple[SourceFeature, ...], + *, + operation: str, + ) -> tuple[tuple[SourceFeature, Observed[object]], ...]: + target = next((item for item in snapshot.channels if item.channel == channel), None) + if target is None: + raise ConfigError(f"{operation} target channel is absent from snapshot") + observations = { + SourceFeature.BURST: target.burst, + SourceFeature.MODULATION: target.modulation, + } + return tuple( + (feature, observations[feature]) + for feature in features + if feature in observations + ) + + @classmethod + def _validate_source_sweep_v2_interlocks( + cls, + snapshot: SourceSnapshotV2, + channel: int, + features: tuple[SourceFeature, ...], + *, + operation: str, + ) -> None: + expected_types = { + SourceFeature.BURST: BurstFacet, + SourceFeature.MODULATION: ModulationFacet, + } + for feature, observed in cls._source_sweep_v2_interlock_observations( + snapshot, + channel, + features, + operation=operation, + ): + expected_type = expected_types[feature] + if observed.availability is not Availability.VALUE or not isinstance( + observed.value, + expected_type, + ): + raise ConfigError( + f"{operation} requires readable inactive {feature.value} state" + ) + if ( + observed.value.enabled.availability is not Availability.VALUE + or observed.value.enabled.value is not False + ): + raise ConfigError(f"{operation} requires inactive {feature.value} state") + def _validate_source_sweep_v2_preflight( self, request: SourceSweepConfigureRequest, @@ -5594,12 +7305,20 @@ def _validate_source_sweep_v2_preflight( ) if request.spacing not in profile.spacing_modes: raise ConfigError(f"{operation} spacing is not supported by the runtime profile") - if SourceTriggerSource.INTERNAL not in profile.trigger_sources: - raise ConfigError(f"{operation} internal trigger is not supported by the runtime profile") + if request.trigger_source not in profile.trigger_sources: + raise ConfigError( + f"{operation} requested trigger source is not supported by the runtime profile" + ) if not profile.timing_readable or not profile.marker_readable: raise ConfigError(f"{operation} requires sweep timing and marker readback") if not profile.configuration_readable: raise ConfigError(f"{operation} requires configured internal sweep readback") + self._validate_source_sweep_v2_interlocks( + snapshot, + request.channel, + profile.implicit_disable_features, + operation=operation, + ) basic_profile = self._source_sweep_basic_runtime_profile( snapshot, channel=request.channel, @@ -5647,9 +7366,9 @@ def _validate_source_sweep_v2_readback( trigger = sweep.trigger.value if ( trigger.source.availability is not Availability.VALUE - or trigger.source.value is not SourceTriggerSource.INTERNAL + or trigger.source.value is not request.trigger_source ): - raise ConfigError(f"{operation} trigger source does not match scope") + raise ConfigError(f"{operation} trigger source does not match request") if ( trigger.slope.availability is not Availability.VALUE or trigger.slope.value is not SourceTriggerSlope.POSITIVE @@ -5695,18 +7414,58 @@ def _validate_source_sweep_v2_postcondition( request: SourceSweepConfigureRequest, result: SourceSweepConfigureResult, snapshot: SourceSnapshotV2, - basic: BasicWaveFacet, - sweep: SweepFacet, - output: OutputFacet, - ) -> None: - operation = "source.sweep_configure_v2" - if snapshot.consistency.state is not SnapshotConsistencyState.CONSISTENT: - raise ConfigError(f"{operation} postcondition snapshot is inconsistent") - if output.enabled.availability is not Availability.VALUE or output.enabled.value is not False: - raise ConfigError(f"{operation} postcondition reports output ON") - self._validate_source_sweep_v2_readback(request, basic, sweep, operation=operation) - if result.basic != basic or result.sweep != sweep: - raise ConfigError(f"{operation} result readback does not match postcondition") + basic: BasicWaveFacet, + sweep: SweepFacet, + output: OutputFacet, + ) -> None: + operation = "source.sweep_configure_v2" + if snapshot.consistency.state is not SnapshotConsistencyState.CONSISTENT: + raise ConfigError(f"{operation} postcondition snapshot is inconsistent") + if output.enabled.availability is not Availability.VALUE or output.enabled.value is not False: + raise ConfigError(f"{operation} postcondition reports output ON") + self._validate_source_sweep_v2_readback(request, basic, sweep, operation=operation) + profile = self._source_sweep_runtime_profile( + snapshot, + channel=request.channel, + operation=operation, + ) + self._validate_source_sweep_v2_interlocks( + snapshot, + request.channel, + profile.implicit_disable_features, + operation=operation, + ) + # The driver MAIN phase is write-only. Some instruments maintain + # query-only Sweep fields (for example center/span or a disabled + # marker's retained frequency) that a bounded configure request does + # not own. The independently acquired postcondition snapshot above, + # not the driver MAIN result, is therefore authoritative for those + # fields. Both result and snapshot are separately validated against + # the declared request and safety scope. + + @staticmethod + def _source_arbitrary_workspace_runtime_profile( + snapshot: SourceSnapshotV2, + *, + operation: str, + ) -> SourceArbitraryWorkspaceCapabilityProfile: + feature = next( + ( + candidate + for candidate in snapshot.runtime_profile.features + if candidate.feature is SourceFeature.ARBITRARY_WORKSPACE + and candidate.scope is SourceFacetScope.INSTRUMENT + and candidate.support is SupportState.SUPPORTED + and SourceFeatureDirection.CONFIGURE in candidate.directions + ), + None, + ) + if feature is None or not isinstance( + feature.profile, + SourceArbitraryWorkspaceCapabilityProfile, + ): + raise ConfigError(f"{operation} is not available for the runtime instrument") + return feature.profile @staticmethod def _source_arbitrary_runtime_profile( @@ -5882,125 +7641,509 @@ def _validate_source_arbitrary_storage_v2_postcondition( ): raise ConfigError(f"{operation} result does not match storage readback") - def _validate_source_arbitrary_select_v2_preflight( + def _validate_source_arbitrary_select_v2_preflight( + self, + request: SourceArbitrarySelectRequest, + snapshot: SourceSnapshotV2, + basic: BasicWaveFacet, + arbitrary: ArbitraryFacet, + output: OutputFacet, + ) -> None: + del basic, arbitrary + operation = "source.arbitrary_select_v2" + if snapshot.consistency.state is not SnapshotConsistencyState.CONSISTENT: + raise ConfigError(f"{operation} requires a fresh consistent snapshot") + if output.enabled.availability is not Availability.VALUE or output.enabled.value is not False: + raise ConfigError(f"{operation} requires target output OFF") + profile = self._source_arbitrary_runtime_profile( + snapshot, + channel=request.channel, + operation=operation, + ) + if not profile.selection_readable or not profile.storage_metadata_readable: + raise ConfigError(f"{operation} requires selected waveform and storage digest readback") + if request.playback_mode not in profile.playback_modes: + raise ConfigError(f"{operation} playback mode is not supported by the runtime profile") + if ( + request.playback_mode is SourceArbitraryPlaybackMode.TRUE_ARB + and not profile.sample_rate_readable + ): + raise ConfigError(f"{operation} true-ARB requires readable sample rate") + basic_profile = self._source_arbitrary_basic_runtime_profile( + snapshot, + channel=request.channel, + operation=operation, + ) + if SourceWaveformKind.ARBITRARY not in basic_profile.waveform_kinds: + raise ConfigError(f"{operation} requires arbitrary basic waveform support") + + @staticmethod + def _validate_source_arbitrary_select_v2_readback( + request: SourceArbitrarySelectRequest, + basic: BasicWaveFacet, + arbitrary: ArbitraryFacet, + *, + operation: str, + ) -> None: + if ( + basic.waveform_kind.availability is not Availability.VALUE + or basic.waveform_kind.value is not SourceWaveformKind.ARBITRARY + ): + raise ConfigError(f"{operation} basic waveform readback is not arbitrary") + if ( + arbitrary.selected_waveform_id.availability is not Availability.VALUE + or arbitrary.selected_waveform_id.value != request.slot_id + ): + raise ConfigError(f"{operation} selected waveform readback does not match request") + if ( + arbitrary.playback_mode.availability is not Availability.VALUE + or arbitrary.playback_mode.value is not request.playback_mode + ): + raise ConfigError(f"{operation} playback mode readback does not match request") + if arbitrary.storage_digest.availability is not Availability.VALUE: + raise ConfigError(f"{operation} requires storage digest readback") + if request.playback_mode is SourceArbitraryPlaybackMode.DDS: + if ( + arbitrary.playback_frequency_hz.availability is not Availability.VALUE + or arbitrary.playback_frequency_hz.value != request.playback_frequency_hz + ): + raise ConfigError( + f"{operation} DDS playback frequency readback does not match request" + ) + return + if ( + arbitrary.sample_rate_hz.availability is not Availability.VALUE + or arbitrary.sample_rate_hz.value != request.sample_rate_hz + ): + raise ConfigError(f"{operation} true-ARB sample rate readback does not match request") + + def _validate_source_arbitrary_select_v2_result( + self, + request: SourceArbitrarySelectRequest, + result: object, + ) -> None: + operation = "source.arbitrary_select_v2" + if not isinstance(result, SourceArbitrarySelectResult): + raise ConfigError( + "select_source_arbitrary_v2() returned an invalid SourceArbitrarySelectResult" + ) + if result.channel != request.channel: + raise ConfigError(f"{operation} result channel does not match request") + if result.output_enabled: + raise ConfigError(f"{operation} result reports output ON") + self._validate_source_arbitrary_select_v2_readback( + request, + result.basic, + result.arbitrary, + operation=operation, + ) + + def _validate_source_arbitrary_select_v2_postcondition( + self, + request: SourceArbitrarySelectRequest, + result: SourceArbitrarySelectResult, + snapshot: SourceSnapshotV2, + basic: BasicWaveFacet, + arbitrary: ArbitraryFacet, + output: OutputFacet, + ) -> None: + operation = "source.arbitrary_select_v2" + if snapshot.consistency.state is not SnapshotConsistencyState.CONSISTENT: + raise ConfigError(f"{operation} postcondition snapshot is inconsistent") + if output.enabled.availability is not Availability.VALUE or output.enabled.value is not False: + raise ConfigError(f"{operation} postcondition reports output ON") + self._validate_source_arbitrary_select_v2_readback( + request, + basic, + arbitrary, + operation=operation, + ) + if result.basic != basic or result.arbitrary != arbitrary: + raise ConfigError(f"{operation} result readback does not match postcondition") + + @staticmethod + def _validate_source_arbitrary_volatile_replace_v2_payload( + request: SourceArbitraryVolatileReplaceRequest, + payload: object, + ) -> None: + if not isinstance(payload, bytes): + raise ConfigError("source.arbitrary_volatile_replace_v2 payload must be bytes") + if len(payload) != request.payload_size_bytes: + raise ConfigError( + "source.arbitrary_volatile_replace_v2 payload size does not match the request" + ) + digest = "sha256:" + sha256(payload).hexdigest() + if digest != request.payload_sha256: + raise ConfigError( + "source.arbitrary_volatile_replace_v2 payload SHA-256 does not match the request" + ) + + def _validate_source_arbitrary_volatile_replace_v2_preflight( + self, + request: SourceArbitraryVolatileReplaceRequest, + snapshot: SourceSnapshotV2, + basic: BasicWaveFacet, + output: OutputFacet, + ) -> None: + del basic + operation = "source.arbitrary_volatile_replace_v2" + if snapshot.consistency.state is not SnapshotConsistencyState.CONSISTENT: + raise ConfigError(f"{operation} requires a fresh consistent snapshot") + if output.enabled.availability is not Availability.VALUE or output.enabled.value is not False: + raise ConfigError(f"{operation} requires target output OFF") + profile = self._source_arbitrary_runtime_profile( + snapshot, + channel=request.channel, + operation=operation, + ) + if not profile.selection_readable: + raise ConfigError(f"{operation} requires readable selected ARB state") + minimum = profile.volatile_replace_min_points + maximum = profile.volatile_replace_max_points + payload_maximum = profile.volatile_replace_max_payload_bytes + if minimum is None or maximum is None or payload_maximum is None: + raise ConfigError(f"{operation} requires volatile replace limits in the runtime profile") + if request.point_count < minimum or request.point_count > maximum: + raise ConfigError(f"{operation} point count exceeds the runtime profile") + if request.payload_size_bytes > payload_maximum: + raise ConfigError(f"{operation} payload size exceeds the runtime profile") + basic_profile = self._source_arbitrary_basic_runtime_profile( + snapshot, + channel=request.channel, + operation=operation, + ) + if SourceWaveformKind.ARBITRARY not in basic_profile.waveform_kinds: + raise ConfigError(f"{operation} requires arbitrary basic waveform support") + + @staticmethod + def _validate_source_arbitrary_volatile_replace_v2_result( + request: SourceArbitraryVolatileReplaceRequest, + result: object, + ) -> None: + operation = "source.arbitrary_volatile_replace_v2" + if not isinstance(result, SourceArbitraryVolatileReplaceResult): + raise ConfigError( + "replace_source_arbitrary_volatile_v2() returned an invalid " + "SourceArbitraryVolatileReplaceResult" + ) + if ( + result.channel != request.channel + or result.payload_sha256 != request.payload_sha256 + or result.payload_size_bytes != request.payload_size_bytes + or result.point_count != request.point_count + ): + raise ConfigError(f"{operation} result does not match the request") + if not result.write_completed: + raise ConfigError(f"{operation} result does not prove the write") + + @staticmethod + def _validate_source_arbitrary_volatile_replace_v2_postcondition( + result: SourceArbitraryVolatileReplaceResult, + snapshot: SourceSnapshotV2, + basic: BasicWaveFacet, + arbitrary: ArbitraryFacet, + output: OutputFacet, + ) -> None: + operation = "source.arbitrary_volatile_replace_v2" + if snapshot.consistency.state is not SnapshotConsistencyState.CONSISTENT: + raise ConfigError(f"{operation} postcondition snapshot is inconsistent") + if output.enabled.availability is not Availability.VALUE or output.enabled.value is not False: + raise ConfigError(f"{operation} postcondition reports output ON") + if ( + basic.waveform_kind.availability is not Availability.VALUE + or basic.waveform_kind.value is not SourceWaveformKind.ARBITRARY + ): + raise ConfigError(f"{operation} basic waveform readback is not arbitrary") + if ( + arbitrary.selected_waveform_id.availability is not Availability.VALUE + or arbitrary.selected_waveform_id.value != result.selected_waveform_id + ): + raise ConfigError(f"{operation} selected waveform readback does not match result") + + @staticmethod + def _validate_source_arbitrary_workspace_volatile_replace_v2_payload( + request: SourceArbitraryWorkspaceVolatileReplaceRequest, + payload: object, + ) -> None: + if not isinstance(payload, bytes): + raise ConfigError( + "source.arbitrary_workspace_volatile_replace_v2 payload must be bytes" + ) + if len(payload) != request.payload_size_bytes: + raise ConfigError( + "source.arbitrary_workspace_volatile_replace_v2 payload size does not match " + "the request" + ) + digest = "sha256:" + sha256(payload).hexdigest() + if digest != request.payload_sha256: + raise ConfigError( + "source.arbitrary_workspace_volatile_replace_v2 payload SHA-256 does not " + "match the request" + ) + + @staticmethod + def _validate_source_arbitrary_workspace_volatile_replace_v2_preflight( + request: SourceArbitraryWorkspaceVolatileReplaceRequest, + snapshot: SourceSnapshotV2, + profile: SourceArbitraryWorkspaceCapabilityProfile, + outputs: tuple[OutputFacet, ...], + ) -> None: + operation = "source.arbitrary_workspace_volatile_replace_v2" + if snapshot.consistency.state is not SnapshotConsistencyState.CONSISTENT: + raise ConfigError(f"{operation} requires a fresh consistent snapshot") + if any( + output.enabled.availability is not Availability.VALUE or output.enabled.value is not False + for output in outputs + ): + raise ConfigError(f"{operation} requires every topology output OFF") + if request.point_count < profile.volatile_replace_min_points or request.point_count > profile.volatile_replace_max_points: + raise ConfigError(f"{operation} point count exceeds the runtime profile") + if request.payload_size_bytes > profile.volatile_replace_max_payload_bytes: + raise ConfigError(f"{operation} payload size exceeds the runtime profile") + + @staticmethod + def _validate_source_arbitrary_workspace_volatile_replace_v2_result( + request: SourceArbitraryWorkspaceVolatileReplaceRequest, + profile: SourceArbitraryWorkspaceCapabilityProfile, + result: object, + ) -> None: + operation = "source.arbitrary_workspace_volatile_replace_v2" + if not isinstance(result, SourceArbitraryWorkspaceVolatileReplaceResult): + raise ConfigError( + "replace_source_arbitrary_workspace_volatile_v2() returned an invalid " + "SourceArbitraryWorkspaceVolatileReplaceResult" + ) + if ( + result.workspace_id != profile.workspace_id + or result.payload_sha256 != request.payload_sha256 + or result.payload_size_bytes != request.payload_size_bytes + or result.point_count != request.point_count + ): + raise ConfigError(f"{operation} result does not match the request") + if not result.write_completed: + raise ConfigError(f"{operation} result does not prove the write") + + @staticmethod + def _validate_source_arbitrary_workspace_volatile_replace_v2_postcondition( + snapshot: SourceSnapshotV2, + outputs: tuple[OutputFacet, ...], + ) -> None: + operation = "source.arbitrary_workspace_volatile_replace_v2" + if snapshot.consistency.state is not SnapshotConsistencyState.CONSISTENT: + raise ConfigError(f"{operation} postcondition snapshot is inconsistent") + if any( + output.enabled.availability is not Availability.VALUE or output.enabled.value is not False + for output in outputs + ): + raise ConfigError(f"{operation} postcondition reports an output ON") + + @staticmethod + def _source_counter_configuration_field( + request: SourceCounterConfigureRequest, + ) -> SourceCounterConfigurationField: + fields = tuple( + field + for field, patch_value in ( + (SourceCounterConfigurationField.COUPLING, request.patch.coupling), + (SourceCounterConfigurationField.IMPEDANCE_OHM, request.patch.impedance_ohm), + (SourceCounterConfigurationField.ATTENUATION, request.patch.attenuation), + (SourceCounterConfigurationField.TRIGGER_LEVEL_V, request.patch.trigger_level_v), + (SourceCounterConfigurationField.STATISTICS_ENABLED, request.patch.statistics_enabled), + ) + if patch_value.action is PatchAction.SET + ) + if len(fields) != 1: # pragma: no cover - request model already enforces this. + raise ConfigError("source.counter_configure_v2 requires exactly one Counter field") + return fields[0] + + @staticmethod + def _source_counter_configuration_expected_value( + request: SourceCounterConfigureRequest, + field: SourceCounterConfigurationField, + ) -> object: + values = { + SourceCounterConfigurationField.COUPLING: request.patch.coupling.value, + SourceCounterConfigurationField.IMPEDANCE_OHM: request.patch.impedance_ohm.value, + SourceCounterConfigurationField.ATTENUATION: request.patch.attenuation.value, + SourceCounterConfigurationField.TRIGGER_LEVEL_V: request.patch.trigger_level_v.value, + SourceCounterConfigurationField.STATISTICS_ENABLED: request.patch.statistics_enabled.value, + } + value = values[field] + if value is None: # pragma: no cover - request model already enforces SET values. + raise ConfigError("source.counter_configure_v2 Counter value is missing") + return value + + @staticmethod + def _source_counter_configuration_observed_value( + state: SourceCounterInputState, + field: SourceCounterConfigurationField, + *, + operation: str, + ) -> object: + observed = { + SourceCounterConfigurationField.COUPLING: state.coupling, + SourceCounterConfigurationField.IMPEDANCE_OHM: state.impedance_ohm, + SourceCounterConfigurationField.ATTENUATION: state.attenuation, + SourceCounterConfigurationField.TRIGGER_LEVEL_V: state.trigger_level_v, + SourceCounterConfigurationField.STATISTICS_ENABLED: state.statistics_enabled, + }[field] + if observed.availability is not Availability.VALUE: + raise ConfigError( + f"{operation} requires readable {field.value} Counter configuration" + ) + return observed.value + + def _validate_source_counter_configure_v2_preflight( self, - request: SourceArbitrarySelectRequest, + request: SourceCounterConfigureRequest, snapshot: SourceSnapshotV2, - basic: BasicWaveFacet, - arbitrary: ArbitraryFacet, - output: OutputFacet, - ) -> None: - del basic, arbitrary - operation = "source.arbitrary_select_v2" + counter: SourceCounterInputState, + profile: SourceCounterCapabilityProfile, + ) -> bool: + operation = "source.counter_configure_v2" if snapshot.consistency.state is not SnapshotConsistencyState.CONSISTENT: raise ConfigError(f"{operation} requires a fresh consistent snapshot") - if output.enabled.availability is not Availability.VALUE or output.enabled.value is not False: - raise ConfigError(f"{operation} requires target output OFF") - profile = self._source_arbitrary_runtime_profile( - snapshot, - channel=request.channel, + if not profile.configuration_readable: + raise ConfigError(f"{operation} requires readable Counter configuration") + field = self._source_counter_configuration_field(request) + if field not in profile.configurable_fields: + raise ConfigError(f"{operation} field is not configurable in the runtime profile") + current = self._source_counter_configuration_observed_value( + counter, + field, operation=operation, ) - if not profile.selection_readable or not profile.storage_metadata_readable: - raise ConfigError(f"{operation} requires selected waveform and storage digest readback") - if request.playback_mode not in profile.playback_modes: - raise ConfigError(f"{operation} playback mode is not supported by the runtime profile") - if ( - request.playback_mode is SourceArbitraryPlaybackMode.TRUE_ARB - and not profile.sample_rate_readable - ): - raise ConfigError(f"{operation} true-ARB requires readable sample rate") - basic_profile = self._source_arbitrary_basic_runtime_profile( - snapshot, - channel=request.channel, + return current != self._source_counter_configuration_expected_value(request, field) + + def _validate_source_counter_configure_v2_result( + self, + request: SourceCounterConfigureRequest, + result: object, + ) -> None: + operation = "source.counter_configure_v2" + if not isinstance(result, SourceCounterConfigureResult): + raise ConfigError( + "configure_source_counter_v2() returned an invalid SourceCounterConfigureResult" + ) + if result.input_id != request.input_id: + raise ConfigError(f"{operation} result input_id does not match request") + field = self._source_counter_configuration_field(request) + if self._source_counter_configuration_observed_value( + result.state, + field, operation=operation, - ) - if SourceWaveformKind.ARBITRARY not in basic_profile.waveform_kinds: - raise ConfigError(f"{operation} requires arbitrary basic waveform support") + ) != self._source_counter_configuration_expected_value(request, field): + raise ConfigError(f"{operation} result does not match the request") + + def _validate_source_counter_configure_v2_postcondition( + self, + request: SourceCounterConfigureRequest, + result: SourceCounterConfigureResult, + snapshot: SourceSnapshotV2, + counter: SourceCounterInputState, + profile: SourceCounterCapabilityProfile, + ) -> None: + operation = "source.counter_configure_v2" + if snapshot.consistency.state is not SnapshotConsistencyState.CONSISTENT: + raise ConfigError(f"{operation} postcondition snapshot is inconsistent") + if not profile.configuration_readable: + raise ConfigError(f"{operation} postcondition lacks readable Counter configuration") + self._validate_source_counter_configure_v2_result(request, result) + field = self._source_counter_configuration_field(request) + if self._source_counter_configuration_observed_value( + counter, + field, + operation=operation, + ) != self._source_counter_configuration_expected_value(request, field): + raise ConfigError(f"{operation} readback does not match the request") @staticmethod - def _validate_source_arbitrary_select_v2_readback( - request: SourceArbitrarySelectRequest, - basic: BasicWaveFacet, - arbitrary: ArbitraryFacet, + def _validate_source_counter_enable_v2_preflight( + request: SourceCounterEnableRequest, + snapshot: SourceSnapshotV2, + counter: SourceCounterInputState, + profile: SourceCounterCapabilityProfile, *, operation: str, - ) -> None: - if ( - basic.waveform_kind.availability is not Availability.VALUE - or basic.waveform_kind.value is not SourceWaveformKind.ARBITRARY - ): - raise ConfigError(f"{operation} basic waveform readback is not arbitrary") - if ( - arbitrary.selected_waveform_id.availability is not Availability.VALUE - or arbitrary.selected_waveform_id.value != request.slot_id - ): - raise ConfigError(f"{operation} selected waveform readback does not match request") - if ( - arbitrary.playback_mode.availability is not Availability.VALUE - or arbitrary.playback_mode.value is not request.playback_mode - ): - raise ConfigError(f"{operation} playback mode readback does not match request") - if arbitrary.storage_digest.availability is not Availability.VALUE: - raise ConfigError(f"{operation} requires storage digest readback") - if request.playback_mode is SourceArbitraryPlaybackMode.DDS: - if ( - arbitrary.playback_frequency_hz.availability is not Availability.VALUE - or arbitrary.playback_frequency_hz.value != request.playback_frequency_hz - ): - raise ConfigError( - f"{operation} DDS playback frequency readback does not match request" - ) - return - if ( - arbitrary.sample_rate_hz.availability is not Availability.VALUE - or arbitrary.sample_rate_hz.value != request.sample_rate_hz - ): - raise ConfigError(f"{operation} true-ARB sample rate readback does not match request") + ) -> bool: + if snapshot.consistency.state is not SnapshotConsistencyState.CONSISTENT: + raise ConfigError(f"{operation} requires a fresh consistent snapshot") + if not profile.enabled_configurable: + raise ConfigError(f"{operation} is not configurable in the runtime profile") + if counter.enabled.availability is not Availability.VALUE: + raise ConfigError(f"{operation} requires readable Counter enabled state") + return counter.enabled.value is not request.enabled - def _validate_source_arbitrary_select_v2_result( - self, - request: SourceArbitrarySelectRequest, + @staticmethod + def _validate_source_counter_enable_v2_result( + request: SourceCounterEnableRequest, result: object, + *, + operation: str, ) -> None: - operation = "source.arbitrary_select_v2" - if not isinstance(result, SourceArbitrarySelectResult): + if not isinstance(result, SourceCounterEnableResult): raise ConfigError( - "select_source_arbitrary_v2() returned an invalid SourceArbitrarySelectResult" + "set_source_counter_enabled_v2() returned an invalid SourceCounterEnableResult" ) - if result.channel != request.channel: - raise ConfigError(f"{operation} result channel does not match request") - if result.output_enabled: - raise ConfigError(f"{operation} result reports output ON") - self._validate_source_arbitrary_select_v2_readback( - request, - result.basic, - result.arbitrary, - operation=operation, - ) + if result.input_id != request.input_id or result.enabled is not request.enabled: + raise ConfigError(f"{operation} result does not match the request") - def _validate_source_arbitrary_select_v2_postcondition( + def _validate_source_counter_enable_v2_postcondition( self, - request: SourceArbitrarySelectRequest, - result: SourceArbitrarySelectResult, + request: SourceCounterEnableRequest, + result: SourceCounterEnableResult, snapshot: SourceSnapshotV2, - basic: BasicWaveFacet, - arbitrary: ArbitraryFacet, - output: OutputFacet, + counter: SourceCounterInputState, + profile: SourceCounterCapabilityProfile, + *, + operation: str, ) -> None: - operation = "source.arbitrary_select_v2" if snapshot.consistency.state is not SnapshotConsistencyState.CONSISTENT: raise ConfigError(f"{operation} postcondition snapshot is inconsistent") - if output.enabled.availability is not Availability.VALUE or output.enabled.value is not False: - raise ConfigError(f"{operation} postcondition reports output ON") - self._validate_source_arbitrary_select_v2_readback( - request, - basic, - arbitrary, - operation=operation, - ) - if result.basic != basic or result.arbitrary != arbitrary: - raise ConfigError(f"{operation} result readback does not match postcondition") + if not profile.enabled_configurable: + raise ConfigError(f"{operation} postcondition lacks runtime enable support") + self._validate_source_counter_enable_v2_result(request, result, operation=operation) + if counter.enabled.availability is not Availability.VALUE or ( + counter.enabled.value is not request.enabled + ): + raise ConfigError(f"{operation} enabled readback does not match the request") + + @staticmethod + def _validate_source_counter_measure_v2_preflight( + snapshot: SourceSnapshotV2, + counter: SourceCounterInputState, + profile: SourceCounterCapabilityProfile, + *, + operation: str, + ) -> None: + if snapshot.consistency.state is not SnapshotConsistencyState.CONSISTENT: + raise ConfigError(f"{operation} requires a fresh consistent snapshot") + if counter.enabled.availability is not Availability.VALUE or counter.enabled.value is not True: + raise ConfigError(f"{operation} requires Counter enabled") + if profile.query_effect not in { + SourceQueryEffect.PURE_READ, + SourceQueryEffect.STATEFUL_CONSUMING_READ, + }: + raise ConfigError(f"{operation} requires a known read-only Counter query effect") + + @staticmethod + def _validate_source_counter_measure_v2_result( + request: SourceCounterMeasureRequest, + result: object, + profile: SourceCounterCapabilityProfile, + *, + operation: str, + ) -> None: + if not isinstance(result, SourceCounterMeasureResult): + raise ConfigError( + "measure_source_counter_v2() returned an invalid SourceCounterMeasureResult" + ) + if result.input_id != request.input_id: + raise ConfigError(f"{operation} result input_id does not match request") + if not { + measurement.kind for measurement in result.measurements + } <= set(profile.measurement_kinds): + raise ConfigError(f"{operation} result includes an unsupported measurement kind") @staticmethod def _source_burst_runtime_profile( @@ -6008,6 +8151,7 @@ def _source_burst_runtime_profile( *, channel: int, operation: str, + direction: SourceFeatureDirection = SourceFeatureDirection.CONFIGURE, ) -> SourceBurstCapabilityProfile: feature = next( ( @@ -6017,7 +8161,7 @@ def _source_burst_runtime_profile( and candidate.scope is SourceFacetScope.CHANNEL and channel in candidate.channels and candidate.support is SupportState.SUPPORTED - and SourceFeatureDirection.CONFIGURE in candidate.directions + and direction in candidate.directions ), None, ) @@ -6045,12 +8189,22 @@ def _validate_source_burst_v2_preflight( ) if SourceBurstMode.TRIGGERED not in profile.modes: raise ConfigError(f"{operation} triggered mode is not supported by the runtime profile") - if SourceTriggerSource.INTERNAL not in profile.trigger_sources: - raise ConfigError(f"{operation} internal trigger is not supported by the runtime profile") + if request.trigger_source not in profile.trigger_sources: + raise ConfigError( + f"{operation} requested trigger source is not supported by the runtime profile" + ) if not profile.timing_readable: raise ConfigError(f"{operation} requires burst timing readback") - if not profile.triggered_internal_configuration_readable: - raise ConfigError(f"{operation} requires configured internal triggered burst readback") + configuration_readable = ( + profile.triggered_internal_configuration_readable + if request.trigger_source is SourceTriggerSource.INTERNAL + else profile.triggered_manual_configuration_readable + ) + if not configuration_readable: + raise ConfigError( + f"{operation} requires configured {request.trigger_source.value} " + "triggered burst readback" + ) @staticmethod def _validate_source_burst_v2_readback( @@ -6082,9 +8236,9 @@ def _validate_source_burst_v2_readback( trigger = burst.trigger.value if ( trigger.source.availability is not Availability.VALUE - or trigger.source.value is not SourceTriggerSource.INTERNAL + or trigger.source.value is not request.trigger_source ): - raise ConfigError(f"{operation} trigger source does not match scope") + raise ConfigError(f"{operation} trigger source does not match request") if ( trigger.slope.availability is not Availability.VALUE or trigger.slope.value is not SourceTriggerSlope.POSITIVE @@ -6132,6 +8286,111 @@ def _validate_source_burst_v2_postcondition( raise ConfigError(f"{operation} postcondition reports output ON") self._validate_source_burst_v2_readback(request, burst, operation=operation) + def _validate_source_fire_v2_preflight( + self, + request: SourceFireRequest, + *, + feature: SourceFeature, + snapshot: SourceSnapshotV2, + basic: BasicWaveFacet, + feature_state: BurstFacet | SweepFacet, + output: OutputFacet, + configuration_digest: str, + operation: str, + ) -> None: + if snapshot.consistency.state is not SnapshotConsistencyState.CONSISTENT: + raise ConfigError(f"{operation} requires a fresh consistent snapshot") + if output.enabled.availability is not Availability.VALUE or output.enabled.value is not True: + raise ConfigError(f"{operation} requires target output ON") + if feature is SourceFeature.BURST: + if not isinstance(feature_state, BurstFacet): + raise ConfigError(f"{operation} requires readable burst state") + profile = self._source_burst_runtime_profile( + snapshot, + channel=request.channel, + operation=operation, + direction=SourceFeatureDirection.FIRE, + ) + if ( + SourceBurstMode.TRIGGERED not in profile.modes + or SourceTriggerSource.MANUAL not in profile.trigger_sources + or not profile.timing_readable + or not profile.triggered_manual_configuration_readable + ): + raise ConfigError(f"{operation} runtime Burst profile cannot prove fire scope") + elif feature is SourceFeature.SWEEP: + if not isinstance(feature_state, SweepFacet): + raise ConfigError(f"{operation} requires readable sweep state") + profile = self._source_sweep_runtime_profile( + snapshot, + channel=request.channel, + operation=operation, + direction=SourceFeatureDirection.FIRE, + ) + if ( + SourceTriggerSource.MANUAL not in profile.trigger_sources + or not profile.timing_readable + or not profile.marker_readable + or not profile.configuration_readable + ): + raise ConfigError(f"{operation} runtime Sweep profile cannot prove fire scope") + if ( + basic.frequency_mode.availability is not Availability.VALUE + or basic.frequency_mode.value is not SourceFrequencyMode.SWEEP + ): + raise ConfigError(f"{operation} requires sweep frequency mode") + else: # pragma: no cover - private callers pass one of the two declared features. + raise ValueError("source fire feature must be Burst or Sweep") + trigger = feature_state.trigger + if ( + trigger.availability is not Availability.VALUE + or not isinstance(trigger.value, SourceTriggerState) + or trigger.value.source.availability is not Availability.VALUE + or trigger.value.source.value is not SourceTriggerSource.MANUAL + ): + raise ConfigError(f"{operation} requires manual trigger source") + if source_v2_digest(feature_state) != configuration_digest: + raise ConfigError( + f"{operation} configured feature state no longer matches the same-session receipt" + ) + vpp, offset = self._source_v2_amplitude_offset(basic, operation=operation) + self._check_source_v2_final_output_limits(vpp, offset, operation=operation) + + @staticmethod + def _validate_source_fire_v2_result( + request: SourceFireRequest, + result: object, + *, + operation: str, + ) -> None: + if not isinstance(result, SourceFireResult): + raise ConfigError(f"{operation} driver returned an invalid SourceFireResult") + if result.channel != request.channel: + raise ConfigError(f"{operation} result channel does not match request") + + def _validate_source_fire_v2_postcondition( + self, + request: SourceFireRequest, + *, + feature: SourceFeature, + snapshot: SourceSnapshotV2, + basic: BasicWaveFacet, + feature_state: BurstFacet | SweepFacet, + output: OutputFacet, + configuration_digest: str, + operation: str, + ) -> None: + self._validate_source_fire_v2_preflight( + request, + feature=feature, + snapshot=snapshot, + basic=basic, + feature_state=feature_state, + output=output, + configuration_digest=configuration_digest, + operation=operation, + ) + @staticmethod def _source_pulse_runtime_profile( snapshot: SourceSnapshotV2, @@ -6259,37 +8518,99 @@ def _validate_source_basic_v2_result( offset, operation="source.basic_configure_v2", ) - self._validate_source_basic_v2_patch_readback(request, result.basic) + self._validate_source_basic_v2_patch_readback(request, result.basic) + + def _validate_source_basic_live_v2_result( + self, + request: SourceBasicConfigureRequest, + result: object, + ) -> None: + operation = "source.basic_live_configure_v2" + if not isinstance(result, SourceBasicLiveConfigureResult): + raise ConfigError( + "configure_source_basic_live_v2() returned an invalid " + "SourceBasicLiveConfigureResult" + ) + if result.channel != request.channel: + raise ConfigError(f"{operation} result channel does not match request") + if not result.output_enabled: + raise ConfigError(f"{operation} result reports output OFF") + vpp, offset = self._source_v2_amplitude_offset( + result.basic, + operation=operation, + ) + self._check_source_v2_final_output_limits(vpp, offset, operation=operation) + self._validate_source_basic_v2_patch_readback( + request, + result.basic, + operation=operation, + ) + + def _validate_source_basic_v2_postcondition( + self, + request: SourceBasicConfigureRequest, + result: SourceBasicConfigureResult, + snapshot: SourceSnapshotV2, + basic: BasicWaveFacet, + output: OutputFacet, + ) -> None: + if snapshot.consistency.state is not SnapshotConsistencyState.CONSISTENT: + raise ConfigError("source.basic_configure_v2 postcondition snapshot is inconsistent") + if output.enabled.availability is not Availability.VALUE or output.enabled.value is not False: + raise ConfigError("source.basic_configure_v2 postcondition reports output ON") + self._validate_source_basic_v2_patch_readback(request, basic) + result_vpp, result_offset = self._source_v2_amplitude_offset( + result.basic, + operation="source.basic_configure_v2", + ) + post_vpp, post_offset = self._source_v2_amplitude_offset( + basic, + operation="source.basic_configure_v2", + ) + if (result_vpp, result_offset) != (post_vpp, post_offset): + raise ConfigError( + "source.basic_configure_v2 final amplitude or offset readback does not match" + ) + self._check_source_v2_final_output_limits( + post_vpp, + post_offset, + operation="source.basic_configure_v2", + ) - def _validate_source_basic_v2_postcondition( + def _validate_source_basic_live_v2_postcondition( self, request: SourceBasicConfigureRequest, - result: SourceBasicConfigureResult, + result: SourceBasicLiveConfigureResult, snapshot: SourceSnapshotV2, basic: BasicWaveFacet, output: OutputFacet, ) -> None: + operation = "source.basic_live_configure_v2" if snapshot.consistency.state is not SnapshotConsistencyState.CONSISTENT: - raise ConfigError("source.basic_configure_v2 postcondition snapshot is inconsistent") - if output.enabled.availability is not Availability.VALUE or output.enabled.value is not False: - raise ConfigError("source.basic_configure_v2 postcondition reports output ON") - self._validate_source_basic_v2_patch_readback(request, basic) + raise ConfigError(f"{operation} postcondition snapshot is inconsistent") + if output.enabled.availability is not Availability.VALUE or output.enabled.value is not True: + raise ConfigError(f"{operation} postcondition reports output OFF") + self._validate_source_basic_v2_patch_readback( + request, + basic, + operation=operation, + ) result_vpp, result_offset = self._source_v2_amplitude_offset( result.basic, - operation="source.basic_configure_v2", + operation=operation, ) post_vpp, post_offset = self._source_v2_amplitude_offset( basic, - operation="source.basic_configure_v2", + operation=operation, ) if (result_vpp, result_offset) != (post_vpp, post_offset): raise ConfigError( - "source.basic_configure_v2 final amplitude or offset readback does not match" + f"{operation} final amplitude or offset readback does not match" ) self._check_source_v2_final_output_limits( post_vpp, post_offset, - operation="source.basic_configure_v2", + operation=operation, ) @staticmethod @@ -6315,6 +8636,8 @@ def _source_v2_amplitude_offset( def _validate_source_basic_v2_patch_readback( request: SourceBasicConfigureRequest, basic: BasicWaveFacet, + *, + operation: str = "source.basic_configure_v2", ) -> None: patch = request.patch values = ( @@ -6332,16 +8655,16 @@ def _validate_source_basic_v2_patch_readback( continue if observed.availability is not Availability.VALUE or observed.value != patch_value.value: raise ConfigError( - f"source.basic_configure_v2 {name} readback does not match request" + f"{operation} {name} readback does not match request" ) if patch.amplitude_vpp.action is PatchAction.SET: actual_vpp, _ = SourceService._source_v2_amplitude_offset( basic, - operation="source.basic_configure_v2", + operation=operation, ) if actual_vpp != patch.amplitude_vpp.value: raise ConfigError( - "source.basic_configure_v2 amplitude_vpp readback does not match request" + f"{operation} amplitude_vpp readback does not match request" ) def _check_source_v2_final_output_limits( @@ -6615,6 +8938,9 @@ def _recover_source_v2_outputs_off( return {"status": "not_attempted", "reason": "output_method_unavailable"} if len(channels) != len(output_fields) or len(channels) > context.operation_contract.recovery_max_steps: return {"status": "not_attempted", "reason": "recovery_range_unavailable"} + attempted_channels: list[int] = [] + failed_channels: list[int] = [] + halted_reason: str | None = None try: safe_state = context.make_phase_spec( SourceOperationPhase.FAILURE_SAFE_STATE, @@ -6624,21 +8950,44 @@ def _recover_source_v2_outputs_off( ) with context.authorize_phase(safe_state): for channel in channels: - result = cast(SourceOutputV2Driver, source).set_source_output_v2( - SourceOutputRequest(channel=channel, enabled=False) - ) - if ( - not isinstance(result, SourceOutputResult) - or result.channel != channel - or result.enabled - ): - raise ConfigError(f"{operation} recovery OFF is not proven") + if session_state.health is SessionHealth.POISONED: + halted_reason = "session_poisoned" + break + attempted_channels.append(channel) + try: + result = cast(SourceOutputV2Driver, source).set_source_output_v2( + SourceOutputRequest(channel=channel, enabled=False) + ) + if ( + not isinstance(result, SourceOutputResult) + or result.channel != channel + or result.enabled + ): + raise ConfigError(f"{operation} recovery OFF is not proven") + except BaseException: + failed_channels.append(channel) + if session_state.health is SessionHealth.POISONED: + halted_reason = "session_poisoned" + break except BaseException: return { "status": "off_failed", "channels": list(channels), + "attempted_channels": attempted_channels, + "failed_channels": failed_channels, + "session_health": session_state.health.value, + } + if failed_channels or len(attempted_channels) != len(channels): + recovery = { + "status": "off_failed", + "channels": list(channels), + "attempted_channels": attempted_channels, + "failed_channels": failed_channels, "session_health": session_state.health.value, } + if halted_reason is not None: + recovery["reason"] = halted_reason + return recovery if session_state.health is SessionHealth.POISONED: return { "status": "off_sent_unverified", @@ -6675,26 +9024,248 @@ def _recover_source_v2_outputs_off( io_kind="query", fields=output_fields, ) - except BaseException: - return { - "status": "off_sent_unverified", - "channels": list(channels), - "session_health": session_state.health.value, + except BaseException: + return { + "status": "off_sent_unverified", + "channels": list(channels), + "session_health": session_state.health.value, + } + return { + "status": "off_verified", + "channels": list(channels), + "session_health": session_state.health.value, + } + + def _source_basic_v2_artifact( + self, + *, + context: SourceOperationContextCoordinator, + request: SourceBasicConfigureRequest, + preflight_snapshot: SourceSnapshotV2 | None, + postcondition_snapshot: SourceSnapshotV2 | None, + result: SourceBasicConfigureResult | SourceBasicLiveConfigureResult | None, + recovery: dict[str, object] | None = None, + capability: str = "source.basic_configure_v2", + output_expected: str = "off", + ) -> dict[str, object]: + artifact = context.artifact() + descriptor_digest = ( + None + if preflight_snapshot is None + else preflight_snapshot.runtime_profile.descriptor_digest + ) + artifact["capability_decision"] = { + "capability": capability, + "contract_version": SOURCE_CONTRACT_VERSION, + "descriptor_digest": descriptor_digest, + } + artifact["request"] = source_v2_to_data(request) + if preflight_snapshot is not None: + artifact["preflight"] = { + "target_channel": request.channel, + "snapshot_digest": source_v2_digest(preflight_snapshot), + "consistency": preflight_snapshot.consistency.state.value, + } + if result is not None: + artifact["mutation"] = {"result": source_v2_to_data(result)} + if postcondition_snapshot is not None: + artifact["postcondition"] = { + "snapshot_digest": source_v2_digest(postcondition_snapshot), + "consistency": postcondition_snapshot.consistency.state.value, + } + if recovery is not None: + artifact["recovery"] = dict(recovery) + artifact["final_state"] = { + "session_health": context.session_state.health.value, + "output_expected": output_expected, + } + artifact["evidence_refs"] = sorted( + { + evidence_ref + for feature in ( + () + if preflight_snapshot is None + else preflight_snapshot.runtime_profile.features + ) + for evidence_ref in feature.evidence_refs + } + ) + return artifact + + def _source_harmonic_v2_artifact( + self, + *, + context: SourceOperationContextCoordinator, + request: SourceHarmonicConfigureRequest, + preflight_snapshot: SourceSnapshotV2 | None, + postcondition_snapshot: SourceSnapshotV2 | None, + result: SourceHarmonicConfigureResult | None, + recovery: dict[str, object] | None = None, + ) -> dict[str, object]: + artifact = context.artifact() + descriptor_digest = ( + None + if preflight_snapshot is None + else preflight_snapshot.runtime_profile.descriptor_digest + ) + artifact["capability_decision"] = { + "capability": "source.harmonics_configure_v2", + "contract_version": SOURCE_CONTRACT_VERSION, + "descriptor_digest": descriptor_digest, + } + artifact["request"] = source_v2_to_data(request) + if preflight_snapshot is not None: + artifact["preflight"] = { + "target_channel": request.channel, + "snapshot_digest": source_v2_digest(preflight_snapshot), + "consistency": preflight_snapshot.consistency.state.value, + } + if result is not None: + artifact["mutation"] = {"result": source_v2_to_data(result)} + if postcondition_snapshot is not None: + artifact["postcondition"] = { + "snapshot_digest": source_v2_digest(postcondition_snapshot), + "consistency": postcondition_snapshot.consistency.state.value, + } + if recovery is not None: + artifact["recovery"] = dict(recovery) + artifact["final_state"] = { + "session_health": context.session_state.health.value, + "output_expected": "off", + } + artifact["evidence_refs"] = sorted( + { + evidence_ref + for feature in ( + () + if preflight_snapshot is None + else preflight_snapshot.runtime_profile.features + ) + for evidence_ref in feature.evidence_refs + } + ) + return artifact + + def _source_harmonic_disable_v2_artifact( + self, + *, + context: SourceOperationContextCoordinator, + request: SourceHarmonicDisableRequest, + preflight_snapshot: SourceSnapshotV2 | None, + postcondition_snapshot: SourceSnapshotV2 | None, + result: SourceHarmonicDisableResult | None, + wrote_main: bool, + recovery: dict[str, object] | None = None, + ) -> dict[str, object]: + artifact = context.artifact() + descriptor_digest = ( + None + if preflight_snapshot is None + else preflight_snapshot.runtime_profile.descriptor_digest + ) + artifact["capability_decision"] = { + "capability": "source.harmonics_disable_v2", + "contract_version": SOURCE_CONTRACT_VERSION, + "descriptor_digest": descriptor_digest, + } + artifact["request"] = source_v2_to_data(request) + if preflight_snapshot is not None: + artifact["preflight"] = { + "target_channel": request.channel, + "snapshot_digest": source_v2_digest(preflight_snapshot), + "consistency": preflight_snapshot.consistency.state.value, + } + if result is not None: + artifact["mutation"] = { + "status": "written" if wrote_main else "already_at_target", + "result": source_v2_to_data(result), + } + if postcondition_snapshot is not None: + artifact["postcondition"] = { + "snapshot_digest": source_v2_digest(postcondition_snapshot), + "consistency": postcondition_snapshot.consistency.state.value, + } + if recovery is not None: + artifact["recovery"] = dict(recovery) + artifact["final_state"] = { + "session_health": context.session_state.health.value, + "output_expected": "off", + } + artifact["evidence_refs"] = sorted( + { + evidence_ref + for feature in ( + () + if preflight_snapshot is None + else preflight_snapshot.runtime_profile.features + ) + for evidence_ref in feature.evidence_refs + } + ) + return artifact + + def _source_modulation_v2_artifact( + self, + *, + context: SourceOperationContextCoordinator, + request: SourceModulationConfigureRequest, + preflight_snapshot: SourceSnapshotV2 | None, + postcondition_snapshot: SourceSnapshotV2 | None, + result: SourceModulationConfigureResult | None, + recovery: dict[str, object] | None = None, + ) -> dict[str, object]: + artifact = context.artifact() + descriptor_digest = ( + None + if preflight_snapshot is None + else preflight_snapshot.runtime_profile.descriptor_digest + ) + artifact["capability_decision"] = { + "capability": "source.modulation_configure_v2", + "contract_version": SOURCE_CONTRACT_VERSION, + "descriptor_digest": descriptor_digest, + } + artifact["request"] = source_v2_to_data(request) + if preflight_snapshot is not None: + artifact["preflight"] = { + "target_channel": request.channel, + "snapshot_digest": source_v2_digest(preflight_snapshot), + "consistency": preflight_snapshot.consistency.state.value, + } + if result is not None: + artifact["mutation"] = {"result": source_v2_to_data(result)} + if postcondition_snapshot is not None: + artifact["postcondition"] = { + "snapshot_digest": source_v2_digest(postcondition_snapshot), + "consistency": postcondition_snapshot.consistency.state.value, + } + if recovery is not None: + artifact["recovery"] = dict(recovery) + artifact["final_state"] = { + "session_health": context.session_state.health.value, + "output_expected": "off", + } + artifact["evidence_refs"] = sorted( + { + evidence_ref + for feature in ( + () + if preflight_snapshot is None + else preflight_snapshot.runtime_profile.features + ) + for evidence_ref in feature.evidence_refs } - return { - "status": "off_verified", - "channels": list(channels), - "session_health": session_state.health.value, - } + ) + return artifact - def _source_basic_v2_artifact( + def _source_pm_modulation_v2_artifact( self, *, context: SourceOperationContextCoordinator, - request: SourceBasicConfigureRequest, + request: SourcePmModulationConfigureRequest, preflight_snapshot: SourceSnapshotV2 | None, postcondition_snapshot: SourceSnapshotV2 | None, - result: SourceBasicConfigureResult | None, + result: SourcePmModulationConfigureResult | None, recovery: dict[str, object] | None = None, ) -> dict[str, object]: artifact = context.artifact() @@ -6704,7 +9275,7 @@ def _source_basic_v2_artifact( else preflight_snapshot.runtime_profile.descriptor_digest ) artifact["capability_decision"] = { - "capability": "source.basic_configure_v2", + "capability": "source.modulation_pm_configure_v2", "contract_version": SOURCE_CONTRACT_VERSION, "descriptor_digest": descriptor_digest, } @@ -6741,14 +9312,14 @@ def _source_basic_v2_artifact( ) return artifact - def _source_harmonic_v2_artifact( + def _source_fm_modulation_v2_artifact( self, *, context: SourceOperationContextCoordinator, - request: SourceHarmonicConfigureRequest, + request: SourceFmModulationConfigureRequest, preflight_snapshot: SourceSnapshotV2 | None, postcondition_snapshot: SourceSnapshotV2 | None, - result: SourceHarmonicConfigureResult | None, + result: SourceFmModulationConfigureResult | None, recovery: dict[str, object] | None = None, ) -> dict[str, object]: artifact = context.artifact() @@ -6758,7 +9329,7 @@ def _source_harmonic_v2_artifact( else preflight_snapshot.runtime_profile.descriptor_digest ) artifact["capability_decision"] = { - "capability": "source.harmonics_configure_v2", + "capability": "source.modulation_fm_configure_v2", "contract_version": SOURCE_CONTRACT_VERSION, "descriptor_digest": descriptor_digest, } @@ -6795,15 +9366,14 @@ def _source_harmonic_v2_artifact( ) return artifact - def _source_harmonic_disable_v2_artifact( + def _source_pwm_modulation_v2_artifact( self, *, context: SourceOperationContextCoordinator, - request: SourceHarmonicDisableRequest, + request: SourcePwmModulationConfigureRequest, preflight_snapshot: SourceSnapshotV2 | None, postcondition_snapshot: SourceSnapshotV2 | None, - result: SourceHarmonicDisableResult | None, - wrote_main: bool, + result: SourcePwmModulationConfigureResult | None, recovery: dict[str, object] | None = None, ) -> dict[str, object]: artifact = context.artifact() @@ -6813,7 +9383,7 @@ def _source_harmonic_disable_v2_artifact( else preflight_snapshot.runtime_profile.descriptor_digest ) artifact["capability_decision"] = { - "capability": "source.harmonics_disable_v2", + "capability": "source.modulation_pwm_configure_v2", "contract_version": SOURCE_CONTRACT_VERSION, "descriptor_digest": descriptor_digest, } @@ -6825,10 +9395,7 @@ def _source_harmonic_disable_v2_artifact( "consistency": preflight_snapshot.consistency.state.value, } if result is not None: - artifact["mutation"] = { - "status": "written" if wrote_main else "already_at_target", - "result": source_v2_to_data(result), - } + artifact["mutation"] = {"result": source_v2_to_data(result)} if postcondition_snapshot is not None: artifact["postcondition"] = { "snapshot_digest": source_v2_digest(postcondition_snapshot), @@ -6853,14 +9420,14 @@ def _source_harmonic_disable_v2_artifact( ) return artifact - def _source_modulation_v2_artifact( + def _source_sweep_v2_artifact( self, *, context: SourceOperationContextCoordinator, - request: SourceModulationConfigureRequest, + request: SourceSweepConfigureRequest, preflight_snapshot: SourceSnapshotV2 | None, postcondition_snapshot: SourceSnapshotV2 | None, - result: SourceModulationConfigureResult | None, + result: SourceSweepConfigureResult | None, recovery: dict[str, object] | None = None, ) -> dict[str, object]: artifact = context.artifact() @@ -6870,11 +9437,15 @@ def _source_modulation_v2_artifact( else preflight_snapshot.runtime_profile.descriptor_digest ) artifact["capability_decision"] = { - "capability": "source.modulation_configure_v2", + "capability": "source.sweep_configure_v2", "contract_version": SOURCE_CONTRACT_VERSION, "descriptor_digest": descriptor_digest, } - artifact["request"] = source_v2_to_data(request) + request_data = cast(dict[str, object], source_v2_to_data(request)) + if request.trigger_source is SourceTriggerSource.INTERNAL: + request_data = dict(request_data) + request_data.pop("trigger_source", None) + artifact["request"] = request_data if preflight_snapshot is not None: artifact["preflight"] = { "target_channel": request.channel, @@ -6907,15 +9478,16 @@ def _source_modulation_v2_artifact( ) return artifact - def _source_pm_modulation_v2_artifact( + def _source_arbitrary_storage_v2_artifact( self, *, context: SourceOperationContextCoordinator, - request: SourcePmModulationConfigureRequest, + request: SourceArbitraryStorageRequest, preflight_snapshot: SourceSnapshotV2 | None, postcondition_snapshot: SourceSnapshotV2 | None, - result: SourcePmModulationConfigureResult | None, - recovery: dict[str, object] | None = None, + preflight_slot: SourceArbitraryStorageSlot | None, + postcondition_slot: SourceArbitraryStorageSlot | None, + result: SourceArbitraryStorageResult | None, ) -> dict[str, object]: artifact = context.artifact() descriptor_digest = ( @@ -6924,7 +9496,7 @@ def _source_pm_modulation_v2_artifact( else preflight_snapshot.runtime_profile.descriptor_digest ) artifact["capability_decision"] = { - "capability": "source.modulation_pm_configure_v2", + "capability": "source.arbitrary_storage_v2", "contract_version": SOURCE_CONTRACT_VERSION, "descriptor_digest": descriptor_digest, } @@ -6934,6 +9506,11 @@ def _source_pm_modulation_v2_artifact( "target_channel": request.channel, "snapshot_digest": source_v2_digest(preflight_snapshot), "consistency": preflight_snapshot.consistency.state.value, + "storage_slot": ( + None + if preflight_slot is None + else source_v2_to_data(preflight_slot) + ), } if result is not None: artifact["mutation"] = {"result": source_v2_to_data(result)} @@ -6941,12 +9518,17 @@ def _source_pm_modulation_v2_artifact( artifact["postcondition"] = { "snapshot_digest": source_v2_digest(postcondition_snapshot), "consistency": postcondition_snapshot.consistency.state.value, + "storage_slot": ( + None + if postcondition_slot is None + else source_v2_to_data(postcondition_slot) + ), } - if recovery is not None: - artifact["recovery"] = dict(recovery) artifact["final_state"] = { "session_health": context.session_state.health.value, - "output_expected": "off", + "output_expected": "unchanged", + "selection_expected": "unchanged", + "storage_readback_verified": postcondition_slot is not None, } artifact["evidence_refs"] = sorted( { @@ -6961,14 +9543,14 @@ def _source_pm_modulation_v2_artifact( ) return artifact - def _source_fm_modulation_v2_artifact( + def _source_arbitrary_select_v2_artifact( self, *, context: SourceOperationContextCoordinator, - request: SourceFmModulationConfigureRequest, + request: SourceArbitrarySelectRequest, preflight_snapshot: SourceSnapshotV2 | None, postcondition_snapshot: SourceSnapshotV2 | None, - result: SourceFmModulationConfigureResult | None, + result: SourceArbitrarySelectResult | None, recovery: dict[str, object] | None = None, ) -> dict[str, object]: artifact = context.artifact() @@ -6978,7 +9560,7 @@ def _source_fm_modulation_v2_artifact( else preflight_snapshot.runtime_profile.descriptor_digest ) artifact["capability_decision"] = { - "capability": "source.modulation_fm_configure_v2", + "capability": "source.arbitrary_select_v2", "contract_version": SOURCE_CONTRACT_VERSION, "descriptor_digest": descriptor_digest, } @@ -7015,14 +9597,14 @@ def _source_fm_modulation_v2_artifact( ) return artifact - def _source_pwm_modulation_v2_artifact( + def _source_arbitrary_volatile_replace_v2_artifact( self, *, context: SourceOperationContextCoordinator, - request: SourcePwmModulationConfigureRequest, + request: SourceArbitraryVolatileReplaceRequest, preflight_snapshot: SourceSnapshotV2 | None, postcondition_snapshot: SourceSnapshotV2 | None, - result: SourcePwmModulationConfigureResult | None, + result: SourceArbitraryVolatileReplaceResult | None, recovery: dict[str, object] | None = None, ) -> dict[str, object]: artifact = context.artifact() @@ -7032,7 +9614,7 @@ def _source_pwm_modulation_v2_artifact( else preflight_snapshot.runtime_profile.descriptor_digest ) artifact["capability_decision"] = { - "capability": "source.modulation_pwm_configure_v2", + "capability": "source.arbitrary_volatile_replace_v2", "contract_version": SOURCE_CONTRACT_VERSION, "descriptor_digest": descriptor_digest, } @@ -7055,6 +9637,15 @@ def _source_pwm_modulation_v2_artifact( artifact["final_state"] = { "session_health": context.session_state.health.value, "output_expected": "off", + "selection_expected": None if result is None else result.selected_waveform_id, + "content_readback_verified": ( + None if result is None else result.content_readback_verified + ), + "previous_content": ( + "restorable" + if result is not None and result.previous_content_restorable + else "unrecoverable" + ), } artifact["evidence_refs"] = sorted( { @@ -7069,14 +9660,14 @@ def _source_pwm_modulation_v2_artifact( ) return artifact - def _source_sweep_v2_artifact( + def _source_arbitrary_workspace_volatile_replace_v2_artifact( self, *, context: SourceOperationContextCoordinator, - request: SourceSweepConfigureRequest, + request: SourceArbitraryWorkspaceVolatileReplaceRequest, preflight_snapshot: SourceSnapshotV2 | None, postcondition_snapshot: SourceSnapshotV2 | None, - result: SourceSweepConfigureResult | None, + result: SourceArbitraryWorkspaceVolatileReplaceResult | None, recovery: dict[str, object] | None = None, ) -> dict[str, object]: artifact = context.artifact() @@ -7086,14 +9677,14 @@ def _source_sweep_v2_artifact( else preflight_snapshot.runtime_profile.descriptor_digest ) artifact["capability_decision"] = { - "capability": "source.sweep_configure_v2", + "capability": "source.arbitrary_workspace_volatile_replace_v2", "contract_version": SOURCE_CONTRACT_VERSION, "descriptor_digest": descriptor_digest, } artifact["request"] = source_v2_to_data(request) if preflight_snapshot is not None: artifact["preflight"] = { - "target_channel": request.channel, + "topology_channels": [item.channel for item in preflight_snapshot.channels], "snapshot_digest": source_v2_digest(preflight_snapshot), "consistency": preflight_snapshot.consistency.state.value, } @@ -7108,7 +9699,12 @@ def _source_sweep_v2_artifact( artifact["recovery"] = dict(recovery) artifact["final_state"] = { "session_health": context.session_state.health.value, - "output_expected": "off", + "topology_outputs_expected": "off", + "channel_selection": "unverified", + "content_readback_verified": ( + None if result is None else result.content_readback_verified + ), + "previous_content": "unrecoverable", } artifact["evidence_refs"] = sorted( { @@ -7123,16 +9719,17 @@ def _source_sweep_v2_artifact( ) return artifact - def _source_arbitrary_storage_v2_artifact( + def _source_counter_v2_artifact( self, *, context: SourceOperationContextCoordinator, - request: SourceArbitraryStorageRequest, + request: SourceCounterConfigureRequest | SourceCounterEnableRequest, preflight_snapshot: SourceSnapshotV2 | None, postcondition_snapshot: SourceSnapshotV2 | None, - preflight_slot: SourceArbitraryStorageSlot | None, - postcondition_slot: SourceArbitraryStorageSlot | None, - result: SourceArbitraryStorageResult | None, + result: SourceCounterConfigureResult | SourceCounterEnableResult | None, + wrote_main: bool, + capability: str, + recovery: dict[str, object] | None = None, ) -> dict[str, object]: artifact = context.artifact() descriptor_digest = ( @@ -7141,39 +9738,33 @@ def _source_arbitrary_storage_v2_artifact( else preflight_snapshot.runtime_profile.descriptor_digest ) artifact["capability_decision"] = { - "capability": "source.arbitrary_storage_v2", + "capability": capability, "contract_version": SOURCE_CONTRACT_VERSION, "descriptor_digest": descriptor_digest, } artifact["request"] = source_v2_to_data(request) if preflight_snapshot is not None: artifact["preflight"] = { - "target_channel": request.channel, + "target_input_id": request.input_id, "snapshot_digest": source_v2_digest(preflight_snapshot), "consistency": preflight_snapshot.consistency.state.value, - "storage_slot": ( - None - if preflight_slot is None - else source_v2_to_data(preflight_slot) - ), } if result is not None: - artifact["mutation"] = {"result": source_v2_to_data(result)} + artifact["mutation"] = { + "status": "written" if wrote_main else "already_at_target", + "result": source_v2_to_data(result), + } if postcondition_snapshot is not None: artifact["postcondition"] = { "snapshot_digest": source_v2_digest(postcondition_snapshot), "consistency": postcondition_snapshot.consistency.state.value, - "storage_slot": ( - None - if postcondition_slot is None - else source_v2_to_data(postcondition_slot) - ), } + if recovery is not None: + artifact["recovery"] = dict(recovery) artifact["final_state"] = { "session_health": context.session_state.health.value, - "output_expected": "unchanged", - "selection_expected": "unchanged", - "storage_readback_verified": postcondition_slot is not None, + "counter_input_id": request.input_id, + "automatic_rollback": "not_available", } artifact["evidence_refs"] = sorted( { @@ -7188,14 +9779,14 @@ def _source_arbitrary_storage_v2_artifact( ) return artifact - def _source_arbitrary_select_v2_artifact( + def _source_burst_v2_artifact( self, *, context: SourceOperationContextCoordinator, - request: SourceArbitrarySelectRequest, + request: SourceBurstConfigureRequest, preflight_snapshot: SourceSnapshotV2 | None, postcondition_snapshot: SourceSnapshotV2 | None, - result: SourceArbitrarySelectResult | None, + result: SourceBurstConfigureResult | None, recovery: dict[str, object] | None = None, ) -> dict[str, object]: artifact = context.artifact() @@ -7205,11 +9796,15 @@ def _source_arbitrary_select_v2_artifact( else preflight_snapshot.runtime_profile.descriptor_digest ) artifact["capability_decision"] = { - "capability": "source.arbitrary_select_v2", + "capability": "source.burst_configure_v2", "contract_version": SOURCE_CONTRACT_VERSION, "descriptor_digest": descriptor_digest, } - artifact["request"] = source_v2_to_data(request) + request_data = cast(dict[str, object], source_v2_to_data(request)) + if request.trigger_source is SourceTriggerSource.INTERNAL: + request_data = dict(request_data) + request_data.pop("trigger_source", None) + artifact["request"] = request_data if preflight_snapshot is not None: artifact["preflight"] = { "target_channel": request.channel, @@ -7242,16 +9837,22 @@ def _source_arbitrary_select_v2_artifact( ) return artifact - def _source_burst_v2_artifact( + def _source_fire_v2_artifact( self, *, context: SourceOperationContextCoordinator, - request: SourceBurstConfigureRequest, + request: SourceFireRequest, + feature: SourceFeature, preflight_snapshot: SourceSnapshotV2 | None, postcondition_snapshot: SourceSnapshotV2 | None, - result: SourceBurstConfigureResult | None, + result: SourceFireResult | None, recovery: dict[str, object] | None = None, ) -> dict[str, object]: + capability = ( + "source.burst_fire_v2" + if feature is SourceFeature.BURST + else "source.sweep_fire_v2" + ) artifact = context.artifact() descriptor_digest = ( None @@ -7259,11 +9860,12 @@ def _source_burst_v2_artifact( else preflight_snapshot.runtime_profile.descriptor_digest ) artifact["capability_decision"] = { - "capability": "source.burst_configure_v2", + "capability": capability, "contract_version": SOURCE_CONTRACT_VERSION, "descriptor_digest": descriptor_digest, } artifact["request"] = source_v2_to_data(request) + artifact["persistent_session_verified"] = True if preflight_snapshot is not None: artifact["preflight"] = { "target_channel": request.channel, @@ -7271,27 +9873,32 @@ def _source_burst_v2_artifact( "consistency": preflight_snapshot.consistency.state.value, } if result is not None: - artifact["mutation"] = {"result": source_v2_to_data(result)} + artifact["mutation"] = { + "result": source_v2_to_data(result), + "command_completed": True, + } if postcondition_snapshot is not None: artifact["postcondition"] = { "snapshot_digest": source_v2_digest(postcondition_snapshot), "consistency": postcondition_snapshot.consistency.state.value, + "emission_verified": False, + "external_measurement_required": True, } if recovery is not None: artifact["recovery"] = dict(recovery) artifact["final_state"] = { "session_health": context.session_state.health.value, - "output_expected": "off", + "output_expected": "off" if recovery is not None else "on", } artifact["evidence_refs"] = sorted( { evidence_ref - for feature in ( + for declared_feature in ( () if preflight_snapshot is None else preflight_snapshot.runtime_profile.features ) - for evidence_ref in feature.evidence_refs + for evidence_ref in declared_feature.evidence_refs } ) return artifact @@ -7594,6 +10201,44 @@ def _attach_source_arbitrary_select_v2_diagnostics( except Exception: pass + def _attach_source_arbitrary_volatile_replace_v2_diagnostics( + self, + exc: BaseException, + **kwargs: object, + ) -> None: + try: + setattr( + exc, + "source_operation_artifact", + self._source_arbitrary_volatile_replace_v2_artifact(**kwargs), + ) + except Exception: + pass + + def _attach_source_arbitrary_workspace_volatile_replace_v2_diagnostics( + self, + exc: BaseException, + **kwargs: object, + ) -> None: + try: + setattr( + exc, + "source_operation_artifact", + self._source_arbitrary_workspace_volatile_replace_v2_artifact(**kwargs), + ) + except Exception: + pass + + def _attach_source_counter_v2_diagnostics( + self, + exc: BaseException, + **kwargs: object, + ) -> None: + try: + setattr(exc, "source_operation_artifact", self._source_counter_v2_artifact(**kwargs)) + except Exception: + pass + def _attach_source_burst_v2_diagnostics( self, exc: BaseException, @@ -7604,6 +10249,16 @@ def _attach_source_burst_v2_diagnostics( except Exception: pass + def _attach_source_fire_v2_diagnostics( + self, + exc: BaseException, + **kwargs: object, + ) -> None: + try: + setattr(exc, "source_operation_artifact", self._source_fire_v2_artifact(**kwargs)) + except Exception: + pass + def _attach_source_pulse_v2_diagnostics( self, exc: BaseException, @@ -8026,10 +10681,15 @@ def configure_burst( def trigger_burst(self, channel: int | None = None) -> None: source_cfg = self._source_config() channel = source_cfg.default_channel if channel is None else channel + if self._maps_v1_routes_to_source_v2("source.burst_fire_v2"): + self.fire_burst_v2(SourceFireRequest(channel=channel)) + return + overlapping = ["source.burst_configure_v2"] + if self._maps_v1_routes_to_source_v2("source.output_v2"): + overlapping.append("source.output_v2") self._reject_v1_route_for_source_v2( SourceV1WriteRouteId.TRIGGER_BURST, - "source.output_v2", - "source.burst_configure_v2", + *overlapping, ) required = ["source.burst_trigger"] if source_cfg.check_errors: @@ -8092,10 +10752,15 @@ def configure_sweep( def trigger_sweep(self, channel: int | None = None) -> None: source_cfg = self._source_config() channel = source_cfg.default_channel if channel is None else channel + if self._maps_v1_routes_to_source_v2("source.sweep_fire_v2"): + self.fire_sweep_v2(SourceFireRequest(channel=channel)) + return + overlapping = ["source.sweep_configure_v2"] + if self._maps_v1_routes_to_source_v2("source.output_v2"): + overlapping.append("source.output_v2") self._reject_v1_route_for_source_v2( SourceV1WriteRouteId.TRIGGER_SWEEP, - "source.output_v2", - "source.sweep_configure_v2", + *overlapping, ) required = ["source.sweep_trigger"] if source_cfg.check_errors: @@ -8112,12 +10777,83 @@ def trigger_sweep(self, channel: int | None = None) -> None: ) def snapshot_restorable_state(self, channel: int | None = None) -> RestorableSourceState: + if self._declares_source_v2_basic_restore(): + source_cfg = self._source_config() + target_channel = source_cfg.default_channel if channel is None else channel + status = self._source_status_from_v2_snapshot( + self.snapshot_v2(), + target_channel, + ) + if self.state_guard is not None: + self.state_guard.observe(status) + return RestorableSourceState.from_status(status) return RestorableSourceState.from_status(self.status(channel=channel)) def restore_restorable_state(self, state: RestorableSourceState) -> SourceStatus: - self._reject_v1_route_for_source_v2( - SourceV1WriteRouteId.RESTORE, - "source.basic_configure_v2", + if self._declares_source_v2_basic_restore(): + # Basic V2 MAIN phases permit exactly one bounded driver write. + # Build every request before turning output OFF, then preserve the + # legacy restore order with separate transactions. + requests = ( + SourceBasicConfigureRequest( + channel=state.channel, + patch=SourceBasicPatch( + waveform_kind=PatchValue( + PatchAction.SET, + self._source_v2_waveform_from_v1(state.function), + ), + ), + ), + SourceBasicConfigureRequest( + channel=state.channel, + patch=SourceBasicPatch( + amplitude_vpp=PatchValue( + PatchAction.SET, + state.amplitude_vpp, + ), + ), + ), + SourceBasicConfigureRequest( + channel=state.channel, + patch=SourceBasicPatch( + frequency_hz=PatchValue( + PatchAction.SET, + state.frequency_hz, + ), + ), + ), + *( + ( + SourceBasicConfigureRequest( + channel=state.channel, + patch=SourceBasicPatch( + square_duty_cycle_percent=PatchValue( + PatchAction.SET, + state.square_duty_cycle_percent, + ), + ), + ), + ) + if state.square_duty_cycle_percent is not None + else () + ), + ) + self._set_output_v2_transaction( + SourceOutputRequest(channel=state.channel, enabled=False), + ) + final_snapshot = None + for request in requests: + final_snapshot = self._configure_basic_v2_transaction(request).snapshot + assert final_snapshot is not None + if state.output == "ON": + output = self._set_output_v2_transaction( + SourceOutputRequest(channel=state.channel, enabled=True), + ) + final_snapshot = output.snapshot + status = self._source_status_from_v2_snapshot(final_snapshot, state.channel) + self._state_guard_after_write(status) + return status + overlapping = [ "source.harmonics_configure_v2", "source.harmonics_disable_v2", "source.modulation_configure_v2", @@ -8131,8 +10867,12 @@ def restore_restorable_state(self, state: RestorableSourceState) -> SourceStatus "source.coupling_configure_v2", "source.tracking_configure_v2", "source.phase_relation_configure_v2", - "source.output_v2", - ) + ] + if self._maps_v1_routes_to_source_v2("source.basic_configure_v2"): + overlapping.append("source.basic_configure_v2") + if self._maps_v1_routes_to_source_v2("source.output_v2"): + overlapping.append("source.output_v2") + self._reject_v1_route_for_source_v2(SourceV1WriteRouteId.RESTORE, *overlapping) self.set_output(channel=state.channel, enabled=False) self.set_function(channel=state.channel, function=state.function) self.set_amplitude_vpp(channel=state.channel, value_vpp=state.amplitude_vpp) @@ -8147,15 +10887,20 @@ def restore_restorable_state(self, state: RestorableSourceState) -> SourceStatus def set_frequency(self, channel: int | None, value_hz: float) -> SourceStatus: source_cfg = self._source_config() channel = source_cfg.default_channel if channel is None else channel - if self._declares_source_v2_capability("source.basic_configure_v2"): - transaction = self._configure_basic_v2_transaction( - SourceBasicConfigureRequest( - channel=channel, - patch=SourceBasicPatch( - frequency_hz=PatchValue(PatchAction.SET, value_hz), - ), - ) + if self._maps_v1_routes_to_source_v2("source.basic_configure_v2"): + request = SourceBasicConfigureRequest( + channel=channel, + patch=SourceBasicPatch( + frequency_hz=PatchValue(PatchAction.SET, value_hz), + ), ) + if self._maps_v1_routes_to_source_v2("source.basic_live_configure_v2"): + try: + transaction = self._configure_basic_live_v2_transaction(request) + except _SourceV2BasicRequiresOffMutation: + transaction = self._configure_basic_v2_transaction(request) + else: + transaction = self._configure_basic_v2_transaction(request) status = self._source_status_from_v2_snapshot(transaction.snapshot, channel) self._state_guard_after_write(status) return status @@ -8186,7 +10931,7 @@ def set_frequency(self, channel: int | None, value_hz: float) -> SourceStatus: def set_output(self, channel: int | None, enabled: bool) -> SourceStatus: source_cfg = self._source_config() channel = source_cfg.default_channel if channel is None else channel - if self._declares_source_v2_capability("source.output_v2"): + if self._maps_v1_routes_to_source_v2("source.output_v2"): transaction = self._set_output_v2_transaction( SourceOutputRequest(channel=channel, enabled=enabled), ) @@ -8260,7 +11005,7 @@ def _set_function_v1( def set_function(self, channel: int | None, function: str) -> SourceStatus: source_cfg = self._source_config() channel = source_cfg.default_channel if channel is None else channel - if self._declares_source_v2_capability("source.basic_configure_v2"): + if self._maps_v1_routes_to_source_v2("source.basic_configure_v2"): waveform = self._source_v2_waveform_from_v1(function) if self._source_v2_basic_declares_waveform( channel=channel, @@ -8293,7 +11038,7 @@ def set_function(self, channel: int | None, function: str) -> SourceStatus: def set_square_duty_cycle(self, channel: int | None, duty_percent: float) -> SourceStatus: source_cfg = self._source_config() channel = source_cfg.default_channel if channel is None else channel - if self._declares_source_v2_capability("source.basic_configure_v2"): + if self._maps_v1_routes_to_source_v2("source.basic_configure_v2"): transaction = self._configure_basic_v2_transaction( SourceBasicConfigureRequest( channel=channel, @@ -8323,15 +11068,20 @@ def set_amplitude_vpp(self, channel: int | None, value_vpp: float) -> SourceStat source_cfg = self._source_config() self._check_source_vpp(value_vpp, field="source amplitude / 信号源幅度") channel = source_cfg.default_channel if channel is None else channel - if self._declares_source_v2_capability("source.basic_configure_v2"): - transaction = self._configure_basic_v2_transaction( - SourceBasicConfigureRequest( - channel=channel, - patch=SourceBasicPatch( - amplitude_vpp=PatchValue(PatchAction.SET, value_vpp), - ), - ) + if self._maps_v1_routes_to_source_v2("source.basic_configure_v2"): + request = SourceBasicConfigureRequest( + channel=channel, + patch=SourceBasicPatch( + amplitude_vpp=PatchValue(PatchAction.SET, value_vpp), + ), ) + if self._maps_v1_routes_to_source_v2("source.basic_live_configure_v2"): + try: + transaction = self._configure_basic_live_v2_transaction(request) + except _SourceV2BasicRequiresOffMutation: + transaction = self._configure_basic_v2_transaction(request) + else: + transaction = self._configure_basic_v2_transaction(request) status = self._source_status_from_v2_snapshot(transaction.snapshot, channel) self._state_guard_after_write(status) return status @@ -8364,12 +11114,18 @@ def upload_arbitrary_waveform( output_on: bool = False, ) -> SourceStatus: source_cfg = self._source_config() - self._reject_v1_route_for_source_v2( - SourceV1WriteRouteId.UPLOAD_ARBITRARY, - "source.basic_configure_v2", - "source.output_v2", + overlapping = [ "source.arbitrary_storage_v2", "source.arbitrary_select_v2", + "source.arbitrary_volatile_replace_v2", + ] + if self._maps_v1_routes_to_source_v2("source.basic_configure_v2"): + overlapping.append("source.basic_configure_v2") + if self._maps_v1_routes_to_source_v2("source.output_v2"): + overlapping.append("source.output_v2") + self._reject_v1_route_for_source_v2( + SourceV1WriteRouteId.UPLOAD_ARBITRARY, + *overlapping, ) self._require_finite( playback_frequency_hz, diff --git a/src/wavebench/services/source_snapshot_v2.py b/src/wavebench/services/source_snapshot_v2.py index 9e62893..2180eb6 100644 --- a/src/wavebench/services/source_snapshot_v2.py +++ b/src/wavebench/services/source_snapshot_v2.py @@ -15,6 +15,7 @@ BurstFacet, HarmonicFacet, ModulationFacet, + NoiseOverlayFacet, Observed, OutputFacet, SnapshotConsistencyState, @@ -23,6 +24,9 @@ SourceAnchorField, SourceChannelStateV2, SourceCounterInputState, + SourceCouplingCapabilityProfile, + SourceCouplingDimensionState, + SourceCouplingState, SourceCrossChannelStateV2, SourceDescriptorExtensions, SourceFacetQueryContract, @@ -31,6 +35,7 @@ SourceFeatureCapability, SourceFieldId, SourceFieldRef, + SourceNoiseOverlayCapabilityProfile, SourceQueryExecutionRecord, SourceQueryEffect, SourceQueryItemOutcome, @@ -44,6 +49,8 @@ SourceSemanticQueryPlan, SourceSnapshotConsistency, SourceSnapshotV2, + SourceSyncCapabilityProfile, + SourceSyncState, SourceSystemStateV2, SourceTypedObservation, SweepFacet, @@ -424,7 +431,7 @@ def _anchor_predicate_value( and isinstance(value, ArbitraryFacet) ): return _observed_value(value.playback_mode) - if isinstance(value, SourceRelationState): + if isinstance(value, (SourceRelationState, SourceCouplingState)): return _observed_value(value.enabled) return None @@ -550,6 +557,77 @@ def _channel_state( features: tuple[SourceFeatureCapability, ...], ) -> SourceChannelStateV2: target = SourceScopeRef(SourceFacetScope.CHANNEL, channel=channel) + sync = _field_value( + values, + SourceFieldRef(SourceFieldId.SYNC, target), + features, + SourceFeature.SYNC, + ) + if sync.availability is Availability.VALUE: + state = sync.value + assert isinstance(state, SourceSyncState) + profile = next( + ( + feature.profile + for feature in features + if feature.feature is SourceFeature.SYNC + and feature.scope is SourceFacetScope.CHANNEL + and feature.channels == (channel,) + and isinstance(feature.profile, SourceSyncCapabilityProfile) + ), + None, + ) + if profile is None: + raise SourceSnapshotContractError("source sync observation has no runtime profile") + if state.enabled.availability is Availability.VALUE and not profile.enabled_readable: + raise SourceSnapshotContractError( + "source sync observation reports unreadable enabled state" + ) + if state.polarity.availability is Availability.VALUE and not profile.polarity_readable: + raise SourceSnapshotContractError( + "source sync observation reports unreadable polarity" + ) + if state.source_channel.availability is Availability.VALUE and ( + not profile.source_channel_readable + or state.source_channel.value not in profile.source_channels + ): + raise SourceSnapshotContractError( + "source sync observation references an undeclared source channel" + ) + noise_overlay = _field_value( + values, + SourceFieldRef(SourceFieldId.NOISE_OVERLAY, target), + features, + SourceFeature.NOISE_OVERLAY, + ) + if noise_overlay.availability is Availability.VALUE: + state = noise_overlay.value + assert isinstance(state, NoiseOverlayFacet) + profile = next( + ( + feature.profile + for feature in features + if feature.feature is SourceFeature.NOISE_OVERLAY + and feature.scope is SourceFacetScope.CHANNEL + and feature.channels == (channel,) + and isinstance(feature.profile, SourceNoiseOverlayCapabilityProfile) + ), + None, + ) + if profile is None: + raise SourceSnapshotContractError( + "source noise overlay observation has no runtime profile" + ) + if state.enabled.availability is Availability.VALUE and not profile.enabled_readable: + raise SourceSnapshotContractError( + "source noise overlay observation reports unreadable enabled state" + ) + if state.scales.availability is Availability.VALUE and tuple( + scale.kind for scale in state.scales.value + ) != profile.scale_kinds: + raise SourceSnapshotContractError( + "source noise overlay observation scale kinds do not match runtime profile" + ) return SourceChannelStateV2( channel=channel, basic=_field_value( @@ -600,6 +678,8 @@ def _channel_state( features, SourceFeature.ARBITRARY, ), + sync=sync, + noise_overlay=noise_overlay, ) @@ -630,12 +710,6 @@ def _system_state( features, SourceFeature.REFERENCE_CLOCK, ), - sync=_field_value( - values, - SourceFieldRef(SourceFieldId.SYNC, instrument), - features, - SourceFeature.SYNC, - ), cascade=_field_value( values, SourceFieldRef(SourceFieldId.CASCADE, instrument), @@ -650,7 +724,7 @@ def _cross_channel_state( values: dict[SourceFieldRef, Observed[object]], features: tuple[SourceFeatureCapability, ...], ) -> SourceCrossChannelStateV2: - relations: list[SourceRelationState] = [] + relations: list[SourceRelationState | SourceCouplingState] = [] relation_fields = { SourceFeature.COMBINE: SourceFieldId.COMBINE, SourceFeature.TRACKING: SourceFieldId.TRACKING, @@ -670,7 +744,68 @@ def _cross_channel_state( feature.feature, ) if observed.availability is Availability.VALUE: - relations.append(observed.value) + if feature.feature is not SourceFeature.COUPLING: + relations.append(observed.value) + continue + profile = feature.profile + state = observed.value + if not isinstance(profile, SourceCouplingCapabilityProfile) or not isinstance( + state, + SourceCouplingState, + ): + raise SourceSnapshotContractError( + "source coupling observation has an invalid runtime profile" + ) + if state.channels != feature.channels: + raise SourceSnapshotContractError( + "source coupling observation does not match its channel set" + ) + if state.enabled.availability is Availability.VALUE and not profile.global_state_readable: + raise SourceSnapshotContractError( + "source coupling observation reports unreadable global state" + ) + if ( + state.reference_channel.availability is Availability.VALUE + and not profile.reference_channel_readable + ): + raise SourceSnapshotContractError( + "source coupling observation reports unreadable reference channel" + ) + if tuple(item.dimension for item in state.dimensions) != profile.dimensions: + raise SourceSnapshotContractError( + "source coupling observation dimensions do not match runtime profile" + ) + if any( + item.parameter.availability is Availability.VALUE + and item.parameter.value.kind not in profile.parameter_kinds + for item in state.dimensions + ): + raise SourceSnapshotContractError( + "source coupling observation uses an undeclared parameter kind" + ) + relations.append(state) + elif feature.feature is SourceFeature.COUPLING: + profile = feature.profile + if not isinstance(profile, SourceCouplingCapabilityProfile): + raise SourceSnapshotContractError( + "source coupling feature has an invalid runtime profile" + ) + relations.append( + SourceCouplingState( + feature=SourceFeature.COUPLING, + channels=feature.channels, + enabled=observed, + reference_channel=observed, + dimensions=tuple( + SourceCouplingDimensionState( + dimension=dimension, + enabled=observed, + parameter=observed, + ) + for dimension in profile.dimensions + ), + ) + ) else: relations.append( SourceRelationState( diff --git a/src/wavebench/transport/guarded.py b/src/wavebench/transport/guarded.py index 4b5cb88..f37d54d 100644 --- a/src/wavebench/transport/guarded.py +++ b/src/wavebench/transport/guarded.py @@ -441,16 +441,6 @@ def _has_verified_bounded_binary_backend(self) -> bool: 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": diff --git a/src/wavebench/tui/app.py b/src/wavebench/tui/app.py index 729531e..b5c17e2 100644 --- a/src/wavebench/tui/app.py +++ b/src/wavebench/tui/app.py @@ -200,7 +200,6 @@ def __init__( self._source_read_in_flight = False self._source_write_in_flight = False self._last_state: PowerPanelState | None = None - self._last_dmm_state: DmmPanelState | None = None self._last_source_state: SourcePanelState | None = None self._power_log_lines: tuple[str, ...] = () self._dmm_log_lines: tuple[str, ...] = () @@ -812,7 +811,6 @@ def _render_state(self, state: PowerPanelState) -> None: self._render_log() def _render_dmm_state(self, state: DmmPanelState) -> None: - self._last_dmm_state = state status = self.query_one("#dmm-status", Static) status.update( f"万用表 / DMM\n" diff --git a/src/wavebench/tui/dmm.py b/src/wavebench/tui/dmm.py index 2b42c80..365c280 100644 --- a/src/wavebench/tui/dmm.py +++ b/src/wavebench/tui/dmm.py @@ -11,7 +11,7 @@ from wavebench.instruments.models import DmmReading from wavebench.logging import CommandLogger from wavebench.services.dmm_service import DmmService -from wavebench.tui.state import DmmPanelState, dmm_state_from_reading +from wavebench.tui.state import DmmPanelState, _logger_lines, dmm_state_from_reading class DmmPanelAdapter(Protocol): @@ -193,7 +193,3 @@ def build_dmm_panel_state( reading=reading, log_lines=log_lines, ) - - -def _logger_lines(logger: CommandLogger) -> list[str]: - return [f"{entry.timestamp} {entry.direction} {entry.text}" for entry in logger.entries[-80:]] diff --git a/src/wavebench/tui/power.py b/src/wavebench/tui/power.py index f1fc523..754e70e 100644 --- a/src/wavebench/tui/power.py +++ b/src/wavebench/tui/power.py @@ -10,7 +10,7 @@ from wavebench.instruments.models import PowerMeasurement, PowerProtectionStatus, PowerStatus from wavebench.logging import CommandLogger from wavebench.services.power_service import PowerService -from wavebench.tui.state import PowerPanelState, channel_state_from_status, config_status +from wavebench.tui.state import PowerPanelState, _logger_lines, channel_state_from_status, config_status class PowerPanelAdapter(Protocol): @@ -425,7 +425,3 @@ def build_power_panel_state( ), log_lines=tuple(log_lines), ) - - -def _logger_lines(logger: CommandLogger) -> list[str]: - return [f"{entry.timestamp} {entry.direction} {entry.text}" for entry in logger.entries[-80:]] diff --git a/src/wavebench/tui/source.py b/src/wavebench/tui/source.py index d187bd1..2edd50e 100644 --- a/src/wavebench/tui/source.py +++ b/src/wavebench/tui/source.py @@ -8,7 +8,7 @@ from wavebench.instruments.models import SourceStatus from wavebench.logging import CommandLogger from wavebench.services.source_service import SourceService -from wavebench.tui.state import SourcePanelState, source_state_from_status +from wavebench.tui.state import SourcePanelState, _logger_lines, source_state_from_status class SourcePanelAdapter(Protocol): @@ -169,7 +169,3 @@ def build_source_panel_state( status=status, log_lines=log_lines, ) - - -def _logger_lines(logger: CommandLogger) -> list[str]: - return [f"{entry.timestamp} {entry.direction} {entry.text}" for entry in logger.entries[-80:]] diff --git a/src/wavebench/tui/state.py b/src/wavebench/tui/state.py index bb40b51..04f5ee7 100644 --- a/src/wavebench/tui/state.py +++ b/src/wavebench/tui/state.py @@ -4,6 +4,7 @@ from wavebench.config import WaveBenchConfig from wavebench.instruments.models import DmmReading, PowerProtectionStatus, PowerStatus, SourceStatus +from wavebench.logging import CommandLogger @dataclass(frozen=True) @@ -77,6 +78,10 @@ class SourcePanelState: ) +def _logger_lines(logger: CommandLogger) -> list[str]: + return [f"{entry.timestamp} {entry.direction} {entry.text}" for entry in logger.entries[-80:]] + + def format_optional_number(value: float | None, unit: str = "", digits: int = 6) -> str: if value is None: return "未知 / N/A" diff --git a/tests/source_v2_fixtures.py b/tests/source_v2_fixtures.py index c9a338e..9056e22 100644 --- a/tests/source_v2_fixtures.py +++ b/tests/source_v2_fixtures.py @@ -7,12 +7,14 @@ SOURCE_CONTRACT_VERSION, Availability, BasicWaveFacet, + NoiseOverlayFacet, Observed, OutputFacet, SourceAmplitude, SourceAmplitudeUnit, SourceBasicCapabilityProfile, SourceConstraintApplicability, + SourceCouplingState, SourceDescriptorExtensions, SourceFacetQueryContract, SourceFacetScope, @@ -38,6 +40,7 @@ SourceRuntimeIdentity, SourceSafetyProfile, SourceTopologyContract, + SourceSyncState, SourceTypedObservation, SourceWaveformKind, SupportState, @@ -239,11 +242,17 @@ def __init__( drift: bool = False, harmonic_unavailable: bool = False, anchor_unknown: bool = False, + sync_state: SourceSyncState | None = None, + noise_overlay: NoiseOverlayFacet | None = None, + coupling_state: SourceCouplingState | None = None, ) -> None: self.combined = combined self.drift = drift self.harmonic_unavailable = harmonic_unavailable self.anchor_unknown = anchor_unknown + self.sync_state = sync_state + self.noise_overlay = noise_overlay + self.coupling_state = coupling_state self.plans = [] self.closed = False @@ -287,6 +296,18 @@ def execute_source_query_plan_v2(self, plan): value = output_facet( enabled=(self.drift and item.phase.value == "anchor_after") ) + elif field.field is SourceFieldId.SYNC: + if self.sync_state is None: + raise AssertionError("sync state was not configured") + value = self.sync_state + elif field.field is SourceFieldId.NOISE_OVERLAY: + if self.noise_overlay is None: + raise AssertionError("noise overlay was not configured") + value = self.noise_overlay + elif field.field is SourceFieldId.COUPLING: + if self.coupling_state is None: + raise AssertionError("coupling state was not configured") + value = self.coupling_state else: raise AssertionError(field) observations.append(SourceTypedObservation(field, value)) diff --git a/tests/test_cli.py b/tests/test_cli.py index 2d57f9b..7e42970 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -648,7 +648,36 @@ def test_source_v2_commands_accept_explicit_channels(self): "1.5", ] ) + live = build_parser().parse_args( + [ + "source", + "basic-live-configure-v2", + "--channel", + "2", + "--frequency-hz", + "2000", + ] + ) output = build_parser().parse_args(["source", "output-v2", "--channel", "2", "on"]) + counter_configure = build_parser().parse_args( + [ + "source", + "counter-configure-v2", + "--input-id", + "counter", + "--coupling", + "dc", + ] + ) + counter_enable = build_parser().parse_args( + ["source", "counter-enable-v2", "--input-id", "counter"] + ) + counter_disable = build_parser().parse_args( + ["source", "counter-disable-v2", "--input-id", "counter"] + ) + counter_measure = build_parser().parse_args( + ["source", "counter-measure-v2", "--input-id", "counter"] + ) harmonics = build_parser().parse_args( [ "source", @@ -768,9 +797,22 @@ def test_source_v2_commands_accept_explicit_channels(self): self.assertEqual(basic.waveform, "square") self.assertEqual(basic.frequency_hz, 1000.0) self.assertEqual(basic.amplitude_vpp, 1.5) + self.assertEqual(live.command, "basic-live-configure-v2") + self.assertEqual(live.channel, 2) + self.assertEqual(live.frequency_hz, 2000.0) + self.assertIsNone(live.amplitude_vpp) self.assertEqual(output.command, "output-v2") self.assertEqual(output.channel, 2) self.assertEqual(output.state, "on") + self.assertEqual(counter_configure.command, "counter-configure-v2") + self.assertEqual(counter_configure.input_id, "counter") + self.assertEqual(counter_configure.coupling, "dc") + self.assertEqual(counter_enable.command, "counter-enable-v2") + self.assertEqual(counter_enable.input_id, "counter") + self.assertEqual(counter_disable.command, "counter-disable-v2") + self.assertEqual(counter_disable.input_id, "counter") + self.assertEqual(counter_measure.command, "counter-measure-v2") + self.assertEqual(counter_measure.input_id, "counter") self.assertEqual(harmonics.command, "harmonics-configure-v2") self.assertEqual(harmonics.channel, 2) self.assertEqual(harmonics.order, 8) @@ -814,6 +856,116 @@ def test_source_v2_commands_accept_explicit_channels(self): self.assertEqual(burst.internal_period_s, 0.25) self.assertEqual(burst.delay_s, 0.5) + def test_source_basic_live_configure_v2_dispatches_typed_request(self): + payload = { + "schema": "wavebench.source.operation.v1", + "operation": "source.basic_live_configure_v2", + } + service = Mock() + service.configure_basic_live_v2.return_value = (object(), payload) + stdout = io.StringIO() + + with patch("wavebench.cli._load_source_service", return_value=service), redirect_stdout(stdout): + code = main( + [ + "source", + "basic-live-configure-v2", + "--channel", + "2", + "--amplitude-vpp", + "1.5", + ] + ) + + self.assertEqual(code, 0) + request = service.configure_basic_live_v2.call_args.args[0] + self.assertEqual(request.channel, 2) + self.assertEqual(request.patch.amplitude_vpp.value, 1.5) + self.assertEqual(json.loads(stdout.getvalue()), payload) + + def test_source_counter_v2_commands_dispatch_typed_requests(self): + from wavebench.instruments.source_extensions import ( + SourceCounterMeasureResult, + SourceCounterMeasurementKind, + SourceCounterMeasurementV2, + SourceInputCoupling, + ) + + configure_payload = { + "schema": "wavebench.source.operation.v1", + "operation": "source.counter_configure_v2", + } + enable_payload = { + "schema": "wavebench.source.operation.v1", + "operation": "source.counter_enable_v2", + } + service = Mock() + service.configure_counter_v2.return_value = (object(), configure_payload) + service.set_counter_enabled_v2.return_value = (object(), enable_payload) + service.measure_counter_v2.return_value = SourceCounterMeasureResult( + "counter", + ( + SourceCounterMeasurementV2(SourceCounterMeasurementKind.DUTY_PERCENT, 50.0), + SourceCounterMeasurementV2( + SourceCounterMeasurementKind.FREQUENCY_HZ, + 1_000.0, + ), + ), + ) + + with patch("wavebench.cli._load_source_service", return_value=service): + self.assertEqual( + main( + [ + "source", + "counter-configure-v2", + "--input-id", + "counter", + "--coupling", + "dc", + ] + ), + 0, + ) + self.assertEqual( + main(["source", "counter-enable-v2", "--input-id", "counter"]), + 0, + ) + stdout = io.StringIO() + with redirect_stdout(stdout): + self.assertEqual( + main(["source", "counter-measure-v2", "--input-id", "counter"]), + 0, + ) + + configure_request = service.configure_counter_v2.call_args.args[0] + self.assertEqual(configure_request.input_id, "counter") + self.assertEqual(configure_request.patch.coupling.value, SourceInputCoupling.DC) + enable_request = service.set_counter_enabled_v2.call_args.args[0] + self.assertEqual(enable_request.input_id, "counter") + self.assertTrue(enable_request.enabled) + measure_request = service.measure_counter_v2.call_args.args[0] + self.assertEqual(measure_request.input_id, "counter") + self.assertEqual( + json.loads(stdout.getvalue()), + { + "type": "SourceCounterMeasureResult", + "input_id": "counter", + "measurements": [ + { + "type": "SourceCounterMeasurementV2", + "kind": "duty_percent", + "value": 50.0, + }, + { + "type": "SourceCounterMeasurementV2", + "kind": "frequency_hz", + "value": 1_000.0, + }, + ], + }, + ) + def test_source_harmonics_disable_v2_dispatches_typed_request(self): payload = { "schema": "wavebench.source.operation.v1", @@ -1192,8 +1344,12 @@ def test_executable_plugin_info_loads_v2_descriptor(self): def test_executable_plugin_doctor_loads_descriptors(self): stdout = io.StringIO() - with redirect_stdout(stdout): - code = main(["plugin", "doctor", "--load"]) + with patch( + "wavebench.instruments.registry.entry_points", + return_value=FakePluginEntryPoints(), + ): + with redirect_stdout(stdout): + code = main(["plugin", "doctor", "--load"]) self.assertEqual(code, 0) self.assertIn("可执行描述符有效", stdout.getvalue()) diff --git a/tests/test_instrument_models.py b/tests/test_instrument_models.py index e237c92..1faa101 100644 --- a/tests/test_instrument_models.py +++ b/tests/test_instrument_models.py @@ -437,6 +437,19 @@ def test_source_sweep_profile_serializes_complete_query_only_snapshot(): } +def test_source_sweep_profile_preserves_disabled_marker_frequency_outside_window(): + profile = _source_sweep_profile( + start_hz=1000.0, + stop_hz=2000.0, + center_hz=1500.0, + span_hz=1000.0, + marker_frequency_hz=550.0, + ) + + assert profile.marker_enabled is False + assert profile.marker_frequency_hz == 550.0 + + @pytest.mark.parametrize( "changes, message", [ @@ -456,7 +469,7 @@ def test_source_sweep_profile_serializes_complete_query_only_snapshot(): ({"trigger_source": "BUS"}, "trigger source"), ({"trigger_slope": "BOTH"}, "trigger slope"), ({"trigger_out": "HIGH"}, "trigger output"), - ({"marker_frequency_hz": 1001.0}, "marker frequency"), + ({"marker_enabled": True, "marker_frequency_hz": 1001.0}, "marker frequency"), ({"spacing": "STEP", "marker_enabled": True}, "step spacing"), ], ) @@ -526,6 +539,17 @@ def test_source_sweep_configuration_accepts_center_span_without_duplicate_window assert configuration.effective_stop_hz == 1000.0 +def test_source_sweep_configuration_accepts_disabled_marker_frequency_outside_window(): + configuration = _source_sweep_configuration( + start_hz=1000.0, + stop_hz=2000.0, + marker_frequency_hz=550.0, + ) + + assert configuration.marker_enabled is False + assert configuration.marker_frequency_hz == 550.0 + + def test_source_sweep_configuration_accepts_a_restorable_zero_span_window(): configuration = _source_sweep_configuration( start_hz=1000.0, @@ -578,7 +602,7 @@ def test_source_sweep_configuration_can_restore_a_complete_profile(): ({"steps": 1}, "steps"), ({"sweep_time_s": 0.0}, "sweep time"), ({"trigger_source": "BUS"}, "trigger source"), - ({"marker_frequency_hz": 1001.0}, "marker frequency"), + ({"marker_enabled": True, "marker_frequency_hz": 1001.0}, "marker frequency"), ], ) def test_source_sweep_configuration_rejects_ambiguous_or_unsafe_targets(changes, message): diff --git a/tests/test_operation_specs.py b/tests/test_operation_specs.py index 3ff850b..6db30c7 100644 --- a/tests/test_operation_specs.py +++ b/tests/test_operation_specs.py @@ -6,8 +6,13 @@ from wavebench.instruments.source_extensions import ( SOURCE_ARBITRARY_SELECT_V2_OPERATION_CONTRACT, SOURCE_ARBITRARY_STORAGE_V2_OPERATION_CONTRACT, + SOURCE_ARBITRARY_VOLATILE_REPLACE_V2_OPERATION_CONTRACT, SOURCE_BASIC_CONFIGURE_V2_OPERATION_CONTRACT, SOURCE_BURST_CONFIGURE_V2_OPERATION_CONTRACT, + SOURCE_BURST_FIRE_V2_OPERATION_CONTRACT, + SOURCE_COUNTER_CONFIGURE_V2_OPERATION_CONTRACT, + SOURCE_COUNTER_DISABLE_V2_OPERATION_CONTRACT, + SOURCE_COUNTER_ENABLE_V2_OPERATION_CONTRACT, SOURCE_FM_MODULATION_CONFIGURE_V2_OPERATION_CONTRACT, SOURCE_HARMONICS_DISABLE_V2_OPERATION_CONTRACT, SOURCE_HARMONICS_CONFIGURE_V2_OPERATION_CONTRACT, @@ -18,6 +23,7 @@ SOURCE_PULSE_CONFIGURE_V2_OPERATION_CONTRACT, SOURCE_PWM_MODULATION_CONFIGURE_V2_OPERATION_CONTRACT, SOURCE_SWEEP_CONFIGURE_V2_OPERATION_CONTRACT, + SOURCE_SWEEP_FIRE_V2_OPERATION_CONTRACT, SourceEnergyEffect, ) from wavebench.services.operation_specs import ( @@ -43,6 +49,27 @@ def test_source_output_spec_describes_mutation_and_restore_boundary() -> None: assert spec.as_dict()["required_capabilities"] == ["source.output"] +def test_source_counter_v2_specs_keep_configuration_enable_and_measure_separate() -> None: + configure = require_operation_spec("source.counter_configure_v2") + enable = require_operation_spec("source.counter_enable_v2") + disable = require_operation_spec("source.counter_disable_v2") + measure = require_operation_spec("source.counter_measure_v2") + + for spec, contract in ( + (configure, SOURCE_COUNTER_CONFIGURE_V2_OPERATION_CONTRACT), + (enable, SOURCE_COUNTER_ENABLE_V2_OPERATION_CONTRACT), + (disable, SOURCE_COUNTER_DISABLE_V2_OPERATION_CONTRACT), + ): + assert spec.effect == "write" + assert spec.required_capabilities == (contract.capability,) + assert spec.changed_fields == ("source.input.counter",) + assert spec.restore_coverage == "source-v2-counter-no-rollback" + assert "no_automatic_rollback" in spec.risk_flags + assert measure.effect == "stateful_read" + assert measure.required_capabilities == ("source.counter_measure_v2",) + assert measure.restore_coverage == "none-read-only" + + def test_rf_source_m0_specs_are_read_only_and_exclusive() -> None: identity = require_operation_spec("rf_source.idn") snapshot = require_operation_spec("rf_source.snapshot") @@ -226,6 +253,17 @@ def test_source_v2_write_specs_match_their_static_operation_contracts() -> None: "source-v2-arbitrary-selection", ("source_v2", "output_must_be_off", "arbitrary_selection"), ), + ( + SOURCE_ARBITRARY_VOLATILE_REPLACE_V2_OPERATION_CONTRACT, + "source-v2-arbitrary-volatile", + ( + "source_v2", + "output_must_be_off", + "arbitrary_volatile_replace", + "payload_not_artifact", + "no_retry", + ), + ), ( SOURCE_OUTPUT_ENABLE_V2_OPERATION_CONTRACT, "source-v2-output", @@ -293,11 +331,31 @@ def test_source_v2_write_specs_match_their_static_operation_contracts() -> None: assert SOURCE_ARBITRARY_SELECT_V2_OPERATION_CONTRACT.energy_effect is ( SourceEnergyEffect.POTENTIAL_WHILE_OFF ) + assert SOURCE_ARBITRARY_VOLATILE_REPLACE_V2_OPERATION_CONTRACT.energy_effect is ( + SourceEnergyEffect.POTENTIAL_WHILE_OFF + ) assert SOURCE_OUTPUT_ENABLE_V2_OPERATION_CONTRACT.energy_effect is SourceEnergyEffect.EMIT assert SOURCE_OUTPUT_DISABLE_V2_OPERATION_CONTRACT.energy_effect is ( SourceEnergyEffect.DECREASE_ONLY ) + for contract, configure_capability in ( + (SOURCE_BURST_FIRE_V2_OPERATION_CONTRACT, "source.burst_configure_v2"), + (SOURCE_SWEEP_FIRE_V2_OPERATION_CONTRACT, "source.sweep_configure_v2"), + ): + spec = require_operation_spec(contract.operation) + assert contract.energy_effect is SourceEnergyEffect.EMIT + assert spec.required_capabilities == ( + contract.capability, + configure_capability, + "source.output_v2", + ) + assert spec.postcondition_fields == tuple( + field.value for field in contract.postcondition_fields + ) + assert "persistent_session_required" in spec.risk_flags + assert "no_retry" in spec.risk_flags + def test_registry_is_read_only_and_filters_by_instrument_kind() -> None: assert get_operation_spec("run.check") is not None diff --git a/tests/test_plugin_lifecycle.py b/tests/test_plugin_lifecycle.py index e126e50..f5bf4b5 100644 --- a/tests/test_plugin_lifecycle.py +++ b/tests/test_plugin_lifecycle.py @@ -258,6 +258,7 @@ def _plugin_wheel( *, version: str, driver_id: str = "example.scope", + additional_driver_id: str | None = None, distribution: str = "wavebench-example-scope", kind: str = "scope", capabilities: tuple[str, ...] = ("scope.idn",), @@ -272,6 +273,10 @@ def _plugin_wheel( ) -> Path: if source_v2 and legacy_source_v1: raise ValueError("a fixture wheel cannot be both Source V1-only and Source V2") + if additional_driver_id is not None and ( + broken_descriptor or source_v2 or legacy_source_v1 or additional_driver_id == driver_id + ): + raise ValueError("additional fixture entry points require distinct default descriptors") filename_name = distribution.replace("-", "_") dist_info = f"{filename_name}-{version}.dist-info" package_name = "wavebench_example_scope" @@ -303,6 +308,15 @@ def _plugin_wheel( source_v2_write=source_v2_write, ) else: + additional_descriptor = ( + f""" + +def descriptor_v2(): + return _descriptor({additional_driver_id!r}) +""" + if additional_driver_id is not None + else "" + ) package = f'''from wavebench.instruments.api import InstrumentDescriptor @@ -314,9 +328,9 @@ def close(self): pass -def descriptor(): +def _descriptor(driver_id): return InstrumentDescriptor( - driver_id={driver_id!r}, + driver_id=driver_id, kind={kind!r}, display_name="Example Instrument", manufacturer="Example", @@ -329,6 +343,11 @@ def descriptor(): permissions=("instrument.io",), factory=lambda context: Driver(), ) + + +def descriptor(): + return _descriptor({driver_id!r}) +{additional_descriptor} '''.encode() members = { f"{dist_info}/METADATA": metadata, @@ -338,8 +357,12 @@ def descriptor(): f"{package_name}/__init__.py": package, } if include_entry_point: + entries = [(driver_id, f"{package_name}:descriptor")] + if additional_driver_id is not None: + entries.append((additional_driver_id, f"{package_name}:descriptor_v2")) members[f"{dist_info}/entry_points.txt"] = ( - f"[wavebench.instruments]\n{driver_id} = {package_name}:descriptor\n" + "[wavebench.instruments]\n" + + "".join(f"{name} = {target}\n" for name, target in entries) ).encode() output = io.StringIO(newline="") writer = csv.writer(output, lineterminator="\n") @@ -792,6 +815,157 @@ def test_install_status_and_remove_round_trip(tmp_path): assert lifecycle.installed() == () +def test_multi_entry_point_distribution_installs_updates_and_removes_atomically(tmp_path): + python = _target_venv(tmp_path) + v1 = _plugin_wheel( + tmp_path, + version="0.1.0", + driver_id="example.scope", + additional_driver_id="example.scope-v2", + distribution="wavebench-example-multi", + ) + v2 = _plugin_wheel( + tmp_path, + version="0.2.0", + driver_id="example.scope", + additional_driver_id="example.scope-v2", + distribution="wavebench-example-multi", + ) + lifecycle = PluginLifecycle(python_executable=python) + + assert lifecycle.install(v1).status == "installed" + assert [ + (item.driver_id, item.version, item.status) + for item in lifecycle.installed() + ] == [ + ("example.scope", "0.1.0", "healthy"), + ("example.scope-v2", "0.1.0", "healthy"), + ] + assert lifecycle.info("example.scope-v2").distribution == "wavebench-example-multi" + registry_script = """ +from wavebench.instruments.registry import build_instrument_registry + +registry = build_instrument_registry() +assert registry.resolve("example.scope", expected_kind="scope").origin == "entry_point" +assert registry.resolve("example.scope-v2", expected_kind="scope").origin == "entry_point" +""" + _run([str(python), "-I", "-c", registry_script]) + + assert lifecycle.upgrade(v2).status == "upgraded" + assert {item.version for item in lifecycle.installed()} == {"0.2.0"} + assert lifecycle.remove("example.scope-v2").status == "removed" + assert lifecycle.installed() == () + ledger = json.loads(lifecycle.ledger_path.read_text(encoding="utf-8")) + assert ledger["plugins"] == {} + + +def test_lifecycle_migrates_a_single_entry_v1_ledger_on_next_mutation(tmp_path): + python = _target_venv(tmp_path) + wheel = _plugin_wheel(tmp_path, version="0.1.0") + lifecycle = PluginLifecycle(python_executable=python) + lifecycle.install(wheel) + current = json.loads(lifecycle.ledger_path.read_text(encoding="utf-8")) + record = next(iter(current["plugins"].values())) + entry = record["entry_points"][0] + legacy_record = { + "driver_id": entry["driver_id"], + "distribution": record["distribution"], + "normalized_distribution": record["normalized_distribution"], + "version": record["version"], + "entry_point": entry["value"], + "wheel_sha256": record["wheel_sha256"], + "metadata_sha256": record["metadata_sha256"], + "record_sha256": record["record_sha256"], + "wheel_cache": record["wheel_cache"], + "installed_files_sha256": record["installed_files_sha256"], + "installed_record_sha256": record["installed_record_sha256"], + } + lifecycle._write_json( + lifecycle.ledger_path, + { + "schema_version": 1, + "environment": current["environment"], + "generation": current["generation"], + "plugins": {entry["driver_id"]: legacy_record}, + }, + ) + + assert lifecycle.info("example.scope").status == "healthy" + assert lifecycle.remove("example.scope").status == "removed" + migrated = json.loads(lifecycle.ledger_path.read_text(encoding="utf-8")) + assert migrated["schema_version"] == 2 + assert migrated["plugins"] == {} + + +def test_lifecycle_upgrades_a_legacy_single_entry_distribution_to_add_opt_in_entry(tmp_path): + python = _target_venv(tmp_path) + v1 = _plugin_wheel( + tmp_path, + version="0.1.0", + driver_id="example.scope", + distribution="wavebench-example-multi", + ) + v2 = _plugin_wheel( + tmp_path, + version="0.2.0", + driver_id="example.scope", + additional_driver_id="example.scope-v2", + distribution="wavebench-example-multi", + ) + lifecycle = PluginLifecycle(python_executable=python) + lifecycle.install(v1) + + assert lifecycle.upgrade(v2).status == "upgraded" + assert [item.driver_id for item in lifecycle.installed()] == [ + "example.scope", + "example.scope-v2", + ] + + +def test_lifecycle_recovers_a_v1_prepared_journal(tmp_path): + python = _target_venv(tmp_path) + wheel = _plugin_wheel(tmp_path, version="0.1.0") + lifecycle = PluginLifecycle(python_executable=python) + environment = lifecycle.environment() + lifecycle.state_dir.mkdir(mode=0o700) + with lifecycle._inspected_input(wheel) as package: + cached = lifecycle._cache_wheel(package) + record = lifecycle._package_record(package, cached) + entry = record["entry_points"][0] + legacy_record = { + "driver_id": entry["driver_id"], + "distribution": record["distribution"], + "normalized_distribution": record["normalized_distribution"], + "version": record["version"], + "entry_point": entry["value"], + "wheel_sha256": record["wheel_sha256"], + "metadata_sha256": record["metadata_sha256"], + "record_sha256": record["record_sha256"], + "wheel_cache": record["wheel_cache"], + "installed_files_sha256": record["installed_files_sha256"], + "installed_record_sha256": record["installed_record_sha256"], + } + lifecycle._write_json( + lifecycle.journal_path, + { + "schema_version": 1, + "environment": environment.to_json(), + "operation": "install", + "stage": "prepared", + "before_ledger": { + "schema_version": 1, + "environment": environment.to_json(), + "generation": 0, + "plugins": {}, + }, + "package": legacy_record, + }, + ) + + assert lifecycle.recover().status == "recovered-before-mutation" + assert not lifecycle.journal_path.exists() + + def test_source_directory_install_keeps_built_wheel_alive_until_cached(tmp_path): python = _target_venv(tmp_path) source = _plugin_source(tmp_path) @@ -996,7 +1170,7 @@ def test_prepared_journal_blocks_mutation_and_can_be_recovered(tmp_path): lifecycle.journal_path.write_text( json.dumps( { - "schema_version": 1, + "schema_version": 2, "environment": environment.to_json(), "operation": "install", "stage": "prepared", diff --git a/tests/test_plugin_package_inspect.py b/tests/test_plugin_package_inspect.py index d2152d5..b19add4 100644 --- a/tests/test_plugin_package_inspect.py +++ b/tests/test_plugin_package_inspect.py @@ -339,7 +339,7 @@ def test_inspect_wheel_rejects_excessive_uncompressed_size(tmp_path, monkeypatch inspect_plugin_wheel(path) -def test_inspect_wheel_rejects_multi_driver_distribution(tmp_path): +def test_inspect_wheel_accepts_distinct_entry_points_from_one_distribution(tmp_path): path = _wheel( tmp_path, entry_points=( @@ -349,8 +349,7 @@ def test_inspect_wheel_rejects_multi_driver_distribution(tmp_path): ), ) - with pytest.raises(ConfigError, match="exactly one instrument entry point"): - inspect_plugin_wheel(path) + assert inspect_plugin_wheel(path).driver_ids == ("example.dmm", "example.scope") def test_inspect_source_directory_builds_one_offline_wheel(tmp_path): diff --git a/tests/test_run_plan.py b/tests/test_run_plan.py index 83d69d6..d865cd3 100644 --- a/tests/test_run_plan.py +++ b/tests/test_run_plan.py @@ -584,6 +584,91 @@ def test_source_v2_steps_validate_explicit_channels_and_closed_basic_patch(self) with self.assertRaisesRegex(ConfigError, "leading_transition_s must be <="): load_run_plan(oversized_transition) + def test_source_basic_live_v2_step_requires_one_live_field(self): + plan = load_run_plan(self._write_plan(""" +[[steps]] +kind = "source.basic_live_configure_v2" +channel = 2 +frequency_hz = 2000 +""")) + + self.assertEqual( + plan.steps[0].fields, + {"channel": 2, "frequency_hz": 2000.0}, + ) + + no_field = self._write_plan(""" +[[steps]] +kind = "source.basic_live_configure_v2" +channel = 2 +""") + with self.assertRaisesRegex(ConfigError, "requires exactly one"): + load_run_plan(no_field) + + two_fields = self._write_plan(""" +[[steps]] +kind = "source.basic_live_configure_v2" +channel = 2 +frequency_hz = 2000 +amplitude_vpp = 1.5 +""") + with self.assertRaisesRegex(ConfigError, "requires exactly one"): + load_run_plan(two_fields) + + def test_source_counter_v2_steps_require_one_config_field(self): + plan = load_run_plan(self._write_plan(""" +[[steps]] +kind = "source.counter_configure_v2" +input_id = "counter" +coupling = "DC" + +[[steps]] +kind = "source.counter_enable_v2" +input_id = "counter" + +[[steps]] +kind = "source.counter_measure_v2" +input_id = "counter" + +[[steps]] +kind = "source.counter_disable_v2" +input_id = "counter" +""")) + + self.assertEqual( + plan.steps[0].fields, + {"input_id": "counter", "coupling": "dc"}, + ) + self.assertEqual(plan.steps[1].fields, {"input_id": "counter"}) + self.assertEqual(plan.steps[2].fields, {"input_id": "counter"}) + self.assertEqual(plan.steps[3].fields, {"input_id": "counter"}) + + no_field = self._write_plan(""" +[[steps]] +kind = "source.counter_configure_v2" +input_id = "counter" +""") + with self.assertRaisesRegex(ConfigError, "requires exactly one Counter field"): + load_run_plan(no_field) + + two_fields = self._write_plan(""" +[[steps]] +kind = "source.counter_configure_v2" +input_id = "counter" +coupling = "ac" +attenuation = 10 +""") + with self.assertRaisesRegex(ConfigError, "requires exactly one Counter field"): + load_run_plan(two_fields) + + invalid_input = self._write_plan(""" +[[steps]] +kind = "source.counter_measure_v2" +input_id = "" +""") + with self.assertRaisesRegex(ConfigError, "input_id"): + load_run_plan(invalid_input) + def test_source_v2_harmonic_disable_step_accepts_only_channel(self): plan = load_run_plan(self._write_plan(""" [[steps]] @@ -607,7 +692,10 @@ def test_format_run_plan_schema_lists_expect_and_power_output(self): self.assertIn("power.output", text) self.assertIn("source.arb_load", text) self.assertIn("source.basic_configure_v2", text) + self.assertIn("source.basic_live_configure_v2", text) self.assertIn("source.output_enable_v2", text) + self.assertIn("source.counter_configure_v2", text) + self.assertIn("source.counter_measure_v2", text) self.assertIn("source.harmonics_configure_v2", text) self.assertIn("source.harmonics_disable_v2", text) self.assertIn("source.modulation_configure_v2", text) @@ -615,13 +703,56 @@ def test_format_run_plan_schema_lists_expect_and_power_output(self): self.assertIn("source.modulation_fm_configure_v2", text) self.assertIn("source.modulation_pwm_configure_v2", text) self.assertIn("source.sweep_configure_v2", text) + self.assertIn("source.sweep_fire_v2", text) self.assertIn("source.burst_configure_v2", text) self.assertIn("source.pulse_configure_v2", text) + self.assertIn("source.arbitrary_volatile_replace_v2", text) self.assertIn("sweep.frequency_response", text) self.assertIn("[steps.expect]", text) self.assertIn("[steps.expect_fft]", text) self.assertIn("frequency_estimate_hz", text) + def test_source_v2_manual_sweep_fire_and_volatile_arb_plan_fields(self): + plan = load_run_plan(self._write_plan(""" +[[steps]] +kind = "source.sweep_configure_v2" +channel = 1 +start_hz = 100 +stop_hz = 1000 +spacing = "linear" +steps = 10 +sweep_time_s = 1 +trigger_source = "manual" + +[[steps]] +kind = "source.sweep_fire_v2" +channel = 1 + +[[steps]] +kind = "source.arbitrary_volatile_replace_v2" +channel = 1 +file = "volatile.bin" +point_count = 2 +""")) + + self.assertEqual(plan.steps[0].fields["trigger_source"], "manual") + self.assertEqual(plan.steps[1].kind, "source.sweep_fire_v2") + self.assertEqual(plan.steps[2].fields["point_count"], 2) + + invalid = self._write_plan(""" +[[steps]] +kind = "source.sweep_configure_v2" +channel = 1 +start_hz = 100 +stop_hz = 1000 +spacing = "linear" +steps = 10 +sweep_time_s = 1 +trigger_source = "external" +""") + with self.assertRaisesRegex(ConfigError, "trigger_source must be internal or manual"): + load_run_plan(invalid) + def test_frequency_response_plan_normalizes_log_frequency_points_and_fit(self): plan = load_run_plan(self._write_plan(""" [[steps]] diff --git a/tests/test_run_service.py b/tests/test_run_service.py index 56c86e5..ed06dae 100644 --- a/tests/test_run_service.py +++ b/tests/test_run_service.py @@ -25,7 +25,7 @@ WaveformConfig, ) from wavebench.drivers.dp800 import PowerStatus -from wavebench.errors import ConfigError, SessionHealthError, TransportIOError +from wavebench.errors import ConfigError, DataError, SessionHealthError, TransportIOError from wavebench.logging import CommandLogger from wavebench.services.run_plan import load_run_plan from wavebench.services.run_service import RunInstrumentServices, RunService @@ -415,6 +415,48 @@ def _apply_safety_gate(self, step, gate, *, services=None): self.assertEqual(len(result.steps), 1) self.assertEqual(run_data["error"]["code"], "safety_gate_failed") + def test_expected_step_failure_runs_safety_gate_before_stopping(self): + with TemporaryDirectory() as tmp: + plan = load_run_plan( + write_plan( + tmp, + """ +[[steps]] +kind = "sleep" +duration_s = 0.001 +""", + ) + ) + plan.steps[0].fields["safety_gate"] = { + "enabled": True, + "source_channels": [1], + } + + class OfflineRunService(RunService): + @contextmanager + def _run_instrument_services(self, plan): + del plan + yield RunInstrumentServices() + + def _run_safety_guards(self, plan, *, services=None): + del plan, services + + def _run_step(self, plan, step, **kwargs): + del plan, step, kwargs + raise DataError("Counter measurement is not ready") + + def _apply_safety_gate(self, step, gate, *, services=None): + del step, gate, services + return {"status": "ok", "actions": [{"state": "off"}]} + + result = OfflineRunService(config=make_config(tmp), logger=CommandLogger()).run(plan) + run_data = json.loads(result.run_json_path.read_text(encoding="utf-8")) + + self.assertEqual(len(result.steps), 1) + self.assertEqual(result.steps[0].artifact["error"]["code"], "data_error") + self.assertEqual(result.steps[0].artifact["safety_gate"]["status"], "ok") + self.assertEqual(run_data["error"]["code"], "safety_gate_failed") + def test_check_rejects_missing_capability_before_opening_session(self): with TemporaryDirectory() as tmp: plan = load_run_plan( @@ -470,6 +512,89 @@ def test_check_requires_source_v2_capability_before_opening_session(self): open_services.assert_not_called() + def test_check_requires_source_basic_live_v2_capability_before_opening_session(self): + with TemporaryDirectory() as tmp: + plan = load_run_plan( + write_plan( + tmp, + """ +[[steps]] +kind = "source.basic_live_configure_v2" +channel = 1 +frequency_hz = 1000 +""", + ) + ) + descriptor = SimpleNamespace( + driver_id="minimal.source-v2", + capabilities=( + "source.snapshot_v2", + "source.basic_configure_v2", + "source.output_v2", + ), + ) + service = RunService(config=make_config(tmp), logger=CommandLogger()) + + with patch( + "wavebench.services.run_service.resolve_instrument_descriptor", + return_value=descriptor, + ), patch.object(service, "_run_instrument_services") as open_services: + with self.assertRaisesRegex(ConfigError, "source.basic_live_configure_v2"): + service.run(plan) + + open_services.assert_not_called() + + def test_check_requires_source_counter_measure_v2_capability_before_opening_session(self): + with TemporaryDirectory() as tmp: + plan = load_run_plan( + write_plan( + tmp, + """ +[[steps]] +kind = "source.counter_measure_v2" +input_id = "counter" +""", + ) + ) + descriptor = SimpleNamespace( + driver_id="minimal.source-v2", + capabilities=("source.snapshot_v2",), + ) + service = RunService(config=make_config(tmp), logger=CommandLogger()) + + with patch( + "wavebench.services.run_service.resolve_instrument_descriptor", + return_value=descriptor, + ), patch.object(service, "_run_instrument_services") as open_services: + with self.assertRaisesRegex(ConfigError, "source.counter_measure_v2"): + service.run(plan) + + open_services.assert_not_called() + + def test_check_uses_counter_enable_capability_for_counter_disable_v2(self): + with TemporaryDirectory() as tmp: + plan = load_run_plan( + write_plan( + tmp, + """ +[[steps]] +kind = "source.counter_disable_v2" +input_id = "counter" +""", + ) + ) + descriptor = SimpleNamespace( + driver_id="minimal.source-v2", + capabilities=("source.snapshot_v2", "source.counter_enable_v2"), + ) + service = RunService(config=make_config(tmp), logger=CommandLogger()) + + with patch( + "wavebench.services.run_service.resolve_instrument_descriptor", + return_value=descriptor, + ): + service.check(plan) + def test_check_requires_source_v2_harmonic_capability_before_opening_session(self): with TemporaryDirectory() as tmp: plan = load_run_plan( @@ -725,6 +850,38 @@ def test_check_accepts_source_v2_steps_without_v1_source_write_capabilities(self ): service.check(plan) + def test_check_accepts_v2_restore_without_v1_source_write_capabilities(self): + with TemporaryDirectory() as tmp: + plan = load_run_plan( + write_plan( + tmp, + """ +[restore] +source_state = true +source_channel = 1 + +[[steps]] +kind = "sleep" +duration_s = 0.001 +""", + ) + ) + descriptor = SimpleNamespace( + driver_id="minimal.source-v2", + capabilities=( + "source.snapshot_v2", + "source.basic_configure_v2", + "source.output_v2", + ), + ) + service = RunService(config=make_config(tmp), logger=CommandLogger()) + + with patch( + "wavebench.services.run_service.resolve_instrument_descriptor", + return_value=descriptor, + ): + service.check(plan) + def test_check_requires_protection_capability_for_power_output_on(self): with TemporaryDirectory() as tmp: plan = load_run_plan( @@ -1833,6 +1990,160 @@ def _run_safety_guards(self, plan, *, services=None): run_data = json.loads((run_dir / "run.json").read_text(encoding="utf-8")) self.assertEqual(run_data["source_operations"], [artifact]) + def test_runs_source_basic_live_v2_step_and_writes_operation_artifact(self): + with TemporaryDirectory() as tmp: + plan = load_run_plan( + write_plan( + tmp, + """ +[[steps]] +kind = "source.basic_live_configure_v2" +channel = 1 +frequency_hz = 2000 +""", + ) + ) + artifact = { + "schema": "wavebench.source.operation.v1", + "operation": "source.basic_live_configure_v2", + } + source = Mock() + source.configure_basic_live_v2.return_value = (SimpleNamespace(), artifact) + + class OfflineV2RunService(RunService): + def check(self, plan): + del plan + + @contextmanager + def _run_instrument_services(self, plan): + del plan + yield RunInstrumentServices(source=source) + + def _run_safety_guards(self, plan, *, services=None): + del plan, services + + result = OfflineV2RunService(config=make_config(tmp), logger=CommandLogger()).run(plan) + run_data = json.loads(result.run_json_path.read_text(encoding="utf-8")) + + request = source.configure_basic_live_v2.call_args.args[0] + self.assertEqual(request.channel, 1) + self.assertEqual(request.patch.frequency_hz.value, 2000.0) + self.assertEqual(run_data["source_operations"], [artifact]) + self.assertEqual(result.steps[0].artifact["source_operation"], artifact) + + def test_runs_source_counter_v2_steps_and_keeps_measurement_out_of_source_operations(self): + from wavebench.instruments.source_extensions import ( + SourceCounterMeasureResult, + SourceCounterMeasurementKind, + SourceCounterMeasurementV2, + SourceInputCoupling, + ) + + with TemporaryDirectory() as tmp: + plan = load_run_plan( + write_plan( + tmp, + """ +[[steps]] +kind = "source.counter_configure_v2" +input_id = "counter" +coupling = "dc" + +[[steps]] +kind = "source.counter_enable_v2" +input_id = "counter" + +[[steps]] +kind = "source.counter_measure_v2" +input_id = "counter" + +[[steps]] +kind = "source.counter_disable_v2" +input_id = "counter" +""", + ) + ) + configure_artifact = { + "schema": "wavebench.source.operation.v1", + "operation": "source.counter_configure_v2", + } + enable_artifact = { + "schema": "wavebench.source.operation.v1", + "operation": "source.counter_enable_v2", + } + disable_artifact = { + "schema": "wavebench.source.operation.v1", + "operation": "source.counter_disable_v2", + } + source = Mock() + source.configure_counter_v2.return_value = (SimpleNamespace(), configure_artifact) + source.set_counter_enabled_v2.side_effect = [ + (SimpleNamespace(), enable_artifact), + (SimpleNamespace(), disable_artifact), + ] + source.measure_counter_v2.return_value = SourceCounterMeasureResult( + "counter", + ( + SourceCounterMeasurementV2(SourceCounterMeasurementKind.DUTY_PERCENT, 50.0), + SourceCounterMeasurementV2( + SourceCounterMeasurementKind.FREQUENCY_HZ, + 1_000.0, + ), + ), + ) + + class OfflineV2RunService(RunService): + def check(self, plan): + del plan + + @contextmanager + def _run_instrument_services(self, plan): + del plan + yield RunInstrumentServices(source=source) + + def _run_safety_guards(self, plan, *, services=None): + del plan, services + + result = OfflineV2RunService(config=make_config(tmp), logger=CommandLogger()).run(plan) + run_data = json.loads(result.run_json_path.read_text(encoding="utf-8")) + + configure_request = source.configure_counter_v2.call_args.args[0] + self.assertEqual(configure_request.input_id, "counter") + self.assertEqual( + configure_request.patch.coupling.value, + SourceInputCoupling.DC, + ) + self.assertEqual( + [call.args[0].enabled for call in source.set_counter_enabled_v2.call_args_list], + [True, False], + ) + self.assertEqual(source.measure_counter_v2.call_args.args[0].input_id, "counter") + self.assertEqual( + run_data["source_operations"], + [configure_artifact, enable_artifact, disable_artifact], + ) + self.assertEqual( + result.steps[2].artifact, + { + "counter_measurement": { + "type": "SourceCounterMeasureResult", + "input_id": "counter", + "measurements": [ + { + "type": "SourceCounterMeasurementV2", + "kind": "duty_percent", + "value": 50.0, + }, + { + "type": "SourceCounterMeasurementV2", + "kind": "frequency_hz", + "value": 1_000.0, + }, + ], + } + }, + ) + def test_restores_source_state_after_success_when_enabled(self): with TemporaryDirectory() as tmp: plan = load_run_plan( @@ -2514,6 +2825,106 @@ def _run_safety_guards(self, plan, *, services=None): self.assertEqual(run_data["source_operations"], artifacts) self.assertNotIn(payload.decode("ascii"), json.dumps(run_data, ensure_ascii=False)) + def test_runs_manual_sweep_fire_and_volatile_arb_without_payload_in_artifacts(self): + with TemporaryDirectory() as tmp: + payload = b"wavebench-volatile-arb-sentinel-45a80a30" + payload_path = Path(tmp) / "volatile.bin" + payload_path.write_bytes(payload) + plan = load_run_plan( + write_plan( + tmp, + """ +[[steps]] +kind = "source.sweep_configure_v2" +channel = 1 +start_hz = 100 +stop_hz = 1000 +spacing = "linear" +steps = 10 +sweep_time_s = 1 +trigger_source = "manual" + +[[steps]] +kind = "source.sweep_fire_v2" +channel = 1 + +[[steps]] +kind = "source.arbitrary_volatile_replace_v2" +channel = 1 +file = "volatile.bin" +point_count = 2 + +[[steps]] +kind = "source.arbitrary_workspace_volatile_replace_v2" +file = "volatile.bin" +point_count = 2 +""", + ) + ) + artifacts = [ + { + "schema": "wavebench.source.operation.v1", + "operation": "source.sweep_configure_v2", + }, + { + "schema": "wavebench.source.operation.v1", + "operation": "source.sweep_fire_v2", + }, + { + "schema": "wavebench.source.operation.v1", + "operation": "source.arbitrary_volatile_replace_v2", + "request": {"payload_sha256": "sha256:" + sha256(payload).hexdigest()}, + }, + { + "schema": "wavebench.source.operation.v1", + "operation": "source.arbitrary_workspace_volatile_replace_v2", + "request": {"payload_sha256": "sha256:" + sha256(payload).hexdigest()}, + }, + ] + source = Mock() + source.configure_sweep_v2.return_value = (SimpleNamespace(), artifacts[0]) + source.fire_sweep_v2.return_value = (SimpleNamespace(), artifacts[1]) + source.replace_arbitrary_volatile_v2.return_value = (SimpleNamespace(), artifacts[2]) + source.replace_arbitrary_workspace_volatile_v2.return_value = ( + SimpleNamespace(), + artifacts[3], + ) + + class OfflineV2RunService(RunService): + def check(self, plan): + del plan + + @contextmanager + def _run_instrument_services(self, plan): + del plan + yield RunInstrumentServices(source=source) + + def _run_safety_guards(self, plan, *, services=None): + del plan, services + + result = OfflineV2RunService(config=make_config(tmp), logger=CommandLogger()).run(plan) + run_data = json.loads(result.run_json_path.read_text(encoding="utf-8")) + + sweep_request = source.configure_sweep_v2.call_args.args[0] + self.assertEqual(sweep_request.trigger_source.value, "manual") + self.assertEqual(source.fire_sweep_v2.call_args.args[0].channel, 1) + volatile_request = source.replace_arbitrary_volatile_v2.call_args.args[0] + self.assertEqual(volatile_request.point_count, 2) + self.assertEqual(volatile_request.payload_size_bytes, len(payload)) + self.assertEqual( + source.replace_arbitrary_volatile_v2.call_args.kwargs["payload"], + payload, + ) + workspace_request = source.replace_arbitrary_workspace_volatile_v2.call_args.args[0] + self.assertEqual(workspace_request.point_count, 2) + self.assertEqual(workspace_request.payload_size_bytes, len(payload)) + self.assertEqual( + source.replace_arbitrary_workspace_volatile_v2.call_args.kwargs["payload"], + payload, + ) + self.assertEqual(run_data["source_operations"], artifacts) + self.assertNotIn(payload.decode("ascii"), json.dumps(run_data, ensure_ascii=False)) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_scope_waveform_executor.py b/tests/test_scope_waveform_executor.py index 786f674..0bd523e 100644 --- a/tests/test_scope_waveform_executor.py +++ b/tests/test_scope_waveform_executor.py @@ -182,19 +182,6 @@ def _bounded_transport( 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 diff --git a/tests/test_source_arbitrary_v2.py b/tests/test_source_arbitrary_v2.py index 5d804a1..aced82d 100644 --- a/tests/test_source_arbitrary_v2.py +++ b/tests/test_source_arbitrary_v2.py @@ -32,6 +32,11 @@ SourceArbitraryStorageRequest, SourceArbitraryStorageResult, SourceArbitraryStorageSlot, + SourceArbitraryVolatileReplaceRequest, + SourceArbitraryVolatileReplaceResult, + SourceActivationPredicate, + SourceActivationRule, + SourceAnchorField, SourceConstraintApplicability, SourceFacetQueryContract, SourceFacetScope, @@ -130,6 +135,7 @@ def __init__( session_state: InstrumentSessionState, output_enabled: bool = False, postcondition_mismatch: bool = False, + volatile_write_error: bool = False, ) -> None: self.transport = GuardedAuditedTransport( _TextTransport(), @@ -137,22 +143,41 @@ def __init__( ) self.output_enabled = output_enabled self.postcondition_mismatch = postcondition_mismatch + self.volatile_write_error = volatile_write_error self.basic = basic_facet() self.arbitrary = _arbitrary() self.slots: dict[str, bytes] = {} self.storage_requests: list[tuple[SourceArbitraryStorageRequest, bytes]] = [] self.select_requests: list[SourceArbitrarySelectRequest] = [] + self.volatile_requests: list[tuple[SourceArbitraryVolatileReplaceRequest, bytes]] = [] self.output_requests: list[SourceOutputRequest] = [] self.v1_upload_calls = 0 + self.query_plans = [] + self.query_records = [] def close(self) -> None: self.transport.close() def execute_source_query_plan_v2(self, plan) -> SourceQueryExecutionRecord: + self.query_plans.append(plan) records = [] for index, item in enumerate(plan.items): if index == 0: self.transport.query("SOURCE:STATE?") + if ( + item.activation_any + and self.basic.waveform_kind.value is not SourceWaveformKind.ARBITRARY + ): + records.append( + SourceProtocolQueryRecord( + item_id=item.item_id, + effect=item.effect, + outcome=SourceQueryItemOutcome.SKIPPED, + query_count=0, + reason_code=SourceReasonCode.INACTIVE_BY_ANCHOR, + ) + ) + continue observations = [] for field in item.fields: if field.field is SourceFieldId.IDENTITY: @@ -179,7 +204,7 @@ def execute_source_query_plan_v2(self, plan) -> SourceQueryExecutionRecord: observations=tuple(observations), ) ) - return SourceQueryExecutionRecord( + record = SourceQueryExecutionRecord( contract_version=SOURCE_CONTRACT_VERSION, plan_id=plan.plan_id, items=tuple(records), @@ -187,6 +212,8 @@ def execute_source_query_plan_v2(self, plan) -> SourceQueryExecutionRecord: device_revision_token_before="revision-1", device_revision_token_after="revision-1", ) + self.query_records.append(record) + return record def read_source_arbitrary_storage_v2( self, @@ -268,6 +295,32 @@ def select_source_arbitrary_v2( False, ) + def replace_source_arbitrary_volatile_v2( + self, + request: SourceArbitraryVolatileReplaceRequest, + payload: bytes, + ) -> SourceArbitraryVolatileReplaceResult: + self.transport.write_bytes(payload) + self.volatile_requests.append((request, payload)) + if self.volatile_write_error: + raise ConfigError("fake volatile binary write result is unknown") + self.basic = replace( + self.basic, + waveform_kind=Observed.value_of(SourceWaveformKind.ARBITRARY), + waveform_id=Observed.value_of("volatile"), + ) + self.arbitrary = _arbitrary(slot_id="volatile") + return SourceArbitraryVolatileReplaceResult( + request.channel, + request.payload_sha256, + request.payload_size_bytes, + request.point_count, + "volatile", + True, + False, + False, + ) + def set_source_output_v2(self, request: SourceOutputRequest) -> SourceOutputResult: self.transport.write("SOURCE:OUTPUT") self.output_requests.append(request) @@ -289,7 +342,9 @@ def _output(self) -> OutputFacet: ) def _readback_arbitrary(self) -> ArbitraryFacet: - if not self.postcondition_mismatch or not self.select_requests: + if not self.postcondition_mismatch or not ( + self.select_requests or self.volatile_requests + ): return self.arbitrary return replace( self.arbitrary, @@ -303,6 +358,8 @@ def _extensions( SourceArbitraryPlaybackMode.DDS, SourceArbitraryPlaybackMode.TRUE_ARB, ), + v1_route_migration_enabled: bool = True, + arbitrary_active_only: bool = False, ): base = source_extensions() basic, output = base.features @@ -324,19 +381,34 @@ def _extensions( SourceStorageWriteMode.REPLACE_IF_DIGEST_MATCHES, ), storage_max_payload_bytes=4096, + volatile_replace_min_points=2, + volatile_replace_max_points=16_384, + volatile_replace_max_payload_bytes=32_768, ), ) arbitrary_query = SourceFacetQueryContract( feature=SourceFeature.ARBITRARY, scope=SourceFacetScope.CHANNEL, fields=(SourceFieldId.ARBITRARY_SELECTION,), - activation_any=(), + activation_any=( + SourceActivationRule( + predicates=( + SourceActivationPredicate( + field=SourceAnchorField.WAVEFORM_KIND, + equals=SourceWaveformKind.ARBITRARY, + ), + ), + ), + ) + if arbitrary_active_only + else (), effect=SourceQueryEffect.PURE_READ, max_queries=1, - required=True, + required=not arbitrary_active_only, ) return replace( base, + v1_route_migration_enabled=v1_route_migration_enabled, features=( arbitrary, replace( @@ -391,16 +463,21 @@ def _service( output_enabled: bool = False, postcondition_mismatch: bool = False, dual_contract: bool = False, + volatile: bool = False, + volatile_write_error: bool = False, + v1_route_migration_enabled: bool = True, playback_modes: tuple[SourceArbitraryPlaybackMode, ...] = ( SourceArbitraryPlaybackMode.DDS, SourceArbitraryPlaybackMode.TRUE_ARB, ), + arbitrary_active_only: bool = False, ) -> tuple[SourceService, _ArbitraryWriteDriver]: session_state = InstrumentSessionState(epoch_id="source-arbitrary-v2") driver = _ArbitraryWriteDriver( session_state=session_state, output_enabled=output_enabled, postcondition_mismatch=postcondition_mismatch, + volatile_write_error=volatile_write_error, ) capabilities = [ "source.snapshot_v2", @@ -410,8 +487,17 @@ def _service( ] if dual_contract: capabilities.append("source.arbitrary_upload") + if volatile: + capabilities.append("source.arbitrary_volatile_replace_v2") descriptor = replace( - source_descriptor(driver=driver, extensions=_extensions(playback_modes=playback_modes)), + source_descriptor( + driver=driver, + extensions=_extensions( + playback_modes=playback_modes, + v1_route_migration_enabled=v1_route_migration_enabled, + arbitrary_active_only=arbitrary_active_only, + ), + ), capabilities=tuple(capabilities), ) validate_source_descriptor(descriptor) @@ -445,6 +531,15 @@ def _storage_request( ) +def _volatile_request(payload: bytes) -> SourceArbitraryVolatileReplaceRequest: + return SourceArbitraryVolatileReplaceRequest( + channel=1, + payload_sha256=_digest(payload), + payload_size_bytes=len(payload), + point_count=len(payload) // 2, + ) + + def test_arbitrary_storage_v2_writes_once_without_selecting_or_disabling_output() -> None: service, driver = _service(output_enabled=True) payload = b"raw-arbitrary-storage-payload-must-not-leak" @@ -592,6 +687,139 @@ def test_arbitrary_select_v2_postcondition_mismatch_runs_one_off_recovery() -> N } +def test_arbitrary_volatile_replace_v2_writes_once_and_keeps_output_off() -> None: + service, driver = _service(volatile=True) + payload = b"\x00\x00\xff\x3f" + request = _volatile_request(payload) + + result, artifact = service.replace_arbitrary_volatile_v2( + request, + payload=payload, + correlation_id="arb-volatile", + ) + + assert driver.volatile_requests == [(request, payload)] + assert driver.output_requests == [] + assert driver.output_enabled is False + assert driver.transport.counters.binary_write_completed == 1 + assert result.content_readback_verified is False + assert result.previous_content_restorable is False + assert artifact["operation"] == "source.arbitrary_volatile_replace_v2" + assert artifact["request"] == { + "type": "SourceArbitraryVolatileReplaceRequest", + "channel": 1, + "payload_sha256": _digest(payload), + "payload_size_bytes": len(payload), + "point_count": 2, + } + assert artifact["final_state"] == { + "session_health": "healthy", + "output_expected": "off", + "selection_expected": "volatile", + "content_readback_verified": False, + "previous_content": "unrecoverable", + } + assert payload.hex() not in repr(artifact) + assert [item["phase"] for item in artifact["phases"]] == [ + "preflight", + "main", + "postcondition", + ] + + +def test_arbitrary_volatile_replace_v2_accepts_an_inactive_preflight_selection() -> None: + service, driver = _service(volatile=True, arbitrary_active_only=True) + payload = b"\x00\x00\xff\x3f" + + result, _ = service.replace_arbitrary_volatile_v2( + _volatile_request(payload), + payload=payload, + ) + + assert result.selected_waveform_id == "volatile" + assert driver.volatile_requests == [(_volatile_request(payload), payload)] + assert any( + record.outcome is SourceQueryItemOutcome.SKIPPED + for record in driver.query_records[0].items + ) + assert any( + record.outcome is SourceQueryItemOutcome.OBSERVED + and any( + observation.field.field is SourceFieldId.ARBITRARY_SELECTION + for observation in record.observations + ) + for record in driver.query_records[1].items + ) + + +def test_arbitrary_volatile_replace_v2_rejects_invalid_payload_and_preflight_before_write() -> None: + payload = b"\x00\x00\xff\x3f" + request = _volatile_request(payload) + service, driver = _service(volatile=True) + + with pytest.raises(ConfigError, match="SHA-256"): + service.replace_arbitrary_volatile_v2(request, payload=b"\x00\x00\x00\x00") + with pytest.raises(ConfigError, match="must be bytes"): + service.replace_arbitrary_volatile_v2( + request, + payload=bytearray(payload), # type: ignore[arg-type] + ) + assert driver.transport.counters.binary_write_requests == 0 + assert driver.transport.counters.query_calls == 0 + + output_on, output_on_driver = _service(output_enabled=True, volatile=True) + with pytest.raises(ConfigError, match="target output OFF"): + output_on.replace_arbitrary_volatile_v2(request, payload=payload) + + below_minimum = SourceArbitraryVolatileReplaceRequest( + 1, + _digest(b"\x00\x00"), + 2, + 1, + ) + with pytest.raises(ConfigError, match="point count exceeds"): + service.replace_arbitrary_volatile_v2(below_minimum, payload=b"\x00\x00") + + assert driver.volatile_requests == [] + assert driver.transport.counters.binary_write_requests == 0 + assert output_on_driver.volatile_requests == [] + assert output_on_driver.transport.counters.binary_write_requests == 0 + + +@pytest.mark.parametrize( + ("postcondition_mismatch", "volatile_write_error", "message"), + ( + (True, False, "selected waveform readback"), + (False, True, "volatile binary write result is unknown"), + ), +) +def test_arbitrary_volatile_replace_v2_failure_runs_one_off_recovery( + postcondition_mismatch: bool, + volatile_write_error: bool, + message: str, +) -> None: + service, driver = _service( + volatile=True, + postcondition_mismatch=postcondition_mismatch, + volatile_write_error=volatile_write_error, + ) + payload = b"\x00\x00\xff\x3f" + request = _volatile_request(payload) + + with pytest.raises(ConfigError, match=message) as raised: + service.replace_arbitrary_volatile_v2(request, payload=payload) + + artifact = raised.value.source_operation_artifact + assert driver.volatile_requests == [(request, payload)] + assert driver.transport.counters.binary_write_requests == 1 + assert driver.output_requests == [SourceOutputRequest(channel=1, enabled=False)] + assert artifact["recovery"] == { + "status": "off_verified", + "session_health": "uncertain", + } + assert artifact["final_state"]["previous_content"] == "unrecoverable" + + def test_v1_arbitrary_upload_rejects_before_loading_file_or_io_for_dual_contract_driver() -> None: service, driver = _service(dual_contract=True) @@ -607,3 +835,24 @@ def test_v1_arbitrary_upload_rejects_before_loading_file_or_io_for_dual_contract assert driver.transport.counters.binary_write_requests == 0 assert driver.transport.counters.write_requests == 0 assert driver.transport.counters.query_calls == 0 + + +def test_v1_arbitrary_upload_keeps_the_advanced_overlap_gate_when_migration_is_disabled() -> None: + service, driver = _service( + dual_contract=True, + volatile=True, + v1_route_migration_enabled=False, + ) + + with pytest.raises(ConfigError, match="cannot run for a Source V2 write driver"): + service.upload_arbitrary_waveform( + channel=1, + file_path="does-not-exist.csv", + playback_frequency_hz=1_000.0, + amplitude_vpp=1.0, + ) + + assert driver.v1_upload_calls == 0 + assert driver.transport.counters.binary_write_requests == 0 + assert driver.transport.counters.write_requests == 0 + assert driver.transport.counters.query_calls == 0 diff --git a/tests/test_source_arbitrary_workspace_v2.py b/tests/test_source_arbitrary_workspace_v2.py new file mode 100644 index 0000000..2b1f553 --- /dev/null +++ b/tests/test_source_arbitrary_workspace_v2.py @@ -0,0 +1,498 @@ +from __future__ import annotations + +from dataclasses import replace +from hashlib import sha256 +from pathlib import Path + +import pytest + +from wavebench.config import ( + AutoscaleConfig, + ConnectionConfig, + OutputConfig, + SafetyLimitsConfig, + ScopeConfig, + SourceConfig, + WaveBenchConfig, + WaveformConfig, +) +from wavebench.errors import ConfigError +from wavebench.instruments.capabilities import validate_declared_capabilities +from wavebench.instruments.source_extension_capabilities import validate_source_descriptor +from wavebench.instruments.source_extensions import ( + SOURCE_CONTRACT_VERSION, + Observed, + OutputFacet, + SourceArbitraryWorkspaceCapabilityProfile, + SourceArbitraryWorkspaceVolatileReplaceRequest, + SourceArbitraryWorkspaceVolatileReplaceResult, + SourceAmplitudeUnit, + SourceBasicCapabilityProfile, + SourceConstraintApplicability, + SourceDescriptorExtensions, + SourceFacetQueryContract, + SourceFacetScope, + SourceFeature, + SourceFeatureCapability, + SourceFeatureDirection, + SourceFieldId, + SourceFrequencyMode, + SourceOutputCapabilityProfile, + SourceOutputPolarity, + SourceOutputRequest, + SourceOutputResult, + SourceProtocolQueryRecord, + SourceQueryContract, + SourceQueryEffect, + SourceQueryExecutionRecord, + SourceQueryItemOutcome, + SourceRuntimeIdentity, + SourceSafetyProfile, + SourceTopologyContract, + SourceTypedObservation, + SourceWaveformKind, + SupportState, +) +from wavebench.logging import CommandLogger +from wavebench.services.source_service import SourceService +from wavebench.transport.contracts import ReplayPolicy +from wavebench.transport.guarded import GuardedAuditedTransport +from wavebench.transport.session import InstrumentSessionState + +from tests.source_v2_fixtures import basic_facet, missing, source_descriptor + + +class _TextTransport: + resource = "fake-source-arbitrary-workspace-v2" + + def record_event(self, direction: str, text: str) -> None: + del direction, text + + def query(self, command: str, *, replay: ReplayPolicy = ReplayPolicy.NO_REPLAY) -> str: + del command, replay + return "ok" + + def write(self, command: str) -> None: + del command + + def write_bytes(self, command: bytes) -> None: + del command + + def close(self) -> None: + pass + + +def _digest(payload: bytes) -> str: + return "sha256:" + sha256(payload).hexdigest() + + +def _output(enabled: bool) -> OutputFacet: + return OutputFacet( + enabled=Observed.value_of(enabled), + display_load=missing(), + polarity=Observed.value_of(SourceOutputPolarity.NORMAL), + ) + + +class _WorkspaceDriver: + def __init__( + self, + *, + session_state: InstrumentSessionState, + outputs: dict[int, bool] | None = None, + postcondition_mismatch: bool = False, + write_error: bool = False, + invalid_recovery_result_channels: tuple[int, ...] = (), + ) -> None: + self.transport = GuardedAuditedTransport( + _TextTransport(), + session_state=session_state, + ) + self.outputs = {1: False, 2: False} if outputs is None else dict(outputs) + self.postcondition_mismatch = postcondition_mismatch + self.write_error = write_error + self.invalid_recovery_result_channels = frozenset(invalid_recovery_result_channels) + self.workspace_requests: list[ + tuple[SourceArbitraryWorkspaceVolatileReplaceRequest, bytes] + ] = [] + self.output_requests: list[SourceOutputRequest] = [] + + def close(self) -> None: + self.transport.close() + + def execute_source_query_plan_v2(self, plan) -> SourceQueryExecutionRecord: + records = [] + for item in plan.items: + if item.phase.value == "anchor_before": + self.transport.query("SOURCE:STATE?") + observations = [] + for field in item.fields: + if field.field is SourceFieldId.IDENTITY: + value = SourceRuntimeIdentity( + manufacturer="Example", + model="EX2", + firmware_id="1.0", + ) + elif field.field is SourceFieldId.BASIC: + value = basic_facet() + elif field.field is SourceFieldId.OUTPUT: + assert field.target.channel is not None + enabled = self.outputs[field.target.channel] + if ( + self.postcondition_mismatch + and self.workspace_requests + and not self.output_requests + and field.target.channel == 2 + ): + enabled = True + value = _output(enabled) + else: # pragma: no cover - the descriptor has no other read fields. + raise AssertionError(field) + observations.append(SourceTypedObservation(field, value)) + records.append( + SourceProtocolQueryRecord( + item_id=item.item_id, + effect=item.effect, + outcome=SourceQueryItemOutcome.OBSERVED, + query_count=(1 if item.phase.value == "anchor_before" else 0), + observations=tuple(observations), + ) + ) + return SourceQueryExecutionRecord( + contract_version=SOURCE_CONTRACT_VERSION, + plan_id=plan.plan_id, + items=tuple(records), + query_count=sum(record.query_count for record in records), + device_revision_token_before="revision-1", + device_revision_token_after="revision-1", + ) + + def replace_source_arbitrary_workspace_volatile_v2( + self, + request: SourceArbitraryWorkspaceVolatileReplaceRequest, + payload: bytes, + ) -> SourceArbitraryWorkspaceVolatileReplaceResult: + self.transport.write_bytes(payload) + self.workspace_requests.append((request, payload)) + if self.write_error: + raise ConfigError("fake workspace binary write result is unknown") + return SourceArbitraryWorkspaceVolatileReplaceResult( + workspace_id="volatile", + payload_sha256=request.payload_sha256, + payload_size_bytes=request.payload_size_bytes, + point_count=request.point_count, + write_completed=True, + content_readback_verified=False, + previous_content_restorable=False, + ) + + def set_source_output_v2(self, request: SourceOutputRequest) -> SourceOutputResult: + self.transport.write("SOURCE:OUTPUT") + self.output_requests.append(request) + self.outputs[request.channel] = request.enabled + if request.enabled: + raise AssertionError("the workspace fixture only uses recovery OFF") + if request.channel in self.invalid_recovery_result_channels: + return SourceOutputResult(channel=request.channel + 10, enabled=False) + return SourceOutputResult(channel=request.channel, enabled=False) + + +def _extensions() -> SourceDescriptorExtensions: + applicability = SourceConstraintApplicability() + return SourceDescriptorExtensions( + contract_version=SOURCE_CONTRACT_VERSION, + topology=SourceTopologyContract((1, 2)), + features=( + SourceFeatureCapability( + feature=SourceFeature.ARBITRARY_WORKSPACE, + support=SupportState.SUPPORTED, + directions=(SourceFeatureDirection.CONFIGURE,), + scope=SourceFacetScope.INSTRUMENT, + channels=(), + applicability=applicability, + profile=SourceArbitraryWorkspaceCapabilityProfile( + workspace_id="volatile", + volatile_replace_min_points=2, + volatile_replace_max_points=16_384, + volatile_replace_max_payload_bytes=32_768, + ), + ), + *( + SourceFeatureCapability( + feature=SourceFeature.BASIC, + support=SupportState.SUPPORTED, + directions=(SourceFeatureDirection.READ,), + scope=SourceFacetScope.CHANNEL, + channels=(channel,), + applicability=applicability, + profile=SourceBasicCapabilityProfile( + waveform_kinds=(SourceWaveformKind.SINE,), + frequency_modes=(SourceFrequencyMode.FIXED,), + amplitude_units=(SourceAmplitudeUnit.VPP,), + offset_readable=False, + phase_readable=False, + square_duty_readable=False, + ), + ) + for channel in (1, 2) + ), + *( + SourceFeatureCapability( + feature=SourceFeature.OUTPUT, + support=SupportState.SUPPORTED, + directions=( + SourceFeatureDirection.DISABLE, + SourceFeatureDirection.ENABLE, + SourceFeatureDirection.READ, + ), + scope=SourceFacetScope.CHANNEL, + channels=(channel,), + applicability=applicability, + profile=SourceOutputCapabilityProfile( + output_readable=True, + display_load_readable=False, + polarity_readable=True, + ), + ) + for channel in (1, 2) + ), + ), + query_contract=SourceQueryContract( + anchor_fields=( + SourceFieldId.BASIC, + SourceFieldId.OUTPUT, + SourceFieldId.IDENTITY, + ), + facets=( + SourceFacetQueryContract( + feature=SourceFeature.BASIC, + scope=SourceFacetScope.CHANNEL, + fields=(SourceFieldId.BASIC,), + activation_any=(), + effect=SourceQueryEffect.PURE_READ, + max_queries=1, + required=True, + ), + SourceFacetQueryContract( + feature=SourceFeature.BASIC, + scope=SourceFacetScope.INSTRUMENT, + fields=(SourceFieldId.IDENTITY,), + activation_any=(), + effect=SourceQueryEffect.PURE_READ, + max_queries=1, + required=True, + ), + SourceFacetQueryContract( + feature=SourceFeature.OUTPUT, + scope=SourceFacetScope.CHANNEL, + fields=(SourceFieldId.OUTPUT,), + activation_any=(), + effect=SourceQueryEffect.PURE_READ, + max_queries=1, + required=True, + ), + ), + max_queries=16, + timeout_ms=2_000, + ), + safety_profile=SourceSafetyProfile(), + ) + + +def _config() -> WaveBenchConfig: + return WaveBenchConfig( + connection=ConnectionConfig("lan", "TCPIP::scope::INSTR", 1_000, 1_000), + scope=ScopeConfig("rtm2032", None, 1, False, True), + autoscale=AutoscaleConfig(True, True), + waveform=WaveformConfig("real", "lsbf", "DMAX"), + output=OutputConfig(Path("data/raw"), "timestamp_label", True, True, True, True, False), + source_path=Path("wavebench.toml"), + source=SourceConfig("example.source-v2", "TCPIP::source::INSTR", 1, False, True, 0), + safety_limits=SafetyLimitsConfig(), + ) + + +def _service( + *, + outputs: dict[int, bool] | None = None, + postcondition_mismatch: bool = False, + write_error: bool = False, + invalid_recovery_result_channels: tuple[int, ...] = (), +) -> tuple[SourceService, _WorkspaceDriver]: + session_state = InstrumentSessionState(epoch_id="source-arbitrary-workspace-v2") + driver = _WorkspaceDriver( + session_state=session_state, + outputs=outputs, + postcondition_mismatch=postcondition_mismatch, + write_error=write_error, + invalid_recovery_result_channels=invalid_recovery_result_channels, + ) + descriptor = replace( + source_descriptor(driver=driver, extensions=_extensions()), + capabilities=( + "source.snapshot_v2", + "source.output_v2", + "source.arbitrary_workspace_volatile_replace_v2", + ), + ) + validate_source_descriptor(descriptor) + validate_declared_capabilities(descriptor, driver) + return ( + SourceService( + config=_config(), + logger=CommandLogger(), + session=driver, # type: ignore[arg-type] + descriptor=descriptor, + transport=driver.transport, + session_state=session_state, + ), + driver, + ) + + +def _request(payload: bytes) -> SourceArbitraryWorkspaceVolatileReplaceRequest: + return SourceArbitraryWorkspaceVolatileReplaceRequest( + payload_sha256=_digest(payload), + payload_size_bytes=len(payload), + point_count=len(payload) // 2, + ) + + +def test_workspace_result_cannot_claim_previous_content_is_restorable() -> None: + payload = b"\x00\x00\xff\x3f" + + with pytest.raises(ValueError, match="cannot claim previous content is restorable"): + SourceArbitraryWorkspaceVolatileReplaceResult( + workspace_id="volatile", + payload_sha256=_digest(payload), + payload_size_bytes=len(payload), + point_count=len(payload) // 2, + write_completed=True, + content_readback_verified=False, + previous_content_restorable=True, + ) + + +def test_workspace_capability_requires_off_support_on_every_topology_channel() -> None: + extensions = replace( + _extensions(), + features=tuple( + replace(feature, directions=(SourceFeatureDirection.READ,)) + if feature.feature is SourceFeature.OUTPUT and feature.channels == (2,) + else feature + for feature in _extensions().features + ), + ) + session_state = InstrumentSessionState(epoch_id="workspace-missing-off-support") + driver = _WorkspaceDriver(session_state=session_state) + descriptor = replace( + source_descriptor(driver=driver, extensions=extensions), + capabilities=( + "source.snapshot_v2", + "source.output_v2", + "source.arbitrary_workspace_volatile_replace_v2", + ), + ) + + with pytest.raises(ConfigError, match="output DISABLE support on every topology channel"): + validate_source_descriptor(descriptor) + + +def test_workspace_volatile_replace_writes_once_without_claiming_a_channel() -> None: + service, driver = _service() + payload = b"\x00\x00\xff\x3f" + request = _request(payload) + + result, artifact = service.replace_arbitrary_workspace_volatile_v2( + request, + payload=payload, + correlation_id="arb-workspace", + ) + + assert driver.workspace_requests == [(request, payload)] + assert driver.output_requests == [] + assert driver.transport.counters.binary_write_completed == 1 + assert result.workspace_id == "volatile" + assert artifact["operation"] == "source.arbitrary_workspace_volatile_replace_v2" + assert artifact["final_state"] == { + "session_health": "healthy", + "topology_outputs_expected": "off", + "channel_selection": "unverified", + "content_readback_verified": False, + "previous_content": "unrecoverable", + } + assert payload.hex() not in repr(artifact) + assert [item["phase"] for item in artifact["phases"]] == [ + "preflight", + "main", + "postcondition", + ] + + +def test_workspace_volatile_replace_requires_every_output_off_before_binary_write() -> None: + service, driver = _service(outputs={1: False, 2: True}) + payload = b"\x00\x00\xff\x3f" + + with pytest.raises(ConfigError, match="every topology output OFF"): + service.replace_arbitrary_workspace_volatile_v2(_request(payload), payload=payload) + + assert driver.workspace_requests == [] + assert driver.transport.counters.binary_write_requests == 0 + assert driver.output_requests == [] + + +@pytest.mark.parametrize( + ("postcondition_mismatch", "write_error", "message"), + ( + (True, False, "postcondition reports an output ON"), + (False, True, "workspace binary write result is unknown"), + ), +) +def test_workspace_volatile_replace_recovers_every_output_after_main_failure( + postcondition_mismatch: bool, + write_error: bool, + message: str, +) -> None: + service, driver = _service( + postcondition_mismatch=postcondition_mismatch, + write_error=write_error, + ) + payload = b"\x00\x00\xff\x3f" + + with pytest.raises(ConfigError, match=message) as raised: + service.replace_arbitrary_workspace_volatile_v2(_request(payload), payload=payload) + + artifact = raised.value.source_operation_artifact + assert driver.transport.counters.binary_write_requests == 1 + assert driver.output_requests == [ + SourceOutputRequest(channel=1, enabled=False), + SourceOutputRequest(channel=2, enabled=False), + ] + assert artifact["recovery"] == { + "status": "off_verified", + "channels": [1, 2], + "session_health": "uncertain", + } + + +def test_workspace_recovery_continues_after_an_unproven_off_result() -> None: + service, driver = _service( + write_error=True, + invalid_recovery_result_channels=(1,), + ) + payload = b"\x00\x00\xff\x3f" + + with pytest.raises(ConfigError, match="workspace binary write result is unknown") as raised: + service.replace_arbitrary_workspace_volatile_v2(_request(payload), payload=payload) + + assert driver.output_requests == [ + SourceOutputRequest(channel=1, enabled=False), + SourceOutputRequest(channel=2, enabled=False), + ] + assert raised.value.source_operation_artifact["recovery"] == { + "status": "off_failed", + "channels": [1, 2], + "attempted_channels": [1, 2], + "failed_channels": [1], + "session_health": "uncertain", + } diff --git a/tests/test_source_basic_configure_v2.py b/tests/test_source_basic_configure_v2.py index 397ae9f..5191540 100644 --- a/tests/test_source_basic_configure_v2.py +++ b/tests/test_source_basic_configure_v2.py @@ -26,6 +26,7 @@ SourceAmplitudeUnit, SourceBasicConfigureRequest, SourceBasicConfigureResult, + SourceBasicLiveConfigureResult, SourceBasicPatch, SourceFeatureDirection, SourceFieldId, @@ -37,6 +38,7 @@ SourceRuntimeIdentity, SourceTypedObservation, SourceV1WriteRouteId, + SourceWaveformKind, PatchAction, PatchValue, ) @@ -108,6 +110,7 @@ def __init__( self.raise_after_write = raise_after_write self.basic = basic_facet() self.basic_requests: list[SourceBasicConfigureRequest] = [] + self.live_basic_requests: list[SourceBasicConfigureRequest] = [] self.output_requests: list[SourceOutputRequest] = [] self.v1_output_calls = 0 self.closed = False @@ -169,11 +172,33 @@ def configure_source_basic_v2( output_enabled=False, ) + def configure_source_basic_live_v2( + self, + request: SourceBasicConfigureRequest, + ) -> SourceBasicLiveConfigureResult: + self.transport.write("SOURCE:LIVE CONFIGURE") + self.live_basic_requests.append(request) + self.basic = self._apply_patch(request) + if self.raise_after_write: + raise ConfigError("fake live basic configure failed after write") + return SourceBasicLiveConfigureResult( + channel=request.channel, + basic=self.basic, + output_enabled=True, + ) + def set_source_output_v2(self, request: SourceOutputRequest) -> SourceOutputResult: self.transport.write("SOURCE:OUTPUT OFF") self.output_requests.append(request) self.output_enabled = request.enabled - return SourceOutputResult(channel=request.channel, enabled=request.enabled) + if not request.enabled: + return SourceOutputResult(channel=request.channel, enabled=False) + return SourceOutputResult( + channel=request.channel, + enabled=True, + final_amplitude=self.basic.amplitude.value, + final_offset_v=self.basic.offset_v.value, + ) def set_output(self, *args, **kwargs): del args, kwargs @@ -181,7 +206,9 @@ def set_output(self, *args, **kwargs): raise AssertionError("M5-B recovery must not fall back to the V1 output route") def _readback_basic(self): - if self.postcondition_frequency_hz is None or not self.basic_requests: + if self.postcondition_frequency_hz is None or not ( + self.basic_requests or self.live_basic_requests + ): return self.basic return replace( self.basic, @@ -274,6 +301,93 @@ def forbidden_direct_route(*args: object, **kwargs: object) -> object: raise AttributeError(name) +class _AdditiveV2Driver(_BasicWriteDriver): + """Expose V2 explicitly while retaining the complete legacy V1 routes.""" + + def __init__(self, **kwargs: object) -> None: + super().__init__(**kwargs) # type: ignore[arg-type] + self.v1_frequency_requests: list[tuple[int, float, bool, bool]] = [] + self.v1_function_requests: list[tuple[int, str, bool]] = [] + self.v1_amplitude_requests: list[tuple[int, float, bool]] = [] + self.v1_output_requests: list[tuple[int, bool, bool]] = [] + self.v1_upload_calls = 0 + + def get_status(self, channel: int) -> SourceStatus: + return SourceStatus( + channel=channel, + output="ON" if self.output_enabled else "OFF", + function="SIN", + frequency_hz=1_000.0, + amplitude=1.0, + amplitude_unit="VPP", + offset_v=0.0, + phase_deg=0.0, + frequency_mode="FIX", + sweep_enabled="OFF", + apply_raw=None, + square_duty_cycle_percent=50.0, + ) + + def set_frequency( + self, + channel: int, + value_hz: float, + *, + ensure_fix_mode: bool, + check_errors: bool, + ) -> SourceStatus: + self.v1_frequency_requests.append((channel, value_hz, ensure_fix_mode, check_errors)) + return replace(self.get_status(channel), frequency_hz=value_hz) + + def set_function( + self, + channel: int, + function: str, + *, + check_errors: bool, + ) -> SourceStatus: + self.v1_function_requests.append((channel, function, check_errors)) + return replace(self.get_status(channel), function=function.strip().upper()) + + def set_amplitude_vpp( + self, + channel: int, + value_vpp: float, + *, + check_errors: bool, + ) -> SourceStatus: + self.v1_amplitude_requests.append((channel, value_vpp, check_errors)) + return replace(self.get_status(channel), amplitude=value_vpp) + + def set_output( + self, + channel: int, + enabled: bool, + *, + check_errors: bool, + ) -> SourceStatus: + self.v1_output_requests.append((channel, enabled, check_errors)) + self.output_enabled = enabled + return self.get_status(channel) + + def upload_dg4000_dac14_block(self, **kwargs: object) -> SourceStatus: + self.v1_upload_calls += 1 + return SourceStatus( + channel=kwargs["channel"], # type: ignore[arg-type] + output="ON" if kwargs["output_on"] else "OFF", + function="USER", + frequency_hz=kwargs["playback_frequency_hz"], # type: ignore[arg-type] + amplitude=kwargs["amplitude_vpp"], # type: ignore[arg-type] + amplitude_unit="VPP", + offset_v=kwargs["offset_v"], # type: ignore[arg-type] + phase_deg=0.0, + frequency_mode="FIX", + sweep_enabled="OFF", + apply_raw=None, + square_duty_cycle_percent=None, + ) + + class _LegacyWaveformFallbackDriver(_BasicWriteDriver): """V1 function support retained outside a narrower V2 basic profile.""" @@ -328,7 +442,14 @@ def _config(*, limits: SafetyLimitsConfig = SafetyLimitsConfig()) -> WaveBenchCo ) -def _write_extensions(*, include_output: bool): +def _write_extensions( + *, + include_output: bool, + live_frequency: bool = False, + live_amplitude_vpp: bool = False, + waveform_kinds: tuple[SourceWaveformKind, ...] = (SourceWaveformKind.SINE,), + square_duty_readable: bool = False, +): extensions = source_extensions() basic, output = extensions.features return replace( @@ -340,6 +461,13 @@ def _write_extensions(*, include_output: bool): SourceFeatureDirection.CONFIGURE, SourceFeatureDirection.READ, ), + profile=replace( + basic.profile, + live_frequency_configurable=live_frequency, + live_amplitude_vpp_configurable=live_amplitude_vpp, + waveform_kinds=waveform_kinds, + square_duty_readable=square_duty_readable, + ), ), replace( output, @@ -361,10 +489,15 @@ def _service( *, combined: bool = True, include_output: bool = True, + include_live: bool = False, + live_frequency: bool = True, + live_amplitude_vpp: bool = True, output_enabled: bool = False, postcondition_frequency_hz: float | None = None, raise_after_write: bool = False, limits: SafetyLimitsConfig = SafetyLimitsConfig(), + waveform_kinds: tuple[SourceWaveformKind, ...] = (SourceWaveformKind.SINE,), + square_duty_readable: bool = False, ) -> tuple[SourceService, _BasicWriteDriver]: session_state = InstrumentSessionState(epoch_id="source-basic-v2") driver = _BasicWriteDriver( @@ -375,10 +508,18 @@ def _service( raise_after_write=raise_after_write, ) - extensions = _write_extensions(include_output=include_output) + extensions = _write_extensions( + include_output=include_output, + live_frequency=(include_live and live_frequency), + live_amplitude_vpp=(include_live and live_amplitude_vpp), + waveform_kinds=waveform_kinds, + square_duty_readable=square_duty_readable, + ) capabilities = ["source.snapshot_v2", "source.basic_configure_v2"] if include_output: capabilities.append("source.output_v2") + if include_live: + capabilities.append("source.basic_live_configure_v2") descriptor = replace( source_descriptor(driver=driver, extensions=extensions), capabilities=tuple(capabilities), @@ -480,6 +621,52 @@ def _dual_contract_service() -> tuple[SourceService, _DualContractDriver]: ) +def _additive_v2_service() -> tuple[SourceService, _AdditiveV2Driver]: + session_state = InstrumentSessionState(epoch_id="source-additive-v2") + driver = _AdditiveV2Driver(session_state=session_state, combined=True) + descriptor = replace( + source_descriptor( + driver=driver, + extensions=replace( + _write_extensions( + include_output=True, + live_frequency=True, + live_amplitude_vpp=True, + ), + v1_route_migration_enabled=False, + ), + ), + capabilities=( + "source.snapshot_v2", + "source.basic_configure_v2", + "source.basic_live_configure_v2", + "source.output_v2", + "source.status", + "source.set_frequency", + "source.set_function", + "source.set_amplitude_vpp", + "source.output", + "source.arbitrary_upload", + ), + ) + validate_source_descriptor(descriptor) + validate_declared_capabilities(descriptor, driver) + config = _config() + assert config.source is not None + config = replace(config, source=replace(config.source, check_errors=False)) + return ( + SourceService( + config=config, + logger=CommandLogger(), + session=driver, # type: ignore[arg-type] + descriptor=descriptor, + transport=driver.transport, + session_state=session_state, + ), + driver, + ) + + def _frequency_request(value_hz: float = 2_000.0) -> SourceBasicConfigureRequest: return SourceBasicConfigureRequest( channel=1, @@ -489,6 +676,48 @@ def _frequency_request(value_hz: float = 2_000.0) -> SourceBasicConfigureRequest ) +def test_basic_live_capability_requires_off_basic_and_output_transactions() -> None: + driver = _BasicWriteDriver( + session_state=InstrumentSessionState(epoch_id="source-live-dependencies"), + combined=True, + ) + descriptor = replace( + source_descriptor( + driver=driver, + extensions=_write_extensions( + include_output=False, + live_frequency=True, + ), + ), + capabilities=("source.snapshot_v2", "source.basic_live_configure_v2"), + ) + + with pytest.raises(ConfigError, match="requires source.basic_configure_v2"): + validate_source_descriptor(descriptor) + + +def test_basic_live_capability_requires_explicit_per_field_profile() -> None: + driver = _BasicWriteDriver( + session_state=InstrumentSessionState(epoch_id="source-live-profile"), + combined=True, + ) + descriptor = replace( + source_descriptor( + driver=driver, + extensions=_write_extensions(include_output=True), + ), + capabilities=( + "source.snapshot_v2", + "source.basic_configure_v2", + "source.output_v2", + "source.basic_live_configure_v2", + ), + ) + + with pytest.raises(ConfigError, match="per-channel fixed-mode live"): + validate_source_descriptor(descriptor) + + @pytest.mark.parametrize("combined", (True, False)) def test_basic_configure_v2_public_service_supports_combined_and_scalar_queries( combined: bool, @@ -517,6 +746,150 @@ def test_basic_configure_v2_public_service_supports_combined_and_scalar_queries( assert "SOURCE:STATE?" not in repr(artifact) +def test_basic_live_configure_v2_public_service_keeps_output_on() -> None: + service, driver = _service(output_enabled=True, include_live=True) + request = _frequency_request() + + result, artifact = service.configure_basic_live_v2( + request, + correlation_id="basic-live-write", + ) + + assert result.output_enabled is True + assert result.basic.frequency_hz.value == 2_000.0 + assert driver.basic_requests == [] + assert driver.live_basic_requests == [request] + assert driver.output_requests == [] + assert driver.transport.counters.write_completed == 1 + assert driver.transport.counters.query_calls == 2 + assert artifact["operation"] == "source.basic_live_configure_v2" + assert artifact["capability_decision"]["capability"] == ( + "source.basic_live_configure_v2" + ) + assert artifact["final_state"] == { + "session_health": "healthy", + "output_expected": "on", + } + + +def test_v1_live_capable_basic_route_uses_off_transaction_when_output_is_off() -> None: + service, driver = _service(include_live=True) + + status = service.set_frequency(channel=1, value_hz=2_000.0) + + assert status.output == "OFF" + assert driver.basic_requests == [_frequency_request()] + assert driver.live_basic_requests == [] + assert driver.output_requests == [] + assert driver.transport.counters.write_completed == 1 + + +@pytest.mark.parametrize( + ("method", "value", "expected_field"), + ( + ("set_frequency", 2_000.0, "frequency_hz"), + ("set_amplitude_vpp", 1.5, "amplitude_vpp"), + ), +) +def test_v1_live_basic_routes_do_not_cycle_output( + method: str, + value: float, + expected_field: str, +) -> None: + service, driver = _service(output_enabled=True, include_live=True) + + status = getattr(service, method)(channel=1, **{ + "value_hz" if method == "set_frequency" else "value_vpp": value, + }) + + assert status.output == "ON" + assert driver.basic_requests == [] + assert len(driver.live_basic_requests) == 1 + assert getattr(driver.live_basic_requests[0].patch, expected_field).value == value + assert driver.output_requests == [] + assert driver.transport.counters.write_completed == 1 + assert driver.transport.counters.query_calls == 2 + + +def test_frequency_response_style_live_sequence_never_cycles_output() -> None: + service, driver = _service(output_enabled=True, include_live=True) + + for amplitude_vpp in (0.5, 1.0): + service.set_amplitude_vpp(channel=1, value_vpp=amplitude_vpp) + for frequency_hz in (100.0, 1_000.0): + status = service.set_frequency(channel=1, value_hz=frequency_hz) + assert status.output == "ON" + + assert len(driver.live_basic_requests) == 6 + assert driver.basic_requests == [] + assert driver.output_requests == [] + assert driver.transport.counters.write_completed == 6 + assert driver.transport.counters.query_calls == 12 + + +def test_basic_live_configure_v2_rejects_output_off_before_write() -> None: + service, driver = _service(include_live=True) + + with pytest.raises(ConfigError, match="target output ON"): + service.configure_basic_live_v2(_frequency_request()) + + assert driver.live_basic_requests == [] + assert driver.output_requests == [] + assert driver.transport.counters.write_requests == 0 + + +def test_basic_live_configure_v2_rejects_multiple_fields_before_write() -> None: + service, driver = _service(output_enabled=True, include_live=True) + request = SourceBasicConfigureRequest( + channel=1, + patch=SourceBasicPatch( + frequency_hz=PatchValue(PatchAction.SET, 2_000.0), + amplitude_vpp=PatchValue(PatchAction.SET, 1.5), + ), + ) + + with pytest.raises(ConfigError, match="exactly one"): + service.configure_basic_live_v2(request) + + assert driver.live_basic_requests == [] + assert driver.transport.counters.write_requests == 0 + + +def test_basic_live_configure_v2_enforces_per_field_profile() -> None: + service, driver = _service( + output_enabled=True, + include_live=True, + live_frequency=False, + live_amplitude_vpp=True, + ) + + with pytest.raises(ConfigError, match="frequency_hz is not declared"): + service.configure_basic_live_v2(_frequency_request()) + + assert driver.live_basic_requests == [] + assert driver.transport.counters.write_requests == 0 + + +def test_basic_live_configure_v2_rejects_safety_limit_before_write() -> None: + service, driver = _service( + output_enabled=True, + include_live=True, + limits=SafetyLimitsConfig(max_source_vpp=2.0), + ) + request = SourceBasicConfigureRequest( + channel=1, + patch=SourceBasicPatch( + amplitude_vpp=PatchValue(PatchAction.SET, 2.5), + ), + ) + + with pytest.raises(ConfigError, match="max_source_vpp"): + service.configure_basic_live_v2(request) + + assert driver.live_basic_requests == [] + assert driver.transport.counters.write_requests == 0 + + def test_v1_frequency_route_maps_to_v2_for_a_dual_contract_driver() -> None: service, driver = _service() assert service.descriptor is not None @@ -590,9 +963,146 @@ def test_v1_function_outside_the_v2_profile_keeps_its_legacy_route( assert driver.transport.counters.write_completed == 1 -def test_v1_restore_route_rejects_before_io_for_a_dual_contract_driver() -> None: +def test_v1_restore_route_uses_v2_basic_and_output_transactions() -> None: + service, driver = _service() + + status = service.restore_restorable_state( + RestorableSourceState( + channel=1, + output="OFF", + function="SIN", + frequency_hz=1_000.0, + amplitude_vpp=1.0, + amplitude_unit="VPP", + ) + ) + + assert status.output == "OFF" + assert driver.basic_requests == [ + SourceBasicConfigureRequest( + channel=1, + patch=SourceBasicPatch( + waveform_kind=PatchValue(PatchAction.SET, SourceWaveformKind.SINE), + ), + ), + SourceBasicConfigureRequest( + channel=1, + patch=SourceBasicPatch( + amplitude_vpp=PatchValue(PatchAction.SET, 1.0), + ), + ), + SourceBasicConfigureRequest( + channel=1, + patch=SourceBasicPatch( + frequency_hz=PatchValue(PatchAction.SET, 1_000.0), + ), + ), + ] + assert driver.output_requests == [] + assert driver.transport.counters.write_requests == 3 + + +def test_v1_restore_v2_splits_square_duty_after_waveform_restore() -> None: + service, driver = _service( + waveform_kinds=(SourceWaveformKind.SINE, SourceWaveformKind.SQUARE), + square_duty_readable=True, + ) + + service.restore_restorable_state( + RestorableSourceState( + channel=1, + output="OFF", + function="SQU", + frequency_hz=1_000.0, + amplitude_vpp=1.0, + amplitude_unit="VPP", + square_duty_cycle_percent=25.0, + ) + ) + + assert [ + next( + name + for name, value in ( + ("waveform_kind", request.patch.waveform_kind), + ("amplitude_vpp", request.patch.amplitude_vpp), + ("frequency_hz", request.patch.frequency_hz), + ("square_duty_cycle_percent", request.patch.square_duty_cycle_percent), + ) + if value.action is PatchAction.SET + ) + for request in driver.basic_requests + ] == [ + "waveform_kind", + "amplitude_vpp", + "frequency_hz", + "square_duty_cycle_percent", + ] + + +def test_restorable_snapshot_uses_v2_when_the_full_restore_route_is_declared() -> None: service, driver = _service() + state = service.snapshot_restorable_state(channel=1) + + assert state == RestorableSourceState( + channel=1, + output="OFF", + function="SIN", + frequency_hz=1_000.0, + amplitude_vpp=1.0, + amplitude_unit="VPP", + ) + assert driver.transport.counters.query_calls > 0 + assert driver.transport.counters.write_requests == 0 + + +def test_v2_restore_rejects_unmappable_waveform_before_turning_output_off() -> None: + service, driver = _service(output_enabled=True) + + with pytest.raises(ConfigError, match="cannot map this waveform"): + service.restore_restorable_state( + RestorableSourceState( + channel=1, + output="ON", + function="USER", + frequency_hz=1_000.0, + amplitude_vpp=1.0, + amplitude_unit="VPP", + ) + ) + + assert driver.basic_requests == [] + assert driver.output_requests == [] + assert driver.transport.counters.write_requests == 0 + + +def test_v1_restore_route_restores_original_on_state_through_v2_output() -> None: + service, driver = _service(output_enabled=True) + + status = service.restore_restorable_state( + RestorableSourceState( + channel=1, + output="ON", + function="SIN", + frequency_hz=1_000.0, + amplitude_vpp=1.0, + amplitude_unit="VPP", + ) + ) + + assert status.output == "ON" + assert driver.output_requests == [ + SourceOutputRequest(channel=1, enabled=False), + SourceOutputRequest(channel=1, enabled=True), + ] + assert len(driver.basic_requests) == 3 + assert driver.transport.counters.write_requests == 5 + + +def test_v1_restore_route_rejects_partial_v2_restore_before_io() -> None: + service, driver = _service(include_output=False) + with pytest.raises(ConfigError, match="restore_restorable_state cannot run"): service.restore_restorable_state( RestorableSourceState( @@ -608,6 +1118,82 @@ def test_v1_restore_route_rejects_before_io_for_a_dual_contract_driver() -> None assert driver.transport.counters.write_requests == 0 +def test_additive_v2_keeps_legacy_routes_and_exposes_explicit_v2(tmp_path: Path) -> None: + service, driver = _additive_v2_service() + + status = service.set_frequency(channel=1, value_hz=2_000.0) + + assert status.frequency_hz == 2_000.0 + assert driver.v1_frequency_requests == [(1, 2_000.0, True, False)] + assert driver.basic_requests == [] + assert service._declares_source_v2_basic_restore() is False + + result, _ = service.configure_basic_v2(_frequency_request(3_000.0)) + + assert result.basic.frequency_hz.value == 3_000.0 + assert driver.basic_requests == [_frequency_request(3_000.0)] + + output = service.set_output(channel=1, enabled=True) + + assert output.output == "ON" + assert driver.v1_output_requests == [(1, True, False)] + assert driver.output_requests == [] + + service.set_output(channel=1, enabled=False) + waveform = tmp_path / "waveform.csv" + waveform.write_text("0\n1\n", encoding="utf-8") + uploaded = service.upload_arbitrary_waveform( + channel=1, + file_path=str(waveform), + playback_frequency_hz=1_000.0, + amplitude_vpp=1.0, + ) + + assert uploaded.function == "USER" + assert driver.v1_upload_calls == 1 + + restored = service.restore_restorable_state( + RestorableSourceState( + channel=1, + output="ON", + function="SIN", + frequency_hz=1_000.0, + amplitude_vpp=1.0, + amplitude_unit="VPP", + ) + ) + + assert restored.output == "ON" + assert driver.v1_function_requests == [(1, "SIN", False)] + assert driver.v1_amplitude_requests == [(1, 1.0, False)] + assert driver.v1_frequency_requests[-1] == (1, 1_000.0, True, False) + assert driver.v1_output_requests[-2:] == [(1, False, False), (1, True, False)] + assert driver.output_requests == [] + + +def test_migrating_v2_keeps_v1_arbitrary_upload_rejected_before_file_load() -> None: + service, driver = _additive_v2_service() + assert service.descriptor is not None + assert service.descriptor.source_extensions is not None + service.descriptor = replace( + service.descriptor, + source_extensions=replace( + service.descriptor.source_extensions, + v1_route_migration_enabled=True, + ), + ) + + with pytest.raises(ConfigError, match="cannot run for a Source V2 write driver"): + service.upload_arbitrary_waveform( + channel=1, + file_path="does-not-exist.csv", + playback_frequency_hz=1_000.0, + amplitude_vpp=1.0, + ) + + assert driver.v1_upload_calls == 0 + + @pytest.mark.parametrize( "operation", ("upload", "trigger_burst", "trigger_sweep"), @@ -645,6 +1231,7 @@ def test_dual_contract_driver_classifies_every_v1_write_route() -> None: SourceV1WriteRouteId.SET_AMPLITUDE_VPP, SourceV1WriteRouteId.SET_SQUARE_DUTY_CYCLE, SourceV1WriteRouteId.SET_OUTPUT, + SourceV1WriteRouteId.RESTORE, } assert len(driver.basic_requests) == 4 @@ -743,6 +1330,16 @@ def test_dual_contract_driver_classifies_every_v1_write_route() -> None: "configure_sweep", ] + service.restore_restorable_state( + RestorableSourceState( + channel=1, + output="OFF", + function="SIN", + frequency_hz=1_000.0, + amplitude_vpp=1.0, + amplitude_unit="VPP", + ) + ) writes_before_rejections = driver.transport.counters.write_requests with pytest.raises(ConfigError, match="cannot run for a Source V2 write driver"): service.trigger_burst(channel=1) @@ -755,23 +1352,10 @@ def test_dual_contract_driver_classifies_every_v1_write_route() -> None: playback_frequency_hz=1_000.0, amplitude_vpp=1.0, ) - with pytest.raises(ConfigError, match="restore_restorable_state cannot run"): - service.restore_restorable_state( - RestorableSourceState( - channel=1, - output="OFF", - function="SIN", - frequency_hz=1_000.0, - amplitude_vpp=1.0, - amplitude_unit="VPP", - ) - ) - rejected_routes = { SourceV1WriteRouteId.TRIGGER_BURST, SourceV1WriteRouteId.TRIGGER_SWEEP, SourceV1WriteRouteId.UPLOAD_ARBITRARY, - SourceV1WriteRouteId.RESTORE, } assert driver.transport.counters.write_requests == writes_before_rejections assert mapped_routes | disjoint_routes | rejected_routes == set(SourceV1WriteRouteId) @@ -845,6 +1429,47 @@ def test_basic_configure_v2_postcondition_mismatch_runs_one_off_recovery() -> No assert service.session_state.health is SessionHealth.UNCERTAIN +def test_basic_live_configure_v2_postcondition_mismatch_runs_one_off_recovery() -> None: + service, driver = _service( + output_enabled=True, + include_live=True, + postcondition_frequency_hz=2_001.0, + ) + + with pytest.raises(ConfigError, match="frequency_hz readback") as raised: + service.configure_basic_live_v2(_frequency_request()) + + artifact = raised.value.source_operation_artifact + assert driver.basic_requests == [] + assert driver.live_basic_requests == [_frequency_request()] + assert driver.output_requests == [SourceOutputRequest(channel=1, enabled=False)] + assert driver.transport.counters.write_completed == 2 + assert artifact["operation"] == "source.basic_live_configure_v2" + assert artifact["recovery"] == { + "status": "off_verified", + "session_health": "uncertain", + } + assert artifact["safe_state_verified"] is True + assert artifact["final_state"]["session_health"] == "uncertain" + assert service.session_state is not None + assert service.session_state.health is SessionHealth.UNCERTAIN + + +def test_basic_live_configure_v2_failure_is_not_retried() -> None: + service, driver = _service( + output_enabled=True, + include_live=True, + raise_after_write=True, + ) + + with pytest.raises(ConfigError, match="failed after write"): + service.configure_basic_live_v2(_frequency_request()) + + assert driver.live_basic_requests == [_frequency_request()] + assert driver.output_requests == [SourceOutputRequest(channel=1, enabled=False)] + assert driver.transport.counters.write_requests == 2 + + def test_basic_configure_v2_never_falls_back_to_v1_output_for_recovery() -> None: service, driver = _service(include_output=False, raise_after_write=True) diff --git a/tests/test_source_budget.py b/tests/test_source_budget.py index 64cccad..b5fa4ee 100644 --- a/tests/test_source_budget.py +++ b/tests/test_source_budget.py @@ -14,6 +14,7 @@ HarmonicCompleteness, HarmonicFacet, ModulationFacet, + NoiseOverlayFacet, Observed, OutputFacet, ResistanceBounds, @@ -22,6 +23,7 @@ SourceAmplitudeUnit, SourceArbitraryOvershootConstraint, SourceArbitraryPlaybackMode, + SourceBudgetBlockerCode, SourceChannelStateV2, SourceComponentAmplitude, SourceConstraintApplicability, @@ -42,6 +44,8 @@ SourceModulationEnvelopeConstraint, SourceModulationKind, SourceModulationSource, + SourceNoiseOverlayScale, + SourceNoiseOverlayScaleKind, SourceOutputPolarity, SourceNoisePeakConstraint, SourceReasonCode, @@ -145,6 +149,7 @@ def _channel( modulation: Observed[ModulationFacet] | None = None, sweep: Observed[SweepFacet] | None = None, arbitrary: Observed[ArbitraryFacet] | None = None, + noise_overlay: Observed[NoiseOverlayFacet] | None = None, ) -> SourceChannelStateV2: return SourceChannelStateV2( channel=channel, @@ -178,6 +183,8 @@ def _channel( burst=_missing(), pulse=_missing(), arbitrary=arbitrary or _missing(), + sync=_missing(), + noise_overlay=noise_overlay or _missing(), ) @@ -353,6 +360,122 @@ def test_basic_open_circuit_budget_is_pure_and_authorizable() -> None: assert budget.shared_power.availability is Availability.NOT_APPLICABLE +def test_disabled_noise_overlay_preserves_the_basic_waveform_budget() -> None: + extensions = _extensions(safety_profile=_constraints()) + without_overlay = _snapshot(extensions, (_channel(1),)) + with_overlay = _snapshot( + extensions, + ( + _channel( + 1, + noise_overlay=Observed.value_of( + NoiseOverlayFacet( + enabled=Observed.value_of(False), + scales=Observed.value_of( + ( + SourceNoiseOverlayScale( + SourceNoiseOverlayScaleKind.PERCENT, + 10.0, + ), + ) + ), + ) + ), + ), + ), + ) + + assert evaluate_source_output_budget( + _request( + with_overlay, + extensions, + terminations=(_termination(with_overlay, 1),), + ) + ) == evaluate_source_output_budget( + _request( + without_overlay, + extensions, + terminations=(_termination(without_overlay, 1),), + ) + ) + + +def test_enabled_noise_overlay_does_not_reuse_standalone_noise_peak_constraint() -> None: + extensions = _extensions( + safety_profile=_constraints( + _constraint( + "safety.noise", + SourceSafetyConstraintKind.NOISE_PEAK, + SourceNoisePeakConstraint(0.25), + ) + ) + ) + snapshot = _snapshot( + extensions, + ( + _channel( + 1, + noise_overlay=Observed.value_of( + NoiseOverlayFacet( + enabled=Observed.value_of(True), + scales=Observed.value_of( + ( + SourceNoiseOverlayScale( + SourceNoiseOverlayScaleKind.PERCENT, + 10.0, + ), + ) + ), + ) + ), + ), + ), + ) + + budget = evaluate_source_output_budget( + _request(snapshot, extensions, terminations=(_termination(snapshot, 1),)) + ) + + assert not budget.can_authorize_energy + assert budget.bounds.availability is Availability.NOT_QUERIED + assert budget.blockers == (SourceBudgetBlockerCode.NOISE_OVERLAY_BOUND_MISSING,) + + +@pytest.mark.parametrize( + "noise_overlay", + ( + pytest.param( + _missing(Availability.NOT_QUERIED), + id="facet-not-queried", + ), + pytest.param( + Observed.value_of( + NoiseOverlayFacet( + enabled=_missing(Availability.NOT_QUERIED), + scales=_missing(Availability.NOT_QUERIED), + ) + ), + id="enabled-not-queried", + ), + ), +) +def test_unknown_noise_overlay_state_fails_closed( + noise_overlay: Observed[NoiseOverlayFacet], +) -> None: + extensions = _extensions(safety_profile=_constraints()) + snapshot = _snapshot( + extensions, + (_channel(1, noise_overlay=noise_overlay),), + ) + + budget = evaluate_source_output_budget( + _request(snapshot, extensions, terminations=(_termination(snapshot, 1),)) + ) + + assert not budget.can_authorize_energy + assert SourceBudgetBlockerCode.NOISE_OVERLAY_BOUND_MISSING in budget.blockers + + def test_missing_or_non_resistive_termination_fails_closed() -> None: extensions = _extensions(safety_profile=_constraints()) snapshot = _snapshot(extensions, (_channel(1),)) diff --git a/tests/test_source_burst_v2.py b/tests/test_source_burst_v2.py index 8d4ea26..09d7ff5 100644 --- a/tests/test_source_burst_v2.py +++ b/tests/test_source_burst_v2.py @@ -36,6 +36,8 @@ SourceFeatureCapability, SourceFeatureDirection, SourceFieldId, + SourceFireRequest, + SourceFireResult, SourceGatePolarity, SourceOutputPolarity, SourceOutputRequest, @@ -89,6 +91,7 @@ def _burst( phase_deg: float = 30.0, internal_period_s: float = 0.25, delay_s: float = 0.5, + trigger_source: SourceTriggerSource = SourceTriggerSource.INTERNAL, ) -> BurstFacet: return BurstFacet( enabled=Observed.value_of(True), @@ -100,7 +103,7 @@ def _burst( gate_polarity=Observed.value_of(SourceGatePolarity.NORMAL), trigger=Observed.value_of( SourceTriggerState( - source=Observed.value_of(SourceTriggerSource.INTERNAL), + source=Observed.value_of(trigger_source), slope=Observed.value_of(SourceTriggerSlope.POSITIVE), output=Observed.value_of(SourceTriggerOutput.OFF), ) @@ -115,6 +118,8 @@ def __init__( session_state: InstrumentSessionState, output_enabled: bool = False, postcondition_mismatch: bool = False, + post_fire_mismatch: bool = False, + raise_after_fire: bool = False, ) -> None: self.transport = GuardedAuditedTransport( _TextTransport(), @@ -122,8 +127,11 @@ def __init__( ) self.output_enabled = output_enabled self.postcondition_mismatch = postcondition_mismatch + self.post_fire_mismatch = post_fire_mismatch + self.raise_after_fire = raise_after_fire self.burst = _burst() self.burst_requests: list[SourceBurstConfigureRequest] = [] + self.fire_requests: list[SourceFireRequest] = [] self.output_requests: list[SourceOutputRequest] = [] self.v1_burst_calls = 0 self.v1_trigger_calls = 0 @@ -182,6 +190,7 @@ def configure_source_burst_v2( phase_deg=request.phase_deg, internal_period_s=request.internal_period_s, delay_s=request.delay_s, + trigger_source=request.trigger_source, ) return SourceBurstConfigureResult( channel=request.channel, @@ -189,12 +198,25 @@ def configure_source_burst_v2( output_enabled=False, ) + def fire_source_burst_v2(self, request: SourceFireRequest) -> SourceFireResult: + self.transport.write("SOURCE:BURST:FIRE") + self.fire_requests.append(request) + if self.raise_after_fire: + raise ConfigError("fake Burst fire failed after write") + return SourceFireResult(channel=request.channel) + def set_source_output_v2(self, request: SourceOutputRequest) -> SourceOutputResult: self.transport.write("SOURCE:OUTPUT") self.output_requests.append(request) self.output_enabled = request.enabled if request.enabled: - raise AssertionError("the Burst fixture only uses recovery OFF") + basic = basic_facet() + return SourceOutputResult( + channel=request.channel, + enabled=True, + final_amplitude=basic.amplitude.value, + final_offset_v=basic.offset_v.value, + ) return SourceOutputResult(channel=request.channel, enabled=False) def configure_burst(self, *args: object, **kwargs: object) -> object: @@ -215,6 +237,11 @@ def _output(self) -> OutputFacet: ) def _readback_burst(self) -> BurstFacet: + if self.post_fire_mismatch and self.fire_requests: + return _burst( + delay_s=self.burst.delay_s.value * 2.0, + trigger_source=self.burst.trigger.value.source.value, + ) if not self.postcondition_mismatch or not self.burst_requests: return self.burst request = self.burst_requests[-1] @@ -226,22 +253,30 @@ def _readback_burst(self) -> BurstFacet: ) -def _extensions(): +def _extensions(*, include_fire: bool = False): base = source_extensions() basic, output = base.features burst = SourceFeatureCapability( feature=SourceFeature.BURST, support=SupportState.SUPPORTED, - directions=(SourceFeatureDirection.CONFIGURE, SourceFeatureDirection.READ), + directions=( + SourceFeatureDirection.CONFIGURE, + *((SourceFeatureDirection.FIRE,) if include_fire else ()), + SourceFeatureDirection.READ, + ), scope=SourceFacetScope.CHANNEL, channels=(1,), applicability=SourceConstraintApplicability(), profile=SourceBurstCapabilityProfile( modes=(SourceBurstMode.TRIGGERED,), - trigger_sources=(SourceTriggerSource.INTERNAL,), + trigger_sources=( + SourceTriggerSource.INTERNAL, + *((SourceTriggerSource.MANUAL,) if include_fire else ()), + ), timing_readable=True, gate_readable=False, triggered_internal_configuration_readable=True, + triggered_manual_configuration_readable=include_fire, ), ) burst_query = SourceFacetQueryContract( @@ -304,23 +339,30 @@ def _service( *, output_enabled: bool = False, postcondition_mismatch: bool = False, + post_fire_mismatch: bool = False, + raise_after_fire: bool = False, dual_contract: bool = False, + include_fire: bool = False, ) -> tuple[SourceService, _BurstWriteDriver]: session_state = InstrumentSessionState(epoch_id="source-burst-v2") driver = _BurstWriteDriver( session_state=session_state, output_enabled=output_enabled, postcondition_mismatch=postcondition_mismatch, + post_fire_mismatch=post_fire_mismatch, + raise_after_fire=raise_after_fire, ) capabilities = [ "source.snapshot_v2", "source.burst_configure_v2", "source.output_v2", ] + if include_fire: + capabilities.append("source.burst_fire_v2") if dual_contract: capabilities.extend(("source.burst_configure", "source.burst_trigger")) descriptor = replace( - source_descriptor(driver=driver, extensions=_extensions()), + source_descriptor(driver=driver, extensions=_extensions(include_fire=include_fire)), capabilities=tuple(capabilities), ) validate_source_descriptor(descriptor) @@ -338,15 +380,54 @@ def _service( ) -def _request() -> SourceBurstConfigureRequest: +def _request( + *, + trigger_source: SourceTriggerSource = SourceTriggerSource.INTERNAL, +) -> SourceBurstConfigureRequest: return SourceBurstConfigureRequest( channel=1, cycles=12, phase_deg=30.0, internal_period_s=0.25, delay_s=0.5, + trigger_source=trigger_source, + ) + + +def test_burst_fire_capability_requires_manual_configuration_readback() -> None: + session_state = InstrumentSessionState(epoch_id="source-burst-fire-profile") + driver = _BurstWriteDriver(session_state=session_state) + extensions = _extensions(include_fire=True) + basic, burst, output = extensions.features + descriptor = replace( + source_descriptor( + driver=driver, + extensions=replace( + extensions, + features=( + basic, + replace( + burst, + profile=replace( + burst.profile, + triggered_manual_configuration_readable=False, + ), + ), + output, + ), + ), + ), + capabilities=( + "source.snapshot_v2", + "source.burst_configure_v2", + "source.burst_fire_v2", + "source.output_v2", + ), ) + with pytest.raises(ConfigError, match="readable manual triggered burst"): + validate_source_descriptor(descriptor) + def test_burst_configure_v2_writes_once_and_keeps_output_off() -> None: service, driver = _service() @@ -440,3 +521,120 @@ def test_v1_restore_rejects_before_io_for_a_burst_v2_driver() -> None: assert driver.transport.counters.write_requests == 0 assert driver.transport.counters.query_calls == 0 + + +def test_burst_fire_v2_reuses_configuring_session_and_keeps_output_on() -> None: + service, driver = _service(include_fire=True) + configured, configure_artifact = service.configure_burst_v2( + _request(trigger_source=SourceTriggerSource.MANUAL) + ) + service.set_output_v2(SourceOutputRequest(channel=1, enabled=True)) + + result, artifact = service.fire_burst_v2( + SourceFireRequest(channel=1), + correlation_id="burst-fire", + ) + + assert result == SourceFireResult(channel=1) + assert configured.burst.trigger.value.source.value is SourceTriggerSource.MANUAL + assert configure_artifact["request"]["trigger_source"] == "manual" + assert driver.fire_requests == [SourceFireRequest(channel=1)] + assert driver.output_enabled is True + assert driver.output_requests == [SourceOutputRequest(channel=1, enabled=True)] + assert artifact["operation"] == "source.burst_fire_v2" + assert artifact["persistent_session_verified"] is True + assert artifact["postcondition"]["emission_verified"] is False + assert artifact["postcondition"]["external_measurement_required"] is True + assert artifact["final_state"] == { + "session_health": "healthy", + "output_expected": "on", + } + + +def test_burst_fire_v2_requires_same_session_configuration_before_io() -> None: + service, driver = _service(include_fire=True) + + with pytest.raises(ConfigError, match="configuration from the same session"): + service.fire_burst_v2(SourceFireRequest(channel=1)) + + assert driver.fire_requests == [] + assert driver.transport.counters.query_calls == 0 + assert driver.transport.counters.write_requests == 0 + + +def test_burst_fire_v2_requires_persistent_session_before_io() -> None: + service, driver = _service(include_fire=True) + service.session = None + + with pytest.raises(ConfigError, match="persistent source session"): + service.fire_burst_v2(SourceFireRequest(channel=1)) + + assert driver.fire_requests == [] + assert driver.transport.counters.query_calls == 0 + assert driver.transport.counters.write_requests == 0 + + +def test_burst_fire_v2_requires_output_on_before_fire_write() -> None: + service, driver = _service(include_fire=True) + service.configure_burst_v2(_request(trigger_source=SourceTriggerSource.MANUAL)) + + with pytest.raises(ConfigError, match="target output ON"): + service.fire_burst_v2(SourceFireRequest(channel=1)) + + assert driver.fire_requests == [] + assert driver.output_requests == [] + + +def test_burst_fire_v2_rejects_internal_trigger_configuration() -> None: + service, driver = _service(include_fire=True) + service.configure_burst_v2(_request()) + service.set_output_v2(SourceOutputRequest(channel=1, enabled=True)) + + with pytest.raises(ConfigError, match="manual trigger source"): + service.fire_burst_v2(SourceFireRequest(channel=1)) + + assert driver.fire_requests == [] + assert driver.output_enabled is True + + +def test_burst_fire_v2_failure_is_not_retried_and_recovers_off() -> None: + service, driver = _service(include_fire=True, raise_after_fire=True) + service.configure_burst_v2(_request(trigger_source=SourceTriggerSource.MANUAL)) + service.set_output_v2(SourceOutputRequest(channel=1, enabled=True)) + + with pytest.raises(ConfigError, match="failed after write") as raised: + service.fire_burst_v2(SourceFireRequest(channel=1)) + + artifact = raised.value.source_operation_artifact + assert driver.fire_requests == [SourceFireRequest(channel=1)] + assert driver.output_requests == [ + SourceOutputRequest(channel=1, enabled=True), + SourceOutputRequest(channel=1, enabled=False), + ] + assert driver.output_enabled is False + assert artifact["recovery"]["status"] == "off_verified" + assert artifact["final_state"]["output_expected"] == "off" + + +def test_burst_fire_v2_postcondition_mismatch_recovers_off() -> None: + service, driver = _service(include_fire=True, post_fire_mismatch=True) + service.configure_burst_v2(_request(trigger_source=SourceTriggerSource.MANUAL)) + service.set_output_v2(SourceOutputRequest(channel=1, enabled=True)) + + with pytest.raises(ConfigError, match="same-session receipt"): + service.fire_burst_v2(SourceFireRequest(channel=1)) + + assert driver.fire_requests == [SourceFireRequest(channel=1)] + assert driver.output_requests[-1] == SourceOutputRequest(channel=1, enabled=False) + assert driver.output_enabled is False + + +def test_v1_burst_trigger_maps_to_fire_v2_when_declared() -> None: + service, driver = _service(include_fire=True, dual_contract=True) + service.configure_burst_v2(_request(trigger_source=SourceTriggerSource.MANUAL)) + service.set_output_v2(SourceOutputRequest(channel=1, enabled=True)) + + service.trigger_burst(channel=1) + + assert driver.fire_requests == [SourceFireRequest(channel=1)] + assert driver.v1_trigger_calls == 0 diff --git a/tests/test_source_conformance.py b/tests/test_source_conformance.py index 33faad8..c9f61e5 100644 --- a/tests/test_source_conformance.py +++ b/tests/test_source_conformance.py @@ -9,6 +9,7 @@ from tests.source_v2_fixtures import source_descriptor, source_extensions from wavebench.errors import ConfigError from wavebench.instruments.source_conformance import ( + _CAPABILITY_SCOPE, SOURCE_CONFORMANCE_DIRECTORY, SOURCE_CONFORMANCE_SCHEMA, SOURCE_CONFORMANCE_SCHEME, @@ -18,7 +19,12 @@ source_conformance_wheel_binding_digest, validate_source_conformance_distribution, ) -from wavebench.instruments.source_extensions import SOURCE_CONTRACT_VERSION, source_v2_digest +from wavebench.instruments.source_extensions import ( + SOURCE_CONTRACT_VERSION, + SourceFeature, + SourceFeatureDirection, + source_v2_digest, +) def _manifest_document( @@ -71,6 +77,13 @@ def test_manifest_parser_verifies_required_identity_and_digest() -> None: parse_source_conformance_manifest(document) +def test_sweep_fire_conformance_scope_matches_the_v2_contract() -> None: + assert _CAPABILITY_SCOPE["source.sweep_fire_v2"] == ( + SourceFeature.SWEEP, + frozenset({SourceFeatureDirection.FIRE}), + ) + + @pytest.mark.parametrize( ("field", "value", "message"), [ diff --git a/tests/test_source_counter_v2.py b/tests/test_source_counter_v2.py new file mode 100644 index 0000000..589c08c --- /dev/null +++ b/tests/test_source_counter_v2.py @@ -0,0 +1,455 @@ +from __future__ import annotations + +from dataclasses import replace +from pathlib import Path + +import pytest + +from wavebench.config import ( + AutoscaleConfig, + ConnectionConfig, + OutputConfig, + SafetyLimitsConfig, + ScopeConfig, + SourceConfig, + WaveBenchConfig, + WaveformConfig, +) +from wavebench.errors import ConfigError +from wavebench.instruments.capabilities import validate_declared_capabilities +from wavebench.instruments.source_extension_capabilities import validate_source_descriptor +from wavebench.instruments.source_extensions import ( + SOURCE_CONTRACT_VERSION, + Availability, + Observed, + PatchAction, + PatchValue, + SourceConstraintApplicability, + SourceCounterCapabilityProfile, + SourceCounterConfigurationField, + SourceCounterConfigurationPatch, + SourceCounterConfigureRequest, + SourceCounterConfigureResult, + SourceCounterEnableRequest, + SourceCounterEnableResult, + SourceCounterInputState, + SourceCounterMeasureRequest, + SourceCounterMeasurementKind, + SourceCounterMeasurementV2, + SourceCounterMeasureResult, + SourceFacetQueryContract, + SourceFacetScope, + SourceFeature, + SourceFeatureCapability, + SourceFeatureDirection, + SourceFieldId, + SourceInputCoupling, + SourceProtocolQueryRecord, + SourceQueryEffect, + SourceQueryExecutionRecord, + SourceQueryItemOutcome, + SourceReasonCode, + SourceRuntimeIdentity, + SourceTopologyContract, + SourceTypedObservation, + SupportState, +) +from wavebench.logging import CommandLogger +from wavebench.services.source_service import SourceService +from wavebench.transport.contracts import ReplayPolicy +from wavebench.transport.guarded import GuardedAuditedTransport +from wavebench.transport.session import InstrumentSessionState + +from tests.source_v2_fixtures import source_descriptor, source_extensions + + +class _TextTransport: + resource = "fake-source-counter-v2" + + def record_event(self, direction: str, text: str) -> None: + del direction, text + + def query(self, command: str, *, replay: ReplayPolicy = ReplayPolicy.NO_REPLAY) -> str: + del command, replay + return "ok" + + def write(self, command: str) -> None: + del command + + def close(self) -> None: + pass + + +def _missing() -> Observed[object]: + return Observed.missing(Availability.NOT_QUERIED, SourceReasonCode.NOT_REQUESTED) + + +def _counter( + *, + enabled: bool = False, + coupling: SourceInputCoupling = SourceInputCoupling.DC, + impedance_ohm: float = 1_000_000.0, + attenuation: int = 1, + trigger_level_v: float = 0.0, + statistics_enabled: bool = False, +) -> SourceCounterInputState: + return SourceCounterInputState( + input_id="counter", + enabled=Observed.value_of(enabled), + measurements=( + Observed.missing( + Availability.NOT_APPLICABLE, + SourceReasonCode.INACTIVE_BY_ANCHOR, + ) + if not enabled + else _missing() + ), + coupling=Observed.value_of(coupling), + impedance_ohm=Observed.value_of(impedance_ohm), + attenuation=Observed.value_of(attenuation), + gate_time_s=Observed.missing( + Availability.UNSUPPORTED, + SourceReasonCode.DESCRIPTOR_UNSUPPORTED, + ), + trigger_level_v=Observed.value_of(trigger_level_v), + statistics_enabled=Observed.value_of(statistics_enabled), + ) + + +class _CounterDriver: + def __init__( + self, + *, + session_state: InstrumentSessionState, + enabled: bool = False, + postcondition_mismatch: bool = False, + ) -> None: + self.transport = GuardedAuditedTransport(_TextTransport(), session_state=session_state) + self.counter = _counter(enabled=enabled) + self.postcondition_mismatch = postcondition_mismatch + self.configure_requests: list[SourceCounterConfigureRequest] = [] + self.enable_requests: list[SourceCounterEnableRequest] = [] + self.measure_requests: list[SourceCounterMeasureRequest] = [] + + def close(self) -> None: + self.transport.close() + + def execute_source_query_plan_v2(self, plan) -> SourceQueryExecutionRecord: + records = [] + for index, item in enumerate(plan.items): + if index == 0: + self.transport.query("SOURCE:STATE?") + observations = [] + for field in item.fields: + if field.field is SourceFieldId.IDENTITY: + value = SourceRuntimeIdentity( + manufacturer="Example", + model="EX1", + firmware_id="1.0", + ) + elif field.field is SourceFieldId.BASIC: + from tests.source_v2_fixtures import basic_facet + + value = basic_facet() + elif field.field is SourceFieldId.OUTPUT: + from tests.source_v2_fixtures import output_facet + + value = output_facet() + else: + assert field.field is SourceFieldId.COUNTER + value = self._snapshot_counter() + observations.append(SourceTypedObservation(field, value)) + records.append( + SourceProtocolQueryRecord( + item_id=item.item_id, + effect=item.effect, + outcome=SourceQueryItemOutcome.OBSERVED, + query_count=(1 if index == 0 else 0), + observations=tuple(observations), + ) + ) + return SourceQueryExecutionRecord( + contract_version=SOURCE_CONTRACT_VERSION, + plan_id=plan.plan_id, + items=tuple(records), + query_count=1, + device_revision_token_before="revision-1", + device_revision_token_after="revision-1", + ) + + def configure_source_counter_v2( + self, + request: SourceCounterConfigureRequest, + ) -> SourceCounterConfigureResult: + self.transport.write("SOURCE:COUNTER:CONFIGURE") + self.configure_requests.append(request) + values = { + SourceCounterConfigurationField.COUPLING: ("coupling", request.patch.coupling.value), + SourceCounterConfigurationField.IMPEDANCE_OHM: ( + "impedance_ohm", + request.patch.impedance_ohm.value, + ), + SourceCounterConfigurationField.ATTENUATION: ( + "attenuation", + request.patch.attenuation.value, + ), + SourceCounterConfigurationField.TRIGGER_LEVEL_V: ( + "trigger_level_v", + request.patch.trigger_level_v.value, + ), + SourceCounterConfigurationField.STATISTICS_ENABLED: ( + "statistics_enabled", + request.patch.statistics_enabled.value, + ), + } + field = next( + key + for key, patch_value in ( + (SourceCounterConfigurationField.COUPLING, request.patch.coupling), + (SourceCounterConfigurationField.IMPEDANCE_OHM, request.patch.impedance_ohm), + (SourceCounterConfigurationField.ATTENUATION, request.patch.attenuation), + (SourceCounterConfigurationField.TRIGGER_LEVEL_V, request.patch.trigger_level_v), + (SourceCounterConfigurationField.STATISTICS_ENABLED, request.patch.statistics_enabled), + ) + if patch_value.action is PatchAction.SET + ) + name, value = values[field] + self.counter = replace(self.counter, **{name: Observed.value_of(value)}) + return SourceCounterConfigureResult(request.input_id, self.counter) + + def set_source_counter_enabled_v2( + self, + request: SourceCounterEnableRequest, + ) -> SourceCounterEnableResult: + self.transport.write("SOURCE:COUNTER:ENABLE") + self.enable_requests.append(request) + self.counter = replace(self.counter, enabled=Observed.value_of(request.enabled)) + return SourceCounterEnableResult(request.input_id, request.enabled) + + def measure_source_counter_v2( + self, + request: SourceCounterMeasureRequest, + ) -> SourceCounterMeasureResult: + self.transport.query("SOURCE:COUNTER:MEASURE?") + self.measure_requests.append(request) + return SourceCounterMeasureResult( + request.input_id, + ( + SourceCounterMeasurementV2(SourceCounterMeasurementKind.DUTY_PERCENT, 40.0), + SourceCounterMeasurementV2(SourceCounterMeasurementKind.FREQUENCY_HZ, 1_000.0), + ), + ) + + def _snapshot_counter(self) -> SourceCounterInputState: + if not self.postcondition_mismatch or not self.configure_requests: + return self.counter + return replace(self.counter, coupling=Observed.value_of(SourceInputCoupling.DC)) + + +def _extensions(): + base = source_extensions() + counter = SourceFeatureCapability( + feature=SourceFeature.COUNTER, + support=SupportState.SUPPORTED, + directions=( + SourceFeatureDirection.CONFIGURE, + SourceFeatureDirection.DISABLE, + SourceFeatureDirection.ENABLE, + SourceFeatureDirection.READ, + ), + scope=SourceFacetScope.INPUT, + channels=(), + applicability=SourceConstraintApplicability(), + profile=SourceCounterCapabilityProfile( + input_ids=("counter",), + measurement_kinds=( + SourceCounterMeasurementKind.DUTY_PERCENT, + SourceCounterMeasurementKind.FREQUENCY_HZ, + ), + configuration_readable=True, + query_effect=SourceQueryEffect.PURE_READ, + readable_configuration_fields=( + SourceCounterConfigurationField.ATTENUATION, + SourceCounterConfigurationField.COUPLING, + SourceCounterConfigurationField.IMPEDANCE_OHM, + SourceCounterConfigurationField.STATISTICS_ENABLED, + SourceCounterConfigurationField.TRIGGER_LEVEL_V, + ), + configurable_fields=( + SourceCounterConfigurationField.ATTENUATION, + SourceCounterConfigurationField.COUPLING, + SourceCounterConfigurationField.IMPEDANCE_OHM, + SourceCounterConfigurationField.STATISTICS_ENABLED, + SourceCounterConfigurationField.TRIGGER_LEVEL_V, + ), + enabled_configurable=True, + ), + ) + counter_query = SourceFacetQueryContract( + feature=SourceFeature.COUNTER, + scope=SourceFacetScope.INPUT, + fields=(SourceFieldId.COUNTER,), + activation_any=(), + effect=SourceQueryEffect.PURE_READ, + max_queries=1, + required=True, + ) + return replace( + base, + topology=SourceTopologyContract((1,), input_ids=("counter",)), + features=tuple( + sorted( + (*base.features, counter), + key=lambda item: (item.feature.value, item.scope.value, item.channels), + ) + ), + query_contract=replace( + base.query_contract, + facets=tuple( + sorted( + (*base.query_contract.facets, counter_query), + key=lambda item: ( + item.feature.value, + item.scope.value, + tuple(field.value for field in item.fields), + ), + ) + ), + max_queries=7, + ), + ) + + +def _config() -> WaveBenchConfig: + return WaveBenchConfig( + connection=ConnectionConfig("lan", "TCPIP::scope::INSTR", 1_000, 1_000), + scope=ScopeConfig("rtm2032", None, 1, False, True), + autoscale=AutoscaleConfig(True, True), + waveform=WaveformConfig("real", "lsbf", "DMAX"), + output=OutputConfig(Path("data/raw"), "timestamp_label", True, True, True, True, False), + source_path=Path("wavebench.toml"), + source=SourceConfig( + "example.source-v2", + "TCPIP::source::INSTR", + 1, + False, + True, + 0, + ), + safety_limits=SafetyLimitsConfig(), + ) + + +def _service( + *, + enabled: bool = False, + postcondition_mismatch: bool = False, +) -> tuple[SourceService, _CounterDriver]: + session_state = InstrumentSessionState(epoch_id="source-counter-v2") + driver = _CounterDriver( + session_state=session_state, + enabled=enabled, + postcondition_mismatch=postcondition_mismatch, + ) + descriptor = replace( + source_descriptor(extensions=_extensions()), + capabilities=( + "source.snapshot_v2", + "source.counter_configure_v2", + "source.counter_enable_v2", + "source.counter_measure_v2", + ), + ) + validate_source_descriptor(descriptor) + validate_declared_capabilities(descriptor, driver) + return ( + SourceService( + config=_config(), + logger=CommandLogger(), + session=driver, # type: ignore[arg-type] + descriptor=descriptor, + transport=driver.transport, + session_state=session_state, + ), + driver, + ) + + +def _configure_request() -> SourceCounterConfigureRequest: + return SourceCounterConfigureRequest( + "counter", + SourceCounterConfigurationPatch( + coupling=PatchValue(PatchAction.SET, SourceInputCoupling.AC) + ), + ) + + +def test_counter_configure_v2_writes_once_and_records_no_rollback_boundary() -> None: + service, driver = _service() + + result, artifact = service.configure_counter_v2(_configure_request()) + + assert result.state.coupling.value is SourceInputCoupling.AC + assert driver.configure_requests == [_configure_request()] + assert driver.enable_requests == [] + assert driver.transport.counters.write_completed == 1 + assert artifact["mutation"]["status"] == "written" + assert artifact["final_state"] == { + "session_health": "healthy", + "counter_input_id": "counter", + "automatic_rollback": "not_available", + } + + +def test_counter_enable_v2_is_independent_from_configuration() -> None: + service, driver = _service() + request = SourceCounterEnableRequest("counter", True) + + result, artifact = service.set_counter_enabled_v2(request) + + assert result.enabled is True + assert driver.configure_requests == [] + assert driver.enable_requests == [request] + assert driver.transport.counters.write_completed == 1 + assert artifact["operation"] == "source.counter_enable_v2" + + +def test_counter_measure_v2_queries_once_after_enabled_preflight_without_writes() -> None: + service, driver = _service(enabled=True) + request = SourceCounterMeasureRequest("counter") + + result = service.measure_counter_v2(request) + + assert result.input_id == "counter" + assert driver.measure_requests == [request] + assert driver.configure_requests == [] + assert driver.enable_requests == [] + assert driver.transport.counters.write_requests == 0 + assert driver.transport.counters.query_calls == 2 + + +def test_counter_measure_v2_refuses_disabled_counter_before_measurement_query() -> None: + service, driver = _service(enabled=False) + + with pytest.raises(ConfigError, match="requires Counter enabled"): + service.measure_counter_v2(SourceCounterMeasureRequest("counter")) + + assert driver.measure_requests == [] + assert driver.transport.counters.write_requests == 0 + + +def test_counter_postcondition_failure_does_not_write_rollback_or_disable() -> None: + service, driver = _service(postcondition_mismatch=True) + + with pytest.raises(ConfigError, match="readback does not match") as raised: + service.configure_counter_v2(_configure_request()) + + artifact = raised.value.source_operation_artifact + assert driver.configure_requests == [_configure_request()] + assert driver.enable_requests == [] + assert driver.transport.counters.write_completed == 1 + assert artifact["recovery"] == { + "status": "not_attempted", + "reason": "counter_state_not_rollback_safe", + } + assert artifact["final_state"]["session_health"] == "poisoned" diff --git a/tests/test_source_cross_channel_v2.py b/tests/test_source_cross_channel_v2.py index 449cdb8..ee78f00 100644 --- a/tests/test_source_cross_channel_v2.py +++ b/tests/test_source_cross_channel_v2.py @@ -29,7 +29,12 @@ OutputFacet, SourceCombineConfigureRequest, SourceConstraintApplicability, + SourceCouplingCapabilityProfile, SourceCouplingConfigureRequest, + SourceCouplingDimension, + SourceCouplingDimensionState, + SourceCouplingParameterKind, + SourceCouplingState, SourceCrossChannelCapabilityProfile, SourceCrossChannelConfigureResult, SourceFacetQueryContract, @@ -239,7 +244,26 @@ def _output(self, channel: int) -> OutputFacet: polarity=Observed.value_of(SourceOutputPolarity.NORMAL), ) - def _relation(self, enabled: bool) -> SourceRelationState: + def _relation(self, enabled: bool) -> SourceRelationState | SourceCouplingState: + if self.feature is SourceFeature.COUPLING: + not_queried = Observed.missing( + Availability.NOT_QUERIED, + SourceReasonCode.NOT_REQUESTED, + ) + return SourceCouplingState( + feature=SourceFeature.COUPLING, + channels=(1, 2), + enabled=Observed.value_of(enabled), + reference_channel=Observed.value_of(1), + dimensions=tuple( + SourceCouplingDimensionState( + dimension=dimension, + enabled=Observed.value_of(enabled), + parameter=not_queried, + ) + for dimension in SourceCouplingDimension + ), + ) return SourceRelationState( feature=self.feature, channels=(1, 2), @@ -296,12 +320,28 @@ def _extensions( ): base = source_extensions() basic, output = base.features - profile = SourceCrossChannelCapabilityProfile( - relation_kinds=(feature,), - supported_channel_sets=((1, 2),), - relation_graph_readable=True, - shared_power_constraint_readable=False, - configuration_readable=configuration_readable, + profile = ( + SourceCouplingCapabilityProfile( + dimensions=tuple(SourceCouplingDimension), + parameter_kinds=( + SourceCouplingParameterKind.AMPLITUDE_DEVIATION_VPP, + SourceCouplingParameterKind.FREQUENCY_DEVIATION_HZ, + SourceCouplingParameterKind.PHASE_DEVIATION_DEG, + ), + supported_channel_sets=((1, 2),), + global_state_readable=True, + reference_channel_readable=True, + relation_graph_readable=True, + configuration_readable=configuration_readable, + ) + if feature is SourceFeature.COUPLING + else SourceCrossChannelCapabilityProfile( + relation_kinds=(feature,), + supported_channel_sets=((1, 2),), + relation_graph_readable=True, + shared_power_constraint_readable=False, + configuration_readable=configuration_readable, + ) ) relation = SourceFeatureCapability( feature=feature, diff --git a/tests/test_source_extensions.py b/tests/test_source_extensions.py index be45cd7..b6fccce 100644 --- a/tests/test_source_extensions.py +++ b/tests/test_source_extensions.py @@ -1,6 +1,7 @@ from __future__ import annotations from dataclasses import fields, replace +import json from math import nan from pathlib import Path import re @@ -29,6 +30,7 @@ SourceReasonCode, SupportState, source_v2_canonical_json, + source_v2_to_data, ) from tests.source_v2_fixtures import ( @@ -117,7 +119,70 @@ def test_source_public_exports_are_explicit_and_preserve_identity() -> None: re.S, ) assert match is not None - assert module.__all__[arbitrary_start + len(arbitrary_exports) :] == match.group(1).splitlines() + relation_exports = match.group(1).splitlines() + relation_start = arbitrary_start + len(arbitrary_exports) + assert module.__all__[relation_start : relation_start + len(relation_exports)] == relation_exports + match = re.search( + r"首次稳定版 Coupling 只读模型修正在上述清单末尾追加以下精确条目:\n\n```text\n(.*?)\n```", + rfc, + re.S, + ) + assert match is not None + coupling_exports = match.group(1).splitlines() + coupling_start = relation_start + len(relation_exports) + assert module.__all__[coupling_start : coupling_start + len(coupling_exports)] == coupling_exports + match = re.search( + r"首次稳定版 Sync 只读模型修正在上述清单末尾追加以下精确条目:\n\n```text\n(.*?)\n```", + rfc, + re.S, + ) + assert match is not None + sync_exports = match.group(1).splitlines() + sync_start = coupling_start + len(coupling_exports) + assert module.__all__[sync_start : sync_start + len(sync_exports)] == sync_exports + match = re.search( + r"首次稳定版 Noise Overlay 只读模型在上述清单末尾追加以下精确条目:\n\n```text\n(.*?)\n```", + rfc, + re.S, + ) + assert match is not None + noise_exports = match.group(1).splitlines() + noise_start = sync_start + len(sync_exports) + assert module.__all__[noise_start : noise_start + len(noise_exports)] == noise_exports + match = re.search( + r"D1-2/输出开启时的受限 Basic 修改在上述清单末尾追加以下精确条目:\n\n```text\n(.*?)\n```", + rfc, + re.S, + ) + assert match is not None + live_exports = match.group(1).splitlines() + live_start = noise_start + len(noise_exports) + assert module.__all__[live_start : live_start + len(live_exports)] == live_exports + match = re.search( + r"D1-3/Burst 与 Sweep fire 在上述清单末尾追加以下精确条目:\n\n```text\n(.*?)\n```", + rfc, + re.S, + ) + assert match is not None + fire_exports = match.group(1).splitlines() + fire_start = live_start + len(live_exports) + assert module.__all__[fire_start : fire_start + len(fire_exports)] == fire_exports + match = re.search( + r"D1-4/volatile ARB 与 Counter 收口在上述清单末尾追加以下精确条目:\n\n```text\n(.*?)\n```", + rfc, + re.S, + ) + assert match is not None + d1_4_exports = match.group(1).splitlines() + d1_4_start = fire_start + len(fire_exports) + assert module.__all__[d1_4_start : d1_4_start + len(d1_4_exports)] == d1_4_exports + match = re.search( + r"D1-5/无通道 VOLATILE workspace 在上述清单末尾追加以下精确条目:\n\n```text\n(.*?)\n```", + rfc, + re.S, + ) + assert match is not None + assert module.__all__[d1_4_start + len(d1_4_exports) :] == match.group(1).splitlines() def test_observed_preserves_missing_reason_and_rejects_nonfinite_value() -> None: @@ -140,6 +205,73 @@ def test_observed_preserves_missing_reason_and_rejects_nonfinite_value() -> None Observed.value_of(1.0, evidence_refs=("/tmp/private.json",)) +def test_source_v2_canonical_json_omits_only_additive_legacy_defaults() -> None: + basic = module.SourceBasicCapabilityProfile( + waveform_kinds=(module.SourceWaveformKind.SINE,), + frequency_modes=(module.SourceFrequencyMode.FIXED,), + amplitude_units=(module.SourceAmplitudeUnit.VPP,), + offset_readable=True, + phase_readable=True, + square_duty_readable=False, + ) + basic_data = source_v2_to_data(basic) + basic_canonical = json.loads(source_v2_canonical_json(basic)) + assert basic_data["live_frequency_configurable"] is False + assert basic_data["live_amplitude_vpp_configurable"] is False + assert "live_frequency_configurable" not in basic_canonical + assert "live_amplitude_vpp_configurable" not in basic_canonical + assert "live_frequency_configurable" in json.loads( + source_v2_canonical_json(replace(basic, live_frequency_configurable=True)) + ) + + extensions = source_extensions() + extensions_data = source_v2_to_data(extensions) + extensions_canonical = json.loads(source_v2_canonical_json(extensions)) + assert extensions_data["v1_route_migration_enabled"] is True + assert "v1_route_migration_enabled" not in extensions_canonical + assert json.loads( + source_v2_canonical_json(replace(extensions, v1_route_migration_enabled=False)) + )["v1_route_migration_enabled"] is False + + sweep = module.SourceSweepCapabilityProfile( + spacing_modes=(module.SourceSweepSpacing.LINEAR,), + trigger_sources=(module.SourceTriggerSource.INTERNAL,), + timing_readable=True, + marker_readable=True, + ) + assert source_v2_to_data(sweep)["implicit_disable_features"] == [] + assert "implicit_disable_features" not in json.loads(source_v2_canonical_json(sweep)) + assert json.loads( + source_v2_canonical_json( + replace(sweep, implicit_disable_features=(module.SourceFeature.BURST,)) + ) + )["implicit_disable_features"] == ["burst"] + + burst = module.SourceBurstCapabilityProfile( + modes=(module.SourceBurstMode.TRIGGERED,), + trigger_sources=(module.SourceTriggerSource.INTERNAL,), + timing_readable=True, + gate_readable=True, + ) + burst_data = source_v2_to_data(burst) + burst_canonical = json.loads(source_v2_canonical_json(burst)) + assert burst_data["triggered_manual_configuration_readable"] is False + assert burst_data["inactive_readable"] is False + assert "triggered_manual_configuration_readable" not in burst_canonical + assert "inactive_readable" not in burst_canonical + interlocked_burst = json.loads( + source_v2_canonical_json( + replace( + burst, + triggered_manual_configuration_readable=True, + inactive_readable=True, + ) + ) + ) + assert interlocked_burst["triggered_manual_configuration_readable"] is True + assert interlocked_burst["inactive_readable"] is True + + def test_resistance_bounds_require_two_finite_positive_limits() -> None: assert module.ResistanceBounds(49.5, 50.5).maximum_ohm == 50.5 @@ -158,12 +290,28 @@ def test_source_v2_profile_and_facet_field_shapes_are_frozen() -> None: "offset_readable", "phase_readable", "square_duty_readable", + "live_frequency_configurable", + "live_amplitude_vpp_configurable", ), "SourceOutputCapabilityProfile": ( "output_readable", "display_load_readable", "polarity_readable", ), + "SourceDescriptorExtensions": ( + "contract_version", + "topology", + "features", + "query_contract", + "safety_profile", + "v1_route_migration_enabled", + ), + "SourceNoiseOverlayCapabilityProfile": ( + "enabled_readable", + "scale_kinds", + ), + "SourceNoiseOverlayScale": ("kind", "value"), + "NoiseOverlayFacet": ("enabled", "scales"), "SourceHarmonicCapabilityProfile": ( "minimum_order", "maximum_order", @@ -186,6 +334,7 @@ def test_source_v2_profile_and_facet_field_shapes_are_frozen() -> None: "timing_readable", "marker_readable", "configuration_readable", + "implicit_disable_features", ), "SourceBurstCapabilityProfile": ( "modes", @@ -193,6 +342,8 @@ def test_source_v2_profile_and_facet_field_shapes_are_frozen() -> None: "timing_readable", "gate_readable", "triggered_internal_configuration_readable", + "triggered_manual_configuration_readable", + "inactive_readable", ), "SourcePulseCapabilityProfile": ( "hold_modes", @@ -208,17 +359,57 @@ def test_source_v2_profile_and_facet_field_shapes_are_frozen() -> None: "storage_slot_metadata_readable", "storage_write_modes", "storage_max_payload_bytes", + "volatile_replace_min_points", + "volatile_replace_max_points", + "volatile_replace_max_payload_bytes", + ), + "SourceArbitraryWorkspaceCapabilityProfile": ( + "workspace_id", + "volatile_replace_min_points", + "volatile_replace_max_points", + "volatile_replace_max_payload_bytes", ), "SourceCounterCapabilityProfile": ( "input_ids", "measurement_kinds", "configuration_readable", "query_effect", + "readable_configuration_fields", + "configurable_fields", + "enabled_configurable", + ), + "SourceCouplingCapabilityProfile": ( + "dimensions", + "parameter_kinds", + "supported_channel_sets", + "global_state_readable", + "reference_channel_readable", + "relation_graph_readable", + "configuration_readable", + ), + "SourceCouplingParameter": ("kind", "value"), + "SourceCouplingDimensionState": ("dimension", "enabled", "parameter"), + "SourceCouplingState": ( + "feature", + "channels", + "enabled", + "reference_channel", + "dimensions", + ), + "SourceReferenceClockCapabilityProfile": ( + "modes", + "frequency_readable", + "lock_state_readable", + ), + "SourceSyncCapabilityProfile": ( + "enabled_readable", + "polarity_readable", + "source_channel_readable", + "source_channels", ), - "SourceClockSyncCapabilityProfile": ( - "reference_clock_modes", - "sync_readable", - "cascade_readable", + "SourceCascadeCapabilityProfile": ( + "enabled_readable", + "role_readable", ), "SourceCrossChannelCapabilityProfile": ( "relation_kinds", @@ -293,6 +484,9 @@ def test_source_v2_profile_and_facet_field_shapes_are_frozen() -> None: ), "SourceBasicConfigureRequest": ("channel", "patch", "mode"), "SourceBasicConfigureResult": ("channel", "basic", "output_enabled"), + "SourceBasicLiveConfigureResult": ("channel", "basic", "output_enabled"), + "SourceFireRequest": ("channel",), + "SourceFireResult": ("channel",), "SourceOutputRequest": ("channel", "enabled"), "SourceOutputResult": ( "channel", @@ -322,6 +516,7 @@ def test_source_v2_profile_and_facet_field_shapes_are_frozen() -> None: "phase_deg", "internal_period_s", "delay_s", + "trigger_source", ), "SourceBurstConfigureResult": ("channel", "burst", "output_enabled"), "SourceFmModulationConfigureRequest": ( @@ -344,6 +539,7 @@ def test_source_v2_profile_and_facet_field_shapes_are_frozen() -> None: "spacing", "steps", "sweep_time_s", + "trigger_source", ), "SourceSweepConfigureResult": ("channel", "basic", "sweep", "output_enabled"), "SourcePulseConfigureRequest": ( @@ -476,9 +672,21 @@ def test_source_v2_profile_and_facet_field_shapes_are_frozen() -> None: "SourceSystemStateV2": ( "counters", "reference_clock", - "sync", "cascade", ), + "SourceChannelStateV2": ( + "channel", + "basic", + "output", + "harmonics", + "modulation", + "sweep", + "burst", + "pulse", + "arbitrary", + "sync", + "noise_overlay", + ), "SourceCrossChannelStateV2": ( "relations", "relation_graph", @@ -530,21 +738,33 @@ def test_source_snapshot_capability_is_additive_and_validated() -> None: expected = { "source.snapshot_v2": ("execute_source_query_plan_v2",), "source.basic_configure_v2": ("configure_source_basic_v2",), + "source.basic_live_configure_v2": ("configure_source_basic_live_v2",), "source.harmonics_configure_v2": ("configure_source_harmonics_v2",), "source.harmonics_disable_v2": ("disable_source_harmonics_v2",), "source.modulation_configure_v2": ("configure_source_modulation_v2",), "source.pulse_configure_v2": ("configure_source_pulse_v2",), "source.modulation_pm_configure_v2": ("configure_source_pm_modulation_v2",), "source.burst_configure_v2": ("configure_source_burst_v2",), + "source.burst_fire_v2": ("fire_source_burst_v2",), "source.modulation_fm_configure_v2": ("configure_source_fm_modulation_v2",), "source.modulation_pwm_configure_v2": ("configure_source_pwm_modulation_v2",), "source.sweep_configure_v2": ("configure_source_sweep_v2",), + "source.sweep_fire_v2": ("fire_source_sweep_v2",), "source.output_v2": ("set_source_output_v2",), "source.arbitrary_storage_v2": ( "read_source_arbitrary_storage_v2", "mutate_source_arbitrary_storage_v2", ), "source.arbitrary_select_v2": ("select_source_arbitrary_v2",), + "source.arbitrary_volatile_replace_v2": ( + "replace_source_arbitrary_volatile_v2", + ), + "source.arbitrary_workspace_volatile_replace_v2": ( + "replace_source_arbitrary_workspace_volatile_v2", + ), + "source.counter_configure_v2": ("configure_source_counter_v2",), + "source.counter_enable_v2": ("set_source_counter_enabled_v2",), + "source.counter_measure_v2": ("measure_source_counter_v2",), "source.combine_configure_v2": ("configure_source_combine_v2",), "source.coupling_configure_v2": ("configure_source_coupling_v2",), "source.tracking_configure_v2": ("configure_source_tracking_v2",), @@ -586,6 +806,12 @@ def test_source_v2_basic_write_models_are_closed_and_serializable() -> None: } assert keep.action is module.PatchAction.KEEP assert module.SourceBasicConfigureResult(1, basic_facet(), False).output_enabled is False + assert module.SourceBasicLiveConfigureResult(1, basic_facet(), True).output_enabled is True + assert module.source_v2_to_data(module.SourceFireRequest(1)) == { + "type": "SourceFireRequest", + "channel": 1, + } + assert module.SourceFireResult(1).channel == 1 assert module.SourceOutputResult(1, False) == module.SourceOutputResult(1, False) with pytest.raises(ValueError, match="SET patch values"): @@ -611,6 +837,8 @@ def test_source_v2_basic_write_models_are_closed_and_serializable() -> None: ) with pytest.raises(ValueError, match="output_enabled=False"): module.SourceBasicConfigureResult(1, basic_facet(), True) + with pytest.raises(ValueError, match="output_enabled=True"): + module.SourceBasicLiveConfigureResult(1, basic_facet(), False) with pytest.raises(ValueError, match="final VPP amplitude"): module.SourceBasicConfigureResult( 1, @@ -869,15 +1097,25 @@ def test_source_v2_burst_write_models_are_closed_and_serializable() -> None: "phase_deg": 30.0, "internal_period_s": 0.25, "delay_s": 0.5, + "trigger_source": "internal", } assert result.burst.mode.value is module.SourceBurstMode.TRIGGERED with pytest.raises(ValueError, match="must be <= 500000"): module.SourceBurstConfigureRequest(1, 500_001, 30.0, 0.25, 0.5) with pytest.raises(ValueError, match="must be > 0"): module.SourceBurstConfigureRequest(1, 12, 30.0, 0.0, 0.5) + with pytest.raises(ValueError, match="internal or manual"): + module.SourceBurstConfigureRequest( + 1, + 12, + 30.0, + 0.25, + 0.5, + module.SourceTriggerSource.EXTERNAL, + ) with pytest.raises(ValueError, match="output_enabled=False"): module.SourceBurstConfigureResult(1, burst, True) - with pytest.raises(ValueError, match="internal trigger readback"): + with pytest.raises(ValueError, match="internal or manual trigger readback"): module.SourceBurstConfigureResult( 1, replace( @@ -1072,6 +1310,7 @@ def test_source_v2_sweep_write_models_are_closed_and_serializable() -> None: "spacing": "linear", "steps": 101, "sweep_time_s": 1.0, + "trigger_source": "internal", } assert result.sweep.spacing.value is module.SourceSweepSpacing.LINEAR with pytest.raises(ValueError, match="start_hz must be > 0"): @@ -1101,6 +1340,16 @@ def test_source_v2_sweep_write_models_are_closed_and_serializable() -> None: 2_049, 1.0, ) + with pytest.raises(ValueError, match="internal or manual"): + module.SourceSweepConfigureRequest( + 1, + 100.0, + 1_000.0, + module.SourceSweepSpacing.LINEAR, + 101, + 1.0, + module.SourceTriggerSource.BUS, + ) with pytest.raises(ValueError, match="output_enabled=False"): module.SourceSweepConfigureResult(1, basic, sweep, True) with pytest.raises(ValueError, match="sweep frequency mode"): @@ -1279,6 +1528,151 @@ def test_source_v2_arbitrary_write_models_keep_payload_out_of_public_data() -> N module.SourceArbitrarySelectResult(1, basic, arbitrary, True) +def test_source_v2_volatile_arb_and_counter_contract_models_are_explicit() -> None: + digest = "sha256:" + "c" * 64 + request = module.SourceArbitraryVolatileReplaceRequest( + channel=1, + payload_sha256=digest, + payload_size_bytes=128, + point_count=64, + ) + assert module.source_v2_to_data(request) == { + "type": "SourceArbitraryVolatileReplaceRequest", + "channel": 1, + "payload_sha256": digest, + "payload_size_bytes": 128, + "point_count": 64, + } + assert ( + module.SourceArbitraryVolatileReplaceResult( + 1, + digest, + 128, + 64, + "USER", + True, + False, + False, + ).previous_content_restorable + is False + ) + with pytest.raises(ValueError, match="point_count"): + module.SourceArbitraryVolatileReplaceRequest(1, digest, 128, 0) + with pytest.raises(ValueError, match="write_completed=True"): + module.SourceArbitraryVolatileReplaceResult( + 1, + digest, + 128, + 64, + "USER", + False, + False, + False, + ) + + profile = module.SourceArbitraryCapabilityProfile( + playback_modes=(module.SourceArbitraryPlaybackMode.DDS,), + selection_readable=True, + storage_metadata_readable=False, + sample_rate_readable=False, + volatile_replace_min_points=2, + volatile_replace_max_points=16_384, + volatile_replace_max_payload_bytes=32_768, + ) + assert profile.volatile_replace_max_points == 16_384 + with pytest.raises(ValueError, match="provided together"): + module.SourceArbitraryCapabilityProfile( + playback_modes=(module.SourceArbitraryPlaybackMode.DDS,), + selection_readable=True, + storage_metadata_readable=False, + sample_rate_readable=False, + volatile_replace_min_points=2, + ) + + counter_profile = module.SourceCounterCapabilityProfile( + input_ids=("counter",), + measurement_kinds=(module.SourceCounterMeasurementKind.FREQUENCY_HZ,), + configuration_readable=True, + query_effect=module.SourceQueryEffect.PURE_READ, + readable_configuration_fields=( + module.SourceCounterConfigurationField.ATTENUATION, + module.SourceCounterConfigurationField.COUPLING, + module.SourceCounterConfigurationField.IMPEDANCE_OHM, + module.SourceCounterConfigurationField.STATISTICS_ENABLED, + module.SourceCounterConfigurationField.TRIGGER_LEVEL_V, + ), + configurable_fields=( + module.SourceCounterConfigurationField.COUPLING, + module.SourceCounterConfigurationField.IMPEDANCE_OHM, + ), + enabled_configurable=True, + ) + assert counter_profile.enabled_configurable is True + with pytest.raises(ValueError, match="matching readable"): + module.SourceCounterCapabilityProfile( + input_ids=("counter",), + measurement_kinds=(module.SourceCounterMeasurementKind.FREQUENCY_HZ,), + configuration_readable=True, + query_effect=module.SourceQueryEffect.PURE_READ, + configurable_fields=(module.SourceCounterConfigurationField.COUPLING,), + ) + + patch = module.SourceCounterConfigurationPatch( + coupling=module.PatchValue( + module.PatchAction.SET, + module.SourceInputCoupling.AC, + ) + ) + configured = module.SourceCounterConfigureRequest("counter", patch) + assert module.source_v2_to_data(configured)["patch"]["coupling"]["value"] == "ac" + with pytest.raises(ValueError, match="exactly one SET"): + module.SourceCounterConfigurationPatch() + with pytest.raises(ValueError, match="exactly one SET"): + module.SourceCounterConfigurationPatch( + coupling=module.PatchValue( + module.PatchAction.SET, + module.SourceInputCoupling.AC, + ), + impedance_ohm=module.PatchValue(module.PatchAction.SET, 1_000_000.0), + ) + with pytest.raises(ValueError, match="AC or DC"): + module.SourceCounterConfigurationPatch( + coupling=module.PatchValue( + module.PatchAction.SET, + module.SourceInputCoupling.UNKNOWN, + ) + ) + + state = module.SourceCounterInputState( + input_id="counter", + enabled=Observed.value_of(False), + measurements=Observed.value_of(()), + coupling=Observed.value_of(module.SourceInputCoupling.AC), + impedance_ohm=Observed.value_of(1_000_000.0), + attenuation=Observed.value_of(1), + gate_time_s=Observed.missing( + Availability.UNSUPPORTED, + module.SourceReasonCode.DESCRIPTOR_UNSUPPORTED, + ), + trigger_level_v=Observed.value_of(0.0), + statistics_enabled=Observed.value_of(False), + ) + assert module.SourceCounterConfigureResult("counter", state).state is state + assert module.SourceCounterEnableRequest("counter", True).enabled is True + measured = module.SourceCounterMeasureResult( + "counter", + ( + module.SourceCounterMeasurementV2( + module.SourceCounterMeasurementKind.FREQUENCY_HZ, + 1_000.0, + ), + ), + ) + assert measured.measurements[0].value == 1_000.0 + with pytest.raises(ValueError, match="requires measurements"): + module.SourceCounterMeasureResult("counter", ()) + + def test_source_v2_cross_channel_write_models_are_closed_and_serializable() -> None: relation = module.SourceRelationState( feature=module.SourceFeature.COMBINE, @@ -1346,6 +1740,201 @@ def test_source_v2_cross_channel_write_models_are_closed_and_serializable() -> N ) +def test_source_v2_coupling_read_model_separates_dimensions_and_parameters() -> None: + profile = module.SourceCouplingCapabilityProfile( + dimensions=( + module.SourceCouplingDimension.AMPLITUDE, + module.SourceCouplingDimension.FREQUENCY, + module.SourceCouplingDimension.PHASE, + ), + parameter_kinds=( + module.SourceCouplingParameterKind.AMPLITUDE_DEVIATION_VPP, + module.SourceCouplingParameterKind.AMPLITUDE_RATIO, + module.SourceCouplingParameterKind.FREQUENCY_DEVIATION_HZ, + module.SourceCouplingParameterKind.FREQUENCY_RATIO, + module.SourceCouplingParameterKind.PHASE_DEVIATION_DEG, + module.SourceCouplingParameterKind.PHASE_RATIO, + ), + supported_channel_sets=((1, 2),), + global_state_readable=True, + reference_channel_readable=True, + relation_graph_readable=False, + ) + missing = Observed.missing( + Availability.NOT_QUERIED, + SourceReasonCode.NOT_REQUESTED, + ) + state = module.SourceCouplingState( + feature=module.SourceFeature.COUPLING, + channels=(1, 2), + enabled=missing, + reference_channel=Observed.value_of(1), + dimensions=( + module.SourceCouplingDimensionState( + module.SourceCouplingDimension.AMPLITUDE, + Observed.value_of(True), + Observed.value_of( + module.SourceCouplingParameter( + module.SourceCouplingParameterKind.AMPLITUDE_RATIO, + 0.5, + ) + ), + ), + module.SourceCouplingDimensionState( + module.SourceCouplingDimension.FREQUENCY, + Observed.value_of(True), + Observed.value_of( + module.SourceCouplingParameter( + module.SourceCouplingParameterKind.FREQUENCY_DEVIATION_HZ, + 500.0, + ) + ), + ), + module.SourceCouplingDimensionState( + module.SourceCouplingDimension.PHASE, + Observed.value_of(False), + missing, + ), + ), + ) + + assert profile.configuration_readable is False + assert module.source_v2_to_data(state)["dimensions"][0]["parameter"]["value"] == { + "type": "SourceCouplingParameter", + "kind": "amplitude_ratio", + "value": 0.5, + } + base = source_extensions() + mismatched_feature = module.SourceFeatureCapability( + feature=module.SourceFeature.COUPLING, + support=module.SupportState.SUPPORTED, + directions=(module.SourceFeatureDirection.READ,), + scope=module.SourceFacetScope.CHANNEL_SET, + channels=(1, 2), + applicability=module.SourceConstraintApplicability(), + profile=replace(profile, supported_channel_sets=((1, 3),)), + ) + with pytest.raises(ValueError, match="channel set is not declared"): + replace( + base, + topology=module.SourceTopologyContract((1, 2, 3)), + features=(base.features[0], mismatched_feature, base.features[1]), + ) + with pytest.raises(ValueError, match="does not match its dimension"): + module.SourceCouplingDimensionState( + module.SourceCouplingDimension.PHASE, + Observed.value_of(True), + Observed.value_of( + module.SourceCouplingParameter( + module.SourceCouplingParameterKind.FREQUENCY_RATIO, + 2.0, + ) + ), + ) + with pytest.raises(ValueError, match="must be a participant"): + replace(state, reference_channel=Observed.value_of(3)) + with pytest.raises(ValueError, match="feature is not a relation"): + module.SourceRelationState( + feature=module.SourceFeature.COUPLING, + channels=(1, 2), + enabled=Observed.value_of(True), + ) + + +def test_source_v2_sync_is_channel_scoped_and_uses_a_dedicated_profile() -> None: + profile = module.SourceSyncCapabilityProfile( + enabled_readable=True, + polarity_readable=True, + source_channel_readable=True, + source_channels=(1, 2), + ) + feature = module.SourceFeatureCapability( + feature=module.SourceFeature.SYNC, + support=module.SupportState.SUPPORTED, + directions=(module.SourceFeatureDirection.READ,), + scope=module.SourceFacetScope.CHANNEL, + channels=(1,), + applicability=module.SourceConstraintApplicability(), + profile=profile, + ) + + assert feature.profile is profile + assert not hasattr(module, "SourceClockSyncCapabilityProfile") + with pytest.raises(ValueError, match="cannot use scope"): + module.SourceFieldRef( + module.SourceFieldId.SYNC, + module.SourceScopeRef(module.SourceFacetScope.INSTRUMENT), + ) + with pytest.raises(ValueError, match="cannot use scope"): + replace(feature, scope=module.SourceFacetScope.INSTRUMENT, channels=()) + with pytest.raises(ValueError, match="must not be empty"): + module.SourceSyncCapabilityProfile(True, False, True) + with pytest.raises(ValueError, match="polarity value has an invalid type"): + module.SourceSyncState( + enabled=Observed.value_of(False), + polarity=Observed.value_of(module.SourceOutputPolarity.NORMAL), + source_channel=Observed.value_of(1), + ) + + +def test_source_v2_noise_overlay_is_distinct_from_the_noise_waveform() -> None: + profile = module.SourceNoiseOverlayCapabilityProfile( + enabled_readable=True, + scale_kinds=( + module.SourceNoiseOverlayScaleKind.PERCENT, + module.SourceNoiseOverlayScaleKind.RATIO, + module.SourceNoiseOverlayScaleKind.RATIO_DB, + ), + ) + facet = module.NoiseOverlayFacet( + enabled=Observed.value_of(True), + scales=Observed.value_of( + ( + module.SourceNoiseOverlayScale( + module.SourceNoiseOverlayScaleKind.PERCENT, + 25.0, + ), + module.SourceNoiseOverlayScale( + module.SourceNoiseOverlayScaleKind.RATIO, + 0.25, + ), + module.SourceNoiseOverlayScale( + module.SourceNoiseOverlayScaleKind.RATIO_DB, + -12.0412, + ), + ) + ), + ) + + assert profile.scale_kinds[-1] is module.SourceNoiseOverlayScaleKind.RATIO_DB + assert module.source_v2_to_data(facet)["scales"]["value"][0] == { + "type": "SourceNoiseOverlayScale", + "kind": "percent", + "value": 25.0, + } + assert module.SourceFeature.NOISE_OVERLAY is not module.SourceFeature.BASIC + assert module.SourceFieldId.NOISE_OVERLAY.value == "source.channel.noise_overlay" + assert module.SourceNoiseOverlayScale( + module.SourceNoiseOverlayScaleKind.PERCENT, + 100.0, + ).value == 100.0 + with pytest.raises(ValueError, match="must be >= 0"): + module.SourceNoiseOverlayScale( + module.SourceNoiseOverlayScaleKind.RATIO, + -0.1, + ) + with pytest.raises(ValueError, match="must be <= 100"): + module.SourceNoiseOverlayScale( + module.SourceNoiseOverlayScaleKind.PERCENT, + 100.1, + ) + with pytest.raises(ValueError, match="sorted and unique"): + replace( + facet, + scales=Observed.value_of(tuple(reversed(facet.scales.value))), + ) + + def test_source_v2_arbitrary_write_capabilities_require_explicit_readback() -> None: extensions = source_extensions() basic, output = extensions.features @@ -1467,6 +2056,151 @@ def select_source_arbitrary_v2(self, request): "read_source_arbitrary_storage_v2": lambda self, channel, slot_id: None, "select_source_arbitrary_v2": lambda self, request: None, }, + )(), + ) + + +def test_source_v2_volatile_arb_replace_requires_limits_and_off_recovery() -> None: + extensions = source_extensions() + basic, output = extensions.features + arbitrary = module.SourceFeatureCapability( + feature=module.SourceFeature.ARBITRARY, + support=module.SupportState.SUPPORTED, + directions=(module.SourceFeatureDirection.CONFIGURE, module.SourceFeatureDirection.READ), + scope=module.SourceFacetScope.CHANNEL, + channels=(1,), + applicability=module.SourceConstraintApplicability(), + profile=module.SourceArbitraryCapabilityProfile( + playback_modes=(module.SourceArbitraryPlaybackMode.DDS,), + selection_readable=True, + storage_metadata_readable=False, + sample_rate_readable=False, + volatile_replace_min_points=2, + volatile_replace_max_points=16_384, + volatile_replace_max_payload_bytes=32_768, + ), + ) + arbitrary_query = module.SourceFacetQueryContract( + feature=module.SourceFeature.ARBITRARY, + scope=module.SourceFacetScope.CHANNEL, + fields=(module.SourceFieldId.ARBITRARY_SELECTION,), + activation_any=(), + effect=module.SourceQueryEffect.PURE_READ, + max_queries=1, + required=True, + ) + configured_extensions = replace( + extensions, + features=( + arbitrary, + replace( + basic, + profile=replace( + basic.profile, + waveform_kinds=( + module.SourceWaveformKind.ARBITRARY, + module.SourceWaveformKind.SINE, + ), + ), + ), + replace( + output, + directions=( + module.SourceFeatureDirection.DISABLE, + module.SourceFeatureDirection.ENABLE, + module.SourceFeatureDirection.READ, + ), + ), + ), + query_contract=replace( + extensions.query_contract, + facets=(arbitrary_query, *extensions.query_contract.facets), + max_queries=7, + ), + ) + descriptor = replace( + source_descriptor(extensions=configured_extensions), + capabilities=( + "source.snapshot_v2", + "source.output_v2", + "source.arbitrary_volatile_replace_v2", + ), + ) + + class VolatileArbitraryWriteDriver(SourceV2FakeDriver): + def set_source_output_v2(self, request): + raise AssertionError(request) + + def replace_source_arbitrary_volatile_v2(self, request, payload): + raise AssertionError((request, payload)) + + validate_source_descriptor(descriptor) + validate_declared_capabilities(descriptor, VolatileArbitraryWriteDriver(combined=True)) + + with pytest.raises(ConfigError, match="requires source.output_v2"): + validate_source_descriptor( + replace( + descriptor, + capabilities=( + "source.snapshot_v2", + "source.arbitrary_volatile_replace_v2", + ), + source_extensions=replace( + configured_extensions, + features=( + arbitrary, + configured_extensions.features[1], + replace( + configured_extensions.features[2], + directions=(module.SourceFeatureDirection.READ,), + ), + ), + ), + ) + ) + with pytest.raises(ConfigError, match="volatile replace limits"): + validate_source_descriptor( + replace( + descriptor, + source_extensions=replace( + configured_extensions, + features=( + replace( + arbitrary, + profile=replace( + arbitrary.profile, + volatile_replace_min_points=None, + volatile_replace_max_points=None, + volatile_replace_max_payload_bytes=None, + ), + ), + configured_extensions.features[1], + configured_extensions.features[2], + ), + ), + ) + ) + with pytest.raises(ConfigError, match="arbitrary basic waveform"): + validate_source_descriptor( + replace( + descriptor, + source_extensions=replace( + configured_extensions, + features=(arbitrary, basic, configured_extensions.features[2]), + ), + ) + ) + with pytest.raises(TypeError, match="replace_source_arbitrary_volatile_v2"): + validate_declared_capabilities( + descriptor, + type( + "MissingVolatileArbitraryDriver", + (), + { + "close": lambda self: None, + "execute_source_query_plan_v2": lambda self, plan: None, + "set_source_output_v2": lambda self, request: None, + }, )(), ) @@ -1535,7 +2269,25 @@ def set_source_output_v2(self, request): ), ) ) - with pytest.raises(ConfigError, match="matching output ENABLE and DISABLE"): + disable_only_descriptor = replace( + descriptor, + source_extensions=replace( + write_extensions, + features=( + write_extensions.features[0], + replace( + write_extensions.features[1], + directions=( + SourceFeatureDirection.DISABLE, + SourceFeatureDirection.READ, + ), + ), + ), + ), + ) + validate_source_descriptor(disable_only_descriptor) + + with pytest.raises(ConfigError, match="requires output DISABLE directions"): validate_source_descriptor( replace( descriptor, @@ -2330,7 +3082,7 @@ def configure_source_pulse_v2(self, request): ) -def test_source_v2_burst_write_requires_triggered_internal_direction_and_readback() -> None: +def test_source_v2_burst_write_requires_supported_trigger_direction_and_readback() -> None: extensions = source_extensions() basic, output = extensions.features burst = module.SourceFeatureCapability( @@ -2397,7 +3149,7 @@ def configure_source_burst_v2(self, request): ), ) ) - with pytest.raises(ConfigError, match="readable internal triggered burst configuration"): + with pytest.raises(ConfigError, match="readable internal or manual triggered burst"): validate_source_descriptor( replace( descriptor, @@ -2445,7 +3197,7 @@ def configure_source_burst_v2(self, request): ) -def test_source_v2_sweep_write_requires_internal_direction_and_readback() -> None: +def test_source_v2_sweep_write_requires_supported_trigger_direction_and_readback() -> None: extensions = source_extensions() basic, output = extensions.features basic = replace( @@ -2526,7 +3278,7 @@ def configure_source_sweep_v2(self, request): ), ) ) - with pytest.raises(ConfigError, match="readable internal sweep configuration"): + with pytest.raises(ConfigError, match="readable internal or manual sweep configuration"): validate_source_descriptor( replace( descriptor, diff --git a/tests/test_source_operation_context.py b/tests/test_source_operation_context.py index 4ddc210..d514c3c 100644 --- a/tests/test_source_operation_context.py +++ b/tests/test_source_operation_context.py @@ -443,6 +443,59 @@ def test_unknown_effect_and_reenergizing_restore_are_rejected_without_io() -> No assert transport.audit_snapshot()["counters"]["query_calls"] == 0 +def test_fire_effect_allows_output_on_baseline_but_requires_emergency_off() -> None: + transport = GuardedAuditedTransport(_TextTransport()) # type: ignore[arg-type] + contract = SourceOperationContract( + operation="source.test_fire_v2", + capability="source.test_fire_v2", + feature=SourceFeature.BASIC, + direction=SourceFeatureDirection.FIRE, + energy_effect=SourceEnergyEffect.EMIT, + storage_effect=SourceStorageEffect.NONE, + required_fields=( + SourceFieldId.BASIC, + SourceFieldId.OUTPUT, + SourceFieldId.IDENTITY, + ), + changed_fields=(SourceFieldId.BASIC,), + postcondition_fields=(SourceFieldId.BASIC,), + cleanup_verification_fields=(SourceFieldId.OUTPUT,), + v1_equivalent_routes=(SourceV1WriteRouteId.SET_FREQUENCY,), + v1_overlapping_routes=(), + operation_timeout_ms=5_000, + main_max_steps=1, + recovery_max_steps=1, + verification_max_steps=2, + ) + context = SourceOperationContextCoordinator( + session_state=transport.session_state, + operation_spec=_spec(contract), + operation_contract=contract, + connection_timeout_ms=1_000, + baseline_snapshot_digest="sha256:" + "1" * 64, + fields=FIELDS, + required_off_outputs=(), + emergency_off_outputs=(SourceScopeRef(SourceFacetScope.CHANNEL, channel=1),), + restore_order=(), + non_restorable_fields=(BASIC, OUTPUT), + ) + context.complete() + + with pytest.raises(ValueError, match="fire operations require explicit emergency OFF"): + SourceOperationContextCoordinator( + session_state=transport.session_state, + operation_spec=_spec(contract), + operation_contract=contract, + connection_timeout_ms=1_000, + baseline_snapshot_digest="sha256:" + "1" * 64, + fields=FIELDS, + required_off_outputs=(), + emergency_off_outputs=(), + restore_order=(), + non_restorable_fields=(BASIC, OUTPUT), + ) + + def test_affected_closure_digest_rejects_tampering() -> None: transport, context = _context() closure = context.closure diff --git a/tests/test_source_output_v2.py b/tests/test_source_output_v2.py index c933b6f..4e789a2 100644 --- a/tests/test_source_output_v2.py +++ b/tests/test_source_output_v2.py @@ -191,7 +191,15 @@ def _config(*, limits: SafetyLimitsConfig = SafetyLimitsConfig()) -> WaveBenchCo ) -def _extensions(*, final_vpp_available: bool): +def _extensions( + *, + final_vpp_available: bool, + output_directions: tuple[SourceFeatureDirection, ...] = ( + SourceFeatureDirection.DISABLE, + SourceFeatureDirection.ENABLE, + SourceFeatureDirection.READ, + ), +): base = source_extensions() basic, output = base.features if not final_vpp_available: @@ -206,11 +214,7 @@ def _extensions(*, final_vpp_available: bool): second_basic = replace(basic, channels=(2,)) output = replace( output, - directions=( - SourceFeatureDirection.DISABLE, - SourceFeatureDirection.ENABLE, - SourceFeatureDirection.READ, - ), + directions=output_directions, ) second_output = replace(output, channels=(2,)) return replace( @@ -228,6 +232,11 @@ def _service( ignore_enable: bool = False, raise_after_output_write: bool = False, limits: SafetyLimitsConfig = SafetyLimitsConfig(), + output_directions: tuple[SourceFeatureDirection, ...] = ( + SourceFeatureDirection.DISABLE, + SourceFeatureDirection.ENABLE, + SourceFeatureDirection.READ, + ), ) -> tuple[SourceService, _OutputDriver]: session_state = InstrumentSessionState(epoch_id="source-output-v2") driver = _OutputDriver( @@ -238,7 +247,13 @@ def _service( raise_after_output_write=raise_after_output_write, ) descriptor = replace( - source_descriptor(driver=driver, extensions=_extensions(final_vpp_available=final_vpp_available)), + source_descriptor( + driver=driver, + extensions=_extensions( + final_vpp_available=final_vpp_available, + output_directions=output_directions, + ), + ), capabilities=("source.snapshot_v2", "source.output_v2"), ) validate_source_descriptor(descriptor) @@ -321,6 +336,21 @@ def test_output_disable_v2_does_not_require_final_vpp_or_offset() -> None: assert driver.output_requests == [SourceOutputRequest(channel=1, enabled=False)] +def test_output_enable_v2_rejects_a_disable_only_runtime_profile_before_write() -> None: + service, driver = _service( + output_directions=( + SourceFeatureDirection.DISABLE, + SourceFeatureDirection.READ, + ) + ) + + with pytest.raises(ConfigError, match="not available for the runtime target channel"): + service._set_output_v2_transaction(SourceOutputRequest(channel=1, enabled=True)) + + assert driver.output_requests == [] + assert driver.transport.counters.write_requests == 0 + + def test_output_enable_v2_rejects_missing_final_vpp_or_offset_before_write() -> None: service, driver = _service(final_vpp_available=False) diff --git a/tests/test_source_snapshot_v2.py b/tests/test_source_snapshot_v2.py index 64d1899..c367102 100644 --- a/tests/test_source_snapshot_v2.py +++ b/tests/test_source_snapshot_v2.py @@ -28,13 +28,31 @@ from wavebench.instruments.source_extensions import ( SOURCE_OPERATION_ARTIFACT_SCHEMA, SOURCE_SNAPSHOT_SCHEMA, + NoiseOverlayFacet, + Observed, SnapshotConsistencyState, SourceQueryExecutionRecord, SourceCrossChannelCapabilityProfile, + SourceCouplingCapabilityProfile, + SourceCouplingDimension, + SourceCouplingDimensionState, + SourceCouplingParameter, + SourceCouplingParameterKind, + SourceCouplingState, SourceFacetScope, + SourceFacetQueryContract, SourceFeature, SourceFeatureCapability, + SourceFeatureDirection, + SourceFieldId, SourceHarmonicPreset, + SourceNoiseOverlayCapabilityProfile, + SourceNoiseOverlayScale, + SourceNoiseOverlayScaleKind, + SourceQueryEffect, + SourceSyncCapabilityProfile, + SourceSyncPolarity, + SourceSyncState, SourceTopologyContract, SupportState, source_snapshot_v2_operation_artifact, @@ -53,6 +71,7 @@ from tests.source_v2_fixtures import ( SourceV2FakeDriver, source_descriptor, + source_extensions, source_extensions_with_harmonics, ) from wavebench.instruments.source_extensions import SourceConstraintApplicability @@ -112,6 +131,348 @@ def test_snapshot_v2_accepts_combined_and_scalar_protocol_plans( assert driver.plans[0].allowed_effects[0].value == "pure_read" +def test_snapshot_v2_projects_channel_sync_and_validates_source_channel() -> None: + extensions = source_extensions_with_harmonics() + sync_profile = SourceSyncCapabilityProfile( + enabled_readable=True, + polarity_readable=True, + source_channel_readable=True, + source_channels=(1, 2), + ) + sync_feature = SourceFeatureCapability( + feature=SourceFeature.SYNC, + support=SupportState.SUPPORTED, + directions=(SourceFeatureDirection.READ,), + scope=SourceFacetScope.CHANNEL, + channels=(1,), + applicability=SourceConstraintApplicability(), + profile=sync_profile, + ) + sync_query = SourceFacetQueryContract( + feature=SourceFeature.SYNC, + scope=SourceFacetScope.CHANNEL, + fields=(SourceFieldId.SYNC,), + activation_any=(), + effect=SourceQueryEffect.PURE_READ, + max_queries=1, + required=True, + ) + extensions = replace( + extensions, + topology=SourceTopologyContract((1, 2)), + features=(*extensions.features, sync_feature), + query_contract=replace( + extensions.query_contract, + facets=(*extensions.query_contract.facets, sync_query), + max_queries=extensions.query_contract.max_queries + 1, + ), + ) + state = SourceSyncState( + enabled=Observed.value_of(False), + polarity=Observed.value_of(SourceSyncPolarity.NEGATIVE), + source_channel=Observed.value_of(2), + ) + driver = SourceV2FakeDriver(combined=True, sync_state=state) + service = make_service(driver) + service.descriptor = source_descriptor(driver=driver, extensions=extensions) + + snapshot = service.snapshot_v2() + + assert snapshot.channels[0].sync.value == state + assert snapshot.channels[1].sync.availability.value == "unsupported" + sync_item = next( + item for item in driver.plans[0].items if item.feature is SourceFeature.SYNC + ) + assert sync_item.target.scope is SourceFacetScope.CHANNEL + assert sync_item.target.channel == 1 + + invalid_sync = replace( + sync_feature, + profile=replace(sync_profile, source_channels=(1,)), + ) + invalid_extensions = replace( + extensions, + features=(*extensions.features[:-1], invalid_sync), + ) + invalid_driver = SourceV2FakeDriver(combined=True, sync_state=state) + invalid_service = make_service(invalid_driver) + invalid_service.descriptor = source_descriptor( + driver=invalid_driver, + extensions=invalid_extensions, + ) + with pytest.raises(SourceSnapshotContractError, match="undeclared source channel"): + invalid_service.snapshot_v2() + + invalid_profiles = ( + ( + replace(sync_profile, enabled_readable=False), + "unreadable enabled state", + ), + ( + replace(sync_profile, polarity_readable=False), + "unreadable polarity", + ), + ) + for profile, message in invalid_profiles: + invalid_extensions = replace( + extensions, + features=(*extensions.features[:-1], replace(sync_feature, profile=profile)), + ) + invalid_driver = SourceV2FakeDriver(combined=True, sync_state=state) + invalid_service = make_service(invalid_driver) + invalid_service.descriptor = source_descriptor( + driver=invalid_driver, + extensions=invalid_extensions, + ) + with pytest.raises(SourceSnapshotContractError, match=message): + invalid_service.snapshot_v2() + + +def test_snapshot_v2_projects_noise_overlay_without_changing_basic_noise_kind() -> None: + extensions = source_extensions() + noise_feature = SourceFeatureCapability( + feature=SourceFeature.NOISE_OVERLAY, + support=SupportState.SUPPORTED, + directions=(SourceFeatureDirection.READ,), + scope=SourceFacetScope.CHANNEL, + channels=(1,), + applicability=SourceConstraintApplicability(), + profile=SourceNoiseOverlayCapabilityProfile( + enabled_readable=True, + scale_kinds=(SourceNoiseOverlayScaleKind.PERCENT,), + ), + ) + noise_query = SourceFacetQueryContract( + feature=SourceFeature.NOISE_OVERLAY, + scope=SourceFacetScope.CHANNEL, + fields=(SourceFieldId.NOISE_OVERLAY,), + activation_any=(), + effect=SourceQueryEffect.PURE_READ, + max_queries=2, + required=True, + ) + extensions = replace( + extensions, + features=(extensions.features[0], noise_feature, extensions.features[1]), + query_contract=replace( + extensions.query_contract, + facets=( + extensions.query_contract.facets[0], + extensions.query_contract.facets[1], + noise_query, + extensions.query_contract.facets[2], + ), + max_queries=extensions.query_contract.max_queries + 2, + ), + ) + noise = NoiseOverlayFacet( + enabled=Observed.value_of(False), + scales=Observed.value_of( + ( + SourceNoiseOverlayScale( + SourceNoiseOverlayScaleKind.PERCENT, + 10.0, + ), + ) + ), + ) + driver = SourceV2FakeDriver(combined=True, noise_overlay=noise) + service = make_service(driver) + service.descriptor = source_descriptor(driver=driver, extensions=extensions) + + snapshot = service.snapshot_v2() + + assert snapshot.channels[0].noise_overlay.value == noise + assert snapshot.channels[0].basic.value.waveform_kind.value.value == "sine" + noise_item = next( + item + for item in driver.plans[0].items + if item.feature is SourceFeature.NOISE_OVERLAY + ) + assert noise_item.target.scope is SourceFacetScope.CHANNEL + assert noise_item.max_queries == 2 + + invalid_profiles = ( + ( + SourceNoiseOverlayCapabilityProfile( + enabled_readable=False, + scale_kinds=(SourceNoiseOverlayScaleKind.PERCENT,), + ), + "unreadable enabled state", + ), + ( + SourceNoiseOverlayCapabilityProfile( + enabled_readable=True, + scale_kinds=(SourceNoiseOverlayScaleKind.RATIO,), + ), + "scale kinds do not match", + ), + ) + for profile, message in invalid_profiles: + invalid_extensions = replace( + extensions, + features=( + extensions.features[0], + replace(noise_feature, profile=profile), + extensions.features[2], + ), + ) + invalid_driver = SourceV2FakeDriver(combined=True, noise_overlay=noise) + invalid_service = make_service(invalid_driver) + invalid_service.descriptor = source_descriptor( + driver=invalid_driver, + extensions=invalid_extensions, + ) + with pytest.raises(SourceSnapshotContractError, match=message): + invalid_service.snapshot_v2() + + +def test_snapshot_v2_validates_coupling_readback_against_runtime_profile() -> None: + extensions = source_extensions() + profile = SourceCouplingCapabilityProfile( + dimensions=( + SourceCouplingDimension.AMPLITUDE, + SourceCouplingDimension.FREQUENCY, + SourceCouplingDimension.PHASE, + ), + parameter_kinds=( + SourceCouplingParameterKind.AMPLITUDE_DEVIATION_VPP, + SourceCouplingParameterKind.FREQUENCY_DEVIATION_HZ, + SourceCouplingParameterKind.PHASE_DEVIATION_DEG, + ), + supported_channel_sets=((1, 2),), + global_state_readable=True, + reference_channel_readable=True, + relation_graph_readable=False, + ) + coupling_feature = SourceFeatureCapability( + feature=SourceFeature.COUPLING, + support=SupportState.SUPPORTED, + directions=(SourceFeatureDirection.READ,), + scope=SourceFacetScope.CHANNEL_SET, + channels=(1, 2), + applicability=SourceConstraintApplicability(), + profile=profile, + ) + coupling_query = SourceFacetQueryContract( + feature=SourceFeature.COUPLING, + scope=SourceFacetScope.CHANNEL_SET, + fields=(SourceFieldId.COUPLING,), + activation_any=(), + effect=SourceQueryEffect.PURE_READ, + max_queries=1, + required=True, + ) + extensions = replace( + extensions, + topology=SourceTopologyContract((1, 2)), + features=(extensions.features[0], coupling_feature, extensions.features[1]), + query_contract=replace( + extensions.query_contract, + facets=( + extensions.query_contract.facets[0], + extensions.query_contract.facets[1], + coupling_query, + extensions.query_contract.facets[2], + ), + max_queries=extensions.query_contract.max_queries + 1, + ), + ) + + def dimension( + kind: SourceCouplingDimension, + parameter_kind: SourceCouplingParameterKind, + ) -> SourceCouplingDimensionState: + return SourceCouplingDimensionState( + dimension=kind, + enabled=Observed.value_of(kind is not SourceCouplingDimension.PHASE), + parameter=Observed.value_of(SourceCouplingParameter(parameter_kind, 1.0)), + ) + + state = SourceCouplingState( + feature=SourceFeature.COUPLING, + channels=(1, 2), + enabled=Observed.value_of(True), + reference_channel=Observed.value_of(1), + dimensions=( + dimension( + SourceCouplingDimension.AMPLITUDE, + SourceCouplingParameterKind.AMPLITUDE_DEVIATION_VPP, + ), + dimension( + SourceCouplingDimension.FREQUENCY, + SourceCouplingParameterKind.FREQUENCY_DEVIATION_HZ, + ), + dimension( + SourceCouplingDimension.PHASE, + SourceCouplingParameterKind.PHASE_DEVIATION_DEG, + ), + ), + ) + driver = SourceV2FakeDriver(combined=True, coupling_state=state) + service = make_service(driver) + service.descriptor = source_descriptor(driver=driver, extensions=extensions) + + assert service.snapshot_v2().cross_channel.value.relations == (state,) + + invalid_cases = ( + ( + replace(profile, global_state_readable=False), + state, + "unreadable global state", + ), + ( + replace(profile, reference_channel_readable=False), + state, + "unreadable reference channel", + ), + ( + replace( + profile, + dimensions=(SourceCouplingDimension.AMPLITUDE,), + parameter_kinds=( + SourceCouplingParameterKind.AMPLITUDE_DEVIATION_VPP, + ), + ), + state, + "dimensions do not match", + ), + ( + replace( + profile, + parameter_kinds=(SourceCouplingParameterKind.AMPLITUDE_DEVIATION_VPP,), + ), + state, + "undeclared parameter kind", + ), + ( + profile, + replace(state, channels=(1, 3)), + "does not match its channel set", + ), + ) + for invalid_profile, invalid_state, message in invalid_cases: + invalid_extensions = replace( + extensions, + features=( + extensions.features[0], + replace(coupling_feature, profile=invalid_profile), + extensions.features[2], + ), + ) + invalid_driver = SourceV2FakeDriver( + combined=True, + coupling_state=invalid_state, + ) + invalid_service = make_service(invalid_driver) + invalid_service.descriptor = source_descriptor( + driver=invalid_driver, + extensions=invalid_extensions, + ) + with pytest.raises(SourceSnapshotContractError, match=message): + invalid_service.snapshot_v2() + + def test_snapshot_v2_runtime_identity_can_only_narrow_descriptor_features() -> None: extensions = source_extensions_with_harmonics() narrowed_output = replace( @@ -575,6 +936,7 @@ def configure_sweep_v2(self, request): assert request.spacing.value == "linear" assert request.steps == 101 assert request.sweep_time_s == 1.0 + assert request.trigger_source.value == "manual" return object(), sweep_artifact def configure_burst_v2(self, request): @@ -730,6 +1092,8 @@ def configure_burst_v2(self, request): "101", "--sweep-time-s", "1", + "--trigger-source", + "manual", "--config", "unused.toml", ] @@ -1097,6 +1461,50 @@ def select_arbitrary_v2(self, request): assert "abc" not in json.dumps(storage_payload, ensure_ascii=False) +def test_source_v2_volatile_arbitrary_cli_emits_payload_free_operation_artifact( + tmp_path, + capsys, +) -> None: + payload_file = tmp_path / "volatile.bin" + payload_file.write_bytes(b"\x00\x00\xff\x3f") + artifact = { + "schema": SOURCE_OPERATION_ARTIFACT_SCHEMA, + "operation": "source.arbitrary_volatile_replace_v2", + "request": {"payload_sha256": "sha256:" + "a" * 64}, + } + + class _Service: + def replace_arbitrary_volatile_v2(self, request, *, payload): + assert request.channel == 1 + assert request.payload_size_bytes == 4 + assert request.point_count == 2 + assert payload == b"\x00\x00\xff\x3f" + return object(), artifact + + with patch("wavebench.cli._load_source_service", return_value=_Service()): + exit_code = cli.main( + [ + "--json", + "source", + "arbitrary-volatile-replace-v2", + "--channel", + "1", + "--payload-file", + str(payload_file), + "--point-count", + "2", + "--config", + "unused.toml", + ] + ) + + result = json.loads(capsys.readouterr().out) + assert exit_code == 0 + assert result["result"] == artifact + assert payload_file.name not in json.dumps(result, ensure_ascii=False) + assert payload_file.read_bytes().hex() not in json.dumps(result, ensure_ascii=False) + + def test_source_v2_arbitrary_cli_rejects_invalid_request_before_loading_service(tmp_path, capsys) -> None: payload_file = tmp_path / "payload.bin" payload_file.write_bytes(b"abc") diff --git a/tests/test_source_sweep_v2.py b/tests/test_source_sweep_v2.py index d63728e..88ccc42 100644 --- a/tests/test_source_sweep_v2.py +++ b/tests/test_source_sweep_v2.py @@ -30,6 +30,8 @@ SourceFeatureCapability, SourceFeatureDirection, SourceFieldId, + SourceFireRequest, + SourceFireResult, SourceFrequencyMode, SourceOutputPolarity, SourceOutputRequest, @@ -91,6 +93,7 @@ def _sweep( spacing: SourceSweepSpacing = SourceSweepSpacing.LINEAR, steps: int = 101, sweep_time_s: float = 1.0, + trigger_source: SourceTriggerSource = SourceTriggerSource.INTERNAL, ) -> SweepFacet: return SweepFacet( enabled=Observed.value_of(enabled), @@ -104,7 +107,7 @@ def _sweep( return_time_s=Observed.value_of(0.0), trigger=Observed.value_of( SourceTriggerState( - source=Observed.value_of(SourceTriggerSource.INTERNAL), + source=Observed.value_of(trigger_source), slope=Observed.value_of(SourceTriggerSlope.POSITIVE), output=Observed.value_of(SourceTriggerOutput.OFF), ) @@ -128,6 +131,9 @@ def __init__( session_state: InstrumentSessionState, output_enabled: bool = False, postcondition_mismatch: bool = False, + result_marker_frequency_hz: float | None = None, + post_fire_mismatch: bool = False, + raise_after_fire: bool = False, ) -> None: self.transport = GuardedAuditedTransport( _TextTransport(), @@ -135,9 +141,13 @@ def __init__( ) self.output_enabled = output_enabled self.postcondition_mismatch = postcondition_mismatch + self.result_marker_frequency_hz = result_marker_frequency_hz + self.post_fire_mismatch = post_fire_mismatch + self.raise_after_fire = raise_after_fire self.basic = basic_facet() self.sweep = _sweep() self.sweep_requests: list[SourceSweepConfigureRequest] = [] + self.fire_requests: list[SourceFireRequest] = [] self.output_requests: list[SourceOutputRequest] = [] self.v1_sweep_configure_calls = 0 self.v1_sweep_trigger_calls = 0 @@ -202,20 +212,44 @@ def configure_source_sweep_v2( spacing=request.spacing, steps=request.steps, sweep_time_s=request.sweep_time_s, + trigger_source=request.trigger_source, ) + result_sweep = self.sweep + if self.result_marker_frequency_hz is not None: + result_sweep = replace( + result_sweep, + marker=Observed.value_of( + SourceSweepMarker( + enabled=Observed.value_of(False), + frequency_hz=Observed.value_of(self.result_marker_frequency_hz), + ) + ), + ) return SourceSweepConfigureResult( channel=request.channel, basic=self.basic, - sweep=self.sweep, + sweep=result_sweep, output_enabled=False, ) + def fire_source_sweep_v2(self, request: SourceFireRequest) -> SourceFireResult: + self.transport.write("SOURCE:SWEEP:FIRE") + self.fire_requests.append(request) + if self.raise_after_fire: + raise ConfigError("fake Sweep fire failed after write") + return SourceFireResult(channel=request.channel) + def set_source_output_v2(self, request: SourceOutputRequest) -> SourceOutputResult: self.transport.write("SOURCE:OUTPUT") self.output_requests.append(request) self.output_enabled = request.enabled if request.enabled: - raise AssertionError("the Sweep fixture only uses recovery OFF") + return SourceOutputResult( + channel=request.channel, + enabled=True, + final_amplitude=self.basic.amplitude.value, + final_offset_v=self.basic.offset_v.value, + ) return SourceOutputResult(channel=request.channel, enabled=False) def configure_sweep(self, *args: object, **kwargs: object) -> object: @@ -244,6 +278,8 @@ def _readback_basic(self): ) def _readback_sweep(self) -> SweepFacet: + if self.post_fire_mismatch and self.fire_requests: + return replace(self.sweep, sweep_time_s=Observed.value_of(2.0)) if not self.postcondition_mismatch or not self.sweep_requests: return self.sweep return replace(self.sweep, sweep_time_s=Observed.value_of(2.0)) @@ -256,6 +292,8 @@ def _extensions( SourceSweepSpacing.LOGARITHMIC, SourceSweepSpacing.STEP, ), + include_fire: bool = False, + v1_route_migration_enabled: bool = True, ): base = source_extensions() basic, output = base.features @@ -272,13 +310,20 @@ def _extensions( sweep = SourceFeatureCapability( feature=SourceFeature.SWEEP, support=SupportState.SUPPORTED, - directions=(SourceFeatureDirection.CONFIGURE, SourceFeatureDirection.READ), + directions=( + SourceFeatureDirection.CONFIGURE, + *((SourceFeatureDirection.FIRE,) if include_fire else ()), + SourceFeatureDirection.READ, + ), scope=SourceFacetScope.CHANNEL, channels=(1,), applicability=SourceConstraintApplicability(), profile=SourceSweepCapabilityProfile( spacing_modes=spacing_modes, - trigger_sources=(SourceTriggerSource.INTERNAL,), + trigger_sources=( + SourceTriggerSource.INTERNAL, + *((SourceTriggerSource.MANUAL,) if include_fire else ()), + ), timing_readable=True, marker_readable=True, configuration_readable=True, @@ -317,6 +362,7 @@ def _extensions( ), max_queries=7, ), + v1_route_migration_enabled=v1_route_migration_enabled, ) @@ -344,7 +390,12 @@ def _service( *, output_enabled: bool = False, postcondition_mismatch: bool = False, + result_marker_frequency_hz: float | None = None, + post_fire_mismatch: bool = False, + raise_after_fire: bool = False, dual_contract: bool = False, + include_fire: bool = False, + v1_route_migration_enabled: bool = True, spacing_modes: tuple[SourceSweepSpacing, ...] = ( SourceSweepSpacing.LINEAR, SourceSweepSpacing.LOGARITHMIC, @@ -356,16 +407,28 @@ def _service( session_state=session_state, output_enabled=output_enabled, postcondition_mismatch=postcondition_mismatch, + result_marker_frequency_hz=result_marker_frequency_hz, + post_fire_mismatch=post_fire_mismatch, + raise_after_fire=raise_after_fire, ) capabilities = [ "source.snapshot_v2", "source.sweep_configure_v2", "source.output_v2", ] + if include_fire: + capabilities.append("source.sweep_fire_v2") if dual_contract: capabilities.extend(("source.sweep_configure", "source.sweep_trigger")) descriptor = replace( - source_descriptor(driver=driver, extensions=_extensions(spacing_modes=spacing_modes)), + source_descriptor( + driver=driver, + extensions=_extensions( + spacing_modes=spacing_modes, + include_fire=include_fire, + v1_route_migration_enabled=v1_route_migration_enabled, + ), + ), capabilities=tuple(capabilities), ) validate_source_descriptor(descriptor) @@ -386,6 +449,7 @@ def _service( def _request( *, spacing: SourceSweepSpacing = SourceSweepSpacing.LINEAR, + trigger_source: SourceTriggerSource = SourceTriggerSource.INTERNAL, ) -> SourceSweepConfigureRequest: return SourceSweepConfigureRequest( channel=1, @@ -394,9 +458,45 @@ def _request( spacing=spacing, steps=101, sweep_time_s=1.0, + trigger_source=trigger_source, ) +def test_sweep_fire_capability_requires_manual_configuration_readback() -> None: + session_state = InstrumentSessionState(epoch_id="source-sweep-fire-profile") + driver = _SweepWriteDriver(session_state=session_state) + extensions = _extensions(include_fire=True) + basic, output, sweep = extensions.features + descriptor = replace( + source_descriptor( + driver=driver, + extensions=replace( + extensions, + features=( + basic, + output, + replace( + sweep, + profile=replace( + sweep.profile, + trigger_sources=(SourceTriggerSource.INTERNAL,), + ), + ), + ), + ), + ), + capabilities=( + "source.snapshot_v2", + "source.sweep_configure_v2", + "source.sweep_fire_v2", + "source.output_v2", + ), + ) + + with pytest.raises(ConfigError, match="readable manual sweep"): + validate_source_descriptor(descriptor) + + def test_sweep_configure_v2_writes_once_and_keeps_output_off() -> None: service, driver = _service() request = _request() @@ -459,6 +559,16 @@ def test_sweep_configure_v2_postcondition_mismatch_runs_one_off_recovery() -> No } +def test_sweep_configure_v2_uses_independent_postcondition_for_query_only_fields() -> None: + service, driver = _service(result_marker_frequency_hz=550.0) + + result, _ = service.configure_sweep_v2(_request()) + + assert result.sweep.marker.value.frequency_hz.value == 550.0 + assert driver.sweep.marker.value.frequency_hz.availability is Availability.NOT_APPLICABLE + assert driver.output_requests == [] + + def test_sweep_configure_v2_rejects_unsupported_spacing_before_write() -> None: service, driver = _service(spacing_modes=(SourceSweepSpacing.LINEAR,)) @@ -483,6 +593,20 @@ def test_v1_sweep_routes_reject_before_io_for_a_dual_contract_driver() -> None: assert driver.transport.counters.query_calls == 0 +def test_v1_sweep_overlap_stays_closed_when_migration_is_disabled() -> None: + service, driver = _service(dual_contract=True, v1_route_migration_enabled=False) + + with pytest.raises(ConfigError, match="cannot run for a Source V2 write driver"): + service.configure_sweep(object()) # type: ignore[arg-type] + with pytest.raises(ConfigError, match="cannot run for a Source V2 write driver"): + service.trigger_sweep(channel=1) + + assert driver.v1_sweep_configure_calls == 0 + assert driver.v1_sweep_trigger_calls == 0 + assert driver.transport.counters.write_requests == 0 + assert driver.transport.counters.query_calls == 0 + + def test_v1_restore_rejects_before_io_for_a_sweep_v2_driver() -> None: service, driver = _service(dual_contract=True) @@ -491,3 +615,108 @@ def test_v1_restore_rejects_before_io_for_a_sweep_v2_driver() -> None: assert driver.transport.counters.write_requests == 0 assert driver.transport.counters.query_calls == 0 + + +def test_sweep_fire_v2_reuses_configuring_session_and_keeps_output_on() -> None: + service, driver = _service(include_fire=True) + configured, configure_artifact = service.configure_sweep_v2( + _request(trigger_source=SourceTriggerSource.MANUAL) + ) + service.set_output_v2(SourceOutputRequest(channel=1, enabled=True)) + + result, artifact = service.fire_sweep_v2( + SourceFireRequest(channel=1), + correlation_id="sweep-fire", + ) + + assert result == SourceFireResult(channel=1) + assert configured.sweep.trigger.value.source.value is SourceTriggerSource.MANUAL + assert configure_artifact["request"]["trigger_source"] == "manual" + assert driver.fire_requests == [SourceFireRequest(channel=1)] + assert driver.output_enabled is True + assert driver.output_requests == [SourceOutputRequest(channel=1, enabled=True)] + assert artifact["operation"] == "source.sweep_fire_v2" + assert artifact["persistent_session_verified"] is True + assert artifact["postcondition"]["emission_verified"] is False + assert artifact["postcondition"]["external_measurement_required"] is True + assert artifact["final_state"] == { + "session_health": "healthy", + "output_expected": "on", + } + + +def test_sweep_fire_v2_requires_same_session_configuration_before_io() -> None: + service, driver = _service(include_fire=True) + + with pytest.raises(ConfigError, match="configuration from the same session"): + service.fire_sweep_v2(SourceFireRequest(channel=1)) + + assert driver.fire_requests == [] + assert driver.transport.counters.query_calls == 0 + assert driver.transport.counters.write_requests == 0 + + +def test_sweep_fire_v2_requires_output_on_before_fire_write() -> None: + service, driver = _service(include_fire=True) + service.configure_sweep_v2(_request(trigger_source=SourceTriggerSource.MANUAL)) + + with pytest.raises(ConfigError, match="target output ON"): + service.fire_sweep_v2(SourceFireRequest(channel=1)) + + assert driver.fire_requests == [] + assert driver.output_requests == [] + + +def test_sweep_fire_v2_rejects_internal_trigger_configuration() -> None: + service, driver = _service(include_fire=True) + service.configure_sweep_v2(_request()) + service.set_output_v2(SourceOutputRequest(channel=1, enabled=True)) + + with pytest.raises(ConfigError, match="manual trigger source"): + service.fire_sweep_v2(SourceFireRequest(channel=1)) + + assert driver.fire_requests == [] + assert driver.output_enabled is True + + +def test_sweep_fire_v2_failure_is_not_retried_and_recovers_off() -> None: + service, driver = _service(include_fire=True, raise_after_fire=True) + service.configure_sweep_v2(_request(trigger_source=SourceTriggerSource.MANUAL)) + service.set_output_v2(SourceOutputRequest(channel=1, enabled=True)) + + with pytest.raises(ConfigError, match="failed after write") as raised: + service.fire_sweep_v2(SourceFireRequest(channel=1)) + + artifact = raised.value.source_operation_artifact + assert driver.fire_requests == [SourceFireRequest(channel=1)] + assert driver.output_requests == [ + SourceOutputRequest(channel=1, enabled=True), + SourceOutputRequest(channel=1, enabled=False), + ] + assert driver.output_enabled is False + assert artifact["recovery"]["status"] == "off_verified" + assert artifact["final_state"]["output_expected"] == "off" + + +def test_sweep_fire_v2_postcondition_mismatch_recovers_off() -> None: + service, driver = _service(include_fire=True, post_fire_mismatch=True) + service.configure_sweep_v2(_request(trigger_source=SourceTriggerSource.MANUAL)) + service.set_output_v2(SourceOutputRequest(channel=1, enabled=True)) + + with pytest.raises(ConfigError, match="same-session receipt"): + service.fire_sweep_v2(SourceFireRequest(channel=1)) + + assert driver.fire_requests == [SourceFireRequest(channel=1)] + assert driver.output_requests[-1] == SourceOutputRequest(channel=1, enabled=False) + assert driver.output_enabled is False + + +def test_v1_sweep_trigger_maps_to_fire_v2_when_declared() -> None: + service, driver = _service(include_fire=True, dual_contract=True) + service.configure_sweep_v2(_request(trigger_source=SourceTriggerSource.MANUAL)) + service.set_output_v2(SourceOutputRequest(channel=1, enabled=True)) + + service.trigger_sweep(channel=1) + + assert driver.fire_requests == [SourceFireRequest(channel=1)] + assert driver.v1_sweep_trigger_calls == 0 diff --git a/tests/test_source_v1_routes.py b/tests/test_source_v1_routes.py index 4556ceb..777594f 100644 --- a/tests/test_source_v1_routes.py +++ b/tests/test_source_v1_routes.py @@ -31,6 +31,7 @@ def test_source_v1_write_inventory_remains_complete_alongside_v2_operation_specs assert inventoried_operations <= source_write_operations assert source_write_operations - inventoried_operations == { "source.basic_configure_v2", + "source.basic_live_configure_v2", "source.output_enable_v2", "source.output_disable_v2", "source.harmonics_configure_v2", @@ -40,10 +41,17 @@ def test_source_v1_write_inventory_remains_complete_alongside_v2_operation_specs "source.modulation_fm_configure_v2", "source.modulation_pwm_configure_v2", "source.sweep_configure_v2", + "source.sweep_fire_v2", "source.burst_configure_v2", + "source.burst_fire_v2", "source.pulse_configure_v2", "source.arbitrary_storage_v2", "source.arbitrary_select_v2", + "source.arbitrary_volatile_replace_v2", + "source.arbitrary_workspace_volatile_replace_v2", + "source.counter_configure_v2", + "source.counter_enable_v2", + "source.counter_disable_v2", "source.combine_configure_v2", "source.coupling_configure_v2", "source.tracking_configure_v2", @@ -68,19 +76,27 @@ def test_source_v1_indirect_write_entries_are_frozen_and_v2_steps_are_additive() } expected_v2_run_steps = { "source.basic_configure_v2", + "source.basic_live_configure_v2", "source.output_enable_v2", "source.output_disable_v2", + "source.counter_configure_v2", + "source.counter_enable_v2", + "source.counter_disable_v2", + "source.counter_measure_v2", "source.harmonics_configure_v2", "source.harmonics_disable_v2", "source.modulation_configure_v2", "source.modulation_pm_configure_v2", "source.modulation_fm_configure_v2", "source.modulation_pwm_configure_v2", - "source.sweep_configure_v2", - "source.burst_configure_v2", + "source.sweep_configure_v2", + "source.sweep_fire_v2", + "source.burst_configure_v2", "source.pulse_configure_v2", "source.arbitrary_storage_v2", - "source.arbitrary_select_v2", + "source.arbitrary_select_v2", + "source.arbitrary_volatile_replace_v2", + "source.arbitrary_workspace_volatile_replace_v2", "source.combine_configure_v2", "source.coupling_configure_v2", "source.tracking_configure_v2", @@ -94,6 +110,7 @@ def test_source_v1_indirect_write_entries_are_frozen_and_v2_steps_are_additive() } == expected_v1_run_steps | expected_v2_run_steps expected_v2_operations = { "source.basic_configure_v2", + "source.basic_live_configure_v2", "source.output_enable_v2", "source.output_disable_v2", "source.harmonics_configure_v2", @@ -103,10 +120,16 @@ def test_source_v1_indirect_write_entries_are_frozen_and_v2_steps_are_additive() "source.modulation_fm_configure_v2", "source.modulation_pwm_configure_v2", "source.sweep_configure_v2", + "source.sweep_fire_v2", "source.burst_configure_v2", + "source.burst_fire_v2", "source.pulse_configure_v2", "source.arbitrary_storage_v2", "source.arbitrary_select_v2", + "source.arbitrary_workspace_volatile_replace_v2", + "source.counter_configure_v2", + "source.counter_enable_v2", + "source.counter_disable_v2", "source.combine_configure_v2", "source.coupling_configure_v2", "source.tracking_configure_v2", @@ -116,13 +139,21 @@ def test_source_v1_indirect_write_entries_are_frozen_and_v2_steps_are_additive() spec.operation for spec in list_operation_specs(instrument_kind="source") if "_v2" in spec.operation and spec.effect == "write" - } == expected_v2_operations + } == expected_v2_operations | { + "source.arbitrary_volatile_replace_v2", + "source.arbitrary_workspace_volatile_replace_v2", + } with TemporaryDirectory() as tmp: valid_steps = { "source.basic_configure_v2": "channel = 1\nfrequency_hz = 1000\n", + "source.basic_live_configure_v2": "channel = 1\nfrequency_hz = 1000\n", "source.output_enable_v2": "channel = 1\n", "source.output_disable_v2": "channel = 1\n", + "source.counter_configure_v2": "input_id = \"counter\"\ncoupling = \"dc\"\n", + "source.counter_enable_v2": "input_id = \"counter\"\n", + "source.counter_disable_v2": "input_id = \"counter\"\n", + "source.counter_measure_v2": "input_id = \"counter\"\n", "source.harmonics_configure_v2": "channel = 1\norder = 8\npreset = \"odd\"\n", "source.harmonics_disable_v2": "channel = 1\n", "source.modulation_configure_v2": "channel = 1\ndepth_percent = 80\ninternal_frequency_hz = 25\n", @@ -134,6 +165,7 @@ def test_source_v1_indirect_write_entries_are_frozen_and_v2_steps_are_additive() "source.pulse_configure_v2": "channel = 1\nwidth_s = 1e-6\ndelay_s = 0\nleading_transition_s = 1e-8\ntrailing_transition_s = 1e-8\n", "source.arbitrary_storage_v2": "channel = 1\nslot_id = \"slot_a\"\nfile = \"payload.bin\"\nwrite_mode = \"create_only\"\n", "source.arbitrary_select_v2": "channel = 1\nslot_id = \"slot_a\"\nplayback_mode = \"dds\"\nplayback_frequency_hz = 1000\n", + "source.arbitrary_workspace_volatile_replace_v2": "file = \"payload.bin\"\npoint_count = 2\n", "source.combine_configure_v2": "channels = [1, 2]\nenabled = true\n", "source.coupling_configure_v2": "channels = [1, 2]\nenabled = true\n", "source.tracking_configure_v2": "channels = [1, 2]\nenabled = true\n", diff --git a/tests/test_waveform_binary_factory.py b/tests/test_waveform_binary_factory.py index 4b21003..d247de7 100644 --- a/tests/test_waveform_binary_factory.py +++ b/tests/test_waveform_binary_factory.py @@ -186,7 +186,6 @@ def factory(context): 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?"]