• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

WashU-Astroparticle-Lab / straxion / 17967756706

24 Sep 2025 05:58AM UTC coverage: 57.59% (+0.9%) from 56.703%
17967756706

Pull #47

github

web-flow
Merge ce42fdf8e into 74115fa76
Pull Request #47: Simpler hit finding

3 of 7 new or added lines in 2 files covered. (42.86%)

3 existing lines in 2 files now uncovered.

626 of 1087 relevant lines covered (57.59%)

1.15 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

52.69
/straxion/plugins/hits.py
1
import strax
2✔
2
import numpy as np
2✔
3
import warnings
2✔
4
from straxion.utils import (
2✔
5
    DATA_DTYPE,
6
    INDEX_DTYPE,
7
    SECOND_TO_NANOSECOND,
8
    HIT_WINDOW_LENGTH_LEFT,
9
    HIT_WINDOW_LENGTH_RIGHT,
10
    base_waveform_dtype,
11
)
12

13
export, __all__ = strax.exporter()
2✔
14

15

16
@export
2✔
17
@strax.takes_config(
2✔
18
    strax.Option(
19
        "hit_threshold_dx",
20
        default=None,
21
        track=True,
22
        type=float,
23
        help=(
24
            "Threshold for hit finding in units of dx=df/f0. "
25
            "If None, the hit threshold will be calculated based on the signal statistics."
26
        ),
27
    ),
28
    strax.Option(
29
        "hit_thresholds_sigma",
30
        default=[3.0 for _ in range(41)],
31
        track=True,
32
        type=list,
33
        help=(
34
            "Threshold for hit finding in units of sigma of standard deviation of the noise. "
35
            "If None, the hit threshold will be calculated based on the signal statistics."
36
        ),
37
    ),
38
    strax.Option(
39
        "hit_min_width",
40
        default=0.25e-3,
41
        track=True,
42
        type=float,
43
        help="Minimum width for hit finding in units of seconds.",
44
    ),
45
    strax.Option(
46
        "fs",
47
        default=38_000,
48
        track=True,
49
        type=int,
50
        help="Sampling frequency (assumed the same for all channels) in unit of Hz",
51
    ),
52
)
53
class DxHits(strax.Plugin):
2✔
54
    """Find and characterize hits in dx=df/f0 data.
55

56
    The hit-finding algorithm is based on the kernel convolved signal.
57
    """
58

59
    __version__ = "0.0.1"
2✔
60

61
    # Inherited from straxen. Not optimized outside XENONnT.
62
    rechunk_on_save = False
2✔
63
    compressor = "zstd"
2✔
64
    chunk_target_size_mb = 2000
2✔
65
    rechunk_on_load = True
2✔
66
    chunk_source_size_mb = 100
2✔
67

68
    depends_on = ["records"]
2✔
69
    provides = "hits"
2✔
70
    data_kind = "hits"
2✔
71
    save_when = strax.SaveWhen.ALWAYS
2✔
72

73
    def infer_dtype(self):
2✔
74
        dtype = base_waveform_dtype()
2✔
75
        self.hit_waveform_length = HIT_WINDOW_LENGTH_LEFT + HIT_WINDOW_LENGTH_RIGHT
2✔
76

77
        dtype = base_waveform_dtype()
2✔
78
        dtype.append(
2✔
79
            (
80
                (
81
                    (
82
                        "Width of the hit waveform (length above the hit threshold) "
83
                        "in unit of samples.",
84
                    ),
85
                    "width",
86
                ),
87
                INDEX_DTYPE,
88
            )
89
        )
90
        dtype.append(
2✔
91
            (
92
                (
93
                    (
94
                        "Hit waveform of dx=df/f0 only after baseline corrections, "
95
                        "aligned at the maximum of the dx=df/f0 waveform."
96
                    ),
97
                    "data_dx",
98
                ),
99
                DATA_DTYPE,
100
                self.hit_waveform_length,
101
            )
102
        )
103
        dtype.append(
2✔
104
            (
105
                (
106
                    ("Maximum amplitude of the dx hit waveform",),
107
                    "amplitude",
108
                ),
109
                DATA_DTYPE,
110
            )
111
        )
112
        dtype.append(
2✔
113
            (
114
                (
115
                    "Maximum amplitude of the dx hit waveform further smoothed by moving average",
116
                    "amplitude_moving_average",
117
                ),
118
                DATA_DTYPE,
119
            )
120
        )
121
        dtype.append(
2✔
122
            (
123
                (
124
                    "Maximum amplitude of the dx hit waveform further smoothed by pulse kernel",
125
                    "amplitude_convolved",
126
                ),
127
                DATA_DTYPE,
128
            )
129
        )
130
        dtype.append(
2✔
131
            (
132
                (
133
                    (
134
                        "Record index of the maximum amplitude of the dx hit waveform "
135
                        "further smoothed by pulse kernel."
136
                    ),
137
                    "amplitude_convolved_max_record_i",
138
                ),
139
                INDEX_DTYPE,
140
            )
141
        )
142
        dtype.append(
2✔
143
            (
144
                (
145
                    (
146
                        "Record index of the maximum amplitude of the dx hit waveform "
147
                        "further smoothed by moving average."
148
                    ),
149
                    "amplitude_moving_average_max_record_i",
150
                ),
151
                INDEX_DTYPE,
152
            )
153
        )
