Skip to main content
Signal processing is applied to each detector module independently before hit finding. The entry point is charge_signal_proc in workflow.py, which calls functions from pedestals.py and noise_filter.py in a fixed sequence. The goal is to subtract a per-channel baseline (pedestal) and remove several categories of noise — coherent pickup, high-frequency noise, and microphonics — while preserving the signal regions of interest (ROI).
1

Raw pedestal estimation

ped.compute_pedestal(noise_type='raw')
The first pass computes a rough per-channel baseline from the raw ADC waveforms. Because no mask exists yet, the computation uses all samples (is_raw=True), which biases the result in the presence of signal.To correct for this, the code performs n_iter = 2 refinement iterations using the raw_rms_thr = 3 threshold:
for i in range(n_iter):
    update_mask_inputs(thresh, mean, std)
    mean, std = compute_pedestal_nb(dc.data_daq, dc.mask_daq, False)
Samples more than 3 × RMS from the mean are masked out before recomputing the pedestal. The result is stored as noise_raw on the event. NaN values (used to align frames) are replaced with the pedestal mean, and the pedestal is subtracted from all waveforms.
At least 10 unmasked samples are required to compute a valid RMS. Channels with fewer valid samples receive a pedestal of 0. A minimum value of 1e-5 prevents the RMS from reaching exactly zero.
2

ROI mask refinement (pre-FFT, 2 iterations)

for n_iter in range(2):
    ped.compute_pedestal(noise_type='filt')
    ped.refine_mask(n_pass=1)
After the raw pedestal subtraction, two further iterations refine the mask to better identify signal regions. The refine_mask function applies different algorithms depending on the wire view type.Collection view (unipolar signal)mask_collection_signal:A region is marked as signal if it contains a pulse-like feature satisfying all of:
  • Duration above low_thr exceeds min_dt = 10 samples
  • Peak value exceeds high_thr
  • Rise time (samples from start to peak) ≥ min_rise = 3
  • Fall time (samples from peak to end) ≥ min_fall = 8
Signal regions are padded by pad_bef = 10 samples before and pad_aft = 15 samples after.Induction view (bipolar signal)mask_induction_signal:A region is marked as signal if it contains both a positive lobe and a negative lobe satisfying:
  • Positive lobe: duration ≥ min_dt = 8, low_thr = 1.8σ, high_thr = 2.5σ, rise ≥ 3, fall ≥ 1
  • Negative lobe: duration ≥ 8, low_thr = -1.8σ, high_thr = -2.5σ, rise ≥ 1, fall ≥ 3
  • If the time between the positive peak and negative peak is within max_dt_pos_neg = 20 samples, the gap between the lobes is also masked
Signal regions are padded by pad_bef = 10 and pad_aft = 15 samples.The default thresholds come from default_reco_parameters.json:
"mask": {
  "coll": {
    "min_dt": 10,
    "low_thr": [2.0, 2.0],
    "high_thr": [5.0, 3.0],
    "min_rise": 3,
    "min_fall": 8,
    "pad_bef": 10,
    "pad_aft": 15
  },
  "ind": {
    "max_dt_pos_neg": 20,
    "pad_bef": 10,
    "pad_aft": 15,
    "pos": { "min_dt": 8, "low_thr": [1.8, 2.0], "high_thr": [2.5, 3.0], "min_rise": 3, "min_fall": 1 },
    "neg": { "min_dt": 8, "low_thr": [-1.8, -1.8], "high_thr": [-2.5, -2.5], "min_rise": 1, "min_fall": 3 }
  }
}
The low_thr and high_thr arrays have two entries, one for each mask refinement pass (n_pass = 1 or 2). Pass 1 (pre-FFT) uses index 0; pass 2 (post-FFT) uses index 1.
3

FFT low-pass filter

noise.FFT_low_pass(False)
A Gaussian low-pass filter is applied in the frequency domain to suppress high-frequency noise:
freq = rfftfreq(nsamples, d=1.0/sampling_rate)

gauss_cut = np.ones_like(freq)
mask = freq >= lowpass_cut
gauss_cut[mask] = gaussian(freq[mask], lowpass_cut, gaus_sigma)

