# Copyright (c) ETH Zurich, SIS ID and HVL D-ITET
#
"""
The device class `Technix` and its corresponding configuration class
"""
import logging
from time import sleep
from typing import Optional, Union, cast
from hvl_ccb import configdataclass
from hvl_ccb.dev import SingleCommDevice
from hvl_ccb.utils.poller import Poller
from hvl_ccb.utils.typing import Number
from hvl_ccb.utils.validation import validate_bool, validate_number
from .base import (
TechnixError,
TechnixFaultError,
_GetRegisters,
_SetRegisters,
_Status,
_TechnixCommunicationClasses,
)
logger = logging.getLogger(__name__)
[docs]
@configdataclass
class TechnixConfig:
#: communication channel between computer and Technix
communication_channel: _TechnixCommunicationClasses
#: Maximal Output voltage
max_voltage: Number
#: Maximal Output current
max_current: Number
#: Polling interval in s to maintain to watchdog of the device
polling_interval_sec: Number = 4
#: Time to wait after stopping the device
post_stop_pause_sec: Number = 1
#: Time for pulsing a register
register_pulse_time: Number = 0.1
#: Read output voltage and current within the polling event
read_output_while_polling: bool = False
[docs]
class Technix(SingleCommDevice):
"""
Device class to control capacitor chargers from Technix
"""
def __init__(self, com, dev_config) -> None:
# Call superclass constructor
super().__init__(com, dev_config)
# maximum output current of the hardware
self._max_current_hardware = self.config.max_current
# maximum output voltage of the hardware
self._max_voltage_hardware = self.config.max_voltage
self._set_voltage: Number = 0
self._set_current: Number = 0
#: status of Technix
self._status: Optional[_Status] = None
#: Status Poller to maintain the watchdog of the device
self._status_poller: Poller = Poller(
spoll_handler=self._spoll_handler,
polling_interval_sec=self.config.polling_interval_sec,
)
logger.debug("Technix Power Supply initialised.")
[docs]
@staticmethod
def config_cls():
return TechnixConfig
[docs]
def default_com_cls(self) -> _TechnixCommunicationClasses: # type: ignore
return self.config.communication_channel
@property
def is_started(self) -> bool:
"""
Is the device started?
"""
return self.status is not None
[docs]
def start(self):
"""
Start the device and set it into the remote controllable mode. The high
voltage is turn off, and the status poller is started.
"""
if self.is_started:
logger.debug("Technix device was already started.")
return
super().start()
with self.com.access_lock:
logger.debug("Starting Technix...")
self.remote = True
self.output = False
self.inhibit = False
self._status_poller.start_polling()
logger.info("Started Technix")
[docs]
def stop(self):
"""
Stop the device. The status poller is stopped and the high voltage output
is turn off.
"""
if not self.is_started:
logger.debug("Technix device was not started.")
return
with self.com.access_lock:
self._status_poller.stop_polling()
self.output = False
self.remote = False
self._status = None
sleep(self.config.post_stop_pause_sec)
super().stop()
logger.info("Stopped Technix")
def _set_register(self, register: _SetRegisters, value: Union[bool, int]):
"""
Function to set a value to a register
"""
command = f"{register},{int(value)}"
answer = self.com.query(command)
if answer != command:
msg = f"Expected '{command}', but answer was '{answer}'"
logger.error(msg)
raise TechnixError(msg)
def _get_register(self, register: _GetRegisters) -> int:
"""
Function to query a register
"""
_register: str = str(register)
answer = self.com.query(_register)
if answer[: len(_register)] != _register:
msg = f"Expected '{_register}', but answer was '{answer}'"
logger.error(msg)
raise TechnixError(msg)
return int(answer[len(_register) :])
def _spoll_handler(self):
"""
Function to be called from the poller
"""
with self.com.access_lock:
"""
This method can be called manually and by the poller. In case of a
fault the communication is stopped and subsequent calls of this function
are skipped. Only one unique call of this function is allowed to be
performed at the same time. cf. issue 161 on gitlab.com/ethz_hvl/hvl_ccb
"""
try:
self.query_status()
logger.info(self.status)
except TechnixError as e:
self._status_poller.stop_polling()
self._status = None
msg = (
"An error occurred during the polling event and the connection "
"is closed"
)
logger.error(msg)
raise TechnixError(msg) from e
@property
def status(self) -> Optional[_Status]:
"""
The status of the device with the different states as sub-fields
"""
return self._status
@property
def max_current(self) -> Number:
"""
Maximal output current of the hardware in A
"""
return self._max_current_hardware
@property
def max_voltage(self) -> Number:
"""
Maximal output voltage of the hardware in V
"""
return self._max_voltage_hardware
[docs]
def query_status(self, *, _retry: bool = False):
"""
Query the status of the device.
:return: This function returns nothing
"""
with self.com.access_lock:
"""
This method can be called manually and by the poller. In case of a
fault the communication is stopped and subsequent calls of this function
are skipped. Only one unique call of this function is allowed to be
performed at the same time. cf. issue 161 on gitlab.com/ethz_hvl/hvl_ccb
"""
try:
status_byte = self._get_register(_GetRegisters.STATUS) # type: ignore
validate_number(
"Integer of status byte", status_byte, (0, 255), int, logger
)
status_bits = [bool(int(bit)) for bit in f"{status_byte:08b}"]
status_bits[1] = not status_bits[1]
if self.config.read_output_while_polling:
voltage: Optional[Number] = self._measure_voltage()
current: Optional[Number] = self._measure_current()
else:
voltage, current = None, None
self._status = _Status(*status_bits, voltage, current) # type: ignore
if self._status.fault and not self.open_interlock:
msg = (
"The fault flag was detected with closed interlock. There can"
"be a hardware problem with the device."
)
logger.error(msg)
raise TechnixFaultError(msg)
except TechnixFaultError as e:
if not _retry:
# When the interlock gets closed, the HV has to be turned off to
# remove the fault flag. This is achieved with this retry.
logger.info(
"Try to clear the fault. If it persists, there is a "
"real hardware problem."
)
self.output = False
self.query_status(_retry=True)
else:
self._status = None
msg = "An error occurred during querying the status"
logger.error(msg)
raise TechnixError(msg) from e
def _measure_voltage(self) -> Number:
"""
Internal function to measure the output voltage
"""
return (
self._get_register(_GetRegisters.VOLTAGE) # type: ignore
/ 4095
* self.max_voltage
)
@property
def voltage(self) -> Number:
"""
Actual voltage at the output in V
"""
if not self.is_started:
return 0
if self.config.read_output_while_polling:
_voltage = cast(_Status, self.status).voltage
assert _voltage is not None # Make mypy happy, `_voltage` is None when it
# is not queried during polling
else:
_voltage = self._measure_voltage()
logger.info(f"Present Output Voltage: {_voltage:_.2f} V")
return _voltage
@voltage.setter
def voltage(self, value: Number):
"""
Set voltage of the high voltage output
:param value: Voltage as a `Number` in V
:raises ValueError: if the set voltage is below 0 V or higher than the
maximal voltage of the device
"""
validate_number("Set Voltage", value, (0, self.max_voltage), logger=logger)
_voltage = int(4095 * value / self.max_voltage)
validate_number(
"Register value of set voltage", _voltage, (0, 4095), int, logger=logger
)
# Double-check the value if it is really within the limits
self._set_voltage = value
logger.info(f"Set Output Voltage: {value:_.2f} V")
self._set_register(_SetRegisters.VOLTAGE, _voltage) # type: ignore
@property
def set_voltage(self) -> Number:
"""Return the set voltage (may differ from actual value) in V"""
return self._set_voltage
@set_voltage.setter
def set_voltage(self, value: Number) -> None:
"""Set the output voltage"""
self.voltage = value
def _measure_current(self) -> Number:
"""
Internal function to measure the output current
"""
return (
self._get_register(_GetRegisters.CURRENT) # type: ignore
/ 4095
* self.max_current
)
@property
def current(self) -> Number:
"""
Actual current of the output in A
"""
if not self.is_started:
return 0
if self.config.read_output_while_polling:
_current = cast(_Status, self.status).current
assert _current is not None # Make mypy happy, `_current` is None when it
# is not queried during polling
else:
_current = self._measure_current()
logger.info(f"Present Output Current: {_current:_.3f} A")
return _current
@current.setter
def current(self, value: Number):
"""
Set current of the output
:param value: Current as a `Number` in A
"""
validate_number("Set Current", value, (0, self.max_current), logger=logger)
_current = int(4095 * value / self.max_current)
validate_number(
"Register value of set current", _current, (0, 4095), int, logger=logger
)
# Double-check the value if it is really within the limits
self._set_current = value
logger.info(f"Set Output Current: {value:_.3f} A")
self._set_register(_SetRegisters.CURRENT, _current) # type: ignore
@property
def set_current(self) -> Number:
"""Return the set current (may differ from actual value) in A"""
return self._set_current
@set_current.setter
def set_current(self, value: Number) -> None:
"""Set the output current"""
self.current = value
@property
def output(self) -> Optional[bool]:
"""
State of the high voltage output
"""
if self.is_started:
return cast(_Status, self.status).output
return None
@output.setter
def output(self, value: bool):
"""
Activates the output of the source
:param value: `True` for activation, `False` for deactivation
:raises TypeError: if value is not a `bool`
"""
validate_bool("Enable HV-Output", value, logger)
register = _SetRegisters.HVON if value else _SetRegisters.HVOFF
self._set_register(register, True) # type: ignore
sleep(self.config.register_pulse_time)
self._set_register(register, False) # type: ignore
logger.info(f"HV-Output is {'' if value else 'de'}activated")
@property
def remote(self) -> Optional[bool]:
"""
Is the device in remote control mode?
"""
if self.is_started:
return cast(_Status, self.status).remote
return None
@remote.setter
def remote(self, value: bool):
"""
(De-)Activate the remote control mode
:param value: `True` to control the device with this remote control, `False` to
control it with the hardware front panel
"""
validate_bool("Remote control", value, logger)
self._set_register(_SetRegisters.LOCAL, not value) # type: ignore
logger.info(f"Remote control is {'' if value else 'de'}activated")
@property
def inhibit(self) -> Optional[bool]:
"""
Is the output of the voltage inhibited?
The output stage can still be active.
"""
if self.is_started:
return cast(_Status, self.status).inhibit
return None
@inhibit.setter
def inhibit(self, value: bool):
"""
Inhibit the output without deactivating the HV-output section. To generate
high voltage this value must be `False`(!).
:param value: `True` to turn off the output for a short time, `False` to re-turn
it on
:raises TypeError: if value is not a `bool`
"""
validate_bool("Inhibit the output", value, logger)
self._set_register(_SetRegisters.INHIBIT, value) # type: ignore
logger.info(f"Inhibit is {'' if value else 'de'}activated")
@property
def open_interlock(self) -> Optional[bool]:
"""
Is the interlock open? (in safe mode)
"""
if self.is_started:
return cast(_Status, self.status).open_interlock
return None
@property
def voltage_regulation(self) -> Optional[bool]:
"""
Status if the output is in voltage regulation mode (or current regulation)
"""
if self.is_started:
return cast(_Status, self.status).voltage_regulation
return None