doris_rs/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 * DORIS 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/doris/graphs/contributors)
12 * This framework is licensed under Mozilla Public V2 license.
13 *
14 * Documentation: https://github.com/nav-solutions/doris
15 */
16
17extern crate num_derive;
18
19#[cfg(feature = "serde")]
20#[macro_use]
21extern crate serde;
22
23extern crate gnss_rs as gnss;
24extern crate num;
25
26pub mod constants;
27pub mod error;
28pub mod frequency;
29pub mod header;
30pub mod matcher;
31pub mod observable;
32pub mod production;
33pub mod record;
34pub mod station;
35
36mod epoch;
37
38#[cfg(test)]
39mod tests;
40
41use std::{
42 fs::File,
43 io::{BufReader, BufWriter, Read, Write},
44 path::Path,
45 str::FromStr,
46};
47
48use itertools::Itertools;
49
50#[cfg(feature = "flate2")]
51use flate2::{read::GzDecoder, write::GzEncoder, Compression as GzCompression};
52
53use hifitime::prelude::{Duration, Epoch};
54
55use crate::{
56 error::{FormattingError, ParsingError},
57 header::Header,
58 matcher::Matcher,
59 production::ProductionAttributes,
60 record::{ClockOffset, Record},
61 station::GroundStation,
62};
63
64/// [Comments] found in [DORIS] files
65pub type Comments = Vec<String>;
66
67pub mod prelude {
68 // export
69 pub use crate::{
70 error::{FormattingError, ParsingError},
71 frequency::Frequency,
72 header::{Antenna, Header, Receiver, Version},
73 matcher::Matcher,
74 observable::Observable,
75 production::ProductionAttributes,
76 record::{
77 ClockOffset, EpochFlag, Key, Measurements, Observation, ObservationKey, Record, SNR,
78 },
79 station::GroundStation,
80 Comments, DORIS,
81 };
82
83 pub use gnss::prelude::{Constellation, DOMESTrackingPoint, COSPAR, DOMES, SV};
84
85 pub use hifitime::{Duration, Epoch, Polynomial, TimeScale, TimeSeries};
86}
87
88pub(crate) fn fmt_doris(content: &str, marker: &str) -> String {
89 if content.len() < 60 {
90 format!("{:<padding$}{}", content, marker, padding = 60)
91 } else {
92 let mut string = String::new();
93 let nb_lines = num_integer::div_ceil(content.len(), 60);
94 for i in 0..nb_lines {
95 let start_off = i * 60;
96 let end_off = std::cmp::min(start_off + 60, content.len());
97 let chunk = &content[start_off..end_off];
98 string.push_str(&format!("{:<padding$}{}", chunk, marker, padding = 60));
99 if i < nb_lines - 1 {
100 string.push('\n');
101 }
102 }
103 string
104 }
105}
106
107pub(crate) fn fmt_comment(content: &str) -> String {
108 fmt_doris(content, "COMMENT")
109}
110
111#[derive(Clone, Default, Debug, PartialEq)]
112/// [DORIS] is composed of a [Header] and a [Record] section.
113/// ```
114/// use std::str::FromStr;
115/// use doris_rs::prelude::*;
116///
117/// let doris = DORIS::from_gzip_file("data/DOR/V3/cs2rx18164.gz")
118/// .unwrap();
119///
120/// assert_eq!(doris.header.satellite, "CRYOSAT-2");
121///
122/// let agency = "CNES".to_string(); // Agency / producer
123/// let program = "Expert".to_string(); // Software name
124/// let run_by = "CNES".to_string(); // Operator
125/// let date = "20180614 090016 UTC".to_string(); // Date of production
126/// let observer = "SPA_BN1_4.7P1".to_string(); // Operator
127///
128/// assert_eq!(doris.header.program, Some(program));
129/// assert_eq!(doris.header.run_by, Some(run_by));
130/// assert_eq!(doris.header.date, Some(date)); // currently not interpreted
131/// assert_eq!(doris.header.observer, Some(observer));
132/// assert_eq!(doris.header.agency, Some(agency));
133///
134/// assert!(doris.header.doi.is_none());
135/// assert!(doris.header.license.is_none());
136///
137/// let observables = vec![
138/// Observable::UnambiguousPhaseRange(Frequency::DORIS1), // phase, in meters of prop.
139/// Observable::UnambiguousPhaseRange(Frequency::DORIS2),
140/// Observable::PseudoRange(Frequency::DORIS1), // decoded pseudo range
141/// Observable::PseudoRange(Frequency::DORIS2),
142/// Observable::Power(Frequency::DORIS1), // received power
143/// Observable::Power(Frequency::DORIS2), // received power
144/// Observable::FrequencyRatio, // f1/f2 ratio (=drift image)
145/// Observable::Pressure, // pressure, at ground station level (hPa)
146/// Observable::Temperature, // temperature, at ground station level (°C)
147/// Observable::HumidityRate, // saturation rate, at ground station level (%)
148/// ];
149///
150/// assert_eq!(doris.header.observables, observables);
151///
152/// assert_eq!(doris.header.ground_stations.len(), 53); // network
153///
154/// // Stations helper
155/// let site_matcher = Matcher::Site("TOULOUSE");
156///
157/// let toulouse = GroundStation::default()
158/// .with_domes(DOMES::from_str("10003S005").unwrap())
159/// .with_site_name("TOULOUSE") // site name
160/// .with_site_label("TLSB") // site label/mnemonic
161/// .with_unique_id(13) // file dependent
162/// .with_frequency_shift(0) // f1/f2 site shift for this day
163/// .with_beacon_revision(3); // DORIS 3rd generation
164///
165/// // helper
166/// assert_eq!(doris.ground_station(site_matcher), Some(toulouse));
167/// ```
168pub struct DORIS {
169 /// [Header] gives general information
170 pub header: Header,
171
172 /// [Record] gives the actual file content
173 pub record: Record,
174
175 /// [ProductionAttributes] is attached to files that were
176 /// named according to the standard conventions.
177 pub production: Option<ProductionAttributes>,
178}
179
180impl DORIS {
181 /// Builds a new [DORIS] struct from given [Header] and [Record] sections.
182 pub fn new(header: Header, record: Record) -> DORIS {
183 DORIS {
184 header,
185 record,
186 production: Default::default(),
187 }
188 }
189
190 /// Copy and return this [DORIS] with updated [Header].
191 pub fn with_header(&self, header: Header) -> Self {
192 Self {
193 header,
194 record: self.record.clone(),
195 production: Default::default(),
196 }
197 }
198
199 /// Replace [Header] with mutable access.
200 pub fn replace_header(&mut self, header: Header) {
201 self.header = header.clone();
202 }
203
204 /// Copies and returns a [DORIS] with updated [Record]
205 pub fn with_record(&self, record: Record) -> Self {
206 DORIS {
207 record,
208 header: self.header.clone(),
209 production: self.production.clone(),
210 }
211 }
212
213 /// Replace [Record] with mutable access.
214 pub fn replace_record(&mut self, record: Record) {
215 self.record = record.clone();
216 }
217
218 /// Parse [DORIS] content by consuming [BufReader] (efficient buffered reader).
219 pub fn parse<R: Read>(reader: &mut BufReader<R>) -> Result<Self, ParsingError> {
220 // Parses Header section (=consumes header until this point)
221 let mut header = Header::parse(reader)?;
222
223 // Parse record (=consumes rest of this resource)
224 // Comments are preserved and store "as is"
225 let record = Record::parse(&mut header, reader)?;
226
227 Ok(Self {
228 header,
229 record,
230 production: Default::default(),
231 })
232 }
233
234 /// Format [DORIS] into writable I/O using efficient buffered writer
235 /// and following standard specifications. This is the mirror operation of [Self::parse].
236 pub fn format<W: Write>(&self, writer: &mut BufWriter<W>) -> Result<(), FormattingError> {
237 self.header.format(writer)?;
238 self.record.format(writer, &self.header)?;
239 writer.flush()?;
240 Ok(())
241 }
242
243 /// Parses [DORIS] from local readable file.
244 pub fn from_file<P: AsRef<Path>>(path: P) -> Result<DORIS, ParsingError> {
245 let path = path.as_ref();
246
247 // deduce all we can from file name
248 let file_attributes = match path.file_name() {
249 Some(filename) => {
250 let filename = filename.to_string_lossy().to_string();
251 if let Ok(prod) = ProductionAttributes::from_str(&filename) {
252 Some(prod)
253 } else {
254 None
255 }
256 },
257 _ => None,
258 };
259
260 let fd = File::open(path)?;
261
262 let mut reader = BufReader::new(fd);
263 let mut doris = Self::parse(&mut reader)?;
264
265 doris.production = file_attributes;
266
267 Ok(doris)
268 }
269
270 /// Dumps [DORIS] into writable local file (as readable ASCII UTF-8)
271 /// using efficient buffered formatting.
272 /// This is the mirror operation of [Self::from_file].
273 ///
274 /// ```
275 /// use doris_rs::prelude::*;
276 ///
277 /// let doris = DORIS::from_gzip_file("data/DOR/V3/cs2rx18164.gz")
278 /// .unwrap();
279 ///
280 /// doris.to_file("demo.txt")
281 /// .unwrap();
282 ///
283 /// let parsed = DORIS::from_file("demo.txt")
284 /// .unwrap();
285 ///
286 /// assert_eq!(parsed.header.satellite, "CRYOSAT-2");
287 /// assert_eq!(parsed.header.ground_stations.len(), 53); // Network
288 /// ```
289 pub fn to_file<P: AsRef<Path>>(&self, path: P) -> Result<(), FormattingError> {
290 let fd = File::create(path)?;
291 let mut writer = BufWriter::new(fd);
292 self.format(&mut writer)?;
293 Ok(())
294 }
295
296 /// Parses [DORIS] from local gzip compressed file.
297 ///
298 /// ```
299 /// use std::str::FromStr;
300 /// use doris_rs::prelude::*;
301 ///
302 /// let doris = DORIS::from_gzip_file("data/DOR/V3/cs2rx18164.gz")
303 /// .unwrap();
304 ///
305 /// assert_eq!(doris.header.satellite, "CRYOSAT-2");
306 ///
307 /// let agency = "CNES".to_string(); // Agency / producer
308 /// let program = "Expert".to_string(); // Software name
309 /// let run_by = "CNES".to_string(); // Operator
310 /// let date = "20180614 090016 UTC".to_string(); // Date of production
311 /// let observer = "SPA_BN1_4.7P1".to_string(); // Operator
312 ///
313 /// assert_eq!(doris.header.program, Some(program));
314 /// assert_eq!(doris.header.run_by, Some(run_by));
315 /// assert_eq!(doris.header.date, Some(date)); // currently not interpreted
316 /// assert_eq!(doris.header.observer, Some(observer));
317 /// assert_eq!(doris.header.agency, Some(agency));
318 ///
319 /// assert!(doris.header.doi.is_none());
320 /// assert!(doris.header.license.is_none());
321 ///
322 /// let observables = vec![
323 /// Observable::UnambiguousPhaseRange(Frequency::DORIS1), // phase, in meters of prop.
324 /// Observable::UnambiguousPhaseRange(Frequency::DORIS2),
325 /// Observable::PseudoRange(Frequency::DORIS1), // decoded pseudo range
326 /// Observable::PseudoRange(Frequency::DORIS2),
327 /// Observable::Power(Frequency::DORIS1), // received power
328 /// Observable::Power(Frequency::DORIS2), // received power
329 /// Observable::FrequencyRatio, // f1/f2 ratio (=drift image)
330 /// Observable::Pressure, // pressure, at ground station level (hPa)
331 /// Observable::Temperature, // temperature, at ground station level (°C)
332 /// Observable::HumidityRate, // saturation rate, at ground station level (%)
333 /// ];
334 ///
335 /// assert_eq!(doris.header.observables, observables);
336 ///
337 /// assert_eq!(doris.header.ground_stations.len(), 53); // network
338 ///
339 /// // Stations helper
340 /// let site_matcher = Matcher::Site("TOULOUSE");
341 ///
342 /// let toulouse = GroundStation::default()
343 /// .with_domes(DOMES::from_str("10003S005").unwrap())
344 /// .with_site_name("TOULOUSE") // site name
345 /// .with_site_label("TLSB") // site label/mnemonic
346 /// .with_unique_id(13) // file dependent
347 /// .with_frequency_shift(0) // f1/f2 site shift for this day
348 /// .with_beacon_revision(3); // DORIS 3rd generation
349 ///
350 /// // helper
351 /// assert_eq!(doris.ground_station(site_matcher), Some(toulouse));
352 /// ```
353 #[cfg(feature = "flate2")]
354 #[cfg_attr(docsrs, doc(cfg(feature = "flate2")))]
355 pub fn from_gzip_file<P: AsRef<Path>>(path: P) -> Result<DORIS, ParsingError> {
356 let path = path.as_ref();
357
358 // deduce all we can from file name
359 let file_attributes = match path.file_name() {
360 Some(filename) => {
361 let filename = filename.to_string_lossy().to_string();
362 if let Ok(prod) = ProductionAttributes::from_str(&filename) {
363 Some(prod)
364 } else {
365 None
366 }
367 },
368 _ => None,
369 };
370
371 let fd = File::open(path)?;
372
373 let reader = GzDecoder::new(fd);
374 let mut reader = BufReader::new(reader);
375 let mut doris = Self::parse(&mut reader)?;
376
377 doris.production = file_attributes;
378
379 Ok(doris)
380 }
381
382 /// Dumps and gzip encodes [DORIS] into writable local file,
383 /// using efficient buffered formatting.
384 /// This is the mirror operation of [Self::from_gzip_file].
385 ///
386 /// ```
387 /// use doris_rs::prelude::*;
388 ///
389 /// let doris = DORIS::from_gzip_file("data/DOR/V3/cs2rx18164.gz")
390 /// .unwrap();
391 ///
392 /// doris.to_gzip_file("demo.gz")
393 /// .unwrap();
394 ///
395 /// let parsed = DORIS::from_gzip_file("demo.gz")
396 /// .unwrap();
397 ///
398 /// assert_eq!(parsed.header.satellite, "CRYOSAT-2");
399 /// assert_eq!(parsed.header.ground_stations.len(), 53); // Network
400 /// ```
401 #[cfg(feature = "flate2")]
402 #[cfg_attr(docsrs, doc(cfg(feature = "flate2")))]
403 pub fn to_gzip_file<P: AsRef<Path>>(&self, path: P) -> Result<(), FormattingError> {
404 let fd = File::create(path)?;
405 let compression = GzCompression::new(5);
406 let mut writer = BufWriter::new(GzEncoder::new(fd, compression));
407 self.format(&mut writer)?;
408 Ok(())
409 }
410
411 /// Determines whether this structure results of combining several structures
412 /// into a single one. This is determined by the presence of a custom yet somewhat standardized Header comment.
413 pub fn is_merged(&self) -> bool {
414 for comment in self.header.comments.iter() {
415 if comment.eq("FILE MERGE") {
416 return true;
417 }
418 }
419
420 false
421 }
422
423 /// Returns [GroundStation] information for matching site
424 pub fn ground_station<'a>(&self, matcher: Matcher<'a>) -> Option<GroundStation> {
425 self.header
426 .ground_stations
427 .iter()
428 .filter(|station| station.matches(&matcher))
429 .reduce(|k, _| k)
430 .cloned()
431 }
432
433 /// Returns measurement satellite [ClockOffset] [Iterator] for all Epochs, in chronological order
434 ///
435 /// ```
436 /// use doris_rs::prelude::*;
437 ///
438 /// let doris = DORIS::from_gzip_file("data/DOR/V3/cs2rx18164.gz")
439 /// .unwrap();
440 ///
441 /// assert_eq!(doris.header.satellite, "CRYOSAT-2");
442 ///
443 /// for (i, (epoch, clock_offset)) in doris.satellite_clock_offset_iter().enumerate() {
444 ///
445 /// assert_eq!(clock_offset.extrapolated, false); // actual measurement
446 ///
447 /// if i == 0 {
448 /// assert_eq!(clock_offset.offset.to_seconds(), -4.326631626);
449 /// } else if i == 10 {
450 /// assert_eq!(clock_offset.offset.to_seconds(), -4.326631711);
451 /// }
452 /// }
453 /// ```
454 pub fn satellite_clock_offset_iter(
455 &self,
456 ) -> Box<dyn Iterator<Item = (Epoch, ClockOffset)> + '_> {
457 Box::new(
458 self.record
459 .measurements
460 .iter()
461 .filter_map(|(k, v)| {
462 if let Some(clock_offset) = v.satellite_clock_offset {
463 Some((k.epoch, clock_offset))
464 } else {
465 None
466 }
467 })
468 .unique(),
469 )
470 }
471
472 /// Returns histogram analysis of the sampling period, as ([Duration], population [usize]) tuple.
473 /// ```
474 /// use doris_rs::prelude::*;
475 /// use itertools::Itertools;
476 ///
477 /// let doris = DORIS::from_gzip_file("data/DOR/V3/cs2rx18164.gz")
478 /// .unwrap();
479 ///
480 /// // requires more than 2 measurements
481 /// let (sampling_period, population) = doris.sampling_histogram()
482 /// .sorted()
483 /// .nth(0) // dominant
484 /// .unwrap();
485 ///
486 /// assert_eq!(sampling_period, Duration::from_seconds(3.0));
487 /// ```
488 pub fn sampling_histogram(&self) -> Box<dyn Iterator<Item = (Duration, usize)> + '_> {
489 Box::new(
490 self.record
491 .epochs_iter()
492 .zip(self.record.epochs_iter().skip(1))
493 .map(|((ek_1, _), (ek_2, _))| ek_2 - ek_1)
494 .fold(vec![], |mut list, dt| {
495 let mut found = false;
496
497 for (delta, pop) in list.iter_mut() {
498 if *delta == dt {
499 *pop += 1;
500 found = true;
501 break;
502 }
503 }
504
505 if !found {
506 list.push((dt, 1));
507 }
508
509 list
510 })
511 .into_iter(),
512 )
513 }
514
515 /// Studies actual measurement rate and returns the highest
516 /// value in the histogram as the dominant sampling rate
517 ///
518 /// ```
519 /// use doris_rs::prelude::*;
520 /// use itertools::Itertools;
521 ///
522 /// let doris = DORIS::from_gzip_file("data/DOR/V3/cs2rx18164.gz")
523 /// .unwrap();
524 ///
525 /// // requires more than 2 measurements
526 /// let sampling_period = doris.dominant_sampling_period()
527 /// .unwrap();
528 ///
529 /// assert_eq!(sampling_period, Duration::from_seconds(3.0));
530 /// ```
531 pub fn dominant_sampling_period(&self) -> Option<Duration> {
532 self.sampling_histogram()
533 .sorted()
534 .map(|(dt, _)| dt)
535 .reduce(|k, _| k)
536 }
537
538 /// Generates (guesses) a standardized (uppercase) filename from this actual [DORIS] data set.
539 /// This is particularly useful when initiated from a file that did not follow
540 /// standard naming conventions.
541 ///
542 /// ```
543 /// use doris_rs::prelude::*;
544 ///
545 /// // parse standard file
546 /// let doris = DORIS::from_gzip_file("data/DOR/V3/cs2rx18164.gz")
547 /// .unwrap();
548 ///
549 /// assert_eq!(doris.standard_filename(), "CS2RX18164.gz");
550 ///
551 /// // Dump using random name
552 /// doris.to_file("example.txt")
553 /// .unwrap();
554 ///
555 /// // parse back & use
556 /// let parsed = DORIS::from_file("example.txt")
557 /// .unwrap();
558 ///
559 /// assert_eq!(parsed.header.satellite, "CRYOSAT-2");
560 ///
561 /// // when coming from non standard names,
562 /// // all fields are deduced from actual content.
563 /// assert_eq!(parsed.standard_filename(), "CRYOS18164");
564 /// ```
565 pub fn standard_filename(&self) -> String {
566 if let Some(attributes) = &self.production {
567 attributes.to_string()
568 } else {
569 let mut doy = 0;
570 let mut year = 0i32;
571
572 let sat_len = self.header.satellite.len();
573 let mut sat_name = self.header.satellite[..std::cmp::min(sat_len, 5)].to_string();
574
575 if let Some(epoch) = self.header.time_of_first_observation {
576 year = epoch.year() - 2000;
577 doy = epoch.day_of_year().round() as u32;
578 }
579
580 for _ in sat_len..5 {
581 sat_name.push('X');
582 }
583
584 format!("{}{:02}{:03}", sat_name, year, doy)
585 }
586 }
587
588 /// Copies and returns new [DORIS] that is the result
589 /// of ground station observation differentiation.
590 /// See [Self::observations_substract_mut] for more information.
591 ///
592 /// ```
593 /// use doris_rs::prelude::*;
594 ///
595 /// let parsed = DORIS::from_gzip_file("data/DOR/V3/cs2rx18164.gz")
596 /// .unwrap();
597 ///
598 /// // basic example, this will produce a NULL DORIS.
599 /// // Standard use case is to use a synchronous observation of the
600 /// // same network.
601 /// let residuals = parsed.substract(&parsed);
602 ///
603 /// // dump
604 /// residuals.to_file("residuals.txt")
605 /// .unwrap();
606 /// ```
607 pub fn substract(&self, rhs: &Self) -> Self {
608 let mut s = self.clone();
609 s.substract_mut(rhs);
610 s
611 }
612
613 /// Substract (in place) this [DORIS] file to another, creating
614 /// a "residual" [DORIS] file. All synchronous measurements of
615 /// matching stations are substracted to one another.
616 /// Satellite clock offset is preserved.
617 /// All other stations observations (non-synchronous, no remote counter part)
618 /// are dropped: you are left with residual content only after this operation.
619 pub fn substract_mut(&mut self, rhs: &Self) {
620 self.record.measurements.retain(|k, measurements| {
621 if let Some(rhs_measurements) = rhs.record.measurements.get(&k) {
622 measurements.observations.retain(|obs_k, observation| {
623 if let Some(rhs_obs) = rhs_measurements.observations.get(&obs_k) {
624 observation.value -= rhs_obs.value;
625 true
626 } else {
627 false
628 }
629 });
630
631 !measurements.observations.is_empty()
632 } else {
633 false
634 }
635 });
636 // if let Some(rhs) = rhs.record.as_obs() {
637 // if let Some(rec) = self.record.as_mut_obs() {
638 // rec.retain(|k, v| {
639 // v.signals.retain_mut(|sig| {
640 // let mut reference = 0.0;
641 // let mut min_dt = Duration::MAX;
642
643 // // temporal filter
644 // let filtered_rhs_epochs = rhs.iter().filter(|(rhs, _)| {
645 // let dt = (rhs.epoch - k.epoch).abs();
646 // dt <= half_lhs_dt
647 // });
648
649 // for (rhs_epoch, rhs_values) in filtered_rhs_epochs {
650 // for rhs_sig in rhs_values.signals.iter() {
651 // if rhs_sig.sv == sig.sv && rhs_sig.observable == sig.observable {
652 // let dt = (rhs_epoch.epoch - k.epoch).abs();
653 // if dt <= min_dt {
654 // reference = rhs_sig.value;
655 // min_dt = dt;
656 // }
657 // }
658 // }
659 // }
660
661 // if min_dt < Duration::MAX {
662 // sig.value -= reference;
663 // }
664
665 // min_dt < Duration::MAX
666 // });
667
668 // !v.signals.is_empty()
669 // });
670 // }
671 // }
672 }
673}
674
675#[cfg(test)]
676mod test {
677 use super::*;
678 use crate::fmt_comment;
679
680 #[test]
681 fn fmt_comments_singleline() {
682 for desc in [
683 "test",
684 "just a basic comment",
685 "just another lengthy comment blahblabblah",
686 ] {
687 let comment = fmt_comment(desc);
688 assert!(
689 comment.len() >= 60,
690 "comments should be at least 60 byte long"
691 );
692
693 assert_eq!(
694 comment.find("COMMENT"),
695 Some(60),
696 "comment marker should located @ 60"
697 );
698 }
699 }
700
701 #[test]
702 fn fmt_wrapped_comments() {
703 for desc in ["just trying to form a very lengthy comment that will overflow since it does not fit in a single line",
704 "just trying to form a very very lengthy comment that will overflow since it does fit on three very meaningful lines. Imazdmazdpoakzdpoakzpdokpokddddddddddddddddddaaaaaaaaaaaaaaaaaaaaaaa"] {
705 let nb_lines = num_integer::div_ceil(desc.len(), 60);
706 let comments = fmt_comment(desc);
707 assert_eq!(comments.lines().count(), nb_lines);
708 for line in comments.lines() {
709 assert!(line.len() >= 60, "comment line should be at least 60 byte long");
710 assert_eq!(line.find("COMMENT"), Some(60), "comment marker should located @ 60");
711 }
712 }
713 }
714
715 #[test]
716 fn fmt_observables_v3() {
717 for (desc, expected) in [
718("R 9 C1C L1C S1C C2C C2P L2C L2P S2C S2P",
719"R 9 C1C L1C S1C C2C C2P L2C L2P S2C S2P SYS / # / OBS TYPES"),
720("G 18 C1C L1C S1C C2P C2W C2S C2L C2X L2P L2W L2S L2L L2X S2P S2W S2S S2L S2X",
721"G 18 C1C L1C S1C C2P C2W C2S C2L C2X L2P L2W L2S L2L L2X SYS / # / OBS TYPES
722 S2P S2W S2S S2L S2X SYS / # / OBS TYPES"),
723 ] {
724 assert_eq!(fmt_doris(desc, "SYS / # / OBS TYPES"), expected);
725 }
726 }
727}