Skip to main content

sidereon_core/
antex.rs

1//! ANTEX 1.4 receiver and satellite antenna parser.
2//!
3//! The parser owns the byte/record grammar for the antenna calibration blocks
4//! used by PPP and RTK correction paths. Values are stored in SI units:
5//! PCO/PCV are meters, azimuth and zenith grids are degrees.
6
7use crate::antenna;
8use crate::constants::MM_PER_M;
9use crate::format::columns::{fortran_f64, raw_field};
10use crate::format::{Diagnostics, RecordRef, Skip, SkipReason};
11use crate::validate::{self, FieldError};
12use std::collections::BTreeMap;
13use std::fmt;
14
15/// Parsed ANTEX antenna calibration product.
16#[derive(Debug, Clone, PartialEq)]
17pub struct Antex {
18    pub antennas: BTreeMap<String, Antenna>,
19    antenna_intervals: BTreeMap<String, Vec<Antenna>>,
20    /// Count of malformed records skipped during a forgiving parse (a corrupt PCV
21    /// grid value or an unrecognized grid-row head); each is surfaced as a typed
22    /// [`Skip`] in the parser's [`Diagnostics`]. A clean file parses with
23    /// `skipped_records == 0`; a non-zero count lets a caller tell a pristine
24    /// product apart from one that carried a malformed record without aborting the
25    /// whole parse. No fabricated sample is emitted in its place. Read it through
26    /// [`Antex::skipped_records`]. Mirrors [`crate::atmosphere::Ionex::skipped_records`].
27    skipped_records: usize,
28}
29
30/// Receiver or satellite antenna block.
31#[derive(Debug, Clone, PartialEq)]
32pub struct Antenna {
33    pub id: String,
34    pub kind: AntennaKind,
35    pub antenna_type: String,
36    pub serial: String,
37    pub dazi_deg: f64,
38    pub zenith_start_deg: f64,
39    pub zenith_end_deg: f64,
40    pub zenith_step_deg: f64,
41    pub sinex_code: Option<String>,
42    pub valid_from: Option<AntexDateTime>,
43    pub valid_until: Option<AntexDateTime>,
44    pub frequencies: BTreeMap<String, Frequency>,
45}
46
47/// ANTEX antenna block role.
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum AntennaKind {
50    Receiver,
51    Satellite,
52}
53
54/// Frequency-specific PCO/PCV calibration block.
55#[derive(Debug, Clone, PartialEq)]
56pub struct Frequency {
57    pub frequency: String,
58    pub pco_m: [f64; 3],
59    pub pcv_samples: Vec<PcvSample>,
60}
61
62/// One phase-center-variation grid value.
63#[derive(Debug, Clone, PartialEq)]
64pub struct PcvSample {
65    pub grid: PcvGrid,
66    pub azimuth_deg: Option<f64>,
67    pub zenith_deg: f64,
68    pub value_m: f64,
69}
70
71/// PCV grid type.
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub enum PcvGrid {
74    NoAzimuth,
75    Azimuth,
76}
77
78/// Civil UTC-like timestamp fields from `VALID FROM` / `VALID UNTIL`.
79#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
80pub struct AntexDateTime {
81    pub year: i32,
82    pub month: u8,
83    pub day: u8,
84    pub hour: u8,
85    pub minute: u8,
86    pub second: u8,
87}
88
89/// ANTEX parse or lookup error.
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub enum AntexError {
92    InvalidDateTime,
93    InvalidInput {
94        field: &'static str,
95        reason: &'static str,
96    },
97    UnknownFrequency {
98        antenna_id: String,
99        frequency: String,
100    },
101    MissingPco {
102        antenna_id: String,
103        frequency: String,
104    },
105    EmptyPcvGrid {
106        antenna_id: String,
107        frequency: String,
108    },
109}
110
111impl fmt::Display for AntexError {
112    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
113        match self {
114            Self::InvalidDateTime => write!(f, "invalid ANTEX datetime"),
115            Self::InvalidInput { field, reason } => {
116                write!(f, "invalid ANTEX input {field}: {reason}")
117            }
118            Self::UnknownFrequency {
119                antenna_id,
120                frequency,
121            } => write!(f, "unknown frequency {frequency:?} for {antenna_id:?}"),
122            Self::MissingPco {
123                antenna_id,
124                frequency,
125            } => write!(
126                f,
127                "missing or malformed PCO for frequency {frequency:?} on {antenna_id:?}"
128            ),
129            Self::EmptyPcvGrid {
130                antenna_id,
131                frequency,
132            } => write!(
133                f,
134                "empty PCV grid for frequency {frequency:?} on {antenna_id:?}"
135            ),
136        }
137    }
138}
139
140impl std::error::Error for AntexError {}
141
142#[derive(Debug, Clone)]
143struct ParseState {
144    antennas: BTreeMap<String, Antenna>,
145    antenna_intervals: BTreeMap<String, Vec<Antenna>>,
146    current_antenna: Option<Antenna>,
147    current_frequency: Option<FrequencyState>,
148    /// One-based number of the line currently being processed, attached to skips.
149    line: usize,
150    /// Non-fatal diagnostics: typed skips for malformed records the forgiving
151    /// parser dropped rather than aborting on.
152    diagnostics: Diagnostics,
153}
154
155#[derive(Debug, Clone)]
156struct FrequencyState {
157    frequency: String,
158    phase: FrequencyPhase,
159    pco_m: Option<[f64; 3]>,
160    samples: Vec<PcvSample>,
161}
162
163#[derive(Debug, Clone, Copy, PartialEq, Eq)]
164enum FrequencyPhase {
165    Pco,
166    Pcv,
167}
168
169impl Antex {
170    /// Parse ANTEX text into receiver and satellite antenna blocks.
171    pub fn parse(text: &str) -> Result<Self, AntexError> {
172        let mut state = ParseState {
173            antennas: BTreeMap::new(),
174            antenna_intervals: BTreeMap::new(),
175            current_antenna: None,
176            current_frequency: None,
177            line: 0,
178            diagnostics: Diagnostics::new(),
179        };
180
181        for (index, line) in text.lines().enumerate() {
182            state.line = index + 1;
183            step(line, &mut state)?;
184        }
185        finalize_antenna(&mut state)?;
186
187        let skipped_records = state.diagnostics.skips.len();
188        Ok(Self {
189            antennas: state.antennas,
190            antenna_intervals: state.antenna_intervals,
191            skipped_records,
192        })
193    }
194
195    /// Number of records skipped during a forgiving parse (see the field docs).
196    pub fn skipped_records(&self) -> usize {
197        self.skipped_records
198    }
199
200    /// Return an antenna by the `TYPE / SERIAL` id.
201    pub fn antenna(&self, id: &str) -> Option<&Antenna> {
202        self.antennas.get(id.trim())
203    }
204
205    /// Return all validity blocks for a `TYPE / SERIAL` id, in file order.
206    pub fn antenna_intervals(&self, id: &str) -> impl Iterator<Item = &Antenna> {
207        self.antenna_intervals.get(id.trim()).into_iter().flatten()
208    }
209
210    /// Return the antenna validity block for a `TYPE / SERIAL` id at an epoch.
211    pub fn antenna_at(&self, id: &str, epoch: AntexDateTime) -> Option<&Antenna> {
212        self.antenna_intervals(id)
213            .find(|antenna| antenna.valid_at(epoch))
214    }
215
216    /// Return the satellite antenna block for a PRN at an epoch.
217    pub fn satellite_antenna(&self, prn: &str, epoch: AntexDateTime) -> Option<&Antenna> {
218        let prn = prn.trim();
219        self.antenna_intervals.values().flatten().find(|antenna| {
220            antenna.kind == AntennaKind::Satellite
221                && antenna.serial.trim() == prn
222                && antenna.valid_at(epoch)
223        })
224    }
225}
226
227impl Antenna {
228    /// Whether this antenna block is valid at `epoch`.
229    pub fn valid_at(&self, epoch: AntexDateTime) -> bool {
230        self.valid_from.is_none_or(|from| epoch >= from)
231            && self.valid_until.is_none_or(|until| epoch <= until)
232    }
233
234    /// Frequency-dependent PCO (north/east/up), meters.
235    pub fn pco(&self, frequency: &str) -> Result<[f64; 3], AntexError> {
236        self.frequencies
237            .get(frequency.trim())
238            .map(|f| f.pco_m)
239            .ok_or_else(|| AntexError::UnknownFrequency {
240                antenna_id: self.id.clone(),
241                frequency: frequency.to_string(),
242            })
243    }
244
245    /// Frequency-dependent PCV, meters, with linear zenith/azimuth interpolation.
246    pub fn pcv(
247        &self,
248        frequency: &str,
249        zenith_deg: f64,
250        azimuth_deg: Option<f64>,
251    ) -> Result<f64, AntexError> {
252        validate_pcv_zenith(zenith_deg, self.zenith_start_deg, self.zenith_end_deg)?;
253
254        let frequency =
255            self.frequencies
256                .get(frequency.trim())
257                .ok_or_else(|| AntexError::UnknownFrequency {
258                    antenna_id: self.id.clone(),
259                    frequency: frequency.to_string(),
260                })?;
261
262        frequency.pcv(self.id.as_str(), zenith_deg, azimuth_deg)
263    }
264}
265
266impl Frequency {
267    fn pcv(
268        &self,
269        antenna_id: &str,
270        zenith_deg: f64,
271        azimuth_deg: Option<f64>,
272    ) -> Result<f64, AntexError> {
273        let noazi: Vec<(f64, f64)> = self
274            .pcv_samples
275            .iter()
276            .filter(|sample| sample.grid == PcvGrid::NoAzimuth)
277            .map(|sample| (sample.zenith_deg, sample.value_m))
278            .collect();
279
280        let has_azimuth = self
281            .pcv_samples
282            .iter()
283            .any(|sample| sample.grid == PcvGrid::Azimuth);
284
285        if azimuth_deg.is_none() || !has_azimuth {
286            return interpolate(antenna_id, &self.frequency, &noazi, zenith_deg);
287        }
288
289        let mut azimuth_samples: BTreeMap<OrderedF64, Vec<(f64, f64)>> = BTreeMap::new();
290        for sample in self
291            .pcv_samples
292            .iter()
293            .filter(|sample| sample.grid == PcvGrid::Azimuth)
294        {
295            if let Some(azimuth) = sample.azimuth_deg {
296                azimuth_samples
297                    .entry(OrderedF64(azimuth))
298                    .or_default()
299                    .push((sample.zenith_deg, sample.value_m));
300            }
301        }
302
303        if azimuth_samples.is_empty() {
304            interpolate(antenna_id, &self.frequency, &noazi, zenith_deg)
305        } else {
306            interpolate_azimuth(
307                antenna_id,
308                &self.frequency,
309                &azimuth_samples,
310                azimuth_deg.expect("checked Some"),
311                zenith_deg,
312            )
313        }
314    }
315}
316
317fn validate_pcv_zenith(
318    zenith_deg: f64,
319    zenith_start_deg: f64,
320    zenith_end_deg: f64,
321) -> Result<(), AntexError> {
322    validate::finite(zenith_deg, "zenith_deg").map_err(map_antex_field_error)?;
323    if zenith_deg < zenith_start_deg || zenith_deg > zenith_end_deg {
324        return Err(invalid_input("zenith_deg", "out of range"));
325    }
326    Ok(())
327}
328
329fn map_antex_field_error(error: validate::FieldError) -> AntexError {
330    invalid_input(error.field(), error.reason())
331}
332
333fn invalid_input(field: &'static str, reason: &'static str) -> AntexError {
334    AntexError::InvalidInput { field, reason }
335}
336
337impl AntexDateTime {
338    pub fn new(
339        year: i32,
340        month: u8,
341        day: u8,
342        hour: u8,
343        minute: u8,
344        second: u8,
345    ) -> Result<Self, AntexError> {
346        let civil = validate::civil_datetime_with_second_policy(
347            i64::from(year),
348            i64::from(month),
349            i64::from(day),
350            i64::from(hour),
351            i64::from(minute),
352            f64::from(second),
353            validate::CivilSecondPolicy::UtcLike,
354        )
355        .map_err(|_| AntexError::InvalidDateTime)?;
356        Ok(Self::from_valid_civil(civil))
357    }
358
359    fn from_valid_civil(civil: validate::ValidCivil) -> Self {
360        Self {
361            year: civil.year as i32,
362            month: civil.month as u8,
363            day: civil.day as u8,
364            hour: civil.hour as u8,
365            minute: civil.minute as u8,
366            second: civil.second.trunc() as u8,
367        }
368    }
369}
370
371#[derive(Debug, Clone, Copy, PartialEq)]
372struct OrderedF64(f64);
373
374impl Eq for OrderedF64 {}
375
376impl PartialOrd for OrderedF64 {
377    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
378        Some(self.cmp(other))
379    }
380}
381
382impl Ord for OrderedF64 {
383    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
384        self.0.total_cmp(&other.0)
385    }
386}
387
388// ── Serialization ────────────────────────────────────────────────────
389//
390// The inverse of [`Antex::parse`]: a parsed product is rendered back to ANTEX
391// text. The canonical [`Antex`] container is the format-agnostic IR (SI units:
392// PCO/PCV in meters, angles in degrees); this writer maps it onto the fixed
393// `1.4` ANTEX record grammar. It is round-trippable at the IR level - parsing
394// the output reproduces the same [`Antex`] (every antenna interval, frequency,
395// PCO triple, and PCV sample). It is not a byte-for-byte reproduction of an
396// arbitrary source file: free-text header records, comments, and `# OF
397// FREQUENCIES`/method lines that the parser does not model are not carried, so
398// the writer emits a minimal canonical header instead.
399
400/// ANTEX label column: records carry their record-type tag at columns 60..80.
401const LABEL_COLUMN: usize = 60;
402
403impl Antex {
404    /// Serialize this product back to ANTEX 1.4 text.
405    ///
406    /// Deterministic and pure: the same [`Antex`] always produces byte-identical
407    /// output, and re-parsing it yields an equal [`Antex`]. Antenna validity
408    /// blocks are emitted in `TYPE / SERIAL` id order, preserving the file-order
409    /// sequence within each id, so both the latest-wins [`Antex::antenna`] view
410    /// and the per-id [`Antex::antenna_intervals`] view round-trip.
411    ///
412    /// The parse -> encode -> parse equality holds for products with no skipped
413    /// records ([`Antex::skipped_records`] is zero): skips are not re-emitted and
414    /// the skip count is part of the derived [`PartialEq`], so a product that
415    /// dropped a malformed record would not compare equal to its re-parse.
416    pub fn encode(&self) -> String {
417        let mut out = String::new();
418        out.push_str(&labeled("     1.4            M", "ANTEX VERSION / SYST"));
419        out.push_str(&labeled("", "END OF HEADER"));
420        for blocks in self.antenna_intervals.values() {
421            for antenna in blocks {
422                encode_antenna(antenna, &mut out);
423            }
424        }
425        out
426    }
427}
428
429fn encode_antenna(antenna: &Antenna, out: &mut String) {
430    out.push_str(&labeled("", "START OF ANTENNA"));
431    out.push_str(&labeled(&antenna.id, "TYPE / SERIAL NO"));
432    out.push_str(&labeled(&fmt_num(antenna.dazi_deg), "DAZI"));
433    out.push_str(&labeled(
434        &format!(
435            "{} {} {}",
436            fmt_num(antenna.zenith_start_deg),
437            fmt_num(antenna.zenith_end_deg),
438            fmt_num(antenna.zenith_step_deg),
439        ),
440        "ZEN1 / ZEN2 / DZEN",
441    ));
442    if let Some(code) = &antenna.sinex_code {
443        out.push_str(&labeled(code, "SINEX CODE"));
444    }
445    if let Some(from) = antenna.valid_from {
446        out.push_str(&labeled(&fmt_datetime(from), "VALID FROM"));
447    }
448    if let Some(until) = antenna.valid_until {
449        out.push_str(&labeled(&fmt_datetime(until), "VALID UNTIL"));
450    }
451    for frequency in antenna.frequencies.values() {
452        encode_frequency(frequency, out);
453    }
454    out.push_str(&labeled("", "END OF ANTENNA"));
455}
456
457fn encode_frequency(frequency: &Frequency, out: &mut String) {
458    out.push_str(&labeled(&frequency.frequency, "START OF FREQUENCY"));
459    out.push_str(&labeled(
460        &format!(
461            "{} {} {}",
462            fmt_num(frequency.pco_m[0] * MM_PER_M),
463            fmt_num(frequency.pco_m[1] * MM_PER_M),
464            fmt_num(frequency.pco_m[2] * MM_PER_M),
465        ),
466        "NORTH / EAST / UP",
467    ));
468
469    // NOAZI grid row. Samples are emitted in their stored (zenith-index) order;
470    // the reader reconstructs each zenith from the antenna grid by position, so
471    // only the values are written.
472    let noazi: Vec<&PcvSample> = frequency
473        .pcv_samples
474        .iter()
475        .filter(|sample| sample.grid == PcvGrid::NoAzimuth)
476        .collect();
477    if !noazi.is_empty() {
478        out.push_str(&pcv_row("NOAZI", &noazi));
479    }
480
481    // Azimuth-dependent rows, grouped by azimuth in first-seen order. Each
482    // azimuth's samples are contiguous in the sample vector (one ANTEX row), so
483    // grouping preserves the per-row zenith-index order.
484    let mut azimuth_rows: Vec<(f64, Vec<&PcvSample>)> = Vec::new();
485    for sample in &frequency.pcv_samples {
486        if sample.grid != PcvGrid::Azimuth {
487            continue;
488        }
489        let Some(azimuth) = sample.azimuth_deg else {
490            continue;
491        };
492        match azimuth_rows
493            .iter_mut()
494            .find(|(az, _)| az.to_bits() == azimuth.to_bits())
495        {
496            Some((_, row)) => row.push(sample),
497            None => azimuth_rows.push((azimuth, vec![sample])),
498        }
499    }
500    for (azimuth, row) in &azimuth_rows {
501        out.push_str(&pcv_row(&fmt_num(*azimuth), row));
502    }
503
504    out.push_str(&labeled("", "END OF FREQUENCY"));
505}
506
507/// Render one PCV grid row: a leading label token (`NOAZI` or an azimuth) then
508/// the millimeter values. PCV rows carry no record tag, so the reader routes
509/// them through its default (grid-row) arm regardless of line length.
510fn pcv_row(head: &str, samples: &[&PcvSample]) -> String {
511    let mut line = String::from(head);
512    for sample in samples {
513        line.push(' ');
514        line.push_str(&fmt_num(sample.value_m * MM_PER_M));
515    }
516    line.push('\n');
517    line
518}
519
520/// A labeled fixed-column ANTEX record: the body left-justified into the tag
521/// column, then the record-type label.
522fn labeled(body: &str, label: &str) -> String {
523    format!("{body:<LABEL_COLUMN$}{label}\n")
524}
525
526/// `VALID FROM` / `VALID UNTIL` value field: the six civil components the reader
527/// reads as floats. Seconds are emitted from the stored integer second.
528fn fmt_datetime(dt: AntexDateTime) -> String {
529    format!(
530        "{} {} {} {} {} {}",
531        dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second
532    )
533}
534
535/// Shortest decimal form of a value that round-trips back to the same `f64`.
536fn fmt_num(value: f64) -> String {
537    format!("{value}")
538}
539
540fn step(line: &str, state: &mut ParseState) -> Result<(), AntexError> {
541    match tag(line) {
542        "START OF ANTENNA" => {
543            finalize_antenna(state)?;
544            state.current_antenna = None;
545            state.current_frequency = None;
546        }
547        "END OF ANTENNA" => finalize_antenna(state)?,
548        "TYPE / SERIAL NO" => parse_type_serial(line, state),
549        "DAZI" => parse_dazi(line, state),
550        "ZEN1 / ZEN2 / DZEN" => parse_zenith_grid(line, state),
551        "SINEX CODE" => parse_sinex_code(line, state),
552        "VALID FROM" => parse_valid(line, state, ValidField::From)?,
553        "VALID UNTIL" => parse_valid(line, state, ValidField::Until)?,
554        "START OF FREQUENCY" => begin_frequency(line, state),
555        "END OF FREQUENCY" => finalize_frequency(state)?,
556        "NORTH / EAST / UP" => parse_pco(line, state),
557        _ => parse_pcv_row(line, state),
558    }
559    Ok(())
560}
561
562fn parse_type_serial(line: &str, state: &mut ParseState) {
563    state.current_antenna = Some(decode_antenna_header(line));
564    state.current_frequency = None;
565}
566
567fn parse_dazi(line: &str, state: &mut ParseState) {
568    let Some(current) = state.current_antenna.as_mut() else {
569        return;
570    };
571    if let Some(dazi) = parse_floats_from_prefix(line).first() {
572        current.dazi_deg = *dazi;
573    }
574}
575
576fn parse_zenith_grid(line: &str, state: &mut ParseState) {
577    let Some(current) = state.current_antenna.as_mut() else {
578        return;
579    };
580    let values = parse_floats_from_prefix(line);
581    if values.len() >= 3 {
582        current.zenith_start_deg = values[0];
583        current.zenith_end_deg = values[1];
584        current.zenith_step_deg = values[2];
585    }
586}
587
588fn parse_sinex_code(line: &str, state: &mut ParseState) {
589    let Some(current) = state.current_antenna.as_mut() else {
590        return;
591    };
592    let code = raw_field(line, 0, 60).trim();
593    if !code.is_empty() {
594        current.sinex_code = Some(code.to_string());
595    }
596}
597
598#[derive(Debug, Clone, Copy)]
599enum ValidField {
600    From,
601    Until,
602}
603
604fn parse_valid(line: &str, state: &mut ParseState, field: ValidField) -> Result<(), AntexError> {
605    let Some(current) = state.current_antenna.as_mut() else {
606        return Ok(());
607    };
608    let values = parse_floats_from_prefix(line);
609    if values.len() >= 6 {
610        let year = datetime_i32(values[0])?;
611        let month = datetime_u8(values[1])?;
612        let day = datetime_u8(values[2])?;
613        let hour = datetime_u8(values[3])?;
614        let minute = datetime_u8(values[4])?;
615        let civil = validate::civil_datetime_with_second_policy(
616            i64::from(year),
617            i64::from(month),
618            i64::from(day),
619            i64::from(hour),
620            i64::from(minute),
621            values[5],
622            validate::CivilSecondPolicy::UtcLike,
623        )
624        .map_err(|_| AntexError::InvalidDateTime)?;
625        let dt = AntexDateTime::from_valid_civil(civil);
626        match field {
627            ValidField::From => current.valid_from = Some(dt),
628            ValidField::Until => current.valid_until = Some(dt),
629        }
630    }
631    Ok(())
632}
633
634fn datetime_i32(value: f64) -> Result<i32, AntexError> {
635    if !value.is_finite()
636        || value.fract() != 0.0
637        || value < i32::MIN as f64
638        || value > i32::MAX as f64
639    {
640        return Err(AntexError::InvalidDateTime);
641    }
642    Ok(value as i32)
643}
644
645fn datetime_u8(value: f64) -> Result<u8, AntexError> {
646    if !value.is_finite() || value.fract() != 0.0 || value < 0.0 || value > u8::MAX as f64 {
647        return Err(AntexError::InvalidDateTime);
648    }
649    Ok(value as u8)
650}
651
652fn decode_antenna_header(line: &str) -> Antenna {
653    let id = raw_field(line, 0, 60).trim().to_string();
654    let antenna_type = raw_field(line, 0, 20).trim().to_string();
655    let serial = raw_field(line, 20, 40).trim().to_string();
656    let kind = if is_satellite_serial(&serial) {
657        AntennaKind::Satellite
658    } else {
659        AntennaKind::Receiver
660    };
661
662    Antenna {
663        id,
664        kind,
665        antenna_type,
666        serial,
667        dazi_deg: 0.0,
668        zenith_start_deg: 0.0,
669        zenith_end_deg: 0.0,
670        zenith_step_deg: 0.0,
671        sinex_code: None,
672        valid_from: None,
673        valid_until: None,
674        frequencies: BTreeMap::new(),
675    }
676}
677
678fn is_satellite_serial(serial: &str) -> bool {
679    let bytes = serial.as_bytes();
680    bytes.len() == 3
681        && bytes[0].is_ascii_uppercase()
682        && bytes[1].is_ascii_digit()
683        && bytes[2].is_ascii_digit()
684}
685
686fn begin_frequency(line: &str, state: &mut ParseState) {
687    if state.current_antenna.is_none() {
688        return;
689    }
690    state.current_frequency = Some(FrequencyState {
691        frequency: raw_field(line, 0, 20).trim().to_string(),
692        phase: FrequencyPhase::Pco,
693        pco_m: None,
694        samples: Vec::new(),
695    });
696}
697
698fn parse_pco(line: &str, state: &mut ParseState) {
699    let Some(current_frequency) = state.current_frequency.as_mut() else {
700        return;
701    };
702    if current_frequency.phase != FrequencyPhase::Pco {
703        return;
704    }
705
706    let values = parse_floats_from_prefix(line);
707    if values.len() >= 3 && values[..3].iter().all(|value| value.is_finite()) {
708        current_frequency.pco_m = Some([
709            values[0] / MM_PER_M,
710            values[1] / MM_PER_M,
711            values[2] / MM_PER_M,
712        ]);
713        current_frequency.phase = FrequencyPhase::Pcv;
714    }
715}
716
717fn parse_pcv_row(line: &str, state: &mut ParseState) {
718    if state
719        .current_frequency
720        .as_ref()
721        .is_none_or(|frequency| frequency.phase != FrequencyPhase::Pcv)
722    {
723        return;
724    }
725
726    let tokens = parse_tokens(line);
727    let Some((first, values)) = tokens.split_first() else {
728        return;
729    };
730
731    if *first == "NOAZI" {
732        add_pcv_values(None, values, state);
733    } else if let Some(azimuth) = parse_float(first) {
734        add_pcv_values(Some(azimuth), values, state);
735    } else {
736        // A grid row whose head token is neither `NOAZI` nor a parseable azimuth
737        // is recorded as a typed skip rather than silently dropped, consistent
738        // with the rest of the sans-I/O contract. Real ANTEX rows always carry a
739        // recognized head, so a clean file is unaffected.
740        state.diagnostics.push_skip(Skip {
741            at: RecordRef::at_line(state.line),
742            reason: SkipReason::MalformedField(FieldError::FloatParse {
743                field: "antex pcv row head",
744                value: (*first).to_string(),
745            }),
746        });
747    }
748}
749
750fn add_pcv_values(azimuth_deg: Option<f64>, values: &[&str], state: &mut ParseState) {
751    let Some(current_antenna) = state.current_antenna.as_ref() else {
752        return;
753    };
754    let Some(current_frequency) = state.current_frequency.as_mut() else {
755        return;
756    };
757
758    let grid_start = current_antenna.zenith_start_deg;
759    let grid_step = current_antenna.zenith_step_deg;
760    let line = state.line;
761    for (index, value_text) in values.iter().enumerate() {
762        let Some(value) = parse_float(value_text) else {
763            // A malformed PCV grid value is skipped with a typed reason rather
764            // than silently dropped or replaced by a fabricated default. The
765            // remaining valid samples on the row are still recovered.
766            state.diagnostics.push_skip(Skip {
767                at: RecordRef::at_line(line),
768                reason: SkipReason::MalformedField(FieldError::FloatParse {
769                    field: "antex pcv value",
770                    value: (*value_text).to_string(),
771                }),
772            });
773            continue;
774        };
775        let zenith_deg = if grid_step == 0.0 {
776            grid_start
777        } else {
778            grid_start + grid_step * index as f64
779        };
780        current_frequency.samples.push(PcvSample {
781            grid: if azimuth_deg.is_some() {
782                PcvGrid::Azimuth
783            } else {
784                PcvGrid::NoAzimuth
785            },
786            azimuth_deg,
787            zenith_deg,
788            value_m: value / MM_PER_M,
789        });
790    }
791}
792
793fn finalize_frequency(state: &mut ParseState) -> Result<(), AntexError> {
794    let Some(current_frequency) = state.current_frequency.take() else {
795        return Ok(());
796    };
797    let Some(current_antenna) = state.current_antenna.as_mut() else {
798        return Ok(());
799    };
800
801    let pco_m = current_frequency
802        .pco_m
803        .ok_or_else(|| AntexError::MissingPco {
804            antenna_id: current_antenna.id.clone(),
805            frequency: current_frequency.frequency.clone(),
806        })?;
807
808    let frequency = Frequency {
809        frequency: current_frequency.frequency,
810        pco_m,
811        pcv_samples: current_frequency.samples,
812    };
813    current_antenna
814        .frequencies
815        .insert(frequency.frequency.clone(), frequency);
816    Ok(())
817}
818
819fn finalize_antenna(state: &mut ParseState) -> Result<(), AntexError> {
820    finalize_frequency(state)?;
821    let Some(current_antenna) = state.current_antenna.take() else {
822        return Ok(());
823    };
824    state
825        .antenna_intervals
826        .entry(current_antenna.id.clone())
827        .or_default()
828        .push(current_antenna.clone());
829    state
830        .antennas
831        .insert(current_antenna.id.clone(), current_antenna);
832    Ok(())
833}
834
835fn interpolate_azimuth(
836    antenna_id: &str,
837    frequency: &str,
838    azimuth_samples: &BTreeMap<OrderedF64, Vec<(f64, f64)>>,
839    azimuth_deg: f64,
840    zenith_deg: f64,
841) -> Result<f64, AntexError> {
842    let azimuth = antenna::normalize_azimuth(azimuth_deg);
843    let azimuths: Vec<f64> = azimuth_samples.keys().map(|az| az.0).collect();
844    let (low_deg, high_deg) = antenna::azimuth_bracket(&azimuths, azimuth);
845
846    let low_samples = &azimuth_samples[&OrderedF64(low_deg)];
847    let high_samples = &azimuth_samples[&OrderedF64(high_deg)];
848
849    let low_value = interpolate(antenna_id, frequency, low_samples, zenith_deg)?;
850    let high_value = interpolate(antenna_id, frequency, high_samples, zenith_deg)?;
851
852    Ok(antenna::blend_azimuth(
853        low_deg, high_deg, azimuth, low_value, high_value,
854    ))
855}
856
857fn interpolate(
858    antenna_id: &str,
859    frequency: &str,
860    samples: &[(f64, f64)],
861    zenith_deg: f64,
862) -> Result<f64, AntexError> {
863    if samples.is_empty() {
864        return Err(AntexError::EmptyPcvGrid {
865            antenna_id: antenna_id.to_string(),
866            frequency: frequency.to_string(),
867        });
868    }
869
870    let mut sorted = samples.to_vec();
871    sorted.sort_by(|a, b| a.0.total_cmp(&b.0));
872
873    Ok(antenna::interpolate_zenith_sorted(&sorted, zenith_deg)
874        .expect("non-empty grid yields a value"))
875}
876
877fn tag(line: &str) -> &str {
878    raw_field(line, 60, 80).trim()
879}
880
881fn parse_tokens(line: &str) -> Vec<&str> {
882    line.split_whitespace().collect()
883}
884
885fn parse_floats_from_prefix(line: &str) -> Vec<f64> {
886    let mut values = Vec::new();
887    for token in parse_tokens(line) {
888        let Some(value) = parse_float(token) else {
889            break;
890        };
891        values.push(value);
892    }
893    values
894}
895
896fn parse_float(token: &str) -> Option<f64> {
897    fortran_f64(token, 0, token.len(), "antex numeric field")
898}
899
900#[cfg(test)]
901mod tests {
902    use super::*;
903
904    fn test_antenna() -> Antenna {
905        Antenna {
906            id: "TESTANT TESTSER".to_string(),
907            kind: AntennaKind::Receiver,
908            antenna_type: "TESTANT".to_string(),
909            serial: "TESTSER".to_string(),
910            dazi_deg: 0.0,
911            zenith_start_deg: 0.0,
912            zenith_end_deg: 10.0,
913            zenith_step_deg: 10.0,
914            sinex_code: None,
915            valid_from: None,
916            valid_until: None,
917            frequencies: BTreeMap::from([(
918                "G01".to_string(),
919                Frequency {
920                    frequency: "G01".to_string(),
921                    pco_m: [0.0, 0.0, 0.0],
922                    pcv_samples: vec![
923                        PcvSample {
924                            grid: PcvGrid::NoAzimuth,
925                            azimuth_deg: None,
926                            zenith_deg: 0.0,
927                            value_m: 1.0,
928                        },
929                        PcvSample {
930                            grid: PcvGrid::NoAzimuth,
931                            azimuth_deg: None,
932                            zenith_deg: 10.0,
933                            value_m: 3.0,
934                        },
935                    ],
936                },
937            )]),
938        }
939    }
940
941    #[test]
942    fn pcv_rejects_nonfinite_zenith() {
943        let antenna = test_antenna();
944        for zenith_deg in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
945            assert_eq!(
946                antenna.pcv("G01", zenith_deg, None),
947                Err(AntexError::InvalidInput {
948                    field: "zenith_deg",
949                    reason: "not finite"
950                })
951            );
952        }
953    }
954
955    #[test]
956    fn pcv_rejects_out_of_range_zenith() {
957        let antenna = test_antenna();
958        assert_eq!(
959            antenna.pcv("G01", 11.0, None),
960            Err(AntexError::InvalidInput {
961                field: "zenith_deg",
962                reason: "out of range"
963            })
964        );
965    }
966
967    #[test]
968    fn pcv_accepts_valid_zenith_unchanged() {
969        let antenna = test_antenna();
970        let got = antenna.pcv("G01", 5.0, None).expect("valid PCV");
971        assert_eq!(got.to_bits(), 2.0_f64.to_bits());
972    }
973
974    fn line(prefix: &str, tag: &str) -> String {
975        format!("{prefix:<60}{tag}")
976    }
977
978    fn synthetic_block() -> String {
979        [
980            line("", "START OF ANTENNA"),
981            line("TESTANT             TESTSER", "TYPE / SERIAL NO"),
982            line("0", "DAZI"),
983            line("0.0 10.0 5.0", "ZEN1 / ZEN2 / DZEN"),
984            line("IGS_TEST", "SINEX CODE"),
985            line("2020 1 1 0 0 0", "VALID FROM"),
986            line("2021 12 31 23 59 59", "VALID UNTIL"),
987            line("G01", "START OF FREQUENCY"),
988            line("1.5 2.0 3.0", "NORTH / EAST / UP"),
989            "NOAZI 1.0 2.0 3.0".to_string(),
990            "0.0 1.0 2.0 3.0".to_string(),
991            "90.0 4.0 5.0 6.0".to_string(),
992            line("", "END OF FREQUENCY"),
993            line("", "END OF ANTENNA"),
994        ]
995        .join("\n")
996    }
997
998    #[test]
999    fn encode_round_trips_synthetic_block() {
1000        let antex = Antex::parse(&synthetic_block()).expect("parse synthetic block");
1001        assert_eq!(antex.skipped_records(), 0);
1002        let encoded = antex.encode();
1003        let reparsed = Antex::parse(&encoded).expect("re-parse encoded block");
1004        assert_eq!(antex, reparsed);
1005        assert_eq!(encoded, reparsed.encode());
1006    }
1007
1008    #[test]
1009    fn malformed_pcv_value_is_skipped_not_silent() {
1010        // One corrupt grid value on an otherwise valid NOAZI row: the parse
1011        // succeeds, the bad value is dropped with a typed skip (counted), and the
1012        // surrounding valid samples survive - never silently dropped, never a
1013        // fabricated default.
1014        let text = [
1015            line("", "START OF ANTENNA"),
1016            line("TESTANT             TESTSER", "TYPE / SERIAL NO"),
1017            line("0.0 10.0 5.0", "ZEN1 / ZEN2 / DZEN"),
1018            line("G01", "START OF FREQUENCY"),
1019            line("0.0 0.0 0.0", "NORTH / EAST / UP"),
1020            "NOAZI 1.0 BAD 3.0".to_string(),
1021            line("", "END OF FREQUENCY"),
1022            line("", "END OF ANTENNA"),
1023        ]
1024        .join("\n");
1025
1026        let antex = Antex::parse(&text).expect("forgiving parse");
1027        assert_eq!(antex.skipped_records(), 1);
1028        let antenna = antex
1029            .antenna("TESTANT             TESTSER")
1030            .expect("antenna");
1031        let frequency = &antenna.frequencies["G01"];
1032        // Three tokens on the row, one malformed: two valid samples recovered.
1033        assert_eq!(frequency.pcv_samples.len(), 2);
1034    }
1035}