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}