Skip to main content

Scientific test & measurement toolbox

Project description

PyTestLab logo

PyTestLab

Modern Python toolbox for laboratory
test-and-measurement automation, data management and analysis.

PyPI Docs Apache License


✨ Key Features

  • Unified driver layer – consistent high-level API across oscilloscopes, PSUs, DMMs, VNAs, AWGs, spectrum & power meters, DC loads, … (see pytestlab.instruments.*).
  • Chainable facade API – fluent method chaining for readable instrument control: psu.channel(1).set(5.0, 0.1).on().
  • Plug-and-play profiles – YAML descriptors validated by Pydantic & JSON-schema. Browse ready-made Keysight profiles in pytestlab/profiles/keysight.
  • Simulation mode – develop anywhere using the built-in SimBackend (no hardware required, deterministic outputs for CI).
  • Record & Replay – record real instrument sessions and replay them exactly for reproducible measurements, offline analysis, and regression testing with strict sequence validation.
  • Bench descriptors – group multiple instruments in one bench.yaml, define safety limits, automation hooks, traceability and measurement plans.
  • High-level measurement builder – notebook-friendly MeasurementSession for parameter sweeps that stores data as Polars DataFrames and exports straight to the experiment database.
  • Rich database – compressed storage of experiments & measurements with full-text search (MeasurementDatabase).
  • Powerful CLIpytestlab … commands to list/validate profiles, query instruments, convert benches to simulation, replay sessions, etc.
  • Extensible back-ends – VISA, Lamb server, pure simulation; drop-in new transports via the InstrumentIO protocol.
  • Docs & examples – Jupyter tutorials, MkDocs site, and 40+ ready-to-run scripts in examples/.

🚀 Quick Start

1. Install

pip install pytestlab           # core
pip install pytestlab[full]     # + plotting, uncertainties, etc.

Need VISA? Install NI-VISA or Keysight IO Libraries, then pip install pyvisa.

2. Hello Oscilloscope (simulated)

from pytestlab.instruments import AutoInstrument

def main():
    scope = AutoInstrument.from_config("keysight/DSOX1204G", simulate=True)
    scope.connect_backend()

    # simple façade usage with method chaining
    scope.channel(1).setup(scale=0.5).enable()
    scope.trigger.setup_edge(source="CH1", level=0.2)

    trace = scope.read_channels(1)      # Polars DataFrame
    print(trace.head())

    scope.close()

main()

3. Build a Bench

# bench.yaml  (excerpt)
bench_name: "Power-Amp Characterisation"
simulate: false           # set to true for dry-runs / CI
instruments:
  psu:
    profile: "keysight/EDU36311A"
    address: "TCPIP0::172.22.1.5::inst0::INSTR"
    safety_limits:
      channels:
        1: {voltage: {max: 6.0}, current: {max: 3}}
  dmm:
    profile: "keysight/34470A"
    address: "USB0::0x0957::0x1B07::MY56430012::INSTR"
import pytestlab

def run():
    with pytestlab.Bench.open("bench.yaml") as bench:
        v = bench.dmm.measure_voltage_dc()
        print("Measured:", v.values, v.units)

run()

4. Record & Replay Sessions

Record real instrument interactions and replay them exactly:

# Record a measurement session
pytestlab replay record my_measurement.py --bench bench.yaml --output session.yaml

# Replay the recorded session
pytestlab replay run my_measurement.py --session session.yaml

Perfect for reproducible measurements, offline analysis, and catching script changes!


🔄 Record & Replay Mode

PyTestLab's Record & Replay system enables you to capture real instrument interactions and replay them with exact sequence validation. This powerful feature supports reproducible measurements, offline development, and regression testing.

Core Benefits

  • 🎯 Reproducible Measurements – Exact same SCPI command sequences every time
  • 🛡️ Measurement Integrity – Scripts cannot deviate from validated sequences
  • 🔬 Offline Analysis – Run complex measurements without real hardware
  • 🧪 Regression Testing – Catch unintended script modifications immediately

How It Works

  1. Recording Phase: The SessionRecordingBackend wraps your real instrument backends and logs all commands, responses, and timestamps to a YAML session file.

  2. Replay Phase: The ReplayBackend loads the session and validates that your script executes the exact same command sequence. Any deviation triggers a ReplayMismatchError.

Usage Examples

Basic Recording & Replay

# Record a measurement with real instruments
pytestlab replay record voltage_sweep.py --bench lab_bench.yaml --output sweep_session.yaml

# Replay the exact sequence (simulated)
pytestlab replay run voltage_sweep.py --session sweep_session.yaml

