Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,34 @@ API changes can land even when the minor version is unchanged.

## [Unreleased]

### Added
- `SR830Device` (and `DebugSR830Device`): Stanford Research SR830 DSP lock-in
amplifier over a Prologix GPIB-USB controller. It combines several capabilities:
`AnalogInputStreamDevice` (the four rear-panel Aux A/D inputs via `OAUX?`, plus
hardware-timed buffered acquisition of the demodulated outputs from the internal
data buffer), `AnalogOutputDevice` (the four rear-panel Aux D/A outputs via
`AUXV`), `PhaseLockedDetectionDevice` (X/Y/R/theta, reference frequency, signal
input source, sensitivity, and time constant), and `TriggerableDevice` (the
rear-panel TRIG IN). Enums: `AuxInput`, `AuxOutput`, `StreamChannel`,
`InputSource`. `doInitializeDevice` self-discovers the Prologix among the
connected FTDI adaptors by confirming `*IDN?`, and pins the adaptor's serial.
- `PrologixGPIBPort` (`hardwarelibrary/communication/`): a `SerialPort` subclass
that speaks the Prologix GPIB-USB controller `++` protocol. GPIB instruments
talk to it with the ordinary `readString`/`writeString` primitives; the `++read
eoi` handshake is encapsulated in its `readString`.
- New DAQ capability contracts in `daq/daqdevice.py`: `PhaseLockedDetectionDevice`
(lock-in / phase-locked detection), `TriggerableDevice` with the `TriggerSource`
enum, and the `SampleClock` enum for stream sample clocking.

### Changed
- `AnalogInputStreamDevice`: the sample-rate parameter of
`configureStream`/`acquireWaveform` is renamed `scanRate` -> `sampleRate`.
`LabjackDevice.configureStream` keeps `scanRate` as a temporary deprecated
synonym for existing callers.
- `LabjackDevice` now imports `u3` (LabJackPython) lazily at point of use, so
`import hardwarelibrary.daq` (and the new SR830 driver) works on hosts that do
not have LabJackPython installed.

## [1.3.3] - 2026-07-08

### Changed
Expand Down
1 change: 1 addition & 0 deletions hardwarelibrary/communication/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from .communicationport import *
from .serialport import SerialPort
from .prologixgpibport import PrologixGPIBPort
from .tcpport import TCPPort, UnableToOpenTCPPort
from .labviewtcpport import LabviewTCPPort
from .usbport import USBPort
Expand Down
75 changes: 75 additions & 0 deletions hardwarelibrary/communication/prologixgpibport.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
from .serialport import SerialPort


class PrologixGPIBPort(SerialPort):
"""A GPIB instrument reached through a Prologix GPIB-USB controller.

The Prologix controller enumerates as a generic FTDI serial port
(VID 0x0403 / PID 0x6001), so this IS a SerialPort: it inherits the FTDI
discovery and every read/write primitive unchanged, and adds only the
controller handshake in open(). GPIB payloads are ordinary ASCII, so callers
use the inherited readString/writeString (and the writeStringReadMatch
family); there is no GPIB-specific read/write method.

The controller runs in manual-read mode (++auto 0): a query is read back by
an explicit '++read eoi', which this class issues inside readString(). So the
driver still talks to the port with plain writeString/readString (and the
writeStringReadMatch family) and never touches a GPIB-specific method. Manual
read is used rather than ++auto 1 because auto mode addresses the instrument
to talk after every command, including commands with no reply (e.g. SENS),
which leaves the bus in an error state.
"""

def __init__(self, gpibAddress, portPath=None, idVendor=0x0403,
idProduct=0x6001, serialNumber=None):
"""Create a port for the instrument at GPIB address gpibAddress.

gpibAddress is the addressed instrument's GPIB (IEEE-488) primary
address. The remaining arguments locate the FTDI-based Prologix adaptor
itself and are forwarded to SerialPort: an explicit portPath, or the
adaptor's idVendor/idProduct/serialNumber for discovery.
"""
super().__init__(idVendor=idVendor, idProduct=idProduct,
serialNumber=serialNumber, portPath=portPath)
self.gpibAddress = gpibAddress

def open(self, baudRate=115200, timeout=1.0):
"""Open the serial line, then apply the Prologix controller handshake.

Configuring the controller in open() (rather than a separate step the
caller must remember) mirrors SerialPort.open()/USBPort.open(): once
open() returns, the port is ready for readString/writeString.
"""
super().open(baudRate=baudRate, timeout=timeout)
self.configureController()