154
        dtype.append(
2✔
155
            (
156
                (
157
                    (
158
                        "Hit waveform of dx=df/f0 further smoothed by moving average, "
159
                        "aligned at the maximum of the dx=df/f0 waveform."
160
                    ),
161
                    "data_dx_moving_average",
162
                ),
163
                DATA_DTYPE,
164
                self.hit_waveform_length,
165
            )
166
        )
167
        dtype.append(
2✔
168
            (
169
                (
170
                    "Hit waveform of dx=df/f0 further smoothed by pulse kernel, "
171
                    "aligned at the maximum of the dx=df/f0 waveform.",
172
                    "data_dx_convolved",
173
                ),
174
                DATA_DTYPE,
175
                self.hit_waveform_length,
176
            )
177
        )
178
        dtype.append(
2✔
179
            (
180
                (
181
                    "Hit finding threshold in unit of dx=df/f0 for kernel convolved signal.",
182
                    "hit_threshold",
183
                ),
184
                DATA_DTYPE,
185
            )
186
        )
187

188
        return dtype
2✔
189

190
    def setup(self):
2✔
191
        self.hit_waveform_length = HIT_WINDOW_LENGTH_LEFT + HIT_WINDOW_LENGTH_RIGHT
×
192
        self.hit_window_length_left = HIT_WINDOW_LENGTH_LEFT
×
193
        self.hit_window_length_right = HIT_WINDOW_LENGTH_RIGHT
×
194

195
        self.hit_threshold_dx = self.config["hit_threshold_dx"]
×
196
        self.hit_thresholds_sigma = self.config["hit_thresholds_sigma"]
×
197
        self.hit_min_width_samples = self.config["hit_min_width"] * self.fs
×
UNCOV
198
        self.fs = self.config["fs"]
×
199
        self.dt_exact = 1 / self.fs * SECOND_TO_NANOSECOND
×
200

201
    @staticmethod
2✔
202
    def calculate_hit_threshold(signal, hit_threshold_sigma):
2✔
203
        """Calculate hit threshold based on signal statistics.
204

205
        Args:
206
            signal (np.ndarray): The signal array to analyze.
207
            hit_threshold_sigma (float): Threshold multiplier in units of sigma.
208

209
        Returns:
210
            float: The calculated hit threshold.
211

212
        """
213
        signal_mean = np.mean(signal, axis=1)
×
214
        signal_std = np.std(signal, axis=1)
×
215

216
        # The naive hit threshold is a multiple of the standard deviation of the signal.
217
        hit_threshold = signal_mean + hit_threshold_sigma * signal_std
×
218

UNCOV
219
        return hit_threshold
×
220

221
    def determine_hit_threshold(self, records):
2✔
222
        """Determine the hit threshold based on the provided configuration.
223
        You can either provide hit_threshold_dx or hit_thresholds_sigma.
224
        You cannot provide both.
225
        """
NEW
226
        if self.hit_threshold_dx is None and self.hit_thresholds_sigma is not None:
×
227
            # If hit_thresholds_sigma are single values,
228
            # we need to convert them to arrays.
229
            if isinstance(self.hit_thresholds_sigma, float):
×
230
                self.hit_thresholds_sigma = np.full(
×
231
                    len(records["channel"]), self.hit_thresholds_sigma
232
                )
233
            else:
234
                self.hit_thresholds_sigma = np.array(self.hit_thresholds_sigma)
×
235
            # Calculate hit threshold and find hit candidates
236
            self.hit_threshold_dx = self.calculate_hit_threshold(
×
237
                records["data_dx_convolved"],
238
                self.hit_thresholds_sigma[records["channel"]],
239
            )
NEW
240
        elif self.hit_threshold_dx is not None and self.hit_thresholds_sigma is None:
×
241
            # If hit_threshold_dx is a single value, we need to convert it to an array.
242
            if isinstance(self.hit_threshold_dx, float):
×
243
                self.hit_threshold_dx = np.full(len(records["channel"]), self.hit_threshold_dx)
×
244
            else:
245
                self.hit_threshold_dx = np.array(self.hit_threshold_dx)
×
246
        else:
247
            raise ValueError(
×
248
                "Either hit_threshold_dx or hit_thresholds_sigma "
249
                "must be provided. You cannot provide both."
250
            )
251

252
    @staticmethod
2✔
253
    def find_hit_candidates(signal, hit_threshold, min_pulse_width):
2✔
254
        """Finds potential hit candidates, correctly handling edge cases.
255

256
        Args:
257
            signal: The signal array.
258
            hit_threshold: Threshold value for hit detection.
259
            min_pulse_width: Minimum width required for a valid hit in samples.
260

261
        Returns:
262
            tuple: (hit_start_indices, hit_widths) for valid hits.
263
        """
264
        # 1. Create a boolean array where True means the signal is at or above the threshold
265
        above_threshold = signal >= hit_threshold
×
266

267
        # 2. Pad the array with False at both ends. This is the key to finding
268
        #    hits that start at index 0 or end at the last index.
269
        padded_array = np.concatenate(([False], above_threshold, [False]))
×
270

271
        # 3. Find the changes from False to True (starts) and True to False (ends)
272
        #    A change from 0 to 1 is a start (diff = 1).
273
        #    A change from 1 to 0 is an end (diff = -1).
274
        diffs = np.diff(padded_array.astype(int))
