1#[allow(
28 dead_code,
29 unused_variables,
30 unused_assignments,
31 unused_mut,
32 non_snake_case,
33 non_camel_case_types,
34 clippy::approx_constant,
35 clippy::excessive_precision,
36 clippy::too_many_arguments,
37 clippy::needless_return,
38 clippy::assign_op_pattern,
39 clippy::manual_range_contains,
40 clippy::collapsible_if,
41 clippy::collapsible_else_if,
42 clippy::float_cmp,
43 clippy::needless_late_init,
44 clippy::field_reassign_with_default
45)]
46mod vallado;
47
48use crate::astro::tle;
49use crate::validate::{self, FieldError};
50use thiserror::Error;
51
52const MAX_VALLADO_SATNUM: u32 = 99_999;
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum Sgp4InputErrorKind {
59 NonFinite,
61 NotPositive,
63 Negative,
65 OutOfRange,
67 Missing,
69 FloatParse,
71 IntParse,
73 InvalidCivilDate,
75 InvalidCivilTime,
77}
78
79impl core::fmt::Display for Sgp4InputErrorKind {
80 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
81 let label = match self {
82 Self::NonFinite => "not finite",
83 Self::NotPositive => "not positive",
84 Self::Negative => "negative",
85 Self::OutOfRange => "out of range",
86 Self::Missing => "missing",
87 Self::FloatParse => "invalid float",
88 Self::IntParse => "invalid integer",
89 Self::InvalidCivilDate => "invalid civil date",
90 Self::InvalidCivilTime => "invalid civil time",
91 };
92 f.write_str(label)
93 }
94}
95
96impl From<&FieldError> for Sgp4InputErrorKind {
97 fn from(error: &FieldError) -> Self {
98 match error {
99 FieldError::Missing { .. } => Self::Missing,
100 FieldError::NonFinite { .. } => Self::NonFinite,
101 FieldError::NotPositive { .. } => Self::NotPositive,
102 FieldError::Negative { .. } => Self::Negative,
103 FieldError::OutOfRange { .. } => Self::OutOfRange,
104 FieldError::FloatParse { .. } => Self::FloatParse,
105 FieldError::IntParse { .. } => Self::IntParse,
106 FieldError::InvalidCivilDate { .. } => Self::InvalidCivilDate,
107 FieldError::InvalidCivilTime { .. } => Self::InvalidCivilTime,
108 }
109 }
110}
111
112#[derive(Error, Debug, Clone, PartialEq)]
114pub enum Error {
115 #[error("invalid SGP4 input {field}: {kind}")]
118 InvalidInput {
119 field: &'static str,
121 kind: Sgp4InputErrorKind,
123 },
124 #[error("SGP4 returned non-finite {field}")]
126 NonFiniteOutput {
127 field: &'static str,
129 },
130 #[error("invalid TLE: {0}")]
132 InvalidTle(String),
133 #[error("SGP4 error code {code}")]
139 Sgp4 { code: i32 },
140}
141
142const MAX_MINUTES_SINCE_EPOCH: f64 = 10_000_000.0;
143
144#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
148pub struct MinutesSinceEpoch(pub f64);
149
150#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
152pub struct Prediction {
153 pub position: [f64; 3],
155 pub velocity: [f64; 3],
157}
158
159#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
164pub struct JulianDate(pub f64, pub f64);
165
166pub(crate) fn sgp4_julian_date_from_calendar(
167 year: i32,
168 mon: i32,
169 day: i32,
170 hr: i32,
171 minute: i32,
172 sec: f64,
173) -> JulianDate {
174 let (jd, jdfrac) = vallado::jday_SGP4(year, mon, day, hr, minute, sec);
175 JulianDate(jd, jdfrac)
176}
177
178pub(crate) fn sgp4_julian_date_from_day_of_year(year: i32, days: f64) -> JulianDate {
179 let (mon, day, hr, minute, sec) = vallado::days2mdhms_SGP4(year, days);
180 let JulianDate(jd, jdfrac_raw) =
181 sgp4_julian_date_from_calendar(year, mon, day, hr, minute, sec);
182 let jdfrac = (jdfrac_raw * 100_000_000.0).round() / 100_000_000.0;
183 JulianDate(jd, jdfrac)
184}
185
186#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
191pub enum OpsMode {
192 #[default]
196 Improved,
197 Afspc,
201}
202
203impl OpsMode {
204 fn as_char(self) -> char {
205 match self {
206 OpsMode::Improved => 'i',
207 OpsMode::Afspc => 'a',
208 }
209 }
210}
211
212#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
223pub struct ElementSet {
224 pub epoch: JulianDate,
232 pub bstar: f64,
234 pub mean_motion_dot: f64,
236 pub mean_motion_double_dot: f64,
238 pub eccentricity: f64,
240 pub argument_of_perigee_deg: f64,
242 pub inclination_deg: f64,
244 pub mean_anomaly_deg: f64,
246 pub mean_motion_rev_per_day: f64,
248 pub right_ascension_deg: f64,
250 pub catalog_number: u32,
254}
255
256#[derive(Clone)]
265pub struct Satellite {
266 line1: String,
267 line2: String,
268 elements: ElementSet,
271 opsmode: OpsMode,
272 satrec: Box<vallado::ElsetRec>,
275}
276
277impl std::fmt::Debug for Satellite {
278 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
279 f.debug_struct("Satellite")
280 .field("line1", &self.line1)
281 .field("line2", &self.line2)
282 .field("elements", &self.elements)
283 .field("opsmode", &self.opsmode)
284 .finish_non_exhaustive()
285 }
286}
287
288impl Satellite {
289 pub fn from_tle(line1: &str, line2: &str) -> Result<Self, Error> {
296 Self::from_tle_with_opsmode(line1, line2, OpsMode::Improved)
297 }
298
299 pub fn from_tle_with_opsmode(
314 line1: &str,
315 line2: &str,
316 opsmode: OpsMode,
317 ) -> Result<Self, Error> {
318 let l1 = line1.trim();
319 let l2 = line2.trim();
320
321 let parsed = tle::parse(l1, l2).map_err(|e| Error::InvalidTle(e.to_string()))?;
322 let elements = parsed
323 .elements
324 .to_element_set()
325 .map_err(map_tle_bridge_error)?;
326 let satrec = init_satrec_from_elements(&elements, opsmode)?;
327
328 Ok(Satellite {
329 line1: l1.to_string(),
330 line2: l2.to_string(),
331 elements,
332 opsmode,
333 satrec: Box::new(satrec),
334 })
335 }
336
337 pub fn from_3line(block: &str) -> Result<Self, Error> {
345 Self::from_3line_with_opsmode(block, OpsMode::Improved)
346 }
347
348 pub fn from_3line_with_opsmode(block: &str, opsmode: OpsMode) -> Result<Self, Error> {
351 let mut l1 = None;
352 let mut l2 = None;
353 for line in block.lines() {
354 let line = line.trim();
355 if l1.is_none() && line.starts_with("1 ") {
356 l1 = Some(line.to_string());
357 } else if l2.is_none() && line.starts_with("2 ") {
358 l2 = Some(line.to_string());
359 }
360 }
361 let l1 = l1.ok_or_else(|| Error::InvalidTle("no line 1 in TLE block".into()))?;
362 let l2 = l2.ok_or_else(|| Error::InvalidTle("no line 2 in TLE block".into()))?;
363 Self::from_tle_with_opsmode(&l1, &l2, opsmode)
364 }
365
366 pub fn from_elements(elements: &ElementSet) -> Result<Self, Error> {
377 Self::from_elements_with_opsmode(elements, OpsMode::Improved)
378 }
379
380 pub fn from_elements_with_opsmode(
383 elements: &ElementSet,
384 opsmode: OpsMode,
385 ) -> Result<Self, Error> {
386 let satrec = init_satrec_from_elements(elements, opsmode)?;
387 Ok(Satellite {
388 line1: String::new(),
389 line2: String::new(),
390 elements: elements.clone(),
391 opsmode,
392 satrec: Box::new(satrec),
393 })
394 }
395
396 pub fn propagate(&self, t: MinutesSinceEpoch) -> Result<Prediction, Error> {
401 propagate_satrec((*self.satrec).clone(), t)
404 }
405
406 pub fn propagate_jd(&self, jd: JulianDate) -> Result<Prediction, Error> {
415 validate::finite(jd.0, "julian_date.whole").map_err(map_input_error)?;
416 validate::finite_in_range_exclusive_upper(jd.1, 0.0, 1.0, "julian_date.fraction")
417 .map_err(map_input_error)?;
418 let tsince =
419 (jd.0 - self.satrec.jdsatepoch) * 1440.0 + (jd.1 - self.satrec.jdsatepochF) * 1440.0;
420 validate::finite(tsince, "minutes_since_epoch").map_err(map_input_error)?;
421 self.propagate(MinutesSinceEpoch(tsince))
422 }
423
424 pub(crate) fn mean_motion_rad_per_min(&self) -> f64 {
425 self.satrec.no_kozai
426 }
427
428 pub(crate) fn eccentricity(&self) -> f64 {
429 self.satrec.ecco
430 }
431
432 pub fn line1(&self) -> &str {
435 &self.line1
436 }
437
438 pub fn line2(&self) -> &str {
441 &self.line2
442 }
443
444 pub fn epoch_jd(&self) -> JulianDate {
449 JulianDate(self.satrec.jdsatepoch, self.satrec.jdsatepochF)
450 }
451
452 fn has_source_tle(&self) -> bool {
453 !self.line1.is_empty() && !self.line2.is_empty()
454 }
455}
456
457pub fn propagate_elements(
465 elements: &ElementSet,
466 t: MinutesSinceEpoch,
467) -> Result<Prediction, Error> {
468 propagate_elements_with_opsmode(elements, t, OpsMode::Improved)
469}
470
471pub fn propagate_elements_with_opsmode(
473 elements: &ElementSet,
474 t: MinutesSinceEpoch,
475 opsmode: OpsMode,
476) -> Result<Prediction, Error> {
477 let satrec = init_satrec_from_elements(elements, opsmode)?;
478 propagate_satrec(satrec, t)
479}
480
481fn propagate_arc(
492 satellite: &Satellite,
493 times: &[MinutesSinceEpoch],
494) -> Result<Vec<Prediction>, Error> {
495 times.iter().map(|&t| satellite.propagate(t)).collect()
496}
497
498pub fn propagate_batch(
539 satellites: &[Satellite],
540 times: &[MinutesSinceEpoch],
541) -> Vec<Result<Vec<Prediction>, Error>> {
542 satellites
543 .iter()
544 .map(|satellite| propagate_arc(satellite, times))
545 .collect()
546}
547
548pub fn propagate_batch_parallel(
559 satellites: &[Satellite],
560 times: &[MinutesSinceEpoch],
561) -> Vec<Result<Vec<Prediction>, Error>> {
562 use rayon::prelude::*;
563 satellites
564 .par_iter()
565 .map(|satellite| propagate_arc(satellite, times))
566 .collect()
567}
568
569impl serde::Serialize for Satellite {
570 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
571 use serde::ser::SerializeStruct;
572 let mut st = s.serialize_struct("Satellite", 2)?;
573 if self.has_source_tle() {
574 st.serialize_field("line1", &self.line1)?;
575 st.serialize_field("line2", &self.line2)?;
576 } else {
577 st.serialize_field("elements", &self.elements)?;
578 st.serialize_field("opsmode", &self.opsmode)?;
579 }
580 st.end()
581 }
582}
583
584impl<'de> serde::Deserialize<'de> for Satellite {
585 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
586 #[derive(serde::Deserialize)]
587 struct Wire {
588 line1: Option<String>,
589 line2: Option<String>,
590 elements: Option<ElementSet>,
591 opsmode: Option<OpsMode>,
592 }
593 let w = Wire::deserialize(d)?;
594 let opsmode = w.opsmode.unwrap_or_default();
595 let has_tle_line = w
596 .line1
597 .as_deref()
598 .is_some_and(|line| !line.trim().is_empty())
599 || w.line2
600 .as_deref()
601 .is_some_and(|line| !line.trim().is_empty());
602 if let Some(elements) = w.elements {
603 if has_tle_line {
604 Err(serde::de::Error::custom(
605 "ambiguous Satellite wire format: use either TLE lines or elements",
606 ))
607 } else {
608 Satellite::from_elements_with_opsmode(&elements, opsmode)
609 .map_err(serde::de::Error::custom)
610 }
611 } else if let (Some(line1), Some(line2)) = (w.line1, w.line2) {
612 if line1.trim().is_empty() || line2.trim().is_empty() {
613 Err(serde::de::Error::custom(
614 "Satellite wire format requires non-empty line1/line2 or elements",
615 ))
616 } else {
617 Satellite::from_tle_with_opsmode(&line1, &line2, opsmode)
618 .map_err(serde::de::Error::custom)
619 }
620 } else {
621 Err(serde::de::Error::custom(
622 "Satellite wire format requires non-empty line1/line2 or elements",
623 ))
624 }
625 }
626}
627
628fn propagate_satrec(
631 mut satrec: vallado::ElsetRec,
632 t: MinutesSinceEpoch,
633) -> Result<Prediction, Error> {
634 validate::finite(t.0, "minutes_since_epoch").map_err(map_input_error)?;
635 if t.0.abs() > MAX_MINUTES_SINCE_EPOCH {
636 return Err(invalid_domain("minutes_since_epoch"));
637 }
638
639 let mut r = [0.0_f64; 3];
640 let mut v = [0.0_f64; 3];
641 let ok = vallado::sgp4(&mut satrec, t.0, &mut r, &mut v);
642 if !ok || satrec.error != 0 {
643 return Err(Error::Sgp4 { code: satrec.error });
644 }
645 validate_prediction(r, v)?;
646 Ok(Prediction {
647 position: r,
648 velocity: v,
649 })
650}
651
652fn validate_prediction(position: [f64; 3], velocity: [f64; 3]) -> Result<(), Error> {
653 validate::finite_vec3(position, "position_km").map_err(map_output_error)?;
654 validate::finite_vec3(velocity, "velocity_km_s").map_err(map_output_error)?;
655 Ok(())
656}
657
658fn init_satrec_from_elements(
663 elements: &ElementSet,
664 opsmode: OpsMode,
665) -> Result<vallado::ElsetRec, Error> {
666 validate_elements(elements)?;
667
668 let deg2rad = std::f64::consts::PI / 180.0;
669 let xpdotp = 1440.0 / (2.0 * std::f64::consts::PI);
670
671 let inclo = elements.inclination_deg * deg2rad;
672 let nodeo = elements.right_ascension_deg * deg2rad;
673 let argpo = elements.argument_of_perigee_deg * deg2rad;
674 let mo = elements.mean_anomaly_deg * deg2rad;
675 let no_kozai = elements.mean_motion_rev_per_day / xpdotp;
676 let ndot = elements.mean_motion_dot / (xpdotp * 1440.0);
679 let nddot = elements.mean_motion_double_dot / (xpdotp * 1440.0 * 1440.0);
680
681 let JulianDate(jd, jdfrac) = elements.epoch;
682 let epoch_sgp4 = jd + jdfrac - 2433281.5;
683
684 let satnum_str = format!("{:>5}", elements.catalog_number);
685
686 let mut satrec = vallado::ElsetRec {
687 jdsatepoch: jd,
688 jdsatepochF: jdfrac,
689 ..vallado::ElsetRec::default()
690 };
691
692 vallado::sgp4init(
693 vallado::GravConstType::Wgs72,
694 opsmode.as_char(),
695 &satnum_str,
696 epoch_sgp4,
697 elements.bstar,
698 ndot,
699 nddot,
700 elements.eccentricity,
701 argpo,
702 inclo,
703 mo,
704 no_kozai,
705 nodeo,
706 &mut satrec,
707 );
708
709 satrec.jdsatepoch = jd;
711 satrec.jdsatepochF = jdfrac;
712
713 Ok(satrec)
714}
715
716fn validate_elements(elements: &ElementSet) -> Result<(), Error> {
717 if elements.catalog_number > MAX_VALLADO_SATNUM {
718 return Err(invalid_domain("element.catalog_number"));
719 }
720 validate_epoch(elements.epoch)?;
721 validate::finite(elements.bstar, "element.bstar").map_err(map_input_error)?;
722 validate::finite(elements.mean_motion_dot, "element.mean_motion_dot")
723 .map_err(map_input_error)?;
724 validate::finite(
725 elements.mean_motion_double_dot,
726 "element.mean_motion_double_dot",
727 )
728 .map_err(map_input_error)?;
729 validate::finite_in_range_exclusive_upper(
730 elements.eccentricity,
731 0.0,
732 1.0,
733 "element.eccentricity",
734 )
735 .map_err(map_input_error)?;
736 validate::finite(
737 elements.argument_of_perigee_deg,
738 "element.argument_of_perigee_deg",
739 )
740 .map_err(map_input_error)?;
741 validate::finite(elements.inclination_deg, "element.inclination_deg")
742 .map_err(map_input_error)?;
743 validate::finite(elements.mean_anomaly_deg, "element.mean_anomaly_deg")
744 .map_err(map_input_error)?;
745 validate::finite_positive(
746 elements.mean_motion_rev_per_day,
747 "element.mean_motion_rev_per_day",
748 )
749 .map_err(map_input_error)?;
750 validate::finite(elements.right_ascension_deg, "element.right_ascension_deg")
751 .map_err(map_input_error)?;
752
753 Ok(())
754}
755
756fn validate_epoch(epoch: JulianDate) -> Result<(), Error> {
757 validate::finite(epoch.0, "element.epoch.whole").map_err(map_input_error)?;
758 validate::finite(epoch.1, "element.epoch.fraction").map_err(map_input_error)?;
759
760 let total = epoch.0 + epoch.1;
761 validate::finite(total, "element.epoch").map_err(map_input_error)?;
762 if !(0.0..=5_000_000.0).contains(&total) {
763 return Err(invalid_domain("element.epoch"));
764 }
765 Ok(())
766}
767
768fn map_input_error(error: FieldError) -> Error {
769 Error::InvalidInput {
770 field: error.field(),
771 kind: Sgp4InputErrorKind::from(&error),
772 }
773}
774
775fn invalid_domain(field: &'static str) -> Error {
776 Error::InvalidInput {
777 field,
778 kind: Sgp4InputErrorKind::OutOfRange,
779 }
780}
781
782fn map_tle_bridge_error(error: tle::TleError) -> Error {
783 match error {
784 tle::TleError::InvalidField { field, reason } => Error::InvalidInput {
785 field,
786 kind: match reason {
787 "not finite" => Sgp4InputErrorKind::NonFinite,
788 "not positive" => Sgp4InputErrorKind::NotPositive,
789 "negative" => Sgp4InputErrorKind::Negative,
790 "out of range" => Sgp4InputErrorKind::OutOfRange,
791 _ => Sgp4InputErrorKind::OutOfRange,
792 },
793 },
794 other => Error::InvalidTle(other.to_string()),
795 }
796}
797
798fn map_output_error(error: FieldError) -> Error {
799 Error::NonFiniteOutput {
800 field: error.field(),
801 }
802}
803
804#[derive(Debug)]
806pub struct NamedSatellite {
807 pub name: String,
811 pub satellite: Satellite,
813}
814
815#[derive(Debug)]
818pub struct TleFile {
819 pub satellites: Vec<NamedSatellite>,
821 pub skipped: usize,
827}
828
829pub fn parse_tle_file(text: &str) -> TleFile {
838 parse_tle_file_with_opsmode(text, OpsMode::Improved)
839}
840
841pub fn parse_tle_file_with_opsmode(text: &str, opsmode: OpsMode) -> TleFile {
843 let lines: Vec<&str> = text.lines().map(str::trim).collect();
844 let mut satellites = Vec::new();
845 let mut skipped = 0usize;
846 let mut pending_name = String::new();
847 let mut i = 0;
848 while i < lines.len() {
849 let line = lines[i];
850 if line.is_empty() {
851 i += 1;
852 continue;
853 }
854 if line.starts_with("1 ") {
855 let mut j = i + 1;
857 while j < lines.len() && lines[j].is_empty() {
858 j += 1;
859 }
860 if j < lines.len() && lines[j].starts_with("2 ") {
861 if let Ok(satellite) = Satellite::from_tle_with_opsmode(line, lines[j], opsmode) {
862 satellites.push(NamedSatellite {
863 name: std::mem::take(&mut pending_name),
864 satellite,
865 });
866 } else {
867 skipped += 1;
868 pending_name.clear();
869 }
870 i = j + 1;
871 continue;
872 }
873 pending_name.clear();
875 i += 1;
876 continue;
877 }
878 if line.starts_with("2 ") {
879 pending_name.clear();
882 i += 1;
883 continue;
884 }
885 pending_name = line.strip_prefix("0 ").unwrap_or(line).trim().to_string();
887 i += 1;
888 }
889 TleFile {
890 satellites,
891 skipped,
892 }
893}
894
895#[cfg(test)]
896mod tests {
897 use super::{
898 parse_tle_file, propagate_batch, propagate_batch_parallel, propagate_elements, ElementSet,
899 Error, JulianDate, MinutesSinceEpoch, Satellite, Sgp4InputErrorKind,
900 MAX_MINUTES_SINCE_EPOCH,
901 };
902
903 #[test]
909 fn non_ascii_tle_returns_invalid_tle_not_panic() {
910 let line1 = "1 25544U 98067A 18184.80969102 .00001614 00000-0 31745-4 0 9993";
911 let line2 = "2 25544 51.6414 295.8524 0003435 262.6267 204.2868 15.54005638121106";
912 assert!(
913 Satellite::from_tle(line1, line2).is_ok(),
914 "clean ASCII TLE must still parse"
915 );
916
917 let mut bad1 = String::from(&line1[..18]);
920 bad1.push('\u{20ac}');
921 bad1.push_str(&line1[19..]);
922 assert!(
923 !bad1.is_char_boundary(20),
924 "corruption must straddle byte 20"
925 );
926
927 let err = Satellite::from_tle(&bad1, line2).expect_err("non-ASCII TLE must not parse");
928 assert!(
929 matches!(err, Error::InvalidTle(_)),
930 "expected a typed InvalidTle error, got: {err:?}"
931 );
932 }
933
934 const ISS_L1: &str = "1 25544U 98067A 18184.80969102 .00001614 00000-0 31745-4 0 9993";
938 const ISS_L2: &str = "2 25544 51.6414 295.8524 0003435 262.6267 204.2868 15.54005638121106";
939
940 #[test]
941 fn parse_tle_file_three_line_captures_names() {
942 let text = format!("ISS (ZARYA)\n{ISS_L1}\n{ISS_L2}\nSECOND SAT\n{ISS_L1}\n{ISS_L2}\n");
943 let f = parse_tle_file(&text);
944 assert_eq!(f.satellites.len(), 2);
945 assert_eq!(f.skipped, 0);
946 assert_eq!(f.satellites[0].name, "ISS (ZARYA)");
947 assert_eq!(f.satellites[1].name, "SECOND SAT");
948 assert_eq!(f.satellites[0].satellite.line1(), ISS_L1);
949 assert_eq!(f.satellites[0].satellite.line2(), ISS_L2);
950 }
951
952 #[test]
953 fn parse_tle_file_bare_two_line_has_empty_name() {
954 let f = parse_tle_file(&format!("{ISS_L1}\n{ISS_L2}"));
955 assert_eq!(f.satellites.len(), 1);
956 assert_eq!(f.satellites[0].name, "");
957 assert_eq!(f.satellites[0].satellite.line1(), ISS_L1);
958 }
959
960 #[test]
961 fn parse_tle_file_strips_celestrak_zero_name_marker() {
962 let f = parse_tle_file(&format!("0 ISS (ZARYA)\n{ISS_L1}\n{ISS_L2}"));
963 assert_eq!(f.satellites.len(), 1);
964 assert_eq!(f.satellites[0].name, "ISS (ZARYA)");
965 }
966
967 #[test]
968 fn parse_tle_file_tolerates_crlf_blanks_and_whitespace() {
969 let text = format!("\r\n ISS (ZARYA) \r\n{ISS_L1}\r\n\r\n{ISS_L2}\r\n\r\n");
970 let f = parse_tle_file(&text);
971 assert_eq!(f.satellites.len(), 1);
972 assert_eq!(f.satellites[0].name, "ISS (ZARYA)");
973 assert_eq!(f.satellites[0].satellite.line1(), ISS_L1);
974 }
975
976 #[test]
977 fn parse_tle_file_skips_malformed_record_and_counts_it() {
978 let text = format!(
981 "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"
982 );
983 let f = parse_tle_file(&text);
984 assert_eq!(
985 f.satellites.len(),
986 2,
987 "the malformed record must be skipped"
988 );
989 assert_eq!(f.skipped, 1, "the skipped record must be counted");
990 assert_eq!(f.satellites[0].name, "GOOD ONE");
991 assert_eq!(f.satellites[1].name, "GOOD TWO");
992 }
993
994 #[test]
995 fn parse_tle_file_stray_line2_does_not_leak_name() {
996 let text = format!("ORPHAN NAME\n2 stray line two\n{ISS_L1}\n{ISS_L2}\n");
999 let f = parse_tle_file(&text);
1000 assert_eq!(f.satellites.len(), 1);
1001 assert_eq!(f.satellites[0].name, "", "stray name must not leak forward");
1002 }
1003
1004 fn iss_elements() -> ElementSet {
1005 crate::astro::tle::parse(ISS_L1, ISS_L2)
1006 .unwrap()
1007 .elements
1008 .to_element_set()
1009 .expect("valid TLE bridge")
1010 }
1011
1012 fn assert_invalid_input<T>(
1013 result: Result<T, Error>,
1014 field: &'static str,
1015 kind: Sgp4InputErrorKind,
1016 ) {
1017 match result {
1018 Err(Error::InvalidInput {
1019 field: actual_field,
1020 kind: actual_kind,
1021 }) => {
1022 assert_eq!(actual_field, field);
1023 assert_eq!(actual_kind, kind);
1024 }
1025 Err(err) => panic!("expected InvalidInput({field}, {kind}), got {err:?}"),
1026 Ok(_) => panic!("expected InvalidInput({field}, {kind}), got Ok"),
1027 }
1028 }
1029
1030 fn assert_same(a: &Satellite, b: &Satellite) {
1033 let (ea, eb) = (a.epoch_jd(), b.epoch_jd());
1034 assert_eq!(
1035 (ea.0.to_bits(), ea.1.to_bits()),
1036 (eb.0.to_bits(), eb.1.to_bits()),
1037 "epoch JD differs"
1038 );
1039 for &t in &[0.0, 100.0, 1440.0] {
1040 let pa = a.propagate(MinutesSinceEpoch(t)).unwrap();
1041 let pb = b.propagate(MinutesSinceEpoch(t)).unwrap();
1042 for axis in 0..3 {
1043 assert_eq!(
1044 pa.position[axis].to_bits(),
1045 pb.position[axis].to_bits(),
1046 "position[{axis}] differs at t={t}"
1047 );
1048 assert_eq!(
1049 pa.velocity[axis].to_bits(),
1050 pb.velocity[axis].to_bits(),
1051 "velocity[{axis}] differs at t={t}"
1052 );
1053 }
1054 }
1055 }
1056
1057 #[test]
1058 fn serde_round_trips_tle_satellites() {
1059 let sat = Satellite::from_tle(ISS_L1, ISS_L2).unwrap();
1060 let encoded = serde_json::to_string(&sat).unwrap();
1061 assert!(encoded.contains("\"line1\""));
1062 assert!(encoded.contains("\"line2\""));
1063 assert!(!encoded.contains("\"elements\""));
1064
1065 let decoded: Satellite = serde_json::from_str(&encoded).unwrap();
1066 assert_eq!(decoded.line1(), ISS_L1);
1067 assert_eq!(decoded.line2(), ISS_L2);
1068 assert_same(&sat, &decoded);
1069 }
1070
1071 #[test]
1072 fn serde_round_trips_element_built_satellites() {
1073 let elements = iss_elements();
1074 let sat = Satellite::from_elements(&elements).unwrap();
1075 let encoded = serde_json::to_string(&sat).unwrap();
1076 assert!(encoded.contains("\"elements\""));
1077 assert!(encoded.contains("\"opsmode\""));
1078 assert!(!encoded.contains("\"line1\""));
1079 assert!(!encoded.contains("\"line2\""));
1080
1081 let decoded: Satellite = serde_json::from_str(&encoded).unwrap();
1082 assert!(decoded.line1().is_empty());
1083 assert!(decoded.line2().is_empty());
1084 assert_same(&sat, &decoded);
1085 }
1086
1087 #[test]
1088 fn from_elements_rejects_non_finite_fields_before_sgp4init() {
1089 let mut elements = iss_elements();
1090 elements.bstar = f64::NAN;
1091
1092 assert_invalid_input(
1093 Satellite::from_elements(&elements),
1094 "element.bstar",
1095 Sgp4InputErrorKind::NonFinite,
1096 );
1097 }
1098
1099 #[test]
1100 fn from_elements_rejects_sgp4_domain_before_sgp4init() {
1101 let mut elements = iss_elements();
1102 elements.mean_motion_rev_per_day = 0.0;
1103 assert_invalid_input(
1104 Satellite::from_elements(&elements),
1105 "element.mean_motion_rev_per_day",
1106 Sgp4InputErrorKind::NotPositive,
1107 );
1108
1109 let mut elements = iss_elements();
1110 elements.eccentricity = -0.1;
1111 assert_invalid_input(
1112 Satellite::from_elements(&elements),
1113 "element.eccentricity",
1114 Sgp4InputErrorKind::OutOfRange,
1115 );
1116
1117 let mut elements = iss_elements();
1118 elements.eccentricity = 1.0;
1119 assert_invalid_input(
1120 Satellite::from_elements(&elements),
1121 "element.eccentricity",
1122 Sgp4InputErrorKind::OutOfRange,
1123 );
1124
1125 let mut elements = iss_elements();
1126 elements.catalog_number = 100_000;
1127 assert_invalid_input(
1128 Satellite::from_elements(&elements),
1129 "element.catalog_number",
1130 Sgp4InputErrorKind::OutOfRange,
1131 );
1132 }
1133
1134 #[test]
1135 fn from_elements_rejects_invalid_epoch() {
1136 let mut elements = iss_elements();
1137 elements.epoch = JulianDate(f64::NAN, 0.0);
1138 assert_invalid_input(
1139 Satellite::from_elements(&elements),
1140 "element.epoch.whole",
1141 Sgp4InputErrorKind::NonFinite,
1142 );
1143
1144 let mut elements = iss_elements();
1145 elements.epoch = JulianDate(9_000_000.0, 0.0);
1146 assert_invalid_input(
1147 Satellite::from_elements(&elements),
1148 "element.epoch",
1149 Sgp4InputErrorKind::OutOfRange,
1150 );
1151 }
1152
1153 #[test]
1154 fn from_elements_accepts_full_julian_epoch() {
1155 let mut elements = iss_elements();
1156 elements.epoch = super::sgp4_julian_date_from_calendar(2057, 1, 1, 0, 0, 0.0);
1157 Satellite::from_elements(&elements).expect("full 2057 epoch is valid");
1158 }
1159
1160 #[test]
1161 fn from_tle_accepts_epoch_after_parser_conversion_to_full_jd() {
1162 let mut line1 = ISS_L1.to_string();
1163 line1.replace_range(18..32, "19366.00000000");
1164
1165 Satellite::from_tle(&line1, ISS_L2).expect("TLE epoch is converted to full JD");
1166 }
1167
1168 #[test]
1169 fn propagation_rejects_non_finite_time_inputs() {
1170 let sat = Satellite::from_tle(ISS_L1, ISS_L2).unwrap();
1171 assert_invalid_input(
1172 sat.propagate(MinutesSinceEpoch(f64::NAN)),
1173 "minutes_since_epoch",
1174 Sgp4InputErrorKind::NonFinite,
1175 );
1176 assert_invalid_input(
1177 sat.propagate_jd(JulianDate(f64::INFINITY, 0.0)),
1178 "julian_date.whole",
1179 Sgp4InputErrorKind::NonFinite,
1180 );
1181
1182 let elements = iss_elements();
1183 assert_invalid_input(
1184 propagate_elements(&elements, MinutesSinceEpoch(f64::INFINITY)),
1185 "minutes_since_epoch",
1186 Sgp4InputErrorKind::NonFinite,
1187 );
1188 }
1189
1190 #[test]
1191 fn propagation_rejects_out_of_domain_time_inputs() {
1192 let sat = Satellite::from_tle(ISS_L1, ISS_L2).unwrap();
1193 assert_invalid_input(
1194 sat.propagate(MinutesSinceEpoch(MAX_MINUTES_SINCE_EPOCH.next_up())),
1195 "minutes_since_epoch",
1196 Sgp4InputErrorKind::OutOfRange,
1197 );
1198 assert_invalid_input(
1199 sat.propagate_jd(JulianDate(2_458_304.0, 1.0)),
1200 "julian_date.fraction",
1201 Sgp4InputErrorKind::OutOfRange,
1202 );
1203 }
1204
1205 #[test]
1206 fn lenient_trailing_whitespace_and_content_past_col_69() {
1207 let clean = Satellite::from_tle(ISS_L1, ISS_L2).unwrap();
1208
1209 let pad = Satellite::from_tle(&format!("{ISS_L1} "), &format!("{ISS_L2}\t ")).unwrap();
1211 assert_same(&clean, &pad);
1212
1213 let extra =
1216 Satellite::from_tle(&format!("{ISS_L1} EXTRA-JUNK"), &format!("{ISS_L2} 999999"))
1217 .unwrap();
1218 assert_same(&clean, &extra);
1219 }
1220
1221 #[test]
1222 fn lenient_leading_dot_and_assumed_decimal_fields() {
1223 let sat = Satellite::from_tle(ISS_L1, ISS_L2).unwrap();
1228 let p = sat.propagate(MinutesSinceEpoch(0.0)).unwrap();
1229 let r = (p.position[0].powi(2) + p.position[1].powi(2) + p.position[2].powi(2)).sqrt();
1230 assert!(
1231 (6500.0..=7200.0).contains(&r),
1232 "ISS radius {r} km outside LEO"
1233 );
1234 }
1235
1236 #[test]
1237 fn lenient_missing_optional_bookkeeping_fields() {
1238 let l1: String = ISS_L1
1243 .char_indices()
1244 .map(|(i, c)| {
1245 if i == 62 || (64..=67).contains(&i) {
1246 ' '
1247 } else {
1248 c
1249 }
1250 })
1251 .collect();
1252 let l2: String = ISS_L2
1253 .char_indices()
1254 .map(|(i, c)| if (63..=67).contains(&i) { ' ' } else { c })
1255 .collect();
1256 let clean = Satellite::from_tle(ISS_L1, ISS_L2).unwrap();
1259 let blanked = Satellite::from_tle(&l1, &l2).unwrap();
1260 assert_same(&clean, &blanked);
1261 }
1262
1263 #[test]
1264 fn three_line_form_strips_name_line() {
1265 let clean = Satellite::from_tle(ISS_L1, ISS_L2).unwrap();
1266
1267 let block = format!("ISS (ZARYA)\n{ISS_L1}\n{ISS_L2}\n");
1268 let three = Satellite::from_3line(&block).unwrap();
1269 assert_same(&clean, &three);
1270
1271 let two = Satellite::from_3line(&format!("{ISS_L1}\n{ISS_L2}")).unwrap();
1273 assert_same(&clean, &two);
1274 }
1275
1276 #[test]
1277 fn three_line_form_rejects_block_without_element_lines() {
1278 assert!(Satellite::from_3line("just a name\nand some text").is_err());
1279 assert!(Satellite::from_3line("").is_err());
1280 }
1281
1282 const CSS_L1: &str = "1 48274U 21035A 24001.50000000 .00015000 00000-0 18000-3 0 9990";
1287 const CSS_L2: &str = "2 48274 41.4700 100.0000 0006000 90.0000 270.0000 15.61000000 10000";
1288
1289 const DECAY_L1: &str = "1 28872U 05037B 05333.02012661 .25992681 00000-0 24476-3 0 1534";
1294 const DECAY_L2: &str = "2 28872 96.4736 157.9986 0303955 244.0492 110.6523 16.46015938 10708";
1295
1296 fn batch_times() -> Vec<MinutesSinceEpoch> {
1297 (0..33)
1300 .map(|i| MinutesSinceEpoch(i as f64 * 45.0))
1301 .collect()
1302 }
1303
1304 #[test]
1308 fn batch_is_bit_identical_to_per_satellite_propagate() {
1309 let satellites = [
1310 Satellite::from_tle(ISS_L1, ISS_L2).unwrap(),
1311 Satellite::from_tle(CSS_L1, CSS_L2).unwrap(),
1312 ];
1313 let times = batch_times();
1314
1315 let batch = propagate_batch(&satellites, ×);
1316 assert_eq!(batch.len(), satellites.len());
1317
1318 for (sat_idx, satellite) in satellites.iter().enumerate() {
1319 let arc = batch[sat_idx]
1320 .as_ref()
1321 .expect("clean satellite arc must be Ok");
1322 assert_eq!(arc.len(), times.len());
1323 for (epoch_idx, &t) in times.iter().enumerate() {
1324 let reference = satellite.propagate(t).expect("per-sat propagate ok");
1325 for axis in 0..3 {
1326 assert_eq!(
1327 arc[epoch_idx].position[axis].to_bits(),
1328 reference.position[axis].to_bits(),
1329 "position bits sat {sat_idx} epoch {epoch_idx} axis {axis}"
1330 );
1331 assert_eq!(
1332 arc[epoch_idx].velocity[axis].to_bits(),
1333 reference.velocity[axis].to_bits(),
1334 "velocity bits sat {sat_idx} epoch {epoch_idx} axis {axis}"
1335 );
1336 }
1337 }
1338 }
1339 }
1340
1341 #[test]
1344 fn parallel_batch_is_bit_identical_to_serial() {
1345 let satellites = [
1346 Satellite::from_tle(ISS_L1, ISS_L2).unwrap(),
1347 Satellite::from_tle(CSS_L1, CSS_L2).unwrap(),
1348 Satellite::from_tle(ISS_L1, ISS_L2).unwrap(),
1349 ];
1350 let times = batch_times();
1351
1352 let serial = propagate_batch(&satellites, ×);
1353 let parallel = propagate_batch_parallel(&satellites, ×);
1354 assert_eq!(serial.len(), parallel.len());
1355
1356 for sat_idx in 0..satellites.len() {
1357 let s = serial[sat_idx].as_ref().expect("serial arc ok");
1358 let p = parallel[sat_idx].as_ref().expect("parallel arc ok");
1359 assert_eq!(s.len(), p.len());
1360 for epoch_idx in 0..times.len() {
1361 for axis in 0..3 {
1362 assert_eq!(
1363 s[epoch_idx].position[axis].to_bits(),
1364 p[epoch_idx].position[axis].to_bits(),
1365 "position bits sat {sat_idx} epoch {epoch_idx} axis {axis}"
1366 );
1367 assert_eq!(
1368 s[epoch_idx].velocity[axis].to_bits(),
1369 p[epoch_idx].velocity[axis].to_bits(),
1370 "velocity bits sat {sat_idx} epoch {epoch_idx} axis {axis}"
1371 );
1372 }
1373 }
1374 }
1375 }
1376
1377 #[test]
1382 fn failing_satellite_yields_per_item_error_without_poisoning_batch() {
1383 let clean_a = Satellite::from_tle(ISS_L1, ISS_L2).unwrap();
1384 let decay = Satellite::from_tle(DECAY_L1, DECAY_L2).unwrap();
1385 let clean_b = Satellite::from_tle(CSS_L1, CSS_L2).unwrap();
1386
1387 let times: Vec<MinutesSinceEpoch> = (0..=24)
1390 .map(|i| MinutesSinceEpoch(i as f64 * 120.0))
1391 .collect();
1392 assert!(
1393 times.iter().any(|&t| decay.propagate(t).is_err()),
1394 "decaying fixture must error on the grid"
1395 );
1396 assert!(
1397 times.iter().all(|&t| clean_a.propagate(t).is_ok()),
1398 "clean fixture must span the grid"
1399 );
1400
1401 let satellites = [clean_a, decay, clean_b];
1402 for batch in [
1403 propagate_batch(&satellites, ×),
1404 propagate_batch_parallel(&satellites, ×),
1405 ] {
1406 assert_eq!(batch.len(), 3);
1407 assert!(batch[0].is_ok(), "clean satellite 0 must survive");
1409 assert_eq!(batch[0].as_ref().unwrap().len(), times.len());
1410 assert!(batch[2].is_ok(), "clean satellite 2 must survive");
1411 assert_eq!(batch[2].as_ref().unwrap().len(), times.len());
1412 assert!(
1414 matches!(batch[1], Err(Error::Sgp4 { .. })),
1415 "decaying satellite must yield an SGP4 error, got {:?}",
1416 batch[1]
1417 );
1418 }
1419 }
1420
1421 #[test]
1422 fn batch_handles_empty_inputs() {
1423 let sat = Satellite::from_tle(ISS_L1, ISS_L2).unwrap();
1424 let times = batch_times();
1425
1426 assert!(propagate_batch(&[], ×).is_empty());
1428 assert!(propagate_batch_parallel(&[], ×).is_empty());
1429
1430 let no_times = propagate_batch(std::slice::from_ref(&sat), &[]);
1432 assert_eq!(no_times.len(), 1);
1433 assert!(no_times[0].as_ref().unwrap().is_empty());
1434 }
1435
1436 #[test]
1437 fn rejects_genuine_corruption() {
1438 assert!(Satellite::from_tle("", "").is_err());
1440 assert!(Satellite::from_tle("hello world", "goodbye world").is_err());
1442 assert!(Satellite::from_tle(ISS_L2, ISS_L1).is_err());
1444 let l2_wrong = "2 25545 51.6414 295.8524 0003435 262.6267 204.2868 15.54005638121106";
1446 assert!(matches!(
1447 Satellite::from_tle(ISS_L1, l2_wrong),
1448 Err(Error::InvalidTle(_))
1449 ));
1450 }
1451}