def configureController(self):
"""Send the one-time Prologix controller handshake.

++eos 2 appends a line-feed to commands forwarded to the instrument (the
SR830 and most GPIB instruments accept LF or EOI as a terminator).
++eot_enable 0 keeps the controller from appending its own terminator to
replies: the instrument already ends its response with CR-LF, so a second
appended LF would be left stranded in the buffer and corrupt the next
read.
"""
for command in ("++mode 1",
"++addr {0}".format(self.gpibAddress),
"++auto 0",
"++eoi 1",
"++eos 2",
"++eot_enable 0",
"++read_tmo_ms 1500"):
self.writeString(command + "\n")

def readString(self, endPoint=None) -> str:
"""Read one reply from the addressed instrument.

In manual-read mode (++auto 0) the controller does not forward a reply
until told to, so this issues '++read eoi' (read until the instrument
asserts EOI) and then reads the forwarded bytes with the inherited serial
readString. Encapsulating the handshake here lets the driver use the
ordinary readString/writeStringReadMatch primitives unchanged.
"""
self.writeString("++read eoi\n")
return super().readString(endPoint)
8 changes: 7 additions & 1 deletion hardwarelibrary/daq/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,11 @@
from .daqdevice import (
AnalogInputDevice, AnalogOutputDevice, AnalogIODevice, AnalogInputStreamDevice,
DigitalInputDevice, DigitalOutputDevice, DigitalIODevice,
PhaseLockedDetectionDevice, InputSource,
TriggerableDevice, TriggerSource, SampleClock,
)
from .labjackdevice import LabjackDevice, DebugLabjackDevice
from .labjackdevice import LabjackDevice, DebugLabjackDevice
from .sr830device import (
SR830Device, DebugSR830Device, DebugPrologixGPIBPort,
AuxInput, AuxOutput, StreamChannel,
)
184 changes: 175 additions & 9 deletions hardwarelibrary/daq/daqdevice.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,19 +43,19 @@ class AnalogInputStreamDevice(AnalogInputDevice):
"""Hardware-timed analog input (waveform acquisition).

Combine with PhysicalDevice in a driver. The driver implements the four
streaming primitives; acquireWaveform is provided on top of them. scanRate is
the per-channel sample rate in Hz; readStream returns one block of samples as
{channel: [volts, ...]}. The aggregate rate (scanRate times the number of
channels) is the hardware limit, not scanRate alone.
streaming primitives; acquireWaveform is provided on top of them. sampleRate
is the per-channel sample rate in Hz; readStream returns one block of samples
as {channel: [volts, ...]}. The aggregate rate (sampleRate times the number
of channels) is the hardware limit, not sampleRate alone.

One-shot acquisition (blocks until sampleCount samples are collected):

waveform = device.acquireWaveform(channels=[0], scanRate=5000, sampleCount=1000)
waveform = device.acquireWaveform(channels=[0], sampleRate=5000, sampleCount=1000)
samples = waveform[0] # 1000 calibrated voltages from AIN0

Continuous acquisition with the primitives:

device.configureStream(channels=[0, 1], scanRate=2000)
device.configureStream(channels=[0, 1], sampleRate=2000)
device.startStream()
try:
while acquiring:
Expand All @@ -66,23 +66,33 @@ class AnalogInputStreamDevice(AnalogInputDevice):
"""

@abstractmethod
def configureStream(self, channels, scanRate):
def configureStream(self, channels, sampleRate):
"""Set up a hardware-timed acquisition of channels at sampleRate (Hz)."""
...

@abstractmethod
def startStream(self):
"""Start the configured acquisition."""
...

@abstractmethod
def readStream(self):
"""Return the samples acquired since the last read, as {channel: [volts, ...]}."""
...

@abstractmethod
def stopStream(self):
"""Stop the acquisition and release any hardware streaming resources."""
...

def acquireWaveform(self, channels, scanRate, sampleCount):
self.configureStream(channels, scanRate)
def acquireWaveform(self, channels, sampleRate, sampleCount):
"""Acquire exactly sampleCount samples per channel, blocking until done.

Configures, starts, and drains the stream (looping readStream) on the
caller's behalf, then stops it; returns {channel: [volts, ...]} truncated
to sampleCount per channel.
"""
self.configureStream(channels, sampleRate)
samples = {channel: [] for channel in channels}
self.startStream()
try:
Expand All @@ -95,6 +105,162 @@ def acquireWaveform(self, channels, scanRate, sampleCount):
return {channel: values[:sampleCount] for channel, values in samples.items()}


