-
Notifications
You must be signed in to change notification settings - Fork 1
/
Copy path__init__.py
374 lines (339 loc) · 15.7 KB
/
__init__.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
"""The heart of the *oyb* package. Contains orbital models and conversions.
"""
import datetime
import numpy
from math import pi, cos, sin, acos, asin
from oyb import anomaly, rot, earth
class Orbit(object):
"""Restricted two-body propagation model
"""
def __init__(self, a_m=None, e=None, i_rad=None, O_rad=None, w_rad=None, M_rad=None, tEpoch_dt=None):
"""Initializes a restricted two-body propagation model for a spherical
earth. Defaults to a circular GEO orbit with all angles set to 0.
(Epoch is set to the UTC time when the object is created.)
"""
self.tEpoch_dt = tEpoch_dt if tEpoch_dt is not None else datetime.datetime.utcnow()
self.a_m = a_m if a_m is not None else ((earth.tSidDay_s / (2 * pi))**2 * earth.mu_m3ps2)**(1/3)
self.e = e
self.i_rad = i_rad
self.O_rad = O_rad
self.w_rad = w_rad
self.M_rad = M_rad
def __str__(self):
"""Converts an Orbit object into a string representation (invoked by the
*str* and *print* functions) that references the shape (altitude at
perigee and apogee) and object location in memory.
"""
hPer_m, hApo_m = self.getShape()
return '<%g x %g [km] %s at 0x%08x>' % (hPer_m * 1e-3, hApo_m * 1e-3, self.__class__.__name__, id(self))
def getPeriod(self):
"""Returns the period of the current orbit, in seconds.
"""
T_s = 2 * pi * (self.a_m**3 / earth.mu_m3ps2)**0.5
return T_s
def getTrue(self, t_dt=None):
"""Returns the true anomaly of the object when propagated to a given
datetime.
"""
if t_dt is None:
t_dt = self.tEpoch_dt
dt_s = (t_dt - self.tEpoch_dt).total_seconds()
dM_rad = dt_s * 2 * pi / self.getPeriod()
M_rad = (self.M_rad + dM_rad) % (2 * pi)
return anomaly.mean2true(M_rad, self.e)
def getRpqw(self, t_dt=None):
"""Returns the 3-component position vector of the object at the given
datetime, as evaluted in the PQW (co-planar) frame.
"""
tht_rad = self.getTrue(t_dt)
d = 1 + self.e * cos(tht_rad)
p_m = self.a_m * (1 - self.e**2) * cos(tht_rad) / d
q_m = self.a_m * (1 - self.e**2) * sin(tht_rad) / d
return numpy.array([p_m, q_m, 0])
def getQpqw2eci(self):
"""Returns the frame transformation matrix from the PQW (co-planar)
frame to the earth-centered inertial (ECI) frame, w.r.t. J2000.
"""
w = rot.Z(self.w_rad)
i = rot.X(self.i_rad)
O = rot.Z(self.O_rad)
return (w.dot(i).dot(O)).transpose()
def getReci(self, t_dt=None):
"""Returns the position of the object at the given point in time, as
evaluated within the earth-centered inertial (ECI) frame, in meters.
"""
rPqw = self.getRpqw(t_dt)
Qpqw2eci = self.getQpqw2eci()
return Qpqw2eci.dot(rPqw)
def getRlla(self, t_dt=None):
"""Computes and returns the position at the given datetime, in latitude,
longitude, and altitude (radians, radians, and meters, respectively).
"""
if t_dt is None:
t_dt = self.tEpoch_dt
rEci_m = self.getReci(t_dt)
rEcf_m = earth.getQeci2ecf(t_dt).dot(rEci_m)
rLla_radm = rot.xyz2sph(rEcf_m)
return numpy.array([rLla_radm[1], rLla_radm[0], rLla_radm[2] - earth.eqRad_m])
def getAngMom(self):
"""Returns the scalar angular momentum of the orbit, in m/s^2.
"""
return (earth.mu_m3ps2 * self.a_m * (1 - self.e**2))**0.5
def getShape(self):
"""Returns a two-element tuple containing the altitude of the object at
perigee and apogee, in meters above spherical sea level.
"""
hPer_m = self.a_m * (1 - self.e) - earth.eqRad_m
hApo_m = self.a_m * (1 + self.e) - earth.eqRad_m
return (hPer_m, hApo_m)
def getShapeVel(self):
"""Returns a two-element tuple containing the scalar velocity of the
object at perigee and apogee, in meters per second.
"""
rPer_m = self.a_m * (1 - self.e)
rApo_m = self.a_m * (1 + self.e)
h_m2ps = self.getAngMom()
return h_m2ps / rPer_m, h_m2ps / rApo_m
def getTaaRad(self):
"""Returns the true-anomaly averaged radius (geometric mean of radius at
perigee and apogee), in meters.
"""
rPer_m = self.a_m * (1 - self.e)
rApo_m = self.a_m * (1 + self.e)
return (rPer_m * rApo_m)**0.5
def setShape(self, hPer_m, hApo_m):
"""Sets the a_m (semi-major axis) and e (eccentricity) values of an
orbit based on the given altitude (meters above spherical sea level)
at perigee and apogee.
"""
rPer_m = hPer_m + earth.eqRad_m
rApo_m = hApo_m + earth.eqRad_m
self.a_m = 0.5 * (rPer_m + rApo_m)
self.e = (rApo_m - rPer_m) / (rApo_m + rPer_m)
def propagate(self, tEpoch_dt=None, T_s=None, nSamples=1000):
"""Computes inertial position over the course of one orbit, beginning
with the given datetime (or, if not provided, the element epoch).
This defaults to 1,000 samples within that time range.
"""
if tEpoch_dt is None:
tEpoch_dt = self.tEpoch_dt
if T_s is None:
T_s = self.getPeriod()
ti_s = numpy.linspace(0, T_s, nSamples)
rEci_m = numpy.zeros((0,3))
for t_s in ti_s:
r = self.getReci(tEpoch_dt + datetime.timedelta(t_s/86400))
rEci_m = numpy.append(rEci_m, r.reshape(1,-1), axis=0)
return rEci_m
def track(self, tEpoch_dt=None, T_s=None, nSamples=1000):
"""Computes lat/lon/alt position over the course of one orbit, beginning
with the given datetime (or, if not provided, the element epoch).
This defaults to 1,000 samples within that time range.
"""
if tEpoch_dt is None:
tEpoch_dt = self.tEpoch_dt
if T_s is None:
T_s = self.getPeriod()
ti_s = numpy.linspace(0, T_s, nSamples)
rLla_radm = numpy.zeros((0,3))
for t_s in ti_s:
r = self.getRlla(tEpoch_dt + datetime.timedelta(t_s/86400))
rLla_radm = numpy.append(rLla_radm, r.reshape(1,-1), axis=0)
return rLla_radm
def getApogee(self):
"""Returns the 3-component position vector of the object at apogee, in
meters and evaluated within the earth-centered inertial (ECI) frame.
"""
ra_m = self.a_m * (1 + self.e)
return self.getQpqw2eci().dot(numpy.array([-ra_m,0,0]))
def getPerigee(self):
"""Returns the 3-component position vector of the object at perigee, in
meters and evaluated within the earth-centered inertial (ECI) frame.
"""
rp_m = self.a_m * (1 - self.e)
return self.getQpqw2eci().dot(numpy.array([rp_m,0,0]))
def getAscNode(self):
"""Returns the 3-component position vector of the object at the
ascending node, in meters and evaluated within the earth-centered
inertial (ECI) frame.
"""
tht_rad = 2 * pi - anomaly.mean2true(self.M_rad, self.e) - self.w_rad
d = 1 + self.e * cos(tht_rad)
p_m = self.a_m * (1 - self.e**2) * cos(tht_rad) / d
q_m = self.a_m * (1 - self.e**2) * sin(tht_rad) / d
rPqw_m = numpy.array([p_m, q_m, 0])
return self.getQpqw2eci().dot(rPqw_m)
def getDescNode(self):
"""Returns the 3-component position vector of the object at the
descending node, in meters and evaluated within the earth-centered
inertial (ECI) frame.
"""
tht_rad = pi - anomaly.mean2true(self.M_rad, self.e) - self.w_rad
d = 1 + self.e * cos(tht_rad)
p_m = self.a_m * (1 - self.e**2) * cos(tht_rad) / d
q_m = self.a_m * (1 - self.e**2) * sin(tht_rad) / d
rPqw_m = numpy.array([p_m, q_m, 0])
return self.getQpqw2eci().dot(rPqw_m)
@classmethod
def fromRV(cls, rEci_m, vEci_mps):
"""Constructs an Orbit object from the given position (meters) and
velocity (meters-per-second) state vectors, as evaluated in the
earth-centered inertial (ECI) frame.
"""
r_m = rEci_m.dot(rEci_m)**0.5
v_mps = vEci_mps.dot(vEci_mps)**0.5
vr_mps = rEci_m.dot(vEci_mps) / r_m
hEci_m2ps = numpy.cross(rEci_m, vEci_mps)
h_m2ps = hEci_m2ps.dot(hEci_m2ps)**0.5
i_rad = acos(hEci_m2ps[2] / h_m2ps)
Neci = numpy.cross(numpy.array([0,0,1]), hEci_m2ps)
N = Neci.dot(Neci)**0.5
O_rad = acos(Neci[0] / N)
if Neci[1] < 0:
O_rad = 2 * pi - O_rad
eEci = (numpy.cross(vEci_mps, hEci_m2ps) - earth.mu_m3ps2 * rEci_m / r_m) / earth.mu_m3ps2
e = eEci.dot(eEci)**0.5
w_rad = acos(Neci.dot(eEci) / N / e)
if eEci[2] < 0:
w_rad = 2 * pi - w_rad
tht_rad = acos(eEci.dot(rEci_m) / e / r_m)
if vr_mps < 0:
tht_rad = 2 * pi - tht_rad
M_rad = anomaly.true2mean(tht_rad, e)
a_m = h_m2ps**2 / (earth.mu_m3ps2 * (1 - e**2))
return cls(a_m=a_m, e=e, i_rad=i_rad, O_rad=O_rad, w_rad=w_rad, M_rad=M_rad)
@classmethod
def fromHTht(cls, h1_m, tht1_rad, h2_m, tht2_rad):
"""Constructs an Orbit object based on the given altitude (meters above
spherical sea level) and true anomaly values (in radians).
"""
r1_m = h1_m + earth.eqRad_m
r2_m = h2_m + earth.eqRad_m
e = (r1_m - r2_m) / (r2_m * cos(tht2_rad) - r1_m * cos(tht1_rad))
h_m2ps = (r1_m * earth.mu_m3ps2 * (1 + e * cos(tht1_rad)))**0.5
a_m = h_m2ps**2 / (earth.mu_m3ps2 * (1 - e**2))
return cls(a_m=a_m, e=e)
@classmethod
def fromTle(cls, line1, line2):
"""Constructs an Orbit object from the given two strings, as formatted
by the TLE (two-line element) specification (one string per line).
"""
ey = line1[18:20]
ed = line1[20:32]
inc = line2[8:16]
raan = line2[17:25]
ecc = line2[26:33]
aop = line2[34:42]
ma = line2[43:51]
mm = line2[52:63]
a_m = (earth.mu_m3ps2 * (86400 / (2 * pi * float(mm)))**2)**(1/3)
e = float('.' + ecc)
i_rad = float(inc) * pi / 180
O_rad = float(raan) * pi / 180
w_rad = float(aop) * pi / 180
M_rad = float(ma) * pi / 180
tEpoch_y = 2000 + int(ey) if int(ey) < 50 else 1900 + int(ey)
tEpoch_d = float(ed) - 1
tEpoch_dt = datetime.datetime(tEpoch_y, 1, 1, 0, 0, 0) + datetime.timedelta(tEpoch_d)
return cls(a_m=a_m, e=e, i_rad=i_rad, O_rad=O_rad, w_rad=w_rad, M_rad=M_rad, tEpoch_dt=tEpoch_dt)
class MeanJ2(Orbit):
"""Implements propagation to evolve elements with mean J2 perturbations
"""
def getRaanRate(self):
"""Computes and returns the rate at which the right-ascension of the
ascending node precesses, in radians per second, as a result of the
non-spherical Earth's J2 harmonic.
"""
return -1.5 * earth.mu_m3ps2**0.5 * earth.j2 * earth.eqRad_m**2 * cos(self.i_rad) / ((1 - self.e**2)**2 * self.a_m**3.5)
def getAopRate(self):
"""Computes and returns the rate at which the argument of perigee
processes, in radians per second, as a result of the non-spherical
Earth's J2 harmonic.
"""
return -1.5 * earth.mu_m3ps2**0.5 * earth.j2 * earth.eqRad_m**2 * (2.5 * sin(self.i_rad)**2 - 2) / ((1 - self.e**2)**2 * self.a_m**3.5)
def getRaan(self, t_dt=None):
"""Computes the RAAN value of the object (in radians) at the given point
in time, as propagated by the orbit-averaged mean precession computed
in the *getRaanRate* method.
"""
if t_dt is None:
return self.O_rad
dt_s = (t_dt - self.tEpoch_dt).total_seconds()
dRaan_radps = self.getRaanRate()
return (self.O_rad + dRaan_radps * dt_s) % (2 * pi)
def getAop(self, t_dt=None):
"""Computes the AoP value of the object (in radians) at the given point
in time, as propagated by the orbit-averaged mean precession computed
in the *getAopRate* method.
"""
if t_dt is None:
return self.w_rad
dt_s = (t_dt - self.tEpoch_dt).total_seconds()
dAop_radps = self.getAopRate()
return (self.w_rad + dAop_radps * dt_s) % (2 * pi)
def getQpqw2eci(self, t_dt=None):
"""Returns a time-varying tranformation matrix that converts vectors
from the PQW (co-orbital) frame to the earth-centered inertial (ECI)
frame (w.r.t. the J2000 epochal orientation). This includes the RAAN
precession and AoP procession rates induced by the J2 harmonic.
"""
w = rot.Z(self.getAop(t_dt))
i = rot.X(self.i_rad)
O = rot.Z(self.getRaan(t_dt))
return (w.dot(i).dot(O)).transpose()
def getReci(self, t_dt=None):
"""Computes and returns the position of the object, in meters, as
evaluted in the earth-centered inertial (ECI) frame. This includes
the RAAN precession and ApP procession rates induced by J2 harmonic.
"""
rPqw = self.getRpqw(t_dt)
Qpqw2eci = self.getQpqw2eci(t_dt)
return Qpqw2eci.dot(rPqw)
@classmethod
def fromSunSync(cls, T_s):
"""Returns a new MeanJ2 orbit object scaled to a specific inclination
and altitude to achieve the given period, but with fixed RAAN
(inclined such that the orbital plane orientation w.r.t. the sun
remains constant).
"""
a_m = (T_s * earth.mu_m3ps2**0.5 / (2 * pi))**(2/3)
dRaan_radps = 2 * pi / earth.tSidYear_s
d = -1.5 * earth.mu_m3ps2**0.5 * earth.j2 * earth.eqRad_m**2 / a_m**3.5
i_rad = acos(dRaan_radps / d)
return cls(a_m=a_m, i_rad=i_rad)
@classmethod
def fromConstAop(cls, T_s):
"""Determines an eccentric sun-synch orbit from the given period
"""
a_m = (T_s * earth.mu_m3ps2**0.5 / (2 * pi))**(2/3)
i_rad = pi - asin(0.8**0.5)
dRaan_radps = 2 * pi / earth.tSidYear_s
n = -3 * cos(i_rad) * earth.mu_m3ps2**0.5 * earth.j2 * earth.eqRad_m**2
d = 2 * dRaan_radps * a_m**3.5
e = (1 - (n / d)**0.5)**0.5
return cls(a_m=a_m, e=e, i_rad=i_rad)
@classmethod
def fromMolniya(cls, lon_rad):
"""Returns a Molniya-style orbit designed to peak at the given longitude
(in radians). Inclined to match RAAN recession to solar orientation.
"""
i_rad = 63.4 * pi/180
w_rad = 270 * pi/180
T_s = 0.5 * earth.tSidDay_s
a_m = ((T_s / (2 * pi))**2 * earth.mu_m3ps2)**(1/3)
o = cls(a_m=a_m, e=0.74105, i_rad=i_rad, w_rad=w_rad)
o.O_rad = (earth.getGmst(o.tEpoch_dt) + lon_rad) % (2 * pi)
return o
@classmethod
def fromTundra(cls, lon_rad):
"""According to some definitions, tundra orbits do not necessarily match
inclination to eliminate RAAN recession. QZSS, for example, targets
apogee dwell at a specific latitude. While technically not requiring
a J2 model, it is implemented here to support cases where the
inclination (at 63.4 degrees) is choosen to ensure a fixed track.
"""
i_rad = 63.4 * pi/180
o = cls(e=0.25, i_rad=i_rad, w_rad=1.5*pi)
gmst0_rad = earth.getGmst(o.tEpoch_dt)
o.O_rad = (gmst0_rad + lon_rad - acos(cos(o.i_rad) * cos(o.w_rad)) + pi) % (2 * pi)
return o