Skip to content

Commit 8dca407

Browse files
committed
resolve pr#336 (except for one bug)
1 parent 2f41a3a commit 8dca407

File tree

5 files changed

+202
-64
lines changed

5 files changed

+202
-64
lines changed

gs/backend/positioning/sgp4.py

Lines changed: 0 additions & 62 deletions
This file was deleted.
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
from dataclasses import dataclass
2+
from datetime import datetime
3+
4+
from sgp4.api import SGP4_ERRORS, Satrec, jday
5+
from sgp4.api import WGS72 as GRAVITY_MODEL
6+
from sgp4.api import accelerated
7+
from loguru import logger
8+
from math import radians
9+
10+
from gs.backend.positioning.tle import AFSPC_MODE_IMPROVED, TLEData
11+
12+
#log whether accelerated mode is used
13+
if not accelerated:
14+
logger.warning(
15+
"SGP4 accelerated mode is not available. "
16+
"Falling back to pure Python implementation (slower)."
17+
)
18+
else:
19+
logger.info("SGP4 accelerated mode is enabled.")
20+
21+
22+
@dataclass
23+
class SGP4Data:
24+
"""Data structure representing the satellite's position and velocity."""
25+
26+
position_km: tuple[float, float, float]
27+
velocity_km_sec: tuple[float, float, float]
28+
29+
30+
def setup_sgp4(tle: TLEData) -> Satrec:
31+
"""
32+
Initialize the SGP4 satellite model using TLE data. Formatting and SGP4 initialization pulled from link below
33+
https://pypi.org/project/sgp4/
34+
"""
35+
36+
37+
sat = Satrec()
38+
39+
40+
sat.sgp4init( #causes error when tle.eccentricity is low while tle.drag_term is high)
41+
GRAVITY_MODEL, # gravity model
42+
AFSPC_MODE_IMPROVED, # propagation mode
43+
tle.satellite_number, # satellite number
44+
tle.convert_epoch_values_to_jd(), # epoch (Julian date)
45+
tle.drag_term, # BSTAR drag term
46+
#6.2485e-05,
47+
tle.first_derivative_mean_motion, # first time derivative of mean motion
48+
tle.second_derivative_mean_motion, # second time derivative of mean motion
49+
tle.eccentricity, # eccentricity
50+
radians(tle.argument_of_perigee), # argument of perigee (radians)
51+
radians(tle.inclination), # inclination (radians)
52+
radians(tle.mean_anomaly), # mean anomaly (radians)
53+
tle.mean_motion * (2 * 3.141592653589793 / 1440.0), # mean motion (radians/min)
54+
radians(tle.right_ascension), # RA of ascending node (radians)
55+
)
56+
"""
57+
sat.sgp4init(
58+
GRAVITY_MODEL, # gravity model
59+
'i', # 'a' = old AFSPC mode, 'i' = improved mode
60+
25544, # satnum: Satellite number
61+
25545.69339541, # epoch: days since 1949 December 31 00:00 UT
62+
3.8792e-05, # bstar: drag coefficient (1/earth radii)
63+
0.0, # ndot: first time derivative of mean motion (radians/min^2)
64+
0.0, # nddot: second derivative of mean motion (radians/min^3)
65+
0.0007417, # ecco: eccentricity (0..1)
66+
0.3083420829620822, # argpo: argument of perigee (radians)
67+
0.9013560935706996, # inclo: inclination (radians)
68+
1.4946964807494398, # mo: mean anomaly (radians)
69+
0.06763602333248933, # no_kozai: mean motion (radians/min)
70+
3.686137125541276, # nodeo: R.A. of ascending node (radians)
71+
)
72+
"""
73+
print("GRAVITY_MODEL:", GRAVITY_MODEL)
74+
print("Propagation mode:", AFSPC_MODE_IMPROVED)
75+
print("Satellite number:", tle.satellite_number)
76+
print("Epoch (Julian date):", tle.convert_epoch_values_to_jd())
77+
print("BSTAR drag term:", tle.drag_term)
78+
print("First derivative of mean motion:", tle.first_derivative_mean_motion)
79+
print("Second derivative of mean motion:", tle.second_derivative_mean_motion)
80+
print("Eccentricity:", tle.eccentricity)
81+
print("Argument of perigee (rad):", radians(tle.argument_of_perigee))
82+
print("Inclination (rad):", radians(tle.inclination))
83+
print("Mean anomaly (rad):", radians(tle.mean_anomaly))
84+
print("Mean motion (rad/min):", tle.mean_motion * (2 * 3.141592653589793 / 1440.0))
85+
print("RA of ascending node (rad):", radians(tle.right_ascension))
86+
return sat
87+
88+
89+
def get_sat_position(tle: TLEData, dt: datetime) -> SGP4Data:
90+
"""
91+
Compute the satellite's position and velocity at a given time.
92+
93+
Arguments are
94+
tle(TLEData): Two-line element set representing the satellite.
95+
dt(datetime): The timestamp for which to calculate the position.
96+
97+
"""
98+
sat = setup_sgp4(tle)
99+
jd, fr = jday(dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second)
100+
error_code, position, velocity = sat.sgp4(jd, fr)
101+
102+
#print("eccentricity (parsed):", tle.eccentricity)
103+
104+
105+
if 0.0 <= tle.eccentricity and tle.eccentricity <= 1.0:
106+
print("tle.eccentricity within expected bounds")
107+
108+
error_message = SGP4_ERRORS.get(error_code, None)
109+
if error_message is not None:
110+
raise RuntimeError(error_message)
111+
112+
return SGP4Data(
113+
position_km=tuple(position),
114+
velocity_km_sec=tuple(velocity),
115+
)

