# 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