×
275

276
        hit_start_indices = np.where(diffs == 1)[0]
×
277
        hit_end_indices = np.where(diffs == -1)[0]
×
278

279
        # If no hits are found, return empty lists
280
        if len(hit_start_indices) == 0:
×
281
            return np.array([]), np.array([])
×
282

283
        # 4. Calculate the width of each potential hit
284
        hit_widths = hit_end_indices - hit_start_indices
×
285

286
        # 5. Filter the hits by the minimum pulse width
287
        valid_mask = hit_widths >= min_pulse_width
×
288

289
        return hit_start_indices[valid_mask], hit_widths[valid_mask]
×
290

291
    def compute(self, records):
2✔
292
        """Process records to find and characterize hits.
293

294
        Args:
295
            records: Array of records containing signal data.
296

297
        Returns:
298
            np.ndarray: Array of hits with waveform data and characteristics.
299
        """
300
        self.determine_hit_threshold(records)
×
301

302
        results = []
×
303

304
        for record in records:
×
305
            hits = self._process_single_record(record)
×
306
            if hits is not None and len(hits) > 0:
×
307
                results.append(hits)
×
308

309
        if not results:
×
310
            return np.zeros(0, dtype=self.infer_dtype())
×
311

312
        results = np.concatenate(results)
×
313

314
        # Order hits first by time
315
        results = results[np.argsort(results["time"])]
×
316

317
        # Truncate hits endtime to record endtime
318
        results["endtime"] = np.minimum(results["endtime"], records["endtime"][0])
×
319

320
        return results
×
321

322
    def _process_single_record(self, record):
2✔
323
        """Process a single record to find hits.
324

325
        Args:
326
            record: Single record containing signal data.
327

328
        Returns:
329
            np.ndarray or None: Array of hits found in the record, or None if no hits.
330
        """
331
        ch = int(record["channel"])
×
332
        hit_start_i, hit_widths = self.find_hit_candidates(
×
333
            record["data_dx_convolved"], self.hit_threshold_dx[ch], self.hit_min_width_samples
334
        )
335

336
        if len(hit_start_i) == 0:
×
337
            return None
×
338

339
        hits = np.zeros(len(hit_start_i), dtype=self.infer_dtype())
×
340
        hits["width"] = hit_widths
×
341
        hits["channel"] = record["channel"]
×
342
        hits["dt"] = self.dt_exact  # Will be converted to int when saving
×
343
        hits["hit_threshold"] = self.hit_threshold_dx[hits["channel"]]
×
344

345
        for i, start_i in enumerate(hit_start_i):
×
346
            self._process_hit(
×
347
                hits[i],
348
                record["data_dx_convolved"],
349
                record["data_dx_moving_average"],
350
                record["data_dx"],
351
                start_i,
352
                hit_widths[i],
353
                record["time"],
354
                previous_hit_end_i=hit_start_i[i - 1] + hit_widths[i - 1] if i > 0 else None,
355
                next_hit_start_i=hit_start_i[i + 1] if i < len(hit_start_i) - 1 else None,
356
            )
357

358
        return hits
×
359

360
    def _process_hit(
2✔
361
        self,
362
        hit,
363
        signal_convolved,
364
        signal_ma,
365
        signal_raw,
366
        start_i,
367
        width,
368
        start_time,
369
        previous_hit_end_i=None,
370
        next_hit_start_i=None,
371
    ):
372
        """Process a single hit candidate.
373

374
        Args:
375
            hit: Hit array to populate
376
            signal_convolved: Convolved signal array
377
            signal_ma: Moving average signal array
378
            signal_raw: Raw signal array
379
            start_i: Start index of the hit
380
            width: Width of the hit in samples
381
            start_time: Start time of the record
382
            previous_hit_end_i: End index of the previous hit
383
            next_hit_start_i: Start index of the next hit
384
        """
385
        # Extract hit waveform
386
        above_threshold = signal_convolved[start_i : start_i + width]
×
387

388
        # Find maximum amplitude and its position
389
        max_i = np.argmax(above_threshold)
×
390

391
        # Align waveform around maximum
392
        aligned_i = start_i + max_i
×
393

394
        # Handle left boundary, considering previous hit if it exists
395
        left_boundary = aligned_i - self.hit_window_length_left
×
396
        if previous_hit_end_i is not None:
×
397
            left_i = max(0, left_boundary, previous_hit_end_i)
×
398
        else:
399
            left_i = max(0, left_boundary)
×
400

401
        # Handle right boundary, considering next hit if it exists
402
        right_boundary = aligned_i + self.hit_window_length_right
×
403
        if next_hit_start_i is not None:
×
404
            right_i = min(len(signal_convolved), right_boundary, next_hit_start_i)
×
405
        else:
406
            right_i = min(len(signal_convolved), right_boundary)
×
407

408
        # Calculate valid sample ranges
409
        n_right_valid_samples = min(right_i - aligned_i, self.hit_window_length_right)
×
410
        n_left_valid_samples = min(aligned_i - left_i, self.hit_window_length_left)
×
411

412
        # Calculate target indices in the hit waveform array
413
        target_start = self.hit_window_length_left - n_left_valid_samples
×
414
        target_end = self.hit_window_length_left + n_right_valid_samples
×
415

416
        # Extract waveform
417
        hit["data_dx_convolved"][target_start:target_end] = signal_convolved[left_i:right_i]
