Skip to main content

ppsd_rs/
lib.rs

1//! Seismic PPSD (Probabilistic Power Spectral Density) computation.
2//!
3//! A pure-Rust implementation of the McNamara & Buland (2004) PPSD algorithm,
4//! matching [ObsPy's PPSD](https://docs.obspy.org/packages/autogen/obspy.signal.spectral_estimation.PPSD.html)
5//! output within 0.5 dB tolerance. Uses [`stationxml_rs::Response`] directly
6//! for instrument response evaluation — no intermediate types needed.
7//!
8//! # Algorithm
9//!
10//! The PPSD pipeline for each time segment:
11//!
12//! 1. **Welch's method** — overlap-averaged periodogram with cosine taper and linear detrend
13//! 2. **Instrument response removal** — evaluates PAZ, FIR, and Coefficients stages from
14//!    [`stationxml_rs::Response`], then divides PSD by |H(f)|²
15//! 3. **Velocity→acceleration correction** — multiplies by ω² = (2πf)²
16//! 4. **dB conversion** — 10 × log₁₀(PSD)
17//! 5. **Period binning** — averages dB values into octave-spaced period bins
18//!
19//! # Quick Start
20//!
21//! ```no_run
22//! use ppsd_rs::process_segment;
23//!
24//! // Load inventory (FDSN StationXML or SC3ML — auto-detected)
25//! let inv = stationxml_rs::read_from_file("IA.JAGI.xml").unwrap();
26//! let channel = &inv.networks[0].stations[0].channels[0];
27//! let response = channel.response.as_ref().unwrap();
28//! let sample_rate = channel.sample_rate;
29//!
30//! // Compute PSD for one segment
31//! # let samples = vec![0.0_f64; 72000];
32//! # let nfft = 4096;
33//! # let nlap = 2048;
34//! # let psd_periods = vec![1.0];
35//! # let bin_left = vec![0.5];
36//! # let bin_right = vec![1.5];
37//! let result = process_segment(
38//!     &samples, sample_rate, nfft, nlap,
39//!     response, &psd_periods, &bin_left, &bin_right,
40//! ).unwrap();
41//! ```
42//!
43//! # Response Evaluation
44//!
45//! [`eval_response`] walks all stages in a [`stationxml_rs::Response`] and computes
46//! the combined |H(f)|²:
47//!
48//! | Stage type | Type | Method |
49//! |------------|------|--------|
50//! | Sensor | [`stationxml_rs::PolesZeros`] | Laplace transfer function × gain |
51//! | ADC | [`stationxml_rs::Coefficients`] (Digital) | DTFT of numerators × gain |
52//! | FIR filter | [`stationxml_rs::FIR`] | DTFT with DC normalization × gain |
53//! | Gain-only | (none of above) | gain² |
54//!
55//! FIR coefficients are DC-normalized to match evalresp behavior.
56//! [`stationxml_rs::Symmetry`] expansion is handled automatically
57//! (`None` = all coefficients, `Even` = mirror, `Odd` = mirror with negation).
58
59#![warn(missing_docs)]
60
61use num_complex::Complex;
62use realfft::RealFftPlanner;
63use stationxml_rs::{
64    CfTransferFunction, FIR, PzTransferFunction, Response, ResponseStage, Symmetry,
65};
66use std::f64::consts::PI;
67
68// ─── Error ─────────────────────────────────────────────────────────
69
70/// Errors that can occur during PSD computation.
71#[derive(Debug, thiserror::Error)]
72pub enum PpsdError {
73    /// Response has no stages to evaluate.
74    #[error("response has no stages")]
75    EmptyResponse,
76
77    /// Unsupported transfer function type encountered.
78    #[error("unsupported transfer function type in stage {stage}: {detail}")]
79    UnsupportedStage {
80        /// Stage number (1-based).
81        stage: u32,
82        /// Description of what is unsupported.
83        detail: String,
84    },
85
86    /// A digital stage has no decimation info (needed for DTFT sample rate).
87    #[error("stage {0} has FIR/Coefficients but no decimation info")]
88    MissingDecimation(u32),
89
90    /// A digital stage has a non-positive or NaN sample rate.
91    ///
92    /// Guards against silent NaN propagation through the DTFT inner loop, which
93    /// would otherwise occur when `input_sample_rate` is `0.0`, negative, or NaN
94    /// (`f / 0.0 = ±Inf`, `cos/sin(±Inf) = NaN`).
95    #[error("stage {0} has invalid sample rate (must be finite and > 0)")]
96    InvalidSampleRate(u32),
97}
98
99/// Result type for ppsd operations.
100pub type Result<T> = std::result::Result<T, PpsdError>;
101
102// ─── Public types ──────────────────────────────────────────────────
103
104// ─── Step 1: Cosine taper ──────────────────────────────────────────
105
106/// Cosine taper (Tukey window) matching ObsPy's `cosine_taper(npts, p)`.
107///
108/// Produces a window of length `npts` where a fraction `p` of the total
109/// length is tapered with a raised cosine (half on each side).
110///
111/// # Arguments
112///
113/// * `npts` — Number of points in the window.
114/// * `p` — Taper fraction, 0.0 (rectangular) to 1.0 (full Hann). ObsPy default is 0.05.
115///
116/// # Examples
117///
118/// ```
119/// let taper = ppsd_rs::cosine_taper(100, 0.1);
120/// assert_eq!(taper.len(), 100);
121/// // Edges are tapered, center is 1.0
122/// assert!((taper[50] - 1.0).abs() < 1e-12);
123/// ```
124pub fn cosine_taper(npts: usize, p: f64) -> Vec<f64> {
125    let mut taper = vec![0.0; npts];
126
127    let frac = if p == 0.0 || p == 1.0 {
128        (npts as f64 * p / 2.0) as usize
129    } else {
130        (npts as f64 * p / 2.0 + 0.5) as usize
131    };
132
133    let idx1 = 0usize;
134    let mut idx2 = frac.saturating_sub(1);
135    let mut idx3 = npts.saturating_sub(frac);
136    let idx4 = npts - 1;
137
138    if idx1 == idx2 {
139        idx2 += 1;
140    }
141    if idx3 == idx4 {
142        idx3 -= 1;
143    }
144
145    // Rising ramp
146    let ramp_len = (idx2 - idx1) as f64;
147    for (i, val) in taper.iter_mut().enumerate().take(idx2 + 1).skip(idx1) {
148        *val = 0.5 * (1.0 - (PI * (i as f64 - idx1 as f64) / ramp_len).cos());
149    }
150    // Flat top
151    for val in taper.iter_mut().take(idx3).skip(idx2 + 1) {
152        *val = 1.0;
153    }
154    // Falling ramp
155    let ramp_len = (idx4 - idx3) as f64;
156    for (i, val) in taper.iter_mut().enumerate().take(idx4 + 1).skip(idx3) {
157        *val = 0.5 * (1.0 + (PI * (idx3 as f64 - i as f64) / ramp_len).cos());
158    }
159
160    taper
161}
162
163// ─── Step 2: FFT and power spectrum ────────────────────────────────
164
165/// Compute one-sided power spectrum via real FFT.
166///
167/// Returns `(freqs, power)` where `freqs` are in Hz and `power` is scaled
168/// by `1/fs` (power spectral density). Non-DC, non-Nyquist bins are doubled
169/// for the single-sided spectrum.
170///
171/// # Arguments
172///
173/// * `samples` — Input signal (time domain).
174/// * `sample_rate` — Sampling rate in Hz.
175///
176/// # Returns
177///
178/// Tuple of `(frequencies_hz, power_spectral_density)`, each of length `N/2 + 1`.
179pub fn fft_power(samples: &[f64], sample_rate: f64) -> (Vec<f64>, Vec<f64>) {
180    let n = samples.len();
181    let mut planner = RealFftPlanner::<f64>::new();
182    let fft = planner.plan_fft_forward(n);
183
184    let mut input = samples.to_vec();
185    let mut spectrum = fft.make_output_vec();
186    fft.process(&mut input, &mut spectrum).unwrap();
187
188    let n_freqs = spectrum.len(); // n/2 + 1
189    let mut freqs = Vec::with_capacity(n_freqs);
190    let mut power = Vec::with_capacity(n_freqs);
191
192    for (i, c) in spectrum.iter().enumerate() {
193        freqs.push(i as f64 * sample_rate / n as f64);
194        let mut p = (c.re * c.re + c.im * c.im) / (sample_rate * n as f64);
195        // Double non-DC, non-Nyquist bins for single-sided spectrum
196        if i > 0 && i < n_freqs - 1 {
197            p *= 2.0;
198        }
199        power.push(p);
200    }
201
202    (freqs, power)
203}
204
205// ─── Step 3: Instrument response evaluation ────────────────────────
206
207/// Evaluate a single PAZ stage |H(f)|² at given frequencies.
208fn eval_paz(
209    pz: &stationxml_rs::PolesZeros,
210    stage_gain: f64,
211    stage_number: u32,
212    freqs: &[f64],
213) -> Result<Vec<f64>> {
214    if pz.pz_transfer_function_type == PzTransferFunction::DigitalZTransform {
215        return Err(PpsdError::UnsupportedStage {
216            stage: stage_number,
217            detail: "DigitalZTransform PAZ not supported (requires sample rate)".into(),
218        });
219    }
220
221    Ok(freqs
222        .iter()
223        .map(|&f| {
224            let s = match pz.pz_transfer_function_type {
225                PzTransferFunction::LaplaceRadians => Complex::new(0.0, 2.0 * PI * f),
226                PzTransferFunction::LaplaceHertz => Complex::new(0.0, f),
227                PzTransferFunction::DigitalZTransform => unreachable!(),
228            };
229
230            let mut h = Complex::new(1.0, 0.0);
231            for z in &pz.zeros {
232                h *= s - Complex::new(z.real, z.imaginary);
233            }
234            for p in &pz.poles {
235                h /= s - Complex::new(p.real, p.imaginary);
236            }
237            h *= pz.normalization_factor;
238            h *= stage_gain;
239            h.norm_sqr() // |H(f)|²
240        })
241        .collect())
242}
243
244/// Compute DC-normalized DTFT |H(f)|² × gain² for a set of coefficients.
245///
246/// Shared by FIR and digital Coefficients stage evaluation.
247fn dtft_power_normalized(
248    coeffs: &[f64],
249    stage_gain: f64,
250    sample_rate: f64,
251    freqs: &[f64],
252) -> Vec<f64> {
253    // DC-normalize: fold norm² into gain² so the inner loop uses raw coefficients.
254    let coeff_sum: f64 = coeffs.iter().sum();
255    let norm_sq = if coeff_sum.abs() > 1e-30 {
256        1.0 / (coeff_sum * coeff_sum)
257    } else {
258        1.0
259    };
260    let scale = stage_gain * stage_gain * norm_sq;
261
262    freqs
263        .iter()
264        .map(|&f| {
265            let phase_step = -2.0 * PI * f / sample_rate;
266            let mut re = 0.0;
267            let mut im = 0.0;
268            for (k, &coeff) in coeffs.iter().enumerate() {
269                let phase = phase_step * k as f64;
270                re += coeff * phase.cos();
271                im += coeff * phase.sin();
272            }
273            (re * re + im * im) * scale
274        })
275        .collect()
276}
277
278/// Evaluate a FIR stage |H(f)|² via DTFT at given frequencies.
279///
280/// Coefficients are DC-normalized (matching evalresp behavior).
281///
282/// SEED FIR symmetry codes "B" (`Odd`) and "C" (`Even`) both describe
283/// **symmetric** (linear-phase) filters — only the first half of the taps is
284/// stored and the full filter is reconstructed by mirroring WITHOUT negation.
285/// They differ only in the total tap count's parity:
286/// - `Even` (C): even total length — mirror the full listed half: `[a,b] → [a,b,b,a]`.
287/// - `Odd` (B): odd total length — the last listed tap is the center, so mirror
288///   the half EXCLUDING the center: `[a,b,c] → [a,b,c,b,a]`.
289///
290/// (There is no antisymmetric/negated FIR in the SEED standard; negating the
291/// `Odd` mirror collapses the coefficient sum to a ~1e-17 float residual, which
292/// blows up the `1/sum²` DC normalization and floors the resulting PSD.)
293fn eval_fir(fir: &FIR, stage_gain: f64, sample_rate: f64, freqs: &[f64]) -> Vec<f64> {
294    match fir.symmetry {
295        Symmetry::None => {
296            dtft_power_normalized(&fir.numerator_coefficients, stage_gain, sample_rate, freqs)
297        }
298        Symmetry::Even => {
299            let mut full = fir.numerator_coefficients.clone();
300            full.extend(fir.numerator_coefficients.iter().rev());
301            dtft_power_normalized(&full, stage_gain, sample_rate, freqs)
302        }
303        Symmetry::Odd => {
304            let mut full = fir.numerator_coefficients.clone();
305            // Mirror without negation, skipping the center tap (last listed).
306            full.extend(fir.numerator_coefficients.iter().rev().skip(1).copied());
307            dtft_power_normalized(&full, stage_gain, sample_rate, freqs)
308        }
309    }
310}
311
312/// Get sample rate for a digital stage from its Decimation info.
313fn stage_sample_rate(stage: &ResponseStage) -> Option<f64> {
314    stage.decimation.as_ref().map(|d| d.input_sample_rate)
315}
316
317/// Evaluate full instrument response |H(f)|² at given frequencies.
318///
319/// Walks all stages in [`stationxml_rs::Response::stages`], evaluating PAZ, FIR,
320/// and Coefficients stages. Returns the product of all stage |H(f)|² values.
321///
322/// FIR coefficients are DC-normalized (divided by their sum) to match evalresp
323/// behavior. Symmetry expansion is handled for `Even` and `Odd` FIR filters.
324///
325/// # Arguments
326///
327/// * `response` — Instrument response from a [`stationxml_rs::Channel`].
328/// * `freqs` — Frequencies (Hz) at which to evaluate the response.
329///
330/// # Returns
331///
332/// Vector of |H(f)|² values, same length as `freqs`.
333///
334/// # Errors
335///
336/// Returns [`PpsdError`] if:
337/// - Response has no stages ([`PpsdError::EmptyResponse`])
338/// - A digital stage lacks decimation info ([`PpsdError::MissingDecimation`])
339/// - An unsupported transfer function type is encountered ([`PpsdError::UnsupportedStage`])
340///
341/// # Examples
342///
343/// ```no_run
344/// let inv = stationxml_rs::read_from_file("station.xml").unwrap();
345/// let response = inv.networks[0].stations[0].channels[0]
346///     .response.as_ref().unwrap();
347/// let freqs: Vec<f64> = (1..=100).map(|i| i as f64 * 0.1).collect();
348/// let h_sq = ppsd_rs::eval_response(response, &freqs).unwrap();
349/// assert_eq!(h_sq.len(), freqs.len());
350/// ```
351pub fn eval_response(response: &Response, freqs: &[f64]) -> Result<Vec<f64>> {
352    if response.stages.is_empty() {
353        return Err(PpsdError::EmptyResponse);
354    }
355
356    let mut result = vec![1.0_f64; freqs.len()];
357
358    for stage in &response.stages {
359        let gain = stage.stage_gain.as_ref().map(|g| g.value).unwrap_or(1.0);
360
361        let stage_resp = if let Some(pz) = &stage.poles_zeros {
362            eval_paz(pz, gain, stage.number, freqs)?
363        } else if let Some(fir) = &stage.fir {
364            let fs = stage_sample_rate(stage).ok_or(PpsdError::MissingDecimation(stage.number))?;
365            if !fs.is_finite() || fs <= 0.0 {
366                return Err(PpsdError::InvalidSampleRate(stage.number));
367            }
368            eval_fir(fir, gain, fs, freqs)
369        } else if let Some(coeffs) = &stage.coefficients {
370            match coeffs.cf_transfer_function_type {
371                CfTransferFunction::Digital => {
372                    let fs = stage_sample_rate(stage)
373                        .ok_or(PpsdError::MissingDecimation(stage.number))?;
374                    if !fs.is_finite() || fs <= 0.0 {
375                        return Err(PpsdError::InvalidSampleRate(stage.number));
376                    }
377                    dtft_power_normalized(&coeffs.numerators, gain, fs, freqs)
378                }
379                CfTransferFunction::AnalogRadians | CfTransferFunction::AnalogHertz => {
380                    return Err(PpsdError::UnsupportedStage {
381                        stage: stage.number,
382                        detail: "analog Coefficients stage not supported".into(),
383                    });
384                }
385            }
386        } else {
387            // Gain-only stage: multiply all by gain² directly, no allocation needed
388            let gain_sq = gain * gain;
389            for r in result.iter_mut() {
390                *r *= gain_sq;
391            }
392            continue;
393        };
394
395        for (r, h) in result.iter_mut().zip(stage_resp.iter()) {
396            *r *= h;
397        }
398    }
399
400    Ok(result)
401}
402
403// ─── Step 4: Konno-Ohmachi smoothing ───────────────────────────────
404
405/// Konno-Ohmachi log-frequency spectral smoothing.
406///
407/// Smooths a spectrum using the Konno & Ohmachi (1998) windowing function,
408/// which is constant-width in log-frequency space. The smoothing kernel is
409/// `W(f, fc) = [sin(b × log₁₀(f/fc)) / (b × log₁₀(f/fc))]⁴`.
410///
411/// # Arguments
412///
413/// * `freqs` — Frequency values (Hz). Entries ≤ 0 are passed through unsmoothed.
414/// * `spectrum` — Spectral values at each frequency.
415/// * `bandwidth` — Smoothing bandwidth parameter (typically 40.0). Higher = less smoothing.
416pub fn konno_ohmachi_smooth(freqs: &[f64], spectrum: &[f64], bandwidth: f64) -> Vec<f64> {
417    let n = freqs.len();
418    let mut smoothed = vec![0.0; n];
419
420    // Precompute log10 of all frequencies to avoid N² log10 calls.
421    let log_freqs: Vec<f64> = freqs.iter().map(|&f| f.log10()).collect();
422
423    for i in 0..n {
424        if freqs[i] <= 0.0 {
425            smoothed[i] = spectrum[i];
426            continue;
427        }
428
429        let log_fc = log_freqs[i];
430        let mut wsum = 0.0;
431        let mut vsum = 0.0;
432        for j in 0..n {
433            if freqs[j] <= 0.0 {
434                continue;
435            }
436            let log_ratio = log_freqs[j] - log_fc;
437            let w = if log_ratio.abs() < 1e-10 {
438                1.0
439            } else {
440                let arg = bandwidth * log_ratio;
441                let sinc = arg.sin() / arg;
442                sinc.powi(4)
443            };
444            wsum += w;
445            vsum += w * spectrum[j];
446        }
447        if wsum > 0.0 {
448            smoothed[i] = vsum / wsum;
449        }
450    }
451
452    smoothed
453}
454
455// ─── Step 5: Welch's method ────────────────────────────────────────
456
457/// Compute PSD using Welch's method matching ObsPy/matplotlib's `mlab.psd`.
458///
459/// Applies a cosine taper (`p=0.2`), linear detrend, overlap-averaged FFT,
460/// and `scale_by_freq=True` normalization. Returns one-sided PSD.
461///
462/// # Arguments
463///
464/// * `data` — Input time-series samples.
465/// * `nfft` — FFT length (number of samples per sub-segment).
466/// * `sample_rate` — Sampling rate in Hz.
467/// * `noverlap` — Number of overlapping samples between consecutive sub-segments.
468///
469/// # Returns
470///
471/// Tuple of `(frequencies_hz, power_spectral_density)`, each of length `nfft/2 + 1`.
472pub fn welch_psd(
473    data: &[f64],
474    nfft: usize,
475    sample_rate: f64,
476    noverlap: usize,
477) -> (Vec<f64>, Vec<f64>) {
478    let taper = cosine_taper(nfft, 0.2);
479    let taper_norm_sq: f64 = taper.iter().map(|w| w * w).sum();
480
481    let step = nfft - noverlap;
482    let n_segments = if data.len() >= nfft {
483        (data.len() - nfft) / step + 1
484    } else {
485        0
486    };
487
488    let n_freqs = nfft / 2 + 1;
489    let mut power_avg = vec![0.0; n_freqs];
490
491    let mut planner = RealFftPlanner::<f64>::new();
492    let fft = planner.plan_fft_forward(nfft);
493    let mut spectrum = fft.make_output_vec();
494    let mut buf = vec![0.0_f64; nfft];
495
496    for seg_idx in 0..n_segments {
497        let offset = seg_idx * step;
498        buf.copy_from_slice(&data[offset..offset + nfft]);
499        detrend_linear(&mut buf);
500
501        for (s, w) in buf.iter_mut().zip(taper.iter()) {
502            *s *= w;
503        }
504
505        fft.process(&mut buf, &mut spectrum).unwrap();
506
507        for (i, c) in spectrum.iter().enumerate() {
508            power_avg[i] += c.re * c.re + c.im * c.im;
509        }
510    }
511
512    if n_segments == 0 {
513        let freqs = (0..n_freqs)
514            .map(|i| i as f64 * sample_rate / nfft as f64)
515            .collect();
516        return (freqs, power_avg);
517    }
518
519    let scale = sample_rate * taper_norm_sq * n_segments as f64;
520    for (i, p) in power_avg.iter_mut().enumerate() {
521        *p /= scale;
522        if i > 0 && i < n_freqs - 1 {
523            *p *= 2.0;
524        }
525    }
526
527    let freqs = (0..n_freqs)
528        .map(|i| i as f64 * sample_rate / nfft as f64)
529        .collect();
530
531    (freqs, power_avg)
532}
533
534/// Linear detrend (least-squares line removal), matching `mlab.detrend_linear`.
535fn detrend_linear(data: &mut [f64]) {
536    let n = data.len() as f64;
537    let mut sx = 0.0;
538    let mut sy = 0.0;
539    let mut sxy = 0.0;
540    let mut sxx = 0.0;
541    for (i, &y) in data.iter().enumerate() {
542        let x = i as f64;
543        sx += x;
544        sy += y;
545        sxy += x * y;
546        sxx += x * x;
547    }
548    let denom = n * sxx - sx * sx;
549    if denom.abs() < 1e-30 {
550        let mean = sy / n;
551        for d in data.iter_mut() {
552            *d -= mean;
553        }
554        return;
555    }
556    let slope = (n * sxy - sx * sy) / denom;
557    let intercept = (sy - slope * sx) / n;
558    for (i, d) in data.iter_mut().enumerate() {
559        *d -= intercept + slope * i as f64;
560    }
561}
562
563// ─── Step 6: Period binning ────────────────────────────────────────
564
565/// Bin PSD values into period bins by averaging (ObsPy style).
566///
567/// For each bin defined by `[bin_left[i], bin_right[i]]`, averages all `spec_db`
568/// values whose corresponding `psd_periods` fall within the bin. Returns `None`
569/// for bins with no matching periods.
570///
571/// # Arguments
572///
573/// * `psd_periods` — Period values (seconds) from the Welch FFT, in descending order.
574/// * `spec_db` — PSD values in dB at each period.
575/// * `bin_left` — Left edges of the period bins (seconds).
576/// * `bin_right` — Right edges of the period bins (seconds).
577pub fn period_bin_average(
578    psd_periods: &[f64],
579    spec_db: &[f64],
580    bin_left: &[f64],
581    bin_right: &[f64],
582) -> Vec<Option<f64>> {
583    bin_left
584        .iter()
585        .zip(bin_right.iter())
586        .map(|(&left, &right)| {
587            let mut sum = 0.0;
588            let mut count = 0usize;
589            for (j, &period) in psd_periods.iter().enumerate() {
590                if period >= left && period <= right {
591                    sum += spec_db[j];
592                    count += 1;
593                }
594            }
595            if count > 0 {
596                Some(sum / count as f64)
597            } else {
598                None
599            }
600        })
601        .collect()
602}
603
604/// Compute period binning edges matching ObsPy `PPSD._setup_period_binning`.
605///
606/// Generates octave-spaced bins covering the range of `psd_periods`.
607///
608/// # Arguments
609///
610/// * `psd_periods` — Period values (seconds) from the Welch FFT.
611/// * `period_step_octaves` — Step size between bin centers in octaves (ObsPy default: 0.04).
612/// * `period_smoothing_width_octaves` — Width of each bin in octaves (ObsPy default: 0.3).
613///
614/// # Returns
615///
616/// Tuple of `(left_edges, right_edges, centers)` — all in seconds.
617pub fn setup_period_binning(
618    psd_periods: &[f64],
619    period_step_octaves: f64,
620    period_smoothing_width_octaves: f64,
621) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
622    let step_factor = 2.0_f64.powf(period_step_octaves);
623    let smooth_factor = 2.0_f64.powf(period_smoothing_width_octaves);
624
625    let period_min = psd_periods.iter().cloned().reduce(f64::min).unwrap();
626    let period_max = psd_periods.iter().cloned().reduce(f64::max).unwrap();
627
628    let mut lefts = Vec::new();
629    let mut rights = Vec::new();
630    let mut centers = Vec::new();
631
632    let mut per_left = period_min / smooth_factor.sqrt();
633    loop {
634        let per_right = per_left * smooth_factor;
635        let per_center = (per_left * per_right).sqrt();
636
637        if per_center > period_max {
638            break;
639        }
640
641        if per_right > period_min && per_left < period_max {
642            lefts.push(per_left);
643            rights.push(per_right);
644            centers.push(per_center);
645        }
646
647        per_left *= step_factor;
648    }
649
650    (lefts, rights, centers)
651}
652
653// ─── Step 7: Full PSD pipeline ─────────────────────────────────────
654
655/// Process one PPSD segment, replicating ObsPy's `PPSD.__process` exactly.
656///
657/// Performs the full pipeline:
658/// 1. **Welch PSD** — `mlab.psd` with cosine taper and linear detrend
659/// 2. **Reorder** — skip DC bin, reverse to period order
660/// 3. **Response removal** — divide by |H(f)|² and multiply by ω² (velocity→acceleration)
661/// 4. **dB conversion** — 10 × log₁₀(PSD)
662/// 5. **Period bin averaging** — octave-spaced bins
663///
664/// Takes [`stationxml_rs::Response`] directly — no intermediate types needed.
665///
666/// # Arguments
667///
668/// * `segment` — Time-domain samples for one PPSD segment.
669/// * `sample_rate` — Sampling rate in Hz.
670/// * `nfft` — FFT length for Welch's method.
671/// * `nlap` — Overlap samples for Welch's method.
672/// * `response` — Instrument response from [`stationxml_rs::Channel`].
673/// * `psd_periods` — Period values (seconds) from the Welch FFT.
674/// * `bin_left` — Left edges of period bins (seconds).
675/// * `bin_right` — Right edges of period bins (seconds).
676///
677/// # Returns
678///
679/// Vector of `Option<f64>` — dB values per period bin. `None` for empty bins.
680///
681/// # Errors
682///
683/// Returns [`PpsdError`] if response evaluation fails (see [`eval_response`]).
684#[allow(clippy::too_many_arguments)]
685pub fn process_segment(
686    segment: &[f64],
687    sample_rate: f64,
688    nfft: usize,
689    nlap: usize,
690    response: &Response,
691    psd_periods: &[f64],
692    bin_left: &[f64],
693    bin_right: &[f64],
694) -> Result<Vec<Option<f64>>> {
695    // 1. Welch PSD
696    let (freqs, power) = welch_psd(segment, nfft, sample_rate, nlap);
697
698    // 2. Skip DC bin (index 0), reverse to period order
699    let spec: Vec<f64> = power[1..].iter().rev().copied().collect();
700
701    // 3. Instrument response removal + 4. dB conversion (fused)
702    let resp_power = eval_response(response, &freqs[1..])?;
703    let resp_power_rev: Vec<f64> = resp_power.iter().rev().copied().collect();
704
705    let dtiny = 1e-20_f64;
706    let n_spec = spec.len();
707    let spec_db: Vec<f64> = (0..n_spec)
708        .map(|i| {
709            let f = freqs[n_spec - i]; // reversed freq (skip DC)
710            let w = 2.0 * PI * f;
711            let val = w * w * spec[i] / resp_power_rev[i];
712            10.0 * (if val < dtiny { dtiny } else { val }).log10()
713        })
714        .collect();
715
716    // 5. Period bin averaging
717    Ok(period_bin_average(
718        psd_periods,
719        &spec_db,
720        bin_left,
721        bin_right,
722    ))
723}
724
725// ─── Tests ─────────────────────────────────────────────────────────
726
727#[cfg(test)]
728mod tests {
729    use super::*;
730    use stationxml_rs::{Coefficients, Decimation, PoleZero, PolesZeros, StageGain};
731    use std::path::PathBuf;
732
733    fn test_vectors_dir() -> PathBuf {
734        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
735            .join("pyscripts")
736            .join("test_vectors")
737    }
738
739    /// Helper: extract a `Vec<f64>` from a JSON array field.
740    fn json_f64_vec(val: &serde_json::Value, key: &str) -> Vec<f64> {
741        val[key]
742            .as_array()
743            .unwrap()
744            .iter()
745            .map(|v| v.as_f64().unwrap())
746            .collect()
747    }
748
749    /// Helper: parse `Vec<Vec<Option<f64>>>` from JSON (for expected dB values).
750    fn json_option_f64_matrix(val: &serde_json::Value, key: &str) -> Vec<Vec<Option<f64>>> {
751        val[key]
752            .as_array()
753            .unwrap()
754            .iter()
755            .map(|seg| seg.as_array().unwrap().iter().map(|v| v.as_f64()).collect())
756            .collect()
757    }
758
759    /// Helper: compare PSD result against expected dB values with tolerance.
760    fn assert_psd_match(
761        result: &[Option<f64>],
762        expected: &[Option<f64>],
763        seg_idx: usize,
764        label: &str,
765    ) {
766        assert_eq!(
767            result.len(),
768            expected.len(),
769            "segment {seg_idx}: bin count mismatch"
770        );
771
772        let mut max_err = 0.0_f64;
773        let mut n_compared = 0usize;
774        for (bin, (got, exp)) in result.iter().zip(expected.iter()).enumerate() {
775            match (got, exp) {
776                (Some(g), Some(e)) => {
777                    let err = (g - e).abs();
778                    if err > max_err {
779                        max_err = err;
780                    }
781                    assert!(
782                        err < 0.5,
783                        "segment {seg_idx} bin {bin}: got {g:.4} dB, expected {e:.4} dB, err={err:.4} dB"
784                    );
785                    n_compared += 1;
786                }
787                (None, None) => {}
788                (Some(g), None) => {
789                    panic!("segment {seg_idx} bin {bin}: got {g:.4} dB, expected None");
790                }
791                (None, Some(e)) => {
792                    panic!("segment {seg_idx} bin {bin}: got None, expected {e:.4} dB");
793                }
794            }
795        }
796        eprintln!("{label} segment {seg_idx}: compared {n_compared} bins, max_err={max_err:.6} dB");
797    }
798
799    /// Helper: build a PAZ-only Response from test vector JSON.
800    fn response_from_json(data: &serde_json::Value) -> Response {
801        let zeros_re = json_f64_vec(data, "zeros_real");
802        let zeros_im = json_f64_vec(data, "zeros_imag");
803        let poles_re = json_f64_vec(data, "poles_real");
804        let poles_im = json_f64_vec(data, "poles_imag");
805
806        Response {
807            instrument_sensitivity: None,
808            stages: vec![ResponseStage {
809                number: 1,
810                stage_gain: Some(StageGain {
811                    value: data["stage_gain"].as_f64().unwrap(),
812                    frequency: data["normalization_frequency"].as_f64().unwrap(),
813                }),
814                poles_zeros: Some(PolesZeros {
815                    input_units: stationxml_rs::Units {
816                        name: "M/S".into(),
817                        description: None,
818                    },
819                    output_units: stationxml_rs::Units {
820                        name: "V".into(),
821                        description: None,
822                    },
823                    pz_transfer_function_type: PzTransferFunction::LaplaceRadians,
824                    normalization_factor: data["normalization_factor"].as_f64().unwrap(),
825                    normalization_frequency: data["normalization_frequency"].as_f64().unwrap(),
826                    zeros: zeros_re
827                        .iter()
828                        .zip(zeros_im.iter())
829                        .enumerate()
830                        .map(|(i, (&r, &im))| PoleZero {
831                            number: i as u32,
832                            real: r,
833                            imaginary: im,
834                        })
835                        .collect(),
836                    poles: poles_re
837                        .iter()
838                        .zip(poles_im.iter())
839                        .enumerate()
840                        .map(|(i, (&r, &im))| PoleZero {
841                            number: i as u32,
842                            real: r,
843                            imaginary: im,
844                        })
845                        .collect(),
846                }),
847                coefficients: None,
848                fir: None,
849                decimation: None,
850            }],
851        }
852    }
853
854    /// Helper: build FIR stages from test vector JSON.
855    fn fir_stages_from_json(data: &serde_json::Value) -> Vec<ResponseStage> {
856        data.as_array()
857            .unwrap()
858            .iter()
859            .enumerate()
860            .map(|(i, s)| {
861                let coefficients = json_f64_vec(s, "coefficients");
862                let symmetry = match s["symmetry"].as_str().unwrap() {
863                    "asymmetric" => Symmetry::None,
864                    "even" => Symmetry::Even,
865                    "odd" => Symmetry::Odd,
866                    other => panic!("unknown symmetry: {other}"),
867                };
868                let stage_gain = s["stage_gain"].as_f64().unwrap();
869                let sample_rate = s["sample_rate"].as_f64().unwrap();
870
871                ResponseStage {
872                    number: (i + 2) as u32,
873                    stage_gain: Some(StageGain {
874                        value: stage_gain,
875                        frequency: 1.0,
876                    }),
877                    poles_zeros: None,
878                    coefficients: None,
879                    fir: Some(FIR {
880                        input_units: stationxml_rs::Units {
881                            name: "COUNTS".into(),
882                            description: None,
883                        },
884                        output_units: stationxml_rs::Units {
885                            name: "COUNTS".into(),
886                            description: None,
887                        },
888                        symmetry,
889                        numerator_coefficients: coefficients,
890                    }),
891                    decimation: Some(Decimation {
892                        input_sample_rate: sample_rate,
893                        factor: 1,
894                        offset: 0,
895                        delay: 0.0,
896                        correction: 0.0,
897                    }),
898                }
899            })
900            .collect()
901    }
902
903    // -- Cosine taper --
904
905    #[test]
906    fn test_cosine_taper() {
907        let path = test_vectors_dir().join("cosine_taper.json");
908        let data: serde_json::Value =
909            serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap();
910
911        let n = data["n"].as_u64().unwrap() as usize;
912        let p = data["taper_percentage"].as_f64().unwrap();
913        let expected = json_f64_vec(&data, "taper_weights");
914
915        let result = cosine_taper(n, p);
916
917        assert_eq!(result.len(), expected.len());
918        for (i, (r, e)) in result.iter().zip(expected.iter()).enumerate() {
919            assert!(
920                (r - e).abs() < 1e-12,
921                "taper mismatch at index {i}: got {r}, expected {e}"
922            );
923        }
924    }
925
926    // -- FFT power spectrum --
927
928    #[test]
929    fn test_fft_power() {
930        let path = test_vectors_dir().join("fft_power.json");
931        let data: serde_json::Value =
932            serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap();
933
934        let sample_rate = data["sample_rate"].as_f64().unwrap();
935        let signal_tapered = json_f64_vec(&data, "signal_tapered");
936        let expected_freqs = json_f64_vec(&data, "freqs");
937        let expected_power = json_f64_vec(&data, "power");
938
939        let (freqs, power) = fft_power(&signal_tapered, sample_rate);
940
941        assert_eq!(freqs.len(), expected_freqs.len());
942        for (i, (r, e)) in freqs.iter().zip(expected_freqs.iter()).enumerate() {
943            assert!(
944                (r - e).abs() < 1e-10,
945                "freq mismatch at bin {i}: got {r}, expected {e}"
946            );
947        }
948
949        assert_eq!(power.len(), expected_power.len());
950        for (i, (r, e)) in power.iter().zip(expected_power.iter()).enumerate() {
951            let tol = e.abs() * 1e-6 + 1e-20;
952            assert!(
953                (r - e).abs() < tol,
954                "power mismatch at bin {i}: got {r:.6e}, expected {e:.6e}"
955            );
956        }
957    }
958
959    // -- Instrument response (PAZ only) --
960
961    #[test]
962    fn test_instrument_response() {
963        let path = test_vectors_dir().join("instrument_response.json");
964        let data: serde_json::Value =
965            serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap();
966
967        let response = response_from_json(&data);
968
969        let freqs = json_f64_vec(&data, "freqs");
970        let expected_power = json_f64_vec(&data, "response_power");
971
972        let result = eval_response(&response, &freqs).unwrap();
973
974        assert_eq!(result.len(), expected_power.len());
975        for (i, (r, e)) in result.iter().zip(expected_power.iter()).enumerate() {
976            let rel_err = if *e != 0.0 {
977                ((r - e) / e).abs()
978            } else {
979                r.abs()
980            };
981            assert!(
982                rel_err < 1e-6,
983                "response power mismatch at bin {i} (freq={:.4}): got {r:.6e}, expected {e:.6e}, rel_err={rel_err:.2e}",
984                freqs[i]
985            );
986        }
987    }
988
989    // -- FIR response --
990
991    #[test]
992    fn test_fir_response() {
993        let path = test_vectors_dir().join("fir_response.json");
994        let data: serde_json::Value =
995            serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap();
996
997        let coefficients = json_f64_vec(&data, "coefficients");
998        let sample_rate = data["sample_rate"].as_f64().unwrap();
999        let stage_gain = data["stage_gain"].as_f64().unwrap();
1000
1001        let fir = FIR {
1002            input_units: stationxml_rs::Units {
1003                name: "COUNTS".into(),
1004                description: None,
1005            },
1006            output_units: stationxml_rs::Units {
1007                name: "COUNTS".into(),
1008                description: None,
1009            },
1010            symmetry: Symmetry::None,
1011            numerator_coefficients: coefficients,
1012        };
1013
1014        let freqs = json_f64_vec(&data, "freqs");
1015        let expected_power = json_f64_vec(&data, "response_power");
1016
1017        let result = eval_fir(&fir, stage_gain, sample_rate, &freqs);
1018
1019        assert_eq!(result.len(), expected_power.len());
1020        for (i, (r, e)) in result.iter().zip(expected_power.iter()).enumerate() {
1021            let rel_err = if *e > 1e-20 {
1022                ((r - e) / e).abs()
1023            } else {
1024                (r - e).abs()
1025            };
1026            assert!(
1027                rel_err < 1e-6,
1028                "FIR power mismatch at bin {i} (freq={:.4}): got {r:.6e}, expected {e:.6e}, rel_err={rel_err:.2e}",
1029                freqs[i]
1030            );
1031        }
1032    }
1033
1034    // -- FIR symmetry expansion (SEED B/C are both SYMMETRIC) --
1035
1036    #[test]
1037    fn test_fir_symmetry_odd_is_symmetric_not_negated() {
1038        // SEED symmetry "B" (Odd) = odd-length SYMMETRIC linear-phase FIR.
1039        // Only the first half + the center tap are listed; the full filter
1040        // mirrors the first half (EXCLUDING the center) WITHOUT negation:
1041        //   listed [c0, c1, c2(center)]  ->  full [c0, c1, c2, c1, c0]
1042        // A negated/antisymmetric expansion makes the coefficient sum collapse
1043        // to a ~1e-17 floating-point residual, so the DC normalization
1044        // (1/sum²) explodes and the deconvolved PSD floors to -200 dB.
1045        let units = || stationxml_rs::Units {
1046            name: "COUNTS".into(),
1047            description: None,
1048        };
1049        let fir_odd = FIR {
1050            input_units: units(),
1051            output_units: units(),
1052            symmetry: Symmetry::Odd,
1053            numerator_coefficients: vec![0.1, 0.2, 0.4],
1054        };
1055        // Explicit, fully-listed equivalent (what evalresp/ObsPy reconstruct).
1056        let fir_ref = FIR {
1057            input_units: units(),
1058            output_units: units(),
1059            symmetry: Symmetry::None,
1060            numerator_coefficients: vec![0.1, 0.2, 0.4, 0.2, 0.1],
1061        };
1062
1063        let sample_rate = 100.0;
1064        let freqs = [0.0, 1.0, 5.0, 20.0, 40.0];
1065        let got = eval_fir(&fir_odd, 1.0, sample_rate, &freqs);
1066        let want = eval_fir(&fir_ref, 1.0, sample_rate, &freqs);
1067
1068        for (i, (g, w)) in got.iter().zip(want.iter()).enumerate() {
1069            let rel = ((g - w) / w.max(1e-30)).abs();
1070            assert!(
1071                rel < 1e-9,
1072                "Odd-symmetry FIR must equal its explicit symmetric expansion \
1073                 at bin {i} (f={:.1}): got {g:.6e}, want {w:.6e}, rel_err={rel:.2e}",
1074                freqs[i]
1075            );
1076        }
1077        // A normalized symmetric low-pass must have |H(DC)|² ≈ gain² = 1.
1078        assert!(
1079            (got[0] - 1.0).abs() < 1e-9,
1080            "DC gain² must be ~1 for a normalized symmetric FIR, got {}",
1081            got[0]
1082        );
1083    }
1084
1085    // -- Full PSD (PAZ only) --
1086
1087    #[test]
1088    fn test_full_psd() {
1089        let path = test_vectors_dir().join("full_psd.json");
1090        let data: serde_json::Value =
1091            serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap();
1092
1093        let sample_rate = data["sample_rate"].as_f64().unwrap();
1094        let seg_len = data["seg_len"].as_u64().unwrap() as usize;
1095        let nfft = data["nfft"].as_u64().unwrap() as usize;
1096        let nlap = data["nlap"].as_u64().unwrap() as usize;
1097        let overlap = data["overlap"].as_f64().unwrap();
1098        let n_segments = data["n_segments"].as_u64().unwrap() as usize;
1099
1100        let input_data = json_f64_vec(&data, "input_data");
1101        let psd_periods = json_f64_vec(&data, "psd_periods");
1102        let bin_left = json_f64_vec(&data, "period_bin_left");
1103        let bin_right = json_f64_vec(&data, "period_bin_right");
1104
1105        let response = response_from_json(&data["response"]);
1106
1107        let expected_db = json_option_f64_matrix(&data, "psd_values_db");
1108        let step = (seg_len as f64 * (1.0 - overlap)) as usize;
1109
1110        for (seg_idx, expected_seg) in expected_db.iter().enumerate().take(n_segments) {
1111            let start = seg_idx * step;
1112            let segment = &input_data[start..start + seg_len];
1113
1114            let result = process_segment(
1115                segment,
1116                sample_rate,
1117                nfft,
1118                nlap,
1119                &response,
1120                &psd_periods,
1121                &bin_left,
1122                &bin_right,
1123            )
1124            .unwrap();
1125
1126            assert_psd_match(&result, expected_seg, seg_idx, "PAZ-only");
1127        }
1128    }
1129
1130    // -- Konno-Ohmachi smoothing --
1131
1132    #[test]
1133    fn test_konno_ohmachi() {
1134        let path = test_vectors_dir().join("konno_ohmachi.json");
1135        let data: serde_json::Value =
1136            serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap();
1137
1138        let freqs = json_f64_vec(&data, "freqs");
1139        let spectrum = json_f64_vec(&data, "spectrum");
1140        let bandwidth = data["bandwidth"].as_f64().unwrap();
1141        let expected = json_f64_vec(&data, "smoothed");
1142
1143        let result = konno_ohmachi_smooth(&freqs, &spectrum, bandwidth);
1144
1145        assert_eq!(result.len(), expected.len());
1146        for (i, (r, e)) in result.iter().zip(expected.iter()).enumerate() {
1147            let tol = e.abs() * 1e-4 + 1e-20;
1148            assert!(
1149                (r - e).abs() < tol,
1150                "smoothing mismatch at bin {i} (freq={:.4}): got {r:.6e}, expected {e:.6e}",
1151                freqs[i]
1152            );
1153        }
1154    }
1155
1156    // -- Sample-rate validation guards --
1157
1158    fn make_units() -> stationxml_rs::Units {
1159        stationxml_rs::Units {
1160            name: "COUNTS".into(),
1161            description: None,
1162        }
1163    }
1164
1165    fn fir_stage_with_sample_rate(sample_rate: f64) -> ResponseStage {
1166        ResponseStage {
1167            number: 1,
1168            stage_gain: Some(StageGain {
1169                value: 1.0,
1170                frequency: 1.0,
1171            }),
1172            poles_zeros: None,
1173            coefficients: None,
1174            fir: Some(FIR {
1175                input_units: make_units(),
1176                output_units: make_units(),
1177                symmetry: Symmetry::None,
1178                numerator_coefficients: vec![0.25, 0.5, 0.25],
1179            }),
1180            decimation: Some(Decimation {
1181                input_sample_rate: sample_rate,
1182                factor: 1,
1183                offset: 0,
1184                delay: 0.0,
1185                correction: 0.0,
1186            }),
1187        }
1188    }
1189
1190    fn digital_coeffs_stage_with_sample_rate(sample_rate: f64) -> ResponseStage {
1191        ResponseStage {
1192            number: 1,
1193            stage_gain: Some(StageGain {
1194                value: 1.0,
1195                frequency: 1.0,
1196            }),
1197            poles_zeros: None,
1198            fir: None,
1199            coefficients: Some(Coefficients {
1200                input_units: make_units(),
1201                output_units: make_units(),
1202                cf_transfer_function_type: CfTransferFunction::Digital,
1203                numerators: vec![0.25, 0.5, 0.25],
1204                denominators: vec![],
1205            }),
1206            decimation: Some(Decimation {
1207                input_sample_rate: sample_rate,
1208                factor: 1,
1209                offset: 0,
1210                delay: 0.0,
1211                correction: 0.0,
1212            }),
1213        }
1214    }
1215
1216    #[test]
1217    fn test_eval_response_rejects_zero_sample_rate_fir() {
1218        let response = Response {
1219            instrument_sensitivity: None,
1220            stages: vec![fir_stage_with_sample_rate(0.0)],
1221        };
1222        let freqs = vec![0.1, 0.5, 1.0];
1223        let err = eval_response(&response, &freqs).unwrap_err();
1224        assert!(
1225            matches!(err, PpsdError::InvalidSampleRate(1)),
1226            "expected InvalidSampleRate(1), got {err:?}"
1227        );
1228    }
1229
1230    #[test]
1231    fn test_eval_response_rejects_negative_sample_rate_fir() {
1232        let response = Response {
1233            instrument_sensitivity: None,
1234            stages: vec![fir_stage_with_sample_rate(-100.0)],
1235        };
1236        let freqs = vec![0.1, 0.5, 1.0];
1237        let err = eval_response(&response, &freqs).unwrap_err();
1238        assert!(matches!(err, PpsdError::InvalidSampleRate(1)));
1239    }
1240
1241    #[test]
1242    fn test_eval_response_rejects_nan_sample_rate_fir() {
1243        let response = Response {
1244            instrument_sensitivity: None,
1245            stages: vec![fir_stage_with_sample_rate(f64::NAN)],
1246        };
1247        let freqs = vec![0.1, 0.5, 1.0];
1248        let err = eval_response(&response, &freqs).unwrap_err();
1249        assert!(matches!(err, PpsdError::InvalidSampleRate(1)));
1250    }
1251
1252    #[test]
1253    fn test_eval_response_rejects_zero_sample_rate_coefficients() {
1254        let response = Response {
1255            instrument_sensitivity: None,
1256            stages: vec![digital_coeffs_stage_with_sample_rate(0.0)],
1257        };
1258        let freqs = vec![0.1, 0.5, 1.0];
1259        let err = eval_response(&response, &freqs).unwrap_err();
1260        assert!(
1261            matches!(err, PpsdError::InvalidSampleRate(1)),
1262            "expected InvalidSampleRate(1), got {err:?}"
1263        );
1264    }
1265
1266    // -- Full PSD with FIR --
1267
1268    #[test]
1269    fn test_full_psd_with_fir() {
1270        let path = test_vectors_dir().join("full_psd_with_fir.json");
1271        let data: serde_json::Value =
1272            serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap();
1273
1274        let sample_rate = data["sample_rate"].as_f64().unwrap();
1275        let seg_len = data["seg_len"].as_u64().unwrap() as usize;
1276        let nfft = data["nfft"].as_u64().unwrap() as usize;
1277        let nlap = data["nlap"].as_u64().unwrap() as usize;
1278        let overlap = data["overlap"].as_f64().unwrap();
1279        let n_segments = data["n_segments"].as_u64().unwrap() as usize;
1280
1281        let input_data = json_f64_vec(&data, "input_data");
1282        let psd_periods = json_f64_vec(&data, "psd_periods");
1283        let bin_left = json_f64_vec(&data, "period_bin_left");
1284        let bin_right = json_f64_vec(&data, "period_bin_right");
1285
1286        // Build Response: PAZ stage + FIR stages
1287        let mut response = response_from_json(&data["response"]);
1288        let fir_stages = fir_stages_from_json(&data["fir_stages"]);
1289        response.stages.extend(fir_stages);
1290
1291        let expected_db = json_option_f64_matrix(&data, "psd_values_db");
1292        assert!(n_segments > 0, "expected at least 1 segment in test vector");
1293        let step = (seg_len as f64 * (1.0 - overlap)) as usize;
1294
1295        for (seg_idx, expected_seg) in expected_db.iter().enumerate().take(n_segments) {
1296            let start = seg_idx * step;
1297            let segment = &input_data[start..start + seg_len];
1298
1299            let result = process_segment(
1300                segment,
1301                sample_rate,
1302                nfft,
1303                nlap,
1304                &response,
1305                &psd_periods,
1306                &bin_left,
1307                &bin_right,
1308            )
1309            .unwrap();
1310
1311            assert_psd_match(&result, expected_seg, seg_idx, "PAZ+FIR");
1312        }
1313    }
1314}