gs/backend/positioning/tle.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ def to_tle(self) -> str:
6363
)
6464
first_line += str(calculate_checksum(first_line))
6565
second_line += str(calculate_checksum(second_line))
66+
#print("hi!")
67+
#print(f"{first_line}\n{second_line}")
6668
return f"{first_line}\n{second_line}"
6769

6870
def format_epoch_to_date(self, epoch_year: int, epoch_day: float) -> str:
@@ -177,6 +179,7 @@ def get_tle_data(object_id: int) -> TLEData:
177179
return parse_tle_data(output)
178180

179181

182+
180183
# Testing section
181184

182185

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import pytest
2+
from datetime import datetime
3+
from gs.backend.positioning.sgp4_handler import (
4+
setup_sgp4,
5+
get_sat_position,
6+
SGP4Data
7+
)
8+
from gs.backend.positioning.tle import parse_tle_data
9+
from sgp4.api import Satrec
10+
from sgp4.conveniences import dump_satrec
11+
from sgp4.earth_gravity import wgs72
12+
from sgp4.io import twoline2rv
13+
from sys import stdout
14+
15+
16+
def test_setup_sgp4(): #test setup_sgp4 returns a valid Satrec object
17+
tle_str = (
18+
"ISS (ZARYA)\n"
19+
"1 25544U 98067A 23058.54791667 .00016717 00000+0 10270-3 0 9997\n"
20+
"2 25544 51.6435 143.0464 0004767 278.8055 81.2436 15.49815308274053"
21+
)
22+
tle = parse_tle_data(tle_str)
23+
sat = setup_sgp4(tle)
24+
assert isinstance(sat, Satrec)
25+
26+
27+
def test_get_sat_position(): #test that output has expected structure and data types
28+
tle_str = (
29+
"CSS (MENGTIAN)\n"
30+
"1 54216U 22143A 25308.16010129 .00032649 00000-0 38953-3 0 9993\n"
31+
"2 54216 41.4668 251.0039 0006377 319.7576 40.2790 15.60341380158647"
32+
)
33+
34+
tle_str2 = (
35+
"ISS (ZARYA)\n"
36+
"1 25544U 98067A 25308.35786713 .00010709 00000-0 19707-3 0 9992\n"
37+
"2 25544 51.6336 332.4903 0005031 16.0382 344.0765 15.49743270536903"
38+
39+
)
40+
tle = parse_tle_data(tle_str)
41+
#sat = setup_sgp4(tle)
42+
#stdout.writelines(dump_satrec(sat))
43+
# ensure it's between 0 and 1
44+
#assert 0.0 <= tle.eccentricity < 1.0, "parsed eccentricity out of range!"
45+
dt = datetime(2025, 11, 12, 12, 0, 0)
46+
data = get_sat_position(tle, dt)
47+
assert isinstance(data, SGP4Data)
48+
49+
assert len(data.position_km) == 3 #test that position and velocity have x y z components (3D, length 3)
50+
assert len(data.velocity_km_sec) == 3
51+
assert all(isinstance(x, float) for x in data.position_km) #loop through position_km and velocity_km,
52+
assert all(isinstance(x, float) for x in data.velocity_km_sec) # ensuring data types are floats
Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,12 @@
99
@pytest.mark.parametrize(
1010
"tle_line, expected_checksum",
1111
[
12-
("1 25544U 98067A 23058.54791667 .00016717 00000+0 10270-3 0 9991", 8),
12+
("1 25544U 98067A 23058.54791667 .00016717 00000+0 10270-3 0 9991", 8), #sample value
13+
("", 0), #empty line
14+
("ABCDEFG-+", 1), #check addition on minus
15+
("1 25544U---", (1+2+5+5+4+4 + 3) % 10), #combination of digits and minus
16+
17+
1318
],
1419
)
1520
def test_calculate_checksum(tle_line, expected_checksum):
@@ -21,9 +26,17 @@ def test_calculate_checksum(tle_line, expected_checksum):
2126
[
2227
("123456-3", 0.00123456), # 1.23456e-3
2328
("678901+2", 678.901), # 6.78901e2
24-
# ("-456789-5", -5.6789e-6), # -0.56789e-5
29+
("000001+0", 0.00001),
30+
("999999-1", 0.999999), # 9.99999e-1
31+
("123456+0", 1.23456), # 1.23456e0
32+
33+
# ("-456789-5", -5.6789e-6), # -0.56789e-5 (negative value, fails)
2534
],
2635
)
36+
#I did some research and it seems that the function won't be called for negative values for real TLEs, so failing on negative values should be fine
37+
38+
39+
2740
def test_convert_decimal_point_assumed(input_value, expected_result):
2841
assert convert_decimal_point_assumed(input_value) == pytest.approx(expected_result, rel=1e-6)
2942

