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
76/// Parsed TLE orbital elements in canonical astrodynamic units.
77///
78/// Angles are degrees, mean motion is revolutions/day and its derivatives
79/// rev/day^2 and rev/day^3, BSTAR drag is 1/earth-radii, and the epoch is the
80/// calendar `epoch_year` plus the one-based fractional `epoch_day_of_year`.
81#[derive(Debug, Clone, PartialEq)]
82pub struct TleElements {
83    pub catalog_number: String,
84    pub classification: String,
85    pub international_designator: String,
86    pub epoch_year: i32,
87    pub epoch_day_of_year: f64,
88    pub mean_motion_dot: f64,
89    pub mean_motion_double_dot: f64,
90    pub bstar: f64,
91    pub ephemeris_type: i32,
92    pub elset_number: i32,
93    pub inclination_deg: f64,
94    pub raan_deg: f64,
95    pub eccentricity: f64,
96    pub arg_perigee_deg: f64,
97    pub mean_anomaly_deg: f64,
98    pub mean_motion: f64,
99    pub rev_number: i32,
100}
101
102impl TleElements {
103    /// Convert these parsed TLE elements into the canonical SGP4 [`ElementSet`]
104    /// IR consumed by [`crate::astro::sgp4::Satellite::from_elements`].
105    ///
106    /// This is the single TLE-to-IR mapping: the public TLE entry point parses a
107    /// TLE to [`TleElements`], converts here, and feeds the result into the same
108    /// `ElementSet -> satrec` initialization every other input format uses, so
109    /// there is no separate TLE-direct propagation path.
110    ///
111    /// The mapping is bit-preserving for SGP4. The angle, eccentricity, mean
112    /// motion, and epoch-day fields are carried through unchanged until the
113    /// epoch is converted through the same `days2mdhms`/`jday` math and
114    /// 8-decimal fraction rounding Vallado uses for TLE input. B\* and the
115    /// second mean-motion derivative are decoded with `powi` in [`parse`]
116    /// precisely so they equal the `mantissa * 10^exp` product the element-set
117    /// initializer expects; they too pass through unchanged.
118    ///
119    /// The catalog number is parsed to the numeric form `ElementSet` carries; it
120    /// is used only for SGP4 diagnostics and does not affect propagation, so a
121    /// non-numeric (Alpha-5) catalog falls back to `0`.
122    pub fn to_element_set(&self) -> Result<ElementSet, TleError> {
123        validate_tle_bridge(self)?;
124        Ok(ElementSet {
125            epoch: sgp4::sgp4_julian_date_from_day_of_year(self.epoch_year, self.epoch_day_of_year),
126            bstar: self.bstar,
127            mean_motion_dot: self.mean_motion_dot,
128            mean_motion_double_dot: self.mean_motion_double_dot,
129            eccentricity: self.eccentricity,
130            argument_of_perigee_deg: self.arg_perigee_deg,
131            inclination_deg: self.inclination_deg,
132            mean_anomaly_deg: self.mean_anomaly_deg,
133            mean_motion_rev_per_day: self.mean_motion,
134            right_ascension_deg: self.raan_deg,
135            catalog_number: self.catalog_number.trim().parse().unwrap_or(0),
136        })
137    }
138}
139
140/// A reported checksum discrepancy. The format grammar does not reject a line on
141/// a bad checksum (it is advisory), so this is surfaced for the host to log.
142#[derive(Debug, Clone, PartialEq, Eq)]
143pub struct ChecksumWarning {
144    /// Human label for the offending line (`"line 1"` / `"line 2"`).
145    pub line_label: &'static str,
146    /// Checksum digit found in column 69.
147    pub expected: u8,
148    /// Checksum computed from columns 1-68.
149    pub computed: u8,
150}
151
152/// The result of [`parse`]: the elements plus any advisory checksum warnings.
153#[derive(Debug, Clone, PartialEq)]
154pub struct ParsedTle {
155    pub elements: TleElements,
156    pub checksum_warnings: Vec<ChecksumWarning>,
157}
158
159/// Failure modes of [`parse`]. Messages mirror the historical reference strings.
160#[derive(Debug, Clone, PartialEq, Eq)]
161pub enum TleError {
162    NonAscii,
163    Format,
164    SatelliteMismatch,
165    InvalidField {
166        field: &'static str,
167        reason: &'static str,
168    },
169    Field(String),
170}
171
172impl fmt::Display for TleError {
173    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
174        match self {
175            TleError::NonAscii => write!(f, "TLE lines contain non-ASCII characters"),
176            TleError::Format => write!(
177                f,
178                "TLE format error: line does not match the Two-Line Element fixed-width format"
179            ),
180            TleError::SatelliteMismatch => {
181                write!(f, "Satellite numbers in lines 1 and 2 do not match")
182            }
183            TleError::InvalidField { field, reason } => {
184                write!(f, "TLE invalid field {field}: {reason}")
185            }
186            TleError::Field(msg) => write!(f, "TLE parse error: {msg}"),
187        }
188    }
189}
190
191impl std::error::Error for TleError {}
192
193fn validate_tle_bridge(elements: &TleElements) -> Result<(), TleError> {
194    validate::finite(elements.epoch_day_of_year, "epoch_day_of_year").map_err(map_tle_field)?;
195    validate::finite(elements.bstar, "bstar").map_err(map_tle_field)?;
196    validate::finite(elements.mean_motion_dot, "mean_motion_dot").map_err(map_tle_field)?;
197    validate::finite(elements.mean_motion_double_dot, "mean_motion_double_dot")
198        .map_err(map_tle_field)?;
199    validate::finite_in_range_exclusive_upper(elements.eccentricity, 0.0, 1.0, "eccentricity")
200        .map_err(map_tle_field)?;
201    validate::finite(elements.arg_perigee_deg, "arg_perigee_deg").map_err(map_tle_field)?;
202    validate::finite(elements.inclination_deg, "inclination_deg").map_err(map_tle_field)?;
203    validate::finite(elements.mean_anomaly_deg, "mean_anomaly_deg").map_err(map_tle_field)?;
204    validate::finite_positive(elements.mean_motion, "mean_motion").map_err(map_tle_field)?;
205    validate::finite(elements.raan_deg, "raan_deg").map_err(map_tle_field)?;
206    Ok(())
207}
208
209fn map_tle_field(error: validate::FieldError) -> TleError {
210    TleError::InvalidField {
211        field: error.field(),
212        reason: error.reason(),
213    }
214}
215
216/// Parse a two-line element set into [`TleElements`].
217///
218/// The parser is liberal: trailing content past column 69 is trimmed, leading-dot
219/// floats are normalized, and an invalid checksum is reported (via
220/// [`ParsedTle::checksum_warnings`]) rather than rejected.
221pub fn parse(line1: &str, line2: &str) -> Result<ParsedTle, TleError> {
222    if !is_ascii(line1) || !is_ascii(line2) {
223        return Err(TleError::NonAscii);
224    }
225
226    let line1 = clean_line(line1);
227    let line2 = clean_line(line2);
228
229    validate_format(&line1, &line2)?;
230    let elements = extract_fields(&line1, &line2)?;
231    let checksum_warnings = checksum_warnings(&line1, &line2);
232
233    Ok(ParsedTle {
234        elements,
235        checksum_warnings,
236    })
237}
238
239/// Encode [`TleElements`] as the two 69-character TLE lines (with checksums).
240///
241/// The caller is responsible for supplying normalized field values (defaults
242/// applied, widths validated); this function performs the fixed-width formatting,
243/// assumed-decimal encoding, and checksum generation.
244pub fn encode(el: &TleElements) -> (String, String) {
245    let cat = pad_leading(el.catalog_number.trim(), CATALOG_WIDTH);
246    let cls = &el.classification;
247    let intl = pad_trailing(&el.international_designator, INTL_DESIGNATOR_WIDTH);
248
249    let epoch_two_digit = el.epoch_year.rem_euclid(100);
250
251    let l1_body = format!(
252        "1 {cat}{cls} {intl} {epoch} {ndot} {nddot} {bstar} {ephtype} {elnum}",
253        epoch = fmt_epoch(epoch_two_digit, el.epoch_day_of_year),
254        ndot = fmt_ndot(el.mean_motion_dot),
255        nddot = fmt_assumed_decimal(el.mean_motion_double_dot),
256        bstar = fmt_assumed_decimal(el.bstar),
257        ephtype = el.ephemeris_type,
258        elnum = pad_leading(&el.elset_number.to_string(), ELSET_WIDTH),
259    );
260    let line1 = pad_and_checksum(&l1_body);
261
262    let l2_body = format!(
263        "2 {cat} {inclo} {raan} {ecc} {argp} {mo} {mm}{revnum}",
264        inclo = fmt_angle(el.inclination_deg),
265        raan = fmt_angle(el.raan_deg),
266        ecc = fmt_eccentricity(el.eccentricity),
267        argp = fmt_angle(el.arg_perigee_deg),
268        mo = fmt_angle(el.mean_anomaly_deg),
269        mm = fmt_mean_motion(el.mean_motion),
270        revnum = pad_leading(&el.rev_number.to_string(), REV_WIDTH),
271    );
272    let line2 = pad_and_checksum(&l2_body);
273
274    (line1, line2)
275}
276
277// -- Parsing internals --
278
279fn is_ascii(line: &str) -> bool {
280    line.chars().all(|c| (c as u32) <= MAX_ASCII)
281}
282
283/// Trim trailing whitespace and clamp to the significant TLE width.
284fn clean_line(line: &str) -> String {
285    let trimmed = line.trim_end();
286    if trimmed.len() > MAX_LINE_LEN {
287        trimmed[..MAX_LINE_LEN].to_string()
288    } else {
289        trimmed.to_string()
290    }
291}
292
293fn validate_format(line1: &str, line2: &str) -> Result<(), TleError> {
294    validate_line(line1, '1', LINE1_MIN_LEN, &LINE1_POSITIONS)?;
295    validate_line(line2, '2', LINE2_MIN_LEN, &LINE2_POSITIONS)?;
296    if slice_inclusive(line1, 2, 6) == slice_inclusive(line2, 2, 6) {
297        Ok(())
298    } else {
299        Err(TleError::SatelliteMismatch)
300    }
301}
302
303fn validate_line(
304    line: &str,
305    prefix: char,
306    min_len: usize,
307    positions: &[(usize, char)],
308) -> Result<(), TleError> {
309    let len = line.chars().count();
310    if len < min_len {
311        return Err(TleError::Format);
312    }
313    let mut start = String::with_capacity(2);
314    start.push(prefix);
315    start.push(' ');
316    if !line.starts_with(&start) {
317        return Err(TleError::Format);
318    }
319    if positions
320        .iter()
321        .all(|&(pos, ch)| char_at(line, pos) == Some(ch))
322    {
323        Ok(())
324    } else {
325        Err(TleError::Format)
326    }
327}
328
329const LINE1_POSITIONS: [(usize, char); 8] = [
330    (8, ' '),
331    (23, '.'),
332    (32, ' '),
333    (34, '.'),
334    (43, ' '),
335    (52, ' '),
336    (61, ' '),
337    (63, ' '),
338];
339
340const LINE2_POSITIONS: [(usize, char); 10] = [
341    (7, ' '),
342    (11, '.'),
343    (16, ' '),
344    (20, '.'),
345    (25, ' '),
346    (33, ' '),
347    (37, '.'),
348    (42, ' '),
349    (46, '.'),
350    (51, ' '),
351];
352
353fn extract_fields(line1: &str, line2: &str) -> Result<TleElements, TleError> {
354    let two_digit_year = parse_int(slice_inclusive(line1, 18, 19).trim())?;
355    let epoch_year = if two_digit_year < YEAR_PIVOT {
356        2000 + two_digit_year
357    } else {
358        1900 + two_digit_year
359    };
360
361    Ok(TleElements {
362        catalog_number: slice_inclusive(line1, 2, 6).trim().to_string(),
363        classification: char_at(line1, 7).unwrap_or('U').to_string(),
364        international_designator: slice_inclusive(line1, 9, 16).trim_end().to_string(),
365        epoch_year,
366        epoch_day_of_year: parse_float(slice_inclusive(line1, 20, 31))?,
367        mean_motion_dot: parse_float(slice_inclusive(line1, 33, 42))?,
368        mean_motion_double_dot: parse_assumed_decimal(line1, 44, 45, 49, 50, 51)?,
369        bstar: parse_assumed_decimal(line1, 53, 54, 58, 59, 60)?,
370        ephemeris_type: parse_int_or_default(
371            char_at(line1, 62)
372                .map(|c| c.to_string())
373                .unwrap_or_default()
374                .trim(),
375            0,
376        )?,
377        elset_number: parse_int_or_default(slice_inclusive(line1, 64, 67).trim(), 0)?,
378        inclination_deg: parse_float(slice_inclusive(line2, 8, 15))?,
379        raan_deg: parse_float(slice_inclusive(line2, 17, 24))?,
380        eccentricity: parse_eccentricity(slice_inclusive(line2, 26, 32))?,
381        arg_perigee_deg: parse_float(slice_inclusive(line2, 34, 41))?,
382        mean_anomaly_deg: parse_float(slice_inclusive(line2, 43, 50))?,
383        mean_motion: parse_float(slice_inclusive(line2, 52, 62))?,
384        rev_number: parse_int_or_default(slice_inclusive(line2, 63, 67).trim(), 0)?,
385    })
386}
387
388/// Parse an "assumed decimal" exponent field: `[sign][mantissa][exp_sign][exp]`,
389/// representing `0.<mantissa> * 10^exp`.
390fn parse_assumed_decimal(
391    line: &str,
392    sign_pos: usize,
393    mant_start: usize,
394    mant_end: usize,
395    exp_start: usize,
396    exp_end: usize,
397) -> Result<f64, TleError> {
398    let sign = if char_at(line, sign_pos) == Some('-') {
399        -1.0
400    } else {
401        1.0
402    };
403    let mantissa_field = format!("0.{}", slice_inclusive(line, mant_start, mant_end));
404    let mantissa = parse_float_raw(mantissa_field.trim())?;
405    let exp = parse_int(slice_inclusive(line, exp_start, exp_end).trim())?;
406    // Decode with `powi` (integer exponent), matching `decode_assumed_decimal_field`
407    // and the SGP4 element-set init: the value reaching SGP4 must be the exact
408    // `mantissa * 10^exp` product the golden path produces, so the canonical
409    // element set built from a parsed TLE drives SGP4 bit-identically.
410    Ok(sign * mantissa * 10.0_f64.powi(exp))
411}
412
413/// Parse the implicit-leading-`0.` eccentricity field (spaces read as `0`).
414fn parse_eccentricity(field: &str) -> Result<f64, TleError> {
415    let digits = field.replace(' ', "0");
416    parse_float_raw(&format!("0.{digits}"))
417}
418
419/// Replicate the reference float normalization: trim, strip a leading `+`, and
420/// supply the integer `0` for a leading-dot value before strict float parsing.
421fn parse_float(field: &str) -> Result<f64, TleError> {
422    let trimmed = field.trim();
423    let without_plus = trimmed.strip_prefix('+').unwrap_or(trimmed);
424    let normalized = if let Some(rest) = without_plus.strip_prefix("-.") {
425        format!("-0.{rest}")
426    } else if let Some(rest) = without_plus.strip_prefix('.') {
427        format!("0.{rest}")
428    } else {
429        without_plus.to_string()
430    };
431    parse_float_raw(&normalized)
432}
433
434/// Strict float parse that rejects the integer-only and leading/trailing-dot forms
435/// the reference `String.to_float/1` rejects, so malformed fields surface as errors.
436fn parse_float_raw(text: &str) -> Result<f64, TleError> {
437    if !text.contains('.') {
438        return Err(TleError::Field(format!("invalid float {text:?}")));
439    }
440    let body = text.strip_prefix('-').unwrap_or(text);
441    if body.starts_with('.') || body.ends_with('.') {
442        return Err(TleError::Field(format!("invalid float {text:?}")));
443    }
444    text.parse::<f64>()
445        .map_err(|_| TleError::Field(format!("invalid float {text:?}")))
446}
447
448fn parse_int(text: &str) -> Result<i32, TleError> {
449    text.parse::<i32>()
450        .map_err(|_| TleError::Field(format!("invalid integer {text:?}")))
451}
452
453/// Parse an integer field that is optional in practice: a blank (all-spaces)
454/// field falls back to `default`. The element-set and revolution numbers are
455/// bookkeeping fields some generators leave empty; they do not affect SGP4
456/// propagation, so a blank one is a cosmetic absence rather than corruption.
457fn parse_int_or_default(text: &str, default: i32) -> Result<i32, TleError> {
458    if text.is_empty() {
459        Ok(default)
460    } else {
461        parse_int(text)
462    }
463}
464
465fn checksum_warnings(line1: &str, line2: &str) -> Vec<ChecksumWarning> {
466    [("line 1", line1), ("line 2", line2)]
467        .into_iter()
468        .filter_map(|(label, line)| check_one(label, line))
469        .collect()
470}
471
472fn check_one(label: &'static str, line: &str) -> Option<ChecksumWarning> {
473    if line.chars().count() < MAX_LINE_LEN {
474        return None;
475    }
476    let expected = char_at(line, CHECKSUM_COL)
477        .and_then(|c| c.to_digit(10))
478        .map(|d| d as u8)?;
479    let computed = compute_checksum(line);
480    if expected == computed {
481        None
482    } else {
483        Some(ChecksumWarning {
484            line_label: label,
485            expected,
486            computed,
487        })
488    }
489}
490
491/// Modulo-10 checksum over columns 1-68: digits add their value, `-` adds 1, all
492/// other characters add 0.
493fn compute_checksum(line: &str) -> u8 {
494    let sum: u32 = line
495        .chars()
496        .take(BODY_LEN)
497        .map(|c| match c {
498            '0'..='9' => c as u32 - '0' as u32,
499            '-' => 1,
500            _ => 0,
501        })
502        .sum();
503    (sum % 10) as u8
504}
505
506// -- Slicing helpers (TLE lines are ASCII, so char index == byte index) --
507
508/// Inclusive character slice mirroring the reference `String.slice(s, a..b)`:
509/// clamps to the available length and returns `""` when `start` is past the end.
510fn slice_inclusive(s: &str, start: usize, end_inclusive: usize) -> &str {
511    let len = s.len();
512    if start >= len {
513        return "";
514    }
515    let end = (end_inclusive + 1).min(len);
516    &s[start..end]
517}
518
519fn char_at(s: &str, index: usize) -> Option<char> {
520    s.as_bytes().get(index).map(|&b| b as char)
521}
522
523/// Quantize a value onto the TLE "assumed decimal" grid (five significant
524/// mantissa digits and a power-of-ten exponent) and decode it back, yielding the
525/// exact `f64` SGP4 receives when the same quantity is carried through a TLE.
526///
527/// OMM encodes B\* and the second mean-motion derivative as plain decimals, but
528/// their canonical SGP4 representation is this five-digit assumed-decimal field;
529/// quantizing through it lets an OMM drive SGP4 bit-identically to the equivalent
530/// TLE. The decode mirrors the parse in `sgp4::init_satrec_from_tle`
531/// (`mantissa * 10f64.powi(exp)`), so a quantized OMM B\* equals the value the
532/// matching TLE produces to 0 ULP.
533pub(crate) fn assumed_decimal_quantize(value: f64) -> f64 {
534    if value == 0.0 {
535        return 0.0;
536    }
537    decode_assumed_decimal_field(&fmt_assumed_decimal(value))
538}
539
540/// Decode the eight-or-more character assumed-decimal field emitted by
541/// [`fmt_assumed_decimal`] (`"[sign|space]MMMMM[exp-sign]E"`).
542fn decode_assumed_decimal_field(field: &str) -> f64 {
543    let sign = if field.starts_with('-') { -1.0 } else { 1.0 };
544    let body = &field[1..];
545    let mantissa_digits = &body[..ASSUMED_DECIMAL_MANTISSA_DIGITS];
546    let exp_field = &body[ASSUMED_DECIMAL_MANTISSA_DIGITS..];
547    let exp_field = exp_field.strip_prefix('+').unwrap_or(exp_field);
548    let mantissa: f64 = format!("0.{mantissa_digits}").parse().unwrap_or(0.0);
549    let exp: i32 = exp_field.parse().unwrap_or(0);
550    sign * mantissa * 10.0_f64.powi(exp)
551}
552
553// -- Encoding internals --
554
555fn fmt_epoch(year_two_digit: i32, day_of_year: f64) -> String {
556    let yr = pad_leading_zeros(&year_two_digit.to_string(), EPOCH_YEAR_WIDTH);
557    let days = fixed_decimals(day_of_year, EPOCH_DAY_DECIMALS);
558    format!("{yr}{}", pad_leading_zeros(&days, EPOCH_DAY_WIDTH))
559}
560
561fn fmt_ndot(val: f64) -> String {
562    let sign = if val < 0.0 { '-' } else { ' ' };
563    let mut digits = fixed_decimals(val.abs(), NDOT_DECIMALS);
564    if let Some(rest) = digits.strip_prefix('0') {
565        digits = rest.to_string();
566    }
567    format!("{sign}{}", pad_leading(&digits, NDOT_WIDTH))
568}
569
570/// Format an "assumed decimal" field (`0.<mantissa> * 10^exp`) for the drag terms.
571fn fmt_assumed_decimal(val: f64) -> String {
572    if val == 0.0 {
573        return " 00000-0".to_string();
574    }
575    let sign = if val < 0.0 { '-' } else { ' ' };
576    let av = val.abs();
577    let raw_exp = floor(log10(av)) as i32;
578    let mut exp = raw_exp + 1;
579    let mantissa = av / pow(10.0, exp as f64);
580    let mut mant_full = fixed_decimals(mantissa, ASSUMED_DECIMAL_MANTISSA_DECIMALS);
581    if mant_full.starts_with("1.") {
582        exp += 1;
583        mant_full = fixed_decimals(mantissa / 10.0, ASSUMED_DECIMAL_MANTISSA_DECIMALS);
584    }
585    let mant_str: String = mant_full
586        .chars()
587        .skip(2)
588        .take(ASSUMED_DECIMAL_MANTISSA_DIGITS)
589        .collect();
590    let exp_sign = if exp >= 0 { '+' } else { '-' };
591    format!("{sign}{mant_str}{exp_sign}{}", exp.abs())
592}
593
594fn fmt_eccentricity(ecc: f64) -> String {
595    let formatted = fixed_decimals(ecc, ECCENTRICITY_DECIMALS);
596    let digits = formatted.strip_prefix("0.").unwrap_or(&formatted);
597    pad_leading_zeros(digits, ECCENTRICITY_DIGITS)
598}
599
600fn fmt_angle(val: f64) -> String {
601    pad_leading(&fixed_decimals(val, ANGLE_DECIMALS), ANGLE_WIDTH)
602}
603
604fn fmt_mean_motion(val: f64) -> String {
605    pad_leading(
606        &fixed_decimals(val, MEAN_MOTION_DECIMALS),
607        MEAN_MOTION_WIDTH,
608    )
609}
610
611fn pad_and_checksum(body: &str) -> String {
612    let clamped: String = body.chars().take(BODY_LEN).collect();
613    let padded = pad_trailing(&clamped, BODY_LEN);
614    let checksum = compute_checksum(&padded);
615    format!("{padded}{checksum}")
616}
617
618/// Fixed-decimal formatting matching Erlang `float_to_binary/2` `{decimals, n}`
619/// (round-half-to-even on the shortest exact decimal expansion).
620fn fixed_decimals(value: f64, decimals: usize) -> String {
621    format!("{value:.decimals$}")
622}
623
624fn pad_leading(s: &str, width: usize) -> String {
625    pad_leading_with(s, width, ' ')
626}
627
628fn pad_leading_zeros(s: &str, width: usize) -> String {
629    pad_leading_with(s, width, '0')
630}
631
632fn pad_leading_with(s: &str, width: usize, fill: char) -> String {
633    let len = s.chars().count();
634    if len >= width {
635        s.to_string()
636    } else {
637        let mut out: String = std::iter::repeat_n(fill, width - len).collect();
638        out.push_str(s);
639        out
640    }
641}
642
643fn pad_trailing(s: &str, width: usize) -> String {
644    let len = s.chars().count();
645    if len >= width {
646        s.to_string()
647    } else {
648        let mut out = s.to_string();
649        out.extend(std::iter::repeat_n(' ', width - len));
650        out
651    }
652}
653
654#[cfg(test)]
655mod tests {
656    use super::*;
657
658    const ISS_L1: &str = "1 25544U 98067A   18184.80969102  .00001614  00000-0  31745-4 0  9993";
659    const ISS_L2: &str = "2 25544  51.6414 295.8524 0003435 262.6267 204.2868 15.54005638121106";
660
661    #[test]
662    fn parses_iss_fields() {
663        let parsed = parse(ISS_L1, ISS_L2).unwrap();
664        let el = parsed.elements;
665        assert_eq!(el.catalog_number, "25544");
666        assert_eq!(el.classification, "U");
667        assert_eq!(el.international_designator, "98067A");
668        assert_eq!(el.epoch_year, 2018);
669        assert_eq!(el.epoch_day_of_year, 184.80969102);
670        assert_eq!(el.inclination_deg, 51.6414);
671        assert_eq!(el.eccentricity, 0.0003435);
672        assert_eq!(el.mean_motion, 15.54005638);
673        assert_eq!(el.rev_number, 12110);
674        assert!(parsed.checksum_warnings.is_empty());
675    }
676
677    #[test]
678    fn round_trips_iss_character_exact() {
679        let parsed = parse(ISS_L1, ISS_L2).unwrap();
680        let (l1, l2) = encode(&parsed.elements);
681        assert_eq!(l1, ISS_L1);
682        assert_eq!(l2, ISS_L2);
683    }
684
685    #[test]
686    fn low_catalog_numbers_keep_leading_zeros() {
687        let l1 = "1 00005U 58002B   00179.78495062  .00000023  00000-0  28098-4 0  4753";
688        let l2 = "2 00005  34.2682 348.7242 1859667 331.7664  19.3264 10.82419157413667";
689        let parsed = parse(l1, l2).unwrap();
690        assert_eq!(parsed.elements.catalog_number, "00005");
691        assert_eq!(parsed.elements.epoch_year, 2000);
692    }
693
694    #[test]
695    fn rejects_empty_lines() {
696        assert!(parse("", "").is_err());
697    }
698
699    #[test]
700    fn rejects_non_tle_text() {
701        assert!(matches!(
702            parse("hello world", "goodbye world"),
703            Err(TleError::Format)
704        ));
705    }
706
707    #[test]
708    fn rejects_swapped_lines() {
709        assert!(parse(ISS_L2, ISS_L1).is_err());
710    }
711
712    #[test]
713    fn rejects_non_ascii() {
714        assert_eq!(
715            parse("1 25544\u{fc} test", "2 25544\u{fc} test"),
716            Err(TleError::NonAscii)
717        );
718    }
719
720    #[test]
721    fn rejects_mismatched_satellite_numbers() {
722        let l1 = "1 25544U 98067A   18184.80969102  .00001614  00000-0  31745-4 0  9993";
723        let l2 = "2 25545  51.6414 295.8524 0003435 262.6267 204.2868 15.54005638121106";
724        assert_eq!(parse(l1, l2), Err(TleError::SatelliteMismatch));
725    }
726
727    #[test]
728    fn parses_negative_drag_terms() {
729        // Construct a line with a negative bstar and verify sign handling.
730        let parsed = parse(ISS_L1, ISS_L2).unwrap();
731        assert!(parsed.elements.bstar > 0.0);
732        assert_eq!(parsed.elements.mean_motion_double_dot, 0.0);
733    }
734
735    #[test]
736    fn element_bridge_rejects_invalid_values() {
737        let mut el = parse(ISS_L1, ISS_L2).unwrap().elements;
738        el.mean_motion = f64::NAN;
739        assert_eq!(
740            el.to_element_set(),
741            Err(TleError::InvalidField {
742                field: "mean_motion",
743                reason: "not finite"
744            })
745        );
746
747        let mut el = parse(ISS_L1, ISS_L2).unwrap().elements;
748        el.eccentricity = 1.0;
749        assert_eq!(
750            el.to_element_set(),
751            Err(TleError::InvalidField {
752                field: "eccentricity",
753                reason: "out of range"
754            })
755        );
756    }
757
758    #[test]
759    fn assumed_decimal_rounding_carry_bumps_exponent() {
760        let mut el = parse(ISS_L1, ISS_L2).unwrap().elements;
761        el.mean_motion_double_dot = 9.999996e-5;
762        el.bstar = 9.999996e-5;
763
764        let (line1, line2) = encode(&el);
765        assert_eq!(slice_inclusive(&line1, 44, 51), " 10000-3");
766        assert_eq!(slice_inclusive(&line1, 53, 60), " 10000-3");
767
768        let parsed = parse(&line1, &line2).unwrap().elements;
769        assert_eq!(parsed.mean_motion_double_dot, 1.0e-4);
770        assert_eq!(parsed.bstar, 1.0e-4);
771
772        let (round_trip_line1, round_trip_line2) = encode(&parsed);
773        assert_eq!(round_trip_line1, line1);
774        assert_eq!(round_trip_line2, line2);
775    }
776
777    #[test]
778    fn checksum_mismatch_is_reported_not_rejected() {
779        // Flip the final checksum digit of line 1 (9993 -> 9990).
780        let bad_l1 = "1 25544U 98067A   18184.80969102  .00001614  00000-0  31745-4 0  9990";
781        let parsed = parse(bad_l1, ISS_L2).unwrap();
782        assert_eq!(parsed.checksum_warnings.len(), 1);
783        assert_eq!(parsed.checksum_warnings[0].line_label, "line 1");
784        assert_eq!(parsed.checksum_warnings[0].expected, 0);
785        assert_eq!(parsed.checksum_warnings[0].computed, 3);
786    }
787}