Source code for hvl_ccb.dev.cube.picube

#  Copyright (c) ETH Zurich, SIS ID and HVL D-ITET
#
"""
A PICube is a BaseCube with build in Power Inverter
"""
from __future__ import annotations

import logging
from dataclasses import dataclass
from time import sleep, time
from typing import Optional

from hvl_ccb.configuration import configdataclass
from hvl_ccb.utils.typing import Number
from hvl_ccb.utils.validation import validate_number

from . import constants
from .base import (
    BaseCube,
    BaseCubeConfiguration,
    BaseCubeOpcUaCommunication,
    BaseCubeOpcUaCommunicationConfig,
)
from .constants import _CubeOpcEndpoint
from .errors import PICubeTestParameterError

logger = logging.getLogger(__name__)


[docs] @configdataclass class PICubeOpcUaCommunicationConfig(BaseCubeOpcUaCommunicationConfig): endpoint_name: _CubeOpcEndpoint = _CubeOpcEndpoint.PI_CUBE # type: ignore
[docs] class PICubeOpcUaCommunication(BaseCubeOpcUaCommunication):
[docs] @staticmethod def config_cls(): return PICubeOpcUaCommunicationConfig
[docs] @configdataclass class PICubeConfiguration(BaseCubeConfiguration): timeout_test_parameters: Number = 2.0
[docs] def clean_values(self): super().clean_values() if self.timeout_test_parameters < 0: raise ValueError( "Timeout for setting test parameters needs to be not negative." )
@dataclass class _TestParameter: """ Class to access the test parameter. For the PICube it is possible to set a voltage target and specify the slope of the voltage ramp. """ _handle: PICube tolerance: Number = 0.01 # tolerance is needed because of numerical precision @property def voltage(self) -> float: """ Target voltage of the PICube. :return: target voltage in volt """ display_value = float(self._handle.read(constants._Power.VOLTAGE_TARGET)) power_setup = self._handle._power_setup logger.info( f"Test parameter: voltage is {display_value:_.2f} {power_setup.unit}" ) return self._handle._apply_polarity(display_value / power_setup.scale) @voltage.setter def voltage(self, value: Number) -> None: """ Target voltage of the PICube :param value: target voltage in volt :raises PICubeTestParameterError: if the value cannot be set within the given time """ power_setup = self.power_setup if power_setup is None: return unit = power_setup.unit if self._handle.power_setup in constants.DC_POWER_SETUPS: if self._handle._polarity == constants.Polarity.POSITIVE: validate_number( "voltage", value, (0, self._handle.voltage_max), logger=logger ) else: validate_number( "voltage", value, (self._handle.voltage_max, 0), logger=logger ) else: validate_number( "voltage", value, (0, self._handle.voltage_max), logger=logger ) voltage_opc = value * power_setup.scale self._handle.write(constants._Power.VOLTAGE_TARGET, abs(voltage_opc)) start_time = time() rec_target_voltage = self.voltage while not (self._check_tolerance(value, rec_target_voltage, self.tolerance)): if (time() - start_time) >= self._handle.config.timeout_test_parameters: msg = ( f"Test parameters: voltage should be set to {value:_.2f} V, " f"but is {rec_target_voltage:_.2f} V." ) logger.error(msg) raise PICubeTestParameterError(msg) sleep(0.1) rec_target_voltage = self.voltage logger.info(f"Test parameters: voltage is set to {voltage_opc:_.2f} {unit}") @property def slope(self) -> float: """ slope of the voltage ramp. While the value is always positive, the slope will be negative if the target voltage is below the current voltage. :return: slope in V/s """ display_value = float(self._handle.read(constants._Power.VOLTAGE_SLOPE)) power_setup = self._handle._power_setup logger.info(f"Test parameter: slope is {display_value:_.2f} {power_setup.unit}") return display_value / power_setup.scale @slope.setter def slope(self, value: Number) -> None: """ slope of the voltage ramp. While the value is always positive, the slope will be negative if the target voltage is below the current voltage. :param value: slope in V/s :raises PICubeTestParameterError: if the value cannot be set within the given time """ power_setup = self.power_setup if power_setup is None: return slope_max = power_setup.slope_max slope_min = power_setup.slope_min unit = power_setup.unit validate_number("slope", value, (slope_min, slope_max), logger=logger) slope_opc = value * power_setup.scale self._handle.write(constants._Power.VOLTAGE_SLOPE, slope_opc) start_time = time() rec_slope = self.slope # tolerance is needed because of numerical precision while not ( self._check_tolerance( value, rec_slope, self.tolerance, ignore_polarity=True ) ): if (time() - start_time) >= self._handle.config.timeout_test_parameters: msg = ( f"Test parameters: slope should be set to {value:_.2f} V/s, " f"but is {rec_slope:_.2f} V/s." ) logger.error(msg) raise PICubeTestParameterError(msg) sleep(0.1) rec_slope = self.slope logger.info(f"Test parameters: slope is set to {slope_opc:_.2f} {unit}/s") @property def power_setup(self) -> Optional[constants.PowerSetup]: """ Checks if cube is in correct state and returns the power state. :return: Power Setup of PICube. """ if self._handle._status is not constants.SafetyStatus.RED_OPERATE: logger.warning( "To set test parameters, the PICube needs to be in " f"Status 'RED_OPERATE', but is in '{self._handle._status.name}'" ) return None power_setup = self._handle._power_setup if power_setup in ( constants.PowerSetup.NO_SOURCE, constants.PowerSetup.EXTERNAL_SOURCE, ): logger.warning( "It is not possible to set new test parameters while " f"the PICube is in '{power_setup}' mode." ) return None return power_setup def _check_tolerance( self, set_value: Number, actual_value: Number, tol: Number, ignore_polarity: bool = False, ) -> bool: """ Checks if the actual value is within the tolerance of the set value. :param set_value: :param actual_value: :param tol: value between 0 and 1 :param ignore_polarity: if `True`, always compare positive values, cf. #291 :return: `True` if within the tolerance, otherwise `False` """ if ( self._handle._polarity == constants.Polarity.NEGATIVE and not ignore_polarity ): value = (1 - tol) * set_value >= actual_value >= (1 + tol) * set_value else: value = (1 - tol) * set_value <= actual_value <= (1 + tol) * set_value return value
[docs] class PICube(BaseCube): """ Variant of the BaseCube with build in Power Inverter """ def __init__(self, com, dev_config=None): super().__init__(com, dev_config) self.test_parameter = _TestParameter(self)
[docs] @staticmethod def default_com_cls(): return PICubeOpcUaCommunication
[docs] @staticmethod def config_cls(): return PICubeConfiguration
@property def operate(self) -> Optional[bool]: # this construct is necessary to keep mypy happy return super().operate @operate.setter def operate(self, state: bool) -> None: """ Set operate state. If the state is RED_READY, this will turn on the high voltage and close the safety switches. If the state is RED_OPERATE and a status change to RED_READY is queried - with a measured output voltage above the voltage limit -- a warning is logged :param state: set operate state :raises CubeStatusChangeError: if `state=True` and cube is not in RED_READY or if `state=False` and cube is not in RED_OPERATE """ if self.power_setup in constants.DC_POWER_SETUPS: voltage_limit = self.measurement_ch_3.noise_level elif self.power_setup in constants.AC_POWER_SETUPS: voltage_limit = self.measurement_ch_1.noise_level elif self.power_setup == constants.PowerSetup.POWER_INVERTER_220V: # TODO voltage_limit for PowerInverter_220, what is the return of actual # voltage? # Which value is returned? voltage_limit = 10 else: voltage_limit = 0 if ( self.voltage_actual > voltage_limit and self._status is constants.SafetyStatus.RED_OPERATE and not state ): logger.warning( "The output voltage needs to be 0 V in order to change " "Status from RED_OPERATE to RED_READY." ) return BaseCube.operate.fset(self, state) # type: ignore @property def voltage_max(self) -> float: """ Reads the maximum voltage of the setup and returns in V. :return: the maximum voltage of the setup in V. """ power_setup = self._power_setup value = float(self.read(constants._Power.VOLTAGE_MAX)) logger.info( "Maximum Output Voltage with current Setup " f"is {value:_.2f} {power_setup.unit}" ) value = value / power_setup.scale return self._apply_polarity(value) @property def voltage_actual(self) -> float: """ Reads the actual measured voltage and returns the value in V. :return: the actual voltage of the setup in V. """ power_setup = self._power_setup value = float(self.read(constants._Power.VOLTAGE_ACTUAL)) logger.info(f"Actual Output Voltage is {value:_.2f} {power_setup.unit}") value = value / power_setup.scale return self._apply_polarity(value) @property def voltage_primary(self) -> float: """ Read the current primary voltage at the output of the frequency converter (before transformer). :return: primary voltage in V """ value = float(self.read(constants._Power.VOLTAGE_PRIMARY)) logger.info( f"Primary Voltage at the Output of the Power Inverter is {value:_.2f} V" ) return value @property def current_primary(self) -> float: """ Read the current primary current at the output of the frequency converter (before transformer). :return: primary current in A """ value = float(self.read(constants._Power.CURRENT_PRIMARY)) logger.info( f"Primary Current at the Output of the Power Inverter is {value:_.2f} A" ) return value @property def frequency(self) -> float: """ Read the electrical frequency of the current PICube setup. :return: the frequency in Hz """ value = float(self.read(constants._Power.FREQUENCY)) logger.info(f"Output Frequency of the Power Inverter is {value} Hz") return value @property def _power_setup(self) -> constants.PowerSetup: """ Return the power setup selected in the PICube's settings, without logging :return: the power setup """ return constants.PowerSetup(self.read(constants._Power.SETUP)) @property def power_setup(self) -> constants.PowerSetup: """ Return the power setup selected in the PICube's settings. :return: the power setup """ value = self._power_setup logger.info( f"Current programmed Power Setup of the PICube " f"is {value.name}" # type: ignore ) return value @property def _polarity(self) -> Optional[constants.Polarity]: value = None if self._power_setup in constants.DC_POWER_SETUPS: value = constants.Polarity(self.read(constants._Power.POLARITY)) return value @property def polarity(self) -> Optional[constants.Polarity]: """ Polarity of a DC setup. :return: if a DC setup is programmed the polarity is returned, else None. """ value = self._polarity if value is not None: logger.info( f"The polarity of the experiment setup is {value.name}" # type: ignore ) else: logger.info("Only DC-Setups have a polarity.") return value def _apply_polarity(self, value: Number) -> Number: if ( self._power_setup in constants.DC_POWER_SETUPS and self._polarity == constants.Polarity.NEGATIVE ): value = -value return value