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/iv.py ADDED
@@ -0,0 +1,2868 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Generates IV and (optional) IT plots using power supplies connected by RS232.
4
+
5
+ Tests will be run concurrently on *ALL* detected PSUs unless
6
+ otherwise specified.
7
+
8
+ Supported power supplies:
9
+
10
+ hvpsu : keithley 2410, 2614b; ISEG SHQ 222M, 224M
11
+
12
+ Safety:
13
+
14
+ To protect devices under test, the script ensures that the rate of change of
15
+ voltage is strictly limited to no more than one 10V step per second.
16
+
17
+ The script sets the current limit on the power supply, and will stop the
18
+ test early if the leakage current exceeds a slightly lower figure.
19
+ """
20
+
21
+ import argparse
22
+ import collections
23
+ import concurrent.futures as cf
24
+ import contextlib
25
+ import csv
26
+ import ctypes
27
+ import datetime
28
+ import functools
29
+ import itertools
30
+ import logging
31
+ import math
32
+ import multiprocessing as mp # Process, Queue
33
+ import os
34
+ import pathlib
35
+ import platform
36
+ import random
37
+ import socket
38
+ import statistics as stat
39
+ import sys
40
+ import threading
41
+ import time
42
+
43
+ import matplotlib
44
+ # agg is used only for writing plots to files, not to the window manager
45
+ # this option is set to avoid problems running the script on remote hosts
46
+ # over ssh, matplotlib.use must be called in this exact position
47
+ matplotlib.use('agg')
48
+ import matplotlib.pyplot as plt
49
+ from matplotlib.ticker import EngFormatter
50
+ import serial
51
+ import zmq
52
+
53
+ from yoctopuce import yocto_api as yapi
54
+ from yoctopuce import yocto_temperature as ytemp
55
+ from yoctopuce import yocto_humidity as yhumi
56
+
57
+ from mmcb import common
58
+ from mmcb import lexicon
59
+ from mmcb import sequence
60
+
61
+
62
+ ##############################################################################
63
+ # command line option handler
64
+ ##############################################################################
65
+
66
+ def check_settling_time(val):
67
+ """
68
+ Check settling time is reasonable.
69
+
70
+ --------------------------------------------------------------------------
71
+ args
72
+ val : float
73
+ allowable percentage deviation from mean
74
+ --------------------------------------------------------------------------
75
+ returns : int
76
+ --------------------------------------------------------------------------
77
+ """
78
+ val = float(val)
79
+ if not 0.25 <= val <= 10:
80
+ raise argparse.ArgumentTypeError(
81
+ f'{val}: settling time should between 0.25 and 10 seconds.')
82
+ return val
83
+
84
+
85
+ def check_minutes(val):
86
+ """
87
+ check basic validity of minutes value
88
+
89
+ --------------------------------------------------------------------------
90
+ args
91
+ val : float
92
+ allowable percentage deviation from mean
93
+ --------------------------------------------------------------------------
94
+ returns : int
95
+ --------------------------------------------------------------------------
96
+ """
97
+ val = int(val)
98
+ if not 0 <= val <= 2880:
99
+ raise argparse.ArgumentTypeError(
100
+ f'{val}: '
101
+ 'hold time in minutes should be less than 2880 (48 hours)')
102
+ return val
103
+
104
+
105
+ def check_percent(val):
106
+ """
107
+ check basic validity of percentage value
108
+
109
+ --------------------------------------------------------------------------
110
+ args
111
+ val : float
112
+ allowable percentage deviation from mean
113
+ --------------------------------------------------------------------------
114
+ returns : float
115
+ --------------------------------------------------------------------------
116
+ """
117
+ val = float(val)
118
+ if val >= 1000:
119
+ raise argparse.ArgumentTypeError(
120
+ f'{val}: '
121
+ 'percentage deviation from the mean should be less than 1000')
122
+ return val
123
+
124
+
125
+ def check_psunum(val):
126
+ """
127
+ check basic validity of number
128
+
129
+ --------------------------------------------------------------------------
130
+ args
131
+ val : int
132
+ number of power supplies to emulate
133
+ --------------------------------------------------------------------------
134
+ returns : int
135
+ --------------------------------------------------------------------------
136
+ """
137
+ val = int(val)
138
+ if val <= 0:
139
+ raise argparse.ArgumentTypeError(
140
+ f'{val}: '
141
+ 'number of emulated power supplies should be 1 or more')
142
+ return val
143
+
144
+
145
+ def check_samples(val):
146
+ """
147
+ check basic validity of number of samples
148
+
149
+ --------------------------------------------------------------------------
150
+ args
151
+ val : int
152
+ number of samples
153
+ --------------------------------------------------------------------------
154
+ returns : int
155
+ --------------------------------------------------------------------------
156
+ """
157
+ val = int(val)
158
+ if not 10 <= val <= 100:
159
+ raise argparse.ArgumentTypeError(
160
+ f'{val}: '
161
+ 'number of samples should be between 10 and 100')
162
+ return val
163
+
164
+
165
+ def check_stepsize(val):
166
+ """
167
+ Expect int step size
168
+
169
+ --------------------------------------------------------------------------
170
+ args
171
+ val : int
172
+ step size in volts
173
+ --------------------------------------------------------------------------
174
+ returns : int
175
+ --------------------------------------------------------------------------
176
+ """
177
+ val = abs(int(val))
178
+ if not 1 <= val <= 10:
179
+ raise argparse.ArgumentTypeError(
180
+ f'{val}: value should be between 1 and 10')
181
+ return val
182
+
183
+
184
+ def check_voltage(val):
185
+ """
186
+ check basic validity of relative movement value
187
+
188
+ --------------------------------------------------------------------------
189
+ args
190
+ val : int
191
+ bias voltage
192
+ --------------------------------------------------------------------------
193
+ returns : int
194
+ --------------------------------------------------------------------------
195
+ """
196
+ val = int(val)
197
+ if not -1100 <= val <= 1100:
198
+ raise argparse.ArgumentTypeError(
199
+ f'{val}: '
200
+ 'voltage should be between -1100V and 1100V')
201
+ return val
202
+
203
+
204
+ def check_arguments(settings):
205
+ """
206
+ handle command line options
207
+
208
+ --------------------------------------------------------------------------
209
+ args
210
+ settings : dictionary
211
+ contains core information about the test environment
212
+ --------------------------------------------------------------------------
213
+ returns
214
+ settings : no explicit return, mutable type amended in place
215
+ --------------------------------------------------------------------------
216
+ """
217
+ parser = argparse.ArgumentParser(
218
+ description='Script to generate IV (and optionally IT) plots using\
219
+ Keithley 2410 and 2614b power supplies over RS232. ISEG SHQ\
220
+ 222M and 224M power supplies may also be used though these are slow\
221
+ to converge to set voltages (and may never converge when the set\
222
+ voltage is in the region -10 <= V <= 10) hence the test may take a\
223
+ long time to complete. If detected, data from YoctoPuce humidity and\
224
+ PT100 temperature sensors will be incorporated into exported data and\
225
+ (appropriate) plots. Tests will be run concurrently on *ALL* detected\
226
+ high-voltage PSUs unless specifically excluded using --alias. Support\
227
+ requests to: Alan Taylor, Physics Dept.,\
228
+ University of Liverpool, avt@hep.ph.liv.ac.uk')
229
+ parser.add_argument(
230
+ 'voltage', nargs='?', metavar='voltage',
231
+ help='value in volts',
232
+ type=check_voltage, default=None)
233
+ parser.add_argument(
234
+ '--stepsize', nargs=1, metavar='stepsize',
235
+ help='Use a constant step size between 1V and 10V, the sign is not\
236
+ important as the script will decide this itself. The default if\
237
+ this option is not specified is to use smaller\
238
+ step sizes when close to zero volts,\
239
+ from 0 to +/-10 with 1V steps,\
240
+ then -10 to +/-50 with 5V steps,\
241
+ then -50 to +/-N with 10V steps.',
242
+ type=check_stepsize, default=None)
243
+ parser.add_argument(
244
+ '--range', nargs=1, metavar='filename',
245
+ help='Rather than using the step size parameters detailed in\
246
+ the --stepsize option above, read a range and step definition from\
247
+ a CSV file. Multiple ranges with different step sizes may be defined.\
248
+ The file format is as follows. Comments starting with # are\
249
+ supported - on any line if a # is found, it and any text to the right\
250
+ are ignored. Blank lines are allowed. Valid lines contain either (1)\
251
+ a single value that defines the default step size in volts that\
252
+ applies over the entire range from 0V to the maximum test voltage\
253
+ (only provide this type of line once, valid value is any integer from\
254
+ 1 to 10), and (2) three comma-separated values: start, stop, step\
255
+ (provide as many of these as required - the step size should be\
256
+ between 1 and 10). The sign of any of the supplied numbers will be\
257
+ ignored since the script uses the range and step definitions to\
258
+ build a generic number sequence appropriate for forward or reverse\
259
+ bias operation.',
260
+ type=common.check_file_exists, default=None)
261
+ parser.add_argument(
262
+ '--omitreturn',
263
+ action='store_true',
264
+ help='Only record data outbound (typically 0V to -nV) and do not\
265
+ record data for the return (typically -nV to 0V). By default the\
266
+ script records data for both outbound and return.')
267
+ parser.add_argument(
268
+ '--atlas',
269
+ action='store_true',
270
+ help='Enable various adjustments for the ATLAS project. When returning\
271
+ to the original voltage after testing, add in an additional delay\
272
+ between each voltage step to help avoid the current limit being\
273
+ exceeded.')
274
+ parser.add_argument(
275
+ '--hold', nargs=1, metavar='minutes',
276
+ help='After the initial outbound IV test, perform an IT test for the\
277
+ given number of minutes, logging the current values and timestamps.\
278
+ A value of 0 will hold indefinitely until the user manually\
279
+ terminates the test with by pressing the \'q\' key followed by enter',
280
+ type=check_minutes, default=None)
281
+
282
+ group1 = parser.add_mutually_exclusive_group()
283
+ group1.add_argument(
284
+ '-f', '--front',
285
+ action='store_true',
286
+ help='Use front output on PSU.')
287
+ group1.add_argument(
288
+ '-r', '--rear',
289
+ action='store_true',
290
+ help='Use rear output on PSU.')
291
+
292
+ # --json requires --itk_serno to be set
293
+ parser.add_argument(
294
+ '--itk_serno', nargs=1, metavar='itk_serno',
295
+ help='ATLAS ITk Pixel module serial number. Add this detail to plot\
296
+ titles and to the JSON file output (see --json).',
297
+ default='')
298
+ parser.add_argument(
299
+ '--json',
300
+ action='store_true',
301
+ help='Write a JSON file for each PSU channel that acquires data.')
302
+
303
+ parser.add_argument(
304
+ '--reset',
305
+ action='store_true',
306
+ help='Reset PSU before setting voltage. This is a diagnostic feature\
307
+ to address topical issues, and should not normally be required')
308
+ parser.add_argument(
309
+ '--forwardbias',
310
+ action='store_true',
311
+ help='Allow the script to create voltage sequences containing\
312
+ positive voltages. As a safety feature, by default the script only\
313
+ allows reverse bias, limiting the output to voltages from 0 to -nV and\
314
+ preventing positive voltage outputs.')
315
+ parser.add_argument(
316
+ '--settle',
317
+ action='store_true',
318
+ help='At each voltage step, monitor PSU current values until they\
319
+ become stable, then capture final value. If the value does not\
320
+ stabilise, a mean value of the last few values is returned.\
321
+ Parameters are --pc and --samples. Explicitly setting either of\
322
+ those parameters implies --settle.')
323
+ parser.add_argument(
324
+ '--settling_time', nargs=1, metavar='settling_time',
325
+ help='Manually set the time between setting the voltage and measuring\
326
+ the voltage and current.',
327
+ type=check_settling_time, default=[2.0])
328
+ parser.add_argument(
329
+ '--pc', nargs=1, metavar='percentage',
330
+ help='the percentage deviation of individual values from the mean\
331
+ that is deemed acceptable, the default value is 5). Setting this\
332
+ value implies --settle.',
333
+ type=check_percent, default=None)
334
+ parser.add_argument(
335
+ '--samples', nargs=1, metavar='samples',
336
+ help='samples is the total number of times data is captured from the\
337
+ PSU for each voltage step. Sensible values are probably less than\
338
+ 100. The default (and minimum) value is 10. Setting this value\
339
+ implies --settle.',
340
+ type=check_samples, default=None)
341
+ parser.add_argument(
342
+ '--sense', nargs=1, metavar='sensor_name',
343
+ help='Specify the name of the environmental sensor to receive data\
344
+ from as it appears in sense.py logs. This option ONLY applies to the\
345
+ ATLAS inner tracker (ITK) pixels multi-module cycling box.',
346
+ default=None)
347
+ parser.add_argument(
348
+ '-l', '--limit', nargs=1, metavar='threshold',
349
+ help='The threshold value above which limiting action will be taken.\
350
+ This is the compliance value to be set on the PSU channel, when this\
351
+ value is reached the PSU channel changes from being a constant voltage\
352
+ source to become a constant current source. The default value if unset\
353
+ is 10uA; the minimum possible value is 1nA. Values can be specified\
354
+ with either scientific (10e-9) or engineering notation (10n).',
355
+ type=common.check_current, default=[10e-6])
356
+ parser.add_argument(
357
+ '--label', nargs=1, metavar='plot_label',
358
+ help='if just a single PSU is being used, use this text on the plot\
359
+ instead of the PSU serial number. Use speech marks around the label if\
360
+ it contains spaces.',
361
+ default=None)
362
+ parser.add_argument(
363
+ '--alias', nargs=1, metavar='filename',
364
+ help='Substitute power supply channel identifiers for human readable\
365
+ descriptions in plots and log files, and allow power supply channels\
366
+ to be individually disabled. This option reads in a CSV file where\
367
+ each line consists of five fields: enable, model, serial number,\
368
+ channel identifier, and description. Use the model and serial number\
369
+ as reported by detect.py. Anything after a # is treated as a comment.\
370
+ If the enable field is left empty that will enable the power supply\
371
+ channel (use no/off/disable to disable it). The channel identifier\
372
+ should be omitted for single channel power supplies.',
373
+ type=common.check_file_exists, default=None)
374
+ parser.add_argument(
375
+ '--svg', action='store_true',
376
+ help='Plot to the Scalable Vector Graphics (SVG) file format instead\
377
+ of the default Portable Network Graphics (PNG). For the kind of\
378
+ sparse plots typically generated by this script, SVG files are\
379
+ better quality, have smaller file sizes, and render faster than PNG.')
380
+ parser.add_argument(
381
+ '-i', '--initial', action='store_true',
382
+ help='Return power supply to its initial voltage after completion of\
383
+ test.')
384
+ parser.add_argument(
385
+ '-d', '--debug', nargs=1, metavar='number',
386
+ help='Allow testing of the script when a power supply is not\
387
+ available. The number indicates the quantity of single channel power\
388
+ supplies to emulate.',
389
+ type=check_psunum, default=None)
390
+
391
+ args = parser.parse_args()
392
+
393
+ if args.json and not args.itk_serno:
394
+ parser.error("--json requires --itk_serno to be set")
395
+
396
+ handle_complex_arguments(settings, args)
397
+ handle_simple_arguments(settings, args)
398
+
399
+
400
+ def handle_complex_arguments(settings, args):
401
+ """
402
+ handle command line arguments that:
403
+
404
+ (1) cannot be combined
405
+ (2) affect the values of other arguments
406
+ (3) have values that require transformation
407
+
408
+ --------------------------------------------------------------------------
409
+ args
410
+ settings : dictionary
411
+ contains core information about the test environment
412
+ args : argparse.Namespace
413
+ command line arguments and values
414
+ --------------------------------------------------------------------------
415
+ returns
416
+ settings : no explicit return, mutable type amended in place
417
+ --------------------------------------------------------------------------
418
+ """
419
+ # do not change settings if none given
420
+ if args.front:
421
+ settings['rear'] = False
422
+ elif args.rear:
423
+ settings['rear'] = True
424
+
425
+ if args.voltage is not None:
426
+ # perform basic safety checks
427
+ if not args.forwardbias and args.voltage > 0:
428
+ sys.exit('to enable positive voltages use --forwardbias')
429
+ elif args.forwardbias and args.voltage < 0:
430
+ sys.exit('to enable negative voltages omit --forwardbias')
431
+ else:
432
+ settings['voltage'] = args.voltage
433
+ else:
434
+ # set sensible defaults
435
+ settings['voltage'] = 80 if args.forwardbias else -80
436
+
437
+ if args.settle or args.samples or args.pc:
438
+ settings['settle'] = True
439
+
440
+ if args.atlas:
441
+ settings['atlas'] = True
442
+
443
+ if args.pc:
444
+ settings['pc'] = args.pc[0] / 100
445
+
446
+ if args.alias:
447
+ filename = args.alias[0]
448
+ common.read_aliases(settings, filename)
449
+
450
+ if args.range:
451
+ filename = args.range[0]
452
+ sequence.read_user_range_step_file(settings, filename)
453
+
454
+ if args.stepsize:
455
+ settings['stepsize'] = args.stepsize[0]
456
+ if args.atlas:
457
+ print('--stepsize overrides default behaviour for --atlas')
458
+
459
+ settings['current_limit'] = args.limit[0]
460
+ settings['settling_time'] = args.settling_time[0]
461
+
462
+
463
+ def handle_simple_arguments(settings, args):
464
+ """
465
+ handle command line arguments that require a simple assignment
466
+
467
+ These assignments could be performed manually which would make the
468
+ code trivial to follow but rather long. The implementation here works well
469
+ as the number of (simple) command line arguments grows.
470
+
471
+ --------------------------------------------------------------------------
472
+ args
473
+ settings : dictionary
474
+ contains core information about the test environment
475
+ args : argparse.Namespace
476
+ command line arguments and values
477
+ --------------------------------------------------------------------------
478
+ returns
479
+ settings : no explicit return, mutable type amended in place
480
+ --------------------------------------------------------------------------
481
+ """
482
+ # collate all possible command line arguments and their respective values
483
+ all_command_line_arguments = ((argument, getattr(args, argument))
484
+ for argument in dir(args))
485
+
486
+ # limit the selection to command line options that:
487
+ #
488
+ # (1) have values that can be stored without transformation
489
+ # (2) the user has actually specified
490
+ stored_as_boolean = frozenset(
491
+ ('forwardbias', 'initial', 'json', 'omitreturn', 'reset', 'svg')
492
+ )
493
+ stored_as_list = frozenset(
494
+ ('debug', 'hold', 'label', 'samples', 'sense', 'itk_serno')
495
+ )
496
+ simple_assignments = stored_as_boolean.union(stored_as_list)
497
+ shortlist = ((argument, value)
498
+ for argument, value in all_command_line_arguments
499
+ if argument in simple_assignments and value)
500
+
501
+ # assign given arguments and values to settings
502
+ for argument, value in shortlist:
503
+ try:
504
+ settings[argument] = value[0]
505
+ except TypeError:
506
+ # object is not subscriptable (does not belong to stored_as_list)
507
+ settings[argument] = value
508
+
509
+
510
+ ##############################################################################
511
+ # logging debug messages
512
+ ##############################################################################
513
+
514
+ def write_debug_information_to_log():
515
+ """
516
+ Write basic information about the environment to the log file (but not to
517
+ the screen).
518
+
519
+ --------------------------------------------------------------------------
520
+ args : none
521
+ --------------------------------------------------------------------------
522
+ returns : none
523
+ --------------------------------------------------------------------------
524
+ """
525
+ # Ensure that the command line invocation could be copied and pasted back
526
+ # into the terminal by wrapping strings with spaces with speech marks.
527
+ sav = (
528
+ f'"{a}"' if ' ' in a else a
529
+ for a in sys.argv
530
+ )
531
+
532
+ common.log_with_colour(logging.DEBUG, f'invocation: {" ".join(sav)}')
533
+ common.log_with_colour(logging.DEBUG, f'platform: {platform.platform()}')
534
+ messages = [
535
+ f'python: {platform.python_version()}',
536
+ f'pyserial: {serial.VERSION}',
537
+ f'matplotlib: {matplotlib.__version__}'
538
+ ]
539
+ common.log_with_colour(logging.DEBUG, ', '.join(messages))
540
+
541
+
542
+ ##############################################################################
543
+ # utilities
544
+ ##############################################################################
545
+
546
+ def scale_timestamps(timestamps):
547
+ """
548
+ Amend timestamps so the start of the experiment is zero, and scale values
549
+ so they are sensible for human-readable plot axes.
550
+
551
+ --------------------------------------------------------------------------
552
+ args
553
+ timestamps : list of floats
554
+ values in seconds since the epoch
555
+ e.g.
556
+ [1627383466.9587026, 1627383472.445601, 1627383476.924957, ...]
557
+ --------------------------------------------------------------------------
558
+ returns
559
+ values : list of floats
560
+ e.g.
561
+ [0.0, 0.09144830703735352, 0.16610424121220907, ...]
562
+ units : string
563
+ 'minutes' or 'hours'
564
+ --------------------------------------------------------------------------
565
+ """
566
+ mintimestamp = min(timestamps)
567
+ minutes_of_available_data = (max(timestamps) - mintimestamp) / 60
568
+
569
+ if minutes_of_available_data > 180:
570
+ scale = 3600
571
+ units = 'hours'
572
+ else:
573
+ scale = 60
574
+ units = 'minutes'
575
+
576
+ values = [(t - mintimestamp) / scale for t in timestamps]
577
+
578
+ return values, units
579
+
580
+
581
+ ##############################################################################
582
+ # set psu values
583
+ ##############################################################################
584
+
585
+ def configure_psu(settings, pipeline, ser, dev):
586
+ """
587
+ Set the initial conditions for the power supply.
588
+
589
+ Keithley: With the range being used, voltages can only be specified to two
590
+ decimal places, though setting digits in the second decimal place is
591
+ unreliable (read back values do not always match) so limit values to be
592
+ set to 1 decimal place.
593
+
594
+ --------------------------------------------------------------------------
595
+ args
596
+ settings : dictionary
597
+ contains core information about the test environment
598
+ pipeline : instance of class Production
599
+ contains all the queues through which the production pipeline
600
+ processes communicate
601
+ ser : serial.Serial
602
+ reference for serial port
603
+ dev : instance of class common.Channel()
604
+ contains details of a device and its serial port
605
+ --------------------------------------------------------------------------
606
+ returns : bool, float, float
607
+ success (True if the device was found, False otherwise)
608
+ measured_voltage
609
+ measured_current
610
+ --------------------------------------------------------------------------
611
+ """
612
+ # constrain the voltage to be set to the given number of decimal places
613
+ voltage = common.decimal_quantize(0, settings['decimal_places'])
614
+
615
+ if settings['reset'] and dev.manufacturer == 'keithley':
616
+ # reset to default conditions, output off
617
+ command_string = lexicon.power(dev.model, 'reset')
618
+ common.send_command(pipeline, ser, dev, command_string)
619
+
620
+ # allow device time to complete reset before sending more commands
621
+ time.sleep(0.5)
622
+
623
+ # set voltage to zero and set compliance
624
+ if dev.manufacturer == 'keithley':
625
+ # limit the number of characters sent
626
+ # e.g. reduce 7.999999999999999e-05 to '8.00e-05'
627
+ compliance = f'{settings["current_limit"]:.2e}'
628
+ command_string = lexicon.power(dev.model, 'configure',
629
+ voltage, compliance,
630
+ channel=dev.channel)
631
+ common.send_command(pipeline, ser, dev, command_string)
632
+
633
+ elif dev.manufacturer == 'iseg':
634
+ command_string = lexicon.power(dev.model, 'set auto ramp',
635
+ channel=dev.channel)
636
+ common.atomic_send_command_read_response(pipeline, ser, dev, command_string)
637
+
638
+ command_string = lexicon.power(dev.model, 'set char delay')
639
+ common.atomic_send_command_read_response(pipeline, ser, dev, command_string)
640
+
641
+ volts_per_second = 20
642
+ command_string = lexicon.power(dev.model,
643
+ 'set voltage max rate of change',
644
+ volts_per_second,
645
+ channel=dev.channel)
646
+ common.atomic_send_command_read_response(pipeline, ser, dev, command_string)
647
+
648
+ # change front/rear routing if requested, otherwise do not change it
649
+ if settings['rear'] is not None and dev.model == '2410':
650
+ destination = 'REAR' if settings['rear'] else 'FRON'
651
+ command_string = lexicon.power('2410', 'set route', destination)
652
+ common.send_command(pipeline, ser, dev, command_string)
653
+
654
+ # set output on (Keithley only, ISEG SHQ hardware only switch on front panel)
655
+ if dev.manufacturer == 'keithley':
656
+ command_string = lexicon.power(dev.model, 'output on', channel=dev.channel)
657
+ common.send_command(pipeline, ser, dev, command_string)
658
+
659
+ # allow a little time for the current to settle before reading
660
+ time.sleep(1)
661
+
662
+ return read_psu_and_verify(settings, pipeline, ser, voltage, dev)
663
+
664
+
665
+ def in_compliance(ser, pipeline, dev):
666
+ """
667
+ Check if the power supply channel reports it is in compliance - i.e. the
668
+ set current limit has been exceeded - and it is now acting as a constant
669
+ current source instead of a constant voltage source.
670
+
671
+ Note that for the ISEG SHQ with the uA range selected, a current overflow
672
+ (OVERFLOW on display) transient is often seen when ramping down to a low
673
+ voltage, may need to check a second time to be sure.
674
+
675
+ --------------------------------------------------------------------------
676
+ args
677
+ ser : serial.Serial
678
+ reference for serial port
679
+ pipeline : instance of class Production
680
+ contains all the queues through which the production pipeline
681
+ processes communicate
682
+ dev : instance of class common.Channel()
683
+ contains details of a device and its serial port
684
+ --------------------------------------------------------------------------
685
+ returns
686
+ incomp : bool
687
+ True if the PSU is in compliance
688
+ --------------------------------------------------------------------------
689
+ """
690
+ incomp = report_status = False
691
+ command_string = lexicon.power(dev.model, 'check compliance', channel=dev.channel)
692
+ response = common.atomic_send_command_read_response(pipeline, ser, dev, command_string)
693
+
694
+ if dev.model == '2410':
695
+ if response in {'0', '1'}:
696
+ incomp = response == '1'
697
+ else:
698
+ report_status = True
699
+
700
+ elif dev.model == '2614b':
701
+ if response in {'true', 'false'}:
702
+ incomp = response == 'true'
703
+ else:
704
+ report_status = True
705
+
706
+ elif dev.manufacturer == 'iseg':
707
+ if '=ERR' in response:
708
+ incomp = '=ERR' in response
709
+ # clear the error by repeating the reading (must read response)
710
+ common.atomic_send_command_read_response(pipeline, ser, dev, command_string)
711
+
712
+ if report_status:
713
+ message = f'{dev.ident}, problem reading compliance status'
714
+ common.log_with_colour(logging.WARNING, message)
715
+
716
+ return incomp
717
+
718
+
719
+ def read_psu_set_voltage(pipeline, ser, dev):
720
+ """
721
+ Read the voltage that the PSU has been asked to output (rather than the
722
+ actual instantaneous voltage measured at the output terminals).
723
+
724
+ --------------------------------------------------------------------------
725
+ args
726
+ pipeline : instance of class Production
727
+ contains all the queues through which the production pipeline
728
+ processes communicate
729
+ ser : serial.Serial
730
+ reference for serial port
731
+ dev : instance of class Channel()
732
+ contains details of a device and its serial port
733
+ --------------------------------------------------------------------------
734
+ returns
735
+ set_volt : float or None
736
+ float if the value could be read or None if it could not
737
+ --------------------------------------------------------------------------
738
+ """
739
+ set_volt = None
740
+
741
+ command_string = lexicon.power(dev.model, 'read set voltage', channel=dev.channel)
742
+ local_buffer = common.atomic_send_command_read_response(pipeline, ser, dev, command_string)
743
+
744
+ if dev.manufacturer == 'iseg' and dev.model == 'shq':
745
+ set_volt = common.iseg_value_to_float(local_buffer)
746
+
747
+ elif dev.manufacturer == 'keithley' and dev.model in {'2410', '2614b'}:
748
+ if dev.model == '2410':
749
+ # e.g. '-5.000000E+00,-1.005998E-10,+9.910000E+37,+1.185742E+04,+2.150800E+04'
750
+ item = next(iter(local_buffer.split(',')))
751
+ else:
752
+ # e.g. '-5.00000e+00'
753
+ item = local_buffer
754
+
755
+ try:
756
+ set_volt = float(item)
757
+ except (TypeError, ValueError):
758
+ common.log_with_colour(logging.WARNING, f'{dev.ident}, problem reading set voltage')
759
+
760
+ return set_volt
761
+
762
+
763
+ def read_psu_and_verify(settings, pipeline, ser, desired_voltage, dev):
764
+ """
765
+ Verify that the set voltage read back from the psu matches the
766
+ desired value, then return the measured voltage and current.
767
+
768
+ --------------------------------------------------------------------------
769
+ args
770
+ settings : dictionary
771
+ contains core information about the test environment
772
+ pipeline : instance of class Production
773
+ contains all the queues through which the production pipeline
774
+ processes communicate
775
+ ser : serial.Serial
776
+ reference for serial port
777
+ desired_voltage : decimal.Decimal
778
+ voltage that was set
779
+ dev : instance of class common.Channel()
780
+ contains details of a device and its serial port
781
+ --------------------------------------------------------------------------
782
+ returns
783
+ success : bool
784
+ True if a matching voltage was read back
785
+ measured_voltage : float
786
+ measured_current : float
787
+ --------------------------------------------------------------------------
788
+ """
789
+ set_volt = read_psu_set_voltage(pipeline, ser, dev)
790
+ if set_volt is not None:
791
+ set_voltage = common.decimal_quantize(set_volt, settings['decimal_places'])
792
+
793
+ if set_voltage == desired_voltage:
794
+ success = True
795
+ else:
796
+ success = False
797
+ common.log_with_colour(logging.WARNING,
798
+ 'set voltage differs from voltage read back')
799
+
800
+ measured_voltage, measured_current = common.read_psu_measured_vi(pipeline, ser, dev)
801
+ else:
802
+ success = False
803
+ measured_voltage = measured_current = None
804
+
805
+ return success, measured_voltage, measured_current
806
+
807
+
808
+ def current_limit(settings, pipeline, measured_current,
809
+ measured_voltage, set_voltage, ser, dev):
810
+ """
811
+ Test whether the measured leakage current has exceeded one of two limits,
812
+ both related to the maximum leakage current value supplied as an argument
813
+ to command line option --limit <n>.
814
+
815
+ (1) Soft limit: has the measured leakage current exceeded a threshold
816
+ value, calculated as a percentage of <n>
817
+
818
+ (2) Hard limit: has the power supply channel - having had its current limit
819
+ set to <n> - reported its compliance bit has been set.
820
+
821
+ General notes on Keithley 2410 behaviour:
822
+
823
+ When the device reports compliance, current is clamped to a value below -
824
+ not at - the set limit, e.g. for this contrived scenario using a 2nA
825
+ limit:
826
+
827
+ INFO : 2410 4343654, IV, -10, V, -9.578194e+00, V, -1.954002e-09, A
828
+ WARNING : 2410 4343654 reports compliance
829
+ INFO : 2410 4343654, IV, -15, V, -1.058404e+01, V, -1.954872e-09, A
830
+ WARNING : 2410 4343654 reports compliance
831
+ WARNING : 2410 4343654 set and measured voltage differ
832
+
833
+ And sometimes the device does not report compliance, even when it is
834
+ clearly operating as a current source (10nA limit here):
835
+
836
+ INFO : 2410 4343654, IV, -15, V, -1.290832e+01, V, -9.956312e-09, A
837
+ WARNING : 2410 4343654 leakage current exceeded soft limit
838
+ WARNING : 2410 4343654 set and measured voltage differ
839
+
840
+ The hard limit test employs an additional check of whether the measured
841
+ voltage differs from the set voltage, an indication that the power supply
842
+ may have changed from being a constant voltage source to a constant
843
+ current source. This additional check is necessary since bit 14 of the
844
+ measurement event register - which indicates compliance - seems to be set
845
+ as the leakage current approaches the compliance limit, rather than
846
+ actually reaching it.
847
+
848
+ Using miniterm to monitor bit 14 with :STAT:MEAS?, and gradually
849
+ increasing the reverse bias voltage it can be seen that bit 14 is set
850
+ before the compliance limit is reached and before the "Cmpl" text flashes
851
+ on the front panel display.
852
+
853
+ --------------------------------------------------------------------------
854
+ args
855
+ settings : dictionary
856
+ contains core information about the test environment
857
+ pipeline : instance of class Production
858
+ contains all the queues through which the production pipeline
859
+ processes communicate
860
+ measured_current : float
861
+ measured_voltage : float
862
+ set_voltage : float
863
+ ser : serial.Serial
864
+ reference for serial port
865
+ dev : instance of class common.Channel()
866
+ contains details of a device and its serial port
867
+ --------------------------------------------------------------------------
868
+ returns : bool
869
+ True if either limit has been exceeded, False otherwise
870
+ --------------------------------------------------------------------------
871
+ """
872
+ # the soft limit is set as a percentage of the value provided by command
873
+ # line option --limit (or default value)
874
+ soft_limit = abs(measured_current) >= abs(settings['current_limit'])
875
+ if soft_limit:
876
+ common.log_with_colour(logging.WARNING,
877
+ f'{dev.ident} leakage current exceeded soft limit')
878
+
879
+ # always run this test even if the soft limit has been exceeded, as it's
880
+ # valuable to have the power supply channel compliance status reported in
881
+ # the log for debugging purposes
882
+ hard_limit = in_compliance(ser, pipeline, dev)
883
+ if hard_limit:
884
+ common.log_with_colour(logging.WARNING, f'{dev.ident} reports compliance')
885
+
886
+ if dev.manufacturer != 'iseg':
887
+ # setting exact voltages for ISEG devices may be troublesome
888
+
889
+ asv = abs(set_voltage)
890
+ margin = 1 if asv > 10 else max(asv * 0.1, 0.1)
891
+ lower_bound = asv - margin
892
+ upper_bound = asv + margin
893
+ within_bounds = lower_bound < abs(measured_voltage) < upper_bound
894
+
895
+ if not within_bounds:
896
+ common.log_with_colour(logging.WARNING,
897
+ f'{dev.ident} set and measured voltage differ')
898
+
899
+ if dev.model == '2410':
900
+ # see the function docstring above for why this additional check is
901
+ # necessary
902
+ hard_limit = hard_limit and not within_bounds
903
+
904
+ return soft_limit or hard_limit
905
+
906
+
907
+ ##############################################################################
908
+ # threads
909
+ ##############################################################################
910
+
911
+ def check_key(graceful_quit):
912
+ """
913
+ Identify whether the user has decided to stop testing early by pressing
914
+ 'q' then 'enter' (both key presses required to avoid an accidental quit),
915
+ and set a flag in shared memory as True indicating this has occurred.
916
+
917
+ The status of this flag can be read from within the individual threads
918
+ operating the power supplies, which can then handle exiting gracefully.
919
+
920
+ --------------------------------------------------------------------------
921
+ args
922
+ graceful_quit : multiprocessing.Value(ctypes.c_bool, False)
923
+ shared memory containing a single boolean value to indicate
924
+ whether the user has requested the script terminate early
925
+ --------------------------------------------------------------------------
926
+ returns : no explicit return
927
+ graceful_quit : multiprocessing shared memory may be amended
928
+ --------------------------------------------------------------------------
929
+ """
930
+ while True:
931
+ try:
932
+ line = sys.stdin.read(1).lower()
933
+ except AttributeError:
934
+ pass
935
+ else:
936
+ if line == 'q':
937
+ common.log_with_colour(logging.INFO, 'user requested graceful quit')
938
+ graceful_quit.value = True
939
+
940
+
941
+ def liveplot(pipeline, zmq_skt):
942
+ """
943
+ Receive data from all power supplies under test, and send the data to
944
+ an external plotter (liveplot.py).
945
+
946
+ To avoid dropped data packets ZeroMQ REQ/REP is the pattern used, though
947
+ the desired behaviour is really PUB/SUB. PUB/SUB is unreliable in its
948
+ basic form, and can't be used. With REQ/REP liveplot.py is obliged to
949
+ reply to the message this thread sends, but that reply can be discarded.
950
+
951
+ --------------------------------------------------------------------------
952
+ args
953
+ pipeline : instance of class Production
954
+ contains all the queues through which the production pipeline
955
+ processes communicate
956
+ zmq_skt : zmq.sugar.socket.Socket
957
+ ZeroMQ socket to communicate with an external live-plotting script
958
+ --------------------------------------------------------------------------
959
+ returns : none
960
+ --------------------------------------------------------------------------
961
+ """
962
+ while True:
963
+ message_tx = pipeline.liveplot.get()
964
+
965
+ # sentinel value to quit
966
+ if message_tx is None:
967
+ break
968
+
969
+ # send message then attempt to receive reply
970
+ try:
971
+ zmq_skt.send_json(message_tx)
972
+ except zmq.error.ZMQError:
973
+ # liveplot.py is not running
974
+ pass
975
+ else:
976
+ # receive and discard reply from liveplot.py
977
+ with contextlib.suppress(zmq.error.ZMQError):
978
+ zmq_skt.recv_json()
979
+
980
+
981
+ def sense(pipeline, zmq_skt, settings):
982
+ """
983
+ Receive data packets from sense.py via ZeroMQ REQ/REP, and place them on a
984
+ queue for processing later.
985
+
986
+ If a zmq.error.ZMQError exception occurs while attempting to bind to a
987
+ socket, this is almost always caused by a previous invocation of this
988
+ script having been forcibly terminated. When the current invocation tries
989
+ to bind, this will fail.
990
+
991
+ In this case the user should find the PID owning the port currently, in
992
+ the case below this is 32539:
993
+
994
+ pi@raspberrypi:~/dev/1d-phantom/utilities $ sudo netstat -ltnp
995
+ Active Internet connections (only servers)
996
+ Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name
997
+ tcp 0 0 0.0.0.0:5556 0.0.0.0:* LISTEN 32539/python3
998
+ tcp 0 0 0.0.0.0:22 0.0.0.0:* LISTEN 776/sshd
999
+ tcp 0 0 127.0.0.1:631 0.0.0.0:* LISTEN 27597/cupsd
1000
+ tcp 0 0 127.0.0.1:25 0.0.0.0:* LISTEN 992/exim4
1001
+ tcp6 0 0 :::22 :::* LISTEN 776/sshd
1002
+ tcp6 0 0 ::1:631 :::* LISTEN 27597/cupsd
1003
+ tcp6 0 0 ::1:25 :::* LISTEN 992/exim4
1004
+
1005
+ pi@raspberrypi:~/dev/1d-phantom/utilities $ ps -ef | grep 32539
1006
+ pi 1977 1624 0 17:33 pts/19 00:00:00 grep --color=auto 32539
1007
+ pi 32539 31217 0 16:07 pts/13 00:00:26 python3 ./iv.py -5 --sense hyt221_x
1008
+ pi 32546 32539 0 16:07 pts/13 00:00:00 python3 ./iv.py -5 --sense hyt221_x
1009
+
1010
+ And we can regain access by killing the PID:
1011
+
1012
+ kill -9 32539
1013
+
1014
+ --------------------------------------------------------------------------
1015
+ args
1016
+ pipeline : instance of class Production
1017
+ contains all the queues through which the production pipeline
1018
+ processes communicate
1019
+ zmq_skt : zmq.sugar.socket.Socket
1020
+ ZeroMQ socket to communicate with external environmental sensing
1021
+ script
1022
+ settings : dictionary
1023
+ contains core information about the test environment
1024
+ --------------------------------------------------------------------------
1025
+ returns : no explicit return
1026
+ pipeline.sense_dict : multiprocessing.Manager().dict()
1027
+ repository for the most recently acquired environmental data
1028
+ received from sense.py
1029
+ --------------------------------------------------------------------------
1030
+ """
1031
+ # configure communication with sense.py
1032
+ # Use REP/REQ (with handshaking) instead of PUB/SUB to avoid packet loss
1033
+ port = 5556
1034
+ try:
1035
+ zmq_skt.bind(f'tcp://*:{port}')
1036
+ except zmq.error.ZMQError as zerr:
1037
+ message = f'ZeroMQ: {zerr} when binding to port {port}'
1038
+ common.log_with_colour(logging.WARNING, message)
1039
+ message = 'ZeroMQ: find PID of current owner with: sudo netstat -ltnp'
1040
+ common.log_with_colour(logging.WARNING, message)
1041
+ else:
1042
+ try:
1043
+ while True:
1044
+ # receive message from sense.py, this intentionally blocks hence
1045
+ # why this is a daemon thread
1046
+ message = zmq_skt.recv_json()
1047
+
1048
+ if message is not None and settings['sense'] is not None:
1049
+ valid = {k: v for k, v in message.items() if settings['sense'] in k}
1050
+ if valid:
1051
+ pipeline.sense_dict = valid
1052
+
1053
+ # send a minimal message back to sense.py for handshake
1054
+ zmq_skt.send_json(None)
1055
+ except zmq.error.ZMQError:
1056
+ zmq_skt.close()
1057
+
1058
+
1059
+ ##############################################################################
1060
+ # generation of fake data for --debug <n> command line option
1061
+ ##############################################################################
1062
+
1063
+ def debug_generate_data(settings, ivtdat):
1064
+ """
1065
+ --------------------------------------------------------------------------
1066
+ args
1067
+ settings : dictionary
1068
+ contains core information about the test environment
1069
+ ivtdat : instance of class Packet
1070
+ contains data for a given power supply channel's IV and IT curves
1071
+ --------------------------------------------------------------------------
1072
+ returns
1073
+ ivtdat : no explicit return, mutable type amended in place
1074
+ --------------------------------------------------------------------------
1075
+ """
1076
+ # outbound iv
1077
+ debug_iv_test(settings, ivtdat)
1078
+
1079
+ # hold stability
1080
+ debug_hold_stability(settings, ivtdat)
1081
+
1082
+ # return iv
1083
+ if not settings['omitreturn']:
1084
+ debug_iv_test(settings, ivtdat, reverse=True)
1085
+
1086
+
1087
+ def debug_hold_stability(settings, ivtdat):
1088
+ """
1089
+ Generate simulated hold stability data.
1090
+
1091
+ --------------------------------------------------------------------------
1092
+ args
1093
+ settings : dictionary
1094
+ contains core information about the test environment
1095
+ ivtdat : instance of class Packet
1096
+ contains data for a given power supply channel's IV and IT curves
1097
+ --------------------------------------------------------------------------
1098
+ returns
1099
+ ivtdat : no explicit return, mutable type amended in place
1100
+ --------------------------------------------------------------------------
1101
+ """
1102
+ hold = settings['hold']
1103
+ voltage = settings['voltage']
1104
+
1105
+ if hold is not None and hold >= 1:
1106
+ ivtdat.hold_voltage = voltage
1107
+ variation = random.uniform(1, 1.05)
1108
+
1109
+ for minute in range(hold):
1110
+ ivtdat.hold_current.append(noisy(diode(voltage, variation)))
1111
+ ivtdat.hold_timestamp.append(noisy(minute))
1112
+
1113
+
1114
+ def debug_iv_test(settings, ivtdat, reverse=False):
1115
+ """
1116
+ Generate simulated IV test data with the voltage sequence in the order
1117
+ given.
1118
+
1119
+ --------------------------------------------------------------------------
1120
+ args
1121
+ settings : dictionary
1122
+ contains core information about the test environment
1123
+ ivtdat : instance of class Packet
1124
+ contains data for a given power supply channel's IV and IT curves
1125
+ reverse : boolean
1126
+ reverse number sequence if True, False otherwise
1127
+ --------------------------------------------------------------------------
1128
+ returns
1129
+ ivtdat : no explicit return, mutable type amended in place
1130
+ --------------------------------------------------------------------------
1131
+ """
1132
+ start = 0
1133
+ stop = settings['voltage']
1134
+ variation = random.uniform(1, 1.1)
1135
+
1136
+ if reverse:
1137
+ # this is a ramp-down IV
1138
+ # manually mark the point where the ramp-down data starts
1139
+ ivtdat.set_voltage.append('split')
1140
+ ivtdat.measured_current.append('split')
1141
+ ivtdat.measured_voltage.append('split')
1142
+ ivtdat.measured_timestamp.append('split')
1143
+ start, stop = stop, start
1144
+
1145
+ variablestep = settings['stepsize'] is None
1146
+ for voltage in sequence.test_run(start, stop, settings['stepsize'],
1147
+ variablestep=variablestep,
1148
+ bespoke_sequence_lut=settings['bespoke_sequence_lut']):
1149
+ vpsu = noisy(voltage)
1150
+ ipsu = diode(vpsu, variation)
1151
+
1152
+ ivtdat.set_voltage.append(voltage)
1153
+ ivtdat.measured_current.append(ipsu)
1154
+ ivtdat.measured_voltage.append(vpsu)
1155
+ ivtdat.measured_timestamp.append(time.time())
1156
+
1157
+
1158
+ def diode(voltage, variation):
1159
+ """
1160
+ A very rough approximation of the leakage current of a reverse biased
1161
+ diode.
1162
+
1163
+ --------------------------------------------------------------------------
1164
+ args
1165
+ voltage : numeric
1166
+ variation : float
1167
+ this value, when held constant over a range of voltages, allows
1168
+ this function to be used to generate a series of subtly different
1169
+ curves, approximating the variation that might be expected in a
1170
+ test environment
1171
+ --------------------------------------------------------------------------
1172
+ returns : float
1173
+ leakage current
1174
+ --------------------------------------------------------------------------
1175
+ """
1176
+ return pow(abs(float(voltage)), 0.25) * -0.5e-8 * variation
1177
+
1178
+
1179
+ def noisy(value):
1180
+ """
1181
+ Add a small amount of noise to the supplied argument.
1182
+
1183
+ --------------------------------------------------------------------------
1184
+ args
1185
+ value : float
1186
+ --------------------------------------------------------------------------
1187
+ returns : float
1188
+ --------------------------------------------------------------------------
1189
+ """
1190
+ adjust = value * 0.005
1191
+ return random.uniform(value - adjust, value + adjust)
1192
+
1193
+
1194
+ ##############################################################################
1195
+ # temperature and humidity sensing
1196
+ ##############################################################################
1197
+
1198
+ def add_environmental_string(text, sensor_readings, unit):
1199
+ """
1200
+ Append temperature data to log string.
1201
+
1202
+ --------------------------------------------------------------------------
1203
+ args
1204
+ text : string
1205
+ e.g. '2614b 4428182 a, IV, -2, V, -1.99294e+00, V, 4.76837e-14, A'
1206
+ sensor_readings : dict
1207
+ e.g. {'PT100MK1-DC3D6': 22.31, 'PT100MK1-DC392': 19.16}
1208
+ unit : string
1209
+ '\u00b0C' or 'RH%' for temperature and humidity respectively
1210
+ --------------------------------------------------------------------------
1211
+ returns : string
1212
+ e.g.
1213
+ ('2614b 4428182 a, IV, -2, V, -1.99294e+00, V, 4.76837e-14, A, '
1214
+ 'PT100MK1-DC3D6, 22.31, °C, PT100MK1-DC392, 19.45, °C')
1215
+ --------------------------------------------------------------------------
1216
+ """
1217
+ strings = (f'{name}, {reading:.2f}, {unit}'
1218
+ for name, reading in sensor_readings.items())
1219
+
1220
+ return ', '.join(itertools.chain([text], strings))
1221
+
1222
+
1223
+ def get_environmental_data(settings, pipeline):
1224
+ """
1225
+ Get environmental data from the appropriate source.
1226
+
1227
+ --------------------------------------------------------------------------
1228
+ args
1229
+ settings : dictionary
1230
+ contains core information about the test environment
1231
+ pipeline : instance of class Production
1232
+ contains all the queues through which the production pipeline
1233
+ processes communicate
1234
+ --------------------------------------------------------------------------
1235
+ returns
1236
+ temp : dict
1237
+ humi : dict
1238
+ --------------------------------------------------------------------------
1239
+ """
1240
+ if settings['sense'] is None:
1241
+ # read directly from Yoctopuce sensors
1242
+ temp = read_environment(settings['temperature_sensors'], pipeline)
1243
+ humi = read_environment(settings['humidity_sensors'], pipeline)
1244
+ elif settings['sense'] and pipeline.sense_dict:
1245
+ # receive data sent by sense.py using ZeroMQ
1246
+ temp = {
1247
+ k.split('_temperature')[0]: v
1248
+ for k, v in pipeline.sense_dict.items()
1249
+ if '_temperature' in k
1250
+ }
1251
+ humi = {
1252
+ k.split('_relative_humidity')[0]: v
1253
+ for k, v in pipeline.sense_dict.items()
1254
+ if '_relative_humidity' in k
1255
+ }
1256
+ else:
1257
+ temp = {}
1258
+ humi = {}
1259
+
1260
+ return temp, humi
1261
+
1262
+
1263
+ def register_hub(location):
1264
+ """
1265
+ Initialise the Yoctopuce API to use sensor modules from the given
1266
+ location.
1267
+
1268
+ If argument location contains an IP address, the call to
1269
+ yapi.YAPI.RegisterHub will take 20 seconds to time-out if there is
1270
+ no response.
1271
+
1272
+ It cannot be inferred from a return value of yapi.YAPI.SUCCESS that
1273
+ sensor modules will be found in the given location.
1274
+
1275
+ --------------------------------------------------------------------------
1276
+ args
1277
+ location : string
1278
+ either 'usb' or an ip address e.g. '192.168.0.200'
1279
+ --------------------------------------------------------------------------
1280
+ returns
1281
+ registered : boolean
1282
+ True if the API initialisation was successful, False otherwise.
1283
+ --------------------------------------------------------------------------
1284
+ """
1285
+ registered = False
1286
+ errmsg = yapi.YRefParam()
1287
+
1288
+ try:
1289
+ registered = yapi.YAPI.RegisterHub(location, errmsg) == yapi.YAPI.SUCCESS
1290
+ except ImportError:
1291
+ common.log_with_colour(logging.WARNING, 'yoctopuce: unable to import YAPI shared library')
1292
+ if sys.platform == 'darwin':
1293
+ message = ('allow libyapi.dylib in macOS System Preferences, '
1294
+ 'Security and Privacy, General')
1295
+ common.log_with_colour(logging.WARNING, f'yoctopuce: {message}')
1296
+
1297
+ else:
1298
+ if not registered:
1299
+ common.log_with_colour(logging.WARNING, f'yoctopuce: {errmsg.value}')
1300
+
1301
+ return registered
1302
+
1303
+
1304
+ def yoctopuce_api_set(settings):
1305
+ """
1306
+ Initialise the Yoctopuce API to use sensor modules attached to a
1307
+ YoctoHub-Ethernet if one is present. Otherwise, defer to USB connected
1308
+ sensor modules.
1309
+
1310
+ --------------------------------------------------------------------------
1311
+ args
1312
+ settings : dictionary
1313
+ contains core information about the test environment
1314
+ --------------------------------------------------------------------------
1315
+ returns
1316
+ registered : boolean
1317
+ True if the API initialisation was successful, False otherwise.
1318
+ --------------------------------------------------------------------------
1319
+ """
1320
+ registered = False
1321
+
1322
+ # Preferentially configure the Yoctopuce API to access sensor modules via
1323
+ # a YoctoHub-Ethernet at the given IP address.
1324
+ #
1325
+ # The call to register_hub will take 10 seconds to time out if there is no
1326
+ # response from a YoctoHub-Ethernet at the given IP address, so check that
1327
+ # a basic connection attempt is successful before calling.
1328
+ #
1329
+ # The YoctoHub-Ethernet should respond to connection attempts on
1330
+ # ports 80 and 4444.
1331
+ ip_address = settings['temperature_ip']
1332
+ ports = {80, 4444}
1333
+ if any(port_responsive(ip_address, port) for port in ports):
1334
+ registered = register_hub(ip_address)
1335
+
1336
+ # fall back to allowing the API to access Yoctopuce modules connected to
1337
+ # local USB ports
1338
+ if not registered:
1339
+ registered = register_hub('usb')
1340
+
1341
+ return registered
1342
+
1343
+
1344
+ def detect_yoctopuce_sensors(settings):
1345
+ """
1346
+ Detect environmental sensors applicable to the test environment.
1347
+
1348
+ PT100 sensors are used for temperature monitoring, and a Yocto-Meteo-V2
1349
+ is used for humidity monitoring. The Sensiron SHT35 based Yocto-Meteo-V2
1350
+ also contains pressure and temperature sensors which are ignored; pressure
1351
+ isn't important for this experiment, and its temperature sensor can't
1352
+ measure below -45°C which limits its utility when working with dry ice.
1353
+
1354
+ This is run before any IV testing threads are run, hence the API can be
1355
+ accessed without using the lock.
1356
+
1357
+ --------------------------------------------------------------------------
1358
+ args
1359
+ settings : dictionary
1360
+ contains core information about the test environment
1361
+ --------------------------------------------------------------------------
1362
+ returns
1363
+ settings : no explicit return, mutable type amended in place
1364
+ --------------------------------------------------------------------------
1365
+ """
1366
+ if not yoctopuce_api_set(settings):
1367
+ return
1368
+
1369
+ # collect temperature sensors
1370
+ # ytemp.YTemperature.FirstTemperature() will find *all* modules
1371
+ # capable of reading temperature including dedicated temperature
1372
+ # modules (Yocto-PT100) as well as modules that acquire multiple
1373
+ # parameters (Yocto-Meteo-V2)
1374
+ sensors = {}
1375
+ sensor = ytemp.YTemperature.FirstTemperature()
1376
+ while sensor is not None:
1377
+ if sensor.isOnline():
1378
+ name = sensor.get_friendlyName().partition('.')[0]
1379
+
1380
+ sensors[name] = sensor
1381
+ common.log_with_colour(logging.INFO,
1382
+ f'yoctopuce: temperature sensor found: {name}')
1383
+
1384
+ sensor = sensor.nextTemperature()
1385
+
1386
+ # write found sensors to settings, display for user
1387
+ if sensors:
1388
+ settings['temperature_sensors'] = sensors
1389
+ else:
1390
+ common.log_with_colour(logging.WARNING,
1391
+ 'yoctopuce: no temperature sensors found')
1392
+
1393
+ # collect humidity sensors
1394
+ sensors = {}
1395
+ sensor = yhumi.YHumidity.FirstHumidity()
1396
+ while sensor is not None:
1397
+ if sensor.isOnline():
1398
+ name = sensor.get_friendlyName().partition('.')[0]
1399
+ sensors[name] = sensor
1400
+ common.log_with_colour(logging.INFO,
1401
+ f'yoctopuce: humidity sensor found: {name}')
1402
+ sensor = sensor.nextHumidity()
1403
+
1404
+ # write found humidity sensors to settings, display for user
1405
+ if sensors:
1406
+ settings['humidity_sensors'] = sensors
1407
+ else:
1408
+ common.log_with_colour(logging.WARNING,
1409
+ 'yoctopuce: no humidity sensors found')
1410
+
1411
+
1412
+ def port_responsive(host, port):
1413
+ """
1414
+ Establish whether the port at the given ip address is responsive to a
1415
+ connection attempt. A timeout period of 2 seconds is used.
1416
+
1417
+ In essence, check if the node is present without having to handle the
1418
+ platform dependent nature of ping.
1419
+ --------------------------------------------------------------------------
1420
+ args
1421
+ host : string
1422
+ local IPV4 address e.g. '192.168.0.200'
1423
+ port : int
1424
+ port number, e.g. 22
1425
+ --------------------------------------------------------------------------
1426
+ returns : bool
1427
+ True if node is responsive, False otherwise
1428
+ --------------------------------------------------------------------------
1429
+ """
1430
+ skt = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
1431
+ skt.settimeout(2)
1432
+
1433
+ with contextlib.closing(skt) as sock:
1434
+ return sock.connect_ex((host, port)) == 0
1435
+
1436
+
1437
+ def read_environment(sensor_details, pipeline):
1438
+ """
1439
+ Read environmental data (temperature or humidity) from given Yoctopuce
1440
+ sensors.
1441
+
1442
+ This function is called from within threads, and there is a risk that the
1443
+ Yoctopuce API could be accessed concurrently, hence the presence of the
1444
+ lock here.
1445
+
1446
+ --------------------------------------------------------------------------
1447
+ args
1448
+ sensor_details : dictionary
1449
+ {sensor_name: sensor_identifier, ...}
1450
+ pipeline : instance of class Production
1451
+ contains all the queues through which the production pipeline
1452
+ processes communicate
1453
+ --------------------------------------------------------------------------
1454
+ returns
1455
+ results : dict
1456
+ e.g. {'PT100MK1-DC3D6': 22.31, 'PT100MK1-DC392': 19.16}
1457
+ --------------------------------------------------------------------------
1458
+ """
1459
+ results = {}
1460
+
1461
+ if sensor_details is not None:
1462
+ with pipeline.yapiaccess:
1463
+ results = {name: sensor.get_currentValue()
1464
+ for name, sensor in sensor_details.items()
1465
+ if sensor.isOnline()}
1466
+
1467
+ return results
1468
+
1469
+
1470
+ def report_environmental_issues(ivtdat, dev, test_iv, outbound=True):
1471
+ """
1472
+ --------------------------------------------------------------------------
1473
+ args
1474
+ ivtdat : instance of class Packet
1475
+ contains data for a given power supply channel's IV and IT curves
1476
+ dev : instance of class common.Channel()
1477
+ contains details of a device and its serial port
1478
+ test_iv : bool
1479
+ True = data from IV test, False = data from IT test
1480
+ outbound : bool
1481
+ For lists that may contain a 'split' marker, outbound = True
1482
+ selects items before the marker, False selects items after the
1483
+ marker. For other lists, this is ignored.
1484
+ --------------------------------------------------------------------------
1485
+ returns : none
1486
+ entries may be made in the log file
1487
+ --------------------------------------------------------------------------
1488
+ """
1489
+ tolerance_symbol = chr(177)
1490
+ degree_symbol = chr(176)
1491
+ # allow up to +/- 5% around mean
1492
+ ten_percent = 0.05
1493
+ categories = {'temperature': f'{degree_symbol}C', 'humidity': 'RH%'}
1494
+
1495
+ for category, units in categories.items():
1496
+ # measurement is in the following generic form:
1497
+ # {'PT100MK1-DC3D6': [21.25, 21.25, 21.26, 21.26, ..., 21.24],
1498
+ # 'PT100MK1-DC392': [20.9, 20.92, 20.92, 20.94, ..., 20.94]}
1499
+ measurement = ivtdat.extract_environmental_data(category, test_iv, outbound)
1500
+
1501
+ for sensor, readings in measurement.items():
1502
+ try:
1503
+ mean = stat.mean(readings)
1504
+ except stat.StatisticsError:
1505
+ continue
1506
+
1507
+ outliers = (r for r in readings if not math.isclose(r, mean,
1508
+ rel_tol=ten_percent,
1509
+ abs_tol=0.5))
1510
+ if any(outliers):
1511
+ message = (f'{dev.ident}, {sensor} ({category} {units}), '
1512
+ f'{tolerance_symbol}5% tolerance exceeded')
1513
+ common.log_with_colour(logging.WARNING, message)
1514
+
1515
+
1516
+ ##############################################################################
1517
+ # run test profile
1518
+ ##############################################################################
1519
+
1520
+ def get_iv_data_from_psu(dev, settings, pipeline, graceful_quit):
1521
+ """
1522
+ Apply the given range of bias voltages with given step size and read back
1523
+ the respective leakage currents.
1524
+
1525
+ Exit early if leakage current exceeds software current limit.
1526
+ --------------------------------------------------------------------------
1527
+ args
1528
+ dev : instance of class common.Channel()
1529
+ contains details of a device and its serial port
1530
+ settings : dictionary
1531
+ contains core information about the test environment
1532
+ pipeline : instance of class Production
1533
+ contains all the queues through which the production pipeline
1534
+ processes communicate
1535
+ graceful_quit : multiprocessing.Value(ctypes.c_bool, False)
1536
+ shared memory containing a single boolean value to indicate
1537
+ whether the user has requested the script terminate early
1538
+ --------------------------------------------------------------------------
1539
+ returns
1540
+ ivtdat : instance of class Packet
1541
+ contains data for a given power supply channel's IV and IT curves
1542
+ --------------------------------------------------------------------------
1543
+ """
1544
+ ivtdat = common.Packet(
1545
+ dev.manufacturer,
1546
+ dev.model,
1547
+ dev.serial_number,
1548
+ dev.channel,
1549
+ dev.ident,
1550
+ settings['itk_serno'],
1551
+ )
1552
+
1553
+ ##########################################################################
1554
+ # DEBUG - ignore power supplies, generate data internally
1555
+ ##########################################################################
1556
+ if settings['debug'] is not None:
1557
+ debug_generate_data(settings, ivtdat)
1558
+ return ivtdat
1559
+ ##########################################################################
1560
+
1561
+ with serial.Serial(port=dev.port) as ser:
1562
+ ser.apply_settings(dev.config)
1563
+
1564
+ if dev.model == '2410':
1565
+ command_string = lexicon.power(dev.model, 'clear event registers')
1566
+ common.send_command(pipeline, ser, dev, command_string)
1567
+
1568
+ # obtain starting voltage
1569
+ initial_voltage = read_psu_set_voltage(pipeline, ser, dev)
1570
+ if initial_voltage is not None:
1571
+ common.log_with_colour(logging.INFO,
1572
+ (f'{dev.ident}, initial psu voltage is '
1573
+ f'{common.si_prefix(initial_voltage)}V'))
1574
+
1575
+ ###################################################################
1576
+ # (1) gradually move from starting voltage to one step from zero
1577
+ target_voltage = 0
1578
+ start = True
1579
+ transition_voltage(settings, pipeline, initial_voltage,
1580
+ target_voltage, ser, dev, start)
1581
+
1582
+ # set to zero volts, configure range, set compliance etc...
1583
+ configure_psu(settings, pipeline, ser, dev)
1584
+
1585
+ ###################################################################
1586
+ common.log_with_colour(logging.WARNING,
1587
+ (f'{dev.ident}, during IV test phase, '
1588
+ 'press \'q\' then \'enter\' '
1589
+ 'to exit the test early'))
1590
+
1591
+ ###################################################################
1592
+ # (2) gather data for IV plot
1593
+ # (part 1) ramp-up IV test (typically 0V to -nV)
1594
+ proceed_with_testing, early_termination_voltage = \
1595
+ run_iv_test(settings, pipeline, ser, ivtdat, graceful_quit, dev)
1596
+
1597
+ if proceed_with_testing:
1598
+ # report environmental status only if variance is excessive
1599
+ report_environmental_issues(ivtdat, dev, test_iv=True, outbound=True)
1600
+
1601
+ # (part 2) hold for number of minutes specified
1602
+ hold_stability(settings, pipeline, graceful_quit, ser,
1603
+ read_psu_set_voltage(pipeline, ser, dev),
1604
+ ivtdat, dev)
1605
+
1606
+ # report environmental status only if variance is excessive
1607
+ report_environmental_issues(ivtdat, dev, test_iv=False)
1608
+
1609
+ if not settings['omitreturn']:
1610
+ # (part 3) ramp-down IV test (typically -nV to 0V)
1611
+ run_iv_test(settings, pipeline, ser, ivtdat, graceful_quit, dev,
1612
+ intercept=early_termination_voltage, reverse=True)
1613
+
1614
+ # report environmental status only if variance is excessive
1615
+ report_environmental_issues(ivtdat, dev, test_iv=True, outbound=False)
1616
+
1617
+ ###################################################################
1618
+ # (3) bring the power supply's voltage back to its initial state
1619
+ # if it's safe to do so, otherwise move to 0V
1620
+ if proceed_with_testing and settings['initial']:
1621
+ target_voltage = initial_voltage
1622
+ log_message = 'returning to initial psu voltage'
1623
+ else:
1624
+ target_voltage = 0
1625
+ log_message = 'moving to 0V'
1626
+
1627
+ voltage_after_iv = read_psu_set_voltage(pipeline, ser, dev)
1628
+ if voltage_after_iv != initial_voltage:
1629
+ if voltage_after_iv is not None:
1630
+ common.log_with_colour(logging.INFO, f'{dev.ident}, {log_message}')
1631
+ start = False
1632
+ transition_voltage(settings, pipeline, voltage_after_iv,
1633
+ target_voltage, ser, dev, start)
1634
+ else:
1635
+ if target_voltage == 0:
1636
+ log_message = log_message.replace('moving', 'move')
1637
+ else:
1638
+ log_message = log_message.replace('returning', 'return')
1639
+
1640
+ common.log_with_colour(logging.ERROR, f'{dev.ident}, cannot {log_message}')
1641
+ else:
1642
+ common.log_with_colour(logging.WARNING,
1643
+ f'{dev.ident}, cannot be read from, check output')
1644
+
1645
+ return ivtdat
1646
+
1647
+
1648
+ def hold_stability(settings, pipeline, graceful_quit, ser, voltage, ivtdat, dev):
1649
+ """
1650
+ Perform an IT test for the given duration.
1651
+
1652
+ FIXME : this function needs to use time.monotonic() for sleep delays only
1653
+
1654
+ --------------------------------------------------------------------------
1655
+ args
1656
+ settings : dictionary
1657
+ contains core information about the test environment
1658
+ pipeline : instance of class Production
1659
+ contains all the queues through which the production pipeline
1660
+ processes communicate
1661
+ graceful_quit : multiprocessing.Value(ctypes.c_bool, False)
1662
+ shared memory containing a single boolean value to indicate
1663
+ whether the user has requested the script terminate early
1664
+ ser : serial.Serial
1665
+ reference for serial port
1666
+ voltage : float
1667
+ ivtdat : instance of class Packet
1668
+ contains data for a given power supply channel's IV and IT curves
1669
+ dev : instance of class common.Channel()
1670
+ contains details of a device and its serial port
1671
+ --------------------------------------------------------------------------
1672
+ returns
1673
+ ivtdat : no explicit return, mutable type amended in place
1674
+ --------------------------------------------------------------------------
1675
+ """
1676
+ hold_minutes = settings['hold']
1677
+ if hold_minutes is None:
1678
+ return
1679
+
1680
+ indefinite = hold_minutes == 0
1681
+ sample_period_seconds = 5
1682
+
1683
+ ###########################################################################
1684
+ # display voltage hold (I-T) summary
1685
+ ###########################################################################
1686
+
1687
+ hold_sta_raw = datetime.datetime.now(datetime.UTC)
1688
+ hold_end_raw = hold_sta_raw + datetime.timedelta(minutes=hold_minutes)
1689
+ hold_end = hold_end_raw.isoformat().split('.')[0].replace('T', ' ')
1690
+
1691
+ if indefinite:
1692
+ tdesc = 'indefinitely'
1693
+ else:
1694
+ suffix = '' if hold_minutes == 1 else 's'
1695
+ tdesc = f'for {hold_minutes} minute{suffix} until {hold_end} UTC'
1696
+
1697
+ common.log_with_colour(
1698
+ logging.INFO,
1699
+ f'{dev.ident}, IT, holding at {voltage:.0f}V {tdesc}'
1700
+ )
1701
+
1702
+ ###########################################################################
1703
+
1704
+ ivtdat.hold_voltage = voltage
1705
+ timestamp_mono = time.monotonic()
1706
+ end_time_mono = timestamp_mono + hold_minutes * 60
1707
+
1708
+ while timestamp_mono < end_time_mono or indefinite:
1709
+ if graceful_quit.value:
1710
+ break
1711
+
1712
+ _, ipsu = common.read_psu_measured_vi(pipeline, ser, dev)
1713
+
1714
+ # add environmental data if available
1715
+ temp, humi = get_environmental_data(settings, pipeline)
1716
+
1717
+ ivtdat.hold_current.append(ipsu)
1718
+ ivtdat.hold_timestamp.append(time.time())
1719
+ ivtdat.hold_temperature.append(temp)
1720
+ ivtdat.hold_humidity.append(humi)
1721
+
1722
+ text = f'{dev.ident}, IT, {ipsu:>13.6e}, A'
1723
+ text = add_environmental_string(text, temp, '\u00b0C')
1724
+ common.log_with_colour(
1725
+ logging.INFO,
1726
+ add_environmental_string(text, humi, 'RH%')
1727
+ )
1728
+
1729
+ timestamp_mono = common.rate_limit(
1730
+ timestamp_mono, sample_period_seconds
1731
+ )
1732
+
1733
+
1734
+ def rate_limit_delay(settings, previous_voltage, next_voltage):
1735
+ """
1736
+ Supply a delay sufficient for the rate of change of voltage not to exceed
1737
+ 10V/s in standard configuration, or 2V/s using the ATLAS workaround.
1738
+
1739
+ In principle, this function allows the delay between small voltage steps
1740
+ (1V, 5V) to be shorter, reducing the time to perform the whole test, which
1741
+ has value when using iv.py and a supporting script to cycle the
1742
+ bias voltage between 0V and -1100V several hundred times.
1743
+
1744
+ However, since this script may be used with different power supplies with
1745
+ varying performance - to be cautious - a minimum settling time is enforced.
1746
+ If HV-cycling becomes common usage, it may be beneficial to write minimum
1747
+ settling times for each power supply type (derived from empirical testing)
1748
+ in the cache file written by detect.py, to take advantage of time savings.
1749
+
1750
+ Note that this is NOT the settling time between setting the voltage and
1751
+ reading back V/I. That is handled in common.set_psu_voltage_and_read().
1752
+
1753
+ --------------------------------------------------------------------------
1754
+ args
1755
+ settings : dictionary
1756
+ contains core information about the test environment
1757
+ previous_voltage : numeric
1758
+ next_voltage : numeric
1759
+ --------------------------------------------------------------------------
1760
+ returns
1761
+ duration : numeric
1762
+ value in seconds
1763
+ --------------------------------------------------------------------------
1764
+ """
1765
+ if settings['atlas']:
1766
+ duration = 5
1767
+ else:
1768
+ max_voltage_step = 10
1769
+
1770
+ try:
1771
+ delta_v = abs(next_voltage - previous_voltage)
1772
+ except TypeError:
1773
+ # previous_voltage is None
1774
+ delta_v = max_voltage_step
1775
+
1776
+ duration = delta_v / max_voltage_step
1777
+
1778
+ return duration
1779
+
1780
+
1781
+ def run_iv_test(settings, pipeline, ser, ivtdat, graceful_quit, dev,
1782
+ intercept=None, reverse=False):
1783
+ """
1784
+ Run IV test with the voltage sequence in the order given.
1785
+
1786
+ Notes on function time delays:
1787
+
1788
+ (1) Before the measurement: settling time. The time between setting the
1789
+ voltage and subsequently measuring the voltage and current.
1790
+
1791
+ (2) After the measurement: rate limit. An additional delay that serves to
1792
+ limit the average rate of change in volts per second.
1793
+
1794
+ --------------------------------------------------------------------------
1795
+ args
1796
+ settings : dictionary
1797
+ contains core information about the test environment
1798
+ pipeline : instance of class Production
1799
+ contains all the queues through which the production pipeline
1800
+ processes communicate
1801
+ ser : serial.Serial
1802
+ reference for serial port
1803
+ ivtdat : instance of class Packet
1804
+ contains data for a given power supply channel's IV and IT curves
1805
+ graceful_quit : multiprocessing.Value(ctypes.c_bool, False)
1806
+ shared memory containing a single boolean value to indicate
1807
+ whether the user has requested the script terminate early
1808
+ dev : instance of class common.Channel()
1809
+ contains details of a device and its serial port
1810
+ intercept : int or None
1811
+ if this function is being called a second time after the previous
1812
+ run terminated early because of a software current over-limit
1813
+ event, the voltage the previous run terminated at can be
1814
+ supplied here to avoid subjecting the chip to a potentially
1815
+ damaging large step voltage change
1816
+ reverse : boolean
1817
+ reverse number sequence if True, False otherwise
1818
+ --------------------------------------------------------------------------
1819
+ returns
1820
+ proceed_with_testing : boolean
1821
+ True when at least one voltage did not trigger a software current
1822
+ limit event, False otherwise
1823
+ last_successful_voltage : int or None
1824
+ None if test terminates normally, or int if not
1825
+ ivtdat : no explicit return, mutable type amended in place
1826
+ --------------------------------------------------------------------------
1827
+ """
1828
+ prv = pri = None
1829
+ buf = collections.deque([], 3)
1830
+
1831
+ start = 0
1832
+ stop = settings['voltage'] if intercept is None else intercept
1833
+ if reverse:
1834
+ # this is a ramp-down IV, manually mark the point where the ramp-down
1835
+ # data starts
1836
+ ivtdat.set_voltage.append('split')
1837
+ ivtdat.measured_current.append('split')
1838
+ ivtdat.sigma_current.append('split')
1839
+ ivtdat.measured_voltage.append('split')
1840
+ ivtdat.measured_timestamp.append('split')
1841
+ ivtdat.measured_temperature.append('split')
1842
+ ivtdat.measured_humidity.append('split')
1843
+ start, stop = stop, start
1844
+ suffix = '(return)'
1845
+ else:
1846
+ suffix = '(outbound)'
1847
+
1848
+ common.log_with_colour(logging.INFO, f'{dev.ident}, IV, running test {suffix}')
1849
+
1850
+ early_termination = False
1851
+ last_successful_voltage = None
1852
+ padding = len(str(settings['voltage']))
1853
+ variablestep = settings['stepsize'] is None
1854
+ for voltage in sequence.test_run(start, stop, settings['stepsize'],
1855
+ variablestep=variablestep,
1856
+ bespoke_sequence_lut=settings['bespoke_sequence_lut']):
1857
+ if graceful_quit.value:
1858
+ break
1859
+
1860
+ # set parameters for voltage/time rate limiting
1861
+ time_0 = time.monotonic()
1862
+ duration = rate_limit_delay(settings, last_successful_voltage, voltage)
1863
+
1864
+ # obtain readings from power supply
1865
+ # set voltage -> settling time -> read back values
1866
+ vpsu, ipsu = common.set_psu_voltage_and_read(
1867
+ settings, pipeline, voltage, ser, dev, settings['settling_time']
1868
+ )
1869
+
1870
+ if settings['settle']:
1871
+ vpsu, ipsu, sigma_current = settle(settings, pipeline, ser, vpsu, ipsu, dev)
1872
+ else:
1873
+ sigma_current = 0.0
1874
+
1875
+ if vpsu is None or ipsu is None:
1876
+ graceful_quit.value = True
1877
+ break
1878
+
1879
+ # send data packet to liveplot.py
1880
+ pipeline.liveplot.put([dev.ident, voltage, ipsu, reverse])
1881
+
1882
+ # display summary of power supply readings
1883
+ text = (f'{dev.ident}, IV, '
1884
+ f'{voltage:>{padding}d}, V, '
1885
+ f'{vpsu:>13.6e}' + ', V, '
1886
+ f'{ipsu:>13.6e}' + ', A')
1887
+
1888
+ # add environmental data if available
1889
+ temp, humi = get_environmental_data(settings, pipeline)
1890
+
1891
+ text = add_environmental_string(text, temp, '\u00b0C')
1892
+ common.log_with_colour(logging.INFO,
1893
+ add_environmental_string(text, humi, 'RH%'))
1894
+
1895
+ # current limit protection (absolute threshold)
1896
+ over_threshold = current_limit(settings, pipeline,
1897
+ ipsu, vpsu, voltage, ser, dev)
1898
+
1899
+ # rate of change of leakage current (breakdown detection)
1900
+ # only check when voltage is moving away from 0V
1901
+ if not reverse and not over_threshold:
1902
+ if prv is not None:
1903
+ try:
1904
+ gradient = abs((pri - ipsu) / (prv - vpsu))
1905
+ except ZeroDivisionError:
1906
+ gradient = 0
1907
+ common.log_with_colour(logging.INFO,
1908
+ (f'{dev.ident}, IV, '
1909
+ f'previous ({prv:.6e}V) and prevailing ({vpsu:.6e}V) '
1910
+ 'voltages are identical'))
1911
+
1912
+ if len(buf) == buf.maxlen and gradient > stat.mean(buf) * 20 and abs(ipsu) > 2e-09:
1913
+ common.log_with_colour(logging.INFO,
1914
+ (f'{dev.ident}, IV, '
1915
+ 'leakage current excessive rate of change'))
1916
+ buf.append(gradient)
1917
+ prv, pri = vpsu, ipsu
1918
+
1919
+ # store data in all cases
1920
+ ivtdat.set_voltage.append(voltage)
1921
+ ivtdat.measured_current.append(ipsu)
1922
+ ivtdat.sigma_current.append(sigma_current)
1923
+ ivtdat.measured_voltage.append(vpsu)
1924
+ ivtdat.measured_timestamp.append(time.time())
1925
+ ivtdat.measured_temperature.append(temp)
1926
+ ivtdat.measured_humidity.append(humi)
1927
+
1928
+ if not over_threshold and voltage != 0:
1929
+ # mark this as a safe voltage to quickly return to,
1930
+ # in case there is a problem at the next voltage
1931
+ last_successful_voltage = voltage
1932
+
1933
+ if not reverse and over_threshold:
1934
+ # one or more limits have been exceeded on the outbound IV
1935
+ # return to last safe voltage level and exit from this test early
1936
+ #
1937
+ # do not force exit for return IV, since the voltage is reducing
1938
+ # anyway
1939
+ early_termination = True
1940
+ if last_successful_voltage is not None:
1941
+ common.log_with_colour(logging.INFO,
1942
+ (f'{dev.ident}, IV, '
1943
+ 'returning to last safe voltage '
1944
+ f'({last_successful_voltage:d}V)'))
1945
+ common.set_psu_voltage(settings, pipeline, last_successful_voltage,
1946
+ ser, dev)
1947
+ break
1948
+
1949
+ # voltage/second rate limit
1950
+ tdif = time.monotonic() - time_0
1951
+ if tdif < duration:
1952
+ time.sleep(duration - tdif)
1953
+
1954
+ if (early_termination and last_successful_voltage is None) or graceful_quit.value:
1955
+ common.log_with_colour(logging.INFO, f'{dev.ident}, IV, halting further testing')
1956
+ proceed_with_testing = False
1957
+ else:
1958
+ proceed_with_testing = True
1959
+
1960
+ return proceed_with_testing, last_successful_voltage
1961
+
1962
+
1963
+ def settle(settings, pipeline, ser, initial_voltage, initial_current, dev):
1964
+ """
1965
+ This function averages successive readings to smooth noisy IV curves.
1966
+
1967
+ When used with large sample counts, it can also be used to obtain more
1968
+ reliable results from devices that require long settling times, where
1969
+ current values read shortly after a change of voltage are not likely to be
1970
+ representative of stable operating conditions.
1971
+
1972
+ Method:
1973
+
1974
+ Measured current and voltage values are read from the PSU in quick
1975
+ succession and loaded into a fixed-size N-element LIFO queue. Once the
1976
+ queue is full, a test is made to see if all contained values are within a
1977
+ given distance from their mean value. If the test passes, the function
1978
+ terminates early.
1979
+
1980
+ If the test fails, another current/voltage pair is loaded (discarding the
1981
+ oldest entry in the process) and the test is repeated. This process is
1982
+ repeated a user-specified number of times.
1983
+
1984
+ In both cases, the mean values of the voltages and currents contained in
1985
+ the queues are returned.
1986
+
1987
+ The function is tolerant to problems reading values from the PSU, though
1988
+ any such occurrence should be rare.
1989
+
1990
+ --------------------------------------------------------------------------
1991
+ args
1992
+ settings : dictionary
1993
+ contains core information about the test environment
1994
+ pipeline : instance of class Production
1995
+ contains all the queues through which the production pipeline
1996
+ processes communicate
1997
+ ser : serial.Serial
1998
+ reference for serial port
1999
+ initial_voltage : float
2000
+ initial_current : float
2001
+ dev : instance of class common.Channel()
2002
+ contains details of a device and its serial port
2003
+ --------------------------------------------------------------------------
2004
+ returns
2005
+ final_value : tuple
2006
+ (True, float, float) if satisfactory result found otherwise
2007
+ (False, None, None)
2008
+ --------------------------------------------------------------------------
2009
+ """
2010
+ # no need to clear these deques since stale values will be pushed out
2011
+ # before any calculations are performed
2012
+ dev.measured_voltages.append(initial_voltage)
2013
+ dev.measured_currents.append(initial_current)
2014
+
2015
+ rel_tol = settings['pc']
2016
+ max_samples = settings['samples']
2017
+ max_attempts = max_samples * 2
2018
+ samples_received = 1
2019
+ attempts = 1
2020
+
2021
+ while samples_received < max_samples and attempts < max_attempts:
2022
+ attempts += 1
2023
+
2024
+ volt, curr = common.read_psu_measured_vi(pipeline, ser, dev)
2025
+ if volt is None:
2026
+ continue
2027
+
2028
+ samples_received += 1
2029
+ dev.measured_voltages.append(volt)
2030
+ dev.measured_currents.append(curr)
2031
+
2032
+ if samples_received >= dev.window_size:
2033
+ meai = stat.mean(dev.measured_currents)
2034
+ similar = (math.isclose(i, meai, rel_tol=rel_tol) for i in dev.measured_currents)
2035
+ if all(similar):
2036
+ break
2037
+
2038
+ if samples_received >= dev.window_size:
2039
+ final_value = stat.mean(dev.measured_voltages), stat.mean(dev.measured_currents), stat.stdev(dev.measured_currents)
2040
+ else:
2041
+ final_value = None, None, None
2042
+ common.log_with_colour(logging.WARNING, f'{dev.ident}, underflow')
2043
+
2044
+ return final_value
2045
+
2046
+
2047
+ def transition_voltage(settings, pipeline, initial_voltage,
2048
+ target_voltage, ser, dev, start):
2049
+ """
2050
+ Handle voltage transitions (1) before commencement of IV testing, and (2)
2051
+ after.
2052
+
2053
+ --------------------------------------------------------------------------
2054
+ args
2055
+ settings : dictionary
2056
+ contains core information about the test environment
2057
+ pipeline : instance of class Production
2058
+ contains all the queues through which the production pipeline
2059
+ processes communicate
2060
+ initial_voltage : float
2061
+ set voltage at present
2062
+ target_voltage : float
2063
+ voltage to transition to
2064
+ ser : serial.Serial
2065
+ reference for serial port
2066
+ dev : instance of class common.Channel()
2067
+ contains details of a device and its serial port
2068
+ start : bool
2069
+ True if this is the initial voltage to 0V transition,
2070
+ or the end-of-test 0V to initial voltage transition
2071
+ --------------------------------------------------------------------------
2072
+ returns : none
2073
+ --------------------------------------------------------------------------
2074
+ """
2075
+ # don't perform transition if voltages are closer than 1mV
2076
+ if math.isclose(initial_voltage, target_voltage, abs_tol=0.001):
2077
+ return
2078
+
2079
+ message = (f'{dev.ident}, transitioning '
2080
+ f'from {common.si_prefix(initial_voltage)}V '
2081
+ f'to {common.si_prefix(target_voltage)}V')
2082
+ common.log_with_colour(logging.INFO, message)
2083
+
2084
+ if dev.manufacturer == 'keithley':
2085
+ number_sequence = sequence.to_start if start else sequence.to_original
2086
+ timestamp = None
2087
+ duration = 5 if settings['atlas'] else 1
2088
+ step = 10
2089
+
2090
+ for voltage in number_sequence(initial_voltage, target_voltage, step):
2091
+ timestamp = common.rate_limit(timestamp, duration)
2092
+ common.set_psu_voltage(settings, pipeline, voltage, ser, dev)
2093
+
2094
+ if start:
2095
+ time.sleep(duration)
2096
+
2097
+ elif dev.manufacturer == 'iseg':
2098
+ # use power supply's internal ramp feature to avoid having manage
2099
+ # the ISEG SHQ's tardy response time
2100
+
2101
+ # read voltage rate of change
2102
+ command_string = lexicon.power(dev.model,
2103
+ 'read max rate of change',
2104
+ channel=dev.channel)
2105
+ max_vroc = common.atomic_send_command_read_response(pipeline, ser, dev, command_string)
2106
+
2107
+ # set voltage rate of change for ramp
2108
+ volts_per_second = 2 if settings['atlas'] else 10
2109
+ command_string = lexicon.power(dev.model,
2110
+ 'set voltage max rate of change',
2111
+ volts_per_second,
2112
+ channel=dev.channel)
2113
+ common.atomic_send_command_read_response(pipeline, ser, dev, command_string)
2114
+
2115
+ # auto ramp will progress towards the given voltage at the given rate
2116
+ command_string = lexicon.power(dev.model,
2117
+ 'set voltage',
2118
+ target_voltage,
2119
+ channel=dev.channel)
2120
+ common.atomic_send_command_read_response(pipeline, ser, dev, command_string)
2121
+
2122
+ # wait to reach given voltage
2123
+ common.wait_for_voltage_to_stabilise(ser, pipeline, dev, target_voltage)
2124
+
2125
+ # revert voltage rate of change to original value
2126
+ command_string = lexicon.power(dev.model,
2127
+ 'set voltage max rate of change',
2128
+ max_vroc,
2129
+ channel=dev.channel)
2130
+ common.atomic_send_command_read_response(pipeline, ser, dev, command_string)
2131
+
2132
+ common.log_with_colour(logging.INFO, f'{dev.ident}, transition complete')
2133
+
2134
+
2135
+ ##############################################################################
2136
+ # save files
2137
+ ##############################################################################
2138
+
2139
+ def save_data(data, filename):
2140
+ """
2141
+ Write recorded data to mass storage.
2142
+
2143
+ --------------------------------------------------------------------------
2144
+ args
2145
+ data : instance of common.Consignment()
2146
+ contains data for the whole data acquisition session
2147
+ filename : string
2148
+ filename without extension
2149
+ --------------------------------------------------------------------------
2150
+ returns : none
2151
+ --------------------------------------------------------------------------
2152
+ """
2153
+ logging.info('Saving data')
2154
+
2155
+ # compressed python pickle format, for post-processing
2156
+ common.data_write(data, f'{filename}.pbz2')
2157
+
2158
+ # generic csv
2159
+ with open(f'{filename}.csv', 'w', encoding='utf-8') as csvfile:
2160
+ row = csv.writer(csvfile)
2161
+ common.write_consignment_csv(data, row)
2162
+
2163
+
2164
+ ##############################################################################
2165
+ # plot (power supply data)
2166
+ ##############################################################################
2167
+
2168
+ def create_plots(settings, data, filename, session):
2169
+ """
2170
+ Create and write plots to mass storage.
2171
+
2172
+ --------------------------------------------------------------------------
2173
+ args
2174
+ settings : dictionary
2175
+ contains core information about the test environment
2176
+ data : instance of common.Consignment()
2177
+ contains data for the whole data acquisition session
2178
+ filename : string
2179
+ filename without extension
2180
+ session : string
2181
+ date and time string, e.g. '20210719_143439'
2182
+ --------------------------------------------------------------------------
2183
+ returns : none
2184
+ --------------------------------------------------------------------------
2185
+ """
2186
+ logging.info('Creating plots')
2187
+
2188
+ # create directory for plots
2189
+ directory = f'{filename}_plots'
2190
+ if not os.path.exists(directory):
2191
+ os.makedirs(directory)
2192
+
2193
+ # create list of colours from a ten-entry colour map
2194
+ #
2195
+ # the blue colour at index 0 is used for the temperature y-axis in function
2196
+ # save_individual_stability_plots(), so omit this initial blue from the
2197
+ # palette used for plotting other curves
2198
+ #
2199
+ # see: https://matplotlib.org/3.1.1/gallery/color/colormap_reference.html
2200
+ colors = [plt.cm.tab10(x) for x in range(1, 10)]
2201
+
2202
+ # set matplotlib defaults
2203
+ matplotlib.rcParams.update({
2204
+ # remove chartjunk
2205
+ 'axes.spines.top': False,
2206
+ 'axes.spines.right': False,
2207
+ # fontsize of the x and y labels
2208
+ 'axes.labelsize': 'medium',
2209
+ # fontsize of the axes title
2210
+ 'axes.titlesize': 'medium',
2211
+ # fontsize of the tick labels
2212
+ 'xtick.labelsize': 'small',
2213
+ 'ytick.labelsize': 'small',
2214
+ # fontsize of plot-line labels
2215
+ 'legend.fontsize': 'xx-small'})
2216
+
2217
+ # plot all IV curves
2218
+ save_combined_iv_plot(settings, colors, data, directory, session)
2219
+ save_individual_iv_plots(settings, colors, data, directory, session)
2220
+
2221
+ # plot all leakage current stability curves
2222
+ save_combined_stability_plot(settings, colors, data, directory, session)
2223
+ save_individual_stability_plots(settings, colors, data, directory, session)
2224
+
2225
+ # plot environmental data for iv outbound, it, and iv return as appropriate
2226
+ save_environmental_plots(settings, data, directory, session)
2227
+
2228
+
2229
+ def save_combined_iv_plot(settings, colors, data, directory, session):
2230
+ """
2231
+ Create a single plot containing all the IV curves from all power supply
2232
+ channels.
2233
+
2234
+ --------------------------------------------------------------------------
2235
+ args
2236
+ settings : dictionary
2237
+ contains core information about the test environment
2238
+ colors : list
2239
+ matplotlib colour map
2240
+ data : instance of common.Consignment()
2241
+ contains data for the whole data acquisition session
2242
+ directory : string
2243
+ directory to write plot to
2244
+ session : string
2245
+ date and time string, e.g. '20210719_143439'
2246
+ --------------------------------------------------------------------------
2247
+ returns : none
2248
+ --------------------------------------------------------------------------
2249
+ """
2250
+ if len(data.packets) < 2:
2251
+ return
2252
+
2253
+ fig, axis = plt.subplots(1, 1)
2254
+
2255
+ for packet, color in zip(data.packets, itertools.cycle(colors)):
2256
+ outbound_v, return_v = common.list_split(packet.set_voltage)
2257
+ outbound_i, return_i = common.list_split(packet.measured_current)
2258
+ lt_out = '.-' if len(outbound_v) <= 30 else '-'
2259
+
2260
+ if return_v or return_i:
2261
+ # outbound and return curves
2262
+ lt_ret = '.--' if len(return_v) <= 30 else '--'
2263
+ axis.plot(outbound_v, outbound_i, lt_out,
2264
+ linewidth=0.5, markersize=1, color=color,
2265
+ label=f'{packet.ident} outbound')
2266
+ axis.plot(return_v, return_i, lt_ret,
2267
+ linewidth=0.5, markersize=1, color=color,
2268
+ label=f'{packet.ident} return')
2269
+ else:
2270
+ # outbound curve only
2271
+ axis.plot(outbound_v, outbound_i, '-',
2272
+ linewidth=0.5, markersize=1, color=color,
2273
+ label=packet.ident)
2274
+
2275
+ if len(data.packets) <= 12:
2276
+ axis.legend()
2277
+
2278
+ if not data.forwardbias:
2279
+ axis.invert_xaxis()
2280
+ axis.invert_yaxis()
2281
+
2282
+ axis.set_xlabel('bias voltage (V)')
2283
+ axis.set_ylabel('leakage current (A)')
2284
+
2285
+ title_items = [
2286
+ 'IV',
2287
+ data.label,
2288
+ ', '.join(x for x in [pathlib.Path(session).name, settings['itk_serno']] if x),
2289
+ ]
2290
+ plot_title = '\n'.join(x for x in title_items if x)
2291
+ axis.set_title(plot_title)
2292
+
2293
+ axis.yaxis.set_major_formatter(EngFormatter(places=1))
2294
+ axis.xaxis.set_major_formatter(EngFormatter(places=0))
2295
+
2296
+ plt.tight_layout()
2297
+ common.save_plot(settings, os.path.join(directory, 'iv_all'))
2298
+
2299
+ plt.close(fig)
2300
+
2301
+
2302
+ def save_individual_iv_plots(settings, colors, data, directory, session):
2303
+ """
2304
+ Create a separate plot for each individual power supply channel.
2305
+ each plot contains outbound and return IV curves.
2306
+
2307
+ --------------------------------------------------------------------------
2308
+ args
2309
+ settings : dictionary
2310
+ contains core information about the test environment
2311
+ colors : list
2312
+ matplotlib colour map
2313
+ data : instance of common.Consignment()
2314
+ contains data for the whole data acquisition session
2315
+ directory : string
2316
+ directory to write plot to
2317
+ session : string
2318
+ date and time string, e.g. '20210719_143439'
2319
+ --------------------------------------------------------------------------
2320
+ returns : none
2321
+ --------------------------------------------------------------------------
2322
+ """
2323
+ for packet, color in zip(data.packets, itertools.cycle(colors)):
2324
+ fig, axis = plt.subplots(1, 1)
2325
+
2326
+ outbound_v, return_v = common.list_split(packet.set_voltage)
2327
+ outbound_i, return_i = common.list_split(packet.measured_current)
2328
+ lt_out = '.-' if len(outbound_v) <= 30 else '-'
2329
+
2330
+ if return_v or return_i:
2331
+ # outbound and return curves
2332
+ lt_ret = '.--' if len(return_v) <= 30 else '--'
2333
+ axis.plot(outbound_v, outbound_i, lt_out,
2334
+ linewidth=0.5, markersize=1, color=color,
2335
+ label='outbound')
2336
+ axis.plot(return_v, return_i, lt_ret,
2337
+ linewidth=0.5, markersize=1, color=color,
2338
+ label='return')
2339
+ else:
2340
+ # outbound curve only
2341
+ axis.plot(outbound_v, outbound_i, lt_out,
2342
+ linewidth=0.5, markersize=1, color=color)
2343
+
2344
+ if len(return_v) > 1:
2345
+ axis.legend()
2346
+
2347
+ if not data.forwardbias:
2348
+ axis.invert_xaxis()
2349
+ axis.invert_yaxis()
2350
+
2351
+ axis.set_xlabel('bias voltage (V)')
2352
+ axis.set_ylabel('leakage current (A)')
2353
+
2354
+ title_items = [
2355
+ f'IV, {packet.ident}',
2356
+ data.label,
2357
+ ', '.join(x for x in [pathlib.Path(session).name, settings['itk_serno']] if x),
2358
+ ]
2359
+ plot_title = '\n'.join(x for x in title_items if x)
2360
+ axis.set_title(plot_title)
2361
+
2362
+ axis.yaxis.set_major_formatter(EngFormatter(places=1))
2363
+ axis.xaxis.set_major_formatter(EngFormatter(places=0))
2364
+
2365
+ plt.tight_layout()
2366
+
2367
+ # replace spaces so files are easier to work with on the command line
2368
+ safe = packet.ident.replace(' ', '_')
2369
+ common.save_plot(settings, os.path.join(directory, f'iv_{safe}'))
2370
+
2371
+ plt.close(fig)
2372
+
2373
+
2374
+ def save_combined_stability_plot(settings, colors, data, directory, session):
2375
+ """
2376
+ Create a single plot containing all the IT curves from all power supply
2377
+ channels.
2378
+
2379
+ --------------------------------------------------------------------------
2380
+ args
2381
+ settings : dictionary
2382
+ contains core information about the test environment
2383
+ colors : list
2384
+ matplotlib colour map
2385
+ data : instance of common.Consignment()
2386
+ contains data for the whole data acquisition session
2387
+ directory : string
2388
+ directory to write plot to
2389
+ session : string
2390
+ date and time string, e.g. '20210719_143439'
2391
+ --------------------------------------------------------------------------
2392
+ returns : none
2393
+ --------------------------------------------------------------------------
2394
+ """
2395
+ test_not_requested = data.hold is None
2396
+ insufficient_packets = len(data.packets) < 2
2397
+ some_data_missing = not all(packet.hold_voltage for packet in data.packets)
2398
+ if test_not_requested or insufficient_packets or some_data_missing:
2399
+ return
2400
+
2401
+ fig, axis = plt.subplots(1, 1)
2402
+
2403
+ for packet, color in zip(data.packets, itertools.cycle(colors)):
2404
+ initial_timestamp = min(packet.hold_timestamp)
2405
+ itt = [(x - initial_timestamp) / 60 for x in packet.hold_timestamp]
2406
+ linetype = '.-' if len(packet.hold_timestamp) <= 30 else '-'
2407
+ axis.plot(itt, packet.hold_current, linetype,
2408
+ label=packet.ident, linewidth=0.5, markersize=1, color=color)
2409
+
2410
+ axis.yaxis.set_major_formatter(EngFormatter(places=3))
2411
+ axis.invert_yaxis()
2412
+ axis.set_ylabel('leakage current (A)')
2413
+ axis.set_xlabel('time (minutes)')
2414
+ axis.legend()
2415
+
2416
+ # title of plot
2417
+ title_items = [
2418
+ f'IT, {data.packets[0].hold_voltage:.0f}V',
2419
+ data.label,
2420
+ ', '.join(x for x in [pathlib.Path(session).name, settings['itk_serno']] if x),
2421
+ ]
2422
+ plot_title = '\n'.join(x for x in title_items if x)
2423
+ axis.set_title(plot_title)
2424
+
2425
+ plt.tight_layout()
2426
+ common.save_plot(settings, os.path.join(directory, 'it_all'))
2427
+
2428
+ plt.close(fig)
2429
+
2430
+
2431
+ def save_individual_stability_plots(settings, colors, data, directory, session):
2432
+ """
2433
+ Create a separate plot for each individual power supply channel.
2434
+ each plot contains outbound and return IT curves.
2435
+
2436
+ --------------------------------------------------------------------------
2437
+ args
2438
+ settings : dictionary
2439
+ contains core information about the test environment
2440
+ colors : list
2441
+ matplotlib colour map
2442
+ data : instance of common.Consignment()
2443
+ contains data for the whole data acquisition session
2444
+ directory : string
2445
+ directory to write plot to
2446
+ session : string
2447
+ date and time string, e.g. '20210719_143439'
2448
+ --------------------------------------------------------------------------
2449
+ returns : none
2450
+ --------------------------------------------------------------------------
2451
+ """
2452
+ if data.hold is None or not all(packet.hold_voltage for packet in data.packets):
2453
+ return
2454
+
2455
+ for packet, color in zip(data.packets, itertools.cycle(colors)):
2456
+ fig, axis = plt.subplots(1, 1)
2457
+
2458
+ initial_timestamp = min(packet.hold_timestamp)
2459
+ itt = [(x - initial_timestamp) / 60 for x in packet.hold_timestamp]
2460
+ linetype = '.-' if len(packet.hold_timestamp) <= 30 else '-'
2461
+ axis.plot(itt, packet.hold_current,
2462
+ linetype, linewidth=0.5, markersize=1, color=color)
2463
+
2464
+ axis.yaxis.set_major_formatter(EngFormatter(places=3))
2465
+ axis.invert_yaxis()
2466
+ axis.set_ylabel('leakage current (A)')
2467
+ axis.set_xlabel('time (minutes)')
2468
+
2469
+ # title of plot
2470
+ title_items = [
2471
+ f'IT, {packet.hold_voltage:.0f}V, {packet.ident}',
2472
+ data.label,
2473
+ ', '.join(x for x in [pathlib.Path(session).name, settings['itk_serno']] if x),
2474
+ ]
2475
+ plot_title = '\n'.join(x for x in title_items if x)
2476
+ axis.set_title(plot_title)
2477
+
2478
+ plt.tight_layout()
2479
+
2480
+ # replace spaces so files are easier to work with on the command line
2481
+ safe = packet.ident.replace(' ', '_')
2482
+ common.save_plot(settings, os.path.join(directory, f'it_{safe}'))
2483
+
2484
+ plt.close(fig)
2485
+
2486
+
2487
+ ##############################################################################
2488
+ # plot (environmental data)
2489
+ ##############################################################################
2490
+
2491
+ def save_environmental_plots(settings, data, directory, session):
2492
+ """
2493
+ Plot environmental data for each power supply channel on the same plot for
2494
+ iv outbound, it, and iv return.
2495
+
2496
+ --------------------------------------------------------------------------
2497
+ args
2498
+ settings : dictionary
2499
+ contains core information about the test environment
2500
+ data : instance of common.Consignment()
2501
+ contains data for the whole data acquisition session
2502
+ directory : string
2503
+ directory to write plot to
2504
+ session : string
2505
+ date and time string, e.g. '20210719_143439'
2506
+ --------------------------------------------------------------------------
2507
+ returns : none
2508
+ --------------------------------------------------------------------------
2509
+ """
2510
+ # three plots: iv outbound, it, iv return
2511
+ options = [
2512
+ (True, True, 'IV outbound'),
2513
+ (False, True, 'IT'),
2514
+ (True, False, 'IV return')]
2515
+
2516
+ # data.packets may contain multiple PSU channels
2517
+ for packet in data.packets:
2518
+ for test_iv, outbound, title in options:
2519
+
2520
+ # Extract data for this individual plot. it_y1 and it_y1 are dicts
2521
+ # and may contain data for multiple sensors
2522
+ it_x = packet.extract_timestamp_data(test_iv, outbound)
2523
+ it_y1 = packet.extract_environmental_data('temperature', test_iv, outbound)
2524
+ it_y2 = packet.extract_environmental_data('humidity', test_iv, outbound)
2525
+
2526
+ # assemble axes to plot
2527
+ y_axes = {'temperature (°C)': it_y1, 'humidity (RH%)': it_y2}
2528
+ y_axes_to_plot = {k: v for k, v in y_axes.items() if v}
2529
+ if len(y_axes_to_plot) > 1:
2530
+ # set matplotlib defaults
2531
+ matplotlib.rcParams.update({'axes.spines.right': True})
2532
+
2533
+ # omit plots with incomplete data
2534
+ no_x_axis = not it_x
2535
+ no_y_axis = not y_axes_to_plot
2536
+ if no_x_axis or no_y_axis:
2537
+ continue
2538
+
2539
+ # adjust timestamps so zero is the start of the experiment, and
2540
+ # they are scaled for readability.
2541
+ it_x, units = scale_timestamps(it_x)
2542
+
2543
+ # plot
2544
+ fig, host = plt.subplots(figsize=(8, 4))
2545
+ fig.subplots_adjust(right=0.75)
2546
+ lwi = 0.4
2547
+ msi = 0.6
2548
+ tkw = {'size': 4, 'width': 1.5}
2549
+
2550
+ # y-axes for temperature, humidity or both?
2551
+ for index, (axis_title, axis_data) in enumerate(y_axes_to_plot.items()):
2552
+
2553
+ if index == 0:
2554
+ color = 'g'
2555
+ for sensor_name, sensor_data in axis_data.items():
2556
+
2557
+ # handle any missing initial sensor data
2558
+ it_x_local = it_x[-len(sensor_data):]
2559
+
2560
+ host.step(it_x_local, sensor_data, color=color, where='post',
2561
+ linewidth=lwi, markersize=msi, label=sensor_name)
2562
+
2563
+ host.yaxis.label.set_color(color)
2564
+ host.tick_params(axis='y', colors=color, **tkw)
2565
+ host.set_ylabel(axis_title)
2566
+ else:
2567
+ color = 'r'
2568
+ par2 = host.twinx()
2569
+ for sensor_name, sensor_data in axis_data.items():
2570
+
2571
+ # handle any missing initial sensor data
2572
+ it_x_local = it_x[-len(sensor_data):]
2573
+
2574
+ par2.step(it_x_local, sensor_data, color=color, where='post',
2575
+ linewidth=lwi, markersize=msi, label=sensor_name)
2576
+
2577
+ par2.yaxis.label.set_color(color)
2578
+ par2.tick_params(axis='y', colors=color, **tkw)
2579
+ par2.set_ylabel(axis_title)
2580
+
2581
+ host.set_xlabel(f'elapsed time ({units})')
2582
+
2583
+ host.tick_params(axis='x', **tkw)
2584
+
2585
+ title_items = [f'{title} environment, {session}', data.label, settings['itk_serno']]
2586
+ plot_title = '\n'.join(x for x in title_items if x)
2587
+ host.set_title(plot_title)
2588
+
2589
+ plt.tight_layout()
2590
+
2591
+ filename = f'{title}_env'.lower().replace(' ', '_')
2592
+ common.save_plot(settings, os.path.join(directory, filename))
2593
+
2594
+
2595
+ ##############################################################################
2596
+ # main
2597
+ ##############################################################################
2598
+
2599
+ def main():
2600
+ """
2601
+ Collects IV and IT data from high-voltage power supplies over RS232.
2602
+ """
2603
+ # date and time to the nearest second when the script was started
2604
+ session = common.timestamp_to_utc(time.time())
2605
+
2606
+ # ensure all files are contained in a directory
2607
+ if not os.path.exists(session):
2608
+ os.makedirs(session)
2609
+ session = os.path.join(session, session)
2610
+
2611
+ settings = {
2612
+ 'alias': None,
2613
+ 'atlas': False,
2614
+ 'bespoke_sequence_lut': None,
2615
+ 'current_limit': None,
2616
+ 'debug': None,
2617
+ 'decimal_places': 1,
2618
+ 'forwardbias': False,
2619
+ 'hold': None,
2620
+ 'humidity_sensors': None,
2621
+ 'ignore': None,
2622
+ 'initial': False,
2623
+ 'itk_serno': '',
2624
+ 'json': False,
2625
+ 'label': None,
2626
+ 'omitreturn': False,
2627
+ 'pc': 0.05,
2628
+ 'rear': None,
2629
+ 'reset': None,
2630
+ 'samples': 10,
2631
+ 'sense': None,
2632
+ 'settle': False,
2633
+ # default is set in the command line handler
2634
+ 'settling_time': None,
2635
+ 'stepsize': None,
2636
+ 'svg': False,
2637
+ 'temperature_ip': '192.168.0.200',
2638
+ 'temperature_sensors': None,
2639
+ 'voltage': 0,
2640
+ }
2641
+
2642
+ ##########################################################################
2643
+ # read command line arguments
2644
+ ##########################################################################
2645
+
2646
+ check_arguments(settings)
2647
+
2648
+ ##########################################################################
2649
+ # establish which power supply channels to use, or generate some fake
2650
+ # channels if the user has requested debug
2651
+ ##########################################################################
2652
+
2653
+ if settings['debug'] is not None:
2654
+ # create <n> number of power supplies on unique ports, to match the
2655
+ # value given in --debug <n>
2656
+ psus = None
2657
+ channels = []
2658
+ for serial_number in range(settings['debug']):
2659
+ port = f'debug_port_{serial_number}'
2660
+ serno = str(1234567 + serial_number)
2661
+
2662
+ channels.append(common.Channel(port=port, config=None,
2663
+ serial_number=serno, model='debug',
2664
+ manufacturer='debug', channel=[],
2665
+ category='debug', release_delay=None,
2666
+ alias=None))
2667
+ else:
2668
+ # read all high-voltage power supplies from cache
2669
+ psus = common.cache_read(['hvpsu'])
2670
+
2671
+ # convert the serial port centric cache entries to power supply
2672
+ # channels, and remove any channels the user doesn't want to use
2673
+ all_channels = common.ports_to_channels(settings, psus)
2674
+ channels = common.exclude_channels(settings, all_channels)
2675
+
2676
+ if not channels:
2677
+ sys.exit('no devices to test')
2678
+
2679
+ ##########################################################################
2680
+ # zeromq simple request-reply for interaction with external scripts
2681
+ ##########################################################################
2682
+
2683
+ context = zmq.Context(io_threads=2)
2684
+
2685
+ # liveplot.py interaction
2686
+ zmq_skt = context.socket(zmq.REQ)
2687
+ # zmq.RCVTIMEO and zmq.SNDTIMEO specified in units of milliseconds
2688
+ zmq_skt.setsockopt(zmq.RCVTIMEO, 200)
2689
+ zmq_skt.setsockopt(zmq.SNDTIMEO, 200)
2690
+ zmq_skt.setsockopt(zmq.DELAY_ATTACH_ON_CONNECT, 1)
2691
+ port = 5555
2692
+ try:
2693
+ zmq_skt.connect(f'tcp://localhost:{port}')
2694
+ except zmq.error.ZMQError as zerr:
2695
+ message = f'ZeroMQ: {zerr} when connecting to port {port}'
2696
+ common.log_with_colour(logging.WARNING, message)
2697
+ message = 'ZeroMQ: find PID of current owner with: sudo netstat -ltnp'
2698
+ common.log_with_colour(logging.WARNING, message)
2699
+
2700
+ # sense.py interaction
2701
+ zmq_skt2 = context.socket(zmq.REP)
2702
+
2703
+ ##########################################################################
2704
+ # enable logging to file and screen
2705
+ ##########################################################################
2706
+
2707
+ log = f'{session}.log'
2708
+ logging.basicConfig(
2709
+ filename=log,
2710
+ level=logging.DEBUG,
2711
+ format='%(asctime)s : %(levelname)s : %(message)s')
2712
+ logging.getLogger('matplotlib').setLevel(logging.WARNING)
2713
+
2714
+ # enable logging to screen
2715
+ console = logging.StreamHandler()
2716
+ console.setLevel(logging.INFO)
2717
+ formatter = logging.Formatter('%(levelname)s : %(message)s')
2718
+ console.setFormatter(formatter)
2719
+ logging.getLogger('').addHandler(console)
2720
+
2721
+ # set logging time to UTC to match session timestamp
2722
+ logging.Formatter.converter = time.gmtime
2723
+
2724
+ ##########################################################################
2725
+ # set up resources for threads
2726
+ ##########################################################################
2727
+
2728
+ graceful_quit = mp.Value(ctypes.c_bool, False)
2729
+
2730
+ class Production:
2731
+ """
2732
+ Queues and locks to support threaded operation.
2733
+
2734
+ RS232 will not tolerate concurrent access. portaccess is used to
2735
+ prevent more than one thread trying to interact with the same RS232
2736
+ port at the same time for multi-channel power supplies. For
2737
+ simplicity, locks are created for all power supplies, even if they
2738
+ only have a single channel.
2739
+
2740
+ The Python Yoctopuce API is not thread safe.
2741
+ """
2742
+ keypress_queue = mp.Queue()
2743
+ liveplot = mp.Queue()
2744
+ # maxsize is set to 1 to ensure the environmental data available to
2745
+ # this script is the most recently acquired from sense.py. We don't
2746
+ # care about older data acquisitions.
2747
+ sense = mp.Queue(maxsize=1)
2748
+ sense_dict = mp.Manager().dict()
2749
+ portaccess = {port: threading.Lock()
2750
+ for port in {channel.port for channel in channels}}
2751
+ yapiaccess = threading.Lock()
2752
+
2753
+ pipeline = Production()
2754
+
2755
+ # input_thread: keypress detection for early test termination
2756
+ input_thread = threading.Thread(target=check_key, daemon=True,
2757
+ args=(graceful_quit, ))
2758
+ input_thread.start()
2759
+
2760
+ # livp: collate sampled data points and send them to an external plotter
2761
+ livp = threading.Thread(target=liveplot, args=(pipeline, zmq_skt))
2762
+ livp.start()
2763
+
2764
+ # sens: receive environmental data from sense.py
2765
+ sens = threading.Thread(target=sense, daemon=True, args=(pipeline, zmq_skt2, settings))
2766
+ sens.start()
2767
+
2768
+ ##########################################################################
2769
+ # Commit command line invocation and core environment to log
2770
+ ##########################################################################
2771
+
2772
+ write_debug_information_to_log()
2773
+
2774
+ ##########################################################################
2775
+ # Check status of outputs and interlock (inhibit) on all power supplies
2776
+ ##########################################################################
2777
+
2778
+ common.initial_power_supply_check(settings, pipeline, psus, channels)
2779
+
2780
+ ##########################################################################
2781
+ # detect presence of temperature and humidity sensors
2782
+ ##########################################################################
2783
+
2784
+ if not settings['debug']:
2785
+ if not settings['sense']:
2786
+ detect_yoctopuce_sensors(settings)
2787
+ else:
2788
+ message = 'direct access to Yoctopuce sensors disabled'
2789
+ common.log_with_colour(logging.INFO, message)
2790
+ message = ('listening for environmental data from '
2791
+ f'sensor: {settings["sense"]}')
2792
+ common.log_with_colour(logging.INFO, message)
2793
+
2794
+ # sense.py typically transmits data every 5 seconds. There's a
2795
+ # chance that if it and this script are started at the same time,
2796
+ # when this script records its first data point, no environmental data
2797
+ # will have been received yet. Provide a basic mitigation for this.
2798
+ for _ in itertools.repeat(None, 5):
2799
+ if pipeline.sense_dict:
2800
+ break
2801
+ time.sleep(1)
2802
+ else:
2803
+ message = f'no data matching {settings["sense"]} being received from sense.py'
2804
+ common.log_with_colour(logging.WARNING, message)
2805
+
2806
+ ##########################################################################
2807
+ # run iv test on all power supply channels concurrently
2808
+ ##########################################################################
2809
+
2810
+ pipeline.liveplot.put('reset')
2811
+
2812
+ if channels:
2813
+ common.log_with_colour(logging.INFO, 'Collecting IV data')
2814
+
2815
+ environmental_data_present = bool(settings['temperature_sensors'])\
2816
+ or bool(settings['humidity_sensors'])
2817
+ consignment = common.Consignment(settings['label'], settings['alias'],
2818
+ settings['forwardbias'], settings['hold'],
2819
+ environmental_data_present)
2820
+
2821
+ _gidfp_pf = functools.partial(get_iv_data_from_psu, settings=settings,
2822
+ pipeline=pipeline, graceful_quit=graceful_quit)
2823
+ with cf.ThreadPoolExecutor() as executor:
2824
+ board_iv = (executor.submit(_gidfp_pf, channel) for channel in channels)
2825
+ for future in cf.as_completed(board_iv):
2826
+ consignment.packets.append(future.result())
2827
+
2828
+ ##########################################################################
2829
+ # release resources for YoctoPuce API and threads
2830
+ ##########################################################################
2831
+
2832
+ if not settings['debug'] and not settings['sense']:
2833
+ yapi.YAPI.FreeAPI()
2834
+
2835
+ # terminate thread for sending data to external plotter
2836
+ pipeline.liveplot.put(None)
2837
+ livp.join()
2838
+
2839
+ ##########################################################################
2840
+ # save data and generate plots
2841
+ ##########################################################################
2842
+
2843
+ if not channels:
2844
+ return
2845
+
2846
+ consignment.remove_bad_packets()
2847
+
2848
+ # ensure data is arranged in serial number order
2849
+ # this keeps the line colours of tested chips consistent across plots
2850
+ consignment.packets.sort()
2851
+
2852
+ # add the user's label to the base filename
2853
+ filename = session
2854
+ if consignment.safe_label:
2855
+ filename = f'{session}_{consignment.safe_label}'
2856
+
2857
+ if settings['json']:
2858
+ consignment.write_json_files(session)
2859
+
2860
+ save_data(consignment, filename)
2861
+ create_plots(settings, consignment, filename, session)
2862
+
2863
+ logging.info('Finished')
2864
+
2865
+
2866
+ ##############################################################################
2867
+ if __name__ == '__main__':
2868
+ main()