The Indian hardware engineering teams that scale successfully from prototype to volume production all have one thing in common: they stop thinking of oscilloscope measurements as “set up the bench, press the button, read the number off the screen” and start thinking of them as executable tests that run in CI alongside every firmware build. The hardware test discipline catches up with the software test discipline, and measurement becomes repeatable, version-controlled, and auditable.
pyPicoSDK is the Python wrapper around Pico Technology’s native PicoScope drivers, and it turns the PicoScope family, from the entry-level PicoScope 2000B up to the 6000E flagship, into a library a Python test suite can import. Combined with pytest, the de facto standard Python test framework, you get a production test harness that grows cleanly from 10 tests on a startup’s first prototype to 500+ tests on a mature product’s release-gate test suite.
This post is for Indian hardware engineering managers, test engineers, and firmware leads who want to know what a production-grade pytest + pyPicoSDK setup actually looks like, not a hobby-level snippet that works on one bench, but a framework that holds up when five test engineers across Bengaluru, Pune, and Chennai are all running the same suite on different hardware revisions. GSAS Micro Systems is India’s authorized Pico Technology partner, PicoScope hardware, hands-on pyPicoSDK bring-up support, and Python test integration consulting are available from our six India offices.
Why pytest is the right Python test framework for hardware test suites
Python has three major test frameworks: unittest (in the stdlib), nose (deprecated), and pytest. For hardware test suites specifically, pytest wins on three features that matter:
- Fixtures. pytest fixtures let you set up complex instrument state (open a PicoScope, configure channels, register buffers, establish a baseline) once at session scope, and every test that needs that state declares the fixture as a dependency. Teardown happens automatically when the session ends. This is the right abstraction for hardware resources, you do not want to re-open the USB connection 500 times across 500 tests.
- Parameterization. A single test function can be invoked with dozens of different input parameters (different bandwidths, different trigger levels, different signal frequencies) via
@pytest.mark.parametrize. The framework treats each parameterization as a distinct test, reports them separately, and lets you focus on one variant when debugging. - Plugins.
pytest-xdist(parallel execution),pytest-rerunfailures(retry flaky tests),pytest-html(human-readable reports), andpytest-junit(CI integration via JUnit XML) all exist as first-party plugins. A hardware team does not have to build these from scratch.
Folder layout for a production pytest + pyPicoSDK suite
The directory structure that scales for an Indian hardware team:
test-suite/
├── conftest.py # session-scoped fixtures (PicoScope connection,
│ # signal generator, DUT power supply, test report)
├── lib/
│ ├── __init__.py
│ ├── picoscope_helpers.py # pyPicoSDK abstraction layer
│ ├── measurement_helpers.py # measurement primitives (Vpp, rise time, FFT peak)
│ ├── tolerance.py # test-limit schema and comparison
│ └── reporting.py # structured result logging for MES integration
├── tests/
│ ├── test_power_rails.py # DC voltage sanity
│ ├── test_clock_integrity.py # clock signal timing
│ ├── test_can_decode.py # CAN bus protocol decode
│ ├── test_rise_fall_times.py # digital edge timing
│ ├── test_pwm_duty.py # motor drive PWM validation
│ └── ...
├── test_limits/
│ ├── default.yaml # default pass/fail thresholds
│ └── variant_a.yaml # per-variant overrides
├── requirements.txt
└── pytest.ini # pytest config + markers
This layout separates three concerns: the lib/ directory holds reusable measurement primitives, the tests/ directory holds executable test cases that call into lib/, and test_limits/ holds YAML files that define the pass/fail thresholds as data rather than hard-coded numbers in Python code. Variants of the same product (consumer vs industrial, 230 V vs 110 V mains, BLE vs Wi-Fi) get different YAML files with the same test functions operating against them.
The conftest.py fixtures: hardware setup once per session
# conftest.py
import pytest
from picosdk.ps5000a import ps5000a as ps
from picosdk.functions import assert_pico_ok
from lib.picoscope_helpers import PicoScope5000D
@pytest.fixture(scope="session")
def picoscope():
"""Open the PicoScope once per test session."""
scope = PicoScope5000D()
scope.open(resolution_bits=14)
yield scope
scope.close()
@pytest.fixture(scope="session")
def test_limits(pytestconfig):
"""Load the pass/fail thresholds for this test run."""
import yaml
variant = pytestconfig.getoption("--variant", default="default")
with open(f"test_limits/{variant}.yaml") as f:
return yaml.safe_load(f)
@pytest.fixture(scope="function")
def scope_ready(picoscope):
"""Reset the scope to a known configuration before each test."""
picoscope.reset_channels()
picoscope.clear_trigger()
yield picoscope
# Optional: snapshot failed-test waveforms to disk for later analysis
Session-scoped fixtures (the PicoScope connection, the test limits) run once at the start of the test run. Function-scoped fixtures (the per-test scope reset) run before every test. This gives you fast test execution, the USB handshake happens once, and clean isolation between tests.
Parameterized test cases: one function, many scenarios
# tests/test_rise_fall_times.py
import pytest
from lib.measurement_helpers import measure_rise_time
@pytest.mark.parametrize("signal_name,expected_rise_ns,tolerance_ns", [
("CLK_24MHZ", 5.0, 1.0),
("DATA_STROBE_A", 8.0, 2.0),
("DATA_STROBE_B", 8.0, 2.0),
("RESET_N", 20.0, 5.0),
("INT_REQ_0", 10.0, 3.0),
])
def test_digital_rise_time(scope_ready, signal_name, expected_rise_ns, tolerance_ns):
"""Verify digital signal rise time against the per-signal spec."""
waveform = scope_ready.capture_signal(signal_name)
rise_ns = measure_rise_time(waveform, threshold_low=0.2, threshold_high=0.8)
assert abs(rise_ns - expected_rise_ns) <= tolerance_ns, \
f"{signal_name} rise time {rise_ns:.1f} ns out of tolerance [{expected_rise_ns - tolerance_ns:.1f}, {expected_rise_ns + tolerance_ns:.1f}]"
pytest treats each tuple in the parametrize list as a separate test case. A run of this file reports 5 test results, one per signal, and a failure in DATA_STROBE_B does not mask the result for the other 4. For an Indian production test bench where a specific signal is problematic on one hardware revision, this isolation is exactly what the test engineer needs to triage the failure without re-running the entire suite.
Capture + measure helper pattern
The lib/picoscope_helpers.py and lib/measurement_helpers.py modules wrap the raw pyPicoSDK calls in something a test engineer can read:
# lib/picoscope_helpers.py
class PicoScope5000D:
def __init__(self):
self.handle = None
def open(self, resolution_bits=14):
self.handle = ctypes.c_int16()
status = ps.ps5000aOpenUnit(
ctypes.byref(self.handle),
None,
ps.PS5000A_DEVICE_RESOLUTION[f"PS5000A_DR_{resolution_bits}BIT"],
)
assert_pico_ok(status)
def capture_signal(self, channel, range_volts, sample_count, sample_interval_ns):
"""Block-mode capture returning a NumPy array."""
# ... pyPicoSDK setup, trigger, capture, readback ...
return waveform_numpy_array
def close(self):
if self.handle:
ps.ps5000aCloseUnit(self.handle)
# lib/measurement_helpers.py
import numpy as np
def measure_rise_time(waveform, sample_rate, threshold_low=0.2, threshold_high=0.8):
"""Measure 20-80 % rise time in nanoseconds."""
vmin, vmax = waveform.min(), waveform.max()
low = vmin + threshold_low * (vmax - vmin)
high = vmin + threshold_high * (vmax - vmin)
low_idx = np.argmax(waveform > low)
high_idx = np.argmax(waveform > high)
return (high_idx - low_idx) * (1e9 / sample_rate)
def measure_vpp(waveform):
"""Peak-to-peak voltage."""
return waveform.max() - waveform.min()
def measure_fft_peak(waveform, sample_rate):
"""Dominant frequency component in hertz."""
spectrum = np.abs(np.fft.rfft(waveform))
peak_bin = np.argmax(spectrum[1:]) + 1
return peak_bin * sample_rate / len(waveform)
These helpers are small, testable, and reusable across every test in the suite. When a new test engineer joins the team, the learning curve is “read measurement_helpers.py” rather than “understand the PicoSDK C API”.
CI integration: JUnit XML and pytest-html
For teams wiring the pytest + pyPicoSDK suite into a CI pipeline (Jenkins, GitHub Actions, GitLab CI, or a self-hosted runner at the production line), two output formats matter:
- JUnit XML:
pytest --junit-xml=results.xmlproduces a machine-readable results file that Jenkins, GitLab, and GitHub Actions all know how to parse and display as a test report. Failures show up in the build UI with stack traces and assertion messages. - HTML report:
pytest --html=report.html --self-contained-htmlproduces a human-readable report with every test’s status, duration, and captured stdout/stderr. This is the report format test engineers share with manufacturing engineering when a failure needs debugging.
For an Indian EMS line running the same test suite across thousands of boards per shift, pair pytest with pytest-xdist (parallel execution across multiple PicoScope benches) to scale linearly with the number of scopes in the test rack.
Integration with MES: per-unit test result upload
For Indian contract manufacturing lines that need per-unit traceability, medical under ISO 13485, automotive under IATF 16949, defence under DGQA, the pytest suite’s JUnit XML output is the raw material for MES upload. A small post-run script parses the JUnit XML, extracts per-test results keyed by the DUT serial number (captured at the start of the test session from a barcode scanner or device UID read), and POSTs the result to the line’s MES endpoint.
This closes the loop between the measurement the test engineer designed in test_rise_fall_times.py and the compliance record the auditor reviews three months later during a plant visit.
Hardware recommendations for Indian test benches
- Startup / prototype bench (1-10 tests): PicoScope 2205A MSO or PicoScope 2405A. USB 2.0, mixed-signal, budget-friendly entry point.
- Professional development bench (10-100 tests): PicoScope 5000D FlexRes or PicoScope 5444D. USB 3.0, 14/16-bit resolution, MSO variants for protocol-aware tests.
- Production test rack (100+ tests, parallel execution): PicoScope 6000E flagship. Multi-channel, deep memory, high throughput. Pair with
pytest-xdistand multiple scopes for parallel execution across DUTs. - Precision analog tests: PicoScope 4444 differential for 3-phase power measurements, PicoScope 4262 for 16-bit precision work.
Further reading
Also appears in:
Interested in Pico Technology tools?
Talk to our application engineers for personalized tool recommendations.
More from Pico Technology
View all →