Programmatic Usage

from pytestlab.instruments import AutoInstrument
from pytestlab.instruments.backends import ReplayBackend

def main():
    # Load a recorded session
    replay_backend = ReplayBackend("recorded_session.yaml")

    # Create instrument with replay backend
    psu = AutoInstrument.from_config(
        "keysight/EDU36311A",
        backend_override=replay_backend
    )

    psu.connect_backend()

    # This will replay the exact recorded sequence
    psu.set_voltage(1, 5.0)
    voltage = psu.read_voltage(1)

    psu.close()

main()

Session File Format

psu:
  profile: keysight/EDU36311A
  log:
  - type: query
    command: '*IDN?'
    response: 'Keysight Technologies,EDU36311A,CN61130056,K-01.08.03-01.00-01.08-02.00'
    timestamp: 0.029241038020700216
  - type: write
    command: 'VOLT 5.0, (@1)'
    timestamp: 0.8096857140189968
  - type: query
    command: 'MEAS:VOLT? (@1)'
    response: '+4.99918100E+00'
    timestamp: 1.614894539990928

Error Detection

If your script deviates from the recorded sequence:

# During recording: set_voltage(1, 5.0)
# During replay: set_voltage(1, 3.0)  # ← Different value!

# Raises: ReplayMismatchError: Expected 'VOLT 5.0, (@1)' but got 'VOLT 3.0, (@1)'

Advanced Features

  • Multi-instrument sessions – Record PSU, oscilloscope, DMM interactions simultaneously
  • Timestamp preservation – Exact timing information for analysis
  • Automatic error checking – Captures instrument :SYSTem:ERRor? queries
  • CLI integration – Full command-line workflow support
  • Backend flexibility – Works with VISA, LAMB, and custom backends

See examples/replay_mode/ for complete working examples and tutorials.


📈 Plotting

PyTestLab includes a lightweight, backend-agnostic plotting layer with a default matplotlib backend. Install plotting extras:

pip install 'pytestlab[plot]'

What you get

  • MeasurementResult.plot() – plot numeric arrays or polars.DataFrame results.
  • Experiment.plot() – plot the experiment's internal DataFrame.
  • MeasurementSession.plot() – plot data gathered during a session after run().

All plotting uses a declarative PlotSpec with sensible defaults, and automatically picks "Time (s)" as x-axis if available.

Basic usage

from pytestlab.plotting import PlotSpec
from pytestlab.experiments import Experiment

exp = Experiment("Demo")
exp.add_trial({"Time (s)": [0,1,2], "Voltage (V)": [0.0, 1.2, 2.4]})
fig = exp.plot(PlotSpec(title="Experiment Plot"))

MeasurementResult example (1D array)

import numpy as np
from pytestlab.experiments import MeasurementResult
from pytestlab.plotting import PlotSpec

arr = np.sin(np.linspace(0, 2*np.pi, 500))
res = MeasurementResult(values=arr, instrument="sim", units="V", measurement_type="sine", sampling_rate=1000.0)
fig = res.plot(PlotSpec(title="Sine Wave"))

MeasurementSession example

from pytestlab.measurements import MeasurementSession
from pytestlab.plotting import PlotSpec

with MeasurementSession("Quick Session") as session:
    @session.acquire
    def sample():
        return {"Time (s)": [0,1,2], "Value": [0.1, 0.2, 0.1]}

    experiment = session.run()
    fig = session.plot(PlotSpec(title="Session Data"))

Oscilloscope example – Keysight DSOX1204G (simulated)

from pytestlab.instruments import AutoInstrument
from pytestlab.plotting import PlotSpec

scope = AutoInstrument.from_config("keysight/DSOX1204G", simulate=True)
scope.connect_backend()

result = scope.read_channels(1)  # MeasurementResult with a Polars DataFrame inside
fig = result.plot(PlotSpec(title="DSOX1204G CH1"))

scope.close()

See runnable scripts in examples/plot_*.


🔧 Chainable Facade API

PyTestLab features a fluent, chainable API that makes instrument control code clean and readable:

Power Supply Example

from pytestlab.instruments import AutoInstrument

psu = AutoInstrument.from_config("keysight/E36312A")

# Method chaining for clean configuration
psu.channel(1).set(voltage=5.0, current_limit=0.1).slew(duration_s=1.0).on()
psu.channel(2).set(voltage=3.3, current_limit=0.05).on()

# Measurements
voltage = psu.channel(1).measure_voltage()
current = psu.channel(1).measure_current()