×
418
        hit["data_dx_moving_average"][target_start:target_end] = signal_ma[left_i:right_i]
×
419
        hit["data_dx"][target_start:target_end] = signal_raw[left_i:right_i]
×
420
        hit["amplitude_convolved"] = np.max(signal_convolved[left_i:right_i])
×
421
        hit["amplitude_moving_average"] = np.max(signal_ma[left_i:right_i])
×
422
        hit["amplitude"] = np.max(signal_raw[left_i:right_i])
×
423
        hit["length"] = right_i - left_i
×
424
        hit["amplitude_convolved_max_record_i"] = np.int32(
×
425
            np.argmax(signal_convolved[left_i:right_i]) + left_i
426
        )
427
        hit["amplitude_moving_average_max_record_i"] = np.int32(
×
428
            np.argmax(signal_ma[left_i:right_i]) + left_i
429
        )
430

431
        # Calculate time and endtime
432
        hit["time"] = np.int64(start_time + np.int64(left_i * self.dt_exact))
×
433
        hit["endtime"] = np.int64(start_time + np.int64(right_i * self.dt_exact))
×
434

435

436
@export
2✔
437
@strax.takes_config(
2✔
438
    strax.Option(
439
        "record_length",
440
        default=1_900_000,
441
        track=False,  # Not tracking record length, but we will have to check if it is as promised
442
        type=int,
443
        help=(
444
            "Number of samples in each dataset."
445
            "We assumed that each sample is equally spaced in time, with interval 1/fs."
446
            "It should not go beyond a billion so that numpy can still handle."
447
        ),
448
    ),
449
    strax.Option(
450
        "fs",
451
        default=38_000,
452
        track=True,
453
        type=int,
454
        help="Sampling frequency (assumed the same for all channels) in unit of Hz",
455
    ),
456
    strax.Option(
457
        "hit_thresholds_sigma",
458
        default=[3.0 for _ in range(41)],
459
        track=True,
460
        type=list,
461
        help="Threshold for hit finding in units of sigma of standard deviation of the noise.",
462
    ),
463
    strax.Option(
464
        "noisy_channel_signal_std_multipliers",
465
        default=[2.0 for _ in range(41)],
466
        track=True,
467
        type=list,
468
        help=(
469
            "If the signal standard deviation above this threshold times of signal absolute "
470
            "mean, the signal is considered noisy and the hit threshold is increased."
471
        ),
472
    ),
473
    strax.Option(
474
        "min_pulse_widths",
475
        default=[20 for _ in range(41)],
476
        track=True,
477
        type=list,
478
        help=(
479
            "Minimum pulse width in unit of samples. If the pulse width is below this "
480
            "threshold, the hit is not considered a new hit."
481
        ),
482
    ),
483
    strax.Option(
484
        "hit_convolved_inspection_window_length",
485
        default=60,
486
        track=True,
487
        type=int,
488
        help=(
489
            "Length of the convolved hit inspection window (to find maximum and minimum) "
490
            "in unit of samples."
491
        ),
492
    ),
493
    strax.Option(
494
        "hit_extended_inspection_window_length",
495
        default=100,
496
        track=True,
497
        type=int,
498
        help=(
499
            "Length of the extended convolved hit inspection window (to find maximum and minimum) "
500
            "in unit of samples."
501
        ),
502
    ),
503
    strax.Option(
504
        "hit_moving_average_inspection_window_length",
505
        default=40,
506
        track=True,
507
        type=int,
508
        help=(
509
            "Length of the moving averaged hit inspection window (to find maximum and minimum) "
510
            "in unit of samples."
511
        ),
512
    ),
513
)
514
class Hits(strax.Plugin):
2✔
515
    """Find and characterize hits in processed phase angle data.
516

517
    This plugin identifies significant signal excursions (hits) in processed phase angle
518
    data and extracts their characteristics including amplitude, timing, and waveform
519
    data. The hit-finding algorithm uses adaptive thresholds based on signal statistics
520
    and applies various filtering criteria to distinguish real hits from noise.
521

522
    Processing workflow:
523
    1. Calculate adaptive hit thresholds based on signal statistics for each channel.
524
    2. Identify hit candidates using threshold crossing and minimum width criteria.
525
    3. Calculate hit characteristics (amplitude, timing, alignment point).
526
    4. Extract and align hit waveforms for further analysis.
527

528
    Provides:
529
    - hits: Characterized hits with waveform data and timing information.
530

531
    """
532

533
    __version__ = "0.0.0"
2✔
534

535
    # Inherited from straxen. Not optimized outside XENONnT.
536
    rechunk_on_save = False
2✔
537
    compressor = "zstd"
2✔
538
    chunk_target_size_mb = 2000
2✔
539
    rechunk_on_load = True
2✔
540
    chunk_source_size_mb = 100
2✔
541

542
    depends_on = ["records"]
2✔
543
    provides = "hits"
2✔
544
    data_kind = "hits"
2✔
545
    save_when = strax.SaveWhen.ALWAYS
2✔
546

547
    def setup(self):
2✔
548
        self.hit_waveform_length = HIT_WINDOW_LENGTH_LEFT + HIT_WINDOW_LENGTH_RIGHT
2✔
549
        self.hit_window_length_left = HIT_WINDOW_LENGTH_LEFT
