From 06592f7e46253a7993bd63675778cabefbd66b21 Mon Sep 17 00:00:00 2001 From: suvan-kumar Date: Sun, 3 Aug 2025 16:35:28 -0700 Subject: [PATCH 1/5] to_tle for geosynchronous --- pyproject.toml | 1 + src/tatc/schemas/orbit.py | 54 +++++++++++++++++++++++++++++++++++++++ src/tatc/utils.py | 2 +- 3 files changed, 56 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 5241a12..e3571c7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,7 @@ classifiers = [ "Development Status :: 4 - Beta" ] dependencies = [ + "astropy", "geopandas >= 0.13.2", "joblib", "numba", diff --git a/src/tatc/schemas/orbit.py b/src/tatc/schemas/orbit.py index f9f80c1..4c9dc25 100644 --- a/src/tatc/schemas/orbit.py +++ b/src/tatc/schemas/orbit.py @@ -11,6 +11,7 @@ from typing import List, Optional, Tuple, Union import numpy as np +from astropy.time import Time from pydantic import AfterValidator, BaseModel, Field from sgp4.api import Satrec, WGS72 from sgp4 import exporter @@ -1181,3 +1182,56 @@ def get_derived_orbit( eccentricity=self.eccentricity, perigee_argument=self.perigee_argument, ) + +class GeosynchronousOrbit(CircularOrbit): + """ + Geosynchronous orbit defined by longitude to be observed. + Geostationary orbit can be specified with inclination equal to 0. + """ + + type: Literal["geosynchronous"] = Field( + "geosynchronous", description="Orbit type discriminator." + ) + altitude: float = Field(35786000, description="Mean altitude (meters).") + longitude: float = Field(0, description="Longitude (degrees).", ge=0, lt=360) + + def get_true_anomaly(self) -> float: + """ + Gets the true anomaly for the satellite over a given longitude at a given epoch. + + Returns: + float: True anomaly in degrees. + """ + # convert to astropy time + t = Time(self.epoch, scale="utc") + # get greenwich sidereal time in degrees + gst = t.sidereal_time('mean', 'greenwich').deg + # return earth-centered inertial angle + return (self.longitude + gst) % 360 + + def to_tle(self, lazy_load: bool = None) -> TwoLineElements: + """ + Converts this orbit to a two line elements representation. + + Args: + lazy_load (bool): True, if this tle should be lazy-loaded. + + Returns: + TwoLineElements: the two line elements orbit + """ + if lazy_load is None: + lazy_load = config.rc.orbit_tle_lazy_load + if lazy_load: + tle = self.__dict__.get("tle") + else: + tle = None + if tle is None: + tle = CircularOrbit( + altitude=self.altitude, + inclination=self.inclination, + right_ascension_ascending_node=self.right_ascension_ascending_node, + true_anomaly=self.compute_true_anomaly(), + epoch=self.epoch, + ).to_tle() + self.__dict__["tle"] = tle + return tle \ No newline at end of file diff --git a/src/tatc/utils.py b/src/tatc/utils.py index 04f2202..d8b0e9e 100644 --- a/src/tatc/utils.py +++ b/src/tatc/utils.py @@ -40,7 +40,7 @@ def mean_anomaly_to_true_anomaly(mean_anomaly: float, eccentricity: float = 0) - Args: mean_anomaly (float): The mean anomaly (degrees). - true_anomaly (float): The orbit eccentricity. + eccentricity (float): The orbit eccentricity. Returns: float: The true anomaly (degrees). From 55baceb3208ab977072c7da7ebe0cd63f7c05fbc Mon Sep 17 00:00:00 2001 From: suvan-kumar Date: Sun, 3 Aug 2025 17:00:30 -0700 Subject: [PATCH 2/5] added get_derived_orbit --- src/tatc/schemas/orbit.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/tatc/schemas/orbit.py b/src/tatc/schemas/orbit.py index 4c9dc25..0587104 100644 --- a/src/tatc/schemas/orbit.py +++ b/src/tatc/schemas/orbit.py @@ -1208,7 +1208,36 @@ def get_true_anomaly(self) -> float: gst = t.sidereal_time('mean', 'greenwich').deg # return earth-centered inertial angle return (self.longitude + gst) % 360 + + def get_derived_orbit( + self, delta_mean_anomaly: float, delta_raan: float + ) -> GeosynchronousOrbit: + """ + Gets a derived geosynchronous orbit with pertubations to the + mean anomaly (interpreted as a shift in observed longitude) and + right ascension of ascending node. + + Args: + delta_mean_anomaly (float): Delta mean anomaly (degrees). + delta_raan (float): Delta right ascension of ascending node (degrees). + Returns: + GeosynchronousOrbit: the derived orbit + """ + true_anomaly = utils.mean_anomaly_to_true_anomaly( + np.mod(self.get_mean_anomaly() + delta_mean_anomaly, 360) + ) + longitude = np.mod(self.longitude + delta_mean_anomaly, 360) + raan = np.mod(self.right_ascension_ascending_node + delta_raan, 360) + return GeosynchronousOrbit( + altitude=self.altitude, + true_anomaly=true_anomaly, + epoch=self.epoch, + inclination=self.inclination, + right_ascension_ascending_node=raan, + longitude=longitude, + ) + def to_tle(self, lazy_load: bool = None) -> TwoLineElements: """ Converts this orbit to a two line elements representation. From b20b305603078a74d4168556f2d25d9b59f82932 Mon Sep 17 00:00:00 2001 From: GitHub Actions Bot <> Date: Mon, 4 Aug 2025 00:03:14 +0000 Subject: [PATCH 3/5] files reformatted with black --- src/tatc/schemas/orbit.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/tatc/schemas/orbit.py b/src/tatc/schemas/orbit.py index 0587104..3ee171e 100644 --- a/src/tatc/schemas/orbit.py +++ b/src/tatc/schemas/orbit.py @@ -1183,6 +1183,7 @@ def get_derived_orbit( perigee_argument=self.perigee_argument, ) + class GeosynchronousOrbit(CircularOrbit): """ Geosynchronous orbit defined by longitude to be observed. @@ -1205,16 +1206,16 @@ def get_true_anomaly(self) -> float: # convert to astropy time t = Time(self.epoch, scale="utc") # get greenwich sidereal time in degrees - gst = t.sidereal_time('mean', 'greenwich').deg + gst = t.sidereal_time("mean", "greenwich").deg # return earth-centered inertial angle return (self.longitude + gst) % 360 - + def get_derived_orbit( self, delta_mean_anomaly: float, delta_raan: float ) -> GeosynchronousOrbit: """ - Gets a derived geosynchronous orbit with pertubations to the - mean anomaly (interpreted as a shift in observed longitude) and + Gets a derived geosynchronous orbit with pertubations to the + mean anomaly (interpreted as a shift in observed longitude) and right ascension of ascending node. Args: @@ -1237,7 +1238,7 @@ def get_derived_orbit( right_ascension_ascending_node=raan, longitude=longitude, ) - + def to_tle(self, lazy_load: bool = None) -> TwoLineElements: """ Converts this orbit to a two line elements representation. @@ -1263,4 +1264,4 @@ def to_tle(self, lazy_load: bool = None) -> TwoLineElements: epoch=self.epoch, ).to_tle() self.__dict__["tle"] = tle - return tle \ No newline at end of file + return tle From 5e3e82f4e11f54eb393c40f3c73b4759609576cb Mon Sep 17 00:00:00 2001 From: suvan-kumar Date: Thu, 9 Apr 2026 19:50:25 -0400 Subject: [PATCH 4/5] replace astropy time with skyfield --- pyproject.toml | 1 - src/tatc/schemas/__init__.py | 1 + src/tatc/schemas/orbit.py | 10 +- tests/schemas/test_geosynchronous_orbit.py | 124 +++++++++++++++++++++ 4 files changed, 128 insertions(+), 8 deletions(-) create mode 100644 tests/schemas/test_geosynchronous_orbit.py diff --git a/pyproject.toml b/pyproject.toml index e3571c7..5241a12 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,6 @@ classifiers = [ "Development Status :: 4 - Beta" ] dependencies = [ - "astropy", "geopandas >= 0.13.2", "joblib", "numba", diff --git a/src/tatc/schemas/__init__.py b/src/tatc/schemas/__init__.py index e530b50..c8aba87 100644 --- a/src/tatc/schemas/__init__.py +++ b/src/tatc/schemas/__init__.py @@ -11,6 +11,7 @@ KeplerianOrbit, MolniyaOrbit, TundraOrbit, + GeosynchronousOrbit, ) from .point import Point, GroundStation from .satellite import ( diff --git a/src/tatc/schemas/orbit.py b/src/tatc/schemas/orbit.py index 3ee171e..d7d00eb 100644 --- a/src/tatc/schemas/orbit.py +++ b/src/tatc/schemas/orbit.py @@ -11,7 +11,6 @@ from typing import List, Optional, Tuple, Union import numpy as np -from astropy.time import Time from pydantic import AfterValidator, BaseModel, Field from sgp4.api import Satrec, WGS72 from sgp4 import exporter @@ -1203,11 +1202,8 @@ def get_true_anomaly(self) -> float: Returns: float: True anomaly in degrees. """ - # convert to astropy time - t = Time(self.epoch, scale="utc") - # get greenwich sidereal time in degrees - gst = t.sidereal_time("mean", "greenwich").deg - # return earth-centered inertial angle + t = constants.timescale.from_datetime(self.epoch) + gst = t.gmst * 15 return (self.longitude + gst) % 360 def get_derived_orbit( @@ -1260,7 +1256,7 @@ def to_tle(self, lazy_load: bool = None) -> TwoLineElements: altitude=self.altitude, inclination=self.inclination, right_ascension_ascending_node=self.right_ascension_ascending_node, - true_anomaly=self.compute_true_anomaly(), + true_anomaly=self.get_true_anomaly(), epoch=self.epoch, ).to_tle() self.__dict__["tle"] = tle diff --git a/tests/schemas/test_geosynchronous_orbit.py b/tests/schemas/test_geosynchronous_orbit.py new file mode 100644 index 0000000..012cee1 --- /dev/null +++ b/tests/schemas/test_geosynchronous_orbit.py @@ -0,0 +1,124 @@ +import unittest + +from datetime import datetime, timezone + +from tatc import constants +from tatc.schemas import GeosynchronousOrbit + + +class TestGeosynchronousOrbit(unittest.TestCase): + def setUp(self): + self.test_data = { + "altitude": 35786000, + "epoch": datetime(2022, 1, 1, 12, tzinfo=timezone.utc), + "inclination": 0.0, + "right_ascension_ascending_node": 0.0, + "longitude": 10.0, + } + self.test_orbit = GeosynchronousOrbit(**self.test_data) + + def test_good_data(self): + self.assertEqual(self.test_orbit.altitude, self.test_data.get("altitude")) + self.assertEqual(self.test_orbit.epoch, self.test_data.get("epoch")) + self.assertEqual(self.test_orbit.inclination, self.test_data.get("inclination")) + self.assertEqual( + self.test_orbit.right_ascension_ascending_node, + self.test_data.get("right_ascension_ascending_node"), + ) + self.assertEqual(self.test_orbit.longitude, self.test_data.get("longitude")) + + def test_default_altitude(self): + o = GeosynchronousOrbit(longitude=0.0) + self.assertEqual(o.altitude, 35786000) + + def test_good_data_iso8601_datetime(self): + good_data = { + "altitude": 35786000, + "epoch": "2022-01-01T12:00:00Z", + "inclination": 0.0, + "right_ascension_ascending_node": 0.0, + "longitude": 10.0, + } + o = GeosynchronousOrbit(**good_data) + self.assertEqual(o.altitude, good_data.get("altitude")) + self.assertEqual(o.epoch, datetime(2022, 1, 1, 12, tzinfo=timezone.utc)) + self.assertEqual(o.inclination, good_data.get("inclination")) + self.assertEqual( + o.right_ascension_ascending_node, + good_data.get("right_ascension_ascending_node"), + ) + self.assertEqual(o.longitude, good_data.get("longitude")) + + def test_get_true_anomaly_matches_sidereal_time(self): + t = constants.timescale.from_datetime(self.test_data.get("epoch")) + expected = (self.test_data.get("longitude") + t.gmst * 15) % 360 + self.assertAlmostEqual( + self.test_orbit.get_true_anomaly(), expected, delta=1e-6 + ) + + def test_get_derived_orbit(self): + derived_orbit = self.test_orbit.get_derived_orbit(20, 10) + self.assertAlmostEqual( + derived_orbit.longitude, + (self.test_orbit.longitude + 20) % 360, + delta=0.001, + ) + self.assertAlmostEqual( + derived_orbit.right_ascension_ascending_node, + self.test_orbit.right_ascension_ascending_node + 10, + delta=0.001, + ) + + def test_to_tle(self): + tle = self.test_orbit.to_tle() + self.assertAlmostEqual( + tle.get_altitude(), self.test_data.get("altitude"), delta=1.0 + ) + self.assertAlmostEqual( + tle.get_epoch().timestamp(), + self.test_data.get("epoch").timestamp(), + delta=1, + ) + self.assertEqual( + tle.get_inclination(), + self.test_data.get("inclination"), + ) + self.assertAlmostEqual( + tle.get_right_ascension_ascending_node(), + self.test_data.get("right_ascension_ascending_node"), + delta=0.001, + ) + + def test_to_tle_subpoint_matches_longitude(self): + tle = self.test_orbit.to_tle() + sat = tle.as_skyfield() + t = constants.timescale.from_datetime(self.test_data.get("epoch")) + subpoint = sat.at(t).subpoint() + self.assertAlmostEqual( + subpoint.longitude.degrees, self.test_data.get("longitude"), delta=0.1 + ) + self.assertAlmostEqual(subpoint.latitude.degrees, 0.0, delta=0.1) + + def test_to_tle_subpoint_multiple_longitudes(self): + for longitude in (0.0, 45.0, 137.5, 270.0): + with self.subTest(longitude=longitude): + o = GeosynchronousOrbit( + epoch=self.test_data.get("epoch"), + longitude=longitude, + inclination=0.0, + ) + tle = o.to_tle() + sat = tle.as_skyfield() + t = constants.timescale.from_datetime(self.test_data.get("epoch")) + subpoint = sat.at(t).subpoint() + sub_lon = subpoint.longitude.degrees % 360 + self.assertAlmostEqual(sub_lon, longitude, delta=0.1) + self.assertAlmostEqual(subpoint.latitude.degrees, 0.0, delta=0.1) + + def test_bad_longitude_negative(self): + with self.assertRaises(Exception): + GeosynchronousOrbit(longitude=-1.0) + + def test_bad_longitude_too_large(self): + with self.assertRaises(Exception): + GeosynchronousOrbit(longitude=360.0) From 625729ddc8d62369ca78aeff9758cb36867d9a5e Mon Sep 17 00:00:00 2001 From: GitHub Actions Bot <> Date: Thu, 9 Apr 2026 23:52:53 +0000 Subject: [PATCH 5/5] files reformatted with black --- src/tatc/analysis/dop.py | 1 + src/tatc/constants.py | 1 - src/tatc/schemas/satellite.py | 1 + src/tatc/utils.py | 1 + tests/schemas/test_geosynchronous_orbit.py | 4 +--- 5 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/tatc/analysis/dop.py b/src/tatc/analysis/dop.py index fab7deb..ea64c79 100644 --- a/src/tatc/analysis/dop.py +++ b/src/tatc/analysis/dop.py @@ -5,6 +5,7 @@ @author: Michael P. Jones @author: Paul T. Grogan """ + import warnings from datetime import datetime from enum import Enum diff --git a/src/tatc/constants.py b/src/tatc/constants.py index 3caf751..7730517 100644 --- a/src/tatc/constants.py +++ b/src/tatc/constants.py @@ -10,7 +10,6 @@ import numpy as np from skyfield.api import load, Loader - # load ephemeris file resources_dir = os.path.join(os.path.dirname(__file__), "resources") de421_loader = Loader(resources_dir) diff --git a/src/tatc/schemas/satellite.py b/src/tatc/schemas/satellite.py index 66e8146..ff658a8 100644 --- a/src/tatc/schemas/satellite.py +++ b/src/tatc/schemas/satellite.py @@ -4,6 +4,7 @@ @author: Paul T. Grogan """ + from __future__ import annotations import copy diff --git a/src/tatc/utils.py b/src/tatc/utils.py index d8b0e9e..6c38847 100644 --- a/src/tatc/utils.py +++ b/src/tatc/utils.py @@ -4,6 +4,7 @@ @author: Paul T. Grogan """ + import re from typing import List, Union diff --git a/tests/schemas/test_geosynchronous_orbit.py b/tests/schemas/test_geosynchronous_orbit.py index 012cee1..6d3fac0 100644 --- a/tests/schemas/test_geosynchronous_orbit.py +++ b/tests/schemas/test_geosynchronous_orbit.py @@ -52,9 +52,7 @@ def test_good_data_iso8601_datetime(self): def test_get_true_anomaly_matches_sidereal_time(self): t = constants.timescale.from_datetime(self.test_data.get("epoch")) expected = (self.test_data.get("longitude") + t.gmst * 15) % 360 - self.assertAlmostEqual( - self.test_orbit.get_true_anomaly(), expected, delta=1e-6 - ) + self.assertAlmostEqual(self.test_orbit.get_true_anomaly(), expected, delta=1e-6) def test_get_derived_orbit(self): derived_orbit = self.test_orbit.get_derived_orbit(20, 10)