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/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)