Current section
Files
Jump to
Current section
Files
lib/sgp4_ex.ex
defmodule Sgp4Ex do
@moduledoc """
SGP4 propagation module for Elixir.
"""
alias Sgp4Ex.TLE
alias Sgp4Ex.TemeState
@microseconds_per_day 86_400 * 1_000_000
# Helper to parse floats that may have leading dots (like .123 instead of 0.123)
defp parse_float(str) do
trimmed = String.trim(str)
# Add leading 0 if string starts with . or -.
normalized =
case trimmed do
"." <> _ -> "0" <> trimmed
"-." <> rest -> "-0." <> rest
_ -> trimmed
end
String.to_float(normalized)
end
@doc """
Parse a TLE (Two-Line Element) set into a TLE struct.
The TLE consists of two lines, each with a specific format.
## Parameters
- `line1`: The first line of the TLE.
- `line2`: The second line of the TLE.
## Returns
- `{:ok, TLE.t()}`: The parsed TLE struct.
- `{:error, String.t()}`: An error message if the parsing fails.
## Example
iex> line1 = "1 25544U 98067A 21275.54791667 .00001264 00000-0 39629-5 0 9993"
iex> line2 = "2 25544 51.6456 23.4367 0001234 45.6789 314.3210 15.48999999 12"
iex> case Sgp4Ex.parse_tle(line1, line2) do
...> {:ok, %Sgp4Ex.TLE{}} -> :ok
...> _ -> :error
...> end
:ok
"""
@spec parse_tle(String.t(), String.t()) :: {:ok, TLE.t()} | {:error, String.t()}
def parse_tle(longstr1, longstr2) do
with :ok <- validate_ascii(longstr1, longstr2),
line1 <- clean_tle_line(longstr1, 69),
line2 <- clean_tle_line(longstr2, 69),
:ok <- validate_line_format(line1, line2),
{:ok, fields} <- extract_tle_fields(line1, line2) do
{:ok, build_tle_struct(fields, line1, line2)}
end
end
defp validate_ascii(line1, line2) do
if String.to_charlist(line1) |> Enum.all?(&(&1 <= 127)) and
String.to_charlist(line2) |> Enum.all?(&(&1 <= 127)) do
:ok
else
{:error, "TLE lines contain non-ASCII characters"}
end
end
defp clean_tle_line(line, max_length) do
line
|> remove_trailing_whitespace()
|> truncate_to_valid_tle_length(max_length)
end
defp remove_trailing_whitespace(line), do: String.trim_trailing(line)
defp truncate_to_valid_tle_length(line, max_length) when byte_size(line) > max_length do
String.slice(line, 0..(max_length - 1))
end
defp truncate_to_valid_tle_length(line, _max_length), do: line
defp validate_line_format(line1, line2) do
with :ok <- validate_line1_positions(line1),
:ok <- validate_line2_positions(line2),
:ok <- validate_matching_satellite_numbers(line1, line2) do
:ok
end
end
defp validate_line1_positions(line) do
cond do
String.length(line) < 64 ->
{:error, format_error_message()}
not String.starts_with?(line, "1 ") ->
{:error, format_error_message()}
not all_positions_valid?(line, line1_positions()) ->
{:error, format_error_message()}
true ->
:ok
end
end
defp validate_line2_positions(line) do
cond do
String.length(line) < 68 ->
{:error, format_error_message()}
not String.starts_with?(line, "2 ") ->
{:error, format_error_message()}
not all_positions_valid?(line, line2_positions()) ->
{:error, format_error_message()}
true ->
:ok
end
end
defp all_positions_valid?(line, positions) do
Enum.all?(positions, fn {pos, expected_char} ->
String.at(line, pos) == expected_char
end)
end
defp line1_positions do
[{8, " "}, {23, "."}, {32, " "}, {34, "."}, {43, " "}, {52, " "}, {61, " "}, {63, " "}]
end
defp line2_positions do
[
{7, " "},
{11, "."},
{16, " "},
{20, "."},
{25, " "},
{33, " "},
{37, "."},
{42, " "},
{46, "."},
{51, " "}
]
end
defp validate_matching_satellite_numbers(line1, line2) do
if String.slice(line1, 2..6) == String.slice(line2, 2..6) do
:ok
else
{:error, "Object numbers in lines 1 and 2 do not match"}
end
end
defp format_error_message do
"TLE format error\n\nThe Two-Line Element (TLE) format was designed for punch cards, and so\nis very strict about the position of every period, space, and digit.\nYour line does not quite match."
end
defp extract_tle_fields(line1, line2) do
try do
fields = %{
catalog_number: String.slice(line1, 2..6),
classification: String.at(line1, 7) || "U",
intldesg: String.trim_trailing(String.slice(line1, 9..16)),
two_digit_year: String.slice(line1, 18..19) |> String.trim() |> String.to_integer(),
epochdays: String.slice(line1, 20..31) |> parse_float(),
ndot: String.slice(line1, 33..42) |> parse_float(),
nddot: parse_nddot(line1),
bstar: parse_bstar(line1),
ephtype: String.at(line1, 62) |> String.to_integer(),
elnum: String.slice(line1, 64..67) |> String.trim() |> String.to_integer(),
inclo: String.slice(line2, 8..15) |> parse_float(),
nodeo: String.slice(line2, 17..24) |> parse_float(),
ecco: parse_eccentricity(line2),
argpo: String.slice(line2, 34..41) |> parse_float(),
mo: String.slice(line2, 43..50) |> parse_float(),
no_kozai: String.slice(line2, 52..62) |> parse_float(),
revnum: String.slice(line2, 63..67) |> String.trim() |> String.to_integer()
}
{:ok, fields}
rescue
e -> {:error, "TLE format error: #{Exception.message(e)}"}
end
end
defp parse_nddot(line1) do
sign = if String.at(line1, 44) == "-", do: -1, else: 1
mantissa = ("0." <> String.slice(line1, 45..49)) |> String.trim() |> String.to_float()
exp = String.slice(line1, 50..51) |> String.trim() |> String.to_integer()
sign * mantissa * :math.pow(10.0, exp)
end
defp parse_bstar(line1) do
sign = if String.at(line1, 53) == "-", do: -1, else: 1
mantissa = ("0." <> String.slice(line1, 54..58)) |> String.trim() |> String.to_float()
exp = String.slice(line1, 59..60) |> String.trim() |> String.to_integer()
sign * mantissa * :math.pow(10.0, exp)
end
defp parse_eccentricity(line2) do
("0." <> String.replace(String.slice(line2, 26..32), " ", "0")) |> String.to_float()
end
defp build_tle_struct(fields, line1, line2) do
epoch_year =
if fields.two_digit_year < 57,
do: 2000 + fields.two_digit_year,
else: 1900 + fields.two_digit_year
epoch = calculate_epoch(epoch_year, fields.epochdays)
%TLE{
line1: line1,
line2: line2,
catalog_number: fields.catalog_number,
classification: fields.classification,
international_designator: fields.intldesg,
epoch: epoch,
mean_motion_dot: fields.ndot,
mean_motion_double_dot: fields.nddot,
bstar: fields.bstar,
ephemeris_type: fields.ephtype,
elset_number: fields.elnum,
inclination_deg: fields.inclo,
raan_deg: fields.nodeo,
eccentricity: fields.ecco,
arg_perigee_deg: fields.argpo,
mean_anomaly_deg: fields.mo,
mean_motion: fields.no_kozai,
rev_number: fields.revnum
}
end
defp calculate_epoch(epoch_year, epochdays) do
days_from_jan1 = epochdays - 1
whole_days = trunc(days_from_jan1)
fractional_day = days_from_jan1 - whole_days
start_of_year = DateTime.new!(Date.new!(epoch_year, 1, 1), Time.new!(0, 0, 0, 0))
epoch_with_days = DateTime.add(start_of_year, whole_days, :day)
microseconds = round(fractional_day * @microseconds_per_day)
DateTime.add(epoch_with_days, microseconds, :microsecond)
end
@doc """
Propagate a TLE to a specific epoch using the SGP4 algorithm.
The epoch is the time to which the TLE should be propagated.
## Parameters
- `tle`: The TLE data structure containing the satellite's orbital elements.
- `epoch`: The epoch to which the TLE should be propagated.
## Returns
- `{:ok, TemeState.t()}`: The propagated Teme state of the satellite.
- `{:error, String.t()}`: An error message if the propagation fails.
## Example
iex> {:ok, tle} = Sgp4Ex.parse_tle(
...> "1 25544U 98067A 21275.54791667 .00001264 00000-0 39629-5 0 9993",
...> "2 25544 51.6456 23.4367 0001234 45.6789 314.3210 15.48999999 12"
...> )
iex> epoch = ~U[2021-10-02T14:00:00Z]
iex> case Sgp4Ex.propagate_tle_to_epoch(tle, epoch) do
...> {:ok, %Sgp4Ex.TemeState{position: {x, y, z}, velocity: {vx, vy, vz}}} when is_float(x) and is_float(y) and is_float(z) and is_float(vx) and is_float(vy) and is_float(vz) -> :ok
...> _ -> :error
...> end
:ok
"""
@spec propagate_tle_to_epoch(TLE.t(), DateTime.t()) ::
{:ok, TemeState.t()} | {:error, String.t()}
def propagate_tle_to_epoch(tle, epoch) do
# SGP4 expects time since epoch in MINUTES, not seconds!
# Use microsecond precision for accurate time calculation
tsince_minutes = DateTime.diff(epoch, tle.epoch, :microsecond) / 60_000_000.0
# Call the NIF function to propagate the TLE
case apply(SGP4NIF, :propagate_tle, [tle.line1, tle.line2, tsince_minutes]) do
{:ok, data} ->
# NIF returns position in meters and velocity in m/s
# Convert to km and km/s for consistency
{x_m, y_m, z_m} = elem(data, 0)
{vx_m, vy_m, vz_m} = elem(data, 1)
{:ok,
%TemeState{
position: {x_m / 1000.0, y_m / 1000.0, z_m / 1000.0},
velocity: {vx_m / 1000.0, vy_m / 1000.0, vz_m / 1000.0}
}}
{:error, reason} ->
{:error, reason}
end
end
@doc """
Propagate a TLE to geodetic coordinates at a specific epoch.
This is a convenience function that propagates the satellite position and
converts it to geodetic coordinates (latitude, longitude, altitude).
## Parameters
- `tle`: The TLE data structure containing the satellite's orbital elements
- `epoch`: The UTC datetime to which the TLE should be propagated
## Returns
- `{:ok, %{latitude: float, longitude: float, altitude_km: float}}` where:
- `latitude`: Geodetic latitude in degrees (-90 to 90)
- `longitude`: Geodetic longitude in degrees (-180 to 180)
- `altitude_km`: Height above WGS84 ellipsoid in kilometers
- `{:error, String.t()}`: An error message if propagation fails
## Example
iex> {:ok, tle} = Sgp4Ex.parse_tle(
...> "1 25544U 98067A 21275.54791667 .00001264 00000-0 39629-5 0 9993",
...> "2 25544 51.6456 23.4367 0001234 45.6789 314.3210 15.48999999 12"
...> )
iex> epoch = ~U[2021-10-02T14:00:00Z]
iex> case Sgp4Ex.propagate_to_geodetic(tle, epoch) do
...> {:ok, %{latitude: lat, longitude: lon, altitude_km: alt}} when is_float(lat) and is_float(lon) and is_float(alt) -> :ok
...> _ -> :error
...> end
:ok
"""
@spec propagate_to_geodetic(TLE.t(), DateTime.t()) ::
{:ok, %{latitude: float(), longitude: float(), altitude_km: float()}}
| {:error, String.t()}
def propagate_to_geodetic(%TLE{} = tle, %DateTime{} = epoch) do
alias Sgp4Ex.CoordinateSystems
case propagate_tle_to_epoch(tle, epoch) do
{:ok, %TemeState{position: position}} ->
CoordinateSystems.teme_to_geodetic(position, epoch)
{:error, reason} ->
{:error, reason}
end
end
end