Source code for pslab.instrument.analog

"""Objects related to the PSLab's analog input channels.

This module contains several module level variables with details on the analog
inputs' capabilities, including possible gain values, voltage ranges, and
firmware-interal enumeration.

This module also contains the AnalogInput class, an instance of which functions
as a model of a particular analog input.
"""

import logging
from typing import List, Union

import numpy as np

logger = logging.getLogger(__name__)

GAIN_VALUES = (1, 2, 4, 5, 8, 10, 16, 32)

ANALOG_CHANNELS = (
    "CH1",
    "CH2",
    "CH3",
    "MIC",
    "CAP",
    "RES",
    "VOL",
    "AN4",
)

INPUT_RANGES = {
    "CH1": (16.5, -16.5),  # Specify inverted channels explicitly by reversing range!
    "CH2": (16.5, -16.5),
    "CH3": (-3.3, 3.3),  # external gain control analog input
    "MIC": (-3.3, 3.3),  # connected to MIC amplifier
    "CAP": (0, 3.3),
    "RES": (0, 3.3),
    "VOL": (0, 3.3),
    "AN4": (0, 3.3),
}

PIC_ADC_MULTIPLEX = {
    "CH1": 3,
    "CH2": 0,
    "CH3": 1,
    "MIC": 2,
    "AN4": 4,
    "RES": 7,
    "CAP": 5,
    "VOL": 8,
}


[docs]class AnalogInput: """Model of the PSLab's analog inputs, used to scale raw values to voltages. Parameters ---------- name : {'CH1', 'CH2', 'CH3', 'MIC', 'CAP', 'RES', 'VOL'} Name of the analog channel to model. Attributes ---------- samples_in_buffer : int Number of samples collected on this channel currently being held in the device's ADC buffer. buffer_idx : int or None Location in the device's ADC buffer where the samples are stored. None if no samples captured by this channel are currently held in the buffer. chosa : int Number used to refer to this channel in the firmware. """ def __init__(self, name: str): self._name = name self._resolution = 2 ** 10 - 1 if self._name == "CH1": self.programmable_gain_amplifier = 1 self._gain = 1 elif self._name == "CH2": self.programmable_gain_amplifier = 2 self._gain = 1 else: self.programmable_gain_amplifier = None self._gain = 1 self.samples_in_buffer = 0 self.buffer_idx = None self._scale = np.poly1d(0) self._unscale = np.poly1d(0) self.chosa = PIC_ADC_MULTIPLEX[self._name] self._calibrate() @property def gain(self) -> Union[int, None]: """int: Analog gain. Setting a new gain level will automatically recalibrate the channel. On channels other than CH1 and CH2 gain is None. Raises ------ TypeError If gain is set on a channel which does not support it. ValueError If a gain value other than 1, 2, 4, 5, 8, 10, 16, 32 is set. """ if self._name in ("CH1", "CH2"): return self._gain else: return None @gain.setter def gain(self, value: Union[int, float]): if self._name not in ("CH1", "CH2"): raise TypeError(f"Analog gain is not available on {self._name}.") if value not in GAIN_VALUES: raise ValueError(f"Invalid gain. Valid values are {GAIN_VALUES}.") self._gain = value self._calibrate() @property def resolution(self) -> int: """int: Resolution in bits. Setting a new resolution will automatically recalibrate the channel. Raises ------ ValueError If a resolution other than 10 or 12 is set. """ return int(np.log2((self._resolution + 1))) @resolution.setter def resolution(self, value: int): if value not in (10, 12): raise ValueError("Resolution must be 10 or 12 bits.") self._resolution = 2 ** value - 1 self._calibrate() def _calibrate(self): A = INPUT_RANGES[self._name][0] / self._gain B = INPUT_RANGES[self._name][1] / self._gain slope = B - A intercept = A self._scale = np.poly1d([slope / self._resolution, intercept]) self._unscale = np.poly1d( [self._resolution / slope, -self._resolution * intercept / slope] )
[docs] def scale(self, raw: Union[int, List[int]]) -> float: """Translate raw integer value from device to corresponding voltage. Inverse of :meth:`unscale`. Parameters ---------- raw : int, List[int] An integer, or a list of integers, received from the device. Returns ------- float Voltage, translated from raw based on channel range, gain, and resolution. """ return self._scale(raw)
[docs] def unscale(self, voltage: float) -> int: """Translate a voltage to a raw integer value interpretable by the device. Inverse of :meth:`scale`. Parameters ---------- voltage : float Voltage in volts. Returns ------- int Corresponding integer value, adjusted for resolution and gain and clipped to the channel's range. """ level = self._unscale(voltage) level = np.clip(level, 0, self._resolution) level = np.round(level) return int(level)
[docs]class AnalogOutput: """Model of the PSLab's analog outputs. Parameters ---------- name : str Name of the analog output pin represented by this instance. Attributes ---------- frequency : float Frequency of the waveform on this pin in Hz. wavetype : {'sine', 'tria', 'custom'} Type of waveform on this pin. 'sine' is a sine wave with amplitude 3.3 V, 'tria' is a triangle wave with amplitude 3.3 V, 'custom' is any other waveform set with :meth:`load_function` or :meth:`load_table`. """ RANGE = (-3.3, 3.3) def __init__(self, name): self.name = name self.frequency = 0 self.wavetype = "sine" self._waveform_table = self.RANGE[1] * np.sin( np.arange( self.RANGE[0], self.RANGE[1], (self.RANGE[1] - self.RANGE[0]) / 512 ) ) @property def waveform_table(self) -> np.ndarray: """numpy.ndarray: 512-value waveform table loaded on this output.""" # A form of amplitude control. Max PWM duty cycle out of 512 clock cycles. return self._range_normalize(self._waveform_table, 511) @waveform_table.setter def waveform_table(self, points: np.ndarray): if max(points) - min(points) > self.RANGE[1] - self.RANGE[0]: logger.warning(f"Analog output {self.name} saturated.") self._waveform_table = np.clip(points, self.RANGE[0], self.RANGE[1]) @property def lowres_waveform_table(self) -> np.ndarray: """numpy.ndarray: 32-value waveform table loaded on this output.""" # Max PWM duty cycle out of 64 clock cycles. return self._range_normalize(self._waveform_table[::16], 63) def _range_normalize(self, x: np.ndarray, norm: int = 1) -> np.ndarray: """Normalize waveform table to the digital output range.""" x = (x - self.RANGE[0]) / (self.RANGE[1] - self.RANGE[0]) * norm return np.int16(np.round(x)).tolist()