From 61bbac47f9ccc0bd437a243ede3249792a350293 Mon Sep 17 00:00:00 2001 From: Liang Cheng Date: Wed, 18 Dec 2024 23:15:54 -0500 Subject: [PATCH 1/5] Update commit message --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 19c3496..347c63c 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,11 @@ n=15.50437522, rev_num=18780) and you can then access its attributes like `t.argp`, `t.epoch`... +TLE can also be written out to a string based on the orbital value: +```python +print(tle.to_lines()) +``` + ### TLE format specification Some more or less complete TLE format specifications can be found on the following websites: From 62fbcba042efdcd5c5dccf6932068a9c8c64040e Mon Sep 17 00:00:00 2001 From: Liang Cheng Date: Thu, 19 Dec 2024 01:14:45 -0500 Subject: [PATCH 2/5] developed tle_to_lines --- tests/test_tle.py | 6 +++++ tletools/tle.py | 65 ++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 65 insertions(+), 6 deletions(-) diff --git a/tests/test_tle.py b/tests/test_tle.py index c7dd480..537dbc3 100644 --- a/tests/test_tle.py +++ b/tests/test_tle.py @@ -27,3 +27,9 @@ def test_asdict(tle): def test_astuple(tle): assert type(tle)(*tle.astuple()) == tle + +def test_to_lines(tle_lines): + t = TLE.from_lines(*tle_lines) + lines = t.to_lines() + tle_joined_lines = '\n'.join(tle_lines) + assert(lines == tle_joined_lines) \ No newline at end of file diff --git a/tletools/tle.py b/tletools/tle.py index 7ad7ff6..b2826e1 100644 --- a/tletools/tle.py +++ b/tletools/tle.py @@ -34,7 +34,6 @@ from .utils import partition, rev as u_rev, M_to_nu as _M_to_nu - DEG2RAD = np.pi / 180. RAD2DEG = 180. / np.pi @@ -69,6 +68,37 @@ def _parse_float(s): return float(s[0] + '.' + s[1:6] + 'e' + s[6:8]) +def _float_to_string(f: float, digits: int = 8) -> str: + """Convert a float to a string with implicit dot and exponential notation. + + >>> _float_to_string(0.00012345, digits) + '12345-3' + """ + if f == 0: + # zero gets a special string + return "0" * digits + "-0" + format_string = '{:.' + str(digits) + 'E}' + s = format_string.format(f) + # skip the first zero in the exponent, and if it is +0, make it -0 to confirm to the TLE convention + exponent = int(s[digits + 3] + s[digits + 4] + s[digits + 5]) + exponent = exponent + 1 + + # skip the decimal point, and E + return s[0] + s[2:digits + 1] + str(exponent) + +def _calculate_check_sum_on_tle_line(line: str) -> int: + """Calculate the checksum of a TLE line. + + The checksum is calculated by taking the sum of all the digits in the line, ignoring spaces, and then taking the + modulo 10 of that sum. + + >>> _calculate_check_sum_on_tle_line('1 25544U 98067A 19249.04864348 .00001909 00000-0 40858-4 0 9990') + 0 + """ + sum_of_digits = sum(int(c) for c in line if c.isdigit()) + sum(1 for c in line if c == '-') + return sum_of_digits % 10 + + @attr.s class TLE: """Data class representing a single TLE. @@ -145,7 +175,7 @@ def epoch(self): """Epoch of the TLE, as an :class:`astropy.time.Time` object.""" if self._epoch is None: year = np.datetime64(self.epoch_year - 1970, 'Y') - day = np.timedelta64(int((self.epoch_day - 1) * 86400 * 10**6), 'us') + day = np.timedelta64(int((self.epoch_day - 1) * 86400 * 10 ** 6), 'us') self._epoch = Time(year + day, format='datetime64', scale='utc') return self._epoch @@ -153,7 +183,7 @@ def epoch(self): def a(self): """Semi-major axis.""" if self._a is None: - self._a = (_Earth.k.value / (self.n * np.pi / 43200) ** 2) ** (1/3) / 1000 + self._a = (_Earth.k.value / (self.n * np.pi / 43200) ** 2) ** (1 / 3) / 1000 return self._a @property @@ -239,6 +269,29 @@ def asdict(self, computed=False, epoch=False): d.update(epoch=self.epoch) return d + def to_lines(self): + templates = [ + "{name}", + "1 {norad}{classification} {int_desig} {epoch_year_last_digits:02d}{epoch_day:12.8f} {dn_o2_wo_leading_zero} {ddn_o6_wo_e} {bstar_wo_e} 0 {set_num:3d}", + "2 {norad} {inc:8.4f} {raan:8.4f} {ecc_wo_leading_zero} {argp:8.4f} {M:8.4f} {n:11.8f}{rev_num:4d}", + ] + additional_dict = { + 'epoch_year_last_digits': self.epoch_year % 100, + 'dn_o2_wo_leading_zero': "{dn_o2:.8f}".format(dn_o2=self.dn_o2).lstrip('0'), # dn_o2 without leading zero + 'ddn_o6_wo_e': _float_to_string(self.ddn_o6, digits=5), + 'line1_check_sum': 0, # TODO: implement + 'bstar_wo_e': _float_to_string(self.bstar, digits=5), + 'ecc_wo_leading_zero': "{ecc:.7f}".format(ecc=self.ecc).lstrip('0').lstrip('.'), # ecc without leading zero + 'line2_check_sum': 5, # TODO: implement + } + lines = [template.format(**{**self.asdict(), **additional_dict}) for template in templates] + line_1_mod = _calculate_check_sum_on_tle_line(lines[1]) + line_2_mod = _calculate_check_sum_on_tle_line(lines[2]) + lines[1] = lines[1] + str(line_1_mod) + lines[2] = lines[2] + str(line_2_mod) + + return "\n".join(lines) + @attr.s class TLEu(TLE): @@ -256,7 +309,7 @@ class TLEu(TLE): def a(self): """Semi-major axis.""" if self._a is None: - self._a = (_Earth.k.value / self.n.to_value(u.rad/u.s) ** 2) ** (1/3) * u.m + self._a = (_Earth.k.value / self.n.to_value(u.rad / u.s) ** 2) ** (1 / 3) * u.m return self._a @property @@ -280,8 +333,8 @@ def from_lines(cls, name, line1, line2): int_desig=line1[9:17], epoch_year=line1[18:20], epoch_day=float(line1[20:32]), - dn_o2=u.Quantity(float(line1[33:43]), u_rev / u.day**2), - ddn_o6=u.Quantity(_parse_float(line1[44:52]), u_rev / u.day**3), + dn_o2=u.Quantity(float(line1[33:43]), u_rev / u.day ** 2), + ddn_o6=u.Quantity(_parse_float(line1[44:52]), u_rev / u.day ** 3), bstar=u.Quantity(_parse_float(line1[53:61]), 1 / u.earthRad), set_num=line1[64:68], inc=u.Quantity(float(line2[8:16]), u.deg), From 02d06d722ad938aab6f2ad19503f4c8734c90d17 Mon Sep 17 00:00:00 2001 From: Liang Cheng Date: Mon, 23 Dec 2024 08:14:23 -0500 Subject: [PATCH 3/5] Development of TLE to_lines and TLE from_orbit functions (#3) * fake values for now * update mean motion * update mean motion * update mean anomaly * update mean motion * update mean motion and anomaly * update mean anomaly * mean motion is correct * good to go * good to go --------- Co-authored-by: Liang Cheng --- tests/conftest.py | 32 ++++++++++++++++++++ tests/test_tle.py | 45 ++++++++++++++++++++++++++-- tletools/tle.py | 76 ++++++++++++++++++++++++++++++++++++++++------- tletools/utils.py | 14 ++++++++- 4 files changed, 153 insertions(+), 14 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 71ac8aa..f1f4d37 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,6 +9,20 @@ def tle_string(): 1 25544U 98067A 19249.04864348 .00001909 00000-0 40858-4 0 9990 2 25544 51.6464 320.1755 0007999 10.9066 53.2893 15.50437522187805""" +@pytest.fixture +def tle_from_lines_to_lines_expected_string(): + # expected result of tle = TLE.from_lines(*tle_lines).to_lines() + return """ISS (ZARYA) +1 25544U 98067A 19249.04864348 +.00001909 +00000-0 +40858-4 0 9990 +2 25544 51.6464 320.1755 0007999 10.9066 53.2893 15.50437522187805""" + +@pytest.fixture +def tle_from_orbit_to_lines_expected_string(): + # expected result of tle = TLE.from_lines(*tle_lines).to_lines() + return """ISS (ZARYA) +1 25544U 98067A 19249.04864348 +.00001909 +00000-0 +40858-4 0 9990 +2 25544 51.6464 320.1755 0007999 10.9066 53.2893 15.50437522187805""" + @pytest.fixture def tle_string2(): # This TLE tests high mean anomaly value. @@ -16,14 +30,32 @@ def tle_string2(): 1 28654U 05018A 20098.54037539 .00000075 00000-0 65128-4 0 9992 2 28654 99.0522 154.2797 0015184 73.2195 287.0641 14.12501077766909""" +@pytest.fixture +def tle_strings2_from_orbit_expected(): + return """NOAA 18 +1 28654U 05018A 20098.54037539 +.00000075 +00000-0 +65128-4 0 9992 +2 28654 99.0522 154.2797 0015184 73.2195 287.0641 14.12501077766909""" + @pytest.fixture def tle_lines(tle_string): return tle_string.splitlines() +@pytest.fixture +def tle_from_lines_to_lines_expected(tle_from_lines_to_lines_expected_string): + return tle_from_lines_to_lines_expected_string.splitlines() + +@pytest.fixture +def tle_from_orbit_to_lines_expected(tle_from_orbit_to_lines_expected_string): + return tle_from_orbit_to_lines_expected_string.splitlines() + @pytest.fixture def tle_lines2(tle_string2): return tle_string2.splitlines() +@pytest.fixture +def tle_lines2_from_orbit_expected(tle_strings2_from_orbit_expected): + return tle_strings2_from_orbit_expected.splitlines() + @pytest.fixture def tle(): return TLE('ISS (ZARYA)', '25544', 'U', '98067A', diff --git a/tests/test_tle.py b/tests/test_tle.py index 537dbc3..cd32f8b 100644 --- a/tests/test_tle.py +++ b/tests/test_tle.py @@ -3,14 +3,17 @@ import astropy.units as u + def test_from_lines(tle_lines): t = TLE.from_lines(*tle_lines) assert isinstance(t, TLE) + def test_from_lines_high_M(tle_lines2): t = TLE.from_lines(*tle_lines2) assert isinstance(t, TLE) + def test_from_lines_with_units(tle_lines): t = TLEu.from_lines(*tle_lines) assert isinstance(t, TLEu) @@ -28,8 +31,46 @@ def test_asdict(tle): def test_astuple(tle): assert type(tle)(*tle.astuple()) == tle -def test_to_lines(tle_lines): + +def test_to_lines(tle_lines, tle_from_lines_to_lines_expected): t = TLE.from_lines(*tle_lines) lines = t.to_lines() tle_joined_lines = '\n'.join(tle_lines) - assert(lines == tle_joined_lines) \ No newline at end of file + assert (lines == tle_from_lines_to_lines_expected) + +def test_orbit_to_lines(tle_lines, tle_from_orbit_to_lines_expected): + tle = TLE.from_lines(*tle_lines) + orbit = tle.to_orbit() + # lines = orbit.to_lines() + + lines_from_orbit = TLE.from_orbit( + orbit, + name=tle.name, + int_desig=tle.int_desig, + classification=tle.classification, + norad=tle.norad, + dn_o2=tle.dn_o2, + ddn_o6=tle.ddn_o6, + bstar=tle.bstar, + rev_num=tle.rev_num, + ).to_lines() + assert lines_from_orbit == tle_from_orbit_to_lines_expected + + +def test_from_orbit_to_lines2(tle_lines2, tle_lines2_from_orbit_expected): + tle = TLE.from_lines(*tle_lines2) + orbit = tle.to_orbit() + # lines = orbit.to_lines() + + lines_from_orbit = TLE.from_orbit( + orbit, + name=tle.name, + int_desig=tle.int_desig, + classification=tle.classification, + norad=tle.norad, + dn_o2=tle.dn_o2, + ddn_o6=tle.ddn_o6, + bstar=tle.bstar, + rev_num=tle.rev_num, + ).to_lines() + assert tle_lines2_from_orbit_expected == lines_from_orbit diff --git a/tletools/tle.py b/tletools/tle.py index b2826e1..601a093 100644 --- a/tletools/tle.py +++ b/tletools/tle.py @@ -31,8 +31,9 @@ # Maybe remove them from here? from poliastro.twobody import Orbit as _Orbit from poliastro.bodies import Earth as _Earth +from poliastro.core import angles -from .utils import partition, rev as u_rev, M_to_nu as _M_to_nu +from .utils import partition, rev as u_rev, M_to_nu as _M_to_nu, nu_to_M as _nu_to_M DEG2RAD = np.pi / 180. RAD2DEG = 180. / np.pi @@ -76,15 +77,15 @@ def _float_to_string(f: float, digits: int = 8) -> str: """ if f == 0: # zero gets a special string - return "0" * digits + "-0" - format_string = '{:.' + str(digits) + 'E}' + return "+" + "0" * digits + "-0" + format_string = '{:+.' + str(digits) + 'E}' s = format_string.format(f) # skip the first zero in the exponent, and if it is +0, make it -0 to confirm to the TLE convention - exponent = int(s[digits + 3] + s[digits + 4] + s[digits + 5]) + exponent = int(s[digits + 4: digits+7]) exponent = exponent + 1 # skip the decimal point, and E - return s[0] + s[2:digits + 1] + str(exponent) + return s[0:2] + s[3:digits + 2] + str(exponent) def _calculate_check_sum_on_tle_line(line: str) -> int: """Calculate the checksum of a TLE line. @@ -256,6 +257,59 @@ def to_orbit(self, attractor=_Earth): nu=u.Quantity(self.nu, u.deg), epoch=self.epoch) + @classmethod + def from_orbit(cls, orbit:_Orbit, name:str = "UNASSIGNED", norad:str = "00000", classification:str="U", int_desig:str="00000A", + dn_o2:float=0.0, ddn_o6:float=0.0, bstar:float=0.0, set_num:int=999, rev_num:int=999): + '''Convert from a :class:`poliastro.twobody.orbit.Orbit` around the attractor. + + >>> from poliastro.twobody import Orbit + >>> from astropy import units as u + >>> from poliastro.bodies import Earth + >>> from astropy.time import Time + >>> orbit_epoch_str = "2024-02-01T00:29:29.126688Z" + >>> orbit_epoch_time = Time.strptime(orbit_epoch_str, "%Y-%m-%dT%H:%M:%S.%fZ") + >>> orbit = Orbit.from_classical( + ... attractor=Earth, + ... a = 6788 << u.km, + ... ecc = 0.0007999 << u.one, + ... inc = 51.6464 << u.deg, + ... raan= 320.1755 << u.deg, + ... argp= 10.9066 << u.deg, + ... nu= 53.2893 << u.deg, + ... epoch = orbit_epoch_time + ... ) + >>> tle = TLE.from_orbit(orbit, name="UNASSIGNED", norad="00000", classification="U", ...) + >>> tle.to_lines() + ''' + mean_motion = orbit.n * (24 * 60 * 60 * u.s) + mean_motion = mean_motion / ((2*np.pi)< Date: Sat, 28 Dec 2024 07:54:33 -0500 Subject: [PATCH 4/5] Update documentation and code format --- tletools/tle.py | 57 +++++++++++++++++++++++++++++++++---------------- 1 file changed, 39 insertions(+), 18 deletions(-) diff --git a/tletools/tle.py b/tletools/tle.py index 601a093..7085072 100644 --- a/tletools/tle.py +++ b/tletools/tle.py @@ -72,8 +72,12 @@ def _parse_float(s): def _float_to_string(f: float, digits: int = 8) -> str: """Convert a float to a string with implicit dot and exponential notation. - >>> _float_to_string(0.00012345, digits) - '12345-3' + >>> _float_to_string(0.00012345, digits=5) + '+12345-3' + >>> _float_to_string(-0.00012345, digits=5) + '-12345-3' + >>> _float_to_string(0.0, digits=5) + '+00000-5' """ if f == 0: # zero gets a special string @@ -81,12 +85,13 @@ def _float_to_string(f: float, digits: int = 8) -> str: format_string = '{:+.' + str(digits) + 'E}' s = format_string.format(f) # skip the first zero in the exponent, and if it is +0, make it -0 to confirm to the TLE convention - exponent = int(s[digits + 4: digits+7]) + exponent = int(s[digits + 4: digits + 7]) exponent = exponent + 1 # skip the decimal point, and E return s[0:2] + s[3:digits + 2] + str(exponent) + def _calculate_check_sum_on_tle_line(line: str) -> int: """Calculate the checksum of a TLE line. @@ -258,15 +263,18 @@ def to_orbit(self, attractor=_Earth): epoch=self.epoch) @classmethod - def from_orbit(cls, orbit:_Orbit, name:str = "UNASSIGNED", norad:str = "00000", classification:str="U", int_desig:str="00000A", - dn_o2:float=0.0, ddn_o6:float=0.0, bstar:float=0.0, set_num:int=999, rev_num:int=999): - '''Convert from a :class:`poliastro.twobody.orbit.Orbit` around the attractor. + def from_orbit(cls, orbit: _Orbit, name: str = "UNASSIGNED", norad: str = "00000", classification: str = "U", + int_desig: str = "00000A", + dn_o2: float = 0.0, ddn_o6: float = 0.0, bstar: float = 0.0, set_num: int = 999, rev_num: int = 999): + '''Convert from a :class:`poliastro.twobody.orbit.Orbit` around the attractor into a TLE. + Additional information, such as the name, NORAD ID, classification, int_desig, dn_o2, ddn_o6, bstar, set_num, and rev_num needs + to be manually provided, or copied from a valid TLE. >>> from poliastro.twobody import Orbit >>> from astropy import units as u >>> from poliastro.bodies import Earth >>> from astropy.time import Time - >>> orbit_epoch_str = "2024-02-01T00:29:29.126688Z" + >>> orbit_epoch_str = "2024-02-01T00:00:00.000000Z" >>> orbit_epoch_time = Time.strptime(orbit_epoch_str, "%Y-%m-%dT%H:%M:%S.%fZ") >>> orbit = Orbit.from_classical( ... attractor=Earth, @@ -278,14 +286,28 @@ def from_orbit(cls, orbit:_Orbit, name:str = "UNASSIGNED", norad:str = "00000", ... nu= 53.2893 << u.deg, ... epoch = orbit_epoch_time ... ) - >>> tle = TLE.from_orbit(orbit, name="UNASSIGNED", norad="00000", classification="U", ...) - >>> tle.to_lines() + >>> tle = TLE.from_orbit( + ... orbit, + ... name="UNASSIGNED", + ... norad="00000", + ... classification="U", + ... int_desig="00000A", + ... dn_o2=0.0, + ... ddn_o6=0.0, + ... bstar=0.0, + ... set_num=999, + ... rev_num=999 + ) + >>> tle_string = tle.to_lines() + >>> tle_string = """UNASSIGNED + ... 1 00000U 00000A 24032.00000000 +.00001909 +00000-0 +40858-4 0 9999 + ... 2 00000 51.6464 320.1755 0007999 10.9066 53.2158 15.52351307009990""" ''' mean_motion = orbit.n * (24 * 60 * 60 * u.s) - mean_motion = mean_motion / ((2*np.pi)< Date: Sat, 28 Dec 2024 08:00:21 -0500 Subject: [PATCH 5/5] documentation update --- tletools/tle.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tletools/tle.py b/tletools/tle.py index 7085072..4760e58 100644 --- a/tletools/tle.py +++ b/tletools/tle.py @@ -297,12 +297,14 @@ def from_orbit(cls, orbit: _Orbit, name: str = "UNASSIGNED", norad: str = "00000 ... bstar=0.0, ... set_num=999, ... rev_num=999 - ) - >>> tle_string = tle.to_lines() + ... ) + >>> tle_lines = tle.to_lines() + >>> tle_string = "\n".join(tle_lines) >>> tle_string = """UNASSIGNED ... 1 00000U 00000A 24032.00000000 +.00001909 +00000-0 +40858-4 0 9999 ... 2 00000 51.6464 320.1755 0007999 10.9066 53.2158 15.52351307009990""" ''' + mean_motion = orbit.n * (24 * 60 * 60 * u.s) mean_motion = mean_motion / ((2 * np.pi) << u.rad) mean_motion = mean_motion.value