Hide keyboard shortcuts

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. 

5 

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""" 

9 

10import dataclasses 

11import logging 

12import time 

13from typing import ClassVar, Iterable, Tuple, Union 

14 

15from .visa import VisaDevice, VisaDeviceConfig 

16from ..comm import ( 

17 VisaCommunication, 

18 VisaCommunicationConfig, 

19) 

20from ..configuration import configdataclass 

21from ..utils.typing import Number 

22 

23 

24class PSI9000Error(Exception): 

25 """ 

26 Base error class regarding problems with the PSI 9000 supply. 

27 """ 

28 

29 pass 

30 

31 

32@configdataclass 

33class PSI9000VisaCommunicationConfig(VisaCommunicationConfig): 

34 """ 

35 Visa communication protocol config dataclass with specification for the PSI 9000 

36 power supply. 

37 """ 

38 

39 interface_type: Union[ 

40 str, VisaCommunicationConfig.InterfaceType 

41 ] = VisaCommunicationConfig.InterfaceType.TCPIP_SOCKET # type: ignore 

42 

43 

44class PSI9000VisaCommunication(VisaCommunication): 

45 """ 

46 Communication protocol used with the PSI 9000 power supply. 

47 """ 

48 

49 @staticmethod 

50 def config_cls(): 

51 return PSI9000VisaCommunicationConfig 

52 

53 

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. 

59 

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 """ 

64 

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 

69 

70 #: Lower voltage limit in V, depending on the experimental setup. 

71 voltage_lower_limit: Number = 0.0 

72 

73 #: Upper voltage limit in V, depending on the experimental setup. 

74 voltage_upper_limit: Number = 10.0 

75 

76 #: Lower current limit in A, depending on the experimental setup. 

77 current_lower_limit: Number = 0.0 

78 

79 #: Upper current limit in A, depending on the experimental setup. 

80 current_upper_limit: Number = 2040.0 

81 

82 wait_sec_system_lock: Number = 0.5 

83 wait_sec_settings_effect: Number = 1 

84 wait_sec_initialisation: Number = 2 

85 

86 # limit with 63 A grid connection, absolute limit, never ever change this value 

87 _POWER_GRID_LIMIT: ClassVar = 43500 

88 

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) 

92 

93 def clean_values(self) -> None: 

94 

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 ) 

100 

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.") 

108 

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.") 

116 

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.") 

124 

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 ) 

144 

145 

146class PSI9000(VisaDevice): 

147 """ 

148 Elektro Automatik PSI 9000 power supply. 

149 """ 

150 

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 

155 

156 # Master slave nominal voltage and current, if 4 devices are in parallel 

157 MS_NOMINAL_VOLTAGE = 80 

158 MS_NOMINAL_CURRENT = 2040 

159 

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) 

166 

167 @staticmethod 

168 def config_cls(): 

169 return PSI9000Config 

170 

171 def start(self) -> None: 

172 """ 

173 Start this device. 

174 """ 

175 

176 logging.info("Starting device " + str(self)) 

177 super().start() 

178 

179 def stop(self) -> None: 

180 """ 

181 Stop this device. Turns off output and lock, if enabled. 

182 """ 

183 

184 logging.info("Stopping power supply.") 

185 

186 current_lock = self.get_system_lock() 

187 

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) 

194 

195 if current_lock: 

196 # locked 

197 self.set_system_lock(False) 

198 

199 super().stop() 

200 

201 @staticmethod 

202 def default_com_cls(): 

203 return PSI9000VisaCommunication 

204 

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 

209 

210 :param lock: True: locking, False: unlocking 

211 """ 

212 

213 current_system_lock = self.get_system_lock() 

214 

215 if lock is current_system_lock: 

216 logging.info(f"Lock already at desired state: {lock}") 

217 return 

218 

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() 

224 

225 if not new_system_lock: 

226 # locking was unsuccessful 

227 raise PSI9000Error("Locking system unsuccessful.") 

228 

229 logging.info("Power supply is now locked and ready for access.") 

230 return 

231 

232 if not lock: 

233 # we want to unlock the system 

234 voltage, current = self.measure_voltage_current() 

235 

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) 

246 

247 self.com.write("SYSTem:LOCK OFF") 

248 time.sleep(self.config.wait_sec_system_lock) 

249 new_system_lock = self.get_system_lock() 

250 

251 if new_system_lock: 

252 # locking was unsuccessful 

253 raise PSI9000Error("Unlocking system was unsuccessful.") 

254 

255 logging.info("Power supply is now unlocked and ready for shutdown.") 

256 return 

257 

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. 

262 

263 :return: the current lock state of the device 

264 """ 