class InputSource(Enum):
"""Signal input a lock-in demodulator measures, named instrument-agnostically.

A driver maps each member to its hardware setting (for the SR830: SingleEnded
-> input A, Differential -> A-B, Current1M -> current input at 1 MOhm,
Current100M -> current input at 100 MOhm).
"""

SingleEnded = "SingleEnded"
Differential = "Differential"
Current1M = "Current1M"
Current100M = "Current100M"


class PhaseLockedDetectionDevice(ABC):
"""Phase-locked (lock-in) detection capability. Combine with PhysicalDevice.

Reads the demodulated outputs (X, Y, R, theta) and reference frequency, and
configures the signal input source, sensitivity (full-scale, in volts), and
time constant (in seconds). Sensitivity and time constant are expressed in
physical units so no instrument's discrete step encoding leaks into the
contract; a driver snaps a requested value to its nearest supported step.
"""

@abstractmethod
def getInPhaseVoltage(self):
"""Returns the in-phase component X, in volts."""
...

@abstractmethod
def getQuadratureVoltage(self):
"""Returns the quadrature component Y, in volts."""
...

@abstractmethod
def getMagnitude(self):
"""Returns the magnitude R = sqrt(X^2 + Y^2), in volts."""
...

@abstractmethod
def getPhase(self):
"""Returns the phase theta, in degrees."""
...

@abstractmethod
def getReferenceFrequency(self):
"""Returns the reference frequency, in Hz."""
...

@abstractmethod
def getInputSource(self) -> InputSource:
"""Returns the signal input the demodulator currently measures."""
...

@abstractmethod
def setInputSource(self, source: InputSource):
"""Select which signal input (an InputSource member) the demodulator measures."""
...

@abstractmethod
def getSensitivity(self):
"""Returns the full-scale sensitivity, in volts."""
...

@abstractmethod
def setSensitivity(self, volts):
"""Set the full-scale sensitivity to the nearest supported step, in volts."""
...

@abstractmethod
def getTimeConstant(self):
"""Returns the time constant, in seconds."""
...

@abstractmethod
def setTimeConstant(self, seconds):
"""Set the time constant to the nearest supported step, in seconds."""
...

def supportedInputSources(self):
"""Optional: the InputSource members this instrument supports, or None."""
return None

def supportedSensitivities(self):
"""Optional: the full-scale sensitivities (volts) this instrument supports, or None."""
return None

def supportedTimeConstants(self):
"""Optional: the time constants (seconds) this instrument supports, or None."""
return None

def getDemodulatedValues(self):
"""One reading of all demodulated outputs plus the reference frequency.

Built on the individual getters; a driver may override it to read the
outputs atomically (a single coherent timepoint) when the hardware
supports it.
"""
return {
"X": self.getInPhaseVoltage(),
"Y": self.getQuadratureVoltage(),
"R": self.getMagnitude(),
"theta": self.getPhase(),
"referenceFrequency": self.getReferenceFrequency(),
}


class TriggerSource(Enum):
"""Where a triggerable acquisition gets its start.

A trigger starts (arms) an acquisition; it is not the sampling clock (see
SampleClock), which is a separate concept.
"""

Internal = "Internal" # start immediately
External = "External" # start on an external hardware trigger


class SampleClock(Enum):
"""What paces the samples of a stream acquisition: the device's own timebase
at a fixed rate (Internal), or one sample per external clock/trigger edge
(External). Distinct from TriggerSource, which only starts the acquisition."""

Internal = "Internal"
External = "External"


class TriggerableDevice(ABC):
"""Capability for a device whose acquisition can be armed to a trigger.

setTriggerSource selects an internal (immediate) start versus waiting for an
external hardware trigger, and softwareTrigger() issues a manual trigger edge
(equivalent to a pulse on the external trigger line). Combine with
PhysicalDevice in a driver.
"""

@abstractmethod
def setTriggerSource(self, source: 'TriggerSource'):
"""Select whether the acquisition starts immediately or on an external trigger."""
...

@abstractmethod
def getTriggerSource(self) -> 'TriggerSource':
"""Returns the currently selected TriggerSource."""
...

@abstractmethod
def softwareTrigger(self):
"""Issue a manual (software) trigger edge."""
...

def supportedTriggerSources(self):
"""Optional: the TriggerSource members this device supports, or None."""
return None


class DigitalInputDevice(ABC):
"""Digital input capability. Combine with PhysicalDevice in a driver."""

Expand Down
Loading
Loading