pytesprocess 0.1.1__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.
Files changed (60) hide show
  1. pytesprocess/__init__.py +9 -0
  2. pytesprocess/_version.py +2 -0
  3. pytesprocess/cli/__init__.py +1 -0
  4. pytesprocess/cli/commands/__init__.py +5 -0
  5. pytesprocess/cli/commands/event.py +66 -0
  6. pytesprocess/cli/commands/filter.py +17 -0
  7. pytesprocess/cli/commands/ivsweep.py +29 -0
  8. pytesprocess/cli/common.py +86 -0
  9. pytesprocess/cli/main.py +81 -0
  10. pytesprocess/config/__init__.py +4 -0
  11. pytesprocess/config/loader.py +94 -0
  12. pytesprocess/config/manager.py +297 -0
  13. pytesprocess/config/resolvers/__init__.py +5 -0
  14. pytesprocess/config/resolvers/common.py +56 -0
  15. pytesprocess/config/resolvers/feature.py +293 -0
  16. pytesprocess/config/resolvers/salting.py +86 -0
  17. pytesprocess/config/resolvers/trigger.py +84 -0
  18. pytesprocess/config/selectors.py +108 -0
  19. pytesprocess/config/validation.py +314 -0
  20. pytesprocess/config/warnings.py +2 -0
  21. pytesprocess/core/__init__.py +10 -0
  22. pytesprocess/core/algorithms.py +1455 -0
  23. pytesprocess/core/didv.py +1648 -0
  24. pytesprocess/core/eventbuilder.py +495 -0
  25. pytesprocess/core/filterbuilder.py +81 -0
  26. pytesprocess/core/filterdata.py +1849 -0
  27. pytesprocess/core/ivsweep.py +2072 -0
  28. pytesprocess/core/noise.py +923 -0
  29. pytesprocess/core/noisemodel.py +1408 -0
  30. pytesprocess/core/oftrigger.py +1035 -0
  31. pytesprocess/core/template.py +450 -0
  32. pytesprocess/process/__init__.py +6 -0
  33. pytesprocess/process/data_source.py +185 -0
  34. pytesprocess/process/event_context.py +35 -0
  35. pytesprocess/process/feature_plan.py +186 -0
  36. pytesprocess/process/feature_resources.py +267 -0
  37. pytesprocess/process/features.py +1024 -0
  38. pytesprocess/process/filterprocess.py +1176 -0
  39. pytesprocess/process/ivprocess.py +1380 -0
  40. pytesprocess/process/processing_data.py +967 -0
  41. pytesprocess/process/randoms.py +921 -0
  42. pytesprocess/process/triggers.py +1011 -0
  43. pytesprocess/salting/__init__.py +7 -0
  44. pytesprocess/salting/generator.py +364 -0
  45. pytesprocess/salting/injector.py +329 -0
  46. pytesprocess/salting/sampling.py +84 -0
  47. pytesprocess/utils/__init__.py +5 -0
  48. pytesprocess/utils/arg_utils.py +122 -0
  49. pytesprocess/utils/dataframe_output.py +120 -0
  50. pytesprocess/utils/filter_hdf5.py +594 -0
  51. pytesprocess/utils/utils.py +701 -0
  52. pytesprocess/workflows/__init__.py +3 -0
  53. pytesprocess/workflows/processing.py +317 -0
  54. pytesprocess/workflows/salting.py +133 -0
  55. pytesprocess-0.1.1.dist-info/METADATA +211 -0
  56. pytesprocess-0.1.1.dist-info/RECORD +60 -0
  57. pytesprocess-0.1.1.dist-info/WHEEL +5 -0
  58. pytesprocess-0.1.1.dist-info/entry_points.txt +2 -0
  59. pytesprocess-0.1.1.dist-info/licenses/LICENSE +21 -0
  60. pytesprocess-0.1.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1035 @@
