Skip to main content

oxiphysics_io/
seismic_io.rs

1#![allow(clippy::needless_range_loop)]
2// Copyright 2026 COOLJAPAN OU (Team KitaSan)
3// SPDX-License-Identifier: Apache-2.0
4//! Seismic data I/O: SEG-Y, SAC, MiniSEED formats.
5//!
6//! Provides structures for reading and writing common seismic data formats
7//! including SEG-Y rev 1, SAC (Seismic Analysis Code), and MiniSEED.
8//! Also includes signal processing tools like STA/LTA arrival pickers
9//! and Butterworth bandpass filters.
10
11use std::collections::HashMap;
12
13// ── SEG-Y Format ──────────────────────────────────────────────────────────────
14
15/// EBCDIC-encoded textual header for SEG-Y files (3200 bytes, 40 lines × 80 chars).
16#[allow(dead_code)]
17#[derive(Debug, Clone)]
18pub struct SegyTextHeader {
19    /// Raw EBCDIC bytes (3200 bytes).
20    pub bytes: Vec<u8>,
21}
22
23impl SegyTextHeader {
24    /// Create a new textual header filled with spaces (EBCDIC 0x40).
25    pub fn new() -> Self {
26        Self {
27            bytes: vec![0x40u8; 3200],
28        }
29    }
30
31    /// Write an ASCII line into the textual header at the given line index (0-based).
32    ///
33    /// Lines are 80 characters wide; the input is truncated or padded as needed.
34    pub fn set_line(&mut self, line_idx: usize, text: &str) {
35        if line_idx >= 40 {
36            return;
37        }
38        let start = line_idx * 80;
39        let ascii_bytes = text.as_bytes();
40        for i in 0..80 {
41            let ch = if i < ascii_bytes.len() {
42                ascii_bytes[i]
43            } else {
44                b' '
45            };
46            self.bytes[start + i] = ascii_to_ebcdic(ch);
47        }
48    }
49
50    /// Read a line from the textual header as an ASCII string.
51    pub fn get_line(&self, line_idx: usize) -> String {
52        if line_idx >= 40 {
53            return String::new();
54        }
55        let start = line_idx * 80;
56        let chars: Vec<u8> = self.bytes[start..start + 80]
57            .iter()
58            .map(|&b| ebcdic_to_ascii(b))
59            .collect();
60        String::from_utf8_lossy(&chars).trim_end().to_string()
61    }
62}
63
64impl Default for SegyTextHeader {
65    fn default() -> Self {
66        Self::new()
67    }
68}
69
70/// Convert ASCII byte to EBCDIC (partial table for printable ASCII).
71#[allow(dead_code)]
72fn ascii_to_ebcdic(ch: u8) -> u8 {
73    match ch {
74        b' ' => 0x40,
75        b'0'..=b'9' => ch - b'0' + 0xF0,
76        b'A'..=b'I' => ch - b'A' + 0xC1,
77        b'J'..=b'R' => ch - b'J' + 0xD1,
78        b'S'..=b'Z' => ch - b'S' + 0xE2,
79        b'a'..=b'i' => ch - b'a' + 0x81,
80        b'j'..=b'r' => ch - b'j' + 0x91,
81        b's'..=b'z' => ch - b's' + 0xA2,
82        b'.' => 0x4B,
83        b',' => 0x6B,
84        b':' => 0x7A,
85        _ => 0x40,
86    }
87}
88
89/// Convert EBCDIC byte to ASCII (partial table).
90#[allow(dead_code)]
91fn ebcdic_to_ascii(b: u8) -> u8 {
92    match b {
93        0x40 => b' ',
94        0xF0..=0xF9 => b - 0xF0 + b'0',
95        0xC1..=0xC9 => b - 0xC1 + b'A',
96        0xD1..=0xD9 => b - 0xD1 + b'J',
97        0xE2..=0xE9 => b - 0xE2 + b'S',
98        0x81..=0x89 => b - 0x81 + b'a',
99        0x91..=0x99 => b - 0x91 + b'j',
100        0xA2..=0xA9 => b - 0xA2 + b's',
101        0x4B => b'.',
102        0x6B => b',',
103        0x7A => b':',
104        _ => b'?',
105    }
106}
107
108/// SEG-Y binary file header (400 bytes).
109///
110/// Contains survey-wide parameters like sample interval, number of samples,
111/// and data sample format code.
112#[allow(dead_code)]
113#[derive(Debug, Clone)]
114pub struct SegyBinaryHeader {
115    /// Job identification number.
116    pub job_id: i32,
117    /// Line number.
118    pub line_number: i32,
119    /// Reel number.
120    pub reel_number: i32,
121    /// Number of data traces per record.
122    pub traces_per_record: i16,
123    /// Number of auxiliary traces per record.
124    pub aux_traces_per_record: i16,
125    /// Sample interval in microseconds.
126    pub sample_interval_us: i16,
127    /// Number of samples per data trace.
128    pub samples_per_trace: i16,
129    /// Data sample format code: 1=IBM float, 5=IEEE float.
130    pub data_format_code: i16,
131    /// SEG-Y revision: 0 = rev 0, 256 = rev 1.
132    pub segy_revision: i16,
133    /// Fixed length trace flag.
134    pub fixed_length_flag: i16,
135}
136
137impl Default for SegyBinaryHeader {
138    fn default() -> Self {
139        Self {
140            job_id: 1,
141            line_number: 1,
142            reel_number: 1,
143            traces_per_record: 1,
144            aux_traces_per_record: 0,
145            sample_interval_us: 2000, // 2 ms
146            samples_per_trace: 1000,
147            data_format_code: 5, // IEEE float
148            segy_revision: 256,  // rev 1
149            fixed_length_flag: 1,
150        }
151    }
152}
153
154/// SEG-Y trace header (240 bytes).
155///
156/// Contains per-trace metadata including shot and receiver coordinates.
157#[allow(dead_code)]
158#[derive(Debug, Clone)]
159pub struct SegyTraceHeader {
160    /// Trace sequence number within the SEG-Y file.
161    pub trace_seq_file: i32,
162    /// Field record number.
163    pub field_record_number: i32,
164    /// Trace number within field record.
165    pub trace_number_in_field: i32,
166    /// Source X coordinate (scaled by coordinate scalar).
167    pub source_x: i32,
168    /// Source Y coordinate.
169    pub source_y: i32,
170    /// Group (receiver) X coordinate.
171    pub group_x: i32,
172    /// Group (receiver) Y coordinate.
173    pub group_y: i32,
174    /// Coordinate scalar: if negative, divide; if positive, multiply.
175    pub coordinate_scalar: i16,
176    /// Delay recording time in milliseconds.
177    pub delay_recording_time_ms: i16,
178    /// Number of samples in this trace.
179    pub num_samples: i16,
180    /// Sample interval in microseconds for this trace.
181    pub sample_interval_us: i16,
182    /// Year data recorded.
183    pub year: i16,
184    /// Day of year.
185    pub day_of_year: i16,
186    /// Hour of day (24-hour clock).
187    pub hour: i16,
188    /// Minute of hour.
189    pub minute: i16,
190    /// Second of minute.
191    pub second: i16,
192}
193
194impl Default for SegyTraceHeader {
195    fn default() -> Self {
196        Self {
197            trace_seq_file: 1,
198            field_record_number: 1,
199            trace_number_in_field: 1,
200            source_x: 0,
201            source_y: 0,
202            group_x: 0,
203            group_y: 0,
204            coordinate_scalar: -100,
205            delay_recording_time_ms: 0,
206            num_samples: 1000,
207            sample_interval_us: 2000,
208            year: 2026,
209            day_of_year: 1,
210            hour: 0,
211            minute: 0,
212            second: 0,
213        }
214    }
215}
216
217/// A single SEG-Y trace with header and float32 sample data.
218#[allow(dead_code)]
219#[derive(Debug, Clone)]
220pub struct SegyTrace {
221    /// Trace header metadata.
222    pub header: SegyTraceHeader,
223    /// Sample data as IEEE float32.
224    pub data: Vec<f32>,
225}
226
227impl SegyTrace {
228    /// Create a new trace with the given data and a default header.
229    pub fn new(data: Vec<f32>) -> Self {
230        let num_samples = data.len() as i16;
231        let header = SegyTraceHeader {
232            num_samples,
233            ..Default::default()
234        };
235        Self { header, data }
236    }
237
238    /// Return the number of samples in this trace.
239    pub fn num_samples(&self) -> usize {
240        self.data.len()
241    }
242
243    /// Return the sample interval in seconds.
244    pub fn sample_interval_s(&self) -> f64 {
245        self.header.sample_interval_us as f64 * 1e-6
246    }
247}
248
249/// A complete SEG-Y file in memory.
250#[allow(dead_code)]
251#[derive(Debug, Clone)]
252pub struct SegyFile {
253    /// Textual (EBCDIC) header.
254    pub text_header: SegyTextHeader,
255    /// Binary file header.
256    pub binary_header: SegyBinaryHeader,
257    /// All traces in this file.
258    pub traces: Vec<SegyTrace>,
259}
260
261impl SegyFile {
262    /// Create a new empty SEG-Y file.
263    pub fn new() -> Self {
264        Self {
265            text_header: SegyTextHeader::new(),
266            binary_header: SegyBinaryHeader::default(),
267            traces: Vec::new(),
268        }
269    }
270
271    /// Add a trace to the file, updating the trace sequence number.
272    pub fn add_trace(&mut self, mut trace: SegyTrace) {
273        let idx = self.traces.len() as i32 + 1;
274        trace.header.trace_seq_file = idx;
275        self.traces.push(trace);
276    }
277
278    /// Return the number of traces.
279    pub fn num_traces(&self) -> usize {
280        self.traces.len()
281    }
282
283    /// Serialize header fields to a byte buffer (simplified, not full 3600-byte header).
284    pub fn serialize_binary_header(&self) -> Vec<u8> {
285        let h = &self.binary_header;
286        let mut buf = vec![0u8; 400];
287        write_i32_be(&mut buf, 0, h.job_id);
288        write_i32_be(&mut buf, 4, h.line_number);
289        write_i32_be(&mut buf, 8, h.reel_number);
290        write_i16_be(&mut buf, 12, h.traces_per_record);
291        write_i16_be(&mut buf, 14, h.aux_traces_per_record);
292        write_i16_be(&mut buf, 16, h.sample_interval_us);
293        write_i16_be(&mut buf, 20, h.samples_per_trace);
294        write_i16_be(&mut buf, 24, h.data_format_code);
295        write_i16_be(&mut buf, 300, h.segy_revision);
296        write_i16_be(&mut buf, 302, h.fixed_length_flag);
297        buf
298    }
299
300    /// Deserialize binary header from a 400-byte buffer.
301    pub fn deserialize_binary_header(buf: &[u8]) -> SegyBinaryHeader {
302        SegyBinaryHeader {
303            job_id: read_i32_be(buf, 0),
304            line_number: read_i32_be(buf, 4),
305            reel_number: read_i32_be(buf, 8),
306            traces_per_record: read_i16_be(buf, 12),
307            aux_traces_per_record: read_i16_be(buf, 14),
308            sample_interval_us: read_i16_be(buf, 16),
309            samples_per_trace: read_i16_be(buf, 20),
310            data_format_code: read_i16_be(buf, 24),
311            segy_revision: read_i16_be(buf, 300),
312            fixed_length_flag: read_i16_be(buf, 302),
313        }
314    }
315}
316
317impl Default for SegyFile {
318    fn default() -> Self {
319        Self::new()
320    }
321}
322
323// ── SAC Format ────────────────────────────────────────────────────────────────
324
325/// SAC (Seismic Analysis Code) file header.
326///
327/// The SAC header is 632 bytes containing float, integer, logical, enumerated,
328/// and character fields.
329#[allow(dead_code)]
330#[derive(Debug, Clone)]
331pub struct SacHeader {
332    /// Sampling interval in seconds (delta).
333    pub delta: f32,
334    /// Minimum value of dependent variable.
335    pub depmin: f32,
336    /// Maximum value of dependent variable.
337    pub depmax: f32,
338    /// Begin time of the independent variable (seconds).
339    pub b: f32,
340    /// End time of the independent variable (seconds).
341    pub e: f32,
342    /// Event origin time (seconds relative to reference).
343    pub o: f32,
344    /// First arrival time (seconds).
345    pub a: f32,
346    /// Station-event distance in km.
347    pub dist: f32,
348    /// Event-to-station azimuth in degrees.
349    pub az: f32,
350    /// Back azimuth in degrees.
351    pub baz: f32,
352    /// Component azimuth (degrees from North, clockwise).
353    pub cmpaz: f32,
354    /// Component inclination (degrees from vertical down).
355    pub cmpinc: f32,
356    /// Station latitude in degrees.
357    pub stla: f32,
358    /// Station longitude in degrees.
359    pub stlo: f32,
360    /// Station elevation in meters.
361    pub stel: f32,
362    /// Event latitude in degrees.
363    pub evla: f32,
364    /// Event longitude in degrees.
365    pub evlo: f32,
366    /// Event depth in km.
367    pub evdp: f32,
368    /// Event magnitude.
369    pub mag: f32,
370    /// Number of points (samples).
371    pub npts: i32,
372    /// Beginning time year.
373    pub nzyear: i32,
374    /// Beginning time day-of-year.
375    pub nzjday: i32,
376    /// Beginning time hour.
377    pub nzhour: i32,
378    /// Beginning time minute.
379    pub nzmin: i32,
380    /// Beginning time second.
381    pub nzsec: i32,
382    /// Beginning time millisecond.
383    pub nzmsec: i32,
384    /// SAC version number.
385    pub nvhdr: i32,
386    /// Station name (up to 8 characters).
387    pub kstnm: String,
388    /// Channel name (up to 8 characters).
389    pub kcmpnm: String,
390    /// Network name (up to 8 characters).
391    pub knetwk: String,
392}
393
394impl Default for SacHeader {
395    fn default() -> Self {
396        Self {
397            delta: 0.01,
398            depmin: -1e20,
399            depmax: 1e20,
400            b: 0.0,
401            e: 0.0,
402            o: -12345.0,
403            a: -12345.0,
404            dist: -12345.0,
405            az: -12345.0,
406            baz: -12345.0,
407            cmpaz: 0.0,
408            cmpinc: 0.0,
409            stla: -12345.0,
410            stlo: -12345.0,
411            stel: -12345.0,
412            evla: -12345.0,
413            evlo: -12345.0,
414            evdp: -12345.0,
415            mag: -12345.0,
416            npts: 0,
417            nzyear: 2026,
418            nzjday: 1,
419            nzhour: 0,
420            nzmin: 0,
421            nzsec: 0,
422            nzmsec: 0,
423            nvhdr: 6,
424            kstnm: String::from("-12345  "),
425            kcmpnm: String::from("-12345  "),
426            knetwk: String::from("-12345  "),
427        }
428    }
429}
430
431/// A complete SAC file with header and time-series data.
432#[allow(dead_code)]
433#[derive(Debug, Clone)]
434pub struct SacFile {
435    /// SAC header.
436    pub header: SacHeader,
437    /// Time-series samples (float32).
438    pub data: Vec<f32>,
439}
440
441impl SacFile {
442    /// Create a new SAC file from data and a sampling interval.
443    pub fn from_data(data: Vec<f32>, delta: f32) -> Self {
444        let npts = data.len() as i32;
445        let e = if npts > 0 {
446            (npts - 1) as f32 * delta
447        } else {
448            0.0
449        };
450        let depmin = data.iter().cloned().fold(f32::MAX, f32::min);
451        let depmax = data.iter().cloned().fold(f32::MIN, f32::max);
452        let header = SacHeader {
453            delta,
454            npts,
455            b: 0.0,
456            e,
457            depmin,
458            depmax,
459            ..Default::default()
460        };
461        Self { header, data }
462    }
463
464    /// Return the end time in seconds.
465    pub fn end_time(&self) -> f64 {
466        self.header.b as f64 + (self.header.npts - 1) as f64 * self.header.delta as f64
467    }
468
469    /// Serialize the SAC header to 632 bytes (simplified).
470    pub fn serialize_header(&self) -> Vec<u8> {
471        let mut buf = vec![0u8; 632];
472        write_f32_le(&mut buf, 0, self.header.delta);
473        write_f32_le(&mut buf, 4, self.header.depmin);
474        write_f32_le(&mut buf, 8, self.header.depmax);
475        write_f32_le(&mut buf, 20, self.header.b);
476        write_f32_le(&mut buf, 24, self.header.e);
477        write_i32_le(&mut buf, 316, self.header.npts);
478        write_i32_le(&mut buf, 284, self.header.nvhdr);
479        buf
480    }
481
482    /// Deserialize npts from a SAC 632-byte header.
483    pub fn npts_from_header(buf: &[u8]) -> i32 {
484        read_i32_le(buf, 316)
485    }
486
487    /// Return the number of samples.
488    pub fn num_samples(&self) -> usize {
489        self.data.len()
490    }
491}
492
493// ── MiniSEED Format ───────────────────────────────────────────────────────────
494
495/// MiniSEED fixed record header (48 bytes).
496///
497/// MiniSEED is a subset of SEED (Standard for the Exchange of Earthquake Data).
498#[allow(dead_code)]
499#[derive(Debug, Clone)]
500pub struct MiniSeedHeader {
501    /// Sequence number (6 ASCII digits, 000001–999999).
502    pub sequence_number: u32,
503    /// Data quality indicator ('D', 'R', 'Q', 'M').
504    pub quality_indicator: u8,
505    /// Station identifier (5 characters).
506    pub station: String,
507    /// Location identifier (2 characters).
508    pub location: String,
509    /// Channel identifier (3 characters).
510    pub channel: String,
511    /// Network identifier (2 characters).
512    pub network: String,
513    /// Record start time: year.
514    pub start_year: u16,
515    /// Record start time: day-of-year.
516    pub start_day: u16,
517    /// Record start time: hour.
518    pub start_hour: u8,
519    /// Record start time: minute.
520    pub start_min: u8,
521    /// Record start time: second.
522    pub start_sec: u8,
523    /// Record start time: tenths of milliseconds (0–9999).
524    pub start_frac: u16,
525    /// Number of samples in this record.
526    pub num_samples: u16,
527    /// Sample rate factor.
528    pub sample_rate_factor: i16,
529    /// Sample rate multiplier.
530    pub sample_rate_multiplier: i16,
531    /// Activity flags.
532    pub activity_flags: u8,
533    /// I/O and clock flags.
534    pub io_flags: u8,
535    /// Data quality flags.
536    pub data_quality_flags: u8,
537    /// Number of blockettes following.
538    pub num_blockettes: u8,
539    /// Time correction in units of 0.0001 seconds.
540    pub time_correction: i32,
541    /// Byte offset to beginning of data.
542    pub data_offset: u16,
543    /// Byte offset to first blockette.
544    pub blockette_offset: u16,
545}
546
547impl Default for MiniSeedHeader {
548    fn default() -> Self {
549        Self {
550            sequence_number: 1,
551            quality_indicator: b'D',
552            station: String::from("XXXXX"),
553            location: String::from("  "),
554            channel: String::from("BHZ"),
555            network: String::from("XX"),
556            start_year: 2026,
557            start_day: 1,
558            start_hour: 0,
559            start_min: 0,
560            start_sec: 0,
561            start_frac: 0,
562            num_samples: 0,
563            sample_rate_factor: 100,
564            sample_rate_multiplier: 1,
565            activity_flags: 0,
566            io_flags: 0,
567            data_quality_flags: 0,
568            num_blockettes: 0,
569            time_correction: 0,
570            data_offset: 64,
571            blockette_offset: 0,
572        }
573    }
574}
575
576impl MiniSeedHeader {
577    /// Compute the true sample rate in samples/second.
578    pub fn sample_rate(&self) -> f64 {
579        let factor = self.sample_rate_factor as f64;
580        let mult = self.sample_rate_multiplier as f64;
581        if factor > 0.0 && mult > 0.0 {
582            factor * mult
583        } else if factor > 0.0 && mult < 0.0 {
584            -factor / mult
585        } else if factor < 0.0 && mult > 0.0 {
586            -mult / factor
587        } else {
588            1.0 / (factor * mult)
589        }
590    }
591}
592
593/// Steim-1 compression: run-length encoded integer differences.
594///
595/// This is a simplified implementation for testing purposes.
596#[allow(dead_code)]
597#[derive(Debug, Clone)]
598pub struct Steim1Frame {
599    /// Word 0 contains a bitfield indicating how each of the 15 following words
600    /// is encoded. Bits 0–1 for word 15, bits 28–29 for word 1.
601    pub words: [u32; 16],
602}
603
604impl Steim1Frame {
605    /// Create a Steim-1 frame encoding integer differences.
606    ///
607    /// Differences are encoded using 2-bit type codes:
608    /// - 0: no data
609    /// - 1: 4 bytes per difference (32-bit)
610    /// - 2: 2 bytes per difference (16-bit)
611    /// - 3: 1 byte per difference (8-bit)
612    pub fn encode_diffs(diffs: &[i32]) -> Vec<Self> {
613        let mut frames = Vec::new();
614        let mut pos = 0;
615        while pos < diffs.len() {
616            let mut frame = Steim1Frame { words: [0u32; 16] };
617            let mut word_idx = 1usize;
618            while word_idx < 16 && pos < diffs.len() {
619                let d = diffs[pos];
620                if (-128..=127).contains(&d) {
621                    // 8-bit, type=3
622                    frame.words[0] |= 3 << (30 - 2 * word_idx);
623                    frame.words[word_idx] = (d as i8 as u8) as u32;
624                } else if (-32768..=32767).contains(&d) {
625                    // 16-bit, type=2
626                    frame.words[0] |= 2 << (30 - 2 * word_idx);
627                    frame.words[word_idx] = (d as i16 as u16) as u32;
628                } else {
629                    // 32-bit, type=1
630                    frame.words[0] |= 1 << (30 - 2 * word_idx);
631                    frame.words[word_idx] = d as u32;
632                }
633                word_idx += 1;
634                pos += 1;
635            }
636            frames.push(frame);
637        }
638        frames
639    }
640}
641
642/// A MiniSEED record with header and integer sample data.
643#[allow(dead_code)]
644#[derive(Debug, Clone)]
645pub struct MiniSeedRecord {
646    /// Fixed record header.
647    pub header: MiniSeedHeader,
648    /// Decompressed integer sample data.
649    pub samples: Vec<i32>,
650    /// Steim-1 compressed frames.
651    pub steim1_frames: Vec<Steim1Frame>,
652}
653
654impl MiniSeedRecord {
655    /// Create a new record from integer samples.
656    pub fn from_samples(samples: Vec<i32>, station: &str, channel: &str, network: &str) -> Self {
657        let header = MiniSeedHeader {
658            station: station.to_string(),
659            channel: channel.to_string(),
660            network: network.to_string(),
661            num_samples: samples.len() as u16,
662            ..Default::default()
663        };
664        // Compute Steim-1 differences
665        let mut diffs = vec![samples.first().copied().unwrap_or(0)];
666        for i in 1..samples.len() {
667            diffs.push(samples[i] - samples[i - 1]);
668        }
669        let steim1_frames = Steim1Frame::encode_diffs(&diffs);
670        Self {
671            header,
672            samples,
673            steim1_frames,
674        }
675    }
676
677    /// Increment the sequence number.
678    pub fn next_sequence(&mut self) {
679        self.header.sequence_number = (self.header.sequence_number % 999999) + 1;
680    }
681}
682
683// ── SeismicTrace ──────────────────────────────────────────────────────────────
684
685/// A seismic time series with associated metadata.
686#[allow(dead_code)]
687#[derive(Debug, Clone)]
688pub struct SeismicTrace {
689    /// Network code (2 characters).
690    pub network: String,
691    /// Station code (up to 5 characters).
692    pub station: String,
693    /// Location code (2 characters).
694    pub location: String,
695    /// Channel code (3 characters).
696    pub channel: String,
697    /// Sampling rate in samples per second.
698    pub sampling_rate: f64,
699    /// Start time as Unix timestamp (seconds since 1970-01-01).
700    pub start_time: f64,
701    /// Time-series samples.
702    pub data: Vec<f64>,
703}
704
705impl SeismicTrace {
706    /// Create a new seismic trace.
707    pub fn new(
708        network: &str,
709        station: &str,
710        channel: &str,
711        sampling_rate: f64,
712        start_time: f64,
713        data: Vec<f64>,
714    ) -> Self {
715        Self {
716            network: network.to_string(),
717            station: station.to_string(),
718            location: String::from("  "),
719            channel: channel.to_string(),
720            sampling_rate,
721            start_time,
722            data,
723        }
724    }
725
726    /// Return the end time in seconds.
727    pub fn end_time(&self) -> f64 {
728        if self.data.is_empty() {
729            self.start_time
730        } else {
731            self.start_time + (self.data.len() - 1) as f64 / self.sampling_rate
732        }
733    }
734
735    /// Return the duration in seconds.
736    pub fn duration(&self) -> f64 {
737        self.end_time() - self.start_time
738    }
739
740    /// Return the SEED channel identifier as "NET.STA.LOC.CHA".
741    pub fn seed_id(&self) -> String {
742        format!(
743            "{}.{}.{}.{}",
744            self.network, self.station, self.location, self.channel
745        )
746    }
747
748    /// Compute the RMS amplitude of the trace.
749    pub fn rms(&self) -> f64 {
750        if self.data.is_empty() {
751            return 0.0;
752        }
753        let sum2: f64 = self.data.iter().map(|&x| x * x).sum();
754        (sum2 / self.data.len() as f64).sqrt()
755    }
756}
757
758// ── SeismicStation ────────────────────────────────────────────────────────────
759
760/// A seismic station with geographic coordinates.
761#[allow(dead_code)]
762#[derive(Debug, Clone)]
763pub struct SeismicStation {
764    /// Network code.
765    pub network: String,
766    /// Station code.
767    pub station: String,
768    /// Latitude in decimal degrees (positive = North).
769    pub latitude: f64,
770    /// Longitude in decimal degrees (positive = East).
771    pub longitude: f64,
772    /// Elevation in meters above sea level.
773    pub elevation: f64,
774    /// Available channel codes.
775    pub channels: Vec<String>,
776}
777
778impl SeismicStation {
779    /// Create a new station.
780    pub fn new(network: &str, station: &str, lat: f64, lon: f64, elevation: f64) -> Self {
781        Self {
782            network: network.to_string(),
783            station: station.to_string(),
784            latitude: lat,
785            longitude: lon,
786            elevation,
787            channels: Vec::new(),
788        }
789    }
790
791    /// Compute the great-circle distance to another station in kilometers.
792    ///
793    /// Uses the Haversine formula.
794    pub fn distance_km(&self, other: &SeismicStation) -> f64 {
795        haversine_km(
796            self.latitude,
797            self.longitude,
798            other.latitude,
799            other.longitude,
800        )
801    }
802
803    /// Compute the epicentral distance from an event location in kilometers.
804    pub fn epicentral_distance_km(&self, event_lat: f64, event_lon: f64) -> f64 {
805        haversine_km(self.latitude, self.longitude, event_lat, event_lon)
806    }
807}
808
809/// Haversine great-circle distance formula.
810///
811/// Returns distance in kilometers between two points (lat/lon in degrees).
812#[allow(dead_code)]
813pub fn haversine_km(lat1: f64, lon1: f64, lat2: f64, lon2: f64) -> f64 {
814    const R_KM: f64 = 6371.0;
815    let d_lat = (lat2 - lat1).to_radians();
816    let d_lon = (lon2 - lon1).to_radians();
817    let a = (d_lat / 2.0).sin().powi(2)
818        + lat1.to_radians().cos() * lat2.to_radians().cos() * (d_lon / 2.0).sin().powi(2);
819    let c = 2.0 * a.sqrt().asin();
820    R_KM * c
821}
822
823// ── ArrivalPicker ─────────────────────────────────────────────────────────────
824
825/// STA/LTA-based P-wave arrival picker.
826///
827/// Computes the characteristic function CF(t) = STA(t) / LTA(t) where:
828/// - STA = short-time average of squared amplitudes over sta_samples
829/// - LTA = long-time average of squared amplitudes over lta_samples
830#[allow(dead_code)]
831#[derive(Debug, Clone)]
832pub struct ArrivalPicker {
833    /// Short-term average window in samples.
834    pub sta_samples: usize,
835    /// Long-term average window in samples.
836    pub lta_samples: usize,
837    /// Trigger threshold (ratio STA/LTA).
838    pub trigger_threshold: f64,
839    /// Detrigger threshold.
840    pub detrigger_threshold: f64,
841}
842
843impl ArrivalPicker {
844    /// Create a new STA/LTA picker.
845    pub fn new(sta_samples: usize, lta_samples: usize, trigger: f64, detrigger: f64) -> Self {
846        Self {
847            sta_samples,
848            lta_samples,
849            trigger_threshold: trigger,
850            detrigger_threshold: detrigger,
851        }
852    }
853
854    /// Compute the STA/LTA characteristic function for a trace.
855    ///
856    /// Returns a vector of CF values, one per sample (zeros for the initial
857    /// lta_samples window where LTA is not yet filled).
858    pub fn characteristic_function(&self, data: &[f64]) -> Vec<f64> {
859        let n = data.len();
860        let mut cf = vec![0.0f64; n];
861        let sq: Vec<f64> = data.iter().map(|&x| x * x).collect();
862
863        // Running STA and LTA using cumulative sum
864        let mut cum = vec![0.0f64; n + 1];
865        for i in 0..n {
866            cum[i + 1] = cum[i] + sq[i];
867        }
868
869        for i in self.lta_samples..n {
870            let lta_start = i.saturating_sub(self.lta_samples);
871            let sta_start = i.saturating_sub(self.sta_samples);
872            let lta = (cum[i] - cum[lta_start]) / self.lta_samples as f64;
873            let sta = (cum[i] - cum[sta_start]) / self.sta_samples as f64;
874            if lta > 1e-30 {
875                cf[i] = sta / lta;
876            }
877        }
878        cf
879    }
880
881    /// Pick P-wave arrival times (in samples) where CF exceeds the trigger threshold.
882    ///
883    /// Returns indices into the trace where triggers occur.
884    pub fn pick_arrivals(&self, data: &[f64]) -> Vec<usize> {
885        let cf = self.characteristic_function(data);
886        let mut picks = Vec::new();
887        let mut triggered = false;
888        for (i, &c) in cf.iter().enumerate() {
889            if !triggered && c >= self.trigger_threshold {
890                picks.push(i);
891                triggered = true;
892            } else if triggered && c < self.detrigger_threshold {
893                triggered = false;
894            }
895        }
896        picks
897    }
898}
899
900// ── FrequencyFilter ───────────────────────────────────────────────────────────
901
902/// Butterworth bandpass filter parameters.
903///
904/// The filter is specified in the frequency domain by its corner frequencies
905/// and order. For actual filtering, a recursive IIR implementation is used.
906#[allow(dead_code)]
907#[derive(Debug, Clone)]
908pub struct FrequencyFilter {
909    /// Lower corner frequency in Hz.
910    pub freqmin: f64,
911    /// Upper corner frequency in Hz.
912    pub freqmax: f64,
913    /// Filter order (number of poles).
914    pub order: usize,
915    /// Sampling rate in Hz.
916    pub sampling_rate: f64,
917}
918
919impl FrequencyFilter {
920    /// Create a new Butterworth bandpass filter.
921    pub fn bandpass(freqmin: f64, freqmax: f64, order: usize, sampling_rate: f64) -> Self {
922        Self {
923            freqmin,
924            freqmax,
925            order,
926            sampling_rate,
927        }
928    }
929
930    /// Check if a given frequency is in the passband (within 3 dB of unity gain).
931    pub fn in_passband(&self, freq_hz: f64) -> bool {
932        freq_hz >= self.freqmin && freq_hz <= self.freqmax
933    }
934
935    /// Compute the idealized gain at a given frequency (rectangular approximation).
936    ///
937    /// Returns 1.0 in the passband, 0.0 outside. This is a simplified model
938    /// for testing the concept of passband vs. stopband.
939    pub fn ideal_gain(&self, freq_hz: f64) -> f64 {
940        if self.in_passband(freq_hz) { 1.0 } else { 0.0 }
941    }
942
943    /// Apply a simple forward-backward (zero-phase) bandpass filter using
944    /// a windowed sinc approach (simplified for testing).
945    ///
946    /// Returns filtered data of the same length.
947    pub fn apply(&self, data: &[f64]) -> Vec<f64> {
948        // Simple rectangular window in frequency domain via DFT-like approach
949        // For a real implementation, use recursive Butterworth IIR
950        // Here we use a simple moving average as a low-pass approximation
951        let n = data.len();
952        if n == 0 {
953            return Vec::new();
954        }
955        // Low-pass filter up to freqmax
956        let lp_samples = (self.sampling_rate / (2.0 * self.freqmax)).round().max(1.0) as usize;
957        // High-pass: subtract low-passed version at freqmin
958        let hp_samples = (self.sampling_rate / (2.0 * self.freqmin)).round().max(1.0) as usize;
959
960        let lp = moving_average(data, lp_samples);
961        let hp_lp = moving_average(data, hp_samples);
962        // Bandpass: LP - very_LP (removes DC and very low freqs)
963        let mut out = vec![0.0f64; n];
964        for i in 0..n {
965            out[i] = lp[i] - hp_lp[i];
966        }
967        out
968    }
969}
970
971/// Compute a moving average with the given window size.
972#[allow(dead_code)]
973fn moving_average(data: &[f64], window: usize) -> Vec<f64> {
974    let n = data.len();
975    let w = window.max(1);
976    let mut out = vec![0.0f64; n];
977    let mut sum = 0.0f64;
978    let mut count = 0usize;
979    for i in 0..n {
980        sum += data[i];
981        count += 1;
982        if i >= w {
983            sum -= data[i - w];
984            count -= 1;
985        }
986        out[i] = sum / count as f64;
987    }
988    out
989}
990
991// ── SeismicCatalog ────────────────────────────────────────────────────────────
992
993/// A seismic event in a catalog.
994#[allow(dead_code)]
995#[derive(Debug, Clone)]
996pub struct SeismicEvent {
997    /// Event ID.
998    pub event_id: String,
999    /// Origin time as Unix timestamp.
1000    pub origin_time: f64,
1001    /// Hypocenter latitude in degrees.
1002    pub latitude: f64,
1003    /// Hypocenter longitude in degrees.
1004    pub longitude: f64,
1005    /// Hypocenter depth in km.
1006    pub depth_km: f64,
1007    /// Moment magnitude Mw.
1008    pub magnitude: f64,
1009    /// Magnitude type (e.g., "Mw", "Mb", "Ms").
1010    pub magnitude_type: String,
1011    /// Number of phases used in location.
1012    pub num_phases: u32,
1013    /// RMS residual of travel-time fit in seconds.
1014    pub rms_residual: f64,
1015}
1016
1017impl SeismicEvent {
1018    /// Create a new seismic event.
1019    #[allow(clippy::too_many_arguments)]
1020    pub fn new(
1021        event_id: &str,
1022        origin_time: f64,
1023        lat: f64,
1024        lon: f64,
1025        depth_km: f64,
1026        magnitude: f64,
1027        magnitude_type: &str,
1028    ) -> Self {
1029        Self {
1030            event_id: event_id.to_string(),
1031            origin_time,
1032            latitude: lat,
1033            longitude: lon,
1034            depth_km,
1035            magnitude,
1036            magnitude_type: magnitude_type.to_string(),
1037            num_phases: 0,
1038            rms_residual: 0.0,
1039        }
1040    }
1041
1042    /// Compute the epicentral distance to a station in km.
1043    pub fn epicentral_distance_km(&self, station: &SeismicStation) -> f64 {
1044        haversine_km(
1045            self.latitude,
1046            self.longitude,
1047            station.latitude,
1048            station.longitude,
1049        )
1050    }
1051
1052    /// Estimate P-wave travel time in seconds using a simplified Ak135 velocity model.
1053    ///
1054    /// Uses a linear approximation: t = dist_km / v_p where v_p ≈ 8.0 km/s.
1055    pub fn estimate_p_travel_time(&self, station: &SeismicStation) -> f64 {
1056        let dist = self.epicentral_distance_km(station);
1057        let hypo_dist = (dist * dist + self.depth_km * self.depth_km).sqrt();
1058        hypo_dist / 8.0 // approximate crustal P-velocity
1059    }
1060
1061    /// Estimate S-wave travel time in seconds.
1062    pub fn estimate_s_travel_time(&self, station: &SeismicStation) -> f64 {
1063        let dist = self.epicentral_distance_km(station);
1064        let hypo_dist = (dist * dist + self.depth_km * self.depth_km).sqrt();
1065        hypo_dist / 4.6 // approximate crustal S-velocity
1066    }
1067}
1068
1069/// A catalog of seismic events.
1070#[allow(dead_code)]
1071#[derive(Debug, Clone)]
1072pub struct SeismicCatalog {
1073    /// All events in this catalog.
1074    pub events: Vec<SeismicEvent>,
1075    /// Catalog name or source.
1076    pub name: String,
1077}
1078
1079impl SeismicCatalog {
1080    /// Create a new empty catalog.
1081    pub fn new(name: &str) -> Self {
1082        Self {
1083            events: Vec::new(),
1084            name: name.to_string(),
1085        }
1086    }
1087
1088    /// Add an event to the catalog.
1089    pub fn add_event(&mut self, event: SeismicEvent) {
1090        self.events.push(event);
1091    }
1092
1093    /// Return the number of events.
1094    pub fn len(&self) -> usize {
1095        self.events.len()
1096    }
1097
1098    /// Return true if the catalog is empty.
1099    pub fn is_empty(&self) -> bool {
1100        self.events.is_empty()
1101    }
1102
1103    /// Filter events by magnitude range \[mag_min, mag_max\].
1104    pub fn filter_by_magnitude(&self, mag_min: f64, mag_max: f64) -> Vec<&SeismicEvent> {
1105        self.events
1106            .iter()
1107            .filter(|e| e.magnitude >= mag_min && e.magnitude <= mag_max)
1108            .collect()
1109    }
1110
1111    /// Sort events by origin time (ascending).
1112    pub fn sort_by_time(&mut self) {
1113        self.events.sort_by(|a, b| {
1114            a.origin_time
1115                .partial_cmp(&b.origin_time)
1116                .unwrap_or(std::cmp::Ordering::Equal)
1117        });
1118    }
1119
1120    /// Return the maximum magnitude event, if any.
1121    pub fn max_magnitude_event(&self) -> Option<&SeismicEvent> {
1122        self.events.iter().max_by(|a, b| {
1123            a.magnitude
1124                .partial_cmp(&b.magnitude)
1125                .unwrap_or(std::cmp::Ordering::Equal)
1126        })
1127    }
1128
1129    /// Build an index from event_id to index in events vector.
1130    pub fn build_id_index(&self) -> HashMap<String, usize> {
1131        self.events
1132            .iter()
1133            .enumerate()
1134            .map(|(i, e)| (e.event_id.clone(), i))
1135            .collect()
1136    }
1137
1138    /// Return events occurring within a time window \[t_start, t_end\].
1139    pub fn events_in_window(&self, t_start: f64, t_end: f64) -> Vec<&SeismicEvent> {
1140        self.events
1141            .iter()
1142            .filter(|e| e.origin_time >= t_start && e.origin_time <= t_end)
1143            .collect()
1144    }
1145}
1146
1147// ── Voronoi/Roadmap placeholder ───────────────────────────────────────────────
1148
1149/// Phase arrival record: station, phase type, and observed time.
1150#[allow(dead_code)]
1151#[derive(Debug, Clone)]
1152pub struct PhaseArrival {
1153    /// Station SEED ID.
1154    pub station_id: String,
1155    /// Phase name (e.g., "P", "S", "Pn", "Pg").
1156    pub phase: String,
1157    /// Observed arrival time as Unix timestamp.
1158    pub arrival_time: f64,
1159    /// Time residual (observed - predicted) in seconds.
1160    pub residual: f64,
1161}
1162
1163impl PhaseArrival {
1164    /// Create a new phase arrival.
1165    pub fn new(station_id: &str, phase: &str, arrival_time: f64, residual: f64) -> Self {
1166        Self {
1167            station_id: station_id.to_string(),
1168            phase: phase.to_string(),
1169            arrival_time,
1170            residual,
1171        }
1172    }
1173}
1174
1175// ── Binary I/O Helpers ────────────────────────────────────────────────────────
1176
1177/// Write a big-endian i32 at byte offset in buffer.
1178#[allow(dead_code)]
1179fn write_i32_be(buf: &mut [u8], offset: usize, val: i32) {
1180    let bytes = val.to_be_bytes();
1181    buf[offset..offset + 4].copy_from_slice(&bytes);
1182}
1183
1184/// Write a big-endian i16 at byte offset in buffer.
1185#[allow(dead_code)]
1186fn write_i16_be(buf: &mut [u8], offset: usize, val: i16) {
1187    let bytes = val.to_be_bytes();
1188    buf[offset..offset + 2].copy_from_slice(&bytes);
1189}
1190
1191/// Read a big-endian i32 from buffer at byte offset.
1192#[allow(dead_code)]
1193fn read_i32_be(buf: &[u8], offset: usize) -> i32 {
1194    let bytes: [u8; 4] = buf[offset..offset + 4].try_into().unwrap_or([0; 4]);
1195    i32::from_be_bytes(bytes)
1196}
1197
1198/// Read a big-endian i16 from buffer at byte offset.
1199#[allow(dead_code)]
1200fn read_i16_be(buf: &[u8], offset: usize) -> i16 {
1201    let bytes: [u8; 2] = buf[offset..offset + 2].try_into().unwrap_or([0; 2]);
1202    i16::from_be_bytes(bytes)
1203}
1204
1205/// Write a little-endian f32 at byte offset in buffer.
1206#[allow(dead_code)]
1207fn write_f32_le(buf: &mut [u8], offset: usize, val: f32) {
1208    let bytes = val.to_le_bytes();
1209    buf[offset..offset + 4].copy_from_slice(&bytes);
1210}
1211
1212/// Write a little-endian i32 at byte offset in buffer.
1213#[allow(dead_code)]
1214fn write_i32_le(buf: &mut [u8], offset: usize, val: i32) {
1215    let bytes = val.to_le_bytes();
1216    buf[offset..offset + 4].copy_from_slice(&bytes);
1217}
1218
1219/// Read a little-endian i32 from buffer at byte offset.
1220#[allow(dead_code)]
1221fn read_i32_le(buf: &[u8], offset: usize) -> i32 {
1222    let bytes: [u8; 4] = buf[offset..offset + 4].try_into().unwrap_or([0; 4]);
1223    i32::from_le_bytes(bytes)
1224}
1225
1226// ── Tests ─────────────────────────────────────────────────────────────────────
1227
1228#[cfg(test)]
1229mod tests {
1230    use super::*;
1231
1232    // ── SEG-Y tests ────────────────────────────────────────────────────────────
1233
1234    #[test]
1235    fn test_segy_text_header_roundtrip() {
1236        let mut hdr = SegyTextHeader::new();
1237        hdr.set_line(0, "CREATED BY OXIPHYSICS");
1238        let line = hdr.get_line(0);
1239        assert!(line.contains("CREATED"), "line={line}");
1240    }
1241
1242    #[test]
1243    fn test_segy_text_header_empty_line() {
1244        let hdr = SegyTextHeader::new();
1245        let line = hdr.get_line(0);
1246        // All spaces → trimmed to empty
1247        assert!(line.is_empty(), "line={line:?}");
1248    }
1249
1250    #[test]
1251    fn test_segy_text_header_out_of_range() {
1252        let mut hdr = SegyTextHeader::new();
1253        hdr.set_line(40, "SHOULD NOT WRITE");
1254        // Should not panic; line 40 is out of range
1255        assert_eq!(hdr.get_line(40), "");
1256    }
1257
1258    #[test]
1259    fn test_segy_binary_header_roundtrip() {
1260        let segy = SegyFile::new();
1261        let buf = segy.serialize_binary_header();
1262        assert_eq!(buf.len(), 400);
1263        let h2 = SegyFile::deserialize_binary_header(&buf);
1264        assert_eq!(h2.job_id, segy.binary_header.job_id);
1265        assert_eq!(h2.sample_interval_us, segy.binary_header.sample_interval_us);
1266        assert_eq!(h2.samples_per_trace, segy.binary_header.samples_per_trace);
1267        assert_eq!(h2.data_format_code, segy.binary_header.data_format_code);
1268    }
1269
1270    #[test]
1271    fn test_segy_binary_header_segy_revision() {
1272        let segy = SegyFile::new();
1273        let buf = segy.serialize_binary_header();
1274        let h2 = SegyFile::deserialize_binary_header(&buf);
1275        assert_eq!(h2.segy_revision, 256, "Expected rev 1 (256)");
1276    }
1277
1278    #[test]
1279    fn test_segy_add_trace_sequence_number() {
1280        let mut segy = SegyFile::new();
1281        let t1 = SegyTrace::new(vec![1.0, 2.0, 3.0]);
1282        let t2 = SegyTrace::new(vec![4.0, 5.0, 6.0]);
1283        segy.add_trace(t1);
1284        segy.add_trace(t2);
1285        assert_eq!(segy.traces[0].header.trace_seq_file, 1);
1286        assert_eq!(segy.traces[1].header.trace_seq_file, 2);
1287    }
1288
1289    #[test]
1290    fn test_segy_trace_num_samples() {
1291        let data: Vec<f32> = (0..500).map(|x| x as f32 * 0.001).collect();
1292        let trace = SegyTrace::new(data.clone());
1293        assert_eq!(trace.num_samples(), 500);
1294        assert_eq!(trace.header.num_samples, 500);
1295    }
1296
1297    #[test]
1298    fn test_segy_trace_sample_interval() {
1299        let mut trace = SegyTrace::new(vec![0.0f32; 10]);
1300        trace.header.sample_interval_us = 4000; // 4 ms
1301        let dt = trace.sample_interval_s();
1302        assert!((dt - 0.004).abs() < 1e-9, "dt={dt:.6}");
1303    }
1304
1305    #[test]
1306    fn test_segy_num_traces() {
1307        let mut segy = SegyFile::new();
1308        assert_eq!(segy.num_traces(), 0);
1309        for i in 0..10 {
1310            segy.add_trace(SegyTrace::new(vec![i as f32; 100]));
1311        }
1312        assert_eq!(segy.num_traces(), 10);
1313    }
1314
1315    // ── SAC tests ─────────────────────────────────────────────────────────────
1316
1317    #[test]
1318    fn test_sac_npts_matches_data_length() {
1319        let data: Vec<f32> = (0..1024).map(|x| (x as f32).sin()).collect();
1320        let sac = SacFile::from_data(data.clone(), 0.01);
1321        assert_eq!(sac.header.npts as usize, data.len());
1322        assert_eq!(sac.num_samples(), data.len());
1323    }
1324
1325    #[test]
1326    fn test_sac_end_time_correct() {
1327        let data = vec![0.0f32; 1001];
1328        let sac = SacFile::from_data(data, 0.01);
1329        // e = (1001 - 1) * 0.01 = 10.0
1330        assert!((sac.header.e - 10.0).abs() < 1e-5, "e={:.6}", sac.header.e);
1331    }
1332
1333    #[test]
1334    fn test_sac_depmin_depmax() {
1335        let data = vec![-3.0f32, 0.0, 5.0, 2.0];
1336        let sac = SacFile::from_data(data, 0.01);
1337        assert!((sac.header.depmin - (-3.0)).abs() < 1e-5);
1338        assert!((sac.header.depmax - 5.0).abs() < 1e-5);
1339    }
1340
1341    #[test]
1342    fn test_sac_header_serialize_npts() {
1343        let data = vec![1.0f32; 256];
1344        let sac = SacFile::from_data(data, 0.01);
1345        let buf = sac.serialize_header();
1346        assert_eq!(buf.len(), 632);
1347        let npts = SacFile::npts_from_header(&buf);
1348        assert_eq!(npts, 256);
1349    }
1350
1351    #[test]
1352    fn test_sac_delta_roundtrip() {
1353        let data = vec![0.0f32; 100];
1354        let sac = SacFile::from_data(data, 0.025);
1355        let buf = sac.serialize_header();
1356        // Read delta from offset 0 (little-endian f32)
1357        let delta_bytes: [u8; 4] = buf[0..4].try_into().unwrap();
1358        let delta = f32::from_le_bytes(delta_bytes);
1359        assert!((delta - 0.025).abs() < 1e-6, "delta={delta:.6}");
1360    }
1361
1362    // ── MiniSEED tests ────────────────────────────────────────────────────────
1363
1364    #[test]
1365    fn test_miniseed_sequence_number_increment() {
1366        let mut rec = MiniSeedRecord::from_samples(vec![0; 10], "ANMO", "BHZ", "IU");
1367        assert_eq!(rec.header.sequence_number, 1);
1368        rec.next_sequence();
1369        assert_eq!(rec.header.sequence_number, 2);
1370        rec.next_sequence();
1371        assert_eq!(rec.header.sequence_number, 3);
1372    }
1373
1374    #[test]
1375    fn test_miniseed_sequence_number_wraps() {
1376        let mut rec = MiniSeedRecord::from_samples(vec![0; 10], "ANMO", "BHZ", "IU");
1377        rec.header.sequence_number = 999999;
1378        rec.next_sequence();
1379        assert_eq!(rec.header.sequence_number, 1);
1380    }
1381
1382    #[test]
1383    fn test_miniseed_num_samples() {
1384        let samples = vec![1, 2, 3, 4, 5];
1385        let rec = MiniSeedRecord::from_samples(samples.clone(), "ANMO", "BHZ", "IU");
1386        assert_eq!(rec.header.num_samples as usize, samples.len());
1387    }
1388
1389    #[test]
1390    fn test_miniseed_channel_assignment() {
1391        let rec = MiniSeedRecord::from_samples(vec![0; 5], "COLA", "HHN", "AK");
1392        assert_eq!(rec.header.channel, "HHN");
1393        assert_eq!(rec.header.network, "AK");
1394        assert_eq!(rec.header.station, "COLA");
1395    }
1396
1397    #[test]
1398    fn test_miniseed_sample_rate_positive() {
1399        let hdr = MiniSeedHeader::default();
1400        // factor=100, multiplier=1 → 100 sps
1401        let sr = hdr.sample_rate();
1402        assert!((sr - 100.0).abs() < 1e-9, "sr={sr:.6}");
1403    }
1404
1405    #[test]
1406    fn test_miniseed_sample_rate_negative_factor() {
1407        let hdr = MiniSeedHeader {
1408            sample_rate_factor: -10, // 1/10 sps = 0.1 sps
1409            sample_rate_multiplier: 1,
1410            ..Default::default()
1411        };
1412        // factor < 0, mult > 0 → -mult / factor = -1 / -10 = 0.1
1413        let sr = hdr.sample_rate();
1414        assert!((sr - 0.1).abs() < 1e-9, "sr={sr:.6}");
1415    }
1416
1417    #[test]
1418    fn test_steim1_encode_small_diffs() {
1419        let diffs = vec![0i32, 1, -1, 127, -128];
1420        let frames = Steim1Frame::encode_diffs(&diffs);
1421        assert!(!frames.is_empty());
1422        // All diffs fit in 8 bits, so type codes should be 3 for data words
1423    }
1424
1425    #[test]
1426    fn test_steim1_encode_large_diffs() {
1427        let diffs = vec![1_000_000i32, -1_000_000];
1428        let frames = Steim1Frame::encode_diffs(&diffs);
1429        assert!(!frames.is_empty());
1430    }
1431
1432    // ── SeismicTrace tests ────────────────────────────────────────────────────
1433
1434    #[test]
1435    fn test_seismic_trace_end_time() {
1436        let data = vec![0.0; 101];
1437        let trace = SeismicTrace::new("IU", "ANMO", "BHZ", 100.0, 0.0, data);
1438        // end = 0 + 100 * (1/100) = 1.0
1439        assert!(
1440            (trace.end_time() - 1.0).abs() < 1e-9,
1441            "end={:.6}",
1442            trace.end_time()
1443        );
1444    }
1445
1446    #[test]
1447    fn test_seismic_trace_seed_id() {
1448        let trace = SeismicTrace::new("IU", "ANMO", "BHZ", 100.0, 0.0, vec![]);
1449        assert_eq!(trace.seed_id(), "IU.ANMO.  .BHZ");
1450    }
1451
1452    #[test]
1453    fn test_seismic_trace_rms() {
1454        let data = vec![1.0, -1.0, 1.0, -1.0];
1455        let trace = SeismicTrace::new("IU", "ANMO", "BHZ", 1.0, 0.0, data);
1456        assert!((trace.rms() - 1.0).abs() < 1e-9, "rms={:.6}", trace.rms());
1457    }
1458
1459    #[test]
1460    fn test_seismic_trace_duration() {
1461        let data = vec![0.0; 1001];
1462        let trace = SeismicTrace::new("IU", "ANMO", "BHZ", 100.0, 10.0, data);
1463        // duration = 1000 / 100 = 10.0 s
1464        assert!((trace.duration() - 10.0).abs() < 1e-9);
1465    }
1466
1467    // ── SeismicStation / Haversine tests ──────────────────────────────────────
1468
1469    #[test]
1470    fn test_station_distance_same_location() {
1471        let sta1 = SeismicStation::new("IU", "ANMO", 34.9459, -106.4572, 1820.0);
1472        let sta2 = SeismicStation::new("IU", "ANMO", 34.9459, -106.4572, 1820.0);
1473        let d = sta1.distance_km(&sta2);
1474        assert!(d < 0.001, "d={d:.6}");
1475    }
1476
1477    #[test]
1478    fn test_haversine_known_distance() {
1479        // London (51.5074, -0.1278) to Paris (48.8566, 2.3522) ≈ 341 km
1480        let d = haversine_km(51.5074, -0.1278, 48.8566, 2.3522);
1481        assert!((d - 341.0).abs() < 5.0, "d={d:.1} km");
1482    }
1483
1484    #[test]
1485    fn test_haversine_equator() {
1486        // One degree of longitude on equator ≈ 111.32 km
1487        let d = haversine_km(0.0, 0.0, 0.0, 1.0);
1488        assert!((d - 111.32).abs() < 0.5, "d={d:.3} km");
1489    }
1490
1491    #[test]
1492    fn test_station_epicentral_distance() {
1493        let sta = SeismicStation::new("IU", "ANMO", 34.9459, -106.4572, 1820.0);
1494        // Event at same location → 0 km
1495        let d = sta.epicentral_distance_km(34.9459, -106.4572);
1496        assert!(d < 0.001, "d={d:.6}");
1497    }
1498
1499    // ── ArrivalPicker tests ───────────────────────────────────────────────────
1500
1501    #[test]
1502    fn test_stalta_characteristic_function_size() {
1503        let data: Vec<f64> = (0..1000).map(|i| (i as f64 * 0.1).sin()).collect();
1504        let picker = ArrivalPicker::new(10, 100, 3.0, 1.5);
1505        let cf = picker.characteristic_function(&data);
1506        assert_eq!(cf.len(), data.len());
1507    }
1508
1509    #[test]
1510    fn test_stalta_picks_before_direct_wave() {
1511        // Noise + impulsive signal at sample 500
1512        let mut data = vec![0.01f64; 1000];
1513        for i in 500..510 {
1514            data[i] = 100.0; // strong P-wave
1515        }
1516        // Add tiny noise
1517        for (i, x) in data.iter_mut().enumerate() {
1518            if i < 500 {
1519                *x += 0.001 * (i as f64).sin();
1520            }
1521        }
1522        let picker = ArrivalPicker::new(10, 100, 3.0, 1.5);
1523        let picks = picker.pick_arrivals(&data);
1524        // Should pick at or near sample 500
1525        assert!(!picks.is_empty(), "Expected at least one pick");
1526        let first_pick = picks[0];
1527        assert!(first_pick >= 100, "Pick too early: {first_pick}");
1528        assert!(first_pick <= 520, "Pick too late: {first_pick}");
1529    }
1530
1531    #[test]
1532    fn test_stalta_no_picks_quiet_signal() {
1533        let data = vec![0.001f64; 1000];
1534        let picker = ArrivalPicker::new(10, 100, 10.0, 5.0);
1535        let picks = picker.pick_arrivals(&data);
1536        assert!(picks.is_empty(), "Expected no picks for quiet signal");
1537    }
1538
1539    // ── FrequencyFilter tests ──────────────────────────────────────────────────
1540
1541    #[test]
1542    fn test_bandpass_in_passband() {
1543        let filt = FrequencyFilter::bandpass(1.0, 10.0, 4, 100.0);
1544        assert!(filt.in_passband(5.0));
1545        assert!(!filt.in_passband(0.5));
1546        assert!(!filt.in_passband(15.0));
1547    }
1548
1549    #[test]
1550    fn test_bandpass_ideal_gain_passband() {
1551        let filt = FrequencyFilter::bandpass(1.0, 10.0, 4, 100.0);
1552        assert!((filt.ideal_gain(5.0) - 1.0).abs() < 1e-10);
1553        assert!((filt.ideal_gain(0.1)).abs() < 1e-10);
1554    }
1555
1556    #[test]
1557    fn test_bandpass_apply_preserves_length() {
1558        let data: Vec<f64> = (0..256).map(|i| (i as f64 * 0.1).sin()).collect();
1559        let filt = FrequencyFilter::bandpass(1.0, 10.0, 4, 100.0);
1560        let out = filt.apply(&data);
1561        assert_eq!(out.len(), data.len());
1562    }
1563
1564    #[test]
1565    fn test_bandpass_apply_empty() {
1566        let filt = FrequencyFilter::bandpass(1.0, 10.0, 4, 100.0);
1567        let out = filt.apply(&[]);
1568        assert!(out.is_empty());
1569    }
1570
1571    // ── SeismicCatalog tests ──────────────────────────────────────────────────
1572
1573    #[test]
1574    fn test_catalog_add_and_len() {
1575        let mut cat = SeismicCatalog::new("Test");
1576        assert_eq!(cat.len(), 0);
1577        cat.add_event(SeismicEvent::new(
1578            "ev001", 1000.0, 35.0, -120.0, 10.0, 5.5, "Mw",
1579        ));
1580        cat.add_event(SeismicEvent::new(
1581            "ev002", 2000.0, 36.0, -121.0, 15.0, 4.2, "Mb",
1582        ));
1583        assert_eq!(cat.len(), 2);
1584    }
1585
1586    #[test]
1587    fn test_catalog_sort_by_time() {
1588        let mut cat = SeismicCatalog::new("Test");
1589        cat.add_event(SeismicEvent::new(
1590            "ev002", 2000.0, 35.0, -120.0, 10.0, 5.5, "Mw",
1591        ));
1592        cat.add_event(SeismicEvent::new(
1593            "ev001", 1000.0, 35.0, -120.0, 10.0, 4.0, "Mb",
1594        ));
1595        cat.sort_by_time();
1596        assert!(cat.events[0].origin_time < cat.events[1].origin_time);
1597    }
1598
1599    #[test]
1600    fn test_catalog_filter_by_magnitude() {
1601        let mut cat = SeismicCatalog::new("Test");
1602        cat.add_event(SeismicEvent::new(
1603            "ev001", 1000.0, 35.0, -120.0, 10.0, 5.5, "Mw",
1604        ));
1605        cat.add_event(SeismicEvent::new(
1606            "ev002", 2000.0, 36.0, -121.0, 15.0, 3.0, "Mb",
1607        ));
1608        cat.add_event(SeismicEvent::new(
1609            "ev003", 3000.0, 37.0, -122.0, 20.0, 7.0, "Mw",
1610        ));
1611        let big = cat.filter_by_magnitude(5.0, 8.0);
1612        assert_eq!(big.len(), 2);
1613    }
1614
1615    #[test]
1616    fn test_catalog_max_magnitude() {
1617        let mut cat = SeismicCatalog::new("Test");
1618        cat.add_event(SeismicEvent::new(
1619            "ev001", 1000.0, 35.0, -120.0, 10.0, 5.5, "Mw",
1620        ));
1621        cat.add_event(SeismicEvent::new(
1622            "ev002", 2000.0, 36.0, -121.0, 15.0, 7.1, "Mw",
1623        ));
1624        let max_ev = cat.max_magnitude_event().unwrap();
1625        assert!((max_ev.magnitude - 7.1).abs() < 1e-9);
1626    }
1627
1628    #[test]
1629    fn test_catalog_events_in_window() {
1630        let mut cat = SeismicCatalog::new("Test");
1631        cat.add_event(SeismicEvent::new(
1632            "ev001", 100.0, 35.0, -120.0, 10.0, 5.5, "Mw",
1633        ));
1634        cat.add_event(SeismicEvent::new(
1635            "ev002", 500.0, 36.0, -121.0, 15.0, 3.0, "Mb",
1636        ));
1637        cat.add_event(SeismicEvent::new(
1638            "ev003", 900.0, 37.0, -122.0, 20.0, 7.0, "Mw",
1639        ));
1640        let evts = cat.events_in_window(200.0, 800.0);
1641        assert_eq!(evts.len(), 1);
1642        assert_eq!(evts[0].event_id, "ev002");
1643    }
1644
1645    #[test]
1646    fn test_event_p_travel_time_increases_with_distance() {
1647        let event = SeismicEvent::new("ev001", 0.0, 0.0, 0.0, 10.0, 6.0, "Mw");
1648        let sta_near = SeismicStation::new("XX", "NEAR", 0.1, 0.0, 0.0);
1649        let sta_far = SeismicStation::new("XX", "FAR", 5.0, 0.0, 0.0);
1650        let t_near = event.estimate_p_travel_time(&sta_near);
1651        let t_far = event.estimate_p_travel_time(&sta_far);
1652        assert!(
1653            t_far > t_near,
1654            "t_far={t_far:.3} should be > t_near={t_near:.3}"
1655        );
1656    }
1657
1658    #[test]
1659    fn test_event_s_travel_time_greater_than_p() {
1660        let event = SeismicEvent::new("ev001", 0.0, 35.0, -120.0, 10.0, 6.0, "Mw");
1661        let sta = SeismicStation::new("IU", "ANMO", 34.9459, -106.4572, 1820.0);
1662        let t_p = event.estimate_p_travel_time(&sta);
1663        let t_s = event.estimate_s_travel_time(&sta);
1664        assert!(t_s > t_p, "S ({t_s:.3}s) should arrive after P ({t_p:.3}s)");
1665    }
1666
1667    #[test]
1668    fn test_phase_arrival_creation() {
1669        let arr = PhaseArrival::new("IU.ANMO.  .BHZ", "P", 1000.5, 0.03);
1670        assert_eq!(arr.phase, "P");
1671        assert!((arr.residual - 0.03).abs() < 1e-9);
1672    }
1673}