2✔
550
        self.hit_window_length_right = HIT_WINDOW_LENGTH_RIGHT
2✔
551

552
        self.hit_thresholds_sigma = np.array(self.config["hit_thresholds_sigma"])
2✔
553
        self.noisy_channel_signal_std_multipliers = np.array(
2✔
554
            self.config["noisy_channel_signal_std_multipliers"]
555
        )
556
        self.hit_ma_inspection_window_length = self.config[
2✔
557
            "hit_moving_average_inspection_window_length"
558
        ]
559
        self.hit_convolved_inspection_window_length = self.config[
2✔
560
            "hit_convolved_inspection_window_length"
561
        ]
562
        self.hit_extended_inspection_window_length = self.config[
2✔
563
            "hit_extended_inspection_window_length"
564
        ]
565

566
        self.record_length = self.config["record_length"]
2✔
567
        self.dt_exact = 1 / self.config["fs"] * SECOND_TO_NANOSECOND
2✔
568

569
        self._check_hit_parameters()
2✔
570

571
    def _check_hit_parameters(self):
2✔
572
        """Check for potentially problematic parameters and issue warnings."""
573
        if self.hit_ma_inspection_window_length > self.hit_waveform_length:
2✔
574
            warnings.warn(
×
575
                "The hit-waveform recording window might be too short to save enough information: "
576
                f"hit_ma_inspection_window_length={self.hit_ma_inspection_window_length} "
577
                f"is larger than hit_waveform_length={self.hit_waveform_length}."
578
            )
579
        if self.hit_convolved_inspection_window_length > self.hit_waveform_length:
2✔
580
            warnings.warn(
×
581
                "The hit-waveform recording window might be too short to save enough information: "
582
                "hit_convolved_inspection_window_length="
583
                f"{self.hit_convolved_inspection_window_length} "
584
                f"is larger than hit_waveform_length={self.hit_waveform_length}."
585
            )
586
        if self.hit_extended_inspection_window_length > self.hit_waveform_length:
2✔
587
            warnings.warn(
×
588
                "The hit-waveform recording window might be too short to save enough information: "
589
                "hit_extended_inspection_window_length="
590
                f"{self.hit_extended_inspection_window_length} "
591
                f"is larger than hit_waveform_length={self.hit_waveform_length}."
592
            )
593

594
    def infer_dtype(self):
2✔
595
        self.hit_waveform_length = HIT_WINDOW_LENGTH_LEFT + HIT_WINDOW_LENGTH_RIGHT
2✔
596

597
        dtype = base_waveform_dtype()
2✔
598
        dtype.append(
2✔
599
            (
600
                (
601
                    (
602
                        "Width of the hit waveform (length above the hit threshold) "
603
                        "in unit of samples.",
604
                    ),
605
                    "width",
606
                ),
607
                INDEX_DTYPE,
608
            )
609
        )
610
        dtype.append(
2✔
611
            (
612
                (
613
                    (
614
                        "Hit waveform of phase angle (theta) only after baseline corrections, "
615
                        "aligned at the maximum of the moving averaged waveform."
616
                    ),
617
                    "data_theta",
618
                ),
619
                DATA_DTYPE,
620
                self.hit_waveform_length,
621
            )
622
        )
623
        dtype.append(
2✔
624
            (
625
                (
626
                    (
627
                        "Hit waveform of phase angle (theta) further smoothed by moving average, "
628
                        "aligned at the maximum of the moving averaged waveform."
629
                    ),
630
                    "data_theta_moving_average",
631
                ),
632
                DATA_DTYPE,
633
                self.hit_waveform_length,
634
            )
635
        )
636
        dtype.append(
2✔
637
            (
638
                (
639
                    (
640
                        "Hit waveform of phase angle (theta) further smoothed by pulse kernel, "
641
                        "aligned at the maximum of the moving averaged waveform."
642
                    ),
643
                    "data_theta_convolved",
644
                ),
645
                DATA_DTYPE,
646
                self.hit_waveform_length,
647
            )
648
        )
649
        dtype.append(
2✔
650
            (
651
                (
652
                    "Hit finding threshold determined by signal statistics in unit of rad.",
653
                    "hit_threshold",
654
                ),
655
                DATA_DTYPE,
656
            )
657
        )
658
        dtype.append(
2✔
659
            (
660
                ("Index of alignment point (the maximum) in the records", "aligned_at_records_i"),
661
                INDEX_DTYPE,
662
            )
663
        )
664
        dtype.append(
2✔
665
            (
666
                (
667
                    (
668
                        "Maximum amplitude of the convolved hit waveform (within the "
669
                        "hit window) in unit of rad.",
670
                    ),
671
                    "amplitude_convolved_max",
672
                ),
673
                DATA_DTYPE,
674
            )
675
        )
676
        dtype.append(
2✔
677
            (
678
                (
679
                    (
680
                        "Minimum amplitude of the convolved hit waveform (within the "
681
                        "hit window) in unit of rad.",
682
                    ),
683
                    "amplitude_convolved_min",
684
                ),
685
                DATA_DTYPE,
686
            )
687
        )
688
        dtype.append(
2✔
689
            (
690
                (
691
                    (
692
                        "Maximum amplitude of the convolved hit waveform (within the "
693
                        "extended hit window) in unit of rad.",
694
                    ),
695
                    "amplitude_convolved_max_ext",
696
                ),
697
                DATA_DTYPE,
698
            )
699
        )
