From dd6e16fa886fdae3a7c246427aef18bf0919fc1b Mon Sep 17 00:00:00 2001 From: Sergey Lukin Date: Sun, 8 Mar 2026 18:01:00 +0100 Subject: [PATCH 1/9] Add Kiprim DC310S power supply driver Add support for the Kiprim DC310S single-output serial power supply. This adds a conservative InstrumentKit-style driver with: - voltage and current setpoints - output enable/disable - measured voltage, current, and power readback - documented 0-30 V and 0-10 A bounds on setpoints Also add transcript-based unit tests, package exports, API reference docs, and a minimal example script. --- doc/examples/kiprim/ex_kiprim_dc310s.py | 14 ++ doc/source/apiref/index.rst | 1 + doc/source/apiref/kiprim.rst | 12 ++ src/instruments/__init__.py | 1 + src/instruments/kiprim/__init__.py | 6 + src/instruments/kiprim/dc310s.py | 180 ++++++++++++++++++++++++ tests/test_kiprim/__init__.py | 1 + tests/test_kiprim/test_dc310s.py | 122 ++++++++++++++++ 8 files changed, 337 insertions(+) create mode 100644 doc/examples/kiprim/ex_kiprim_dc310s.py create mode 100644 doc/source/apiref/kiprim.rst create mode 100644 src/instruments/kiprim/__init__.py create mode 100644 src/instruments/kiprim/dc310s.py create mode 100644 tests/test_kiprim/__init__.py create mode 100644 tests/test_kiprim/test_dc310s.py diff --git a/doc/examples/kiprim/ex_kiprim_dc310s.py b/doc/examples/kiprim/ex_kiprim_dc310s.py new file mode 100644 index 00000000..30129444 --- /dev/null +++ b/doc/examples/kiprim/ex_kiprim_dc310s.py @@ -0,0 +1,14 @@ +#!/usr/bin/env python + +import instruments as ik + +psu = ik.kiprim.DC310S.open_serial("COM8", baud=115200, timeout=0.5) + +print(psu.name) +print(f"Setpoint: {psu.voltage}, {psu.current}, output={psu.output}") +print(f"Measured: {psu.voltage_sense}, {psu.current_sense}, {psu.power_sense}") + +# Uncomment to configure the supply explicitly. +# psu.voltage = 5 * ik.units.volt +# psu.current = 0.25 * ik.units.ampere +# psu.output = True diff --git a/doc/source/apiref/index.rst b/doc/source/apiref/index.rst index 4ad370ae..271aaa24 100644 --- a/doc/source/apiref/index.rst +++ b/doc/source/apiref/index.rst @@ -24,6 +24,7 @@ Contents: holzworth hp keithley + kiprim lakeshore minghe mettler_toledo diff --git a/doc/source/apiref/kiprim.rst b/doc/source/apiref/kiprim.rst new file mode 100644 index 00000000..9429cf6c --- /dev/null +++ b/doc/source/apiref/kiprim.rst @@ -0,0 +1,12 @@ +.. currentmodule:: instruments.kiprim + +====== +Kiprim +====== + +:class:`DC310S` Power Supply +============================ + +.. autoclass:: DC310S + :members: + :undoc-members: diff --git a/src/instruments/__init__.py b/src/instruments/__init__.py index 660326bf..2b54bfd1 100644 --- a/src/instruments/__init__.py +++ b/src/instruments/__init__.py @@ -24,6 +24,7 @@ from . import holzworth from . import hp from . import keithley +from . import kiprim from . import lakeshore from . import mettler_toledo from . import minghe diff --git a/src/instruments/kiprim/__init__.py b/src/instruments/kiprim/__init__.py new file mode 100644 index 00000000..baf4c316 --- /dev/null +++ b/src/instruments/kiprim/__init__.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python +""" +Module containing Kiprim instruments. +""" + +from .dc310s import DC310S diff --git a/src/instruments/kiprim/dc310s.py b/src/instruments/kiprim/dc310s.py new file mode 100644 index 00000000..ced6afda --- /dev/null +++ b/src/instruments/kiprim/dc310s.py @@ -0,0 +1,180 @@ +#!/usr/bin/env python +""" +Driver for the Kiprim DC310S single-output power supply. +""" + +# IMPORTS ##################################################################### + +from instruments.abstract_instruments import PowerSupply +from instruments.units import ureg as u +from instruments.util_fns import bounded_unitful_property, unitful_property + +# FUNCTIONS ################################################################### + + +def _parse_output_state(reply): + """ + Normalize the DC310S output-state reply into a boolean value. + + The DC310S has been observed to report either ``ON``/``OFF`` or ``1``/``0`` + depending on firmware and transport state. + """ + + reply = reply.strip().upper() + if reply in {"1", "ON"}: + return True + if reply in {"0", "OFF"}: + return False + raise ValueError(f"Unexpected output-state reply: {reply}") + + +# CLASSES ##################################################################### + + +class DC310S(PowerSupply, PowerSupply.Channel): + """ + The Kiprim DC310S is a single-output programmable DC power supply. + + Because the supply has one programmable output, this object inherits from + both `~instruments.abstract_instruments.power_supply.PowerSupply` and + `~instruments.abstract_instruments.power_supply.PowerSupply.Channel`. + + Example usage: + + >>> import instruments as ik + >>> psu = ik.kiprim.DC310S.open_serial("COM8", baud=115200, timeout=0.5) + >>> psu.voltage = 5 * ik.units.volt + >>> psu.current = 0.25 * ik.units.ampere + >>> psu.output = True + >>> psu.voltage_sense + + """ + + voltage, voltage_min, voltage_max = bounded_unitful_property( + "VOLT", + u.volt, + format_code="{:.3f}", + input_decoration=str.strip, + valid_range=(0 * u.volt, 30 * u.volt), + doc=""" + Gets/sets the programmed output voltage. + + The DC310S product documentation specifies a programmable output range + of 0 V to 30 V. + + :units: As specified, or assumed to be :math:`\\text{V}` otherwise. + :type: `float` or `~pint.Quantity` + """, + ) + + current, current_min, current_max = bounded_unitful_property( + "CURR", + u.amp, + format_code="{:.3f}", + input_decoration=str.strip, + valid_range=(0 * u.amp, 10 * u.amp), + doc=""" + Gets/sets the programmed output current limit. + + The DC310S product documentation specifies a programmable output range + of 0 A to 10 A. + + :units: As specified, or assumed to be :math:`\\text{A}` otherwise. + :type: `float` or `~pint.Quantity` + """, + ) + + voltage_sense = unitful_property( + "MEAS:VOLT", + u.volt, + readonly=True, + input_decoration=str.strip, + doc=""" + Gets the measured output voltage. + + :units: :math:`\\text{V}` + :rtype: `~pint.Quantity` + """, + ) + + current_sense = unitful_property( + "MEAS:CURR", + u.amp, + readonly=True, + input_decoration=str.strip, + doc=""" + Gets the measured output current. + + :units: :math:`\\text{A}` + :rtype: `~pint.Quantity` + """, + ) + + power_sense = unitful_property( + "MEAS:POW", + u.watt, + readonly=True, + input_decoration=str.strip, + doc=""" + Gets the measured output power. + + :units: :math:`\\text{W}` + :rtype: `~pint.Quantity` + """, + ) + + @property + def output(self): + """ + Gets/sets the output state. + + :type: `bool` + """ + + return _parse_output_state(self.query("OUTP?")) + + @output.setter + def output(self, newval): + if not isinstance(newval, bool): + raise TypeError("Output state must be specified with a boolean value.") + self.sendcmd(f"OUTP {'ON' if newval else 'OFF'}") + + @property + def name(self): + """ + Gets the instrument name as reported by ``*IDN?``. + + :rtype: `str` + """ + + idn_string = self.query("*IDN?") + idn_parts = [part.strip() for part in idn_string.split(",")] + if len(idn_parts) >= 2: + return " ".join(idn_parts[:2]) + return idn_string.strip() + + @property + def mode(self): + """ + Unimplemented. + """ + + raise NotImplementedError("The DC310S does not expose a stable mode query.") + + @mode.setter + def mode(self, newval): + """ + Unimplemented. + """ + + raise NotImplementedError("The DC310S does not expose a stable mode query.") + + @property + def channel(self): + """ + Return the single output channel. + + :rtype: `tuple` + """ + + return (self,) diff --git a/tests/test_kiprim/__init__.py b/tests/test_kiprim/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/tests/test_kiprim/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/test_kiprim/test_dc310s.py b/tests/test_kiprim/test_dc310s.py new file mode 100644 index 00000000..ae318623 --- /dev/null +++ b/tests/test_kiprim/test_dc310s.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python +""" +Unit tests for the Kiprim DC310S single-output power supply. +""" + +# IMPORTS ##################################################################### + +import pytest + +import instruments as ik +from instruments.units import ureg as u +from tests import expected_protocol, unit_eq + +# TESTS ####################################################################### + + +def test_channel(): + with expected_protocol(ik.kiprim.DC310S, [], [], sep="\n") as psu: + assert psu.channel[0] == psu + assert len(psu.channel) == 1 + + +def test_name(): + with expected_protocol( + ik.kiprim.DC310S, ["*IDN?"], ["KIPRIM,DC310S,22371243,FV:V3.7.0"], sep="\n" + ) as psu: + assert psu.name == "KIPRIM DC310S" + + +def test_voltage(): + with expected_protocol( + ik.kiprim.DC310S, ["VOLT 5.000", "VOLT?"], ["5.000"], sep="\n" + ) as psu: + psu.voltage = 5 * u.volt + unit_eq(psu.voltage, 5 * u.volt) + + +def test_voltage_bounds(): + with expected_protocol(ik.kiprim.DC310S, [], [], sep="\n") as psu: + unit_eq(psu.voltage_min, 0 * u.volt) + unit_eq(psu.voltage_max, 30 * u.volt) + with pytest.raises(ValueError): + psu.voltage = 30.001 * u.volt + with pytest.raises(ValueError): + psu.voltage = -0.001 * u.volt + + +def test_current(): + with expected_protocol( + ik.kiprim.DC310S, ["CURR 0.250", "CURR?"], ["0.250"], sep="\n" + ) as psu: + psu.current = 0.25 * u.amp + unit_eq(psu.current, 0.25 * u.amp) + + +def test_current_bounds(): + with expected_protocol(ik.kiprim.DC310S, [], [], sep="\n") as psu: + unit_eq(psu.current_min, 0 * u.amp) + unit_eq(psu.current_max, 10 * u.amp) + with pytest.raises(ValueError): + psu.current = 10.001 * u.amp + with pytest.raises(ValueError): + psu.current = -0.001 * u.amp + + +def test_voltage_sense(): + with expected_protocol( + ik.kiprim.DC310S, ["MEAS:VOLT?"], ["12.340"], sep="\n" + ) as psu: + unit_eq(psu.voltage_sense, 12.34 * u.volt) + + +def test_current_sense(): + with expected_protocol( + ik.kiprim.DC310S, ["MEAS:CURR?"], ["0.456"], sep="\n" + ) as psu: + unit_eq(psu.current_sense, 0.456 * u.amp) + + +def test_power_sense(): + with expected_protocol(ik.kiprim.DC310S, ["MEAS:POW?"], ["5.624"], sep="\n") as psu: + unit_eq(psu.power_sense, 5.624 * u.watt) + + +def test_output_on(): + with expected_protocol( + ik.kiprim.DC310S, ["OUTP ON", "OUTP?"], ["ON"], sep="\n" + ) as psu: + psu.output = True + assert psu.output + + +def test_output_off_numeric_reply(): + with expected_protocol( + ik.kiprim.DC310S, ["OUTP OFF", "OUTP?"], ["0"], sep="\n" + ) as psu: + psu.output = False + assert psu.output is False + + +def test_output_invalid_reply(): + with expected_protocol(ik.kiprim.DC310S, ["OUTP?"], ["MAYBE"], sep="\n") as psu: + with pytest.raises(ValueError): + _ = psu.output + + +def test_output_setter_requires_bool(): + with expected_protocol(ik.kiprim.DC310S, [], [], sep="\n") as psu: + with pytest.raises(TypeError): + psu.output = 1 + + +def test_mode_getter_unimplemented(): + with expected_protocol(ik.kiprim.DC310S, [], [], sep="\n") as psu: + with pytest.raises(NotImplementedError): + _ = psu.mode + + +def test_mode_setter_unimplemented(): + with expected_protocol(ik.kiprim.DC310S, [], [], sep="\n") as psu: + with pytest.raises(NotImplementedError): + psu.mode = "cv" From 07c1ccbaabac1d0b3f418723d6ba8b1afc8e1e8f Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 8 Mar 2026 17:09:44 +0000 Subject: [PATCH 2/9] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/test_kiprim/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_kiprim/__init__.py b/tests/test_kiprim/__init__.py index 8b137891..e69de29b 100644 --- a/tests/test_kiprim/__init__.py +++ b/tests/test_kiprim/__init__.py @@ -1 +0,0 @@ - From 1849a33d363abe0d52355585bd5c2e5d838140cf Mon Sep 17 00:00:00 2001 From: Sergey Lukin Date: Sun, 8 Mar 2026 21:55:43 +0100 Subject: [PATCH 3/9] Add OWON SDS1104 oscilloscope driver Add support for the OWON SDS1104 oscilloscope family over raw USB. This adds a conservative upstream-facing driver with: - run/stop control - channel display, coupling, probe, scale, offset, position, and invert - acquisition mode, averages, memory depth, timebase scale, and horizontal offset - trigger status, trigger mode, edge-trigger source/coupling/slope/level - scalar measurements and measurement blob queries - screen waveform retrieval and waveform metadata - BMP screen capture - deep-memory capture - saved-waveform list and raw data access Also add the raw USB binary helpers needed by the driver, transcript-style unit tests, API docs, and a minimal example script. The initial driver keeps memory-depth support conservative for compatibility hardware that appears capped at 20K record length despite broader family docs. --- doc/examples/ex_owon_sds1104.py | 26 + doc/source/apiref/index.rst | 1 + doc/source/apiref/owon.rst | 16 + src/instruments/__init__.py | 1 + .../comm/usb_communicator.py | 94 +- src/instruments/owon/__init__.py | 10 + src/instruments/owon/sds1104.py | 1816 +++++++++++++++++ src/instruments/thorlabs/_abstract.py | 2 +- tests/test_comm/test_usb_communicator.py | 53 +- tests/test_owon/test_sds1104.py | 803 ++++++++ 10 files changed, 2814 insertions(+), 8 deletions(-) create mode 100644 doc/examples/ex_owon_sds1104.py create mode 100644 doc/source/apiref/owon.rst create mode 100644 src/instruments/owon/__init__.py create mode 100644 src/instruments/owon/sds1104.py create mode 100644 tests/test_owon/test_sds1104.py diff --git a/doc/examples/ex_owon_sds1104.py b/doc/examples/ex_owon_sds1104.py new file mode 100644 index 00000000..e064ec7c --- /dev/null +++ b/doc/examples/ex_owon_sds1104.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python +""" +Minimal example for connecting to an OWON SDS1104-family oscilloscope. +""" + +import instruments as ik + + +def main(): + """ + Open the scope over raw USB, print a few stable values, and read the + current CH1 screen waveform. + """ + scope = ik.owon.OWONSDS1104.open_usb() + + print(f"Identity: {scope.name}") + print(f"Timebase scale: {scope.timebase_scale}") + print(f"CH1 displayed: {scope.channel[0].display}") + print(f"CH1 coupling: {scope.channel[0].coupling}") + time_s, voltage_v = scope.channel[0].read_waveform() + print(f"CH1 waveform samples: {len(voltage_v)}") + print(f"First sample: t={time_s[0]!r}, v={voltage_v[0]!r}") + + +if __name__ == "__main__": + main() diff --git a/doc/source/apiref/index.rst b/doc/source/apiref/index.rst index 4ad370ae..92654c1e 100644 --- a/doc/source/apiref/index.rst +++ b/doc/source/apiref/index.rst @@ -30,6 +30,7 @@ Contents: newport ondax oxford + owon pfeiffer phasematrix picowatt diff --git a/doc/source/apiref/owon.rst b/doc/source/apiref/owon.rst new file mode 100644 index 00000000..b364b000 --- /dev/null +++ b/doc/source/apiref/owon.rst @@ -0,0 +1,16 @@ +.. + TODO: put documentation license header here. + +.. currentmodule:: instruments.owon + +==== +OWON +==== + +:class:`OWONSDS1104` Oscilloscope +================================= + +.. autoclass:: OWONSDS1104 + :members: + :undoc-members: + diff --git a/src/instruments/__init__.py b/src/instruments/__init__.py index 660326bf..499cc4ec 100644 --- a/src/instruments/__init__.py +++ b/src/instruments/__init__.py @@ -29,6 +29,7 @@ from . import minghe from . import newport from . import oxford +from . import owon from . import phasematrix from . import pfeiffer from . import picowatt diff --git a/src/instruments/abstract_instruments/comm/usb_communicator.py b/src/instruments/abstract_instruments/comm/usb_communicator.py index 05c882d4..b4cab32d 100644 --- a/src/instruments/abstract_instruments/comm/usb_communicator.py +++ b/src/instruments/abstract_instruments/comm/usb_communicator.py @@ -112,7 +112,7 @@ def timeout(self): @timeout.setter def timeout(self, newval): newval = assume_units(newval, u.second).to(u.ms).magnitude - self._dev.default_timeout = newval + self._dev.default_timeout = int(round(newval)) # FILE-LIKE METHODS # @@ -136,7 +136,7 @@ def read_raw(self, size=-1): if size == -1: size = self._max_packet_size term = self._terminator.encode("utf-8") - read_val = bytes(self._ep_in.read(size)) + read_val = self.read_packet(size) if term not in read_val: raise OSError( f"Did not find the terminator in the returned string. " @@ -144,6 +144,75 @@ def read_raw(self, size=-1): ) return read_val.rstrip(term) + def read_packet(self, size=-1): + """ + Read a single raw USB packet without interpreting terminators. + + :param int size: Number of bytes requested from the USB endpoint. + A value of ``-1`` reads one full endpoint packet. + :rtype: `bytes` + """ + if size == -1: + size = self._max_packet_size + return bytes(self._ep_in.read(size)) + + def read_exact(self, size, chunk_size=None): + """ + Read exactly ``size`` raw bytes from the USB endpoint. + + :param int size: Total number of bytes to read. + :param int chunk_size: Optional packet request size to use for each + underlying endpoint read. + :rtype: `bytes` + """ + if size < 0: + raise ValueError("Size must be non-negative.") + if chunk_size is None: + chunk_size = self._max_packet_size + if chunk_size <= 0: + raise ValueError("Chunk size must be positive.") + + result = bytearray() + while len(result) < size: + packet = self.read_packet(min(chunk_size, size - len(result))) + result.extend(packet) + return bytes(result) + + def read_binary(self, size=-1): + """ + Read raw binary data without looking for a terminator. + + If ``size`` is negative, this reads packets until a short packet or a + USB timeout indicates the transfer has completed. If ``size`` is + non-negative, this reads exactly that many bytes. + + :param int size: Number of bytes to read, or ``-1`` to read until the + transfer completes. + :rtype: `bytes` + """ + if size >= 0: + return self.read_exact(size) + + result = bytearray() + while True: + try: + packet = self.read_packet() + except usb.core.USBTimeoutError: + if result: + break + raise + except usb.core.USBError as exc: + if result and "timeout" in str(exc).lower(): + break + raise + + if not packet: + break + result.extend(packet) + if len(packet) < self._max_packet_size: + break + return bytes(result) + def write_raw(self, msg): """Write bytes to the raw usb connection object. @@ -152,10 +221,10 @@ def write_raw(self, msg): """ self._ep_out.write(msg) - def seek(self, offset): # pylint: disable=unused-argument,no-self-use + def seek(self, offset): # pylint: disable=unused-argument raise NotImplementedError - def tell(self): # pylint: disable=no-self-use + def tell(self): raise NotImplementedError def flush_input(self): @@ -163,7 +232,22 @@ def flush_input(self): Instruct the communicator to flush the input buffer, discarding the entirety of its contents. """ - self._ep_in.read(self._max_packet_size) + original_timeout = self._dev.default_timeout + self._dev.default_timeout = 50 + try: + while True: + try: + packet = self.read_packet() + except usb.core.USBTimeoutError: + break + except usb.core.USBError as exc: + if "timeout" in str(exc).lower(): + break + raise + if not packet: + break + finally: + self._dev.default_timeout = original_timeout # METHODS # diff --git a/src/instruments/owon/__init__.py b/src/instruments/owon/__init__.py new file mode 100644 index 00000000..cc18e775 --- /dev/null +++ b/src/instruments/owon/__init__.py @@ -0,0 +1,10 @@ +#!/usr/bin/env python +""" +Module containing OWON instruments. +""" + +from .sds1104 import ( + OWONSDS1104, + SDS1104DeepMemoryCapture, + SDS1104SavedWaveformEntry, +) diff --git a/src/instruments/owon/sds1104.py b/src/instruments/owon/sds1104.py new file mode 100644 index 00000000..3594c168 --- /dev/null +++ b/src/instruments/owon/sds1104.py @@ -0,0 +1,1816 @@ +#!/usr/bin/env python +""" +Provides support for the OWON SDS1104 oscilloscope family. +""" + +# pylint: disable=too-many-lines + +# IMPORTS ##################################################################### + + +from dataclasses import dataclass +from enum import Enum +import json +import re +import struct +from typing import Any + +import usb.core + +from instruments.abstract_instruments import Oscilloscope +from instruments.abstract_instruments.comm import USBCommunicator +from instruments.generic_scpi import SCPIInstrument +from instruments.optional_dep_finder import numpy +from instruments.units import ureg as u +from instruments.util_fns import ProxyList, assume_units + +# HELPERS ##################################################################### + + +_TIME_UNITS = { + "ns": 1e-9, + "us": 1e-6, + "ms": 1e-3, + "s": 1.0, +} + +_VERTICAL_UNITS = { + "mv": 1e-3, + "v": 1.0, + "kv": 1e3, +} + +_MEASUREMENT_UNITS = { + "uv": 1e-6, + "mv": 1e-3, + "v": 1.0, + "kv": 1e3, + "uvs": 1e-6, + "mvs": 1e-3, + "vs": 1.0, + "ns": 1e-9, + "us": 1e-6, + "ms": 1e-3, + "s": 1.0, + "hz": 1.0, + "khz": 1e3, + "mhz": 1e6, + "ghz": 1e9, + "%": 1.0, +} + +_TIMEBASE_TOKENS = { + 1.0e-9: "1.0ns", + 2.0e-9: "2.0ns", + 5.0e-9: "5.0ns", + 10e-9: "10ns", + 20e-9: "20ns", + 50e-9: "50ns", + 100e-9: "100ns", + 200e-9: "200ns", + 500e-9: "500ns", + 1e-6: "1us", + 2e-6: "2us", + 5e-6: "5us", + 10e-6: "10us", + 20e-6: "20us", + 50e-6: "50us", + 100e-6: "100us", + 200e-6: "200us", + 500e-6: "500us", + 1e-3: "1ms", + 2e-3: "2ms", + 5e-3: "5ms", + 10e-3: "10ms", + 20e-3: "20ms", + 50e-3: "50ms", + 100e-3: "100ms", + 200e-3: "200ms", + 500e-3: "500ms", + 1.0: "1s", + 2.0: "2s", + 5.0: "5s", + 10.0: "10s", + 20.0: "20s", + 50.0: "50s", + 100.0: "100s", +} + +_VERTICAL_SCALE_TOKENS = { + 2e-3: "2mv", + 5e-3: "5mv", + 10e-3: "10mv", + 20e-3: "20mv", + 50e-3: "50mv", + 100e-3: "100mv", + 200e-3: "200mv", + 500e-3: "500mv", + 1.0: "1v", + 2.0: "2v", + 5.0: "5v", + 10.0: "10v", +} + +_MEMORY_DEPTH_TOKENS = { + 1_000: "1K", + 5_000: "5K", + 10_000: "10K", + 100_000: "100K", + 1_000_000: "1M", + 10_000_000: "10M", +} + +_MEASUREMENT_VALUE_RE = re.compile( + r"(?P[-+]?\d+(?:\.\d*)?(?:[eE][-+]?\d+)?)\s*(?P[A-Za-z%*]+)?\s*$" +) +_MEASUREMENT_KV_RE = re.compile(r'"(?P[A-Za-z0-9]+)"\s*:\s*"(?P[^"\r\n]*)"') +_MEASUREMENT_CHANNEL_BLOCK_RE = re.compile( + r'"CH(?P\d+)"\s*:\s*\{(?P.*?)\}(?=,\s*"CH\d+"\s*:|\s*\}\s*$)', + re.DOTALL, +) + + +def _clean_reply(reply): + """ + Normalizes a DOS1104 text reply. + """ + text = reply.strip() + if text.endswith("->"): + text = text[:-2].rstrip() + return text + + +def _strip_packet_prefix(payload, field_name): + """ + Strips the four-byte SDS1104 binary packet prefix. + """ + if len(payload) < 4: + raise ValueError(f"{field_name} payload is too short.") + return payload[4:] + + +def _parse_bool(reply, field_name): + """ + Parses a boolean-like reply. + """ + cleaned = _clean_reply(reply).upper() + if cleaned in {"ON", "1"}: + return True + if cleaned in {"OFF", "0"}: + return False + raise ValueError(f"Invalid {field_name} reply: {reply!r}") + + +def _parse_float(reply, field_name): + """ + Parses a float reply. + """ + cleaned = _clean_reply(reply) + try: + return float(cleaned) + except ValueError as exc: + raise ValueError(f"Invalid {field_name} reply: {reply!r}") from exc + + +def _parse_quantity_token(token, units_map, field_name): + """ + Parses a quantity token like ``100mV`` or ``1ms``. + """ + cleaned = _clean_reply(token).strip().lower() + for suffix, scale in units_map.items(): + if cleaned.endswith(suffix): + magnitude = cleaned[: -len(suffix)] + try: + return float(magnitude) * scale + except ValueError as exc: + raise ValueError(f"Invalid {field_name} reply: {token!r}") from exc + raise ValueError(f"Invalid {field_name} reply: {token!r}") + + +def _parse_timebase_token(token): + """ + Parses a timebase token to seconds per division. + """ + return _parse_quantity_token(token, _TIME_UNITS, "timebase") + + +def _parse_vertical_scale_token(token): + """ + Parses a vertical scale token to volts per division. + """ + return _parse_quantity_token(token, _VERTICAL_UNITS, "vertical scale") + + +def _parse_probe_token(token): + """ + Parses a probe token such as ``10X`` or ``X10``. + """ + cleaned = _clean_reply(token).upper() + if cleaned.startswith("X"): + cleaned = cleaned[1:] + elif cleaned.endswith("X"): + cleaned = cleaned[:-1] + try: + value = int(cleaned) + except ValueError as exc: + raise ValueError(f"Invalid probe reply: {token!r}") from exc + if value not in {1, 10, 100, 1000}: + raise ValueError(f"Invalid probe reply: {token!r}") + return value + + +def _format_probe_token(value): + """ + Formats a probe attenuation token. + """ + if isinstance(value, str): + value = _parse_probe_token(value) + if value not in {1, 10, 100, 1000}: + raise ValueError("Probe attenuation must be one of 1, 10, 100, or 1000.") + return f"{value}X" + + +def _parse_memory_depth_token(token): + """ + Parses a memory depth token. + """ + cleaned = _clean_reply(token).upper() + if cleaned.endswith("K"): + return int(float(cleaned[:-1]) * 1000) + if cleaned.endswith("M"): + return int(float(cleaned[:-1]) * 1_000_000) + return int(cleaned) + + +def _parse_measurement_token(token, field_name): + """ + Parses a scalar measurement token. + """ + cleaned = _clean_reply(token).strip() + if not cleaned or cleaned == "?": + raise ValueError(f"Invalid {field_name} reply: {token!r}") + + match = _MEASUREMENT_VALUE_RE.search(cleaned) + if match is None: + raise ValueError(f"Invalid {field_name} reply: {token!r}") + + value = float(match.group("value")) + unit = (match.group("unit") or "").replace("*", "").lower() + if not unit: + return value + if unit not in _MEASUREMENT_UNITS: + raise ValueError(f"Invalid {field_name} reply: {token!r}") + return value * _MEASUREMENT_UNITS[unit] + + +def _parse_measurement_count(token, field_name): + """ + Parses a count-like measurement reply. + """ + return int(round(_parse_measurement_token(token, field_name))) + + +def _parse_json_payload(payload, field_name): + """ + Parses an SDS1104 binary JSON payload. + """ + text = _strip_packet_prefix(payload, field_name).decode("utf-8", errors="replace") + try: + parsed = json.loads(text.strip()) + except json.JSONDecodeError as exc: + raise ValueError(f"Invalid {field_name} payload: {text!r}") from exc + if not isinstance(parsed, dict): + raise ValueError(f"Invalid {field_name} payload: {text!r}") + return parsed + + +def _sanitize_json_text(text): + """ + Replaces control characters with spaces before JSON parsing. + """ + return "".join(character if ord(character) >= 0x20 else " " for character in text) + + +def _parse_json_array_payload(payload, field_name): + """ + Parses a length-prefixed JSON array payload. + """ + text = _strip_packet_prefix(payload, field_name).decode("utf-8", errors="replace") + text = _sanitize_json_text(text).strip() + if text == "[?]": + return [] + text = re.sub(r",\s*}", "}", text) + text = re.sub(r",\s*]", "]", text) + try: + parsed = json.loads(text) + except json.JSONDecodeError as exc: + raise ValueError(f"Invalid {field_name} payload: {text!r}") from exc + if not isinstance(parsed, list): + raise ValueError(f"Invalid {field_name} payload: {text!r}") + for item in parsed: + if not isinstance(item, dict): + raise ValueError(f"Invalid {field_name} entry: {item!r}") + return parsed + + +def _parse_measurement_payload(payload, channel): + """ + Parses a single-channel measurement blob payload. + """ + text = _strip_packet_prefix(payload, "measurement data").decode( + "utf-8", errors="replace" + ) + text = _sanitize_json_text(text).strip() + try: + parsed = json.loads(text) + except json.JSONDecodeError as exc: + pairs = { + match.group("key"): match.group("value") + for match in _MEASUREMENT_KV_RE.finditer(text) + if match.group("key") != f"CH{channel}" + } + if not pairs: + raise ValueError( + f"Invalid CH{channel} measurement data payload: {text!r}" + ) from exc + return pairs + + nested = parsed.get(f"CH{channel}") + if isinstance(nested, dict): + return { + str(key): "" if value is None else str(value) + for key, value in nested.items() + } + if not isinstance(parsed, dict): + raise ValueError(f"Invalid CH{channel} measurement data payload: {text!r}") + return { + str(key): "" if value is None else str(value) for key, value in parsed.items() + } + + +def _parse_measurement_map_payload(payload): + """ + Parses an all-channel measurement blob payload. + """ + text = _strip_packet_prefix(payload, "all-channel measurement data").decode( + "utf-8", errors="replace" + ) + text = _sanitize_json_text(text).strip() + try: + parsed = json.loads(text) + except json.JSONDecodeError as exc: + channel_map = {} + for match in _MEASUREMENT_CHANNEL_BLOCK_RE.finditer(text): + channel = int(match.group("channel")) + channel_map[channel] = { + kv_match.group("key"): kv_match.group("value") + for kv_match in _MEASUREMENT_KV_RE.finditer(match.group("body")) + } + if not channel_map: + raise ValueError( + f"Invalid all-channel measurement payload: {text!r}" + ) from exc + return channel_map + + channel_map = {} + if not isinstance(parsed, dict): + raise ValueError(f"Invalid all-channel measurement payload: {text!r}") + for key, value in parsed.items(): + if ( + not isinstance(key, str) + or not key.startswith("CH") + or not isinstance(value, dict) + ): + continue + try: + channel = int(key[2:]) + except ValueError: + continue + channel_map[channel] = { + str(item_key): "" if item_value is None else str(item_value) + for item_key, item_value in value.items() + } + if not channel_map: + raise ValueError(f"Invalid all-channel measurement payload: {text!r}") + return channel_map + + +def _parse_sample_rate(token): + """ + Parses a sample rate token such as ``1MS/s``. + """ + cleaned = _clean_reply(token).strip().lower() + units = { + "ks/s": 1e3, + "ms/s": 1e6, + "gs/s": 1e9, + } + for suffix, scale in units.items(): + if cleaned.endswith(suffix): + magnitude = cleaned[: -len(suffix)] + try: + return float(magnitude) * scale + except ValueError as exc: + raise ValueError(f"Invalid sample rate reply: {token!r}") from exc + raise ValueError(f"Invalid sample rate reply: {token!r}") + + +def _parse_waveform_adc(raw_bytes, field_name): + """ + Parses little-endian signed 16-bit ADC samples. + """ + if len(raw_bytes) % 2 != 0: + raise ValueError( + f"{field_name} payload length is not 16-bit aligned: {len(raw_bytes)}" + ) + if numpy is not None: + return numpy.frombuffer(raw_bytes, dtype="`` prompt. + """ + + def __init__(self, dev): + super().__init__(dev) + self._terminator = "" + + def _sendcmd(self, msg): + self.write(msg, encoding="ascii") + + def _query(self, msg, size=-1): + self._sendcmd(msg) + return self.read_binary(size).decode("utf-8") + + +@dataclass(frozen=True) +class SDS1104DeepMemoryCapture: + """ + Parsed deep-memory bundle returned by ``:DATA:WAVE:DEPMem:All?``. + """ + + metadata: dict[str, Any] + raw_channels: dict[int, Any] + + +@dataclass(frozen=True) +class SDS1104SavedWaveformEntry: + """ + Saved-waveform index entry returned by ``:SAVE:READ:HEAD?``. + """ + + index: str + raw: dict[str, Any] + + +class OWONSDS1104( + SCPIInstrument, Oscilloscope +): # pylint: disable=too-many-public-methods + """ + Conservative driver for the OWON SDS1104 oscilloscope family. + + This driver targets the text-based raw-USB control surface shared by the + OWON SDS1104 and compatible HANMATEK DOS1104 units. The public API covers + stable control, scalar measurements, measurement blobs, screen-waveform + retrieval, BMP capture, deep-memory capture, and saved-waveform access. + + Example usage: + + >>> import instruments as ik + >>> scope = ik.owon.OWONSDS1104.open_usb() + >>> scope.name + 'OWON,SDS1104,...' + >>> scope.channel[0].display + True + """ + + DEFAULT_USB_VID = 0x5345 + DEFAULT_USB_PID = 0x1234 + + class AcquisitionMode(Enum): + """ + Acquisition modes supported by the SDS1104 family. + """ + + # pylint: disable=invalid-name + + sample = "SAMPle" + average = "AVERage" + peak_detect = "PEAK" + + class Coupling(Enum): + """ + Input coupling modes for SDS1104 channels. + """ + + # pylint: disable=invalid-name + + ac = "AC" + dc = "DC" + ground = "GND" + + class TriggerStatus(Enum): + """ + Acquisition / trigger status values reported by ``:TRIGger:STATUS?``. + """ + + # pylint: disable=invalid-name + + auto = "AUTO" + ready = "READY" + trig = "TRIG" + scan = "SCAN" + stop = "STOP" + + class TriggerMode(Enum): + """ + General trigger modes supported by the verified SDS1104 API surface. + """ + + # pylint: disable=invalid-name + + edge = "EDGE" + video = "VIDEO" + + class TriggerSource(Enum): + """ + Edge-trigger sources. + """ + + # pylint: disable=invalid-name + + ch1 = "CH1" + ch2 = "CH2" + ch3 = "CH3" + ch4 = "CH4" + + class TriggerCoupling(Enum): + """ + Edge-trigger coupling modes verified on hardware. + """ + + # pylint: disable=invalid-name + + ac = "AC" + dc = "DC" + + class TriggerSlope(Enum): + """ + Edge-trigger slope modes verified on hardware. + """ + + # pylint: disable=invalid-name + + rise = "RISE" + fall = "FALL" + + class DataSource(Oscilloscope.DataSource): + """ + Represents a non-waveform SDS1104 data source. + + Only physical channels support waveform transfer in the initial + driver. + """ + + @property + def name(self): + return self._name + + def read_waveform(self, bin_format=True): # pylint: disable=unused-argument + raise NotImplementedError( + "Waveform transfer is only supported for physical SDS1104 channels." + ) + + class Channel(DataSource, Oscilloscope.Channel): + """ + Class representing a physical channel on the SDS1104. + """ + + def __init__(self, parent, idx): + self._parent = parent + self._idx = idx + 1 + super().__init__(parent, f"CH{self._idx}") + + def sendcmd(self, cmd): + """ + Sends a channel-scoped command. + """ + self._parent.sendcmd(f":CH{self._idx}:{cmd}") + + def query(self, cmd): + """ + Queries a channel-scoped command. + """ + return self._parent.query(f":CH{self._idx}:{cmd}") + + @property + def display(self): + """ + Gets/sets whether the channel is displayed. + + :type: `bool` + """ + return _parse_bool(self.query("DISP?"), "channel display state") + + @display.setter + def display(self, newval): + if not isinstance(newval, bool): + raise TypeError("Display state must be specified with a boolean value.") + self.sendcmd(f"DISP {'ON' if newval else 'OFF'}") + + @property + def coupling(self): + """ + Gets/sets the channel coupling mode. + + :type: `OWONSDS1104.Coupling` + """ + return OWONSDS1104.Coupling(_clean_reply(self.query("COUP?")).upper()) + + @coupling.setter + def coupling(self, newval): + if not isinstance(newval, OWONSDS1104.Coupling): + raise TypeError( + "Coupling setting must be a `OWONSDS1104.Coupling` value." + ) + self.sendcmd(f"COUP {newval.value}") + + @property + def probe_attenuation(self): + """ + Gets/sets the configured probe attenuation. + + :type: `int` + """ + return _parse_probe_token(self.query("PROB?")) + + @probe_attenuation.setter + def probe_attenuation(self, newval): + self.sendcmd(f"PROB {_format_probe_token(newval)}") + + @property + def scale(self): + """ + Gets/sets the vertical scale in volts per division. + + :type: `~pint.Quantity` + """ + return u.Quantity(_parse_vertical_scale_token(self.query("SCAL?")), u.volt) + + @scale.setter + def scale(self, newval): + token = _format_discrete_quantity( + newval, u.volt, _VERTICAL_SCALE_TOKENS, "vertical scale" + ) + self.sendcmd(f"SCAL {token}") + + @property + def offset(self): + """ + Gets/sets the vertical offset in volts. + + :type: `~pint.Quantity` + """ + return u.Quantity(_parse_float(self.query("OFFS?"), "offset"), u.volt) + + @offset.setter + def offset(self, newval): + newval = assume_units(newval, u.volt).to(u.volt) + self.sendcmd(f"OFFS {newval.magnitude}") + + @property + def position(self): + """ + Gets/sets the vertical channel position in divisions. + + :type: `float` + """ + return _parse_float(self.query("POS?"), "position") + + @position.setter + def position(self, newval): + self.sendcmd(f"POS {float(newval)}") + + @property + def invert(self): + """ + Gets/sets whether the channel waveform is inverted. + + :type: `bool` + """ + return _parse_bool(self.query("INVErse?"), "channel invert state") + + @invert.setter + def invert(self, newval): + if not isinstance(newval, bool): + raise TypeError("Invert state must be specified with a boolean value.") + self.sendcmd(f"INVErse {'ON' if newval else 'OFF'}") + + def measure_frequency(self): + """ + Measures the channel frequency. + + :rtype: `~pint.Quantity` + """ + return self._parent.measure_frequency(self._idx) + + def measure_period(self): + """ + Measures the channel period. + + :rtype: `~pint.Quantity` + """ + return self._parent.measure_period(self._idx) + + def measure_peak_to_peak(self): + """ + Measures the channel peak-to-peak voltage. + + :rtype: `~pint.Quantity` + """ + return self._parent.measure_peak_to_peak(self._idx) + + def measure_rms(self): + """ + Measures the channel cycle RMS voltage. + + :rtype: `~pint.Quantity` + """ + return self._parent.measure_rms(self._idx) + + def measure_average(self): + """ + Measures the channel average voltage. + """ + return self._parent.measure_average(self._idx) + + def measure_maximum(self): + """ + Measures the channel maximum voltage. + """ + return self._parent.measure_maximum(self._idx) + + def measure_minimum(self): + """ + Measures the channel minimum voltage. + """ + return self._parent.measure_minimum(self._idx) + + def read_measurement_data(self, long_form=False): + """ + Reads the measurement blob for this channel. + """ + return self._parent.read_measurement_data(self._idx, long_form=long_form) + + def read_waveform(self, bin_format=True): + """ + Reads the current screen waveform for this channel. + """ + if not bin_format: + raise NotImplementedError( + "The OWON SDS1104 driver currently supports binary " + "waveform transfer only." + ) + return self._parent.read_waveform(self._idx) + + def read_deep_memory(self): + """ + Reads the deep-memory waveform for this channel. + """ + return self._parent.read_deep_memory_channel(self._idx) + + def __init__(self, filelike): + super().__init__(filelike) + self._file.timeout = 1 * u.second + + @classmethod + def open_usb(cls, vid=DEFAULT_USB_VID, pid=DEFAULT_USB_PID, timeout=1 * u.second): + """ + Opens an SDS1104-family scope using the default raw USB VID/PID. + + A best-effort OWON-family SCPI enable handshake is attempted after the + communicator is opened. + """ + dev = usb.core.find(idVendor=vid, idProduct=pid) + if dev is None: + raise OSError("No such device found.") + + inst = cls(_OWONPromptUSBCommunicator(dev)) + inst.timeout = assume_units(timeout, u.second) + inst._enable_scpi_mode() + return inst + + def _enable_scpi_mode(self): + """ + Best-effort OWON-family SCPI enable handshake. + """ + try: + self._file.write_raw(b":SDSLSCPI#") + return _clean_reply(self._file.read()) == ":SCPION" + except OSError: + return False + + def _binary_query(self, command): + """ + Sends a raw USB command and reads a binary reply. + """ + self._file.write_raw(command.encode("ascii")) + if not hasattr(self._file, "read_binary"): + raise NotImplementedError( + "Binary waveform support requires a communicator that " + "implements read_binary()." + ) + return self._file.read_binary() + + def _binary_query_exact(self, command, size): + """ + Sends a raw USB command and reads an exact-size binary reply. + """ + self._file.write_raw(command.encode("ascii")) + if not hasattr(self._file, "read_exact"): + raise NotImplementedError( + "Binary waveform support requires a communicator that " + "implements read_exact()." + ) + return self._file.read_exact(size) + + def _query_length_prefixed_binary(self, command, max_body_size=20_000_000): + """ + Sends a raw USB command and reads a little-endian length-prefixed body. + """ + self._file.write_raw(command.encode("ascii")) + if not hasattr(self._file, "read_exact"): + raise NotImplementedError( + "Length-prefixed binary support requires a communicator that " + "implements read_exact()." + ) + + header = self._file.read_exact(4) + if len(header) < 4: + raise ValueError(f"Length-prefixed reply too short for {command!r}.") + + body_size = int.from_bytes(header, byteorder="little", signed=False) + if body_size <= 0: + raise ValueError( + f"Invalid length-prefixed body size for {command!r}: {body_size}" + ) + if body_size > max_body_size: + raise ValueError( + f"Length-prefixed body for {command!r} exceeds safety limit: " + f"{body_size}" + ) + return header + self._file.read_exact(body_size) + + def _waveform_metadata(self): + """ + Reads the screen-waveform metadata block. + """ + return _parse_json_payload( + self._binary_query(":DATA:WAVE:SCREen:HEAD?"), "waveform metadata" + ) + + def read_waveform_metadata(self): + """ + Reads the screen-waveform metadata JSON. + + :rtype: `dict` + """ + return self._waveform_metadata() + + def _extract_channel_metadata(self, metadata, channel): + channels = metadata.get("CHANNEL") + if not isinstance(channels, list) or channel - 1 >= len(channels): + raise ValueError( + f"Metadata does not contain channel {channel}: {metadata!r}" + ) + channel_metadata = channels[channel - 1] + if not isinstance(channel_metadata, dict): + raise ValueError( + f"Invalid channel metadata for CH{channel}: {channel_metadata!r}" + ) + return channel_metadata + + def _sample_rate_hz(self, metadata): + sample = metadata.get("SAMPLE") + if not isinstance(sample, dict): + raise ValueError(f"Metadata missing SAMPLE block: {metadata!r}") + return _parse_sample_rate(str(sample["SAMPLERATE"])) + + def _waveform_point_count(self, metadata): + sample = metadata.get("SAMPLE") + if not isinstance(sample, dict): + raise ValueError(f"Metadata missing SAMPLE block: {metadata!r}") + return int(sample["DATALEN"]) + + def _horizontal_offset_pixels(self, metadata): + timebase = metadata.get("TIMEBASE") + if not isinstance(timebase, dict): + raise ValueError(f"Metadata missing TIMEBASE block: {metadata!r}") + return int(timebase["HOFFSET"]) + + def _vertical_scale_v_div(self, metadata, channel): + channel_metadata = self._extract_channel_metadata(metadata, channel) + return _parse_vertical_scale_token(str(channel_metadata["SCALE"])) + + def _vertical_offset_pixels(self, metadata, channel): + channel_metadata = self._extract_channel_metadata(metadata, channel) + return int(channel_metadata["OFFSET"]) + + def _probe_attenuation(self, metadata, channel): + channel_metadata = self._extract_channel_metadata(metadata, channel) + return _parse_probe_token(str(channel_metadata["PROBE"])) + + def _waveform_time_axis(self, metadata, point_count): + sample_rate = self._sample_rate_hz(metadata) + sample_time = 5.0 / sample_rate + horizontal_offset = self._horizontal_offset_pixels(metadata) + time_offset = -1.0 * horizontal_offset * 2.0 * sample_time + if numpy is not None: + indices = numpy.arange(point_count, dtype=float) + return (indices - point_count / 2.0) * sample_time - time_offset + return tuple( + (index - point_count / 2.0) * sample_time - time_offset + for index in range(point_count) + ) + + def _waveform_voltage_axis(self, metadata, channel, raw_adc): + vertical_offset = self._vertical_offset_pixels(metadata, channel) + volts_per_div = self._vertical_scale_v_div(metadata, channel) + probe = self._probe_attenuation(metadata, channel) + if numpy is not None and isinstance(raw_adc, numpy.ndarray): + return ( + volts_per_div + * probe + * (raw_adc.astype(float) - vertical_offset * 8.25) + / 410.0 + ) + return tuple( + volts_per_div * probe * (sample - vertical_offset * 8.25) / 410.0 + for sample in raw_adc + ) + + @property + def name(self): + """ + The cleaned instrument identity string reported by ``*IDN?``. + """ + return _clean_reply(super().name) + + @property + def channel(self): + """ + Gets the SDS1104 channel proxy list. + """ + return ProxyList(self, self.Channel, range(4)) + + @property + def ref(self): + """ + Gets reference data-source objects. + """ + return ProxyList( + self, lambda scope, idx: self.DataSource(scope, f"REF{idx + 1}"), range(4) + ) + + @property + def math(self): + """ + Gets the math data-source object. + """ + return self.DataSource(self, "MATH") + + @property + def acquire_mode(self): + """ + Gets/sets the acquisition mode. + + :type: `OWONSDS1104.AcquisitionMode` + """ + reply = _clean_reply(self.query(":ACQUire:Mode?")).upper() + if reply.startswith("SAMP"): + return self.AcquisitionMode.sample + if reply.startswith("AVER"): + return self.AcquisitionMode.average + if reply.startswith("PEAK"): + return self.AcquisitionMode.peak_detect + raise ValueError(f"Invalid acquisition mode reply: {reply!r}") + + @acquire_mode.setter + def acquire_mode(self, newval): + if not isinstance(newval, self.AcquisitionMode): + raise TypeError( + 'Acquisition mode must be one of "SAMPle", "AVERage", or "PEAK".' + ) + self.sendcmd(f":ACQUire:Mode {newval.value}") + + @property + def acquire_averages(self): + """ + Gets/sets the acquisition average count. + + :type: `int` + """ + return int(_clean_reply(self.query(":ACQUire:average:num?"))) + + @acquire_averages.setter + def acquire_averages(self, newval): + if newval not in {4, 16, 64, 128}: + raise ValueError( + "Average count not supported by instrument; must be one of " + "{4, 16, 64, 128}." + ) + self.sendcmd(f":ACQUire:average:num {int(newval)}") + + @property + def memory_depth(self): + """ + Gets/sets the acquisition memory depth. + + :type: `int` + """ + return _parse_memory_depth_token(self.query(":ACQUIRE:DEPMEM?")) + + @memory_depth.setter + def memory_depth(self, newval): + if newval not in _MEMORY_DEPTH_TOKENS: + raise ValueError( + "Memory depth must be one of 1K, 5K, 10K, 100K, 1M, or 10M. " + "20M and 40M are documented, but are not yet verified in this driver." + ) + self.sendcmd(f":ACQUIRE:DEPMEM {_MEMORY_DEPTH_TOKENS[int(newval)]}") + + @property + def timebase_scale(self): + """ + Gets/sets the horizontal scale in seconds per division. + + :type: `~pint.Quantity` + """ + seconds = _parse_timebase_token(self.query(":HORIzontal:Scale?")) + return u.Quantity(seconds, u.second) + + @timebase_scale.setter + def timebase_scale(self, newval): + token = _format_discrete_quantity( + newval, u.second, _TIMEBASE_TOKENS, "timebase scale" + ) + self.sendcmd(f":HORIzontal:Scale {token}") + + @property + def trigger_status(self): + """ + Gets the current trigger / acquisition status. + + :type: `OWONSDS1104.TriggerStatus` + """ + reply = _clean_reply(self.query(":TRIGger:STATUS?")).upper() + try: + return self.TriggerStatus(reply) + except ValueError as exc: + raise ValueError(f"Invalid trigger status reply: {reply!r}") from exc + + @property + def trigger_mode(self): + """ + Gets/sets the current trigger mode. + + :type: `OWONSDS1104.TriggerMode` + """ + reply = _clean_reply(self.query(":TRIGger:SINGle:MODE?")).upper() + try: + return self.TriggerMode(reply) + except ValueError as exc: + raise ValueError(f"Invalid trigger mode reply: {reply!r}") from exc + + @trigger_mode.setter + def trigger_mode(self, newval): + if not isinstance(newval, self.TriggerMode): + raise TypeError( + "Trigger mode must be specified with a " + "`OWONSDS1104.TriggerMode` value." + ) + self.sendcmd(f":TRIGger:SINGle:MODE {newval.value}") + + def _require_edge_trigger_mode(self): + mode = self.trigger_mode + if mode != self.TriggerMode.edge: + raise NotImplementedError( + "Trigger source, coupling, slope, and level are only exposed " + "for EDGE trigger mode in this driver." + ) + + @property + def trigger_source(self): + """ + Gets/sets the edge-trigger source. + + This property is only available when ``trigger_mode`` is ``EDGE``. + + :type: `OWONSDS1104.TriggerSource` + """ + self._require_edge_trigger_mode() + reply = _clean_reply(self.query(":TRIGger:SINGle:EDGE:SOURce?")).upper() + try: + return self.TriggerSource(reply) + except ValueError as exc: + raise ValueError(f"Invalid trigger source reply: {reply!r}") from exc + + @trigger_source.setter + def trigger_source(self, newval): + self._require_edge_trigger_mode() + if not isinstance(newval, self.TriggerSource): + raise TypeError( + "Trigger source must be specified with a " + "`OWONSDS1104.TriggerSource` value." + ) + self.sendcmd(f":TRIGger:SINGle:EDGE:SOURce {newval.value}") + + @property + def trigger_coupling(self): + """ + Gets/sets the edge-trigger coupling. + + This property is only available when ``trigger_mode`` is ``EDGE``. + + :type: `OWONSDS1104.TriggerCoupling` + """ + self._require_edge_trigger_mode() + reply = _clean_reply(self.query(":TRIGger:SINGle:EDGE:COUPling?")).upper() + try: + return self.TriggerCoupling(reply) + except ValueError as exc: + raise ValueError(f"Invalid trigger coupling reply: {reply!r}") from exc + + @trigger_coupling.setter + def trigger_coupling(self, newval): + self._require_edge_trigger_mode() + if not isinstance(newval, self.TriggerCoupling): + raise TypeError( + "Trigger coupling must be specified with a " + "`OWONSDS1104.TriggerCoupling` value." + ) + self.sendcmd(f":TRIGger:SINGle:EDGE:COUPling {newval.value}") + + @property + def trigger_slope(self): + """ + Gets/sets the edge-trigger slope. + + This property is only available when ``trigger_mode`` is ``EDGE``. + + :type: `OWONSDS1104.TriggerSlope` + """ + self._require_edge_trigger_mode() + reply = _clean_reply(self.query(":TRIGger:SINGle:EDGE:SLOPe?")).upper() + try: + return self.TriggerSlope(reply) + except ValueError as exc: + raise ValueError(f"Invalid trigger slope reply: {reply!r}") from exc + + @trigger_slope.setter + def trigger_slope(self, newval): + self._require_edge_trigger_mode() + if not isinstance(newval, self.TriggerSlope): + raise TypeError( + "Trigger slope must be specified with a " + "`OWONSDS1104.TriggerSlope` value." + ) + self.sendcmd(f":TRIGger:SINGle:EDGE:SLOPe {newval.value}") + + @property + def trigger_level(self): + """ + Gets/sets the edge-trigger level. + + This property is only available when ``trigger_mode`` is ``EDGE``. + + :type: `~pint.Quantity` + """ + self._require_edge_trigger_mode() + value = _parse_measurement_token( + self.query(":TRIGger:SINGle:EDGE:LEVel?"), "trigger level" + ) + return u.Quantity(value, u.volt) + + @trigger_level.setter + def trigger_level(self, newval): + self._require_edge_trigger_mode() + newval = assume_units(newval, u.volt).to(u.volt) + self.sendcmd(f":TRIGger:SINGle:EDGE:LEVel {newval.magnitude}V") + + @property + def horizontal_offset(self): + """ + Gets/sets the horizontal offset in the instrument's division units. + + :type: `float` + """ + return _parse_float(self.query(":HORIzontal:OFFSET?"), "horizontal offset") + + @horizontal_offset.setter + def horizontal_offset(self, newval): + self.sendcmd(f":HORIzontal:OFFSET {float(newval)}") + + @property + def measurement_display_enabled(self): + """ + Gets/sets whether the on-screen measurement table is displayed. + + :type: `bool` + """ + return _parse_bool( + self.query(":MEASUrement:DISPlay?"), "measurement display state" + ) + + @measurement_display_enabled.setter + def measurement_display_enabled(self, newval): + if not isinstance(newval, bool): + raise TypeError( + "Measurement display state must be specified with a boolean value." + ) + self.sendcmd(f":MEASUrement:DISPlay {'ON' if newval else 'OFF'}") + + def run(self): + """ + Starts acquisition. + """ + self.sendcmd(":RUN") + + def stop(self): + """ + Stops acquisition. + """ + self.sendcmd(":STOP") + + def autoscale(self): + """ + Executes the scope autoscale action. + + This is an action-like command, not a persistent boolean setting. It + may reconfigure acquisition, timebase, and channel settings. + """ + self.sendcmd(":AUTOscale ON") + + def read_waveform(self, channel): + """ + Reads the current screen waveform for a channel. + + :param int channel: One-based channel number from 1 to 4. + :rtype: `tuple` + :return: Pair ``(x, y)`` of time and voltage samples. + """ + self._validate_channel(channel) + metadata = self._waveform_metadata() + point_count = self._waveform_point_count(metadata) + payload = self._binary_query_exact( + f":DATA:WAVE:SCREEN:CH{channel}?", 4 + 2 * point_count + ) + raw_adc = _parse_waveform_adc( + _strip_packet_prefix(payload, f"screen waveform CH{channel}"), + f"screen waveform CH{channel}", + ) + if len(raw_adc) != point_count: + raise ValueError( + f"Screen waveform point count mismatch for CH{channel}: " + f"metadata={point_count}, payload={len(raw_adc)}" + ) + return ( + self._waveform_time_axis(metadata, point_count), + self._waveform_voltage_axis(metadata, channel, raw_adc), + ) + + def force_trigger(self): + raise NotImplementedError( + "The initial OWON SDS1104 driver does not expose trigger control." + ) + + def _validate_channel(self, channel): + if channel not in {1, 2, 3, 4}: + raise ValueError("Channel index must be between 1 and 4.") + + def _measure(self, channel, item, field_name, units): + self._validate_channel(channel) + token = self.query(f":MEASUrement:CH{channel}:{item}?") + value = _parse_measurement_token(token, field_name) + return u.Quantity(value, units) + + def _measure_short(self, channel, item, field_name, units): + self._validate_channel(channel) + token = self.query(f":MEAS:CH{channel}:{item}?") + value = _parse_measurement_token(token, field_name) + return u.Quantity(value, units) + + def _measure_short_count(self, channel, item, field_name): + self._validate_channel(channel) + token = self.query(f":MEAS:CH{channel}:{item}?") + return _parse_measurement_count(token, field_name) + + def read_measurement_data(self, channel, long_form=False): + """ + Reads the wrapper-style measurement blob for a single channel. + + :param int channel: One-based channel number from 1 to 4. + :param bool long_form: Use ``:MEASUrement:CH?`` instead of + ``:MEAS:CH?``. + :rtype: `dict` + """ + self._validate_channel(channel) + command = f":MEASUrement:CH{channel}?" if long_form else f":MEAS:CH{channel}?" + return _parse_measurement_payload(self._binary_query(command), channel) + + def read_all_measurement_data(self, long_form=False): + """ + Reads the wrapper-style all-channel measurement blob. + + :param bool long_form: Use ``:MEASUrement:ALL?`` instead of ``:MEAS?``. + :rtype: `dict` + """ + command = ":MEASUrement:ALL?" if long_form else ":MEAS?" + return _parse_measurement_map_payload(self._binary_query(command)) + + def measure_frequency(self, channel): + """ + Measures the frequency of a channel. + + :param int channel: One-based channel number from 1 to 4. + :rtype: `~pint.Quantity` + """ + return self._measure(channel, "FREQuency", "frequency", u.hertz) + + def measure_period(self, channel): + """ + Measures the period of a channel. + + :param int channel: One-based channel number from 1 to 4. + :rtype: `~pint.Quantity` + """ + return self._measure(channel, "PERiod", "period", u.second) + + def measure_peak_to_peak(self, channel): + """ + Measures the peak-to-peak voltage of a channel. + + :param int channel: One-based channel number from 1 to 4. + :rtype: `~pint.Quantity` + """ + return self._measure(channel, "PKPK", "peak-to-peak voltage", u.volt) + + def measure_rms(self, channel): + """ + Measures the cycle RMS voltage of a channel. + + :param int channel: One-based channel number from 1 to 4. + :rtype: `~pint.Quantity` + """ + return self._measure(channel, "CYCRms", "RMS voltage", u.volt) + + def measure_average(self, channel): + """ + Measures the average voltage of a channel. + + :param int channel: One-based channel number from 1 to 4. + :rtype: `~pint.Quantity` + """ + return self._measure(channel, "AVERage", "average voltage", u.volt) + + def measure_maximum(self, channel): + """ + Measures the maximum voltage of a channel. + + :param int channel: One-based channel number from 1 to 4. + :rtype: `~pint.Quantity` + """ + return self._measure(channel, "MAX", "maximum voltage", u.volt) + + def measure_minimum(self, channel): + """ + Measures the minimum voltage of a channel. + + :param int channel: One-based channel number from 1 to 4. + :rtype: `~pint.Quantity` + """ + return self._measure(channel, "MIN", "minimum voltage", u.volt) + + def measure_top(self, channel): + """ + Measures the top voltage of a channel. + + :param int channel: One-based channel number from 1 to 4. + :rtype: `~pint.Quantity` + """ + return self._measure(channel, "VTOP", "top voltage", u.volt) + + def measure_base(self, channel): + """ + Measures the base voltage of a channel. + + :param int channel: One-based channel number from 1 to 4. + :rtype: `~pint.Quantity` + """ + return self._measure(channel, "VBASe", "base voltage", u.volt) + + def measure_amplitude(self, channel): + """ + Measures the amplitude voltage of a channel. + + :param int channel: One-based channel number from 1 to 4. + :rtype: `~pint.Quantity` + """ + return self._measure(channel, "VAMP", "amplitude voltage", u.volt) + + def measure_rise_time(self, channel): + """ + Measures the rise time of a channel. + + :param int channel: One-based channel number from 1 to 4. + :rtype: `~pint.Quantity` + """ + return self._measure(channel, "RTime", "rise time", u.second) + + def measure_fall_time(self, channel): + """ + Measures the fall time of a channel. + + :param int channel: One-based channel number from 1 to 4. + :rtype: `~pint.Quantity` + """ + return self._measure(channel, "FTime", "fall time", u.second) + + def measure_positive_width(self, channel): + """ + Measures the positive pulse width of a channel. + + :param int channel: One-based channel number from 1 to 4. + :rtype: `~pint.Quantity` + """ + return self._measure(channel, "PWIDth", "positive pulse width", u.second) + + def measure_negative_width(self, channel): + """ + Measures the negative pulse width of a channel. + + :param int channel: One-based channel number from 1 to 4. + :rtype: `~pint.Quantity` + """ + return self._measure(channel, "NWIDth", "negative pulse width", u.second) + + def measure_positive_duty(self, channel): + """ + Measures the positive duty cycle of a channel. + + :param int channel: One-based channel number from 1 to 4. + :rtype: `~pint.Quantity` + """ + return self._measure(channel, "PDUTy", "positive duty cycle", u.percent) + + def measure_negative_duty(self, channel): + """ + Measures the negative duty cycle of a channel. + + :param int channel: One-based channel number from 1 to 4. + :rtype: `~pint.Quantity` + """ + return self._measure(channel, "NDUTy", "negative duty cycle", u.percent) + + def measure_overshoot(self, channel): + """ + Measures the overshoot percentage of a channel. + + :param int channel: One-based channel number from 1 to 4. + :rtype: `~pint.Quantity` + """ + return self._measure(channel, "OVERshoot", "overshoot", u.percent) + + def measure_preshoot(self, channel): + """ + Measures the preshoot percentage of a channel. + + :param int channel: One-based channel number from 1 to 4. + :rtype: `~pint.Quantity` + """ + return self._measure(channel, "PREShoot", "preshoot", u.percent) + + def measure_square_sum(self, channel): + """ + Measures the short-form ``SQUAresum`` quantity for a channel. + + :param int channel: One-based channel number from 1 to 4. + :rtype: `~pint.Quantity` + """ + return self._measure_short(channel, "SQUARESUM", "square sum", u.volt) + + def measure_cursor_rms(self, channel): + """ + Measures the cursor RMS voltage of a channel. + + :param int channel: One-based channel number from 1 to 4. + :rtype: `~pint.Quantity` + """ + return self._measure_short(channel, "CURSorrms", "cursor RMS voltage", u.volt) + + def measure_screen_duty(self, channel): + """ + Measures the screen duty percentage of a channel. + + :param int channel: One-based channel number from 1 to 4. + :rtype: `~pint.Quantity` + """ + return self._measure_short(channel, "SCREenduty", "screen duty", u.percent) + + def measure_positive_pulse_count(self, channel): + """ + Measures the positive pulse count of a channel. + + :param int channel: One-based channel number from 1 to 4. + :rtype: `int` + """ + return self._measure_short_count(channel, "PPULSENUM", "positive pulse count") + + def measure_negative_pulse_count(self, channel): + """ + Measures the negative pulse count of a channel. + + :param int channel: One-based channel number from 1 to 4. + :rtype: `int` + """ + return self._measure_short_count(channel, "NPULSENUM", "negative pulse count") + + def measure_rise_edge_count(self, channel): + """ + Measures the rising-edge count of a channel. + + :param int channel: One-based channel number from 1 to 4. + :rtype: `int` + """ + self._validate_channel(channel) + return _parse_measurement_count( + self.query(f":MEASUrement:CH{channel}:RISEedgenum?"), "rising-edge count" + ) + + def measure_fall_edge_count(self, channel): + """ + Measures the falling-edge count of a channel. + + :param int channel: One-based channel number from 1 to 4. + :rtype: `int` + """ + self._validate_channel(channel) + return _parse_measurement_count( + self.query(f":MEASUrement:CH{channel}:FALLedgenum?"), "falling-edge count" + ) + + def measure_area(self, channel): + """ + Measures the area of a channel. + + :param int channel: One-based channel number from 1 to 4. + :rtype: `~pint.Quantity` + """ + return self._measure_short(channel, "AREA", "area", u.volt * u.second) + + def measure_cycle_area(self, channel): + """ + Measures the cycle area of a channel. + + :param int channel: One-based channel number from 1 to 4. + :rtype: `~pint.Quantity` + """ + return self._measure_short( + channel, "CYCLEAREA", "cycle area", u.volt * u.second + ) + + def measure_hard_frequency(self, channel): + """ + Measures the hard frequency of a channel. + + :param int channel: One-based channel number from 1 to 4. + :rtype: `~pint.Quantity` + """ + return self._measure(channel, "HARDfrequency", "hard frequency", u.hertz) + + def _validate_waveform_point_count( # pylint: disable=too-many-arguments,too-many-positional-arguments + self, + metadata, + point_count, + channel, + field_name, + allow_metadata_short_by_one=False, + ): + expected = self._waveform_point_count(metadata) + if point_count == expected: + return + if allow_metadata_short_by_one and point_count + 1 == expected: + return + raise ValueError( + f"{field_name} point count mismatch for CH{channel}: " + f"metadata={expected}, payload={point_count}" + ) + + def _build_waveform_axes( # pylint: disable=too-many-arguments,too-many-positional-arguments + self, metadata, channel, raw_adc, field_name, allow_metadata_short_by_one=False + ): + self._validate_waveform_point_count( + metadata, + len(raw_adc), + channel, + field_name, + allow_metadata_short_by_one=allow_metadata_short_by_one, + ) + point_count = len(raw_adc) + return ( + self._waveform_time_axis(metadata, point_count), + self._waveform_voltage_axis(metadata, channel, raw_adc), + ) + + def read_screen_bmp(self): + """ + Reads the current screen image as BMP bytes. + + :rtype: `bytes` + """ + payload = self._query_length_prefixed_binary(":DATA:WAVE:SCREen:BMP?") + bmp_data = payload[4:] + if len(bmp_data) < 14 or not bmp_data.startswith(b"BM"): + raise ValueError( + "SDS1104 screen BMP payload does not contain a valid BMP header." + ) + bmp_size = int.from_bytes(bmp_data[2:6], byteorder="little", signed=False) + if bmp_size != len(bmp_data): + raise ValueError( + f"SDS1104 BMP length mismatch: header says {bmp_size} bytes, " + f"received {len(bmp_data)}." + ) + return bmp_data + + def read_deep_memory_metadata(self): + """ + Reads the deep-memory metadata JSON. + + :rtype: `dict` + """ + return _parse_json_payload( + self._query_length_prefixed_binary(":DATA:WAVE:DEPMEM:HEAD?"), + "deep-memory metadata", + ) + + def read_deep_memory_channel(self, channel): + """ + Reads the deep-memory waveform for a channel. + + :param int channel: One-based channel number from 1 to 4. + :rtype: `tuple` + """ + self._validate_channel(channel) + metadata = self.read_deep_memory_metadata() + payload = self._query_length_prefixed_binary(f":DATA:WAVE:DEPMEM:CH{channel}?") + raw_adc = _parse_waveform_adc( + _strip_packet_prefix(payload, f"deep-memory waveform CH{channel}"), + f"deep-memory waveform CH{channel}", + ) + return self._build_waveform_axes( + metadata, + channel, + raw_adc, + "Deep-memory waveform", + allow_metadata_short_by_one=True, + ) + + def read_deep_memory_all(self): # pylint: disable=too-many-locals,too-many-branches + """ + Reads the bundled deep-memory capture as metadata plus raw channel data. + + :rtype: `SDS1104DeepMemoryCapture` + """ + payload = self._query_length_prefixed_binary( + ":DATA:WAVE:DEPMem:All?", max_body_size=100_000_000 + ) + body = payload[4:] + if len(body) < 4: + raise ValueError( + "Deep-memory bundle payload is too short for metadata length." + ) + + metadata_size = int.from_bytes(body[:4], byteorder="little", signed=False) + if metadata_size <= 0: + raise ValueError( + f"Invalid deep-memory bundle metadata length: {metadata_size}" + ) + if 4 + metadata_size > len(body): + raise ValueError( + "Deep-memory bundle metadata length exceeds the received payload size." + ) + + metadata_text = body[4 : 4 + metadata_size].decode("utf-8", errors="replace") + metadata = json.loads(metadata_text.strip()) + if not isinstance(metadata, dict): + raise ValueError(f"Invalid deep-memory bundle metadata: {metadata_text!r}") + + offset = 4 + metadata_size + raw_blocks = [] + block_channel = 1 + while offset < len(body): + if offset + 4 > len(body): + raise ValueError( + "Deep-memory bundle is truncated before a channel block length." + ) + block_size = int.from_bytes( + body[offset : offset + 4], byteorder="little", signed=False + ) + offset += 4 + if offset + block_size > len(body): + raise ValueError( + f"Deep-memory bundle CH{block_channel} block exceeds the " + "received payload size." + ) + raw_adc = _parse_waveform_adc( + body[offset : offset + block_size], + f"deep-memory bundle CH{block_channel}", + ) + self._validate_waveform_point_count( + metadata, + len(raw_adc), + block_channel, + "Deep-memory bundle waveform", + allow_metadata_short_by_one=True, + ) + raw_blocks.append(raw_adc) + offset += block_size + block_channel += 1 + + if not raw_blocks: + raise ValueError("Deep-memory bundle did not contain any channel blocks.") + + raw_channels = {} + metadata_channels = metadata.get("CHANNEL") + displayed_channel_ids = [] + if isinstance(metadata_channels, list): + for index, channel_metadata in enumerate(metadata_channels, start=1): + if ( + isinstance(channel_metadata, dict) + and str(channel_metadata.get("DISPLAY", "")).upper() == "ON" + ): + displayed_channel_ids.append(index) + + if displayed_channel_ids and len(displayed_channel_ids) == len(raw_blocks): + channel_ids = displayed_channel_ids + else: + channel_ids = list(range(1, len(raw_blocks) + 1)) + + for channel_id, raw_adc in zip(channel_ids, raw_blocks, strict=True): + raw_channels[channel_id] = raw_adc + + return SDS1104DeepMemoryCapture( + metadata=metadata, + raw_channels=raw_channels, + ) + + def list_saved_waveforms(self): + """ + Lists saved-waveform entries exposed by ``:SAVE:READ:HEAD?``. + + :rtype: `list` + """ + payload = self._query_length_prefixed_binary(":SAVE:READ:HEAD?") + return [ + SDS1104SavedWaveformEntry(index=str(item["Index"]), raw=dict(item)) + for item in _parse_json_array_payload(payload, "saved waveform head") + ] + + def read_saved_waveform_data(self, index): + """ + Reads the raw ADC payload for a saved-waveform entry. + + :param index: Saved-waveform index token. + :rtype: `numpy.ndarray` or `tuple` + """ + cleaned_index = str(index).strip() + if not cleaned_index: + raise ValueError("Saved waveform index must not be empty.") + payload = self._query_length_prefixed_binary(f":SAVE:READ:DATA {cleaned_index}") + return _parse_waveform_adc( + _strip_packet_prefix( + payload, f"saved waveform data for index {cleaned_index}" + ), + f"saved waveform data for index {cleaned_index}", + ) diff --git a/src/instruments/thorlabs/_abstract.py b/src/instruments/thorlabs/_abstract.py index 7959760d..68c6b636 100644 --- a/src/instruments/thorlabs/_abstract.py +++ b/src/instruments/thorlabs/_abstract.py @@ -84,7 +84,7 @@ def querypacket(self, packet, expect=None, timeout=None, expect_data_len=None): break else: tic = time.time() - if tic - t_start > timeout: + if tic - t_start >= timeout: break if not resp: diff --git a/tests/test_comm/test_usb_communicator.py b/tests/test_comm/test_usb_communicator.py index 8969302c..fa580b71 100644 --- a/tests/test_comm/test_usb_communicator.py +++ b/tests/test_comm/test_usb_communicator.py @@ -127,6 +127,7 @@ def test_timeout_set_unitless(inst): set_val = inst._dev.default_timeout exp_val = 1000 * val assert set_val == exp_val + assert isinstance(set_val, int) def test_timeout_set_minutes(inst): @@ -157,6 +158,14 @@ def test_read_raw(inst): assert inst.read_raw() == msg_exp +def test_read_packet(inst): + """Read a single raw USB packet.""" + msg = b"\x01\x02\x03" + inst._ep_in.read.return_value = msg + + assert inst.read_packet() == msg + + def test_read_raw_size(inst): """If size is -1, read 1000 bytes.""" msg = b"message\n" @@ -170,6 +179,46 @@ def test_read_raw_size(inst): inst._ep_in.read.assert_called_with(max_size) +def test_read_exact(inst): + """Read an exact number of raw USB bytes.""" + inst.read_packet = mock.MagicMock(side_effect=[b"\x01\x02", b"\x03\x04"]) + + assert inst.read_exact(4, chunk_size=2) == b"\x01\x02\x03\x04" + + +def test_read_exact_negative_size(inst): + """Reject a negative exact read size.""" + with pytest.raises(ValueError) as err: + inst.read_exact(-1) + assert err.value.args[0] == "Size must be non-negative." + + +def test_read_binary_exact(inst): + """Read a binary reply with explicit size.""" + inst.read_exact = mock.MagicMock(return_value=b"\x00\x01") + + assert inst.read_binary(2) == b"\x00\x01" + inst.read_exact.assert_called_with(2) + + +def test_read_binary_until_short_packet(inst): + """Read a binary reply until the device sends a short packet.""" + inst._max_packet_size = 4 + inst.read_packet = mock.MagicMock(side_effect=[b"1234", b"56"]) + + assert inst.read_binary() == b"123456" + + +def test_read_binary_until_timeout(inst): + """Read a binary reply until a timeout after receiving data.""" + inst._max_packet_size = 4 + inst.read_packet = mock.MagicMock( + side_effect=[b"1234", usb.core.USBTimeoutError("timeout")] + ) + + assert inst.read_binary() == b"1234" + + def test_read_raw_termination_char_not_found(inst): """Raise IOError if termination character not found.""" msg = b"message" @@ -209,9 +258,9 @@ def test_tell(inst): def test_flush_input(inst): """Flush the input out by trying to read until no more available.""" - inst._ep_in.read.side_effect = [b"message\n", usb.core.USBTimeoutError] + inst._ep_in.read.side_effect = [b"message\n", usb.core.USBTimeoutError("timeout")] inst.flush_input() - inst._ep_in.read.assert_called() + assert inst._ep_in.read.call_count == 2 def test_sendcmd(inst): diff --git a/tests/test_owon/test_sds1104.py b/tests/test_owon/test_sds1104.py new file mode 100644 index 00000000..f356bed2 --- /dev/null +++ b/tests/test_owon/test_sds1104.py @@ -0,0 +1,803 @@ +#!/usr/bin/env python +""" +Tests for the OWON SDS1104 oscilloscope driver. +""" + +# IMPORTS #################################################################### + + +from io import BytesIO +import json +import struct + +import pytest + +import instruments as ik +from instruments.abstract_instruments.comm import LoopbackCommunicator +from instruments.units import ureg as u +from tests import expected_protocol, unit_eq + +# TESTS ###################################################################### + + +def _make_length_prefixed_payload(body): + return struct.pack(""] + ) as scope: + assert scope.name == "OWON,SDS1104,1234,V1.0" + + +def test_channel_proxy(): + with expected_protocol(ik.owon.OWONSDS1104, [], []) as scope: + assert scope.channel[0].name == "CH1" + assert scope.channel[3].name == "CH4" + + +def test_channel_display(): + with expected_protocol( + ik.owon.OWONSDS1104, + [":CH1:DISP?", ":CH2:DISP ON"], + ["1"], + ) as scope: + assert scope.channel[0].display is True + scope.channel[1].display = True + + +def test_channel_display_type_error(): + with expected_protocol(ik.owon.OWONSDS1104, [], []) as scope: + with pytest.raises(TypeError) as err: + scope.channel[0].display = "ON" + assert ( + err.value.args[0] == "Display state must be specified with a boolean value." + ) + + +def test_channel_coupling(): + with expected_protocol( + ik.owon.OWONSDS1104, + [":CH1:COUP?", ":CH2:COUP AC"], + ["DC"], + ) as scope: + assert scope.channel[0].coupling == scope.Coupling.dc + scope.channel[1].coupling = scope.Coupling.ac + + +def test_channel_coupling_type_error(): + with expected_protocol(ik.owon.OWONSDS1104, [], []) as scope: + with pytest.raises(TypeError) as err: + scope.channel[0].coupling = "DC" + assert ( + err.value.args[0] + == "Coupling setting must be a `OWONSDS1104.Coupling` value." + ) + + +def test_channel_probe(): + with expected_protocol( + ik.owon.OWONSDS1104, + [":CH1:PROB?", ":CH2:PROB 100X"], + ["10X"], + ) as scope: + assert scope.channel[0].probe_attenuation == 10 + scope.channel[1].probe_attenuation = 100 + + +def test_channel_probe_value_error(): + with expected_protocol(ik.owon.OWONSDS1104, [], []) as scope: + with pytest.raises(ValueError) as err: + scope.channel[0].probe_attenuation = 3 + assert ( + err.value.args[0] == "Probe attenuation must be one of 1, 10, 100, or 1000." + ) + + +def test_channel_scale(): + with expected_protocol( + ik.owon.OWONSDS1104, + [":CH1:SCAL?", ":CH2:SCAL 500mv"], + ["100mV"], + ) as scope: + unit_eq(scope.channel[0].scale, 0.1 * u.volt) + scope.channel[1].scale = 0.5 * u.volt + + +def test_channel_scale_value_error(): + with expected_protocol(ik.owon.OWONSDS1104, [], []) as scope: + with pytest.raises(ValueError) as err: + scope.channel[0].scale = 3.0 * u.volt + assert ( + err.value.args[0] + == "Unsupported vertical scale. Must be one of the documented discrete values." + ) + + +def test_channel_offset(): + with expected_protocol( + ik.owon.OWONSDS1104, + [":CH1:OFFS?", ":CH2:OFFS 0.25"], + ["-0.5"], + ) as scope: + unit_eq(scope.channel[0].offset, -0.5 * u.volt) + scope.channel[1].offset = 0.25 * u.volt + + +def test_channel_position(): + with expected_protocol( + ik.owon.OWONSDS1104, + [":CH1:POS?", ":CH2:POS -1.5"], + ["0.25"], + ) as scope: + assert scope.channel[0].position == pytest.approx(0.25) + scope.channel[1].position = -1.5 + + +def test_channel_invert(): + with expected_protocol( + ik.owon.OWONSDS1104, + [":CH1:INVErse?", ":CH2:INVErse OFF"], + ["ON"], + ) as scope: + assert scope.channel[0].invert is True + scope.channel[1].invert = False + + +def test_channel_invert_type_error(): + with expected_protocol(ik.owon.OWONSDS1104, [], []) as scope: + with pytest.raises(TypeError) as err: + scope.channel[0].invert = 1 + assert ( + err.value.args[0] == "Invert state must be specified with a boolean value." + ) + + +def test_acquire_mode(): + with expected_protocol( + ik.owon.OWONSDS1104, + [":ACQUire:Mode?", ":ACQUire:Mode AVERage"], + ["SAMP"], + ) as scope: + assert scope.acquire_mode == scope.AcquisitionMode.sample + scope.acquire_mode = scope.AcquisitionMode.average + + +def test_acquire_mode_type_error(): + with expected_protocol(ik.owon.OWONSDS1104, [], []) as scope: + with pytest.raises(TypeError) as err: + scope.acquire_mode = "SAMPle" + assert ( + err.value.args[0] + == 'Acquisition mode must be one of "SAMPle", "AVERage", or "PEAK".' + ) + + +def test_acquire_averages(): + with expected_protocol( + ik.owon.OWONSDS1104, + [":ACQUire:average:num?", ":ACQUire:average:num 64"], + ["16"], + ) as scope: + assert scope.acquire_averages == 16 + scope.acquire_averages = 64 + + +def test_acquire_averages_value_error(): + with expected_protocol(ik.owon.OWONSDS1104, [], []) as scope: + with pytest.raises(ValueError) as err: + scope.acquire_averages = 8 + assert ( + err.value.args[0] + == "Average count not supported by instrument; must be one of {4, 16, 64, 128}." + ) + + +def test_memory_depth(): + with expected_protocol( + ik.owon.OWONSDS1104, + [":ACQUIRE:DEPMEM?", ":ACQUIRE:DEPMEM 100K"], + ["10K"], + ) as scope: + assert scope.memory_depth == 10_000 + scope.memory_depth = 100_000 + + +def test_memory_depth_value_error(): + with expected_protocol(ik.owon.OWONSDS1104, [], []) as scope: + with pytest.raises(ValueError) as err: + scope.memory_depth = 42 + assert ( + err.value.args[0] + == "Memory depth must be one of 1K, 5K, 10K, 100K, 1M, or 10M. 20M and 40M are documented, but are not yet verified in this driver." + ) + + +def test_timebase_scale(): + with expected_protocol( + ik.owon.OWONSDS1104, + [":HORIzontal:Scale?", ":HORIzontal:Scale 10ms"], + ["1ms"], + ) as scope: + unit_eq(scope.timebase_scale, 1e-3 * u.second) + scope.timebase_scale = 10e-3 * u.second + + +def test_timebase_scale_value_error(): + with expected_protocol(ik.owon.OWONSDS1104, [], []) as scope: + with pytest.raises(ValueError) as err: + scope.timebase_scale = 3e-3 * u.second + assert ( + err.value.args[0] + == "Unsupported timebase scale. Must be one of the documented discrete values." + ) + + +def test_horizontal_offset(): + with expected_protocol( + ik.owon.OWONSDS1104, + [":HORIzontal:OFFSET?", ":HORIzontal:OFFSET 1.5"], + ["0.25"], + ) as scope: + assert scope.horizontal_offset == pytest.approx(0.25) + scope.horizontal_offset = 1.5 + + +def test_measurement_display_enabled(): + with expected_protocol( + ik.owon.OWONSDS1104, + [":MEASUrement:DISPlay?", ":MEASUrement:DISPlay OFF"], + ["ON"], + ) as scope: + assert scope.measurement_display_enabled is True + scope.measurement_display_enabled = False + + +def test_measurement_display_enabled_type_error(): + with expected_protocol(ik.owon.OWONSDS1104, [], []) as scope: + with pytest.raises(TypeError) as err: + scope.measurement_display_enabled = "OFF" + assert ( + err.value.args[0] + == "Measurement display state must be specified with a boolean value." + ) + + +def test_run_stop(): + with expected_protocol(ik.owon.OWONSDS1104, [":RUN", ":STOP"], []) as scope: + scope.run() + scope.stop() + + +def test_trigger_status(): + with expected_protocol( + ik.owon.OWONSDS1104, + [":TRIGger:STATUS?"], + ["READy"], + ) as scope: + assert scope.trigger_status == scope.TriggerStatus.ready + + +def test_trigger_mode(): + with expected_protocol( + ik.owon.OWONSDS1104, + [":TRIGger:SINGle:MODE?", ":TRIGger:SINGle:MODE VIDEO"], + ["EDGE"], + ) as scope: + assert scope.trigger_mode == scope.TriggerMode.edge + scope.trigger_mode = scope.TriggerMode.video + + +def test_trigger_mode_type_error(): + with expected_protocol(ik.owon.OWONSDS1104, [], []) as scope: + with pytest.raises(TypeError) as err: + scope.trigger_mode = "EDGE" + assert ( + err.value.args[0] + == "Trigger mode must be specified with a `OWONSDS1104.TriggerMode` value." + ) + + +def test_edge_trigger_properties(): + with expected_protocol( + ik.owon.OWONSDS1104, + [ + ":TRIGger:SINGle:MODE?", + ":TRIGger:SINGle:EDGE:SOURce?", + ":TRIGger:SINGle:MODE?", + ":TRIGger:SINGle:EDGE:SOURce CH2", + ":TRIGger:SINGle:MODE?", + ":TRIGger:SINGle:EDGE:COUPling?", + ":TRIGger:SINGle:MODE?", + ":TRIGger:SINGle:EDGE:COUPling AC", + ":TRIGger:SINGle:MODE?", + ":TRIGger:SINGle:EDGE:SLOPe?", + ":TRIGger:SINGle:MODE?", + ":TRIGger:SINGle:EDGE:SLOPe FALL", + ":TRIGger:SINGle:MODE?", + ":TRIGger:SINGle:EDGE:LEVel?", + ":TRIGger:SINGle:MODE?", + ":TRIGger:SINGle:EDGE:LEVel 0V", + ], + [ + "EDGE", + "CH1", + "EDGE", + "EDGE", + "DC", + "EDGE", + "EDGE", + "RISE", + "EDGE", + "EDGE", + "4.00mV", + "EDGE", + ], + ) as scope: + assert scope.trigger_source == scope.TriggerSource.ch1 + scope.trigger_source = scope.TriggerSource.ch2 + assert scope.trigger_coupling == scope.TriggerCoupling.dc + scope.trigger_coupling = scope.TriggerCoupling.ac + assert scope.trigger_slope == scope.TriggerSlope.rise + scope.trigger_slope = scope.TriggerSlope.fall + unit_eq(scope.trigger_level, 4e-3 * u.volt) + scope.trigger_level = 0 * u.volt + + +def test_edge_trigger_properties_require_edge_mode(): + with expected_protocol( + ik.owon.OWONSDS1104, + [":TRIGger:SINGle:MODE?"], + ["VIDEO"], + ) as scope: + with pytest.raises(NotImplementedError) as err: + _ = scope.trigger_source + assert ( + err.value.args[0] + == "Trigger source, coupling, slope, and level are only exposed for EDGE trigger mode in this driver." + ) + + +def test_edge_trigger_type_errors(): + with expected_protocol( + ik.owon.OWONSDS1104, + [":TRIGger:SINGle:MODE?", ":TRIGger:SINGle:MODE?", ":TRIGger:SINGle:MODE?"], + ["EDGE", "EDGE", "EDGE"], + ) as scope: + with pytest.raises(TypeError) as err: + scope.trigger_source = "CH1" + assert ( + err.value.args[0] + == "Trigger source must be specified with a `OWONSDS1104.TriggerSource` value." + ) + + with pytest.raises(TypeError) as err: + scope.trigger_coupling = "DC" + assert ( + err.value.args[0] + == "Trigger coupling must be specified with a `OWONSDS1104.TriggerCoupling` value." + ) + + with pytest.raises(TypeError) as err: + scope.trigger_slope = "RISE" + assert ( + err.value.args[0] + == "Trigger slope must be specified with a `OWONSDS1104.TriggerSlope` value." + ) + + +def test_autoscale(): + with expected_protocol(ik.owon.OWONSDS1104, [":AUTOscale ON"], []) as scope: + scope.autoscale() + + +def test_measure_frequency(): + with expected_protocol( + ik.owon.OWONSDS1104, + [":MEASUrement:CH1:FREQuency?"], + ["12.5kHz"], + ) as scope: + unit_eq(scope.measure_frequency(1), 12_500 * u.hertz) + + +def test_measure_period(): + with expected_protocol( + ik.owon.OWONSDS1104, + [":MEASUrement:CH2:PERiod?"], + ["5ms"], + ) as scope: + unit_eq(scope.measure_period(2), 5e-3 * u.second) + + +def test_measure_peak_to_peak(): + with expected_protocol( + ik.owon.OWONSDS1104, + [":MEASUrement:CH3:PKPK?"], + ["2.5V"], + ) as scope: + unit_eq(scope.measure_peak_to_peak(3), 2.5 * u.volt) + + +def test_measure_rms(): + with expected_protocol( + ik.owon.OWONSDS1104, + [":MEASUrement:CH4:CYCRms?"], + ["125mV"], + ) as scope: + unit_eq(scope.measure_rms(4), 0.125 * u.volt) + + +def test_extended_long_form_measurements(): + with expected_protocol( + ik.owon.OWONSDS1104, + [ + ":MEASUrement:CH1:AVERage?", + ":MEASUrement:CH1:MAX?", + ":MEASUrement:CH1:MIN?", + ":MEASUrement:CH1:VTOP?", + ":MEASUrement:CH1:VBASe?", + ":MEASUrement:CH1:VAMP?", + ":MEASUrement:CH1:RTime?", + ":MEASUrement:CH1:FTime?", + ":MEASUrement:CH1:PWIDth?", + ":MEASUrement:CH1:NWIDth?", + ":MEASUrement:CH1:PDUTy?", + ":MEASUrement:CH1:NDUTy?", + ":MEASUrement:CH1:OVERshoot?", + ":MEASUrement:CH1:PREShoot?", + ], + [ + "V : 10.00mV", + "Vmax : 80.00mV", + "Vmin : -10.00mV", + "Vtop : 70.00mV", + "Vbase : 5.00mV", + "Vamp : 65.00mV", + "Rt : 400us", + "Ft : 500us", + "PW : 2.00ms", + "NW : 3.00ms", + "PD : 60.0%", + "ND : 40.0%", + "OS : 5.0%", + "PS : 2.5%", + ], + ) as scope: + unit_eq(scope.measure_average(1), 10e-3 * u.volt) + unit_eq(scope.measure_maximum(1), 80e-3 * u.volt) + unit_eq(scope.measure_minimum(1), -10e-3 * u.volt) + unit_eq(scope.measure_top(1), 70e-3 * u.volt) + unit_eq(scope.measure_base(1), 5e-3 * u.volt) + unit_eq(scope.measure_amplitude(1), 65e-3 * u.volt) + unit_eq(scope.measure_rise_time(1), 400e-6 * u.second) + unit_eq(scope.measure_fall_time(1), 500e-6 * u.second) + unit_eq(scope.measure_positive_width(1), 2e-3 * u.second) + unit_eq(scope.measure_negative_width(1), 3e-3 * u.second) + unit_eq(scope.measure_positive_duty(1), 60 * u.percent) + unit_eq(scope.measure_negative_duty(1), 40 * u.percent) + unit_eq(scope.measure_overshoot(1), 5 * u.percent) + unit_eq(scope.measure_preshoot(1), 2.5 * u.percent) + + +def test_extended_short_form_measurements(): + with expected_protocol( + ik.owon.OWONSDS1104, + [ + ":MEAS:CH1:SQUARESUM?", + ":MEAS:CH1:CURSorrms?", + ":MEAS:CH1:SCREenduty?", + ":MEAS:CH1:PPULSENUM?", + ":MEAS:CH1:NPULSENUM?", + ":MEAS:CH1:AREA?", + ":MEAS:CH1:CYCLEAREA?", + ], + [ + "Vr : 20.00mV", + "CR : 5.000mV", + "WP : 40.0%", + "+PC : 4", + "-PC : 3", + "AR : 1.5mV*s", + "CA : 500uV*s", + ], + ) as scope: + unit_eq(scope.measure_square_sum(1), 20e-3 * u.volt) + unit_eq(scope.measure_cursor_rms(1), 5e-3 * u.volt) + unit_eq(scope.measure_screen_duty(1), 40 * u.percent) + assert scope.measure_positive_pulse_count(1) == 4 + assert scope.measure_negative_pulse_count(1) == 3 + unit_eq(scope.measure_area(1), 1.5e-3 * u.volt * u.second) + unit_eq(scope.measure_cycle_area(1), 500e-6 * u.volt * u.second) + + +def test_edge_count_and_hard_frequency_measurements(): + with expected_protocol( + ik.owon.OWONSDS1104, + [ + ":MEASUrement:CH1:RISEedgenum?", + ":MEASUrement:CH1:FALLedgenum?", + ":MEASUrement:CH1:HARDfrequency?", + ], + [ + "+E : 4", + "-E : 3", + "<2Hz", + ], + ) as scope: + assert scope.measure_rise_edge_count(1) == 4 + assert scope.measure_fall_edge_count(1) == 3 + unit_eq(scope.measure_hard_frequency(1), 2 * u.hertz) + + +def test_channel_measurement_helpers(): + with expected_protocol( + ik.owon.OWONSDS1104, + [ + ":MEASUrement:CH1:FREQuency?", + ":MEASUrement:CH1:PERiod?", + ":MEASUrement:CH1:PKPK?", + ":MEASUrement:CH1:CYCRms?", + ":MEASUrement:CH1:AVERage?", + ":MEASUrement:CH1:MAX?", + ":MEASUrement:CH1:MIN?", + ], + ["100Hz", "10ms", "800mV", "200mV", "100mV", "900mV", "-50mV"], + ) as scope: + unit_eq(scope.channel[0].measure_frequency(), 100 * u.hertz) + unit_eq(scope.channel[0].measure_period(), 10e-3 * u.second) + unit_eq(scope.channel[0].measure_peak_to_peak(), 0.8 * u.volt) + unit_eq(scope.channel[0].measure_rms(), 0.2 * u.volt) + unit_eq(scope.channel[0].measure_average(), 0.1 * u.volt) + unit_eq(scope.channel[0].measure_maximum(), 0.9 * u.volt) + unit_eq(scope.channel[0].measure_minimum(), -0.05 * u.volt) + + +def test_measurement_channel_validation(): + with expected_protocol(ik.owon.OWONSDS1104, [], []) as scope: + with pytest.raises(ValueError) as err: + scope.measure_frequency(0) + assert err.value.args[0] == "Channel index must be between 1 and 4." + + +def test_force_trigger_not_implemented(): + with expected_protocol(ik.owon.OWONSDS1104, [], []) as scope: + with pytest.raises(NotImplementedError): + scope.force_trigger() + + +def test_channel_read_waveform_binary(): + metadata = { + "CHANNEL": [ + {"SCALE": "100mV", "PROBE": "10X", "OFFSET": "0"}, + {"SCALE": "100mV", "PROBE": "10X", "OFFSET": "0"}, + {"SCALE": "100mV", "PROBE": "10X", "OFFSET": "0"}, + {"SCALE": "100mV", "PROBE": "10X", "OFFSET": "0"}, + ], + "SAMPLE": {"SAMPLERATE": "1MS/s", "DATALEN": "4"}, + "TIMEBASE": {"HOFFSET": "0"}, + } + metadata_payload = b"\x00\x00\x00\x00" + json.dumps(metadata).encode("ascii") + waveform_payload = b"\x00\x00\x00\x00" + struct.pack("<4h", 0, 82, -82, 410) + + scope, stdout = _make_binary_scope( + binary_replies=[metadata_payload], + exact_replies=[waveform_payload], + ) + + x, y = scope.channel[0].read_waveform() + + assert stdout.getvalue() == (b":DATA:WAVE:SCREen:HEAD?" b":DATA:WAVE:SCREEN:CH1?") + assert tuple(float(value) for value in x) == pytest.approx( + (-10e-6, -5e-6, 0.0, 5e-6) + ) + assert tuple(float(value) for value in y) == pytest.approx((0.0, 0.2, -0.2, 1.0)) + + +def test_read_waveform_metadata(): + metadata = { + "CHANNEL": [ + {"SCALE": "100mV", "PROBE": "10X", "OFFSET": "0"}, + {"SCALE": "100mV", "PROBE": "10X", "OFFSET": "0"}, + {"SCALE": "100mV", "PROBE": "10X", "OFFSET": "0"}, + {"SCALE": "100mV", "PROBE": "10X", "OFFSET": "0"}, + ], + "SAMPLE": {"SAMPLERATE": "1MS/s", "DATALEN": "4"}, + "TIMEBASE": {"HOFFSET": "0"}, + } + metadata_payload = b"\x00\x00\x00\x00" + json.dumps(metadata).encode("ascii") + + scope, stdout = _make_binary_scope(binary_replies=[metadata_payload]) + + result = scope.read_waveform_metadata() + + assert result["SAMPLE"]["DATALEN"] == "4" + assert stdout.getvalue() == b":DATA:WAVE:SCREen:HEAD?" + + +def test_channel_read_waveform_ascii_not_implemented(): + with expected_protocol(ik.owon.OWONSDS1104, [], []) as scope: + with pytest.raises(NotImplementedError): + scope.channel[0].read_waveform(bin_format=False) + + +def test_math_read_waveform_not_implemented(): + with expected_protocol(ik.owon.OWONSDS1104, [], []) as scope: + with pytest.raises(NotImplementedError): + scope.math.read_waveform() + + +def test_read_measurement_data_short_and_long(): + short_payload = ( + b"\x00\x00\x00\x00" + b'{"CH1":{"FREQuency":"1.00kHz","PKPK":"2.00V"}}' + ) + long_payload = ( + b"\x00\x00\x00\x00" + b'{"CH1":{"FREQuency":"1.00kHz","PKPK":"2.00V"}}' + ) + scope, stdout = _make_binary_scope( + binary_replies=[short_payload, long_payload], + ) + + assert scope.read_measurement_data(1) == {"FREQuency": "1.00kHz", "PKPK": "2.00V"} + assert scope.channel[0].read_measurement_data(long_form=True) == { + "FREQuency": "1.00kHz", + "PKPK": "2.00V", + } + assert stdout.getvalue() == (b":MEAS:CH1?" b":MEASUrement:CH1?") + + +def test_read_all_measurement_data_short_and_long(): + short_payload = ( + b"\x00\x00\x00\x00" + b'{"CH1":{"FREQuency":"1.00kHz"},"CH2":{"PKPK":"2.00V"}}' + ) + long_payload = ( + b"\x00\x00\x00\x00" + b'{"CH1":{"FREQuency":"1.00kHz"},"CH2":{"PKPK":"2.00V"}}' + ) + scope, stdout = _make_binary_scope( + binary_replies=[short_payload, long_payload], + ) + + assert scope.read_all_measurement_data() == { + 1: {"FREQuency": "1.00kHz"}, + 2: {"PKPK": "2.00V"}, + } + assert scope.read_all_measurement_data(long_form=True) == { + 1: {"FREQuency": "1.00kHz"}, + 2: {"PKPK": "2.00V"}, + } + assert stdout.getvalue() == (b":MEAS?" b":MEASUrement:ALL?") + + +def test_read_screen_bmp(): + bmp_body = b"BM" + struct.pack(" Date: Sun, 8 Mar 2026 21:01:37 +0000 Subject: [PATCH 4/9] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- doc/source/apiref/owon.rst | 1 - 1 file changed, 1 deletion(-) diff --git a/doc/source/apiref/owon.rst b/doc/source/apiref/owon.rst index b364b000..aedc077d 100644 --- a/doc/source/apiref/owon.rst +++ b/doc/source/apiref/owon.rst @@ -13,4 +13,3 @@ OWON .. autoclass:: OWONSDS1104 :members: :undoc-members: - From 9c8dd59d15c4704b8410c53dee3cdfc5af585a22 Mon Sep 17 00:00:00 2001 From: Sergey Lukin Date: Mon, 9 Mar 2026 00:24:03 +0100 Subject: [PATCH 5/9] Add missing DC310S name fallback coverage --- tests/test_kiprim/test_dc310s.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/test_kiprim/test_dc310s.py b/tests/test_kiprim/test_dc310s.py index ae318623..98d3617e 100644 --- a/tests/test_kiprim/test_dc310s.py +++ b/tests/test_kiprim/test_dc310s.py @@ -27,6 +27,11 @@ def test_name(): assert psu.name == "KIPRIM DC310S" +def test_name_single_field_reply(): + with expected_protocol(ik.kiprim.DC310S, ["*IDN?"], ["DC310S"], sep="\n") as psu: + assert psu.name == "DC310S" + + def test_voltage(): with expected_protocol( ik.kiprim.DC310S, ["VOLT 5.000", "VOLT?"], ["5.000"], sep="\n" From d6a9ed90c3bb43f1176237296b9a5281d8026f40 Mon Sep 17 00:00:00 2001 From: Sergey Lukin Date: Fri, 20 Mar 2026 23:03:51 +0100 Subject: [PATCH 6/9] OWON SDS1104 oscilloscope driver USB connection bugfixes --- src/instruments/owon/sds1104.py | 87 ++++++++++++++++++++++++++++----- 1 file changed, 74 insertions(+), 13 deletions(-) diff --git a/src/instruments/owon/sds1104.py b/src/instruments/owon/sds1104.py index 3594c168..d7dff9f5 100644 --- a/src/instruments/owon/sds1104.py +++ b/src/instruments/owon/sds1104.py @@ -13,9 +13,15 @@ import json import re import struct +import time from typing import Any import usb.core +import usb.util +try: + import libusb_package +except ImportError: # pragma: no cover - optional runtime helper + libusb_package = None from instruments.abstract_instruments import Oscilloscope from instruments.abstract_instruments.comm import USBCommunicator @@ -78,18 +84,18 @@ 100e-6: "100us", 200e-6: "200us", 500e-6: "500us", - 1e-3: "1ms", - 2e-3: "2ms", - 5e-3: "5ms", + 1e-3: "1.0ms", + 2e-3: "2.0ms", + 5e-3: "5.0ms", 10e-3: "10ms", 20e-3: "20ms", 50e-3: "50ms", 100e-3: "100ms", 200e-3: "200ms", 500e-3: "500ms", - 1.0: "1s", - 2.0: "2s", - 5.0: "5s", + 1.0: "1.0s", + 2.0: "2.0s", + 5.0: "5.0s", 10.0: "10s", 20.0: "20s", 50.0: "50s", @@ -461,7 +467,17 @@ def _sendcmd(self, msg): def _query(self, msg, size=-1): self._sendcmd(msg) - return self.read_binary(size).decode("utf-8") + return self.read(size, encoding="utf-8") + + def close(self): + """ + Close the communicator without forcing a USB device reset. + + Resetting the OWON scope on every close can trigger a full device + re-enumeration on Windows and is unnecessarily disruptive during probe + attempts that fail partway through initialization. + """ + usb.util.dispose_resources(self._dev) @dataclass(frozen=True) @@ -804,32 +820,77 @@ def __init__(self, filelike): super().__init__(filelike) self._file.timeout = 1 * u.second + def close(self): + """ + Closes the underlying transport if it exposes a close method. + """ + close_method = getattr(self._file, "close", None) + if callable(close_method): + close_method() + @classmethod - def open_usb(cls, vid=DEFAULT_USB_VID, pid=DEFAULT_USB_PID, timeout=1 * u.second): + def open_usb( + cls, + vid=DEFAULT_USB_VID, + pid=DEFAULT_USB_PID, + timeout=1 * u.second, + enable_scpi=True, + ignore_scpi_failure=True, + settle_time=0.25, + ): """ Opens an SDS1104-family scope using the default raw USB VID/PID. A best-effort OWON-family SCPI enable handshake is attempted after the communicator is opened. """ - dev = usb.core.find(idVendor=vid, idProduct=pid) + backend = None + if libusb_package is not None: + try: + backend = libusb_package.get_libusb1_backend() + except Exception: + backend = None + + dev = usb.core.find(idVendor=vid, idProduct=pid, backend=backend) if dev is None: raise OSError("No such device found.") inst = cls(_OWONPromptUSBCommunicator(dev)) inst.timeout = assume_units(timeout, u.second) - inst._enable_scpi_mode() + inst._file.flush_input() + time.sleep(0.1) + if enable_scpi: + ok = inst.ensure_scpi_mode(strict=not ignore_scpi_failure, settle_time=settle_time) + if not ok and not ignore_scpi_failure: + inst.close() + raise OSError("OWON SDS1104 SCPI enable handshake failed.") return inst - def _enable_scpi_mode(self): + def ensure_scpi_mode(self, strict=False, settle_time=0.25): """ Best-effort OWON-family SCPI enable handshake. """ + original_timeout = self.timeout try: + self._file.flush_input() self._file.write_raw(b":SDSLSCPI#") - return _clean_reply(self._file.read()) == ":SCPION" - except OSError: + time.sleep(max(float(settle_time), 0.0)) + self.timeout = 0.5 * u.second + reply = self._file.read() + self._file.flush_input() + return ":SCPION" in _clean_reply(reply) + except (usb.core.USBTimeoutError, usb.core.USBError, OSError): + if strict: + raise return False + finally: + self.timeout = original_timeout + + def _enable_scpi_mode(self, settle_time=0.25): + """ + Backward-compatible alias for the public SCPI-mode helper. + """ + return self.ensure_scpi_mode(strict=False, settle_time=settle_time) def _binary_query(self, command): """ From 8a434fec8d32aad91ab34361e2320a948767edb5 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 20 Mar 2026 22:05:08 +0000 Subject: [PATCH 7/9] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/instruments/owon/sds1104.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/instruments/owon/sds1104.py b/src/instruments/owon/sds1104.py index d7dff9f5..683e1bcd 100644 --- a/src/instruments/owon/sds1104.py +++ b/src/instruments/owon/sds1104.py @@ -18,6 +18,7 @@ import usb.core import usb.util + try: import libusb_package except ImportError: # pragma: no cover - optional runtime helper @@ -860,7 +861,9 @@ def open_usb( inst._file.flush_input() time.sleep(0.1) if enable_scpi: - ok = inst.ensure_scpi_mode(strict=not ignore_scpi_failure, settle_time=settle_time) + ok = inst.ensure_scpi_mode( + strict=not ignore_scpi_failure, settle_time=settle_time + ) if not ok and not ignore_scpi_failure: inst.close() raise OSError("OWON SDS1104 SCPI enable handshake failed.") From 0d5f2418c3c221c5e899e4c646f90c930a2b54d8 Mon Sep 17 00:00:00 2001 From: Sergey Lukin Date: Fri, 3 Apr 2026 12:19:06 +0200 Subject: [PATCH 8/9] Refactor OWON DOS1104 example runner, stabilize examples, and keep deep-memory-all as the supported path --- .gitignore | 3 + doc/examples/_owon_capture_common.py | 616 ++++++++ doc/examples/_owon_capture_debug.py | 1402 +++++++++++++++++ doc/examples/_owon_capture_stable.py | 386 +++++ doc/examples/ex_owon_sds1104.py | 38 +- doc/examples/owon_esp32_trigger_jig.md | 301 ++++ .../owon_esp32_trigger_jig_pio/.gitignore | 5 + .../owon_esp32_trigger_jig_pio/platformio.ini | 11 + .../owon_esp32_trigger_jig_pio/src/main.cpp | 214 +++ doc/examples/owon_esp32_trigger_jig_runner.py | 293 ++++ doc/examples/owon_runner_refactor_20260402.md | 140 ++ doc/examples/owon_safe_scope_smoke.py | 418 +++++ doc/examples/owon_scope_state_dump.py | 227 +++ doc/examples/scope.html | 578 +++++++ doc/owon/DOS1104_SCPI_Command_Matrix.md | 399 +++++ .../SDS1000_Oscilloscopes_SCPI_Protocol.pdf | Bin 0 -> 477213 bytes pyproject.toml | 5 + src/instruments/owon/__init__.py | 1 + src/instruments/owon/sds1104.py | 1204 +++++++++++++- tests/test_owon/test_sds1104.py | 906 ++++++++++- tests/test_owon/test_sds1104_hardware.py | 551 +++++++ 21 files changed, 7564 insertions(+), 134 deletions(-) create mode 100644 doc/examples/_owon_capture_common.py create mode 100644 doc/examples/_owon_capture_debug.py create mode 100644 doc/examples/_owon_capture_stable.py create mode 100644 doc/examples/owon_esp32_trigger_jig.md create mode 100644 doc/examples/owon_esp32_trigger_jig_pio/.gitignore create mode 100644 doc/examples/owon_esp32_trigger_jig_pio/platformio.ini create mode 100644 doc/examples/owon_esp32_trigger_jig_pio/src/main.cpp create mode 100644 doc/examples/owon_esp32_trigger_jig_runner.py create mode 100644 doc/examples/owon_runner_refactor_20260402.md create mode 100644 doc/examples/owon_safe_scope_smoke.py create mode 100644 doc/examples/owon_scope_state_dump.py create mode 100644 doc/examples/scope.html create mode 100644 doc/owon/DOS1104_SCPI_Command_Matrix.md create mode 100644 doc/owon/SDS1000_Oscilloscopes_SCPI_Protocol.pdf create mode 100644 tests/test_owon/test_sds1104_hardware.py diff --git a/.gitignore b/.gitignore index dfa829df..cbba33c1 100644 --- a/.gitignore +++ b/.gitignore @@ -69,3 +69,6 @@ coverage.xml # version file generated by setuptools_scm src/instruments/_version.py +doc/examples/artifacts/ +InstrumentKit_1.zip +InstrumentKit_2.zip diff --git a/doc/examples/_owon_capture_common.py b/doc/examples/_owon_capture_common.py new file mode 100644 index 00000000..7755c62d --- /dev/null +++ b/doc/examples/_owon_capture_common.py @@ -0,0 +1,616 @@ +#!/usr/bin/env python +""" +Shared helpers for OWON DOS1104 example capture scripts. +""" + +from __future__ import annotations + +import csv +from dataclasses import dataclass +from datetime import datetime, timezone +import json +from pathlib import Path +from typing import Any +import time + +import serial + +import instruments as ik +import instruments.owon.sds1104 as owon_sds1104 +from instruments.units import ureg as u + + +def _timestamp(): + return datetime.now(timezone.utc) + + +def _artifact_dir(root, label, timestamp=None): + timestamp = _timestamp() if timestamp is None else timestamp + date_dir = Path(root) / timestamp.strftime("%Y%m%d") + return date_dir / f"{timestamp.strftime('%Y%m%d_%H%M%S')}_{label}" + + +def _clean_reply(text): + cleaned = str(text).strip() + if cleaned.endswith("->"): + cleaned = cleaned[:-2].rstrip() + return cleaned + + +def _format_token(value, base_unit, units): + quantity = u.Quantity(value, base_unit) if not hasattr(value, "to") else value + quantity = quantity.to(base_unit) + for scale, suffix in units: + scaled = quantity.to(scale).magnitude + if abs(scaled) >= 1 or suffix == units[-1][1]: + text = f"{scaled:.12f}".rstrip("0").rstrip(".") + if text == "-0": + text = "0" + return f"{text}{suffix}" + raise ValueError(f"Could not format token for {value!r}") + + +def _format_time_token(value): + return _format_token( + value, + u.second, + [ + (u.microsecond, "us"), + (u.millisecond, "ms"), + (u.second, "s"), + ], + ) + + +def _format_voltage_token(value): + return _format_token( + value, + u.volt, + [ + (u.millivolt, "mV"), + (u.volt, "V"), + ], + ) + + +@dataclass +class WaveformSummary: + channel: int + sample_count: int + time_start_s: float + time_end_s: float + voltage_min_v: float + voltage_max_v: float + voltage_pp_v: float + csv_path: str + + +@dataclass +class RawWaveformSummary: + channel: int + sample_count: int + raw_min: int + raw_max: int + raw_first_16: list[int] + metadata_scale: str + metadata_probe: str + metadata_offset: Any + + +@dataclass +class TraceContext: + scpi_trace_path: str | None + state_trace_path: str | None + state_probe_style: str = "full" + state_seq: int = 0 + + +def _format_sample_rate_text(x_values): + if len(x_values) < 2: + return "unknown" + dt = abs(x_values[1] - x_values[0]) + if dt <= 0: + return "unknown" + sample_rate = 1.0 / dt + + def _fmt(value): + return f"{value:.6f}".rstrip("0").rstrip(".") + + if sample_rate >= 0.9995e9: + return f"{_fmt(sample_rate / 1e9)}GS/s" + if sample_rate >= 0.9995e6: + return f"{_fmt(sample_rate / 1e6)}MS/s" + if sample_rate >= 0.9995e3: + return f"{_fmt(sample_rate / 1e3)}kS/s" + return f"{_fmt(sample_rate)}S/s" + + +def _write_jsonl(path, payload): + if path is None: + return + with Path(path).open("a", encoding="utf-8") as handle: + handle.write(json.dumps(payload, ensure_ascii=True) + "\n") + + +def _make_trace_context(out_dir, args): + trace_root = Path(args.trace_dir) / out_dir.name if args.trace_dir else out_dir + trace_root.mkdir(parents=True, exist_ok=True) + return TraceContext( + scpi_trace_path=( + str(trace_root / "scpi_trace.jsonl") if args.trace_scpi else None + ), + state_trace_path=( + str(trace_root / "state_trace.jsonl") if args.trace_state else None + ), + state_probe_style=args.state_probe_style, + ) + + +def _metadata_sample_rate_text(metadata): + sample = metadata.get("SAMPLE") if isinstance(metadata, dict) else None + if not isinstance(sample, dict): + return None + raw = sample.get("SAMPLERATE") + if raw in {None, ""}: + return None + return _clean_reply(str(raw)).replace("(", "").replace(")", "") + + +def _open_scope(args): + deadline = time.monotonic() + args.scope_open_retry_s + last_exc = None + while time.monotonic() < deadline: + try: + strict_open = bool(getattr(args, "strict_open_scpi", False)) + return ik.owon.OWONSDS1104.open_usb( + vid=int(args.scope_vid, 0), + pid=int(args.scope_pid, 0), + timeout=args.scope_timeout_s * u.second, + enable_scpi=(args.enable_scpi or strict_open), + ignore_scpi_failure=not strict_open, + settle_time=args.scope_settle_s, + ) + except Exception as exc: # pragma: no cover - bench retry path + last_exc = exc + time.sleep(0.5) + raise RuntimeError(f"Scope open failed: {last_exc}") + + +def _open_serial(args): + return serial.Serial( + port=args.esp_port, + baudrate=args.esp_baud, + timeout=0.2, + write_timeout=0.2, + ) + + +def _configure_esp_from_args(args): + with _open_serial(args) as port: + return _configure_esp(port, args) + + +def _serial_write_line(port, line): + port.write((line + "\n").encode("utf-8")) + port.flush() + + +def _serial_drain(port, duration_s): + deadline = time.monotonic() + max(duration_s, 0.0) + lines = [] + while time.monotonic() < deadline: + raw = port.readline() + if raw: + lines.append(raw.decode("utf-8", errors="replace").rstrip()) + else: + time.sleep(0.01) + return lines + + +def _configure_esp(port, args): + _serial_write_line(port, "status") + if args.pulse_mode == "burst": + _serial_write_line(port, "burst") + else: + _serial_write_line(port, f"single {int(args.pulse_width_us)}") + _serial_write_line(port, f"gap {int(args.pulse_gap_us)}") + _serial_write_line(port, f"frame {int(args.pulse_frame_us)}") + _serial_write_line(port, f"half {int(args.slope_half_period_us)}") + return _serial_drain(port, 0.5) + + +def _capture_screenshot(scope, out_dir, args): + bmp_path = out_dir / f"{args.profile}_{args.arm}_scope_screen.bmp" + bmp_path.write_bytes(scope.read_screen_bmp()) + return bmp_path + + +def _capture_waveform(scope, channel, out_dir): + x_axis, y_axis = scope.read_waveform(channel) + csv_path = out_dir / f"ch{channel}_waveform.csv" + + x_values = [float(value) for value in x_axis] + y_values = [float(value) for value in y_axis] + + with csv_path.open("w", encoding="utf-8", newline="") as handle: + writer = csv.writer(handle) + writer.writerow(["time_s", "voltage_v"]) + writer.writerows(zip(x_values, y_values, strict=True)) + + v_min = min(y_values) + v_max = max(y_values) + return WaveformSummary( + channel=channel, + sample_count=len(y_values), + time_start_s=x_values[0], + time_end_s=x_values[-1], + voltage_min_v=v_min, + voltage_max_v=v_max, + voltage_pp_v=v_max - v_min, + csv_path=str(csv_path), + ) + + +def _capture_screen_channel_raw(scope, metadata, channel, out_dir): + point_count = scope._waveform_point_count(metadata) # pylint: disable=protected-access + payload = scope._binary_query_exact( # pylint: disable=protected-access + f":DATA:WAVE:SCREen:CH{channel}?", 4 + 2 * point_count + ) + raw_adc = owon_sds1104._parse_waveform_adc( # pylint: disable=protected-access + owon_sds1104._strip_packet_prefix(payload, f"screen waveform CH{channel}"), # pylint: disable=protected-access + f"screen waveform CH{channel}", + ) + x_axis = scope._waveform_time_axis(metadata, point_count) # pylint: disable=protected-access + y_axis = scope._waveform_voltage_axis(metadata, channel, raw_adc) # pylint: disable=protected-access + csv_path = out_dir / f"ch{channel}_waveform.csv" + + x_values = [float(value) for value in x_axis] + y_values = [float(value) for value in y_axis] + + with csv_path.open("w", encoding="utf-8", newline="") as handle: + writer = csv.writer(handle) + writer.writerow(["time_s", "voltage_v"]) + writer.writerows(zip(x_values, y_values, strict=True)) + + v_min = min(y_values) + v_max = max(y_values) + summary = WaveformSummary( + channel=channel, + sample_count=len(y_values), + time_start_s=x_values[0], + time_end_s=x_values[-1], + voltage_min_v=v_min, + voltage_max_v=v_max, + voltage_pp_v=v_max - v_min, + csv_path=str(csv_path), + ) + return summary, {"x": x_values, "y": y_values} + + +def _capture_depmem_all_summary_and_series(scope, out_dir): + raw_payload = scope.read_deep_memory_all_raw() + raw_path = out_dir / "depmem_all_raw.bin" + raw_path.write_bytes(raw_payload) + capture = scope._parse_deep_memory_all_payload(raw_payload) # pylint: disable=protected-access + summary = { + "metadata": { + "timebase_scale": capture.metadata.get("TIMEBASE", {}).get("SCALE"), + "hoffset": capture.metadata.get("TIMEBASE", {}).get("HOFFSET"), + "datalen": capture.metadata.get("SAMPLE", {}).get("DATALEN"), + "sample_rate": capture.metadata.get("SAMPLE", {}).get("SAMPLERATE"), + "depmem": capture.metadata.get("SAMPLE", {}).get("DEPMEM"), + "screenoffset": capture.metadata.get("SAMPLE", {}).get("SCREENOFFSET"), + "runstatus": capture.metadata.get("RUNSTATUS"), + }, + "channels": { + f"CH{channel}": { + "samples": len(raw_values), + "raw_min": int(min(raw_values)), + "raw_max": int(max(raw_values)), + "first_16": [int(value) for value in list(raw_values)[:16]], + } + for channel, raw_values in capture.raw_channels.items() + }, + } + summary_path = out_dir / "depmem_all_summary.json" + _safe_json_write(summary_path, summary) + + series = {} + for channel, raw_values in capture.raw_channels.items(): + x_axis = scope._waveform_time_axis(capture.metadata, len(raw_values)) # pylint: disable=protected-access + y_axis = scope._waveform_voltage_axis(capture.metadata, channel, raw_values) # pylint: disable=protected-access + series[channel] = { + "x": [float(value) for value in x_axis], + "y": [float(value) for value in y_axis], + } + return summary, summary_path, raw_path, series + + +def _safe_json_write(path, payload): + path.write_text(json.dumps(payload, indent=2), encoding="utf-8") + + +def _build_scope_html_data_from_series( + args, + scope_state, + waveform_data, + memory_depth_text=None, + sample_rate_text=None, +): + first_channel = next(iter(waveform_data.values()), {"x": [0.0, 1.0], "y": []}) + time_window_s = None + if len(first_channel["x"]) >= 2: + time_window_s = abs(float(first_channel["x"][-1]) - float(first_channel["x"][0])) + if sample_rate_text is None: + sample_rate_text = _format_sample_rate_text(first_channel["x"]) + if memory_depth_text is None: + memory_depth_text = str(len(first_channel["y"])) if first_channel["y"] else "unknown" + + status_text = str(scope_state.get("trigger_status", "STOP")) + if "." in status_text: + status_text = status_text.split(".")[-1].upper() + + if args.profile == "edge": + trigger_source = args.edge_source.upper() + trigger_coupling = args.edge_coupling.upper() + trigger_edge = "falling" if args.edge_slope.lower() == "fall" else "rising" + elif args.profile == "pulse": + trigger_source = args.pulse_source.upper() + trigger_coupling = args.pulse_coupling.upper() + trigger_edge = "falling" if args.pulse_dir.upper() == "NEG" else "rising" + else: + trigger_source = args.slope_source.upper() + trigger_coupling = "DC" + trigger_edge = "falling" if args.slope_edge.upper() == "NEG" else "rising" + + return { + "meta": { + "status": status_text.title() if status_text == "STOP" else status_text, + "timebase_s_div": args.timebase_s_div, + "time_window_s": time_window_s, + "sample_rate_text": sample_rate_text, + "memory_depth_text": memory_depth_text, + }, + "trigger": { + "source": trigger_source, + "coupling": trigger_coupling, + "edge": trigger_edge, + "level_v": args.trigger_level_v, + "horizontal_pos_s": 0.0, + }, + "channels": { + "CH1": { + "visible": 1 in args.capture_channels, + "volts_per_div": args.ch1_scale_v_div, + "position_div": args.ch1_position_div, + "color": "#ffff00", + "data": waveform_data.get(1, {}).get("y", []), + "time_s": waveform_data.get(1, {}).get("x", []), + }, + "CH2": { + "visible": 2 in args.capture_channels, + "volts_per_div": args.ch2_scale_v_div, + "position_div": args.ch2_position_div, + "color": "#00ffff", + "data": waveform_data.get(2, {}).get("y", []), + "time_s": waveform_data.get(2, {}).get("x", []), + }, + "CH3": { + "visible": 3 in args.capture_channels, + "volts_per_div": args.ch3_scale_v_div, + "position_div": args.ch3_position_div, + "color": "#ff8800", + "data": waveform_data.get(3, {}).get("y", []), + "time_s": waveform_data.get(3, {}).get("x", []), + }, + }, + } + + +def _build_scope_html_data(args, scope_state, waveforms): + waveform_data = {} + for summary in waveforms: + with Path(summary.csv_path).open(newline="", encoding="utf-8") as handle: + rows = list(csv.DictReader(handle)) + waveform_data[summary.channel] = { + "x": [float(row["time_s"]) for row in rows], + "y": [float(row["voltage_v"]) for row in rows], + } + return _build_scope_html_data_from_series(args, scope_state, waveform_data) + + +def _render_scope_html_with_data(out_dir, scope_data, stem): + template_path = Path(__file__).resolve().parent / "scope.html" + template = template_path.read_text(encoding="utf-8") + template = template.replace("scope_view_data.js", f"{stem}_data.js") + template = template.replace("scope_view.json", f"{stem}.json") + html_path = out_dir / f"{stem}.html" + json_path = out_dir / f"{stem}.json" + js_path = out_dir / f"{stem}_data.js" + html_path.write_text(template, encoding="utf-8") + json_path.write_text(json.dumps(scope_data, indent=2), encoding="utf-8") + js_path.write_text( + "window.__SCOPE_DATA__ = " + json.dumps(scope_data) + ";\n", encoding="utf-8" + ) + return html_path, json_path + + +def _render_scope_html(out_dir, args, scope_state, waveforms, sample_rate_text=None): + scope_data = _build_scope_html_data(args, scope_state, waveforms) + if sample_rate_text is not None: + scope_data["meta"]["sample_rate_text"] = sample_rate_text + return _render_scope_html_with_data(out_dir, scope_data, "scope_view") + + +def _max_abs_delta(values_a, values_b): + if not values_a or not values_b: + return 0.0 + return max(abs(float(a) - float(b)) for a, b in zip(values_a, values_b, strict=True)) + + +def _mean_abs_delta(values_a, values_b): + if not values_a or not values_b: + return 0.0 + deltas = [abs(float(a) - float(b)) for a, b in zip(values_a, values_b, strict=True)] + return sum(deltas) / len(deltas) + + +def _align_waveform_series(series_a, series_b): + x_a = list(series_a["x"]) + y_a = list(series_a["y"]) + x_b = list(series_b["x"]) + y_b = list(series_b["y"]) + if len(y_a) == len(y_b): + return { + "alignment": "exact", + "x_a": x_a, + "y_a": y_a, + "x_b": x_b, + "y_b": y_b, + } + + if abs(len(y_a) - len(y_b)) == 1: + if len(y_a) > len(y_b): + candidates = [ + ("drop_first_a", x_a[1:], y_a[1:], x_b, y_b), + ("drop_last_a", x_a[:-1], y_a[:-1], x_b, y_b), + ] + else: + candidates = [ + ("drop_first_b", x_a, y_a, x_b[1:], y_b[1:]), + ("drop_last_b", x_a, y_a, x_b[:-1], y_b[:-1]), + ] + best = min( + candidates, + key=lambda item: ( + _max_abs_delta(item[2], item[4]), + _mean_abs_delta(item[2], item[4]), + ), + ) + return { + "alignment": best[0], + "x_a": list(best[1]), + "y_a": list(best[2]), + "x_b": list(best[3]), + "y_b": list(best[4]), + } + + shared = min(len(y_a), len(y_b)) + return { + "alignment": f"prefix_trim_to_{shared}", + "x_a": x_a[:shared], + "y_a": y_a[:shared], + "x_b": x_b[:shared], + "y_b": y_b[:shared], + } + + +def _compare_waveform_series(name_a, series_a, name_b, series_b): + aligned = _align_waveform_series(series_a, series_b) + delta_samples = [ + abs(float(a) - float(b)) + for a, b in zip(aligned["y_a"], aligned["y_b"], strict=True) + ] + return { + "series_a": name_a, + "series_b": name_b, + "count_a": len(series_a["y"]), + "count_b": len(series_b["y"]), + "aligned_count": len(aligned["y_a"]), + "alignment": aligned["alignment"], + "max_abs_voltage_delta_v": max(delta_samples) if delta_samples else 0.0, + "mean_abs_voltage_delta_v": sum(delta_samples) / len(delta_samples) + if delta_samples + else 0.0, + "diff_sample_count": sum(1 for delta in delta_samples if delta > 1e-9), + "max_abs_time_delta_s": _max_abs_delta(aligned["x_a"], aligned["x_b"]), + "voltage_pp_a_v": ( + max(series_a["y"]) - min(series_a["y"]) if series_a["y"] else 0.0 + ), + "voltage_pp_b_v": ( + max(series_b["y"]) - min(series_b["y"]) if series_b["y"] else 0.0 + ), + } + + +def _render_waveform_comparison_html(out_dir, comparison): + html_path = out_dir / "waveform_comparison.html" + lines = [ + "", + '', + "", + '', + '', + "Waveform Comparison", + "", + "", + "", + "

