spherapy 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- spherapy/__init__.py +93 -0
- spherapy/__main__.py +17 -0
- spherapy/_version.py +34 -0
- spherapy/data/TLEs/25544.tle +153378 -0
- spherapy/orbit.py +874 -0
- spherapy/timespan.py +436 -0
- spherapy/updater.py +91 -0
- spherapy/util/__init__.py +0 -0
- spherapy/util/celestrak.py +87 -0
- spherapy/util/constants.py +38 -0
- spherapy/util/credentials.py +131 -0
- spherapy/util/elements_u.py +70 -0
- spherapy/util/epoch_u.py +149 -0
- spherapy/util/exceptions.py +7 -0
- spherapy/util/orbital_u.py +72 -0
- spherapy/util/spacetrack.py +272 -0
- spherapy-0.2.0.dist-info/METADATA +211 -0
- spherapy-0.2.0.dist-info/RECORD +22 -0
- spherapy-0.2.0.dist-info/WHEEL +5 -0
- spherapy-0.2.0.dist-info/entry_points.txt +3 -0
- spherapy-0.2.0.dist-info/licenses/LICENSE.md +19 -0
- spherapy-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Functions to fetch and store spacetrack credentials on the system."""
|
|
2
|
+
|
|
3
|
+
import configparser
|
|
4
|
+
import getpass
|
|
5
|
+
|
|
6
|
+
import keyring
|
|
7
|
+
import keyring.errors
|
|
8
|
+
|
|
9
|
+
import spherapy
|
|
10
|
+
|
|
11
|
+
# used to store/fetch the username in keyring (abuse of API)
|
|
12
|
+
USERNAME_KEY = "spherapy_username"
|
|
13
|
+
|
|
14
|
+
def fetchConfigCredentials(config:configparser.ConfigParser) -> dict[str, str|None]:
|
|
15
|
+
"""Fetch spacetrack credentials from config file.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
config: ConfigParser object containing config fields
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
dict containing username and password
|
|
22
|
+
"""
|
|
23
|
+
username = config['credentials'].get('SpacetrackUser')
|
|
24
|
+
if username == 'None':
|
|
25
|
+
username = None
|
|
26
|
+
|
|
27
|
+
password = config['credentials'].get('SpacetrackPasswd')
|
|
28
|
+
if password == 'None': # noqa: S105, password not hardcoded, just parsing empty config file
|
|
29
|
+
password = None
|
|
30
|
+
|
|
31
|
+
return {'user':username, 'passwd':password}
|
|
32
|
+
|
|
33
|
+
def fetchKeyringCredentials() -> dict[str,str|None]:
|
|
34
|
+
"""Fetch spacetrack credentials from system keyring.
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
dict containing username and password
|
|
38
|
+
|
|
39
|
+
Raises:
|
|
40
|
+
ValueError: If no system keyring exists
|
|
41
|
+
"""
|
|
42
|
+
username = _fetchUser()
|
|
43
|
+
if username is None:
|
|
44
|
+
return {'user':None, 'passwd':None}
|
|
45
|
+
password = _fetchPass(username)
|
|
46
|
+
return {'user':username, 'passwd':password}
|
|
47
|
+
|
|
48
|
+
def _fetchUser() -> str|None:
|
|
49
|
+
try:
|
|
50
|
+
username = keyring.get_password(spherapy.service_name, USERNAME_KEY)
|
|
51
|
+
except keyring.errors.NoKeyringError:
|
|
52
|
+
raise ValueError('No Keyring exists on this machine: '
|
|
53
|
+
'did you forget to set env variable SPHERAPY_CONFIG_DIR '
|
|
54
|
+
'if using on a headless machine?') \
|
|
55
|
+
from keyring.errors.NoKeyringError
|
|
56
|
+
return username
|
|
57
|
+
|
|
58
|
+
def _fetchPass(username:str) -> str|None:
|
|
59
|
+
try:
|
|
60
|
+
password = keyring.get_password(spherapy.service_name, username)
|
|
61
|
+
except keyring.errors.NoKeyringError:
|
|
62
|
+
raise ValueError('No Keyring exists on this machine: '
|
|
63
|
+
'did you forget to set env variable SPHERAPY_CONFIG_DIR') \
|
|
64
|
+
from keyring.errors.NoKeyringError
|
|
65
|
+
return password
|
|
66
|
+
|
|
67
|
+
def _reloadCredentials(config_parser:configparser.ConfigParser|None=None):
|
|
68
|
+
if config_parser is None:
|
|
69
|
+
spherapy.spacetrack_credentials = fetchKeyringCredentials()
|
|
70
|
+
else:
|
|
71
|
+
spherapy.spacetrack_credentials = fetchConfigCredentials(config_parser)
|
|
72
|
+
|
|
73
|
+
def storeCredentials(user:None|str=None, passwd:None|str=None) -> bool:
|
|
74
|
+
"""Store spacetrack credentials in system keyring.
|
|
75
|
+
|
|
76
|
+
Can set either username or password, but can't set a password without a username
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
user: [optional] username string
|
|
80
|
+
passwd: [optional] password string
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
True if successfully stored
|
|
84
|
+
|
|
85
|
+
Raises:
|
|
86
|
+
KeyError: raised if trying to store a password without a username
|
|
87
|
+
"""
|
|
88
|
+
if user is not None:
|
|
89
|
+
keyring.set_password(spherapy.service_name, USERNAME_KEY, user)
|
|
90
|
+
else:
|
|
91
|
+
user = _fetchUser()
|
|
92
|
+
if user is None:
|
|
93
|
+
raise KeyError("Can't set a Spacetrack password without a username: "
|
|
94
|
+
"no existing username")
|
|
95
|
+
if passwd is not None:
|
|
96
|
+
keyring.set_password(spherapy.service_name, user, passwd)
|
|
97
|
+
|
|
98
|
+
# check stored credentials match input
|
|
99
|
+
stored_user = _fetchUser()
|
|
100
|
+
stored_pass = _fetchPass(stored_user) if stored_user is not None else None
|
|
101
|
+
if (stored_user == user and stored_pass == passwd):
|
|
102
|
+
_reloadCredentials()
|
|
103
|
+
return True
|
|
104
|
+
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
def clearCredentials() -> None:
|
|
108
|
+
"""Clear the stored spacetrack credentials.
|
|
109
|
+
|
|
110
|
+
Raises:
|
|
111
|
+
KeyError: If no credentials are currently stored.
|
|
112
|
+
"""
|
|
113
|
+
user = _fetchUser()
|
|
114
|
+
if user is not None:
|
|
115
|
+
keyring.delete_password(spherapy.service_name, user)
|
|
116
|
+
else:
|
|
117
|
+
raise KeyError("No stored USER - no credentials to delete.")
|
|
118
|
+
keyring.delete_password(spherapy.service_name, USERNAME_KEY)
|
|
119
|
+
|
|
120
|
+
_reloadCredentials()
|
|
121
|
+
|
|
122
|
+
def createCredentials():
|
|
123
|
+
"""Script helper function to create and store credentials.
|
|
124
|
+
|
|
125
|
+
Called by command line script. Requires user input.
|
|
126
|
+
"""
|
|
127
|
+
user = input('Please enter your spacetrack username:')
|
|
128
|
+
passwd = getpass.getpass(prompt='Please enter your spacetrack password:')
|
|
129
|
+
print("These credentials are being saved in your system keyring") # noqa: T201 print needed
|
|
130
|
+
if not storeCredentials(user=user, passwd=passwd):
|
|
131
|
+
print("Could not save credentials in system keyring") # noqa: T201 print needed
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Utility functions for dealing with propagation element set strings."""
|
|
2
|
+
|
|
3
|
+
from typing import TypedDict
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ElementsLineDict(TypedDict):
|
|
7
|
+
"""TypedDict container for lines of an element set.
|
|
8
|
+
|
|
9
|
+
Attributes:
|
|
10
|
+
fields: list of each field of an element set line as strings
|
|
11
|
+
line_str: original whole line string of element set (no newline)
|
|
12
|
+
"""
|
|
13
|
+
fields: list[str]
|
|
14
|
+
line_str: str
|
|
15
|
+
|
|
16
|
+
def split3LELineIntoFields(line:str) -> ElementsLineDict:
|
|
17
|
+
"""Create an ElementsLineDict from an element set line.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
line: string
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
ElementsLineDict
|
|
24
|
+
"""
|
|
25
|
+
fields = line.split()
|
|
26
|
+
# insert fields into and store original line in tle dict
|
|
27
|
+
line_dict:ElementsLineDict = {'fields':fields,
|
|
28
|
+
'line_str':line}
|
|
29
|
+
|
|
30
|
+
return line_dict
|
|
31
|
+
|
|
32
|
+
def stringify3LEDict(tle_dict:dict[int, ElementsLineDict]) -> str:
|
|
33
|
+
r"""Turn an element set dict 3LE dict back into a \n delimited string."""
|
|
34
|
+
lines = [line_dict['line_str'] for line_dict in tle_dict.values()]
|
|
35
|
+
return '\n'.join(lines)
|
|
36
|
+
|
|
37
|
+
def dictify3LEs(lines:list[str]) -> list[dict[int, ElementsLineDict]]:
|
|
38
|
+
"""Turn list of strings into list of dicts storing TLE info.
|
|
39
|
+
|
|
40
|
+
dict:
|
|
41
|
+
key: line number (0-2)
|
|
42
|
+
value: dict
|
|
43
|
+
key: 'fields' | 'line_str'
|
|
44
|
+
value: 'list of fields as strings' | original TLE line str
|
|
45
|
+
"""
|
|
46
|
+
if len(lines) == 0:
|
|
47
|
+
raise ValueError("No data")
|
|
48
|
+
if len(lines)%3 != 0:
|
|
49
|
+
# If not obvious what lines relate to eachother, can't make assumption -> abort.
|
|
50
|
+
raise ValueError('Incomplete TLEs present, aborting')
|
|
51
|
+
|
|
52
|
+
list_3les = []
|
|
53
|
+
tle:dict[int,ElementsLineDict] = {0:{'fields':[], 'line_str':''},
|
|
54
|
+
1:{'fields':[], 'line_str':''},
|
|
55
|
+
2:{'fields':[], 'line_str':''}}
|
|
56
|
+
for line in lines:
|
|
57
|
+
line_dict = split3LELineIntoFields(line)
|
|
58
|
+
# insert fields into and store original line in tle dict
|
|
59
|
+
tle_line_num = int(line_dict['fields'][0])
|
|
60
|
+
tle[tle_line_num] = line_dict
|
|
61
|
+
|
|
62
|
+
if tle_line_num == 2: # noqa: PLR2004
|
|
63
|
+
# store parsed tle
|
|
64
|
+
list_3les.append(tle.copy())
|
|
65
|
+
# empty dict
|
|
66
|
+
tle[0] = {'fields':[], 'line_str':''}
|
|
67
|
+
tle[1] = {'fields':[], 'line_str':''}
|
|
68
|
+
tle[2] = {'fields':[], 'line_str':''}
|
|
69
|
+
|
|
70
|
+
return list_3les
|
spherapy/util/epoch_u.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Utility functions for converting different epochs.
|
|
2
|
+
|
|
3
|
+
Attributes:
|
|
4
|
+
GMST_epoch: [description]
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import datetime as dt
|
|
8
|
+
import pathlib
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
from spherapy.util import elements_u
|
|
13
|
+
|
|
14
|
+
GMST_epoch = dt.datetime(2000, 1, 1, 12, 0, 0)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def epoch2datetime(epoch_str: str) -> dt.datetime:
|
|
18
|
+
"""Converts a fractional epoch string to a datetime object.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
epoch_str: fractional year epoch
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
equivalent datetime
|
|
25
|
+
"""
|
|
26
|
+
if not isinstance(epoch_str, str):
|
|
27
|
+
epoch_str = str(epoch_str)
|
|
28
|
+
|
|
29
|
+
year = int(epoch_str[:2])
|
|
30
|
+
if year < 50: # noqa: PLR2004
|
|
31
|
+
year += 2000
|
|
32
|
+
else:
|
|
33
|
+
year += 1900
|
|
34
|
+
|
|
35
|
+
fractional_day_of_year = float(epoch_str[2:])
|
|
36
|
+
|
|
37
|
+
base = dt.datetime(year, 1, 1, tzinfo=dt.timezone.utc)
|
|
38
|
+
return base + dt.timedelta(days=fractional_day_of_year) - dt.timedelta(days=1)
|
|
39
|
+
|
|
40
|
+
def epochEarlierThan(epoch_a:str, epoch_b:str) -> bool:
|
|
41
|
+
"""Check if epoch A is earlier than epoch B.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
epoch_a: TLE epoch string A
|
|
45
|
+
epoch_b: TLE epoch string B
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
True if epoch A is earlier than epoch B
|
|
49
|
+
"""
|
|
50
|
+
datetime_a = epoch2datetime(epoch_a)
|
|
51
|
+
datetime_b = epoch2datetime(epoch_b)
|
|
52
|
+
return datetime_a < datetime_b
|
|
53
|
+
|
|
54
|
+
def epochLaterThan(epoch_a:str, epoch_b:str) -> bool:
|
|
55
|
+
"""Check if epoch A is later than epoch B.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
epoch_a: TLE epoch string A
|
|
59
|
+
epoch_b: TLE epoch string B
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
True if epoch A is later than epoch B
|
|
63
|
+
"""
|
|
64
|
+
datetime_a = epoch2datetime(epoch_a)
|
|
65
|
+
datetime_b = epoch2datetime(epoch_b)
|
|
66
|
+
return datetime_a > datetime_b
|
|
67
|
+
|
|
68
|
+
def datetime2TLEepoch(date: dt.datetime) -> str:
|
|
69
|
+
"""Converts a datetime to a TLE epoch string.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
date: Datetime object
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
TLE epoch string, with fractional seconds
|
|
76
|
+
"""
|
|
77
|
+
tzinfo = date.tzinfo
|
|
78
|
+
year_str = str(date.year)[-2:]
|
|
79
|
+
day_str = str(date.timetuple().tm_yday).zfill(3)
|
|
80
|
+
fraction_str = str(
|
|
81
|
+
(date - dt.datetime(date.year, date.month, date.day, tzinfo=tzinfo)).total_seconds()
|
|
82
|
+
/ dt.timedelta(days=1).total_seconds()
|
|
83
|
+
)[1:]
|
|
84
|
+
|
|
85
|
+
return year_str + day_str + fraction_str
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def datetime2sgp4epoch(date: dt.datetime) -> float:
|
|
89
|
+
"""Converts a datetime to an sgp4 epoch.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
date: Datetime object
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
SGP4 epoch, with fractional seconds
|
|
96
|
+
"""
|
|
97
|
+
tzinfo = date.tzinfo
|
|
98
|
+
sgp4start = dt.datetime(1949, 12, 31, 0, 0, 0, tzinfo=tzinfo)
|
|
99
|
+
delta = date - sgp4start
|
|
100
|
+
return delta.days + delta.seconds / 86400
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def findClosestDatetimeIndices(test_arr:np.ndarray[tuple[int], np.dtype[np.datetime64]],
|
|
104
|
+
source_arr:np.ndarray[tuple[int], np.dtype[np.datetime64]]) \
|
|
105
|
+
-> np.ndarray[tuple[int], np.dtype[np.int64]]:
|
|
106
|
+
"""Find the index of the closest datetime in source arr for each datetime in test_arr.
|
|
107
|
+
|
|
108
|
+
Both search_arr and source_arr must be sorted.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
test_arr: Mx1 array of datetimes, will find closest time for each element in this array
|
|
112
|
+
source_arr: Nx1: array of datetimes to compare to
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
Mx1 array of indices of source_arr
|
|
116
|
+
"""
|
|
117
|
+
# convert to unix timestamps first to save memory
|
|
118
|
+
test_arr_fl = np.vectorize(lambda x:x.timestamp())(test_arr)
|
|
119
|
+
source_arr_fl = np.vectorize(lambda x:x.timestamp())(source_arr)
|
|
120
|
+
idx_arr = np.zeros(test_arr_fl.shape, dtype=np.int64)
|
|
121
|
+
for ii in range(len(test_arr_fl)):
|
|
122
|
+
diff = np.abs(source_arr_fl-test_arr_fl[ii])
|
|
123
|
+
idx_arr[ii] = np.argmin(diff,axis=0)
|
|
124
|
+
|
|
125
|
+
return idx_arr
|
|
126
|
+
|
|
127
|
+
def getStoredEpochs(tle_path:pathlib.Path) -> None|tuple[dt.datetime, dt.datetime|None]:
|
|
128
|
+
"""Return the start and end epoch for tle_path.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
tle_path: tle file
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
(first epoch datetime, last epoch datetime)
|
|
135
|
+
None if no spacetrack tle stored for sat_id
|
|
136
|
+
"""
|
|
137
|
+
if not tle_path.exists():
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
with tle_path.open('r') as fp:
|
|
141
|
+
lines = fp.readlines()
|
|
142
|
+
|
|
143
|
+
first_tle_line_1 = elements_u.split3LELineIntoFields(lines[1])
|
|
144
|
+
last_tle_line_1 = elements_u.split3LELineIntoFields(lines[-2])
|
|
145
|
+
|
|
146
|
+
first_epoch_dt = epoch2datetime(first_tle_line_1['fields'][3])
|
|
147
|
+
last_epoch_dt = epoch2datetime(last_tle_line_1['fields'][3])
|
|
148
|
+
|
|
149
|
+
return (first_epoch_dt, last_epoch_dt)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Utility functions for orbital calculations."""
|
|
2
|
+
import numpy as np
|
|
3
|
+
|
|
4
|
+
import spherapy.util.constants as consts
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def ssoInc(alt:float, e:float=0) -> float:
|
|
8
|
+
# TODO: update to calculate for different central bodies
|
|
9
|
+
"""Generates required inclination for given altitude [km] to maintain Sun Syncrhonous orbit.
|
|
10
|
+
|
|
11
|
+
Args:
|
|
12
|
+
alt: altitude of orbit in km
|
|
13
|
+
e: [Optional] eccentricity of orbit
|
|
14
|
+
Default is circular (0)
|
|
15
|
+
|
|
16
|
+
Returns:
|
|
17
|
+
Inclination angle in degrees
|
|
18
|
+
"""
|
|
19
|
+
a = consts.R_EARTH + alt * 1e3
|
|
20
|
+
# print(a)
|
|
21
|
+
p = a * (1 - e**2)
|
|
22
|
+
# print(p)
|
|
23
|
+
period = 2 * np.pi * np.sqrt(a**3 / consts.GM_EARTH)
|
|
24
|
+
# print(period)
|
|
25
|
+
req_prec_rate = (2 * np.pi / 365.25) * (1 / 86400.)
|
|
26
|
+
# print(req_prec_rate)
|
|
27
|
+
req_prec_orb = req_prec_rate * period
|
|
28
|
+
# print(req_prec_orb)
|
|
29
|
+
|
|
30
|
+
cosi = (req_prec_orb * p**2) / (-3 * np.pi * consts.J2 * consts.R_EARTH**2)
|
|
31
|
+
# print(cosi)
|
|
32
|
+
|
|
33
|
+
inc = np.arccos(cosi)
|
|
34
|
+
|
|
35
|
+
return np.rad2deg(inc)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def calcPeriod(a:float) -> float:
|
|
39
|
+
"""Returns the period of an elliptical or circular orbit.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
a: semi-major axis in m
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
Orbital period in s
|
|
46
|
+
"""
|
|
47
|
+
return 2 * np.pi * np.sqrt(a**3 / consts.GM_EARTH)
|
|
48
|
+
|
|
49
|
+
def calcOrbitalVel(a:float, pos:np.ndarray[tuple[int],np.dtype[np.float64]]) -> float:
|
|
50
|
+
"""Return the instantaneous velocity magnitude for an elliptical orbit at position.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
a: semi-major axis in m
|
|
54
|
+
pos: cartesian position, assuming the origin is at the central body.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
instantaneous velocity magnitude.
|
|
58
|
+
"""
|
|
59
|
+
r = np.linalg.norm(pos)
|
|
60
|
+
|
|
61
|
+
return np.sqrt(consts.GM_EARTH * (2 / r - 1 / a))
|
|
62
|
+
|
|
63
|
+
def calcMeanMotion(a:float) -> float:
|
|
64
|
+
"""Returns mean motion [radians/s] for an elliptical or circular orbit with semi-major axis a.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
a: semi-major axis in m
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
Orbital period in s
|
|
71
|
+
"""
|
|
72
|
+
return np.sqrt(consts.GM_EARTH / a**3)
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""Functions to fetch TLEs from Spacetrack.
|
|
2
|
+
|
|
3
|
+
Attributes:
|
|
4
|
+
MAX_RETRIES: number of times to try and reach Spacetrack.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import datetime as dt
|
|
8
|
+
import logging
|
|
9
|
+
import pathlib
|
|
10
|
+
|
|
11
|
+
from typing import TypedDict
|
|
12
|
+
from typing_extensions import NotRequired
|
|
13
|
+
|
|
14
|
+
import spacetrack as sp
|
|
15
|
+
|
|
16
|
+
import spherapy
|
|
17
|
+
from spherapy.util import elements_u, epoch_u
|
|
18
|
+
|
|
19
|
+
MAX_RETRIES=3
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
class _TLEGetter:
|
|
24
|
+
"""Container around python spacetrack library."""
|
|
25
|
+
def __init__(self, sat_id_list:list[int], user:None|str=None, passwd:None|str=None):
|
|
26
|
+
# initialise the client
|
|
27
|
+
if user is None:
|
|
28
|
+
self.username = spherapy.spacetrack_credentials['user']
|
|
29
|
+
else:
|
|
30
|
+
self.username = user
|
|
31
|
+
if passwd is None:
|
|
32
|
+
self.password = spherapy.spacetrack_credentials['passwd']
|
|
33
|
+
else:
|
|
34
|
+
self.password = passwd
|
|
35
|
+
if self.username is None or self.password is None:
|
|
36
|
+
raise InvalidCredentialsError('No Spacetrack Credentials have been entered')
|
|
37
|
+
|
|
38
|
+
self.modified_ids = []
|
|
39
|
+
try:
|
|
40
|
+
self.stc = sp.SpaceTrackClient(self.username, self.password)
|
|
41
|
+
for sat_id in sat_id_list:
|
|
42
|
+
logger.info("Trying to update TLEs for %s", sat_id)
|
|
43
|
+
if not self.checkTLEFileExists(sat_id) or self.getNumPastTLEs(sat_id) == 0:
|
|
44
|
+
res = self.fetchAll(sat_id)
|
|
45
|
+
if res is not None:
|
|
46
|
+
self.modified_ids.append(res)
|
|
47
|
+
else:
|
|
48
|
+
res = self.fetchLatest(sat_id)
|
|
49
|
+
if res is not None:
|
|
50
|
+
self.modified_ids.append(res)
|
|
51
|
+
|
|
52
|
+
except sp.AuthenticationError:
|
|
53
|
+
raise InvalidCredentialsError('Username and password are incorrect!') from sp.AuthenticationError #noqa: E501
|
|
54
|
+
|
|
55
|
+
def getModifiedIDs(self) -> list[int]:
|
|
56
|
+
return self.modified_ids
|
|
57
|
+
|
|
58
|
+
def checkTLEFileExists(self, sat_id:int) -> bool:
|
|
59
|
+
return getTLEFilePath(sat_id).exists()
|
|
60
|
+
|
|
61
|
+
def fetchAll(self, sat_id:int) -> int|None:
|
|
62
|
+
"""Fetch all TLEs for sat_id.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
sat_id: satcat id to fetch
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
sat_id if succesfully fetched
|
|
69
|
+
None if couldn't fetch
|
|
70
|
+
"""
|
|
71
|
+
attempt_number = 0
|
|
72
|
+
while attempt_number < MAX_RETRIES:
|
|
73
|
+
try:
|
|
74
|
+
opts = self._calcRequestAllOptions(sat_id)
|
|
75
|
+
logger.info("Requesting TLEs from spacetrack with following options: %s", opts)
|
|
76
|
+
resp_line_iterator = self.stc.tle(**opts)
|
|
77
|
+
resp_lines = list(resp_line_iterator)
|
|
78
|
+
tle_dict_list = elements_u.dictify3LEs(resp_lines)
|
|
79
|
+
self._writeTLEsToNewFile(sat_id, tle_dict_list)
|
|
80
|
+
break
|
|
81
|
+
except TimeoutError:
|
|
82
|
+
attempt_number += 1
|
|
83
|
+
|
|
84
|
+
if attempt_number == MAX_RETRIES:
|
|
85
|
+
logger.error("Could not fetch All TLEs for sat %s: failed %s times.",
|
|
86
|
+
sat_id, attempt_number)
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
return sat_id
|
|
90
|
+
|
|
91
|
+
def getNumPastTLEs(self, sat_id:int) -> int:
|
|
92
|
+
"""Retrieve number of TLEs stored for sat_id."""
|
|
93
|
+
with getTLEFilePath(sat_id).open('r') as fp:
|
|
94
|
+
lines = fp.readlines()
|
|
95
|
+
return int(len(lines)/3)
|
|
96
|
+
|
|
97
|
+
def fetchLatest(self, sat_id:int) -> int|None:
|
|
98
|
+
"""Fetch the latest TLEs for sat_id, and append them to the TLE file.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
sat_id: satcat id to fetch
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
sat_id if succesfully fetched
|
|
105
|
+
None if couldn't fetch
|
|
106
|
+
"""
|
|
107
|
+
attempt_number = 0
|
|
108
|
+
while attempt_number < MAX_RETRIES:
|
|
109
|
+
try:
|
|
110
|
+
_, days_since_last_epoch = self._findLocalLastEpoch(sat_id)
|
|
111
|
+
if days_since_last_epoch > 0.0:
|
|
112
|
+
opts = self._calcRequestPartialOptions(sat_id)
|
|
113
|
+
logger.info("Requesting TLEs from spacetrack with following options: %s", opts)
|
|
114
|
+
resp_line_iterator = self.stc.tle(**opts)
|
|
115
|
+
resp_lines = list(resp_line_iterator)
|
|
116
|
+
tle_dict_list = elements_u.dictify3LEs(resp_lines)
|
|
117
|
+
self._writeTLEsToFile(sat_id, tle_dict_list)
|
|
118
|
+
break
|
|
119
|
+
except TimeoutError:
|
|
120
|
+
attempt_number += 1
|
|
121
|
+
if attempt_number == MAX_RETRIES:
|
|
122
|
+
logger.error("Could not fetch the latest TLEs for sat %s: failed %s times.",
|
|
123
|
+
sat_id, attempt_number)
|
|
124
|
+
raise TimeoutError(f"Could not fetch the latest TLEs for sat {sat_id}: "
|
|
125
|
+
f"failed {attempt_number} times.")
|
|
126
|
+
|
|
127
|
+
return sat_id
|
|
128
|
+
|
|
129
|
+
def _findLocalLastEpoch(self, sat_id:int) -> tuple[str, float]:
|
|
130
|
+
"""Find the last TLE epoch in a TLE file."""
|
|
131
|
+
# get penultimate and ultimate epochs
|
|
132
|
+
with getTLEFilePath(sat_id).open('r') as fp:
|
|
133
|
+
lines = fp.readlines()
|
|
134
|
+
while lines[-1] == '':
|
|
135
|
+
lines = lines[:-1]
|
|
136
|
+
last_epoch_line = lines[-2]
|
|
137
|
+
last_tle_epoch = last_epoch_line.split()[3]
|
|
138
|
+
|
|
139
|
+
last_epoch_datetime = epoch_u.epoch2datetime(last_tle_epoch)
|
|
140
|
+
delta = dt.datetime.now(tz=dt.timezone.utc) - last_epoch_datetime
|
|
141
|
+
|
|
142
|
+
return last_tle_epoch, delta.days
|
|
143
|
+
|
|
144
|
+
def _calcRequestAllOptions(self, sat_id:int) -> "_RequestOptions":
|
|
145
|
+
"""Format spacetrack library request options. Request all TLEs."""
|
|
146
|
+
request_options:_RequestOptions = {
|
|
147
|
+
'norad_cat_id': sat_id,
|
|
148
|
+
'orderby': 'epoch asc',
|
|
149
|
+
'limit': 500000,
|
|
150
|
+
'format': '3le',
|
|
151
|
+
'iter_lines': True}
|
|
152
|
+
|
|
153
|
+
return request_options
|
|
154
|
+
|
|
155
|
+
def _calcRequestPartialOptions(self, sat_id:int) -> "_RequestOptions":
|
|
156
|
+
"""Format spacetrack library request options. Request all TLEs since last epoch."""
|
|
157
|
+
_, days_since_last_epoch = self._findLocalLastEpoch(sat_id)
|
|
158
|
+
|
|
159
|
+
request_options:_RequestOptions = {
|
|
160
|
+
'norad_cat_id': sat_id,
|
|
161
|
+
'orderby': 'epoch asc',
|
|
162
|
+
'epoch': f'>now-{days_since_last_epoch+1}',
|
|
163
|
+
'limit': 500000,
|
|
164
|
+
'format': '3le',
|
|
165
|
+
'iter_lines': True}
|
|
166
|
+
|
|
167
|
+
return request_options
|
|
168
|
+
|
|
169
|
+
def _writeTLEsToFile(self, sat_id:int,
|
|
170
|
+
tle_dict_list:list[dict[int, elements_u.ElementsLineDict]]):
|
|
171
|
+
"""Append TLEs to TLE file."""
|
|
172
|
+
last_epoch, _ = self._findLocalLastEpoch(sat_id)
|
|
173
|
+
first_new_idx = None
|
|
174
|
+
for tle_idx, tle_dict in enumerate(tle_dict_list):
|
|
175
|
+
epoch = tle_dict[1]['fields'][3]
|
|
176
|
+
if epoch_u.epochLaterThan(epoch, last_epoch):
|
|
177
|
+
first_new_idx = tle_idx
|
|
178
|
+
break
|
|
179
|
+
if first_new_idx is not None:
|
|
180
|
+
with getTLEFilePath(sat_id).open('a') as fp:
|
|
181
|
+
for tle_dict in tle_dict_list[first_new_idx:]:
|
|
182
|
+
fp.write('\n')
|
|
183
|
+
fp.write(elements_u.stringify3LEDict(tle_dict))
|
|
184
|
+
|
|
185
|
+
def _writeTLEsToNewFile(self, sat_id:int,
|
|
186
|
+
tle_dict_list:list[dict[int, elements_u.ElementsLineDict]]):
|
|
187
|
+
"""New TLE, write entire content to new file."""
|
|
188
|
+
with getTLEFilePath(sat_id).open('w') as fp:
|
|
189
|
+
# don't want to begin or end file with newline
|
|
190
|
+
fp.write(elements_u.stringify3LEDict(tle_dict_list[0]))
|
|
191
|
+
for tle_dict in tle_dict_list[1:]:
|
|
192
|
+
fp.write('\n')
|
|
193
|
+
fp.write(elements_u.stringify3LEDict(tle_dict))
|
|
194
|
+
|
|
195
|
+
class InvalidCredentialsError(Exception):
|
|
196
|
+
"""Error indicating invalid credentials used to access spacetrack."""
|
|
197
|
+
def __init__(self, message:str): # noqa: D107
|
|
198
|
+
super().__init__(message)
|
|
199
|
+
|
|
200
|
+
class _RequestOptions(TypedDict):
|
|
201
|
+
norad_cat_id: int
|
|
202
|
+
orderby: str
|
|
203
|
+
epoch: NotRequired[str]
|
|
204
|
+
limit: int
|
|
205
|
+
format: str
|
|
206
|
+
iter_lines: bool
|
|
207
|
+
|
|
208
|
+
def updateTLEs(sat_id_list:list[int], user:None|str=None, passwd:None|str=None) -> list[int]:
|
|
209
|
+
"""Fetch most recent TLE for satcat IDs from spacetrack.
|
|
210
|
+
|
|
211
|
+
Fetch most recent TLEs for provided list of satcat IDs, and append to file.
|
|
212
|
+
If no TLE file yet exists, will download all historical TLEs
|
|
213
|
+
Will try MAX_RETRIES before raising a TimeoutError
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
sat_id_list: list of satcat ids to fetch
|
|
217
|
+
user: [Optional] overriding spacetrack username to use
|
|
218
|
+
passwd: [Optional] overriding spacetrack password to use
|
|
219
|
+
|
|
220
|
+
Returns:
|
|
221
|
+
list:list of satcat ids successfully fetched
|
|
222
|
+
|
|
223
|
+
Raises:
|
|
224
|
+
TimeoutError
|
|
225
|
+
"""
|
|
226
|
+
logger.info("Using SPACETRACK to update TLEs")
|
|
227
|
+
g = _TLEGetter(sat_id_list,user=user,passwd=passwd)
|
|
228
|
+
|
|
229
|
+
return g.getModifiedIDs()
|
|
230
|
+
|
|
231
|
+
def getTLEFilePath(sat_id:int) -> pathlib.Path:
|
|
232
|
+
"""Gives path to file where spacetrack TLE is stored.
|
|
233
|
+
|
|
234
|
+
Spacetrack TLEs are stored in {satcadID}.tle
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
sat_id: satcat ID
|
|
238
|
+
|
|
239
|
+
Returns:
|
|
240
|
+
path to file
|
|
241
|
+
"""
|
|
242
|
+
return spherapy.tle_dir.joinpath(f'{sat_id}.tle')
|
|
243
|
+
|
|
244
|
+
def getStoredEpochs(sat_id:int) -> None|tuple[dt.datetime, dt.datetime|None]:
|
|
245
|
+
"""Return the start and end epoch for {sat_id}.tle .
|
|
246
|
+
|
|
247
|
+
Args:
|
|
248
|
+
sat_id: satcat id to check
|
|
249
|
+
|
|
250
|
+
Returns:
|
|
251
|
+
(first epoch datetime, last epoch datetime)
|
|
252
|
+
None if no spacetrack tle stored for sat_id
|
|
253
|
+
"""
|
|
254
|
+
tle_path = getTLEFilePath(sat_id)
|
|
255
|
+
|
|
256
|
+
return epoch_u.getStoredEpochs(tle_path)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def doCredentialsExist() -> bool:
|
|
260
|
+
"""Checks if spacetrack credentials have been loaded into Spherapy.
|
|
261
|
+
|
|
262
|
+
Returns:
|
|
263
|
+
bool:
|
|
264
|
+
"""
|
|
265
|
+
user_stored = False
|
|
266
|
+
passwd_stored = False
|
|
267
|
+
if spherapy.spacetrack_credentials['user'] is not None:
|
|
268
|
+
user_stored = True
|
|
269
|
+
if spherapy.spacetrack_credentials['passwd'] is not None:
|
|
270
|
+
passwd_stored = True
|
|
271
|
+
|
|
272
|
+
return (user_stored and passwd_stored)
|