700
        dtype.append(
2✔
701
            (
702
                (
703
                    (
704
                        "Minimum amplitude of the convolved hit waveform (within the "
705
                        "extended hit window) in unit of rad.",
706
                    ),
707
                    "amplitude_convolved_min_ext",
708
                ),
709
                DATA_DTYPE,
710
            )
711
        )
712
        dtype.append(
2✔
713
            (
714
                (
715
                    (
716
                        "Maximum amplitude of the moving averaged hit waveform (within the "
717
                        "hit window) in unit of rad.",
718
                    ),
719
                    "amplitude_ma_max",
720
                ),
721
                DATA_DTYPE,
722
            )
723
        )
724
        dtype.append(
2✔
725
            (
726
                (
727
                    (
728
                        "Minimum amplitude of the moving averaged hit waveform (within the "
729
                        "hit window) in unit of rad.",
730
                    ),
731
                    "amplitude_ma_min",
732
                ),
733
                DATA_DTYPE,
734
            )
735
        )
736
        dtype.append(
2✔
737
            (
738
                (
739
                    (
740
                        "Maximum amplitude of the moving averaged hit waveform (within the "
741
                        "extended hit window) in unit of rad.",
742
                    ),
743
                    "amplitude_ma_max_ext",
744
                ),
745
                DATA_DTYPE,
746
            )
747
        )
748
        dtype.append(
2✔
749
            (
750
                (
751
                    (
752
                        "Minimum amplitude of the moving averaged hit waveform (within the "
753
                        "extended hit window) in unit of rad.",
754
                    ),
755
                    "amplitude_ma_min_ext",
756
                ),
757
                DATA_DTYPE,
758
            )
759
        )
760
        dtype.append(
2✔
761
            (
762
                (
763
                    "Mean of the convolved signal in the record.",
764
                    "record_convolved_mean",
765
                ),
766
                DATA_DTYPE,
767
            )
768
        )
769
        dtype.append(
2✔
770
            (
771
                (
772
                    "Standard deviation of the convolved signal in the record.",
773
                    "record_convolved_std",
774
                ),
775
                DATA_DTYPE,
776
            )
777
        )
778
        dtype.append(
2✔
779
            (
780
                (
781
                    "Mean of the moving averaged signal in the record.",
782
                    "record_ma_mean",
783
                ),
784
                DATA_DTYPE,
785
            )
786
        )
787
        dtype.append(
2✔
788
            (
789
                (
790
                    "Standard deviation of the moving averaged signal in the record.",
791
                    "record_ma_std",
792
                ),
793
                DATA_DTYPE,
794
            )
795
        )
796
        dtype.append(
2✔
797
            (
798
                (
799
                    "Mean of the raw signal in the record.",
800
                    "record_raw_mean",
801
                ),
802
                DATA_DTYPE,
803
            )
804
        )
805
        dtype.append(
2✔
806
            (
807
                (
808
                    "Standard deviation of the raw signal in the record.",
809
                    "record_raw_std",
810
                ),
811
                DATA_DTYPE,
812
            )
813
        )
814

815
        return dtype
2✔
816

817
    @staticmethod
2✔
818
    def calculate_hit_threshold(signal, hit_threshold_sigma, noisy_channel_signal_std_multiplier):
2✔
819
        """Calculate hit threshold based on signal statistics.
820

821
        Args:
822
            signal (np.ndarray): The signal array to analyze.
823
            hit_threshold_sigma (float): Threshold multiplier in units of sigma.
824
            noisy_channel_signal_std_multiplier (float): Multiplier to detect noisy channels.
825

826
        Returns:
827
            float: The calculated hit threshold.
828

829
        """
830
        signal_mean = np.mean(signal)
2✔
831
        signal_abs_mean = np.mean(np.abs(signal))
2✔
832
        signal_std = np.std(signal)
2✔
833

834
        # The naive hit threshold is a multiple of the standard deviation of the signal.
835
        hit_threshold = signal_mean + hit_threshold_sigma * signal_std
2✔
836

837
        # If the signal is noisy, the baseline might be too high.
838
        if signal_std > noisy_channel_signal_std_multiplier * signal_abs_mean:
2✔
839
            # We will use the quiet part of the signal to redefine a lowered hit threshold.
840
            quiet_mask = signal < hit_threshold
×
841
            hit_threshold = signal_mean + hit_threshold_sigma * np.std(signal[quiet_mask])
×
842

843
        return hit_threshold
2✔
844

845
    def compute(self, records):
2✔
846
        """Process records to find and characterize hits.
847

848
        Args:
849
            records: Array of processed records containing signal data.
850

851
        Returns:
852
            np.ndarray: Array of hits with waveform data and characteristics.
853

854
        """
855
        results = []
2✔
856

857
        for r in records:
2✔
858
            hits = self._process_single_record(r)
2✔
859
            if hits is not None and len(hits) > 0:
2✔
860
                results.append(hits)
×
861

862
        # Sort hits by time.
863
        if not results:
2✔
864
            return np.zeros(0, dtype=self.infer_dtype())
2✔
865

866
        results = np.concatenate(results)
×
867
        results = results[np.argsort(results["time"])]
×
868

869
        return results
×
870

871
    def _process_single_record(self, record):
