Source code for instro.eload.drivers.virtual

"""Virtual E-Load driver: a load and its source simulated in-process, with no transport, server, or hardware."""

from __future__ import annotations

import math
import random

from instro.eload import ELoadDriverBase
from instro.eload.types import LoadMode, SlewRateDirection

NOISE_PERCENT = 0.005


def _add_noise(value: float) -> float:
    return random.gauss(value, abs(value) * NOISE_PERCENT / 3)


def _check_channel(channel: int) -> None:
    if channel != 1:
        raise ValueError(f"VirtualELoad has a single channel, got channel {channel}")


[docs] class VirtualELoad(ELoadDriverBase): """Single-channel E-Load simulated in-process: no transport, server, or hardware. The load sinks from a simulated source: a fixed voltage behind an internal resistance. With the input off the terminals read the open-circuit voltage at 0 A; with it on, the readings are the operating point the active mode and level set on that source, plus noise (0.5%). As on the `BK85XXB`, ``short_output`` also switches the input: a short pulls the terminals to 0 V at the source's short-circuit current, and ending it turns the input off. Range and slew-rate settings are accepted and have no effect on readings, so any `InstroELoad` call works. Levels the source cannot reach are not errors: CC and CR stop at the short-circuit current, and CP stops at the source's maximum power (half its open-circuit voltage), where a real load would lose regulation. There are no ratings, protections, or time dynamics. """ def __init__(self, source_voltage: float = 12.0, source_resistance: float = 0.5) -> None: """Create a virtual E-Load. Args: source_voltage (float): Open-circuit voltage of the simulated source, in volts. Must not be negative. source_resistance (float): Internal resistance of the simulated source, in ohms. Must be positive; it sets how far the voltage sags as the load draws current. Both stay settable as attributes, e.g. to sag the source mid-run, and are validated on every set. Raises: ValueError: If ``source_voltage`` is negative or ``source_resistance`` is not positive. Example:: from instro.eload import InstroELoad, LoadMode from instro.eload.drivers import VirtualELoad eload = InstroELoad(name="eload", driver=VirtualELoad(source_voltage=12.0, source_resistance=0.5)) with eload: eload.set_mode(LoadMode.CC) eload.set_level(2.0) eload.output_enable(True) print(eload.get_current().latest) # about 2.0 print(eload.get_voltage().latest) # about 11.0 """ self.source_voltage = source_voltage self.source_resistance = source_resistance self._mode = LoadMode.CC self._levels: dict[LoadMode, float] = { LoadMode.CC: 0.0, # above any source, so CV draws nothing until set, however source_voltage changes LoadMode.CV: math.inf, LoadMode.CP: 0.0, LoadMode.CR: 1000.0, } self._curr_limit: float | None = None self._input_enabled = False self._shorted = False @property def source_voltage(self) -> float: """Open-circuit voltage of the simulated source, in volts.""" return self._source_voltage @source_voltage.setter def source_voltage(self, value: float) -> None: if value < 0: raise ValueError(f"source_voltage must not be negative, got {value}") self._source_voltage = value @property def source_resistance(self) -> float: """Internal resistance of the simulated source, in ohms.""" return self._source_resistance @source_resistance.setter def source_resistance(self, value: float) -> None: if value <= 0: raise ValueError(f"source_resistance must be positive, got {value}") self._source_resistance = value
[docs] def open(self) -> None: """Nothing to connect to."""
[docs] def close(self) -> None: """Nothing to disconnect."""
[docs] def short_output(self, enable: bool, channel: int) -> None: """Start or end a short; like the `BK85XXB`, this also turns the input on or off. The short stays set until ``short_output(False)``: turning the input off and on again with ``output_enable`` leaves it shorted, as ``INPut`` alone leaves ``INPut:SHORt`` on the BK85XXB. """ _check_channel(channel) self._shorted = enable self._input_enabled = enable
[docs] def set_mode(self, mode: LoadMode, channel: int) -> None: _check_channel(channel) self._mode = mode
[docs] def set_level(self, mode: LoadMode, value: float, channel: int, curr_limit: float | None) -> None: """Set ``mode``'s level; each mode keeps its own. ``curr_limit`` caps the current in CV mode.""" _check_channel(channel) if value < 0: raise ValueError(f"{mode.value} level must be non-negative, got {value}") if curr_limit is not None and curr_limit < 0: raise ValueError(f"curr_limit must be non-negative, got {curr_limit}") self._levels[mode] = value if mode is LoadMode.CV: self._curr_limit = curr_limit
[docs] def set_range(self, mode: LoadMode, value: float, channel: int) -> None: """Accepted; no effect on readings.""" _check_channel(channel)
[docs] def set_slewrate(self, direction: SlewRateDirection, rate: float, channel: int) -> None: """Accepted; no effect on readings, which settle instantly.""" _check_channel(channel)
[docs] def output_enable(self, enable: bool, channel: int) -> None: """Turn the input on or off; a short set by `short_output` stays set.""" _check_channel(channel) self._input_enabled = enable
[docs] def get_current(self, channel: int) -> float: _check_channel(channel) return _add_noise(self._operating_point()[1])
[docs] def get_voltage(self, channel: int) -> float: _check_channel(channel) return _add_noise(self._operating_point()[0])
def _operating_point(self) -> tuple[float, float]: """Return the noise-free ``(volts, amperes)`` at the terminals.""" vs, rs = self.source_voltage, self.source_resistance short_circuit_current = vs / rs if not self._input_enabled: return vs, 0.0 if self._shorted: return 0.0, short_circuit_current level = self._levels[self._mode] if self._mode is LoadMode.CC: current = min(level, short_circuit_current) elif self._mode is LoadMode.CR: current = vs / (rs + level) elif self._mode is LoadMode.CV: current = max(vs - level, 0.0) / rs if self._curr_limit is not None: current = min(current, self._curr_limit) else: # V * I = P with V = vs - I * rs; past the source's maximum power, sit at the maximum-power point discriminant = vs * vs - 4 * rs * level current = (vs - math.sqrt(discriminant)) / (2 * rs) if discriminant >= 0 else vs / (2 * rs) return vs - current * rs, current