Skip to main content

satkit/tle/
mod.rs

1use crate::sgp4::SatRec;
2use crate::Instant;
3use crate::TimeScale;
4
5use crate::sgp4::{SGP4InitArgs, SGP4Source};
6
7// TLE fitting from state vectors
8mod fitting;
9
10use anyhow::{bail, Context, Result};
11
12// 'I' and 'O' are not part of the allowed chars to avoid any confusion with 0 or 1
13const ALPHA5_MATCHING: &str = "ABCDEFGHJKLMNPQRSTUVWXYZ";
14
15///
16/// Structure representing a Two-Line Element Set (TLE), a satellite
17/// ephemeris format from the 1970s that is still somehow in use
18/// today and can be used to calculate satellite position and
19/// velocity in the "TEME" frame (not-quite GCRF) using the
20/// "Simplified General Perturbations-4" (SGP-4) mathematical
21/// model that is also included in this package.
22///
23/// For details, see: <https://en.wikipedia.org/wiki/Two-line_element_set>
24///
25/// The TLE format is still commonly used to represent satellite
26/// ephemerides, and satellite ephemerides catalogs in this format
27/// are publicly available at www.space-track.org (registration
28/// required)
29///
30/// TLEs sometimes have a "line 0" that includes the name of the satellite
31///
32/// # Example Usage:
33///
34///
35/// ```
36/// use satkit::TLE;
37/// use satkit::Instant;
38/// use satkit::sgp4::sgp4;
39/// use satkit::frametransform;
40/// use satkit::itrfcoord::ITRFCoord;
41///
42/// let lines = vec!["0 INTELSAT 902",
43///     "1 26900U 01039A   06106.74503247  .00000045  00000-0  10000-3 0  8290",
44///     "2 26900   0.0164 266.5378 0003319  86.1794 182.2590  1.00273847 16981   9300."];
45///
46/// let mut tle = TLE::load_3line(lines[0], lines[1], lines[2]).unwrap();
47/// let tm = Instant::from_datetime(2006, 5, 1, 11, 0, 0.0).unwrap();
48///
49/// // Use SGP4 to get position,
50/// let states = sgp4(&mut tle, &[tm]).unwrap();
51///
52/// println!("pTEME = {}", states.pos);
53/// // Rotate the position to the ITRF frame (Earth-fixed)
54/// // Since pTEME is a 3xN array where N is the number of times
55/// // (we are just using a single time)
56/// // we need to convert to a fixed matrix to rotate
57/// let pITRF = frametransform::qteme2itrf(&tm) * states.pos.fixed_view::<3,1>(0,0);
58///
59/// println!("pITRF = {}", pITRF);
60///
61/// // Convert to an "ITRF Coordinate" and print geodetic position
62/// let itrf = ITRFCoord::from_slice(&states.pos.fixed_view::<3,1>(0,0).as_slice()).unwrap();
63///
64/// println!("latitude = {} deg", itrf.latitude_deg());
65/// println!("longitude = {} deg", itrf.longitude_deg());
66/// println!("altitude = {} m", itrf.hae());
67///
68/// ```
69///
70///
71#[derive(Clone, Debug, PartialEq, PartialOrd)]
72pub struct TLE {
73    /// Name of satellite
74    pub name: String,
75    /// String describing launch
76    pub intl_desig: String,
77    /// Satellite NORAD number
78    pub sat_num: i32,
79    /// Launch year
80    pub desig_year: i32,
81    /// Numbered launch of year
82    pub desig_launch: i32,
83    /// Piece of launch
84    pub desig_piece: String,
85    /// TLE epoch
86    pub epoch: Instant,
87    /// One half of 1st derivative of mean motion wrt time, in revs/day^2
88    pub mean_motion_dot: f64,
89    /// One sixth of 2nd derivative of mean motion wrt tim, in revs/day^3
90    pub mean_motion_dot_dot: f64,
91    /// Starred ballistic coefficient, in units of inverse Earth radii
92    pub bstar: f64,
93    /// Usually 0
94    pub ephem_type: u8,
95    /// Bulliten number
96    pub element_num: i32,
97    /// Inclination, degrees
98    pub inclination: f64,
99    /// Right ascension of ascending node, degrees
100    pub raan: f64,
101    /// Eccentricity
102    pub eccen: f64,
103    /// Argument of perigee, degrees
104    pub arg_of_perigee: f64,
105    /// Mean anomaly, degrees
106    pub mean_anomaly: f64,
107    /// Mean motion, revs / day
108    pub mean_motion: f64,
109    /// Revolution number
110    pub rev_num: i32,
111
112    pub(crate) satrec: Option<SatRec>,
113}
114
115impl SGP4Source for TLE {
116    fn epoch(&self) -> Instant {
117        self.epoch
118    }
119
120    fn satrec_mut(&mut self) -> &mut Option<SatRec> {
121        &mut self.satrec
122    }
123
124    fn sgp4_init_args(&self) -> anyhow::Result<SGP4InitArgs> {
125        use std::f64::consts::PI;
126
127        const TWOPI: f64 = PI * 2.0;
128
129        Ok(SGP4InitArgs {
130            // Vallado expects JD UTC and then subtracts 2433281.5 inside the legacy interface.
131            jdsatepoch: self.epoch.as_jd_with_scale(TimeScale::UTC),
132            bstar: self.bstar,
133            // Convert rev/day(+derivatives) to rad/min(+derivatives), matching sgp4_impl.
134            no: self.mean_motion / (1440.0 / TWOPI),
135            ndot: self.mean_motion_dot / (1440.0 * 1440.0 / TWOPI),
136            nddot: self.mean_motion_dot_dot / (1440.0 * 1440.0 * 1440.0 / TWOPI),
137            ecco: self.eccen,
138            inclo: self.inclination.to_radians(),
139            nodeo: self.raan.to_radians(),
140            argpo: self.arg_of_perigee.to_radians(),
141            mo: self.mean_anomaly.to_radians(),
142        })
143    }
144}
145
146impl TLE {
147    /// Load a vector of strings representing Two-Line Element Set (TLE) lines into a vector of
148    /// TLE structures.
149    /// This function will call [`Self::load_2line`] respectively [`Self::load_3line`] as required
150    /// for each TLE entry it encounters in the input lines.
151    ///
152    /// Those TLEs can then be used to compute satellite position and
153    /// velocity as a function of time.
154    ///
155    /// For details, see [here](https://en.wikipedia.org/wiki/Two-line_element_set)
156    ///
157    /// # Arguments:
158    ///   * `lines` - a reference to a [`Vec`] of [`String`] representing TLE lines
159    ///
160    /// # Returns:
161    ///  * A [`Vec`] of [`TLE`] objects or string indicating error condition
162    ///
163    /// # Example
164    ///
165    /// ```
166    /// use satkit::TLE;
167    ///
168    /// let lines = vec![
169    ///     "2 PATHFINDER".to_string(),
170    ///     "1 45727U 20037E   24323.73967089  .00003818  00000+0  31595-3 0  9995".to_string(),
171    ///     "2 45727  97.7798 139.6782 0011624 329.2427  30.8113 14.99451155239085".to_string(),
172    ///     "0 SHINSEI (MS-F2)".to_string(),
173    ///     "1  5485U 71080A   24324.43728894  .00000099  00000-0  13784-3 0  9992".to_string(),
174    ///     "2  5485  32.0564  70.0187 0639723 198.9447 158.6281 12.74214074476065".to_string(),
175    /// ];
176    ///
177    /// let tles = TLE::from_lines(&lines).unwrap();
178    ///
179    /// ```
180    pub fn from_lines(lines: &[String]) -> Result<Vec<Self>> {
181        let mut tles: Vec<Self> = Vec::<Self>::new();
182        let empty: &String = &String::new();
183        let mut line0: &String = empty;
184        let mut line1: &String = empty;
185        let mut line2: &String;
186
187        for line in lines {
188            if line.len() < 2 {
189                continue;
190            }
191            if line.chars().nth(0).unwrap() == '1'
192                && line.chars().nth(1).unwrap() == ' '
193                && line.len() == 69
194            {
195                line1 = line;
196            } else if line.chars().nth(0).unwrap() == '2'
197                && line.chars().nth(1).unwrap() == ' '
198                && line.len() == 69
199            {
200                line2 = line;
201                if line0.is_empty() {
202                    tles.push(Self::load_2line(line1, line2)?);
203                } else {
204                    tles.push(Self::load_3line(line0, line1, line2)?);
205                }
206                line0 = empty;
207                line1 = empty;
208            } else {
209                line0 = line;
210            }
211        }
212
213        Ok(tles)
214    }
215
216    ///
217    /// Return a default empty TLE.  Note that values are invalid.
218    ///
219    pub fn new() -> Self {
220        Self {
221            name: "none".to_string(),
222            intl_desig: "".to_string(),
223            sat_num: 0,
224            desig_year: 0,
225            desig_launch: 0,
226            desig_piece: "A".to_string(),
227            epoch: Instant::J2000,
228            mean_motion_dot: 0.0,
229            mean_motion_dot_dot: 0.0,
230            bstar: 0.0,
231            ephem_type: b'U',
232            element_num: 0,
233            inclination: 0.0,
234            raan: 0.0,
235            eccen: 0.0,
236            arg_of_perigee: 0.0,
237            mean_anomaly: 0.0,
238            mean_motion: 0.0,
239            rev_num: 0,
240            satrec: None,
241        }
242    }
243
244    /// Load 3 lines as string into a structure representing
245    /// a Two-Line Element Set  (TLE)
246    ///
247    /// The TLE can then be used to compute satellite position and
248    /// velocity as a function of time.
249    ///
250    /// For details, see [here](https://en.wikipedia.org/wiki/Two-line_element_set)
251    ///
252    /// # Arguments:
253    ///
254    ///   * `line0` - the "0"th line of the TLE, which sometimes contains
255    ///     the satellite name
256    ///   * `line1` - the 1st line of TLE
257    ///   * `line2` - the 2nd line of the TLE
258    ///
259    /// # Returns:
260    ///
261    ///  * A TLE object or string indicating error condition
262    ///
263    /// # Example
264    ///
265    /// ```
266    ///
267    /// use satkit::TLE;
268    /// let line0: &str = "0 INTELSAT 902";
269    /// let line1: &str = "1 26900U 01039A   06106.74503247  .00000045  00000-0  10000-3 0  8290";
270    /// let line2: &str = "2 26900   0.0164 266.5378 0003319  86.1794 182.2590  1.00273847 16981   9300.";
271    /// let tle = TLE::load_3line(&line0.to_string(),
272    ///     &line1.to_string(),
273    ///     &line2.to_string()
274    ///     ).unwrap();
275    ///
276    /// ```
277    ///
278    pub fn load_3line(line0: &str, line1: &str, line2: &str) -> Result<Self> {
279        if line1.len() < 69 || line2.len() < 69 {
280            bail!(
281                "Invalid TLE line lengths: line1 = {}, line2 = {}",
282                line1.len(),
283                line2.len()
284            );
285        }
286
287        match Self::load_2line(line1, line2) {
288            Ok(mut tle) => {
289                tle.name = {
290                    if line0.len() > 2 && line0.chars().nth(0).unwrap() == '0' {
291                        line0[2..].to_string()
292                    } else {
293                        String::from(line0)
294                    }
295                };
296                Ok(tle)
297            }
298            Err(e) => Err(e),
299        }
300    }
301
302    /// Load 2 lines as strings into a structure representing
303    /// a Two-Line Element Set  (TLE)
304    ///
305    /// The TLE can then be used to compute satellite position and
306    /// velocity as a function of time.
307    ///
308    /// For details, see [here](https://en.wikipedia.org/wiki/Two-line_element_set)
309    ///
310    /// # Arguments:
311    ///
312    ///   * `line1` - the 1st line of TLE
313    ///   * `line2` - the 2nd line of the TLE
314    ///
315    /// # Returns:
316    ///
317    ///  * A TLE object or string indicating error condition
318    ///
319    /// # Example
320    ///
321    /// ```
322    ///
323    /// use satkit::TLE;
324    /// let line1: &str = "1 26900U 01039A   06106.74503247  .00000045  00000-0  10000-3 0  8290";
325    /// let line2: &str = "2 26900   0.0164 266.5378 0003319  86.1794 182.2590  1.00273847 16981   9300.";
326    /// let tle = TLE::load_2line(
327    ///     &line1.to_string(),
328    ///     &line2.to_string()
329    ///     ).unwrap();
330    ///
331    /// ```
332    ///
333    pub fn load_2line(line1: &str, line2: &str) -> Result<Self> {
334        if line1.len() < 69 {
335            bail!(
336                "Line 1 too short: expected 69 characters, got {}",
337                line1.len()
338            );
339        }
340        if line2.len() < 69 {
341            bail!(
342                "Line 2 too short: expected 69 characters, got {}",
343                line2.len()
344            );
345        }
346
347        let mut year: u32 = {
348            let mut mstr: String = "1".to_owned();
349            mstr.push_str(&line1[18..20]);
350            let mut s = mstr.parse().context("Could not parse year")?;
351            s -= 100;
352            s
353        };
354        // See: https://celestrak.org/columns/v04n03/
355        // Years >= 1957 = 1900s
356        // Years < 1957 = 2000s
357        let century = if year >= 57 { 1900 } else { 2000 };
358        year += century;
359        let day_of_year: f64 = line1[20..32]
360            .parse()
361            .context("Could not parse day of year")?;
362
363        // Note: day_of_year starts from 1, not zero,
364        // also, go from Jan 2 to avoid leap-second
365        // issues, hence the "-2" at end
366        let epoch = Instant::from_date(year as i32, 1, 2)
367            .context("Invalid year, month, or day")?
368            .add_utc_days(day_of_year - 2.0);
369
370        Ok(Self {
371            name: "none".to_string(),
372            sat_num: Self::alpha5_to_int(&line1[2..7])
373                .context("Could not parse satellite number")?,
374
375            intl_desig: { line1[9..16].trim().to_string() },
376            desig_year: { line1[9..11].trim().parse().unwrap_or(70) },
377            desig_launch: { line1[11..14].trim().parse().unwrap_or_default() },
378            desig_piece: line1[14..18]
379                .trim()
380                .parse()
381                .context("Could not parse desig_piece")?,
382
383            epoch,
384            mean_motion_dot: {
385                let mut mstr: String = "0".to_owned();
386                mstr.push_str(&line1[34..43]);
387                let mut m = mstr.parse().context("Could not parse mean motion dot")?;
388                if line1.chars().nth(33).unwrap() == '-' {
389                    m *= -1.0;
390                }
391                m
392            },
393            mean_motion_dot_dot: {
394                let mut mstr: String = "0.".to_owned();
395                mstr.push_str(&line1[45..50]);
396                mstr.push('E');
397                mstr.push_str(&line1[50..53]);
398                let mut m = mstr
399                    .trim()
400                    .parse()
401                    .context("Coudl not parse mean motion dot dot")?;
402                if line1.chars().nth(44).unwrap() == '-' {
403                    m *= -1.0;
404                }
405                m
406            },
407            bstar: {
408                let mut mstr: String = "0.".to_owned();
409                mstr.push_str(&line1[54..59]);
410                mstr.push('E');
411                mstr.push_str(&line1[59..62]);
412                let mut m = mstr
413                    .trim()
414                    .parse()
415                    .context("Could not parse bstar (drag)")?;
416                if line1.chars().nth(53).unwrap() == '-' {
417                    m *= -1.0;
418                }
419                m
420            },
421            ephem_type: { line1[62..63].trim().parse().unwrap_or_default() },
422            element_num: line1[64..68]
423                .trim()
424                .parse()
425                .context("Could not parse element number")?,
426
427            inclination: line2[8..16]
428                .trim()
429                .parse()
430                .context("Could not parse inclination")?,
431
432            raan: line2[17..25]
433                .trim()
434                .parse()
435                .context("Could not parse raan")?,
436
437            eccen: {
438                let mut mstr: String = "0.".to_owned();
439                mstr.push_str(&line2[26..33]);
440                mstr.trim()
441                    .parse()
442                    .context("Could not parse eccentricity")?
443            },
444            arg_of_perigee: line2[34..42]
445                .trim()
446                .parse()
447                .context("Could not parse arg of perigee")?,
448
449            mean_anomaly: line2[42..51]
450                .trim()
451                .parse()
452                .context("Could not parse mean anomaly")?,
453
454            mean_motion: line2[52..63]
455                .trim()
456                .parse()
457                .context("Could not parse mean motion")?,
458
459            rev_num: line2[63..68]
460                .trim()
461                .parse()
462                .context("Could not parse rev num")?,
463            satrec: None,
464        })
465    }
466
467    /// Format this TLE back into the two canonical 69-char lines.
468    ///
469    /// # Returns:
470    ///
471    /// * `lines` - Result with OK value containing 2-element array of two strings representing the TLE lines
472    ///
473    /// # Example:
474    ///
475    /// ```rust
476    /// let lines = [
477    ///     "ISS (ZARYA)".to_string(),
478    ///     "1 B5544U 98067A   24356.58519896  .00014389  00000-0  25222-3 0  9990".to_string(),
479    ///     "2 B5544  51.6403 106.8969 0007877   6.1421 113.2479 15.50801739487613".to_string(),
480    /// ];
481    /// // Construct the TLE from the lines
482    /// let tle = satkit::TLE::from_lines(&lines).unwrap()[0].clone();
483    ///
484    /// // Show that we can re-create the same lines
485    /// assert_eq!(tle.to_2line().unwrap()[0], lines[1]);
486    /// assert_eq!(tle.to_2line().unwrap()[1], lines[2]);
487    /// ```
488    ///
489    pub fn to_2line(&self) -> Result<[String; 2]> {
490        // Epoch as (YY, DOY.fraction)
491        let (yy, doy) = self.epoch_to_tle_ydoy()?;
492
493        // Satellite number in alpha5
494        let sat_alpha5 = Self::int_to_alpha5(self.sat_num)?;
495
496        // Format ndot/2, nddot/6, bstar with correct implied fields
497        let (ndot_sign, ndot_body) = tle_formatter::format_ndot(self.mean_motion_dot);
498        let (nddot_sign, nddot_mant, nddot_exp2) =
499            tle_formatter::format_implied(self.mean_motion_dot_dot);
500        let (bstar_sign, bstar_mant, bstar_exp2) = tle_formatter::format_implied(self.bstar);
501
502        // Ephemeris type as a single digit '0'..'9'
503        let et = if (0..=9).contains(&self.ephem_type) {
504            char::from(b'0' + self.ephem_type)
505        } else {
506            '0'
507        };
508
509        // ------- Build Line 1 -------
510        let sat5 = format!("{:<5}", sat_alpha5); // cols 3-7
511
512        // International designator triplet.
513        // Last 2 digits of launch year, 3-digit launch number within year, 3-character piece identifier.
514        // Never decoded, so no need to re-encode.
515        let desig = format!("{:<8}", self.intl_desig); // cols 10-17
516
517        let epoch = format!("{:0>2}{:012.8}", yy, doy); // cols 19-32
518        let ndot = format!("{}{}", ndot_sign, ndot_body); // cols 34-43 (10 chars total)
519        let nddot = format!("{}{}{}", nddot_sign, nddot_mant, nddot_exp2); // cols 45-52 (8 chars)
520        let bstar = format!("{}{}{}", bstar_sign, bstar_mant, bstar_exp2); // cols 54-61 (8 chars)
521        let elem_no = format!("{:>4}", self.element_num.max(0)); // cols 65-68
522
523        let mut l1 = format!("1 {sat5}U {desig} {epoch} {ndot} {nddot} {bstar} {et} {elem_no}");
524
525        let cksum1 = tle_formatter::tle_checksum(&l1);
526        l1.push(char::from(b'0' + cksum1));
527
528        // ------- Build Line 2 -------
529        let incl = format!("{:8.4}", self.inclination);
530        let raan = format!("{:8.4}", self.raan);
531        let ecc7 = format!("{:0>7}", (self.eccen.abs() * 1.0e7 + 0.5).floor() as u64);
532        let argp = format!("{:8.4}", self.arg_of_perigee);
533        let mean_anom = format!("{:8.4}", self.mean_anomaly);
534        let n = format!("{:11.8}", self.mean_motion);
535        let rev = format!("{:>5}", self.rev_num.max(0));
536
537        let mut l2 = format!("2 {sat_alpha5:<5} {incl} {raan} {ecc7} {argp} {mean_anom} {n}{rev}");
538
539        // Ensure 68 chars before checksum (line-2 is stable with these widths)
540        if l2.len() != 68 {
541            if l2.len() < 68 {
542                l2.push_str(&" ".repeat(68 - l2.len()));
543            } else {
544                l2.truncate(68);
545            }
546        }
547        let cksum2 = tle_formatter::tle_checksum(&l2);
548        l2.push(char::from(b'0' + cksum2));
549
550        Ok([l1, l2])
551    }
552
553    /// Convenience: include "line 0" (name) above the two TLE lines.
554    ///
555    /// Format this TLE back into name line plus two canonical 69-char lines.
556    ///
557    /// # Returns:
558    ///
559    /// * `lines` - Result with OK value containing 3-element array of name line as string and
560    ///   two strings representing the TLE lines
561    ///
562    /// # Example:
563    ///
564    /// ```rust
565    /// let lines = [
566    ///     "ISS (ZARYA)".to_string(),
567    ///     "1 B5544U 98067A   24356.58519896  .00014389  00000-0  25222-3 0  9990".to_string(),
568    ///     "2 B5544  51.6403 106.8969 0007877   6.1421 113.2479 15.50801739487613".to_string(),
569    /// ];
570    /// // Construct the TLE from the lines
571    /// let tle = satkit::TLE::from_lines(&lines).unwrap()[0].clone();
572    ///
573    /// // Show that we can re-create the same lines
574    /// assert_eq!(tle.to_3line().unwrap()[0], lines[0]);
575    /// assert_eq!(tle.to_3line().unwrap()[1], lines[1]);
576    /// assert_eq!(tle.to_3line().unwrap()[2], lines[2]);
577    /// ```
578    //////
579    pub fn to_3line(&self) -> Result<[String; 3]> {
580        let [l1, l2] = self.to_2line()?;
581        Ok([self.name.clone(), l1, l2])
582    }
583
584    /// Compute (two-digit year, fractional day-of-year) from epoch.
585    fn epoch_to_tle_ydoy(&self) -> Result<(u8, f64)> {
586        let (year, _, _, _, _, _) = self.epoch.as_datetime();
587
588        if !(1957..=2056).contains(&year) {
589            bail!("Year out of range for TLE: {}", year);
590        }
591
592        // Day-of-year.
593        let doy_int = self.epoch.day_of_year();
594
595        // Fraction of day.
596        // Note: This works with days that have leap seconds
597        // (in which second of day is normalized to 86401 instead of 86400).
598        let frac = self.epoch.as_mjd_utc() % 1.0;
599        let doy = (doy_int as f64) + frac;
600        // Years >= 1957 = 1900s
601        // Years < 1957 = 2000s
602        // See: https://celestrak.org/columns/v04n03/
603        let century = if year >= 1957 { 1900 } else { 2000 };
604        let year = ((year - century) % 100) as u8;
605        Ok((year, doy))
606    }
607
608    /// Convert an alpha5 formated Satellite Catalog Number, also known as NORAD ID, to a plain
609    /// numerical ID.
610    ///
611    /// 5 digit NORAD IDs are getting exhausted while many formats, like TLE, rely on them being
612    /// limited to 5 characters. Thus the introduction of the alpha 5 format.
613    ///
614    /// Up to number 99999 plain numerical id and alpha5 are identicial. Starting with 100000 the
615    /// alpha5 string uses a character instead of the first digit to handle satellite numbers
616    /// in the 100000 to 339999 range.
617    /// 'I' and 'O' are not part of the allowed chars to avoid any confusion with 0 or 1
618    ///
619    /// # Arguments:
620    ///  * `alpha5` - a reference to a str representing an alpha5 encoded satellite number.
621    ///
622    /// # Returns:
623    ///  * An i32 of the plain numerical satellite number or string indicating error condition
624    ///
625    /// # Example
626    /// ```
627    /// use satkit::TLE;
628    ///
629    /// let sat_num = TLE::alpha5_to_int("S9994");
630    /// // sat_num has the value 269994
631    /// ```
632    pub fn alpha5_to_int(alpha5: &str) -> Result<i32> {
633        match alpha5.chars().nth(0) {
634            // Alpha char is only possible at the first position, so if the first char is a
635            // digit or a whitespace the standard `.parse()` can be used.
636            Some(c) if c.is_ascii_digit() || c.is_whitespace() => match alpha5.trim().parse() {
637                Ok(i) => Ok(i),
638                Err(e) => bail!("Invalid sat num: {}", e.to_string()),
639            },
640            Some(c) if c.is_alphabetic() => {
641                match ALPHA5_MATCHING
642                    .chars()
643                    .position(|m| m == c.to_ascii_uppercase())
644                {
645                    Some(p) => match alpha5[1..].parse::<i32>() {
646                        Ok(i) => Ok((p as i32 + 10) * 10000 + i),
647                        Err(e) => bail!("Invalid sat num: {}", e.to_string()),
648                    },
649                    None => bail!("Invalid first digit in sat num: {}", c),
650                }
651            }
652            Some(c) => bail!("Invalid first digit in sat num: {}", c),
653            None => bail!("Parse error"),
654        }
655    }
656
657    /// Convert a numerical Satellite Catalog Number, also known as NORAD ID, to an alpha5 String.
658    ///
659    /// 5 digit NORAD IDs are getting exhausted while many formats, like TLE, rely on them being
660    /// limited to 5 characters. Thus the introduction of the alpha 5 format.
661    ///
662    /// Up to number 99999 plain numerical id and alpha5 are identicial. Starting with 100000 the
663    /// alpha5 string uses a character instead of the first digit to handle satellite numbers
664    /// in the 100000 to 339999 range.
665    /// 'I' and 'O' are not part of the allowed chars to avoid any confusion with 0 or 1
666    ///
667    /// # Arguments:
668    ///  * `sat_num` - An i32 of a plain numerical satellite number
669    ///
670    /// # Returns:
671    ///   * A String representing an alpha5 encoded satellite number or string indicating error
672    ///     condition
673    ///
674    /// # Example
675    /// ```
676    /// use satkit::TLE;
677    ///
678    /// let alpha5_sat_num = TLE::int_to_alpha5(269994);
679    /// // alpha5_sat_num has the String value "S9994"
680    /// ```
681    pub fn int_to_alpha5(sat_num: i32) -> Result<String> {
682        match sat_num {
683            i @ 0..=99999 => Ok(format!("{:0>5}", i)),
684            i @ 100000..=339999 => {
685                let c = ALPHA5_MATCHING
686                    .chars()
687                    .nth(i as usize / 10000 - 10)
688                    .unwrap();
689                Ok(format!("{c}{:0>4}", i % 10000))
690            }
691            _i @ 340000.. => bail!("Sat num >= 340000 cannot be represented in alpha5 format"),
692            _ => bail!("Invalid sat num value"),
693        }
694    }
695
696    /// Return a string representation of the TLE
697    /// in a human-readable format
698    ///
699    /// # Example
700    ///
701    /// ```
702    /// use satkit::TLE;
703    /// let line0: &str = "0 INTELSAT 902";
704    /// let line1: &str = "1 26900U 01039A   06106.74503247  .00000045  00000-0  10000-3 0  8290";
705    /// let line2: &str = "2 26900   0.0164 266.5378 0003319  86.1794 182.2590  1.00273847 16981";
706    /// let tle = TLE::load_3line(&line0.to_string(),
707    ///    &line1.to_string(),
708    ///   &line2.to_string()
709    /// ).unwrap();
710    /// println!("{}", tle.to_pretty_string());
711    /// ```
712    ///
713    pub fn to_pretty_string(&self) -> String {
714        format!(
715            r#"
716            TLE: {}
717                         NORAD ID: {},
718                      Launch Year: {},
719                            Epoch: {},
720                  Mean Motion Dot: {} revs / day^2,
721              Mean Motion Dot Dot: {} revs / day^3,
722                             Drag: {},
723                      Inclination: {} deg,
724                             RAAN: {} deg,
725                            eccen: {},
726                   Arg of Perigee: {} deg,
727                     Mean Anomaly: {} deg,
728                      Mean Motion: {} revs / day
729                            Rev #: {}
730        "#,
731            self.name,
732            Self::int_to_alpha5(self.sat_num).unwrap(),
733            match self.desig_year > 50 {
734                true => self.desig_year + 1900,
735                false => self.desig_year + 2000,
736            },
737            self.epoch,
738            self.mean_motion_dot * 2.0,
739            self.mean_motion_dot_dot * 6.0,
740            self.bstar,
741            self.inclination,
742            self.raan,
743            self.eccen,
744            self.arg_of_perigee,
745            self.mean_anomaly,
746            self.mean_motion,
747            self.rev_num,
748        )
749    }
750}
751
752impl Default for TLE {
753    fn default() -> Self {
754        Self::new()
755    }
756}
757
758impl std::fmt::Display for TLE {
759    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
760        write!(f, "{}", self.to_pretty_string())
761    }
762}
763
764mod tle_formatter {
765
766    /// Format ndot/2 as sign + ".dddddddd" (10 cols split as [sign][9-body])
767    pub fn format_ndot(v: f64) -> (char, String) {
768        let sign = if v < 0.0 { '-' } else { ' ' };
769        let mut body = format!("{:.8}", v.abs());
770        if let Some(stripped) = body.strip_prefix('0') {
771            body = stripped.to_string(); // turn "0.xxxxxxxx" into ".xxxxxxxx"
772        }
773        // ensure width 9 (".dddddddd")
774        if body.len() < 9 {
775            body = format!("{:>9}", body);
776        } else if body.len() > 9 {
777            body.truncate(9);
778        }
779        (sign, body)
780    }
781
782    /// Format value for implied-exponent fields (nddot/6 and bstar).
783    /// Returns (sign, mantissa[5], exp[2 with sign]) per TLE ("MMMMM±E", where E is 0..9).
784    pub fn format_implied(v: f64) -> (char, String, String) {
785        if v == 0.0 {
786            // Exact zero as " 00000-0"
787            return (' ', "00000".to_string(), "-0".to_string());
788        }
789        let sign = if v < 0.0 { '-' } else { ' ' };
790        let x = v.abs();
791
792        // Represent v ≈ mant * 10^(e - 5) with mant in [0, 99999]
793        let mut e10 = x.log10().floor() as i32; // base-10 exponent
794        let mut mant = (x / 10f64.powi(e10) * 1.0e4).round() as i64;
795
796        // Normalize if rounding pushed mant to 100000
797        if mant == 100_000 {
798            mant = 10_000;
799            e10 += 1;
800        }
801
802        // TLE stores a single-digit exponent with sign: "±d"
803        // e = e10 (we already accounted for mant being *1e5)
804        let e = e10 + 1;
805        let mant_s = format!("{:0>5}", mant.max(0));
806
807        // Clamp to displayable range [-9, 9]; real TLEs fit this for these fields
808        let e_clamped = e.clamp(-9, 9);
809        let exp_s = format!("{:+}", e_clamped);
810
811        (sign, mant_s, exp_s)
812    }
813
814    /// Compute the TLE checksum (mod 10) over the first 68 characters.
815    pub fn tle_checksum(s: &str) -> u8 {
816        let mut sum: u32 = 0;
817        for (i, c) in s.chars().enumerate() {
818            if i >= 68 {
819                break;
820            }
821            sum += match c {
822                '0'..='9' => c as u32 - '0' as u32,
823                '-' => 1,
824                _ => 0,
825            };
826        }
827        (sum % 10) as u8
828    }
829}
830
831#[cfg(test)]
832mod tests {
833    use super::*;
834
835    #[test]
836    fn testload() -> Result<()> {
837        let line1: &str = "1 26900U 01039A   06106.74503247  .00000045  00000-0  10000-3 0  8290";
838        let line2: &str =
839            "2 26900   0.0164 266.5378 0003319  86.1794 182.2590  1.00273847 16981   9300.";
840        let line0: &str = "0 INTELSAT 902";
841        match TLE::load_3line(line0, line1, line2) {
842            Ok(_t) => {}
843
844            Err(s) => {
845                bail!("load_3line: Err = \"{}\"", s);
846            }
847        }
848        match TLE::load_2line(line1, line2) {
849            Ok(_t) => {}
850            Err(s) => {
851                bail!("load_2line: Err = \"{}\"", s);
852            }
853        }
854        Ok(())
855    }
856
857    #[test]
858    fn test_from_lines() -> Result<()> {
859        let lines = vec![
860            "2023-193D".to_string(),
861            "1 58556U 23193D   25003.79555039  .00279397  31144-4  86159-3 0  9996".to_string(),
862            "2 58556  97.2472  26.1173 0004235 271.4738  88.6051 15.91743157 60937".to_string(),
863            "0 CPOD FLT2 (TYVAK-0033)".to_string(),
864            "1 52780U 22057BB  23036.86744141  .00018086  00000-0  87869-3 0  9991".to_string(),
865            "2 52780  97.5313 154.3283 0011660  53.1934 307.0368 15.18441019 16465".to_string(),
866            "1998-067WV".to_string(),
867            "1 60955U 98067WV  24295.33823779  .06453473  12009-4  26290-2 0  9998".to_string(),
868            "2 60955  51.6166  43.0490 0010894 336.3668  23.6849 16.22453324  8315".to_string(),
869            "2 PATHFINDER".to_string(),
870            "1 45727U 20037E   24323.73967089  .00003818  00000+0  31595-3 0  9995".to_string(),
871            "2 45727  97.7798 139.6782 0011624 329.2427  30.8113 14.99451155239085".to_string(),
872            "0 SHINSEI (MS-F2)".to_string(),
873            "1  5485U 71080A   24324.43728894  .00000099  00000-0  13784-3 0  9992".to_string(),
874            "2  5485  32.0564  70.0187 0639723 198.9447 158.6281 12.74214074476065".to_string(),
875            "OSCAR 7 (AO-7)".to_string(),
876            "1 07530U 74089B   24323.87818483 -.00000039  00000+0  47934-4 0  9997".to_string(),
877            "2 07530 101.9893 320.0351 0012269 147.9195 274.9996 12.53682684288423".to_string(),
878            "1 52743U 22057M   23037.04954473  .00011781  00000-0  61944-3 0  9993".to_string(),
879            "2 52743  97.5265 153.6940 0008594  82.9904  31.3082 15.15793680 38769".to_string(),
880            "0 ISS (ZARYA)".to_string(),
881            "1 B5544U 98067A   24356.58519896  .00014389  00000-0  25222-3 0  9992".to_string(),
882            "2 B5544  51.6403 106.8969 0007877   6.1421 113.2479 15.50801739487615".to_string(), // Note: Invalid checksum.
883            "0 ISS (ZARYA)".to_string(),
884            "1 Z9999U 98067A   24356.58519896  .00014389  00000-0  25222-3 0  9992".to_string(),
885            "2 Z9999  51.6403 106.8969 0007877   6.1421 113.2479 15.50801739487615".to_string(), // Note: Invalid checksum.
886        ];
887
888        let tles = match TLE::from_lines(&lines) {
889            Ok(t) => t,
890            Err(s) => {
891                bail!("load_lines: Err = \"{}\"", s);
892            }
893        };
894
895        if tles.len() != 9 {
896            bail!("load_lines: Err = \"Incorrect number of elements parsed\"");
897        }
898
899        if tles[0].name != "2023-193D" {
900            bail!(
901                "load_lines: Err = \"Error parsing sat name {}\"",
902                tles[0].name
903            );
904        }
905
906        if tles[1].name != "CPOD FLT2 (TYVAK-0033)" {
907            bail!(
908                "load_lines: Err = \"Error parsing sat name {}\"",
909                tles[1].name
910            );
911        }
912
913        if tles[2].name != "1998-067WV" {
914            bail!(
915                "load_lines: Err = \"Error parsing sat name {}\"",
916                tles[2].name
917            );
918        }
919
920        if tles[3].name != "2 PATHFINDER" {
921            bail!(
922                "load_lines: Err = \"Error parsing sat name {}\"",
923                tles[3].name
924            );
925        }
926
927        if tles[4].name != "SHINSEI (MS-F2)" {
928            bail!(
929                "load_lines: Err = \"Error parsing sat name {}\"",
930                tles[4].name
931            );
932        }
933
934        if tles[4].sat_num != 5485 {
935            bail!(
936                "load_lines: Err = \"Error parsing sat num {}\"",
937                tles[4].sat_num
938            );
939        }
940
941        if tles[5].name != "OSCAR 7 (AO-7)" {
942            bail!(
943                "load_lines: Err = \"Error parsing sat name {}\"",
944                tles[5].name
945            );
946        }
947
948        if tles[5].sat_num != 7530 {
949            bail!(
950                "load_lines: Err = \"Error parsing sat num {}\"",
951                tles[5].sat_num
952            );
953        }
954
955        if tles[6].name != "none" {
956            bail!(
957                "load_lines: Err = \"Error parsing sat name {}\"",
958                tles[6].name
959            );
960        }
961
962        Ok(())
963    }
964
965    #[test]
966    fn test_from_invalid_from_lines() -> Result<()> {
967        let res = TLE::from_lines(&[
968            "0 INVALID TLE".to_string(),
969            "1 12345U 67890A 12345.67890123  .00000123  00000-0  12345-6 0  9992".to_string(),
970            "2 12345  51.6403 106.8969 0007877   6.1421 113.2479 15.50801739487615".to_string(),
971        ]);
972        assert!(res.is_err(), "Expected error due to short lines, got OK");
973        assert!(
974            res.unwrap_err()
975                .to_string()
976                .contains("Invalid TLE line lengths"),
977            "Expected error about invalid line lengths."
978        );
979
980        Ok(())
981    }
982
983    #[test]
984    fn test_from_invalid_tle2() -> Result<()> {
985        let res = TLE::load_2line(
986            "1 12345U 67890A 12345.67890123  .00000123  00000-0  12345-6 0 9992",
987            "2 12345 51.6403 106.8969 0007877   6.1421 113.2479",
988        );
989        assert!(res.is_err(), "Expected error due to short line2, got OK");
990        assert!(
991            res.unwrap_err().to_string().contains("too short"),
992            "Expected error about line being too short."
993        );
994
995        Ok(())
996    }
997
998    #[test]
999    fn test_from_invalid_tle3() -> Result<()> {
1000        let res = TLE::load_3line(
1001            "0 INVALID TLE",
1002            "1 12345U 67890A 12345.67890123  .00000123  00000-0  12345-6 0 9992",
1003            "2 12345 51.6403 106.8969 0007877   6.1421 113.2479",
1004        );
1005        assert!(res.is_err(), "Expected error due to short line2, got OK");
1006        assert!(
1007            res.unwrap_err()
1008                .to_string()
1009                .contains("Invalid TLE line lengths"),
1010            "Expected error about invalid line lengths."
1011        );
1012        Ok(())
1013    }
1014
1015    #[test]
1016    fn test_alpha5_to_int() -> Result<()> {
1017        // 0-padded less-than-5-digits
1018        match TLE::alpha5_to_int("00091") {
1019            Ok(91) => {}
1020            Ok(i) => bail!("Error parsing '00091' as 91: got {}", i),
1021            Err(e) => bail!("Error parsing '00091' as 91: {}", e),
1022        }
1023
1024        // Non-0-padded less-than-5-digits
1025        match TLE::alpha5_to_int("  982") {
1026            Ok(982) => {}
1027            Ok(i) => bail!("Error parsing '  982' as 982: got {}", i),
1028            Err(e) => bail!("Error parsing '  982' as 982: {}", e),
1029        }
1030
1031        // Numerical 5 digit
1032        match TLE::alpha5_to_int("99993") {
1033            Ok(99993) => {}
1034            Ok(i) => bail!("Error parsing '99993' as 99993: got {}", i),
1035            Err(e) => bail!("Error parsing '99993' as 99993: {}", e),
1036        }
1037
1038        // Alpha5
1039        match TLE::alpha5_to_int("S9994") {
1040            Ok(269994) => {}
1041            Ok(i) => bail!("Error parsing 'S9994' as 269994: got {}", i),
1042            Err(e) => bail!("Error parsing 'S9994' as 269994: {}", e),
1043        }
1044
1045        Ok(())
1046    }
1047
1048    #[test]
1049    fn test_int_to_alpha5() -> Result<()> {
1050        match TLE::int_to_alpha5(91) {
1051            Ok(ref s) if s == "00091" => {}
1052            Ok(ref s) => bail!("Error converting 91 to '00091': got {}", s),
1053            Err(e) => bail!("Error converting 91 to '00091': {}", e),
1054        }
1055
1056        match TLE::int_to_alpha5(99993) {
1057            Ok(ref s) if s == "99993" => {}
1058            Ok(ref s) => bail!("Error converting 99993 to '99993': got {}", s),
1059            Err(e) => bail!("Error converting 99993 to '99993': {}", e),
1060        }
1061
1062        // Alpha5
1063        match TLE::int_to_alpha5(269994) {
1064            Ok(ref s) if s == "S9994" => {}
1065            Ok(ref s) => bail!("Error converting 269994 to 'S9994': got {}", s),
1066            Err(e) => bail!("Error converting 269994 to 'S9994': {}", e),
1067        }
1068
1069        Ok(())
1070    }
1071
1072    #[test]
1073    fn test_3line_encoding() -> Result<()> {
1074        let line0 = "ISS (ZARYA)";
1075        let line1 = "1 25544U 98067A   08264.51782528 -.00002182  00000-0 -11606-4 0  2927";
1076        let line2 = "2 25544  51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537";
1077
1078        let orig = TLE::load_3line(line0, line1, line2)?;
1079
1080        // Format back to text
1081        let [l0, l1, l2] = orig.to_3line()?;
1082
1083        // Check that it matches.
1084        assert_eq!(l1, line1, "Line 1 must match original");
1085        assert_eq!(l2, line2, "Line 2 must match original");
1086        assert_eq!(l0, line0, "Line 0 (name) must be preserved");
1087
1088        Ok(())
1089    }
1090
1091    #[test]
1092    fn test_2line_encoding() -> Result<()> {
1093        let line1 = "1 25544U 98067A   08264.51782528 -.00002182  00000-0 -11606-4 0  2927";
1094        let line2 = "2 25544  51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537";
1095
1096        let orig = TLE::load_2line(line1, line2)?;
1097
1098        // Format back to text
1099        let [l1, l2] = orig.to_2line()?;
1100
1101        // Check that it matches.
1102        assert_eq!(l1, line1, "Line 1 must match original");
1103        assert_eq!(l2, line2, "Line 2 must match original");
1104
1105        Ok(())
1106    }
1107
1108    #[test]
1109    fn test_2line_encoding_many_times() -> Result<()> {
1110        let tle_examples = vec![
1111            [
1112                // "2023-193D"
1113                "1 58556U 23193D   25003.79555039  .00279397  31144-4  86159-3 0  9996".to_string(),
1114                "2 58556  97.2472  26.1173 0004235 271.4738  88.6051 15.91743157 60937".to_string(),
1115            ],
1116            [
1117                // "0 CPOD FLT2 (TYVAK-0033)"
1118                "1 52780U 22057BB  23036.86744141  .00018086  00000-0  87869-3 0  9991".to_string(),
1119                "2 52780  97.5313 154.3283 0011660  53.1934 307.0368 15.18441019 16465".to_string(),
1120            ],
1121            [
1122                // "1998-067WV"
1123                "1 60955U 98067WV  24295.33823779  .06453473  12009-4  26290-2 0  9998".to_string(),
1124                "2 60955  51.6166  43.0490 0010894 336.3668  23.6849 16.22453324  8315".to_string(),
1125            ],
1126            [
1127                // "2 PATHFINDER"
1128                "1 45727U 20037E   24323.73967089  .00003818  00000+0  31595-3 0  9995".to_string(),
1129                "2 45727  97.7798 139.6782 0011624 329.2427  30.8113 14.99451155239085".to_string(),
1130            ],
1131            // [
1132            //     // "0 SHINSEI (MS-F2)". Exclude because it does not use a 5-digit NORAD ID, and thus the encoding isn't as expected.
1133            //     "1  5485U 71080A   24324.43728894  .00000099  00000-0  13784-3 0  9992".to_string(),
1134            //     "2  5485  32.0564  70.0187 0639723 198.9447 158.6281 12.74214074476065".to_string(),
1135            // ],
1136            [
1137                // "OSCAR 7 (AO-7)"
1138                "1 07530U 74089B   24323.87818483 -.00000039  00000+0  47934-4 0  9997".to_string(),
1139                "2 07530 101.9893 320.0351 0012269 147.9195 274.9996 12.53682684288423".to_string(),
1140            ],
1141            [
1142                "1 52743U 22057M   23037.04954473  .00011781  00000-0  61944-3 0  9993".to_string(),
1143                "2 52743  97.5265 153.6940 0008594  82.9904  31.3082 15.15793680 38769".to_string(),
1144            ],
1145            [
1146                // "0 ISS (ZARYA)"
1147                "1 B5544U 98067A   24356.58519896  .00014389  00000-0  25222-3 0  9992".to_string(),
1148                "2 B5544  51.6403 106.8969 0007877   6.1421 113.2479 15.50801739487613".to_string(),
1149            ],
1150            [
1151                // "0 ISS (ZARYA)"
1152                "1 Z9999U 98067A   24356.58519896  .00014389  00000-0  25222-3 0  9992".to_string(),
1153                "2 Z9999  51.6403 106.8969 0007877   6.1421 113.2479 15.50801739487611".to_string(),
1154            ],
1155        ];
1156
1157        for tle in tle_examples {
1158            let tle_loaded = TLE::load_2line(&tle[0], &tle[1])?;
1159            let [l1, l2] = tle_loaded.to_2line()?;
1160
1161            // Check that it matches.
1162            // Allow ignoring the sign of the exponent on zero.
1163            if tle[0].contains(" 00000+0 ") {
1164                let mut expected: String = tle[0].replace(" 00000+0 ", " 00000-0 ");
1165
1166                // Increment the checksum digit at the end of the line.
1167                if let Some(last_char) = expected.chars().last() {
1168                    if let Some(digit) = last_char.to_digit(10) {
1169                        let new_digit = (digit + 1) % 10; // wrap around if needed
1170                        expected.pop(); // remove last char
1171                        expected.push(char::from_digit(new_digit, 10).unwrap());
1172                    }
1173                }
1174
1175                assert_eq!(l1, expected, "Line 1 must match original");
1176            } else {
1177                assert_eq!(l2, tle[1], "Line 2 must match original");
1178            }
1179        }
1180
1181        Ok(())
1182    }
1183
1184
1185    #[test]
1186    fn test_2line_encoding_with_invalid_past_date() -> Result<()> {
1187        let line1 = "1 25544U 98067A   08264.51782528 -.00002182  00000-0 -11606-4 0  2927";
1188        let line2 = "2 25544  51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537";
1189
1190        let mut tle = TLE::load_2line(line1, line2)?;
1191        tle.epoch = Instant::from_date(1952, 6, 13)?;
1192
1193        let result = tle.to_2line();
1194
1195        // Check that it errors.
1196        assert!(result.is_err(), "Expected error due to epoch before 1957, got {:?}", result);
1197
1198        Ok(())
1199    }
1200
1201    #[test]
1202    fn test_2line_encoding_with_invalid_future_date() -> Result<()> {
1203        let line1 = "1 25544U 98067A   08264.51782528 -.00002182  00000-0 -11606-4 0  2927";
1204        let line2 = "2 25544  51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537";
1205
1206        let mut tle = TLE::load_2line(line1, line2)?;
1207        tle.epoch = Instant::from_date(2057, 6, 13)?;
1208
1209        let result = tle.to_2line();
1210
1211        // Check that it errors.
1212        assert!(result.is_err(), "Expected error due to epoch after 2056, got {:?}", result);
1213
1214        Ok(())
1215    }
1216}