mmcb-rs232-avt 1.0.13__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/common.py ADDED
@@ -0,0 +1,2605 @@
1
+ """
2
+ Common functions
3
+
4
+ callable functions from this file:
5
+
6
+ atomic_send_command_read_response
7
+ cache_read
8
+ check_current
9
+ check_file_exists
10
+ check_ports_accessible
11
+ data_read
12
+ data_write
13
+ decimal_quantize
14
+ dew_point
15
+ exclude_channels
16
+ initial_power_supply_check
17
+ interpret_numeric
18
+ iseg_value_to_float
19
+ list_split
20
+ log_with_colour
21
+ missing_ports
22
+ ports_to_channels
23
+ rate_limit
24
+ read_aliases
25
+ read_psu_measured_vi
26
+ report_output_status
27
+ round_safely
28
+ rs232_port_is_valid
29
+ save_plot
30
+ send_command
31
+ set_psu_voltage
32
+ set_psu_voltage_and_read
33
+ si_prefix
34
+ stage_speed
35
+ synchronise_psu
36
+ timestamp_to_utc
37
+ time_axis_adjustment
38
+ unique
39
+ wait_for_voltage_to_stabilise
40
+ write_consignment_csv
41
+
42
+ data structures:
43
+
44
+ ANSIColours
45
+ Channel
46
+ Consignment
47
+ DEVICE_CACHE
48
+ Packet
49
+ UNIT
50
+ """
51
+
52
+ import argparse
53
+ import bz2
54
+ import collections
55
+ import contextlib
56
+ import copy
57
+ import datetime
58
+ import decimal
59
+ import itertools
60
+ import json
61
+ import logging
62
+ import os
63
+ import pickle
64
+ import re
65
+ import sys
66
+ import time
67
+ import types
68
+
69
+ import matplotlib
70
+ # agg is used only for writing plots to files, not to the window manager
71
+ # this option is set to avoid problems running scripts on remote hosts
72
+ # over ssh, matplotlib.use must be called in this exact position
73
+ matplotlib.use('agg')
74
+ import matplotlib.pyplot as plt
75
+ import numpy as np
76
+ import serial
77
+
78
+ from mmcb import lexicon
79
+
80
+
81
+ ##############################################################################
82
+ # data structures
83
+ ##############################################################################
84
+
85
+
86
+ DEVICE_CACHE = os.path.expanduser('~/.cache.json')
87
+
88
+
89
+ UNIT = types.SimpleNamespace(
90
+ calculated_dew_point='DP\u00b0C',
91
+ flow_rate='lps',
92
+ pressure='hPa',
93
+ relative_humidity='RH%',
94
+ strain='arbitrary',
95
+ temperature='\u00b0C',
96
+ vacuum='kPa',
97
+ )
98
+
99
+
100
+ class ANSIColours:
101
+ """
102
+ ANSI 3/4 bit terminal escape sequences
103
+
104
+ https://en.wikipedia.org/wiki/ANSI_escape_code
105
+ """
106
+ # foreground colours
107
+ FG_BRIGHT_BLACK = '\033[90m'
108
+ FG_BRIGHT_RED = '\033[91m'
109
+ FG_BRIGHT_GREEN = '\033[92m'
110
+ FG_BRIGHT_YELLOW = '\033[93m'
111
+ FG_BRIGHT_BLUE = '\033[94m'
112
+ FG_BRIGHT_MAGENTA = '\033[95m'
113
+ FG_BRIGHT_CYAN = '\033[96m'
114
+ FG_BRIGHT_WHITE = '\033[97m'
115
+
116
+ FG_BLACK = '\033[30m'
117
+ FG_RED = '\033[31m'
118
+ FG_GREEN = '\033[32m'
119
+ FG_YELLOW = '\033[33m'
120
+ FG_BLUE = '\033[34m'
121
+ FG_CYAN = '\033[35m'
122
+ FG_MAGENTA = '\033[36m'
123
+ FG_WHITE = '\033[37m'
124
+
125
+ # background colours
126
+ BG_BRIGHT_BLACK = '\033[100m'
127
+ BG_BRIGHT_RED = '\033[101m'
128
+ BG_BRIGHT_GREEN = '\033[102m'
129
+ BG_BRIGHT_YELLOW = '\033[103m'
130
+ BG_BRIGHT_BLUE = '\033[104m'
131
+ BG_BRIGHT_MAGENTA = '\033[105m'
132
+ BG_BRIGHT_CYAN = '\033[106m'
133
+ BG_BRIGHT_WHITE = '\033[107m'
134
+
135
+ BG_BLACK = '\033[40m'
136
+ BG_RED = '\033[41m'
137
+ BG_GREEN = '\033[42m'
138
+ BG_YELLOW = '\033[43m'
139
+ BG_BLUE = '\033[44m'
140
+ BG_MAGENTA = '\033[45m'
141
+ BG_CYAN = '\033[46m'
142
+ BG_WHITE = '\033[47m'
143
+
144
+ # attributes
145
+ ENDC = '\033[0m'
146
+ BOLD = '\033[1m'
147
+ BLINK = '\033[5m'
148
+ UNDERLINE = '\033[4m'
149
+
150
+
151
+ class Packet:
152
+ """
153
+ This class holds all data acquired from a single power supply channel
154
+ during a single run of iv.py.
155
+
156
+ The IV and IT data may represent one of the following three scenarios:
157
+
158
+ * A single outbound IV
159
+ * A single outbound IV and IT
160
+ * An outbound IV, IT, and return IV
161
+ """
162
+ __slots__ = {
163
+ 'manufacturer': 'The manufacturer of the power supply.',
164
+ 'model': 'The model name of the power supply.',
165
+ 'serial_number': 'The serial number of the power supply.',
166
+ 'channel': 'Which power supply channel was data acquired from.',
167
+ 'ident': 'hardware identifier string or user supplied alias',
168
+ 'set_voltage': 'IV data: list of set voltages.',
169
+ 'measured_current': 'IV data: list of measured leakage currents at each set voltage',
170
+ 'sigma_current': 'Standard deviation of each measured leakage current figure.',
171
+ 'measured_voltage': 'IV data: list of measured bias voltages at each set voltage',
172
+ 'measured_timestamp': 'IV data: timestamp of each data acquisition',
173
+ 'measured_temperature': 'IV data: temperature (°C) at each data acquisition',
174
+ 'measured_humidity': 'IV data: humidity (RH%) at each data acquisition',
175
+ 'hold_voltage': 'IT data: constant set voltage for IT test',
176
+ 'hold_current': 'IT data: list of measured leakage currents at each set voltage',
177
+ 'hold_timestamp': 'IT data: timestamp of each data acquisition',
178
+ 'hold_temperature': 'IT data: temperature (°C) at each data acquisition',
179
+ 'hold_humidity': 'IT data: humidity (RH%) at each data acquisition',
180
+ 'itk_serno': 'ATLAS Itkpix module serial number IF SUPPLIED'}
181
+
182
+ def __init__(self, manufacturer, model, serial_number, channel, ident, itk_serno):
183
+ self.manufacturer = manufacturer
184
+ self.model = model
185
+ self.serial_number = serial_number
186
+ self.channel = channel
187
+ self.ident = ident
188
+ self.itk_serno = itk_serno
189
+ self.set_voltage = []
190
+ self.measured_current = []
191
+ self.sigma_current = []
192
+ self.measured_voltage = []
193
+ self.measured_timestamp = []
194
+ self.measured_temperature = []
195
+ self.measured_humidity = []
196
+ self.hold_voltage = None
197
+ self.hold_current = []
198
+ self.hold_timestamp = []
199
+ self.hold_temperature = []
200
+ self.hold_humidity = []
201
+
202
+ def __repr__(self):
203
+ return (f'Packet('
204
+ f'manufacturer="{self.manufacturer}", '
205
+ f'model="{self.model}", '
206
+ f'serial_number="{self.serial_number}", '
207
+ f'channel="{self.channel}", '
208
+ f'ident="{self.ident}", '
209
+ f'set_voltage={self.set_voltage}, '
210
+ f'measured_current={self.measured_current}, '
211
+ f'measured_voltage={self.measured_voltage}, '
212
+ f'sigma_current={self.sigma_current}, '
213
+ f'measured_timestamp={self.measured_timestamp}, '
214
+ f'measured_temperature={self.measured_temperature}, '
215
+ f'measured_humidity={self.measured_humidity}, '
216
+ f'hold_voltage={self.hold_voltage}, '
217
+ f'hold_current={self.hold_current}, '
218
+ f'hold_timestamp={self.hold_timestamp}, '
219
+ f'hold_temperature={self.hold_temperature}, '
220
+ f'hold_humidity={self.hold_humidity})')
221
+
222
+ def __str__(self):
223
+ # verbose power supply identifier
224
+ identifier = f'{self.manufacturer} {self.model}'
225
+ if self.serial_number:
226
+ identifier += f' s.no. {self.serial_number}'
227
+ if self.channel:
228
+ identifier += f' channel {self.channel}'
229
+
230
+ # number of data points in IV and IT tests
231
+ try:
232
+ split_index = self.measured_current.index('split')
233
+ except ValueError:
234
+ outbound_iv_len = len(self.measured_current)
235
+ return_iv_len = 0
236
+ else:
237
+ outbound_iv_len = len(self.measured_current[:split_index])
238
+ return_iv_len = len(self.measured_current[split_index + 1:])
239
+
240
+ out_iv_text = f'Outbound IV data points : {outbound_iv_len}'
241
+ hold_it_text = f'IT data points : {len(self.hold_current)}'
242
+ ret_iv_text = f'Return IV data points : {return_iv_len}'
243
+
244
+ return '\n'.join([identifier, out_iv_text, hold_it_text, ret_iv_text])
245
+
246
+ # sufficient to allow a list of class instances to be sorted
247
+ def __lt__(self, value):
248
+ return (f'{self.model}{self.serial_number}{self.channel}'
249
+ < f'{value.model}{value.serial_number}{value.channel}')
250
+
251
+ def extract_environmental_data(self, category, test_iv, outbound=True):
252
+ """
253
+ Extract measurements from their originally stored state to one that is
254
+ more readily usable for data analysis.
255
+
256
+ For the desired data source, the dict within the list represents
257
+ measurements from all the available sensors within the category at a
258
+ given point in time (for IT tests) or per voltage step (for IV tests).
259
+
260
+ e.g.
261
+ [{'PT100MK1-DC3D6': 22.31, 'PT100MK1-DC392': 19.16}, ...]
262
+
263
+ These lists may have had a 'split' marker added by function
264
+ run_iv_test() in iv.py if both outbound and return IV tests were
265
+ requested.
266
+
267
+ ----------------------------------------------------------------------
268
+ args
269
+ category : string
270
+ 'temperature' or 'humidity'
271
+ test_iv : bool
272
+ True = data from IV test, False = data from IT test
273
+ outbound : bool
274
+ For lists that may contain a 'split' marker, outbound = True
275
+ selects items before the marker, False selects items after the
276
+ marker. For other lists, this is ignored.
277
+ ----------------------------------------------------------------------
278
+ returns : dict
279
+ e.g.
280
+ {'PT100MK1-DC3D6': [21.25, 21.25, 21.26, 21.26, ..., 21.24],
281
+ 'PT100MK1-DC392': [20.9, 20.92, 20.92, 20.94, ..., 20.94]}
282
+ ----------------------------------------------------------------------
283
+ """
284
+ assert category in {'temperature', 'humidity'}, 'unknown category'
285
+
286
+ # select original data source
287
+ temp = category == 'temperature'
288
+ if test_iv:
289
+ var = self.measured_temperature if temp else self.measured_humidity
290
+ out, ret = list_split(var)
291
+ var = out if outbound else ret
292
+ else:
293
+ var = self.hold_temperature if temp else self.hold_humidity
294
+
295
+ # transform selected data
296
+ summary = collections.defaultdict(list)
297
+
298
+ for measurement in var:
299
+ for sensor, reading in measurement.items():
300
+ summary[sensor].append(reading)
301
+
302
+ # return transformed data source
303
+ return dict(summary)
304
+
305
+ def extract_timestamp_data(self, test_iv, outbound=True):
306
+ """
307
+ Extract timestamps to match data obtained by
308
+ self.extract_environmental_data.
309
+
310
+ ----------------------------------------------------------------------
311
+ args
312
+ test_iv : bool
313
+ True = data from IV test, False = data from IT test
314
+ outbound : bool
315
+ For lists that may contain a 'split' marker, outbound = True
316
+ selects items before the marker, False selects items after the
317
+ marker. For other lists, this is ignored.
318
+ ----------------------------------------------------------------------
319
+ returns : list
320
+ e.g.
321
+ [21.25, 21.25, 21.26, 21.26, ..., 21.24]
322
+ ----------------------------------------------------------------------
323
+ """
324
+ # select original data source
325
+ if test_iv:
326
+ out, ret = list_split(self.measured_timestamp)
327
+ var = out if outbound else ret
328
+ else:
329
+ var = self.hold_timestamp
330
+
331
+ return var
332
+
333
+ @staticmethod
334
+ def _unpack_environmental_data(data, first_only=True):
335
+ """
336
+ The iv.py software is written to accumulate environmental data
337
+ (temperature/humidity) from all Yoctopuce sensors that acquire those
338
+ parameters. The supplied data is grouped by data point, return the
339
+ supplied data grouped by sensor to make it easier to process.
340
+
341
+ ----------------------------------------------------------------------
342
+ args
343
+ data : list of dict
344
+ e.g.
345
+ [
346
+ {"METEOMK2-12E3A7": 46.2},
347
+ {"METEOMK2-12E3A7": 46.0},
348
+ ...
349
+ {"METEOMK2-12E3A7": 46.0},
350
+ {"METEOMK2-12E3A7": 46.0},
351
+ ]
352
+
353
+ This data is a list of dicts, where each dict contains all
354
+ sensors that returned a reading for that data point.
355
+ first_only : bool
356
+ Only return data for the first sensor found, this is used to
357
+ make sure that we have
358
+ ----------------------------------------------------------------------
359
+ returns : dict or list of values
360
+ e.g.
361
+ for multiple sensors:
362
+ {
363
+ "METEOMK2-12E3A7": [46.2, 46.0, ... 46.0, 46.0],
364
+ "METEOMK2-12E3B4": [45.1, 45.2, ... 45.4, 45.3],
365
+ }
366
+ for a single sensor:
367
+ [46.2, 46.0, ... 46.0, 46.0]
368
+
369
+ Returned data is grouped by sensor. If there is only one sensor, there
370
+ is little value in tagging the values with the sensor they came from,
371
+ so a list is returned. If there is more than one sensor, then the data
372
+ is returned grouped by sensor.
373
+
374
+ ----------------------------------------------------------------------
375
+ """
376
+ # grouped by data point -> grouped by sensor
377
+ sendat = collections.defaultdict(list)
378
+
379
+ for point in data:
380
+ for sensor, values in point.items():
381
+ sendat[sensor].append(values)
382
+
383
+ sendat = dict(sendat)
384
+
385
+ # if there's only one sensor, just return a list of its values
386
+ lsendat = len(sendat)
387
+ if lsendat == 1:
388
+ return next(iter(sendat.values()))
389
+ elif lsendat > 1 and first_only:
390
+ logging.info(
391
+ '_unpack_environmental_data: arbitrary sensor chosen (from n=%s)',
392
+ lsendat
393
+ )
394
+ return next(iter(sendat.values()))
395
+
396
+ return sendat
397
+
398
+ def write_json(self, session):
399
+ """
400
+ Write JSON file to mass storage for ATLAS Itkpix.
401
+
402
+ Only data for the outbound IV test is transferred to the JSON file.
403
+ Return IV and IT information, if present, is omitted.
404
+
405
+ Refer to specification:
406
+ https://itk.docs.cern.ch/pixels/sensors/upload_tests_sensor_PDB/
407
+
408
+ Institute codes:
409
+ https://itk.docs.cern.ch/general/Production_Database/Institute_Codes/
410
+ """
411
+ filename = f'{session}_{self.itk_serno}.json'
412
+
413
+ # only interested in 'outbound IV': ignore any 'return IV' data
414
+ osvo, _rsvo = list_split(self.set_voltage)
415
+ omcu, _rmcu = list_split(self.measured_current)
416
+ ocsi, _rcsi = list_split(self.sigma_current)
417
+ omvo, _rmvo = list_split(self.measured_voltage)
418
+ omti, _rmti = list_split(self.measured_timestamp)
419
+ omte, _rmte = list_split(self.measured_temperature)
420
+ omhu, _rmhu = list_split(self.measured_humidity)
421
+
422
+ # Convert current measurements to uA
423
+ omcu[:] = [x * 1000000 for x in omcu]
424
+
425
+ # iv.py can be run in forward or reverse bias, present values as
426
+ # positive polarity
427
+ furthest_from_zero = max(osvo, key=abs)
428
+ if furthest_from_zero < 0:
429
+ for outbound_var in [osvo, omcu, ocsi, omvo]:
430
+ outbound_var[:] = [-x for x in outbound_var]
431
+
432
+ # check number of data points in array match
433
+ temp = self._unpack_environmental_data(omte)
434
+ humi = self._unpack_environmental_data(omhu)
435
+
436
+ losvo = len(osvo)
437
+ lomcu = len(omcu)
438
+ locsi = len(ocsi)
439
+ ltemp = len(temp)
440
+ lhumi = len(humi)
441
+
442
+ if not losvo == lomcu == locsi == ltemp == lhumi:
443
+ logging.info(
444
+ 'write_json: IV_ARRAY length mismatch: svo %s, mcu %s, csi %s, temp %s, humi %s',
445
+ losvo, lomcu, locsi, ltemp, lhumi
446
+ )
447
+ logging.info(
448
+ 'write_json: refer to https://itk.docs.cern.ch/pixels/sensors/'
449
+ 'upload_tests_sensor_PDB/#iv-checking-your-data-input'
450
+ )
451
+ logging.info('write_json: malformed JSON file created')
452
+
453
+ # '27.10.2020 23:32'
454
+ dts = datetime.datetime.utcfromtimestamp(min(omti)).strftime('%d.%m.%Y %H:%M')
455
+
456
+ # Create the JSON file even if it's malformed, to help the user
457
+ # diagnose the issue.
458
+ with open(filename, 'w', encoding='utf-8') as outfile:
459
+ json.dump(
460
+ {
461
+ 'component': self.itk_serno,
462
+ 'test': 'IV',
463
+ 'institution': 'LIV',
464
+ 'date': dts,
465
+ 'prefix': 'uA',
466
+ 'depletion_voltage': max(osvo),
467
+ 'IV_ARRAY': {
468
+ 'voltage': osvo,
469
+ 'current': omcu,
470
+ 'sigma current': ocsi,
471
+ 'temperature': temp,
472
+ 'humidity': humi,
473
+ }
474
+ },
475
+ outfile,
476
+ )
477
+
478
+
479
+ class Consignment:
480
+ """
481
+ Contains data for an entire data acquisition session, which may include
482
+ packets from multiple power supplies, each of which may have multiple
483
+ channels.
484
+ """
485
+ __slots__ = {
486
+ 'label': 'Plot title string.',
487
+ 'safe_label': ('Either a simplified version of the label string that may be\n'
488
+ 'appended to a filename and is easy to work with on the command\n'
489
+ 'line, or None'),
490
+ 'aliases': ('A dictionary containing mappings between power supply channel\n'
491
+ 'hardware identifiers and user-submitted descriptions of each\n'
492
+ 'channel\'s purpose.'),
493
+ 'forwardbias': ('A boolean indicating whether the user specified forward bias\n'
494
+ 'operation.'),
495
+ 'hold': 'A boolean indicating whether the user specified an IT test',
496
+ 'environmental_data_present': ('A boolean indicating that environmental sensors'
497
+ 'are presented.'),
498
+ 'packets': ('A list containing the data packets captured during an entire\n'
499
+ 'data acquisition session.')}
500
+
501
+ def __init__(self, label, aliases, forwardbias, hold, environmental_data_present):
502
+ self.label = label
503
+ self.aliases = aliases
504
+ self.forwardbias = forwardbias
505
+ self.hold = hold
506
+ self.environmental_data_present = environmental_data_present
507
+ self.packets = []
508
+
509
+ # create a safe and easy to work with label for appending to a
510
+ # filename later
511
+ if self.label:
512
+ self.safe_label = ''.join(c
513
+ for c in self.label.replace(' ', '-')
514
+ if c not in r'<>:"/\|?*')
515
+ else:
516
+ self.safe_label = None
517
+
518
+ def __repr__(self):
519
+ return (f'Consignment('
520
+ f'label="{self.label}", '
521
+ f'aliases={self.aliases}, '
522
+ f'forwardbias={self.forwardbias}, '
523
+ f'hold={self.hold}, '
524
+ f'environmental_data_present={self.environmental_data_present})')
525
+
526
+ def __str__(self):
527
+ pretty = [f'label: {self.label}', f'safe_label: {self.safe_label}',
528
+ f'aliases: {self.aliases}', f'forwardbias: {self.forwardbias}',
529
+ f'hold: {self.hold}',
530
+ f'environmental_data_present: {self.environmental_data_present}',
531
+ f'number of packets: {len(self.packets)}']
532
+
533
+ return '\n'.join(pretty)
534
+
535
+ def remove_bad_packets(self):
536
+ """
537
+ Only retain usable packets. This operation should be performed before
538
+ attempting to plot or store data.
539
+ """
540
+ self.packets = [packet
541
+ for packet in self.packets
542
+ if packet is not None and len(packet.set_voltage) > 1]
543
+
544
+ def write_json_files(self, session):
545
+ """
546
+ Write all packet data to filestore.
547
+ """
548
+ for packet in self.packets:
549
+ packet.write_json(session)
550
+
551
+
552
+ class Channel:
553
+ """
554
+ Handle power supplies on a per-channel basis.
555
+ """
556
+ __slots__ = {
557
+ 'port': 'Serial port on which to communicate with the power supply.',
558
+ 'config': 'Serial port configuration parameters.',
559
+ 'serial_number': 'The serial number of the power supply.',
560
+ 'model': 'The model name of the power supply.',
561
+ 'manufacturer': 'The manufacturer of the power supply.',
562
+ 'channel': 'Which power supply channel to use.',
563
+ 'category': 'Tailor behaviour for high or low voltage use.',
564
+ 'release_delay': 'Delay between sequential serial port interactions.',
565
+ 'ident': 'hardware identifier string or user supplied alias.',
566
+ 'window_size': 'Size of measured_voltages and measured_currents queues.',
567
+ 'measured_voltages': 'Queue to store voltages for smoothing.',
568
+ 'measured_currents': 'Queue to store currents for smoothing.'}
569
+
570
+ def __init__(self, port, config, serial_number, model, manufacturer,
571
+ channel, category, release_delay, alias):
572
+ self.port = port
573
+ self.config = config
574
+ self.serial_number = serial_number
575
+ self.model = model
576
+ self.manufacturer = manufacturer
577
+ self.channel = channel
578
+ self.category = category
579
+ self.release_delay = release_delay
580
+ self.window_size = 10
581
+ self.measured_voltages = collections.deque([], self.window_size)
582
+ self.measured_currents = collections.deque([], self.window_size)
583
+
584
+ _parameters = (self.model, self.serial_number, self.channel)
585
+ _ident_key = '_'.join(p for p in _parameters if p).lower()
586
+ _text = ' '.join(p for p in _parameters if p).lower()
587
+ try:
588
+ _text = alias.get(_ident_key, _text)
589
+ except AttributeError:
590
+ # alias is None
591
+ pass
592
+ finally:
593
+ self.ident = _text
594
+
595
+ def __repr__(self):
596
+ return (f'Channel('
597
+ f'port="{self.port}", config={self.config}, '
598
+ f'serial_number="{self.serial_number}", '
599
+ f'model="{self.model}", '
600
+ f'manufacturer="{self.manufacturer}", '
601
+ f'channel="{self.channel}", '
602
+ f'category="{self.category}", '
603
+ f'release_delay={self.release_delay}, '
604
+ f'ident="{self.ident}")')
605
+
606
+ def __str__(self):
607
+ description = f'{self.manufacturer} {self.model} s.no. {self.serial_number}'
608
+ description += f' ch. {self.channel}' if self.channel else ''
609
+ return description
610
+
611
+ # sufficient to allow a list of class instances to be sorted
612
+ def __lt__(self, value):
613
+ return (f'{self.model}{self.serial_number}{self.channel}'
614
+ < f'{value.model}{value.serial_number}{value.channel}')
615
+
616
+ # sufficient to allow a class instance to be removed from a list
617
+ def __eq__(self, value):
618
+ return (f'{self.model}{self.serial_number}{self.channel}'
619
+ == f'{value.model}{value.serial_number}{value.channel}')
620
+
621
+
622
+ ##############################################################################
623
+ # file i/o
624
+ ##############################################################################
625
+
626
+ def _file_is_compressed(filename):
627
+ """
628
+ Determine if a data file is compressed from its extension.
629
+
630
+ --------------------------------------------------------------------------
631
+ args
632
+ filename : string
633
+ --------------------------------------------------------------------------
634
+ returns
635
+ restored_progress : boolean
636
+ True if the filename extension indicates a compressed file,
637
+ False otherwise
638
+ --------------------------------------------------------------------------
639
+ """
640
+ return 'bz2' in os.path.splitext(filename)[-1].lower()
641
+
642
+
643
+ def configcache_read(filename):
644
+ """
645
+ Retrieve previously stored serial port configuration and attached devices
646
+ from human-readable cache file.
647
+
648
+ --------------------------------------------------------------------------
649
+ args
650
+ filename : string
651
+ --------------------------------------------------------------------------
652
+ returns
653
+ restored_progress : dict
654
+ deserialized cached data or None
655
+ --------------------------------------------------------------------------
656
+ """
657
+ restored_progress = None
658
+
659
+ if os.path.isfile(filename):
660
+ with open(filename, 'r', encoding='utf-8') as infile:
661
+ try:
662
+ restored_progress = json.load(infile)
663
+ except json.JSONDecodeError:
664
+ sys.exit(f'exiting: problem reading data from {filename}')
665
+
666
+ return restored_progress
667
+
668
+
669
+ def configcache_write(data, filename):
670
+ """
671
+ Store serial port configuration and attached devices to cache in
672
+ human-readable form.
673
+
674
+ --------------------------------------------------------------------------
675
+ args
676
+ data : dict
677
+ a record each detected device, its respective category and
678
+ serial port configuration.
679
+ filename : string
680
+ --------------------------------------------------------------------------
681
+ returns : none
682
+ --------------------------------------------------------------------------
683
+ """
684
+ with open(filename, 'w', encoding='utf-8') as outfile:
685
+ try:
686
+ json.dump(data, outfile)
687
+ except TypeError:
688
+ sys.exit(f'exiting: problem writing data to {filename}')
689
+
690
+
691
+ def data_read(filename):
692
+ """
693
+ Retrieve previously stored data from file.
694
+
695
+ --------------------------------------------------------------------------
696
+ args
697
+ filename : string
698
+ --------------------------------------------------------------------------
699
+ returns
700
+ restored_progress : unpickled data or None
701
+ --------------------------------------------------------------------------
702
+ """
703
+ restored_progress = None
704
+
705
+ if os.path.isfile(filename):
706
+ file_read = bz2.open if _file_is_compressed(filename) else open
707
+
708
+ with file_read(filename, 'rb') as infile:
709
+ try:
710
+ restored_progress = pickle.load(infile)
711
+ except (pickle.UnpicklingError, AttributeError, EOFError,
712
+ ImportError, IndexError, TypeError):
713
+ sys.exit(f'exiting: problem reading data from {filename}')
714
+
715
+ return restored_progress
716
+
717
+
718
+ def data_write(data, filename):
719
+ """
720
+ Store data to file for later retrieval.
721
+
722
+ --------------------------------------------------------------------------
723
+ args
724
+ data : data structure to store
725
+ filename : string
726
+ --------------------------------------------------------------------------
727
+ returns : none
728
+ --------------------------------------------------------------------------
729
+ """
730
+ file_write = bz2.open if _file_is_compressed(filename) else open
731
+
732
+ with file_write(filename, 'wb') as outfile:
733
+ try:
734
+ pickle.dump(data, outfile)
735
+ except pickle.PicklingError:
736
+ sys.exit(f'exiting: problem writing data to {filename}')
737
+
738
+
739
+ def read_aliases(settings, filename):
740
+ """
741
+ Read in alias file, and write results to settings:
742
+ (1) create a dictionary of aliases
743
+ (2) create a dictionary of channels to ignore
744
+
745
+ Comments starting with a # are allowed.
746
+
747
+ e.g.
748
+
749
+ # Keithley 2614b
750
+ ,2614b,4428182,a,layer front # blue pcb v1.0a
751
+ ,2614b,4428182,b,layer rear # green pcb v1.0b
752
+ # Keithley 2410 (borrowed from cleanroom)
753
+ ,2410,4343654,,neutron detector
754
+ off,2410,1390035,,layer external # off-axis
755
+
756
+ which would generate aliases as indicated in the log:
757
+
758
+ INFO : alias 2614b_4428182_a -> layer front
759
+ INFO : alias 2614b_4428182_b -> layer rear
760
+ INFO : alias 2410_4343654 -> neutron detector
761
+
762
+ --------------------------------------------------------------------------
763
+ args
764
+ settings : dictionary
765
+ contains core information about the test environment
766
+ filename : string
767
+ filename with extension
768
+ --------------------------------------------------------------------------
769
+ returns
770
+ settings : dict
771
+ no explicit return, mutable type amended in place
772
+ --------------------------------------------------------------------------
773
+ """
774
+ if os.path.exists(filename):
775
+ alias = {}
776
+ ignore = {}
777
+
778
+ with open(filename, 'r', encoding='utf-8') as csvfile:
779
+ for line_num, row in enumerate(csvfile):
780
+ # remove comments
781
+ no_comment = row.split('#')[0].strip()
782
+
783
+ # separate comma separated values
784
+ fields = (field.strip() for field in no_comment.split(','))
785
+ try:
786
+ enable, model, serialnum, channel, description = fields
787
+ except ValueError:
788
+ # not enough values to unpack
789
+ if no_comment:
790
+ print(f'line {line_num} (expected 5 fields): {no_comment}')
791
+ continue
792
+
793
+ identifier = '_'.join(x for x in [model, serialnum, channel] if x).lower()
794
+
795
+ if description:
796
+ alias[identifier] = description.replace('"', '').replace('\'', '')
797
+
798
+ if enable.lower() in {'no', 'off', 'disable'}:
799
+ ignore[identifier] = True
800
+
801
+ settings['alias'] = alias if alias else None
802
+ settings['ignore'] = ignore if ignore else None
803
+ else:
804
+ print(f'file {filename} could not be read from')
805
+
806
+
807
+ def save_plot(settings, filename):
808
+ """
809
+ Save plot to mass storage in the requested file format.
810
+
811
+ --------------------------------------------------------------------------
812
+ args
813
+ settings : dictionary
814
+ contains core information about the test environment
815
+ filename : string
816
+ filename without extension
817
+ --------------------------------------------------------------------------
818
+ returns : none
819
+ --------------------------------------------------------------------------
820
+ """
821
+ if settings['svg']:
822
+ plt.savefig(f'{filename}.svg')
823
+ else:
824
+ plt.savefig(f'{filename}.png', dpi=400)
825
+
826
+
827
+ def write_consignment_csv(consignment, row):
828
+ """
829
+ Write recorded data in consignment to mass storage.
830
+
831
+ ASSUMES a file has been opened for writing.
832
+
833
+ --------------------------------------------------------------------------
834
+ args
835
+ consignment : instance of class Consignment
836
+ contains data for the whole data acquisition session
837
+ row : csv.writer
838
+ writer object responsible for converting data to delimited strings
839
+ --------------------------------------------------------------------------
840
+ returns : none
841
+ --------------------------------------------------------------------------
842
+ """
843
+ # --label: write label if user has supplied it
844
+ if consignment.label is not None:
845
+ row.writerow(itertools.chain(['label'], [consignment.label]))
846
+
847
+ # --alias: write aliases if user has supplied them
848
+ if consignment.aliases is not None:
849
+ for channel_identifier, alias in consignment.aliases.items():
850
+ row.writerow(itertools.chain(['alias'], [channel_identifier], [alias]))
851
+
852
+ stored_as_dict = {'measured_temperature', 'hold_temperature',
853
+ 'measured_humidity', 'hold_humidity'}
854
+ not_iterable = {'channel', 'hold_voltage', 'ident',
855
+ 'manufacturer', 'model', 'serial_number'}
856
+
857
+ # write all data packets
858
+ for packet in consignment.packets:
859
+
860
+ # extract data from instance of class Packet in (key, value) pairs
861
+ # if the value contains something
862
+ # use __slots__ to maintain ordering
863
+ packet_data = {k: getattr(packet, k) for k in packet.__slots__
864
+ if getattr(packet, k)}
865
+
866
+ for field, item in packet_data.items():
867
+ if field in not_iterable:
868
+ row.writerow(itertools.chain([field], [item]))
869
+ elif field in stored_as_dict:
870
+ # list will contain dicts that need to be extracted, e.g.
871
+ # [{'PT100MK1-DC3D6': 20.52, 'PT100MK1-DC392': 19.9},
872
+ # {'PT100MK1-DC3D6': 20.52, 'PT100MK1-DC392': 19.89}, ...]
873
+ tmd = collections.defaultdict(list)
874
+ for temp_measurement in item:
875
+ if temp_measurement != 'split':
876
+ for sensor, reading in temp_measurement.items():
877
+ tmd[sensor].append(reading)
878
+ else:
879
+ for sensor in tmd:
880
+ tmd[sensor].append(temp_measurement)
881
+
882
+ # write the rows to the csv file
883
+ for sensor, measurements in tmd.items():
884
+ row.writerow(itertools.chain([f'{field} ({sensor})'], measurements))
885
+ else:
886
+ # item is a plain list
887
+ row.writerow(itertools.chain([field], item))
888
+
889
+
890
+ ##############################################################################
891
+ # identifier/alias management
892
+ ##############################################################################
893
+
894
+ def _alias_to_log(settings):
895
+ """
896
+ Enter the mapping of power supply channel identifiers to aliases into the
897
+ log.
898
+
899
+ --------------------------------------------------------------------------
900
+ args
901
+ settings : dictionary
902
+ contains core information about the test environment
903
+ --------------------------------------------------------------------------
904
+ returns : none
905
+ --------------------------------------------------------------------------
906
+ """
907
+ if settings['alias'] is not None:
908
+ for ident, alias in settings['alias'].items():
909
+ logging.info('alias: %s -> %s', ident, alias)
910
+
911
+
912
+ def _lookup_reference(packet, use_channel=True, separator=' '):
913
+ """
914
+ For the given data packet, return the hardware identifier for its power
915
+ supply channel.
916
+
917
+ --------------------------------------------------------------------------
918
+ args
919
+ packet : instance of class Channel
920
+ Contains data for a given power supply channel's IV and IT curves.
921
+ use_channel : bool
922
+ If True, use channel identifier in returned string.
923
+ Useful to disable when working at power supply level
924
+ rather than channel-level, e.g. when checking power supply
925
+ interlocks and creating error logs that would be misleading if
926
+ they contained a channel identifier.
927
+ separator : string
928
+ Separator character used between values, typically ' ' or '_'.
929
+ --------------------------------------------------------------------------
930
+ returns : string
931
+ e.g.
932
+ use_channel=True
933
+ '2410 1272738' (single channel)
934
+ '2614b 4428182 b' (dual channel)
935
+ use_channel=False
936
+ '2410 1272738' (single channel)
937
+ '2614b 4428182' (dual channel)
938
+ --------------------------------------------------------------------------
939
+ """
940
+ text = f'{packet.model}'
941
+ if packet.serial_number:
942
+ text += f'{separator}{packet.serial_number}'
943
+ if packet.channel and use_channel:
944
+ text += f'{separator}{packet.channel}'
945
+
946
+ return text
947
+
948
+
949
+ def _lookup_decorative(alias, packet):
950
+ """
951
+ Decorative print of the power supply channel with its alias (if it
952
+ exists).
953
+
954
+ --------------------------------------------------------------------------
955
+ args
956
+ alias : dictionary
957
+ maps aliases to power supply channel identifiers
958
+ packet : packet : instance of class Packet or class Channel
959
+ contains data for a given power supply channel's IV and IT curves
960
+ --------------------------------------------------------------------------
961
+ returns : string
962
+ e.g.
963
+ 'keithley 2614b s.no. 4428182 channel b (layer rear)'
964
+ or
965
+ 'keithley 2614b s.no. 4428182 channel a'
966
+ --------------------------------------------------------------------------
967
+ """
968
+ parameters = (packet.model, packet.serial_number, packet.channel)
969
+ ident_key = '_'.join(p for p in parameters if p).lower()
970
+
971
+ dtext = f'{packet.manufacturer} {packet.model}'
972
+ if packet.serial_number:
973
+ dtext += f' s.no. {packet.serial_number}'
974
+ if packet.channel:
975
+ dtext += f' channel {packet.channel}'
976
+
977
+ try:
978
+ atext = alias[ident_key]
979
+ except (KeyError, TypeError):
980
+ suffix = ''
981
+ else:
982
+ suffix = f' ({atext})'
983
+
984
+ return f'{dtext}{suffix}'
985
+
986
+
987
+ ##############################################################################
988
+ # utilities
989
+ ##############################################################################
990
+
991
+
992
+ def decimal_quantize(value, decimal_places):
993
+ """
994
+ Returns a decimal rounded to the user-specified number of decimal places.
995
+
996
+ --------------------------------------------------------------------------
997
+ args
998
+ value : numeric or string representation of numeric
999
+ decimal_places : positive integer
1000
+ --------------------------------------------------------------------------
1001
+ returns : decimal.Decimal
1002
+ --------------------------------------------------------------------------
1003
+ """
1004
+ value = decimal.Decimal(value)
1005
+
1006
+ # 0: '0', 1: '0.1', 2: '0.01' etc...
1007
+ decplc = f'{pow(10, -decimal_places):.{decimal_places}f}'
1008
+
1009
+ return value.quantize(decimal.Decimal(decplc), rounding=decimal.ROUND_HALF_UP)
1010
+
1011
+
1012
+ def dew_point(tdc, rhp):
1013
+ """
1014
+ Calculate the dew point given temperature and humidity.
1015
+
1016
+ https://en.wikipedia.org/wiki/Dew_point
1017
+
1018
+ Murray, F. W., 1967, On the Computation of Saturation Vapor Pressure
1019
+
1020
+ Magnus, Tetens : pressure in mbar
1021
+ t = 20.1 (deg C)
1022
+ u = 7.5
1023
+ v = 237.3
1024
+ w = 0.7858 (vapor pressure in mbar)
1025
+
1026
+ In [25]: math.pow(10, (t * u)/(t + v) + w)
1027
+ Out[25]: 23.521463268069194
1028
+
1029
+ For low temperature and low pressure with accuracy, use Goff-Gratch.
1030
+
1031
+ --------------------------------------------------------------------------
1032
+ args
1033
+ tdc : float
1034
+ temperature degrees Celsius
1035
+ rhp : float
1036
+ relative humidity percentage
1037
+ --------------------------------------------------------------------------
1038
+ returns : float
1039
+ dew point degrees Celsius
1040
+ --------------------------------------------------------------------------
1041
+ """
1042
+ # constants
1043
+ # a = 6.1121 # mbar
1044
+ b = 18.678
1045
+ c = 257.14 # °C
1046
+ d = 234.5 # °C
1047
+
1048
+ ymtrh = np.log((rhp / 100) * np.exp((b - tdc / d) * (tdc / (c + tdc))))
1049
+ tdp = (c * ymtrh) / (b - ymtrh)
1050
+
1051
+ return tdp
1052
+
1053
+
1054
+ def interpret_numeric(val):
1055
+ """
1056
+ Convert numbers in either engineering or scientific notation from string
1057
+ form to a float.
1058
+
1059
+ Useful resource: https://regex101.com
1060
+
1061
+ --------------------------------------------------------------------------
1062
+ args
1063
+ val : string
1064
+ --------------------------------------------------------------------------
1065
+ returns
1066
+ success : bool
1067
+ val : float if conversion was successful, otherwise return the
1068
+ problematic string
1069
+ --------------------------------------------------------------------------
1070
+ """
1071
+ success = True
1072
+
1073
+ eng_notation = re.match(r'^[+\-]?\d*\.?\d*[yzafpnumkMGTPEZY]$', val)
1074
+ sci_notation = re.match(r'^[+\-]?(?:\d+\.|\d+\.\d+|\.\d+|\d+)(?:[eE][+\-]?\d+)?$', val)
1075
+
1076
+ if eng_notation is not None:
1077
+ value = float(eng_notation[0][:-1])
1078
+ suffix = eng_notation[0][-1]
1079
+ val = value * pow(10, 'yzafpnum kMGTPEZY'.find(suffix) * 3 - 24)
1080
+ elif sci_notation is not None:
1081
+ val = float(sci_notation[0])
1082
+ else:
1083
+ success = False
1084
+
1085
+ return success, val
1086
+
1087
+
1088
+ def iseg_value_to_float(value):
1089
+ """
1090
+ For values read back from ISEG SHQ power supplies.
1091
+
1092
+ While set voltages are specified as positive-only magnitudes (with
1093
+ polarity defined by the hardware switch on the rear panel), the values
1094
+ read back have the correct polarity.
1095
+
1096
+ e.g.
1097
+
1098
+ Set PSU channel 1 voltage to 120V with:
1099
+
1100
+ D1=120 (sets voltage)
1101
+ G1 (requests power supply to ramp up to given voltage)
1102
+
1103
+ poll G1 until it responds with S1=ON
1104
+
1105
+ U1 returns with (polarity, significand, signed exponent):
1106
+ -01200-01
1107
+
1108
+ which this function returns as -120.0
1109
+
1110
+ --------------------------------------------------------------------------
1111
+ args
1112
+ value : string
1113
+ response from ISEG command
1114
+ --------------------------------------------------------------------------
1115
+ returns
1116
+ fvalue : float
1117
+ --------------------------------------------------------------------------
1118
+ """
1119
+ fvalue = None
1120
+ significand, exponent = value[:-3], value[-3:]
1121
+
1122
+ with contextlib.suppress(ValueError):
1123
+ fvalue = float(f'{significand}e{exponent}')
1124
+
1125
+ return fvalue
1126
+
1127
+
1128
+ def list_split(data):
1129
+ """
1130
+ Split list at the 'split' marker.
1131
+
1132
+ --------------------------------------------------------------------------
1133
+ args
1134
+ data : list
1135
+ --------------------------------------------------------------------------
1136
+ returns
1137
+ out : list
1138
+ ret : list
1139
+ --------------------------------------------------------------------------
1140
+ """
1141
+ try:
1142
+ split_index = data.index('split')
1143
+ except ValueError:
1144
+ out = data
1145
+ ret = []
1146
+ else:
1147
+ out = data[:split_index]
1148
+ ret = data[split_index + 1:]
1149
+
1150
+ return out, ret
1151
+
1152
+
1153
+ def round_safely(start, stop, step, first=True):
1154
+ """
1155
+ Round the value to the given step "in the direction of travel" so the
1156
+ resultant value is always contained within the bounds of start and stop.
1157
+
1158
+ --------------------------------------------------------------------------
1159
+ args
1160
+ start : numeric (int, float or decimal.Decimal)
1161
+ start value of the number sequence
1162
+ stop : numeric (int, float or decimal.Decimal)
1163
+ stop value of the number sequence
1164
+ step : int
1165
+ step size
1166
+ first : bool
1167
+ if True, round the start value, if False, round the stop value
1168
+ --------------------------------------------------------------------------
1169
+ returns : int
1170
+ rounded and aligned value
1171
+ --------------------------------------------------------------------------
1172
+ """
1173
+ step = abs(step)
1174
+ value, step = (float(start), step) if first else (float(stop), -step)
1175
+ return int(value - (value % -step if start < stop else value % step))
1176
+
1177
+
1178
+ def si_prefix(value, dec_places=3, compact=True):
1179
+ """
1180
+ Provide an approximation of the given number in engineering
1181
+ representation, to the given number of decimal places, with the SI
1182
+ (Systeme Internationale) unit prefix appended.
1183
+
1184
+ --------------------------------------------------------------------------
1185
+ args
1186
+ value : string, float or int
1187
+ numeric value
1188
+ dec_places : int
1189
+ number of decimal places to display
1190
+ compact : bool
1191
+ if True remove trailing zeros from number, if the remainder is a
1192
+ whole number, remove the trailing decimal point
1193
+ e.g.
1194
+ 103.100 -> 103.1
1195
+ 10.000 -> 10
1196
+ --------------------------------------------------------------------------
1197
+ returns : string or None
1198
+ value with SI unit prefix
1199
+ --------------------------------------------------------------------------
1200
+ """
1201
+ # make sure the number is in scientific notation
1202
+ # then separate value and exponent
1203
+ try:
1204
+ significand, exponent = f'{float(value):e}'.lower().split('e')
1205
+ except TypeError:
1206
+ return None
1207
+
1208
+ significand = float(significand)
1209
+ exponent = int(exponent)
1210
+
1211
+ # align with 10**3 boundaries
1212
+ while exponent % 3 != 0:
1213
+ exponent -= 1
1214
+ significand *= 10
1215
+
1216
+ if -24 <= exponent <= 24:
1217
+ # derive SI unit prefix
1218
+ if exponent == 0:
1219
+ prefix = ''
1220
+ else:
1221
+ prefix = 'yzafpnum kMGTPEZY'[8 + exponent // 3]
1222
+
1223
+ # remove trailing zeroes
1224
+ # if the number is a whole number, remove the trailing decimal point as well
1225
+ significand = f'{significand:.{dec_places}f}'
1226
+ if compact:
1227
+ # avoid rstrip('0.') to ensure '0.0' doesn't become ''
1228
+ significand = significand.rstrip('0').rstrip('.')
1229
+ else:
1230
+ # handle the case where the supplied value is too large or too small
1231
+ prefix = ''
1232
+ significand = float(value)
1233
+
1234
+ return f'{significand}{prefix}'
1235
+
1236
+
1237
+ def timestamp_to_utc(tref):
1238
+ """
1239
+ Converts a timestamp into a string in UTC to the nearest second.
1240
+
1241
+ e.g. 1567065212.1064236 converts to '20190829_075332'
1242
+
1243
+ --------------------------------------------------------------------------
1244
+ args
1245
+ tref : float
1246
+ time in seconds since the epoch
1247
+ --------------------------------------------------------------------------
1248
+ returns : string
1249
+ --------------------------------------------------------------------------
1250
+ """
1251
+ utc = datetime.datetime.utcfromtimestamp(tref).isoformat().split('.')[0]
1252
+ return utc.replace('-', '').replace(':', '').replace('T', '_')
1253
+
1254
+
1255
+ def time_axis_adjustment(data):
1256
+ """
1257
+ Generate the parameters necessary to transform absolute UNIX-style epoch
1258
+ timestamps to relative timestamps with human-readable units.
1259
+
1260
+ --------------------------------------------------------------------------
1261
+ args
1262
+ data : pandas.DataFrame
1263
+ --------------------------------------------------------------------------
1264
+ returns
1265
+ units : string
1266
+ data : pandas.DataFrame
1267
+ no explicit return, mutable type amended in place
1268
+ --------------------------------------------------------------------------
1269
+ """
1270
+ earliest_timestamp = data['timestamp'].min()
1271
+ latest_timestamp = data['timestamp'].max()
1272
+
1273
+ minutes_of_available_data = (latest_timestamp - earliest_timestamp) / 60
1274
+
1275
+ if minutes_of_available_data > 180:
1276
+ units = 'hours'
1277
+ scale = 3600
1278
+ else:
1279
+ units = 'minutes'
1280
+ scale = 60
1281
+
1282
+ data['timestamp'] = (data['timestamp'] - earliest_timestamp) / scale
1283
+
1284
+ return units
1285
+
1286
+
1287
+ ##############################################################################
1288
+ # command line argument processing
1289
+ ##############################################################################
1290
+
1291
+ def check_current(val):
1292
+ """
1293
+ Checks current values.
1294
+
1295
+ Note that the Keithley 2410 will not allow compliance values to be set
1296
+ to less than 0.1% of the measurement range, p.18-69 (p.449 in PDF).
1297
+
1298
+ --------------------------------------------------------------------------
1299
+ args
1300
+ val : string
1301
+ --------------------------------------------------------------------------
1302
+ returns
1303
+ val : float
1304
+ --------------------------------------------------------------------------
1305
+ """
1306
+ success, val = interpret_numeric(val)
1307
+
1308
+ if not success:
1309
+ raise argparse.ArgumentTypeError(
1310
+ f'{val}: '
1311
+ 'should be a value in engineering or scientific notation')
1312
+
1313
+ if val < 1e-9:
1314
+ raise argparse.ArgumentTypeError(
1315
+ f'{val}: '
1316
+ 'is too small for range of the power supply')
1317
+
1318
+ return val
1319
+
1320
+
1321
+ def check_file_exists(filename):
1322
+ """
1323
+ check if file exists
1324
+
1325
+ --------------------------------------------------------------------------
1326
+ args
1327
+ val : string
1328
+ filename, e.g. '20200612_132725_psuwatch.log'
1329
+ --------------------------------------------------------------------------
1330
+ returns : string
1331
+ --------------------------------------------------------------------------
1332
+ """
1333
+ if not os.path.exists(filename):
1334
+ raise argparse.ArgumentTypeError(f'{filename}: file does not exist')
1335
+
1336
+ return filename
1337
+
1338
+
1339
+ ##############################################################################
1340
+ # cache import
1341
+ ##############################################################################
1342
+
1343
+ def _logprint(uselog, level, message):
1344
+ """
1345
+ Display via logging module or print as appropriate.
1346
+
1347
+ --------------------------------------------------------------------------
1348
+ args
1349
+ uselog : bool
1350
+ use logging if True, print otherwise
1351
+ level : int
1352
+ logging level e.g. logging.DEBUG
1353
+ text : string
1354
+ text to display
1355
+ --------------------------------------------------------------------------
1356
+ returns : none
1357
+ --------------------------------------------------------------------------
1358
+ """
1359
+ if uselog:
1360
+ log_with_colour(level, message)
1361
+ else:
1362
+ print(message)
1363
+
1364
+
1365
+ def cache_read(device_types=None, uselog=False, quiet=True):
1366
+ """
1367
+ Get the details of all the given devices from the cache, and in the process
1368
+ transform the dictionary from having device types as keys, to having
1369
+ port names as keys.
1370
+
1371
+ --------------------------------------------------------------------------
1372
+ args
1373
+ device_types : list or None
1374
+ device types to search cache for e.g. ['hvpsu', 'lvpsu']
1375
+ valid types are: controller, daq, hvpsu, lvpsu
1376
+ if this is None, do not check for validity, import all devices
1377
+ uselog : bool
1378
+ use print if False (default), otherwise use the logging module
1379
+ quiet : bool
1380
+ no printing
1381
+ --------------------------------------------------------------------------
1382
+ returns
1383
+ found : dict
1384
+ {port: ({port_config}, device_type, device_serial_number), ...}
1385
+ --------------------------------------------------------------------------
1386
+ """
1387
+ cache = configcache_read(DEVICE_CACHE)
1388
+
1389
+ if cache is None:
1390
+ _logprint(
1391
+ uselog,
1392
+ logging.CRITICAL,
1393
+ 'please run detect.py before using this script (cache missing)'
1394
+ )
1395
+ sys.exit()
1396
+
1397
+ # if the plaform differs from the platform the cache was created on, the
1398
+ # serial port data contained in the cache will almost certainly be wrong
1399
+ cached_platform = cache.pop('platform', None)
1400
+ if cached_platform is not None and cached_platform != sys.platform.lower():
1401
+ _logprint(
1402
+ uselog,
1403
+ logging.CRITICAL,
1404
+ 'please run detect.py before using this script (platform mismatch)'
1405
+ )
1406
+ sys.exit()
1407
+
1408
+ found = {}
1409
+ for device_type, device_details in cache.items():
1410
+ if device_types is not None and device_type not in device_types:
1411
+ continue
1412
+
1413
+ serial_config = device_details[0]
1414
+
1415
+ # there may be multiple devices for this device type
1416
+ for device in device_details[1]:
1417
+ port, manufacturer, model, serial_number, _, channels, release_delay = device
1418
+ found[port] = (serial_config, device_type, serial_number, model, manufacturer,
1419
+ channels, release_delay)
1420
+ snu = serial_number if serial_number else 'unknown'
1421
+ if not quiet:
1422
+ _logprint(
1423
+ uselog, logging.INFO,
1424
+ f'cache: {manufacturer} {model} serial number {snu} on {port}'
1425
+ )
1426
+
1427
+ if not found:
1428
+ _logprint(
1429
+ uselog,
1430
+ logging.CRITICAL,
1431
+ f'cache did not contain any matching devices: {", ".join(device_types)}'
1432
+ )
1433
+ sys.exit()
1434
+
1435
+ return found
1436
+
1437
+
1438
+ def ports_to_channels(settings, found):
1439
+ """
1440
+ Convert the per-port data structure read from the cache file (as written
1441
+ by detect.py), and convert it to a per-channel data structure that is
1442
+ necessary for scripts that need to manage multiple-channel power supplies.
1443
+
1444
+ Ignores anything in 'found' that isn't a power supply.
1445
+
1446
+ --------------------------------------------------------------------------
1447
+ args
1448
+ settings : dict
1449
+ contains core information about the test environment
1450
+ found : dict
1451
+ {port: ({port_config}, device_type,
1452
+ device_serial_number, model, channels), ...}
1453
+ e.g.
1454
+ {'/dev/cu.usbserial-AH06DY15': ({'baudrate': 9600, 'bytesize': 8,
1455
+ 'parity': 'N', 'stopbits': 1,
1456
+ 'xonxoff': False, 'dsrdtr': False,
1457
+ 'rtscts': False, 'timeout': 1,
1458
+ 'write_timeout': 1,
1459
+ 'inter_byte_timeout': None},
1460
+ 'hvpsu',
1461
+ '1272738',
1462
+ '2614b',
1463
+ 'keithley',
1464
+ ['a', 'b'])}
1465
+ --------------------------------------------------------------------------
1466
+ returns
1467
+ channels : list containing instances of class Channel
1468
+ e.g.
1469
+ [Channel(port="/dev/cu.usbserial-AH06DY15",
1470
+ config={'baudrate': 9600, 'bytesize': 8, 'parity': 'N',
1471
+ 'stopbits': 1, 'xonxoff': False, 'dsrdtr': False,
1472
+ 'rtscts': False, 'timeout': 1,
1473
+ 'write_timeout': 1, 'inter_byte_timeout': None},
1474
+ serial_number="4428182", model="2614b",
1475
+ manufacturer="keithley", channel="a", category="hvpsu"),
1476
+ Channel(port="/dev/cu.usbserial-AH06DY15",
1477
+ config={'baudrate': 9600, 'bytesize': 8, 'parity': 'N',
1478
+ 'stopbits': 1, 'xonxoff': False, 'dsrdtr': False,
1479
+ 'rtscts': False, 'timeout': 1,
1480
+ 'write_timeout': 1, 'inter_byte_timeout': None},
1481
+ serial_number="4428182", model="2614b",
1482
+ manufacturer="keithley", channel="b", category="hvpsu")]
1483
+ --------------------------------------------------------------------------
1484
+ """
1485
+ channels = []
1486
+
1487
+ for port, details in found.items():
1488
+ (config, device_type, serial_number, model,
1489
+ manufacturer, psu_channels, release_delay) = details
1490
+
1491
+ if device_type not in {'hvpsu', 'lvpsu'}:
1492
+ continue
1493
+
1494
+ for channel in psu_channels:
1495
+ channels.append(Channel(port, config, serial_number,
1496
+ model, manufacturer, channel,
1497
+ device_type, release_delay,
1498
+ settings['alias']))
1499
+
1500
+ return channels
1501
+
1502
+
1503
+ def exclude_channels(settings, channels):
1504
+ """
1505
+ Exclude power supply channels from testing that the user has
1506
+ specified using the --alias command line option. The default is to leave
1507
+ a channel off.
1508
+
1509
+ --------------------------------------------------------------------------
1510
+ args
1511
+ settings : dictionary
1512
+ contains core information about the test environment
1513
+ channels : list containing instances of class Channel
1514
+ e.g.
1515
+ [Channel(port="/dev/cu.usbserial-AH06DY15",
1516
+ config={'baudrate': 9600, 'bytesize': 8, 'parity': 'N',
1517
+ 'stopbits': 1, 'xonxoff': False, 'dsrdtr': False,
1518
+ 'rtscts': False, 'timeout': 1,
1519
+ 'write_timeout': 1, 'inter_byte_timeout': None},
1520
+ serial_number="4428182", model="2614b",
1521
+ manufacturer="keithley", channel="a", category="hvpsu"),
1522
+ Channel(port="/dev/cu.usbserial-AH06DY15",
1523
+ config={'baudrate': 9600, 'bytesize': 8, 'parity': 'N',
1524
+ 'stopbits': 1, 'xonxoff': False, 'dsrdtr': False,
1525
+ 'rtscts': False, 'timeout': 1,
1526
+ 'write_timeout': 1, 'inter_byte_timeout': None},
1527
+ serial_number="4428182", model="2614b",
1528
+ manufacturer="keithley", channel="b", category="hvpsu")]
1529
+ --------------------------------------------------------------------------
1530
+ returns
1531
+ channels : list containing instances of class Channel
1532
+ e.g. see above
1533
+ --------------------------------------------------------------------------
1534
+ """
1535
+ try:
1536
+ return [channel
1537
+ for channel in channels
1538
+ if _lookup_reference(channel, separator='_') not in settings['ignore']]
1539
+ except TypeError:
1540
+ return channels
1541
+
1542
+
1543
+ ##############################################################################
1544
+ # motion controller
1545
+ ##############################################################################
1546
+
1547
+ def stage_speed(controller_type, stage, speed):
1548
+ """
1549
+ mm4006 does not have a command to return the stage type, so assume that
1550
+ the stage is an ims400ccha as this matches the experimental setup.
1551
+
1552
+ --------------------------------------------------------------------------
1553
+ args
1554
+ controller_type : string
1555
+ name of the controller
1556
+ stage : string
1557
+ name of the stage
1558
+ speed : string
1559
+ 'slow', 'normal' or 'fast'
1560
+ --------------------------------------------------------------------------
1561
+ returns : tuple (int, int)
1562
+ acceleration, max_velocity
1563
+ --------------------------------------------------------------------------
1564
+ """
1565
+ ils150 = {'slow': (5, 2), 'normal': (5, 5), 'fast': (10, 10)}
1566
+ ims400 = {'slow': (6, 4), 'normal': (6, 8), 'fast': (6, 12)}
1567
+ accvel = {'ils150pp': ils150, 'ims400': ims400}
1568
+
1569
+ if 'mm4006' in controller_type and 'unknown' in stage:
1570
+ stage = 'ims400'
1571
+
1572
+ return accvel[stage][speed]
1573
+
1574
+
1575
+ ##############################################################################
1576
+ # serial port interaction
1577
+ ##############################################################################
1578
+
1579
+ def rs232_port_is_valid(com):
1580
+ """
1581
+ Detect if serial port is an FTDI device.
1582
+
1583
+ Compare each string to each variable; a match in any one is sufficient
1584
+ for the whole test to pass. The values in com may be None, 'n/a' or the
1585
+ text supplied by a detected device.
1586
+
1587
+ The FTDI USB to RS232 adapter deployed in the test environment is:
1588
+ Startech Network Adapter ICUSB2321F
1589
+ https://www.startech.com/en-us/cards-adapters/icusb2321f
1590
+ https://uk.rs-online.com/web/p/serial-converters-extenders/1238048/
1591
+
1592
+ Oncology Systems Limited (OSL) recommended USB to RS232 adaptor for IBA
1593
+ products is also based on an FTDI chipset (untested):
1594
+ https://www.delock.de/produkt/61460/merkmale.html?setLanguage=en
1595
+
1596
+ --------------------------------------------------------------------------
1597
+ args
1598
+ com : stlp.ListPortInfo
1599
+ contains human-readable information regarding the serial port
1600
+ --------------------------------------------------------------------------
1601
+ returns
1602
+ success : bool
1603
+ True if this is an FTDI device, False otherwise
1604
+ --------------------------------------------------------------------------
1605
+ """
1606
+ # The Digilent Nexys 4 Artix-7 FPGA Trainer Board used as part of the
1607
+ # neutron detector setup shares data with Sam's Windows DAQ application
1608
+ # via a shared UART/JTAG USB port. This uses an onboard FTDI device, so
1609
+ # ensure no scripts in this suite will interact with it.
1610
+ if com.hwid == 'USB VID:PID=0403:6010 SER=210274552605B':
1611
+ return False
1612
+
1613
+ success = False
1614
+ test_strings = ('FTDI', 'FT232R', 'FT232R')
1615
+ test_variables = (com.manufacturer, com.product, com.description)
1616
+
1617
+ for tstr, tvar in zip(test_strings, test_variables):
1618
+ try:
1619
+ test = tstr in tvar
1620
+ except TypeError:
1621
+ pass
1622
+ else:
1623
+ success = success or test
1624
+ if test:
1625
+ break
1626
+
1627
+ return success
1628
+
1629
+
1630
+ def atomic_send_command_read_response(pipeline, ser, dev, command):
1631
+ """
1632
+ Atomic send serial port command and receive reply.
1633
+
1634
+ Each serial port associated with a power supply has a lock, and power
1635
+ supplies are managed on a per-channel basis. The locks are used to ensure
1636
+ that threads controlling channels on multiple-channel PSUs do not attempt
1637
+ to use their shared serial port at the same time.
1638
+
1639
+ An exception on the initial serial port write should not happen unless
1640
+ the serial port has been disconnected from the computer.
1641
+
1642
+ --------------------------------------------------------------------------
1643
+ args
1644
+ pipeline : instance of class Production
1645
+ contains all the queues through which the production pipeline
1646
+ processes communicate
1647
+ ser : serial.Serial
1648
+ reference for serial port
1649
+ dev : instance of class Channel
1650
+ contains details of a device and its serial port
1651
+ command : bytes
1652
+ command to be sent via RS232 to the power supply
1653
+ --------------------------------------------------------------------------
1654
+ returns
1655
+ response : string or None
1656
+ returned command string
1657
+ --------------------------------------------------------------------------
1658
+ """
1659
+ # only proceed if no other thread is using this port
1660
+ with pipeline.portaccess[dev.port]:
1661
+ # if this is a multiple-channel psu, allow some settling time before
1662
+ # accessing the serial port
1663
+ with contextlib.suppress(TypeError):
1664
+ time.sleep(dev.release_delay)
1665
+
1666
+ try:
1667
+ ser.write(command)
1668
+ except serial.SerialException:
1669
+ response = None
1670
+ message = f'{dev.ident}, cannot access serial port'
1671
+ log_with_colour(logging.ERROR, message)
1672
+ else:
1673
+ # attempt to read back response
1674
+ try:
1675
+ response = ser.readline()
1676
+ except serial.SerialException:
1677
+ response = None
1678
+ message = f'{dev.ident}, cannot access serial port'
1679
+ log_with_colour(logging.ERROR, message)
1680
+ else:
1681
+ if dev.manufacturer == 'iseg':
1682
+ # ISEG SHQ replies with local echo on first line
1683
+ # and response on the second line
1684
+ response = ser.readline()
1685
+
1686
+ # AttributeError: handle case where response is None
1687
+ with contextlib.suppress(AttributeError):
1688
+ response = response.strip().decode('utf-8', errors='replace')
1689
+
1690
+ return response
1691
+
1692
+
1693
+ def send_command(pipeline, ser, dev, command):
1694
+ """
1695
+ Send serial port command only.
1696
+
1697
+ This is typically used where a response is not expected from the power
1698
+ supply and attempting to read a response will only result in a "long" wait
1699
+ for the serial port to time out. In general do not use this function with
1700
+ ISEG power supplies, since they always echo something, and whatever that
1701
+ is needs to be consumed to prevent problems later.
1702
+
1703
+ Each serial port associated with a power supply has a lock, and power
1704
+ supplies are managed on a per-channel basis. The locks are used to ensure
1705
+ that threads controlling channels on multiple-channel PSUs do not attempt
1706
+ to use their shared serial port at the same time.
1707
+
1708
+ An exception on the initial serial port write should not happen unless
1709
+ the serial port has been disconnected from the computer.
1710
+
1711
+ --------------------------------------------------------------------------
1712
+ args
1713
+ pipeline : instance of class Production
1714
+ contains all the queues through which the production pipeline
1715
+ processes communicate
1716
+ ser : serial.Serial
1717
+ reference for serial port
1718
+ dev : instance of class Channel
1719
+ contains details of a device and its serial port
1720
+ command : bytes
1721
+ command to be sent via RS232 to the power supply
1722
+ --------------------------------------------------------------------------
1723
+ returns : none
1724
+ --------------------------------------------------------------------------
1725
+ """
1726
+ with pipeline.portaccess[dev.port]:
1727
+ # if this is a multiple-channel psu, allow some settling time before
1728
+ # accessing the serial port
1729
+ with contextlib.suppress(TypeError):
1730
+ time.sleep(dev.release_delay)
1731
+
1732
+ try:
1733
+ ser.write(command)
1734
+ except serial.SerialException:
1735
+ message = f'{dev.ident}, cannot access serial port'
1736
+ log_with_colour(logging.ERROR, message)
1737
+
1738
+
1739
+ def check_ports_accessible(found, channels, close_after_check=True):
1740
+ """
1741
+ Check that all the ports listed in cache entries can be successfully
1742
+ opened.
1743
+
1744
+ This attempts to open unique serial ports. Hence, for multiple-channel
1745
+ devices, only a single attempt is made to open its port.
1746
+
1747
+ --------------------------------------------------------------------------
1748
+ args
1749
+ found : dict
1750
+ {port: ({port_config}, device_type,
1751
+ device_serial_number, model), ...}
1752
+ e.g.
1753
+ {'/dev/cu.usbserial-AH06DY15': ({'baudrate': 9600, 'bytesize': 8,
1754
+ 'parity': 'N', 'stopbits': 1,
1755
+ 'xonxoff': False, 'dsrdtr': False,
1756
+ 'rtscts': False, 'timeout': 1,
1757
+ 'write_timeout': 1,
1758
+ 'inter_byte_timeout': None},
1759
+ 'hvpsu',
1760
+ '1272738',
1761
+ '2410')}
1762
+ channels : list containing instances of class Channel
1763
+ close_after_check : bool
1764
+ defaults to True, psustat.py sets this to False to keep checked
1765
+ ports open.
1766
+ --------------------------------------------------------------------------
1767
+ returns
1768
+ spd : dict
1769
+ {port: serial_port_identifier, ...}
1770
+ E.g.
1771
+ {'/dev/ttyUSB0': Serial<id=0xaf4e9c70, open=True>
1772
+ (port='/dev/ttyUSB0', baudrate=9600, bytesize=8,
1773
+ parity='N', stopbits=1, timeout=1,
1774
+ xonxoff=False, rtscts=True, dsrdtr=False),
1775
+ '/dev/ttyUSB1': Serial<id=0xaf553b50, open=True>
1776
+ (port='/dev/ttyUSB1', baudrate=9600, bytesize=8,
1777
+ parity='N', stopbits=1, timeout=1,
1778
+ xonxoff=False, rtscts=False, dsrdtr=False)}
1779
+ This is ignored by most callers, it's only currently used by
1780
+ psustat.py.
1781
+ channels : list containing instances of class Channel
1782
+ no explicit return - mutable type amended in place
1783
+ --------------------------------------------------------------------------
1784
+ """
1785
+ port_missing = False
1786
+
1787
+ spd = {}
1788
+ for port, value in found.items():
1789
+ config, *_ = value
1790
+
1791
+ ser = serial.Serial()
1792
+ ser.apply_settings(config)
1793
+ ser.port = port
1794
+ try:
1795
+ ser.open()
1796
+ except (FileNotFoundError, OSError, serial.SerialException):
1797
+ port_missing = True
1798
+ message = f'could not open port {port}'
1799
+ log_with_colour(logging.ERROR, message)
1800
+ break
1801
+ else:
1802
+ if close_after_check:
1803
+ ser.close()
1804
+ else:
1805
+ spd[port] = ser
1806
+
1807
+ if port_missing:
1808
+ # remove everything from channels to prevent any data being taken
1809
+ # given that it's probably better to have the user fix the problem
1810
+ # with the test configuration, rather than continuing the test
1811
+ # with part(s) of the setup inactive
1812
+ channels.clear()
1813
+ message = 'not all serial ports listed in the cache were accessible'
1814
+ log_with_colour(logging.ERROR, message)
1815
+ message = 'check connections and/or run ./detect.py'
1816
+ log_with_colour(logging.ERROR, message)
1817
+
1818
+ return spd
1819
+
1820
+
1821
+ def missing_ports(found):
1822
+ """
1823
+ Check that all the ports listed in cache entries can be successfully
1824
+ opened.
1825
+
1826
+ --------------------------------------------------------------------------
1827
+ args
1828
+ found : dict
1829
+ {port: ({port_config}, device_type,
1830
+ device_serial_number, model), ...}
1831
+ e.g.
1832
+ {'/dev/cu.usbserial-AH06DY15': ({'baudrate': 9600, 'bytesize': 8,
1833
+ 'parity': 'N', 'stopbits': 1,
1834
+ 'xonxoff': False, 'dsrdtr': False,
1835
+ 'rtscts': False, 'timeout': 1,
1836
+ 'write_timeout': 1,
1837
+ 'inter_byte_timeout': None},
1838
+ 'hvpsu',
1839
+ '1272738',
1840
+ '2410')}
1841
+ --------------------------------------------------------------------------
1842
+ returns
1843
+ port_missing : bool
1844
+ True if at least one port could not be successfully opened,
1845
+ False otherwise
1846
+ --------------------------------------------------------------------------
1847
+ """
1848
+ port_missing = False
1849
+
1850
+ for port, value in found.items():
1851
+ config, *_ = value
1852
+
1853
+ ser = serial.Serial()
1854
+ ser.apply_settings(config)
1855
+ ser.port = port
1856
+ try:
1857
+ ser.open()
1858
+ except (FileNotFoundError, serial.SerialException):
1859
+ port_missing = True
1860
+ print(f'could not open port {port}')
1861
+ else:
1862
+ ser.close()
1863
+
1864
+ return port_missing
1865
+
1866
+
1867
+ ##############################################################################
1868
+ # power supply interaction
1869
+ ##############################################################################
1870
+
1871
+ def _channel_mismatch(cache, user):
1872
+ """
1873
+ Check for a match between the channel identifier from the cache and the
1874
+ one supplied by the user.
1875
+
1876
+ Users are likely to incorrectly specify channel identifiers, since some
1877
+ manufacturers use letters and some use numbers. If there is an
1878
+ alphanumeric mismatch between cache and user values, this alone should not
1879
+ be sufficient to remove the channel.
1880
+
1881
+ --------------------------------------------------------------------------
1882
+ args
1883
+ cache : string, single character
1884
+ channel identifier read from cache, e.g. '1', '2', 'a' or 'b'
1885
+ user : string, single character
1886
+ channel identifier from command line, e.g. '1', '2', 'a' or 'b'
1887
+ --------------------------------------------------------------------------
1888
+ returns : bool
1889
+ True if the two arguments match, False otherwise
1890
+ --------------------------------------------------------------------------
1891
+ """
1892
+ cache_alpha = cache.isalpha()
1893
+ if cache_alpha != user.isalpha():
1894
+ if cache_alpha:
1895
+ user = chr(ord(user) - ord('1') + ord('a'))
1896
+ else:
1897
+ user = str(ord(user) - ord('a') + 1)
1898
+
1899
+ return cache != user
1900
+
1901
+
1902
+ def initial_power_supply_check(settings, pipeline, psus, channels, psuset=False):
1903
+ """
1904
+ Establishes RS232 communications with power supplies (as required).
1905
+ Checks status of channel outputs and interlocks (inhibits).
1906
+
1907
+ --------------------------------------------------------------------------
1908
+ args
1909
+ settings : dictionary
1910
+ contains core information about the test environment
1911
+ pipeline : instance of class Production
1912
+ contains all the queues through which the production pipeline
1913
+ processes communicate
1914
+ psus : dict
1915
+ {port: ({port_config}, device_type, device_serial_number), ...}
1916
+ contents of the cache filtered by hvpsu category
1917
+ channels : list
1918
+ contains instances of class Channel, one for each
1919
+ power supply channel
1920
+ psuset : bool
1921
+ selects the error message depending on the caller
1922
+ --------------------------------------------------------------------------
1923
+ returns
1924
+ channels : list
1925
+ no explicit return, mutable type amended in place
1926
+ --------------------------------------------------------------------------
1927
+ """
1928
+ port_used = collections.defaultdict(int)
1929
+ outstat = []
1930
+ intstat = []
1931
+ polstat = []
1932
+
1933
+ if settings['debug'] is None:
1934
+ check_ports_accessible(psus, channels)
1935
+
1936
+ for dev in channels:
1937
+ message = f'enabled: {_lookup_decorative(settings["alias"], dev)}'
1938
+ log_with_colour(logging.INFO, message)
1939
+
1940
+ with serial.Serial(port=dev.port) as ser:
1941
+ ser.apply_settings(dev.config)
1942
+
1943
+ # try to ensure consistent state
1944
+ # clear FTDI output buffer state before sending
1945
+ ser.reset_output_buffer()
1946
+ # clear PSU state
1947
+ if dev.model == '2614b':
1948
+ command_string = lexicon.power(dev.model, 'terminator only',
1949
+ channel=dev.channel)
1950
+ send_command(pipeline, ser, dev, command_string)
1951
+ # clear FTDI input buffer state
1952
+ ser.reset_input_buffer()
1953
+ # arbitrary settle time before proceeding
1954
+ time.sleep(0.5)
1955
+
1956
+ # ensure serial port communication with PSU
1957
+ # this is for ISEG SHQ only, on a per-PSU basis
1958
+ if not port_used[dev.port]:
1959
+ synchronise_psu(ser, pipeline, dev)
1960
+
1961
+ # check power supply output
1962
+ # but don't do this if called from psuset.py, since the user
1963
+ # of that script may be issuing a command to turn a
1964
+ # power supply channel output on
1965
+ if not psuset:
1966
+ outstat.append(report_output_status(ser, pipeline, dev))
1967
+
1968
+ # check interlock
1969
+ # this is per-PSU on Keithley, per-channel on ISEG
1970
+ if ((not port_used[dev.port] or dev.manufacturer == 'iseg')
1971
+ and dev.manufacturer not in {'agilent', 'hameg'}):
1972
+ intstat.append(_report_interlock_status(ser, pipeline, dev))
1973
+
1974
+ if dev.manufacturer == 'iseg':
1975
+ polstat.append(_report_polarity_status(settings, ser, pipeline, dev, psuset))
1976
+
1977
+ ser.reset_input_buffer()
1978
+ ser.reset_output_buffer()
1979
+
1980
+ port_used[dev.port] += 1
1981
+
1982
+ if any(outstat):
1983
+ message = 'all power supply outputs must be on to proceed'
1984
+ log_with_colour(logging.ERROR, message)
1985
+ channels.clear()
1986
+
1987
+ if any(intstat):
1988
+ message = 'all power supply interlocks must be inactive to proceed'
1989
+ log_with_colour(logging.ERROR, message)
1990
+ channels.clear()
1991
+
1992
+ if any(polstat):
1993
+ if psuset:
1994
+ message = 'set voltage should agree with polarity switch to proceed'
1995
+ log_with_colour(logging.ERROR, message)
1996
+ else:
1997
+ message = 'all polarity switches must agree with --forwardbias to proceed'
1998
+ log_with_colour(logging.ERROR, message)
1999
+
2000
+ channels.clear()
2001
+ else:
2002
+ message = '--debug: any connected power supplies will be ignored'
2003
+ log_with_colour(logging.WARNING, message)
2004
+ message = '--debug: IV data will generated internally'
2005
+ log_with_colour(logging.WARNING, message)
2006
+
2007
+
2008
+ def rate_limit(timestamp, duration):
2009
+ """
2010
+ Sleep the thread to limit the rate of change of voltage to the
2011
+ device under test. The execution time of the thread is taken into account
2012
+ to ensure that the rate of change of voltage is not limited more than
2013
+ necessary.
2014
+
2015
+ --------------------------------------------------------------------------
2016
+ args
2017
+ timestamp : float
2018
+ value previously returned by time.monotonic()
2019
+ duration : float
2020
+ nominal delay time in seconds required to not exceed the desired
2021
+ volts-per-second rate limit
2022
+ --------------------------------------------------------------------------
2023
+ returns : float
2024
+ value returned by time.monotonic(), this value will be returned to
2025
+ this function at the next call as argument timestamp
2026
+ --------------------------------------------------------------------------
2027
+ """
2028
+ if timestamp is not None:
2029
+ tdiff = time.monotonic() - timestamp
2030
+ if tdiff < duration:
2031
+ time.sleep(duration - tdiff)
2032
+ else:
2033
+ time.sleep(duration)
2034
+
2035
+ return time.monotonic()
2036
+
2037
+
2038
+ def read_psu_measured_vi(pipeline, ser, dev):
2039
+ """
2040
+ Read the voltage and current as measured at the psu output terminals
2041
+ from a high voltage power supply.
2042
+
2043
+ --------------------------------------------------------------------------
2044
+ args
2045
+ pipeline : instance of class Production
2046
+ contains all the queues through which the production pipeline
2047
+ processes communicate
2048
+ ser : serial.Serial
2049
+ reference for serial port
2050
+ dev : instance of class Channel
2051
+ contains details of a device and its serial port
2052
+ --------------------------------------------------------------------------
2053
+ returns
2054
+ measured_voltage : float or None
2055
+ measured_current : float or None
2056
+ --------------------------------------------------------------------------
2057
+ """
2058
+ measured_voltage = measured_current = None
2059
+
2060
+ if dev.manufacturer in {'agilent', 'iseg'}:
2061
+ convert = iseg_value_to_float if dev.manufacturer == 'iseg' else float
2062
+
2063
+ command_string = lexicon.power(dev.model, 'read voltage', channel=dev.channel)
2064
+ local_buffer = atomic_send_command_read_response(pipeline, ser, dev, command_string)
2065
+ with contextlib.suppress(ValueError):
2066
+ measured_voltage = convert(local_buffer)
2067
+
2068
+ command_string = lexicon.power(dev.model, 'read current', channel=dev.channel)
2069
+ local_buffer = atomic_send_command_read_response(pipeline, ser, dev, command_string)
2070
+ with contextlib.suppress(ValueError):
2071
+ measured_current = convert(local_buffer)
2072
+
2073
+ if measured_voltage is None or measured_current is None:
2074
+ measured_voltage = measured_current = None
2075
+
2076
+ elif dev.manufacturer == 'keithley':
2077
+ command_string = lexicon.power(dev.model, 'read measured vi', channel=dev.channel)
2078
+ local_buffer = atomic_send_command_read_response(pipeline, ser, dev, command_string)
2079
+
2080
+ if local_buffer is not None:
2081
+ separator = ',' if dev.model == '2410' else None
2082
+ items = (float(x) for x in local_buffer.split(separator))
2083
+
2084
+ try:
2085
+ measured_voltage = next(items)
2086
+ measured_current = next(items)
2087
+ except (StopIteration, ValueError):
2088
+ measured_voltage = measured_current = None
2089
+ message = f'{dev.ident} problem reading measured vi'
2090
+ log_with_colour(logging.WARNING, message)
2091
+
2092
+ return measured_voltage, measured_current
2093
+
2094
+
2095
+ def _report_interlock_status(ser, pipeline, dev):
2096
+ """
2097
+ Check status of power supply interlock.
2098
+
2099
+ This is per-PSU on Keithley, per-channel on ISEG.
2100
+
2101
+ --------------------------------------------------------------------------
2102
+ args
2103
+ ser : serial.Serial
2104
+ reference for serial port
2105
+ pipeline : instance of class Production
2106
+ contains all the queues through which the production pipeline
2107
+ processes communicate
2108
+ dev : instance of class Channel
2109
+ contains details of a device and its serial port
2110
+ --------------------------------------------------------------------------
2111
+ returns : none
2112
+ --------------------------------------------------------------------------
2113
+ """
2114
+ assert dev.manufacturer not in {'agilent', 'hameg'},\
2115
+ 'function not callable for Agilent or Hameg PSU'
2116
+
2117
+ fail = False
2118
+ interlock_set = False
2119
+
2120
+ command_string = lexicon.power(dev.model, 'check interlock', channel=dev.channel)
2121
+ register = atomic_send_command_read_response(pipeline, ser, dev, command_string)
2122
+
2123
+ if dev.manufacturer == 'iseg':
2124
+ # Interlocks are per-channel for ISEG SHQ, so include the channel
2125
+ # identifier in the ident string. For consistency with Keithley below
2126
+ # do not report the alias to the user.
2127
+ ident = _lookup_reference(dev)
2128
+ interlock_set = '=INH' in register
2129
+ else:
2130
+ # Interlocks are per-PSU for Keithley, so omit the channel identifier
2131
+ # if it exists.
2132
+ ident = _lookup_reference(dev, use_channel=False)
2133
+ try:
2134
+ regval = int(float(register))
2135
+ except ValueError:
2136
+ message = f'{ident}, problem checking interlock'
2137
+ log_with_colour(logging.WARNING, message)
2138
+ fail = True
2139
+ else:
2140
+ if dev.model == '2614b':
2141
+ # bit 11 (status.measurement.INTERLOCK) p.11-280 (648)
2142
+ # Without hardware interlock: '0.00000e+00'
2143
+ # with hardware interlock: '2.04800e+03'
2144
+ interlock_set = regval & 2048 == 0
2145
+ elif dev.model == '2410':
2146
+ if regval in {0, 1}:
2147
+ # p.18-9 (389)
2148
+ # returns '0' (disabled - no restrictions) or '1' (enabled)
2149
+ interlock_set = register == 0
2150
+ else:
2151
+ message = f'{ident}, problem checking interlock'
2152
+ log_with_colour(logging.WARNING, message)
2153
+
2154
+ if interlock_set:
2155
+ message = f'{ident}, interlock active'
2156
+ log_with_colour(logging.WARNING, message)
2157
+ fail = True
2158
+
2159
+ return fail
2160
+
2161
+
2162
+ def report_output_status(ser, pipeline, dev):
2163
+ """
2164
+ Check that the output for the given power supply channel is on.
2165
+
2166
+ While the outputs may be switched on/off over RS232 for all the supported
2167
+ power supplies except ISEG SHQ, this is generally used as a check to make
2168
+ sure the user has configured the test environment correctly.
2169
+
2170
+ Values returned in variable output:
2171
+
2172
+ +------------------+-----------+---------------+---------------+
2173
+ | dev.manufacturer | dev.model | output OFF | output ON |
2174
+ +------------------+-----------+---------------+---------------+
2175
+ | 'agilent' | 'e3634a' | '0' | '1' |
2176
+ | 'agilent' | 'e3647a' | '0' | '1' |
2177
+ | 'hameg' | 'hmp4040' | '0' | '1' |
2178
+ | 'iseg' | 'shq' | '=OFF' | '=ON' |
2179
+ | 'keithley' | '2410' | '0' | '1' |
2180
+ | 'keithley' | '2614b' | '0.00000e+00' | '1.00000e+00' |
2181
+ +------------------+-----------+---------------+---------------+
2182
+
2183
+ --------------------------------------------------------------------------
2184
+ args
2185
+ ser : serial.Serial
2186
+ reference for serial port
2187
+ pipeline : instance of class Production
2188
+ contains all the queues through which the production pipeline
2189
+ processes communicate
2190
+ dev : instance of class Channel
2191
+ contains details of a device and its serial port
2192
+ --------------------------------------------------------------------------
2193
+ returns
2194
+ fail : bool
2195
+ True if the output was found to be off, False otherwise
2196
+ --------------------------------------------------------------------------
2197
+ """
2198
+ fail = False
2199
+
2200
+ command_string = lexicon.power(dev.model, 'check output', channel=dev.channel)
2201
+ output = atomic_send_command_read_response(pipeline, ser, dev, command_string)
2202
+
2203
+ if dev.manufacturer == 'iseg':
2204
+ if '=ON' not in output:
2205
+ log_with_colour(logging.WARNING, f'{dev.ident}, output off')
2206
+ fail = True
2207
+
2208
+ elif dev.manufacturer in {'agilent', 'hameg', 'keithley'}:
2209
+ try:
2210
+ outval = int(float(output))
2211
+ except ValueError:
2212
+ message = f'{dev.ident}, problem checking output'
2213
+ log_with_colour(logging.WARNING, message)
2214
+ fail = True
2215
+ else:
2216
+ if outval in {0, 1}:
2217
+ if outval == 0:
2218
+ message = f'{dev.ident}, output off'
2219
+ log_with_colour(logging.WARNING, message)
2220
+ fail = True
2221
+ else:
2222
+ message = f'{dev.ident}, problem checking output'
2223
+ log_with_colour(logging.WARNING, message)
2224
+ fail = True
2225
+
2226
+ return fail
2227
+
2228
+
2229
+ def _report_polarity_status(settings, ser, pipeline, dev, psuset):
2230
+ """
2231
+ Returned string from 'check module status' command appears to be base 10.
2232
+
2233
+ e.g.
2234
+ inhibit is bit 5 (0=inactive, 1=active),
2235
+ polarity is bit 2 (0=negative, 1=positive)
2236
+
2237
+ For channel 1, with inhibit active (terminator removed from front
2238
+ panel inhibit socket) and the polarity switch set to POS on the
2239
+ rear panel, the 'S1' command returns '036'
2240
+
2241
+ e.g.
2242
+ polarity set to positive: '004', negative: '000'
2243
+
2244
+ --------------------------------------------------------------------------
2245
+ args
2246
+ settings : dictionary
2247
+ contains core information about the test environment
2248
+ ser : serial.Serial
2249
+ reference for serial port
2250
+ pipeline : instance of class Production
2251
+ contains all the queues through which the production pipeline
2252
+ processes communicate
2253
+ dev : instance of class Channel
2254
+ contains details of a device and its serial port
2255
+ psuset : bool
2256
+ selects the error message depending on the caller
2257
+ --------------------------------------------------------------------------
2258
+ returns
2259
+ fail : bool
2260
+ True if the channel's hardware-set polarity was found to conflict
2261
+ with the user's forward/reverse bias command line setting,
2262
+ False otherwise
2263
+ --------------------------------------------------------------------------
2264
+ """
2265
+ assert dev.model == 'shq', 'function only callable for ISEG SHQ PSU'
2266
+
2267
+ fail = False
2268
+ message = ''
2269
+
2270
+ command_string = lexicon.power(dev.model, 'check module status', channel=dev.channel)
2271
+ register = atomic_send_command_read_response(pipeline, ser, dev, command_string)
2272
+
2273
+ try:
2274
+ polarity_negative = int(register) & 4 == 0
2275
+ except ValueError:
2276
+ fail = True
2277
+ message = f'{dev.ident} problem checking polarity'
2278
+ log_with_colour(logging.WARNING, message)
2279
+ else:
2280
+ poltxt = 'negative' if polarity_negative else 'positive'
2281
+ polvol = 'negative' if settings['voltage'] < 0 else 'positive'
2282
+
2283
+ if psuset:
2284
+ if settings['voltage'] > 0 != polarity_negative:
2285
+ fail = True
2286
+ message = (f'{dev.ident}, conflict between set voltage ({polvol}) '
2287
+ f'and rear panel polarity switch ({poltxt})')
2288
+
2289
+ elif settings['forwardbias'] == polarity_negative:
2290
+ # forwardbias is a setting from iv.py
2291
+ fail = True
2292
+ message = (f'{dev.ident}, forward bias setting ({settings["forwardbias"]}) '
2293
+ f'conflicts with rear panel channel polarity switch ({poltxt})')
2294
+
2295
+ if fail:
2296
+ log_with_colour(logging.WARNING, message)
2297
+
2298
+ return fail
2299
+
2300
+
2301
+ def set_psu_voltage(settings, pipeline, voltage, ser, dev):
2302
+ """
2303
+ Set voltage on PSU.
2304
+
2305
+ For the Keithley 2410's 1kV range, voltages can only be specified to two
2306
+ decimal places, though setting digits in the second decimal place is
2307
+ unreliable (read back values do not always match) so values should be
2308
+ limited to one decimal place.
2309
+
2310
+ The ISEG SHQ ramps voltage to the desired value instead of setting it
2311
+ instantaneously, therefore poll status to check it has reached the
2312
+ desired.
2313
+
2314
+ --------------------------------------------------------------------------
2315
+ args
2316
+ settings : dictionary
2317
+ contains core information about the test environment
2318
+ pipeline : instance of class Production
2319
+ contains all the queues through which the production pipeline
2320
+ processes communicate
2321
+ voltage : int
2322
+ ser : serial.Serial
2323
+ reference for serial port
2324
+ dev : instance of class Channel
2325
+ contains details of a device and its serial port
2326
+ --------------------------------------------------------------------------
2327
+ returns : none
2328
+ --------------------------------------------------------------------------
2329
+ """
2330
+ target_voltage = voltage
2331
+ if dev.manufacturer == 'iseg':
2332
+ # ISEG sets output channel polarity with hardware screws on the
2333
+ # rear panel, all voltages issued to the PSU have to be positive.
2334
+ voltage = abs(voltage)
2335
+
2336
+ # constrain the voltage to be set to the given number of decimal places
2337
+ voltage = decimal_quantize(voltage, settings['decimal_places'])
2338
+
2339
+ # set voltage
2340
+ command_string = lexicon.power(dev.model, 'set voltage', voltage, channel=dev.channel)
2341
+ if dev.manufacturer in {'agilent', 'keithley', 'hameg'}:
2342
+ send_command(pipeline, ser, dev, command_string)
2343
+ elif dev.manufacturer == 'iseg':
2344
+ atomic_send_command_read_response(pipeline, ser, dev, command_string)
2345
+ wait_for_voltage_to_stabilise(ser, pipeline, dev, target_voltage)
2346
+
2347
+ if dev.manufacturer == 'agilent':
2348
+ command_string = lexicon.power(dev.model, 'clear event registers', channel=dev.channel)
2349
+ atomic_send_command_read_response(pipeline, ser, dev, command_string)
2350
+
2351
+
2352
+ def set_psu_voltage_and_read(settings, pipeline, voltage, ser, dev, settling_time=2):
2353
+ """
2354
+ Set voltage on PSU then read back the measured voltage and current.
2355
+
2356
+ For the Keithley 2410's 1kV range, voltages can only be specified to two
2357
+ decimal places, though setting digits in the second decimal place is
2358
+ unreliable (read back values do not always match) so values should be
2359
+ limited to one decimal place.
2360
+
2361
+ The ISEG SHQ ramps voltage to the desired value instead of setting it
2362
+ instantaneously, therefore poll status to check it has reached the
2363
+ desired.
2364
+
2365
+ --------------------------------------------------------------------------
2366
+ args
2367
+ settings : dictionary
2368
+ contains core information about the test environment
2369
+ pipeline : instance of class Production
2370
+ contains all the queues through which the production pipeline
2371
+ processes communicate
2372
+ voltage : int
2373
+ ser : serial.Serial
2374
+ reference for serial port
2375
+ dev : instance of class Channel
2376
+ contains details of a device and its serial port
2377
+ settling_time : int
2378
+ seconds
2379
+ --------------------------------------------------------------------------
2380
+ returns float, float
2381
+ measured_voltage, measured_current
2382
+ --------------------------------------------------------------------------
2383
+ """
2384
+ set_psu_voltage(settings, pipeline, voltage, ser, dev)
2385
+
2386
+ time.sleep(settling_time)
2387
+
2388
+ return read_psu_measured_vi(pipeline, ser, dev)
2389
+
2390
+
2391
+ def synchronise_psu(ser, pipeline, dev):
2392
+ """
2393
+ Only executed for ISEG SHQ power supplies.
2394
+
2395
+ "In order to assure synchronisation between the computer and the supply,
2396
+ <CR><LF> has to be sent as first command."
2397
+
2398
+ RS-232 Interface Programmers Guide for SHQ Devices, p.3
2399
+
2400
+ --------------------------------------------------------------------------
2401
+ args
2402
+ ser : serial.Serial
2403
+ reference for serial port
2404
+ pipeline : instance of class Production
2405
+ contains all the queues through which the production pipeline
2406
+ processes communicate
2407
+ dev : instance of class Channel
2408
+ contains details of a device and its serial port
2409
+ --------------------------------------------------------------------------
2410
+ returns : none
2411
+ --------------------------------------------------------------------------
2412
+ """
2413
+ if dev.manufacturer == 'iseg':
2414
+ command_string = lexicon.power(dev.model, 'synchronise')
2415
+ atomic_send_command_read_response(pipeline, ser, dev, command_string)
2416
+
2417
+
2418
+ def unique(settings, psus, channels):
2419
+ """
2420
+ Remove all power supply channels that do not match the command line
2421
+ options that specifically identify which power supply channel to set:
2422
+
2423
+ --manufacturer
2424
+ --model
2425
+ --serial
2426
+ --channel
2427
+ --port
2428
+
2429
+ Two measures are used to limit the amount of typing the user has to
2430
+ perform:
2431
+
2432
+ (1) for --serial and --port: allow the user to enter
2433
+ partial information, e.g. where there are two power supplies:
2434
+
2435
+ cache: keithley 2410 serial number 4343654 on /dev/cu.usbserial-AH06DY15
2436
+ cache: keithley 2614b serial number 4428182 on /dev/cu.usbserial-AH06DWHE
2437
+
2438
+ using --serial 654 would be sufficient to identify the keithley 2410
2439
+ since 654 does not appear in the other serial number.
2440
+
2441
+ (2) for --channel, allow the user to mix up alphabetic and numeric
2442
+ identifiers, so the following pairings are viewed as equivalent:
2443
+
2444
+ '1' and 'a'
2445
+ '2' and 'b'
2446
+
2447
+ --------------------------------------------------------------------------
2448
+ args
2449
+ settings : dict
2450
+ contains core information about the test environment
2451
+ psus : dict
2452
+ {port: ({port_config}, device_type, device_serial_number), ...}
2453
+ contents of the cache filtered by hvpsu and lvpsu categories
2454
+ channels : list
2455
+ contains instances of class Channel, one for each
2456
+ power supply channel
2457
+ --------------------------------------------------------------------------
2458
+ returns
2459
+ single : bool
2460
+ True if only a single channel is left, False otherwise
2461
+ psus : dict
2462
+ no explicit return, mutable type amended in place
2463
+ channels : list
2464
+ no explicit return, mutable type amended in place
2465
+ --------------------------------------------------------------------------
2466
+ """
2467
+ # user supplied command line values
2468
+ user = (settings['manufacturer'], settings['model'], settings['serial'],
2469
+ settings['port'], settings['channel'])
2470
+
2471
+ # tests for manufacturer, model, serial_number and port
2472
+ def generic(cached_values, user_value):
2473
+ return user_value not in cached_values
2474
+
2475
+ test = (generic, generic, generic, generic, _channel_mismatch)
2476
+
2477
+ for channel in copy.deepcopy(channels):
2478
+ cache = (channel.manufacturer, channel.model, channel.serial_number,
2479
+ channel.port, channel.channel)
2480
+ if any(t(c, u) for c, u, t in zip(cache, user, test) if u is not None):
2481
+ channels.remove(channel)
2482
+
2483
+ single = len(channels) == 1
2484
+ if single:
2485
+ # discard surplus entries in psus data structure: only retain power
2486
+ # supplies with ports matching those remaining in channels
2487
+ channel_ports = {channel.port for channel in channels}
2488
+ for psu_port in set(psus):
2489
+ if psu_port not in channel_ports:
2490
+ del psus[psu_port]
2491
+
2492
+ return single
2493
+
2494
+
2495
+ def wait_for_voltage_to_stabilise(ser, pipeline, dev, target_voltage):
2496
+ """
2497
+ Because ISEG SHQ ramps up voltage instead of setting it instantaneously,
2498
+ need to poll status to check it has reached its destination.
2499
+
2500
+ Note that even when the response to the command contains ON - indicating
2501
+ that the power supply deems that the voltage set has been reached - the
2502
+ front panel display and the measured voltage both indicate that there is
2503
+ still some additional settling time.
2504
+
2505
+ The front panel display has less resolution available than the value read
2506
+ back over the serial port connection, and does show some rounding errors.
2507
+
2508
+ FIXME - note that the ISEG SHQ often doesn't want to settle to the set
2509
+ voltage value, particularly in the -5 < n < 5 range. e.g. d1=1, then check
2510
+ with d1 '00010-10' to indicate the PSU has understood the command, then
2511
+ minutes later u1 still shows it has not converged '-00022-01'. It may be
2512
+ better to simply perform a ramp and sample at 1s intervals. The ISEG SHQ
2513
+ 222M (2x 2kV / 6mA) seems to perform better in this regard than the ISEG
2514
+ SHQ 224M (2x 4kV / 3mA)
2515
+
2516
+ --------------------------------------------------------------------------
2517
+ args
2518
+ ser : serial.Serial
2519
+ reference for serial port
2520
+ pipeline : instance of class Production
2521
+ contains all the queues through which the production pipeline
2522
+ processes communicate
2523
+ dev : instance of class Channel
2524
+ contains details of a device and its serial port
2525
+ target_voltage : numeric
2526
+ FIXME the sign of this argument may not match the measured voltage
2527
+ as read back from the instrument - the caller should make sure it
2528
+ matches
2529
+ --------------------------------------------------------------------------
2530
+ returns : none
2531
+ --------------------------------------------------------------------------
2532
+ """
2533
+ assert dev.model == 'shq', 'function only callable for ISEG SHQ PSU'
2534
+ target_voltage = float(target_voltage)
2535
+
2536
+ while True:
2537
+ command_string = lexicon.power(dev.model, 'check output', channel=dev.channel)
2538
+ local_buffer = atomic_send_command_read_response(pipeline, ser, dev, command_string)
2539
+
2540
+ if '=ON' in local_buffer:
2541
+ break
2542
+
2543
+ time.sleep(0.5)
2544
+
2545
+ # the psu voltage may still need to stabilise
2546
+ for _ in itertools.repeat(None, 16):
2547
+ command_string = lexicon.power(dev.model, 'read voltage', channel=dev.channel)
2548
+ local_buffer = atomic_send_command_read_response(pipeline, ser, dev, command_string)
2549
+ measured_voltage = iseg_value_to_float(local_buffer)
2550
+
2551
+ diff = abs(abs(target_voltage) - abs(measured_voltage))
2552
+ if diff <= 0.5 or diff < abs(target_voltage) * 0.04:
2553
+ break
2554
+
2555
+ time.sleep(1)
2556
+ else:
2557
+ mev = f'{si_prefix(measured_voltage)}V'
2558
+ tav = f'{si_prefix(target_voltage)}V'
2559
+ message = (f'{dev.ident}, measured voltage {mev} '
2560
+ f'did not converge to set voltage {tav}')
2561
+ log_with_colour(logging.WARNING, message)
2562
+
2563
+
2564
+ ##############################################################################
2565
+ # logging
2566
+ ##############################################################################
2567
+
2568
+ def log_with_colour(level, message, quiet=False):
2569
+ """
2570
+ Write messages to the log file. This can be safely called from threads,
2571
+ but NOT from processes.
2572
+
2573
+ "Thread Safety
2574
+
2575
+ The logging module is intended to be thread-safe without any special work
2576
+ needing to be done by its clients. It achieves this though using threading
2577
+ locks; there is one lock to serialize access to the module’s shared data,
2578
+ and each handler also creates a lock to serialize access to its underlying
2579
+ I/O."
2580
+
2581
+ https://docs.python.org/3/library/logging.html#thread-safety
2582
+
2583
+ --------------------------------------------------------------------------
2584
+ args
2585
+ level : int
2586
+ logging level e.g. logging.DEBUG
2587
+ message : string
2588
+ message to be sent to the log file
2589
+ quiet : bool
2590
+ do not log
2591
+ --------------------------------------------------------------------------
2592
+ returns : none
2593
+ --------------------------------------------------------------------------
2594
+ """
2595
+ if quiet:
2596
+ return
2597
+
2598
+ if level == logging.WARNING:
2599
+ message = (f'{ANSIColours.FG_BLACK}{ANSIColours.BG_YELLOW}'
2600
+ f'{ANSIColours.BOLD}{message}{ANSIColours.ENDC}')
2601
+ elif level in {logging.ERROR, logging.CRITICAL}:
2602
+ message = (f'{ANSIColours.FG_WHITE}{ANSIColours.BG_RED}'
2603
+ f'{ANSIColours.BOLD}{message}{ANSIColours.ENDC}')
2604
+
2605
+ logging.log(level, message)