sodetlib 0.6.1rc1__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.
sodetlib/util.py ADDED
@@ -0,0 +1,880 @@
1
+ """
2
+ Module for miscellaneous functions and classes that are useful in many sodetlib
3
+ scripts.
4
+ """
5
+ import numpy as np
6
+ from scipy import signal
7
+ import time
8
+ import os
9
+ from collections import namedtuple
10
+ import sodetlib
11
+ from sodetlib import det_config
12
+ from sodetlib.constants import *
13
+ from sotodlib.tod_ops.fft_ops import calc_psd
14
+ from sotodlib.core import AxisManager
15
+
16
+ if not os.environ.get('NO_PYSMURF', False):
17
+ try:
18
+ import epics
19
+ import pysmurf
20
+ from pysmurf.client.command.cryo_card import cmd_make
21
+ except Exception:
22
+ os.environ['NO_PYSMURF'] = True
23
+
24
+ StreamSeg = namedtuple("StreamSeg", "times sig mask")
25
+
26
+ class TermColors:
27
+ HEADER = '\n\033[95m'
28
+ OKBLUE = '\033[94m'
29
+ OKGREEN = '\033[92m'
30
+ WARNING = '\033[93m'
31
+ FAIL = '\033[91m'
32
+ ENDC = '\033[0m'
33
+ BOLD = '\033[1m'
34
+ UNDERLINE = '\033[4m'
35
+
36
+
37
+ def cprint(msg, style=TermColors.OKBLUE):
38
+ if style is True:
39
+ style = TermColors.OKGREEN
40
+ elif style is False:
41
+ style = TermColors.FAIL
42
+ print(f"{style}{msg}{TermColors.ENDC}")
43
+
44
+
45
+ def make_filename(S, name, ctime=None, plot=False):
46
+ """
47
+ Creates a timestamped filename in the pysmurf outputs or plot directory.
48
+ """
49
+ if ctime is None:
50
+ ctime = S.get_timestamp()
51
+
52
+ if plot:
53
+ ddir = S.plot_dir
54
+ else:
55
+ ddir = S.output_dir
56
+
57
+ return os.path.join(ddir, f'{ctime}_{name}')
58
+
59
+
60
+ def _encode_data(data):
61
+ """
62
+ Encodes a data object into one that is serializable with
63
+ json so that it can be sent over UDP.
64
+ """
65
+ if isinstance(data, list):
66
+ return [_encode_data(d) for d in data]
67
+ elif isinstance(data, dict):
68
+ return {str(k): _encode_data(d) for k, d in data.items()}
69
+ elif isinstance(data, np.ndarray):
70
+ return data.tolist()
71
+ else:
72
+ return data
73
+
74
+
75
+ def set_session_data(S, key, val):
76
+ """
77
+ Adds data to the OCS Session data object. If this is being run directly
78
+ from an OCS PysmurfController operation, the _ocs_session object will be
79
+ set in the pysmurf instance. If not, publish message so pysmurf monitor can
80
+ relay data to the correct controller agent.
81
+ """
82
+ session = getattr(S, '_ocs_session', None)
83
+ if session is not None:
84
+ session.data[key] = _encode_data(val)
85
+ else:
86
+ S.pub.publish(_encode_data({key: val}), msgtype='session_data')
87
+
88
+
89
+
90
+ def pub_ocs_log(S, msg, log=True):
91
+ """
92
+ Passes a string to the OCS pysmurf controller to be logged to be passed
93
+ around the OCS network.
94
+ """
95
+ if log:
96
+ S.log(msg)
97
+
98
+ session = getattr(S, '_ocs_session', None)
99
+ if session is not None:
100
+ session.add_message(msg)
101
+
102
+ S.pub.publish(msg, msgtype='session_log')
103
+
104
+
105
+ class OCSAbortError(Exception):
106
+ """Error that's thrown when an operation is aborted through OCS"""
107
+
108
+
109
+ def stop_point(S):
110
+ """
111
+ This function will throw an error if the OCS session status is 'stopping'.
112
+ This allows the pysmurf controller to gracefully tell long-running sodetlib
113
+ functions to stop when possible without killing the agent.
114
+ """
115
+ session = getattr(S, '_ocs_session', None)
116
+ if session is not None:
117
+ if session.status == 'stopping':
118
+ raise OCSAbortError()
119
+
120
+
121
+
122
+ def load_bgmap(bands, channels, bgmap_file):
123
+ """
124
+ Loads bias-group assignments and channel polarity for
125
+ set of channels.
126
+
127
+ Args
128
+ ----
129
+ bands : np.ndarray
130
+ Array of len <nchans> containing the smurf-band of each channel
131
+ channels : np.ndarray
132
+ Array of len <nchans> containing the smurf-channel of each channel
133
+ bgmap_file : str
134
+ Path to the bgmap file.
135
+
136
+ Returns
137
+ --------
138
+ bgmap : np.ndarray
139
+ Array of lenght <nchans> containing bias-group assignments of each
140
+ channel. Unassigned channels contain -1.
141
+ polarity : np.ndarray
142
+ Array of lenght <nchans> containing the polarity of each channel,
143
+ or whether the squid current steps up or down with bias-current.
144
+ """
145
+ bgmap_full = np.load(bgmap_file, allow_pickle=True).item()
146
+ idxs = map_band_chans(
147
+ bands, channels, bgmap_full['bands'], bgmap_full['channels']
148
+ )
149
+ bgmap = bgmap_full['bgmap'][idxs]
150
+ polarity = bgmap_full['polarity'][idxs]
151
+ bgmap[idxs == -1] = -1
152
+
153
+ return bgmap, polarity
154
+
155
+
156
+ def map_band_chans(b1, c1, b2, c2, chans_per_band=512):
157
+ """
158
+ Returns an index mapping of length nchans1 from one set of bands and
159
+ channels to another. Note that unmapped indices are returned as -1, so this
160
+ must be handled before (or after) indexing or else you'll get weird
161
+ results.
162
+
163
+ Args
164
+ ----
165
+ b1 (np.ndarray):
166
+ Array of length nchans1 containing the smurf band of each channel
167
+ c1 (np.ndarray):
168
+ Array of length nchans1 containing the smurf channel of each channel
169
+ b2 (np.ndarray):
170
+ Array of length nchans2 containing the smurf band of each channel
171
+ c2 (np.ndarray):
172
+ Array of length nchans2 containing the smurf channel of each channel
173
+ chans_per_band (int):
174
+ Lets just hope this never changes.
175
+ """
176
+ acs1 = b1 * chans_per_band + c1
177
+ acs2 = b2 * chans_per_band + c2
178
+
179
+ mapping = np.full_like(acs1, -1, dtype=int)
180
+ for i, ac in enumerate(acs1):
181
+ x = np.where(acs2 == ac)[0]
182
+ if len(x > 0):
183
+ mapping[i] = x[0]
184
+
185
+ return mapping
186
+
187
+
188
+ def get_metadata(S, cfg):
189
+ """
190
+ Gets collection of metadata from smurf and detconfig instance that's
191
+ useful for pretty much everything. This should be included in all output
192
+ data files created by sodetlib.
193
+ """
194
+ return {
195
+ 'tunefile': getattr(S, 'tune_file', None),
196
+ 'high_low_current_ratio': S.high_low_current_ratio,
197
+ 'R_sh': S.R_sh,
198
+ 'pA_per_phi0': S.pA_per_phi0,
199
+ 'rtm_bit_to_volt': S._rtm_slow_dac_bit_to_volt,
200
+ 'bias_line_resistance': S.bias_line_resistance,
201
+ 'chans_per_band': S.get_number_channels(),
202
+ 'high_current_mode': get_current_mode_array(S),
203
+ 'timestamp': time.time(),
204
+ 'stream_id': cfg.stream_id,
205
+ 'action': S.pub._action,
206
+ 'action_timestamp': S.pub._action_ts,
207
+ 'bgmap_file': cfg.dev.exp.get('bgmap_file'),
208
+ 'iv_file': cfg.dev.exp.get('iv_file'),
209
+ 'v_bias': S.get_tes_bias_bipolar_array(),
210
+ 'pysmurf_client_version': pysmurf.__version__,
211
+ 'rogue_version': S._caget(f'{S.epics_root}:AMCc:RogueVersion'),
212
+ 'smurf_core_version': S._caget(f'{S.epics_root}:AMCc:SmurfApplication:SmurfVersion'),
213
+ 'sodetlib_version': sodetlib.__version__,
214
+ 'fpga_git_hash': S.get_fpga_git_hash_short(),
215
+ 'cryocard_fw_version': S.C.get_fw_version(),
216
+ 'crate_id': cfg.sys['crate_id'],
217
+ 'slot': cfg.slot,
218
+ }
219
+
220
+
221
+ def validate_and_save(fname, data, S=None, cfg=None, register=True,
222
+ make_path=True):
223
+ """
224
+ Does some basic data validation for sodetlib data files to make sure
225
+ they are normalized based on the following rules:
226
+ 1. The 'meta' key exists storing metadata from smurf and cfg instances
227
+ such as the stream-id, pysmurf constants like high-low-current-ratio
228
+ etc., action and timestamp and more. See the get_metadata function
229
+ definition for more.
230
+ 2. 'bands', and 'channels' arrays are defined, specifying the band/
231
+ channel combinations for data taking / analysis products.
232
+ 3. 'sid' field exists to contain one or more session-ids for the
233
+ analysis.
234
+
235
+ Args
236
+ ----
237
+ fname (str):
238
+ Filename or full-path of datafile. If ``make_path`` is set to True,
239
+ this will be turned into a file-path using the ``make_filename``
240
+ function. If not, this will be treated as the save-file-path
241
+ data (dict):
242
+ Data to be written to disk. This should contain at least the keys
243
+ ``bands``, ``channels``, ``sid``, along with any other data
244
+ products. If the key ``meta`` does not already exist, metadata
245
+ will be populated using the smurf and det-config instances.
246
+ S (SmurfControl):
247
+ Pysmurf instance. This must be set if registering data file,
248
+ ``meta`` is not yet set, or ``make_path`` is True.
249
+ cfg (DetConfig):
250
+ det-config instance. This must be set if registering data file,
251
+ ``meta`` is not yet set, or ``make_path`` is True.
252
+ register (bool):
253
+ If True, will register the file with the pysmurf publisher
254
+ make_path (bool):
255
+ If true, will create a path by passing fname to the function
256
+ make_filename (putting it in the pysmurf output directory)
257
+ """
258
+ if register or make_path or 'meta' not in data:
259
+ if S is None or cfg is None:
260
+ raise ValueError("Pysmurf and cfg instances must be set")
261
+
262
+ _data = data.copy()
263
+ if 'meta' not in data:
264
+ meta = get_metadata(S, cfg)
265
+ _data['meta'] = meta
266
+
267
+ for k in ['channels', 'bands', 'sid']:
268
+ if k not in data:
269
+ raise ValueError(f"Key '{k}' is required in data")
270
+
271
+ if make_path and S is not None:
272
+ path = make_filename(S, fname)
273
+ else:
274
+ path = fname
275
+
276
+ np.save(path, _data, allow_pickle=True)
277
+ if S is not None and register:
278
+ S.pub.register_file(path, 'sodetlib_data', format='npy')
279
+ return path
280
+
281
+
282
+ def get_tracking_kwargs(S, cfg, band, kwargs=None):
283
+ band_cfg = cfg.dev.bands[band]
284
+ tk = {
285
+ 'reset_rate_khz': cfg.dev.exp['flux_ramp_rate_khz'],
286
+ 'lms_freq_hz': band_cfg['lms_freq_hz'],
287
+ 'lms_gain': band_cfg['lms_gain'],
288
+ 'fraction_full_scale': band_cfg['frac_pp'],
289
+ 'make_plot': True,
290
+ 'show_plot': True,
291
+ 'channel': [],
292
+ 'nsamp': 2**18,
293
+ 'return_data': True,
294
+ 'feedback_start_frac': cfg.dev.exp['feedback_start_frac'],
295
+ 'feedback_end_frac': cfg.dev.exp['feedback_end_frac'],
296
+ 'feedback_gain': band_cfg['feedback_gain'],
297
+ }
298
+ if kwargs is not None:
299
+ tk.update(kwargs)
300
+ return tk
301
+
302
+ def get_psd(S, times, phases, detrend='constant', nperseg=2**12, fs=None):
303
+ """
304
+ Returns PSD for all channels.
305
+ Args:
306
+ S:
307
+ pysmurf.SmurfControl object
308
+ times: np.ndarray
309
+ timestamps (in ns)
310
+ phases: np.ndarray
311
+ Array of phases
312
+ detrend: str
313
+ Detrend argument to pass to signal.welch
314
+ nperseg: int
315
+ nperseg arg for signal.welch
316
+ fs: float
317
+ sample frequency for signal.welch. If None will calculate using the
318
+ timestamp array.
319
+ Returns:
320
+ f: np.ndarray
321
+ Frequencies
322
+ Pxx: np.ndarray
323
+ PSD in pA/sqrt(Hz)
324
+ """
325
+ if fs is None:
326
+ fs = 1/np.diff(times/1e9).mean()
327
+ current = phases * S.pA_per_phi0 / (2 * np.pi)
328
+ f, Pxx = signal.welch(current, detrend=detrend, nperseg=nperseg, fs=fs)
329
+ Pxx = np.sqrt(Pxx)
330
+ return f, Pxx
331
+
332
+ def get_asd(am, pA_per_phi0=9e6, **psd_kwargs):
333
+ """
334
+ Returns ASD (sqrt(PSD)) for all channels.
335
+
336
+ Args
337
+ ----
338
+ am: AxisManager
339
+ timestamps (in ns)
340
+ pA_per_phi0: float
341
+ Conversion from phi_0 to pA, defaults to 9e6 set by the mux chip
342
+ mutual inductance between TES to SQUID.
343
+ psd_kwargs: dictionary
344
+ keyword arguments taken by scipy.welch function.
345
+
346
+ Returns:
347
+ f: np.ndarray
348
+ Frequencies
349
+ Axx: np.ndarray
350
+ ASD in pA/sqrt(Hz)
351
+ """
352
+ f, Pxx = calc_psd(am, **psd_kwargs)
353
+ Axx = np.sqrt(Pxx)*pA_per_phi0/(2 * np.pi)
354
+ return f, Axx
355
+
356
+
357
+ class SectionTimer:
358
+ def __init__(self):
359
+ self.sections = []
360
+ self.start_time = None
361
+ self.stop_time = None
362
+
363
+ def start_section(self, name):
364
+ if self.start_time is None:
365
+ self.start_time = time.time()
366
+ self.sections.append((time.time(), name))
367
+
368
+ def stop(self):
369
+ self.stop_time = time.time()
370
+ self.sections.append((time.time(), 'STOP'))
371
+
372
+ def reset(self):
373
+ self.sections = []
374
+ self.start_time = None
375
+ self.stop_time = None
376
+
377
+ def summary(self):
378
+ out = "="*80 + '\nTiming Summary\n' + '-'*80 + '\n'
379
+ out += f"Total time: {self.stop_time - self.start_time} sec\n"
380
+ out += 'name\tdur\tstart\n' + '='*80 + '\n'
381
+
382
+ name_len = max([len(name) for t, name in self.sections])
383
+
384
+ for i in range(len(self.sections) - 1):
385
+ t, name = self.sections[i]
386
+ dur = self.sections[i+1][0] - t
387
+ out += f'{name:{name_len}s}\t{dur:.2f}\t{t:.0f}\n'
388
+
389
+ return out
390
+
391
+
392
+ def dev_cfg_from_pysmurf(S, save_file=None, clobber=True):
393
+ """
394
+ Creates a populated device cfg object from a fully tuned pysmurf instance.
395
+ If a save-file is specifed, the device config file will be written there.
396
+ By default this will not save the device config to a file!! If you want
397
+ overwrite the currently used device cfg, you can run::
398
+
399
+ dev_cfg_from_pysmurf(S, save_file=cfg.dev_file, clobber=True)
400
+
401
+ Args
402
+ ----
403
+ S : SmurfControl object
404
+ The pysmurf instance should be in a state where a tunefile is loaded,
405
+ attenuations, biases, and any other parameter are already set
406
+ correctly.
407
+ save_file : path
408
+ Path to save-file location. Remember that if you are running in a
409
+ docker container, you have to give the path as it is inside the
410
+ container. For example, the OCS_CONFIG_DIR is mapped to /config inside
411
+ the docker.
412
+ clobber : bool
413
+ If true, will overwrite the save_file if one already exists at that
414
+ location.
415
+ """
416
+ dev = det_config.DeviceConfig()
417
+
418
+ # Experiment setup
419
+ amp_biases = S.get_amplifier_biases()
420
+ if hasattr(S, 'tune_file'):
421
+ tunefile = S.tune_file
422
+ else:
423
+ cprint("No tunefile is loaded! Loading tunefile=None", False)
424
+ tunefile = None
425
+ dev.exp.update({
426
+ 'amp_50k_Id': amp_biases['50K_Id'],
427
+ 'amp_50k_Vg': amp_biases['50K_Vg'],
428
+ 'amp_hemt_Id': amp_biases['hemt_Id'],
429
+ 'amp_hemt_Vg': amp_biases['hemt_Vg'],
430
+ 'tunefile': tunefile,
431
+ 'bias_line_resistance': S._bias_line_resistance,
432
+ 'high_low_current_ratio': S._high_low_current_ratio,
433
+ 'pA_per_phi0': S._pA_per_phi0,
434
+ })
435
+
436
+ # Right now not getting any bias group info
437
+ for band in S._bands:
438
+ tone_powers = S.get_amplitude_scale_array(band)[S.which_on(band)]
439
+ if len(tone_powers) == 0:
440
+ drive = S._amplitude_scale[band]
441
+ cprint(f"No channels are on in band {band}. Setting drive to "
442
+ f"pysmurf-cfg value: {drive}", style=TermColors.WARNING)
443
+ else:
444
+ drives, counts = np.unique(tone_powers, return_counts=True)
445
+ drive = drives[np.nanargmax(counts)]
446
+ if len(drives) > 1:
447
+ print(f"Multiple drive powers exist for band {band} ({drives})!")
448
+ print(f"Using most common power: {drive}")
449
+
450
+ feedback_start_frac = S._feedback_to_feedback_frac(band, S.get_feedback_start(band))
451
+ feedback_end_frac = S._feedback_to_feedback_frac(band, S.get_feedback_end(band))
452
+
453
+ flux_ramp_rate_khz = S.get_flux_ramp_freq()
454
+ lms_freq_hz = S.get_lms_freq_hz(band)
455
+ nphi0 = np.round(lms_freq_hz / flux_ramp_rate_khz / 1e3)
456
+
457
+ dev.bands[band].update({
458
+ 'uc_att': S.get_att_uc(band),
459
+ 'dc_att': S.get_att_dc(band),
460
+ 'drive': drive,
461
+ 'feedback_start_frac': feedback_start_frac,
462
+ 'feedback_end_frac': feedback_end_frac,
463
+ 'lms_gain': S.get_lms_gain(band),
464
+ 'frac_pp': S.get_fraction_full_scale(),
465
+ 'flux_ramp_rate_khz': flux_ramp_rate_khz,
466
+ 'lms_freq_hz': lms_freq_hz,
467
+ 'nphi0': nphi0
468
+ })
469
+
470
+ if save_file is not None:
471
+ if clobber and os.path.exists(save_file):
472
+ print(f"Rewriting existing file: {save_file}")
473
+ dev.dump(save_file, clobber=clobber)
474
+ return dev
475
+
476
+
477
+ def get_wls_from_am(am, nperseg=2**16, fmin=10., fmax=20., pA_per_phi0=9e6):
478
+ """
479
+ Gets white-noise levels for each channel from the axis manager returned
480
+ by smurf_ops.load_session.
481
+
482
+ Args
483
+ ----
484
+ am : AxisManager
485
+ Smurf data returned by so.load_session or the G3tSmurf class
486
+ nperseg : int
487
+ nperseg to be passed to welch
488
+ fmin : float
489
+ Min frequency to use for white noise mask
490
+ fmax : float
491
+ Max freq to use for white noise mask
492
+ pA_per_phi0 : float
493
+ S.pA_per_phi0 unit conversion. This will eventually make its way
494
+ into the axis manager, but for now I'm just hardcoding this here
495
+ as a keyword argument until we get there.
496
+
497
+ Returns
498
+ --------
499
+ wls : array of floats
500
+ Array of the white-noise level for each channel, indexed by readout-
501
+ channel number
502
+ band_medians : array of floats
503
+ Array of the median white noise level for each band.
504
+ """
505
+ fsamp = 1./np.median(np.diff(am.timestamps))
506
+ fs, pxx = signal.welch(am.signal * pA_per_phi0 / (2*np.pi),
507
+ fs=fsamp, nperseg=nperseg)
508
+ pxx = np.sqrt(pxx)
509
+ fmask = (fmin < fs) & (fs < fmax)
510
+ wls = np.median(pxx[:, fmask], axis=1)
511
+ band_medians = np.zeros(8)
512
+ for i in range(8):
513
+ m = am.ch_info.band == i
514
+ band_medians[i] = np.median(wls[m])
515
+ return wls, band_medians
516
+
517
+
518
+ # useful functions for analysis etc.
519
+ def invert_mask(mask):
520
+ """
521
+ Converts a readout mask from (band, chan)->rchan form to rchan->abs_chan
522
+ form.
523
+ """
524
+ bands, chans = np.where(mask != -1)
525
+ maskinv = np.zeros_like(bands, dtype=np.int16)
526
+ for b, c in zip(bands, chans):
527
+ maskinv[mask[b, c]] = b * CHANS_PER_BAND + c
528
+ return maskinv
529
+
530
+
531
+ def get_r2(sig, sig_hat):
532
+ """ Gets r-squared value for a signal"""
533
+ sst = np.sum((sig - sig.mean())**2)
534
+ sse = np.sum((sig - sig_hat)**2)
535
+ r2 = 1 - sse / sst
536
+ if r2 < 0:
537
+ return 0
538
+ return r2
539
+
540
+
541
+ class _Register:
542
+ def __init__(self, S, addr):
543
+ self.S = S
544
+ self.addr = S.epics_root + ":" + addr
545
+
546
+ def get(self, **kw):
547
+ return self.S._caget(self.addr, **kw)
548
+
549
+ def set(self, val, **kw):
550
+ self.S._caput(self.addr, val, **kw)
551
+
552
+ class Registers:
553
+ """
554
+ Utility class for storing, getting, and setting SO-rogue registers even if
555
+ they are not in the standard rogue tree, or settable by existing pysmurf
556
+ get/set functions
557
+ """
558
+ _root = 'AMCc:'
559
+ _processor = _root + "SmurfProcessor:"
560
+ _sostream = _processor + "SOStream:"
561
+ _sofilewriter = _sostream + 'SOFileWriter:'
562
+ _source_root = _root + 'StreamDataSource:'
563
+
564
+ _registers = {
565
+ 'pysmurf_action': _sostream + 'pysmurf_action',
566
+ 'pysmurf_action_timestamp': _sostream + "pysmurf_action_timestamp",
567
+ 'stream_tag': _sostream + "stream_tag",
568
+ 'open_g3stream': _sostream + "open_g3stream",
569
+ 'g3_session_id': _sofilewriter + "session_id",
570
+ 'g3_filepath': _sofilewriter + "filepath",
571
+ 'debug_data': _sostream + "DebugData",
572
+ 'debug_meta': _sostream + "DebugMeta",
573
+ 'debug_builder': _sostream + "DebugBuilder",
574
+ 'flac_level': _sostream + "FlacLevel",
575
+ 'source_enable': _source_root + 'SourceEnable',
576
+ 'enable_compression': _sostream + 'EnableCompression',
577
+ 'agg_time': _sostream + 'AggTime',
578
+ }
579
+
580
+ def __init__(self, S):
581
+ self.S = S
582
+ for name, reg in self._registers.items():
583
+ setattr(self, name, _Register(S, reg))
584
+
585
+
586
+ def get_current_mode_array(S):
587
+ """
588
+ Gets high-current-mode relay status for all bias groups
589
+ """
590
+ relay = S.get_cryo_card_relays()
591
+ relay = S.get_cryo_card_relays() # querey twice to ensure update
592
+ bgs = np.arange(S._n_bias_groups)
593
+ hcms = np.zeros_like(bgs, dtype=bool)
594
+ for i, bg in enumerate(bgs):
595
+ r = np.ravel(S._pic_to_bias_group[np.where(
596
+ S._pic_to_bias_group[:, 1] == bg)])[0]
597
+ hcms[i] = (relay >> r) & 1
598
+ return hcms
599
+
600
+
601
+ def set_current_mode(S, bgs, mode, const_current=True):
602
+ """
603
+ Sets one or more bias lines to high current mode. If const_current is True,
604
+ will also update the DC bias voltages to try and preserve current based on
605
+ the set high_low_current_ratio. We need this function to replace the
606
+ existing pysmurf function so we can set both PV's in a single epics call,
607
+ minimizing heating on the cryostat.
608
+
609
+ This function will attempt to check the existing rogue relay state to
610
+ determine if the voltages need to be updated or not. This may not work all
611
+ of the time since there is no relay readback for the high-current-mode
612
+ relays. That means this will set the voltages incorrectly if there is
613
+ somehow an inconsistency between the rogue relay state and the real relay
614
+ state.
615
+
616
+ Args
617
+ ----
618
+ S : SmurfControl
619
+ Pysmurf control instance
620
+ bgs : (int, list)
621
+ Bias groups to switch to high-current-mode
622
+ mode : int
623
+ 1 for high-current, 0 for low-current
624
+ const_current : bool
625
+ If true, will adjust voltage values simultaneously based on
626
+ S.high_low_current_ratio
627
+ """
628
+
629
+ bgs = np.atleast_1d(bgs).astype(int)
630
+
631
+ # DO this twice bc pysmurf does for some reason
632
+ old_relay = S.get_cryo_card_relays()
633
+ old_relay = S.get_cryo_card_relays()
634
+ new_relay = np.copy(old_relay)
635
+
636
+ old_dac_volt_arr = S.get_rtm_slow_dac_volt_array()
637
+ new_dac_volt_arr = np.copy(old_dac_volt_arr)
638
+
639
+ for bg in bgs:
640
+ # Gets relay bit index for bg
641
+ idx = np.where(S._pic_to_bias_group[:, 1] == bg)[0][0]
642
+ # Bit in relay mask corresponding to this bg's high-current
643
+ r = S._pic_to_bias_group[idx, 0]
644
+
645
+ # Index of bg's DAC pair
646
+ pair_idx = np.where(S._bias_group_to_pair[:, 0] == bg)[0][0]
647
+ pos_idx = S._bias_group_to_pair[pair_idx, 1] - 1
648
+ neg_idx = S._bias_group_to_pair[pair_idx, 2] - 1
649
+ if mode:
650
+ # sets relay bit to 1
651
+ new_relay = new_relay | (1 << r)
652
+
653
+ # if const_current and the old_relay bit was zero, divide bias
654
+ # voltage by high_low_ratio
655
+ if const_current and not (old_relay >> r) & 1:
656
+ new_dac_volt_arr[pos_idx] /= S.high_low_current_ratio
657
+ new_dac_volt_arr[neg_idx] /= S.high_low_current_ratio
658
+ else:
659
+ # Sets relay bit to 0
660
+ new_relay = new_relay & ~(1 << r)
661
+
662
+ # if const_current and the old_relay bit was one, mult bias
663
+ # voltage by high_low_ratio
664
+ if const_current and (old_relay >> r) & 1:
665
+ new_dac_volt_arr[pos_idx] *= S.high_low_current_ratio
666
+ new_dac_volt_arr[neg_idx] *= S.high_low_current_ratio
667
+
668
+ relay_data = cmd_make(0, S.C.relay_address, new_relay)
669
+
670
+ # Sets DAC data values, clipping the data array to make sure they contain
671
+ # the correct no. of bits
672
+ dac_data = (new_dac_volt_arr / S._rtm_slow_dac_bit_to_volt).astype(int)
673
+ nbits = S._rtm_slow_dac_nbits
674
+ dac_data = np.clip(dac_data, -2**(nbits-1), 2**(nbits-1)-1)
675
+
676
+ dac_data_reg = S.rtm_spi_max_root + S._rtm_slow_dac_data_array_reg
677
+
678
+
679
+ if isinstance(S.C.writepv, str):
680
+ cryocard_writepv = S.C.writepv
681
+ else:
682
+ cryocard_writepv = S.C.writepv.pvname
683
+
684
+ # It takes longer for DC voltages to settle than it does to toggle the
685
+ # high-current relay, so we can set them at the same time when switchign
686
+ # to hcm, but when switching to lcm we need a sleep statement to prevent
687
+ # dets from latching.
688
+ if mode:
689
+ epics.caput_many([cryocard_writepv, dac_data_reg], [relay_data, dac_data],
690
+ wait=True)
691
+ else:
692
+ S._caput(dac_data_reg, dac_data)
693
+ time.sleep(0.04)
694
+ S._caput(cryocard_writepv, relay_data)
695
+
696
+ time.sleep(0.1) # Just to be safe
697
+
698
+
699
+ def save_fig(S, fig, name, tag=''):
700
+ """
701
+ Saves figure to pysmurf plots directory and publishes result.
702
+
703
+ Args
704
+ -----
705
+ S : SmurfControl
706
+ Pysmurf instantc
707
+ fig : Figure
708
+ Matplotlib figure to save
709
+ name : Figure name
710
+ This will be passed to ``make_filename`` so it is timestamped
711
+ and placed in the pysmurf plots directory.
712
+ """
713
+ fname = make_filename(S, name, plot=True)
714
+ fig.savefig(fname)
715
+ S.pub.register_file(fname, tag, plot=True)
716
+ return fname
717
+
718
+
719
+ ################################################################################
720
+ # AxisManager Utility Functions
721
+ ################################################################################
722
+
723
+ class RestrictionException(Exception):
724
+ """Exception for when cannot restrict AxisManger properly"""
725
+
726
+ def restrict_to_times(am, t0, t1, in_place=False):
727
+ """
728
+ Restricts axis manager to a time range (t0, t1)
729
+ """
730
+ m = (t0 < am.timestamps) & (am.timestamps < t1)
731
+ if not m.any():
732
+ raise RestrictionException
733
+ i0, i1 = np.where(m)[0][[0, -1]] + am.samps.offset
734
+ return am.restrict('samps', (i0, i1), in_place=in_place)
735
+
736
+ def dict_to_am(d, skip_bad_types=False):
737
+ """
738
+ Attempts to convert a dictionary into an axis manager. This can be used on
739
+ dicts containing basic types such as (str, int, float) along with numpy
740
+ arrays. The AxisManager will not have any structure such as "axes", but
741
+ this is useful if you want to nest semi-arbitrary data such as the "meta"
742
+ dict into an axis manager.
743
+
744
+ Args
745
+ -----
746
+ d : dict
747
+ Dict to convert ot axismanager
748
+ skip_bad_types : bool
749
+ If True, will skip any value that is not a str, int, float, or
750
+ np.ndarray. If False, this will raise an error for invalid types.
751
+ """
752
+ allowed_types = (str, int, float, np.ndarray)
753
+ am = AxisManager()
754
+ for k, v in d.items():
755
+ if isinstance(v, dict):
756
+ am.wrap(k, dict_to_am(v))
757
+ if isinstance(v, allowed_types):
758
+ am.wrap(k, v)
759
+ elif not skip_bad_types:
760
+ raise ValueError(
761
+ f"Key {k} is of type {type(v)} which cannot be wrapped by an "
762
+ "axismanager")
763
+ return am
764
+
765
+ def remap_dets(src, dst, load_axes=False, idxmap=None):
766
+ """
767
+ This function takes in two axis-managers, and returns a copy of the 2nd
768
+ which is identical except all fields aligned with 'dets' are remapped so they
769
+ match up with the 'dets' field of the first array. This is very useful if you
770
+ want to compare data across tunes, or across cryostats. An additional field
771
+ "unmapped" of shape ``(dets, )`` will be added containing mask for which
772
+ channels were unmapped by the idxmap.
773
+
774
+ Args
775
+ ----
776
+ src : AxisManager
777
+ "source" data. A copy of this data will be returned, with the 'dets'
778
+ axis remapped to match those of the dest AxisManager
779
+ dst : AxisManager
780
+ "destination" data. The 'dets' axis will be taken from this AxisManager.
781
+ load_axes : bool
782
+ If True, will add ``src`` axes into the copy, and copy the axis
783
+ assignments. Note that if you are trying to nest this in another
784
+ axismanager, this may cause trouble if 'src', and 'dst' have
785
+ axes with the same name but different values. If this is false,
786
+ only the 'dets' assignment will be used, allowing for safe nesting.
787
+ idxmap : optional, np.ndarry
788
+ If an idxmap is passed, that will be used to remap the dets axis.
789
+ If none is passed, an idxmap will be created to match up bands and
790
+ channels of the src and dst. This might be useful if you want to
791
+ map across cooldowns based on a detmap.
792
+ """
793
+ if idxmap is None:
794
+ idxmap = map_band_chans(
795
+ dst.bands, dst.channels,
796
+ src.bands, src.channels,
797
+ )
798
+
799
+ axes = [dst.dets]
800
+ if load_axes:
801
+ axes.extend([v for k, v in src._axes.items() if k != 'dets'])
802
+
803
+ am_new = AxisManager(*axes)
804
+ for k, axes in src._assignments.items():
805
+ f = src._fields[k]
806
+ if isinstance(f, AxisManager):
807
+ am_new.wrap(k, remap_dets(f, dst, load_axes=load_axes, idxmap=idxmap))
808
+ elif isinstance(f, np.ndarray):
809
+ assignments = []
810
+ if load_axes:
811
+ assignments.extend([
812
+ (i, n) for i, n in enumerate(axes)
813
+ if n is not None and n!='dets'
814
+ ])
815
+ if 'dets' in axes:
816
+ i = axes.index('dets')
817
+ f = np.rollaxis(np.rollaxis(f, i)[idxmap], -i)
818
+ am_new.wrap(k, f, assignments)
819
+ else: # Literals
820
+ am_new.wrap(k, f)
821
+
822
+ am_new.wrap('unmapped', idxmap == -1, [(0, 'dets')])
823
+ return am_new
824
+
825
+
826
+ def overbias_dets(S, cfg, bias_groups=None, biases=None, cool_wait=None,
827
+ high_current_mode=False):
828
+ """
829
+ Overbiases detectors based on parameters in the device cfg.
830
+
831
+ Args
832
+ ------
833
+ S : SmurfControl
834
+ Pysmurf control instance
835
+ cfg : DetConfig
836
+ DetConfig instance
837
+ bias_groups : list
838
+ List of bias groups to overbias
839
+ biases: list
840
+ Array of final bias values to set after overbiasing. This must
841
+ be an array of length 12 where biases[bg] is the bias of bias group bg.
842
+ If this is not set, the ``cool_voltage`` set in the device cfg file
843
+ will be used for each bias group.
844
+ cool_wait : float
845
+ If set, time to wait after overbiasing dets. Defaults to no wait time.
846
+ high_current_mode : bool, List[bool]
847
+ Whether the bias groups should be left in high-current-mode. If a list is
848
+ passed, it should be an array of len 12 where high_current_mode[bg] is
849
+ the desired mode for bias line ``bg``.
850
+ """
851
+ if bias_groups is None:
852
+ bias_groups = cfg.dev.exp['active_bgs']
853
+
854
+ if isinstance(high_current_mode, (bool, int)):
855
+ high_current_mode = [high_current_mode for _ in range(12)]
856
+ high_current_mode = np.atleast_1d(high_current_mode)
857
+
858
+ S.log("Overbiasing Detectors")
859
+ set_current_mode(S, bias_groups, 1, const_current=False)
860
+ for bg in bias_groups:
861
+ S.set_tes_bias_bipolar(bg, cfg.dev.bias_groups[bg]['overbias_voltage'])
862
+
863
+ wait_time = cfg.dev.exp['overbias_wait']
864
+ S.log(f"Waiting at ob volt for {wait_time} sec")
865
+ time.sleep(wait_time)
866
+
867
+ for bg in bias_groups:
868
+ if biases is not None:
869
+ bias = biases[bg]
870
+ else:
871
+ bias = cfg.dev.bias_groups[bg]['cool_voltage']
872
+ S.set_tes_bias_bipolar(bg, bias)
873
+
874
+ lcm_bgs = np.where(~high_current_mode)[0]
875
+ set_current_mode(S, lcm_bgs, 0, const_current=False)
876
+
877
+ if cool_wait is not None:
878
+ time.sleep(cool_wait)
879
+
880
+ return S.get_tes_bias_bipolar_array()