Coverage for C:\Users\hjanssen\HOME\pyCharmProjects\ethz_hvl\hvl_ccb\hvl_ccb\dev\ea_psi9000.py : 28%

Hot-keys on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1# Copyright (c) 2019-2020 ETH Zurich, SIS ID and HVL D-ITET
2#
3"""
4Device class for controlling a Elektro Automatik PSI 9000 power supply over VISA.
6It is necessary that a backend for pyvisa is installed.
7This can be NI-Visa oder pyvisa-py (up to know, all the testing was done with NI-Visa)
8"""
10import dataclasses
11import logging
12import time
13from typing import ClassVar, Iterable, Tuple, Union
15from .visa import VisaDevice, VisaDeviceConfig
16from ..comm import (
17 VisaCommunication,
18 VisaCommunicationConfig,
19)
20from ..configuration import configdataclass
21from ..utils.typing import Number
24class PSI9000Error(Exception):
25 """
26 Base error class regarding problems with the PSI 9000 supply.
27 """
29 pass
32@configdataclass
33class PSI9000VisaCommunicationConfig(VisaCommunicationConfig):
34 """
35 Visa communication protocol config dataclass with specification for the PSI 9000
36 power supply.
37 """
39 interface_type: Union[
40 str, VisaCommunicationConfig.InterfaceType
41 ] = VisaCommunicationConfig.InterfaceType.TCPIP_SOCKET # type: ignore
44class PSI9000VisaCommunication(VisaCommunication):
45 """
46 Communication protocol used with the PSI 9000 power supply.
47 """
49 @staticmethod
50 def config_cls():
51 return PSI9000VisaCommunicationConfig
54@configdataclass
55class PSI9000Config(VisaDeviceConfig):
56 """
57 Elektro Automatik PSI 9000 power supply device class.
58 The device is communicating over a VISA TCP socket.
60 Using this power supply, DC voltage and current can be supplied to a load with up
61 to 2040 A and 80 V (using all four available units in parallel). The maximum power
62 is limited by the grid, being at 43.5 kW available through the CEE63 power socket.
63 """
65 #: Power limit in W depending on the experimental setup. With
66 #: 3x63A, this is 43.5kW. Do not change this value, if you do not know what you
67 #: are doing. There is no lower power limit.
68 power_limit: Number = 43500
70 #: Lower voltage limit in V, depending on the experimental setup.
71 voltage_lower_limit: Number = 0.0
73 #: Upper voltage limit in V, depending on the experimental setup.
74 voltage_upper_limit: Number = 10.0
76 #: Lower current limit in A, depending on the experimental setup.
77 current_lower_limit: Number = 0.0
79 #: Upper current limit in A, depending on the experimental setup.
80 current_upper_limit: Number = 2040.0
82 wait_sec_system_lock: Number = 0.5
83 wait_sec_settings_effect: Number = 1
84 wait_sec_initialisation: Number = 2
86 # limit with 63 A grid connection, absolute limit, never ever change this value
87 _POWER_GRID_LIMIT: ClassVar = 43500
89 # do not touch this values, unless you really know what you are doing
90 _VOLTAGE_UPPER_LIMIT: ClassVar = 81.6 # nominal voltage + 2% (absolute max)
91 _CURRENT_UPPER_LIMIT: ClassVar = 2080.8 # nominal current + 2 % (absolute max)
93 def clean_values(self) -> None:
95 # check that power_limit is in range
96 if self.power_limit > self._POWER_GRID_LIMIT or self.power_limit < 0:
97 raise ValueError(
98 f"Power limit out of range. Must be in " f"0..{self._POWER_GRID_LIMIT}"
99 )
101 # check that lower voltage limits are in range
102 if (
103 self.voltage_lower_limit < 0
104 or self.voltage_lower_limit > self.voltage_upper_limit
105 or self.voltage_lower_limit > self._VOLTAGE_UPPER_LIMIT
106 ):
107 raise ValueError("Lower voltage limit out of range.")
109 # check that upper voltage limits are in range
110 if (
111 self.voltage_upper_limit < 0
112 or self.voltage_upper_limit < self.voltage_lower_limit
113 or self.voltage_upper_limit > self._VOLTAGE_UPPER_LIMIT
114 ):
115 raise ValueError("Upper voltage limit out of range.")
117 # check that lower current limits are in range
118 if (
119 self.current_lower_limit < 0
120 or self.current_lower_limit > self.current_upper_limit
121 or self.current_lower_limit > self._CURRENT_UPPER_LIMIT
122 ):
123 raise ValueError("Lower current limit out of range.")
125 # check that upper current limits are in range
126 if (
127 self.current_upper_limit < 0
128 or self.current_upper_limit < self.current_lower_limit
129 or self.current_upper_limit > self._CURRENT_UPPER_LIMIT
130 ):
131 raise ValueError("Upper current limit out of range.")
132 if self.wait_sec_system_lock <= 0:
133 raise ValueError(
134 "Wait time for system lock must be a positive value (in seconds)."
135 )
136 if self.wait_sec_settings_effect <= 0:
137 raise ValueError(
138 "Wait time for settings effect must be a positive value (in seconds)."
139 )
140 if self.wait_sec_initialisation <= 0:
141 raise ValueError(
142 "Wait time after initialisation must be a positive value (in seconds)."
143 )
146class PSI9000(VisaDevice):
147 """
148 Elektro Automatik PSI 9000 power supply.
149 """
151 # unlock the device only if measured voltage and current are below these values
152 # user defined, not a technical requirement of the device
153 SHUTDOWN_VOLTAGE_LIMIT = 0.1
154 SHUTDOWN_CURRENT_LIMIT = 0.1
156 # Master slave nominal voltage and current, if 4 devices are in parallel
157 MS_NOMINAL_VOLTAGE = 80
158 MS_NOMINAL_CURRENT = 2040
160 def __init__(
161 self,
162 com: Union[PSI9000VisaCommunication, PSI9000VisaCommunicationConfig, dict],
163 dev_config: Union[PSI9000Config, dict, None] = None,
164 ):
165 super().__init__(com, dev_config)
167 @staticmethod
168 def config_cls():
169 return PSI9000Config
171 def start(self) -> None:
172 """
173 Start this device.
174 """
176 logging.info("Starting device " + str(self))
177 super().start()
179 def stop(self) -> None:
180 """
181 Stop this device. Turns off output and lock, if enabled.
182 """
184 logging.info("Stopping power supply.")
186 current_lock = self.get_system_lock()
188 if current_lock and self.get_output():
189 # locked and output on
190 self.set_voltage_current(0, 0)
191 time.sleep(self.config.wait_sec_settings_effect)
192 self.set_output(False)
193 time.sleep(self.config.wait_sec_settings_effect)
195 if current_lock:
196 # locked
197 self.set_system_lock(False)
199 super().stop()
201 @staticmethod
202 def default_com_cls():
203 return PSI9000VisaCommunication
205 def set_system_lock(self, lock: bool) -> None:
206 """
207 Lock / unlock the device, after locking the control is limited to this class
208 unlocking only possible when voltage and current are below the defined limits
210 :param lock: True: locking, False: unlocking
211 """
213 current_system_lock = self.get_system_lock()
215 if lock is current_system_lock:
216 logging.info(f"Lock already at desired state: {lock}")
217 return
219 if lock:
220 # we want to lock the system
221 self.com.write("SYSTem:LOCK ON")
222 time.sleep(self.config.wait_sec_system_lock)
223 new_system_lock = self.get_system_lock()
225 if not new_system_lock:
226 # locking was unsuccessful
227 raise PSI9000Error("Locking system unsuccessful.")
229 logging.info("Power supply is now locked and ready for access.")
230 return
232 if not lock:
233 # we want to unlock the system
234 voltage, current = self.measure_voltage_current()
236 if (
237 voltage > self.SHUTDOWN_VOLTAGE_LIMIT
238 or current > self.SHUTDOWN_CURRENT_LIMIT
239 ):
240 err_msg = (
241 f"Output voltage and current should be zero: "
242 f"V = {voltage} V, I = {current} A"
243 )
244 logging.error(err_msg)
245 raise PSI9000Error(err_msg)
247 self.com.write("SYSTem:LOCK OFF")
248 time.sleep(self.config.wait_sec_system_lock)
249 new_system_lock = self.get_system_lock()
251 if new_system_lock:
252 # locking was unsuccessful
253 raise PSI9000Error("Unlocking system was unsuccessful.")
255 logging.info("Power supply is now unlocked and ready for shutdown.")
256 return
258 def get_system_lock(self) -> bool:
259 """
260 Get the current lock state of the system. The lock state is true,
261 if the remote control is active and false, if not.
263 :return: the current lock state of the device
264 """
266 lock_string = self.com.query("SYSTem:LOCK:OWNer?")
267 if lock_string == "REMOTE":
268 return True
269 elif lock_string == "NONE" or lock_string == "LOCAL":
270 return False
271 else:
272 raise PSI9000Error(
273 f"Illegal answer to SYSTem:LOCK:OWNer? received: {lock_string}"
274 )
276 def set_output(self, target_onstate: bool) -> None:
277 """
278 Enables / disables the DC output.
280 :param target_onstate: enable or disable the output power
281 :raises PSI9000Error: if operation was not successful
282 """
284 if target_onstate:
285 self.com.write("OUTPut ON")
286 elif not target_onstate:
287 self.com.write("OUTPut OFF")
289 output_state = self.get_output()
291 if target_onstate and output_state:
292 logging.info("Output successfully switched on.")
293 elif target_onstate and not output_state:
294 logging.error("Output was not switched on.")
295 raise PSI9000Error("Output was not switched on.")
296 elif not target_onstate and not output_state:
297 logging.info("Output successfully switched off.")
298 elif not target_onstate and output_state:
299 logging.error("Output was not switched off.")
300 raise PSI9000Error("Output was not switched off.")
302 def get_output(self) -> bool:
303 """
304 Reads the current state of the DC output of the source. Returns True,
305 if it is enabled, false otherwise.
307 :return: the state of the DC output
308 """
310 on_off_string = self.com.query("OUTPut?")
311 if on_off_string == "ON":
312 return True
313 elif on_off_string == "OFF":
314 return False
315 else:
316 raise PSI9000Error(f"Query of OUTPut? is not ON or OFF: {on_off_string}")
318 def measure_voltage_current(self) -> Tuple[float, float]:
319 """
320 Measure the DC output voltage and current
322 :return: Umeas in V, Imeas in A
323 """
325 list_ret = self.com.query("MEASure:VOLTage?", "MEASure:CURRent?")
326 assert len(list_ret) == 2
327 ret = self._remove_units(list_ret)
328 return ret[0], ret[1]
330 def set_voltage_current(self, volt: float, current: float) -> None:
331 """
332 Set voltage and current setpoints.
334 After setting voltage and current, a check is performed if writing was
335 successful.
337 :param volt: is the setpoint voltage: 0..81.6 V (1.02 * 0-80 V)
338 (absolute max, can be smaller if limits are set)
339 :param current: is the setpoint current: 0..2080.8 A (1.02 * 0 - 2040 A)
340 (absolute max, can be smaller if limits are set)
341 :raises PSI9000Error: if the desired setpoint is out of limits
342 """
344 self.com.write(f"SOURce:VOLTage {volt:f}", f"SOURce:CURRent {current:f}")
346 read_voltage, read_current = self.get_voltage_current_setpoint()
348 if read_voltage == volt and read_current == current:
349 logging.info(
350 f"Setting voltage to {volt:.2f} V and current to {current:.2f} A was "
351 f"successful."
352 )
354 else:
355 v_lower, i_lower = self.get_ui_lower_limits()
356 v_upper, i_upper, p_upper = self.get_uip_upper_limits()
358 err_msg = (
359 f"Setting U = {volt:f} V and I = {current:f} A was not successful: "
360 f"voltage has to be between {v_lower} V and {v_upper} V, "
361 f"current between {i_lower} A and {i_upper} A. "
362 f"Actual settings are: U = {read_voltage} V, I = {read_current} A"
363 )
364 logging.error(err_msg)
365 raise PSI9000Error(err_msg)
367 def get_voltage_current_setpoint(self) -> Tuple[float, float]:
368 """
369 Get the voltage and current setpoint of the current source.
371 :return: Uset in V, Iset in A
372 """
374 list_ret = self.com.query("SOURce:VOLTage?", "SOURce:CURRent?")
375 assert len(list_ret) == 2
376 ret = self._remove_units(list_ret)
377 return ret[0], ret[1]
379 def set_upper_limits(
380 self,
381 voltage_limit: float = None,
382 current_limit: float = None,
383 power_limit: float = None,
384 ) -> None:
385 """
386 Set the upper limits for voltage, current and power.
387 After writing the values a check is performed if the values are set.
388 If a parameter is left blank, the maximum configurable limit is set.
390 :param voltage_limit: is the voltage limit in V
391 :param current_limit: is the current limit in A
392 :param power_limit: is the power limit in W
393 :raises PSI9000Error: if limits are out of range
394 """
396 voltage_limit = (
397 voltage_limit
398 if voltage_limit is not None
399 else self.config.voltage_upper_limit
400 )
401 current_limit = (
402 current_limit
403 if current_limit is not None
404 else self.config.current_upper_limit
405 )
406 power_limit = (
407 power_limit if power_limit is not None else self.config.power_limit
408 )
410 v_lower, i_lower = self.get_ui_lower_limits()
412 # Creates a new configclass object just to check the limits; will raise
413 # ValueError if not within limits (see PSI9000Config.clean_values)
414 dataclasses.replace(
415 self.config,
416 voltage_lower_limit=v_lower,
417 current_lower_limit=i_lower,
418 voltage_upper_limit=voltage_limit,
419 current_upper_limit=current_limit,
420 power_limit=power_limit,
421 )
423 self.com.write(
424 f"SOURce:VOLTage:LIMit:HIGH {voltage_limit}",
425 f"SOURce:CURRent:LIMit:HIGH {current_limit}",
426 f"SOURce:POWer:LIMit:HIGH {power_limit}",
427 )
429 # wait until settings are made
430 time.sleep(self.config.wait_sec_settings_effect)
432 v_higher, i_higher, p_higher = self.get_uip_upper_limits()
434 if (
435 v_higher == voltage_limit
436 and i_higher == current_limit
437 and p_higher == power_limit
438 ):
439 logging.info(
440 f"New upper voltage, current, power limits set: Umax = {voltage_limit} "
441 f"V, Imax = {current_limit} A, Pmax = {power_limit} W"
442 )
444 else:
445 raise PSI9000Error("Setting upper limits was not successful.")
447 def set_lower_limits(
448 self, voltage_limit: float = None, current_limit: float = None
449 ) -> None:
450 """
451 Set the lower limits for voltage and current.
452 After writing the values a check is performed if the values are set correctly.
454 :param voltage_limit: is the lower voltage limit in V
455 :param current_limit: is the lower current limit in A
456 :raises PSI9000Error: if the limits are out of range
457 """
459 voltage_limit = (
460 voltage_limit
461 if voltage_limit is not None
462 else self.config.voltage_lower_limit
463 )
464 current_limit = (
465 current_limit
466 if current_limit is not None
467 else self.config.current_lower_limit
468 )
470 v_upper, i_upper, p_upper = self.get_uip_upper_limits()
472 # Creates a new configclass object just to check the limits; will raise
473 # ValueError if not within limits (see PSI9000Config.clean_values)
474 dataclasses.replace(
475 self.config,
476 voltage_upper_limit=v_upper,
477 current_upper_limit=i_upper,
478 power_limit=p_upper,
479 voltage_lower_limit=voltage_limit,
480 current_lower_limit=current_limit,
481 )
483 self.com.write(
484 f"SOURce:VOLTage:LIMit:LOW {voltage_limit}",
485 f"SOURce:CURRent:LIMit:LOW {current_limit}",
486 )
488 v_lower, i_lower = self.get_ui_lower_limits()
490 if v_lower == voltage_limit and i_lower == current_limit:
491 logging.info(
492 f"New lower voltage and current limits set: Umin = {voltage_limit} V, "
493 f"Imin = {current_limit} A"
494 )
496 else:
497 raise PSI9000Error("Setting lower limits was unsuccessful.")
499 def get_ui_lower_limits(self) -> Tuple[float, float]:
500 """
501 Get the lower voltage and current limits. A lower power limit does not exist.
503 :return: Umin in V, Imin in A
504 """
506 list_ret = self.com.query(
507 "SOURce:VOLTage:LIMit:LOW?", "SOURce:CURRent:LIMit:LOW?"
508 )
509 assert len(list_ret) == 2
510 ret = self._remove_units(list_ret)
511 return ret[0], ret[1]
513 def get_uip_upper_limits(self) -> Tuple[float, float, float]:
514 """
515 Get the upper voltage, current and power limits.
517 :return: Umax in V, Imax in A, Pmax in W
518 """
520 list_ret = self.com.query(
521 "SOURce:VOLTage:LIMit:HIGH?",
522 "SOURce:CURRent:LIMit:HIGH?",
523 "SOURce:POWer:LIMit:HIGH?",
524 )
525 assert len(list_ret) == 3
526 ret = self._remove_units(list_ret)
527 return ret[0], ret[1], ret[2]
529 def check_master_slave_config(self) -> None:
530 """
531 Checks if the master / slave configuration and initializes if successful
533 :raises PSI9000Error: if master-slave configuration failed
534 """
536 # verify that MS is enabled
537 if self.com.query("SYSTem:MS:ENABle?") != "ON":
538 logging.error("Master-slave-mode not enabled.")
539 raise PSI9000Error("Master-slave-mode not enabled.")
541 # verify that this device ist MASTER
542 if self.com.query("SYSTem:MS:LINK?") != "MASTER":
543 logging.error("Device is not Master.")
544 raise PSI9000Error("Device is not Master.")
546 # begin initialization
547 self.com.write("SYSTem:MS:INITialisation")
548 time.sleep(self.config.wait_sec_initialisation)
550 # check for correct init
551 if self.com.query("SYSTem:MS:CONDition?") != "INIT":
552 logging.error("Master-slave initialisation failed.")
553 raise PSI9000Error("Master-slave initialisation failed.")
555 # read resulting master-slave nominal voltage and current
556 ms_voltage, ms_current = self._remove_units(
557 self.com.query("SYSTem:MS:NOMinal:VOLTage?", "SYSTem:MS:NOMinal:CURRent?")
558 )
560 # check for correct total voltage and current
561 if (
562 ms_current != self.MS_NOMINAL_CURRENT
563 or ms_voltage != self.MS_NOMINAL_VOLTAGE
564 ):
565 err_msg = (
566 f"Nominal voltage and current should be {self.MS_NOMINAL_VOLTAGE} V, "
567 f"{self.MS_NOMINAL_CURRENT} A; but are {ms_voltage} V, {ms_current} A."
568 )
569 logging.error(err_msg)
570 raise PSI9000Error(err_msg)
572 # here the initialization is successful
573 logging.info("Initialization of Master/Slave successful")
575 @staticmethod
576 def _remove_units(list_with_strings: Iterable[str]) -> Tuple[float, ...]:
577 """
578 Removes the last two characters of each string in the list and
579 convert it to float (is only working for units with one character e.g. V, A, W)
581 :param list_with_strings: list with return strings containing value and unit
582 :return: list of floats without units
583 """
585 return tuple(float(value[:-2]) for value in list_with_strings)