Source code for instro.psu.drivers.virtual

"""Virtual PSU driver: outputs simulated in-process, with no transport, server, or hardware."""

from __future__ import annotations

import math
import random
from collections.abc import Mapping
from dataclasses import dataclass

from instro.psu import OperatingMode, PSUDriverBase

# The SCPI simulator's defaults (instro.psu.scpi_sim_server), so both simulated PSUs behave alike.
# Copied rather than imported: that module pulls in its terminal UI, too heavy for every
# `import instro.psu.drivers`.
DEFAULT_NUM_CHANNELS = 2
DEFAULT_LOAD_RESISTANCE = 1000.0
DEFAULT_VOLTAGE_MAX = 60.0
DEFAULT_CURRENT_MAX = 10.0
NOISE_PERCENT = 0.005


@dataclass
class _ChannelState:
    load_resistance: float = DEFAULT_LOAD_RESISTANCE
    voltage_setpoint: float = 0.0
    # unlike the SCPI simulator's 0 A, so setting a voltage and enabling the output is enough
    current_limit: float = DEFAULT_CURRENT_MAX
    output_enabled: bool = False
    overvoltage_protection_level: float = DEFAULT_VOLTAGE_MAX
    overvoltage_protection_enabled: bool = False
    overvoltage_protection_delay: float = 0.0
    overcurrent_protection_level: float = DEFAULT_CURRENT_MAX
    overcurrent_protection_enabled: bool = False
    overvoltage_protection_tripped: bool = False
    overcurrent_protection_tripped: bool = False
    remote_sense_enabled: bool = False


