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
spherapy/timespan.py
ADDED
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
"""Class for series of timestamps.
|
|
2
|
+
|
|
3
|
+
This module provides:
|
|
4
|
+
- TimeSpan: a series of timestamps to be used by an orbit object
|
|
5
|
+
"""
|
|
6
|
+
import datetime as dt
|
|
7
|
+
import logging
|
|
8
|
+
|
|
9
|
+
from typing import cast
|
|
10
|
+
from typing_extensions import Self
|
|
11
|
+
|
|
12
|
+
from astropy.time import Time as astropyTime
|
|
13
|
+
from dateutil.relativedelta import relativedelta
|
|
14
|
+
import numpy as np
|
|
15
|
+
from skyfield.api import load
|
|
16
|
+
import skyfield.timelib
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TimeSpan:
|
|
22
|
+
"""A series of timestamps.
|
|
23
|
+
|
|
24
|
+
Attributes:
|
|
25
|
+
start: The first timestamp
|
|
26
|
+
end: The last timestamp
|
|
27
|
+
time_step: The difference between timestamps in seconds
|
|
28
|
+
Can be None if irregular steps
|
|
29
|
+
time_period: The difference between end and start in seconds
|
|
30
|
+
|
|
31
|
+
"""
|
|
32
|
+
def __init__(self, t0:dt.datetime, timestep:str='1S', timeperiod:str='10S'):
|
|
33
|
+
"""Creates a series of timestamps in UTC.
|
|
34
|
+
|
|
35
|
+
Difference between each timestamp = timestep
|
|
36
|
+
Total duration = greatest integer number of timesteps less than timeperiod
|
|
37
|
+
If timeperiod is an integer multiple of timestep,
|
|
38
|
+
then TimeSpan[-1] - TimeSpan[0] = timeperiod
|
|
39
|
+
If timeperiod is NOT an integer multiple of timestep,
|
|
40
|
+
then TimeSpan[-1] = int(timeperiod/timestep) (Note: int cast, not round)
|
|
41
|
+
|
|
42
|
+
Does not account for Leap seconds, similar to all Posix compliant UTC based time
|
|
43
|
+
representations. see: https://numpy.org/doc/stable/reference/arrays.datetime.html#datetime64-shortcomings
|
|
44
|
+
for equivalent shortcomings.
|
|
45
|
+
Always contains at least two timestamps
|
|
46
|
+
|
|
47
|
+
Parameters
|
|
48
|
+
t0: datetime defining the start of the TimeSpan.
|
|
49
|
+
If timezone naive, assumed to be in UTC
|
|
50
|
+
If timezone aware, will be converted to UTC
|
|
51
|
+
timestep: String describing the time step of the time span.
|
|
52
|
+
The string is constructed as an integer or float, followed by a time unit:
|
|
53
|
+
(d)ays, (H)ours, (M)inutes, (S)econds, (mS) milliseconds, (uS) microseconds
|
|
54
|
+
(the default is '1S')
|
|
55
|
+
timeperiod: String describing the time period of the time span.
|
|
56
|
+
The string is constructed as an integer or float, followed by a time unit:
|
|
57
|
+
(d)ays, (H)ours, (M)inutes, (S)econds, (mS) milliseconds, (uS) microseconds
|
|
58
|
+
(the default is '1d')
|
|
59
|
+
|
|
60
|
+
Raises:
|
|
61
|
+
------
|
|
62
|
+
ValueError
|
|
63
|
+
"""
|
|
64
|
+
# convert and/or add timezone info
|
|
65
|
+
if t0.tzinfo is not None:
|
|
66
|
+
t0 = t0.astimezone(dt.timezone.utc)
|
|
67
|
+
else:
|
|
68
|
+
t0 = t0.replace(tzinfo=dt.timezone.utc)
|
|
69
|
+
|
|
70
|
+
self.start:dt.datetime = t0
|
|
71
|
+
self.time_step:None|dt.timedelta = self._parseTimestep(timestep)
|
|
72
|
+
self.end:dt.datetime = self._parseTimeperiod(t0, timeperiod)
|
|
73
|
+
self.time_period = self.end - self.start
|
|
74
|
+
|
|
75
|
+
logger.info("Creating TimeSpan between %s and %s with %s seconds timestep",
|
|
76
|
+
self.start, self.end, self.time_step.total_seconds())
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
if self.start >= self.end:
|
|
80
|
+
logger.error("Timeperiod: %s results in an end date earlier than the start date",
|
|
81
|
+
timeperiod)
|
|
82
|
+
raise ValueError(f"Invalid timeperiod value: {timeperiod}")
|
|
83
|
+
|
|
84
|
+
if self.time_period < self.time_step:
|
|
85
|
+
logger.error("Timeperiod: %s is smaller than the timestep %s.", timeperiod, timestep)
|
|
86
|
+
raise ValueError(f"Invalid timeperiod value, too small: {timeperiod}")
|
|
87
|
+
|
|
88
|
+
# Calculate step numbers and correct for non integer steps.
|
|
89
|
+
n_down = int(np.floor(self.time_period / self.time_step))
|
|
90
|
+
if n_down != self.time_period / self.time_step:
|
|
91
|
+
logger.info("Rounding to previous integer number of timesteps")
|
|
92
|
+
self.end = n_down * self.time_step + self.start
|
|
93
|
+
self.time_period = self.end - self.start
|
|
94
|
+
|
|
95
|
+
logger.info("Adjusting TimeSpan to %s -> %s with %s seconds timestep",
|
|
96
|
+
self.start, self.end, self.time_step.total_seconds())
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
self._timearr = np.arange(self.start.replace(tzinfo=None),
|
|
100
|
+
self.end.replace(tzinfo=None)+self.time_step,
|
|
101
|
+
self.time_step).astype(dt.datetime)
|
|
102
|
+
self._timearr = np.vectorize(lambda x: x.replace(tzinfo=dt.timezone.utc))(self._timearr)
|
|
103
|
+
self._skyfield_timespan = load.timescale()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def __hash__(self) -> int:
|
|
107
|
+
"""Returns a hash of the timespan."""
|
|
108
|
+
return hash((self.start, self.end, self.time_step))
|
|
109
|
+
|
|
110
|
+
# Make it callable and return the data for that entry
|
|
111
|
+
def __call__(self) -> np.ndarray[tuple[int], np.dtype[np.datetime64]]:
|
|
112
|
+
"""Returns the internal _timearr when the TimeSpan is called."""
|
|
113
|
+
return self._timearr
|
|
114
|
+
|
|
115
|
+
def __getitem__(self, idx:None|int|np.integer|tuple|list|np.ndarray|slice=None) \
|
|
116
|
+
-> None|dt.datetime|np.ndarray[tuple[int], np.dtype[np.datetime64]]: # noqa: PLR0911
|
|
117
|
+
"""Returns an index or slice of the TimeSpan as an array of datetime objects."""
|
|
118
|
+
if idx is None:
|
|
119
|
+
return self._timearr
|
|
120
|
+
if isinstance(idx, int|np.integer):
|
|
121
|
+
return self._timearr[idx]
|
|
122
|
+
if isinstance(idx, tuple):
|
|
123
|
+
if len(idx) == 2: #noqa: PLR2004
|
|
124
|
+
return self._timearr[idx[0]:idx[1]]
|
|
125
|
+
return self._timearr[idx[0]:idx[1]:idx[2]]
|
|
126
|
+
if isinstance(idx, list):
|
|
127
|
+
return self._timearr[[idx]]
|
|
128
|
+
if isinstance(idx, np.ndarray):
|
|
129
|
+
return self._timearr[idx]
|
|
130
|
+
if isinstance(idx,slice):
|
|
131
|
+
return self._timearr[idx]
|
|
132
|
+
raise TypeError('index is unknown type')
|
|
133
|
+
|
|
134
|
+
def __eq__(self, other:object) -> bool:
|
|
135
|
+
"""Checks if other TimeSpan is equal to self."""
|
|
136
|
+
if not isinstance(other, TimeSpan):
|
|
137
|
+
return NotImplemented
|
|
138
|
+
|
|
139
|
+
if len(self) != len(other):
|
|
140
|
+
return False
|
|
141
|
+
|
|
142
|
+
return bool(np.all(self.asDatetime() == other.asDatetime()))
|
|
143
|
+
|
|
144
|
+
def __add__(self, other:Self) -> 'TimeSpan':
|
|
145
|
+
"""Apeends other TimeSpan to this one, does not order timesteps."""
|
|
146
|
+
self_copy = TimeSpan(self.start)
|
|
147
|
+
|
|
148
|
+
self_copy.start = self.start
|
|
149
|
+
self_copy.time_step = None
|
|
150
|
+
|
|
151
|
+
self_copy.end = other.end
|
|
152
|
+
self_copy.time_period = other.end - self_copy.start
|
|
153
|
+
|
|
154
|
+
self_copy._timearr = np.hstack((self._timearr, other._timearr))
|
|
155
|
+
|
|
156
|
+
return self_copy
|
|
157
|
+
|
|
158
|
+
def asAstropy(self, idx:None|int=None, scale:str='utc') -> astropyTime:
|
|
159
|
+
"""Return ndarray of TimeSpan as astropy.time objects.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
idx: timestamp index to return inside an astropyTime object
|
|
163
|
+
if no index supplied, returns all timestamps
|
|
164
|
+
scale: astropy time scale, can be one of
|
|
165
|
+
('tai', 'tcb', 'tcg', 'tdb', 'tt', 'ut1', 'utc')
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
-------
|
|
169
|
+
ndarray
|
|
170
|
+
"""
|
|
171
|
+
if idx is None:
|
|
172
|
+
return astropyTime(self._timearr, scale=scale)
|
|
173
|
+
return astropyTime(self._timearr[idx], scale=scale)
|
|
174
|
+
|
|
175
|
+
def asDatetime(self, idx:None|int=None) \
|
|
176
|
+
-> dt.datetime|np.ndarray[tuple[int], np.dtype[np.datetime64]]:
|
|
177
|
+
"""Return ndarray of TimeSpan as datetime objects.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
idx: timestamp index to return as a datetime
|
|
181
|
+
If no index supplied, returns whole array
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
-------
|
|
185
|
+
ndarray
|
|
186
|
+
"""
|
|
187
|
+
if idx is None:
|
|
188
|
+
return self._timearr
|
|
189
|
+
return self._timearr[idx]
|
|
190
|
+
|
|
191
|
+
def asSkyfield(self, idx:int) -> skyfield.timelib.Time:
|
|
192
|
+
"""Return TimeSpan element as Skyfield Time object.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
idx: timestamp index to return as skyfield time object
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
-------
|
|
199
|
+
Skyfield Time
|
|
200
|
+
"""
|
|
201
|
+
datetime = self._timearr[idx]
|
|
202
|
+
datetime = datetime.replace(tzinfo=dt.timezone.utc)
|
|
203
|
+
return self._skyfield_timespan.from_datetime(datetime)
|
|
204
|
+
|
|
205
|
+
def asText(self, idx:int) -> str:
|
|
206
|
+
"""Returns a text representation of a particular timestamp.
|
|
207
|
+
|
|
208
|
+
Timestamp will be formatted as YYYY-mm-dd HH:MM:SS
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
idx: timestamp index to format
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
str:
|
|
215
|
+
"""
|
|
216
|
+
return self._timearr[idx].strftime("%Y-%m-%d %H:%M:%S")
|
|
217
|
+
|
|
218
|
+
def secondsSinceStart(self) -> np.ndarray[tuple[int], np.dtype[np.float64]]:
|
|
219
|
+
"""Return ndarray with the seconds of all timesteps since the beginning.
|
|
220
|
+
|
|
221
|
+
Returns:
|
|
222
|
+
array of seconds since start for each timestamp
|
|
223
|
+
"""
|
|
224
|
+
diff = self._timearr - self.start
|
|
225
|
+
days = np.vectorize(lambda x: x.days)(diff)
|
|
226
|
+
secs = np.vectorize(lambda x: x.seconds)(diff)
|
|
227
|
+
usecs = np.vectorize(lambda x: x.microseconds)(diff)
|
|
228
|
+
return days * 86400 + secs + usecs * 1e-6
|
|
229
|
+
|
|
230
|
+
def getClosest(self, t_search:dt.datetime) -> tuple[dt.datetime, int]:
|
|
231
|
+
"""Find the closest time in a TimeSpan.
|
|
232
|
+
|
|
233
|
+
Parameters
|
|
234
|
+
----------
|
|
235
|
+
t_search : datetime to search for in TimeSpan
|
|
236
|
+
If timezone naive, assumed to be in UTC
|
|
237
|
+
If timezone aware, will be converted to UTCtime to find
|
|
238
|
+
|
|
239
|
+
Returns:
|
|
240
|
+
-------
|
|
241
|
+
datetime, int
|
|
242
|
+
Closest datetime in TimeSpan, index of closest date in TimeSpan
|
|
243
|
+
"""
|
|
244
|
+
# convert and/or add timezone info
|
|
245
|
+
if t_search.tzinfo is not None:
|
|
246
|
+
t_search = t_search.astimezone(dt.timezone.utc)
|
|
247
|
+
else:
|
|
248
|
+
t_search = t_search.replace(tzinfo=dt.timezone.utc)
|
|
249
|
+
diff = self._timearr - t_search
|
|
250
|
+
out = np.abs(np.vectorize(lambda x: x.total_seconds())(diff))
|
|
251
|
+
res_index = int(np.argmin(out))
|
|
252
|
+
res_datetime = self.asDatetime(res_index)
|
|
253
|
+
|
|
254
|
+
# res_datetime must be a dt.datetime since res_index is an int, so cast
|
|
255
|
+
res_datetime = cast("dt.datetime", res_datetime)
|
|
256
|
+
|
|
257
|
+
return res_datetime, res_index
|
|
258
|
+
|
|
259
|
+
def areTimesWithin(self, t_search:dt.datetime|np.ndarray[tuple[int], np.dtype[np.datetime64]])\
|
|
260
|
+
-> np.ndarray[tuple[int],np.dtype[np.bool_]]:
|
|
261
|
+
"""Find if the provided times are within the timespan.
|
|
262
|
+
|
|
263
|
+
Args:
|
|
264
|
+
t_search: times to check if within timespan
|
|
265
|
+
If timezone naive, assumed to be in UTC
|
|
266
|
+
If timezone aware, will be converted to UTCtime to find
|
|
267
|
+
|
|
268
|
+
Returns:
|
|
269
|
+
ndarray of bools, True if within timespan
|
|
270
|
+
"""
|
|
271
|
+
if isinstance(t_search, dt.datetime):
|
|
272
|
+
if t_search.tzinfo is not None:
|
|
273
|
+
t_search = t_search.astimezone(dt.timezone.utc)
|
|
274
|
+
else:
|
|
275
|
+
t_search = t_search.replace(tzinfo=dt.timezone.utc)
|
|
276
|
+
|
|
277
|
+
elif isinstance(t_search, np.ndarray):
|
|
278
|
+
if t_search[0].tzinfo is not None:
|
|
279
|
+
t_search = np.vectorize(lambda x:x.astimezone(dt.timezone.utc))(t_search)
|
|
280
|
+
else:
|
|
281
|
+
t_search = np.vectorize(lambda x:x.replace(tzinfo=dt.timezone.utc))(t_search)
|
|
282
|
+
|
|
283
|
+
else:
|
|
284
|
+
raise TypeError('t_search is of an unrecognised type,'
|
|
285
|
+
' should be a datetime object or ndarray')
|
|
286
|
+
|
|
287
|
+
return np.logical_and(t_search>np.asarray(self.start), t_search<np.asarray(self.end))
|
|
288
|
+
|
|
289
|
+
def getFractionalIndices(self, t_search:dt.datetime|np.ndarray[tuple[int],
|
|
290
|
+
np.dtype[np.datetime64]])\
|
|
291
|
+
-> np.ndarray[tuple[int],np.dtype[np.float64]]:
|
|
292
|
+
"""Find the fractional indices of timespan at wich t_search should be inserted.
|
|
293
|
+
|
|
294
|
+
Find the indices in the original timespan at which each value of t_search should be
|
|
295
|
+
inserted to maintain the sorted order.
|
|
296
|
+
The integer part of the index indicates the value immediately prior to the value in
|
|
297
|
+
t_search, while the fractional part represents the point between the two adjacent indices
|
|
298
|
+
in the timespan at which the t_search value falls.
|
|
299
|
+
For example (using integers rather than datetime objects:
|
|
300
|
+
timespan = [0, 1, 5, 6, 10]
|
|
301
|
+
t_search = [0.5, 2, 3, 8]
|
|
302
|
+
timespan.getFractionalIndices(t_search) = [0.5, 1.25, 1.5, 3.5]
|
|
303
|
+
All values of t_search must be within the timespan, otherwise the output is undefined.
|
|
304
|
+
|
|
305
|
+
Args:
|
|
306
|
+
t_search: times to locate within timespan
|
|
307
|
+
If timezone naive, assumed to be in UTC
|
|
308
|
+
If timezone aware, will be converted to UTCtime to find
|
|
309
|
+
|
|
310
|
+
Returns:
|
|
311
|
+
ndarray of fractional indices
|
|
312
|
+
"""
|
|
313
|
+
if isinstance(t_search, dt.datetime):
|
|
314
|
+
if t_search.tzinfo is not None:
|
|
315
|
+
t_search = t_search.astimezone(dt.timezone.utc)
|
|
316
|
+
else:
|
|
317
|
+
t_search = t_search.replace(tzinfo=dt.timezone.utc)
|
|
318
|
+
t_search = np.asarray(t_search)
|
|
319
|
+
elif isinstance(t_search, np.ndarray):
|
|
320
|
+
if t_search[0].tzinfo is not None:
|
|
321
|
+
t_search = np.vectorize(lambda x:x.astimezone(dt.timezone.utc))(t_search)
|
|
322
|
+
else:
|
|
323
|
+
t_search = np.vectorize(lambda x:x.replace(tzinfo=dt.timezone.utc))(t_search)
|
|
324
|
+
else:
|
|
325
|
+
raise TypeError('t_search is of an unrecognised type, '
|
|
326
|
+
'should be a datetime object or ndarray')
|
|
327
|
+
|
|
328
|
+
post_idxs = np.searchsorted(self._timearr, t_search) # type: ignore [arg-type]
|
|
329
|
+
pre_idxs = post_idxs-1
|
|
330
|
+
return (t_search - self._timearr[pre_idxs])\
|
|
331
|
+
/(self._timearr[post_idxs]-self._timearr[pre_idxs]) + pre_idxs
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _parseTimeperiod(self, t0:dt.datetime, timeperiod:str) -> dt.datetime:
|
|
335
|
+
last_idx = 0
|
|
336
|
+
for idx, letter in enumerate(timeperiod):
|
|
337
|
+
last_idx = idx
|
|
338
|
+
if not (letter.isdigit() or letter == '.'):
|
|
339
|
+
break
|
|
340
|
+
|
|
341
|
+
val = float(timeperiod[0:last_idx])
|
|
342
|
+
unit = timeperiod[last_idx:]
|
|
343
|
+
values = np.zeros(6)
|
|
344
|
+
if unit == 'd':
|
|
345
|
+
values[5] = val
|
|
346
|
+
elif unit == 'H':
|
|
347
|
+
values[4] = val
|
|
348
|
+
elif unit == 'M':
|
|
349
|
+
values[3] = val
|
|
350
|
+
elif unit == 'S':
|
|
351
|
+
values[2] = val
|
|
352
|
+
elif unit == 'mS':
|
|
353
|
+
values[0] = 1000 * val
|
|
354
|
+
elif unit == 'uS':
|
|
355
|
+
values[0] = val
|
|
356
|
+
else:
|
|
357
|
+
logger.error("Invalid timeperiod unit: Valid units are (y)ears, (m)onths, (W)eeks,"
|
|
358
|
+
" (d)ays, (H)ours, (M)inutes, (S)econds, (mS) milliseconds,"
|
|
359
|
+
" (uS) microseconds")
|
|
360
|
+
raise ValueError(f"Invalid timeperiod unit:{unit}")
|
|
361
|
+
|
|
362
|
+
return t0 + relativedelta(days=values[5], hours=values[4], minutes=values[3],
|
|
363
|
+
seconds=values[2], microseconds=values[0])
|
|
364
|
+
|
|
365
|
+
def _parseTimestep(self, timestep:str) -> dt.timedelta:
|
|
366
|
+
last_idx = 0
|
|
367
|
+
for idx, letter in enumerate(timestep):
|
|
368
|
+
last_idx = idx
|
|
369
|
+
if not (letter.isdigit() or letter == '.'):
|
|
370
|
+
break
|
|
371
|
+
|
|
372
|
+
val = float(timestep[0:last_idx])
|
|
373
|
+
unit = timestep[last_idx:]
|
|
374
|
+
values = np.zeros(6)
|
|
375
|
+
if unit == 'd':
|
|
376
|
+
values[5] = val
|
|
377
|
+
elif unit == 'H':
|
|
378
|
+
values[4] = val
|
|
379
|
+
elif unit == 'M':
|
|
380
|
+
values[3] = val
|
|
381
|
+
elif unit == 'S':
|
|
382
|
+
values[2] = val
|
|
383
|
+
elif unit == 'mS':
|
|
384
|
+
values[1] = val
|
|
385
|
+
elif unit == 'uS':
|
|
386
|
+
values[0] = val
|
|
387
|
+
else:
|
|
388
|
+
logger.error("Invalid timestep unit: Valid units are (d)ays, (H)ours," \
|
|
389
|
+
" (M)inutes, (S)econds, (mS) milliseconds, (uS) microseconds")
|
|
390
|
+
raise ValueError(f"Invalid timestep unit:{unit}")
|
|
391
|
+
|
|
392
|
+
return dt.timedelta(days=values[5], seconds=values[2],
|
|
393
|
+
microseconds=values[0], milliseconds=values[1],
|
|
394
|
+
minutes=values[3], hours=values[4])
|
|
395
|
+
|
|
396
|
+
def __len__(self) -> int:
|
|
397
|
+
"""Returns the length of the TimeSpan."""
|
|
398
|
+
return len(self._timearr)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def cherryPickFromIndices(self, idxs:int|tuple|slice):
|
|
402
|
+
"""Adjust TimeSpan to only contain the indices specified by idxs.
|
|
403
|
+
|
|
404
|
+
Args:
|
|
405
|
+
idxs: numpy style indexing of TimeSpan
|
|
406
|
+
"""
|
|
407
|
+
self._timearr = self._timearr[idxs]
|
|
408
|
+
self.start = self._timearr[0]
|
|
409
|
+
self.end = self._timearr[-1]
|
|
410
|
+
self.time_step = None
|
|
411
|
+
self.time_period = self.end - self.start
|
|
412
|
+
|
|
413
|
+
@classmethod
|
|
414
|
+
def fromDatetime(cls, dt_arr:np.ndarray[tuple[int], np.dtype[np.datetime64]],
|
|
415
|
+
timezone:dt.timezone=dt.timezone.utc) -> 'TimeSpan':
|
|
416
|
+
"""Create a TimeSpan from an array of datetime objects.
|
|
417
|
+
|
|
418
|
+
Args:
|
|
419
|
+
dt_arr: 1D array of datetime objects
|
|
420
|
+
timezone: timezone to apply to each element of datetime array.
|
|
421
|
+
Default: dt.timezone.utc
|
|
422
|
+
"""
|
|
423
|
+
for ii in range(len(dt_arr)):
|
|
424
|
+
dt_arr[ii] = dt_arr[ii].replace(tzinfo=timezone)
|
|
425
|
+
start = dt_arr[0]
|
|
426
|
+
end = dt_arr[-1]
|
|
427
|
+
tperiod = (end-start).total_seconds()
|
|
428
|
+
tstep = tperiod/len(dt_arr)
|
|
429
|
+
t = cls(start,f'{tstep}S',f'{tperiod}S')
|
|
430
|
+
t._timearr = dt_arr.copy()
|
|
431
|
+
t.start = start
|
|
432
|
+
t.end = end
|
|
433
|
+
t.time_step = None
|
|
434
|
+
t.time_period = t.end-t.start
|
|
435
|
+
|
|
436
|
+
return t
|
spherapy/updater.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Functions to update TLE of a satcat ID, and fetch where the file is stored."""
|
|
2
|
+
import datetime as dt
|
|
3
|
+
import pathlib
|
|
4
|
+
|
|
5
|
+
import spherapy
|
|
6
|
+
from spherapy.util import epoch_u, spacetrack
|
|
7
|
+
import spherapy.util.spacetrack as celestrak
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def updateTLEs(sat_id_list: list[int]) -> list[int]:
|
|
11
|
+
"""Fetch most recent TLE for provided list of satcat IDs.
|
|
12
|
+
|
|
13
|
+
Fetch most recent TLE for provided list of satcat IDs,
|
|
14
|
+
If credentials are stored, use Spacetrack as TLE source, this will fetch all historical
|
|
15
|
+
TLEs for that satcat ID (from most recent stored TLE up to current)
|
|
16
|
+
If no credentials are stored, use Celestrak as TLE source, this will fetch only the most
|
|
17
|
+
recent TLE
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
sat_id_list: list of satcat ids to fetch
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
list of satcat ids successfully fetched
|
|
24
|
+
"""
|
|
25
|
+
if spacetrack.doCredentialsExist():
|
|
26
|
+
modified_list = spacetrack.updateTLEs(sat_id_list)
|
|
27
|
+
else:
|
|
28
|
+
modified_list = celestrak.updateTLEs(sat_id_list)
|
|
29
|
+
|
|
30
|
+
return modified_list
|
|
31
|
+
|
|
32
|
+
def getTLEFilePaths(sat_id_list:list[int], use_packaged:bool=False) -> list[pathlib.Path]:
|
|
33
|
+
"""Fetch list of paths to TLE files.
|
|
34
|
+
|
|
35
|
+
Fetch paths to the file storing the list of TLEs for each provided satcat ID
|
|
36
|
+
If credentials are stored, return path containing all historical TLEs (fetched from Spacetrack)
|
|
37
|
+
If no credentials are stored, return path to .temptle containing only most recent TLE
|
|
38
|
+
(from Celestrak)
|
|
39
|
+
You can force the use of TLE data packaged with spherapy by setting use_packaged=True
|
|
40
|
+
This data will be out of date, and should only be used in examples.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
sat_id_list: list of satcat ids to find paths
|
|
44
|
+
use_packaged: use the default TLEs packaged with spherapy, these will be out of date.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
list of paths
|
|
48
|
+
"""
|
|
49
|
+
if use_packaged and spherapy.packaged_TLEs is None:
|
|
50
|
+
raise ValueError('There are no TLEs packaged with spherapy')
|
|
51
|
+
if use_packaged and spherapy.packaged_TLEs is not None:
|
|
52
|
+
try:
|
|
53
|
+
path_list = []
|
|
54
|
+
attempted_sat_id = 0
|
|
55
|
+
for sat_id in sat_id_list:
|
|
56
|
+
attempted_sat_id = sat_id
|
|
57
|
+
path_list.append(spherapy.packaged_TLEs[sat_id])
|
|
58
|
+
except KeyError as e:
|
|
59
|
+
raise KeyError(f'TLE for {attempted_sat_id} is not packaged with spherapy.') from e
|
|
60
|
+
else:
|
|
61
|
+
return path_list
|
|
62
|
+
elif spacetrack.doCredentialsExist():
|
|
63
|
+
return [ spacetrack.getTLEFilePath(sat_id) for sat_id in sat_id_list ]
|
|
64
|
+
return [ celestrak.getTLEFilePath(sat_id) for sat_id in sat_id_list ]
|
|
65
|
+
|
|
66
|
+
def getStoredEpochLimits(sat_id_list:list[int], use_packaged:bool=False) \
|
|
67
|
+
-> dict[int, None|tuple[dt.datetime, dt.datetime|None]]:
|
|
68
|
+
"""Returns limiting epochs for the stored TLEs for each sat in sat_id_list.
|
|
69
|
+
|
|
70
|
+
[description]
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
sat_id_list (list[int]): [description]
|
|
74
|
+
use_packaged (bool): [description] (default: `False`)
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
list[tuple[dt.datetime, dt.datetime]]: [description]
|
|
78
|
+
"""
|
|
79
|
+
terminating_epochs = {}
|
|
80
|
+
if use_packaged and spherapy.packaged_TLEs is not None:
|
|
81
|
+
for sat_id in sat_id_list:
|
|
82
|
+
packaged_path = spherapy.packaged_TLEs[sat_id]
|
|
83
|
+
terminating_epochs[sat_id] = epoch_u.getStoredEpochs(packaged_path)
|
|
84
|
+
elif spacetrack.doCredentialsExist():
|
|
85
|
+
for sat_id in sat_id_list:
|
|
86
|
+
terminating_epochs[sat_id] = spacetrack.getStoredEpochs(sat_id)
|
|
87
|
+
else:
|
|
88
|
+
for sat_id in sat_id_list:
|
|
89
|
+
terminating_epochs[sat_id] = celestrak.getStoredEpochs(sat_id)
|
|
90
|
+
|
|
91
|
+
return terminating_epochs
|
|
File without changes
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Functions to fetch TLEs from Celestrak.
|
|
2
|
+
|
|
3
|
+
Attributes:
|
|
4
|
+
MAX_RETRIES: number of times to try and reach Celestrak.
|
|
5
|
+
TIMEOUT: timeout for connection and request servicing by Celestrak.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import datetime as dt
|
|
9
|
+
import logging
|
|
10
|
+
import pathlib
|
|
11
|
+
|
|
12
|
+
import requests
|
|
13
|
+
|
|
14
|
+
import spherapy
|
|
15
|
+
from spherapy.util import epoch_u
|
|
16
|
+
|
|
17
|
+
MAX_RETRIES=3
|
|
18
|
+
TIMEOUT=10
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
def updateTLEs(sat_id_list:list[int]) -> list[int]:
|
|
23
|
+
"""Fetch most recent TLE for satcat IDs from celestrak.
|
|
24
|
+
|
|
25
|
+
Fetch most recent TLE for provided list of satcat IDs, and store in file.
|
|
26
|
+
Will try MAX_RETRIES before raising a ValueError
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
sat_id_list: list of satcat ids to fetch
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
list: list of satcat ids successfully fetched
|
|
33
|
+
|
|
34
|
+
Raises:
|
|
35
|
+
TimeoutError
|
|
36
|
+
"""
|
|
37
|
+
logger.info("Using CELESTRAK to update TLEs")
|
|
38
|
+
modified_list = []
|
|
39
|
+
for sat_id in sat_id_list:
|
|
40
|
+
url = f'https://celestrak.org/NORAD/elements/gp.php?CATNR={sat_id}'
|
|
41
|
+
retry_num = 0
|
|
42
|
+
fetch_successful = False
|
|
43
|
+
while retry_num < MAX_RETRIES:
|
|
44
|
+
retry_num += 1
|
|
45
|
+
r = requests.get(url, timeout=TIMEOUT)
|
|
46
|
+
if r.status_code == requests.codes.success:
|
|
47
|
+
fetch_successful = True
|
|
48
|
+
tle_file = getTLEFilePath(sat_id)
|
|
49
|
+
dat_list = r.text.split('\r\n')
|
|
50
|
+
with tle_file.open('w') as fp:
|
|
51
|
+
fp.write(f'0 {dat_list[0].rstrip()}\n')
|
|
52
|
+
fp.write(f'{dat_list[1].rstrip()}\n')
|
|
53
|
+
fp.write(f'{dat_list[2].rstrip()}')
|
|
54
|
+
modified_list.append(sat_id)
|
|
55
|
+
break
|
|
56
|
+
|
|
57
|
+
if retry_num == MAX_RETRIES and fetch_successful:
|
|
58
|
+
logger.error('Could not fetch celestrak information for sat_id: %s', sat_id)
|
|
59
|
+
raise TimeoutError(f'Could not fetch celestrak information for sat_id: {sat_id}')
|
|
60
|
+
|
|
61
|
+
return modified_list
|
|
62
|
+
|
|
63
|
+
def getTLEFilePath(sat_id:int) -> pathlib.Path:
|
|
64
|
+
"""Gives path to file where celestrak TLE is stored.
|
|
65
|
+
|
|
66
|
+
Celestrak TLEs are stored in {satcadID}.temptle
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
sat_id: satcat ID
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
path to file
|
|
73
|
+
"""
|
|
74
|
+
return spherapy.tle_dir.joinpath(f'{sat_id}.temptle')
|
|
75
|
+
|
|
76
|
+
def getStoredEpochs(sat_id:int) -> None|tuple[dt.datetime, dt.datetime|None]:
|
|
77
|
+
"""Return the start and end epoch for {sat_id}.temptle .
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
sat_id: satcat id to check
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
(first epoch datetime, last epoch datetime)
|
|
84
|
+
None if no spacetrack tle stored for sat_id
|
|
85
|
+
"""
|
|
86
|
+
tle_path = getTLEFilePath(sat_id)
|
|
87
|
+
return epoch_u.getStoredEpochs(tle_path)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Namespace of useful orbital constants."""
|
|
2
|
+
|
|
3
|
+
import astropy.constants as astroconst
|
|
4
|
+
|
|
5
|
+
# ######### BODY CONSTANTS ##########
|
|
6
|
+
M_EARTH = astroconst.M_earth.value # Mass of earth (Kg)
|
|
7
|
+
M_SUN = astroconst.M_sun.value # Mass of the sun (Kg)
|
|
8
|
+
M_MOON = 7.342e22 # Mass of the sun (Kg)
|
|
9
|
+
M_MARS = 6.4171e23 # Mass of the sun (Kg)
|
|
10
|
+
R_SUN = astroconst.R_sun.value / 1000 # Radius of sun (695700 km)
|
|
11
|
+
R_EARTH = astroconst.R_earth.value / 1000 # Radius of Earth (6378.1 km)
|
|
12
|
+
R_MOON = 1738 # Radius of the Moon (km)
|
|
13
|
+
R_MARS = 3390 # Radius of Mars (km)
|
|
14
|
+
SUN_MIN_ALT = 0 # Minimum orbital altitude of the sun (km)
|
|
15
|
+
EARTH_MIN_ALT = 350 # Minimum orbital altitude of the earth (km)
|
|
16
|
+
MOON_MIN_ALT = 10 # Minimum orbital altitude of the moon (km)
|
|
17
|
+
MARS_MIN_ALT = 200 # Minimum orbital altitude of mars (km)
|
|
18
|
+
G = astroconst.G.value # Gravitational constant (SI)
|
|
19
|
+
GM_EARTH = astroconst.GM_earth.value # Earth standard gravitational parameter (SI)
|
|
20
|
+
GM_SUN = astroconst.GM_sun.value # Sun standard gravitational parameter (SI)
|
|
21
|
+
GM_MOON = 4.9048695e12 # Moon standard gravitational parameter (SI)
|
|
22
|
+
GM_MARS = 4.282837e13 # Mars standard gravitational parameter (SI)
|
|
23
|
+
AU = astroconst.au.value / 1000 # Earth-Sun avg distance (1.49597871e8 km)
|
|
24
|
+
J2 = 1082.6267e-6 # Earth J2 perturbations (SI)
|
|
25
|
+
W_EARTH = 7.29211510e-5 # Rotation rate of Earth rads/sec
|
|
26
|
+
# ######### TEMP CONSTANTS ##########
|
|
27
|
+
SB_SIGMA = astroconst.sigma_sb.value # the Stefan-Boltzmann constant (5.67037442e-8 SI)
|
|
28
|
+
T_EARTH = 250 # Temperature of the Earth (K)
|
|
29
|
+
T_SUN = 5780 # Temperature of the Sun (K)
|
|
30
|
+
T_SPACE = 4 # Temperature of space (K)
|
|
31
|
+
SOL_CONST = 1391 # Max. flux of the sun at the earth (W/m^2)
|
|
32
|
+
L_SUN = 3.828e26 # Solar luminosity in watts
|
|
33
|
+
|
|
34
|
+
# ######### TIME CONSTANTS ##########
|
|
35
|
+
|
|
36
|
+
DAY_IN_MINS = 24 * 60
|
|
37
|
+
HALF_HOUR = 30
|
|
38
|
+
SECS_PER_DAY = 24 * 60 * 60
|