diff --git a/changelog b/changelog index 762388b..51ecfaa 100644 --- a/changelog +++ b/changelog @@ -7,10 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [1.1.0] - 2024-04-09 +## [1.1.0] - 2024-08-28 ### Added - Additional HEPHY EnvironBox commands for driver and emulator (#74). +- Generic base classes `PowerSupply` and `PowerSupplyChannel` and `LightSource`. +- Rhode&Schwarz NGE100 power supply (#77). +- Photonic F3000 LED light source (#75). + +### Removed +- Dropped support for Python 3.8 ## [1.0.0] - 2024-03-26 diff --git a/docs/index.md b/docs/index.md index c0b28f1..29a942b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -14,5 +14,5 @@ utilities for instrumentation applications. Inspired by Install from GitHub using pip ```bash -pip install git+https://github.com/hephy-dd/comet.git@1.0.0 +pip install git+https://github.com/hephy-dd/comet.git@v1.1.0 ``` diff --git a/mkdocs.yml b/mkdocs.yml index 8184f1f..17c583e 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -15,4 +15,4 @@ theme: highlightjs: true extra_css: [extra.css] extra: - version: 1.0.0 + version: 1.1.0 diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 0000000..bb851f7 --- /dev/null +++ b/mypy.ini @@ -0,0 +1,7 @@ +[mypy] + +[mypy-pint.*] +ignore_missing_imports = True + +[mypy-schema.*] +ignore_missing_imports = True diff --git a/pyproject.toml b/pyproject.toml index fed528d..974a691 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,32 @@ +[project] +name = "comet" +description = "Control and Measurement Toolkit" +authors = [ + {name = "Bernhard Arnold", email = "bernhard.arnold@oeaw.ac.at"}, +] +readme = "README.md" +license = {text = "GPLv3"} +requires-python = ">=3.9" +dependencies = [ + "PyVISA", + "PyVISA-py", + "PyVISA-sim", + "pyserial", + "pyusb", + "numpy", + "pint", + "schema", + "PyYAML", +] +dynamic = ["version"] + +[project.urls] +Homepage = "https://github.com/hephy-dd/comet/" +Source = "https://github.com/hephy-dd/comet/" + [build-system] -requires = ["setuptools"] -build-backend = "setuptools.build_meta" +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.version] +path = "src/comet/__init__.py" diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 9fbf247..0000000 --- a/setup.cfg +++ /dev/null @@ -1,36 +0,0 @@ -[metadata] -name = comet -version = attr: comet.__version__ -author = Bernhard Arnold -author_email = bernhard.arnold@oeaw.ac.at -description = Control and Measurement Toolkit -long_description = file: README.md -long_description_content_type = text/markdown -license = GPLv3 - -[options] -python_requires = >=3.8 -install_requires = - PyVISA - PyVISA-py - PyVISA-sim - pyserial - pyusb - numpy - pint - schema - PyYAML - -[flake8] -ignore = E501 - -[mypy] - -[mypy-pint.*] -ignore_missing_imports = True - -[mypy-schema.*] -ignore_missing_imports = True - -[tool:pytest] -filterwarnings = ignore::DeprecationWarning diff --git a/src/comet/driver/generic/__init__.py b/src/comet/driver/generic/__init__.py index efdb169..9bf7738 100644 --- a/src/comet/driver/generic/__init__.py +++ b/src/comet/driver/generic/__init__.py @@ -1,8 +1,8 @@ from .instrument import ( - InstrumentError, BeeperMixin, ErrorQueueMixin, RouteTerminalMixin, + InstrumentError, Instrument, ) from .source_meter_unit import SourceMeterUnit @@ -13,4 +13,19 @@ MotionController, MotionControllerAxis, ) -from .light_source import LightSource \ No newline at end of file +from .light_source import LightSource + +__all__ = [ + "BeeperMixin", + "ErrorQueueMixin", + "RouteTerminalMixin", + "InstrumentError", + "Instrument", + "SourceMeterUnit", + "Electrometer", + "LCRMeter", + "SwitchingMatrix", + "MotionController", + "MotionControllerAxis", + "LightSource", +] diff --git a/src/comet/driver/generic/electrometer.py b/src/comet/driver/generic/electrometer.py index fef7390..ab7c5d3 100644 --- a/src/comet/driver/generic/electrometer.py +++ b/src/comet/driver/generic/electrometer.py @@ -9,17 +9,13 @@ class Electrometer(Instrument): # Measurements @abstractmethod - def measure_voltage(self) -> float: - ... + def measure_voltage(self) -> float: ... @abstractmethod - def measure_current(self) -> float: - ... + def measure_current(self) -> float: ... @abstractmethod - def measure_resistance(self) -> float: - ... + def measure_resistance(self) -> float: ... @abstractmethod - def measure_charge(self) -> float: - ... \ No newline at end of file + def measure_charge(self) -> float: ... diff --git a/src/comet/driver/generic/instrument.py b/src/comet/driver/generic/instrument.py index 58d8809..b5b4b1e 100644 --- a/src/comet/driver/generic/instrument.py +++ b/src/comet/driver/generic/instrument.py @@ -1,12 +1,15 @@ from abc import ABC, abstractmethod -from typing import List, Iterable, Optional, Tuple +from typing import Optional from ..driver import Driver __all__ = [ "InstrumentError", - "BeeperMixin", + "IdentifyMixin", + "ResetMixin", + "ClearMixin", "ErrorQueueMixin", + "BeeperMixin", "RouteTerminalMixin", "Instrument", ] @@ -23,55 +26,56 @@ def __repr__(self) -> str: return f"{cls_name}({self.code}, {self.message!r})" -class BeeperMixin(ABC): +class IdentifyMixin(ABC): - BEEPER_ON: bool = True - BEEPER_OFF: bool = False + @abstractmethod + def identify(self) -> str: ... + + +class ResetMixin(ABC): - @property # type: ignore @abstractmethod - def beeper(self) -> bool: - ... + def reset(self) -> None: ... + + +class ClearMixin(ABC): - @beeper.setter # type: ignore @abstractmethod - def beeper(self, value: bool) -> None: - ... + def clear(self) -> None: ... class ErrorQueueMixin(ABC): @abstractmethod - def next_error(self) -> Optional[InstrumentError]: - ... + def next_error(self) -> Optional[InstrumentError]: ... -class RouteTerminalMixin(ABC): +class BeeperMixin(ABC): - ROUTE_TERMINAL_FRONT: str = "front" - ROUTE_TERMINAL_REAR: str = "rear" + BEEPER_ON: bool = True + BEEPER_OFF: bool = False - @property # type: ignore + @property @abstractmethod - def route_terminal(self) -> str: - ... + def beeper(self) -> bool: ... - @route_terminal.setter # type: ignore + @beeper.setter @abstractmethod - def route_terminal(self, route_terminal: str) -> None: - ... + def beeper(self, value: bool) -> None: ... -class Instrument(ErrorQueueMixin, Driver): +class RouteTerminalMixin(ABC): - @abstractmethod - def identify(self) -> str: - ... + ROUTE_TERMINAL_FRONT: str = "front" + ROUTE_TERMINAL_REAR: str = "rear" + @property @abstractmethod - def reset(self) -> None: - ... + def route_terminal(self) -> str: ... + @route_terminal.setter @abstractmethod - def clear(self) -> None: - ... \ No newline at end of file + def route_terminal(self, route_terminal: str) -> None: ... + + +class Instrument(IdentifyMixin, ResetMixin, ClearMixin, ErrorQueueMixin, Driver): ... diff --git a/src/comet/driver/generic/lcr_meter.py b/src/comet/driver/generic/lcr_meter.py index 86ae045..d5167ac 100644 --- a/src/comet/driver/generic/lcr_meter.py +++ b/src/comet/driver/generic/lcr_meter.py @@ -8,36 +8,29 @@ class LCRMeter(Instrument): - @property # type: ignore + @property @abstractmethod - def function(self) -> str: - ... + def function(self) -> str: ... - @function.setter # type: ignore + @function.setter @abstractmethod - def function(self, function: str) -> None: - ... + def function(self, function: str) -> None: ... - @property # type: ignore + @property @abstractmethod - def amplitude(self) -> float: - ... + def amplitude(self) -> float: ... - @amplitude.setter # type: ignore + @amplitude.setter @abstractmethod - def amplitude(self, level: float) -> None: - ... + def amplitude(self, level: float) -> None: ... - @property # type: ignore + @property @abstractmethod - def frequency(self) -> float: - ... + def frequency(self) -> float: ... - @frequency.setter # type: ignore + @frequency.setter @abstractmethod - def frequency(self, frequency: float) -> None: - ... + def frequency(self, frequency: float) -> None: ... @abstractmethod - def measure_impedance(self) -> Tuple[float, float]: - ... + def measure_impedance(self) -> Tuple[float, float]: ... diff --git a/src/comet/driver/generic/motion_controller.py b/src/comet/driver/generic/motion_controller.py index 6db8118..aa63695 100644 --- a/src/comet/driver/generic/motion_controller.py +++ b/src/comet/driver/generic/motion_controller.py @@ -15,88 +15,69 @@ def __init__(self, resource, index: int) -> None: self.index: int = index @abstractmethod - def calibrate(self) -> None: - ... + def calibrate(self) -> None: ... @abstractmethod - def range_measure(self) -> None: - ... + def range_measure(self) -> None: ... @property @abstractmethod - def is_calibrated(self) -> bool: - ... + def is_calibrated(self) -> bool: ... @abstractmethod - def move_absolute(self, value: float) -> None: - ... + def move_absolute(self, value: float) -> None: ... @abstractmethod - def move_relative(self, value: float) -> None: - ... + def move_relative(self, value: float) -> None: ... @property @abstractmethod - def position(self) -> float: - ... + def position(self) -> float: ... @property @abstractmethod - def is_moving(self) -> bool: - ... + def is_moving(self) -> bool: ... class MotionController(Instrument): @abstractmethod - def __getitem__(self, index: int) -> MotionControllerAxis: - ... + def __getitem__(self, index: int) -> MotionControllerAxis: ... @abstractmethod - def calibrate(self) -> None: - ... + def calibrate(self) -> None: ... @abstractmethod - def range_measure(self) -> None: - ... + def range_measure(self) -> None: ... @property @abstractmethod - def is_calibrated(self) -> bool: - ... + def is_calibrated(self) -> bool: ... @abstractmethod - def move_absolute(self, position: Position) -> None: - ... + def move_absolute(self, position: Position) -> None: ... @abstractmethod - def move_relative(self, position: Position) -> None: - ... + def move_relative(self, position: Position) -> None: ... @abstractmethod - def abort(self) -> None: - ... + def abort(self) -> None: ... @abstractmethod - def force_abort(self) -> None: - ... + def force_abort(self) -> None: ... @property @abstractmethod - def position(self) -> Position: - ... + def position(self) -> Position: ... @property @abstractmethod - def is_moving(self) -> bool: - ... + def is_moving(self) -> bool: ... - @property # type: ignore + @property @abstractmethod - def joystick_enabled(self) -> bool: - ... + def joystick_enabled(self) -> bool: ... - @joystick_enabled.setter # type: ignore + @joystick_enabled.setter @abstractmethod - def joystick_enabled(self, value: bool) -> None: - ... + def joystick_enabled(self, value: bool) -> None: ... diff --git a/src/comet/driver/generic/power_supply.py b/src/comet/driver/generic/power_supply.py new file mode 100644 index 0000000..55f8b89 --- /dev/null +++ b/src/comet/driver/generic/power_supply.py @@ -0,0 +1,68 @@ +from abc import abstractmethod + +from typing import Iterator + +from .instrument import Driver, Instrument + +__all__ = ["PowerSupply", "PowerSupplyChannel"] + + +class PowerSupplyChannel(Driver): + + def __init__(self, resource, channel: int) -> None: + super().__init__(resource) + self.channel: int = channel + + OUTPUT_ON: bool = True + OUTPUT_OFF: bool = False + + @property + @abstractmethod + def enabled(self) -> bool: ... + + @enabled.setter + @abstractmethod + def enabled(self, state: bool) -> None: ... + + # Voltage source + + @property + @abstractmethod + def voltage_level(self) -> float: ... + + @voltage_level.setter + @abstractmethod + def voltage_level(self, level: float) -> None: ... + + # Current source + + @property + @abstractmethod + def current_limit(self) -> float: ... + + @current_limit.setter + @abstractmethod + def current_limit(self, level: float) -> None: ... + + # Measurements + + @abstractmethod + def measure_voltage(self) -> float: ... + + @abstractmethod + def measure_current(self) -> float: ... + + @abstractmethod + def measure_power(self) -> float: ... + + +class PowerSupply(Instrument): + + @abstractmethod + def __getitem__(self, channel: int) -> PowerSupplyChannel: ... + + @abstractmethod + def __iter__(self) -> Iterator[PowerSupplyChannel]: ... + + @abstractmethod + def __len__(self) -> int: ... diff --git a/src/comet/driver/generic/source_meter_unit.py b/src/comet/driver/generic/source_meter_unit.py index 6e197c9..29c9ae5 100644 --- a/src/comet/driver/generic/source_meter_unit.py +++ b/src/comet/driver/generic/source_meter_unit.py @@ -6,107 +6,92 @@ class SourceMeterUnit(Instrument): + # Output OUTPUT_ON: bool = True OUTPUT_OFF: bool = False - @property # type: ignore + @property @abstractmethod - def output(self) -> bool: - ... + def output(self) -> bool: ... - @output.setter # type: ignore + @output.setter @abstractmethod - def output(self, state: bool) -> None: - ... + def output(self, state: bool) -> None: ... + + # Function FUNCTION_VOLTAGE: str = "voltage" FUNCTION_CURRENT: str = "current" - @property # type: ignore + @property @abstractmethod - def function(self) -> str: - ... + def function(self) -> str: ... - @function.setter # type: ignore + @function.setter @abstractmethod - def function(self, function: str) -> None: - ... + def function(self, function: str) -> None: ... # Voltage source - @property # type: ignore + @property @abstractmethod - def voltage_level(self) -> float: - ... + def voltage_level(self) -> float: ... - @voltage_level.setter # type: ignore + @voltage_level.setter @abstractmethod - def voltage_level(self, level: float) -> None: - ... + def voltage_level(self, level: float) -> None: ... - @property # type: ignore + @property @abstractmethod - def voltage_range(self) -> float: - ... + def voltage_range(self) -> float: ... - @voltage_range.setter # type: ignore + @voltage_range.setter @abstractmethod - def voltage_range(self, level: float) -> None: - ... + def voltage_range(self, level: float) -> None: ... - @property # type: ignore + @property @abstractmethod - def voltage_compliance(self) -> float: - ... + def voltage_compliance(self) -> float: ... - @voltage_compliance.setter # type: ignore + @voltage_compliance.setter @abstractmethod - def voltage_compliance(self, level: float) -> None: - ... + def voltage_compliance(self, level: float) -> None: ... # Current source - @property # type: ignore + @property @abstractmethod - def current_level(self) -> float: - ... + def current_level(self) -> float: ... - @current_level.setter # type: ignore + @current_level.setter @abstractmethod - def current_level(self, level: float) -> None: - ... + def current_level(self, level: float) -> None: ... - @property # type: ignore + @property @abstractmethod - def current_range(self) -> float: - ... + def current_range(self) -> float: ... - @current_range.setter # type: ignore + @current_range.setter @abstractmethod - def current_range(self, level: float) -> None: - ... + def current_range(self, level: float) -> None: ... - @property # type: ignore - def current_compliance(self) -> float: - ... + @property + @abstractmethod + def current_compliance(self) -> float: ... - @current_compliance.setter # type: ignore + @current_compliance.setter @abstractmethod - def current_compliance(self, level: float) -> None: - ... + def current_compliance(self, level: float) -> None: ... @property @abstractmethod - def compliance_tripped(self) -> bool: - ... + def compliance_tripped(self) -> bool: ... # Measurements @abstractmethod - def measure_voltage(self) -> float: - ... + def measure_voltage(self) -> float: ... @abstractmethod - def measure_current(self) -> float: - ... \ No newline at end of file + def measure_current(self) -> float: ... diff --git a/src/comet/driver/generic/switching_matrix.py b/src/comet/driver/generic/switching_matrix.py index 64e746c..ae68e49 100644 --- a/src/comet/driver/generic/switching_matrix.py +++ b/src/comet/driver/generic/switching_matrix.py @@ -7,22 +7,17 @@ class SwitchingMatrix(Instrument): - CHANNELS: List[str] = [] @property @abstractmethod - def closed_channels(self) -> List[str]: - ... + def closed_channels(self) -> List[str]: ... @abstractmethod - def close_channels(self, channels: List[str]) -> None: - ... + def close_channels(self, channels: List[str]) -> None: ... @abstractmethod - def open_channels(self, channels: List[str]) -> None: - ... + def open_channels(self, channels: List[str]) -> None: ... @abstractmethod - def open_all_channels(self) -> None: - ... + def open_all_channels(self) -> None: ... diff --git a/src/comet/driver/itk/corvustt.py b/src/comet/driver/itk/corvustt.py index cd0836c..7ca2e5a 100644 --- a/src/comet/driver/itk/corvustt.py +++ b/src/comet/driver/itk/corvustt.py @@ -89,7 +89,7 @@ def range_measure(self) -> None: @property def is_calibrated(self) -> bool: """Return True if all active axes are calibrated and range measured.""" - values = self.resource.query(f"getcaldone").split() + values = self.resource.query("getcaldone").split() return [int(value) for value in values].count(0x3) == len(values) def move_absolute(self, position: Position) -> None: diff --git a/src/comet/driver/itk/hydra.py b/src/comet/driver/itk/hydra.py index ecd1899..4d97128 100644 --- a/src/comet/driver/itk/hydra.py +++ b/src/comet/driver/itk/hydra.py @@ -94,7 +94,7 @@ def range_measure(self) -> None: @property def is_calibrated(self) -> bool: """Return True if all active axes are calibrated and range measured.""" - status = int(self.resource.query(f"st")) + status = int(self.resource.query("st")) return bool(int(status & 0x18)) def move_absolute(self, position: Position) -> None: diff --git a/src/comet/driver/marzhauser/tango.py b/src/comet/driver/marzhauser/tango.py index 1f0eaa5..a71cb07 100644 --- a/src/comet/driver/marzhauser/tango.py +++ b/src/comet/driver/marzhauser/tango.py @@ -158,7 +158,7 @@ def is_moving(self) -> bool: @property def joystick_enabled(self) -> bool: - result = self.resource.query(f"?joy") + result = self.resource.query("?joy") return bool(int(result)) @joystick_enabled.setter diff --git a/src/comet/driver/marzhauser/venus.py b/src/comet/driver/marzhauser/venus.py index 26c3086..a48872b 100644 --- a/src/comet/driver/marzhauser/venus.py +++ b/src/comet/driver/marzhauser/venus.py @@ -120,7 +120,7 @@ def range_measure(self) -> None: @property def is_calibrated(self) -> bool: """Return True if all active axes are calibrated and range measured.""" - values = self.resource.query(f"getcaldone").split() + values = self.resource.query("getcaldone").split() return [int(value) for value in values].count(0x3) == len(values) def move_absolute(self, position: Position) -> None: @@ -154,4 +154,4 @@ def joystick_enabled(self) -> bool: @joystick_enabled.setter def joystick_enabled(self, value: bool) -> None: - self.resource.write(f"{value:d} joystick") \ No newline at end of file + self.resource.write(f"{value:d} joystick") diff --git a/src/comet/driver/rohde_schwarz/__init__.py b/src/comet/driver/rohde_schwarz/__init__.py new file mode 100644 index 0000000..3472786 --- /dev/null +++ b/src/comet/driver/rohde_schwarz/__init__.py @@ -0,0 +1 @@ +from .nge100 import NGE100 \ No newline at end of file diff --git a/src/comet/driver/rohde_schwarz/nge100.py b/src/comet/driver/rohde_schwarz/nge100.py new file mode 100644 index 0000000..59030da --- /dev/null +++ b/src/comet/driver/rohde_schwarz/nge100.py @@ -0,0 +1,110 @@ +from typing import Optional, Iterator + +from comet.driver.generic import InstrumentError +from comet.driver.generic.power_supply import PowerSupply, PowerSupplyChannel + + +__all__ = ["NGE100", "NGE100Channel"] + + +class NGE100Channel(PowerSupplyChannel): + """Single channel of the NGE100 power supply""" + + @property + def enabled(self) -> bool: + value = int(self.query("OUTPut?")) + + return {0: self.OUTPUT_OFF, 1: self.OUTPUT_ON}[value] + + @enabled.setter + def enabled(self, state: bool) -> None: + value = {self.OUTPUT_OFF: 0, self.OUTPUT_ON: 1}[state] + + self.write(f"OUTPut {value}") + + @property + def voltage_level(self) -> float: + return float(self.query("SOURce:VOLTage:LEVel:IMMediate:AMPLitude?")) + + @voltage_level.setter + def voltage_level(self, level: float) -> None: + if level < 0: + raise ValueError("Voltage level must be non-negative") + if level > 32: + raise ValueError("Voltage level must be less than 32 V") + + self.write(f"SOURce:VOLTage:LEVel:IMMediate:AMPLitude {level}") + + @property + def current_limit(self) -> float: + return float(self.query("SOURce:CURRent:LEVel:IMMediate:AMPLitude?")) + + @current_limit.setter + def current_limit(self, level: float) -> None: + if level < 0: + raise ValueError("Current limit must be non-negative") + if level > 3: + raise ValueError("Current limit must be less than 3 A") + + self.write(f"SOURce:CURRent:LEVel:IMMediate:AMPLitude {level}") + + def measure_voltage(self) -> float: + return float(self.query("MEASure:SCALar:VOLTage:DC?")) + + def measure_current(self) -> float: + return float(self.query("MEASure:SCALar:CURRent:DC?")) + + def measure_power(self) -> float: + return float(self.query("MEASure:SCALar:POWer?")) + + # Helper + def query(self, message: str) -> str: + self.resource.write(f"INSTrument {self.channel + 1}") + return self.resource.query(message).strip() + + def write(self, message: str) -> None: + self.resource.write(f"INSTrument {self.channel + 1}") + self.resource.write(message) + self.resource.query("*OPC?") + + +class NGE100(PowerSupply): + """Rohde & Schwarz NGE100 power supply featuring multiple channels""" + + def identify(self) -> str: + return self.query("*IDN?") + + def reset(self) -> None: + self.write("*RST") + + def clear(self) -> None: + self.write("*CLS") + + def next_error(self) -> Optional[InstrumentError]: + code, message = self.query("SYSTem:ERRor?").split(", ") + if int(code): + return InstrumentError(int(code), message.strip("'")) + return None + + def query(self, message: str) -> str: + return self.resource.query(message).strip() + + def write(self, message: str) -> None: + self.resource.write(message) + self.query("*OPC?") + + def __getitem__(self, channel: int) -> NGE100Channel: + + if not isinstance(channel, int): + raise TypeError("Channel index must be an integer") + + if channel not in range(3): + raise IndexError("Channel index out of range") + + return NGE100Channel(self.resource, channel) + + def __iter__(self) -> Iterator[NGE100Channel]: + return iter([NGE100Channel(self.resource, channel) for channel in range(3)]) + + def __len__(self) -> int: + return 3 diff --git a/src/comet/driver/smc/corvus.py b/src/comet/driver/smc/corvus.py index f97c309..b61a2f8 100644 --- a/src/comet/driver/smc/corvus.py +++ b/src/comet/driver/smc/corvus.py @@ -89,7 +89,7 @@ def range_measure(self) -> None: @property def is_calibrated(self) -> bool: """Return True if all active axes are calibrated and range measured.""" - values = self.resource.query(f"getcaldone").split() + values = self.resource.query("getcaldone").split() return [int(value) for value in values].count(0x3) == len(values) def move_absolute(self, position: Position) -> None: diff --git a/src/comet/emulator/rohde_schwarz/__init__.py b/src/comet/emulator/rohde_schwarz/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/comet/emulator/rohde_schwarz/nge100.py b/src/comet/emulator/rohde_schwarz/nge100.py new file mode 100644 index 0000000..521dc5d --- /dev/null +++ b/src/comet/emulator/rohde_schwarz/nge100.py @@ -0,0 +1,107 @@ +"""Rohde&Schwarz NGE100 power supply emulator""" + +from typing import List +import math + +from comet.emulator import Emulator +from comet.emulator import message, run + + +__all__ = ["NGE100Emulator"] + + +class NGE100Emulator(Emulator): + IDENTITY: str = "Rohde&Schwarz,NGE103B,5601.3800k03/101863,1.54" + + def __init__(self) -> None: + super().__init__() + + self.voltage_levels: List[float] = [0.0, 0.0, 0.0] + self.current_limits: List[float] = [0.0, 00.0, 0.0] + self.enabled_channels: List[bool] = [False, False, False] + + self.selected_channel: int = 0 + + self.resistances: List[float] = [1, 1e3, math.inf] # 1 Ohm, 1 kOhm, infinite + + def get_voltage(self) -> float: + + voltage_from_current_limit = ( + self.current_limits[self.selected_channel] + * self.resistances[self.selected_channel] + ) + + voltage_from_voltage_level = self.voltage_levels[self.selected_channel] + + return min(voltage_from_current_limit, voltage_from_voltage_level) + + def get_current(self) -> float: + current_from_voltage_level = ( + self.voltage_levels[self.selected_channel] + / self.resistances[self.selected_channel] + ) + current_from_current_limit = self.current_limits[self.selected_channel] + + return min(current_from_voltage_level, current_from_current_limit) + + @message(r"^\*IDN\?$") + def identify(self) -> str: + return self.IDENTITY + + @message(r"^INST(?:rument)? (\d)$") + def set_channel(self, channel: int) -> None: + self.selected_channel = int(channel) - 1 + + @message(r"^INST(?:rument)?\?$") + def get_channel(self) -> str: + return str(self.selected_channel + 1) + + @message(r"^OUTP(?:ut)? (\d)$") + def set_enabled(self, enabled: int) -> None: + self.enabled_channels[self.selected_channel] = bool(int(enabled)) + + @message(r"^OUTP(?:ut)?\?$") + def get_enabled(self) -> str: + return str(int(self.enabled_channels[self.selected_channel])) + + @message( + r"(?:SOUR(?:ce)?:)?VOLT(?:age)?(?::LEV(?:el)?)?(?::IMM(?:ediate)?)?(?::AMPL(?:itude)?)? (\d+\.?\d*)$" + ) + def set_voltage_level(self, voltage_level: float) -> None: + voltage_level = min(max(0, float(voltage_level)), 32) + self.voltage_levels[self.selected_channel] = voltage_level + + @message( + r"(?:SOUR(?:ce)?:)?VOLT(?:age)?(?::LEV(?:el)?)?(?::IMM(?:ediate)?)?(?::AMPL(?:itude)?)?\?$" + ) + def get_voltage_level(self) -> str: + return str(self.voltage_levels[self.selected_channel]) + + @message( + r"(?:SOUR(?:ce)?:)?CURR(?:ent)?(?::LEV(?:el)?)?(?::IMM(?:ediate)?)?(?::AMPL(?:itude)?)? (\d+\.?\d*)$" + ) + def set_current_limit(self, current_limit: float) -> None: + current_limit = min(max(0, float(current_limit)), 3) + self.current_limits[self.selected_channel] = current_limit + + @message( + r"(?:SOUR(?:ce)?:)?CURR(?:ent)?(?::LEV(?:el)?)?(?::IMM(?:ediate)?)?(?::AMPL(?:itude)?)?\?$" + ) + def get_current_limit(self) -> str: + return str(self.current_limits[self.selected_channel]) + + @message(r"MEAS(?:ure)?(?::SCAL(?:ar)?)?:VOLT(?:age)?(?::DC)?\?$") + def measure_voltage(self) -> str: + return str(self.get_voltage()) + + @message(r"MEAS(?:ure)?(?::SCAL(?:ar)?)?:CURR(?:ent)?(?::DC)?\?$") + def measure_current(self) -> str: + return str(self.get_current()) + + @message(r"MEAS(?:ure)?(?::SCAL(?:ar)?)?:POW(?:er)?(?::DC)?\?$") + def measure_power(self) -> str: + return str(self.get_voltage() * self.get_current()) + + +if __name__ == "__main__": + run(NGE100Emulator()) diff --git a/src/comet/parameter.py b/src/comet/parameter.py index 8018f33..14429e7 100644 --- a/src/comet/parameter.py +++ b/src/comet/parameter.py @@ -23,7 +23,7 @@ def validate_parameters(cls: ParameterBaseType, values: ParameterValues) -> None """Validates a dictionary containing parameter values.""" for key, parameter in inspect_parameters(cls).items(): if parameter.required: - if not key in values: + if key not in values: raise KeyError(f"missing required parameter: {key!r}") if key in values: parameter.validate(values.get(key)) diff --git a/tests/test_driver_rohde_schwarz_nge100.py b/tests/test_driver_rohde_schwarz_nge100.py new file mode 100644 index 0000000..9778014 --- /dev/null +++ b/tests/test_driver_rohde_schwarz_nge100.py @@ -0,0 +1,121 @@ +import pytest + +from comet.driver.rohde_schwarz.nge100 import NGE100 + +from .test_driver import resource + + +@pytest.fixture +def driver(resource): + return NGE100(resource) + + +def test_identify(driver, resource): + resource.buffer = ["Rohde&Schwarz,NGE103B,5601.3800k03/101863,1.54"] + assert driver.identify() == "Rohde&Schwarz,NGE103B,5601.3800k03/101863,1.54" + assert resource.buffer == ["*IDN?"] + + +def test_reset(driver, resource): + resource.buffer = [""] + driver.reset() + assert resource.buffer == ["*RST", "*OPC?"] + + +def test_clear(driver, resource): + resource.buffer = [""] + driver.clear() + assert resource.buffer == ["*CLS", "*OPC?"] + + +def test_error(driver, resource): + resource.buffer = ["0, 'No error'"] + assert driver.next_error() == None + assert resource.buffer == ["SYSTem:ERRor?"] + + resource.buffer = ["-222, 'Data out of range;INSTrument 5'"] + error = driver.next_error() + assert error.code == -222 + + assert error.message == "Data out of range;INSTrument 5" + assert resource.buffer == ["SYSTem:ERRor?"] + + +def test_get_channel(driver, resource): + assert len(driver) == 3 + for i in range(3): + assert driver[i].channel == i + + with pytest.raises(IndexError): + driver[3] + + with pytest.raises(IndexError): + driver[-1] + + +def test_read_voltage_level(driver, resource): + resource.buffer = ["0"] + assert driver[0].voltage_level == 0.0 + assert resource.buffer == [ + "INSTrument 1", + "SOURce:VOLTage:LEVel:IMMediate:AMPLitude?", + ] + + +def test_set_voltage_level(driver, resource): + resource.buffer = ["1"] + driver[0].voltage_level = 0.0 + assert resource.buffer == [ + "INSTrument 1", + "SOURce:VOLTage:LEVel:IMMediate:AMPLitude 0.0", + "*OPC?", + ] + + with pytest.raises(ValueError): + driver[0].voltage_level = -1 + + with pytest.raises(ValueError): + driver[0].voltage_level = 33 + + +def test_read_current_limit(driver, resource): + resource.buffer = ["0.0"] + assert driver[0].current_limit == 0.0 + assert resource.buffer == [ + "INSTrument 1", + "SOURce:CURRent:LEVel:IMMediate:AMPLitude?", + ] + + +def test_set_current_limit(driver, resource): + resource.buffer = ["1"] + driver[0].current_limit = 0.0 + assert resource.buffer == [ + "INSTrument 1", + "SOURce:CURRent:LEVel:IMMediate:AMPLitude 0.0", + "*OPC?", + ] + + with pytest.raises(ValueError): + driver[0].current_limit = -1 + + with pytest.raises(ValueError): + driver[0].current_limit = 3.1 + + +def test_measure_voltage(driver, resource): + resource.buffer = ["1.0"] + assert driver[0].measure_voltage() == 1.0 + assert resource.buffer == ["INSTrument 1", "MEASure:SCALar:VOLTage:DC?"] + + +def test_measure_current(driver, resource): + resource.buffer = ["1.0"] + assert driver[0].measure_current() == 1.0 + assert resource.buffer == ["INSTrument 1", "MEASure:SCALar:CURRent:DC?"] + + +def test_measure_power(driver, resource): + resource.buffer = ["10.0"] + assert driver[0].measure_power() == 10.0 + assert resource.buffer == ["INSTrument 1", "MEASure:SCALar:POWer?"] diff --git a/tests/test_emulator_rohde_schwarz_nge100.py b/tests/test_emulator_rohde_schwarz_nge100.py new file mode 100644 index 0000000..472e74c --- /dev/null +++ b/tests/test_emulator_rohde_schwarz_nge100.py @@ -0,0 +1,114 @@ +import pytest + +from comet.emulator.rohde_schwarz.nge100 import NGE100Emulator + + +@pytest.fixture +def emulator(): + return NGE100Emulator() + + +def test_identify(emulator): + assert emulator("*IDN?") == "Rohde&Schwarz,NGE103B,5601.3800k03/101863,1.54" + + +def test_channel_selection(emulator): + + for channel in range(3): + emulator(f"INSTrument {channel+1}") + assert emulator("INSTrument?") == f"{channel+1}" + + +def test_initialization(emulator): + + for channel in range(3): + emulator(f"INSTrument {channel+1}") + assert emulator("INSTrument?") == f"{channel+1}" + assert emulator("OUTPut?") == "0" + assert emulator("VOLTage?") == "0.0" + assert emulator("CURRent?") == "0.0" + + +def test_enable_channel(emulator): + + for channel in range(3): + emulator(f"INSTrument {channel+1}") + emulator("OUTPut 1") + assert emulator("OUTPut?") == "1" + + for channel in range(3): + emulator(f"INSTrument {channel+1}") + assert emulator("VOLT?") == "0.0" + assert emulator("CURR?") == "0.0" + + for channel in range(3): + emulator(f"INSTrument {channel+1}") + emulator("OUTPut 0") + assert emulator("OUTPut?") == "0" + + +def test_set_voltage(emulator): + + for channel in range(3): + emulator(f"INSTrument {channel+1}") + emulator("VOLTage 1.0") + assert emulator("VOLTage?") == "1.0" + + +def test_set_current_limit(emulator): + + for channel in range(3): + emulator(f"INSTrument {channel+1}") + emulator("CURRent 2.0") + assert emulator("CURRent?") == "2.0" + + +def test_set_output(emulator): + + for channel in range(3): + emulator(f"INSTrument {channel+1}") + emulator("OUTPut 1") + assert emulator("OUTPut?") == "1" + + for channel in range(3): + emulator(f"INSTrument {channel+1}") + emulator("OUTPut 0") + assert emulator("OUTPut?") == "0" + + +def test_set_voltage_limit(emulator): + """Voltage limited operation with high enough current limit""" + expected_current = [1.0, 0.001, 0.0] + + for channel in range(3): + emulator(f"INSTrument {channel+1}") + emulator("CURR 1.0") + emulator("OUTPut 1") + emulator("VOLTage 1.0") + + assert emulator("OUTPut?") == "1" + assert emulator("CURR?") == "1.0" + assert emulator("VOLT?") == "1.0" + + for channel in range(3): + emulator(f"INSTrument {channel+1}") + assert emulator("MEAS:VOLT?") == "1.0" + assert emulator("MEAS:CURR?") == f"{expected_current[channel]}" + + +def test_set_current_limit(emulator): + """Voltage limited by current limit""" + + current_limit = 0.001 + current_expected = ["0.001", "0.001", "0.0"] + voltages_expected = ["0.001", "1.0", "1.0"] + + for channel in range(3): + emulator(f"INSTrument {channel+1}") + emulator("VOLTage 1.0") + emulator(f"CURR {current_limit}") + emulator("OUTPut 1") + + assert emulator("OUTPut?") == "1" + assert emulator("MEAS:CURR?") == current_expected[channel] + assert emulator("MEAS:VOLT?") == voltages_expected[channel] diff --git a/tox.ini b/tox.ini index d40d51b..a6736d9 100644 --- a/tox.ini +++ b/tox.ini @@ -5,13 +5,11 @@ skip_missing_interpreters = true [testenv] deps = - flake8 - pylint + ruff mypy types-PyYAML pytest commands = - flake8 src --select=E9,F63,F7,F82 - pylint -E src + ruff check src --select=E4,E7,E9,F63,F7,F82 mypy src pytest