From 76d9b759364263ce00c7a79cbee9eb99ab1bb131 Mon Sep 17 00:00:00 2001 From: Yash Anand <164618230+YashAnand69@users.noreply.github.com> Date: Thu, 8 Oct 2026 00:51:41 +0530 Subject: [PATCH 1/4] fix: validate waveform tables before device writes --- pslab/instrument/waveform_generator.py | 31 +++++++--- tests/test_waveform_table_validation.py | 76 +++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 8 deletions(-) create mode 100644 tests/test_waveform_table_validation.py diff --git a/pslab/instrument/waveform_generator.py b/pslab/instrument/waveform_generator.py index 07c3523..7a48078 100644 --- a/pslab/instrument/waveform_generator.py +++ b/pslab/instrument/waveform_generator.py @@ -253,6 +253,11 @@ def load_function( The minimum and maximum x values between which to evaluate 'function'. Should typically correspond to one period. Omit if 'function' is 'sine' or 'tria'. + + Raises + ------ + ValueError + If the function does not return 512 finite real voltage values. """ if function == "sine": @@ -261,7 +266,7 @@ def sine(x): function = sine span = [0, 2 * np.pi] - self._channels[channel].wavetype = "sine" + mode = "sine" elif function == "tria": def tria(x): @@ -269,15 +274,13 @@ def tria(x): function = tria span = [0, 4] - self._channels[channel].wavetype = "tria" + mode = "tria" else: - self._channels[channel].wavetype = "custom" + mode = "custom" x = np.arange(span[0], span[1], (span[1] - span[0]) / 512) y = function(x) - self._load_table( - channel=channel, points=y, mode=self._channels[channel].wavetype - ) + self._load_table(channel=channel, points=y, mode=mode) def load_table(self, channel: str, points: np.ndarray): """Load a custom waveform as a table. @@ -289,13 +292,25 @@ def load_table(self, channel: str, points: np.ndarray): points : np.ndarray Array of voltage values which make up the waveform. Array length must be 512. Values outside the range -3.3 V to 3.3 V will be - clipped. + clipped. All values must be finite real numbers. + + Raises + ------ + ValueError + If the table is not a one-dimensional array of 512 finite real + voltage values. Invalid tables are rejected before device writes. """ self._load_table(channel, points, "custom") def _load_table(self, channel, points, mode="custom"): - self._channels[channel].wavetype = mode + points = np.asarray(points) + if points.ndim != 1 or points.size != self._HIGHRES_TABLE_SIZE: + raise ValueError("Waveform table must contain exactly 512 values.") + if np.iscomplexobj(points) or not np.isfinite(points).all(): + raise ValueError("Waveform voltages must be finite real values.") + self._channels[channel].waveform_table = points + self._channels[channel].wavetype = mode logger.info(f"Reloaded waveform table for {channel}: {mode}.") self._device.send_byte(CP.WAVEGEN) diff --git a/tests/test_waveform_table_validation.py b/tests/test_waveform_table_validation.py new file mode 100644 index 0000000..a19119d --- /dev/null +++ b/tests/test_waveform_table_validation.py @@ -0,0 +1,76 @@ +"""Hardware-independent validation of waveform uploads and command framing.""" + +from unittest.mock import Mock + +import numpy as np +import pytest + +import pslab.protocol as CP +from pslab.connection import ConnectionHandler +from pslab.instrument.waveform_generator import WaveformGenerator + + +@pytest.mark.parametrize("loader", ["table", "function"]) +@pytest.mark.parametrize( + "points", + [ + np.zeros(0), + np.zeros(1), + np.zeros(511), + np.zeros(513), + np.zeros((512, 1)), + np.zeros((2, 256)), + np.full(512, np.nan), + np.full(512, np.inf), + np.full(512, -np.inf), + np.full(512, 1j), + ], +) +def test_invalid_table_does_not_write_or_change_channel(points, loader): + device = Mock(spec=ConnectionHandler) + generator = WaveformGenerator(device) + channel = generator._channels["SI1"] + previous_table = channel.waveform_table.copy() + with pytest.raises(ValueError): + if loader == "table": + generator.load_table("SI1", points) + else: + generator.load_function("SI1", lambda x: points, [0, 1]) + assert device.mock_calls == [] + assert channel.wavetype == "sine" + assert channel.waveform_table == previous_table + + +@pytest.mark.parametrize("channel", ["SI1", "SI2"]) +@pytest.mark.parametrize("dtype", [np.float64, np.int16]) +def test_valid_table_preserves_upload_framing(channel, dtype): + device = Mock(spec=ConnectionHandler) + generator = WaveformGenerator(device) + points = np.linspace(-5, 5, 512).astype(dtype) + original = points.copy() + generator.load_table(channel, points) + clipped = np.clip(points, -3.3, 3.3) + expected = np.round((clipped + 3.3) / 6.6 * 511).astype(np.int16).tolist() + assert [call.args[0] for call in device.send_int.call_args_list] == expected + commands = [call.args[0] for call in device.send_byte.call_args_list] + assert commands[:2] == [ + CP.WAVEGEN, + CP.LOAD_WAVEFORM1 if channel == "SI1" else CP.LOAD_WAVEFORM2, + ] + assert ( + commands[2:] + == np.round((clipped[::16] + 3.3) / 6.6 * 63).astype(np.int16).tolist() + ) + device.get_ack.assert_called_once_with() + assert generator._channels[channel].wavetype == "custom" + np.testing.assert_array_equal(points, original) + + +@pytest.mark.parametrize("function", ["sine", "tria"]) +def test_builtin_functions_keep_fixed_upload_sizes(function): + device = Mock(spec=ConnectionHandler) + generator = WaveformGenerator(device) + generator.load_function("SI1", function) + assert device.send_int.call_count == 512 + assert device.send_byte.call_count == 34 + assert generator._channels["SI1"].wavetype == function From 4fe7dbb901b25b419c025e1505e827ffd167c2ff Mon Sep 17 00:00:00 2001 From: Yash Anand <164618230+YashAnand69@users.noreply.github.com> Date: Thu, 8 Oct 2026 01:00:01 +0530 Subject: [PATCH 2/4] fix: normalize real object waveform values before validation --- pslab/instrument/waveform_generator.py | 11 ++++++++++- tests/test_waveform_table_validation.py | 7 +++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/pslab/instrument/waveform_generator.py b/pslab/instrument/waveform_generator.py index 7a48078..83e9b24 100644 --- a/pslab/instrument/waveform_generator.py +++ b/pslab/instrument/waveform_generator.py @@ -306,7 +306,16 @@ def _load_table(self, channel, points, mode="custom"): points = np.asarray(points) if points.ndim != 1 or points.size != self._HIGHRES_TABLE_SIZE: raise ValueError("Waveform table must contain exactly 512 values.") - if np.iscomplexobj(points) or not np.isfinite(points).all(): + if points.dtype.kind not in "biufOUS": + raise ValueError("Waveform voltages must be finite real values.") + if points.dtype.kind in "OUS": + try: + points = points.astype(float) + except (TypeError, ValueError, OverflowError) as error: + raise ValueError( + "Waveform voltages must be finite real values." + ) from error + if not np.isfinite(points).all(): raise ValueError("Waveform voltages must be finite real values.") self._channels[channel].waveform_table = points diff --git a/tests/test_waveform_table_validation.py b/tests/test_waveform_table_validation.py index a19119d..f8ca10c 100644 --- a/tests/test_waveform_table_validation.py +++ b/tests/test_waveform_table_validation.py @@ -24,6 +24,9 @@ np.full(512, np.inf), np.full(512, -np.inf), np.full(512, 1j), + np.full(512, "not-a-voltage"), + np.full(512, None, dtype=object), + np.full(512, {}, dtype=object), ], ) def test_invalid_table_does_not_write_or_change_channel(points, loader): @@ -42,14 +45,14 @@ def test_invalid_table_does_not_write_or_change_channel(points, loader): @pytest.mark.parametrize("channel", ["SI1", "SI2"]) -@pytest.mark.parametrize("dtype", [np.float64, np.int16]) +@pytest.mark.parametrize("dtype", [np.float64, np.int16, object]) def test_valid_table_preserves_upload_framing(channel, dtype): device = Mock(spec=ConnectionHandler) generator = WaveformGenerator(device) points = np.linspace(-5, 5, 512).astype(dtype) original = points.copy() generator.load_table(channel, points) - clipped = np.clip(points, -3.3, 3.3) + clipped = np.clip(np.asarray(points, dtype=float), -3.3, 3.3) expected = np.round((clipped + 3.3) / 6.6 * 511).astype(np.int16).tolist() assert [call.args[0] for call in device.send_int.call_args_list] == expected commands = [call.args[0] for call in device.send_byte.call_args_list] From 9ea2202d529a5bed481ed7a22f5efeb6076e026b Mon Sep 17 00:00:00 2001 From: Yash Anand <164618230+YashAnand69@users.noreply.github.com> Date: Thu, 8 Oct 2026 01:18:16 +0530 Subject: [PATCH 3/4] Reject boolean waveform tables before changing device state --- pslab/instrument/waveform_generator.py | 5 +++-- tests/test_waveform_table_validation.py | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/pslab/instrument/waveform_generator.py b/pslab/instrument/waveform_generator.py index 83e9b24..73ffcb7 100644 --- a/pslab/instrument/waveform_generator.py +++ b/pslab/instrument/waveform_generator.py @@ -292,7 +292,8 @@ def load_table(self, channel: str, points: np.ndarray): points : np.ndarray Array of voltage values which make up the waveform. Array length must be 512. Values outside the range -3.3 V to 3.3 V will be - clipped. All values must be finite real numbers. + clipped. All values must be finite real numbers. Complex and + boolean-dtype arrays are rejected. Raises ------ @@ -306,7 +307,7 @@ def _load_table(self, channel, points, mode="custom"): points = np.asarray(points) if points.ndim != 1 or points.size != self._HIGHRES_TABLE_SIZE: raise ValueError("Waveform table must contain exactly 512 values.") - if points.dtype.kind not in "biufOUS": + if points.dtype.kind not in "iufOUS": raise ValueError("Waveform voltages must be finite real values.") if points.dtype.kind in "OUS": try: diff --git a/tests/test_waveform_table_validation.py b/tests/test_waveform_table_validation.py index f8ca10c..da35ead 100644 --- a/tests/test_waveform_table_validation.py +++ b/tests/test_waveform_table_validation.py @@ -27,6 +27,7 @@ np.full(512, "not-a-voltage"), np.full(512, None, dtype=object), np.full(512, {}, dtype=object), + np.resize(np.array([False, True]), 512), ], ) def test_invalid_table_does_not_write_or_change_channel(points, loader): From face349a84ec8a1f1f18635e3e4655d48386d9cf Mon Sep 17 00:00:00 2001 From: Yash Anand <164618230+YashAnand69@users.noreply.github.com> Date: Thu, 8 Oct 2026 10:25:17 +0530 Subject: [PATCH 4/4] Reject boolean elements in object waveform tables --- pslab/instrument/waveform_generator.py | 9 ++++++--- tests/test_waveform_table_validation.py | 2 ++ 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/pslab/instrument/waveform_generator.py b/pslab/instrument/waveform_generator.py index 73ffcb7..0836206 100644 --- a/pslab/instrument/waveform_generator.py +++ b/pslab/instrument/waveform_generator.py @@ -292,8 +292,8 @@ def load_table(self, channel: str, points: np.ndarray): points : np.ndarray Array of voltage values which make up the waveform. Array length must be 512. Values outside the range -3.3 V to 3.3 V will be - clipped. All values must be finite real numbers. Complex and - boolean-dtype arrays are rejected. + clipped. All values must be finite real numbers. Complex values and + booleans are rejected. Raises ------ @@ -307,7 +307,10 @@ def _load_table(self, channel, points, mode="custom"): points = np.asarray(points) if points.ndim != 1 or points.size != self._HIGHRES_TABLE_SIZE: raise ValueError("Waveform table must contain exactly 512 values.") - if points.dtype.kind not in "iufOUS": + if points.dtype.kind not in "iufOUS" or ( + points.dtype.kind == "O" + and any(isinstance(value, (bool, np.bool_)) for value in points) + ): raise ValueError("Waveform voltages must be finite real values.") if points.dtype.kind in "OUS": try: diff --git a/tests/test_waveform_table_validation.py b/tests/test_waveform_table_validation.py index da35ead..c378ca1 100644 --- a/tests/test_waveform_table_validation.py +++ b/tests/test_waveform_table_validation.py @@ -28,6 +28,8 @@ np.full(512, None, dtype=object), np.full(512, {}, dtype=object), np.resize(np.array([False, True]), 512), + np.full(512, True, dtype=object), + np.array([np.bool_(False)] + [0.5] * 511, dtype=object), ], ) def test_invalid_table_does_not_write_or_change_channel(points, loader):