1pub mod fit;
28#[allow(
29 dead_code,
30 unused_variables,
31 unused_assignments,
32 unused_mut,
33 non_snake_case,
34 non_camel_case_types,
35 clippy::approx_constant,
36 clippy::excessive_precision,
37 clippy::too_many_arguments,
38 clippy::needless_return,
39 clippy::assign_op_pattern,
40 clippy::manual_range_contains,
41 clippy::collapsible_if,
42 clippy::collapsible_else_if,
43 clippy::float_cmp,
44 clippy::needless_late_init,
45 clippy::field_reassign_with_default
46)]
47mod vallado;
48
49use crate::astro::tle;
50use crate::validate::{self, FieldError};
51use thiserror::Error;
52
53pub use fit::{
54 fit_tle, FitConfig, FitEpoch, FitSample, FitStatistics, Loss, TleFit, TleFitError, TleMetadata,
55 XScale,
56};
57
58const MAX_VALLADO_SATNUM: u32 = 99_999;
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum Sgp4InputErrorKind {
65 NonFinite,
67 NotPositive,
69 Negative,
71 OutOfRange,
73 Missing,
75 FloatParse,
77 IntParse,
79 InvalidCivilDate,
81 InvalidCivilTime,
83}
84
85impl core::fmt::Display for Sgp4InputErrorKind {
86 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
87 let label = match self {
88 Self::NonFinite => "not finite",
89 Self::NotPositive => "not positive",
90 Self::Negative => "negative",
91 Self::OutOfRange => "out of range",
92 Self::Missing => "missing",
93 Self::FloatParse => "invalid float",
94 Self::IntParse => "invalid integer",
95 Self::InvalidCivilDate => "invalid civil date",
96 Self::InvalidCivilTime => "invalid civil time",
97 };
98 f.write_str(label)
99 }
100}
101
102impl From<&FieldError> for Sgp4InputErrorKind {
103 fn from(error: &FieldError) -> Self {
104 match error {
105 FieldError::Missing { .. } => Self::Missing,
106 FieldError::NonFinite { .. } => Self::NonFinite,
107 FieldError::NotPositive { .. } => Self::NotPositive,
108 FieldError::Negative { .. } => Self::Negative,
109 FieldError::OutOfRange { .. } => Self::OutOfRange,
110 FieldError::FloatParse { .. } => Self::FloatParse,
111 FieldError::IntParse { .. } => Self::IntParse,
112 FieldError::InvalidCivilDate { .. } => Self::InvalidCivilDate,
113 FieldError::InvalidCivilTime { .. } => Self::InvalidCivilTime,
114 }
115 }
116}
117
118#[derive(Error, Debug, Clone, PartialEq)]
120pub enum Error {
121 #[error("invalid SGP4 input {field}: {kind}")]
124 InvalidInput {
125 field: &'static str,
127 kind: Sgp4InputErrorKind,
129 },
130 #[error("SGP4 returned non-finite {field}")]
132 NonFiniteOutput {
133 field: &'static str,
135 },
136 #[error("invalid TLE: {0}")]
138 InvalidTle(String),
139 #[error("SGP4 error code {code}")]
145 Sgp4 { code: i32 },
146}
147
148#[derive(Error, Debug, Clone, PartialEq)]
154pub enum DecayLatchedError {
155 #[error(
157 "SGP4 decay latch first failed at {first_failing_epoch:?}; requested {requested_epoch:?}"
158 )]
159 Decayed {
160 first_failing_epoch: MinutesSinceEpoch,
163 requested_epoch: MinutesSinceEpoch,
165 },
166 #[error(transparent)]
168 Propagation(#[from] Error),
169}
170
171const MAX_MINUTES_SINCE_EPOCH: f64 = 10_000_000.0;
172
173#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
177pub struct MinutesSinceEpoch(pub f64);
178
179#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
181pub struct Prediction {
182 pub position: [f64; 3],
184 pub velocity: [f64; 3],
186}
187
188#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
193pub struct JulianDate(pub f64, pub f64);
194
195#[derive(Debug, Clone, Copy, PartialEq, Default, serde::Serialize, serde::Deserialize)]
202pub struct DecayLatch {
203 first_failing_epoch: Option<MinutesSinceEpoch>,
204}
205
206impl DecayLatch {
207 pub const fn new() -> Self {
209 Self {
210 first_failing_epoch: None,
211 }
212 }
213
214 pub const fn first_failing_epoch(self) -> Option<MinutesSinceEpoch> {
216 self.first_failing_epoch
217 }
218
219 pub fn clear(&mut self) {
221 self.first_failing_epoch = None;
222 }
223
224 fn decayed_error(self, requested_epoch: MinutesSinceEpoch) -> Option<DecayLatchedError> {
225 let first_failing_epoch = self.first_failing_epoch?;
226 if requested_epoch.0 >= first_failing_epoch.0 {
227 Some(DecayLatchedError::Decayed {
228 first_failing_epoch,
229 requested_epoch,
230 })
231 } else {
232 None
233 }
234 }
235
236 fn record_decay(&mut self, epoch: MinutesSinceEpoch) -> MinutesSinceEpoch {
237 match self.first_failing_epoch {
238 Some(first) if first.0 <= epoch.0 => first,
239 _ => {
240 self.first_failing_epoch = Some(epoch);
241 epoch
242 }
243 }
244 }
245}
246
247pub(crate) fn sgp4_julian_date_from_calendar(
248 year: i32,
249 mon: i32,
250 day: i32,
251 hr: i32,
252 minute: i32,
253 sec: f64,
254) -> JulianDate {
255 let (jd, jdfrac) = vallado::jday_SGP4(year, mon, day, hr, minute, sec);
256 JulianDate(jd, jdfrac)
257}
258
259pub(crate) fn sgp4_julian_date_from_day_of_year(year: i32, days: f64) -> JulianDate {
260 let (mon, day, hr, minute, sec) = vallado::days2mdhms_SGP4(year, days);
261 let JulianDate(jd, jdfrac_raw) =
262 sgp4_julian_date_from_calendar(year, mon, day, hr, minute, sec);
263 let jdfrac = (jdfrac_raw * 100_000_000.0).round() / 100_000_000.0;
264 JulianDate(jd, jdfrac)
265}
266
267#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
272pub enum OpsMode {
273 #[default]
277 Improved,
278 Afspc,
282}
283
284impl OpsMode {
285 fn as_char(self) -> char {
286 match self {
287 OpsMode::Improved => 'i',
288 OpsMode::Afspc => 'a',
289 }
290 }
291}
292
293#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
304pub struct ElementSet {
305 pub epoch: JulianDate,
313 pub bstar: f64,
315 pub mean_motion_dot: f64,
317 pub mean_motion_double_dot: f64,
319 pub eccentricity: f64,
321 pub argument_of_perigee_deg: f64,
323 pub inclination_deg: f64,
325 pub mean_anomaly_deg: f64,
327 pub mean_motion_rev_per_day: f64,
329 pub right_ascension_deg: f64,
331 pub catalog_number: u32,
335}
336
337#[derive(Clone)]
346pub struct Satellite {
347 line1: String,
348 line2: String,
349 elements: ElementSet,
352 opsmode: OpsMode,
353 satrec: Box<vallado::ElsetRec>,
356}
357
358impl std::fmt::Debug for Satellite {
359 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
360 f.debug_struct("Satellite")
361 .field("line1", &self.line1)
362 .field("line2", &self.line2)
363 .field("elements", &self.elements)
364 .field("opsmode", &self.opsmode)
365 .finish_non_exhaustive()
366 }
367}
368
369impl Satellite {
370 pub fn from_tle(line1: &str, line2: &str) -> Result<Self, Error> {
377 Self::from_tle_with_opsmode(line1, line2, OpsMode::Improved)
378 }
379
380 pub fn from_tle_with_opsmode(
395 line1: &str,
396 line2: &str,
397 opsmode: OpsMode,
398 ) -> Result<Self, Error> {
399 let l1 = line1.trim();
400 let l2 = line2.trim();
401
402 let parsed = tle::parse(l1, l2).map_err(|e| Error::InvalidTle(e.to_string()))?;
403 let elements = parsed
404 .elements
405 .to_element_set()
406 .map_err(map_tle_bridge_error)?;
407 let satrec = init_satrec_from_elements(&elements, opsmode)?;
408
409 Ok(Satellite {
410 line1: l1.to_string(),
411 line2: l2.to_string(),
412 elements,
413 opsmode,
414 satrec: Box::new(satrec),
415 })
416 }
417
418 pub fn from_3line(block: &str) -> Result<Self, Error> {
426 Self::from_3line_with_opsmode(block, OpsMode::Improved)
427 }
428
429 pub fn from_3line_with_opsmode(block: &str, opsmode: OpsMode) -> Result<Self, Error> {
432 let mut l1 = None;
433 let mut l2 = None;
434 for line in block.lines() {
435 let line = line.trim();
436 if l1.is_none() && line.starts_with("1 ") {
437 l1 = Some(line.to_string());
438 } else if l2.is_none() && line.starts_with("2 ") {
439 l2 = Some(line.to_string());
440 }
441 }
442 let l1 = l1.ok_or_else(|| Error::InvalidTle("no line 1 in TLE block".into()))?;
443 let l2 = l2.ok_or_else(|| Error::InvalidTle("no line 2 in TLE block".into()))?;
444 Self::from_tle_with_opsmode(&l1, &l2, opsmode)
445 }
446
447 pub fn from_elements(elements: &ElementSet) -> Result<Self, Error> {
458 Self::from_elements_with_opsmode(elements, OpsMode::Improved)
459 }
460
461 pub fn from_elements_with_opsmode(
464 elements: &ElementSet,
465 opsmode: OpsMode,
466 ) -> Result<Self, Error> {
467 let satrec = init_satrec_from_elements(elements, opsmode)?;
468 Ok(Satellite {
469 line1: String::new(),
470 line2: String::new(),
471 elements: elements.clone(),
472 opsmode,
473 satrec: Box::new(satrec),
474 })
475 }
476
477 pub fn propagate(&self, t: MinutesSinceEpoch) -> Result<Prediction, Error> {
482 propagate_satrec((*self.satrec).clone(), t)
485 }
486
487 pub fn propagate_with_decay_latch(
496 &self,
497 t: MinutesSinceEpoch,
498 latch: &mut DecayLatch,
499 ) -> Result<Prediction, DecayLatchedError> {
500 validate_minutes_since_epoch(t)?;
501 if let Some(error) = latch.decayed_error(t) {
502 return Err(error);
503 }
504 match self.propagate(t) {
505 Ok(prediction) => Ok(prediction),
506 Err(error) if is_decay_like_sgp4_error(&error) => {
507 let first_failing_epoch = latch.record_decay(t);
508 Err(DecayLatchedError::Decayed {
509 first_failing_epoch,
510 requested_epoch: t,
511 })
512 }
513 Err(error) => Err(DecayLatchedError::Propagation(error)),
514 }
515 }
516
517 pub fn propagate_jd(&self, jd: JulianDate) -> Result<Prediction, Error> {
526 self.propagate(minutes_since_epoch_from_jd(&self.satrec, jd)?)
527 }
528
529 pub fn propagate_jd_with_decay_latch(
535 &self,
536 jd: JulianDate,
537 latch: &mut DecayLatch,
538 ) -> Result<Prediction, DecayLatchedError> {
539 let t = minutes_since_epoch_from_jd(&self.satrec, jd)?;
540 self.propagate_with_decay_latch(t, latch)
541 }
542
543 pub(crate) fn mean_motion_rad_per_min(&self) -> f64 {
544 self.satrec.no_kozai
545 }
546
547 pub(crate) fn eccentricity(&self) -> f64 {
548 self.satrec.ecco
549 }
550
551 pub fn line1(&self) -> &str {
554 &self.line1
555 }
556
557 pub fn line2(&self) -> &str {
560 &self.line2
561 }
562
563 pub fn epoch_jd(&self) -> JulianDate {
568 JulianDate(self.satrec.jdsatepoch, self.satrec.jdsatepochF)
569 }
570
571 fn has_source_tle(&self) -> bool {
572 !self.line1.is_empty() && !self.line2.is_empty()
573 }
574}
575
576pub fn propagate_elements(
584 elements: &ElementSet,
585 t: MinutesSinceEpoch,
586) -> Result<Prediction, Error> {
587 propagate_elements_with_opsmode(elements, t, OpsMode::Improved)
588}
589
590pub fn propagate_elements_with_opsmode(
592 elements: &ElementSet,
593 t: MinutesSinceEpoch,
594 opsmode: OpsMode,
595) -> Result<Prediction, Error> {
596 let satrec = init_satrec_from_elements(elements, opsmode)?;
597 propagate_satrec(satrec, t)
598}
599
600fn propagate_arc(
611 satellite: &Satellite,
612 times: &[MinutesSinceEpoch],
613) -> Result<Vec<Prediction>, Error> {
614 times.iter().map(|&t| satellite.propagate(t)).collect()
615}
616
617pub fn propagate_batch(
658 satellites: &[Satellite],
659 times: &[MinutesSinceEpoch],
660) -> Vec<Result<Vec<Prediction>, Error>> {
661 satellites
662 .iter()
663 .map(|satellite| propagate_arc(satellite, times))
664 .collect()
665}
666
667pub fn propagate_batch_parallel(
678 satellites: &[Satellite],
679 times: &[MinutesSinceEpoch],
680) -> Vec<Result<Vec<Prediction>, Error>> {
681 use rayon::prelude::*;
682 satellites
683 .par_iter()
684 .map(|satellite| propagate_arc(satellite, times))
685 .collect()
686}
687
688impl serde::Serialize for Satellite {
689 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
690 use serde::ser::SerializeStruct;
691 let mut st = s.serialize_struct("Satellite", 2)?;
692 if self.has_source_tle() {
693 st.serialize_field("line1", &self.line1)?;
694 st.serialize_field("line2", &self.line2)?;
695 } else {
696 st.serialize_field("elements", &self.elements)?;
697 st.serialize_field("opsmode", &self.opsmode)?;
698 }
699 st.end()
700 }
701}
702
703impl<'de> serde::Deserialize<'de> for Satellite {
704 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
705 #[derive(serde::Deserialize)]
706 struct Wire {
707 line1: Option<String>,
708 line2: Option<String>,
709 elements: Option<ElementSet>,
710 opsmode: Option<OpsMode>,
711 }
712 let w = Wire::deserialize(d)?;
713 let opsmode = w.opsmode.unwrap_or_default();
714 let has_tle_line = w
715 .line1
716 .as_deref()
717 .is_some_and(|line| !line.trim().is_empty())
718 || w.line2
719 .as_deref()
720 .is_some_and(|line| !line.trim().is_empty());
721 if let Some(elements) = w.elements {
722 if has_tle_line {
723 Err(serde::de::Error::custom(
724 "ambiguous Satellite wire format: use either TLE lines or elements",
725 ))
726 } else {
727 Satellite::from_elements_with_opsmode(&elements, opsmode)
728 .map_err(serde::de::Error::custom)
729 }
730 } else if let (Some(line1), Some(line2)) = (w.line1, w.line2) {
731 if line1.trim().is_empty() || line2.trim().is_empty() {
732 Err(serde::de::Error::custom(
733 "Satellite wire format requires non-empty line1/line2 or elements",
734 ))
735 } else {
736 Satellite::from_tle_with_opsmode(&line1, &line2, opsmode)
737 .map_err(serde::de::Error::custom)
738 }
739 } else {
740 Err(serde::de::Error::custom(
741 "Satellite wire format requires non-empty line1/line2 or elements",
742 ))
743 }
744 }
745}
746
747fn propagate_satrec(
750 mut satrec: vallado::ElsetRec,
751 t: MinutesSinceEpoch,
752) -> Result<Prediction, Error> {
753 validate_minutes_since_epoch(t)?;
754
755 let mut r = [0.0_f64; 3];
756 let mut v = [0.0_f64; 3];
757 let ok = vallado::sgp4(&mut satrec, t.0, &mut r, &mut v);
758 if !ok || satrec.error != 0 {
759 return Err(Error::Sgp4 { code: satrec.error });
760 }
761 validate_prediction(r, v)?;
762 Ok(Prediction {
763 position: r,
764 velocity: v,
765 })
766}
767
768fn validate_minutes_since_epoch(t: MinutesSinceEpoch) -> Result<(), Error> {
769 validate::finite(t.0, "minutes_since_epoch").map_err(map_input_error)?;
770 if t.0.abs() > MAX_MINUTES_SINCE_EPOCH {
771 return Err(invalid_domain("minutes_since_epoch"));
772 }
773 Ok(())
774}
775
776fn minutes_since_epoch_from_jd(
777 satrec: &vallado::ElsetRec,
778 jd: JulianDate,
779) -> Result<MinutesSinceEpoch, Error> {
780 validate::finite(jd.0, "julian_date.whole").map_err(map_input_error)?;
781 validate::finite_in_range_exclusive_upper(jd.1, 0.0, 1.0, "julian_date.fraction")
782 .map_err(map_input_error)?;
783 let tsince = (jd.0 - satrec.jdsatepoch) * 1440.0 + (jd.1 - satrec.jdsatepochF) * 1440.0;
784 let t = MinutesSinceEpoch(tsince);
785 validate_minutes_since_epoch(t)?;
786 Ok(t)
787}
788
789fn is_decay_like_sgp4_error(error: &Error) -> bool {
790 matches!(error, Error::Sgp4 { code: 5 | 6 })
791}
792
793fn validate_prediction(position: [f64; 3], velocity: [f64; 3]) -> Result<(), Error> {
794 validate::finite_vec3(position, "position_km").map_err(map_output_error)?;
795 validate::finite_vec3(velocity, "velocity_km_s").map_err(map_output_error)?;
796 Ok(())
797}
798
799fn init_satrec_from_elements(
804 elements: &ElementSet,
805 opsmode: OpsMode,
806) -> Result<vallado::ElsetRec, Error> {
807 validate_elements(elements)?;
808
809 let deg2rad = std::f64::consts::PI / 180.0;
810 let xpdotp = 1440.0 / (2.0 * std::f64::consts::PI);
811
812 let inclo = elements.inclination_deg * deg2rad;
813 let nodeo = elements.right_ascension_deg * deg2rad;
814 let argpo = elements.argument_of_perigee_deg * deg2rad;
815 let mo = elements.mean_anomaly_deg * deg2rad;
816 let no_kozai = elements.mean_motion_rev_per_day / xpdotp;
817 let ndot = elements.mean_motion_dot / (xpdotp * 1440.0);
820 let nddot = elements.mean_motion_double_dot / (xpdotp * 1440.0 * 1440.0);
821
822 let JulianDate(jd, jdfrac) = elements.epoch;
823 let epoch_sgp4 = jd + jdfrac - 2433281.5;
824
825 let satnum_str = format!("{:>5}", elements.catalog_number);
826
827 let mut satrec = vallado::ElsetRec {
828 jdsatepoch: jd,
829 jdsatepochF: jdfrac,
830 ..vallado::ElsetRec::default()
831 };
832
833 vallado::sgp4init(
834 vallado::GravConstType::Wgs72,
835 opsmode.as_char(),
836 &satnum_str,
837 epoch_sgp4,
838 elements.bstar,
839 ndot,
840 nddot,
841 elements.eccentricity,
842 argpo,
843 inclo,
844 mo,
845 no_kozai,
846 nodeo,
847 &mut satrec,
848 );
849
850 satrec.jdsatepoch = jd;
852 satrec.jdsatepochF = jdfrac;
853
854 Ok(satrec)
855}
856
857fn validate_elements(elements: &ElementSet) -> Result<(), Error> {
858 if elements.catalog_number > MAX_VALLADO_SATNUM {
859 return Err(invalid_domain("element.catalog_number"));
860 }
861 validate_epoch(elements.epoch)?;
862 validate::finite(elements.bstar, "element.bstar").map_err(map_input_error)?;
863 validate::finite(elements.mean_motion_dot, "element.mean_motion_dot")
864 .map_err(map_input_error)?;
865 validate::finite(
866 elements.mean_motion_double_dot,
867 "element.mean_motion_double_dot",
868 )
869 .map_err(map_input_error)?;
870 validate::finite_in_range_exclusive_upper(
871 elements.eccentricity,
872 0.0,
873 1.0,
874 "element.eccentricity",
875 )
876 .map_err(map_input_error)?;
877 validate::finite(
878 elements.argument_of_perigee_deg,
879 "element.argument_of_perigee_deg",
880 )
881 .map_err(map_input_error)?;
882 validate::finite(elements.inclination_deg, "element.inclination_deg")
883 .map_err(map_input_error)?;
884 validate::finite(elements.mean_anomaly_deg, "element.mean_anomaly_deg")
885 .map_err(map_input_error)?;
886 validate::finite_positive(
887 elements.mean_motion_rev_per_day,
888 "element.mean_motion_rev_per_day",
889 )
890 .map_err(map_input_error)?;
891 validate::finite(elements.right_ascension_deg, "element.right_ascension_deg")
892 .map_err(map_input_error)?;
893
894 Ok(())
895}
896
897fn validate_epoch(epoch: JulianDate) -> Result<(), Error> {
898 validate::finite(epoch.0, "element.epoch.whole").map_err(map_input_error)?;
899 validate::finite(epoch.1, "element.epoch.fraction").map_err(map_input_error)?;
900
901 let total = epoch.0 + epoch.1;
902 validate::finite(total, "element.epoch").map_err(map_input_error)?;
903 if !(0.0..=5_000_000.0).contains(&total) {
904 return Err(invalid_domain("element.epoch"));
905 }
906 Ok(())
907}
908
909fn map_input_error(error: FieldError) -> Error {
910 Error::InvalidInput {
911 field: error.field(),
912 kind: Sgp4InputErrorKind::from(&error),
913 }
914}
915
916fn invalid_domain(field: &'static str) -> Error {
917 Error::InvalidInput {
918 field,
919 kind: Sgp4InputErrorKind::OutOfRange,
920 }
921}
922
923fn map_tle_bridge_error(error: tle::TleError) -> Error {
924 match error {
925 tle::TleError::InvalidField { field, reason } => Error::InvalidInput {
926 field,
927 kind: match reason {
928 "not finite" => Sgp4InputErrorKind::NonFinite,
929 "not positive" => Sgp4InputErrorKind::NotPositive,
930 "negative" => Sgp4InputErrorKind::Negative,
931 "out of range" => Sgp4InputErrorKind::OutOfRange,
932 _ => Sgp4InputErrorKind::OutOfRange,
933 },
934 },
935 other => Error::InvalidTle(other.to_string()),
936 }
937}
938
939fn map_output_error(error: FieldError) -> Error {
940 Error::NonFiniteOutput {
941 field: error.field(),
942 }
943}
944
945#[derive(Debug)]
947pub struct NamedSatellite {
948 pub name: String,
952 pub satellite: Satellite,
954}
955
956#[derive(Debug)]
959pub struct TleFile {
960 pub satellites: Vec<NamedSatellite>,
962 pub skipped: usize,
968}
969
970pub fn parse_tle_file(text: &str) -> TleFile {
979 parse_tle_file_with_opsmode(text, OpsMode::Improved)
980}
981
982pub fn parse_tle_file_with_opsmode(text: &str, opsmode: OpsMode) -> TleFile {
984 let lines: Vec<&str> = text.lines().map(str::trim).collect();
985 let mut satellites = Vec::new();
986 let mut skipped = 0usize;
987 let mut pending_name = String::new();
988 let mut i = 0;
989 while i < lines.len() {
990 let line = lines[i];
991 if line.is_empty() {
992 i += 1;
993 continue;
994 }
995 if line.starts_with("1 ") {
996 let mut j = i + 1;
998 while j < lines.len() && lines[j].is_empty() {
999 j += 1;
1000 }
1001 if j < lines.len() && lines[j].starts_with("2 ") {
1002 if let Ok(satellite) = Satellite::from_tle_with_opsmode(line, lines[j], opsmode) {
1003 satellites.push(NamedSatellite {
1004 name: std::mem::take(&mut pending_name),
1005 satellite,
1006 });
1007 } else {
1008 skipped += 1;
1009 pending_name.clear();
1010 }
1011 i = j + 1;
1012 continue;
1013 }
1014 pending_name.clear();
1016 i += 1;
1017 continue;
1018 }
1019 if line.starts_with("2 ") {
1020 pending_name.clear();
1023 i += 1;
1024 continue;
1025 }
1026 pending_name = line.strip_prefix("0 ").unwrap_or(line).trim().to_string();
1028 i += 1;
1029 }
1030 TleFile {
1031 satellites,
1032 skipped,
1033 }
1034}
1035
1036#[cfg(test)]
1037mod tests {
1038 use super::{
1039 parse_tle_file, propagate_batch, propagate_batch_parallel, propagate_elements, DecayLatch,
1040 DecayLatchedError, ElementSet, Error, JulianDate, MinutesSinceEpoch, Satellite,
1041 Sgp4InputErrorKind, MAX_MINUTES_SINCE_EPOCH,
1042 };
1043
1044 #[test]
1050 fn non_ascii_tle_returns_invalid_tle_not_panic() {
1051 let line1 = "1 25544U 98067A 18184.80969102 .00001614 00000-0 31745-4 0 9993";
1052 let line2 = "2 25544 51.6414 295.8524 0003435 262.6267 204.2868 15.54005638121106";
1053 assert!(
1054 Satellite::from_tle(line1, line2).is_ok(),
1055 "clean ASCII TLE must still parse"
1056 );
1057
1058 let mut bad1 = String::from(&line1[..18]);
1061 bad1.push('\u{20ac}');
1062 bad1.push_str(&line1[19..]);
1063 assert!(
1064 !bad1.is_char_boundary(20),
1065 "corruption must straddle byte 20"
1066 );
1067
1068 let err = Satellite::from_tle(&bad1, line2).expect_err("non-ASCII TLE must not parse");
1069 assert!(
1070 matches!(err, Error::InvalidTle(_)),
1071 "expected a typed InvalidTle error, got: {err:?}"
1072 );
1073 }
1074
1075 const ISS_L1: &str = "1 25544U 98067A 18184.80969102 .00001614 00000-0 31745-4 0 9993";
1079 const ISS_L2: &str = "2 25544 51.6414 295.8524 0003435 262.6267 204.2868 15.54005638121106";
1080
1081 #[test]
1082 fn parse_tle_file_three_line_captures_names() {
1083 let text = format!("ISS (ZARYA)\n{ISS_L1}\n{ISS_L2}\nSECOND SAT\n{ISS_L1}\n{ISS_L2}\n");
1084 let f = parse_tle_file(&text);
1085 assert_eq!(f.satellites.len(), 2);
1086 assert_eq!(f.skipped, 0);
1087 assert_eq!(f.satellites[0].name, "ISS (ZARYA)");
1088 assert_eq!(f.satellites[1].name, "SECOND SAT");
1089 assert_eq!(f.satellites[0].satellite.line1(), ISS_L1);
1090 assert_eq!(f.satellites[0].satellite.line2(), ISS_L2);
1091 }
1092
1093 #[test]
1094 fn parse_tle_file_bare_two_line_has_empty_name() {
1095 let f = parse_tle_file(&format!("{ISS_L1}\n{ISS_L2}"));
1096 assert_eq!(f.satellites.len(), 1);
1097 assert_eq!(f.satellites[0].name, "");
1098 assert_eq!(f.satellites[0].satellite.line1(), ISS_L1);
1099 }
1100
1101 #[test]
1102 fn parse_tle_file_strips_celestrak_zero_name_marker() {
1103 let f = parse_tle_file(&format!("0 ISS (ZARYA)\n{ISS_L1}\n{ISS_L2}"));
1104 assert_eq!(f.satellites.len(), 1);
1105 assert_eq!(f.satellites[0].name, "ISS (ZARYA)");
1106 }
1107
1108 #[test]
1109 fn parse_tle_file_tolerates_crlf_blanks_and_whitespace() {
1110 let text = format!("\r\n ISS (ZARYA) \r\n{ISS_L1}\r\n\r\n{ISS_L2}\r\n\r\n");
1111 let f = parse_tle_file(&text);
1112 assert_eq!(f.satellites.len(), 1);
1113 assert_eq!(f.satellites[0].name, "ISS (ZARYA)");
1114 assert_eq!(f.satellites[0].satellite.line1(), ISS_L1);
1115 }
1116
1117 #[test]
1118 fn parse_tle_file_skips_malformed_record_and_counts_it() {
1119 let text = format!(
1122 "GOOD ONE\n{ISS_L1}\n{ISS_L2}\nBAD ONE\n1 not a real line\n2 not a real line\nGOOD TWO\n{ISS_L1}\n{ISS_L2}\n"
1123 );
1124 let f = parse_tle_file(&text);
1125 assert_eq!(
1126 f.satellites.len(),
1127 2,
1128 "the malformed record must be skipped"
1129 );
1130 assert_eq!(f.skipped, 1, "the skipped record must be counted");
1131 assert_eq!(f.satellites[0].name, "GOOD ONE");
1132 assert_eq!(f.satellites[1].name, "GOOD TWO");
1133 }
1134
1135 #[test]
1136 fn parse_tle_file_stray_line2_does_not_leak_name() {
1137 let text = format!("ORPHAN NAME\n2 stray line two\n{ISS_L1}\n{ISS_L2}\n");
1140 let f = parse_tle_file(&text);
1141 assert_eq!(f.satellites.len(), 1);
1142 assert_eq!(f.satellites[0].name, "", "stray name must not leak forward");
1143 }
1144
1145 fn iss_elements() -> ElementSet {
1146 crate::astro::tle::parse(ISS_L1, ISS_L2)
1147 .unwrap()
1148 .elements
1149 .to_element_set()
1150 .expect("valid TLE bridge")
1151 }
1152
1153 fn assert_invalid_input<T>(
1154 result: Result<T, Error>,
1155 field: &'static str,
1156 kind: Sgp4InputErrorKind,
1157 ) {
1158 match result {
1159 Err(Error::InvalidInput {
1160 field: actual_field,
1161 kind: actual_kind,
1162 }) => {
1163 assert_eq!(actual_field, field);
1164 assert_eq!(actual_kind, kind);
1165 }
1166 Err(err) => panic!("expected InvalidInput({field}, {kind}), got {err:?}"),
1167 Ok(_) => panic!("expected InvalidInput({field}, {kind}), got Ok"),
1168 }
1169 }
1170
1171 fn assert_same(a: &Satellite, b: &Satellite) {
1174 let (ea, eb) = (a.epoch_jd(), b.epoch_jd());
1175 assert_eq!(
1176 (ea.0.to_bits(), ea.1.to_bits()),
1177 (eb.0.to_bits(), eb.1.to_bits()),
1178 "epoch JD differs"
1179 );
1180 for &t in &[0.0, 100.0, 1440.0] {
1181 let pa = a.propagate(MinutesSinceEpoch(t)).unwrap();
1182 let pb = b.propagate(MinutesSinceEpoch(t)).unwrap();
1183 for axis in 0..3 {
1184 assert_eq!(
1185 pa.position[axis].to_bits(),
1186 pb.position[axis].to_bits(),
1187 "position[{axis}] differs at t={t}"
1188 );
1189 assert_eq!(
1190 pa.velocity[axis].to_bits(),
1191 pb.velocity[axis].to_bits(),
1192 "velocity[{axis}] differs at t={t}"
1193 );
1194 }
1195 }
1196 }
1197
1198 #[test]
1199 fn serde_round_trips_tle_satellites() {
1200 let sat = Satellite::from_tle(ISS_L1, ISS_L2).unwrap();
1201 let encoded = serde_json::to_string(&sat).unwrap();
1202 assert!(encoded.contains("\"line1\""));
1203 assert!(encoded.contains("\"line2\""));
1204 assert!(!encoded.contains("\"elements\""));
1205
1206 let decoded: Satellite = serde_json::from_str(&encoded).unwrap();
1207 assert_eq!(decoded.line1(), ISS_L1);
1208 assert_eq!(decoded.line2(), ISS_L2);
1209 assert_same(&sat, &decoded);
1210 }
1211
1212 #[test]
1213 fn serde_round_trips_element_built_satellites() {
1214 let elements = iss_elements();
1215 let sat = Satellite::from_elements(&elements).unwrap();
1216 let encoded = serde_json::to_string(&sat).unwrap();
1217 assert!(encoded.contains("\"elements\""));
1218 assert!(encoded.contains("\"opsmode\""));
1219 assert!(!encoded.contains("\"line1\""));
1220 assert!(!encoded.contains("\"line2\""));
1221
1222 let decoded: Satellite = serde_json::from_str(&encoded).unwrap();
1223 assert!(decoded.line1().is_empty());
1224 assert!(decoded.line2().is_empty());
1225 assert_same(&sat, &decoded);
1226 }
1227
1228 #[test]
1229 fn from_elements_rejects_non_finite_fields_before_sgp4init() {
1230 let mut elements = iss_elements();
1231 elements.bstar = f64::NAN;
1232
1233 assert_invalid_input(
1234 Satellite::from_elements(&elements),
1235 "element.bstar",
1236 Sgp4InputErrorKind::NonFinite,
1237 );
1238 }
1239
1240 #[test]
1241 fn from_elements_rejects_sgp4_domain_before_sgp4init() {
1242 let mut elements = iss_elements();
1243 elements.mean_motion_rev_per_day = 0.0;
1244 assert_invalid_input(
1245 Satellite::from_elements(&elements),
1246 "element.mean_motion_rev_per_day",
1247 Sgp4InputErrorKind::NotPositive,
1248 );
1249
1250 let mut elements = iss_elements();
1251 elements.eccentricity = -0.1;
1252 assert_invalid_input(
1253 Satellite::from_elements(&elements),
1254 "element.eccentricity",
1255 Sgp4InputErrorKind::OutOfRange,
1256 );
1257
1258 let mut elements = iss_elements();
1259 elements.eccentricity = 1.0;
1260 assert_invalid_input(
1261 Satellite::from_elements(&elements),
1262 "element.eccentricity",
1263 Sgp4InputErrorKind::OutOfRange,
1264 );
1265
1266 let mut elements = iss_elements();
1267 elements.catalog_number = 100_000;
1268 assert_invalid_input(
1269 Satellite::from_elements(&elements),
1270 "element.catalog_number",
1271 Sgp4InputErrorKind::OutOfRange,
1272 );
1273 }
1274
1275 #[test]
1276 fn from_elements_rejects_invalid_epoch() {
1277 let mut elements = iss_elements();
1278 elements.epoch = JulianDate(f64::NAN, 0.0);
1279 assert_invalid_input(
1280 Satellite::from_elements(&elements),
1281 "element.epoch.whole",
1282 Sgp4InputErrorKind::NonFinite,
1283 );
1284
1285 let mut elements = iss_elements();
1286 elements.epoch = JulianDate(9_000_000.0, 0.0);
1287 assert_invalid_input(
1288 Satellite::from_elements(&elements),
1289 "element.epoch",
1290 Sgp4InputErrorKind::OutOfRange,
1291 );
1292 }
1293
1294 #[test]
1295 fn from_elements_accepts_full_julian_epoch() {
1296 let mut elements = iss_elements();
1297 elements.epoch = super::sgp4_julian_date_from_calendar(2057, 1, 1, 0, 0, 0.0);
1298 Satellite::from_elements(&elements).expect("full 2057 epoch is valid");
1299 }
1300
1301 #[test]
1302 fn from_tle_accepts_epoch_after_parser_conversion_to_full_jd() {
1303 let mut line1 = ISS_L1.to_string();
1304 line1.replace_range(18..32, "19366.00000000");
1305
1306 Satellite::from_tle(&line1, ISS_L2).expect("TLE epoch is converted to full JD");
1307 }
1308
1309 #[test]
1310 fn propagation_rejects_non_finite_time_inputs() {
1311 let sat = Satellite::from_tle(ISS_L1, ISS_L2).unwrap();
1312 assert_invalid_input(
1313 sat.propagate(MinutesSinceEpoch(f64::NAN)),
1314 "minutes_since_epoch",
1315 Sgp4InputErrorKind::NonFinite,
1316 );
1317 assert_invalid_input(
1318 sat.propagate_jd(JulianDate(f64::INFINITY, 0.0)),
1319 "julian_date.whole",
1320 Sgp4InputErrorKind::NonFinite,
1321 );
1322
1323 let elements = iss_elements();
1324 assert_invalid_input(
1325 propagate_elements(&elements, MinutesSinceEpoch(f64::INFINITY)),
1326 "minutes_since_epoch",
1327 Sgp4InputErrorKind::NonFinite,
1328 );
1329 }
1330
1331 #[test]
1332 fn propagation_rejects_out_of_domain_time_inputs() {
1333 let sat = Satellite::from_tle(ISS_L1, ISS_L2).unwrap();
1334 assert_invalid_input(
1335 sat.propagate(MinutesSinceEpoch(MAX_MINUTES_SINCE_EPOCH.next_up())),
1336 "minutes_since_epoch",
1337 Sgp4InputErrorKind::OutOfRange,
1338 );
1339 assert_invalid_input(
1340 sat.propagate_jd(JulianDate(2_458_304.0, 1.0)),
1341 "julian_date.fraction",
1342 Sgp4InputErrorKind::OutOfRange,
1343 );
1344 }
1345
1346 #[test]
1347 fn lenient_trailing_whitespace_and_content_past_col_69() {
1348 let clean = Satellite::from_tle(ISS_L1, ISS_L2).unwrap();
1349
1350 let pad = Satellite::from_tle(&format!("{ISS_L1} "), &format!("{ISS_L2}\t ")).unwrap();
1352 assert_same(&clean, &pad);
1353
1354 let extra =
1357 Satellite::from_tle(&format!("{ISS_L1} EXTRA-JUNK"), &format!("{ISS_L2} 999999"))
1358 .unwrap();
1359 assert_same(&clean, &extra);
1360 }
1361
1362 #[test]
1363 fn lenient_leading_dot_and_assumed_decimal_fields() {
1364 let sat = Satellite::from_tle(ISS_L1, ISS_L2).unwrap();
1369 let p = sat.propagate(MinutesSinceEpoch(0.0)).unwrap();
1370 let r = (p.position[0].powi(2) + p.position[1].powi(2) + p.position[2].powi(2)).sqrt();
1371 assert!(
1372 (6500.0..=7200.0).contains(&r),
1373 "ISS radius {r} km outside LEO"
1374 );
1375 }
1376
1377 #[test]
1378 fn lenient_missing_optional_bookkeeping_fields() {
1379 let l1: String = ISS_L1
1384 .char_indices()
1385 .map(|(i, c)| {
1386 if i == 62 || (64..=67).contains(&i) {
1387 ' '
1388 } else {
1389 c
1390 }
1391 })
1392 .collect();
1393 let l2: String = ISS_L2
1394 .char_indices()
1395 .map(|(i, c)| if (63..=67).contains(&i) { ' ' } else { c })
1396 .collect();
1397 let clean = Satellite::from_tle(ISS_L1, ISS_L2).unwrap();
1400 let blanked = Satellite::from_tle(&l1, &l2).unwrap();
1401 assert_same(&clean, &blanked);
1402 }
1403
1404 #[test]
1405 fn three_line_form_strips_name_line() {
1406 let clean = Satellite::from_tle(ISS_L1, ISS_L2).unwrap();
1407
1408 let block = format!("ISS (ZARYA)\n{ISS_L1}\n{ISS_L2}\n");
1409 let three = Satellite::from_3line(&block).unwrap();
1410 assert_same(&clean, &three);
1411
1412 let two = Satellite::from_3line(&format!("{ISS_L1}\n{ISS_L2}")).unwrap();
1414 assert_same(&clean, &two);
1415 }
1416
1417 #[test]
1418 fn three_line_form_rejects_block_without_element_lines() {
1419 assert!(Satellite::from_3line("just a name\nand some text").is_err());
1420 assert!(Satellite::from_3line("").is_err());
1421 }
1422
1423 const CSS_L1: &str = "1 48274U 21035A 24001.50000000 .00015000 00000-0 18000-3 0 9990";
1428 const CSS_L2: &str = "2 48274 41.4700 100.0000 0006000 90.0000 270.0000 15.61000000 10000";
1429
1430 const DECAY_L1: &str = "1 28872U 05037B 05333.02012661 .25992681 00000-0 24476-3 0 1534";
1435 const DECAY_L2: &str = "2 28872 96.4736 157.9986 0303955 244.0492 110.6523 16.46015938 10708";
1436
1437 fn batch_times() -> Vec<MinutesSinceEpoch> {
1438 (0..33)
1441 .map(|i| MinutesSinceEpoch(i as f64 * 45.0))
1442 .collect()
1443 }
1444
1445 #[test]
1449 fn batch_is_bit_identical_to_per_satellite_propagate() {
1450 let satellites = [
1451 Satellite::from_tle(ISS_L1, ISS_L2).unwrap(),
1452 Satellite::from_tle(CSS_L1, CSS_L2).unwrap(),
1453 ];
1454 let times = batch_times();
1455
1456 let batch = propagate_batch(&satellites, ×);
1457 assert_eq!(batch.len(), satellites.len());
1458
1459 for (sat_idx, satellite) in satellites.iter().enumerate() {
1460 let arc = batch[sat_idx]
1461 .as_ref()
1462 .expect("clean satellite arc must be Ok");
1463 assert_eq!(arc.len(), times.len());
1464 for (epoch_idx, &t) in times.iter().enumerate() {
1465 let reference = satellite.propagate(t).expect("per-sat propagate ok");
1466 for axis in 0..3 {
1467 assert_eq!(
1468 arc[epoch_idx].position[axis].to_bits(),
1469 reference.position[axis].to_bits(),
1470 "position bits sat {sat_idx} epoch {epoch_idx} axis {axis}"
1471 );
1472 assert_eq!(
1473 arc[epoch_idx].velocity[axis].to_bits(),
1474 reference.velocity[axis].to_bits(),
1475 "velocity bits sat {sat_idx} epoch {epoch_idx} axis {axis}"
1476 );
1477 }
1478 }
1479 }
1480 }
1481
1482 #[test]
1485 fn parallel_batch_is_bit_identical_to_serial() {
1486 let satellites = [
1487 Satellite::from_tle(ISS_L1, ISS_L2).unwrap(),
1488 Satellite::from_tle(CSS_L1, CSS_L2).unwrap(),
1489 Satellite::from_tle(ISS_L1, ISS_L2).unwrap(),
1490 ];
1491 let times = batch_times();
1492
1493 let serial = propagate_batch(&satellites, ×);
1494 let parallel = propagate_batch_parallel(&satellites, ×);
1495 assert_eq!(serial.len(), parallel.len());
1496
1497 for sat_idx in 0..satellites.len() {
1498 let s = serial[sat_idx].as_ref().expect("serial arc ok");
1499 let p = parallel[sat_idx].as_ref().expect("parallel arc ok");
1500 assert_eq!(s.len(), p.len());
1501 for epoch_idx in 0..times.len() {
1502 for axis in 0..3 {
1503 assert_eq!(
1504 s[epoch_idx].position[axis].to_bits(),
1505 p[epoch_idx].position[axis].to_bits(),
1506 "position bits sat {sat_idx} epoch {epoch_idx} axis {axis}"
1507 );
1508 assert_eq!(
1509 s[epoch_idx].velocity[axis].to_bits(),
1510 p[epoch_idx].velocity[axis].to_bits(),
1511 "velocity bits sat {sat_idx} epoch {epoch_idx} axis {axis}"
1512 );
1513 }
1514 }
1515 }
1516 }
1517
1518 #[test]
1523 fn failing_satellite_yields_per_item_error_without_poisoning_batch() {
1524 let clean_a = Satellite::from_tle(ISS_L1, ISS_L2).unwrap();
1525 let decay = Satellite::from_tle(DECAY_L1, DECAY_L2).unwrap();
1526 let clean_b = Satellite::from_tle(CSS_L1, CSS_L2).unwrap();
1527
1528 let times: Vec<MinutesSinceEpoch> = (0..=24)
1531 .map(|i| MinutesSinceEpoch(i as f64 * 120.0))
1532 .collect();
1533 assert!(
1534 times.iter().any(|&t| decay.propagate(t).is_err()),
1535 "decaying fixture must error on the grid"
1536 );
1537 assert!(
1538 times.iter().all(|&t| clean_a.propagate(t).is_ok()),
1539 "clean fixture must span the grid"
1540 );
1541
1542 let satellites = [clean_a, decay, clean_b];
1543 for batch in [
1544 propagate_batch(&satellites, ×),
1545 propagate_batch_parallel(&satellites, ×),
1546 ] {
1547 assert_eq!(batch.len(), 3);
1548 assert!(batch[0].is_ok(), "clean satellite 0 must survive");
1550 assert_eq!(batch[0].as_ref().unwrap().len(), times.len());
1551 assert!(batch[2].is_ok(), "clean satellite 2 must survive");
1552 assert_eq!(batch[2].as_ref().unwrap().len(), times.len());
1553 assert!(
1555 matches!(batch[1], Err(Error::Sgp4 { .. })),
1556 "decaying satellite must yield an SGP4 error, got {:?}",
1557 batch[1]
1558 );
1559 }
1560 }
1561
1562 #[test]
1568 fn decay_latch_reports_later_epochs_decayed_without_changing_raw_propagation() {
1569 let satellite = Satellite::from_tle(DECAY_L1, DECAY_L2).unwrap();
1570 let t_decay = MinutesSinceEpoch(1440.0);
1571 let t_later = MinutesSinceEpoch(1450.0);
1572
1573 assert_eq!(
1574 satellite.propagate(t_decay).unwrap_err(),
1575 Error::Sgp4 { code: 6 },
1576 "Vallado fixture must report decayed at 1440 min"
1577 );
1578
1579 let raw_later = satellite
1580 .propagate(t_later)
1581 .expect("raw Vallado propagation resumes at 1450 min");
1582
1583 let mut latch = DecayLatch::new();
1584 let first = satellite
1585 .propagate_with_decay_latch(t_decay, &mut latch)
1586 .expect_err("latched propagation must report the first decay epoch");
1587 assert_eq!(
1588 first,
1589 DecayLatchedError::Decayed {
1590 first_failing_epoch: t_decay,
1591 requested_epoch: t_decay,
1592 }
1593 );
1594 assert_eq!(latch.first_failing_epoch(), Some(t_decay));
1595
1596 let later = satellite
1597 .propagate_with_decay_latch(t_later, &mut latch)
1598 .expect_err("later latched propagation must not emit a raw state");
1599 assert_eq!(
1600 later,
1601 DecayLatchedError::Decayed {
1602 first_failing_epoch: t_decay,
1603 requested_epoch: t_later,
1604 }
1605 );
1606
1607 let raw_after_latch = satellite
1608 .propagate(t_later)
1609 .expect("raw propagation must remain stateless after latch use");
1610 assert_eq!(
1611 raw_after_latch.position.map(f64::to_bits),
1612 raw_later.position.map(f64::to_bits),
1613 "raw position bits must not change after latch use"
1614 );
1615 assert_eq!(
1616 raw_after_latch.velocity.map(f64::to_bits),
1617 raw_later.velocity.map(f64::to_bits),
1618 "raw velocity bits must not change after latch use"
1619 );
1620
1621 let earlier = satellite
1622 .propagate_with_decay_latch(MinutesSinceEpoch(120.0), &mut latch)
1623 .expect("earlier epochs remain available through the latch");
1624 let raw_earlier = satellite
1625 .propagate(MinutesSinceEpoch(120.0))
1626 .expect("raw earlier epoch ok");
1627 assert_eq!(
1628 earlier.position.map(f64::to_bits),
1629 raw_earlier.position.map(f64::to_bits)
1630 );
1631 assert_eq!(
1632 earlier.velocity.map(f64::to_bits),
1633 raw_earlier.velocity.map(f64::to_bits)
1634 );
1635 }
1636
1637 #[test]
1638 fn batch_handles_empty_inputs() {
1639 let sat = Satellite::from_tle(ISS_L1, ISS_L2).unwrap();
1640 let times = batch_times();
1641
1642 assert!(propagate_batch(&[], ×).is_empty());
1644 assert!(propagate_batch_parallel(&[], ×).is_empty());
1645
1646 let no_times = propagate_batch(std::slice::from_ref(&sat), &[]);
1648 assert_eq!(no_times.len(), 1);
1649 assert!(no_times[0].as_ref().unwrap().is_empty());
1650 }
1651
1652 #[test]
1653 fn rejects_genuine_corruption() {
1654 assert!(Satellite::from_tle("", "").is_err());
1656 assert!(Satellite::from_tle("hello world", "goodbye world").is_err());
1658 assert!(Satellite::from_tle(ISS_L2, ISS_L1).is_err());
1660 let l2_wrong = "2 25545 51.6414 295.8524 0003435 262.6267 204.2868 15.54005638121106";
1662 assert!(matches!(
1663 Satellite::from_tle(ISS_L1, l2_wrong),
1664 Err(Error::InvalidTle(_))
1665 ));
1666 }
1667}