[docs] class VirtualPSU(PSUDriverBase): """PSU simulated in-process: no transport, server, or hardware. Each channel drives its own resistive load. With the output on, a channel holds its voltage setpoint (constant voltage) until the load would draw more than the current limit, then holds the current limit (constant current). Readings carry 0.5% noise, the SCPI simulator's level. The current limit starts at the channel maximum (10 A), so setting a voltage and enabling the output is enough to see it. As on the SCPI simulator, voltages must lie within 0 to 60 V and currents within 0 to 10 A, or the setter raises ``ValueError``. Unlike it, a setpoint may sit above its protection level, which is how a virtual channel trips: there is no external source to push the output past its setpoint. Enabled overvoltage or overcurrent protection trips when the output exceeds its level, latching the output off until `clear_protection` and a new `output_enable`; ``output_enable`` alone also clears the latch, as on the SCPI simulator. As there, protection is checked after every change that can move the output (setpoints, protection settings, output enable, and the load), so a fault trips when it happens, and readings never change state. Remote sense and the overvoltage protection delay are stored and have no effect on readings. To exercise the VISA transport as well, use `SimulatedPSU` with the bundled SCPI simulator. """ def __init__( self, num_channels: int = DEFAULT_NUM_CHANNELS, load_resistance: float | Mapping[int, float] = DEFAULT_LOAD_RESISTANCE, ) -> None: """Create a virtual PSU. Args: num_channels (int): Number of output channels, numbered from 1. Pass the same count to `InstroPSU`; a channel above it raises ``ValueError``. load_resistance (float | Mapping[int, float]): Resistance of each channel's load, in ohms: one value for every channel, or a mapping from channel to value, with channels left out at 1000 ohms. ``0`` is a short and ``math.inf`` an open circuit. Change it at any time with `set_load_resistance` to move a channel between constant voltage and constant current. Raises: ValueError: ``num_channels`` is less than 1, or ``load_resistance`` is negative or names a channel that does not exist. Example:: from instro.psu import InstroPSU from instro.psu.drivers import VirtualPSU driver = VirtualPSU(num_channels=2, load_resistance={1: 10.0, 2: 1.0}) with InstroPSU(name="psu", driver=driver, num_channels=2) as psu: for channel in (1, 2): psu.apply(current_limit=1.0, voltage=5.0, enable=True, channel=channel) print(psu.get_operating_mode(channel=1).latest) # CV: 10 ohms draws 0.5 A print(psu.get_operating_mode(channel=2).latest) # CC: 1 ohm would draw 5 A driver.set_load_resistance(100.0, channel=2) print(psu.get_operating_mode(channel=2).latest) # CV """ if num_channels < 1: raise ValueError(f"num_channels must be at least 1, got {num_channels}") self.num_channels = num_channels self._channels = {channel: _ChannelState() for channel in range(1, num_channels + 1)} if isinstance(load_resistance, Mapping): for channel, resistance in load_resistance.items(): self.set_load_resistance(resistance, channel=channel) else: self.set_load_resistance(load_resistance)
[docs] def set_load_resistance(self, resistance: float, channel: int | None = None) -> None: """Set the load on ``channel``, or on every channel when ``channel`` is ``None``. Args: resistance (float): Load resistance in ohms. ``0`` is a short and ``math.inf`` an open circuit. channel (int | None): Target channel (1-indexed), or ``None`` for every channel. Raises: ValueError: ``resistance`` is negative, or ``channel`` does not exist. """ if not resistance >= 0: raise ValueError(f"load_resistance must not be negative, got {resistance}") channels = self._channels.values() if channel is None else [self._channel(channel)] for state in channels: state.load_resistance = resistance _check_protection(state)
[docs] def get_load_resistance(self, channel: int) -> float: """Return the load resistance on ``channel``, in ohms.""" return self._channel(channel).load_resistance
[docs] def open(self) -> None: """Nothing to connect to."""
[docs] def close(self) -> None: """Nothing to disconnect."""
[docs] def set_voltage(self, voltage: float, channel: int) -> None: state = self._channel(channel) state.voltage_setpoint = _check_range("voltage", voltage, DEFAULT_VOLTAGE_MAX, "V") _check_protection(state)
[docs] def get_voltage(self, channel: int) -> float: voltage, _, _ = _operating_point(self._channel(channel)) return _add_noise(voltage)
[docs] def set_current_limit(self, current_limit: float, channel: int) -> None: state = self._channel(channel) state.current_limit = _check_range("current_limit", current_limit, DEFAULT_CURRENT_MAX, "A") _check_protection(state)
[docs] def get_current(self, channel: int) -> float: _, current, _ = _operating_point(self._channel(channel)) return _add_noise(current)
[docs] def output_enable(self, enable: bool, channel: int) -> None: state = self._channel(channel) if enable: state.overvoltage_protection_tripped = False state.overcurrent_protection_tripped = False state.output_enabled = enable _check_protection(state)
[docs] def get_output_status(self, channel: int) -> bool: return self._channel(channel).output_enabled
[docs] def get_voltage_setpoint(self, channel: int) -> float: return self._channel(channel).voltage_setpoint
[docs] def get_current_setpoint(self, channel: int) -> float: return self._channel(channel).current_limit
[docs] def get_operating_mode(self, channel: int) -> OperatingMode: _, _, mode = _operating_point(self._channel(channel)) return mode
[docs] def set_overvoltage_protection_level(self, voltage: float, channel: int) -> None: state = self._channel(channel) state.overvoltage_protection_level = _check_range( "overvoltage protection level", voltage, DEFAULT_VOLTAGE_MAX, "V" ) _check_protection(state)
[docs] def get_overvoltage_protection_level(self, channel: int) -> float: return self._channel(channel).overvoltage_protection_level
[docs] def set_overvoltage_protection_enabled(self, enabled: bool, channel: int) -> None: state = self._channel(channel) state.overvoltage_protection_enabled = enabled _check_protection(state)
[docs] def get_overvoltage_protection_enabled(self, channel: int) -> bool: return self._channel(channel).overvoltage_protection_enabled
[docs] def set_overvoltage_protection_delay(self, delay: float, channel: int) -> None: """Stored; protection trips without delay.""" self._channel(channel).overvoltage_protection_delay = delay
[docs] def get_overvoltage_protection_delay(self, channel: int) -> float: return self._channel(channel).overvoltage_protection_delay
[docs] def set_overcurrent_protection_level(self, current: float, channel: int) -> None: state = self._channel(channel) state.overcurrent_protection_level = _check_range( "overcurrent protection level", current, DEFAULT_CURRENT_MAX, "A" ) _check_protection(state)
[docs] def get_overcurrent_protection_level(self, channel: int) -> float: return self._channel(channel).overcurrent_protection_level
[docs] def set_overcurrent_protection_enabled(self, enabled: bool, channel: int) -> None: state = self._channel(channel) state.overcurrent_protection_enabled = enabled _check_protection(state)
[docs] def get_overcurrent_protection_enabled(self, channel: int) -> bool: return self._channel(channel).overcurrent_protection_enabled
[docs] def clear_protection(self, channel: int) -> None: state = self._channel(channel) state.overvoltage_protection_tripped = False state.overcurrent_protection_tripped = False
[docs] def get_overvoltage_protection_tripped(self, channel: int) -> bool: return self._channel(channel).overvoltage_protection_tripped
[docs] def get_overcurrent_protection_tripped(self, channel: int) -> bool: return self._channel(channel).overcurrent_protection_tripped
[docs] def set_remote_sense_enabled(self, enabled: bool, channel: int) -> None: """Stored; the virtual load has no lead resistance, so readings are unchanged.""" self._channel(channel).remote_sense_enabled = enabled
[docs] def get_remote_sense_enabled(self, channel: int) -> bool: return self._channel(channel).remote_sense_enabled
def _channel(self, channel: int) -> _ChannelState: if channel not in self._channels: raise ValueError(f"VirtualPSU has channels 1 to {self.num_channels}, got {channel}") return self._channels[channel]
def _operating_point(state: _ChannelState) -> tuple[float, float, OperatingMode]: """Return the noise-free ``(voltage, current, mode)`` of a channel.""" if not state.output_enabled: return 0.0, 0.0, OperatingMode.OFF resistance = state.load_resistance if resistance == 0: demand = math.inf if state.voltage_setpoint > 0 else 0.0 else: demand = state.voltage_setpoint / resistance if demand <= state.current_limit: return state.voltage_setpoint, demand, OperatingMode.CONSTANT_VOLTAGE return state.current_limit * resistance, state.current_limit, OperatingMode.CONSTANT_CURRENT def _check_protection(state: _ChannelState) -> None: """Trip an enabled protection the output now exceeds, latching the output off.""" voltage, current, _ = _operating_point(state) if state.overvoltage_protection_enabled and voltage > state.overvoltage_protection_level: state.overvoltage_protection_tripped = True elif state.overcurrent_protection_enabled and current > state.overcurrent_protection_level: state.overcurrent_protection_tripped = True else: return state.output_enabled = False def _check_range(name: str, value: float, maximum: float, unit: str) -> float: if not 0 <= value <= maximum: raise ValueError(f"{name} must be within 0 to {maximum:g} {unit}, got {value}") return value def _add_noise(value: float) -> float: return random.gauss(value, abs(value) * NOISE_PERCENT / 3)