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/orbit.py ADDED
@@ -0,0 +1,874 @@
1
+ """Class for orbital data.
2
+
3
+ This module provides:
4
+ - OrbitAttrDict: A TypedDict typing the instance attributes of an Orbit
5
+ - Orbit: A class of timestamped orbital data
6
+ """
7
+ import datetime as dt
8
+ import logging
9
+ import pathlib
10
+
11
+ import types
12
+ import typing
13
+ from typing import Any, TypedDict, cast
14
+
15
+ from astropy import units as astropy_units
16
+ from astropy.time import Time as astropyTime
17
+ from hapsira.bodies import Earth, Mars, Moon, Sun
18
+ from hapsira.ephem import Ephem
19
+ from hapsira.twobody import Orbit as hapsiraOrbit
20
+ import numpy as np
21
+ from sgp4.api import WGS72, Satrec
22
+
23
+ # Don't need to import all of skyfield just for EarthSatellite loading.
24
+ # TODO: figure out the lightest import to use
25
+ from skyfield.api import EarthSatellite, load, wgs84
26
+ from skyfield.framelib import itrs
27
+
28
+ from spherapy.timespan import TimeSpan
29
+ from spherapy.util import epoch_u, exceptions
30
+ import spherapy.util.constants as consts
31
+ import spherapy.util.orbital_u as orbit_u
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+
36
+ class OrbitAttrDict(TypedDict):
37
+ """A TypedDict providing type annotations for the Orbit class.
38
+
39
+ Attributes:
40
+ name:
41
+ satcat_id:
42
+ gen_type:
43
+ timespan:
44
+ TLE_epochs:
45
+ pos:
46
+ pos_ecef:
47
+ vel_ecef:
48
+ vel:
49
+ lat:
50
+ lon:
51
+ sun_pos:
52
+ moon_pos:
53
+ alt:
54
+ eclipse:
55
+ central_body:
56
+ period:
57
+ period_steps:
58
+ semi_major:
59
+ ecc:
60
+ inc:
61
+ raan:
62
+ argp:
63
+ """
64
+ name: None|str
65
+ satcat_id: None|int
66
+ gen_type: None|str
67
+ timespan: None|TimeSpan
68
+ TLE_epochs: None|np.ndarray[tuple[int], np.dtype[np.datetime64]]
69
+ pos: None|np.ndarray[tuple[int,int], np.dtype[np.float64]]
70
+ pos_ecef: None|np.ndarray[tuple[int,int], np.dtype[np.float64]]
71
+ vel_ecef: None|np.ndarray[tuple[int,int], np.dtype[np.float64]]
72
+ vel: None|np.ndarray[tuple[int,int], np.dtype[np.float64]]
73
+ lat: None|np.ndarray[tuple[int], np.dtype[np.float64]]
74
+ lon: None|np.ndarray[tuple[int], np.dtype[np.float64]]
75
+ sun_pos: None|np.ndarray[tuple[int,int], np.dtype[np.float64]]
76
+ moon_pos: None|np.ndarray[tuple[int,int], np.dtype[np.float64]]
77
+ alt: None|np.ndarray[tuple[int], np.dtype[np.float64]]
78
+ eclipse: None|np.ndarray[tuple[int], np.dtype[np.bool_]]
79
+ central_body: None|str
80
+ period: None|float
81
+ period_steps: None|int
82
+ semi_major: None|np.ndarray[tuple[int], np.dtype[np.float64]]
83
+ ecc: None|np.ndarray[tuple[int], np.dtype[np.float64]]
84
+ inc: None|np.ndarray[tuple[int], np.dtype[np.float64]]
85
+ raan: None|np.ndarray[tuple[int], np.dtype[np.float64]]
86
+ argp: None|np.ndarray[tuple[int], np.dtype[np.float64]]
87
+
88
+ def _createEmptyOrbitAttrDict() -> OrbitAttrDict:
89
+ return OrbitAttrDict({
90
+ 'name': None,
91
+ 'satcat_id':None,
92
+ 'gen_type':None,
93
+ 'timespan':None,
94
+ 'TLE_epochs':None,
95
+ 'pos':None,
96
+ 'pos_ecef':None,
97
+ 'vel_ecef':None,
98
+ 'vel':None,
99
+ 'lat':None,
100
+ 'lon':None,
101
+ 'sun_pos':None,
102
+ 'moon_pos':None,
103
+ 'alt':None,
104
+ 'eclipse':None,
105
+ 'central_body':None,
106
+ 'period':None,
107
+ 'period_steps':None,
108
+ 'semi_major':None,
109
+ 'ecc':None,
110
+ 'inc':None,
111
+ 'raan':None,
112
+ 'argp':None,
113
+ })
114
+
115
+ def _validateOrbitAttrDict(obj:OrbitAttrDict, typ: type) -> bool:
116
+ """Validates all fields of a TypedDict have been instantiated, and are of correct type.
117
+
118
+ Raises:
119
+ KeyError: If a field of the TypedDict is missing
120
+ TypeError: If a field of the TYpedDict is incorrect
121
+ """
122
+ for attr_name, spec_attr_type in typ.__annotations__.items():
123
+ try:
124
+ attr_value = obj.get(attr_name, None)
125
+ except KeyError as e:
126
+ # Check for missing keys
127
+ raise KeyError('Typed Dict is missing key: %s', attr_name) from e
128
+ if type(spec_attr_type) is types.UnionType:
129
+ # check union of types
130
+ good_type = False
131
+ for sub_type in typing.get_args(spec_attr_type):
132
+ # choose validator
133
+ if sub_type in (None, types.NoneType):
134
+ validator = _NoneValidator
135
+ else:
136
+ validator = _genericValidator
137
+ if validator(attr_value, sub_type):
138
+ good_type = True
139
+ break
140
+ if not good_type:
141
+ raise TypeError(f'{attr_name} is type {type(attr_value)}, '
142
+ f'should be {spec_attr_type}')
143
+ elif not _genericValidator(attr_value, spec_attr_type):
144
+ # check all other types
145
+ raise TypeError(f'{attr_name} is type {type(attr_value)}, should be {spec_attr_type}')
146
+ return True
147
+
148
+ def _NoneValidator(obj:object, check_type:type) -> bool: #noqa: ARG001
149
+ return type(obj) is types.NoneType
150
+
151
+ def _genericValidator(obj:object, check_type:type|types.GenericAlias) -> bool:
152
+ if type(check_type) is types.GenericAlias:
153
+ if type(obj) is check_type.__origin__:
154
+ return True
155
+ elif type(obj) is check_type:
156
+ return True
157
+ return False
158
+
159
+ class Orbit:
160
+ """Timestamped orbital data for a satellite, coordinate system depending on the central body.
161
+
162
+ Attributes:
163
+ timespan: Timespan over which orbit is to be simulated
164
+ name: Name of the satellite
165
+ satcat_id: NORAD satellite catelogue ID (if generated from a TLE)
166
+ gen_type: How the orbit was generated
167
+ TLE_epochs: Nx1 numpy array of TLE epoch used for propagation at each timestamp
168
+ units: TLE epoch
169
+ pos: Nx3 numpy array of cartesian coordinates of the position of the satellite
170
+ at each timestamp
171
+ units: km
172
+ frame: ECI
173
+ vel: Nx3 numpy array of cartesian velocities of the satellite at each timestamp
174
+ units: m/s
175
+ frame: ECI
176
+ pos_ecef: Nx3 numpy array of cartesian coordinates of the position of the satellite
177
+ at each timestamp
178
+ units: km
179
+ frame: ECEF
180
+ vel_ecef: Nx3 numpy array of cartesian velocities of the satellite at each timestamp
181
+ units: m/s
182
+ frame: ECEF
183
+ lat: Nx1 numpy array of central body latitudes of the satellite at each timestamp
184
+ units: degrees
185
+ lon: Nx1 numpy array of central body longitudes of the satellite at each timestamp
186
+ units: degrees
187
+ sun_pos: Nx3 numpy array of cartesian coordinates of the position of the Sun
188
+ at each timestamp
189
+ units: km
190
+ frame: ECI
191
+ moon_pos: Nx3 numpy array of cartesian coordinates of the position of the Moon
192
+ at each timestamp
193
+ units: km
194
+ frame: ECI
195
+ alt: Nx1 numpy array of altitudes above central body at each timestamp
196
+ units: km
197
+ eclipse: Nx1 numpy array of flag indicating if satellite is eclipsed at each timestamp
198
+ units: km
199
+ central_body: body the satellite is orbiting
200
+ period: orbital period in secs
201
+ period_steps: number of TimeSpan timestamps required to complete an orbit
202
+ semi_major: Nx1 numpy array of orbit semi-major axis calculated at that timestep
203
+ units: km
204
+ will be constant if no orbital maneauvers
205
+ ecc: Nx1 numpy array of orbit eccentricity calculated at that timestep
206
+ units: unitless
207
+ will be constant if no orbital maneauvers
208
+ inc: Nx1 numpy array of orbit inclination calculated at that timestep
209
+ units: degree
210
+ will be constant if no orbital maneauvers
211
+ raan: Nx1 numpy array of orbit RAAN calculated at that timestep
212
+ units: degree
213
+ will be constant if no orbital maneauvers
214
+ argp: Nx1 numpy array of orbit Arg Perigee calculated at that timestep
215
+ units: degree
216
+ will be constant if no orbital maneauvers
217
+ """
218
+
219
+ def __init__(self, data:OrbitAttrDict, calc_astrobodies:bool=False):
220
+ """The constructor should never be called directly.
221
+
222
+ Use one of:
223
+ Orbit.fromTLE()
224
+ Orbit.fromListOfPositions()
225
+ Orbit.fromPropagatedOrbitalParam()
226
+ Orbit.fromAnalyticalOrbitalParam()
227
+ """
228
+ # Should always be called from a class method, spit error if not.
229
+ if not _validateOrbitAttrDict(data, OrbitAttrDict):
230
+ logger.error("Orbit() should not be called directly, "
231
+ "use one of the fromXX constructors.")
232
+ raise ValueError("Orbit() should not be called directly, "
233
+ "use one of the fromXX constructors")
234
+
235
+ self.name = data['name']
236
+ self.satcat_id = data['satcat_id']
237
+ self.gen_type = data['gen_type']
238
+
239
+ #time data
240
+ self.timespan = data['timespan']
241
+ self.TLE_epochs = data['TLE_epochs']
242
+
243
+ #pos data
244
+ self.pos = data['pos']
245
+ self.pos_ecef = data['pos_ecef']
246
+ self.vel_ecef = data['vel_ecef']
247
+ self.vel = data['vel']
248
+ self.lat = data['lat']
249
+ self.lon = data['lon']
250
+ self.alt = data['alt']
251
+
252
+ # These should be None from the constructor
253
+ self.sun_pos = data['sun_pos']
254
+ self.moon_pos = data['moon_pos']
255
+ self.eclipse = data['eclipse']
256
+
257
+ #orbit_data
258
+ self.central_body = data['central_body']
259
+ self.period = data['period']
260
+ self.period_steps = data['period_steps']
261
+ self.semi_major = data['semi_major']
262
+ self.ecc = data['ecc']
263
+ self.inc = data['inc']
264
+ self.raan = data['raan']
265
+ self.argp = data['argp']
266
+
267
+ # Check required fields are not empty
268
+ if self.timespan is None:
269
+ raise AttributeError('Error in creating Orbit object, no Timespan assigned')
270
+ if self.pos is None:
271
+ raise AttributeError('Error in creating Orbit object, no position data assigned')
272
+ if self.vel is None:
273
+ raise AttributeError('Error in creating Orbit object, no velocity data assigned')
274
+ if self.name is None:
275
+ raise AttributeError('Error in creating Orbit object, no name assigned')
276
+ if self.gen_type is None:
277
+ raise AttributeError('Error in creating Orbit object, no generator type assigned')
278
+
279
+ if calc_astrobodies:
280
+ logger.info('Creating ephemeris for Sun using timespan')
281
+ # Timescale for sun position calculation should use TDB, not UTC
282
+ # The resultant difference is likely very small
283
+ ephem_sun = Ephem.from_body(Sun,
284
+ astropyTime(self.timespan.asAstropy(scale='tdb')),
285
+ attractor=Earth)
286
+ sun_pos = ephem_sun.rv()[0]
287
+ self.sun_pos = np.asarray(sun_pos.to(astropy_units.km))
288
+
289
+ logger.info('Creating ephemeris for Moon using timespan')
290
+ ephem_moon = Ephem.from_body(Moon,
291
+ astropyTime(self.timespan.asAstropy(scale='tdb')),
292
+ attractor=Earth)
293
+ moon_pos = ephem_moon.rv()[0]
294
+ self.moon_pos = np.asarray(moon_pos.to(astropy_units.km))
295
+ self.eclipse = self._calcEclipse(self.pos, self.sun_pos)
296
+
297
+ #pos_list
298
+ @classmethod
299
+ def fromListOfPositions(cls, timespan:TimeSpan,
300
+ positions:np.ndarray[tuple[int, int], np.dtype[np.float64]],
301
+ astrobodies:bool=False) -> 'Orbit':
302
+ """Create an orbit from a list of positions.
303
+
304
+ Creat an obit by explicitly specifying the position of the
305
+ satellite at each point in time. Useful for simplified test cases; but
306
+ may lead to unphysical orbits.
307
+
308
+ Args:
309
+ timespan: Timespan over which orbit is to be simulated
310
+ positions: Nx3 numpy array of cartesian coordinates of the position of the satellite
311
+ at each timestamp
312
+ units: km
313
+ frame: ECI
314
+ astrobodies: [Optional] Flag to calculate Sun and Moon positions at timestamps
315
+ Default is False
316
+
317
+ Returns:
318
+ satplot.Orbit
319
+ """
320
+ attr_dct = _createEmptyOrbitAttrDict()
321
+ if len(positions) != len(timespan):
322
+ raise ValueError(f"Number of supplied positions does not match timespan length: "
323
+ f"{len(positions)} =/= {len(timespan)}")
324
+
325
+ attr_dct['timespan'] = timespan
326
+ attr_dct['pos'] = positions
327
+ # Assume linear motion between each position at each timestep;
328
+ # Then assume it stops at the last timestep.
329
+ vel = positions[1:] - positions[:-1]
330
+ attr_dct['vel'] = np.concatenate((vel, np.array([[0, 0, 0]])))
331
+ attr_dct['name'] = 'Sat from position list'
332
+ attr_dct['gen_type'] = 'position list'
333
+
334
+ return cls(attr_dct, calc_astrobodies=astrobodies)
335
+
336
+ #TLE
337
+ @classmethod
338
+ def fromTLE(cls, timespan:TimeSpan,
339
+ tle_path:pathlib.Path,
340
+ astrobodies:bool=True,
341
+ unsafe:bool=False) -> 'Orbit':
342
+ """Create an orbit from an existing TLE or a list of historical TLEs.
343
+
344
+ Args:
345
+ timespan : TimeSpan over which orbit is to be simulated
346
+ tle_path : path to file containing TLEs for a satellite
347
+ astrobodies: [Optional] Flag to calculate Sun and Moon positions at timestamps
348
+ Default is False
349
+ unsafe: [Optional] Flag to ignore TLE usage more than 14 days either side of timestamps
350
+ Optional
351
+ Default is False
352
+
353
+ Returns:
354
+ -------
355
+ satplot.Orbit
356
+ """
357
+ # load all TLEs and create skyfield earth sats
358
+ skyfld_earth_sats = load.tle_file(f'{tle_path}')
359
+
360
+ # generate list of epochs for each TLE
361
+ epochs = np.asarray([a.epoch.utc_datetime() for a in skyfld_earth_sats])
362
+
363
+ # ensure no duplicate skyfld sats
364
+ _, unq_idxs = np.unique(epochs, return_index=True)
365
+ unq_skyfld_earth_sats = list(np.asarray(skyfld_earth_sats)[unq_idxs])
366
+
367
+ skyfld_earth_sats = unq_skyfld_earth_sats
368
+ tle_epoch_dates = [sat.epoch.utc_datetime() for sat in skyfld_earth_sats]
369
+
370
+ # check timespan is valid for provided TLEs
371
+ if timespan.start < tle_epoch_dates[0] - dt.timedelta(days=14):
372
+ logger.error("Timespan begins before provided TLEs (+14 days)")
373
+ if not unsafe:
374
+ raise exceptions.OutOfRangeError("Timespan begins before provided TLEs (+14 days)")
375
+ elif timespan.start > tle_epoch_dates[-1] + dt.timedelta(days=14):
376
+ logger.error("Timespan begins after provided TLEs (+14 days)")
377
+ if not unsafe:
378
+ raise exceptions.OutOfRangeError("Timespan begins after provided TLEs (+14 days)")
379
+ elif timespan.end > tle_epoch_dates[-1] + dt.timedelta(days=14):
380
+ logger.error("Timespan ends after provided TLEs (+14 days)")
381
+ if not unsafe:
382
+ raise exceptions.OutOfRangeError("Timespan ends after provided TLEs (+14 days)")
383
+
384
+ attr_dct = _createEmptyOrbitAttrDict()
385
+
386
+ _timespan_arr = timespan.asDatetime()
387
+ _timespan_arr = cast("np.ndarray[tuple[int], np.dtype[np.datetime64]]", _timespan_arr)
388
+ closest_tle_epochs = epoch_u.findClosestDatetimeIndices(_timespan_arr,
389
+ np.asarray(tle_epoch_dates))
390
+
391
+ d = np.hstack((1, np.diff(closest_tle_epochs)))
392
+
393
+ # timestep idx where TLE epoch changes, append len(timespan)
394
+ # to not require separate loop iteration to handle last element
395
+ timespan_epoch_trans_idxs = np.hstack((np.where(d!=0)[0],len(timespan)))
396
+ # tle_dates idx where TLE epoch changes in timespan
397
+ timespan_epoch_trans_tle_idxs = closest_tle_epochs[np.where(d!=0)[0]]
398
+
399
+ tle_epoch_idxs = []
400
+ sub_timespans = []
401
+ for ii, trans_start_idx in enumerate(timespan_epoch_trans_idxs[:-1]):
402
+ tle_epoch_idxs.append(timespan_epoch_trans_tle_idxs[ii])
403
+ sub_timespans.append(timespan[trans_start_idx,timespan_epoch_trans_idxs[ii+1]])
404
+
405
+ skyfld_ts = load.timescale(builtin=True)
406
+
407
+ # Calculate data for first run
408
+ sub_timespan = sub_timespans[0]
409
+ # help out static type analysis (won't be None), no runtime effect
410
+ sub_timespan = cast("np.ndarray[tuple[int], np.dtype[np.datetime64]]",sub_timespan)
411
+ sub_skyfld_earthsat = skyfld_earth_sats[tle_epoch_idxs[0]]
412
+ tle_epoch = tle_epoch_dates[tle_epoch_idxs[0]]
413
+ sat_rec = sub_skyfld_earthsat.at(skyfld_ts.utc(sub_timespan))
414
+ pos = sat_rec.position.km.T
415
+ vel = sat_rec.velocity.km_per_s.T * 1000
416
+ ecef = sat_rec.frame_xyz_and_velocity(itrs)
417
+ pos_ecef = ecef[0].km.T
418
+ vel_ecef = ecef[1].km_per_s.T * 1000
419
+
420
+ skyfld_lat, skyfld_lon = wgs84.latlon_of(sat_rec)
421
+ lat = skyfld_lat.degrees
422
+ lon = skyfld_lon.degrees
423
+ ecc = np.tile(sub_skyfld_earthsat.model.ecco,len(sub_timespan))
424
+ inc = np.tile(sub_skyfld_earthsat.model.inclo,len(sub_timespan))
425
+ semi_major = np.tile(sub_skyfld_earthsat.model.a * consts.R_EARTH,len(sub_timespan))
426
+ raan = np.tile(sub_skyfld_earthsat.model.nodeo,len(sub_timespan))
427
+ argp = np.tile(sub_skyfld_earthsat.model.argpo,len(sub_timespan))
428
+ TLE_epochs = np.tile(tle_epoch,len(sub_timespan)) #noqa: N806
429
+
430
+
431
+ # fill in data for any other sub timespans
432
+ for ii in range(1,len(sub_timespans)):
433
+ sub_timespan = sub_timespans[ii]
434
+ # help out static type analysis (won't be None), no runtime effect
435
+ sub_timespan = cast("np.ndarray[tuple[int], np.dtype[np.datetime64]]",sub_timespan)
436
+ sub_skyfld_earthsat = skyfld_earth_sats[tle_epoch_idxs[ii]]
437
+ tle_epoch = tle_epoch_dates[tle_epoch_idxs[ii]]
438
+ sat_rec = sub_skyfld_earthsat.at(skyfld_ts.utc(sub_timespan))
439
+ pos = np.vstack((pos, sat_rec.position.km.T))
440
+ vel = np.vstack((vel, sat_rec.velocity.km_per_s.T * 1000))
441
+ ecef = sat_rec.frame_xyz_and_velocity(itrs)
442
+ pos_ecef = np.vstack((pos_ecef, ecef[0].km.T))
443
+ vel_ecef = np.vstack((vel_ecef, ecef[1].km_per_s.T * 1000))
444
+
445
+ skyfld_lat, skyfld_lon = wgs84.latlon_of(sat_rec)
446
+ lat = np.concatenate((lat, skyfld_lat.degrees))
447
+ lon = np.concatenate((lon, skyfld_lon.degrees))
448
+ ecc = np.concatenate((ecc, np.tile(sub_skyfld_earthsat.model.ecco, len(sub_timespan))))
449
+ inc = np.concatenate((inc,
450
+ np.tile(sub_skyfld_earthsat.model.inclo, len(sub_timespan))))
451
+ semi_major = np.concatenate((semi_major,
452
+ np.tile(sub_skyfld_earthsat.model.a * consts.R_EARTH, len(sub_timespan)))) #noqa:E501
453
+ raan = np.concatenate((raan,
454
+ np.tile(sub_skyfld_earthsat.model.nodeo, len(sub_timespan))))
455
+ argp = np.concatenate((argp,
456
+ np.tile(sub_skyfld_earthsat.model.argpo, len(sub_timespan))))
457
+ TLE_epochs = np.concatenate((TLE_epochs, np.tile(tle_epoch, len(sub_timespan)))) #noqa: N806
458
+
459
+ attr_dct['timespan'] = timespan
460
+ attr_dct['satcat_id'] = int(skyfld_earth_sats[0].target_name.split('#')[-1].split(' ')[0])
461
+ attr_dct['gen_type'] = 'propagated from TLE'
462
+ attr_dct['central_body'] = 'Earth'
463
+ attr_dct['pos'] = pos
464
+ attr_dct['alt'] = np.linalg.norm(pos, axis=1) - consts.R_EARTH
465
+ attr_dct['pos_ecef'] = pos_ecef
466
+ attr_dct['vel_ecef'] = vel_ecef
467
+ attr_dct['vel'] = vel
468
+ attr_dct['lat'] = lat
469
+ attr_dct['lon'] = lon
470
+ attr_dct['ecc'] = ecc
471
+ attr_dct['inc'] = inc
472
+ attr_dct['semi_major'] = semi_major
473
+ attr_dct['raan'] = raan
474
+ attr_dct['argp'] = argp
475
+ attr_dct['TLE_epochs'] = TLE_epochs
476
+
477
+ period = float(2 * np.pi / sub_skyfld_earthsat.model.no_kozai * 60)
478
+ attr_dct['period'] = period
479
+
480
+ if timespan.time_step is not None:
481
+ attr_dct['period_steps'] = int(period / timespan.time_step.total_seconds())
482
+ else:
483
+ attr_dct['period_steps'] = None
484
+ attr_dct['name'] = sub_skyfld_earthsat.name
485
+
486
+ return cls(attr_dct, calc_astrobodies=astrobodies)
487
+
488
+ #fake TLE
489
+ @classmethod
490
+ def fromPropagatedOrbitalParam(cls, timespan:TimeSpan, #noqa: PLR0913
491
+ a:float=6978,
492
+ ecc:float=0,
493
+ inc:float=0,
494
+ raan:float=0,
495
+ argp:float=0,
496
+ mean_nu:float=0,
497
+ name:str='Fake TLE',
498
+ astrobodies:bool=True,
499
+ unsafe:bool=False) -> 'Orbit':
500
+ """Create an orbit from orbital parameters, propagated using sgp4.
501
+
502
+ Orbits created using this class method will respect gravity corrections such as J4,
503
+ allowing for semi-analytical sun-synchronous orbits.
504
+
505
+ Args:
506
+ timespan: Timespan over which orbit is to be simulated
507
+ a: [Optional] semi-major axis of the orbit in km
508
+ Default is 6978 ~ 600km above the earth.
509
+ ecc: [Optional] eccentricty, dimensionless number 0 < ecc < 1
510
+ Default is 0, which is a circular orbit
511
+ inc: [Optional] inclination of orbit in degrees
512
+ Default is 0, which represents an orbit around the Earth's equator
513
+ raan: [Optional] right-ascension of the ascending node
514
+ Default is 0
515
+ argp: [Optional] argument of the perigee in degrees
516
+ Default is 0, which represents an orbit with its semimajor axis in
517
+ the plane of the Earth's equator
518
+ mean_nu: [Optional] mean anomaly in degrees
519
+ Default is 0, which represents an orbit that is beginning at periapsis
520
+ name: [Optional] string giving the name of the orbit
521
+ Default is 'Fake TLE'
522
+ astrobodies: [Optional] Flag to calculate Sun and Moon positions at timestamps
523
+ Default is False
524
+ unsafe: [Optional] Flag to ignore semi-major axis inside Earth's radius
525
+ Default is False
526
+
527
+ Returns:
528
+ satplot.Orbit
529
+ """
530
+ if a < consts.R_EARTH + consts.EARTH_MIN_ALT:
531
+ logger.error("Semimajor axis, %s, is too close to Earth", a)
532
+ if not unsafe:
533
+ raise exceptions.OutOfRangeError(f"Semimajor axis, {a}, is too close to Earth")
534
+
535
+ if ecc > 1 or ecc < 0:
536
+ logger.error("Eccentricity, %s, is non circular or eliptical", ecc)
537
+ raise exceptions.OutOfRangeError(f"Eccentricity, {ecc}, is non circular or eliptical")
538
+
539
+ if inc > 180 or inc < -180: #noqa: PLR2004
540
+ logger.error("Inclination, %s, is out of range, should be -180 < inc < 180", inc)
541
+ raise exceptions.OutOfRangeError(f"Inclination, {inc}, is out of range, "
542
+ f"should be -180 < inc < 180")
543
+
544
+ if raan > 360 or raan < 0: #noqa: PLR2004
545
+ logger.error("RAAN, %s, is out of range, should be 0 < inc < 360", raan)
546
+ raise exceptions.OutOfRangeError(f"RAAN, {raan}, is out of range, "
547
+ f"should be 0 < inc < 360")
548
+
549
+ if argp > 360 or argp < 0: #noqa: PLR2004
550
+ logger.error("Argument of periapsis, %s, is out of range, "
551
+ "should be 0 < argp < 360", inc)
552
+ raise exceptions.OutOfRangeError(f"Argument of periapsis, {inc}, is out of range, "
553
+ f"should be 0 < argp < 360")
554
+
555
+ if mean_nu > 360 or mean_nu < 0: #noqa: PLR2004
556
+ logger.error("Mean anomaly, %s, is out of range, should be 0 < mean_nu < 360", mean_nu)
557
+ raise exceptions.OutOfRangeError(f"Mean anomaly, {mean_nu}, is out of range, "
558
+ f"should be 0 < mean_nu < 360")
559
+
560
+ attr_dct = _createEmptyOrbitAttrDict()
561
+
562
+ skyfld_ts = load.timescale(builtin=True)
563
+
564
+ t0_epoch = epoch_u.datetime2sgp4epoch(timespan.start)
565
+ satrec = Satrec()
566
+ mean_motion = orbit_u.calcMeanMotion(a * 1e3)
567
+
568
+ satrec.sgp4init(WGS72, # gravity model
569
+ 'i', # mode
570
+ 1, # satnum
571
+ t0_epoch, # mode
572
+ 0, # bstar [/earth radii]
573
+ 0, # ndot [revs/day]
574
+ 0, # nddot [revs/day^3]
575
+ ecc, # ecc
576
+ argp, # arg perigee [radians]
577
+ inc, # inclination [radians]
578
+ mean_nu, # mean anomaly [radians]
579
+ mean_motion * 60, # mean motion [rad/min]
580
+ raan) # raan [radians]
581
+
582
+ skyfld_earthsat = EarthSatellite.from_satrec(satrec, skyfld_ts)
583
+
584
+ pos = np.empty((len(timespan), 3))
585
+ vel = np.empty((len(timespan), 3))
586
+
587
+ _timespan_arr = timespan.asDatetime()
588
+ _timespan_arr = cast("np.ndarray[tuple[int], np.dtype[np.datetime64]]", _timespan_arr)
589
+ for ii, timestep in enumerate(_timespan_arr):
590
+ pos[ii, :] = skyfld_earthsat.at(skyfld_ts.utc(timestep)).position.km
591
+ vel[ii, :] = skyfld_earthsat.at(skyfld_ts.utc(timestep)).velocity.km_per_s * 1000
592
+
593
+ # TODO: calculate ecef and lat,lon values.
594
+ # TODO: satrec doesn't have a frame_xyz_and_velocity in this case
595
+
596
+ period = float(2 * np.pi / skyfld_earthsat.model.no_kozai * 60)
597
+ if timespan.time_step is not None:
598
+ period_steps = int(period / timespan.time_step.total_seconds())
599
+ else:
600
+ period_steps = None
601
+
602
+ attr_dct['name'] = name
603
+ attr_dct['timespan'] = timespan
604
+ attr_dct['gen_type'] = 'propagated from orbital param'
605
+ attr_dct['central_body'] = 'Earth'
606
+ attr_dct['pos'] = pos
607
+ attr_dct['alt'] = np.linalg.norm(pos, axis=1) - consts.R_EARTH
608
+ attr_dct['vel'] = vel
609
+ attr_dct['ecc'] = np.full(len(timespan), ecc)
610
+ attr_dct['inc'] = np.full(len(timespan), inc)
611
+ attr_dct['semi_major'] = np.full(len(timespan), a)
612
+ attr_dct['raan'] = np.full(len(timespan), raan)
613
+ attr_dct['argp'] = np.full(len(timespan), argp)
614
+ attr_dct['TLE_epochs'] = np.full(len(timespan),timespan.start)
615
+ attr_dct['period'] = period
616
+ attr_dct['period_steps'] = period_steps
617
+
618
+ return cls(attr_dct, calc_astrobodies=astrobodies)
619
+
620
+ #analytical
621
+ @classmethod
622
+ def fromAnalyticalOrbitalParam(cls, timespan:TimeSpan, #noqa: C901, PLR0912, PLR0913
623
+ body:str='Earth',
624
+ a:float=6978,
625
+ ecc:float=0,
626
+ inc:float=0,
627
+ raan:float=0,
628
+ argp:float=0,
629
+ mean_nu:float=0,
630
+ name:str='Analytical',
631
+ astrobodies:bool=True,
632
+ unsafe:bool=False) -> 'Orbit':
633
+ """Create an analytical orbit defined by orbital parameters.
634
+
635
+ Orbits created using this class method will NOT respect gravity corrections such as J4,
636
+ and as such sun-synchronous orbit are not possible.
637
+
638
+ Args:
639
+ timespan: Timespan over which orbit is to be simulated
640
+ body: [Optional] string indicating around what body the satellite orbits,
641
+ Default is 'Earth'
642
+ Options are ['Earth','Sun','Mars','Moon']
643
+ a: [Optional] semi-major axis of the orbit in km
644
+ Default is 6978 ~ 600km above the earth.
645
+ ecc: [Optional] eccentricty, dimensionless number 0 < ecc < 1
646
+ Default is 0, which is a circular orbit
647
+ inc: [Optional] inclination of orbit in degrees
648
+ Default is 0, which represents an orbit around the Earth's equator
649
+ raan: [Optional] right-ascension of the ascending node
650
+ Default is 0
651
+ argp: [Optional] argument of the perigee in degrees
652
+ Default is 0, which represents an orbit with its semimajor axis in
653
+ the plane of the Earth's equator
654
+ mean_nu: [Optional] mean anomaly in degrees
655
+ Default is 0, which represents an orbit that is beginning at periapsis
656
+ name: [Optional] string giving the name of the orbit
657
+ Default is 'Analytical'
658
+ astrobodies: [Optional] Flag to calculate Sun and Moon positions at timestamps
659
+ Default is False
660
+ unsafe: [Optional] Flag to ignore semi-major axis inside Earth's radius
661
+ Default is False
662
+
663
+ Returns:
664
+ satplot.Orbit
665
+ """
666
+ if body.upper() == 'EARTH':
667
+ central_body = Earth
668
+ min_a = consts.R_EARTH + consts.EARTH_MIN_ALT
669
+ elif body.upper() == 'SUN':
670
+ central_body = Sun
671
+ min_a = consts.R_SUN + consts.SUN_MIN_ALT
672
+ elif body.upper() == 'MARS':
673
+ central_body = Mars
674
+ min_a = consts.R_MARS + consts.MARS_MIN_ALT
675
+ elif body.upper() == 'MOON':
676
+ central_body = Moon
677
+ min_a = consts.R_MOON + consts.MOON_MIN_ALT
678
+ else:
679
+ logger.error("Invalid central body %s. Valid options are Earth, Sun, Mars, Moon", body)
680
+ raise ValueError(f"Invalid central body {body}.")
681
+
682
+ if a < min_a:
683
+ logger.error("Semimajor axis, %s, is too close to the central body, {body.upper()}", a)
684
+ if not unsafe:
685
+ raise exceptions.OutOfRangeError(f"Semimajor axis, {a}, is too close "
686
+ f"to the central body, {body.upper()}")
687
+
688
+ if ecc > 1 or ecc < 0:
689
+ logger.error("Eccentricity, %s, is non circular or eliptical", ecc)
690
+ raise exceptions.OutOfRangeError(f"Eccentricity, {ecc}, is non circular or eliptical")
691
+
692
+ if inc > 180 or inc < -180: #noqa: PLR2004
693
+ logger.error("Inclination, %s, is out of range, should be -180 < inc < 180", inc)
694
+ raise exceptions.OutOfRangeError(f"Inclination, {inc}, is out of range, "
695
+ f"should be -180 < inc < 180")
696
+
697
+ if raan > 360 or raan < 0: #noqa: PLR2004
698
+ logger.error("RAAN, %s, is out of range, should be 0 < inc < 360", inc)
699
+ raise exceptions.OutOfRangeError(f"RAAN, {inc}, is out of range, "
700
+ f"should be 0 < inc < 360")
701
+
702
+ if argp > 360 or argp < 0: #noqa: PLR2004
703
+ logger.error("Argument of periapsis, %s, is out of range, "
704
+ "should be 0 < argp < 360", raan)
705
+ raise exceptions.OutOfRangeError(f"Argument of periapsis, {raan}, is out of "
706
+ f"range, should be 0 < argp < 360")
707
+
708
+ if mean_nu > 360 or mean_nu < 0: #noqa: PLR2004
709
+ logger.error("Mean anomaly, %s, is out of range, should be 0 < mean_nu < 360", mean_nu)
710
+ raise exceptions.OutOfRangeError(f"Mean anomaly, {mean_nu}, is out of range, "
711
+ f"should be 0 < mean_nu < 360")
712
+
713
+ attr_dct = _createEmptyOrbitAttrDict()
714
+
715
+ logger.info("Creating analytical orbit")
716
+ orb = hapsiraOrbit.from_classical(central_body,
717
+ a * astropy_units.one * astropy_units.km,
718
+ ecc * astropy_units.one,
719
+ inc * astropy_units.one * astropy_units.deg,
720
+ raan * astropy_units.one * astropy_units.deg,
721
+ argp * astropy_units.one * astropy_units.deg,
722
+ mean_nu * astropy_units.one * astropy_units.deg)
723
+
724
+
725
+ logger.info("Creating ephemeris for orbit, using timespan")
726
+ ephem = Ephem.from_orbit(orb, timespan.asAstropy())
727
+
728
+ pos = np.asarray(ephem.rv()[0], dtype=np.float64)
729
+ vel = np.asarray(ephem.rv()[1], dtype=np.float64) * 1000
730
+
731
+ period = float(orb.period.unit.in_units('s') * orb.period.value)
732
+ if timespan.time_step is not None:
733
+ period_steps = int(period / timespan.time_step.total_seconds())
734
+ else:
735
+ period_steps = None
736
+
737
+ attr_dct['name'] = name
738
+ attr_dct['timespan'] = timespan
739
+ attr_dct['gen_type'] = 'analytical orbit'
740
+ attr_dct['central_body'] = body
741
+ attr_dct['pos'] = pos
742
+ # TODO: altitude should be sourced from central body
743
+ attr_dct['alt'] = np.linalg.norm(pos, axis=1) - consts.R_EARTH
744
+ attr_dct['vel'] = vel
745
+ attr_dct['ecc'] = ecc * np.ones(len(timespan))
746
+ attr_dct['inc'] = inc * np.ones(len(timespan))
747
+ attr_dct['semi_major'] = a * np.ones(len(timespan))
748
+ attr_dct['raan'] = raan * np.ones(len(timespan))
749
+ attr_dct['argp'] = argp * np.ones(len(timespan))
750
+ attr_dct['period'] = period
751
+ attr_dct['period_steps'] = period_steps
752
+
753
+ return cls(attr_dct, calc_astrobodies=astrobodies)
754
+
755
+ @classmethod
756
+ def fromDummyConstantPosition(cls, timespan:TimeSpan,
757
+ pos:tuple[float,float,float]|np.ndarray[tuple[int],np.dtype[np.float64]],
758
+ sun_pos:tuple[float,float,float]|np.ndarray[tuple[int],np.dtype[np.float64]]|None=None,
759
+ moon_pos:tuple[float,float,float]|np.ndarray[tuple[int],np.dtype[np.float64]]|None=None) -> 'Orbit': #noqa: E501
760
+ """Creates an static orbit for testing.
761
+
762
+ Satellite position is defined by pos, while sun and moon positions are optional,
763
+ but can also be specified.
764
+
765
+ Args:
766
+ timespan: Timespan over which orbit is to be simulated
767
+ pos: Nx3 numpy array of cartesian coordinates of the position of the satellite
768
+ at each timestamp
769
+ units: km
770
+ frame: ECI
771
+ sun_pos: [Optional] Nx3 numpy array of cartesian coordinates of the position of the Sun
772
+ at each timestamp
773
+ units: km
774
+ frame: ECI
775
+ moon_pos: [Optional] Nx3 numpy array of cartesian coordinates of the position of the
776
+ Moon at each timestamp
777
+ units: km
778
+ frame: ECI
779
+
780
+ Returns:
781
+ satplot.Orbit
782
+ """
783
+ attr_dct = _createEmptyOrbitAttrDict()
784
+ attr_dct['timespan'] = timespan
785
+ attr_dct['name'] = 'dummy_const_pos'
786
+ attr_dct['pos'] = np.full((len(timespan), 3), pos)
787
+ attr_dct['vel'] = np.full((len(timespan), 3), 0)
788
+ attr_dct['gen_type'] = 'constant position for testing'
789
+ obj = cls(attr_dct, calc_astrobodies=False)
790
+ if sun_pos is not None:
791
+ obj.sun_pos = np.full((len(timespan), 3), sun_pos)
792
+ if moon_pos is not None:
793
+ obj.moon_pos = np.full((len(timespan), 3), moon_pos)
794
+ return obj
795
+
796
+ def getPosition(self, search_time:dt.datetime|astropyTime) \
797
+ -> np.ndarray[tuple[int],np.dtype[np.float64]]:
798
+ """Return the position at the specified or closest time.
799
+
800
+ Args:
801
+ search_time: the timestamp to search for
802
+
803
+ Returns:
804
+ position
805
+
806
+ Raises:
807
+ ValueError: orbit has no pos data
808
+ """
809
+ if self.pos is None:
810
+ raise ValueError("Orbit has no pos data")
811
+ return self._getAttributeClosestTime(self.pos, search_time)
812
+
813
+ def getVelocity(self, search_time:dt.datetime|astropyTime) \
814
+ -> np.ndarray[tuple[int], np.dtype[np.float64]]:
815
+ """Return the velocity at the specified or closest time.
816
+
817
+ Args:
818
+ search_time: the timestamp to search for
819
+
820
+ Returns:
821
+ velocity
822
+
823
+ Raises:
824
+ ValueError: orbit has no vel data
825
+ """
826
+ if self.vel is None:
827
+ raise ValueError("Orbit has no vel data")
828
+ return self._getAttributeClosestTime(self.vel, search_time)
829
+
830
+ def _getAttributeClosestTime(self, attr:np.ndarray[Any, np.dtype[np.float64]],
831
+ search_time:dt.datetime|astropyTime)\
832
+ -> np.ndarray[tuple[int], np.dtype[np.float64]]:
833
+
834
+ if self.timespan is None:
835
+ raise ValueError("Orbit has no timespan")
836
+
837
+ if isinstance(search_time, dt.datetime):
838
+ _, closest_idx = self.timespan.getClosest(search_time)
839
+ elif isinstance(search_time, astropyTime):
840
+ search_dt = search_time.to_datetime()
841
+ _, closest_idx = self.timespan.getClosest(search_dt)
842
+ else:
843
+ logger.error("%s cannot be used to index %s", search_time, attr)
844
+ raise TypeError(f"{search_time} cannot be used to index {attr}")
845
+
846
+ return attr[closest_idx, :]
847
+
848
+ def _calcEclipse(self, pos:np.ndarray[tuple[int,int],np.dtype[np.float64]],
849
+ sun:np.ndarray[tuple[int,int],np.dtype[np.float64]])\
850
+ -> np.ndarray[tuple[int],np.dtype[np.bool_]]:
851
+ earth_ang_size = np.arctan(consts.R_EARTH/consts.AU)
852
+ neg_sun_norm = np.linalg.norm(-sun,axis=1)
853
+ neg_sun_unit = -sun/neg_sun_norm[:,None]
854
+ pos_neg_sun = pos-sun
855
+ pos_neg_sun_norm = np.linalg.norm(pos_neg_sun,axis=1)
856
+ pos_neg_sun_unit = pos_neg_sun/pos_neg_sun_norm[:,None]
857
+ earth_sat_ang_sep = np.arccos(np.sum(neg_sun_unit*pos_neg_sun_unit, axis=1))
858
+ sunlit_angsep_truth = earth_sat_ang_sep > earth_ang_size
859
+ sunlit_dist_truth = pos_neg_sun_norm < neg_sun_norm
860
+ sunlit = np.logical_or(sunlit_angsep_truth, sunlit_dist_truth)
861
+
862
+ return np.logical_not(sunlit)
863
+
864
+
865
+
866
+ # def _findClosestEpochIndices(target:list[float], values:float) -> list[int]:
867
+ # right_idx = np.searchsorted(target, values)
868
+ # left_idx = right_idx - 1
869
+ # # replace any idx greater than len of target with last idx in target
870
+ # right_idx[np.where(right_idx==len(target))] = len(target) - 1
871
+ # stacked_idx = np.hstack((left_idx.reshape(-1,1),right_idx.reshape(-1,1)))
872
+ # target_vals_at_idxs = target[stacked_idx]
873
+ # closest_idx_columns = np.argmin(np.abs(target_vals_at_idxs - values.reshape(-1,1)),axis=1)
874
+ # return stacked_idx[range(len(stacked_idx)),closest_idx_columns]