Waveform Comparison

", + '

BMP is the ground-truth screen capture. The HTML views below render the public waveform APIs grouped by acquisition family.

', + ] + if comparison.get("bmp_name"): + lines.extend( + [ + "

Reference BMP

", + f'
Scope BMP
', + ] + ) + + lines.extend( + [ + "

Numeric Comparison

", + "", + "", + "", + ] + ) + for family, channels in comparison.get("families", {}).items(): + for channel_name, metrics in channels.items(): + lines.append( + "" + f"" + f"" + f"" + f"" + f"" + f"" + f"" + f"" + f"" + f"" + "" + ) + lines.extend(["", "
FamilyChannelABCountsAlignmentMean |dV|Max |dV|Diff samplesMax |dt|
{family}{channel_name}{metrics['series_a']}{metrics['series_b']}{metrics['count_a']} vs {metrics['count_b']} (shared {metrics['aligned_count']}){metrics['alignment']}{metrics['mean_abs_voltage_delta_v']:.6g} V{metrics['max_abs_voltage_delta_v']:.6g} V{metrics['diff_sample_count']}{metrics['max_abs_time_delta_s']:.6g} s
"]) + + views = comparison.get("views", []) + if views: + lines.extend(["

Rendered Views

", '
']) + for view in views: + lines.extend( + [ + '
', + f"