265 

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 ) 

275 

276 def set_output(self, target_onstate: bool) -> None: 

277 """ 

278 Enables / disables the DC output. 

279 

280 :param target_onstate: enable or disable the output power 

281 :raises PSI9000Error: if operation was not successful 

282 """ 

283 

284 if target_onstate: 

285 self.com.write("OUTPut ON") 

286 elif not target_onstate: 

287 self.com.write("OUTPut OFF") 

288 

289 output_state = self.get_output() 

290 

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.") 

301 

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. 

306 

307 :return: the state of the DC output 

308 """ 

309 

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}") 

317 

318 def measure_voltage_current(self) -> Tuple[float, float]: 

319 """ 

320 Measure the DC output voltage and current 

321 

322 :return: Umeas in V, Imeas in A 

323 """ 

324 

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] 

329 

330 def set_voltage_current(self, volt: float, current: float) -> None: 

331 """ 

332 Set voltage and current setpoints. 

333 

334 After setting voltage and current, a check is performed if writing was 

335 successful. 

336 

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 """ 

343 

344 self.com.write(f"SOURce:VOLTage {volt:f}", f"SOURce:CURRent {current:f}") 

345 

346 read_voltage, read_current = self.get_voltage_current_setpoint() 

347 

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 ) 

353 

354 else: 

355 v_lower, i_lower = self.get_ui_lower_limits() 

356 v_upper, i_upper, p_upper = self.get_uip_upper_limits() 

357 

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) 

366 

367 def get_voltage_current_setpoint(self) -> Tuple[float, float]: 

368 """ 

369 Get the voltage and current setpoint of the current source. 

370 

371 :return: Uset in V, Iset in A 

372 """ 

373 

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] 

378 

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. 

389 

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 """ 

395 

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 ) 

409 

410 v_lower, i_lower = self.get_ui_lower_limits() 

411 

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 ) 

422 

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 ) 

428 

429 # wait until settings are made 

430 time.sleep(self.config.wait_sec_settings_effect) 

431 

432 v_higher, i_higher, p_higher = self.get_uip_upper_limits() 

433 

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 ) 

443 

444 else: 

445 raise PSI9000Error("Setting upper limits was not successful.") 

446 

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. 

453 

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 """ 

458 

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 ) 

469 

470 v_upper, i_upper, p_upper = self.get_uip_upper_limits() 

471 

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 ) 

482 

483 self.com.write( 

484 f"SOURce:VOLTage:LIMit:LOW {voltage_limit}", 

485 f"SOURce:CURRent:LIMit:LOW {current_limit}", 

486 ) 

487 

488 v_lower, i_lower = self.get_ui_lower_limits() 

489 

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 ) 

495 

496 else: 

497 raise PSI9000Error("Setting lower limits was unsuccessful.") 

498 

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. 

502 

503 :return: Umin in V, Imin in A 

504 """ 

505 

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] 

512 

513 def get_uip_upper_limits(self) -> Tuple[float, float, float]: 

514 """ 

515 Get the upper voltage, current and power limits. 

516 

517 :return: Umax in V, Imax in A, Pmax in W 

518 """ 

519 

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] 

528 

529 def check_master_slave_config(self) -> None: 

530 """ 

531 Checks if the master / slave configuration and initializes if successful 

532 

533 :raises PSI9000Error: if master-slave configuration failed 

534 """ 

535 

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.") 

540 

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.") 

545 

546 # begin initialization 

547 self.com.write("SYSTem:MS:INITialisation") 

548 time.sleep(self.config.wait_sec_initialisation) 

549 

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.") 

554 

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 ) 

559 

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) 

571 

572 # here the initialization is successful 

573 logging.info("Initialization of Master/Slave successful") 

574 

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) 

580 

581 :param list_with_strings: list with return strings containing value and unit 

582 :return: list of floats without units 

583 """ 

584 

585 return tuple(float(value[:-2]) for value in list_with_strings)