From 43bd9620cab7b8fe0064d1020690497ecb734889 Mon Sep 17 00:00:00 2001 From: Scaxlibur <51772892+Scaxlibur@users.noreply.github.com> Date: Tue, 8 Sep 2026 20:12:06 +0800 Subject: [PATCH] fix(siglent): verify restored scope settings by readback --- doc/reference/plugin-catalog-en.md | 4 +- doc/reference/plugin-catalog.md | 4 +- .../doc/WAVEBENCH_CAPABILITY_MATRIX.md | 6 ++ .../doc/WAVEBENCH_CAPABILITY_MATRIX_EN.md | 4 + .../wavebench-siglent-sds3000/pyproject.toml | 2 +- .../wavebench_siglent_sds3000/descriptor.py | 2 +- .../src/wavebench_siglent_sds3000/driver.py | 49 +++++++++--- .../tests/test_driver.py | 75 ++++++++++++++++++- .../tests/test_wheel.py | 2 +- .../doc/SDS800X_HD_COVERAGE_MATRIX.md | 6 ++ .../doc/SDS800X_HD_COVERAGE_MATRIX_EN.md | 4 + .../pyproject.toml | 2 +- .../descriptor.py | 2 +- .../wavebench_siglent_sds800x_hd/driver.py | 36 ++++++--- .../tests/test_driver.py | 2 +- .../tests/test_fetch_waveform.py | 50 +++++++++++++ .../tests/test_wheel.py | 4 +- 17 files changed, 222 insertions(+), 32 deletions(-) diff --git a/doc/reference/plugin-catalog-en.md b/doc/reference/plugin-catalog-en.md index f298d13..84a2d15 100644 --- a/doc/reference/plugin-catalog-en.md +++ b/doc/reference/plugin-catalog-en.md @@ -26,8 +26,8 @@ Regenerate it with `python scripts/generate_plugin_catalog.py`; verify drift wit | [`wavebench-rohde-schwarz-rtm2000`](../../packages/wavebench-rohde-schwarz-rtm2000/README_EN.md) | `0.15.0` | `rohde-schwarz.rtm2032` | scope | RTM2032, RTM2000 | `>=3.11` | `wavebench>=0.8.26,<0.9` | | [`wavebench-shengpu-sp3000a`](../../packages/wavebench-shengpu-sp3000a/README_EN.md) | `0.2.0` | `shengpu.sp30120` | sweep_analyzer | SP30120 | `>=3.11` | `wavebench>=0.8,<0.9` | | [`wavebench-siglent-sdg2000x`](../../packages/wavebench-siglent-sdg2000x/README_EN.md) | `0.8.2` | `siglent.sdg2000x` | source | SDG2042X, SDG2082X, SDG2122X | `>=3.11` | `wavebench>=0.8.24,<0.9` | -| [`wavebench-siglent-sds3000`](../../packages/wavebench-siglent-sds3000/README_EN.md) | `0.1.0` | `siglent.sds3000` | scope | SDS3054 | `>=3.11` | `wavebench>=0.8.24,<0.9` | -| [`wavebench-siglent-sds800x-hd`](../../packages/wavebench-siglent-sds800x-hd/README_EN.md) | `0.6.0` | `siglent.sds800x-hd` | scope | SDS802X HD, SDS804X HD, SDS812X HD, SDS814X HD, SDS822X HD, SDS824X HD | `>=3.11` | `wavebench>=0.8.23,<0.9` | +| [`wavebench-siglent-sds3000`](../../packages/wavebench-siglent-sds3000/README_EN.md) | `0.1.1` | `siglent.sds3000` | scope | SDS3054 | `>=3.11` | `wavebench>=0.8.24,<0.9` | +| [`wavebench-siglent-sds800x-hd`](../../packages/wavebench-siglent-sds800x-hd/README_EN.md) | `0.6.1` | `siglent.sds800x-hd` | scope | SDS802X HD, SDS804X HD, SDS812X HD, SDS814X HD, SDS822X HD, SDS824X HD | `>=3.11` | `wavebench>=0.8.23,<0.9` | ## Declared capabilities diff --git a/doc/reference/plugin-catalog.md b/doc/reference/plugin-catalog.md index e8b3de1..21d32f7 100644 --- a/doc/reference/plugin-catalog.md +++ b/doc/reference/plugin-catalog.md @@ -25,8 +25,8 @@ | [`wavebench-rohde-schwarz-rtm2000`](../../packages/wavebench-rohde-schwarz-rtm2000/README.md) | `0.15.0` | `rohde-schwarz.rtm2032` | 示波器 | RTM2032, RTM2000 | `>=3.11` | `wavebench>=0.8.26,<0.9` | | [`wavebench-shengpu-sp3000a`](../../packages/wavebench-shengpu-sp3000a/README.md) | `0.2.0` | `shengpu.sp30120` | 扫频仪 | SP30120 | `>=3.11` | `wavebench>=0.8,<0.9` | | [`wavebench-siglent-sdg2000x`](../../packages/wavebench-siglent-sdg2000x/README.md) | `0.8.2` | `siglent.sdg2000x` | 信号源 | SDG2042X, SDG2082X, SDG2122X | `>=3.11` | `wavebench>=0.8.24,<0.9` | -| [`wavebench-siglent-sds3000`](../../packages/wavebench-siglent-sds3000/README.md) | `0.1.0` | `siglent.sds3000` | 示波器 | SDS3054 | `>=3.11` | `wavebench>=0.8.24,<0.9` | -| [`wavebench-siglent-sds800x-hd`](../../packages/wavebench-siglent-sds800x-hd/README.md) | `0.6.0` | `siglent.sds800x-hd` | 示波器 | SDS802X HD, SDS804X HD, SDS812X HD, SDS814X HD, SDS822X HD, SDS824X HD | `>=3.11` | `wavebench>=0.8.23,<0.9` | +| [`wavebench-siglent-sds3000`](../../packages/wavebench-siglent-sds3000/README.md) | `0.1.1` | `siglent.sds3000` | 示波器 | SDS3054 | `>=3.11` | `wavebench>=0.8.24,<0.9` | +| [`wavebench-siglent-sds800x-hd`](../../packages/wavebench-siglent-sds800x-hd/README.md) | `0.6.1` | `siglent.sds800x-hd` | 示波器 | SDS802X HD, SDS804X HD, SDS812X HD, SDS814X HD, SDS822X HD, SDS824X HD | `>=3.11` | `wavebench>=0.8.23,<0.9` | ## 已声明 capability diff --git a/packages/wavebench-siglent-sds3000/doc/WAVEBENCH_CAPABILITY_MATRIX.md b/packages/wavebench-siglent-sds3000/doc/WAVEBENCH_CAPABILITY_MATRIX.md index ce885c0..ad4a28a 100644 --- a/packages/wavebench-siglent-sds3000/doc/WAVEBENCH_CAPABILITY_MATRIX.md +++ b/packages/wavebench-siglent-sds3000/doc/WAVEBENCH_CAPABILITY_MATRIX.md @@ -53,3 +53,9 @@ 本矩阵回答「WaveBench 当前接口能否表达」;[`COMMAND_COVERAGE.md`](COMMAND_COVERAGE.md) 与机器目录回答「手册的每个明确实体如何处置」。两者分母不同:前者是 26 项 capability,后者是 578 个明确手册实体。 因此,100% 覆盖不等于 100% 实机执行。复位、校准、文件、网络、hardcopy、选件激活、关机和任意脚本仍必须隔离;选件缺失、型号不适用、固件未确认和核心模型缺口也必须保留为可审计结论。 + +## 恢复后的设置验证 + +波形传输的 CHDR/CFMT/CORD/WFSU,以及采集路径修改的 TRMD/TDIV/VDIV/TRA,在写回原设置后逐项独立查询。枚举按规范化值比较,时基与垂直档位按单位换算后以 `rtol=1e-9, atol=0` 比较;响应格式等价不应误报漂移。读回不符、响应无效或恢复写入失败均使操作失败,并记录具体字段。 + +结构化传输或会话失败后不继续恢复 I/O。普通主操作错误与恢复错误通过异常原因保留。此次修复经过离线故障测试,包括设备静默忽略写入;未进行新的实机验收,既有实机记录不用于声称新增回读已验收。 diff --git a/packages/wavebench-siglent-sds3000/doc/WAVEBENCH_CAPABILITY_MATRIX_EN.md b/packages/wavebench-siglent-sds3000/doc/WAVEBENCH_CAPABILITY_MATRIX_EN.md index d8fb4eb..4804a34 100644 --- a/packages/wavebench-siglent-sds3000/doc/WAVEBENCH_CAPABILITY_MATRIX_EN.md +++ b/packages/wavebench-siglent-sds3000/doc/WAVEBENCH_CAPABILITY_MATRIX_EN.md @@ -53,3 +53,7 @@ Disposition meanings: This matrix answers whether each current WaveBench interface can be represented. [`COMMAND_COVERAGE_EN.md`](COMMAND_COVERAGE_EN.md) and the machine catalog answer how every explicit manual entity is disposed. Their denominators differ: 26 capabilities here and 578 explicit manual entities in the command catalog. One hundred percent coverage therefore does not mean executing every instruction on hardware. Reset, calibration, filesystem, network, hardcopy, option activation, shutdown, and arbitrary-script paths remain quarantined. Missing options, model exclusions, unverified firmware behavior, and core-model gaps remain auditable coverage outcomes. + +## Restoration readback + +Restored CHDR/CFMT/CORD/WFSU settings and capture TRMD/TDIV/VDIV/TRA settings are independently queried after each write. Enumerations are normalized; timebase and vertical scales are compared in base units with `rtol=1e-9, atol=0`. A mismatch, malformed response or restore write failure fails the operation and identifies the affected field. Structured transport/session failures stop further restoration I/O. A primary operation failure is retained as the cause when restoration also fails. This change has offline fault coverage, including silently ignored writes; no new hardware acceptance was performed. diff --git a/packages/wavebench-siglent-sds3000/pyproject.toml b/packages/wavebench-siglent-sds3000/pyproject.toml index d92d525..d33e6d6 100644 --- a/packages/wavebench-siglent-sds3000/pyproject.toml +++ b/packages/wavebench-siglent-sds3000/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "wavebench-siglent-sds3000" -version = "0.1.0" +version = "0.1.1" description = "SIGLENT SDS3000 oscilloscope plugin for WaveBench" readme = "README.md" requires-python = ">=3.11" diff --git a/packages/wavebench-siglent-sds3000/src/wavebench_siglent_sds3000/descriptor.py b/packages/wavebench-siglent-sds3000/src/wavebench_siglent_sds3000/descriptor.py index e1df072..c3a5e47 100644 --- a/packages/wavebench-siglent-sds3000/src/wavebench_siglent_sds3000/descriptor.py +++ b/packages/wavebench-siglent-sds3000/src/wavebench_siglent_sds3000/descriptor.py @@ -42,7 +42,7 @@ def descriptor() -> InstrumentDescriptor: wavebench_min_version="0.8.24", wavebench_max_version="0.9.0", distribution="wavebench-siglent-sds3000", - version="0.1.0", + version="0.1.1", source="entry_point:siglent.sds3000", scope_coupling_policy="switchable-termination", config_fields=("connection.resource", "scope.driver", "waveform.*"), diff --git a/packages/wavebench-siglent-sds3000/src/wavebench_siglent_sds3000/driver.py b/packages/wavebench-siglent-sds3000/src/wavebench_siglent_sds3000/driver.py index 4a21ff4..a136568 100644 --- a/packages/wavebench-siglent-sds3000/src/wavebench_siglent_sds3000/driver.py +++ b/packages/wavebench-siglent-sds3000/src/wavebench_siglent_sds3000/driver.py @@ -2,7 +2,7 @@ from contextlib import contextmanager from dataclasses import dataclass, field -from math import isfinite +from math import isclose, isfinite import re from threading import RLock from typing import TYPE_CHECKING, Callable, Iterator @@ -342,6 +342,35 @@ def _query_waveform_transfer_state(self) -> _WaveformTransferState: ), ) + def _verify_restored_setting(self, name: str, expected: str) -> None: + response = self.transport.query(f"{name}?", replay=ReplayPolicy.NO_REPLAY) + parsers = {"CHDR": self._parse_header_state, "CFMT": self._parse_format_state, + "CORD": self._parse_order_state, "WFSU": self._parse_setup_state, + "TRMD": self._parse_trigger_mode} + if name in parsers: + actual = parsers[name](response) + matches = actual == expected + elif name.endswith(":TRA"): + actual = self._parse_trace_state(response, channel=int(name[1:name.index(":")])) + matches = actual == expected + else: + time_axis = name == "TDIV" + units = frozenset({"", "S", "MS", "US", "NS", "KS"} if time_axis else {"", "V", "MV", "UV", "NV", "KV"}) + header = "TIME_DIV" if time_axis else name.replace(":VDIV", ":VOLT_DIV") + actual = self._parse_positive_quantity(response, headers=(name, header), units=units, name=name) + def quantity(value: str) -> float: + match = _QUANTITY_RE.fullmatch(value) + assert match is not None + unit = match.group("unit") + scale = {"M": 1e-3, "U": 1e-6, "N": 1e-9, "K": 1e3}.get(unit[:1], 1.) + return float(match.group("number")) * scale + matches = isclose(quantity(actual), quantity(expected), rel_tol=1e-9, abs_tol=0.) + if not matches: + raise StateDriftError( + f"SDS3000 restore readback mismatch: {name}", expected={name: expected}, + actual={name: actual}, diff={name: {"expected": expected, "actual": actual}}, + ) + @contextmanager def _temporary_waveform_transfer_state( self, @@ -371,6 +400,7 @@ def _temporary_waveform_transfer_state( for command, previous in reversed(restore): try: self.transport.write(f"{command} {previous}") + self._verify_restored_setting(command, previous) except _STRUCTURED_IO_ERRORS: raise except Exception as exc: # pragma: no branch - all failures are retained @@ -378,16 +408,16 @@ def _temporary_waveform_transfer_state( if failures: expected = {command: previous for command, previous, _ in failures} diff = { - command: {"expected": previous, "actual": "unknown"} - for command, previous, _ in failures + command: {"expected": previous, "actual": getattr(exc, "actual", {}).get(command, "unknown")} + for command, previous, exc in failures } names = ", ".join(command for command, _, _ in failures) raise StateDriftError( f"failed to restore SDS3000 waveform transfer state: {names}", expected=expected, - actual={command: "unknown" for command in expected}, + actual={name: getattr(exc, "actual", {}).get(name, "unknown") for name, _, exc in failures}, diff=diff, - ) from failures[0][2] + ) from (operation_failure or failures[0][2]) @staticmethod def _validate_waveform_points(points: str) -> str: @@ -555,6 +585,7 @@ def _temporary_capture_state( for name, command, expected in reversed(restore): try: self.transport.write(command) + self._verify_restored_setting(name, expected) except _STRUCTURED_IO_ERRORS: raise except Exception as exc: # pragma: no branch - all failures are retained @@ -562,16 +593,16 @@ def _temporary_capture_state( if failures: expected = {name: value for name, value, _ in failures} diff = { - name: {"expected": value, "actual": "unknown"} - for name, value, _ in failures + name: {"expected": value, "actual": getattr(exc, "actual", {}).get(name, "unknown")} + for name, value, exc in failures } names = ", ".join(name for name, _, _ in failures) raise StateDriftError( f"failed to restore SDS3000 capture state: {names}", expected=expected, - actual={name: "unknown" for name in expected}, + actual={name: getattr(exc, "actual", {}).get(name, "unknown") for name, _, exc in failures}, diff=diff, - ) from failures[0][2] + ) from (operation_failure or failures[0][2]) def _acquire_once(self) -> None: budget_ms = max(min(self.io_timeout_ms, self.opc_timeout_ms), 1_000) diff --git a/packages/wavebench-siglent-sds3000/tests/test_driver.py b/packages/wavebench-siglent-sds3000/tests/test_driver.py index a6c00e8..f10bbc9 100644 --- a/packages/wavebench-siglent-sds3000/tests/test_driver.py +++ b/packages/wavebench-siglent-sds3000/tests/test_driver.py @@ -416,6 +416,7 @@ def test_fetch_waveform_uses_existing_capability_and_restores_transfer_state() - "WFSU?", "C1:WF? DESC", "C1:WF? DAT1", + "WFSU?", "CORD?", "CFMT?", "CHDR?", ] assert transport.writes == [ "CHDR OFF", @@ -519,7 +520,7 @@ def _capture_transport( } return FakeTransport( responses=responses, - response_sequences={"TRMD?": ["TRMD AUTO", f"TRMD {final_trigger_mode}"]}, + response_sequences={"TRMD?": ["TRMD AUTO", f"TRMD {final_trigger_mode}", "TRMD AUTO"]}, binary_responses={ "C1:WF? DESC": _word_descriptor(), "C1:WF? DAT1": struct.pack("<4h", -2, 0, 2, 4), @@ -647,3 +648,75 @@ def test_capture_rejects_invalid_requests_before_io( assert transport.queries == [] assert transport.writes == [] + + +def _track_setting_writes(transport, *, ignored): + original = transport.write + def write(command): + original(command) + if command != ignored and " " in command: + name, value = command.split(" ", 1) + transport.responses[name + "?"] = value + transport.write = write + + +@pytest.mark.parametrize("ignored", ["CHDR SHORT", "CFMT DEF9,BYTE,BIN", "CORD HI", "WFSU SP,4,NP,10,FP,2,SN,0"]) +def test_transfer_restore_detects_silently_ignored_write(ignored): + transport = _waveform_transport() + _track_setting_writes(transport, ignored=ignored) + with pytest.raises(StateDriftError) as caught: + SDS3000Scope(transport).fetch_waveform(1, check_errors=False) + name = ignored.split(" ", 1)[0] + assert caught.value.actual[name] != caught.value.expected[name] + assert caught.value.diff[name]["actual"] == caught.value.actual[name] + + +@pytest.mark.parametrize("ignored", ["TDIV 2 MS", "C1:VDIV 200 MV", "C1:TRA OFF", "TRMD AUTO"]) +def test_capture_restore_detects_silently_ignored_write(ignored): + transport = _capture_transport(trace_states={1: "OFF"}) + # The first two trigger reads model baseline and completed acquisition. + transport.response_sequences["TRMD?"] = ["AUTO", "STOP"] + original_query = transport.query + def query(command, *, replay): + if command in transport.response_sequences and not transport.response_sequences[command]: + del transport.response_sequences[command] + return original_query(command, replay=replay) + transport.query = query + transport.responses["TRMD?"] = "STOP" + _track_setting_writes(transport, ignored=ignored) + with pytest.raises(StateDriftError) as caught: + SDS3000Scope(transport).capture_waveform(1, check_errors=False, time_range_s=.01, vertical_scale_v_per_div=.5) + assert ignored.split(" ", 1)[0] in caught.value.diff + + +@pytest.mark.parametrize("name,expected,response", [ + ("TDIV", "2 MS", "TIME_DIV 0.002 S"), + ("C1:VDIV", "200 MV", "C1:VOLT_DIV 0.2 V"), +]) +def test_restore_compares_quantities_after_unit_conversion(name, expected, response): + scope = SDS3000Scope(FakeTransport(responses={name + "?": response})) + scope._verify_restored_setting(name, expected) + + +def test_restore_readback_failure_retains_primary_waveform_error(): + primary = TimeoutError("primary waveform failure") + transport = _waveform_transport(data_response=primary) + transport.response_sequences["CHDR?"] = ["SHORT", "OFF"] + with pytest.raises(StateDriftError) as caught: + SDS3000Scope(transport).fetch_waveform(1, check_errors=False) + assert caught.value.__cause__ is primary + + +def test_restore_verification_query_failure_stops_following_io(): + failure = _transport_failure(operation="query", synchronization=Synchronization.LOST) + transport = _waveform_transport() + original = transport.query + def query(command, *, replay): + if command == "WFSU?" and transport.writes: + raise failure + return original(command, replay=replay) + transport.query = query + with pytest.raises(TransportIOError) as caught: + SDS3000Scope(transport).fetch_waveform(1, check_errors=False) + assert caught.value is failure + assert transport.writes[-1] == "WFSU SP,4,NP,10,FP,2,SN,0" diff --git a/packages/wavebench-siglent-sds3000/tests/test_wheel.py b/packages/wavebench-siglent-sds3000/tests/test_wheel.py index ff84be9..b1aac9b 100644 --- a/packages/wavebench-siglent-sds3000/tests/test_wheel.py +++ b/packages/wavebench-siglent-sds3000/tests/test_wheel.py @@ -13,7 +13,7 @@ PACKAGE_ROOT = Path(__file__).resolve().parents[1] -PACKAGE_VERSION = "0.1.0" +PACKAGE_VERSION = "0.1.1" def _run(command: list[str], *, cwd: Path) -> subprocess.CompletedProcess[str]: diff --git a/packages/wavebench-siglent-sds800x-hd/doc/SDS800X_HD_COVERAGE_MATRIX.md b/packages/wavebench-siglent-sds800x-hd/doc/SDS800X_HD_COVERAGE_MATRIX.md index 46aeddb..a60ad2c 100644 --- a/packages/wavebench-siglent-sds800x-hd/doc/SDS800X_HD_COVERAGE_MATRIX.md +++ b/packages/wavebench-siglent-sds800x-hd/doc/SDS800X_HD_COVERAGE_MATRIX.md @@ -123,3 +123,9 @@ x[i] = horizontal_delay - timebase * 10 / 2 + i * sample_interval - [功能覆盖开发路线](SDS800X_HD_COVERAGE_MILESTONES.md) - [实机验收记录](SDS800X_HD_HARDWARE_ACCEPTANCE.md) - [Scope R1.3 conformance](SDS800X_HD_R13_CONFORMANCE.md) + +## 波形传输恢复验证 + +legacy `fetch_waveform` 写回原传输设置后,重新读取 source/start/interval/points/width/byte order 六项并与快照比较。写入被设备忽略、回读无效或不一致时操作失败;不会只凭命令返回就认定恢复成功。结构化传输或会话失败后停止后续恢复 I/O,普通主操作与恢复失败信息均保留。 + +本次新增验证仅做离线测试和制品检查,不扩展型号/固件实机范围,也不承诺自动恢复 capture 修改的时基、垂直档位或通道显示。测试专用 R1.3 fixture 与此生产路径保持各自的证据边界。 diff --git a/packages/wavebench-siglent-sds800x-hd/doc/SDS800X_HD_COVERAGE_MATRIX_EN.md b/packages/wavebench-siglent-sds800x-hd/doc/SDS800X_HD_COVERAGE_MATRIX_EN.md index 1a24ccd..c0176ca 100644 --- a/packages/wavebench-siglent-sds800x-hd/doc/SDS800X_HD_COVERAGE_MATRIX_EN.md +++ b/packages/wavebench-siglent-sds800x-hd/doc/SDS800X_HD_COVERAGE_MATRIX_EN.md @@ -129,3 +129,7 @@ This list indexes protocol domains from the current driver. The complete source - [Feature-coverage development roadmap](SDS800X_HD_COVERAGE_MILESTONES_EN.md) - [Hardware acceptance record](SDS800X_HD_HARDWARE_ACCEPTANCE_EN.md) - [Scope R1.3 conformance](SDS800X_HD_R13_CONFORMANCE.md) (Chinese) + +## Waveform transfer restoration verification + +After restoring the legacy `fetch_waveform` transfer settings, the driver freshly reads source, start, interval, points, width and byte order and compares them with the baseline. Ignored writes, malformed readback and mismatches fail the operation. Structured transport/session failures stop subsequent restoration I/O; ordinary primary and restoration errors are both retained. This change is tested offline and does not extend hardware acceptance or promise restoration of capture timebase, vertical scale or channel display. The R1.3 test fixture remains separate from this production path. diff --git a/packages/wavebench-siglent-sds800x-hd/pyproject.toml b/packages/wavebench-siglent-sds800x-hd/pyproject.toml index de87dcc..e384989 100644 --- a/packages/wavebench-siglent-sds800x-hd/pyproject.toml +++ b/packages/wavebench-siglent-sds800x-hd/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "wavebench-siglent-sds800x-hd" -version = "0.6.0" +version = "0.6.1" description = "SIGLENT SDS800X HD oscilloscope driver plugin for WaveBench" readme = "README.md" requires-python = ">=3.11" diff --git a/packages/wavebench-siglent-sds800x-hd/src/wavebench_siglent_sds800x_hd/descriptor.py b/packages/wavebench-siglent-sds800x-hd/src/wavebench_siglent_sds800x_hd/descriptor.py index 88cc515..c970665 100644 --- a/packages/wavebench-siglent-sds800x-hd/src/wavebench_siglent_sds800x_hd/descriptor.py +++ b/packages/wavebench-siglent-sds800x-hd/src/wavebench_siglent_sds800x_hd/descriptor.py @@ -64,7 +64,7 @@ def descriptor() -> InstrumentDescriptor: wavebench_min_version="0.8.23", wavebench_max_version="0.9.0", distribution="wavebench-siglent-sds800x-hd", - version="0.6.0", + version="0.6.1", source="entry_point:siglent.sds800x-hd", scope_coupling_policy="fixed-high-impedance", config_fields=("connection.resource", "scope.driver", "waveform.*"), diff --git a/packages/wavebench-siglent-sds800x-hd/src/wavebench_siglent_sds800x_hd/driver.py b/packages/wavebench-siglent-sds800x-hd/src/wavebench_siglent_sds800x_hd/driver.py index bd144e5..b00de11 100644 --- a/packages/wavebench-siglent-sds800x-hd/src/wavebench_siglent_sds800x_hd/driver.py +++ b/packages/wavebench-siglent-sds800x-hd/src/wavebench_siglent_sds800x_hd/driver.py @@ -1,12 +1,12 @@ from __future__ import annotations from collections.abc import Callable -from dataclasses import dataclass, field +from dataclasses import asdict, dataclass, field from decimal import Decimal, InvalidOperation from math import isfinite import time -from wavebench.errors import DataError, OperationTimeout +from wavebench.errors import DataError, OperationTimeout, SessionHealthError, StateDriftError, TransportIOError from wavebench.instruments import ( ScopeAcquisitionCompletion, ScopeAcquisitionControlBaseline, @@ -680,6 +680,8 @@ def _restore_waveform_transfer_state(self, state: _WaveformTransferState) -> Non for command in commands: try: self.transport.write(command) + except (TransportIOError, SessionHealthError): + raise except Exception as exc: failures.append((command, exc)) if failures: @@ -693,6 +695,15 @@ def _restore_waveform_transfer_state(self, state: _WaveformTransferState) -> Non f"{command!r}: {error}" ) raise first_error + actual = self._read_waveform_transfer_state() + if actual != state: + expected_values, actual_values = asdict(state), asdict(actual) + raise StateDriftError( + "SDS800X HD waveform transfer restore readback mismatch", + expected=expected_values, actual=actual_values, + diff={key: {"expected": value, "actual": actual_values[key]} + for key, value in expected_values.items() if actual_values[key] != value}, + ) def _read_waveform_chunks(self, *, points: int, max_points: int) -> bytes: chunks: list[bytes] = [] @@ -876,15 +887,20 @@ def fetch_waveform( primary_error = exc raise finally: - try: - self._restore_waveform_transfer_state(state) - except Exception as restore_error: - if primary_error is None: + if not isinstance(primary_error, (TransportIOError, SessionHealthError)): + try: + self._restore_waveform_transfer_state(state) + except (TransportIOError, SessionHealthError) as restore_error: + if primary_error is not None: + restore_error.add_note(f"Primary waveform error: {primary_error}") raise - primary_error.add_note( - "SDS800X HD waveform transfer state restoration also failed: " - f"{restore_error}" - ) + except Exception as restore_error: + if primary_error is None: + raise + primary_error.add_note( + "SDS800X HD waveform transfer state restoration also failed: " + f"{restore_error}" + ) def capture_waveform( self, diff --git a/packages/wavebench-siglent-sds800x-hd/tests/test_driver.py b/packages/wavebench-siglent-sds800x-hd/tests/test_driver.py index 56ffead..069038d 100644 --- a/packages/wavebench-siglent-sds800x-hd/tests/test_driver.py +++ b/packages/wavebench-siglent-sds800x-hd/tests/test_driver.py @@ -68,7 +68,7 @@ def test_descriptor_is_executable_metadata_without_import_io() -> None: ) assert item.scope_coupling_policy == "fixed-high-impedance" assert item.distribution == "wavebench-siglent-sds800x-hd" - assert item.version == "0.6.0" + assert item.version == "0.6.1" assert item.wavebench_min_version == "0.8.23" assert item.scope_extensions is not None assert item.scope_extensions.screenshot_profile is SDS800X_HD_SCREENSHOT_PROFILE diff --git a/packages/wavebench-siglent-sds800x-hd/tests/test_fetch_waveform.py b/packages/wavebench-siglent-sds800x-hd/tests/test_fetch_waveform.py index cbc6ee3..0dffea0 100644 --- a/packages/wavebench-siglent-sds800x-hd/tests/test_fetch_waveform.py +++ b/packages/wavebench-siglent-sds800x-hd/tests/test_fetch_waveform.py @@ -378,6 +378,7 @@ def test_fetch_waveform_reads_stopped_record_in_chunks_and_restores_state() -> N ("write", ":WAVeform:START 4"), ("binary", ":WAVeform:DATA?"), *(("write", command) for command in _RESTORE_WRITES), + *(("query", command) for command in _ORIGINAL_TRANSFER_STATE), ] @@ -640,3 +641,52 @@ def test_fetch_waveform_restore_failure_does_not_hide_primary_failure() -> None: assert any("restoration also failed" in note for note in caught.value.__notes__) assert transport.writes[-1] == ":WAVeform:START 5" + + +@pytest.mark.parametrize("ignored", _RESTORE_WRITES[2:]) +def test_restore_detects_silently_ignored_transfer_write(ignored): + from wavebench.errors import StateDriftError + transport = FakeTransport() + original = transport.write + def write(command): + original(command) + if command != ignored: + name, value = command.split(" ", 1) + transport.responses[name + "?"] = value + transport.write = write + with pytest.raises(StateDriftError) as caught: + SDS800XHDScope(transport).fetch_waveform(2, check_errors=False) + assert caught.value.diff + assert transport.writes[-len(_RESTORE_WRITES):] == _RESTORE_WRITES + + +def test_readback_error_does_not_hide_primary_error(): + primary = TimeoutError("primary binary failure") + transport = FakeTransport(chunks=[primary]) + original = transport.query + def query(command): + if transport.writes and transport.writes[-1] == _RESTORE_WRITES[-1]: + raise DataError("unreadable restored state") + return original(command) + transport.query = query + with pytest.raises(TimeoutError) as caught: + SDS800XHDScope(transport).fetch_waveform(2, check_errors=False) + assert caught.value is primary + assert any("unreadable restored state" in note for note in caught.value.__notes__) + + +@pytest.mark.parametrize("during_restore", [False, True]) +def test_structured_session_failure_stops_all_following_restore_io(during_restore): + from wavebench.errors import SessionHealthError + failure = SessionHealthError("session poisoned", health="poisoned", io_kind="write" if during_restore else "query", epoch_id="test") + transport = FakeTransport( + chunks=None if during_restore else [failure], + write_failures={_RESTORE_WRITES[2]: failure} if during_restore else None, + ) + with pytest.raises(SessionHealthError) as caught: + SDS800XHDScope(transport).fetch_waveform(2, check_errors=False) + assert caught.value is failure + if during_restore: + assert transport.operations[-1] == ("write", _RESTORE_WRITES[2]) + else: + assert transport.operations[-1] == ("binary", ":WAVeform:DATA?") diff --git a/packages/wavebench-siglent-sds800x-hd/tests/test_wheel.py b/packages/wavebench-siglent-sds800x-hd/tests/test_wheel.py index 1c366bd..3935984 100644 --- a/packages/wavebench-siglent-sds800x-hd/tests/test_wheel.py +++ b/packages/wavebench-siglent-sds800x-hd/tests/test_wheel.py @@ -60,7 +60,7 @@ def test_wheel_install_discovery_reinstall_and_uninstall_without_instrument_io( ], cwd=tmp_path, ) - plugin_wheel = next(wheelhouse.glob("wavebench_siglent_sds800x_hd-0.6.0-*.whl")) + plugin_wheel = next(wheelhouse.glob("wavebench_siglent_sds800x_hd-0.6.1-*.whl")) venv_dir = tmp_path / "venv" _run([sys.executable, "-m", "venv", str(venv_dir)], cwd=tmp_path) python = venv_dir / "bin" / "python" @@ -97,7 +97,7 @@ def forbidden(*args, **kwargs): descriptor = points[0].load()() assert descriptor.driver_id == "siglent.sds800x-hd" assert descriptor.distribution == "wavebench-siglent-sds800x-hd" -assert descriptor.version == "0.6.0" +assert descriptor.version == "0.6.1" assert descriptor.wavebench_min_version == "0.8.23" assert descriptor.capabilities == ( "scope.idn",