# Clean shutdown
psu.channel(1).off()
psu.channel(2).off()
psu.close()

Oscilloscope Example

scope = AutoInstrument.from_config("keysight/DSOX1204G", simulate=True)
scope.connect_backend()

# Configure multiple channels with chaining
scope.channel(1).setup(scale=0.5, offset=0, coupling="DC").enable()
scope.channel(2).setup(scale=1.0, coupling="AC").enable()

# Setup trigger and acquisition
scope.trigger.setup_edge(source="CH1", level=0.2, slope="POSITIVE")
scope.acquisition.set_acquisition_type("NORMAL").set_acquisition_mode("REAL_TIME")

# Capture data
scope.trigger.single()
traces = scope.read_channels([1, 2])

scope.close()

Benefits

  • Direct function calls – No complex concurrency patterns needed
  • Method chaininginstrument.channel(1).set(5.0).on()
  • Readable sequences – Complex setups in clean, linear code
  • Error prevention – Method chaining encourages proper instrument setup

📚 Documentation

Section Link
Installation docs/installation.md
10-minute tour (Jupyter) docs/tutorials/10_minute_tour.ipynb
User Guide docs/user_guide/*
API Guide docs/user_guide/api_guide.md
Bench descriptors docs/user_guide/bench_descriptors.md
Chainable Facades docs/user_guide/chainable_facades.md
Plotting docs/user_guide/plotting.md
API reference docs/api/*
Instrument profile gallery docs/profiles/gallery.md
Tutorials
Compliance and Audit docs/tutorials/compliance.ipynb
Custom Validations docs/tutorials/custom_validations.ipynb
Profile Creation docs/tutorials/profile_creation.ipynb
Migration Guide docs/tutorials/migration_guide.ipynb

HTML docs hosted at https://pytestlab.readthedocs.io (builds from docs/).


📊 Measurement Sessions

PyTestLab's MeasurementSession provides a powerful framework for parameter sweeps and data acquisition:

Basic Parameter Sweep

from pytestlab.measurements import MeasurementSession
import numpy as np

with MeasurementSession("Voltage Response Test") as session:
    # Define sweep parameters
    session.parameter("voltage", np.linspace(0, 5, 10), unit="V")
    session.parameter("delay", [0.1, 0.5], unit="s")

    # Setup instruments
    psu = session.instrument("psu", "keysight/EDU36311A", simulate=True)
    dmm = session.instrument("dmm", "keysight/34470A", simulate=True)

    # Define measurement function
    @session.acquire
    def measure_response(voltage, delay, psu, dmm):
        psu.channel(1).set_voltage(voltage).on()
        time.sleep(delay)

        result = dmm.measure_voltage_dc()
        psu.channel(1).off()

        return {"measured_voltage": result.values}

    # Execute sweep
    experiment = session.run(show_progress=True)
    print(f"Collected {len(experiment.data)} measurements")

Bench Integration

from pytestlab import Bench
from pytestlab.measurements import MeasurementSession

# Use existing bench configuration with measurement session
with Bench.open("lab_bench.yaml") as bench:
    with MeasurementSession(bench=bench) as session:
        # Session inherits instruments and experiment context from bench
        session.parameter("frequency", np.logspace(3, 6, 50), unit="Hz")

        @session.acquire
        def frequency_response(frequency, psu, scope, fgen):
            fgen.channel(1).setup_sine(frequency=frequency, amplitude=1.0)
            scope.trigger.single()
            return {"amplitude": scope.measure_amplitude(1)}

        experiment = session.run()
        # Data automatically saved to bench database

⚡ Parallel Tasks

Execute background operations simultaneously with data acquisition using @session.task:

PSU Ramping with Continuous Monitoring

with MeasurementSession("Power Ramp Analysis") as session:
    psu = session.instrument("psu", "keysight/E36311A", simulate=True)
    dmm = session.instrument("dmm", "keysight/34470A", simulate=True)

    # Background task: Continuously ramp voltage
    @session.task
    def voltage_ramp(psu, stop_event):
        while not stop_event.is_set():
            # Ramp up 1V to 5V over 4 seconds
            for v in np.linspace(1.0, 5.0, 20):
                if stop_event.is_set(): break
                psu.channel(1).set_voltage(v)
                time.sleep(0.2)

            # Ramp down 5V to 1V over 4 seconds
            for v in np.linspace(5.0, 1.0, 20):
                if stop_event.is_set(): break
                psu.channel(1).set_voltage(v)
                time.sleep(0.2)

    # Acquisition: Monitor voltage every 100ms
    @session.acquire
    def monitor_voltage(dmm):
        voltage = dmm.measure_voltage_dc()
        return {"measured_voltage": voltage.values}

    # Run for 30 seconds with 100ms acquisition interval
    experiment = session.run(duration=30.0, interval=0.1)
    print(f"Captured {len(experiment.data)} voltage points during ramp")

Multiple Parallel Tasks

with MeasurementSession("Complex Power Analysis") as session:
    psu = session.instrument("psu", "keysight/E36311A", simulate=True)
    load = session.instrument("load", "keysight/EL34143A", simulate=True)
    scope = session.instrument("scope", "keysight/DSOX1204G", simulate=True)

    # Task 1: Voltage stepping
    @session.task
    def voltage_steps(psu, stop_event):
        voltages = [3.3, 5.0, 12.0, 5.0, 3.3]
        while not stop_event.is_set():
            for v in voltages:
                if stop_event.is_set(): break
                psu.channel(1).set_voltage(v)
                time.sleep(2.0)

    # Task 2: Load pulsing
    @session.task
    def load_pulsing(load, stop_event):
        load.set_mode("CC")
        while not stop_event.is_set():
            load.set_current(1.0).enable_input(True)
            time.sleep(1.0)
            if stop_event.is_set(): break
            load.set_current(0.1)
            time.sleep(1.0)

    # Task 3: Scope triggering
    @session.task
    def scope_triggering(scope, stop_event):
        scope.channel(1).setup(scale=1.0).enable()
        scope.trigger.setup_edge(source="CH1", level=2.5)
        while not stop_event.is_set():
            scope.trigger.single()
            time.sleep(0.5)

    # Acquisition: Monitor all parameters
    @session.acquire
    def power_monitoring(psu, scope):
        voltage = psu.channel(1).get_voltage()
        current = psu.channel(1).get_current()
        power = voltage * current

        try:
            scope_data = scope.read_channels(1)
            scope_samples = len(scope_data)
        except:
            scope_samples = 0

        return {
            "supply_voltage": voltage,
            "supply_current": current,
            "power_consumption": power,
            "scope_samples": scope_samples
        }

    # Run all tasks in parallel for 20 seconds
    experiment = session.run(duration=20.0, interval=0.3)

💾 Database & Persistence

PyTestLab includes a powerful measurement database with full-text search and automatic data management:

Basic Database Usage

from pytestlab.experiments import MeasurementDatabase

# Create/open database
with MeasurementDatabase("lab_measurements") as db:
    # Store experiment
    experiment_id = db.store_experiment(None, experiment)  # Auto-generated ID
    print(f"Stored experiment: {experiment_id}")

    # List all experiments
    experiments = db.list_experiments()
    print(f"Database contains {len(experiments)} experiments")

    # Search experiments by description
    results = db.search_experiments("voltage sweep")
    for result in results:
        print(f"Found: {result['title']} - {result['description']}")

    # Retrieve specific experiment
    exp = db.retrieve_experiment(experiment_id)
    print(f"Retrieved data: {len(exp.data)} measurements")

Bench-Database Integration

# bench.yaml with database configuration
bench_config = """
bench_name: "Automated Test Station"
experiment:
  title: "Device Characterization"
  database_path: "station_measurements.db"
  operator: "Lab Station A"

instruments:
  psu:
    profile: "keysight/E36311A"
    address: "TCPIP0::192.168.1.100::INSTR"
"""

with Bench.open("bench.yaml") as bench:
    # Database automatically initialized from bench config
    print(f"Database: {bench.db.db_path}")

    with MeasurementSession(bench=bench) as session:
        # ... perform measurements ...
        experiment = session.run()
        # Experiment automatically saved to bench database

    # Query database
    recent_experiments = bench.db.list_experiments()
    print(f"Recent experiments: {len(recent_experiments)}")

Advanced Database Features

with MeasurementDatabase("advanced_lab") as db:
    # Full-text search across descriptions and notes
    power_tests = db.search_experiments("power consumption efficiency")
    thermal_tests = db.search_experiments("temperature cycling")

    # Database statistics
    stats = db.get_stats()
    print(f"Total experiments: {stats['experiments']}")
    print(f"Total measurements: {stats['measurements']}")

    # Cross-experiment analysis
    all_experiments = [db.retrieve_experiment(eid) for eid in db.list_experiments()]

    # Combine data from multiple experiments
    combined_data = pl.concat([exp.data for exp in all_experiments])
    print(f"Combined dataset: {len(combined_data)} total measurements")

🔒 Compliance & Audit

PyTestLab provides built-in compliance features for regulated environments:

Automatic Measurement Signing

# Compliance features are automatically enabled
from pytestlab.instruments import AutoInstrument

dmm = AutoInstrument.from_config("keysight/34470A")
dmm.connect_backend()

# Every measurement is automatically signed
result = dmm.measure_voltage_dc()

# Access compliance envelope
print("Measurement signature:", result.envelope['signature'])
print("Measurement hash:", result.envelope['sha'])
print("Timestamp:", result.envelope['timestamp'])

# Provenance information (PROV-O compatible)
print("Provenance:", result.prov)

# Save measurement with compliance envelope
result.save("voltage_measurement.h5")
# Creates: voltage_measurement.h5 (data) + voltage_measurement.h5.env.json (envelope)

Audit Trail

from pytestlab.compliance import AuditTrail

# Audit trail automatically tracks all measurement operations
# Review audit history
with open(f"{Path.home()}/.pytestlab/audit.sqlite", 'r') as audit_db:
    # Audit entries include:
    # - Actor (who performed the action)
    # - Action (what was done)
    # - Timestamp (when it occurred)
    # - Envelope (cryptographic proof)
    print("All measurement operations are automatically audited")

Database Compliance Integration

with MeasurementDatabase("compliant_lab") as db:
    # Store measurement with automatic envelope persistence
    measurement_id = db.store_measurement(None, signed_result)

    # Retrieve measurement with envelope verification
    retrieved = db.retrieve_measurement(measurement_id)

    # Envelopes are stored in separate table for integrity
    # Query: SELECT * FROM measurement_envelopes WHERE codename = ?
    print("Compliance envelopes automatically persisted")

Instrument State Signatures

from pytestlab.compliance import Signature

# Create instrument state snapshot
psu = AutoInstrument.from_config("keysight/E36311A")
psu.connect_backend()

# Configure instrument
psu.channel(1).set_voltage(5.0).set_current_limit(1.0)

# Create cryptographic signature of current state
signature = Signature.create(psu)
print("Instrument configuration hash:", signature.hash)

# Verify instrument state hasn't changed
later_signature = Signature.create(psu)
if signature.verify(later_signature):
    print("✅ Instrument configuration unchanged")
else:
    print("⚠️ Instrument configuration has been modified")

🧑‍💻 Contributing

Pull requests are welcome! See CONTRIBUTING.md and the Code of Conduct. Run the test-suite (pytest), type-check (mypy), lint/format (ruff), and keep commits conventional (cz c).


🗜️ License

Apache-2.0 © 2023 Emmanuel Olowe & contributors.

Commercial support / custom drivers? Open an issue or contact support@pytestlab.org.


Built with ❤️  by scientists, for scientists.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

pytestlab-0.2.3.tar.gz (13.2 MB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

pytestlab-0.2.3-py3-none-any.whl (218.2 kB view details)

Uploaded Python 3

File details

Details for the file pytestlab-0.2.3.tar.gz.

File metadata

  • Download URL: pytestlab-0.2.3.tar.gz
  • Upload date:
  • Size: 13.2 MB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.7

File hashes

Hashes for pytestlab-0.2.3.tar.gz
Algorithm Hash digest
SHA256 5a9f6e4985fcb278df1c1d6fef9a8cc98956343975908ccdd1c936631bf377f5
MD5 ed88f02d3206cbb8aa5603be1c81469d
BLAKE2b-256 c6912e520658160c2270ad1098647dc6abe523dceaa8a0f135caa256af24fadf

See more details on using hashes here.

Provenance

The following attestation bundles were made for pytestlab-0.2.3.tar.gz:

Publisher: publish_release.yml on labiium/pytestlab

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file pytestlab-0.2.3-py3-none-any.whl.

File metadata

  • Download URL: pytestlab-0.2.3-py3-none-any.whl
  • Upload date:
  • Size: 218.2 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.7

File hashes

Hashes for pytestlab-0.2.3-py3-none-any.whl
Algorithm Hash digest
SHA256 d956e6c5b927c1ee1c7c5bce0d221d4269ff41580dd9ee8c97c792462dc04d69
MD5 d5b1db4e3f975d55d4c9e915fed67d17
BLAKE2b-256 e37087b3ea6f7603ceb2079ad6115dac30c8066ae81315ed61d501f668b4677b

See more details on using hashes here.

Provenance

The following attestation bundles were made for pytestlab-0.2.3-py3-none-any.whl:

Publisher: publish_release.yml on labiium/pytestlab

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page