mmcb-rs232-avt 1.0.14__py3-none-any.whl → 1.0.18__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
mmcb_rs232/psuwatch.py ADDED
@@ -0,0 +1,540 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Monitors the voltage and current of power supplies connected by FTDI USB to
4
+ RS232 adaptors. Results are written to the screen and to a log file.
5
+
6
+ All power supplies supported by detect.py are usable by this script.
7
+ """
8
+
9
+ import argparse
10
+ import collections
11
+ import concurrent.futures as cf
12
+ import functools
13
+ import logging
14
+ import threading
15
+ import time
16
+
17
+ import serial
18
+
19
+ from mmcb import common
20
+ from mmcb import lexicon
21
+
22
+
23
+ ##############################################################################
24
+ # utilities
25
+ ##############################################################################
26
+
27
+ def format_reading(settings, tcolours, psu_info):
28
+ """
29
+ format PSU serial number, voltage and current read from PSU
30
+ adding colour to current values that are over given thresholds
31
+
32
+ --------------------------------------------------------------------------
33
+ args
34
+ settings : dictionary
35
+ contains core information about the test environment
36
+ tcolours : class
37
+ contains ANSI colour escape sequences
38
+ psu_info : tuple (string, string, [(float, float), ...])
39
+ (psu_type, psu_serial_number, [(voltage, current), ])
40
+ e.g. ('hvpsu', '4343654', [(0.0, 5.143E-12), ...])
41
+ may contain readings for more than one power supply output
42
+ --------------------------------------------------------------------------
43
+ returns
44
+ retstr : string
45
+ --------------------------------------------------------------------------
46
+ """
47
+ dev, readings = psu_info
48
+
49
+ if dev.category == 'hvpsu':
50
+ warning = settings['hv_warning']
51
+ critical = settings['hv_critical']
52
+ else:
53
+ warning = settings['lv_warning']
54
+ critical = settings['lv_critical']
55
+
56
+ colour_start = colour_end = output = ''
57
+ ident = dev.ident.replace(' ', '_')
58
+
59
+ for voltage, current in readings:
60
+ if voltage is not None and current is not None:
61
+ if warning is not None:
62
+ if abs(current) > warning:
63
+ colour_start = tcolours.BG_YELLOW
64
+ colour_end = tcolours.ENDC
65
+ if critical is not None:
66
+ if abs(current) > critical:
67
+ colour_start = tcolours.BG_RED + tcolours.FG_WHITE
68
+ colour_end = tcolours.ENDC
69
+ output += (f' '
70
+ f'{common.si_prefix(voltage, compact=False).rjust(9)}V '
71
+ f'{colour_start}'
72
+ f'{common.si_prefix(current, compact=False).rjust(9)}A'
73
+ f'{colour_end}')
74
+ else:
75
+ colour_start = tcolours.BG_RED + tcolours.FG_WHITE
76
+ colour_end = tcolours.ENDC
77
+ output += f' {colour_start}None{colour_end}'
78
+
79
+ return f'{ident}{output}'
80
+
81
+
82
+ ##############################################################################
83
+ # command line option handler
84
+ ##############################################################################
85
+
86
+ def check_duration(val):
87
+ """
88
+ time between samples in seconds
89
+
90
+ --------------------------------------------------------------------------
91
+ args
92
+ val : int
93
+ the runtime of read_psu_vi is typically around 1 second
94
+ so sensible values range from 2 to perhaps 60 seconds
95
+ --------------------------------------------------------------------------
96
+ returns
97
+ val : int
98
+ --------------------------------------------------------------------------
99
+ """
100
+ val = int(val)
101
+ if not 0 <= val <= 60:
102
+ raise argparse.ArgumentTypeError(
103
+ f'{val}: '
104
+ 'should be a positive numeric value between 0 and 60 (seconds)')
105
+ return val
106
+
107
+
108
+ def check_arguments(settings):
109
+ """
110
+ handle command line options
111
+
112
+ --------------------------------------------------------------------------
113
+ args
114
+ settings : dictionary
115
+ contains core information about the test environment
116
+ --------------------------------------------------------------------------
117
+ returns : none
118
+ --------------------------------------------------------------------------
119
+ """
120
+ parser = argparse.ArgumentParser(
121
+ description='Monitors the voltage and current of all Keithley 2410,\
122
+ 2614b; ISEG SHQ 222M, 224M; Agilent e3647a, e3634a; and Hameg\
123
+ (Rohde & Schwarz) HMP4040 power supplies connected by\
124
+ FTDI USB to RS232 adaptors.')
125
+ parser.add_argument(
126
+ '--alias', nargs=1, metavar='filename',
127
+ help='By default, serial numbers read from the power supplies over\
128
+ RS232 are used to identify them in the logs. This option allows a CSV\
129
+ file containing aliases to be read in, where each line consists of a\
130
+ serial number then a textual description, separated by a comma\
131
+ e.g. 4120021,LVSC03 red. These aliases are then substituted for the\
132
+ serial numbers in the logs and in filenames as appropriate. Comments\
133
+ starting with a hash "#" are allowed.',
134
+ default=None)
135
+ parser.add_argument(
136
+ '-t', '--time', nargs=1, metavar='seconds',
137
+ help='Time between samples in seconds. if this option is not used,\
138
+ the script will default to sampling as fast as possible,\
139
+ around one sample per second.',
140
+ type=check_duration, default=[1])
141
+ parser.add_argument(
142
+ '--whv', nargs=2, metavar=('threshold', 'threshold'),
143
+ help='For high voltage PSUs, the threshold values above which current\
144
+ readings will be displayed with coloured warning highlights.\
145
+ Values above the lower threshold will be shown in yellow, values\
146
+ above the upper threshold will be shown in red. Values can be\
147
+ specified with either scientific (10e-12) or engineering notation\
148
+ (10p)',
149
+ type=common.check_current, default=(None, None))
150
+ parser.add_argument(
151
+ '--wlv', nargs=2, metavar=('threshold', 'threshold'),
152
+ help='For low voltage PSUs, the threshold values above which current\
153
+ readings will be displayed with coloured warning highlights.\
154
+ Values above the lower threshold will be shown in yellow, values\
155
+ above the upper threshold will be shown in red. Values can be\
156
+ specified with either scientific (10e-12) or engineering notation\
157
+ (10p)',
158
+ type=common.check_current, default=(None, None))
159
+
160
+ args = parser.parse_args()
161
+
162
+ settings['time'] = args.time[0]
163
+
164
+ th1, th2 = args.whv
165
+ if th1 is not None and th2 is not None:
166
+ if th1 > th2:
167
+ th1, th2 = th2, th1
168
+ settings['hv_warning'] = th1
169
+ settings['hv_critical'] = th2
170
+
171
+ th1, th2 = args.wlv
172
+ if th1 is not None and th2 is not None:
173
+ if th1 > th2:
174
+ th1, th2 = th2, th1
175
+ settings['lv_warning'] = th1
176
+ settings['lv_critical'] = th2
177
+
178
+ if args.alias:
179
+ filename = args.alias[0]
180
+ common.read_aliases(settings, filename)
181
+
182
+
183
+ ##############################################################################
184
+ # get data from devices on serial ports
185
+ ##############################################################################
186
+
187
+ def read_psu_vi(dev, pipeline):
188
+ """
189
+ read voltage and current from PSU
190
+
191
+ --------------------------------------------------------------------------
192
+ args
193
+ dev : instance of class Channel
194
+ contains details of a device and its serial port
195
+ pipeline : instance of class Production
196
+ contains all the queues through which the production pipeline
197
+ processes communicate
198
+ --------------------------------------------------------------------------
199
+ returns
200
+ dev : instance of class Channel
201
+ contains details of a device and its serial port
202
+ readings : list
203
+ [(float, float)]
204
+ --------------------------------------------------------------------------
205
+ """
206
+ with serial.Serial(port=dev.port) as ser:
207
+ ser.apply_settings(dev.config)
208
+ if dev.manufacturer in {'keithley', 'iseg'}:
209
+ volt, curr = common.read_psu_measured_vi(pipeline, ser, dev)
210
+ elif dev.manufacturer in {'agilent', 'hameg', 'hewlett-packard'}:
211
+ if dev.model == 'e3634a':
212
+ command_string = lexicon.power(dev.model, 'set remote')
213
+ common.send_command(pipeline, ser, dev, command_string)
214
+
215
+ command_string = lexicon.power(dev.model, 'read voltage', channel=dev.channel)
216
+ measured_voltage = common.atomic_send_command_read_response(pipeline, ser,
217
+ dev, command_string)
218
+
219
+ command_string = lexicon.power(dev.model, 'read current', channel=dev.channel)
220
+ measured_current = common.atomic_send_command_read_response(pipeline, ser,
221
+ dev, command_string)
222
+ try:
223
+ volt = float(measured_voltage)
224
+ curr = float(measured_current)
225
+ except ValueError:
226
+ volt = curr = None
227
+
228
+ # ignore readings if output is off
229
+ if dev.manufacturer == 'hameg':
230
+ command_string = lexicon.power(dev.model, 'check output', channel=dev.channel)
231
+ output_status = common.atomic_send_command_read_response(pipeline, ser,
232
+ dev, command_string)
233
+ if output_status == '0':
234
+ volt = curr = None
235
+
236
+ else:
237
+ logging.error('unknown power supply')
238
+ volt = curr = None
239
+
240
+ readings = [(volt, curr)]
241
+
242
+ return dev, readings
243
+
244
+
245
+ ##############################################################################
246
+ # moved from common
247
+ ##############################################################################
248
+
249
+ def initial_power_supply_check(settings, pipeline, psus, channels, psuset=False):
250
+ """
251
+ Establishes RS232 communications with power supplies (as required).
252
+ Checks status of channel outputs and interlocks (inhibits).
253
+
254
+ --------------------------------------------------------------------------
255
+ args
256
+ settings : dictionary
257
+ contains core information about the test environment
258
+ pipeline : instance of class Production
259
+ contains all the queues through which the production pipeline
260
+ processes communicate
261
+ psus : dict
262
+ {port: ({port_config}, device_type, device_serial_number), ...}
263
+ contents of the cache filtered by hvpsu category
264
+ channels : list
265
+ contains instances of class Channel, one for each
266
+ power supply channel
267
+ psuset : bool
268
+ selects the error message depending on the caller
269
+ --------------------------------------------------------------------------
270
+ returns
271
+ channels : list
272
+ no explicit return, mutable type amended in place
273
+ --------------------------------------------------------------------------
274
+ """
275
+ port_used = collections.defaultdict(int)
276
+ outstat = []
277
+ intstat = []
278
+ polstat = []
279
+
280
+ if settings['debug'] is None:
281
+ common.check_ports_accessible(psus, channels)
282
+
283
+ for dev in channels.copy():
284
+ # message = f'enabled: {common._lookup_decorative(settings["alias"], dev)}'
285
+ # common.log_with_colour(logging.INFO, message)
286
+
287
+ with serial.Serial(port=dev.port) as ser:
288
+ ser.apply_settings(dev.config)
289
+
290
+ # try to ensure consistent state
291
+ # clear FTDI output buffer state before sending
292
+ ser.reset_output_buffer()
293
+ # clear PSU state
294
+ if dev.model == '2614b':
295
+ command_string = lexicon.power(dev.model, 'terminator only',
296
+ channel=dev.channel)
297
+ common.send_command(pipeline, ser, dev, command_string)
298
+ # clear FTDI input buffer state
299
+ ser.reset_input_buffer()
300
+ # arbitrary settle time before proceeding
301
+ time.sleep(0.5)
302
+
303
+ # ensure serial port communication with PSU
304
+ # this is for ISEG SHQ only, on a per-PSU basis
305
+ if not port_used[dev.port]:
306
+ common.synchronise_psu(ser, pipeline, dev)
307
+
308
+ # check power supply output
309
+ # but don't do this if called from psuset.py, since the user
310
+ # of that script may be issuing a command to turn a
311
+ # power supply channel output on
312
+ if not psuset:
313
+ tmp_stat = report_output_status(ser, pipeline, dev)
314
+ if tmp_stat:
315
+ channels.remove(dev)
316
+ # outstat.append(report_output_status(ser, pipeline, dev))
317
+
318
+ # check interlock
319
+ # this is per-PSU on Keithley, per-channel on ISEG
320
+ if ((not port_used[dev.port] or dev.manufacturer == 'iseg')
321
+ and dev.manufacturer not in {'agilent', 'hameg'}):
322
+ intstat.append(common._report_interlock_status(ser, pipeline, dev))
323
+
324
+ if dev.manufacturer == 'iseg':
325
+ polstat.append(common._report_polarity_status(settings, ser, pipeline, dev, psuset))
326
+
327
+ ser.reset_input_buffer()
328
+ ser.reset_output_buffer()
329
+
330
+ port_used[dev.port] += 1
331
+
332
+ if any(outstat):
333
+ message = 'all power supply outputs must be on to proceed'
334
+ common.log_with_colour(logging.ERROR, message)
335
+ channels.clear()
336
+
337
+ if any(intstat):
338
+ message = 'all power supply interlocks must be inactive to proceed'
339
+ common.log_with_colour(logging.ERROR, message)
340
+ channels.clear()
341
+
342
+ if any(polstat):
343
+ if psuset:
344
+ message = 'set voltage should agree with polarity switch to proceed'
345
+ common.log_with_colour(logging.ERROR, message)
346
+ else:
347
+ message = 'all polarity switches must agree with --forwardbias to proceed'
348
+ common.log_with_colour(logging.ERROR, message)
349
+
350
+ channels.clear()
351
+ else:
352
+ message = '--debug: any connected power supplies will be ignored'
353
+ log_with_colour(logging.WARNING, message)
354
+ message = '--debug: IV data will generated internally'
355
+ log_with_colour(logging.WARNING, message)
356
+
357
+
358
+ def report_output_status(ser, pipeline, dev):
359
+ """
360
+ Check that the output for the given power supply channel is on.
361
+
362
+ While the outputs may be switched on/off over RS232 for all the supported
363
+ power supplies except ISEG SHQ, this is generally used as a check to make
364
+ sure the user has configured the test environment correctly.
365
+
366
+ Values returned in variable output:
367
+
368
+ +------------------+-----------+---------------+---------------+
369
+ | dev.manufacturer | dev.model | output OFF | output ON |
370
+ +------------------+-----------+---------------+---------------+
371
+ | 'agilent' | 'e3634a' | '0' | '1' |
372
+ | 'agilent' | 'e3647a' | '0' | '1' |
373
+ | 'hameg' | 'hmp4040' | '0' | '1' |
374
+ | 'iseg' | 'shq' | '=OFF' | '=ON' |
375
+ | 'keithley' | '2410' | '0' | '1' |
376
+ | 'keithley' | '2614b' | '0.00000e+00' | '1.00000e+00' |
377
+ +------------------+-----------+---------------+---------------+
378
+
379
+ --------------------------------------------------------------------------
380
+ args
381
+ ser : serial.Serial
382
+ reference for serial port
383
+ pipeline : instance of class Production
384
+ contains all the queues through which the production pipeline
385
+ processes communicate
386
+ dev : instance of class Channel
387
+ contains details of a device and its serial port
388
+ --------------------------------------------------------------------------
389
+ returns
390
+ fail : bool
391
+ True if the output was found to be off, False otherwise
392
+ --------------------------------------------------------------------------
393
+ """
394
+ fail = False
395
+
396
+ command_string = lexicon.power(dev.model, 'check output', channel=dev.channel)
397
+ output = common.atomic_send_command_read_response(pipeline, ser, dev, command_string)
398
+
399
+ if dev.manufacturer == 'iseg':
400
+ if '=ON' not in output:
401
+ # log_with_colour(logging.WARNING, f'{dev.ident}, output off')
402
+ fail = True
403
+
404
+ elif dev.manufacturer in {'agilent', 'hameg', 'keithley'}:
405
+ try:
406
+ outval = int(float(output))
407
+ except ValueError:
408
+ message = f'{dev.ident}, problem checking output'
409
+ log_with_colour(logging.ERROR, message)
410
+ fail = True
411
+ else:
412
+ if outval in {0, 1}:
413
+ if outval == 0:
414
+ # message = f'{dev.ident}, output off'
415
+ # log_with_colour(logging.WARNING, message)
416
+ fail = True
417
+ else:
418
+ message = f'{dev.ident}, problem checking output'
419
+ log_with_colour(logging.ERROR, message)
420
+ fail = True
421
+
422
+ return fail
423
+
424
+
425
+ ##############################################################################
426
+ # main
427
+ ##############################################################################
428
+
429
+ def main():
430
+ """
431
+ monitor the voltage and current on power supplies contained in the cache file
432
+ created by detect.py
433
+ """
434
+ # date and time to the nearest second when the script was started
435
+ session = common.timestamp_to_utc(time.time())
436
+
437
+ # initialise
438
+ settings = {
439
+ 'alias': None,
440
+ 'debug': None,
441
+ 'time': None,
442
+ 'hv_warning': None,
443
+ 'hv_critical': None,
444
+ 'lv_warning': None,
445
+ 'lv_critical': None}
446
+
447
+ ##########################################################################
448
+ # read command line arguments
449
+ ##########################################################################
450
+
451
+ check_arguments(settings)
452
+
453
+ ##########################################################################
454
+ # enable logging to file
455
+ ##########################################################################
456
+
457
+ log = f'{session}_psuwatch.log'
458
+ logging.basicConfig(
459
+ filename=log,
460
+ level=logging.INFO,
461
+ format='%(asctime)s : %(levelname)s : %(message)s')
462
+
463
+ # enable logging to screen
464
+ console = logging.StreamHandler()
465
+ console.setLevel(logging.INFO)
466
+ formatter = logging.Formatter('%(asctime)s : %(levelname)s : %(message)s')
467
+
468
+ console.setFormatter(formatter)
469
+ logging.getLogger('').addHandler(console)
470
+
471
+ # set logging time to UTC to match session timestamp
472
+ logging.Formatter.converter = time.gmtime
473
+
474
+ ##########################################################################
475
+ # read cache
476
+ ##########################################################################
477
+
478
+ # record the details of the power supplies being watched in the log
479
+ psus = common.cache_read(['hvpsu', 'lvpsu'], uselog=True)
480
+ channels = common.ports_to_channels(settings, psus)
481
+
482
+ ##########################################################################
483
+ # set up resources for threads
484
+ ##########################################################################
485
+
486
+ class Production:
487
+ """
488
+ Queues and locks to support threaded operation.
489
+
490
+ RS232 will not tolerate concurrent access. portaccess is used to
491
+ prevent more than one thread trying to write to the same RS232 port at
492
+ the same time for multi-channel power supplies. For simplicity, locks
493
+ are created for all power supplies, even if they only have a single
494
+ channel.
495
+ """
496
+ portaccess = {
497
+ port: threading.Lock()
498
+ for port in {channel.port for channel in channels}
499
+ }
500
+
501
+ pipeline = Production()
502
+
503
+ ##########################################################################
504
+ # Check status of outputs and interlock (inhibit) on all power supplies
505
+ ##########################################################################
506
+
507
+ initial_power_supply_check(settings, pipeline, psus, channels)
508
+
509
+ ##########################################################################
510
+ # monitor power supply channels
511
+ ##########################################################################
512
+
513
+ _rpvi_pf = functools.partial(read_psu_vi, pipeline=pipeline)
514
+
515
+ if channels:
516
+ sample_period_seconds = settings['time']
517
+ timestamp_mono = time.monotonic()
518
+
519
+ while True:
520
+ data = []
521
+ with cf.ThreadPoolExecutor() as executor:
522
+ psu_reading = (executor.submit(_rpvi_pf, channel) for channel in channels)
523
+ for future in cf.as_completed(psu_reading):
524
+ data.append(future.result())
525
+
526
+ # this will sort by serial number then channel order
527
+ data.sort()
528
+
529
+ # determine text and colour to print for each reading
530
+ line = (format_reading(settings, common.ANSIColours, psu_info) for psu_info in data)
531
+ logging.info(' | '.join(line))
532
+
533
+ timestamp_mono = common.rate_limit(
534
+ timestamp_mono, sample_period_seconds
535
+ )
536
+
537
+
538
+ ##############################################################################
539
+ if __name__ == '__main__':
540
+ main()