Source code for hvl_ccb.dev.heinzinger.device

#  Copyright (c) ETH Zurich, SIS ID and HVL D-ITET
#
"""
Device classes for Heinzinger Digital Interface I/II and Heinzinger PNC power supply.

The Heinzinger Digital Interface I/II is used for many Heinzinger power units.
Interface Manual:
https://www.heinzinger.com/assets/uploads/downloads/Handbuch_DigitalInterface_2021-12-14-V1.6.pdf

The Heinzinger PNC series is a series of high voltage direct current power supplies.
The class Heinzinger is tested with different PNChp-types.
Check the code carefully before using it with other PNC devices, especially PNC3p
or PNCcap.
Manufacturer homepage:
https://www.heinzinger.com/en/products/pnc-serie
"""
import logging
import re
from time import sleep
from typing import Optional, Union

from hvl_ccb.configuration import configdataclass
from hvl_ccb.dev.base import SingleCommDevice
from hvl_ccb.dev.heinzinger.base import HeinzingerSerialCommunication
from hvl_ccb.dev.heinzinger.constants import (
    HeinzingerDeviceNotRecognizedError,
    HeinzingerSetValueError,
    RecordingsEnum,
)
from hvl_ccb.dev.heinzinger.mixin import DeprecatedHeinzingerMixin
from hvl_ccb.utils.typing import Number
from hvl_ccb.utils.validation import validate_bool, validate_number

logger = logging.getLogger(__name__)