1
+ import copy
2
+ import os
3
+ import warnings
4
+
5
+ import numpy as np
6
+ import pyarrow as pa
7
+ import qetpy as qp
8
+ from scipy import special, stats
9
+ from scipy.fft import ifft
10
+ from scipy.signal import oaconvolve
11
+ import vaex as vx
12
+
13
+ vx.settings.main.thread_count = 1
14
+ vx.settings.main.thread_count_io = 1
15
+ pa.set_cpu_count(1)
16
+
17
+ os.environ["OMP_NUM_THREADS"] = "1"
18
+ os.environ["MKL_NUM_THREADS"] = "1"
19
+ os.environ["NUMEXPR_NUM_THREADS"] = "1"
20
+ os.environ["OPENBLAS_NUM_THREADS"] = "1"
21
+
22
+ __all__ = ['OptimumFilterTrigger',
23
+ 'shift_templates_to_match_chi2',
24
+ 'combine_trigger_data']
25
+
26
+
27
+ def _getchangeslessthanthresh(x, threshold):
28
+ """
29
+ Helper function that returns a list of the start and ending indices
30
+ of the ranges of inputted values that change by less than the
31
+ specified threshold value.
32
+
33
+ Parameters
34
+ ----------
35
+ x : ndarray
36
+ 1-dimensional of values.
37
+ threshold : int
38
+ Value to detect the different ranges of vals that change by
39
+ less than this threshold value.
40
+
41
+ Returns
42
+ -------
43
+ ranges : ndarray
44
+ List of tuples that each store the start and ending index of
45
+ each range. For example, vals[ranges[0][0]:ranges[0][1]] gives
46
+ the first section of values that change by less than the
47
+ specified threshold.
48
+ vals : ndarray
49
+ The corresponding starting and ending values for each range in
50
+ x.
51
+
52
+ """
53
+
54
+ diff = x[1:]-x[:-1]
55
+ a = diff>threshold
56
+ inds = np.where(a)[0]+1
57
+
58
+ start_inds = np.zeros(len(inds)+1, dtype = int)
59
+ start_inds[1:] = inds
60
+
61
+ end_inds = np.zeros(len(inds)+1, dtype = int)
62
+ end_inds[-1] = len(x)
63
+ end_inds[:-1] = inds
64
+
65
+ ranges = np.array(list(zip(start_inds,end_inds)))
66
+
67
+ if len(x)!=0:
68
+ vals = np.array([(x[st], x[end-1]) for (st, end) in ranges])
69
+ else:
70
+ vals = np.array([])
71
+
72
+ return ranges, vals
73
+
74
+
75
+
76
+ def _getchangeslessthandynamicthresh(x, amplitudes, threshold_function):
77
+ """
78
+ Helper function that returns a list of the start and ending indices
79
+ of ranges in x where consecutive values are separated by less than
80
+ a dynamically determined threshold.
81
+
82
+ Parameters
83
+ ----------
84
+ x : ndarray
85
+ 1-dimensional array of time values.
86
+ amplitudes : ndarray
87
+ 1-dimensional array of values at each time point, same shape as x.
88
+ amplitudes_interp : ndarray
89
+ 1-dimensional interpolation array that maps values from `vals` to `threshold` values.
90
+ Must be monotonically increasing!
91
+ threshold_interp : ndarray
92
+ 1-dimensional interpolation array that maps values from `vals` to `threshold` values.
93
+ log_interp : bool
94
+ Whether to interpolate in log space (if False, then default to linear)
95
+
96
+ Returns
97
+ -------
98
+ ranges : ndarray
99
+ List of tuples that each store the start and ending index of each range.
100
+ For example, x[ranges[0][0]:ranges[0][1]] gives the first section of values
101
+ that change by less than the dynamically determined threshold.
102
+ vals : ndarray
103
+ The corresponding starting and ending values for each range in x.
104
+ """
105
+
106
+ # Initialize lists for storing start and end indices of each range
107
+ start_inds = []
108
+ end_inds = []
109
+
110
+ # Initialize the first region starting at index 0
111
+ current_start = 0
112
+
113
+ for i in range(1, len(x)):
114
+ # Calculate the dynamic threshold for the current range
115
+ max_amplitude = np.max(amplitudes[current_start:i+1]) # Maximum value in current region of `amplitudes`
116
+ dynamic_threshold = threshold_function(max_amplitude) # Calculate dynamic threshold
117
+
118
+ # Check if the difference exceeds the dynamic threshold
119
+ if (x[i] - x[i - 1]) > dynamic_threshold:
120
+ # Mark the end of the current range
121
+ start_inds.append(current_start)
122
+ end_inds.append(i)
123
+
124
+ # Start a new range
125
+ current_start = i
126
+
127
+ # Add the final range
128
+ start_inds.append(current_start)
129
+ end_inds.append(len(x))
130
+
131
+ # Combine start and end indices into ranges
132
+ ranges = np.array(list(zip(start_inds, end_inds)))
133
+
134
+ # Extract start and end values for each range
135
+ if len(x) != 0:
136
+ vals = np.array([(x[st], x[end - 1]) for (st, end) in ranges])
137
+ else:
138
+ vals = np.array([])
139
+
140
+ return ranges, vals
141
+
142
+
143
+
144
+ def shift_templates_to_match_chi2(fs, primary_template, secondary_templates, noisecsd, relative_amplitudes=None):
145
+ """
146
+ Shift "secondary_template" in time such that running a
147
+ "primary_template" trigger on both templates results in chi2 maximized
148
+ at the same time. Assumes noise is the same between templates.
149
+
150
+ ----------
151
+ fs : float
152
+ The sample rate of the data (Hz)
153
+ primary_template : ndarray
154
+ The pulse template(s) to be used when creating the optimum
155
+ filter (assumed to be normalized). We will do an NxM trigger.
156
+ You can pass this in a few ways:
157
+ 3D array [channels, amplitudes, samples]
158
+ 1D array or 2D array with shape [1,S] or [S,1]
159
+ - Will be reshaped into 1 x 1 x S
160
+ 2D array with two non-unity dimensions
161
+ - Will raise an error because we don't know whether
162
+ to set N_chan = 1 or M_amp = 1
163
+ secondary_templates : list of ndarrays
164
+ The pulse template(s) to shift in time such that a
165
+ primary_template NxM trigger running on this template will trigger
166
+ at the same time as the same trigger running on a primary_template
167
+ template.
168
+ Shape requirements for each ndarray are the same as primary_template.
169
+ Note that the secondary templates must have the same N and M as the
170
+ primary template!
171
+ noisecsd : ndarray
172
+ The two-sided cross power spectral density in units of A^2/Hz
173
+ Same shape requirements as "primary_template", though the number of
174
+ samples is replaced by the number of frequencies (which
175
+ should be equal, anyway).
176
+ relative_amplitudes : ndarray
177
+ The relative scaling factors between the amplitude degrees of freedom.
178
+ Must be 1D array of length M_amp.
179
+ Defaults to an array of ones if not provided.
180
+ ----------
181
+ Returns:
182
+ secondary_templates_shifted : list of 3D ndarrays
183
+ The shifted templates such that when the primary trigger is run on them,
184
+ delta chi2 is maximized at the same time.
185
+ time_shift_samples : ndarray
186
+ For each secondary template, the number of samples that we shifted
187
+
188
+ """
189
+
190
+ # Container to return objects
191
+ secondary_templates_shifted = [None for _ in range(len(secondary_templates))]
192
+ time_shift_samples = np.zeros(len(secondary_templates), dtype=int)
193
+
194
+ # Loop over templates
195
+ for i, secondary_template in enumerate(secondary_templates):
196
+
197
+ # Reshape secondary template if needed to [channels, amplitudes, samples]
198
+ # This is now renamed s_template after reshaping
199
+ n_dims_template = len(secondary_template.shape)
200
+ if n_dims_template == 1:
201
+ s_template = np.reshape(secondary_template, (1, 1, len(secondary_template)))
202
+ elif n_dims_template == 2:
203
+ if secondary_template.shape[0] == 1:
204
+ s_template = np.reshape(secondary_template, (1, 1, secondary_template.shape[1]))
205
+ elif secondary_template.shape[1] == 1:
206
+ s_template = np.reshape(secondary_template, (1, 1, secondary_template.shape[0]))
207
+ else:
208
+ raise ValueError(
209
+ f'Primary template is shaped as {secondary_template.shape}. ' +
210
+ 'It should be (N, M, samples) or (samples,) or ' +
211
+ '(1, samples) or (samples, 1).'
212
+ )
213
+ elif n_dims_template == 3:
214
+ s_template = np.copy(secondary_template)
215
+
216
+ n_channels, m_amplitudes, f_frequencies = s_template.shape
217
+
218
+ # Set relative amplitudes if not given
219
+ if relative_amplitudes is None:
220
+ relative_amplitudes = np.ones(m_amplitudes)
221
+
222
+ # Create primary OF trigger
223
+ primary_oftrigger = OptimumFilterTrigger(
224
+ '|'.join([f'channel_{j}' for j in range(n_channels)]),
225
+ fs, primary_template, noisecsd, int(f_frequencies/2))
226
+
227
+ # Convert primary template to a trace by summing amplitudes in
228
+ # the appropriate ratios
229
+ primary_trace = np.zeros((n_channels, f_frequencies))
230
+ for iamp in range(m_amplitudes):
231
+ primary_trace += primary_template[:,iamp,:] * relative_amplitudes[iamp]
232
+
233
+ # Run the primary trigger on the primary template
234
+ primary_oftrigger.update_trace(primary_trace, padding=False)
235
+
236
+ # Determine what time the primary template delivers its max delta chi2
237
+ primary_delta_chi2 = primary_oftrigger.get_filtered_delta_chi2()
238
+ time_max_amplitude_primary_samples = np.argmax(primary_delta_chi2)
239
+
240
+ # Convert secondary template to a trace by summing amplitudes in
241
+ # the appropriate ratios
242
+ secondary_trace = np.zeros((n_channels, f_frequencies))
243
+ for iamp in range(m_amplitudes):
244
+ secondary_trace += s_template[:,iamp,:] * relative_amplitudes[iamp]
245
+
246
+ # Run the primary trigger on the primary template
247
+ primary_oftrigger.update_trace(secondary_trace, padding=False)
248
+
249
+ # Determine what time the primary template delivers its max delta chi2
250
+ secondary_delta_chi2 = primary_oftrigger.get_filtered_delta_chi2()
251
+ time_max_amplitude_secondary_samples = np.argmax(secondary_delta_chi2)
252
+
253
+ # Determine the time shift and apply it to the secondary template
254
+ time_shift_samples[i] = time_max_amplitude_primary_samples - time_max_amplitude_secondary_samples
255
+ secondary_templates_shifted[i] = np.roll(s_template, time_shift_samples[i])
256
+
257
+ return secondary_templates_shifted, time_shift_samples
258
+
259
+
260
+ def combine_trigger_data(
261
+ original_trigger_data, new_trigger_data, original_triggers, new_triggers
262
+ ):
263
+ """
264
+ Combines the dictionaries `original_trigger_data` and `new_trigger_data`
265
+ without replicating entries, appending only values corresponding to entries
266
+ in `new_triggers` that are not in `original_triggers`.
267
+
268
+ Parameters
269
+ ----------
270
+ original_trigger_data : dict
271
+ Dictionary containing the original trigger data.
272
+ new_trigger_data : dict
273
+ Dictionary containing the new trigger data.
274
+ original_triggers : list
275
+ List of integers representing original triggers.
276
+ new_triggers : list
277
+ List of integers representing new triggers.
278
+
279
+ Returns
280
+ -------
281
+ dict
282
+ Combined dictionary with new non-duplicate entries appended.
283
+ """
284
+
285
+ # Find entries in `new_triggers` not in `original_triggers`
286
+ unique_new_triggers = set(new_triggers) - set(original_triggers)
287
+ trigger_name = list(original_trigger_data.keys())[0]
288
+
289
+ # Get the original and new trigger name keys
290
+ appended_inner_dict = copy.deepcopy(original_trigger_data[trigger_name])
291
+ new_inner_dict = copy.deepcopy(new_trigger_data[trigger_name])
292
+
293
+ # Loop through each key in the inner dictionaries
294
+ for key in new_inner_dict.keys():
295
+
296
+ # Keys that are named similarly are identical
297
+ # in reference, not just in value. I.e. trigger_index and
298
+ # trigger_index_ch1|ch2 are the same object.
299
+ if ('_' + trigger_name) in key:
300
+ continue
301
+
302
+ # Convert pyarrow string arrays to lists if necessary
303
+ revert_to_arrow = False
304
+ if isinstance(appended_inner_dict[key], pa.lib.StringArray):
305
+ appended_inner_dict[key] = appended_inner_dict[key].to_pylist()
306
+ new_inner_dict[key] = new_inner_dict[key].to_pylist()
307
+ revert_to_arrow = True
308
+
309
+ # Append values corresponding to unique triggers from new_trigger_data
310
+ for idx, trigger in enumerate(new_triggers):
311
+ if trigger in unique_new_triggers:
312
+ appended_inner_dict[key].append(new_inner_dict[key][idx])
313
+
314
+ # Convert back to pyarrow arrays if they were originally in that format
315
+ if revert_to_arrow:
316
+ appended_inner_dict[key] = pa.array(appended_inner_dict[key], type=pa.string())
317
+
318
+ return {trigger_name: appended_inner_dict}
319
+
320
+
321
+
322
+ class OptimumFilterTrigger:
323
+ """
324
+ Class for applying a time-domain optimum filter to a long trace,
325
+ which can be thought of as an FIR filter.
326
+
327
+ Attributes
328
+ ----------
329
+ phi : ndarray
330
+ The optimum filter in time-domain, equal to the inverse FT of
331
+ (FT of the template/power spectral density of noise)
332
+ norm : float
333
+ The normalization of the optimal amplitude.
334
+ fs : float
335
+ The sample rate of the data (Hz).
336
+ traces : ndarray
337
+ Trace(s) to be filtered, assumed to be an ndarray of
338
+ shape 1D or 2D = (# of channels, # of trace bins). Should
339
+ be in units of Amps.
340
+ template : ndarray
341
+ The template that will be used for the Optimum Filter.
342
+ noisepsd : ndarray
343
+ The two-sided noise PSD that will be used to create the Optimum
344
+ Filter.
345
+ filtered_trace : ndarray
346
+ The result of the FIR filter on each of the traces.
347
+ resolution : float
348
+ The expected energy resolution in Amps given by the template
349
+ and the noisepsd, calculated from the Optimum Filter.
350
+
351
+
352
+ """
353
+
354
+ def __init__(self, trigger_channel,
355
+ fs, template, noisecsd,
356
+ pretrigger_samples,
357
+ ignored_frequency_peaks=None,
358
+ ignore_harmonics=False,
359
+ trigger_name=None):
360
+ """
361
+ Initialization of the FIR filter.
362
+
363
+ ----------
364
+ trigger_channel : str or list of str
365
+ the channel name (s) of the trigger. In case of NxM trigger,
366
+ it can be a list of channels
367
+ fs : float
368
+ The sample rate of the data (Hz)
369
+ template : ndarray
370
+ The pulse template(s) to be used when creating the optimum
371
+ filter (assumed to be normalized). We will do an NxM trigger.
372
+ You can pass this in a few ways:
373
+ 3D array [channels, amplitudes, samples]
374
+ 1D array or 2D array with shape [1,S] or [S,1]
375
+ - Will be reshaped into 1 x 1 x S
376
+ 2D array with two non-unity dimensions
377
+ - Will raise an error because we don't know whether
378
+ to set N_chan = 1 or M_amp = 1
379
+ noisecsd : ndarray
380
+ The two-sided cross power spectral density in units of A^2/Hz
381
+ Same shape requirements as "template", though the number of
382
+ samples is replaced by the number of frequencies (which
383
+ should be equal, anyway).
384
+
385
+ ignored_frequency_peaks : float or list of float [default=None]
386
+ frequencies that are ignored in optimal filter
387
+ calculation. The nearest bin (both positive/negative frequencies) from
388
+ given frequency is selected and CSD frequency set to infinity.
389
+
390
+
391
+
392
+ """
393
+
394
+ # save internal variables
395
+ self._fs = fs
396
+ self._pretrigger_samples = pretrigger_samples
397
+
398
+ # trigger_channel might be a list of channels for the NxM trigger
399
+ self._trigger_channel = qp.utils.convert_channel_list_to_name(trigger_channel)
400
+
401
+ # trigger name
402
+ self._trigger_name = str(trigger_name)
403
+ if trigger_name is None:
404
+ self._trigger_name = str(self._trigger_channel)
405
+ if '\0' in self._trigger_name:
406
+ self._trigger_name = self._trigger_name.replace('\0', '')
407
+
408
+ # Reshape template if needed to [channels, amplitudes, samples]
409
+ n_dims_template = len(template.shape)
410
+ if n_dims_template == 1:
411
+ self._template = np.reshape(template, (1, 1, len(template)))
412
+ elif n_dims_template == 2:
413
+ if template.shape[0] == 1:
414
+ self._template = np.reshape(template, (1, 1, template.shape[1]))
415
+ elif template.shape[1] == 1:
416
+ self._template = np.reshape(template, (1, 1, template.shape[0]))
417
+ else:
418
+ raise ValueError(
419
+ f'Template is shaped as {template.shape}. ' +
420
+ 'It should be (N, M, samples) or (samples,) or ' +
421
+ '(1, samples) or (samples, 1).'
422
+ )
423
+ elif n_dims_template == 3:
424
+ self._template = template
425
+
426
+ self._nb_samples = self._template.shape[-1]
427
+ self._posttrigger_samples = self._nb_samples - self._pretrigger_samples
428
+
429
+
430
+ # Reshape CSD if needed to [channels, amplitudes, frequencies]
431
+ n_dims_csd = len(noisecsd.shape)
432
+ if n_dims_csd == 1:
433
+ self._noisecsd = np.reshape(noisecsd, (1, 1, len(noisecsd)))
434
+ elif n_dims_csd == 2:
435
+ if noisecsd.shape[0] == 1:
436
+ self._noisecsd = np.reshape(noisecsd, (1, 1, noisecsd.shape[1]))
437
+ elif noisecsd.shape[1] == 1:
438
+ self._noisecsd = np.reshape(noisecsd, (1, 1, noisecsd.shape[0]))
439
+ else:
440
+ raise ValueError(
441
+ f'Noise CSD is shaped as {noisecsd.shape}. ' +
442
+ 'Should be (N, M, frequencies) or (frequencies,) or ' +
443
+ '(1, frequencies) or (frequencies, 1).'
444
+ )
445
+ elif n_dims_csd == 3:
446
+ self._noisecsd = noisecsd
447
+
448
+ # Save the number of channels, amplitudes, and frequencies/times
449
+ self._n_channels, _, self._f_frequencies = self._noisecsd.shape
450
+ self._m_amplitudes = self._template.shape[1]
451
+ self._t_times = self._f_frequencies
452
+
453
+ # trigger index shift if pretrigger not midpoint
454
+ self._trigger_index_shift = self._pretrigger_samples - self._nb_samples//2
455
+
456
+ # check array shapes
457
+ #if isinstance(trigger_channel, str):
458
+ # trigger_channel = [trigger_channel]
459
+
460
+ # intitialize trigger data
461
+ # dictionary
462
+ self._trigger_data = None
463
+
464
+ # Create an OF Base object to run the OF pre-calculations
465
+ self._of_base = qp.OFBase(fs)
466
+
467
+ # tag
468
+ template_tag = 'default'
469
+
470
+ self._of_base.add_template(
471
+ self._trigger_channel,
472
+ self._template,
473
+ template_tag=template_tag,
474
+ pretrigger_samples=self._pretrigger_samples
475
+ )
476
+
477
+ self._of_base.set_csd(self._trigger_channel, self._noisecsd,
478
+ ignored_frequency_peaks=ignored_frequency_peaks,
479
+ ignore_harmonics=ignore_harmonics)
480
+ self._of_base.calc_phi(self._trigger_channel,
481
+ template_tag=template_tag,
482
+ calc_weight=True)
483
+
484
+ self._iw_matrix = self._of_base.iweight(self._trigger_channel, template_tag)
485
+ self._w_matrix = self._of_base.weight(self._trigger_channel, template_tag)
486
+
487
+ # Get phi then take inverse fourier transform
488
+ # phi shape in OF base (n_channels, m_amplitudes, f_frequencies)
489
+ self._phi_fd = np.copy(self._of_base.phi(self._trigger_channel, template_tag))
490
+ self._phi_fd[:,:,0] = 0 #ensure we do not use DC information
491
+ self._phi_td = ifft(self._phi_fd, axis=2).real
492
+
493
+ # calculate the normalization of the optimum filter
494
+ self._norm = np.dot(self._phi_td[0,0], self._template[0,0])
495
+
496
+ # calculate the expected energy resolution for each amplitude
497
+ self._resolution = np.sqrt(np.diag(self._iw_matrix))
498
+
499
+ # Variable to store the chi2 threshold (set when running the trigger)
500
+ self.chi2_threshold = None
501
+
502
+
503
+ def get_filtered_trace(self):
504
+ """
505
+ Get current filtered trace
506
+ """
507
+
508
+ return self._filtered_trace
509
+
510
+
511
+ def get_filtered_delta_chi2(self):
512
+ """
513
+ Get current delta chi2 trace
514
+ """
515
+
516
+ return self._delta_chi2_trace
517
+
518
+
519
+ def get_chi2_threshold(self):
520
+ """
521
+ Get current chi2 threshold (varies based on sigma level)
522
+ """
523
+
524
+ return self.chi2_threshold
525
+
526
+
527
+ def get_trigger_data(self):
528
+ """
529
+ Get current filtered trace
530
+ """
531
+
532
+ return self._trigger_data
533
+
534
+ def get_trigger_data_df(self):
535
+ """
536
+ Get current filtered trace
537
+ """
538
+
539
+ df = None
540
+ if self._trigger_data is not None:
541
+
542
+ data = copy.deepcopy(self._trigger_data[self._trigger_name])
543
+
544
+ prepared = {}
545
+ for key, values in data.items():
546
+ if all(isinstance(v, str) or v is None for v in values):
547
+ # Convert to Arrow string array
548
+ prepared[key] = pa.array(values, type=pa.string())
549
+ elif all(isinstance(v, (float, int, np.number)) or v is None for v in values):
550
+ # Convert to NumPy array (let vaex handle dtype)
551
+ prepared[key] = np.array(values, dtype=float)
552
+ else:
553
+ # fallback to generic Arrow array
554
+ prepared[key] = pa.array(values)
555
+
556
+ df = vx.from_dict(prepared)
557
+
558
+ return df
559
+
560
+
561
+ def get_phi(self):
562
+ """
563
+ Get optimal filter
564
+ in time domain
565
+ """
566
+
567
+ return self._phi_td
568
+
569
+
570
+ def get_norm(self):
571
+ """
572
+ Get normalization
573
+ """
574
+
575
+ return self._norm
576
+
577
+
578
+ def get_resolution(self):
579
+ """
580
+ Get resolution
581
+ """
582
+ return self._resolution
583
+
584
+
585
+
586
+ def update_trace(self, trace=None, filtered_trace=None,
587
+ padding=True):
588
+ """
589
+ Method to apply the FIR filter the inputted traces with
590
+ specified times.
591
+
592
+ Parameters
593
+ ----------
594
+
595
+ trace : ndarray, optional
596
+ trigger channel trace(s) to be filtered
597
+ units: Amps
598
+ Pulses should be positive-going for consistency,
599
+ but this technically doesn't matter
600
+ Shape: 2D array [N_channels, samples]
601
+ Default: None (required "filtered_trace")
602
+
603
+ filtered_trace : ndarray, optional
604
+ OF filtered channel trace(s)
605
+ units: Amps
606
+ Shape: 2D array [M_amplitudes, samples]
607
+
608
+ padding : bool
609
+ if True, set the filtered values to zero near the edges
610
+
611
+ """
612
+
613
+ # check input
614
+ if trace is None and filtered_trace is None:
615
+ raise ValueError(
616
+ 'ERROR: "trace" or "filtered_trace required!'
617
+ )
618
+
619
+ # reshape if needed
620
+ if (trace is not None) and (trace.ndim == 1):
621
+ self._raw_trace = np.reshape(trace, (1, len(trace)))
622
+ else:
623
+ self._raw_trace = trace
624
+
625
+ self._raw_trace_LPF_50kHz = np.copy(self._raw_trace)
626
+ for ich in range(self._n_channels):
627
+ self._raw_trace_LPF_50kHz[ich] = qp.utils.lowpassfilter(
628
+ self._raw_trace[ich],
629
+ cut_off_freq=50e3,
630
+ fs=self._fs
631
+ )
632
+
633
+ if (filtered_trace is not None) and (filtered_trace.ndim == 1):
634
+ self._filtered_trace = np.reshape(filtered_trace,
635
+ (1, len(filtered_trace)))
636
+ else:
637
+ self._filtered_trace = filtered_trace
638
+
639
+ # check dimension of trace(s) here
640
+ if trace is not None and self._raw_trace.shape[0] != self._n_channels:
641
+ raise ValueError(
642
+ f'ERROR: "trace" has shape {trace.shape}, ' +
643
+ f'but we have {self._n_channels} channels!'
644
+ )
645
+
646
+ if (filtered_trace is not None
647
+ and self._filtered_trace.shape[0] != self._m_amplitudes):
648
+ raise ValueError(
649
+ f'ERROR: "filtered_trace" has shape {filtered_trace.shape}, ' +
650
+ f'but we have {self._m_amplitudes} channels!'
651
+ )
652
+
653
+ # filter trace
654
+ if self._filtered_trace is None:
655
+
656
+ # V is the vector of convolutions; equivalently, the element in WA = V
657
+ V_td = np.zeros((self._m_amplitudes, self._raw_trace.shape[-1]))
658
+ for theta in range(self._m_amplitudes):
659
+ V_td_per_channel = oaconvolve(self._raw_trace,
660
+ self._phi_td[:,theta,:],
661
+ mode='same', axes=-1)
662
+ V_td[theta,:] = np.sum(V_td_per_channel, axis=0)
663
+
664
+ self._filtered_trace = np.einsum('ij,jz->iz', self._iw_matrix, V_td).real
665
+
666
+ # Calculate chi2 for the filtered trace(s)
667
+ self._delta_chi2_trace = np.einsum('iz,ij,jz->z',
668
+ self._filtered_trace,
669
+ self._w_matrix,
670
+ self._filtered_trace)
671
+
672
+ # set the filtered values to zero near the edges,
673
+ # so as not to use the padded values in the analysis
674
+ if padding:
675
+ cut_len = self._t_times
676
+ self._delta_chi2_trace[:cut_len] = 0.0
677
+ self._delta_chi2_trace[-(cut_len)+(cut_len+1)%2:] = 0.0
678
+
679
+
680
+ def find_triggers(self, thresh,
681
+ pileup_window_msec=None, pileup_window_samples=None,
682
+ positive_pulses=True, dynamic=False,
683
+ dynamic_threshold_function=None, residual=False,
684
+ saturation_amplitudes_LPF_50kHz=None,
685
+ edge_exclusion_msec=None,
686
+ livetime=None,
687
+ return_trigger_data=False):
688
+ """
689
+ Method to detect events in the traces with a delta chi2 amplitude
690
+ greater than the specified threshold. Note that this may return
691
+ duplicate events, so care should be taken in post-processing to
692
+ get rid of such events.
693
+
694
+ Make sure to also read the documentation for find_triggers_once().
695
+
696
+ If residual == False, then this simply calls find_triggers_once().
697
+
698
+ If residual == True, then we call find_triggers_once(). We then
699
+ subtract the best-fit triggered pulses from the trace (in delta
700
+ chi2 space), excluding saturated pulses which we know won't match
701
+ the template. Then, we retrigger on the residual trace. If any new
702
+ triggers above threshold are found, we combine those with the
703
+ first-pass triggers and save all the triggers in the
704
+ self._trigger_data dictionary.
705
+
706
+ Parameters
707
+ ----------
708
+
709
+ thresh : float
710
+ See find_triggers_once() for documentation.
711
+ pileup_window_msec : float, optional
712
+ See find_triggers_once() for documentation.
713
+ pileup_window_samples : int, optional
714
+ See find_triggers_once() for documentation.
715
+ dynamic : bool, optional
716
+ See find_triggers_once() for documentation.
717
+ dynamic_threshold_function : function, optional
718
+ See find_triggers_once() for documentation.
719
+ residual : bool, optional
720
+ If True, we search for triggers on the residual chi2 trace
721
+ after subtracting the best-fit pulses from the first-pass
722
+ trigger round.
723
+ saturation_amplitudes_LPF_50kHz : array_like, optional
724
+ If provided, this should be an N-length array or list, where
725
+ N is the number of channels. Each element represents the
726
+ saturation amplitude in each channel, in A.
727
+ The raw trace is passed through a 50 kHz low-pass filter.
728
+ Then, if the LPF-filtered trace passes above the saturation
729
+ amplitude within template_length/4 samples of a given trigger,
730
+ this trigger is considered to be a saturated pulse. The
731
+ residual algorithm won't run on this trigger.
732
+ If residual == True, but saturation_amplitudes_LPF_50kHz is
733
+ not provided, we just set the saturation amplitude to inf or
734
+ negative inf, based on positive_pulses; equivalently,
735
+ disabling the check saturation.
736
+ edge_exclusion_msec: float, optional
737
+ exclude trigger within edge_exclusion_msec or beginning and end trace
738
+
739
+ return_trigger_data : bool, optional
740
+ If True, return four objects: the first-pass trigger dictionary,
741
+ the first-pass delta chi2 trace, the second-pass trigger
742
+ dictionary, and the second-pass delta chi2 trace. If False,
743
+ return nothing. Regardless of this argument's value, all the
744
+ triggers will be saved in self._trigger_data, but setting this
745
+ True is the only way to determine if an individual trigger
746
+ was from the first-pass or second-pass triggering.
747
+
748
+ """
749
+
750
+ if residual:
751
+
752
+ # Set the saturation amplitude to +/- infinity if not given
753
+ if saturation_amplitudes_LPF_50kHz is None:
754
+ if positive_pulses:
755
+ saturation_amplitudes_LPF_50kHz = [np.inf for _ in range(self._n_channels)]
756
+ else:
757
+ saturation_amplitudes_LPF_50kHz = [-1 * np.inf for _ in range(self._n_channels)]
758
+
759
+ # Do first pass of triggers
760
+ self.find_triggers_once(thresh,
761
+ pileup_window_msec, pileup_window_samples,
762
+ dynamic, dynamic_threshold_function)
763
+
764
+ # Save the first-pass triggers so we don't lose them
765
+ original_triggers = np.copy(self._trigger_data[self._trigger_name]['trigger_index'])
766
+ original_trigger_data = copy.deepcopy(self._trigger_data)
767
+ original_delta_chi2_trace = np.copy(self._delta_chi2_trace)
768
+
769
+ # Loop over first-pass triggers
770
+ for trigger_index in original_triggers:
771
+
772
+ # For each trigger, check if the pulse is saturated. If so,
773
+ # ignore this trigger and move on to the next trigger.
774
+ saturated = False
775
+ for ch_index in range(self._n_channels):
776
+ pulse_amplitude_A = self._raw_trace_LPF_50kHz[ch_index][trigger_index - int(self._t_times / 4) : trigger_index + int(self._t_times / 4)]
777
+ if positive_pulses:
778
+ if sum(pulse_amplitude_A > saturation_amplitudes_LPF_50kHz[ch_index]) > 0:
779
+ saturated = True
780
+ else:
781
+ if sum(pulse_amplitude_A < - 1 * saturation_amplitudes_LPF_50kHz[ch_index]) > 0:
782
+ saturated = True
783
+
784
+ if saturated:
785
+ continue
786
+
787
+ # Based on the M filtered amplitude(s) at the location of
788
+ # the trigger, construct the "best" chi2 shape vs. time,
789
+ # assuming the templates are correct. This is stored in
790
+ # delta_chi2_trace.
791
+ trigger_amplitudes = self._filtered_trace[:,trigger_index]
792
+
793
+ trigger_trace = np.zeros((self._n_channels, self._t_times))
794
+ for iamp in range(self._m_amplitudes):
795
+ trigger_trace += self._template[:, iamp, :] * trigger_amplitudes[iamp]
796
+
797
+ V_td = np.zeros((self._m_amplitudes, self._t_times))
798
+ for theta in range(self._m_amplitudes):
799
+ V_td_per_channel = oaconvolve(trigger_trace, self._phi_td[theta,:],
800
+ mode='same', axes=-1)
801
+ V_td[theta,:] = np.sum(V_td_per_channel, axis=0)
802
+
803
+ filtered_trace = np.einsum('ij,jz->iz', self._iw_matrix, V_td).real
804
+
805
+ delta_chi2_trace = np.einsum('iz,ij,jz->z',
806
+ filtered_trace,
807
+ self._w_matrix,
808
+ filtered_trace)
809
+
810
+ # Subtract delta_chi2_trace for this trigger from the original
811
+ # full self._delta_chi2_trace.
812
+ j_samples = np.argmax(delta_chi2_trace)
813
+ self._delta_chi2_trace[trigger_index - j_samples : trigger_index - j_samples + self._t_times] -= (
814
+ delta_chi2_trace
815
+ )
816
+
817
+ # Retrigger on the residual self._delta_chi2_trace
818
+ self.find_triggers_once(thresh,
819
+ pileup_window_msec, pileup_window_samples,
820
+ dynamic, dynamic_threshold_function)
821
+
822
+ # Save our second-pass triggers so we don't lose them
823
+ new_triggers = np.copy(self._trigger_data[self._trigger_name]['trigger_index'])
824
+ new_trigger_data = copy.deepcopy(self._trigger_data)
825
+
826
+ # Return self._delta_chi2_trace to its original value
827
+ # because right now, it is saving the residual trace.
828
+ self._residual_delta_chi2_trace = np.copy(self._delta_chi2_trace)
829
+ new_delta_chi2_trace = np.copy(self._delta_chi2_trace)
830
+ self._delta_chi2_trace = np.copy(original_delta_chi2_trace)
831
+
832
+ # Combine all triggers into a single dictionary
833
+ combined_trigger_data = combine_trigger_data(
834
+ original_trigger_data, new_trigger_data, original_triggers, new_triggers)
835
+ self._trigger_data = copy.deepcopy(combined_trigger_data)
836
+
837
+ # If the user wants the first-pass and second-pass triggers
838
+ # separately, return those
839
+ if return_trigger_data:
840
+ return original_trigger_data, original_delta_chi2_trace, new_trigger_data, new_delta_chi2_trace
841
+
842
+
843
+ else:
844
+
845
+ # If residual algorithm is disabled, just run find_triggers_once()
846
+ self.find_triggers_once(thresh,
847
+ pileup_window_msec, pileup_window_samples,
848
+ dynamic, dynamic_threshold_function)
849
+
850
+
851
+ # remove trigger from edge exlusion
852
+ if edge_exclusion_msec is not None:
853
+
854
+ threshold_min = edge_exclusion_msec*1e-3
855
+ threshold_max = (self._filtered_trace.shape[-1]/self._fs) - (edge_exclusion_msec*1e-3)
856
+
857
+ trigger_data_copy = copy.deepcopy(self._trigger_data)
858
+
859
+ for chan, data in trigger_data_copy.items():
860
+
861
+ # trigger time, if not triggers do nothing
862
+ trigger_times = data['trigger_time']
863
+ if len(trigger_times) == 0:
864
+ continue
865
+
866
+ # Keep only items strictly inside the range (exclusive)
867
+ indices_to_keep = [i for i, t in enumerate(trigger_times) if threshold_min < t < threshold_max]
868
+
869
+ # Filter all lists consistently
870
+ filtered_data = {
871
+ k: [v[i] for i in indices_to_keep]
872
+ for k, v in data.items()
873
+ }
874
+
875
+ n = len(indices_to_keep)
876
+ filtered_data[f'trigger_edge_exclusion_time_{chan}'] = [edge_exclusion_msec*1e-3] * n
877
+ if livetime is not None:
878
+ filtered_data[f'trigger_livetime_{chan}'] = [livetime] * n
879
+ self._trigger_data[chan] = copy.deepcopy(filtered_data)
880
+
881
+
882
+ def find_triggers_once(self, thresh,
883
+ pileup_window_msec=None, pileup_window_samples=None,
884
+ dynamic=False,
885
+ dynamic_threshold_function=None):
886
+ """
887
+ Method to detect events in the traces with a delta chi2 amplitude
888
+ greater than the specified threshold. Note that this may return
889
+ duplicate events, so care should be taken in post-processing to
890
+ get rid of such events.
891
+
892
+ Parameters
893
+ ----------
894
+ thresh : float
895
+ The number of standard deviations of the energy resolution
896
+ to use as the threshold for which events will be detected
897
+ as a pulse.
898
+ pileup_window_msec : float, optional
899
+ A static pileup window that coalesces regions of time with
900
+ delta chi2 above threshold into a single trigger, as long
901
+ as they are separated by less than the window.
902
+ pileup_window_samples : int, optional
903
+ Same as pileup_window_msec, but in samples
904
+ dynamic : bool, optional
905
+ If True, then pileup_window_msec and pileup_window_samples
906
+ will be ignored. Instead the pileup window will be
907
+ trigger amplitude-dependent. If True, you must also pass,
908
+ dynamic_threshold_function.
909
+ dynamic_threshold_function : function, optional
910
+ A function (probably lambda/anonymous, but doesn't have
911
+ to be) that converts a delta chi2 value to a pileup window
912
+ in samples. This function should accept one scalar float as
913
+ an argument and output one scalar float.
914
+
915
+ """
916
+
917
+ # check filtered trace
918
+ if self._delta_chi2_trace is None:
919
+ raise ValueError('ERROR: Filter trace not available. '
920
+ + ' Use "update_trace" first!')
921
+
922
+
923
+ # intialize dictionary with list of
924
+ # variables
925
+ trigger_data = {
926
+ 'trigger_delta_chi2':list(),
927
+ 'trigger_time': list(),
928
+ 'trigger_index': list(),
929
+ 'trigger_pileup_window': list(),
930
+ 'trigger_threshold_sigma': list(),
931
+ 'trigger_type': list()}
932
+ for iamp in range(self._m_amplitudes):
933
+ trigger_data[f'trigger_amplitude_{iamp}'] = list()
934
+ if self._m_amplitudes == 1:
935
+ trigger_data[f'trigger_amplitude'] = list()
936
+
937
+ # merge window
938
+ pileup_window = 0
939
+ if pileup_window_msec is not None:
940
+ pileup_window = int(pileup_window_msec*self._fs/1000)
941
+ elif pileup_window_samples is not None:
942
+ pileup_window = pileup_window_samples
943
+
944
+ # Trigger on chi2, using "thresh" in sigma units as representing
945
+ # the two-sided survival fraction of a Gaussian. For example, if
946
+ # thresh = 1, we will trigger on 32% of samples by random chance;
947
+ # corresponding to the 32% of probability density outside the
948
+ # 1-sigma bands in a Gaussian. If thresh = 5, we will trigger on
949
+ # 5.733e-07 of samples by random chance; again, the integrated
950
+ # probability beyond +/- 5 sigma in a Gaussian.
951
+ # This is done so that the trigger behaves roughly as it used to,
952
+ # while accounting for the difference between a chi2 and Gaussian
953
+ # distribution.
954
+
955
+ #stats.norm.sf can't give arbitrarilly small values, need to switch
956
+ #to a different calculation method around threshold = 30. We'll just
957
+ #assume k = 1 so chi^2 = sigma^2
958
+
959
+ if thresh < 25:
960
+ survival_fraction = stats.norm.sf(thresh) * 2
961
+ chi2_threshold = special.gammainccinv(self._m_amplitudes / 2, survival_fraction) * 2
962
+ else:
963
+ chi2_threshold = thresh**2
964
+ if self._m_amplitudes > 1:
965
+ warnings.warn('You have asked for an amplitude threshold of ' +
966
+ f'{thresh} sigma, but this is too high for us ' +
967
+ 'calculate the equivalent chi2 threshold. We are ' +
968
+ 'going to use the result for M = 1, which is a ' +
969
+ f'threshold of chi2 = {chi2_threshold}.')
970
+
971
+ self.chi2_threshold = chi2_threshold
972
+
973
+ triggers_mask = self._delta_chi2_trace > chi2_threshold
974
+
975
+ triggers = np.where(triggers_mask)[0]
976
+
977
+ # check if any left over detected triggers are within
978
+ # the specified pulse_range from each other
979
+
980
+ if dynamic:
981
+ trigger_ranges, trigger_vals = _getchangeslessthandynamicthresh(
982
+ triggers, self._delta_chi2_trace[triggers_mask],
983
+ dynamic_threshold_function)
984
+ else:
985
+ trigger_ranges, trigger_vals = _getchangeslessthanthresh(
986
+ triggers, pileup_window)
987
+
988
+ # set the trigger type to pulses
989
+ rangetypes = np.zeros((len(trigger_ranges), 3), dtype=bool)
990
+ rangetypes[:,1] = True
991
+
992
+ # for each range with changes less than the merge_windw
993
+ # keep only the bin with the largest amplitude
994
+ for irange, evt_range in enumerate(trigger_ranges):
995
+
996
+ if evt_range[1]>evt_range[0]:
997
+
998
+ evt_inds = triggers[evt_range[0]:evt_range[1]]
999
+
1000
+ # find index maximum amplitude
1001
+ evt_ind = None
1002
+ evt_ind = evt_inds[np.argmax(self._delta_chi2_trace[evt_inds])]
1003
+ evt_ind_shift = evt_ind + self._trigger_index_shift
1004
+
1005
+ # fill dictionary
1006
+ trigger_data['trigger_index'].extend([evt_ind_shift])
1007
+ trigger_data['trigger_time'].extend([evt_ind_shift/self._fs])
1008
+ trigger_data['trigger_delta_chi2'].extend([self._delta_chi2_trace[evt_ind]])
1009
+ trigger_data['trigger_type'].extend([4])
1010
+ for iamp in range(self._m_amplitudes):
1011
+ trigger_data[f'trigger_amplitude_{iamp}'].extend([self._filtered_trace[iamp][evt_ind]])
1012
+ if self._m_amplitudes == 1:
1013
+ trigger_data[f'trigger_amplitude'].extend([self._filtered_trace[0][evt_ind]])
1014
+
1015
+ # extra parameters
1016
+ trigger_data['trigger_threshold_sigma'].extend([thresh])
1017
+ trigger_data['trigger_pileup_window'].extend([pileup_window])
1018
+
1019
+
1020
+ # add trigger channel
1021
+ nb_events = len(trigger_data['trigger_index'])
1022
+ if nb_events>0:
1023
+ trigger_data['trigger_channel'] = [str(self._trigger_name)]*nb_events
1024
+
1025
+
1026
+ # duplicate key channel name
1027
+ self._trigger_data = dict()
1028
+ self._trigger_data[self._trigger_name] = trigger_data.copy()
1029
+
1030
+ for key, val in trigger_data.items():
1031
+ newkey = key + '_' + self._trigger_name
1032
+ self._trigger_data[self._trigger_name][newkey] = val
1033
+
1034
+
1035
+