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.
@@ -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
@@ -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,7 @@
1
+ """Generic exceptions that apply throughout satplot."""
2
+
3
+ class OutOfRangeError(Exception):
4
+ """The value is out of the acceptable range."""
5
+
6
+ class DimensionError(Exception):
7
+ """Dimension of an array is invalid."""
@@ -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)