From ba4c3f17262e684a4676d41f4c77e6bdc35d585d Mon Sep 17 00:00:00 2001 From: Kartik Khandelwal <78846877+Illtrapx@users.noreply.github.com> Date: Tue, 20 Oct 2026 15:28:40 +0200 Subject: [PATCH 1/2] fix(logic_analyzer): count pulses from the measured signal period The firmware does not implement START_COUNTING and FETCH_COUNT, so count_pulses() always returned 0. Measure the period of the signal with a capture instead, and calculate the number of pulses from it. --- pslab/instrument/logic_analyzer.py | 62 ++++++++++++------------------ tests/test_logic_analyzer.py | 48 +++++++++++++++++++++++ 2 files changed, 73 insertions(+), 37 deletions(-) diff --git a/pslab/instrument/logic_analyzer.py b/pslab/instrument/logic_analyzer.py index 8888034..42e81db 100644 --- a/pslab/instrument/logic_analyzer.py +++ b/pslab/instrument/logic_analyzer.py @@ -713,12 +713,15 @@ def get_states(self) -> Dict[str, bool]: } def count_pulses( - self, channel: str = "FRQ", interval: float = 1, block: bool = True - ) -> Union[int, None]: + self, channel: str = "FRQ", interval: float = 1, timeout: float = 1 + ) -> int: """Count pulses on a digital input. - The counter is 16 bits, so it will roll over after 65535 pulses. This - method can be used at the same time as the oscilloscope. + The number of pulses is calculated from the period of the signal, which + is measured by capturing logic events. This method can be used at the + same time as the oscilloscope. The signal must be slow enough for the + capture to resolve the time between two events, and fast enough for two + events to be captured within the timeout. Parameters ---------- @@ -727,46 +730,31 @@ def count_pulses( interval : float, optional Time in seconds during which to count pulses. The default value is 1 second. - block : bool, optional - Whether to block while counting pulses or not. If False, this - method will return None, and the pulses must be manually fetched - using :meth:`fetch_pulse_count`. Additionally, the interval - argument has no meaning if block is False; the counter will keep - counting even after the interval time has expired. The default - value is True. + timeout : float, optional + Timeout in seconds before cancelling the capture. The default value + is 1 second. Returns ------- - Union[int, None] - Number of pulses counted during the interval, or None if block is - False. + int + Number of pulses counted during the interval, or 0 if the signal is + too slow, or too fast, to be measured. """ - self._reset_prescaler() - self._device.send_byte(CP.COMMON) - self._device.send_byte(CP.START_COUNTING) - self._device.send_byte(self._channels[channel].number) - self._device.get_ack() - - if block: - time.sleep(interval) - else: - return + timestamps = self.capture( + channel, 2, modes=["sixteen rising"], timeout=timeout + )[0] - return self.fetch_pulse_count() + try: + # Sixteen rising edges are captured, so the time between two + # captured events covers 16 periods of the signal. + period = (timestamps[1] - timestamps[0]) * 1e-6 / 16 + except IndexError: + return 0 - def fetch_pulse_count(self) -> int: - """Return the number of pulses counted since calling :meth:`count_pulses`. + if period <= 0: + return 0 - Returns - ------- - int - Number of pulses counted since calling :meth:`count_pulses`. - """ - self._device.send_byte(CP.COMMON) - self._device.send_byte(CP.FETCH_COUNT) - count = self._device.get_int() - self._device.get_ack() - return count + return int(round(interval / period)) def _reset_prescaler(self): self._device.send_byte(CP.TIMING) diff --git a/tests/test_logic_analyzer.py b/tests/test_logic_analyzer.py index 550a929..51ea32b 100644 --- a/tests/test_logic_analyzer.py +++ b/tests/test_logic_analyzer.py @@ -280,3 +280,51 @@ def test_count_pulses(la): pulses = la.count_pulses("LA2", interval) expected_pulses = FREQUENCY * interval assert expected_pulses == pytest.approx(pulses, rel=0.1) # Pretty bad accuracy. + + +# The tests below do not need a connected PSLab, since the capture is mocked. + + +class FakeDevice: + """Stand-in for a SerialHandler, recording the commands it receives.""" + + def __init__(self): + self.commands = [] + + def send_byte(self, value): + self.commands.append(value) + + def send_int(self, value): + self.commands.append(value) + + def get_ack(self): + return True + + def get_byte(self): + return 0 + + def get_int(self): + return 0 + + +def test_count_pulses_from_the_measured_period(mocker): + la = LogicAnalyzer(FakeDevice()) + # A 100 kHz signal, sampled every 16th rising edge. + mocker.patch.object(la, "capture", return_value=[np.array([0, 160])]) + assert la.count_pulses("LA2", 0.2) == FREQUENCY * 0.2 + + +@pytest.mark.parametrize("timestamps", [np.array([]), np.array([500, 500])]) +def test_count_pulses_without_a_measurable_period(mocker, timestamps): + la = LogicAnalyzer(FakeDevice()) + mocker.patch.object(la, "capture", return_value=[timestamps]) + assert la.count_pulses("LA2", 0.2) == 0 + + +def test_count_pulses_does_not_use_the_firmware_counter(mocker): + device = FakeDevice() + la = LogicAnalyzer(device) + mocker.patch.object(la, "capture", return_value=[np.array([0, 160])]) + la.count_pulses("LA2", 0.2) + assert CP.START_COUNTING not in device.commands + assert CP.FETCH_COUNT not in device.commands From 62f063de32b34a8e8e5106d0fcc5208d2ee5729d Mon Sep 17 00:00:00 2001 From: Kartik Khandelwal Date: Tue, 20 Oct 2026 17:30:06 +0200 Subject: [PATCH 2/2] fix(logic_analyzer): restore the channel map and reject negative intervals count_pulses() hands a channel name to capture(), which maps that name to channel one and leaves it there, so a later capture(2, ...) selected the same input twice. Save the map and put it back in a finally block, the way measure_frequency() does. A negative interval used to raise ValueError from time.sleep(); it now raised ValueError before the capture instead of returning a negative count. The docstring claimed the method can run alongside the oscilloscope, but the capture it uses stops the logic capture and clears the shared buffer. --- pslab/instrument/logic_analyzer.py | 20 +++++++++++--- tests/test_logic_analyzer.py | 42 ++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/pslab/instrument/logic_analyzer.py b/pslab/instrument/logic_analyzer.py index 42e81db..5e59ed4 100644 --- a/pslab/instrument/logic_analyzer.py +++ b/pslab/instrument/logic_analyzer.py @@ -718,7 +718,7 @@ def count_pulses( """Count pulses on a digital input. The number of pulses is calculated from the period of the signal, which - is measured by capturing logic events. This method can be used at the + is measured by capturing logic events. This method cannot be used at the same time as the oscilloscope. The signal must be slow enough for the capture to resolve the time between two events, and fast enough for two events to be captured within the timeout. @@ -739,10 +739,22 @@ def count_pulses( int Number of pulses counted during the interval, or 0 if the signal is too slow, or too fast, to be measured. + + Raises + ------ + ValueError if the interval is negative. """ - timestamps = self.capture( - channel, 2, modes=["sixteen rising"], timeout=timeout - )[0] + if interval < 0: + raise ValueError("Interval cannot be negative.") + + tmp = self._channel_one_map + self._channel_one_map = channel + try: + timestamps = self.capture( + channel, 2, modes=["sixteen rising"], timeout=timeout + )[0] + finally: + self._channel_one_map = tmp try: # Sixteen rising edges are captured, so the time between two diff --git a/tests/test_logic_analyzer.py b/tests/test_logic_analyzer.py index 51ea32b..a6b7895 100644 --- a/tests/test_logic_analyzer.py +++ b/tests/test_logic_analyzer.py @@ -328,3 +328,45 @@ def test_count_pulses_does_not_use_the_firmware_counter(mocker): la.count_pulses("LA2", 0.2) assert CP.START_COUNTING not in device.commands assert CP.FETCH_COUNT not in device.commands + + +def capture_updating_the_channel_map(la, timestamps): + """Stand-in for capture(), which maps a channel name to channel one.""" + + def capture(channels, events, **kwargs): + la._check_arguments(channels, events) + return [timestamps] + + return capture + + +def test_count_pulses_restores_the_channel_map(mocker): + la = LogicAnalyzer(FakeDevice()) + mocker.patch.object( + la, + "capture", + side_effect=capture_updating_the_channel_map(la, np.array([0, 160])), + ) + assert la.count_pulses("LA2", 0.2) == FREQUENCY * 0.2 + assert la._channel_one_map == "LA1" + + +def test_count_pulses_restores_the_channel_map_when_capture_fails(mocker): + la = LogicAnalyzer(FakeDevice()) + + def capture(channels, events, **kwargs): + la._check_arguments(channels, events) + raise ValueError("capture failed") + + mocker.patch.object(la, "capture", side_effect=capture) + with pytest.raises(ValueError): + la.count_pulses("LA2", 0.2) + assert la._channel_one_map == "LA1" + + +def test_count_pulses_rejects_a_negative_interval(mocker): + la = LogicAnalyzer(FakeDevice()) + capture = mocker.patch.object(la, "capture", return_value=[np.array([0, 160])]) + with pytest.raises(ValueError): + la.count_pulses("LA2", -0.2) + assert not capture.called