@@ -38,5 +51,22 @@ def test_parse_tle_data():
3851
parsed_data = parse_tle_data(tle_str)
3952

4053
assert parsed_data.satellite_number == 25544
54+
assert parsed_data.first_derivative_mean_motion == pytest.approx(.00016717, rel=1e-4)
4155
assert parsed_data.inclination == pytest.approx(51.6435, rel=1e-4)
4256
assert parsed_data.right_ascension == pytest.approx(143.0464, rel=1e-4)
57+
58+
59+
60+
def test_parse_tle_data_invalid(): #test for corrupted TLEs with mismatched checksums
61+
tle_str = (
62+
"ISS (ZARYA)\n"
63+
"1 25544U 98067A 23058.54791667 .00016717 00000+0 10270-3 0 9997\n"
64+
"2 25544 51.6435 143.0464 0004767 278.8055 81.2436 15.49815308274053"
65+
)
66+
67+
bad_tle1 = tle_str.replace("9997", "9998")
68+
bad_tle2 = tle_str.replace("15.49815308274053", "15.49815308274054")
69+
with pytest.raises(ValueError, match="Invalid checksum for line 1"):
70+
parse_tle_data(bad_tle1)
71+
with pytest.raises(ValueError, match="Invalid checksum for line 2"):
72+
parse_tle_data(bad_tle2)

0 commit comments

Comments
 (0)