{view['title']}

", + f'

Open HTML | Open JSON

', + f'', + "
", + ] + ) + lines.append("
") + + lines.extend(["", ""]) + html_path.write_text("\n".join(lines) + "\n", encoding="utf-8") + return html_path diff --git a/doc/examples/_owon_capture_debug.py b/doc/examples/_owon_capture_debug.py new file mode 100644 index 00000000..9981a374 --- /dev/null +++ b/doc/examples/_owon_capture_debug.py @@ -0,0 +1,1402 @@ +#!/usr/bin/env python +""" +Debug and validation helpers for the OWON ESP32 trigger jig runner. +""" + +from __future__ import annotations + +import argparse +from dataclasses import asdict +import json +from pathlib import Path +import time + +import usb.core + +import instruments as ik +from instruments.units import ureg as u + +from _owon_capture_common import ( + RawWaveformSummary, + _build_scope_html_data_from_series, + _capture_screenshot, + _capture_waveform, + _clean_reply, + _compare_waveform_series, + _format_time_token, + _format_voltage_token, + _make_trace_context, + _metadata_sample_rate_text, + _open_scope, + _render_scope_html, + _render_scope_html_with_data, + _render_waveform_comparison_html, + _safe_json_write, + _timestamp, + _write_jsonl, +) + + +def _raw_waveform_summary(scope, channel): + metadata = scope.read_waveform_metadata() + point_count = scope._waveform_point_count(metadata) # pylint: disable=protected-access + payload = scope._binary_query_exact( # pylint: disable=protected-access + f":DATA:WAVE:SCREEN:CH{channel}?", + 4 + 2 * point_count, + ) + raw_adc = ik.owon.sds1104._parse_waveform_adc( # pylint: disable=protected-access + ik.owon.sds1104._strip_packet_prefix(payload, f"screen waveform CH{channel}"), + f"screen waveform CH{channel}", + ) + raw_list = [int(value) for value in raw_adc] + channel_meta = metadata["CHANNEL"][channel - 1] + return RawWaveformSummary( + channel=channel, + sample_count=len(raw_list), + raw_min=min(raw_list), + raw_max=max(raw_list), + raw_first_16=raw_list[:16], + metadata_scale=str(channel_meta.get("SCALE")), + metadata_probe=str(channel_meta.get("PROBE")), + metadata_offset=channel_meta.get("OFFSET"), + ), metadata + + +def _summarize_numeric_axis(values): + values = [float(value) for value in values] + return { + "count": len(values), + "min": min(values), + "max": max(values), + "pp": max(values) - min(values), + "first_16": values[:16], + } + + +def _capture_waveform_truth( + scope, out_dir, args, scope_state, capture_channels, deep_first=False +): + truth = { + "screen_bmp_path": None, + "screen_metadata_path": None, + "screen_metadata": None, + "screen_waveforms": {}, + "screen_channel_alias_waveforms": {}, + "deep_memory_metadata_path": None, + "deep_memory_metadata": None, + "deep_memory_channels": {}, + "deep_memory_bundle": None, + "errors": [], + } + screen_series = {} + screen_alias_series = {} + deep_channel_series = {} + deep_bundle_series = {} + screen_metadata = None + deep_metadata = None + comparison = { + "bmp_name": None, + "views": [], + "families": {}, + } + + try: + deep_metadata = scope.read_deep_memory_metadata() + deep_metadata_path = out_dir / "deep_memory_metadata.json" + _safe_json_write(deep_metadata_path, deep_metadata) + truth["deep_memory_metadata_path"] = str(deep_metadata_path) + truth["deep_memory_metadata"] = deep_metadata + except Exception as exc: + truth["errors"].append(f"read_deep_memory_metadata: {exc}") + deep_metadata = None + + for channel in capture_channels: + try: + x_axis, y_axis = scope.read_deep_memory_channel(channel) + x_values = [float(value) for value in x_axis] + y_values = [float(value) for value in y_axis] + truth["deep_memory_channels"][f"CH{channel}"] = { + "time": _summarize_numeric_axis(x_values), + "voltage": _summarize_numeric_axis(y_values), + } + deep_channel_series[channel] = {"x": x_values, "y": y_values} + except Exception as exc: + truth["errors"].append(f"read_deep_memory_channel({channel}): {exc}") + + try: + bundle = scope.read_deep_memory_all() + truth["deep_memory_bundle"] = { + "metadata_keys": sorted(bundle.metadata.keys()), + "raw_channels": { + f"CH{channel}": { + "count": len(raw_values), + "min": int(min(raw_values)), + "max": int(max(raw_values)), + "first_16": [int(value) for value in list(raw_values)[:16]], + } + for channel, raw_values in bundle.raw_channels.items() + }, + } + converted_channels = {} + for channel, raw_values in bundle.raw_channels.items(): + x_axis = scope._waveform_time_axis(bundle.metadata, len(raw_values)) # pylint: disable=protected-access + y_axis = scope._waveform_voltage_axis(bundle.metadata, channel, raw_values) # pylint: disable=protected-access + x_values = [float(value) for value in x_axis] + y_values = [float(value) for value in y_axis] + converted_channels[f"CH{channel}"] = { + "time": _summarize_numeric_axis(x_values), + "voltage": _summarize_numeric_axis(y_values), + } + deep_bundle_series[channel] = {"x": x_values, "y": y_values} + truth["deep_memory_bundle"]["converted_channels"] = converted_channels + except Exception as exc: + truth["errors"].append(f"read_deep_memory_all: {exc}") + + if not deep_first: + bmp_path = out_dir / "waveform_truth_screen.bmp" + bmp_path.write_bytes(scope.read_screen_bmp()) + truth["screen_bmp_path"] = str(bmp_path) + comparison["bmp_name"] = bmp_path.name + + try: + screen_metadata = scope.read_waveform_metadata() + screen_metadata_path = out_dir / "screen_waveform_metadata.json" + _safe_json_write(screen_metadata_path, screen_metadata) + truth["screen_metadata_path"] = str(screen_metadata_path) + truth["screen_metadata"] = screen_metadata + except Exception as exc: + truth["errors"].append(f"read_waveform_metadata: {exc}") + screen_metadata = None + + for channel in capture_channels: + try: + raw_summary, metadata = _raw_waveform_summary(scope, channel) + truth["screen_waveforms"][f"CH{channel}"] = { + "raw_summary": asdict(raw_summary), + "metadata_scale": str(metadata["CHANNEL"][channel - 1].get("SCALE")), + "metadata_probe": str(metadata["CHANNEL"][channel - 1].get("PROBE")), + "metadata_offset": metadata["CHANNEL"][channel - 1].get("OFFSET"), + } + except Exception as exc: + truth["errors"].append(f"raw screen CH{channel}: {exc}") + + try: + x_axis, y_axis = scope.read_waveform(channel) + x_values = [float(value) for value in x_axis] + y_values = [float(value) for value in y_axis] + truth["screen_waveforms"].setdefault(f"CH{channel}", {}) + truth["screen_waveforms"][f"CH{channel}"]["converted_summary"] = { + "time": _summarize_numeric_axis(x_values), + "voltage": _summarize_numeric_axis(y_values), + } + screen_series[channel] = {"x": x_values, "y": y_values} + except Exception as exc: + truth["errors"].append(f"read_waveform({channel}): {exc}") + + try: + x_axis, y_axis = scope.channel[channel - 1].read_waveform() + x_values = [float(value) for value in x_axis] + y_values = [float(value) for value in y_axis] + truth["screen_channel_alias_waveforms"][f"CH{channel}"] = { + "time": _summarize_numeric_axis(x_values), + "voltage": _summarize_numeric_axis(y_values), + } + screen_alias_series[channel] = {"x": x_values, "y": y_values} + except Exception as exc: + truth["errors"].append(f"channel[{channel - 1}].read_waveform(): {exc}") + + truth_path = out_dir / "waveform_truth.json" + _safe_json_write(truth_path, truth) + + view_specs = [ + ( + "screen_read_waveform_view", + "Screen `read_waveform(channel)`", + screen_series, + screen_metadata, + ), + ( + "screen_channel_alias_view", + "Screen `channel[n].read_waveform()`", + screen_alias_series, + screen_metadata, + ), + ( + "deep_memory_channel_view", + "Deep `read_deep_memory_channel(channel)`", + deep_channel_series, + deep_metadata, + ), + ( + "deep_memory_bundle_view", + "Deep `read_deep_memory_all()` converted from raw bundle", + deep_bundle_series, + deep_metadata, + ), + ] + for stem, title, waveform_data, metadata in view_specs: + if not waveform_data: + continue + first_channel = next(iter(waveform_data.values())) + scope_data = _build_scope_html_data_from_series( + args, + scope_state, + waveform_data, + memory_depth_text=str(len(first_channel["y"])), + sample_rate_text=_metadata_sample_rate_text(metadata), + ) + html_path, json_path = _render_scope_html_with_data(out_dir, scope_data, stem) + comparison["views"].append( + { + "stem": stem, + "title": title, + "html_name": html_path.name, + "json_name": json_path.name, + } + ) + + family_map = { + "screen": ( + "read_waveform(channel)", + screen_series, + "channel[n].read_waveform()", + screen_alias_series, + ), + "deep_memory": ( + "read_deep_memory_channel(channel)", + deep_channel_series, + "read_deep_memory_all()", + deep_bundle_series, + ), + } + for family_name, (name_a, series_a, name_b, series_b) in family_map.items(): + channels = {} + for channel in sorted(set(series_a) & set(series_b)): + channels[f"CH{channel}"] = _compare_waveform_series( + name_a, + series_a[channel], + name_b, + series_b[channel], + ) + if channels: + comparison["families"][family_name] = channels + + comparison_path = out_dir / "waveform_comparison.json" + _safe_json_write(comparison_path, comparison) + comparison_html_path = _render_waveform_comparison_html(out_dir, comparison) + comparison["json_path"] = str(comparison_path) + comparison["html_path"] = str(comparison_html_path) + + return truth, truth_path, comparison + + +def _classify_error(text): + lowered = str(text).lower() + if "timeout" in lowered or "timed out" in lowered: + return "transport_timeout" + if "pipe error" in lowered: + return "transport_pipe_error" + if "length-prefixed body" in lowered or "length mismatch" in lowered: + return "malformed_length_prefix" + if "deep_memory" in lowered or "deep-memory" in lowered or "depmem" in lowered: + return "deep_memory_error" + if "waveform" in lowered or lowered.startswith("ch1:") or lowered.startswith("ch2:"): + return "waveform_read_error" + return "unknown_error" + + +def _is_transport_failure(exc): + if isinstance(exc, (usb.core.USBTimeoutError, usb.core.USBError)): + return True + text = str(exc).lower() + if isinstance(exc, OSError) and ( + "timeout" in text + or "timed out" in text + or "pipe" in text + or "access denied" in text + ): + return True + if isinstance(exc, ValueError) and ( + "length-prefixed" in text + or "payload is too short" in text + or "packet prefix" in text + or "header" in text + ): + return True + return False + + +def _snapshot_scope_state(scope): + def capture(fn): + try: + value = fn() + if hasattr(value, "units"): + return {"value": value.magnitude, "units": str(value.units)} + return str(value) + except Exception as exc: + return f"error: {type(exc).__name__}: {exc}" + + return { + "trigger_status": capture(lambda: scope.trigger_status), + "trigger_type": capture(lambda: scope.trigger_type), + "single_trigger_mode": capture(lambda: scope.single_trigger_mode), + "trigger_sweep": capture(lambda: scope.trigger_sweep), + "timebase_scale": capture(lambda: scope.timebase_scale), + "horizontal_offset": capture(lambda: scope.horizontal_offset), + "memory_depth": capture(lambda: scope.memory_depth), + "acquire_mode": capture(lambda: scope.acquire_mode), + "acquire_averages": capture(lambda: scope.acquire_averages), + "measurement_display_enabled": capture( + lambda: scope.measurement_display_enabled + ), + "raw_edge_level": capture( + lambda: _clean_reply(scope.query(":TRIGger:SINGle:EDGE:LEVel?")) + ), + "trigger_configuration": capture(lambda: scope.read_trigger_configuration()), + } + + +def _snapshot_scope_state_minimal(scope): + def capture(fn): + try: + return str(fn()) + except Exception as exc: + return f"error: {type(exc).__name__}: {exc}" + + return { + "trigger_status": capture(lambda: _clean_reply(scope.query(":TRIGger:STATUS?"))), + "trigger_sweep": capture( + lambda: _clean_reply(scope.query(":TRIGger:SINGle:SWEEp?")) + ), + "raw_edge_level": capture( + lambda: _clean_reply(scope.query(":TRIGger:SINGle:EDGE:LEVel?")) + ), + "timebase_scale": capture(lambda: _clean_reply(scope.query(":HORIzontal:Scale?"))), + "memory_depth": capture(lambda: _clean_reply(scope.query(":ACQUIRE:DEPMEM?"))), + } + + +def _capture_state_for_style(scope, style): + if style == "off": + return None + if style == "minimal": + return _snapshot_scope_state_minimal(scope) + return _snapshot_scope_state(scope) + + +def _record_state_snapshot(trace_ctx, phase, command_label, snapshot, error=None): + if ( + trace_ctx is None + or trace_ctx.state_trace_path is None + or trace_ctx.state_probe_style == "off" + or snapshot is None + ): + return + trace_ctx.state_seq += 1 + payload = { + "ts": _timestamp().isoformat(timespec="microseconds"), + "seq": trace_ctx.state_seq, + "phase": phase, + "command_label": command_label, + "snapshot": snapshot, + } + if error is not None: + payload["error"] = error + _write_jsonl(trace_ctx.state_trace_path, payload) + + +def _apply_state_step(scope, label, trace_ctx, callback, settle_s=0.05): + style = "full" if trace_ctx is None else trace_ctx.state_probe_style + _record_state_snapshot( + trace_ctx, "before", label, _capture_state_for_style(scope, style) + ) + try: + result = callback() + except Exception as exc: + _record_state_snapshot( + trace_ctx, + "error", + label, + _capture_state_for_style(scope, style), + error=f"{type(exc).__name__}: {exc}", + ) + raise + _record_state_snapshot( + trace_ctx, "after", label, _capture_state_for_style(scope, style) + ) + time.sleep(max(float(settle_s), 0.0)) + _record_state_snapshot( + trace_ctx, + "after_settle", + label, + _capture_state_for_style(scope, style), + ) + return result + + +def _apply_channel_setup(scope, args, trace_ctx): + for channel in range(1, 5): + _apply_state_step( + scope, + f"channel[{channel}].display = {channel in args.capture_channels}", + trace_ctx, + lambda channel=channel: setattr( + scope.channel[channel - 1], + "display", + channel in args.capture_channels, + ), + ) + + _apply_state_step( + scope, + f"channel[1].probe_attenuation = {int(args.ch1_probe)}", + trace_ctx, + lambda: setattr(scope.channel[0], "probe_attenuation", int(args.ch1_probe)), + ) + _apply_state_step( + scope, + f"channel[2].probe_attenuation = {int(args.ch2_probe)}", + trace_ctx, + lambda: setattr(scope.channel[1], "probe_attenuation", int(args.ch2_probe)), + ) + if 3 in args.capture_channels: + _apply_state_step( + scope, + f"channel[3].probe_attenuation = {int(args.ch3_probe)}", + trace_ctx, + lambda: setattr(scope.channel[2], "probe_attenuation", int(args.ch3_probe)), + ) + + if args.ch1_scale_v_div is not None: + _apply_state_step( + scope, + f"channel[1].scale = {args.ch1_scale_v_div} V/div", + trace_ctx, + lambda: setattr(scope.channel[0], "scale", args.ch1_scale_v_div * u.volt), + ) + if args.ch2_scale_v_div is not None: + _apply_state_step( + scope, + f"channel[2].scale = {args.ch2_scale_v_div} V/div", + trace_ctx, + lambda: setattr(scope.channel[1], "scale", args.ch2_scale_v_div * u.volt), + ) + if args.ch3_scale_v_div is not None and 3 in args.capture_channels: + _apply_state_step( + scope, + f"channel[3].scale = {args.ch3_scale_v_div} V/div", + trace_ctx, + lambda: setattr(scope.channel[2], "scale", args.ch3_scale_v_div * u.volt), + ) + + _apply_state_step( + scope, + f"channel[1].position = {float(args.ch1_position_div)}", + trace_ctx, + lambda: setattr(scope.channel[0], "position", float(args.ch1_position_div)), + ) + _apply_state_step( + scope, + f"channel[2].position = {float(args.ch2_position_div)}", + trace_ctx, + lambda: setattr(scope.channel[1], "position", float(args.ch2_position_div)), + ) + if 3 in args.capture_channels: + _apply_state_step( + scope, + f"channel[3].position = {float(args.ch3_position_div)}", + trace_ctx, + lambda: setattr(scope.channel[2], "position", float(args.ch3_position_div)), + ) + + +def _apply_trigger_common(scope, args, trace_ctx): + sweep_readback = None + if args.trigger_sweep: + try: + _apply_state_step( + scope, + f"trigger_sweep = {args.trigger_sweep}", + trace_ctx, + lambda: setattr( + scope, "trigger_sweep", scope.TriggerSweep(args.trigger_sweep) + ), + ) + sweep_readback = str(scope.trigger_sweep) + except Exception as exc: + sweep_readback = f"error: {exc}" + return sweep_readback + + +def _configure_scope(scope, args, trace_ctx): + _apply_channel_setup(scope, args, trace_ctx) + if args.memory_depth is not None: + _apply_state_step( + scope, + f"memory_depth = {int(args.memory_depth)}", + trace_ctx, + lambda: setattr(scope, "memory_depth", int(args.memory_depth)), + ) + if args.horizontal_offset_div is not None: + _apply_state_step( + scope, + f"horizontal_offset = {float(args.horizontal_offset_div)}", + trace_ctx, + lambda: setattr(scope, "horizontal_offset", float(args.horizontal_offset_div)), + ) + if args.measurement_display == "on": + _apply_state_step( + scope, + "measurement_display_enabled = True", + trace_ctx, + lambda: setattr(scope, "measurement_display_enabled", True), + ) + elif args.measurement_display == "off": + _apply_state_step( + scope, + "measurement_display_enabled = False", + trace_ctx, + lambda: setattr(scope, "measurement_display_enabled", False), + ) + _apply_state_step( + scope, + f"timebase_scale = {args.timebase_s_div}", + trace_ctx, + lambda: setattr(scope, "timebase_scale", args.timebase_s_div * u.second), + ) + _apply_state_step( + scope, + ":TRIGger:TYPE SINGle", + trace_ctx, + lambda: scope.sendcmd(":TRIGger:TYPE SINGle"), + ) + + if args.profile == "edge": + _apply_state_step( + scope, + "single_trigger_mode = EDGE", + trace_ctx, + lambda: setattr(scope, "single_trigger_mode", scope.SingleTriggerMode.edge), + ) + _apply_state_step( + scope, + f"trigger_source = {args.edge_source}", + trace_ctx, + lambda: setattr( + scope, + "trigger_source", + getattr(scope.TriggerSource, args.edge_source.lower()), + ), + ) + _apply_state_step( + scope, + f"trigger_coupling = {args.edge_coupling}", + trace_ctx, + lambda: setattr( + scope, + "trigger_coupling", + getattr(scope.TriggerCoupling, args.edge_coupling.lower()), + ), + ) + _apply_state_step( + scope, + f"trigger_slope = {args.edge_slope}", + trace_ctx, + lambda: setattr( + scope, + "trigger_slope", + getattr(scope.TriggerSlope, args.edge_slope.lower()), + ), + ) + if args.trigger_level_v is not None: + _apply_state_step( + scope, + f"trigger_level = {args.trigger_level_v} V", + trace_ctx, + lambda: setattr(scope, "trigger_level", args.trigger_level_v * u.volt), + ) + elif args.profile == "pulse": + _apply_state_step( + scope, + "single_trigger_mode = PULSe", + trace_ctx, + lambda: setattr(scope, "single_trigger_mode", scope.SingleTriggerMode.pulse), + ) + _apply_state_step( + scope, + f"PULSe:SOURce {args.pulse_source.upper()}", + trace_ctx, + lambda: scope.sendcmd( + f":TRIGger:SINGle:PULSe:SOURce {args.pulse_source.upper()}" + ), + ) + _apply_state_step( + scope, + f"PULSe:SIGN {args.pulse_sign}", + trace_ctx, + lambda: scope.sendcmd(f":TRIGger:SINGle:PULSe:SIGN {args.pulse_sign}"), + ) + _apply_state_step( + scope, + f"PULSe:TIME {args.pulse_trigger_time_us} us", + trace_ctx, + lambda: scope.sendcmd( + f":TRIGger:SINGle:PULSe:TIME {_format_time_token(args.pulse_trigger_time_us * u.microsecond)}" + ), + ) + _apply_state_step( + scope, + f"PULSe:DIR {args.pulse_dir.upper()}", + trace_ctx, + lambda: scope.sendcmd(f":TRIGger:SINGle:PULSe:DIR {args.pulse_dir.upper()}"), + ) + _apply_state_step( + scope, + f"PULSe:COUPling {args.pulse_coupling.upper()}", + trace_ctx, + lambda: scope.sendcmd( + f":TRIGger:SINGle:PULSe:COUPling {args.pulse_coupling.upper()}" + ), + ) + elif args.profile == "slope": + _apply_state_step( + scope, + "single_trigger_mode = SLOPe", + trace_ctx, + lambda: setattr(scope, "single_trigger_mode", scope.SingleTriggerMode.slope), + ) + _apply_state_step( + scope, + f"SLOPe:SOURce {args.slope_source.upper()}", + trace_ctx, + lambda: scope.sendcmd( + f":TRIGger:SINGle:SLOPe:SOURce {args.slope_source.upper()}" + ), + ) + _apply_state_step( + scope, + f"SLOPe:ULevel {args.slope_upper_v} V", + trace_ctx, + lambda: scope.sendcmd( + f":TRIGger:SINGle:SLOPe:ULevel {_format_voltage_token(args.slope_upper_v * u.volt)}" + ), + ) + _apply_state_step( + scope, + f"SLOPe:LLevel {args.slope_lower_v} V", + trace_ctx, + lambda: scope.sendcmd( + f":TRIGger:SINGle:SLOPe:LLevel {_format_voltage_token(args.slope_lower_v * u.volt)}" + ), + ) + _apply_state_step( + scope, + f"SLOPe:SIGN {args.slope_sign}", + trace_ctx, + lambda: scope.sendcmd(f":TRIGger:SINGle:SLOPe:SIGN {args.slope_sign}"), + ) + _apply_state_step( + scope, + f"SLOPe:TIME {args.slope_trigger_time_us} us", + trace_ctx, + lambda: scope.sendcmd( + f":TRIGger:SINGle:SLOPe:TIME {_format_time_token(args.slope_trigger_time_us * u.microsecond)}" + ), + ) + _apply_state_step( + scope, + f"SLOPe:SLOPe {args.slope_edge.upper()}", + trace_ctx, + lambda: scope.sendcmd( + f":TRIGger:SINGle:SLOPe:SLOPe {args.slope_edge.upper()}" + ), + ) + else: + raise ValueError(f"Unsupported profile: {args.profile}") + + _apply_state_step( + scope, + f"trigger_holdoff = {args.trigger_holdoff_ns} ns", + trace_ctx, + lambda: setattr(scope, "trigger_holdoff", args.trigger_holdoff_ns * u.nanosecond), + ) + sweep_readback = _apply_trigger_common(scope, args, trace_ctx) + + if args.arm == "run": + _apply_state_step(scope, "run()", trace_ctx, scope.run) + elif args.arm == "single": + _apply_state_step( + scope, + f"single(stop_first={args.single_stop_first}, arm_method={args.arm_method})", + trace_ctx, + lambda: scope.single( + stop_first=args.single_stop_first, + arm_method=args.arm_method, + ), + ) + elif args.arm == "stop": + _apply_state_step(scope, "stop()", trace_ctx, scope.stop) + return { + "trigger_sweep": sweep_readback, + "trigger_status": str(scope.trigger_status), + "trigger_snapshot": str(scope.read_trigger_configuration()), + "raw_edge_level": _clean_reply(scope.query(":TRIGger:SINGle:EDGE:LEVel?")), + "trigger_type": str(scope.trigger_type), + } + + +def _restore_stable_edge(scope, args): + try: + scope.sendcmd(":TRIGger:TYPE SINGle") + scope.sendcmd(":TRIGger:SINGle:MODE EDGE") + scope.sendcmd(f":TRIGger:SINGle:EDGE:SOURce {args.edge_source.upper()}") + scope.sendcmd(f":TRIGger:SINGle:EDGE:COUPling {args.edge_coupling.upper()}") + scope.sendcmd(f":TRIGger:SINGle:EDGE:SLOPe {args.edge_slope.upper()}") + if args.trigger_level_v is not None: + scope.sendcmd( + f":TRIGger:SINGle:EDGE:LEVel {_format_voltage_token(args.trigger_level_v * u.volt)}" + ) + except Exception: + pass + + +def _collect_scope_state(scope, style="full"): + if style == "off": + return {} + if style == "minimal": + return _snapshot_scope_state_minimal(scope) + state = {} + try: + state["trigger_status"] = str(scope.trigger_status) + except Exception as exc: + state["trigger_status"] = f"error: {exc}" + try: + state["trigger_type"] = str(scope.trigger_type) + except Exception as exc: + state["trigger_type"] = f"error: {exc}" + try: + state["timebase_scale"] = str(scope.timebase_scale) + except Exception as exc: + state["timebase_scale"] = f"error: {exc}" + try: + state["trigger_sweep"] = str(scope.trigger_sweep) + except Exception as exc: + state["trigger_sweep"] = f"error: {exc}" + try: + state["raw_edge_level"] = _clean_reply( + scope.query(":TRIGger:SINGle:EDGE:LEVel?") + ) + except Exception as exc: + state["raw_edge_level"] = f"error: {exc}" + try: + state["trigger_snapshot"] = str(scope.read_trigger_configuration()) + except Exception as exc: + state["trigger_snapshot"] = f"error: {exc}" + return state + + +def _namespace_with_overrides(args, **updates): + data = vars(args).copy() + data.update(updates) + return argparse.Namespace(**data) + + +def _write_report( + out_dir, + args, + esp_lines, + scope_state, + after_arm_state, + after_finalize_state, + screenshot_path, + html_view_path, + html_data_path, + trace_ctx, + screenshot_error, + waveforms, + waveform_errors, + waveform_truth_path=None, + waveform_truth=None, + waveform_comparison=None, +): + report_path = out_dir / "report.md" + json_path = out_dir / "report.json" + + waveform_rows = [] + for summary in waveforms: + waveform_rows.append( + f"| CH{summary.channel} | {summary.sample_count} | " + f"{summary.time_start_s:.6g} | {summary.time_end_s:.6g} | " + f"{summary.voltage_min_v:.6g} | {summary.voltage_max_v:.6g} | " + f"{summary.voltage_pp_v:.6g} | `{Path(summary.csv_path).name}` |" + ) + + report_lines = [ + f"# OWON ESP32 Trigger Jig Run: {args.label}", + "", + f"- Timestamp: `{_timestamp().isoformat(timespec='seconds')}`", + f"- Profile: `{args.profile}`", + f"- ESP32 port: `{args.esp_port}`", + f"- Scope VID:PID: `{args.scope_vid}:{args.scope_pid}`", + f"- Arm command: `{args.arm}`", + f"- Screenshot: `{screenshot_path.name if screenshot_path is not None else 'not captured'}`", + "", + "## Primary Scope Screen", + "", + *( + [f"![Primary scope screen]({Path(screenshot_path).name})", ""] + if screenshot_path is not None + else [] + ), + "", + "## Derived Scope View", + "", + *( + [ + f"- [Open `scope_view.html`]({Path(html_view_path).name})", + f"- [Open `scope_view.json`]({Path(html_data_path).name})", + "", + "> VS Code Markdown preview disables local scripts/iframes here.", + "> Open the HTML file directly to view the rendered scope screen.", + "", + ] + if html_view_path is not None and html_data_path is not None + else [] + ), + *( + [ + "", + "## Trace Artifacts", + "", + f"- [Open `scpi_trace.jsonl`]({Path(trace_ctx.scpi_trace_path).name})", + f"- [Open `state_trace.jsonl`]({Path(trace_ctx.state_trace_path).name})", + "", + ] + if trace_ctx is not None + and ( + trace_ctx.scpi_trace_path is not None + or trace_ctx.state_trace_path is not None + ) + else [] + ), + "", + "## ESP32 Configuration", + "", + f"- Pulse mode: `{args.pulse_mode}`", + f"- Pulse width: `{args.pulse_width_us} us`", + f"- Pulse gap: `{args.pulse_gap_us} us`", + f"- Frame gap: `{args.pulse_frame_us} us`", + f"- Slope half-period: `{args.slope_half_period_us} us`", + "", + "## Scope State", + "", + f"- Trigger status: `{scope_state['trigger_status']}`", + f"- Timebase scale: `{scope_state['timebase_scale']}`", + f"- Trigger snapshot: `{scope_state['trigger_snapshot']}`", + "", + "## Arming States", + "", + f"- After arm: `{after_arm_state}`", + f"- After finalize: `{after_finalize_state}`", + "", + "## ESP32 Serial Output", + "", + "```text", + *esp_lines, + "```", + "", + "## Waveforms", + "", + "| Channel | Samples | t_start (s) | t_end (s) | v_min (V) | v_max (V) | v_pp (V) | CSV |", + "| --- | ---: | ---: | ---: | ---: | ---: | ---: | --- |", + *waveform_rows, + "", + *( + [ + "## Waveform Truth", + "", + f"- [Open `waveform_truth.json`]({Path(waveform_truth_path).name})", + "", + f"- Errors: `{'; '.join(waveform_truth['errors']) if waveform_truth and waveform_truth.get('errors') else 'none'}`", + "", + ] + if waveform_truth_path is not None and waveform_truth is not None + else [] + ), + *( + [ + "## Waveform Comparison", + "", + f"- [Open `waveform_comparison.html`]({Path(waveform_comparison['html_path']).name})", + f"- [Open `waveform_comparison.json`]({Path(waveform_comparison['json_path']).name})", + "", + "| Family | Channel | A | B | Counts | Alignment | Mean dV | Max dV | Diff samples | Max dt |", + "| --- | --- | --- | --- | --- | --- | ---: | ---: | ---: | ---: |", + *[ + f"| `{family}` | `{channel_name}` | " + f"`{metrics['series_a']}` | `{metrics['series_b']}` | " + f"`{metrics['count_a']} vs {metrics['count_b']} (shared {metrics['aligned_count']})` | " + f"`{metrics['alignment']}` | " + f"`{metrics['mean_abs_voltage_delta_v']:.6g}` | " + f"`{metrics['max_abs_voltage_delta_v']:.6g}` | " + f"`{metrics['diff_sample_count']}` | " + f"`{metrics['max_abs_time_delta_s']:.6g}` |" + for family, channels in waveform_comparison["families"].items() + for channel_name, metrics in channels.items() + ], + "", + ] + if waveform_comparison is not None + else [] + ), + "", + "## Notes", + "", + "- This report is generated by `owon_esp32_trigger_jig_runner.py`.", + "- The BMP is the primary evidence artifact.", + "- `scope_view.html` is a derived view built from `scope_view.json`.", + "- `waveform_comparison.html` groups the screen and deep-memory APIs side by side.", + "- HTML waveform views keep the discrete front-panel `M:` scale and show a separate derived `Span:` from the returned x-axis.", + "- It records the exact run inputs and captured artifacts, but it does not yet make a formal pass/fail verdict.", + ] + if waveform_errors: + report_lines.extend( + [ + "", + "## Waveform Errors", + "", + *[f"- {item}" for item in waveform_errors], + ] + ) + if screenshot_error: + report_lines.extend( + [ + "", + "## Screenshot Error", + "", + f"- {screenshot_error}", + ] + ) + report_path.write_text("\n".join(report_lines) + "\n", encoding="utf-8") + + payload = { + "label": args.label, + "profile": args.profile, + "esp_port": args.esp_port, + "scope_vid": args.scope_vid, + "scope_pid": args.scope_pid, + "arm": args.arm, + "arm_method": args.arm_method, + "single_stop_first": args.single_stop_first, + "finalize_method": args.finalize_method, + "finalize_delay_s": args.finalize_delay_s, + "trigger_status": scope_state["trigger_status"], + "timebase_scale": scope_state["timebase_scale"], + "trigger_snapshot": scope_state["trigger_snapshot"], + "after_arm_state": after_arm_state, + "after_finalize_state": after_finalize_state, + "screenshot_path": None if screenshot_path is None else str(screenshot_path), + "html_view_path": None if html_view_path is None else str(html_view_path), + "html_data_path": None if html_data_path is None else str(html_data_path), + "scpi_trace_path": None if trace_ctx is None else trace_ctx.scpi_trace_path, + "state_trace_path": None if trace_ctx is None else trace_ctx.state_trace_path, + "waveform_truth_path": None if waveform_truth_path is None else str(waveform_truth_path), + "waveform_comparison_path": ( + None if waveform_comparison is None else str(waveform_comparison["html_path"]) + ), + "waveform_comparison_json_path": ( + None if waveform_comparison is None else str(waveform_comparison["json_path"]) + ), + "screenshot_error": screenshot_error, + "waveforms": [asdict(summary) for summary in waveforms], + "waveform_errors": waveform_errors, + "esp_serial_lines": esp_lines, + } + json_path.write_text(json.dumps(payload, indent=2), encoding="utf-8") + + return report_path, json_path + + +def _run_capture_case(scope, args, out_dir, esp_lines): + out_dir.mkdir(parents=True, exist_ok=True) + trace_ctx = _make_trace_context(out_dir, args) + if trace_ctx.scpi_trace_path is not None: + scope.enable_trace(trace_ctx.scpi_trace_path) + extra_state = _configure_scope(scope, args, trace_ctx) + time.sleep(max(args.capture_delay_s, 0.0)) + after_arm_state = {} + if not args.immediate_deep_probe: + after_arm_state = _collect_scope_state(scope, style=args.state_probe_style) + after_arm_state.update(extra_state) + if args.finalize_method != "none": + _apply_state_step( + scope, + f"freeze_acquisition(method={args.finalize_method})", + trace_ctx, + lambda: scope.freeze_acquisition( + method=args.finalize_method, + settle_time=args.finalize_delay_s, + ), + settle_s=0.0, + ) + after_finalize_state = {} + if not args.immediate_deep_probe: + after_finalize_state = _collect_scope_state(scope, style=args.state_probe_style) + scope_state = dict(after_finalize_state) + else: + scope_state = dict(extra_state) + + screenshot_path = None + html_view_path = None + html_data_path = None + screenshot_error = None + waveform_truth = None + waveform_truth_path = None + waveform_comparison = None + waveforms = [] + waveform_errors = [] + + if args.validate_waveforms: + try: + waveform_truth, waveform_truth_path, waveform_comparison = _capture_waveform_truth( + scope, + out_dir, + args, + scope_state, + args.capture_channels, + deep_first=args.immediate_deep_probe, + ) + except Exception as exc: + waveform_errors.append(f"waveform truth capture: {exc}") + + if args.immediate_deep_probe: + if waveform_truth is not None: + after_arm_state = _collect_scope_state(scope, style="minimal") + after_arm_state.update(extra_state) + after_finalize_state = dict(after_arm_state) + scope_state = dict(after_arm_state) + else: + try: + screenshot_path = _capture_screenshot(scope, out_dir, args) + except Exception as exc: + screenshot_error = str(exc) + + for channel in args.capture_channels: + try: + waveforms.append(_capture_waveform(scope, channel, out_dir)) + except Exception as exc: + waveform_errors.append(f"CH{channel}: {exc}") + + scope_view_sample_rate_text = None + try: + scope_view_sample_rate_text = _metadata_sample_rate_text( + scope.read_waveform_metadata() + ) + except Exception as exc: + waveform_errors.append(f"scope_view sample-rate metadata: {exc}") + + try: + html_view_path, html_data_path = _render_scope_html( + out_dir, + args, + scope_state, + waveforms, + sample_rate_text=scope_view_sample_rate_text, + ) + except Exception as exc: + waveform_errors.append(f"scope_view.html: {exc}") + + report_path, json_path = _write_report( + out_dir, + args, + esp_lines, + scope_state, + after_arm_state, + after_finalize_state, + screenshot_path, + html_view_path, + html_data_path, + trace_ctx, + screenshot_error, + waveforms, + waveform_errors, + waveform_truth_path=waveform_truth_path, + waveform_truth=waveform_truth, + waveform_comparison=waveform_comparison, + ) + primary_error = None + if screenshot_error: + primary_error = screenshot_error + elif waveform_errors: + primary_error = waveform_errors[0] + return { + "label": args.label, + "profile": args.profile, + "trigger_status": scope_state.get("trigger_status"), + "trigger_status_before_reads": after_arm_state.get("trigger_status"), + "trigger_status_after_finalize": after_finalize_state.get("trigger_status"), + "trigger_sweep": scope_state.get("trigger_sweep"), + "arm_method": args.arm_method, + "single_stop_first": args.single_stop_first, + "finalize_method": args.finalize_method, + "screenshot_path": None if screenshot_path is None else str(screenshot_path), + "screenshot_error": screenshot_error, + "failure_class": None if primary_error is None else _classify_error(primary_error), + "deep_metadata_ok": bool( + waveform_truth is not None and waveform_truth.get("deep_memory_metadata") is not None + ), + "deep_ch1_ok": bool( + waveform_truth is not None and "CH1" in waveform_truth.get("deep_memory_channels", {}) + ), + "deep_bundle_ok": bool( + waveform_truth is not None and waveform_truth.get("deep_memory_bundle") is not None + ), + "session_error": None, + "waveform_errors": list(waveform_errors), + "report_path": str(report_path), + "json_path": str(json_path), + }, report_path, json_path, waveforms + + +def _build_verification_cases(args): + cases = [] + if args.verify_pulse: + trigger_time_us = int(args.pulse_trigger_time_us) + width_map = { + ">": max(trigger_time_us * 4, trigger_time_us + 100), + "<": max(10, trigger_time_us // 2), + "=": trigger_time_us, + } + for sign, suffix in ((">", "gt"), ("<", "lt"), ("=", "eq")): + case_width_us = width_map[sign] + cases.append( + _namespace_with_overrides( + args, + label=( + f"{args.label}_pulse_{suffix}_{trigger_time_us}us_" + f"src_{case_width_us}us" + ), + profile="pulse", + pulse_mode="single", + pulse_width_us=case_width_us, + pulse_sign=sign, + ) + ) + elif args.verify_slope: + for edge in ("POS", "NEG"): + for sign, suffix in ((">", "gt"), ("<", "lt"), ("=", "eq")): + cases.append( + _namespace_with_overrides( + args, + label=( + f"{args.label}_slope_{edge.lower()}_{suffix}_" + f"{int(args.slope_trigger_time_us)}us" + ), + profile="slope", + slope_edge=edge, + slope_sign=sign, + ) + ) + return cases + + +def _build_edge_arming_cases(args): + base = { + "profile": "edge", + "capture_channels": [1, 2], + "pulse_mode": "single", + "pulse_width_us": 100, + "pulse_gap_us": 2000, + "pulse_frame_us": 2000, + "slope_half_period_us": 2000, + "ch1_probe": 1, + "ch2_probe": 1, + "ch1_scale_v_div": 1.0, + "ch2_scale_v_div": 1.0, + "ch1_position_div": 1.0, + "ch2_position_div": -2.0, + "timebase_s_div": 200e-6, + "trigger_level_v": 1.65, + "trigger_holdoff_ns": 100, + "validate_waveforms": True, + "arm": "single", + } + cases = [ + ("edge_auto_legacy_stopfirst", "AUTO", "legacy_single", True, "none"), + ("edge_normal_legacy_stopfirst", "NORMal", "legacy_single", True, "none"), + ("edge_normal_legacy_nostopfirst", "NORMal", "legacy_single", False, "none"), + ("edge_single_legacy_stopfirst", "SINGle", "legacy_single", True, "none"), + ("edge_auto_running_stopfirst_runningstop", "AUTO", "running_run", True, "running_stop"), + ("edge_normal_running_stopfirst_runningstop", "NORMal", "running_run", True, "running_stop"), + ("edge_normal_running_nostopfirst_runningstop", "NORMal", "running_run", False, "running_stop"), + ("edge_single_running_stopfirst_runningstop", "SINGle", "running_run", True, "running_stop"), + ("edge_normal_legacy_nostopfirst_runningstop", "NORMal", "legacy_single", False, "running_stop"), + ("edge_normal_legacy_stopfirst_runningstop", "NORMal", "legacy_single", True, "running_stop"), + ] + built = [] + for label, sweep, arm_method, stop_first, finalize_method in cases: + built.append( + _namespace_with_overrides( + args, + label=label, + trigger_sweep=sweep, + arm_method=arm_method, + single_stop_first=stop_first, + finalize_method=finalize_method, + **base, + ) + ) + return built + + +def _run_case_with_fresh_scope(args, case_args, out_dir, esp_lines): + scope = None + transport_failure = False + clean_success = False + try: + scope = _open_scope(case_args) + summary, report_path, json_path, waveforms = _run_capture_case( + scope, case_args, out_dir, esp_lines + ) + transport_failure = bool( + summary.get("failure_class") + in {"transport_timeout", "transport_pipe_error", "malformed_length_prefix"} + or ( + summary.get("session_error") is not None + and _is_transport_failure(summary["session_error"]) + ) + ) + clean_success = not transport_failure + return summary, report_path, json_path, waveforms + except Exception as exc: + failure_class = _classify_error(exc) + transport_failure = _is_transport_failure(exc) + report_path = out_dir / "case_error.txt" + report_path.write_text(f"{type(exc).__name__}: {exc}\n", encoding="utf-8") + summary = { + "label": case_args.label, + "profile": case_args.profile, + "trigger_status": None, + "trigger_status_before_reads": None, + "trigger_status_after_finalize": None, + "trigger_sweep": case_args.trigger_sweep, + "arm_method": case_args.arm_method, + "single_stop_first": case_args.single_stop_first, + "finalize_method": case_args.finalize_method, + "screenshot_path": None, + "screenshot_error": None, + "failure_class": failure_class, + "deep_metadata_ok": False, + "deep_ch1_ok": False, + "deep_bundle_ok": False, + "waveform_errors": [], + "session_error": f"{type(exc).__name__}: {exc}", + "report_path": str(report_path), + "json_path": None, + } + return summary, report_path, None, [] + finally: + if scope is not None: + try: + scope.disable_trace() + except Exception: + pass + try: + if transport_failure: + if case_args.restore_after_failure: + try: + _restore_stable_edge(scope, case_args) + except Exception: + pass + scope.close( + reset_device=case_args.hard_reset_on_failure, + settle_time=case_args.reset_settle_s, + ) + else: + if clean_success and case_args.restore_after_success: + try: + _restore_stable_edge(scope, case_args) + except Exception: + pass + scope.close() + except Exception: + pass + + +def _write_verification_report(out_dir, args, summaries): + report_path = out_dir / "verification_report.md" + json_path = out_dir / "verification_report.json" + + rows = [] + for summary in summaries: + rows.append( + f"| `{summary['label']}` | `{summary['profile']}` | " + f"`{summary['trigger_status']}` | `{summary['trigger_sweep']}` | " + f"`{Path(summary['screenshot_path']).name if summary['screenshot_path'] else 'none'}` | " + f"`{Path(summary['report_path']).name}` | " + f"`{'; '.join(summary['waveform_errors']) if summary['waveform_errors'] else 'none'}` |" + ) + + lines = [ + f"# OWON Trigger Verification: {args.label}", + "", + f"- Timestamp: `{_timestamp().isoformat(timespec='seconds')}`", + f"- ESP32 port: `{args.esp_port}`", + f"- Scope VID:PID: `{args.scope_vid}:{args.scope_pid}`", + "", + "| Case | Profile | Trigger status | Sweep | Screenshot | Report | Waveform errors |", + "| --- | --- | --- | --- | --- | --- | --- |", + *rows, + "", + "## Case Reports", + "", + *[ + f"- [{Path(item['report_path']).name}]({Path(item['label']) / Path(item['report_path']).name})" + for item in summaries + ], + ] + report_path.write_text("\n".join(lines) + "\n", encoding="utf-8") + json_path.write_text(json.dumps(summaries, indent=2), encoding="utf-8") + return report_path, json_path + + +def _write_edge_arming_report(out_dir, args, summaries): + report_path = out_dir / "edge_arming_report.md" + json_path = out_dir / "edge_arming_report.json" + + rows = [] + for summary in summaries: + rows.append( + f"| `{summary['label']}` | `{summary['trigger_sweep']}` | " + f"`{summary['arm_method']}` | `{summary['single_stop_first']}` | " + f"`{summary['finalize_method']}` | " + f"`{summary['trigger_status_before_reads']}` | " + f"`{summary['trigger_status_after_finalize']}` | " + f"`{summary['deep_metadata_ok']}` | `{summary['deep_ch1_ok']}` | " + f"`{summary['deep_bundle_ok']}` | " + f"`{summary['failure_class'] or 'none'}` | " + f"`{Path(summary['report_path']).name if summary.get('report_path') else 'none'}` |" + ) + + lines = [ + f"# OWON Edge Arming Matrix: {args.label}", + "", + f"- Timestamp: `{_timestamp().isoformat(timespec='seconds')}`", + f"- ESP32 port: `{args.esp_port}`", + f"- Scope VID:PID: `{args.scope_vid}:{args.scope_pid}`", + "", + "| Case | Sweep | Arm | Stop First | Finalize | Status Before Reads | Status After Finalize | Deep Meta | Deep CH1 | Deep Bundle | Failure Class | Report |", + "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |", + *rows, + "", + "## Case Reports", + "", + *[ + f"- [{Path(item['report_path']).name}]({Path(item['label']) / Path(item['report_path']).name})" + for item in summaries + if item.get("report_path") + ], + ] + report_path.write_text("\n".join(lines) + "\n", encoding="utf-8") + json_path.write_text(json.dumps(summaries, indent=2), encoding="utf-8") + return report_path, json_path diff --git a/doc/examples/_owon_capture_stable.py b/doc/examples/_owon_capture_stable.py new file mode 100644 index 00000000..f9b8acd7 --- /dev/null +++ b/doc/examples/_owon_capture_stable.py @@ -0,0 +1,386 @@ +#!/usr/bin/env python +""" +Stable capture helpers for the OWON ESP32 trigger jig runner. + +This module contains the promoted, supported example flows: + +- --capture-edge-pretty +- --capture-edge-pretty-burst +""" + +from __future__ import annotations + +from dataclasses import asdict +import json +from pathlib import Path +from types import SimpleNamespace +import time + +import instruments.owon.sds1104 as owon_sds1104 + +from _owon_capture_common import ( + _build_scope_html_data_from_series, + _capture_depmem_all_summary_and_series, + _capture_screen_channel_raw, + _capture_screenshot, + _clean_reply, + _compare_waveform_series, + _configure_esp_from_args, + _make_trace_context, + _metadata_sample_rate_text, + _open_scope, + _render_scope_html_with_data, + _render_waveform_comparison_html, + _safe_json_write, + _timestamp, +) + + +def _is_pretty_edge_mode(args): + return bool(args.capture_edge_pretty or args.capture_edge_pretty_burst) + + +def _apply_pretty_edge_defaults(args): + if not _is_pretty_edge_mode(args): + return args + + args.profile = "edge" + args.arm = "single" + # Bench finding on the connected DOS1104: forcing SWEEp NORMal in this + # promoted path is unsafe. The stable sequence uses raw setup plus + # `:TRIGger:SINGle:SWEEp SINGle`, then waits for a genuine trigger event. + args.trigger_sweep = None + args.trigger_level_v = 1.65 + args.ch1_probe = 1 + args.ch2_probe = 1 + args.ch1_position_div = 1.0 + args.ch2_position_div = -2.0 + args.ch1_scale_v_div = 1.0 + args.ch2_scale_v_div = 1.0 + args.timebase_s_div = 0.0002 + args.pulse_mode = "burst" if args.capture_edge_pretty_burst else "single" + args.pulse_width_us = 100 + args.pulse_gap_us = 2000 + args.pulse_frame_us = 2000 + args.slope_half_period_us = 2000 + if args.label == "owon_esp32_trigger_jig": + args.label = ( + "pretty_edge_capture_burst" + if args.capture_edge_pretty_burst + else "pretty_edge_capture" + ) + return args + + +def _run_proven_edge_capture_case(args, out_dir): + out_dir.mkdir(parents=True, exist_ok=True) + trace_ctx = _make_trace_context(out_dir, args) + scope = _open_scope(args) + try: + if trace_ctx.scpi_trace_path is not None: + scope.enable_trace(trace_ctx.scpi_trace_path) + + initial_status = _clean_reply(scope.query(":TRIGger:STATUS?")) + resumed_before_setup = False + if initial_status.upper() == "STOP": + scope.run() + time.sleep(0.2) + resumed_before_setup = True + + sequence = [ + ":CH1:DISP ON", + ":CH2:DISP ON", + ":CH3:DISP OFF", + ":CH4:DISP OFF", + ":CH1:PROB 1X", + ":CH2:PROB 1X", + ":CH1:SCAL 1v", + ":CH2:SCAL 1v", + ":CH1:POS 1.0", + ":CH2:POS -2.0", + ":ACQUire:Mode SAMPle", + ":ACQUIRE:DEPMEM 10K", + ":ACQUIRE:DEPMEM?", + ":HORIzontal:Scale 500us", + ":HORIzontal:Scale?", + ":TRIGger:TYPE SINGle", + ":TRIGger:SINGle:MODE EDGE", + ":TRIGger:SINGle:EDGE:SOURce CH1", + ":TRIGger:SINGle:EDGE:COUPling DC", + ":TRIGger:SINGle:EDGE:SLOPe RISE", + ":TRIGger:SINGle:EDGE:LEVel 1.64V", + ":TRIGger:SINGle:EDGE:LEVel?", + ":TRIGger:SINGle:HOLDoff 100ns", + ":TRIGger:SINGle:SWEEp SINGle", + ] + for command in sequence: + if command.endswith("?"): + scope.query(command) + else: + scope.sendcmd(command) + + esp_lines = _configure_esp_from_args(args) + time.sleep(0.5) + time.sleep(0.5) + + status_text = _clean_reply(scope.query(":TRIGger:STATUS?")) + screen_metadata = owon_sds1104._parse_json_payload( # pylint: disable=protected-access + scope._binary_query(":DATA:WAVE:SCREen:HEAD?"), # pylint: disable=protected-access + "waveform metadata", + ) + screen_metadata_path = out_dir / "screen_waveform_metadata.json" + _safe_json_write(screen_metadata_path, screen_metadata) + + waveforms = [] + screen_series = {} + for channel in (1, 2): + summary, series = _capture_screen_channel_raw( + scope, screen_metadata, channel, out_dir + ) + waveforms.append(summary) + screen_series[channel] = series + + screenshot_path = _capture_screenshot( + scope, out_dir, SimpleNamespace(profile="edge", arm="single") + ) + ( + depmem_summary, + depmem_summary_path, + depmem_raw_path, + depmem_series, + ) = _capture_depmem_all_summary_and_series(scope, out_dir) + + scope_state = { + "trigger_status": f"TriggerStatus.{status_text.lower()}", + "initial_trigger_status": initial_status, + "resumed_before_setup": resumed_before_setup, + "timebase_scale": "0.0005 second", + "trigger_snapshot": ( + "SDS1104TriggerConfiguration(" + f"status=<{status_text}>, " + "trigger_type=, " + "single_trigger_mode=, " + "holdoff=, " + "edge_source=, " + "edge_coupling=, " + "edge_slope=, " + "edge_level=, " + "video_source=None, video_standard=None, video_sync=None, video_line_number=None)" + ), + "trigger_sweep": "TriggerSweep.single", + "raw_edge_level": _clean_reply(scope.query(":TRIGger:SINGle:EDGE:LEVel?")), + } + + sample_rate_text = _metadata_sample_rate_text(screen_metadata) + scope_html_data = _build_scope_html_data_from_series( + args, + scope_state, + screen_series, + memory_depth_text=str(len(next(iter(screen_series.values()))["y"])), + sample_rate_text=sample_rate_text, + ) + html_view_path, html_data_path = _render_scope_html_with_data( + out_dir, scope_html_data, "scope_view" + ) + + depmem_view_path = None + depmem_view_json_path = None + if depmem_series: + depmem_scope_data = _build_scope_html_data_from_series( + args, + scope_state, + depmem_series, + memory_depth_text=str(len(next(iter(depmem_series.values()))["y"])), + sample_rate_text=depmem_summary["metadata"]["sample_rate"], + ) + depmem_view_path, depmem_view_json_path = _render_scope_html_with_data( + out_dir, depmem_scope_data, "depmem_all_view" + ) + + report_path = out_dir / "report.md" + json_path = out_dir / "report.json" + lines = [ + f"# OWON ESP32 Trigger Jig Run: {args.label}", + "", + f"- Timestamp: `{_timestamp().isoformat(timespec='seconds')}`", + "- Profile: `edge`", + f"- ESP32 port: `{args.esp_port}`", + f"- Scope VID:PID: `{args.scope_vid}:{args.scope_pid}`", + "- Arm command: `sequenced_raw_edge`", + f"- Screenshot: `{Path(screenshot_path).name}`", + "", + "## Primary Scope Screen", + "", + f"![Primary scope screen]({Path(screenshot_path).name})", + "", + "## Derived Scope View", + "", + f"- [Open `scope_view.html`]({Path(html_view_path).name})", + f"- [Open `scope_view.json`]({Path(html_data_path).name})", + "", + "## Scope State", + "", + f"- Initial trigger status: `{scope_state['initial_trigger_status']}`", + f"- Resumed before setup: `{scope_state['resumed_before_setup']}`", + f"- Trigger status: `{scope_state['trigger_status']}`", + "- Timebase scale: `0.0005 second`", + f"- Trigger sweep: `{scope_state['trigger_sweep']}`", + f"- Raw edge level: `{scope_state['raw_edge_level']}`", + "", + "## ESP32 Serial Output", + "", + "```text", + *esp_lines, + "```", + "", + "## Waveforms", + "", + "| Channel | Samples | t_start (s) | t_end (s) | v_min (V) | v_max (V) | v_pp (V) | CSV | Plot |", + "| --- | ---: | ---: | ---: | ---: | ---: | ---: | --- | --- |", + ] + for summary in waveforms: + lines.append( + f"| CH{summary.channel} | {summary.sample_count} | {summary.time_start_s:.6g} | " + f"{summary.time_end_s:.6g} | {summary.voltage_min_v:.6g} | " + f"{summary.voltage_max_v:.6g} | {summary.voltage_pp_v:.6g} | " + f"`{Path(summary.csv_path).name}` | " + f"`screen_ch{summary.channel}_plot.html` |" + ) + lines.extend( + [ + "", + "## Screen Metadata", + "", + f"- [Open `screen_waveform_metadata.json`]({screen_metadata_path.name})", + "", + "## DEPMem:All Summary", + "", + f"- [Open `depmem_all_summary.json`]({depmem_summary_path.name})", + f"- [Open `depmem_all_raw.bin`]({depmem_raw_path.name})", + ] + ) + if depmem_view_path is not None: + lines.extend( + [ + f"- [Open `depmem_all_view.html`]({Path(depmem_view_path).name})", + f"- [Open `depmem_all_view.json`]({Path(depmem_view_json_path).name})", + ] + ) + + waveform_comparison = { + "bmp_name": Path(screenshot_path).name, + "views": [ + { + "stem": "scope_view", + "title": "Screen `:DATA:WAVE:SCREen:CH?`", + "html_name": Path(html_view_path).name, + "json_name": Path(html_data_path).name, + } + ], + "families": {}, + } + if depmem_view_path is not None: + waveform_comparison["views"].append( + { + "stem": "depmem_all_view", + "title": "Deep `:DATA:WAVE:DEPMem:All?` converted from raw bundle", + "html_name": Path(depmem_view_path).name, + "json_name": Path(depmem_view_json_path).name, + } + ) + + channels = {} + for channel in sorted(set(screen_series) & set(depmem_series)): + channels[f"CH{channel}"] = _compare_waveform_series( + "screen `:DATA:WAVE:SCREen:CH?`", + screen_series[channel], + "deep `:DATA:WAVE:DEPMem:All?`", + depmem_series[channel], + ) + if channels: + waveform_comparison["families"]["screen_vs_depmem_all"] = channels + + waveform_comparison_path = out_dir / "waveform_comparison.json" + _safe_json_write(waveform_comparison_path, waveform_comparison) + waveform_comparison_html_path = _render_waveform_comparison_html( + out_dir, waveform_comparison + ) + + lines.extend( + [ + "", + "## Waveform Comparison", + "", + f"- [Open `waveform_comparison.html`]({waveform_comparison_html_path.name})", + f"- [Open `waveform_comparison.json`]({waveform_comparison_path.name})", + "", + "| Family | Channel | A | B | Counts | Alignment | Mean dV | Max dV | Diff samples | Max dt |", + "| --- | --- | --- | --- | --- | --- | ---: | ---: | ---: | ---: |", + ] + ) + for family, family_channels in waveform_comparison["families"].items(): + for channel_name, metrics in family_channels.items(): + lines.append( + f"| `{family}` | `{channel_name}` | `{metrics['series_a']}` | " + f"`{metrics['series_b']}` | " + f"`{metrics['count_a']} vs {metrics['count_b']} (shared {metrics['aligned_count']})` | " + f"`{metrics['alignment']}` | " + f"{metrics['mean_abs_voltage_delta_v']:.6g} | " + f"{metrics['max_abs_voltage_delta_v']:.6g} | " + f"{metrics['diff_sample_count']} | " + f"{metrics['max_abs_time_delta_s']:.6g} |" + ) + + if trace_ctx.scpi_trace_path is not None: + lines.extend( + [ + "", + "## Trace", + "", + f"- [Open `scpi_trace.jsonl`]({Path(trace_ctx.scpi_trace_path).name})", + ] + ) + report_path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + payload = { + "label": args.label, + "profile": "edge", + "esp_port": args.esp_port, + "scope_vid": args.scope_vid, + "scope_pid": args.scope_pid, + "arm": "sequenced_raw_edge", + "trigger_status": scope_state["trigger_status"], + "initial_trigger_status": scope_state["initial_trigger_status"], + "resumed_before_setup": scope_state["resumed_before_setup"], + "timebase_scale": scope_state["timebase_scale"], + "trigger_snapshot": scope_state["trigger_snapshot"], + "trigger_sweep": scope_state["trigger_sweep"], + "raw_edge_level": scope_state["raw_edge_level"], + "screenshot_path": str(screenshot_path), + "html_view_path": str(html_view_path), + "html_data_path": str(html_data_path), + "screen_waveform_metadata_path": str(screen_metadata_path), + "depmem_all_summary_path": str(depmem_summary_path), + "depmem_all_raw_path": str(depmem_raw_path), + "depmem_all_view_path": ( + None if depmem_view_path is None else str(depmem_view_path) + ), + "waveform_comparison_path": str(waveform_comparison_html_path), + "waveform_comparison_json_path": str(waveform_comparison_path), + "scpi_trace_path": ( + None if trace_ctx.scpi_trace_path is None else trace_ctx.scpi_trace_path + ), + "waveforms": [asdict(summary) for summary in waveforms], + "esp_serial_lines": esp_lines, + } + json_path.write_text(json.dumps(payload, indent=2), encoding="utf-8") + return payload, report_path, json_path, waveforms + finally: + try: + scope.disable_trace() + except Exception: + pass + try: + scope.close() + except Exception: + pass diff --git a/doc/examples/ex_owon_sds1104.py b/doc/examples/ex_owon_sds1104.py index e064ec7c..aefe968f 100644 --- a/doc/examples/ex_owon_sds1104.py +++ b/doc/examples/ex_owon_sds1104.py @@ -4,22 +4,42 @@ """ import instruments as ik +from instruments.units import ureg as u def main(): """ Open the scope over raw USB, print a few stable values, and read the - current CH1 screen waveform. + current CH1 screen waveform. The trigger example below only uses the + conservative trigger paths promoted in the driver. """ - scope = ik.owon.OWONSDS1104.open_usb() + scope = ik.owon.OWONSDS1104.open_usb( + enable_scpi=False, + ignore_scpi_failure=True, + settle_time=0.1, + ) + try: + print(f"Identity: {scope.name}") + print(f"Timebase scale: {scope.timebase_scale}") + print(f"CH1 displayed: {scope.channel[0].display}") + print(f"CH1 coupling: {scope.channel[0].coupling}") + print(f"Trigger type: {scope.trigger_type}") + print(f"Single trigger mode: {scope.single_trigger_mode}") - print(f"Identity: {scope.name}") - print(f"Timebase scale: {scope.timebase_scale}") - print(f"CH1 displayed: {scope.channel[0].display}") - print(f"CH1 coupling: {scope.channel[0].coupling}") - time_s, voltage_v = scope.channel[0].read_waveform() - print(f"CH1 waveform samples: {len(voltage_v)}") - print(f"First sample: t={time_s[0]!r}, v={voltage_v[0]!r}") + scope.stop() + scope.single_trigger_mode = scope.SingleTriggerMode.edge + scope.trigger_source = scope.TriggerSource.ch1 + scope.trigger_coupling = scope.TriggerCoupling.dc + scope.trigger_slope = scope.TriggerSlope.rise + scope.trigger_level = 25 * u.millivolt + scope.trigger_holdoff = 100 * u.nanosecond + print(f"Trigger snapshot: {scope.read_trigger_configuration()}") + + time_s, voltage_v = scope.channel[0].read_waveform() + print(f"CH1 waveform samples: {len(voltage_v)}") + print(f"First sample: t={time_s[0]!r}, v={voltage_v[0]!r}") + finally: + scope.close() if __name__ == "__main__": diff --git a/doc/examples/owon_esp32_trigger_jig.md b/doc/examples/owon_esp32_trigger_jig.md new file mode 100644 index 00000000..785da772 --- /dev/null +++ b/doc/examples/owon_esp32_trigger_jig.md @@ -0,0 +1,301 @@ +# ESP32 Trigger Test Jig For OWON SDS1104 / DOS1104 + +This note describes a very small bench jig for validating the SDS1104 / DOS1104 +trigger families that now look real on hardware but are not fully exercised in +the front-panel UI: + +- `PULSe` +- `SLOPe` +- `EDGE:COUPling HF` +- `EDGE:SOURce ACLine` + +The goal is not RF-grade timing accuracy. The goal is to generate clean, +repeatable, low-voltage bench signals that are good enough to validate trigger +behavior at easy time scales such as `10 us`, `50 us`, `100 us`, and `1 ms`. + +## Hardware + +Use any ordinary 3.3 V ESP32 dev board with exposed GPIOs. + +Preferred firmware path: + +- PlatformIO project: [owon_esp32_trigger_jig_pio/platformio.ini](./owon_esp32_trigger_jig_pio/platformio.ini) +- PlatformIO source: [owon_esp32_trigger_jig_pio/src/main.cpp](./owon_esp32_trigger_jig_pio/src/main.cpp) +- Command-line scope runner: [owon_esp32_trigger_jig_runner.py](./owon_esp32_trigger_jig_runner.py) + +Recommended parts: + +- 1 x ESP32 dev board +- 2 x `330 ohm` series resistors +- 1 x `1 kohm` series resistor +- 1 x `100 kohm` resistor, optional +- 4 x capacitors for the slope node + - `4.7 nF` + - `22 nF` + - `47 nF` + - `470 nF` +- jumper wires +- optional small breadboard + +Use `10x` probes if possible. They load the signal less and make the RC slope +node behave more predictably. + +## Exact Wiring + +Common ground: + +- ESP32 `GND` -> scope ground clips for all channels used + +CH1 direct pulse output: + +- ESP32 `GPIO18` -> `330 ohm` -> scope `CH1` probe tip + +CH2 slope output node: + +- ESP32 `GPIO19` -> `1 kohm` -> node `SLOPE_OUT` +- `SLOPE_OUT` -> scope `CH2` probe tip +- `SLOPE_OUT` -> selected capacitor -> `GND` +- optional: `SLOPE_OUT` -> `100 kohm` -> `GND` + +CH3 frame marker, optional: + +- ESP32 `GPIO21` -> `330 ohm` -> scope `CH3` probe tip + +Do not connect the scope probe tips directly to `3V3`. + +### RC Values For Approximate Slope Times + +The CH2 node is a first-order RC edge shaper. The approximate `10%` to `90%` +rise or fall time is about `2.2 * R * C`. + +With `R = 1 kohm`: + +- `4.7 nF` -> about `10 us` +- `22 nF` -> about `48 us` +- `47 nF` -> about `103 us` +- `470 nF` -> about `1.03 ms` + +This is good enough for conservative trigger validation. + +## What Each Channel Is For + +`CH1` + +- direct digital pulse train +- use this for `PULSe` trigger tests + +`CH2` + +- RC-shaped version of a square wave +- use this for `SLOPe` trigger tests + +`CH3` + +- optional burst marker +- useful when checking where one pulse frame starts + +## Scope Setup Suggestions + +For pulse tests on `CH1`: + +- source: `CH1` +- trigger mode: `PULSe` +- coupling: start with `DC` +- slope or direction: match the pulse edge you care about + +For slope tests on `CH2`: + +- source: `CH2` +- trigger mode: `SLOPe` +- set upper and lower levels around the middle of the RC swing, for example + `0.8 V` and `2.4 V` +- set `TIME` near the expected slope time from the RC table + +For `HF` coupling tests: + +- start from ordinary `EDGE` mode on `CH1` +- compare trigger behavior with `DC`, `AC`, and `HF` +- `HF` is easiest to see later with a signal that has slow baseline movement + plus a fast edge component + +For `ACLine`: + +- the ESP32 jig does not generate line-synchronous AC +- use a separate isolated low-voltage AC source later, for example a small + transformer secondary +- never connect mains directly to the scope or to the ESP32 + +## Minimal Test Procedure + +### Pulse Trigger + +The firmware emits a repeating burst on `CH1` with pulse widths: + +- `10 us` +- `50 us` +- `100 us` +- `1000 us` + +Suggested checks: + +- set `PULSe:TIME 50us` +- try `PULSe:SIGN >`, `<`, and `=` +- verify the scope prefers the matching pulse width or class + +### Slope Trigger + +Pick one capacitor on `CH2`, for example: + +- `22 nF` for about `50 us` + +Suggested checks: + +- set `SLOPe:TIME 50us` +- try `SLOPe:SIGN >`, `<`, and `=` +- try `SLOPe:SLOPe POS` and `NEG` +- verify that triggering changes when you swap the capacitor to another + timescale + +### HF Coupling + +Start with the CH1 pulse source. + +Suggested checks: + +- compare `EDGE:COUPling DC`, `AC`, and `HF` +- later, if needed, add a slow baseline modulation source and confirm `HF` + ignores it better than `DC` + +## Limitations + +- `delayMicroseconds()` on ESP32 is good enough for bench trigger validation, + not for calibrated timing metrology +- the RC slope node is only approximately linear +- for sub-microsecond work, use RP2040 PIO or a function generator instead + +## Firmware + +The maintained firmware path is the PlatformIO project: + +- [owon_esp32_trigger_jig_pio/platformio.ini](./owon_esp32_trigger_jig_pio/platformio.ini) +- [owon_esp32_trigger_jig_pio/src/main.cpp](./owon_esp32_trigger_jig_pio/src/main.cpp) + +## PlatformIO Build And Upload + +From the repo root: + +```powershell +cd doc/examples/owon_esp32_trigger_jig_pio +pio run +pio run -t upload +pio device monitor +``` + +## Command-Line Runner + +The Python runner configures the ESP32 over serial, configures the scope, +captures a BMP screenshot, saves waveform CSV files, and writes a Markdown and +JSON report into a timestamped artifact directory. + +Basic pulse example: + +```powershell +python doc/examples/owon_esp32_trigger_jig_runner.py ` + --esp-port COM7 ` + --profile pulse ` + --pulse-mode burst ` + --pulse-trigger-time-us 50 ` + --capture-channels 1 ` + --timebase-s-div 0.00005 +``` + +Basic slope example: + +```powershell +python doc/examples/owon_esp32_trigger_jig_runner.py ` + --esp-port COM7 ` + --profile slope ` + --slope-trigger-time-us 50 ` + --slope-upper-v 2.4 ` + --slope-lower-v 0.8 ` + --capture-channels 2 ` + --timebase-s-div 0.001 +``` + +Pretty stable edge capture: + +```powershell +python doc/examples/owon_esp32_trigger_jig_runner.py ` + --esp-port COM7 ` + --capture-edge-pretty +``` + +Waveform truth validation capture: + +```powershell +python doc/examples/owon_esp32_trigger_jig_runner.py ` + --esp-port COM7 ` + --capture-edge-pretty ` + --validate-waveforms +``` + +The runner saves artifacts under: + +```text +doc/examples/artifacts/YYYYMMDD_HHMMSS_