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::parse::{fortran_f64, raw_field};
10use crate::validate;
11use std::collections::BTreeMap;
12use std::fmt;
13
14/// Parsed ANTEX antenna calibration product.
15#[derive(Debug, Clone, PartialEq)]
16pub struct Antex {
17    pub antennas: BTreeMap<String, Antenna>,
18    antenna_intervals: BTreeMap<String, Vec<Antenna>>,
19}
20
21/// Receiver or satellite antenna block.
22#[derive(Debug, Clone, PartialEq)]
23pub struct Antenna {
24    pub id: String,
25    pub kind: AntennaKind,
26    pub antenna_type: String,
27    pub serial: String,
28    pub dazi_deg: f64,
29    pub zenith_start_deg: f64,
30    pub zenith_end_deg: f64,
31    pub zenith_step_deg: f64,
32    pub sinex_code: Option<String>,
33    pub valid_from: Option<AntexDateTime>,
34    pub valid_until: Option<AntexDateTime>,
35    pub frequencies: BTreeMap<String, Frequency>,
36}
37
38/// ANTEX antenna block role.
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum AntennaKind {
41    Receiver,
42    Satellite,
43}
44
45/// Frequency-specific PCO/PCV calibration block.
46#[derive(Debug, Clone, PartialEq)]
47pub struct Frequency {
48    pub frequency: String,
49    pub pco_m: [f64; 3],
50    pub pcv_samples: Vec<PcvSample>,
51}
52
53/// One phase-center-variation grid value.
54#[derive(Debug, Clone, PartialEq)]
55pub struct PcvSample {
56    pub grid: PcvGrid,
57    pub azimuth_deg: Option<f64>,
58    pub zenith_deg: f64,
59    pub value_m: f64,
60}
61
62/// PCV grid type.
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum PcvGrid {
65    NoAzimuth,
66    Azimuth,
67}
68
69/// Civil UTC-like timestamp fields from `VALID FROM` / `VALID UNTIL`.
70#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
71pub struct AntexDateTime {
72    pub year: i32,
73    pub month: u8,
74    pub day: u8,
75    pub hour: u8,
76    pub minute: u8,
77    pub second: u8,
78}
79
80/// ANTEX parse or lookup error.
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub enum AntexError {
83    InvalidDateTime,
84    InvalidInput {
85        field: &'static str,
86        reason: &'static str,
87    },
88    UnknownFrequency {
89        antenna_id: String,
90        frequency: String,
91    },
92    MissingPco {
93        antenna_id: String,
94        frequency: String,
95    },
96    EmptyPcvGrid {
97        antenna_id: String,
98        frequency: String,
99    },
100}
101
102impl fmt::Display for AntexError {
103    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
104        match self {
105            Self::InvalidDateTime => write!(f, "invalid ANTEX datetime"),
106            Self::InvalidInput { field, reason } => {
107                write!(f, "invalid ANTEX input {field}: {reason}")
108            }
109            Self::UnknownFrequency {
110                antenna_id,
111                frequency,
112            } => write!(f, "unknown frequency {frequency:?} for {antenna_id:?}"),
113            Self::MissingPco {
114                antenna_id,
115                frequency,
116            } => write!(
117                f,
118                "missing or malformed PCO for frequency {frequency:?} on {antenna_id:?}"
119            ),
120            Self::EmptyPcvGrid {
121                antenna_id,
122                frequency,
123            } => write!(
124                f,
125                "empty PCV grid for frequency {frequency:?} on {antenna_id:?}"
126            ),
127        }
128    }
129}
130
131impl std::error::Error for AntexError {}
132
133#[derive(Debug, Clone)]
134struct ParseState {
135    antennas: BTreeMap<String, Antenna>,
136    antenna_intervals: BTreeMap<String, Vec<Antenna>>,
137    current_antenna: Option<Antenna>,
138    current_frequency: Option<FrequencyState>,
139}
140
141#[derive(Debug, Clone)]
142struct FrequencyState {
143    frequency: String,
144    phase: FrequencyPhase,
145    pco_m: Option<[f64; 3]>,
146    samples: Vec<PcvSample>,
147}
148
149#[derive(Debug, Clone, Copy, PartialEq, Eq)]
150enum FrequencyPhase {
151    Pco,
152    Pcv,
153}
154
155impl Antex {
156    /// Parse ANTEX text into receiver and satellite antenna blocks.
157    pub fn parse(text: &str) -> Result<Self, AntexError> {
158        let mut state = ParseState {
159            antennas: BTreeMap::new(),
160            antenna_intervals: BTreeMap::new(),
161            current_antenna: None,
162            current_frequency: None,
163        };
164
165        for line in text.lines() {
166            step(line, &mut state)?;
167        }
168        finalize_antenna(&mut state)?;
169
170        Ok(Self {
171            antennas: state.antennas,
172            antenna_intervals: state.antenna_intervals,
173        })
174    }
175
176    /// Return an antenna by the `TYPE / SERIAL` id.
177    pub fn antenna(&self, id: &str) -> Option<&Antenna> {
178        self.antennas.get(id.trim())
179    }
180
181    /// Return all validity blocks for a `TYPE / SERIAL` id, in file order.
182    pub fn antenna_intervals(&self, id: &str) -> impl Iterator<Item = &Antenna> {
183        self.antenna_intervals.get(id.trim()).into_iter().flatten()
184    }
185
186    /// Return the antenna validity block for a `TYPE / SERIAL` id at an epoch.
187    pub fn antenna_at(&self, id: &str, epoch: AntexDateTime) -> Option<&Antenna> {
188        self.antenna_intervals(id)
189            .find(|antenna| antenna.valid_at(epoch))
190    }
191
192    /// Return the satellite antenna block for a PRN at an epoch.
193    pub fn satellite_antenna(&self, prn: &str, epoch: AntexDateTime) -> Option<&Antenna> {
194        let prn = prn.trim();
195        self.antenna_intervals.values().flatten().find(|antenna| {
196            antenna.kind == AntennaKind::Satellite
197                && antenna.serial.trim() == prn
198                && antenna.valid_at(epoch)
199        })
200    }
201}
202
203impl Antenna {
204    /// Whether this antenna block is valid at `epoch`.
205    pub fn valid_at(&self, epoch: AntexDateTime) -> bool {
206        self.valid_from.is_none_or(|from| epoch >= from)
207            && self.valid_until.is_none_or(|until| epoch <= until)
208    }
209
210    /// Frequency-dependent PCO (north/east/up), meters.
211    pub fn pco(&self, frequency: &str) -> Result<[f64; 3], AntexError> {
212        self.frequencies
213            .get(frequency.trim())
214            .map(|f| f.pco_m)
215            .ok_or_else(|| AntexError::UnknownFrequency {
216                antenna_id: self.id.clone(),
217                frequency: frequency.to_string(),
218            })
219    }
220
221    /// Frequency-dependent PCV, meters, with linear zenith/azimuth interpolation.
222    pub fn pcv(
223        &self,
224        frequency: &str,
225        zenith_deg: f64,
226        azimuth_deg: Option<f64>,
227    ) -> Result<f64, AntexError> {
228        validate_pcv_zenith(zenith_deg, self.zenith_start_deg, self.zenith_end_deg)?;
229
230        let frequency =
231            self.frequencies
232                .get(frequency.trim())
233                .ok_or_else(|| AntexError::UnknownFrequency {
234                    antenna_id: self.id.clone(),
235                    frequency: frequency.to_string(),
236                })?;
237
238        frequency.pcv(self.id.as_str(), zenith_deg, azimuth_deg)
239    }
240}
241
242impl Frequency {
243    fn pcv(
244        &self,
245        antenna_id: &str,
246        zenith_deg: f64,
247        azimuth_deg: Option<f64>,
248    ) -> Result<f64, AntexError> {
249        let noazi: Vec<(f64, f64)> = self
250            .pcv_samples
251            .iter()
252            .filter(|sample| sample.grid == PcvGrid::NoAzimuth)
253            .map(|sample| (sample.zenith_deg, sample.value_m))
254            .collect();
255
256        let has_azimuth = self
257            .pcv_samples
258            .iter()
259            .any(|sample| sample.grid == PcvGrid::Azimuth);
260
261        if azimuth_deg.is_none() || !has_azimuth {
262            return interpolate(antenna_id, &self.frequency, &noazi, zenith_deg);
263        }
264
265        let mut azimuth_samples: BTreeMap<OrderedF64, Vec<(f64, f64)>> = BTreeMap::new();
266        for sample in self
267            .pcv_samples
268            .iter()
269            .filter(|sample| sample.grid == PcvGrid::Azimuth)
270        {
271            if let Some(azimuth) = sample.azimuth_deg {
272                azimuth_samples
273                    .entry(OrderedF64(azimuth))
274                    .or_default()
275                    .push((sample.zenith_deg, sample.value_m));
276            }
277        }
278
279        if azimuth_samples.is_empty() {
280            interpolate(antenna_id, &self.frequency, &noazi, zenith_deg)
281        } else {
282            interpolate_azimuth(
283                antenna_id,
284                &self.frequency,
285                &azimuth_samples,
286                azimuth_deg.expect("checked Some"),
287                zenith_deg,
288            )
289        }
290    }
291}
292
293fn validate_pcv_zenith(
294    zenith_deg: f64,
295    zenith_start_deg: f64,
296    zenith_end_deg: f64,
297) -> Result<(), AntexError> {
298    validate::finite(zenith_deg, "zenith_deg").map_err(map_antex_field_error)?;
299    if zenith_deg < zenith_start_deg || zenith_deg > zenith_end_deg {
300        return Err(invalid_input("zenith_deg", "out of range"));
301    }
302    Ok(())
303}
304
305fn map_antex_field_error(error: validate::FieldError) -> AntexError {
306    invalid_input(error.field(), error.reason())
307}
308
309fn invalid_input(field: &'static str, reason: &'static str) -> AntexError {
310    AntexError::InvalidInput { field, reason }
311}
312
313impl AntexDateTime {
314    pub fn new(
315        year: i32,
316        month: u8,
317        day: u8,
318        hour: u8,
319        minute: u8,
320        second: u8,
321    ) -> Result<Self, AntexError> {
322        let civil = validate::civil_datetime_with_second_policy(
323            i64::from(year),
324            i64::from(month),
325            i64::from(day),
326            i64::from(hour),
327            i64::from(minute),
328            f64::from(second),
329            validate::CivilSecondPolicy::UtcLike,
330        )
331        .map_err(|_| AntexError::InvalidDateTime)?;
332        Ok(Self::from_valid_civil(civil))
333    }
334
335    fn from_valid_civil(civil: validate::ValidCivil) -> Self {
336        Self {
337            year: civil.year as i32,
338            month: civil.month as u8,
339            day: civil.day as u8,
340            hour: civil.hour as u8,
341            minute: civil.minute as u8,
342            second: civil.second.trunc() as u8,
343        }
344    }
345}
346
347#[derive(Debug, Clone, Copy, PartialEq)]
348struct OrderedF64(f64);
349
350impl Eq for OrderedF64 {}
351
352impl PartialOrd for OrderedF64 {
353    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
354        Some(self.cmp(other))
355    }
356}
357
358impl Ord for OrderedF64 {
359    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
360        self.0.total_cmp(&other.0)
361    }
362}
363
364fn step(line: &str, state: &mut ParseState) -> Result<(), AntexError> {
365    match tag(line) {
366        "START OF ANTENNA" => {
367            finalize_antenna(state)?;
368            state.current_antenna = None;
369            state.current_frequency = None;
370        }
371        "END OF ANTENNA" => finalize_antenna(state)?,
372        "TYPE / SERIAL NO" => parse_type_serial(line, state),
373        "DAZI" => parse_dazi(line, state),
374        "ZEN1 / ZEN2 / DZEN" => parse_zenith_grid(line, state),
375        "SINEX CODE" => parse_sinex_code(line, state),
376        "VALID FROM" => parse_valid(line, state, ValidField::From)?,
377        "VALID UNTIL" => parse_valid(line, state, ValidField::Until)?,
378        "START OF FREQUENCY" => begin_frequency(line, state),
379        "END OF FREQUENCY" => finalize_frequency(state)?,
380        "NORTH / EAST / UP" => parse_pco(line, state),
381        _ => parse_pcv_row(line, state),
382    }
383    Ok(())
384}
385
386fn parse_type_serial(line: &str, state: &mut ParseState) {
387    state.current_antenna = Some(decode_antenna_header(line));
388    state.current_frequency = None;
389}
390
391fn parse_dazi(line: &str, state: &mut ParseState) {
392    let Some(current) = state.current_antenna.as_mut() else {
393        return;
394    };
395    if let Some(dazi) = parse_floats_from_prefix(line).first() {
396        current.dazi_deg = *dazi;
397    }
398}
399
400fn parse_zenith_grid(line: &str, state: &mut ParseState) {
401    let Some(current) = state.current_antenna.as_mut() else {
402        return;
403    };
404    let values = parse_floats_from_prefix(line);
405    if values.len() >= 3 {
406        current.zenith_start_deg = values[0];
407        current.zenith_end_deg = values[1];
408        current.zenith_step_deg = values[2];
409    }
410}
411
412fn parse_sinex_code(line: &str, state: &mut ParseState) {
413    let Some(current) = state.current_antenna.as_mut() else {
414        return;
415    };
416    let code = raw_field(line, 0, 60).trim();
417    if !code.is_empty() {
418        current.sinex_code = Some(code.to_string());
419    }
420}
421
422#[derive(Debug, Clone, Copy)]
423enum ValidField {
424    From,
425    Until,
426}
427
428fn parse_valid(line: &str, state: &mut ParseState, field: ValidField) -> Result<(), AntexError> {
429    let Some(current) = state.current_antenna.as_mut() else {
430        return Ok(());
431    };
432    let values = parse_floats_from_prefix(line);
433    if values.len() >= 6 {
434        let year = datetime_i32(values[0])?;
435        let month = datetime_u8(values[1])?;
436        let day = datetime_u8(values[2])?;
437        let hour = datetime_u8(values[3])?;
438        let minute = datetime_u8(values[4])?;
439        let civil = validate::civil_datetime_with_second_policy(
440            i64::from(year),
441            i64::from(month),
442            i64::from(day),
443            i64::from(hour),
444            i64::from(minute),
445            values[5],
446            validate::CivilSecondPolicy::UtcLike,
447        )
448        .map_err(|_| AntexError::InvalidDateTime)?;
449        let dt = AntexDateTime::from_valid_civil(civil);
450        match field {
451            ValidField::From => current.valid_from = Some(dt),
452            ValidField::Until => current.valid_until = Some(dt),
453        }
454    }
455    Ok(())
456}
457
458fn datetime_i32(value: f64) -> Result<i32, AntexError> {
459    if !value.is_finite()
460        || value.fract() != 0.0
461        || value < i32::MIN as f64
462        || value > i32::MAX as f64
463    {
464        return Err(AntexError::InvalidDateTime);
465    }
466    Ok(value as i32)
467}
468
469fn datetime_u8(value: f64) -> Result<u8, AntexError> {
470    if !value.is_finite() || value.fract() != 0.0 || value < 0.0 || value > u8::MAX as f64 {
471        return Err(AntexError::InvalidDateTime);
472    }
473    Ok(value as u8)
474}
475
476fn decode_antenna_header(line: &str) -> Antenna {
477    let id = raw_field(line, 0, 60).trim().to_string();
478    let antenna_type = raw_field(line, 0, 20).trim().to_string();
479    let serial = raw_field(line, 20, 40).trim().to_string();
480    let kind = if is_satellite_serial(&serial) {
481        AntennaKind::Satellite
482    } else {
483        AntennaKind::Receiver
484    };
485
486    Antenna {
487        id,
488        kind,
489        antenna_type,
490        serial,
491        dazi_deg: 0.0,
492        zenith_start_deg: 0.0,
493        zenith_end_deg: 0.0,
494        zenith_step_deg: 0.0,
495        sinex_code: None,
496        valid_from: None,
497        valid_until: None,
498        frequencies: BTreeMap::new(),
499    }
500}
501
502fn is_satellite_serial(serial: &str) -> bool {
503    let bytes = serial.as_bytes();
504    bytes.len() == 3
505        && bytes[0].is_ascii_uppercase()
506        && bytes[1].is_ascii_digit()
507        && bytes[2].is_ascii_digit()
508}
509
510fn begin_frequency(line: &str, state: &mut ParseState) {
511    if state.current_antenna.is_none() {
512        return;
513    }
514    state.current_frequency = Some(FrequencyState {
515        frequency: raw_field(line, 0, 20).trim().to_string(),
516        phase: FrequencyPhase::Pco,
517        pco_m: None,
518        samples: Vec::new(),
519    });
520}
521
522fn parse_pco(line: &str, state: &mut ParseState) {
523    let Some(current_frequency) = state.current_frequency.as_mut() else {
524        return;
525    };
526    if current_frequency.phase != FrequencyPhase::Pco {
527        return;
528    }
529
530    let values = parse_floats_from_prefix(line);
531    if values.len() >= 3 && values[..3].iter().all(|value| value.is_finite()) {
532        current_frequency.pco_m = Some([
533            values[0] / MM_PER_M,
534            values[1] / MM_PER_M,
535            values[2] / MM_PER_M,
536        ]);
537        current_frequency.phase = FrequencyPhase::Pcv;
538    }
539}
540
541fn parse_pcv_row(line: &str, state: &mut ParseState) {
542    if state
543        .current_frequency
544        .as_ref()
545        .is_none_or(|frequency| frequency.phase != FrequencyPhase::Pcv)
546    {
547        return;
548    }
549
550    let tokens = parse_tokens(line);
551    let Some((first, values)) = tokens.split_first() else {
552        return;
553    };
554
555    if *first == "NOAZI" {
556        add_pcv_values(None, values, state);
557    } else if let Some(azimuth) = parse_float(first) {
558        add_pcv_values(Some(azimuth), values, state);
559    }
560}
561
562fn add_pcv_values(azimuth_deg: Option<f64>, values: &[&str], state: &mut ParseState) {
563    let Some(current_antenna) = state.current_antenna.as_ref() else {
564        return;
565    };
566    let Some(current_frequency) = state.current_frequency.as_mut() else {
567        return;
568    };
569
570    let grid_start = current_antenna.zenith_start_deg;
571    let grid_step = current_antenna.zenith_step_deg;
572    for (index, value_text) in values.iter().enumerate() {
573        let Some(value) = parse_float(value_text) else {
574            continue;
575        };
576        let zenith_deg = if grid_step == 0.0 {
577            grid_start
578        } else {
579            grid_start + grid_step * index as f64
580        };
581        current_frequency.samples.push(PcvSample {
582            grid: if azimuth_deg.is_some() {
583                PcvGrid::Azimuth
584            } else {
585                PcvGrid::NoAzimuth
586            },
587            azimuth_deg,
588            zenith_deg,
589            value_m: value / MM_PER_M,
590        });
591    }
592}
593
594fn finalize_frequency(state: &mut ParseState) -> Result<(), AntexError> {
595    let Some(current_frequency) = state.current_frequency.take() else {
596        return Ok(());
597    };
598    let Some(current_antenna) = state.current_antenna.as_mut() else {
599        return Ok(());
600    };
601
602    let pco_m = current_frequency
603        .pco_m
604        .ok_or_else(|| AntexError::MissingPco {
605            antenna_id: current_antenna.id.clone(),
606            frequency: current_frequency.frequency.clone(),
607        })?;
608
609    let frequency = Frequency {
610        frequency: current_frequency.frequency,
611        pco_m,
612        pcv_samples: current_frequency.samples,
613    };
614    current_antenna
615        .frequencies
616        .insert(frequency.frequency.clone(), frequency);
617    Ok(())
618}
619
620fn finalize_antenna(state: &mut ParseState) -> Result<(), AntexError> {
621    finalize_frequency(state)?;
622    let Some(current_antenna) = state.current_antenna.take() else {
623        return Ok(());
624    };
625    state
626        .antenna_intervals
627        .entry(current_antenna.id.clone())
628        .or_default()
629        .push(current_antenna.clone());
630    state
631        .antennas
632        .insert(current_antenna.id.clone(), current_antenna);
633    Ok(())
634}
635
636fn interpolate_azimuth(
637    antenna_id: &str,
638    frequency: &str,
639    azimuth_samples: &BTreeMap<OrderedF64, Vec<(f64, f64)>>,
640    azimuth_deg: f64,
641    zenith_deg: f64,
642) -> Result<f64, AntexError> {
643    let azimuth = antenna::normalize_azimuth(azimuth_deg);
644    let azimuths: Vec<f64> = azimuth_samples.keys().map(|az| az.0).collect();
645    let (low_deg, high_deg) = antenna::azimuth_bracket(&azimuths, azimuth);
646
647    let low_samples = &azimuth_samples[&OrderedF64(low_deg)];
648    let high_samples = &azimuth_samples[&OrderedF64(high_deg)];
649
650    let low_value = interpolate(antenna_id, frequency, low_samples, zenith_deg)?;
651    let high_value = interpolate(antenna_id, frequency, high_samples, zenith_deg)?;
652
653    Ok(antenna::blend_azimuth(
654        low_deg, high_deg, azimuth, low_value, high_value,
655    ))
656}
657
658fn interpolate(
659    antenna_id: &str,
660    frequency: &str,
661    samples: &[(f64, f64)],
662    zenith_deg: f64,
663) -> Result<f64, AntexError> {
664    if samples.is_empty() {
665        return Err(AntexError::EmptyPcvGrid {
666            antenna_id: antenna_id.to_string(),
667            frequency: frequency.to_string(),
668        });
669    }
670
671    let mut sorted = samples.to_vec();
672    sorted.sort_by(|a, b| a.0.total_cmp(&b.0));
673
674    Ok(antenna::interpolate_zenith_sorted(&sorted, zenith_deg)
675        .expect("non-empty grid yields a value"))
676}
677
678fn tag(line: &str) -> &str {
679    raw_field(line, 60, 80).trim()
680}
681
682fn parse_tokens(line: &str) -> Vec<&str> {
683    line.split_whitespace().collect()
684}
685
686fn parse_floats_from_prefix(line: &str) -> Vec<f64> {
687    let mut values = Vec::new();
688    for token in parse_tokens(line) {
689        let Some(value) = parse_float(token) else {
690            break;
691        };
692        values.push(value);
693    }
694    values
695}
696
697fn parse_float(token: &str) -> Option<f64> {
698    fortran_f64(token, 0, token.len())
699}
700
701#[cfg(test)]
702mod tests {
703    use super::*;
704
705    fn test_antenna() -> Antenna {
706        Antenna {
707            id: "TESTANT TESTSER".to_string(),
708            kind: AntennaKind::Receiver,
709            antenna_type: "TESTANT".to_string(),
710            serial: "TESTSER".to_string(),
711            dazi_deg: 0.0,
712            zenith_start_deg: 0.0,
713            zenith_end_deg: 10.0,
714            zenith_step_deg: 10.0,
715            sinex_code: None,
716            valid_from: None,
717            valid_until: None,
718            frequencies: BTreeMap::from([(
719                "G01".to_string(),
720                Frequency {
721                    frequency: "G01".to_string(),
722                    pco_m: [0.0, 0.0, 0.0],
723                    pcv_samples: vec![
724                        PcvSample {
725                            grid: PcvGrid::NoAzimuth,
726                            azimuth_deg: None,
727                            zenith_deg: 0.0,
728                            value_m: 1.0,
729                        },
730                        PcvSample {
731                            grid: PcvGrid::NoAzimuth,
732                            azimuth_deg: None,
733                            zenith_deg: 10.0,
734                            value_m: 3.0,
735                        },
736                    ],
737                },
738            )]),
739        }
740    }
741
742    #[test]
743    fn pcv_rejects_nonfinite_zenith() {
744        let antenna = test_antenna();
745        for zenith_deg in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
746            assert_eq!(
747                antenna.pcv("G01", zenith_deg, None),
748                Err(AntexError::InvalidInput {
749                    field: "zenith_deg",
750                    reason: "not finite"
751                })
752            );
753        }
754    }
755
756    #[test]
757    fn pcv_rejects_out_of_range_zenith() {
758        let antenna = test_antenna();
759        assert_eq!(
760            antenna.pcv("G01", 11.0, None),
761            Err(AntexError::InvalidInput {
762                field: "zenith_deg",
763                reason: "out of range"
764            })
765        );
766    }
767
768    #[test]
769    fn pcv_accepts_valid_zenith_unchanged() {
770        let antenna = test_antenna();
771        let got = antenna.pcv("G01", 5.0, None).expect("valid PCV");
772        assert_eq!(got.to_bits(), 2.0_f64.to_bits());
773    }
774}