Source code for hvl_ccb.dev.ea_psi9000.ea_psi9000

#  Copyright (c) ETH Zurich, SIS ID and HVL D-ITET
#
"""
Device class for controlling a Elektro Automatik PSI 9000 power supply over VISA.

It is necessary that a backend for pyvisa is installed.
This can be NI-Visa oder pyvisa-py (up to now, all the testing was done with NI-Visa)
"""

import dataclasses
import logging
import time
from typing import ClassVar, Iterable, Optional, Union

from hvl_ccb.comm.visa import VisaCommunication, VisaCommunicationConfig
from hvl_ccb.configuration import configdataclass
from hvl_ccb.dev.base import DeviceError
from hvl_ccb.dev.visa import VisaDevice, VisaDeviceConfig
from hvl_ccb.utils.typing import Number

logger = logging.getLogger(__name__)


[docs] class PSI9000Error(DeviceError): """ Base error class regarding problems with the PSI 9000 supply. """ pass
[docs] @configdataclass class PSI9000VisaCommunicationConfig(VisaCommunicationConfig): """ Visa communication protocol config dataclass with specification for the PSI 9000 power supply. """ interface_type: Union[ str, VisaCommunicationConfig.InterfaceType ] = VisaCommunicationConfig.InterfaceType.TCPIP_SOCKET # type: ignore
[docs] class PSI9000VisaCommunication(VisaCommunication): """ Communication protocol used with the PSI 9000 power supply. """
[docs] @staticmethod def config_cls(): return PSI9000VisaCommunicationConfig
[docs] @configdataclass class PSI9000Config(VisaDeviceConfig): """ Elektro Automatik PSI 9000 power supply device class. The device is communicating over a VISA TCP socket. Using this power supply, DC voltage and current can be supplied to a load with up to 2040 A and 80 V (using all four available units in parallel). The maximum power is limited by the grid, being at 43.5 kW available through the CEE63 power socket. """ #: Power limit in W depending on the experimental setup. With #: 3x63A, this is 43.5kW. Do not change this value, if you do not know what you #: are doing. There is no lower power limit. power_limit: Number = 43500 #: Lower voltage limit in V, depending on the experimental setup. voltage_lower_limit: Number = 0.0 #: Upper voltage limit in V, depending on the experimental setup. voltage_upper_limit: Number = 10.0 #: Lower current limit in A, depending on the experimental setup. current_lower_limit: Number = 0.0 #: Upper current limit in A, depending on the experimental setup. current_upper_limit: Number = 2040.0 wait_sec_system_lock: Number = 0.5 wait_sec_settings_effect: Number = 1 wait_sec_initialisation: Number = 2 # limit with 63 A grid connection, absolute limit, never ever change this value _POWER_GRID_LIMIT: ClassVar = 43500 # do not touch this values, unless you really know what you are doing _VOLTAGE_UPPER_LIMIT: ClassVar = 81.6 # nominal voltage + 2% (absolute max) _CURRENT_UPPER_LIMIT: ClassVar = 2080.8 # nominal current + 2 % (absolute max)
[docs] def clean_values(self) -> None: super().clean_values() # check that power_limit is in range if self.power_limit > self._POWER_GRID_LIMIT or self.power_limit < 0: raise ValueError( f"Power limit out of range. Must be in 0..{self._POWER_GRID_LIMIT}" ) # check that lower voltage limits are in range if ( self.voltage_lower_limit < 0 or self.voltage_lower_limit > self.voltage_upper_limit or self.voltage_lower_limit > self._VOLTAGE_UPPER_LIMIT ): raise ValueError("Lower voltage limit out of range.") # check that upper voltage limits are in range if ( self.voltage_upper_limit < 0 or self.voltage_upper_limit < self.voltage_lower_limit or self.voltage_upper_limit > self._VOLTAGE_UPPER_LIMIT ): raise ValueError("Upper voltage limit out of range.") # check that lower current limits are in range if ( self.current_lower_limit < 0 or self.current_lower_limit > self.current_upper_limit or self.current_lower_limit > self._CURRENT_UPPER_LIMIT ): raise ValueError("Lower current limit out of range.") # check that upper current limits are in range if ( self.current_upper_limit < 0 or self.current_upper_limit < self.current_lower_limit or self.current_upper_limit > self._CURRENT_UPPER_LIMIT ): raise ValueError("Upper current limit out of range.") if self.wait_sec_system_lock <= 0: raise ValueError( "Wait time for system lock must be a positive value (in seconds)." ) if self.wait_sec_settings_effect <= 0: raise ValueError( "Wait time for settings effect must be a positive value (in seconds)." ) if self.wait_sec_initialisation <= 0: raise ValueError( "Wait time after initialisation must be a positive value (in seconds)." )
[docs] class PSI9000(VisaDevice): """ Elektro Automatik PSI 9000 power supply. """ # unlock the device only if measured voltage and current are below these values # user defined, not a technical requirement of the device SHUTDOWN_VOLTAGE_LIMIT = 0.1 SHUTDOWN_CURRENT_LIMIT = 0.1 # Master slave nominal voltage and current, if 4 devices are in parallel MS_NOMINAL_VOLTAGE = 80 MS_NOMINAL_CURRENT = 2040 def __init__( self, com: Union[PSI9000VisaCommunication, PSI9000VisaCommunicationConfig, dict], dev_config: Union[PSI9000Config, dict, None] = None, ): super().__init__(com, dev_config)
[docs] @staticmethod def config_cls(): return PSI9000Config
[docs] def start(self) -> None: """ Start this device. """ logger.info("Starting device " + str(self)) super().start()
[docs] def stop(self) -> None: """ Stop this device. Turns off output and lock, if enabled. """ logger.info("Stopping power supply.") current_lock = self.get_system_lock() if current_lock and self.get_output(): # locked and output on self.set_voltage_current(0, 0) time.sleep(self.config.wait_sec_settings_effect) self.set_output(False) time.sleep(self.config.wait_sec_settings_effect) if current_lock: # locked self.set_system_lock(False) super().stop()
[docs] @staticmethod def default_com_cls(): return PSI9000VisaCommunication
[docs] def set_system_lock(self, lock: bool) -> None: """ Lock / unlock the device, after locking the control is limited to this class unlocking only possible when voltage and current are below the defined limits :param lock: True: locking, False: unlocking """ current_system_lock = self.get_system_lock() if lock is current_system_lock: logger.info(f"Lock already at desired state: {lock}") return if lock: # we want to lock the system self.com.write("SYSTem:LOCK ON") time.sleep(self.config.wait_sec_system_lock) new_system_lock = self.get_system_lock() if not new_system_lock: # locking was unsuccessful raise PSI9000Error("Locking system unsuccessful.") logger.info("Power supply is now locked and ready for access.") return if not lock: # we want to unlock the system voltage, current = self.measure_voltage_current() if ( voltage > self.SHUTDOWN_VOLTAGE_LIMIT or current > self.SHUTDOWN_CURRENT_LIMIT ): err_msg = ( "Output voltage and current should be zero: " f"V = {voltage} V, I = {current} A" ) logger.error(err_msg) raise PSI9000Error(err_msg) self.com.write("SYSTem:LOCK OFF") time.sleep(self.config.wait_sec_system_lock) new_system_lock = self.get_system_lock() if new_system_lock: # locking was unsuccessful raise PSI9000Error("Unlocking system was unsuccessful.") logger.info("Power supply is now unlocked and ready for shutdown.") return
[docs] def get_system_lock(self) -> bool: """ Get the current lock state of the system. The lock state is true, if the remote control is active and false, if not. :return: the current lock state of the device """ lock_string = self.com.query("SYSTem:LOCK:OWNer?") if lock_string == "REMOTE": return True elif lock_string == "NONE" or lock_string == "LOCAL": return False else: raise PSI9000Error( f"Illegal answer to SYSTem:LOCK:OWNer? received: {lock_string}" )
[docs] def set_output(self, target_onstate: bool) -> None: """ Enables / disables the DC output. :param target_onstate: enable or disable the output power :raises PSI9000Error: if operation was not successful """ if target_onstate: self.com.write("OUTPut ON") elif not target_onstate: self.com.write("OUTPut OFF") output_state = self.get_output() if target_onstate and output_state: logger.info("Output successfully switched on.") elif target_onstate and not output_state: logger.error("Output was not switched on.") raise PSI9000Error("Output was not switched on.") elif not target_onstate and not output_state: logger.info("Output successfully switched off.") elif not target_onstate and output_state: logger.error("Output was not switched off.") raise PSI9000Error("Output was not switched off.")
[docs] def get_output(self) -> bool: """ Reads the current state of the DC output of the source. Returns True, if it is enabled, false otherwise. :return: the state of the DC output """ on_off_string = self.com.query("OUTPut?") if on_off_string == "ON": return True elif on_off_string == "OFF": return False else: raise PSI9000Error(f"Query of OUTPut? is not ON or OFF: {on_off_string}")
[docs] def measure_voltage_current(self) -> tuple[float, float]: """ Measure the DC output voltage and current :return: Umeas in V, Imeas in A """ list_ret = self.com.query("MEASure:VOLTage?", "MEASure:CURRent?") assert len(list_ret) == 2 ret = self._remove_units(list_ret) return ret[0], ret[1]
[docs] def set_voltage_current(self, volt: float, current: float) -> None: """ Set voltage and current setpoints. After setting voltage and current, a check is performed if writing was successful. :param volt: is the setpoint voltage: 0..81.6 V (1.02 * 0-80 V) (absolute max, can be smaller if limits are set) :param current: is the setpoint current: 0..2080.8 A (1.02 * 0 - 2040 A) (absolute max, can be smaller if limits are set) :raises PSI9000Error: if the desired setpoint is out of limits """ self.com.write(f"SOURce:VOLTage {volt:f}", f"SOURce:CURRent {current:f}") read_voltage, read_current = self.get_voltage_current_setpoint() if read_voltage == volt and read_current == current: logger.info( f"Setting voltage to {volt:.2f} V and current to {current:.2f} A was " "successful." ) else: v_lower, i_lower = self.get_ui_lower_limits() v_upper, i_upper, p_upper = self.get_uip_upper_limits() err_msg = ( f"Setting U = {volt:f} V and I = {current:f} A was not successful: " f"voltage has to be between {v_lower} V and {v_upper} V, " f"current between {i_lower} A and {i_upper} A. " f"Actual settings are: U = {read_voltage} V, I = {read_current} A" ) logger.error(err_msg) raise PSI9000Error(err_msg)
[docs] def get_voltage_current_setpoint(self) -> tuple[float, float]: """ Get the voltage and current setpoint of the current source. :return: Uset in V, Iset in A """ list_ret = self.com.query("SOURce:VOLTage?", "SOURce:CURRent?") assert len(list_ret) == 2 ret = self._remove_units(list_ret) return ret[0], ret[1]
[docs] def set_upper_limits( self, voltage_limit: Optional[float] = None, current_limit: Optional[float] = None, power_limit: Optional[float] = None, ) -> None: """ Set the upper limits for voltage, current and power. After writing the values a check is performed if the values are set. If a parameter is left blank, the maximum configurable limit is set. :param voltage_limit: is the voltage limit in V :param current_limit: is the current limit in A :param power_limit: is the power limit in W :raises PSI9000Error: if limits are out of range """ voltage_limit = ( voltage_limit if voltage_limit is not None else self.config.voltage_upper_limit ) current_limit = ( current_limit if current_limit is not None else self.config.current_upper_limit ) power_limit = ( power_limit if power_limit is not None else self.config.power_limit ) v_lower, i_lower = self.get_ui_lower_limits() # Creates a new configclass object just to check the limits; will raise # ValueError if not within limits (see PSI9000Config.clean_values) dataclasses.replace( self.config, voltage_lower_limit=v_lower, current_lower_limit=i_lower, voltage_upper_limit=voltage_limit, current_upper_limit=current_limit, power_limit=power_limit, ) self.com.write( f"SOURce:VOLTage:LIMit:HIGH {voltage_limit}", f"SOURce:CURRent:LIMit:HIGH {current_limit}", f"SOURce:POWer:LIMit:HIGH {power_limit}", ) # wait until settings are made time.sleep(self.config.wait_sec_settings_effect) v_higher, i_higher, p_higher = self.get_uip_upper_limits() if ( v_higher == voltage_limit and i_higher == current_limit and p_higher == power_limit ): logger.info( f"New upper voltage, current, power limits set: Umax = {voltage_limit} " f"V, Imax = {current_limit} A, Pmax = {power_limit} W" ) else: raise PSI9000Error("Setting upper limits was not successful.")
[docs] def set_lower_limits( self, voltage_limit: Optional[float] = None, current_limit: Optional[float] = None, ) -> None: """ Set the lower limits for voltage and current. After writing the values a check is performed if the values are set correctly. :param voltage_limit: is the lower voltage limit in V :param current_limit: is the lower current limit in A :raises PSI9000Error: if the limits are out of range """ voltage_limit = ( voltage_limit if voltage_limit is not None else self.config.voltage_lower_limit ) current_limit = ( current_limit if current_limit is not None else self.config.current_lower_limit ) v_upper, i_upper, p_upper = self.get_uip_upper_limits() # Creates a new configclass object just to check the limits; will raise # ValueError if not within limits (see PSI9000Config.clean_values) dataclasses.replace( self.config, voltage_upper_limit=v_upper, current_upper_limit=i_upper, power_limit=p_upper, voltage_lower_limit=voltage_limit, current_lower_limit=current_limit, ) self.com.write( f"SOURce:VOLTage:LIMit:LOW {voltage_limit}", f"SOURce:CURRent:LIMit:LOW {current_limit}", ) v_lower, i_lower = self.get_ui_lower_limits() if v_lower == voltage_limit and i_lower == current_limit: logger.info( f"New lower voltage and current limits set: Umin = {voltage_limit} V, " f"Imin = {current_limit} A" ) else: raise PSI9000Error("Setting lower limits was unsuccessful.")
[docs] def get_ui_lower_limits(self) -> tuple[float, float]: """ Get the lower voltage and current limits. A lower power limit does not exist. :return: Umin in V, Imin in A """ list_ret = self.com.query( "SOURce:VOLTage:LIMit:LOW?", "SOURce:CURRent:LIMit:LOW?" ) assert len(list_ret) == 2 ret = self._remove_units(list_ret) return ret[0], ret[1]
[docs] def get_uip_upper_limits(self) -> tuple[float, float, float]: """ Get the upper voltage, current and power limits. :return: Umax in V, Imax in A, Pmax in W """ list_ret = self.com.query( "SOURce:VOLTage:LIMit:HIGH?", "SOURce:CURRent:LIMit:HIGH?", "SOURce:POWer:LIMit:HIGH?", ) assert len(list_ret) == 3 ret = self._remove_units(list_ret) return ret[0], ret[1], ret[2]
[docs] def check_master_slave_config(self) -> None: """ Checks if the master / slave configuration and initializes if successful :raises PSI9000Error: if master-slave configuration failed """ # verify that MS is enabled if self.com.query("SYSTem:MS:ENABle?") != "ON": logger.error("Master-slave-mode not enabled.") raise PSI9000Error("Master-slave-mode not enabled.") # verify that this device ist MASTER if self.com.query("SYSTem:MS:LINK?") != "MASTER": logger.error("Device is not Master.") raise PSI9000Error("Device is not Master.") # begin initialization self.com.write("SYSTem:MS:INITialisation") time.sleep(self.config.wait_sec_initialisation) # check for correct init if self.com.query("SYSTem:MS:CONDition?") != "INIT": logger.error("Master-slave initialisation failed.") raise PSI9000Error("Master-slave initialisation failed.") # read resulting master-slave nominal voltage and current ms_voltage, ms_current = self._remove_units( self.com.query("SYSTem:MS:NOMinal:VOLTage?", "SYSTem:MS:NOMinal:CURRent?") ) # check for correct total voltage and current if ( ms_current != self.MS_NOMINAL_CURRENT or ms_voltage != self.MS_NOMINAL_VOLTAGE ): err_msg = ( f"Nominal voltage and current should be {self.MS_NOMINAL_VOLTAGE} V, " f"{self.MS_NOMINAL_CURRENT} A; but are {ms_voltage} V, {ms_current} A." ) logger.error(err_msg) raise PSI9000Error(err_msg) # here the initialization is successful logger.info("Initialization of Master/Slave successful")
@staticmethod def _remove_units(list_with_strings: Iterable[str]) -> tuple[float, ...]: """ Removes the last two characters of each string in the list and convert it to float (is only working for units with one character e.g. V, A, W) :param list_with_strings: list with return strings containing value and unit :return: list of floats without units """ return tuple(float(value[:-2]) for value in list_with_strings)