2✔
872
        """Process a single record to find hits.
873

874
        Args:
875
            record: Single record containing signal data.
876

877
        Returns:
878
            np.ndarray or None: Array of hits found in the record, or None if no hits.
879

880
        """
881
        ch = int(record["channel"])
2✔
882
        signal = record["data_theta_convolved"]
2✔
883
        signal_ma = record["data_theta_moving_average"]
2✔
884
        signal_raw = record["data_theta"]
2✔
885
        min_pulse_width = self.config["min_pulse_widths"][ch]
2✔
886

887
        # Calculate hit threshold and find hit candidates
888
        hit_threshold = self.calculate_hit_threshold(
2✔
889
            signal, self.hit_thresholds_sigma[ch], self.noisy_channel_signal_std_multipliers[ch]
890
        )
891

892
        hit_candidates = self._find_hit_candidates(signal, hit_threshold, min_pulse_width)
2✔
893
        if len(hit_candidates) == 0:
2✔
894
            return None
×
895

896
        # Process each hit candidate
897
        hits = self._process_hit_candidates(
2✔
898
            hit_candidates, record, signal, signal_ma, signal_raw, hit_threshold, ch
899
        )
900

901
        hits["record_raw_mean"] = np.mean(signal_raw)
2✔
902
        hits["record_raw_std"] = np.std(signal_raw)
2✔
903
        hits["record_convolved_std"] = np.std(signal)
2✔
904
        hits["record_convolved_mean"] = np.mean(signal)
2✔
905
        hits["record_ma_mean"] = np.mean(signal_ma)
2✔
906
        hits["record_ma_std"] = np.std(signal_ma)
2✔
907

908
        return hits
2✔
909

910
    def _find_hit_candidates(self, signal, hit_threshold, min_pulse_width):
2✔
911
        """Find potential hit candidates based on threshold crossing.
912

913
        Args:
914
            signal: The convolved signal array.
915
            hit_threshold: Threshold value for hit detection.
916
            min_pulse_width: Minimum width required for a valid hit.
917

918
        Returns:
919
            tuple: (hit_start_indices, hit_widths) for valid hits.
920

921
        """
922
        below_threshold_indices = np.where(signal < hit_threshold)[0]
2✔
923
        if len(below_threshold_indices) == 0:
2✔
924
            return [], []
2✔
925

926
        # Find the start of the hits
927
        hits_width = np.diff(below_threshold_indices, prepend=1)
2✔
928

929
        # Filter by minimum pulse width
930
        valid_mask = hits_width >= min_pulse_width
2✔
931
        hit_end_indices = below_threshold_indices[valid_mask]
2✔
932
        hit_widths = hits_width[valid_mask]
2✔
933
        hit_start_indices = hit_end_indices - hit_widths
2✔
934

935
        return hit_start_indices, hit_widths
2✔
936

937
    def _process_hit_candidates(
2✔
938
        self, hit_candidates, record, signal, signal_ma, signal_raw, hit_threshold, channel
939
    ):
940
        """Process hit candidates to extract hit characteristics and waveforms.
941

942
        Args:
943
            hit_candidates: Tuple of (hit_start_indices, hit_widths).
944
            record: The original record.
945
            signal: The convolved signal array.
946
            signal_ma: The moving average signal array.
947
            signal_raw: The raw signal array.
948
            hit_threshold: The hit threshold value.
949
            channel: The channel number.
950

951
        Returns:
952
            np.ndarray: Array of processed hits.
953

954
        """
955
        hit_start_indices, hit_widths = hit_candidates
2✔
956

957
        hits = np.zeros(len(hit_start_indices), dtype=self.infer_dtype())
2✔
958
        hits["width"] = hit_widths
2✔
959

960
        for i, h_start_i in enumerate(hit_start_indices):
2✔
961
            self._process_single_hit(
×
962
                hits[i], h_start_i, record, signal, signal_ma, signal_raw, hit_threshold, channel
963
            )
964

965
        return hits
2✔
966

967
    def _process_single_hit(
2✔
968
        self, hit, hit_start_i, record, signal, signal_ma, signal_raw, hit_threshold, channel
969
    ):
970
        """Process a single hit to extract its characteristics and waveform.
971

972
        Args:
973
            hit: The hit array element to populate.
974
            hit_start_i: Start index of the hit.
975
            record: The original record.
976
            signal: The convolved signal array.
977
            signal_ma: The moving average signal array.
978
            signal_raw: The raw signal array.
979
            hit_threshold: The hit threshold value.
980
            channel: The channel number.
981

982
        """
983
        # Set basic hit properties
984
        hit["hit_threshold"] = hit_threshold
×
985
        hit["channel"] = channel
×
986
        hit["dt"] = self.dt_exact  # Will be converted to int when saving
×
987

988
        # Calculate amplitude characteristics
989
        self._calculate_hit_amplitudes(hit, hit_start_i, signal, signal_ma)
×
990

991
        # Find alignment point and extract waveforms
992
        aligned_index = self._find_alignment_point(hit_start_i, signal, signal_ma)
×
993
        hit["aligned_at_records_i"] = aligned_index
×
994

995
        # Extract and align waveforms
996
        self._extract_hit_waveforms(hit, aligned_index, record, signal_raw, signal_ma, signal)
×
997

998
    def _calculate_hit_amplitudes(self, hit, hit_start_i, signal, signal_ma):