fdata = rfft(dc.data_daq, axis=1)
fdata *= gauss_cut[None, :]
dc.data_daq = irfft(fdata, n=cf.n_sample[cf.imod], axis=1)
Default parameters from default_reco_parameters.json:
"fft": {
  "freq": -1,
  "low_cut": 0.6,
  "gaus_sigma": 0.02
}
  • low_cut = 0.6 MHz: frequencies at or above this value are attenuated
  • gaus_sigma = 0.02 MHz: rolloff width of the Gaussian filter
  • freq = -1: no specific frequency notch is applied by default; if positive, that frequency is removed with a narrow notch (sigma = 0.001 MHz)
The filter is flat at 1.0 for frequencies below low_cut and follows a Gaussian decay above it, centered at low_cut.
4

Zero-padding after FFT

if dc.data_daq.shape[-1] != cf.n_sample[cf.imod]:
    dc.data_daq = np.insert(dc.data_daq, dc.data_daq.shape[-1], 0, axis=-1)
if dc.mask_daq.shape[-1] != cf.n_sample[cf.imod]:
    dc.mask_daq = np.insert(dc.mask_daq, dc.mask_daq.shape[-1], 0, axis=-1)
When the number of samples is odd, the real FFT (irfft) returns an even number of samples, causing a length mismatch. A zero is appended to both the data and mask arrays to restore the expected length.
5

Re-compute pedestal and refine mask (post-FFT, 2 iterations, pass 2)

for n_iter in range(2):
    ped.compute_pedestal(noise_type='filt')
    ped.refine_mask(n_pass=2)
After filtering, the pedestal and mask are recomputed. This second pass uses the n_pass=2 thresholds (index 1 of low_thr / high_thr arrays), which are tuned to the filtered waveform shape.
6

Shield coupling removal (PDVD TDE View 0 only)

noise.shield_coupling()
This step is specific to PDVD modules 2 and 3. For channels in View 0, channels are grouped by CRU (groups of 476 channels), and the per-CRU median waveform is subtracted from all alive channels in that CRU.Capacitance weighting is applied before and reversed after the subtraction:
dc.data_daq = dc.data_daq * (calib / capa)[:, None]
# ... subtract median ...
dc.data_daq = dc.data_daq * (capa / calib)[:, None]
This step is a no-op for all other detectors and modules.
7

Coherent noise removal (CNR)

noise.coherent_noise()
CNR subtracts a common-mode signal shared across groups of channels on the same readout card. The mode is selected by the per_view_per_card flag (default: 1):per_view_per_card = 1 (default) — coherent_noise_per_view_per_card:Channels are grouped by their (view, card) pair from the channel map. For each group, the weighted mean of non-ROI samples is computed and subtracted:
keys = np.core.defchararray.add(views.astype(str), "_" + cards.astype(str))
_, group_ids = np.unique(keys, return_inverse=True)
# accumulate masked sums per group
np.add.at(sums, group_ids, data)
np.add.at(norm, group_ids, dc.mask_daq)
means = sums / denom
dc.data_daq -= means[group_ids]
Optional parameters (both off by default):
  • capa_weight = 0: apply per-channel capacitance weights before computing the group mean
  • calibrated = 0: apply per-channel gain calibration
per_view = 1coherent_noise_per_view: groups channels by view within each card slice (requires groupings to specify card sizes, default 32 channels per group).Neither flagregular_coherent_noise: groups channels into fixed-size slices of groupings[0] = 32 channels and subtracts the masked mean.Default configuration:
"coherent": {
  "groupings": [32],
  "per_view": 0,
  "per_view_per_card": 1,
  "capa_weight": 0,
  "calibrated": 0
}
8

Microphonic noise removal

noise.median_filter()
A centered sliding median filter suppresses low-frequency pickup noise (microphonics):
data_masked = np.where(dc.mask_daq, dc.data_daq, np.nan)
baseline = centered_median_filter(data_masked, window)
np.nan_to_num(baseline, copy=False, nan=0.0)
dc.data_daq -= baseline
ROI samples are replaced with NaN before the median is computed, so the filter only uses baseline samples. The window size is set by noise.microphonic.window in the configuration (-1 by default, which skips this step entirely).The centered_median_filter pads the array so that the output window is centered on each sample rather than left-aligned.
9

Final pedestal computation

ped.compute_pedestal(noise_type='filt')
ped.refine_mask(n_pass=2)
ped.compute_pedestal(noise_type='filt')
A final two-step pedestal subtraction is performed after all noise removal steps. This ensures the per-channel mean is zero and the stored RMS reflects the residual noise level used for hit finding thresholds.

Build docs developers (and LLMs) love