Skip to main content

sidereon_core/astro/
tle.rs

1//! Two-Line Element (TLE) format parser and encoder.
2//!
3//! TLE is the legacy fixed-width format for satellite orbital elements, designed
4//! for 80-column punch cards. This module owns the complete format grammar: ASCII
5//! and fixed-width validation, the modulo-10 checksum, the "assumed decimal"
6//! exponent codec used for the drag terms, the per-field number formatting, and
7//! the two-digit-year pivot. It runs identically regardless of the calling
8//! language, so it lives in the core; the sidereon Elixir binding only marshals the
9//! epoch between its native `DateTime` and the `(year, day_of_year)` pair this
10//! module exposes, normalizes input defaults, and maps errors.
11//!
12//! The epoch is represented here as a calendar year plus a one-based fractional
13//! day-of-year, exactly the two quantities the TLE epoch field encodes. This
14//! module owns the TLE two-digit-year pivot and converts that pair into SGP4's
15//! split Julian date when building the format-agnostic element set.
16
17use std::fmt;
18
19use libm::{floor, log10, pow};
20
21use crate::astro::sgp4::{self, ElementSet};
22use crate::validate;
23
24/// Maximum significant length of a TLE line (columns 1-69). Trailing content is
25/// trimmed to this width before parsing, matching the reference behavior.
26const MAX_LINE_LEN: usize = 69;
27/// Highest ASCII code point permitted in a TLE line.
28const MAX_ASCII: u32 = 127;
29/// Minimum significant length of line 1 accepted by the lenient parser.
30const LINE1_MIN_LEN: usize = 64;
31/// Minimum significant length of line 2 accepted by the lenient parser.
32const LINE2_MIN_LEN: usize = 68;
33/// Column index of the checksum digit (zero-based).
34const CHECKSUM_COL: usize = 68;
35/// Two-digit-year pivot: years below this map to 2000+, otherwise 1900+. This is
36/// the long-standing NORAD convention for the TLE epoch year.
37const YEAR_PIVOT: i32 = 57;
38/// The TLE record body occupies columns 1-68; column 69 is the checksum.
39const BODY_LEN: usize = 68;
40
41/// Decimal places carried by the TLE epoch day-of-year field.
42const EPOCH_DAY_DECIMALS: usize = 8;
43/// Total width of the formatted epoch day-of-year field (`DDD.DDDDDDDD`).
44const EPOCH_DAY_WIDTH: usize = 12;
45/// Decimal places carried by the first mean-motion derivative field.
46const NDOT_DECIMALS: usize = 8;
47/// Width of the formatted first mean-motion derivative field.
48const NDOT_WIDTH: usize = 9;
49/// Decimal places carried by the assumed-decimal mantissa.
50const ASSUMED_DECIMAL_MANTISSA_DECIMALS: usize = 5;
51/// Number of mantissa digits emitted in an assumed-decimal field.
52const ASSUMED_DECIMAL_MANTISSA_DIGITS: usize = 5;
53/// Decimal places carried by the eccentricity field.
54const ECCENTRICITY_DECIMALS: usize = 7;
55/// Digits emitted for the (leading-decimal-stripped) eccentricity field.
56const ECCENTRICITY_DIGITS: usize = 7;
57/// Decimal places carried by an angle field (inclination, RAAN, ...).
58const ANGLE_DECIMALS: usize = 4;
59/// Width of a formatted angle field.
60const ANGLE_WIDTH: usize = 8;
61/// Decimal places carried by the mean-motion field.
62const MEAN_MOTION_DECIMALS: usize = 8;
63/// Width of the formatted mean-motion field.
64const MEAN_MOTION_WIDTH: usize = 11;
65/// Width of the element-set-number field.
66const ELSET_WIDTH: usize = 4;
67/// Width of the revolution-number field.
68const REV_WIDTH: usize = 5;
69/// Width of the zero-padded catalog-number field.
70const CATALOG_WIDTH: usize = 5;
71/// Width of the international-designator field.
72const INTL_DESIGNATOR_WIDTH: usize = 8;
73/// Width of the two-digit epoch year field.
74const EPOCH_YEAR_WIDTH: usize = 2;
75/// Highest catalog number that can be represented in a legacy TLE field.
76pub const MAX_TLE_CATALOG_NUMBER: u32 = 339_999;
77/// Highest catalog number that remains a five-digit numeric TLE field.
78pub const MAX_NUMERIC_TLE_CATALOG_NUMBER: u32 = 99_999;
79/// Alpha-5 leading letters, in their published numeric order.
80const ALPHA5_LETTERS: &str = "ABCDEFGHJKLMNPQRSTUVWXYZ";
81/// Number of numeric suffix slots under each Alpha-5 leading letter.
82const ALPHA5_SUFFIX_MODULUS: u32 = 10_000;
83
84/// Parsed TLE orbital elements in canonical astrodynamic units.
85///
86/// Angles are degrees, mean motion is revolutions/day and its derivatives
87/// rev/day^2 and rev/day^3, BSTAR drag is 1/earth-radii, and the epoch is the
88/// calendar `epoch_year` plus the one-based fractional `epoch_day_of_year`.
89#[derive(Debug, Clone, PartialEq)]
90pub struct TleElements {
91    /// Catalog number as it appeared at the format boundary.
92    ///
93    /// Numeric TLE fields keep their five-character representation (for
94    /// example, `"00005"`), and Alpha-5 fields keep their letter-plus-four-digit
95    /// representation (for example, `"A0000"`). Use
96    /// [`decode_catalog_number`] to obtain the numeric catalog id.
97    pub catalog_number: String,
98    pub classification: String,
99    pub international_designator: String,
100    pub epoch_year: i32,
101    pub epoch_day_of_year: f64,
102    pub mean_motion_dot: f64,
103    pub mean_motion_double_dot: f64,
104    pub bstar: f64,
105    pub ephemeris_type: i32,
106    pub elset_number: i32,
107    pub inclination_deg: f64,
108    pub raan_deg: f64,
109    pub eccentricity: f64,
110    pub arg_perigee_deg: f64,
111    pub mean_anomaly_deg: f64,
112    pub mean_motion: f64,
113    pub rev_number: i32,
114}
115
116impl TleElements {
117    /// Convert these parsed TLE elements into the canonical SGP4 [`ElementSet`]
118    /// IR consumed by [`crate::astro::sgp4::Satellite::from_elements`].
119    ///
120    /// This is the single TLE-to-IR mapping: the public TLE entry point parses a
121    /// TLE to [`TleElements`], converts here, and feeds the result into the same
122    /// `ElementSet -> satrec` initialization every other input format uses, so
123    /// there is no separate TLE-direct propagation path.
124    ///
125    /// The mapping is bit-preserving for SGP4. The angle, eccentricity, mean
126    /// motion, and epoch-day fields are carried through unchanged until the
127    /// epoch is converted through the same `days2mdhms`/`jday` math and
128    /// 8-decimal fraction rounding Vallado uses for TLE input. B\* and the
129    /// second mean-motion derivative are decoded with `powi` in [`parse`]
130    /// precisely so they equal the `mantissa * 10^exp` product the element-set
131    /// initializer expects; they too pass through unchanged.
132    ///
133    /// The catalog number is decoded to the numeric form `ElementSet` carries.
134    /// It is used only for SGP4 diagnostics and does not affect propagation, but
135    /// it must still survive the TLE bridge without silent loss.
136    pub fn to_element_set(&self) -> Result<ElementSet, TleError> {
137        validate_tle_bridge(self)?;
138        Ok(ElementSet {
139            epoch: sgp4::sgp4_julian_date_from_day_of_year(self.epoch_year, self.epoch_day_of_year),
140            bstar: self.bstar,
141            mean_motion_dot: self.mean_motion_dot,
142            mean_motion_double_dot: self.mean_motion_double_dot,
143            eccentricity: self.eccentricity,
144            argument_of_perigee_deg: self.arg_perigee_deg,
145            inclination_deg: self.inclination_deg,
146            mean_anomaly_deg: self.mean_anomaly_deg,
147            mean_motion_rev_per_day: self.mean_motion,
148            right_ascension_deg: self.raan_deg,
149            catalog_number: decode_catalog_number(&self.catalog_number)?,
150        })
151    }
152}
153
154/// A reported checksum discrepancy. The format grammar does not reject a line on
155/// a bad checksum (it is advisory), so this is surfaced for the host to log.
156#[derive(Debug, Clone, PartialEq, Eq)]
157pub struct ChecksumWarning {
158    /// Human label for the offending line (`"line 1"` / `"line 2"`).
159    pub line_label: &'static str,
160    /// Checksum digit found in column 69.
161    pub expected: u8,
162    /// Checksum computed from columns 1-68.
163    pub computed: u8,
164}
165
166/// The result of [`parse`]: the elements plus any advisory checksum warnings.
167#[derive(Debug, Clone, PartialEq)]
168pub struct ParsedTle {
169    pub elements: TleElements,
170    pub checksum_warnings: Vec<ChecksumWarning>,
171}
172
173/// Failure modes of [`parse`]. Messages mirror the historical reference strings.
174#[derive(Debug, Clone, PartialEq, Eq)]
175pub enum TleError {
176    /// A TLE line contained a non-ASCII character.
177    NonAscii,
178    /// A line failed fixed-width TLE grammar validation.
179    Format,
180    /// The catalog-number fields in line 1 and line 2 differed.
181    SatelliteMismatch,
182    /// The five-character catalog field was neither numeric nor valid Alpha-5.
183    InvalidCatalogNumber {
184        /// The rejected catalog field.
185        value: String,
186        /// The validation reason.
187        reason: &'static str,
188    },
189    /// The catalog id is outside the range representable by TLE or Alpha-5.
190    CatalogNumberOutOfRange {
191        /// The rejected numeric catalog id.
192        catalog_number: u32,
193    },
194    /// A decoded scalar field failed boundary validation.
195    InvalidField {
196        /// Field name.
197        field: &'static str,
198        /// Validation reason.
199        reason: &'static str,
200    },
201    /// A scalar field could not be parsed.
202    Field(String),
203}
204
205impl fmt::Display for TleError {
206    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
207        match self {
208            TleError::NonAscii => write!(f, "TLE lines contain non-ASCII characters"),
209            TleError::Format => write!(
210                f,
211                "TLE format error: line does not match the Two-Line Element fixed-width format"
212            ),
213            TleError::SatelliteMismatch => {
214                write!(f, "Satellite numbers in lines 1 and 2 do not match")
215            }
216            TleError::InvalidCatalogNumber { value, reason } => {
217                write!(f, "TLE invalid catalog number {value:?}: {reason}")
218            }
219            TleError::CatalogNumberOutOfRange { catalog_number } => write!(
220                f,
221                "TLE catalog number {catalog_number} cannot be encoded in a five-character field"
222            ),
223            TleError::InvalidField { field, reason } => {
224                write!(f, "TLE invalid field {field}: {reason}")
225            }
226            TleError::Field(msg) => write!(f, "TLE parse error: {msg}"),
227        }
228    }
229}
230
231impl std::error::Error for TleError {}
232
233fn validate_tle_bridge(elements: &TleElements) -> Result<(), TleError> {
234    validate::finite(elements.epoch_day_of_year, "epoch_day_of_year").map_err(map_tle_field)?;
235    validate::finite(elements.bstar, "bstar").map_err(map_tle_field)?;
236    validate::finite(elements.mean_motion_dot, "mean_motion_dot").map_err(map_tle_field)?;
237    validate::finite(elements.mean_motion_double_dot, "mean_motion_double_dot")
238        .map_err(map_tle_field)?;
239    validate::finite_in_range_exclusive_upper(elements.eccentricity, 0.0, 1.0, "eccentricity")
240        .map_err(map_tle_field)?;
241    validate::finite(elements.arg_perigee_deg, "arg_perigee_deg").map_err(map_tle_field)?;
242    validate::finite(elements.inclination_deg, "inclination_deg").map_err(map_tle_field)?;
243    validate::finite(elements.mean_anomaly_deg, "mean_anomaly_deg").map_err(map_tle_field)?;
244    validate::finite_positive(elements.mean_motion, "mean_motion").map_err(map_tle_field)?;
245    validate::finite(elements.raan_deg, "raan_deg").map_err(map_tle_field)?;
246    Ok(())
247}
248
249fn map_tle_field(error: validate::FieldError) -> TleError {
250    TleError::InvalidField {
251        field: error.field(),
252        reason: error.reason(),
253    }
254}
255
256/// Parse a two-line element set into [`TleElements`].
257///
258/// The parser is liberal: trailing content past column 69 is trimmed, leading-dot
259/// floats are normalized, and an invalid checksum is reported (via
260/// [`ParsedTle::checksum_warnings`]) rather than rejected.
261pub fn parse(line1: &str, line2: &str) -> Result<ParsedTle, TleError> {
262    if !is_ascii(line1) || !is_ascii(line2) {
263        return Err(TleError::NonAscii);
264    }
265
266    let line1 = clean_line(line1);
267    let line2 = clean_line(line2);
268
269    validate_format(&line1, &line2)?;
270    let elements = extract_fields(&line1, &line2)?;
271    let checksum_warnings = checksum_warnings(&line1, &line2);
272
273    Ok(ParsedTle {
274        elements,
275        checksum_warnings,
276    })
277}
278
279/// Encode a numeric catalog id into its five-character TLE catalog field.
280///
281/// Values `0..=99999` encode as five decimal digits. Values
282/// `100000..=339999` encode with Alpha-5, where the first character carries the
283/// ten-thousands digit group and the four trailing characters carry the
284/// remainder. Larger values return [`TleError::CatalogNumberOutOfRange`] because
285/// the TLE field has no representation for them.
286pub fn encode_catalog_number(catalog_number: u32) -> Result<String, TleError> {
287    if catalog_number <= MAX_NUMERIC_TLE_CATALOG_NUMBER {
288        return Ok(format!("{catalog_number:05}"));
289    }
290    if catalog_number > MAX_TLE_CATALOG_NUMBER {
291        return Err(TleError::CatalogNumberOutOfRange { catalog_number });
292    }
293
294    let prefix = catalog_number / ALPHA5_SUFFIX_MODULUS;
295    let suffix = catalog_number % ALPHA5_SUFFIX_MODULUS;
296    let letter = alpha5_letter_for_value(prefix)
297        .ok_or(TleError::CatalogNumberOutOfRange { catalog_number })?;
298    Ok(format!("{letter}{suffix:04}"))
299}
300
301/// Decode a five-character TLE catalog field into its numeric catalog id.
302///
303/// Plain numeric fields decode directly. Alpha-5 fields decode by the published
304/// `letter_value * 10000 + suffix` rule, with letters `I` and `O` rejected.
305pub fn decode_catalog_number(field: &str) -> Result<u32, TleError> {
306    let field = field.trim();
307    if field.is_empty() {
308        return Err(TleError::InvalidCatalogNumber {
309            value: field.to_string(),
310            reason: "empty field",
311        });
312    }
313
314    if field.bytes().all(|b| b.is_ascii_digit()) {
315        if field.len() > CATALOG_WIDTH {
316            return Err(TleError::InvalidCatalogNumber {
317                value: field.to_string(),
318                reason: "numeric TLE field is wider than five digits",
319            });
320        }
321        return field
322            .parse::<u32>()
323            .map_err(|_| TleError::InvalidCatalogNumber {
324                value: field.to_string(),
325                reason: "invalid numeric field",
326            });
327    }
328
329    if field.len() != CATALOG_WIDTH {
330        return Err(TleError::InvalidCatalogNumber {
331            value: field.to_string(),
332            reason: "Alpha-5 field must be one letter followed by four digits",
333        });
334    }
335
336    let mut chars = field.chars();
337    let letter = chars.next().expect("non-empty field");
338    let prefix = alpha5_value_for_letter(letter).ok_or_else(|| TleError::InvalidCatalogNumber {
339        value: field.to_string(),
340        reason: "invalid Alpha-5 leading letter",
341    })?;
342    let suffix = chars.as_str();
343    if !suffix.bytes().all(|b| b.is_ascii_digit()) {
344        return Err(TleError::InvalidCatalogNumber {
345            value: field.to_string(),
346            reason: "Alpha-5 suffix must be four digits",
347        });
348    }
349    let suffix = suffix
350        .parse::<u32>()
351        .map_err(|_| TleError::InvalidCatalogNumber {
352            value: field.to_string(),
353            reason: "invalid Alpha-5 suffix",
354        })?;
355    Ok(prefix * ALPHA5_SUFFIX_MODULUS + suffix)
356}
357
358/// Encode [`TleElements`] as the two 69-character TLE lines (with checksums).
359///
360/// The caller is responsible for supplying normalized field values (defaults
361/// applied, widths validated); this function performs the fixed-width formatting,
362/// assumed-decimal encoding, and checksum generation.
363pub fn encode(el: &TleElements) -> Result<(String, String), TleError> {
364    let cat = encode_catalog_number_text(&el.catalog_number)?;
365    let cls = &el.classification;
366    let intl = pad_trailing(&el.international_designator, INTL_DESIGNATOR_WIDTH);
367
368    let epoch_two_digit = el.epoch_year.rem_euclid(100);
369
370    let l1_body = format!(
371        "1 {cat}{cls} {intl} {epoch} {ndot} {nddot} {bstar} {ephtype} {elnum}",
372        epoch = fmt_epoch(epoch_two_digit, el.epoch_day_of_year),
373        ndot = fmt_ndot(el.mean_motion_dot),
374        nddot = fmt_assumed_decimal(el.mean_motion_double_dot),
375        bstar = fmt_assumed_decimal(el.bstar),
376        ephtype = el.ephemeris_type,
377        elnum = pad_leading(&el.elset_number.to_string(), ELSET_WIDTH),
378    );
379    let line1 = pad_and_checksum(&l1_body);
380
381    let l2_body = format!(
382        "2 {cat} {inclo} {raan} {ecc} {argp} {mo} {mm}{revnum}",
383        inclo = fmt_angle(el.inclination_deg),
384        raan = fmt_angle(el.raan_deg),
385        ecc = fmt_eccentricity(el.eccentricity),
386        argp = fmt_angle(el.arg_perigee_deg),
387        mo = fmt_angle(el.mean_anomaly_deg),
388        mm = fmt_mean_motion(el.mean_motion),
389        revnum = pad_leading(&el.rev_number.to_string(), REV_WIDTH),
390    );
391    let line2 = pad_and_checksum(&l2_body);
392
393    Ok((line1, line2))
394}
395
396// -- Parsing internals --
397
398fn is_ascii(line: &str) -> bool {
399    line.chars().all(|c| (c as u32) <= MAX_ASCII)
400}
401
402/// Trim trailing whitespace and clamp to the significant TLE width.
403fn clean_line(line: &str) -> String {
404    let trimmed = line.trim_end();
405    if trimmed.len() > MAX_LINE_LEN {
406        trimmed[..MAX_LINE_LEN].to_string()
407    } else {
408        trimmed.to_string()
409    }
410}
411
412fn validate_format(line1: &str, line2: &str) -> Result<(), TleError> {
413    validate_line(line1, '1', LINE1_MIN_LEN, &LINE1_POSITIONS)?;
414    validate_line(line2, '2', LINE2_MIN_LEN, &LINE2_POSITIONS)?;
415    if slice_inclusive(line1, 2, 6) == slice_inclusive(line2, 2, 6) {
416        Ok(())
417    } else {
418        Err(TleError::SatelliteMismatch)
419    }
420}
421
422fn validate_line(
423    line: &str,
424    prefix: char,
425    min_len: usize,
426    positions: &[(usize, char)],
427) -> Result<(), TleError> {
428    let len = line.chars().count();
429    if len < min_len {
430        return Err(TleError::Format);
431    }
432    let mut start = String::with_capacity(2);
433    start.push(prefix);
434    start.push(' ');
435    if !line.starts_with(&start) {
436        return Err(TleError::Format);
437    }
438    if positions
439        .iter()
440        .all(|&(pos, ch)| char_at(line, pos) == Some(ch))
441    {
442        Ok(())
443    } else {
444        Err(TleError::Format)
445    }
446}
447
448const LINE1_POSITIONS: [(usize, char); 8] = [
449    (8, ' '),
450    (23, '.'),
451    (32, ' '),
452    (34, '.'),
453    (43, ' '),
454    (52, ' '),
455    (61, ' '),
456    (63, ' '),
457];
458
459const LINE2_POSITIONS: [(usize, char); 10] = [
460    (7, ' '),
461    (11, '.'),
462    (16, ' '),
463    (20, '.'),
464    (25, ' '),
465    (33, ' '),
466    (37, '.'),
467    (42, ' '),
468    (46, '.'),
469    (51, ' '),
470];
471
472fn extract_fields(line1: &str, line2: &str) -> Result<TleElements, TleError> {
473    let catalog_number = slice_inclusive(line1, 2, 6).trim().to_string();
474    decode_catalog_number(&catalog_number)?;
475
476    let two_digit_year = parse_int(slice_inclusive(line1, 18, 19).trim())?;
477    let epoch_year = if two_digit_year < YEAR_PIVOT {
478        2000 + two_digit_year
479    } else {
480        1900 + two_digit_year
481    };
482
483    Ok(TleElements {
484        catalog_number,
485        classification: char_at(line1, 7).unwrap_or('U').to_string(),
486        international_designator: slice_inclusive(line1, 9, 16).trim_end().to_string(),
487        epoch_year,
488        epoch_day_of_year: parse_float(slice_inclusive(line1, 20, 31))?,
489        mean_motion_dot: parse_float(slice_inclusive(line1, 33, 42))?,
490        mean_motion_double_dot: parse_assumed_decimal(line1, 44, 45, 49, 50, 51)?,
491        bstar: parse_assumed_decimal(line1, 53, 54, 58, 59, 60)?,
492        ephemeris_type: parse_int_or_default(
493            char_at(line1, 62)
494                .map(|c| c.to_string())
495                .unwrap_or_default()
496                .trim(),
497            0,
498        )?,
499        elset_number: parse_int_or_default(slice_inclusive(line1, 64, 67).trim(), 0)?,
500        inclination_deg: parse_float(slice_inclusive(line2, 8, 15))?,
501        raan_deg: parse_float(slice_inclusive(line2, 17, 24))?,
502        eccentricity: parse_eccentricity(slice_inclusive(line2, 26, 32))?,
503        arg_perigee_deg: parse_float(slice_inclusive(line2, 34, 41))?,
504        mean_anomaly_deg: parse_float(slice_inclusive(line2, 43, 50))?,
505        mean_motion: parse_float(slice_inclusive(line2, 52, 62))?,
506        rev_number: parse_int_or_default(slice_inclusive(line2, 63, 67).trim(), 0)?,
507    })
508}
509
510/// Parse an "assumed decimal" exponent field: `[sign][mantissa][exp_sign][exp]`,
511/// representing `0.<mantissa> * 10^exp`.
512fn parse_assumed_decimal(
513    line: &str,
514    sign_pos: usize,
515    mant_start: usize,
516    mant_end: usize,
517    exp_start: usize,
518    exp_end: usize,
519) -> Result<f64, TleError> {
520    let sign = if char_at(line, sign_pos) == Some('-') {
521        -1.0
522    } else {
523        1.0
524    };
525    let mantissa_field = format!("0.{}", slice_inclusive(line, mant_start, mant_end));
526    let mantissa = parse_float_raw(mantissa_field.trim())?;
527    let exp = parse_int(slice_inclusive(line, exp_start, exp_end).trim())?;
528    // Decode with `powi` (integer exponent), matching `decode_assumed_decimal_field`
529    // and the SGP4 element-set init: the value reaching SGP4 must be the exact
530    // `mantissa * 10^exp` product the golden path produces, so the canonical
531    // element set built from a parsed TLE drives SGP4 bit-identically.
532    Ok(sign * mantissa * 10.0_f64.powi(exp))
533}
534
535/// Parse the implicit-leading-`0.` eccentricity field (spaces read as `0`).
536fn parse_eccentricity(field: &str) -> Result<f64, TleError> {
537    let digits = field.replace(' ', "0");
538    parse_float_raw(&format!("0.{digits}"))
539}
540
541/// Replicate the reference float normalization: trim, strip a leading `+`, and
542/// supply the integer `0` for a leading-dot value before strict float parsing.
543fn parse_float(field: &str) -> Result<f64, TleError> {
544    let trimmed = field.trim();
545    let without_plus = trimmed.strip_prefix('+').unwrap_or(trimmed);
546    let normalized = if let Some(rest) = without_plus.strip_prefix("-.") {
547        format!("-0.{rest}")
548    } else if let Some(rest) = without_plus.strip_prefix('.') {
549        format!("0.{rest}")
550    } else {
551        without_plus.to_string()
552    };
553    parse_float_raw(&normalized)
554}
555
556/// Strict float parse that rejects the integer-only and leading/trailing-dot forms
557/// the reference `String.to_float/1` rejects, so malformed fields surface as errors.
558fn parse_float_raw(text: &str) -> Result<f64, TleError> {
559    if !text.contains('.') {
560        return Err(TleError::Field(format!("invalid float {text:?}")));
561    }
562    let body = text.strip_prefix('-').unwrap_or(text);
563    if body.starts_with('.') || body.ends_with('.') {
564        return Err(TleError::Field(format!("invalid float {text:?}")));
565    }
566    text.parse::<f64>()
567        .map_err(|_| TleError::Field(format!("invalid float {text:?}")))
568}
569
570fn parse_int(text: &str) -> Result<i32, TleError> {
571    text.parse::<i32>()
572        .map_err(|_| TleError::Field(format!("invalid integer {text:?}")))
573}
574
575/// Parse an integer field that is optional in practice: a blank (all-spaces)
576/// field falls back to `default`. The element-set and revolution numbers are
577/// bookkeeping fields some generators leave empty; they do not affect SGP4
578/// propagation, so a blank one is a cosmetic absence rather than corruption.
579fn parse_int_or_default(text: &str, default: i32) -> Result<i32, TleError> {
580    if text.is_empty() {
581        Ok(default)
582    } else {
583        parse_int(text)
584    }
585}
586
587fn checksum_warnings(line1: &str, line2: &str) -> Vec<ChecksumWarning> {
588    [("line 1", line1), ("line 2", line2)]
589        .into_iter()
590        .filter_map(|(label, line)| check_one(label, line))
591        .collect()
592}
593
594fn check_one(label: &'static str, line: &str) -> Option<ChecksumWarning> {
595    if line.chars().count() < MAX_LINE_LEN {
596        return None;
597    }
598    let expected = char_at(line, CHECKSUM_COL)
599        .and_then(|c| c.to_digit(10))
600        .map(|d| d as u8)?;
601    let computed = compute_checksum(line);
602    if expected == computed {
603        None
604    } else {
605        Some(ChecksumWarning {
606            line_label: label,
607            expected,
608            computed,
609        })
610    }
611}
612
613/// Modulo-10 checksum over columns 1-68: digits add their value, `-` adds 1, all
614/// other characters add 0.
615fn compute_checksum(line: &str) -> u8 {
616    let sum: u32 = line
617        .chars()
618        .take(BODY_LEN)
619        .map(|c| match c {
620            '0'..='9' => c as u32 - '0' as u32,
621            '-' => 1,
622            _ => 0,
623        })
624        .sum();
625    (sum % 10) as u8
626}
627
628// -- Slicing helpers (TLE lines are ASCII, so char index == byte index) --
629
630/// Inclusive character slice mirroring the reference `String.slice(s, a..b)`:
631/// clamps to the available length and returns `""` when `start` is past the end.
632fn slice_inclusive(s: &str, start: usize, end_inclusive: usize) -> &str {
633    let len = s.len();
634    if start >= len {
635        return "";
636    }
637    let end = (end_inclusive + 1).min(len);
638    &s[start..end]
639}
640
641fn char_at(s: &str, index: usize) -> Option<char> {
642    s.as_bytes().get(index).map(|&b| b as char)
643}
644
645fn alpha5_value_for_letter(letter: char) -> Option<u32> {
646    ALPHA5_LETTERS
647        .chars()
648        .position(|candidate| candidate == letter)
649        .map(|index| 10 + index as u32)
650}
651
652fn alpha5_letter_for_value(value: u32) -> Option<char> {
653    if value < 10 {
654        return None;
655    }
656    ALPHA5_LETTERS.chars().nth((value - 10) as usize)
657}
658
659fn encode_catalog_number_text(text: &str) -> Result<String, TleError> {
660    let trimmed = text.trim();
661    if trimmed.bytes().all(|b| b.is_ascii_digit()) {
662        let catalog_number =
663            trimmed
664                .parse::<u32>()
665                .map_err(|_| TleError::InvalidCatalogNumber {
666                    value: trimmed.to_string(),
667                    reason: "invalid numeric field",
668                })?;
669        encode_catalog_number(catalog_number)
670    } else {
671        let catalog_number = decode_catalog_number(trimmed)?;
672        encode_catalog_number(catalog_number)
673    }
674}
675
676/// Quantize a value onto the TLE "assumed decimal" grid (five significant
677/// mantissa digits and a power-of-ten exponent) and decode it back, yielding the
678/// exact `f64` SGP4 receives when the same quantity is carried through a TLE.
679///
680/// OMM encodes B\* and the second mean-motion derivative as plain decimals, but
681/// their canonical SGP4 representation is this five-digit assumed-decimal field;
682/// quantizing through it lets an OMM drive SGP4 bit-identically to the equivalent
683/// TLE. The decode mirrors the parse in `sgp4::init_satrec_from_tle`
684/// (`mantissa * 10f64.powi(exp)`), so a quantized OMM B\* equals the value the
685/// matching TLE produces to 0 ULP.
686pub(crate) fn assumed_decimal_quantize(value: f64) -> f64 {
687    if value == 0.0 {
688        return 0.0;
689    }
690    decode_assumed_decimal_field(&fmt_assumed_decimal(value))
691}
692
693/// Decode the eight-or-more character assumed-decimal field emitted by
694/// [`fmt_assumed_decimal`] (`"[sign|space]MMMMM[exp-sign]E"`).
695fn decode_assumed_decimal_field(field: &str) -> f64 {
696    let sign = if field.starts_with('-') { -1.0 } else { 1.0 };
697    let body = &field[1..];
698    let mantissa_digits = &body[..ASSUMED_DECIMAL_MANTISSA_DIGITS];
699    let exp_field = &body[ASSUMED_DECIMAL_MANTISSA_DIGITS..];
700    let exp_field = exp_field.strip_prefix('+').unwrap_or(exp_field);
701    let mantissa: f64 = format!("0.{mantissa_digits}").parse().unwrap_or(0.0);
702    let exp: i32 = exp_field.parse().unwrap_or(0);
703    sign * mantissa * 10.0_f64.powi(exp)
704}
705
706// -- Encoding internals --
707
708fn fmt_epoch(year_two_digit: i32, day_of_year: f64) -> String {
709    let yr = pad_leading_zeros(&year_two_digit.to_string(), EPOCH_YEAR_WIDTH);
710    let days = fixed_decimals(day_of_year, EPOCH_DAY_DECIMALS);
711    format!("{yr}{}", pad_leading_zeros(&days, EPOCH_DAY_WIDTH))
712}
713
714fn fmt_ndot(val: f64) -> String {
715    let sign = if val < 0.0 { '-' } else { ' ' };
716    let mut digits = fixed_decimals(val.abs(), NDOT_DECIMALS);
717    if let Some(rest) = digits.strip_prefix('0') {
718        digits = rest.to_string();
719    }
720    format!("{sign}{}", pad_leading(&digits, NDOT_WIDTH))
721}
722
723/// Format an "assumed decimal" field (`0.<mantissa> * 10^exp`) for the drag terms.
724fn fmt_assumed_decimal(val: f64) -> String {
725    if val == 0.0 {
726        return " 00000-0".to_string();
727    }
728    let sign = if val < 0.0 { '-' } else { ' ' };
729    let av = val.abs();
730    let raw_exp = floor(log10(av)) as i32;
731    let mut exp = raw_exp + 1;
732    let mantissa = av / pow(10.0, exp as f64);
733    let mut mant_full = fixed_decimals(mantissa, ASSUMED_DECIMAL_MANTISSA_DECIMALS);
734    if mant_full.starts_with("1.") {
735        exp += 1;
736        mant_full = fixed_decimals(mantissa / 10.0, ASSUMED_DECIMAL_MANTISSA_DECIMALS);
737    }
738    let mant_str: String = mant_full
739        .chars()
740        .skip(2)
741        .take(ASSUMED_DECIMAL_MANTISSA_DIGITS)
742        .collect();
743    let exp_sign = if exp >= 0 { '+' } else { '-' };
744    format!("{sign}{mant_str}{exp_sign}{}", exp.abs())
745}
746
747fn fmt_eccentricity(ecc: f64) -> String {
748    let formatted = fixed_decimals(ecc, ECCENTRICITY_DECIMALS);
749    let digits = formatted.strip_prefix("0.").unwrap_or(&formatted);
750    pad_leading_zeros(digits, ECCENTRICITY_DIGITS)
751}
752
753fn fmt_angle(val: f64) -> String {
754    pad_leading(&fixed_decimals(val, ANGLE_DECIMALS), ANGLE_WIDTH)
755}
756
757fn fmt_mean_motion(val: f64) -> String {
758    pad_leading(
759        &fixed_decimals(val, MEAN_MOTION_DECIMALS),
760        MEAN_MOTION_WIDTH,
761    )
762}
763
764fn pad_and_checksum(body: &str) -> String {
765    let clamped: String = body.chars().take(BODY_LEN).collect();
766    let padded = pad_trailing(&clamped, BODY_LEN);
767    let checksum = compute_checksum(&padded);
768    format!("{padded}{checksum}")
769}
770
771/// Fixed-decimal formatting matching Erlang `float_to_binary/2` `{decimals, n}`
772/// (round-half-to-even on the shortest exact decimal expansion).
773fn fixed_decimals(value: f64, decimals: usize) -> String {
774    format!("{value:.decimals$}")
775}
776
777fn pad_leading(s: &str, width: usize) -> String {
778    pad_leading_with(s, width, ' ')
779}
780
781fn pad_leading_zeros(s: &str, width: usize) -> String {
782    pad_leading_with(s, width, '0')
783}
784
785fn pad_leading_with(s: &str, width: usize, fill: char) -> String {
786    let len = s.chars().count();
787    if len >= width {
788        s.to_string()
789    } else {
790        let mut out: String = std::iter::repeat_n(fill, width - len).collect();
791        out.push_str(s);
792        out
793    }
794}
795
796fn pad_trailing(s: &str, width: usize) -> String {
797    let len = s.chars().count();
798    if len >= width {
799        s.to_string()
800    } else {
801        let mut out = s.to_string();
802        out.extend(std::iter::repeat_n(' ', width - len));
803        out
804    }
805}
806
807#[cfg(test)]
808mod tests {
809    use super::*;
810
811    const ISS_L1: &str = "1 25544U 98067A   18184.80969102  .00001614  00000-0  31745-4 0  9993";
812    const ISS_L2: &str = "2 25544  51.6414 295.8524 0003435 262.6267 204.2868 15.54005638121106";
813
814    #[test]
815    fn parses_iss_fields() {
816        let parsed = parse(ISS_L1, ISS_L2).unwrap();
817        let el = parsed.elements;
818        assert_eq!(el.catalog_number, "25544");
819        assert_eq!(el.classification, "U");
820        assert_eq!(el.international_designator, "98067A");
821        assert_eq!(el.epoch_year, 2018);
822        assert_eq!(el.epoch_day_of_year, 184.80969102);
823        assert_eq!(el.inclination_deg, 51.6414);
824        assert_eq!(el.eccentricity, 0.0003435);
825        assert_eq!(el.mean_motion, 15.54005638);
826        assert_eq!(el.rev_number, 12110);
827        assert!(parsed.checksum_warnings.is_empty());
828    }
829
830    #[test]
831    fn round_trips_iss_character_exact() {
832        let parsed = parse(ISS_L1, ISS_L2).unwrap();
833        let (l1, l2) = encode(&parsed.elements).unwrap();
834        assert_eq!(l1, ISS_L1);
835        assert_eq!(l2, ISS_L2);
836    }
837
838    #[test]
839    fn alpha5_catalog_examples_match_published_table() {
840        for (field, value) in [
841            ("A0000", 100_000),
842            ("E8493", 148_493),
843            ("H6932", 176_932),
844            ("J2931", 182_931),
845            ("P4018", 234_018),
846            ("W1928", 301_928),
847            ("Z9999", 339_999),
848        ] {
849            assert_eq!(decode_catalog_number(field), Ok(value));
850            assert_eq!(encode_catalog_number(value), Ok(field.to_string()));
851        }
852    }
853
854    #[test]
855    fn alpha5_catalog_round_trips_exhaustive_letter_alphabet() {
856        for letter in ALPHA5_LETTERS.chars() {
857            for suffix in [0, 1, 9998, 9999] {
858                let field = format!("{letter}{suffix:04}");
859                let value = decode_catalog_number(&field).unwrap();
860                assert_eq!(encode_catalog_number(value).unwrap(), field);
861            }
862        }
863        assert!(decode_catalog_number("I0000").is_err());
864        assert!(decode_catalog_number("O0000").is_err());
865        assert!(decode_catalog_number("a0000").is_err());
866    }
867
868    #[test]
869    fn alpha5_tle_bridge_preserves_numeric_catalog_id() {
870        let parsed = parse(ISS_L1, ISS_L2).unwrap();
871        let mut el = parsed.elements;
872        el.catalog_number = "A0000".to_string();
873
874        let (line1, line2) = encode(&el).unwrap();
875        assert_eq!(slice_inclusive(&line1, 2, 6), "A0000");
876        assert_eq!(slice_inclusive(&line2, 2, 6), "A0000");
877
878        let parsed = parse(&line1, &line2).unwrap();
879        assert_eq!(parsed.elements.catalog_number, "A0000");
880        assert_eq!(
881            parsed.elements.to_element_set().unwrap().catalog_number,
882            100_000
883        );
884    }
885
886    #[test]
887    fn tle_encode_rejects_catalog_numbers_outside_alpha5_range() {
888        let parsed = parse(ISS_L1, ISS_L2).unwrap();
889        let mut el = parsed.elements;
890        el.catalog_number = "340000".to_string();
891        assert_eq!(
892            encode(&el),
893            Err(TleError::CatalogNumberOutOfRange {
894                catalog_number: 340_000
895            })
896        );
897    }
898
899    #[test]
900    fn low_catalog_numbers_keep_leading_zeros() {
901        let l1 = "1 00005U 58002B   00179.78495062  .00000023  00000-0  28098-4 0  4753";
902        let l2 = "2 00005  34.2682 348.7242 1859667 331.7664  19.3264 10.82419157413667";
903        let parsed = parse(l1, l2).unwrap();
904        assert_eq!(parsed.elements.catalog_number, "00005");
905        assert_eq!(parsed.elements.epoch_year, 2000);
906    }
907
908    #[test]
909    fn rejects_empty_lines() {
910        assert!(parse("", "").is_err());
911    }
912
913    #[test]
914    fn rejects_non_tle_text() {
915        assert!(matches!(
916            parse("hello world", "goodbye world"),
917            Err(TleError::Format)
918        ));
919    }
920
921    #[test]
922    fn rejects_swapped_lines() {
923        assert!(parse(ISS_L2, ISS_L1).is_err());
924    }
925
926    #[test]
927    fn rejects_non_ascii() {
928        assert_eq!(
929            parse("1 25544\u{fc} test", "2 25544\u{fc} test"),
930            Err(TleError::NonAscii)
931        );
932    }
933
934    #[test]
935    fn rejects_mismatched_satellite_numbers() {
936        let l1 = "1 25544U 98067A   18184.80969102  .00001614  00000-0  31745-4 0  9993";
937        let l2 = "2 25545  51.6414 295.8524 0003435 262.6267 204.2868 15.54005638121106";
938        assert_eq!(parse(l1, l2), Err(TleError::SatelliteMismatch));
939    }
940
941    #[test]
942    fn parses_negative_drag_terms() {
943        // Construct a line with a negative bstar and verify sign handling.
944        let parsed = parse(ISS_L1, ISS_L2).unwrap();
945        assert!(parsed.elements.bstar > 0.0);
946        assert_eq!(parsed.elements.mean_motion_double_dot, 0.0);
947    }
948
949    #[test]
950    fn element_bridge_rejects_invalid_values() {
951        let mut el = parse(ISS_L1, ISS_L2).unwrap().elements;
952        el.mean_motion = f64::NAN;
953        assert_eq!(
954            el.to_element_set(),
955            Err(TleError::InvalidField {
956                field: "mean_motion",
957                reason: "not finite"
958            })
959        );
960
961        let mut el = parse(ISS_L1, ISS_L2).unwrap().elements;
962        el.eccentricity = 1.0;
963        assert_eq!(
964            el.to_element_set(),
965            Err(TleError::InvalidField {
966                field: "eccentricity",
967                reason: "out of range"
968            })
969        );
970    }
971
972    #[test]
973    fn assumed_decimal_rounding_carry_bumps_exponent() {
974        let mut el = parse(ISS_L1, ISS_L2).unwrap().elements;
975        el.mean_motion_double_dot = 9.999996e-5;
976        el.bstar = 9.999996e-5;
977
978        let (line1, line2) = encode(&el).unwrap();
979        assert_eq!(slice_inclusive(&line1, 44, 51), " 10000-3");
980        assert_eq!(slice_inclusive(&line1, 53, 60), " 10000-3");
981
982        let parsed = parse(&line1, &line2).unwrap().elements;
983        assert_eq!(parsed.mean_motion_double_dot, 1.0e-4);
984        assert_eq!(parsed.bstar, 1.0e-4);
985
986        let (round_trip_line1, round_trip_line2) = encode(&parsed).unwrap();
987        assert_eq!(round_trip_line1, line1);
988        assert_eq!(round_trip_line2, line2);
989    }
990
991    #[test]
992    fn checksum_mismatch_is_reported_not_rejected() {
993        // Flip the final checksum digit of line 1 (9993 -> 9990).
994        let bad_l1 = "1 25544U 98067A   18184.80969102  .00001614  00000-0  31745-4 0  9990";
995        let parsed = parse(bad_l1, ISS_L2).unwrap();
996        assert_eq!(parsed.checksum_warnings.len(), 1);
997        assert_eq!(parsed.checksum_warnings[0].line_label, "line 1");
998        assert_eq!(parsed.checksum_warnings[0].expected, 0);
999        assert_eq!(parsed.checksum_warnings[0].computed, 3);
1000    }
1001}