defmodule SpaceDust.Utils.Tle do @moduledoc """ Utility functions for parsing two-line element sets (TLEs) """ 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 (TLE) from a string" 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 """ Get the position and velocity (in the TEME frame) of a satellite at a given time. Uses the sgp4_ex library for fast NIF-based propagation. """ 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