mmcb-rs232-avt 1.0.14__py3-none-any.whl → 1.0.18__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mmcb_rs232/__init__.py +0 -0
- mmcb_rs232/common.py +2605 -0
- mmcb_rs232/detect.py +1053 -0
- mmcb_rs232/dmm.py +126 -0
- mmcb_rs232/dmm_interface.py +162 -0
- mmcb_rs232/iv.py +2868 -0
- mmcb_rs232/lexicon.py +580 -0
- mmcb_rs232/psuset.py +938 -0
- mmcb_rs232/psustat.py +705 -0
- mmcb_rs232/psuwatch.py +540 -0
- mmcb_rs232/sequence.py +483 -0
- mmcb_rs232/ult80.py +500 -0
- {mmcb_rs232_avt-1.0.14.dist-info → mmcb_rs232_avt-1.0.18.dist-info}/METADATA +1 -1
- mmcb_rs232_avt-1.0.18.dist-info/RECORD +17 -0
- mmcb_rs232_avt-1.0.18.dist-info/top_level.txt +1 -0
- mmcb_rs232_avt-1.0.14.dist-info/RECORD +0 -5
- mmcb_rs232_avt-1.0.14.dist-info/top_level.txt +0 -1
- {mmcb_rs232_avt-1.0.14.dist-info → mmcb_rs232_avt-1.0.18.dist-info}/WHEEL +0 -0
- {mmcb_rs232_avt-1.0.14.dist-info → mmcb_rs232_avt-1.0.18.dist-info}/entry_points.txt +0 -0
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)
|