[docs] @configdataclass class HeinzingerConfig: """ Device configuration dataclass for Heinzinger power supplies. """ #: default number of recordings used in averaging the current # or the voltage [1, 2, 4, 8, 16] default_number_of_recordings: Union[int, RecordingsEnum] = 1 #: number of decimals sent for setting the current limit or the voltage, between 1 # and 10 number_of_decimals: int = 6 #: Time to wait after subsequent commands during stop (in seconds) wait_sec_stop_commands: Number = 0.5
[docs] def clean_values(self): if not isinstance(self.default_number_of_recordings, RecordingsEnum): self.force_value( "default_number_of_recordings", RecordingsEnum(self.default_number_of_recordings), ) if self.number_of_decimals not in range(1, 11): raise ValueError( "The number of decimals should be an integer between 1 and 10." ) if self.wait_sec_stop_commands <= 0: raise ValueError( "Wait time after subsequent commands during stop must be be a " "positive value (in seconds)." )
[docs] class Heinzinger(SingleCommDevice, DeprecatedHeinzingerMixin): """ Heinzinger power supply device class. The power supply is controlled over a Heinzinger Digital Interface I/II Sends basic SCPI commands and reads the answer. Only the standard instruction set from the manual is implemented. """ def __init__(self, com, dev_config=None) -> None: # Call superclass constructor super().__init__(com, dev_config) # Version of the interface (will be retrieved after com is opened) self._interface_version = "" # Status of the voltage output (it has to be updated via the output = True and # output = False because querying it is not supported) self._output_status: Optional[bool] = None # Serial number of the device (will be retrieved after com is opened) self._serial_number: str = "" # model of the device (derived from serial number) self._model: str = "" # maximum output current of the hardware (unit A) self._max_current_hardware: Number = 0 # maximum output voltage of the hardware (unit V) self._max_voltage_hardware: Number = 0 # maximum output current set by user (unit A) self._max_current: Number = 0 # maximum output voltage set by user (unit V) self._max_voltage: Number = 0 # the tolerance value for checking if the set voltage/current is correctly set self._epsilon: Number = 1e-3 # identify voltage range, because unit is always V self._voltage_multiplier: Number = 0 # identify current range, because unit is always A self._current_multiplier: Number = 0 def __repr__(self): return ( f"HeinzingerPNC({self._serial_number}), with " f"HeinzingerDI({self._interface_version})" )
[docs] @staticmethod def default_com_cls() -> type[HeinzingerSerialCommunication]: return HeinzingerSerialCommunication
[docs] @staticmethod def config_cls() -> type[HeinzingerConfig]: return HeinzingerConfig
[docs] def start(self) -> None: """ Opens the communication protocol and configures the device. :raises SerialCommunicationIOError: when communication port cannot be opened. """ logger.info("Starting device " + str(self)) super().start() self._interface_version = self.get_interface_version() # find out which type of source this is: self.identify_device() self.number_of_recordings = self.config.default_number_of_recordings
[docs] def stop(self) -> None: """ Stop the device. Closes also the communication protocol. """ logger.info(f"Stopping device {self}") if not self.com.is_open: logger.warning(f"Device {self} already stopped") else: # set the voltage to zero self.voltage = 0 sleep(self.config.wait_sec_stop_commands) # switch off the voltage output self.output = False sleep(self.config.wait_sec_stop_commands) super().stop()
[docs] def identify_device(self) -> None: """ Identify the device nominal voltage and current based on its serial number. :raises SerialCommunicationIOError: when communication port is not opened """ serial_number = self.get_serial_number() # regex to find the model of the device regex_vc = r"(\d+)-(\d+)" # voltage-current info regex_model = r"PNC.*?" + regex_vc + r"\s?[a-z]{3}" result = re.search(regex_model, serial_number) if result: self._serial_number = serial_number model = result.group() self._model = model # regex to find the nominal voltage and nominal current match = re.search(regex_vc, model) assert match # already matched in regex_model expression voltage = int(match.group(1)) current = int(match.group(2)) # the units of voltage (V) and current (A) self._max_voltage_hardware = voltage self._max_voltage = voltage self._max_current_hardware = current / 1000 self._max_current = current / 1000 if self._max_voltage_hardware < 100000: self._voltage_multiplier = 1.0 else: self._voltage_multiplier = 1e3 if self._max_current_hardware < 0.001: self._current_multiplier = 1e-6 # I_nenn < 1 mA elif self._max_current_hardware > 1: self._current_multiplier = 1.0 # I_nenn else: self._current_multiplier = 1e-3 # I_nenn >= 1 mA & < 1 A logger.info(f"Device {model} successfully identified") else: raise HeinzingerDeviceNotRecognizedError(serial_number)
[docs] def reset_interface(self) -> None: """ Reset of the digital interface; only Digital Interface I: Power supply is switched to the Local-Mode (Manual operation) :raises SerialCommunicationIOError: when communication port is not opened """ logger.info("Reset of the digital interface") self.com.write("*RST")
[docs] def get_interface_version(self) -> str: """ Queries the version number of the digital interface. :raises SerialCommunicationIOError: when communication port is not opened """ version = self.com.query("VERS?") logger.info(f"The interface version of the Heinzinger: {version}") return version
[docs] def get_serial_number(self) -> str: """ Ask the device for its serial number and returns the answer as a string. :return: string containing the device serial number :raises SerialCommunicationIOError: when communication port is not opened """ value = self.com.query("*IDN?") logger.info(f"The identification string of the Heinzinger: {value}") return value
@property def max_current_hardware(self) -> Number: """Maximal output current the hardware can provide in A""" return self._max_current_hardware @property def max_voltage_hardware(self) -> Number: """Maximal output voltage the hardware can provide in V""" return self._max_voltage_hardware @property def max_current(self) -> Number: """Maximal settable output current in A""" return self._max_current @max_current.setter def max_current(self, value: Optional[Number]): """Maximal settable output current in A""" if value is None: # Reset the user-defined limit to the hardware limit self._max_current = self.max_current_hardware return validate_number( "max_current", value, (0, self.max_current_hardware), logger=logger ) self._max_current = value @property def max_voltage(self) -> Number: """Maximal settable output voltage in V""" return self._max_voltage @max_voltage.setter def max_voltage(self, value: Optional[Number]): """Maximal settable output voltage in V""" if value is None: # Reset the user-defined limit to the hardware limit self._max_voltage = self.max_voltage_hardware return validate_number( "max_voltage", value, (0, self.max_voltage_hardware), logger=logger ) self._max_voltage = value @property def output(self) -> Optional[bool]: """ Switch DC voltage output on and updates the output status. :raises SerialCommunicationIOError: when communication port is not opened """ return self._output_status @output.setter def output(self, value: bool) -> None: """ Switch DC voltage output on or off and updates the output status. :raises SerialCommunicationIOError: when communication port is not opened :raises TypeError: if value is not a `bool` """ validate_bool("Enable DC voltage output", value, logger) if value: self.com.write("OUTP ON") self._output_status = True else: self.com.write("OUTP OFF") self._output_status = False logger.info(f"DC voltage output is {'ON' if value else 'OFF'}") @property def number_of_recordings(self) -> int: """ Queries the number of recordings the device is using for average value calculation. :return: int number of recordings :raises SerialCommunicationIOError: when communication port is not opened """ value = self.com.query("AVER?") logger.info( "the number of recordings the device is using for " f"average value calculation: {value}" ) return int(value) @number_of_recordings.setter def number_of_recordings(self, value: Union[int, RecordingsEnum]) -> None: """ Sets the number of recordings the device is using for average value calculation. The possible values are 1, 2, 4, 8 and 16. :raises SerialCommunicationIOError: when communication port is not opened """ value = int(RecordingsEnum(value).value) # if the input is not 1, 2, 4, 8, or 16, RangeEnum picks a suitable one validate_number("number_of_recordings", value, (1, 16), int, logger=logger) self.com.write(f"AVER {value}") @property def voltage(self) -> Number: """ Ask the Device to measure its output voltage and return the measurement result in V. :return: measured voltage as float in V :raises SerialCommunicationIOError: when communication port is not opened """ value = float(self.com.query("MEAS:VOLT?")) * self._voltage_multiplier logger.info(f"Output measured voltage of the Heinzinger PNC: {value} V") return value @voltage.setter def voltage(self, value: Number) -> None: """ Sets the output voltage of the Heinzinger PNC to the given value in V. Same as set_voltage.setter :param value: voltage expressed in V :raises SerialCommunicationIOError: when communication port is not opened :raises HeinzingerSetValueError: when the value was not set correctly """ validate_number("voltage", value, (0, self.max_voltage), logger=logger) # value_input could be in kV or V depending on self._max_voltage_hardware value_command = value / self._voltage_multiplier self.com.write(f"VOLT {value_command:.{self.config.number_of_decimals}f}") # Validation: sleep(self.config.wait_sec_stop_commands) set_value = self.set_voltage if abs(value - set_value) > self._epsilon * self._max_voltage_hardware: msg = ( f"Tried to set voltage = {value} V, " f"but the device did not take the value; the value is = {set_value} V" ) logger.error(msg) raise HeinzingerSetValueError(msg) else: logger.info(f"Output voltage of the Heinzinger PNC is set to: {value} V") @property def set_voltage(self) -> Number: """ Queries the set voltage of the Heinzinger PNC (not the measured voltage!) in V. :raises SerialCommunicationIOError: when communication port is not opened """ value = float(self.com.query("VOLT?")) * self._voltage_multiplier logger.info(f"Output set voltage of the Heinzinger PNC: {value} V") return value @set_voltage.setter def set_voltage(self, value: Number) -> None: """ Sets the output voltage of the Heinzinger PNC to the given value in V. Same as voltage.setter :param value: voltage expressed in V :raises SerialCommunicationIOError: when communication port is not opened """ self.voltage = value @property def current(self) -> Number: """ Ask the Device to measure its output current and return the measurement result in A. :return: measured current as float :raises SerialCommunicationIOError: when communication port is not opened """ value = float(self.com.query("MEAS:CURR?")) * self._current_multiplier logger.info(f"Output measured current of the Heinzinger PNC: {value} A") return value @current.setter def current(self, value: Number) -> None: """ Sets the output current of the Heinzinger PNC to the given value in A. Same as set_current.setter :param value: current expressed in A :raises SerialCommunicationIOError: when communication port is not opened :raises HeinzingerSetValueError: when the value was not set correctly """ validate_number("current", value, (0, self.max_current), logger=logger) # value_input could be in µA, mA, or A depending on self._max_current_hardware value_command = value / self._current_multiplier self.com.write(f"CURR {value_command:.{self.config.number_of_decimals}f}") # Validation: sleep(self.config.wait_sec_stop_commands) set_value = self.set_current if abs(value - set_value) > self._epsilon * self._max_current_hardware: msg = ( f"Tried to set current = {value} A, " f"but the device did not take the value; the value is = {set_value} A" ) logger.error(msg) raise HeinzingerSetValueError(msg) else: logger.info(f"Output current of the Heinzinger PNC is set to: {value} A") @property def set_current(self) -> Number: """ Queries the set current of the Heinzinger PNC (not the measured current!) in A. :raises SerialCommunicationIOError: when communication port is not opened """ value = float(self.com.query("CURR?")) * self._current_multiplier logger.info(f"Output set current of the Heinzinger PNC: {value} A") return value @set_current.setter def set_current(self, value: Number) -> None: """ Sets the output current of the Heinzinger PNC to the given value in A. Same as current.setter :param value: current expressed in A :raises SerialCommunicationIOError: when communication port is not opened """ self.current = value