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
148const MAX_MINUTES_SINCE_EPOCH: f64 = 10_000_000.0;
149
150#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
154pub struct MinutesSinceEpoch(pub f64);
155
156#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
158pub struct Prediction {
159 pub position: [f64; 3],
161 pub velocity: [f64; 3],
163}
164
165#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
170pub struct JulianDate(pub f64, pub f64);
171
172pub(crate) fn sgp4_julian_date_from_calendar(
173 year: i32,
174 mon: i32,
175 day: i32,
176 hr: i32,
177 minute: i32,
178 sec: f64,
179) -> JulianDate {
180 let (jd, jdfrac) = vallado::jday_SGP4(year, mon, day, hr, minute, sec);
181 JulianDate(jd, jdfrac)
182}
183
184pub(crate) fn sgp4_julian_date_from_day_of_year(year: i32, days: f64) -> JulianDate {
185 let (mon, day, hr, minute, sec) = vallado::days2mdhms_SGP4(year, days);
186 let JulianDate(jd, jdfrac_raw) =
187 sgp4_julian_date_from_calendar(year, mon, day, hr, minute, sec);
188 let jdfrac = (jdfrac_raw * 100_000_000.0).round() / 100_000_000.0;
189 JulianDate(jd, jdfrac)
190}
191
192#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
197pub enum OpsMode {
198 #[default]
202 Improved,
203 Afspc,
207}
208
209impl OpsMode {
210 fn as_char(self) -> char {
211 match self {
212 OpsMode::Improved => 'i',
213 OpsMode::Afspc => 'a',
214 }
215 }
216}
217
218#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
229pub struct ElementSet {
230 pub epoch: JulianDate,
238 pub bstar: f64,
240 pub mean_motion_dot: f64,
242 pub mean_motion_double_dot: f64,
244 pub eccentricity: f64,
246 pub argument_of_perigee_deg: f64,
248 pub inclination_deg: f64,
250 pub mean_anomaly_deg: f64,
252 pub mean_motion_rev_per_day: f64,
254 pub right_ascension_deg: f64,
256 pub catalog_number: u32,
260}
261
262#[derive(Clone)]
271pub struct Satellite {
272 line1: String,
273 line2: String,
274 elements: ElementSet,
277 opsmode: OpsMode,
278 satrec: Box<vallado::ElsetRec>,
281}
282
283impl std::fmt::Debug for Satellite {
284 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
285 f.debug_struct("Satellite")
286 .field("line1", &self.line1)
287 .field("line2", &self.line2)
288 .field("elements", &self.elements)
289 .field("opsmode", &self.opsmode)
290 .finish_non_exhaustive()
291 }
292}
293
294impl Satellite {
295 pub fn from_tle(line1: &str, line2: &str) -> Result<Self, Error> {
302 Self::from_tle_with_opsmode(line1, line2, OpsMode::Improved)
303 }
304
305 pub fn from_tle_with_opsmode(
320 line1: &str,
321 line2: &str,
322 opsmode: OpsMode,
323 ) -> Result<Self, Error> {
324 let l1 = line1.trim();
325 let l2 = line2.trim();
326
327 let parsed = tle::parse(l1, l2).map_err(|e| Error::InvalidTle(e.to_string()))?;
328 let elements = parsed
329 .elements
330 .to_element_set()
331 .map_err(map_tle_bridge_error)?;
332 let satrec = init_satrec_from_elements(&elements, opsmode)?;
333
334 Ok(Satellite {
335 line1: l1.to_string(),
336 line2: l2.to_string(),
337 elements,
338 opsmode,
339 satrec: Box::new(satrec),
340 })
341 }
342
343 pub fn from_3line(block: &str) -> Result<Self, Error> {
351 Self::from_3line_with_opsmode(block, OpsMode::Improved)
352 }
353
354 pub fn from_3line_with_opsmode(block: &str, opsmode: OpsMode) -> Result<Self, Error> {
357 let mut l1 = None;
358 let mut l2 = None;
359 for line in block.lines() {
360 let line = line.trim();
361 if l1.is_none() && line.starts_with("1 ") {
362 l1 = Some(line.to_string());
363 } else if l2.is_none() && line.starts_with("2 ") {
364 l2 = Some(line.to_string());
365 }
366 }
367 let l1 = l1.ok_or_else(|| Error::InvalidTle("no line 1 in TLE block".into()))?;
368 let l2 = l2.ok_or_else(|| Error::InvalidTle("no line 2 in TLE block".into()))?;
369 Self::from_tle_with_opsmode(&l1, &l2, opsmode)
370 }
371
372 pub fn from_elements(elements: &ElementSet) -> Result<Self, Error> {
383 Self::from_elements_with_opsmode(elements, OpsMode::Improved)
384 }
385
386 pub fn from_elements_with_opsmode(
389 elements: &ElementSet,
390 opsmode: OpsMode,
391 ) -> Result<Self, Error> {
392 let satrec = init_satrec_from_elements(elements, opsmode)?;
393 Ok(Satellite {
394 line1: String::new(),
395 line2: String::new(),
396 elements: elements.clone(),
397 opsmode,
398 satrec: Box::new(satrec),
399 })
400 }
401
402 pub fn propagate(&self, t: MinutesSinceEpoch) -> Result<Prediction, Error> {
407 propagate_satrec((*self.satrec).clone(), t)
410 }
411
412 pub fn propagate_jd(&self, jd: JulianDate) -> Result<Prediction, Error> {
421 validate::finite(jd.0, "julian_date.whole").map_err(map_input_error)?;
422 validate::finite_in_range_exclusive_upper(jd.1, 0.0, 1.0, "julian_date.fraction")
423 .map_err(map_input_error)?;
424 let tsince =
425 (jd.0 - self.satrec.jdsatepoch) * 1440.0 + (jd.1 - self.satrec.jdsatepochF) * 1440.0;
426 validate::finite(tsince, "minutes_since_epoch").map_err(map_input_error)?;
427 self.propagate(MinutesSinceEpoch(tsince))
428 }
429
430 pub(crate) fn mean_motion_rad_per_min(&self) -> f64 {
431 self.satrec.no_kozai
432 }
433
434 pub(crate) fn eccentricity(&self) -> f64 {
435 self.satrec.ecco
436 }
437
438 pub fn line1(&self) -> &str {
441 &self.line1
442 }
443
444 pub fn line2(&self) -> &str {
447 &self.line2
448 }
449
450 pub fn epoch_jd(&self) -> JulianDate {
455 JulianDate(self.satrec.jdsatepoch, self.satrec.jdsatepochF)
456 }
457
458 fn has_source_tle(&self) -> bool {
459 !self.line1.is_empty() && !self.line2.is_empty()
460 }
461}
462
463pub fn propagate_elements(
471 elements: &ElementSet,
472 t: MinutesSinceEpoch,
473) -> Result<Prediction, Error> {
474 propagate_elements_with_opsmode(elements, t, OpsMode::Improved)
475}
476
477pub fn propagate_elements_with_opsmode(
479 elements: &ElementSet,
480 t: MinutesSinceEpoch,
481 opsmode: OpsMode,
482) -> Result<Prediction, Error> {
483 let satrec = init_satrec_from_elements(elements, opsmode)?;
484 propagate_satrec(satrec, t)
485}
486
487fn propagate_arc(
498 satellite: &Satellite,
499 times: &[MinutesSinceEpoch],
500) -> Result<Vec<Prediction>, Error> {
501 times.iter().map(|&t| satellite.propagate(t)).collect()
502}
503
504pub fn propagate_batch(
545 satellites: &[Satellite],
546 times: &[MinutesSinceEpoch],
547) -> Vec<Result<Vec<Prediction>, Error>> {
548 satellites
549 .iter()
550 .map(|satellite| propagate_arc(satellite, times))
551 .collect()
552}
553
554pub fn propagate_batch_parallel(
565 satellites: &[Satellite],
566 times: &[MinutesSinceEpoch],
567) -> Vec<Result<Vec<Prediction>, Error>> {
568 use rayon::prelude::*;
569 satellites
570 .par_iter()
571 .map(|satellite| propagate_arc(satellite, times))
572 .collect()
573}
574
575impl serde::Serialize for Satellite {
576 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
577 use serde::ser::SerializeStruct;
578 let mut st = s.serialize_struct("Satellite", 2)?;
579 if self.has_source_tle() {
580 st.serialize_field("line1", &self.line1)?;
581 st.serialize_field("line2", &self.line2)?;
582 } else {
583 st.serialize_field("elements", &self.elements)?;
584 st.serialize_field("opsmode", &self.opsmode)?;
585 }
586 st.end()
587 }
588}
589
590impl<'de> serde::Deserialize<'de> for Satellite {
591 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
592 #[derive(serde::Deserialize)]
593 struct Wire {
594 line1: Option<String>,
595 line2: Option<String>,
596 elements: Option<ElementSet>,
597 opsmode: Option<OpsMode>,
598 }
599 let w = Wire::deserialize(d)?;
600 let opsmode = w.opsmode.unwrap_or_default();
601 let has_tle_line = w
602 .line1
603 .as_deref()
604 .is_some_and(|line| !line.trim().is_empty())
605 || w.line2
606 .as_deref()
607 .is_some_and(|line| !line.trim().is_empty());
608 if let Some(elements) = w.elements {
609 if has_tle_line {
610 Err(serde::de::Error::custom(
611 "ambiguous Satellite wire format: use either TLE lines or elements",
612 ))
613 } else {
614 Satellite::from_elements_with_opsmode(&elements, opsmode)
615 .map_err(serde::de::Error::custom)
616 }
617 } else if let (Some(line1), Some(line2)) = (w.line1, w.line2) {
618 if line1.trim().is_empty() || line2.trim().is_empty() {
619 Err(serde::de::Error::custom(
620 "Satellite wire format requires non-empty line1/line2 or elements",
621 ))
622 } else {
623 Satellite::from_tle_with_opsmode(&line1, &line2, opsmode)
624 .map_err(serde::de::Error::custom)
625 }
626 } else {
627 Err(serde::de::Error::custom(
628 "Satellite wire format requires non-empty line1/line2 or elements",
629 ))
630 }
631 }
632}
633
634fn propagate_satrec(
637 mut satrec: vallado::ElsetRec,
638 t: MinutesSinceEpoch,
639) -> Result<Prediction, Error> {
640 validate::finite(t.0, "minutes_since_epoch").map_err(map_input_error)?;
641 if t.0.abs() > MAX_MINUTES_SINCE_EPOCH {
642 return Err(invalid_domain("minutes_since_epoch"));
643 }
644
645 let mut r = [0.0_f64; 3];
646 let mut v = [0.0_f64; 3];
647 let ok = vallado::sgp4(&mut satrec, t.0, &mut r, &mut v);
648 if !ok || satrec.error != 0 {
649 return Err(Error::Sgp4 { code: satrec.error });
650 }
651 validate_prediction(r, v)?;
652 Ok(Prediction {
653 position: r,
654 velocity: v,
655 })
656}
657
658fn validate_prediction(position: [f64; 3], velocity: [f64; 3]) -> Result<(), Error> {
659 validate::finite_vec3(position, "position_km").map_err(map_output_error)?;
660 validate::finite_vec3(velocity, "velocity_km_s").map_err(map_output_error)?;
661 Ok(())
662}
663
664fn init_satrec_from_elements(
669 elements: &ElementSet,
670 opsmode: OpsMode,
671) -> Result<vallado::ElsetRec, Error> {
672 validate_elements(elements)?;
673
674 let deg2rad = std::f64::consts::PI / 180.0;
675 let xpdotp = 1440.0 / (2.0 * std::f64::consts::PI);
676
677 let inclo = elements.inclination_deg * deg2rad;
678 let nodeo = elements.right_ascension_deg * deg2rad;
679 let argpo = elements.argument_of_perigee_deg * deg2rad;
680 let mo = elements.mean_anomaly_deg * deg2rad;
681 let no_kozai = elements.mean_motion_rev_per_day / xpdotp;
682 let ndot = elements.mean_motion_dot / (xpdotp * 1440.0);
685 let nddot = elements.mean_motion_double_dot / (xpdotp * 1440.0 * 1440.0);
686
687 let JulianDate(jd, jdfrac) = elements.epoch;
688 let epoch_sgp4 = jd + jdfrac - 2433281.5;
689
690 let satnum_str = format!("{:>5}", elements.catalog_number);
691
692 let mut satrec = vallado::ElsetRec {
693 jdsatepoch: jd,
694 jdsatepochF: jdfrac,
695 ..vallado::ElsetRec::default()
696 };
697
698 vallado::sgp4init(
699 vallado::GravConstType::Wgs72,
700 opsmode.as_char(),
701 &satnum_str,
702 epoch_sgp4,
703 elements.bstar,
704 ndot,
705 nddot,
706 elements.eccentricity,
707 argpo,
708 inclo,
709 mo,
710 no_kozai,
711 nodeo,
712 &mut satrec,
713 );
714
715 satrec.jdsatepoch = jd;
717 satrec.jdsatepochF = jdfrac;
718
719 Ok(satrec)
720}
721
722fn validate_elements(elements: &ElementSet) -> Result<(), Error> {
723 if elements.catalog_number > MAX_VALLADO_SATNUM {
724 return Err(invalid_domain("element.catalog_number"));
725 }
726 validate_epoch(elements.epoch)?;
727 validate::finite(elements.bstar, "element.bstar").map_err(map_input_error)?;
728 validate::finite(elements.mean_motion_dot, "element.mean_motion_dot")
729 .map_err(map_input_error)?;
730 validate::finite(
731 elements.mean_motion_double_dot,
732 "element.mean_motion_double_dot",
733 )
734 .map_err(map_input_error)?;
735 validate::finite_in_range_exclusive_upper(
736 elements.eccentricity,
737 0.0,
738 1.0,
739 "element.eccentricity",
740 )
741 .map_err(map_input_error)?;
742 validate::finite(
743 elements.argument_of_perigee_deg,
744 "element.argument_of_perigee_deg",
745 )
746 .map_err(map_input_error)?;
747 validate::finite(elements.inclination_deg, "element.inclination_deg")
748 .map_err(map_input_error)?;
749 validate::finite(elements.mean_anomaly_deg, "element.mean_anomaly_deg")
750 .map_err(map_input_error)?;
751 validate::finite_positive(
752 elements.mean_motion_rev_per_day,
753 "element.mean_motion_rev_per_day",
754 )
755 .map_err(map_input_error)?;
756 validate::finite(elements.right_ascension_deg, "element.right_ascension_deg")
757 .map_err(map_input_error)?;
758
759 Ok(())
760}
761
762fn validate_epoch(epoch: JulianDate) -> Result<(), Error> {
763 validate::finite(epoch.0, "element.epoch.whole").map_err(map_input_error)?;
764 validate::finite(epoch.1, "element.epoch.fraction").map_err(map_input_error)?;
765
766 let total = epoch.0 + epoch.1;
767 validate::finite(total, "element.epoch").map_err(map_input_error)?;
768 if !(0.0..=5_000_000.0).contains(&total) {
769 return Err(invalid_domain("element.epoch"));
770 }
771 Ok(())
772}
773
774fn map_input_error(error: FieldError) -> Error {
775 Error::InvalidInput {
776 field: error.field(),
777 kind: Sgp4InputErrorKind::from(&error),
778 }
779}
780
781fn invalid_domain(field: &'static str) -> Error {
782 Error::InvalidInput {
783 field,
784 kind: Sgp4InputErrorKind::OutOfRange,
785 }
786}
787
788fn map_tle_bridge_error(error: tle::TleError) -> Error {
789 match error {
790 tle::TleError::InvalidField { field, reason } => Error::InvalidInput {
791 field,
792 kind: match reason {
793 "not finite" => Sgp4InputErrorKind::NonFinite,
794 "not positive" => Sgp4InputErrorKind::NotPositive,
795 "negative" => Sgp4InputErrorKind::Negative,
796 "out of range" => Sgp4InputErrorKind::OutOfRange,
797 _ => Sgp4InputErrorKind::OutOfRange,
798 },
799 },
800 other => Error::InvalidTle(other.to_string()),
801 }
802}
803
804fn map_output_error(error: FieldError) -> Error {
805 Error::NonFiniteOutput {
806 field: error.field(),
807 }
808}
809
810#[derive(Debug)]
812pub struct NamedSatellite {
813 pub name: String,
817 pub satellite: Satellite,
819}
820
821#[derive(Debug)]
824pub struct TleFile {
825 pub satellites: Vec<NamedSatellite>,
827 pub skipped: usize,
833}
834
835pub fn parse_tle_file(text: &str) -> TleFile {
844 parse_tle_file_with_opsmode(text, OpsMode::Improved)
845}
846
847pub fn parse_tle_file_with_opsmode(text: &str, opsmode: OpsMode) -> TleFile {
849 let lines: Vec<&str> = text.lines().map(str::trim).collect();
850 let mut satellites = Vec::new();
851 let mut skipped = 0usize;
852 let mut pending_name = String::new();
853 let mut i = 0;
854 while i < lines.len() {
855 let line = lines[i];
856 if line.is_empty() {
857 i += 1;
858 continue;
859 }
860 if line.starts_with("1 ") {
861 let mut j = i + 1;
863 while j < lines.len() && lines[j].is_empty() {
864 j += 1;
865 }
866 if j < lines.len() && lines[j].starts_with("2 ") {
867 if let Ok(satellite) = Satellite::from_tle_with_opsmode(line, lines[j], opsmode) {
868 satellites.push(NamedSatellite {
869 name: std::mem::take(&mut pending_name),
870 satellite,
871 });
872 } else {
873 skipped += 1;
874 pending_name.clear();
875 }
876 i = j + 1;
877 continue;
878 }
879 pending_name.clear();
881 i += 1;
882 continue;
883 }
884 if line.starts_with("2 ") {
885 pending_name.clear();
888 i += 1;
889 continue;
890 }
891 pending_name = line.strip_prefix("0 ").unwrap_or(line).trim().to_string();
893 i += 1;
894 }
895 TleFile {
896 satellites,
897 skipped,
898 }
899}
900
901#[cfg(test)]
902mod tests {
903 use super::{
904 parse_tle_file, propagate_batch, propagate_batch_parallel, propagate_elements, ElementSet,
905 Error, JulianDate, MinutesSinceEpoch, Satellite, Sgp4InputErrorKind,
906 MAX_MINUTES_SINCE_EPOCH,
907 };
908
909 #[test]
915 fn non_ascii_tle_returns_invalid_tle_not_panic() {
916 let line1 = "1 25544U 98067A 18184.80969102 .00001614 00000-0 31745-4 0 9993";
917 let line2 = "2 25544 51.6414 295.8524 0003435 262.6267 204.2868 15.54005638121106";
918 assert!(
919 Satellite::from_tle(line1, line2).is_ok(),
920 "clean ASCII TLE must still parse"
921 );
922
923 let mut bad1 = String::from(&line1[..18]);
926 bad1.push('\u{20ac}');
927 bad1.push_str(&line1[19..]);
928 assert!(
929 !bad1.is_char_boundary(20),
930 "corruption must straddle byte 20"
931 );
932
933 let err = Satellite::from_tle(&bad1, line2).expect_err("non-ASCII TLE must not parse");
934 assert!(
935 matches!(err, Error::InvalidTle(_)),
936 "expected a typed InvalidTle error, got: {err:?}"
937 );
938 }
939
940 const ISS_L1: &str = "1 25544U 98067A 18184.80969102 .00001614 00000-0 31745-4 0 9993";
944 const ISS_L2: &str = "2 25544 51.6414 295.8524 0003435 262.6267 204.2868 15.54005638121106";
945
946 #[test]
947 fn parse_tle_file_three_line_captures_names() {
948 let text = format!("ISS (ZARYA)\n{ISS_L1}\n{ISS_L2}\nSECOND SAT\n{ISS_L1}\n{ISS_L2}\n");
949 let f = parse_tle_file(&text);
950 assert_eq!(f.satellites.len(), 2);
951 assert_eq!(f.skipped, 0);
952 assert_eq!(f.satellites[0].name, "ISS (ZARYA)");
953 assert_eq!(f.satellites[1].name, "SECOND SAT");
954 assert_eq!(f.satellites[0].satellite.line1(), ISS_L1);
955 assert_eq!(f.satellites[0].satellite.line2(), ISS_L2);
956 }
957
958 #[test]
959 fn parse_tle_file_bare_two_line_has_empty_name() {
960 let f = parse_tle_file(&format!("{ISS_L1}\n{ISS_L2}"));
961 assert_eq!(f.satellites.len(), 1);
962 assert_eq!(f.satellites[0].name, "");
963 assert_eq!(f.satellites[0].satellite.line1(), ISS_L1);
964 }
965
966 #[test]
967 fn parse_tle_file_strips_celestrak_zero_name_marker() {
968 let f = parse_tle_file(&format!("0 ISS (ZARYA)\n{ISS_L1}\n{ISS_L2}"));
969 assert_eq!(f.satellites.len(), 1);
970 assert_eq!(f.satellites[0].name, "ISS (ZARYA)");
971 }
972
973 #[test]
974 fn parse_tle_file_tolerates_crlf_blanks_and_whitespace() {
975 let text = format!("\r\n ISS (ZARYA) \r\n{ISS_L1}\r\n\r\n{ISS_L2}\r\n\r\n");
976 let f = parse_tle_file(&text);
977 assert_eq!(f.satellites.len(), 1);
978 assert_eq!(f.satellites[0].name, "ISS (ZARYA)");
979 assert_eq!(f.satellites[0].satellite.line1(), ISS_L1);
980 }
981
982 #[test]
983 fn parse_tle_file_skips_malformed_record_and_counts_it() {
984 let text = format!(
987 "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"
988 );
989 let f = parse_tle_file(&text);
990 assert_eq!(
991 f.satellites.len(),
992 2,
993 "the malformed record must be skipped"
994 );
995 assert_eq!(f.skipped, 1, "the skipped record must be counted");
996 assert_eq!(f.satellites[0].name, "GOOD ONE");
997 assert_eq!(f.satellites[1].name, "GOOD TWO");
998 }
999
1000 #[test]
1001 fn parse_tle_file_stray_line2_does_not_leak_name() {
1002 let text = format!("ORPHAN NAME\n2 stray line two\n{ISS_L1}\n{ISS_L2}\n");
1005 let f = parse_tle_file(&text);
1006 assert_eq!(f.satellites.len(), 1);
1007 assert_eq!(f.satellites[0].name, "", "stray name must not leak forward");
1008 }
1009
1010 fn iss_elements() -> ElementSet {
1011 crate::astro::tle::parse(ISS_L1, ISS_L2)
1012 .unwrap()
1013 .elements
1014 .to_element_set()
1015 .expect("valid TLE bridge")
1016 }
1017
1018 fn assert_invalid_input<T>(
1019 result: Result<T, Error>,
1020 field: &'static str,
1021 kind: Sgp4InputErrorKind,
1022 ) {
1023 match result {
1024 Err(Error::InvalidInput {
1025 field: actual_field,
1026 kind: actual_kind,
1027 }) => {
1028 assert_eq!(actual_field, field);
1029 assert_eq!(actual_kind, kind);
1030 }
1031 Err(err) => panic!("expected InvalidInput({field}, {kind}), got {err:?}"),
1032 Ok(_) => panic!("expected InvalidInput({field}, {kind}), got Ok"),
1033 }
1034 }
1035
1036 fn assert_same(a: &Satellite, b: &Satellite) {
1039 let (ea, eb) = (a.epoch_jd(), b.epoch_jd());
1040 assert_eq!(
1041 (ea.0.to_bits(), ea.1.to_bits()),
1042 (eb.0.to_bits(), eb.1.to_bits()),
1043 "epoch JD differs"
1044 );
1045 for &t in &[0.0, 100.0, 1440.0] {
1046 let pa = a.propagate(MinutesSinceEpoch(t)).unwrap();
1047 let pb = b.propagate(MinutesSinceEpoch(t)).unwrap();
1048 for axis in 0..3 {
1049 assert_eq!(
1050 pa.position[axis].to_bits(),
1051 pb.position[axis].to_bits(),
1052 "position[{axis}] differs at t={t}"
1053 );
1054 assert_eq!(
1055 pa.velocity[axis].to_bits(),
1056 pb.velocity[axis].to_bits(),
1057 "velocity[{axis}] differs at t={t}"
1058 );
1059 }
1060 }
1061 }
1062
1063 #[test]
1064 fn serde_round_trips_tle_satellites() {
1065 let sat = Satellite::from_tle(ISS_L1, ISS_L2).unwrap();
1066 let encoded = serde_json::to_string(&sat).unwrap();
1067 assert!(encoded.contains("\"line1\""));
1068 assert!(encoded.contains("\"line2\""));
1069 assert!(!encoded.contains("\"elements\""));
1070
1071 let decoded: Satellite = serde_json::from_str(&encoded).unwrap();
1072 assert_eq!(decoded.line1(), ISS_L1);
1073 assert_eq!(decoded.line2(), ISS_L2);
1074 assert_same(&sat, &decoded);
1075 }
1076
1077 #[test]
1078 fn serde_round_trips_element_built_satellites() {
1079 let elements = iss_elements();
1080 let sat = Satellite::from_elements(&elements).unwrap();
1081 let encoded = serde_json::to_string(&sat).unwrap();
1082 assert!(encoded.contains("\"elements\""));
1083 assert!(encoded.contains("\"opsmode\""));
1084 assert!(!encoded.contains("\"line1\""));
1085 assert!(!encoded.contains("\"line2\""));
1086
1087 let decoded: Satellite = serde_json::from_str(&encoded).unwrap();
1088 assert!(decoded.line1().is_empty());
1089 assert!(decoded.line2().is_empty());
1090 assert_same(&sat, &decoded);
1091 }
1092
1093 #[test]
1094 fn from_elements_rejects_non_finite_fields_before_sgp4init() {
1095 let mut elements = iss_elements();
1096 elements.bstar = f64::NAN;
1097
1098 assert_invalid_input(
1099 Satellite::from_elements(&elements),
1100 "element.bstar",
1101 Sgp4InputErrorKind::NonFinite,
1102 );
1103 }
1104
1105 #[test]
1106 fn from_elements_rejects_sgp4_domain_before_sgp4init() {
1107 let mut elements = iss_elements();
1108 elements.mean_motion_rev_per_day = 0.0;
1109 assert_invalid_input(
1110 Satellite::from_elements(&elements),
1111 "element.mean_motion_rev_per_day",
1112 Sgp4InputErrorKind::NotPositive,
1113 );
1114
1115 let mut elements = iss_elements();
1116 elements.eccentricity = -0.1;
1117 assert_invalid_input(
1118 Satellite::from_elements(&elements),
1119 "element.eccentricity",
1120 Sgp4InputErrorKind::OutOfRange,
1121 );
1122
1123 let mut elements = iss_elements();
1124 elements.eccentricity = 1.0;
1125 assert_invalid_input(
1126 Satellite::from_elements(&elements),
1127 "element.eccentricity",
1128 Sgp4InputErrorKind::OutOfRange,
1129 );
1130
1131 let mut elements = iss_elements();
1132 elements.catalog_number = 100_000;
1133 assert_invalid_input(
1134 Satellite::from_elements(&elements),
1135 "element.catalog_number",
1136 Sgp4InputErrorKind::OutOfRange,
1137 );
1138 }
1139
1140 #[test]
1141 fn from_elements_rejects_invalid_epoch() {
1142 let mut elements = iss_elements();
1143 elements.epoch = JulianDate(f64::NAN, 0.0);
1144 assert_invalid_input(
1145 Satellite::from_elements(&elements),
1146 "element.epoch.whole",
1147 Sgp4InputErrorKind::NonFinite,
1148 );
1149
1150 let mut elements = iss_elements();
1151 elements.epoch = JulianDate(9_000_000.0, 0.0);
1152 assert_invalid_input(
1153 Satellite::from_elements(&elements),
1154 "element.epoch",
1155 Sgp4InputErrorKind::OutOfRange,
1156 );
1157 }
1158
1159 #[test]
1160 fn from_elements_accepts_full_julian_epoch() {
1161 let mut elements = iss_elements();
1162 elements.epoch = super::sgp4_julian_date_from_calendar(2057, 1, 1, 0, 0, 0.0);
1163 Satellite::from_elements(&elements).expect("full 2057 epoch is valid");
1164 }
1165
1166 #[test]
1167 fn from_tle_accepts_epoch_after_parser_conversion_to_full_jd() {
1168 let mut line1 = ISS_L1.to_string();
1169 line1.replace_range(18..32, "19366.00000000");
1170
1171 Satellite::from_tle(&line1, ISS_L2).expect("TLE epoch is converted to full JD");
1172 }
1173
1174 #[test]
1175 fn propagation_rejects_non_finite_time_inputs() {
1176 let sat = Satellite::from_tle(ISS_L1, ISS_L2).unwrap();
1177 assert_invalid_input(
1178 sat.propagate(MinutesSinceEpoch(f64::NAN)),
1179 "minutes_since_epoch",
1180 Sgp4InputErrorKind::NonFinite,
1181 );
1182 assert_invalid_input(
1183 sat.propagate_jd(JulianDate(f64::INFINITY, 0.0)),
1184 "julian_date.whole",
1185 Sgp4InputErrorKind::NonFinite,
1186 );
1187
1188 let elements = iss_elements();
1189 assert_invalid_input(
1190 propagate_elements(&elements, MinutesSinceEpoch(f64::INFINITY)),
1191 "minutes_since_epoch",
1192 Sgp4InputErrorKind::NonFinite,
1193 );
1194 }
1195
1196 #[test]
1197 fn propagation_rejects_out_of_domain_time_inputs() {
1198 let sat = Satellite::from_tle(ISS_L1, ISS_L2).unwrap();
1199 assert_invalid_input(
1200 sat.propagate(MinutesSinceEpoch(MAX_MINUTES_SINCE_EPOCH.next_up())),
1201 "minutes_since_epoch",
1202 Sgp4InputErrorKind::OutOfRange,
1203 );
1204 assert_invalid_input(
1205 sat.propagate_jd(JulianDate(2_458_304.0, 1.0)),
1206 "julian_date.fraction",
1207 Sgp4InputErrorKind::OutOfRange,
1208 );
1209 }
1210
1211 #[test]
1212 fn lenient_trailing_whitespace_and_content_past_col_69() {
1213 let clean = Satellite::from_tle(ISS_L1, ISS_L2).unwrap();
1214
1215 let pad = Satellite::from_tle(&format!("{ISS_L1} "), &format!("{ISS_L2}\t ")).unwrap();
1217 assert_same(&clean, &pad);
1218
1219 let extra =
1222 Satellite::from_tle(&format!("{ISS_L1} EXTRA-JUNK"), &format!("{ISS_L2} 999999"))
1223 .unwrap();
1224 assert_same(&clean, &extra);
1225 }
1226
1227 #[test]
1228 fn lenient_leading_dot_and_assumed_decimal_fields() {
1229 let sat = Satellite::from_tle(ISS_L1, ISS_L2).unwrap();
1234 let p = sat.propagate(MinutesSinceEpoch(0.0)).unwrap();
1235 let r = (p.position[0].powi(2) + p.position[1].powi(2) + p.position[2].powi(2)).sqrt();
1236 assert!(
1237 (6500.0..=7200.0).contains(&r),
1238 "ISS radius {r} km outside LEO"
1239 );
1240 }
1241
1242 #[test]
1243 fn lenient_missing_optional_bookkeeping_fields() {
1244 let l1: String = ISS_L1
1249 .char_indices()
1250 .map(|(i, c)| {
1251 if i == 62 || (64..=67).contains(&i) {
1252 ' '
1253 } else {
1254 c
1255 }
1256 })
1257 .collect();
1258 let l2: String = ISS_L2
1259 .char_indices()
1260 .map(|(i, c)| if (63..=67).contains(&i) { ' ' } else { c })
1261 .collect();
1262 let clean = Satellite::from_tle(ISS_L1, ISS_L2).unwrap();
1265 let blanked = Satellite::from_tle(&l1, &l2).unwrap();
1266 assert_same(&clean, &blanked);
1267 }
1268
1269 #[test]
1270 fn three_line_form_strips_name_line() {
1271 let clean = Satellite::from_tle(ISS_L1, ISS_L2).unwrap();
1272
1273 let block = format!("ISS (ZARYA)\n{ISS_L1}\n{ISS_L2}\n");
1274 let three = Satellite::from_3line(&block).unwrap();
1275 assert_same(&clean, &three);
1276
1277 let two = Satellite::from_3line(&format!("{ISS_L1}\n{ISS_L2}")).unwrap();
1279 assert_same(&clean, &two);
1280 }
1281
1282 #[test]
1283 fn three_line_form_rejects_block_without_element_lines() {
1284 assert!(Satellite::from_3line("just a name\nand some text").is_err());
1285 assert!(Satellite::from_3line("").is_err());
1286 }
1287
1288 const CSS_L1: &str = "1 48274U 21035A 24001.50000000 .00015000 00000-0 18000-3 0 9990";
1293 const CSS_L2: &str = "2 48274 41.4700 100.0000 0006000 90.0000 270.0000 15.61000000 10000";
1294
1295 const DECAY_L1: &str = "1 28872U 05037B 05333.02012661 .25992681 00000-0 24476-3 0 1534";
1300 const DECAY_L2: &str = "2 28872 96.4736 157.9986 0303955 244.0492 110.6523 16.46015938 10708";
1301
1302 fn batch_times() -> Vec<MinutesSinceEpoch> {
1303 (0..33)
1306 .map(|i| MinutesSinceEpoch(i as f64 * 45.0))
1307 .collect()
1308 }
1309
1310 #[test]
1314 fn batch_is_bit_identical_to_per_satellite_propagate() {
1315 let satellites = [
1316 Satellite::from_tle(ISS_L1, ISS_L2).unwrap(),
1317 Satellite::from_tle(CSS_L1, CSS_L2).unwrap(),
1318 ];
1319 let times = batch_times();
1320
1321 let batch = propagate_batch(&satellites, ×);
1322 assert_eq!(batch.len(), satellites.len());
1323
1324 for (sat_idx, satellite) in satellites.iter().enumerate() {
1325 let arc = batch[sat_idx]
1326 .as_ref()
1327 .expect("clean satellite arc must be Ok");
1328 assert_eq!(arc.len(), times.len());
1329 for (epoch_idx, &t) in times.iter().enumerate() {
1330 let reference = satellite.propagate(t).expect("per-sat propagate ok");
1331 for axis in 0..3 {
1332 assert_eq!(
1333 arc[epoch_idx].position[axis].to_bits(),
1334 reference.position[axis].to_bits(),
1335 "position bits sat {sat_idx} epoch {epoch_idx} axis {axis}"
1336 );
1337 assert_eq!(
1338 arc[epoch_idx].velocity[axis].to_bits(),
1339 reference.velocity[axis].to_bits(),
1340 "velocity bits sat {sat_idx} epoch {epoch_idx} axis {axis}"
1341 );
1342 }
1343 }
1344 }
1345 }
1346
1347 #[test]
1350 fn parallel_batch_is_bit_identical_to_serial() {
1351 let satellites = [
1352 Satellite::from_tle(ISS_L1, ISS_L2).unwrap(),
1353 Satellite::from_tle(CSS_L1, CSS_L2).unwrap(),
1354 Satellite::from_tle(ISS_L1, ISS_L2).unwrap(),
1355 ];
1356 let times = batch_times();
1357
1358 let serial = propagate_batch(&satellites, ×);
1359 let parallel = propagate_batch_parallel(&satellites, ×);
1360 assert_eq!(serial.len(), parallel.len());
1361
1362 for sat_idx in 0..satellites.len() {
1363 let s = serial[sat_idx].as_ref().expect("serial arc ok");
1364 let p = parallel[sat_idx].as_ref().expect("parallel arc ok");
1365 assert_eq!(s.len(), p.len());
1366 for epoch_idx in 0..times.len() {
1367 for axis in 0..3 {
1368 assert_eq!(
1369 s[epoch_idx].position[axis].to_bits(),
1370 p[epoch_idx].position[axis].to_bits(),
1371 "position bits sat {sat_idx} epoch {epoch_idx} axis {axis}"
1372 );
1373 assert_eq!(
1374 s[epoch_idx].velocity[axis].to_bits(),
1375 p[epoch_idx].velocity[axis].to_bits(),
1376 "velocity bits sat {sat_idx} epoch {epoch_idx} axis {axis}"
1377 );
1378 }
1379 }
1380 }
1381 }
1382
1383 #[test]
1388 fn failing_satellite_yields_per_item_error_without_poisoning_batch() {
1389 let clean_a = Satellite::from_tle(ISS_L1, ISS_L2).unwrap();
1390 let decay = Satellite::from_tle(DECAY_L1, DECAY_L2).unwrap();
1391 let clean_b = Satellite::from_tle(CSS_L1, CSS_L2).unwrap();
1392
1393 let times: Vec<MinutesSinceEpoch> = (0..=24)
1396 .map(|i| MinutesSinceEpoch(i as f64 * 120.0))
1397 .collect();
1398 assert!(
1399 times.iter().any(|&t| decay.propagate(t).is_err()),
1400 "decaying fixture must error on the grid"
1401 );
1402 assert!(
1403 times.iter().all(|&t| clean_a.propagate(t).is_ok()),
1404 "clean fixture must span the grid"
1405 );
1406
1407 let satellites = [clean_a, decay, clean_b];
1408 for batch in [
1409 propagate_batch(&satellites, ×),
1410 propagate_batch_parallel(&satellites, ×),
1411 ] {
1412 assert_eq!(batch.len(), 3);
1413 assert!(batch[0].is_ok(), "clean satellite 0 must survive");
1415 assert_eq!(batch[0].as_ref().unwrap().len(), times.len());
1416 assert!(batch[2].is_ok(), "clean satellite 2 must survive");
1417 assert_eq!(batch[2].as_ref().unwrap().len(), times.len());
1418 assert!(
1420 matches!(batch[1], Err(Error::Sgp4 { .. })),
1421 "decaying satellite must yield an SGP4 error, got {:?}",
1422 batch[1]
1423 );
1424 }
1425 }
1426
1427 #[test]
1428 fn batch_handles_empty_inputs() {
1429 let sat = Satellite::from_tle(ISS_L1, ISS_L2).unwrap();
1430 let times = batch_times();
1431
1432 assert!(propagate_batch(&[], ×).is_empty());
1434 assert!(propagate_batch_parallel(&[], ×).is_empty());
1435
1436 let no_times = propagate_batch(std::slice::from_ref(&sat), &[]);
1438 assert_eq!(no_times.len(), 1);
1439 assert!(no_times[0].as_ref().unwrap().is_empty());
1440 }
1441
1442 #[test]
1443 fn rejects_genuine_corruption() {
1444 assert!(Satellite::from_tle("", "").is_err());
1446 assert!(Satellite::from_tle("hello world", "goodbye world").is_err());
1448 assert!(Satellite::from_tle(ISS_L2, ISS_L1).is_err());
1450 let l2_wrong = "2 25545 51.6414 295.8524 0003435 262.6267 204.2868 15.54005638121106";
1452 assert!(matches!(
1453 Satellite::from_tle(ISS_L1, l2_wrong),
1454 Err(Error::InvalidTle(_))
1455 ));
1456 }
1457}