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.
- pytesprocess/__init__.py +9 -0
- pytesprocess/_version.py +2 -0
- pytesprocess/cli/__init__.py +1 -0
- pytesprocess/cli/commands/__init__.py +5 -0
- pytesprocess/cli/commands/event.py +66 -0
- pytesprocess/cli/commands/filter.py +17 -0
- pytesprocess/cli/commands/ivsweep.py +29 -0
- pytesprocess/cli/common.py +86 -0
- pytesprocess/cli/main.py +81 -0
- pytesprocess/config/__init__.py +4 -0
- pytesprocess/config/loader.py +94 -0
- pytesprocess/config/manager.py +297 -0
- pytesprocess/config/resolvers/__init__.py +5 -0
- pytesprocess/config/resolvers/common.py +56 -0
- pytesprocess/config/resolvers/feature.py +293 -0
- pytesprocess/config/resolvers/salting.py +86 -0
- pytesprocess/config/resolvers/trigger.py +84 -0
- pytesprocess/config/selectors.py +108 -0
- pytesprocess/config/validation.py +314 -0
- pytesprocess/config/warnings.py +2 -0
- pytesprocess/core/__init__.py +10 -0
- pytesprocess/core/algorithms.py +1455 -0
- pytesprocess/core/didv.py +1648 -0
- pytesprocess/core/eventbuilder.py +495 -0
- pytesprocess/core/filterbuilder.py +81 -0
- pytesprocess/core/filterdata.py +1849 -0
- pytesprocess/core/ivsweep.py +2072 -0
- pytesprocess/core/noise.py +923 -0
- pytesprocess/core/noisemodel.py +1408 -0
- pytesprocess/core/oftrigger.py +1035 -0
- pytesprocess/core/template.py +450 -0
- pytesprocess/process/__init__.py +6 -0
- pytesprocess/process/data_source.py +185 -0
- pytesprocess/process/event_context.py +35 -0
- pytesprocess/process/feature_plan.py +186 -0
- pytesprocess/process/feature_resources.py +267 -0
- pytesprocess/process/features.py +1024 -0
- pytesprocess/process/filterprocess.py +1176 -0
- pytesprocess/process/ivprocess.py +1380 -0
- pytesprocess/process/processing_data.py +967 -0
- pytesprocess/process/randoms.py +921 -0
- pytesprocess/process/triggers.py +1011 -0
- pytesprocess/salting/__init__.py +7 -0
- pytesprocess/salting/generator.py +364 -0
- pytesprocess/salting/injector.py +329 -0
- pytesprocess/salting/sampling.py +84 -0
- pytesprocess/utils/__init__.py +5 -0
- pytesprocess/utils/arg_utils.py +122 -0
- pytesprocess/utils/dataframe_output.py +120 -0
- pytesprocess/utils/filter_hdf5.py +594 -0
- pytesprocess/utils/utils.py +701 -0
- pytesprocess/workflows/__init__.py +3 -0
- pytesprocess/workflows/processing.py +317 -0
- pytesprocess/workflows/salting.py +133 -0
- pytesprocess-0.1.1.dist-info/METADATA +211 -0
- pytesprocess-0.1.1.dist-info/RECORD +60 -0
- pytesprocess-0.1.1.dist-info/WHEEL +5 -0
- pytesprocess-0.1.1.dist-info/entry_points.txt +2 -0
- pytesprocess-0.1.1.dist-info/licenses/LICENSE +21 -0
- 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
|
+
|