1#![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#[derive(Debug, thiserror::Error)]
72pub enum PpsdError {
73 #[error("response has no stages")]
75 EmptyResponse,
76
77 #[error("unsupported transfer function type in stage {stage}: {detail}")]
79 UnsupportedStage {
80 stage: u32,
82 detail: String,
84 },
85
86 #[error("stage {0} has FIR/Coefficients but no decimation info")]
88 MissingDecimation(u32),
89
90 #[error("stage {0} has invalid sample rate (must be finite and > 0)")]
96 InvalidSampleRate(u32),
97}
98
99pub type Result<T> = std::result::Result<T, PpsdError>;
101
102pub 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 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 for val in taper.iter_mut().take(idx3).skip(idx2 + 1) {
152 *val = 1.0;
153 }
154 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
163pub 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(); 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 if i > 0 && i < n_freqs - 1 {
197 p *= 2.0;
198 }
199 power.push(p);
200 }
201
202 (freqs, power)
203}
204
205fn 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() })
241 .collect())
242}
243
244fn dtft_power_normalized(
248 coeffs: &[f64],
249 stage_gain: f64,
250 sample_rate: f64,
251 freqs: &[f64],
252) -> Vec<f64> {
253 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
278fn 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 full.extend(fir.numerator_coefficients.iter().rev().skip(1).copied());
307 dtft_power_normalized(&full, stage_gain, sample_rate, freqs)
308 }
309 }
310}
311
312fn stage_sample_rate(stage: &ResponseStage) -> Option<f64> {
314 stage.decimation.as_ref().map(|d| d.input_sample_rate)
315}
316
317pub 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 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
403pub 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 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
455pub 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
534fn 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
563pub 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
604pub 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#[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 let (freqs, power) = welch_psd(segment, nfft, sample_rate, nlap);
697
698 let spec: Vec<f64> = power[1..].iter().rev().copied().collect();
700
701 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]; 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 Ok(period_bin_average(
718 psd_periods,
719 &spec_db,
720 bin_left,
721 bin_right,
722 ))
723}
724
725#[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 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 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 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 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 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 #[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 #[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 #[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 #[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 #[test]
1037 fn test_fir_symmetry_odd_is_symmetric_not_negated() {
1038 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 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 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 #[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 #[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 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 #[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 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}