reboost 0.8.3__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.
- reboost/__init__.py +14 -0
- reboost/_version.py +34 -0
- reboost/build_evt.py +134 -0
- reboost/build_glm.py +305 -0
- reboost/build_hit.py +466 -0
- reboost/cli.py +194 -0
- reboost/core.py +526 -0
- reboost/daq/__init__.py +5 -0
- reboost/daq/core.py +262 -0
- reboost/daq/utils.py +28 -0
- reboost/hpge/__init__.py +0 -0
- reboost/hpge/psd.py +847 -0
- reboost/hpge/surface.py +284 -0
- reboost/hpge/utils.py +79 -0
- reboost/iterator.py +226 -0
- reboost/log_utils.py +29 -0
- reboost/math/__init__.py +0 -0
- reboost/math/functions.py +175 -0
- reboost/math/stats.py +119 -0
- reboost/optmap/__init__.py +5 -0
- reboost/optmap/cli.py +246 -0
- reboost/optmap/convolve.py +325 -0
- reboost/optmap/create.py +423 -0
- reboost/optmap/evt.py +141 -0
- reboost/optmap/mapview.py +208 -0
- reboost/optmap/numba_pdg.py +26 -0
- reboost/optmap/optmap.py +328 -0
- reboost/profile.py +82 -0
- reboost/shape/__init__.py +0 -0
- reboost/shape/cluster.py +260 -0
- reboost/shape/group.py +189 -0
- reboost/shape/reduction.py +0 -0
- reboost/spms/__init__.py +5 -0
- reboost/spms/pe.py +178 -0
- reboost/units.py +107 -0
- reboost/utils.py +503 -0
- reboost-0.8.3.dist-info/METADATA +82 -0
- reboost-0.8.3.dist-info/RECORD +42 -0
- reboost-0.8.3.dist-info/WHEEL +5 -0
- reboost-0.8.3.dist-info/entry_points.txt +3 -0
- reboost-0.8.3.dist-info/licenses/LICENSE +674 -0
- reboost-0.8.3.dist-info/top_level.txt +1 -0
reboost/hpge/psd.py
ADDED
|
@@ -0,0 +1,847 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from math import erf, exp
|
|
5
|
+
|
|
6
|
+
import awkward as ak
|
|
7
|
+
import numba
|
|
8
|
+
import numpy as np
|
|
9
|
+
import pint
|
|
10
|
+
import pyg4ometry
|
|
11
|
+
from lgdo import Array, VectorOfVectors
|
|
12
|
+
from numpy.typing import ArrayLike, NDArray
|
|
13
|
+
|
|
14
|
+
from .. import units
|
|
15
|
+
from ..units import ureg as u
|
|
16
|
+
from .utils import HPGeScalarRZField
|
|
17
|
+
|
|
18
|
+
log = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def r90(edep: ak.Array, xloc: ak.Array, yloc: ak.Array, zloc: ak.Array) -> Array:
|
|
22
|
+
"""R90 HPGe pulse shape heuristic.
|
|
23
|
+
|
|
24
|
+
Parameters
|
|
25
|
+
----------
|
|
26
|
+
edep
|
|
27
|
+
array of energy.
|
|
28
|
+
xloc
|
|
29
|
+
array of x coordinate position.
|
|
30
|
+
yloc
|
|
31
|
+
array of y coordinate position.
|
|
32
|
+
zloc
|
|
33
|
+
array of z coordinate position.
|
|
34
|
+
"""
|
|
35
|
+
tot_energy = ak.sum(edep, axis=-1, keepdims=True)
|
|
36
|
+
|
|
37
|
+
def eweight_mean(field, energy):
|
|
38
|
+
return ak.sum(energy * field, axis=-1, keepdims=True) / tot_energy
|
|
39
|
+
|
|
40
|
+
# Compute distance of each edep to the weighted mean
|
|
41
|
+
dist = np.sqrt(
|
|
42
|
+
(xloc - eweight_mean(edep, xloc)) ** 2
|
|
43
|
+
+ (yloc - eweight_mean(edep, yloc)) ** 2
|
|
44
|
+
+ (zloc - eweight_mean(edep, zloc)) ** 2
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
# Sort distances and corresponding edep within each event
|
|
48
|
+
sorted_indices = ak.argsort(dist, axis=-1)
|
|
49
|
+
sorted_dist = dist[sorted_indices]
|
|
50
|
+
sorted_edep = edep[sorted_indices]
|
|
51
|
+
|
|
52
|
+
def _ak_cumsum(layout, **_kwargs):
|
|
53
|
+
if layout.is_numpy:
|
|
54
|
+
return ak.contents.NumpyArray(np.cumsum(layout.data))
|
|
55
|
+
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
# Calculate the cumulative sum of energies for each event
|
|
59
|
+
cumsum_edep = ak.transform(
|
|
60
|
+
_ak_cumsum, sorted_edep
|
|
61
|
+
) # Implement cumulative sum over whole jagged array
|
|
62
|
+
if len(edep) == 1:
|
|
63
|
+
cumsum_edep_corrected = cumsum_edep
|
|
64
|
+
else:
|
|
65
|
+
cumsum_edep_corrected = (
|
|
66
|
+
cumsum_edep[1:] - cumsum_edep[:-1, -1]
|
|
67
|
+
) # correct to get cumsum of each lower level array
|
|
68
|
+
cumsum_edep_corrected = ak.concatenate(
|
|
69
|
+
[
|
|
70
|
+
cumsum_edep[:1], # The first element of the original cumsum is correct
|
|
71
|
+
cumsum_edep_corrected,
|
|
72
|
+
]
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
threshold = 0.9 * tot_energy
|
|
76
|
+
r90_indices = ak.argmax(cumsum_edep_corrected >= threshold, axis=-1, keepdims=True)
|
|
77
|
+
r90 = sorted_dist[r90_indices]
|
|
78
|
+
|
|
79
|
+
return Array(ak.flatten(r90).to_numpy())
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def drift_time(
|
|
83
|
+
xloc: ArrayLike,
|
|
84
|
+
yloc: ArrayLike,
|
|
85
|
+
zloc: ArrayLike,
|
|
86
|
+
dt_map: HPGeScalarRZField,
|
|
87
|
+
coord_offset: pint.Quantity | pyg4ometry.gdml.Position = (0, 0, 0) * u.m,
|
|
88
|
+
) -> VectorOfVectors:
|
|
89
|
+
"""Calculates drift times for each step (cluster) in an HPGe detector.
|
|
90
|
+
|
|
91
|
+
Parameters
|
|
92
|
+
----------
|
|
93
|
+
xloc
|
|
94
|
+
awkward array of x coordinate position.
|
|
95
|
+
yloc
|
|
96
|
+
awkward array of y coordinate position.
|
|
97
|
+
zloc
|
|
98
|
+
awkward array of z coordinate position.
|
|
99
|
+
dt_map
|
|
100
|
+
the drift time map.
|
|
101
|
+
coord_offset
|
|
102
|
+
this `(x, y, z)` coordinates will be subtracted to (xloc, yloc, zloc)`
|
|
103
|
+
before drift time computation. The length units must be the same as
|
|
104
|
+
`xloc`, `yloc` and `zloc`.
|
|
105
|
+
"""
|
|
106
|
+
# sanitize coord_offset
|
|
107
|
+
coord_offset = units.pg4_to_pint(coord_offset)
|
|
108
|
+
|
|
109
|
+
# unit handling (for matching with drift time map units)
|
|
110
|
+
xu, yu = [units.units_convfact(data, dt_map.r_units) for data in (xloc, yloc)]
|
|
111
|
+
zu = units.units_convfact(zloc, dt_map.z_units)
|
|
112
|
+
|
|
113
|
+
# unwrap LGDOs
|
|
114
|
+
xloc, yloc, zloc = [units.unwrap_lgdo(data)[0] for data in (xloc, yloc, zloc)]
|
|
115
|
+
|
|
116
|
+
# awkward transform to apply the drift time map to the step coordinates
|
|
117
|
+
def _ak_dt_map(layouts, **_kwargs):
|
|
118
|
+
if layouts[0].is_numpy and layouts[1].is_numpy:
|
|
119
|
+
return ak.contents.NumpyArray(
|
|
120
|
+
dt_map.φ(np.stack([layouts[0].data, layouts[1].data], axis=1))
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
# transform coordinates
|
|
126
|
+
xloc = xu * xloc - coord_offset[0].to(dt_map.r_units).m
|
|
127
|
+
yloc = yu * yloc - coord_offset[1].to(dt_map.r_units).m
|
|
128
|
+
zloc = zu * zloc - coord_offset[2].to(dt_map.z_units).m
|
|
129
|
+
|
|
130
|
+
# evaluate the drift time
|
|
131
|
+
dt_values = ak.transform(
|
|
132
|
+
_ak_dt_map,
|
|
133
|
+
np.sqrt(xloc**2 + yloc**2),
|
|
134
|
+
zloc,
|
|
135
|
+
)
|
|
136
|
+
return VectorOfVectors(
|
|
137
|
+
dt_values,
|
|
138
|
+
attrs={"units": units.unit_to_lh5_attr(dt_map.φ_units)},
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def drift_time_heuristic(
|
|
143
|
+
drift_time: ArrayLike,
|
|
144
|
+
edep: ArrayLike,
|
|
145
|
+
) -> Array:
|
|
146
|
+
"""HPGe drift-time-based pulse-shape heuristic.
|
|
147
|
+
|
|
148
|
+
See :func:`_drift_time_heuristic_impl` for a description of the algorithm.
|
|
149
|
+
|
|
150
|
+
Parameters
|
|
151
|
+
----------
|
|
152
|
+
drift_time
|
|
153
|
+
drift time of charges originating from steps/clusters. Can be
|
|
154
|
+
calculated with :func:`drift_time`.
|
|
155
|
+
edep
|
|
156
|
+
energy deposited in step/cluster (same shape as `drift_time`).
|
|
157
|
+
"""
|
|
158
|
+
# extract LGDO data and units
|
|
159
|
+
drift_time, t_units = units.unwrap_lgdo(drift_time)
|
|
160
|
+
edep, e_units = units.unwrap_lgdo(edep)
|
|
161
|
+
|
|
162
|
+
# we want to attach the right units to the dt heuristic, if possible
|
|
163
|
+
attrs = {}
|
|
164
|
+
if t_units is not None and e_units is not None:
|
|
165
|
+
attrs["units"] = units.unit_to_lh5_attr(t_units / e_units)
|
|
166
|
+
|
|
167
|
+
return Array(_drift_time_heuristic_impl(drift_time, edep), attrs=attrs)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@numba.njit(cache=True)
|
|
171
|
+
def _drift_time_heuristic_impl(
|
|
172
|
+
dt: ak.Array,
|
|
173
|
+
edep: ak.Array,
|
|
174
|
+
) -> NDArray:
|
|
175
|
+
r"""Low-level implementation of the HPGe drift-time-based pulse-shape heuristic.
|
|
176
|
+
|
|
177
|
+
Accepts Awkward arrays and uses Numba to speed up the computation.
|
|
178
|
+
|
|
179
|
+
For each hit (collection of steps), the drift times and corresponding
|
|
180
|
+
energies are sorted in ascending order. The function finds the optimal
|
|
181
|
+
split point :math:`m` that maximizes the *identification metric*:
|
|
182
|
+
|
|
183
|
+
.. math::
|
|
184
|
+
|
|
185
|
+
I = \frac{|T_1 - T_2|}{E_\text{s}(E_1, E_2)}
|
|
186
|
+
|
|
187
|
+
where:
|
|
188
|
+
|
|
189
|
+
.. math::
|
|
190
|
+
|
|
191
|
+
T_1 = \frac{\sum_{i < m} t_i E_i}{\sum_{i < m} E_i}
|
|
192
|
+
\quad \text{and} \quad
|
|
193
|
+
T_2 = \frac{\sum_{i \geq m} t_i E_i}{\sum_{i \geq m} E_i}
|
|
194
|
+
|
|
195
|
+
are the energy-weighted mean drift times of the two groups.
|
|
196
|
+
|
|
197
|
+
.. math::
|
|
198
|
+
|
|
199
|
+
E_\text{scale}(E_1, E_2) = \frac{1}{\sqrt{E_1 E_2}}
|
|
200
|
+
|
|
201
|
+
is the scaling factor.
|
|
202
|
+
|
|
203
|
+
The function iterates over all possible values of :math:`m` and selects the
|
|
204
|
+
maximum `I` as the drift time heuristic value.
|
|
205
|
+
"""
|
|
206
|
+
dt_heu = np.zeros(len(dt))
|
|
207
|
+
|
|
208
|
+
# loop over hits
|
|
209
|
+
for i in range(len(dt)):
|
|
210
|
+
t = np.asarray(dt[i])
|
|
211
|
+
e = np.asarray(edep[i])
|
|
212
|
+
|
|
213
|
+
valid_idx = np.where(e > 0)[0]
|
|
214
|
+
if len(valid_idx) < 2:
|
|
215
|
+
continue
|
|
216
|
+
|
|
217
|
+
t = t[valid_idx]
|
|
218
|
+
e = e[valid_idx]
|
|
219
|
+
|
|
220
|
+
sort_idx = np.argsort(t)
|
|
221
|
+
t = t[sort_idx]
|
|
222
|
+
e = e[sort_idx]
|
|
223
|
+
|
|
224
|
+
max_id_metric = 0
|
|
225
|
+
for j in range(1, len(t)):
|
|
226
|
+
e1 = np.sum(e[:j])
|
|
227
|
+
e2 = np.sum(e[j:])
|
|
228
|
+
|
|
229
|
+
t1 = np.sum(t[:j] * e[:j]) / e1
|
|
230
|
+
t2 = np.sum(t[j:] * e[j:]) / e2
|
|
231
|
+
|
|
232
|
+
id_metric = abs(t1 - t2) * np.sqrt(e1 * e2)
|
|
233
|
+
|
|
234
|
+
max_id_metric = max(max_id_metric, id_metric)
|
|
235
|
+
|
|
236
|
+
dt_heu[i] = max_id_metric
|
|
237
|
+
|
|
238
|
+
return dt_heu
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
@numba.njit(cache=True)
|
|
242
|
+
def _njit_erf(x: ArrayLike) -> NDArray:
|
|
243
|
+
"""Error function that can take in a numpy array."""
|
|
244
|
+
out = np.empty_like(x)
|
|
245
|
+
for i in range(x.size):
|
|
246
|
+
out[i] = erf(x[i])
|
|
247
|
+
return out
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
@numba.njit(cache=True)
|
|
251
|
+
def _current_pulse_model(
|
|
252
|
+
times: ArrayLike,
|
|
253
|
+
amax: float,
|
|
254
|
+
mu: float,
|
|
255
|
+
sigma: float,
|
|
256
|
+
tail_fraction: float,
|
|
257
|
+
tau: float,
|
|
258
|
+
high_tail_fraction: float = 0,
|
|
259
|
+
high_tau: float = 0,
|
|
260
|
+
) -> NDArray:
|
|
261
|
+
r"""Analytic model for the current pulse in a Germanium detector.
|
|
262
|
+
|
|
263
|
+
Consists of a Gaussian, a high side exponential tail and a low side tail:
|
|
264
|
+
|
|
265
|
+
.. math::
|
|
266
|
+
|
|
267
|
+
\begin{align}
|
|
268
|
+
A(t) = \; &A_\text{max} \times (1-p-p_h) \times \text{Gauss}(t;\mu,\sigma) \\
|
|
269
|
+
&+ A \times p \; \left(1 - \text{erf}\left(\frac{t-\mu}{\sigma_i}\right)\right) \times \frac{e^{t/\tau}}{2e^{\mu/\tau}} \\
|
|
270
|
+
&+ A \times p_h \; \left(1 - \text{erf}\left(-\frac{t-\mu}{\sigma_i}\right)\right) \times \frac{1}{2}e^{-t/\tau}
|
|
271
|
+
\end{align}
|
|
272
|
+
|
|
273
|
+
Parameters
|
|
274
|
+
----------
|
|
275
|
+
times
|
|
276
|
+
Array of times to compute current for.
|
|
277
|
+
amax
|
|
278
|
+
Maximum current for the template
|
|
279
|
+
mu
|
|
280
|
+
Time of the maximum current.
|
|
281
|
+
sigma
|
|
282
|
+
Width of the current pulse
|
|
283
|
+
tail_fraction
|
|
284
|
+
Fraction of the tail in the pulse.
|
|
285
|
+
tau
|
|
286
|
+
Time constant of the low time tail.
|
|
287
|
+
high__tail_fraction
|
|
288
|
+
Fraction of the high tail in the pulse.
|
|
289
|
+
high_tau
|
|
290
|
+
Time constant of the high time tail.
|
|
291
|
+
|
|
292
|
+
Returns
|
|
293
|
+
-------
|
|
294
|
+
The predicted current waveform for this energy deposit.
|
|
295
|
+
"""
|
|
296
|
+
norm = 2 * exp(mu / tau)
|
|
297
|
+
norm_high = 2
|
|
298
|
+
|
|
299
|
+
dx = times - mu
|
|
300
|
+
term1 = (
|
|
301
|
+
amax * (1 - tail_fraction - high_tail_fraction) * np.exp(-(dx * dx) / (2 * sigma * sigma))
|
|
302
|
+
)
|
|
303
|
+
term2 = amax * tail_fraction * (1 - _njit_erf(dx / sigma)) * np.exp(times / tau) / norm
|
|
304
|
+
term3 = (
|
|
305
|
+
amax
|
|
306
|
+
* high_tail_fraction
|
|
307
|
+
* (1 - _njit_erf(-dx / sigma))
|
|
308
|
+
* np.exp(-(times - mu) / high_tau)
|
|
309
|
+
/ norm_high
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
return term1 + term2 + term3
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
@numba.njit(cache=True)
|
|
316
|
+
def _interpolate_pulse_model(
|
|
317
|
+
template: Array, time: float, start: float, end: float, dt: float, mu: float
|
|
318
|
+
) -> NDArray:
|
|
319
|
+
"""Interpolate to extract the pulse model given a particular mu."""
|
|
320
|
+
local_time = time - mu - start
|
|
321
|
+
|
|
322
|
+
if (local_time < start) or (int(local_time) > end):
|
|
323
|
+
return 0
|
|
324
|
+
|
|
325
|
+
sample = int(local_time / dt)
|
|
326
|
+
A_before = template[sample]
|
|
327
|
+
A_after = template[sample + 1]
|
|
328
|
+
|
|
329
|
+
frac = (local_time - int(local_time)) / dt
|
|
330
|
+
return A_before + frac * (A_after - A_before)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def make_convolved_surface_library(bulk_template: np.array, surface_library: np.array) -> NDArray:
|
|
334
|
+
"""Make the convolved surface library out of the template.
|
|
335
|
+
|
|
336
|
+
This convolves every row of the surface_library with the template and reshapes the output
|
|
337
|
+
to match the initial template. It returns a 2D array with one more row than the surface_library
|
|
338
|
+
and each row the same length as the template. The final row is the bulk_template for easier interpolation.
|
|
339
|
+
|
|
340
|
+
Parameters
|
|
341
|
+
----------
|
|
342
|
+
bulk_template
|
|
343
|
+
The template for the bulk response
|
|
344
|
+
surface_library
|
|
345
|
+
The 2D array of the surface library.
|
|
346
|
+
|
|
347
|
+
Returns
|
|
348
|
+
-------
|
|
349
|
+
2D array of the surface library convolved with the bulk response.
|
|
350
|
+
"""
|
|
351
|
+
# force surface library to be 2D
|
|
352
|
+
if surface_library.ndim == 1:
|
|
353
|
+
surface_library = surface_library.reshape((-1, 1))
|
|
354
|
+
|
|
355
|
+
templates = np.zeros((len(bulk_template), np.shape(surface_library)[1] + 1))
|
|
356
|
+
|
|
357
|
+
for i in range(np.shape(surface_library)[1]):
|
|
358
|
+
templates[:, i] = convolve_surface_response(
|
|
359
|
+
surface_library[1:, i] - surface_library[:-1, i], bulk_template
|
|
360
|
+
)[: len(bulk_template)]
|
|
361
|
+
|
|
362
|
+
templates[:, -1] = bulk_template
|
|
363
|
+
|
|
364
|
+
return templates
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def convolve_surface_response(surf_current: np.ndarray, bulk_pulse: np.ndarray) -> NDArray:
|
|
368
|
+
"""Convolve the surface response pulse with the bulk current pulse.
|
|
369
|
+
|
|
370
|
+
This combines the current induced on the edge of the FCCD region with the bulk response
|
|
371
|
+
on the p+ contact.
|
|
372
|
+
|
|
373
|
+
Parameters
|
|
374
|
+
----------
|
|
375
|
+
surf_current
|
|
376
|
+
array of the current induced via diffusion against time.
|
|
377
|
+
bulk_pulse
|
|
378
|
+
the pulse template to convolve the surface current with.
|
|
379
|
+
|
|
380
|
+
Returns
|
|
381
|
+
-------
|
|
382
|
+
the current waveform after convolution.
|
|
383
|
+
"""
|
|
384
|
+
return np.convolve(surf_current, bulk_pulse, mode="full")[: len(surf_current)]
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
@numba.njit(cache=True)
|
|
388
|
+
def get_current_waveform(
|
|
389
|
+
edep: ak.Array,
|
|
390
|
+
drift_time: ak.Array,
|
|
391
|
+
template: ArrayLike,
|
|
392
|
+
start: float,
|
|
393
|
+
dt: float,
|
|
394
|
+
range_t: tuple,
|
|
395
|
+
) -> tuple(NDArray, NDArray):
|
|
396
|
+
r"""Estimate the current waveform.
|
|
397
|
+
|
|
398
|
+
Based on modelling the current as a sum over the current pulse model defined by
|
|
399
|
+
the template.
|
|
400
|
+
|
|
401
|
+
.. math::
|
|
402
|
+
A(t) = \sum_i E_i \times N f(t, dt_i, \vec{\theta})
|
|
403
|
+
|
|
404
|
+
Where:
|
|
405
|
+
- :math:`f(t)` is the template
|
|
406
|
+
- :math`\vec{\theta}` are the parameters :math:`(\sigma, p, \tau)`
|
|
407
|
+
- :math:`E_i` and :math:`dt_i` are the deposited energy and drift time.
|
|
408
|
+
- :math:`N` is a normalisation term
|
|
409
|
+
|
|
410
|
+
Parameters
|
|
411
|
+
----------
|
|
412
|
+
edep
|
|
413
|
+
Array of energies for each step
|
|
414
|
+
drift_time
|
|
415
|
+
Array of drift times for each step
|
|
416
|
+
template
|
|
417
|
+
array of the template for the current waveforms
|
|
418
|
+
start
|
|
419
|
+
first time value of the template
|
|
420
|
+
dt
|
|
421
|
+
timestep (in ns) for the template.
|
|
422
|
+
range_t
|
|
423
|
+
a range of times to search around
|
|
424
|
+
|
|
425
|
+
Returns
|
|
426
|
+
-------
|
|
427
|
+
A tuple of the time and current for the current waveform for this event.
|
|
428
|
+
"""
|
|
429
|
+
n = len(template)
|
|
430
|
+
|
|
431
|
+
times = np.arange(n) * dt + start
|
|
432
|
+
y = np.zeros_like(times, dtype=np.float64)
|
|
433
|
+
|
|
434
|
+
for j in range(n):
|
|
435
|
+
time = start + dt * j
|
|
436
|
+
if (time < range_t[0]) or (time > (range_t[1] - dt)):
|
|
437
|
+
continue
|
|
438
|
+
y[j] = _get_waveform_value(j, edep, drift_time, template, start, dt, range_t)
|
|
439
|
+
|
|
440
|
+
return times, y
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
@numba.njit(cache=True)
|
|
444
|
+
def _get_waveform_value_surface(
|
|
445
|
+
idx: int,
|
|
446
|
+
edep: NDArray,
|
|
447
|
+
drift_time: np.array,
|
|
448
|
+
dist_to_nplus: np.array,
|
|
449
|
+
bulk_template: ArrayLike,
|
|
450
|
+
templates_surface: ArrayLike,
|
|
451
|
+
activeness_surface: ArrayLike,
|
|
452
|
+
distance_step_in_um: float,
|
|
453
|
+
fccd: float,
|
|
454
|
+
start: float,
|
|
455
|
+
dt: float,
|
|
456
|
+
) -> tuple[float, float]:
|
|
457
|
+
"""Get the value of the waveform at a certain index.
|
|
458
|
+
|
|
459
|
+
Parameters
|
|
460
|
+
----------
|
|
461
|
+
idx
|
|
462
|
+
the index of the time array to find the waveform at.
|
|
463
|
+
edep
|
|
464
|
+
Array of energies for each step
|
|
465
|
+
drift_time
|
|
466
|
+
Array of drift times for each step
|
|
467
|
+
template
|
|
468
|
+
array of the template for the current waveforms
|
|
469
|
+
templates_surface
|
|
470
|
+
The current templates from the surface.
|
|
471
|
+
activeness_surface
|
|
472
|
+
The total collected charge for each surface point.
|
|
473
|
+
dist_step_in_um
|
|
474
|
+
The binning in distance for the surface pulse library.
|
|
475
|
+
start
|
|
476
|
+
first time value of the template
|
|
477
|
+
dt
|
|
478
|
+
timestep (in ns) for the template.
|
|
479
|
+
|
|
480
|
+
Returns
|
|
481
|
+
-------
|
|
482
|
+
Value of the current waveform and the energy.
|
|
483
|
+
"""
|
|
484
|
+
n = len(bulk_template)
|
|
485
|
+
out = 0
|
|
486
|
+
etmp = 0
|
|
487
|
+
time = start + dt * idx
|
|
488
|
+
|
|
489
|
+
for i in range(len(edep)):
|
|
490
|
+
E = edep[i]
|
|
491
|
+
mu = drift_time[i]
|
|
492
|
+
dist = dist_to_nplus[i]
|
|
493
|
+
|
|
494
|
+
if dist < fccd:
|
|
495
|
+
dist_bin = int(dist / distance_step_in_um)
|
|
496
|
+
|
|
497
|
+
# get two values (to allow linear interpolation)
|
|
498
|
+
value_low = _interpolate_pulse_model(
|
|
499
|
+
templates_surface[dist_bin], time, start, start + dt * n, dt, mu
|
|
500
|
+
)
|
|
501
|
+
value_high = _interpolate_pulse_model(
|
|
502
|
+
templates_surface[dist_bin + 1], time, start, start + dt * n, dt, mu
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
# interpolate between distance bins
|
|
506
|
+
diff = dist / distance_step_in_um - dist_bin
|
|
507
|
+
out += E * (value_low + diff * (value_high - value_low))
|
|
508
|
+
|
|
509
|
+
act_low = activeness_surface[dist_bin]
|
|
510
|
+
act_high = activeness_surface[dist_bin + 1]
|
|
511
|
+
etmp += (act_low + diff * (act_high - act_low)) * E
|
|
512
|
+
|
|
513
|
+
else:
|
|
514
|
+
out += E * _interpolate_pulse_model(bulk_template, time, start, start + dt * n, dt, mu)
|
|
515
|
+
etmp += E
|
|
516
|
+
return out, etmp
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
@numba.njit(cache=True)
|
|
520
|
+
def _get_waveform_value(
|
|
521
|
+
idx: int,
|
|
522
|
+
edep: ak.Array,
|
|
523
|
+
drift_time: ak.Array,
|
|
524
|
+
template: ArrayLike,
|
|
525
|
+
start: float,
|
|
526
|
+
dt: float,
|
|
527
|
+
) -> float:
|
|
528
|
+
"""Get the value of the waveform at a certain index.
|
|
529
|
+
|
|
530
|
+
Parameters
|
|
531
|
+
----------
|
|
532
|
+
idx
|
|
533
|
+
the index of the time array to find the waveform at.
|
|
534
|
+
edep
|
|
535
|
+
Array of energies for each step
|
|
536
|
+
drift_time
|
|
537
|
+
Array of drift times for each step
|
|
538
|
+
template
|
|
539
|
+
array of the template for the current waveforms
|
|
540
|
+
start
|
|
541
|
+
first time value of the template
|
|
542
|
+
dt
|
|
543
|
+
timestep (in ns) for the template.
|
|
544
|
+
|
|
545
|
+
Returns
|
|
546
|
+
-------
|
|
547
|
+
Value of the current waveform
|
|
548
|
+
"""
|
|
549
|
+
n = len(template)
|
|
550
|
+
out = 0
|
|
551
|
+
time = start + dt * idx
|
|
552
|
+
|
|
553
|
+
for i in range(len(edep)):
|
|
554
|
+
E = edep[i]
|
|
555
|
+
mu = drift_time[i]
|
|
556
|
+
|
|
557
|
+
out += E * _interpolate_pulse_model(template, time, start, start + dt * n, dt, mu)
|
|
558
|
+
|
|
559
|
+
return out
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def get_current_template(
|
|
563
|
+
low: float = -1000, high: float = 4000, step: float = 1, mean_aoe: float = 1, **kwargs
|
|
564
|
+
) -> tuple[NDArray, NDArray]:
|
|
565
|
+
"""Build the current template from the analytic model, defined by :func:`_current_pulse_model`.
|
|
566
|
+
|
|
567
|
+
Parameters
|
|
568
|
+
----------
|
|
569
|
+
low
|
|
570
|
+
start of the template
|
|
571
|
+
high
|
|
572
|
+
end of the template
|
|
573
|
+
step
|
|
574
|
+
time-step, this should divide high-low
|
|
575
|
+
mean_aoe
|
|
576
|
+
The mean AoE value for this detector (to normalise current pulses).
|
|
577
|
+
**kwargs
|
|
578
|
+
Other keyword arguments passed to :func:`_current_pulse_model`.
|
|
579
|
+
|
|
580
|
+
Returns
|
|
581
|
+
-------
|
|
582
|
+
tuple of the (template,times)
|
|
583
|
+
"""
|
|
584
|
+
if int((high - low) / step) != (high - low) / step:
|
|
585
|
+
msg = "Time template is not a multiple of the time-step."
|
|
586
|
+
raise ValueError(msg)
|
|
587
|
+
|
|
588
|
+
x = np.linspace(low, high, int((high - low) / step) + 1)
|
|
589
|
+
template = _current_pulse_model(x, **kwargs)
|
|
590
|
+
template /= np.max(template)
|
|
591
|
+
template *= mean_aoe
|
|
592
|
+
|
|
593
|
+
return template, x
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
@numba.njit(cache=True)
|
|
597
|
+
def _get_waveform_maximum_impl(
|
|
598
|
+
t: ArrayLike,
|
|
599
|
+
e: ArrayLike,
|
|
600
|
+
dist: ArrayLike,
|
|
601
|
+
template: ArrayLike,
|
|
602
|
+
templates_surface: ArrayLike,
|
|
603
|
+
activeness_surface: ArrayLike,
|
|
604
|
+
tmin: float,
|
|
605
|
+
tmax: float,
|
|
606
|
+
start: float,
|
|
607
|
+
fccd: float,
|
|
608
|
+
n: int,
|
|
609
|
+
time_step: int,
|
|
610
|
+
surface_step_in_um: float,
|
|
611
|
+
include_surface_effects: bool,
|
|
612
|
+
):
|
|
613
|
+
"""Basic implementation to get the maximum of the waveform.
|
|
614
|
+
|
|
615
|
+
Parameters
|
|
616
|
+
----------
|
|
617
|
+
t
|
|
618
|
+
drift time for each step.
|
|
619
|
+
e
|
|
620
|
+
energy for each step.
|
|
621
|
+
dist
|
|
622
|
+
distance to surface for each step.
|
|
623
|
+
"""
|
|
624
|
+
max_a = 0
|
|
625
|
+
max_t = 0
|
|
626
|
+
energy = np.sum(e)
|
|
627
|
+
|
|
628
|
+
for j in range(0, n, time_step):
|
|
629
|
+
time = start + j
|
|
630
|
+
|
|
631
|
+
# skip anything not in the range tmin to tmax (for surface affects this can be later)
|
|
632
|
+
has_surface_hit = include_surface_effects
|
|
633
|
+
|
|
634
|
+
if time < tmin or (time > (tmax + time_step)):
|
|
635
|
+
continue
|
|
636
|
+
|
|
637
|
+
if not has_surface_hit:
|
|
638
|
+
val_tmp = _get_waveform_value(j, e, t, template, start=start, dt=1.0)
|
|
639
|
+
else:
|
|
640
|
+
val_tmp, energy = _get_waveform_value_surface(
|
|
641
|
+
j,
|
|
642
|
+
e,
|
|
643
|
+
t,
|
|
644
|
+
dist,
|
|
645
|
+
template,
|
|
646
|
+
templates_surface.T,
|
|
647
|
+
activeness_surface,
|
|
648
|
+
distance_step_in_um=surface_step_in_um,
|
|
649
|
+
fccd=fccd,
|
|
650
|
+
start=start,
|
|
651
|
+
dt=1.0,
|
|
652
|
+
)
|
|
653
|
+
|
|
654
|
+
if val_tmp > max_a:
|
|
655
|
+
max_t = time
|
|
656
|
+
max_a = val_tmp
|
|
657
|
+
|
|
658
|
+
return max_t, max_a, energy
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
@numba.njit(cache=True)
|
|
662
|
+
def _estimate_current_impl(
|
|
663
|
+
edep: ak.Array,
|
|
664
|
+
dt: ak.Array,
|
|
665
|
+
dist_to_nplus: ak.Array,
|
|
666
|
+
template: np.array,
|
|
667
|
+
times: np.array,
|
|
668
|
+
include_surface_effects: bool,
|
|
669
|
+
fccd: float,
|
|
670
|
+
templates_surface: np.array,
|
|
671
|
+
activeness_surface: np.array,
|
|
672
|
+
surface_step_in_um: float,
|
|
673
|
+
) -> tuple[NDArray, NDArray, NDArray]:
|
|
674
|
+
"""Estimate the maximum current that would be measured in the HPGe detector.
|
|
675
|
+
|
|
676
|
+
This is based on extracting a waveform with :func:`get_current_waveform` and finding the maxima of it.
|
|
677
|
+
|
|
678
|
+
Parameters
|
|
679
|
+
----------
|
|
680
|
+
edep
|
|
681
|
+
Array of energies for each step.
|
|
682
|
+
dt
|
|
683
|
+
Array of drift times for each step.
|
|
684
|
+
dist_to_nplus
|
|
685
|
+
Array of distance to nplus contact for each step (can be `None`, in which case no surface effects are included.)
|
|
686
|
+
template
|
|
687
|
+
array of the bulk pulse template
|
|
688
|
+
times
|
|
689
|
+
time-stamps for the bulk pulse template
|
|
690
|
+
"""
|
|
691
|
+
A = np.zeros(len(dt))
|
|
692
|
+
maximum_t = np.zeros(len(dt))
|
|
693
|
+
energy = np.zeros(len(dt))
|
|
694
|
+
|
|
695
|
+
time_step = 1
|
|
696
|
+
n = len(template)
|
|
697
|
+
start = times[0]
|
|
698
|
+
|
|
699
|
+
if include_surface_effects:
|
|
700
|
+
offsets = times[np.argmax(templates_surface, axis=0)]
|
|
701
|
+
|
|
702
|
+
# make the convolved surface library
|
|
703
|
+
if include_surface_effects and np.diff(times)[0] != 1.0:
|
|
704
|
+
msg = "The surface convolution requires a template with 1 ns binning"
|
|
705
|
+
raise ValueError(msg)
|
|
706
|
+
|
|
707
|
+
for i in range(len(dt)):
|
|
708
|
+
t = np.asarray(dt[i])
|
|
709
|
+
e = np.asarray(edep[i])
|
|
710
|
+
dist = np.asarray(dist_to_nplus[i])
|
|
711
|
+
|
|
712
|
+
# get the expected maximum
|
|
713
|
+
tmax = float(np.max(t))
|
|
714
|
+
tmin = float(np.min(t))
|
|
715
|
+
|
|
716
|
+
# correct the maximum expected time for surface sims
|
|
717
|
+
if include_surface_effects:
|
|
718
|
+
ncols = templates_surface.shape[1]
|
|
719
|
+
|
|
720
|
+
for j, d in enumerate(dist):
|
|
721
|
+
dtmp = int(d / surface_step_in_um)
|
|
722
|
+
|
|
723
|
+
# Use branchless selection
|
|
724
|
+
use_offset = dtmp <= ncols
|
|
725
|
+
offset_val = offsets[dtmp] if use_offset else 0.0
|
|
726
|
+
time_tmp = t[j] + offset_val * use_offset
|
|
727
|
+
|
|
728
|
+
tmax = max(tmax, time_tmp)
|
|
729
|
+
|
|
730
|
+
for time_step in [20, 1]:
|
|
731
|
+
if time_step == 1:
|
|
732
|
+
tmin = int(maximum_t[i] - 50)
|
|
733
|
+
tmax = int(maximum_t[i] + 50)
|
|
734
|
+
|
|
735
|
+
# get the value
|
|
736
|
+
maximum_t[i], A[i], energy[i] = _get_waveform_maximum_impl(
|
|
737
|
+
t,
|
|
738
|
+
e,
|
|
739
|
+
dist,
|
|
740
|
+
template,
|
|
741
|
+
templates_surface,
|
|
742
|
+
activeness_surface,
|
|
743
|
+
tmin=tmin,
|
|
744
|
+
tmax=tmax,
|
|
745
|
+
start=start,
|
|
746
|
+
fccd=fccd,
|
|
747
|
+
n=n,
|
|
748
|
+
time_step=time_step,
|
|
749
|
+
surface_step_in_um=surface_step_in_um,
|
|
750
|
+
include_surface_effects=include_surface_effects,
|
|
751
|
+
)
|
|
752
|
+
|
|
753
|
+
return A, maximum_t, energy
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
def maximum_current(
|
|
757
|
+
edep: ArrayLike,
|
|
758
|
+
drift_time: ArrayLike,
|
|
759
|
+
dist_to_nplus: ArrayLike | None = None,
|
|
760
|
+
*,
|
|
761
|
+
template: np.array,
|
|
762
|
+
times: np.array,
|
|
763
|
+
fccd_in_um: float = 0,
|
|
764
|
+
templates_surface: ArrayLike | None = None,
|
|
765
|
+
activeness_surface: ArrayLike | None = None,
|
|
766
|
+
surface_step_in_um: float = 10,
|
|
767
|
+
return_mode: str = "current",
|
|
768
|
+
) -> Array:
|
|
769
|
+
"""Estimate the maximum current in the HPGe detector based on :func:`_estimate_current_impl`.
|
|
770
|
+
|
|
771
|
+
Parameters
|
|
772
|
+
----------
|
|
773
|
+
edep
|
|
774
|
+
Array of energies for each step.
|
|
775
|
+
drift_time
|
|
776
|
+
Array of drift times for each step.
|
|
777
|
+
dist_to_nplus
|
|
778
|
+
Distance to n-plus electrode, only needed if surface heuristics are enabled.
|
|
779
|
+
template
|
|
780
|
+
array of the bulk pulse template
|
|
781
|
+
times
|
|
782
|
+
time-stamps for the bulk pulse template
|
|
783
|
+
fccd
|
|
784
|
+
Value of the full-charge-collection depth, if `None` no surface corrections are performed.
|
|
785
|
+
surface_library
|
|
786
|
+
2D array (distance, time) of the rate of charge arriving at the p-n junction. Each row
|
|
787
|
+
should be an array of length 10000 giving the charge arriving at the p-n junction for each timestep
|
|
788
|
+
(in ns). This is produced by :func:`.hpge.surface.get_surface_response` or other libraries.
|
|
789
|
+
surface_step_in_um
|
|
790
|
+
Distance step for the surface library.
|
|
791
|
+
return_mode
|
|
792
|
+
either current, energy or max_time
|
|
793
|
+
|
|
794
|
+
Returns
|
|
795
|
+
-------
|
|
796
|
+
An Array of the maximum current/ time / energy for each hit.
|
|
797
|
+
"""
|
|
798
|
+
# extract LGDO data and units
|
|
799
|
+
|
|
800
|
+
drift_time, _ = units.unwrap_lgdo(drift_time)
|
|
801
|
+
edep, _ = units.unwrap_lgdo(edep)
|
|
802
|
+
dist_to_nplus, _ = units.unwrap_lgdo(dist_to_nplus)
|
|
803
|
+
|
|
804
|
+
include_surface_effects = False
|
|
805
|
+
|
|
806
|
+
if templates_surface is not None:
|
|
807
|
+
if dist_to_nplus is None:
|
|
808
|
+
msg = "Surface effects requested but distance not provided"
|
|
809
|
+
raise ValueError(msg)
|
|
810
|
+
|
|
811
|
+
include_surface_effects = True
|
|
812
|
+
else:
|
|
813
|
+
# convert types to keep numba happy
|
|
814
|
+
templates_surface = np.zeros((1, len(template)))
|
|
815
|
+
dist_to_nplus = ak.full_like(edep, np.nan)
|
|
816
|
+
|
|
817
|
+
# convert types for numba
|
|
818
|
+
if activeness_surface is None:
|
|
819
|
+
activeness_surface = np.zeros(len(template))
|
|
820
|
+
|
|
821
|
+
if not ak.all(ak.num(edep, axis=-1) == ak.num(drift_time, axis=-1)):
|
|
822
|
+
msg = "edep and drift time must have the same shape"
|
|
823
|
+
raise ValueError(msg)
|
|
824
|
+
|
|
825
|
+
curr, time, energy = _estimate_current_impl(
|
|
826
|
+
ak.values_astype(ak.Array(edep), np.float64),
|
|
827
|
+
ak.values_astype(ak.Array(drift_time), np.float64),
|
|
828
|
+
ak.values_astype(ak.Array(dist_to_nplus), np.float64),
|
|
829
|
+
template=template,
|
|
830
|
+
times=times,
|
|
831
|
+
fccd=fccd_in_um,
|
|
832
|
+
include_surface_effects=include_surface_effects,
|
|
833
|
+
templates_surface=templates_surface,
|
|
834
|
+
activeness_surface=activeness_surface,
|
|
835
|
+
surface_step_in_um=surface_step_in_um,
|
|
836
|
+
)
|
|
837
|
+
|
|
838
|
+
# return
|
|
839
|
+
if return_mode == "max_time":
|
|
840
|
+
return Array(time)
|
|
841
|
+
if return_mode == "current":
|
|
842
|
+
return Array(curr)
|
|
843
|
+
if return_mode == "energy":
|
|
844
|
+
return Array(energy)
|
|
845
|
+
|
|
846
|
+
msg = f"Return mode {return_mode} is not implemented."
|
|
847
|
+
raise ValueError(msg)
|