rinex/
lib.rs

1#![doc(
2    html_logo_url = "https://raw.githubusercontent.com/nav-solutions/.github/master/logos/logo2.jpg"
3)]
4#![doc = include_str!("../README.md")]
5#![cfg_attr(docsrs, feature(doc_cfg))]
6#![allow(clippy::type_complexity)]
7
8/*
9 * RINEX is part of the nav-solutions framework.
10 * Authors: Guillaume W. Bres <guillaume.bressaix@gmail.com> et al.
11 * (cf. https://github.com/nav-solutions/rinex/graphs/contributors)
12 * This framework is shipped under Mozilla Public V2 license.
13 *
14 * Documentation: https://github.com/nav-solutions/rinex
15 */
16
17extern crate num_derive;
18
19#[macro_use]
20extern crate lazy_static;
21
22#[cfg(feature = "serde")]
23#[macro_use]
24extern crate serde;
25
26extern crate gnss_rs as gnss;
27extern crate num;
28
29#[cfg(feature = "qc")]
30extern crate gnss_qc_traits as qc_traits;
31
32pub mod antex;
33pub mod carrier;
34pub mod clock;
35pub mod doris;
36pub mod error;
37pub mod hardware;
38pub mod hatanaka;
39pub mod header;
40pub mod marker;
41pub mod meteo;
42pub mod navigation;
43pub mod observation;
44pub mod production;
45pub mod record;
46pub mod types;
47pub mod version;
48
49mod bibliography;
50mod constants;
51mod epoch;
52mod iterators;
53mod leap;
54mod linspace;
55mod observable;
56mod sampling;
57
58#[cfg(feature = "qc")]
59#[cfg_attr(docsrs, doc(cfg(feature = "qc")))]
60mod qc;
61
62#[cfg(feature = "processing")]
63#[cfg_attr(docsrs, doc(cfg(feature = "processing")))]
64mod processing;
65
66#[cfg(feature = "binex")]
67#[cfg_attr(docsrs, doc(cfg(feature = "binex")))]
68mod binex;
69
70#[cfg(feature = "rtcm")]
71#[cfg_attr(docsrs, doc(cfg(feature = "rtcm")))]
72mod rtcm;
73
74#[cfg(test)]
75mod tests;
76
77use std::{
78    collections::HashMap,
79    fs::File,
80    io::{BufReader, BufWriter, Read, Write},
81    path::Path,
82    str::FromStr,
83};
84
85use itertools::Itertools;
86
87use antex::{Antenna, FrequencyDependentData};
88
89#[cfg(feature = "antex")]
90use antex::{AntennaMatcher, AntennaSpecific};
91
92#[cfg(feature = "flate2")]
93use flate2::{read::GzDecoder, write::GzEncoder, Compression as GzCompression};
94
95#[cfg(feature = "clock")]
96use std::collections::BTreeMap;
97
98use crate::{
99    epoch::epoch_decompose,
100    hatanaka::CRINEX,
101    observable::Observable,
102    production::{DataSource, DetailedProductionAttributes, ProductionAttributes, FFU, PPU},
103};
104
105/// Package to include all basic structures
106pub mod prelude {
107    // export
108    pub use crate::{
109        carrier::Carrier,
110        doris::Station,
111        error::{Error, FormattingError, ParsingError},
112        hatanaka::{
113            Decompressor, DecompressorExpert, DecompressorExpertIO, DecompressorIO, CRINEX,
114        },
115        header::Header,
116        leap::Leap,
117        observable::Observable,
118        types::Type as RinexType,
119        version::Version,
120        Rinex,
121    };
122
123    pub use crate::marker::{GeodeticMarker, MarkerType};
124
125    pub use crate::meteo::MeteoKey;
126
127    pub use crate::prod::ProductionAttributes;
128    pub use crate::record::{Comments, Record};
129
130    // pub re-export
131    pub use gnss::prelude::{Constellation, DOMESTrackingPoint, COSPAR, DOMES, SV};
132    pub use hifitime::{Duration, Epoch, Polynomial, TimeScale, TimeSeries};
133
134    #[cfg(feature = "antex")]
135    #[cfg_attr(docsrs, doc(cfg(feature = "antex")))]
136    pub mod antex {
137        pub use crate::antex::AntennaMatcher;
138    }
139
140    #[cfg(feature = "obs")]
141    #[cfg_attr(docsrs, doc(cfg(feature = "obs")))]
142    pub mod obs {
143        pub use crate::carrier::Carrier;
144
145        pub use crate::observation::{
146            ClockObservation, Combination, CombinationKey, EpochFlag, LliFlags, ObsKey,
147            Observations, SignalObservation, SNR,
148        };
149    }
150
151    #[cfg(feature = "binex")]
152    #[cfg_attr(docsrs, doc(cfg(feature = "binex")))]
153    pub mod binex {
154        pub use crate::binex::{BIN2RNX, RNX2BIN};
155        pub use binex::prelude::{Message, Meta};
156    }
157
158    #[cfg(feature = "clock")]
159    #[cfg_attr(docsrs, doc(cfg(feature = "clock")))]
160    pub mod clock {
161        pub use crate::clock::{ClockKey, ClockProfile, ClockProfileType, ClockType, WorkClock};
162    }
163
164    #[cfg(feature = "nav")]
165    #[cfg_attr(docsrs, doc(cfg(feature = "nav")))]
166    pub mod nav {
167        pub use anise::{
168            astro::AzElRange,
169            errors::AlmanacResult,
170            prelude::{Almanac, Frame, Orbit},
171        };
172    }
173
174    #[cfg(feature = "ut1")]
175    #[cfg_attr(docsrs, doc(cfg(feature = "ut1")))]
176    pub mod ut1 {
177        pub use hifitime::ut1::{DeltaTaiUt1, Ut1Provider};
178    }
179
180    #[cfg(feature = "qc")]
181    #[cfg_attr(docsrs, doc(cfg(feature = "qc")))]
182    pub mod qc {
183        pub use qc_traits::{Merge, MergeError};
184    }
185
186    #[cfg(feature = "processing")]
187    #[cfg_attr(docsrs, doc(cfg(feature = "processing")))]
188    pub mod processing {
189        pub use qc_traits::{
190            Decimate, DecimationFilter, Filter, MaskFilter, Masking, Preprocessing, Split,
191            TimeCorrection, TimeCorrectionError, TimeCorrectionsDB, Timeshift,
192        };
193    }
194
195    #[cfg(feature = "binex")]
196    #[cfg_attr(docsrs, doc(cfg(feature = "binex")))]
197    pub use crate::binex::BIN2RNX;
198
199    #[cfg(feature = "rtcm")]
200    #[cfg_attr(docsrs, doc(cfg(feature = "rtcm")))]
201    pub use crate::rtcm::RTCM2RNX;
202}
203
204/// Package dedicated to file production.
205pub mod prod {
206    pub use crate::production::{
207        DataSource, DetailedProductionAttributes, ProductionAttributes, FFU, PPU,
208    };
209}
210
211use carrier::Carrier;
212use prelude::*;
213
214#[cfg(feature = "processing")]
215use qc_traits::{MaskFilter, Masking};
216
217#[cfg(feature = "processing")]
218use crate::{
219    clock::record::clock_mask_mut, doris::mask::mask_mut as doris_mask_mut,
220    header::processing::header_mask_mut, meteo::mask::mask_mut as meteo_mask_mut,
221    navigation::mask::mask_mut as navigation_mask_mut,
222    observation::mask::mask_mut as observation_mask_mut,
223};
224
225#[cfg(docsrs)]
226pub use bibliography::Bibliography;
227
228/*
229 * returns true if given line is a comment
230 */
231pub(crate) fn is_rinex_comment(content: &str) -> bool {
232    content.len() > 60 && content.trim_end().ends_with("COMMENT")
233}
234
235/*
236 * macro to format one header line or a comment
237 */
238pub(crate) fn fmt_rinex(content: &str, marker: &str) -> String {
239    if content.len() < 60 {
240        format!("{:<padding$}{}", content, marker, padding = 60)
241    } else {
242        let mut string = String::new();
243        let nb_lines = num_integer::div_ceil(content.len(), 60);
244        for i in 0..nb_lines {
245            let start_off = i * 60;
246            let end_off = std::cmp::min(start_off + 60, content.len());
247            let chunk = &content[start_off..end_off];
248            string.push_str(&format!("{:<padding$}{}", chunk, marker, padding = 60));
249            if i < nb_lines - 1 {
250                string.push('\n');
251            }
252        }
253        string
254    }
255}
256
257/*
258 * macro to generate comments with standardized formatting
259 */
260pub(crate) fn fmt_comment(content: &str) -> String {
261    fmt_rinex(content, "COMMENT")
262}
263
264#[derive(Clone, Debug)]
265/// [Rinex] comprises a [Header] and a [Record] section.
266/// ```
267/// use rinex::prelude::*;
268/// let rnx = Rinex::from_file("data/OBS/V2/delf0010.21o")
269///     .unwrap();
270/// // header contains high level information
271/// // like file standard revision:
272/// assert_eq!(rnx.header.version.major, 2);
273/// assert_eq!(rnx.header.version.minor, 11);
274///
275/// let marker = rnx.header.geodetic_marker
276///         .as_ref()
277///         .unwrap();
278///
279/// assert_eq!(marker.number(), Some("13502M004".to_string()));
280///
281/// // Constellation describes which kind of vehicles
282/// // are to be encountered in the record, or which
283/// // GNSS constellation the data will be referred to.
284/// // Mixed constellation, means a combination of vehicles or
285/// // GNSS constellations is expected
286/// assert_eq!(rnx.header.constellation, Some(Constellation::Mixed));
287/// // Some information on the hardware being used might be stored
288/// println!("{:#?}", rnx.header.rcvr);
289/// // comments encountered in the Header section
290/// println!("{:#?}", rnx.header.comments);
291/// // sampling interval was set
292/// assert_eq!(rnx.header.sampling_interval, Some(Duration::from_seconds(30.0))); // 30s sample rate
293/// // record content is RINEX format dependent.
294/// ```
295pub struct Rinex {
296    /// [Header] gives general information and describes following content.
297    pub header: Header,
298    /// [Comments] stored as they appeared in file body
299    pub comments: Comments,
300    /// [Record] is the actual file content and is heavily [RinexType] dependent
301    pub record: Record,
302    /// [ProductionAttributes] filled
303    pub production: ProductionAttributes,
304}
305
306impl Rinex {
307    /// Builds a new [Rinex] struct from given header & body sections.
308    pub fn new(header: Header, record: record::Record) -> Rinex {
309        Rinex {
310            header,
311            record,
312            comments: Comments::new(),
313            production: ProductionAttributes::default(),
314        }
315    }
316
317    /// Builds a default Navigation [Rinex], useful in data production context.
318    pub fn basic_nav() -> Self {
319        Self {
320            header: Header::basic_nav(),
321            comments: Default::default(),
322            production: ProductionAttributes::default(),
323            record: Record::NavRecord(Default::default()),
324        }
325    }
326
327    /// Builds a default Observation [Rinex], useful in data production context.
328    pub fn basic_obs() -> Self {
329        Self {
330            header: Header::basic_obs(),
331            comments: Default::default(),
332            production: ProductionAttributes::default(),
333            record: Record::ObsRecord(Default::default()),
334        }
335    }
336
337    /// Builds a default Observation [CRINEX], useful in data production context.
338    pub fn basic_crinex() -> Self {
339        Self {
340            comments: Default::default(),
341            header: Header::basic_crinex(),
342            production: ProductionAttributes::default(),
343            record: Record::ObsRecord(Default::default()),
344        }
345    }
346
347    /// Copy and return this [Rinex] with updated [Header].
348    pub fn with_header(&self, header: Header) -> Self {
349        Self {
350            header,
351            record: self.record.clone(),
352            comments: self.comments.clone(),
353            production: self.production.clone(),
354        }
355    }
356
357    /// Replace [Header] with mutable access.
358    pub fn replace_header(&mut self, header: Header) {
359        self.header = header.clone();
360    }
361
362    /// Copy and return this [Rinex] with updated [Record]
363    pub fn with_record(&self, record: Record) -> Self {
364        Rinex {
365            record,
366            header: self.header.clone(),
367            comments: self.comments.clone(),
368            production: self.production.clone(),
369        }
370    }
371
372    /// Replace [Record] with mutable access.
373    pub fn replace_record(&mut self, record: Record) {
374        self.record = record.clone();
375    }
376
377    /// Converts self to CRINEX (compressed RINEX) format.
378    /// If current revision is < 3 then file gets converted to CRINEX1
379    /// format, otherwise, modern Observations are converted to CRINEX3.
380    /// This has no effect if self is not an Observation RINEX.
381    ///
382    /// ```
383    /// use rinex::prelude::*;
384    /// let rinex = Rinex::from_file("data/OBS/V3/DUTH0630.22O")
385    ///     .unwrap();
386    ///
387    /// // convert to CRINEX
388    /// let crinex = rinex.rnx2crnx();
389    /// assert!(crinex.to_file("test.crx").is_ok());
390    /// ```
391    pub fn rnx2crnx(&self) -> Self {
392        let mut s = self.clone();
393        s.rnx2crnx_mut();
394        s
395    }
396
397    /// Mutable [Self::rnx2crnx] implementation
398    pub fn rnx2crnx_mut(&mut self) {
399        if self.is_observation_rinex() {
400            let mut crinex = CRINEX::default();
401            crinex.version.major = match self.header.version.major {
402                1 | 2 => 1,
403                _ => 3,
404            };
405            crinex.date = epoch::now();
406            crinex.prog = format!(
407                "rs-rinex v{}",
408                Header::format_pkg_version(env!("CARGO_PKG_VERSION"))
409            );
410            self.header = self.header.with_crinex(crinex);
411        }
412    }
413
414    /// Copies and convert this supposedly Compact (compressed) [Rinex] into
415    /// readable [Rinex]. This has no effect if this [Rinex] is not a compressed Observation RINEX.
416    pub fn crnx2rnx(&self) -> Self {
417        let mut s = self.clone();
418        s.crnx2rnx_mut();
419        s
420    }
421
422    /// [Rinex::crnx2rnx] mutable implementation
423    pub fn crnx2rnx_mut(&mut self) {
424        if self.is_observation_rinex() {
425            let params = self.header.obs.as_ref().unwrap();
426            self.header = self
427                .header
428                .with_observation_fields(observation::HeaderFields {
429                    crinex: None,
430                    codes: params.codes.clone(),
431                    clock_offset_applied: params.clock_offset_applied,
432                    scaling: params.scaling.clone(),
433                    timeof_first_obs: params.timeof_first_obs,
434                    timeof_last_obs: params.timeof_last_obs,
435                });
436
437            self.header.program = Some(format!(
438                "rs-rinex v{}",
439                Header::format_pkg_version(env!("CARGO_PKG_VERSION"))
440            ));
441        }
442    }
443
444    /// Returns a file name that would describe this [Rinex] according to standard naming conventions.
445    /// For this information to be 100% complete, this [Rinex] must originate a file that
446    /// followed standard naming conventions itself.
447    ///
448    /// Otherwise you must provide [ProductionAttributes] yourself with "custom" values
449    /// to fullfil the remaining fields.
450    ///
451    /// In any case, this method is infaillible: we will always generate something,
452    /// missing fields are blanked.
453    ///
454    /// NB: this method
455    ///  - generates an upper case [String] as per standard conventions.
456    ///  - prefers lengthy (V3) names as opposed to short (V2) file names,
457    /// when applied to Observation, Navigation and Meteo formats.
458    /// Use "short" to change that default behavior.
459    ///  - you can use "suffix" to append a custom suffix to the standard name right away.
460    /// ```
461    /// use rinex::prelude::*;
462    /// // Parse a File that follows standard naming conventions
463    /// // and verify we generate something correct
464    /// ```
465    pub fn standard_filename(
466        &self,
467        short: bool,
468        suffix: Option<&str>,
469        custom: Option<ProductionAttributes>,
470    ) -> String {
471        let header = &self.header;
472        let rinextype = header.rinex_type;
473        let is_crinex = header.is_crinex();
474        let constellation = header.constellation;
475
476        let mut filename = match rinextype {
477            RinexType::ObservationData | RinexType::MeteoData | RinexType::NavigationData => {
478                let name = match custom {
479                    Some(ref custom) => custom.name.clone(),
480                    None => self.production.name.clone(),
481                };
482                let ddd = match &custom {
483                    Some(ref custom) => format!("{:03}", custom.doy),
484                    None => {
485                        if let Some(epoch) = self.first_epoch() {
486                            let ddd = epoch.day_of_year().round() as u32;
487                            format!("{:03}", ddd)
488                        } else {
489                            "DDD".to_string()
490                        }
491                    },
492                };
493                if short {
494                    let yy = match &custom {
495                        Some(ref custom) => format!("{:02}", custom.year - 2_000),
496                        None => {
497                            if let Some(epoch) = self.first_epoch() {
498                                let yy = epoch_decompose(epoch).0;
499                                format!("{:02}", yy - 2_000)
500                            } else {
501                                "YY".to_string()
502                            }
503                        },
504                    };
505                    let ext = match rinextype {
506                        RinexType::ObservationData => {
507                            if is_crinex {
508                                'D'
509                            } else {
510                                'O'
511                            }
512                        },
513                        RinexType::MeteoData => 'M',
514                        RinexType::NavigationData => match constellation {
515                            Some(Constellation::Glonass) => 'G',
516                            _ => 'N',
517                        },
518                        _ => unreachable!("unreachable"),
519                    };
520                    ProductionAttributes::rinex_short_format(&name, &ddd, &yy, ext)
521                } else {
522                    /* long /V3 like format */
523                    let batch = match &custom {
524                        Some(ref custom) => {
525                            if let Some(details) = &custom.v3_details {
526                                details.batch
527                            } else {
528                                0
529                            }
530                        },
531                        None => {
532                            if let Some(details) = &self.production.v3_details {
533                                details.batch
534                            } else {
535                                0
536                            }
537                        },
538                    };
539
540                    let country = match &custom {
541                        Some(ref custom) => {
542                            if let Some(details) = &custom.v3_details {
543                                details.country.to_string()
544                            } else {
545                                "CCC".to_string()
546                            }
547                        },
548                        None => {
549                            if let Some(details) = &self.production.v3_details {
550                                details.country.to_string()
551                            } else {
552                                "CCC".to_string()
553                            }
554                        },
555                    };
556
557                    let src = match &header.rcvr {
558                        Some(_) => 'R', // means GNSS rcvr
559                        None => {
560                            if let Some(details) = &self.production.v3_details {
561                                details.data_src.to_char()
562                            } else {
563                                'U' // means: unspecified
564                            }
565                        },
566                    };
567
568                    let yyyy = match &custom {
569                        Some(ref custom) => format!("{:04}", custom.year),
570                        None => {
571                            if let Some(t0) = self.first_epoch() {
572                                let yy = epoch_decompose(t0).0;
573                                format!("{:04}", yy)
574                            } else {
575                                "YYYY".to_string()
576                            }
577                        },
578                    };
579
580                    let (hh, mm) = match &custom {
581                        Some(ref custom) => {
582                            if let Some(details) = &custom.v3_details {
583                                (format!("{:02}", details.hh), format!("{:02}", details.mm))
584                            } else {
585                                ("HH".to_string(), "MM".to_string())
586                            }
587                        },
588                        None => {
589                            if let Some(epoch) = self.first_epoch() {
590                                let (_, _, _, hh, mm, _, _) = epoch_decompose(epoch);
591                                (format!("{:02}", hh), format!("{:02}", mm))
592                            } else {
593                                ("HH".to_string(), "MM".to_string())
594                            }
595                        },
596                    };
597
598                    // FFU sampling rate
599                    let ffu = match self.dominant_sampling_interval() {
600                        Some(duration) => FFU::from(duration).to_string(),
601                        None => {
602                            if let Some(ref custom) = custom {
603                                if let Some(details) = &custom.v3_details {
604                                    if let Some(ffu) = details.ffu {
605                                        ffu.to_string()
606                                    } else {
607                                        "XXX".to_string()
608                                    }
609                                } else {
610                                    "XXX".to_string()
611                                }
612                            } else {
613                                "XXX".to_string()
614                            }
615                        },
616                    };
617
618                    // ffu only in OBS file names
619                    let ffu = match rinextype {
620                        RinexType::ObservationData => Some(ffu),
621                        _ => None,
622                    };
623
624                    // PPU periodicity
625                    let ppu = match custom {
626                        Some(custom) => {
627                            if let Some(details) = &custom.v3_details {
628                                details.ppu
629                            } else {
630                                PPU::Unspecified
631                            }
632                        },
633                        None => {
634                            if let Some(details) = &self.production.v3_details {
635                                details.ppu
636                            } else {
637                                PPU::Unspecified
638                            }
639                        },
640                    };
641
642                    let fmt = match rinextype {
643                        RinexType::ObservationData => "MO".to_string(),
644                        RinexType::MeteoData => "MM".to_string(),
645                        RinexType::NavigationData => match constellation {
646                            Some(Constellation::Mixed) | None => "MN".to_string(),
647                            Some(constell) => format!("M{:x}", constell),
648                        },
649                        _ => unreachable!("unreachable fmt"),
650                    };
651
652                    let ext = if is_crinex { "crx" } else { "rnx" };
653
654                    ProductionAttributes::rinex_long_format(
655                        &name,
656                        batch,
657                        &country,
658                        src,
659                        &yyyy,
660                        &ddd,
661                        &hh,
662                        &mm,
663                        &ppu.to_string(),
664                        ffu.as_deref(),
665                        &fmt,
666                        ext,
667                    )
668                }
669            },
670            rinex => unimplemented!("{} format", rinex),
671        };
672        if let Some(suffix) = suffix {
673            filename.push_str(suffix);
674        }
675        filename
676    }
677
678    /// Guesses File [ProductionAttributes] from the actual Record content.
679    /// This is particularly useful when working with datasets we are confident about,
680    /// yet that do not follow standard naming conventions.
681    /// Note that this method is infaillible, because we default to blank fields
682    /// in case we cannot retrieve them.
683    pub fn guess_production_attributes(&self) -> ProductionAttributes {
684        // start from content identified from the filename
685        let mut attributes = self.production.clone();
686
687        let first_epoch = self.first_epoch();
688        let last_epoch = self.last_epoch();
689        let first_epoch_gregorian = first_epoch.map(|t0| t0.to_gregorian_utc());
690
691        match first_epoch_gregorian {
692            Some((y, _, _, _, _, _, _)) => attributes.year = y as u32,
693            _ => {},
694        }
695        match first_epoch {
696            Some(t0) => attributes.doy = t0.day_of_year().round() as u32,
697            _ => {},
698        }
699
700        // notes on attribute."name"
701        // - Non detailed OBS RINEX: this is usually the station name
702        //   which can be named after a geodetic marker
703        // - Non detailed NAV RINEX: station name
704        // - CLK RINEX: name of the local clock
705        // - IONEX: agency
706        match self.header.rinex_type {
707            RinexType::ClockData => match &self.header.clock {
708                Some(clk) => match &clk.ref_clock {
709                    Some(refclock) => attributes.name = refclock.to_string(),
710                    _ => {
711                        if let Some(site) = &clk.site {
712                            attributes.name = site.to_string();
713                        } else {
714                            if let Some(agency) = &self.header.agency {
715                                attributes.name = agency.to_string();
716                            }
717                        }
718                    },
719                },
720                _ => {
721                    if let Some(agency) = &self.header.agency {
722                        attributes.name = agency.to_string();
723                    }
724                },
725            },
726            _ => match &self.header.geodetic_marker {
727                Some(marker) => attributes.name = marker.name.to_string(),
728                _ => {
729                    if let Some(agency) = &self.header.agency {
730                        attributes.name = agency.to_string();
731                    }
732                },
733            },
734        }
735
736        if let Some(ref mut details) = attributes.v3_details {
737            if let Some((_, _, _, hh, mm, _, _)) = first_epoch_gregorian {
738                details.hh = hh;
739                details.mm = mm;
740            }
741            if let Some(first_epoch) = first_epoch {
742                if let Some(last_epoch) = last_epoch {
743                    let total_dt = last_epoch - first_epoch;
744                    details.ppu = PPU::from(total_dt);
745                }
746            }
747        } else {
748            attributes.v3_details = Some(DetailedProductionAttributes {
749                batch: 0,                      // see notes down below
750                country: "XXX".to_string(),    // see notes down below
751                data_src: DataSource::Unknown, // see notes down below
752                ppu: match (first_epoch, last_epoch) {
753                    (Some(first), Some(last)) => {
754                        let total_dt = last - first;
755                        PPU::from(total_dt)
756                    },
757                    _ => PPU::Unspecified,
758                },
759                ffu: self.dominant_sampling_interval().map(FFU::from),
760                hh: match first_epoch_gregorian {
761                    Some((_, _, _, hh, _, _, _)) => hh,
762                    _ => 0,
763                },
764                mm: match first_epoch_gregorian {
765                    Some((_, _, _, _, mm, _, _)) => mm,
766                    _ => 0,
767                },
768            });
769        }
770        /*
771         * Several fields cannot be deduced from the actual
772         * Record content. If provided filename did not describe them,
773         * we have no means to recover them.
774         * Example of such fields would be:
775         *    + Country Code: would require a worldwide country database
776         *    + Data source: is only defined in the filename
777         */
778        attributes
779    }
780
781    /// Parse [RINEX] content by consuming [BufReader] (efficient buffered reader).
782    /// Attributes potentially described by a file name need to be provided either
783    /// manually / externally, or guessed when parsing has been completed.
784    pub fn parse<R: Read>(reader: &mut BufReader<R>) -> Result<Self, ParsingError> {
785        // Parses Header section (=consumes header until this point)
786        let mut header = Header::parse(reader)?;
787
788        // Parse record (=consumes rest of this resource)
789        // Comments are preserved and store "as is"
790        let (record, comments) = Record::parse(&mut header, reader)?;
791
792        Ok(Self {
793            header,
794            comments,
795            record,
796            production: Default::default(),
797        })
798    }
799
800    /// Format [RINEX] into writable I/O using efficient buffered writer
801    /// and following standard specifications. The revision to be followed is defined
802    /// in [Header] section. This is the mirror operation of [Self::parse].
803    pub fn format<W: Write>(&self, writer: &mut BufWriter<W>) -> Result<(), FormattingError> {
804        self.header.format(writer)?;
805        self.record.format(writer, &self.header)?;
806        writer.flush()?;
807        Ok(())
808    }
809
810    /// Parses [Rinex] from local readable file.
811    /// Will panic if provided file does not exist or is not readable.
812    /// See [Self::from_gzip_file] for seamless Gzip support.
813    ///
814    /// If file name follows standard naming conventions, then internal definitions
815    /// will truly be complete. Otherwise [ProductionAttributes] cannot be fully determined.
816    /// If you want or need to you can either
817    ///  1. define it yourself with further customization
818    ///  2. use the smart guesser (after parsing): [Self::guess_production_attributes]
819    ///
820    /// This is typically needed in data production contexts.
821    ///
822    /// The parser automatically picks up the RINEX format and we support
823    /// all of them, CRINEX (Compat RINEX) is natively supported.
824    /// NB: the SINEX format is different and handled in a dedicated library.
825    pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Rinex, ParsingError> {
826        let path = path.as_ref();
827
828        // deduce all we can from file name
829        let file_attributes = match path.file_name() {
830            Some(filename) => {
831                let filename = filename.to_string_lossy().to_string();
832                if let Ok(prod) = ProductionAttributes::from_str(&filename) {
833                    prod
834                } else {
835                    ProductionAttributes::default()
836                }
837            },
838            _ => ProductionAttributes::default(),
839        };
840
841        let fd = File::open(path).expect("from_file: open error");
842
843        let mut reader = BufReader::new(fd);
844        let mut rinex = Self::parse(&mut reader)?;
845        rinex.production = file_attributes;
846        Ok(rinex)
847    }
848
849    /// Dumps [RINEX] into writable local file (as readable ASCII UTF-8)
850    /// using efficient buffered formatting.
851    /// This is the mirror operation of [Self::from_file].
852    /// Returns total amount of bytes that was generated.
853    /// ```
854    /// // Read a RINEX and dump it without any modifications
855    /// use rinex::prelude::*;
856    /// let rnx = Rinex::from_file("data/OBS/V3/DUTH0630.22O")
857    ///   .unwrap();
858    /// assert!(rnx.to_file("test.rnx").is_ok());
859    /// ```
860    ///
861    /// Other useful links are in data production contexts:
862    ///   * [Self::standard_filename] to generate a standardized filename
863    ///   * [Self::guess_production_attributes] helps generate standardized filenames for
864    ///     files that do not follow naming conventions
865    pub fn to_file<P: AsRef<Path>>(&self, path: P) -> Result<(), FormattingError> {
866        let fd = File::create(path)?;
867        let mut writer = BufWriter::new(fd);
868        self.format(&mut writer)?;
869        Ok(())
870    }
871
872    /// Parses [Rinex] from local gzip compressed file.
873    /// Will panic if provided file does not exist or is not readable.
874    /// Refer to [Self::from_file] for more information.
875    ///
876    /// ```
877    /// use rinex::prelude::Rinex;
878    /// ```
879    #[cfg(feature = "flate2")]
880    #[cfg_attr(docsrs, doc(cfg(feature = "flate2")))]
881    pub fn from_gzip_file<P: AsRef<Path>>(path: P) -> Result<Rinex, ParsingError> {
882        let path = path.as_ref();
883
884        // deduce all we can from file name
885        let file_attributes = match path.file_name() {
886            Some(filename) => {
887                let filename = filename.to_string_lossy().to_string();
888                if let Ok(prod) = ProductionAttributes::from_str(&filename) {
889                    prod
890                } else {
891                    ProductionAttributes::default()
892                }
893            },
894            _ => ProductionAttributes::default(),
895        };
896
897        let fd = File::open(path).expect("from_file: open error");
898
899        let reader = GzDecoder::new(fd);
900        let mut reader = BufReader::new(reader);
901        let mut rinex = Self::parse(&mut reader)?;
902        rinex.production = file_attributes;
903        Ok(rinex)
904    }
905
906    /// Dumps and gzip encodes [RINEX] into writable local file,
907    /// using efficient buffered formatting.
908    /// This is the mirror operation of [Self::from_gzip_file].
909    /// Returns total amount of bytes that was generated.
910    /// ```
911    /// // Read a RINEX and dump it without any modifications
912    /// use rinex::prelude::*;
913    /// let rnx = Rinex::from_file("data/OBS/V3/DUTH0630.22O")
914    ///   .unwrap();
915    /// assert!(rnx.to_file("test.rnx").is_ok());
916    /// ```
917    ///
918    /// Other useful links are in data production contexts:
919    ///   * [Self::standard_filename] to generate a standardized filename
920    ///   * [Self::guess_production_attributes] helps generate standardized filenames for
921    ///     files that do not follow naming conventions
922    #[cfg(feature = "flate2")]
923    #[cfg_attr(docsrs, doc(cfg(feature = "flate2")))]
924    pub fn to_gzip_file<P: AsRef<Path>>(&self, path: P) -> Result<(), FormattingError> {
925        let fd = File::create(path)?;
926        let compression = GzCompression::new(5);
927        let mut writer = BufWriter::new(GzEncoder::new(fd, compression));
928        self.format(&mut writer)?;
929        Ok(())
930    }
931
932    /// Returns true if this is an ATX RINEX
933    pub fn is_antex(&self) -> bool {
934        self.header.rinex_type == types::Type::AntennaData
935    }
936
937    /// Returns true if this is a CLOCK RINEX
938    pub fn is_clock_rinex(&self) -> bool {
939        self.header.rinex_type == types::Type::ClockData
940    }
941
942    /// Returns true if Differential Code Biases (DCBs)
943    /// are compensated for, in this file, for this GNSS constellation.
944    /// DCBs are biases due to tiny frequency differences,
945    /// in both the SV embedded code generator, and receiver PLL.
946    /// If this is true, that means all code signals received in from
947    /// all SV within that constellation, have intrinsinc DCB compensation.
948    /// In very high precision and specific applications, you then do not have
949    /// to deal with their compensation yourself.
950    pub fn dcb_compensation(&self, constellation: Constellation) -> bool {
951        self.header
952            .dcb_compensations
953            .iter()
954            .filter(|dcb| dcb.constellation == constellation)
955            .count()
956            > 0
957    }
958    /// Returns true if Antenna Phase Center variations are compensated
959    /// for in this file. Useful for high precision application.
960    pub fn pcv_compensation(&self, constellation: Constellation) -> bool {
961        self.header
962            .pcv_compensations
963            .iter()
964            .filter(|pcv| pcv.constellation == constellation)
965            .count()
966            > 0
967    }
968
969    /// Determines whether [Rinex] is the result of a previous [Merge] operation.
970    /// That is, the combination of two files merged together.  
971    /// This is determined by the presence of custom yet somewhat standardized [Comments].
972    pub fn is_merged(&self) -> bool {
973        let special_comment = String::from("FILE MERGE");
974        for comment in self.header.comments.iter() {
975            if comment.contains(&special_comment) {
976                return true;
977            }
978        }
979        false
980    }
981}
982
983/*
984 * Methods that return an Iterator exclusively.
985 * These methods are used to browse data easily and efficiently.
986 */
987impl Rinex {
988    /// Returns [Epoch] Iterator. This applies to all but ANTEX special format,
989    /// for which we return null.
990    pub fn epoch_iter(&self) -> Box<dyn Iterator<Item = Epoch> + '_> {
991        if let Some(r) = self.record.as_obs() {
992            Box::new(r.iter().map(|(k, _)| k.epoch))
993        } else if let Some(r) = self.record.as_meteo() {
994            Box::new(r.iter().map(|(k, _)| k.epoch).unique())
995        } else if let Some(r) = self.record.as_doris() {
996            Box::new(r.iter().map(|(k, _)| k.epoch))
997        } else if let Some(r) = self.record.as_nav() {
998            Box::new(r.iter().map(|(k, _)| k.epoch))
999        } else if let Some(r) = self.record.as_clock() {
1000            Box::new(r.iter().map(|(k, _)| *k))
1001        } else {
1002            Box::new([].into_iter())
1003        }
1004    }
1005
1006    /// Returns a [SV] iterator, from all satellites encountered in this [Rinex].
1007    pub fn sv_iter(&self) -> Box<dyn Iterator<Item = SV> + '_> {
1008        if self.is_observation_rinex() {
1009            Box::new(
1010                self.signal_observations_iter()
1011                    .map(|(_, v)| v.sv)
1012                    .sorted()
1013                    .unique(),
1014            )
1015        } else if let Some(record) = self.record.as_nav() {
1016            Box::new(record.iter().map(|(k, _)| k.sv).sorted().unique())
1017        } else if let Some(record) = self.record.as_clock() {
1018            Box::new(
1019                // grab all embedded sv clocks
1020                record
1021                    .iter()
1022                    .flat_map(|(_, keys)| {
1023                        keys.iter()
1024                            .filter_map(|(key, _)| key.clock_type.as_sv())
1025                            .collect::<Vec<_>>()
1026                            .into_iter()
1027                    })
1028                    .unique(),
1029            )
1030        } else {
1031            Box::new([].into_iter())
1032        }
1033    }
1034
1035    // /// List all [`SV`] per epoch of appearance.
1036    // /// ```
1037    // /// use rinex::prelude::*;
1038    // /// use std::str::FromStr;
1039    // /// let rnx = Rinex::from_file("data/OBS/V2/aopr0010.17o")
1040    // ///     .unwrap();
1041    // ///
1042    // /// let mut data = rnx.sv_epoch();
1043    // ///
1044    // /// if let Some((epoch, vehicles)) = data.nth(0) {
1045    // ///     assert_eq!(epoch, Epoch::from_str("2017-01-01T00:00:00 GPST").unwrap());
1046    // ///     let expected = vec![
1047    // ///         SV::new(Constellation::GPS, 03),
1048    // ///         SV::new(Constellation::GPS, 08),
1049    // ///         SV::new(Constellation::GPS, 14),
1050    // ///         SV::new(Constellation::GPS, 16),
1051    // ///         SV::new(Constellation::GPS, 22),
1052    // ///         SV::new(Constellation::GPS, 23),
1053    // ///         SV::new(Constellation::GPS, 26),
1054    // ///         SV::new(Constellation::GPS, 27),
1055    // ///         SV::new(Constellation::GPS, 31),
1056    // ///         SV::new(Constellation::GPS, 32),
1057    // ///     ];
1058    // ///     assert_eq!(*vehicles, expected);
1059    // /// }
1060    // /// ```
1061    // pub fn sv_epoch(&self) -> Box<dyn Iterator<Item = (Epoch, Vec<SV>)> + '_> {
1062    //     if let Some(record) = self.record.as_obs() {
1063    //         Box::new(
1064    //             record.iter().map(|((epoch, _), (_clk, entries))| {
1065    //                 (*epoch, entries.keys().unique().cloned().collect())
1066    //             }),
1067    //         )
1068    //     } else if let Some(record) = self.record.as_nav() {
1069    //         Box::new(
1070    //             // grab all vehicles through all epochs,
1071    //             // fold them into individual lists
1072    //             record.iter().map(|(epoch, frames)| {
1073    //                 (
1074    //                     *epoch,
1075    //                     frames
1076    //                         .iter()
1077    //                         .filter_map(|fr| {
1078    //                             if let Some((_, sv, _)) = fr.as_eph() {
1079    //                                 Some(sv)
1080    //                             } else if let Some((_, sv, _)) = fr.as_eop() {
1081    //                                 Some(sv)
1082    //                             } else if let Some((_, sv, _)) = fr.as_ion() {
1083    //                                 Some(sv)
1084    //                             } else if let Some((_, sv, _)) = fr.as_sto() {
1085    //                                 Some(sv)
1086    //                             } else {
1087    //                                 None
1088    //                             }
1089    //                         })
1090    //                         .fold(vec![], |mut list, sv| {
1091    //                             if !list.contains(&sv) {
1092    //                                 list.push(sv);
1093    //                             }
1094    //                             list
1095    //                         }),
1096    //                 )
1097    //             }),
1098    //         )
1099    //     } else {
1100    //         panic!(
1101    //             ".sv_epoch() is not feasible on \"{:?}\" RINEX",
1102    //             self.header.rinex_type
1103    //         );
1104    //     }
1105    // }
1106
1107    /// Returns [Constellation]s Iterator.
1108    /// ```
1109    /// use rinex::prelude::*;
1110    /// use itertools::Itertools; // .sorted()
1111    /// let rnx = Rinex::from_file("data/OBS/V3/ACOR00ESP_R_20213550000_01D_30S_MO.rnx")
1112    ///     .unwrap();
1113    ///
1114    /// assert!(
1115    ///     rnx.constellations_iter().sorted().eq(
1116    ///         vec![
1117    ///             Constellation::GPS,
1118    ///             Constellation::Glonass,
1119    ///             Constellation::BeiDou,
1120    ///             Constellation::Galileo,
1121    ///         ]
1122    ///     ),
1123    ///     "parsed wrong GNSS context",
1124    /// );
1125    /// ```
1126    pub fn constellations_iter(&self) -> Box<dyn Iterator<Item = Constellation> + '_> {
1127        // Creates a unique list from .sv_iter()
1128        Box::new(self.sv_iter().map(|sv| sv.constellation).unique().sorted())
1129    }
1130
1131    // /// Returns an Iterator over Unique Constellations, per Epoch
1132    // pub fn constellation_epoch(
1133    //     &self,
1134    // ) -> Box<dyn Iterator<Item = (Epoch, Vec<Constellation>)> + '_> {
1135    //     Box::new(self.sv_epoch().map(|(epoch, svnn)| {
1136    //         (
1137    //             epoch,
1138    //             svnn.iter().map(|sv| sv.constellation).unique().collect(),
1139    //         )
1140    //     }))
1141    // }
1142
1143    /// Returns [Observable]s Iterator.
1144    /// Applies to Observation RINEX, Meteo RINEX and DORIS.
1145    /// Returns null for any other formats.  
1146    pub fn observables_iter(&self) -> Box<dyn Iterator<Item = &Observable> + '_> {
1147        if self.is_observation_rinex() {
1148            Box::new(
1149                self.signal_observations_iter()
1150                    .map(|(_, v)| &v.observable)
1151                    .unique()
1152                    .sorted(),
1153            )
1154        } else if self.is_meteo_rinex() {
1155            Box::new(
1156                self.meteo_observations_iter()
1157                    .map(|(k, _)| &k.observable)
1158                    .unique()
1159                    .sorted(),
1160            )
1161        // } else if self.record.as_doris().is_some() {
1162        //     Box::new(
1163        //         self.doris()
1164        //             .flat_map(|(_, stations)| {
1165        //                 stations
1166        //                     .iter()
1167        //                     .flat_map(|(_, observables)| observables.iter().map(|(k, _)| k))
1168        //             })
1169        //             .unique(),
1170        //     )
1171        } else {
1172            Box::new([].into_iter())
1173        }
1174    }
1175
1176    /// ANTEX antennas specifications browsing
1177    pub fn antennas(
1178        &self,
1179    ) -> Box<dyn Iterator<Item = &(Antenna, HashMap<Carrier, FrequencyDependentData>)> + '_> {
1180        Box::new(
1181            self.record
1182                .as_antex()
1183                .into_iter()
1184                .flat_map(|record| record.iter()),
1185        )
1186    }
1187
1188    /// Copies and returns new [Rinex] that is the result
1189    /// of observation differentiation. See [Self::observations_substract_mut] for more
1190    /// information.
1191    pub fn observations_substract(&self, rhs: &Self) -> Result<Self, Error> {
1192        let mut s = self.clone();
1193        s.observations_substract_mut(rhs)?;
1194        Ok(s)
1195    }
1196
1197    /// Modifies [Rinex] in place with observation differentiation
1198    /// using the remote (RHS) counterpart, for each identical observation and signal source.
1199    ///
1200    /// This is currently limited to Observation RINEX. NB:
1201    /// - Only matched (differentiated) symbols will remain, any other observations are
1202    /// discarded.
1203    /// - Output symbols are not compliant with Observation RINEX, this is sort
1204    /// of like a "residual" RINEX. Use with care.
1205    ///
1206    /// This allows analyzing a local clock used as GNSS receiver reference clock
1207    /// spread to dual GNSS receiver, by means of phase differential analysis.
1208    pub fn observations_substract_mut(&mut self, rhs: &Self) -> Result<(), Error> {
1209        let lhs_dt = self
1210            .dominant_sampling_interval()
1211            .ok_or(Error::UndeterminedSamplingPeriod)?;
1212
1213        let half_lhs_dt = lhs_dt / 2.0;
1214
1215        if let Some(rhs) = rhs.record.as_obs() {
1216            if let Some(rec) = self.record.as_mut_obs() {
1217                rec.retain(|k, v| {
1218                    v.signals.retain_mut(|sig| {
1219                        let mut reference = 0.0;
1220                        let mut min_dt = Duration::MAX;
1221
1222                        // temporal filter
1223                        let filtered_rhs_epochs = rhs.iter().filter(|(rhs, _)| {
1224                            let dt = (rhs.epoch - k.epoch).abs();
1225                            dt <= half_lhs_dt
1226                        });
1227
1228                        for (rhs_epoch, rhs_values) in filtered_rhs_epochs {
1229                            for rhs_sig in rhs_values.signals.iter() {
1230                                if rhs_sig.sv == sig.sv && rhs_sig.observable == sig.observable {
1231                                    let dt = (rhs_epoch.epoch - k.epoch).abs();
1232                                    if dt <= min_dt {
1233                                        reference = rhs_sig.value;
1234                                        min_dt = dt;
1235                                    }
1236                                }
1237                            }
1238                        }
1239
1240                        if min_dt < Duration::MAX {
1241                            sig.value -= reference;
1242                        }
1243
1244                        min_dt < Duration::MAX
1245                    });
1246
1247                    !v.signals.is_empty()
1248                });
1249            }
1250        }
1251
1252        Ok(())
1253    }
1254}
1255
1256#[cfg(feature = "processing")]
1257#[cfg_attr(docsrs, doc(cfg(feature = "processing")))]
1258impl Masking for Rinex {
1259    fn mask(&self, f: &MaskFilter) -> Self {
1260        let mut s = self.clone();
1261        s.mask_mut(f);
1262        s
1263    }
1264    fn mask_mut(&mut self, f: &MaskFilter) {
1265        header_mask_mut(&mut self.header, f);
1266        if let Some(rec) = self.record.as_mut_obs() {
1267            observation_mask_mut(rec, f);
1268        } else if let Some(rec) = self.record.as_mut_nav() {
1269            navigation_mask_mut(rec, f);
1270        } else if let Some(rec) = self.record.as_mut_clock() {
1271            clock_mask_mut(rec, f);
1272        } else if let Some(rec) = self.record.as_mut_meteo() {
1273            meteo_mask_mut(rec, f);
1274        } else if let Some(rec) = self.record.as_mut_doris() {
1275            doris_mask_mut(rec, f);
1276        }
1277    }
1278}
1279
1280#[cfg(feature = "clock")]
1281use crate::clock::{ClockKey, ClockProfile, ClockProfileType};
1282
1283/*
1284 * Clock RINEX specific feature
1285 */
1286#[cfg(feature = "clock")]
1287#[cfg_attr(docsrs, doc(cfg(feature = "clock")))]
1288impl Rinex {
1289    /// Returns Iterator over Clock RINEX content.
1290    pub fn precise_clock(
1291        &self,
1292    ) -> Box<dyn Iterator<Item = (&Epoch, &BTreeMap<ClockKey, ClockProfile>)> + '_> {
1293        Box::new(
1294            self.record
1295                .as_clock()
1296                .into_iter()
1297                .flat_map(|record| record.iter()),
1298        )
1299    }
1300    /// Returns Iterator over Clock RINEX content for Space Vehicles only (not ground stations).
1301    pub fn precise_sv_clock(
1302        &self,
1303    ) -> Box<dyn Iterator<Item = (Epoch, SV, ClockProfileType, ClockProfile)> + '_> {
1304        Box::new(self.precise_clock().flat_map(|(epoch, rec)| {
1305            rec.iter().filter_map(|(key, profile)| {
1306                key.clock_type
1307                    .as_sv()
1308                    .map(|sv| (*epoch, sv, key.profile_type.clone(), profile.clone()))
1309            })
1310        }))
1311    }
1312    /// Returns Iterator over Clock RINEX content for Ground Station clocks only (not onboard clocks)
1313    pub fn precise_station_clock(
1314        &self,
1315    ) -> Box<dyn Iterator<Item = (Epoch, String, ClockProfileType, ClockProfile)> + '_> {
1316        Box::new(self.precise_clock().flat_map(|(epoch, rec)| {
1317            rec.iter().filter_map(|(key, profile)| {
1318                key.clock_type.as_station().map(|clk_name| {
1319                    (
1320                        *epoch,
1321                        clk_name.clone(),
1322                        key.profile_type.clone(),
1323                        profile.clone(),
1324                    )
1325                })
1326            })
1327        }))
1328    }
1329}
1330
1331/*
1332 * ANTEX specific feature
1333 */
1334#[cfg(feature = "antex")]
1335#[cfg_attr(docsrs, doc(cfg(feature = "antex")))]
1336impl Rinex {
1337    /// Iterates over antenna specifications that are still valid
1338    pub fn antex_valid_calibrations(
1339        &self,
1340        now: Epoch,
1341    ) -> Box<dyn Iterator<Item = (&Antenna, &HashMap<Carrier, FrequencyDependentData>)> + '_> {
1342        Box::new(self.antennas().filter_map(move |(ant, data)| {
1343            if ant.is_valid(now) {
1344                Some((ant, data))
1345            } else {
1346                None
1347            }
1348        }))
1349    }
1350    /// Returns APC offset for given spacecraft, expressed in NEU coordinates [mm] for given
1351    /// frequency. "now" is used to determine calibration validity (in time).
1352    pub fn sv_antenna_apc_offset(
1353        &self,
1354        now: Epoch,
1355        sv: SV,
1356        freq: Carrier,
1357    ) -> Option<(f64, f64, f64)> {
1358        self.antex_valid_calibrations(now)
1359            .filter_map(|(ant, freqdata)| match &ant.specific {
1360                AntennaSpecific::SvAntenna(sv_ant) => {
1361                    if sv_ant.sv == sv {
1362                        freqdata
1363                            .get(&freq)
1364                            .map(|freqdata| freqdata.apc_eccentricity)
1365                    } else {
1366                        None
1367                    }
1368                },
1369                _ => None,
1370            })
1371            .reduce(|k, _| k) // we're expecting a single match here
1372    }
1373    /// Returns APC offset for given RX Antenna model (ground station model).
1374    /// Model name is the IGS code, which has to match exactly but we're case insensitive.
1375    /// The APC offset is expressed in NEU coordinates
1376    /// [mm]. "now" is used to determine calibration validity (in time).
1377    pub fn rx_antenna_apc_offset(
1378        &self,
1379        now: Epoch,
1380        matcher: AntennaMatcher,
1381        freq: Carrier,
1382    ) -> Option<(f64, f64, f64)> {
1383        let to_match = matcher.to_lowercase();
1384        self.antex_valid_calibrations(now)
1385            .filter_map(|(ant, freqdata)| match &ant.specific {
1386                AntennaSpecific::RxAntenna(rx_ant) => match &to_match {
1387                    AntennaMatcher::IGSCode(code) => {
1388                        if rx_ant.igs_type.to_lowercase().eq(code) {
1389                            freqdata
1390                                .get(&freq)
1391                                .map(|freqdata| freqdata.apc_eccentricity)
1392                        } else {
1393                            None
1394                        }
1395                    },
1396                    AntennaMatcher::SerialNumber(sn) => {
1397                        if rx_ant.igs_type.to_lowercase().eq(sn) {
1398                            freqdata
1399                                .get(&freq)
1400                                .map(|freqdata| freqdata.apc_eccentricity)
1401                        } else {
1402                            None
1403                        }
1404                    },
1405                },
1406                _ => None,
1407            })
1408            .reduce(|k, _| k) // we're expecting a single match here
1409    }
1410}
1411
1412#[cfg(test)]
1413mod test {
1414    use super::*;
1415    use crate::{fmt_comment, is_rinex_comment};
1416    #[test]
1417    fn fmt_comments_singleline() {
1418        for desc in [
1419            "test",
1420            "just a basic comment",
1421            "just another lengthy comment blahblabblah",
1422        ] {
1423            let comment = fmt_comment(desc);
1424            assert!(
1425                comment.len() >= 60,
1426                "comments should be at least 60 byte long"
1427            );
1428            assert_eq!(
1429                comment.find("COMMENT"),
1430                Some(60),
1431                "comment marker should located @ 60"
1432            );
1433            assert!(is_rinex_comment(&comment), "should be valid comment");
1434        }
1435    }
1436    #[test]
1437    fn fmt_wrapped_comments() {
1438        for desc in ["just trying to form a very lengthy comment that will overflow since it does not fit in a single line",
1439            "just trying to form a very very lengthy comment that will overflow since it does fit on three very meaningful lines. Imazdmazdpoakzdpoakzpdokpokddddddddddddddddddaaaaaaaaaaaaaaaaaaaaaaa"] {
1440            let nb_lines = num_integer::div_ceil(desc.len(), 60);
1441            let comments = fmt_comment(desc);
1442            assert_eq!(comments.lines().count(), nb_lines);
1443            for line in comments.lines() {
1444                assert!(line.len() >= 60, "comment line should be at least 60 byte long");
1445                assert_eq!(line.find("COMMENT"), Some(60), "comment marker should located @ 60");
1446                assert!(is_rinex_comment(line), "should be valid comment");
1447            }
1448        }
1449    }
1450    #[test]
1451    fn fmt_observables_v3() {
1452        for (desc, expected) in [
1453("R    9 C1C L1C S1C C2C C2P L2C L2P S2C S2P",
1454"R    9 C1C L1C S1C C2C C2P L2C L2P S2C S2P                  SYS / # / OBS TYPES"),
1455("G   18 C1C L1C S1C C2P C2W C2S C2L C2X L2P L2W L2S L2L L2X         S2P S2W S2S S2L S2X",
1456"G   18 C1C L1C S1C C2P C2W C2S C2L C2X L2P L2W L2S L2L L2X  SYS / # / OBS TYPES
1457       S2P S2W S2S S2L S2X                                  SYS / # / OBS TYPES"),
1458        ] {
1459            assert_eq!(fmt_rinex(desc, "SYS / # / OBS TYPES"), expected);
1460        }
1461    }
1462}