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)]
148#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
149pub struct MinutesSinceEpoch(pub f64);
150
151#[derive(Debug, Clone, PartialEq)]
153#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
154pub struct Prediction {
155 pub position: [f64; 3],
157 pub velocity: [f64; 3],
159}
160
161#[derive(Debug, Clone, Copy, PartialEq)]
166#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
167pub struct JulianDate(pub f64, pub f64);
168
169pub(crate) fn sgp4_julian_date_from_calendar(
170 year: i32,
171 mon: i32,
172 day: i32,
173 hr: i32,
174 minute: i32,
175 sec: f64,
176) -> JulianDate {
177 let (jd, jdfrac) = vallado::jday_SGP4(year, mon, day, hr, minute, sec);
178 JulianDate(jd, jdfrac)
179}
180
181pub(crate) fn sgp4_julian_date_from_day_of_year(year: i32, days: f64) -> JulianDate {
182 let (mon, day, hr, minute, sec) = vallado::days2mdhms_SGP4(year, days);
183 let JulianDate(jd, jdfrac_raw) =
184 sgp4_julian_date_from_calendar(year, mon, day, hr, minute, sec);
185 let jdfrac = (jdfrac_raw * 100_000_000.0).round() / 100_000_000.0;
186 JulianDate(jd, jdfrac)
187}
188
189#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
194#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
195pub enum OpsMode {
196 #[default]
200 Improved,
201 Afspc,
205}
206
207impl OpsMode {
208 fn as_char(self) -> char {
209 match self {
210 OpsMode::Improved => 'i',
211 OpsMode::Afspc => 'a',
212 }
213 }
214}
215
216#[derive(Debug, Clone, PartialEq)]
227#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
228pub struct ElementSet {
229 pub epoch: JulianDate,
237 pub bstar: f64,
239 pub mean_motion_dot: f64,
241 pub mean_motion_double_dot: f64,
243 pub eccentricity: f64,
245 pub argument_of_perigee_deg: f64,
247 pub inclination_deg: f64,
249 pub mean_anomaly_deg: f64,
251 pub mean_motion_rev_per_day: f64,
253 pub right_ascension_deg: f64,
255 pub catalog_number: u32,
259}
260
261#[derive(Clone)]
270pub struct Satellite {
271 line1: String,
272 line2: String,
273 elements: ElementSet,
276 opsmode: OpsMode,
277 satrec: Box<vallado::ElsetRec>,
280}
281
282impl std::fmt::Debug for Satellite {
283 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
284 f.debug_struct("Satellite")
285 .field("line1", &self.line1)
286 .field("line2", &self.line2)
287 .field("elements", &self.elements)
288 .field("opsmode", &self.opsmode)
289 .finish_non_exhaustive()
290 }
291}
292
293impl Satellite {
294 pub fn from_tle(line1: &str, line2: &str) -> Result<Self, Error> {
301 Self::from_tle_with_opsmode(line1, line2, OpsMode::Improved)
302 }
303
304 pub fn from_tle_with_opsmode(
319 line1: &str,
320 line2: &str,
321 opsmode: OpsMode,
322 ) -> Result<Self, Error> {
323 let l1 = line1.trim();
324 let l2 = line2.trim();
325
326 let parsed = tle::parse(l1, l2).map_err(|e| Error::InvalidTle(e.to_string()))?;
327 let elements = parsed
328 .elements
329 .to_element_set()
330 .map_err(map_tle_bridge_error)?;
331 let satrec = init_satrec_from_elements(&elements, opsmode)?;
332
333 Ok(Satellite {
334 line1: l1.to_string(),
335 line2: l2.to_string(),
336 elements,
337 opsmode,
338 satrec: Box::new(satrec),
339 })
340 }
341
342 pub fn from_3line(block: &str) -> Result<Self, Error> {
350 Self::from_3line_with_opsmode(block, OpsMode::Improved)
351 }
352
353 pub fn from_3line_with_opsmode(block: &str, opsmode: OpsMode) -> Result<Self, Error> {
356 let mut l1 = None;
357 let mut l2 = None;
358 for line in block.lines() {
359 let line = line.trim();
360 if l1.is_none() && line.starts_with("1 ") {
361 l1 = Some(line.to_string());
362 } else if l2.is_none() && line.starts_with("2 ") {
363 l2 = Some(line.to_string());
364 }
365 }
366 let l1 = l1.ok_or_else(|| Error::InvalidTle("no line 1 in TLE block".into()))?;
367 let l2 = l2.ok_or_else(|| Error::InvalidTle("no line 2 in TLE block".into()))?;
368 Self::from_tle_with_opsmode(&l1, &l2, opsmode)
369 }
370
371 pub fn from_elements(elements: &ElementSet) -> Result<Self, Error> {
382 Self::from_elements_with_opsmode(elements, OpsMode::Improved)
383 }
384
385 pub fn from_elements_with_opsmode(
388 elements: &ElementSet,
389 opsmode: OpsMode,
390 ) -> Result<Self, Error> {
391 let satrec = init_satrec_from_elements(elements, opsmode)?;
392 Ok(Satellite {
393 line1: String::new(),
394 line2: String::new(),
395 elements: elements.clone(),
396 opsmode,
397 satrec: Box::new(satrec),
398 })
399 }
400
401 pub fn propagate(&self, t: MinutesSinceEpoch) -> Result<Prediction, Error> {
406 propagate_satrec((*self.satrec).clone(), t)
409 }
410
411 pub fn propagate_jd(&self, jd: JulianDate) -> Result<Prediction, Error> {
420 validate::finite(jd.0, "julian_date.whole").map_err(map_input_error)?;
421 validate::finite_in_range_exclusive_upper(jd.1, 0.0, 1.0, "julian_date.fraction")
422 .map_err(map_input_error)?;
423 let tsince =
424 (jd.0 - self.satrec.jdsatepoch) * 1440.0 + (jd.1 - self.satrec.jdsatepochF) * 1440.0;
425 validate::finite(tsince, "minutes_since_epoch").map_err(map_input_error)?;
426 self.propagate(MinutesSinceEpoch(tsince))
427 }
428
429 pub(crate) fn mean_motion_rad_per_min(&self) -> f64 {
430 self.satrec.no_kozai
431 }
432
433 pub(crate) fn eccentricity(&self) -> f64 {
434 self.satrec.ecco
435 }
436
437 pub fn line1(&self) -> &str {
440 &self.line1
441 }
442
443 pub fn line2(&self) -> &str {
446 &self.line2
447 }
448
449 pub fn epoch_jd(&self) -> JulianDate {
454 JulianDate(self.satrec.jdsatepoch, self.satrec.jdsatepochF)
455 }
456
457 #[cfg(feature = "serde")]
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
487#[cfg(feature = "serde")]
488impl serde::Serialize for Satellite {
489 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
490 use serde::ser::SerializeStruct;
491 let mut st = s.serialize_struct("Satellite", 2)?;
492 if self.has_source_tle() {
493 st.serialize_field("line1", &self.line1)?;
494 st.serialize_field("line2", &self.line2)?;
495 } else {
496 st.serialize_field("elements", &self.elements)?;
497 st.serialize_field("opsmode", &self.opsmode)?;
498 }
499 st.end()
500 }
501}
502
503#[cfg(feature = "serde")]
504impl<'de> serde::Deserialize<'de> for Satellite {
505 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
506 #[derive(serde::Deserialize)]
507 struct Wire {
508 line1: Option<String>,
509 line2: Option<String>,
510 elements: Option<ElementSet>,
511 opsmode: Option<OpsMode>,
512 }
513 let w = Wire::deserialize(d)?;
514 let opsmode = w.opsmode.unwrap_or_default();
515 let has_tle_line = w
516 .line1
517 .as_deref()
518 .is_some_and(|line| !line.trim().is_empty())
519 || w.line2
520 .as_deref()
521 .is_some_and(|line| !line.trim().is_empty());
522 if let Some(elements) = w.elements {
523 if has_tle_line {
524 Err(serde::de::Error::custom(
525 "ambiguous Satellite wire format: use either TLE lines or elements",
526 ))
527 } else {
528 Satellite::from_elements_with_opsmode(&elements, opsmode)
529 .map_err(serde::de::Error::custom)
530 }
531 } else if let (Some(line1), Some(line2)) = (w.line1, w.line2) {
532 if line1.trim().is_empty() || line2.trim().is_empty() {
533 Err(serde::de::Error::custom(
534 "Satellite wire format requires non-empty line1/line2 or elements",
535 ))
536 } else {
537 Satellite::from_tle_with_opsmode(&line1, &line2, opsmode)
538 .map_err(serde::de::Error::custom)
539 }
540 } else {
541 Err(serde::de::Error::custom(
542 "Satellite wire format requires non-empty line1/line2 or elements",
543 ))
544 }
545 }
546}
547
548fn propagate_satrec(
551 mut satrec: vallado::ElsetRec,
552 t: MinutesSinceEpoch,
553) -> Result<Prediction, Error> {
554 validate::finite(t.0, "minutes_since_epoch").map_err(map_input_error)?;
555 if t.0.abs() > MAX_MINUTES_SINCE_EPOCH {
556 return Err(invalid_domain("minutes_since_epoch"));
557 }
558
559 let mut r = [0.0_f64; 3];
560 let mut v = [0.0_f64; 3];
561 let ok = vallado::sgp4(&mut satrec, t.0, &mut r, &mut v);
562 if !ok || satrec.error != 0 {
563 return Err(Error::Sgp4 { code: satrec.error });
564 }
565 validate_prediction(r, v)?;
566 Ok(Prediction {
567 position: r,
568 velocity: v,
569 })
570}
571
572fn validate_prediction(position: [f64; 3], velocity: [f64; 3]) -> Result<(), Error> {
573 validate::finite_vec3(position, "position_km").map_err(map_output_error)?;
574 validate::finite_vec3(velocity, "velocity_km_s").map_err(map_output_error)?;
575 Ok(())
576}
577
578fn init_satrec_from_elements(
583 elements: &ElementSet,
584 opsmode: OpsMode,
585) -> Result<vallado::ElsetRec, Error> {
586 validate_elements(elements)?;
587
588 let deg2rad = std::f64::consts::PI / 180.0;
589 let xpdotp = 1440.0 / (2.0 * std::f64::consts::PI);
590
591 let inclo = elements.inclination_deg * deg2rad;
592 let nodeo = elements.right_ascension_deg * deg2rad;
593 let argpo = elements.argument_of_perigee_deg * deg2rad;
594 let mo = elements.mean_anomaly_deg * deg2rad;
595 let no_kozai = elements.mean_motion_rev_per_day / xpdotp;
596 let ndot = elements.mean_motion_dot / (xpdotp * 1440.0);
599 let nddot = elements.mean_motion_double_dot / (xpdotp * 1440.0 * 1440.0);
600
601 let JulianDate(jd, jdfrac) = elements.epoch;
602 let epoch_sgp4 = jd + jdfrac - 2433281.5;
603
604 let satnum_str = format!("{:>5}", elements.catalog_number);
605
606 let mut satrec = vallado::ElsetRec {
607 jdsatepoch: jd,
608 jdsatepochF: jdfrac,
609 ..vallado::ElsetRec::default()
610 };
611
612 vallado::sgp4init(
613 vallado::GravConstType::Wgs72,
614 opsmode.as_char(),
615 &satnum_str,
616 epoch_sgp4,
617 elements.bstar,
618 ndot,
619 nddot,
620 elements.eccentricity,
621 argpo,
622 inclo,
623 mo,
624 no_kozai,
625 nodeo,
626 &mut satrec,
627 );
628
629 satrec.jdsatepoch = jd;
631 satrec.jdsatepochF = jdfrac;
632
633 Ok(satrec)
634}
635
636fn validate_elements(elements: &ElementSet) -> Result<(), Error> {
637 if elements.catalog_number > MAX_VALLADO_SATNUM {
638 return Err(invalid_domain("element.catalog_number"));
639 }
640 validate_epoch(elements.epoch)?;
641 validate::finite(elements.bstar, "element.bstar").map_err(map_input_error)?;
642 validate::finite(elements.mean_motion_dot, "element.mean_motion_dot")
643 .map_err(map_input_error)?;
644 validate::finite(
645 elements.mean_motion_double_dot,
646 "element.mean_motion_double_dot",
647 )
648 .map_err(map_input_error)?;
649 validate::finite_in_range_exclusive_upper(
650 elements.eccentricity,
651 0.0,
652 1.0,
653 "element.eccentricity",
654 )
655 .map_err(map_input_error)?;
656 validate::finite(
657 elements.argument_of_perigee_deg,
658 "element.argument_of_perigee_deg",
659 )
660 .map_err(map_input_error)?;
661 validate::finite(elements.inclination_deg, "element.inclination_deg")
662 .map_err(map_input_error)?;
663 validate::finite(elements.mean_anomaly_deg, "element.mean_anomaly_deg")
664 .map_err(map_input_error)?;
665 validate::finite_positive(
666 elements.mean_motion_rev_per_day,
667 "element.mean_motion_rev_per_day",
668 )
669 .map_err(map_input_error)?;
670 validate::finite(elements.right_ascension_deg, "element.right_ascension_deg")
671 .map_err(map_input_error)?;
672
673 Ok(())
674}
675
676fn validate_epoch(epoch: JulianDate) -> Result<(), Error> {
677 validate::finite(epoch.0, "element.epoch.whole").map_err(map_input_error)?;
678 validate::finite(epoch.1, "element.epoch.fraction").map_err(map_input_error)?;
679
680 let total = epoch.0 + epoch.1;
681 validate::finite(total, "element.epoch").map_err(map_input_error)?;
682 if !(0.0..=5_000_000.0).contains(&total) {
683 return Err(invalid_domain("element.epoch"));
684 }
685 Ok(())
686}
687
688fn map_input_error(error: FieldError) -> Error {
689 Error::InvalidInput {
690 field: error.field(),
691 kind: Sgp4InputErrorKind::from(&error),
692 }
693}
694
695fn invalid_domain(field: &'static str) -> Error {
696 Error::InvalidInput {
697 field,
698 kind: Sgp4InputErrorKind::OutOfRange,
699 }
700}
701
702fn map_tle_bridge_error(error: tle::TleError) -> Error {
703 match error {
704 tle::TleError::InvalidField { field, reason } => Error::InvalidInput {
705 field,
706 kind: match reason {
707 "not finite" => Sgp4InputErrorKind::NonFinite,
708 "not positive" => Sgp4InputErrorKind::NotPositive,
709 "negative" => Sgp4InputErrorKind::Negative,
710 "out of range" => Sgp4InputErrorKind::OutOfRange,
711 _ => Sgp4InputErrorKind::OutOfRange,
712 },
713 },
714 other => Error::InvalidTle(other.to_string()),
715 }
716}
717
718fn map_output_error(error: FieldError) -> Error {
719 Error::NonFiniteOutput {
720 field: error.field(),
721 }
722}
723
724#[cfg(test)]
725mod tests {
726 use super::{
727 propagate_elements, ElementSet, Error, JulianDate, MinutesSinceEpoch, Satellite,
728 Sgp4InputErrorKind, MAX_MINUTES_SINCE_EPOCH,
729 };
730
731 #[test]
737 fn non_ascii_tle_returns_invalid_tle_not_panic() {
738 let line1 = "1 25544U 98067A 18184.80969102 .00001614 00000-0 31745-4 0 9993";
739 let line2 = "2 25544 51.6414 295.8524 0003435 262.6267 204.2868 15.54005638121106";
740 assert!(
741 Satellite::from_tle(line1, line2).is_ok(),
742 "clean ASCII TLE must still parse"
743 );
744
745 let mut bad1 = String::from(&line1[..18]);
748 bad1.push('\u{20ac}');
749 bad1.push_str(&line1[19..]);
750 assert!(
751 !bad1.is_char_boundary(20),
752 "corruption must straddle byte 20"
753 );
754
755 let err = Satellite::from_tle(&bad1, line2).expect_err("non-ASCII TLE must not parse");
756 assert!(
757 matches!(err, Error::InvalidTle(_)),
758 "expected a typed InvalidTle error, got: {err:?}"
759 );
760 }
761
762 const ISS_L1: &str = "1 25544U 98067A 18184.80969102 .00001614 00000-0 31745-4 0 9993";
766 const ISS_L2: &str = "2 25544 51.6414 295.8524 0003435 262.6267 204.2868 15.54005638121106";
767
768 fn iss_elements() -> ElementSet {
769 crate::astro::tle::parse(ISS_L1, ISS_L2)
770 .unwrap()
771 .elements
772 .to_element_set()
773 .expect("valid TLE bridge")
774 }
775
776 fn assert_invalid_input<T>(
777 result: Result<T, Error>,
778 field: &'static str,
779 kind: Sgp4InputErrorKind,
780 ) {
781 match result {
782 Err(Error::InvalidInput {
783 field: actual_field,
784 kind: actual_kind,
785 }) => {
786 assert_eq!(actual_field, field);
787 assert_eq!(actual_kind, kind);
788 }
789 Err(err) => panic!("expected InvalidInput({field}, {kind}), got {err:?}"),
790 Ok(_) => panic!("expected InvalidInput({field}, {kind}), got Ok"),
791 }
792 }
793
794 fn assert_same(a: &Satellite, b: &Satellite) {
797 let (ea, eb) = (a.epoch_jd(), b.epoch_jd());
798 assert_eq!(
799 (ea.0.to_bits(), ea.1.to_bits()),
800 (eb.0.to_bits(), eb.1.to_bits()),
801 "epoch JD differs"
802 );
803 for &t in &[0.0, 100.0, 1440.0] {
804 let pa = a.propagate(MinutesSinceEpoch(t)).unwrap();
805 let pb = b.propagate(MinutesSinceEpoch(t)).unwrap();
806 for axis in 0..3 {
807 assert_eq!(
808 pa.position[axis].to_bits(),
809 pb.position[axis].to_bits(),
810 "position[{axis}] differs at t={t}"
811 );
812 assert_eq!(
813 pa.velocity[axis].to_bits(),
814 pb.velocity[axis].to_bits(),
815 "velocity[{axis}] differs at t={t}"
816 );
817 }
818 }
819 }
820
821 #[cfg(feature = "serde")]
822 #[test]
823 fn serde_round_trips_tle_satellites() {
824 let sat = Satellite::from_tle(ISS_L1, ISS_L2).unwrap();
825 let encoded = serde_json::to_string(&sat).unwrap();
826 assert!(encoded.contains("\"line1\""));
827 assert!(encoded.contains("\"line2\""));
828 assert!(!encoded.contains("\"elements\""));
829
830 let decoded: Satellite = serde_json::from_str(&encoded).unwrap();
831 assert_eq!(decoded.line1(), ISS_L1);
832 assert_eq!(decoded.line2(), ISS_L2);
833 assert_same(&sat, &decoded);
834 }
835
836 #[cfg(feature = "serde")]
837 #[test]
838 fn serde_round_trips_element_built_satellites() {
839 let elements = iss_elements();
840 let sat = Satellite::from_elements(&elements).unwrap();
841 let encoded = serde_json::to_string(&sat).unwrap();
842 assert!(encoded.contains("\"elements\""));
843 assert!(encoded.contains("\"opsmode\""));
844 assert!(!encoded.contains("\"line1\""));
845 assert!(!encoded.contains("\"line2\""));
846
847 let decoded: Satellite = serde_json::from_str(&encoded).unwrap();
848 assert!(decoded.line1().is_empty());
849 assert!(decoded.line2().is_empty());
850 assert_same(&sat, &decoded);
851 }
852
853 #[test]
854 fn from_elements_rejects_non_finite_fields_before_sgp4init() {
855 let mut elements = iss_elements();
856 elements.bstar = f64::NAN;
857
858 assert_invalid_input(
859 Satellite::from_elements(&elements),
860 "element.bstar",
861 Sgp4InputErrorKind::NonFinite,
862 );
863 }
864
865 #[test]
866 fn from_elements_rejects_sgp4_domain_before_sgp4init() {
867 let mut elements = iss_elements();
868 elements.mean_motion_rev_per_day = 0.0;
869 assert_invalid_input(
870 Satellite::from_elements(&elements),
871 "element.mean_motion_rev_per_day",
872 Sgp4InputErrorKind::NotPositive,
873 );
874
875 let mut elements = iss_elements();
876 elements.eccentricity = -0.1;
877 assert_invalid_input(
878 Satellite::from_elements(&elements),
879 "element.eccentricity",
880 Sgp4InputErrorKind::OutOfRange,
881 );
882
883 let mut elements = iss_elements();
884 elements.eccentricity = 1.0;
885 assert_invalid_input(
886 Satellite::from_elements(&elements),
887 "element.eccentricity",
888 Sgp4InputErrorKind::OutOfRange,
889 );
890
891 let mut elements = iss_elements();
892 elements.catalog_number = 100_000;
893 assert_invalid_input(
894 Satellite::from_elements(&elements),
895 "element.catalog_number",
896 Sgp4InputErrorKind::OutOfRange,
897 );
898 }
899
900 #[test]
901 fn from_elements_rejects_invalid_epoch() {
902 let mut elements = iss_elements();
903 elements.epoch = JulianDate(f64::NAN, 0.0);
904 assert_invalid_input(
905 Satellite::from_elements(&elements),
906 "element.epoch.whole",
907 Sgp4InputErrorKind::NonFinite,
908 );
909
910 let mut elements = iss_elements();
911 elements.epoch = JulianDate(9_000_000.0, 0.0);
912 assert_invalid_input(
913 Satellite::from_elements(&elements),
914 "element.epoch",
915 Sgp4InputErrorKind::OutOfRange,
916 );
917 }
918
919 #[test]
920 fn from_elements_accepts_full_julian_epoch() {
921 let mut elements = iss_elements();
922 elements.epoch = super::sgp4_julian_date_from_calendar(2057, 1, 1, 0, 0, 0.0);
923 Satellite::from_elements(&elements).expect("full 2057 epoch is valid");
924 }
925
926 #[test]
927 fn from_tle_accepts_epoch_after_parser_conversion_to_full_jd() {
928 let mut line1 = ISS_L1.to_string();
929 line1.replace_range(18..32, "19366.00000000");
930
931 Satellite::from_tle(&line1, ISS_L2).expect("TLE epoch is converted to full JD");
932 }
933
934 #[test]
935 fn propagation_rejects_non_finite_time_inputs() {
936 let sat = Satellite::from_tle(ISS_L1, ISS_L2).unwrap();
937 assert_invalid_input(
938 sat.propagate(MinutesSinceEpoch(f64::NAN)),
939 "minutes_since_epoch",
940 Sgp4InputErrorKind::NonFinite,
941 );
942 assert_invalid_input(
943 sat.propagate_jd(JulianDate(f64::INFINITY, 0.0)),
944 "julian_date.whole",
945 Sgp4InputErrorKind::NonFinite,
946 );
947
948 let elements = iss_elements();
949 assert_invalid_input(
950 propagate_elements(&elements, MinutesSinceEpoch(f64::INFINITY)),
951 "minutes_since_epoch",
952 Sgp4InputErrorKind::NonFinite,
953 );
954 }
955
956 #[test]
957 fn propagation_rejects_out_of_domain_time_inputs() {
958 let sat = Satellite::from_tle(ISS_L1, ISS_L2).unwrap();
959 assert_invalid_input(
960 sat.propagate(MinutesSinceEpoch(MAX_MINUTES_SINCE_EPOCH.next_up())),
961 "minutes_since_epoch",
962 Sgp4InputErrorKind::OutOfRange,
963 );
964 assert_invalid_input(
965 sat.propagate_jd(JulianDate(2_458_304.0, 1.0)),
966 "julian_date.fraction",
967 Sgp4InputErrorKind::OutOfRange,
968 );
969 }
970
971 #[test]
972 fn lenient_trailing_whitespace_and_content_past_col_69() {
973 let clean = Satellite::from_tle(ISS_L1, ISS_L2).unwrap();
974
975 let pad = Satellite::from_tle(&format!("{ISS_L1} "), &format!("{ISS_L2}\t ")).unwrap();
977 assert_same(&clean, &pad);
978
979 let extra =
982 Satellite::from_tle(&format!("{ISS_L1} EXTRA-JUNK"), &format!("{ISS_L2} 999999"))
983 .unwrap();
984 assert_same(&clean, &extra);
985 }
986
987 #[test]
988 fn lenient_leading_dot_and_assumed_decimal_fields() {
989 let sat = Satellite::from_tle(ISS_L1, ISS_L2).unwrap();
994 let p = sat.propagate(MinutesSinceEpoch(0.0)).unwrap();
995 let r = (p.position[0].powi(2) + p.position[1].powi(2) + p.position[2].powi(2)).sqrt();
996 assert!(
997 (6500.0..=7200.0).contains(&r),
998 "ISS radius {r} km outside LEO"
999 );
1000 }
1001
1002 #[test]
1003 fn lenient_missing_optional_bookkeeping_fields() {
1004 let l1: String = ISS_L1
1009 .char_indices()
1010 .map(|(i, c)| {
1011 if i == 62 || (64..=67).contains(&i) {
1012 ' '
1013 } else {
1014 c
1015 }
1016 })
1017 .collect();
1018 let l2: String = ISS_L2
1019 .char_indices()
1020 .map(|(i, c)| if (63..=67).contains(&i) { ' ' } else { c })
1021 .collect();
1022 let clean = Satellite::from_tle(ISS_L1, ISS_L2).unwrap();
1025 let blanked = Satellite::from_tle(&l1, &l2).unwrap();
1026 assert_same(&clean, &blanked);
1027 }
1028
1029 #[test]
1030 fn three_line_form_strips_name_line() {
1031 let clean = Satellite::from_tle(ISS_L1, ISS_L2).unwrap();
1032
1033 let block = format!("ISS (ZARYA)\n{ISS_L1}\n{ISS_L2}\n");
1034 let three = Satellite::from_3line(&block).unwrap();
1035 assert_same(&clean, &three);
1036
1037 let two = Satellite::from_3line(&format!("{ISS_L1}\n{ISS_L2}")).unwrap();
1039 assert_same(&clean, &two);
1040 }
1041
1042 #[test]
1043 fn three_line_form_rejects_block_without_element_lines() {
1044 assert!(Satellite::from_3line("just a name\nand some text").is_err());
1045 assert!(Satellite::from_3line("").is_err());
1046 }
1047
1048 #[test]
1049 fn rejects_genuine_corruption() {
1050 assert!(Satellite::from_tle("", "").is_err());
1052 assert!(Satellite::from_tle("hello world", "goodbye world").is_err());
1054 assert!(Satellite::from_tle(ISS_L2, ISS_L1).is_err());
1056 let l2_wrong = "2 25545 51.6414 295.8524 0003435 262.6267 204.2868 15.54005638121106";
1058 assert!(matches!(
1059 Satellite::from_tle(ISS_L1, l2_wrong),
1060 Err(Error::InvalidTle(_))
1061 ));
1062 }
1063}