2✔
999
        """Calculate amplitude characteristics for a hit.
1000

1001
        Args:
1002
            hit: The hit array element to populate.
1003
            hit_start_i: Start index of the hit.
1004
            signal: The convolved signal array.
1005
            signal_ma: The moving average signal array.
1006

1007
        """
1008
        hit_end_i = min(
×
1009
            hit_start_i + self.hit_convolved_inspection_window_length,
1010
            self.record_length,
1011
        )
1012
        hit_extended_end_i = min(
×
1013
            hit_start_i + self.hit_extended_inspection_window_length,
1014
            self.record_length,
1015
        )
1016
        # Find the maximum and minimum of the hit in the inspection windows
1017
        hit_convolved_inspection_waveform = signal[hit_start_i:hit_end_i]
×
1018
        hit_convolved_extended_inspection_waveform = signal[hit_start_i:hit_extended_end_i]
×
1019
        hit_ma_inspection_waveform = signal_ma[hit_start_i:hit_end_i]
×
1020
        hit_ma_extended_inspection_waveform = signal_ma[hit_start_i:hit_extended_end_i]
×
1021

1022
        hit["amplitude_convolved_max"] = np.max(hit_convolved_inspection_waveform)
×
1023
        hit["amplitude_convolved_min"] = np.min(hit_convolved_inspection_waveform)
×
1024
        hit["amplitude_convolved_max_ext"] = np.max(hit_convolved_extended_inspection_waveform)
×
1025
        hit["amplitude_convolved_min_ext"] = np.min(hit_convolved_extended_inspection_waveform)
×
1026
        hit["amplitude_ma_max"] = np.max(hit_ma_inspection_waveform)
×
1027
        hit["amplitude_ma_min"] = np.min(hit_ma_inspection_waveform)
×
1028
        hit["amplitude_ma_max_ext"] = np.max(hit_ma_extended_inspection_waveform)
×
1029
        hit["amplitude_ma_min_ext"] = np.min(hit_ma_extended_inspection_waveform)
×
1030

1031
    def _find_alignment_point(self, hit_start_i, signal, signal_ma):
2✔
1032
        """Find the alignment point for waveform extraction.
1033

1034
        Args:
1035
            hit_start_i: Start index of the hit.
1036
            signal: The convolved signal array.
1037
            signal_ma: The moving average signal array.
1038

1039
        Returns:
1040
            int: Index of the alignment point.
1041

1042
        """
1043
        # Index of kernel-convolved signal in records
1044
        hit_inspection_waveform = signal[
×
1045
            hit_start_i : min(
1046
                hit_start_i + self.hit_convolved_inspection_window_length,
1047
                self.record_length,
1048
            )
1049
        ]
1050
        hit_max_i = np.argmax(hit_inspection_waveform) + hit_start_i
×
1051

1052
        # Align waveforms at the maximum of the moving averaged signal
1053
        # Search the maximum in the moving averaged signal within the inspection window
1054
        search_start = max(hit_max_i - self.hit_ma_inspection_window_length, 0)
×
1055
        search_end = min(hit_max_i + self.hit_ma_inspection_window_length, self.record_length)
×
1056

1057
        argmax_ma_i = np.argmax(signal_ma[search_start:search_end]) + search_start
×
1058

1059
        return argmax_ma_i
×
1060

1061
    def _extract_hit_waveforms(self, hit, aligned_index, record, signal_raw, signal_ma, signal):
2✔
1062
        """Extract and align hit waveforms.
1063

1064
        Args:
1065
            hit: The hit array element to populate.
1066
            aligned_index: Index of the alignment point.
1067
            record: The original record.
1068
            signal_raw: The raw signal array.
1069
            signal_ma: The moving average signal array.
1070
            signal: The convolved signal array.
1071

1072
        """
1073
        # Calculate valid sample ranges
1074
        n_right_valid_samples = min(
×
1075
            self.record_length - aligned_index, self.hit_window_length_right
1076
        )
1077
        n_left_valid_samples = min(aligned_index, self.hit_window_length_left)
×
1078

1079
        # Calculate waveform extraction boundaries
1080
        hit_wf_start_i = max(aligned_index - self.hit_window_length_left, 0)
×
1081
        hit_wf_end_i = min(aligned_index + self.hit_window_length_right, self.record_length)
×
1082

1083
        # Set timing information
1084
        hit["time"] = record["time"] + np.int64(hit_wf_start_i * self.dt_exact)
×
1085
        hit["endtime"] = min(
×
1086
            record["time"] + np.int64(hit_wf_end_i * self.dt_exact), record["endtime"]
1087
        )
1088
        hit["length"] = hit_wf_end_i - hit_wf_start_i
×
1089

1090
        # Calculate target indices in the hit waveform arrays
1091
        target_start = self.hit_window_length_left - n_left_valid_samples
×
1092
        target_end = self.hit_window_length_left + n_right_valid_samples
×
1093

1094
        # Extract waveforms
1095
        hit["data_theta"][target_start:target_end] = signal_raw[hit_wf_start_i:hit_wf_end_i]
×
1096
        hit["data_theta_moving_average"][target_start:target_end] = signal_ma[
×
1097
            hit_wf_start_i:hit_wf_end_i
1098
        ]
1099
        hit["data_theta_convolved"][target_start:target_end] = signal[hit_wf_start_i:hit_wf_end_i]
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc