1use std::fmt;
18
19use libm::{floor, log10, pow};
20
21use crate::astro::sgp4::{self, ElementSet};
22use crate::validate;
23
24const MAX_LINE_LEN: usize = 69;
27const MAX_ASCII: u32 = 127;
29const LINE1_MIN_LEN: usize = 64;
31const LINE2_MIN_LEN: usize = 68;
33const CHECKSUM_COL: usize = 68;
35const YEAR_PIVOT: i32 = 57;
38const BODY_LEN: usize = 68;
40
41const EPOCH_DAY_DECIMALS: usize = 8;
43const EPOCH_DAY_WIDTH: usize = 12;
45const NDOT_DECIMALS: usize = 8;
47const NDOT_WIDTH: usize = 9;
49const ASSUMED_DECIMAL_MANTISSA_DECIMALS: usize = 5;
51const ASSUMED_DECIMAL_MANTISSA_DIGITS: usize = 5;
53const ECCENTRICITY_DECIMALS: usize = 7;
55const ECCENTRICITY_DIGITS: usize = 7;
57const ANGLE_DECIMALS: usize = 4;
59const ANGLE_WIDTH: usize = 8;
61const MEAN_MOTION_DECIMALS: usize = 8;
63const MEAN_MOTION_WIDTH: usize = 11;
65const ELSET_WIDTH: usize = 4;
67const REV_WIDTH: usize = 5;
69const CATALOG_WIDTH: usize = 5;
71const INTL_DESIGNATOR_WIDTH: usize = 8;
73const EPOCH_YEAR_WIDTH: usize = 2;
75
76#[derive(Debug, Clone, PartialEq)]
82pub struct TleElements {
83 pub catalog_number: String,
84 pub classification: String,
85 pub international_designator: String,
86 pub epoch_year: i32,
87 pub epoch_day_of_year: f64,
88 pub mean_motion_dot: f64,
89 pub mean_motion_double_dot: f64,
90 pub bstar: f64,
91 pub ephemeris_type: i32,
92 pub elset_number: i32,
93 pub inclination_deg: f64,
94 pub raan_deg: f64,
95 pub eccentricity: f64,
96 pub arg_perigee_deg: f64,
97 pub mean_anomaly_deg: f64,
98 pub mean_motion: f64,
99 pub rev_number: i32,
100}
101
102impl TleElements {
103 pub fn to_element_set(&self) -> Result<ElementSet, TleError> {
123 validate_tle_bridge(self)?;
124 Ok(ElementSet {
125 epoch: sgp4::sgp4_julian_date_from_day_of_year(self.epoch_year, self.epoch_day_of_year),
126 bstar: self.bstar,
127 mean_motion_dot: self.mean_motion_dot,
128 mean_motion_double_dot: self.mean_motion_double_dot,
129 eccentricity: self.eccentricity,
130 argument_of_perigee_deg: self.arg_perigee_deg,
131 inclination_deg: self.inclination_deg,
132 mean_anomaly_deg: self.mean_anomaly_deg,
133 mean_motion_rev_per_day: self.mean_motion,
134 right_ascension_deg: self.raan_deg,
135 catalog_number: self.catalog_number.trim().parse().unwrap_or(0),
136 })
137 }
138}
139
140#[derive(Debug, Clone, PartialEq, Eq)]
143pub struct ChecksumWarning {
144 pub line_label: &'static str,
146 pub expected: u8,
148 pub computed: u8,
150}
151
152#[derive(Debug, Clone, PartialEq)]
154pub struct ParsedTle {
155 pub elements: TleElements,
156 pub checksum_warnings: Vec<ChecksumWarning>,
157}
158
159#[derive(Debug, Clone, PartialEq, Eq)]
161pub enum TleError {
162 NonAscii,
163 Format,
164 SatelliteMismatch,
165 InvalidField {
166 field: &'static str,
167 reason: &'static str,
168 },
169 Field(String),
170}
171
172impl fmt::Display for TleError {
173 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
174 match self {
175 TleError::NonAscii => write!(f, "TLE lines contain non-ASCII characters"),
176 TleError::Format => write!(
177 f,
178 "TLE format error: line does not match the Two-Line Element fixed-width format"
179 ),
180 TleError::SatelliteMismatch => {
181 write!(f, "Satellite numbers in lines 1 and 2 do not match")
182 }
183 TleError::InvalidField { field, reason } => {
184 write!(f, "TLE invalid field {field}: {reason}")
185 }
186 TleError::Field(msg) => write!(f, "TLE parse error: {msg}"),
187 }
188 }
189}
190
191impl std::error::Error for TleError {}
192
193fn validate_tle_bridge(elements: &TleElements) -> Result<(), TleError> {
194 validate::finite(elements.epoch_day_of_year, "epoch_day_of_year").map_err(map_tle_field)?;
195 validate::finite(elements.bstar, "bstar").map_err(map_tle_field)?;
196 validate::finite(elements.mean_motion_dot, "mean_motion_dot").map_err(map_tle_field)?;
197 validate::finite(elements.mean_motion_double_dot, "mean_motion_double_dot")
198 .map_err(map_tle_field)?;
199 validate::finite_in_range_exclusive_upper(elements.eccentricity, 0.0, 1.0, "eccentricity")
200 .map_err(map_tle_field)?;
201 validate::finite(elements.arg_perigee_deg, "arg_perigee_deg").map_err(map_tle_field)?;
202 validate::finite(elements.inclination_deg, "inclination_deg").map_err(map_tle_field)?;
203 validate::finite(elements.mean_anomaly_deg, "mean_anomaly_deg").map_err(map_tle_field)?;
204 validate::finite_positive(elements.mean_motion, "mean_motion").map_err(map_tle_field)?;
205 validate::finite(elements.raan_deg, "raan_deg").map_err(map_tle_field)?;
206 Ok(())
207}
208
209fn map_tle_field(error: validate::FieldError) -> TleError {
210 TleError::InvalidField {
211 field: error.field(),
212 reason: error.reason(),
213 }
214}
215
216pub fn parse(line1: &str, line2: &str) -> Result<ParsedTle, TleError> {
222 if !is_ascii(line1) || !is_ascii(line2) {
223 return Err(TleError::NonAscii);
224 }
225
226 let line1 = clean_line(line1);
227 let line2 = clean_line(line2);
228
229 validate_format(&line1, &line2)?;
230 let elements = extract_fields(&line1, &line2)?;
231 let checksum_warnings = checksum_warnings(&line1, &line2);
232
233 Ok(ParsedTle {
234 elements,
235 checksum_warnings,
236 })
237}
238
239pub fn encode(el: &TleElements) -> (String, String) {
245 let cat = pad_leading(el.catalog_number.trim(), CATALOG_WIDTH);
246 let cls = &el.classification;
247 let intl = pad_trailing(&el.international_designator, INTL_DESIGNATOR_WIDTH);
248
249 let epoch_two_digit = el.epoch_year.rem_euclid(100);
250
251 let l1_body = format!(
252 "1 {cat}{cls} {intl} {epoch} {ndot} {nddot} {bstar} {ephtype} {elnum}",
253 epoch = fmt_epoch(epoch_two_digit, el.epoch_day_of_year),
254 ndot = fmt_ndot(el.mean_motion_dot),
255 nddot = fmt_assumed_decimal(el.mean_motion_double_dot),
256 bstar = fmt_assumed_decimal(el.bstar),
257 ephtype = el.ephemeris_type,
258 elnum = pad_leading(&el.elset_number.to_string(), ELSET_WIDTH),
259 );
260 let line1 = pad_and_checksum(&l1_body);
261
262 let l2_body = format!(
263 "2 {cat} {inclo} {raan} {ecc} {argp} {mo} {mm}{revnum}",
264 inclo = fmt_angle(el.inclination_deg),
265 raan = fmt_angle(el.raan_deg),
266 ecc = fmt_eccentricity(el.eccentricity),
267 argp = fmt_angle(el.arg_perigee_deg),
268 mo = fmt_angle(el.mean_anomaly_deg),
269 mm = fmt_mean_motion(el.mean_motion),
270 revnum = pad_leading(&el.rev_number.to_string(), REV_WIDTH),
271 );
272 let line2 = pad_and_checksum(&l2_body);
273
274 (line1, line2)
275}
276
277fn is_ascii(line: &str) -> bool {
280 line.chars().all(|c| (c as u32) <= MAX_ASCII)
281}
282
283fn clean_line(line: &str) -> String {
285 let trimmed = line.trim_end();
286 if trimmed.len() > MAX_LINE_LEN {
287 trimmed[..MAX_LINE_LEN].to_string()
288 } else {
289 trimmed.to_string()
290 }
291}
292
293fn validate_format(line1: &str, line2: &str) -> Result<(), TleError> {
294 validate_line(line1, '1', LINE1_MIN_LEN, &LINE1_POSITIONS)?;
295 validate_line(line2, '2', LINE2_MIN_LEN, &LINE2_POSITIONS)?;
296 if slice_inclusive(line1, 2, 6) == slice_inclusive(line2, 2, 6) {
297 Ok(())
298 } else {
299 Err(TleError::SatelliteMismatch)
300 }
301}
302
303fn validate_line(
304 line: &str,
305 prefix: char,
306 min_len: usize,
307 positions: &[(usize, char)],
308) -> Result<(), TleError> {
309 let len = line.chars().count();
310 if len < min_len {
311 return Err(TleError::Format);
312 }
313 let mut start = String::with_capacity(2);
314 start.push(prefix);
315 start.push(' ');
316 if !line.starts_with(&start) {
317 return Err(TleError::Format);
318 }
319 if positions
320 .iter()
321 .all(|&(pos, ch)| char_at(line, pos) == Some(ch))
322 {
323 Ok(())
324 } else {
325 Err(TleError::Format)
326 }
327}
328
329const LINE1_POSITIONS: [(usize, char); 8] = [
330 (8, ' '),
331 (23, '.'),
332 (32, ' '),
333 (34, '.'),
334 (43, ' '),
335 (52, ' '),
336 (61, ' '),
337 (63, ' '),
338];
339
340const LINE2_POSITIONS: [(usize, char); 10] = [
341 (7, ' '),
342 (11, '.'),
343 (16, ' '),
344 (20, '.'),
345 (25, ' '),
346 (33, ' '),
347 (37, '.'),
348 (42, ' '),
349 (46, '.'),
350 (51, ' '),
351];
352
353fn extract_fields(line1: &str, line2: &str) -> Result<TleElements, TleError> {
354 let two_digit_year = parse_int(slice_inclusive(line1, 18, 19).trim())?;
355 let epoch_year = if two_digit_year < YEAR_PIVOT {
356 2000 + two_digit_year
357 } else {
358 1900 + two_digit_year
359 };
360
361 Ok(TleElements {
362 catalog_number: slice_inclusive(line1, 2, 6).trim().to_string(),
363 classification: char_at(line1, 7).unwrap_or('U').to_string(),
364 international_designator: slice_inclusive(line1, 9, 16).trim_end().to_string(),
365 epoch_year,
366 epoch_day_of_year: parse_float(slice_inclusive(line1, 20, 31))?,
367 mean_motion_dot: parse_float(slice_inclusive(line1, 33, 42))?,
368 mean_motion_double_dot: parse_assumed_decimal(line1, 44, 45, 49, 50, 51)?,
369 bstar: parse_assumed_decimal(line1, 53, 54, 58, 59, 60)?,
370 ephemeris_type: parse_int_or_default(
371 char_at(line1, 62)
372 .map(|c| c.to_string())
373 .unwrap_or_default()
374 .trim(),
375 0,
376 )?,
377 elset_number: parse_int_or_default(slice_inclusive(line1, 64, 67).trim(), 0)?,
378 inclination_deg: parse_float(slice_inclusive(line2, 8, 15))?,
379 raan_deg: parse_float(slice_inclusive(line2, 17, 24))?,
380 eccentricity: parse_eccentricity(slice_inclusive(line2, 26, 32))?,
381 arg_perigee_deg: parse_float(slice_inclusive(line2, 34, 41))?,
382 mean_anomaly_deg: parse_float(slice_inclusive(line2, 43, 50))?,
383 mean_motion: parse_float(slice_inclusive(line2, 52, 62))?,
384 rev_number: parse_int_or_default(slice_inclusive(line2, 63, 67).trim(), 0)?,
385 })
386}
387
388fn parse_assumed_decimal(
391 line: &str,
392 sign_pos: usize,
393 mant_start: usize,
394 mant_end: usize,
395 exp_start: usize,
396 exp_end: usize,
397) -> Result<f64, TleError> {
398 let sign = if char_at(line, sign_pos) == Some('-') {
399 -1.0
400 } else {
401 1.0
402 };
403 let mantissa_field = format!("0.{}", slice_inclusive(line, mant_start, mant_end));
404 let mantissa = parse_float_raw(mantissa_field.trim())?;
405 let exp = parse_int(slice_inclusive(line, exp_start, exp_end).trim())?;
406 Ok(sign * mantissa * 10.0_f64.powi(exp))
411}
412
413fn parse_eccentricity(field: &str) -> Result<f64, TleError> {
415 let digits = field.replace(' ', "0");
416 parse_float_raw(&format!("0.{digits}"))
417}
418
419fn parse_float(field: &str) -> Result<f64, TleError> {
422 let trimmed = field.trim();
423 let without_plus = trimmed.strip_prefix('+').unwrap_or(trimmed);
424 let normalized = if let Some(rest) = without_plus.strip_prefix("-.") {
425 format!("-0.{rest}")
426 } else if let Some(rest) = without_plus.strip_prefix('.') {
427 format!("0.{rest}")
428 } else {
429 without_plus.to_string()
430 };
431 parse_float_raw(&normalized)
432}
433
434fn parse_float_raw(text: &str) -> Result<f64, TleError> {
437 if !text.contains('.') {
438 return Err(TleError::Field(format!("invalid float {text:?}")));
439 }
440 let body = text.strip_prefix('-').unwrap_or(text);
441 if body.starts_with('.') || body.ends_with('.') {
442 return Err(TleError::Field(format!("invalid float {text:?}")));
443 }
444 text.parse::<f64>()
445 .map_err(|_| TleError::Field(format!("invalid float {text:?}")))
446}
447
448fn parse_int(text: &str) -> Result<i32, TleError> {
449 text.parse::<i32>()
450 .map_err(|_| TleError::Field(format!("invalid integer {text:?}")))
451}
452
453fn parse_int_or_default(text: &str, default: i32) -> Result<i32, TleError> {
458 if text.is_empty() {
459 Ok(default)
460 } else {
461 parse_int(text)
462 }
463}
464
465fn checksum_warnings(line1: &str, line2: &str) -> Vec<ChecksumWarning> {
466 [("line 1", line1), ("line 2", line2)]
467 .into_iter()
468 .filter_map(|(label, line)| check_one(label, line))
469 .collect()
470}
471
472fn check_one(label: &'static str, line: &str) -> Option<ChecksumWarning> {
473 if line.chars().count() < MAX_LINE_LEN {
474 return None;
475 }
476 let expected = char_at(line, CHECKSUM_COL)
477 .and_then(|c| c.to_digit(10))
478 .map(|d| d as u8)?;
479 let computed = compute_checksum(line);
480 if expected == computed {
481 None
482 } else {
483 Some(ChecksumWarning {
484 line_label: label,
485 expected,
486 computed,
487 })
488 }
489}
490
491fn compute_checksum(line: &str) -> u8 {
494 let sum: u32 = line
495 .chars()
496 .take(BODY_LEN)
497 .map(|c| match c {
498 '0'..='9' => c as u32 - '0' as u32,
499 '-' => 1,
500 _ => 0,
501 })
502 .sum();
503 (sum % 10) as u8
504}
505
506fn slice_inclusive(s: &str, start: usize, end_inclusive: usize) -> &str {
511 let len = s.len();
512 if start >= len {
513 return "";
514 }
515 let end = (end_inclusive + 1).min(len);
516 &s[start..end]
517}
518
519fn char_at(s: &str, index: usize) -> Option<char> {
520 s.as_bytes().get(index).map(|&b| b as char)
521}
522
523pub(crate) fn assumed_decimal_quantize(value: f64) -> f64 {
534 if value == 0.0 {
535 return 0.0;
536 }
537 decode_assumed_decimal_field(&fmt_assumed_decimal(value))
538}
539
540fn decode_assumed_decimal_field(field: &str) -> f64 {
543 let sign = if field.starts_with('-') { -1.0 } else { 1.0 };
544 let body = &field[1..];
545 let mantissa_digits = &body[..ASSUMED_DECIMAL_MANTISSA_DIGITS];
546 let exp_field = &body[ASSUMED_DECIMAL_MANTISSA_DIGITS..];
547 let exp_field = exp_field.strip_prefix('+').unwrap_or(exp_field);
548 let mantissa: f64 = format!("0.{mantissa_digits}").parse().unwrap_or(0.0);
549 let exp: i32 = exp_field.parse().unwrap_or(0);
550 sign * mantissa * 10.0_f64.powi(exp)
551}
552
553fn fmt_epoch(year_two_digit: i32, day_of_year: f64) -> String {
556 let yr = pad_leading_zeros(&year_two_digit.to_string(), EPOCH_YEAR_WIDTH);
557 let days = fixed_decimals(day_of_year, EPOCH_DAY_DECIMALS);
558 format!("{yr}{}", pad_leading_zeros(&days, EPOCH_DAY_WIDTH))
559}
560
561fn fmt_ndot(val: f64) -> String {
562 let sign = if val < 0.0 { '-' } else { ' ' };
563 let mut digits = fixed_decimals(val.abs(), NDOT_DECIMALS);
564 if let Some(rest) = digits.strip_prefix('0') {
565 digits = rest.to_string();
566 }
567 format!("{sign}{}", pad_leading(&digits, NDOT_WIDTH))
568}
569
570fn fmt_assumed_decimal(val: f64) -> String {
572 if val == 0.0 {
573 return " 00000-0".to_string();
574 }
575 let sign = if val < 0.0 { '-' } else { ' ' };
576 let av = val.abs();
577 let raw_exp = floor(log10(av)) as i32;
578 let mut exp = raw_exp + 1;
579 let mantissa = av / pow(10.0, exp as f64);
580 let mut mant_full = fixed_decimals(mantissa, ASSUMED_DECIMAL_MANTISSA_DECIMALS);
581 if mant_full.starts_with("1.") {
582 exp += 1;
583 mant_full = fixed_decimals(mantissa / 10.0, ASSUMED_DECIMAL_MANTISSA_DECIMALS);
584 }
585 let mant_str: String = mant_full
586 .chars()
587 .skip(2)
588 .take(ASSUMED_DECIMAL_MANTISSA_DIGITS)
589 .collect();
590 let exp_sign = if exp >= 0 { '+' } else { '-' };
591 format!("{sign}{mant_str}{exp_sign}{}", exp.abs())
592}
593
594fn fmt_eccentricity(ecc: f64) -> String {
595 let formatted = fixed_decimals(ecc, ECCENTRICITY_DECIMALS);
596 let digits = formatted.strip_prefix("0.").unwrap_or(&formatted);
597 pad_leading_zeros(digits, ECCENTRICITY_DIGITS)
598}
599
600fn fmt_angle(val: f64) -> String {
601 pad_leading(&fixed_decimals(val, ANGLE_DECIMALS), ANGLE_WIDTH)
602}
603
604fn fmt_mean_motion(val: f64) -> String {
605 pad_leading(
606 &fixed_decimals(val, MEAN_MOTION_DECIMALS),
607 MEAN_MOTION_WIDTH,
608 )
609}
610
611fn pad_and_checksum(body: &str) -> String {
612 let clamped: String = body.chars().take(BODY_LEN).collect();
613 let padded = pad_trailing(&clamped, BODY_LEN);
614 let checksum = compute_checksum(&padded);
615 format!("{padded}{checksum}")
616}
617
618fn fixed_decimals(value: f64, decimals: usize) -> String {
621 format!("{value:.decimals$}")
622}
623
624fn pad_leading(s: &str, width: usize) -> String {
625 pad_leading_with(s, width, ' ')
626}
627
628fn pad_leading_zeros(s: &str, width: usize) -> String {
629 pad_leading_with(s, width, '0')
630}
631
632fn pad_leading_with(s: &str, width: usize, fill: char) -> String {
633 let len = s.chars().count();
634 if len >= width {
635 s.to_string()
636 } else {
637 let mut out: String = std::iter::repeat_n(fill, width - len).collect();
638 out.push_str(s);
639 out
640 }
641}
642
643fn pad_trailing(s: &str, width: usize) -> String {
644 let len = s.chars().count();
645 if len >= width {
646 s.to_string()
647 } else {
648 let mut out = s.to_string();
649 out.extend(std::iter::repeat_n(' ', width - len));
650 out
651 }
652}
653
654#[cfg(test)]
655mod tests {
656 use super::*;
657
658 const ISS_L1: &str = "1 25544U 98067A 18184.80969102 .00001614 00000-0 31745-4 0 9993";
659 const ISS_L2: &str = "2 25544 51.6414 295.8524 0003435 262.6267 204.2868 15.54005638121106";
660
661 #[test]
662 fn parses_iss_fields() {
663 let parsed = parse(ISS_L1, ISS_L2).unwrap();
664 let el = parsed.elements;
665 assert_eq!(el.catalog_number, "25544");
666 assert_eq!(el.classification, "U");
667 assert_eq!(el.international_designator, "98067A");
668 assert_eq!(el.epoch_year, 2018);
669 assert_eq!(el.epoch_day_of_year, 184.80969102);
670 assert_eq!(el.inclination_deg, 51.6414);
671 assert_eq!(el.eccentricity, 0.0003435);
672 assert_eq!(el.mean_motion, 15.54005638);
673 assert_eq!(el.rev_number, 12110);
674 assert!(parsed.checksum_warnings.is_empty());
675 }
676
677 #[test]
678 fn round_trips_iss_character_exact() {
679 let parsed = parse(ISS_L1, ISS_L2).unwrap();
680 let (l1, l2) = encode(&parsed.elements);
681 assert_eq!(l1, ISS_L1);
682 assert_eq!(l2, ISS_L2);
683 }
684
685 #[test]
686 fn low_catalog_numbers_keep_leading_zeros() {
687 let l1 = "1 00005U 58002B 00179.78495062 .00000023 00000-0 28098-4 0 4753";
688 let l2 = "2 00005 34.2682 348.7242 1859667 331.7664 19.3264 10.82419157413667";
689 let parsed = parse(l1, l2).unwrap();
690 assert_eq!(parsed.elements.catalog_number, "00005");
691 assert_eq!(parsed.elements.epoch_year, 2000);
692 }
693
694 #[test]
695 fn rejects_empty_lines() {
696 assert!(parse("", "").is_err());
697 }
698
699 #[test]
700 fn rejects_non_tle_text() {
701 assert!(matches!(
702 parse("hello world", "goodbye world"),
703 Err(TleError::Format)
704 ));
705 }
706
707 #[test]
708 fn rejects_swapped_lines() {
709 assert!(parse(ISS_L2, ISS_L1).is_err());
710 }
711
712 #[test]
713 fn rejects_non_ascii() {
714 assert_eq!(
715 parse("1 25544\u{fc} test", "2 25544\u{fc} test"),
716 Err(TleError::NonAscii)
717 );
718 }
719
720 #[test]
721 fn rejects_mismatched_satellite_numbers() {
722 let l1 = "1 25544U 98067A 18184.80969102 .00001614 00000-0 31745-4 0 9993";
723 let l2 = "2 25545 51.6414 295.8524 0003435 262.6267 204.2868 15.54005638121106";
724 assert_eq!(parse(l1, l2), Err(TleError::SatelliteMismatch));
725 }
726
727 #[test]
728 fn parses_negative_drag_terms() {
729 let parsed = parse(ISS_L1, ISS_L2).unwrap();
731 assert!(parsed.elements.bstar > 0.0);
732 assert_eq!(parsed.elements.mean_motion_double_dot, 0.0);
733 }
734
735 #[test]
736 fn element_bridge_rejects_invalid_values() {
737 let mut el = parse(ISS_L1, ISS_L2).unwrap().elements;
738 el.mean_motion = f64::NAN;
739 assert_eq!(
740 el.to_element_set(),
741 Err(TleError::InvalidField {
742 field: "mean_motion",
743 reason: "not finite"
744 })
745 );
746
747 let mut el = parse(ISS_L1, ISS_L2).unwrap().elements;
748 el.eccentricity = 1.0;
749 assert_eq!(
750 el.to_element_set(),
751 Err(TleError::InvalidField {
752 field: "eccentricity",
753 reason: "out of range"
754 })
755 );
756 }
757
758 #[test]
759 fn assumed_decimal_rounding_carry_bumps_exponent() {
760 let mut el = parse(ISS_L1, ISS_L2).unwrap().elements;
761 el.mean_motion_double_dot = 9.999996e-5;
762 el.bstar = 9.999996e-5;
763
764 let (line1, line2) = encode(&el);
765 assert_eq!(slice_inclusive(&line1, 44, 51), " 10000-3");
766 assert_eq!(slice_inclusive(&line1, 53, 60), " 10000-3");
767
768 let parsed = parse(&line1, &line2).unwrap().elements;
769 assert_eq!(parsed.mean_motion_double_dot, 1.0e-4);
770 assert_eq!(parsed.bstar, 1.0e-4);
771
772 let (round_trip_line1, round_trip_line2) = encode(&parsed);
773 assert_eq!(round_trip_line1, line1);
774 assert_eq!(round_trip_line2, line2);
775 }
776
777 #[test]
778 fn checksum_mismatch_is_reported_not_rejected() {
779 let bad_l1 = "1 25544U 98067A 18184.80969102 .00001614 00000-0 31745-4 0 9990";
781 let parsed = parse(bad_l1, ISS_L2).unwrap();
782 assert_eq!(parsed.checksum_warnings.len(), 1);
783 assert_eq!(parsed.checksum_warnings[0].line_label, "line 1");
784 assert_eq!(parsed.checksum_warnings[0].expected, 0);
785 assert_eq!(parsed.checksum_warnings[0].computed, 3);
786 }
787}