Current section
Files
Jump to
Current section
Files
lib/utils/tle.ex
defmodule SpaceDust.Utils.Tle do
@moduledoc """
Parsing and propagation utilities for Two-Line Element Sets (TLEs).
This module provides functions to:
- Parse TLE strings into structured data
- Propagate TLEs to compute satellite position/velocity at any time
## TLE Format
A TLE consists of two 69-character lines containing orbital elements in a
fixed-width format. The format is standardized by NORAD/USSPACECOM.
## SGP4 Propagation
TLE propagation uses the SGP4 (Simplified General Perturbations 4) algorithm,
which accounts for:
- Earth's oblateness (J2 perturbation)
- Atmospheric drag
- Solar/lunar gravitational effects (for high-altitude orbits)
The output is in the TEME (True Equator Mean Equinox) reference frame,
which can be converted to ECI J2000 using `SpaceDust.State.Transforms`.
## Example
# Parse a TLE
line1 = "1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9002"
line2 = "2 25544 51.6400 208.1200 0001234 85.0000 275.0000 15.48919100123456"
{:ok, tle} = SpaceDust.Utils.Tle.parseTLE(line1, line2)
# Propagate to a future time
epoch = ~U[2024-01-02 12:00:00Z]
{position, velocity} = SpaceDust.Utils.Tle.getRVatTime(tle, epoch)
# position is {x, y, z} in km
# velocity is {vx, vy, vz} in km/s
"""
require Logger
alias SpaceDust.Utils.TwoLineElementSet, as: TwoLineElementSet
alias SpaceDust.Utils.Constants, as: Constants
@microsecondsPerDay Constants.secondsPerDay() * 1_000_000
@doc """
Parse a Two-Line Element set from its string representation.
## Parameters
- `line1` - First line of the TLE (69 characters)
- `line2` - Second line of the TLE (69 characters)
## Returns
- `{:ok, %TwoLineElementSet{}}` - Successfully parsed TLE
- `{:error, reason}` - Parsing failed
## Example
line1 = "1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9002"
line2 = "2 25544 51.6400 208.1200 0001234 85.0000 275.0000 15.48919100123456"
{:ok, tle} = SpaceDust.Utils.Tle.parseTLE(line1, line2)
"""
@spec parseTLE(String.t(), String.t()) :: {:ok, TwoLineElementSet.t()} | {:error, String.t()}
def parseTLE(line1, line2) do
# check to ensure each line is 69 characters
case {String.length(line1), String.length(line2)} do
# TLE has correct number of characters, proceed with parsing
{69, 69} ->
try do
lastDigitsOfYear = String.to_integer(String.slice(line1, 18..19))
currentYear = Date.utc_today().year
epochYear =
if lastDigitsOfYear > currentYear - 2000 do
1900 + lastDigitsOfYear
else
2000 + lastDigitsOfYear
end
# day 1 is 0 days from the start of the year
dayOfYear = String.to_float(String.slice(line1, 20..31)) - 1
daysToAdd = trunc(dayOfYear)
microsecondsToAdd = trunc((dayOfYear - daysToAdd) * @microsecondsPerDay)
startOfYear = DateTime.new!(Date.new!(epochYear, 1, 1), Time.new!(0, 0, 0, 0))
epoch =
DateTime.add(startOfYear, daysToAdd, :day)
|> DateTime.add(microsecondsToAdd, :microsecond)
meanMotionDoubleDot =
if String.at(line1, 44) == "-" do
-1 * String.to_float(String.replace("0." <> String.slice(line1, 45..49), " ", "")) *
Float.pow(10.0, String.to_integer(String.slice(line1, 50..51)))
else
String.to_float(String.replace("0." <> String.slice(line1, 45..49), " ", "")) *
Float.pow(10.0, String.to_integer(String.slice(line1, 50..51)))
end
meanMotionDot =
if String.at(line1, 33) == "-" do
-1 * String.to_float(String.replace("0" <> String.slice(line1, 34..42), " ", ""))
else
String.to_float(String.replace("0" <> String.slice(line1, 34..42), " ", ""))
end
bStar =
if String.at(line1, 53) == "-" do
-1 * String.to_float(String.replace("0." <> String.slice(line1, 54..58), " ", "")) *
Float.pow(10.0, String.to_integer(String.slice(line1, 59..60)))
else
String.to_float(String.replace("0." <> String.slice(line1, 53..58), " ", "")) *
Float.pow(10.0, String.to_integer(String.slice(line1, 59..60)))
end
tle = %TwoLineElementSet{
# line 1 parameters
line1: line1,
catalogNumber: String.replace(String.slice(line1, 2..6), " ", ""),
classification: String.at(line1, 7),
internationalDesignator: String.replace(String.slice(line1, 9..16), " ", ""),
epoch: epoch,
meanMotionDot: meanMotionDot,
meanMotionDoubleDot: meanMotionDoubleDot,
bStar: bStar,
ephemerisType: String.to_integer(String.at(line1, 62)),
elementSetNumber:
String.to_integer(String.replace(String.slice(line1, 64..67), " ", "")),
# line 2 parameters
line2: line2,
inclinationDeg: String.to_float(String.replace(String.slice(line2, 8..15), " ", "")),
raanDeg: String.to_float(String.replace(String.slice(line2, 17..24), " ", "")),
eccentricity:
String.to_float(String.replace("0." <> String.slice(line2, 26..32), " ", "")),
argPerigeeDeg: String.to_float(String.replace(String.slice(line2, 34..41), " ", "")),
meanAnomalyDeg: String.to_float(String.replace(String.slice(line2, 43..50), " ", "")),
meanMotion: String.to_float(String.replace(String.slice(line2, 52..62), " ", "")),
revNumber: String.to_integer(String.replace(String.slice(line2, 63..67), " ", ""))
}
{:ok, tle}
rescue
ArgumentError -> {:error, "Unable to parse TLE- check all fields are correctly spaced"}
end
_ ->
{:error, "Unable to parse TLE- line length is incorrect"}
end
end
@doc """
Propagate a TLE to compute position and velocity at a given epoch.
Uses the SGP4 algorithm via the sgp4_ex library (native C++ implementation)
for fast and accurate propagation. The output is in the TEME reference frame.
## Parameters
- `twoLineElementSet` - Parsed TLE from `parseTLE/2`
- `utcEpoch` - Target time as a DateTime in UTC
## Returns
- `{{x, y, z}, {vx, vy, vz}}` - Position (km) and velocity (km/s) in TEME frame
- `{:error, reason}` - Propagation failed
## Example
{:ok, tle} = SpaceDust.Utils.Tle.parseTLE(line1, line2)
{position, velocity} = SpaceDust.Utils.Tle.getRVatTime(tle, ~U[2024-01-15 12:00:00Z])
# Convert to ECI J2000 for further calculations
teme = SpaceDust.State.TEMEState.new(epoch, position, velocity)
eci = SpaceDust.State.Transforms.teme_to_eci(teme)
## Notes
- Propagation accuracy degrades as time increases from the TLE epoch
- TLEs are typically valid for a few days before/after their epoch
- For best results, use the most recent TLE available
"""
@spec getRVatTime(TwoLineElementSet.t(), DateTime.t()) ::
{{float(), float(), float()}, {float(), float(), float()}} | {:error, term()}
def getRVatTime(twoLineElementSet, utcEpoch) do
case Sgp4Ex.parse_tle(twoLineElementSet.line1, twoLineElementSet.line2) do
{:ok, sgp4_tle} ->
case Sgp4Ex.propagate_tle_to_epoch(sgp4_tle, utcEpoch) do
{:ok, %Sgp4Ex.TemeState{position: {rx, ry, rz}, velocity: {vx, vy, vz}}} ->
{{rx, ry, rz}, {vx, vy, vz}}
{:error, reason} ->
Logger.error("SGP4 propagation failed: #{reason}")
{:error, reason}
end
{:error, reason} ->
Logger.error("Failed to parse TLE: #{reason}")
{:error, reason}
end
end
end